refactor(sphere)!: remove the remaining process-global state — one Sphere per network, disposable, concurrent-safe - #772
Conversation
…mport() to their storage (#766) BREAKING: `Sphere.getInstance()`, `Sphere.isInitialized()` and the root export `getSphere` are removed. Root exports 126 -> 125. The consumer gate found ZERO users across all 29 sibling repos — every fleet `getInstance` is TokenRegistry's, every `getSphere` is repo-local, every `isInitialized` is a consumer's own field. The replacement is the instance the entry point already returns, plus `sphere.isReady`. Why they had to go rather than be deprecated: after a second Sphere is created and then destroyed, `getInstance()` returns null while the first is alive and serving money. A deprecation note does not stop a wrong answer being consumed. The static is replaced by a PRIVATE `WeakMap<StorageProvider, Set<Sphere>>` used only by clear()/import(). Keyed by object identity — StorageProvider.id is a class constant ('file-storage'), so comparing it would still have destroyed an unrelated instance. Private, so it is not a liveness API; WeakMap, so it is bounded by the provider's own lifetime. Registration happens at exactly the three publication points, preserving #767's invariant that a half-built Sphere is never reachable. - clear({storage}) now destroys only the Spheres built on THAT storage. It used to destroy whichever Sphere was constructed last, killing a live wallet on an unrelated provider and dropping every sphere.on() handler with no event and no error. - import()'s `needsClear` is likewise storage-scoped; it used to fire merely because some instance was live somewhere. The `exists(storage)` disjunct is preserved, which is the storage-wipe contract sphere and Boxy-Run actually depend on. - importFromLegacyFile returned `Sphere.getInstance()!` because importFromJSON DISCARDED the Sphere it built. Threading it out (additive, non-breaking) fixes the cause, not the symptom — an interleaved init used to make that path return the wrong object. Also folded in, both in the same entry-point preambles this touches: - #770.5: TokenRegistry.resetInstance() now calls the instance dispose() instead of only stopAutoRefresh(), which left `disposed` unset, the generation unchanged and the in-flight fetch running — so a load past its entry guard re-armed the interval on an instance getInstance() could no longer return. - #769.2: init now forwards `verification` to create/load. It was silently dropped, so a consumer opting into the worker pool at the documented entry point got the sequential verifier with no indication. (The issue also claimed `debug` was dropped; it is not — init configures the global logger itself.) - #766 item 4: the debug flag was one-way. `if (options.debug)` meant no second init could ever turn it off; all four entry points now honour an explicit false. createNodeProviders had the mirror-image bug — `?? false` silently disabled a flag the consumer had already set — and now matches createBrowserProviders in only overriding when told to. The logger stays process-global deliberately. 370 call sites across 27 files, most in providers constructed before any Sphere exists and shared between Spheres by design (ConnectClient has no owning Sphere at all). Threading a handle would break every provider constructor to gain per-instance control of a third of the messages, and the worst outcome of the global is noisy stdout — unlike the registry global, which produced a 10^8 balance error. Tests: singleton-hygiene resets deleted (they existed only because the static did); afterEach blocks that used getInstance() to find the leftover now hold the returned reference. Two real guards kept with the mechanism changed, not the assertion — the clear()-destroys-a-live-Sphere test seeds the private map instead of the static. New tests/integration/sphere-instance-scoping.test.ts proves the point in both directions, and was falsified against three separate mutations: restoring the old last-constructed-wins static reds the survives half, making liveOn return nothing reds the own-Sphere-is-destroyed half, and returning every Sphere regardless of storage reds all three. Construction order in those tests is load-bearing — A is built last, so it is exactly the instance the deleted static pointed at.
…them (#766) Each Sphere loads its own snapshot of the tracked-address registry and every persist wrote that snapshot WHOLESALE. Two Spheres over one storage: A switchToAddress(1) leaves disk [0,1]; B switchToAddress(2) leaves disk [0,2] — A's address erased from the record while A's in-memory getActiveAddresses() still reports it. The user's other funded addresses vanish from the UI until rediscovered. Deliberately NOT network-scoped, which is what #766 assumed. The payload is {index, hidden, createdAt, updatedAt} and deriveDirectAddress takes no network, so index n is byte-identical on every network — the content belongs on the shared side of "shared seed, isolated operational state". And this is a lost update, not a scoping problem: it happens with both Spheres on testnet2. Renaming the key would orphan every deployed wallet's address list for no benefit; the pv2g2 precedent does not transfer, because there the old data was genuinely unreadable and a survivor was actively harmful. saveTrackedAddresses is now read-merge-write, serialized per provider instance. Union by index; the greater updatedAt wins `hidden`; createdAt keeps the earlier value. A union is only safe because there is no delete path — verified, `_trackedAddresses.delete` has zero hits; removals go through Sphere.clear(), which drops the key rather than persisting a short list. That reasoning is recorded on the port docstring, since it is the next implementer who would otherwise reintroduce this. setAddressHidden and trackScannedAddresses now stamp updatedAt alongside hidden. Without a real clock the merge degenerates to arbitrary-wins. One correction to the design during implementation: a strict parse that dropped unusable rows broke an existing round-trip pin and would have silently deleted a real address if any stored row were ever odd — worse than the bug being fixed. The parse now repairs instead: only a row with no finite index is dropped, missing timestamps read as 0 so a timeless observation loses every conflict rather than winning on a fabricated one, and unknown fields survive both parse and merge. Test written BEFORE the fix and confirmed red against unfixed code (disk [0,2] where [0,1,2] was expected), then red again twice more when saveTrackedAddresses was reverted to the wholesale write. Mutation probe added and hand-verified KILLED. Known residual, now tracked as #771: two SEPARATE FileStorageProvider objects over one dataDir still clobber — that provider caches the whole KV in memory and rewrites the entire file on every set(), so this affects every key, not just this one. The browser providers read through to the shared store and are fixed cross-instance.
There was a problem hiding this comment.
🟡 Changes recommended
Malformed tracked-address indices can alias derived addresses, while several central regressions and migration documentation remain insufficiently guarded.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Removes Sphere’s global singleton lifecycle while improving concurrent storage handling and teardown safety.
Changes:
- Scopes live Sphere instances by storage and removes singleton APIs.
- Merges concurrent tracked-address updates.
- Fixes verification forwarding, logger configuration, registry disposal, and legacy import returns.
File summaries
| File | Description |
|---|---|
core/Sphere.ts |
Reworks lifecycle, imports, logging, verification, and address timestamps. |
index.ts |
Removes getSphere export. |
registry/TokenRegistry.ts |
Fully disposes reset singleton. |
storage/tracked-addresses.ts |
Adds parsing and merge helpers. |
storage/storage-provider.ts |
Defines merge-on-save contract. |
impl/nodejs/storage/FileStorageProvider.ts |
Implements serialized address merging. |
impl/nodejs/index.ts |
Preserves explicitly configured logging. |
impl/browser/storage/LocalStorageProvider.ts |
Implements serialized address merging. |
impl/browser/storage/IndexedDBStorageProvider.ts |
Implements serialized address merging. |
tests/mutation/probes.json |
Adds tracked-address merge probe. |
tests/integration/sphere-instance-scoping.test.ts |
Tests storage-scoped lifecycle behavior. |
tests/integration/tracked-addresses-concurrent.test.ts |
Tests concurrent address persistence. |
tests/integration/sphere-payments-v2-wiring.test.ts |
Updates publication assertions. |
tests/integration/wallet-clear.test.ts |
Removes singleton resets. |
tests/integration/tracked-addresses.test.ts |
Removes singleton resets. |
tests/integration/nametag-overwrite-guard.test.ts |
Removes singleton resets. |
tests/integration/nametag-normalization.test.ts |
Removes singleton resets. |
tests/unit/core/Sphere.status.test.ts |
Tracks instances locally for teardown. |
tests/unit/core/Sphere.registerNametag.test.ts |
Removes singleton resets. |
tests/unit/core/Sphere.network-delegation.test.ts |
Tracks instances locally for teardown. |
tests/unit/core/Sphere.destroy-secrets.test.ts |
Tracks instances locally for teardown. |
tests/unit/core/Sphere.clear.test.ts |
Adapts clear tests to storage-scoped registry. |
Review details
Suppressed comments (2)
core/Sphere.ts:714
- The new-wallet
init()branch also needs a Sphere-level regression provingverificationreaches the created engine. Testing only the engine factory would still pass if this forwarding line were removed, recreating the documented sequential-verifier fallback for newly created wallets.
verification: options.verification,
core/Sphere.ts:1999
- The return type now exposes the imported Sphere, but the public JSDoc still says the method returns only success and an optional mnemonic. Document
sphereso generated API docs describe the new additive contract.
}): Promise<{ success: boolean; sphere?: Sphere; mnemonic?: string; error?: string }> {
- Files reviewed: 22/22 changed files
- Comments generated: 7
- Review effort level: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| market, | ||
| communications: options.communications, | ||
| password: options.password, | ||
| verification: options.verification, |
There was a problem hiding this comment.
Fixed in c1b05e0f — new tests/unit/core/Sphere.init-verification.test.ts, covering both branches.
You were right that this was undeletable-in-name-only: the existing worker tests build the engine directly and never go through Sphere.init, so either forwarding line could have gone with the suite green.
The falsification discriminates, which is the part I care about: deleting the load() forwarding reds only the load test, deleting the create() forwarding reds only the create test, other cases green in both runs. The discriminator is which verifier owns engine.verify — a probe token makes the pool verifier reject while the stubbed Token.verify resolves. Two control cases are kept so a rejection cannot be mistaken for an unrelated failure. Two mutation probes added.
| for (const live of Sphere.liveOn(storage)) { | ||
| logger.debug('Sphere', 'Destroying Sphere instance on this storage...'); | ||
| await live.destroy(); |
There was a problem hiding this comment.
Fixed in c1b05e0f. Correct — _liveByStorage maps to a Set, but every test had one Sphere per storage and the unit test seeded a single mock, so an implementation tracking only one instance per provider would have passed.
Added a case with two live Spheres on the same provider asserting clear() destroys both. Falsified by narrowing registerLive to set(storage, new Set([sphere])) — reds only that test, expected true to be false, other six green.
| if (result.success) { | ||
| const sphere = Sphere.getInstance(); | ||
| return { success: true, sphere: sphere!, mnemonic: result.mnemonic }; | ||
| return { success: true, sphere: result.sphere, mnemonic: result.mnemonic }; |
There was a problem hiding this comment.
Fixed in c1b05e0f. There was no test of either method, which is how the wrong-object bug survived in the first place.
Added an interleaving regression: another Sphere stays alive on different storage while the sphere-wallet-JSON path runs, asserting the returned Sphere is the one imported into the supplied storage.
Three separate falsifications, each reddening its own case: discarding the Sphere in importFromJSON's mnemonic branch reds two tests; the master-key branch reds one; dropping sphere: result.sphere in importFromLegacyFile reds one. All with expected undefined to be defined. Also documented the additive sphere field on the @returns, per your suppressed comment.
| // dispose(), not stopAutoRefresh(): the latter leaves `disposed` unset, the generation | ||
| // unchanged and the in-flight fetch running, so a load already past its entry guard | ||
| // re-arms the interval on an instance getInstance() can no longer return — an | ||
| // unstoppable timer plus a live abort timer. (#770) | ||
| TokenRegistry.instance?.dispose(); |
There was a problem hiding this comment.
Fixed in c1b05e0f. Added a static-path regression with fake timers and an in-flight fetch.
The interesting part is what turned out to be load-bearing. Reverting to stopAutoRefresh() reds the test — but when I removed the isDisposed/interval assertions to check the abort independently, it still red with expected +0 to be 1: the fetch was never aborted. The interval assertion alone does not red, which is exactly your point — the old code cleared the timer and did nothing else, so a test asserting only the timer would have passed against it.
| * - serialize concurrent calls on the provider instance, so one call's read | ||
| * cannot interleave with another's write; | ||
| * - a failed write must not brick later writes, and must still reject to its | ||
| * own caller. |
There was a problem hiding this comment.
Fixed in c1b05e0f. Added tests/unit/storage/contracts/tracked-addresses.contract.ts and ran it against all three providers, following the existing tests/unit/payments-v2/contracts/ idiom. Includes the failed-write recovery case you asked for.
Nine falsifications, three per rule, and each reds only the provider it was applied to:
mergeTrackedAddresses(onDisk, entries)→entries: each provider reds its own four cases, other eight green.- serialization removed: reds only
serializes concurrent calls,expected [0,3] to deeply equal [0,1,2,3]. - rejection-swallow removed: reds only the recovery case — the second write inherits the first's rejection.
That last one is the case I would not have written without the prompt, and it is the one that matters operationally.
| function toEntry(value: unknown): TrackedAddressEntry | null { | ||
| if (typeof value !== 'object' || value === null) return null; | ||
| const e = value as Record<string, unknown>; | ||
| if (typeof e.index !== 'number' || !Number.isFinite(e.index)) return null; |
There was a problem hiding this comment.
Fixed in c1b05e0f, and this was the one real correctness bug in the review.
Number.isFinite(1.5) is true, and deriveKeyAtPath parseInt()s the path segment (core/crypto.ts:232), so a stored row with index 1.5 derives index 1's keys — a malformed row silently impersonating a real address. Negatives reached derivation too. Now requires a non-negative integer.
Worth recording why this was easy to get wrong: the parse deliberately follows repair-over-drop, because dropping an odd row deletes one of the user's addresses. That is right for every field except the index — an unusable index is not a recoverable address, and keeping it aliases one that is. The rationale now lives on the saveTrackedAddresses port docstring, where the next implementer looks.
Tested and falsified: restoring Number.isFinite reds three cases ([0,1,1.5] vs [0,1], [-1,0] vs [0], and the mixed set). One vacuity caught while writing it — a stored NaN cannot round-trip, since JSON.stringify writes null, so a NaN-only assertion would have passed either way. Folded into a rejection set carrying 1.5, -1, Infinity, a string and a missing index, so every test in the file reds under the revert.
| // ============================================================================= | ||
|
|
||
| export { Sphere, createSphere, loadSphere, initSphere, getSphere, sphereExists, checkNetworkHealth, logger, SphereError, PartialSendConflictError, isSphereError, isPossiblyCommittedSendOutcome } from './core'; | ||
| export { Sphere, createSphere, loadSphere, initSphere, sphereExists, checkNetworkHealth, logger, SphereError, PartialSendConflictError, isSphereError, isPossiblyCommittedSendOutcome } from './core'; |
There was a problem hiding this comment.
Fixed in c1b05e0f. That section was mine, written in #767 and never revisited when this PR removed the APIs it describes — so the migration guide was actively directing consumers at Sphere.getInstance()/isInitialized() and telling them clear()/import() are storage-blind.
Rewritten: the removals with their replacement (hold the instance the entry point returns; sphere.isReady), the new storage-scoped clear()/import() behaviour, and what genuinely remains — two FileStorageProvider objects over one dataDir, now tracked as #771.
…se the coverage gaps Copilot found Copilot reviewed #772 and found one correctness bug, one stale doc, and five places where the guard could be deleted with the suite still green. All valid. CORRECTNESS — an unusable index aliased a real address parseTrackedAddresses accepted any FINITE index, so a stored row with index 1.5 passed. deriveKeyAtPath parseInt()s the path segment (core/crypto.ts:232), so that row derives index 1's keys — a malformed row silently impersonating a real address. Negatives reached derivation too. Now a non-negative integer is required. This inverts the repair-over-drop rule the merge otherwise follows, deliberately: dropping a row normally deletes one of the user's addresses, but an unusable index is not a recoverable address, and keeping it aliases one that is. The rationale lives on the port docstring, where the next implementer looks. DOCS — docs/MIGRATION-TOKEN-REGISTRY.md still told consumers Sphere.getInstance() and isInitialized() exist and that clear()/import() are storage-blind. I wrote that in #767 and never revisited it when this PR removed them. Now points at #771 for what genuinely remains. COVERAGE — five guards that could be deleted with the suite green This is the class that has repeatedly bitten this branch, and Copilot caught it by auditing whether the TESTS constrain the code rather than whether the code is correct: - Either `verification: options.verification` forwarding line could be deleted; the existing worker tests build the engine directly and never go through Sphere.init. - _liveByStorage holds a Set per storage, but every test had one Sphere per storage, so an implementation tracking only one instance passed. - importFromLegacyFile/importFromJSON had no test at all — a refactor could discard result.sphere again, reinstating the wrong-object bug this PR fixes. - resetInstance()'s new teardown had no static-path regression. - The merge contract was tested against FileStorageProvider only; the two browser providers could satisfy the interface while still replacing snapshots. Closed with 26 new tests including a shared provider contract suite run against all three implementations, following the tests/unit/payments-v2/contracts/ idiom. Probes 61 -> 71. Every new test was falsified individually, and the falsifications discriminate: deleting the load-branch forwarding reds only the load test, the create-branch only the create test; each provider's merge mutation reds only that provider's four cases. For resetInstance the interval assertion alone does NOT red — only the in-flight-abort assertion does, which is exactly the point, since the old code cleared the timer and did nothing else. One vacuity caught in passing: a stored NaN cannot round-trip (JSON.stringify writes null), so a NaN-only assertion would have passed either way. Folded into a rejection-set case carrying 1.5, -1, Infinity, a string and a missing index, so every test in that file reds under the revert.
There was a problem hiding this comment.
🟡 Changes recommended
The parser accepts out-of-range BIP32 indices, and the breaking lifecycle documentation remains contradictory.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
impl/nodejs/index.ts:177
- This changes omitted
debugfrom “reset to false” to “preserve the consumer’s current setting,” but the Node provider tests do not cover either omitted or explicit-false behavior. Add a regression that pre-enables the logger, verifiescreateNodeProviderswithoutdebugpreserves it, and verifiesdebug: falsedisables it.
- Files reviewed: 28/28 changed files
- Comments generated: 3
- Review effort level: Balanced
| // `undefined` leaves whatever the provider factory or consumer set; an explicit | ||
| // `false` MUST turn debug off. A truthy-only check made this process-global flag | ||
| // one-way — no second init could ever quieten it (#766). | ||
| if (options.debug !== undefined) logger.configure({ debug: options.debug }); |
There was a problem hiding this comment.
Fixed — new tests/unit/core/Sphere.debug-logging.test.ts, 11 tests, discriminating per entry point: reverting init reds three, create/load/import one each, no cross-talk. Four probes added.
Fair hit, and pointed: this is the same coverage gap you flagged last round, in code I wrote to satisfy that round.
Writing it turned up something I had wrong. I had told my reviewer that #769's debug finding was mistaken because init configures the logger itself. The forwarding really is missing — init does not pass debug to the create/load it dispatches to. It is harmless today only because init configures the global first and !== undefined makes the later calls no-ops. Under a ?? false rewrite, Sphere.init({ debug: true }) would end up off, because create would then run configure({ debug: undefined ?? false }) after init had set it true. So the guard is load-bearing for a second reason beyond the one-way bug, and there is now a test pinning that exact sequence.
| function toEntry(value: unknown): TrackedAddressEntry | null { | ||
| if (typeof value !== 'object' || value === null) return null; | ||
| const e = value as Record<string, unknown>; | ||
| if (typeof e.index !== 'number' || !Number.isInteger(e.index) || e.index < 0) return null; |
There was a problem hiding this comment.
Fixed. Correct, and it shows my previous fix caught only one end of the range.
Last round I rejected fractional and negative indices because deriveKeyAtPath parseInt()s the path segment, so 1.5 derives index 1's keys. But deriveChildKey serializes the child number as index.toString(16).padStart(8, '0') (core/crypto.ts:166-177) — above 0xffffffff, toString(16) yields more than 8 hex digits and padStart does not truncate, so the derivation silently emits extra bytes. Same defect, opposite end. Now bounded to a uint32.
Boundary cases added, and each clause is independently load-bearing: removing <= 0xffffffff reds only the over-range case; >= 0 only the negatives; Number.isInteger only the fractionals. 0xffffffff, 0x80000000 (the hardened threshold) and 0 stay green under every mutant, so the ceiling never swallows a legal index. Two probes added.
| `Sphere.getInstance()`, `Sphere.isInitialized()` and the `getSphere` export are **removed** | ||
| (see [#766](https://github.com/unicity-sphere/sphere-sdk/issues/766)); hold the instance the | ||
| entry point returns, and use `sphere.isReady`. `Sphere.clear()` / `Sphere.import()` now | ||
| destroy only Spheres built on the storage they are given. |
There was a problem hiding this comment.
Fixed. Both locations now give one account.
The migration guide had the removed statics sitting under "Not fixed by this release" — I edited that section to describe the removal but left it under the old heading, so it read as the opposite of what it said. It now has an "Also removed" section for the lifecycle globals, with only the #771 FileStorageProvider limitation left under "Not fixed".
CHANGELOG [Unreleased] described only #767's registry work; there was no entry for this PR at all. Added five: the BREAKING removal with the export count and the reason it was not deprecated, the clear()/import() scoping, the tracked-address merge and uint32 bound, the debug/verification fixes, and #770.5.
…r fix; reconcile the release notes
Copilot's second pass, three findings, all valid — and one shows the previous round fixed
only half a bug.
1. The index guard stopped short of the derivation's real range
Last round rejected fractional and negative indices, because deriveKeyAtPath parseInt()s
the path segment so a row with index 1.5 derives index 1's keys. But deriveChildKey
serializes the child number as `index.toString(16).padStart(8, '0')`
(core/crypto.ts:166-177), and above 0xffffffff toString(16) yields MORE than 8 hex digits —
padStart does not truncate, so the derivation silently emits extra bytes. Same class of
defect, the other end of the range. Now a uint32.
Boundary tests added, and each clause proven independently load-bearing: removing
`<= 0xffffffff` reds only the over-range case, `>= 0` only the negatives, `Number.isInteger`
only the fractionals. 0xffffffff, 0x80000000 (hardened threshold) and 0 stay green under
every mutant, so the ceiling never swallows a legal index.
2. The logger fix had no regression
The one-way flag could have come back with the suite green — the same coverage gap Copilot
flagged last round, in code written to satisfy that round. Eleven tests, discriminating per
entry point: reverting `init` reds three, `create`/`load`/`import` one each, no cross-talk.
That work turned up a subtlety worth recording: `Sphere.init` does NOT forward `debug` to
the create/load it dispatches to. It works today only because init configures the global
first AND `!== undefined` makes the later calls no-ops. Under a `?? false` rewrite,
`init({ debug: true })` would end up OFF, because create would then run
`configure({ debug: undefined ?? false })` after init had set it true. So the guard is
load-bearing for a second reason beyond the one-way bug, and a test pins exactly that
sequence. My earlier claim that "#769's debug finding was wrong" was too strong — the
forwarding really is missing; it is currently harmless rather than absent.
3. The release notes contradicted the code
The migration guide still listed the now-removed statics under "Not fixed by this release",
and CHANGELOG [Unreleased] described only #767's registry work — so a consumer reading
either was told the Sphere lifecycle globals still exist. Both now give one account, with
only the #771 FileStorageProvider limitation left under "Not fixed".
Probes 71 -> 77, all hand-verified KILLED. One had gone STALE when the uint32 fix moved the
check into isDerivableIndex(); repointed rather than deleted, and its replace still
reproduces the original Number.isFinite bug.
There was a problem hiding this comment.
🟡 Changes recommended
Backing-store identity, shared-provider teardown, and cross-provider browser writes remain concurrency-unsafe.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (7)
Previously missed (4) — in code that hasn't changed since the last review.
core/Sphere.ts:2189
- Using wall-clock time directly can make an explicit hide/unhide silently lose to the same row already on disk. If the clock moved backwards—or the stored row has a future
updatedAt—this value is smaller, somergeTrackedAddressesrestores the oldhiddenvalue even though this method emits the change event. Advance from both the clock and the entry's prior version.
This issue also appears on line 2680 of the same file.
impl/browser/storage/IndexedDBStorageProvider.ts:249
trackedWritesserializes only this provider object. Two IndexedDB providers using the same default database each have an independent chain, and thegetandsethere run in separate transactions; concurrent saves can both read[0]and then overwrite each other with[0,1]/[0,2]. Put the read and merged write in one IndexedDB read-write transaction (which serializes across connections), and add a two-provider contract case.
impl/browser/storage/LocalStorageProvider.ts:177- This mutex is per provider object, not per backing
Storage. Two LocalStorage providers over the samewindow.localStoragehave independent chains; becauseawait this.get(...)yields, both saves can read the old registry before either writes, and the lastsetdrops the other address. Coordinate by backing storage/prefix (including the multi-context case) and test two provider instances sharing one Storage object.
storage/storage-provider.ts:75 - The contract documents only “non-negative integer,” but the shared parser now rejects values above
0xffffffffand the changelog calls the field a uint32. Custom StorageProvider implementations following this interface can therefore still admit the off-standard indices this PR intends to exclude. State the upper bound here as part of the normative contract.
core/Sphere.ts:2680
- This bulk visibility update has the same backward-clock failure as
setAddressHidden: a future/priorupdatedAtalready on disk beatsDate.now(), so the newly selected visibility is not persisted. Make the timestamp monotonic relative to the tracked entry.
(tracked as { hidden: boolean; updatedAt: number }).updatedAt = Date.now();
core/Sphere.ts:1317
- The
Setexplicitly permits multiple live Spheres to share one provider, but destroying either Sphere still unconditionally callsthis._storage.disconnect()(core/Sphere.ts:3489). For IndexedDB and LocalStorage, the remaining Sphere staysisReadybut its next storage operation failsensureConnected(). Shared providers need ownership/reference counting so only the last registered Sphere disconnects storage (whileclear()can still destroy all of them).
if (!live) {
live = new Set();
Sphere._liveByStorage.set(sphere._storage, live);
}
live.add(sphere);
core/Sphere.ts:2000
- The public README still demonstrates
const { success, mnemonic, error } = await Sphere.importFromJSON(...)(README.md:684) and discards the returned instance. WithgetInstance()removed, a consumer following that example has no handle to use or destroy the live Sphere this method created. Update that example to retainsphereand explain it is present on success.
static async importFromJSON(options: Omit<SphereImportOptions, 'mnemonic' | 'masterKey' | 'chainCode' | 'derivationPath' | 'basePath' | 'derivationMode'> & {
jsonContent: string;
password?: string;
}): Promise<{ success: boolean; sphere?: Sphere; mnemonic?: string; error?: string }> {
- Files reviewed: 30/30 changed files
- Comments generated: 1
- Review effort level: Balanced
| // storage they were handed, instead of whichever Sphere was constructed last. Keyed by | ||
| // object identity, so Spheres on different providers never see each other. Two | ||
| // providers over one dataDir/DB are still distinct keys — see #766. | ||
| private static readonly _liveByStorage = new WeakMap<StorageProvider, Set<Sphere>>(); |
There was a problem hiding this comment.
Fixed in 3a4a0d54. You are right, and this was a regression I introduced in this PR — worse than the behaviour it replaced. The old last-constructed-wins static at least destroyed some Sphere in that scenario; object keying destroyed none, leaving the twin isReady with its mnemonic and journals gone.
The port now carries an optional readonly backingStoreId identifying the store — not the object, and not the class, since id is a class constant ('file-storage') and that is exactly the wrong granularity, as you said. Scheme-namespaced so unrelated stores cannot collide:
file:<resolved absolute path>
indexeddb:<enc dbName>:<enc prefix>
localstorage:<Storage object tag>:<enc prefix>
Omitting it falls back to object identity, so custom providers keep today's behaviour. _liveByStorage becomes Map<string, Set<Sphere>>, with the entry deleted when its Set empties — string keys are not weak, so that cleanup is now load-bearing and has its own test and probe.
Two things worth recording:
Your case had a mirror image that the naive fix would have created. LocalStorageProvider.getStorageSafe() mints a fresh in-memory Storage per provider under SSR, so keying on prefix alone would have merged genuinely unrelated stores into one — the same aliasing in the opposite direction. Hence the WeakMap<Storage, string> object tag.
One falsification ran against real pre-fix code, not a mutant. An accidental checkout put the new tests against the WeakMap implementation and the twin Sphere survived exactly as you described. Separately, setting backingStoreId = 'file:' — the class-constant mistake the new docstring warns against — reds four integration tests as well as the unit ones: cross-wallet kills return immediately.
Eleven falsifications in total, probes 77 → 80, all KILLED. docs/INTEGRATION.md publishes the StorageProvider interface custom implementers code against and now carries the member; the migration guide's "Not fixed" section separates the halves — teardown aliasing fixed here, the whole-file write clobber still open as #771.
Copilot round 3, and it caught a regression I introduced in this PR.
_liveByStorage was keyed by provider OBJECT identity, which does not match what clear()
actually erases. Two provider objects can address the same store: two
IndexedDBStorageProviders sharing dbName+prefix, two LocalStorageProviders over one Storage
with one prefix, two FileStorageProviders resolving to one filePath. Different WeakMap keys,
identical data. So Sphere.clear({storage: A}) emptied the KV while a Sphere registered under
its twin B stayed isReady — mnemonic and money journals deleted underneath a wallet that
believed it was fine.
Strictly worse than before this PR: the old last-constructed-wins static at least destroyed
SOME Sphere in that case. Object keying destroyed none.
I had been next to this and mis-scoped it. #771 records two FileStorageProviders clobbering
each other's WRITES, and the migration guide listed "two providers over one dataDir" as a
known residual — but I never saw that the same aliasing breaks the teardown scoping I was
adding, in the browser providers too, and that this half is a regression rather than an
inherited limit.
The port gains an optional readonly backingStoreId identifying the STORE — not the object,
and not the class (`id` is a class constant like 'file-storage', exactly the wrong
granularity). Two providers returning the same value share erasure; omitting it falls back
to object identity, so custom providers keep today's behaviour. Values are scheme-namespaced
so a file store and an IndexedDB store can never collide:
file:<resolved absolute path>
indexeddb:<enc dbName>:<enc prefix>
localstorage:<Storage object tag>:<enc prefix>
The localStorage object tag is not belt-and-braces: getStorageSafe() mints a fresh in-memory
Storage per provider under SSR, so prefix-only keying would have merged unrelated stores into
one — the same class of bug in the other direction.
_liveByStorage becomes Map<string, Set<Sphere>> and unregisterLive deletes the entry when its
Set empties, so the map stays bounded now that keys are strings rather than weak refs.
Eleven falsifications, each with its own failure message, including one against REAL pre-fix
code rather than a mutant: an accidental checkout ran the new tests against HEAD's WeakMap
version and the twin Sphere survived exactly as reported. Notably `backingStoreId = 'file:'`
(a class constant, the mistake the docstring warns against) reds four integration tests as
well as the unit ones — cross-wallet kills come straight back.
Probes 77 -> 80, all hand-verified KILLED; one refreshed rather than deleted when
registerLive moved onto the key. Suppressions file untouched.
Docs: CHANGELOG and the migration guide both said "object identity"; INTEGRATION.md publishes
the StorageProvider interface custom implementers code against and now carries the member.
The migration guide's "Not fixed" section now separates the halves — teardown aliasing is
fixed here, the whole-file write clobber (#771) is not.
There was a problem hiding this comment.
🟡 Changes recommended
Browser providers still lose tracked-address updates across provider objects sharing one backing store, and invalid public address indices remain accepted.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
storage/tracked-addresses.ts:11
- This validates only rows read from storage, while public writers still accept invalid indices:
switchToAddress()rejects onlyindex < 0, andensureAddressTracked()derives and persists fractional or greater-than-uint32 values. ThusswitchToAddress(1.5)can derive index 1's key, report success, and write an entry that this parser silently drops on reload. Apply the same uint32 guard before every derivation/persist path, ideally centrally inensureAddressTracked().
- Files reviewed: 32/32 changed files
- Comments generated: 5
- Review effort level: Balanced
| const run = this.trackedWrites.then(async () => { | ||
| const onDisk = parseTrackedAddresses(await this.get(STORAGE_KEYS_GLOBAL.TRACKED_ADDRESSES)); |
| const run = this.trackedWrites.then(async () => { | ||
| const onDisk = parseTrackedAddresses(await this.get(STORAGE_KEYS_GLOBAL.TRACKED_ADDRESSES)); |
| this.isTxtMode = this.filePath.endsWith('.txt'); | ||
| this.backingStoreId = `file:${path.resolve(this.filePath)}`; |
There was a problem hiding this comment.
Fixed. Confirmed: only backingStoreId was resolved — this.dataDir and this.filePath kept whatever relative form the caller passed, so a later process.chdir() would have this provider reading and writing a different file while still reporting the id computed from the old cwd. clear() would then empty one store and destroy the liveness bucket of another, which is the exact failure mode round 3 was about, reached by a different route.
Both are now resolved once at construction, and backingStoreId is derived from the already-absolute filePath rather than resolving independently — so the identifier and the file it names cannot diverge.
| // Live Spheres, keyed by the BACKING STORE their provider addresses. NOT a liveness | ||
| // API: it exists only so clear()/import() tear down the instances whose data they | ||
| // really erase. Object identity was too narrow — two providers over one dataDir/DB | ||
| // are different objects but the same file, so clearing through one left the other's | ||
| // Sphere live over an emptied KV (#766). | ||
| private static readonly _liveByStorage = new Map<string, Set<Sphere>>(); |
There was a problem hiding this comment.
Fixed — description updated. You were right that it described the first attempt rather than the merged code: WeakMap<StorageProvider, Set<Sphere>> keyed by object identity and "bounded by provider lifetime" is now a Map<string, Set<Sphere>> keyed by backingStoreId, with explicit empty-set deletion precisely because string keys are not weak.
Worth keeping visible rather than quietly rewriting, so the description now records both: object identity was the first attempt, why it was wrong (your round-3 finding), and why class identity is wrong in the other direction.
| * A stored `index` must be a NON-NEGATIVE INTEGER. `deriveKeyAtPath` parseInt()s | ||
| * that path segment, so `1.5` derives index 1's keys and the row aliases a real | ||
| * address; such rows are dropped on read rather than repaired. |
There was a problem hiding this comment.
Fixed. The contract said "NON-NEGATIVE INTEGER" while the implementation and changelog require a uint32 — so a custom provider following the port text would have retained values above 0xffffffff.
The docstring now states the ceiling and why it exists: deriveChildKey pads the child number to 8 hex digits, so a larger value emits extra bytes and derives off-standard. Both ends of the range are now given in the one place a custom implementer reads.
… per provider object
Copilot round 4. Two of its five findings are consequences of the contract the previous
commit added: backingStoreId explicitly permits multiple provider objects per store, which
by my own definition makes a per-instance write chain insufficient. Two objects both read
the old registry, the last put wins — the lost update this PR set out to fix, one level up.
IndexedDB: the per-instance chain is gone; read, merge and put now happen inside ONE
readwrite transaction. IndexedDB serializes overlapping readwrite transactions across every
connection to a database, so this covers separate provider objects AND separate tabs, which
no in-process coordination could.
LocalStorage: no transaction exists, so the chain moves to a module-level
Map<backingStoreId, Promise>. That covers separate provider objects in ONE JS realm and
nothing more — two tabs still lose updates, and the comment says so rather than implying a
guarantee that is not there.
Also fixed from the same round:
- FileStorageProvider resolved only its backingStoreId, leaving dataDir/filePath relative.
A later process.chdir() would have it read and write a DIFFERENT file while reporting the
id computed from the old cwd — round 3's failure mode by another route. Both are now
resolved at construction, and the id derives from the absolute filePath so the identifier
and the file it names cannot diverge.
- The port docstring said "non-negative integer" while the code requires a uint32, so a
custom provider following the text would keep values deriveChildKey serializes with extra
bytes. It now states both ends of the range and why.
FileStorageProvider's cross-object case is deliberately NOT claimed, and the agent proved
why empirically rather than arguing it: after a sibling correctly persists [0,1,2], a single
unrelated `a.set('other_key','x')` leaves the on-disk registry EMPTY — that provider caches
the whole KV and rewrites the file from its own stale copy. Serializing just this path would
turn the contract case green while any unrelated write still destroys the registry. A green
test that a neighbouring write invalidates is worse than an honestly absent one, so the
cross-object cases are omitted for File with the reason recorded at the call site. #771.
One piece of defensive code was written and then removed: an explicit try/catch + tx.abort()
in the success handler proved UNFALSIFIABLE — the spec's abort path already rejects, and the
test passed with it deleted. Dead code that looks like a safeguard is worse than none.
Probes 80 -> 84, all hand-verified KILLED; one re-pointed when the merge moved inside the
transaction. No probe was added for the chain-map eviction: removing it leaks memory without
changing observable behaviour, so the probe would SURVIVE, and a hollow probe is worse than
an acknowledged gap.
…lve refactor filePath is resolved once at construction now, so the probe's find string no longer matched and the runner reported it STALE (which fails the run — the intended behaviour: a probe must never silently stop guarding anything).
…1) checkOracle POSTed get_round_number with empty params and keyed the verdict off response.ok. The gateway is a routing layer: it refuses ANY call carrying neither stateId nor shardId with HTTP 400 before it looks at the method. So the probe reported every HEALTHY gateway as unhealthy — including the one you would reach for to gate a mainnet cutover. Send get_block_height with a 32-byte stateId (all-zero: it routes like any other and reads as a probe) and read the JSON-RPC body. The status code cannot answer this in either direction — a healthy gateway answers a routing mistake with a 400 plus a body, and JSON-RPC puts application errors inside a 200 — so only a numeric result.blockNumber counts as healthy, and the gateway's own error string is surfaced in preference to a bare status. Verified live: testnet2 and mainnet both answer (block heights ~40.9k / ~268k). The e2e case asserted `typeof healthy === 'boolean'`, which passes whether the network is up or down — it is why this survived. It now asserts healthy, with the error text in the expectation so a genuine outage names itself. Reverting the request shape fails it against both live gateways.
#770.1) setIdentity assigned this.nostrClient = new NostrClient(...) and only disposed the old one on the success tail. If connect rejected or the deadline won, the OLD client kept its sockets, ping intervals and auto-reconnect chain while being unreachable from the provider — disconnect() reaches only the field, and the mux is no backstop (it disposes its own client only when it is not shared). Nothing in setIdentity touches status, so the caller's next retry re-entered the same branch and orphaned another one. The replacement is now built into a local and the field moves only after a successful connect: a failed connect disposes the replacement and leaves the provider on the working old client; a throwing subscribeToEvents disposes the old one from a finally, because by then the swap is committed and nothing else can reach it. Deliberately not 'tear down both' — the mux SHARES this client, so that would kill its socket over a transient relay timeout with nothing to reconnect it. Folds in the same-class timer leak in connect(): Promise.race does not cancel the loser, so the deadline timer pinned Node's event loop for the full timeout after the call returned. Both sites now go through connectWithDeadline, which clears it in a finally.
Each verified against source, not inferred: - 'receive seen-set' in the durable pv2g2 KV: there is none. STORE_KEYS lists the complete inventory and has no such entry; the (tokenId, stateHash) dedup runs against heldStates, an in-memory Map built per composition and seeded from the inventory view, backed by the server-side history dedupKey. The KV list was also missing checkpoints, shortfalls, the epoch latch and the suspectedSpent/knownSpends overlays — it now points at STORE_KEYS as the source of truth. - 'short symbols resolved via registry' on the money path: they are not. getCoinIdBySymbol/normalizeCoinId have zero call sites in modules/payments-v2/ or core/Sphere.ts. mint() rejects non-hex outright, send() byte-compares, and requests.create passes coinId straight through — so a reader following the quickstart's coinId: 'UCT' gets a rejection from one and a silent no-match from the others. Three sites carried it. - 'the facade consumes it for short-symbol -> coinId resolution': presentation only. - 'configured both by provider factories and by Sphere itself': #767 removed the factory calls; a Sphere now owns its registry and disposes it. The symbol one is not cosmetic — it is why a registry finding was initially mis-scoped as presentation-only during the #767 review.
…by REJECTING (#770.4) The SDK 3.0.1 WorkerPool.dispose() only terminate()s its workers. A dispatched task resolves solely from worker.onmessage, which a terminated worker never posts, and the queue is never drained — so WorkerTokenVerifier.verify, which awaits Promise.all(pool.run(...)), hangs forever. pool is private readonly upstream, so the settle lives in our subclass. Reachable: setOracleApiKey -> PaymentsFacade.setEngine disposes the replaced engine while a receive drain may be mid-verify. It REJECTS rather than resolving { ok: false }, and that is the load-bearing part. The only engine.verify caller in the money path is Receive.screen(), where a falsy verdict is a PERMANENT rejectAck(entry, 'invalid') — so resolving would throw away a VALID incoming token because an api-key change landed mid-drain. A rejection takes the drain's catch instead: the entry stays unacked and re-lists. (Confirmed end to end: SphereError carries no .retryable, so isRetryableAckError leaves it on the normal path.) createWorker() also refuses once disposed — the SDK leaves its workers and idle arrays populated, so a task slipping in afterwards could resurrect the pool, or be handed an already-terminated worker that will never answer. Two stale comments claiming in-flight ops finish on the old engine corrected (the core/Sphere.ts one is in the next commit — that file was held by another change).
Sphere.static instance and the clear()/import() cross-wallet kill were open when #767 shipped; this release fixes both, two sections above.
…e trust base The existing case built an engine with trustBaseJson.networkId = 4 and then asserted only that chainPubkey was a Uint8Array — i.e. that construction succeeded. An engine that ignored the trust base entirely and defaulted to a fixed id would have passed it just as well, so nothing pinned the claim CLAUDE.md makes about the trust base being the single source of the network id. That claim is what keeps mainnet money from verifying against another chain. decodeToken compares the token's genesis network against the engine's and does NOT verify proofs, so it reads the derived id offline against a real minted token. The test decodes the same token on a trust base declaring the minting network (resolves) and on one declaring mainnet (rejects, naming both ids). Falsified: hardcoding the engine's id to mainnet fails exactly this test.
…ards Nine new probes across the two #770 fixes and the trust-base networkId derivation. The money one is verifier-cancel-resolves-not-rejects: a cancelled verification that RESOLVES a falsy verdict makes Receive.screen() permanently rejectAck a valid incoming token, so the mutant must not survive. Not yet run as a full suite — the tree is still carrying in-flight work; the whole set runs before the push.
Receive.start() spawned both its wake callback and its 30s poll with a bare `void this.drainOnce()`, registering neither with the facade's quiescence gate. So PaymentsFacade.stop() could return while a drain was still doing wallet-api I/O, engine.verify, scoped-KV writes and transfer:incoming emission — and destroy() carried on tearing down underneath it. The wake path was accidentally covered: the facade subscribes 'mailbox' at the top of start() and tracks the SingleFlight promise the port's onWake bridge later joins. That held only because the facade subscribes before receiveLoop.start() runs. The poll had nothing watching it at all. Both now go through one spawn helper that registers the op via the existing FacadeHooks.track. No ordering change in stop(): track() inserts synchronously and stop() is synchronous through receiveLoop.stop(), so a drain in flight at stop time is already registered and the interval can produce no new one. Ordered deliberately after a328052 — tracking a drain wedged in a worker-pool verify would otherwise have turned a silent leak into a permanent hang of stop().
There was a problem hiding this comment.
🟡 Changes recommended
Store-level initialization and multi-Sphere concurrency can still overwrite wallet or payment-recovery state.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (3)
Previously missed (1) — in code that hasn't changed since the last review.
core/global-cell.ts:69
cellBag()accepts any object stored under the public global key, but this property read is not protected. If another page script placed aProxythere whosegettrap throws, importing any module that callssharedCell()throws, contrary to this helper's stated rule that hostile values are refused rather than crashing. Guard the cell lookup and fall back throughput()just as writes and validator calls already do.
core/Sphere.ts:1419
- The generation check still has a publication window while
clear()is active. An init that starts after the entry bump records that new generation; if it finishes beforeclearStore()reaches itsfinally, this comparison succeeds, the Sphere is registered afterliveOn()was snapshotted, and the subsequent wipe leaves it ready over an empty store. The added test delays publication until afterclear()returns, so it does not cover this ordering. Track an in-progress clear count per store (including concurrent clears) and refuse publication whenever it is nonzero, in addition to comparing generations.
if (Sphere.clearGenerationOf(sphere._storage) !== clearGeneration) {
core/network-health.ts:336
responseTimeMsis captured before the newly awaited response body. A gateway can deliver headers immediately and take seconds to stream the JSON, yet this reports near-zero latency (including the body-timeout path), so health telemetry and thresholds are inaccurate. Compute the elapsed time afterresponse.json()settles.
const responseTimeMs = Date.now() - startTime;
const body: unknown = await response.json().catch(() => null);
- Files reviewed: 54/54 changed files
- Comments generated: 2
- Review effort level: Balanced
| const clearGeneration = Sphere.clearGenerationOf(options.storage); | ||
|
|
||
| // Check if wallet already exists | ||
| if (await Sphere.exists(options.storage)) { | ||
| throw new SphereError('Wallet already exists. Use Sphere.load() or Sphere.clear() first.', 'ALREADY_INITIALIZED'); |
| if (!live) { | ||
| live = new Set(); | ||
| Sphere._liveByStorage.set(key, live); | ||
| } | ||
| live.add(sphere); |
There was a problem hiding this comment.
🔵 Needs a closer look
The broad lifecycle, storage-concurrency, teardown, and money-path impact warrants final human validation despite extensive regression coverage.
Review details
- Files reviewed: 54/54 changed files
- Comments generated: 0 new
- Review effort level: Balanced
There was a problem hiding this comment.
🟡 Changes recommended
Initialization, clearing, shared payment-store writes, and failed transport connections still contain concurrency or resource-lifecycle failures.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (3)
core/Sphere.ts:1295
- The two generation bumps still leave a publication window. If an init starts after the entry bump, it records that generation; while
clearStore()is paused beforestorage.clear(), that init can finish andpublishLive()sees the same generation, so it registers successfully. The later wipe and exit bump do not re-check or destroy the newly registered Sphere, leaving itisReadyover an empty store. Track an active clear per backing-store key (or otherwise make publication fail for the entire clear interval), and add a case that starts and publishes the init before the parked wipe is released.
// Bumped on ENTRY and again on exit, so an init that publishes anywhere in this
// window is refused too — not only one that publishes after the wipe (#772). The
// snapshot below is taken before the wipe, so a Sphere that registers between the
// two is neither destroyed here nor able to keep its data.
core/Sphere.ts:1436
- Registering multiple live Spheres for one backing store makes the payments stores concurrently writable without shared coordination. Each composition creates a fresh
ScopedKV(core/payments-v2-wiring.ts:306-310), whileListStoreserializes read-modify-write only byScopedKVobject identity (modules/payments-v2/machine/journal.ts:60-63,129-139). Two Spheres on the same address can therefore both read the same delivery journal/backstop and overwrite one another, potentially dropping a certified-but-undelivered blob. Either reject a second live Sphere for the same store or key all money-store coordination by backing store + scoped key before treating thisSetas supported.
let live = Sphere._liveByStorage.get(key);
if (!live) {
live = new Set();
Sphere._liveByStorage.set(key, live);
}
live.add(sphere);
core/Sphere.ts:936
- The clear generation does not close the existing create/create TOCTOU. Two concurrent
Sphere.init({ autoGenerate: true })calls on one empty backing store both capture the same generation, both can observeexists() === false, and both then write different mnemonics and publish successfully; the stored mnemonic belongs to whichever write landed last while the other live Sphere serves a different identity. Reserve/serialize wallet creation per backing-store key before the existence check and key write, and add a two-concurrent-init regression.
// #772: recorded BEFORE the first storage read/write and re-checked at publication —
// a clear() cannot see this init to destroy it, so it would otherwise wipe the keys
// written below out from under a Sphere that goes on to report itself ready.
const clearGeneration = Sphere.clearGenerationOf(options.storage);
// Check if wallet already exists
if (await Sphere.exists(options.storage)) {
throw new SphereError('Wallet already exists. Use Sphere.load() or Sphere.clear() first.', 'ALREADY_INITIALIZED');
- Files reviewed: 54/54 changed files
- Comments generated: 1
- Review effort level: Balanced
| await this.connectWithDeadline( | ||
| this.nostrClient, | ||
| `Transport connection timed out after ${this.config.timeout}ms` | ||
| ); |
checkCompatibility runs only in handleHandshake, and updateSphere returns EARLY for a live host — before the lock-edge guard, which is itself behind wasLocked. So the live rebind was the one path that re-ran no compatibility check at all: a host switching network without locking kept the approved session and served the dApp a chain it never agreed to, while sphere_getIdentity still reported the old network. Worse for the dApp, it cannot detect this itself. Keys and the DIRECT:// address are network-free (deriveDirectAddress takes no network, base path m/44'/0'/0'), so identity:changed does not fire and the address looks unchanged; networkId is surfaced only in the handshake response, so a long-lived session has no channel to learn the network moved. The comparison is hoisted into the live branch, network only — identity is deliberately not compared there, because changing it is what an address switch IS. Pinned both ways: a network change revokes and pushes wallet:disconnected (and the snapshot still moves, so the next handshake reports where the wallet is), and an address switch on the same network keeps the session. Not reachable from the Sphere frontend, which reloads the page on a switch and so rebuilds the host with no session — but Connect is a public protocol and nothing in the API stopped another host from doing it.
The v1 goggregator testnet is gone — 'testnet' has pointed at the v2 network since #765, and 'testnet2' is an alias of the same configuration. Displaying 'Testnet2' asks every user to reason about a version distinction that no longer exists. DISPLAY ONLY. NETWORKS[].name has zero non-display consumers (both entries feed labels: the badge, the switcher rows, the modals). The identifier is untouched: 'testnet2' remains the exact string wallet-api matches on, the scope in pv2g2:{network}:{pubkey}: and the file name of the token registry, and SPHERE_NETWORKS[].name — the lowercase 'testnet2' that rides the Connect handshake — is a different field and did not move. Marked breaking because it is user-visible and any consumer asserting the string sees it change; nothing functional depends on it. Collapsing the two IDENTIFIERS into one is a separate, much larger question: the id is a wire string shared with wallet-api and the scope of every wallet's local KV, so renaming it would orphan stored state and needs a coordinated release.
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a4440913cd
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| this.backingStoreId = | ||
| `indexeddb:${encodeURIComponent(this.dbName)}:${encodeURIComponent(this.prefix)}`; |
There was a problem hiding this comment.
Align IndexedDB lifecycle scope with database-wide clears
When two providers use the same dbName with different prefixes, this ID places their Spheres in separate lifecycle buckets, but IndexedDBStorageProvider.clear() without an argument calls idbClear() and empties the entire kv object store. Clearing one prefixed wallet therefore deletes the other wallet's data without destroying its Sphere, leaving it isReady over an erased store. Either restrict clear() to the configured prefix or make the backing-store ID cover the whole database.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Valid, and the sharper framing is that this is #766 re-entering along a dimension the original fix did not consider. Fixed in f6e1a84.
Confirmed: clear() with no prefix takes the idbClear() path (IndexedDBStorageProvider.ts:182-213) and empties the entire kv object store — every prefix in it — while backingStoreId included the prefix. So two prefixed wallets in one database sat in separate liveness buckets while sharing one erasure fate, and clearing either wiped the other's data and left its Sphere isReady over an emptied store. Exactly the failure this PR exists to remove.
I took your second option — make the id cover the database — rather than scoping clear() to the prefix. clear() is documented and used as a whole-wallet wipe (Sphere.clear passes no prefix deliberately, so the mnemonic goes with it), and narrowing a destructive operation is the riskier half of the pair. Aligning the id is fail-safe in the right direction: the bucket becomes wider than strictly necessary, and nobody is left live over erased data. The invariant is now stated in the code — backingStoreId names the unit of erasure — which is the thing that was implicit and therefore driftable.
Two existing tests pinned the wrong invariant (differs on the database name and on the key prefix independently, cannot be forged across the dbName/prefix boundary) and are rewritten rather than deleted, with the reason. Falsified by restoring the prefixed expression; probe idb-store-id-splits-on-prefix.
| this.network = config.network; | ||
| } | ||
| this.isTxtMode = this.filePath.endsWith('.txt'); | ||
| this.backingStoreId = `file:${this.filePath}`; |
There was a problem hiding this comment.
Canonicalize aliases before deriving the file store ID
When the same wallet directory is reached through a real path and a symlink, path.resolve() leaves those aliases distinct even though both providers read and overwrite the same wallet.json. Their backingStoreId values consequently differ, so Sphere.clear() through one alias misses the live Sphere registered through the other alias, wipes its file, and leaves that Sphere reporting ready. Derive the identity from a canonical filesystem path rather than only the lexically resolved path.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Correct — fixed in f6e1a84.
path.resolve is purely lexical, so a dataDir reached through a symlink and the same directory reached directly gave different ids for one wallet.json, and Sphere.clear() through one alias missed the Sphere registered through the other.
The wrinkle worth recording is that realpathSync alone does not work here: it throws when the path does not exist, and a fresh dataDir legitimately does not yet — a provider built before connect() created it would then disagree with one built after, reintroducing the split it was meant to close. So the canonicalisation walks up to the deepest existing ancestor, passes that through realpathSync, and appends the not-yet-created tail lexically. Both providers agree whether or not the directory exists, as long as the symlinked part of the path does — which is the case that causes the aliasing in the first place.
Three tests: the symlink alias (skipped rather than failed where symlink privilege is unavailable), genuinely different directories still separating, and before/after directory creation agreeing. Falsified by restoring the lexical form; probe file-store-id-not-canonical, and the existing file-backing-store-id-is-a-class-constant probe was re-pointed rather than dropped when its find string moved.
…772) Two ways the id disagreed with what clear() actually destroys — both the #766 bug re-entering along a dimension the original fix did not consider. P1, IndexedDB. The id was dbName + PREFIX, but clear() with no prefix calls idbClear(), which empties the whole kv object store — every prefix in it. Two prefixed wallets in one database were therefore in separate liveness buckets while sharing one erasure fate: clearing either wiped the other's data and left its Sphere isReady over an emptied store. The id is now the database alone. Chose that over scoping clear() to the prefix: clear() is documented and used as a whole-wallet wipe (Sphere.clear passes no prefix, deliberately, so the mnemonic goes with it), and narrowing a destructive operation is the riskier half of the fix. Aligning the id is fail-safe — the bucket becomes wider than strictly necessary, and nobody is left live over erased data. Two tests pinned the old, wrong invariant ('differs on the key prefix', 'cannot be forged across the dbName/prefix boundary') and are rewritten to pin the corrected one. P2, files. path.resolve is purely LEXICAL, so a dataDir reached through a symlink and the same directory reached directly produced different ids for ONE wallet.json — and Sphere.clear() through one alias missed the Sphere registered through the other. Now canonicalised: the deepest EXISTING ancestor is passed through realpathSync and the not-yet-created tail appended lexically, so two providers agree whether or not the directory has been created yet. realpathSync alone would have thrown on a fresh dataDir. Both falsified by restoring the previous expression; both probed. The existing file-provider probe went stale with the change and is re-pointed rather than deleted.
|
@codex review Both findings are fixed in
Worth a specific look: whether widening the IndexedDB bucket now over-destroys anywhere — |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f6e1a847cd
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| // clear() with no prefix calls idbClear(), which empties the whole object | ||
| // store. Splitting prefixes into separate liveness buckets let a clear wipe | ||
| // another wallet's data and leave its Sphere isReady over the remains. | ||
| this.backingStoreId = `indexeddb:${encodeURIComponent(this.dbName)}`; |
There was a problem hiding this comment.
Avoid treating sibling prefixes as an existing import target
When a Sphere is live under prefix q_ and Sphere.import() is called for an unused prefix p_ in the same database, this database-wide ID makes Sphere.liveOn(pStorage) return the q_ Sphere. The needsClear check in core/Sphere.ts therefore calls the unprefixed clear(), destroying the live wallet and deleting all of its data even though Sphere.exists(pStorage) is false and the import could safely write into the empty prefix. Keep database-wide teardown for an actual erase, but do not use that widened bucket to decide whether the target prefix has a wallet that needs erasing.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
You were right to push on this — it is the exact overreach I asked to be challenged, and I had not seen it. Fixed in ddd56c25.
Confirmed at core/Sphere.ts: needsClear = Sphere.liveOn(storage).length > 0 || await Sphere.exists(storage). With the widened bucket, liveOn(pStorage) returns the q_ Sphere, so an import into an empty prefix took the unprefixed clear() path, wiped the database and destroyed a live wallet nobody asked to touch — while Sphere.exists(pStorage) was false the whole time.
The distinction your comment draws is the one I had missed: the bucket names the unit of erasure, which is deliberately wider than the unit of existence. Correct for teardown-on-erase; wrong as an input to "is there something here to erase". So the decision is now the target's own wallet, plus a _storage === identity check that keeps the case the bucket was covering — a Sphere built on this very provider object, whose data may already be gone. When a clear does happen it still tears down the whole bucket, which is right.
Testing it needed a real IndexedDB: it is the only shape where the bucket is wider than existence (two providers on one file are one bucket and one existence). The scoping suite gains a fake-indexeddb case asserting the sibling stays live and keeps its data. Falsified by restoring the bucket-based condition; probe import-clears-on-the-widened-bucket.
| } | ||
| this.isTxtMode = this.filePath.endsWith('.txt'); | ||
| // Canonical, not merely resolved: aliases of one file must share an id. | ||
| this.backingStoreId = `file:${canonicalPath(this.filePath)}`; |
There was a problem hiding this comment.
Preserve the final symlink when identifying atomic file writes
When filePath itself is a symlink to another wallet file, canonicalPath() resolves it to the target and gives both providers the same lifecycle bucket, but save() writes filePath + '.tmp' and renames that temporary file over filePath; the rename replaces the symlink rather than modifying its target. Clearing through the symlink consequently destroys the Sphere for the target path even though the target wallet remains intact and the two paths diverge. Canonicalize the containing directory while retaining the final directory entry, rather than resolving the final symlink.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Correct, and a genuinely subtle one — fixed in ddd56c25.
The asymmetry is exactly as you describe: a directory symlink is a true alias, because a rename inside it lands on the same file; a final-component symlink is not, because save() writes ${filePath}.tmp and renames it over filePath, replacing the link instead of writing through it. So the two paths agree until the first save and diverge permanently after it — and sharing a bucket would have let a clear through the link destroy the target's Sphere while the target wallet stayed intact. Worse than the aliasing I was fixing, since the data survives to make the stale Sphere look correct.
canonicalPath now applies to dataDir only and the final entry is kept verbatim. Two tests, opposite directions: a symlinked directory still shares an id, and two paths differing only in a final-component link do not. Falsified by restoring the whole-path canonicalisation; probe file-store-id-resolves-the-final-symlink.
Both rounds of yours landed on the same mechanism, which is worth saying plainly: the first found that the id disagreed with what clear() erases, and the second that I then let that widened id leak into decisions it had no business informing.
…on (Codex round 2) I asked Codex to challenge the trade in f6e1a84 — widening the IndexedDB backing-store id to the whole database — and it found both places the widening overreached. Both are consequences of that commit, not of the original bug. 1. Sphere.import() decided `needsClear` on the liveness bucket. That bucket now names the unit of ERASURE, which for IndexedDB is the whole database, so it is deliberately WIDER than the EXISTENCE scope: a sibling prefix's live Sphere lands in it. Importing into an UNUSED prefix therefore called the unprefixed clear(), wiped the database and destroyed a live wallet under another prefix — even though Sphere.exists() on the target was false and the import could simply have written into the empty prefix. The decision is now the target's own wallet, plus a `_storage ===` identity check that keeps the case the bucket was covering: a Sphere built on this very provider object whose data may already be gone. Erasure keeps the wide bucket, which is right — when a clear does happen, everything it destroys must go down with it. 2. canonicalPath() resolved the FINAL component too. A directory symlink is a true alias, but a file-name symlink is not: save() writes `${filePath}.tmp` and renames it OVER filePath, which REPLACES the link rather than writing through it. Two such paths diverge on the first save, so sharing a bucket would have let a clear through the link destroy the target's Sphere while the target file stayed intact. The directory is canonicalised and the final entry kept. The sibling-prefix case needed a real IndexedDB to express — it is the only shape where the bucket is wider than existence — so the scoping suite gains a fake-indexeddb case. Both fixes falsified by restoring the previous expression; both probed; the two file-provider probes whose find strings moved were re-pointed rather than dropped.
Everything under Unreleased ships as 0.16.0: the lifecycle globals removed (#766), the teardown fixes (#770 items 1-6), the cross-bundle identity cell, the mainnet network, the 'dev' network removed, the health probe, the Connect network-change revoke (#774), and the backingStoreId corrections from the Codex rounds. Breaking, so a minor bump on 0.x.
Closes #766. Folds in #769 item 2 and #770 item 5. Surfaced #771.
Continues #767 (per-instance TokenRegistry), which closed item 1 inside the SDK.
What was wrong
Sphereheld a process-globalstatic instance. Everycreate/load/importoverwrote it, so with two Spheres alivegetInstance()andisInitialized()described whichever was constructed last — and after a second Sphere was created and destroyed,getInstance()returned null while the first was alive and serving money.Worse,
clear()andimport()keyed off that static rather than the storage they were handed.Sphere.import({ storage: B })destroyed a live wallet on storage A: identity nulled,paymentsthrowingNOT_INITIALIZED, providers disconnected, and everysphere.on()handler dropped — with no event and no error. The app simply went deaf.What changed
Sphere.instanceis gone. Replaced by a privateMap<string, Set<Sphere>>used only byclear()/import(), keyed by backing-store identity — a new optionalbackingStoreIdon theStorageProviderport.Object identity was the first attempt and was wrong: two provider objects can address the same store (two
IndexedDBStorageProviders sharingdbName+prefix, twoFileStorageProviders resolving to one path), soclear()through one emptied the KV while a Sphere registered under its twin stayedisReadywith its mnemonic gone. Class identity is wrong the other way —StorageProvider.idis a class constant ('file-storage'). Providers omitting the member fall back to object identity, so custom implementations keep today's behaviour. Because string keys are not weak,unregisterLivedeletes an entry when itsSetempties, and that cleanup has its own test and probe.Registration happens at exactly the three publication points, preserving #767's invariant that a half-built Sphere is never reachable. Still private — it is not a liveness API.
BREAKING:
Sphere.getInstance(),Sphere.isInitialized()and the root exportgetSphereare removed (126 → 125 exports). The consumer gate found zero users across all 29 sibling repos — every fleetgetInstanceisTokenRegistry's, everygetSphereis repo-local, everyisInitializedis a consumer's own field. Replacement: the instance the entry point already returns, plussphere.isReady.They were removed rather than deprecated because the defect survives deprecation: a note does not stop a wrong answer being consumed.
clear()/import()are storage-scoped. They destroy only Spheres built on the storage they were given. Theexists(storage)disjunct is preserved — that is the storage-wipe contractsphereandBoxy-Runactually depend on, so no consumer moves.importFromLegacyFilereturned the wrong object. It reached forgetInstance()!becauseimportFromJSONdiscarded the Sphere it built. Threading it out (additive, non-breaking) fixes the cause; an interleaved init used to make that path return someone else's Sphere.tracked_addressesno longer loses addresses. Every persist wrote the instance's whole snapshot, so AswitchToAddress(1)→ disk[0,1], then BswitchToAddress(2)→ disk[0,2], with A's entry erased while A's in-memory view still showed it. Now read-merge-write, serialized per provider; union by index, greaterupdatedAtwinshidden.Deliberately not network-scoped, contrary to the issue: the payload is
{index, hidden, createdAt, updatedAt}andderiveDirectAddresstakes no network, so index n is byte-identical everywhere — this belongs on the shared side of "shared seed, isolated operational state". And it is a lost update, not a scoping problem: it reproduces with both Spheres on testnet2. Renaming would have orphaned every deployed wallet's address list for nothing.The logger stays process-global, deliberately. 370 call sites across 27 files, most in providers constructed before any Sphere exists and shared between them by design (
ConnectClienthas no owning Sphere at all). Threading a handle would break every provider constructor to gain per-instance control of a third of the messages, and the worst outcome of this particular global is noisy stdout — unlike the registry global, which produced a 10^8 balance error. Fixed the real bug instead:if (options.debug)made the flag one-way, so no second init could quieten it.createNodeProvidershad the mirror-image bug (?? falsesilently disabled a flag the consumer had set) and now matchescreateBrowserProviders.Folded in
TokenRegistry.resetInstance()now calls the instancedispose()instead of onlystopAutoRefresh(), which leftdisposedunset, the generation unchanged and the in-flight fetch running, so a load past its entry guard re-armed the interval on an instancegetInstance()could no longer return.initnow forwardsverificationtocreate/load. It was silently dropped, so opting into the worker pool at the documented entry point gave you the sequential verifier. (The issue also claimeddebugwas lost; it is not —initconfigures the logger itself.)Verification
Every gate by exit code: lint / typecheck / typecheck:tests / build / test:run = 0. 2075 tests / 121 files. 61/61 mutation probes KILLED.
Both new test suites were falsified, and two-sidedly:
sphere-instance-scoping.test.ts— restoring the old last-constructed-wins static reds the unrelated Sphere survives half; makingliveOnreturn nothing reds the own Sphere is still destroyed half; returning every Sphere regardless of storage reds all three. Construction order in those tests is load-bearing: A is built last, so it is exactly the instance the deleted static pointed at — otherwise the first test would pass against the old code.tracked-addresses-concurrent.test.ts— written before the fix and confirmed red against unfixed code (disk[0,2]where[0,1,2]was expected), then red twice more on reverting to the wholesale write.Real guards were kept with the mechanism changed rather than the assertion: the
clear()-destroys-a-live-Sphere test seeds the private map instead of the static.One implementation correction worth recording
The first
tracked_addressesparse dropped unusable rows. That broke an existing round-trip pin and would have silently deleted a real user's address if any stored row were ever odd — worse than the bug being fixed. It now repairs instead: only a row with no finiteindexis dropped, and missing timestamps read as0so a timeless observation loses every conflict rather than winning on a fabricated one.Not fixed here
getDecimals,getSymbol,normalizeCoinId,TokenRegistry.getInstance) still resolve the process-global. refactor(registry): a Sphere owns its token registry, and destroy() disposes it #767 fixedsphere.payments.assets(), but the frontend'suseTopUp/useAssetsread those exports directly, so the 10^8 decimals bug is still live at the consumer boundary. Removing them is a genuine breaking change requiring a coordinated frontend PR — unlike everything above, which broke nobody.FileStorageProviderobjects over onedataDirclobber the entire wallet file, money journals included. Larger than this issue and filed separately.Added after the first review rounds (overnight batch)
The PR grew past #766 on instruction: "make sure this pr fixes all outstanding issues with
mainnet stuff and global state." Everything below is that sweep. Each fix was falsified —
the guard reverted, the test observed failing, the guard restored — and the observed failure
is recorded in the commit message rather than asserted in the abstract.
#770 items 1–4 — teardown leaving live work behind
Landed in the mandated order (4 → 2 → 1 ‖ 3): tracking the mailbox drain before fixing the
worker-pool hang would have converted a silent leak into a permanent hang of
facade.stop().setIdentityorphaned the previousNostrClient. It movedthis.nostrClientbefore connecting, so a failed connect left the old client's sockets, ping intervals and
auto-reconnect chain running and unreachable —
disconnect()only reaches the field —and since
setIdentitynever touchesstatus, the caller's next retry orphaned another.The field now moves only after a successful connect. Not "tear down both": the mux shares
that client, so that would kill its socket over a transient relay timeout. Folds in the
same-class un-cleared deadline timer in
connect().engine.dispose()mid-verify never settled the batch. The base SDK'sWorkerPool.dispose()onlyterminate()s; a dispatched task resolves solely fromworker.onmessage, soverifyhung forever. Reachable viasetOracleApiKey→setEngine. The cancellation rejects, and that is the money-critical part rather thana style choice:
Receive.screen()turns a falsy verdict into a permanentrejectAck(entry, 'invalid'), so resolving{ ok: false }would have destroyed a validincoming token because an api-key change landed mid-drain. Only affects consumers opting
into
verification.createWorker.Receive.start()spawned its 30 s poll witha bare
void drainOnce(), sostop()could return while a drain was still doingwallet-api I/O, verification, scoped-KV writes and
transfer:incomingemission. The wakepath was accidentally covered — but only by subscriber ordering.
switchToAddressracingdestroy()re-armed the wallet. Liveness was checkedonce, then awaited ~8 times, and
_initializedis cleared last so it could not mark"teardown has begun". Two live vectors: the transport mux was rebuilt and reconnected
(
destroy()leaves it null, which is exactly the build condition), and the switch'sstop/start pair — queued behind
destroy()'s own stop — started a whole new vertical foran owner whose
destroy()had already resolved. A destroyed latch set asdestroy()'sfirst statement gates both. A refused switch also no longer persists the index it never
finished moving to, which sent the next boot to the wrong address.
Two of the added guards are defence in depth and not individually falsifiable — an
earlier guard throws first on every path reaching them. Their comments say so, and they are
deliberately not probed, rather than shipping probes that would report SURVIVED forever.
#769.1 —
checkNetworkHealthcould not tell healthy from unhealthyIt POSTed
get_round_numberwith empty params and keyed the verdict offresponse.ok. Thegateway is a routing layer and refuses any call carrying neither
stateIdnorshardIdwith HTTP 400 before it looks at the method — so the check you would reach for to gate a
mainnet cutover reported every live gateway unhealthy. It now sends
get_block_heightwitha 32-byte
stateIdand reads the JSON-RPC body: the status code cannot answer this ineither direction, since a healthy gateway answers a routing mistake with a 400 plus a body
and JSON-RPC puts application errors inside a 200.
The e2e that should have caught this asserted
typeof healthy === 'boolean', which passeswhether the network is up or down. It now asserts
healthy, with the error text inside theexpectation so a genuine outage names itself. Verified live against testnet2 and mainnet
(block heights ~40.9k / ~268k); reverting the request shape fails it against both.
#769.3 — documentation the code does not honour
Four CLAUDE.md claims corrected against source: there is no durable receive seen-set
(dedup runs against an in-memory
heldStatesmap seeded from the inventory view, backed bythe server-side history
dedupKey), and coin symbols are not registry-resolved on themoney path (
mint()rejects non-hex,send()byte-compares,requests.createpassesthrough) — three sites showed
coinId: 'UCT'. That last one is not cosmetic: it is why aregistry finding was initially mis-scoped as presentation-only during the #767 review.
tests/aggregator/gained theINVALID_TRUSTBASEvacuity guard CLAUDE.md already assertedas fact —
grep -rn INVALID_TRUSTBASE tests/previously returned zero. A token the realaggregator-go certified must be refused when the trust base carries a wrong root key, with
node ids left intact so the quorum rule finds each node and rejects it on its key rather
than through "No root node defined" (asserted absent). Two layers, because either alone is
dishonest: the engine-level call is exactly what
Receive.screen()makes but can only sayrefused; the trace-level assertion names the cause, on a reconstruction that is
self-checking (the correct trust base must come back OK through the same context). It does
not exercise quorum — the compose stack is single-root-node, so mainnet's 4-node /
threshold-3 shape remains unexercised anywhere in the repo.
A related weak test was replaced: the factory case for "the trust base is the single source
of the network id" asserted only that construction succeeded, which an engine ignoring the
trust base entirely would also satisfy. It now decodes a real minted token offline to read
the derived id.
#773 — the seven doc items inside this PR's blast radius
Only those; the other ~25 are pre-existing rot for a separate PR. The one that matters:
docs/INTEGRATION.mdpublished theStorageProviderinterface withsaveTrackedAddressesand no contract, so a custom provider written from that doc clobbers addresses — the
exact #766 data-loss bug this PR fixes. It now carries the write contract, the uint32 rule,
and a worked implementation that was extracted, type-checked under
--strictand executed.Still not fixed here
the consumer gate across all 31 sibling repos found that nine of the ten free readers have
zero consumers, and the tenth (
coinIdsMatch) has one. The blocker is the frontend's16
getInstance()sites plus 5 lines each inagentic-chatbotandsphere-infra. Thecomplete SDK edit list and frontend migration are on Remove the process-global state: one Sphere per network, disposable, and concurrent instances must not interfere #766, ready to execute as a paired PR.
blocked on a wallet-api delivery outbox, filed as unicity-sphere/wallet-api#135; item 2's
cheap classification patch is unblocked but is a money-path change that wants its own PR.
Neither belongs in a lifecycle PR.
Review round 5 (Copilot) — six findings, all valid, all fixed
Two were introduced by the fixes above, which is the useful kind of review.
this.nostrClientto after the connect (item 1) closed the orphan leak and opened this:a
disconnect()during the pending connect nulls the field and returns, and theresolving swap then installs a live client plus subscriptions owned by nobody. There is
now an ownership re-check before any field moves, and it rejects — with the identity
staged, a failed swap applies neither client nor identity, so resolving would tell the
caller it took.
Promise.racesubscribes to every input and does not detach when another wins, so racing against one
long-lived promise pinned a reaction per finished verify until
dispose()— retentiongrowing with every token received, inside the class added in item 4 to fix a teardown
bug. Now per-call, and deliberately not the reviewer's deferred-plus-race shape, which
allocates three promises per call where this allocates the one already owed to the
caller — which also dissolves the unhandled-rejection question rather than managing it.
setIdentityapplied the identity before the client — see Background work outlives its owner's teardown — five confirmed sites, one missing primitive #770 item 6 above; foundindependently from the diff.
readBlockNumberaccepted any string or number, so{blockNumber: "error"}read ashealthy: the same too-permissive reading as the bug checkNetworkHealth reports healthy gateways as unhealthy; Sphere.init drops verification/debug; three docs claims the code does not honour #769.1 fixes, one layer down.
importantly — at
_deriveAddressInternal, the one point every index reaches derivationthrough. The storage layer alone is too late:
ensureAddressTrackedmutates thein-memory registry before persisting. The reviewer also asked for a guard at
switchToAddress; declined, because derivation refuses first on every reachable path,so it would be exactly the non-falsifiable guard whose probe was deleted this round.
Sphere.clear()could wipe a store under an in-flight init, which then published aSphere reporting
isReadyover nothing. Fixed with a per-store clear generation ratherthan the suggested serialization:
import()clears internally, so a per-store mutexround-trips through itself, and a
clear()blocked behind a slow bring-up is a worseoutage than a loud refusal.
Review rounds 6-7 (Copilot + local codex)
Four more findings, three of them real, and the two most serious showed this PR's own fix
was incomplete.
tsup.shared.jsdefines 12 configs, eachsplitting: false, each emitting esm+cjs — soSphere._liveByStorageand the cleargeneration were per-bundle. A Sphere built through
sphere-sdkwas invisible to aclear()throughsphere-sdk/core, which wiped the store and left itisReadyover anemptied KV: Remove the process-global state: one Sphere per network, disposable, and concurrent instances must not interfere #766 again, across the entry-point boundary.
LocalStorageProviderhadthe same shape from a module-local tag counter, and its read-merge-write serializer with
it. All of it moved to a versioned
globalThiscell, the patterncore/logger.tsalready uses. The object-key allocator had to move too — sharing the registry while
leaving the counter split would have been worse than the bug, putting two unrelated
wallets in one bucket.
destroy()landing INSIDE module bring-up. The switch guards are checks before anawait, so a teardown starting mid-bring-up had its module-clearing loop run first and the
continuation then registered a fully-built set — its own engine, hence its own worker
pool — on a Sphere whose
destroy()had returned.checkNetworkHealthdropped its deadline before reading the body.fetchsettles onthe headers, so a gateway stalling mid-response hung the probe forever.
WorkerTokenVerifier.verifyawaits the genesis rule before the pool fan-out, so a reentrant
dispose()cannot landbefore the cancellation is registered — I wrote the proposed test and it passes with the
proposed guard removed. Kept the two-line guard as insurance against an SDK reordering,
labelled in code and test as unreachable today and deliberately unprobed.
FileStorageProvider's tracked-address chain acrossprovider objects. The premise — that
backingStoreIdmade that scenario supported — iswrong; it scopes teardown, and the concurrent-write half is open as Two FileStorageProvider objects on one dataDir clobber the entire wallet file, money journals included #771. I implemented
it, then reverted: that provider rewrites the whole file from a per-object cache, so a
sibling's unrelated write still rolls the registry back, and serializing the tracked
path alone would have made the skipped contract case pass while leaving the provider
unsafe.
The mutation run earned its keep
A full run left three survivors, and two were guards added in these rounds silently
shadowing older ones: the reentrancy re-check also rejects a post-dispose
verify(), andthe post-bring-up discard undoes whatever the bring-up built — so in both cases deleting
the older guard stopped changing any observable outcome. The guards are right and stay;
the tests were sharpened to assert what each guard uniquely does (the torn-down verifier is
never entered; the bring-up is never started). The third survivor was a badly-aimed
probe of mine that swapped a shared WeakMap while leaving the shared counter, so it could
never fail.
Verification
Every gate by exit code: lint / typecheck / typecheck:tests / build / test:run = 0.
2198 tests / 129 files. 116/116 mutation probes KILLED.
One probe was removed rather than fixed, and the reason is worth stating: the
ensureAlive()before_transport.setIdentityis shadowed by the index-persist guardadded later in the same commit, which throws first on every path reaching it. Its probe
SURVIVED a full run. The guard stays (it still covers the await between them) and is
annotated as defence in depth; the probe went, because one that nothing can kill is worse
than none. Two other guards in that method are annotated the same way and were never
probed.