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:
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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()]);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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]})));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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);
|
||||
download_component_resumable(&client, component, &dest, downloaded).await?;
|
||||
|
||||
// DHT Phase 2: when the manifest pins a BLAKE3 digest, route the fetch
|
||||
// through the swarm seam (swarm-assist, origin always wins). With no
|
||||
// providers registered (iroh-swarm feature off) this is identical to
|
||||
// calling the resumable HTTP origin directly — same bytes, now
|
||||
// content-addressed. A swarm hit is BLAKE3-verified inside the seam;
|
||||
// we still enforce the mandatory SHA-256 gate on peer bytes here and
|
||||
// re-fetch from origin if a (consistency-broken) peer slips through.
|
||||
let digest = component.blake3.as_deref().and_then(|b| {
|
||||
let s = b.trim();
|
||||
let normalized = if s.contains(':') {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!("blake3:{s}")
|
||||
};
|
||||
crate::content_hash::ContentDigest::parse(&normalized).ok()
|
||||
});
|
||||
if let Some(digest) = digest {
|
||||
let client_ref = &client;
|
||||
let dest_ref = &dest;
|
||||
let source = crate::swarm::fetch_content_addressed(
|
||||
&digest,
|
||||
&crate::swarm::providers(),
|
||||
&dest,
|
||||
move || async move {
|
||||
download_component_resumable(client_ref, component, dest_ref, downloaded).await
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
if source == crate::swarm::FetchSource::Swarm {
|
||||
let bytes = tokio::fs::read(&dest).await?;
|
||||
if crate::content_hash::sha256_hex(&bytes) != component.sha256 {
|
||||
warn!(
|
||||
name = %component.name,
|
||||
"swarm bytes passed BLAKE3 but failed the SHA-256 manifest gate — re-fetching from origin"
|
||||
);
|
||||
let _ = tokio::fs::remove_file(&dest).await;
|
||||
download_component_resumable(&client, component, &dest, downloaded).await?;
|
||||
}
|
||||
}
|
||||
// This is a PUBLIC release blob and it just passed both the BLAKE3 and
|
||||
// SHA-256 gates — announce that we can now seed it to peers. Best-effort
|
||||
// and inert unless the iroh swarm is active; never blocks the install.
|
||||
crate::swarm::announce_held_blob(&digest.hex, &dest).await;
|
||||
} else {
|
||||
download_component_resumable(&client, component, &dest, downloaded).await?;
|
||||
}
|
||||
downloaded += component.size_bytes;
|
||||
DOWNLOAD_BYTES.store(downloaded, Ordering::Relaxed);
|
||||
info!(
|
||||
@@ -993,6 +1044,25 @@ async fn download_component_resumable(
|
||||
.context("read staging file for hash check")?;
|
||||
let hash = hex::encode(Sha256::digest(&bytes));
|
||||
if hash == component.sha256 {
|
||||
// DHT Phase 1: if the manifest also pins a BLAKE3 digest, it must
|
||||
// match too. SHA-256 stays the mandatory gate during migration;
|
||||
// BLAKE3 is the hash the iroh swarm will fetch/verify by, so a
|
||||
// present-but-wrong BLAKE3 means the bytes aren't swarm-consistent
|
||||
// — treat it like a SHA mismatch and re-download.
|
||||
if let Some(b3) = component.blake3.as_deref() {
|
||||
let expected = b3.trim().strip_prefix("blake3:").unwrap_or(b3.trim());
|
||||
let actual = crate::content_hash::blake3_hex(&bytes);
|
||||
if !actual.eq_ignore_ascii_case(expected) {
|
||||
let _ = tokio::fs::remove_file(dest).await;
|
||||
last_err = Some(anyhow::anyhow!(
|
||||
"BLAKE3 mismatch for {}: expected {}, got {}",
|
||||
component.name,
|
||||
expected,
|
||||
actual
|
||||
));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
// SHA mismatch — the file on disk is garbage. Nuke it and
|
||||
@@ -1675,6 +1745,7 @@ mod tests {
|
||||
download_url: "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.26-alpha/archipelago".into(),
|
||||
sha256: "x".into(),
|
||||
size_bytes: 1,
|
||||
blake3: None,
|
||||
},
|
||||
ComponentUpdate {
|
||||
name: "frontend".into(),
|
||||
@@ -1683,6 +1754,7 @@ mod tests {
|
||||
download_url: "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.26-alpha/frontend.tar.gz".into(),
|
||||
sha256: "y".into(),
|
||||
size_bytes: 2,
|
||||
blake3: None,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -1882,9 +1954,10 @@ mod tests {
|
||||
tokio::fs::write(staging.join("archipelago"), b"staged")
|
||||
.await
|
||||
.unwrap();
|
||||
// A *complete* staged update carries the marker; without it the state
|
||||
// self-heal correctly treats this as a partial download and clears
|
||||
// update_in_progress (see has_staged_update / #26).
|
||||
// A *complete* staged update carries the .download-complete marker;
|
||||
// without it has_staged_update() reads the staging as partial and the
|
||||
// load_state self-heal clears update_in_progress (see #26). This test
|
||||
// simulates a complete staging, so write the marker.
|
||||
tokio::fs::write(staging.join(STAGED_COMPLETE_MARKER), b"1")
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -1902,6 +1975,7 @@ mod tests {
|
||||
download_url: "https://example.com/binary".to_string(),
|
||||
sha256: "abc123".to_string(),
|
||||
size_bytes: 5000,
|
||||
blake3: None,
|
||||
}],
|
||||
}),
|
||||
update_in_progress: true,
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user