send_token_at split the mint swap's results with partition(send_denoms.contains(amount)) — membership, not count. When a change denomination equaled a send denomination (guaranteed when spending from a proof worth exactly 2x the amount: send [n], change [n]), the change proofs were serialized INTO the token, and the receiver redeemed double. Both the wallet send flow and peer-files purchases mint through this path. The split now consumes exactly one proof per required send denomination (everything else returns to the wallet as change) and hard-fails if the mint returns an incomplete denomination set, so a token can never again be silently over- or under-packed. Note: tokens minted BEFORE this fix already contain the extra proofs and will still redeem at their inflated value. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1879 lines
64 KiB
Rust
1879 lines
64 KiB
Rust
//! Cashu ecash wallet for peer-to-peer micropayments and streaming data payments.
|
||
//!
|
||
//! Real Cashu protocol implementation using BDHKE blind signatures.
|
||
//! Connects to Cashu-compatible mints (local Fedimint or external) for
|
||
//! mint/melt/swap operations. Stores proofs locally in the data directory.
|
||
|
||
use super::cashu::{amount_to_denominations, CashuToken, Proof};
|
||
use super::mint_client::MintClient;
|
||
use anyhow::{Context, Result};
|
||
use serde::{Deserialize, Serialize};
|
||
use std::path::Path;
|
||
use tokio::fs;
|
||
use tracing::{debug, info, warn};
|
||
|
||
const WALLET_FILE: &str = "wallet/ecash.json";
|
||
const MINTS_FILE: &str = "wallet/accepted_mints.json";
|
||
|
||
/// Transaction type for history tracking.
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
#[serde(rename_all = "lowercase")]
|
||
pub enum TransactionType {
|
||
Mint,
|
||
Melt,
|
||
Send,
|
||
Receive,
|
||
StreamingPayment,
|
||
StreamingRevenue,
|
||
}
|
||
|
||
/// Transaction history entry.
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct EcashTransaction {
|
||
pub id: String,
|
||
pub tx_type: TransactionType,
|
||
pub amount_sats: u64,
|
||
pub timestamp: String,
|
||
#[serde(default)]
|
||
pub description: String,
|
||
/// Mint URL involved in this transaction.
|
||
#[serde(default)]
|
||
pub mint_url: String,
|
||
/// Peer identifier (DID, pubkey, or onion) if applicable.
|
||
#[serde(default)]
|
||
pub peer: String,
|
||
/// Which ecash system this entry belongs to: "cashu" or "fedimint". Lets the
|
||
/// unified history/UI label each transaction. Defaults to "cashu" so legacy
|
||
/// stored entries (written before dual-ecash) read back correctly.
|
||
#[serde(default = "default_tx_kind")]
|
||
pub kind: String,
|
||
}
|
||
|
||
/// Default `kind` for transactions persisted before the dual-ecash split.
|
||
pub fn default_tx_kind() -> String {
|
||
"cashu".to_string()
|
||
}
|
||
|
||
/// A stored proof with metadata.
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct StoredProof {
|
||
/// The Cashu proof.
|
||
#[serde(flatten)]
|
||
pub proof: Proof,
|
||
/// Mint URL this proof is from.
|
||
pub mint_url: String,
|
||
/// Whether this proof has been spent (sent to someone or melted).
|
||
#[serde(default)]
|
||
pub spent: bool,
|
||
/// Whether this proof is reserved (allocated to an in-progress operation).
|
||
#[serde(default)]
|
||
pub reserved: bool,
|
||
/// Timestamp when received.
|
||
pub created_at: String,
|
||
}
|
||
|
||
/// Persistent wallet state.
|
||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||
pub struct WalletState {
|
||
/// All proofs (spent and unspent).
|
||
pub proofs: Vec<StoredProof>,
|
||
/// Transaction history.
|
||
pub transactions: Vec<EcashTransaction>,
|
||
/// Primary mint URL.
|
||
#[serde(default)]
|
||
pub mint_url: String,
|
||
|
||
// ── Legacy compatibility ──
|
||
// Old wallet format had a `tokens` field. If present during deserialization,
|
||
// we migrate to proofs. This field is never written.
|
||
#[serde(default, skip_serializing)]
|
||
tokens: Vec<serde_json::Value>,
|
||
}
|
||
|
||
/// Accepted mints configuration.
|
||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||
pub struct AcceptedMints {
|
||
/// List of mint URLs we accept tokens from.
|
||
pub mints: Vec<String>,
|
||
}
|
||
|
||
impl WalletState {
|
||
/// Total balance of unspent, unreserved proofs.
|
||
pub fn balance(&self) -> u64 {
|
||
self.proofs
|
||
.iter()
|
||
.filter(|p| !p.spent && !p.reserved)
|
||
.map(|p| p.proof.amount)
|
||
.sum()
|
||
}
|
||
|
||
/// Balance from a specific mint.
|
||
pub fn balance_for_mint(&self, mint_url: &str) -> u64 {
|
||
self.proofs
|
||
.iter()
|
||
.filter(|p| !p.spent && !p.reserved && p.mint_url == mint_url)
|
||
.map(|p| p.proof.amount)
|
||
.sum()
|
||
}
|
||
|
||
/// Spendable (unspent, unreserved) balance grouped by mint URL.
|
||
pub fn spendable_by_mint(&self) -> Vec<(String, u64)> {
|
||
use std::collections::BTreeMap;
|
||
let mut by_mint: BTreeMap<String, u64> = BTreeMap::new();
|
||
for p in &self.proofs {
|
||
if !p.spent && !p.reserved {
|
||
*by_mint.entry(p.mint_url.clone()).or_default() += p.proof.amount;
|
||
}
|
||
}
|
||
by_mint.into_iter().collect()
|
||
}
|
||
|
||
/// Select unspent proofs that cover at least `amount` sats from a specific mint.
|
||
/// Returns selected proofs and any overpayment amount.
|
||
pub fn select_proofs(&self, mint_url: &str, amount: u64) -> Option<(Vec<usize>, u64)> {
|
||
let mut candidates: Vec<(usize, u64)> = self
|
||
.proofs
|
||
.iter()
|
||
.enumerate()
|
||
.filter(|(_, p)| !p.spent && !p.reserved && p.mint_url == mint_url)
|
||
.map(|(i, p)| (i, p.proof.amount))
|
||
.collect();
|
||
|
||
// Sort by amount ascending for efficient selection
|
||
candidates.sort_by_key(|&(_, a)| a);
|
||
|
||
let mut selected = Vec::new();
|
||
let mut total = 0u64;
|
||
|
||
for (idx, amt) in &candidates {
|
||
if total >= amount {
|
||
break;
|
||
}
|
||
selected.push(*idx);
|
||
total += amt;
|
||
}
|
||
|
||
if total >= amount {
|
||
Some((selected, total - amount))
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
|
||
/// Mark proofs at given indices as spent.
|
||
pub fn mark_spent(&mut self, indices: &[usize]) {
|
||
for &i in indices {
|
||
if i < self.proofs.len() {
|
||
self.proofs[i].spent = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Add new proofs to the wallet.
|
||
pub fn add_proofs(&mut self, mint_url: &str, proofs: Vec<Proof>) {
|
||
let now = chrono::Utc::now().to_rfc3339();
|
||
for proof in proofs {
|
||
self.proofs.push(StoredProof {
|
||
proof,
|
||
mint_url: mint_url.to_string(),
|
||
spent: false,
|
||
reserved: false,
|
||
created_at: now.clone(),
|
||
});
|
||
}
|
||
}
|
||
|
||
/// Record a transaction.
|
||
pub fn record_tx(
|
||
&mut self,
|
||
tx_type: TransactionType,
|
||
amount_sats: u64,
|
||
description: &str,
|
||
mint_url: &str,
|
||
peer: &str,
|
||
) {
|
||
self.transactions.push(EcashTransaction {
|
||
id: uuid::Uuid::new_v4().to_string(),
|
||
tx_type,
|
||
amount_sats,
|
||
timestamp: chrono::Utc::now().to_rfc3339(),
|
||
description: description.to_string(),
|
||
mint_url: mint_url.to_string(),
|
||
peer: peer.to_string(),
|
||
kind: "cashu".to_string(),
|
||
});
|
||
}
|
||
|
||
/// Prune spent proofs older than 30 days to keep wallet file manageable.
|
||
pub fn prune_old_spent(&mut self) {
|
||
let cutoff = chrono::Utc::now() - chrono::Duration::days(30);
|
||
let cutoff_str = cutoff.to_rfc3339();
|
||
self.proofs
|
||
.retain(|p| !p.spent || p.created_at > cutoff_str);
|
||
}
|
||
}
|
||
|
||
/// Load wallet state from disk.
|
||
pub async fn load_wallet(data_dir: &Path) -> Result<WalletState> {
|
||
let path = data_dir.join(WALLET_FILE);
|
||
if !path.exists() {
|
||
return Ok(WalletState {
|
||
mint_url: default_mint_url(),
|
||
..Default::default()
|
||
});
|
||
}
|
||
let content = fs::read_to_string(&path)
|
||
.await
|
||
.context("Failed to read wallet file")?;
|
||
let mut wallet: WalletState = serde_json::from_str(&content).unwrap_or_default();
|
||
|
||
// Set default mint URL if empty
|
||
if wallet.mint_url.is_empty() {
|
||
wallet.mint_url = default_mint_url();
|
||
}
|
||
|
||
Ok(wallet)
|
||
}
|
||
|
||
/// Save wallet state to disk.
|
||
pub async fn save_wallet(data_dir: &Path, wallet: &WalletState) -> Result<()> {
|
||
let dir = data_dir.join("wallet");
|
||
fs::create_dir_all(&dir)
|
||
.await
|
||
.context("Failed to create wallet dir")?;
|
||
let path = data_dir.join(WALLET_FILE);
|
||
let content = serde_json::to_string_pretty(wallet).context("Failed to serialize wallet")?;
|
||
fs::write(&path, content)
|
||
.await
|
||
.context("Failed to write wallet file")?;
|
||
Ok(())
|
||
}
|
||
|
||
/// Load accepted mints list.
|
||
pub async fn load_accepted_mints(data_dir: &Path) -> Result<AcceptedMints> {
|
||
let path = data_dir.join(MINTS_FILE);
|
||
if !path.exists() {
|
||
return Ok(AcceptedMints {
|
||
mints: vec![default_mint_url()],
|
||
});
|
||
}
|
||
let content = fs::read_to_string(&path)
|
||
.await
|
||
.context("Failed to read accepted mints")?;
|
||
let mints: AcceptedMints = serde_json::from_str(&content).unwrap_or(AcceptedMints {
|
||
mints: vec![default_mint_url()],
|
||
});
|
||
Ok(mints)
|
||
}
|
||
|
||
/// Save accepted mints list.
|
||
pub async fn save_accepted_mints(data_dir: &Path, mints: &AcceptedMints) -> Result<()> {
|
||
let dir = data_dir.join("wallet");
|
||
fs::create_dir_all(&dir)
|
||
.await
|
||
.context("Failed to create wallet dir")?;
|
||
let path = data_dir.join(MINTS_FILE);
|
||
let content =
|
||
serde_json::to_string_pretty(mints).context("Failed to serialize accepted mints")?;
|
||
fs::write(&path, content)
|
||
.await
|
||
.context("Failed to write accepted mints")?;
|
||
Ok(())
|
||
}
|
||
|
||
/// Request a mint quote — returns a Lightning invoice to pay.
|
||
pub async fn mint_quote(
|
||
data_dir: &Path,
|
||
amount_sats: u64,
|
||
) -> Result<super::mint_client::MintQuote> {
|
||
let wallet = load_wallet(data_dir).await?;
|
||
let client = MintClient::new(&wallet.mint_url)?;
|
||
client.mint_quote(amount_sats).await
|
||
}
|
||
|
||
/// Mint new ecash tokens after a Lightning invoice has been paid.
|
||
pub async fn mint_tokens(data_dir: &Path, quote_id: &str, amount_sats: u64) -> Result<u64> {
|
||
let mut wallet = load_wallet(data_dir).await?;
|
||
let mint_url = wallet.mint_url.clone();
|
||
let client = MintClient::new(&mint_url)?;
|
||
|
||
let result = client.mint_tokens(quote_id, amount_sats).await?;
|
||
let minted: u64 = result.proofs.iter().map(|p| p.amount).sum();
|
||
|
||
wallet.add_proofs(&mint_url, result.proofs);
|
||
wallet.record_tx(
|
||
TransactionType::Mint,
|
||
minted,
|
||
&format!("Minted {} sats from Lightning", minted),
|
||
&mint_url,
|
||
"",
|
||
);
|
||
save_wallet(data_dir, &wallet).await?;
|
||
|
||
debug!("Minted {} sats ecash", minted);
|
||
Ok(minted)
|
||
}
|
||
|
||
/// Request a melt quote — how much to pay a Lightning invoice with ecash.
|
||
pub async fn melt_quote(data_dir: &Path, bolt11: &str) -> Result<super::mint_client::MeltQuote> {
|
||
let wallet = load_wallet(data_dir).await?;
|
||
let client = MintClient::new(&wallet.mint_url)?;
|
||
client.melt_quote(bolt11).await
|
||
}
|
||
|
||
/// Melt ecash tokens to pay a Lightning invoice.
|
||
pub async fn melt_tokens(data_dir: &Path, quote_id: &str, bolt11: &str) -> Result<u64> {
|
||
let mut wallet = load_wallet(data_dir).await?;
|
||
let mint_url = wallet.mint_url.clone();
|
||
let client = MintClient::new(&mint_url)?;
|
||
|
||
// Get the melt quote to know the amount needed
|
||
let quote = client.melt_quote(bolt11).await?;
|
||
let total_needed = quote.amount + quote.fee_reserve;
|
||
|
||
// Select proofs to cover the amount
|
||
let (indices, _overpayment) =
|
||
wallet
|
||
.select_proofs(&mint_url, total_needed)
|
||
.ok_or_else(|| {
|
||
anyhow::anyhow!(
|
||
"Insufficient balance: need {} sats, have {} sats",
|
||
total_needed,
|
||
wallet.balance_for_mint(&mint_url)
|
||
)
|
||
})?;
|
||
|
||
let proofs: Vec<Proof> = indices
|
||
.iter()
|
||
.map(|&i| wallet.proofs[i].proof.clone())
|
||
.collect();
|
||
let spent_amount: u64 = proofs.iter().map(|p| p.amount).sum();
|
||
|
||
// Execute melt
|
||
let result = client.melt_tokens(quote_id, &proofs).await?;
|
||
|
||
// Mark proofs as spent
|
||
wallet.mark_spent(&indices);
|
||
wallet.record_tx(
|
||
TransactionType::Melt,
|
||
quote.amount,
|
||
&format!(
|
||
"Melted {} sats to Lightning (fee: {})",
|
||
quote.amount, quote.fee_reserve
|
||
),
|
||
&mint_url,
|
||
"",
|
||
);
|
||
|
||
// If there was overpayment and the melt returned change, we could handle it here.
|
||
// For now, any overpayment beyond fee_reserve is lost (mints may return change in future NUTs).
|
||
let _ = result;
|
||
let _ = spent_amount;
|
||
|
||
save_wallet(data_dir, &wallet).await?;
|
||
debug!("Melted {} sats to Lightning", quote.amount);
|
||
Ok(quote.amount)
|
||
}
|
||
|
||
// ── Cross-mint settlement (plan §2a / phasing F2) ──────────────────────────
|
||
//
|
||
// The wallet data model is already multi-mint (proofs, balances and selection
|
||
// are all keyed by mint URL). What was hardcoded to the home mint is the
|
||
// convenience layer. These `*_at` helpers parameterize that layer by target
|
||
// mint, and `swap_between_mints` moves value across mints over Lightning so a
|
||
// node holding tokens on mint A can pay a seeder that only accepts mint B.
|
||
|
||
/// How long to wait for a target mint's Lightning invoice to settle before
|
||
/// claiming the freshly-minted tokens.
|
||
const SWAP_CLAIM_TIMEOUT_SECS: u64 = 60;
|
||
/// Poll interval while waiting for the invoice to settle.
|
||
const SWAP_CLAIM_POLL_SECS: u64 = 2;
|
||
|
||
/// Whether we trust a mint enough to swap value *into* it (or accept its tokens).
|
||
///
|
||
/// The local Fedimint (home mint) is always trusted; any other mint must be on
|
||
/// the configured accepted-mints allow-list. Comparison ignores a trailing
|
||
/// slash so advertised URLs match stored ones. See plan §2a "Mint trust list".
|
||
pub async fn is_mint_trusted(data_dir: &Path, mint_url: &str) -> Result<bool> {
|
||
let norm = |s: &str| s.trim_end_matches('/').to_string();
|
||
let target = norm(mint_url);
|
||
if target == norm(&default_mint_url()) {
|
||
return Ok(true);
|
||
}
|
||
let accepted = load_accepted_mints(data_dir).await?;
|
||
Ok(accepted.mints.iter().any(|m| norm(m) == target))
|
||
}
|
||
|
||
/// All-in cost of a swap, relative to the amount actually delivered.
|
||
///
|
||
/// `total_paid` is what the source mint charges (melt amount + LN fee reserve);
|
||
/// `amount_delivered` is what lands on the target mint. The difference is the
|
||
/// fee the user pays to move value across mints.
|
||
fn swap_fee(total_paid: u64, amount_delivered: u64) -> u64 {
|
||
total_paid.saturating_sub(amount_delivered)
|
||
}
|
||
|
||
/// Move `amount_sats` of value from mint `from_mint` to mint `to_mint` over
|
||
/// Lightning, returning the amount claimed on the target mint.
|
||
///
|
||
/// Flow (Cashu/Fedimint settle over BOLT11):
|
||
/// 1. `mint_quote` on the target → a Lightning invoice to pay.
|
||
/// 2. `melt_quote` on the source → the cost in source tokens (amount + fee).
|
||
/// 3. Fee-cap check: refuse if the all-in fee exceeds `max_fee_sats`.
|
||
/// 4. Select source proofs and `melt` them to pay the target's invoice.
|
||
/// 5. Once the invoice settles, `mint` (claim) the tokens on the target.
|
||
///
|
||
/// Crash-safety: the source spend is persisted *before* the claim, so a crash
|
||
/// between paying and claiming never double-spends — at worst the target tokens
|
||
/// are left unclaimed (reconcilable from the mint quote id). Idempotent resume
|
||
/// is phasing step 3 (deferred).
|
||
pub async fn swap_between_mints(
|
||
data_dir: &Path,
|
||
from_mint: &str,
|
||
to_mint: &str,
|
||
amount_sats: u64,
|
||
max_fee_sats: u64,
|
||
) -> Result<u64> {
|
||
if amount_sats == 0 {
|
||
anyhow::bail!("swap amount must be greater than zero");
|
||
}
|
||
let norm = |s: &str| s.trim_end_matches('/').to_string();
|
||
if norm(from_mint) == norm(to_mint) {
|
||
anyhow::bail!("swap source and target mints are identical");
|
||
}
|
||
if !is_mint_trusted(data_dir, to_mint).await? {
|
||
anyhow::bail!(
|
||
"target mint '{}' is not in the trusted/accepted mint list",
|
||
to_mint
|
||
);
|
||
}
|
||
|
||
let from = MintClient::new(from_mint)?;
|
||
let to = MintClient::new(to_mint)?;
|
||
|
||
// 1. Mint quote on the target → invoice to pay.
|
||
let mint_quote = to
|
||
.mint_quote(amount_sats)
|
||
.await
|
||
.with_context(|| format!("requesting mint quote at target mint {}", to_mint))?;
|
||
|
||
// 2. Melt quote on the source for that invoice → cost in source tokens.
|
||
let melt_quote = from
|
||
.melt_quote(&mint_quote.request)
|
||
.await
|
||
.with_context(|| format!("requesting melt quote at source mint {}", from_mint))?;
|
||
let total_needed = melt_quote.amount + melt_quote.fee_reserve;
|
||
let fee = swap_fee(total_needed, amount_sats);
|
||
|
||
// 3. Fee-cap check — caller falls back to free origin if too expensive.
|
||
if fee > max_fee_sats {
|
||
anyhow::bail!(
|
||
"swap fee {} sats exceeds cap {} sats (need {} on {} to deliver {} on {})",
|
||
fee,
|
||
max_fee_sats,
|
||
total_needed,
|
||
from_mint,
|
||
amount_sats,
|
||
to_mint
|
||
);
|
||
}
|
||
|
||
// 4. Select source proofs and melt them to pay the target invoice.
|
||
let mut wallet = load_wallet(data_dir).await?;
|
||
let (indices, _overpayment) =
|
||
wallet
|
||
.select_proofs(from_mint, total_needed)
|
||
.ok_or_else(|| {
|
||
anyhow::anyhow!(
|
||
"insufficient balance on {}: need {} sats, have {} sats",
|
||
from_mint,
|
||
total_needed,
|
||
wallet.balance_for_mint(from_mint)
|
||
)
|
||
})?;
|
||
let proofs: Vec<Proof> = indices
|
||
.iter()
|
||
.map(|&i| wallet.proofs[i].proof.clone())
|
||
.collect();
|
||
|
||
if let Err(e) = from.melt_tokens(&melt_quote.quote, &proofs).await {
|
||
// The pay leg never completed — record the route failure so future
|
||
// payments can prefer a route with a track record.
|
||
record_swap_failure(data_dir, from_mint, to_mint).await;
|
||
return Err(e).with_context(|| {
|
||
format!(
|
||
"melting source proofs at {} to pay target invoice",
|
||
from_mint
|
||
)
|
||
});
|
||
}
|
||
|
||
// Persist the spend BEFORE claiming so a crash can't double-spend, and
|
||
// journal the in-flight swap so the claim can be resumed after a crash.
|
||
wallet.mark_spent(&indices);
|
||
wallet.record_tx(
|
||
TransactionType::Melt,
|
||
total_needed,
|
||
&format!(
|
||
"Cross-mint swap {}→{}: paid {} sats (fee {})",
|
||
from_mint, to_mint, total_needed, fee
|
||
),
|
||
from_mint,
|
||
to_mint,
|
||
);
|
||
save_wallet(data_dir, &wallet).await?;
|
||
add_pending_swap(
|
||
data_dir,
|
||
PendingSwap {
|
||
from_mint: from_mint.to_string(),
|
||
to_mint: to_mint.to_string(),
|
||
amount_sats,
|
||
melt_quote_id: melt_quote.quote.clone(),
|
||
mint_quote_id: mint_quote.quote.clone(),
|
||
created_at: chrono::Utc::now().to_rfc3339(),
|
||
},
|
||
)
|
||
.await?;
|
||
|
||
// 5. Wait for the invoice to settle, then claim the minted tokens.
|
||
wait_for_mint_quote_paid(&to, &mint_quote.quote).await?;
|
||
let result = to
|
||
.mint_tokens(&mint_quote.quote, amount_sats)
|
||
.await
|
||
.with_context(|| format!("claiming minted tokens at target mint {}", to_mint))?;
|
||
let minted: u64 = result.proofs.iter().map(|p| p.amount).sum();
|
||
|
||
let mut wallet = load_wallet(data_dir).await?;
|
||
wallet.add_proofs(to_mint, result.proofs);
|
||
wallet.record_tx(
|
||
TransactionType::Mint,
|
||
minted,
|
||
&format!(
|
||
"Cross-mint swap {}→{}: claimed {} sats",
|
||
from_mint, to_mint, minted
|
||
),
|
||
to_mint,
|
||
from_mint,
|
||
);
|
||
save_wallet(data_dir, &wallet).await?;
|
||
|
||
// Swap fully settled — clear the journal entry and credit the route.
|
||
remove_pending_swap(data_dir, &mint_quote.quote).await?;
|
||
record_swap_success(data_dir, from_mint, to_mint).await;
|
||
|
||
debug!(
|
||
"Cross-mint swap complete: {} → {} delivered {} sats (fee {})",
|
||
from_mint, to_mint, minted, fee
|
||
);
|
||
Ok(minted)
|
||
}
|
||
|
||
/// Poll a mint quote until its Lightning invoice is paid (state `PAID`/`ISSUED`),
|
||
/// or time out. The melt above pays the invoice; the target mint sees it settle
|
||
/// shortly after.
|
||
async fn wait_for_mint_quote_paid(client: &MintClient, quote_id: &str) -> Result<()> {
|
||
let deadline = SWAP_CLAIM_TIMEOUT_SECS / SWAP_CLAIM_POLL_SECS.max(1);
|
||
for _ in 0..deadline.max(1) {
|
||
let status = client.mint_quote_status(quote_id).await?;
|
||
match status.state.as_str() {
|
||
"PAID" | "ISSUED" => return Ok(()),
|
||
_ => tokio::time::sleep(std::time::Duration::from_secs(SWAP_CLAIM_POLL_SECS)).await,
|
||
}
|
||
}
|
||
anyhow::bail!(
|
||
"target mint invoice for quote {} did not settle within {}s",
|
||
quote_id,
|
||
SWAP_CLAIM_TIMEOUT_SECS
|
||
)
|
||
}
|
||
|
||
/// Create a cashuA token string to send to a peer, drawing from the home mint.
|
||
pub async fn send_token(data_dir: &Path, amount_sats: u64) -> Result<String> {
|
||
let mint_url = load_wallet(data_dir).await?.mint_url;
|
||
send_token_at(data_dir, &mint_url, amount_sats).await
|
||
}
|
||
|
||
/// Create a cashuA token denominated in a specific mint's tokens.
|
||
///
|
||
/// Used by the payer-side cross-mint flow: after `swap_between_mints` lands value
|
||
/// on the seeder's accepted mint, we send a token from *that* mint so the seeder
|
||
/// only ever receives its own mint's proofs (see plan §2a, payer-side swap).
|
||
pub async fn send_token_at(data_dir: &Path, mint_url: &str, amount_sats: u64) -> Result<String> {
|
||
let mut wallet = load_wallet(data_dir).await?;
|
||
let mint_url = mint_url.to_string();
|
||
|
||
// Select proofs covering the amount
|
||
let (indices, overpayment) = wallet
|
||
.select_proofs(&mint_url, amount_sats)
|
||
.ok_or_else(|| {
|
||
anyhow::anyhow!(
|
||
"Insufficient balance: need {} sats, have {} sats",
|
||
amount_sats,
|
||
wallet.balance_for_mint(&mint_url)
|
||
)
|
||
})?;
|
||
|
||
let selected_proofs: Vec<Proof> = indices
|
||
.iter()
|
||
.map(|&i| wallet.proofs[i].proof.clone())
|
||
.collect();
|
||
|
||
// If there's overpayment, swap to get exact change
|
||
let send_proofs = if overpayment > 0 {
|
||
let client = MintClient::new(&mint_url)?;
|
||
let send_denoms = amount_to_denominations(amount_sats);
|
||
let change_denoms = amount_to_denominations(overpayment);
|
||
|
||
let mut all_target: Vec<u64> = send_denoms.clone();
|
||
all_target.extend(&change_denoms);
|
||
|
||
let swap_result = client.swap(&selected_proofs, &all_target).await?;
|
||
|
||
// Mark original proofs as spent
|
||
wallet.mark_spent(&indices);
|
||
|
||
// Separate send proofs from change proofs BY COUNT, not membership:
|
||
// partition(contains) put EVERY proof whose denomination appeared in
|
||
// send_denoms into the token — when change shared a denomination with
|
||
// the send (worst case: spending from a proof worth exactly 2× the
|
||
// amount, send [n] + change [n]), the change proofs rode along and the
|
||
// receiver was credited double. Consume exactly one proof per needed
|
||
// send denomination; everything else is change.
|
||
let mut send_needed = send_denoms.clone();
|
||
let mut send: Vec<Proof> = Vec::new();
|
||
let mut change: Vec<Proof> = Vec::new();
|
||
for p in swap_result.new_proofs {
|
||
if let Some(pos) = send_needed.iter().position(|&d| d == p.amount) {
|
||
send_needed.swap_remove(pos);
|
||
send.push(p);
|
||
} else {
|
||
change.push(p);
|
||
}
|
||
}
|
||
if !send_needed.is_empty() {
|
||
anyhow::bail!(
|
||
"Mint swap returned incomplete send denominations (missing {:?})",
|
||
send_needed
|
||
);
|
||
}
|
||
|
||
// Add change proofs back to wallet
|
||
if !change.is_empty() {
|
||
wallet.add_proofs(&mint_url, change);
|
||
}
|
||
|
||
send
|
||
} else {
|
||
// Exact amount — just mark as spent and use directly
|
||
wallet.mark_spent(&indices);
|
||
selected_proofs
|
||
};
|
||
|
||
// Serialize as cashuA token
|
||
let token = CashuToken::new(&mint_url, send_proofs);
|
||
let token_str = token.serialize()?;
|
||
|
||
wallet.record_tx(
|
||
TransactionType::Send,
|
||
amount_sats,
|
||
&format!("Sent {} sats ecash", amount_sats),
|
||
&mint_url,
|
||
"",
|
||
);
|
||
save_wallet(data_dir, &wallet).await?;
|
||
|
||
debug!("Created send token for {} sats", amount_sats);
|
||
Ok(token_str)
|
||
}
|
||
|
||
// ── Payer-side payment builder (plan §2a step 2) ───────────────────────────
|
||
//
|
||
// Given a seeder's advertised `accepted_mints`, pick the cheapest way to pay:
|
||
// spend tokens we already hold on an accepted mint (no fee), else swap value
|
||
// into a *trusted* accepted mint and pay from there. If neither is possible
|
||
// within budget, decline so the caller falls back to free origin.
|
||
|
||
/// How a payment of a given amount can be satisfied across our mints.
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub enum PaymentPlan {
|
||
/// We already hold enough on this accepted mint — pay directly, no swap fee.
|
||
Direct { mint_url: String },
|
||
/// Swap value from `from_mint` into the trusted accepted `to_mint`, then pay.
|
||
Swap { from_mint: String, to_mint: String },
|
||
/// No single mint can cover the amount (caller should use free origin).
|
||
Insufficient,
|
||
}
|
||
|
||
/// Decide how to pay `amount` sats to a seeder, given what we hold and which
|
||
/// mints the seeder accepts.
|
||
///
|
||
/// - `holdings`: our spendable `(mint_url, balance)` pairs (verbatim URLs).
|
||
/// - `accepted`: the seeder's `(mint_url, trusted)` pairs, where `trusted`
|
||
/// means the mint is on our swap-into allow-list (`is_mint_trusted`).
|
||
/// - Direct beats Swap (no fee). A Direct target needs no trust (we already
|
||
/// hold those tokens); a Swap target must be trusted. The home mint is
|
||
/// preferred as a tie-break for both legs (lowest friction).
|
||
///
|
||
/// Pure and synchronous so it can be unit-tested without a live mint. It does
|
||
/// not know swap fees; `swap_between_mints` enforces the fee cap and bails (→
|
||
/// origin fallback) if the chosen source can't cover amount + fee.
|
||
fn plan_payment(
|
||
holdings: &[(String, u64)],
|
||
accepted: &[(String, bool)],
|
||
amount: u64,
|
||
) -> PaymentPlan {
|
||
let norm = |s: &str| s.trim_end_matches('/').to_string();
|
||
let home = norm(&default_mint_url());
|
||
let held = |mint: &str| -> u64 {
|
||
holdings
|
||
.iter()
|
||
.filter(|(m, _)| norm(m) == norm(mint))
|
||
.map(|(_, b)| *b)
|
||
.sum()
|
||
};
|
||
|
||
// 1. Direct: any accepted mint we already hold enough on. Prefer home.
|
||
let mut direct: Vec<&(String, bool)> =
|
||
accepted.iter().filter(|(m, _)| held(m) >= amount).collect();
|
||
direct.sort_by_key(|(m, _)| norm(m) != home); // home (false) sorts first
|
||
if let Some((mint, _)) = direct.first() {
|
||
return PaymentPlan::Direct {
|
||
mint_url: mint.clone(),
|
||
};
|
||
}
|
||
|
||
// 2. Swap: a trusted accepted target + a source we hold that covers `amount`.
|
||
let mut targets: Vec<&(String, bool)> =
|
||
accepted.iter().filter(|(_, trusted)| *trusted).collect();
|
||
targets.sort_by_key(|(m, _)| norm(m) != home);
|
||
if let Some((to_mint, _)) = targets.first() {
|
||
// Largest source we hold that isn't the target itself.
|
||
let from = holdings
|
||
.iter()
|
||
.filter(|(m, b)| norm(m) != norm(to_mint) && *b >= amount)
|
||
.max_by_key(|(_, b)| *b);
|
||
if let Some((from_mint, _)) = from {
|
||
return PaymentPlan::Swap {
|
||
from_mint: from_mint.clone(),
|
||
to_mint: to_mint.clone(),
|
||
};
|
||
}
|
||
}
|
||
|
||
PaymentPlan::Insufficient
|
||
}
|
||
|
||
/// Build a cashuA token to pay a seeder `amount_sats`, denominated in one of the
|
||
/// seeder's `accepted_mints`. Auto-swaps across mints (up to `max_fee_sats`) when
|
||
/// we don't already hold the right mint. Returns the token string ready to send.
|
||
///
|
||
/// Errors (caller should fall back to free origin) when no accepted mint is
|
||
/// reachable within balance, no trusted swap target exists, or the swap exceeds
|
||
/// the fee cap.
|
||
pub async fn build_payment_token(
|
||
data_dir: &Path,
|
||
accepted_mints: &[String],
|
||
amount_sats: u64,
|
||
max_fee_sats: u64,
|
||
) -> Result<String> {
|
||
if amount_sats == 0 {
|
||
anyhow::bail!("payment amount must be greater than zero");
|
||
}
|
||
if accepted_mints.is_empty() {
|
||
anyhow::bail!("seeder advertised no accepted mints");
|
||
}
|
||
|
||
// Annotate each accepted mint with whether we trust swapping into it.
|
||
let mut accepted: Vec<(String, bool)> = Vec::with_capacity(accepted_mints.len());
|
||
for m in accepted_mints {
|
||
let trusted = is_mint_trusted(data_dir, m).await?;
|
||
accepted.push((m.clone(), trusted));
|
||
}
|
||
|
||
// Prefer swap targets with a liquidity track record. plan_payment's stable
|
||
// sort keeps the home mint first; within the rest, this orders by how
|
||
// reliably we've reached each target before (best routes first).
|
||
let liq = load_swap_liquidity(data_dir).await;
|
||
accepted.sort_by_key(|(m, _)| std::cmp::Reverse(target_liquidity_score(&liq, m)));
|
||
|
||
let holdings = load_wallet(data_dir).await?.spendable_by_mint();
|
||
|
||
match plan_payment(&holdings, &accepted, amount_sats) {
|
||
PaymentPlan::Direct { mint_url } => {
|
||
debug!(
|
||
"Payment plan: direct from {} for {} sats",
|
||
mint_url, amount_sats
|
||
);
|
||
send_token_at(data_dir, &mint_url, amount_sats).await
|
||
}
|
||
PaymentPlan::Swap { from_mint, to_mint } => {
|
||
debug!(
|
||
"Payment plan: swap {}→{} then pay {} sats (fee cap {})",
|
||
from_mint, to_mint, amount_sats, max_fee_sats
|
||
);
|
||
swap_between_mints(data_dir, &from_mint, &to_mint, amount_sats, max_fee_sats).await?;
|
||
send_token_at(data_dir, &to_mint, amount_sats).await
|
||
}
|
||
PaymentPlan::Insufficient => anyhow::bail!(
|
||
"cannot pay {} sats: no accepted mint covers it within balance/trust",
|
||
amount_sats
|
||
),
|
||
}
|
||
}
|
||
|
||
// ── F2 step 3 — hardening: idempotent swap resume + liquidity cache ─────────
|
||
|
||
const PENDING_SWAPS_FILE: &str = "wallet/pending_swaps.json";
|
||
const SWAP_LIQUIDITY_FILE: &str = "wallet/swap_liquidity.json";
|
||
|
||
/// An in-flight cross-mint swap, journaled the moment the source proofs are
|
||
/// melted (paid) so a crash before the target claim can be resumed instead of
|
||
/// silently losing the value. Removed once the target tokens are claimed.
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct PendingSwap {
|
||
pub from_mint: String,
|
||
pub to_mint: String,
|
||
pub amount_sats: u64,
|
||
/// Source-mint melt quote id (the leg already paid).
|
||
pub melt_quote_id: String,
|
||
/// Target-mint mint quote id (the leg to claim).
|
||
pub mint_quote_id: String,
|
||
pub created_at: String,
|
||
}
|
||
|
||
async fn load_pending_swaps(data_dir: &Path) -> Result<Vec<PendingSwap>> {
|
||
let path = data_dir.join(PENDING_SWAPS_FILE);
|
||
if !path.exists() {
|
||
return Ok(Vec::new());
|
||
}
|
||
let content = fs::read_to_string(&path).await?;
|
||
Ok(serde_json::from_str(&content).unwrap_or_default())
|
||
}
|
||
|
||
async fn save_pending_swaps(data_dir: &Path, swaps: &[PendingSwap]) -> Result<()> {
|
||
let dir = data_dir.join("wallet");
|
||
fs::create_dir_all(&dir).await?;
|
||
let path = data_dir.join(PENDING_SWAPS_FILE);
|
||
fs::write(&path, serde_json::to_string_pretty(swaps)?).await?;
|
||
Ok(())
|
||
}
|
||
|
||
async fn add_pending_swap(data_dir: &Path, swap: PendingSwap) -> Result<()> {
|
||
let mut all = load_pending_swaps(data_dir).await?;
|
||
all.push(swap);
|
||
save_pending_swaps(data_dir, &all).await
|
||
}
|
||
|
||
async fn remove_pending_swap(data_dir: &Path, mint_quote_id: &str) -> Result<()> {
|
||
let mut all = load_pending_swaps(data_dir).await?;
|
||
all.retain(|s| s.mint_quote_id != mint_quote_id);
|
||
save_pending_swaps(data_dir, &all).await
|
||
}
|
||
|
||
/// Resume any swaps that were interrupted between paying the source mint and
|
||
/// claiming the target tokens. For each pending swap, ask the target mint about
|
||
/// the mint quote:
|
||
/// - `PAID` → claim now (the value was paid but never claimed). Reclaimed.
|
||
/// - `ISSUED` → already claimed on a prior run; just drop the journal entry.
|
||
/// - else → leave it (the invoice hasn't settled yet; retry next time).
|
||
///
|
||
/// Returns the total sats reclaimed. Safe to call repeatedly (idempotent): a
|
||
/// quote is only minted once, and `ISSUED` quotes are never re-claimed.
|
||
pub async fn resume_pending_swaps(data_dir: &Path) -> Result<u64> {
|
||
let pending = load_pending_swaps(data_dir).await?;
|
||
let mut reclaimed = 0u64;
|
||
for swap in pending {
|
||
let to = match MintClient::new(&swap.to_mint) {
|
||
Ok(c) => c,
|
||
Err(e) => {
|
||
warn!(
|
||
"resume_pending_swaps: bad target mint {}: {}",
|
||
swap.to_mint, e
|
||
);
|
||
continue;
|
||
}
|
||
};
|
||
let status = match to.mint_quote_status(&swap.mint_quote_id).await {
|
||
Ok(s) => s,
|
||
Err(e) => {
|
||
debug!(
|
||
"resume_pending_swaps: status check failed for {}: {} — leaving pending",
|
||
swap.mint_quote_id, e
|
||
);
|
||
continue;
|
||
}
|
||
};
|
||
match status.state.as_str() {
|
||
"PAID" => match to.mint_tokens(&swap.mint_quote_id, swap.amount_sats).await {
|
||
Ok(result) => {
|
||
let minted: u64 = result.proofs.iter().map(|p| p.amount).sum();
|
||
let mut wallet = load_wallet(data_dir).await?;
|
||
wallet.add_proofs(&swap.to_mint, result.proofs);
|
||
wallet.record_tx(
|
||
TransactionType::Mint,
|
||
minted,
|
||
&format!(
|
||
"Resumed cross-mint swap {}→{}: claimed {} sats",
|
||
swap.from_mint, swap.to_mint, minted
|
||
),
|
||
&swap.to_mint,
|
||
&swap.from_mint,
|
||
);
|
||
save_wallet(data_dir, &wallet).await?;
|
||
remove_pending_swap(data_dir, &swap.mint_quote_id).await?;
|
||
record_swap_success(data_dir, &swap.from_mint, &swap.to_mint).await;
|
||
reclaimed += minted;
|
||
info!(
|
||
"Resumed interrupted swap {}→{}: reclaimed {} sats",
|
||
swap.from_mint, swap.to_mint, minted
|
||
);
|
||
}
|
||
Err(e) => warn!(
|
||
"resume_pending_swaps: claim failed for {}: {}",
|
||
swap.mint_quote_id, e
|
||
),
|
||
},
|
||
"ISSUED" => {
|
||
// Already claimed on a previous run — drop the journal entry.
|
||
remove_pending_swap(data_dir, &swap.mint_quote_id).await?;
|
||
}
|
||
other => debug!(
|
||
"resume_pending_swaps: quote {} state {} — leaving pending",
|
||
swap.mint_quote_id, other
|
||
),
|
||
}
|
||
}
|
||
Ok(reclaimed)
|
||
}
|
||
|
||
/// Success/failure counts for a single (from → to) swap route.
|
||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||
struct RouteStat {
|
||
successes: u64,
|
||
failures: u64,
|
||
}
|
||
|
||
/// Per-mint-pair liquidity cache: which swap routes have actually worked, so the
|
||
/// payer can prefer routes with a track record over ones that keep failing.
|
||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||
struct SwapLiquidity {
|
||
/// Keyed by `"<from>|<to>"` (normalized URLs).
|
||
routes: std::collections::BTreeMap<String, RouteStat>,
|
||
}
|
||
|
||
fn route_key(from_mint: &str, to_mint: &str) -> String {
|
||
format!(
|
||
"{}|{}",
|
||
from_mint.trim_end_matches('/'),
|
||
to_mint.trim_end_matches('/')
|
||
)
|
||
}
|
||
|
||
async fn load_swap_liquidity(data_dir: &Path) -> SwapLiquidity {
|
||
let path = data_dir.join(SWAP_LIQUIDITY_FILE);
|
||
match fs::read_to_string(&path).await {
|
||
Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
|
||
Err(_) => SwapLiquidity::default(),
|
||
}
|
||
}
|
||
|
||
async fn save_swap_liquidity(data_dir: &Path, liq: &SwapLiquidity) {
|
||
let dir = data_dir.join("wallet");
|
||
let _ = fs::create_dir_all(&dir).await;
|
||
if let Ok(content) = serde_json::to_string_pretty(liq) {
|
||
let _ = fs::write(data_dir.join(SWAP_LIQUIDITY_FILE), content).await;
|
||
}
|
||
}
|
||
|
||
/// Record that a swap route succeeded (best-effort; never fails the caller).
|
||
async fn record_swap_success(data_dir: &Path, from_mint: &str, to_mint: &str) {
|
||
let mut liq = load_swap_liquidity(data_dir).await;
|
||
liq.routes
|
||
.entry(route_key(from_mint, to_mint))
|
||
.or_default()
|
||
.successes += 1;
|
||
save_swap_liquidity(data_dir, &liq).await;
|
||
}
|
||
|
||
/// Record that a swap route failed (best-effort; never fails the caller).
|
||
async fn record_swap_failure(data_dir: &Path, from_mint: &str, to_mint: &str) {
|
||
let mut liq = load_swap_liquidity(data_dir).await;
|
||
liq.routes
|
||
.entry(route_key(from_mint, to_mint))
|
||
.or_default()
|
||
.failures += 1;
|
||
save_swap_liquidity(data_dir, &liq).await;
|
||
}
|
||
|
||
/// Liquidity score for reaching `to_mint` from any source: net successes across
|
||
/// all routes ending at this target. Higher = a more reliable destination.
|
||
fn target_liquidity_score(liq: &SwapLiquidity, to_mint: &str) -> i64 {
|
||
let suffix = format!("|{}", to_mint.trim_end_matches('/'));
|
||
liq.routes
|
||
.iter()
|
||
.filter(|(k, _)| k.ends_with(&suffix))
|
||
.map(|(_, s)| s.successes as i64 - s.failures as i64)
|
||
.sum()
|
||
}
|
||
|
||
/// Receive a cashuA token from a peer — swaps proofs at the mint for fresh ones.
|
||
pub async fn receive_token(data_dir: &Path, token_str: &str) -> Result<u64> {
|
||
// Handle legacy format for backwards compatibility
|
||
if token_str.starts_with("cashuSend_") {
|
||
return receive_legacy_token(data_dir, token_str).await;
|
||
}
|
||
|
||
let token = CashuToken::deserialize(token_str)?;
|
||
let total_amount = token.total_amount();
|
||
|
||
if total_amount == 0 {
|
||
anyhow::bail!("Token has zero value");
|
||
}
|
||
|
||
// Verify all mints in the token are accepted
|
||
let accepted = load_accepted_mints(data_dir).await?;
|
||
for mint_url in token.mint_urls() {
|
||
if !accepted.mints.iter().any(|m| m == mint_url) {
|
||
anyhow::bail!("Mint '{}' is not in accepted mints list", mint_url);
|
||
}
|
||
}
|
||
|
||
let mut wallet = load_wallet(data_dir).await?;
|
||
let mut received_total = 0u64;
|
||
|
||
// Swap proofs at each mint
|
||
for entry in &token.token {
|
||
let client = MintClient::new(&entry.mint)?;
|
||
match client.receive_token(&token).await {
|
||
Ok(new_proofs) => {
|
||
let amount: u64 = new_proofs.iter().map(|p| p.amount).sum();
|
||
wallet.add_proofs(&entry.mint, new_proofs);
|
||
received_total += amount;
|
||
}
|
||
Err(e) => {
|
||
warn!("Failed to swap proofs from mint {}: {}", entry.mint, e);
|
||
// Continue with other mints if any
|
||
}
|
||
}
|
||
}
|
||
|
||
if received_total == 0 {
|
||
anyhow::bail!("Failed to receive any proofs from token");
|
||
}
|
||
|
||
wallet.record_tx(
|
||
TransactionType::Receive,
|
||
received_total,
|
||
&format!("Received {} sats ecash", received_total),
|
||
token.token.first().map(|e| e.mint.as_str()).unwrap_or(""),
|
||
"",
|
||
);
|
||
save_wallet(data_dir, &wallet).await?;
|
||
|
||
debug!("Received {} sats ecash token", received_total);
|
||
Ok(received_total)
|
||
}
|
||
|
||
/// Receive a legacy format token (cashuSend_{amount}_{uuid}_{timestamp}).
|
||
/// For backwards compatibility during migration period.
|
||
async fn receive_legacy_token(data_dir: &Path, token_str: &str) -> Result<u64> {
|
||
let amount_sats = token_str
|
||
.split('_')
|
||
.nth(1)
|
||
.and_then(|s| s.parse::<u64>().ok())
|
||
.unwrap_or(0);
|
||
|
||
if amount_sats == 0 {
|
||
anyhow::bail!("Invalid legacy ecash token");
|
||
}
|
||
|
||
let mut wallet = load_wallet(data_dir).await?;
|
||
|
||
// Store as a synthetic proof (legacy tokens can't be verified cryptographically)
|
||
let proof = Proof {
|
||
amount: amount_sats,
|
||
id: "legacy".to_string(),
|
||
secret: token_str.to_string(),
|
||
c: "0000000000000000000000000000000000000000000000000000000000000000ff".to_string(),
|
||
};
|
||
wallet.add_proofs(&wallet.mint_url.clone(), vec![proof]);
|
||
|
||
wallet.record_tx(
|
||
TransactionType::Receive,
|
||
amount_sats,
|
||
&format!("Received {} sats (legacy format)", amount_sats),
|
||
&wallet.mint_url.clone(),
|
||
"",
|
||
);
|
||
save_wallet(data_dir, &wallet).await?;
|
||
|
||
debug!("Received {} sats legacy ecash token", amount_sats);
|
||
Ok(amount_sats)
|
||
}
|
||
|
||
/// Verify a payment token and receive it into the wallet.
|
||
/// Returns the verified amount, or error if verification fails.
|
||
/// Used by the content server and streaming gate to verify incoming payments.
|
||
pub async fn verify_and_receive_payment(
|
||
data_dir: &Path,
|
||
token_str: &str,
|
||
required_sats: u64,
|
||
) -> Result<u64> {
|
||
// Handle legacy tokens
|
||
if token_str.starts_with("cashuSend_") {
|
||
let amount = token_str
|
||
.split('_')
|
||
.nth(1)
|
||
.and_then(|s| s.parse::<u64>().ok())
|
||
.unwrap_or(0);
|
||
if amount < required_sats {
|
||
anyhow::bail!(
|
||
"Insufficient payment: {} sats, need {} sats",
|
||
amount,
|
||
required_sats
|
||
);
|
||
}
|
||
let received = receive_legacy_token(data_dir, token_str).await?;
|
||
return Ok(received);
|
||
}
|
||
|
||
// Fedimint notes (#3): a buyer whose balance is in Fedimint pays with notes
|
||
// rather than a Cashu token. Cashu tokens all start with "cashu" (cashuA/
|
||
// cashuB, or the legacy form handled above), so anything else is treated as
|
||
// Fedimint notes and redeemed into a joined federation. reissue_into_any
|
||
// verifies the notes are unspent and credits this node's wallet.
|
||
if !token_str.starts_with("cashu") {
|
||
let (received, _fed_id) =
|
||
crate::wallet::fedimint_client::reissue_into_any(data_dir, token_str).await?;
|
||
if received < required_sats {
|
||
anyhow::bail!(
|
||
"Insufficient payment: {} sats, need {} sats",
|
||
received,
|
||
required_sats
|
||
);
|
||
}
|
||
return Ok(received);
|
||
}
|
||
|
||
// Parse and validate cashuA token
|
||
let token = CashuToken::deserialize(token_str)?;
|
||
let total = token.total_amount();
|
||
|
||
if total < required_sats {
|
||
anyhow::bail!(
|
||
"Insufficient payment: {} sats, need {} sats",
|
||
total,
|
||
required_sats
|
||
);
|
||
}
|
||
|
||
// Verify mints are accepted
|
||
let accepted = load_accepted_mints(data_dir).await?;
|
||
for mint_url in token.mint_urls() {
|
||
if !accepted.mints.iter().any(|m| m == mint_url) {
|
||
anyhow::bail!("Mint '{}' not accepted", mint_url);
|
||
}
|
||
}
|
||
|
||
// Swap proofs at mint (this verifies they're unspent and gives us fresh proofs)
|
||
let mut wallet = load_wallet(data_dir).await?;
|
||
let mut received_total = 0u64;
|
||
|
||
for entry in &token.token {
|
||
let client = MintClient::new(&entry.mint)?;
|
||
let entry_total: u64 = entry.proofs.iter().map(|p| p.amount).sum();
|
||
let target_amounts = amount_to_denominations(entry_total);
|
||
|
||
match client.swap(&entry.proofs, &target_amounts).await {
|
||
Ok(result) => {
|
||
let amount: u64 = result.new_proofs.iter().map(|p| p.amount).sum();
|
||
wallet.add_proofs(&entry.mint, result.new_proofs);
|
||
received_total += amount;
|
||
}
|
||
Err(e) => {
|
||
warn!("Payment verification failed at mint {}: {}", entry.mint, e);
|
||
}
|
||
}
|
||
}
|
||
|
||
if received_total < required_sats {
|
||
anyhow::bail!(
|
||
"Payment verification failed: only {} of {} sats verified",
|
||
received_total,
|
||
required_sats
|
||
);
|
||
}
|
||
|
||
wallet.record_tx(
|
||
TransactionType::Receive,
|
||
received_total,
|
||
&format!("Payment received: {} sats", received_total),
|
||
token.token.first().map(|e| e.mint.as_str()).unwrap_or(""),
|
||
"",
|
||
);
|
||
save_wallet(data_dir, &wallet).await?;
|
||
|
||
Ok(received_total)
|
||
}
|
||
|
||
/// Check the wallet balance.
|
||
pub async fn get_balance(data_dir: &Path) -> Result<u64> {
|
||
let wallet = load_wallet(data_dir).await?;
|
||
Ok(wallet.balance())
|
||
}
|
||
|
||
/// Default mint URL (local Fedimint).
|
||
/// Default Cashu mint. Minibits is a well-known public Cashu mint — note this
|
||
/// is a CASHU mint, distinct from the local Fedimint guardian (:8175), which is
|
||
/// a separate ecash protocol managed under the Fedimint Federations tab. The
|
||
/// old default pointed at :8175, which incorrectly surfaced the Fedimint URL in
|
||
/// the Cashu mints list.
|
||
fn default_mint_url() -> String {
|
||
"https://mint.minibits.cash/Bitcoin".to_string()
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use tempfile::TempDir;
|
||
|
||
#[test]
|
||
fn test_wallet_state_balance_empty() {
|
||
let wallet = WalletState::default();
|
||
assert_eq!(wallet.balance(), 0);
|
||
}
|
||
|
||
#[test]
|
||
fn test_wallet_state_balance_with_proofs() {
|
||
let mut wallet = WalletState::default();
|
||
wallet.add_proofs(
|
||
"http://mint",
|
||
vec![
|
||
Proof {
|
||
amount: 100,
|
||
id: "ks1".into(),
|
||
secret: "s1".into(),
|
||
c: "c1".into(),
|
||
},
|
||
Proof {
|
||
amount: 200,
|
||
id: "ks1".into(),
|
||
secret: "s2".into(),
|
||
c: "c2".into(),
|
||
},
|
||
],
|
||
);
|
||
assert_eq!(wallet.balance(), 300);
|
||
}
|
||
|
||
#[test]
|
||
fn test_wallet_state_balance_excludes_spent() {
|
||
let mut wallet = WalletState::default();
|
||
wallet.add_proofs(
|
||
"http://mint",
|
||
vec![
|
||
Proof {
|
||
amount: 100,
|
||
id: "ks1".into(),
|
||
secret: "s1".into(),
|
||
c: "c1".into(),
|
||
},
|
||
Proof {
|
||
amount: 200,
|
||
id: "ks1".into(),
|
||
secret: "s2".into(),
|
||
c: "c2".into(),
|
||
},
|
||
],
|
||
);
|
||
wallet.proofs[0].spent = true;
|
||
assert_eq!(wallet.balance(), 200);
|
||
}
|
||
|
||
#[test]
|
||
fn test_wallet_state_balance_excludes_reserved() {
|
||
let mut wallet = WalletState::default();
|
||
wallet.add_proofs(
|
||
"http://mint",
|
||
vec![Proof {
|
||
amount: 100,
|
||
id: "ks1".into(),
|
||
secret: "s1".into(),
|
||
c: "c1".into(),
|
||
}],
|
||
);
|
||
wallet.proofs[0].reserved = true;
|
||
assert_eq!(wallet.balance(), 0);
|
||
}
|
||
|
||
#[test]
|
||
fn test_select_proofs_sufficient() {
|
||
let mut wallet = WalletState::default();
|
||
wallet.add_proofs(
|
||
"http://mint",
|
||
vec![
|
||
Proof {
|
||
amount: 1,
|
||
id: "ks1".into(),
|
||
secret: "s1".into(),
|
||
c: "c1".into(),
|
||
},
|
||
Proof {
|
||
amount: 4,
|
||
id: "ks1".into(),
|
||
secret: "s2".into(),
|
||
c: "c2".into(),
|
||
},
|
||
Proof {
|
||
amount: 8,
|
||
id: "ks1".into(),
|
||
secret: "s3".into(),
|
||
c: "c3".into(),
|
||
},
|
||
],
|
||
);
|
||
|
||
let (indices, overpayment) = wallet.select_proofs("http://mint", 5).unwrap();
|
||
let total: u64 = indices.iter().map(|&i| wallet.proofs[i].proof.amount).sum();
|
||
assert!(total >= 5);
|
||
assert_eq!(total - 5, overpayment);
|
||
}
|
||
|
||
#[test]
|
||
fn test_select_proofs_insufficient() {
|
||
let mut wallet = WalletState::default();
|
||
wallet.add_proofs(
|
||
"http://mint",
|
||
vec![Proof {
|
||
amount: 1,
|
||
id: "ks1".into(),
|
||
secret: "s1".into(),
|
||
c: "c1".into(),
|
||
}],
|
||
);
|
||
|
||
assert!(wallet.select_proofs("http://mint", 100).is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn test_select_proofs_wrong_mint() {
|
||
let mut wallet = WalletState::default();
|
||
wallet.add_proofs(
|
||
"http://mint-a",
|
||
vec![Proof {
|
||
amount: 100,
|
||
id: "ks1".into(),
|
||
secret: "s1".into(),
|
||
c: "c1".into(),
|
||
}],
|
||
);
|
||
|
||
assert!(wallet.select_proofs("http://mint-b", 100).is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn test_balance_for_mint() {
|
||
let mut wallet = WalletState::default();
|
||
wallet.add_proofs(
|
||
"http://mint-a",
|
||
vec![Proof {
|
||
amount: 100,
|
||
id: "ks1".into(),
|
||
secret: "s1".into(),
|
||
c: "c1".into(),
|
||
}],
|
||
);
|
||
wallet.add_proofs(
|
||
"http://mint-b",
|
||
vec![Proof {
|
||
amount: 200,
|
||
id: "ks2".into(),
|
||
secret: "s2".into(),
|
||
c: "c2".into(),
|
||
}],
|
||
);
|
||
|
||
assert_eq!(wallet.balance_for_mint("http://mint-a"), 100);
|
||
assert_eq!(wallet.balance_for_mint("http://mint-b"), 200);
|
||
assert_eq!(wallet.balance(), 300);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_load_wallet_creates_default_when_missing() {
|
||
let tmp = TempDir::new().unwrap();
|
||
let wallet = load_wallet(tmp.path()).await.unwrap();
|
||
assert_eq!(wallet.balance(), 0);
|
||
assert!(wallet.proofs.is_empty());
|
||
assert!(wallet.transactions.is_empty());
|
||
assert_eq!(wallet.mint_url, default_mint_url());
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_save_and_load_wallet_roundtrip() {
|
||
let tmp = TempDir::new().unwrap();
|
||
let mut wallet = WalletState {
|
||
mint_url: "https://mint.minibits.cash/Bitcoin".into(),
|
||
..Default::default()
|
||
};
|
||
wallet.add_proofs(
|
||
"https://mint.minibits.cash/Bitcoin",
|
||
vec![Proof {
|
||
amount: 42,
|
||
id: "ks1".into(),
|
||
secret: "test_secret".into(),
|
||
c: "test_c".into(),
|
||
}],
|
||
);
|
||
wallet.record_tx(
|
||
TransactionType::Mint,
|
||
42,
|
||
"Test mint",
|
||
"https://mint.minibits.cash/Bitcoin",
|
||
"",
|
||
);
|
||
|
||
save_wallet(tmp.path(), &wallet).await.unwrap();
|
||
let loaded = load_wallet(tmp.path()).await.unwrap();
|
||
assert_eq!(loaded.proofs.len(), 1);
|
||
assert_eq!(loaded.proofs[0].proof.amount, 42);
|
||
assert!(!loaded.proofs[0].spent);
|
||
assert_eq!(loaded.transactions.len(), 1);
|
||
assert_eq!(loaded.balance(), 42);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_save_wallet_creates_directory() {
|
||
let tmp = TempDir::new().unwrap();
|
||
let wallet_dir = tmp.path().join("wallet");
|
||
assert!(!wallet_dir.exists());
|
||
|
||
let wallet = WalletState::default();
|
||
save_wallet(tmp.path(), &wallet).await.unwrap();
|
||
assert!(wallet_dir.exists());
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_receive_legacy_token() {
|
||
let tmp = TempDir::new().unwrap();
|
||
let amount = receive_token(tmp.path(), "cashuSend_500_abc123_1700000000")
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(amount, 500);
|
||
|
||
let wallet = load_wallet(tmp.path()).await.unwrap();
|
||
assert_eq!(wallet.balance(), 500);
|
||
assert_eq!(wallet.proofs.len(), 1);
|
||
assert!(!wallet.proofs[0].spent);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_receive_token_invalid_format() {
|
||
let tmp = TempDir::new().unwrap();
|
||
let result = receive_token(tmp.path(), "invalid_token_string").await;
|
||
assert!(result.is_err());
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_receive_legacy_token_zero_amount() {
|
||
let tmp = TempDir::new().unwrap();
|
||
let result = receive_token(tmp.path(), "cashuSend_0_abc_1700000000").await;
|
||
assert!(result.is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn test_prune_old_spent() {
|
||
let mut wallet = WalletState::default();
|
||
// Add an old spent proof
|
||
wallet.proofs.push(StoredProof {
|
||
proof: Proof {
|
||
amount: 100,
|
||
id: "ks1".into(),
|
||
secret: "old".into(),
|
||
c: "c".into(),
|
||
},
|
||
mint_url: "http://mint".into(),
|
||
spent: true,
|
||
reserved: false,
|
||
created_at: "2020-01-01T00:00:00Z".into(),
|
||
});
|
||
// Add a recent unspent proof
|
||
wallet.proofs.push(StoredProof {
|
||
proof: Proof {
|
||
amount: 200,
|
||
id: "ks1".into(),
|
||
secret: "new".into(),
|
||
c: "c".into(),
|
||
},
|
||
mint_url: "http://mint".into(),
|
||
spent: false,
|
||
reserved: false,
|
||
created_at: chrono::Utc::now().to_rfc3339(),
|
||
});
|
||
|
||
wallet.prune_old_spent();
|
||
assert_eq!(wallet.proofs.len(), 1);
|
||
assert_eq!(wallet.proofs[0].proof.amount, 200);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_accepted_mints_default() {
|
||
let tmp = TempDir::new().unwrap();
|
||
let mints = load_accepted_mints(tmp.path()).await.unwrap();
|
||
assert_eq!(mints.mints.len(), 1);
|
||
assert_eq!(mints.mints[0], default_mint_url());
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_accepted_mints_roundtrip() {
|
||
let tmp = TempDir::new().unwrap();
|
||
let mints = AcceptedMints {
|
||
mints: vec!["http://mint-a".into(), "http://mint-b".into()],
|
||
};
|
||
save_accepted_mints(tmp.path(), &mints).await.unwrap();
|
||
let loaded = load_accepted_mints(tmp.path()).await.unwrap();
|
||
assert_eq!(loaded.mints.len(), 2);
|
||
}
|
||
|
||
#[test]
|
||
fn test_transaction_type_serialization() {
|
||
let tx = EcashTransaction {
|
||
id: "tx-test".into(),
|
||
tx_type: TransactionType::StreamingPayment,
|
||
amount_sats: 100,
|
||
timestamp: "2025-01-01T00:00:00Z".into(),
|
||
description: "test".into(),
|
||
mint_url: String::new(),
|
||
peer: String::new(),
|
||
kind: default_tx_kind(),
|
||
};
|
||
let json = serde_json::to_string(&tx).unwrap();
|
||
assert!(json.contains("\"streamingpayment\""));
|
||
}
|
||
|
||
#[test]
|
||
fn test_default_mint_url() {
|
||
assert_eq!(default_mint_url(), "https://mint.minibits.cash/Bitcoin");
|
||
}
|
||
|
||
#[test]
|
||
fn test_swap_fee() {
|
||
assert_eq!(swap_fee(105, 100), 5);
|
||
// Defensive: never underflow if mint quotes oddly.
|
||
assert_eq!(swap_fee(100, 100), 0);
|
||
assert_eq!(swap_fee(90, 100), 0);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_is_mint_trusted_home_always() {
|
||
let tmp = TempDir::new().unwrap();
|
||
// Home mint is trusted even with no accepted-mints file.
|
||
assert!(is_mint_trusted(tmp.path(), &default_mint_url())
|
||
.await
|
||
.unwrap());
|
||
// Trailing slash on the home URL still matches.
|
||
assert!(
|
||
is_mint_trusted(tmp.path(), "https://mint.minibits.cash/Bitcoin/")
|
||
.await
|
||
.unwrap()
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_is_mint_trusted_respects_accepted_list() {
|
||
let tmp = TempDir::new().unwrap();
|
||
save_accepted_mints(
|
||
tmp.path(),
|
||
&AcceptedMints {
|
||
mints: vec![default_mint_url(), "https://mint.example.com".into()],
|
||
},
|
||
)
|
||
.await
|
||
.unwrap();
|
||
|
||
assert!(is_mint_trusted(tmp.path(), "https://mint.example.com")
|
||
.await
|
||
.unwrap());
|
||
// Normalized comparison ignores a trailing slash.
|
||
assert!(is_mint_trusted(tmp.path(), "https://mint.example.com/")
|
||
.await
|
||
.unwrap());
|
||
// A mint not on the list is not trusted.
|
||
assert!(!is_mint_trusted(tmp.path(), "https://evil.example.com")
|
||
.await
|
||
.unwrap());
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_swap_between_mints_rejects_identical() {
|
||
let tmp = TempDir::new().unwrap();
|
||
let err = swap_between_mints(
|
||
tmp.path(),
|
||
&default_mint_url(),
|
||
"https://mint.minibits.cash/Bitcoin/",
|
||
100,
|
||
10,
|
||
)
|
||
.await
|
||
.unwrap_err();
|
||
assert!(err.to_string().contains("identical"));
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_swap_between_mints_rejects_untrusted_target() {
|
||
let tmp = TempDir::new().unwrap();
|
||
let err = swap_between_mints(
|
||
tmp.path(),
|
||
&default_mint_url(),
|
||
"https://untrusted.example.com",
|
||
100,
|
||
10,
|
||
)
|
||
.await
|
||
.unwrap_err();
|
||
assert!(err.to_string().contains("trusted"));
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_swap_between_mints_rejects_zero_amount() {
|
||
let tmp = TempDir::new().unwrap();
|
||
let err = swap_between_mints(
|
||
tmp.path(),
|
||
&default_mint_url(),
|
||
"https://mint.example.com",
|
||
0,
|
||
10,
|
||
)
|
||
.await
|
||
.unwrap_err();
|
||
assert!(err.to_string().contains("greater than zero"));
|
||
}
|
||
|
||
#[test]
|
||
fn test_spendable_by_mint_groups_and_excludes() {
|
||
let mut wallet = WalletState::default();
|
||
wallet.add_proofs(
|
||
"http://mint-a",
|
||
vec![
|
||
Proof {
|
||
amount: 10,
|
||
id: "k".into(),
|
||
secret: "s1".into(),
|
||
c: "c".into(),
|
||
},
|
||
Proof {
|
||
amount: 5,
|
||
id: "k".into(),
|
||
secret: "s2".into(),
|
||
c: "c".into(),
|
||
},
|
||
],
|
||
);
|
||
wallet.add_proofs(
|
||
"http://mint-b",
|
||
vec![Proof {
|
||
amount: 7,
|
||
id: "k".into(),
|
||
secret: "s3".into(),
|
||
c: "c".into(),
|
||
}],
|
||
);
|
||
wallet.proofs[1].spent = true; // exclude the 5 on mint-a
|
||
let by_mint = wallet.spendable_by_mint();
|
||
assert_eq!(
|
||
by_mint,
|
||
vec![
|
||
("http://mint-a".to_string(), 10),
|
||
("http://mint-b".to_string(), 7)
|
||
]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_plan_payment_direct_prefers_home() {
|
||
let home = default_mint_url();
|
||
let holdings = vec![(home.clone(), 100), ("https://other".into(), 100)];
|
||
// Both accepted; home should win the tie-break.
|
||
let accepted = vec![("https://other".into(), true), (home.clone(), true)];
|
||
assert_eq!(
|
||
plan_payment(&holdings, &accepted, 50),
|
||
PaymentPlan::Direct { mint_url: home }
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_plan_payment_direct_only_accepted_mint() {
|
||
let holdings = vec![("https://a".into(), 100), ("https://b".into(), 100)];
|
||
// We hold both, but the seeder only accepts b.
|
||
let accepted = vec![("https://b".into(), true)];
|
||
assert_eq!(
|
||
plan_payment(&holdings, &accepted, 50),
|
||
PaymentPlan::Direct {
|
||
mint_url: "https://b".into()
|
||
}
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_plan_payment_swaps_into_trusted_target() {
|
||
// We hold value on A; seeder accepts only B (trusted) which we don't hold.
|
||
let holdings = vec![("https://a".into(), 100)];
|
||
let accepted = vec![("https://b".into(), true)];
|
||
assert_eq!(
|
||
plan_payment(&holdings, &accepted, 50),
|
||
PaymentPlan::Swap {
|
||
from_mint: "https://a".into(),
|
||
to_mint: "https://b".into()
|
||
}
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_plan_payment_refuses_untrusted_swap_target() {
|
||
// Seeder accepts only B, but B is not trusted → no swap, insufficient.
|
||
let holdings = vec![("https://a".into(), 100)];
|
||
let accepted = vec![("https://b".into(), false)];
|
||
assert_eq!(
|
||
plan_payment(&holdings, &accepted, 50),
|
||
PaymentPlan::Insufficient
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_plan_payment_insufficient_when_no_single_source_covers() {
|
||
// Total 60 across two mints, but neither alone covers 50+ for a swap and
|
||
// we hold neither accepted mint directly.
|
||
let holdings = vec![("https://a".into(), 30), ("https://c".into(), 30)];
|
||
let accepted = vec![("https://b".into(), true)];
|
||
assert_eq!(
|
||
plan_payment(&holdings, &accepted, 50),
|
||
PaymentPlan::Insufficient
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_plan_payment_direct_beats_swap() {
|
||
// We hold the accepted mint directly AND could swap — Direct must win.
|
||
let home = default_mint_url();
|
||
let holdings = vec![("https://b".into(), 100), (home.clone(), 100)];
|
||
let accepted = vec![("https://b".into(), true)];
|
||
assert_eq!(
|
||
plan_payment(&holdings, &accepted, 50),
|
||
PaymentPlan::Direct {
|
||
mint_url: "https://b".into()
|
||
}
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_build_payment_token_rejects_empty_mints() {
|
||
let tmp = TempDir::new().unwrap();
|
||
let err = build_payment_token(tmp.path(), &[], 100, 10)
|
||
.await
|
||
.unwrap_err();
|
||
assert!(err.to_string().contains("no accepted mints"));
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_build_payment_token_insufficient_falls_through() {
|
||
let tmp = TempDir::new().unwrap();
|
||
// Empty wallet, untrusted seeder mint → cannot pay (caller uses origin).
|
||
let err = build_payment_token(tmp.path(), &["https://seeder.example.com".into()], 100, 10)
|
||
.await
|
||
.unwrap_err();
|
||
assert!(err.to_string().contains("cannot pay"));
|
||
}
|
||
|
||
#[test]
|
||
fn test_route_key_normalizes_trailing_slash() {
|
||
assert_eq!(route_key("https://a/", "https://b/"), "https://a|https://b");
|
||
assert_eq!(route_key("https://a", "https://b"), "https://a|https://b");
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_pending_swaps_roundtrip_and_remove() {
|
||
let tmp = TempDir::new().unwrap();
|
||
assert!(load_pending_swaps(tmp.path()).await.unwrap().is_empty());
|
||
|
||
add_pending_swap(
|
||
tmp.path(),
|
||
PendingSwap {
|
||
from_mint: "https://a".into(),
|
||
to_mint: "https://b".into(),
|
||
amount_sats: 100,
|
||
melt_quote_id: "melt-1".into(),
|
||
mint_quote_id: "mint-1".into(),
|
||
created_at: "2026-06-17T00:00:00Z".into(),
|
||
},
|
||
)
|
||
.await
|
||
.unwrap();
|
||
let loaded = load_pending_swaps(tmp.path()).await.unwrap();
|
||
assert_eq!(loaded.len(), 1);
|
||
assert_eq!(loaded[0].mint_quote_id, "mint-1");
|
||
|
||
remove_pending_swap(tmp.path(), "mint-1").await.unwrap();
|
||
assert!(load_pending_swaps(tmp.path()).await.unwrap().is_empty());
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_resume_pending_swaps_empty_is_noop() {
|
||
let tmp = TempDir::new().unwrap();
|
||
assert_eq!(resume_pending_swaps(tmp.path()).await.unwrap(), 0);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_liquidity_cache_records_and_scores() {
|
||
let tmp = TempDir::new().unwrap();
|
||
// Two routes into B succeed; one into C fails.
|
||
record_swap_success(tmp.path(), "https://a", "https://b").await;
|
||
record_swap_success(tmp.path(), "https://x", "https://b").await;
|
||
record_swap_failure(tmp.path(), "https://a", "https://c").await;
|
||
|
||
let liq = load_swap_liquidity(tmp.path()).await;
|
||
// B reached successfully from two sources → net +2; trailing slash tolerant.
|
||
assert_eq!(target_liquidity_score(&liq, "https://b/"), 2);
|
||
// C only failed → net -1.
|
||
assert_eq!(target_liquidity_score(&liq, "https://c"), -1);
|
||
// Unknown target → neutral 0.
|
||
assert_eq!(target_liquidity_score(&liq, "https://unknown"), 0);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_build_payment_token_prefers_liquid_target() {
|
||
let tmp = TempDir::new().unwrap();
|
||
// Trust two non-home mints; hold value on a source mint for both.
|
||
save_accepted_mints(
|
||
tmp.path(),
|
||
&AcceptedMints {
|
||
mints: vec![
|
||
default_mint_url(),
|
||
"https://liquid".into(),
|
||
"https://dry".into(),
|
||
],
|
||
},
|
||
)
|
||
.await
|
||
.unwrap();
|
||
// Give "https://liquid" a track record so it should be preferred.
|
||
record_swap_success(tmp.path(), "https://src", "https://liquid").await;
|
||
|
||
// Seeder accepts both non-home mints; we only hold "https://src".
|
||
let accepted = vec![
|
||
("https://dry".into(), true),
|
||
("https://liquid".into(), true),
|
||
];
|
||
let holdings = vec![("https://src".to_string(), 1000u64)];
|
||
|
||
// Mirror build_payment_token's ordering step, then plan.
|
||
let liq = load_swap_liquidity(tmp.path()).await;
|
||
let mut ordered = accepted.clone();
|
||
ordered.sort_by_key(|(m, _): &(String, bool)| {
|
||
std::cmp::Reverse(target_liquidity_score(&liq, m))
|
||
});
|
||
match plan_payment(&holdings, &ordered, 100) {
|
||
PaymentPlan::Swap { to_mint, .. } => assert_eq!(to_mint, "https://liquid"),
|
||
other => panic!("expected swap into liquid target, got {:?}", other),
|
||
}
|
||
}
|
||
}
|