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