diff --git a/package.json b/package.json index 023823b95..290e38bd0 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "turbo": "^2.5.4", "typescript": "5.8.3" }, - "packageManager": "pnpm@10.14.0", + "packageManager": "pnpm@10.18.3", "engines": { "node": ">=18" } diff --git a/packages/utils/migration/README.md b/packages/utils/migration/README.md new file mode 100644 index 000000000..03e5b7a0d --- /dev/null +++ b/packages/utils/migration/README.md @@ -0,0 +1,3 @@ +# @0xsequence/wallet-migration + +See [0xsequence project page](https://github.com/0xsequence/sequence.js). diff --git a/packages/utils/migration/package.json b/packages/utils/migration/package.json new file mode 100644 index 000000000..5ee301c83 --- /dev/null +++ b/packages/utils/migration/package.json @@ -0,0 +1,48 @@ +{ + "name": "@0xsequence/wallet-migration", + "version": "0.0.0", + "license": "Apache-2.0", + "type": "module", + "publishConfig": { + "access": "public" + }, + "private": false, + "scripts": { + "build": "tsc", + "dev": "tsc --watch", + "test": "vitest run", + "test:coverage": "vitest run --coverage", + "typecheck": "tsc --noEmit", + "clean": "rimraf dist" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "devDependencies": { + "@0xsequence/relayerv2": "npm:@0xsequence/relayer@^2.3.29", + "@0xsequence/signhubv2": "npm:@0xsequence/signhub@^2.3.29", + "@repo/typescript-config": "workspace:^", + "@types/node": "^22.15.29", + "@vitest/coverage-v8": "^3.2.4", + "dotenv": "^16.5.0", + "ethers": "6.15.0", + "fake-indexeddb": "^6.0.1", + "typescript": "^5.8.3", + "vitest": "^3.2.1" + }, + "dependencies": { + "@0xsequence/abi": "workspace:^", + "@0xsequence/v2core": "npm:@0xsequence/core@^2.3.29", + "@0xsequence/v2sessions": "npm:@0xsequence/sessions@^2.3.29", + "@0xsequence/v2migration": "npm:@0xsequence/migration@^2.3.29", + "@0xsequence/v2wallet": "npm:@0xsequence/wallet@^2.3.29", + "@0xsequence/wallet-core": "workspace:^", + "@0xsequence/wallet-primitives": "workspace:^", + "mipd": "^0.0.7", + "ox": "^0.7.2", + "viem": "^2.30.6" + } +} diff --git a/packages/utils/migration/src/index.ts b/packages/utils/migration/src/index.ts new file mode 100644 index 000000000..71b5e3e3b --- /dev/null +++ b/packages/utils/migration/src/index.ts @@ -0,0 +1,2 @@ +export * as migration from './migrations/index.js' +export * from './types.js' diff --git a/packages/utils/migration/src/migrations/index.ts b/packages/utils/migration/src/migrations/index.ts new file mode 100644 index 000000000..f66d2753c --- /dev/null +++ b/packages/utils/migration/src/migrations/index.ts @@ -0,0 +1,79 @@ +import { State } from '@0xsequence/wallet-core' +import { Payload } from '@0xsequence/wallet-primitives' +import { Address, Hex } from 'ox' +import { UnsignedMigration } from '../types.js' +import { MigrationEncoder_v1v3 } from './v1/encoder_v1_v3.js' + +export interface MigrationEncoder { + fromVersion: number + toVersion: number + + /** + * Converts from `FromConfigType` to `ToConfigType` + * @param fromConfig The configuration to convert from + * @param options The convert options + * @returns The converted configuration + */ + convertConfig: (fromConfig: FromConfigType, options: ConvertOptionsType) => Promise + + /** + * Prepares a migration for a given wallet address and context + * @param walletAddress The wallet address to prepare the migration for + * @param contexts The contexts to prepare the migration for + * @param toConfig The configuration to prepare the migration for + * @param options The prepare options + * @returns The migration payload to be signed + */ + prepareMigration: ( + walletAddress: Address.Address, + toContext: ToContextType, + toConfig: ToConfigType, + options: PrepareOptionsType, + ) => Promise + + /** + * Encodes the a transaction for a given migration + * @param migration The migration to encode the transaction for + * @returns The encoded transaction + */ + toTransactionData: (migration: State.Migration) => Promise<{ + to: Address.Address + data: Hex.Hex + }> + + /** + * Decodes the payload from a migration + * @param payload The payload to decode + * @returns The decoded address and resulting image hash for the migration payload + */ + decodePayload: (payload: Payload.Calls) => Promise<{ + address: Address.Address + toImageHash: Hex.Hex + }> +} + +export interface Migrator { + fromVersion: number + toVersion: number + + convertWallet: (fromWallet: FromWallet, options: ConvertOptionsType) => Promise +} + +export const encoders: MigrationEncoder[] = [new MigrationEncoder_v1v3()] + +export function getMigrationEncoder< + FromConfigType, + ToConfigType, + ToContextType, + ConvertOptionsType, + PrepareOptionsType, +>( + fromVersion: number, + toVersion: number, +): MigrationEncoder { + const encoder = encoders.find((encoder) => encoder.fromVersion === fromVersion && encoder.toVersion === toVersion) + if (!encoder) { + throw new Error(`Unsupported from version: ${fromVersion} to version: ${toVersion}`) + } + return encoder +} diff --git a/packages/utils/migration/src/migrations/v1/encoder_v1_v3.ts b/packages/utils/migration/src/migrations/v1/encoder_v1_v3.ts new file mode 100644 index 000000000..a7e13347f --- /dev/null +++ b/packages/utils/migration/src/migrations/v1/encoder_v1_v3.ts @@ -0,0 +1,63 @@ +import { v1 } from '@0xsequence/v2core' +import { Config as V3Config, Context as V3Context } from '@0xsequence/wallet-primitives' +import { Address } from 'ox' +import { UnsignedMigration } from '../../types.js' +import { MigrationEncoder } from '../index.js' +import { BaseMigrationEncoder_v1v2, PrepareOptions as BasePrepareOptions } from '../v2/base.js' +import { ConvertOptions as V3ConvertOptions, createDefaultV3Topology } from '../v3/config.js' + +export type ConvertOptions = V3ConvertOptions +export type PrepareOptions = BasePrepareOptions + +// uint160(keccak256("org.sequence.sdk.migration.v1v3.space.nonce")) +export const MIGRATION_V1_V3_NONCE_SPACE = '0x9e4d5bdafd978baf1290aff23057245a2a62bef5' + +export class MigrationEncoder_v1v3 + extends BaseMigrationEncoder_v1v2 + implements + MigrationEncoder +{ + fromVersion = 1 + toVersion = 3 + + async convertConfig(fromConfig: v1.config.WalletConfig, options: ConvertOptions): Promise { + if (fromConfig.version !== 1) { + throw new Error('Invalid v1 config') + } + const signerLeaves: V3Config.SignerLeaf[] = fromConfig.signers.map((signer) => ({ + type: 'signer', + address: Address.from(signer.address), + weight: BigInt(signer.weight), + })) + const v1NestedTopology = V3Config.flatLeavesToTopology(signerLeaves) + return { + threshold: 1n, + checkpoint: 0n, + topology: [ + { + type: 'nested', + weight: 1n, + threshold: BigInt(fromConfig.threshold), + tree: v1NestedTopology, + }, + { + type: 'nested', + weight: 1n, + threshold: 2n, + tree: createDefaultV3Topology(options), + }, + ], + } + } + + async prepareMigration( + walletAddress: Address.Address, + toContext: V3Context.Context, + toConfig: V3Config.Config, + options: PrepareOptions, + ): Promise { + options.space = options.space ?? BigInt(MIGRATION_V1_V3_NONCE_SPACE) + + return super.prepareMigrationToImplementation(walletAddress, toContext.stage2, toConfig, options) + } +} diff --git a/packages/utils/migration/src/migrations/v1/migrator_v1_v3.ts b/packages/utils/migration/src/migrations/v1/migrator_v1_v3.ts new file mode 100644 index 000000000..c27a65d18 --- /dev/null +++ b/packages/utils/migration/src/migrations/v1/migrator_v1_v3.ts @@ -0,0 +1,89 @@ +import { v1, commons as v2commons } from '@0xsequence/v2core' +import { WalletV1 } from '@0xsequence/v2wallet' +import { State, Wallet as WalletV3 } from '@0xsequence/wallet-core' +import { Payload, Context as V3Context } from '@0xsequence/wallet-primitives' +import { Address, Hex } from 'ox' +import { Migrator } from '../index.js' +import { ConvertOptions, MigrationEncoder_v1v3, PrepareOptions } from './encoder_v1_v3.js' + +export type MigratorV1V3Options = ConvertOptions & + PrepareOptions & { + v3Context?: V3Context.Context + } + +export class Migrator_v1v3 implements Migrator { + fromVersion = 1 + toVersion = 3 + + constructor( + private readonly v3StateProvider: State.Provider, + private readonly encoder: MigrationEncoder_v1v3 = new MigrationEncoder_v1v3(), + ) {} + + private convertV1Context(v1Wallet: v2commons.context.WalletContext): V3Context.Context & { guest?: Address.Address } { + Hex.assert(v1Wallet.walletCreationCode) + return { + factory: Address.from(v1Wallet.factory), + stage1: Address.from(v1Wallet.mainModule), + stage2: Address.from(v1Wallet.mainModuleUpgradable), + creationCode: v1Wallet.walletCreationCode, + guest: Address.from(v1Wallet.guestModule), + } + } + + async convertWallet(v1Wallet: WalletV1, options: MigratorV1V3Options): Promise { + // Prepare configuration + const walletAddress = Address.from(v1Wallet.address) + const v3Context = options.v3Context || V3Context.Rc3 + const v1Config = v1Wallet.config + const v3Config = await this.encoder.convertConfig(v1Config, options) + + // Save v1 wallet information to v3 state provider + const v1ImageHash = v1.config.ConfigCoder.imageHashOf(v1Config) + Hex.assert(v1ImageHash) + if (this.v3StateProvider instanceof State.Sequence.Provider) { + // Force save the v1 configuration to key machine + const v1ServiceConfig = { + threshold: Number(v1Config.threshold), + signers: v1Config.signers.map(({ weight, address }) => ({ weight: Number(weight), address })), + } + await this.v3StateProvider.forceSaveConfiguration(v1ServiceConfig, this.fromVersion) + } + await this.v3StateProvider.saveDeploy(v1ImageHash, this.convertV1Context(v1Wallet.context)) + await this.v3StateProvider.saveConfiguration(v3Config) + + // Prepare migration + const unsignedMigration = await this.encoder.prepareMigration(walletAddress, v3Context, v3Config, options) + + // Sign migration + const chainId = v1Wallet.chainId + const v2Nonce = v2commons.transaction.encodeNonce(unsignedMigration.payload.space, unsignedMigration.payload.nonce) + const txBundle: v2commons.transaction.TransactionBundle = { + entrypoint: walletAddress, + transactions: unsignedMigration.payload.calls.map((tx: Payload.Call) => ({ + to: tx.to, + data: tx.data, + gasLimit: 0n, + revertOnError: true, + })), + nonce: v2Nonce, + } + const { signature } = await v1Wallet.signTransactionBundle(txBundle) + Hex.assert(signature) + + // Save to tracker + const signedMigration: State.Migration = { + ...unsignedMigration, + fromImageHash: v1ImageHash, + chainId: Number(chainId), + signature, + } + await this.v3StateProvider.saveMigration(walletAddress, signedMigration) + + // Return v3 wallet + return new WalletV3(walletAddress, { + knownContexts: [{ name: 'v3', development: false, ...v3Context }], + stateProvider: this.v3StateProvider, + }) + } +} diff --git a/packages/utils/migration/src/migrations/v2/base.ts b/packages/utils/migration/src/migrations/v2/base.ts new file mode 100644 index 000000000..8dab27d2a --- /dev/null +++ b/packages/utils/migration/src/migrations/v2/base.ts @@ -0,0 +1,115 @@ +import { commons as v2commons } from '@0xsequence/v2core' +import { State } from '@0xsequence/wallet-core' +import { Payload, Config as V3Config } from '@0xsequence/wallet-primitives' +import { AbiFunction, Address, Hex } from 'ox' +import { UnsignedMigration } from '../../types.js' + +export type PrepareOptions = { + space?: bigint +} + +// V1 and V2 share the same interfaces +export abstract class BaseMigrationEncoder_v1v2 { + abstract fromVersion: number + toVersion = 3 + + protected async prepareMigrationToImplementation( + walletAddress: Address.Address, + toImplementation: Address.Address, + toConfig: V3Config.Config, + options: PrepareOptions, + ): Promise { + const space = options?.space ?? 0n + const nonce = 0n // Nonce must be unused + + // Update implementation to v3 + const updateImplementationAbi = AbiFunction.from('function updateImplementation(address implementation)') + const updateImplementationTx: Payload.Call = { + to: walletAddress, + data: AbiFunction.encodeData(updateImplementationAbi, [toImplementation]), + value: 0n, + gasLimit: 0n, + delegateCall: false, + onlyFallback: false, + behaviorOnError: 'revert', + } + // Update configuration to v3 + const toImageHash = Hex.fromBytes(V3Config.hashConfiguration(toConfig)) + const updateImageHashAbi = AbiFunction.from('function updateImageHash(bytes32 imageHash)') + const updateImageHashTx: Payload.Call = { + to: walletAddress, + data: AbiFunction.encodeData(updateImageHashAbi, [toImageHash]), + value: 0n, + gasLimit: 0n, + delegateCall: false, + onlyFallback: false, + behaviorOnError: 'revert', + } + + const payload: Payload.Calls = { + type: 'call', + space, + nonce, + calls: [updateImplementationTx, updateImageHashTx], + } + + return { + payload, + fromVersion: this.fromVersion, + toVersion: this.toVersion, + toConfig, + } + } + + async toTransactionData(migration: State.Migration): Promise<{ to: Address.Address; data: Hex.Hex }> { + const { payload, signature, chainId } = migration + const walletAddress = payload.calls[0]!.to + const v2Nonce = v2commons.transaction.encodeNonce(payload.space, payload.nonce) + const transactions = payload.calls.map((tx) => ({ + to: tx.to, + data: tx.data, + gasLimit: tx.gasLimit, + revertOnError: tx.behaviorOnError === 'revert', + })) + const digest = v2commons.transaction.digestOfTransactions(v2Nonce, transactions) + const txBundle: v2commons.transaction.SignedTransactionBundle = { + entrypoint: walletAddress, + transactions, + nonce: v2Nonce, + chainId, + signature, + intent: { + id: digest, + wallet: walletAddress, + }, + } + const encodedData = v2commons.transaction.encodeBundleExecData(txBundle) + Hex.assert(encodedData) + return { + to: walletAddress, + data: encodedData, + } + } + + async decodePayload(payload: Payload.Calls): Promise<{ + address: Address.Address + toImageHash: Hex.Hex + }> { + if (payload.calls.length !== 2) { + throw new Error('Invalid calls') + } + const tx1 = payload.calls[0]! + const tx2 = payload.calls[1]! + if (tx1.to !== tx2.to) { + throw new Error('Invalid to address') + } + const updateImplementationAbi = AbiFunction.from('function updateImplementation(address implementation)') + AbiFunction.decodeData(updateImplementationAbi, tx1.data) // Check decoding works for update implementation + const updateImageHashAbi = AbiFunction.from('function updateImageHash(bytes32 imageHash)') + const updateImageHashArgs = AbiFunction.decodeData(updateImageHashAbi, tx2.data) + return { + address: tx1.to, + toImageHash: updateImageHashArgs[0], + } + } +} diff --git a/packages/utils/migration/src/migrations/v2/config.ts b/packages/utils/migration/src/migrations/v2/config.ts new file mode 100644 index 000000000..12cd26b8f --- /dev/null +++ b/packages/utils/migration/src/migrations/v2/config.ts @@ -0,0 +1,32 @@ +import { v2 } from '@0xsequence/v2core' +import { Config as V3Config } from '@0xsequence/wallet-primitives' +import { Address, Hex } from 'ox' + +export const convertTreeToTopology = (tree: v2.config.Topology): V3Config.Topology => { + if (v2.config.isSignerLeaf(tree)) { + return { + type: 'signer', + address: Address.from(tree.address), + weight: BigInt(tree.weight), + } + } + if (v2.config.isSubdigestLeaf(tree)) { + Hex.assert(tree.subdigest) + return { + type: 'subdigest', + digest: tree.subdigest, + } + } + if (v2.config.isNestedLeaf(tree)) { + return { + type: 'nested', + weight: BigInt(tree.weight), + threshold: BigInt(tree.threshold), + tree: convertTreeToTopology(tree.tree), + } + } + if (v2.config.isNode(tree)) { + return [convertTreeToTopology(tree.left), convertTreeToTopology(tree.right)] + } + throw new Error('Invalid tree') +} diff --git a/packages/utils/migration/src/migrations/v2/encoder_v2_v3.ts b/packages/utils/migration/src/migrations/v2/encoder_v2_v3.ts new file mode 100644 index 000000000..18bf77eb0 --- /dev/null +++ b/packages/utils/migration/src/migrations/v2/encoder_v2_v3.ts @@ -0,0 +1,59 @@ +import { v2 } from '@0xsequence/v2core' +import { Config as V3Config, Context as V3Context } from '@0xsequence/wallet-primitives' +import { Address } from 'ox' +import { UnsignedMigration } from '../../types.js' +import { MigrationEncoder } from '../index.js' +import { ConvertOptions as V3ConvertOptions, createDefaultV3Topology } from '../v3/config.js' +import { BaseMigrationEncoder_v1v2, PrepareOptions as BasePrepareOptions } from './base.js' +import { convertTreeToTopology } from './config.js' + +export type PrepareOptions = BasePrepareOptions +export type ConvertOptions = V3ConvertOptions + +// uint160(keccak256("org.sequence.sdk.migration.v2v3.space.nonce")) +export const MIGRATION_V2_V3_NONCE_SPACE = '0xf9fe6701dd3716c9cdb4faf375921627b507d142' + +export class MigrationEncoder_v2v3 + extends BaseMigrationEncoder_v1v2 + implements + MigrationEncoder +{ + fromVersion = 2 + toVersion = 3 + + async prepareMigration( + walletAddress: Address.Address, + toContext: V3Context.Context, + toConfig: V3Config.Config, + options: PrepareOptions, + ): Promise { + options.space = options.space ?? BigInt(MIGRATION_V2_V3_NONCE_SPACE) + + return super.prepareMigrationToImplementation(walletAddress, toContext.stage2, toConfig, options) + } + + async convertConfig(fromConfig: v2.config.WalletConfig, options: ConvertOptions): Promise { + if (fromConfig.version !== 2) { + throw new Error('Invalid v2 config') + } + const v2ConfigTopology: V3Config.Topology = convertTreeToTopology(fromConfig.tree) + return { + threshold: 1n, + checkpoint: 0n, + topology: [ + { + type: 'nested', + weight: 1n, + threshold: BigInt(fromConfig.threshold), + tree: v2ConfigTopology, + }, + { + type: 'nested', + weight: 1n, + threshold: 2n, + tree: createDefaultV3Topology(options), + }, + ], + } + } +} diff --git a/packages/utils/migration/src/migrations/v2/migrator_v2_v3.ts b/packages/utils/migration/src/migrations/v2/migrator_v2_v3.ts new file mode 100644 index 000000000..0f5613b92 --- /dev/null +++ b/packages/utils/migration/src/migrations/v2/migrator_v2_v3.ts @@ -0,0 +1,110 @@ +import { v2, commons as v2commons } from '@0xsequence/v2core' +import { WalletV2 } from '@0xsequence/v2wallet' +import { State, Wallet as WalletV3 } from '@0xsequence/wallet-core' +import { Payload, Context as V3Context } from '@0xsequence/wallet-primitives' +import { Address, Hex } from 'ox' +import { Migrator } from '../index.js' +import { ConvertOptions, MigrationEncoder_v2v3, PrepareOptions } from './encoder_v2_v3.js' + +export type MigratorV2V3Options = ConvertOptions & + PrepareOptions & { + v3Context?: V3Context.Context + } + +function encodeV2ConfigTree(tree: v2.config.Topology): any { + if (v2.config.isNode(tree)) { + return { + left: encodeV2ConfigTree(tree.left), + right: encodeV2ConfigTree(tree.right), + } + } else if (v2.config.isSignerLeaf(tree)) { + return { + weight: Number(tree.weight), + address: tree.address, + } + } else if (v2.config.isNestedLeaf(tree)) { + return { + weight: Number(tree.weight), + threshold: Number(tree.threshold), + tree: encodeV2ConfigTree(tree.tree), + } + } else if (v2.config.isNodeLeaf(tree)) { + return { node: tree.nodeHash } + } else { + return { ...tree } + } +} + +export class Migrator_v2v3 implements Migrator { + fromVersion = 2 + toVersion = 3 + + constructor( + private readonly v3StateProvider: State.Provider, + private readonly encoder: MigrationEncoder_v2v3 = new MigrationEncoder_v2v3(), + ) {} + + private convertV2Context(v2Wallet: v2commons.context.WalletContext): V3Context.Context & { guest?: Address.Address } { + Hex.assert(v2Wallet.walletCreationCode) + return { + factory: Address.from(v2Wallet.factory), + stage1: Address.from(v2Wallet.mainModule), + stage2: Address.from(v2Wallet.mainModuleUpgradable), + creationCode: v2Wallet.walletCreationCode, + guest: Address.from(v2Wallet.guestModule), + } + } + + async convertWallet(v2Wallet: WalletV2, options: MigratorV2V3Options): Promise { + // Prepare configuration + const walletAddress = Address.from(v2Wallet.address) + const v3Context = options.v3Context || V3Context.Rc3 + const v2Config = v2Wallet.config + const v3Config = await this.encoder.convertConfig(v2Config, options) + + // Save v2 wallet information to v3 state provider + const v2ImageHash = v2.config.ConfigCoder.imageHashOf(v2Config) + Hex.assert(v2ImageHash) + if (this.v3StateProvider instanceof State.Sequence.Provider) { + // Force save the v2 configuration to key machine + const v2ServiceConfig = encodeV2ConfigTree(v2Config.tree) + await this.v3StateProvider.forceSaveConfiguration(v2ServiceConfig, this.fromVersion) + } + await this.v3StateProvider.saveDeploy(v2ImageHash, this.convertV2Context(v2Wallet.context)) + await this.v3StateProvider.saveConfiguration(v3Config) + + // Prepare migration + const unsignedMigration = await this.encoder.prepareMigration(walletAddress, v3Context, v3Config, options) + + // Sign migration + const chainId = v2Wallet.chainId + const v2Nonce = v2commons.transaction.encodeNonce(unsignedMigration.payload.space, unsignedMigration.payload.nonce) + const txBundle: v2commons.transaction.TransactionBundle = { + entrypoint: walletAddress, + transactions: unsignedMigration.payload.calls.map((tx: Payload.Call) => ({ + to: tx.to, + data: tx.data, + gasLimit: 0n, + revertOnError: true, + })), + nonce: v2Nonce, + } + const { signature } = await v2Wallet.signTransactionBundle(txBundle) + Hex.assert(signature) + + // Save to tracker + const signedMigration: State.Migration = { + ...unsignedMigration, + fromImageHash: v2ImageHash, + chainId: Number(chainId), + signature, + } + await this.v3StateProvider.saveMigration(walletAddress, signedMigration) + + // Return v3 wallet + return new WalletV3(walletAddress, { + knownContexts: [{ name: 'v3', development: false, ...v3Context }], + stateProvider: this.v3StateProvider, + }) + } +} diff --git a/packages/utils/migration/src/migrations/v3/config.ts b/packages/utils/migration/src/migrations/v3/config.ts new file mode 100644 index 000000000..f44c331e5 --- /dev/null +++ b/packages/utils/migration/src/migrations/v3/config.ts @@ -0,0 +1,88 @@ +import { + Config as V3Config, + Extensions as V3Extensions, + GenericTree as V3GenericTree, + SessionConfig as V3SessionConfig, +} from '@0xsequence/wallet-primitives' +import { Address, Hex } from 'ox' + +export type ConvertOptions = { + loginSigner: { + address: Address.Address + imageHash?: Hex.Hex + } + noWalletGuard?: boolean + noSessions?: boolean + extensions?: V3Extensions.Extensions +} + +export const createDefaultV3Topology = (options: ConvertOptions): V3Config.Topology => { + const { loginSigner, extensions } = options + // Login topology + const loginTopology: V3Config.SapientSignerLeaf | V3Config.SignerLeaf = loginSigner.imageHash + ? { + type: 'sapient-signer', + address: loginSigner.address, + weight: 1n, + imageHash: loginSigner.imageHash, + } + : { + type: 'signer', + address: loginSigner.address, + weight: 1n, + } + + // Wallet guard topology + const walletGuardTopology: V3Config.SignerLeaf | undefined = options.noWalletGuard + ? undefined + : { + type: 'signer', + address: '0xa2e70CeaB3Eb145F32d110383B75B330fA4e288a', // Guard wallet signer + weight: 1n, + } + + // Placeholder recovery topology + const recoveryTopology: V3Config.SapientSignerLeaf = { + type: 'sapient-signer', + address: (extensions ?? V3Extensions.Rc3).recovery, + weight: 255n, + imageHash: '0x0000000000000000000000000000000000000000000000000000000000000000', + } + + // Session topology + let nestedSessionTopology: V3Config.NestedLeaf | undefined = undefined + if (!options.noSessions) { + let sessionsImageHash: Hex.Hex = '0x0000000000000000000000000000000000000000000000000000000000000000' + if (!loginSigner.imageHash) { + // We can't use the login signer with sessions if it is a sapient signer + const sessionsTopology = V3SessionConfig.emptySessionsTopology(loginSigner.address) + const sessionsConfig = V3SessionConfig.sessionsTopologyToConfigurationTree(sessionsTopology) + sessionsImageHash = V3GenericTree.hash(sessionsConfig) + } + const sessionTopology: V3Config.SapientSignerLeaf = { + type: 'sapient-signer', + address: (extensions ?? V3Extensions.Rc3).sessions, + weight: 1n, + imageHash: sessionsImageHash, + } + + // Sessions are protected by a guard signer + const sessionGuardTopology: V3Config.SignerLeaf = { + type: 'signer', + address: '0x18002Fc09deF9A47437cc64e270843dE094f5984', // Guard session signer + weight: 1n, + } + nestedSessionTopology = { + type: 'nested', + weight: 255n, + threshold: 2n, + tree: [sessionTopology, sessionGuardTopology], + } + } + + // Return the wallet topology + return [ + walletGuardTopology ? [loginTopology, walletGuardTopology] : loginTopology, + nestedSessionTopology ? [recoveryTopology, nestedSessionTopology] : recoveryTopology, + ] +} diff --git a/packages/utils/migration/src/types.ts b/packages/utils/migration/src/types.ts new file mode 100644 index 000000000..3819b7bea --- /dev/null +++ b/packages/utils/migration/src/types.ts @@ -0,0 +1,5 @@ +import { State } from '@0xsequence/wallet-core' + +export type UnsignedMigration = Omit & { + chainId?: number +} diff --git a/packages/utils/migration/test/testUtils.ts b/packages/utils/migration/test/testUtils.ts new file mode 100644 index 000000000..4c9ff5a9a --- /dev/null +++ b/packages/utils/migration/test/testUtils.ts @@ -0,0 +1,54 @@ +import { commons as v2commons } from '@0xsequence/v2core' +import { Signers as V3Signers } from '@0xsequence/wallet-core' +import { Context as V3Context } from '@0xsequence/wallet-primitives' +import { ethers } from 'ethers' +import { Address, Hex, Provider, Secp256k1 } from 'ox' + +export type MultiSigner = { + pk: Hex.Hex + address: Address.Address + v2: ethers.Signer + v3: V3Signers.Pk.Pk +} + +export const createMultiSigner = (pk: Hex.Hex, provider: ethers.Provider): MultiSigner => { + const v2Signer = new ethers.Wallet(pk, provider) + // Override the v2.getAddress() to return the address lower cased for v1 Orchestrator compatibility + const v2: any = v2Signer + v2.getAddress = async () => { + return v2Signer.address.toLowerCase() + } + return { + pk, + address: Address.fromPublicKey(Secp256k1.getPublicKey({ privateKey: pk })), + v2, + v3: new V3Signers.Pk.Pk(pk), + } +} + +export const createAnvilSigner = async ( + v2Provider: ethers.Provider, + v3Provider: Provider.Provider, +): Promise => { + const anvilSigner = createMultiSigner(Secp256k1.randomPrivateKey(), v2Provider) + await v3Provider.request({ + method: 'anvil_impersonateAccount', + params: [anvilSigner.address], + }) + await v3Provider.request({ + method: 'anvil_setBalance', + params: [anvilSigner.address, '0x1000000000000000000000000000000000000000'], + }) + return anvilSigner +} + +export const convertV2ContextToV3Context = (context: v2commons.context.WalletContext): V3Context.Context => { + Hex.assert(context.walletCreationCode) + return { + // Close enough + factory: Address.from(context.factory), + stage1: Address.from(context.mainModule), + stage2: Address.from(context.mainModuleUpgradable), + creationCode: context.walletCreationCode, + } +} diff --git a/packages/utils/migration/test/v1/encoder_v1_v3.test.ts b/packages/utils/migration/test/v1/encoder_v1_v3.test.ts new file mode 100644 index 000000000..be7f31a9c --- /dev/null +++ b/packages/utils/migration/test/v1/encoder_v1_v3.test.ts @@ -0,0 +1,623 @@ +import { LocalRelayer } from '@0xsequence/relayerv2' +import { Orchestrator } from '@0xsequence/signhubv2' +import { v1, commons as v2commons } from '@0xsequence/v2core' +import { Wallet as V1Wallet } from '@0xsequence/v2wallet' // V1 and V2 wallets share the same implementation +import { Envelope, State, Wallet as V3Wallet } from '@0xsequence/wallet-core' +import { + Payload, + Config as V3Config, + Context as V3Context, + Extensions as V3Extensions, +} from '@0xsequence/wallet-primitives' +import { ethers } from 'ethers' +import { AbiFunction, Address, Hex, Provider, RpcTransport, Secp256k1 } from 'ox' +import { fromRpcStatus } from 'ox/TransactionReceipt' +import { assert, beforeEach, describe, expect, it } from 'vitest' +import { MIGRATION_V1_V3_NONCE_SPACE, MigrationEncoder_v1v3 } from '../../src/migrations/v1/encoder_v1_v3.js' +import { convertV2ContextToV3Context, createAnvilSigner, createMultiSigner, MultiSigner } from '../testUtils.js' + +describe('MigrationEncoder_v1v3', async () => { + let anvilSigner: MultiSigner + let testSigner: MultiSigner + + let providers: { + v2: ethers.Provider + v3: Provider.Provider + } + let chainId: number + + let migration: MigrationEncoder_v1v3 + + let testAddress: Address.Address + + beforeEach(async () => { + migration = new MigrationEncoder_v1v3() + const url = 'http://127.0.0.1:8545' + providers = { + v2: ethers.getDefaultProvider(url), + v3: Provider.from(RpcTransport.fromHttp(url)), + } + chainId = Number(await providers.v3.request({ method: 'eth_chainId' })) + testAddress = '0x742d35cc6635c0532925a3b8d563a6b35b7f05f1' + const testSignerPk = Secp256k1.randomPrivateKey() + testSigner = createMultiSigner(testSignerPk, providers.v2) + }) + + describe('convertConfig', async () => { + it('should convert v1 config to v3 config with single signer', async () => { + const v1Config: v1.config.WalletConfig = { + version: 1, + threshold: 1, + signers: [ + { + weight: 1, + address: testSigner.address, + }, + ], + } + + const options = { + loginSigner: { + address: testSigner.address, + }, + } + + const v3Config = await migration.convertConfig(v1Config, options) + + expect(v3Config.threshold).toBe(1n) + expect(v3Config.checkpoint).toBe(0n) + expect(v3Config.topology).toHaveLength(2) + + // Check first topology (v1 signers) - single signer becomes a single leaf + const v1Topology = v3Config.topology[0] as V3Config.NestedLeaf + expect(v1Topology.type).toBe('nested') + expect(v1Topology.weight).toBe(1n) + expect(v1Topology.threshold).toBe(1n) + expect(V3Config.isSignerLeaf(v1Topology.tree)).toBe(true) + if (V3Config.isSignerLeaf(v1Topology.tree)) { + expect(v1Topology.tree.type).toBe('signer') + expect(v1Topology.tree.address).toBe(testSigner.address) + expect(v1Topology.tree.weight).toBe(1n) + } + + // Check second topology (v3 extensions) + const v3Topology = v3Config.topology[1] as V3Config.NestedLeaf + expect(v3Topology.type).toBe('nested') + expect(v3Topology.weight).toBe(1n) + expect(v3Topology.threshold).toBe(2n) + }) + + it('should convert v1 config to v3 config with multiple signers', async () => { + const testSigner2 = createMultiSigner(Secp256k1.randomPrivateKey(), providers.v2) + const v1Config: v1.config.WalletConfig = { + version: 1, + threshold: 2, + signers: [ + { + weight: 1, + address: testSigner.address, + }, + { + weight: 1, + address: testSigner2.address, + }, + ], + } + + const options = { + loginSigner: { + address: testSigner.address, + }, + } + + const v3Config = await migration.convertConfig(v1Config, options) + + expect(v3Config.threshold).toBe(1n) + expect(v3Config.checkpoint).toBe(0n) + + // Check first topology (v1 signers) - multiple signers become a node array + const v1Topology = v3Config.topology[0] as V3Config.NestedLeaf + expect(v1Topology.type).toBe('nested') + expect(v1Topology.weight).toBe(1n) + expect(v1Topology.threshold).toBe(2n) + expect(Array.isArray(v1Topology.tree)).toBe(true) + expect(v1Topology.tree).toHaveLength(2) + expect(v1Topology.tree[0].type).toBe('signer') + expect(v1Topology.tree[0].address).toBe(testSigner.address) + expect(v1Topology.tree[0].weight).toBe(1n) + expect(v1Topology.tree[1].type).toBe('signer') + expect(v1Topology.tree[1].address).toBe(testSigner2.address) + expect(v1Topology.tree[1].weight).toBe(1n) + }) + + it('should convert v1 config with custom extensions', async () => { + const v1Config: v1.config.WalletConfig = { + version: 1, + threshold: 1, + signers: [ + { + weight: 1, + address: testSigner.address, + }, + ], + } + + const customExtensions: V3Extensions.Extensions = { + passkeys: '0x1234567890123456789012345678901234567890', + recovery: '0x1111111111111111111111111111111111111111', + sessions: '0x2222222222222222222222222222222222222222', + } + + const options = { + loginSigner: { + address: testSigner.address, + }, + extensions: customExtensions, + } + + const v3Config = await migration.convertConfig(v1Config, options) + + // Check that custom extensions are used in the v3 topology + const v3Topology = v3Config.topology[1] as V3Config.NestedLeaf + // The v3 topology should have a tree that's an array with two sub-arrays + expect(Array.isArray(v3Topology.tree)).toBe(true) + expect(v3Topology.tree).toHaveLength(2) + + // First sub-array should contain login and guard signers + const loginArray = v3Topology.tree[0] as V3Config.Node + expect(Array.isArray(loginArray)).toBe(true) + expect(loginArray).toHaveLength(2) + + // First element should be login topology + const loginTopology = loginArray[0] + expect(V3Config.isSignerLeaf(loginTopology)).toBe(true) + if (V3Config.isSignerLeaf(loginTopology)) { + expect(loginTopology.type).toBe('signer') + expect(loginTopology.address).toBe(testSigner.address) + } + + // Second sub-array should contain recovery and sessions modules + const modulesArray = v3Topology.tree[1] as V3Config.Node + expect(Array.isArray(modulesArray)).toBe(true) + expect(modulesArray).toHaveLength(2) + + // First module should be recovery + const recoveryLeaf = modulesArray[0] as V3Config.SapientSignerLeaf + expect(recoveryLeaf.type).toBe('sapient-signer') + expect(recoveryLeaf.address).toBe(customExtensions.recovery) + + // Second module should be sessions (nested) + const sessionsLeaf = modulesArray[1] as V3Config.NestedLeaf + expect(sessionsLeaf.type).toBe('nested') + expect(Array.isArray(sessionsLeaf.tree)).toBe(true) + expect(sessionsLeaf.tree).toHaveLength(2) + + const sessionsSapientLeaf = sessionsLeaf.tree[0] as V3Config.SapientSignerLeaf + expect(sessionsSapientLeaf.type).toBe('sapient-signer') + expect(sessionsSapientLeaf.address).toBe(customExtensions.sessions) + }) + + it('should handle login signer with image hash', async () => { + const v1Config: v1.config.WalletConfig = { + version: 1, + threshold: 1, + signers: [ + { + weight: 1, + address: testSigner.address, + }, + ], + } + + const imageHash = '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef' + const options = { + loginSigner: { + address: testSigner.address, + imageHash: imageHash as Hex.Hex, + }, + } + + const v3Config = await migration.convertConfig(v1Config, options) + + // Check that login signer is a sapient signer with image hash + const v3Topology = v3Config.topology[1] as V3Config.NestedLeaf + expect(Array.isArray(v3Topology.tree)).toBe(true) + expect(v3Topology.tree).toHaveLength(2) + + // First sub-array should contain login and guard signers + const loginArray = v3Topology.tree[0] as V3Config.Node + expect(Array.isArray(loginArray)).toBe(true) + expect(loginArray).toHaveLength(2) + + // First element should be login topology (sapient signer with image hash) + const loginTopology = loginArray[0] as V3Config.SapientSignerLeaf + expect(loginTopology.type).toBe('sapient-signer') + expect(loginTopology.address).toBe(testSigner.address) + expect(loginTopology.imageHash).toBe(imageHash) + }) + }) + + describe('prepareMigration', async () => { + it('should prepare migration transactions correctly', async () => { + const walletAddress = testAddress + + const v3Config: V3Config.Config = { + threshold: 1n, + checkpoint: 0n, + topology: [ + { + type: 'nested', + weight: 1n, + threshold: 1n, + tree: { + type: 'signer', + address: testSigner.address, + weight: 1n, + }, + }, + { + type: 'nested', + weight: 1n, + threshold: 2n, + tree: [ + { + type: 'signer', + address: testSigner.address, + weight: 1n, + }, + { + type: 'signer', + address: '0xa2e70CeaB3Eb145F32d110383B75B330fA4e288a', + weight: 1n, + }, + ], + }, + ], + } + + const randomSpace = BigInt(Math.floor(Math.random() * 10000000000)) + const migrationResult = await migration.prepareMigration(walletAddress, V3Context.Rc3, v3Config, { + space: BigInt(randomSpace), + }) + + expect(migrationResult.fromVersion).toBe(1) + expect(migrationResult.toVersion).toBe(3) + expect(migrationResult.payload.calls).toHaveLength(2) + expect(migrationResult.payload.nonce).toBe(0n) + expect(migrationResult.payload.space).toBe(randomSpace) + + // Check first transaction (update implementation) + const updateImplTx = migrationResult.payload.calls[0] + expect(updateImplTx.to).toBe(walletAddress) + + const updateImplementationAbi = AbiFunction.from('function updateImplementation(address implementation)') + const decodedImplArgs = AbiFunction.decodeData(updateImplementationAbi, updateImplTx.data) + expect(decodedImplArgs[0].toLowerCase()).toBe(V3Context.Rc3.stage2.toLowerCase()) + + // Check second transaction (update image hash) + const updateImageHashTx = migrationResult.payload.calls[1] + expect(updateImageHashTx.to).toBe(walletAddress) + + const updateImageHashAbi = AbiFunction.from('function updateImageHash(bytes32 imageHash)') + const decodedImageHashArgs = AbiFunction.decodeData(updateImageHashAbi, updateImageHashTx.data) + const expectedImageHash = Hex.fromBytes(V3Config.hashConfiguration(v3Config)) + expect(decodedImageHashArgs[0]).toBe(expectedImageHash) + }) + + it('should use custom context when provided', async () => { + const walletAddress = testAddress + const customContext: V3Context.Context = { + stage1: '0x1111111111111111111111111111111111111111', + stage2: '0x2222222222222222222222222222222222222222', + creationCode: '0x3333333333333333333333333333333333333333333333333333333333333333', + factory: '0x4444444444444444444444444444444444444444', + } + + const v3Config: V3Config.Config = { + threshold: 1n, + checkpoint: 0n, + topology: [ + { + type: 'nested', + weight: 1n, + threshold: 1n, + tree: { + type: 'signer', + address: testSigner.address, + weight: 1n, + }, + }, + { + type: 'nested', + weight: 1n, + threshold: 2n, + tree: [ + { + type: 'signer', + address: testSigner.address, + weight: 1n, + }, + { + type: 'signer', + address: '0xa2e70CeaB3Eb145F32d110383B75B330fA4e288a', + weight: 1n, + }, + ], + }, + ], + } + + const migrationResult = await migration.prepareMigration(walletAddress, customContext, v3Config, {}) + + const updateImplTx = migrationResult.payload.calls[0] + const updateImplementationAbi = AbiFunction.from('function updateImplementation(address implementation)') + const decodedImplArgs = AbiFunction.decodeData(updateImplementationAbi, updateImplTx.data) + expect(decodedImplArgs[0]).toBe(customContext.stage2) + }) + }) + + describe('decodeTransactions', async () => { + it('should decode transactions correctly', async () => { + const walletAddress = testAddress + const imageHash = '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef' + + const updateImplementationAbi = AbiFunction.from('function updateImplementation(address implementation)') + const updateImageHashAbi = AbiFunction.from('function updateImageHash(bytes32 imageHash)') + + const payload: Payload.Calls = { + type: 'call', + space: 0n, + nonce: 0n, + calls: [ + { + to: walletAddress, + value: 0n, + gasLimit: 0n, + delegateCall: false, + onlyFallback: false, + behaviorOnError: 'revert', + data: AbiFunction.encodeData(updateImplementationAbi, [V3Context.Rc3.stage2]), + }, + { + to: walletAddress, + value: 0n, + gasLimit: 0n, + delegateCall: false, + onlyFallback: false, + behaviorOnError: 'revert', + data: AbiFunction.encodeData(updateImageHashAbi, [imageHash]), + }, + ], + } + + const decoded = await migration.decodePayload(payload) + + expect(decoded.address).toBe(walletAddress) + expect(decoded.toImageHash).toBe(imageHash) + }) + + it('should throw error for invalid number of calls', async () => { + const payload: Payload.Calls = { + type: 'call', + space: 0n, + nonce: 0n, + calls: [ + { + to: testAddress, + value: 0n, + data: '0x1234567890abcdef', + gasLimit: 0n, + delegateCall: false, + onlyFallback: false, + behaviorOnError: 'revert', + }, + ], + } + + await expect(migration.decodePayload(payload)).rejects.toThrow('Invalid calls') + }) + + it('should throw error when payload addresses do not match', async () => { + const differentAddress = '0x9999999999999999999999999999999999999999' + const updateImplementationAbi = AbiFunction.from('function updateImplementation(address implementation)') + const updateImageHashAbi = AbiFunction.from('function updateImageHash(bytes32 imageHash)') + + const payload: Payload.Calls = { + type: 'call', + space: 0n, + nonce: 0n, + calls: [ + { + to: testAddress, + value: 0n, + data: AbiFunction.encodeData(updateImplementationAbi, [V3Context.Rc3.stage2]), + gasLimit: 0n, + delegateCall: false, + onlyFallback: false, + behaviorOnError: 'revert', + }, + { + to: differentAddress, + value: 0n, + data: AbiFunction.encodeData(updateImageHashAbi, [ + '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + ]), + gasLimit: 0n, + delegateCall: false, + onlyFallback: false, + behaviorOnError: 'revert', + }, + ], + } + + await expect(migration.decodePayload(payload)).rejects.toThrow('Invalid to address') + }) + + it('should throw error for invalid payload data', async () => { + const payload: Payload.Calls = { + type: 'call', + space: 0n, + nonce: 0n, + calls: [ + { + to: testAddress, + value: 0n, + gasLimit: 0n, + delegateCall: false, + onlyFallback: false, + behaviorOnError: 'revert', + data: '0xinvalid', + }, + { + to: testAddress, + value: 0n, + gasLimit: 0n, + delegateCall: false, + onlyFallback: false, + behaviorOnError: 'revert', + data: '0xalsoinvalid', + }, + ], + } + + await expect(migration.decodePayload(payload)).rejects.toThrow(/^Invalid byte sequence/) + }) + }) + + describe('constants', async () => { + it('should have correct nonce space', () => { + expect(MIGRATION_V1_V3_NONCE_SPACE).toBe('0x9e4d5bdafd978baf1290aff23057245a2a62bef5') + }) + + it('should have correct version numbers', () => { + expect(migration.fromVersion).toBe(1) + expect(migration.toVersion).toBe(3) + }) + }) + + describe('integration test', async () => { + it('should use migration ', async () => { + // Create v1 config + const v1Config: v1.config.WalletConfig = { + version: 1, + threshold: 1, + signers: [ + { + weight: 1, + address: testSigner.address, + }, + // Include a random signer to avoid image hash collisions + { + weight: 1, + address: Address.fromPublicKey(Secp256k1.getPublicKey({ privateKey: Secp256k1.randomPrivateKey() })), + }, + ], + } + const v1ImageHash = v1.config.ConfigCoder.imageHashOf(v1Config) + Hex.assert(v1ImageHash) + const orchestrator = new Orchestrator([testSigner.v2]) + const anvilSigner = await createAnvilSigner(providers.v2, providers.v3) + const v1Wallet = await V1Wallet.newWallet< + v1.config.WalletConfig, + v1.signature.Signature, + v1.signature.UnrecoveredSignature + >({ + context: v1.DeployedWalletContext, + chainId: Number(chainId), + coders: { + config: v1.config.ConfigCoder, + signature: v1.signature.SignatureCoder, + }, + orchestrator, + config: v1Config, + provider: providers.v2, + relayer: new LocalRelayer(anvilSigner.v2), + }) + const walletAddress = Address.from(v1Wallet.address) + + // Convert to v3 config + const options = { + loginSigner: { + address: testSigner.address, + }, + } + const v3Config = await migration.convertConfig(v1Config, options) + + // Prepare migration + const unsignedMigration = await migration.prepareMigration(walletAddress, V3Context.Rc3, v3Config, {}) + + // Decode transactions + const decoded = await migration.decodePayload(unsignedMigration.payload) + expect(decoded.address).toBe(walletAddress) + expect(decoded.toImageHash).toBe(Hex.fromBytes(V3Config.hashConfiguration(v3Config))) + + // Sign it using v1 wallet + const v2Nonce = v2commons.transaction.encodeNonce( + unsignedMigration.payload.space, + unsignedMigration.payload.nonce, + ) + const txBundle: v2commons.transaction.TransactionBundle = { + entrypoint: walletAddress, + transactions: unsignedMigration.payload.calls.map( + (call): v2commons.transaction.Transaction => ({ + to: call.to, + data: call.data, + gasLimit: call.gasLimit.toString(), + delegateCall: call.delegateCall, + revertOnError: call.behaviorOnError === 'revert', + }), + ), + nonce: v2Nonce, + } + const signedTxBundle = await v1Wallet.signTransactionBundle(txBundle) + const decorated = await v1Wallet.decorateTransactions(signedTxBundle) + + // Send it + const tx = await v1Wallet.sendSignedTransaction(decorated) + const receipt = await tx.wait() + expect(receipt?.status).toBe(1) + // This should now be a v3 wallet on chain + + // Save the wallet information to the state provider + const stateProvider = new State.Local.Provider() + await stateProvider.saveDeploy(v1ImageHash, convertV2ContextToV3Context(v1.DeployedWalletContext)) + await stateProvider.saveConfiguration(v3Config) + + // Test the wallet works as a v3 wallet now with a test transaction + const v3Wallet = new V3Wallet(walletAddress, { stateProvider }) + const call: Payload.Call = { + to: Address.from('0x0000000000000000000000000000000000000000'), + data: Hex.fromString('0x'), + value: 0n, + gasLimit: 0n, + delegateCall: false, + onlyFallback: false, + behaviorOnError: 'revert', + } + const envelope = await v3Wallet.prepareTransaction(providers.v3, [call], { + space: BigInt(Math.floor(Math.random() * 1000000000000000000)), + noConfigUpdate: true, + }) + + const signature = await testSigner.v3.sign(v3Wallet.address, Number(chainId), envelope.payload) + const signedEnvelope = Envelope.toSigned(envelope, [ + { + address: testSigner.address, + signature, + }, + ]) + const signedTx = await v3Wallet.buildTransaction(providers.v3, signedEnvelope) + const testTx = await providers.v3.request({ + method: 'eth_sendTransaction', + params: [signedTx], + }) + await new Promise((resolve) => setTimeout(resolve, 1000)) + const testReceipt = await providers.v3.request({ + method: 'eth_getTransactionReceipt', + params: [testTx], + }) + assert(testReceipt?.status, 'Receipt status is undefined') + expect(fromRpcStatus[testReceipt.status]).toBe('success') + }) + }, 30_000) +}) diff --git a/packages/utils/migration/test/v1/migrator_v1_v3.test.ts b/packages/utils/migration/test/v1/migrator_v1_v3.test.ts new file mode 100644 index 000000000..a1ceda1da --- /dev/null +++ b/packages/utils/migration/test/v1/migrator_v1_v3.test.ts @@ -0,0 +1,121 @@ +import { LocalRelayer } from '@0xsequence/relayerv2' +import { Orchestrator } from '@0xsequence/signhubv2' +import { v1 } from '@0xsequence/v2core' +import { Wallet as V1Wallet } from '@0xsequence/v2wallet' // V1 and V2 wallets share the same implementation +import { Envelope, State } from '@0xsequence/wallet-core' +import { Payload } from '@0xsequence/wallet-primitives' +import { ethers } from 'ethers' +import { Address, Hex, Provider, RpcTransport, Secp256k1 } from 'ox' +import { fromRpcStatus } from 'ox/TransactionReceipt' +import { assert, beforeEach, describe, expect, it } from 'vitest' +import { Migrator_v1v3, MigratorV1V3Options } from '../../src/migrations/v1/migrator_v1_v3.js' +import { createAnvilSigner, createMultiSigner, type MultiSigner } from '../testUtils.js' + +describe('Migrator_v1v3', async () => { + let testSigner: MultiSigner + + let providers: { + v2: ethers.Provider + v3: Provider.Provider + } + let chainId: number + + let stateProvider: State.Provider + + let migrator: Migrator_v1v3 + + beforeEach(async () => { + const url = 'http://127.0.0.1:8545' + providers = { + v2: ethers.getDefaultProvider(url), + v3: Provider.from(RpcTransport.fromHttp(url)), + } + chainId = Number(await providers.v3.request({ method: 'eth_chainId' })) + + stateProvider = new State.Local.Provider() + // stateProvider = new State.Sequence.Provider('http://127.0.0.1:36261') + migrator = new Migrator_v1v3(stateProvider) + + testSigner = createMultiSigner(Secp256k1.randomPrivateKey(), providers.v2) + }) + + describe('convertWallet', async () => { + it('should convert a v1 wallet to a v3 wallet', async () => { + const v1Config = { + version: 1, + threshold: 1, + signers: [ + { + weight: 1, + address: testSigner.address, + }, + // Include a random signer to avoid image hash collisions + { + weight: 1, + address: Address.fromPublicKey(Secp256k1.getPublicKey({ privateKey: Secp256k1.randomPrivateKey() })), + }, + ], + } + const orchestrator = new Orchestrator([testSigner.v2]) + const anvilSigner = await createAnvilSigner(providers.v2, providers.v3) + const v1Wallet = await V1Wallet.newWallet< + v1.config.WalletConfig, + v1.signature.Signature, + v1.signature.UnrecoveredSignature + >({ + context: v1.DeployedWalletContext, + chainId, + coders: { + config: v1.config.ConfigCoder, + signature: v1.signature.SignatureCoder, + }, + orchestrator, + config: v1Config, + relayer: new LocalRelayer(anvilSigner.v2), + }) + const v1ImageHash = v1.config.ConfigCoder.imageHashOf(v1Config) + + const options: MigratorV1V3Options = { + loginSigner: { + address: testSigner.address, + }, + } + const v3Wallet = await migrator.convertWallet(v1Wallet, options) + + // Test the wallet works as a v3 wallet now with a test transaction + const call: Payload.Call = { + to: Address.from('0x0000000000000000000000000000000000000000'), + data: Hex.fromString('0x'), + value: 0n, + gasLimit: 0n, + delegateCall: false, + onlyFallback: false, + behaviorOnError: 'revert', + } + const envelope = await v3Wallet.prepareTransaction(providers.v3, [call], { + space: BigInt(Math.floor(Math.random() * 1000000000000000000)), + noConfigUpdate: true, + }) + + const signature = await testSigner.v3.sign(v3Wallet.address, Number(chainId), envelope.payload) + const signedEnvelope = Envelope.toSigned(envelope, [ + { + address: testSigner.address, + signature, + }, + ]) + const signedTx = await v3Wallet.buildTransaction(providers.v3, signedEnvelope) + const testTx = await providers.v3.request({ + method: 'eth_sendTransaction', + params: [signedTx], + }) + await new Promise((resolve) => setTimeout(resolve, 1000)) + const receipt = await providers.v3.request({ + method: 'eth_getTransactionReceipt', + params: [testTx], + }) + assert(receipt?.status, 'Receipt status is undefined') + expect(fromRpcStatus[receipt.status]).toBe('success') + }) + }, 30_000) +}) diff --git a/packages/utils/migration/test/v2/encoder_v2_v3.test.ts b/packages/utils/migration/test/v2/encoder_v2_v3.test.ts new file mode 100644 index 000000000..a8513962e --- /dev/null +++ b/packages/utils/migration/test/v2/encoder_v2_v3.test.ts @@ -0,0 +1,621 @@ +import { LocalRelayer } from '@0xsequence/relayerv2' +import { Orchestrator } from '@0xsequence/signhubv2' +import { v2, commons as v2commons } from '@0xsequence/v2core' +import { Wallet as V2Wallet } from '@0xsequence/v2wallet' +import { Envelope, State, Wallet as V3Wallet } from '@0xsequence/wallet-core' +import { + Payload, + Config as V3Config, + Context as V3Context, + Extensions as V3Extensions, +} from '@0xsequence/wallet-primitives' +import { ethers } from 'ethers' +import { AbiFunction, Address, Hex, Provider, RpcTransport, Secp256k1 } from 'ox' +import { fromRpcStatus } from 'ox/TransactionReceipt' +import { assert, beforeEach, describe, expect, it } from 'vitest' +import { MIGRATION_V2_V3_NONCE_SPACE, MigrationEncoder_v2v3 } from '../../src/migrations/v2/encoder_v2_v3.js' +import { convertV2ContextToV3Context, createAnvilSigner, createMultiSigner, MultiSigner } from '../testUtils.js' + +describe('MigrationEncoder_v2v3', async () => { + let testSigner: MultiSigner + + let providers: { + v2: ethers.Provider + v3: Provider.Provider + } + let chainId: number + + let migration: MigrationEncoder_v2v3 + + let testAddress: Address.Address + + beforeEach(async () => { + migration = new MigrationEncoder_v2v3() + const url = 'http://127.0.0.1:8545' + providers = { + v2: ethers.getDefaultProvider(url), + v3: Provider.from(RpcTransport.fromHttp(url)), + } + chainId = Number(await providers.v3.request({ method: 'eth_chainId' })) + testAddress = '0x742d35cc6635c0532925a3b8d563a6b35b7f05f1' + const testSignerPk = Secp256k1.randomPrivateKey() + testSigner = createMultiSigner(testSignerPk, providers.v2) + }) + + describe('convertConfig', async () => { + it('should convert v2 config to v3 config with single signer', async () => { + const v2Config: v2.config.WalletConfig = { + version: 2, + threshold: 1, + checkpoint: 0, + tree: { + address: testSigner.address, + weight: 1, + }, + } + + const options = { + loginSigner: { + address: testSigner.address, + }, + } + + const v3Config = await migration.convertConfig(v2Config, options) + + expect(v3Config.threshold).toBe(1n) + expect(v3Config.checkpoint).toBe(0n) + expect(v3Config.topology).toHaveLength(2) + + // Check first topology (v2 signers) - becomes a nested leaf + const v2Topology = v3Config.topology[0] as V3Config.NestedLeaf + expect(v2Topology.type).toBe('nested') + expect(v2Topology.weight).toBe(1n) + expect(v2Topology.threshold).toBe(1n) + expect(V3Config.isSignerLeaf(v2Topology.tree)).toBe(true) + if (V3Config.isSignerLeaf(v2Topology.tree)) { + expect(v2Topology.tree.type).toBe('signer') + expect(v2Topology.tree.address).toBe(testSigner.address) + expect(v2Topology.tree.weight).toBe(1n) + } + + // Check second topology (v3 extensions) + const v3Topology = v3Config.topology[1] as V3Config.NestedLeaf + expect(v3Topology.type).toBe('nested') + expect(v3Topology.weight).toBe(1n) + expect(v3Topology.threshold).toBe(2n) + }) + + it('should convert v2 config to v3 config with multiple signers', async () => { + const testSigner2 = createMultiSigner(Secp256k1.randomPrivateKey(), providers.v2) + const v2Config: v2.config.WalletConfig = { + version: 2, + threshold: 2, + checkpoint: 0, + tree: { + left: { + address: testSigner.address, + weight: 1, + }, + right: { + address: testSigner2.address, + weight: 1, + }, + }, + } + + const options = { + loginSigner: { + address: testSigner.address, + }, + } + + const v3Config = await migration.convertConfig(v2Config, options) + + expect(v3Config.threshold).toBe(1n) + expect(v3Config.checkpoint).toBe(0n) + + // Check first topology (v2 signers) - multiple signers become a node array + const v2Topology = v3Config.topology[0] as V3Config.NestedLeaf + expect(v2Topology.type).toBe('nested') + expect(v2Topology.weight).toBe(1n) + expect(v2Topology.threshold).toBe(2n) + expect(Array.isArray(v2Topology.tree)).toBe(true) + expect(v2Topology.tree).toHaveLength(2) + expect(v2Topology.tree[0].type).toBe('signer') + expect(v2Topology.tree[0].address).toBe(testSigner.address) + expect(v2Topology.tree[0].weight).toBe(1n) + expect(v2Topology.tree[1].type).toBe('signer') + expect(v2Topology.tree[1].address).toBe(testSigner2.address) + expect(v2Topology.tree[1].weight).toBe(1n) + }) + + it('should convert v2 config with custom extensions', async () => { + const v2Config: v2.config.WalletConfig = { + version: 2, + threshold: 1, + checkpoint: 0, + tree: { + address: testSigner.address, + weight: 1, + }, + } + + const customExtensions: V3Extensions.Extensions = { + passkeys: '0x1234567890123456789012345678901234567890', + recovery: '0x1111111111111111111111111111111111111111', + sessions: '0x2222222222222222222222222222222222222222', + } + + const options = { + loginSigner: { + address: testSigner.address, + }, + extensions: customExtensions, + } + + const v3Config = await migration.convertConfig(v2Config, options) + + // Check that custom extensions are used in the v3 topology + const v3Topology = v3Config.topology[1] as V3Config.NestedLeaf + // The v3 topology should have a tree that's an array with two sub-arrays + expect(Array.isArray(v3Topology.tree)).toBe(true) + expect(v3Topology.tree).toHaveLength(2) + + // First sub-array should contain login and guard signers + const loginArray = v3Topology.tree[0] as V3Config.Node + expect(Array.isArray(loginArray)).toBe(true) + expect(loginArray).toHaveLength(2) + + // First element should be login topology + const loginTopology = loginArray[0] + expect(V3Config.isSignerLeaf(loginTopology)).toBe(true) + if (V3Config.isSignerLeaf(loginTopology)) { + expect(loginTopology.type).toBe('signer') + expect(loginTopology.address).toBe(testSigner.address) + } + + // Second sub-array should contain recovery and sessions modules + const modulesArray = v3Topology.tree[1] as V3Config.Node + expect(Array.isArray(modulesArray)).toBe(true) + expect(modulesArray).toHaveLength(2) + + // First module should be recovery + const recoveryLeaf = modulesArray[0] as V3Config.SapientSignerLeaf + expect(recoveryLeaf.type).toBe('sapient-signer') + expect(recoveryLeaf.address).toBe(customExtensions.recovery) + + // Second module should be sessions (nested) + const sessionsLeaf = modulesArray[1] as V3Config.NestedLeaf + expect(sessionsLeaf.type).toBe('nested') + expect(Array.isArray(sessionsLeaf.tree)).toBe(true) + expect(sessionsLeaf.tree).toHaveLength(2) + + const sessionsSapientLeaf = sessionsLeaf.tree[0] as V3Config.SapientSignerLeaf + expect(sessionsSapientLeaf.type).toBe('sapient-signer') + expect(sessionsSapientLeaf.address).toBe(customExtensions.sessions) + }) + + it('should handle login signer with image hash', async () => { + const v2Config: v2.config.WalletConfig = { + version: 2, + threshold: 1, + checkpoint: 0, + tree: { + address: testSigner.address, + weight: 1, + }, + } + + const imageHash = '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef' + const options = { + loginSigner: { + address: testSigner.address, + imageHash: imageHash as Hex.Hex, + }, + } + + const v3Config = await migration.convertConfig(v2Config, options) + + // Check that login signer is a sapient signer with image hash + const v3Topology = v3Config.topology[1] as V3Config.NestedLeaf + expect(Array.isArray(v3Topology.tree)).toBe(true) + expect(v3Topology.tree).toHaveLength(2) + + // First sub-array should contain login and guard signers + const loginArray = v3Topology.tree[0] as V3Config.Node + expect(Array.isArray(loginArray)).toBe(true) + expect(loginArray).toHaveLength(2) + + // First element should be login topology (sapient signer with image hash) + const loginTopology = loginArray[0] as V3Config.SapientSignerLeaf + expect(loginTopology.type).toBe('sapient-signer') + expect(loginTopology.address).toBe(testSigner.address) + expect(loginTopology.imageHash).toBe(imageHash) + }) + }) + + describe('prepareMigration', async () => { + it('should prepare migration transactions correctly', async () => { + const walletAddress = testAddress + + const v3Config: V3Config.Config = { + threshold: 1n, + checkpoint: 0n, + topology: [ + { + type: 'nested', + weight: 1n, + threshold: 1n, + tree: { + type: 'signer', + address: testSigner.address, + weight: 1n, + }, + }, + { + type: 'nested', + weight: 1n, + threshold: 2n, + tree: [ + { + type: 'signer', + address: testSigner.address, + weight: 1n, + }, + { + type: 'signer', + address: '0xa2e70CeaB3Eb145F32d110383B75B330fA4e288a', + weight: 1n, + }, + ], + }, + ], + } + + const randomSpace = BigInt(Math.floor(Math.random() * 10000000000)) + const migrationResult = await migration.prepareMigration(walletAddress, V3Context.Rc3, v3Config, { + space: BigInt(randomSpace), + }) + + expect(migrationResult.fromVersion).toBe(2) + expect(migrationResult.toVersion).toBe(3) + expect(migrationResult.payload.calls).toHaveLength(2) + expect(migrationResult.payload.nonce).toBe(0n) + expect(migrationResult.payload.space).toBe(randomSpace) + + // Check first transaction (update implementation) + const updateImplTx = migrationResult.payload.calls[0] + expect(updateImplTx.to).toBe(walletAddress) + + const updateImplementationAbi = AbiFunction.from('function updateImplementation(address implementation)') + const decodedImplArgs = AbiFunction.decodeData(updateImplementationAbi, updateImplTx.data) + expect(decodedImplArgs[0].toLowerCase()).toBe(V3Context.Rc3.stage2.toLowerCase()) + + // Check second transaction (update image hash) + const updateImageHashTx = migrationResult.payload.calls[1] + expect(updateImageHashTx.to).toBe(walletAddress) + + const updateImageHashAbi = AbiFunction.from('function updateImageHash(bytes32 imageHash)') + const decodedImageHashArgs = AbiFunction.decodeData(updateImageHashAbi, updateImageHashTx.data) + const expectedImageHash = Hex.fromBytes(V3Config.hashConfiguration(v3Config)) + expect(decodedImageHashArgs[0]).toBe(expectedImageHash) + }) + + it('should use custom context when provided', async () => { + const walletAddress = testAddress + const customContext: V3Context.Context = { + stage1: '0x1111111111111111111111111111111111111111', + stage2: '0x2222222222222222222222222222222222222222', + creationCode: '0x3333333333333333333333333333333333333333333333333333333333333333', + factory: '0x4444444444444444444444444444444444444444', + } + + const v3Config: V3Config.Config = { + threshold: 1n, + checkpoint: 0n, + topology: [ + { + type: 'nested', + weight: 1n, + threshold: 1n, + tree: { + type: 'signer', + address: testSigner.address, + weight: 1n, + }, + }, + { + type: 'nested', + weight: 1n, + threshold: 2n, + tree: [ + { + type: 'signer', + address: testSigner.address, + weight: 1n, + }, + { + type: 'signer', + address: '0xa2e70CeaB3Eb145F32d110383B75B330fA4e288a', + weight: 1n, + }, + ], + }, + ], + } + + const migrationResult = await migration.prepareMigration(walletAddress, customContext, v3Config, {}) + + const updateImplTx = migrationResult.payload.calls[0] + const updateImplementationAbi = AbiFunction.from('function updateImplementation(address implementation)') + const decodedImplArgs = AbiFunction.decodeData(updateImplementationAbi, updateImplTx.data) + expect(decodedImplArgs[0]).toBe(customContext.stage2) + }) + }) + + describe('decodeTransactions', async () => { + it('should decode transactions correctly', async () => { + const walletAddress = testAddress + const imageHash = '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef' + + const updateImplementationAbi = AbiFunction.from('function updateImplementation(address implementation)') + const updateImageHashAbi = AbiFunction.from('function updateImageHash(bytes32 imageHash)') + + const payload: Payload.Calls = { + type: 'call', + space: 0n, + nonce: 0n, + calls: [ + { + to: walletAddress, + value: 0n, + gasLimit: 0n, + delegateCall: false, + onlyFallback: false, + behaviorOnError: 'revert', + data: AbiFunction.encodeData(updateImplementationAbi, [V3Context.Rc3.stage2]), + }, + { + to: walletAddress, + value: 0n, + gasLimit: 0n, + delegateCall: false, + onlyFallback: false, + behaviorOnError: 'revert', + data: AbiFunction.encodeData(updateImageHashAbi, [imageHash]), + }, + ], + } + + const decoded = await migration.decodePayload(payload) + + expect(decoded.address).toBe(walletAddress) + expect(decoded.toImageHash).toBe(imageHash) + }) + + it('should throw error for invalid number of calls', async () => { + const payload: Payload.Calls = { + type: 'call', + space: 0n, + nonce: 0n, + calls: [ + { + to: testAddress, + value: 0n, + data: '0x1234567890abcdef', + gasLimit: 0n, + delegateCall: false, + onlyFallback: false, + behaviorOnError: 'revert', + }, + ], + } + + await expect(migration.decodePayload(payload)).rejects.toThrow('Invalid calls') + }) + + it('should throw error when payload addresses do not match', async () => { + const differentAddress = '0x9999999999999999999999999999999999999999' + const updateImplementationAbi = AbiFunction.from('function updateImplementation(address implementation)') + const updateImageHashAbi = AbiFunction.from('function updateImageHash(bytes32 imageHash)') + + const payload: Payload.Calls = { + type: 'call', + space: 0n, + nonce: 0n, + calls: [ + { + to: testAddress, + value: 0n, + data: AbiFunction.encodeData(updateImplementationAbi, [V3Context.Rc3.stage2]), + gasLimit: 0n, + delegateCall: false, + onlyFallback: false, + behaviorOnError: 'revert', + }, + { + to: differentAddress, + value: 0n, + data: AbiFunction.encodeData(updateImageHashAbi, [ + '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + ]), + gasLimit: 0n, + delegateCall: false, + onlyFallback: false, + behaviorOnError: 'revert', + }, + ], + } + + await expect(migration.decodePayload(payload)).rejects.toThrow('Invalid to address') + }) + + it('should throw error for invalid payload data', async () => { + const payload: Payload.Calls = { + type: 'call', + space: 0n, + nonce: 0n, + calls: [ + { + to: testAddress, + value: 0n, + gasLimit: 0n, + delegateCall: false, + onlyFallback: false, + behaviorOnError: 'revert', + data: '0xinvalid', + }, + { + to: testAddress, + value: 0n, + gasLimit: 0n, + delegateCall: false, + onlyFallback: false, + behaviorOnError: 'revert', + data: '0xalsoinvalid', + }, + ], + } + + await expect(migration.decodePayload(payload)).rejects.toThrow(/^Invalid byte sequence/) + }) + }) + + describe('constants', async () => { + it('should have correct nonce space', () => { + expect(MIGRATION_V2_V3_NONCE_SPACE).toBe('0xf9fe6701dd3716c9cdb4faf375921627b507d142') + }) + + it('should have correct version numbers', () => { + expect(migration.fromVersion).toBe(2) + expect(migration.toVersion).toBe(3) + }) + }) + + describe('integration test', async () => { + it('should use migration ', async () => { + // Create v2 config + const v2Config: v2.config.WalletConfig = { + version: 2, + threshold: 1, + checkpoint: 0, + tree: { + left: { + weight: 1, + address: testSigner.address, + }, + // Include a random signer to avoid image hash collisions + right: { + weight: 1, + address: Address.fromPublicKey(Secp256k1.getPublicKey({ privateKey: Secp256k1.randomPrivateKey() })), + }, + }, + } + const v2ImageHash = v2.config.ConfigCoder.imageHashOf(v2Config) + Hex.assert(v2ImageHash) + const orchestrator = new Orchestrator([testSigner.v2]) + const anvilSigner = await createAnvilSigner(providers.v2, providers.v3) + const v2Wallet = await V2Wallet.newWallet< + v2.config.WalletConfig, + v2.signature.Signature, + v2.signature.UnrecoveredSignature + >({ + context: v2.DeployedWalletContext, + chainId: Number(chainId), + coders: { + config: v2.config.ConfigCoder, + signature: v2.signature.SignatureCoder, + }, + orchestrator, + config: v2Config, + provider: providers.v2, + relayer: new LocalRelayer(anvilSigner.v2), + }) + const walletAddress = Address.from(v2Wallet.address) + + // Convert to v3 config + const options = { + loginSigner: { + address: testSigner.address, + }, + } + const v3Config = await migration.convertConfig(v2Config, options) + + // Prepare migration + const unsignedMigration = await migration.prepareMigration(walletAddress, V3Context.Rc3, v3Config, {}) + + // Decode transactions + const decoded = await migration.decodePayload(unsignedMigration.payload) + expect(decoded.address).toBe(walletAddress) + expect(decoded.toImageHash).toBe(Hex.fromBytes(V3Config.hashConfiguration(v3Config))) + + // Sign it using v2 wallet + const v2Nonce = v2commons.transaction.encodeNonce( + unsignedMigration.payload.space, + unsignedMigration.payload.nonce, + ) + const txBundle: v2commons.transaction.TransactionBundle = { + entrypoint: walletAddress, + transactions: unsignedMigration.payload.calls.map( + (call): v2commons.transaction.Transaction => ({ + to: call.to, + data: call.data, + gasLimit: call.gasLimit.toString(), + delegateCall: call.delegateCall, + revertOnError: call.behaviorOnError === 'revert', + }), + ), + nonce: v2Nonce, + } + const signedTxBundle = await v2Wallet.signTransactionBundle(txBundle) + const decorated = await v2Wallet.decorateTransactions(signedTxBundle) + + // Send it + const tx = await v2Wallet.sendSignedTransaction(decorated) + const receipt = await tx.wait() + expect(receipt?.status).toBe(1) + // This should now be a v3 wallet on chain + + // Save the wallet information to the state provider + const stateProvider = new State.Local.Provider() + await stateProvider.saveDeploy(v2ImageHash, convertV2ContextToV3Context(v2.DeployedWalletContext)) + await stateProvider.saveConfiguration(v3Config) + + // Test the wallet works as a v3 wallet now with a test transaction + const v3Wallet = new V3Wallet(walletAddress, { stateProvider }) + const call: Payload.Call = { + to: Address.from('0x0000000000000000000000000000000000000000'), + data: Hex.fromString('0x'), + value: 0n, + gasLimit: 0n, + delegateCall: false, + onlyFallback: false, + behaviorOnError: 'revert', + } + const envelope = await v3Wallet.prepareTransaction(providers.v3, [call], { + space: BigInt(Math.floor(Math.random() * 1000000000000000000)), + noConfigUpdate: true, + }) + + const signature = await testSigner.v3.sign(v3Wallet.address, Number(chainId), envelope.payload) + const signedEnvelope = Envelope.toSigned(envelope, [ + { + address: testSigner.address, + signature, + }, + ]) + const signedTx = await v3Wallet.buildTransaction(providers.v3, signedEnvelope) + const testTx = await providers.v3.request({ + method: 'eth_sendTransaction', + params: [signedTx], + }) + await new Promise((resolve) => setTimeout(resolve, 1000)) + const testReceipt = await providers.v3.request({ + method: 'eth_getTransactionReceipt', + params: [testTx], + }) + assert(testReceipt?.status, 'Receipt status is undefined') + expect(fromRpcStatus[testReceipt.status]).toBe('success') + }) + }, 30_000) +}) diff --git a/packages/utils/migration/test/v2/migrator_v2_v3.test.ts b/packages/utils/migration/test/v2/migrator_v2_v3.test.ts new file mode 100644 index 000000000..a78257594 --- /dev/null +++ b/packages/utils/migration/test/v2/migrator_v2_v3.test.ts @@ -0,0 +1,121 @@ +import { LocalRelayer } from '@0xsequence/relayerv2' +import { Orchestrator } from '@0xsequence/signhubv2' +import { v2 } from '@0xsequence/v2core' +import { Wallet as V2Wallet } from '@0xsequence/v2wallet' +import { Envelope, State } from '@0xsequence/wallet-core' +import { Payload } from '@0xsequence/wallet-primitives' +import { ethers } from 'ethers' +import { Address, Hex, Provider, RpcTransport, Secp256k1 } from 'ox' +import { fromRpcStatus } from 'ox/TransactionReceipt' +import { assert, beforeEach, describe, expect, it } from 'vitest' +import { Migrator_v2v3, MigratorV2V3Options } from '../../src/migrations/v2/migrator_v2_v3.js' +import { createAnvilSigner, createMultiSigner, type MultiSigner } from '../testUtils.js' + +describe('Migrator_v2v3', async () => { + let testSigner: MultiSigner + + let providers: { + v2: ethers.Provider + v3: Provider.Provider + } + let chainId: number + + let stateProvider: State.Provider + + let migrator: Migrator_v2v3 + + beforeEach(async () => { + const url = 'http://127.0.0.1:8545' + providers = { + v2: ethers.getDefaultProvider(url), + v3: Provider.from(RpcTransport.fromHttp(url)), + } + chainId = Number(await providers.v3.request({ method: 'eth_chainId' })) + + stateProvider = new State.Local.Provider() + // stateProvider = new State.Sequence.Provider('http://127.0.0.1:36261') + migrator = new Migrator_v2v3(stateProvider) + + testSigner = createMultiSigner(Secp256k1.randomPrivateKey(), providers.v2) + }) + + describe('convertWallet', async () => { + it('should convert a v2 wallet to a v3 wallet', async () => { + const v2Config = { + version: 2, + threshold: 1, + checkpoint: 0, + tree: { + left: { + weight: 1, + address: testSigner.address, + }, + right: { + weight: 1, + address: Address.fromPublicKey(Secp256k1.getPublicKey({ privateKey: Secp256k1.randomPrivateKey() })), + }, + }, + } + const orchestrator = new Orchestrator([testSigner.v2]) + const anvilSigner = await createAnvilSigner(providers.v2, providers.v3) + const v2Wallet = await V2Wallet.newWallet< + v2.config.WalletConfig, + v2.signature.Signature, + v2.signature.UnrecoveredSignature + >({ + context: v2.DeployedWalletContext, + chainId, + coders: { + config: v2.config.ConfigCoder, + signature: v2.signature.SignatureCoder, + }, + orchestrator, + config: v2Config, + relayer: new LocalRelayer(anvilSigner.v2), + }) + const v2ImageHash = v2.config.ConfigCoder.imageHashOf(v2Config) + + const options: MigratorV2V3Options = { + loginSigner: { + address: testSigner.address, + }, + } + const v3Wallet = await migrator.convertWallet(v2Wallet, options) + + // Test the wallet works as a v3 wallet now with a test transaction + const call: Payload.Call = { + to: Address.from('0x0000000000000000000000000000000000000000'), + data: Hex.fromString('0x'), + value: 0n, + gasLimit: 0n, + delegateCall: false, + onlyFallback: false, + behaviorOnError: 'revert', + } + const envelope = await v3Wallet.prepareTransaction(providers.v3, [call], { + space: BigInt(Math.floor(Math.random() * 1000000000000000000)), + noConfigUpdate: true, + }) + + const signature = await testSigner.v3.sign(v3Wallet.address, Number(chainId), envelope.payload) + const signedEnvelope = Envelope.toSigned(envelope, [ + { + address: testSigner.address, + signature, + }, + ]) + const signedTx = await v3Wallet.buildTransaction(providers.v3, signedEnvelope) + const testTx = await providers.v3.request({ + method: 'eth_sendTransaction', + params: [signedTx], + }) + await new Promise((resolve) => setTimeout(resolve, 1000)) + const receipt = await providers.v3.request({ + method: 'eth_getTransactionReceipt', + params: [testTx], + }) + assert(receipt?.status, 'Receipt status is undefined') + expect(fromRpcStatus[receipt.status]).toBe('success') + }) + }, 30_000) +}) diff --git a/packages/utils/migration/tsconfig.json b/packages/utils/migration/tsconfig.json new file mode 100644 index 000000000..fed9c77b4 --- /dev/null +++ b/packages/utils/migration/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "@repo/typescript-config/base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "types": ["node"] + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/utils/migration/vitest.config.ts b/packages/utils/migration/vitest.config.ts new file mode 100644 index 000000000..a9025a388 --- /dev/null +++ b/packages/utils/migration/vitest.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + fileParallelism: false, + poolOptions: { + threads: { + singleThread: true, + }, + }, + }, +}) diff --git a/packages/wallet/core/src/state/cached.ts b/packages/wallet/core/src/state/cached.ts index 401611eb2..bba8ea279 100644 --- a/packages/wallet/core/src/state/cached.ts +++ b/packages/wallet/core/src/state/cached.ts @@ -1,5 +1,5 @@ import { Address, Hex } from 'ox' -import { MaybePromise, Provider } from './index.js' +import { MaybePromise, Migration, Provider } from './index.js' import { Config, Context, GenericTree, Payload, Signature } from '@0xsequence/wallet-primitives' import { normalizeAddressKeys } from './utils.js' @@ -232,4 +232,26 @@ export class Cached implements Provider { savePayload(wallet: Address.Address, payload: Payload.Parented, chainId: number): MaybePromise { return this.args.source.savePayload(wallet, payload, chainId) } + + async getMigration( + wallet: Address.Address, + fromImageHash: Hex.Hex, + fromVersion: number, + chainId: number, + ): Promise { + const cached = await this.args.cache.getMigration(wallet, fromImageHash, fromVersion, chainId) + if (cached) { + return cached + } + const source = await this.args.source.getMigration(wallet, fromImageHash, fromVersion, chainId) + if (source) { + await this.args.cache.saveMigration(wallet, source) + } + return source + } + + saveMigration(wallet: Address.Address, migration: Migration): MaybePromise { + // Only save to cache when we read from source + return this.args.source.saveMigration(wallet, migration) + } } diff --git a/packages/wallet/core/src/state/index.ts b/packages/wallet/core/src/state/index.ts index 53e169908..a0a025267 100644 --- a/packages/wallet/core/src/state/index.ts +++ b/packages/wallet/core/src/state/index.ts @@ -3,6 +3,16 @@ import { Context, Config, Payload, Signature, GenericTree } from '@0xsequence/wa export type Provider = Reader & Writer +export type Migration = { + fromVersion: number + toVersion: number + fromImageHash: Hex.Hex + toConfig: Config.Config + payload: Payload.Calls + signature: Hex.Hex // Encoded + chainId: number +} + export interface Reader { getConfiguration(imageHash: Hex.Hex): MaybePromise @@ -52,6 +62,13 @@ export interface Reader { getPayload( opHash: Hex.Hex, ): MaybePromise<{ chainId: number; payload: Payload.Parented; wallet: Address.Address } | undefined> + + getMigration( + wallet: Address.Address, + fromImageHash: Hex.Hex, + fromVersion: number, + chainId: number, + ): MaybePromise } export interface Writer { @@ -75,6 +92,8 @@ export interface Writer { saveConfiguration(config: Config.Config): MaybePromise saveDeploy(imageHash: Hex.Hex, context: Context.Context): MaybePromise savePayload(wallet: Address.Address, payload: Payload.Parented, chainId: number): MaybePromise + + saveMigration(wallet: Address.Address, migration: Migration): MaybePromise } export type MaybePromise = T | Promise diff --git a/packages/wallet/core/src/state/local/index.ts b/packages/wallet/core/src/state/local/index.ts index 77e15da6c..cde4f93da 100644 --- a/packages/wallet/core/src/state/local/index.ts +++ b/packages/wallet/core/src/state/local/index.ts @@ -8,7 +8,7 @@ import { GenericTree, } from '@0xsequence/wallet-primitives' import { Address, Bytes, Hex, PersonalMessage, Secp256k1 } from 'ox' -import { Provider as ProviderInterface } from '../index.js' +import { Migration, Provider as ProviderInterface } from '../index.js' import { MemoryStore } from './memory.js' import { normalizeAddressKeys } from '../utils.js' @@ -61,6 +61,15 @@ export interface Store { // generic trees loadTree: (rootHash: Hex.Hex) => Promise saveTree: (rootHash: Hex.Hex, tree: GenericTree.Tree) => Promise + + // migrations + loadMigration: ( + wallet: Address.Address, + fromImageHash: Hex.Hex, + fromVersion: number, + chainId: number, + ) => Promise + saveMigration: (wallet: Address.Address, migration: Migration) => Promise } export class Provider implements ProviderInterface { @@ -435,6 +444,19 @@ export class Provider implements ProviderInterface { const subdigest = Hex.fromBytes(Payload.hash(wallet, chainId, payload)) return this.store.savePayloadOfSubdigest(subdigest, { content: payload, chainId, wallet }) } + + async getMigration( + wallet: Address.Address, + fromImageHash: Hex.Hex, + fromVersion: number, + chainId: number, + ): Promise { + return this.store.loadMigration(wallet, fromImageHash, fromVersion, chainId) + } + + async saveMigration(wallet: Address.Address, migration: Migration): Promise { + return this.store.saveMigration(wallet, migration) + } } export * from './memory.js' diff --git a/packages/wallet/core/src/state/local/indexed-db.ts b/packages/wallet/core/src/state/local/indexed-db.ts index 98a43743c..56ada1e71 100644 --- a/packages/wallet/core/src/state/local/indexed-db.ts +++ b/packages/wallet/core/src/state/local/indexed-db.ts @@ -1,6 +1,7 @@ import { Context, Payload, Signature, Config, GenericTree } from '@0xsequence/wallet-primitives' import { Address, Hex } from 'ox' import { Store } from './index.js' +import { Migration } from '../index.js' const DB_VERSION = 1 const STORE_CONFIGS = 'configs' @@ -11,6 +12,7 @@ const STORE_SIGNATURES = 'signatures' const STORE_SAPIENT_SIGNER_SUBDIGESTS = 'sapientSignerSubdigests' const STORE_SAPIENT_SIGNATURES = 'sapientSignatures' const STORE_TREES = 'trees' +const STORE_MIGRATIONS = 'migrations' export class IndexedDbStore implements Store { private _db: IDBDatabase | null = null @@ -52,6 +54,9 @@ export class IndexedDbStore implements Store { if (!db.objectStoreNames.contains(STORE_TREES)) { db.createObjectStore(STORE_TREES) } + if (!db.objectStoreNames.contains(STORE_MIGRATIONS)) { + db.createObjectStore(STORE_MIGRATIONS) + } } request.onsuccess = () => { @@ -201,4 +206,28 @@ export class IndexedDbStore implements Store { async saveTree(rootHash: Hex.Hex, tree: GenericTree.Tree): Promise { await this.put(STORE_TREES, rootHash.toLowerCase(), tree) } + + private getMigrationKey( + wallet: Address.Address, + fromImageHash: Hex.Hex, + fromVersion: number, + chainId: number, + ): string { + return `${wallet.toLowerCase()}-${fromImageHash.toLowerCase()}-${fromVersion}-${chainId}` + } + + async loadMigration( + wallet: Address.Address, + fromImageHash: Hex.Hex, + fromVersion: number, + chainId: number, + ): Promise { + const key = this.getMigrationKey(wallet, fromImageHash, fromVersion, chainId) + return this.get(STORE_MIGRATIONS, key) + } + + async saveMigration(wallet: Address.Address, migration: Migration): Promise { + const key = this.getMigrationKey(wallet, migration.fromImageHash, migration.fromVersion, migration.chainId) + await this.put(STORE_MIGRATIONS, key, migration) + } } diff --git a/packages/wallet/core/src/state/local/memory.ts b/packages/wallet/core/src/state/local/memory.ts index 5d3ad3e2b..04f260606 100644 --- a/packages/wallet/core/src/state/local/memory.ts +++ b/packages/wallet/core/src/state/local/memory.ts @@ -1,6 +1,7 @@ import { Context, Payload, Signature, Config, GenericTree } from '@0xsequence/wallet-primitives' import { Address, Hex } from 'ox' import { Store } from './index.js' +import { Migration } from '../index.js' export class MemoryStore implements Store { private configs = new Map<`0x${string}`, Config.Config>() @@ -14,6 +15,8 @@ export class MemoryStore implements Store { private trees = new Map<`0x${string}`, GenericTree.Tree>() + private migrations = new Map() + private deepCopy(value: T): T { // modern runtime → fast native path if (typeof structuredClone === 'function') { @@ -153,4 +156,29 @@ export class MemoryStore implements Store { async saveTree(rootHash: Hex.Hex, tree: GenericTree.Tree): Promise { this.trees.set(rootHash.toLowerCase() as `0x${string}`, this.deepCopy(tree)) } + + private getMigrationKey( + wallet: Address.Address, + fromImageHash: Hex.Hex, + fromVersion: number, + chainId: number, + ): string { + return `${wallet.toLowerCase()}-${fromImageHash.toLowerCase()}-${fromVersion}-${chainId}` + } + + async loadMigration( + wallet: Address.Address, + fromImageHash: Hex.Hex, + fromVersion: number, + chainId: number, + ): Promise { + const key = this.getMigrationKey(wallet, fromImageHash, fromVersion, chainId) + const migration = this.migrations.get(key) + return migration ? this.deepCopy(migration) : undefined + } + + async saveMigration(wallet: Address.Address, migration: Migration): Promise { + const key = this.getMigrationKey(wallet, migration.fromImageHash, migration.fromVersion, migration.chainId) + this.migrations.set(key, this.deepCopy(migration)) + } } diff --git a/packages/wallet/core/src/state/remote/dev-http.ts b/packages/wallet/core/src/state/remote/dev-http.ts index d7fe0f492..d7489b5fd 100644 --- a/packages/wallet/core/src/state/remote/dev-http.ts +++ b/packages/wallet/core/src/state/remote/dev-http.ts @@ -1,6 +1,6 @@ import { Address, Hex } from 'ox' import { Config, Context, GenericTree, Payload, Signature, Utils } from '@0xsequence/wallet-primitives' -import { Provider } from '../index.js' +import { Migration, Provider } from '../index.js' export class DevHttpProvider implements Provider { private readonly baseUrl: string @@ -250,4 +250,17 @@ export class DevHttpProvider implements Provider { async savePayload(wallet: Address.Address, payload: Payload.Parented, chainId: number): Promise { return this.request('POST', '/payload', { wallet, payload, chainId }) } + + async getMigration( + wallet: Address.Address, + fromImageHash: Hex.Hex, + fromVersion: number, + chainId: number, + ): Promise { + return this.request('GET', `/migration/${wallet}/from/${fromImageHash}/version/${fromVersion}/chain/${chainId}`) + } + + async saveMigration(wallet: Address.Address, migration: Migration): Promise { + return this.request('POST', '/migration', { wallet, migration }) + } } diff --git a/packages/wallet/core/src/state/sequence/index.ts b/packages/wallet/core/src/state/sequence/index.ts index 140b3f1ca..1dd67428a 100644 --- a/packages/wallet/core/src/state/sequence/index.ts +++ b/packages/wallet/core/src/state/sequence/index.ts @@ -8,8 +8,10 @@ import { Signature as oxSignature, TransactionRequest, } from 'ox' -import { normalizeAddressKeys, Provider as ProviderInterface } from '../index.js' -import { Sessions, SignatureType } from './sessions.gen.js' +import { Migration, normalizeAddressKeys, Provider as ProviderInterface } from '../index.js' +import { Context as ServiceContext, Sessions, SignatureType, TransactionBundle } from './sessions.gen.js' + +type ContextWithGuest = Context.Context & { guest?: Address.Address } export class Provider implements ProviderInterface { private readonly service: Sessions @@ -29,22 +31,14 @@ export class Provider implements ProviderInterface { } async getDeploy(wallet: Address.Address): Promise<{ imageHash: Hex.Hex; context: Context.Context } | undefined> { - const { deployHash, context } = await this.service.deployHash({ wallet }) + const { deployHash, context: serviceContext } = await this.service.deployHash({ wallet }) Hex.assert(deployHash) - Address.assert(context.factory) - Address.assert(context.mainModule) - Address.assert(context.mainModuleUpgradable) - Hex.assert(context.walletCreationCode) + const context = fromServiceContext(serviceContext) return { imageHash: deployHash, - context: { - factory: context.factory, - stage1: context.mainModule, - stage2: context.mainModuleUpgradable, - creationCode: context.walletCreationCode, - }, + context, } } @@ -271,18 +265,51 @@ export class Provider implements ProviderInterface { return { payload: fromServicePayload(payload), wallet, chainId: Number(chainID) } } - async saveWallet(deployConfiguration: Config.Config, context: Context.Context): Promise { + async getMigration( + wallet: Address.Address, + fromImageHash: Hex.Hex, + fromVersion: number, + chainId: number, + ): Promise { + const chainIdString = chainId.toString() + const { migrations } = await this.service.migrations({ wallet, fromImageHash, fromVersion, chainID: chainIdString }) + + const chainMigrations = migrations[chainIdString] + if (!chainMigrations) { + return undefined + } + const toVersions = Object.keys(chainMigrations) + .map(Number) + .sort((a: number, b: number) => b - a) + + for (const toVersion of toVersions) { + for (const [toHash, transactions] of Object.entries(chainMigrations[toVersion]!)) { + if (!toHash || !transactions || !Hex.validate(toHash) || !Hex.validate(transactions.signature)) { + continue + } + const toConfig = await this.getConfiguration(toHash) + if (!toConfig || !Hex.validate(toHash)) { + continue + } + return { + fromImageHash, + fromVersion, + toVersion, + toConfig, + payload: fromServiceTransactionBundle(transactions), + signature: transactions.signature, + chainId, + } + } + } + } + + async saveWallet(deployConfiguration: Config.Config, context: ContextWithGuest): Promise { + const contextVersion = Context.getVersionFromContext(context) await this.service.saveWallet({ - version: 3, + version: contextVersion, deployConfig: getServiceConfig(deployConfiguration), - context: { - version: 3, - factory: context.factory, - mainModule: context.stage1, - mainModuleUpgradable: context.stage2, - guestModule: Constants.DefaultGuestAddress, - walletCreationCode: context.creationCode, - }, + context: getServiceContext(context, contextVersion), }) } @@ -350,8 +377,19 @@ export class Provider implements ProviderInterface { await this.service.saveConfig({ version: 3, config: getServiceConfig(config) }) } - async saveDeploy(_imageHash: Hex.Hex, _context: Context.Context): Promise { - // TODO: save deploy hash even if we don't have its configuration + // FIXME This is here to cater for saving non v3 configurations to key machine. + async forceSaveConfiguration(config: any, version: number): Promise { + await this.service.saveConfig({ version, config }) + } + + async saveDeploy(imageHash: Hex.Hex, context: ContextWithGuest): Promise { + // Config must already be saved to use this method + const { version, config } = await this.service.config({ imageHash }) + await this.service.saveWallet({ + version, + deployConfig: config, + context: getServiceContext(context, version), + }) } async savePayload(wallet: Address.Address, payload: Payload.Parented, chainId: number): Promise { @@ -362,6 +400,29 @@ export class Provider implements ProviderInterface { chainID: chainId.toString(), }) } + + async saveMigration(wallet: Address.Address, migration: Migration): Promise { + const serviceConfig = getServiceConfig(migration.toConfig) + const nonce = encodeTransactionBundleNonce(migration.payload.space, migration.payload.nonce) + await this.service.saveMigration({ + wallet, + fromVersion: migration.fromVersion, + toVersion: migration.toVersion, + toConfig: serviceConfig, + executor: wallet, + transactions: migration.payload.calls.map((tx) => ({ + to: tx.to, + value: tx.value.toString(), + data: tx.data, + gasLimit: tx.gasLimit.toString(), + delegateCall: tx.delegateCall, + revertOnError: tx.behaviorOnError === 'revert', + })), + nonce, + signature: migration.signature, + chainID: migration.chainId.toString(), + }) + } } const passkeySigners = [Extensions.Dev1.passkeys, Extensions.Dev2.passkeys, Extensions.Rc3.passkeys].map( @@ -671,3 +732,65 @@ function getSignerSignatures( throw new Error(`unknown topology '${JSON.stringify(topology)}'`) } } + +function fromServiceContext(context: ServiceContext): Context.Context { + Address.assert(context.factory) + Address.assert(context.mainModule) + Address.assert(context.mainModuleUpgradable) + Hex.assert(context.walletCreationCode) + return { + factory: Address.from(context.factory), + stage1: context.mainModule, + stage2: context.mainModuleUpgradable, + creationCode: context.walletCreationCode, + } +} + +function getServiceContext(context: ContextWithGuest, contextVersion?: number): ServiceContext { + return { + version: contextVersion ?? Context.getVersionFromContext(context), + guestModule: context.guest ?? Constants.DefaultGuestAddress, + factory: context.factory, + mainModule: context.stage1, + mainModuleUpgradable: context.stage2, + walletCreationCode: context.creationCode, + } +} + +function fromServiceTransactionBundle(bundle: TransactionBundle): Payload.Calls { + // Decode nonce and space + const [space, nonce] = decodeTransactionBundleNonce(bundle.nonce) + return { + type: 'call', + space, + nonce, + calls: bundle.transactions.map((tx) => { + const data = tx.data || '0x' + Hex.assert(data) + return { + to: Address.from(tx.to), + value: BigInt(tx.value || '0'), + data, + gasLimit: BigInt(tx.gasLimit || '0'), + delegateCall: false, + onlyFallback: false, + behaviorOnError: 'revert', + } + }), + } +} + +export function decodeTransactionBundleNonce(nonce: string): [bigint, bigint] { + const bnonce = BigInt(nonce) + const shr = 2n ** 96n + return [bnonce / shr, bnonce % shr] +} + +export function encodeTransactionBundleNonce(space: bigint, nonce: bigint): string { + const shl = 2n ** 96n + if (nonce / shl !== 0n) { + throw new Error('Space already encoded') + } + const encoded = nonce + space * shl + return encoded.toString() +} diff --git a/packages/wallet/core/src/utils/migration/migration-encoder.ts b/packages/wallet/core/src/utils/migration/migration-encoder.ts new file mode 100644 index 000000000..0e77f0503 --- /dev/null +++ b/packages/wallet/core/src/utils/migration/migration-encoder.ts @@ -0,0 +1,81 @@ +// FIXME +// This contains logic for encoding a v2 transaction bundle to execution data. +// Ideally this would live in the migration package. +// We don't want core to depend on the migration package as the migration package depends on v2. + +import { AbiFunction, Address, Hex } from 'ox' +import { State } from '../../index.js' +import { encodeTransactionBundleNonce } from '../../state/sequence/index.js' + +const V2_EXECUTE_ABI = AbiFunction.from({ + type: 'function', + name: 'execute', + constant: false, + inputs: [ + { + components: [ + { + type: 'bool', + name: 'delegateCall', + }, + { + type: 'bool', + name: 'revertOnError', + }, + { + type: 'uint256', + name: 'gasLimit', + }, + { + type: 'address', + name: 'target', + }, + { + type: 'uint256', + name: 'value', + }, + { + type: 'bytes', + name: 'data', + }, + ], + type: 'tuple[]', + }, + { + type: 'uint256', + }, + { + type: 'bytes', + }, + ], + outputs: [], + payable: false, + stateMutability: 'nonpayable', +}) + +export function encodeMigration(migration: State.Migration): { + to: Address.Address + data: Hex.Hex +} { + if (migration.fromVersion === 1 || migration.fromVersion === 2) { + const to = migration.payload.calls[0]?.to + if (!to) { + throw new Error('No to address found') + } + const v2Transactions = migration.payload.calls.map((call) => ({ + target: Address.from(call.to), + data: call.data, + value: call.value, + gasLimit: call.gasLimit, + delegateCall: call.delegateCall, + revertOnError: call.behaviorOnError === 'revert', + })) + const v2Nonce = encodeTransactionBundleNonce(migration.payload.space, migration.payload.nonce) + const data = AbiFunction.encodeData(V2_EXECUTE_ABI, [v2Transactions, BigInt(v2Nonce), migration.signature]) + return { + to: Address.from(to), + data, + } + } + throw new Error(`Unsupported migration version ${migration.fromVersion}`) +} diff --git a/packages/wallet/core/src/wallet.ts b/packages/wallet/core/src/wallet.ts index 05a02da09..b6edc9a34 100644 --- a/packages/wallet/core/src/wallet.ts +++ b/packages/wallet/core/src/wallet.ts @@ -7,10 +7,11 @@ import { Address as SequenceAddress, Signature as SequenceSignature, } from '@0xsequence/wallet-primitives' -import { AbiFunction, Address, Bytes, Hex, Provider, TypedData } from 'ox' +import { AbiFunction, AbiParameters, Address, Bytes, Hex, Provider, TypedData } from 'ox' import * as Envelope from './envelope.js' import * as State from './state/index.js' import { UserOperation } from 'ox/erc4337' +import { encodeMigration } from './utils/migration/migration-encoder.js' export type WalletOptions = { knownContexts: Context.KnownContext[] @@ -33,6 +34,9 @@ export type WalletStatus = { imageHash: Hex.Hex /** Pending updates in reverse chronological order (newest first) */ pendingUpdates: Array<{ imageHash: Hex.Hex; signature: SequenceSignature.RawSignature }> + /** Pending migrations, fully encoded with signature */ + pendingMigrations: Array + version: number chainId?: number counterFactual: { context: Context.KnownContext | Context.Context @@ -49,7 +53,7 @@ export type WalletStatusWithOnchain = WalletStatus & { export class Wallet { public readonly guest: Address.Address public readonly stateProvider: State.Provider - public readonly knownContexts: Context.KnownContext[] + public readonly knownContexts: Context.Context[] constructor( readonly address: Address.Address, @@ -122,9 +126,12 @@ export class Wallet { Config.evaluateConfigurationSafety(configuration) } + const status = await this.getStatus() + this.requireV3Wallet(status) + const imageHash = Config.hashConfiguration(configuration) const blankEnvelope = ( - await Promise.all([this.prepareBlankEnvelope(0), this.stateProvider.saveConfiguration(configuration)]) + await Promise.all([this.prepareBlankEnvelope(0, status), this.stateProvider.saveConfiguration(configuration)]) )[0] return { @@ -172,6 +179,7 @@ export class Wallet { let chainId: number | undefined let imageHash: Hex.Hex let updates: Array<{ imageHash: Hex.Hex; signature: SequenceSignature.RawSignature }> = [] + let migrations: Array = [] let onChainImageHash: Hex.Hex | undefined let stage: 'stage1' | 'stage2' | undefined @@ -182,19 +190,20 @@ export class Wallet { // Try to use a context from the known contexts, so we populate // the capabilities of the context - const counterFactualContext = - this.knownContexts.find( - (kc) => - Address.isEqual(deployInformation.context.factory, kc.factory) && - Address.isEqual(deployInformation.context.stage1, kc.stage1), - ) ?? deployInformation.context + const counterFactualContext = deployInformation + ? (this.knownContexts.find( + (kc) => + Address.isEqual(deployInformation.context.factory, kc.factory) && + Address.isEqual(deployInformation.context.stage1, kc.stage1), + ) ?? deployInformation.context) + : undefined let context: Context.KnownContext | Context.Context | undefined if (provider) { // Get chain ID, deployment status, and implementation const requests = await Promise.all([ - provider.request({ method: 'eth_chainId' }), + provider.request({ method: 'eth_chainId' }).then((res) => Number(res)), this.isDeployed(provider), provider .request({ @@ -202,21 +211,36 @@ export class Wallet { params: [{ to: this.address, data: AbiFunction.encodeData(Constants.GET_IMPLEMENTATION) }, 'latest'], }) .then((res) => { - const address = `0x${res.slice(-40)}` - Address.assert(address, { strict: false }) - return address + return AbiFunction.decodeResult(Constants.GET_IMPLEMENTATION, res) }) - .catch(() => undefined), + .catch(() => { + // Fallback to reading storage slot + const position = AbiParameters.encode(AbiParameters.from(['address']), [this.address]) + return provider + .request({ + method: 'eth_getStorageAt', + params: [this.address, position, 'latest'], + }) + .then((res) => { + const [implementation] = AbiParameters.decode(AbiParameters.from(['address']), Bytes.fromHex(res)) + const implementationAddress = Address.from(implementation) + if (Address.isEqual(implementationAddress, '0x0000000000000000000000000000000000000000')) { + return undefined + } + return implementationAddress + }) + .catch(() => undefined) + }), ]) - chainId = Number(requests[0]) + chainId = requests[0] isDeployed = requests[1] implementation = requests[2] // Try to find the context from the known contexts (or use the counterfactual context) context = implementation ? [...this.knownContexts, counterFactualContext].find( - (kc) => Address.isEqual(implementation!, kc.stage1) || Address.isEqual(implementation!, kc.stage2), + (kc) => kc && (Address.isEqual(implementation!, kc.stage1) || Address.isEqual(implementation!, kc.stage2)), ) : counterFactualContext @@ -242,20 +266,47 @@ export class Wallet { } onChainImageHash = deployInformation.imageHash } - - // Get configuration updates - updates = await this.stateProvider.getConfigurationUpdates(this.address, onChainImageHash) - imageHash = updates[updates.length - 1]?.imageHash ?? onChainImageHash + } else if (deployInformation) { + context = deployInformation.context } else { - // Without a provider, we can only get information from the state provider - updates = await this.stateProvider.getConfigurationUpdates(this.address, deployInformation.imageHash) - imageHash = updates[updates.length - 1]?.imageHash ?? deployInformation.imageHash + throw new Error(`cannot find status information for ${this.address}. Missing deploy information and no provider.`) } + let fromImageHash = onChainImageHash ?? deployInformation.imageHash + + // Get migrations + const detectedContextVersion = Context.getVersionFromContext(context) + let version = detectedContextVersion + if (detectedContextVersion !== 3) { + // TODO Cater for pending v3 -> v3 migrations + const migration = await this.stateProvider.getMigration( + this.address, + fromImageHash, + detectedContextVersion, + chainId ?? 0, + ) + if (migration) { + //TODO Support successive migrations + if (migration.toVersion !== 3) { + throw new Error( + `wallet migration is not for v3. Got ${migration.toVersion} for ${this.address} from version ${detectedContextVersion} to version ${migration.toVersion}.`, + ) + } + migrations.push(migration) + // We will perform the migration and update configurations from there. + fromImageHash = Bytes.toHex(Config.hashConfiguration(migration.toConfig)) + version = migration.toVersion + } + } + + // Get configuration updates + updates = await this.stateProvider.getConfigurationUpdates(this.address, fromImageHash) + imageHash = updates[updates.length - 1]?.imageHash ?? fromImageHash + // Get the current configuration const configuration = await this.stateProvider.getConfiguration(imageHash) if (!configuration) { - throw new Error(`cannot find configuration details for ${this.address}`) + throw new Error(`cannot find configuration details for ${this.address} with image hash ${imageHash}`) } if (provider) { @@ -267,6 +318,8 @@ export class Wallet { configuration, imageHash, pendingUpdates: [...updates].reverse(), + pendingMigrations: [...migrations], + version, chainId, onChainImageHash: onChainImageHash!, context, @@ -279,10 +332,12 @@ export class Wallet { configuration, imageHash, pendingUpdates: [...updates].reverse(), + pendingMigrations: [...migrations], + version, chainId, counterFactual: { context: counterFactualContext, - imageHash: deployInformation.imageHash, + imageHash: deployInformation?.imageHash ?? '', }, } as T extends Provider.Provider ? WalletStatusWithOnchain : WalletStatus } @@ -349,6 +404,7 @@ export class Wallet { } const [chainId, status] = await Promise.all([provider.request({ method: 'eth_chainId' }), this.getStatus(provider)]) + this.requireV3Wallet(status, true) // If entrypoint is address(0) then 4337 is not enabled in this wallet if (!status.context.capabilities?.erc4337?.entrypoint) { @@ -402,7 +458,7 @@ export class Wallet { factory, factoryData, }, - ...(await this.prepareBlankEnvelope(Number(chainId), provider)), + ...(await this.prepareBlankEnvelope(Number(chainId), status)), } } @@ -442,6 +498,7 @@ export class Wallet { options?: { space?: bigint noConfigUpdate?: boolean + noPendingMigrations?: boolean unsafe?: boolean }, ): Promise> { @@ -466,10 +523,12 @@ export class Wallet { this.getNonce(provider, space), ]) - // If the latest configuration does not match the onchain configuration - // then we bundle the update into the transaction envelope + // If the latest configuration does not match the onchain configuration, we bundle the update into the transaction envelope + // Same for pending migrations + const status = await this.getStatus(provider) + this.requireV3Wallet(status, options?.noPendingMigrations) + if (!options?.noConfigUpdate) { - const status = await this.getStatus(provider) if (status.imageHash !== status.onChainImageHash) { calls.push({ to: this.address, @@ -490,11 +549,17 @@ export class Wallet { nonce, calls, }, - ...(await this.prepareBlankEnvelope(Number(chainId), provider)), + ...(await this.prepareBlankEnvelope(Number(chainId), status)), } } - async buildTransaction(provider: Provider.Provider, envelope: Envelope.Signed) { + async buildTransaction( + provider: Provider.Provider, + envelope: Envelope.Signed, + ): Promise<{ + to: Address.Address + data: Hex.Hex + }> { const status = await this.getStatus(provider) const updatedEnvelope = { ...envelope, configuration: status.configuration } @@ -502,63 +567,59 @@ export class Wallet { if (weight < threshold) { throw new Error('insufficient weight in envelope') } - const signature = Envelope.encodeSignature(updatedEnvelope) - if (status.isDeployed) { - return { - to: this.address, - data: AbiFunction.encodeData(Constants.EXECUTE, [ - Bytes.toHex(Payload.encode(envelope.payload)), - Bytes.toHex( - SequenceSignature.encodeSignature({ - ...signature, - suffix: status.pendingUpdates.map(({ signature }) => signature), - }), - ), - ]), - } - } else { + const encodedCalls: { + to: Address.Address + data: Hex.Hex + }[] = [] + + // Deployment + if (!status.isDeployed) { const deploy = await this.buildDeployTransaction() + encodedCalls.push(deploy) + } - return { - to: this.guest, - data: Bytes.toHex( - Payload.encode({ - type: 'call', - space: 0n, - nonce: 0n, - calls: [ - { - to: deploy.to, - value: 0n, - data: deploy.data, - gasLimit: 0n, - delegateCall: false, - onlyFallback: false, - behaviorOnError: 'revert', - }, - { - to: this.address, - value: 0n, - data: AbiFunction.encodeData(Constants.EXECUTE, [ - Bytes.toHex(Payload.encode(envelope.payload)), - Bytes.toHex( - SequenceSignature.encodeSignature({ - ...signature, - suffix: status.pendingUpdates.map(({ signature }) => signature), - }), - ), - ]), - gasLimit: 0n, - delegateCall: false, - onlyFallback: false, - behaviorOnError: 'revert', - }, - ], + // Pending migrations + if (status.pendingMigrations.length > 0) { + encodedCalls.push(...status.pendingMigrations.map(encodeMigration)) + } + + // Requested payload + encodedCalls.push({ + to: this.address, + data: AbiFunction.encodeData(Constants.EXECUTE, [ + Bytes.toHex(Payload.encode(envelope.payload)), + Bytes.toHex( + SequenceSignature.encodeSignature({ + ...signature, + suffix: status.pendingUpdates.map(({ signature }) => signature), }), ), - } + ]), + }) + + if (encodedCalls.length === 1) { + return encodedCalls[0]! + } + + return { + to: this.guest, + data: Bytes.toHex( + Payload.encode({ + type: 'call', + space: 0n, + nonce: 0n, + calls: encodedCalls.map((call) => ({ + ...call, + value: 0n, + gasLimit: 0n, + delegateCall: false, + onlyFallback: false, + behaviorOnError: 'revert', + })), + }), + ), } } @@ -574,8 +635,11 @@ export class Wallet { const messageSize = Hex.size(hexMessage) encodedMessage = Hex.concat(Hex.fromString(`${`\x19Ethereum Signed Message:\n${messageSize}`}`), hexMessage) } + const status = await this.getStatus() + this.requireV3Wallet(status, true) + return { - ...(await this.prepareBlankEnvelope(chainId)), + ...(await this.prepareBlankEnvelope(chainId, status)), payload: Payload.fromMessage(encodedMessage), } } @@ -585,6 +649,8 @@ export class Wallet { provider?: Provider.Provider, ): Promise { const status = await this.getStatus(provider) + this.requireV3Wallet(status, true) + const signature = Envelope.encodeSignature(envelope) if (!status.isDeployed) { const deployTransaction = await this.buildDeployTransaction() @@ -597,13 +663,21 @@ export class Wallet { return encoded } - private async prepareBlankEnvelope(chainId: number, provider?: Provider.Provider) { - const status = await this.getStatus(provider) - + private prepareBlankEnvelope(chainId: number, status: WalletStatus) { return { wallet: this.address, chainId: chainId, configuration: status.configuration, } } + + private requireV3Wallet(status: WalletStatus, noPendingMigrations: boolean = false): boolean { + if (status.version !== 3) { + throw new Error('migrate to v3 before performing this action') + } + if (noPendingMigrations && status.pendingMigrations.length > 0) { + throw new Error('execute pending migrations before performing this action') + } + return true + } } diff --git a/packages/wallet/primitives/src/config.ts b/packages/wallet/primitives/src/config.ts index d662d39a0..a42dbe64b 100644 --- a/packages/wallet/primitives/src/config.ts +++ b/packages/wallet/primitives/src/config.ts @@ -2,7 +2,6 @@ import { Address, Bytes, Hash, Hex } from 'ox' import { isRawConfig, isRawNestedLeaf, - isRawNode, isRawSignerLeaf, isSignedSapientSignerLeaf, isSignedSignerLeaf, diff --git a/packages/wallet/primitives/src/context.ts b/packages/wallet/primitives/src/context.ts index 001b9a5f1..f397cf6a7 100644 --- a/packages/wallet/primitives/src/context.ts +++ b/packages/wallet/primitives/src/context.ts @@ -72,6 +72,32 @@ export const KnownContexts: KnownContext[] = [ { name: 'Rc3_4337', development: true, ...Rc3_4337 }, ] +export function isContext(context: any): context is Context { + return ( + (context as Context).factory !== undefined && + (context as Context).stage1 !== undefined && + (context as Context).stage2 !== undefined && + (context as Context).creationCode !== undefined + ) +} + export function isKnownContext(context: Context): context is KnownContext { return (context as KnownContext).name !== undefined && (context as KnownContext).development !== undefined } + +export function getVersionFromContext(context: Context): number { + if ( + Address.isEqual(context.stage1, '0xd01F11855bCcb95f88D7A48492F66410d4637313') && + Address.isEqual(context.stage2, '0x7EFE6cE415956c5f80C6530cC6cc81b4808F6118') + ) { + return 1 + } + if ( + Address.isEqual(context.stage1, '0xfBf8f1A5E00034762D928f46d438B947f5d4065d') && + Address.isEqual(context.stage2, '0x4222dcA3974E39A8b41c411FeDDE9b09Ae14b911') + ) { + return 2 + } + // We assume this is a v3 context + return 3 +} diff --git a/packages/wallet/wdk/test/sessions.test.ts b/packages/wallet/wdk/test/sessions.test.ts index f6d8a144b..48441aba2 100644 --- a/packages/wallet/wdk/test/sessions.test.ts +++ b/packages/wallet/wdk/test/sessions.test.ts @@ -261,6 +261,10 @@ describe('Sessions (via Manager)', () => { // Undeployed wallet return Promise.resolve('0x') } + if (method === 'eth_getStorageAt') { + // Return 0 for storage slots (implementation) + return Promise.resolve('0x0000000000000000000000000000000000000000000000000000000000000000') + } if (method === 'eth_call' && params[0].data === AbiFunction.encodeData(Constants.READ_NONCE, [0n])) { // Nonce is 0 return Promise.resolve('0x00') @@ -335,6 +339,10 @@ describe('Sessions (via Manager)', () => { // Undeployed wallet return Promise.resolve('0x') } + if (method === 'eth_getStorageAt') { + // Return 0 for storage slots (implementation) + return Promise.resolve('0x0000000000000000000000000000000000000000000000000000000000000000') + } if (method === 'eth_call' && params[0].data === AbiFunction.encodeData(Constants.READ_NONCE, [0n])) { // Nonce is 0 return Promise.resolve('0x00') @@ -410,6 +418,10 @@ describe('Sessions (via Manager)', () => { // Undeployed wallet return Promise.resolve('0x') } + if (method === 'eth_getStorageAt') { + // Return 0 for storage slots (implementation) + return Promise.resolve('0x0000000000000000000000000000000000000000000000000000000000000000') + } if (method === 'eth_call' && params[0].data === AbiFunction.encodeData(Constants.READ_NONCE, [0n])) { // Nonce is 0 return Promise.resolve('0x00') @@ -497,6 +509,10 @@ describe('Sessions (via Manager)', () => { // Undeployed wallet return Promise.resolve('0x') } + if (method === 'eth_getStorageAt') { + // Return 0 for storage slots (implementation) + return Promise.resolve('0x0000000000000000000000000000000000000000000000000000000000000000') + } if (method === 'eth_call' && params[0].data === AbiFunction.encodeData(Constants.READ_NONCE, [0n])) { // Nonce is 0 return Promise.resolve('0x00') diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ad45d777b..9c9e99e62 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -239,6 +239,70 @@ importers: specifier: ^5.8.3 version: 5.8.3 + packages/utils/migration: + dependencies: + '@0xsequence/abi': + specifier: workspace:^ + version: link:../abi + '@0xsequence/v2core': + specifier: npm:@0xsequence/core@^2.3.29 + version: '@0xsequence/core@2.3.30(ethers@6.15.0)' + '@0xsequence/v2migration': + specifier: npm:@0xsequence/migration@^2.3.29 + version: '@0xsequence/migration@2.3.31(ethers@6.15.0)' + '@0xsequence/v2sessions': + specifier: npm:@0xsequence/sessions@^2.3.29 + version: '@0xsequence/sessions@2.3.31(ethers@6.15.0)' + '@0xsequence/v2wallet': + specifier: npm:@0xsequence/wallet@^2.3.29 + version: '@0xsequence/wallet@2.3.30(ethers@6.15.0)' + '@0xsequence/wallet-core': + specifier: workspace:^ + version: link:../../wallet/core + '@0xsequence/wallet-primitives': + specifier: workspace:^ + version: link:../../wallet/primitives + mipd: + specifier: ^0.0.7 + version: 0.0.7(typescript@5.8.3) + ox: + specifier: ^0.7.2 + version: 0.7.2(typescript@5.8.3) + viem: + specifier: ^2.30.6 + version: 2.38.2(typescript@5.8.3) + devDependencies: + '@0xsequence/relayerv2': + specifier: npm:@0xsequence/relayer@^2.3.29 + version: '@0xsequence/relayer@2.3.30(ethers@6.15.0)' + '@0xsequence/signhubv2': + specifier: npm:@0xsequence/signhub@^2.3.29 + version: '@0xsequence/signhub@2.3.30(ethers@6.15.0)' + '@repo/typescript-config': + specifier: workspace:^ + version: link:../../../repo/typescript-config + '@types/node': + specifier: ^22.15.29 + version: 22.18.10 + '@vitest/coverage-v8': + specifier: ^3.2.4 + version: 3.2.4(vitest@3.2.4(@types/node@22.18.10)(happy-dom@17.6.3)) + dotenv: + specifier: ^16.5.0 + version: 16.6.1 + ethers: + specifier: 6.15.0 + version: 6.15.0 + fake-indexeddb: + specifier: ^6.0.1 + version: 6.2.3 + typescript: + specifier: ^5.8.3 + version: 5.8.3 + vitest: + specifier: ^3.2.1 + version: 3.2.4(@types/node@22.18.10)(happy-dom@17.6.3) + packages/wallet/core: dependencies: '@0xsequence/guard': @@ -510,9 +574,99 @@ importers: packages: + '@0xsequence/abi@2.3.30': + resolution: {integrity: sha512-dJxZOl+bTYnU0mOiYRDSrdQ0G5IKrARx4JhFMhGCnOaaaMlR/UUxP4ySR+xFF8ULQAdgjRL11z7P7qBr5cWvlQ==} + + '@0xsequence/abi@2.3.31': + resolution: {integrity: sha512-fpCsp4H1hMp3g7a+lqmEu8dF31QBI3+u2g6s3Lp3tlaZ4BcRwinQGDULyf2ZZW3ohVvmj56/7rlZa3ec3H3o8Q==} + + '@0xsequence/core@2.3.30': + resolution: {integrity: sha512-NEP8xXZaB3wOKSKrtiMMu1BbFsJ4UhT7sLp07kK266EtIeKfMWKcc32m6gtI5Q2eJazZDZLSao5pE98H6QSLYg==} + peerDependencies: + ethers: '>=6' + + '@0xsequence/core@2.3.31': + resolution: {integrity: sha512-ptElC5bi28Y/oQaipnY86bcEbu7IbMSne9FbEhkpAgw0YOGWq19WxOoTkVSBCJCjm+8utPaYiV4T+nQOq1lQVA==} + peerDependencies: + ethers: '>=6' + + '@0xsequence/indexer@2.3.30': + resolution: {integrity: sha512-eEK1uDQoYQkXJhnsEF7ipd1i6pzE+LbK5azTgP9XGqzICBZRmus/DB/15OA6wLiY//aLJdL++kBwzh24WrxaZg==} + + '@0xsequence/indexer@2.3.31': + resolution: {integrity: sha512-xqYfOrbk4hbzouLAva63jRpCys5s04UoMvO2BIrWPDeAZigrcG1axG5J57NbgAw3OQl3WZ+oHU6m2XMP1T2WjA==} + + '@0xsequence/migration@2.3.31': + resolution: {integrity: sha512-7Xp0yakiT2JUdGQjDhpGUzkE7/pv1R1vWPHUMTn74YQr4/bpUkPxoRgsG3Kvdf0GFIuECyRqN8Ptdr8sFoQNag==} + peerDependencies: + ethers: '>=6' + + '@0xsequence/network@2.3.30': + resolution: {integrity: sha512-p7f0sMwXRbGTK+7tf2FaV4Cn2KpCK7wXoWbJ4hX5Uo0qiNpKg3NLscAsMUSYuwe9WVhLtzpK7V3UF0TvSGUeWg==} + peerDependencies: + ethers: '>=6' + + '@0xsequence/network@2.3.31': + resolution: {integrity: sha512-1rlLUIHIFAi3KFOfrCS3Y/b/i/gjIedQD6AA5nR2nWyIt+79D490qpfYcMThhdoRo1pLNDwUQbyKp25FBGNH4A==} + peerDependencies: + ethers: '>=6' + + '@0xsequence/relayer@2.3.30': + resolution: {integrity: sha512-cJF2+dbCiZrvWUIMmEGHv39psMr2c74tkDqBu0zZKCETQjP6bZZjCTy+uI5kUjflY3g+Lt2K9stRu4u/CzT8fw==} + peerDependencies: + ethers: '>=6' + + '@0xsequence/relayer@2.3.31': + resolution: {integrity: sha512-SPEhvih7o0hiMFUsQyGy1vb7klzcYDr1VVy5n7XpT6I4QkQ/q5ScL8SvetRzLdljv/MvHwpeS3ZaCbYmFV8itA==} + peerDependencies: + ethers: '>=6' + + '@0xsequence/replacer@2.3.31': + resolution: {integrity: sha512-HDbsNGTuTl+A9LWLHIVpNfX+Ev2ZiEbyfnZrPYDtqWzgbMiQdojpCX3nxQnlVuRaYLCoU2Mv/WXbL6EGi2mB2w==} + peerDependencies: + ethers: '>=6' + + '@0xsequence/sessions@2.3.31': + resolution: {integrity: sha512-xDHQfqCW27RTxb9q43xuXsce6N/iM2qE8RDtxv1sQDwNOHWPOlHCazKmzuka+7yBIim0FLP9Soc4UhS4ho1swQ==} + peerDependencies: + ethers: '>=6' + + '@0xsequence/signhub@2.3.30': + resolution: {integrity: sha512-XTy7H0lXnQXLLlp8ZW1++8ex7wTQvtHuPmnX9aU+mTaItwArfIzop0ZduAcRW0qs1T7Ixn6YlmEEQg63jfdCrw==} + peerDependencies: + ethers: '>=6' + + '@0xsequence/signhub@2.3.31': + resolution: {integrity: sha512-xRuct1hVejsklC1ecHmjw1MUY+gkl4F2WDDGot4c1qJpwGsur0/BFGCnWdaBwixt2LaDUQPxIW0jjl8uHEYnkw==} + peerDependencies: + ethers: '>=6' + '@0xsequence/tee-verifier@0.1.2': resolution: {integrity: sha512-7sKr8/T4newknx6LAukjlRI3siGiGhBnZohz2Z3jX0zb0EBQdKUq0L//A7CPSckHFPxTg/QvQU2v8e9x9GfkDw==} + '@0xsequence/utils@2.3.30': + resolution: {integrity: sha512-4YeBAH9jKXgfgRy5dWtp8J42YdlMQk3Ft6axh3KpbpN9wI4UL9GGWCWl8FdRZDXkU0UqY1wK1+lxbJwkVIouLA==} + peerDependencies: + ethers: '>=6' + + '@0xsequence/utils@2.3.31': + resolution: {integrity: sha512-tUoqXhPAvbq3KBKRr+t+65AuNgZ4EodtedJCtTp/mKjTuUriHAzCR75VmOV+o3zUDMyQ7n9K22OYo3NCZpPvJQ==} + peerDependencies: + ethers: '>=6' + + '@0xsequence/wallet@2.3.30': + resolution: {integrity: sha512-tRlRG8sZhTSfszgk/cevYt1GCqawWsoMcPQzUzP+aFzzUROIlm2mB3GgNMHAk/92PxZISjJyFoK1XZKjQTnfXA==} + peerDependencies: + ethers: '>=6' + + '@0xsequence/wallet@2.3.31': + resolution: {integrity: sha512-WY2wDJSHOAbD3sdJ05Bi1iROVJxA8pbl64WZV3l4E1wQ9iJSbz6sY3kMK3jhduXl3snJlZhRUEKMXZeyebr0iw==} + peerDependencies: + ethers: '>=6' + + '@adraffy/ens-normalize@1.10.1': + resolution: {integrity: sha512-96Z2IP3mYmF1Xg2cDm8f1gWGf/HUVedQ3FMifV4kG/PQ4yEP51xDtRAEfhVNt5f/uzpNkZHwWQuUcu6D6K+Ekw==} + '@adraffy/ens-normalize@1.11.1': resolution: {integrity: sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==} @@ -1052,6 +1206,9 @@ packages: resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==} engines: {node: ^14.21.3 || >=16} + '@noble/curves@1.2.0': + resolution: {integrity: sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==} + '@noble/curves@1.9.1': resolution: {integrity: sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==} engines: {node: ^14.21.3 || >=16} @@ -1060,6 +1217,10 @@ packages: resolution: {integrity: sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==} engines: {node: ^14.21.3 || >=16} + '@noble/hashes@1.3.2': + resolution: {integrity: sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==} + engines: {node: '>= 16'} + '@noble/hashes@1.4.0': resolution: {integrity: sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==} engines: {node: '>= 16'} @@ -1260,6 +1421,9 @@ packages: '@types/node@22.18.10': resolution: {integrity: sha512-anNG/V/Efn/YZY4pRzbACnKxNKoBng2VTFydVu8RRs5hQjikP8CQfaeAV59VFSCzKNp90mXiVXW2QzV56rwMrg==} + '@types/node@22.7.5': + resolution: {integrity: sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==} + '@types/prop-types@15.7.15': resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} @@ -1420,6 +1584,9 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + aes-js@4.0.0-beta.5: + resolution: {integrity: sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==} + agent-base@7.1.4: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} @@ -1942,6 +2109,10 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + ethers@6.15.0: + resolution: {integrity: sha512-Kf/3ZW54L4UT0pZtsY/rf+EkBU7Qi5nnhonjUb8yTXcxH3cdcWrV2cRyk0Xk/4jK6OoHhxxZHriyhje20If2hQ==} + engines: {node: '>=14.0.0'} + eventemitter3@5.0.1: resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} @@ -2450,6 +2621,9 @@ packages: resolution: {integrity: sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==} engines: {node: 20 || >=22} + js-base64@3.7.8: + resolution: {integrity: sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -3382,6 +3556,9 @@ packages: tslib@1.14.1: resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} + tslib@2.7.0: + resolution: {integrity: sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -3472,6 +3649,9 @@ packages: undefsafe@2.0.5: resolution: {integrity: sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==} + undici-types@6.19.8: + resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==} + undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} @@ -3649,6 +3829,18 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@8.17.1: + resolution: {integrity: sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + ws@8.18.3: resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} engines: {node: '>=10.0.0'} @@ -3683,11 +3875,125 @@ packages: snapshots: + '@0xsequence/abi@2.3.30': {} + + '@0xsequence/abi@2.3.31': {} + + '@0xsequence/core@2.3.30(ethers@6.15.0)': + dependencies: + '@0xsequence/abi': 2.3.30 + '@0xsequence/utils': 2.3.30(ethers@6.15.0) + ethers: 6.15.0 + + '@0xsequence/core@2.3.31(ethers@6.15.0)': + dependencies: + '@0xsequence/abi': 2.3.31 + '@0xsequence/utils': 2.3.31(ethers@6.15.0) + ethers: 6.15.0 + + '@0xsequence/indexer@2.3.30': {} + + '@0xsequence/indexer@2.3.31': {} + + '@0xsequence/migration@2.3.31(ethers@6.15.0)': + dependencies: + '@0xsequence/abi': 2.3.31 + '@0xsequence/core': 2.3.31(ethers@6.15.0) + '@0xsequence/wallet': 2.3.31(ethers@6.15.0) + ethers: 6.15.0 + + '@0xsequence/network@2.3.30(ethers@6.15.0)': + dependencies: + '@0xsequence/core': 2.3.30(ethers@6.15.0) + '@0xsequence/indexer': 2.3.30 + '@0xsequence/relayer': 2.3.30(ethers@6.15.0) + '@0xsequence/utils': 2.3.30(ethers@6.15.0) + ethers: 6.15.0 + + '@0xsequence/network@2.3.31(ethers@6.15.0)': + dependencies: + '@0xsequence/core': 2.3.31(ethers@6.15.0) + '@0xsequence/indexer': 2.3.31 + '@0xsequence/relayer': 2.3.31(ethers@6.15.0) + '@0xsequence/utils': 2.3.31(ethers@6.15.0) + ethers: 6.15.0 + + '@0xsequence/relayer@2.3.30(ethers@6.15.0)': + dependencies: + '@0xsequence/abi': 2.3.30 + '@0xsequence/core': 2.3.30(ethers@6.15.0) + '@0xsequence/utils': 2.3.30(ethers@6.15.0) + ethers: 6.15.0 + + '@0xsequence/relayer@2.3.31(ethers@6.15.0)': + dependencies: + '@0xsequence/abi': 2.3.31 + '@0xsequence/core': 2.3.31(ethers@6.15.0) + '@0xsequence/utils': 2.3.31(ethers@6.15.0) + ethers: 6.15.0 + + '@0xsequence/replacer@2.3.31(ethers@6.15.0)': + dependencies: + '@0xsequence/abi': 2.3.31 + '@0xsequence/core': 2.3.31(ethers@6.15.0) + ethers: 6.15.0 + + '@0xsequence/sessions@2.3.31(ethers@6.15.0)': + dependencies: + '@0xsequence/core': 2.3.31(ethers@6.15.0) + '@0xsequence/migration': 2.3.31(ethers@6.15.0) + '@0xsequence/replacer': 2.3.31(ethers@6.15.0) + '@0xsequence/utils': 2.3.31(ethers@6.15.0) + ethers: 6.15.0 + idb: 7.1.1 + + '@0xsequence/signhub@2.3.30(ethers@6.15.0)': + dependencies: + '@0xsequence/core': 2.3.30(ethers@6.15.0) + ethers: 6.15.0 + + '@0xsequence/signhub@2.3.31(ethers@6.15.0)': + dependencies: + '@0xsequence/core': 2.3.31(ethers@6.15.0) + ethers: 6.15.0 + '@0xsequence/tee-verifier@0.1.2': dependencies: cbor2: 1.12.0 pkijs: 3.3.0 + '@0xsequence/utils@2.3.30(ethers@6.15.0)': + dependencies: + ethers: 6.15.0 + js-base64: 3.7.8 + + '@0xsequence/utils@2.3.31(ethers@6.15.0)': + dependencies: + ethers: 6.15.0 + js-base64: 3.7.8 + + '@0xsequence/wallet@2.3.30(ethers@6.15.0)': + dependencies: + '@0xsequence/abi': 2.3.30 + '@0xsequence/core': 2.3.30(ethers@6.15.0) + '@0xsequence/network': 2.3.30(ethers@6.15.0) + '@0xsequence/relayer': 2.3.30(ethers@6.15.0) + '@0xsequence/signhub': 2.3.30(ethers@6.15.0) + '@0xsequence/utils': 2.3.30(ethers@6.15.0) + ethers: 6.15.0 + + '@0xsequence/wallet@2.3.31(ethers@6.15.0)': + dependencies: + '@0xsequence/abi': 2.3.31 + '@0xsequence/core': 2.3.31(ethers@6.15.0) + '@0xsequence/network': 2.3.31(ethers@6.15.0) + '@0xsequence/relayer': 2.3.31(ethers@6.15.0) + '@0xsequence/signhub': 2.3.31(ethers@6.15.0) + '@0xsequence/utils': 2.3.31(ethers@6.15.0) + ethers: 6.15.0 + + '@adraffy/ens-normalize@1.10.1': {} + '@adraffy/ens-normalize@1.11.1': {} '@ampproject/remapping@2.3.0': @@ -4191,6 +4497,10 @@ snapshots: '@noble/ciphers@1.3.0': {} + '@noble/curves@1.2.0': + dependencies: + '@noble/hashes': 1.3.2 + '@noble/curves@1.9.1': dependencies: '@noble/hashes': 1.8.0 @@ -4199,6 +4509,8 @@ snapshots: dependencies: '@noble/hashes': 1.8.0 + '@noble/hashes@1.3.2': {} + '@noble/hashes@1.4.0': {} '@noble/hashes@1.8.0': {} @@ -4359,7 +4671,7 @@ snapshots: '@types/glob@7.2.0': dependencies: '@types/minimatch': 6.0.0 - '@types/node': 20.19.21 + '@types/node': 22.18.10 '@types/inquirer@6.5.0': dependencies: @@ -4382,6 +4694,10 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/node@22.7.5': + dependencies: + undici-types: 6.19.8 + '@types/prop-types@15.7.15': {} '@types/react-dom@18.3.0': @@ -4404,7 +4720,7 @@ snapshots: '@types/through@0.0.33': dependencies: - '@types/node': 20.19.21 + '@types/node': 22.18.10 '@types/tinycolor2@1.4.6': {} @@ -4586,6 +4902,8 @@ snapshots: acorn@8.15.0: {} + aes-js@4.0.0-beta.5: {} + agent-base@7.1.4: {} aggregate-error@3.1.0: @@ -5280,6 +5598,19 @@ snapshots: esutils@2.0.3: {} + ethers@6.15.0: + dependencies: + '@adraffy/ens-normalize': 1.10.1 + '@noble/curves': 1.2.0 + '@noble/hashes': 1.3.2 + '@types/node': 22.7.5 + aes-js: 4.0.0-beta.5 + tslib: 2.7.0 + ws: 8.17.1 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + eventemitter3@5.0.1: {} execa@5.1.1: @@ -5854,6 +6185,8 @@ snapshots: dependencies: '@isaacs/cliui': 8.0.2 + js-base64@3.7.8: {} + js-tokens@4.0.0: {} js-tokens@9.0.1: {} @@ -6225,7 +6558,7 @@ snapshots: '@noble/hashes': 1.8.0 '@scure/bip32': 1.7.0 '@scure/bip39': 1.6.0 - abitype: 1.1.0(typescript@5.8.3) + abitype: 1.1.1(typescript@5.8.3) eventemitter3: 5.0.1 optionalDependencies: typescript: 5.8.3 @@ -6887,6 +7220,8 @@ snapshots: tslib@1.14.1: {} + tslib@2.7.0: {} + tslib@2.8.1: {} turbo-darwin-64@2.5.8: @@ -6982,6 +7317,8 @@ snapshots: undefsafe@2.0.5: {} + undici-types@6.19.8: {} + undici-types@6.21.0: {} universalify@0.1.2: {} @@ -7185,6 +7522,8 @@ snapshots: wrappy@1.0.2: {} + ws@8.17.1: {} + ws@8.18.3: {} y18n@5.0.8: {}