Found by an adversarial sweep (6 lenses, 13 candidates, 8 confirmed after independent refutation attempts; deduplicated to 5 distinct defects). None are in the #767 diff — all are pre-existing, in files that change touches only incidentally or not at all.
The shape
Seven defects fixed during #767 were all one class: work that outlives the teardown meant to stop it. Every one was fixed locally at its site. The sweep asked whether that shape is systemic, and all six lenses independently concluded it is, with the same diagnosis:
The repo defines a teardown verb per component and hand-rolls a boolean latch for it. There is no shared abstraction linking a spawned background task to its owner's lifetime, so each latch is checked only where someone happened to look. Seven fixes hardened one latch — TokenRegistry.dispose — and the same shape sits unguarded elsewhere.
Notably, where a component owns its timer outright the discipline is correct (e.g. ConvergenceHeartbeat.schedule() re-checks running after clearTimer, so a late settle() cannot re-arm). The failures cluster where work is spawned from an async continuation and the owner's teardown has already passed its check.
1. A failed address switch orphans a whole NostrClient — transport/NostrTransportProvider.ts:495-534
setIdentity overwrites this.nostrClient at :500, and the only disposal of the previous client is the success tail oldClient.disconnect() at :534. No try/finally.
Reach: sphere.switchToAddress(n) → core/Sphere.ts:2306 await this._transport.setIdentity(...) → the :495 branch is taken on any running wallet (nothing in setIdentity ever changes status) → one slow relay makes NostrClient.connect's Promise.all reject, and the provider's 10s race (TIMEOUTS.WEBSOCKET_CONNECT) fires before the SDK's own 30s per-relay timeout → throw skips :534 and propagates uncaught out of switchToAddress.
The orphan stays fully live: per-relay ping setInterval, an exponential auto-reconnect chain guarded only by this.closed (which nothing will now set), open sockets, and relay subscriptions whose onEvent closures still call provider.handleEvent(...) — including writing LAST_WALLET_EVENT_TS through a storage provider destroy() has already disconnected.
Teardown cannot reach it: disconnect() (:335-347) closes the replacement. grep -rn "oldClient" returns exactly :497 and :534. The Mux is no backstop — it deliberately doesn't close a shared client (MultiAddressTransportMux.ts:423-425), and rebindToSharedClient() only runs after setIdentity resolves. Each retry leaks another, because status is still 'connected'.
2. The mailbox drain runs past facade.stop() — modules/payments-v2/receive/Receive.ts:118-133
Found independently by three lenses. Receive's own poll interval launches void this.drainOnce(), which is never registered in PaymentsFacade.pendingOps — the documented quiescence gate. So facade.stop() clears the interval and returns while a drain is still doing wallet-api I/O, engine.verify, scoped-KV writes and transfer:incoming emission.
That work continues into the rest of Sphere.destroy(), against providers being disconnected underneath it. This one is money-adjacent: a token can be verified and acked while its owner is being torn down.
3. switchToAddress racing destroy() re-arms a complete live wallet — core/Sphere.ts:2186
An unawaited sphere.switchToAddress(1) concurrent with await sphere.destroy() can, after destroy() has returned, construct a fresh MultiAddressTransportMux with its own NostrClient and sockets, fresh per-address modules holding transport subscriptions, a newly started PaymentsFacade (wallet-api session, wake WebSocket with reconnect + heartbeat timers, per-stream pull intervals, receive poll), and re-identify/reconnect the transport.
A destroyed Sphere ends up with a full live wallet attached and no owner.
4. engine.dispose() mid-verify hangs quiescence forever — core/Sphere.ts:1463-1471 (breaks-function)
With the documented opt-in worker pool (Sphere.init({ verification: { createWorker } })), disposing the engine terminates its workers without settling the in-flight batch. The awaiting drain promise never resolves or rejects, so the facade's quiescence loop waits on it forever. Not a leak — a hang, on the documented teardown path.
5. TokenRegistry's static teardown doesn't use the instance one — registry/TokenRegistry.ts:260-272
resetInstance() / static destroy() call stopAutoRefresh() only. They don't set the disposed flag, don't bump the generation, and don't abort an in-flight fetch — so an initial load already in its continuation re-arms the interval on an instance getInstance() can no longer return, and its abort timer survives. The instance dispose() added in #767 does all of this correctly; the static path simply doesn't call it.
This is the cheapest fix of the five and the closest to #767's scope.
Recommendation
Fixing these five individually repeats what #767 did seven times. The lenses agree the answer is one primitive: a way to register spawned background work against its owner's lifetime, so that teardown cancels what it started rather than each site remembering to re-check a latch after every await. PaymentsFacade.pendingOps is the closest thing that exists — #2 is a defect precisely because one spawn site forgot to use it.
Suggested order: 5 (trivial, in-file), then 4 (a hang, on a documented path), then 1 and 2, then 3 with the primitive.
Sweep method: 6 parallel lenses over timers, sockets/aborts, floating promises, partial teardown, the payments vertical, and the registry; every candidate then handed to an independent agent instructed to refute it and to reject anything already fixed in #767. 13 candidates → 8 confirmed → 5 distinct. Evidence gathered 2026-09-02 against refactor/per-instance-registry.
Found by an adversarial sweep (6 lenses, 13 candidates, 8 confirmed after independent refutation attempts; deduplicated to 5 distinct defects). None are in the #767 diff — all are pre-existing, in files that change touches only incidentally or not at all.
The shape
Seven defects fixed during #767 were all one class: work that outlives the teardown meant to stop it. Every one was fixed locally at its site. The sweep asked whether that shape is systemic, and all six lenses independently concluded it is, with the same diagnosis:
Notably, where a component owns its timer outright the discipline is correct (e.g.
ConvergenceHeartbeat.schedule()re-checksrunningafterclearTimer, so a latesettle()cannot re-arm). The failures cluster where work is spawned from an async continuation and the owner's teardown has already passed its check.1. A failed address switch orphans a whole NostrClient —
transport/NostrTransportProvider.ts:495-534setIdentityoverwritesthis.nostrClientat:500, and the only disposal of the previous client is the success tailoldClient.disconnect()at:534. No try/finally.Reach:
sphere.switchToAddress(n)→core/Sphere.ts:2306await this._transport.setIdentity(...)→ the:495branch is taken on any running wallet (nothing insetIdentityever changesstatus) → one slow relay makesNostrClient.connect'sPromise.allreject, and the provider's 10s race (TIMEOUTS.WEBSOCKET_CONNECT) fires before the SDK's own 30s per-relay timeout → throw skips:534and propagates uncaught out ofswitchToAddress.The orphan stays fully live: per-relay ping
setInterval, an exponential auto-reconnect chain guarded only bythis.closed(which nothing will now set), open sockets, and relay subscriptions whoseonEventclosures still callprovider.handleEvent(...)— including writingLAST_WALLET_EVENT_TSthrough a storage providerdestroy()has already disconnected.Teardown cannot reach it:
disconnect()(:335-347) closes the replacement.grep -rn "oldClient"returns exactly:497and:534. The Mux is no backstop — it deliberately doesn't close a shared client (MultiAddressTransportMux.ts:423-425), andrebindToSharedClient()only runs aftersetIdentityresolves. Each retry leaks another, becausestatusis still'connected'.2. The mailbox drain runs past
facade.stop()—modules/payments-v2/receive/Receive.ts:118-133Found independently by three lenses.
Receive's own poll interval launchesvoid this.drainOnce(), which is never registered inPaymentsFacade.pendingOps— the documented quiescence gate. Sofacade.stop()clears the interval and returns while a drain is still doing wallet-api I/O,engine.verify, scoped-KV writes andtransfer:incomingemission.That work continues into the rest of
Sphere.destroy(), against providers being disconnected underneath it. This one is money-adjacent: a token can be verified and acked while its owner is being torn down.3.
switchToAddressracingdestroy()re-arms a complete live wallet —core/Sphere.ts:2186An unawaited
sphere.switchToAddress(1)concurrent withawait sphere.destroy()can, afterdestroy()has returned, construct a freshMultiAddressTransportMuxwith its ownNostrClientand sockets, fresh per-address modules holding transport subscriptions, a newly startedPaymentsFacade(wallet-api session, wake WebSocket with reconnect + heartbeat timers, per-stream pull intervals, receive poll), and re-identify/reconnect the transport.A destroyed Sphere ends up with a full live wallet attached and no owner.
4.
engine.dispose()mid-verify hangs quiescence forever —core/Sphere.ts:1463-1471(breaks-function)With the documented opt-in worker pool (
Sphere.init({ verification: { createWorker } })), disposing the engine terminates its workers without settling the in-flight batch. The awaiting drain promise never resolves or rejects, so the facade's quiescence loop waits on it forever. Not a leak — a hang, on the documented teardown path.5.
TokenRegistry's static teardown doesn't use the instance one —registry/TokenRegistry.ts:260-272resetInstance()/ staticdestroy()callstopAutoRefresh()only. They don't set the disposed flag, don't bump the generation, and don't abort an in-flight fetch — so an initial load already in its continuation re-arms the interval on an instancegetInstance()can no longer return, and its abort timer survives. The instancedispose()added in #767 does all of this correctly; the static path simply doesn't call it.This is the cheapest fix of the five and the closest to #767's scope.
Recommendation
Fixing these five individually repeats what #767 did seven times. The lenses agree the answer is one primitive: a way to register spawned background work against its owner's lifetime, so that teardown cancels what it started rather than each site remembering to re-check a latch after every await.
PaymentsFacade.pendingOpsis the closest thing that exists — #2 is a defect precisely because one spawn site forgot to use it.Suggested order: 5 (trivial, in-file), then 4 (a hang, on a documented path), then 1 and 2, then 3 with the primitive.
Sweep method: 6 parallel lenses over timers, sockets/aborts, floating promises, partial teardown, the payments vertical, and the registry; every candidate then handed to an independent agent instructed to refute it and to reject anything already fixed in #767. 13 candidates → 8 confirmed → 5 distinct. Evidence gathered 2026-09-02 against
refactor/per-instance-registry.