Skip to content

NetworkEvent::RpcIncomingMessageHandled is declared but never emitted; handler is sync fn with no PeerId #58

Description

@sacha-l

Context: Building an application-level authorization gate (HMAC-based pairing) where both sides refuse to act on incoming RPCs until a challenge/response handshake completes. The spec called for a 4-message handshake (Challenge → ResponseAndChallenge → Response → Ack) driven from the event loop on incoming RPC events, with each side reacting to the sender's PeerId.

Problem: Two stacked gaps in v0.2.1 force a redesign:

  1. NetworkEvent::RpcIncomingMessageHandled { data: RpcData } is declared in prelude.rs:486 but nothing in core/mod.rs ever pushes it onto the event queue (confirmed by full-source grep — only GossipsubIncomingMessageHandled has an emit site). Apps cannot react to incoming RPC via the normal node.next_event() loop at all.
  2. The only path the library gives you for incoming RPC is the rpc_handler_fn: fn(RpcData) -> RpcData registered via CoreBuilder::with_rpc. It is a bare fn pointer (so no captured app state — same issue as (example PR closing issues) Dev to main #8 above), it is synchronous (cannot .await, cannot take a tokio::sync::Mutex), and it receives no PeerId for the caller. The response is whatever bytes the fn returns, sent back on the same RPC channel; there is no other hook.

Consequence for the app: The 4-message event-driven handshake is not expressible on SwarmNL v0.2.1. The protocol was simplified to a single round-trip Challenge(nonce) → Response(hmac) per direction, with each side independently initiating against the other on ConnectionEstablished. Mutual authentication still holds in aggregate (each side proves knowledge of S to the other via its own challenge), at the cost of losing the combined ResponseAndChallenge + Ack flow.

Consequence for the data-plane gate: The app's three is_trusted(&peer) checks become "two enforceable + one best-effort". The two initiator-side checks (before sending DataPing, after receiving DataPong as the RPC response) are checkable. The handler-side check on incoming DataPing cannot see the caller, so it falls back to a "single connected peer" assumption cached from ConnectionEstablished and consulted via a static OnceLock<Arc<HandlerCtx>> — clean for a two-node demo, but does not generalise. Any production app that wants a per-peer receive-side gate is blocked on this.

Suggestion: Either (a) actually emit RpcIncomingMessageHandled { data, peer } from the request-response branch in core/mod.rs so apps can handle RPC in the normal async event loop, or (b) change with_rpc to accept Arc<dyn Fn(PeerId, RpcData) -> BoxFuture<'static, RpcData> + Send + Sync> so handlers can take state and see the caller and do async work. A narrower composable version is a per-peer delivery-filter hook: one closure (PeerId, AppData) -> Deliver | Drop | Disconnect registered on the Node, usable for pairing, rate limiting, allowlists, mute lists, and paid-tier gating.

Workaround: static HANDLER_CTX: OnceLock<Arc<HandlerCtx>> holding the shared secret, a std::sync::Mutex-backed PairingBook (not tokio::sync::Mutex, so the sync handler can lock it without blocking the runtime), and an Option<PeerId> cache of the one currently-connected peer, set from ConnectionEstablished. Document clearly that the handler-side gate only works because n=2.

Severity: blocking (for the 4-message protocol) → important (after the protocol simplification)

Relevant API: NetworkEvent::RpcIncomingMessageHandled, CoreBuilder::with_rpc, RpcConfig, AppData::SendRpc


Originally logged while building apps/paired-exchange in forge-p2p. See library-feedback.md for the full index of findings.

See also: #53, #54 — related RPC handler / event gaps.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions