From a6351b3abfc490d6ea59d75167968bcb6535abac Mon Sep 17 00:00:00 2001 From: VGabriel45 Date: Fri, 18 Jul 2025 14:47:13 +0300 Subject: [PATCH] added remote logout + tests --- .../src/sequence/types/signature-request.ts | 2 + packages/wallet/wdk/src/sequence/wallets.ts | 94 ++++++++++++++----- packages/wallet/wdk/test/wallets.test.ts | 80 ++++++++++++++++ 3 files changed, 155 insertions(+), 21 deletions(-) diff --git a/packages/wallet/wdk/src/sequence/types/signature-request.ts b/packages/wallet/wdk/src/sequence/types/signature-request.ts index 97339f754..cbce933da 100644 --- a/packages/wallet/wdk/src/sequence/types/signature-request.ts +++ b/packages/wallet/wdk/src/sequence/types/signature-request.ts @@ -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 @@ -17,6 +18,7 @@ export type ActionToPayload = { export const Actions = { Logout: 'logout', + RemoteLogout: 'remote-logout', Login: 'login', SendTransaction: 'send-transaction', SignMessage: 'sign-message', diff --git a/packages/wallet/wdk/src/sequence/wallets.ts b/packages/wallet/wdk/src/sequence/wallets.ts index b43b22143..d73923a25 100644 --- a/packages/wallet/wdk/src/sequence/wallets.ts +++ b/packages/wallet/wdk/src/sequence/wallets.ts @@ -252,6 +252,17 @@ export interface WalletsInterface { options?: T, ): Promise + /** + * 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 + /** * Completes the "hard logout" process. * @@ -265,6 +276,19 @@ export interface WalletsInterface { */ completeLogout(requestId: string, options?: { skipValidateSave?: boolean }): Promise + /** + * 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 + /** * Retrieves the full, resolved configuration of a wallet. * @@ -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 { + 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 }) { @@ -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 { + 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 + } } diff --git a/packages/wallet/wdk/test/wallets.test.ts b/packages/wallet/wdk/test/wallets.test.ts index 633d2ad36..3c3e3be6b 100644 --- a/packages/wallet/wdk/test/wallets.test.ts +++ b/packages/wallet/wdk/test/wallets.test.ts @@ -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') + }) })