You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
A Sphere is initialized for one network, is thrown away and destroyed when done, and creating a second one at the same time must not disturb the first. That is not how it behaves today.
The singleton was never a design decision anyone signed off — private static instance: Sphere | null arrives in 225eb593 "init" (2026-01-27), the repo's first commit. It predates the payments rewrite, the ports work and every design doc, and was never revisited.
What is already correct
This bounds the work, so it is worth stating first. The Sphere objects themselves are genuinely independent — verified empirically across 14 executed scenarios, not by reading source:
Separate providers, module sets, facades, and separate token engines (a._tokenEngine !== b._tokenEngine); each reports its own networkId (4 vs 1).
Instance Sphere SDK #1 keeps actively serving money after a second init: a seeded token arrived, assets() went 40 → 47, inventory:updated fired, and its wallet-api session recorded stopCalls=0 — the second init never touched it.
token-engine/ has no mutable module-level state at all.
destroy() is already correctly guarded — if (Sphere.instance === this) (core/Sphere.ts:3454) — so one instance's teardown cannot null the static out from under another.
The pv2g2:{network}:{chainPubkey}: money KV and the per-remoteUrl registry cache both partition correctly, even on a shared storage provider.
So the layer underneath is instance-local. It is the top layer that is not.
1. TokenRegistry — the money-facing one
registry/TokenRegistry.ts is a process-global singleton. Sphere.configureTokenRegistry (core/Sphere.ts:788) is called unconditionally by every entry point (:642, :829, :928, :1033), and configure() on a changed remoteUrl wipes the in-memory maps.
Observed: instance #1 on testnet2, holding a coin; init a second Sphere on mainnet with a different storage provider; read instance #1's own asset before and after:
Same instance, same coin, untouched storage. Its UI renders 40 base units as 40 instead of 0.0000004 — a silent 10^8 error on a live balance, no error and no event. Fallbacks are coinId.slice(0,6).toUpperCase() for symbol and ?? 0 for decimals.
Nothing on instance #1 restores it — not switchToAddress, not setOracleApiKey. Only an explicit re-configure, which immediately inverts the damage onto instance #2.
Concurrently it is worse. Under Promise.all([init(testnet2), init(mainnet)]) the testnet2 registry is never fetched at all: configure() is synchronous so both inits reach it before their first await, and performInitialLoad re-reads this.remoteUrllive after suspending on the cache read — by which time it is mainnet's.
Note the generation guard added in #765 does not address this. It stops a stale result being applied or mis-cached across a switch; it cannot make one singleton serve two networks. The last configure() still owns it.
The facade takes the singleton as a dependency at core/payments-v2-wiring.ts:337 (registry: TokenRegistry.getInstance()), which is the natural seam to make it per-instance.
2. Sphere.instance static
core/Sphere.ts:462, written unconditionally at :866 / :966 / :1106. After a second instance is created and then destroyed, Sphere.getInstance() returns null while instance #1 is alive and serving money. getInstance() and isInitialized() are unusable as liveness signals the moment two instances exist.
There is one non-test caller inside the SDK: core/Sphere.ts:1524, the sphere-wallet JSON branch of importFromLegacyFile, which returns Sphere.getInstance()! rather than the instance it just built — an interleaved init returns the wrong object.
3. clear() and import() are storage-blind — the sharpest edge
core/Sphere.ts:1011 computes needsClear = Sphere.instance !== null || await Sphere.exists(options.storage). The first disjunct fires on any live instance anywhere. Sphere.clear() then calls Sphere.instance.destroy() (:1159-1162) without checking that instance uses the storage being cleared.
Observed: Sphere.import({ storage: B }) while instance #1 is live on storage A destroys instance #1 — isReady false, identity undefined, payments throws NOT_INITIALIZED, storage disconnected, and eventHandlers.clear() (:3451) drops every sphere.on() subscription with no event and no error. The app simply goes deaf. Storage A's data on disk survives; the live object does not. Same for a bare Sphere.clear({ storage: B }), whose docstring (:1140-1152) promises only that it clears the given storage.
4. Logger on globalThis
core/logger.ts:41-56 keys state on globalThis.__sphere_sdk_logger__, and Sphere.init does if (options.debug) logger.configure({debug:true}) (:630) — truthy-only, so a second init with debug:false cannot turn it back off. Last-write-wins across instances.
Related, surfaced by the same investigation
Not process-global, but they break the same expectation and are cheap to fix alongside:
tracked_addresses is unscoped and written wholesale. Instance A switchToAddress(1) → disk holds [0,1]. Instance B switchToAddress(2) → disk becomes [0,2]. A's address 1 is gone from the persisted record while A's in-memory getActiveAddresses() still reports it (constants.ts:46, written at core/Sphere.ts:3025). Same shape for the other bare globals: mnemonic, base_path, derivation_mode, wallet_source, wallet_exists.
Same network + same storage = two writers on single-writer stores. Both facades share one pv2g2:testnet2:{pubkey}:cursor:inventory while holding divergent inventories. ListStore.mutate is a read-modify-write serialized by a SerialChain keyed on ScopedKV object identity (machine/journal.ts:62-63,127-138), and each Sphere builds a fresh ScopedKV (stores.ts:30-35) — so the chains are independent over identical keys. Lost updates on the delivery journal and intent backstop. They also rotate the same auth:refresh:{deviceId}:pv2 row against each other.
A second init with a different mnemonic on the same storage silently ignores it — takes the walletExists load branch (:648-671), never compares, returns created: false with the first wallet's identity.
create() is TOCTOU — the exists() guard (:815-817) is several awaits from the storeMnemonic() write (:851), so two concurrent init({autoGenerate:true}) on one empty storage both generate and the second overwrites; the first Sphere then runs on a mnemonic no longer in storage.
Rough shape
Make the registry per-instance (or keyed by network) and inject it, rather than TokenRegistry.getInstance(). Highest value — it is the only money-facing item.
Scope or remove the Sphere.instance static; fix the importFromLegacyFile caller. Decide whether getInstance()/isInitialized() survive as public API at all.
Make clear()/import() storage-aware — only destroy an instance that actually uses the storage being cleared.
Per-instance logger config, or accept it as process-global and document it.
Network-scope tracked_addresses, and decide whether same-(network, storage) double-init should be refused outright rather than silently producing two writers.
Evidence
All 14 scenarios executed under vitest against the repo's own harnesses. The engine is real (real trust bases, real DIRECT:// derivation) and FileStorageProvider is real, so items 1–4 and tracked_addresses do not depend on any fake. The facade-orchestration observations use the in-process FakeWalletApi and are exactly as strong as it is.
Out of scope for #765 (mainnet enablement), which is deliberately kept self-contained.
The requirement
A
Sphereis initialized for one network, is thrown away and destroyed when done, and creating a second one at the same time must not disturb the first. That is not how it behaves today.The singleton was never a design decision anyone signed off —
private static instance: Sphere | nullarrives in225eb593 "init"(2026-01-27), the repo's first commit. It predates the payments rewrite, the ports work and every design doc, and was never revisited.What is already correct
This bounds the work, so it is worth stating first. The
Sphereobjects themselves are genuinely independent — verified empirically across 14 executed scenarios, not by reading source:a._tokenEngine !== b._tokenEngine); each reports its ownnetworkId(4 vs 1).init: a seeded token arrived,assets()went 40 → 47,inventory:updatedfired, and its wallet-api session recordedstopCalls=0— the second init never touched it.token-engine/has no mutable module-level state at all.destroy()is already correctly guarded —if (Sphere.instance === this)(core/Sphere.ts:3454) — so one instance's teardown cannot null the static out from under another.pv2g2:{network}:{chainPubkey}:money KV and the per-remoteUrlregistry cache both partition correctly, even on a shared storage provider.So the layer underneath is instance-local. It is the top layer that is not.
1. TokenRegistry — the money-facing one
registry/TokenRegistry.tsis a process-global singleton.Sphere.configureTokenRegistry(core/Sphere.ts:788) is called unconditionally by every entry point (:642,:829,:928,:1033), andconfigure()on a changedremoteUrlwipes the in-memory maps.Observed: instance #1 on testnet2, holding a coin; init a second Sphere on mainnet with a different storage provider; read instance #1's own asset before and after:
Same instance, same coin, untouched storage. Its UI renders 40 base units as
40instead of0.0000004— a silent 10^8 error on a live balance, no error and no event. Fallbacks arecoinId.slice(0,6).toUpperCase()for symbol and?? 0for decimals.Nothing on instance #1 restores it — not
switchToAddress, notsetOracleApiKey. Only an explicit re-configure, which immediately inverts the damage onto instance #2.Concurrently it is worse. Under
Promise.all([init(testnet2), init(mainnet)])the testnet2 registry is never fetched at all:configure()is synchronous so both inits reach it before their first await, andperformInitialLoadre-readsthis.remoteUrllive after suspending on the cache read — by which time it is mainnet's.Note the generation guard added in #765 does not address this. It stops a stale result being applied or mis-cached across a switch; it cannot make one singleton serve two networks. The last
configure()still owns it.The facade takes the singleton as a dependency at
core/payments-v2-wiring.ts:337(registry: TokenRegistry.getInstance()), which is the natural seam to make it per-instance.2.
Sphere.instancestaticcore/Sphere.ts:462, written unconditionally at:866/:966/:1106. After a second instance is created and then destroyed,Sphere.getInstance()returns null while instance #1 is alive and serving money.getInstance()andisInitialized()are unusable as liveness signals the moment two instances exist.There is one non-test caller inside the SDK:
core/Sphere.ts:1524, thesphere-walletJSON branch ofimportFromLegacyFile, which returnsSphere.getInstance()!rather than the instance it just built — an interleaved init returns the wrong object.3.
clear()andimport()are storage-blind — the sharpest edgecore/Sphere.ts:1011computesneedsClear = Sphere.instance !== null || await Sphere.exists(options.storage). The first disjunct fires on any live instance anywhere.Sphere.clear()then callsSphere.instance.destroy()(:1159-1162) without checking that instance uses the storage being cleared.Observed:
Sphere.import({ storage: B })while instance #1 is live on storage A destroys instance #1 —isReadyfalse,identityundefined,paymentsthrowsNOT_INITIALIZED, storage disconnected, andeventHandlers.clear()(:3451) drops everysphere.on()subscription with no event and no error. The app simply goes deaf. Storage A's data on disk survives; the live object does not. Same for a bareSphere.clear({ storage: B }), whose docstring (:1140-1152) promises only that it clears the given storage.4. Logger on
globalThiscore/logger.ts:41-56keys state onglobalThis.__sphere_sdk_logger__, andSphere.initdoesif (options.debug) logger.configure({debug:true})(:630) — truthy-only, so a second init withdebug:falsecannot turn it back off. Last-write-wins across instances.Related, surfaced by the same investigation
Not process-global, but they break the same expectation and are cheap to fix alongside:
tracked_addressesis unscoped and written wholesale. Instance AswitchToAddress(1)→ disk holds[0,1]. Instance BswitchToAddress(2)→ disk becomes[0,2]. A's address 1 is gone from the persisted record while A's in-memorygetActiveAddresses()still reports it (constants.ts:46, written atcore/Sphere.ts:3025). Same shape for the other bare globals:mnemonic,base_path,derivation_mode,wallet_source,wallet_exists.pv2g2:testnet2:{pubkey}:cursor:inventorywhile holding divergent inventories.ListStore.mutateis a read-modify-write serialized by aSerialChainkeyed on ScopedKV object identity (machine/journal.ts:62-63,127-138), and each Sphere builds a freshScopedKV(stores.ts:30-35) — so the chains are independent over identical keys. Lost updates on the delivery journal and intent backstop. They also rotate the sameauth:refresh:{deviceId}:pv2row against each other.initwith a different mnemonic on the same storage silently ignores it — takes thewalletExistsload branch (:648-671), never compares, returnscreated: falsewith the first wallet's identity.create()is TOCTOU — theexists()guard (:815-817) is several awaits from thestoreMnemonic()write (:851), so two concurrentinit({autoGenerate:true})on one empty storage both generate and the second overwrites; the first Sphere then runs on a mnemonic no longer in storage.Rough shape
TokenRegistry.getInstance(). Highest value — it is the only money-facing item.Sphere.instancestatic; fix theimportFromLegacyFilecaller. Decide whethergetInstance()/isInitialized()survive as public API at all.clear()/import()storage-aware — only destroy an instance that actually uses the storage being cleared.tracked_addresses, and decide whether same-(network, storage) double-init should be refused outright rather than silently producing two writers.Evidence
All 14 scenarios executed under vitest against the repo's own harnesses. The engine is real (real trust bases, real
DIRECT://derivation) andFileStorageProvideris real, so items 1–4 andtracked_addressesdo not depend on any fake. The facade-orchestration observations use the in-processFakeWalletApiand are exactly as strong as it is.Out of scope for #765 (mainnet enablement), which is deliberately kept self-contained.