use std::cmp::max;
use std::collections::BTreeMap;
use std::fmt;
use fedimint_client::module::init::recovery::{RecoveryFromHistory, RecoveryFromHistoryCommon};
use fedimint_client::module::init::ClientModuleRecoverArgs;
use fedimint_client::module::{ClientContext, OutPointRange};
use fedimint_core::core::OperationId;
use fedimint_core::db::{DatabaseTransaction, IDatabaseTransactionOpsCoreTyped as _};
use fedimint_core::encoding::{Decodable, Encodable};
use fedimint_core::{
apply, async_trait_maybe_send, Amount, NumPeersExt, OutPoint, PeerId, Tiered, TieredMulti,
};
use fedimint_derive_secret::DerivableSecret;
use fedimint_logging::{LOG_CLIENT_MODULE_MINT, LOG_CLIENT_RECOVERY, LOG_CLIENT_RECOVERY_MINT};
use fedimint_mint_common::{MintInput, MintOutput, Nonce};
use serde::{Deserialize, Serialize};
use tbs::{AggregatePublicKey, BlindedMessage, PublicKeyShare};
use threshold_crypto::G1Affine;
use tracing::{debug, info, trace, warn};
use super::EcashBackup;
use crate::backup::EcashBackupV0;
use crate::client_db::{
NextECashNoteIndexKey, NoteKey, RecoveryFinalizedKey, RecoveryStateKey, ReusedNoteIndices,
};
use crate::event::NoteCreated;
use crate::output::{
MintOutputCommon, MintOutputStateMachine, MintOutputStatesCreated, NoteIssuanceRequest,
};
use crate::{MintClientInit, MintClientModule, MintClientStateMachines, NoteIndex, SpendableNote};
#[derive(Clone, Debug)]
pub struct MintRecovery {
state: MintRecoveryStateV2,
secret: DerivableSecret,
client_ctx: ClientContext<MintClientModule>,
}
#[apply(async_trait_maybe_send!)]
impl RecoveryFromHistory for MintRecovery {
type Init = MintClientInit;
async fn new(
_init: &Self::Init,
args: &ClientModuleRecoverArgs<Self::Init>,
snapshot: Option<&EcashBackup>,
) -> anyhow::Result<(Self, u64)> {
let snapshot_v0 = match snapshot {
Some(EcashBackup::V0(snapshot_v0)) => Some(snapshot_v0),
Some(EcashBackup::Default { variant, .. }) => {
warn!(%variant, "Unsupported backup variant. Ignoring mint backup.");
None
}
None => None,
};
let config = args.cfg();
let secret = args.module_root_secret().clone();
let (snapshot, starting_session) = if let Some(snapshot) = snapshot_v0 {
(snapshot.clone(), snapshot.session_count)
} else {
(EcashBackupV0::new_empty(), 0)
};
Ok((
MintRecovery {
state: MintRecoveryStateV2::from_backup(
snapshot,
100,
config.tbs_pks.clone(),
config.peer_tbs_pks.clone(),
&secret,
),
secret,
client_ctx: args.context(),
},
starting_session,
))
}
async fn load_dbtx(
_init: &Self::Init,
dbtx: &mut DatabaseTransaction<'_>,
args: &ClientModuleRecoverArgs<Self::Init>,
) -> anyhow::Result<Option<(Self, RecoveryFromHistoryCommon)>> {
dbtx.ensure_isolated()
.expect("Must be in prefixed database");
Ok(dbtx
.get_value(&RecoveryStateKey)
.await
.and_then(|(state, common)| {
if let MintRecoveryState::V2(state) = state {
Some((state, common))
} else {
warn!(target: LOG_CLIENT_RECOVERY, "Found unknown version recovery state. Ignoring");
None
}
})
.map(|(state, common)| {
(
MintRecovery {
state,
secret: args.module_root_secret().clone(),
client_ctx: args.context(),
},
common,
)
}))
}
async fn store_dbtx(
&self,
dbtx: &mut DatabaseTransaction<'_>,
common: &RecoveryFromHistoryCommon,
) {
dbtx.ensure_isolated()
.expect("Must be in prefixed database");
dbtx.insert_entry(
&RecoveryStateKey,
&(MintRecoveryState::V2(self.state.clone()), common.clone()),
)
.await;
}
async fn delete_dbtx(&self, dbtx: &mut DatabaseTransaction<'_>) {
dbtx.remove_entry(&RecoveryStateKey).await;
}
async fn load_finalized(dbtx: &mut DatabaseTransaction<'_>) -> Option<bool> {
dbtx.get_value(&RecoveryFinalizedKey).await
}
async fn store_finalized(dbtx: &mut DatabaseTransaction<'_>, state: bool) {
dbtx.insert_entry(&RecoveryFinalizedKey, &state).await;
}
async fn handle_input(
&mut self,
_client_ctx: &ClientContext<MintClientModule>,
_idx: usize,
input: &MintInput,
_session_idx: u64,
) -> anyhow::Result<()> {
self.state.handle_input(input);
Ok(())
}
async fn handle_output(
&mut self,
_client_ctx: &ClientContext<MintClientModule>,
out_point: OutPoint,
output: &MintOutput,
_session_idx: u64,
) -> anyhow::Result<()> {
self.state.handle_output(out_point, output, &self.secret);
Ok(())
}
async fn finalize_dbtx(&self, dbtx: &mut DatabaseTransaction<'_>) -> anyhow::Result<()> {
let finalized = self.state.clone().finalize();
let restored_amount = finalized
.unconfirmed_notes
.iter()
.map(|entry| entry.1)
.sum::<Amount>()
+ finalized.spendable_notes.total_amount();
info!(
amount = %restored_amount,
burned_total = %finalized.burned_total,
"Finalizing mint recovery"
);
dbtx.insert_new_entry(&ReusedNoteIndices, &finalized.reused_note_indices)
.await;
debug!(
target: LOG_CLIENT_RECOVERY_MINT,
len = finalized.spendable_notes.count_items(),
"Restoring spendable notes"
);
for (amount, note) in finalized.spendable_notes.into_iter_items() {
let key = NoteKey {
amount,
nonce: note.nonce(),
};
debug!(target: LOG_CLIENT_MODULE_MINT, %amount, %note, "Restoring note");
self.client_ctx
.log_event(
dbtx,
NoteCreated {
nonce: note.nonce(),
},
)
.await;
dbtx.insert_new_entry(&key, ¬e.to_undecoded()).await;
}
for (amount, note_idx) in finalized.next_note_idx.iter() {
debug!(
target: LOG_CLIENT_RECOVERY_MINT,
%amount,
%note_idx,
"Restoring NextECashNodeIndex"
);
dbtx.insert_entry(&NextECashNoteIndexKey(amount), ¬e_idx.as_u64())
.await;
}
debug!(
target: LOG_CLIENT_RECOVERY_MINT,
len = finalized.unconfirmed_notes.len(),
"Restoring unconfirmed notes state machines"
);
for (out_point, amount, issuance_request) in finalized.unconfirmed_notes {
self.client_ctx
.add_state_machines_dbtx(
dbtx,
self.client_ctx
.map_dyn(vec![MintClientStateMachines::Output(
MintOutputStateMachine {
common: MintOutputCommon {
operation_id: OperationId::new_random(),
out_point_range: OutPointRange::new_single(
out_point.txid,
out_point.out_idx,
)
.expect("Can't overflow"),
},
state: crate::output::MintOutputStates::Created(
MintOutputStatesCreated {
amount,
issuance_request,
},
),
},
)])
.collect(),
)
.await?;
}
debug!(
target: LOG_CLIENT_RECOVERY_MINT,
"Mint module recovery finalized"
);
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct EcashRecoveryFinalState {
pub spendable_notes: TieredMulti<SpendableNote>,
pub unconfirmed_notes: Vec<(OutPoint, Amount, NoteIssuanceRequest)>,
pub next_note_idx: Tiered<NoteIndex>,
pub burned_total: Amount,
pub reused_note_indices: Vec<(Amount, NoteIndex)>,
}
#[derive(
Debug, Clone, Eq, PartialEq, PartialOrd, Ord, Decodable, Encodable, Serialize, Deserialize,
)]
struct CompressedBlindedMessage(#[serde(with = "serde_big_array::BigArray")] [u8; 48]);
impl From<BlindedMessage> for CompressedBlindedMessage {
fn from(value: BlindedMessage) -> Self {
Self(value.0.to_compressed())
}
}
impl From<CompressedBlindedMessage> for BlindedMessage {
fn from(value: CompressedBlindedMessage) -> Self {
BlindedMessage(
std::convert::Into::<Option<G1Affine>>::into(G1Affine::from_compressed(&value.0))
.expect("We never produce invalid compressed blinded messages"),
)
}
}
#[derive(Debug, Clone, Decodable, Encodable)]
pub enum MintRecoveryState {
#[encodable(index = 2)]
V2(MintRecoveryStateV2),
#[encodable_default]
Default { variant: u64, bytes: Vec<u8> },
}
#[derive(Clone, Eq, PartialEq, Decodable, Encodable, Serialize, Deserialize)]
pub struct MintRecoveryStateV2 {
spendable_notes: BTreeMap<Nonce, (Amount, SpendableNote)>,
pending_outputs: BTreeMap<Nonce, (OutPoint, Amount, NoteIssuanceRequest)>,
pending_nonces: BTreeMap<CompressedBlindedMessage, (NoteIssuanceRequest, NoteIndex, Amount)>,
used_nonces: BTreeMap<CompressedBlindedMessage, (NoteIssuanceRequest, NoteIndex, Amount)>,
reused_note_indices: Vec<(Amount, NoteIndex)>,
burned_total: Amount,
next_pending_note_idx: Tiered<NoteIndex>,
last_used_nonce_idx: Tiered<NoteIndex>,
threshold: u64,
pub_key_shares: BTreeMap<PeerId, Tiered<PublicKeyShare>>,
tbs_pks: Tiered<AggregatePublicKey>,
gap_limit: u64,
}
impl fmt::Debug for MintRecoveryStateV2 {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_fmt(format_args!(
"MintRestoreInProgressState(pending_outputs: {}, pending_nonces: {}, used_nonces: {}, burned_total: {})",
self.pending_outputs.len(),
self.pending_nonces.len(),
self.used_nonces.len(),
self.burned_total,
))
}
}
impl MintRecoveryStateV2 {
pub fn from_backup(
backup: EcashBackupV0,
gap_limit: u64,
tbs_pks: Tiered<AggregatePublicKey>,
pub_key_shares: BTreeMap<PeerId, Tiered<PublicKeyShare>>,
secret: &DerivableSecret,
) -> Self {
let amount_tiers: Vec<_> = tbs_pks.tiers().copied().collect();
let mut s = Self {
spendable_notes: backup
.spendable_notes
.into_iter_items()
.map(|(amount, note)| (note.nonce(), (amount, note)))
.collect(),
pending_outputs: backup
.pending_notes
.into_iter()
.map(|(outpoint, amount, issuance_request)| {
(
issuance_request.nonce(),
(outpoint, amount, issuance_request),
)
})
.collect(),
reused_note_indices: Vec::new(),
pending_nonces: BTreeMap::default(),
used_nonces: BTreeMap::default(),
burned_total: Amount::ZERO,
next_pending_note_idx: backup.next_note_idx.clone(),
last_used_nonce_idx: backup
.next_note_idx
.into_iter()
.filter_map(|(a, idx)| idx.prev().map(|idx| (a, idx)))
.collect(),
threshold: pub_key_shares.to_num_peers().threshold() as u64,
gap_limit,
tbs_pks,
pub_key_shares,
};
for amount in amount_tiers {
s.fill_initial_pending_nonces(amount, secret);
}
s
}
fn fill_initial_pending_nonces(&mut self, amount: Amount, secret: &DerivableSecret) {
debug!(%amount, count=self.gap_limit, "Generating initial set of nonces for amount tier");
for _ in 0..self.gap_limit {
self.add_next_pending_nonce_in_pending_pool(amount, secret);
}
}
fn add_next_pending_nonce_in_pending_pool(&mut self, amount: Amount, secret: &DerivableSecret) {
let note_idx_ref = self.next_pending_note_idx.get_mut_or_default(amount);
let (note_issuance_request, blind_nonce) = NoteIssuanceRequest::new(
fedimint_core::secp256k1::SECP256K1,
&MintClientModule::new_note_secret_static(secret, amount, *note_idx_ref),
);
assert!(self
.pending_nonces
.insert(
blind_nonce.0.into(),
(note_issuance_request, *note_idx_ref, amount)
)
.is_none());
note_idx_ref.advance();
}
pub fn handle_input(&mut self, input: &MintInput) {
match input {
MintInput::V0(input) => {
self.pending_outputs.remove(&input.note.nonce);
self.spendable_notes.remove(&input.note.nonce);
}
MintInput::Default { variant, .. } => {
trace!("Ignoring future mint input variant {variant}");
}
}
}
pub fn handle_output(
&mut self,
out_point: OutPoint,
output: &MintOutput,
secret: &DerivableSecret,
) {
let output = match output {
MintOutput::V0(output) => output,
MintOutput::Default { variant, .. } => {
trace!("Ignoring future mint output variant {variant}");
return;
}
};
if let Some((_issuance_request, note_idx, amount)) =
self.used_nonces.get(&output.blind_nonce.0.into())
{
self.burned_total += *amount;
self.reused_note_indices.push((*amount, *note_idx));
warn!(
target: LOG_CLIENT_RECOVERY_MINT,
%note_idx,
%amount,
burned_total = %self.burned_total,
"Detected reused nonce during recovery. This means client probably burned funds in the past."
);
}
if let Some((issuance_request, note_idx, pending_amount)) =
self.pending_nonces.remove(&output.blind_nonce.0.into())
{
self.observe_nonce_idx_being_used(pending_amount, note_idx, secret);
if pending_amount == output.amount {
self.used_nonces.insert(
output.blind_nonce.0.into(),
(issuance_request, note_idx, pending_amount),
);
self.pending_outputs.insert(
issuance_request.nonce(),
(out_point, output.amount, issuance_request),
);
} else {
self.pending_nonces.insert(
output.blind_nonce.0.into(),
(issuance_request, note_idx, pending_amount),
);
warn!(
target: LOG_CLIENT_RECOVERY_MINT,
output = ?out_point,
blind_nonce = ?output.blind_nonce.0,
expected_amount = %pending_amount,
found_amount = %output.amount,
"Transaction output contains blind nonce that looks like ours but is of the wrong amount. Ignoring."
);
}
}
}
fn observe_nonce_idx_being_used(
&mut self,
amount: Amount,
note_idx: NoteIndex,
secret: &DerivableSecret,
) {
self.last_used_nonce_idx.insert(
amount,
max(
self.last_used_nonce_idx
.get(amount)
.copied()
.unwrap_or_default(),
note_idx,
),
);
while self.next_pending_note_idx.get_mut_or_default(amount).0
< self.gap_limit
+ self
.last_used_nonce_idx
.get(amount)
.expect("must be there already")
.0
{
self.add_next_pending_nonce_in_pending_pool(amount, secret);
}
}
pub fn finalize(self) -> EcashRecoveryFinalState {
EcashRecoveryFinalState {
spendable_notes: self.spendable_notes.into_values().collect(),
unconfirmed_notes: self.pending_outputs.into_values().collect(),
next_note_idx: self
.last_used_nonce_idx
.iter()
.map(|(amount, value)| (amount, value.next()))
.collect(),
reused_note_indices: self.reused_note_indices,
burned_total: self.burned_total,
}
}
}