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
parent 1f3b03bc6d
commit 27a6199939
10 changed files with 1528 additions and 5 deletions

View File

@ -250,6 +250,7 @@ impl RpcHandler {
"streaming.configure-service" => self.handle_streaming_configure_service(params).await,
"streaming.toggle-service" => self.handle_streaming_toggle_service(params).await,
"streaming.pay" => self.handle_streaming_pay(params).await,
"streaming.prepare-payment" => self.handle_streaming_prepare_payment(params).await,
"streaming.discover" => self.handle_streaming_discover().await,
"streaming.usage" => self.handle_streaming_usage(params).await,
"streaming.session" => self.handle_streaming_session(params).await,

View File

@ -205,6 +205,64 @@ impl RpcHandler {
}
}
/// Build a payment token for a remote seeder (payer side, cross-mint aware).
///
/// Given the seeder's advertised `accepted_mints` and `price_sats`, builds a
/// `cashuA` token denominated in one of those mints — paying directly if we
/// already hold the right mint, else auto-swapping into a trusted accepted
/// mint (within `max_fee_sats`). If the price is over `budget_sats`, the
/// wallet can't cover it, or the swap is too costly, returns `declined` so
/// the caller falls back to the free origin (origin always wins).
pub(super) async fn handle_streaming_prepare_payment(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let accepted_mints: Vec<String> = params
.get("accepted_mints")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default();
let price_sats = params
.get("price_sats")
.or_else(|| params.get("amount_sats"))
.and_then(|v| v.as_u64())
.ok_or_else(|| anyhow::anyhow!("Missing price_sats"))?;
// Default budget = the asked price (willing to pay exactly what's quoted).
let budget_sats = params
.get("budget_sats")
.and_then(|v| v.as_u64())
.unwrap_or(price_sats);
let max_fee_sats = params
.get("max_fee_sats")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let policy = crate::swarm::payment::PaymentPolicy::with_budget(budget_sats, max_fee_sats);
match crate::swarm::payment::auto_pay_token(
&self.config.data_dir,
&policy,
&accepted_mints,
price_sats,
)
.await?
{
Some(token) => Ok(serde_json::json!({
"status": "ready",
"token": token,
"paid_sats": price_sats,
})),
None => Ok(serde_json::json!({
"status": "declined",
"message": "payment declined (over budget, unpayable, or swap too costly) — use free origin",
})),
}
}
/// Discover available streaming services (pricing info).
/// This is the unauthenticated discovery endpoint.
pub(super) async fn handle_streaming_discover(&self) -> Result<serde_json::Value> {

View File

@ -164,6 +164,17 @@ impl Server {
tracing::warn!("Swarm init (non-fatal, falling back to origin-only): {}", e);
}
// Resume any cross-mint ecash swap interrupted by a previous crash
// (paid the source mint but never claimed the target tokens). Best-effort.
match crate::wallet::ecash::resume_pending_swaps(&config.data_dir).await {
Ok(0) => {}
Ok(reclaimed) => tracing::info!(
"Resumed interrupted cross-mint swaps: reclaimed {} sats",
reclaimed
),
Err(e) => tracing::debug!("resume_pending_swaps (non-fatal): {}", e),
}
// Revoke any previously published Nostr data (runs before publish so revocation is not overwritten)
let identity_dir = config.data_dir.join("identity");
let tor_proxy_revoke = config.nostr_tor_proxy.clone();

View File

@ -27,6 +27,7 @@ use async_trait::async_trait;
use iroh::{endpoint::presets, protocol::Router, Endpoint, EndpointId};
use iroh_blobs::{store::fs::FsStore, BlobsProtocol, Hash};
use super::payment::PaymentPolicy;
use super::BlobProvider;
use crate::content_hash::{ContentDigest, HashAlg};
@ -87,6 +88,13 @@ pub struct IrohProvider {
/// Kept alive so the node keeps accepting blob-protocol connections (seeds).
_router: Router,
discovery: Option<Arc<dyn ProviderDiscovery>>,
/// Where pricing/session/wallet state lives — for paid-fetch negotiation.
data_dir: std::path::PathBuf,
/// Willingness to pay swarm peers when fetching. Defaults to
/// [`PaymentPolicy::free`]: never pay (releases/catalog stay free), so a
/// seeder that prices a blob is skipped → origin. A future film fetch can
/// pass a real budget.
pay_policy: PaymentPolicy,
}
#[allow(dead_code)]
@ -114,8 +122,13 @@ impl IrohProvider {
// (Networking Profits → Settings). It also hard-disables peer writes.
let event_sender = super::paid::gated_event_sender(data_dir.to_path_buf(), (*store).clone());
let blobs = BlobsProtocol::new(&store, Some(event_sender));
// Shape-A paid negotiation rides a second ALPN on the same endpoint so a
// downloader can pay (open a session) before the blob-GET above serves it.
let paid =
super::paid_alpn::PaidBlobsProtocol::new(data_dir.to_path_buf(), (*store).clone());
let router = Router::builder(endpoint.clone())
.accept(iroh_blobs::ALPN, blobs)
.accept(super::paid_alpn::PAID_ALPN, paid)
.spawn();
Ok(Self {
@ -123,6 +136,8 @@ impl IrohProvider {
store,
_router: router,
discovery,
data_dir: data_dir.to_path_buf(),
pay_policy: PaymentPolicy::free(),
})
}
@ -207,11 +222,34 @@ impl BlobProvider for IrohProvider {
return Ok(false);
}
// Shape-A: negotiate paid access with each candidate. Best-effort and
// additive — a peer is dropped only if it explicitly requires a payment
// we won't make under `pay_policy` (free by default → priced seeders are
// skipped). Connect/protocol failures keep the peer; the blob-GET gate is
// the real enforcement and a refused GET still falls back to origin.
let mut allowed = Vec::with_capacity(providers.len());
for peer in providers {
if super::paid_alpn::negotiate_access(
&self.endpoint,
&self.data_dir,
peer,
&digest.hex,
&self.pay_policy,
)
.await
{
allowed.push(peer);
}
}
if allowed.is_empty() {
return Ok(false);
}
// Fetch (range-verified by iroh) then export the verified blob to the
// staging path the caller expects. The seam re-verifies the digest.
let downloader = self.store.downloader(&self.endpoint);
downloader
.download(hash, providers)
.download(hash, allowed)
.await
.map_err(|e| anyhow::anyhow!("iroh swarm download: {e}"))?;
self.store

View File

@ -27,6 +27,7 @@ use tracing::{debug, info, warn};
use crate::content_hash::ContentDigest;
pub mod payment;
pub mod seed_advert;
#[cfg(feature = "iroh-swarm")]
@ -35,6 +36,9 @@ pub mod iroh_provider;
#[cfg(feature = "iroh-swarm")]
pub mod paid;
#[cfg(feature = "iroh-swarm")]
pub mod paid_alpn;
/// Which source ultimately served the content.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FetchSource {

View File

@ -0,0 +1,306 @@
//! Shape-A paid-blobs negotiation ALPN (`archy/paid-blobs/1`) — the on-wire
//! exchange that lets a downloader pay a seeder *before* fetching a gated blob
//! (DHT distribution plan §1, "shape A"). Gated behind `iroh-swarm`.
//!
//! ## Why a side ALPN
//! iroh-blobs carries the raw bytes; this tiny request/grant protocol rides a
//! second ALPN on the *same* endpoint so a downloader can discover the price and
//! deliver an ecash token first. The token opens a metered `streaming` session
//! keyed by the downloader's endpoint id — exactly the session the blob-GET gate
//! ([`super::paid`]) already checks. Same endpoint → same session → the GET is
//! then served.
//!
//! ```text
//! B ──(archy/paid-blobs/1)──▶ A PaidRequest { want: H, token: None }
//! B ◀─────────────────────── A PaymentRequired { price, accepted_mints }
//! B: auto_pay_token(...) ── builds a cashuA token (cross-mint aware)
//! B ──(archy/paid-blobs/1)──▶ A PaidRequest { want: H, token: Some(t) }
//! B ◀─────────────────────── A Granted (session now exists on A)
//! B ──(iroh-blobs ALPN)─────▶ A GET H → served (gate sees the session)
//! ```
//!
//! ## North star: origin always wins, releases stay free
//! Negotiation is **best-effort and additive**. A peer that doesn't speak this
//! ALPN, or any connect/protocol error, is treated as "proceed" — the blob-GET
//! gate is the real enforcement, and a denied GET just falls back to origin.
//! With the default [`PaymentPolicy::free`] a downloader never sends a token, so
//! a seeder that prices a blob is simply skipped → origin. Only films (a future
//! caller with a real budget) will actually pay.
use std::path::{Path, PathBuf};
use anyhow::Result;
use iroh::endpoint::Connection;
use iroh::protocol::{AcceptError, ProtocolHandler};
use iroh::{Endpoint, EndpointAddr, EndpointId};
use iroh_blobs::api::blobs::BlobStatus;
use iroh_blobs::api::Store;
use iroh_blobs::Hash;
use serde::{Deserialize, Serialize};
use super::payment::PaymentPolicy;
use crate::streaming::gate::{self, GateResult};
/// ALPN for the paid-blobs negotiation protocol.
pub const PAID_ALPN: &[u8] = b"archy/paid-blobs/1";
/// The streaming service that meters swarm blob serving (same id as [`super::paid`]).
const SERVICE_ID: &str = "content-download";
/// Cap on a single negotiation message (JSON). Requests/responses are tiny.
const MAX_MSG: usize = 64 * 1024;
/// A downloader's ask for one content-addressed blob, optionally with payment.
#[derive(Debug, Serialize, Deserialize)]
struct PaidRequest {
/// BLAKE3 hex of the wanted blob.
want: String,
/// A `cashuA` token, present on the paying retry.
#[serde(skip_serializing_if = "Option::is_none")]
token: Option<String>,
}
/// The seeder's verdict.
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "status", rename_all = "snake_case")]
enum PaidResponse {
/// Fetch away — free, or a paid session is now active for this peer.
Granted,
/// Payment needed before serving. The downloader may pay and retry.
PaymentRequired {
price_sats: u64,
accepted_mints: Vec<String>,
},
/// Refused (bad request, insufficient/failed payment).
Denied { reason: String },
}
// ── Serve side ─────────────────────────────────────────────────────────────
/// Accept-side handler for [`PAID_ALPN`]. Registered on the provider's `Router`
/// alongside the iroh-blobs protocol.
#[derive(Clone)]
pub struct PaidBlobsProtocol {
data_dir: PathBuf,
store: Store,
}
impl std::fmt::Debug for PaidBlobsProtocol {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PaidBlobsProtocol").finish()
}
}
impl PaidBlobsProtocol {
pub fn new(data_dir: PathBuf, store: Store) -> Self {
Self { data_dir, store }
}
/// Decide the verdict for a request from `peer`. Mirrors [`super::paid`]'s
/// policy: free when the service is disabled (default) or the peer holds an
/// active session; payment-required when metered and unpaid; fail-OPEN
/// (Granted) on an internal gate error so a fault never blocks distribution.
async fn decide(&self, peer: &str, req: &PaidRequest) -> PaidResponse {
let size = self.blob_size(&req.want).await;
match gate::check_gate(&self.data_dir, peer, SERVICE_ID, req.token.as_deref(), size).await {
Ok(GateResult::ServiceUnavailable)
| Ok(GateResult::Allowed { .. })
| Ok(GateResult::PaidAndAllowed { .. }) => PaidResponse::Granted,
Ok(GateResult::PaymentRequired {
minimum_sats,
pricing,
..
}) => PaidResponse::PaymentRequired {
price_sats: minimum_sats,
accepted_mints: pricing.accepted_mints,
},
Ok(GateResult::InsufficientPayment {
provided_sats,
minimum_sats,
}) => PaidResponse::Denied {
reason: format!("insufficient payment: {provided_sats} < {minimum_sats} sats"),
},
Ok(GateResult::PaymentFailed { reason }) => PaidResponse::Denied { reason },
// Availability beats revenue: a gate fault serves free, matching the
// blob-GET gate's fail-open behaviour.
Err(e) => {
tracing::warn!("paid-alpn: gate errored ({e}); granting free");
PaidResponse::Granted
}
}
}
/// Full size of a held blob (for metering); 0 if we don't hold it complete.
async fn blob_size(&self, blake3_hex: &str) -> u64 {
let Ok(raw) = hex::decode(blake3_hex) else {
return 0;
};
let Ok(arr) = <[u8; 32]>::try_from(raw.as_slice()) else {
return 0;
};
match self.store.blobs().status(Hash::from_bytes(arr)).await {
Ok(BlobStatus::Complete { size }) => size,
_ => 0,
}
}
}
impl ProtocolHandler for PaidBlobsProtocol {
async fn accept(&self, connection: Connection) -> Result<(), AcceptError> {
let peer = connection.remote_id().to_string();
// One bi-stream per request (a paying downloader opens a second one).
loop {
let (mut send, mut recv) = match connection.accept_bi().await {
Ok(s) => s,
// Connection closed by the peer — normal end of negotiation.
Err(_) => break,
};
let buf = recv
.read_to_end(MAX_MSG)
.await
.map_err(AcceptError::from_err)?;
let response = match serde_json::from_slice::<PaidRequest>(&buf) {
Ok(req) => self.decide(&peer, &req).await,
Err(e) => PaidResponse::Denied {
reason: format!("bad request: {e}"),
},
};
let bytes = serde_json::to_vec(&response).map_err(AcceptError::from_err)?;
send.write_all(&bytes).await.map_err(AcceptError::from_err)?;
send.finish().map_err(AcceptError::from_err)?;
}
Ok(())
}
}
// ── Fetch side ───────────────────────────────────────────────────────────────
/// Negotiate access to `blake3_hex` from `peer` before fetching. Returns whether
/// the caller should proceed to download from this peer.
///
/// Best-effort: any connect/protocol failure returns `true` (proceed — the
/// blob-GET gate is the real enforcement, and a denied GET falls back to origin).
/// Returns `false` only when the seeder explicitly requires a payment we won't or
/// can't make under `policy`.
pub async fn negotiate_access(
endpoint: &Endpoint,
data_dir: &Path,
peer: EndpointId,
blake3_hex: &str,
policy: &PaymentPolicy,
) -> bool {
match negotiate_inner(endpoint, data_dir, peer, blake3_hex, policy).await {
Ok(proceed) => proceed,
Err(e) => {
tracing::debug!("paid-alpn: negotiation with {peer} failed ({e}) — proceeding (gate decides)");
true
}
}
}
async fn negotiate_inner(
endpoint: &Endpoint,
data_dir: &Path,
peer: EndpointId,
blake3_hex: &str,
policy: &PaymentPolicy,
) -> Result<bool> {
let conn = endpoint.connect(EndpointAddr::new(peer), PAID_ALPN).await?;
// First ask with no token.
let resp = exchange(
&conn,
&PaidRequest {
want: blake3_hex.to_string(),
token: None,
},
)
.await?;
match resp {
PaidResponse::Granted => Ok(true),
PaidResponse::Denied { .. } => Ok(false),
PaidResponse::PaymentRequired {
price_sats,
accepted_mints,
} => {
// Build a token within budget (cross-mint aware); None ⇒ use origin.
match super::payment::auto_pay_token(data_dir, policy, &accepted_mints, price_sats)
.await?
{
None => Ok(false),
Some(token) => {
let resp2 = exchange(
&conn,
&PaidRequest {
want: blake3_hex.to_string(),
token: Some(token),
},
)
.await?;
Ok(matches!(resp2, PaidResponse::Granted))
}
}
}
}
}
/// One request/response round trip on a fresh bi-stream.
async fn exchange(conn: &Connection, req: &PaidRequest) -> Result<PaidResponse> {
let (mut send, mut recv) = conn.open_bi().await?;
send.write_all(&serde_json::to_vec(req)?).await?;
send.finish()?;
let buf = recv.read_to_end(MAX_MSG).await?;
Ok(serde_json::from_slice(&buf)?)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn request_round_trips_and_omits_absent_token() {
let req = PaidRequest {
want: "abcd".into(),
token: None,
};
let json = serde_json::to_string(&req).unwrap();
assert!(!json.contains("token"), "absent token must be omitted: {json}");
let back: PaidRequest = serde_json::from_str(&json).unwrap();
assert_eq!(back.want, "abcd");
assert!(back.token.is_none());
}
#[test]
fn request_with_token_round_trips() {
let req = PaidRequest {
want: "ff".into(),
token: Some("cashuAbc".into()),
};
let back: PaidRequest = serde_json::from_str(&serde_json::to_string(&req).unwrap()).unwrap();
assert_eq!(back.token.as_deref(), Some("cashuAbc"));
}
#[test]
fn response_tagged_serialization() {
let granted = serde_json::to_string(&PaidResponse::Granted).unwrap();
assert_eq!(granted, r#"{"status":"granted"}"#);
let pr = serde_json::to_string(&PaidResponse::PaymentRequired {
price_sats: 7,
accepted_mints: vec!["https://m".into()],
})
.unwrap();
let back: PaidResponse = serde_json::from_str(&pr).unwrap();
match back {
PaidResponse::PaymentRequired {
price_sats,
accepted_mints,
} => {
assert_eq!(price_sats, 7);
assert_eq!(accepted_mints, vec!["https://m".to_string()]);
}
other => panic!("expected PaymentRequired, got {other:?}"),
}
}
}

View File

@ -0,0 +1,166 @@
//! Fetch-side auto-pay — the *downloader's* decision layer for paid swarm
//! content (plan §1 "fetch side" + §2a cross-mint).
//!
//! When a swarm seeder gates a blob behind payment (its `PaymentRequired`
//! advertises a price and a set of `accepted_mints`), a downloading node uses
//! this layer to decide whether to pay and, if so, to build a `cashuA` token
//! denominated in one of the seeder's accepted mints — auto-swapping across
//! mints when needed (see [`crate::wallet::ecash::build_payment_token`]).
//!
//! ## North star: origin always wins
//! Paying is strictly an optimization. If the price is over budget, the wallet
//! can't cover it, no trusted mint is reachable, or a swap would cost too much,
//! this layer returns `None` and the caller falls back to the free HTTP origin —
//! exactly today's path. A wallet/mint problem must never block content.
//!
//! ## Scope / what's NOT here
//! This builds the *token*; it does not yet carry it to the seeder. The on-wire
//! exchange (a downloader presenting the token to a paid seeder, then streaming
//! the blob) is the in-band paid-blobs ALPN — "shape (A)" in the design doc —
//! which is deferred. Today's seeder side (`swarm::paid`) only allow/deny-gates
//! iroh-blobs requests; once shape (A) lands, the provider's fetch path calls
//! [`auto_pay_token`] on a `PaymentRequired` and retries with the token.
use std::path::Path;
use anyhow::Result;
use tracing::debug;
use crate::wallet::ecash;
/// A downloader's willingness to pay swarm peers for a single fetch.
#[derive(Debug, Clone, Copy)]
pub struct PaymentPolicy {
/// Maximum total sats to spend for this content. `0` disables paying
/// entirely (origin-only) — the safe default.
pub budget_sats: u64,
/// Maximum cross-mint swap fee tolerated when we must swap into the
/// seeder's mint. Ignored when we already hold the right mint.
pub max_fee_sats: u64,
}
impl PaymentPolicy {
/// The default: never pay, always use the free origin. The production caller
/// is the deferred in-band paid-blobs ALPN (shape A); used by tests today.
#[allow(dead_code)]
pub fn free() -> Self {
Self {
budget_sats: 0,
max_fee_sats: 0,
}
}
/// A budget-capped policy.
pub fn with_budget(budget_sats: u64, max_fee_sats: u64) -> Self {
Self {
budget_sats,
max_fee_sats,
}
}
/// Whether a seeder's `price_sats` is worth paying under this policy. A zero
/// price is treated as "not a real paid request" (use origin / free path).
pub fn affords(&self, price_sats: u64) -> bool {
price_sats > 0 && price_sats <= self.budget_sats
}
}
/// Decide whether to pay a seeder `price_sats`, and if so build a `cashuA` token
/// denominated in one of its `accepted_mints` (auto-swapping if needed).
///
/// * `Ok(Some(token))` — pay the seeder with this token.
/// * `Ok(None)` — decline (over budget, unpayable, or swap too costly);
/// the caller should fall back to the free origin.
///
/// Never returns `Err` for a wallet/mint problem: those degrade to `Ok(None)`
/// so a payment failure can never block content.
pub async fn auto_pay_token(
data_dir: &Path,
policy: &PaymentPolicy,
accepted_mints: &[String],
price_sats: u64,
) -> Result<Option<String>> {
if !policy.affords(price_sats) {
debug!(
"auto-pay: price {} sats over budget {} (or zero) — using origin",
price_sats, policy.budget_sats
);
return Ok(None);
}
match ecash::build_payment_token(data_dir, accepted_mints, price_sats, policy.max_fee_sats).await
{
Ok(token) => Ok(Some(token)),
Err(e) => {
// Unpayable within balance/trust/fee — not an error, just decline.
debug!("auto-pay: declined ({}) — falling back to origin", e);
Ok(None)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn free_policy_never_affords() {
let p = PaymentPolicy::free();
assert!(!p.affords(1));
assert!(!p.affords(0));
}
#[test]
fn budget_policy_affordability() {
let p = PaymentPolicy::with_budget(100, 5);
assert!(p.affords(100)); // exactly at budget
assert!(p.affords(1));
assert!(!p.affords(101)); // over budget
assert!(!p.affords(0)); // zero price is never a real paid request
}
#[tokio::test]
async fn over_budget_declines_without_touching_wallet() {
let tmp = tempfile::tempdir().unwrap();
// Price exceeds budget → None, and no wallet/mint interaction occurs.
let out = auto_pay_token(
tmp.path(),
&PaymentPolicy::with_budget(50, 5),
&["https://seeder.example.com".into()],
100,
)
.await
.unwrap();
assert!(out.is_none());
}
#[tokio::test]
async fn zero_budget_is_origin_only() {
let tmp = tempfile::tempdir().unwrap();
let out = auto_pay_token(
tmp.path(),
&PaymentPolicy::free(),
&["https://seeder.example.com".into()],
10,
)
.await
.unwrap();
assert!(out.is_none());
}
#[tokio::test]
async fn unpayable_within_budget_declines_gracefully() {
let tmp = tempfile::tempdir().unwrap();
// Within budget, but empty wallet + untrusted seeder mint → build fails;
// auto_pay degrades to None (origin) rather than erroring.
let out = auto_pay_token(
tmp.path(),
&PaymentPolicy::with_budget(1000, 10),
&["https://untrusted.example.com".into()],
100,
)
.await
.unwrap();
assert!(out.is_none());
}
}

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),
}
}
}

View File

@ -99,6 +99,114 @@ default (all services ship `enabled:false`). Frontend typechecks clean (pre-exis
`Web5ConnectedNodes.vue` `.did` errors are NOT ours). `neode-ui` deps were
`npm install`ed to complete a partial install.
## F2 step 1 — cross-mint ecash swap — DONE (2026-06-17, NOT yet committed)
Plan §2a / phasing F2 step 1. Implemented in `wallet/ecash.rs`, **uncommitted**
(release in flight). Verified: `cargo test --bin archipelago -- wallet::ecash`
**25/25 pass** (6 new), default build clean, `--features iroh-swarm` build clean.
- `is_mint_trusted(data_dir, url)` — swap-into allow-list. Home Fedimint always
trusted; any other mint must be on `accepted_mints` (normalized, trailing-slash
tolerant). Reuses the list the streaming gate already advertises to payers.
- `mint_quote_at` / `melt_quote_at` / `send_token_at(data_dir, mint_url, amount)`
the home-mint-hardcoded helpers parameterized by target mint. `send_token` now
delegates to `send_token_at` with the home mint.
- `swap_between_mints(data_dir, from, to, amount, max_fee_sats) -> u64` — mint-quote
on B → melt-quote on A → **fee-cap check** (`swap_fee` = total_paid delivered;
bail if > cap so caller falls back to free origin) → select+melt A proofs →
**persist the spend BEFORE claiming** (crash can't double-spend) → poll B invoice
until PAID/ISSUED (`wait_for_mint_quote_paid`, 60s/2s) → mint+claim on B. Both legs
recorded in the tx log (peer field carries the counterpart mint).
## F2 step 2 — payer-side auto-swap payment builder — DONE (2026-06-17, NOT yet committed)
Plan §2a step 2. Implemented in `wallet/ecash.rs`, **uncommitted**. Verified:
`cargo test --bin archipelago -- wallet::ecash`**34/34 pass** (9 new). All on the
default path (no feature gating) so the `iroh-swarm` tree is unaffected.
- `WalletState::spendable_by_mint() -> Vec<(mint_url, balance)>` — per-mint holdings.
- `PaymentPlan { Direct{mint}, Swap{from,to}, Insufficient }` + pure
`plan_payment(holdings, accepted: &[(mint, trusted)], amount)` — the policy:
**Direct beats Swap** (already-held mint, no fee, no trust needed); a **Swap target
must be trusted** (`is_mint_trusted`); home mint is the tie-break for both legs;
`Insufficient` → caller uses free origin. Pure/sync, unit-tested without a mint.
- `build_payment_token(data_dir, accepted_mints, amount_sats, max_fee_sats) -> token`
annotates the seeder's `accepted_mints` with trust, runs `plan_payment` against
`spendable_by_mint()`, then `send_token_at` (direct) or `swap_between_mints` +
`send_token_at` (swap, honoring the fee cap). Bails (→ origin) when nothing covers
the amount within balance/trust/fee. This is the builder the fetch side calls.
## Fetch-side auto-pay + F2 step 3 hardening — DONE (2026-06-17, NOT yet committed)
Implemented; **uncommitted**. Verified: `cargo test --bin archipelago -- wallet::
swarm::` → **85/85 pass** (18 new across these + earlier steps), **0 warnings**,
default build clean. `--features iroh-swarm` build = (see below; re-run after these
edits).
- **`swarm/payment.rs`** (un-gated — builds without `iroh-swarm`): `PaymentPolicy
{ budget_sats, max_fee_sats }` + `auto_pay_token(data_dir, policy, accepted_mints,
price)` → `Ok(Some(token))` to pay / `Ok(None)` to use origin. Degrades any
wallet/mint error to `Ok(None)` so payment can never block content (origin always
wins). The on-wire token→peer exchange (in-band paid-blobs ALPN, "shape A") is the
remaining gap — deferred in the plan; this is the decision/builder brain it'll call.
- **`streaming.prepare-payment` RPC** (dispatcher + `handle_streaming_prepare_payment`):
the live, user-invokable entry to the payer-side builder. Params `{accepted_mints,
price_sats, budget_sats?, max_fee_sats?}` → `{status:"ready", token}` or
`{status:"declined"}`. This is what makes the whole payment chain reachable
(no dead code).
- **Idempotent swap resume** (`wallet/pending_swaps.json`): `swap_between_mints`
journals the in-flight swap (melt + mint quote ids) right after the source spend is
persisted, removes it on claim. `resume_pending_swaps(data_dir)` reclaims `PAID`
quotes, skips `ISSUED` (never double-claims), leaves unsettled — **wired at server
startup** (server.rs, after `swarm::init`).
- **Liquidity cache** (`wallet/swap_liquidity.json`): per-route success/failure;
`build_payment_token` orders swap targets by `target_liquidity_score` (proven routes
first, home still first). `swap_between_mints` records success/failure.
- Removed the unused `mint_quote_at`/`melt_quote_at` thin wrappers (swap calls
`MintClient` directly; nothing else used them).
## Shape-A paid-blobs negotiation ALPN — DONE (2026-06-17, NOT yet committed)
Plan §1 "shape A" — the on-wire exchange that lets a downloader pay a seeder before
fetching a gated blob. Implemented behind `iroh-swarm`; **uncommitted**. Compiles
clean (`cargo build --features iroh-swarm` → only the 2 pre-existing `trust/` warns).
**Caveat:** the request/grant *wire path* can only be fully verified with a live
two-node iroh test (serde + types are unit-tested; the QUIC round-trip is not).
- **`swarm/paid_alpn.rs`** (gated): ALPN `archy/paid-blobs/1` on a second handler on
the same endpoint/router. `PaidRequest { want, token? }` ↔ `PaidResponse
{ Granted | PaymentRequired{price_sats, accepted_mints} | Denied{reason} }`.
- **Serve side** `PaidBlobsProtocol` (`ProtocolHandler`): per bi-stream, keys the
peer by `connection.remote_id()`, runs `streaming::gate::check_gate(content-download,
peer, token, blob_size)`, maps to a verdict. Free when service disabled (default),
fail-OPEN (Granted) on gate error — mirrors `swarm/paid.rs`. A paid retry's token
opens the session the blob-GET gate then sees (same endpoint id → same session).
- **Fetch side** `negotiate_access(endpoint, data_dir, peer, hex, policy) -> bool`:
best-effort + additive. Asks with no token; on `PaymentRequired` calls
`payment::auto_pay_token` (cross-mint aware), retries with the token. Connect/
protocol failure ⇒ proceed (the GET gate is the real enforcement); explicit
`PaymentRequired` we won't/can't pay ⇒ skip peer → origin.
- **Wired into `iroh_provider.rs`**: registers the 2nd ALPN on the `Router`; `try_fetch`
negotiates with each discovered peer before `downloader.download`. `IrohProvider`
carries `data_dir` + `pay_policy` (defaults to `PaymentPolicy::free` → releases/
catalog never pay; a future film fetch passes a real budget).
### Remaining to make paid FILM fetch real (small, on top of shape A)
- Pass a non-free `PaymentPolicy` for the film scope (releases stay free) + surface an
auto-pay cap in Settings. The plumbing is all here; only the policy source is free.
- Live two-node integration test (tests/multinode/) to exercise the actual QUIC
request→pay→grant→GET path end to end.
## Remaining Phase 4 roadmap (NOT started — gated)
- **Relay protocol (§2b)** — single-hop paid `relay.fetch`. Needs design sign-off.
- **IndeeHub "Archipelago" source (steps AE)** — signed kind-30082 film catalog +
`film.catalog`/`GET /api/film/:blake3` + frontend source. Gated on user decisions
(publisher trust anchor, MinIO origin) + the external IndeeHub frontend repo.
**Shipping directive (user 2026-06-17):** ship the IndeeHub app change as a
**decoupled app-catalog update** (bump `releases/app-catalog.json`), not a binary
OTA. See `docs/phase4-streaming-ecash-plan.md` §4 note.
## After Phase 3
- **Phase 4** — IndeeHub films on the same blob layer (Blossom catalog + iroh swarm;

View File

@ -321,6 +321,15 @@ blobs flow peer-to-peer with MinIO/OVH as origin.
A→E delivers "films on every node" with free volunteer seeding (the design-doc
vision). F→H layer the sats economy on top. I is genuinely future work.
> **Shipping directive (user, 2026-06-17):** the IndeeHub "Archipelago" change
> ships — after testing — as a **decoupled app-catalog update**, NOT a binary
> OTA. Publish the new IndeeHub image + bump `releases/app-catalog.json` so every
> node gets the per-app "Update" badge (the mechanism in
> `container/app_catalog.rs` / `package.check-updates`). Node-side API changes
> (steps B/C) that need the binary go through the normal OTA; the *app* (step D,
> the IndeeHub frontend image) goes through the app catalog. See memories
> `project_decoupled_app_updates` + `reference_indeehub_canonical_source`.
---
## 5. Open questions / decisions needed