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:
co-authored by
Claude Opus 4.8
parent
1f3b03bc6d
commit
27a6199939
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user