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