Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
bf86ff6
Add recovery module on signUp and login
Agusx1211 Apr 23, 2025
402ee4d
Remvoe recovery on logout
Agusx1211 Apr 23, 2025
bc9e571
Move recovery to its own module
Agusx1211 Apr 23, 2025
4fbbcf0
Basic recovery methods
Agusx1211 Apr 24, 2025
aa3e118
Replace Janitor with generic Cron
Agusx1211 Apr 24, 2025
b88e594
Listen for recovery payloads
Agusx1211 Apr 24, 2025
601d5ec
Expose recovery methods
Agusx1211 Apr 24, 2025
f2871b1
Fix typo
Agusx1211 Apr 24, 2025
0b5fcac
Remove locks polyfill
Agusx1211 Apr 25, 2025
65fd7ab
Handle failed jobs
Agusx1211 Apr 25, 2025
00cf888
Initialize modules
Agusx1211 Apr 25, 2025
6bd2289
30000 days timelock is a bit long
Agusx1211 Apr 25, 2025
7a38a2b
Device as initialy recovery
Agusx1211 Apr 25, 2025
1eb343d
Set as logging-out when request has been created
Agusx1211 Apr 25, 2025
e1025dd
Recovery encoding fixes
Agusx1211 Apr 25, 2025
3a5eefe
Merge branch 'master' of github.com:0xsequence/sequence.js into recov…
Agusx1211 Apr 28, 2025
a62ae3c
Merge branch 'master' of github.com:0xsequence/sequence.js into recov…
Agusx1211 Apr 29, 2025
6809891
Generic requestConfigurationUpdate method
Agusx1211 Apr 30, 2025
5202f8d
Standalone remove and add recovery methods
Agusx1211 Apr 30, 2025
f0a5171
Complete remove recovery methods
Agusx1211 Apr 30, 2025
88936d3
Add payload to state tracker
Agusx1211 Apr 30, 2025
47f95cd
Save recovery payload
Agusx1211 May 2, 2025
dddc306
Mock locks for tests
Agusx1211 May 5, 2025
b26f78a
Fix recovery queued execution and test
Agusx1211 May 5, 2025
e1c5da7
Clear executed payloads
Agusx1211 May 5, 2025
05bfa95
Merge branch 'master' of github.com:0xsequence/sequence.js into recov…
Agusx1211 May 5, 2025
04ca548
Start Anvil for tests CLI
Agusx1211 May 5, 2025
35877ad
Check if module initialize is a function
Agusx1211 May 5, 2025
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 packages/wallet/primitives/src/extensions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ import { Address } from 'ox'

export type Extensions = {
passkeys: Address.Address
recovery: Address.Address
}

export const Dev1: Extensions = {
passkeys: '0x8f26281dB84C18aAeEa8a53F94c835393229d296',
recovery: '0xd98da48C4FF9c19742eA5856A277424557C863a6',
}

export * as Passkeys from './passkeys.js'
Expand Down
168 changes: 136 additions & 32 deletions packages/wallet/primitives/src/extensions/recovery.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,32 @@
import { Address, Bytes, Hash, Hex } from 'ox'
import { Abi, AbiFunction, Address, Bytes, Hash, Hex, Provider } from 'ox'
import * as Payload from '../payload.js'
import { getSignPayload } from 'ox/TypedData'
import * as GenericTree from '../generic-tree.js'
import { getSelector } from 'ox/AbiFunction'
import { Signature } from '../index.js'
import { packRSY } from '../utils.js'

export const FLAG_RECOVERY_LEAF = 1
export const FLAG_NODE = 3
export const FLAG_BRANCH = 4

const RECOVERY_LEAF_PREFIX = Bytes.fromString('Sequence recovery leaf:\n')

export const QUEUE_PAYLOAD = Abi.from([
'function queuePayload(address _wallet, address _signer, (uint8 kind,bool noChainId,(address to,uint256 value,bytes data,uint256 gasLimit,bool delegateCall,bool onlyFallback,uint256 behaviorOnError)[] calls,uint256 space,uint256 nonce,bytes message,bytes32 imageHash,bytes32 digest,address[] parentWallets) calldata _payload, bytes calldata _signature) external',
])[0]

export const TIMESTAMP_FOR_QUEUED_PAYLOAD = Abi.from([
'function timestampForQueuedPayload(address _wallet, address _signer, bytes32 _payloadHash) external view returns (uint256)',
])[0]

export const QUEUED_PAYLOAD_HASHES = Abi.from([
'function queuedPayloadHashes(address _wallet, address _signer, uint256 _index) external view returns (bytes32)',
])[0]

export const TOTAL_QUEUED_PAYLOADS = Abi.from([
'function totalQueuedPayloads(address _wallet, address _signer) external view returns (uint256)',
])[0]

/**
* A leaf in the Recovery tree, storing:
* - signer who can queue a payload
Expand Down Expand Up @@ -311,33 +329,6 @@ export function fromRecoveryLeaves(leaves: RecoveryLeaf[]): Tree {
return [left, right] as Branch
}

/**
* Creates the EIP-712 domain separator for the "Sequence Wallet - Recovery Mode" domain
*
* @param wallet - The wallet address
* @param chainId - The chain ID
* @param noChainId - Whether to omit the chain ID from the domain separator
* @returns The domain separator hash
*/
export function domainSeparator(wallet: Address.Address, chainId: bigint, noChainId: boolean): Hex.Hex {
// keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)")
const EIP712_DOMAIN_TYPEHASH = Hash.keccak256(
Bytes.fromString('EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)'),
)
const nameHash = Hash.keccak256(Bytes.fromString(DOMAIN_NAME))
const versionHash = Hash.keccak256(Bytes.fromString(DOMAIN_VERSION))

const chain = noChainId ? 0 : Number(chainId)
const encoded = Bytes.concat(
EIP712_DOMAIN_TYPEHASH,
nameHash,
versionHash,
Bytes.padLeft(Bytes.fromNumber(chain), 32),
Bytes.padLeft(Bytes.fromHex(wallet), 32),
)
return Hash.keccak256(encoded, { as: 'Hex' })
}

/**
* Produces an EIP-712 typed data hash for a "recovery mode" payload,
* matching the logic in Recovery.sol:
Expand All @@ -360,9 +351,8 @@ export function hashRecoveryPayload(
chainId: bigint,
noChainId: boolean,
): Hex.Hex {
const ds = domainSeparator(wallet, chainId, noChainId)
const structHash = Bytes.fromHex(getSignPayload(Payload.toTyped(wallet, noChainId ? 0n : chainId, payload)))
return Hash.keccak256(Bytes.concat(Bytes.fromString('\x19\x01'), Hex.toBytes(ds), structHash), { as: 'Hex' })
const recoveryPayload = Payload.toRecovery(payload)
return Hex.fromBytes(Payload.hash(wallet, noChainId ? 0n : chainId, recoveryPayload))
}

/**
Expand Down Expand Up @@ -435,3 +425,117 @@ export function fromGenericTree(tree: GenericTree.Tree): Tree {
throw new Error('Invalid tree format')
}
}

/**
* Encodes the calldata for queueing a recovery payload on the recovery extension
*
* @param wallet - The wallet address that owns the recovery configuration
* @param payload - The recovery payload to queue for execution
* @param signer - The recovery signer address that is queueing the payload
* @param signature - The signature from the recovery signer authorizing the payload
* @returns The encoded calldata for the queuePayload function on the recovery extension
*/
export function encodeCalldata(
wallet: Address.Address,
payload: Payload.Recovery<any>,
signer: Address.Address,
signature: Signature.SignatureOfSignerLeaf,
) {
let signatureBytes: Hex.Hex

if (signature.type === 'erc1271') {
signatureBytes = signature.data
} else {
signatureBytes = Bytes.toHex(packRSY(signature))
}

return AbiFunction.encodeData(QUEUE_PAYLOAD, [wallet, signer, payload, signatureBytes])
}

/**
* Gets the total number of payloads queued by a recovery signer for a wallet
*
* @param provider - The provider to use for making the eth_call
* @param extension - The address of the recovery extension contract
* @param wallet - The wallet address to check queued payloads for
* @param signer - The recovery signer address to check queued payloads for
* @returns The total number of payloads queued by this signer for this wallet
*/
export async function totalQueuedPayloads(
provider: Provider.Provider,
extension: Address.Address,
wallet: Address.Address,
signer: Address.Address,
): Promise<bigint> {
const total = await provider.request({
method: 'eth_call',
params: [
{
to: extension,
data: AbiFunction.encodeData(TOTAL_QUEUED_PAYLOADS, [wallet, signer]),
},
],
})

return Hex.toBigInt(total)
}

/**
* Gets the hash of a queued payload at a specific index
*
* @param provider - The provider to use for making the eth_call
* @param extension - The address of the recovery extension contract
* @param wallet - The wallet address to get the queued payload for
* @param signer - The recovery signer address that queued the payload
* @param index - The index of the queued payload to get the hash for
* @returns The hash of the queued payload at the specified index
*/
export async function queuedPayloadHashOf(
provider: Provider.Provider,
extension: Address.Address,
wallet: Address.Address,
signer: Address.Address,
index: bigint,
): Promise<Hex.Hex> {
const hash = await provider.request({
method: 'eth_call',
params: [
{
to: extension,
data: AbiFunction.encodeData(QUEUED_PAYLOAD_HASHES, [wallet, signer, index]),
},
],
})

return hash
}

/**
* Gets the timestamp when a specific payload was queued
*
* @param provider - The provider to use for making the eth_call
* @param extension - The address of the recovery extension contract
* @param wallet - The wallet address the payload was queued for
* @param signer - The recovery signer address that queued the payload
* @param payloadHash - The hash of the queued payload to get the timestamp for
* @returns The timestamp when the payload was queued, or 0 if not found
*/
export async function timestampForQueuedPayload(
provider: Provider.Provider,
extension: Address.Address,
wallet: Address.Address,
signer: Address.Address,
payloadHash: Hex.Hex,
): Promise<bigint> {
const timestamp = await provider.request({
method: 'eth_call',
params: [
{
to: extension,
data: AbiFunction.encodeData(TIMESTAMP_FOR_QUEUED_PAYLOAD, [wallet, signer, payloadHash]),
},
],
})

return Hex.toBigInt(timestamp)
}
47 changes: 44 additions & 3 deletions packages/wallet/primitives/src/payload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,11 @@ export type Parent = {
parentWallets?: Address.Address[]
}

export type Payload = Calls | Message | ConfigUpdate | Digest
export type Recovery<T extends Calls | Message | ConfigUpdate | Digest> = T & {
recovery: true
}

export type Payload = Calls | Message | ConfigUpdate | Digest | Recovery<Calls | Message | ConfigUpdate | Digest>

export type Parented = Payload & Parent

Expand Down Expand Up @@ -97,6 +101,21 @@ export function isConfigUpdate(payload: Payload): payload is ConfigUpdate {
return payload.type === 'config-update'
}

export function isRecovery(payload: Payload): payload is Recovery<Payload> {
return (payload as Recovery<Payload>).recovery === true
}

export function toRecovery<T extends Payload>(payload: T): Recovery<T> {
if (isRecovery(payload)) {
return payload
}

return {
...payload,
recovery: true,
}
}

export function encode(payload: Calls, self?: Address.Address): Bytes.Bytes {
const callsLen = payload.calls.length
const nonceBytesNeeded = minBytesFor(payload.nonce)
Expand Down Expand Up @@ -299,13 +318,35 @@ export function hash(wallet: Address.Address, chainId: bigint, payload: Parented
return Bytes.fromHex(getSignPayload(typedData))
}

export function toTyped(wallet: Address.Address, chainId: bigint, payload: Parented): TypedDataToSign {
const domain = {
function domainFor(
payload: Payload,
wallet: Address.Address,
chainId: bigint,
): {
name: string
version: string
chainId: number
verifyingContract: Address.Address
} {
if (isRecovery(payload)) {
return {
name: 'Sequence Wallet - Recovery Mode',
version: '1',
chainId: Number(chainId),
verifyingContract: wallet,
}
}

return {
name: 'Sequence Wallet',
version: '3',
chainId: Number(chainId),
verifyingContract: wallet,
}
}

export function toTyped(wallet: Address.Address, chainId: bigint, payload: Parented): TypedDataToSign {
const domain = domainFor(payload, wallet, chainId)

switch (payload.type) {
case 'call': {
Expand Down
3 changes: 2 additions & 1 deletion packages/wallet/wdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"idb": "^7.1.1",
"jwt-decode": "^4.0.0",
"ox": "^0.7.0",
"uuid": "^11.1.0"
"uuid": "^11.1.0",
"web-locks": "^0.0.8"
}
}
1 change: 1 addition & 0 deletions packages/wallet/wdk/src/dbs/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ export { Generic } from './generic.js'
export { Signatures } from './signatures.js'
export { Transactions } from './transactions.js'
export { Wallets } from './wallets.js'
export { Recovery } from './recovery.js'
15 changes: 15 additions & 0 deletions packages/wallet/wdk/src/dbs/recovery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { Generic } from './generic.js'
import { QueuedRecoveryPayload } from '../sequence/types/recovery.js'

const TABLE_NAME = 'queued-recovery-payloads'
export class Recovery extends Generic<QueuedRecoveryPayload, 'id'> {
constructor(dbName: string = 'sequence-recovery') {
super(dbName, TABLE_NAME, 'id', [
(db: IDBDatabase) => {
if (!db.objectStoreNames.contains(TABLE_NAME)) {
db.createObjectStore(TABLE_NAME)
}
},
])
}
}
82 changes: 82 additions & 0 deletions packages/wallet/wdk/src/sequence/cron.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { Shared } from './manager.js'
import 'web-locks'

interface CronJob {
id: string
interval: number
lastRun: number
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'

constructor(private readonly shared: Shared) {
this.start()
}

private start() {
// Check every minute
this.checkInterval = setInterval(() => this.checkJobs(), 60 * 1000)
this.checkJobs()
}

// 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,
}

this.jobs.set(id, job)
this.syncWithStorage()
}

// Unregister a job by ID
unregisterJob(id: string) {
if (this.jobs.delete(id)) {
this.syncWithStorage()
}
}

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) {
await job.handler()
job.lastRun = now
storage.set(id, { lastRun: now })
}
}

await this.syncWithStorage()
})
}

private async getStorageState(): Promise<Map<string, { lastRun: number }>> {
const state = localStorage.getItem(this.STORAGE_KEY)
return new Map(state ? JSON.parse(state) : [])
}

private async syncWithStorage() {
const state = Array.from(this.jobs.entries()).map(([id, job]) => [id, { lastRun: job.lastRun }])
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(state))
}
}
Loading