Skip to main content

fedimint_mint_client/
output.rs

1use std::collections::BTreeMap;
2use std::future::ready;
3use std::hash;
4use std::time::Duration;
5
6use assert_matches::assert_matches;
7use fedimint_api_client::api::{
8    FederationApiExt, SerdeOutputOutcome, ServerError,
9    VERSION_THAT_INTRODUCED_AWAIT_OUTPUTS_OUTCOMES, deserialize_outcome,
10};
11use fedimint_api_client::query::FilterMapThreshold;
12use fedimint_client_module::DynGlobalClientContext;
13use fedimint_client_module::module::{ClientContext, OutPointRange};
14use fedimint_client_module::sm::{ClientSMDatabaseTransaction, State, StateTransition};
15use fedimint_core::core::{Decoder, OperationId};
16use fedimint_core::db::IDatabaseTransactionOpsCoreTyped;
17use fedimint_core::encoding::{Decodable, Encodable};
18use fedimint_core::endpoint_constants::AWAIT_OUTPUTS_OUTCOMES_ENDPOINT;
19use fedimint_core::module::ApiRequestErased;
20use fedimint_core::secp256k1::{Keypair, Secp256k1, Signing};
21use fedimint_core::util::FmtCompact as _;
22use fedimint_core::{Amount, NumPeersExt, OutPoint, PeerId, Tiered, TransactionId, crit};
23use fedimint_derive_secret::{ChildId, DerivableSecret};
24use fedimint_logging::LOG_CLIENT_MODULE_MINT;
25use fedimint_mint_common::endpoint_constants::AWAIT_OUTPUT_OUTCOME_ENDPOINT;
26use fedimint_mint_common::{BlindNonce, MintOutputOutcome, Nonce};
27use futures::future::join_all;
28use rayon::iter::{IndexedParallelIterator, IntoParallelIterator as _, ParallelIterator as _};
29use serde::{Deserialize, Serialize};
30use tbs::{
31    AggregatePublicKey, BlindedMessage, BlindedSignature, BlindedSignatureShare, BlindingKey,
32    PublicKeyShare, aggregate_signature_shares, blind_message, unblind_signature,
33};
34use tracing::{debug, warn};
35
36use crate::client_db::NoteKey;
37use crate::error::VerifyBlindShareError;
38use crate::events::{NoteCreated, ReceivePaymentStatus, ReceivePaymentUpdateEvent};
39use crate::{MintClientContext, MintClientModule, SpendableNote};
40
41/// Child ID used to derive the spend key from a note's [`DerivableSecret`]
42const SPEND_KEY_CHILD_ID: ChildId = ChildId(0);
43
44/// Child ID used to derive the blinding key from a note's [`DerivableSecret`]
45const BLINDING_KEY_CHILD_ID: ChildId = ChildId(1);
46
47#[cfg_attr(doc, aquamarine::aquamarine)]
48/// State machine managing the e-cash issuance process related to a mint output.
49///
50/// ```mermaid
51/// graph LR
52///     classDef virtual fill:#fff,stroke-dasharray: 5 5
53///
54///     Created -- containing tx rejected --> Aborted
55///     Created -- await output outcome --> Outcome["Outcome Received"]:::virtual
56///     subgraph Await Outcome
57///     Outcome -- valid blind signatures  --> Succeeded
58///     Outcome -- invalid blind signatures  --> Failed
59///     end
60/// ```
61#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
62pub enum MintOutputStates {
63    /// Issuance request was created, we are waiting for blind signatures
64    Created(MintOutputStatesCreated),
65    /// The transaction containing the issuance was rejected, we can stop
66    /// looking for decryption shares
67    Aborted(MintOutputStatesAborted),
68    // FIXME: handle offline federation failure mode more gracefully
69    /// The transaction containing the issuance was accepted but an unexpected
70    /// error occurred, this should never happen with a honest federation and
71    /// bug-free code.
72    Failed(MintOutputStatesFailed),
73    /// The issuance was completed successfully and the e-cash notes added to
74    /// our wallet
75    Succeeded(MintOutputStatesSucceeded),
76    /// Issuance request was created, we are waiting for blind signatures
77    CreatedMulti(MintOutputStatesCreatedMulti),
78}
79
80#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
81pub struct MintOutputCommonV0 {
82    pub(crate) operation_id: OperationId,
83    pub(crate) out_point: OutPoint,
84}
85
86#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
87pub struct MintOutputCommon {
88    pub(crate) operation_id: OperationId,
89    pub(crate) out_point_range: OutPointRange,
90}
91
92impl MintOutputCommon {
93    pub fn txid(self) -> TransactionId {
94        self.out_point_range.txid()
95    }
96}
97
98#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
99pub struct MintOutputStateMachineV0 {
100    pub(crate) common: MintOutputCommonV0,
101    pub(crate) state: MintOutputStates,
102}
103
104#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
105pub struct MintOutputStateMachine {
106    pub(crate) common: MintOutputCommon,
107    pub(crate) state: MintOutputStates,
108}
109
110impl MintOutputStateMachine {
111    /// Transaction ID this output belongs to
112    pub fn txid(&self) -> TransactionId {
113        self.common.out_point_range.txid()
114    }
115
116    /// Returns `(out_idx, amount, nonce, blind_nonce)` for each note being
117    /// created.
118    ///
119    /// Only available when the state machine is in a `Created` or
120    /// `CreatedMulti` state (i.e. before the blind signature is finalized).
121    /// Returns an empty vec for terminal states.
122    pub fn created_nonces(&self) -> Vec<(u64, Amount, Nonce, BlindNonce)> {
123        match &self.state {
124            MintOutputStates::Created(c) => {
125                vec![(
126                    self.common.out_point_range.start_idx(),
127                    c.amount,
128                    c.issuance_request.nonce(),
129                    BlindNonce(c.issuance_request.blinded_message()),
130                )]
131            }
132            MintOutputStates::CreatedMulti(c) => c
133                .issuance_requests
134                .iter()
135                .map(|(idx, (amount, req))| {
136                    (
137                        *idx,
138                        *amount,
139                        req.nonce(),
140                        BlindNonce(req.blinded_message()),
141                    )
142                })
143                .collect(),
144            _ => vec![],
145        }
146    }
147}
148
149impl State for MintOutputStateMachine {
150    type ModuleContext = MintClientContext;
151
152    fn transitions(
153        &self,
154        context: &Self::ModuleContext,
155        global_context: &DynGlobalClientContext,
156    ) -> Vec<StateTransition<Self>> {
157        match &self.state {
158            MintOutputStates::Created(created) => {
159                created.transitions(context, global_context, self.common)
160            }
161            MintOutputStates::CreatedMulti(created) => {
162                created.transitions(context, global_context, self.common)
163            }
164            MintOutputStates::Aborted(_)
165            | MintOutputStates::Failed(_)
166            | MintOutputStates::Succeeded(_) => {
167                vec![]
168            }
169        }
170    }
171
172    fn operation_id(&self) -> OperationId {
173        self.common.operation_id
174    }
175
176    fn fmt_visualization(&self, f: &mut dyn std::fmt::Write, indent: &str) -> std::fmt::Result {
177        let txid = self.common.out_point_range.txid();
178        let start = self.common.out_point_range.start_idx();
179        let count = self.common.out_point_range.count();
180        match &self.state {
181            MintOutputStates::Created(c) => {
182                let nonce = c.issuance_request.nonce();
183                let blind_nonce = BlindNonce(c.issuance_request.blinded_message());
184                write!(
185                    f,
186                    "{indent}MintOutputStateMachine\n\
187                     {indent}  state: Created  tx={}:[{start},{end})\n\
188                     {indent}  note: amount={}  nonce={}  blind_nonce={}",
189                    txid.fmt_short(),
190                    c.amount,
191                    nonce.fmt_short(),
192                    blind_nonce.fmt_short(),
193                    end = start + count as u64,
194                )
195            }
196            MintOutputStates::CreatedMulti(c) => {
197                let total: Amount = c.issuance_requests.values().map(|(a, _)| *a).sum();
198                write!(
199                    f,
200                    "{indent}MintOutputStateMachine\n\
201                     {indent}  state: CreatedMulti  tx={}:[{start},{end})  {} notes, total={total}",
202                    txid.fmt_short(),
203                    c.issuance_requests.len(),
204                    end = start + count as u64,
205                )?;
206                for (idx, (amount, req)) in &c.issuance_requests {
207                    let nonce = req.nonce();
208                    let blind_nonce = BlindNonce(req.blinded_message());
209                    write!(
210                        f,
211                        "\n{indent}  [{idx}] amount={amount}  nonce={}  blind_nonce={}",
212                        nonce.fmt_short(),
213                        blind_nonce.fmt_short(),
214                    )?;
215                }
216                Ok(())
217            }
218            MintOutputStates::Succeeded(s) => {
219                write!(
220                    f,
221                    "{indent}MintOutputStateMachine\n{indent}  state: Succeeded  amount={}",
222                    s.amount,
223                )
224            }
225            MintOutputStates::Aborted(_) => {
226                write!(
227                    f,
228                    "{indent}MintOutputStateMachine\n{indent}  state: Aborted  tx={}",
229                    txid.fmt_short(),
230                )
231            }
232            MintOutputStates::Failed(fail) => {
233                write!(
234                    f,
235                    "{indent}MintOutputStateMachine\n{indent}  state: Failed  error={}",
236                    fail.error,
237                )
238            }
239        }
240    }
241}
242
243/// See [`MintOutputStates`]
244#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
245pub struct MintOutputStatesCreated {
246    pub(crate) amount: Amount,
247    pub(crate) issuance_request: NoteIssuanceRequest,
248}
249
250impl MintOutputStatesCreated {
251    fn transitions(
252        &self,
253        // TODO: make cheaper to clone (Arc?)
254        context: &MintClientContext,
255        global_context: &DynGlobalClientContext,
256        common: MintOutputCommon,
257    ) -> Vec<StateTransition<MintOutputStateMachine>> {
258        let tbs_pks = context.tbs_pks.clone();
259        let client_ctx = context.client_ctx.clone();
260        let balance_update_sender = context.balance_update_sender.clone();
261
262        vec![
263            // Check if transaction was rejected
264            StateTransition::new(
265                Self::await_tx_rejected(global_context.clone(), common),
266                |_dbtx, (), state| Box::pin(async move { Self::transition_tx_rejected(&state) }),
267            ),
268            // Check for output outcome
269            StateTransition::new(
270                Self::await_outcome_ready(
271                    global_context.clone(),
272                    common,
273                    context.mint_decoder.clone(),
274                    self.amount,
275                    self.issuance_request.blinded_message(),
276                    context.peer_tbs_pks.clone(),
277                ),
278                move |dbtx, blinded_signature_shares, old_state| {
279                    Box::pin(Self::transition_outcome_ready(
280                        client_ctx.clone(),
281                        dbtx,
282                        blinded_signature_shares,
283                        old_state,
284                        tbs_pks.clone(),
285                        balance_update_sender.clone(),
286                    ))
287                },
288            ),
289        ]
290    }
291
292    async fn await_tx_rejected(global_context: DynGlobalClientContext, common: MintOutputCommon) {
293        let txid = common.txid();
294        debug!(target: LOG_CLIENT_MODULE_MINT, %txid, "Awaiting tx rejection");
295        let accept_fut = global_context.await_tx_accepted(common.txid());
296        tokio::pin!(accept_fut);
297        let result = tokio::select! {
298            result = &mut accept_fut => result,
299            () = fedimint_core::runtime::sleep(Duration::from_secs(300)) => {
300                warn!(
301                    target: LOG_CLIENT_MODULE_MINT,
302                    %txid,
303                    "Transaction not accepted or rejected after 5 minutes, possibly stuck or never submitted",
304                );
305                accept_fut.await
306            }
307        };
308        if result.is_err() {
309            return;
310        }
311        std::future::pending::<()>().await;
312    }
313
314    fn transition_tx_rejected(old_state: &MintOutputStateMachine) -> MintOutputStateMachine {
315        assert_matches!(old_state.state, MintOutputStates::Created(_));
316
317        MintOutputStateMachine {
318            common: old_state.common,
319            state: MintOutputStates::Aborted(MintOutputStatesAborted),
320        }
321    }
322
323    async fn await_outcome_ready(
324        global_context: DynGlobalClientContext,
325        common: MintOutputCommon,
326        module_decoder: Decoder,
327        amount: Amount,
328        message: BlindedMessage,
329        tbs_pks: BTreeMap<PeerId, Tiered<PublicKeyShare>>,
330    ) -> BTreeMap<PeerId, BlindedSignatureShare> {
331        let txid = common.txid();
332        let out_idx = common.out_point_range.start_idx();
333        debug!(target: LOG_CLIENT_MODULE_MINT, %txid, %out_idx, "Awaiting output outcome");
334        global_context
335            .api()
336            .request_with_strategy_retry(
337                // this query collects a threshold of 2f + 1 valid blind signature shares
338                FilterMapThreshold::new(
339                    move |peer, outcome| {
340                        ready(
341                            verify_blind_share(
342                                peer,
343                                &outcome,
344                                amount,
345                                message,
346                                &module_decoder,
347                                &tbs_pks,
348                            )
349                            .map_err(|err| {
350                                ServerError::InvalidResponse(err.fmt_compact().to_string())
351                            }),
352                        )
353                    },
354                    global_context.api().all_peers().to_num_peers(),
355                ),
356                AWAIT_OUTPUT_OUTCOME_ENDPOINT.to_owned(),
357                ApiRequestErased::new(OutPoint {
358                    txid: common.txid(),
359                    out_idx: common.out_point_range.start_idx(),
360                }),
361            )
362            .await
363    }
364
365    async fn transition_outcome_ready(
366        client_ctx: ClientContext<MintClientModule>,
367        dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
368        blinded_signature_shares: BTreeMap<PeerId, BlindedSignatureShare>,
369        old_state: MintOutputStateMachine,
370        tbs_pks: Tiered<AggregatePublicKey>,
371        balance_update_sender: tokio::sync::watch::Sender<()>,
372    ) -> MintOutputStateMachine {
373        // we combine the shares, finalize the issuance request with the blind signature
374        // and store the resulting note in the database
375
376        let MintOutputStates::Created(created) = old_state.state else {
377            panic!("Unexpected prior state")
378        };
379
380        let agg_blind_signature = aggregate_signature_shares(
381            &blinded_signature_shares
382                .into_iter()
383                .map(|(peer, share)| (peer.to_usize() as u64, share))
384                .collect(),
385        );
386
387        let amount_key = tbs_pks
388            .tier(&created.amount)
389            .expect("We obtained this amount from tbs_pks when we created the output");
390
391        // this implies that the mint client config's public keys are inconsistent
392        if !tbs::verify_blinded_signature(
393            created.issuance_request.blinded_message(),
394            agg_blind_signature,
395            *amount_key,
396        ) {
397            return MintOutputStateMachine {
398                common: old_state.common,
399                state: MintOutputStates::Failed(MintOutputStatesFailed {
400                    error: "Invalid blind signature".to_string(),
401                }),
402            };
403        }
404
405        let spendable_note = created.issuance_request.finalize(agg_blind_signature);
406
407        assert!(spendable_note.note().verify(*amount_key));
408
409        debug!(target: LOG_CLIENT_MODULE_MINT, amount = %created.amount, note=%spendable_note, "Adding new note from transaction output");
410
411        client_ctx
412            .log_event(
413                &mut dbtx.module_tx(),
414                NoteCreated {
415                    nonce: spendable_note.nonce(),
416                },
417            )
418            .await;
419        if let Some(note) = dbtx
420            .module_tx()
421            .insert_entry(
422                &NoteKey {
423                    amount: created.amount,
424                    nonce: spendable_note.nonce(),
425                },
426                &spendable_note.to_undecoded(),
427            )
428            .await
429        {
430            crit!(target: LOG_CLIENT_MODULE_MINT, %note, "E-cash note was replaced in DB");
431        }
432
433        dbtx.module_tx()
434            .on_commit(move || balance_update_sender.send_replace(()));
435
436        MintOutputStateMachine {
437            common: old_state.common,
438            state: MintOutputStates::Succeeded(MintOutputStatesSucceeded {
439                amount: created.amount,
440            }),
441        }
442    }
443}
444
445/// See [`MintOutputStates`]
446#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
447pub struct MintOutputStatesCreatedMulti {
448    pub(crate) issuance_requests: BTreeMap<u64, (Amount, NoteIssuanceRequest)>,
449}
450
451impl MintOutputStatesCreatedMulti {
452    fn transitions(
453        &self,
454        // TODO: make cheaper to clone (Arc?)
455        context: &MintClientContext,
456        global_context: &DynGlobalClientContext,
457        common: MintOutputCommon,
458    ) -> Vec<StateTransition<MintOutputStateMachine>> {
459        let tbs_pks = context.tbs_pks.clone();
460        let client_ctx = context.client_ctx.clone();
461        let client_ctx_rejected = context.client_ctx.clone();
462        let balance_update_sender = context.balance_update_sender.clone();
463
464        vec![
465            // Check if transaction was rejected
466            StateTransition::new(
467                Self::await_tx_rejected(global_context.clone(), common),
468                move |dbtx, (), state| {
469                    Box::pin(Self::transition_tx_rejected(
470                        client_ctx_rejected.clone(),
471                        dbtx,
472                        state,
473                    ))
474                },
475            ),
476            // Check for output outcome
477            StateTransition::new(
478                Self::await_outcome_ready(
479                    global_context.clone(),
480                    common,
481                    context.mint_decoder.clone(),
482                    self.issuance_requests.clone(),
483                    context.peer_tbs_pks.clone(),
484                ),
485                move |dbtx, blinded_signature_shares, old_state| {
486                    Box::pin(Self::transition_outcome_ready(
487                        client_ctx.clone(),
488                        dbtx,
489                        blinded_signature_shares,
490                        old_state,
491                        tbs_pks.clone(),
492                        balance_update_sender.clone(),
493                    ))
494                },
495            ),
496        ]
497    }
498
499    async fn await_tx_rejected(global_context: DynGlobalClientContext, common: MintOutputCommon) {
500        let txid = common.txid();
501        debug!(target: LOG_CLIENT_MODULE_MINT, %txid, "Awaiting tx rejection");
502        let accept_fut = global_context.await_tx_accepted(common.txid());
503        tokio::pin!(accept_fut);
504        let result = tokio::select! {
505            result = &mut accept_fut => result,
506            () = fedimint_core::runtime::sleep(Duration::from_secs(300)) => {
507                warn!(
508                    target: LOG_CLIENT_MODULE_MINT,
509                    %txid,
510                    "Transaction not accepted or rejected after 5 minutes, possibly stuck or never submitted",
511                );
512                accept_fut.await
513            }
514        };
515        if result.is_err() {
516            return;
517        }
518        std::future::pending::<()>().await;
519    }
520
521    async fn transition_tx_rejected(
522        client_ctx: ClientContext<MintClientModule>,
523        dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
524        old_state: MintOutputStateMachine,
525    ) -> MintOutputStateMachine {
526        assert_matches!(old_state.state, MintOutputStates::CreatedMulti(_));
527
528        client_ctx
529            .log_event(
530                &mut dbtx.module_tx(),
531                ReceivePaymentUpdateEvent {
532                    operation_id: old_state.common.operation_id,
533                    status: ReceivePaymentStatus::Rejected,
534                },
535            )
536            .await;
537
538        MintOutputStateMachine {
539            common: old_state.common,
540            state: MintOutputStates::Aborted(MintOutputStatesAborted),
541        }
542    }
543
544    async fn await_outcome_ready(
545        global_context: DynGlobalClientContext,
546        common: MintOutputCommon,
547        module_decoder: Decoder,
548        issuance_requests: BTreeMap<u64, (Amount, NoteIssuanceRequest)>,
549        tbs_pks: BTreeMap<PeerId, Tiered<PublicKeyShare>>,
550    ) -> Vec<(u64, BTreeMap<PeerId, BlindedSignatureShare>)> {
551        let txid = common.txid();
552        let out_idx = common.out_point_range.start_idx();
553        debug!(target: LOG_CLIENT_MODULE_MINT, %txid, %out_idx, "Awaiting output outcome");
554        let api = global_context.api();
555        let core_api_version = global_context.core_api_version().await;
556
557        // Use the new efficient batch endpoint if the server supports it
558        if VERSION_THAT_INTRODUCED_AWAIT_OUTPUTS_OUTCOMES <= core_api_version {
559            Self::await_outcome_ready_batch(api, common, module_decoder, issuance_requests, tbs_pks)
560                .await
561        } else {
562            // Fall back to the old sequential approach for older servers
563            Self::await_outcome_ready_legacy(
564                api,
565                common,
566                module_decoder,
567                issuance_requests,
568                tbs_pks,
569            )
570            .await
571        }
572    }
573
574    /// Efficient batch version using `AWAIT_OUTPUTS_OUTCOMES_ENDPOINT`
575    async fn await_outcome_ready_batch(
576        api: &fedimint_api_client::api::DynGlobalApi,
577        common: MintOutputCommon,
578        module_decoder: Decoder,
579        issuance_requests: BTreeMap<u64, (Amount, NoteIssuanceRequest)>,
580        tbs_pks: BTreeMap<PeerId, Tiered<PublicKeyShare>>,
581    ) -> Vec<(u64, BTreeMap<PeerId, BlindedSignatureShare>)> {
582        if issuance_requests.is_empty() {
583            return vec![];
584        }
585
586        // Use custom query strategy to collect and verify outcomes from all guardians
587        let issuance_requests_clone = issuance_requests.clone();
588        // The verification is synchronous; the strategy awaits it.
589        let verify = move |peer: PeerId, outcomes: Vec<Option<SerdeOutputOutcome>>| {
590            // Verify the response has the expected length
591            if outcomes.len() != common.out_point_range.count() {
592                return Err(ServerError::InvalidResponse(format!(
593                    "Peer {peer} returned {} outcomes but expected {}",
594                    outcomes.len(),
595                    common.out_point_range.count()
596                )));
597            }
598
599            // Verify each outcome and extract valid blind signature shares
600            // If ANY share is invalid, reject the ENTIRE response from this guardian
601            let mut verified_shares = Vec::with_capacity(outcomes.len());
602            for (relative_idx, outcome_opt) in outcomes.into_iter().enumerate() {
603                let out_idx = common.out_point_range.start_idx() + relative_idx as u64;
604
605                // We should have an issuance request for every output in the range
606                let (amount, issuance_request) = issuance_requests_clone
607                    .get(&out_idx)
608                    .expect("issuance_request must exist for every output in range");
609
610                let share = if let Some(outcome) = outcome_opt {
611                    match verify_blind_share(
612                        peer,
613                        &outcome,
614                        *amount,
615                        issuance_request.blinded_message(),
616                        &module_decoder,
617                        &tbs_pks,
618                    ) {
619                        Ok(share) => Some(share),
620                        Err(err) => {
621                            // Invalid share - reject entire response from this guardian
622                            tracing::warn!(
623                                target: LOG_CLIENT_MODULE_MINT,
624                                %peer,
625                                err = %err.fmt_compact(),
626                                out_point = %OutPoint { txid: common.txid(), out_idx},
627                                "Invalid signature share from peer"
628                            );
629                            return Err(ServerError::InvalidResponse(
630                                err.fmt_compact().to_string(),
631                            ));
632                        }
633                    }
634                } else {
635                    None
636                };
637
638                verified_shares.push(share);
639            }
640
641            Ok(verified_shares)
642        };
643
644        let verified_shares_per_output: BTreeMap<PeerId, Vec<Option<BlindedSignatureShare>>> = api
645            .request_with_strategy_retry(
646                FilterMapThreshold::new(
647                    move |peer, outcomes| ready(verify(peer, outcomes)),
648                    api.all_peers().to_num_peers(),
649                ),
650                AWAIT_OUTPUTS_OUTCOMES_ENDPOINT.to_owned(),
651                ApiRequestErased::new(common.out_point_range),
652            )
653            .await;
654
655        // Reorganize from per-peer to per-output
656        let threshold = api.all_peers().to_num_peers().threshold();
657        let mut ret = vec![];
658
659        for (out_idx, (_amount, _issuance_request)) in issuance_requests {
660            let relative_idx = (out_idx - common.out_point_range.start_idx()) as usize;
661            let mut blinded_sig_shares = BTreeMap::new();
662
663            // Collect verified shares from all peers for this output
664            for (peer_id, shares) in &verified_shares_per_output {
665                if let Some(Some(share)) = shares.get(relative_idx) {
666                    blinded_sig_shares.insert(*peer_id, *share);
667                }
668            }
669
670            assert!(threshold <= blinded_sig_shares.len());
671            ret.push((out_idx, blinded_sig_shares));
672        }
673
674        ret
675    }
676
677    /// Legacy sequential version for backwards compatibility
678    async fn await_outcome_ready_legacy(
679        api: &fedimint_api_client::api::DynGlobalApi,
680        common: MintOutputCommon,
681        module_decoder: Decoder,
682        issuance_requests: BTreeMap<u64, (Amount, NoteIssuanceRequest)>,
683        tbs_pks: BTreeMap<PeerId, Tiered<PublicKeyShare>>,
684    ) -> Vec<(u64, BTreeMap<PeerId, BlindedSignatureShare>)> {
685        let mut ret = vec![];
686        let mut issuance_requests_iter = issuance_requests.into_iter();
687
688        // Wait for the result of the first output only, to save server side
689        // resources
690        if let Some((out_idx, (amount, issuance_request))) = issuance_requests_iter.next() {
691            let module_decoder = module_decoder.clone();
692            let tbs_pks = tbs_pks.clone();
693
694            let blinded_sig_share = api
695                .request_with_strategy_retry(
696                    FilterMapThreshold::new(
697                        move |peer, outcome| {
698                            ready(
699                                verify_blind_share(
700                                    peer,
701                                    &outcome,
702                                    amount,
703                                    issuance_request.blinded_message(),
704                                    &module_decoder,
705                                    &tbs_pks,
706                                )
707                                .map_err(|err| {
708                                    ServerError::InvalidResponse(err.fmt_compact().to_string())
709                                }),
710                            )
711                        },
712                        api.all_peers().to_num_peers(),
713                    ),
714                    AWAIT_OUTPUT_OUTCOME_ENDPOINT.to_owned(),
715                    ApiRequestErased::new(OutPoint {
716                        txid: common.txid(),
717                        out_idx,
718                    }),
719                )
720                .await;
721
722            ret.push((out_idx, blinded_sig_share));
723        } else {
724            return vec![];
725        }
726
727        // We know the tx outcomes are ready, get all of them at once
728        ret.extend(
729            join_all(
730                issuance_requests_iter.map(|(out_idx, (amount, issuance_request))| {
731                    let module_decoder = module_decoder.clone();
732                    let tbs_pks = tbs_pks.clone();
733                    async move {
734                        let blinded_sig_share = api
735                            .request_with_strategy_retry(
736                                FilterMapThreshold::new(
737                                    move |peer, outcome| {
738                                        ready(
739                                            verify_blind_share(
740                                                peer,
741                                                &outcome,
742                                                amount,
743                                                issuance_request.blinded_message(),
744                                                &module_decoder,
745                                                &tbs_pks,
746                                            )
747                                            .map_err(
748                                                |err| {
749                                                    ServerError::InvalidResponse(
750                                                        err.fmt_compact().to_string(),
751                                                    )
752                                                },
753                                            ),
754                                        )
755                                    },
756                                    api.all_peers().to_num_peers(),
757                                ),
758                                AWAIT_OUTPUT_OUTCOME_ENDPOINT.to_owned(),
759                                ApiRequestErased::new(OutPoint {
760                                    txid: common.txid(),
761                                    out_idx,
762                                }),
763                            )
764                            .await;
765
766                        (out_idx, blinded_sig_share)
767                    }
768                }),
769            )
770            .await,
771        );
772
773        ret
774    }
775
776    async fn transition_outcome_ready(
777        client_ctx: ClientContext<MintClientModule>,
778        dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
779        blinded_signature_shares: Vec<(u64, BTreeMap<PeerId, BlindedSignatureShare>)>,
780        old_state: MintOutputStateMachine,
781        tbs_pks: Tiered<AggregatePublicKey>,
782        balance_update_sender: tokio::sync::watch::Sender<()>,
783    ) -> MintOutputStateMachine {
784        // we combine the shares, finalize the issuance request with the blind signature
785        // and store the resulting note in the database
786
787        let mut amount_total = Amount::ZERO;
788        let MintOutputStates::CreatedMulti(created) = old_state.state else {
789            panic!("Unexpected prior state")
790        };
791
792        let mut spendable_notes: Vec<(Amount, SpendableNote)> = vec![];
793
794        // Note verification is relatively slow and CPU-bound, so parallelize them
795        blinded_signature_shares
796            .into_par_iter()
797            .map(|(out_idx, blinded_signature_shares)| {
798                let agg_blind_signature = aggregate_signature_shares(
799                    &blinded_signature_shares
800                        .into_iter()
801                        .map(|(peer, share)| (peer.to_usize() as u64, share))
802                        .collect(),
803                );
804
805                // this implies that the mint client config's public keys are inconsistent
806                let (amount, issuance_request) =
807                    created.issuance_requests.get(&out_idx).expect("Must have");
808
809                let amount_key = tbs_pks.tier(amount).expect("Must have keys for any amount");
810
811                let spendable_note = issuance_request.finalize(agg_blind_signature);
812
813                assert!(spendable_note.note().verify(*amount_key), "We checked all signature shares in the trigger future, so the combined signature has to be valid");
814
815                (*amount, spendable_note)
816            })
817            .collect_into_vec(&mut spendable_notes);
818
819        for (amount, spendable_note) in spendable_notes {
820            debug!(target: LOG_CLIENT_MODULE_MINT, amount = %amount, note=%spendable_note, "Adding new note from transaction output");
821
822            client_ctx
823                .log_event(
824                    &mut dbtx.module_tx(),
825                    NoteCreated {
826                        nonce: spendable_note.nonce(),
827                    },
828                )
829                .await;
830
831            amount_total += amount;
832            if let Some(note) = dbtx
833                .module_tx()
834                .insert_entry(
835                    &NoteKey {
836                        amount,
837                        nonce: spendable_note.nonce(),
838                    },
839                    &spendable_note.to_undecoded(),
840                )
841                .await
842            {
843                crit!(target: LOG_CLIENT_MODULE_MINT, %note, "E-cash note was replaced in DB");
844            }
845        }
846
847        client_ctx
848            .log_event(
849                &mut dbtx.module_tx(),
850                ReceivePaymentUpdateEvent {
851                    operation_id: old_state.common.operation_id,
852                    status: ReceivePaymentStatus::Success,
853                },
854            )
855            .await;
856
857        dbtx.module_tx()
858            .on_commit(move || balance_update_sender.send_replace(()));
859
860        MintOutputStateMachine {
861            common: old_state.common,
862            state: MintOutputStates::Succeeded(MintOutputStatesSucceeded {
863                amount: amount_total,
864            }),
865        }
866    }
867}
868
869/// # Panics
870/// If the given `outcome` is not a [`MintOutputOutcome::V0`] outcome.
871pub fn verify_blind_share(
872    peer: PeerId,
873    outcome: &SerdeOutputOutcome,
874    amount: Amount,
875    blinded_message: BlindedMessage,
876    decoder: &Decoder,
877    peer_tbs_pks: &BTreeMap<PeerId, Tiered<PublicKeyShare>>,
878) -> Result<BlindedSignatureShare, VerifyBlindShareError> {
879    let outcome = deserialize_outcome::<MintOutputOutcome>(outcome, decoder)?;
880
881    let blinded_signature_share = outcome
882        .ensure_v0_ref()
883        .expect("We only process output outcome versions created by ourselves")
884        .0;
885
886    let amount_key = peer_tbs_pks
887        .get(&peer)
888        .ok_or(VerifyBlindShareError::UnknownPeer { peer })?
889        .tier(&amount)
890        .map_err(|_| VerifyBlindShareError::InvalidAmountTier { amount })?;
891
892    if !tbs::verify_signature_share(blinded_message, blinded_signature_share, *amount_key) {
893        return Err(VerifyBlindShareError::InvalidSignature);
894    }
895
896    Ok(blinded_signature_share)
897}
898
899/// See [`MintOutputStates`]
900#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
901pub struct MintOutputStatesAborted;
902
903/// See [`MintOutputStates`]
904#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
905pub struct MintOutputStatesFailed {
906    pub error: String,
907}
908
909/// See [`MintOutputStates`]
910#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
911pub struct MintOutputStatesSucceeded {
912    pub amount: Amount,
913}
914
915/// Keeps the data to generate [`SpendableNote`] once the
916/// mint successfully processed the transaction signing the corresponding
917/// [`BlindNonce`].
918#[derive(Debug, Copy, Clone, PartialEq, Eq, Deserialize, Serialize, Encodable, Decodable)]
919pub struct NoteIssuanceRequest {
920    /// Spend key from which the note nonce (corresponding public key) is
921    /// derived
922    spend_key: Keypair,
923    /// Key to unblind the blind signature supplied by the mint for this note
924    blinding_key: BlindingKey,
925}
926
927impl hash::Hash for NoteIssuanceRequest {
928    fn hash<H: hash::Hasher>(&self, state: &mut H) {
929        self.spend_key.hash(state);
930        // ignore `blinding_key` as it doesn't impl Hash; `spend_key` has enough
931        // entropy anyway
932    }
933}
934impl NoteIssuanceRequest {
935    /// Generate a request session for a single note and returns it plus the
936    /// corresponding blinded message
937    pub fn new<C>(ctx: &Secp256k1<C>, secret: &DerivableSecret) -> (NoteIssuanceRequest, BlindNonce)
938    where
939        C: Signing,
940    {
941        let spend_key = secret.child_key(SPEND_KEY_CHILD_ID).to_secp_key(ctx);
942        let nonce = Nonce(spend_key.public_key());
943        let blinding_key = BlindingKey(secret.child_key(BLINDING_KEY_CHILD_ID).to_bls12_381_key());
944        let blinded_nonce = blind_message(nonce.to_message(), blinding_key);
945
946        let cr = NoteIssuanceRequest {
947            spend_key,
948            blinding_key,
949        };
950
951        (cr, BlindNonce(blinded_nonce))
952    }
953
954    /// Return nonce of the e-cash note being requested
955    pub fn nonce(&self) -> Nonce {
956        Nonce(self.spend_key.public_key())
957    }
958
959    pub fn blinded_message(&self) -> BlindedMessage {
960        blind_message(self.nonce().to_message(), self.blinding_key)
961    }
962
963    /// Use the blind signature to create spendable e-cash notes
964    pub fn finalize(&self, blinded_signature: BlindedSignature) -> SpendableNote {
965        SpendableNote {
966            signature: unblind_signature(self.blinding_key, blinded_signature),
967            spend_key: self.spend_key,
968        }
969    }
970
971    pub fn blinding_key(&self) -> &BlindingKey {
972        &self.blinding_key
973    }
974
975    pub fn spend_key(&self) -> &Keypair {
976        &self.spend_key
977    }
978}