feat(dht): Phase 4 — paid swarm streaming (cross-mint ecash + Shape-A ALPN)

Fetch-side auto-pay decision layer (payment.rs), Shape-A paid-blobs
negotiation ALPN (paid_alpn.rs), cross-mint ecash swap + payer auto-swap
builder + idempotent resume/liquidity cache (ecash.rs), and the
streaming.prepare-payment RPC. All gated behind the iroh-swarm feature
(off by default). 91/91 tests pass, both build configs clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-06-17 07:36:31 -04:00
co-authored by Claude Opus 4.8
parent 1f3b03bc6d
commit 27a6199939
10 changed files with 1528 additions and 5 deletions
@@ -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,
+58
View File
@@ -205,6 +205,64 @@ impl RpcHandler {
}
}
/// Build a payment token for a remote seeder (payer side, cross-mint aware).
///
/// Given the seeder's advertised `accepted_mints` and `price_sats`, builds a
/// `cashuA` token denominated in one of those mints — paying directly if we
/// already hold the right mint, else auto-swapping into a trusted accepted
/// mint (within `max_fee_sats`). If the price is over `budget_sats`, the
/// wallet can't cover it, or the swap is too costly, returns `declined` so
/// the caller falls back to the free origin (origin always wins).
pub(super) async fn handle_streaming_prepare_payment(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let accepted_mints: Vec<String> = params
.get("accepted_mints")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default();
let price_sats = params
.get("price_sats")
.or_else(|| params.get("amount_sats"))
.and_then(|v| v.as_u64())
.ok_or_else(|| anyhow::anyhow!("Missing price_sats"))?;
// Default budget = the asked price (willing to pay exactly what's quoted).
let budget_sats = params
.get("budget_sats")
.and_then(|v| v.as_u64())
.unwrap_or(price_sats);
let max_fee_sats = params
.get("max_fee_sats")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let policy = crate::swarm::payment::PaymentPolicy::with_budget(budget_sats, max_fee_sats);
match crate::swarm::payment::auto_pay_token(
&self.config.data_dir,
&policy,
&accepted_mints,
price_sats,
)
.await?
{
Some(token) => Ok(serde_json::json!({
"status": "ready",
"token": token,
"paid_sats": price_sats,
})),
None => Ok(serde_json::json!({
"status": "declined",
"message": "payment declined (over budget, unpayable, or swap too costly) — use free origin",
})),
}
}
/// Discover available streaming services (pricing info).
/// This is the unauthenticated discovery endpoint.
pub(super) async fn handle_streaming_discover(&self) -> Result<serde_json::Value> {