From 9fa56a8274054118c016de914a61b9ea6a84fc53 Mon Sep 17 00:00:00 2001 From: archipelago Date: Tue, 16 Jun 2026 15:13:35 -0400 Subject: [PATCH] =?UTF-8?q?feat(dht):=20Phase=203=20core=20=E2=80=94=20sig?= =?UTF-8?q?ned=20Nostr=20seed-advertisement=20protocol?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The discovery wire format that feeds the swarm's ProviderDiscovery seam: a node announces 'I seed blake3 H from iroh endpoint E' as a signed NIP-33 addressable Nostr event. Scope is releases/catalog content ONLY (decided 2026-06-16) — never private user blobs. - swarm/seed_advert.rs: kind 30081, d-tag = blake3 hex (one current advert per author+hash, latest-replaces), content {"v":1,"endpoint_id":...}. advertisement_builder / advertisement_filter / parse_endpoint_id / endpoint_ids_from_events (dedup). Endpoint ids stay opaque strings so the protocol is dep-light + unit-testable on the default build. 4/4 tests pass (sign->parse roundtrip, filter targeting, reject wrong-kind/ empty, dedup across nodes). Next (task #12): gated NostrSeedDiscovery glue (query relays, parse ids -> iroh::EndpointId), publish path, wire swarm::providers(). Co-Authored-By: Claude Opus 4.8 (1M context) --- core/archipelago/src/swarm/mod.rs | 2 + core/archipelago/src/swarm/seed_advert.rs | 134 ++++++++++++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 core/archipelago/src/swarm/seed_advert.rs diff --git a/core/archipelago/src/swarm/mod.rs b/core/archipelago/src/swarm/mod.rs index a142fef9..8abe1068 100644 --- a/core/archipelago/src/swarm/mod.rs +++ b/core/archipelago/src/swarm/mod.rs @@ -27,6 +27,8 @@ use tracing::{debug, info, warn}; use crate::content_hash::ContentDigest; +pub mod seed_advert; + #[cfg(feature = "iroh-swarm")] pub mod iroh_provider; diff --git a/core/archipelago/src/swarm/seed_advert.rs b/core/archipelago/src/swarm/seed_advert.rs new file mode 100644 index 00000000..0f425b7c --- /dev/null +++ b/core/archipelago/src/swarm/seed_advert.rs @@ -0,0 +1,134 @@ +//! Phase 3 discovery — signed Nostr "seed advertisement" events. +//! +//! A node that holds a PUBLIC release / app-image blob (addressed by BLAKE3) +//! announces "I can seed hash H from iroh endpoint E" as a signed, NIP-33 +//! addressable Nostr event. **Scope is releases/catalog content ONLY** — never +//! private user blobs (decided 2026-06-16): smallest privacy surface, covers +//! the OTA + app-install use-cases. Discovery queries these events to find +//! swarm seeds for a hash; the iroh provider then dials those endpoints. +//! +//! Event shape (NIP-33 addressable, kind [`ARCHIPELAGO_SEED_KIND`]): +//! - `d` tag = blake3 hex of the content → one current advert per (author, hash) +//! - content = `{"v":1,"endpoint_id":""}` +//! - author pubkey = the node's seed-derived Nostr identity (signs the event) +//! +//! Endpoint ids stay opaque strings here so this protocol layer builds/parses/ +//! publishes/queries WITHOUT the heavy iroh dep; only the `iroh-swarm` +//! discovery glue parses the string into an `iroh::EndpointId`. + +// The publish/query path that calls these lives behind `iroh-swarm` (it needs +// the node's iroh EndpointId), so in the default build they're exercised only +// by unit tests — allow them to stand without a production caller. +#![allow(dead_code)] + +use nostr_sdk::{Event, EventBuilder, Filter, Kind, Tag}; +use serde::{Deserialize, Serialize}; + +/// NIP-33 addressable kind for Archipelago seed advertisements. +/// Distinct from the node-discovery app-data kind (30078). +pub const ARCHIPELAGO_SEED_KIND: u16 = 30081; + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct AdvertContent { + v: u8, + endpoint_id: String, +} + +/// Build the (unsigned) advertisement event for `blake3_hex` served from +/// `endpoint_id`. Sign with the node's Nostr key (`.sign_with_keys()` / +/// `.sign()`) or publish via `client.send_event_builder()`. +pub fn advertisement_builder(blake3_hex: &str, endpoint_id: &str) -> EventBuilder { + let content = serde_json::to_string(&AdvertContent { + v: 1, + endpoint_id: endpoint_id.to_string(), + }) + .expect("serialize advert content"); + EventBuilder::new(Kind::Custom(ARCHIPELAGO_SEED_KIND), content) + .tag(Tag::identifier(blake3_hex.to_string())) +} + +/// Filter matching all current seed advertisements for `blake3_hex` (one per +/// advertising node; NIP-33 latest-replaces per author). +pub fn advertisement_filter(blake3_hex: &str) -> Filter { + Filter::new() + .kind(Kind::Custom(ARCHIPELAGO_SEED_KIND)) + .identifier(blake3_hex.to_string()) +} + +/// Extract the advertised endpoint id from an event, or `None` if it is the +/// wrong kind or malformed. +pub fn parse_endpoint_id(event: &Event) -> Option { + if event.kind != Kind::Custom(ARCHIPELAGO_SEED_KIND) { + return None; + } + serde_json::from_str::(&event.content) + .ok() + .map(|c| c.endpoint_id) + .filter(|s| !s.is_empty()) +} + +/// Collect the unique advertised endpoint ids across a set of events, skipping +/// malformed ones. Order-preserving, de-duplicated. +pub fn endpoint_ids_from_events<'a>(events: impl IntoIterator) -> Vec { + let mut seen = std::collections::HashSet::new(); + let mut out = Vec::new(); + for ev in events { + if let Some(id) = parse_endpoint_id(ev) { + if seen.insert(id.clone()) { + out.push(id); + } + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use nostr_sdk::Keys; + + #[test] + fn build_sign_parse_roundtrip() { + let keys = Keys::generate(); + let hash = "a".repeat(64); + let endpoint = "node-example-endpoint-id"; + let event = advertisement_builder(&hash, endpoint) + .sign_with_keys(&keys) + .unwrap(); + assert_eq!(event.kind, Kind::Custom(ARCHIPELAGO_SEED_KIND)); + assert_eq!(parse_endpoint_id(&event).as_deref(), Some(endpoint)); + } + + #[test] + fn filter_targets_the_hash_dtag_and_kind() { + let hash = "b".repeat(64); + let json = serde_json::to_string(&advertisement_filter(&hash)).unwrap(); + assert!(json.contains(&hash), "filter must target the hash d-tag"); + assert!(json.contains("30081"), "filter must constrain the seed kind"); + } + + #[test] + fn parse_rejects_wrong_kind_and_empty_endpoint() { + let keys = Keys::generate(); + let wrong_kind = EventBuilder::new(Kind::Custom(1), "{}") + .sign_with_keys(&keys) + .unwrap(); + assert_eq!(parse_endpoint_id(&wrong_kind), None); + let empty_endpoint = advertisement_builder(&"c".repeat(64), "") + .sign_with_keys(&keys) + .unwrap(); + assert_eq!(parse_endpoint_id(&empty_endpoint), None); + } + + #[test] + fn dedups_endpoint_ids_across_events() { + let a = Keys::generate(); + let b = Keys::generate(); + let hash = "d".repeat(64); + let e1 = advertisement_builder(&hash, "endpoint-A").sign_with_keys(&a).unwrap(); + let e2 = advertisement_builder(&hash, "endpoint-A").sign_with_keys(&b).unwrap(); + let e3 = advertisement_builder(&hash, "endpoint-B").sign_with_keys(&b).unwrap(); + let ids = endpoint_ids_from_events([&e1, &e2, &e3]); + assert_eq!(ids, vec!["endpoint-A".to_string(), "endpoint-B".to_string()]); + } +}