Skip to main content

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