Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,8 @@ export class WalletStandardProvider extends ProviderEventEmitter implements Sola

if (account) {
this.emit('accountsChanged', new PublicKey(account.publicKey))
} else {
this.emit('disconnect', undefined)
}
}
})
Expand Down
14 changes: 14 additions & 0 deletions packages/adapters/solana/src/tests/WalletStandardProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,4 +158,18 @@ describe('WalletStandardProvider specific tests', () => {
// should be the exact same object guaranteeing usage of requestedChains object
expect(walletStandardProvider.chains[0]).toBe(requestedChains[testingChainIndex])
})

it('should emit disconnect when StandardEvents change is triggered with undefined account', () => {
// Get the 'change' event handler that was registered in bindEvents
const onChangeMock = wallet.features['standard:events'].on as ReturnType<typeof vi.fn>
const changeHandler = onChangeMock.mock.calls.find(call => call[0] === 'change')?.[1]

expect(changeHandler).toBeDefined()

// Simulate wallet disconnecting externally - when accounts[0] is undefined
// This happens when the wallet extension disconnects and sends a change event with empty/undefined accounts
changeHandler?.({ accounts: [undefined] })

expect(emitSpy).toHaveBeenCalledWith('disconnect', undefined)
})
})
10 changes: 4 additions & 6 deletions packages/appkit/src/client/appkit-base-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1405,17 +1405,15 @@ export abstract class AppKitBaseClient {
}) {
const { chainNamespace, closeModal } = options || {}

ChainController.resetAccount(chainNamespace)
ChainController.resetNetwork(chainNamespace)

StorageUtil.removeConnectedNamespace(chainNamespace)

const namespaces = Array.from(ChainController.state.chains.keys())
const namespacesToDisconnect = chainNamespace ? [chainNamespace] : namespaces
namespacesToDisconnect.forEach(ns =>
StorageUtil.addDisconnectedConnectorId(ConnectorController.getConnectorId(ns) || '', ns)
)
ConnectorController.removeConnectorId(chainNamespace)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we still need this one

ChainController.resetAccount(chainNamespace)
ChainController.resetNetwork(chainNamespace)

StorageUtil.removeConnectedNamespace(chainNamespace)
Comment on lines +1413 to +1416
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any reason for putting these later in the loop?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the reason is explained in my PR comment also Copilots explains it as well.


ProviderController.resetChain(chainNamespace)

Expand Down
147 changes: 146 additions & 1 deletion packages/appkit/tests/client/appkit-base-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,22 @@ import {
type ChainAdapter,
ChainController,
ConnectionController,
ConnectorController,
CoreHelperUtil,
ModalController,
ProviderController,
type RemoteFeatures,
SendController,
StorageUtil,
WcHelpersUtil
} from '@reown/appkit-controllers'
import { mockChainControllerState } from '@reown/appkit-controllers/testing'
import { ErrorUtil, TokenUtil } from '@reown/appkit-utils'

import { solanaCaipNetwork } from '../../exports/testing.js'
import { AppKitBaseClient } from '../../src/client/appkit-base-client'
import { ConfigUtil } from '../../src/utils/ConfigUtil'
import { mainnet } from '../mocks/Networks'
import { mainnet, solana } from '../mocks/Networks'

describe('AppKitBaseClient.checkAllowedOrigins', () => {
let baseClient: AppKitBaseClient
Expand Down Expand Up @@ -642,4 +646,145 @@ describe('AppKitBaseClient initialization', () => {

await vi.waitFor(() => expect(fetchRemoteFeaturesSpy).toHaveBeenCalled())
})

describe('AppKitBaseClient.onDisconnectNamespace execution order', () => {
it('should call getConnectorId before resetAccount to avoid undefined connectorId', () => {
const namespace = ConstantsUtil.CHAIN.SOLANA
const connectorId = 'phantom'

// Mock getConnectorId to return a value initially
const getConnectorIdSpy = vi
.spyOn(ConnectorController, 'getConnectorId')
.mockReturnValue(connectorId)

// Mock resetAccount to call removeConnectorId
const resetAccountSpy = vi.spyOn(ChainController, 'resetAccount').mockImplementation(ns => {
// Simulate what resetAccount does - it calls removeConnectorId
if (ns) {
ConnectorController.removeConnectorId(ns)
}
})

const addDisconnectedConnectorIdSpy = vi
.spyOn(StorageUtil, 'addDisconnectedConnectorId')
.mockImplementation(() => {})

// Mock removeConnectorId to simulate its side effect
vi.spyOn(ConnectorController, 'removeConnectorId').mockImplementation(() => {
// After removeConnectorId is called, getConnectorId should return undefined
getConnectorIdSpy.mockReturnValue(undefined)
})

vi.spyOn(ChainController, 'resetNetwork').mockImplementation(() => {})
vi.spyOn(StorageUtil, 'removeConnectedNamespace').mockImplementation(() => {})
vi.spyOn(ProviderController, 'resetChain').mockImplementation(() => {})

mockChainControllerState({
activeChain: namespace,
chains: new Map([
[
namespace,
{ caipNetwork: solanaCaipNetwork, accountState: { caipAddress: 'solana:1:0x123' } }
]
])
})

const baseClient = new (class extends AppKitBaseClient {
constructor() {
super({
projectId: 'test-project-id',
networks: [solana],
adapters: [],
sdkVersion: 'html-wagmi-1'
})
}
async injectModalUi() {}
async syncIdentity() {}
override async syncAdapterConnections() {
return Promise.resolve()
}
})()

// Call onDisconnectNamespace
;(baseClient as any).onDisconnectNamespace({
chainNamespace: namespace,
closeModal: false
})

// Verify that getConnectorId was called BEFORE resetAccount
// This ensures connectorId is captured before it's removed
const getConnectorIdCallOrder = getConnectorIdSpy.mock.invocationCallOrder[0]
const resetAccountCallOrder = resetAccountSpy.mock.invocationCallOrder[0]

expect(getConnectorIdCallOrder).toBeDefined()
expect(resetAccountCallOrder).toBeDefined()
expect(getConnectorIdCallOrder).toBeLessThan(resetAccountCallOrder!)

// Verify that addDisconnectedConnectorId was called with the correct connectorId
expect(addDisconnectedConnectorIdSpy).toHaveBeenCalledWith(connectorId, namespace)
// Verify it was NOT called with invalid values that indicate getConnectorId returned undefined
expect(addDisconnectedConnectorIdSpy).not.toHaveBeenCalledWith('', namespace)
expect(addDisconnectedConnectorIdSpy).not.toHaveBeenCalledWith(undefined, namespace)
expect(addDisconnectedConnectorIdSpy).not.toHaveBeenCalledWith(null, namespace)
})

it('should call removeConnectorId only once via resetAccount', () => {
const namespace = ConstantsUtil.CHAIN.SOLANA
const connectorId = 'phantom'

vi.spyOn(ConnectorController, 'getConnectorId').mockReturnValue(connectorId)

const removeConnectorIdSpy = vi
.spyOn(ConnectorController, 'removeConnectorId')
.mockImplementation(() => {})

// Mock resetAccount to call removeConnectorId (simulating real behavior)
vi.spyOn(ChainController, 'resetAccount').mockImplementation(ns => {
if (ns) {
ConnectorController.removeConnectorId(ns)
}
})

vi.spyOn(ChainController, 'resetNetwork').mockImplementation(() => {})
vi.spyOn(StorageUtil, 'addDisconnectedConnectorId').mockImplementation(() => {})
vi.spyOn(StorageUtil, 'removeConnectedNamespace').mockImplementation(() => {})
vi.spyOn(ProviderController, 'resetChain').mockImplementation(() => {})

mockChainControllerState({
activeChain: namespace,
chains: new Map([
[
namespace,
{ caipNetwork: solanaCaipNetwork, accountState: { caipAddress: 'solana:1:0x123' } }
]
])
})

const baseClient = new (class extends AppKitBaseClient {
constructor() {
super({
projectId: 'test-project-id',
networks: [solana],
adapters: [],
sdkVersion: 'html-wagmi-1'
})
}
async injectModalUi() {}
async syncIdentity() {}
override async syncAdapterConnections() {
return Promise.resolve()
}
})()

// Call onDisconnectNamespace
;(baseClient as any).onDisconnectNamespace({
chainNamespace: namespace,
closeModal: false
})

// Verify removeConnectorId was called exactly once (via resetAccount)
expect(removeConnectorIdSpy).toHaveBeenCalledTimes(1)
expect(removeConnectorIdSpy).toHaveBeenCalledWith(namespace)
})
})
})
Loading