Skip to content

Commit 51c28f7

Browse files
laulpoganclaude
andauthored
feat(nostr): persist self nostr relays so the pull-loop is correct when asymmetric (#333)
Follow-up to #332. The pull-loop pulled only from `peers[*].nostr_transport.relay` (the relays we reach *peers* on), which is correct only when both sides paired over the same relay. A peer sends to us by publishing to a relay *we're* reachable on — not necessarily one we reach them on. So record that: - `endpoints::pin_self_nostr_relay` / `self_nostr_relays` — a deduped `self.nostr_relays[]` set (additive on the self block; composes with the existing slot fields). - `wire nostr pair`/`accept`/`fetch --relay X` now record X as a relay we're reachable on (accept folds it into its existing relay-state RMW). - `relay::nostr_relays_from_peers` now unions `self.nostr_relays[]` (the authoritative "where peers publish our inbound" set) with the peer-transport relays (still covers the symmetric case before a self-relay is recorded). Net: a `transport: nostr` peer round-trips regardless of pairing symmetry. Still additive — no nostr relay recorded → empty pull set → HTTP path byte-identical. Unit tests: `self_nostr_relay_roundtrips_and_dedups` (roundtrip, dedup, empty, doesn't clobber other self keys) + the relay-helper test now asserts the self∪peer union + dedup. 600 lib tests green; clippy clean. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent fcf2a08 commit 51c28f7

4 files changed

Lines changed: 112 additions & 12 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ the PR description linked in each section.
1212

1313
### Added
1414

15-
- **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`.
15+
- **Nostr transport is now bidirectional through the daemon** (#227 D3, RFC-007): `run_sync_pull` now also pulls Nostr-delivered events — it pulls events `#p`-tagged to our npub (`kind:1`) from the relays we're reachable on, 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). The pull set is `self.nostr_relays[]` (relays we paired/fetched over — where peers publish *our* inbound, so it's correct even when pairing is asymmetric) unioned with the peer-transport relays; `wire nostr pair/accept/fetch --relay X` records X. 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 has no nostr relay recorded (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`.
1616

1717
- **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).
1818

src/cli/nostr.rs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,8 +133,11 @@ fn cmd_accept(npub: &str, relay: &str, as_json: bool) -> Result<()> {
133133
.and_then(Value::as_str)
134134
.map(str::to_string)
135135
.unwrap_or_else(|| crate::agent_card::display_handle_from_did(&peer_did).to_string());
136+
// Also record this relay as one WE are reachable on (we received + ack here)
137+
// so the daemon pull-loop pulls our inbound from it — see pin_self_nostr_relay.
136138
crate::config::update_relay_state(|rs| {
137-
crate::endpoints::pin_peer_nostr_transport(rs, &peer_handle, npub, relay)
139+
crate::endpoints::pin_peer_nostr_transport(rs, &peer_handle, npub, relay)?;
140+
crate::endpoints::pin_self_nostr_relay(rs, relay)
138141
})?;
139142

140143
// Send the pair-ack (our card) back over the relay.
@@ -186,6 +189,10 @@ fn cmd_pair(npub: &str, relay: &str, as_json: bool) -> Result<()> {
186189
ws.publish(&ev).await.context("publish pair-request")
187190
})??;
188191

192+
// Record this relay as one we're reachable on (the peer will ack + later
193+
// send to us here) so the daemon pull-loop services it.
194+
crate::config::update_relay_state(|rs| crate::endpoints::pin_self_nostr_relay(rs, relay))?;
195+
189196
if as_json {
190197
println!(
191198
"{}",
@@ -207,6 +214,9 @@ fn cmd_fetch(relay: &str, limit: usize, as_json: bool) -> Result<()> {
207214
let nsk = require_transport_key()?;
208215
let my_xonly = crate::nostr_key::xonly_from_secret(&nsk)
209216
.map_err(|e| anyhow!("transport key unusable: {e}"))?;
217+
// Fetching here means we treat this relay as one we're reachable on — record
218+
// it so the daemon pull-loop keeps servicing it without a manual fetch.
219+
crate::config::update_relay_state(|rs| crate::endpoints::pin_self_nostr_relay(rs, relay))?;
210220
let filter = Filter {
211221
p_tags: vec![hex::encode(my_xonly)],
212222
kinds: vec![

src/cli/relay.rs

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1408,23 +1408,34 @@ fn inbox_contains_probe_ack(path: &std::path::Path, nonce: &str) -> bool {
14081408
.any(|e| crate::probe::is_probe_ack_for(&e, nonce))
14091409
}
14101410

1411-
/// RFC-007 D3 pull-loop helper: the distinct relays any peer is reachable on
1412-
/// over Nostr (`peers[*].nostr_transport.relay`). In the common symmetric
1413-
/// pairing (both sides `wire nostr pair/accept --relay X`) this is exactly the
1414-
/// relay a peer publishes our inbound messages to, so it's where we pull from.
1411+
/// RFC-007 D3 pull-loop helper: the distinct Nostr relays to pull our inbound
1412+
/// from. Two sources, deduped:
1413+
/// 1. `self.nostr_relays[]` — relays WE are reachable on (recorded when we
1414+
/// `wire nostr pair/accept/fetch --relay X`). A peer sends to us by
1415+
/// publishing to a relay *we're* reachable on, so this is the authoritative
1416+
/// set — correct even when pairing is asymmetric.
1417+
/// 2. `peers[*].nostr_transport.relay` — relays we reach *peers* on. Covers the
1418+
/// symmetric same-relay case even before a self-relay was recorded.
1419+
///
14151420
/// Pure — unit-tested.
14161421
fn nostr_relays_from_peers(state: &Value) -> Vec<String> {
14171422
let mut relays: Vec<String> = Vec::new();
1423+
let mut push_distinct = |r: &str| {
1424+
if !r.is_empty() && !relays.iter().any(|x| x == r) {
1425+
relays.push(r.to_string());
1426+
}
1427+
};
1428+
for r in crate::endpoints::self_nostr_relays(state) {
1429+
push_distinct(&r);
1430+
}
14181431
if let Some(peers) = state.get("peers").and_then(Value::as_object) {
14191432
for p in peers.values() {
14201433
if let Some(r) = p
14211434
.get("nostr_transport")
14221435
.and_then(|n| n.get("relay"))
14231436
.and_then(Value::as_str)
1424-
&& !r.is_empty()
1425-
&& !relays.iter().any(|x| x == r)
14261437
{
1427-
relays.push(r.to_string());
1438+
push_distinct(r);
14281439
}
14291440
}
14301441
}
@@ -1858,8 +1869,11 @@ mod slot_reresolve_tests {
18581869
use super::*;
18591870

18601871
#[test]
1861-
fn nostr_relays_from_peers_distinct_and_skips_transportless() {
1872+
fn nostr_relays_from_peers_unions_self_and_peers_distinct() {
18621873
let state = serde_json::json!({
1874+
// Relays we're reachable on (the authoritative set) — incl. one no
1875+
// peer-transport mentions (the asymmetric case).
1876+
"self": { "nostr_relays": ["wss://self", "wss://r1"] },
18631877
"peers": {
18641878
"alice": { "nostr_transport": { "npub": "aa", "relay": "wss://r1" } },
18651879
"bob": { "nostr_transport": { "npub": "bb", "relay": "wss://r2" } },
@@ -1873,8 +1887,16 @@ mod slot_reresolve_tests {
18731887
});
18741888
let mut relays = nostr_relays_from_peers(&state);
18751889
relays.sort();
1876-
assert_eq!(relays, vec!["wss://r1".to_string(), "wss://r2".to_string()]);
1877-
// No peers / no transports → empty (the additive no-op case).
1890+
// self (wss://self, wss://r1) ∪ peers (r1 dup, r2) → 3 distinct.
1891+
assert_eq!(
1892+
relays,
1893+
vec![
1894+
"wss://r1".to_string(),
1895+
"wss://r2".to_string(),
1896+
"wss://self".to_string()
1897+
]
1898+
);
1899+
// No peers / no transports / no self → empty (the additive no-op case).
18781900
assert!(nostr_relays_from_peers(&serde_json::json!({})).is_empty());
18791901
assert!(nostr_relays_from_peers(&serde_json::json!({"peers": {}})).is_empty());
18801902
}

src/endpoints.rs

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,6 +418,54 @@ pub fn peer_nostr_transport(relay_state: &Value, peer_handle: &str) -> Option<(S
418418
Some((npub, relay))
419419
}
420420

421+
/// RFC-007 D3: record a Nostr relay this session is *reachable on* — one we've
422+
/// paired/fetched over (`wire nostr pair/accept/fetch --relay X`). Persisted as
423+
/// the distinct set `self.nostr_relays[]`. The daemon pull-loop reads this as
424+
/// the authoritative "where do peers publish my inbound" set: a peer sends to me
425+
/// by publishing to a relay *I'm* reachable on, which isn't necessarily a relay
426+
/// *I* reach *them* on (the asymmetric case the peer-transport set misses).
427+
/// Read-modify-write, idempotent (dedups).
428+
pub fn pin_self_nostr_relay(relay_state: &mut Value, relay_url: &str) -> Result<()> {
429+
if relay_url.is_empty() {
430+
return Ok(());
431+
}
432+
let self_obj = relay_state
433+
.as_object_mut()
434+
.map(|m| {
435+
m.entry("self")
436+
.or_insert_with(|| Value::Object(Default::default()))
437+
})
438+
.ok_or_else(|| anyhow::anyhow!("relay_state.json root is not an object"))?
439+
.as_object_mut()
440+
.ok_or_else(|| anyhow::anyhow!("relay_state.self is not an object"))?;
441+
let arr = self_obj
442+
.entry("nostr_relays")
443+
.or_insert_with(|| Value::Array(Vec::new()))
444+
.as_array_mut()
445+
.ok_or_else(|| anyhow::anyhow!("relay_state.self.nostr_relays is not an array"))?;
446+
if !arr.iter().any(|v| v.as_str() == Some(relay_url)) {
447+
arr.push(Value::String(relay_url.to_string()));
448+
}
449+
Ok(())
450+
}
451+
452+
/// The distinct Nostr relays this session is reachable on (`self.nostr_relays[]`).
453+
/// Empty when never paired over Nostr.
454+
pub fn self_nostr_relays(relay_state: &Value) -> Vec<String> {
455+
relay_state
456+
.get("self")
457+
.and_then(|s| s.get("nostr_relays"))
458+
.and_then(Value::as_array)
459+
.map(|a| {
460+
a.iter()
461+
.filter_map(|v| v.as_str())
462+
.filter(|s| !s.is_empty())
463+
.map(str::to_string)
464+
.collect()
465+
})
466+
.unwrap_or_default()
467+
}
468+
421469
/// Infer an endpoint scope from a relay URL: `unix://` -> Uds, a loopback
422470
/// host -> Local, otherwise Federation. LAN is never inferred (a private-
423471
/// range IP is indistinguishable from a federation host by URL alone) and
@@ -564,6 +612,26 @@ mod tests {
564612
assert!(peer_endpoints_in_priority_order(&state, "alice").is_empty());
565613
}
566614

615+
#[test]
616+
fn self_nostr_relay_roundtrips_and_dedups() {
617+
let mut state = json!({});
618+
pin_self_nostr_relay(&mut state, "wss://r1").unwrap();
619+
pin_self_nostr_relay(&mut state, "wss://r2").unwrap();
620+
pin_self_nostr_relay(&mut state, "wss://r1").unwrap(); // dup → no-op
621+
pin_self_nostr_relay(&mut state, "").unwrap(); // empty → no-op
622+
assert_eq!(
623+
self_nostr_relays(&state),
624+
vec!["wss://r1".to_string(), "wss://r2".to_string()]
625+
);
626+
// Composes with an existing self block (doesn't clobber other self keys).
627+
let mut state2 = json!({"self": {"relay_url": "https://wireup.net", "slot_id": "s"}});
628+
pin_self_nostr_relay(&mut state2, "wss://x").unwrap();
629+
assert_eq!(state2["self"]["relay_url"], "https://wireup.net");
630+
assert_eq!(self_nostr_relays(&state2), vec!["wss://x".to_string()]);
631+
// Absent → empty.
632+
assert!(self_nostr_relays(&json!({})).is_empty());
633+
}
634+
567635
#[test]
568636
fn endpoints_are_local_only_catches_loopback_pin_incl_mislabeled_scope() {
569637
// Empty → false (nothing to judge).

0 commit comments

Comments
 (0)