archy/docs/dht-RESUME.md
archipelago 27a6199939 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>
2026-06-17 07:36:31 -04:00

14 KiB
Raw Blame History

DHT work — RESUME HERE

Last updated: 2026-06-16 · Branch: agent-trust-wip · Worktree: ~/Projects/archy-dht

This file is the single source of truth for resuming the DHT / peer-distribution work after a restart. Read it top to bottom, run the Verify state block, then continue at Next step.


⚠️ CRITICAL — where to work (do not skip)

  • Work ONLY in the worktree ~/Projects/archy-dht on branch agent-trust-wip.
  • NEVER run git checkout / branch-switch / commit in the shared tree ~/Projects/archy. Another agent cuts releases on main there. Git branch state is global to one working tree, so a checkout in the shared tree drags every session onto that branch and can clobber uncommitted work. That already happened once — the worktree exists specifically to prevent it. See memory feedback_concurrent_agent_tree.
  • The shared tree stays on main for the release agent. Leave it alone.

Build facts (so you don't get surprised)

  • It's a binary crate: test with cargo test --bin archipelago -- <filter> (there is no lib target).
  • The test profile is opt-level=3 → every incremental test rebuild of the archipelago crate is ~5 min; a cold build of the iroh feature tree is ~19 min. Budget for it. Run builds in the background and poll.
  • Default build = no iroh. The iroh swarm engine is behind the iroh-swarm Cargo feature (off by default): cargo build --features iroh-swarm.
  • Plain cargo build (no feature) is the fleet build and is unaffected by any DHT work.

Verify state (run these first on resume)

cd ~/Projects/archy-dht
git branch --show-current            # → agent-trust-wip
git log --oneline -7                 # see the commit list below
git status --short                   # should be clean (or your in-progress edits)
git worktree list                    # archy-dht → agent-trust-wip; archy → main
# sanity compile (default, fast-ish):
cargo build --bin archipelago 2>&1 | tail -3

What is DONE (committed on agent-trust-wip)

Design doc: docs/dht-distribution-design.md (the full plan).

Commit Phase Summary
0fef8086 base parked trust module + seed::derive_release_root_ed25519 (pre-existing)
27f11bf8 0 signed-catalog authenticity wired: trust/ module verifies the release-root detached signature in app_catalog::fetch_one; release-root KAT pinned
f0cb91ed 1 BLAKE3 alongside SHA-256: content_hash.rs, ComponentUpdate.blake3, BlobMeta.blake3
2523c9e3 2 seam swarm/mod.rsBlobProvider + fetch_content_addressed (verify peer bytes, origin-always-wins); iroh-swarm flag; wired into update.rs
082946aa 2 engine real swarm/iroh_provider.rs over iroh 1.0 + iroh-blobs 0.103 (optional deps). Dep tree proven to resolve+compile against the pinned stack
9fa56a82 3 core swarm/seed_advert.rs — signed Nostr seed-advertisement protocol (NIP-33 kind 30081, d-tag=blake3)

All tests green at each step. Total new modules: trust/, content_hash.rs, swarm/.

task #12 — Phase 3 glue + wiring — DONE (2026-06-17, NOT yet committed)

Implemented in the worktree, uncommitted (release in flight — do not commit/merge until the user says so). Verified: default cargo build clean, cargo build --features iroh-swarm clean, cargo test --bin archipelago -- swarm::8/8 pass.

  1. NostrSeedDiscovery (swarm/iroh_provider.rs) — ProviderDiscovery made async (#[async_trait]); impl queries relays via the new seed_advert::fetch_seed_endpoint_ids and parses each string with EndpointId::from_str (EndpointId = PublicKey, has FromStr/Display), skipping unparseable. try_fetch now .awaits discovery.
  2. Publish path — dep-free seed_advert::fetch_seed_endpoint_ids + publish_seed_advert (reuse now-pub(crate) build_nostr_client / load_or_create_nostr_keys); IrohProvider::seed_and_advertise imports the blob into the FsStore (blobs().add_pathTagInfo) with a defensive hash-match, then publishes. Scope: releases/catalog only.
  3. Wiringswarm::init() builds the IrohProvider once at startup into a OnceLock<SwarmRuntime> (keeps endpoint/router alive → keeps seeding); providers() returns the registered provider; announce_held_blob() is called from update.rs after each release component passes both hash gates. New config swarm_enabled (ARCHIPELAGO_SWARM_ENABLED, default false); server.rs calls swarm::init. All iroh code stays behind iroh-swarm; default build inert.

iroh-blobs paid-serving spike (open Q#1) — RESOLVED: BlobsProtocol::new(&store, Some(EventSender)) + EventMask intercept gives native per-request allow/deny (RequestMode::InterceptResult<(), AbortReason>), connection-level reject (ConnectMode::Intercept), and per-request throttle/meter (ThrottleMode::Intercept).

NEW: Phase 4+ plan (paid streaming / relay / IndeeHub) — docs/phase4-streaming-ecash-plan.md

Design for: (1) ecash-paid swarm transport, (2) networking through nodes / relay, (3) IndeeHub "Archipelago" content source (signed Nostr film catalog, kind 30082). Headline: ~80% already exists (Cashu wallet, streaming/ payment gate + metering, 4-tier transport, the swarm above). Also shipped this session: a Networking Profits → Settings UI in neode-ui (new views/web5/Web5NetworkingProfitsSettings.vue + route + button in Web5QuickActions.vue + common.settings i18n) that drives the existing streaming.list-services/configure-service RPCs; free-everything is the default (all services ship enabled:false). Frontend typechecks clean (pre-existing Web5ConnectedNodes.vue .did errors are NOT ours). neode-ui deps were npm installed 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::ecash25/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::ecash34/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; MinIO origin). Each HLS .ts segment = a content-addressed blob.
  • Phase 0 GO-LIVE (needs the user) — the catalog/manifest signature anchor trust::anchor::RELEASE_ROOT_PUBKEY_HEX is still None; the pinned KAT is the TEST mnemonic, not the real key. Going live = signing ceremony with the real release master seed (only the user has it) → derive release-root → bake its pubkey into anchor.rs → sign the real releases/app-catalog.json. Until then verification is advisory (verify-if-present, anchor not enforced).

Mergeability

As of last check we were only ~4 commits diverged from main; the only shared-file overlap is seed.rs + update.rs. Do NOT merge to main while the release is in flight — that's the user's call. Sync (merge main → agent-trust-wip) once the release lands and main is clean.

Background build logs from the last session (may be stale)

/tmp/dht-*.log — phase test/build outputs. Safe to ignore/delete on resume.