diff --git a/README.md b/README.md index ca68adf1d..4c663ccf8 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,8 @@ Sequence v3 core libraries and [wallet-contracts-v3](https://github.com/0xsequen - Run tests: `pnpm test` + > **Note:** Tests require [anvil](https://github.com/foundry-rs/foundry/tree/master/anvil) and [forge](https://github.com/foundry-rs/foundry) to be installed. You can run a local anvil instance using `pnpm run test:anvil`. + - Linting and formatting is enforced via git hooks ## License diff --git a/package.json b/package.json index 0cf6c222c..db7450c4c 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "typecheck": "turbo typecheck", "postinstall": "lefthook install", "dev:server": "node packages/wallet/primitives-cli/dist/index.js server", - "reinstall": "rimraf -g ./**/node_modules && pnpm install" + "reinstall": "rimraf -g ./**/node_modules && pnpm install", + "test:anvil": "anvil --fork-url https://nodes.sequence.app/arbitrum" }, "devDependencies": { "@changesets/cli": "^2.29.0", diff --git a/packages/wallet/wdk/src/dbs/generic.ts b/packages/wallet/wdk/src/dbs/generic.ts index 155a3870b..34fa6dbed 100644 --- a/packages/wallet/wdk/src/dbs/generic.ts +++ b/packages/wallet/wdk/src/dbs/generic.ts @@ -180,4 +180,15 @@ export class Generic { removeListener(listener: DbUpdateListener): void { this.listeners = this.listeners.filter((l) => l !== listener) } + + public async close(): Promise { + if (this._db) { + this._db.close() + this._db = null + } + if (this.broadcastChannel) { + this.broadcastChannel.close() + this.broadcastChannel = undefined + } + } } diff --git a/packages/wallet/wdk/src/sequence/cron.ts b/packages/wallet/wdk/src/sequence/cron.ts index a189927e6..21a28e5d0 100644 --- a/packages/wallet/wdk/src/sequence/cron.ts +++ b/packages/wallet/wdk/src/sequence/cron.ts @@ -7,79 +7,127 @@ interface CronJob { handler: () => Promise } -// Manages scheduled jobs across multiple tabs export class Cron { private jobs: Map = new Map() private checkInterval?: ReturnType private readonly STORAGE_KEY = 'sequence-cron-jobs' + private isStopping: boolean = false + private currentCheckJobsPromise: Promise = Promise.resolve() constructor(private readonly shared: Shared) { this.start() } private start() { - // Check every minute - this.checkInterval = setInterval(() => this.checkJobs(), 60 * 1000) - this.checkJobs() + if (this.isStopping) return + this.executeCheckJobsChain() + this.checkInterval = setInterval(() => this.executeCheckJobsChain(), 60 * 1000) + } + + // Wraps checkJobs to chain executions and manage currentCheckJobsPromise + private executeCheckJobsChain(): void { + this.currentCheckJobsPromise = this.currentCheckJobsPromise + .catch(() => {}) // Ignore errors from previous chain link for sequencing + .then(() => { + if (!this.isStopping) { + return this.checkJobs() + } + return Promise.resolve() + }) + } + + public async stop(): Promise { + this.isStopping = true + + if (this.checkInterval) { + clearInterval(this.checkInterval) + this.checkInterval = undefined + this.shared.modules.logger.log('Cron: Interval cleared.') + } + + // Wait for the promise of the last (or current) checkJobs execution + await this.currentCheckJobsPromise.catch((err) => { + console.error('Cron: Error during currentCheckJobsPromise settlement in stop():', err) + }) } - // Register a new job with a unique ID and interval in milliseconds registerJob(id: string, interval: number, handler: () => Promise) { if (this.jobs.has(id)) { throw new Error(`Job with ID ${id} already exists`) } - - const job: CronJob = { - id, - interval, - lastRun: 0, - handler, - } - + const job: CronJob = { id, interval, lastRun: 0, handler } this.jobs.set(id, job) - this.syncWithStorage() + // No syncWithStorage needed here, it happens in checkJobs } - // Unregister a job by ID unregisterJob(id: string) { - if (this.jobs.delete(id)) { - this.syncWithStorage() - } + this.jobs.delete(id) } - private async checkJobs() { - await navigator.locks.request('sequence-cron-jobs', async (lock: Lock | null) => { - if (!lock) return - - const now = Date.now() - const storage = await this.getStorageState() - - for (const [id, job] of this.jobs) { - const lastRun = storage.get(id)?.lastRun ?? job.lastRun - const timeSinceLastRun = now - lastRun - - if (timeSinceLastRun >= job.interval) { - try { - await job.handler() - job.lastRun = now - storage.set(id, { lastRun: now }) - } catch (error) { - console.error(`Cron job ${id} failed:`, error) - // Continue with other jobs even if this one failed + private async checkJobs(): Promise { + if (this.isStopping) { + return + } + + try { + await navigator.locks.request('sequence-cron-jobs', async (lock: Lock | null) => { + if (this.isStopping) { + return + } + if (!lock) { + return + } + + const now = Date.now() + const storage = await this.getStorageState() + + for (const [id, job] of this.jobs) { + if (this.isStopping) { + break + } + + const lastRun = storage.get(id)?.lastRun ?? job.lastRun + const timeSinceLastRun = now - lastRun + + if (timeSinceLastRun >= job.interval) { + try { + await job.handler() + if (!this.isStopping) { + job.lastRun = now + storage.set(id, { lastRun: now }) + } else { + } + } catch (error) { + if (error instanceof DOMException && error.name === 'AbortError') { + this.shared.modules.logger.log(`Cron: Job ${id} was aborted.`) + } else { + console.error(`Cron job ${id} failed:`, error) + } + } } } - } - await this.syncWithStorage() - }) + if (!this.isStopping) { + await this.syncWithStorage() + } + }) + } catch (error) { + if (error instanceof DOMException && error.name === 'AbortError') { + this.shared.modules.logger.log('Cron: navigator.locks.request was aborted.') + } else { + console.error('Cron: Error in navigator.locks.request:', error) + } + } } private async getStorageState(): Promise> { + if (this.isStopping) return new Map() const state = localStorage.getItem(this.STORAGE_KEY) return new Map(state ? JSON.parse(state) : []) } private async syncWithStorage() { + if (this.isStopping) return const state = Array.from(this.jobs.entries()).map(([id, job]) => [id, { lastRun: job.lastRun }]) localStorage.setItem(this.STORAGE_KEY, JSON.stringify(state)) } diff --git a/packages/wallet/wdk/src/sequence/handlers/mnemonic.ts b/packages/wallet/wdk/src/sequence/handlers/mnemonic.ts index e4da0829d..069a5f83b 100644 --- a/packages/wallet/wdk/src/sequence/handlers/mnemonic.ts +++ b/packages/wallet/wdk/src/sequence/handlers/mnemonic.ts @@ -3,7 +3,7 @@ import { Address, Hex, Mnemonic } from 'ox' import { Handler } from './handler.js' import { Signatures } from '../signatures.js' import { Kinds } from '../types/signer.js' -import { SignerReady, SignerUnavailable, BaseSignatureRequest } from '../types/index.js' +import { SignerReady, SignerUnavailable, BaseSignatureRequest, SignerActionable } from '../types/index.js' type RespondFn = (mnemonic: string) => Promise @@ -42,7 +42,7 @@ export class MnemonicHandler implements Handler { address: Address.Address, _imageHash: Hex.Hex | undefined, request: BaseSignatureRequest, - ): Promise { + ): Promise { const onPromptMnemonic = this.onPromptMnemonic if (!onPromptMnemonic) { return { @@ -56,7 +56,8 @@ export class MnemonicHandler implements Handler { return { address, handler: this, - status: 'ready', + status: 'actionable', + message: 'enter-mnemonic', handle: () => new Promise(async (resolve, reject) => { const respond = async (mnemonic: string) => { diff --git a/packages/wallet/wdk/src/sequence/manager.ts b/packages/wallet/wdk/src/sequence/manager.ts index 98c96ad38..6951bb7b1 100644 --- a/packages/wallet/wdk/src/sequence/manager.ts +++ b/packages/wallet/wdk/src/sequence/manager.ts @@ -96,7 +96,7 @@ export const ManagerOptionsDefaults = { stateProvider: new State.Local.Provider(new State.Local.IndexedDbStore()), networks: Network.All, - relayers: [Relayer.Local.LocalRelayer.createFromWindow(window)].filter((r) => r !== undefined), + relayers: () => [Relayer.Local.LocalRelayer.createFromWindow(window)].filter((r) => r !== undefined), defaultGuardTopology: { // TODO: Move this somewhere else @@ -225,7 +225,7 @@ export class Manager { stateProvider: ops.stateProvider, networks: ops.networks, - relayers: ops.relayers, + relayers: typeof ops.relayers === 'function' ? ops.relayers() : ops.relayers, defaultGuardTopology: ops.defaultGuardTopology, defaultSessionsTopology: ops.defaultSessionsTopology, @@ -552,4 +552,19 @@ export class Manager { public async updateQueuedRecoveryPayloads() { return this.shared.modules.recovery.updateQueuedRecoveryPayloads() } + + // DBs + + public async stop() { + await this.shared.modules.cron.stop() + + await Promise.all([ + this.shared.databases.authKeys.close(), + this.shared.databases.authCommitments.close(), + this.shared.databases.manager.close(), + this.shared.databases.recovery.close(), + this.shared.databases.signatures.close(), + this.shared.databases.transactions.close(), + ]) + } } diff --git a/packages/wallet/wdk/src/sequence/wallets.ts b/packages/wallet/wdk/src/sequence/wallets.ts index b94a2213c..b64c00d48 100644 --- a/packages/wallet/wdk/src/sequence/wallets.ts +++ b/packages/wallet/wdk/src/sequence/wallets.ts @@ -572,6 +572,9 @@ export class Wallets { const device = await this.shared.modules.devices.create() const { devicesTopology, modules, guardTopology } = await this.getConfigurationParts(args.wallet) + // Witness the wallet + await this.shared.modules.devices.witness(device.address, args.wallet) + // Add device to devices topology const prevDevices = Config.getSigners(devicesTopology) if (prevDevices.sapientSigners.length > 0) { diff --git a/packages/wallet/wdk/test/constants.ts b/packages/wallet/wdk/test/constants.ts index 4e1c73fe1..523edccff 100644 --- a/packages/wallet/wdk/test/constants.ts +++ b/packages/wallet/wdk/test/constants.ts @@ -1,5 +1,9 @@ import { config as dotenvConfig } from 'dotenv' import { Abi, Address } from 'ox' +import { Manager, ManagerOptions, ManagerOptionsDefaults } from '../src/sequence' +import { mockEthereum } from './setup' +import { Signers as CoreSigners } from '@0xsequence/wallet-core' +import * as Db from '../src/dbs' const envFile = process.env.CI ? '.env.test' : '.env.test.local' dotenvConfig({ path: envFile }) @@ -10,3 +14,49 @@ export const EMITTER_ABI = Abi.from(['function explicitEmit()', 'function implic // Environment variables export const { RPC_URL, PRIVATE_KEY } = process.env export const CAN_RUN_LIVE = !!RPC_URL && !!PRIVATE_KEY +export const LOCAL_RPC_URL = process.env.LOCAL_RPC_URL || 'http://localhost:8545' + +let testIdCounter = 0 + +export function newManager(options?: ManagerOptions, noEthereumMock?: boolean, tag?: string) { + if (!noEthereumMock) { + mockEthereum() + } + + testIdCounter++ + const dbSuffix = tag ? `_${tag}_testrun_${testIdCounter}` : `_testrun_${testIdCounter}` + + // Ensure options and its identity sub-object exist for easier merging + const effectiveOptions = { + ...options, + identity: { ...ManagerOptionsDefaults.identity, ...options?.identity }, + } + + return new Manager({ + networks: [ + { + name: 'Arbitrum (local fork)', + rpc: LOCAL_RPC_URL, + chainId: 42161n, + explorer: 'https://arbiscan.io/', + nativeCurrency: { + name: 'Ether', + symbol: 'ETH', + decimals: 18, + }, + }, + ], + // Override DBs with unique names if not provided in options, + // otherwise, use the provided DB instance. + // This assumes options?.someDb is either undefined or a fully constructed DB instance. + encryptedPksDb: effectiveOptions.encryptedPksDb || new CoreSigners.Pk.Encrypted.EncryptedPksDb('pk-db' + dbSuffix), + managerDb: effectiveOptions.managerDb || new Db.Wallets('sequence-manager' + dbSuffix), + transactionsDb: effectiveOptions.transactionsDb || new Db.Transactions('sequence-transactions' + dbSuffix), + signaturesDb: effectiveOptions.signaturesDb || new Db.Signatures('sequence-signature-requests' + dbSuffix), + authCommitmentsDb: + effectiveOptions.authCommitmentsDb || new Db.AuthCommitments('sequence-auth-commitments' + dbSuffix), + authKeysDb: effectiveOptions.authKeysDb || new Db.AuthKeys('sequence-auth-keys' + dbSuffix), + recoveryDb: effectiveOptions.recoveryDb || new Db.Recovery('sequence-recovery' + dbSuffix), + ...effectiveOptions, + }) +} diff --git a/packages/wallet/wdk/test/recovery.test.ts b/packages/wallet/wdk/test/recovery.test.ts index 2a3a2e6f3..b237b9f3e 100644 --- a/packages/wallet/wdk/test/recovery.test.ts +++ b/packages/wallet/wdk/test/recovery.test.ts @@ -2,29 +2,15 @@ import { describe, expect, it } from 'vitest' import { Manager, QueuedRecoveryPayload, SignerReady, TransactionDefined } from '../src/sequence' import { Bytes, Hex, Mnemonic, Provider, RpcTransport } from 'ox' import { Payload } from '@0xsequence/wallet-primitives' - -const LOCAL_RPC_URL = 'http://localhost:8545' +import { LOCAL_RPC_URL, newManager } from './constants' describe('Recovery', () => { it('Should execute a recovery', async () => { - const manager = new Manager({ + const manager = newManager({ defaultRecoverySettings: { requiredDeltaTime: 2n, // 2 seconds minTimestamp: 0n, }, - networks: [ - { - name: 'Arbitrum (local fork)', - rpc: LOCAL_RPC_URL, - chainId: 42161n, - explorer: 'https://arbiscan.io/', - nativeCurrency: { - name: 'Ether', - symbol: 'ETH', - decimals: 18, - }, - }, - ], }) const mnemonic = Mnemonic.random(Mnemonic.english) diff --git a/packages/wallet/wdk/test/sessions.test.ts b/packages/wallet/wdk/test/sessions.test.ts index ac6adec86..7c29ddf88 100644 --- a/packages/wallet/wdk/test/sessions.test.ts +++ b/packages/wallet/wdk/test/sessions.test.ts @@ -4,6 +4,7 @@ import { Signers as CoreSigners, Wallet as CoreWallet, Envelope, Relayer, State import { Attestation, Constants, Payload, Permission } from '../../primitives/src/index.js' import { Sequence } from '../src/index.js' import { CAN_RUN_LIVE, EMITTER_ABI, EMITTER_ADDRESS, PRIVATE_KEY, RPC_URL } from './constants' +import { SignerActionable } from '../src/sequence/index.js' describe('Sessions (via Manager)', () => { // Shared components @@ -194,10 +195,10 @@ describe('Sessions (via Manager)', () => { // Sign and complete the request const sigRequest = await wdk.manager.getSignatureRequest(requestId) const identitySigner = sigRequest.signers.find((s) => s.address === wdk.identitySignerAddress) - if (!identitySigner || identitySigner.status !== 'ready') { + if (!identitySigner || identitySigner.status !== 'actionable') { throw new Error(`Identity signer not found or not ready: ${identitySigner?.status}`) } - const handled = await identitySigner.handle() + const handled = await (identitySigner as SignerActionable).handle() if (!handled) { throw new Error('Failed to handle identity signer') } @@ -255,10 +256,10 @@ describe('Sessions (via Manager)', () => { // Sign the request (Wallet UI action) const sigRequest = await wdk.manager.getSignatureRequest(requestId) const identitySigner = sigRequest.signers[0] - if (!identitySigner || identitySigner.status !== 'ready') { - throw new Error(`Identity signer not found or not ready: ${identitySigner?.status}`) + if (!identitySigner || identitySigner.status !== 'actionable') { + throw new Error(`Identity signer not found or not actionable: ${identitySigner?.status}`) } - const handled = await identitySigner.handle() + const handled = await (identitySigner as SignerActionable).handle() if (!handled) { throw new Error('Failed to handle identity signer') } diff --git a/packages/wallet/wdk/test/setup.ts b/packages/wallet/wdk/test/setup.ts index 5b1a73655..e19587147 100644 --- a/packages/wallet/wdk/test/setup.ts +++ b/packages/wallet/wdk/test/setup.ts @@ -1,6 +1,7 @@ import { indexedDB, IDBFactory } from 'fake-indexeddb' import { Provider, RpcTransport } from 'ox' import { vi } from 'vitest' +import { LOCAL_RPC_URL } from './constants' // Add IndexedDB support to the test environment global.indexedDB = indexedDB @@ -48,20 +49,15 @@ if (!global.navigator.locks || !('request' in global.navigator.locks)) { console.log('navigator.locks already exists and appears to have a "request" property.') } -let ANVIL_RPC_URL = 'http://localhost:8545' - -export function setAnvilRpcUrl(url: string) { - ANVIL_RPC_URL = url -} - -// Add window.ethereum support, pointing to the the Anvil local RPC -if (typeof (window as any).ethereum === 'undefined') { - console.log('mocking window.ethereum') - ;(window as any).ethereum = { - request: vi.fn().mockImplementation(async (args: any) => { - // Pipe the request to the Anvil local RPC - const provider = Provider.from(RpcTransport.fromHttp(ANVIL_RPC_URL)) - return provider.request(args) - }), +export function mockEthereum() { + // Add window.ethereum support, pointing to the the Anvil local RPC + if (typeof (window as any).ethereum === 'undefined') { + ;(window as any).ethereum = { + request: vi.fn().mockImplementation(async (args: any) => { + // Pipe the request to the Anvil local RPC + const provider = Provider.from(RpcTransport.fromHttp(LOCAL_RPC_URL)) + return provider.request(args) + }), + } } } diff --git a/packages/wallet/wdk/test/wallets.test.ts b/packages/wallet/wdk/test/wallets.test.ts new file mode 100644 index 000000000..caf998638 --- /dev/null +++ b/packages/wallet/wdk/test/wallets.test.ts @@ -0,0 +1,210 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { Manager, SignerActionable, SignerReady } from '../src/sequence' +import { Mnemonic } from 'ox' +import { newManager } from './constants' + +describe('Wallets', () => { + let manager: Manager | undefined + + afterEach(async () => { + await manager?.stop() + }) + + it('Should create a new wallet using a mnemonic', async () => { + manager = newManager() + const wallet = await manager.signUp({ mnemonic: Mnemonic.random(Mnemonic.english), kind: 'mnemonic' }) + expect(wallet).toBeDefined() + }) + + it('Should logout from a wallet using the login key', async () => { + const manager = newManager() + const loginMnemonic = Mnemonic.random(Mnemonic.english) + const wallet = await manager.signUp({ mnemonic: loginMnemonic, kind: 'mnemonic', noGuard: true }) + expect(wallet).toBeDefined() + + const wallets = await manager.listWallets() + expect(wallets.length).toBe(1) + expect(wallets[0].address).toBe(wallet!) + + const requestId = await manager.logout(wallet!) + expect(requestId).toBeDefined() + + let signRequests = 0 + const unregistedUI = manager.registerMnemonicUI(async (respond) => { + signRequests++ + await respond(loginMnemonic) + }) + + const request = await manager.getSignatureRequest(requestId) + expect(request).toBeDefined() + expect(request.action).toBe('logout') + + const loginSigner = request.signers.find((signer) => signer.handler?.kind === 'login-mnemonic') + expect(loginSigner).toBeDefined() + expect(loginSigner?.status).toBe('actionable') + + const result = await (loginSigner as SignerActionable).handle() + expect(result).toBe(true) + + expect(signRequests).toBe(1) + unregistedUI() + + await manager.completeLogout(requestId) + expect((await manager.getSignatureRequest(requestId))?.status).toBe('completed') + const wallets2 = await manager.listWallets() + expect(wallets2.length).toBe(0) + }) + + it('Should logout from a wallet using the device key', async () => { + const manager = newManager() + const loginMnemonic = Mnemonic.random(Mnemonic.english) + const wallet = await manager.signUp({ mnemonic: loginMnemonic, kind: 'mnemonic', noGuard: true }) + expect(wallet).toBeDefined() + + const wallets = await manager.listWallets() + expect(wallets.length).toBe(1) + expect(wallets[0].address).toBe(wallet!) + + const requestId = await manager.logout(wallet!) + expect(requestId).toBeDefined() + + const request = await manager.getSignatureRequest(requestId) + expect(request).toBeDefined() + expect(request.action).toBe('logout') + + const deviceSigner = request.signers.find((signer) => signer.handler?.kind === 'local-device') + expect(deviceSigner).toBeDefined() + expect(deviceSigner?.status).toBe('ready') + + const result = await (deviceSigner as SignerReady).handle() + expect(result).toBe(true) + + await manager.completeLogout(requestId) + expect((await manager.getSignatureRequest(requestId))?.status).toBe('completed') + const wallets2 = await manager.listWallets() + expect(wallets2.length).toBe(0) + }) + + it('Should login to an existing wallet using the mnemonic signer', async () => { + manager = newManager() + const mnemonic = Mnemonic.random(Mnemonic.english) + const wallet = await manager.signUp({ mnemonic, kind: 'mnemonic', noGuard: true }) + expect(wallet).toBeDefined() + + // Clear the storage without logging out + await manager.stop() + + manager = newManager(undefined, undefined, 'device-2') + expect(manager.listWallets()).resolves.toEqual([]) + const requestId1 = await manager.login({ wallet: wallet! }) + expect(requestId1).toBeDefined() + + let signRequests = 0 + const unregistedUI = manager.registerMnemonicUI(async (respond) => { + signRequests++ + await respond(mnemonic) + }) + + const request = await manager.getSignatureRequest(requestId1!) + expect(request).toBeDefined() + expect(request.action).toBe('login') + + const mnemonicSigner = request.signers.find((signer) => signer.handler?.kind === 'login-mnemonic') + expect(mnemonicSigner).toBeDefined() + expect(mnemonicSigner?.status).toBe('actionable') + + const result = await (mnemonicSigner as SignerActionable).handle() + expect(result).toBe(true) + + expect(signRequests).toBe(1) + unregistedUI() + + // Complete the login process + await manager.completeLogin(requestId1!) + expect((await manager.getSignatureRequest(requestId1!))?.status).toBe('completed') + const wallets2 = await manager.listWallets() + expect(wallets2.length).toBe(1) + expect(wallets2[0].address).toBe(wallet!) + + // The wallet should have 2 device keys and 2 recovery keys + const config = await manager.getConfiguration(wallet!) + expect(config.devices.length).toBe(2) + const recovery = await manager.getRecoverySigners(wallet!) + expect(recovery?.length).toBe(2) + }) + + it('Should logout and then login to an existing wallet using the mnemonic signer', async () => { + manager = newManager() + + expect(manager.listWallets()).resolves.toEqual([]) + + const mnemonic = Mnemonic.random(Mnemonic.english) + const wallet = await manager.signUp({ mnemonic, kind: 'mnemonic', noGuard: true }) + expect(wallet).toBeDefined() + + const wallets = await manager.listWallets() + expect(wallets.length).toBe(1) + expect(wallets[0].address).toBe(wallet!) + + const requestId = await manager.logout(wallet!) + expect(requestId).toBeDefined() + + const request = await manager.getSignatureRequest(requestId) + expect(request).toBeDefined() + expect(request.action).toBe('logout') + + const deviceSigner = request.signers.find((signer) => signer.handler?.kind === 'local-device') + expect(deviceSigner).toBeDefined() + expect(deviceSigner?.status).toBe('ready') + + const result = await (deviceSigner as SignerReady).handle() + expect(result).toBe(true) + + await manager.completeLogout(requestId) + expect((await manager.getSignatureRequest(requestId))?.status).toBe('completed') + + expect(manager.listWallets()).resolves.toEqual([]) + + // Login again to the same wallet + const requestId2 = await manager.login({ wallet: wallet! }) + expect(requestId2).toBeDefined() + + let signRequests2 = 0 + const unregistedUI2 = manager.registerMnemonicUI(async (respond) => { + signRequests2++ + await respond(mnemonic) + }) + + const request2 = await manager.getSignatureRequest(requestId2!) + expect(request2).toBeDefined() + expect(request2.action).toBe('login') + + const mnemonicSigner2 = request2.signers.find((signer) => signer.handler?.kind === 'login-mnemonic') + expect(mnemonicSigner2).toBeDefined() + expect(mnemonicSigner2?.status).toBe('actionable') + + const result2 = await (mnemonicSigner2 as SignerActionable).handle() + expect(result2).toBe(true) + + expect(signRequests2).toBe(1) + unregistedUI2() + + await manager.completeLogin(requestId2!) + expect((await manager.getSignatureRequest(requestId2!))?.status).toBe('completed') + const wallets3 = await manager.listWallets() + expect(wallets3.length).toBe(1) + expect(wallets3[0].address).toBe(wallet!) + + // The wallet should have a single device key and a single recovery key + const config = await manager.getConfiguration(wallet!) + expect(config.devices.length).toBe(1) + const recovery = await manager.getRecoverySigners(wallet!) + expect(recovery?.length).toBe(1) + + // The kind of the device key should be 'local-device' + expect(config.devices[0].kind).toBe('local-device') + + // The kind of the recovery key should be 'local-recovery' + expect(recovery?.[0].kind).toBe('local-device') + }) +})