Skip to content

List Wallet Devices #817

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jul 22, 2025
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
17 changes: 17 additions & 0 deletions packages/wallet/wdk/src/sequence/types/device.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { Address } from 'ox'

/**
* Represents a device key that is authorized to sign for a wallet.
*/
export interface Device {
/**
* The on-chain address of the device key.
*/
address: Address.Address

/**
* True if this is the key for the current local session.
* This is useful for UI to distinguish the active device from others and to exclude from remote logout if true.
*/
isLocal: boolean
}
29 changes: 29 additions & 0 deletions packages/wallet/wdk/src/sequence/wallets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { Action } from './types/index.js'
import { Kinds, SignerWithKind, WitnessExtraSignerKind } from './types/signer.js'
import { Wallet, WalletSelectionUiHandler } from './types/wallet.js'
import { AuthCodeHandler } from './handlers/authcode.js'
import { Device } from './types/device.js'

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

/**
* Lists all device keys currently authorized in the wallet's on-chain configuration.
*
* This method inspects the wallet's configuration to find all signers that
* have been identified as 'local-device' keys. It also indicates which of
* these keys corresponds to the current, active session.
*
* @param wallet The address of the wallet to query.
* @returns A promise that resolves to an array of `Device` objects.
*/
listDevices(wallet: Address.Address): Promise<Device[]>

/**
* Registers a UI handler for wallet selection.
*
Expand Down Expand Up @@ -480,6 +493,22 @@ export class Wallets implements WalletsInterface {
return this.shared.databases.manager.list()
}

public async listDevices(wallet: Address.Address): Promise<Device[]> {
const walletEntry = await this.get(wallet)
if (!walletEntry) {
throw new Error('wallet-not-found')
}

const localDeviceAddress = walletEntry.device

const { devices: deviceSigners } = await this.getConfiguration(wallet)

return deviceSigners.map((signer) => ({
address: signer.address,
isLocal: Address.isEqual(signer.address, localDeviceAddress),
}))
}

public registerWalletSelector(handler: WalletSelectionUiHandler) {
if (this.walletSelectionUiHandler) {
throw new Error('wallet-selector-already-registered')
Expand Down
93 changes: 93 additions & 0 deletions packages/wallet/wdk/test/wallets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -382,4 +382,97 @@ describe('Wallets', () => {
expect(callbackCalls).toBe(1)
unregisterCallback!()
})

it('Should list all active devices for a wallet', async () => {
const manager = newManager()
const wallet = await manager.wallets.signUp({
mnemonic: Mnemonic.random(Mnemonic.english),
kind: 'mnemonic',
noGuard: true,
})

const devices = await manager.wallets.listDevices(wallet!)
expect(devices.length).toBe(1)
expect(devices[0].address).not.toBe(wallet)
expect(devices[0].isLocal).toBe(true)
expect(devices[0]).toBeDefined()
})

it('Should list all active devices for a wallet, including a new remote device', async () => {
// Step 1: Wallet signs up on device 1
const loginMnemonic = Mnemonic.random(Mnemonic.english)
const managerDevice1 = newManager(undefined, undefined, 'device-1')

const wallet = await managerDevice1.wallets.signUp({
mnemonic: loginMnemonic,
kind: 'mnemonic',
noGuard: true,
})
expect(wallet).toBeDefined()

// Verify initial state from Device 1's perspective
const devices1 = await managerDevice1.wallets.listDevices(wallet!)
expect(devices1.length).toBe(1)
expect(devices1[0].isLocal).toBe(true)
const device1Address = devices1[0].address

// Wallet logs in on device 2
const managerDevice2 = newManager(undefined, undefined, 'device-2')

// Initiate the login process from Device 2. This returns a signature request ID.
const requestId = await managerDevice2.wallets.login({ wallet: wallet! })
expect(requestId).toBeDefined()

// Register the Mnemonic UI handler for Device 2 to authorize the new device.
// It will provide the master mnemonic when asked.
const unregisterUI = managerDevice2.registerMnemonicUI(async (respond) => {
await respond(loginMnemonic)
})

// Get the signature request and handle it using the mnemonic signer.
const sigRequest = await managerDevice2.signatures.get(requestId)
const mnemonicSigner = sigRequest.signers.find((s) => s.handler?.kind === 'login-mnemonic')
expect(mnemonicSigner).toBeDefined()
expect(mnemonicSigner?.status).toBe('actionable')

const handled = await (mnemonicSigner as SignerActionable).handle()
expect(handled).toBe(true)

// Clean up the UI handler
unregisterUI()

// Finalize the login for Device 2
await managerDevice2.wallets.completeLogin(requestId)

// Step 3: Verification from both devices' perspectives

// Verify from Device 2's perspective
const devices2 = await managerDevice2.wallets.listDevices(wallet!)
expect(devices2.length).toBe(2)

const device2Entry = devices2.find((d) => d.isLocal === true) // Device 2 is the local device
const device1EntryForDevice2 = devices2.find((d) => d.isLocal === false) // Device 1 is the remote device

expect(device2Entry).toBeDefined()
expect(device2Entry?.isLocal).toBe(true)
expect(device1EntryForDevice2).toBeDefined()
expect(device1EntryForDevice2?.address).toBe(device1Address)

// Verify from Device 1's perspective
const devices1AfterLogin = await managerDevice1.wallets.listDevices(wallet!)
expect(devices1AfterLogin.length).toBe(2) // Now the wallet has logged in on two devices

const device1EntryForDevice1 = devices1AfterLogin.find((d) => d.isLocal === true)
const device2EntryForDevice1 = devices1AfterLogin.find((d) => d.isLocal === false)

expect(device1EntryForDevice1).toBeDefined()
expect(device1EntryForDevice1?.isLocal).toBe(true)
expect(device1EntryForDevice1?.address).toBe(device1Address)
expect(device2EntryForDevice1).toBeDefined()
expect(device2EntryForDevice1?.isLocal).toBe(false)

// Stop the managers to clean up resources
await managerDevice1.stop()
await managerDevice2.stop()
})
})