Skip to content

Nostr DM delivery flakiness across CLI process boundaries (intermittent receiver miss) #473

Description

@vrogojin

Filing as a focused follow-up to #455 / #464 / #465. The mux dispatch await fix (PR #465) closed the in-process race where receive() resolved before handleIncomingTransfer completed. A separate cross-process race remains, surfaced during the sphere-sdk#456 swap-roundtrip soak (2026-06-10).

Symptom

In manual-test-swap-roundtrip.sh Scenario A:

  1. Alice spawns a CLI process, runs sphere swap propose --to @bob --offer 50 UCT --want 5 ETH --escrow @escrow-test-02. The proposal returns status: proposed. Alice's process exits.
  2. Bob spawns a fresh CLI process, runs sphere swap list --role acceptor. Returns "No swaps found".
  3. Bob's soak loop polls every 3 s (each iteration a fresh CLI process, fresh sphere instance) for up to 90 s.
  4. Intermittently: bob never sees the proposal. 90 s timeout fires.

Same soak retried within the next minute: passes cleanly.

Same class of flakiness was already observed in #455 (single-coin faucet) and root-caused there to the in-process mux-dispatch await gap — that fix is necessary but is not sufficient for the cross-process case.

Why it's bad

This is the canonical handoff shape for any CLI-driven swap, accounting, or DM workflow:

  • A → B handoff via Nostr DM, where A's process exits between send and B's first poll.
  • Multi-step soaks where each section is a separate CLI invocation.
  • Real users running sphere ad-hoc from a terminal (`sphere swap propose ...; sphere swap list` in a script).
  • Background daemons that batch-poll on a timer rather than holding a long-running subscription.

Anything that depends on "the receiver's next poll catches the sender's last write" is fragile under this race.

Likely contributing mechanisms

These overlap and stack — no single hypothesis fully explains the observed pattern. Investigation should distinguish.

M1. `since` cursor advances before delivery.
`NostrTransportProvider` persists `last_wallet_event_ts` / `last_dm_event_ts` per pubkey prefix. On reconnect, the next subscription's `since` filter starts from that timestamp. A receiver whose previous boot saw events up to T1 will not re-fetch events with `created_at < T1`. If a sender's DM was published at T0 but the receiver advanced past T0 before processing it (or the relay's clock drift puts T0 in the future at write time and the receiver's now-clock has moved past it), the DM is permanently invisible to this receiver on the relay's `since`-filtered subscription.

M2. NIP-17 ±2-day created_at randomization vs since filter.
NIP-17 gift-wrap (kind 1059) deliberately randomizes `created_at` by ±2 days for sender privacy (`transport/MultiAddressTransportMux.ts:986+`). The mux compensates by using `globalDmSince - NIP17_TIMESTAMP_RANDOMIZATION` for chat subscriptions, but only if the persisted dmSince includes that buffer. Fresh CLI boots may compute since=now without that look-back, missing recent DMs whose randomized `created_at` already advanced past now.

M3. Subscription armed AFTER the relay's "since now" cutoff.
The mux gate (issue #442 fix) suppresses subscriptions until armSubscriptions() runs after module load. For a fresh CLI boot:

  • `now_at_subscribe = T_arm` is some milliseconds-to-seconds after `now_at_init`.
  • The relay receives `since=T_subscribe` and returns events with `created_at >= T_subscribe`.
  • Any DM published between `T_subscribe` and the relay applying the filter is in a microscopic but real race window.

M4. EOSE delivered before backlog drained.
Some relays send EOSE optimistically. The receiver's `fetchPendingEvents` wait loop may resolve on EOSE while the relay is still streaming buffered events. Subsequent events arrive but the receiver has already returned to caller ("No swaps found").

M5. Relay event retention / propagation.
Public testnet relay may evict recent events under load, or have multi-replica state that hasn't converged. A sender's write to replica A may not be visible to a receiver's read from replica B within the polling window.

What we know

  • PR fix(transport)(sphere-sdk#464): mux dispatch await gap #465 (mux dispatch await) is merged on main and the CLI's bundled SDK has it. It fixes the in-process variant but not the cross-process one.
  • The escrow's own DM handling (which is also Nostr-based) always works in the soak — pings, swap announces, deposits all land. Difference: the escrow holds a long-running subscription rather than re-booting per operation.
  • A bob CLI invocation that runs `sphere payments sync` first does NOT always recover. The poll loop retries every 3 s and still misses.

Suggested investigation

  1. Reproduce deterministically. Wrap the soak's bob-poll loop in a script that captures: per-iteration since timestamp, subscription filter wire payload, EOSE timing, and raw event count returned. Run alice in a fresh shell, capture her DM's `created_at`. Diff against bob's per-iteration since cursor.

  2. Instrument NostrTransportProvider. Add [Nostr] subscribe filter={...} since=N and [Nostr] eose_received traces visible at `DEBUG=Nostr`. The mux already has `logger.debug('Mux', 'updateSubscriptions...')` — extend to log filter wire bytes.

  3. Test each mechanism in isolation.

    • M1: drive the receiver with an explicit `since=0` filter and see if the missed DMs reappear.
    • M2: bump the chat subscription's look-back buffer by 10× and re-run; does flakiness drop?
    • M3: measure `T_subscribe - T_send` distribution; correlate with miss rate.
    • M4: count events received post-EOSE; if non-zero on miss runs, the mux is closing the wait window too early.
    • M5: query the same DM event from two different SDK instances back-to-back, with relay-side timestamps. If only one sees it, it's a replica / retention issue.
  4. Fix candidates (depending on which mechanism wins):

    • For M1/M3: persist `since = T_subscribe - K` for some K (e.g. 5 minutes) instead of `= T_subscribe`, accepting one tier of duplicate event processing.
    • For M2: increase the chat look-back constant; the SDK already has a NIP-17 buffer but it may be too tight.
    • For M4: wait for first event or N seconds, whichever later, instead of EOSE alone.
    • For M5: query multiple relays and union. Today the mux subscribes via a single `NostrClient` per relay; a quorum-of-N approach would mask single-relay state divergence.

Acceptance

  • 10 consecutive successful runs of `manual-test-swap-roundtrip.sh` Scenario A against testnet with no retries.
  • A unit/integration test that simulates the sender-exits-then-receiver-boots flow with a mock relay that has clock skew + delayed event propagation, and asserts the receiver still sees the DM within a documented bound.
  • Operator-facing documentation: any soak / CLI workflow that depends on cross-process DM handoff has a known retry contract (e.g. "poll for up to N seconds with a since-buffer of M seconds").

Related

Workaround for soaks until this lands

`manual-test-swap-roundtrip.sh` Scenario A passes on retry within ~1 min. Soaks can wrap the bob-poll section in a retry loop with a max-retry count. This is a band-aid — the underlying flakiness needs a real fix because real users do not retry.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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