diff --git a/core/archipelago/src/api/rpc/dispatcher.rs b/core/archipelago/src/api/rpc/dispatcher.rs index 88f1fd70..cdc65627 100644 --- a/core/archipelago/src/api/rpc/dispatcher.rs +++ b/core/archipelago/src/api/rpc/dispatcher.rs @@ -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, diff --git a/core/archipelago/src/api/rpc/streaming.rs b/core/archipelago/src/api/rpc/streaming.rs index 866945bb..6b5aa84e 100644 --- a/core/archipelago/src/api/rpc/streaming.rs +++ b/core/archipelago/src/api/rpc/streaming.rs @@ -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, + ) -> Result { + let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; + let accepted_mints: Vec = 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 { diff --git a/core/archipelago/src/server.rs b/core/archipelago/src/server.rs index dfdc0501..4e699da6 100644 --- a/core/archipelago/src/server.rs +++ b/core/archipelago/src/server.rs @@ -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(); diff --git a/core/archipelago/src/swarm/iroh_provider.rs b/core/archipelago/src/swarm/iroh_provider.rs index 5dbc09ab..afc57cc3 100644 --- a/core/archipelago/src/swarm/iroh_provider.rs +++ b/core/archipelago/src/swarm/iroh_provider.rs @@ -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>, + /// 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 diff --git a/core/archipelago/src/swarm/mod.rs b/core/archipelago/src/swarm/mod.rs index 45f0ff83..f675e6b0 100644 --- a/core/archipelago/src/swarm/mod.rs +++ b/core/archipelago/src/swarm/mod.rs @@ -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 { diff --git a/core/archipelago/src/swarm/paid_alpn.rs b/core/archipelago/src/swarm/paid_alpn.rs new file mode 100644 index 00000000..8a1fca5c --- /dev/null +++ b/core/archipelago/src/swarm/paid_alpn.rs @@ -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, +} + +/// 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, + }, + /// 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::(&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 { + 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 { + 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:?}"), + } + } +} diff --git a/core/archipelago/src/swarm/payment.rs b/core/archipelago/src/swarm/payment.rs new file mode 100644 index 00000000..3e681678 --- /dev/null +++ b/core/archipelago/src/swarm/payment.rs @@ -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> { + 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()); + } +} diff --git a/core/archipelago/src/wallet/ecash.rs b/core/archipelago/src/wallet/ecash.rs index 1107f4b4..537f1269 100644 --- a/core/archipelago/src/wallet/ecash.rs +++ b/core/archipelago/src/wallet/ecash.rs @@ -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 = 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, 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 { +// ── 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 { + 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 { + 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 = 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 { + 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 { + 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 { 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 { + 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> { + 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 { + 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 `"|"` (normalized URLs). + routes: std::collections::BTreeMap, +} + +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 { // 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), + } + } } diff --git a/docs/dht-RESUME.md b/docs/dht-RESUME.md index cf56cd37..58ca8cdd 100644 --- a/docs/dht-RESUME.md +++ b/docs/dht-RESUME.md @@ -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 A–E)** — 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; diff --git a/docs/phase4-streaming-ecash-plan.md b/docs/phase4-streaming-ecash-plan.md index c139b9ea..16c7c8a3 100644 --- a/docs/phase4-streaming-ecash-plan.md +++ b/docs/phase4-streaming-ecash-plan.md @@ -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