From 72f9c9c22c0f58526a6295bb85a0b329d37ac876 Mon Sep 17 00:00:00 2001 From: 0xEdouardEth <15703023+0xEdouardEth@users.noreply.github.com> Date: Sun, 3 May 2026 14:30:00 +0200 Subject: [PATCH 1/2] fix: update store when user switches wallet accounts ## Summary - When a user switches accounts in their wallet (e.g. Phantom), `onAccountsChanged` was already handling the empty-accounts case (disconnect) but silently ignored the non-empty case; leaving the store with a stale session reference so React components (address display, balance, etc.) would not re-render with the new account. - Added the missing `else` branch in `connectWallet` to call `updateState` with the new `accounts[0]` when the wallet reports a different account, keeping the store in sync. - Added two unit tests covering both cases: account switch updates the store, and empty accounts array triggers a full disconnect. --- packages/client/src/client/actions.test.ts | 55 +++++++++++++++++++++- packages/client/src/client/actions.ts | 14 ++++++ 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/packages/client/src/client/actions.test.ts b/packages/client/src/client/actions.test.ts index ca00da0..b3a57cc 100644 --- a/packages/client/src/client/actions.test.ts +++ b/packages/client/src/client/actions.test.ts @@ -5,7 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { SolanaClientRuntime } from '../rpc/types'; import type { ClientActions } from '../types'; import { createWalletRegistry } from '../wallet/registry'; -import type { WalletConnector, WalletRegistry } from '../wallet/types'; +import type { WalletAccount, WalletConnector, WalletRegistry } from '../wallet/types'; import { createActions } from './actions'; import { createDefaultClientStore } from './createClientStore'; @@ -256,4 +256,57 @@ describe('client actions', () => { expect(signature).toBe(AIRDROP_SIGNATURE); expect(airdropFactoryMock).toHaveBeenCalled(); }); + + it('updates the store account when the user switches accounts in their wallet', async () => { + let accountsChangedListener: ((accounts: WalletAccount[]) => void) | undefined; + walletConnector.connect = vi.fn(async () => ({ + account: { address: ACCOUNT_ADDRESS, publicKey: new Uint8Array(32) }, + connector: { id: 'wallet-1', name: 'Wallet 1' }, + disconnect: vi.fn(async () => undefined), + signTransaction: vi.fn(), + onAccountsChanged: (listener: (accounts: WalletAccount[]) => void) => { + accountsChangedListener = listener; + return () => { + accountsChangedListener = undefined; + }; + }, + })); + + await actions.connectWallet('wallet-1'); + expect(store.getState().wallet.status).toBe('connected'); + + const NEW_ADDRESS = 'new-addr' as Address; + accountsChangedListener?.([{ address: NEW_ADDRESS, publicKey: new Uint8Array(32) }]); + + const state = store.getState(); + expect(state.wallet.status).toBe('connected'); + if (state.wallet.status === 'connected') { + expect(state.wallet.session.account.address).toBe(NEW_ADDRESS); + } + }); + + it('disconnects when the wallet reports no accounts via onAccountsChanged', async () => { + let accountsChangedListener: ((accounts: WalletAccount[]) => void) | undefined; + walletConnector.connect = vi.fn(async () => ({ + account: { address: ACCOUNT_ADDRESS, publicKey: new Uint8Array(32) }, + connector: { id: 'wallet-1', name: 'Wallet 1' }, + disconnect: vi.fn(async () => undefined), + signTransaction: vi.fn(), + onAccountsChanged: (listener: (accounts: WalletAccount[]) => void) => { + accountsChangedListener = listener; + return () => { + accountsChangedListener = undefined; + }; + }, + })); + + await actions.connectWallet('wallet-1'); + expect(store.getState().wallet.status).toBe('connected'); + + accountsChangedListener?.([]); + // disconnectWallet is called with void — drain all pending microtasks + await new Promise((resolve) => setTimeout(resolve)); + + expect(store.getState().wallet.status).toBe('disconnected'); + }); }); diff --git a/packages/client/src/client/actions.ts b/packages/client/src/client/actions.ts index a9ad435..5d6a8b7 100644 --- a/packages/client/src/client/actions.ts +++ b/packages/client/src/client/actions.ts @@ -199,6 +199,20 @@ export function createActions({ connectors, logger: inputLogger, runtime, store walletEventsCleanup?.(); walletEventsCleanup = undefined; void disconnectWallet(); + } else { + updateState(store, { + wallet: { + autoConnect: autoConnectPreference, + connectorId: resolvedConnectorId, + session: { ...session, account: accounts[0] }, + status: 'connected', + }, + }); + logger({ + data: { address: accounts[0].address.toString(), connectorId: resolvedConnectorId }, + level: 'info', + message: 'wallet account changed', + }); } }); } From b5b2d88f98a11f6874833a1592e60569f8319db3 Mon Sep 17 00:00:00 2001 From: 0xEdouardEth <15703023+0xEdouardEth@users.noreply.github.com> Date: Sun, 3 May 2026 15:10:00 +0200 Subject: [PATCH 2/2] fix: consolidate mutable wallet session state into a single object The wallet standard session had two independent mutable variables (currentAccount and sessionAccount) updated by account change listeners. This scattered mutation made it easy for future changes to update one but forget the other, leading to inconsistent state where signing operations could reference a stale account. This consolidates both into a single sessionState object, making the mutation surface explicit and co-located. The session's account property is now a getter that always reads from sessionState, so consumers always see the latest account after a wallet-side switch instead of a stale snapshot captured at session creation time. --- packages/client/src/wallet/standard.ts | 30 ++++++++++++++++---------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/packages/client/src/wallet/standard.ts b/packages/client/src/wallet/standard.ts index fdee54c..61e1c6a 100644 --- a/packages/client/src/wallet/standard.ts +++ b/packages/client/src/wallet/standard.ts @@ -113,8 +113,14 @@ export type WalletStandardSessionOptions = Readonly<{ export function createWalletStandardSession(options: WalletStandardSessionOptions): WalletSession { const { account, defaultChain, disconnect, metadata, onAccountsChanged, wallet } = options; - let currentAccount = account; - let sessionAccount = toSessionAccount(currentAccount); + + // Mutable session state: updated by account change listeners. + // All signing operations read from this object to ensure they always + // use the most recent account after a wallet-side account switch. + const sessionState = { + currentAccount: account, + sessionAccount: toSessionAccount(account), + }; const signMessageFeature = wallet.features[SolanaSignMessage] as | SolanaSignMessageFeature[typeof SolanaSignMessage] @@ -126,12 +132,12 @@ export function createWalletStandardSession(options: WalletStandardSessionOption | SolanaSignAndSendTransactionFeature[typeof SolanaSignAndSendTransaction] | undefined; - const resolvedChain = defaultChain ?? getChain(currentAccount); + const resolvedChain = defaultChain ?? getChain(sessionState.currentAccount); const signMessage = signMessageFeature ? async (message: Uint8Array) => { const [output] = await signMessageFeature.signMessage({ - account: currentAccount, + account: sessionState.currentAccount, message, }); return output.signature; @@ -143,12 +149,12 @@ export function createWalletStandardSession(options: WalletStandardSessionOption const wireBytes = new Uint8Array(transactionEncoder.encode(transaction)); const request = resolvedChain ? { - account: currentAccount, + account: sessionState.currentAccount, chain: resolvedChain, transaction: wireBytes, } : { - account: currentAccount, + account: sessionState.currentAccount, transaction: wireBytes, }; const [output] = await signTransactionFeature.signTransaction(request); @@ -159,9 +165,9 @@ export function createWalletStandardSession(options: WalletStandardSessionOption const sendTransaction = signAndSendFeature ? async (transaction: SendableTransaction & Transaction, config?: Readonly<{ commitment?: Commitment }>) => { const wireBytes = new Uint8Array(transactionEncoder.encode(transaction)); - const chain: IdentifierString = defaultChain ?? getChain(currentAccount) ?? 'solana:mainnet-beta'; + const chain: IdentifierString = defaultChain ?? getChain(sessionState.currentAccount) ?? 'solana:mainnet-beta'; const [output] = await signAndSendFeature.signAndSendTransaction({ - account: currentAccount, + account: sessionState.currentAccount, chain, options: { commitment: mapCommitment(config?.commitment), @@ -187,8 +193,8 @@ export function createWalletStandardSession(options: WalletStandardSessionOption listener([]); return; } - currentAccount = accounts[0]; - sessionAccount = toSessionAccount(currentAccount); + sessionState.currentAccount = accounts[0]; + sessionState.sessionAccount = toSessionAccount(sessionState.currentAccount); listener(accounts.map(toSessionAccount)); }); changeUnsubscribe = off; @@ -200,7 +206,9 @@ export function createWalletStandardSession(options: WalletStandardSessionOption : undefined; return { - account: sessionAccount, + get account() { + return sessionState.sessionAccount; + }, connector: metadata, disconnect: disconnectSession, onAccountsChanged: handleAccountsChanged,