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
- Alice's history.json has the SENT entry with bob's chain pubkey, amount, etc. The L3 burn was accepted by the aggregator.
- 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
- 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}]
- 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"]
- 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):
- Init alice's Sphere, send transfer.
await alice.destroy().
- Wait 1–2 seconds.
- Init bob's Sphere from his mnemonic, fresh storage OR existing-storage carry-over.
await bob.payments.receive({finalize:true}).
- Assert token arrived.
Option B — Subprocess (closer to CLI):
- Spawn
sphere payments send as child process; wait for exit.
- Spawn
sphere payments receive --finalize as child process; capture output.
- 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
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 --finalizereturns "No new transfers found" indefinitely, even though alice'ssphere payments sendsucceeds and the kind-31113 TOKEN_TRANSFER event reaches the testnet relay.The same flow works fine in
tests/e2e/uxf-send-receive.test.tsbecause 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/clilinked to local sphere-sdk via the standardnpm linkpath.Relay:
wss://nostr-relay.testnet.unicity.networkWalkthrough doc:
manual-test-full-recovery.md(PR #222, branchdocs/issue-218-full-recovery-manual-test)Repro script:
manual-test-full-recovery.shon the same branch.Minimal repro:
Evidence the bug is on the receiver, not the relay or sender
WebSocket.sendand logging every outgoing message:messagehandler:[Mux] handleEventnever logs between[Mux] updateSubscriptions: walletSub=sub_3and[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—This is the per-address
AddressTransportAdapter'sfetchPendingEvents. It's a no-op.PaymentsModule.receive()callsthis.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) beforereceive()returns.This isn't the whole story though — patching the no-op to delegate to
this.mux.fetchPendingEvents()(which does have a proper boundedsince: now-24h-2daysone-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):
since=stored_ts. Bob's stored ts is1779443013= alice's event'screated_atexactly. The relay returns the event (inclusivesince), but maybe the SDK's dedup or timing drops it.NostrClient.handleEventMessagehas a silenttry/catcharoundsubscription.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.tsbuilds both Sphere instances in the same Node process and keeps them alive throughout the test: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:
uxf-send-receivecannot exercise.What we need
A new e2e test that exercises the cross-process pattern. Two options:
Option A — In-process lifecycle (simpler):
await alice.destroy().await bob.payments.receive({finalize:true}).Option B — Subprocess (closer to CLI):
sphere payments sendas child process; wait for exit.sphere payments receive --finalizeas child process; capture output.sphere balanceshows 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
docs/issue-218-full-recovery-manual-test)project_cross_process_nostr_gap.mdAcceptance
manual-test-full-recovery.md§§§C/§D run cleanly end-to-end on testnet without surprises