Skip to content

Cross-process Nostr token transfer is broken — CLI sender→exit→receiver→start loses events; no e2e coverage #223

Description

@vrogojin

Summary

While running the manual walkthrough from #218 / PR #222 on real testnet, the basic alice→bob token transfer via the CLI failed end-to-end: bob's sphere payments receive --finalize returns "No new transfers found" indefinitely, even though alice's sphere payments send succeeds and the kind-31113 TOKEN_TRANSFER event reaches the testnet relay.

The same flow works fine in tests/e2e/uxf-send-receive.test.ts because that test runs sender + receiver in the same process — both Sphere instances stay alive, bob's live subscription catches alice's published event in real-time. The CLI is fundamentally different (sender exits, then receiver starts seconds later) and that path has zero e2e coverage.

Repro

Branch: integration/all-fixes (commit 13dc1dc at time of repro)
CLI: @unicity-sphere/cli linked to local sphere-sdk via the standard npm link path.
Relay: wss://nostr-relay.testnet.unicity.network
Walkthrough doc: manual-test-full-recovery.md (PR #222, branch docs/issue-218-full-recovery-manual-test)
Repro script: manual-test-full-recovery.sh on the same branch.

Minimal repro:

# In two separate working dirs, each fresh
cd ~/test-alice
sphere wallet create alice && sphere wallet use alice
SPHERE_ALLOW_MNEMONIC_NON_TTY=1 sphere init --network testnet --nametag alice-XXX
# Save mnemonic
sphere faucet
sphere payments sync
sphere payments send @bob-XXX 20 UCT      # Returns "Status: submitted"

cd ~/test-bob
sphere wallet create bob && sphere wallet use bob
SPHERE_ALLOW_MNEMONIC_NON_TTY=1 sphere init --network testnet --nametag bob-XXX
sphere payments receive --finalize        # "No new transfers found" — should see 20 UCT
sphere balance                            # "No tokens found"

Evidence the bug is on the receiver, not the relay or sender

  1. Alice's history.json has the SENT entry with bob's chain pubkey, amount, etc. The L3 burn was accepted by the aggregator.
  2. Direct WebSocket query to the relay (anonymous, mimicking the SDK's filter) returns alice's event:
    REQ {kinds:[4,31113,31115,31116], "#p":[bob_transport_pubkey], since:1779443013}
    → EVENT {id:422e59544b1f..., kind:31113, created_at:1779443013, pubkey:927524810c90a65c... (alice)}
    EOSE
    
  3. The SDK does send the exact same REQ — confirmed by monkey-patching WebSocket.send and logging every outgoing message:
    ["REQ","sub_3",{"kinds":[4,31113,31115,31116],"#p":["93cd74...bob..."], "since": 1779443013}]
    
  4. The SDK does receive the EVENT — confirmed by monkey-patching the WS message handler:
    [WS-RECV] ["EVENT","sub_3", {<alice's event>}]
    [WS-RECV] ["EOSE","sub_3"]
    
  5. But [Mux] handleEvent never logs between [Mux] updateSubscriptions: walletSub=sub_3 and [Mux] Wallet subscription EOSE. The event arrives at NostrClient but never reaches MUX dispatch — no decrypt success log, no decrypt failure log, nothing. Even with full SDK debug logging enabled (logger.configure({debug:true})).

Suspicious code

transport/MultiAddressTransportMux.ts:1937

async fetchPendingEvents(): Promise<void> {
  // Fetching is handled by subscription — no-op for mux-based adapters
  // The mux subscription already includes this address's pubkey
}

This is the per-address AddressTransportAdapter's fetchPendingEvents. It's a no-op. PaymentsModule.receive() calls this.deps!.transport.fetchPendingEvents() and then snapshots/loads tokens — but the no-op means the snapshot/load doesn't actually wait for any one-shot relay query. The live subscription's onEvent handler runs async, and there's no guarantee it completes (or even fires) before receive() returns.

This isn't the whole story though — patching the no-op to delegate to this.mux.fetchPendingEvents() (which does have a proper bounded since: now-24h-2days one-shot fetch + await for EOSE) did not fix the issue in a quick test. The event still doesn't reach MUX dispatch.

Possible additional contributors (need verification):

  • The MUX's persistent subscription uses since=stored_ts. Bob's stored ts is 1779443013 = alice's event's created_at exactly. The relay returns the event (inclusive since), but maybe the SDK's dedup or timing drops it.
  • NostrClient.handleEventMessage has a silent try/catch around subscription.listener.onEvent(event) — if the listener throws synchronously, the event is dropped without any log.

Why our e2e tests don't catch this

tests/e2e/uxf-send-receive.test.ts builds both Sphere instances in the same Node process and keeps them alive throughout the test:

const a = await initWallet('alice', aliceTag);
const b = await initWallet('bob', bobTag);
// ...
await a.sphere.payments.send({ recipient: `@${bobTag}`, ... });
await waitFor(async () => {
  try { await b.sphere.payments.receive({ finalize: true }); } catch {}
  return getBalance(b.sphere, SYMBOL).total >= 1000n ? bal : null;
});

Bob's MUX is live and subscribed when alice publishes. The persistent subscription's onEvent fires in real-time. No backfill needed.

The CLI is fundamentally different:

  • Alice's process publishes → exits.
  • Bob's process starts seconds later, must backfill events from when it was offline.
  • This is the cross-process boundary uxf-send-receive cannot exercise.

What we need

A new e2e test that exercises the cross-process pattern. Two options:

Option A — In-process lifecycle (simpler):

  1. Init alice's Sphere, send transfer.
  2. await alice.destroy().
  3. Wait 1–2 seconds.
  4. Init bob's Sphere from his mnemonic, fresh storage OR existing-storage carry-over.
  5. await bob.payments.receive({finalize:true}).
  6. Assert token arrived.

Option B — Subprocess (closer to CLI):

  1. Spawn sphere payments send as child process; wait for exit.
  2. Spawn sphere payments receive --finalize as child process; capture output.
  3. Assert "No new transfers found" is NOT in output and sphere balance shows the expected amount.

Either way, without this test we cannot claim Nostr delivery works on the CLI surface. Same-process assertions are not equivalent.

Refs

Acceptance

  • Failing cross-process e2e test added (Option A or B above)
  • Root cause isolated (NostrClient listener swallow? subscription map mistiming? handler registration race?)
  • Fix lands; same e2e test passes
  • Manual walkthrough manual-test-full-recovery.md §§§C/§D run cleanly end-to-end on testnet without surprises

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