Skip to content
Merged
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
3 changes: 3 additions & 0 deletions packages/appkit/src/adapters/ChainAdapterBlueprint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
type AccountType,
type Connector as AppKitConnector,
ChainController,
type Connection,
type Tokens,
type WriteContractArgs
} from '@reown/appkit-controllers'
Expand All @@ -26,13 +27,15 @@ import type { ChainAdapterConnector } from './ChainAdapterConnector.js'
type EventName =
| 'disconnect'
| 'accountChanged'
| 'connections'
| 'switchNetwork'
| 'connectors'
| 'pendingTransactions'
type EventData = {
disconnect: () => void
accountChanged: { address: string; chainId?: number | string }
switchNetwork: { address?: string; chainId: number | string }
connections: Connection[]
connectors: ChainAdapterConnector[]
pendingTransactions: () => void
}
Expand Down
11 changes: 11 additions & 0 deletions packages/appkit/src/client/appkit-base-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -749,6 +749,10 @@ export abstract class AppKitBaseClient {

adapter.on('disconnect', this.disconnect.bind(this, chainNamespace))

adapter.on('connections', connections => {
this.setConnections(connections, chainNamespace)
})

adapter.on('pendingTransactions', () => {
const address = AccountController.state.address
const activeCaipNetwork = ChainController.state.activeCaipNetwork
Expand Down Expand Up @@ -1485,6 +1489,13 @@ export abstract class AppKitBaseClient {
ConnectorController.setConnectors(allConnectors)
}

public setConnections: (typeof ConnectionController)['setConnections'] = (
connections,
chainNamespace
) => {
ConnectionController.setConnections(connections, chainNamespace)
}

public fetchIdentity: (typeof BlockchainApiController)['fetchIdentity'] = request =>
BlockchainApiController.fetchIdentity(request)

Expand Down
4 changes: 3 additions & 1 deletion packages/common/src/utils/SafeLocalStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export type SafeLocalStorageItems = {
'@appkit/ens_cache': string
'@appkit/identity_cache': string
'@appkit/preferred_account_types': string
'@appkit/connections': string
/*
* DO NOT CHANGE: @walletconnect/universal-provider requires us to set this specific key
* This value is a stringified version of { href: stiring; name: string }
Expand Down Expand Up @@ -48,7 +49,8 @@ export const SafeLocalStorageKeys = {
PORTFOLIO_CACHE: '@appkit/portfolio_cache',
ENS_CACHE: '@appkit/ens_cache',
IDENTITY_CACHE: '@appkit/identity_cache',
PREFERRED_ACCOUNT_TYPES: '@appkit/preferred_account_types'
PREFERRED_ACCOUNT_TYPES: '@appkit/preferred_account_types',
CONNECTIONS: '@appkit/connections'
} as const satisfies Record<string, keyof SafeLocalStorageItems>

export type SafeLocalStorageKey = keyof SafeLocalStorageItems | NamespacedConnectorKey
Expand Down
3 changes: 2 additions & 1 deletion packages/controllers/exports/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ export type { OnRampControllerState, OnRampProvider } from '../src/controllers/O
export { ConnectionController } from '../src/controllers/ConnectionController.js'
export type {
ConnectionControllerClient,
ConnectionControllerState
ConnectionControllerState,
Connection
} from '../src/controllers/ConnectionController.js'

export { ConnectorController } from '../src/controllers/ConnectorController.js'
Expand Down
45 changes: 44 additions & 1 deletion packages/controllers/src/controllers/ConnectionController.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { proxy, ref } from 'valtio/vanilla'
import { subscribeKey as subKey } from 'valtio/vanilla/utils'

import { type CaipNetwork, type ChainNamespace } from '@reown/appkit-common'
import { type CaipAddress, type CaipNetwork, type ChainNamespace } from '@reown/appkit-common'
import type { W3mFrameTypes } from '@reown/appkit-wallet'

import { CoreHelperUtil } from '../utils/CoreHelperUtil.js'
Expand All @@ -25,6 +25,17 @@ import { RouterController } from './RouterController.js'
import { TransactionsController } from './TransactionsController.js'

// -- Types --------------------------------------------- //
export type Connection = {
accounts: { address: string }[]
connectorId: string
}

interface SwitchAccountParams {
connection: Connection
address: string
namespace: ChainNamespace
}

export interface ConnectExternalOptions {
id: Connector['id']
type: Connector['type']
Expand Down Expand Up @@ -62,6 +73,7 @@ export interface ConnectionControllerClient {
}

export interface ConnectionControllerState {
connections: Map<ChainNamespace, Connection[]>
_client?: ConnectionControllerClient
wcUri?: string
wcPairingExpiry?: number
Expand All @@ -81,16 +93,19 @@ type StateKey = keyof ConnectionControllerState

// -- State --------------------------------------------- //
const state = proxy<ConnectionControllerState>({
connections: new Map(),
wcError: false,
buffering: false,
status: 'disconnected'
})

// eslint-disable-next-line init-declarations
let wcConnectionPromise: Promise<void> | undefined

// -- Controller ---------------------------------------- //
export const ConnectionController = {
state,

subscribeKey<K extends StateKey>(
key: K,
callback: (value: ConnectionControllerState[K]) => void
Expand Down Expand Up @@ -298,5 +313,33 @@ export const ConnectionController = {
} catch (error) {
throw new Error('Failed to disconnect')
}
},

setConnections(connections: Connection[], chainNamespace: ChainNamespace) {
state.connections.set(chainNamespace, connections)
},

switchAccount({ connection, address, namespace }: SwitchAccountParams) {
const connectedConnectorId = ConnectorController.state.activeConnectorIds[namespace]
const isConnectorConnected = connectedConnectorId === connection.connectorId

if (isConnectorConnected) {
const currentNetwork = ChainController.state.activeCaipNetwork

if (currentNetwork) {
const caipAddress = `${namespace}:${currentNetwork.id}:${address}`
AccountController.setCaipAddress(caipAddress as CaipAddress, namespace)
Comment thread
0xmkh marked this conversation as resolved.
} else {
console.warn(`No current network found for namespace "${namespace}"`)
}
} else {
const connector = ConnectorController.getConnector(connection.connectorId)

if (connector) {
this.connectExternal(connector, namespace)
} else {
console.warn(`No connector found for namespace "${namespace}"`)
}
}
}
}
28 changes: 28 additions & 0 deletions packages/controllers/src/utils/StorageUtil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
getSafeConnectorIdKey
} from '@reown/appkit-common'

import type { Connection } from '../controllers/ConnectionController.js'
import type {
BlockchainApiBalanceResponse,
BlockchainApiIdentityResponse,
Expand Down Expand Up @@ -582,5 +583,32 @@ export const StorageUtil = {
}

return undefined
},
setConnections(connections: Connection[], chainNamespace: ChainNamespace) {
try {
const newConnections = {
...StorageUtil.getConnections(),
[chainNamespace]: connections
}

SafeLocalStorage.setItem(SafeLocalStorageKeys.CONNECTIONS, JSON.stringify(newConnections))
} catch (error) {
console.error('Unable to sync connections to storage', error)
}
},
getConnections() {
try {
Comment thread
0xmkh marked this conversation as resolved.
const connectionsStorage = SafeLocalStorage.getItem(SafeLocalStorageKeys.CONNECTIONS)

if (!connectionsStorage) {
return {}
}

return JSON.parse(connectionsStorage) as { [key in ChainNamespace]: Connection[] }
} catch (error) {
console.error('Unable to get connections from storage', error)

return {}
}
}
}
106 changes: 106 additions & 0 deletions packages/controllers/tests/controllers/ConnectionController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
import type {
ChainAdapter,
ConnectionControllerClient,
Connector,
ConnectorType,
NetworkControllerClient
} from '../../exports/index.js'
Expand All @@ -22,6 +23,7 @@ import {
ModalController,
SIWXUtil
} from '../../exports/index.js'
import { AccountController } from '../../exports/index.js'

// -- Setup --------------------------------------------------------------------
const chain = CommonConstantsUtil.CHAIN.EVM
Expand Down Expand Up @@ -118,6 +120,7 @@ describe('ConnectionController', () => {
)

expect(ConnectionController.state).toEqual({
connections: new Map(),
wcError: false,
buffering: false,
status: 'disconnected',
Expand Down Expand Up @@ -334,4 +337,107 @@ describe('ConnectionController', () => {
expect(connectWalletConnectSpy).toHaveBeenCalledTimes(1)
expect(ConnectionController.state.status).toEqual('connected')
})

it('should set connections for a namespace', () => {
const connections = [{ connectorId: 'test-connector', accounts: [{ address: '0x123' }] }]
ConnectionController.setConnections(connections, chain)
expect(ConnectionController.state.connections.get(chain)).toEqual(connections)
})

it('should overwrite existing connections for a namespace', () => {
const initialConnections = [
{ connectorId: 'initial-connector', accounts: [{ address: '0xabc' }] }
]
const newConnections = [{ connectorId: 'new-connector', accounts: [{ address: '0xdef' }] }]
ConnectionController.setConnections(initialConnections, chain)
ConnectionController.setConnections(newConnections, chain)
expect(ConnectionController.state.connections.get(chain)).toEqual(newConnections)
})

it('should switch account if connector is connected', async () => {
const address = '0x123'
const connection = { connectorId: 'test-connector', accounts: [{ address }] }

const setCaipAddressSpy = vi.spyOn(AccountController, 'setCaipAddress')

vi.spyOn(ConnectorController, 'state', 'get').mockReturnValue({
...ConnectorController.state,
activeConnectorIds: {
...(ConnectorController.state?.activeConnectorIds ?? {}),
[chain]: connection.connectorId
}
})
vi.spyOn(ChainController, 'state', 'get').mockReturnValue({
...ChainController.state,
activeCaipNetwork: caipNetworks[0]
})

await ConnectionController.switchAccount({ connection, address, namespace: chain })

expect(setCaipAddressSpy).toHaveBeenCalledWith('eip155:137:0x123', chain)
})

it('should connect to external connector if connector is not connected', async () => {
const address = '0x123'
const connection = { connectorId: 'test-connector', accounts: [{ address }] }
const mockConnector = {
...connection,
provider: {
request: vi.fn().mockResolvedValue(['0x123'])
}
} as unknown as Connector

vi.spyOn(ConnectorController, 'getConnector').mockReturnValue(mockConnector)
vi.spyOn(ConnectionController, 'state', 'get').mockReturnValue({
...ConnectionController.state,
connections: new Map([])
})
vi.spyOn(ConnectorController, 'state', 'get').mockReturnValue({
...ConnectorController.state,
activeConnectorIds: {
...(ConnectorController.state?.activeConnectorIds ?? {}),
[chain]: undefined
}
})

await ConnectionController.switchAccount({ connection, address, namespace: chain })

expect(clientConnectExternalSpy).toHaveBeenCalledWith(mockConnector)
})

it('should log warning if no current network found', async () => {
const connection = { connectorId: 'test-connector', accounts: [{ address: '0x123' }] }
const address = '0x123'

vi.spyOn(ChainController, 'state', 'get').mockReturnValue({
...ChainController.state,
activeCaipNetwork: undefined
})
vi.spyOn(ConnectorController, 'state', 'get').mockReturnValue({
...ConnectorController.state,
activeConnectorIds: {
...(ConnectorController.state?.activeConnectorIds ?? {}),
[chain]: connection.connectorId
}
})

const consoleWarnSpy = vi.spyOn(console, 'warn')

await ConnectionController.switchAccount({ connection, address, namespace: chain })

expect(consoleWarnSpy).toHaveBeenCalledWith('No current network found for namespace "eip155"')
})

it('should log warning if no connector found', async () => {
const address = '0x123'
const connection = { connectorId: 'non-existent-connector', accounts: [{ address }] }

vi.spyOn(ConnectorController, 'getConnector').mockReturnValue(undefined)

const consoleWarnSpy = vi.spyOn(console, 'warn')

await ConnectionController.switchAccount({ connection, address, namespace: chain })

expect(consoleWarnSpy).toHaveBeenCalledWith('No connector found for namespace "eip155"')
})
})
12 changes: 12 additions & 0 deletions packages/controllers/tests/utils/StorageUtil.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,4 +193,16 @@ describe('StorageUtil', () => {
expect(StorageUtil.getConnectedSocialUsername()).toBe(username)
})
})

describe('getConnections', () => {
it('should set and get connections', () => {
StorageUtil.setConnections(
[{ accounts: [{ address: 'address1' }], connectorId: 'connector1' }],
'eip155'
)
expect(StorageUtil.getConnections()).toEqual({
eip155: [{ accounts: [{ address: 'address1' }], connectorId: 'connector1' }]
})
})
})
})
Loading