Skip to main content

fedimint_mintv2_server/
lib.rs

1#![deny(clippy::pedantic)]
2#![allow(clippy::cast_possible_wrap)]
3#![allow(clippy::module_name_repetitions)]
4#![allow(clippy::must_use_candidate)]
5#![allow(clippy::similar_names)]
6
7mod db;
8
9use std::collections::BTreeMap;
10
11use anyhow::{bail, ensure};
12use bitcoin::hashes::sha256;
13use fedimint_core::config::{
14    ServerModuleConfig, ServerModuleConsensusConfig, TypedServerModuleConfig,
15    TypedServerModuleConsensusConfig,
16};
17use fedimint_core::core::ModuleInstanceId;
18use fedimint_core::db::{
19    Database, DatabaseTransaction, DatabaseVersion, IDatabaseTransactionOpsCoreTyped,
20};
21use fedimint_core::encoding::Encodable;
22use fedimint_core::envs::{FM_ENABLE_MODULE_MINTV2_ENV, is_env_var_set_opt};
23use fedimint_core::module::audit::Audit;
24use fedimint_core::module::{
25    AmountUnit, Amounts, ApiEndpoint, ApiError, ApiVersion, CORE_CONSENSUS_VERSION,
26    CoreConsensusVersion, InputMeta, ModuleConsensusVersion, ModuleInit,
27    SupportedModuleApiVersions, TransactionItemAmounts, api_endpoint,
28};
29use fedimint_core::{
30    Amount, BitcoinHash, InPoint, NumPeers, NumPeersExt, OutPoint, PeerId, apply,
31    async_trait_maybe_send, push_db_key_items, push_db_pair_items,
32};
33use fedimint_mintv2_common::config::{
34    FeeConsensus, MintClientConfig, MintConfig, MintConfigConsensus, MintConfigPrivate,
35    consensus_denominations,
36};
37use fedimint_mintv2_common::endpoint_constants::{
38    RECOVERY_COUNT_ENDPOINT, RECOVERY_SLICE_ENDPOINT, RECOVERY_SLICE_HASH_ENDPOINT,
39    SIGNATURE_SHARES_ENDPOINT, SIGNATURE_SHARES_RECOVERY_ENDPOINT,
40};
41use fedimint_mintv2_common::{
42    Denomination, MODULE_CONSENSUS_VERSION, MintCommonInit, MintConsensusItem, MintInput,
43    MintInputError, MintModuleTypes, MintOutput, MintOutputError, MintOutputOutcome, RecoveryItem,
44    verify_note,
45};
46use fedimint_server_core::config::{PeerHandleOps, eval_poly_g2};
47use fedimint_server_core::migration::ServerModuleDbMigrationFn;
48use fedimint_server_core::{
49    ConfigGenModuleArgs, ServerModule, ServerModuleInit, ServerModuleInitArgs,
50};
51use futures::StreamExt;
52use rand::SeedableRng;
53use rand_chacha::ChaChaRng;
54use strum::IntoEnumIterator;
55use tbs::{
56    AggregatePublicKey, BlindedSignatureShare, PublicKeyShare, SecretKeyShare, derive_pk_share,
57};
58use threshold_crypto::ff::Field;
59use threshold_crypto::group::Curve;
60use threshold_crypto::{G2Projective, Scalar};
61
62use crate::db::{
63    BlindedSignatureShareKey, BlindedSignatureSharePrefix, BlindedSignatureShareRecoveryKey,
64    BlindedSignatureShareRecoveryPrefix, DbKeyPrefix, IssuanceCounterKey, IssuanceCounterPrefix,
65    NonceKey, NonceKeyPrefix, RecoveryItemKey, RecoveryItemPrefix,
66};
67
68#[derive(Debug, Clone)]
69pub struct MintInit;
70
71impl ModuleInit for MintInit {
72    type Common = MintCommonInit;
73
74    async fn dump_database(
75        &self,
76        dbtx: &mut DatabaseTransaction<'_>,
77        prefix_names: Vec<String>,
78    ) -> Box<dyn Iterator<Item = (String, Box<dyn erased_serde::Serialize + Send>)> + '_> {
79        let mut mint: BTreeMap<String, Box<dyn erased_serde::Serialize + Send>> = BTreeMap::new();
80        let filtered_prefixes = DbKeyPrefix::iter().filter(|f| {
81            prefix_names.is_empty() || prefix_names.contains(&f.to_string().to_lowercase())
82        });
83        for table in filtered_prefixes {
84            match table {
85                DbKeyPrefix::NoteNonce => {
86                    push_db_key_items!(dbtx, NonceKeyPrefix, NonceKey, mint, "Used Coins");
87                }
88                DbKeyPrefix::BlindedSignatureShare => {
89                    push_db_pair_items!(
90                        dbtx,
91                        BlindedSignatureSharePrefix,
92                        BlindedSignatureShareKey,
93                        BlindedSignatureShare,
94                        mint,
95                        "Blinded Signature Shares"
96                    );
97                }
98                DbKeyPrefix::BlindedSignatureShareRecovery => {
99                    push_db_pair_items!(
100                        dbtx,
101                        BlindedSignatureShareRecoveryPrefix,
102                        BlindedSignatureShareRecoveryKey,
103                        BlindedSignatureShare,
104                        mint,
105                        "Blinded Signature Shares (Recovery)"
106                    );
107                }
108                DbKeyPrefix::MintAuditItem => {
109                    push_db_pair_items!(
110                        dbtx,
111                        IssuanceCounterPrefix,
112                        IssuanceCounterKey,
113                        u64,
114                        mint,
115                        "Issuance Counter"
116                    );
117                }
118                DbKeyPrefix::RecoveryItem => {
119                    push_db_pair_items!(
120                        dbtx,
121                        RecoveryItemPrefix,
122                        RecoveryItemKey,
123                        RecoveryItem,
124                        mint,
125                        "Recovery Items"
126                    );
127                }
128            }
129        }
130
131        Box::new(mint.into_iter())
132    }
133}
134
135#[apply(async_trait_maybe_send!)]
136impl ServerModuleInit for MintInit {
137    type Module = Mint;
138
139    fn versions(&self, _core: CoreConsensusVersion) -> &[ModuleConsensusVersion] {
140        &[MODULE_CONSENSUS_VERSION]
141    }
142
143    fn supported_api_versions(&self) -> SupportedModuleApiVersions {
144        SupportedModuleApiVersions::from_raw(
145            (CORE_CONSENSUS_VERSION.major, CORE_CONSENSUS_VERSION.minor),
146            (
147                MODULE_CONSENSUS_VERSION.major,
148                MODULE_CONSENSUS_VERSION.minor,
149            ),
150            &[(0, 1)],
151        )
152    }
153
154    fn is_enabled_by_default(&self) -> bool {
155        is_env_var_set_opt(FM_ENABLE_MODULE_MINTV2_ENV).unwrap_or(false)
156    }
157
158    async fn init(&self, args: &ServerModuleInitArgs<Self>) -> anyhow::Result<Self::Module> {
159        args.cfg().to_typed().map(|cfg| Mint {
160            cfg,
161            db: args.db().clone(),
162        })
163    }
164
165    fn trusted_dealer_gen(
166        &self,
167        peers: &[PeerId],
168        args: &ConfigGenModuleArgs,
169    ) -> BTreeMap<PeerId, ServerModuleConfig> {
170        let fee_consensus = if args.disable_base_fees {
171            FeeConsensus::zero()
172        } else {
173            FeeConsensus::new(0).expect("Relative fee is within range")
174        };
175
176        let tbs_agg_pks = consensus_denominations()
177            .map(|denomination| (denomination, dealer_agg_pk(denomination.amount())))
178            .collect::<BTreeMap<Denomination, AggregatePublicKey>>();
179
180        let tbs_pks = consensus_denominations()
181            .map(|denomination| {
182                let pks = peers
183                    .iter()
184                    .map(|peer| {
185                        (
186                            *peer,
187                            dealer_pk(denomination.amount(), peers.to_num_peers(), *peer),
188                        )
189                    })
190                    .collect();
191
192                (denomination, pks)
193            })
194            .collect::<BTreeMap<Denomination, BTreeMap<PeerId, PublicKeyShare>>>();
195
196        peers
197            .iter()
198            .map(|peer| {
199                let cfg = MintConfig {
200                    consensus: MintConfigConsensus {
201                        tbs_agg_pks: tbs_agg_pks.clone(),
202                        tbs_pks: tbs_pks.clone(),
203                        fee_consensus: fee_consensus.clone(),
204                        amount_unit: AmountUnit::BITCOIN,
205                    },
206                    private: MintConfigPrivate {
207                        tbs_sks: consensus_denominations()
208                            .map(|denomination| {
209                                (
210                                    denomination,
211                                    dealer_sk(denomination.amount(), peers.to_num_peers(), *peer),
212                                )
213                            })
214                            .collect(),
215                    },
216                };
217
218                (*peer, cfg.to_erased())
219            })
220            .collect()
221    }
222
223    async fn distributed_gen(
224        &self,
225        peers: &(dyn PeerHandleOps + Send + Sync),
226        args: &ConfigGenModuleArgs,
227    ) -> anyhow::Result<ServerModuleConfig> {
228        let fee_consensus = if args.disable_base_fees {
229            FeeConsensus::zero()
230        } else {
231            FeeConsensus::new(0).expect("Relative fee is within range")
232        };
233
234        let mut tbs_sks = BTreeMap::new();
235        let mut tbs_agg_pks = BTreeMap::new();
236        let mut tbs_pks = BTreeMap::new();
237
238        for denomination in consensus_denominations() {
239            let (poly, sk) = peers.run_dkg_g2().await?;
240
241            tbs_sks.insert(denomination, tbs::SecretKeyShare(sk));
242
243            tbs_agg_pks.insert(denomination, AggregatePublicKey(poly[0].to_affine()));
244
245            let pks = peers
246                .num_peers()
247                .peer_ids()
248                .map(|peer| (peer, PublicKeyShare(eval_poly_g2(&poly, &peer))))
249                .collect();
250
251            tbs_pks.insert(denomination, pks);
252        }
253
254        let cfg = MintConfig {
255            private: MintConfigPrivate { tbs_sks },
256            consensus: MintConfigConsensus {
257                tbs_agg_pks,
258                tbs_pks,
259                fee_consensus,
260                amount_unit: AmountUnit::BITCOIN,
261            },
262        };
263
264        Ok(cfg.to_erased())
265    }
266
267    fn validate_config(&self, identity: &PeerId, config: ServerModuleConfig) -> anyhow::Result<()> {
268        let config = config.to_typed::<MintConfig>()?;
269
270        for denomination in consensus_denominations() {
271            let pk = derive_pk_share(&config.private.tbs_sks[&denomination]);
272
273            ensure!(
274                pk == config.consensus.tbs_pks[&denomination][identity],
275                "Mint private key doesn't match pubkey share"
276            );
277        }
278
279        Ok(())
280    }
281
282    fn get_client_config(
283        &self,
284        config: &ServerModuleConsensusConfig,
285    ) -> anyhow::Result<MintClientConfig> {
286        let config = MintConfigConsensus::from_erased(config)?;
287
288        Ok(MintClientConfig {
289            tbs_agg_pks: config.tbs_agg_pks,
290            tbs_pks: config.tbs_pks.clone(),
291            fee_consensus: config.fee_consensus.clone(),
292            amount_unit: config.amount_unit,
293        })
294    }
295
296    fn get_database_migrations(
297        &self,
298    ) -> BTreeMap<DatabaseVersion, ServerModuleDbMigrationFn<Mint>> {
299        BTreeMap::new()
300    }
301}
302
303fn dealer_agg_pk(amount: Amount) -> AggregatePublicKey {
304    AggregatePublicKey((G2Projective::generator() * coefficient(amount, 0)).to_affine())
305}
306
307fn dealer_pk(amount: Amount, num_peers: NumPeers, peer: PeerId) -> PublicKeyShare {
308    derive_pk_share(&dealer_sk(amount, num_peers, peer))
309}
310
311fn dealer_sk(amount: Amount, num_peers: NumPeers, peer: PeerId) -> SecretKeyShare {
312    let x = Scalar::from(peer.to_usize() as u64 + 1);
313
314    // We evaluate the scalar polynomial of degree threshold - 1 at the point x
315    // using the Horner schema.
316
317    let y = (0..num_peers.threshold())
318        .map(|index| coefficient(amount, index as u64))
319        .rev()
320        .reduce(|accumulator, c| accumulator * x + c)
321        .expect("We have at least one coefficient");
322
323    SecretKeyShare(y)
324}
325
326fn coefficient(amount: Amount, index: u64) -> Scalar {
327    Scalar::random(&mut ChaChaRng::from_seed(
328        *(amount, index)
329            .consensus_hash::<sha256::Hash>()
330            .as_byte_array(),
331    ))
332}
333
334#[derive(Debug)]
335pub struct Mint {
336    cfg: MintConfig,
337    db: Database,
338}
339
340impl Mint {
341    pub async fn note_distribution_ui(&self) -> BTreeMap<Denomination, u64> {
342        self.db
343            .begin_transaction_nc()
344            .await
345            .find_by_prefix(&IssuanceCounterPrefix)
346            .await
347            .filter(|entry| std::future::ready(entry.1 > 0))
348            .map(|(key, count)| (key.0, count))
349            .collect()
350            .await
351    }
352}
353
354#[apply(async_trait_maybe_send!)]
355impl ServerModule for Mint {
356    type Common = MintModuleTypes;
357    type Init = MintInit;
358
359    async fn consensus_proposal(
360        &self,
361        _dbtx: &mut DatabaseTransaction<'_>,
362    ) -> Vec<MintConsensusItem> {
363        Vec::new()
364    }
365
366    async fn process_consensus_item<'a, 'b>(
367        &'a self,
368        _dbtx: &mut DatabaseTransaction<'b>,
369        _consensus_item: MintConsensusItem,
370        _peer_id: PeerId,
371    ) -> anyhow::Result<()> {
372        bail!("Mint does not process consensus items");
373    }
374
375    async fn process_input<'a, 'b, 'c>(
376        &'a self,
377        dbtx: &mut DatabaseTransaction<'c>,
378        input: &'b MintInput,
379        _in_point: InPoint,
380    ) -> Result<InputMeta, MintInputError> {
381        let input = input.ensure_v0_ref()?;
382
383        let pk = self
384            .cfg
385            .consensus
386            .tbs_agg_pks
387            .get(&input.note.denomination)
388            .ok_or(MintInputError::InvalidDenomination)?;
389
390        if !verify_note(input.note, *pk) {
391            return Err(MintInputError::InvalidSignature);
392        }
393
394        if dbtx
395            .insert_entry(&NonceKey(input.note.nonce), &())
396            .await
397            .is_some()
398        {
399            return Err(MintInputError::SpentCoin);
400        }
401
402        let new_count = dbtx
403            .remove_entry(&IssuanceCounterKey(input.note.denomination))
404            .await
405            .unwrap_or(0)
406            .checked_sub(1)
407            .expect("Failed to decrement issuance counter");
408
409        dbtx.insert_new_entry(&IssuanceCounterKey(input.note.denomination), &new_count)
410            .await;
411
412        let next_index = get_recovery_count(dbtx).await;
413
414        dbtx.insert_new_entry(
415            &RecoveryItemKey(next_index),
416            &RecoveryItem::Input {
417                nonce_hash: input.note.nonce.consensus_hash(),
418            },
419        )
420        .await;
421
422        let amount = input.note.amount();
423        let unit = self.cfg.consensus.amount_unit;
424
425        Ok(InputMeta {
426            amount: TransactionItemAmounts {
427                amounts: Amounts::new_custom(unit, amount),
428                fees: Amounts::new_custom(unit, self.cfg.consensus.fee_consensus.fee(amount)),
429            },
430            pub_key: input.note.nonce,
431        })
432    }
433
434    async fn process_output<'a, 'b>(
435        &'a self,
436        dbtx: &mut DatabaseTransaction<'b>,
437        output: &'a MintOutput,
438        outpoint: OutPoint,
439    ) -> Result<TransactionItemAmounts, MintOutputError> {
440        let output = output.ensure_v0_ref()?;
441
442        let signature = self
443            .cfg
444            .private
445            .tbs_sks
446            .get(&output.denomination)
447            .map(|key| tbs::sign_message(output.nonce, *key))
448            .ok_or(MintOutputError::InvalidDenomination)?;
449
450        // Store by outpoint for efficient range-based retrieval
451        dbtx.insert_entry(&BlindedSignatureShareKey(outpoint), &signature)
452            .await;
453
454        // Store by blinded message for recovery
455        dbtx.insert_entry(&BlindedSignatureShareRecoveryKey(output.nonce), &signature)
456            .await;
457
458        let new_count = dbtx
459            .remove_entry(&IssuanceCounterKey(output.denomination))
460            .await
461            .unwrap_or(0)
462            .checked_add(1)
463            .expect("Failed to increment issuance counter");
464
465        dbtx.insert_new_entry(&IssuanceCounterKey(output.denomination), &new_count)
466            .await;
467
468        let next_index = get_recovery_count(dbtx).await;
469
470        dbtx.insert_new_entry(
471            &RecoveryItemKey(next_index),
472            &RecoveryItem::Output {
473                denomination: output.denomination,
474                nonce_hash: output.nonce.consensus_hash(),
475                tweak: output.tweak,
476            },
477        )
478        .await;
479
480        let amount = output.amount();
481        let unit = self.cfg.consensus.amount_unit;
482
483        Ok(TransactionItemAmounts {
484            amounts: Amounts::new_custom(unit, amount),
485            fees: Amounts::new_custom(unit, self.cfg.consensus.fee_consensus.fee(amount)),
486        })
487    }
488
489    async fn output_status(
490        &self,
491        _dbtx: &mut DatabaseTransaction<'_>,
492        _outpoint: OutPoint,
493    ) -> Option<MintOutputOutcome> {
494        None
495    }
496
497    async fn audit(
498        &self,
499        dbtx: &mut DatabaseTransaction<'_>,
500        audit: &mut Audit,
501        module_instance_id: ModuleInstanceId,
502    ) {
503        audit
504            .add_items(dbtx, module_instance_id, &IssuanceCounterPrefix, |k, v| {
505                -((k.0.amount().msats * v) as i64)
506            })
507            .await;
508    }
509
510    fn api_endpoints(&self) -> Vec<ApiEndpoint<Self>> {
511        vec![
512            api_endpoint! {
513                SIGNATURE_SHARES_ENDPOINT,
514                ApiVersion::new(0, 1),
515                async |_module: &Mint, context, range: fedimint_core::OutPointRange| -> Vec<BlindedSignatureShare> {
516                    let db = context.db();
517                    let mut dbtx = db.begin_transaction_nc().await;
518                    Ok(get_signature_shares(&mut dbtx, range).await)
519                }
520            },
521            api_endpoint! {
522                SIGNATURE_SHARES_RECOVERY_ENDPOINT,
523                ApiVersion::new(0, 1),
524                async |_module: &Mint, context, messages: Vec<tbs::BlindedMessage>| -> Vec<BlindedSignatureShare> {
525                    let db = context.db();
526                    let mut dbtx = db.begin_transaction_nc().await;
527                    get_signature_shares_recovery(&mut dbtx, messages).await
528                }
529            },
530            api_endpoint! {
531                RECOVERY_SLICE_ENDPOINT,
532                ApiVersion::new(0, 1),
533                async |_module: &Mint, context, range: (u64, u64)| -> Vec<RecoveryItem> {
534                    let db = context.db();
535                    let mut dbtx = db.begin_transaction_nc().await;
536                    Ok(get_recovery_slice(&mut dbtx, range).await)
537                }
538            },
539            api_endpoint! {
540                RECOVERY_SLICE_HASH_ENDPOINT,
541                ApiVersion::new(0, 1),
542                async |_module: &Mint, context, range: (u64, u64)| -> bitcoin::hashes::sha256::Hash {
543                    let db = context.db();
544                    let mut dbtx = db.begin_transaction_nc().await;
545                    Ok(get_recovery_slice(&mut dbtx, range).await.consensus_hash())
546                }
547            },
548            api_endpoint! {
549                RECOVERY_COUNT_ENDPOINT,
550                ApiVersion::new(0, 1),
551                async |_module: &Mint, context, _params: ()| -> u64 {
552                    let db = context.db();
553                    let mut dbtx = db.begin_transaction_nc().await;
554                    Ok(get_recovery_count(&mut dbtx).await)
555                }
556            },
557        ]
558    }
559}
560
561async fn get_signature_shares(
562    dbtx: &mut DatabaseTransaction<'_>,
563    range: fedimint_core::OutPointRange,
564) -> Vec<BlindedSignatureShare> {
565    let start_key = BlindedSignatureShareKey(range.start_out_point());
566    let end_key = BlindedSignatureShareKey(range.end_out_point());
567
568    dbtx.find_by_range(start_key..end_key)
569        .await
570        .map(|entry| entry.1)
571        .collect()
572        .await
573}
574
575async fn get_signature_shares_recovery(
576    dbtx: &mut DatabaseTransaction<'_>,
577    messages: Vec<tbs::BlindedMessage>,
578) -> Result<Vec<BlindedSignatureShare>, ApiError> {
579    let mut shares = Vec::new();
580
581    for message in messages {
582        let share = dbtx
583            .get_value(&BlindedSignatureShareRecoveryKey(message))
584            .await
585            .ok_or(ApiError::bad_request(
586                "No blinded signature share found".to_string(),
587            ))?;
588
589        shares.push(share);
590    }
591
592    Ok(shares)
593}
594
595async fn get_recovery_count(dbtx: &mut DatabaseTransaction<'_>) -> u64 {
596    dbtx.find_by_prefix_sorted_descending(&RecoveryItemPrefix)
597        .await
598        .next()
599        .await
600        .map_or(0, |entry| entry.0.0 + 1)
601}
602
603async fn get_recovery_slice(
604    dbtx: &mut DatabaseTransaction<'_>,
605    range: (u64, u64),
606) -> Vec<RecoveryItem> {
607    dbtx.find_by_range(RecoveryItemKey(range.0)..RecoveryItemKey(range.1))
608        .await
609        .map(|entry| entry.1)
610        .collect()
611        .await
612}