Appearance
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 surface | Where | What it holds |
|---|---|---|
SdkGateway singleton + one /gateway iframe | src/modules/sdk-gateway/sdk-gateway.ts | MessagePort, pending requests, all wallet connections, WalletConnect pairing, Coinbase token in the iframe |
Iframe ConnectionStore | widget ConnectionStore (swapped-gateway:injected-connections) | Injected wallet snapshots in the iframe localStorage |
IntegrationTokenStore singleton | src/storage/integration-tokens.ts | Coinbase JWT in page localStorage (swapped_integration_tokens) |
Named browser popups + React sessionStorage keys | OAuth / wallet popup / WalletsProvider | One 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):
| Item | Decision |
|---|---|
| Wallets are page-global | Intended. Connect / disconnect / switchChain update every client. Do not isolate the iframe store. |
| WalletConnect pairing is one-at-a-time | Intended. 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-global | Intended. Login / logout / expiry notify every live client. Withdrawals use the shared JWT and the caller’s sessionId. |
| Popup names collide | Accepted. Do not start two OAuth or wallet popups at once. |
| First gateway URL wins | Accepted. Do not mix staging and production gateway hosts on one page. |
Lifecycle screenshot items 1–4:
| Item | Decision |
|---|---|
Last destroy() can tear down the iframe for a still-alive client | Fixed. Gateway refs only remove the iframe at refs === 0. See (14). |
destroyOnUnmount can destroy a client another React tree still uses | Fixed. 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 sessions | Fixed. 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 sessionId | Fixed. 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 key | Fixed. 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:
| Item | Decision |
|---|---|
Concurrent wallet sends/signs can race approvals and transactionStatus | Skipped. 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 restartSession | Not 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:
- Create clients A and B with different session ids.
loadSession()on both. Enablewallets. - On A,
await clientA.wallets.connect({ provider: 'metamask', transport: 'injected' }). - On B, call
clientB.wallets.getConnections(). - Observe B lists A’s MetaMask connection. B’s
WalletsProviderUI 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:
- Same setup as (1). Connect MetaMask (shared).
- On A,
await clientA.wallets.disconnectAll()(ordisconnect(walletId)). - B’s
getConnections()is empty. B emitswallets:disconnectedeven though B never asked.
3. WalletConnect pairing is global — QR / deeplink leaks; cancelPairing cancels the other client
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:
- Clients A and B loaded. On A, start WalletConnect (show QR). Do not approve yet.
- On B, read
clientB.wallets.getConnectionState().pairingUrior listen forwallets:pairingUri. - Observe B shows A’s URI. On B,
await clientB.wallets.connect({ provider, transport: 'walletconnect' })→WALLET_PAIRING_IN_PROGRESS. - 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:
- A starts WalletConnect pairing (QR visible).
- B calls
connect({ provider, transport: 'walletconnect', force: true }). - 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:
- Shared MetaMask connected on A and visible on B.
- A:
await clientA.wallets.switchChain(walletId, 'eip155:137'). - B:
getConnection(walletId).namespacesnow 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:
- Two clients, two Phantom (or other
requiresPopup) connects in overlapping user gestures — e.g. two cards, click Connect on both quickly. - 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:
- Shared wallet connected.
- A starts a deposit (
transfer/sendTransaction) and leave the wallet prompt open. - B starts another send on the same wallet/account.
- 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:
- Create A,
loadSession,await clientA.coinbase.connect()(complete OAuth). - Create B (new session),
loadSession,await clientB.coinbase.ready(). clientB.coinbase.isConnected()istruewithout B opening a popup. B cangetBalances()/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:
- Both clients connected (shared token from (8)).
- Render
useCoinbaseConnectionunder eachSwappedConnectProvider. - Call
clientA.coinbase.disconnect(). - A’s hook goes disconnected. B’s hook may still show connected.
clientB.coinbase.isConnected()isfalse. B’sgetBalances()throwsCOINBASE_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:
- Shared token on A and B.
- Expire / revoke the JWT (or stub
coinbaseSessionStatusto 401 on A’s next call). await clientA.coinbase.ensureSession()→false.- B’s stored token is gone. B’s in-flight
startWithdrawalcan throwCOINBASE_NOT_CONNECTED/COINBASE_SESSION_EXPIRED.
11. Concurrent coinbase.connect — OAuth popup name is Login
Methods: coinbase.connect → PopupWindow.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:
- A and B both disconnected (clear
swapped_integration_tokensfirst). - Click Connect on both cards in the same turn.
- One popup. Completing OAuth writes one token; the other
connect()may resolvefalse, hang onwaitForGatewayToken, 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:
- Shared Coinbase connection. Both sessions
active. - A:
startWithdrawal(...). B:startWithdrawal(...)immediately. - Both succeed or both enter
requires2fa. Neither seesCOINBASE_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:
createSwappedConnectClient({ sessionId: 'a', environment: 'staging', modules: ['wallets'] }).createSwappedConnectClient({ sessionId: 'b', environment: 'production', modules: ['wallets'] })(or a localhostwidgetBaseUrlon B).- Inspect
document.querySelectorAll('iframe[src*="gateway"]')— length1,srcis staging (or A’s URL). - B wallet connect / Coinbase
getTokenruns 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:
SdkGateway.destroy()(or tests callingpeekInstance()?.destroy()) force-clearsinstanceandrefseven when other clients still hold the object. In-flightrequest/getToken/ensureReadyon the survivor reject (SdkGateway destroyed); event handlers are cleared.- Unbalanced refs: creating a client that calls
getInstanceand then throwing before the consumer candestroyleaves a leaked iframe. The opposite — extrareleaseInstance— is only possible if something else calls it (not public today). destroyOnUnmount(defaulttrue) with the same client in two trees: unmounting provider 1 schedulesclient.destroy(). Provider 2 is still mounted; its wallet/Coinbase calls start throwingCLIENT_DESTROYED, and if this client was the last gateway ref the iframe is removed for everyone.
Steps (shared client + two providers):
const client = createSwappedConnectClient({ sessionId, modules: ['wallets'] }).- Render two
SwappedConnectProvidertrees with that sameclient(bothdestroyOnUnmountdefault). - Unmount tree 1. After the macrotask,
client.destroyhas run. - Tree 2:
client.wallets.getAvailable()throwsCLIENT_DESTROYED. If no other client held a gateway ref, the iframe is gone.
Steps (last real client):
- Clients A and B both with
wallets. clientA.destroy()— iframe stays (refs === 1).clientB.destroy()— iframe removed. Expected. Any third object that still capturedSdkGateway.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 restoreFromHost → reconnect() → 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:
- Connect WC + injected on A. Destroy A (only client).
- Create B with the same
widgetBaseUrl.loadSession.walletsrestore. - 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):
- Multi-client demo: add two sessions.
- Delete one card.
- The remaining card’s wallets/Coinbase still work (
refsstayed ≥ 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:
- A and B both have a shared wallet + Coinbase token. A has an Exchange Pay order in memory.
await clientA.restartSession().- A: Exchange Pay / Cash App / Coinbase withdrawal in-memory state cleared; Coinbase connection may come back from the store. Wallets still connected.
- 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):
- Open the demo (or any
WalletsProviderapp) in two tabs. Shared two wallets connected. - Tab A: select MetaMask. Tab B: select Phantom.
- Tab A’s active wallet switches to Phantom without a click.
Steps (same tab):
- Two
WalletsProviders (demo cards). Shared two wallets connected. - On A, select wallet W1. On B, select wallet W2.
- Reload. Both cards restore whichever id was written last.
- Disconnect W2 from A. B’s effect may write
nullor 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.
restartSessionon 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:
createSwappedConnectClient({ sessionId: 'same' })twice.loadSession()both.- Complete a wallet deposit or Cash App order.
- Both clients refetch; both UIs may flip to completed.
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
| Method | Isolation | Notes |
|---|---|---|
createSwappedConnectClient | share | Increments gateway refs; first gatewayUrl/widgetOrigin wins |
loadSession | iso* | Own store/cache/generation. * rebuilds A’s modules; shared wallets/token unchanged. Stops A’s poller only |
restartSession | iso* | Own store. Rebuilds payment modules + WS. Resets A’s wallet completed summary only. Shared wallets/token stay |
getSession / getSessionView / getSessionId / getState / getMaintenanceStatus | iso | Own store |
subscribe / on / off | iso | Own emitter |
isModuleEnabled / modules | iso | |
destroy | mutates | Releases gateway ref; last ref destroys iframe (wallets + iframe Coinbase token channel) for everyone. Does not clear swapped_integration_tokens |
Payment methods
| Method | Isolation | Notes |
|---|---|---|
get / getOne / refetch | iso | Own RTK + session |
Exchange Pay
| Method | Isolation | Notes |
|---|---|---|
getSupportedCurrencies / createOrder / getOrder / closeOrder | iso | Session-scoped HTTP + this client’s WS offchain room |
getActiveOrder* / reset / destroy | iso | In-memory + this emitter |
onOrder* | iso |
Same sessionId on two clients: blocked on one page (SESSION_ALREADY_IN_USE). Across tabs, expected (22).
Cash App
| Method | Isolation | Notes |
|---|---|---|
getSupportedAssets / createOrder | iso | |
reset / destroy / on* | iso | Deposit events come from this client’s WS; handler also checks payload.sessionId |
Coinbase
| Method | Isolation | Notes |
|---|---|---|
isConnected | share | Shared store |
ready / isInitializing | share | initialize pulls iframe token into the shared store |
isPopupOpen | iso* | Per ExchangeOAuth, but window name Login collides (11) |
connect | mutates | Shared token + iframe; other clients become connected and emit coinbase:connected |
disconnect | mutates | Removes token for all; every client emits coinbase:disconnected |
ensureSession | mutates | May removeToken / setToken for all; expiry / refresh events fan out |
getBalances / getNetworks / getMinWithdrawalAmount / validateWithdrawalAmount / getTokenDecimals* | share | Shared JWT; session-scoped HTTP |
getFundingTokens / getDefaultFundingTokens / getSelectionAggregatedBalance / getMaxWithdrawableAmount | iso | Pure / this session |
startWithdrawal / confirmWithdrawal | share | Shared JWT; cooldown/2FA state is per instance (12) |
cancelWithdrawal / getActiveWithdrawal / getCompletedTransactionSummary / getCooldown / reset | iso | Per instance |
on* | share* | Connect / disconnect / expiry / ensured fan out from the token store. Withdrawal / cooldown / popup stay per instance |
destroy | iso* | Closes this popup; does not removeToken |
Wallets
| Method | Isolation | Notes |
|---|---|---|
getAvailable / watchAvailability / requiresDeepLink / supportsWalletConnect | iso* | Page detector is per instance; iframe injectedAvailability is shared |
getDeepLinkUrl / openDeepLink | iso | Uses this sessionId |
connect | mutates | Shared host + events (1, 3, 4, 6) |
cancelPairing | mutates | (3) |
disconnect / disconnectProvider / disconnectAll | mutates | (2) |
getConnections / getConnection / isConnected / getConnectionsByProvider / getAddresses / getConnectionState / subscribe | share | Mirror of iframe store |
getBalances / getWalletBalance / getCachedBalances | share | Connection list shared; HTTP uses this sessionId |
signMessage / sendTransaction / sendPreparedTransaction | share | Shared wallet; sessionId from this client (1, 7) |
switchChain | mutates | (5) |
reconnect | mutates | Reloads shared store into this instance; force + popup can rewrite host connections |
transfer.* | share | Plans/quotes use this session; send uses shared wallet |
destroy | iso* | Unsubscribes this instance; does not disconnectAll. Last client destroy still kills the iframe (14) |
React
| API | Isolation | Notes |
|---|---|---|
SwappedConnectProvider | iso* | Context is per tree. destroyOnUnmount default can kill a shared client (14, 16) |
WalletsProvider / useActiveWallet | mutates | Shared host + per-tab swapped_wallets_active_wallet_id (1, 20) |
CoinbaseProvider / useCoinbaseConnection | share | Shared token; connect / disconnect / expiry events fan out (8, 9, 19) |
CashAppProvider / ExchangePayContextProvider | iso | Read the nearest SwappedConnectProvider |
useSessionStorage | iso* | Per tab. Same-tab two trees still share the active-wallet key (20) |
useLocalStorage | share | Other 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 afterloadSession; rooms keyed by that session id)- Exchange Pay / Cash App in-memory orders and expiry timers (
supersededon rebuild) - Payment-methods module
- Coinbase in-memory withdrawal / cooldown / completed summary
- Wallet balance HTTP cache and
QuoteCache(perWalletsinstance) - Formatters (
format.isolation.test.tsis 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)
Scope iframe wallet state— accepted: one wallet host per page; connections are shared.Scope Coinbase tokens / decide shared vs per-session OAuth— accepted: shared login; events fan out.- Unique popup names (
Login,swapped-wallet-popup) — accepted for now (do not start two popups at once). SdkGateway.getInstance: reject or remount whengatewayUrl/widgetOrigindiffer — accepted for now (do not mix environments). Still: neverdestroy()whilerefs > 0.Session-scoped (or tab-scoped) active-wallet storage key— fixed:sessionStorageper tab (20). Same-tab two cards still share the key.Shared Coinbase cooldown— accepted: not needed for different sessions (12).- Serialize overlapping
sendTransaction/signMessageon the shared wallet host (7).