Skip to main content

fedimint_mint_client/
output.rs

1use std::collections::BTreeMap;
2use std::hash;
3use std::time::Duration;
4
5use assert_matches::assert_matches;
6use fedimint_api_client::api::{
7    FederationApiExt, SerdeOutputOutcome, ServerError,
8    VERSION_THAT_INTRODUCED_AWAIT_OUTPUTS_OUTCOMES, deserialize_outcome,
9};
10use fedimint_api_client::query::FilterMapThreshold;
11use fedimint_client_module::DynGlobalClientContext;
12use fedimint_client_module::module::{ClientContext, OutPointRange};
13use fedimint_client_module::sm::{ClientSMDatabaseTransaction, State, StateTransition};
14use fedimint_core::core::{Decoder, OperationId};
15use fedimint_core::db::IDatabaseTransactionOpsCoreTyped;
16use fedimint_core::encoding::{Decodable, Encodable};
17use fedimint_core::endpoint_constants::AWAIT_OUTPUTS_OUTCOMES_ENDPOINT;
18use fedimint_core::module::ApiRequestErased;
19use fedimint_core::secp256k1::{Keypair, Secp256k1, Signing};
20use fedimint_core::util::FmtCompact as _;
21use fedimint_core::{Amount, NumPeersExt, OutPoint, PeerId, Tiered, TransactionId, crit};
22use fedimint_derive_secret::{ChildId, DerivableSecret};
23use fedimint_logging::LOG_CLIENT_MODULE_MINT;
24use fedimint_mint_common::endpoint_constants::AWAIT_OUTPUT_OUTCOME_ENDPOINT;
25use fedimint_mint_common::{BlindNonce, MintOutputOutcome, Nonce};
26use futures::future::join_all;
27use rayon::iter::{IndexedParallelIterator, IntoParallelIterator as _, ParallelIterator as _};
28use serde::{Deserialize, Serialize};
29use tbs::{
30    AggregatePublicKey, BlindedMessage, BlindedSignature, BlindedSignatureShare, BlindingKey,
31    PublicKeyShare, aggregate_signature_shares, blind_message, unblind_signature,
32};
33use tracing::{debug, warn};
34
35use crate::client_db::NoteKey;
36use crate::error::VerifyBlindShareError;
37use crate::events::{NoteCreated, ReceivePaymentStatus, ReceivePaymentUpdateEvent};
38use crate::{MintClientContext, MintClientModule, SpendableNote};
39
40/// Child ID used to derive the spend key from a note's [`DerivableSecret`]
41const SPEND_KEY_CHILD_ID: ChildId = ChildId(0);
42
43/// Child ID used to derive the blinding key from a note's [`DerivableSecret`]
44const BLINDING_KEY_CHILD_ID: ChildId = ChildId(1);
45
46#[cfg_attr(doc, aquamarine::aquamarine)]
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                        verify_blind_share(
340                            peer,
341                            &outcome,
342                            amount,
343                            message,
344                            &module_decoder,
345                            &tbs_pks,
346                        )
347                        .map_err(|err| ServerError::InvalidResponse(err.fmt_compact().to_string()))
348                    },
349                    global_context.api().all_peers().to_num_peers(),
350                ),
351                AWAIT_OUTPUT_OUTCOME_ENDPOINT.to_owned(),
352                ApiRequestErased::new(OutPoint {
353                    txid: common.txid(),
354                    out_idx: common.out_point_range.start_idx(),
355                }),
356            )
357            .await
358    }
359
360    async fn transition_outcome_ready(
361        client_ctx: ClientContext<MintClientModule>,
362        dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
363        blinded_signature_shares: BTreeMap<PeerId, BlindedSignatureShare>,
364        old_state: MintOutputStateMachine,
365        tbs_pks: Tiered<AggregatePublicKey>,
366        balance_update_sender: tokio::sync::watch::Sender<()>,
367    ) -> MintOutputStateMachine {
368        // we combine the shares, finalize the issuance request with the blind signature
369        // and store the resulting note in the database
370
371        let MintOutputStates::Created(created) = old_state.state else {
372            panic!("Unexpected prior state")
373        };
374
375        let agg_blind_signature = aggregate_signature_shares(
376            &blinded_signature_shares
377                .into_iter()
378                .map(|(peer, share)| (peer.to_usize() as u64, share))
379                .collect(),
380        );
381
382        let amount_key = tbs_pks
383            .tier(&created.amount)
384            .expect("We obtained this amount from tbs_pks when we created the output");
385
386        // this implies that the mint client config's public keys are inconsistent
387        if !tbs::verify_blinded_signature(
388            created.issuance_request.blinded_message(),
389            agg_blind_signature,
390            *amount_key,
391        ) {
392            return MintOutputStateMachine {
393                common: old_state.common,
394                state: MintOutputStates::Failed(MintOutputStatesFailed {
395                    error: "Invalid blind signature".to_string(),
396                }),
397            };
398        }
399
400        let spendable_note = created.issuance_request.finalize(agg_blind_signature);
401
402        assert!(spendable_note.note().verify(*amount_key));
403
404        debug!(target: LOG_CLIENT_MODULE_MINT, amount = %created.amount, note=%spendable_note, "Adding new note from transaction output");
405
406        client_ctx
407            .log_event(
408                &mut dbtx.module_tx(),
409                NoteCreated {
410                    nonce: spendable_note.nonce(),
411                },
412            )
413            .await;
414        if let Some(note) = dbtx
415            .module_tx()
416            .insert_entry(
417                &NoteKey {
418                    amount: created.amount,
419                    nonce: spendable_note.nonce(),
420                },
421                &spendable_note.to_undecoded(),
422            )
423            .await
424        {
425            crit!(target: LOG_CLIENT_MODULE_MINT, %note, "E-cash note was replaced in DB");
426        }
427
428        dbtx.module_tx()
429            .on_commit(move || balance_update_sender.send_replace(()));
430
431        MintOutputStateMachine {
432            common: old_state.common,
433            state: MintOutputStates::Succeeded(MintOutputStatesSucceeded {
434                amount: created.amount,
435            }),
436        }
437    }
438}
439
440/// See [`MintOutputStates`]
441#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
442pub struct MintOutputStatesCreatedMulti {
443    pub(crate) issuance_requests: BTreeMap<u64, (Amount, NoteIssuanceRequest)>,
444}
445
446impl MintOutputStatesCreatedMulti {
447    fn transitions(
448        &self,
449        // TODO: make cheaper to clone (Arc?)
450        context: &MintClientContext,
451        global_context: &DynGlobalClientContext,
452        common: MintOutputCommon,
453    ) -> Vec<StateTransition<MintOutputStateMachine>> {
454        let tbs_pks = context.tbs_pks.clone();
455        let client_ctx = context.client_ctx.clone();
456        let client_ctx_rejected = context.client_ctx.clone();
457        let balance_update_sender = context.balance_update_sender.clone();
458
459        vec![
460            // Check if transaction was rejected
461            StateTransition::new(
462                Self::await_tx_rejected(global_context.clone(), common),
463                move |dbtx, (), state| {
464                    Box::pin(Self::transition_tx_rejected(
465                        client_ctx_rejected.clone(),
466                        dbtx,
467                        state,
468                    ))
469                },
470            ),
471            // Check for output outcome
472            StateTransition::new(
473                Self::await_outcome_ready(
474                    global_context.clone(),
475                    common,
476                    context.mint_decoder.clone(),
477                    self.issuance_requests.clone(),
478                    context.peer_tbs_pks.clone(),
479                ),
480                move |dbtx, blinded_signature_shares, old_state| {
481                    Box::pin(Self::transition_outcome_ready(
482                        client_ctx.clone(),
483                        dbtx,
484                        blinded_signature_shares,
485                        old_state,
486                        tbs_pks.clone(),
487                        balance_update_sender.clone(),
488                    ))
489                },
490            ),
491        ]
492    }
493
494    async fn await_tx_rejected(global_context: DynGlobalClientContext, common: MintOutputCommon) {
495        let txid = common.txid();
496        debug!(target: LOG_CLIENT_MODULE_MINT, %txid, "Awaiting tx rejection");
497        let accept_fut = global_context.await_tx_accepted(common.txid());
498        tokio::pin!(accept_fut);
499        let result = tokio::select! {
500            result = &mut accept_fut => result,
501            () = fedimint_core::runtime::sleep(Duration::from_secs(300)) => {
502                warn!(
503                    target: LOG_CLIENT_MODULE_MINT,
504                    %txid,
505                    "Transaction not accepted or rejected after 5 minutes, possibly stuck or never submitted",
506                );
507                accept_fut.await
508            }
509        };
510        if result.is_err() {
511            return;
512        }
513        std::future::pending::<()>().await;
514    }
515
516    async fn transition_tx_rejected(
517        client_ctx: ClientContext<MintClientModule>,
518        dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
519        old_state: MintOutputStateMachine,
520    ) -> MintOutputStateMachine {
521        assert_matches!(old_state.state, MintOutputStates::CreatedMulti(_));
522
523        client_ctx
524            .log_event(
525                &mut dbtx.module_tx(),
526                ReceivePaymentUpdateEvent {
527                    operation_id: old_state.common.operation_id,
528                    status: ReceivePaymentStatus::Rejected,
529                },
530            )
531            .await;
532
533        MintOutputStateMachine {
534            common: old_state.common,
535            state: MintOutputStates::Aborted(MintOutputStatesAborted),
536        }
537    }
538
539    async fn await_outcome_ready(
540        global_context: DynGlobalClientContext,
541        common: MintOutputCommon,
542        module_decoder: Decoder,
543        issuance_requests: BTreeMap<u64, (Amount, NoteIssuanceRequest)>,
544        tbs_pks: BTreeMap<PeerId, Tiered<PublicKeyShare>>,
545    ) -> Vec<(u64, BTreeMap<PeerId, BlindedSignatureShare>)> {
546        let txid = common.txid();
547        let out_idx = common.out_point_range.start_idx();
548        debug!(target: LOG_CLIENT_MODULE_MINT, %txid, %out_idx, "Awaiting output outcome");
549        let api = global_context.api();
550        let core_api_version = global_context.core_api_version().await;
551
552        // Use the new efficient batch endpoint if the server supports it
553        if VERSION_THAT_INTRODUCED_AWAIT_OUTPUTS_OUTCOMES <= core_api_version {
554            Self::await_outcome_ready_batch(api, common, module_decoder, issuance_requests, tbs_pks)
555                .await
556        } else {
557            // Fall back to the old sequential approach for older servers
558            Self::await_outcome_ready_legacy(
559                api,
560                common,
561                module_decoder,
562                issuance_requests,
563                tbs_pks,
564            )
565            .await
566        }
567    }
568
569    /// Efficient batch version using `AWAIT_OUTPUTS_OUTCOMES_ENDPOINT`
570    async fn await_outcome_ready_batch(
571        api: &fedimint_api_client::api::DynGlobalApi,
572        common: MintOutputCommon,
573        module_decoder: Decoder,
574        issuance_requests: BTreeMap<u64, (Amount, NoteIssuanceRequest)>,
575        tbs_pks: BTreeMap<PeerId, Tiered<PublicKeyShare>>,
576    ) -> Vec<(u64, BTreeMap<PeerId, BlindedSignatureShare>)> {
577        if issuance_requests.is_empty() {
578            return vec![];
579        }
580
581        // Use custom query strategy to collect and verify outcomes from all guardians
582        let issuance_requests_clone = issuance_requests.clone();
583        let verified_shares_per_output: BTreeMap<PeerId, Vec<Option<BlindedSignatureShare>>> = api
584            .request_with_strategy_retry(
585                FilterMapThreshold::new(
586                    move |peer, outcomes: Vec<Option<SerdeOutputOutcome>>| {
587                        // Verify the response has the expected length
588                        if outcomes.len() != common.out_point_range.count() {
589                            return Err(ServerError::InvalidResponse(format!(
590                                "Peer {peer} returned {} outcomes but expected {}",
591                                outcomes.len(),
592                                common.out_point_range.count()
593                            )));
594                        }
595
596                        // Verify each outcome and extract valid blind signature shares
597                        // If ANY share is invalid, reject the ENTIRE response from this guardian
598                        let mut verified_shares = Vec::with_capacity(outcomes.len());
599                        for (relative_idx, outcome_opt) in outcomes.into_iter().enumerate() {
600                            let out_idx = common.out_point_range.start_idx() + relative_idx as u64;
601
602                            // We should have an issuance request for every output in the range
603                            let (amount, issuance_request) = issuance_requests_clone
604                                .get(&out_idx)
605                                .expect("issuance_request must exist for every output in range");
606
607                            let share = if let Some(outcome) = outcome_opt {
608                                match verify_blind_share(
609                                    peer,
610                                    &outcome,
611                                    *amount,
612                                    issuance_request.blinded_message(),
613                                    &module_decoder,
614                                    &tbs_pks,
615                                ) {
616                                    Ok(share) => Some(share),
617                                    Err(err) => {
618                                        // Invalid share - reject entire response from this guardian
619                                        tracing::warn!(
620                                            target: LOG_CLIENT_MODULE_MINT,
621                                            %peer,
622                                            err = %err.fmt_compact(),
623                                            out_point = %OutPoint { txid: common.txid(), out_idx},
624                                            "Invalid signature share from peer"
625                                        );
626                                        return Err(ServerError::InvalidResponse(
627                                            err.fmt_compact().to_string(),
628                                        ));
629                                    }
630                                }
631                            } else {
632                                None
633                            };
634
635                            verified_shares.push(share);
636                        }
637
638                        Ok(verified_shares)
639                    },
640                    api.all_peers().to_num_peers(),
641                ),
642                AWAIT_OUTPUTS_OUTCOMES_ENDPOINT.to_owned(),
643                ApiRequestErased::new(common.out_point_range),
644            )
645            .await;
646
647        // Reorganize from per-peer to per-output
648        let threshold = api.all_peers().to_num_peers().threshold();
649        let mut ret = vec![];
650
651        for (out_idx, (_amount, _issuance_request)) in issuance_requests {
652            let relative_idx = (out_idx - common.out_point_range.start_idx()) as usize;
653            let mut blinded_sig_shares = BTreeMap::new();
654
655            // Collect verified shares from all peers for this output
656            for (peer_id, shares) in &verified_shares_per_output {
657                if let Some(Some(share)) = shares.get(relative_idx) {
658                    blinded_sig_shares.insert(*peer_id, *share);
659                }
660            }
661
662            assert!(threshold <= blinded_sig_shares.len());
663            ret.push((out_idx, blinded_sig_shares));
664        }
665
666        ret
667    }
668
669    /// Legacy sequential version for backwards compatibility
670    async fn await_outcome_ready_legacy(
671        api: &fedimint_api_client::api::DynGlobalApi,
672        common: MintOutputCommon,
673        module_decoder: Decoder,
674        issuance_requests: BTreeMap<u64, (Amount, NoteIssuanceRequest)>,
675        tbs_pks: BTreeMap<PeerId, Tiered<PublicKeyShare>>,
676    ) -> Vec<(u64, BTreeMap<PeerId, BlindedSignatureShare>)> {
677        let mut ret = vec![];
678        let mut issuance_requests_iter = issuance_requests.into_iter();
679
680        // Wait for the result of the first output only, to save server side
681        // resources
682        if let Some((out_idx, (amount, issuance_request))) = issuance_requests_iter.next() {
683            let module_decoder = module_decoder.clone();
684            let tbs_pks = tbs_pks.clone();
685
686            let blinded_sig_share = api
687                .request_with_strategy_retry(
688                    FilterMapThreshold::new(
689                        move |peer, outcome| {
690                            verify_blind_share(
691                                peer,
692                                &outcome,
693                                amount,
694                                issuance_request.blinded_message(),
695                                &module_decoder,
696                                &tbs_pks,
697                            )
698                            .map_err(|err| {
699                                ServerError::InvalidResponse(err.fmt_compact().to_string())
700                            })
701                        },
702                        api.all_peers().to_num_peers(),
703                    ),
704                    AWAIT_OUTPUT_OUTCOME_ENDPOINT.to_owned(),
705                    ApiRequestErased::new(OutPoint {
706                        txid: common.txid(),
707                        out_idx,
708                    }),
709                )
710                .await;
711
712            ret.push((out_idx, blinded_sig_share));
713        } else {
714            return vec![];
715        }
716
717        // We know the tx outcomes are ready, get all of them at once
718        ret.extend(
719            join_all(
720                issuance_requests_iter.map(|(out_idx, (amount, issuance_request))| {
721                    let module_decoder = module_decoder.clone();
722                    let tbs_pks = tbs_pks.clone();
723                    async move {
724                        let blinded_sig_share = api
725                            .request_with_strategy_retry(
726                                FilterMapThreshold::new(
727                                    move |peer, outcome| {
728                                        verify_blind_share(
729                                            peer,
730                                            &outcome,
731                                            amount,
732                                            issuance_request.blinded_message(),
733                                            &module_decoder,
734                                            &tbs_pks,
735                                        )
736                                        .map_err(|err| {
737                                            ServerError::InvalidResponse(
738                                                err.fmt_compact().to_string(),
739                                            )
740                                        })
741                                    },
742                                    api.all_peers().to_num_peers(),
743                                ),
744                                AWAIT_OUTPUT_OUTCOME_ENDPOINT.to_owned(),
745                                ApiRequestErased::new(OutPoint {
746                                    txid: common.txid(),
747                                    out_idx,
748                                }),
749                            )
750                            .await;
751
752                        (out_idx, blinded_sig_share)
753                    }
754                }),
755            )
756            .await,
757        );
758
759        ret
760    }
761
762    async fn transition_outcome_ready(
763        client_ctx: ClientContext<MintClientModule>,
764        dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
765        blinded_signature_shares: Vec<(u64, BTreeMap<PeerId, BlindedSignatureShare>)>,
766        old_state: MintOutputStateMachine,
767        tbs_pks: Tiered<AggregatePublicKey>,
768        balance_update_sender: tokio::sync::watch::Sender<()>,
769    ) -> MintOutputStateMachine {
770        // we combine the shares, finalize the issuance request with the blind signature
771        // and store the resulting note in the database
772
773        let mut amount_total = Amount::ZERO;
774        let MintOutputStates::CreatedMulti(created) = old_state.state else {
775            panic!("Unexpected prior state")
776        };
777
778        let mut spendable_notes: Vec<(Amount, SpendableNote)> = vec![];
779
780        // Note verification is relatively slow and CPU-bound, so parallelize them
781        blinded_signature_shares
782            .into_par_iter()
783            .map(|(out_idx, blinded_signature_shares)| {
784                let agg_blind_signature = aggregate_signature_shares(
785                    &blinded_signature_shares
786                        .into_iter()
787                        .map(|(peer, share)| (peer.to_usize() as u64, share))
788                        .collect(),
789                );
790
791                // this implies that the mint client config's public keys are inconsistent
792                let (amount, issuance_request) =
793                    created.issuance_requests.get(&out_idx).expect("Must have");
794
795                let amount_key = tbs_pks.tier(amount).expect("Must have keys for any amount");
796
797                let spendable_note = issuance_request.finalize(agg_blind_signature);
798
799                assert!(spendable_note.note().verify(*amount_key), "We checked all signature shares in the trigger future, so the combined signature has to be valid");
800
801                (*amount, spendable_note)
802            })
803            .collect_into_vec(&mut spendable_notes);
804
805        for (amount, spendable_note) in spendable_notes {
806            debug!(target: LOG_CLIENT_MODULE_MINT, amount = %amount, note=%spendable_note, "Adding new note from transaction output");
807
808            client_ctx
809                .log_event(
810                    &mut dbtx.module_tx(),
811                    NoteCreated {
812                        nonce: spendable_note.nonce(),
813                    },
814                )
815                .await;
816
817            amount_total += amount;
818            if let Some(note) = dbtx
819                .module_tx()
820                .insert_entry(
821                    &NoteKey {
822                        amount,
823                        nonce: spendable_note.nonce(),
824                    },
825                    &spendable_note.to_undecoded(),
826                )
827                .await
828            {
829                crit!(target: LOG_CLIENT_MODULE_MINT, %note, "E-cash note was replaced in DB");
830            }
831        }
832
833        client_ctx
834            .log_event(
835                &mut dbtx.module_tx(),
836                ReceivePaymentUpdateEvent {
837                    operation_id: old_state.common.operation_id,
838                    status: ReceivePaymentStatus::Success,
839                },
840            )
841            .await;
842
843        dbtx.module_tx()
844            .on_commit(move || balance_update_sender.send_replace(()));
845
846        MintOutputStateMachine {
847            common: old_state.common,
848            state: MintOutputStates::Succeeded(MintOutputStatesSucceeded {
849                amount: amount_total,
850            }),
851        }
852    }
853}
854
855/// # Panics
856/// If the given `outcome` is not a [`MintOutputOutcome::V0`] outcome.
857pub fn verify_blind_share(
858    peer: PeerId,
859    outcome: &SerdeOutputOutcome,
860    amount: Amount,
861    blinded_message: BlindedMessage,
862    decoder: &Decoder,
863    peer_tbs_pks: &BTreeMap<PeerId, Tiered<PublicKeyShare>>,
864) -> Result<BlindedSignatureShare, VerifyBlindShareError> {
865    let outcome = deserialize_outcome::<MintOutputOutcome>(outcome, decoder)?;
866
867    let blinded_signature_share = outcome
868        .ensure_v0_ref()
869        .expect("We only process output outcome versions created by ourselves")
870        .0;
871
872    let amount_key = peer_tbs_pks
873        .get(&peer)
874        .ok_or(VerifyBlindShareError::UnknownPeer { peer })?
875        .tier(&amount)
876        .map_err(|_| VerifyBlindShareError::InvalidAmountTier { amount })?;
877
878    if !tbs::verify_signature_share(blinded_message, blinded_signature_share, *amount_key) {
879        return Err(VerifyBlindShareError::InvalidSignature);
880    }
881
882    Ok(blinded_signature_share)
883}
884
885/// See [`MintOutputStates`]
886#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
887pub struct MintOutputStatesAborted;
888
889/// See [`MintOutputStates`]
890#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
891pub struct MintOutputStatesFailed {
892    pub error: String,
893}
894
895/// See [`MintOutputStates`]
896#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
897pub struct MintOutputStatesSucceeded {
898    pub amount: Amount,
899}
900
901/// Keeps the data to generate [`SpendableNote`] once the
902/// mint successfully processed the transaction signing the corresponding
903/// [`BlindNonce`].
904#[derive(Debug, Copy, Clone, PartialEq, Eq, Deserialize, Serialize, Encodable, Decodable)]
905pub struct NoteIssuanceRequest {
906    /// Spend key from which the note nonce (corresponding public key) is
907    /// derived
908    spend_key: Keypair,
909    /// Key to unblind the blind signature supplied by the mint for this note
910    blinding_key: BlindingKey,
911}
912
913impl hash::Hash for NoteIssuanceRequest {
914    fn hash<H: hash::Hasher>(&self, state: &mut H) {
915        self.spend_key.hash(state);
916        // ignore `blinding_key` as it doesn't impl Hash; `spend_key` has enough
917        // entropy anyway
918    }
919}
920impl NoteIssuanceRequest {
921    /// Generate a request session for a single note and returns it plus the
922    /// corresponding blinded message
923    pub fn new<C>(ctx: &Secp256k1<C>, secret: &DerivableSecret) -> (NoteIssuanceRequest, BlindNonce)
924    where
925        C: Signing,
926    {
927        let spend_key = secret.child_key(SPEND_KEY_CHILD_ID).to_secp_key(ctx);
928        let nonce = Nonce(spend_key.public_key());
929        let blinding_key = BlindingKey(secret.child_key(BLINDING_KEY_CHILD_ID).to_bls12_381_key());
930        let blinded_nonce = blind_message(nonce.to_message(), blinding_key);
931
932        let cr = NoteIssuanceRequest {
933            spend_key,
934            blinding_key,
935        };
936
937        (cr, BlindNonce(blinded_nonce))
938    }
939
940    /// Return nonce of the e-cash note being requested
941    pub fn nonce(&self) -> Nonce {
942        Nonce(self.spend_key.public_key())
943    }
944
945    pub fn blinded_message(&self) -> BlindedMessage {
946        blind_message(self.nonce().to_message(), self.blinding_key)
947    }
948
949    /// Use the blind signature to create spendable e-cash notes
950    pub fn finalize(&self, blinded_signature: BlindedSignature) -> SpendableNote {
951        SpendableNote {
952            signature: unblind_signature(self.blinding_key, blinded_signature),
953            spend_key: self.spend_key,
954        }
955    }
956
957    pub fn blinding_key(&self) -> &BlindingKey {
958        &self.blinding_key
959    }
960
961    pub fn spend_key(&self) -> &Keypair {
962        &self.spend_key
963    }
964}