Files
archy/core/archipelago/src/swarm/payment.rs
T
2026-06-17 19:50:46 -04:00

168 lines
5.7 KiB
Rust

//! 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());
}
}