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:
commit
7c458ede8e
3148
core/Cargo.lock
generated
3148
core/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -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"
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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> {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
149
core/archipelago/src/content_hash.rs
Normal file
149
core/archipelago/src/content_hash.rs
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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))?;
|
||||
|
||||
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
262
core/archipelago/src/swarm/iroh_provider.rs
Normal file
262
core/archipelago/src/swarm/iroh_provider.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
358
core/archipelago/src/swarm/mod.rs
Normal file
358
core/archipelago/src/swarm/mod.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
194
core/archipelago/src/swarm/paid.rs
Normal file
194
core/archipelago/src/swarm/paid.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
306
core/archipelago/src/swarm/paid_alpn.rs
Normal file
306
core/archipelago/src/swarm/paid_alpn.rs
Normal 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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
166
core/archipelago/src/swarm/payment.rs
Normal file
166
core/archipelago/src/swarm/payment.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
221
core/archipelago/src/swarm/seed_advert.rs
Normal file
221
core/archipelago/src/swarm/seed_advert.rs
Normal 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()]);
|
||||
}
|
||||
}
|
||||
71
core/archipelago/src/trust/anchor.rs
Normal file
71
core/archipelago/src/trust/anchor.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
87
core/archipelago/src/trust/canonical.rs
Normal file
87
core/archipelago/src/trust/canonical.rs
Normal 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]})));
|
||||
}
|
||||
}
|
||||
56
core/archipelago/src/trust/did.rs
Normal file
56
core/archipelago/src/trust/did.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
23
core/archipelago/src/trust/mod.rs
Normal file
23
core/archipelago/src/trust/mod.rs
Normal 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};
|
||||
191
core/archipelago/src/trust/signed_doc.rs
Normal file
191
core/archipelago/src/trust/signed_doc.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
// 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,
|
||||
|
||||
@ -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
229
docs/dht-RESUME.md
Normal 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 A–E)** — signed kind-30082 film catalog +
|
||||
`film.catalog`/`GET /api/film/:blake3` + frontend source. Gated on user decisions
|
||||
(publisher trust anchor, MinIO origin) + the external IndeeHub frontend repo.
|
||||
**Shipping directive (user 2026-06-17):** ship the IndeeHub app change as a
|
||||
**decoupled app-catalog update** (bump `releases/app-catalog.json`), not a binary
|
||||
OTA. See `docs/phase4-streaming-ecash-plan.md` §4 note.
|
||||
|
||||
## After Phase 3
|
||||
|
||||
- **Phase 4** — IndeeHub films on the same blob layer (Blossom catalog + iroh swarm;
|
||||
MinIO origin). Each HLS `.ts` segment = a content-addressed blob.
|
||||
- **Phase 0 GO-LIVE (needs the user)** — the catalog/manifest signature anchor
|
||||
`trust::anchor::RELEASE_ROOT_PUBKEY_HEX` is still `None`; the pinned KAT is the
|
||||
TEST mnemonic, not the real key. Going live = signing ceremony with the **real
|
||||
release master seed** (only the user has it) → derive release-root → bake its pubkey
|
||||
into `anchor.rs` → sign the real `releases/app-catalog.json`. Until then verification
|
||||
is advisory (verify-if-present, anchor not enforced).
|
||||
|
||||
## Mergeability
|
||||
|
||||
As of last check we were only ~4 commits diverged from `main`; the only shared-file
|
||||
overlap is `seed.rs` + `update.rs`. **Do NOT merge to `main` while the release is in
|
||||
flight** — that's the user's call. Sync (merge main → agent-trust-wip) once the
|
||||
release lands and `main` is clean.
|
||||
|
||||
## Background build logs from the last session (may be stale)
|
||||
`/tmp/dht-*.log` — phase test/build outputs. Safe to ignore/delete on resume.
|
||||
185
docs/dht-distribution-design.md
Normal file
185
docs/dht-distribution-design.md
Normal 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 0–5 dep (revisit
|
||||
post-Phase 3)." iroh hitting 1.0 removes the main reason for that deferral — **this design
|
||||
reverses that non-choice** and adopts iroh as the swarm layer, collapsing the from-scratch
|
||||
Kademlia work.
|
||||
|
||||
## 4. Recommended architecture — three layers, one engine
|
||||
|
||||
Build **one** peer-distribution layer; use it for all three use-cases.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
Authenticity │ Signed Nostr events (per-node npub) + │ "who published this,
|
||||
& Discovery │ seed-derived RELEASE ROOT key for OTA + │ who has it"
|
||||
│ Blossom BUD catalog for IndeeHub │
|
||||
└─────────────────────────────────────────────┘
|
||||
┌─────────────────────────────────────────────┐
|
||||
Integrity & │ BLAKE3 content addressing (iroh-native, │ "name bytes by hash,
|
||||
Addressing │ range-verifiable). SHA-256 kept in manifest │ verify on arrival"
|
||||
│ during migration window. │
|
||||
└─────────────────────────────────────────────┘
|
||||
┌─────────────────────────────────────────────┐
|
||||
Transport & │ iroh-blobs swarm (peers that already have │ "move the bytes"
|
||||
Swarm │ it) ─── fallback ───▶ OVH HTTP / MinIO │
|
||||
│ origin (ALWAYS wins) │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **Integrity/addressing — BLAKE3.** iroh-native, supports verified *range* streaming
|
||||
(essential for HLS + resumable). Keep SHA-256 in the manifest for back-compat through the
|
||||
migration window; add a `blake3` field alongside.
|
||||
- **Discovery/authenticity — signed Nostr events + release root key.**
|
||||
- OTA: the **Phase 0 seed-derived release root key** signs the manifest (BLAKE3 root hash
|
||||
+ version). Integrity ≠ authenticity — content addressing proves *bytes are intact*, the
|
||||
signature proves *we authorized them*. Both are required.
|
||||
- "Who has blob X" advertised via signed Nostr events `{content-hash, provider-npub, ts}`,
|
||||
so nodes find seeds without a central tracker.
|
||||
- IndeeHub: Blossom BUDs for the film catalog + provider/mirror lists.
|
||||
- **Transport/swarm — iroh-blobs, origin fallback.** Node asks the swarm for a hash; peers
|
||||
that have it serve range-verified BLAKE3 streams; if the swarm yields nothing, fall back to
|
||||
the existing resumable HTTP path (`update.rs:821`) against OVH/MinIO. **A node that
|
||||
finishes a download automatically becomes a seed.**
|
||||
|
||||
### Bulletproof posture
|
||||
The swarm sits *above* a proven HTTP path, never in place of it. Worst case (every peer
|
||||
offline, iroh bug, NAT failure) the node downloads exactly as it does today. iroh 1.0 is new;
|
||||
this containment is deliberate.
|
||||
|
||||
## 5. Use-case flows
|
||||
|
||||
### OTA / app installs
|
||||
1. Node reads the **signed** manifest (via signed Nostr event or HTTP), gets BLAKE3 root hash
|
||||
+ release-root signature; verify signature → reject on failure.
|
||||
2. Query swarm (signed provider events) for peers holding that hash.
|
||||
3. Download range-verified BLAKE3 stream from peers; verify full BLAKE3 (+ SHA-256 during
|
||||
migration).
|
||||
4. No peers / failure → resumable HTTP from OVH (current path).
|
||||
5. Apply + health-probe + auto-rollback (unchanged). Updated node **becomes a seed**.
|
||||
6. OCI images: content-address image layers the same way; OVH registry stays the origin.
|
||||
|
||||
### IndeeHub streaming ("backstage → any node")
|
||||
1. Creator publishes a film in **backstage** → FFmpeg → HLS; **each .ts segment is a
|
||||
content-addressed (BLAKE3) blob**, immutable and small → ideal swarm objects.
|
||||
2. Publish a **signed Nostr event** advertising title + segment hashes (Blossom catalog).
|
||||
3. Any node running IndeeHub resolves the content address and **streams from the nearest
|
||||
node(s) that have it stored/cached** via iroh range streaming; MinIO/OVH is origin.
|
||||
4. AES-128 key delivery + NIP-98 auth unchanged (keys gate decryption; swarm only moves
|
||||
encrypted segments — so untrusted seeds can cache without seeing plaintext).
|
||||
|
||||
## 6. Phasing (folds into the existing Phase 0–6 plan)
|
||||
|
||||
0. **Signed manifests (required first, unbuilt).** `derive_release_root_ed25519` /
|
||||
`derive_release_root_nostr` in `seed.rs` (HKDF `archipelago/release/root/ed25519/v1`,
|
||||
`.../secp256k1/v1`); `core/src/trust/` (anchor/bundle/manifest/timestamp/nostr); JCS
|
||||
canonical JSON; ceremony scripts; `manifest-v2.json` with signature. Gives *authenticity*,
|
||||
which content-addressing does not.
|
||||
1. **BLAKE3 alongside SHA-256** in the manifest + `blobs.rs`.
|
||||
2. **iroh-blobs PoC** behind a feature flag: serve OTA blobs from the swarm with HTTP
|
||||
fallback; measure on a scratch/test node, then the fleet.
|
||||
3. **Signed Nostr advertisement events** for releases (publisher identity + provider lists).
|
||||
4. **IndeeHub on the same blob layer** (Blossom catalog + iroh swarm; MinIO origin).
|
||||
|
||||
This collapses the old "Phase 4: build S/Kademlia from scratch" into "adopt iroh," a large
|
||||
de-risking.
|
||||
|
||||
## 7. Open decisions
|
||||
|
||||
- **BLAKE3 migration scope:** dual-hash window length; whether to re-hash historical
|
||||
releases or only BLAKE3 going forward.
|
||||
- **iroh ↔ existing transports:** iroh brings its own QUIC + hole-punching + relays; decide
|
||||
how it coexists with FIPS/Tor (run iroh standalone first; integrate with `TransportRouter`
|
||||
later if useful).
|
||||
- **Seed retention policy:** how long nodes keep blobs to seed others (disk pressure on small
|
||||
nodes); pinning rules for IndeeHub films vs. transient OTA blobs.
|
||||
- **Privacy:** iroh dial-by-key vs. Tor's anonymity; default transport per content type.
|
||||
|
||||
## References
|
||||
- iroh: https://github.com/n0-computer/iroh · iroh-blobs: https://github.com/n0-computer/iroh-blobs · docs: https://docs.iroh.computer/protocols/blobs
|
||||
- Blossom: https://github.com/hzrd149/blossom · NIP-B7: https://nips.nostr.com/B7 · nostr-blossom (Rust): https://docs.rs/nostr-blossom
|
||||
- Web5/DWN (rejected): https://github.com/decentralized-identity/web5-js · https://identity.foundation/decentralized-web-node/spec/ · https://block.xyz/inside/block-contributes-digital-identity-components-to-the-decentralized-identity-foundation
|
||||
380
docs/phase4-streaming-ecash-plan.md
Normal file
380
docs/phase4-streaming-ecash-plan.md
Normal 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 0–3, swarm + Blossom), the
|
||||
Phase 3 swarm work just landed (`swarm/`, `content_hash.rs`, `trust/`).
|
||||
|
||||
This plans three things the user asked for, in one coherent architecture:
|
||||
|
||||
1. **Pay sats (ecash) for transport** of streaming film data between nodes.
|
||||
2. **Networking *through* nodes** — relaying/routing a stream via intermediate peers.
|
||||
3. An **"Archipelago" content source in IndeeHub** that shows every film uploaded
|
||||
to *backstage*, on every node running the IndeeHub app.
|
||||
|
||||
> ## Headline finding
|
||||
> **Most of the primitives already exist.** This is ~80% integration glue, not
|
||||
> greenfield. A full Cashu/ecash wallet, a metered streaming payment gate, a
|
||||
> 4-tier transport layer, the iroh-blobs swarm (just added), signed Nostr
|
||||
> advertisements, and the Ed25519 trust module are all already in the tree. The
|
||||
> genuinely new code is: (a) a paid-serving hook on the iroh side, (b) a relay
|
||||
> protocol, and (c) the IndeeHub film catalog + Archipelago-local API.
|
||||
|
||||
---
|
||||
|
||||
## 0. Inventory — what we can build on (all already in `core/archipelago/src`)
|
||||
|
||||
| Capability | Where | State |
|
||||
| --- | --- | --- |
|
||||
| **Cashu ecash wallet** (mint/melt/send/receive, BDHKE) | `wallet/ecash.rs`, `wallet/cashu.rs`, `wallet/mint_client.rs`, `wallet/bdhke.rs` | ✅ implemented |
|
||||
| **Local mint** (Fedimint) backing the wallet | `apps/fedimint` (`http://127.0.0.1:8175`) | ✅ deployed |
|
||||
| **Lightning** (invoices, pay, channels) for mint/melt | `api/rpc/lnd/*`, `container/lnd.rs`, `apps/lnd` | ✅ implemented |
|
||||
| **Streaming payment gate** (accepts `cashuA` tokens, opens metered session) | `streaming/gate.rs` | ✅ implemented |
|
||||
| **Metering & pricing** (sats per byte / ms / request; e.g. content-download = 1 sat/MB) | `streaming/meter.rs`, `streaming/pricing.rs`, `streaming/session.rs` | ✅ implemented |
|
||||
| **Revenue/profit accounting** (incl. `StreamingRevenue` tx type) | `wallet/profits.rs` | ✅ implemented |
|
||||
| **Paid-service discovery** on Nostr (kind 10021, TollGate TIP-01 shape) | `streaming/advertisement.rs` | ✅ implemented |
|
||||
| **Content server** that verifies+receives payment before serving | `content_server.rs` (`verify_and_receive_payment()`) | ✅ implemented |
|
||||
| **iroh-blobs swarm** (fetch content-addressed blobs from peers, verify, seed) | `swarm/` (`iroh-swarm` feature) | ✅ just added |
|
||||
| **Signed seed adverts** (NIP-33 kind 30081, blake3→endpoint) | `swarm/seed_advert.rs` | ✅ just added |
|
||||
| **BLAKE3 content addressing** | `content_hash.rs` | ✅ implemented |
|
||||
| **Ed25519 trust / `did:key` / detached signatures** | `trust/` | ✅ implemented (anchor ceremony pending) |
|
||||
| **4-tier transport** (Mesh > LAN > FIPS > Tor) + `last_transport` | `transport/*`, `fips/dial.rs` | ✅ implemented |
|
||||
| **Node discovery + federation trust** (Trusted/Observer) | `nostr_handshake.rs`, `federation/*` | ✅ implemented |
|
||||
|
||||
What is **NOT** present and must be built:
|
||||
|
||||
- **A paid-serving hook on the iroh-blobs provider.** Today the swarm seeds to
|
||||
anyone (`BlobsProtocol::new(&store, None)` — no authorization). To charge for
|
||||
swarm bandwidth we need a per-request gate that consults `streaming/gate.rs`.
|
||||
- **A relay protocol.** No "peer A asks peer B to forward traffic to peer C".
|
||||
Transport is point-to-point; there is no multi-hop routing, TTL, or relay
|
||||
accounting.
|
||||
- **IndeeHub Archipelago catalog.** The shipped IndeeHub points at the external
|
||||
`staging-api.indeehub.studio` + AWS S3/CloudFront. Nothing makes a film
|
||||
uploaded on node A visible on node B. No *backstage* code exists yet.
|
||||
|
||||
---
|
||||
|
||||
## 1. Pay sats (ecash) for transport of streaming films
|
||||
|
||||
### Goal
|
||||
When node B streams a film blob (an HLS `.ts` segment) *from* node A's swarm,
|
||||
A earns sats for the bytes it serves — using the ecash gate that already meters
|
||||
`content-download`.
|
||||
|
||||
### What exists vs. what's new
|
||||
- ✅ The economic machinery is done: `streaming/pricing.rs` already ships a
|
||||
`content-download` service priced per MB; `streaming/gate.rs` turns a `cashuA`
|
||||
token into a metered session; `meter.rs` deducts bytes; `profits.rs` records
|
||||
`StreamingRevenue`.
|
||||
- ❌ The swarm serving path doesn't consult any of it. `IrohProvider::new`
|
||||
spins up `BlobsProtocol` that answers every blob request unconditionally.
|
||||
|
||||
### Design — "paid swarm" as a gated blob protocol
|
||||
The clean seam is the iroh-blobs **accept** side. Two viable shapes:
|
||||
|
||||
**(A) In-band gate via a custom ALPN (preferred).** Keep iroh-blobs for the raw
|
||||
byte transfer but front it with a tiny request/grant exchange on a second ALPN
|
||||
(`archy/paid-blobs/1`):
|
||||
1. B wants `blake3:H`. It dials A's endpoint and sends `{want: H, token?: cashuA}`.
|
||||
2. A calls `streaming::gate::check_gate("content-download", peer=B, bytes≈len(H), token)`.
|
||||
- `PaymentRequired` → A replies with price + its accepted mints
|
||||
(`streaming.list-mints`) and the sat amount; B mints/sends a `cashuA` and retries.
|
||||
- `PaidAndAllowed` / `Allowed` (within existing session allotment) → A authorizes
|
||||
the blob hash for this connection and hands off to iroh-blobs to stream it.
|
||||
3. A meters served bytes via `meter::record_and_check` and records revenue.
|
||||
|
||||
**(B) Pre-paid session, then open serving.** B opens a metered session up front
|
||||
(buys N MB of `content-download` allotment with one token), and A's blob protocol
|
||||
checks "does this peer have remaining allotment?" before each blob. Simpler, fewer
|
||||
round-trips, slightly looser accounting. Good first cut.
|
||||
|
||||
Recommend **(B) for v1** (least new protocol surface — reuses sessions verbatim),
|
||||
graduating to **(A)** when we want per-blob price discovery.
|
||||
|
||||
### Free vs. paid policy (important)
|
||||
- **OTA + app-catalog blobs stay FREE.** Charging for security updates is hostile
|
||||
and breaks the "origin always wins" guarantee. Gating applies **only** to the
|
||||
IndeeHub film scope (a per-blob or per-advert "monetized" flag).
|
||||
- Trusted federation peers (`TrustLevel::Trusted`) can be configured to serve each
|
||||
other free; payment is for untrusted/public swarm peers.
|
||||
|
||||
### Integration points
|
||||
- **DONE (2026-06-17):** `swarm/paid.rs` — the accept-side gate. Builds the
|
||||
iroh-blobs `EventSender` (intercept connect + GET, hard-disable `push`) and
|
||||
authorizes each request through `streaming::gate::check_gate("content-download",
|
||||
peer_endpoint, blob_size, None)`. Free when the service is disabled (default);
|
||||
denies unpaid peers when enabled; fails OPEN on internal error. Wired into
|
||||
`IrohProvider::new`; unit-tested. The Settings toggle the user just got drives it.
|
||||
- Reuse: `streaming/gate.rs`, `meter.rs`, `session.rs`, `wallet/ecash.rs`,
|
||||
`streaming/advertisement.rs` (advertise the node as a paid blob seeder).
|
||||
- TODO (fetch side): `swarm::fetch_content_addressed` gains an optional
|
||||
"willing-to-pay budget + token source" so a downloading node can auto-pay from
|
||||
its ecash wallet up to a cap (opening a session via `streaming.pay`), then fall
|
||||
back to origin if too expensive. This is where **cross-mint settlement (§2a)**
|
||||
plugs in — the payer may need to swap into the seeder's accepted mint first.
|
||||
|
||||
---
|
||||
|
||||
## 2. Networking *through* nodes (relayed / routed streaming)
|
||||
|
||||
This is the largest genuinely-new piece. Two distinct meanings — both useful:
|
||||
|
||||
### 2a. iroh-native relays (cheap, already mostly free)
|
||||
iroh 1.0 already hole-punches and falls back to **relay servers** for connectivity
|
||||
when a direct QUIC path can't be established. So "streaming through a node that
|
||||
can reach the seed when I can't" partly exists at the iroh layer. Action: run/seed
|
||||
our **own** iroh relay(s) on the OVH/hub infrastructure and pin them in config, so
|
||||
the swarm doesn't depend on n0's public relays. Low effort, high resilience.
|
||||
|
||||
### 2b. Application-level paid relay (the real gap)
|
||||
"Node B pays node A to fetch a film from origin/swarm on B's behalf and forward it"
|
||||
— useful when B is behind a censored/expensive link and A has good connectivity
|
||||
(the beta-cellular-node scenario from memory). This needs a real protocol:
|
||||
|
||||
- **`relay.offer` advert** (Nostr kind 10021 with a `relay` tag + price/MB) — reuse
|
||||
`streaming/advertisement.rs`; add a `relay-bandwidth` service to `pricing.rs`.
|
||||
- **`relay.fetch` request** over the existing transport (`PeerRequest` in
|
||||
`fips/dial.rs`): `{content: blake3:H | url, pay: cashuA}`. The relay runs the
|
||||
normal `swarm::fetch_content_addressed` (swarm-assist, origin fallback), meters
|
||||
the bytes through `streaming/gate`, and streams them back to the requester.
|
||||
- **Accounting:** add a `RelayBytes` metric to `streaming/meter.rs` distinct from
|
||||
origin `content-download`, so "relay provided" is tracked separately in
|
||||
`profits.rs` (the doc already separates `routing_fees` from `streaming_revenue`).
|
||||
- **Safety rails:** single-hop only for v1 (no A→B→C→D); TTL + loop guard before
|
||||
any multi-hop; cap per-session bytes; only relay the **public film scope**, never
|
||||
private user blobs or arbitrary URLs (prevent open-proxy abuse).
|
||||
|
||||
### Phasing for §2
|
||||
1. Pin our own iroh relays (config only). — *days*
|
||||
2. Single-hop paid `relay.fetch` for film blobs, gated by ecash. — *the core build*
|
||||
3. Multi-hop routing + path discovery. — *deferred; only if single-hop proves out*
|
||||
|
||||
---
|
||||
|
||||
## 2a. Cross-mint ecash settlement — paying across *different* mints
|
||||
|
||||
**Problem (user, 2026-06-17):** payment must work when the payer and the seeder
|
||||
use **different** mints — not only two nodes on the same Fedimint. A node holding
|
||||
tokens on mint **A** must be able to pay a seeder that only accepts mint **B**,
|
||||
automatically.
|
||||
|
||||
### Why this is mostly a generalization, not new crypto
|
||||
The wallet already tracks proofs **per-mint**: `WalletData::balance_for_mint(url)`,
|
||||
`select_proofs(url, amount)`, `add_proofs(url, proofs)` are all mint-scoped, and
|
||||
`MintClient::new(url)` targets any mint. What's hardcoded is convenience: `mint_quote`
|
||||
/ `melt_quote` / `mint_tokens` / `melt_tokens` always use the single home
|
||||
`wallet.mint_url`. So the data model is multi-mint already; we add the *swap* and
|
||||
parameterize the helpers by target mint.
|
||||
|
||||
### The swap primitive (Cashu/Fedimint settle over Lightning)
|
||||
To move value **A → B**, both mints expose BOLT11 mint+melt quotes (already in
|
||||
`mint_client.rs`), and Lightning bridges them:
|
||||
|
||||
1. `MintClient::new(B).mint_quote(amount)` → a BOLT11 invoice `inv_B` (pay it to get B tokens).
|
||||
2. `MintClient::new(A).melt_quote(inv_B)` → cost in A tokens (`amount + fee_reserve`).
|
||||
3. Select A proofs and `melt` them on A to pay `inv_B` over Lightning.
|
||||
4. When `inv_B` settles, `MintClient::new(B).mint_tokens(quote_B)` → claim B tokens;
|
||||
`wallet.add_proofs(B, …)`.
|
||||
|
||||
Net: value lands on B minus (A melt fee + LN routing + B mint fee). The node's LND
|
||||
isn't strictly required — the mints' own LN gateways settle — but a healthy local
|
||||
node/route improves success. Implementation = three thin `*_at(mint_url, …)`
|
||||
variants of the existing helpers + one composer:
|
||||
`swap_between_mints(data_dir, from, to, amount, max_fee_sats) -> Result<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 | low–med | trust/, app_catalog.rs pattern |
|
||||
| **C** | `GET /api/film/:blake3` range-streaming via swarm-assist + MinIO origin | med | swarm/, content_server.rs |
|
||||
| **D** | IndeeHub "Archipelago" source wired to the local API (additive) | med (frontend, external repo) | nginx sub_filter |
|
||||
| **E** | Backstage: upload → FFmpeg → HLS → blob import + catalog publish | med | MinIO/ffmpeg stack |
|
||||
| **F** | **DONE** — paid swarm serving (`swarm/paid.rs` gates the blob protocol via `streaming/gate`); free by default | med | streaming/* |
|
||||
| **F2** | Cross-mint settlement (§2a): `swap_between_mints` + payer-side auto-swap + mint trust list + fee cap | med–high | wallet/ecash, mint_client, lnd |
|
||||
| **G** | Pin our own iroh relays (config) | low | iroh |
|
||||
| **H** | Single-hop paid `relay.fetch` for film blobs | high | transport/, streaming/* |
|
||||
| **I** | Multi-hop routing | high / deferred | — |
|
||||
|
||||
A→E delivers "films on every node" with free volunteer seeding (the design-doc
|
||||
vision). F→H layer the sats economy on top. I is genuinely future work.
|
||||
|
||||
> **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.**
|
||||
@ -26,6 +26,7 @@
|
||||
"back": "Back",
|
||||
"done": "Done",
|
||||
"manage": "Manage",
|
||||
"settings": "Settings",
|
||||
"connect": "Connect",
|
||||
"connecting": "Connecting...",
|
||||
"disconnect": "Disconnect",
|
||||
|
||||
@ -26,6 +26,7 @@
|
||||
"back": "Volver",
|
||||
"done": "Listo",
|
||||
"manage": "Administrar",
|
||||
"settings": "Configuración",
|
||||
"connect": "Conectar",
|
||||
"connecting": "Conectando...",
|
||||
"disconnect": "Desconectar",
|
||||
|
||||
@ -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',
|
||||
|
||||
203
neode-ui/src/views/web5/Web5NetworkingProfitsSettings.vue
Normal file
203
neode-ui/src/views/web5/Web5NetworkingProfitsSettings.vue
Normal 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>
|
||||
@ -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 -->
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user