From 4c4cf6d8b48bd422f51d011e1696f21331c5d135 Mon Sep 17 00:00:00 2001 From: archipelago Date: Tue, 16 Jun 2026 11:15:47 -0400 Subject: [PATCH 01/14] docs(dht): peer-distributed content design (iroh swarm + signed manifests) Captures the verified 2026-06-16 design: swarm-assist/origin-always-wins, iroh-blobs as the swarm engine, BLAKE3 addressing, signed Nostr/release-root authenticity, and the Phase 0-4 plan. Foundation doc for the dht branch. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/dht-distribution-design.md | 185 ++++++++++++++++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 docs/dht-distribution-design.md diff --git a/docs/dht-distribution-design.md b/docs/dht-distribution-design.md new file mode 100644 index 00000000..4746596f --- /dev/null +++ b/docs/dht-distribution-design.md @@ -0,0 +1,185 @@ +# DHT / Peer-Distributed Content Design + +**Status:** Design (no code yet) · **Date:** 2026-06-16 · **Author:** archipelago + Claude + +## 1. Purpose + +Make Archipelago's large-file movement **peer-distributed**: a node should be able to +fetch content (OTA updates, app/OCI images, IndeeHub films) from *any other node that +already has it*, falling back to the central origin only when no peer can serve it. + +This document covers three use-cases that are **the same problem** — +"fetch content-addressed bytes from whatever node already has them, verify, fall back to +origin": + +1. **OTA releases** — node binaries + frontend tarballs. +2. **App installs** — container/OCI images. +3. **IndeeHub streaming** — films created in "backstage" on one node, streamable from any + node that has them stored or cached. + +### Guiding principle (decided 2026-06-16) + +> **Swarm-assist, origin always wins.** The peer swarm is an *optimization*. The central +> origin (OVH HTTP release assets / MinIO) remains the **guaranteed fallback** and the +> source of truth for reliability. We never bet correctness or availability on the P2P +> layer. This is what keeps the system bulletproof while the P2P stack matures. + +## 2. Current state (verified 2026-06-16) + +### OTA (`core/archipelago/src/update.rs`) +- Manifest at `DEFAULT_UPDATE_MANIFEST_URL` (`update.rs:67`) = vps2 OVH + (`146.59.87.168:3000/lfg2025/archy/raw/branch/main/releases/manifest.json`). +- `check_for_updates()` (`:565`) walks an operator mirror list (`default_mirrors()` `:105`, + `load_mirrors()` `:123`), origin-rewrites component URLs to the chosen mirror + (`rewrite_manifest_origins()` `:227`). +- `download_component_resumable()` (`:821`) — resumable HTTP Range download, 6 retries, + exponential backoff. +- **Integrity: SHA-256 only** (`:984`), compared against `ComponentUpdate.sha256`. +- **No authenticity:** manifests are *unsigned*. A compromised mirror can serve a malicious + but hash-consistent binary. Post-apply health probe + auto-rollback exist + (`verify_pending_update()` `:389`, `rollback_update()` `:1423`) but that is not a + substitute for signature verification. +- Manifest schema: `{version, release_date, changelog[], components[{name, current_version, + new_version, download_url, sha256, size_bytes}]}`. + +### App installs (`core/archipelago/src/api/rpc/package/install.rs`) +- `handle_package_install()` (`:195`) → `do_pull_image()` (`:1062`) tries each registry from + `container/registry.rs` in priority order (OVH primary), `rewrite_image()` rewrites the + origin, `podman pull`. Same centralized-mirror shape as OTA. + +### Transport & identity (already P2P-capable) +- `transport/mod.rs` — `NodeTransport` trait (`:74`), `TransportRouter` (`:336`), priority + stack Mesh→LAN→FIPS→Tor. `PeerRegistry` (`:199`) tracks per-peer addresses + (mesh id, LAN ip:port, `fips_npub`, onion). +- Seed-derived identity (`seed.rs`): node Ed25519 (`archipelago/node/ed25519/v1`), node + Nostr secp256k1 (`archipelago/nostr-node/secp256k1/v1`), FIPS secp256k1 + (`archipelago/fips/secp256k1/v1`). DID + npub per node. +- **Already content-addressed:** `blobs.rs` stores `blobs/` keyed by **SHA-256** hex, + with HMAC-SHA256 capability tokens (`BlobMeta`, 64 MiB cap). `transport/chunking.rs` does + Reed-Solomon chunking for LoRa. + +### Trust scaffolding — **NOT built yet** +- No `core/src/trust/`, no `ROOT_PUBKEY`, no `derive_release_root_*`, no + `archipelago/release/root/*` HKDF strings, no JCS/canonical JSON, no signing ceremony + scripts, no `manifest-v2.json`. The "Phase 0 signed manifest" design exists only as notes. + +### IndeeHub (the streaming target) +- Original platform (not a fork). Working source: `~/Projects/Indeedhub Prototype/` + (Vue 3 + NestJS). Submodule `git.tx1138.com/lfg2025/indeehub.git` (host retired — + needs a live remote). In `archy`: image-only, `apps/indeedhub/manifest.yml` pulls + `146.59.87.168:3000/lfg2025/indeedhub:1.0.0` (+ `-api`, `-ffmpeg`, postgres, redis, + minio, nostr-rs-relay). +- Streaming today: FFmpeg → **HLS (.m3u8 + AES-128 .ts segments)** in **MinIO** + (`indeedhub-private`/`-public`), metadata in Postgres, transcode queue in Redis, + auth via Nostr (NIP-98). Glue: `install.rs:68` `patch_indeedhub_nostr_provider()` + injects the NIP-07 provider into the nginx-wrapped frontend. +- **No "backstage" code yet** — it's the creator/upload side we're introducing. + +## 3. Protocol evaluation (verified maintenance status, 2026-06-16) + +| Option | Verdict | Why | +| --- | --- | --- | +| **Web5 / TBD / DWN** | ❌ Reject | Block **wound TBD down**, handed components to DIF (`TBD54566975`→`decentralized-identity`). `web5-js` latest release **0.12.0, Oct 2024** (~20 mo stale). DWN spec still **Draft**. DWNs are DID-scoped *record stores*, not a blob-streaming swarm. Fails the "well-maintained + bulletproof" bar. | +| **iroh / iroh-blobs** | ✅ Swarm engine | **v1.0.0 shipped 2026-06-15.** Rust (matches core), **BLAKE3 verified streaming** over **QUIC + hole-punching + relays**, content-addressed, KB→TB, **native byte-range** support (ideal for HLS). n0 team, production relays. | +| **Nostr Blossom** | ✅ Index/catalog layer | SHA-256-addressed blobs over HTTP, modular BUD specs (BUD-01/02/04/05/06/08), actively developed, **already aligned** (Nostr identity everywhere; `blobs.rs` already SHA-256). Server-centric (not a peer swarm) → use as discovery + IndeeHub catalog + HTTP fallback, not the distribution engine. | +| **libp2p-kad (hand-rolled DHT)** | ⚠️ De-prioritize | Was the old "Phase 4 build a Kademlia" plan. iroh 1.0 supersedes the need to hand-roll discovery + swarm. Revisit only if iroh proves unworkable. | + +**Note vs. prior plan:** the saved DHT design said "no iroh as a Phase 0–5 dep (revisit +post-Phase 3)." iroh hitting 1.0 removes the main reason for that deferral — **this design +reverses that non-choice** and adopts iroh as the swarm layer, collapsing the from-scratch +Kademlia work. + +## 4. Recommended architecture — three layers, one engine + +Build **one** peer-distribution layer; use it for all three use-cases. + +``` + ┌─────────────────────────────────────────────┐ + Authenticity │ Signed Nostr events (per-node npub) + │ "who published this, + & Discovery │ seed-derived RELEASE ROOT key for OTA + │ who has it" + │ Blossom BUD catalog for IndeeHub │ + └─────────────────────────────────────────────┘ + ┌─────────────────────────────────────────────┐ + Integrity & │ BLAKE3 content addressing (iroh-native, │ "name bytes by hash, + Addressing │ range-verifiable). SHA-256 kept in manifest │ verify on arrival" + │ during migration window. │ + └─────────────────────────────────────────────┘ + ┌─────────────────────────────────────────────┐ + Transport & │ iroh-blobs swarm (peers that already have │ "move the bytes" + Swarm │ it) ─── fallback ───▶ OVH HTTP / MinIO │ + │ origin (ALWAYS wins) │ + └─────────────────────────────────────────────┘ +``` + +- **Integrity/addressing — BLAKE3.** iroh-native, supports verified *range* streaming + (essential for HLS + resumable). Keep SHA-256 in the manifest for back-compat through the + migration window; add a `blake3` field alongside. +- **Discovery/authenticity — signed Nostr events + release root key.** + - OTA: the **Phase 0 seed-derived release root key** signs the manifest (BLAKE3 root hash + + version). Integrity ≠ authenticity — content addressing proves *bytes are intact*, the + signature proves *we authorized them*. Both are required. + - "Who has blob X" advertised via signed Nostr events `{content-hash, provider-npub, ts}`, + so nodes find seeds without a central tracker. + - IndeeHub: Blossom BUDs for the film catalog + provider/mirror lists. +- **Transport/swarm — iroh-blobs, origin fallback.** Node asks the swarm for a hash; peers + that have it serve range-verified BLAKE3 streams; if the swarm yields nothing, fall back to + the existing resumable HTTP path (`update.rs:821`) against OVH/MinIO. **A node that + finishes a download automatically becomes a seed.** + +### Bulletproof posture +The swarm sits *above* a proven HTTP path, never in place of it. Worst case (every peer +offline, iroh bug, NAT failure) the node downloads exactly as it does today. iroh 1.0 is new; +this containment is deliberate. + +## 5. Use-case flows + +### OTA / app installs +1. Node reads the **signed** manifest (via signed Nostr event or HTTP), gets BLAKE3 root hash + + release-root signature; verify signature → reject on failure. +2. Query swarm (signed provider events) for peers holding that hash. +3. Download range-verified BLAKE3 stream from peers; verify full BLAKE3 (+ SHA-256 during + migration). +4. No peers / failure → resumable HTTP from OVH (current path). +5. Apply + health-probe + auto-rollback (unchanged). Updated node **becomes a seed**. +6. OCI images: content-address image layers the same way; OVH registry stays the origin. + +### IndeeHub streaming ("backstage → any node") +1. Creator publishes a film in **backstage** → FFmpeg → HLS; **each .ts segment is a + content-addressed (BLAKE3) blob**, immutable and small → ideal swarm objects. +2. Publish a **signed Nostr event** advertising title + segment hashes (Blossom catalog). +3. Any node running IndeeHub resolves the content address and **streams from the nearest + node(s) that have it stored/cached** via iroh range streaming; MinIO/OVH is origin. +4. AES-128 key delivery + NIP-98 auth unchanged (keys gate decryption; swarm only moves + encrypted segments — so untrusted seeds can cache without seeing plaintext). + +## 6. Phasing (folds into the existing Phase 0–6 plan) + +0. **Signed manifests (required first, unbuilt).** `derive_release_root_ed25519` / + `derive_release_root_nostr` in `seed.rs` (HKDF `archipelago/release/root/ed25519/v1`, + `.../secp256k1/v1`); `core/src/trust/` (anchor/bundle/manifest/timestamp/nostr); JCS + canonical JSON; ceremony scripts; `manifest-v2.json` with signature. Gives *authenticity*, + which content-addressing does not. +1. **BLAKE3 alongside SHA-256** in the manifest + `blobs.rs`. +2. **iroh-blobs PoC** behind a feature flag: serve OTA blobs from the swarm with HTTP + fallback; measure on a scratch/test node, then the fleet. +3. **Signed Nostr advertisement events** for releases (publisher identity + provider lists). +4. **IndeeHub on the same blob layer** (Blossom catalog + iroh swarm; MinIO origin). + +This collapses the old "Phase 4: build S/Kademlia from scratch" into "adopt iroh," a large +de-risking. + +## 7. Open decisions + +- **BLAKE3 migration scope:** dual-hash window length; whether to re-hash historical + releases or only BLAKE3 going forward. +- **iroh ↔ existing transports:** iroh brings its own QUIC + hole-punching + relays; decide + how it coexists with FIPS/Tor (run iroh standalone first; integrate with `TransportRouter` + later if useful). +- **Seed retention policy:** how long nodes keep blobs to seed others (disk pressure on small + nodes); pinning rules for IndeeHub films vs. transient OTA blobs. +- **Privacy:** iroh dial-by-key vs. Tor's anonymity; default transport per content type. + +## References +- iroh: https://github.com/n0-computer/iroh · iroh-blobs: https://github.com/n0-computer/iroh-blobs · docs: https://docs.iroh.computer/protocols/blobs +- Blossom: https://github.com/hzrd149/blossom · NIP-B7: https://nips.nostr.com/B7 · nostr-blossom (Rust): https://docs.rs/nostr-blossom +- Web5/DWN (rejected): https://github.com/decentralized-identity/web5-js · https://identity.foundation/decentralized-web-node/spec/ · https://block.xyz/inside/block-contributes-digital-identity-components-to-the-decentralized-identity-foundation From b037a121d0a9abc68bc499755a1c935cffac93c5 Mon Sep 17 00:00:00 2001 From: archipelago Date: Tue, 16 Jun 2026 11:19:00 -0400 Subject: [PATCH 02/14] docs(changelog): curate v1.7.98-alpha notes --- CHANGELOG.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b52619a1..72d5655d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## v1.7.98-alpha (2026-06-16) + +- Apps that crash now recover on their own. Multi-part apps like Immich and IndeedHub could have one of their pieces stop and stay stopped until the whole node was rebooted; the node now checks every couple of minutes and restarts any crashed piece automatically (while still leaving apps you deliberately stopped alone). +- The on-screen kiosk display can no longer slow the whole node down. On machines without a graphics chip the kiosk browser could spin a CPU core at full tilt, starving everything else (including the wallet, which then timed out); it's now capped and uses lighter rendering on those machines. +- If an update download fails, you're taken back to the Download button to retry, instead of being stranded on an Install button for an update that didn't actually finish downloading. +- Your node's identity is clearer and always visible: Settings now shows your Node DID on every node (it previously only appeared if your browser had cached it) plus your node's npub, both with copy buttons. There's also a terminal tool to cryptographically prove all your node's keys come from your one seed phrase. +- The "all nodes over Tor" group chat sends quickly now — the "sending" spinner clears as soon as the reachable nodes have the message, instead of hanging on a slow or offline node. +- Message notifications now have a close button and open the relevant chat when tapped. +- The encrypted mesh transport (FIPS) turns itself on automatically after setup — no button to press — and connects to peers more reliably (it retries and keeps connections warm), so node-to-node features use the fast path more often instead of falling back to Tor. +- Your chat history with other nodes is saved reliably and now encrypted on disk, so it survives restarts and updates and can't be read from a stolen drive (only clearing chat removes it). +- Peer media shows a "connecting" loader before a video or audio file plays, and audio errors are accurate instead of blaming File Browser. +- The Fedimint app now displays with its proper styling, and the Connected Nodes screen stays compact — it shows a few nodes and scrolls, you can tap a node to jump to it in Federation, or tap Message to open its chat. +- App updates can now arrive on their own without waiting for a full system release, so individual apps can be improved and shipped faster. + ## v1.7.97-alpha (2026-06-16) - The Bitcoin sync status on the home screen no longer disappears for a moment when it refreshes. If the node was briefly busy, the panel used to vanish and pop back; it now stays put and simply shows "Updating…" until the next reading arrives, while a genuinely stopped node still correctly shows as not running. From ee46a856de70670b39f9f5c092db75bf5962838f Mon Sep 17 00:00:00 2001 From: archipelago Date: Tue, 16 Jun 2026 11:19:08 -0400 Subject: [PATCH 03/14] docs(whats-new): sync v1.7.98-alpha block --- .../src/views/settings/AccountInfoSection.vue | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/neode-ui/src/views/settings/AccountInfoSection.vue b/neode-ui/src/views/settings/AccountInfoSection.vue index 9e55510d..c2f5a81a 100644 --- a/neode-ui/src/views/settings/AccountInfoSection.vue +++ b/neode-ui/src/views/settings/AccountInfoSection.vue @@ -228,6 +228,26 @@ init()
+ +
+
+ v1.7.98-alpha + June 16, 2026 +
+
+

Apps that crash now recover on their own. Multi-part apps like Immich and IndeedHub could have one of their pieces stop and stay stopped until the whole node was rebooted; the node now checks every couple of minutes and restarts any crashed piece automatically (while still leaving apps you deliberately stopped alone).

+

The on-screen kiosk display can no longer slow the whole node down. On machines without a graphics chip the kiosk browser could spin a CPU core at full tilt, starving everything else (including the wallet, which then timed out); it's now capped and uses lighter rendering on those machines.

+

If an update download fails, you're taken back to the Download button to retry, instead of being stranded on an Install button for an update that didn't actually finish downloading.

+

Your node's identity is clearer and always visible: Settings now shows your Node DID on every node (it previously only appeared if your browser had cached it) plus your node's npub, both with copy buttons. There's also a terminal tool to cryptographically prove all your node's keys come from your one seed phrase.

+

The "all nodes over Tor" group chat sends quickly now — the "sending" spinner clears as soon as the reachable nodes have the message, instead of hanging on a slow or offline node.

+

Message notifications now have a close button and open the relevant chat when tapped.

+

The encrypted mesh transport (FIPS) turns itself on automatically after setup — no button to press — and connects to peers more reliably (it retries and keeps connections warm), so node-to-node features use the fast path more often instead of falling back to Tor.

+

Your chat history with other nodes is saved reliably and now encrypted on disk, so it survives restarts and updates and can't be read from a stolen drive (only clearing chat removes it).

+

Peer media shows a "connecting" loader before a video or audio file plays, and audio errors are accurate instead of blaming File Browser.

+

The Fedimint app now displays with its proper styling, and the Connected Nodes screen stays compact — it shows a few nodes and scrolls, you can tap a node to jump to it in Federation, or tap Message to open its chat.

+

App updates can now arrive on their own without waiting for a full system release, so individual apps can be improved and shipped faster.

+
+
From 0fef8086718914987e6b4b5b5e5b4c1ebd36dfd1 Mon Sep 17 00:00:00 2001 From: archipelago Date: Tue, 16 Jun 2026 11:22:24 -0400 Subject: [PATCH 04/14] wip(trust): park agent's signed-manifest module + release-root key off main Moved here so main stays clean for the v1.7.98 release. Contains the trust/ module (canonical.rs, did.rs, signed_doc.rs) + seed::derive_release_root_ed25519. Not wired into the build yet. Continue this work on this branch. --- core/archipelago/src/seed.rs | 54 +++++++ core/archipelago/src/trust/anchor.rs | 71 +++++++++ core/archipelago/src/trust/canonical.rs | 87 +++++++++++ core/archipelago/src/trust/did.rs | 56 +++++++ core/archipelago/src/trust/mod.rs | 23 +++ core/archipelago/src/trust/signed_doc.rs | 191 +++++++++++++++++++++++ 6 files changed, 482 insertions(+) create mode 100644 core/archipelago/src/trust/anchor.rs create mode 100644 core/archipelago/src/trust/canonical.rs create mode 100644 core/archipelago/src/trust/did.rs create mode 100644 core/archipelago/src/trust/mod.rs create mode 100644 core/archipelago/src/trust/signed_doc.rs diff --git a/core/archipelago/src/seed.rs b/core/archipelago/src/seed.rs index ffe9532a..7b3def15 100644 --- a/core/archipelago/src/seed.rs +++ b/core/archipelago/src/seed.rs @@ -8,6 +8,8 @@ //! ├── HKDF(seed, "archipelago/node/ed25519/v1") → Node Ed25519 → did:key //! ├── HKDF(seed, "archipelago/nostr-node/secp256k1/v1") → Node Nostr key //! ├── HKDF(seed, "archipelago/fips/secp256k1/v1") → FIPS mesh transport key +//! ├── HKDF(seed, "archipelago/release/root/ed25519/v1") → Release-root signing key +//! │ (publisher-only; nodes pin the PUBLIC key — see trust::anchor) //! ├── HKDF(seed, "archipelago/identity/{i}/ed25519/v1") → Identity i Ed25519 //! ├── BIP-32 m/44'/1237'/0'/0/{i} → Identity i Nostr (NIP-06) //! ├── BIP-32 m/84'/0'/0' → Bitcoin Core wallet @@ -34,6 +36,7 @@ const NODE_ED25519_INFO: &[u8] = b"archipelago/node/ed25519/v1"; const NODE_NOSTR_INFO: &[u8] = b"archipelago/nostr-node/secp256k1/v1"; const FIPS_KEY_INFO: &[u8] = b"archipelago/fips/secp256k1/v1"; const LND_ENTROPY_INFO: &[u8] = b"archipelago/lnd/entropy/v1"; +const RELEASE_ROOT_ED25519_INFO: &[u8] = b"archipelago/release/root/ed25519/v1"; // ─── MasterSeed ───────────────────────────────────────────────────────── @@ -88,6 +91,21 @@ pub fn derive_node_ed25519(seed: &MasterSeed) -> Result { Ok(SigningKey::from_bytes(&derived)) } +/// Derive the fleet **release-root** Ed25519 signing key. +/// +/// This is a *publisher-side* derivation: only the holder of the release master +/// seed runs it (e.g. in the signing ceremony). Fleet nodes never derive this — +/// they pin the corresponding PUBLIC key as a trust anchor (see +/// `crate::trust::anchor`) and use it to verify signed manifests/catalogs. +/// +/// Keeping it seed-derived means the signing key is reproducible from a +/// backed-up mnemonic (disaster recovery) rather than a loose key file, and it +/// is domain-separated from every node/identity key by its HKDF info string. +pub fn derive_release_root_ed25519(seed: &MasterSeed) -> Result { + let derived = hkdf_derive_32(seed.as_bytes(), RELEASE_ROOT_ED25519_INFO)?; + Ok(SigningKey::from_bytes(&derived)) +} + /// Derive an identity's Ed25519 signing key by index. pub fn derive_identity_ed25519(seed: &MasterSeed, index: u32) -> Result { let info = format!("archipelago/identity/{}/ed25519/v1", index); @@ -561,4 +579,40 @@ mod tests { ); } + #[test] + fn test_release_root_deterministic_and_domain_separated() { + let (_, seed) = MasterSeed::from_mnemonic_words(TEST_MNEMONIC).unwrap(); + let a = derive_release_root_ed25519(&seed).unwrap(); + let b = derive_release_root_ed25519(&seed).unwrap(); + assert_eq!( + a.verifying_key().as_bytes(), + b.verifying_key().as_bytes(), + "Same mnemonic must produce the same release-root key" + ); + // Must NOT collide with the node key — different HKDF domain. + let node = derive_node_ed25519(&seed).unwrap(); + assert_ne!( + a.verifying_key().as_bytes(), + node.verifying_key().as_bytes(), + "Release-root key must be domain-separated from the node key" + ); + } + + #[test] + fn test_release_root_known_answer() { + // KAT pins the derivation so the signing ceremony, the pinned anchor, + // and any external verifier agree on the bytes for a given mnemonic. + let (_, seed) = MasterSeed::from_mnemonic_words(TEST_MNEMONIC).unwrap(); + let key = derive_release_root_ed25519(&seed).unwrap(); + assert_eq!( + hex::encode(key.to_bytes()), + "__RELEASE_ROOT_PRIV_HEX__", + "release-root private key KAT" + ); + assert_eq!( + hex::encode(key.verifying_key().to_bytes()), + "__RELEASE_ROOT_PUB_HEX__", + "release-root public key KAT" + ); + } } diff --git a/core/archipelago/src/trust/anchor.rs b/core/archipelago/src/trust/anchor.rs new file mode 100644 index 00000000..933520c0 --- /dev/null +++ b/core/archipelago/src/trust/anchor.rs @@ -0,0 +1,71 @@ +//! The fleet's pinned **release-root** trust anchor. +//! +//! Every node ships the release-root *public* key. Signed manifests and the app +//! catalog must be signed by the corresponding private key (derived once, in +//! the signing ceremony, via `seed::derive_release_root_ed25519`). Pinning the +//! key in the binary is what makes a swapped-in mirror key detectable. +//! +//! Until the ceremony runs against the real release master seed, the pinned +//! constant is `None`. While `None`, signature verification still runs and +//! still rejects tampered documents, but it cannot enforce signer *identity* +//! (see `signed_doc::SignatureStatus::anchored`). Set +//! `ARCHY_RELEASE_ROOT_PUBKEY` (64-char hex) to pin a key at runtime for +//! staging/test fleets before the constant is baked in. + +use ed25519_dalek::VerifyingKey; + +/// Hex of the pinned Ed25519 release-root public key (32 bytes / 64 hex chars). +/// +/// TODO(dht Phase 0): bake the real value here after the signing ceremony. +/// Generate it with: `scripts/release-root-ceremony.sh pubkey`. +pub const RELEASE_ROOT_PUBKEY_HEX: Option<&str> = None; + +const ENV_OVERRIDE: &str = "ARCHY_RELEASE_ROOT_PUBKEY"; + +/// Resolve the pinned release-root public key, if any. +/// +/// Runtime env override wins over the baked-in constant so a test fleet can pin +/// a ceremony key without a rebuild. Malformed values are ignored (treated as +/// "not pinned") rather than crashing the node. +pub fn release_root_pubkey() -> Option { + if let Ok(hex_str) = std::env::var(ENV_OVERRIDE) { + if let Some(key) = parse_pubkey_hex(hex_str.trim()) { + return Some(key); + } + tracing::warn!( + "{} is set but not a valid 32-byte hex Ed25519 key; ignoring", + ENV_OVERRIDE + ); + } + RELEASE_ROOT_PUBKEY_HEX.and_then(parse_pubkey_hex) +} + +fn parse_pubkey_hex(s: &str) -> Option { + let bytes = hex::decode(s).ok()?; + let arr: [u8; 32] = bytes.as_slice().try_into().ok()?; + VerifyingKey::from_bytes(&arr).ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unset_constant_is_none() { + // Default build ships no pinned anchor yet. + assert!(RELEASE_ROOT_PUBKEY_HEX.is_none()); + } + + #[test] + fn parses_valid_hex() { + let key = ed25519_dalek::SigningKey::from_bytes(&[9u8; 32]).verifying_key(); + let parsed = parse_pubkey_hex(&hex::encode(key.to_bytes())).unwrap(); + assert_eq!(parsed.as_bytes(), key.as_bytes()); + } + + #[test] + fn rejects_malformed_hex() { + assert!(parse_pubkey_hex("nothex").is_none()); + assert!(parse_pubkey_hex("abcd").is_none()); + } +} diff --git a/core/archipelago/src/trust/canonical.rs b/core/archipelago/src/trust/canonical.rs new file mode 100644 index 00000000..80f6c0ef --- /dev/null +++ b/core/archipelago/src/trust/canonical.rs @@ -0,0 +1,87 @@ +//! Canonical JSON for signing — a pragmatic subset of RFC 8785 (JCS). +//! +//! Signatures are computed over a *byte-exact* serialization so that a verifier +//! reproduces the same preimage the signer hashed. We guarantee: +//! +//! * object keys recursively sorted (lexicographic by Rust `str` ordering, +//! i.e. Unicode scalar value — matches JCS for the ASCII keys we use), +//! * no insignificant whitespace, +//! * arrays preserved in order. +//! +//! We do NOT implement JCS number canonicalization (ECMAScript shortest-form). +//! Archipelago manifests/catalogs carry only integers, strings, bools, arrays +//! and objects, for which `serde_json`'s output is already unambiguous. If a +//! float ever enters a signed document this must be hardened (or rejected). +//! `contains_float()` lets callers enforce that invariant. + +use serde_json::Value; + +/// Serialize `value` to canonical JSON bytes (sorted keys, compact). +/// +/// Rebuilds every object through a `BTreeMap` so the result is independent of +/// the `serde_json/preserve_order` feature being toggled on anywhere in the +/// dependency graph. +pub fn to_canonical_bytes(value: &Value) -> Vec { + let canonical = canonicalize(value); + // serde_json never fails to serialize a Value it produced. + serde_json::to_vec(&canonical).expect("canonical JSON serialization") +} + +/// Reject documents that contain a float anywhere — they are not safely +/// canonicalizable under this implementation. +pub fn contains_float(value: &Value) -> bool { + match value { + Value::Number(n) => n.as_i64().is_none() && n.as_u64().is_none(), + Value::Array(items) => items.iter().any(contains_float), + Value::Object(map) => map.values().any(contains_float), + _ => false, + } +} + +fn canonicalize(value: &Value) -> Value { + match value { + Value::Object(map) => { + // BTreeMap gives deterministic key ordering on serialize. + let sorted: std::collections::BTreeMap = map + .iter() + .map(|(k, v)| (k.clone(), canonicalize(v))) + .collect(); + serde_json::to_value(sorted).expect("canonical object") + } + Value::Array(items) => Value::Array(items.iter().map(canonicalize).collect()), + other => other.clone(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn key_order_does_not_change_bytes() { + let a = json!({"b": 1, "a": 2, "c": {"z": 1, "y": 2}}); + let b = json!({"c": {"y": 2, "z": 1}, "a": 2, "b": 1}); + assert_eq!(to_canonical_bytes(&a), to_canonical_bytes(&b)); + } + + #[test] + fn output_is_sorted_and_compact() { + let v = json!({"b": 1, "a": [3, 2, 1]}); + assert_eq!(to_canonical_bytes(&v), br#"{"a":[3,2,1],"b":1}"#.to_vec()); + } + + #[test] + fn array_order_is_preserved() { + let a = json!([1, 2, 3]); + let b = json!([3, 2, 1]); + assert_ne!(to_canonical_bytes(&a), to_canonical_bytes(&b)); + } + + #[test] + fn detects_floats() { + assert!(contains_float(&json!({"x": 1.5}))); + assert!(contains_float(&json!([1, 2, 0.1]))); + assert!(!contains_float(&json!({"x": 12345, "y": "s", "z": [1, 2]}))); + } +} diff --git a/core/archipelago/src/trust/did.rs b/core/archipelago/src/trust/did.rs new file mode 100644 index 00000000..a7e174a1 --- /dev/null +++ b/core/archipelago/src/trust/did.rs @@ -0,0 +1,56 @@ +//! `did:key` <-> Ed25519 public key, mirroring the encoding already used by +//! `identity_manager` so release-root DIDs are interchangeable with node DIDs. +//! +//! Format: `did:key:z` +//! (`0xed01` is the multicodec varint prefix for an Ed25519 public key.) + +use anyhow::{anyhow, Context, Result}; +use ed25519_dalek::VerifyingKey; + +const ED25519_MULTICODEC: [u8; 2] = [0xed, 0x01]; + +/// Encode an Ed25519 public key as a `did:key` string. +pub fn did_key_for_ed25519(key: &VerifyingKey) -> String { + let mut bytes = Vec::with_capacity(34); + bytes.extend_from_slice(&ED25519_MULTICODEC); + bytes.extend_from_slice(key.as_bytes()); + format!("did:key:z{}", bs58::encode(bytes).into_string()) +} + +/// Decode a `did:key` string into an Ed25519 verifying key. +pub fn ed25519_pubkey_from_did_key(did: &str) -> Result { + let z_part = did + .strip_prefix("did:key:z") + .ok_or_else(|| anyhow!("invalid did:key format: {}", did))?; + let decoded = bs58::decode(z_part) + .into_vec() + .context("invalid base58 in did:key")?; + if decoded.len() != 34 || decoded[0..2] != ED25519_MULTICODEC { + return Err(anyhow!("not an Ed25519 did:key (bad multicodec prefix)")); + } + let arr: [u8; 32] = decoded[2..] + .try_into() + .expect("length checked above"); + VerifyingKey::from_bytes(&arr).context("invalid Ed25519 public key in did:key") +} + +#[cfg(test)] +mod tests { + use super::*; + use ed25519_dalek::SigningKey; + + #[test] + fn roundtrip() { + let key = SigningKey::from_bytes(&[3u8; 32]).verifying_key(); + let did = did_key_for_ed25519(&key); + assert!(did.starts_with("did:key:z6Mk"), "got {}", did); + let back = ed25519_pubkey_from_did_key(&did).unwrap(); + assert_eq!(key.as_bytes(), back.as_bytes()); + } + + #[test] + fn rejects_non_ed25519() { + assert!(ed25519_pubkey_from_did_key("did:key:zQ3shazz").is_err()); + assert!(ed25519_pubkey_from_did_key("not-a-did").is_err()); + } +} diff --git a/core/archipelago/src/trust/mod.rs b/core/archipelago/src/trust/mod.rs new file mode 100644 index 00000000..0bf92b41 --- /dev/null +++ b/core/archipelago/src/trust/mod.rs @@ -0,0 +1,23 @@ +//! Authenticity layer for the DHT distribution plan (Phase 0). +//! +//! Content addressing (SHA-256 today, BLAKE3 later) proves downloaded bytes are +//! *intact*. It does not prove they were *authorized*. This module adds the +//! missing half: detached Ed25519 signatures over canonical JSON, verified +//! against a pinned **release-root** trust anchor. +//! +//! Layout: +//! * [`anchor`] — the pinned release-root public key (+ env override). +//! * [`canonical`] — deterministic JSON serialization for signing. +//! * [`did`] — `did:key` <-> Ed25519 public key. +//! * [`signed_doc`]— detached sign/verify over a signed document. +//! +//! The release-root *private* key is publisher-only and derived in the signing +//! ceremony via [`crate::seed::derive_release_root_ed25519`]; fleet nodes only +//! ever hold the public key. + +pub mod anchor; +pub mod canonical; +pub mod did; +pub mod signed_doc; + +pub use signed_doc::{verify_detached, SignatureStatus}; diff --git a/core/archipelago/src/trust/signed_doc.rs b/core/archipelago/src/trust/signed_doc.rs new file mode 100644 index 00000000..07e1bc88 --- /dev/null +++ b/core/archipelago/src/trust/signed_doc.rs @@ -0,0 +1,191 @@ +//! Detached Ed25519 signatures over canonical JSON documents. +//! +//! A *signed document* is any JSON object carrying two reserved top-level +//! fields: +//! +//! * `signed_by` — the signer's `did:key` (Ed25519), e.g. the release-root. +//! * `signature` — hex-encoded Ed25519 signature over the canonical JSON of +//! the document with **both** reserved fields removed. +//! +//! Removing the fields before canonicalizing makes the signature *detached*: +//! the signer signs the payload, then attaches the proof, without a +//! chicken-and-egg dependency on the signature's own bytes. +//! +//! Authenticity ≠ integrity. Content addressing (SHA-256/BLAKE3 in the +//! manifest) proves the bytes are intact; this signature proves *we authorized +//! them*. The DHT plan requires both. + +use anyhow::{anyhow, bail, Context, Result}; +use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey}; +use serde_json::Value; + +use super::anchor; +use super::canonical; +use super::did; + +pub const SIGNATURE_FIELD: &str = "signature"; +pub const SIGNED_BY_FIELD: &str = "signed_by"; + +/// Outcome of inspecting a document for a detached signature. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SignatureStatus { + /// No `signature` field present. Caller decides whether to accept + /// (during the migration window we still accept unsigned documents). + Unsigned, + /// Signature verified. `anchored` is true when `signed_by` matched the + /// pinned release-root anchor (full authenticity); false means the + /// signature is internally consistent but the signer key is not yet + /// pinned, so it only proves the document wasn't tampered relative to its + /// own claimed key. + Verified { signer_did: String, anchored: bool }, +} + +/// Verify a document's detached signature *if present*. +/// +/// Returns `Ok(Unsigned)` when there is no signature. Returns `Ok(Verified)` +/// when a present signature checks out. Returns `Err` when a signature is +/// present but malformed, fails verification, or names a signer that +/// contradicts the pinned anchor — callers MUST reject the document on `Err`. +pub fn verify_detached(doc: &Value) -> Result { + let obj = doc + .as_object() + .ok_or_else(|| anyhow!("signed document must be a JSON object"))?; + + let signature_hex = match obj.get(SIGNATURE_FIELD) { + None | Some(Value::Null) => return Ok(SignatureStatus::Unsigned), + Some(Value::String(s)) => s, + Some(_) => bail!("`{}` must be a string", SIGNATURE_FIELD), + }; + + let signed_by = obj + .get(SIGNED_BY_FIELD) + .and_then(Value::as_str) + .ok_or_else(|| anyhow!("signed document has `{}` but no `{}`", SIGNATURE_FIELD, SIGNED_BY_FIELD))?; + + let signer = did::ed25519_pubkey_from_did_key(signed_by) + .with_context(|| format!("invalid `{}` did:key", SIGNED_BY_FIELD))?; + + // If the fleet has a pinned release-root, the signer MUST be it. This is + // what stops a mirror from swapping in its own keypair and re-signing. + let anchored = match anchor::release_root_pubkey() { + Some(pinned) => { + if pinned != signer { + bail!("signed_by does not match the pinned release-root anchor"); + } + true + } + None => false, + }; + + let signature = parse_signature_hex(signature_hex)?; + let preimage = signing_preimage(obj)?; + signer + .verify_strict(&preimage, &signature) + .map_err(|_| anyhow!("release-root signature verification failed"))?; + + Ok(SignatureStatus::Verified { + signer_did: signed_by.to_string(), + anchored, + }) +} + +/// Produce a detached signature for `payload` (the document WITHOUT the +/// reserved fields). Used by the signing ceremony and round-trip tests. +/// Returns `(signature_hex, signed_by_did)`. +pub fn sign_detached(key: &SigningKey, payload: &Value) -> Result<(String, String)> { + let obj = payload + .as_object() + .ok_or_else(|| anyhow!("payload must be a JSON object"))?; + if obj.contains_key(SIGNATURE_FIELD) || obj.contains_key(SIGNED_BY_FIELD) { + bail!("payload must not already contain reserved signature fields"); + } + let preimage = signing_preimage(obj)?; + let signature = key.sign(&preimage); + let did = did::did_key_for_ed25519(&key.verifying_key()); + Ok((hex::encode(signature.to_bytes()), did)) +} + +/// Canonical bytes the signature covers: the object minus the reserved fields. +fn signing_preimage(obj: &serde_json::Map) -> Result> { + let mut payload = obj.clone(); + payload.remove(SIGNATURE_FIELD); + payload.remove(SIGNED_BY_FIELD); + let value = Value::Object(payload); + if canonical::contains_float(&value) { + bail!("signed documents must not contain floating-point numbers"); + } + Ok(canonical::to_canonical_bytes(&value)) +} + +fn parse_signature_hex(s: &str) -> Result { + let bytes = hex::decode(s).context("signature is not valid hex")?; + let arr: [u8; 64] = bytes + .as_slice() + .try_into() + .map_err(|_| anyhow!("signature must be 64 bytes, got {}", bytes.len()))?; + Ok(Signature::from_bytes(&arr)) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn test_key() -> SigningKey { + SigningKey::from_bytes(&[7u8; 32]) + } + + fn sign_into(key: &SigningKey, mut doc: Value) -> Value { + let (sig, did) = sign_detached(key, &doc).unwrap(); + let obj = doc.as_object_mut().unwrap(); + obj.insert(SIGNED_BY_FIELD.into(), json!(did)); + obj.insert(SIGNATURE_FIELD.into(), json!(sig)); + doc + } + + #[test] + fn unsigned_document_reports_unsigned() { + let doc = json!({"schema": 1, "apps": {}}); + assert_eq!(verify_detached(&doc).unwrap(), SignatureStatus::Unsigned); + } + + #[test] + fn roundtrip_verifies() { + let signed = sign_into(&test_key(), json!({"schema": 1, "n": 42})); + match verify_detached(&signed).unwrap() { + // No anchor pinned in the default test build → anchored == false. + SignatureStatus::Verified { anchored, .. } => assert!(!anchored), + other => panic!("expected Verified, got {:?}", other), + } + } + + #[test] + fn signature_survives_key_reordering() { + // Re-emitting the document with shuffled keys must not break the sig. + let signed = sign_into(&test_key(), json!({"b": 2, "a": 1})); + let reparsed: Value = + serde_json::from_str(&serde_json::to_string(&signed).unwrap()).unwrap(); + assert!(matches!( + verify_detached(&reparsed).unwrap(), + SignatureStatus::Verified { .. } + )); + } + + #[test] + fn tampered_payload_is_rejected() { + let mut signed = sign_into(&test_key(), json!({"schema": 1, "n": 42})); + signed.as_object_mut().unwrap().insert("n".into(), json!(43)); + assert!(verify_detached(&signed).is_err()); + } + + #[test] + fn missing_signed_by_is_rejected() { + let doc = json!({"schema": 1, "signature": "00"}); + assert!(verify_detached(&doc).is_err()); + } + + #[test] + fn float_payload_cannot_be_signed() { + assert!(sign_detached(&test_key(), &json!({"x": 1.5})).is_err()); + } +} From 27f11bf85a4dfe8117ccbdf795b8aa8122aab3a8 Mon Sep 17 00:00:00 2001 From: archipelago Date: Tue, 16 Jun 2026 12:40:57 -0400 Subject: [PATCH 05/14] feat(trust): wire Phase 0 signed-catalog verification + pin release-root KAT Completes the parked trust module and wires it into the live build: - main.rs: register `mod trust` - app_catalog::fetch_one: verify the release-root detached signature when present (verify against raw JSON so forward-compat fields stay in the signed preimage); accept unsigned during the migration window, hard-reject a present-but-bad signature so a tampering mirror can't pass altered bytes - seed: pin release-root Ed25519 known-answer test (priv+pub) for the signing ceremony / pinned-anchor / external-verifier cross-check - signed_doc: drop unused import 20/20 Phase 0 unit tests pass (trust::canonical/did/signed_doc/anchor, seed release-root, app_catalog). Crate compiles clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- core/archipelago/src/container/app_catalog.rs | 29 +++++++++++++++++-- core/archipelago/src/main.rs | 1 + core/archipelago/src/seed.rs | 4 +-- core/archipelago/src/trust/signed_doc.rs | 2 +- 4 files changed, 30 insertions(+), 6 deletions(-) diff --git a/core/archipelago/src/container/app_catalog.rs b/core/archipelago/src/container/app_catalog.rs index 9253070e..912c3b8f 100644 --- a/core/archipelago/src/container/app_catalog.rs +++ b/core/archipelago/src/container/app_catalog.rs @@ -268,9 +268,32 @@ async fn fetch_one(client: &reqwest::Client, url: &str) -> anyhow::Result { + debug!("app-catalog: unsigned (accepted during migration window)"); + } + crate::trust::SignatureStatus::Verified { signer_did, anchored } => { + if anchored { + info!("app-catalog: release-root signature verified ({})", signer_did); + } else { + warn!( + "app-catalog: signature self-consistent but release-root anchor \ + not pinned ({}); cannot confirm signer identity", + signer_did + ); + } + } + } + Ok(catalog) } diff --git a/core/archipelago/src/main.rs b/core/archipelago/src/main.rs index 0850a813..be620245 100644 --- a/core/archipelago/src/main.rs +++ b/core/archipelago/src/main.rs @@ -68,6 +68,7 @@ mod storage_crypto; mod streaming; mod totp; mod transport; +mod trust; mod update; mod vpn; mod wallet; diff --git a/core/archipelago/src/seed.rs b/core/archipelago/src/seed.rs index 7b3def15..96cc6cbe 100644 --- a/core/archipelago/src/seed.rs +++ b/core/archipelago/src/seed.rs @@ -606,12 +606,12 @@ mod tests { let key = derive_release_root_ed25519(&seed).unwrap(); assert_eq!( hex::encode(key.to_bytes()), - "__RELEASE_ROOT_PRIV_HEX__", + "613ab879e5fbd4fcded32bc7ffad662fff1ce0f744c69baa63e7416ffabe7b71", "release-root private key KAT" ); assert_eq!( hex::encode(key.verifying_key().to_bytes()), - "__RELEASE_ROOT_PUB_HEX__", + "995eaf9188617f0ecbcff9cd44d57adb9aa7dd5f34db2733e97f3e317fb0aba2", "release-root public key KAT" ); } diff --git a/core/archipelago/src/trust/signed_doc.rs b/core/archipelago/src/trust/signed_doc.rs index 07e1bc88..adb4d70a 100644 --- a/core/archipelago/src/trust/signed_doc.rs +++ b/core/archipelago/src/trust/signed_doc.rs @@ -16,7 +16,7 @@ //! them*. The DHT plan requires both. use anyhow::{anyhow, bail, Context, Result}; -use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey}; +use ed25519_dalek::{Signature, Signer, SigningKey}; use serde_json::Value; use super::anchor; From f0cb91ed76975f383444f985a45eeee548c3ba69 Mon Sep 17 00:00:00 2001 From: archipelago Date: Tue, 16 Jun 2026 13:05:27 -0400 Subject: [PATCH 06/14] =?UTF-8?q?feat(dht):=20Phase=201=20=E2=80=94=20BLAK?= =?UTF-8?q?E3=20content=20addressing=20alongside=20SHA-256?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the iroh-native, range-verifiable hash next to the incumbent SHA-256 so the swarm can later fetch/verify by BLAKE3 with the registry/origin as fallback. Non-breaking: SHA-256 stays the mandatory gate; BLAKE3 is verified only when present. - content_hash.rs: HashAlg + ContentDigest (parse/verify ':' multihash strings), blake3_hex/sha256_hex; BLAKE3 known-answer test - update.rs: ComponentUpdate.blake3 (serde-default); verified ALONGSIDE SHA-256 in the resumable download loop, re-download on mismatch - blobs.rs: BlobMeta.blake3 computed on put (on-disk path stays SHA-256-keyed for back-compat; advertises the future swarm address) Drive-by: fix a pre-existing stale test (test_save_and_load_state_roundtrip) that never wrote the .download-complete marker #26 requires, so load_state's self-heal cleared update_in_progress. Unrelated to BLAKE3 — surfaced by running the full update:: suite. 40/40 content_hash/update/blobs tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- core/Cargo.lock | 56 ++++++++-- core/archipelago/Cargo.toml | 1 + core/archipelago/src/blobs.rs | 7 ++ core/archipelago/src/content_hash.rs | 149 +++++++++++++++++++++++++++ core/archipelago/src/main.rs | 1 + core/archipelago/src/update.rs | 34 ++++++ 6 files changed, 238 insertions(+), 10 deletions(-) create mode 100644 core/archipelago/src/content_hash.rs diff --git a/core/Cargo.lock b/core/Cargo.lock index b1d65f8f..96170111 100644 --- a/core/Cargo.lock +++ b/core/Cargo.lock @@ -26,7 +26,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -92,6 +92,7 @@ dependencies = [ "bcrypt", "bip39", "bitcoin", + "blake3", "bs58", "bytes", "chacha20poly1305", @@ -202,10 +203,16 @@ checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" dependencies = [ "base64ct", "blake2", - "cpufeatures", + "cpufeatures 0.2.17", "password-hash", ] +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + [[package]] name = "arrayvec" version = "0.7.6" @@ -424,6 +431,20 @@ dependencies = [ "digest", ] +[[package]] +name = "blake3" +version = "1.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq 0.4.2", + "cpufeatures 0.3.0", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -524,7 +545,7 @@ checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -604,6 +625,12 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + [[package]] name = "core-foundation" version = "0.9.4" @@ -629,6 +656,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc" version = "3.4.0" @@ -686,7 +722,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "curve25519-dalek-derive", "digest", "fiat-crypto", @@ -1921,7 +1957,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" dependencies = [ - "cpufeatures", + "cpufeatures 0.2.17", "opaque-debug", "universal-hash", ] @@ -1933,7 +1969,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "opaque-debug", "universal-hash", ] @@ -2478,7 +2514,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f5058ada175748e33390e40e872bd0fe59a19f265d0158daa551c5a88a76009c" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -2489,7 +2525,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -2506,7 +2542,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -2972,7 +3008,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f124352108f58ef88299e909f6e9470f1cdc8d2a1397963901b4a6366206bf72" dependencies = [ "base32", - "constant_time_eq", + "constant_time_eq 0.3.1", "hmac", "rand 0.9.2", "sha1", diff --git a/core/archipelago/Cargo.toml b/core/archipelago/Cargo.toml index ee7e74f0..f8613089 100644 --- a/core/archipelago/Cargo.toml +++ b/core/archipelago/Cargo.toml @@ -42,6 +42,7 @@ archipelago-performance = { path = "../performance" } # Authentication bcrypt = "0.15" sha2 = "0.10.9" +blake3 = "1" hmac = "0.12.1" uuid = { version = "1.0", features = ["v4"] } regex = "1.10" diff --git a/core/archipelago/src/blobs.rs b/core/archipelago/src/blobs.rs index df17e993..1df52231 100644 --- a/core/archipelago/src/blobs.rs +++ b/core/archipelago/src/blobs.rs @@ -25,6 +25,12 @@ pub const MAX_BLOB_SIZE: u64 = 64 * 1024 * 1024; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BlobMeta { pub cid: String, + /// DHT Phase 1: BLAKE3 hash of the content (iroh-native swarm address). + /// The on-disk path stays SHA-256-keyed (`cid`) for back-compat; this + /// advertises the hash a peer swarm can fetch/range-verify by. Absent in + /// legacy metadata written before Phase 1. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub blake3: Option, pub size: u64, pub mime: String, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -88,6 +94,7 @@ impl BlobStore { let cid = hex::encode(hasher.finalize()); let meta = BlobMeta { cid: cid.clone(), + blake3: Some(crate::content_hash::blake3_hex(bytes)), size: bytes.len() as u64, mime: mime.to_string(), filename, diff --git a/core/archipelago/src/content_hash.rs b/core/archipelago/src/content_hash.rs new file mode 100644 index 00000000..69a4b8bb --- /dev/null +++ b/core/archipelago/src/content_hash.rs @@ -0,0 +1,149 @@ +//! Content hashing for the DHT distribution plan's *integrity & addressing* +//! tier (`docs/dht-distribution-design.md` §4). +//! +//! SHA-256 is the incumbent: it keys `blobs.rs` and verifies OTA components +//! today. BLAKE3 is introduced **alongside** it because iroh-blobs addresses +//! and *range-verifies* content by BLAKE3 — essential for resumable downloads +//! and HLS streaming. During the migration window both may be present; SHA-256 +//! stays mandatory and BLAKE3 is verified when supplied. +//! +//! Digests are written multihash-style as `":"`, e.g. +//! `"blake3:ab12…"` / `"sha256:cd34…"`, matching the app-catalog `digest` field. +//! Both algorithms emit 32-byte (64-hex-char) digests. + +use anyhow::{anyhow, bail, Context, Result}; +use sha2::{Digest, Sha256}; + +const DIGEST_LEN: usize = 32; + +/// Supported content-hash algorithms. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HashAlg { + Sha256, + Blake3, +} + +impl HashAlg { + pub fn as_str(self) -> &'static str { + match self { + HashAlg::Sha256 => "sha256", + HashAlg::Blake3 => "blake3", + } + } +} + +/// Hex-encoded SHA-256 of `bytes`. +pub fn sha256_hex(bytes: &[u8]) -> String { + hex::encode(Sha256::digest(bytes)) +} + +/// Hex-encoded BLAKE3 of `bytes`. +pub fn blake3_hex(bytes: &[u8]) -> String { + blake3::hash(bytes).to_hex().to_string() +} + +/// A parsed `":"` content digest. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ContentDigest { + pub alg: HashAlg, + /// Lowercase hex, validated to the algorithm's length. + pub hex: String, +} + +impl ContentDigest { + /// Parse a multihash-style `":"` string. + pub fn parse(s: &str) -> Result { + let (alg_part, hex_part) = s + .split_once(':') + .ok_or_else(|| anyhow!("digest must be ':', got: {}", s))?; + let alg = match alg_part { + "sha256" => HashAlg::Sha256, + "blake3" => HashAlg::Blake3, + other => bail!("unsupported hash algorithm: {}", other), + }; + let raw = hex::decode(hex_part).context("digest hex is invalid")?; + if raw.len() != DIGEST_LEN { + bail!( + "{} digest must be {} bytes, got {}", + alg.as_str(), + DIGEST_LEN, + raw.len() + ); + } + Ok(Self { + alg, + hex: hex_part.to_ascii_lowercase(), + }) + } + + /// Compute the digest of `bytes` under this digest's algorithm. + pub fn compute_hex(&self, bytes: &[u8]) -> String { + match self.alg { + HashAlg::Sha256 => sha256_hex(bytes), + HashAlg::Blake3 => blake3_hex(bytes), + } + } + + /// Verify `bytes` hash to this digest. Errors (does not panic) on mismatch. + pub fn verify(&self, bytes: &[u8]) -> Result<()> { + let actual = self.compute_hex(bytes); + if actual.eq_ignore_ascii_case(&self.hex) { + Ok(()) + } else { + bail!( + "{} mismatch: expected {}, got {}", + self.alg.as_str(), + self.hex, + actual + ) + } + } +} + +impl std::fmt::Display for ContentDigest { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}:{}", self.alg.as_str(), self.hex) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn digest_lengths_are_32_bytes() { + assert_eq!(sha256_hex(b"hi").len(), 64); + assert_eq!(blake3_hex(b"hi").len(), 64); + } + + #[test] + fn blake3_known_answer() { + // BLAKE3 of the empty input — RFC/reference vector. + assert_eq!( + blake3_hex(b""), + "af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262" + ); + } + + #[test] + fn parse_roundtrip() { + let d = ContentDigest::parse(&format!("blake3:{}", blake3_hex(b"x"))).unwrap(); + assert_eq!(d.alg, HashAlg::Blake3); + assert_eq!(d.to_string(), format!("blake3:{}", blake3_hex(b"x"))); + } + + #[test] + fn verify_accepts_and_rejects() { + let d = ContentDigest::parse(&format!("sha256:{}", sha256_hex(b"payload"))).unwrap(); + assert!(d.verify(b"payload").is_ok()); + assert!(d.verify(b"tampered").is_err()); + } + + #[test] + fn parse_rejects_bad_input() { + assert!(ContentDigest::parse("nocolon").is_err()); + assert!(ContentDigest::parse("md5:abcd").is_err()); + assert!(ContentDigest::parse("blake3:nothex").is_err()); + assert!(ContentDigest::parse("blake3:ab").is_err()); // too short + } +} diff --git a/core/archipelago/src/main.rs b/core/archipelago/src/main.rs index be620245..cbfdfdb5 100644 --- a/core/archipelago/src/main.rs +++ b/core/archipelago/src/main.rs @@ -36,6 +36,7 @@ mod bootstrap; mod config; mod constants; mod container; +mod content_hash; mod content_server; mod crash_recovery; mod credentials; diff --git a/core/archipelago/src/update.rs b/core/archipelago/src/update.rs index c1601123..0d88b8c0 100644 --- a/core/archipelago/src/update.rs +++ b/core/archipelago/src/update.rs @@ -263,6 +263,11 @@ pub struct ComponentUpdate { pub download_url: String, pub sha256: String, pub size_bytes: u64, + /// DHT Phase 1: BLAKE3 content address (bare hex or `"blake3:"`), the + /// iroh-native, range-verifiable hash. Optional during the migration + /// window — when present it is verified ALONGSIDE the mandatory SHA-256. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub blake3: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -997,6 +1002,25 @@ async fn download_component_resumable( .context("read staging file for hash check")?; let hash = hex::encode(Sha256::digest(&bytes)); if hash == component.sha256 { + // DHT Phase 1: if the manifest also pins a BLAKE3 digest, it must + // match too. SHA-256 stays the mandatory gate during migration; + // BLAKE3 is the hash the iroh swarm will fetch/verify by, so a + // present-but-wrong BLAKE3 means the bytes aren't swarm-consistent + // — treat it like a SHA mismatch and re-download. + if let Some(b3) = component.blake3.as_deref() { + let expected = b3.trim().strip_prefix("blake3:").unwrap_or(b3.trim()); + let actual = crate::content_hash::blake3_hex(&bytes); + if !actual.eq_ignore_ascii_case(expected) { + let _ = tokio::fs::remove_file(dest).await; + last_err = Some(anyhow::anyhow!( + "BLAKE3 mismatch for {}: expected {}, got {}", + component.name, + expected, + actual + )); + continue; + } + } return Ok(()); } // SHA mismatch — the file on disk is garbage. Nuke it and @@ -1679,6 +1703,7 @@ mod tests { download_url: "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.26-alpha/archipelago".into(), sha256: "x".into(), size_bytes: 1, + blake3: None, }, ComponentUpdate { name: "frontend".into(), @@ -1687,6 +1712,7 @@ mod tests { download_url: "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.26-alpha/frontend.tar.gz".into(), sha256: "y".into(), size_bytes: 2, + blake3: None, }, ], }; @@ -1886,6 +1912,13 @@ mod tests { tokio::fs::write(staging.join("archipelago"), b"staged") .await .unwrap(); + // A *complete* staged update carries the .download-complete marker; + // without it has_staged_update() reads the staging as partial and the + // load_state self-heal clears update_in_progress (see #26). This test + // simulates a complete staging, so write the marker. + tokio::fs::write(staging.join(STAGED_COMPLETE_MARKER), b"1") + .await + .unwrap(); let state = UpdateState { current_version: "1.0.0".to_string(), last_check: Some("2025-06-15T12:00:00Z".to_string()), @@ -1900,6 +1933,7 @@ mod tests { download_url: "https://example.com/binary".to_string(), sha256: "abc123".to_string(), size_bytes: 5000, + blake3: None, }], }), update_in_progress: true, From 2523c9e3dd160d4b36a2c2ed3aa13364ed421b86 Mon Sep 17 00:00:00 2001 From: archipelago Date: Tue, 16 Jun 2026 13:38:19 -0400 Subject: [PATCH 07/14] =?UTF-8?q?feat(dht):=20Phase=202=20=E2=80=94=20swar?= =?UTF-8?q?m-assist=20fetch=20seam,=20origin=20always=20wins?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lands the transport/swarm orchestration layer (the iroh engine attaches later, behind a flag). The seam is fully exercised today with the origin HTTP path; with no swarm providers registered the behaviour is byte-for-byte identical to before. - swarm/mod.rs: BlobProvider trait + fetch_content_addressed() — tries each provider in order, VERIFIES peer-sourced bytes against the content digest before accepting (untrusted seeds can't inject tampered bytes), falls back to the origin closure if none serve. Returns Swarm|Origin. - Cargo: iroh-swarm feature (off by default; heavy QUIC dep tree attaches here). providers() is empty until enabled → every fetch hits origin. - update.rs: components with a BLAKE3 digest route through the seam, using the existing resumable HTTP downloader as the origin fallback; a swarm hit is re-checked against the mandatory SHA-256 manifest gate (re-fetch from origin on any disagreement). Components without blake3 take the original path untouched. 44/44 swarm/update/content_hash/blobs tests pass (incl. swarm hit/miss, tampered-bytes-rejected→origin, fall-through ordering). Co-Authored-By: Claude Opus 4.8 (1M context) --- core/archipelago/Cargo.toml | 10 ++ core/archipelago/src/main.rs | 1 + core/archipelago/src/swarm/mod.rs | 228 ++++++++++++++++++++++++++++++ core/archipelago/src/update.rs | 44 +++++- 4 files changed, 282 insertions(+), 1 deletion(-) create mode 100644 core/archipelago/src/swarm/mod.rs diff --git a/core/archipelago/Cargo.toml b/core/archipelago/Cargo.toml index f8613089..4bf5e0b3 100644 --- a/core/archipelago/Cargo.toml +++ b/core/archipelago/Cargo.toml @@ -9,6 +9,16 @@ authors = ["Archipelago Team"] name = "archipelago" path = "src/main.rs" +[features] +default = [] +# DHT Phase 2: iroh-blobs peer swarm engine. OFF by default — it pulls a heavy +# QUIC dependency tree, so it ships behind a flag for PoC/measurement on a +# scratch node before any fleet rollout. With the flag off, swarm::providers() +# is empty and every fetch goes straight to the origin HTTP path (today's +# behaviour). Attach the optional iroh / iroh-blobs deps to this feature when +# wiring the IrohProvider. +iroh-swarm = [] + [dependencies] # Core dependencies tokio = { version = "1", features = ["full"] } diff --git a/core/archipelago/src/main.rs b/core/archipelago/src/main.rs index cbfdfdb5..20db5609 100644 --- a/core/archipelago/src/main.rs +++ b/core/archipelago/src/main.rs @@ -67,6 +67,7 @@ mod settings; mod state; mod storage_crypto; mod streaming; +mod swarm; mod totp; mod transport; mod trust; diff --git a/core/archipelago/src/swarm/mod.rs b/core/archipelago/src/swarm/mod.rs new file mode 100644 index 00000000..77f3ae8d --- /dev/null +++ b/core/archipelago/src/swarm/mod.rs @@ -0,0 +1,228 @@ +//! Swarm-assist content fetch — the *transport & swarm* tier of the DHT +//! distribution plan (`docs/dht-distribution-design.md` §4). +//! +//! ## Guiding principle: swarm-assist, origin ALWAYS wins +//! The peer swarm is an optimization layered *above* a proven HTTP path, never +//! in place of it. A node asks each available [`BlobProvider`] (e.g. an +//! iroh-blobs swarm) for content by its [`ContentDigest`]; the first peer that +//! serves bytes which **verify** against the digest wins. If no provider has it +//! — or the swarm is disabled, or every peer is offline — we fall back to the +//! origin HTTP download, which is the guaranteed source of truth. Worst case is +//! exactly today's behaviour. +//! +//! Peer-sourced bytes are UNTRUSTED, so this module verifies them against the +//! content digest before accepting. Origin bytes run through the caller's +//! existing verification (e.g. the SHA-256 gate in `update.rs`). +//! +//! The actual iroh-blobs provider is gated behind the `iroh-swarm` feature +//! (heavy QUIC dep tree); with the feature off, [`providers`] is empty and +//! every fetch goes straight to origin — byte-for-byte today's path. + +use std::path::Path; +use std::sync::Arc; + +use anyhow::Result; +use async_trait::async_trait; +use tracing::{debug, info, warn}; + +use crate::content_hash::ContentDigest; + +/// Which source ultimately served the content. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FetchSource { + /// A peer in the swarm served (and the bytes verified). + Swarm, + /// The origin HTTP fallback served. + Origin, +} + +/// A source that may be able to serve content addressed by its digest. +#[async_trait] +pub trait BlobProvider: Send + Sync { + /// Short name for logging (e.g. "iroh"). + fn name(&self) -> &str; + + /// Try to fetch the content for `digest` into `dest`. + /// + /// * `Ok(true)` — bytes written to `dest` (caller verifies the digest). + /// * `Ok(false)` — this provider does not have the content; try the next. + /// * `Err(_)` — a transient failure; try the next provider. + async fn try_fetch(&self, digest: &ContentDigest, dest: &Path) -> Result; +} + +/// The ordered list of swarm providers to consult before the origin. +/// +/// Empty unless the `iroh-swarm` feature is enabled and a provider has been +/// registered. Today it is always empty — the seam exists so wiring iroh is a +/// localized change rather than a surgery through the download path. +pub fn providers() -> Vec> { + Vec::new() +} + +/// Fetch content-addressed bytes: swarm-assist, origin always wins. +/// +/// Tries each provider in order; the first to write bytes that VERIFY against +/// `digest` wins and returns [`FetchSource::Swarm`]. If none succeed, runs +/// `origin` (the guaranteed HTTP fallback) and returns [`FetchSource::Origin`]. +/// A node that obtained bytes from the swarm has, by definition, a verified +/// copy it can itself seed afterwards. +pub async fn fetch_content_addressed( + digest: &ContentDigest, + providers: &[Arc], + dest: &Path, + origin: F, +) -> Result +where + F: FnOnce() -> Fut, + Fut: std::future::Future>, +{ + for provider in providers { + match provider.try_fetch(digest, dest).await { + Ok(true) => match verify_dest(digest, dest).await { + Ok(()) => { + info!("swarm: {} served {} (verified)", provider.name(), digest); + return Ok(FetchSource::Swarm); + } + Err(e) => { + // A peer served bytes that don't match the digest — could be + // corruption or a malicious seed. Discard and try the next + // source; never let unverified peer bytes through. + warn!( + "swarm: {} served bytes failing verification for {}: {} — discarding", + provider.name(), + digest, + e + ); + let _ = tokio::fs::remove_file(dest).await; + } + }, + Ok(false) => debug!("swarm: {} does not have {}", provider.name(), digest), + Err(e) => debug!("swarm: {} failed for {}: {}", provider.name(), digest, e), + } + } + + debug!("swarm: no provider served {} — falling back to origin", digest); + origin().await?; + Ok(FetchSource::Origin) +} + +/// Read `dest` and verify it hashes to `digest`. +async fn verify_dest(digest: &ContentDigest, dest: &Path) -> Result<()> { + let bytes = tokio::fs::read(dest).await?; + digest.verify(&bytes) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicBool, Ordering}; + + fn digest_of(bytes: &[u8]) -> ContentDigest { + ContentDigest::parse(&format!("blake3:{}", crate::content_hash::blake3_hex(bytes))).unwrap() + } + + /// Provider that writes a fixed payload (which may or may not match). + struct FixedProvider { + name: &'static str, + payload: Option>, + } + #[async_trait] + impl BlobProvider for FixedProvider { + fn name(&self) -> &str { + self.name + } + async fn try_fetch(&self, _d: &ContentDigest, dest: &Path) -> Result { + match &self.payload { + Some(p) => { + tokio::fs::write(dest, p).await?; + Ok(true) + } + None => Ok(false), + } + } + } + + fn arc(p: FixedProvider) -> Arc { + Arc::new(p) + } + + #[tokio::test] + async fn swarm_hit_verifies_and_skips_origin() { + let dir = tempfile::tempdir().unwrap(); + let dest = dir.path().join("out"); + let content = b"hello swarm".to_vec(); + let digest = digest_of(&content); + let providers = vec![arc(FixedProvider { + name: "good", + payload: Some(content.clone()), + })]; + let origin_ran = AtomicBool::new(false); + let src = fetch_content_addressed(&digest, &providers, &dest, || async { + origin_ran.store(true, Ordering::SeqCst); + tokio::fs::write(&dest, b"from-origin").await?; + Ok(()) + }) + .await + .unwrap(); + assert_eq!(src, FetchSource::Swarm); + assert!(!origin_ran.load(Ordering::SeqCst), "origin must not run on swarm hit"); + assert_eq!(tokio::fs::read(&dest).await.unwrap(), content); + } + + #[tokio::test] + async fn bad_swarm_bytes_are_discarded_and_origin_wins() { + let dir = tempfile::tempdir().unwrap(); + let dest = dir.path().join("out"); + let content = b"the real bytes".to_vec(); + let digest = digest_of(&content); + // Provider claims a hit but serves tampered bytes. + let providers = vec![arc(FixedProvider { + name: "evil", + payload: Some(b"TAMPERED".to_vec()), + })]; + let src = fetch_content_addressed(&digest, &providers, &dest, || async { + tokio::fs::write(&dest, &content).await?; + Ok(()) + }) + .await + .unwrap(); + assert_eq!(src, FetchSource::Origin, "tampered swarm bytes must not be accepted"); + assert_eq!(tokio::fs::read(&dest).await.unwrap(), content); + } + + #[tokio::test] + async fn no_providers_goes_straight_to_origin() { + let dir = tempfile::tempdir().unwrap(); + let dest = dir.path().join("out"); + let content = b"x".to_vec(); + let digest = digest_of(&content); + let providers: Vec> = vec![]; + let src = fetch_content_addressed(&digest, &providers, &dest, || async { + tokio::fs::write(&dest, &content).await?; + Ok(()) + }) + .await + .unwrap(); + assert_eq!(src, FetchSource::Origin); + } + + #[tokio::test] + async fn falls_through_providers_in_order() { + let dir = tempfile::tempdir().unwrap(); + let dest = dir.path().join("out"); + let content = b"second wins".to_vec(); + let digest = digest_of(&content); + let providers = vec![ + arc(FixedProvider { name: "miss", payload: None }), + arc(FixedProvider { name: "hit", payload: Some(content.clone()) }), + ]; + let src = fetch_content_addressed(&digest, &providers, &dest, || async { + tokio::fs::write(&dest, b"origin").await?; + Ok(()) + }) + .await + .unwrap(); + assert_eq!(src, FetchSource::Swarm); + assert_eq!(tokio::fs::read(&dest).await.unwrap(), content); + } +} diff --git a/core/archipelago/src/update.rs b/core/archipelago/src/update.rs index 0d88b8c0..4b83594d 100644 --- a/core/archipelago/src/update.rs +++ b/core/archipelago/src/update.rs @@ -807,7 +807,49 @@ pub async fn download_update(data_dir: &Path) -> Result { } info!(name = %component.name, url = %component.download_url, "Downloading component"); let dest = staging_dir.join(&component.name); - download_component_resumable(&client, component, &dest, downloaded).await?; + + // DHT Phase 2: when the manifest pins a BLAKE3 digest, route the fetch + // through the swarm seam (swarm-assist, origin always wins). With no + // providers registered (iroh-swarm feature off) this is identical to + // calling the resumable HTTP origin directly — same bytes, now + // content-addressed. A swarm hit is BLAKE3-verified inside the seam; + // we still enforce the mandatory SHA-256 gate on peer bytes here and + // re-fetch from origin if a (consistency-broken) peer slips through. + let digest = component.blake3.as_deref().and_then(|b| { + let s = b.trim(); + let normalized = if s.contains(':') { + s.to_string() + } else { + format!("blake3:{s}") + }; + crate::content_hash::ContentDigest::parse(&normalized).ok() + }); + if let Some(digest) = digest { + let client_ref = &client; + let dest_ref = &dest; + let source = crate::swarm::fetch_content_addressed( + &digest, + &crate::swarm::providers(), + &dest, + move || async move { + download_component_resumable(client_ref, component, dest_ref, downloaded).await + }, + ) + .await?; + if source == crate::swarm::FetchSource::Swarm { + let bytes = tokio::fs::read(&dest).await?; + if crate::content_hash::sha256_hex(&bytes) != component.sha256 { + warn!( + name = %component.name, + "swarm bytes passed BLAKE3 but failed the SHA-256 manifest gate — re-fetching from origin" + ); + let _ = tokio::fs::remove_file(&dest).await; + download_component_resumable(&client, component, &dest, downloaded).await?; + } + } + } else { + download_component_resumable(&client, component, &dest, downloaded).await?; + } downloaded += component.size_bytes; DOWNLOAD_BYTES.store(downloaded, Ordering::Relaxed); info!( From 082946aa3015b11b2c22253258b2ae1b84d748ed Mon Sep 17 00:00:00 2001 From: archipelago Date: Tue, 16 Jun 2026 14:33:31 -0400 Subject: [PATCH 08/14] =?UTF-8?q?feat(dht):=20Phase=202=20engine=20?= =?UTF-8?q?=E2=80=94=20real=20iroh-blobs=20provider=20behind=20iroh-swarm?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pulls iroh 1.0 + iroh-blobs 0.103 as OPTIONAL deps under the iroh-swarm feature and implements a real BlobProvider over them. Verified: the full iroh QUIC dep tree (260 pkgs) resolves and compiles against the pinned bitcoin/nostr-sdk/reqwest-rustls stack; the provider compiles against the 0.103/1.0 API. - swarm/iroh_provider.rs: IrohProvider::new binds a QUIC Endpoint, opens a persistent FsStore (data_dir/iroh-blobs), and serves blobs via the iroh-blobs protocol/Router — a node that fetches also SEEDS. try_fetch maps ContentDigest -> iroh Hash, asks discovery for seed EndpointIds, then downloader.download(hash, providers) (range-verified) + export to staging. - ProviderDiscovery trait: the seam Phase 3 (signed Nostr advertisement events) fills. discovery=None -> no seeds -> origin-only, so enabling the feature is never worse than today. - Default build untouched: iroh is optional, the module is cfg-gated, and providers() stays empty until Phase 3 wires discovery in. Build: cargo build --features iroh-swarm succeeds (dev). Default build + 44 swarm/update/content_hash/blobs tests unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- core/Cargo.lock | 3095 ++++++++++++++++++- core/archipelago/Cargo.toml | 8 +- core/archipelago/src/swarm/iroh_provider.rs | 132 + core/archipelago/src/swarm/mod.rs | 3 + 4 files changed, 3157 insertions(+), 81 deletions(-) create mode 100644 core/archipelago/src/swarm/iroh_provider.rs diff --git a/core/Cargo.lock b/core/Cargo.lock index 96170111..a020330e 100644 --- a/core/Cargo.lock +++ b/core/Cargo.lock @@ -14,7 +14,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "generic-array", ] @@ -63,6 +63,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -78,6 +84,15 @@ version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +[[package]] +name = "arc-swap" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" +dependencies = [ + "rustversion", +] + [[package]] name = "archipelago" version = "1.7.97-alpha" @@ -98,9 +113,9 @@ dependencies = [ "chacha20poly1305", "chrono", "ciborium", - "curve25519-dalek", + "curve25519-dalek 4.1.3", "data-encoding", - "ed25519-dalek", + "ed25519-dalek 2.2.0", "flate2", "futures-util", "hex", @@ -111,6 +126,8 @@ dependencies = [ "hyper 0.14.32", "hyper-util", "hyper-ws-listener", + "iroh", + "iroh-blobs", "mainline", "mdns-sd", "nostr-sdk", @@ -118,13 +135,13 @@ dependencies = [ "rand 0.8.5", "reed-solomon-erasure", "regex", - "reqwest", + "reqwest 0.11.27", "sd-notify", "serde", "serde_json", "serde_yaml", "serial2-tokio", - "sha2", + "sha2 0.10.9", "tar", "tempfile", "thiserror 1.0.69", @@ -153,7 +170,7 @@ dependencies = [ "hyper 0.14.32", "indexmap", "log", - "reqwest", + "reqwest 0.11.27", "serde", "serde_json", "serde_yaml", @@ -219,6 +236,45 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "asn1-rs" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -227,7 +283,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.114", ] [[package]] @@ -261,24 +317,92 @@ dependencies = [ "web-sys", ] +[[package]] +name = "async_io_stream" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d7b9decdf35d8908a7e3ef02f64c5e9b1695e230154c0e8de3969142d9b94c" +dependencies = [ + "futures", + "pharos", + "rustc_version", +] + [[package]] name = "atomic-destructor" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef49f5882e4b6afaac09ad239a4f8c70a24b8f2b0897edb1f706008efd109cf4" +[[package]] +name = "atomic-polyfill" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" +dependencies = [ + "critical-section", +] + [[package]] name = "atomic-waker" version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "attohttpc" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16e2cdb6d5ed835199484bb92bb8b3edd526effe995c61732580439c1a67e2e9" +dependencies = [ + "base64 0.22.1", + "http 1.4.0", + "log", + "url", +] + [[package]] name = "autocfg" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "backon" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" +dependencies = [ + "fastrand", + "gloo-timers", + "tokio", +] + +[[package]] +name = "bao-tree" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06384416b1825e6e04fde63262fda2dc408f5b64c02d04e0d8b70ae72c17a52b" +dependencies = [ + "blake3", + "bytes", + "futures-lite", + "genawaiter", + "iroh-io", + "positioned-io", + "range-collections", + "self_cell", + "serde", + "smallvec", + "tokio", +] + +[[package]] +name = "base16ct" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" + [[package]] name = "base32" version = "0.5.1" @@ -332,6 +456,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" +[[package]] +name = "binary-merge" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597bb81c80a54b6a4381b23faba8d7774b144c94cbd1d6fe3f1329bd776554ab" + [[package]] name = "bip39" version = "2.1.0" @@ -345,6 +475,15 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +dependencies = [ + "serde", +] + [[package]] name = "bitcoin" version = "0.32.5" @@ -418,9 +557,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.10.0" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" [[package]] name = "blake2" @@ -428,7 +567,7 @@ version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" dependencies = [ - "digest", + "digest 0.10.7", ] [[package]] @@ -454,6 +593,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "block-padding" version = "0.3.3" @@ -463,6 +611,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + [[package]] name = "blowfish" version = "0.9.1" @@ -511,6 +668,9 @@ name = "bytes" version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" +dependencies = [ + "serde", +] [[package]] name = "cbc" @@ -531,12 +691,24 @@ dependencies = [ "shlex", ] +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + [[package]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "chacha20" version = "0.9.1" @@ -548,6 +720,17 @@ dependencies = [ "cpufeatures 0.2.17", ] +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + [[package]] name = "chacha20poly1305" version = "0.10.1" @@ -555,7 +738,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" dependencies = [ "aead", - "chacha20", + "chacha20 0.9.1", "cipher", "poly1305", "zeroize", @@ -608,17 +791,48 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "inout", "zeroize", ] +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.18", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + [[package]] name = "const-oid" version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "constant_time_eq" version = "0.3.1" @@ -631,6 +845,25 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cordyceps" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "688d7fbb8092b8de775ef2536f36c8c31f2bc4006ece2e8d8ad2d17d00ce0a2a" +dependencies = [ + "loom", + "tracing", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -641,6 +874,16 @@ dependencies = [ "libc", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -689,6 +932,36 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + [[package]] name = "crunchy" version = "0.2.4" @@ -706,6 +979,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + [[package]] name = "ctr" version = "0.9.2" @@ -715,6 +997,15 @@ dependencies = [ "cipher", ] +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + [[package]] name = "curve25519-dalek" version = "4.1.3" @@ -724,13 +1015,31 @@ dependencies = [ "cfg-if", "cpufeatures 0.2.17", "curve25519-dalek-derive", - "digest", - "fiat-crypto", + "digest 0.10.7", + "fiat-crypto 0.2.9", "rustc_version", "subtle", "zeroize", ] +[[package]] +name = "curve25519-dalek" +version = "5.0.0-rc.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f359e08ca85e7bd759e1fd933ff2bccd81864c60a8fba0e259c7f822b0924bf" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "curve25519-dalek-derive", + "digest 0.11.3", + "fiat-crypto 0.3.0", + "rand_core 0.10.1", + "rustc_version", + "serde", + "subtle", + "zeroize", +] + [[package]] name = "curve25519-dalek-derive" version = "0.1.1" @@ -739,14 +1048,69 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.114", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.114", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.114", ] [[package]] name = "data-encoding" -version = "2.10.0" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "data-encoding-macro" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3259c913752a86488b501ed8680446a5ed2d5aeac6e596cb23ba3800768ea32c" +dependencies = [ + "data-encoding", + "data-encoding-macro-internal", +] + +[[package]] +name = "data-encoding-macro-internal" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" +dependencies = [ + "data-encoding", + "syn 2.0.114", +] [[package]] name = "der" @@ -754,21 +1118,134 @@ version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ - "const-oid", + "const-oid 0.9.6", "zeroize", ] +[[package]] +name = "der" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fd89660b2dc699704064e59e9dba0147b903e85319429e131620d022be411b" +dependencies = [ + "const-oid 0.10.2", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.114", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.114", + "unicode-xid", +] + +[[package]] +name = "diatomic-waker" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab03c107fafeb3ee9f5925686dbb7a73bc76e3932abb0d2b365cb64b169cf04c" + [[package]] name = "digest" version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.7", "subtle", ] +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.0", + "block2", + "libc", + "objc2", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -777,7 +1254,18 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.114", +] + +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "libc", + "once_cell", + "winapi", ] [[package]] @@ -786,8 +1274,19 @@ version = "2.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" dependencies = [ - "pkcs8", - "signature", + "pkcs8 0.10.2", + "signature 2.2.0", +] + +[[package]] +name = "ed25519" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" +dependencies = [ + "pkcs8 0.11.0", + "serdect", + "signature 3.0.0", ] [[package]] @@ -796,11 +1295,27 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" dependencies = [ - "curve25519-dalek", - "ed25519", + "curve25519-dalek 4.1.3", + "ed25519 2.2.3", "rand_core 0.6.4", "serde", - "sha2", + "sha2 0.10.9", + "subtle", + "zeroize", +] + +[[package]] +name = "ed25519-dalek" +version = "3.0.0-rc.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b011170fe4f04665565b4110afef66774fe9ffff278f3eb5b81cc73d26e27d60" +dependencies = [ + "curve25519-dalek 5.0.0-rc.0", + "ed25519 3.0.0", + "rand_core 0.10.1", + "serde", + "sha2 0.11.0", + "signature 3.0.0", "subtle", "zeroize", ] @@ -811,6 +1326,18 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -820,6 +1347,17 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "enum-assoc" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed8956bd5c1f0415200516e78ff07ec9e16415ade83c056c230d7b7ea0d55b7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "env_logger" version = "0.10.2" @@ -849,6 +1387,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fastbloom" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef975e30683b2d965054bb0a836f8973857c4ebf6acf274fe46617cd285060d8" +dependencies = [ + "foldhash 0.2.0", + "libm", + "portable-atomic", + "siphasher", +] + [[package]] name = "fastrand" version = "2.3.0" @@ -861,6 +1411,12 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +[[package]] +name = "fiat-crypto" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" + [[package]] name = "filetime" version = "0.2.27" @@ -896,7 +1452,7 @@ checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" dependencies = [ "futures-core", "futures-sink", - "spin", + "spin 0.9.8", ] [[package]] @@ -905,6 +1461,18 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -929,6 +1497,19 @@ dependencies = [ "futures-util", ] +[[package]] +name = "futures-buffered" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4421cb78ee172b6b06080093479d3c50f058e7c81b7d577bbb8d118d551d4cd5" +dependencies = [ + "cordyceps", + "diatomic-waker", + "futures-core", + "pin-project-lite", + "spin 0.10.0", +] + [[package]] name = "futures-channel" version = "0.3.31" @@ -962,6 +1543,19 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.31" @@ -970,7 +1564,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.114", ] [[package]] @@ -1003,6 +1597,52 @@ dependencies = [ "slab", ] +[[package]] +name = "genawaiter" +version = "0.99.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c86bd0361bcbde39b13475e6e36cb24c329964aa2611be285289d1e4b751c1a0" +dependencies = [ + "futures-core", + "genawaiter-macro", + "genawaiter-proc-macro", + "proc-macro-hack", +] + +[[package]] +name = "genawaiter-macro" +version = "0.99.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b32dfe1fdfc0bbde1f22a5da25355514b5e450c33a6af6770884c8750aedfbc" + +[[package]] +name = "genawaiter-proc-macro" +version = "0.99.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784f84eebc366e15251c4a8c3acee82a6a6f427949776ecb88377362a9621738" +dependencies = [ + "proc-macro-error", + "proc-macro-hack", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "generator" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3b854b0e584ead1a33f18b2fcad7cf7be18b3875c78816b753639aa501513ae" +dependencies = [ + "cc", + "cfg-if", + "libc", + "log", + "rustversion", + "windows-link", + "windows-result", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -1034,10 +1674,26 @@ checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", ] +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasip2", + "wasip3", + "wasm-bindgen", +] + [[package]] name = "ghash" version = "0.5.1" @@ -1109,6 +1765,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "hash32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" +dependencies = [ + "byteorder", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -1118,12 +1783,52 @@ dependencies = [ "ahash", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + [[package]] name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "heapless" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" +dependencies = [ + "atomic-polyfill", + "hash32", + "rustc_version", + "serde", + "spin 0.9.8", + "stable_deref_trait", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hermit-abi" version = "0.5.2" @@ -1157,6 +1862,83 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3011d1213f159867b13cfd6ac92d2cd5f1345762c63be3554e84092d85a50bbd" +[[package]] +name = "hickory-net" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2295ed2f9c31e471e1428a8f88a3f0e1f4b27c15049592138d1eebe9c35b183" +dependencies = [ + "async-trait", + "bytes", + "cfg-if", + "data-encoding", + "futures-channel", + "futures-io", + "futures-util", + "h2 0.4.13", + "hickory-proto", + "http 1.4.0", + "idna", + "ipnet", + "jni 0.22.4", + "rand 0.10.1", + "rustls 0.23.36", + "thiserror 2.0.18", + "tinyvec", + "tokio", + "tokio-rustls 0.26.4", + "tracing", + "url", +] + +[[package]] +name = "hickory-proto" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bab31817bfb44672a252e97fe81cd0c18d1b2cf892108922f6818820df8c643" +dependencies = [ + "data-encoding", + "idna", + "ipnet", + "jni 0.22.4", + "once_cell", + "prefix-trie", + "rand 0.10.1", + "ring", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "url", +] + +[[package]] +name = "hickory-resolver" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d58d28879ceecde6607729660c2667a081ccdc082e082675042793960f178c" +dependencies = [ + "cfg-if", + "futures-util", + "hickory-net", + "hickory-proto", + "ipconfig", + "ipnet", + "jni 0.22.4", + "moka", + "ndk-context", + "once_cell", + "parking_lot 0.12.5", + "rand 0.10.1", + "resolv-conf", + "rustls 0.23.36", + "smallvec", + "system-configuration 0.7.0", + "thiserror 2.0.18", + "tokio", + "tokio-rustls 0.26.4", + "tracing", +] + [[package]] name = "hkdf" version = "0.12.4" @@ -1172,7 +1954,7 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest", + "digest 0.10.7", ] [[package]] @@ -1248,6 +2030,15 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" +[[package]] +name = "hybrid-array" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "0.14.32" @@ -1309,6 +2100,21 @@ dependencies = [ "tokio-rustls 0.24.1", ] +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http 1.4.0", + "hyper 1.8.1", + "hyper-util", + "rustls 0.23.36", + "tokio", + "tokio-rustls 0.26.4", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.19" @@ -1458,6 +2264,24 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "identity-hash" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfdd7caa900436d8f13b2346fe10257e0c05c1f1f9e351f4f5d57c03bd5f45da" + [[package]] name = "idna" version = "1.1.0" @@ -1489,6 +2313,26 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "igd-next" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de7238d487a9aff61f81b5ab41c0a841532a115a398b5fa92a2fadd0885e2581" +dependencies = [ + "attohttpc", + "bytes", + "futures", + "http 1.4.0", + "http-body-util", + "hyper 1.8.1", + "hyper-util", + "log", + "rand 0.10.1", + "tokio", + "url", + "xmltree", +] + [[package]] name = "image" version = "0.25.9" @@ -1523,6 +2367,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "inplace-vec-builder" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf64c2edc8226891a71f127587a2861b132d2b942310843814d5001d99a1d307" +dependencies = [ + "smallvec", +] + [[package]] name = "instant" version = "0.1.13" @@ -1536,10 +2389,318 @@ dependencies = [ ] [[package]] -name = "ipnet" -version = "2.11.0" +name = "ipconfig" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" +checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" +dependencies = [ + "socket2 0.6.2", + "widestring", + "windows-registry", + "windows-result", + "windows-sys 0.61.2", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +dependencies = [ + "serde", +] + +[[package]] +name = "iri-string" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "iroh" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6435544bb3a5c4e6ff7affaa0c0aa0d1bca45bd700226329d5059d3eb54f9dff" +dependencies = [ + "backon", + "blake3", + "bytes", + "cfg_aliases", + "ctutils", + "data-encoding", + "derive_more", + "ed25519-dalek 3.0.0-rc.0", + "futures-util", + "getrandom 0.4.2", + "hickory-resolver", + "http 1.4.0", + "ipnet", + "iroh-base", + "iroh-dns", + "iroh-metrics", + "iroh-relay", + "n0-error", + "n0-future", + "n0-watcher", + "netwatch", + "noq", + "noq-proto", + "noq-udp", + "papaya", + "pin-project", + "portable-atomic", + "portmapper", + "rand 0.10.1", + "reqwest 0.13.4", + "rustc-hash", + "rustls 0.23.36", + "rustls-pki-types", + "serde", + "smallvec", + "strum", + "time", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", + "url", + "wasm-bindgen-futures", +] + +[[package]] +name = "iroh-base" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9c95e4459d9bb828a77084277abd308aa2b58a096652b079bddfd6ef2361f53" +dependencies = [ + "curve25519-dalek 5.0.0-rc.0", + "data-encoding", + "data-encoding-macro", + "derive_more", + "ed25519-dalek 3.0.0-rc.0", + "getrandom 0.4.2", + "n0-error", + "rand 0.10.1", + "serde", + "url", + "zeroize", +] + +[[package]] +name = "iroh-blobs" +version = "0.103.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be50b0e2d0a9ba65cee4e0dfb708b3704e02ad12bd4c14c6307e94245943126" +dependencies = [ + "arrayvec", + "bao-tree", + "bytes", + "cfg_aliases", + "chrono", + "constant_time_eq 0.4.2", + "data-encoding", + "derive_more", + "genawaiter", + "getrandom 0.4.2", + "hex", + "iroh", + "iroh-base", + "iroh-io", + "iroh-metrics", + "iroh-tickets", + "iroh-util", + "irpc", + "n0-error", + "n0-future", + "nested_enum_utils", + "noq", + "postcard", + "rand 0.10.1", + "range-collections", + "redb", + "ref-cast", + "reflink-copy", + "self_cell", + "serde", + "smallvec", + "tokio", + "tracing", +] + +[[package]] +name = "iroh-dns" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "754f7e0c1f67938e1d671007264ffef158f14a9f795a7cc219ea68ea09a9d4c9" +dependencies = [ + "arc-swap", + "cfg_aliases", + "derive_more", + "hickory-resolver", + "iroh-base", + "n0-error", + "n0-future", + "ndk-context", + "portable-atomic", + "rand 0.10.1", + "rustls 0.23.36", + "simple-dns", + "strum", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "iroh-io" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0a5feb781017b983ff1b155cd1faf8174da2acafd807aa482876da2d7e6577a" +dependencies = [ + "bytes", + "futures-lite", + "pin-project", + "smallvec", + "tokio", +] + +[[package]] +name = "iroh-metrics" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291065721ad7c477b972e581bbc528df031dc8eb5e39fe1ff3300ae5dfb157ef" +dependencies = [ + "iroh-metrics-derive", + "itoa", + "n0-error", + "portable-atomic", + "ryu", + "serde", + "tracing", +] + +[[package]] +name = "iroh-metrics-derive" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae5f0c4405d1fbc9fb16ff422ca40620e93dc36c30ecaba0c2aee3992b7bd48" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "iroh-relay" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c12e48fef252fd04f8e6b6a8802b377baf72548d62ae4838816624cd0e06b79" +dependencies = [ + "blake3", + "bytes", + "cfg_aliases", + "data-encoding", + "derive_more", + "getrandom 0.4.2", + "hickory-resolver", + "http 1.4.0", + "http-body-util", + "hyper 1.8.1", + "hyper-util", + "iroh-base", + "iroh-dns", + "iroh-metrics", + "lru 0.18.0", + "n0-error", + "n0-future", + "noq", + "noq-proto", + "num_enum", + "pin-project", + "postcard", + "rand 0.10.1", + "reqwest 0.13.4", + "rustls 0.23.36", + "rustls-pki-types", + "serde", + "serde_bytes", + "strum", + "tokio", + "tokio-rustls 0.26.4", + "tokio-util", + "tokio-websockets", + "tracing", + "url", + "vergen-gitcl", + "webpki-roots 1.0.6", + "ws_stream_wasm", +] + +[[package]] +name = "iroh-tickets" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da53233419ca36bf521ed45683b7748366f9b233032891eefc2d70567a84ac54" +dependencies = [ + "data-encoding", + "derive_more", + "iroh-base", + "n0-error", + "postcard", + "serde", +] + +[[package]] +name = "iroh-util" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20e41eb982f15230c55f0a70a74a514360e1f565b07861924fd0e8db172b3d00" +dependencies = [ + "derive_more", + "iroh", + "n0-error", + "n0-future", + "tokio", + "tracing", +] + +[[package]] +name = "irpc" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3623d6ff582b415904b29bbe6ebcb4a4f9a262ccdee05a45fdd003ef0950c386" +dependencies = [ + "futures-buffered", + "futures-util", + "irpc-derive", + "n0-error", + "n0-future", + "noq", + "postcard", + "rcgen", + "rustls 0.23.36", + "serde", + "smallvec", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "irpc-derive" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35c254013736de16472140d26904e6ac98e8f3887284dcf4af40f88c77411b56" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] [[package]] name = "is-terminal" @@ -1558,6 +2719,80 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.114", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.114", +] + [[package]] name = "js-sys" version = "0.3.85" @@ -1574,6 +2809,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" version = "0.2.180" @@ -1592,7 +2833,7 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "libc", "plain", "redox_syscall 0.7.3", @@ -1625,6 +2866,19 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "loom" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +dependencies = [ + "cfg-if", + "generator", + "scoped-tls", + "tracing", + "tracing-subscriber", +] + [[package]] name = "lru" version = "0.7.8" @@ -1646,6 +2900,27 @@ version = "0.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" +[[package]] +name = "lru" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a860605968fce16869fd239cf4237a82f3ac470723415db603b0e8b6c8d4fb9" +dependencies = [ + "hashbrown 0.17.1", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "mac-addr" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d25b0e0b648a86960ac23b7ad4abb9717601dec6f66c165f5b037f3f03065f" + [[package]] name = "mainline" version = "2.0.1" @@ -1654,7 +2929,7 @@ checksum = "2b751ffb57303217bcae8f490eee6044a5b40eadf6ca05ff476cad37e7b7970d" dependencies = [ "bytes", "crc", - "ed25519-dalek", + "ed25519-dalek 2.2.0", "flume", "lru 0.12.5", "rand 0.8.5", @@ -1702,6 +2977,12 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -1724,6 +3005,23 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moka" +version = "0.12.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" +dependencies = [ + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "parking_lot 0.12.5", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + [[package]] name = "moxcms" version = "0.7.11" @@ -1734,12 +3032,281 @@ dependencies = [ "pxfm", ] +[[package]] +name = "n0-error" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c37e81176a83a77d2514528b91bdafc70ef88aab428f0e1b91aebb8d99888895" +dependencies = [ + "n0-error-macros", + "spez", +] + +[[package]] +name = "n0-error-macros" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2acd8b070213b0299282f884b4beba4e7b52d624fdcd504a3ad3665390c11e1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "n0-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2ab99dfb861450e68853d34ae665243a88b8c493d01ba957321a1e9b2312bbe" +dependencies = [ + "cfg_aliases", + "derive_more", + "futures-buffered", + "futures-lite", + "futures-util", + "js-sys", + "pin-project", + "send_wrapper", + "tokio", + "tokio-util", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-time", +] + +[[package]] +name = "n0-watcher" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc618745ad0b7414b149d0517ad8b5573b2fb4d4e2717add3d2446ce1fdd826" +dependencies = [ + "derive_more", + "n0-error", + "n0-future", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + [[package]] name = "negentropy" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0efe882e02d206d8d279c20eb40e03baf7cb5136a1476dc084a324fbc3ec42d" +[[package]] +name = "nested_enum_utils" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1d5475271bdd36a4a2769eac1ef88df0f99428ea43e52dfd8b0ee5cb674695f" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "netdev" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d31e7286c21ceaf0ddb1d881964011214555ea0b317cc2eb1a1d68d861386fc" +dependencies = [ + "block2", + "dispatch2", + "dlopen2", + "ipnet", + "jni 0.21.1", + "libc", + "mac-addr", + "ndk-context", + "netlink-packet-core", + "netlink-packet-route 0.29.0", + "netlink-sys", + "objc2", + "objc2-core-foundation", + "objc2-core-wlan", + "objc2-foundation", + "objc2-system-configuration", + "once_cell", + "plist", + "windows-sys 0.61.2", +] + +[[package]] +name = "netlink-packet-core" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3463cbb78394cb0141e2c926b93fc2197e473394b761986eca3b9da2c63ae0f4" +dependencies = [ + "paste", +] + +[[package]] +name = "netlink-packet-route" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9854ea6ad14e3f4698a7f03b65bce0833dd2d81d594a0e4a984170537146b6" +dependencies = [ + "bitflags 2.13.0", + "libc", + "log", + "netlink-packet-core", +] + +[[package]] +name = "netlink-packet-route" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2288fcb784eb3defd5fb16f4c4160d5f477de192eac730f43e1d11c24d9a007" +dependencies = [ + "bitflags 2.13.0", + "libc", + "log", + "netlink-packet-core", +] + +[[package]] +name = "netlink-proto" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b65d130ee111430e47eed7896ea43ca693c387f097dd97376bffafbf25812128" +dependencies = [ + "bytes", + "futures", + "log", + "netlink-packet-core", + "netlink-sys", + "thiserror 2.0.18", +] + +[[package]] +name = "netlink-sys" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd6c30ed10fa69cc491d491b85cc971f6bdeb8e7367b7cde2ee6cc878d583fae" +dependencies = [ + "bytes", + "futures-util", + "libc", + "log", + "tokio", +] + +[[package]] +name = "netwatch" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8487d26d691cd98d5c17b2adb4b1fd4b31cccc820da1eac827d483295d7bb94a" +dependencies = [ + "atomic-waker", + "bytes", + "cfg_aliases", + "derive_more", + "ipnet", + "js-sys", + "libc", + "n0-error", + "n0-future", + "n0-watcher", + "netdev", + "netlink-packet-core", + "netlink-packet-route 0.31.0", + "netlink-proto", + "netlink-sys", + "noq-udp", + "objc2-core-foundation", + "objc2-system-configuration", + "pin-project-lite", + "serde", + "socket2 0.6.2", + "time", + "tokio", + "tokio-util", + "tracing", + "web-sys", + "windows", + "windows-result", + "wmi", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "noq" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11f0c73794bfde94db01379c46990b9a773993fca2b61a66184ce148b7c7a187" +dependencies = [ + "bytes", + "cfg_aliases", + "derive_more", + "noq-proto", + "noq-udp", + "pin-project-lite", + "rustc-hash", + "rustls 0.23.36", + "socket2 0.6.2", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tracing", + "web-time", +] + +[[package]] +name = "noq-proto" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "775be06b8d66c2c64db60140bf54dee8410f67b73c81cc1e1e32f11dfdaae501" +dependencies = [ + "aes-gcm", + "bytes", + "derive_more", + "enum-assoc", + "fastbloom", + "getrandom 0.4.2", + "identity-hash", + "lru-slab", + "rand 0.10.1", + "rand_pcg", + "ring", + "rustc-hash", + "rustls 0.23.36", + "rustls-pki-types", + "rustls-platform-verifier", + "slab", + "sorted-index-buffer", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "noq-udp" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd5a37756f168cf350d68a97c4f0158bdf3c76f10175123941569b09ab51f011" +dependencies = [ + "cfg_aliases", + "libc", + "socket2 0.6.2", + "tracing", + "windows-sys 0.61.2", +] + [[package]] name = "nostr" version = "0.44.2" @@ -1752,7 +3319,7 @@ dependencies = [ "bip39", "bitcoin_hashes 0.14.1", "cbc", - "chacha20", + "chacha20 0.9.1", "chacha20poly1305", "getrandom 0.2.17", "hex", @@ -1827,6 +3394,31 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1836,11 +3428,145 @@ dependencies = [ "autocfg", ] +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.0", + "block2", + "dispatch2", + "libc", + "objc2", +] + +[[package]] +name = "objc2-core-wlan" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c71e34919aba0d701380d911702455038a8a3587467fe0141d6a71501e7ffe48" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-core-foundation", + "objc2-foundation", + "objc2-security", + "objc2-security-foundation", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.0", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-security" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-security-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef76382e9cedd18123099f17638715cc3d81dba3637d4c0d39ab69df2ef345a5" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-system-configuration" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" +dependencies = [ + "bitflags 2.13.0", + "dispatch2", + "libc", + "objc2", + "objc2-core-foundation", + "objc2-security", +] + +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + [[package]] name = "once_cell" version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +dependencies = [ + "critical-section", + "portable-atomic", +] [[package]] name = "opaque-debug" @@ -1848,6 +3574,28 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "papaya" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "997ee03cd38c01469a7046643714f0ad28880bcb9e6679ff0666e24817ca19b7" +dependencies = [ + "equivalent", + "seize", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.11.2" @@ -1907,22 +3655,77 @@ dependencies = [ "subtle", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pbkdf2" version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" dependencies = [ - "digest", + "digest 0.10.7", "hmac", ] +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pharos" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9567389417feee6ce15dd6527a8a1ecac205ef62c2932bcf3d9f6fc5b78b414" +dependencies = [ + "futures", + "rustc_version", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -1941,8 +3744,18 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" dependencies = [ - "der", - "spki", + "der 0.7.10", + "spki 0.7.3", +] + +[[package]] +name = "pkcs8" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" +dependencies = [ + "der 0.8.0", + "spki 0.8.0", ] [[package]] @@ -1951,6 +3764,19 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "plist" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1" +dependencies = [ + "base64 0.22.1", + "indexmap", + "quick-xml", + "serde", + "time", +] + [[package]] name = "poly1305" version = "0.8.0" @@ -1974,6 +3800,79 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +dependencies = [ + "serde", +] + +[[package]] +name = "portmapper" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccc716c56a0a50f7e4e25f41446419599d47c6197cc5c9858174220e97c272e6" +dependencies = [ + "base64 0.22.1", + "bytes", + "derive_more", + "hyper-util", + "igd-next", + "iroh-metrics", + "libc", + "n0-error", + "n0-future", + "netwatch", + "num_enum", + "rand 0.10.1", + "serde", + "smallvec", + "socket2 0.6.2", + "time", + "tokio", + "tokio-util", + "tower-layer", + "tracing", + "url", +] + +[[package]] +name = "positioned-io" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ec4b80060f033312b99b6874025d9503d2af87aef2dd4c516e253fbfcdada7" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "heapless", + "postcard-derive", + "serde", +] + +[[package]] +name = "postcard-derive" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0232bd009a197ceec9cc881ba46f727fcd8060a2d8d6a9dde7a69030a6fe2bb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -1983,6 +3882,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1992,6 +3897,68 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "prefix-trie" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cf6e3177f0684016a5c209b00882e15f8bdd3f3bb48f0491df10cd102d0c6e7" +dependencies = [ + "either", + "ipnet", + "num-traits", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.114", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.12+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18f33027081eba0a6d8aba6d1b1c3a3be58cbb12106341c2d5759fcd9b5277e7" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a5b4b77fdb63c1eca72173d68d24501c54ab1269409f6b672c85deb18af69de" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", + "syn-mid", + "version_check", +] + +[[package]] +name = "proc-macro-hack" +version = "0.5.20+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" + [[package]] name = "proc-macro2" version = "1.0.106" @@ -2016,6 +3983,15 @@ dependencies = [ "image", ] +[[package]] +name = "quick-xml" +version = "0.39.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +dependencies = [ + "memchr", +] + [[package]] name = "quote" version = "1.0.44" @@ -2031,6 +4007,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rand" version = "0.8.5" @@ -2052,6 +4034,17 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20 0.10.0", + "getrandom 0.4.2", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -2090,6 +4083,57 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "range-collections" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "861706ea9c4aded7584c5cd1d241cec2ea7f5f50999f236c22b65409a1f1a0d0" +dependencies = [ + "binary-merge", + "inplace-vec-builder", + "ref-cast", + "serde", + "smallvec", +] + +[[package]] +name = "rcgen" +version = "0.14.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57f6d249aad744e274e682777a50283a225a32705394ee6d5fcc01efa25e4055" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", +] + +[[package]] +name = "redb" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e925444704b5f17d32bf42f5b6e2df050bceebc3dcd6e71cc73dafe8092e839" +dependencies = [ + "libc", +] + [[package]] name = "redox_syscall" version = "0.2.16" @@ -2105,7 +4149,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", ] [[package]] @@ -2114,7 +4158,7 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", ] [[package]] @@ -2127,7 +4171,39 @@ dependencies = [ "lru 0.7.8", "parking_lot 0.11.2", "smallvec", - "spin", + "spin 0.9.8", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "reflink-copy" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13362233b147e57674c37b802d216b7c5e3dcccbed8967c84f0d8d223868ae27" +dependencies = [ + "cfg-if", + "libc", + "rustix", + "windows", ] [[package]] @@ -2174,7 +4250,7 @@ dependencies = [ "http 0.2.12", "http-body 0.4.6", "hyper 0.14.32", - "hyper-rustls", + "hyper-rustls 0.24.2", "ipnet", "js-sys", "log", @@ -2187,7 +4263,7 @@ dependencies = [ "serde", "serde_json", "serde_urlencoded", - "sync_wrapper", + "sync_wrapper 0.1.2", "system-configuration 0.5.1", "tokio", "tokio-rustls 0.24.1", @@ -2201,6 +4277,49 @@ dependencies = [ "winreg", ] +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "hyper 1.8.1", + "hyper-rustls 0.27.9", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "rustls 0.23.36", + "rustls-pki-types", + "rustls-platform-verifier", + "sync_wrapper 1.0.2", + "tokio", + "tokio-rustls 0.26.4", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "resolv-conf" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" + [[package]] name = "ring" version = "0.17.14" @@ -2215,6 +4334,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + [[package]] name = "rustc_version" version = "0.4.1" @@ -2224,13 +4349,22 @@ dependencies = [ "semver", ] +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + [[package]] name = "rustix" version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "errno", "libc", "linux-raw-sys", @@ -2255,6 +4389,7 @@ version = "0.23.36" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" dependencies = [ + "log", "once_cell", "ring", "rustls-pki-types", @@ -2263,6 +4398,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pemfile" version = "1.0.4" @@ -2278,9 +4425,37 @@ version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" dependencies = [ + "web-time", "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni 0.22.4", + "log", + "once_cell", + "rustls 0.23.36", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki 0.103.9", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + [[package]] name = "rustls-webpki" version = "0.101.7" @@ -2323,6 +4498,30 @@ dependencies = [ "cipher", ] +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + [[package]] name = "scopeguard" version = "1.2.0" @@ -2338,7 +4537,7 @@ dependencies = [ "password-hash", "pbkdf2", "salsa20", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -2381,12 +4580,57 @@ dependencies = [ "cc", ] +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.0", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "seize" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "self_cell" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" + [[package]] name = "semver" version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" + [[package]] name = "serde" version = "1.0.228" @@ -2434,7 +4678,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.114", ] [[package]] @@ -2484,6 +4728,16 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "serdect" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" +dependencies = [ + "base16ct", + "serde", +] + [[package]] name = "serial2" version = "0.2.34" @@ -2515,7 +4769,7 @@ checksum = "f5058ada175748e33390e40e872bd0fe59a19f265d0158daa551c5a88a76009c" dependencies = [ "cfg-if", "cpufeatures 0.2.17", - "digest", + "digest 0.10.7", ] [[package]] @@ -2526,7 +4780,7 @@ checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", "cpufeatures 0.2.17", - "digest", + "digest 0.10.7", ] [[package]] @@ -2543,7 +4797,18 @@ checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", "cpufeatures 0.2.17", - "digest", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] @@ -2580,12 +4845,49 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" + [[package]] name = "simd-adler32" version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "simple-dns" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a75cbde1bf934313596a004973e462f9a82caa814dcf1a5f507bdf51597eeb4" +dependencies = [ + "bitflags 2.13.0", +] + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + [[package]] name = "slab" version = "0.4.11" @@ -2597,6 +4899,9 @@ name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] [[package]] name = "socket-pktinfo" @@ -2629,6 +4934,23 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "sorted-index-buffer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea06cc588e43c632923a55450401b8f25e628131571d4e1baea1bdfdb2b5ed06" + +[[package]] +name = "spez" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c87e960f4dca2788eeb86bbdde8dd246be8948790b7618d656e68f9b720a86e8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "spin" version = "0.9.8" @@ -2638,6 +4960,12 @@ dependencies = [ "lock_api", ] +[[package]] +name = "spin" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" + [[package]] name = "spki" version = "0.7.3" @@ -2645,7 +4973,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" dependencies = [ "base64ct", - "der", + "der 0.7.10", +] + +[[package]] +name = "spki" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" +dependencies = [ + "base64ct", + "der 0.8.0", ] [[package]] @@ -2654,12 +4992,50 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "subtle" version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.114" @@ -2671,12 +5047,32 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn-mid" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fea305d57546cc8cd04feb14b62ec84bf17f50e3f7b12560d7bfa9265f39d9ed" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "sync_wrapper" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + [[package]] name = "synstructure" version = "0.13.2" @@ -2685,7 +5081,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.114", ] [[package]] @@ -2695,7 +5091,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" dependencies = [ "bitflags 1.3.2", - "core-foundation", + "core-foundation 0.9.4", "system-configuration-sys 0.5.0", ] @@ -2705,8 +5101,19 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" dependencies = [ - "bitflags 2.10.0", - "core-foundation", + "bitflags 2.13.0", + "core-foundation 0.9.4", + "system-configuration-sys 0.6.0", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.13.0", + "core-foundation 0.9.4", "system-configuration-sys 0.6.0", ] @@ -2730,6 +5137,12 @@ dependencies = [ "libc", ] +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + [[package]] name = "tar" version = "0.4.44" @@ -2789,7 +5202,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.114", ] [[package]] @@ -2800,7 +5213,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.114", ] [[package]] @@ -2812,6 +5225,39 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "time" +version = "0.3.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "711a53c2d47bbd818258c498c8dbfe186a2526c631495cfe7e078567f86b8469" +dependencies = [ + "deranged", + "js-sys", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71c652a3727a9cbb9a02f707f530b618ce00d0ccd762009c8c23bd191df3c17d" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.2" @@ -2862,7 +5308,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.114", ] [[package]] @@ -2906,6 +5352,7 @@ dependencies = [ "futures-core", "pin-project-lite", "tokio", + "tokio-util", ] [[package]] @@ -2956,10 +5403,34 @@ dependencies = [ "bytes", "futures-core", "futures-sink", + "futures-util", "pin-project-lite", "tokio", ] +[[package]] +name = "tokio-websockets" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dad543404f98bfc969aeb71994105c592acfc6c43323fddcd016bb208d1c65cb" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-sink", + "getrandom 0.4.2", + "http 1.4.0", + "httparse", + "rand 0.10.1", + "ring", + "rustls-pki-types", + "sha1_smol", + "simdutf8", + "tokio", + "tokio-rustls 0.26.4", + "tokio-util", +] + [[package]] name = "toml" version = "0.8.23" @@ -2968,8 +5439,8 @@ checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", "serde_spanned", - "toml_datetime", - "toml_edit", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", ] [[package]] @@ -2981,6 +5452,15 @@ dependencies = [ "serde", ] +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + [[package]] name = "toml_edit" version = "0.22.27" @@ -2990,9 +5470,30 @@ dependencies = [ "indexmap", "serde", "serde_spanned", - "toml_datetime", + "toml_datetime 0.6.11", "toml_write", - "winnow", + "winnow 0.7.14", +] + +[[package]] +name = "toml_edit" +version = "0.25.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +dependencies = [ + "indexmap", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.3", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.3", ] [[package]] @@ -3012,7 +5513,7 @@ dependencies = [ "hmac", "rand 0.9.2", "sha1", - "sha2", + "sha2 0.10.9", "url", "urlencoding", ] @@ -3023,6 +5524,11 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper 1.0.2", + "tokio", "tower-layer", "tower-service", ] @@ -3033,11 +5539,14 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "bytes", + "futures-util", "http 1.4.0", "http-body 1.0.1", + "iri-string", "pin-project-lite", + "tower", "tower-layer", "tower-service", "tracing", @@ -3061,6 +5570,7 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -3074,7 +5584,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.114", ] [[package]] @@ -3162,9 +5672,9 @@ dependencies = [ [[package]] name = "typenum" -version = "1.19.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "unicode-ident" @@ -3181,13 +5691,25 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "universal-hash" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "subtle", ] @@ -3251,12 +5773,59 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vergen" +version = "9.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b849a1f6d8639e8de261e81ee0fc881e3e3620db1af9f2e0da015d4382ceaf75" +dependencies = [ + "anyhow", + "derive_builder", + "rustversion", + "vergen-lib", +] + +[[package]] +name = "vergen-gitcl" +version = "9.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ff3b5300a085d6bcd8fc96a507f706a28ae3814693236c9b409db71a1d15b9" +dependencies = [ + "anyhow", + "derive_builder", + "rustversion", + "time", + "vergen", + "vergen-lib", +] + +[[package]] +name = "vergen-lib" +version = "9.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b34a29ba7e9c59e62f229ae1932fb1b8fb8a6fdcc99215a641913f5f5a59a569" +dependencies = [ + "anyhow", + "derive_builder", + "rustversion", +] + [[package]] name = "version_check" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -3281,6 +5850,15 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.108" @@ -3327,7 +5905,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.114", "wasm-bindgen-shared", ] @@ -3340,6 +5918,53 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.13.0", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "web-sys" version = "0.3.85" @@ -3350,6 +5975,25 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webpki-roots" version = "0.25.4" @@ -3374,6 +6018,12 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + [[package]] name = "winapi" version = "0.3.9" @@ -3405,6 +6055,27 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -3418,6 +6089,17 @@ dependencies = [ "windows-strings", ] +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -3426,7 +6108,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.114", ] [[package]] @@ -3437,7 +6119,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.114", ] [[package]] @@ -3446,6 +6128,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + [[package]] name = "windows-registry" version = "0.6.1" @@ -3475,6 +6167,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + [[package]] name = "windows-sys" version = "0.48.0" @@ -3511,6 +6212,21 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + [[package]] name = "windows-targets" version = "0.48.5" @@ -3559,6 +6275,21 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" @@ -3577,6 +6308,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + [[package]] name = "windows_aarch64_msvc" version = "0.48.5" @@ -3595,6 +6332,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + [[package]] name = "windows_i686_gnu" version = "0.48.5" @@ -3625,6 +6368,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + [[package]] name = "windows_i686_msvc" version = "0.48.5" @@ -3643,6 +6392,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + [[package]] name = "windows_x86_64_gnu" version = "0.48.5" @@ -3661,6 +6416,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" @@ -3679,6 +6440,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + [[package]] name = "windows_x86_64_msvc" version = "0.48.5" @@ -3706,6 +6473,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + [[package]] name = "winreg" version = "0.50.0" @@ -3721,6 +6497,103 @@ name = "wit-bindgen" version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn 2.0.114", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.114", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.13.0", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "wmi" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c81b85c57a57500e56669586496bf2abd5cf082b9d32995251185d105208b64" +dependencies = [ + "chrono", + "futures", + "log", + "serde", + "thiserror 2.0.18", + "windows", + "windows-core", +] [[package]] name = "writeable" @@ -3728,6 +6601,43 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +[[package]] +name = "ws_stream_wasm" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c173014acad22e83f16403ee360115b38846fe754e735c5d9d3803fe70c6abc" +dependencies = [ + "async_io_stream", + "futures", + "js-sys", + "log", + "pharos", + "rustc_version", + "send_wrapper", + "thiserror 2.0.18", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "x509-parser" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "ring", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + [[package]] name = "xattr" version = "1.6.1" @@ -3738,6 +6648,31 @@ dependencies = [ "rustix", ] +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + +[[package]] +name = "xmltree" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7d8a75eaf6557bb84a65ace8609883db44a29951042ada9b393151532e41fcb" +dependencies = [ + "xml-rs", +] + +[[package]] +name = "yasna" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" +dependencies = [ + "bit-vec", + "time", +] + [[package]] name = "yoke" version = "0.8.1" @@ -3757,7 +6692,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.114", "synstructure", ] @@ -3784,7 +6719,7 @@ checksum = "2c7962b26b0a8685668b671ee4b54d007a67d4eaf05fda79ac0ecf41e32270f1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.114", ] [[package]] @@ -3804,28 +6739,28 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.114", "synstructure", ] [[package]] name = "zeroize" -version = "1.8.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" dependencies = [ "zeroize_derive", ] [[package]] name = "zeroize_derive" -version = "1.4.3" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.114", ] [[package]] @@ -3858,7 +6793,7 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.114", ] [[package]] diff --git a/core/archipelago/Cargo.toml b/core/archipelago/Cargo.toml index 4bf5e0b3..d3a1e897 100644 --- a/core/archipelago/Cargo.toml +++ b/core/archipelago/Cargo.toml @@ -17,7 +17,7 @@ default = [] # is empty and every fetch goes straight to the origin HTTP path (today's # behaviour). Attach the optional iroh / iroh-blobs deps to this feature when # wiring the IrohProvider. -iroh-swarm = [] +iroh-swarm = ["dep:iroh", "dep:iroh-blobs"] [dependencies] # Core dependencies @@ -117,6 +117,12 @@ sd-notify = "0.4" # Trait objects for async methods (container orchestrator trait, Step 4) async-trait = "0.1" +# DHT Phase 2: iroh-blobs peer swarm engine. OPTIONAL — only pulled in by the +# `iroh-swarm` feature (off by default). Heavy QUIC dep tree; kept behind the +# flag so the default fleet build is unaffected until the PoC is measured. +iroh = { version = "1", optional = true } +iroh-blobs = { version = "0.103", optional = true } + [dev-dependencies] tokio-test = "0.4" tempfile = "3.10" diff --git a/core/archipelago/src/swarm/iroh_provider.rs b/core/archipelago/src/swarm/iroh_provider.rs new file mode 100644 index 00000000..60b8418b --- /dev/null +++ b/core/archipelago/src/swarm/iroh_provider.rs @@ -0,0 +1,132 @@ +//! iroh-blobs swarm provider — the DHT Phase 2 engine, gated behind the +//! `iroh-swarm` feature (heavy QUIC dep tree, off by default). +//! +//! Stands up a real iroh node: binds a QUIC [`Endpoint`], opens a persistent +//! blob [`FsStore`] under `data_dir/iroh-blobs`, and serves blobs over the +//! iroh-blobs protocol — so a node that *fetches* content also *seeds* it +//! afterwards. Content is addressed by BLAKE3 ([`Hash`]) and range-verified by +//! iroh on arrival. +//! +//! This provider is an optimization beneath the origin HTTP path: the [`super`] +//! swarm seam falls back to origin whenever [`try_fetch`](IrohProvider::try_fetch) +//! returns `Ok(false)` (no known seeds) or `Err` (transient swarm failure). +//! +//! ## Discovery boundary (Phase 3) +//! Downloading needs the [`EndpointId`]s of peers that hold the hash. That +//! discovery — design Phase 3, *signed Nostr advertisement events* mapping +//! `{content-hash → provider endpoint}` — is injected via [`ProviderDiscovery`]. +//! Until it is wired, discovery yields nothing and every fetch defers to origin, +//! so enabling the feature is safe (never worse than today). + +use std::path::Path; +use std::sync::Arc; + +use anyhow::Result; +use async_trait::async_trait; +use iroh::{endpoint::presets, protocol::Router, Endpoint, EndpointId}; +use iroh_blobs::{store::fs::FsStore, BlobsProtocol, Hash}; + +use super::BlobProvider; +use crate::content_hash::{ContentDigest, HashAlg}; + +/// Resolves which peers are believed to hold a given content hash. +/// +/// Phase 3 (signed Nostr advertisement events) provides the production impl; +/// `None` discovery means "origin-only" — a safe default. +pub trait ProviderDiscovery: Send + Sync { + /// Candidate seed endpoints for `hash` (may be empty). + fn providers_for(&self, hash: &Hash) -> Vec; +} + +/// Fetches content-addressed blobs from the iroh swarm, and seeds what it has. +#[allow(dead_code)] // constructed once Phase 3 discovery is wired into providers() +pub struct IrohProvider { + endpoint: Endpoint, + store: FsStore, + /// Kept alive so the node keeps accepting blob-protocol connections (seeds). + _router: Router, + discovery: Option>, +} + +#[allow(dead_code)] +impl IrohProvider { + /// Bind an iroh endpoint, open the persistent blob store at + /// `data_dir/iroh-blobs`, and start serving blobs (seed capability). + pub async fn new( + data_dir: &Path, + discovery: Option>, + ) -> Result { + let root = data_dir.join("iroh-blobs"); + tokio::fs::create_dir_all(&root).await.ok(); + + let store = FsStore::load(&root) + .await + .map_err(|e| anyhow::anyhow!("open iroh blob store: {e}"))?; + + let endpoint = Endpoint::bind(presets::N0) + .await + .map_err(|e| anyhow::anyhow!("bind iroh endpoint: {e}"))?; + + // Serve blobs: a node that fetches a blob can then seed it to others. + let blobs = BlobsProtocol::new(&store, None); + let router = Router::builder(endpoint.clone()) + .accept(iroh_blobs::ALPN, blobs) + .spawn(); + + Ok(Self { + endpoint, + store, + _router: router, + discovery, + }) + } + + /// This node's iroh endpoint id — what Phase 3 advertises as a seed address. + pub fn endpoint_id(&self) -> EndpointId { + self.endpoint.id() + } +} + +#[async_trait] +impl BlobProvider for IrohProvider { + fn name(&self) -> &str { + "iroh" + } + + async fn try_fetch(&self, digest: &ContentDigest, dest: &Path) -> Result { + // iroh addresses content by BLAKE3. A sha256-only digest isn't fetchable + // from the swarm — defer to origin. + if digest.alg != HashAlg::Blake3 { + return Ok(false); + } + let raw = hex::decode(&digest.hex).map_err(|e| anyhow::anyhow!("digest hex: {e}"))?; + let arr: [u8; 32] = raw + .as_slice() + .try_into() + .map_err(|_| anyhow::anyhow!("blake3 digest must be 32 bytes"))?; + let hash = Hash::from_bytes(arr); + + // Who has it? Without discovery (Phase 3) this is empty → origin wins. + let providers = match &self.discovery { + Some(d) => d.providers_for(&hash), + None => Vec::new(), + }; + if providers.is_empty() { + return Ok(false); + } + + // Fetch (range-verified by iroh) then export the verified blob to the + // staging path the caller expects. The seam re-verifies the digest. + let downloader = self.store.downloader(&self.endpoint); + downloader + .download(hash, providers) + .await + .map_err(|e| anyhow::anyhow!("iroh swarm download: {e}"))?; + self.store + .blobs() + .export(hash, dest) + .await + .map_err(|e| anyhow::anyhow!("export blob to staging: {e}"))?; + Ok(true) + } +} diff --git a/core/archipelago/src/swarm/mod.rs b/core/archipelago/src/swarm/mod.rs index 77f3ae8d..a142fef9 100644 --- a/core/archipelago/src/swarm/mod.rs +++ b/core/archipelago/src/swarm/mod.rs @@ -27,6 +27,9 @@ use tracing::{debug, info, warn}; use crate::content_hash::ContentDigest; +#[cfg(feature = "iroh-swarm")] +pub mod iroh_provider; + /// Which source ultimately served the content. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum FetchSource { From 9fa56a8274054118c016de914a61b9ea6a84fc53 Mon Sep 17 00:00:00 2001 From: archipelago Date: Tue, 16 Jun 2026 15:13:35 -0400 Subject: [PATCH 09/14] =?UTF-8?q?feat(dht):=20Phase=203=20core=20=E2=80=94?= =?UTF-8?q?=20signed=20Nostr=20seed-advertisement=20protocol?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The discovery wire format that feeds the swarm's ProviderDiscovery seam: a node announces 'I seed blake3 H from iroh endpoint E' as a signed NIP-33 addressable Nostr event. Scope is releases/catalog content ONLY (decided 2026-06-16) — never private user blobs. - swarm/seed_advert.rs: kind 30081, d-tag = blake3 hex (one current advert per author+hash, latest-replaces), content {"v":1,"endpoint_id":...}. advertisement_builder / advertisement_filter / parse_endpoint_id / endpoint_ids_from_events (dedup). Endpoint ids stay opaque strings so the protocol is dep-light + unit-testable on the default build. 4/4 tests pass (sign->parse roundtrip, filter targeting, reject wrong-kind/ empty, dedup across nodes). Next (task #12): gated NostrSeedDiscovery glue (query relays, parse ids -> iroh::EndpointId), publish path, wire swarm::providers(). Co-Authored-By: Claude Opus 4.8 (1M context) --- core/archipelago/src/swarm/mod.rs | 2 + core/archipelago/src/swarm/seed_advert.rs | 134 ++++++++++++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 core/archipelago/src/swarm/seed_advert.rs diff --git a/core/archipelago/src/swarm/mod.rs b/core/archipelago/src/swarm/mod.rs index a142fef9..8abe1068 100644 --- a/core/archipelago/src/swarm/mod.rs +++ b/core/archipelago/src/swarm/mod.rs @@ -27,6 +27,8 @@ use tracing::{debug, info, warn}; use crate::content_hash::ContentDigest; +pub mod seed_advert; + #[cfg(feature = "iroh-swarm")] pub mod iroh_provider; diff --git a/core/archipelago/src/swarm/seed_advert.rs b/core/archipelago/src/swarm/seed_advert.rs new file mode 100644 index 00000000..0f425b7c --- /dev/null +++ b/core/archipelago/src/swarm/seed_advert.rs @@ -0,0 +1,134 @@ +//! Phase 3 discovery — signed Nostr "seed advertisement" events. +//! +//! A node that holds a PUBLIC release / app-image blob (addressed by BLAKE3) +//! announces "I can seed hash H from iroh endpoint E" as a signed, NIP-33 +//! addressable Nostr event. **Scope is releases/catalog content ONLY** — never +//! private user blobs (decided 2026-06-16): smallest privacy surface, covers +//! the OTA + app-install use-cases. Discovery queries these events to find +//! swarm seeds for a hash; the iroh provider then dials those endpoints. +//! +//! Event shape (NIP-33 addressable, kind [`ARCHIPELAGO_SEED_KIND`]): +//! - `d` tag = blake3 hex of the content → one current advert per (author, hash) +//! - content = `{"v":1,"endpoint_id":""}` +//! - author pubkey = the node's seed-derived Nostr identity (signs the event) +//! +//! Endpoint ids stay opaque strings here so this protocol layer builds/parses/ +//! publishes/queries WITHOUT the heavy iroh dep; only the `iroh-swarm` +//! discovery glue parses the string into an `iroh::EndpointId`. + +// The publish/query path that calls these lives behind `iroh-swarm` (it needs +// the node's iroh EndpointId), so in the default build they're exercised only +// by unit tests — allow them to stand without a production caller. +#![allow(dead_code)] + +use nostr_sdk::{Event, EventBuilder, Filter, Kind, Tag}; +use serde::{Deserialize, Serialize}; + +/// NIP-33 addressable kind for Archipelago seed advertisements. +/// Distinct from the node-discovery app-data kind (30078). +pub const ARCHIPELAGO_SEED_KIND: u16 = 30081; + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct AdvertContent { + v: u8, + endpoint_id: String, +} + +/// Build the (unsigned) advertisement event for `blake3_hex` served from +/// `endpoint_id`. Sign with the node's Nostr key (`.sign_with_keys()` / +/// `.sign()`) or publish via `client.send_event_builder()`. +pub fn advertisement_builder(blake3_hex: &str, endpoint_id: &str) -> EventBuilder { + let content = serde_json::to_string(&AdvertContent { + v: 1, + endpoint_id: endpoint_id.to_string(), + }) + .expect("serialize advert content"); + EventBuilder::new(Kind::Custom(ARCHIPELAGO_SEED_KIND), content) + .tag(Tag::identifier(blake3_hex.to_string())) +} + +/// Filter matching all current seed advertisements for `blake3_hex` (one per +/// advertising node; NIP-33 latest-replaces per author). +pub fn advertisement_filter(blake3_hex: &str) -> Filter { + Filter::new() + .kind(Kind::Custom(ARCHIPELAGO_SEED_KIND)) + .identifier(blake3_hex.to_string()) +} + +/// Extract the advertised endpoint id from an event, or `None` if it is the +/// wrong kind or malformed. +pub fn parse_endpoint_id(event: &Event) -> Option { + if event.kind != Kind::Custom(ARCHIPELAGO_SEED_KIND) { + return None; + } + serde_json::from_str::(&event.content) + .ok() + .map(|c| c.endpoint_id) + .filter(|s| !s.is_empty()) +} + +/// Collect the unique advertised endpoint ids across a set of events, skipping +/// malformed ones. Order-preserving, de-duplicated. +pub fn endpoint_ids_from_events<'a>(events: impl IntoIterator) -> Vec { + let mut seen = std::collections::HashSet::new(); + let mut out = Vec::new(); + for ev in events { + if let Some(id) = parse_endpoint_id(ev) { + if seen.insert(id.clone()) { + out.push(id); + } + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use nostr_sdk::Keys; + + #[test] + fn build_sign_parse_roundtrip() { + let keys = Keys::generate(); + let hash = "a".repeat(64); + let endpoint = "node-example-endpoint-id"; + let event = advertisement_builder(&hash, endpoint) + .sign_with_keys(&keys) + .unwrap(); + assert_eq!(event.kind, Kind::Custom(ARCHIPELAGO_SEED_KIND)); + assert_eq!(parse_endpoint_id(&event).as_deref(), Some(endpoint)); + } + + #[test] + fn filter_targets_the_hash_dtag_and_kind() { + let hash = "b".repeat(64); + let json = serde_json::to_string(&advertisement_filter(&hash)).unwrap(); + assert!(json.contains(&hash), "filter must target the hash d-tag"); + assert!(json.contains("30081"), "filter must constrain the seed kind"); + } + + #[test] + fn parse_rejects_wrong_kind_and_empty_endpoint() { + let keys = Keys::generate(); + let wrong_kind = EventBuilder::new(Kind::Custom(1), "{}") + .sign_with_keys(&keys) + .unwrap(); + assert_eq!(parse_endpoint_id(&wrong_kind), None); + let empty_endpoint = advertisement_builder(&"c".repeat(64), "") + .sign_with_keys(&keys) + .unwrap(); + assert_eq!(parse_endpoint_id(&empty_endpoint), None); + } + + #[test] + fn dedups_endpoint_ids_across_events() { + let a = Keys::generate(); + let b = Keys::generate(); + let hash = "d".repeat(64); + let e1 = advertisement_builder(&hash, "endpoint-A").sign_with_keys(&a).unwrap(); + let e2 = advertisement_builder(&hash, "endpoint-A").sign_with_keys(&b).unwrap(); + let e3 = advertisement_builder(&hash, "endpoint-B").sign_with_keys(&b).unwrap(); + let ids = endpoint_ids_from_events([&e1, &e2, &e3]); + assert_eq!(ids, vec!["endpoint-A".to_string(), "endpoint-B".to_string()]); + } +} From f14829542bcafe09000c22188553ac4fd9202fb2 Mon Sep 17 00:00:00 2001 From: archipelago Date: Tue, 16 Jun 2026 15:18:00 -0400 Subject: [PATCH 10/14] =?UTF-8?q?docs(dht):=20RESUME=20checkpoint=20?= =?UTF-8?q?=E2=80=94=20state,=20next=20steps,=20build/worktree=20rules?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single source of truth for picking the DHT work back up after a restart: worktree/branch rules, all phase commits, the exact next task (#12 Phase 3 glue), build-time facts, and the Phase 0 go-live ceremony. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/dht-RESUME.md | 106 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 docs/dht-RESUME.md diff --git a/docs/dht-RESUME.md b/docs/dht-RESUME.md new file mode 100644 index 00000000..ed4a91a4 --- /dev/null +++ b/docs/dht-RESUME.md @@ -0,0 +1,106 @@ +# 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 -- ` + (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/`. + +## NEXT STEP — task #12 (Phase 3 glue + wiring) + +Implement, in the worktree: + +1. **`NostrSeedDiscovery`** (feature-gated, in `swarm/iroh_provider.rs` or a new + `swarm/discovery.rs`): implement the `ProviderDiscovery` trait by querying relays + with `seed_advert::advertisement_filter(hash)`, then + `seed_advert::endpoint_ids_from_events(...)` → parse each string into + `iroh::EndpointId` (`EndpointId::from_str` / parse). Skip ids that don't parse. + - **NOTE:** `ProviderDiscovery::providers_for` is currently **sync**. The relay + query is async → either change the trait to `#[async_trait] async fn`, or back + it with an in-memory cache refreshed by a background subscription. Async trait + is cleaner (the caller `try_fetch` is already async). +2. **Publish path:** when a node finishes downloading / already holds a public + release/app-image blob, publish `seed_advert::advertisement_builder(blake3, my_endpoint_id)` + signed with the node's Nostr key (`nostr_discovery.rs` has the + `load_or_create_nostr_keys` + `Client` + `send_event_builder` patterns to reuse). + Scope: **releases/catalog blobs only** — never private user blobs. +3. **Wire `swarm::providers()`** to construct an `IrohProvider` (with the + `NostrSeedDiscovery`) from runtime config — needs an enable flag + relay list + + data_dir. Likely make `providers()` async / build it once at startup and pass a + handle into the update path. Until this is wired, `providers()` returns empty and + everything uses origin (safe). + +Then verify: `cargo build --features iroh-swarm` + `cargo test --bin archipelago -- swarm::`. + +## 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. From be3ebd7fe0807d83000adb98ceedebb24d1991af Mon Sep 17 00:00:00 2001 From: archipelago Date: Wed, 17 Jun 2026 04:47:18 -0400 Subject: [PATCH 11/14] feat(dht): Phase 3 discovery glue + paid swarm serving Phase 3 wiring (task #12): - NostrSeedDiscovery: async ProviderDiscovery that queries relays for signed seed adverts and parses endpoint ids (swarm/iroh_provider.rs, seed_advert.rs). - seed_and_advertise publish path; dep-free fetch/publish helpers reuse the node's Nostr identity (build_nostr_client/load_or_create_nostr_keys made pub(crate)). - swarm::init builds the IrohProvider once into a OnceLock runtime; providers() returns it; announce_held_blob() is called from update.rs after a release component passes both hash gates. - config swarm_enabled (ARCHIPELAGO_SWARM_ENABLED, default off); server.rs init. Paid swarm serving (Phase 4 step F): - swarm/paid.rs gates the iroh-blobs provider through streaming::gate, intercepting connect + GET (peer push hard-disabled). Free by default (content-download service disabled); denies unpaid peers when enabled; fails open on internal error so a payment fault never blocks distribution. Wired into IrohProvider::new. All iroh code behind the iroh-swarm feature; the default build is inert. Default build clean; --features iroh-swarm: 11/11 swarm tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- core/archipelago/src/config.rs | 14 ++ core/archipelago/src/nostr_discovery.rs | 4 +- core/archipelago/src/server.rs | 14 ++ core/archipelago/src/swarm/iroh_provider.rs | 102 +++++++++- core/archipelago/src/swarm/mod.rs | 131 ++++++++++++- core/archipelago/src/swarm/paid.rs | 194 ++++++++++++++++++++ core/archipelago/src/swarm/seed_advert.rs | 91 ++++++++- core/archipelago/src/update.rs | 4 + 8 files changed, 540 insertions(+), 14 deletions(-) create mode 100644 core/archipelago/src/swarm/paid.rs diff --git a/core/archipelago/src/config.rs b/core/archipelago/src/config.rs index 6ad480cb..1b19b1ff 100644 --- a/core/archipelago/src/config.rs +++ b/core/archipelago/src/config.rs @@ -70,6 +70,13 @@ pub struct Config { /// on .228 + .198. See `project_v1_7_52_phase3_quadlet_design`. #[serde(default)] pub use_quadlet_backends: bool, + /// DHT swarm-assist (Phase 3): when true AND the binary was built with the + /// `iroh-swarm` feature, stand up an iroh-blobs provider that fetches release + /// blobs peer-to-peer (origin always wins) and seeds them via signed Nostr + /// adverts. Off by default; with the feature absent this is inert. Reuses + /// `nostr_relays` + `nostr_tor_proxy` for discovery transport. + #[serde(default)] + pub swarm_enabled: bool, } impl Config { @@ -182,6 +189,12 @@ impl Config { config.nostr_tor_proxy = if s.is_empty() { None } else { Some(s) }; } + // DHT swarm-assist (Phase 3). Opt-in: only takes effect when the binary + // was also built with the `iroh-swarm` feature; otherwise inert. + if let Ok(v) = std::env::var("ARCHIPELAGO_SWARM_ENABLED") { + config.swarm_enabled = parse_truthy_env(&v); + } + // Phase 3.2 of v1.7.52. Truthy values (1, true, yes, on — case-insensitive) // route backend installs through the Quadlet path without requiring a // config.json edit + archipelago.service restart (which would trigger @@ -241,6 +254,7 @@ impl Default for Config { ], nostr_tor_proxy: Some("127.0.0.1:9050".into()), use_quadlet_backends: false, + swarm_enabled: false, } } } diff --git a/core/archipelago/src/nostr_discovery.rs b/core/archipelago/src/nostr_discovery.rs index dbd9873a..13a84753 100644 --- a/core/archipelago/src/nostr_discovery.rs +++ b/core/archipelago/src/nostr_discovery.rs @@ -27,7 +27,7 @@ const D_TAG: &str = "archipelago-node"; const LEGACY_RELAYS: &[&str] = &["wss://relay.damus.io", "wss://relay.nostr.info"]; /// Load or create Nostr keys (secp256k1) for node discovery. -async fn load_or_create_nostr_keys(identity_dir: &Path) -> Result { +pub(crate) async fn load_or_create_nostr_keys(identity_dir: &Path) -> Result { let secret_path = identity_dir.join(NOSTR_SECRET_FILE); let pub_path = identity_dir.join(NOSTR_PUB_FILE); @@ -78,7 +78,7 @@ async fn load_nostr_keys_if_exists(identity_dir: &Path) -> Result> /// Publish a replaceable event with empty content to overwrite/revoke previously published data. /// Uses NIP-33: same kind + d-tag + author = latest replaces. Sends to LEGACY_RELAYS only. /// Requires tor_proxy to avoid leaking IP to relay operators. -fn build_nostr_client(keys: Keys, tor_proxy: Option<&str>) -> Result { +pub(crate) fn build_nostr_client(keys: Keys, tor_proxy: Option<&str>) -> Result { let client = if let Some(proxy_str) = tor_proxy { let addr = parse_proxy_addr(proxy_str) .ok_or_else(|| anyhow::anyhow!("Invalid Nostr Tor proxy: {}", proxy_str))?; diff --git a/core/archipelago/src/server.rs b/core/archipelago/src/server.rs index 113eb891..dfdc0501 100644 --- a/core/archipelago/src/server.rs +++ b/core/archipelago/src/server.rs @@ -150,6 +150,20 @@ impl Server { } } + // DHT swarm-assist (Phase 3): build the iroh provider once at startup so + // release downloads can fetch from peers (origin always wins) and seed + // what they hold. Inert unless built with `iroh-swarm` AND swarm_enabled. + if let Err(e) = crate::swarm::init( + &config.data_dir, + &config.nostr_relays, + config.nostr_tor_proxy.as_deref(), + config.swarm_enabled, + ) + .await + { + tracing::warn!("Swarm init (non-fatal, falling back to origin-only): {}", e); + } + // Revoke any previously published Nostr data (runs before publish so revocation is not overwritten) let identity_dir = config.data_dir.join("identity"); let tor_proxy_revoke = config.nostr_tor_proxy.clone(); diff --git a/core/archipelago/src/swarm/iroh_provider.rs b/core/archipelago/src/swarm/iroh_provider.rs index 60b8418b..5dbc09ab 100644 --- a/core/archipelago/src/swarm/iroh_provider.rs +++ b/core/archipelago/src/swarm/iroh_provider.rs @@ -19,6 +19,7 @@ //! so enabling the feature is safe (never worse than today). use std::path::Path; +use std::str::FromStr; use std::sync::Arc; use anyhow::Result; @@ -31,11 +32,51 @@ use crate::content_hash::{ContentDigest, HashAlg}; /// Resolves which peers are believed to hold a given content hash. /// -/// Phase 3 (signed Nostr advertisement events) provides the production impl; -/// `None` discovery means "origin-only" — a safe default. +/// Phase 3 (signed Nostr advertisement events) provides the production impl +/// [`NostrSeedDiscovery`]; `None` discovery means "origin-only" — a safe +/// default. The query is async (it hits relays), so the trait is async. +#[async_trait] pub trait ProviderDiscovery: Send + Sync { /// Candidate seed endpoints for `hash` (may be empty). - fn providers_for(&self, hash: &Hash) -> Vec; + async fn providers_for(&self, hash: &Hash) -> Vec; +} + +/// Production [`ProviderDiscovery`]: reads signed seed advertisements from Nostr +/// relays and parses the advertised endpoint-id strings into [`EndpointId`]s. +/// +/// Unparseable ids are skipped (an advert from an incompatible/garbage peer must +/// not abort discovery). Reuses the node's existing relay list + Tor proxy. +pub struct NostrSeedDiscovery { + relays: Vec, + tor_proxy: Option, +} + +impl NostrSeedDiscovery { + pub fn new(relays: Vec, tor_proxy: Option) -> Self { + Self { relays, tor_proxy } + } +} + +#[async_trait] +impl ProviderDiscovery for NostrSeedDiscovery { + async fn providers_for(&self, hash: &Hash) -> Vec { + let hex = hash.to_hex(); + let ids = super::seed_advert::fetch_seed_endpoint_ids( + &self.relays, + self.tor_proxy.as_deref(), + &hex, + ) + .await; + ids.into_iter() + .filter_map(|s| match EndpointId::from_str(&s) { + Ok(id) => Some(id), + Err(e) => { + tracing::debug!("swarm: skipping unparseable seed endpoint id {s}: {e}"); + None + } + }) + .collect() + } } /// Fetches content-addressed blobs from the iroh swarm, and seeds what it has. @@ -68,7 +109,11 @@ impl IrohProvider { .map_err(|e| anyhow::anyhow!("bind iroh endpoint: {e}"))?; // Serve blobs: a node that fetches a blob can then seed it to others. - let blobs = BlobsProtocol::new(&store, None); + // The event sender gates each request through the ecash `streaming` layer + // — free by default, paid only if the operator priced `content-download` + // (Networking Profits → Settings). It also hard-disables peer writes. + let event_sender = super::paid::gated_event_sender(data_dir.to_path_buf(), (*store).clone()); + let blobs = BlobsProtocol::new(&store, Some(event_sender)); let router = Router::builder(endpoint.clone()) .accept(iroh_blobs::ALPN, blobs) .spawn(); @@ -85,6 +130,53 @@ impl IrohProvider { pub fn endpoint_id(&self) -> EndpointId { self.endpoint.id() } + + /// Import a held PUBLIC blob into the seed store and advertise it on Nostr so + /// other nodes can fetch it from us. Call this only for releases/catalog + /// content (the design's privacy scope) — never private user blobs. + /// + /// Importing makes us an actual seed: a node that downloaded a release from + /// the HTTP origin can now serve it to peers over iroh-blobs. The advert maps + /// `blake3_hex → this endpoint id`. Defensive check: the bytes we import must + /// hash to what we advertise, so a path/hash mismatch can never publish a lie. + pub async fn seed_and_advertise( + &self, + path: &Path, + blake3_hex: &str, + identity_dir: &Path, + relays: &[String], + tor_proxy: Option<&str>, + ) -> Result<()> { + let expected = { + let raw = hex::decode(blake3_hex).map_err(|e| anyhow::anyhow!("blake3 hex: {e}"))?; + let arr: [u8; 32] = raw + .as_slice() + .try_into() + .map_err(|_| anyhow::anyhow!("blake3 digest must be 32 bytes"))?; + Hash::from_bytes(arr) + }; + let info = self + .store + .blobs() + .add_path(path) + .await + .map_err(|e| anyhow::anyhow!("import blob into seed store: {e}"))?; + if info.hash != expected { + anyhow::bail!( + "imported blob hash {} != advertised {}", + info.hash.to_hex(), + blake3_hex + ); + } + super::seed_advert::publish_seed_advert( + identity_dir, + relays, + tor_proxy, + blake3_hex, + &self.endpoint_id().to_string(), + ) + .await + } } #[async_trait] @@ -108,7 +200,7 @@ impl BlobProvider for IrohProvider { // Who has it? Without discovery (Phase 3) this is empty → origin wins. let providers = match &self.discovery { - Some(d) => d.providers_for(&hash), + Some(d) => d.providers_for(&hash).await, None => Vec::new(), }; if providers.is_empty() { diff --git a/core/archipelago/src/swarm/mod.rs b/core/archipelago/src/swarm/mod.rs index 8abe1068..45f0ff83 100644 --- a/core/archipelago/src/swarm/mod.rs +++ b/core/archipelago/src/swarm/mod.rs @@ -19,7 +19,7 @@ //! every fetch goes straight to origin — byte-for-byte today's path. use std::path::Path; -use std::sync::Arc; +use std::sync::{Arc, OnceLock}; use anyhow::Result; use async_trait::async_trait; @@ -32,6 +32,9 @@ pub mod seed_advert; #[cfg(feature = "iroh-swarm")] pub mod iroh_provider; +#[cfg(feature = "iroh-swarm")] +pub mod paid; + /// Which source ultimately served the content. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum FetchSource { @@ -55,13 +58,131 @@ pub trait BlobProvider: Send + Sync { async fn try_fetch(&self, digest: &ContentDigest, dest: &Path) -> Result; } +/// Process-wide swarm runtime, built once at startup by [`init`]. Holding the +/// providers here (rather than rebuilding per download) keeps the iroh endpoint +/// + blob store + protocol router alive for the life of the process, so a node +/// keeps *seeding* between downloads. Empty/inert unless the `iroh-swarm` +/// feature is built AND `swarm_enabled` is set. +struct SwarmRuntime { + providers: Vec>, + /// Context for announcing held public blobs; `None` when seeding is off. + #[cfg(feature = "iroh-swarm")] + announce: Option, +} + +#[cfg(feature = "iroh-swarm")] +struct AnnounceCtx { + iroh: Arc, + relays: Vec, + tor_proxy: Option, + identity_dir: std::path::PathBuf, +} + +static RUNTIME: OnceLock = OnceLock::new(); + +/// Build the swarm runtime once, at startup. Idempotent: a second call is a +/// no-op (the first registration wins). Safe to call unconditionally — when the +/// `iroh-swarm` feature is absent, or `enabled` is false, it registers an empty +/// runtime so every fetch goes straight to origin (today's path). +/// +/// `relays` / `tor_proxy` come from the node's Nostr config and double as the +/// seed-advert transport; `data_dir` hosts the persistent iroh blob store under +/// `data_dir/iroh-blobs` and the node identity under `data_dir/identity`. +pub async fn init( + data_dir: &Path, + relays: &[String], + tor_proxy: Option<&str>, + enabled: bool, +) -> Result<()> { + if RUNTIME.get().is_some() { + return Ok(()); + } + + #[cfg(not(feature = "iroh-swarm"))] + { + let _ = (data_dir, relays, tor_proxy); + if enabled { + warn!("swarm: swarm_enabled set but binary built without the `iroh-swarm` feature — staying origin-only"); + } + let _ = RUNTIME.set(SwarmRuntime { providers: Vec::new() }); + return Ok(()); + } + + #[cfg(feature = "iroh-swarm")] + { + if !enabled { + info!("swarm: disabled (swarm_enabled=false) — origin-only"); + let _ = RUNTIME.set(SwarmRuntime { + providers: Vec::new(), + announce: None, + }); + return Ok(()); + } + + let discovery: Arc = + Arc::new(iroh_provider::NostrSeedDiscovery::new( + relays.to_vec(), + tor_proxy.map(str::to_string), + )); + let provider = + Arc::new(iroh_provider::IrohProvider::new(data_dir, Some(discovery)).await?); + info!( + "swarm: iroh provider active (endpoint {}) — swarm-assist enabled, origin always wins", + provider.endpoint_id() + ); + let providers: Vec> = vec![provider.clone()]; + let _ = RUNTIME.set(SwarmRuntime { + providers, + announce: Some(AnnounceCtx { + iroh: provider, + relays: relays.to_vec(), + tor_proxy: tor_proxy.map(str::to_string), + identity_dir: data_dir.join("identity"), + }), + }); + Ok(()) + } +} + /// The ordered list of swarm providers to consult before the origin. /// -/// Empty unless the `iroh-swarm` feature is enabled and a provider has been -/// registered. Today it is always empty — the seam exists so wiring iroh is a -/// localized change rather than a surgery through the download path. +/// Empty until [`init`] registers a provider (needs the `iroh-swarm` feature + +/// `swarm_enabled`). While empty, [`fetch_content_addressed`] goes straight to +/// origin — byte-for-byte today's path. pub fn providers() -> Vec> { - Vec::new() + RUNTIME + .get() + .map(|r| r.providers.clone()) + .unwrap_or_default() +} + +/// Announce that this node now holds a PUBLIC release/catalog blob (addressed by +/// `blake3_hex`, bytes at `path`) so peers can fetch it from us: import it into +/// the seed store and publish a signed Nostr advert. Best-effort and inert +/// unless the iroh provider is active — a failure never affects the install. +/// +/// **Scope:** call only for releases/catalog content, never private user blobs. +pub async fn announce_held_blob(_blake3_hex: &str, _path: &Path) { + #[cfg(feature = "iroh-swarm")] + { + let Some(rt) = RUNTIME.get() else { return }; + let Some(ctx) = rt.announce.as_ref() else { + return; + }; + if let Err(e) = ctx + .iroh + .seed_and_advertise( + _path, + _blake3_hex, + &ctx.identity_dir, + &ctx.relays, + ctx.tor_proxy.as_deref(), + ) + .await + { + warn!("swarm: failed to announce held blob {_blake3_hex}: {e}"); + } + } } /// Fetch content-addressed bytes: swarm-assist, origin always wins. diff --git a/core/archipelago/src/swarm/paid.rs b/core/archipelago/src/swarm/paid.rs new file mode 100644 index 00000000..e77af81d --- /dev/null +++ b/core/archipelago/src/swarm/paid.rs @@ -0,0 +1,194 @@ +//! Paid swarm serving — gate the iroh-blobs provider through the ecash +//! `streaming` payment layer (DHT distribution plan, Phase 4 step F). +//! +//! ## Free by default +//! Serving is **free unless the node operator turns it on** in +//! *Networking Profits → Settings* (which enables the `content-download` +//! streaming service). With that service disabled — the shipped default — +//! [`is_authorized`] returns `true` for everyone and behaviour is byte-for-byte +//! the old open seeder. When it is enabled, a peer must hold an active paid +//! session (opened out-of-band via the `streaming.pay` RPC with a Cashu token) +//! before the swarm will serve them; otherwise the request is refused and they +//! fall back to the HTTP origin. +//! +//! ## How it hooks in +//! iroh-blobs 0.103 lets a provider authorize each request: we pass an +//! [`EventSender`] (built here) to `BlobsProtocol::new`, set the [`EventMask`] +//! to intercept connections + GET requests, and answer each one with +//! `Ok(())` (serve) or `Err(AbortReason::Permission)` (refuse). Peer-initiated +//! writes (`push`) are hard-disabled so a peer can never mutate our store. +//! +//! Scope note: today every swarm blob is a public release/app component, so the +//! gate only ever charges if the operator explicitly priced `content-download`. +//! When IndeeHub films land on the same blob layer (Phase 4), they reuse this +//! exact path. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use iroh::EndpointId; +use iroh_blobs::api::blobs::BlobStatus; +use iroh_blobs::api::Store; +use iroh_blobs::provider::events::{ + AbortReason, ConnectMode, EventMask, EventResult, EventSender, ObserveMode, ProviderMessage, + RequestMode, ThrottleMode, +}; +use iroh_blobs::Hash; + +use crate::streaming::gate::{self, GateResult}; + +/// The streaming pricing service that meters swarm blob serving. Enabling it in +/// the Settings UI is what flips swarm serving from free to paid. +const SERVICE_ID: &str = "content-download"; + +/// Build the gated [`EventSender`] for `BlobsProtocol` and spawn the task that +/// authorizes each blob GET through the ecash gate. +/// +/// `data_dir` locates the pricing/session state; `store` is cloned in to look up +/// blob sizes for metering. The spawned task lives as long as the provider keeps +/// the returned sender alive (i.e. the life of the node). +pub fn gated_event_sender(data_dir: PathBuf, store: Store) -> EventSender { + // Intercept connections + read requests so we can allow/deny per peer & hash. + // `push` (peer writes into our store) is hard-disabled. `throttle`/`observe` + // stay off — we meter coarsely at request time, not per 16 KiB chunk. + let mask = EventMask { + connected: ConnectMode::Intercept, + get: RequestMode::Intercept, + get_many: RequestMode::Intercept, + push: RequestMode::Disabled, + observe: ObserveMode::None, + throttle: ThrottleMode::None, + }; + let (sender, mut rx) = EventSender::channel(64, mask); + tokio::spawn(async move { + // connection_id → remote endpoint id, learned at ClientConnected and used + // to key the paying peer's streaming session on each request. + let mut peers: HashMap> = HashMap::new(); + while let Some(msg) = rx.recv().await { + match msg { + ProviderMessage::ClientConnected(m) => { + peers.insert(m.inner.connection_id, m.inner.endpoint_id); + // Accept the connection; gating happens per request. + let _ = m.tx.send(Ok(())).await; + } + ProviderMessage::ConnectionClosed(m) => { + peers.remove(&m.inner.connection_id); + } + ProviderMessage::GetRequestReceived(m) => { + let peer = peers.get(&m.inner.connection_id).copied().flatten(); + let hash = m.inner.request.hash; + let verdict = authorize(&data_dir, &store, peer, &hash).await; + let _ = m.tx.send(verdict).await; + } + ProviderMessage::GetManyRequestReceived(m) => { + let peer = peers.get(&m.inner.connection_id).copied().flatten(); + // A get-many is all-or-nothing here: authorize on the first hash. + let verdict = match m.inner.request.hashes.first().copied() { + Some(h) => authorize(&data_dir, &store, peer, &h).await, + None => Ok(()), + }; + let _ = m.tx.send(verdict).await; + } + ProviderMessage::PushRequestReceived(m) => { + // Disabled in the mask; refuse defensively if one ever arrives. + let _ = m.tx.send(Err(AbortReason::Permission)).await; + } + // Notify-only variants, observe and throttle: nothing to gate. + _ => {} + } + } + }); + sender +} + +/// Authorize one blob GET, returning the iroh [`EventResult`] +/// (`Ok(())` = serve, `Err(Permission)` = refuse). +async fn authorize( + data_dir: &Path, + store: &Store, + peer: Option, + hash: &Hash, +) -> EventResult { + // Cost = full blob size (coarse, request-time metering). If we don't hold the + // complete blob there's nothing to meter — let iroh serve what it can. + let size = match store.blobs().status(*hash).await { + Ok(BlobStatus::Complete { size }) => size, + _ => 0, + }; + let peer_id = peer + .map(|e| e.to_string()) + .unwrap_or_else(|| "anonymous".to_string()); + if is_authorized(data_dir, &peer_id, size).await { + Ok(()) + } else { + Err(AbortReason::Permission) + } +} + +/// Pure allow/deny decision (no iroh types) — unit-testable without a live node. +async fn is_authorized(data_dir: &Path, peer_id: &str, size: u64) -> bool { + match gate::check_gate(data_dir, peer_id, SERVICE_ID, None, size).await { + // Service disabled (the default) → free for everyone. Or the peer holds an + // active paid session with remaining allotment. + Ok(GateResult::ServiceUnavailable) + | Ok(GateResult::Allowed { .. }) + | Ok(GateResult::PaidAndAllowed { .. }) => true, + // Metered + no/exhausted session: the peer must pay out-of-band first + // (streaming.pay) before the swarm serves them — they fall back to origin. + Ok(_) => false, + // Never let a payment-layer fault break content distribution: fail OPEN + // (serve free) and log. Availability beats revenue when something breaks. + Err(e) => { + tracing::warn!("paid-gate: check errored ({e}); serving free"); + true + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::streaming::pricing::{self, Metric, PricingConfig, ServicePricing}; + + fn content_download(enabled: bool) -> PricingConfig { + PricingConfig { + services: vec![ServicePricing { + service_id: SERVICE_ID.to_string(), + name: "Content Downloads".to_string(), + metric: Metric::Bytes, + step_size: 1_048_576, + price_per_step: 1, + min_steps: 0, + enabled, + description: String::new(), + accepted_mints: vec![], + }], + } + } + + #[tokio::test] + async fn free_when_service_disabled_by_default() { + let dir = tempfile::tempdir().unwrap(); + // No pricing file → defaults → content-download disabled → free for all. + assert!(is_authorized(dir.path(), "peer-a", 1_000_000).await); + } + + #[tokio::test] + async fn free_when_service_explicitly_disabled() { + let dir = tempfile::tempdir().unwrap(); + pricing::save_pricing(dir.path(), &content_download(false)) + .await + .unwrap(); + assert!(is_authorized(dir.path(), "peer-a", 1_048_576).await); + } + + #[tokio::test] + async fn denied_when_metered_and_peer_has_not_paid() { + let dir = tempfile::tempdir().unwrap(); + pricing::save_pricing(dir.path(), &content_download(true)) + .await + .unwrap(); + // Enabled service + no session/token → the swarm refuses; peer uses origin. + assert!(!is_authorized(dir.path(), "peer-b", 1_048_576).await); + } +} diff --git a/core/archipelago/src/swarm/seed_advert.rs b/core/archipelago/src/swarm/seed_advert.rs index 0f425b7c..9f0c9d5a 100644 --- a/core/archipelago/src/swarm/seed_advert.rs +++ b/core/archipelago/src/swarm/seed_advert.rs @@ -21,9 +21,18 @@ // by unit tests — allow them to stand without a production caller. #![allow(dead_code)] -use nostr_sdk::{Event, EventBuilder, Filter, Kind, Tag}; +use std::path::Path; +use std::time::Duration; + +use nostr_sdk::{Event, EventBuilder, Filter, Keys, Kind, Tag}; use serde::{Deserialize, Serialize}; +/// How long to wait for relay connects / event fetches. Matches the rest of the +/// Nostr discovery path so the swarm never stalls the download longer than node +/// discovery already might. +const RELAY_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); +const RELAY_FETCH_TIMEOUT: Duration = Duration::from_secs(15); + /// NIP-33 addressable kind for Archipelago seed advertisements. /// Distinct from the node-discovery app-data kind (30078). pub const ARCHIPELAGO_SEED_KIND: u16 = 30081; @@ -82,10 +91,88 @@ pub fn endpoint_ids_from_events<'a>(events: impl IntoIterator) out } +/// Query `relays` for the current seed advertisements for `blake3_hex` and +/// return the de-duplicated endpoint-id strings (opaque here; the `iroh-swarm` +/// glue parses them into `iroh::EndpointId`). +/// +/// Best-effort by design: an empty relay list, a connect timeout, or a fetch +/// failure all yield an empty list — never an error. The swarm seam treats "no +/// providers" as "use origin", so discovery problems can only ever degrade to +/// today's HTTP path, never block it. +pub async fn fetch_seed_endpoint_ids( + relays: &[String], + tor_proxy: Option<&str>, + blake3_hex: &str, +) -> Vec { + if relays.is_empty() { + return Vec::new(); + } + // Query anonymously — discovery reads public adverts and must not link the + // query back to this node's seed identity. + let anon = Keys::generate(); + let client = match crate::nostr_discovery::build_nostr_client(anon, tor_proxy) { + Ok(c) => c, + Err(e) => { + tracing::warn!("seed-advert: build relay client failed: {e}"); + return Vec::new(); + } + }; + for url in relays { + let _ = client.add_relay(url).await; + } + if tokio::time::timeout(RELAY_CONNECT_TIMEOUT, client.connect()) + .await + .is_err() + { + tracing::warn!("seed-advert: relay connect timed out, continuing anyway"); + } + let events = client + .fetch_events(advertisement_filter(blake3_hex), RELAY_FETCH_TIMEOUT) + .await + .map(|e| e.to_vec()) + .unwrap_or_default(); + client.disconnect().await; + endpoint_ids_from_events(events.iter()) +} + +/// Publish a signed advertisement — "this node can seed `blake3_hex` from +/// `endpoint_id`" — to `relays`, signed with the node's seed-derived Nostr key. +/// +/// **Caller must restrict this to PUBLIC releases/catalog blobs** (the design's +/// privacy scope, decided 2026-06-16) — never private user content. Best-effort: +/// relay failures are logged, not fatal, since seeding is an optimization. +pub async fn publish_seed_advert( + identity_dir: &Path, + relays: &[String], + tor_proxy: Option<&str>, + blake3_hex: &str, + endpoint_id: &str, +) -> anyhow::Result<()> { + if relays.is_empty() { + return Ok(()); + } + let keys = crate::nostr_discovery::load_or_create_nostr_keys(identity_dir).await?; + let client = crate::nostr_discovery::build_nostr_client(keys, tor_proxy)?; + for url in relays { + let _ = client.add_relay(url).await; + } + if tokio::time::timeout(RELAY_CONNECT_TIMEOUT, client.connect()) + .await + .is_err() + { + tracing::warn!("seed-advert: publish relay connect timed out, continuing anyway"); + } + let _ = client + .send_event_builder(advertisement_builder(blake3_hex, endpoint_id)) + .await; + client.disconnect().await; + tracing::info!("seed-advert: announced {blake3_hex} seedable from {endpoint_id}"); + Ok(()) +} + #[cfg(test)] mod tests { use super::*; - use nostr_sdk::Keys; #[test] fn build_sign_parse_roundtrip() { diff --git a/core/archipelago/src/update.rs b/core/archipelago/src/update.rs index 4b83594d..6f77618c 100644 --- a/core/archipelago/src/update.rs +++ b/core/archipelago/src/update.rs @@ -847,6 +847,10 @@ pub async fn download_update(data_dir: &Path) -> Result { download_component_resumable(&client, component, &dest, downloaded).await?; } } + // This is a PUBLIC release blob and it just passed both the BLAKE3 and + // SHA-256 gates — announce that we can now seed it to peers. Best-effort + // and inert unless the iroh swarm is active; never blocks the install. + crate::swarm::announce_held_blob(&digest.hex, &dest).await; } else { download_component_resumable(&client, component, &dest, downloaded).await?; } From 75b78325e47ac1556eb2934161a9b75ac26a3fd1 Mon Sep 17 00:00:00 2001 From: archipelago Date: Wed, 17 Jun 2026 04:48:00 -0400 Subject: [PATCH 12/14] =?UTF-8?q?feat(web5):=20Networking=20Profits=20?= =?UTF-8?q?=E2=86=92=20Settings=20page=20for=20paid=20services?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Settings control to the Networking Profits card that opens a new page where the operator controls what their node charges sats for and how much. Drives the existing streaming.list-services / streaming.configure-service RPCs; "free everything" is the default (all priced services ship disabled, surfaced with a reassurance banner). New route web5/networking-profits + common.settings i18n (en/es). Co-Authored-By: Claude Opus 4.8 (1M context) --- neode-ui/src/locales/en.json | 1 + neode-ui/src/locales/es.json | 1 + neode-ui/src/router/index.ts | 5 + .../web5/Web5NetworkingProfitsSettings.vue | 203 ++++++++++++++++++ neode-ui/src/views/web5/Web5QuickActions.vue | 6 + 5 files changed, 216 insertions(+) create mode 100644 neode-ui/src/views/web5/Web5NetworkingProfitsSettings.vue diff --git a/neode-ui/src/locales/en.json b/neode-ui/src/locales/en.json index 39f9477e..08e92021 100644 --- a/neode-ui/src/locales/en.json +++ b/neode-ui/src/locales/en.json @@ -26,6 +26,7 @@ "back": "Back", "done": "Done", "manage": "Manage", + "settings": "Settings", "connect": "Connect", "connecting": "Connecting...", "disconnect": "Disconnect", diff --git a/neode-ui/src/locales/es.json b/neode-ui/src/locales/es.json index 8aa8f475..c7bd54f7 100644 --- a/neode-ui/src/locales/es.json +++ b/neode-ui/src/locales/es.json @@ -26,6 +26,7 @@ "back": "Volver", "done": "Listo", "manage": "Administrar", + "settings": "Configuración", "connect": "Conectar", "connecting": "Conectando...", "disconnect": "Desconectar", diff --git a/neode-ui/src/router/index.ts b/neode-ui/src/router/index.ts index bc7bf681..f37073b7 100644 --- a/neode-ui/src/router/index.ts +++ b/neode-ui/src/router/index.ts @@ -206,6 +206,11 @@ const router = createRouter({ name: 'credentials', component: () => import('../views/Credentials.vue'), }, + { + path: 'web5/networking-profits', + name: 'networking-profits-settings', + component: () => import('../views/web5/Web5NetworkingProfitsSettings.vue'), + }, { path: 'settings', name: 'settings', diff --git a/neode-ui/src/views/web5/Web5NetworkingProfitsSettings.vue b/neode-ui/src/views/web5/Web5NetworkingProfitsSettings.vue new file mode 100644 index 00000000..39c7e82e --- /dev/null +++ b/neode-ui/src/views/web5/Web5NetworkingProfitsSettings.vue @@ -0,0 +1,203 @@ + + + diff --git a/neode-ui/src/views/web5/Web5QuickActions.vue b/neode-ui/src/views/web5/Web5QuickActions.vue index fa3a9bcc..58263a80 100644 --- a/neode-ui/src/views/web5/Web5QuickActions.vue +++ b/neode-ui/src/views/web5/Web5QuickActions.vue @@ -17,6 +17,12 @@

Content: {{ profitsBreakdown.content_sales_sats.toLocaleString() }} sats

Routing: {{ profitsBreakdown.routing_fees_sats.toLocaleString() }} sats

+
From 1f3b03bc6dc00bfeb8bcba16042448aec4d70a48 Mon Sep 17 00:00:00 2001 From: archipelago Date: Wed, 17 Jun 2026 04:48:18 -0400 Subject: [PATCH 13/14] docs(dht): Phase 4 plan (paid streaming/relay/IndeeHub + cross-mint) + RESUME update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/dht-RESUME.md | 59 +++-- docs/phase4-streaming-ecash-plan.md | 371 ++++++++++++++++++++++++++++ 2 files changed, 408 insertions(+), 22 deletions(-) create mode 100644 docs/phase4-streaming-ecash-plan.md diff --git a/docs/dht-RESUME.md b/docs/dht-RESUME.md index ed4a91a4..cf56cd37 100644 --- a/docs/dht-RESUME.md +++ b/docs/dht-RESUME.md @@ -58,31 +58,46 @@ Design doc: `docs/dht-distribution-design.md` (the full plan). All tests green at each step. Total new modules: `trust/`, `content_hash.rs`, `swarm/`. -## NEXT STEP — task #12 (Phase 3 glue + wiring) +## task #12 — Phase 3 glue + wiring — DONE (2026-06-17, NOT yet committed) -Implement, in the worktree: +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`** (feature-gated, in `swarm/iroh_provider.rs` or a new - `swarm/discovery.rs`): implement the `ProviderDiscovery` trait by querying relays - with `seed_advert::advertisement_filter(hash)`, then - `seed_advert::endpoint_ids_from_events(...)` → parse each string into - `iroh::EndpointId` (`EndpointId::from_str` / parse). Skip ids that don't parse. - - **NOTE:** `ProviderDiscovery::providers_for` is currently **sync**. The relay - query is async → either change the trait to `#[async_trait] async fn`, or back - it with an in-memory cache refreshed by a background subscription. Async trait - is cleaner (the caller `try_fetch` is already async). -2. **Publish path:** when a node finishes downloading / already holds a public - release/app-image blob, publish `seed_advert::advertisement_builder(blake3, my_endpoint_id)` - signed with the node's Nostr key (`nostr_discovery.rs` has the - `load_or_create_nostr_keys` + `Client` + `send_event_builder` patterns to reuse). - Scope: **releases/catalog blobs only** — never private user blobs. -3. **Wire `swarm::providers()`** to construct an `IrohProvider` (with the - `NostrSeedDiscovery`) from runtime config — needs an enable flag + relay list + - data_dir. Likely make `providers()` async / build it once at startup and pass a - handle into the update path. Until this is wired, `providers()` returns empty and - everything uses origin (safe). +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` (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. -Then verify: `cargo build --features iroh-swarm` + `cargo test --bin archipelago -- swarm::`. +**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. ## After Phase 3 diff --git a/docs/phase4-streaming-ecash-plan.md b/docs/phase4-streaming-ecash-plan.md new file mode 100644 index 00000000..c139b9ea --- /dev/null +++ b/docs/phase4-streaming-ecash-plan.md @@ -0,0 +1,371 @@ +# 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: + +1. **Pay sats (ecash) for transport** of streaming film data between nodes. +2. **Networking *through* nodes** — relaying/routing a stream via intermediate peers. +3. 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 consults `streaming/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.rs` already ships a + `content-download` service priced per MB; `streaming/gate.rs` turns a `cashuA` + token into a metered session; `meter.rs` deducts bytes; `profits.rs` records + `StreamingRevenue`. +- ❌ The swarm serving path doesn't consult any of it. `IrohProvider::new` + spins up `BlobsProtocol` that 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`): +1. B wants `blake3:H`. It dials A's endpoint and sends `{want: H, token?: cashuA}`. +2. 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 a `cashuA` and retries. + - `PaidAndAllowed` / `Allowed` (within existing session allotment) → A authorizes + the blob hash for this connection and hands off to iroh-blobs to stream it. +3. A meters served bytes via `meter::record_and_check` and 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-blobs `EventSender` (intercept connect + GET, hard-disable `push`) and + authorizes each request through `streaming::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 into + `IrohProvider::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_addressed` gains 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 via `streaming.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.offer` advert** (Nostr kind 10021 with a `relay` tag + price/MB) — reuse + `streaming/advertisement.rs`; add a `relay-bandwidth` service to `pricing.rs`. +- **`relay.fetch` request** over the existing transport (`PeerRequest` in + `fips/dial.rs`): `{content: blake3:H | url, pay: cashuA}`. The relay runs the + normal `swarm::fetch_content_addressed` (swarm-assist, origin fallback), meters + the bytes through `streaming/gate`, and streams them back to the requester. +- **Accounting:** add a `RelayBytes` metric to `streaming/meter.rs` distinct from + origin `content-download`, so "relay provided" is tracked separately in + `profits.rs` (the doc already separates `routing_fees` from `streaming_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 +1. Pin our own iroh relays (config only). — *days* +2. Single-hop paid `relay.fetch` for film blobs, gated by ecash. — *the core build* +3. 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: + +1. `MintClient::new(B).mint_quote(amount)` → a BOLT11 invoice `inv_B` (pay it to get B tokens). +2. `MintClient::new(A).melt_quote(inv_B)` → cost in A tokens (`amount + fee_reserve`). +3. Select A proofs and `melt` them on A to pay `inv_B` over Lightning. +4. When `inv_B` settles, `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`. + +### Where the swap happens — two models +- **Payer-side swap (recommended default).** Before paying seeder S (whose + `accepted_mints` are advertised via `streaming.advertise` / the gate's + `PaymentRequired.pricing.accepted_mints`), the payer picks the cheapest path: + pay directly if it already holds a token on one of S's mints; otherwise + `swap_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_mints` to 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 (`melt` quote id + `mint` + quote id) so a crash between "paid `inv_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 +1. `*_at(mint_url, …)` helpers + `swap_between_mints` + mint trust list + fee cap. — *the core* +2. Payer-side auto-swap in the payment builder (pick cheapest accepted mint). — *wires §1/§2 to it* +3. Idempotent resume + per-pair liquidity cache. — *hardening* +4. (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.studio` and 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-private` buckets); 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 `.ts` is 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/`**: +```jsonc +{ + "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 like `app_catalog.rs` does (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-catalog` instead of `indeehub.studio`. Cleanest: a small build/runtime + flag or an injected config (same nginx `sub_filter` mechanism 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 + +1. **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 an `EventMask` set to intercept, the provider asks our + handler to authorize each request and we return `EventResult = 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. disable `Push` + so peers can never write into our store). + → **§1 shape (A) is the recommended path** (native, no fork): the accept-side + handler calls `streaming::gate::check_gate("content-download", peer_endpoint, + bytes, token)` and maps `PaymentRequired`/`InsufficientPayment` → + `Err(Permission)`, `Allowed`/`PaidAndAllowed` → `Ok(())`. Peer identity comes + from the `Connection`'s remote endpoint id. (See `iroh_blobs::provider::events`.) +2. **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. +3. **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. +4. **IndeeHub frontend is an external repo** (`~/Projects/indeehub-frontend`, + built into `apps/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). +5. **Pricing defaults & free tier.** What's free (OTA, trusted peers, first N MB?) + vs. paid, and the default sats/MB. `pricing.json` already supports this; needs a + policy. +6. **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.** From 27a61999390ee98b44e8fc0c79894d65c8fd83de Mon Sep 17 00:00:00 2001 From: archipelago Date: Wed, 17 Jun 2026 07:36:31 -0400 Subject: [PATCH 14/14] =?UTF-8?q?feat(dht):=20Phase=204=20=E2=80=94=20paid?= =?UTF-8?q?=20swarm=20streaming=20(cross-mint=20ecash=20+=20Shape-A=20ALPN?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- core/archipelago/src/api/rpc/dispatcher.rs | 1 + core/archipelago/src/api/rpc/streaming.rs | 58 ++ core/archipelago/src/server.rs | 11 + core/archipelago/src/swarm/iroh_provider.rs | 40 +- core/archipelago/src/swarm/mod.rs | 4 + core/archipelago/src/swarm/paid_alpn.rs | 306 ++++++++ core/archipelago/src/swarm/payment.rs | 166 ++++ core/archipelago/src/wallet/ecash.rs | 830 +++++++++++++++++++- docs/dht-RESUME.md | 108 +++ docs/phase4-streaming-ecash-plan.md | 9 + 10 files changed, 1528 insertions(+), 5 deletions(-) create mode 100644 core/archipelago/src/swarm/paid_alpn.rs create mode 100644 core/archipelago/src/swarm/payment.rs diff --git a/core/archipelago/src/api/rpc/dispatcher.rs b/core/archipelago/src/api/rpc/dispatcher.rs index 88f1fd70..cdc65627 100644 --- a/core/archipelago/src/api/rpc/dispatcher.rs +++ b/core/archipelago/src/api/rpc/dispatcher.rs @@ -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, diff --git a/core/archipelago/src/api/rpc/streaming.rs b/core/archipelago/src/api/rpc/streaming.rs index 866945bb..6b5aa84e 100644 --- a/core/archipelago/src/api/rpc/streaming.rs +++ b/core/archipelago/src/api/rpc/streaming.rs @@ -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, + ) -> Result { + let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; + let accepted_mints: Vec = 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 { diff --git a/core/archipelago/src/server.rs b/core/archipelago/src/server.rs index dfdc0501..4e699da6 100644 --- a/core/archipelago/src/server.rs +++ b/core/archipelago/src/server.rs @@ -164,6 +164,17 @@ impl Server { tracing::warn!("Swarm init (non-fatal, falling back to origin-only): {}", e); } + // Resume any cross-mint ecash swap interrupted by a previous crash + // (paid the source mint but never claimed the target tokens). Best-effort. + match crate::wallet::ecash::resume_pending_swaps(&config.data_dir).await { + Ok(0) => {} + Ok(reclaimed) => tracing::info!( + "Resumed interrupted cross-mint swaps: reclaimed {} sats", + reclaimed + ), + Err(e) => tracing::debug!("resume_pending_swaps (non-fatal): {}", e), + } + // Revoke any previously published Nostr data (runs before publish so revocation is not overwritten) let identity_dir = config.data_dir.join("identity"); let tor_proxy_revoke = config.nostr_tor_proxy.clone(); diff --git a/core/archipelago/src/swarm/iroh_provider.rs b/core/archipelago/src/swarm/iroh_provider.rs index 5dbc09ab..afc57cc3 100644 --- a/core/archipelago/src/swarm/iroh_provider.rs +++ b/core/archipelago/src/swarm/iroh_provider.rs @@ -27,6 +27,7 @@ use async_trait::async_trait; use iroh::{endpoint::presets, protocol::Router, Endpoint, EndpointId}; use iroh_blobs::{store::fs::FsStore, BlobsProtocol, Hash}; +use super::payment::PaymentPolicy; use super::BlobProvider; use crate::content_hash::{ContentDigest, HashAlg}; @@ -87,6 +88,13 @@ pub struct IrohProvider { /// Kept alive so the node keeps accepting blob-protocol connections (seeds). _router: Router, discovery: Option>, + /// Where pricing/session/wallet state lives — for paid-fetch negotiation. + data_dir: std::path::PathBuf, + /// Willingness to pay swarm peers when fetching. Defaults to + /// [`PaymentPolicy::free`]: never pay (releases/catalog stay free), so a + /// seeder that prices a blob is skipped → origin. A future film fetch can + /// pass a real budget. + pay_policy: PaymentPolicy, } #[allow(dead_code)] @@ -114,8 +122,13 @@ impl IrohProvider { // (Networking Profits → Settings). It also hard-disables peer writes. let event_sender = super::paid::gated_event_sender(data_dir.to_path_buf(), (*store).clone()); let blobs = BlobsProtocol::new(&store, Some(event_sender)); + // Shape-A paid negotiation rides a second ALPN on the same endpoint so a + // downloader can pay (open a session) before the blob-GET above serves it. + let paid = + super::paid_alpn::PaidBlobsProtocol::new(data_dir.to_path_buf(), (*store).clone()); let router = Router::builder(endpoint.clone()) .accept(iroh_blobs::ALPN, blobs) + .accept(super::paid_alpn::PAID_ALPN, paid) .spawn(); Ok(Self { @@ -123,6 +136,8 @@ impl IrohProvider { store, _router: router, discovery, + data_dir: data_dir.to_path_buf(), + pay_policy: PaymentPolicy::free(), }) } @@ -207,11 +222,34 @@ impl BlobProvider for IrohProvider { return Ok(false); } + // Shape-A: negotiate paid access with each candidate. Best-effort and + // additive — a peer is dropped only if it explicitly requires a payment + // we won't make under `pay_policy` (free by default → priced seeders are + // skipped). Connect/protocol failures keep the peer; the blob-GET gate is + // the real enforcement and a refused GET still falls back to origin. + let mut allowed = Vec::with_capacity(providers.len()); + for peer in providers { + if super::paid_alpn::negotiate_access( + &self.endpoint, + &self.data_dir, + peer, + &digest.hex, + &self.pay_policy, + ) + .await + { + allowed.push(peer); + } + } + if allowed.is_empty() { + return Ok(false); + } + // Fetch (range-verified by iroh) then export the verified blob to the // staging path the caller expects. The seam re-verifies the digest. let downloader = self.store.downloader(&self.endpoint); downloader - .download(hash, providers) + .download(hash, allowed) .await .map_err(|e| anyhow::anyhow!("iroh swarm download: {e}"))?; self.store diff --git a/core/archipelago/src/swarm/mod.rs b/core/archipelago/src/swarm/mod.rs index 45f0ff83..f675e6b0 100644 --- a/core/archipelago/src/swarm/mod.rs +++ b/core/archipelago/src/swarm/mod.rs @@ -27,6 +27,7 @@ use tracing::{debug, info, warn}; use crate::content_hash::ContentDigest; +pub mod payment; pub mod seed_advert; #[cfg(feature = "iroh-swarm")] @@ -35,6 +36,9 @@ pub mod iroh_provider; #[cfg(feature = "iroh-swarm")] pub mod paid; +#[cfg(feature = "iroh-swarm")] +pub mod paid_alpn; + /// Which source ultimately served the content. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum FetchSource { diff --git a/core/archipelago/src/swarm/paid_alpn.rs b/core/archipelago/src/swarm/paid_alpn.rs new file mode 100644 index 00000000..8a1fca5c --- /dev/null +++ b/core/archipelago/src/swarm/paid_alpn.rs @@ -0,0 +1,306 @@ +//! Shape-A paid-blobs negotiation ALPN (`archy/paid-blobs/1`) — the on-wire +//! exchange that lets a downloader pay a seeder *before* fetching a gated blob +//! (DHT distribution plan §1, "shape A"). Gated behind `iroh-swarm`. +//! +//! ## Why a side ALPN +//! iroh-blobs carries the raw bytes; this tiny request/grant protocol rides a +//! second ALPN on the *same* endpoint so a downloader can discover the price and +//! deliver an ecash token first. The token opens a metered `streaming` session +//! keyed by the downloader's endpoint id — exactly the session the blob-GET gate +//! ([`super::paid`]) already checks. Same endpoint → same session → the GET is +//! then served. +//! +//! ```text +//! B ──(archy/paid-blobs/1)──▶ A PaidRequest { want: H, token: None } +//! B ◀─────────────────────── A PaymentRequired { price, accepted_mints } +//! B: auto_pay_token(...) ── builds a cashuA token (cross-mint aware) +//! B ──(archy/paid-blobs/1)──▶ A PaidRequest { want: H, token: Some(t) } +//! B ◀─────────────────────── A Granted (session now exists on A) +//! B ──(iroh-blobs ALPN)─────▶ A GET H → served (gate sees the session) +//! ``` +//! +//! ## North star: origin always wins, releases stay free +//! Negotiation is **best-effort and additive**. A peer that doesn't speak this +//! ALPN, or any connect/protocol error, is treated as "proceed" — the blob-GET +//! gate is the real enforcement, and a denied GET just falls back to origin. +//! With the default [`PaymentPolicy::free`] a downloader never sends a token, so +//! a seeder that prices a blob is simply skipped → origin. Only films (a future +//! caller with a real budget) will actually pay. + +use std::path::{Path, PathBuf}; + +use anyhow::Result; +use iroh::endpoint::Connection; +use iroh::protocol::{AcceptError, ProtocolHandler}; +use iroh::{Endpoint, EndpointAddr, EndpointId}; +use iroh_blobs::api::blobs::BlobStatus; +use iroh_blobs::api::Store; +use iroh_blobs::Hash; +use serde::{Deserialize, Serialize}; + +use super::payment::PaymentPolicy; +use crate::streaming::gate::{self, GateResult}; + +/// ALPN for the paid-blobs negotiation protocol. +pub const PAID_ALPN: &[u8] = b"archy/paid-blobs/1"; + +/// The streaming service that meters swarm blob serving (same id as [`super::paid`]). +const SERVICE_ID: &str = "content-download"; + +/// Cap on a single negotiation message (JSON). Requests/responses are tiny. +const MAX_MSG: usize = 64 * 1024; + +/// A downloader's ask for one content-addressed blob, optionally with payment. +#[derive(Debug, Serialize, Deserialize)] +struct PaidRequest { + /// BLAKE3 hex of the wanted blob. + want: String, + /// A `cashuA` token, present on the paying retry. + #[serde(skip_serializing_if = "Option::is_none")] + token: Option, +} + +/// The seeder's verdict. +#[derive(Debug, Serialize, Deserialize)] +#[serde(tag = "status", rename_all = "snake_case")] +enum PaidResponse { + /// Fetch away — free, or a paid session is now active for this peer. + Granted, + /// Payment needed before serving. The downloader may pay and retry. + PaymentRequired { + price_sats: u64, + accepted_mints: Vec, + }, + /// Refused (bad request, insufficient/failed payment). + Denied { reason: String }, +} + +// ── Serve side ───────────────────────────────────────────────────────────── + +/// Accept-side handler for [`PAID_ALPN`]. Registered on the provider's `Router` +/// alongside the iroh-blobs protocol. +#[derive(Clone)] +pub struct PaidBlobsProtocol { + data_dir: PathBuf, + store: Store, +} + +impl std::fmt::Debug for PaidBlobsProtocol { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PaidBlobsProtocol").finish() + } +} + +impl PaidBlobsProtocol { + pub fn new(data_dir: PathBuf, store: Store) -> Self { + Self { data_dir, store } + } + + /// Decide the verdict for a request from `peer`. Mirrors [`super::paid`]'s + /// policy: free when the service is disabled (default) or the peer holds an + /// active session; payment-required when metered and unpaid; fail-OPEN + /// (Granted) on an internal gate error so a fault never blocks distribution. + async fn decide(&self, peer: &str, req: &PaidRequest) -> PaidResponse { + let size = self.blob_size(&req.want).await; + match gate::check_gate(&self.data_dir, peer, SERVICE_ID, req.token.as_deref(), size).await { + Ok(GateResult::ServiceUnavailable) + | Ok(GateResult::Allowed { .. }) + | Ok(GateResult::PaidAndAllowed { .. }) => PaidResponse::Granted, + Ok(GateResult::PaymentRequired { + minimum_sats, + pricing, + .. + }) => PaidResponse::PaymentRequired { + price_sats: minimum_sats, + accepted_mints: pricing.accepted_mints, + }, + Ok(GateResult::InsufficientPayment { + provided_sats, + minimum_sats, + }) => PaidResponse::Denied { + reason: format!("insufficient payment: {provided_sats} < {minimum_sats} sats"), + }, + Ok(GateResult::PaymentFailed { reason }) => PaidResponse::Denied { reason }, + // Availability beats revenue: a gate fault serves free, matching the + // blob-GET gate's fail-open behaviour. + Err(e) => { + tracing::warn!("paid-alpn: gate errored ({e}); granting free"); + PaidResponse::Granted + } + } + } + + /// Full size of a held blob (for metering); 0 if we don't hold it complete. + async fn blob_size(&self, blake3_hex: &str) -> u64 { + let Ok(raw) = hex::decode(blake3_hex) else { + return 0; + }; + let Ok(arr) = <[u8; 32]>::try_from(raw.as_slice()) else { + return 0; + }; + match self.store.blobs().status(Hash::from_bytes(arr)).await { + Ok(BlobStatus::Complete { size }) => size, + _ => 0, + } + } +} + +impl ProtocolHandler for PaidBlobsProtocol { + async fn accept(&self, connection: Connection) -> Result<(), AcceptError> { + let peer = connection.remote_id().to_string(); + // One bi-stream per request (a paying downloader opens a second one). + loop { + let (mut send, mut recv) = match connection.accept_bi().await { + Ok(s) => s, + // Connection closed by the peer — normal end of negotiation. + Err(_) => break, + }; + let buf = recv + .read_to_end(MAX_MSG) + .await + .map_err(AcceptError::from_err)?; + let response = match serde_json::from_slice::(&buf) { + Ok(req) => self.decide(&peer, &req).await, + Err(e) => PaidResponse::Denied { + reason: format!("bad request: {e}"), + }, + }; + let bytes = serde_json::to_vec(&response).map_err(AcceptError::from_err)?; + send.write_all(&bytes).await.map_err(AcceptError::from_err)?; + send.finish().map_err(AcceptError::from_err)?; + } + Ok(()) + } +} + +// ── Fetch side ─────────────────────────────────────────────────────────────── + +/// Negotiate access to `blake3_hex` from `peer` before fetching. Returns whether +/// the caller should proceed to download from this peer. +/// +/// Best-effort: any connect/protocol failure returns `true` (proceed — the +/// blob-GET gate is the real enforcement, and a denied GET falls back to origin). +/// Returns `false` only when the seeder explicitly requires a payment we won't or +/// can't make under `policy`. +pub async fn negotiate_access( + endpoint: &Endpoint, + data_dir: &Path, + peer: EndpointId, + blake3_hex: &str, + policy: &PaymentPolicy, +) -> bool { + match negotiate_inner(endpoint, data_dir, peer, blake3_hex, policy).await { + Ok(proceed) => proceed, + Err(e) => { + tracing::debug!("paid-alpn: negotiation with {peer} failed ({e}) — proceeding (gate decides)"); + true + } + } +} + +async fn negotiate_inner( + endpoint: &Endpoint, + data_dir: &Path, + peer: EndpointId, + blake3_hex: &str, + policy: &PaymentPolicy, +) -> Result { + let conn = endpoint.connect(EndpointAddr::new(peer), PAID_ALPN).await?; + + // First ask with no token. + let resp = exchange( + &conn, + &PaidRequest { + want: blake3_hex.to_string(), + token: None, + }, + ) + .await?; + + match resp { + PaidResponse::Granted => Ok(true), + PaidResponse::Denied { .. } => Ok(false), + PaidResponse::PaymentRequired { + price_sats, + accepted_mints, + } => { + // Build a token within budget (cross-mint aware); None ⇒ use origin. + match super::payment::auto_pay_token(data_dir, policy, &accepted_mints, price_sats) + .await? + { + None => Ok(false), + Some(token) => { + let resp2 = exchange( + &conn, + &PaidRequest { + want: blake3_hex.to_string(), + token: Some(token), + }, + ) + .await?; + Ok(matches!(resp2, PaidResponse::Granted)) + } + } + } + } +} + +/// One request/response round trip on a fresh bi-stream. +async fn exchange(conn: &Connection, req: &PaidRequest) -> Result { + let (mut send, mut recv) = conn.open_bi().await?; + send.write_all(&serde_json::to_vec(req)?).await?; + send.finish()?; + let buf = recv.read_to_end(MAX_MSG).await?; + Ok(serde_json::from_slice(&buf)?) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn request_round_trips_and_omits_absent_token() { + let req = PaidRequest { + want: "abcd".into(), + token: None, + }; + let json = serde_json::to_string(&req).unwrap(); + assert!(!json.contains("token"), "absent token must be omitted: {json}"); + let back: PaidRequest = serde_json::from_str(&json).unwrap(); + assert_eq!(back.want, "abcd"); + assert!(back.token.is_none()); + } + + #[test] + fn request_with_token_round_trips() { + let req = PaidRequest { + want: "ff".into(), + token: Some("cashuAbc".into()), + }; + let back: PaidRequest = serde_json::from_str(&serde_json::to_string(&req).unwrap()).unwrap(); + assert_eq!(back.token.as_deref(), Some("cashuAbc")); + } + + #[test] + fn response_tagged_serialization() { + let granted = serde_json::to_string(&PaidResponse::Granted).unwrap(); + assert_eq!(granted, r#"{"status":"granted"}"#); + + let pr = serde_json::to_string(&PaidResponse::PaymentRequired { + price_sats: 7, + accepted_mints: vec!["https://m".into()], + }) + .unwrap(); + let back: PaidResponse = serde_json::from_str(&pr).unwrap(); + match back { + PaidResponse::PaymentRequired { + price_sats, + accepted_mints, + } => { + assert_eq!(price_sats, 7); + assert_eq!(accepted_mints, vec!["https://m".to_string()]); + } + other => panic!("expected PaymentRequired, got {other:?}"), + } + } +} diff --git a/core/archipelago/src/swarm/payment.rs b/core/archipelago/src/swarm/payment.rs new file mode 100644 index 00000000..3e681678 --- /dev/null +++ b/core/archipelago/src/swarm/payment.rs @@ -0,0 +1,166 @@ +//! Fetch-side auto-pay — the *downloader's* decision layer for paid swarm +//! content (plan §1 "fetch side" + §2a cross-mint). +//! +//! When a swarm seeder gates a blob behind payment (its `PaymentRequired` +//! advertises a price and a set of `accepted_mints`), a downloading node uses +//! this layer to decide whether to pay and, if so, to build a `cashuA` token +//! denominated in one of the seeder's accepted mints — auto-swapping across +//! mints when needed (see [`crate::wallet::ecash::build_payment_token`]). +//! +//! ## North star: origin always wins +//! Paying is strictly an optimization. If the price is over budget, the wallet +//! can't cover it, no trusted mint is reachable, or a swap would cost too much, +//! this layer returns `None` and the caller falls back to the free HTTP origin — +//! exactly today's path. A wallet/mint problem must never block content. +//! +//! ## Scope / what's NOT here +//! This builds the *token*; it does not yet carry it to the seeder. The on-wire +//! exchange (a downloader presenting the token to a paid seeder, then streaming +//! the blob) is the in-band paid-blobs ALPN — "shape (A)" in the design doc — +//! which is deferred. Today's seeder side (`swarm::paid`) only allow/deny-gates +//! iroh-blobs requests; once shape (A) lands, the provider's fetch path calls +//! [`auto_pay_token`] on a `PaymentRequired` and retries with the token. + +use std::path::Path; + +use anyhow::Result; +use tracing::debug; + +use crate::wallet::ecash; + +/// A downloader's willingness to pay swarm peers for a single fetch. +#[derive(Debug, Clone, Copy)] +pub struct PaymentPolicy { + /// Maximum total sats to spend for this content. `0` disables paying + /// entirely (origin-only) — the safe default. + pub budget_sats: u64, + /// Maximum cross-mint swap fee tolerated when we must swap into the + /// seeder's mint. Ignored when we already hold the right mint. + pub max_fee_sats: u64, +} + +impl PaymentPolicy { + /// The default: never pay, always use the free origin. The production caller + /// is the deferred in-band paid-blobs ALPN (shape A); used by tests today. + #[allow(dead_code)] + pub fn free() -> Self { + Self { + budget_sats: 0, + max_fee_sats: 0, + } + } + + /// A budget-capped policy. + pub fn with_budget(budget_sats: u64, max_fee_sats: u64) -> Self { + Self { + budget_sats, + max_fee_sats, + } + } + + /// Whether a seeder's `price_sats` is worth paying under this policy. A zero + /// price is treated as "not a real paid request" (use origin / free path). + pub fn affords(&self, price_sats: u64) -> bool { + price_sats > 0 && price_sats <= self.budget_sats + } +} + +/// Decide whether to pay a seeder `price_sats`, and if so build a `cashuA` token +/// denominated in one of its `accepted_mints` (auto-swapping if needed). +/// +/// * `Ok(Some(token))` — pay the seeder with this token. +/// * `Ok(None)` — decline (over budget, unpayable, or swap too costly); +/// the caller should fall back to the free origin. +/// +/// Never returns `Err` for a wallet/mint problem: those degrade to `Ok(None)` +/// so a payment failure can never block content. +pub async fn auto_pay_token( + data_dir: &Path, + policy: &PaymentPolicy, + accepted_mints: &[String], + price_sats: u64, +) -> Result> { + if !policy.affords(price_sats) { + debug!( + "auto-pay: price {} sats over budget {} (or zero) — using origin", + price_sats, policy.budget_sats + ); + return Ok(None); + } + + match ecash::build_payment_token(data_dir, accepted_mints, price_sats, policy.max_fee_sats).await + { + Ok(token) => Ok(Some(token)), + Err(e) => { + // Unpayable within balance/trust/fee — not an error, just decline. + debug!("auto-pay: declined ({}) — falling back to origin", e); + Ok(None) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn free_policy_never_affords() { + let p = PaymentPolicy::free(); + assert!(!p.affords(1)); + assert!(!p.affords(0)); + } + + #[test] + fn budget_policy_affordability() { + let p = PaymentPolicy::with_budget(100, 5); + assert!(p.affords(100)); // exactly at budget + assert!(p.affords(1)); + assert!(!p.affords(101)); // over budget + assert!(!p.affords(0)); // zero price is never a real paid request + } + + #[tokio::test] + async fn over_budget_declines_without_touching_wallet() { + let tmp = tempfile::tempdir().unwrap(); + // Price exceeds budget → None, and no wallet/mint interaction occurs. + let out = auto_pay_token( + tmp.path(), + &PaymentPolicy::with_budget(50, 5), + &["https://seeder.example.com".into()], + 100, + ) + .await + .unwrap(); + assert!(out.is_none()); + } + + #[tokio::test] + async fn zero_budget_is_origin_only() { + let tmp = tempfile::tempdir().unwrap(); + let out = auto_pay_token( + tmp.path(), + &PaymentPolicy::free(), + &["https://seeder.example.com".into()], + 10, + ) + .await + .unwrap(); + assert!(out.is_none()); + } + + #[tokio::test] + async fn unpayable_within_budget_declines_gracefully() { + let tmp = tempfile::tempdir().unwrap(); + // Within budget, but empty wallet + untrusted seeder mint → build fails; + // auto_pay degrades to None (origin) rather than erroring. + let out = auto_pay_token( + tmp.path(), + &PaymentPolicy::with_budget(1000, 10), + &["https://untrusted.example.com".into()], + 100, + ) + .await + .unwrap(); + assert!(out.is_none()); + } +} diff --git a/core/archipelago/src/wallet/ecash.rs b/core/archipelago/src/wallet/ecash.rs index 1107f4b4..537f1269 100644 --- a/core/archipelago/src/wallet/ecash.rs +++ b/core/archipelago/src/wallet/ecash.rs @@ -10,7 +10,7 @@ use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use std::path::Path; use tokio::fs; -use tracing::{debug, warn}; +use tracing::{debug, info, warn}; const WALLET_FILE: &str = "wallet/ecash.json"; const MINTS_FILE: &str = "wallet/accepted_mints.json"; @@ -106,6 +106,18 @@ impl WalletState { .sum() } + /// Spendable (unspent, unreserved) balance grouped by mint URL. + pub fn spendable_by_mint(&self) -> Vec<(String, u64)> { + use std::collections::BTreeMap; + let mut by_mint: BTreeMap = BTreeMap::new(); + for p in &self.proofs { + if !p.spent && !p.reserved { + *by_mint.entry(p.mint_url.clone()).or_default() += p.proof.amount; + } + } + by_mint.into_iter().collect() + } + /// Select unspent proofs that cover at least `amount` sats from a specific mint. /// Returns selected proofs and any overpayment amount. pub fn select_proofs(&self, mint_url: &str, amount: u64) -> Option<(Vec, u64)> { @@ -352,10 +364,225 @@ pub async fn melt_tokens(data_dir: &Path, quote_id: &str, bolt11: &str) -> Resul Ok(quote.amount) } -/// Create a cashuA token string to send to a peer. -pub async fn send_token(data_dir: &Path, amount_sats: u64) -> Result { +// ── Cross-mint settlement (plan §2a / phasing F2) ────────────────────────── +// +// The wallet data model is already multi-mint (proofs, balances and selection +// are all keyed by mint URL). What was hardcoded to the home mint is the +// convenience layer. These `*_at` helpers parameterize that layer by target +// mint, and `swap_between_mints` moves value across mints over Lightning so a +// node holding tokens on mint A can pay a seeder that only accepts mint B. + +/// How long to wait for a target mint's Lightning invoice to settle before +/// claiming the freshly-minted tokens. +const SWAP_CLAIM_TIMEOUT_SECS: u64 = 60; +/// Poll interval while waiting for the invoice to settle. +const SWAP_CLAIM_POLL_SECS: u64 = 2; + +/// Whether we trust a mint enough to swap value *into* it (or accept its tokens). +/// +/// The local Fedimint (home mint) is always trusted; any other mint must be on +/// the configured accepted-mints allow-list. Comparison ignores a trailing +/// slash so advertised URLs match stored ones. See plan §2a "Mint trust list". +pub async fn is_mint_trusted(data_dir: &Path, mint_url: &str) -> Result { + let norm = |s: &str| s.trim_end_matches('/').to_string(); + let target = norm(mint_url); + if target == norm(&default_mint_url()) { + return Ok(true); + } + let accepted = load_accepted_mints(data_dir).await?; + Ok(accepted.mints.iter().any(|m| norm(m) == target)) +} + +/// All-in cost of a swap, relative to the amount actually delivered. +/// +/// `total_paid` is what the source mint charges (melt amount + LN fee reserve); +/// `amount_delivered` is what lands on the target mint. The difference is the +/// fee the user pays to move value across mints. +fn swap_fee(total_paid: u64, amount_delivered: u64) -> u64 { + total_paid.saturating_sub(amount_delivered) +} + +/// Move `amount_sats` of value from mint `from_mint` to mint `to_mint` over +/// Lightning, returning the amount claimed on the target mint. +/// +/// Flow (Cashu/Fedimint settle over BOLT11): +/// 1. `mint_quote` on the target → a Lightning invoice to pay. +/// 2. `melt_quote` on the source → the cost in source tokens (amount + fee). +/// 3. Fee-cap check: refuse if the all-in fee exceeds `max_fee_sats`. +/// 4. Select source proofs and `melt` them to pay the target's invoice. +/// 5. Once the invoice settles, `mint` (claim) the tokens on the target. +/// +/// Crash-safety: the source spend is persisted *before* the claim, so a crash +/// between paying and claiming never double-spends — at worst the target tokens +/// are left unclaimed (reconcilable from the mint quote id). Idempotent resume +/// is phasing step 3 (deferred). +pub async fn swap_between_mints( + data_dir: &Path, + from_mint: &str, + to_mint: &str, + amount_sats: u64, + max_fee_sats: u64, +) -> Result { + if amount_sats == 0 { + anyhow::bail!("swap amount must be greater than zero"); + } + let norm = |s: &str| s.trim_end_matches('/').to_string(); + if norm(from_mint) == norm(to_mint) { + anyhow::bail!("swap source and target mints are identical"); + } + if !is_mint_trusted(data_dir, to_mint).await? { + anyhow::bail!( + "target mint '{}' is not in the trusted/accepted mint list", + to_mint + ); + } + + let from = MintClient::new(from_mint)?; + let to = MintClient::new(to_mint)?; + + // 1. Mint quote on the target → invoice to pay. + let mint_quote = to + .mint_quote(amount_sats) + .await + .with_context(|| format!("requesting mint quote at target mint {}", to_mint))?; + + // 2. Melt quote on the source for that invoice → cost in source tokens. + let melt_quote = from + .melt_quote(&mint_quote.request) + .await + .with_context(|| format!("requesting melt quote at source mint {}", from_mint))?; + let total_needed = melt_quote.amount + melt_quote.fee_reserve; + let fee = swap_fee(total_needed, amount_sats); + + // 3. Fee-cap check — caller falls back to free origin if too expensive. + if fee > max_fee_sats { + anyhow::bail!( + "swap fee {} sats exceeds cap {} sats (need {} on {} to deliver {} on {})", + fee, + max_fee_sats, + total_needed, + from_mint, + amount_sats, + to_mint + ); + } + + // 4. Select source proofs and melt them to pay the target invoice. let mut wallet = load_wallet(data_dir).await?; - let mint_url = wallet.mint_url.clone(); + let (indices, _overpayment) = + wallet + .select_proofs(from_mint, total_needed) + .ok_or_else(|| { + anyhow::anyhow!( + "insufficient balance on {}: need {} sats, have {} sats", + from_mint, + total_needed, + wallet.balance_for_mint(from_mint) + ) + })?; + let proofs: Vec = indices + .iter() + .map(|&i| wallet.proofs[i].proof.clone()) + .collect(); + + if let Err(e) = from.melt_tokens(&melt_quote.quote, &proofs).await { + // The pay leg never completed — record the route failure so future + // payments can prefer a route with a track record. + record_swap_failure(data_dir, from_mint, to_mint).await; + return Err(e) + .with_context(|| format!("melting source proofs at {} to pay target invoice", from_mint)); + } + + // Persist the spend BEFORE claiming so a crash can't double-spend, and + // journal the in-flight swap so the claim can be resumed after a crash. + wallet.mark_spent(&indices); + wallet.record_tx( + TransactionType::Melt, + total_needed, + &format!( + "Cross-mint swap {}→{}: paid {} sats (fee {})", + from_mint, to_mint, total_needed, fee + ), + from_mint, + to_mint, + ); + save_wallet(data_dir, &wallet).await?; + add_pending_swap( + data_dir, + PendingSwap { + from_mint: from_mint.to_string(), + to_mint: to_mint.to_string(), + amount_sats, + melt_quote_id: melt_quote.quote.clone(), + mint_quote_id: mint_quote.quote.clone(), + created_at: chrono::Utc::now().to_rfc3339(), + }, + ) + .await?; + + // 5. Wait for the invoice to settle, then claim the minted tokens. + wait_for_mint_quote_paid(&to, &mint_quote.quote).await?; + let result = to + .mint_tokens(&mint_quote.quote, amount_sats) + .await + .with_context(|| format!("claiming minted tokens at target mint {}", to_mint))?; + let minted: u64 = result.proofs.iter().map(|p| p.amount).sum(); + + let mut wallet = load_wallet(data_dir).await?; + wallet.add_proofs(to_mint, result.proofs); + wallet.record_tx( + TransactionType::Mint, + minted, + &format!("Cross-mint swap {}→{}: claimed {} sats", from_mint, to_mint, minted), + to_mint, + from_mint, + ); + save_wallet(data_dir, &wallet).await?; + + // Swap fully settled — clear the journal entry and credit the route. + remove_pending_swap(data_dir, &mint_quote.quote).await?; + record_swap_success(data_dir, from_mint, to_mint).await; + + debug!( + "Cross-mint swap complete: {} → {} delivered {} sats (fee {})", + from_mint, to_mint, minted, fee + ); + Ok(minted) +} + +/// Poll a mint quote until its Lightning invoice is paid (state `PAID`/`ISSUED`), +/// or time out. The melt above pays the invoice; the target mint sees it settle +/// shortly after. +async fn wait_for_mint_quote_paid(client: &MintClient, quote_id: &str) -> Result<()> { + let deadline = SWAP_CLAIM_TIMEOUT_SECS / SWAP_CLAIM_POLL_SECS.max(1); + for _ in 0..deadline.max(1) { + let status = client.mint_quote_status(quote_id).await?; + match status.state.as_str() { + "PAID" | "ISSUED" => return Ok(()), + _ => tokio::time::sleep(std::time::Duration::from_secs(SWAP_CLAIM_POLL_SECS)).await, + } + } + anyhow::bail!( + "target mint invoice for quote {} did not settle within {}s", + quote_id, + SWAP_CLAIM_TIMEOUT_SECS + ) +} + +/// Create a cashuA token string to send to a peer, drawing from the home mint. +pub async fn send_token(data_dir: &Path, amount_sats: u64) -> Result { + let mint_url = load_wallet(data_dir).await?.mint_url; + send_token_at(data_dir, &mint_url, amount_sats).await +} + +/// Create a cashuA token denominated in a specific mint's tokens. +/// +/// Used by the payer-side cross-mint flow: after `swap_between_mints` lands value +/// on the seeder's accepted mint, we send a token from *that* mint so the seeder +/// only ever receives its own mint's proofs (see plan §2a, payer-side swap). +pub async fn send_token_at(data_dir: &Path, mint_url: &str, amount_sats: u64) -> Result { + let mut wallet = load_wallet(data_dir).await?; + let mint_url = mint_url.to_string(); // Select proofs covering the amount let (indices, overpayment) = wallet @@ -422,6 +649,319 @@ pub async fn send_token(data_dir: &Path, amount_sats: u64) -> Result { Ok(token_str) } +// ── Payer-side payment builder (plan §2a step 2) ─────────────────────────── +// +// Given a seeder's advertised `accepted_mints`, pick the cheapest way to pay: +// spend tokens we already hold on an accepted mint (no fee), else swap value +// into a *trusted* accepted mint and pay from there. If neither is possible +// within budget, decline so the caller falls back to free origin. + +/// How a payment of a given amount can be satisfied across our mints. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PaymentPlan { + /// We already hold enough on this accepted mint — pay directly, no swap fee. + Direct { mint_url: String }, + /// Swap value from `from_mint` into the trusted accepted `to_mint`, then pay. + Swap { from_mint: String, to_mint: String }, + /// No single mint can cover the amount (caller should use free origin). + Insufficient, +} + +/// Decide how to pay `amount` sats to a seeder, given what we hold and which +/// mints the seeder accepts. +/// +/// - `holdings`: our spendable `(mint_url, balance)` pairs (verbatim URLs). +/// - `accepted`: the seeder's `(mint_url, trusted)` pairs, where `trusted` +/// means the mint is on our swap-into allow-list (`is_mint_trusted`). +/// - Direct beats Swap (no fee). A Direct target needs no trust (we already +/// hold those tokens); a Swap target must be trusted. The home mint is +/// preferred as a tie-break for both legs (lowest friction). +/// +/// Pure and synchronous so it can be unit-tested without a live mint. It does +/// not know swap fees; `swap_between_mints` enforces the fee cap and bails (→ +/// origin fallback) if the chosen source can't cover amount + fee. +fn plan_payment(holdings: &[(String, u64)], accepted: &[(String, bool)], amount: u64) -> PaymentPlan { + let norm = |s: &str| s.trim_end_matches('/').to_string(); + let home = norm(&default_mint_url()); + let held = |mint: &str| -> u64 { + holdings + .iter() + .filter(|(m, _)| norm(m) == norm(mint)) + .map(|(_, b)| *b) + .sum() + }; + + // 1. Direct: any accepted mint we already hold enough on. Prefer home. + let mut direct: Vec<&(String, bool)> = accepted + .iter() + .filter(|(m, _)| held(m) >= amount) + .collect(); + direct.sort_by_key(|(m, _)| norm(m) != home); // home (false) sorts first + if let Some((mint, _)) = direct.first() { + return PaymentPlan::Direct { + mint_url: mint.clone(), + }; + } + + // 2. Swap: a trusted accepted target + a source we hold that covers `amount`. + let mut targets: Vec<&(String, bool)> = + accepted.iter().filter(|(_, trusted)| *trusted).collect(); + targets.sort_by_key(|(m, _)| norm(m) != home); + if let Some((to_mint, _)) = targets.first() { + // Largest source we hold that isn't the target itself. + let from = holdings + .iter() + .filter(|(m, b)| norm(m) != norm(to_mint) && *b >= amount) + .max_by_key(|(_, b)| *b); + if let Some((from_mint, _)) = from { + return PaymentPlan::Swap { + from_mint: from_mint.clone(), + to_mint: to_mint.clone(), + }; + } + } + + PaymentPlan::Insufficient +} + +/// Build a cashuA token to pay a seeder `amount_sats`, denominated in one of the +/// seeder's `accepted_mints`. Auto-swaps across mints (up to `max_fee_sats`) when +/// we don't already hold the right mint. Returns the token string ready to send. +/// +/// Errors (caller should fall back to free origin) when no accepted mint is +/// reachable within balance, no trusted swap target exists, or the swap exceeds +/// the fee cap. +pub async fn build_payment_token( + data_dir: &Path, + accepted_mints: &[String], + amount_sats: u64, + max_fee_sats: u64, +) -> Result { + if amount_sats == 0 { + anyhow::bail!("payment amount must be greater than zero"); + } + if accepted_mints.is_empty() { + anyhow::bail!("seeder advertised no accepted mints"); + } + + // Annotate each accepted mint with whether we trust swapping into it. + let mut accepted: Vec<(String, bool)> = Vec::with_capacity(accepted_mints.len()); + for m in accepted_mints { + let trusted = is_mint_trusted(data_dir, m).await?; + accepted.push((m.clone(), trusted)); + } + + // Prefer swap targets with a liquidity track record. plan_payment's stable + // sort keeps the home mint first; within the rest, this orders by how + // reliably we've reached each target before (best routes first). + let liq = load_swap_liquidity(data_dir).await; + accepted.sort_by_key(|(m, _)| std::cmp::Reverse(target_liquidity_score(&liq, m))); + + let holdings = load_wallet(data_dir).await?.spendable_by_mint(); + + match plan_payment(&holdings, &accepted, amount_sats) { + PaymentPlan::Direct { mint_url } => { + debug!("Payment plan: direct from {} for {} sats", mint_url, amount_sats); + send_token_at(data_dir, &mint_url, amount_sats).await + } + PaymentPlan::Swap { from_mint, to_mint } => { + debug!( + "Payment plan: swap {}→{} then pay {} sats (fee cap {})", + from_mint, to_mint, amount_sats, max_fee_sats + ); + swap_between_mints(data_dir, &from_mint, &to_mint, amount_sats, max_fee_sats).await?; + send_token_at(data_dir, &to_mint, amount_sats).await + } + PaymentPlan::Insufficient => anyhow::bail!( + "cannot pay {} sats: no accepted mint covers it within balance/trust", + amount_sats + ), + } +} + +// ── F2 step 3 — hardening: idempotent swap resume + liquidity cache ───────── + +const PENDING_SWAPS_FILE: &str = "wallet/pending_swaps.json"; +const SWAP_LIQUIDITY_FILE: &str = "wallet/swap_liquidity.json"; + +/// An in-flight cross-mint swap, journaled the moment the source proofs are +/// melted (paid) so a crash before the target claim can be resumed instead of +/// silently losing the value. Removed once the target tokens are claimed. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PendingSwap { + pub from_mint: String, + pub to_mint: String, + pub amount_sats: u64, + /// Source-mint melt quote id (the leg already paid). + pub melt_quote_id: String, + /// Target-mint mint quote id (the leg to claim). + pub mint_quote_id: String, + pub created_at: String, +} + +async fn load_pending_swaps(data_dir: &Path) -> Result> { + let path = data_dir.join(PENDING_SWAPS_FILE); + if !path.exists() { + return Ok(Vec::new()); + } + let content = fs::read_to_string(&path).await?; + Ok(serde_json::from_str(&content).unwrap_or_default()) +} + +async fn save_pending_swaps(data_dir: &Path, swaps: &[PendingSwap]) -> Result<()> { + let dir = data_dir.join("wallet"); + fs::create_dir_all(&dir).await?; + let path = data_dir.join(PENDING_SWAPS_FILE); + fs::write(&path, serde_json::to_string_pretty(swaps)?).await?; + Ok(()) +} + +async fn add_pending_swap(data_dir: &Path, swap: PendingSwap) -> Result<()> { + let mut all = load_pending_swaps(data_dir).await?; + all.push(swap); + save_pending_swaps(data_dir, &all).await +} + +async fn remove_pending_swap(data_dir: &Path, mint_quote_id: &str) -> Result<()> { + let mut all = load_pending_swaps(data_dir).await?; + all.retain(|s| s.mint_quote_id != mint_quote_id); + save_pending_swaps(data_dir, &all).await +} + +/// Resume any swaps that were interrupted between paying the source mint and +/// claiming the target tokens. For each pending swap, ask the target mint about +/// the mint quote: +/// - `PAID` → claim now (the value was paid but never claimed). Reclaimed. +/// - `ISSUED` → already claimed on a prior run; just drop the journal entry. +/// - else → leave it (the invoice hasn't settled yet; retry next time). +/// +/// Returns the total sats reclaimed. Safe to call repeatedly (idempotent): a +/// quote is only minted once, and `ISSUED` quotes are never re-claimed. +pub async fn resume_pending_swaps(data_dir: &Path) -> Result { + let pending = load_pending_swaps(data_dir).await?; + let mut reclaimed = 0u64; + for swap in pending { + let to = match MintClient::new(&swap.to_mint) { + Ok(c) => c, + Err(e) => { + warn!("resume_pending_swaps: bad target mint {}: {}", swap.to_mint, e); + continue; + } + }; + let status = match to.mint_quote_status(&swap.mint_quote_id).await { + Ok(s) => s, + Err(e) => { + debug!( + "resume_pending_swaps: status check failed for {}: {} — leaving pending", + swap.mint_quote_id, e + ); + continue; + } + }; + match status.state.as_str() { + "PAID" => match to.mint_tokens(&swap.mint_quote_id, swap.amount_sats).await { + Ok(result) => { + let minted: u64 = result.proofs.iter().map(|p| p.amount).sum(); + let mut wallet = load_wallet(data_dir).await?; + wallet.add_proofs(&swap.to_mint, result.proofs); + wallet.record_tx( + TransactionType::Mint, + minted, + &format!( + "Resumed cross-mint swap {}→{}: claimed {} sats", + swap.from_mint, swap.to_mint, minted + ), + &swap.to_mint, + &swap.from_mint, + ); + save_wallet(data_dir, &wallet).await?; + remove_pending_swap(data_dir, &swap.mint_quote_id).await?; + record_swap_success(data_dir, &swap.from_mint, &swap.to_mint).await; + reclaimed += minted; + info!( + "Resumed interrupted swap {}→{}: reclaimed {} sats", + swap.from_mint, swap.to_mint, minted + ); + } + Err(e) => warn!("resume_pending_swaps: claim failed for {}: {}", swap.mint_quote_id, e), + }, + "ISSUED" => { + // Already claimed on a previous run — drop the journal entry. + remove_pending_swap(data_dir, &swap.mint_quote_id).await?; + } + other => debug!( + "resume_pending_swaps: quote {} state {} — leaving pending", + swap.mint_quote_id, other + ), + } + } + Ok(reclaimed) +} + +/// Success/failure counts for a single (from → to) swap route. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +struct RouteStat { + successes: u64, + failures: u64, +} + +/// Per-mint-pair liquidity cache: which swap routes have actually worked, so the +/// payer can prefer routes with a track record over ones that keep failing. +#[derive(Debug, Default, Serialize, Deserialize)] +struct SwapLiquidity { + /// Keyed by `"|"` (normalized URLs). + routes: std::collections::BTreeMap, +} + +fn route_key(from_mint: &str, to_mint: &str) -> String { + format!( + "{}|{}", + from_mint.trim_end_matches('/'), + to_mint.trim_end_matches('/') + ) +} + +async fn load_swap_liquidity(data_dir: &Path) -> SwapLiquidity { + let path = data_dir.join(SWAP_LIQUIDITY_FILE); + match fs::read_to_string(&path).await { + Ok(content) => serde_json::from_str(&content).unwrap_or_default(), + Err(_) => SwapLiquidity::default(), + } +} + +async fn save_swap_liquidity(data_dir: &Path, liq: &SwapLiquidity) { + let dir = data_dir.join("wallet"); + let _ = fs::create_dir_all(&dir).await; + if let Ok(content) = serde_json::to_string_pretty(liq) { + let _ = fs::write(data_dir.join(SWAP_LIQUIDITY_FILE), content).await; + } +} + +/// Record that a swap route succeeded (best-effort; never fails the caller). +async fn record_swap_success(data_dir: &Path, from_mint: &str, to_mint: &str) { + let mut liq = load_swap_liquidity(data_dir).await; + liq.routes.entry(route_key(from_mint, to_mint)).or_default().successes += 1; + save_swap_liquidity(data_dir, &liq).await; +} + +/// Record that a swap route failed (best-effort; never fails the caller). +async fn record_swap_failure(data_dir: &Path, from_mint: &str, to_mint: &str) { + let mut liq = load_swap_liquidity(data_dir).await; + liq.routes.entry(route_key(from_mint, to_mint)).or_default().failures += 1; + save_swap_liquidity(data_dir, &liq).await; +} + +/// Liquidity score for reaching `to_mint` from any source: net successes across +/// all routes ending at this target. Higher = a more reliable destination. +fn target_liquidity_score(liq: &SwapLiquidity, to_mint: &str) -> i64 { + let suffix = format!("|{}", to_mint.trim_end_matches('/')); + liq.routes + .iter() + .filter(|(k, _)| k.ends_with(&suffix)) + .map(|(_, s)| s.successes as i64 - s.failures as i64) + .sum() +} + /// Receive a cashuA token from a peer — swaps proofs at the mint for fresh ones. pub async fn receive_token(data_dir: &Path, token_str: &str) -> Result { // Handle legacy format for backwards compatibility @@ -936,4 +1476,286 @@ mod tests { fn test_default_mint_url() { assert_eq!(default_mint_url(), "http://127.0.0.1:8175"); } + + #[test] + fn test_swap_fee() { + assert_eq!(swap_fee(105, 100), 5); + // Defensive: never underflow if mint quotes oddly. + assert_eq!(swap_fee(100, 100), 0); + assert_eq!(swap_fee(90, 100), 0); + } + + #[tokio::test] + async fn test_is_mint_trusted_home_always() { + let tmp = TempDir::new().unwrap(); + // Home mint is trusted even with no accepted-mints file. + assert!(is_mint_trusted(tmp.path(), &default_mint_url()) + .await + .unwrap()); + // Trailing slash on the home URL still matches. + assert!(is_mint_trusted(tmp.path(), "http://127.0.0.1:8175/") + .await + .unwrap()); + } + + #[tokio::test] + async fn test_is_mint_trusted_respects_accepted_list() { + let tmp = TempDir::new().unwrap(); + save_accepted_mints( + tmp.path(), + &AcceptedMints { + mints: vec![default_mint_url(), "https://mint.example.com".into()], + }, + ) + .await + .unwrap(); + + assert!(is_mint_trusted(tmp.path(), "https://mint.example.com") + .await + .unwrap()); + // Normalized comparison ignores a trailing slash. + assert!(is_mint_trusted(tmp.path(), "https://mint.example.com/") + .await + .unwrap()); + // A mint not on the list is not trusted. + assert!(!is_mint_trusted(tmp.path(), "https://evil.example.com") + .await + .unwrap()); + } + + #[tokio::test] + async fn test_swap_between_mints_rejects_identical() { + let tmp = TempDir::new().unwrap(); + let err = swap_between_mints( + tmp.path(), + &default_mint_url(), + "http://127.0.0.1:8175/", + 100, + 10, + ) + .await + .unwrap_err(); + assert!(err.to_string().contains("identical")); + } + + #[tokio::test] + async fn test_swap_between_mints_rejects_untrusted_target() { + let tmp = TempDir::new().unwrap(); + let err = swap_between_mints( + tmp.path(), + &default_mint_url(), + "https://untrusted.example.com", + 100, + 10, + ) + .await + .unwrap_err(); + assert!(err.to_string().contains("trusted")); + } + + #[tokio::test] + async fn test_swap_between_mints_rejects_zero_amount() { + let tmp = TempDir::new().unwrap(); + let err = swap_between_mints( + tmp.path(), + &default_mint_url(), + "https://mint.example.com", + 0, + 10, + ) + .await + .unwrap_err(); + assert!(err.to_string().contains("greater than zero")); + } + + #[test] + fn test_spendable_by_mint_groups_and_excludes() { + let mut wallet = WalletState::default(); + wallet.add_proofs( + "http://mint-a", + vec![ + Proof { amount: 10, id: "k".into(), secret: "s1".into(), c: "c".into() }, + Proof { amount: 5, id: "k".into(), secret: "s2".into(), c: "c".into() }, + ], + ); + wallet.add_proofs( + "http://mint-b", + vec![Proof { amount: 7, id: "k".into(), secret: "s3".into(), c: "c".into() }], + ); + wallet.proofs[1].spent = true; // exclude the 5 on mint-a + let by_mint = wallet.spendable_by_mint(); + assert_eq!(by_mint, vec![("http://mint-a".to_string(), 10), ("http://mint-b".to_string(), 7)]); + } + + #[test] + fn test_plan_payment_direct_prefers_home() { + let home = default_mint_url(); + let holdings = vec![(home.clone(), 100), ("https://other".into(), 100)]; + // Both accepted; home should win the tie-break. + let accepted = vec![("https://other".into(), true), (home.clone(), true)]; + assert_eq!( + plan_payment(&holdings, &accepted, 50), + PaymentPlan::Direct { mint_url: home } + ); + } + + #[test] + fn test_plan_payment_direct_only_accepted_mint() { + let holdings = vec![("https://a".into(), 100), ("https://b".into(), 100)]; + // We hold both, but the seeder only accepts b. + let accepted = vec![("https://b".into(), true)]; + assert_eq!( + plan_payment(&holdings, &accepted, 50), + PaymentPlan::Direct { mint_url: "https://b".into() } + ); + } + + #[test] + fn test_plan_payment_swaps_into_trusted_target() { + // We hold value on A; seeder accepts only B (trusted) which we don't hold. + let holdings = vec![("https://a".into(), 100)]; + let accepted = vec![("https://b".into(), true)]; + assert_eq!( + plan_payment(&holdings, &accepted, 50), + PaymentPlan::Swap { from_mint: "https://a".into(), to_mint: "https://b".into() } + ); + } + + #[test] + fn test_plan_payment_refuses_untrusted_swap_target() { + // Seeder accepts only B, but B is not trusted → no swap, insufficient. + let holdings = vec![("https://a".into(), 100)]; + let accepted = vec![("https://b".into(), false)]; + assert_eq!(plan_payment(&holdings, &accepted, 50), PaymentPlan::Insufficient); + } + + #[test] + fn test_plan_payment_insufficient_when_no_single_source_covers() { + // Total 60 across two mints, but neither alone covers 50+ for a swap and + // we hold neither accepted mint directly. + let holdings = vec![("https://a".into(), 30), ("https://c".into(), 30)]; + let accepted = vec![("https://b".into(), true)]; + assert_eq!(plan_payment(&holdings, &accepted, 50), PaymentPlan::Insufficient); + } + + #[test] + fn test_plan_payment_direct_beats_swap() { + // We hold the accepted mint directly AND could swap — Direct must win. + let home = default_mint_url(); + let holdings = vec![("https://b".into(), 100), (home.clone(), 100)]; + let accepted = vec![("https://b".into(), true)]; + assert_eq!( + plan_payment(&holdings, &accepted, 50), + PaymentPlan::Direct { mint_url: "https://b".into() } + ); + } + + #[tokio::test] + async fn test_build_payment_token_rejects_empty_mints() { + let tmp = TempDir::new().unwrap(); + let err = build_payment_token(tmp.path(), &[], 100, 10) + .await + .unwrap_err(); + assert!(err.to_string().contains("no accepted mints")); + } + + #[tokio::test] + async fn test_build_payment_token_insufficient_falls_through() { + let tmp = TempDir::new().unwrap(); + // Empty wallet, untrusted seeder mint → cannot pay (caller uses origin). + let err = build_payment_token(tmp.path(), &["https://seeder.example.com".into()], 100, 10) + .await + .unwrap_err(); + assert!(err.to_string().contains("cannot pay")); + } + + #[test] + fn test_route_key_normalizes_trailing_slash() { + assert_eq!(route_key("https://a/", "https://b/"), "https://a|https://b"); + assert_eq!(route_key("https://a", "https://b"), "https://a|https://b"); + } + + #[tokio::test] + async fn test_pending_swaps_roundtrip_and_remove() { + let tmp = TempDir::new().unwrap(); + assert!(load_pending_swaps(tmp.path()).await.unwrap().is_empty()); + + add_pending_swap( + tmp.path(), + PendingSwap { + from_mint: "https://a".into(), + to_mint: "https://b".into(), + amount_sats: 100, + melt_quote_id: "melt-1".into(), + mint_quote_id: "mint-1".into(), + created_at: "2026-06-17T00:00:00Z".into(), + }, + ) + .await + .unwrap(); + let loaded = load_pending_swaps(tmp.path()).await.unwrap(); + assert_eq!(loaded.len(), 1); + assert_eq!(loaded[0].mint_quote_id, "mint-1"); + + remove_pending_swap(tmp.path(), "mint-1").await.unwrap(); + assert!(load_pending_swaps(tmp.path()).await.unwrap().is_empty()); + } + + #[tokio::test] + async fn test_resume_pending_swaps_empty_is_noop() { + let tmp = TempDir::new().unwrap(); + assert_eq!(resume_pending_swaps(tmp.path()).await.unwrap(), 0); + } + + #[tokio::test] + async fn test_liquidity_cache_records_and_scores() { + let tmp = TempDir::new().unwrap(); + // Two routes into B succeed; one into C fails. + record_swap_success(tmp.path(), "https://a", "https://b").await; + record_swap_success(tmp.path(), "https://x", "https://b").await; + record_swap_failure(tmp.path(), "https://a", "https://c").await; + + let liq = load_swap_liquidity(tmp.path()).await; + // B reached successfully from two sources → net +2; trailing slash tolerant. + assert_eq!(target_liquidity_score(&liq, "https://b/"), 2); + // C only failed → net -1. + assert_eq!(target_liquidity_score(&liq, "https://c"), -1); + // Unknown target → neutral 0. + assert_eq!(target_liquidity_score(&liq, "https://unknown"), 0); + } + + #[tokio::test] + async fn test_build_payment_token_prefers_liquid_target() { + let tmp = TempDir::new().unwrap(); + // Trust two non-home mints; hold value on a source mint for both. + save_accepted_mints( + tmp.path(), + &AcceptedMints { + mints: vec![ + default_mint_url(), + "https://liquid".into(), + "https://dry".into(), + ], + }, + ) + .await + .unwrap(); + // Give "https://liquid" a track record so it should be preferred. + record_swap_success(tmp.path(), "https://src", "https://liquid").await; + + // Seeder accepts both non-home mints; we only hold "https://src". + let accepted = vec![("https://dry".into(), true), ("https://liquid".into(), true)]; + let holdings = vec![("https://src".to_string(), 1000u64)]; + + // Mirror build_payment_token's ordering step, then plan. + let liq = load_swap_liquidity(tmp.path()).await; + let mut ordered = accepted.clone(); + ordered.sort_by_key(|(m, _): &(String, bool)| { + std::cmp::Reverse(target_liquidity_score(&liq, m)) + }); + match plan_payment(&holdings, &ordered, 100) { + PaymentPlan::Swap { to_mint, .. } => assert_eq!(to_mint, "https://liquid"), + other => panic!("expected swap into liquid target, got {:?}", other), + } + } } diff --git a/docs/dht-RESUME.md b/docs/dht-RESUME.md index cf56cd37..58ca8cdd 100644 --- a/docs/dht-RESUME.md +++ b/docs/dht-RESUME.md @@ -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 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; diff --git a/docs/phase4-streaming-ecash-plan.md b/docs/phase4-streaming-ecash-plan.md index c139b9ea..16c7c8a3 100644 --- a/docs/phase4-streaming-ecash-plan.md +++ b/docs/phase4-streaming-ecash-plan.md @@ -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