phase4-streaming-ecash-plan.md: design for ecash-paid swarm transport, paying across different mints (§2a, Lightning-bridged swaps), networking-through-nodes relay, and an IndeeHub "Archipelago" content source. Records the resolved iroh-blobs paid-serving spike. dht-RESUME.md: task #12 + step F marked done. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
22 KiB
Phase 4+ — Paid swarm streaming & the IndeeHub "Archipelago" source
Status: PLAN / design (2026-06-17) · Branch: agent-trust-wip · not implemented
Builds on: docs/dht-distribution-design.md (Phases 0–3, swarm + Blossom), the
Phase 3 swarm work just landed (swarm/, content_hash.rs, trust/).
This plans three things the user asked for, in one coherent architecture:
- Pay sats (ecash) for transport of streaming film data between nodes.
- Networking through nodes — relaying/routing a stream via intermediate peers.
- An "Archipelago" content source in IndeeHub that shows every film uploaded to backstage, on every node running the IndeeHub app.
Headline finding
Most of the primitives already exist. This is ~80% integration glue, not greenfield. A full Cashu/ecash wallet, a metered streaming payment gate, a 4-tier transport layer, the iroh-blobs swarm (just added), signed Nostr advertisements, and the Ed25519 trust module are all already in the tree. The genuinely new code is: (a) a paid-serving hook on the iroh side, (b) a relay protocol, and (c) the IndeeHub film catalog + Archipelago-local API.
0. Inventory — what we can build on (all already in core/archipelago/src)
| Capability | Where | State |
|---|---|---|
| Cashu ecash wallet (mint/melt/send/receive, BDHKE) | wallet/ecash.rs, wallet/cashu.rs, wallet/mint_client.rs, wallet/bdhke.rs |
✅ implemented |
| Local mint (Fedimint) backing the wallet | apps/fedimint (http://127.0.0.1:8175) |
✅ deployed |
| Lightning (invoices, pay, channels) for mint/melt | api/rpc/lnd/*, container/lnd.rs, apps/lnd |
✅ implemented |
Streaming payment gate (accepts cashuA tokens, opens metered session) |
streaming/gate.rs |
✅ implemented |
| Metering & pricing (sats per byte / ms / request; e.g. content-download = 1 sat/MB) | streaming/meter.rs, streaming/pricing.rs, streaming/session.rs |
✅ implemented |
Revenue/profit accounting (incl. StreamingRevenue tx type) |
wallet/profits.rs |
✅ implemented |
| Paid-service discovery on Nostr (kind 10021, TollGate TIP-01 shape) | streaming/advertisement.rs |
✅ implemented |
| Content server that verifies+receives payment before serving | content_server.rs (verify_and_receive_payment()) |
✅ implemented |
| iroh-blobs swarm (fetch content-addressed blobs from peers, verify, seed) | swarm/ (iroh-swarm feature) |
✅ just added |
| Signed seed adverts (NIP-33 kind 30081, blake3→endpoint) | swarm/seed_advert.rs |
✅ just added |
| BLAKE3 content addressing | content_hash.rs |
✅ implemented |
Ed25519 trust / did:key / detached signatures |
trust/ |
✅ implemented (anchor ceremony pending) |
4-tier transport (Mesh > LAN > FIPS > Tor) + last_transport |
transport/*, fips/dial.rs |
✅ implemented |
| Node discovery + federation trust (Trusted/Observer) | nostr_handshake.rs, federation/* |
✅ implemented |
What is NOT present and must be built:
- A paid-serving hook on the iroh-blobs provider. Today the swarm seeds to
anyone (
BlobsProtocol::new(&store, None)— no authorization). To charge for swarm bandwidth we need a per-request gate that consultsstreaming/gate.rs. - A relay protocol. No "peer A asks peer B to forward traffic to peer C". Transport is point-to-point; there is no multi-hop routing, TTL, or relay accounting.
- IndeeHub Archipelago catalog. The shipped IndeeHub points at the external
staging-api.indeehub.studio+ AWS S3/CloudFront. Nothing makes a film uploaded on node A visible on node B. No backstage code exists yet.
1. Pay sats (ecash) for transport of streaming films
Goal
When node B streams a film blob (an HLS .ts segment) from node A's swarm,
A earns sats for the bytes it serves — using the ecash gate that already meters
content-download.
What exists vs. what's new
- ✅ The economic machinery is done:
streaming/pricing.rsalready ships acontent-downloadservice priced per MB;streaming/gate.rsturns acashuAtoken into a metered session;meter.rsdeducts bytes;profits.rsrecordsStreamingRevenue. - ❌ The swarm serving path doesn't consult any of it.
IrohProvider::newspins upBlobsProtocolthat answers every blob request unconditionally.
Design — "paid swarm" as a gated blob protocol
The clean seam is the iroh-blobs accept side. Two viable shapes:
(A) In-band gate via a custom ALPN (preferred). Keep iroh-blobs for the raw
byte transfer but front it with a tiny request/grant exchange on a second ALPN
(archy/paid-blobs/1):
- B wants
blake3:H. It dials A's endpoint and sends{want: H, token?: cashuA}. - A calls
streaming::gate::check_gate("content-download", peer=B, bytes≈len(H), token).PaymentRequired→ A replies with price + its accepted mints (streaming.list-mints) and the sat amount; B mints/sends acashuAand retries.PaidAndAllowed/Allowed(within existing session allotment) → A authorizes the blob hash for this connection and hands off to iroh-blobs to stream it.
- A meters served bytes via
meter::record_and_checkand records revenue.
(B) Pre-paid session, then open serving. B opens a metered session up front
(buys N MB of content-download allotment with one token), and A's blob protocol
checks "does this peer have remaining allotment?" before each blob. Simpler, fewer
round-trips, slightly looser accounting. Good first cut.
Recommend (B) for v1 (least new protocol surface — reuses sessions verbatim), graduating to (A) when we want per-blob price discovery.
Free vs. paid policy (important)
- OTA + app-catalog blobs stay FREE. Charging for security updates is hostile and breaks the "origin always wins" guarantee. Gating applies only to the IndeeHub film scope (a per-blob or per-advert "monetized" flag).
- Trusted federation peers (
TrustLevel::Trusted) can be configured to serve each other free; payment is for untrusted/public swarm peers.
Integration points
- DONE (2026-06-17):
swarm/paid.rs— the accept-side gate. Builds the iroh-blobsEventSender(intercept connect + GET, hard-disablepush) and authorizes each request throughstreaming::gate::check_gate("content-download", peer_endpoint, blob_size, None). Free when the service is disabled (default); denies unpaid peers when enabled; fails OPEN on internal error. Wired intoIrohProvider::new; unit-tested. The Settings toggle the user just got drives it. - Reuse:
streaming/gate.rs,meter.rs,session.rs,wallet/ecash.rs,streaming/advertisement.rs(advertise the node as a paid blob seeder). - TODO (fetch side):
swarm::fetch_content_addressedgains an optional "willing-to-pay budget + token source" so a downloading node can auto-pay from its ecash wallet up to a cap (opening a session viastreaming.pay), then fall back to origin if too expensive. This is where cross-mint settlement (§2a) plugs in — the payer may need to swap into the seeder's accepted mint first.
2. Networking through nodes (relayed / routed streaming)
This is the largest genuinely-new piece. Two distinct meanings — both useful:
2a. iroh-native relays (cheap, already mostly free)
iroh 1.0 already hole-punches and falls back to relay servers for connectivity when a direct QUIC path can't be established. So "streaming through a node that can reach the seed when I can't" partly exists at the iroh layer. Action: run/seed our own iroh relay(s) on the OVH/hub infrastructure and pin them in config, so the swarm doesn't depend on n0's public relays. Low effort, high resilience.
2b. Application-level paid relay (the real gap)
"Node B pays node A to fetch a film from origin/swarm on B's behalf and forward it" — useful when B is behind a censored/expensive link and A has good connectivity (the beta-cellular-node scenario from memory). This needs a real protocol:
relay.offeradvert (Nostr kind 10021 with arelaytag + price/MB) — reusestreaming/advertisement.rs; add arelay-bandwidthservice topricing.rs.relay.fetchrequest over the existing transport (PeerRequestinfips/dial.rs):{content: blake3:H | url, pay: cashuA}. The relay runs the normalswarm::fetch_content_addressed(swarm-assist, origin fallback), meters the bytes throughstreaming/gate, and streams them back to the requester.- Accounting: add a
RelayBytesmetric tostreaming/meter.rsdistinct from origincontent-download, so "relay provided" is tracked separately inprofits.rs(the doc already separatesrouting_feesfromstreaming_revenue). - Safety rails: single-hop only for v1 (no A→B→C→D); TTL + loop guard before any multi-hop; cap per-session bytes; only relay the public film scope, never private user blobs or arbitrary URLs (prevent open-proxy abuse).
Phasing for §2
- Pin our own iroh relays (config only). — days
- Single-hop paid
relay.fetchfor film blobs, gated by ecash. — the core build - Multi-hop routing + path discovery. — deferred; only if single-hop proves out
2a. Cross-mint ecash settlement — paying across different mints
Problem (user, 2026-06-17): payment must work when the payer and the seeder use different mints — not only two nodes on the same Fedimint. A node holding tokens on mint A must be able to pay a seeder that only accepts mint B, automatically.
Why this is mostly a generalization, not new crypto
The wallet already tracks proofs per-mint: WalletData::balance_for_mint(url),
select_proofs(url, amount), add_proofs(url, proofs) are all mint-scoped, and
MintClient::new(url) targets any mint. What's hardcoded is convenience: mint_quote
/ melt_quote / mint_tokens / melt_tokens always use the single home
wallet.mint_url. So the data model is multi-mint already; we add the swap and
parameterize the helpers by target mint.
The swap primitive (Cashu/Fedimint settle over Lightning)
To move value A → B, both mints expose BOLT11 mint+melt quotes (already in
mint_client.rs), and Lightning bridges them:
MintClient::new(B).mint_quote(amount)→ a BOLT11 invoiceinv_B(pay it to get B tokens).MintClient::new(A).melt_quote(inv_B)→ cost in A tokens (amount + fee_reserve).- Select A proofs and
meltthem on A to payinv_Bover Lightning. - When
inv_Bsettles,MintClient::new(B).mint_tokens(quote_B)→ claim B tokens;wallet.add_proofs(B, …).
Net: value lands on B minus (A melt fee + LN routing + B mint fee). The node's LND
isn't strictly required — the mints' own LN gateways settle — but a healthy local
node/route improves success. Implementation = three thin *_at(mint_url, …)
variants of the existing helpers + one composer:
swap_between_mints(data_dir, from, to, amount, max_fee_sats) -> Result<u64>.
Where the swap happens — two models
- Payer-side swap (recommended default). Before paying seeder S (whose
accepted_mintsare advertised viastreaming.advertise/ the gate'sPaymentRequired.pricing.accepted_mints), the payer picks the cheapest path: pay directly if it already holds a token on one of S's mints; otherwiseswap_between_mints(A → S_mint)then send a token denominated in S's mint. S never has to trust mint A — it only ever receives its own mint's tokens. Clean. - Payee-side auto-consolidation (optional, more liberal). S widens
accepted_mintsto any mint it's willing to melt-swap from, accepts an A token, then swaps A → home-mint in the background. Broader acceptance, but S briefly carries mint-A counterparty risk.
A node can do both: advertise a broad accept list and have payers prefer direct/cheap mints.
Guardrails (these are the real design decisions)
- Mint trust list. Mints can be insolvent or rug. Only swap into / accept mints on a configured allow-list (default: home mint + a small curated set, with the local Fedimint always trusted). Surface this in the Settings UI alongside the per-service pricing.
- Fee/slippage cap. Every swap costs sats.
max_fee_sats(or a max %) refuses a swap that would cost more than the content is worth; the payer then declines and uses origin. Show the all-in cost (price + swap fee) before auto-paying. - Origin always wins. If the LN swap fails (no route, mint offline, over budget), fall back to the HTTP origin with no payment. A mint problem must never block content.
- Idempotency / crash-safety. Persist in-flight swaps (
meltquote id +mintquote id) so a crash between "paidinv_B" and "claimed B tokens" resumes the claim instead of double-paying. Reuse the wallet's tx log. - Liquidity. Swaps need the mints to have inbound/outbound LN liquidity; cache recent swap success per mint-pair and prefer routes that have worked.
Phasing for §2a
*_at(mint_url, …)helpers +swap_between_mints+ mint trust list + fee cap. — the core- Payer-side auto-swap in the payment builder (pick cheapest accepted mint). — wires §1/§2 to it
- Idempotent resume + per-pair liquidity cache. — hardening
- (Optional) payee-side auto-consolidation.
This keeps the headline promise intact: pay anyone, on any trusted mint, automatically — or fall back to free origin.
3. IndeeHub "Archipelago" content source
Goal
A new source tab inside the IndeeHub app, "Archipelago", listing every film
uploaded to backstage, streamable on any node — independent of the external
indeehub.studio API.
Today (from the research)
- IndeeHub frontend (Next.js) is built against
NEXT_PUBLIC_API_URL = staging-api.indeehub.studioand pulls media from AWS S3/CloudFront. It is not Archipelago-aware. nginx proxies it at/app/indeedhub/and injects a NIP-07 Nostr provider. - A MinIO stack exists (
indeedhub-public/indeedhub-privatebuckets); FFmpeg produces HLS there. No backstage upload UI/code exists yet. - The design doc's Phase 4 already describes the target: backstage → FFmpeg → HLS
→ each
.tsis a BLAKE3 blob → signed Nostr "Blossom" catalog event → any node resolves the content address and streams from the nearest holder; MinIO origin.
Architecture — four pieces
(i) Backstage upload + transcode (origin side).
Minimal creator flow on a publisher node: upload master → FFmpeg → HLS
(.m3u8 + .ts) into MinIO (reuse the existing indeedhub-ffmpeg/MinIO stack).
For each segment compute blake3_hex (content_hash::blake3_hex) and import it
into the iroh seed store (IrohProvider::seed_and_advertise, generalized beyond
releases). The playlist references segments by content hash.
(ii) Signed film catalog on Nostr (the "Archipelago" source).
Define a new addressable event — kind 30082, archy-film (sibling of the
30081 seed advert) — published by the publisher node, signed via trust/:
{
"title": "...", "creator_did": "did:key:z...", "duration_s": 5400,
"poster": "blake3:...", // poster image blob
"playlist": "blake3:...", // the .m3u8 (itself a blob)
"segments": ["blake3:...", ...], // ordered .ts segment hashes
"enc": { "scheme": "aes-128", "key_ref": "nip98" }, // see (iv)
"monetized": { "service": "content-download", "sats_per_mb": 1 } // optional
}
The signature uses trust::sign_detached; consumers verify with
trust::verify_detached. Publisher trust: films show in the Archipelago tab
only from publishers on the node's trusted/federation set (or a pinned
"Archipelago film-root" key, mirroring the release-root anchor concept). This is
the key that stops the shared catalog from being a spam vector.
(iii) Archipelago-local film API (makes it appear on every node).
New RPC + HTTP endpoints in api/:
film.catalog/GET /api/film-catalog— query Nostr relays for kind-30082 events from trusted publishers, verify signatures, dedupe, return merged JSON. Cache likeapp_catalog.rsdoes (mtime/TTL, atomic write).GET /api/film/:blake3— serve a segment:swarm::fetch_content_addressed(swarm-assist → MinIO/OVH origin), BLAKE3-verified, with HTTP range support so the player can seek. This is where §1 (paid serving) and §2 (relay) plug in.- The IndeeHub frontend gets an "Archipelago" source that points at
/api/film-cataloginstead ofindeehub.studio. Cleanest: a small build/runtime flag or an injected config (same nginxsub_filtermechanism already used to inject the NIP-07 provider) that registers the Archipelago source alongside the existing studio source — additive, not a replacement.
(iv) Encryption / access (private films). Public films: plaintext segments, freely cacheable, swarm-distributable. Private films: keep AES-128 HLS; untrusted seeds cache only ciphertext (they never see plaintext), and the decryption key is delivered per-viewer via NIP-98 auth (the mechanism IndeeHub already uses) or NIP-44 DM. Payment (§1) gates bytes; the key gates plaintext — two independent locks. This lets us pay strangers to seed encrypted blobs without leaking content.
"On every node" — propagation
Propagation is pull, not push: every node's film.catalog periodically queries
the same Nostr relays (already configured for discovery) for trusted-publisher film
events. A film uploaded on node A is therefore visible on node B as soon as B
refreshes its catalog — exactly how app_catalog.rs already distributes app
updates fleet-wide. No central server; the relays carry only signed metadata, the
blobs flow peer-to-peer with MinIO/OVH as origin.
4. Suggested end-to-end phasing
| Step | Deliverable | Risk | Reuses |
|---|---|---|---|
| A | Generalize seed_and_advertise beyond releases → arbitrary public blob scope (films) |
low | swarm/ |
| B | film.catalog RPC + signed kind-30082 events + trusted-publisher gating |
low–med | trust/, app_catalog.rs pattern |
| C | GET /api/film/:blake3 range-streaming via swarm-assist + MinIO origin |
med | swarm/, content_server.rs |
| D | IndeeHub "Archipelago" source wired to the local API (additive) | med (frontend, external repo) | nginx sub_filter |
| E | Backstage: upload → FFmpeg → HLS → blob import + catalog publish | med | MinIO/ffmpeg stack |
| F | DONE — paid swarm serving (swarm/paid.rs gates the blob protocol via streaming/gate); free by default |
med | streaming/* |
| F2 | Cross-mint settlement (§2a): swap_between_mints + payer-side auto-swap + mint trust list + fee cap |
med–high | wallet/ecash, mint_client, lnd |
| G | Pin our own iroh relays (config) | low | iroh |
| H | Single-hop paid relay.fetch for film blobs |
high | transport/, streaming/* |
| I | Multi-hop routing | high / deferred | — |
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.
5. Open questions / decisions needed
- iroh-blobs authorization granularity. ✅ RESOLVED (2026-06-17 spike).
iroh-blobs 0.103 exposes exactly the hook we need:
BlobsProtocol::new(&store, Some(EventSender)). With anEventMaskset to intercept, the provider asks our handler to authorize each request and we returnEventResult = Result<(), AbortReason>:RequestMode::Intercept/InterceptLog— per-blob-request allow/deny (Err(AbortReason::Permission)denies,Err(AbortReason::RateLimited)defers).ConnectMode::Intercept— reject at the connection handshake (cheap pre-filter).ThrottleMode::Intercept— per-request throttle/meter hook for byte accounting.RequestMode::Disabled— hard-reject a whole request kind (e.g. disablePushso peers can never write into our store). → §1 shape (A) is the recommended path (native, no fork): the accept-side handler callsstreaming::gate::check_gate("content-download", peer_endpoint, bytes, token)and mapsPaymentRequired/InsufficientPayment→Err(Permission),Allowed/PaidAndAllowed→Ok(()). Peer identity comes from theConnection's remote endpoint id. (Seeiroh_blobs::provider::events.)
- Film-publisher trust anchor. One global "Archipelago film-root" key (curated store, like release-root) vs. per-node trusted-publisher sets vs. both. Affects spam resistance and who can publish to everyone's Archipelago tab.
- MinIO as origin across the fleet — single canonical MinIO on the hub vs. per-node MinIO with cross-seeding. The swarm makes per-node origin viable but the first upload needs a home.
- IndeeHub frontend is an external repo (
~/Projects/indeehub-frontend, built intoapps/indeedhub). Adding an "Archipelago" source needs changes there; scope whether it's a build-time source registration or a runtime-injected config (preferred — keeps the node OS in control). - Pricing defaults & free tier. What's free (OTA, trusted peers, first N MB?)
vs. paid, and the default sats/MB.
pricing.jsonalready supports this; needs a policy. - Payment UX / auto-pay caps. A downloading node auto-paying from its ecash wallet needs a user-set ceiling and a "prefer free origin if peer wants > X" rule, so streaming never silently drains the wallet.
6. Why this is tractable
The hard, slow-to-build substrate — an ecash wallet, a metered payment gate, content addressing, a verifying swarm, signed discovery, a trust module, a multi-transport stack — is already in the tree and (for the swarm) just tested. The remaining work is wiring those together along the three axes above, with the two new protocols (paid blob serving, single-hop relay) being the only substantial net-new surface. Everything stays behind feature flags / opt-in config and obeys the project's north star: swarm-assist, origin always wins — and now, free updates, optional paid films.