feat(dht): Phase 4 — paid swarm streaming (cross-mint ecash + Shape-A ALPN)

Fetch-side auto-pay decision layer (payment.rs), Shape-A paid-blobs
negotiation ALPN (paid_alpn.rs), cross-mint ecash swap + payer auto-swap
builder + idempotent resume/liquidity cache (ecash.rs), and the
streaming.prepare-payment RPC. All gated behind the iroh-swarm feature
(off by default). 91/91 tests pass, both build configs clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-06-17 07:36:31 -04:00
co-authored by Claude Opus 4.8
parent 1f3b03bc6d
commit 27a6199939
10 changed files with 1528 additions and 5 deletions
+826 -4
View File
@@ -10,7 +10,7 @@ use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;
use tokio::fs;
use tracing::{debug, warn};
use tracing::{debug, info, warn};
const WALLET_FILE: &str = "wallet/ecash.json";
const MINTS_FILE: &str = "wallet/accepted_mints.json";
@@ -106,6 +106,18 @@ impl WalletState {
.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)> {
@@ -352,10 +364,225 @@ pub async fn melt_tokens(data_dir: &Path, quote_id: &str, bolt11: &str) -> Resul
Ok(quote.amount)
}
/// Create a cashuA token string to send to a peer.
pub async fn send_token(data_dir: &Path, amount_sats: u64) -> Result<String> {
// ── 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 mint_url = wallet.mint_url.clone();
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
@@ -422,6 +649,319 @@ pub async fn send_token(data_dir: &Path, amount_sats: u64) -> Result<String> {
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
@@ -936,4 +1476,286 @@ mod tests {
fn test_default_mint_url() {
assert_eq!(default_mint_url(), "http://127.0.0.1:8175");
}
#[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(), "http://127.0.0.1:8175/")
.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(),
"http://127.0.0.1:8175/",
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),
}
}
}