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, EnvVarDoc, 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    fn get_documented_env_vars(&self) -> Vec<EnvVarDoc> {
159        vec![EnvVarDoc {
160            name: FM_ENABLE_MODULE_MINTV2_ENV,
161            description: "Set to 1/true to enable the MintV2 module (experimental). Disabled by default.",
162        }]
163    }
164
165    async fn init(&self, args: &ServerModuleInitArgs<Self>) -> anyhow::Result<Self::Module> {
166        args.cfg().to_typed().map(|cfg| Mint {
167            cfg,
168            db: args.db().clone(),
169        })
170    }
171
172    fn trusted_dealer_gen(
173        &self,
174        peers: &[PeerId],
175        args: &ConfigGenModuleArgs,
176    ) -> BTreeMap<PeerId, ServerModuleConfig> {
177        let fee_consensus = if args.disable_base_fees {
178            FeeConsensus::zero()
179        } else {
180            FeeConsensus::new(0).expect("Relative fee is within range")
181        };
182
183        let tbs_agg_pks = consensus_denominations()
184            .map(|denomination| (denomination, dealer_agg_pk(denomination.amount())))
185            .collect::<BTreeMap<Denomination, AggregatePublicKey>>();
186
187        let tbs_pks = consensus_denominations()
188            .map(|denomination| {
189                let pks = peers
190                    .iter()
191                    .map(|peer| {
192                        (
193                            *peer,
194                            dealer_pk(denomination.amount(), peers.to_num_peers(), *peer),
195                        )
196                    })
197                    .collect();
198
199                (denomination, pks)
200            })
201            .collect::<BTreeMap<Denomination, BTreeMap<PeerId, PublicKeyShare>>>();
202
203        peers
204            .iter()
205            .map(|peer| {
206                let cfg = MintConfig {
207                    consensus: MintConfigConsensus {
208                        tbs_agg_pks: tbs_agg_pks.clone(),
209                        tbs_pks: tbs_pks.clone(),
210                        fee_consensus: fee_consensus.clone(),
211                        amount_unit: AmountUnit::BITCOIN,
212                    },
213                    private: MintConfigPrivate {
214                        tbs_sks: consensus_denominations()
215                            .map(|denomination| {
216                                (
217                                    denomination,
218                                    dealer_sk(denomination.amount(), peers.to_num_peers(), *peer),
219                                )
220                            })
221                            .collect(),
222                    },
223                };
224
225                (*peer, cfg.to_erased())
226            })
227            .collect()
228    }
229
230    async fn distributed_gen(
231        &self,
232        peers: &(dyn PeerHandleOps + Send + Sync),
233        args: &ConfigGenModuleArgs,
234    ) -> anyhow::Result<ServerModuleConfig> {
235        let fee_consensus = if args.disable_base_fees {
236            FeeConsensus::zero()
237        } else {
238            FeeConsensus::new(0).expect("Relative fee is within range")
239        };
240
241        let mut tbs_sks = BTreeMap::new();
242        let mut tbs_agg_pks = BTreeMap::new();
243        let mut tbs_pks = BTreeMap::new();
244
245        for denomination in consensus_denominations() {
246            let (poly, sk) = peers.run_dkg_g2().await?;
247
248            tbs_sks.insert(denomination, tbs::SecretKeyShare(sk));
249
250            tbs_agg_pks.insert(denomination, AggregatePublicKey(poly[0].to_affine()));
251
252            let pks = peers
253                .num_peers()
254                .peer_ids()
255                .map(|peer| (peer, PublicKeyShare(eval_poly_g2(&poly, &peer))))
256                .collect();
257
258            tbs_pks.insert(denomination, pks);
259        }
260
261        let cfg = MintConfig {
262            private: MintConfigPrivate { tbs_sks },
263            consensus: MintConfigConsensus {
264                tbs_agg_pks,
265                tbs_pks,
266                fee_consensus,
267                amount_unit: AmountUnit::BITCOIN,
268            },
269        };
270
271        Ok(cfg.to_erased())
272    }
273
274    fn validate_config(&self, identity: &PeerId, config: ServerModuleConfig) -> anyhow::Result<()> {
275        let config = config.to_typed::<MintConfig>()?;
276
277        for denomination in consensus_denominations() {
278            let pk = derive_pk_share(&config.private.tbs_sks[&denomination]);
279
280            ensure!(
281                pk == config.consensus.tbs_pks[&denomination][identity],
282                "Mint private key doesn't match pubkey share"
283            );
284        }
285
286        Ok(())
287    }
288
289    fn get_client_config(
290        &self,
291        config: &ServerModuleConsensusConfig,
292    ) -> anyhow::Result<MintClientConfig> {
293        let config = MintConfigConsensus::from_erased(config)?;
294
295        Ok(MintClientConfig {
296            tbs_agg_pks: config.tbs_agg_pks,
297            tbs_pks: config.tbs_pks.clone(),
298            fee_consensus: config.fee_consensus.clone(),
299            amount_unit: config.amount_unit,
300        })
301    }
302
303    fn get_database_migrations(
304        &self,
305    ) -> BTreeMap<DatabaseVersion, ServerModuleDbMigrationFn<Mint>> {
306        BTreeMap::new()
307    }
308}
309
310fn dealer_agg_pk(amount: Amount) -> AggregatePublicKey {
311    AggregatePublicKey((G2Projective::generator() * coefficient(amount, 0)).to_affine())
312}
313
314fn dealer_pk(amount: Amount, num_peers: NumPeers, peer: PeerId) -> PublicKeyShare {
315    derive_pk_share(&dealer_sk(amount, num_peers, peer))
316}
317
318fn dealer_sk(amount: Amount, num_peers: NumPeers, peer: PeerId) -> SecretKeyShare {
319    let x = Scalar::from(peer.to_usize() as u64 + 1);
320
321    // We evaluate the scalar polynomial of degree threshold - 1 at the point x
322    // using the Horner schema.
323
324    let y = (0..num_peers.threshold())
325        .map(|index| coefficient(amount, index as u64))
326        .rev()
327        .reduce(|accumulator, c| accumulator * x + c)
328        .expect("We have at least one coefficient");
329
330    SecretKeyShare(y)
331}
332
333fn coefficient(amount: Amount, index: u64) -> Scalar {
334    Scalar::random(&mut ChaChaRng::from_seed(
335        *(amount, index)
336            .consensus_hash::<sha256::Hash>()
337            .as_byte_array(),
338    ))
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
432        Ok(InputMeta {
433            amount: TransactionItemAmounts {
434                amounts: Amounts::new_custom(unit, amount),
435                fees: Amounts::new_custom(unit, self.cfg.consensus.fee_consensus.fee(amount)),
436            },
437            pub_key: input.note.nonce,
438        })
439    }
440
441    async fn process_output<'a, 'b>(
442        &'a self,
443        dbtx: &mut DatabaseTransaction<'b>,
444        output: &'a MintOutput,
445        outpoint: OutPoint,
446    ) -> Result<TransactionItemAmounts, MintOutputError> {
447        let output = output.ensure_v0_ref()?;
448
449        let signature = self
450            .cfg
451            .private
452            .tbs_sks
453            .get(&output.denomination)
454            .map(|key| tbs::sign_message(output.nonce, *key))
455            .ok_or(MintOutputError::InvalidDenomination)?;
456
457        // Store by outpoint for efficient range-based retrieval
458        dbtx.insert_entry(&BlindedSignatureShareKey(outpoint), &signature)
459            .await;
460
461        // Store by blinded message for recovery
462        dbtx.insert_entry(&BlindedSignatureShareRecoveryKey(output.nonce), &signature)
463            .await;
464
465        let new_count = dbtx
466            .remove_entry(&IssuanceCounterKey(output.denomination))
467            .await
468            .unwrap_or(0)
469            .checked_add(1)
470            .expect("Failed to increment issuance counter");
471
472        dbtx.insert_new_entry(&IssuanceCounterKey(output.denomination), &new_count)
473            .await;
474
475        let next_index = get_recovery_count(dbtx).await;
476
477        dbtx.insert_new_entry(
478            &RecoveryItemKey(next_index),
479            &RecoveryItem::Output {
480                denomination: output.denomination,
481                nonce_hash: output.nonce.consensus_hash(),
482                tweak: output.tweak,
483            },
484        )
485        .await;
486
487        let amount = output.amount();
488        let unit = self.cfg.consensus.amount_unit;
489
490        Ok(TransactionItemAmounts {
491            amounts: Amounts::new_custom(unit, amount),
492            fees: Amounts::new_custom(unit, self.cfg.consensus.fee_consensus.fee(amount)),
493        })
494    }
495
496    async fn output_status(
497        &self,
498        _dbtx: &mut DatabaseTransaction<'_>,
499        _outpoint: OutPoint,
500    ) -> Option<MintOutputOutcome> {
501        None
502    }
503
504    async fn audit(
505        &self,
506        dbtx: &mut DatabaseTransaction<'_>,
507        audit: &mut Audit,
508        module_instance_id: ModuleInstanceId,
509    ) {
510        audit
511            .add_items(dbtx, module_instance_id, &IssuanceCounterPrefix, |k, v| {
512                -((k.0.amount().msats * v) as i64)
513            })
514            .await;
515    }
516
517    fn api_endpoints(&self) -> Vec<ApiEndpoint<Self>> {
518        vec![
519            api_endpoint! {
520                SIGNATURE_SHARES_ENDPOINT,
521                ApiVersion::new(0, 1),
522                async |_module: &Mint, context, range: fedimint_core::OutPointRange| -> Vec<BlindedSignatureShare> {
523                    let db = context.db();
524                    let mut dbtx = db.begin_transaction_nc().await;
525                    Ok(get_signature_shares(&mut dbtx, range).await)
526                }
527            },
528            api_endpoint! {
529                SIGNATURE_SHARES_RECOVERY_ENDPOINT,
530                ApiVersion::new(0, 1),
531                async |_module: &Mint, context, messages: Vec<tbs::BlindedMessage>| -> Vec<BlindedSignatureShare> {
532                    let db = context.db();
533                    let mut dbtx = db.begin_transaction_nc().await;
534                    get_signature_shares_recovery(&mut dbtx, messages).await
535                }
536            },
537            api_endpoint! {
538                RECOVERY_SLICE_ENDPOINT,
539                ApiVersion::new(0, 1),
540                async |_module: &Mint, context, range: (u64, u64)| -> Vec<RecoveryItem> {
541                    let db = context.db();
542                    let mut dbtx = db.begin_transaction_nc().await;
543                    Ok(get_recovery_slice(&mut dbtx, range).await)
544                }
545            },
546            api_endpoint! {
547                RECOVERY_SLICE_HASH_ENDPOINT,
548                ApiVersion::new(0, 1),
549                async |_module: &Mint, context, range: (u64, u64)| -> bitcoin::hashes::sha256::Hash {
550                    let db = context.db();
551                    let mut dbtx = db.begin_transaction_nc().await;
552                    Ok(get_recovery_slice(&mut dbtx, range).await.consensus_hash())
553                }
554            },
555            api_endpoint! {
556                RECOVERY_COUNT_ENDPOINT,
557                ApiVersion::new(0, 1),
558                async |_module: &Mint, context, _params: ()| -> u64 {
559                    let db = context.db();
560                    let mut dbtx = db.begin_transaction_nc().await;
561                    Ok(get_recovery_count(&mut dbtx).await)
562                }
563            },
564        ]
565    }
566}
567
568async fn get_signature_shares(
569    dbtx: &mut DatabaseTransaction<'_>,
570    range: fedimint_core::OutPointRange,
571) -> Vec<BlindedSignatureShare> {
572    let start_key = BlindedSignatureShareKey(range.start_out_point());
573    let end_key = BlindedSignatureShareKey(range.end_out_point());
574
575    dbtx.find_by_range(start_key..end_key)
576        .await
577        .map(|entry| entry.1)
578        .collect()
579        .await
580}
581
582async fn get_signature_shares_recovery(
583    dbtx: &mut DatabaseTransaction<'_>,
584    messages: Vec<tbs::BlindedMessage>,
585) -> Result<Vec<BlindedSignatureShare>, ApiError> {
586    let mut shares = Vec::new();
587
588    for message in messages {
589        let share = dbtx
590            .get_value(&BlindedSignatureShareRecoveryKey(message))
591            .await
592            .ok_or(ApiError::bad_request(
593                "No blinded signature share found".to_string(),
594            ))?;
595
596        shares.push(share);
597    }
598
599    Ok(shares)
600}
601
602async fn get_recovery_count(dbtx: &mut DatabaseTransaction<'_>) -> u64 {
603    dbtx.find_by_prefix_sorted_descending(&RecoveryItemPrefix)
604        .await
605        .next()
606        .await
607        .map_or(0, |entry| entry.0.0 + 1)
608}
609
610async fn get_recovery_slice(
611    dbtx: &mut DatabaseTransaction<'_>,
612    range: (u64, u64),
613) -> Vec<RecoveryItem> {
614    dbtx.find_by_range(RecoveryItemKey(range.0)..RecoveryItemKey(range.1))
615        .await
616        .map(|entry| entry.1)
617        .collect()
618        .await
619}