Skip to content

Commit 4f41344

Browse files
authored
Merge pull request #817 from 0xsequence/feat/wdk-list-devices
List Wallet Devices
2 parents f1d38c3 + dfd5020 commit 4f41344

File tree

3 files changed

+139
-0
lines changed

3 files changed

+139
-0
lines changed
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { Address } from 'ox'
2+
3+
/**
4+
* Represents a device key that is authorized to sign for a wallet.
5+
*/
6+
export interface Device {
7+
/**
8+
* The on-chain address of the device key.
9+
*/
10+
address: Address.Address
11+
12+
/**
13+
* True if this is the key for the current local session.
14+
* This is useful for UI to distinguish the active device from others and to exclude from remote logout if true.
15+
*/
16+
isLocal: boolean
17+
}

packages/wallet/wdk/src/sequence/wallets.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { Action } from './types/index.js'
99
import { Kinds, SignerWithKind, WitnessExtraSignerKind } from './types/signer.js'
1010
import { Wallet, WalletSelectionUiHandler } from './types/wallet.js'
1111
import { AuthCodeHandler } from './handlers/authcode.js'
12+
import { Device } from './types/device.js'
1213

1314
export type StartSignUpWithRedirectArgs = {
1415
kind: 'google-pkce' | 'apple'
@@ -102,6 +103,18 @@ export interface WalletsInterface {
102103
*/
103104
list(): Promise<Wallet[]>
104105

106+
/**
107+
* Lists all device keys currently authorized in the wallet's on-chain configuration.
108+
*
109+
* This method inspects the wallet's configuration to find all signers that
110+
* have been identified as 'local-device' keys. It also indicates which of
111+
* these keys corresponds to the current, active session.
112+
*
113+
* @param wallet The address of the wallet to query.
114+
* @returns A promise that resolves to an array of `Device` objects.
115+
*/
116+
listDevices(wallet: Address.Address): Promise<Device[]>
117+
105118
/**
106119
* Registers a UI handler for wallet selection.
107120
*
@@ -480,6 +493,22 @@ export class Wallets implements WalletsInterface {
480493
return this.shared.databases.manager.list()
481494
}
482495

496+
public async listDevices(wallet: Address.Address): Promise<Device[]> {
497+
const walletEntry = await this.get(wallet)
498+
if (!walletEntry) {
499+
throw new Error('wallet-not-found')
500+
}
501+
502+
const localDeviceAddress = walletEntry.device
503+
504+
const { devices: deviceSigners } = await this.getConfiguration(wallet)
505+
506+
return deviceSigners.map((signer) => ({
507+
address: signer.address,
508+
isLocal: Address.isEqual(signer.address, localDeviceAddress),
509+
}))
510+
}
511+
483512
public registerWalletSelector(handler: WalletSelectionUiHandler) {
484513
if (this.walletSelectionUiHandler) {
485514
throw new Error('wallet-selector-already-registered')

packages/wallet/wdk/test/wallets.test.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,4 +382,97 @@ describe('Wallets', () => {
382382
expect(callbackCalls).toBe(1)
383383
unregisterCallback!()
384384
})
385+
386+
it('Should list all active devices for a wallet', async () => {
387+
const manager = newManager()
388+
const wallet = await manager.wallets.signUp({
389+
mnemonic: Mnemonic.random(Mnemonic.english),
390+
kind: 'mnemonic',
391+
noGuard: true,
392+
})
393+
394+
const devices = await manager.wallets.listDevices(wallet!)
395+
expect(devices.length).toBe(1)
396+
expect(devices[0].address).not.toBe(wallet)
397+
expect(devices[0].isLocal).toBe(true)
398+
expect(devices[0]).toBeDefined()
399+
})
400+
401+
it('Should list all active devices for a wallet, including a new remote device', async () => {
402+
// Step 1: Wallet signs up on device 1
403+
const loginMnemonic = Mnemonic.random(Mnemonic.english)
404+
const managerDevice1 = newManager(undefined, undefined, 'device-1')
405+
406+
const wallet = await managerDevice1.wallets.signUp({
407+
mnemonic: loginMnemonic,
408+
kind: 'mnemonic',
409+
noGuard: true,
410+
})
411+
expect(wallet).toBeDefined()
412+
413+
// Verify initial state from Device 1's perspective
414+
const devices1 = await managerDevice1.wallets.listDevices(wallet!)
415+
expect(devices1.length).toBe(1)
416+
expect(devices1[0].isLocal).toBe(true)
417+
const device1Address = devices1[0].address
418+
419+
// Wallet logs in on device 2
420+
const managerDevice2 = newManager(undefined, undefined, 'device-2')
421+
422+
// Initiate the login process from Device 2. This returns a signature request ID.
423+
const requestId = await managerDevice2.wallets.login({ wallet: wallet! })
424+
expect(requestId).toBeDefined()
425+
426+
// Register the Mnemonic UI handler for Device 2 to authorize the new device.
427+
// It will provide the master mnemonic when asked.
428+
const unregisterUI = managerDevice2.registerMnemonicUI(async (respond) => {
429+
await respond(loginMnemonic)
430+
})
431+
432+
// Get the signature request and handle it using the mnemonic signer.
433+
const sigRequest = await managerDevice2.signatures.get(requestId)
434+
const mnemonicSigner = sigRequest.signers.find((s) => s.handler?.kind === 'login-mnemonic')
435+
expect(mnemonicSigner).toBeDefined()
436+
expect(mnemonicSigner?.status).toBe('actionable')
437+
438+
const handled = await (mnemonicSigner as SignerActionable).handle()
439+
expect(handled).toBe(true)
440+
441+
// Clean up the UI handler
442+
unregisterUI()
443+
444+
// Finalize the login for Device 2
445+
await managerDevice2.wallets.completeLogin(requestId)
446+
447+
// Step 3: Verification from both devices' perspectives
448+
449+
// Verify from Device 2's perspective
450+
const devices2 = await managerDevice2.wallets.listDevices(wallet!)
451+
expect(devices2.length).toBe(2)
452+
453+
const device2Entry = devices2.find((d) => d.isLocal === true) // Device 2 is the local device
454+
const device1EntryForDevice2 = devices2.find((d) => d.isLocal === false) // Device 1 is the remote device
455+
456+
expect(device2Entry).toBeDefined()
457+
expect(device2Entry?.isLocal).toBe(true)
458+
expect(device1EntryForDevice2).toBeDefined()
459+
expect(device1EntryForDevice2?.address).toBe(device1Address)
460+
461+
// Verify from Device 1's perspective
462+
const devices1AfterLogin = await managerDevice1.wallets.listDevices(wallet!)
463+
expect(devices1AfterLogin.length).toBe(2) // Now the wallet has logged in on two devices
464+
465+
const device1EntryForDevice1 = devices1AfterLogin.find((d) => d.isLocal === true)
466+
const device2EntryForDevice1 = devices1AfterLogin.find((d) => d.isLocal === false)
467+
468+
expect(device1EntryForDevice1).toBeDefined()
469+
expect(device1EntryForDevice1?.isLocal).toBe(true)
470+
expect(device1EntryForDevice1?.address).toBe(device1Address)
471+
expect(device2EntryForDevice1).toBeDefined()
472+
expect(device2EntryForDevice1?.isLocal).toBe(false)
473+
474+
// Stop the managers to clean up resources
475+
await managerDevice1.stop()
476+
await managerDevice2.stop()
477+
})
385478
})

0 commit comments

Comments
 (0)