From 5962c975e960bb8bad2d46b635cec7e528dd4cc5 Mon Sep 17 00:00:00 2001 From: Paul Logan Date: Mon, 15 Jun 2026 22:50:51 -0700 Subject: [PATCH] =?UTF-8?q?feat(nostr):=20daemon=20pull-loop=20=E2=80=94?= =?UTF-8?q?=20receive=20over=20Nostr,=20completing=20D3=20(#227)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The receive half of the Nostr transport (send-path landed in #322). The daemon's `run_sync_pull` now, after the HTTP-slot pull, also pulls Nostr-delivered events: - For each relay a peer is reachable on (`peers[*].nostr_transport.relay` — in the common symmetric pairing, exactly where a peer publishes our inbound), it pulls events `#p`-tagged to our npub, `kind:1`. - Each is transport-verified (`nostr_event::verify_and_decode` — recompute the NIP-01 id + check the schnorr sig) and the inner signed wire event is fed through the SAME `crate::pull::process_events` path as HTTP-pulled events — so the inner Ed25519 signature, trust pin, and inbox dedup are all reused, not reimplemented. Transport-verified here, identity-verified there. - Cursor None: Nostr re-pulls a recent window each cycle; process_events dedups by event_id, so repeats are free. Per-relay errors are logged + skipped (one dead relay can't black-hole the others). - Sync wrapper over async NostrWs via a one-shot runtime block_on (the daemon loop is sync) — same bridge as the send path. Together with #322, a `transport: nostr` peer now fully round-trips through the daemon. Strictly additive: a no-op when not `wire enroll nostr`'d or no peer carries a nostr transport, so the HTTP-slot pull is byte-identical. `nostr_relays_from_peers` (distinct, skips transportless/empty) is pure + unit-tested; the decode + process_events layers are already covered by their own tests + the nostr_ws mock-relay tests. 599 lib tests green; clippy clean. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 2 + src/cli/relay.rs | 124 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 75501ce..6bb3c2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ the PR description linked in each section. ### Added +- **Nostr transport is now bidirectional through the daemon** (#227 D3, RFC-007): `run_sync_pull` now also pulls Nostr-delivered events — for each relay a peer is reachable on (`peers[*].nostr_transport.relay`) it pulls events `#p`-tagged to our npub (`kind:1`), transport-verifies them (NIP-01 id + schnorr), and feeds them through the **same `process_events` path** as HTTP-pulled events (inner Ed25519 signature + trust pin + inbox dedup all reused). Together with the send-path fallback (#322), a `transport: nostr` peer now fully round-trips through the daemon. Strictly additive — a no-op when this session isn't `wire enroll nostr`'d or no peer carries a nostr transport (the HTTP-slot pull is byte-identical). The last RFC-007 D3 piece; live public-relay e2e (AC-3) remains a manual dogfood via `wire nostr`. + - **1.0 surface freeze — deprecation policy + a golden MCP-catalog lock** (ROAD_TO_1.0 §6): published [`docs/DEPRECATION_POLICY.md`](docs/DEPRECATION_POLICY.md) — from 1.0, frozen surfaces (CLI verbs, `--json` shapes, the MCP tool catalog, on-disk state, protocol) change only through a deprecation window (announce → runtime warn → ≥1 MINOR & ≥90 days → remove in the next MAJOR), never a silent break. Added a `mcp_catalog_schema_is_frozen` test that golden-locks all 27 MCP tools' name + input-schema props + `required` list, so the agent-facing API can't drift unnoticed. Also promoted the hello-world first-connection round-trip to a required CI gate (`hello-world` job) now that daemon-survival is fixed (#263). - **`wire send` routes over Nostr when no HTTP slot is reachable** (#227 D3.4, RFC-007): the send-path half of daemon routing. `send::attempt_deliver` (the single chokepoint behind `wire send`, group send, MCP `tool_send`, and probes) now falls back to the peer's recorded `nostr_transport` when no HTTP endpoint reaches them — it encodes the *same* signed wire event as a NIP-01 event (`nostr_event::wire_to_nostr_addressed`, full inner Ed25519 sig intact, `p`-tagged to the peer's npub, schnorr-signed by our `wire enroll nostr` key) and publishes it to the paired relay. New `SyncDelivery::DeliveredNostr` verdict (`status: "delivered_nostr"`, carries `transport: "nostr"` + the peer's npub; counts as relay-reached so the peer can `wire nostr fetch` it). **Strictly a fallback** — when the peer has no `nostr_transport` (or this session isn't enrolled) the HTTP-slot path is byte-identical to before. The pull-loop side (daemon auto-pulls Nostr-delivered events into the inbox) is the next slice. diff --git a/src/cli/relay.rs b/src/cli/relay.rs index 327f71a..0ea8b43 100644 --- a/src/cli/relay.rs +++ b/src/cli/relay.rs @@ -1408,6 +1408,84 @@ fn inbox_contains_probe_ack(path: &std::path::Path, nonce: &str) -> bool { .any(|e| crate::probe::is_probe_ack_for(&e, nonce)) } +/// RFC-007 D3 pull-loop helper: the distinct relays any peer is reachable on +/// over Nostr (`peers[*].nostr_transport.relay`). In the common symmetric +/// pairing (both sides `wire nostr pair/accept --relay X`) this is exactly the +/// relay a peer publishes our inbound messages to, so it's where we pull from. +/// Pure — unit-tested. +fn nostr_relays_from_peers(state: &Value) -> Vec { + let mut relays: Vec = Vec::new(); + if let Some(peers) = state.get("peers").and_then(Value::as_object) { + for p in peers.values() { + if let Some(r) = p + .get("nostr_transport") + .and_then(|n| n.get("relay")) + .and_then(Value::as_str) + && !r.is_empty() + && !relays.iter().any(|x| x == r) + { + relays.push(r.to_string()); + } + } + } + relays +} + +/// Pull wire events addressed to us (`#p: `, `kind:1`) from each Nostr +/// relay, transport-verify them (`verify_and_decode` — recompute the NIP-01 id + +/// check the schnorr sig), and return the inner signed wire events. The caller +/// feeds these through the SAME `process_events` path as HTTP-pulled events, so +/// the inner Ed25519 signature + trust pin are verified there (transport-verified +/// here, identity-verified there). Per-relay errors are logged and skipped — one +/// dead relay can't black-hole the others. Sync wrapper over the async NostrWs +/// (the daemon loop is sync); mirrors the send-path `block_on` bridge. +fn pull_nostr_wire_events(relays: &[String], my_xonly: &[u8; 32]) -> Vec { + let my_p_tag = hex::encode(my_xonly); + let rt = match tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + { + Ok(rt) => rt, + Err(e) => { + eprintln!("daemon: nostr pull runtime build failed: {e:#}"); + return Vec::new(); + } + }; + rt.block_on(async { + let mut out: Vec = Vec::new(); + for relay in relays { + let filter = crate::nostr_relay::Filter { + p_tags: vec![my_p_tag.clone()], + kinds: vec![1], + limit: Some(200), + ..Default::default() + }; + let events = match crate::nostr_ws::NostrWs::connect(relay).await { + Ok(mut ws) => match ws.pull(filter).await { + Ok(evs) => evs, + Err(e) => { + eprintln!("daemon: nostr pull on {relay} failed (continuing): {e:#}"); + continue; + } + }, + Err(e) => { + eprintln!("daemon: nostr connect {relay} failed (continuing): {e:#}"); + continue; + } + }; + for ev in &events { + // verify_and_decode authenticates the transport hop only; the + // inner wire event's Ed25519 sig + trust are checked downstream + // in process_events. + if let Ok(wire) = crate::nostr_event::verify_and_decode(ev) { + out.push(wire); + } + } + } + out + }) +} + pub fn run_sync_pull() -> Result { let state = config::read_relay_state()?; if state.get("self").map(Value::is_null).unwrap_or(true) { @@ -1490,6 +1568,30 @@ pub fn run_sync_pull() -> Result { all_rejected.extend(result.rejected); } + // RFC-007 D3 pull-loop: also pull Nostr-delivered events. Additive — a + // no-op when this session isn't `wire enroll nostr`'d or no peer carries a + // nostr transport, so the HTTP-slot path above is byte-identical. We pull + // from the relays peers are reachable on (symmetric pairing → where they + // publish to us), transport-verify, then feed the SAME `process_events` + // path (which re-verifies the inner Ed25519 sig + trust + dedups against + // the inbox). Cursor None: Nostr re-pulls a recent window each cycle and + // process_events dedups by event_id, so repeats are free. + if let Ok(nsk) = config::read_nostr_key() + && let Ok(my_xonly) = crate::nostr_key::xonly_from_secret(&nsk) + { + let relays = nostr_relays_from_peers(&state); + if !relays.is_empty() { + let wire_events = pull_nostr_wire_events(&relays, &my_xonly); + if !wire_events.is_empty() { + total_seen += wire_events.len(); + let result = crate::pull::process_events(&wire_events, None, &inbox_dir)?; + crate::probe::respond_to_probes(&result.probes); + all_written.extend(result.written); + all_rejected.extend(result.rejected); + } + } + } + // P0.3 flock-protected RMW: persist per-slot cursors + keep the legacy // global cursor in sync with the primary slot for back-compat with older // binaries that only read `last_pulled_event_id`. @@ -1755,6 +1857,28 @@ fn try_reresolve_peer_on_slot_4xx( mod slot_reresolve_tests { use super::*; + #[test] + fn nostr_relays_from_peers_distinct_and_skips_transportless() { + let state = serde_json::json!({ + "peers": { + "alice": { "nostr_transport": { "npub": "aa", "relay": "wss://r1" } }, + "bob": { "nostr_transport": { "npub": "bb", "relay": "wss://r2" } }, + // same relay as alice → de-duped + "carol": { "nostr_transport": { "npub": "cc", "relay": "wss://r1" } }, + // no nostr transport → skipped (HTTP-only peer) + "dave": { "endpoints": [] }, + // empty relay → skipped + "erin": { "nostr_transport": { "npub": "ee", "relay": "" } }, + } + }); + let mut relays = nostr_relays_from_peers(&state); + relays.sort(); + assert_eq!(relays, vec!["wss://r1".to_string(), "wss://r2".to_string()]); + // No peers / no transports → empty (the additive no-op case). + assert!(nostr_relays_from_peers(&serde_json::json!({})).is_empty()); + assert!(nostr_relays_from_peers(&serde_json::json!({"peers": {}})).is_empty()); + } + /// Issue #15: the gating logic of try_reresolve_peer_on_slot_4xx /// must short-circuit BEFORE any network call when the error shape /// doesn't smell like slot rotation, when the peer was already