Skip to content

Multi-client isolation issues

Analysis of createSwappedConnectClient when two or more client instances exist in the same browser page (different sessionIds unless noted).

Stores, emitters, WebSockets, RTK caches, Exchange Pay, Cash App, and payment-methods are per client. Almost every cross-instance bug comes from four process-wide shares:

Shared surfaceWhereWhat it holds
SdkGateway singleton + one /gateway iframesrc/modules/sdk-gateway/sdk-gateway.tsMessagePort, pending requests, all wallet connections, WalletConnect pairing, Coinbase token in the iframe
Iframe ConnectionStorewidget ConnectionStore (swapped-gateway:injected-connections)Injected wallet snapshots in the iframe localStorage
IntegrationTokenStore singletonsrc/storage/integration-tokens.tsCoinbase JWT in page localStorage (swapped_integration_tokens)
Named browser popups + React sessionStorage keysOAuth / wallet popup / WalletsProviderOne window name; one active-wallet key per tab

Public docs describe multiple clients on one page (docs/guide/core/client.md). Sharing wallet connections and Coinbase login is intended. This file lists remaining gaps and accepted limits.

Accepted / intended (screenshot items 1–5):

ItemDecision
Wallets are page-globalIntended. Connect / disconnect / switchChain update every client. Do not isolate the iframe store.
WalletConnect pairing is one-at-a-timeIntended. Second pairing without force throws WALLET_PAIRING_IN_PROGRESS. cancelPairing / force abort the in-flight pairing and clear the previous QR. Only the pairing client keeps pairingUri. The host expires the QR from expiryTimestamp (WALLET_PAIRING_EXPIRED).
Coinbase JWT is page-globalIntended. Login / logout / expiry notify every live client. Withdrawals use the shared JWT and the caller’s sessionId.
Popup names collideAccepted. Do not start two OAuth or wallet popups at once.
First gateway URL winsAccepted. Do not mix staging and production gateway hosts on one page.

Lifecycle screenshot items 1–4:

ItemDecision
Last destroy() can tear down the iframe for a still-alive clientFixed. Gateway refs only remove the iframe at refs === 0. See (14).
destroyOnUnmount can destroy a client another React tree still usesFixed. One client may be in only one SwappedConnectProvider (REACT_CLIENT_ALREADY_PROVIDED). See (16).
Demo removeClient does not call client.destroy()Accepted. The card’s provider unmounts with default destroyOnUnmount, which is the correct teardown. Calling destroy() yourself is required only when you pass destroyOnUnmount={false}. See (17).
Destroying the last gateway client drops live WalletConnect sessionsFixed. Last destroy removes the iframe; the next client on the same widget origin restores WC from SignClient storage via reconnect(). See (15).
Two clients, same sessionIdFixed. Same page throws SESSION_ALREADY_IN_USE. Two tabs sharing a session is expected (both see the same payment). See (22).
Active wallet id is one page-wide localStorage keyFixed. Active wallet is sessionStorage (this tab only). Other tabs no longer overwrite the selection. Same-tab two cards still share the key (last write on refresh). See (20).

Skipped / fix later:

ItemDecision
Concurrent wallet sends/signs can race approvals and transactionStatusSkipped. Shared wallet host is intended; overlapping sends/signs can still interleave MetaMask/WC prompts and nonce. Serialize later if product wants one in-flight privileged call per page. See (7).
Two Coinbase withdrawals can run at once (cooldown is per module)Accepted. Not important for different sessions. Each withdraw sends that client’s sessionId + its own idem; a second call that lacks funds just fails. No shared cooldown. See (12).
Foreign wallet:session-rejected still triggers a wasted restartSessionNot an issue. Sockets are room-scoped; session A never receives session B’s reject. See (23).

Severity:

  • P0 — wrong payment, stolen popup, or another client’s session/UI is mutated
  • P1 — connection / token / iframe torn down for a still-alive client
  • P2 — stale UI or config from the first client wins
  • P3 — leak, noise, or latent footgun

Shared wallet host (intended)

The iframe keeps one ConnectionStore. Every Wallets instance subscribes to the same gateway events (connectionsChanged, uri, disconnected, chainChanged, transactionStatus) and mirrors that store locally. Connect / disconnect events now fan out from connectionsChanged diffs so sibling client.on('wallets:connected') / wallets:disconnected listeners fire.

1. Connecting a wallet on client A appears on client B

Methods: wallets.connect, wallets.reconnect, wallets.getConnections, wallets.subscribe, React WalletsProvider / useActiveWallet

What happens: connect writes into the iframe store and emits connectionsChanged. Client B’s Wallets replaces its local map. B can then sendTransaction / transfer.execute with A’s wallet against B’s sessionId.

Steps:

  1. Create clients A and B with different session ids. loadSession() on both. Enable wallets.
  2. On A, await clientA.wallets.connect({ provider: 'metamask', transport: 'injected' }).
  3. On B, call clientB.wallets.getConnections().
  4. Observe B lists A’s MetaMask connection. B’s WalletsProvider UI shows it as connected.

2. disconnect / disconnectProvider / disconnectAll on A drop B’s wallets

Methods: wallets.disconnect, wallets.disconnectProvider, wallets.disconnectAll

What happens: Those methods hit the shared host. The iframe emits disconnected / connectionsChanged to every subscriber.

Steps:

  1. Same setup as (1). Connect MetaMask (shared).
  2. On A, await clientA.wallets.disconnectAll() (or disconnect(walletId)).
  3. B’s getConnections() is empty. B emits wallets:disconnected even though B never asked.

Methods: wallets.connect (WalletConnect), wallets.cancelPairing, wallets.subscribe (pairingUri), event wallets:pairingUri

What happens: The host allows one pairing (pairing_in_progress). uri events go to every Wallets instance. cancelPairing is a single host method with no session key.

Steps:

  1. Clients A and B loaded. On A, start WalletConnect (show QR). Do not approve yet.
  2. On B, read clientB.wallets.getConnectionState().pairingUri or listen for wallets:pairingUri.
  3. Observe B shows A’s URI. On B, await clientB.wallets.connect({ provider, transport: 'walletconnect' })WALLET_PAIRING_IN_PROGRESS.
  4. On B, await clientB.wallets.cancelPairing(). A’s QR is cleared (uri: null).

4. Concurrent WalletConnect connect({ force: true }) aborts the other pairing

Methods: wallets.connect({ force: true })

What happens: Widget WalletConnectTransport.connect aborts the in-flight pairing when force is set.

Steps:

  1. A starts WalletConnect pairing (QR visible).
  2. B calls connect({ provider, transport: 'walletconnect', force: true }).
  3. A’s pairing is aborted; B’s pairing replaces it.

5. switchChain on A changes the chain B uses for the same wallet

Methods: wallets.switchChain

What happens: Host updates the one connection; chainChanged + connectionsChanged fan out.

Steps:

  1. Shared MetaMask connected on A and visible on B.
  2. A: await clientA.wallets.switchChain(walletId, 'eip155:137').
  3. B: getConnection(walletId).namespaces now shows Polygon. A later send from B may target the wrong chain if B’s UI is stale.

6. Wallet popup window name is a singleton (swapped-wallet-popup)

Methods: wallets.connect / sendTransaction / switchChain / reconnect({ force: true }) when routed through WalletPopupClient

What happens: window.open(..., 'swapped-wallet-popup', ...). A second popup navigates the first window. The first handshake times out or closes.

Steps:

  1. Two clients, two Phantom (or other requiresPopup) connects in overlapping user gestures — e.g. two cards, click Connect on both quickly.
  2. Only one popup stays open. The first client rejects with WALLET_POPUP_CLOSED / timeout.

7. Concurrent privileged wallet calls share one host (nonce / approval races)

Status: Skipped — fix later. Shared wallet is intended; do not serialize sends/signs yet.

Methods: wallets.sendTransaction, wallets.sendPreparedTransaction, wallets.transfer.execute*, wallets.signMessage

What happens: Requests are UUID-keyed so they do not collide in pending, but the wallet is one injected provider / one WC session. Two in-flight sends from A and B can interleave approvals, replace a pending tx, or attach transactionStatus to the wrong UI if the host only tracks one wait.

Steps:

  1. Shared wallet connected.
  2. A starts a deposit (transfer / sendTransaction) and leave the wallet prompt open.
  3. B starts another send on the same wallet/account.
  4. Observe one prompt replacing the other, or both UIs reacting to the same transactionStatus.

Shared Coinbase token (intended)

IntegrationTokenStore is a process-wide singleton writing swapped_integration_tokens. SdkGateway.getToken('coinbase') reads the same iframe cookie/token. There is no sessionId on the token. Token-store subscribers fan out coinbase:connected / disconnected / sessionExpired / sessionEnsured to every live Coinbase instance.

8. coinbase.connect on A logs B in

Methods: coinbase.connect, coinbase.isConnected, coinbase.ready, constructor initialize()

What happens (intended): A successful OAuth writes the JWT to the shared store (and iframe). Every live client emits coinbase:connected. A later client’s initialize() / syncTokenFromIframe() sees the token and emits coinbase:connected on that client. Sibling useCoinbaseConnection hooks update immediately.

Steps:

  1. Create A, loadSession, await clientA.coinbase.connect() (complete OAuth).
  2. Create B (new session), loadSession, await clientB.coinbase.ready().
  3. clientB.coinbase.isConnected() is true without B opening a popup. B can getBalances() / startWithdrawal() to B’s session wallet using A’s Coinbase account.

9. coinbase.disconnect on A logs B out

Methods: coinbase.disconnect

What happens (intended): tokenStore.removeToken(Coinbase) is global. Every live client emits coinbase:disconnected and resets in-memory withdrawal / cooldown. B’s next ensureSession / getBalances / startWithdrawal sees no JWT. useCoinbaseConnection on B goes disconnected.

Steps:

  1. Both clients connected (shared token from (8)).
  2. Render useCoinbaseConnection under each SwappedConnectProvider.
  3. Call clientA.coinbase.disconnect().
  4. A’s hook goes disconnected. B’s hook may still show connected. clientB.coinbase.isConnected() is false. B’s getBalances() throws COINBASE_NOT_CONNECTED.

10. ensureSession 401/403 on A deletes the token for B

Methods: coinbase.ensureSession, getBalances, getNetworks, startWithdrawal, confirmWithdrawal (all go through resolveJwtForRequest / ensureSession)

What happens (intended): A 401/403 or isValid: false calls removeToken and emits coinbase:sessionExpired on every live client.

Steps:

  1. Shared token on A and B.
  2. Expire / revoke the JWT (or stub coinbaseSessionStatus to 401 on A’s next call).
  3. await clientA.coinbase.ensureSession()false.
  4. B’s stored token is gone. B’s in-flight startWithdrawal can throw COINBASE_NOT_CONNECTED / COINBASE_SESSION_EXPIRED.

11. Concurrent coinbase.connect — OAuth popup name is Login

Methods: coinbase.connectPopupWindow.open({ title: 'Login' })

What happens: window.open(url, 'Login', ...). The second call reuses/navigates the first popup. PopupWindow.open also close()s any existing popup on that instance, but two instances share the window name.

Steps:

  1. A and B both disconnected (clear swapped_integration_tokens first).
  2. Click Connect on both cards in the same turn.
  3. One popup. Completing OAuth writes one token; the other connect() may resolve false, hang on waitForGatewayToken, or steal the result.

12. Two Coinbase withdrawals can run at once (cooldown is per module)

Status: Accepted — not important for different sessions. Each withdraw is keyed by that client’s sessionId and a unique idem. Insufficient funds on the second call just fail.

Methods: coinbase.startWithdrawal, coinbase.getCooldown, coinbase.confirmWithdrawal

What happens: Cooldown / activeWithdrawal live on the Coinbase instance. Shared JWT + two sessions ⇒ two withdraw APIs with two idems. Product/risk: two merchant destinations from one Coinbase account with no shared cooldown.

Steps:

  1. Shared Coinbase connection. Both sessions active.
  2. A: startWithdrawal(...). B: startWithdrawal(...) immediately.
  3. Both succeed or both enter requires2fa. Neither sees COINBASE_COOLDOWN_ACTIVE.

P1 — Gateway singleton lifecycle and first-wins config

13. getInstance ignores the second client’s gatewayUrl / widgetOrigin

Methods: createSwappedConnectClient when modules includes wallets or coinbase

What happens: First SdkGateway wins. Client B with a different widgetBaseUrl or environment still talks to A’s iframe. Origin checks use A’s widgetOrigin. B’s Coinbase/wallets can silently hit the wrong widget.

Steps:

  1. createSwappedConnectClient({ sessionId: 'a', environment: 'staging', modules: ['wallets'] }).
  2. createSwappedConnectClient({ sessionId: 'b', environment: 'production', modules: ['wallets'] }) (or a localhost widgetBaseUrl on B).
  3. Inspect document.querySelectorAll('iframe[src*="gateway"]') — length 1, src is staging (or A’s URL).
  4. B wallet connect / Coinbase getToken runs against A’s host.

14. Last destroy() tears down the iframe while another client still uses it (refcount bugs)

Status: Fixed for the still-alive-client cases. getInstance / releaseInstance keep the iframe until refs === 0. A client may sit in only one React provider. SdkGateway.destroy() still force-clears leftover refs if something calls it directly (tests / peekInstance()?.destroy()).

Methods: destroy

What happens: Refcount is correct if every gateway client calls getInstance once and releaseInstance once. These cases used to break it:

  1. SdkGateway.destroy() (or tests calling peekInstance()?.destroy()) force-clears instance and refs even when other clients still hold the object. In-flight request / getToken / ensureReady on the survivor reject (SdkGateway destroyed); event handlers are cleared.
  2. Unbalanced refs: creating a client that calls getInstance and then throwing before the consumer can destroy leaves a leaked iframe. The opposite — extra releaseInstance — is only possible if something else calls it (not public today).
  3. destroyOnUnmount (default true) with the same client in two trees: unmounting provider 1 schedules client.destroy(). Provider 2 is still mounted; its wallet/Coinbase calls start throwing CLIENT_DESTROYED, and if this client was the last gateway ref the iframe is removed for everyone.

Steps (shared client + two providers):

  1. const client = createSwappedConnectClient({ sessionId, modules: ['wallets'] }).
  2. Render two SwappedConnectProvider trees with that same client (both destroyOnUnmount default).
  3. Unmount tree 1. After the macrotask, client.destroy has run.
  4. Tree 2: client.wallets.getAvailable() throws CLIENT_DESTROYED. If no other client held a gateway ref, the iframe is gone.

Steps (last real client):

  1. Clients A and B both with wallets.
  2. clientA.destroy() — iframe stays (refs === 1).
  3. clientB.destroy() — iframe removed. Expected. Any third object that still captured SdkGateway.peekInstance() from before is dead.

15. Destroying the last gateway client drops persisted iframe connections for the next mount

Status: Fixed (screenshot item 4). Last releaseInstance removes the iframe and the live WC relay socket. That is expected. The next client on the same widget origin restores both transports: injected from iframe localStorage, WalletConnect from SignClient storage via restoreFromHostreconnect()session.getAll(). A different widgetBaseUrl (see 13) still misses that storage.

Methods: destroy (last gateway ref)

What happens: teardownMount removes the iframe. Injected snapshots hydrate from iframe localStorage. WalletConnect sessions persist in SignClient’s iframe-origin storage (swapped-gateway). restoreFromHost calls reconnect() and rebuilds WC connections from session.getAll().

Steps:

  1. Connect WC + injected on A. Destroy A (only client).
  2. Create B with the same widgetBaseUrl. loadSession. wallets restore.
  3. Injected and WalletConnect both come back without pairing again.

P1 — Session / React teardown that affects siblings

16. SwappedConnectProvider destroyOnUnmount destroys a client another tree still uses

Status: Fixed. The same client in two providers throws REACT_CLIENT_ALREADY_PROVIDED. Destroy is deferred by a macrotask so Strict Mode remounts do not tear down a reused client. Pass destroyOnUnmount={false} when the parent owns lifecycle.

17. Demo removeClient does not call client.destroy()

Status: Accepted. Not an SDK method bug. removeClient drops the card; default destroyOnUnmount destroys that client after a macrotask. That is correct usage. Opting out of unmount destroy and never calling destroy() leaks the socket and a gateway ref — do not do that.

Steps (accepted path):

  1. Multi-client demo: add two sessions.
  2. Delete one card.
  3. The remaining card’s wallets/Coinbase still work (refs stayed ≥ 1). The deleted client is destroyed by the provider.

18. loadSession / restartSession on A do not isolate wallets; they reset A’s payment modules only

Methods: loadSession (new id), restartSession

What happens: rebuildSessionModules destroys/recreates A’s Coinbase, Cash App, Exchange Pay, and WebSocket. It does not recreate wallets (only transfer.resetCompletedSummary()). Shared iframe connections stay. A’s new Coinbase instance initialize()s and may re-pull the shared token (A looks connected again after restart — often desired). B is unchanged except via the shares in (1)–(12).

Steps:

  1. A and B both have a shared wallet + Coinbase token. A has an Exchange Pay order in memory.
  2. await clientA.restartSession().
  3. A: Exchange Pay / Cash App / Coinbase withdrawal in-memory state cleared; Coinbase connection may come back from the store. Wallets still connected.
  4. B: wallets still connected; Coinbase token still present. B’s Exchange Pay order intact.

P2 — Stale UI and first-wins config

19. Coinbase React hooks do not see the other client’s connect/disconnect

Status: Fixed. Token-store change notifications emit coinbase:connected / disconnected / sessionExpired on every live instance, so useCoinbaseConnection on B updates when A connects or disconnects.

20. Active wallet id is one sessionStorage key per tab

Status: Fixed for multi-tab. WalletsProvider uses sessionStorage (useSessionStorage). A leftover localStorage value is migrated once, then removed.

Key: swapped_wallets_active_wallet_id (WalletsProvider + useSessionStorage)

What happens: Each tab keeps its own active wallet across refresh. Tab B selecting Phantom no longer updates tab A. Same-tab two WalletsProviders still share the key: they diverge in memory; the last setActiveWallet / connect wins on refresh. resolveActiveWalletId on B can overwrite A’s id when B’s connection set changes (including when A’s connect fans out — see 1).

Steps (multi-tab):

  1. Open the demo (or any WalletsProvider app) in two tabs. Shared two wallets connected.
  2. Tab A: select MetaMask. Tab B: select Phantom.
  3. Tab A’s active wallet switches to Phantom without a click.

Steps (same tab):

  1. Two WalletsProviders (demo cards). Shared two wallets connected.
  2. On A, select wallet W1. On B, select wallet W2.
  3. Reload. Both cards restore whichever id was written last.
  4. Disconnect W2 from A. B’s effect may write null or W1 into the shared key.

22. Two clients, same sessionId

Status: Fixed. Same-page second client throws SESSION_ALREADY_IN_USE. Two tabs on the same session is expected.

Per-client stores mean two WebSockets, two awaiting-confirmation pollers, two GET /sessions/:id caches. That path is only reachable across tabs (separate JS heaps).

Methods: loadSession, restartSession, wallet/Cash App/Exchange Pay completion (syncSessionFromApi)

What happens:

  • Both sockets subscribe to the same wallet / cashapp / offchain rooms. Completions double-refresh.
  • restartSession on A gets a new id and rebuilds A only. B still holds the old id (may now be invalid).
  • Two createOrder (Cash App / Exchange Pay) against the same session race on the backend.

Steps:

  1. createSwappedConnectClient({ sessionId: 'same' }) twice. loadSession() both.
  2. Complete a wallet deposit or Cash App order.
  3. Both clients refetch; both UIs may flip to completed.
  4. restartSession() on A. B still shows the old session / completed view.

23. wallet:session-rejected is filtered by session id (OK) but restartSession still runs

Status: Not an issue. Sockets are room-scoped; session A does not receive session B events.

Internal: WebSocketService.handleWalletSessionRejected

setSessionRejected no-ops if the payload id ≠ this store’s session. The handler still await this.restartSession() (syncSessionFromApi on this client). That extra GET is unreachable while the server rooms by session.


P3 — Noise, leaks, latent

24. Each Wallets instance starts its own InjectedWalletDetector

Methods: constructor / destroy

Duplicate eip6963:requestProvider + announce listeners. Harmless extra work; destroy removes that instance’s listeners.

25. IntegrationTokenStore has no refcount or destroy

The singleton lives for the page. clear() is unused by the client. Fine for a shared login; impossible to have two Coinbase accounts on one origin.

26. Holding client.coinbase / client.exchangePay across restartSession

Getters return the new module after rebuild. A captured const { coinbase } = client before restart points at a destroyed instance (CLIENT_DESTROYED / SESSION_REPLACED). Same-client bug; worse if one React tree cached the module from a client that later restarted.


Method-by-method impact

Legend: iso = other instances unchanged. share = uses a shared surface. mutates = can change another instance’s observable state.

Client

MethodIsolationNotes
createSwappedConnectClientshareIncrements gateway refs; first gatewayUrl/widgetOrigin wins
loadSessioniso*Own store/cache/generation. * rebuilds A’s modules; shared wallets/token unchanged. Stops A’s poller only
restartSessioniso*Own store. Rebuilds payment modules + WS. Resets A’s wallet completed summary only. Shared wallets/token stay
getSession / getSessionView / getSessionId / getState / getMaintenanceStatusisoOwn store
subscribe / on / offisoOwn emitter
isModuleEnabled / modulesiso
destroymutatesReleases gateway ref; last ref destroys iframe (wallets + iframe Coinbase token channel) for everyone. Does not clear swapped_integration_tokens

Payment methods

MethodIsolationNotes
get / getOne / refetchisoOwn RTK + session

Exchange Pay

MethodIsolationNotes
getSupportedCurrencies / createOrder / getOrder / closeOrderisoSession-scoped HTTP + this client’s WS offchain room
getActiveOrder* / reset / destroyisoIn-memory + this emitter
onOrder*iso

Same sessionId on two clients: blocked on one page (SESSION_ALREADY_IN_USE). Across tabs, expected (22).

Cash App

MethodIsolationNotes
getSupportedAssets / createOrderiso
reset / destroy / on*isoDeposit events come from this client’s WS; handler also checks payload.sessionId

Coinbase

MethodIsolationNotes
isConnectedshareShared store
ready / isInitializingshareinitialize pulls iframe token into the shared store
isPopupOpeniso*Per ExchangeOAuth, but window name Login collides (11)
connectmutatesShared token + iframe; other clients become connected and emit coinbase:connected
disconnectmutatesRemoves token for all; every client emits coinbase:disconnected
ensureSessionmutatesMay removeToken / setToken for all; expiry / refresh events fan out
getBalances / getNetworks / getMinWithdrawalAmount / validateWithdrawalAmount / getTokenDecimals*shareShared JWT; session-scoped HTTP
getFundingTokens / getDefaultFundingTokens / getSelectionAggregatedBalance / getMaxWithdrawableAmountisoPure / this session
startWithdrawal / confirmWithdrawalshareShared JWT; cooldown/2FA state is per instance (12)
cancelWithdrawal / getActiveWithdrawal / getCompletedTransactionSummary / getCooldown / resetisoPer instance
on*share*Connect / disconnect / expiry / ensured fan out from the token store. Withdrawal / cooldown / popup stay per instance
destroyiso*Closes this popup; does not removeToken

Wallets

MethodIsolationNotes
getAvailable / watchAvailability / requiresDeepLink / supportsWalletConnectiso*Page detector is per instance; iframe injectedAvailability is shared
getDeepLinkUrl / openDeepLinkisoUses this sessionId
connectmutatesShared host + events (1, 3, 4, 6)
cancelPairingmutates(3)
disconnect / disconnectProvider / disconnectAllmutates(2)
getConnections / getConnection / isConnected / getConnectionsByProvider / getAddresses / getConnectionState / subscribeshareMirror of iframe store
getBalances / getWalletBalance / getCachedBalancesshareConnection list shared; HTTP uses this sessionId
signMessage / sendTransaction / sendPreparedTransactionshareShared wallet; sessionId from this client (1, 7)
switchChainmutates(5)
reconnectmutatesReloads shared store into this instance; force + popup can rewrite host connections
transfer.*sharePlans/quotes use this session; send uses shared wallet
destroyiso*Unsubscribes this instance; does not disconnectAll. Last client destroy still kills the iframe (14)

React

APIIsolationNotes
SwappedConnectProvideriso*Context is per tree. destroyOnUnmount default can kill a shared client (14, 16)
WalletsProvider / useActiveWalletmutatesShared host + per-tab swapped_wallets_active_wallet_id (1, 20)
CoinbaseProvider / useCoinbaseConnectionshareShared token; connect / disconnect / expiry events fan out (8, 9, 19)
CashAppProvider / ExchangePayContextProviderisoRead the nearest SwappedConnectProvider
useSessionStorageiso*Per tab. Same-tab two trees still share the active-wallet key (20)
useLocalStorageshareOther tabs sync via the storage event if they use the same key

What is already isolated (no issue found)

  • Redux store, RTK Query cache, sessionRequestGeneration, awaiting-confirmation poller
  • EventEmitter (per client)
  • WebSocketService / WebSocketClient (per client after loadSession; rooms keyed by that session id)
  • Exchange Pay / Cash App in-memory orders and expiry timers (superseded on rebuild)
  • Payment-methods module
  • Coinbase in-memory withdrawal / cooldown / completed summary
  • Wallet balance HTTP cache and QuoteCache (per Wallets instance)
  • Formatters (format.isolation.test.ts is BigNumber, not multi-client)

setSessionRejected compares payload.sessionId to this store’s session — a foreign reject does not flip B’s status.


Suggested fix order (for later)

  1. Scope iframe wallet stateaccepted: one wallet host per page; connections are shared.
  2. Scope Coinbase tokens / decide shared vs per-session OAuthaccepted: shared login; events fan out.
  3. Unique popup names (Login, swapped-wallet-popup) — accepted for now (do not start two popups at once).
  4. SdkGateway.getInstance: reject or remount when gatewayUrl / widgetOrigin differ — accepted for now (do not mix environments). Still: never destroy() while refs > 0.
  5. Session-scoped (or tab-scoped) active-wallet storage keyfixed: sessionStorage per tab (20). Same-tab two cards still share the key.
  6. Shared Coinbase cooldownaccepted: not needed for different sessions (12).
  7. Serialize overlapping sendTransaction / signMessage on the shared wallet host (7).