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    Amounts, ApiEndpoint, ApiError, ApiVersion, CORE_CONSENSUS_VERSION, CoreConsensusVersion,
26    InputMeta, ModuleConsensusVersion, ModuleInit, SupportedModuleApiVersions,
27    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};
45use fedimint_server_core::config::{PeerHandleOps, eval_poly_g2};
46use fedimint_server_core::migration::ServerModuleDbMigrationFn;
47use fedimint_server_core::{
48    ConfigGenModuleArgs, 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 supported_api_versions(&self) -> SupportedModuleApiVersions {
143        SupportedModuleApiVersions::from_raw(
144            (CORE_CONSENSUS_VERSION.major, CORE_CONSENSUS_VERSION.minor),
145            (
146                MODULE_CONSENSUS_VERSION.major,
147                MODULE_CONSENSUS_VERSION.minor,
148            ),
149            &[(0, 1)],
150        )
151    }
152
153    fn is_enabled_by_default(&self) -> bool {
154        is_env_var_set_opt(FM_ENABLE_MODULE_MINTV2_ENV).unwrap_or(false)
155    }
156
157    async fn init(&self, args: &ServerModuleInitArgs<Self>) -> anyhow::Result<Self::Module> {
158        args.cfg().to_typed().map(|cfg| Mint {
159            cfg,
160            db: args.db().clone(),
161        })
162    }
163
164    fn trusted_dealer_gen(
165        &self,
166        peers: &[PeerId],
167        args: &ConfigGenModuleArgs,
168    ) -> BTreeMap<PeerId, ServerModuleConfig> {
169        let fee_consensus = if args.disable_base_fees {
170            FeeConsensus::zero()
171        } else {
172            FeeConsensus::new(0).expect("Relative fee is within range")
173        };
174
175        let tbs_agg_pks = consensus_denominations()
176            .map(|denomination| (denomination, dealer_agg_pk(denomination.amount())))
177            .collect::<BTreeMap<Denomination, AggregatePublicKey>>();
178
179        let tbs_pks = consensus_denominations()
180            .map(|denomination| {
181                let pks = peers
182                    .iter()
183                    .map(|peer| {
184                        (
185                            *peer,
186                            dealer_pk(denomination.amount(), peers.to_num_peers(), *peer),
187                        )
188                    })
189                    .collect();
190
191                (denomination, pks)
192            })
193            .collect::<BTreeMap<Denomination, BTreeMap<PeerId, PublicKeyShare>>>();
194
195        peers
196            .iter()
197            .map(|peer| {
198                let cfg = MintConfig {
199                    consensus: MintConfigConsensus {
200                        tbs_agg_pks: tbs_agg_pks.clone(),
201                        tbs_pks: tbs_pks.clone(),
202                        fee_consensus: fee_consensus.clone(),
203                    },
204                    private: MintConfigPrivate {
205                        tbs_sks: consensus_denominations()
206                            .map(|denomination| {
207                                (
208                                    denomination,
209                                    dealer_sk(denomination.amount(), peers.to_num_peers(), *peer),
210                                )
211                            })
212                            .collect(),
213                    },
214                };
215
216                (*peer, cfg.to_erased())
217            })
218            .collect()
219    }
220
221    async fn distributed_gen(
222        &self,
223        peers: &(dyn PeerHandleOps + Send + Sync),
224        args: &ConfigGenModuleArgs,
225    ) -> anyhow::Result<ServerModuleConfig> {
226        let fee_consensus = if args.disable_base_fees {
227            FeeConsensus::zero()
228        } else {
229            FeeConsensus::new(0).expect("Relative fee is within range")
230        };
231
232        let mut tbs_sks = BTreeMap::new();
233        let mut tbs_agg_pks = BTreeMap::new();
234        let mut tbs_pks = BTreeMap::new();
235
236        for denomination in consensus_denominations() {
237            let (poly, sk) = peers.run_dkg_g2().await?;
238
239            tbs_sks.insert(denomination, tbs::SecretKeyShare(sk));
240
241            tbs_agg_pks.insert(denomination, AggregatePublicKey(poly[0].to_affine()));
242
243            let pks = peers
244                .num_peers()
245                .peer_ids()
246                .map(|peer| (peer, PublicKeyShare(eval_poly_g2(&poly, &peer))))
247                .collect();
248
249            tbs_pks.insert(denomination, pks);
250        }
251
252        let cfg = MintConfig {
253            private: MintConfigPrivate { tbs_sks },
254            consensus: MintConfigConsensus {
255                tbs_agg_pks,
256                tbs_pks,
257                fee_consensus,
258            },
259        };
260
261        Ok(cfg.to_erased())
262    }
263
264    fn validate_config(&self, identity: &PeerId, config: ServerModuleConfig) -> anyhow::Result<()> {
265        let config = config.to_typed::<MintConfig>()?;
266
267        for denomination in consensus_denominations() {
268            let pk = derive_pk_share(&config.private.tbs_sks[&denomination]);
269
270            ensure!(
271                pk == config.consensus.tbs_pks[&denomination][identity],
272                "Mint private key doesn't match pubkey share"
273            );
274        }
275
276        Ok(())
277    }
278
279    fn get_client_config(
280        &self,
281        config: &ServerModuleConsensusConfig,
282    ) -> anyhow::Result<MintClientConfig> {
283        let config = MintConfigConsensus::from_erased(config)?;
284
285        Ok(MintClientConfig {
286            tbs_agg_pks: config.tbs_agg_pks,
287            tbs_pks: config.tbs_pks.clone(),
288            fee_consensus: config.fee_consensus.clone(),
289        })
290    }
291
292    fn get_database_migrations(
293        &self,
294    ) -> BTreeMap<DatabaseVersion, ServerModuleDbMigrationFn<Mint>> {
295        BTreeMap::new()
296    }
297}
298
299fn dealer_agg_pk(amount: Amount) -> AggregatePublicKey {
300    AggregatePublicKey((G2Projective::generator() * coefficient(amount, 0)).to_affine())
301}
302
303fn dealer_pk(amount: Amount, num_peers: NumPeers, peer: PeerId) -> PublicKeyShare {
304    derive_pk_share(&dealer_sk(amount, num_peers, peer))
305}
306
307fn dealer_sk(amount: Amount, num_peers: NumPeers, peer: PeerId) -> SecretKeyShare {
308    let x = Scalar::from(peer.to_usize() as u64 + 1);
309
310    // We evaluate the scalar polynomial of degree threshold - 1 at the point x
311    // using the Horner schema.
312
313    let y = (0..num_peers.threshold())
314        .map(|index| coefficient(amount, index as u64))
315        .rev()
316        .reduce(|accumulator, c| accumulator * x + c)
317        .expect("We have at least one coefficient");
318
319    SecretKeyShare(y)
320}
321
322fn coefficient(amount: Amount, index: u64) -> Scalar {
323    Scalar::random(&mut ChaChaRng::from_seed(
324        *(amount, index)
325            .consensus_hash::<sha256::Hash>()
326            .as_byte_array(),
327    ))
328}
329
330#[derive(Debug)]
331pub struct Mint {
332    cfg: MintConfig,
333    db: Database,
334}
335
336impl Mint {
337    pub async fn note_distribution_ui(&self) -> BTreeMap<Denomination, u64> {
338        self.db
339            .begin_transaction_nc()
340            .await
341            .find_by_prefix(&IssuanceCounterPrefix)
342            .await
343            .filter(|entry| std::future::ready(entry.1 > 0))
344            .map(|(key, count)| (key.0, count))
345            .collect()
346            .await
347    }
348}
349
350#[apply(async_trait_maybe_send!)]
351impl ServerModule for Mint {
352    type Common = MintModuleTypes;
353    type Init = MintInit;
354
355    async fn consensus_proposal(
356        &self,
357        _dbtx: &mut DatabaseTransaction<'_>,
358    ) -> Vec<MintConsensusItem> {
359        Vec::new()
360    }
361
362    async fn process_consensus_item<'a, 'b>(
363        &'a self,
364        _dbtx: &mut DatabaseTransaction<'b>,
365        _consensus_item: MintConsensusItem,
366        _peer_id: PeerId,
367    ) -> anyhow::Result<()> {
368        bail!("Mint does not process consensus items");
369    }
370
371    async fn process_input<'a, 'b, 'c>(
372        &'a self,
373        dbtx: &mut DatabaseTransaction<'c>,
374        input: &'b MintInput,
375        _in_point: InPoint,
376    ) -> Result<InputMeta, MintInputError> {
377        let input = input.ensure_v0_ref()?;
378
379        let pk = self
380            .cfg
381            .consensus
382            .tbs_agg_pks
383            .get(&input.note.denomination)
384            .ok_or(MintInputError::InvalidAmountTier)?;
385
386        if !input.note.verify(*pk) {
387            return Err(MintInputError::InvalidSignature);
388        }
389
390        if dbtx
391            .insert_entry(&NonceKey(input.note.nonce), &())
392            .await
393            .is_some()
394        {
395            return Err(MintInputError::SpentCoin);
396        }
397
398        let new_count = dbtx
399            .remove_entry(&IssuanceCounterKey(input.note.denomination))
400            .await
401            .unwrap_or(0)
402            .checked_sub(1)
403            .expect("Failed to decrement issuance counter");
404
405        dbtx.insert_new_entry(&IssuanceCounterKey(input.note.denomination), &new_count)
406            .await;
407
408        let next_index = get_recovery_count(dbtx).await;
409
410        dbtx.insert_new_entry(
411            &RecoveryItemKey(next_index),
412            &RecoveryItem::Input {
413                nonce_hash: input.note.nonce.consensus_hash(),
414            },
415        )
416        .await;
417
418        let amount = input.note.amount();
419        Ok(InputMeta {
420            amount: TransactionItemAmounts {
421                amounts: Amounts::new_bitcoin(amount),
422                fees: Amounts::new_bitcoin(self.cfg.consensus.fee_consensus.fee(amount)),
423            },
424            pub_key: input.note.nonce,
425        })
426    }
427
428    async fn process_output<'a, 'b>(
429        &'a self,
430        dbtx: &mut DatabaseTransaction<'b>,
431        output: &'a MintOutput,
432        outpoint: OutPoint,
433    ) -> Result<TransactionItemAmounts, MintOutputError> {
434        let output = output.ensure_v0_ref()?;
435
436        let signature = self
437            .cfg
438            .private
439            .tbs_sks
440            .get(&output.denomination)
441            .map(|key| tbs::sign_message(output.nonce, *key))
442            .ok_or(MintOutputError::InvalidAmountTier)?;
443
444        // Store by outpoint for efficient range-based retrieval
445        dbtx.insert_entry(&BlindedSignatureShareKey(outpoint), &signature)
446            .await;
447
448        // Store by blinded message for recovery
449        dbtx.insert_entry(&BlindedSignatureShareRecoveryKey(output.nonce), &signature)
450            .await;
451
452        let new_count = dbtx
453            .remove_entry(&IssuanceCounterKey(output.denomination))
454            .await
455            .unwrap_or(0)
456            .checked_add(1)
457            .expect("Failed to increment issuance counter");
458
459        dbtx.insert_new_entry(&IssuanceCounterKey(output.denomination), &new_count)
460            .await;
461
462        let next_index = get_recovery_count(dbtx).await;
463
464        dbtx.insert_new_entry(
465            &RecoveryItemKey(next_index),
466            &RecoveryItem::Output {
467                denomination: output.denomination,
468                nonce_hash: output.nonce.consensus_hash(),
469                tweak: output.tweak,
470            },
471        )
472        .await;
473
474        let amount = output.amount();
475        Ok(TransactionItemAmounts {
476            amounts: Amounts::new_bitcoin(amount),
477            fees: Amounts::new_bitcoin(self.cfg.consensus.fee_consensus.fee(amount)),
478        })
479    }
480
481    async fn output_status(
482        &self,
483        _dbtx: &mut DatabaseTransaction<'_>,
484        _outpoint: OutPoint,
485    ) -> Option<MintOutputOutcome> {
486        None
487    }
488
489    async fn audit(
490        &self,
491        dbtx: &mut DatabaseTransaction<'_>,
492        audit: &mut Audit,
493        module_instance_id: ModuleInstanceId,
494    ) {
495        audit
496            .add_items(dbtx, module_instance_id, &IssuanceCounterPrefix, |k, v| {
497                -((k.0.amount().msats * v) as i64)
498            })
499            .await;
500    }
501
502    fn api_endpoints(&self) -> Vec<ApiEndpoint<Self>> {
503        vec![
504            api_endpoint! {
505                SIGNATURE_SHARES_ENDPOINT,
506                ApiVersion::new(0, 1),
507                async |_module: &Mint, context, range: fedimint_core::OutPointRange| -> Vec<BlindedSignatureShare> {
508                    let start_key = BlindedSignatureShareKey(range.start_out_point());
509                    let end_key = BlindedSignatureShareKey(range.end_out_point());
510
511                    let db = context.db();
512                    let mut dbtx = db.begin_transaction_nc().await;
513                    Ok(dbtx
514                        .find_by_range(start_key..end_key)
515                        .await
516                        .map(|entry| entry.1)
517                        .collect()
518                        .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                    let mut shares = Vec::new();
528
529                    for message in messages {
530                        let share = dbtx.get_value(&BlindedSignatureShareRecoveryKey(message))
531                            .await
532                            .ok_or(ApiError::bad_request("No blinded signature share found".to_string()))?;
533
534                        shares.push(share);
535                    }
536
537                    Ok(shares)
538                }
539            },
540            api_endpoint! {
541                RECOVERY_SLICE_ENDPOINT,
542                ApiVersion::new(0, 1),
543                async |_module: &Mint, context, range: (u64, u64)| -> Vec<RecoveryItem> {
544                    let db = context.db();
545                    let mut dbtx = db.begin_transaction_nc().await;
546                    Ok(get_recovery_slice(&mut dbtx, range).await)
547                }
548            },
549            api_endpoint! {
550                RECOVERY_SLICE_HASH_ENDPOINT,
551                ApiVersion::new(0, 1),
552                async |_module: &Mint, context, range: (u64, u64)| -> bitcoin::hashes::sha256::Hash {
553                    let db = context.db();
554                    let mut dbtx = db.begin_transaction_nc().await;
555                    Ok(get_recovery_slice(&mut dbtx, range).await.consensus_hash())
556                }
557            },
558            api_endpoint! {
559                RECOVERY_COUNT_ENDPOINT,
560                ApiVersion::new(0, 1),
561                async |_module: &Mint, context, _params: ()| -> u64 {
562                    let db = context.db();
563                    let mut dbtx = db.begin_transaction_nc().await;
564                    Ok(get_recovery_count(&mut dbtx).await)
565                }
566            },
567        ]
568    }
569}
570
571async fn get_recovery_count(dbtx: &mut DatabaseTransaction<'_>) -> u64 {
572    dbtx.find_by_prefix_sorted_descending(&RecoveryItemPrefix)
573        .await
574        .next()
575        .await
576        .map_or(0, |entry| entry.0.0 + 1)
577}
578
579async fn get_recovery_slice(
580    dbtx: &mut DatabaseTransaction<'_>,
581    range: (u64, u64),
582) -> Vec<RecoveryItem> {
583    dbtx.find_by_range(RecoveryItemKey(range.0)..RecoveryItemKey(range.1))
584        .await
585        .map(|entry| entry.1)
586        .collect()
587        .await
588}