pub(crate) mod backup;
mod client_db;
mod input;
mod oob;
mod output;
use std::cmp::Ordering;
use std::collections::BTreeMap;
use std::ffi;
use std::fmt::{Display, Formatter};
use std::io::Read;
use std::str::FromStr;
use std::sync::Arc;
use std::time::Duration;
use anyhow::{anyhow, bail, ensure, Context as _};
use async_stream::stream;
use backup::recovery::{MintRestoreStateMachine, MintRestoreStates};
use bitcoin_hashes::{sha256, sha256t, Hash, HashEngine as BitcoinHashEngine};
use client_db::DbKeyPrefix;
use fedimint_client::module::init::{ClientModuleInit, ClientModuleInitArgs};
use fedimint_client::module::{ClientContext, ClientDbTxContext, ClientModule, IClientModule};
use fedimint_client::oplog::{OperationLogEntry, UpdateStreamOrOutcome};
use fedimint_client::sm::util::MapStateTransitions;
use fedimint_client::sm::{Context, DynState, Executor, ModuleNotifier, State, StateTransition};
use fedimint_client::transaction::{ClientInput, ClientOutput, TransactionBuilder};
use fedimint_client::{sm_enum_variant_translation, DynGlobalClientContext};
use fedimint_core::api::GlobalFederationApi;
use fedimint_core::config::{FederationId, FederationIdPrefix};
use fedimint_core::core::{Decoder, IntoDynInstance, ModuleInstanceId, OperationId};
use fedimint_core::db::{
AutocommitError, Database, DatabaseTransaction, IDatabaseTransactionOpsCoreTyped,
};
use fedimint_core::encoding::{Decodable, DecodeError, Encodable};
use fedimint_core::module::registry::ModuleDecoderRegistry;
use fedimint_core::module::{
ApiVersion, CommonModuleInit, ModuleCommon, ModuleInit, MultiApiVersion, TransactionItemAmount,
};
use fedimint_core::util::{BoxFuture, BoxStream, NextOrPending};
use fedimint_core::{
apply, async_trait_maybe_send, push_db_pair_items, Amount, OutPoint, PeerId, Tiered,
TieredMulti, TieredSummary, TransactionId,
};
use fedimint_derive_secret::{ChildId, DerivableSecret};
pub use fedimint_mint_common as common;
use fedimint_mint_common::config::MintClientConfig;
pub use fedimint_mint_common::*;
use futures::{pin_mut, StreamExt};
use secp256k1::{All, KeyPair, Secp256k1};
use serde::{Deserialize, Serialize};
use strum::IntoEnumIterator;
use tbs::AggregatePublicKey;
use thiserror::Error;
use tracing::{debug, info, warn};
use crate::backup::recovery::MintRestoreInProgressState;
use crate::backup::{EcashBackup, EcashBackupV0};
use crate::client_db::{
CancelledOOBSpendKey, CancelledOOBSpendKeyPrefix, NextECashNoteIndexKey,
NextECashNoteIndexKeyPrefix, NoteKey, NoteKeyPrefix,
};
use crate::input::{
MintInputCommon, MintInputStateCreated, MintInputStateMachine, MintInputStates,
};
use crate::oob::{MintOOBStateMachine, MintOOBStates, MintOOBStatesCreated};
use crate::output::{
MintOutputCommon, MintOutputStateMachine, MintOutputStates, MintOutputStatesCreated,
NoteIssuanceRequest,
};
const MINT_E_CASH_TYPE_CHILD_ID: ChildId = ChildId(0);
const MINT_BACKUP_RESTORE_OPERATION_ID: OperationId = OperationId([0x01; 32]);
pub const LOG_TARGET: &str = "client::module::mint";
#[derive(Clone, Debug, Encodable)]
pub struct OOBNotes(Vec<OOBNotesData>);
#[derive(Clone, Debug, Decodable, Encodable)]
enum OOBNotesData {
Notes(TieredMulti<SpendableNote>),
FederationIdPrefix(FederationIdPrefix),
#[encodable_default]
Default {
variant: u64,
bytes: Vec<u8>,
},
}
impl OOBNotes {
pub fn new(
federation_id_prefix: FederationIdPrefix,
notes: TieredMulti<SpendableNote>,
) -> Self {
Self(vec![
OOBNotesData::FederationIdPrefix(federation_id_prefix),
OOBNotesData::Notes(notes),
])
}
pub fn federation_id_prefix(&self) -> FederationIdPrefix {
self.0
.iter()
.find_map(|data| match data {
OOBNotesData::FederationIdPrefix(prefix) => Some(*prefix),
_ => None,
})
.expect("Invariant violated: OOBNotes does not contain a FederationIdPrefix")
}
pub fn notes(&self) -> &TieredMulti<SpendableNote> {
self.0
.iter()
.find_map(|data| match data {
OOBNotesData::Notes(notes) => Some(notes),
_ => None,
})
.expect("Invariant violated: OOBNotes does not contain any notes")
}
}
impl Decodable for OOBNotes {
fn consensus_decode<R: Read>(
r: &mut R,
_modules: &ModuleDecoderRegistry,
) -> Result<Self, DecodeError> {
let inner = Vec::<OOBNotesData>::consensus_decode(r, &ModuleDecoderRegistry::default())?;
if !inner
.iter()
.any(|data| matches!(data, OOBNotesData::Notes(_)))
{
return Err(DecodeError::from_str(
"No e-cash notes were found in OOBNotes data",
));
}
if !inner
.iter()
.any(|data| matches!(data, OOBNotesData::FederationIdPrefix(_)))
{
return Err(DecodeError::from_str(
"No Federation ID provided in OOBNotes data",
));
}
Ok(OOBNotes(inner))
}
}
impl FromStr for OOBNotes {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let bytes = base64::decode(s)?;
let oob_notes: OOBNotes = Decodable::consensus_decode(
&mut std::io::Cursor::new(bytes),
&ModuleDecoderRegistry::default(),
)?;
ensure!(!oob_notes.notes().is_empty(), "OOBNotes cannot be empty");
Ok(oob_notes)
}
}
impl Display for OOBNotes {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
let mut bytes = Vec::new();
Encodable::consensus_encode(self, &mut bytes).expect("encodes correctly");
f.write_str(&base64::encode(&bytes))
}
}
impl Serialize for OOBNotes {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&self.to_string())
}
}
impl<'de> Deserialize<'de> for OOBNotes {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
FromStr::from_str(&s).map_err(serde::de::Error::custom)
}
}
impl OOBNotes {
pub fn total_amount(&self) -> Amount {
self.notes().total_amount()
}
}
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
pub enum ReissueExternalNotesState {
Created,
Issuing,
Done,
Failed(String),
}
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
pub enum SpendOOBState {
Created,
UserCanceledProcessing,
UserCanceledSuccess,
UserCanceledFailure,
Success,
Refunded,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MintOperationMeta {
pub variant: MintOperationMetaVariant,
pub amount: Amount,
pub extra_meta: serde_json::Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MintOperationMetaVariant {
Reissuance {
out_point: OutPoint,
},
SpendOOB {
requested_amount: Amount,
oob_notes: OOBNotes,
},
}
#[derive(Debug, Clone)]
pub struct MintClientInit;
#[apply(async_trait_maybe_send!)]
impl ModuleInit for MintClientInit {
type Common = MintCommonInit;
async fn dump_database(
&self,
dbtx: &mut DatabaseTransaction<'_>,
prefix_names: Vec<String>,
) -> Box<dyn Iterator<Item = (String, Box<dyn erased_serde::Serialize + Send>)> + '_> {
let mut mint_client_items: BTreeMap<String, Box<dyn erased_serde::Serialize + Send>> =
BTreeMap::new();
let filtered_prefixes = DbKeyPrefix::iter().filter(|f| {
prefix_names.is_empty() || prefix_names.contains(&f.to_string().to_lowercase())
});
for table in filtered_prefixes {
match table {
DbKeyPrefix::Note => {
push_db_pair_items!(
dbtx,
NoteKeyPrefix,
NoteKey,
SpendableNote,
mint_client_items,
"Notes"
);
}
DbKeyPrefix::NextECashNoteIndex => {
push_db_pair_items!(
dbtx,
NextECashNoteIndexKeyPrefix,
NextECashNoteIndexKey,
u64,
mint_client_items,
"NextECashNoteIndex"
);
}
DbKeyPrefix::CancelledOOBSpend => {
push_db_pair_items!(
dbtx,
CancelledOOBSpendKeyPrefix,
CancelledOOBSpendKey,
(),
mint_client_items,
"CancelledOOBSpendKey"
);
}
}
}
Box::new(mint_client_items.into_iter())
}
}
#[apply(async_trait_maybe_send!)]
impl ClientModuleInit for MintClientInit {
type Module = MintClientModule;
fn supported_api_versions(&self) -> MultiApiVersion {
MultiApiVersion::try_from_iter([ApiVersion { major: 0, minor: 0 }])
.expect("no version conflicts")
}
async fn init(&self, args: &ClientModuleInitArgs<Self>) -> anyhow::Result<Self::Module> {
Ok(MintClientModule {
federation_id: *args.federation_id(),
cfg: args.cfg().clone(),
secret: args.module_root_secret().clone(),
secp: Secp256k1::new(),
notifier: args.notifier().clone(),
client_ctx: args.context(),
})
}
}
#[derive(Debug)]
pub struct MintClientModule {
federation_id: FederationId,
cfg: MintClientConfig,
secret: DerivableSecret,
secp: Secp256k1<All>,
notifier: ModuleNotifier<DynGlobalClientContext, MintClientStateMachines>,
client_ctx: ClientContext<Self>,
}
#[derive(Debug, Clone)]
pub struct MintClientContext {
pub mint_decoder: Decoder,
pub tbs_pks: Tiered<AggregatePublicKey>,
pub peer_tbs_pks: BTreeMap<PeerId, Tiered<tbs::PublicKeyShare>>,
pub secret: DerivableSecret,
pub module_db: Database,
}
impl MintClientContext {
fn await_cancel_oob_payment(&self, operation_id: OperationId) -> BoxFuture<'static, ()> {
let db = self.module_db.clone();
Box::pin(async move {
db.wait_key_exists(&CancelledOOBSpendKey(operation_id))
.await;
})
}
}
impl Context for MintClientContext {}
#[apply(async_trait_maybe_send!)]
impl ClientModule for MintClientModule {
type Init = MintClientInit;
type Common = MintModuleTypes;
type Backup = EcashBackup;
type ModuleStateMachineContext = MintClientContext;
type States = MintClientStateMachines;
fn context(&self) -> Self::ModuleStateMachineContext {
MintClientContext {
mint_decoder: self.decoder(),
tbs_pks: self.cfg.tbs_pks.clone(),
peer_tbs_pks: self.cfg.peer_tbs_pks.clone(),
secret: self.secret.clone(),
module_db: self.client_ctx.module_db().clone(),
}
}
fn input_amount(
&self,
input: &<Self::Common as ModuleCommon>::Input,
) -> Option<TransactionItemAmount> {
let input = input.maybe_v0_ref()?;
Some(TransactionItemAmount {
amount: input.amount,
fee: self.cfg.fee_consensus.note_spend_abs,
})
}
fn output_amount(
&self,
output: &<Self::Common as ModuleCommon>::Output,
) -> Option<TransactionItemAmount> {
let output = output.maybe_v0_ref()?;
Some(TransactionItemAmount {
amount: output.amount,
fee: self.cfg.fee_consensus.note_issuance_abs,
})
}
async fn handle_cli_command(
&self,
args: &[ffi::OsString],
) -> anyhow::Result<serde_json::Value> {
if args.is_empty() {
return Err(anyhow::format_err!(
"Expected to be called with at leas 1 arguments: <command> …"
));
}
let command = args[0].to_string_lossy();
match command.as_ref() {
"reissue" => {
if args.len() != 2 {
return Err(anyhow::format_err!(
"`reissue` command expects 1 argument: <notes>"
));
}
let oob_notes = args[1]
.to_string_lossy()
.parse::<OOBNotes>()
.map_err(|e| anyhow::format_err!("invalid notes format: {e}"))?;
let amount = oob_notes.total_amount();
let operation_id = self.reissue_external_notes(oob_notes, ()).await?;
let mut updates = self
.subscribe_reissue_external_notes(operation_id)
.await
.unwrap()
.into_stream();
while let Some(update) = updates.next().await {
if let ReissueExternalNotesState::Failed(e) = update {
bail!("Reissue failed: {e}");
}
info!("Update: {:?}", update);
}
Ok(serde_json::to_value(amount).unwrap())
}
command => Err(anyhow::format_err!(
"Unknown command: {command}, supported commands: reissue"
)),
}
}
fn supports_backup(&self) -> bool {
true
}
async fn backup(&self) -> anyhow::Result<EcashBackup> {
self.client_ctx
.module_autocommit(
move |dbtx_ctx, _| {
Box::pin(async move { self.prepare_plaintext_ecash_backup(dbtx_ctx).await })
},
None,
)
.await
.map_err(|e| match e {
AutocommitError::ClosureError { error, .. } => error,
AutocommitError::CommitFailed { last_error, .. } => {
anyhow!("Commit to DB failed: {last_error}")
}
})
}
async fn restore(&self, snapshot: Option<EcashBackup>) -> anyhow::Result<()> {
let snapshot_v0 = match snapshot {
Some(EcashBackup::V0(snapshot_v0)) => Some(snapshot_v0),
Some(EcashBackup::Default { variant, .. }) => {
return Err(anyhow!("Unsupported backup variant: {variant}"))
}
None => None,
};
self.client_ctx
.module_autocommit(
move |dbtx_ctx, _| {
let snapshot_inner = snapshot_v0.clone();
Box::pin(async move { self.restore_inner(dbtx_ctx, snapshot_inner).await })
},
None,
)
.await
.map_err(|e| match e {
AutocommitError::ClosureError { error, .. } => error,
AutocommitError::CommitFailed { last_error, .. } => {
anyhow!("Commit to DB failed: {last_error}")
}
})
}
async fn wipe(
&self,
dbtx: &mut DatabaseTransaction<'_>,
_module_instance_id: ModuleInstanceId,
_executor: Executor<DynGlobalClientContext>,
) -> anyhow::Result<()> {
debug!(target: LOG_TARGET, "Wiping mint module state");
Self::wipe_all_spendable_notes(dbtx).await;
Ok(())
}
fn supports_being_primary(&self) -> bool {
true
}
async fn create_sufficient_input(
&self,
dbtx: &mut DatabaseTransaction<'_>,
operation_id: OperationId,
min_amount: Amount,
) -> anyhow::Result<Vec<ClientInput<MintInput, MintClientStateMachines>>> {
self.create_input(dbtx, operation_id, min_amount).await
}
async fn create_exact_output(
&self,
dbtx: &mut DatabaseTransaction<'_>,
operation_id: OperationId,
amount: Amount,
) -> Vec<ClientOutput<MintOutput, MintClientStateMachines>> {
self.create_output(dbtx, operation_id, 2, amount).await
}
async fn await_primary_module_output(
&self,
operation_id: OperationId,
out_point: OutPoint,
) -> anyhow::Result<Amount> {
self.await_output_finalized(operation_id, out_point).await
}
async fn get_balance(&self, dbtx: &mut DatabaseTransaction<'_>) -> Amount {
self.get_wallet_summary(dbtx).await.total_amount()
}
async fn subscribe_balance_changes(&self) -> BoxStream<'static, ()> {
Box::pin(
self.notifier
.subscribe_all_operations()
.await
.filter_map(|state| async move {
match state {
MintClientStateMachines::Output(MintOutputStateMachine {
state: MintOutputStates::Succeeded(_),
..
}) => Some(()),
MintClientStateMachines::Input(MintInputStateMachine {
state: MintInputStates::Created(_),
..
}) => Some(()),
MintClientStateMachines::OOB(MintOOBStateMachine {
state: MintOOBStates::Created(_),
..
}) => Some(()),
MintClientStateMachines::Restore(MintRestoreStateMachine {
state: MintRestoreStates::Success(_),
..
}) => Some(()),
_ => None,
}
}),
)
}
async fn leave(&self, dbtx: &mut DatabaseTransaction<'_>) -> anyhow::Result<()> {
let balance = ClientModule::get_balance(self, dbtx).await;
if Amount::from_sats(0) < balance {
bail!("Outstanding balance: {balance}");
}
if !self.client_ctx.get_own_active_states().await.is_empty() {
bail!("Pending operations")
}
Ok(())
}
}
impl MintClientModule {
pub async fn get_wallet_summary(&self, dbtx: &mut DatabaseTransaction<'_>) -> TieredSummary {
dbtx.find_by_prefix(&NoteKeyPrefix)
.await
.fold(
TieredSummary::default(),
|mut acc, (key, _note)| async move {
acc.inc(key.amount, 1);
acc
},
)
.await
}
pub async fn create_output(
&self,
dbtx: &mut DatabaseTransaction<'_>,
operation_id: OperationId,
notes_per_denomination: u16,
exact_amount: Amount,
) -> Vec<ClientOutput<MintOutput, MintClientStateMachines>> {
assert!(
exact_amount > Amount::ZERO,
"zero-amount outputs are not supported"
);
let denominations = TieredSummary::represent_amount(
exact_amount,
&self.get_wallet_summary(dbtx).await,
&self.cfg.tbs_pks,
notes_per_denomination,
);
let mut outputs = Vec::new();
for (amount, num) in denominations.iter() {
for _ in 0..num {
let (issuance_request, blind_nonce) = self.new_ecash_note(amount, dbtx).await;
let state_generator = Arc::new(move |txid, out_idx| {
vec![MintClientStateMachines::Output(MintOutputStateMachine {
common: MintOutputCommon {
operation_id,
out_point: OutPoint { txid, out_idx },
},
state: MintOutputStates::Created(MintOutputStatesCreated {
amount,
issuance_request,
}),
})]
});
debug!(
%amount,
"Generated issuance request"
);
outputs.push(ClientOutput {
output: MintOutput::new_v0(amount, blind_nonce),
state_machines: state_generator,
});
}
}
outputs
}
pub async fn await_output_finalized(
&self,
operation_id: OperationId,
out_point: OutPoint,
) -> anyhow::Result<Amount> {
let stream = self
.notifier
.subscribe(operation_id)
.await
.filter_map(|state| async move {
let MintClientStateMachines::Output(state) = state else {
return None;
};
if state.common.out_point != out_point {
return None;
}
match state.state {
MintOutputStates::Succeeded(succeeded) => Some(Ok(succeeded.amount)),
MintOutputStates::Aborted(_) => Some(Err(anyhow!("Transaction was rejected"))),
MintOutputStates::Failed(failed) => Some(Err(anyhow!(
"Failed to finalize transaction: {}",
failed.error
))),
_ => None,
}
});
pin_mut!(stream);
stream.next_or_pending().await
}
pub async fn create_input(
&self,
dbtx: &mut DatabaseTransaction<'_>,
operation_id: OperationId,
min_amount: Amount,
) -> anyhow::Result<Vec<ClientInput<MintInput, MintClientStateMachines>>> {
assert!(
min_amount > Amount::ZERO,
"zero-amount inputs are not supported"
);
let spendable_selected_notes =
Self::select_notes(dbtx, &SelectNotesWithAtleastAmount, min_amount).await?;
for (amount, note) in spendable_selected_notes.iter_items() {
dbtx.remove_entry(&NoteKey {
amount,
nonce: note.nonce(),
})
.await;
}
self.create_input_from_notes(operation_id, spendable_selected_notes)
.await
}
pub async fn create_input_from_notes(
&self,
operation_id: OperationId,
notes: TieredMulti<SpendableNote>,
) -> anyhow::Result<Vec<ClientInput<MintInput, MintClientStateMachines>>> {
let mut inputs = Vec::new();
for (amount, spendable_note) in notes.into_iter() {
let key = self
.cfg
.tbs_pks
.get(amount)
.ok_or(anyhow!("Invalid amount tier: {amount}"))?;
let note = spendable_note.note();
if !note.verify(*key) {
bail!("Invalid note");
}
let sm_gen = Arc::new(move |txid, input_idx| {
vec![MintClientStateMachines::Input(MintInputStateMachine {
common: MintInputCommon {
operation_id,
txid,
input_idx,
},
state: MintInputStates::Created(MintInputStateCreated {
amount,
spendable_note,
}),
})]
});
inputs.push(ClientInput {
input: MintInput::new_v0(amount, note),
keys: vec![spendable_note.spend_key],
state_machines: sm_gen,
});
}
Ok(inputs)
}
async fn spend_notes_oob(
&self,
dbtx: &mut DatabaseTransaction<'_>,
notes_selector: &impl NotesSelector<SpendableNote>,
requested_amount: Amount,
try_cancel_after: Duration,
) -> anyhow::Result<(
OperationId,
Vec<MintClientStateMachines>,
TieredMulti<SpendableNote>,
)> {
ensure!(
requested_amount > Amount::ZERO,
"zero-amount out-of-band spends are not supported"
);
let spendable_selected_notes =
Self::select_notes(dbtx, notes_selector, requested_amount).await?;
let operation_id = spendable_notes_to_operation_id(&spendable_selected_notes);
for (amount, note) in spendable_selected_notes.iter_items() {
dbtx.remove_entry(&NoteKey {
amount,
nonce: note.nonce(),
})
.await;
}
let mut state_machines = Vec::new();
for (amount, spendable_note) in spendable_selected_notes.clone() {
state_machines.push(MintClientStateMachines::OOB(MintOOBStateMachine {
operation_id,
state: MintOOBStates::Created(MintOOBStatesCreated {
amount,
spendable_note,
timeout: fedimint_core::time::now() + try_cancel_after,
}),
}));
}
Ok((operation_id, state_machines, spendable_selected_notes))
}
pub async fn await_spend_oob_refund(&self, operation_id: OperationId) -> SpendOOBRefund {
Box::pin(
self.notifier
.subscribe(operation_id)
.await
.filter_map(|state| async move {
let MintClientStateMachines::OOB(state) = state else {
return None;
};
match state.state {
MintOOBStates::TimeoutRefund(refund) => Some(SpendOOBRefund {
user_triggered: false,
transaction_id: refund.refund_txid,
}),
MintOOBStates::UserRefund(refund) => Some(SpendOOBRefund {
user_triggered: true,
transaction_id: refund.refund_txid,
}),
MintOOBStates::Created(_) => None,
}
}),
)
.next_or_pending()
.await
}
pub async fn await_restore_finished(&self) -> anyhow::Result<Amount> {
let mut restore_stream = self
.notifier
.subscribe(MINT_BACKUP_RESTORE_OPERATION_ID)
.await;
while let Some(restore_step) = restore_stream.next().await {
match restore_step {
MintClientStateMachines::Restore(MintRestoreStateMachine {
state: MintRestoreStates::Success(amount),
..
}) => {
return Ok(amount);
}
MintClientStateMachines::Restore(MintRestoreStateMachine {
state: MintRestoreStates::Failed(error),
..
}) => {
return Err(anyhow!("Restore failed: {}", error.reason));
}
_ => {}
}
}
Err(anyhow!("Restore stream closed without success or failure"))
}
async fn select_notes(
dbtx: &mut DatabaseTransaction<'_>,
notes_selector: &impl NotesSelector<SpendableNote>,
requested_amount: Amount,
) -> anyhow::Result<TieredMulti<SpendableNote>> {
let note_stream = dbtx
.find_by_prefix_sorted_descending(&NoteKeyPrefix)
.await
.map(|(key, note)| (key.amount, note));
notes_selector
.select_notes(note_stream, requested_amount)
.await
}
async fn get_all_spendable_notes(
dbtx: &mut DatabaseTransaction<'_>,
) -> TieredMulti<SpendableNote> {
TieredMulti::from_iter(
(dbtx
.find_by_prefix(&NoteKeyPrefix)
.await
.map(|(key, note)| (key.amount, note))
.collect::<Vec<_>>()
.await)
.into_iter(),
)
}
async fn wipe_all_spendable_notes(dbtx: &mut DatabaseTransaction<'_>) {
debug!(target: LOG_TARGET, "Wiping all spendable notes");
dbtx.remove_by_prefix(&NoteKeyPrefix).await;
assert!(Self::get_all_spendable_notes(dbtx).await.is_empty());
}
async fn get_next_note_index(
&self,
dbtx: &mut DatabaseTransaction<'_>,
amount: Amount,
) -> NoteIndex {
NoteIndex(
dbtx.get_value(&NextECashNoteIndexKey(amount))
.await
.unwrap_or(0),
)
}
pub fn new_note_secret_static(
secret: &DerivableSecret,
amount: Amount,
note_idx: NoteIndex,
) -> DerivableSecret {
assert_eq!(secret.level(), 2);
debug!(?secret, %amount, %note_idx, "Deriving new mint note");
secret
.child_key(MINT_E_CASH_TYPE_CHILD_ID) .child_key(ChildId(note_idx.as_u64()))
.child_key(ChildId(amount.msats))
}
async fn new_note_secret(
&self,
amount: Amount,
dbtx: &mut DatabaseTransaction<'_>,
) -> DerivableSecret {
let new_idx = self.get_next_note_index(dbtx, amount).await;
dbtx.insert_entry(&NextECashNoteIndexKey(amount), &new_idx.next().as_u64())
.await;
Self::new_note_secret_static(&self.secret, amount, new_idx)
}
pub async fn new_ecash_note(
&self,
amount: Amount,
dbtx: &mut DatabaseTransaction<'_>,
) -> (NoteIssuanceRequest, BlindNonce) {
let secret = self.new_note_secret(amount, dbtx).await;
NoteIssuanceRequest::new(&self.secp, secret)
}
pub async fn reissue_external_notes<M: Serialize + Send>(
&self,
oob_notes: OOBNotes,
extra_meta: M,
) -> anyhow::Result<OperationId> {
let notes = oob_notes.notes().clone();
let federation_id_prefix = oob_notes.federation_id_prefix();
ensure!(
notes.total_amount() > Amount::ZERO,
"Reissuing zero-amount e-cash isn't supported"
);
if federation_id_prefix != self.federation_id.to_prefix() {
bail!("Federation ID does not match");
}
let operation_id = OperationId(
notes
.consensus_hash::<sha256t::Hash<OOBReissueTag>>()
.into_inner(),
);
let amount = notes.total_amount();
let mint_input = self.create_input_from_notes(operation_id, notes).await?;
let tx =
TransactionBuilder::new().with_inputs(self.client_ctx.map_dyn(mint_input).collect());
let extra_meta = serde_json::to_value(extra_meta)
.expect("MintClientModule::reissue_external_notes extra_meta is serializable");
let operation_meta_gen = move |txid, _| MintOperationMeta {
variant: MintOperationMetaVariant::Reissuance {
out_point: OutPoint { txid, out_idx: 0 },
},
amount,
extra_meta: extra_meta.clone(),
};
let change = self
.client_ctx
.finalize_and_submit_transaction(
operation_id,
MintCommonInit::KIND.as_str(),
operation_meta_gen,
tx,
)
.await
.context("We already reissued these notes")?
.1;
self.client_ctx
.await_primary_module_outputs(operation_id, change)
.await?;
Ok(operation_id)
}
pub async fn subscribe_reissue_external_notes(
&self,
operation_id: OperationId,
) -> anyhow::Result<UpdateStreamOrOutcome<ReissueExternalNotesState>> {
let operation = self.mint_operation(operation_id).await?;
let out_point = match operation.meta::<MintOperationMeta>().variant {
MintOperationMetaVariant::Reissuance { out_point } => out_point,
_ => bail!("Operation is not a reissuance"),
};
let client_ctx = self.client_ctx.clone();
Ok(operation.outcome_or_updates(&self.client_ctx.global_db(), operation_id, move || {
stream! {
yield ReissueExternalNotesState::Created;
match client_ctx
.transaction_updates(operation_id)
.await
.await_tx_accepted(out_point.txid)
.await
{
Ok(()) => {
yield ReissueExternalNotesState::Issuing;
}
Err(e) => {
yield ReissueExternalNotesState::Failed(format!("Transaction not accepted {e:?}"));
}
}
match client_ctx.self_ref().await_output_finalized(operation_id, out_point).await {
Ok(_) => {
yield ReissueExternalNotesState::Done;
},
Err(e) => {
yield ReissueExternalNotesState::Failed(e.to_string());
},
}
}}
))
}
pub async fn spend_notes<M: Serialize + Send>(
&self,
min_amount: Amount,
try_cancel_after: Duration,
extra_meta: M,
) -> anyhow::Result<(OperationId, OOBNotes)> {
self.spend_notes_with_selector(
&SelectNotesWithAtleastAmount,
min_amount,
try_cancel_after,
extra_meta,
)
.await
}
pub async fn spend_notes_with_selector<M: Serialize + Send>(
&self,
notes_selector: &impl NotesSelector<SpendableNote>,
requested_amount: Amount,
try_cancel_after: Duration,
extra_meta: M,
) -> anyhow::Result<(OperationId, OOBNotes)> {
let federation_id_prefix = self.federation_id.to_prefix();
let extra_meta = serde_json::to_value(extra_meta)
.expect("MintClientModule::spend_notes extra_meta is serializable");
self.client_ctx
.module_autocommit(
move |dbtx, _| {
let extra_meta = extra_meta.clone();
Box::pin(async move {
let (operation_id, states, notes) = self
.spend_notes_oob(
&mut dbtx.module_dbtx(),
notes_selector,
requested_amount,
try_cancel_after,
)
.await?;
let oob_notes = OOBNotes::new(federation_id_prefix, notes);
dbtx.add_state_machines(self.client_ctx.map_dyn(states).collect())
.await?;
dbtx.add_operation_log_entry(
operation_id,
MintCommonInit::KIND.as_str(),
MintOperationMeta {
variant: MintOperationMetaVariant::SpendOOB {
requested_amount,
oob_notes: oob_notes.clone(),
},
amount: oob_notes.total_amount(),
extra_meta,
},
)
.await;
Ok((operation_id, oob_notes))
})
},
Some(100),
)
.await
.map_err(|e| match e {
AutocommitError::ClosureError { error, .. } => error,
AutocommitError::CommitFailed { last_error, .. } => {
anyhow!("Commit to DB failed: {last_error}")
}
})
}
pub async fn validate_notes(&self, oob_notes: OOBNotes) -> anyhow::Result<Amount> {
let federation_id_prefix = oob_notes.federation_id_prefix();
let notes = oob_notes.notes().clone();
if federation_id_prefix != self.federation_id.to_prefix() {
bail!("Federation ID does not match");
}
let tbs_pks = &self.cfg.tbs_pks;
for (idx, (amt, snote)) in notes.iter_items().enumerate() {
let key = tbs_pks
.get(amt)
.ok_or_else(|| anyhow!("Note {idx} uses an invalid amount tier {amt}"))?;
let note = snote.note();
if !note.verify(*key) {
bail!("Note {idx} has an invalid federation signature");
}
let expected_nonce = Nonce(snote.spend_key.public_key());
if note.nonce != expected_nonce {
bail!("Note {idx} cannot be spent using the supplied spend key");
}
}
Ok(notes.total_amount())
}
pub async fn try_cancel_spend_notes(&self, operation_id: OperationId) {
let mut dbtx = self.client_ctx.module_db().begin_transaction().await;
dbtx.insert_entry(&CancelledOOBSpendKey(operation_id), &())
.await;
if let Err(e) = dbtx.commit_tx_result().await {
warn!("We tried to cancel the same OOB spend multiple times concurrently: {e}");
}
}
pub async fn subscribe_spend_notes(
&self,
operation_id: OperationId,
) -> anyhow::Result<UpdateStreamOrOutcome<SpendOOBState>> {
let operation = self.mint_operation(operation_id).await?;
if !matches!(
operation.meta::<MintOperationMeta>().variant,
MintOperationMetaVariant::SpendOOB { .. }
) {
bail!("Operation is not a out-of-band spend");
};
let client_ctx = self.client_ctx.clone();
let global_db = self.client_ctx.global_db();
Ok(
operation.outcome_or_updates(&global_db, operation_id, move || {
stream! {
yield SpendOOBState::Created;
let self_ref = client_ctx.self_ref();
let refund = self_ref
.await_spend_oob_refund(operation_id)
.await;
if refund.user_triggered {
yield SpendOOBState::UserCanceledProcessing;
match client_ctx
.transaction_updates(operation_id)
.await
.await_tx_accepted(refund.transaction_id)
.await
{
Ok(()) => {
yield SpendOOBState::UserCanceledSuccess;
},
Err(_) => {
yield SpendOOBState::UserCanceledFailure;
}
}
} else {
match client_ctx
.transaction_updates(operation_id)
.await
.await_tx_accepted(refund.transaction_id)
.await
{
Ok(()) => {
yield SpendOOBState::Refunded;
},
Err(_) => {
yield SpendOOBState::Success;
}
}
}
}
}),
)
}
async fn mint_operation(&self, operation_id: OperationId) -> anyhow::Result<OperationLogEntry> {
let operation = self.client_ctx.get_operation(operation_id).await?;
if operation.operation_module_kind() != MintCommonInit::KIND.as_str() {
bail!("Operation is not a mint operation");
}
Ok(operation)
}
async fn restore_inner(
&self,
dbtx_ctx: &'_ mut ClientDbTxContext<'_, '_, Self>,
maybe_snapshot: Option<EcashBackupV0>,
) -> anyhow::Result<()> {
if !Self::get_all_spendable_notes(&mut dbtx_ctx.module_dbtx())
.await
.is_empty()
{
warn!(
target: LOG_TARGET,
"Can not start recovery - existing spendable notes found"
);
bail!("Found existing spendable notes. Mint module recovery must be started on an empty state.")
}
if !self.client_ctx.get_own_active_states().await.is_empty() {
warn!(
target: LOG_TARGET,
"Can not start recovery - existing state machines found"
);
bail!("Found existing active state machines. Mint module recovery must be started on an empty state.")
}
let snapshot = maybe_snapshot.unwrap_or(EcashBackupV0::new_empty());
let current_session_count = self.client_ctx.global_api().session_count().await?;
let state = MintRestoreInProgressState::from_backup(
current_session_count,
snapshot,
30,
self.cfg.tbs_pks.clone(),
self.cfg.peer_tbs_pks.clone(),
&self.secret,
);
debug!(target: LOG_TARGET, "Creating MintRestoreStateMachine");
dbtx_ctx
.add_state_machines_dbtx(vec![self.client_ctx.make_dyn_state(
MintClientStateMachines::Restore(MintRestoreStateMachine {
operation_id: MINT_BACKUP_RESTORE_OPERATION_ID,
state: MintRestoreStates::InProgress(state),
}),
)])
.await?;
Ok(())
}
}
pub fn spendable_notes_to_operation_id(
spendable_selected_notes: &TieredMulti<SpendableNote>,
) -> OperationId {
OperationId(
spendable_selected_notes
.consensus_hash::<sha256t::Hash<OOBSpendTag>>()
.into_inner(),
)
}
pub struct SpendOOBRefund {
pub user_triggered: bool,
pub transaction_id: TransactionId,
}
#[apply(async_trait_maybe_send!)]
pub trait NotesSelector<Note>: Send + Sync {
async fn select_notes(
&self,
#[cfg(not(target_family = "wasm"))] stream: impl futures::Stream<Item = (Amount, Note)> + Send,
#[cfg(target_family = "wasm")] stream: impl futures::Stream<Item = (Amount, Note)>,
requested_amount: Amount,
) -> anyhow::Result<TieredMulti<Note>>;
}
pub struct SelectNotesWithAtleastAmount;
#[apply(async_trait_maybe_send!)]
impl<Note: Send> NotesSelector<Note> for SelectNotesWithAtleastAmount {
async fn select_notes(
&self,
#[cfg(not(target_family = "wasm"))] stream: impl futures::Stream<Item = (Amount, Note)> + Send,
#[cfg(target_family = "wasm")] stream: impl futures::Stream<Item = (Amount, Note)>,
requested_amount: Amount,
) -> anyhow::Result<TieredMulti<Note>> {
Ok(select_notes_from_stream(stream, requested_amount).await?)
}
}
async fn select_notes_from_stream<Note>(
stream: impl futures::Stream<Item = (Amount, Note)>,
requested_amount: Amount,
) -> Result<TieredMulti<Note>, InsufficientBalanceError> {
if requested_amount == Amount::ZERO {
return Ok(TieredMulti::default());
}
let mut stream = Box::pin(stream);
let mut selected = vec![];
let mut last_big_note_checkpoint: Option<(Amount, Note, usize)> = None;
let mut pending_amount = requested_amount;
let mut previous_amount: Option<Amount> = None; loop {
if let Some((note_amount, note)) = stream.next().await {
assert!(
previous_amount.map_or(true, |previous| previous >= note_amount),
"notes are not sorted in descending order"
);
previous_amount = Some(note_amount);
match note_amount.cmp(&pending_amount) {
Ordering::Less => {
pending_amount -= note_amount;
selected.push((note_amount, note))
}
Ordering::Greater => {
last_big_note_checkpoint = Some((note_amount, note, selected.len()));
}
Ordering::Equal => {
selected.push((note_amount, note));
return Ok(selected.into_iter().collect());
}
}
} else {
assert!(pending_amount > Amount::ZERO);
if let Some((big_note_amount, big_note, checkpoint)) = last_big_note_checkpoint {
selected.truncate(checkpoint);
selected.push((big_note_amount, big_note));
return Ok(selected.into_iter().collect());
} else {
let total_amount = requested_amount - pending_amount;
return Err(InsufficientBalanceError {
requested_amount,
total_amount,
});
}
}
}
}
#[derive(Debug, Clone, Error)]
pub struct InsufficientBalanceError {
pub requested_amount: Amount,
pub total_amount: Amount,
}
impl std::fmt::Display for InsufficientBalanceError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(
f,
"Insufficient balance: requested {} but only {} available",
self.requested_amount, self.total_amount
)
}
}
#[derive(Debug, Clone, Eq, PartialEq, Decodable, Encodable)]
pub enum MintClientStateMachines {
Output(MintOutputStateMachine),
Input(MintInputStateMachine),
OOB(MintOOBStateMachine),
Restore(MintRestoreStateMachine),
}
impl IntoDynInstance for MintClientStateMachines {
type DynType = DynState<DynGlobalClientContext>;
fn into_dyn(self, instance_id: ModuleInstanceId) -> Self::DynType {
DynState::from_typed(instance_id, self)
}
}
impl State for MintClientStateMachines {
type ModuleContext = MintClientContext;
type GlobalContext = DynGlobalClientContext;
fn transitions(
&self,
context: &Self::ModuleContext,
global_context: &DynGlobalClientContext,
) -> Vec<StateTransition<Self>> {
match self {
MintClientStateMachines::Output(issuance_state) => {
sm_enum_variant_translation!(
issuance_state.transitions(context, global_context),
MintClientStateMachines::Output
)
}
MintClientStateMachines::Input(redemption_state) => {
sm_enum_variant_translation!(
redemption_state.transitions(context, global_context),
MintClientStateMachines::Input
)
}
MintClientStateMachines::OOB(oob_state) => {
sm_enum_variant_translation!(
oob_state.transitions(context, global_context),
MintClientStateMachines::OOB
)
}
MintClientStateMachines::Restore(restore_state) => {
sm_enum_variant_translation!(
restore_state.transitions(context, global_context),
MintClientStateMachines::Restore
)
}
}
}
fn operation_id(&self) -> OperationId {
match self {
MintClientStateMachines::Output(issuance_state) => issuance_state.operation_id(),
MintClientStateMachines::Input(redemption_state) => redemption_state.operation_id(),
MintClientStateMachines::OOB(oob_state) => oob_state.operation_id(),
MintClientStateMachines::Restore(state) => state.operation_id(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize, Encodable, Decodable)]
pub struct SpendableNote {
pub signature: tbs::Signature,
pub spend_key: KeyPair,
}
impl SpendableNote {
fn nonce(&self) -> Nonce {
Nonce(self.spend_key.public_key())
}
fn note(&self) -> Note {
Note {
nonce: self.nonce(),
signature: self.signature,
}
}
}
#[derive(
Copy,
Clone,
Debug,
Serialize,
Deserialize,
PartialEq,
Eq,
Encodable,
Decodable,
Default,
PartialOrd,
Ord,
)]
pub struct NoteIndex(u64);
impl NoteIndex {
pub fn next(self) -> Self {
Self(self.0 + 1)
}
pub fn as_u64(self) -> u64 {
self.0
}
#[allow(unused)]
fn from_u64(v: u64) -> Self {
Self(v)
}
pub fn advance(&mut self) {
*self = self.next()
}
}
impl std::fmt::Display for NoteIndex {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.fmt(f)
}
}
struct OOBSpendTag;
impl sha256t::Tag for OOBSpendTag {
fn engine() -> sha256::HashEngine {
let mut engine = sha256::HashEngine::default();
engine.input(b"oob-spend");
engine
}
}
struct OOBReissueTag;
impl sha256t::Tag for OOBReissueTag {
fn engine() -> sha256::HashEngine {
let mut engine = sha256::HashEngine::default();
engine.input(b"oob-reissue");
engine
}
}
#[cfg(test)]
mod tests {
use fedimint_core::config::FederationId;
use fedimint_core::{Amount, Tiered, TieredMulti, TieredSummary};
use itertools::Itertools;
use crate::{select_notes_from_stream, OOBNotes};
#[test_log::test(tokio::test)]
async fn select_notes_avg_test() {
let max_amount = Amount::from_sats(1000000);
let tiers = Tiered::gen_denominations(2, max_amount);
let tiered =
TieredSummary::represent_amount::<()>(max_amount, &Default::default(), &tiers, 3);
let mut total_notes = 0;
for multiplier in 1..100 {
let stream = reverse_sorted_note_stream(tiered.iter().collect());
let select =
select_notes_from_stream(stream, Amount::from_sats(multiplier * 1000)).await;
total_notes += select.unwrap().into_iter_items().count();
}
assert_eq!(total_notes / 100, 10);
}
#[test_log::test(tokio::test)]
async fn select_notes_returns_exact_amount_with_minimum_notes() {
let f = || {
reverse_sorted_note_stream(vec![
(Amount::from_sats(1), 10),
(Amount::from_sats(5), 10),
(Amount::from_sats(20), 10),
])
};
assert_eq!(
select_notes_from_stream(f(), Amount::from_sats(7))
.await
.unwrap(),
notes(vec![(Amount::from_sats(1), 2), (Amount::from_sats(5), 1)])
);
assert_eq!(
select_notes_from_stream(f(), Amount::from_sats(20))
.await
.unwrap(),
notes(vec![(Amount::from_sats(20), 1)])
);
}
#[test_log::test(tokio::test)]
async fn select_notes_returns_next_smallest_amount_if_exact_change_cannot_be_made() {
let stream = reverse_sorted_note_stream(vec![
(Amount::from_sats(1), 1),
(Amount::from_sats(5), 5),
(Amount::from_sats(20), 5),
]);
assert_eq!(
select_notes_from_stream(stream, Amount::from_sats(7))
.await
.unwrap(),
notes(vec![(Amount::from_sats(5), 2)])
);
}
#[test_log::test(tokio::test)]
async fn select_notes_uses_big_note_if_small_amounts_are_not_sufficient() {
let stream = reverse_sorted_note_stream(vec![
(Amount::from_sats(1), 3),
(Amount::from_sats(5), 3),
(Amount::from_sats(20), 2),
]);
assert_eq!(
select_notes_from_stream(stream, Amount::from_sats(39))
.await
.unwrap(),
notes(vec![(Amount::from_sats(20), 2)])
);
}
#[test_log::test(tokio::test)]
async fn select_notes_returns_error_if_amount_is_too_large() {
let stream = reverse_sorted_note_stream(vec![(Amount::from_sats(10), 1)]);
let error = select_notes_from_stream(stream, Amount::from_sats(100))
.await
.unwrap_err();
assert_eq!(error.total_amount, Amount::from_sats(10));
}
fn reverse_sorted_note_stream(
notes: Vec<(Amount, usize)>,
) -> impl futures::Stream<Item = (Amount, String)> {
futures::stream::iter(
notes
.into_iter()
.flat_map(|(amount, number)| vec![(amount, "dummy note".into()); number])
.sorted()
.rev(),
)
}
fn notes(notes: Vec<(Amount, usize)>) -> TieredMulti<String> {
notes
.into_iter()
.flat_map(|(amount, number)| vec![(amount, "dummy note".into()); number])
.collect()
}
#[test]
fn decoding_empty_oob_notes_fails() {
let empty_oob_notes = OOBNotes::new(FederationId::dummy().to_prefix(), Default::default());
let oob_notes_string = empty_oob_notes.to_string();
let res = oob_notes_string.parse::<OOBNotes>();
assert!(res.is_err(), "An empty OOB notes string should not parse");
}
}