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