fedimint_mint_client/backup/
recovery.rs

1use std::cmp::max;
2use std::collections::BTreeMap;
3use std::fmt;
4use std::ops::Add;
5
6use fedimint_client_module::module::init::ClientModuleRecoverArgs;
7use fedimint_client_module::module::init::recovery::{
8    RecoveryFromHistory, RecoveryFromHistoryCommon,
9};
10use fedimint_client_module::module::{ClientContext, OutPointRange};
11use fedimint_core::bitcoin::hashes::hash160;
12use fedimint_core::core::OperationId;
13use fedimint_core::db::{DatabaseTransaction, IDatabaseTransactionOpsCoreTyped as _};
14use fedimint_core::encoding::{Decodable, Encodable};
15use fedimint_core::secp256k1::SECP256K1;
16use fedimint_core::{
17    Amount, NumPeersExt, OutPoint, PeerId, Tiered, TieredMulti, apply, async_trait_maybe_send,
18};
19use fedimint_derive_secret::DerivableSecret;
20use fedimint_logging::{LOG_CLIENT_MODULE_MINT, LOG_CLIENT_RECOVERY, LOG_CLIENT_RECOVERY_MINT};
21use fedimint_mint_common::{MintInput, MintOutput, Nonce};
22use serde::{Deserialize, Serialize};
23use tbs::{AggregatePublicKey, BlindedMessage, PublicKeyShare};
24use threshold_crypto::G1Affine;
25use tracing::{debug, info, trace, warn};
26
27use super::EcashBackup;
28use crate::backup::EcashBackupV0;
29use crate::client_db::{
30    NextECashNoteIndexKey, NoteKey, RecoveryFinalizedKey, RecoveryStateKey, ReusedNoteIndices,
31};
32use crate::event::NoteCreated;
33use crate::output::{
34    MintOutputCommon, MintOutputStateMachine, MintOutputStatesCreated, NoteIssuanceRequest,
35};
36use crate::{MintClientInit, MintClientModule, MintClientStateMachines, NoteIndex, SpendableNote};
37
38#[derive(Clone, Debug)]
39pub struct MintRecovery {
40    state: MintRecoveryStateV2,
41    secret: DerivableSecret,
42    client_ctx: ClientContext<MintClientModule>,
43}
44
45#[apply(async_trait_maybe_send!)]
46impl RecoveryFromHistory for MintRecovery {
47    type Init = MintClientInit;
48
49    async fn new(
50        _init: &Self::Init,
51        args: &ClientModuleRecoverArgs<Self::Init>,
52        snapshot: Option<&EcashBackup>,
53    ) -> anyhow::Result<(Self, u64)> {
54        let snapshot_v0 = match snapshot {
55            Some(EcashBackup::V0(snapshot_v0)) => Some(snapshot_v0),
56            Some(EcashBackup::Default { variant, .. }) => {
57                warn!(%variant, "Unsupported backup variant. Ignoring mint backup.");
58                None
59            }
60            None => None,
61        };
62
63        let config = args.cfg();
64
65        let secret = args.module_root_secret().clone();
66        let (snapshot, starting_session) = if let Some(snapshot) = snapshot_v0 {
67            (snapshot.clone(), snapshot.session_count)
68        } else {
69            (EcashBackupV0::new_empty(), 0)
70        };
71
72        Ok((
73            MintRecovery {
74                state: MintRecoveryStateV2::from_backup(
75                    snapshot,
76                    100,
77                    config.tbs_pks.clone(),
78                    config.peer_tbs_pks.clone(),
79                    &secret,
80                ),
81                secret,
82                client_ctx: args.context(),
83            },
84            starting_session,
85        ))
86    }
87
88    async fn load_dbtx(
89        _init: &Self::Init,
90        dbtx: &mut DatabaseTransaction<'_>,
91        args: &ClientModuleRecoverArgs<Self::Init>,
92    ) -> anyhow::Result<Option<(Self, RecoveryFromHistoryCommon)>> {
93        dbtx.ensure_isolated()
94            .expect("Must be in prefixed database");
95        Ok(dbtx
96            .get_value(&RecoveryStateKey)
97            .await
98            .and_then(|(state, common)| {
99                if let MintRecoveryState::V2(state) = state {
100                    Some((state, common))
101                } else {
102                    warn!(target: LOG_CLIENT_RECOVERY, "Found unknown version recovery state. Ignoring");
103                    None
104                }
105            })
106            .map(|(state, common)| {
107                (
108                    MintRecovery {
109                        state,
110                        secret: args.module_root_secret().clone(),
111                        client_ctx: args.context(),
112                    },
113                    common,
114                )
115            }))
116    }
117
118    async fn store_dbtx(
119        &self,
120        dbtx: &mut DatabaseTransaction<'_>,
121        common: &RecoveryFromHistoryCommon,
122    ) {
123        dbtx.ensure_isolated()
124            .expect("Must be in prefixed database");
125        dbtx.insert_entry(
126            &RecoveryStateKey,
127            &(MintRecoveryState::V2(self.state.clone()), common.clone()),
128        )
129        .await;
130    }
131
132    async fn delete_dbtx(&self, dbtx: &mut DatabaseTransaction<'_>) {
133        dbtx.remove_entry(&RecoveryStateKey).await;
134    }
135
136    async fn load_finalized(dbtx: &mut DatabaseTransaction<'_>) -> Option<bool> {
137        dbtx.get_value(&RecoveryFinalizedKey).await
138    }
139
140    async fn store_finalized(dbtx: &mut DatabaseTransaction<'_>, state: bool) {
141        dbtx.insert_entry(&RecoveryFinalizedKey, &state).await;
142    }
143
144    async fn handle_input(
145        &mut self,
146        _client_ctx: &ClientContext<MintClientModule>,
147        _idx: usize,
148        input: &MintInput,
149        _session_idx: u64,
150    ) -> anyhow::Result<()> {
151        self.state.handle_input(input);
152        Ok(())
153    }
154
155    async fn handle_output(
156        &mut self,
157        _client_ctx: &ClientContext<MintClientModule>,
158        out_point: OutPoint,
159        output: &MintOutput,
160        _session_idx: u64,
161    ) -> anyhow::Result<()> {
162        self.state.handle_output(out_point, output, &self.secret);
163        Ok(())
164    }
165
166    /// Handle session outcome, adjusting the current state
167    async fn finalize_dbtx(&self, dbtx: &mut DatabaseTransaction<'_>) -> anyhow::Result<()> {
168        let finalized = self.state.clone().finalize();
169
170        let restored_amount = finalized
171            .unconfirmed_notes
172            .iter()
173            .map(|entry| entry.1)
174            .sum::<Amount>()
175            + finalized.spendable_notes.total_amount();
176
177        info!(
178            amount = %restored_amount,
179            burned_total = %finalized.burned_total,
180            "Finalizing mint recovery"
181        );
182
183        dbtx.insert_new_entry(&ReusedNoteIndices, &finalized.reused_note_indices)
184            .await;
185        debug!(
186            target: LOG_CLIENT_RECOVERY_MINT,
187            len = finalized.spendable_notes.count_items(),
188            "Restoring spendable notes"
189        );
190        for (amount, note) in finalized.spendable_notes.into_iter_items() {
191            let key = NoteKey {
192                amount,
193                nonce: note.nonce(),
194            };
195            debug!(target: LOG_CLIENT_MODULE_MINT, %amount, %note, "Restoring note");
196            self.client_ctx
197                .log_event(
198                    dbtx,
199                    NoteCreated {
200                        nonce: note.nonce(),
201                    },
202                )
203                .await;
204            dbtx.insert_new_entry(&key, &note.to_undecoded()).await;
205        }
206
207        for (amount, note_idx) in finalized.next_note_idx.iter() {
208            debug!(
209                target: LOG_CLIENT_RECOVERY_MINT,
210                %amount,
211                %note_idx,
212                "Restoring NextECashNodeIndex"
213            );
214            dbtx.insert_entry(&NextECashNoteIndexKey(amount), &note_idx.as_u64())
215                .await;
216        }
217
218        debug!(
219            target: LOG_CLIENT_RECOVERY_MINT,
220            len = finalized.unconfirmed_notes.len(),
221            "Restoring unconfirmed notes state machines"
222        );
223
224        for (out_point, amount, issuance_request) in finalized.unconfirmed_notes {
225            self.client_ctx
226                .add_state_machines_dbtx(
227                    dbtx,
228                    self.client_ctx
229                        .map_dyn(vec![MintClientStateMachines::Output(
230                            MintOutputStateMachine {
231                                common: MintOutputCommon {
232                                    operation_id: OperationId::new_random(),
233                                    out_point_range: OutPointRange::new_single(
234                                        out_point.txid,
235                                        out_point.out_idx,
236                                    )
237                                    .expect("Can't overflow"),
238                                },
239                                state: crate::output::MintOutputStates::Created(
240                                    MintOutputStatesCreated {
241                                        amount,
242                                        issuance_request,
243                                    },
244                                ),
245                            },
246                        )])
247                        .collect(),
248                )
249                .await?;
250        }
251
252        debug!(
253            target: LOG_CLIENT_RECOVERY_MINT,
254            "Mint module recovery finalized"
255        );
256
257        Ok(())
258    }
259}
260
261#[derive(Debug, Clone)]
262pub struct EcashRecoveryFinalState {
263    pub spendable_notes: TieredMulti<SpendableNote>,
264    /// Unsigned notes
265    pub unconfirmed_notes: Vec<(OutPoint, Amount, NoteIssuanceRequest)>,
266    /// Note index to derive next note in a given amount tier
267    pub next_note_idx: Tiered<NoteIndex>,
268    /// Total burned amount
269    pub burned_total: Amount,
270    /// Note indices that were reused.
271    pub reused_note_indices: Vec<(Amount, NoteIndex)>,
272}
273
274/// Newtype over [`BlindedMessage`] to enable `Ord`
275#[derive(
276    Debug, Clone, Eq, PartialEq, PartialOrd, Ord, Decodable, Encodable, Serialize, Deserialize,
277)]
278struct CompressedBlindedMessage(#[serde(with = "serde_big_array::BigArray")] [u8; 48]);
279
280impl From<BlindedMessage> for CompressedBlindedMessage {
281    fn from(value: BlindedMessage) -> Self {
282        Self(value.0.to_compressed())
283    }
284}
285
286impl From<CompressedBlindedMessage> for BlindedMessage {
287    fn from(value: CompressedBlindedMessage) -> Self {
288        BlindedMessage(
289            std::convert::Into::<Option<G1Affine>>::into(G1Affine::from_compressed(&value.0))
290                .expect("We never produce invalid compressed blinded messages"),
291        )
292    }
293}
294
295#[allow(clippy::large_enum_variant)]
296#[derive(Debug, Clone, Decodable, Encodable)]
297pub enum MintRecoveryState {
298    #[encodable(index = 2)]
299    V2(MintRecoveryStateV2),
300    // index 0 has incompatible db encoding, index 1 was skipped to match with V2
301    #[encodable_default]
302    Default { variant: u64, bytes: Vec<u8> },
303}
304
305/// The state machine used for fast-forwarding backup from point when it was
306/// taken to the present time by following epoch history items from the time the
307/// snapshot was taken.
308///
309/// The caller is responsible for creating it, and then feeding it in order all
310/// valid consensus items from the epoch history between time taken (or even
311/// somewhat before it) and present time.
312#[derive(Clone, Eq, PartialEq, Decodable, Encodable, Serialize, Deserialize)]
313pub struct MintRecoveryStateV2 {
314    spendable_notes: BTreeMap<Nonce, (Amount, SpendableNote)>,
315    /// Nonces that we track that are currently spendable.
316    pending_outputs: BTreeMap<Nonce, (OutPoint, Amount, NoteIssuanceRequest)>,
317    /// Next nonces that we expect might soon get used.
318    /// Once we see them, we move the tracking to `pending_outputs`
319    ///
320    /// Note: since looking up nonces is going to be the most common operation
321    /// the pool is kept shared (so only one lookup is enough), and
322    /// replenishment is done each time a note is consumed.
323    pending_nonces: BTreeMap<CompressedBlindedMessage, (NoteIssuanceRequest, NoteIndex, Amount)>,
324    /// Nonces that we have already used. Used for detecting double-used nonces
325    /// (accidentally burning funds).
326    used_nonces: BTreeMap<CompressedBlindedMessage, (NoteIssuanceRequest, NoteIndex, Amount)>,
327    /// Note indices that were reused.
328    reused_note_indices: Vec<(Amount, NoteIndex)>,
329    /// Total amount probably burned due to re-using nonces
330    burned_total: Amount,
331    /// Tail of `pending`. `pending_notes` is filled by generating note with
332    /// this index and incrementing it.
333    next_pending_note_idx: Tiered<NoteIndex>,
334    /// `LastECashNoteIndex` but tracked in flight. Basically max index of any
335    /// note that got a partial sig from the federation (initialled from the
336    /// backup value). TODO: One could imagine a case where the note was
337    /// issued but not get any partial sigs yet. Very unlikely in real life
338    /// scenario, but worth considering.
339    last_used_nonce_idx: Tiered<NoteIndex>,
340    /// Threshold
341    threshold: u64,
342    /// Public key shares for each peer
343    ///
344    /// Used to validate contributed consensus items
345    pub_key_shares: BTreeMap<PeerId, Tiered<PublicKeyShare>>,
346    /// Aggregate public key for each amount tier
347    tbs_pks: Tiered<AggregatePublicKey>,
348    /// The number of nonces we look-ahead when looking for mints (per each
349    /// amount).
350    gap_limit: u64,
351}
352
353impl fmt::Debug for MintRecoveryStateV2 {
354    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
355        f.write_fmt(format_args!(
356            "MintRestoreInProgressState(pending_outputs: {}, pending_nonces: {}, used_nonces: {}, burned_total: {})",
357            self.pending_outputs.len(),
358            self.pending_nonces.len(),
359            self.used_nonces.len(),
360            self.burned_total,
361        ))
362    }
363}
364
365impl MintRecoveryStateV2 {
366    pub fn from_backup(
367        backup: EcashBackupV0,
368        gap_limit: u64,
369        tbs_pks: Tiered<AggregatePublicKey>,
370        pub_key_shares: BTreeMap<PeerId, Tiered<PublicKeyShare>>,
371        secret: &DerivableSecret,
372    ) -> Self {
373        let amount_tiers: Vec<_> = tbs_pks.tiers().copied().collect();
374        let mut s = Self {
375            spendable_notes: backup
376                .spendable_notes
377                .into_iter_items()
378                .map(|(amount, note)| (note.nonce(), (amount, note)))
379                .collect(),
380            pending_outputs: backup
381                .pending_notes
382                .into_iter()
383                .map(|(outpoint, amount, issuance_request)| {
384                    (
385                        issuance_request.nonce(),
386                        (outpoint, amount, issuance_request),
387                    )
388                })
389                .collect(),
390            reused_note_indices: Vec::new(),
391            pending_nonces: BTreeMap::default(),
392            used_nonces: BTreeMap::default(),
393            burned_total: Amount::ZERO,
394            next_pending_note_idx: backup.next_note_idx.clone(),
395            last_used_nonce_idx: backup
396                .next_note_idx
397                .into_iter()
398                .filter_map(|(a, idx)| idx.prev().map(|idx| (a, idx)))
399                .collect(),
400            threshold: pub_key_shares.to_num_peers().threshold() as u64,
401            gap_limit,
402            tbs_pks,
403            pub_key_shares,
404        };
405
406        for amount in amount_tiers {
407            s.fill_initial_pending_nonces(amount, secret);
408        }
409
410        s
411    }
412
413    /// Fill each tier pool to the gap limit
414    fn fill_initial_pending_nonces(&mut self, amount: Amount, secret: &DerivableSecret) {
415        for _ in 0..self.gap_limit {
416            self.add_next_pending_nonce_in_pending_pool(amount, secret);
417        }
418    }
419
420    /// Add next nonce from `amount` tier to the `next_pending_note_idx`
421    fn add_next_pending_nonce_in_pending_pool(&mut self, amount: Amount, secret: &DerivableSecret) {
422        let note_idx_ref = self.next_pending_note_idx.get_mut_or_default(amount);
423
424        let (note_issuance_request, blind_nonce) = NoteIssuanceRequest::new(
425            fedimint_core::secp256k1::SECP256K1,
426            &MintClientModule::new_note_secret_static(secret, amount, *note_idx_ref),
427        );
428        assert!(
429            self.pending_nonces
430                .insert(
431                    blind_nonce.0.into(),
432                    (note_issuance_request, *note_idx_ref, amount)
433                )
434                .is_none()
435        );
436
437        note_idx_ref.advance();
438    }
439
440    pub fn handle_input(&mut self, input: &MintInput) {
441        match input {
442            MintInput::V0(input) => {
443                // We attempt to delete any nonce we see as spent, simple
444                self.pending_outputs.remove(&input.note.nonce);
445                self.spendable_notes.remove(&input.note.nonce);
446            }
447            MintInput::Default { variant, .. } => {
448                trace!("Ignoring future mint input variant {variant}");
449            }
450        }
451    }
452
453    pub fn handle_output(
454        &mut self,
455        out_point: OutPoint,
456        output: &MintOutput,
457        secret: &DerivableSecret,
458    ) {
459        let output = match output {
460            MintOutput::V0(output) => output,
461            MintOutput::Default { variant, .. } => {
462                trace!("Ignoring future mint output variant {variant}");
463                return;
464            }
465        };
466
467        if let Some((_issuance_request, note_idx, amount)) =
468            self.used_nonces.get(&output.blind_nonce.0.into())
469        {
470            self.burned_total += *amount;
471            self.reused_note_indices.push((*amount, *note_idx));
472            warn!(
473                target: LOG_CLIENT_RECOVERY_MINT,
474                %note_idx,
475                %amount,
476                burned_total = %self.burned_total,
477                "Detected reused nonce during recovery. This means client probably burned funds in the past."
478            );
479        }
480        // There is nothing preventing other users from creating valid
481        // transactions mining notes to our own blind nonce, possibly
482        // even racing with us. Including amount in blind nonce
483        // derivation helps us avoid accidentally using a nonce mined
484        // for as smaller amount, but it doesn't eliminate completely
485        // the possibility that we might use a note mined in a different
486        // transaction, that our original one.
487        // While it is harmless to us, as such duplicated blind nonces are
488        // effective as good the as the original ones (same amount), it
489        // breaks the assumption that all our blind nonces in an our
490        // output need to be in the pending pool. It forces us to be
491        // greedy no matter what and take what we can, and just report
492        // anything suspicious.
493
494        if let Some((issuance_request, note_idx, pending_amount)) =
495            self.pending_nonces.remove(&output.blind_nonce.0.into())
496        {
497            // the moment we see our blind nonce in the epoch history, correctly or
498            // incorrectly used, we know that we must have used
499            // already
500            self.observe_nonce_idx_being_used(pending_amount, note_idx, secret);
501
502            if pending_amount == output.amount {
503                self.used_nonces.insert(
504                    output.blind_nonce.0.into(),
505                    (issuance_request, note_idx, pending_amount),
506                );
507
508                self.pending_outputs.insert(
509                    issuance_request.nonce(),
510                    (out_point, output.amount, issuance_request),
511                );
512            } else {
513                // put it back, incorrect amount
514                self.pending_nonces.insert(
515                    output.blind_nonce.0.into(),
516                    (issuance_request, note_idx, pending_amount),
517                );
518                warn!(
519                    target: LOG_CLIENT_RECOVERY_MINT,
520                    output = ?out_point,
521                    blind_nonce = ?output.blind_nonce.0,
522                    expected_amount = %pending_amount,
523                    found_amount = %output.amount,
524                    "Transaction output contains blind nonce that looks like ours but is of the wrong amount. Ignoring."
525                );
526            }
527        }
528    }
529
530    /// React to a valid pending nonce being tracked being used in the epoch
531    /// history
532    ///
533    /// (Possibly) increment the `self.last_mined_nonce_idx`, then replenish the
534    /// pending pool to always maintain at least `gap_limit` of pending
535    /// nonces in each amount tier.
536    fn observe_nonce_idx_being_used(
537        &mut self,
538        amount: Amount,
539        note_idx: NoteIndex,
540        secret: &DerivableSecret,
541    ) {
542        self.last_used_nonce_idx.insert(
543            amount,
544            max(
545                self.last_used_nonce_idx
546                    .get(amount)
547                    .copied()
548                    .unwrap_or_default(),
549                note_idx,
550            ),
551        );
552
553        while self.next_pending_note_idx.get_mut_or_default(amount).0
554            < self.gap_limit
555                + self
556                    .last_used_nonce_idx
557                    .get(amount)
558                    .expect("must be there already")
559                    .0
560        {
561            self.add_next_pending_nonce_in_pending_pool(amount, secret);
562        }
563    }
564
565    pub fn finalize(self) -> EcashRecoveryFinalState {
566        EcashRecoveryFinalState {
567            spendable_notes: self.spendable_notes.into_values().collect(),
568            unconfirmed_notes: self.pending_outputs.into_values().collect(),
569            // next note idx is the last one detected as used + 1
570            next_note_idx: self
571                .last_used_nonce_idx
572                .iter()
573                .map(|(amount, value)| (amount, value.next()))
574                .collect(),
575            reused_note_indices: self.reused_note_indices,
576            burned_total: self.burned_total,
577        }
578    }
579}
580
581const GAP_LIMIT: u64 = 100;
582
583/// Recovery state that can be checkpointed and resumed (slice-based recovery)
584#[derive(Clone, Debug, Encodable, Decodable)]
585pub struct RecoveryStateV2 {
586    /// Next item index to download
587    pub next_index: u64,
588    /// Total items (for progress calculation)
589    pub total_items: u64,
590    /// Pending outputs - notes we've seen issued and are waiting to collect
591    pending_outputs: BTreeMap<hash160::Hash, (Amount, NoteIssuanceRequest)>,
592    /// Next nonces that we expect might soon get used.
593    pending_nonces: BTreeMap<(Amount, hash160::Hash), (NoteIssuanceRequest, u64)>,
594    /// Tail of pending. `pending_nonces` is filled by generating note with
595    /// this index and incrementing it.
596    next_pending_note_idx: BTreeMap<Amount, u64>,
597    /// `LastECashNoteIndex` but tracked in flight - max index of any note
598    /// that got a partial sig from the federation
599    last_used_nonce_idx: BTreeMap<Amount, u64>,
600}
601
602impl RecoveryStateV2 {
603    pub fn new(total_items: u64, amount_tiers: Vec<Amount>, secret: &DerivableSecret) -> Self {
604        let mut state = Self {
605            next_index: 0,
606            total_items,
607            pending_outputs: BTreeMap::default(),
608            pending_nonces: BTreeMap::default(),
609            next_pending_note_idx: BTreeMap::default(),
610            last_used_nonce_idx: BTreeMap::default(),
611        };
612
613        for amount in amount_tiers {
614            state.add_pending_nonces(amount, GAP_LIMIT, secret);
615        }
616
617        state
618    }
619
620    fn add_pending_nonces(&mut self, amount: Amount, count: u64, secret: &DerivableSecret) {
621        let next_idx = self
622            .next_pending_note_idx
623            .get(&amount)
624            .copied()
625            .unwrap_or_default();
626
627        self.next_pending_note_idx.insert(amount, next_idx + count);
628
629        for i in next_idx..(next_idx + count) {
630            let secret = MintClientModule::new_note_secret_static(secret, amount, NoteIndex(i));
631
632            let (request, blind_nonce) = NoteIssuanceRequest::new(SECP256K1, &secret);
633
634            let hash = blind_nonce.consensus_hash::<hash160::Hash>();
635
636            self.pending_nonces.insert((amount, hash), (request, i));
637        }
638    }
639
640    pub fn handle_output(
641        &mut self,
642        amount: Amount,
643        blind_nonce_hash: hash160::Hash,
644        secret: &DerivableSecret,
645    ) {
646        if let Some((request, idx)) = self.pending_nonces.remove(&(amount, blind_nonce_hash)) {
647            self.observe_nonce_idx_being_used(amount, idx, secret);
648
649            let hash = request.nonce().consensus_hash::<hash160::Hash>();
650
651            self.pending_outputs.insert(hash, (amount, request));
652        }
653    }
654
655    pub fn handle_input(&mut self, nonce_hash: hash160::Hash) {
656        self.pending_outputs.remove(&nonce_hash);
657    }
658
659    fn observe_nonce_idx_being_used(&mut self, amount: Amount, idx: u64, secret: &DerivableSecret) {
660        let last_used_nonce_idx = self
661            .last_used_nonce_idx
662            .get(&amount)
663            .copied()
664            .unwrap_or(idx);
665
666        self.last_used_nonce_idx
667            .insert(amount, max(last_used_nonce_idx, idx));
668
669        let next_pending_note_idx = self
670            .next_pending_note_idx
671            .get(&amount)
672            .copied()
673            .unwrap_or_default();
674
675        let missing = last_used_nonce_idx
676            .add(GAP_LIMIT)
677            .saturating_sub(next_pending_note_idx);
678
679        if missing > 0 {
680            self.add_pending_nonces(amount, missing, secret);
681        }
682    }
683
684    pub fn finalize(self) -> RecoveryStateV2Finalized {
685        RecoveryStateV2Finalized {
686            pending_notes: self.pending_outputs.into_values().collect(),
687            next_note_idx: self
688                .last_used_nonce_idx
689                .into_iter()
690                .map(|(amount, idx)| (amount, NoteIndex(idx + 1)))
691                .collect(),
692        }
693    }
694}
695
696pub struct RecoveryStateV2Finalized {
697    /// Pending notes that need state machines to collect signatures
698    pub pending_notes: Vec<(Amount, NoteIssuanceRequest)>,
699    /// Next note index per amount tier (for restoring `NextECashNoteIndexKey`)
700    pub next_note_idx: BTreeMap<Amount, NoteIndex>,
701}