Skip to main content

fedimint_mint_client/
output.rs

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