Skip to content

Commit 4b7067a

Browse files
authored
Merge pull request #749 from 0xsequence/test-wdk
Fix witness during login and wallet tests
2 parents af7a352 + f2c62f2 commit 4b7067a

File tree

12 files changed

+406
-82
lines changed

12 files changed

+406
-82
lines changed

README.md

+2
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ Sequence v3 core libraries and [wallet-contracts-v3](https://github.com/0xsequen
3030
- Run tests:
3131
`pnpm test`
3232

33+
> **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`.
34+
3335
- Linting and formatting is enforced via git hooks
3436

3537
## License

package.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@
1313
"typecheck": "turbo typecheck",
1414
"postinstall": "lefthook install",
1515
"dev:server": "node packages/wallet/primitives-cli/dist/index.js server",
16-
"reinstall": "rimraf -g ./**/node_modules && pnpm install"
16+
"reinstall": "rimraf -g ./**/node_modules && pnpm install",
17+
"test:anvil": "anvil --fork-url https://nodes.sequence.app/arbitrum"
1718
},
1819
"devDependencies": {
1920
"@changesets/cli": "^2.29.0",

packages/wallet/wdk/src/dbs/generic.ts

+11
Original file line numberDiff line numberDiff line change
@@ -180,4 +180,15 @@ export class Generic<T extends { [P in K]: IDBValidKey }, K extends keyof T> {
180180
removeListener(listener: DbUpdateListener<T, K>): void {
181181
this.listeners = this.listeners.filter((l) => l !== listener)
182182
}
183+
184+
public async close(): Promise<void> {
185+
if (this._db) {
186+
this._db.close()
187+
this._db = null
188+
}
189+
if (this.broadcastChannel) {
190+
this.broadcastChannel.close()
191+
this.broadcastChannel = undefined
192+
}
193+
}
183194
}

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

+88-40
Original file line numberDiff line numberDiff line change
@@ -7,79 +7,127 @@ interface CronJob {
77
handler: () => Promise<void>
88
}
99

10-
// Manages scheduled jobs across multiple tabs
1110
export class Cron {
1211
private jobs: Map<string, CronJob> = new Map()
1312
private checkInterval?: ReturnType<typeof setInterval>
1413
private readonly STORAGE_KEY = 'sequence-cron-jobs'
14+
private isStopping: boolean = false
15+
private currentCheckJobsPromise: Promise<void> = Promise.resolve()
1516

1617
constructor(private readonly shared: Shared) {
1718
this.start()
1819
}
1920

2021
private start() {
21-
// Check every minute
22-
this.checkInterval = setInterval(() => this.checkJobs(), 60 * 1000)
23-
this.checkJobs()
22+
if (this.isStopping) return
23+
this.executeCheckJobsChain()
24+
this.checkInterval = setInterval(() => this.executeCheckJobsChain(), 60 * 1000)
25+
}
26+
27+
// Wraps checkJobs to chain executions and manage currentCheckJobsPromise
28+
private executeCheckJobsChain(): void {
29+
this.currentCheckJobsPromise = this.currentCheckJobsPromise
30+
.catch(() => {}) // Ignore errors from previous chain link for sequencing
31+
.then(() => {
32+
if (!this.isStopping) {
33+
return this.checkJobs()
34+
}
35+
return Promise.resolve()
36+
})
37+
}
38+
39+
public async stop(): Promise<void> {
40+
this.isStopping = true
41+
42+
if (this.checkInterval) {
43+
clearInterval(this.checkInterval)
44+
this.checkInterval = undefined
45+
this.shared.modules.logger.log('Cron: Interval cleared.')
46+
}
47+
48+
// Wait for the promise of the last (or current) checkJobs execution
49+
await this.currentCheckJobsPromise.catch((err) => {
50+
console.error('Cron: Error during currentCheckJobsPromise settlement in stop():', err)
51+
})
2452
}
2553

26-
// Register a new job with a unique ID and interval in milliseconds
2754
registerJob(id: string, interval: number, handler: () => Promise<void>) {
2855
if (this.jobs.has(id)) {
2956
throw new Error(`Job with ID ${id} already exists`)
3057
}
31-
32-
const job: CronJob = {
33-
id,
34-
interval,
35-
lastRun: 0,
36-
handler,
37-
}
38-
58+
const job: CronJob = { id, interval, lastRun: 0, handler }
3959
this.jobs.set(id, job)
40-
this.syncWithStorage()
60+
// No syncWithStorage needed here, it happens in checkJobs
4161
}
4262

43-
// Unregister a job by ID
4463
unregisterJob(id: string) {
45-
if (this.jobs.delete(id)) {
46-
this.syncWithStorage()
47-
}
64+
this.jobs.delete(id)
4865
}
4966

50-
private async checkJobs() {
51-
await navigator.locks.request('sequence-cron-jobs', async (lock: Lock | null) => {
52-
if (!lock) return
53-
54-
const now = Date.now()
55-
const storage = await this.getStorageState()
56-
57-
for (const [id, job] of this.jobs) {
58-
const lastRun = storage.get(id)?.lastRun ?? job.lastRun
59-
const timeSinceLastRun = now - lastRun
60-
61-
if (timeSinceLastRun >= job.interval) {
62-
try {
63-
await job.handler()
64-
job.lastRun = now
65-
storage.set(id, { lastRun: now })
66-
} catch (error) {
67-
console.error(`Cron job ${id} failed:`, error)
68-
// Continue with other jobs even if this one failed
67+
private async checkJobs(): Promise<void> {
68+
if (this.isStopping) {
69+
return
70+
}
71+
72+
try {
73+
await navigator.locks.request('sequence-cron-jobs', async (lock: Lock | null) => {
74+
if (this.isStopping) {
75+
return
76+
}
77+
if (!lock) {
78+
return
79+
}
80+
81+
const now = Date.now()
82+
const storage = await this.getStorageState()
83+
84+
for (const [id, job] of this.jobs) {
85+
if (this.isStopping) {
86+
break
87+
}
88+
89+
const lastRun = storage.get(id)?.lastRun ?? job.lastRun
90+
const timeSinceLastRun = now - lastRun
91+
92+
if (timeSinceLastRun >= job.interval) {
93+
try {
94+
await job.handler()
95+
if (!this.isStopping) {
96+
job.lastRun = now
97+
storage.set(id, { lastRun: now })
98+
} else {
99+
}
100+
} catch (error) {
101+
if (error instanceof DOMException && error.name === 'AbortError') {
102+
this.shared.modules.logger.log(`Cron: Job ${id} was aborted.`)
103+
} else {
104+
console.error(`Cron job ${id} failed:`, error)
105+
}
106+
}
69107
}
70108
}
71-
}
72109

73-
await this.syncWithStorage()
74-
})
110+
if (!this.isStopping) {
111+
await this.syncWithStorage()
112+
}
113+
})
114+
} catch (error) {
115+
if (error instanceof DOMException && error.name === 'AbortError') {
116+
this.shared.modules.logger.log('Cron: navigator.locks.request was aborted.')
117+
} else {
118+
console.error('Cron: Error in navigator.locks.request:', error)
119+
}
120+
}
75121
}
76122

77123
private async getStorageState(): Promise<Map<string, { lastRun: number }>> {
124+
if (this.isStopping) return new Map()
78125
const state = localStorage.getItem(this.STORAGE_KEY)
79126
return new Map(state ? JSON.parse(state) : [])
80127
}
81128

82129
private async syncWithStorage() {
130+
if (this.isStopping) return
83131
const state = Array.from(this.jobs.entries()).map(([id, job]) => [id, { lastRun: job.lastRun }])
84132
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(state))
85133
}

packages/wallet/wdk/src/sequence/handlers/mnemonic.ts

+4-3
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { Address, Hex, Mnemonic } from 'ox'
33
import { Handler } from './handler.js'
44
import { Signatures } from '../signatures.js'
55
import { Kinds } from '../types/signer.js'
6-
import { SignerReady, SignerUnavailable, BaseSignatureRequest } from '../types/index.js'
6+
import { SignerReady, SignerUnavailable, BaseSignatureRequest, SignerActionable } from '../types/index.js'
77

88
type RespondFn = (mnemonic: string) => Promise<void>
99

@@ -42,7 +42,7 @@ export class MnemonicHandler implements Handler {
4242
address: Address.Address,
4343
_imageHash: Hex.Hex | undefined,
4444
request: BaseSignatureRequest,
45-
): Promise<SignerUnavailable | SignerReady> {
45+
): Promise<SignerUnavailable | SignerActionable> {
4646
const onPromptMnemonic = this.onPromptMnemonic
4747
if (!onPromptMnemonic) {
4848
return {
@@ -56,7 +56,8 @@ export class MnemonicHandler implements Handler {
5656
return {
5757
address,
5858
handler: this,
59-
status: 'ready',
59+
status: 'actionable',
60+
message: 'enter-mnemonic',
6061
handle: () =>
6162
new Promise(async (resolve, reject) => {
6263
const respond = async (mnemonic: string) => {

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

+17-2
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ export const ManagerOptionsDefaults = {
9696

9797
stateProvider: new State.Local.Provider(new State.Local.IndexedDbStore()),
9898
networks: Network.All,
99-
relayers: [Relayer.Local.LocalRelayer.createFromWindow(window)].filter((r) => r !== undefined),
99+
relayers: () => [Relayer.Local.LocalRelayer.createFromWindow(window)].filter((r) => r !== undefined),
100100

101101
defaultGuardTopology: {
102102
// TODO: Move this somewhere else
@@ -225,7 +225,7 @@ export class Manager {
225225

226226
stateProvider: ops.stateProvider,
227227
networks: ops.networks,
228-
relayers: ops.relayers,
228+
relayers: typeof ops.relayers === 'function' ? ops.relayers() : ops.relayers,
229229

230230
defaultGuardTopology: ops.defaultGuardTopology,
231231
defaultSessionsTopology: ops.defaultSessionsTopology,
@@ -552,4 +552,19 @@ export class Manager {
552552
public async updateQueuedRecoveryPayloads() {
553553
return this.shared.modules.recovery.updateQueuedRecoveryPayloads()
554554
}
555+
556+
// DBs
557+
558+
public async stop() {
559+
await this.shared.modules.cron.stop()
560+
561+
await Promise.all([
562+
this.shared.databases.authKeys.close(),
563+
this.shared.databases.authCommitments.close(),
564+
this.shared.databases.manager.close(),
565+
this.shared.databases.recovery.close(),
566+
this.shared.databases.signatures.close(),
567+
this.shared.databases.transactions.close(),
568+
])
569+
}
555570
}

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

+3
Original file line numberDiff line numberDiff line change
@@ -572,6 +572,9 @@ export class Wallets {
572572
const device = await this.shared.modules.devices.create()
573573
const { devicesTopology, modules, guardTopology } = await this.getConfigurationParts(args.wallet)
574574

575+
// Witness the wallet
576+
await this.shared.modules.devices.witness(device.address, args.wallet)
577+
575578
// Add device to devices topology
576579
const prevDevices = Config.getSigners(devicesTopology)
577580
if (prevDevices.sapientSigners.length > 0) {

packages/wallet/wdk/test/constants.ts

+50
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { config as dotenvConfig } from 'dotenv'
22
import { Abi, Address } from 'ox'
3+
import { Manager, ManagerOptions, ManagerOptionsDefaults } from '../src/sequence'
4+
import { mockEthereum } from './setup'
5+
import { Signers as CoreSigners } from '@0xsequence/wallet-core'
6+
import * as Db from '../src/dbs'
37

48
const envFile = process.env.CI ? '.env.test' : '.env.test.local'
59
dotenvConfig({ path: envFile })
@@ -10,3 +14,49 @@ export const EMITTER_ABI = Abi.from(['function explicitEmit()', 'function implic
1014
// Environment variables
1115
export const { RPC_URL, PRIVATE_KEY } = process.env
1216
export const CAN_RUN_LIVE = !!RPC_URL && !!PRIVATE_KEY
17+
export const LOCAL_RPC_URL = process.env.LOCAL_RPC_URL || 'http://localhost:8545'
18+
19+
let testIdCounter = 0
20+
21+
export function newManager(options?: ManagerOptions, noEthereumMock?: boolean, tag?: string) {
22+
if (!noEthereumMock) {
23+
mockEthereum()
24+
}
25+
26+
testIdCounter++
27+
const dbSuffix = tag ? `_${tag}_testrun_${testIdCounter}` : `_testrun_${testIdCounter}`
28+
29+
// Ensure options and its identity sub-object exist for easier merging
30+
const effectiveOptions = {
31+
...options,
32+
identity: { ...ManagerOptionsDefaults.identity, ...options?.identity },
33+
}
34+
35+
return new Manager({
36+
networks: [
37+
{
38+
name: 'Arbitrum (local fork)',
39+
rpc: LOCAL_RPC_URL,
40+
chainId: 42161n,
41+
explorer: 'https://arbiscan.io/',
42+
nativeCurrency: {
43+
name: 'Ether',
44+
symbol: 'ETH',
45+
decimals: 18,
46+
},
47+
},
48+
],
49+
// Override DBs with unique names if not provided in options,
50+
// otherwise, use the provided DB instance.
51+
// This assumes options?.someDb is either undefined or a fully constructed DB instance.
52+
encryptedPksDb: effectiveOptions.encryptedPksDb || new CoreSigners.Pk.Encrypted.EncryptedPksDb('pk-db' + dbSuffix),
53+
managerDb: effectiveOptions.managerDb || new Db.Wallets('sequence-manager' + dbSuffix),
54+
transactionsDb: effectiveOptions.transactionsDb || new Db.Transactions('sequence-transactions' + dbSuffix),
55+
signaturesDb: effectiveOptions.signaturesDb || new Db.Signatures('sequence-signature-requests' + dbSuffix),
56+
authCommitmentsDb:
57+
effectiveOptions.authCommitmentsDb || new Db.AuthCommitments('sequence-auth-commitments' + dbSuffix),
58+
authKeysDb: effectiveOptions.authKeysDb || new Db.AuthKeys('sequence-auth-keys' + dbSuffix),
59+
recoveryDb: effectiveOptions.recoveryDb || new Db.Recovery('sequence-recovery' + dbSuffix),
60+
...effectiveOptions,
61+
})
62+
}

packages/wallet/wdk/test/recovery.test.ts

+2-16
Original file line numberDiff line numberDiff line change
@@ -2,29 +2,15 @@ import { describe, expect, it } from 'vitest'
22
import { Manager, QueuedRecoveryPayload, SignerReady, TransactionDefined } from '../src/sequence'
33
import { Bytes, Hex, Mnemonic, Provider, RpcTransport } from 'ox'
44
import { Payload } from '@0xsequence/wallet-primitives'
5-
6-
const LOCAL_RPC_URL = 'http://localhost:8545'
5+
import { LOCAL_RPC_URL, newManager } from './constants'
76

87
describe('Recovery', () => {
98
it('Should execute a recovery', async () => {
10-
const manager = new Manager({
9+
const manager = newManager({
1110
defaultRecoverySettings: {
1211
requiredDeltaTime: 2n, // 2 seconds
1312
minTimestamp: 0n,
1413
},
15-
networks: [
16-
{
17-
name: 'Arbitrum (local fork)',
18-
rpc: LOCAL_RPC_URL,
19-
chainId: 42161n,
20-
explorer: 'https://arbiscan.io/',
21-
nativeCurrency: {
22-
name: 'Ether',
23-
symbol: 'ETH',
24-
decimals: 18,
25-
},
26-
},
27-
],
2814
})
2915

3016
const mnemonic = Mnemonic.random(Mnemonic.english)

0 commit comments

Comments
 (0)