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:
parent
1f3b03bc6d
commit
27a6199939
@ -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,
|
||||
|
||||
@ -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> {
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
306
core/archipelago/src/swarm/paid_alpn.rs
Normal file
306
core/archipelago/src/swarm/paid_alpn.rs
Normal 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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
166
core/archipelago/src/swarm/payment.rs
Normal file
166
core/archipelago/src/swarm/payment.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user