fedimint_mint_client/
output.rs

1use std::collections::BTreeMap;
2use std::hash;
3
4use anyhow::{anyhow, bail};
5use fedimint_api_client::api::{
6    FederationApiExt, PeerError, SerdeOutputOutcome, deserialize_outcome,
7};
8use fedimint_api_client::query::FilterMapThreshold;
9use fedimint_client_module::DynGlobalClientContext;
10use fedimint_client_module::module::{ClientContext, OutPointRange};
11use fedimint_client_module::sm::{ClientSMDatabaseTransaction, State, StateTransition};
12use fedimint_core::core::{Decoder, OperationId};
13use fedimint_core::db::IDatabaseTransactionOpsCoreTyped;
14use fedimint_core::encoding::{Decodable, Encodable};
15use fedimint_core::module::ApiRequestErased;
16use fedimint_core::secp256k1::{Keypair, Secp256k1, Signing};
17use fedimint_core::{Amount, NumPeersExt, OutPoint, PeerId, Tiered, TransactionId, crit};
18use fedimint_derive_secret::{ChildId, DerivableSecret};
19use fedimint_logging::LOG_CLIENT_MODULE_MINT;
20use fedimint_mint_common::endpoint_constants::AWAIT_OUTPUT_OUTCOME_ENDPOINT;
21use fedimint_mint_common::{BlindNonce, MintOutputOutcome, Nonce};
22use futures::future::join_all;
23use rayon::iter::{IndexedParallelIterator, IntoParallelIterator as _, ParallelIterator as _};
24use serde::{Deserialize, Serialize};
25use tbs::{
26    AggregatePublicKey, BlindedMessage, BlindedSignature, BlindedSignatureShare, BlindingKey,
27    PublicKeyShare, aggregate_signature_shares, blind_message, unblind_signature,
28};
29use tracing::debug;
30
31use crate::client_db::NoteKey;
32use crate::event::NoteCreated;
33use crate::{MintClientContext, MintClientModule, SpendableNote};
34
35/// Child ID used to derive the spend key from a note's [`DerivableSecret`]
36const SPEND_KEY_CHILD_ID: ChildId = ChildId(0);
37
38/// Child ID used to derive the blinding key from a note's [`DerivableSecret`]
39const BLINDING_KEY_CHILD_ID: ChildId = ChildId(1);
40
41#[cfg_attr(doc, aquamarine::aquamarine)]
42/// State machine managing the e-cash issuance process related to a mint output.
43///
44/// ```mermaid
45/// graph LR
46///     classDef virtual fill:#fff,stroke-dasharray: 5 5
47///
48///     Created -- containing tx rejected --> Aborted
49///     Created -- await output outcome --> Outcome["Outcome Received"]:::virtual
50///     subgraph Await Outcome
51///     Outcome -- valid blind signatures  --> Succeeded
52///     Outcome -- invalid blind signatures  --> Failed
53///     end
54/// ```
55#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
56pub enum MintOutputStates {
57    /// Issuance request was created, we are waiting for blind signatures
58    Created(MintOutputStatesCreated),
59    /// The transaction containing the issuance was rejected, we can stop
60    /// looking for decryption shares
61    Aborted(MintOutputStatesAborted),
62    // FIXME: handle offline federation failure mode more gracefully
63    /// The transaction containing the issuance was accepted but an unexpected
64    /// error occurred, this should never happen with a honest federation and
65    /// bug-free code.
66    Failed(MintOutputStatesFailed),
67    /// The issuance was completed successfully and the e-cash notes added to
68    /// our wallet
69    Succeeded(MintOutputStatesSucceeded),
70    /// Issuance request was created, we are waiting for blind signatures
71    CreatedMulti(MintOutputStatesCreatedMulti),
72}
73
74#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
75pub struct MintOutputCommonV0 {
76    pub(crate) operation_id: OperationId,
77    pub(crate) out_point: OutPoint,
78}
79
80#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
81pub struct MintOutputCommon {
82    pub(crate) operation_id: OperationId,
83    pub(crate) out_point_range: OutPointRange,
84}
85
86impl MintOutputCommon {
87    pub fn txid(self) -> TransactionId {
88        self.out_point_range.txid()
89    }
90}
91
92#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
93pub struct MintOutputStateMachineV0 {
94    pub(crate) common: MintOutputCommonV0,
95    pub(crate) state: MintOutputStates,
96}
97
98#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
99pub struct MintOutputStateMachine {
100    pub(crate) common: MintOutputCommon,
101    pub(crate) state: MintOutputStates,
102}
103
104impl State for MintOutputStateMachine {
105    type ModuleContext = MintClientContext;
106
107    fn transitions(
108        &self,
109        context: &Self::ModuleContext,
110        global_context: &DynGlobalClientContext,
111    ) -> Vec<StateTransition<Self>> {
112        match &self.state {
113            MintOutputStates::Created(created) => {
114                created.transitions(context, global_context, self.common)
115            }
116            MintOutputStates::CreatedMulti(created) => {
117                created.transitions(context, global_context, self.common)
118            }
119            MintOutputStates::Aborted(_)
120            | MintOutputStates::Failed(_)
121            | MintOutputStates::Succeeded(_) => {
122                vec![]
123            }
124        }
125    }
126
127    fn operation_id(&self) -> OperationId {
128        self.common.operation_id
129    }
130}
131
132/// See [`MintOutputStates`]
133#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
134pub struct MintOutputStatesCreated {
135    pub(crate) amount: Amount,
136    pub(crate) issuance_request: NoteIssuanceRequest,
137}
138
139impl MintOutputStatesCreated {
140    fn transitions(
141        &self,
142        // TODO: make cheaper to clone (Arc?)
143        context: &MintClientContext,
144        global_context: &DynGlobalClientContext,
145        common: MintOutputCommon,
146    ) -> Vec<StateTransition<MintOutputStateMachine>> {
147        let tbs_pks = context.tbs_pks.clone();
148        let client_ctx = context.client_ctx.clone();
149
150        vec![
151            // Check if transaction was rejected
152            StateTransition::new(
153                Self::await_tx_rejected(global_context.clone(), common),
154                |_dbtx, (), state| Box::pin(async move { Self::transition_tx_rejected(&state) }),
155            ),
156            // Check for output outcome
157            StateTransition::new(
158                Self::await_outcome_ready(
159                    global_context.clone(),
160                    common,
161                    context.mint_decoder.clone(),
162                    self.amount,
163                    self.issuance_request.blinded_message(),
164                    context.peer_tbs_pks.clone(),
165                ),
166                move |dbtx, blinded_signature_shares, old_state| {
167                    Box::pin(Self::transition_outcome_ready(
168                        client_ctx.clone(),
169                        dbtx,
170                        blinded_signature_shares,
171                        old_state,
172                        tbs_pks.clone(),
173                    ))
174                },
175            ),
176        ]
177    }
178
179    async fn await_tx_rejected(global_context: DynGlobalClientContext, common: MintOutputCommon) {
180        if global_context
181            .await_tx_accepted(common.txid())
182            .await
183            .is_err()
184        {
185            return;
186        }
187        std::future::pending::<()>().await;
188    }
189
190    fn transition_tx_rejected(old_state: &MintOutputStateMachine) -> MintOutputStateMachine {
191        assert!(matches!(old_state.state, MintOutputStates::Created(_)));
192
193        MintOutputStateMachine {
194            common: old_state.common,
195            state: MintOutputStates::Aborted(MintOutputStatesAborted),
196        }
197    }
198
199    async fn await_outcome_ready(
200        global_context: DynGlobalClientContext,
201        common: MintOutputCommon,
202        module_decoder: Decoder,
203        amount: Amount,
204        message: BlindedMessage,
205        tbs_pks: BTreeMap<PeerId, Tiered<PublicKeyShare>>,
206    ) -> BTreeMap<PeerId, BlindedSignatureShare> {
207        global_context
208            .api()
209            .request_with_strategy_retry(
210                // this query collects a threshold of 2f + 1 valid blind signature shares
211                FilterMapThreshold::new(
212                    move |peer, outcome| {
213                        verify_blind_share(
214                            peer,
215                            &outcome,
216                            amount,
217                            message,
218                            &module_decoder,
219                            &tbs_pks,
220                        )
221                        .map_err(PeerError::InvalidResponse)
222                    },
223                    global_context.api().all_peers().to_num_peers(),
224                ),
225                AWAIT_OUTPUT_OUTCOME_ENDPOINT.to_owned(),
226                ApiRequestErased::new(OutPoint {
227                    txid: common.txid(),
228                    out_idx: common.out_point_range.start_idx(),
229                }),
230            )
231            .await
232    }
233
234    async fn transition_outcome_ready(
235        client_ctx: ClientContext<MintClientModule>,
236        dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
237        blinded_signature_shares: BTreeMap<PeerId, BlindedSignatureShare>,
238        old_state: MintOutputStateMachine,
239        tbs_pks: Tiered<AggregatePublicKey>,
240    ) -> MintOutputStateMachine {
241        // we combine the shares, finalize the issuance request with the blind signature
242        // and store the resulting note in the database
243
244        let MintOutputStates::Created(created) = old_state.state else {
245            panic!("Unexpected prior state")
246        };
247
248        let agg_blind_signature = aggregate_signature_shares(
249            &blinded_signature_shares
250                .into_iter()
251                .map(|(peer, share)| (peer.to_usize() as u64, share))
252                .collect(),
253        );
254
255        let amount_key = tbs_pks
256            .tier(&created.amount)
257            .expect("We obtained this amount from tbs_pks when we created the output");
258
259        // this implies that the mint client config's public keys are inconsistent
260        if !tbs::verify_blinded_signature(
261            created.issuance_request.blinded_message(),
262            agg_blind_signature,
263            *amount_key,
264        ) {
265            return MintOutputStateMachine {
266                common: old_state.common,
267                state: MintOutputStates::Failed(MintOutputStatesFailed {
268                    error: "Invalid blind signature".to_string(),
269                }),
270            };
271        }
272
273        let spendable_note = created.issuance_request.finalize(agg_blind_signature);
274
275        assert!(spendable_note.note().verify(*amount_key));
276
277        debug!(target: LOG_CLIENT_MODULE_MINT, amount = %created.amount, note=%spendable_note, "Adding new note from transaction output");
278
279        client_ctx
280            .log_event(
281                &mut dbtx.module_tx(),
282                NoteCreated {
283                    nonce: spendable_note.nonce(),
284                },
285            )
286            .await;
287        if let Some(note) = dbtx
288            .module_tx()
289            .insert_entry(
290                &NoteKey {
291                    amount: created.amount,
292                    nonce: spendable_note.nonce(),
293                },
294                &spendable_note.to_undecoded(),
295            )
296            .await
297        {
298            crit!(target: LOG_CLIENT_MODULE_MINT, %note, "E-cash note was replaced in DB");
299        }
300
301        MintOutputStateMachine {
302            common: old_state.common,
303            state: MintOutputStates::Succeeded(MintOutputStatesSucceeded {
304                amount: created.amount,
305            }),
306        }
307    }
308}
309
310/// See [`MintOutputStates`]
311#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
312pub struct MintOutputStatesCreatedMulti {
313    pub(crate) issuance_requests: BTreeMap<u64, (Amount, NoteIssuanceRequest)>,
314}
315
316impl MintOutputStatesCreatedMulti {
317    fn transitions(
318        &self,
319        // TODO: make cheaper to clone (Arc?)
320        context: &MintClientContext,
321        global_context: &DynGlobalClientContext,
322        common: MintOutputCommon,
323    ) -> Vec<StateTransition<MintOutputStateMachine>> {
324        let tbs_pks = context.tbs_pks.clone();
325        let client_ctx = context.client_ctx.clone();
326
327        vec![
328            // Check if transaction was rejected
329            StateTransition::new(
330                Self::await_tx_rejected(global_context.clone(), common),
331                |_dbtx, (), state| Box::pin(async move { Self::transition_tx_rejected(&state) }),
332            ),
333            // Check for output outcome
334            StateTransition::new(
335                Self::await_outcome_ready(
336                    global_context.clone(),
337                    common,
338                    context.mint_decoder.clone(),
339                    self.issuance_requests.clone(),
340                    context.peer_tbs_pks.clone(),
341                ),
342                move |dbtx, blinded_signature_shares, old_state| {
343                    Box::pin(Self::transition_outcome_ready(
344                        client_ctx.clone(),
345                        dbtx,
346                        blinded_signature_shares,
347                        old_state,
348                        tbs_pks.clone(),
349                    ))
350                },
351            ),
352        ]
353    }
354
355    async fn await_tx_rejected(global_context: DynGlobalClientContext, common: MintOutputCommon) {
356        if global_context
357            .await_tx_accepted(common.txid())
358            .await
359            .is_err()
360        {
361            return;
362        }
363        std::future::pending::<()>().await;
364    }
365
366    fn transition_tx_rejected(old_state: &MintOutputStateMachine) -> MintOutputStateMachine {
367        assert!(matches!(old_state.state, MintOutputStates::CreatedMulti(_)));
368
369        MintOutputStateMachine {
370            common: old_state.common,
371            state: MintOutputStates::Aborted(MintOutputStatesAborted),
372        }
373    }
374
375    async fn await_outcome_ready(
376        global_context: DynGlobalClientContext,
377        common: MintOutputCommon,
378        module_decoder: Decoder,
379        issuance_requests: BTreeMap<u64, (Amount, NoteIssuanceRequest)>,
380        tbs_pks: BTreeMap<PeerId, Tiered<PublicKeyShare>>,
381    ) -> Vec<(u64, BTreeMap<PeerId, BlindedSignatureShare>)> {
382        let mut ret = vec![];
383        // NOTE: We need a new api endpoint that can confirm multiple notes at once?
384        // --dpc
385        let api = global_context.api();
386        let mut issuance_requests_iter = issuance_requests.into_iter();
387
388        // Wait for the result of the first output only, to save of server side
389        // resources
390        if let Some((out_idx, (amount, issuance_request))) = issuance_requests_iter.next() {
391            let module_decoder = module_decoder.clone();
392            let tbs_pks = tbs_pks.clone();
393
394            let blinded_sig_share = api
395                .request_with_strategy_retry(
396                    // this query collects a threshold of 2f + 1 valid blind signature
397                    // shares
398                    FilterMapThreshold::new(
399                        move |peer, outcome| {
400                            verify_blind_share(
401                                peer,
402                                &outcome,
403                                amount,
404                                issuance_request.blinded_message(),
405                                &module_decoder,
406                                &tbs_pks,
407                            )
408                            .map_err(PeerError::InvalidResponse)
409                        },
410                        api.all_peers().to_num_peers(),
411                    ),
412                    AWAIT_OUTPUT_OUTCOME_ENDPOINT.to_owned(),
413                    ApiRequestErased::new(OutPoint {
414                        txid: common.txid(),
415                        out_idx,
416                    }),
417                )
418                .await;
419
420            ret.push((out_idx, blinded_sig_share));
421        } else {
422            // no outputs, just return nothing
423            return vec![];
424        }
425
426        // We know the tx outcomes are ready, get all of them at once
427        ret.extend(
428            join_all(
429                issuance_requests_iter.map(|(out_idx, (amount, issuance_request))| {
430                    let module_decoder = module_decoder.clone();
431                    let tbs_pks = tbs_pks.clone();
432                    async move {
433                        let blinded_sig_share = api
434                            .request_with_strategy_retry(
435                                // this query collects a threshold of 2f + 1 valid blind signature
436                                // shares
437                                FilterMapThreshold::new(
438                                    move |peer, outcome| {
439                                        verify_blind_share(
440                                            peer,
441                                            &outcome,
442                                            amount,
443                                            issuance_request.blinded_message(),
444                                            &module_decoder,
445                                            &tbs_pks,
446                                        )
447                                        .map_err(PeerError::InvalidResponse)
448                                    },
449                                    api.all_peers().to_num_peers(),
450                                ),
451                                AWAIT_OUTPUT_OUTCOME_ENDPOINT.to_owned(),
452                                ApiRequestErased::new(OutPoint {
453                                    txid: common.txid(),
454                                    out_idx,
455                                }),
456                            )
457                            .await;
458
459                        (out_idx, blinded_sig_share)
460                    }
461                }),
462            )
463            .await,
464        );
465
466        ret
467    }
468
469    async fn transition_outcome_ready(
470        client_ctx: ClientContext<MintClientModule>,
471        dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
472        blinded_signature_shares: Vec<(u64, BTreeMap<PeerId, BlindedSignatureShare>)>,
473        old_state: MintOutputStateMachine,
474        tbs_pks: Tiered<AggregatePublicKey>,
475    ) -> MintOutputStateMachine {
476        // we combine the shares, finalize the issuance request with the blind signature
477        // and store the resulting note in the database
478
479        let mut amount_total = Amount::ZERO;
480        let MintOutputStates::CreatedMulti(created) = old_state.state else {
481            panic!("Unexpected prior state")
482        };
483
484        let mut spendable_notes: Vec<(Amount, SpendableNote)> = vec![];
485
486        // Note verification is relatively slow and CPU-bound, so parallelize them
487        blinded_signature_shares
488            .into_par_iter()
489            .map(|(out_idx, blinded_signature_shares)| {
490                let agg_blind_signature = aggregate_signature_shares(
491                    &blinded_signature_shares
492                        .into_iter()
493                        .map(|(peer, share)| (peer.to_usize() as u64, share))
494                        .collect(),
495                );
496
497                // this implies that the mint client config's public keys are inconsistent
498                let (amount, issuance_request) =
499                    created.issuance_requests.get(&out_idx).expect("Must have");
500
501                let amount_key = tbs_pks.tier(amount).expect("Must have keys for any amount");
502
503                let spendable_note = issuance_request.finalize(agg_blind_signature);
504
505                assert!(spendable_note.note().verify(*amount_key), "We checked all signature shares in the trigger future, so the combined signature has to be valid");
506
507                (*amount, spendable_note)
508            })
509            .collect_into_vec(&mut spendable_notes);
510
511        for (amount, spendable_note) in spendable_notes {
512            debug!(target: LOG_CLIENT_MODULE_MINT, amount = %amount, note=%spendable_note, "Adding new note from transaction output");
513
514            client_ctx
515                .log_event(
516                    &mut dbtx.module_tx(),
517                    NoteCreated {
518                        nonce: spendable_note.nonce(),
519                    },
520                )
521                .await;
522
523            amount_total += amount;
524            if let Some(note) = dbtx
525                .module_tx()
526                .insert_entry(
527                    &NoteKey {
528                        amount,
529                        nonce: spendable_note.nonce(),
530                    },
531                    &spendable_note.to_undecoded(),
532                )
533                .await
534            {
535                crit!(target: LOG_CLIENT_MODULE_MINT, %note, "E-cash note was replaced in DB");
536            }
537        }
538        MintOutputStateMachine {
539            common: old_state.common,
540            state: MintOutputStates::Succeeded(MintOutputStatesSucceeded {
541                amount: amount_total,
542            }),
543        }
544    }
545}
546
547/// # Panics
548/// If the given `outcome` is not a [`MintOutputOutcome::V0`] outcome.
549pub fn verify_blind_share(
550    peer: PeerId,
551    outcome: &SerdeOutputOutcome,
552    amount: Amount,
553    blinded_message: BlindedMessage,
554    decoder: &Decoder,
555    peer_tbs_pks: &BTreeMap<PeerId, Tiered<PublicKeyShare>>,
556) -> anyhow::Result<BlindedSignatureShare> {
557    let outcome = deserialize_outcome::<MintOutputOutcome>(outcome, decoder)?;
558
559    let blinded_signature_share = outcome
560        .ensure_v0_ref()
561        .expect("We only process output outcome versions created by ourselves")
562        .0;
563
564    let amount_key = peer_tbs_pks
565        .get(&peer)
566        .ok_or(anyhow!("Unknown peer"))?
567        .tier(&amount)
568        .map_err(|_| anyhow!("Invalid Amount Tier"))?;
569
570    if !tbs::verify_signature_share(blinded_message, blinded_signature_share, *amount_key) {
571        bail!("Invalid blind signature")
572    }
573
574    Ok(blinded_signature_share)
575}
576
577/// See [`MintOutputStates`]
578#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
579pub struct MintOutputStatesAborted;
580
581/// See [`MintOutputStates`]
582#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
583pub struct MintOutputStatesFailed {
584    pub error: String,
585}
586
587/// See [`MintOutputStates`]
588#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
589pub struct MintOutputStatesSucceeded {
590    pub amount: Amount,
591}
592
593/// Keeps the data to generate [`SpendableNote`] once the
594/// mint successfully processed the transaction signing the corresponding
595/// [`BlindNonce`].
596#[derive(Debug, Copy, Clone, PartialEq, Eq, Deserialize, Serialize, Encodable, Decodable)]
597pub struct NoteIssuanceRequest {
598    /// Spend key from which the note nonce (corresponding public key) is
599    /// derived
600    spend_key: Keypair,
601    /// Key to unblind the blind signature supplied by the mint for this note
602    blinding_key: BlindingKey,
603}
604
605impl hash::Hash for NoteIssuanceRequest {
606    fn hash<H: hash::Hasher>(&self, state: &mut H) {
607        self.spend_key.hash(state);
608        // ignore `blinding_key` as it doesn't impl Hash; `spend_key` has enough
609        // entropy anyway
610    }
611}
612impl NoteIssuanceRequest {
613    /// Generate a request session for a single note and returns it plus the
614    /// corresponding blinded message
615    pub fn new<C>(ctx: &Secp256k1<C>, secret: &DerivableSecret) -> (NoteIssuanceRequest, BlindNonce)
616    where
617        C: Signing,
618    {
619        let spend_key = secret.child_key(SPEND_KEY_CHILD_ID).to_secp_key(ctx);
620        let nonce = Nonce(spend_key.public_key());
621        let blinding_key = BlindingKey(secret.child_key(BLINDING_KEY_CHILD_ID).to_bls12_381_key());
622        let blinded_nonce = blind_message(nonce.to_message(), blinding_key);
623
624        let cr = NoteIssuanceRequest {
625            spend_key,
626            blinding_key,
627        };
628
629        (cr, BlindNonce(blinded_nonce))
630    }
631
632    /// Return nonce of the e-cash note being requested
633    pub fn nonce(&self) -> Nonce {
634        Nonce(self.spend_key.public_key())
635    }
636
637    pub fn blinded_message(&self) -> BlindedMessage {
638        blind_message(self.nonce().to_message(), self.blinding_key)
639    }
640
641    /// Use the blind signature to create spendable e-cash notes
642    pub fn finalize(&self, blinded_signature: BlindedSignature) -> SpendableNote {
643        SpendableNote {
644            signature: unblind_signature(self.blinding_key, blinded_signature),
645            spend_key: self.spend_key,
646        }
647    }
648
649    pub fn blinding_key(&self) -> &BlindingKey {
650        &self.blinding_key
651    }
652
653    pub fn spend_key(&self) -> &Keypair {
654        &self.spend_key
655    }
656}