Merge agent-trust-wip (DHT Phases 0–4) into main

Integrates the DHT/peer-distribution line with the v1.7.98-alpha release
fixes:
- Phase 0 signed-catalog trust + release-root key (KAT-pinned)
- Phase 1 BLAKE3 content addressing alongside SHA-256
- Phase 2 swarm-assist fetch seam (origin always wins) + iroh-blobs
  provider — heavy iroh deps stay behind the off-by-default `iroh-swarm`
  feature, so the default build/deploy is unaffected
- Phase 3 signed Nostr seed-advertisement + discovery glue + paid swarm
  serving + "Networking Profits" Settings page
- Phase 4 paid swarm streaming (cross-mint ecash, Shape-A paid ALPN,
  streaming.prepare-payment), also iroh-swarm-gated

Conflicts resolved: seed.rs (kept release-root KAT tests), update.rs
(comment-only, OTA logic identical), Cargo.lock (regenerated against the
merged Cargo.toml). Default-feature build is clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-06-17 07:50:06 -04:00
commit 7c458ede8e
33 changed files with 7258 additions and 109 deletions

3148
core/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -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 = ["dep:iroh", "dep:iroh-blobs"]
[dependencies]
# Core dependencies
tokio = { version = "1", features = ["full"] }
@ -42,6 +52,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"
@ -106,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"

View File

@ -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,

View File

@ -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<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let accepted_mints: Vec<String> = 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<serde_json::Value> {

View File

@ -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<String>,
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,

View File

@ -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,
}
}
}

View File

@ -268,9 +268,32 @@ async fn fetch_one(client: &reqwest::Client, url: &str) -> anyhow::Result<AppCat
}
let body = resp.text().await?;
let catalog: AppCatalog = serde_json::from_str(&body)?;
// NOTE (DHT Phase 0): when `catalog.signature` is present, verify it against
// the seed-derived release-root pubkey here before accepting. Until signing
// ships we accept unsigned catalogs (same trust level as today's manifest).
// DHT Phase 0 authenticity: verify the release-root signature when present.
// We verify against the raw JSON (the exact bytes the publisher signed),
// not a re-serialization of the typed struct, so unknown forward-compat
// fields stay part of the signed preimage. Unsigned catalogs are still
// accepted during the migration window — same trust level as today's
// manifest — but a *present* signature that fails is a hard reject so a
// tampering mirror cannot pass off altered bytes.
let raw: serde_json::Value = serde_json::from_str(&body)?;
match crate::trust::verify_detached(&raw)? {
crate::trust::SignatureStatus::Unsigned => {
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)
}

View File

@ -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 `"<alg>:<hex>"`, 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 `"<alg>:<hex>"` 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 `"<alg>:<hex>"` string.
pub fn parse(s: &str) -> Result<Self> {
let (alg_part, hex_part) = s
.split_once(':')
.ok_or_else(|| anyhow!("digest must be '<alg>:<hex>', 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
}
}

View File

@ -36,6 +36,7 @@ mod bootstrap;
mod config;
mod constants;
mod container;
mod content_hash;
mod content_server;
mod crash_recovery;
mod credentials;
@ -66,8 +67,10 @@ mod settings;
mod state;
mod storage_crypto;
mod streaming;
mod swarm;
mod totp;
mod transport;
mod trust;
mod update;
mod vpn;
mod wallet;

View File

@ -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<Keys> {
pub(crate) async fn load_or_create_nostr_keys(identity_dir: &Path) -> Result<Keys> {
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<Option<Keys>>
/// 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<Client> {
pub(crate) fn build_nostr_client(keys: Keys, tor_proxy: Option<&str>) -> Result<Client> {
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))?;

View File

@ -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<SigningKey> {
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<SigningKey> {
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<SigningKey> {
let info = format!("archipelago/identity/{}/ed25519/v1", index);
@ -560,4 +578,41 @@ mod tests {
"3a94fb32efab2a5025401d53fd7d82b41323a5c06ad14ce528ebe3a813d88831"
);
}
#[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()),
"613ab879e5fbd4fcded32bc7ffad662fff1ce0f744c69baa63e7416ffabe7b71",
"release-root private key KAT"
);
assert_eq!(
hex::encode(key.verifying_key().to_bytes()),
"995eaf9188617f0ecbcff9cd44d57adb9aa7dd5f34db2733e97f3e317fb0aba2",
"release-root public key KAT"
);
}
}

View File

@ -150,6 +150,31 @@ 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);
}
// 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();

View File

@ -0,0 +1,262 @@
//! 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::str::FromStr;
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::payment::PaymentPolicy;
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
/// [`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).
async fn providers_for(&self, hash: &Hash) -> Vec<EndpointId>;
}
/// 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<String>,
tor_proxy: Option<String>,
}
impl NostrSeedDiscovery {
pub fn new(relays: Vec<String>, tor_proxy: Option<String>) -> Self {
Self { relays, tor_proxy }
}
}
#[async_trait]
impl ProviderDiscovery for NostrSeedDiscovery {
async fn providers_for(&self, hash: &Hash) -> Vec<EndpointId> {
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.
#[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<Arc<dyn ProviderDiscovery>>,
/// 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)]
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<Arc<dyn ProviderDiscovery>>,
) -> Result<Self> {
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.
// 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));
// 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 {
endpoint,
store,
_router: router,
discovery,
data_dir: data_dir.to_path_buf(),
pay_policy: PaymentPolicy::free(),
})
}
/// This node's iroh endpoint id — what Phase 3 advertises as a seed address.
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]
impl BlobProvider for IrohProvider {
fn name(&self) -> &str {
"iroh"
}
async fn try_fetch(&self, digest: &ContentDigest, dest: &Path) -> Result<bool> {
// 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).await,
None => Vec::new(),
};
if providers.is_empty() {
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, allowed)
.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)
}
}

View File

@ -0,0 +1,358 @@
//! 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, OnceLock};
use anyhow::Result;
use async_trait::async_trait;
use tracing::{debug, info, warn};
use crate::content_hash::ContentDigest;
pub mod payment;
pub mod seed_advert;
#[cfg(feature = "iroh-swarm")]
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 {
/// 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<bool>;
}
/// 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<Arc<dyn BlobProvider>>,
/// Context for announcing held public blobs; `None` when seeding is off.
#[cfg(feature = "iroh-swarm")]
announce: Option<AnnounceCtx>,
}
#[cfg(feature = "iroh-swarm")]
struct AnnounceCtx {
iroh: Arc<iroh_provider::IrohProvider>,
relays: Vec<String>,
tor_proxy: Option<String>,
identity_dir: std::path::PathBuf,
}
static RUNTIME: OnceLock<SwarmRuntime> = 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<dyn iroh_provider::ProviderDiscovery> =
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<Arc<dyn BlobProvider>> = 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 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<Arc<dyn BlobProvider>> {
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.
///
/// 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<F, Fut>(
digest: &ContentDigest,
providers: &[Arc<dyn BlobProvider>],
dest: &Path,
origin: F,
) -> Result<FetchSource>
where
F: FnOnce() -> Fut,
Fut: std::future::Future<Output = Result<()>>,
{
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<Vec<u8>>,
}
#[async_trait]
impl BlobProvider for FixedProvider {
fn name(&self) -> &str {
self.name
}
async fn try_fetch(&self, _d: &ContentDigest, dest: &Path) -> Result<bool> {
match &self.payload {
Some(p) => {
tokio::fs::write(dest, p).await?;
Ok(true)
}
None => Ok(false),
}
}
}
fn arc(p: FixedProvider) -> Arc<dyn BlobProvider> {
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<Arc<dyn BlobProvider>> = 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);
}
}

View File

@ -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<u64, Option<EndpointId>> = 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<EndpointId>,
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);
}
}

View File

@ -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<String>,
}
/// 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<String>,
},
/// 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::<PaidRequest>(&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<bool> {
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<PaidResponse> {
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:?}"),
}
}
}

View File

@ -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<Option<String>> {
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());
}
}

View File

@ -0,0 +1,221 @@
//! 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":"<iroh 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 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;
#[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<String> {
if event.kind != Kind::Custom(ARCHIPELAGO_SEED_KIND) {
return None;
}
serde_json::from_str::<AdvertContent>(&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<Item = &'a Event>) -> Vec<String> {
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
}
/// 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<String> {
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::*;
#[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()]);
}
}

View File

@ -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<VerifyingKey> {
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<VerifyingKey> {
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());
}
}

View File

@ -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<u8> {
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<String, Value> = 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]})));
}
}

View File

@ -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<base58btc(0xed01 || 32-byte-pubkey)>`
//! (`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<VerifyingKey> {
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());
}
}

View File

@ -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};

View File

@ -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};
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<SignatureStatus> {
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<String, Value>) -> Result<Vec<u8>> {
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<Signature> {
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());
}
}

View File

@ -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:<hex>"`), 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<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@ -798,7 +803,53 @@ pub async fn download_update(data_dir: &Path) -> Result<DownloadProgress> {
}
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?;
}
}
// 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?;
}
downloaded += component.size_bytes;
DOWNLOAD_BYTES.store(downloaded, Ordering::Relaxed);
info!(
@ -993,6 +1044,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
@ -1675,6 +1745,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(),
@ -1683,6 +1754,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,
},
],
};
@ -1882,9 +1954,10 @@ mod tests {
tokio::fs::write(staging.join("archipelago"), b"staged")
.await
.unwrap();
// A *complete* staged update carries the marker; without it the state
// self-heal correctly treats this as a partial download and clears
// update_in_progress (see has_staged_update / #26).
// 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();
@ -1902,6 +1975,7 @@ mod tests {
download_url: "https://example.com/binary".to_string(),
sha256: "abc123".to_string(),
size_bytes: 5000,
blake3: None,
}],
}),
update_in_progress: true,

View File

@ -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<String, u64> = 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<usize>, 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<String> {
// ── 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<bool> {
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<u64> {
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<Proof> = 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<String> {
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<String> {
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<String> {
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<String> {
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<Vec<PendingSwap>> {
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<u64> {
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 `"<from>|<to>"` (normalized URLs).
routes: std::collections::BTreeMap<String, RouteStat>,
}
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<u64> {
// 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),
}
}
}

229
docs/dht-RESUME.md Normal file
View File

@ -0,0 +1,229 @@
# DHT work — RESUME HERE
**Last updated:** 2026-06-16 · **Branch:** `agent-trust-wip` · **Worktree:** `~/Projects/archy-dht`
This file is the single source of truth for resuming the DHT / peer-distribution
work after a restart. Read it top to bottom, run the **Verify state** block, then
continue at **Next step**.
---
## ⚠️ CRITICAL — where to work (do not skip)
- **Work ONLY in the worktree `~/Projects/archy-dht` on branch `agent-trust-wip`.**
- **NEVER run git checkout / branch-switch / commit in the shared tree `~/Projects/archy`.**
Another agent cuts releases on `main` there. Git branch state is **global to one
working tree**, so a checkout in the shared tree drags every session onto that
branch and can clobber uncommitted work. That already happened once — the worktree
exists specifically to prevent it. See memory `feedback_concurrent_agent_tree`.
- The shared tree stays on `main` for the release agent. Leave it alone.
## Build facts (so you don't get surprised)
- It's a **binary** crate: test with `cargo test --bin archipelago -- <filter>`
(there is no lib target).
- The **test profile is opt-level=3** → every incremental test rebuild of the
`archipelago` crate is **~5 min**; a cold build of the iroh feature tree is ~19 min.
Budget for it. Run builds in the background and poll.
- Default build = no iroh. The iroh swarm engine is behind the **`iroh-swarm`**
Cargo feature (off by default): `cargo build --features iroh-swarm`.
- Plain `cargo build` (no feature) is the fleet build and is unaffected by any DHT work.
## Verify state (run these first on resume)
```bash
cd ~/Projects/archy-dht
git branch --show-current # → agent-trust-wip
git log --oneline -7 # see the commit list below
git status --short # should be clean (or your in-progress edits)
git worktree list # archy-dht → agent-trust-wip; archy → main
# sanity compile (default, fast-ish):
cargo build --bin archipelago 2>&1 | tail -3
```
---
## What is DONE (committed on `agent-trust-wip`)
Design doc: `docs/dht-distribution-design.md` (the full plan).
| Commit | Phase | Summary |
| --- | --- | --- |
| `0fef8086` | base | parked trust module + `seed::derive_release_root_ed25519` (pre-existing) |
| `27f11bf8` | **0** | signed-catalog authenticity wired: `trust/` module verifies the release-root detached signature in `app_catalog::fetch_one`; release-root KAT pinned |
| `f0cb91ed` | **1** | BLAKE3 alongside SHA-256: `content_hash.rs`, `ComponentUpdate.blake3`, `BlobMeta.blake3` |
| `2523c9e3` | **2 seam** | `swarm/mod.rs``BlobProvider` + `fetch_content_addressed` (verify peer bytes, origin-always-wins); `iroh-swarm` flag; wired into `update.rs` |
| `082946aa` | **2 engine** | real `swarm/iroh_provider.rs` over iroh 1.0 + iroh-blobs 0.103 (optional deps). Dep tree proven to resolve+compile against the pinned stack |
| `9fa56a82` | **3 core** | `swarm/seed_advert.rs` — signed Nostr seed-advertisement protocol (NIP-33 kind 30081, d-tag=blake3) |
All tests green at each step. Total new modules: `trust/`, `content_hash.rs`, `swarm/`.
## task #12 — Phase 3 glue + wiring — DONE (2026-06-17, NOT yet committed)
Implemented in the worktree, **uncommitted** (release in flight — do not commit/merge
until the user says so). Verified: default `cargo build` clean, `cargo build
--features iroh-swarm` clean, `cargo test --bin archipelago -- swarm::` → **8/8 pass**.
1. **`NostrSeedDiscovery`** (`swarm/iroh_provider.rs`) — `ProviderDiscovery` made
**async** (`#[async_trait]`); impl queries relays via the new
`seed_advert::fetch_seed_endpoint_ids` and parses each string with
`EndpointId::from_str` (`EndpointId = PublicKey`, has `FromStr`/`Display`),
skipping unparseable. `try_fetch` now `.await`s discovery.
2. **Publish path** — dep-free `seed_advert::fetch_seed_endpoint_ids` +
`publish_seed_advert` (reuse now-`pub(crate)` `build_nostr_client` /
`load_or_create_nostr_keys`); `IrohProvider::seed_and_advertise` imports the blob
into the FsStore (`blobs().add_path``TagInfo`) with a defensive hash-match,
then publishes. Scope: releases/catalog only.
3. **Wiring**`swarm::init()` builds the `IrohProvider` once at startup into a
`OnceLock<SwarmRuntime>` (keeps endpoint/router alive → keeps seeding);
`providers()` returns the registered provider; `announce_held_blob()` is called
from `update.rs` after each release component passes both hash gates. New config
`swarm_enabled` (`ARCHIPELAGO_SWARM_ENABLED`, default false); `server.rs` calls
`swarm::init`. All iroh code stays behind `iroh-swarm`; default build inert.
**iroh-blobs paid-serving spike (open Q#1) — RESOLVED:** `BlobsProtocol::new(&store,
Some(EventSender))` + `EventMask` intercept gives native per-request allow/deny
(`RequestMode::Intercept``Result<(), AbortReason>`), connection-level reject
(`ConnectMode::Intercept`), and per-request throttle/meter (`ThrottleMode::Intercept`).
## NEW: Phase 4+ plan (paid streaming / relay / IndeeHub) — `docs/phase4-streaming-ecash-plan.md`
Design for: (1) ecash-paid swarm transport, (2) networking through nodes / relay,
(3) IndeeHub "Archipelago" content source (signed Nostr film catalog, kind 30082).
Headline: ~80% already exists (Cashu wallet, `streaming/` payment gate + metering,
4-tier transport, the swarm above). Also shipped this session: a **Networking Profits
→ Settings** UI in `neode-ui` (new `views/web5/Web5NetworkingProfitsSettings.vue` +
route + button in `Web5QuickActions.vue` + `common.settings` i18n) that drives the
existing `streaming.list-services`/`configure-service` RPCs; free-everything is the
default (all services ship `enabled:false`). Frontend typechecks clean (pre-existing
`Web5ConnectedNodes.vue` `.did` errors are NOT ours). `neode-ui` deps were
`npm install`ed to complete a partial install.
## F2 step 1 — cross-mint ecash swap — DONE (2026-06-17, NOT yet committed)
Plan §2a / phasing F2 step 1. Implemented in `wallet/ecash.rs`, **uncommitted**
(release in flight). Verified: `cargo test --bin archipelago -- wallet::ecash`
**25/25 pass** (6 new), default build clean, `--features iroh-swarm` build clean.
- `is_mint_trusted(data_dir, url)` — swap-into allow-list. Home Fedimint always
trusted; any other mint must be on `accepted_mints` (normalized, trailing-slash
tolerant). Reuses the list the streaming gate already advertises to payers.
- `mint_quote_at` / `melt_quote_at` / `send_token_at(data_dir, mint_url, amount)`
the home-mint-hardcoded helpers parameterized by target mint. `send_token` now
delegates to `send_token_at` with the home mint.
- `swap_between_mints(data_dir, from, to, amount, max_fee_sats) -> u64` — mint-quote
on B → melt-quote on A → **fee-cap check** (`swap_fee` = total_paid delivered;
bail if > cap so caller falls back to free origin) → select+melt A proofs →
**persist the spend BEFORE claiming** (crash can't double-spend) → poll B invoice
until PAID/ISSUED (`wait_for_mint_quote_paid`, 60s/2s) → mint+claim on B. Both legs
recorded in the tx log (peer field carries the counterpart mint).
## F2 step 2 — payer-side auto-swap payment builder — DONE (2026-06-17, NOT yet committed)
Plan §2a step 2. Implemented in `wallet/ecash.rs`, **uncommitted**. Verified:
`cargo test --bin archipelago -- wallet::ecash`**34/34 pass** (9 new). All on the
default path (no feature gating) so the `iroh-swarm` tree is unaffected.
- `WalletState::spendable_by_mint() -> Vec<(mint_url, balance)>` — per-mint holdings.
- `PaymentPlan { Direct{mint}, Swap{from,to}, Insufficient }` + pure
`plan_payment(holdings, accepted: &[(mint, trusted)], amount)` — the policy:
**Direct beats Swap** (already-held mint, no fee, no trust needed); a **Swap target
must be trusted** (`is_mint_trusted`); home mint is the tie-break for both legs;
`Insufficient` → caller uses free origin. Pure/sync, unit-tested without a mint.
- `build_payment_token(data_dir, accepted_mints, amount_sats, max_fee_sats) -> token`
annotates the seeder's `accepted_mints` with trust, runs `plan_payment` against
`spendable_by_mint()`, then `send_token_at` (direct) or `swap_between_mints` +
`send_token_at` (swap, honoring the fee cap). Bails (→ origin) when nothing covers
the amount within balance/trust/fee. This is the builder the fetch side calls.
## Fetch-side auto-pay + F2 step 3 hardening — DONE (2026-06-17, NOT yet committed)
Implemented; **uncommitted**. Verified: `cargo test --bin archipelago -- wallet::
swarm::` → **85/85 pass** (18 new across these + earlier steps), **0 warnings**,
default build clean. `--features iroh-swarm` build = (see below; re-run after these
edits).
- **`swarm/payment.rs`** (un-gated — builds without `iroh-swarm`): `PaymentPolicy
{ budget_sats, max_fee_sats }` + `auto_pay_token(data_dir, policy, accepted_mints,
price)` → `Ok(Some(token))` to pay / `Ok(None)` to use origin. Degrades any
wallet/mint error to `Ok(None)` so payment can never block content (origin always
wins). The on-wire token→peer exchange (in-band paid-blobs ALPN, "shape A") is the
remaining gap — deferred in the plan; this is the decision/builder brain it'll call.
- **`streaming.prepare-payment` RPC** (dispatcher + `handle_streaming_prepare_payment`):
the live, user-invokable entry to the payer-side builder. Params `{accepted_mints,
price_sats, budget_sats?, max_fee_sats?}` → `{status:"ready", token}` or
`{status:"declined"}`. This is what makes the whole payment chain reachable
(no dead code).
- **Idempotent swap resume** (`wallet/pending_swaps.json`): `swap_between_mints`
journals the in-flight swap (melt + mint quote ids) right after the source spend is
persisted, removes it on claim. `resume_pending_swaps(data_dir)` reclaims `PAID`
quotes, skips `ISSUED` (never double-claims), leaves unsettled — **wired at server
startup** (server.rs, after `swarm::init`).
- **Liquidity cache** (`wallet/swap_liquidity.json`): per-route success/failure;
`build_payment_token` orders swap targets by `target_liquidity_score` (proven routes
first, home still first). `swap_between_mints` records success/failure.
- Removed the unused `mint_quote_at`/`melt_quote_at` thin wrappers (swap calls
`MintClient` directly; nothing else used them).
## Shape-A paid-blobs negotiation ALPN — DONE (2026-06-17, NOT yet committed)
Plan §1 "shape A" — the on-wire exchange that lets a downloader pay a seeder before
fetching a gated blob. Implemented behind `iroh-swarm`; **uncommitted**. Compiles
clean (`cargo build --features iroh-swarm` → only the 2 pre-existing `trust/` warns).
**Caveat:** the request/grant *wire path* can only be fully verified with a live
two-node iroh test (serde + types are unit-tested; the QUIC round-trip is not).
- **`swarm/paid_alpn.rs`** (gated): ALPN `archy/paid-blobs/1` on a second handler on
the same endpoint/router. `PaidRequest { want, token? }` ↔ `PaidResponse
{ Granted | PaymentRequired{price_sats, accepted_mints} | Denied{reason} }`.
- **Serve side** `PaidBlobsProtocol` (`ProtocolHandler`): per bi-stream, keys the
peer by `connection.remote_id()`, runs `streaming::gate::check_gate(content-download,
peer, token, blob_size)`, maps to a verdict. Free when service disabled (default),
fail-OPEN (Granted) on gate error — mirrors `swarm/paid.rs`. A paid retry's token
opens the session the blob-GET gate then sees (same endpoint id → same session).
- **Fetch side** `negotiate_access(endpoint, data_dir, peer, hex, policy) -> bool`:
best-effort + additive. Asks with no token; on `PaymentRequired` calls
`payment::auto_pay_token` (cross-mint aware), retries with the token. Connect/
protocol failure ⇒ proceed (the GET gate is the real enforcement); explicit
`PaymentRequired` we won't/can't pay ⇒ skip peer → origin.
- **Wired into `iroh_provider.rs`**: registers the 2nd ALPN on the `Router`; `try_fetch`
negotiates with each discovered peer before `downloader.download`. `IrohProvider`
carries `data_dir` + `pay_policy` (defaults to `PaymentPolicy::free` → releases/
catalog never pay; a future film fetch passes a real budget).
### Remaining to make paid FILM fetch real (small, on top of shape A)
- Pass a non-free `PaymentPolicy` for the film scope (releases stay free) + surface an
auto-pay cap in Settings. The plumbing is all here; only the policy source is free.
- Live two-node integration test (tests/multinode/) to exercise the actual QUIC
request→pay→grant→GET path end to end.
## Remaining Phase 4 roadmap (NOT started — gated)
- **Relay protocol (§2b)** — single-hop paid `relay.fetch`. Needs design sign-off.
- **IndeeHub "Archipelago" source (steps AE)** — signed kind-30082 film catalog +
`film.catalog`/`GET /api/film/:blake3` + frontend source. Gated on user decisions
(publisher trust anchor, MinIO origin) + the external IndeeHub frontend repo.
**Shipping directive (user 2026-06-17):** ship the IndeeHub app change as a
**decoupled app-catalog update** (bump `releases/app-catalog.json`), not a binary
OTA. See `docs/phase4-streaming-ecash-plan.md` §4 note.
## After Phase 3
- **Phase 4** — IndeeHub films on the same blob layer (Blossom catalog + iroh swarm;
MinIO origin). Each HLS `.ts` segment = a content-addressed blob.
- **Phase 0 GO-LIVE (needs the user)** — the catalog/manifest signature anchor
`trust::anchor::RELEASE_ROOT_PUBKEY_HEX` is still `None`; the pinned KAT is the
TEST mnemonic, not the real key. Going live = signing ceremony with the **real
release master seed** (only the user has it) → derive release-root → bake its pubkey
into `anchor.rs` → sign the real `releases/app-catalog.json`. Until then verification
is advisory (verify-if-present, anchor not enforced).
## Mergeability
As of last check we were only ~4 commits diverged from `main`; the only shared-file
overlap is `seed.rs` + `update.rs`. **Do NOT merge to `main` while the release is in
flight** — that's the user's call. Sync (merge main → agent-trust-wip) once the
release lands and `main` is clean.
## Background build logs from the last session (may be stale)
`/tmp/dht-*.log` — phase test/build outputs. Safe to ignore/delete on resume.

View File

@ -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/<cid>` 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 05 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 06 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

View File

@ -0,0 +1,380 @@
# 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 03, 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<u64>`.
### 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 | lowmed | 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 | medhigh | 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.
> **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
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.**

View File

@ -26,6 +26,7 @@
"back": "Back",
"done": "Done",
"manage": "Manage",
"settings": "Settings",
"connect": "Connect",
"connecting": "Connecting...",
"disconnect": "Disconnect",

View File

@ -26,6 +26,7 @@
"back": "Volver",
"done": "Listo",
"manage": "Administrar",
"settings": "Configuración",
"connect": "Conectar",
"connecting": "Conectando...",
"disconnect": "Desconectar",

View File

@ -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',

View File

@ -0,0 +1,203 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { rpcClient } from '@/api/rpc-client'
import ToggleSwitch from '@/components/ToggleSwitch.vue'
const router = useRouter()
// Mirrors `crate::streaming::pricing::ServicePricing` (Metric is rename_all =
// "lowercase", so it round-trips verbatim back to streaming.configure-service).
type Metric = 'bytes' | 'milliseconds' | 'requests'
interface ServicePricing {
service_id: string
name: string
metric: Metric
step_size: number
price_per_step: number
min_steps: number
enabled: boolean
description: string
accepted_mints: string[]
}
const services = ref<ServicePricing[]>([])
const loading = ref(true)
const loadError = ref('')
const savingId = ref<string | null>(null)
const statusMsg = ref('')
const statusIsError = ref(false)
// "Free everything" is the default every service ships disabled. The banner
// reassures the user nothing is being charged for until they opt in.
const allFree = computed(() => services.value.every((s) => !s.enabled))
function showStatus(msg: string, isError: boolean) {
statusMsg.value = msg
statusIsError.value = isError
setTimeout(() => { statusMsg.value = '' }, 5000)
}
/** Human label for one priced step, e.g. "MB", "minute", "request". */
function unitLabel(metric: Metric, stepSize: number): string {
if (metric === 'bytes') {
if (stepSize === 1_073_741_824) return 'GB'
if (stepSize === 1_048_576) return 'MB'
if (stepSize === 1024) return 'KB'
return `${stepSize.toLocaleString()} bytes`
}
if (metric === 'milliseconds') {
if (stepSize === 3_600_000) return 'hour'
if (stepSize === 60_000) return 'minute'
if (stepSize === 1000) return 'second'
return `${stepSize.toLocaleString()} ms`
}
// requests
return stepSize === 1 ? 'request' : `${stepSize.toLocaleString()} requests`
}
function minimumNote(svc: ServicePricing): string {
if (svc.min_steps <= 0) return ''
return `Minimum purchase: ${svc.min_steps.toLocaleString()} ${unitLabel(svc.metric, svc.step_size)}${svc.min_steps > 1 ? 's' : ''}`
}
async function load() {
loading.value = true
loadError.value = ''
try {
const res = await rpcClient.call<{ services: ServicePricing[] }>({ method: 'streaming.list-services' })
services.value = (res.services || []).map((s) => ({
...s,
// Price must stay >= 1 sat: the backend rejects price_per_step == 0.
price_per_step: Math.max(1, s.price_per_step),
}))
} catch (e) {
loadError.value = e instanceof Error ? e.message : 'Failed to load services'
} finally {
loading.value = false
}
}
async function saveService(svc: ServicePricing) {
if (svc.price_per_step < 1) svc.price_per_step = 1
savingId.value = svc.service_id
try {
await rpcClient.call({
method: 'streaming.configure-service',
params: {
service_id: svc.service_id,
name: svc.name,
metric: svc.metric,
step_size: svc.step_size,
price_per_step: svc.price_per_step,
min_steps: svc.min_steps,
enabled: svc.enabled,
description: svc.description,
accepted_mints: svc.accepted_mints,
},
})
showStatus(
svc.enabled
? `Charging ${svc.price_per_step} sats per ${unitLabel(svc.metric, svc.step_size)} for ${svc.name}.`
: `${svc.name} is now free.`,
false,
)
} catch (e) {
showStatus(e instanceof Error ? e.message : 'Failed to save', true)
// Reload so the UI reflects the persisted truth after a failed write.
void load()
} finally {
savingId.value = null
}
}
onMounted(load)
</script>
<template>
<div class="pb-6">
<button
type="button"
class="text-xs px-3 py-1.5 mb-4 rounded-md bg-white/5 hover:bg-white/10 text-white/70 hover:text-white transition-colors"
@click="router.push('/dashboard/web5')"
> Back to Web5</button>
<div class="mb-6">
<h1 class="text-3xl font-bold text-white mb-2">Networking Profits Settings</h1>
<p class="text-white/70">
Control what your node charges other peers for. By default everything is shared for
free turn a service on to start earning sats (ecash) for it. Payments are collected
as Cashu tokens through your node's wallet.
</p>
</div>
<!-- Status message -->
<div
v-if="statusMsg"
role="status"
aria-live="polite"
class="mb-4 p-3 rounded-lg text-sm"
:class="statusIsError ? 'bg-red-500/20 text-red-300' : 'bg-green-500/20 text-green-300'"
>
{{ statusMsg }}
</div>
<!-- Everything-free reassurance banner -->
<div
v-if="!loading && allFree"
class="mb-6 p-4 rounded-lg bg-green-500/10 border border-green-500/20 flex items-center gap-3"
>
<span class="text-xl"></span>
<p class="text-sm text-green-200">
Everything is free. Your node isn't charging for anything enable a service below to
start earning.
</p>
</div>
<div v-if="loading" class="glass-card p-6 text-white/60 text-sm">Loading services</div>
<div v-else-if="loadError" class="glass-card p-6 text-red-300 text-sm">{{ loadError }}</div>
<div v-else class="space-y-4">
<div v-for="svc in services" :key="svc.service_id" class="glass-card p-6">
<div class="flex items-start justify-between gap-4 mb-3">
<div class="min-w-0">
<h2 class="text-lg font-semibold text-white">{{ svc.name }}</h2>
<p class="text-sm text-white/60 mt-0.5">{{ svc.description }}</p>
</div>
<div class="flex items-center gap-2 shrink-0">
<span class="text-xs" :class="svc.enabled ? 'text-orange-400' : 'text-white/40'">
{{ svc.enabled ? 'Paid' : 'Free' }}
</span>
<ToggleSwitch :model-value="svc.enabled" @update:model-value="(v) => (svc.enabled = v)" />
</div>
</div>
<div class="flex flex-col sm:flex-row sm:items-end gap-3">
<div class="flex-1" :class="{ 'opacity-40 pointer-events-none': !svc.enabled }">
<label class="text-xs text-white/50 block mb-1">Price</label>
<div class="flex items-center gap-2">
<input
v-model.number="svc.price_per_step"
type="number"
min="1"
step="1"
:disabled="!svc.enabled"
class="w-28 bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-orange-500/50"
/>
<span class="text-sm text-white/70">sats per {{ unitLabel(svc.metric, svc.step_size) }}</span>
</div>
<p v-if="minimumNote(svc)" class="text-xs text-white/40 mt-1">{{ minimumNote(svc) }}</p>
</div>
<button
@click="saveService(svc)"
:disabled="savingId === svc.service_id"
class="glass-button glass-button-warning px-4 py-2 rounded-lg text-sm disabled:opacity-50"
>
{{ savingId === svc.service_id ? 'Saving…' : 'Save' }}
</button>
</div>
</div>
</div>
</div>
</template>

View File

@ -17,6 +17,12 @@
<p v-if="profitsBreakdown.content_sales_sats > 0">Content: {{ profitsBreakdown.content_sales_sats.toLocaleString() }} sats</p>
<p v-if="profitsBreakdown.routing_fees_sats > 0">Routing: {{ profitsBreakdown.routing_fees_sats.toLocaleString() }} sats</p>
</div>
<button
@click="router.push('/dashboard/web5/networking-profits')"
class="w-full mt-auto px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
>
{{ t('common.settings') }}
</button>
</div>
<!-- DID Status -->