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>
230 lines
14 KiB
Markdown
230 lines
14 KiB
Markdown
# 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)
|
||
|
||
```bash
|
||
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.rs` — `BlobProvider` + `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 `.await`s 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_path` → `TagInfo`) with a defensive hash-match,
|
||
then publishes. Scope: releases/catalog only.
|
||
3. **Wiring** — `swarm::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::Intercept` → `Result<(), 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 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 A–E)** — 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.
|