Skip to content

Remove the process-global state: one Sphere per network, disposable, and concurrent instances must not interfere #766

Description

@MastaP

The requirement

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:

BEFORE: { symbol: 'TCOIN', name: 'TestnetCoin', decimals: 8, totalAmount: '40' }
AFTER:  { symbol: 'AAAAAA', name: 'aaaa…aa',    decimals: 0, totalAmount: '40' }

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.remoteUrl live 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 #1isReady 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

  1. 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.
  2. Scope or remove the Sphere.instance static; fix the importFromLegacyFile caller. Decide whether getInstance()/isInitialized() survive as public API at all.
  3. Make clear()/import() storage-aware — only destroy an instance that actually uses the storage being cleared.
  4. Per-instance logger config, or accept it as process-global and document it.
  5. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions