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
2 changes: 2 additions & 0 deletions packages/wallet/wdk/src/sequence/types/signature-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Handler } from '../handlers/handler.js'

export type ActionToPayload = {
[Actions.Logout]: Payload.ConfigUpdate
[Actions.RemoteLogout]: Payload.ConfigUpdate
[Actions.Login]: Payload.ConfigUpdate
[Actions.SendTransaction]: Payload.Calls | Payload.Calls4337_07
[Actions.SignMessage]: Payload.Message
Expand All @@ -17,6 +18,7 @@ export type ActionToPayload = {

export const Actions = {
Logout: 'logout',
RemoteLogout: 'remote-logout',
Login: 'login',
SendTransaction: 'send-transaction',
SignMessage: 'sign-message',
Expand Down
94 changes: 73 additions & 21 deletions packages/wallet/wdk/src/sequence/wallets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,17 @@ export interface WalletsInterface {
options?: T,
): Promise<T extends { skipRemoveDevice: true } ? undefined : string>

/**
* Initiates a remote logout process for a given wallet.
*
* This method is used to log out a device from a wallet that is not the local device.
*
* @param wallet The address of the wallet to log out from.
* @param deviceAddress The address of the device to log out.
* @returns A promise that resolves to a `requestId` for the on-chain logout transaction.
*/
remoteLogout(wallet: Address.Address, deviceAddress: Address.Address): Promise<string>

/**
* Completes the "hard logout" process.
*
Expand All @@ -265,6 +276,19 @@ export interface WalletsInterface {
*/
completeLogout(requestId: string, options?: { skipValidateSave?: boolean }): Promise<void>

/**
* Completes a generic configuration update after it has been signed.
*
* This method takes a requestId for any action that results in a configuration
* update (e.g., from `login`, `logout`, `remoteLogout`, `addSigner`, etc.),
* validates it, and saves the new configuration to the state provider. The
* update will be bundled with the next on-chain transaction.
*
* @param requestId The ID of the completed signature request.
* @returns A promise that resolves when the update has been processed.
*/
completeConfigurationUpdate(requestId: string): Promise<void>

/**
* Retrieves the full, resolved configuration of a wallet.
*
Expand Down Expand Up @@ -988,32 +1012,26 @@ export class Wallets implements WalletsInterface {
throw new Error('device-not-found')
}

const { devicesTopology, modules } = await this.getConfigurationParts(wallet)
const nextDevicesTopology = buildCappedTree([
...Config.getSigners(devicesTopology)
.signers.filter((x) => x !== Constants.ZeroAddress && !Address.isEqual(x, device.address))
.map((x) => ({ address: x })),
...Config.getSigners(devicesTopology).sapientSigners,
])
const requestId = await this._prepareDeviceRemovalUpdate(wallet, device.address, 'logout')

// Remove device from the recovery topology, if it exists
if (this.shared.modules.recovery.hasRecoveryModule(modules)) {
await this.shared.modules.recovery.removeRecoverySignerFromModules(modules, device.address)
await this.shared.databases.manager.set({ ...walletEntry, status: 'logging-out' })

return requestId as any
}

public async remoteLogout(wallet: Address.Address, deviceAddress: Address.Address): Promise<string> {
const walletEntry = await this.get(wallet)
if (!walletEntry) {
throw new Error('wallet-not-found')
}

const requestId = await this.requestConfigurationUpdate(
wallet,
{
devicesTopology: nextDevicesTopology,
modules,
},
'logout',
'wallet-webapp',
)
if (Address.isEqual(walletEntry.device, deviceAddress)) {
throw new Error('cannot-remote-logout-from-local-device')
}

await this.shared.databases.manager.set({ ...walletEntry, status: 'logging-out' })
const requestId = await this._prepareDeviceRemovalUpdate(wallet, deviceAddress, 'remote-logout')

return requestId as any
return requestId
}

async completeLogout(requestId: string, options?: { skipValidateSave?: boolean }) {
Expand Down Expand Up @@ -1127,4 +1145,38 @@ export class Wallets implements WalletsInterface {
const onchainStatus = await walletObject.getStatus(provider)
return onchainStatus.imageHash === onchainStatus.onChainImageHash
}

private async _prepareDeviceRemovalUpdate(
wallet: Address.Address,
deviceToRemove: Address.Address,
action: 'logout' | 'remote-logout',
): Promise<string> {
const { devicesTopology, modules } = await this.getConfigurationParts(wallet)

// The result of this entire inner block is a clean, simple list of the remaining devices, ready to be rebuilt.
const nextDevicesTopology = buildCappedTree([
...Config.getSigners(devicesTopology)
.signers.filter((x) => x !== Constants.ZeroAddress && !Address.isEqual(x, deviceToRemove))
.map((x) => ({ address: x })),
...Config.getSigners(devicesTopology).sapientSigners,
])

// Remove the device from the recovery module's topology as well.
if (this.shared.modules.recovery.hasRecoveryModule(modules)) {
await this.shared.modules.recovery.removeRecoverySignerFromModules(modules, deviceToRemove)
}

// Request the configuration update.
const requestId = await this.requestConfigurationUpdate(
wallet,
{
devicesTopology: nextDevicesTopology,
modules,
},
action,
'wallet-webapp',
)

return requestId
}
}
80 changes: 80 additions & 0 deletions packages/wallet/wdk/test/wallets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -475,4 +475,84 @@ describe('Wallets', () => {
await managerDevice1.stop()
await managerDevice2.stop()
})

it('Should remotely log out a device', async () => {
// === Step 1: Setup with two devices ===
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()

const managerDevice2 = newManager(undefined, undefined, 'device-2')
const loginRequestId = await managerDevice2.wallets.login({ wallet: wallet! })

const unregisterUI = managerDevice2.registerMnemonicUI(async (respond) => {
await respond(loginMnemonic)
})

const loginSigRequest = await managerDevice2.signatures.get(loginRequestId)
const mnemonicSigner = loginSigRequest.signers.find((s) => s.handler?.kind === 'login-mnemonic')!
await (mnemonicSigner as SignerActionable).handle()
unregisterUI()

await managerDevice2.wallets.completeLogin(loginRequestId)

const initialDevices = await managerDevice1.wallets.listDevices(wallet!)
console.log('Initial devices', initialDevices)
expect(initialDevices.length).toBe(2)
const device2Address = initialDevices.find((d) => !d.isLocal)!.address

// === Step 2: Initiate remote logout from Device 1 ===
const remoteLogoutRequestId = await managerDevice1.wallets.remoteLogout(wallet!, device2Address)
expect(remoteLogoutRequestId).toBeDefined()

// === Step 3: Authorize the remote logout from Device 1 ===
const logoutSigRequest = await managerDevice1.signatures.get(remoteLogoutRequestId)
expect(logoutSigRequest.action).toBe('remote-logout')

const device1Signer = logoutSigRequest.signers.find((s) => s.handler?.kind === 'local-device')
expect(device1Signer).toBeDefined()
expect(device1Signer?.status).toBe('ready')

const handled = await (device1Signer as SignerReady).handle()
expect(handled).toBe(true)

await managerDevice1.wallets.completeConfigurationUpdate(remoteLogoutRequestId)

// The signature request should now be marked as completed
expect((await managerDevice1.signatures.get(remoteLogoutRequestId))?.status).toBe('completed')

// === Step 5: Verification ===
const finalDevices = await managerDevice1.wallets.listDevices(wallet!)
console.log('Final devices', finalDevices)
expect(finalDevices.length).toBe(1)
expect(finalDevices[0].isLocal).toBe(true)
expect(finalDevices[0].address).not.toBe(device2Address)

await managerDevice1.stop()
await managerDevice2.stop()
})

it('Should not be able to remotely log out from the current device', async () => {
manager = newManager()
const wallet = await manager.wallets.signUp({
mnemonic: Mnemonic.random(Mnemonic.english),
kind: 'mnemonic',
noGuard: true,
})
expect(wallet).toBeDefined()

const devices = await manager.wallets.listDevices(wallet!)
expect(devices.length).toBe(1)
const localDeviceAddress = devices[0].address

const remoteLogoutPromise = manager.wallets.remoteLogout(wallet!, localDeviceAddress)

await expect(remoteLogoutPromise).rejects.toThrow('cannot-remote-logout-from-local-device')
})
})