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