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 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 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 dbtx.insert_entry(&BlindedSignatureShareKey(outpoint), &signature)
464 .await;
465
466 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}