fix(handshake): peer requests actually arrive — background poll, unified relays, real send errors

Three silent failure modes killed nostr peer requests (analyzed from the
live code 2026-07-22): (1) nothing ever polled the relays except the
manual Federation Poll button — a 5-minute background poll now fetches
inbound requests and nudges the websocket when something lands (the
handler's discoverability gate still applies); (2) handshake send/poll
used only the two hardcoded config relays (one defunct) and ignored the
user-managed relay list — every nostr op now uses the merged set;
(3) publish errors were swallowed (let _ =) so 'ok' was reported even
when no relay accepted the event — now a delivery failure is an error
the UI shows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-07-22 19:44:32 -04:00
parent 770e3386f4
commit 6a3b46b5a9
4 changed files with 102 additions and 7 deletions

View File

@ -86,7 +86,7 @@ impl RpcHandler {
let did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)
.unwrap_or_default();
let version = data.server_info.version.clone();
let relays = self.config.nostr_relays.clone();
let relays = self.handshake_relays().await;
let tor_proxy = self.config.nostr_tor_proxy.clone();
tokio::spawn(async move {
if let Err(e) = nostr_handshake::publish_presence(
@ -106,6 +106,17 @@ impl RpcHandler {
Ok(serde_json::json!({ "enabled": enabled }))
}
/// The relay set every handshake operation uses: the user-managed relay
/// list (Settings → Relays, `nostr_relays.json`) merged with the config
/// defaults. Before 2026-07-22 handshake send/poll used ONLY the two
/// hardcoded config relays (one of which is defunct) and ignored user
/// relay edits entirely — so a sender publishing where the receiver
/// never read was a routine, silent way for peer requests to vanish.
pub(super) async fn handshake_relays(&self) -> Vec<String> {
crate::nostr_relays::merged_relay_list(&self.config.data_dir, &self.config.nostr_relays)
.await
}
/// Discover discoverable nodes via Nostr presence events.
/// Returns (nostr_pubkey, npub, DID, version) only — never an onion.
pub(super) async fn handle_handshake_discover(&self) -> Result<serde_json::Value> {
@ -113,9 +124,10 @@ impl RpcHandler {
// to query relays as long as the user is actively browsing — they're
// an anonymous observer of presence events, not publishing anything.
let identity_dir = self.config.data_dir.join("identity");
let relays = self.handshake_relays().await;
let nodes = nostr_handshake::discover_nodes(
&identity_dir,
&self.config.nostr_relays,
&relays,
self.config.nostr_tor_proxy.as_deref(),
)
.await?;
@ -161,7 +173,7 @@ impl RpcHandler {
our_version,
our_name,
message,
&self.config.nostr_relays,
&self.handshake_relays().await,
self.config.nostr_tor_proxy.as_deref(),
)
.await?;
@ -191,6 +203,40 @@ impl RpcHandler {
/// - `PeerReject` → mark matching outbound row as `Rejected`
///
/// Never auto-adds peers, never auto-responds, never sends our onion.
/// Background relay poll (2026-07-22): before this, `handshake.poll` ran
/// ONLY when a user opened Federation and pressed the Poll button — a
/// peer request sat on the relay until the target's operator happened to
/// click, i.e. for most nodes forever ("requests never arrive"). Runs the
/// same poll+dispatch as the RPC (the disabled gate inside still applies)
/// and nudges the websocket revision when anything new lands so open UIs
/// refresh immediately.
pub async fn background_handshake_poll(self: &std::sync::Arc<Self>) {
match self.handle_handshake_poll().await {
Ok(res) => {
let new = res
.get("new_requests")
.and_then(|v| v.as_array())
.map(|a| a.len())
.unwrap_or(0);
let applied = res
.get("applied_invites")
.and_then(|v| v.as_array())
.map(|a| a.len())
.unwrap_or(0);
if new > 0 || applied > 0 {
tracing::info!(
new_requests = new,
applied_invites = applied,
"handshake poll: inbound peer activity"
);
let (data, _) = self.state_manager.get_snapshot().await;
self.state_manager.update_data(data).await;
}
}
Err(e) => tracing::debug!("background handshake poll failed: {e:#}"),
}
}
pub(super) async fn handle_handshake_poll(&self) -> Result<serde_json::Value> {
// Runtime gate: if the user hasn't enabled discoverability, don't
// touch the relays. The poll endpoint is a hard no-op until they
@ -207,9 +253,10 @@ impl RpcHandler {
}));
}
let identity_dir = self.config.data_dir.join("identity");
let relays = self.handshake_relays().await;
let handshakes = nostr_handshake::poll_handshakes(
&identity_dir,
&self.config.nostr_relays,
&relays,
self.config.nostr_tor_proxy.as_deref(),
None,
)

View File

@ -307,9 +307,19 @@ async fn send_handshake_message(
let builder = EventBuilder::new(Kind::EncryptedDirectMessage, encrypted)
.tag(Tag::public_key(recipient_pk));
let _ = client.send_event_builder(builder).await;
// Surface real delivery failures. Before 2026-07-22 this was `let _ =`,
// so a request no relay accepted still reported ok:true to the UI and
// the sender believed it was delivered.
let send = client.send_event_builder(builder).await;
client.disconnect().await;
Ok(())
match send {
Ok(output) if !output.success.is_empty() => Ok(()),
Ok(output) => anyhow::bail!(
"no relay accepted the handshake event (tried {}, all failed)",
output.failed.len()
),
Err(e) => anyhow::bail!("failed to publish handshake event: {e}"),
}
}
/// Send a `PeerRequest` to a discovered node's nostr pubkey. We never

View File

@ -47,6 +47,21 @@ const DEFAULT_RELAYS: &[&str] = &[
"wss://relay.current.fyi",
];
/// The union of the boot-config relay list and the user-managed (enabled)
/// relays — the set every nostr-facing operation should use, so senders and
/// receivers always overlap regardless of which list the operator edited.
pub async fn merged_relay_list(data_dir: &Path, config_relays: &[String]) -> Vec<String> {
let mut relays: Vec<String> = config_relays.to_vec();
if let Ok(store) = load_relays(data_dir).await {
for r in store.relays {
if r.enabled && !relays.contains(&r.url) {
relays.push(r.url);
}
}
}
relays
}
pub async fn load_relays(data_dir: &Path) -> Result<RelayStore> {
let path = data_dir.join(RELAYS_FILE);
if !path.exists() {

View File

@ -209,9 +209,15 @@ impl Server {
let did =
identity::did_key_from_pubkey_hex(&data.server_info.pubkey).unwrap_or_default();
let version = data.server_info.version.clone();
let relays = config.nostr_relays.clone();
// Merged relay set (config + user-managed) — publish presence
// where handshake peers actually read (2026-07-22 unification).
let data_dir_for_relays = config.data_dir.clone();
let config_relays = config.nostr_relays.clone();
let tor_proxy = config.nostr_tor_proxy.clone();
tokio::spawn(async move {
let relays =
crate::nostr_relays::merged_relay_list(&data_dir_for_relays, &config_relays)
.await;
if let Err(e) = nostr_handshake::publish_presence(
&identity_dir,
&did,
@ -253,6 +259,23 @@ impl Server {
.await?,
);
// Background handshake poll: fetch inbound nostr peer requests every
// 5 minutes instead of only when a user presses the Federation Poll
// button (requests used to sit on relays unseen — 2026-07-22). The
// handler's own discoverability gate makes this a no-op until the
// user opts in.
{
let rpc = api_handler.rpc_handler().clone();
tokio::spawn(async move {
let mut tick = tokio::time::interval(std::time::Duration::from_secs(300));
tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
tick.tick().await;
rpc.background_handshake_poll().await;
}
});
}
// Initialize mesh networking service (if config has enabled: true)
{
let data_dir = config.data_dir.clone();