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        Ok(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, 2),
532                async |_module: &Mint, context, range: fedimint_core::OutPointRange| -> Vec<BlindedSignatureShare> {
533                    // Long-polls like the LNv2 decryption key share endpoint, so a
534                    // client can request the shares before acceptance and receive
535                    // them the instant they are written; a client of API 0.1 asks
536                    // after acceptance and is answered at once.
537                    Ok(await_signature_shares(&context.db(), range).await)
538                }
539            },
540            public_api_endpoint! {
541                SIGNATURE_SHARES_RECOVERY_ENDPOINT,
542                ApiVersion::new(0, 1),
543                async |_module: &Mint, context, messages: Vec<tbs::BlindedMessage>| -> Vec<BlindedSignatureShare> {
544                    let db = context.db();
545                    let mut dbtx = db.begin_transaction_nc().await;
546                    get_signature_shares_recovery(&mut dbtx, messages).await
547                }
548            },
549            public_api_endpoint! {
550                RECOVERY_SLICE_ENDPOINT,
551                ApiVersion::new(0, 1),
552                async |_module: &Mint, context, range: (u64, u64)| -> Vec<RecoveryItem> {
553                    let db = context.db();
554                    let mut dbtx = db.begin_transaction_nc().await;
555                    Ok(get_recovery_slice(&mut dbtx, range).await)
556                }
557            },
558            public_api_endpoint! {
559                RECOVERY_SLICE_HASH_ENDPOINT,
560                ApiVersion::new(0, 1),
561                async |_module: &Mint, context, range: (u64, u64)| -> bitcoin::hashes::sha256::Hash {
562                    let db = context.db();
563                    let mut dbtx = db.begin_transaction_nc().await;
564                    Ok(get_recovery_slice(&mut dbtx, range).await.consensus_hash())
565                }
566            },
567            public_api_endpoint! {
568                RECOVERY_COUNT_ENDPOINT,
569                ApiVersion::new(0, 1),
570                async |_module: &Mint, context, _params: ()| -> u64 {
571                    let db = context.db();
572                    let mut dbtx = db.begin_transaction_nc().await;
573                    Ok(get_recovery_count(&mut dbtx).await)
574                }
575            },
576        ]
577    }
578}
579
580/// The signature shares of the range, waiting for them to be written. A
581/// transaction's outputs are processed in one database transaction, so the
582/// range's last share appearing means every share has, and the wait is on
583/// that one. An empty or descending range, which an untrusted request may
584/// carry, has no shares.
585async fn await_signature_shares(
586    db: &Database,
587    range: fedimint_core::OutPointRange,
588) -> Vec<BlindedSignatureShare> {
589    let last = match range.checked_count() {
590        Some(count) if count > 0 => OutPoint {
591            txid: range.txid(),
592            out_idx: range.end_out_point().out_idx - 1,
593        },
594        _ => return Vec::new(),
595    };
596
597    let mut dbtx = db
598        .wait_key_check(&BlindedSignatureShareKey(last), std::convert::identity)
599        .await
600        .1;
601
602    let shares = get_signature_shares(&mut dbtx.to_ref_nc(), range).await;
603
604    shares
605}
606
607async fn get_signature_shares(
608    dbtx: &mut DatabaseTransaction<'_>,
609    range: fedimint_core::OutPointRange,
610) -> Vec<BlindedSignatureShare> {
611    let start_key = BlindedSignatureShareKey(range.start_out_point());
612    let end_key = BlindedSignatureShareKey(range.end_out_point());
613
614    dbtx.find_by_range(start_key..end_key)
615        .await
616        .map(|entry| entry.1)
617        .collect()
618        .await
619}
620
621async fn get_signature_shares_recovery(
622    dbtx: &mut DatabaseTransaction<'_>,
623    messages: Vec<tbs::BlindedMessage>,
624) -> Result<Vec<BlindedSignatureShare>, ApiError> {
625    let mut shares = Vec::new();
626
627    for message in messages {
628        let share = dbtx
629            .get_value(&BlindedSignatureShareRecoveryKey(message))
630            .await
631            .ok_or(ApiError::bad_request(
632                "No blinded signature share found".to_string(),
633            ))?;
634
635        shares.push(share);
636    }
637
638    Ok(shares)
639}
640
641async fn get_recovery_count(dbtx: &mut DatabaseTransaction<'_>) -> u64 {
642    dbtx.find_by_prefix_sorted_descending(&RecoveryItemPrefix)
643        .await
644        .next()
645        .await
646        .map_or(0, |entry| entry.0.0 + 1)
647}
648
649async fn get_recovery_slice(
650    dbtx: &mut DatabaseTransaction<'_>,
651    range: (u64, u64),
652) -> Vec<RecoveryItem> {
653    dbtx.find_by_range(RecoveryItemKey(range.0)..RecoveryItemKey(range.1))
654        .await
655        .map(|entry| entry.1)
656        .collect()
657        .await
658}
659
660fn calculate_mint_issued_ecash_metrics(
661    dbtx: &mut DatabaseTransaction<'_>,
662    amount: Amount,
663    fee: Amount,
664) {
665    dbtx.on_commit(move || {
666        MINT_INOUT_SATS
667            .with_label_values(&["outgoing"])
668            .observe(amount.sats_f64());
669        MINT_INOUT_FEES_SATS
670            .with_label_values(&["outgoing"])
671            .observe(fee.sats_f64());
672        MINT_ISSUED_ECASH_SATS.observe(amount.sats_f64());
673        MINT_ISSUED_ECASH_FEES_SATS.observe(fee.sats_f64());
674    });
675}
676
677fn calculate_mint_redeemed_ecash_metrics(
678    dbtx: &mut DatabaseTransaction<'_>,
679    amount: Amount,
680    fee: Amount,
681) {
682    dbtx.on_commit(move || {
683        MINT_INOUT_SATS
684            .with_label_values(&["incoming"])
685            .observe(amount.sats_f64());
686        MINT_INOUT_FEES_SATS
687            .with_label_values(&["incoming"])
688            .observe(fee.sats_f64());
689        MINT_REDEEMED_ECASH_SATS.observe(amount.sats_f64());
690        MINT_REDEEMED_ECASH_FEES_SATS.observe(fee.sats_f64());
691    });
692}
693
694#[cfg(test)]
695mod tests;