use std::collections::BTreeMap;
use std::str::FromStr;
use std::time::Duration;
use std::{ffi, iter};
use anyhow::{Context, bail};
use clap::{Parser, Subcommand};
use fedimint_core::encoding::{Decodable, Encodable};
use fedimint_core::module::registry::ModuleDecoderRegistry;
use fedimint_core::{Amount, PeerId, TieredMulti};
use futures::StreamExt;
use futures::future::join_all;
use serde::Serialize;
use serde_json::json;
use tracing::{info, warn};
use crate::api::MintFederationApi;
use crate::{
BlindNonce, MintClientModule, Nonce, OOBNotes, ReissueExternalNotesState,
SelectNotesWithAtleastAmount, SelectNotesWithExactAmount,
};
#[derive(Parser, Serialize)]
enum Opts {
Reissue { notes: OOBNotes },
Spend {
amount: Amount,
#[clap(long)]
allow_overpay: bool,
#[clap(long, default_value_t = 60 * 60 * 24 * 7)]
timeout: u64,
#[clap(long)]
include_invite: bool,
},
Split { oob_notes: OOBNotes },
Combine {
#[clap(required = true)]
oob_notes: Vec<OOBNotes>,
},
Validate {
#[clap(long)]
online: bool,
oob_notes: OOBNotes,
},
Dev {
#[clap(subcommand)]
command: DevOpts,
},
}
#[derive(Subcommand, Serialize)]
enum DevOpts {
CheckNonce {
nonce: String,
},
CheckBlindNonce {
blind_nonce: String,
},
}
#[derive(Serialize)]
#[serde(untagged)]
enum PeerCheckResult {
Answer(bool),
Error(String),
}
async fn check_nonce_spent(
mint: &MintClientModule,
nonce: Nonce,
) -> BTreeMap<PeerId, PeerCheckResult> {
let api = mint.client_ctx.module_api();
join_all(api.all_peers().iter().map(|&peer| {
let api = &api;
async move {
let result = match api.check_note_spent_single_peer(peer, nonce).await {
Ok(spent) => PeerCheckResult::Answer(spent),
Err(e) => PeerCheckResult::Error(format!("error: {e}")),
};
(peer, result)
}
}))
.await
.into_iter()
.collect()
}
async fn check_blind_nonce_used(
mint: &MintClientModule,
blind_nonce: BlindNonce,
) -> BTreeMap<PeerId, PeerCheckResult> {
let api = mint.client_ctx.module_api();
join_all(api.all_peers().iter().map(|&peer| {
let api = &api;
async move {
let result = match api
.check_blind_nonce_used_single_peer(peer, blind_nonce)
.await
{
Ok(used) => PeerCheckResult::Answer(used),
Err(e) => PeerCheckResult::Error(format!("error: {e}")),
};
(peer, result)
}
}))
.await
.into_iter()
.collect()
}
async fn check_nonce(mint: &MintClientModule, nonce: &str) -> anyhow::Result<serde_json::Value> {
if let Ok(nonce) = Nonce::consensus_decode_hex(nonce, &ModuleDecoderRegistry::default()) {
return Ok(json!({
"nonce": nonce.consensus_encode_to_hex(),
"spent": check_nonce_spent(mint, nonce).await,
}));
}
let oob_notes = OOBNotes::from_str(nonce)
.context("Argument is neither a hex-encoded nonce nor an e-cash notes string")?;
let mut nonces = Vec::new();
for (amount, note) in oob_notes.notes().iter_items() {
let nonce = note.nonce();
nonces.push(json!({
"nonce": nonce.consensus_encode_to_hex(),
"amount_msat": amount.msats,
"spent": check_nonce_spent(mint, nonce).await,
}));
}
Ok(json!({ "nonces": nonces }))
}
async fn check_blind_nonce(
mint: &MintClientModule,
blind_nonce: &str,
) -> anyhow::Result<serde_json::Value> {
let blind_nonce =
BlindNonce::consensus_decode_hex(blind_nonce, &ModuleDecoderRegistry::default())
.context("Argument is not a hex-encoded blind nonce")?;
Ok(json!({
"blind_nonce": blind_nonce.consensus_encode_to_hex(),
"issued": check_blind_nonce_used(mint, blind_nonce).await,
}))
}
async fn spend(
mint: &MintClientModule,
amount: Amount,
allow_overpay: bool,
timeout: u64,
include_invite: bool,
) -> anyhow::Result<serde_json::Value> {
warn!(
"The client will try to double-spend these notes after the timeout to reclaim \
any unclaimed e-cash."
);
let timeout = Duration::from_secs(timeout);
let (operation, notes) = if allow_overpay {
let (operation, notes) = mint
.spend_notes_with_selector(
&SelectNotesWithAtleastAmount,
amount,
Some(timeout),
include_invite,
(),
)
.await?;
let overspend_amount = notes.total_amount().saturating_sub(amount);
if overspend_amount != Amount::ZERO {
warn!("Selected notes {overspend_amount} worth more than requested");
}
(operation, notes)
} else {
mint.spend_notes_with_selector(
&SelectNotesWithExactAmount,
amount,
Some(timeout),
include_invite,
(),
)
.await?
};
info!("Spend e-cash operation: {}", operation.fmt_short());
Ok(json!({ "notes": notes }))
}
fn split(oob_notes: &OOBNotes) -> serde_json::Value {
let federation = oob_notes.federation_id_prefix();
let notes = oob_notes
.notes()
.iter()
.map(|(amount, notes)| {
let notes = notes
.iter()
.map(|note| {
OOBNotes::new(
federation,
TieredMulti::new(vec![(amount, vec![*note])].into_iter().collect()),
)
})
.collect::<Vec<_>>();
(amount, notes)
})
.collect::<BTreeMap<_, _>>();
json!({ "notes": notes })
}
fn combine(oob_notes: &[OOBNotes]) -> anyhow::Result<serde_json::Value> {
let federation_id_prefix = {
let mut prefixes = oob_notes.iter().map(OOBNotes::federation_id_prefix);
let first = prefixes
.next()
.expect("At least one e-cash notes string expected");
for prefix in prefixes {
if prefix != first {
bail!("Trying to combine e-cash from different federations: {first} and {prefix}");
}
}
first
};
let combined_notes = oob_notes
.iter()
.flat_map(|notes| notes.notes().iter_items().map(|(amt, note)| (amt, *note)))
.collect();
let combined_oob_notes = OOBNotes::new(federation_id_prefix, combined_notes);
Ok(json!({ "notes": combined_oob_notes }))
}
pub(crate) async fn handle_cli_command(
mint: &MintClientModule,
args: &[ffi::OsString],
) -> anyhow::Result<serde_json::Value> {
let opts = Opts::parse_from(iter::once(&ffi::OsString::from("mint")).chain(args.iter()));
match opts {
Opts::Reissue { notes } => {
let amount = notes.total_amount();
let operation_id = mint.reissue_external_notes(notes, ()).await?;
let mut updates = mint
.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}");
}
}
Ok(serde_json::to_value(amount).expect("JSON serialization failed"))
}
Opts::Spend {
amount,
allow_overpay,
timeout,
include_invite,
} => spend(mint, amount, allow_overpay, timeout, include_invite).await,
Opts::Split { oob_notes } => Ok(split(&oob_notes)),
Opts::Combine { oob_notes } => combine(&oob_notes),
Opts::Validate { oob_notes, online } => {
let amount = mint.validate_notes(&oob_notes)?;
if online {
let any_spent = mint.check_note_spent(&oob_notes).await?;
Ok(json!({
"any_spent": any_spent,
"amount_msat": amount,
}))
} else {
Ok(json!({ "amount_msat": amount }))
}
}
Opts::Dev { command } => match command {
DevOpts::CheckNonce { nonce } => check_nonce(mint, &nonce).await,
DevOpts::CheckBlindNonce { blind_nonce } => check_blind_nonce(mint, &blind_nonce).await,
},
}
}
#[cfg(test)]
mod tests {
use bls12_381::G1Affine;
use tbs::BlindedMessage;
use super::*;
#[test]
fn nonce_hex_round_trip() {
let nonce_hex = "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798";
let nonce = Nonce::consensus_decode_hex(nonce_hex, &ModuleDecoderRegistry::default())
.expect("Valid compressed public key");
assert_eq!(nonce.consensus_encode_to_hex(), nonce_hex);
}
#[test]
fn blind_nonce_hex_round_trip() {
let blind_nonce = BlindNonce(BlindedMessage(G1Affine::generator()));
let blind_nonce_hex = blind_nonce.consensus_encode_to_hex();
assert_eq!(blind_nonce_hex.len(), 96);
assert_eq!(
BlindNonce::consensus_decode_hex(&blind_nonce_hex, &ModuleDecoderRegistry::default())
.expect("Valid compressed G1 point"),
blind_nonce
);
}
}