From 939e50ac44ba862c4f2ef6d2ed432dbdfaa4a749 Mon Sep 17 00:00:00 2001 From: Tolgahan Date: Tue, 3 Feb 2026 19:19:52 +0300 Subject: [PATCH 1/2] Add env injection for core and wdk --- .../core/src/bundler/bundlers/pimlico.ts | 10 +- packages/wallet/core/src/env.ts | 68 +++++++ packages/wallet/core/src/index.ts | 1 + packages/wallet/core/src/signers/passkey.ts | 24 ++- .../wallet/core/src/signers/pk/encrypted.ts | 117 ++++++++++-- .../wallet/core/src/state/local/indexed-db.ts | 17 +- .../wallet/core/src/state/remote/dev-http.ts | 10 +- .../wallet/core/src/state/sequence/index.ts | 10 +- packages/wallet/wdk/src/dbs/auth-keys.ts | 19 +- packages/wallet/wdk/src/env.ts | 58 ++++++ packages/wallet/wdk/src/identity/signer.ts | 14 +- packages/wallet/wdk/src/index.ts | 1 + packages/wallet/wdk/src/sequence/cron.ts | 117 +++++++----- .../src/sequence/handlers/authcode-pkce.ts | 8 +- .../wdk/src/sequence/handlers/authcode.ts | 31 ++- .../wdk/src/sequence/handlers/identity.ts | 24 ++- .../wallet/wdk/src/sequence/handlers/otp.ts | 5 +- .../wdk/src/sequence/handlers/passkeys.ts | 24 +-- packages/wallet/wdk/src/sequence/index.ts | 2 + packages/wallet/wdk/src/sequence/manager.ts | 178 ++++++++++++++++-- .../wdk/src/sequence/passkeys-provider.ts | 55 ++++++ packages/wallet/wdk/src/sequence/wallets.ts | 25 ++- 22 files changed, 694 insertions(+), 124 deletions(-) create mode 100644 packages/wallet/core/src/env.ts create mode 100644 packages/wallet/wdk/src/env.ts create mode 100644 packages/wallet/wdk/src/sequence/passkeys-provider.ts diff --git a/packages/wallet/core/src/bundler/bundlers/pimlico.ts b/packages/wallet/core/src/bundler/bundlers/pimlico.ts index e2d95ec331..cc28079a68 100644 --- a/packages/wallet/core/src/bundler/bundlers/pimlico.ts +++ b/packages/wallet/core/src/bundler/bundlers/pimlico.ts @@ -21,11 +21,17 @@ export class PimlicoBundler implements Bundler { public readonly provider: Provider.Provider public readonly bundlerRpcUrl: string + private readonly fetcher: typeof fetch - constructor(bundlerRpcUrl: string, provider: Provider.Provider | string) { + constructor(bundlerRpcUrl: string, provider: Provider.Provider | string, fetcher?: typeof fetch) { this.id = `pimlico-erc4337-${bundlerRpcUrl}` this.provider = typeof provider === 'string' ? Provider.from(RpcTransport.fromHttp(provider)) : provider this.bundlerRpcUrl = bundlerRpcUrl + const resolvedFetch = fetcher ?? (globalThis as any).fetch + if (!resolvedFetch) { + throw new Error('fetch is not available') + } + this.fetcher = resolvedFetch } async isAvailable(entrypoint: Address.Address, chainId: number): Promise { @@ -165,7 +171,7 @@ export class PimlicoBundler implements Bundler { private async bundlerRpc(method: string, params: any[]): Promise { const body = JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }) - const res = await fetch(this.bundlerRpcUrl, { + const res = await this.fetcher(this.bundlerRpcUrl, { method: 'POST', headers: { 'content-type': 'application/json' }, body, diff --git a/packages/wallet/core/src/env.ts b/packages/wallet/core/src/env.ts new file mode 100644 index 0000000000..0a463dd1d8 --- /dev/null +++ b/packages/wallet/core/src/env.ts @@ -0,0 +1,68 @@ +export type StorageLike = { + getItem: (key: string) => string | null + setItem: (key: string, value: string) => void + removeItem: (key: string) => void +} + +export type CryptoLike = { + subtle: SubtleCrypto + getRandomValues: (array: T) => T +} + +export type TextEncodingLike = { + TextEncoder: typeof TextEncoder + TextDecoder: typeof TextDecoder +} + +export type CoreEnv = { + fetch?: typeof fetch + crypto?: CryptoLike + storage?: StorageLike + indexedDB?: IDBFactory + text?: Partial +} + +function isStorageLike(value: unknown): value is StorageLike { + if (!value || typeof value !== 'object') return false + const candidate = value as StorageLike + return ( + typeof candidate.getItem === 'function' && + typeof candidate.setItem === 'function' && + typeof candidate.removeItem === 'function' + ) +} + +export function resolveCoreEnv(env?: CoreEnv): CoreEnv { + const globalObj = globalThis as any + const windowObj = typeof window !== 'undefined' ? window : (globalObj.window ?? {}) + let storage: StorageLike | undefined + let text: Partial | undefined + + if (isStorageLike(env?.storage)) { + storage = env.storage + } else if (isStorageLike(windowObj.localStorage)) { + storage = windowObj.localStorage + } else if (isStorageLike(globalObj.localStorage)) { + storage = globalObj.localStorage + } + + if (env?.text) { + if (!env.text.TextEncoder || !env.text.TextDecoder) { + throw new Error('env.text must provide both TextEncoder and TextDecoder') + } + text = env.text + } else { + text = { + TextEncoder: windowObj.TextEncoder ?? globalObj.TextEncoder, + TextDecoder: windowObj.TextDecoder ?? globalObj.TextDecoder, + } + } + + return { + fetch: env?.fetch ?? windowObj.fetch ?? globalObj.fetch, + crypto: env?.crypto ?? windowObj.crypto ?? globalObj.crypto, + storage, + indexedDB: env?.indexedDB ?? windowObj.indexedDB ?? globalObj.indexedDB, + text, + } +} diff --git a/packages/wallet/core/src/index.ts b/packages/wallet/core/src/index.ts index b36e917cae..27c54b8f53 100644 --- a/packages/wallet/core/src/index.ts +++ b/packages/wallet/core/src/index.ts @@ -5,6 +5,7 @@ export * as State from './state/index.js' export * as Bundler from './bundler/index.js' export * as Envelope from './envelope.js' export * as Utils from './utils/index.js' +export * from './env.js' export { type ExplicitSessionConfig, type ExplicitSession, diff --git a/packages/wallet/core/src/signers/passkey.ts b/packages/wallet/core/src/signers/passkey.ts index 0cc7cacaba..62c6d18862 100644 --- a/packages/wallet/core/src/signers/passkey.ts +++ b/packages/wallet/core/src/signers/passkey.ts @@ -5,12 +5,15 @@ import { WebAuthnP256 } from 'ox' import { State } from '../index.js' import { SapientSigner, Witnessable } from './index.js' +export type WebAuthnLike = Pick + export type PasskeyOptions = { extensions: Pick publicKey: Extensions.Passkeys.PublicKey credentialId: string embedMetadata?: boolean metadata?: Extensions.Passkeys.PasskeyMetadata + webauthn?: WebAuthnLike } export type CreatePasskeyOptions = { @@ -18,6 +21,11 @@ export type CreatePasskeyOptions = { requireUserVerification?: boolean credentialName?: string embedMetadata?: boolean + webauthn?: WebAuthnLike +} + +export type FindPasskeyOptions = { + webauthn?: WebAuthnLike } export type WitnessMessage = { @@ -45,6 +53,7 @@ export class Passkey implements SapientSigner, Witnessable { public readonly imageHash: Hex.Hex public readonly embedMetadata: boolean public readonly metadata?: Extensions.Passkeys.PasskeyMetadata + private readonly webauthn: WebAuthnLike constructor(options: PasskeyOptions) { this.address = options.extensions.passkeys @@ -53,6 +62,7 @@ export class Passkey implements SapientSigner, Witnessable { this.embedMetadata = options.embedMetadata ?? false this.imageHash = Extensions.Passkeys.rootFor(options.publicKey) this.metadata = options.metadata + this.webauthn = options.webauthn ?? WebAuthnP256 } static async loadFromWitness( @@ -60,6 +70,7 @@ export class Passkey implements SapientSigner, Witnessable { extensions: Pick, wallet: Address.Address, imageHash: Hex.Hex, + options?: FindPasskeyOptions, ) { // In the witness we will find the public key, and may find the credential id const witness = await stateReader.getWitnessForSapient(wallet, extensions.passkeys, imageHash) @@ -90,13 +101,15 @@ export class Passkey implements SapientSigner, Witnessable { publicKey: message.publicKey, embedMetadata: decodedSignature.embedMetadata, metadata, + webauthn: options?.webauthn, }) } static async create(extensions: Pick, options?: CreatePasskeyOptions) { + const webauthn = options?.webauthn ?? WebAuthnP256 const name = options?.credentialName ?? `Sequence (${Date.now()})` - const credential = await WebAuthnP256.createCredential({ + const credential = await webauthn.createCredential({ user: { name, }, @@ -120,6 +133,7 @@ export class Passkey implements SapientSigner, Witnessable { }, embedMetadata: options?.embedMetadata, metadata, + webauthn, }) if (options?.stateProvider) { @@ -132,8 +146,10 @@ export class Passkey implements SapientSigner, Witnessable { static async find( stateReader: State.Reader, extensions: Pick, + options?: FindPasskeyOptions, ): Promise { - const response = await WebAuthnP256.sign({ challenge: Hex.random(32) }) + const webauthn = options?.webauthn ?? WebAuthnP256 + const response = await webauthn.sign({ challenge: Hex.random(32) }) if (!response.raw) throw new Error('No credential returned') const authenticatorDataBytes = Bytes.fromHex(response.metadata.authenticatorData) @@ -218,7 +234,7 @@ export class Passkey implements SapientSigner, Witnessable { console.warn('Multiple signers found for passkey', flattened) } - return Passkey.loadFromWitness(stateReader, extensions, flattened[0]!.wallet, flattened[0]!.imageHash) + return Passkey.loadFromWitness(stateReader, extensions, flattened[0]!.wallet, flattened[0]!.imageHash, options) } async signSapient( @@ -234,7 +250,7 @@ export class Passkey implements SapientSigner, Witnessable { const challenge = Hex.fromBytes(Payload.hash(wallet, chainId, payload)) - const response = await WebAuthnP256.sign({ + const response = await this.webauthn.sign({ challenge, credentialId: this.credentialId, userVerification: this.publicKey.requireUserVerification ? 'required' : 'discouraged', diff --git a/packages/wallet/core/src/signers/pk/encrypted.ts b/packages/wallet/core/src/signers/pk/encrypted.ts index c75bc57f93..dce0eb3cc7 100644 --- a/packages/wallet/core/src/signers/pk/encrypted.ts +++ b/packages/wallet/core/src/signers/pk/encrypted.ts @@ -1,4 +1,5 @@ import { Hex, Address, PublicKey, Secp256k1, Bytes } from 'ox' +import { resolveCoreEnv, type CoreEnv, type CryptoLike, type StorageLike, type TextEncodingLike } from '../../env.js' import { PkStore } from './index.js' export interface EncryptedData { @@ -17,6 +18,7 @@ export class EncryptedPksDb { constructor( private readonly localStorageKeyPrefix: string = 'e_pk_key_', tableName: string = 'e_pk', + private readonly env?: CoreEnv, ) { this.tableName = tableName } @@ -25,9 +27,59 @@ export class EncryptedPksDb { return `pk_${address.toLowerCase()}` } + private getIndexedDB(): IDBFactory { + const globalObj = globalThis as any + const indexedDb = this.env?.indexedDB ?? globalObj.indexedDB ?? globalObj.window?.indexedDB + if (!indexedDb) { + throw new Error('indexedDB is not available') + } + return indexedDb + } + + private getStorage(): StorageLike { + const storage = resolveCoreEnv(this.env).storage + if (!storage) { + throw new Error('storage is not available') + } + return storage + } + + private getCrypto(): CryptoLike { + const globalObj = globalThis as any + const crypto = this.env?.crypto ?? globalObj.crypto ?? globalObj.window?.crypto + if (!crypto?.subtle || !crypto?.getRandomValues) { + throw new Error('crypto.subtle is not available') + } + return crypto + } + + private getTextEncoderCtor(): TextEncodingLike['TextEncoder'] { + const globalObj = globalThis as any + if (this.env?.text && (!this.env.text.TextEncoder || !this.env.text.TextDecoder)) { + throw new Error('env.text must provide both TextEncoder and TextDecoder') + } + const encoderCtor = this.env?.text?.TextEncoder ?? globalObj.TextEncoder ?? globalObj.window?.TextEncoder + if (!encoderCtor) { + throw new Error('TextEncoder is not available') + } + return encoderCtor + } + + private getTextDecoderCtor(): TextEncodingLike['TextDecoder'] { + const globalObj = globalThis as any + if (this.env?.text && (!this.env.text.TextEncoder || !this.env.text.TextDecoder)) { + throw new Error('env.text must provide both TextEncoder and TextDecoder') + } + const decoderCtor = this.env?.text?.TextDecoder ?? globalObj.TextDecoder ?? globalObj.window?.TextDecoder + if (!decoderCtor) { + throw new Error('TextDecoder is not available') + } + return decoderCtor + } + private openDB(): Promise { return new Promise((resolve, reject) => { - const request = indexedDB.open(this.dbName, this.dbVersion) + const request = this.getIndexedDB().open(this.dbName, this.dbVersion) request.onupgradeneeded = () => { const db = request.result if (!db.objectStoreNames.contains(this.tableName)) { @@ -73,7 +125,11 @@ export class EncryptedPksDb { } async generateAndStore(): Promise { - const encryptionKey = await window.crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, [ + const crypto = this.getCrypto() + const storage = this.getStorage() + const TextEncoderCtor = this.getTextEncoderCtor() + + const encryptionKey = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, [ 'encrypt', 'decrypt', ]) @@ -84,13 +140,13 @@ export class EncryptedPksDb { const address = Address.fromPublicKey(publicKey) const keyPointer = this.localStorageKeyPrefix + address - const exportedKey = await window.crypto.subtle.exportKey('jwk', encryptionKey) - window.localStorage.setItem(keyPointer, JSON.stringify(exportedKey)) + const exportedKey = await crypto.subtle.exportKey('jwk', encryptionKey) + storage.setItem(keyPointer, JSON.stringify(exportedKey)) - const encoder = new TextEncoder() + const encoder = new TextEncoderCtor() const encodedPk = encoder.encode(privateKey) - const iv = window.crypto.getRandomValues(new Uint8Array(12)) - const encryptedBuffer = await window.crypto.subtle.encrypt({ name: 'AES-GCM', iv }, encryptionKey, encodedPk) + const iv = crypto.getRandomValues(new Uint8Array(12)) + const encryptedBuffer = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, encryptionKey, encodedPk) const encrypted: EncryptedData = { iv, @@ -113,7 +169,7 @@ export class EncryptedPksDb { async getEncryptedPkStore(address: Address.Address): Promise { const entry = await this.getEncryptedEntry(address) if (!entry) return - return new EncryptedPkStore(entry) + return new EncryptedPkStore(entry, this.env) } async listAddresses(): Promise { @@ -125,12 +181,41 @@ export class EncryptedPksDb { const dbKey = this.computeDbKey(address) await this.putData(dbKey, undefined) const keyPointer = this.localStorageKeyPrefix + address - window.localStorage.removeItem(keyPointer) + this.getStorage().removeItem(keyPointer) } } export class EncryptedPkStore implements PkStore { - constructor(private readonly encrypted: EncryptedData) {} + constructor( + private readonly encrypted: EncryptedData, + private readonly env?: CoreEnv, + ) {} + + private getStorage(): StorageLike { + const storage = resolveCoreEnv(this.env).storage + if (!storage) { + throw new Error('storage is not available') + } + return storage + } + + private getCrypto(): CryptoLike { + const globalObj = globalThis as any + const crypto = this.env?.crypto ?? globalObj.crypto ?? globalObj.window?.crypto + if (!crypto?.subtle) { + throw new Error('crypto.subtle is not available') + } + return crypto + } + + private getTextDecoderCtor(): TextEncodingLike['TextDecoder'] { + const globalObj = globalThis as any + const decoderCtor = this.env?.text?.TextDecoder ?? globalObj.TextDecoder ?? globalObj.window?.TextDecoder + if (!decoderCtor) { + throw new Error('TextDecoder is not available') + } + return decoderCtor + } address(): Address.Address { return this.encrypted.address @@ -141,16 +226,20 @@ export class EncryptedPkStore implements PkStore { } async signDigest(digest: Bytes.Bytes): Promise<{ r: bigint; s: bigint; yParity: number }> { - const keyJson = window.localStorage.getItem(this.encrypted.keyPointer) + const storage = this.getStorage() + const crypto = this.getCrypto() + const TextDecoderCtor = this.getTextDecoderCtor() + + const keyJson = storage.getItem(this.encrypted.keyPointer) if (!keyJson) throw new Error('Encryption key not found in localStorage') const jwk = JSON.parse(keyJson) - const encryptionKey = await window.crypto.subtle.importKey('jwk', jwk, { name: 'AES-GCM' }, false, ['decrypt']) - const decryptedBuffer = await window.crypto.subtle.decrypt( + const encryptionKey = await crypto.subtle.importKey('jwk', jwk, { name: 'AES-GCM' }, false, ['decrypt']) + const decryptedBuffer = await crypto.subtle.decrypt( { name: 'AES-GCM', iv: this.encrypted.iv }, encryptionKey, this.encrypted.data, ) - const decoder = new TextDecoder() + const decoder = new TextDecoderCtor() const privateKey = decoder.decode(decryptedBuffer) as Hex.Hex return Secp256k1.sign({ payload: digest, privateKey }) } diff --git a/packages/wallet/core/src/state/local/indexed-db.ts b/packages/wallet/core/src/state/local/indexed-db.ts index 98a43743c2..eeb5cd346f 100644 --- a/packages/wallet/core/src/state/local/indexed-db.ts +++ b/packages/wallet/core/src/state/local/indexed-db.ts @@ -1,5 +1,6 @@ import { Context, Payload, Signature, Config, GenericTree } from '@0xsequence/wallet-primitives' import { Address, Hex } from 'ox' +import type { CoreEnv } from '../../env.js' import { Store } from './index.js' const DB_VERSION = 1 @@ -16,15 +17,27 @@ export class IndexedDbStore implements Store { private _db: IDBDatabase | null = null private dbName: string - constructor(dbName: string = 'sequence-indexeddb') { + constructor( + dbName: string = 'sequence-indexeddb', + private readonly env?: CoreEnv, + ) { this.dbName = dbName } + private getIndexedDB(): IDBFactory { + const globalObj = globalThis as any + const indexedDb = this.env?.indexedDB ?? globalObj.indexedDB ?? globalObj.window?.indexedDB + if (!indexedDb) { + throw new Error('indexedDB is not available') + } + return indexedDb + } + private async openDB(): Promise { if (this._db) return this._db return new Promise((resolve, reject) => { - const request = indexedDB.open(this.dbName, DB_VERSION) + const request = this.getIndexedDB().open(this.dbName, DB_VERSION) request.onupgradeneeded = () => { const db = request.result diff --git a/packages/wallet/core/src/state/remote/dev-http.ts b/packages/wallet/core/src/state/remote/dev-http.ts index d7fe0f4921..3572637117 100644 --- a/packages/wallet/core/src/state/remote/dev-http.ts +++ b/packages/wallet/core/src/state/remote/dev-http.ts @@ -4,10 +4,16 @@ import { Provider } from '../index.js' export class DevHttpProvider implements Provider { private readonly baseUrl: string + private readonly fetcher: typeof fetch - constructor(baseUrl: string) { + constructor(baseUrl: string, fetcher?: typeof fetch) { // Remove trailing slash if present this.baseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl + const resolvedFetch = fetcher ?? (globalThis as any).fetch + if (!resolvedFetch) { + throw new Error('fetch is not available') + } + this.fetcher = resolvedFetch } private async request(method: 'GET' | 'POST', path: string, body?: any): Promise { @@ -24,7 +30,7 @@ export class DevHttpProvider implements Provider { let response: Response try { - response = await fetch(url, options) + response = await this.fetcher(url, options) } catch (networkError) { // Handle immediate network errors (e.g., DNS resolution failure, refused connection) console.error(`Network error during ${method} request to ${url}:`, networkError) diff --git a/packages/wallet/core/src/state/sequence/index.ts b/packages/wallet/core/src/state/sequence/index.ts index 3712f2aa55..a016e640d7 100644 --- a/packages/wallet/core/src/state/sequence/index.ts +++ b/packages/wallet/core/src/state/sequence/index.ts @@ -9,13 +9,17 @@ import { TransactionRequest, } from 'ox' import { normalizeAddressKeys, Provider as ProviderInterface } from '../index.js' -import { Sessions, SignatureType } from './sessions.gen.js' +import { Sessions, SignatureType, type Fetch } from './sessions.gen.js' export class Provider implements ProviderInterface { private readonly service: Sessions - constructor(host = 'https://keymachine.sequence.app') { - this.service = new Sessions(host, fetch) + constructor(host = 'https://keymachine.sequence.app', fetcher?: Fetch) { + const resolvedFetch = fetcher ?? (globalThis as any).fetch + if (!resolvedFetch) { + throw new Error('fetch is not available') + } + this.service = new Sessions(host, resolvedFetch) } async getConfiguration(imageHash: Hex.Hex): Promise { diff --git a/packages/wallet/wdk/src/dbs/auth-keys.ts b/packages/wallet/wdk/src/dbs/auth-keys.ts index 56cf1ddf9f..a4640d381a 100644 --- a/packages/wallet/wdk/src/dbs/auth-keys.ts +++ b/packages/wallet/wdk/src/dbs/auth-keys.ts @@ -1,5 +1,6 @@ import { Generic, Migration } from './generic.js' import { IDBPDatabase, IDBPTransaction } from 'idb' +import type { WdkEnv } from '../env.js' const TABLE_NAME = 'auth-keys' @@ -11,9 +12,12 @@ export type AuthKey = { } export class AuthKeys extends Generic { - private expirationTimers = new Map() + private expirationTimers = new Map>() - constructor(dbName: string = 'sequence-auth-keys') { + constructor( + dbName: string = 'sequence-auth-keys', + private readonly env?: WdkEnv, + ) { super(dbName, TABLE_NAME, 'address', [ ( db: IDBPDatabase, @@ -108,7 +112,11 @@ export class AuthKeys extends Generic { await this.del(authKey.address.toLowerCase()) return } - const timer = window.setTimeout(() => { + const setTimeoutFn = this.env?.timers?.setTimeout ?? (globalThis as any).setTimeout + if (!setTimeoutFn) { + return + } + const timer = setTimeoutFn(() => { console.log('removing expired auth key', authKey) this.del(authKey.address.toLowerCase()) }, delay) @@ -118,7 +126,10 @@ export class AuthKeys extends Generic { private clearExpiration(address: string): void { const timer = this.expirationTimers.get(address.toLowerCase()) if (timer) { - window.clearTimeout(timer) + const clearTimeoutFn = this.env?.timers?.clearTimeout ?? (globalThis as any).clearTimeout + if (clearTimeoutFn) { + clearTimeoutFn(timer) + } this.expirationTimers.delete(address.toLowerCase()) } } diff --git a/packages/wallet/wdk/src/env.ts b/packages/wallet/wdk/src/env.ts new file mode 100644 index 0000000000..4e3cecb1a5 --- /dev/null +++ b/packages/wallet/wdk/src/env.ts @@ -0,0 +1,58 @@ +import type { CoreEnv } from '@0xsequence/wallet-core' +import { resolveCoreEnv } from '@0xsequence/wallet-core' + +export type TimersLike = { + setTimeout: typeof setTimeout + clearTimeout: typeof clearTimeout + setInterval: typeof setInterval + clearInterval: typeof clearInterval +} + +export type LockManagerLike = { + request: (name: string, callback: (lock: Lock | null) => Promise | void) => Promise +} + +export type NavigationLike = { + getPathname: () => string + redirect: (url: string) => void +} + +export type WdkEnv = CoreEnv & { + timers?: TimersLike + locks?: LockManagerLike + navigation?: NavigationLike + urlSearchParams?: typeof URLSearchParams +} + +export function resolveWdkEnv(env?: WdkEnv): WdkEnv { + const core = resolveCoreEnv(env) + const globalObj = globalThis as any + const windowObj = typeof window !== 'undefined' ? window : (globalObj.window ?? {}) + const location = windowObj.location ?? globalObj.location + + return { + ...core, + timers: + env?.timers ?? + (typeof globalObj.setTimeout === 'function' + ? { + setTimeout: globalObj.setTimeout.bind(globalObj), + clearTimeout: globalObj.clearTimeout.bind(globalObj), + setInterval: globalObj.setInterval.bind(globalObj), + clearInterval: globalObj.clearInterval.bind(globalObj), + } + : undefined), + locks: env?.locks ?? globalObj.navigator?.locks ?? windowObj.navigator?.locks, + navigation: + env?.navigation ?? + (location + ? { + getPathname: () => location.pathname, + redirect: (url: string) => { + location.href = url + }, + } + : undefined), + urlSearchParams: env?.urlSearchParams ?? globalObj.URLSearchParams ?? windowObj.URLSearchParams, + } +} diff --git a/packages/wallet/wdk/src/identity/signer.ts b/packages/wallet/wdk/src/identity/signer.ts index fdc53ca417..0bd7162c9b 100644 --- a/packages/wallet/wdk/src/identity/signer.ts +++ b/packages/wallet/wdk/src/identity/signer.ts @@ -1,17 +1,22 @@ import { Address, Signature, Hex, Bytes, PersonalMessage } from 'ox' -import { Signers, State } from '@0xsequence/wallet-core' +import { Signers, State, type CryptoLike } from '@0xsequence/wallet-core' import { IdentityInstrument, KeyType } from '@0xsequence/identity-instrument' import { AuthKey } from '../dbs/auth-keys.js' import { Payload, Signature as SequenceSignature } from '@0xsequence/wallet-primitives' import * as Identity from '@0xsequence/identity-instrument' -export function toIdentityAuthKey(authKey: AuthKey): Identity.AuthKey { +export function toIdentityAuthKey(authKey: AuthKey, crypto?: CryptoLike): Identity.AuthKey { + const globalObj = globalThis as any + const resolvedCrypto = crypto ?? globalObj.window?.crypto ?? globalObj.crypto + if (!resolvedCrypto?.subtle) { + throw new Error('crypto.subtle is not available') + } return { address: authKey.address, keyType: Identity.KeyType.WebCrypto_Secp256r1, signer: authKey.identitySigner, async sign(digest: Bytes.Bytes) { - const authKeySignature = await window.crypto.subtle.sign( + const authKeySignature = await resolvedCrypto.subtle.sign( { name: 'ECDSA', hash: 'SHA-256', @@ -28,6 +33,7 @@ export class IdentitySigner implements Signers.Signer { constructor( readonly identityInstrument: IdentityInstrument, readonly authKey: AuthKey, + private readonly crypto?: CryptoLike, ) {} get address(): Address.Address { @@ -47,7 +53,7 @@ export class IdentitySigner implements Signers.Signer { } async signDigest(digest: Bytes.Bytes): Promise { - const sigHex = await this.identityInstrument.sign(toIdentityAuthKey(this.authKey), digest) + const sigHex = await this.identityInstrument.sign(toIdentityAuthKey(this.authKey, this.crypto), digest) const sig = Signature.fromHex(sigHex) return { type: 'hash', diff --git a/packages/wallet/wdk/src/index.ts b/packages/wallet/wdk/src/index.ts index 973ec785aa..003abdd722 100644 --- a/packages/wallet/wdk/src/index.ts +++ b/packages/wallet/wdk/src/index.ts @@ -1,2 +1,3 @@ export * as Identity from './identity/signer.js' export * as Sequence from './sequence/index.js' +export * from './env.js' diff --git a/packages/wallet/wdk/src/sequence/cron.ts b/packages/wallet/wdk/src/sequence/cron.ts index a1640a7cfa..f95117109a 100644 --- a/packages/wallet/wdk/src/sequence/cron.ts +++ b/packages/wallet/wdk/src/sequence/cron.ts @@ -17,12 +17,14 @@ export class Cron { private readonly STORAGE_KEY = 'sequence-cron-jobs' private isStopping: boolean = false private currentCheckJobsPromise: Promise = Promise.resolve() + private readonly env: Shared['env'] /** * Initializes the Cron scheduler and starts the periodic job checker. * @param shared Shared context for modules and logging. */ constructor(private readonly shared: Shared) { + this.env = shared.env this.start() } @@ -33,7 +35,11 @@ export class Cron { private start() { if (this.isStopping) return this.executeCheckJobsChain() - this.checkInterval = setInterval(() => this.executeCheckJobsChain(), 60 * 1000) + const setIntervalFn = this.env.timers?.setInterval ?? (globalThis as any).setInterval + if (!setIntervalFn) { + return + } + this.checkInterval = setIntervalFn(() => this.executeCheckJobsChain(), 60 * 1000) } /** @@ -58,7 +64,10 @@ export class Cron { this.isStopping = true if (this.checkInterval) { - clearInterval(this.checkInterval) + const clearIntervalFn = this.env.timers?.clearInterval ?? (globalThis as any).clearInterval + if (clearIntervalFn) { + clearIntervalFn(this.checkInterval) + } this.checkInterval = undefined this.shared.modules.logger.log('Cron: Interval cleared.') } @@ -104,48 +113,22 @@ export class Cron { } 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) { + const locks = this.env.locks ?? (globalThis as any).navigator?.locks + if (locks?.request) { + await locks.request('sequence-cron-jobs', async (lock: Lock | null) => { if (this.isStopping) { - break + return } - - 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 }) - } - } 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) - } - } + if (!lock) { + return } - } - - if (!this.isStopping) { - await this.syncWithStorage() - } - }) + await this.runJobs() + }) + } else { + await this.runJobs() + } } catch (error) { - if (error instanceof DOMException && error.name === 'AbortError') { + if (this.isAbortError(error)) { this.shared.modules.logger.log('Cron: navigator.locks.request was aborted.') } else { console.error('Cron: Error in navigator.locks.request:', error) @@ -153,13 +136,51 @@ export class Cron { } } + private async runJobs(): Promise { + 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 }) + } + } catch (error) { + if (this.isAbortError(error)) { + this.shared.modules.logger.log(`Cron: Job ${id} was aborted.`) + } else { + console.error(`Cron job ${id} failed:`, error) + } + } + } + } + + if (!this.isStopping) { + await this.syncWithStorage() + } + } + /** * Loads the persisted last run times for jobs from localStorage. * @returns Map of job IDs to their last run times. */ private async getStorageState(): Promise> { if (this.isStopping) return new Map() - const state = localStorage.getItem(this.STORAGE_KEY) + const storage = this.env.storage + if (!storage) { + return new Map() + } + const state = storage.getItem(this.STORAGE_KEY) return new Map(state ? JSON.parse(state) : []) } @@ -168,7 +189,19 @@ export class Cron { */ private async syncWithStorage() { if (this.isStopping) return + const storage = this.env.storage + if (!storage) { + return + } const state = Array.from(this.jobs.entries()).map(([id, job]) => [id, { lastRun: job.lastRun }]) - localStorage.setItem(this.STORAGE_KEY, JSON.stringify(state)) + storage.setItem(this.STORAGE_KEY, JSON.stringify(state)) + } + + private isAbortError(error: unknown): boolean { + const domException = (globalThis as any).DOMException + if (domException && error instanceof domException) { + return (error as DOMException).name === 'AbortError' + } + return (error as any)?.name === 'AbortError' } } diff --git a/packages/wallet/wdk/src/sequence/handlers/authcode-pkce.ts b/packages/wallet/wdk/src/sequence/handlers/authcode-pkce.ts index 0b4706c0e9..94d456d3e6 100644 --- a/packages/wallet/wdk/src/sequence/handlers/authcode-pkce.ts +++ b/packages/wallet/wdk/src/sequence/handlers/authcode-pkce.ts @@ -5,6 +5,7 @@ import { Signatures } from '../signatures.js' import * as Identity from '@0xsequence/identity-instrument' import { IdentitySigner } from '../../identity/signer.js' import { AuthCodeHandler } from './authcode.js' +import type { WdkEnv } from '../../env.js' export class AuthCodePkceHandler extends AuthCodeHandler implements Handler { constructor( @@ -16,8 +17,9 @@ export class AuthCodePkceHandler extends AuthCodeHandler implements Handler { signatures: Signatures, commitments: Db.AuthCommitments, authKeys: Db.AuthKeys, + env?: WdkEnv, ) { - super(signupKind, issuer, oauthUrl, audience, nitro, signatures, commitments, authKeys) + super(signupKind, issuer, oauthUrl, audience, nitro, signatures, commitments, authKeys, env) } public async commitAuth(target: string, isSignUp: boolean, state?: string, signer?: string) { @@ -40,7 +42,7 @@ export class AuthCodePkceHandler extends AuthCodeHandler implements Handler { isSignUp, }) - const searchParams = new URLSearchParams({ + const searchParams = this.serializeQuery({ code_challenge: codeChallenge, code_challenge_method: 'S256', client_id: this.audience, @@ -51,7 +53,7 @@ export class AuthCodePkceHandler extends AuthCodeHandler implements Handler { state, }) - return `${this.oauthUrl}?${searchParams.toString()}` + return `${this.oauthUrl}?${searchParams}` } public async completeAuth( diff --git a/packages/wallet/wdk/src/sequence/handlers/authcode.ts b/packages/wallet/wdk/src/sequence/handlers/authcode.ts index 74ad9ee935..8e98745e87 100644 --- a/packages/wallet/wdk/src/sequence/handlers/authcode.ts +++ b/packages/wallet/wdk/src/sequence/handlers/authcode.ts @@ -6,6 +6,7 @@ import * as Identity from '@0xsequence/identity-instrument' import { SignerUnavailable, SignerReady, SignerActionable, BaseSignatureRequest } from '../types/signature-request.js' import { IdentitySigner } from '../../identity/signer.js' import { IdentityHandler } from './identity.js' +import type { NavigationLike, WdkEnv } from '../../env.js' export class AuthCodeHandler extends IdentityHandler implements Handler { protected redirectUri: string = '' @@ -19,8 +20,9 @@ export class AuthCodeHandler extends IdentityHandler implements Handler { signatures: Signatures, protected readonly commitments: Db.AuthCommitments, authKeys: Db.AuthKeys, + env?: WdkEnv, ) { - super(nitro, authKeys, signatures, Identity.IdentityType.OIDC) + super(nitro, authKeys, signatures, Identity.IdentityType.OIDC, env) } public get kind() { @@ -45,7 +47,7 @@ export class AuthCodeHandler extends IdentityHandler implements Handler { isSignUp, }) - const searchParams = new URLSearchParams({ + const searchParams = this.serializeQuery({ client_id: this.audience, redirect_uri: this.redirectUri, response_type: 'code', @@ -53,7 +55,7 @@ export class AuthCodeHandler extends IdentityHandler implements Handler { ...(this.signupKind === 'apple' ? {} : { scope: 'openid profile email' }), }) - return `${this.oauthUrl}?${searchParams.toString()}` + return `${this.oauthUrl}?${searchParams}` } public async completeAuth( @@ -94,10 +96,29 @@ export class AuthCodeHandler extends IdentityHandler implements Handler { status: 'actionable', message: 'request-redirect', handle: async () => { - const url = await this.commitAuth(window.location.pathname, false, request.id, address) - window.location.href = url + const navigation = this.getNavigation() + const url = await this.commitAuth(navigation.getPathname(), false, request.id, address) + navigation.redirect(url) return true }, } } + + protected serializeQuery(params: Record): string { + const searchParamsCtor = this.env.urlSearchParams ?? (globalThis as any).URLSearchParams + if (searchParamsCtor) { + return new searchParamsCtor(params).toString() + } + return Object.entries(params) + .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`) + .join('&') + } + + private getNavigation(): NavigationLike { + const navigation = this.env.navigation + if (!navigation) { + throw new Error('navigation is not available') + } + return navigation + } } diff --git a/packages/wallet/wdk/src/sequence/handlers/identity.ts b/packages/wallet/wdk/src/sequence/handlers/identity.ts index f182452220..815c4d3852 100644 --- a/packages/wallet/wdk/src/sequence/handlers/identity.ts +++ b/packages/wallet/wdk/src/sequence/handlers/identity.ts @@ -4,6 +4,7 @@ import * as Identity from '@0xsequence/identity-instrument' import { Signatures } from '../signatures.js' import { BaseSignatureRequest } from '../types/signature-request.js' import { IdentitySigner, toIdentityAuthKey } from '../../identity/signer.js' +import { resolveWdkEnv, type WdkEnv } from '../../env.js' export const identityTypeToHex = (identityType?: Identity.IdentityType): Hex.Hex => { // Bytes4 @@ -19,12 +20,17 @@ export const identityTypeToHex = (identityType?: Identity.IdentityType): Hex.Hex } export class IdentityHandler { + protected readonly env: WdkEnv + constructor( private readonly nitro: Identity.IdentityInstrument, private readonly authKeys: Db.AuthKeys, private readonly signatures: Signatures, public readonly identityType: Identity.IdentityType, - ) {} + env?: WdkEnv, + ) { + this.env = resolveWdkEnv(env) + } public onStatusChange(cb: () => void): () => void { return this.authKeys.addListener(cb) @@ -37,7 +43,7 @@ export class IdentityHandler { throw new Error('no-auth-key') } - const res = await this.nitro.commitVerifier(toIdentityAuthKey(authKey), challenge) + const res = await this.nitro.commitVerifier(toIdentityAuthKey(authKey, this.env.crypto), challenge) return res } @@ -47,7 +53,7 @@ export class IdentityHandler { throw new Error('no-auth-key') } - const res = await this.nitro.completeAuth(toIdentityAuthKey(authKey), challenge) + const res = await this.nitro.completeAuth(toIdentityAuthKey(authKey, this.env.crypto), challenge) authKey.identitySigner = res.signer.address authKey.expiresAt = new Date(Date.now() + 1000 * 60 * 3) // 3 minutes @@ -55,7 +61,7 @@ export class IdentityHandler { await this.authKeys.delBySigner(authKey.identitySigner) await this.authKeys.set(authKey) - const signer = new IdentitySigner(this.nitro, authKey) + const signer = new IdentitySigner(this.nitro, authKey, this.env.crypto) return { signer, email: res.identity.email } } @@ -72,13 +78,17 @@ export class IdentityHandler { if (!authKey) { return undefined } - return new IdentitySigner(this.nitro, authKey) + return new IdentitySigner(this.nitro, authKey, this.env.crypto) } private async getAuthKey(signer: string): Promise { let authKey = await this.authKeys.getBySigner(signer) if (!signer && !authKey) { - const keyPair = await window.crypto.subtle.generateKey( + const crypto = this.env.crypto ?? (globalThis as any).crypto + if (!crypto?.subtle) { + throw new Error('crypto.subtle is not available') + } + const keyPair = await crypto.subtle.generateKey( { name: 'ECDSA', namedCurve: 'P-256', @@ -86,7 +96,7 @@ export class IdentityHandler { false, ['sign', 'verify'], ) - const publicKey = await window.crypto.subtle.exportKey('raw', keyPair.publicKey) + const publicKey = await crypto.subtle.exportKey('raw', keyPair.publicKey) authKey = { address: Hex.fromBytes(new Uint8Array(publicKey)), identitySigner: '', diff --git a/packages/wallet/wdk/src/sequence/handlers/otp.ts b/packages/wallet/wdk/src/sequence/handlers/otp.ts index f037189cbf..a4e7b750b5 100644 --- a/packages/wallet/wdk/src/sequence/handlers/otp.ts +++ b/packages/wallet/wdk/src/sequence/handlers/otp.ts @@ -8,6 +8,7 @@ import { SignerUnavailable, SignerReady, SignerActionable, BaseSignatureRequest import { Kinds } from '../types/signer.js' import { IdentityHandler } from './identity.js' import { AnswerIncorrectError, ChallengeExpiredError, TooManyAttemptsError } from '../errors.js' +import type { WdkEnv } from '../../env.js' type RespondFn = (otp: string) => Promise @@ -18,8 +19,8 @@ export class OtpHandler extends IdentityHandler implements Handler { private onPromptOtp: undefined | PromptOtpHandler - constructor(nitro: Identity.IdentityInstrument, signatures: Signatures, authKeys: Db.AuthKeys) { - super(nitro, authKeys, signatures, Identity.IdentityType.Email) + constructor(nitro: Identity.IdentityInstrument, signatures: Signatures, authKeys: Db.AuthKeys, env?: WdkEnv) { + super(nitro, authKeys, signatures, Identity.IdentityType.Email, env) } public registerUI(onPromptOtp: PromptOtpHandler) { diff --git a/packages/wallet/wdk/src/sequence/handlers/passkeys.ts b/packages/wallet/wdk/src/sequence/handlers/passkeys.ts index da3db7ff32..7f1e5050ad 100644 --- a/packages/wallet/wdk/src/sequence/handlers/passkeys.ts +++ b/packages/wallet/wdk/src/sequence/handlers/passkeys.ts @@ -1,33 +1,35 @@ -import { Signers, State } from '@0xsequence/wallet-core' +import { State } from '@0xsequence/wallet-core' import { Address, Hex } from 'ox' import { Kinds } from '../types/signer.js' import { Signatures } from '../signatures.js' -import { Extensions } from '@0xsequence/wallet-primitives' +import { Config, Extensions } from '@0xsequence/wallet-primitives' import { Handler } from './handler.js' import { SignerActionable, SignerUnavailable, BaseSignatureRequest } from '../types/index.js' +import type { PasskeyProvider, PasskeySigner } from '../passkeys-provider.js' export class PasskeysHandler implements Handler { kind = Kinds.LoginPasskey - private readySigners = new Map() + private readySigners = new Map() constructor( private readonly signatures: Signatures, private readonly extensions: Pick, private readonly stateReader: State.Reader, + private readonly passkeyProvider: PasskeyProvider, ) {} onStatusChange(cb: () => void): () => void { return () => {} } - public addReadySigner(signer: Signers.Passkey.Passkey) { + public addReadySigner(signer: PasskeySigner) { // Use credentialId as key to match specific passkey instances this.readySigners.set(signer.credentialId, signer) } - private async loadPasskey(wallet: Address.Address, imageHash: Hex.Hex): Promise { + private async loadPasskey(wallet: Address.Address, imageHash: Hex.Hex): Promise { try { - return await Signers.Passkey.Passkey.loadFromWitness(this.stateReader, this.extensions, wallet, imageHash) + return await this.passkeyProvider.loadFromWitness(this.stateReader, this.extensions, wallet, imageHash) } catch (e) { console.warn('Failed to load passkey:', e) return undefined @@ -55,7 +57,7 @@ export class PasskeysHandler implements Handler { } // First check if we have a ready signer that matches the imageHash - let passkey: Signers.Passkey.Passkey | undefined + let passkey: PasskeySigner | undefined // Look for a ready signer with matching imageHash for (const readySigner of this.readySigners.values()) { @@ -91,12 +93,10 @@ export class PasskeysHandler implements Handler { message: 'request-interaction-with-passkey', imageHash: imageHash, handle: async () => { - const signature = await passkey.signSapient( - request.envelope.wallet, - request.envelope.chainId, - request.envelope.payload, - imageHash, + const normalized = Config.normalizeSignerSignature( + passkey.signSapient(request.envelope.wallet, request.envelope.chainId, request.envelope.payload, imageHash), ) + const signature = await normalized.signature await this.signatures.addSignature(request.id, { address, imageHash, diff --git a/packages/wallet/wdk/src/sequence/index.ts b/packages/wallet/wdk/src/sequence/index.ts index 37729a4775..ed2f9e440a 100644 --- a/packages/wallet/wdk/src/sequence/index.ts +++ b/packages/wallet/wdk/src/sequence/index.ts @@ -3,6 +3,8 @@ export { Network as Networks } export type { ManagerOptions, Databases, Sequence, Modules, Shared } from './manager.js' export { ManagerOptionsDefaults, CreateWalletOptionsDefaults, applyManagerOptionsDefaults, Manager } from './manager.js' +export { defaultPasskeyProvider } from './passkeys-provider.js' +export type { PasskeyProvider, PasskeySigner } from './passkeys-provider.js' export { Sessions } from './sessions.js' export { Signatures } from './signatures.js' export type { diff --git a/packages/wallet/wdk/src/sequence/manager.ts b/packages/wallet/wdk/src/sequence/manager.ts index ba27116cf3..80ca1653ce 100644 --- a/packages/wallet/wdk/src/sequence/manager.ts +++ b/packages/wallet/wdk/src/sequence/manager.ts @@ -5,6 +5,7 @@ import { createAttestationVerifyingFetch } from '@0xsequence/tee-verifier' import { Config, Constants, Context, Extensions, Network } from '@0xsequence/wallet-primitives' import { Address } from 'ox' import * as Db from '../dbs/index.js' +import { resolveWdkEnv, type WdkEnv } from '../env.js' import { Cron } from './cron.js' import { Devices } from './devices.js' import { Guards, GuardRole } from './guards.js' @@ -31,6 +32,7 @@ import { GuardHandler, PromptCodeHandler } from './handlers/guard.js' import { PasskeyCredential } from '../dbs/index.js' import { PromptMnemonicHandler } from './handlers/mnemonic.js' import { PromptOtpHandler } from './handlers/otp.js' +import { defaultPasskeyProvider, type PasskeyProvider } from './passkeys-provider.js' export type ManagerOptions = { verbose?: boolean @@ -52,6 +54,9 @@ export type ManagerOptions = { dbPruningInterval?: number + env?: WdkEnv + passkeyProvider?: PasskeyProvider + stateProvider?: State.Provider networks?: Network.Network[] relayers?: Relayer.Relayer[] | (() => Relayer.Relayer[]) @@ -70,7 +75,7 @@ export type ManagerOptions = { identity?: { url?: string - fetch?: typeof window.fetch + fetch?: typeof fetch verifyAttestation?: boolean expectedPcr0?: string[] scope?: string @@ -95,6 +100,72 @@ export type ManagerOptions = { } } +export type ResolvedIdentityOptions = { + url: string + fetch?: typeof fetch + verifyAttestation: boolean + expectedPcr0?: string[] + scope?: string + email: { + enabled: boolean + } + google: { + enabled: boolean + clientId: string + } + apple: { + enabled: boolean + clientId: string + } + customProviders?: { + kind: `custom-${string}` + authMethod: 'id-token' | 'authcode' | 'authcode-pkce' + issuer: string + oauthUrl: string + clientId: string + }[] +} + +export type ResolvedManagerOptions = { + verbose: boolean + + extensions: Extensions.Extensions + context: Context.Context + context4337: Context.Context + guest: Address.Address + + encryptedPksDb: CoreSigners.Pk.Encrypted.EncryptedPksDb + managerDb: Db.Wallets + transactionsDb: Db.Transactions + signaturesDb: Db.Signatures + messagesDb: Db.Messages + authCommitmentsDb: Db.AuthCommitments + authKeysDb: Db.AuthKeys + recoveryDb: Db.Recovery + passkeyCredentialsDb: Db.PasskeyCredentials + + dbPruningInterval: number + + env: WdkEnv + passkeyProvider: PasskeyProvider + + stateProvider: State.Provider + networks: Network.Network[] + relayers: Relayer.Relayer[] | (() => Relayer.Relayer[]) + bundlers: Bundler.Bundler[] + guardUrl: string + guardAddresses: Record + + nonWitnessableSigners: Address.Address[] + + defaultGuardTopology: Config.Topology + defaultRecoverySettings: RecoverySettings + + multiInjectedProviderDiscovery: boolean + + identity: ResolvedIdentityOptions +} + export const ManagerOptionsDefaults = { verbose: false, @@ -115,7 +186,9 @@ export const ManagerOptionsDefaults = { dbPruningInterval: 1000 * 60 * 60 * 24, // 24 hours - stateProvider: new State.Sequence.Provider(), + passkeyProvider: defaultPasskeyProvider, + + stateProvider: typeof fetch !== 'undefined' ? new State.Sequence.Provider(undefined, fetch) : undefined, networks: Network.ALL, relayers: () => { if (typeof window !== 'undefined') { @@ -186,13 +259,45 @@ export const CreateWalletOptionsDefaults = { useGuard: false, } -export function applyManagerOptionsDefaults(options?: ManagerOptions) { - const merged = { - ...ManagerOptionsDefaults, - ...options, - identity: { ...ManagerOptionsDefaults.identity, ...options?.identity }, +export function applyManagerOptionsDefaults(options?: ManagerOptions): ResolvedManagerOptions { + const env = resolveWdkEnv(options?.env) + + const identity: ResolvedIdentityOptions = { + ...ManagerOptionsDefaults.identity, + ...options?.identity, + email: { ...ManagerOptionsDefaults.identity.email, ...options?.identity?.email }, + google: { ...ManagerOptionsDefaults.identity.google, ...options?.identity?.google }, + apple: { ...ManagerOptionsDefaults.identity.apple, ...options?.identity?.apple }, + } + + if (!identity.fetch && env.fetch) { + identity.fetch = env.fetch + } + + let encryptedPksDb = options?.encryptedPksDb ?? ManagerOptionsDefaults.encryptedPksDb + if (!options?.encryptedPksDb && options?.env) { + encryptedPksDb = new CoreSigners.Pk.Encrypted.EncryptedPksDb(undefined, undefined, env) + } + + let authKeysDb = options?.authKeysDb ?? ManagerOptionsDefaults.authKeysDb + if (!options?.authKeysDb && options?.env) { + authKeysDb = new Db.AuthKeys(undefined, env) } + let stateProvider = options?.stateProvider ?? ManagerOptionsDefaults.stateProvider + if (!options?.stateProvider && options?.env?.fetch) { + stateProvider = new State.Sequence.Provider(undefined, options.env.fetch) + } else if (!stateProvider && env.fetch) { + stateProvider = new State.Sequence.Provider(undefined, env.fetch) + } + + if (!stateProvider) { + throw new Error('stateProvider is required. Provide ManagerOptions.stateProvider or env.fetch') + } + + const extensions = options?.extensions ?? ManagerOptionsDefaults.extensions + const defaultGuardTopology = options?.defaultGuardTopology ?? ManagerOptionsDefaults.defaultGuardTopology + // Merge and normalize non-witnessable signers. // We always include the sessions extension address for the active extensions set. const nonWitnessable = new Set() @@ -202,12 +307,12 @@ export function applyManagerOptionsDefaults(options?: ManagerOptions) { for (const address of options?.nonWitnessableSigners ?? []) { nonWitnessable.add(address.toLowerCase()) } - nonWitnessable.add(merged.extensions.sessions.toLowerCase()) + nonWitnessable.add(extensions.sessions.toLowerCase()) // Include static signer leaves from the guard topology (e.g. recovery guard signer), // but ignore the placeholder address that is later replaced per-role. - if (merged.defaultGuardTopology) { - const guardTopologySigners = Config.getSigners(merged.defaultGuardTopology) + if (defaultGuardTopology) { + const guardTopologySigners = Config.getSigners(defaultGuardTopology) for (const signer of guardTopologySigners.signers) { if (Address.isEqual(signer, Constants.PlaceholderAddress)) { continue @@ -219,9 +324,46 @@ export function applyManagerOptionsDefaults(options?: ManagerOptions) { } } - merged.nonWitnessableSigners = Array.from(nonWitnessable) as Address.Address[] + return { + verbose: options?.verbose ?? ManagerOptionsDefaults.verbose, - return merged + extensions, + context: options?.context ?? ManagerOptionsDefaults.context, + context4337: options?.context4337 ?? ManagerOptionsDefaults.context4337, + guest: options?.guest ?? ManagerOptionsDefaults.guest, + + encryptedPksDb, + managerDb: options?.managerDb ?? ManagerOptionsDefaults.managerDb, + transactionsDb: options?.transactionsDb ?? ManagerOptionsDefaults.transactionsDb, + signaturesDb: options?.signaturesDb ?? ManagerOptionsDefaults.signaturesDb, + messagesDb: options?.messagesDb ?? ManagerOptionsDefaults.messagesDb, + authCommitmentsDb: options?.authCommitmentsDb ?? ManagerOptionsDefaults.authCommitmentsDb, + recoveryDb: options?.recoveryDb ?? ManagerOptionsDefaults.recoveryDb, + authKeysDb, + passkeyCredentialsDb: options?.passkeyCredentialsDb ?? ManagerOptionsDefaults.passkeyCredentialsDb, + + dbPruningInterval: options?.dbPruningInterval ?? ManagerOptionsDefaults.dbPruningInterval, + + env, + passkeyProvider: options?.passkeyProvider ?? ManagerOptionsDefaults.passkeyProvider, + + stateProvider, + networks: options?.networks ?? ManagerOptionsDefaults.networks, + relayers: options?.relayers ?? ManagerOptionsDefaults.relayers, + bundlers: options?.bundlers ?? ManagerOptionsDefaults.bundlers, + guardUrl: options?.guardUrl ?? ManagerOptionsDefaults.guardUrl, + guardAddresses: options?.guardAddresses ?? ManagerOptionsDefaults.guardAddresses, + + nonWitnessableSigners: Array.from(nonWitnessable) as Address.Address[], + + defaultGuardTopology, + defaultRecoverySettings: options?.defaultRecoverySettings ?? ManagerOptionsDefaults.defaultRecoverySettings, + + multiInjectedProviderDiscovery: + options?.multiInjectedProviderDiscovery ?? ManagerOptionsDefaults.multiInjectedProviderDiscovery, + + identity, + } } export type RecoverySettings = { @@ -283,6 +425,8 @@ export type Shared = { readonly sequence: Sequence readonly databases: Databases + readonly env: WdkEnv + readonly passkeyProvider: PasskeyProvider readonly handlers: Map @@ -469,6 +613,9 @@ export class Manager { pruningInterval: ops.dbPruningInterval, }, + env: ops.env, + passkeyProvider: ops.passkeyProvider, + modules: {} as any, handlers: new Map(), } @@ -501,6 +648,7 @@ export class Manager { modules.signatures, shared.sequence.extensions, shared.sequence.stateProvider, + shared.passkeyProvider, ) shared.handlers.set(Kinds.LoginPasskey, this.passkeysHandler) @@ -523,7 +671,7 @@ export class Manager { const identityInstrument = new IdentityInstrument(ops.identity.url, ops.identity.scope, verifyingFetch) if (ops.identity.email?.enabled) { - this.otpHandler = new OtpHandler(identityInstrument, modules.signatures, shared.databases.authKeys) + this.otpHandler = new OtpHandler(identityInstrument, modules.signatures, shared.databases.authKeys, shared.env) shared.handlers.set(Kinds.LoginEmailOtp, this.otpHandler) } if (ops.identity.google?.enabled) { @@ -538,6 +686,7 @@ export class Manager { modules.signatures, shared.databases.authCommitments, shared.databases.authKeys, + shared.env, ), ) } @@ -553,6 +702,7 @@ export class Manager { modules.signatures, shared.databases.authCommitments, shared.databases.authKeys, + shared.env, ), ) } @@ -573,6 +723,7 @@ export class Manager { modules.signatures, shared.databases.authCommitments, shared.databases.authKeys, + shared.env, ), ) break @@ -588,6 +739,7 @@ export class Manager { modules.signatures, shared.databases.authCommitments, shared.databases.authKeys, + shared.env, ), ) break diff --git a/packages/wallet/wdk/src/sequence/passkeys-provider.ts b/packages/wallet/wdk/src/sequence/passkeys-provider.ts new file mode 100644 index 0000000000..3ed2068fe3 --- /dev/null +++ b/packages/wallet/wdk/src/sequence/passkeys-provider.ts @@ -0,0 +1,55 @@ +import { Signers, State } from '@0xsequence/wallet-core' +import type { Extensions } from '@0xsequence/wallet-primitives' +import type { Address, Hex } from 'ox' + +export type PasskeySigner = Signers.SapientSigner & + Signers.Witnessable & { + credentialId: string + publicKey: Extensions.Passkeys.PublicKey + imageHash: Hex.Hex + } + +export type PasskeyProvider = { + create: ( + extensions: Pick, + options?: Signers.Passkey.CreatePasskeyOptions, + ) => Promise + find: ( + stateReader: State.Reader, + extensions: Pick, + options?: Signers.Passkey.FindPasskeyOptions, + ) => Promise + loadFromWitness: ( + stateReader: State.Reader, + extensions: Pick, + wallet: Address.Address, + imageHash: Hex.Hex, + options?: Signers.Passkey.FindPasskeyOptions, + ) => Promise + fromCredential: (args: { + credentialId: string + publicKey: Extensions.Passkeys.PublicKey + extensions: Pick + embedMetadata?: boolean + metadata?: Extensions.Passkeys.PasskeyMetadata + webauthn?: Signers.Passkey.WebAuthnLike + }) => PasskeySigner + isSigner?: (signer: unknown) => signer is PasskeySigner +} + +export const defaultPasskeyProvider: PasskeyProvider = { + create: (extensions, options) => Signers.Passkey.Passkey.create(extensions, options), + find: (stateReader, extensions, options) => Signers.Passkey.Passkey.find(stateReader, extensions, options), + loadFromWitness: (stateReader, extensions, wallet, imageHash, options) => + Signers.Passkey.Passkey.loadFromWitness(stateReader, extensions, wallet, imageHash, options), + fromCredential: ({ credentialId, publicKey, extensions, embedMetadata, metadata, webauthn }) => + new Signers.Passkey.Passkey({ + credentialId, + publicKey, + extensions, + embedMetadata, + metadata, + webauthn, + }), + isSigner: (signer: unknown): signer is PasskeySigner => signer instanceof Signers.Passkey.Passkey, +} diff --git a/packages/wallet/wdk/src/sequence/wallets.ts b/packages/wallet/wdk/src/sequence/wallets.ts index fd0320cc43..04fb9586a2 100644 --- a/packages/wallet/wdk/src/sequence/wallets.ts +++ b/packages/wallet/wdk/src/sequence/wallets.ts @@ -12,6 +12,7 @@ import { Kinds, SignerWithKind, WitnessExtraSignerKind } from './types/signer.js import { Wallet, WalletSelectionUiHandler } from './types/wallet.js' import { PasskeysHandler } from './handlers/passkeys.js' import { GuardRole } from './guards.js' +import type { PasskeySigner } from './passkeys-provider.js' export type StartSignUpWithRedirectArgs = { kind: 'google-pkce' | 'apple' | `custom-${string}` @@ -626,7 +627,7 @@ export class Wallets implements WalletsInterface { }> { switch (args.kind) { case 'passkey': - const passkeySigner = await Signers.Passkey.Passkey.create(this.shared.sequence.extensions, { + const passkeySigner = await this.shared.passkeyProvider.create(this.shared.sequence.extensions, { stateProvider: this.shared.sequence.stateProvider, credentialName: args.name, }) @@ -891,7 +892,7 @@ export class Wallets implements WalletsInterface { } // Store passkey credential ID mapping if this is a passkey signup - if (args.kind === 'passkey' && loginSigner.signer instanceof Signers.Passkey.Passkey) { + if (args.kind === 'passkey' && this.isPasskeySigner(loginSigner.signer)) { try { await this.shared.databases.passkeyCredentials.saveCredential( loginSigner.signer.credentialId, @@ -1075,7 +1076,7 @@ export class Wallets implements WalletsInterface { } if (isLoginToPasskeyArgs(args)) { - let passkeySigner: Signers.Passkey.Passkey + let passkeySigner: PasskeySigner if (args.credentialId) { // Application-controlled login: use the provided credentialId @@ -1087,7 +1088,7 @@ export class Wallets implements WalletsInterface { } // Create passkey signer from stored credential - passkeySigner = new Signers.Passkey.Passkey({ + passkeySigner = this.shared.passkeyProvider.fromCredential({ credentialId: credential.credentialId, publicKey: credential.publicKey, extensions: this.shared.sequence.extensions, @@ -1098,7 +1099,7 @@ export class Wallets implements WalletsInterface { // Default discovery behavior: use WebAuthn discovery this.shared.modules.logger.log('No credentialId provided, using discovery method') - const foundPasskeySigner = await Signers.Passkey.Passkey.find( + const foundPasskeySigner = await this.shared.passkeyProvider.find( this.shared.sequence.stateProvider, this.shared.sequence.extensions, ) @@ -1153,6 +1154,20 @@ export class Wallets implements WalletsInterface { throw new Error('invalid-login-args') } + private isPasskeySigner(signer: unknown): signer is PasskeySigner { + const guard = this.shared.passkeyProvider.isSigner + if (guard) { + return guard(signer) + } + return ( + typeof signer === 'object' && + signer !== null && + 'credentialId' in signer && + 'publicKey' in signer && + 'imageHash' in signer + ) + } + async completeLogin(requestId: string) { const request = await this.shared.modules.signatures.get(requestId) From 9ce7a2668651ef6e146958ef6ccf3d1c239fd649 Mon Sep 17 00:00:00 2001 From: Tolgahan Date: Tue, 10 Feb 2026 15:42:32 +0300 Subject: [PATCH 2/2] wdk: use injected timer for auth key retry delay --- packages/wallet/wdk/src/dbs/auth-keys.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/wallet/wdk/src/dbs/auth-keys.ts b/packages/wallet/wdk/src/dbs/auth-keys.ts index a4640d381a..4f851f0b33 100644 --- a/packages/wallet/wdk/src/dbs/auth-keys.ts +++ b/packages/wallet/wdk/src/dbs/auth-keys.ts @@ -68,7 +68,10 @@ export class AuthKeys extends Generic { if (result !== undefined) { return result } else if (attempt < 2) { - await new Promise((resolve) => setTimeout(resolve, 50)) + const setTimeoutFn = this.env?.timers?.setTimeout ?? (globalThis as any).setTimeout + if (setTimeoutFn) { + await new Promise((resolve) => setTimeoutFn(resolve, 50)) + } return this.getBySigner(signer, attempt + 1) } else { try {