Skip to content

Fix witness during login and wallet tests #749

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 6 commits into from
May 7, 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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
11 changes: 11 additions & 0 deletions packages/wallet/wdk/src/dbs/generic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,4 +180,15 @@ export class Generic<T extends { [P in K]: IDBValidKey }, K extends keyof T> {
removeListener(listener: DbUpdateListener<T, K>): void {
this.listeners = this.listeners.filter((l) => l !== listener)
}

public async close(): Promise<void> {
if (this._db) {
this._db.close()
this._db = null
}
if (this.broadcastChannel) {
this.broadcastChannel.close()
this.broadcastChannel = undefined
}
}
}
128 changes: 88 additions & 40 deletions packages/wallet/wdk/src/sequence/cron.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,79 +7,127 @@ interface CronJob {
handler: () => Promise<void>
}

// Manages scheduled jobs across multiple tabs
export class Cron {
private jobs: Map<string, CronJob> = new Map()
private checkInterval?: ReturnType<typeof setInterval>
private readonly STORAGE_KEY = 'sequence-cron-jobs'
private isStopping: boolean = false
private currentCheckJobsPromise: Promise<void> = 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<void> {
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<void>) {
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<void> {
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<Map<string, { lastRun: number }>> {
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))
}
Expand Down
7 changes: 4 additions & 3 deletions packages/wallet/wdk/src/sequence/handlers/mnemonic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>

Expand Down Expand Up @@ -42,7 +42,7 @@ export class MnemonicHandler implements Handler {
address: Address.Address,
_imageHash: Hex.Hex | undefined,
request: BaseSignatureRequest,
): Promise<SignerUnavailable | SignerReady> {
): Promise<SignerUnavailable | SignerActionable> {
const onPromptMnemonic = this.onPromptMnemonic
if (!onPromptMnemonic) {
return {
Expand All @@ -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) => {
Expand Down
19 changes: 17 additions & 2 deletions packages/wallet/wdk/src/sequence/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(),
])
}
}
3 changes: 3 additions & 0 deletions packages/wallet/wdk/src/sequence/wallets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
50 changes: 50 additions & 0 deletions packages/wallet/wdk/test/constants.ts
Original file line number Diff line number Diff line change
@@ -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 })
Expand All @@ -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,
})
}
18 changes: 2 additions & 16 deletions packages/wallet/wdk/test/recovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading