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