Archipelago — open-source initial import
This commit is contained in:
@@ -0,0 +1,263 @@
|
||||
//! 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,377 @@
|
||||
//! 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,314 @@
|
||||
//! 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,167 @@
|
||||
//! 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,233 @@
|
||||
//! 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()]
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user