From e80052a71aa2c82613c06254eef0c828e152ed00 Mon Sep 17 00:00:00 2001 From: Michael Standen Date: Tue, 21 Oct 2025 10:52:35 +1300 Subject: [PATCH 01/13] Base v1->v3 wallet migration --- package.json | 2 +- packages/utils/migration/package.json | 48 ++ packages/utils/migration/src/index.ts | 2 + .../utils/migration/src/migrations/index.ts | 48 ++ .../src/migrations/v1/migration_v1_v3.ts | 135 ++++ .../src/migrations/v1/migrator_v1_v3.ts | 77 ++ .../migration/src/migrations/v3/config.ts | 73 ++ packages/utils/migration/src/migrator.ts | 114 +++ .../migration/test/migration_v1_v3.test.ts | 658 ++++++++++++++++++ .../migration/test/migrator_v1_v3.test.ts | 125 ++++ packages/utils/migration/test/testUtils.ts | 24 + packages/utils/migration/tsconfig.json | 10 + packages/utils/migration/vitest.config.ts | 9 + packages/wallet/primitives/src/config.ts | 1 - packages/wallet/primitives/src/context.ts | 9 + pnpm-lock.yaml | 345 ++++++++- 16 files changed, 1675 insertions(+), 5 deletions(-) create mode 100644 packages/utils/migration/package.json create mode 100644 packages/utils/migration/src/index.ts create mode 100644 packages/utils/migration/src/migrations/index.ts create mode 100644 packages/utils/migration/src/migrations/v1/migration_v1_v3.ts create mode 100644 packages/utils/migration/src/migrations/v1/migrator_v1_v3.ts create mode 100644 packages/utils/migration/src/migrations/v3/config.ts create mode 100644 packages/utils/migration/src/migrator.ts create mode 100644 packages/utils/migration/test/migration_v1_v3.test.ts create mode 100644 packages/utils/migration/test/migrator_v1_v3.test.ts create mode 100644 packages/utils/migration/test/testUtils.ts create mode 100644 packages/utils/migration/tsconfig.json create mode 100644 packages/utils/migration/vitest.config.ts 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/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..8e3c1ecef --- /dev/null +++ b/packages/utils/migration/src/index.ts @@ -0,0 +1,2 @@ +export * as migration from './migrations/index.js' +export * as migrator from './migrator.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..278fc930f --- /dev/null +++ b/packages/utils/migration/src/migrations/index.ts @@ -0,0 +1,48 @@ +import { Address, Hex } from 'ox' +import { UnsignedMigration, VersionedContext } from '../migrator.js' +import { Migration_v1v3 } from './v1/migration_v1_v3.js' + +export interface Migration { + 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 + * @returns The prepared migration + */ + prepareMigration: ( + walletAddress: Address.Address, + contexts: VersionedContext, + toConfig: ToConfigType, + ) => Promise + + /** + * Decodes the transactions from a migration + * @param transactions The transactions to decode + * @returns The decoded address and resulting image hash for the migration transactions + */ + decodeTransactions: (transactions: UnsignedMigration['transactions']) => Promise<{ + address: Address.Address + toImageHash: Hex.Hex + }> +} + +export interface Migrator { + fromVersion: number + toVersion: number + + convertWallet: (fromWallet: FromWallet, options: ConvertOptionsType) => Promise +} + +export const v1v3 = new Migration_v1v3() diff --git a/packages/utils/migration/src/migrations/v1/migration_v1_v3.ts b/packages/utils/migration/src/migrations/v1/migration_v1_v3.ts new file mode 100644 index 000000000..e07dc2bbc --- /dev/null +++ b/packages/utils/migration/src/migrations/v1/migration_v1_v3.ts @@ -0,0 +1,135 @@ +import { v1, commons as v2commons } from '@0xsequence/v2core' +import { WalletV1 } from '@0xsequence/v2wallet' +import { Config as V3Config, Context as V3Context, Extensions as V3Extensions } from '@0xsequence/wallet-primitives' +import { AbiFunction, Address, Hex } from 'ox' +import { SignedMigration, UnsignedMigration, VersionedContext } from '../../migrator.js' +import { Migration } from '../index.js' +import { createDefaultV3Topology } from '../v3/config.js' + +// uint160(keccak256("org.sequence.sdk.migration.v1v3.space.nonce")) +export const MIGRATION_V1_V3_NONCE_SPACE = '0x9e4d5bdafd978baf1290aff23057245a2a62bef5' + +export type ConvertOptions = { + loginSigner: { + address: Address.Address + imageHash?: Hex.Hex + } + extensions?: V3Extensions.Extensions +} + +export class Migration_v1v3 implements Migration { + fromVersion = 1 + toVersion = 3 + + async convertConfig(v1Config: v1.config.WalletConfig, options: ConvertOptions): Promise { + const signerLeaves: V3Config.SignerLeaf[] = v1Config.signers.map((signer) => ({ + type: 'signer', + address: Address.from(signer.address), + weight: BigInt(signer.weight), + })) + const v1NestedTopology = V3Config.flatLeavesToTopology(signerLeaves) + const v3Config: V3Config.Config = { + threshold: 1n, + checkpoint: 0n, + topology: [ + { + type: 'nested', + weight: 1n, + threshold: BigInt(v1Config.threshold), + tree: v1NestedTopology, + }, + { + type: 'nested', + weight: 1n, + threshold: 2n, + tree: createDefaultV3Topology(options.loginSigner, options.extensions), + }, + ], + } + return v3Config + } + + async prepareMigration( + walletAddress: Address.Address, + contexts: VersionedContext, + toConfig: V3Config.Config, + ): Promise { + const v3Context = contexts[3] || V3Context.Rc3 + if (!V3Context.isContext(v3Context)) { + throw new Error('Invalid context') + } + + const nonce = v2commons.transaction.encodeNonce(MIGRATION_V1_V3_NONCE_SPACE, 0) + + // Update implementation to v3 + const updateImplementationAbi = AbiFunction.from('function updateImplementation(address implementation)') + const updateImplementationTx = { + to: walletAddress, + data: AbiFunction.encodeData(updateImplementationAbi, [v3Context.stage2]), + } + // Update configuration to v3 + const v3ImageHash = Hex.fromBytes(V3Config.hashConfiguration(toConfig)) + const updateImageHashAbi = AbiFunction.from('function updateImageHash(bytes32 imageHash)') + const updateImageHashTx = { + to: walletAddress, + data: AbiFunction.encodeData(updateImageHashAbi, [v3ImageHash]), + } + + return { + transactions: [updateImplementationTx, updateImageHashTx], + fromVersion: this.fromVersion, + toVersion: this.toVersion, + nonce, + } + } + + /** + * Signs a migration with a wallet + * @notice V1 Wallets must call this method for each chain they are migrating on + * @param migration The unsigned migration to sign + * @param wallet The wallet to sign the migration with + * @returns The signed migration + */ + //FIXME Remove this function. Signing is not a responsibility of the migration class. + async signMigration(migration: UnsignedMigration, wallet: WalletV1): Promise { + const { address } = await this.decodeTransactions(migration.transactions) + if (address !== wallet.address) { + throw new Error('Wallet address does not match migration address') + } + const txBundle: v2commons.transaction.TransactionBundle = { + entrypoint: wallet.address, + transactions: migration.transactions.map((tx) => ({ + to: tx.to, + data: tx.data, + gasLimit: 0n, + revertOnError: true, + })), + nonce: migration.nonce, + } + const { signature } = await wallet.signTransactionBundle(txBundle) + Hex.assert(signature) + return { ...migration, signature } + } + + async decodeTransactions(transactions: UnsignedMigration['transactions']): Promise<{ + address: Address.Address + toImageHash: Hex.Hex + }> { + if (transactions.length !== 2) { + throw new Error('Invalid transactions') + } + const tx1 = transactions[0]! + const tx2 = transactions[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/v1/migrator_v1_v3.ts b/packages/utils/migration/src/migrations/v1/migrator_v1_v3.ts new file mode 100644 index 000000000..bf8c8e2ec --- /dev/null +++ b/packages/utils/migration/src/migrations/v1/migrator_v1_v3.ts @@ -0,0 +1,77 @@ +import { commons as v2commons } from '@0xsequence/v2core' +import { migrator as v2migrator } from '@0xsequence/v2migration' +import { WalletV1 } from '@0xsequence/v2wallet' +import { State, Wallet as WalletV3 } from '@0xsequence/wallet-core' +import { Constants, Context as V3Context } from '@0xsequence/wallet-primitives' +import { Address } from 'ox' +import { Migrator } from '../index.js' +import { ConvertOptions, Migration_v1v3 } from './migration_v1_v3.js' + +export type MigratorV1V3Options = ConvertOptions & { + v3Context?: V3Context.Context +} + +export class Migrator_v1v3 implements Migrator { + fromVersion = 1 + toVersion = 3 + + constructor( + private readonly v1Tracker?: v2migrator.PresignedMigrationTracker, + private readonly v3StateProvider?: State.Provider, + public readonly migration: Migration_v1v3 = new Migration_v1v3(), + ) {} + + async convertWallet(v1Wallet: WalletV1, options: MigratorV1V3Options): Promise { + // Prepare migration + const v3Context = options.v3Context || V3Context.Rc3 + const v1Config = v1Wallet.config + const v3Config = await this.migration.convertConfig(v1Config, options) + await this.v3StateProvider?.saveConfiguration(v3Config) + const unsignedMigration = await this.migration.prepareMigration( + Address.from(v1Wallet.address), + { [3]: v3Context }, + v3Config, + ) + + // Sign migration + const txBundle: v2commons.transaction.TransactionBundle = { + entrypoint: v1Wallet.address, + transactions: unsignedMigration.transactions.map((tx) => ({ + to: tx.to, + data: tx.data, + gasLimit: 0n, + revertOnError: true, + })), + nonce: unsignedMigration.nonce, + } + const signedTxBundle = await v1Wallet.signTransactionBundle(txBundle) + + // Save to tracker + const v2SignedMigration: v2migrator.SignedMigration = { + fromVersion: this.fromVersion, + toVersion: this.toVersion, + toConfig: { + version: 3, + ...v3Config, + }, + tx: signedTxBundle, + } + const versionedContext: v2commons.context.VersionedContext = { + [3]: { + version: 3, + mainModule: v3Context.stage1, + mainModuleUpgradable: v3Context.stage2, + factory: v3Context.factory, + guestModule: Constants.DefaultGuestAddress, + walletCreationCode: v3Context.creationCode, + }, + } + await this.v1Tracker?.saveMigration(v1Wallet.address, v2SignedMigration, versionedContext) + //FIXME State provider should be aware of migrations too + + // Return v3 wallet + return WalletV3.fromConfiguration(v3Config, { + context: v3Context, + }) + } +} 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..768afb0ba --- /dev/null +++ b/packages/utils/migration/src/migrations/v3/config.ts @@ -0,0 +1,73 @@ +import { + Config as V3Config, + Extensions as V3Extensions, + GenericTree as V3GenericTree, + SessionConfig as V3SessionConfig, +} from '@0xsequence/wallet-primitives' +import { Address, Hex } from 'ox' + +export const createDefaultV3Topology = ( + loginSigner: { + address: Address.Address + imageHash?: Hex.Hex + }, + extensions?: V3Extensions.Extensions, +): V3Config.Topology => { + // 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 = { + 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 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, + } + const nestedSessionTopology: V3Config.NestedLeaf = { + type: 'nested', + weight: 255n, + threshold: 2n, + tree: [sessionTopology, sessionGuardTopology], + } + // Return the wallet topology + return [ + [loginTopology, walletGuardTopology], + [recoveryTopology, nestedSessionTopology], + ] +} diff --git a/packages/utils/migration/src/migrator.ts b/packages/utils/migration/src/migrator.ts new file mode 100644 index 000000000..162dbf92c --- /dev/null +++ b/packages/utils/migration/src/migrator.ts @@ -0,0 +1,114 @@ +import { commons as v2commons } from '@0xsequence/v2core' +import { Context as V3Context } from '@0xsequence/wallet-primitives' +import { Address, Hex } from 'ox' + +export type VersionedContext = { [key: number]: v2commons.context.WalletContext | V3Context.Context } + +export type UnsignedMigration = { + transactions: { + to: Address.Address + data: Hex.Hex + }[] + nonce: bigint + fromVersion: number + toVersion: number +} + +export type SignedMigration = UnsignedMigration & { + signature: Hex.Hex +} + +export interface PresignedMigrationTracker { + getMigration( + address: Address.Address, + fromImageHash: Hex.Hex, + fromVersion: number, + chainId: number, + ): Promise + + saveMigration(address: Address.Address, signed: SignedMigration, contexts: VersionedContext): Promise +} + +/* + +// FIXME This class doesn't work because we need to cater for multiple wallet types and multiple chains +export class Migrator { + constructor( + public readonly trackers: PresignedMigrationTracker[], + public readonly migrations: Migration[], + public readonly contexts: VersionedContext + ) { + validateMigrations(migrations) + } + + async getAllMigratePresignedTransactions(args: { + address: Address.Address + fromImageHash: Hex.Hex + fromVersion: number + chainId: number + }): Promise<{ + signedMigrations: SignedMigration[] + lastVersion: number + lastImageHash: Hex.Hex + missing: boolean + }> { + const { address, fromImageHash, fromVersion, chainId } = args + + let currentImageHash = fromImageHash + let currentVersion = fromVersion + + const migs: SignedMigration[] = [] + for (const migration of this.migrations) { + const trackerMigrations = await Promise.all(this.trackers.map(async tracker => ({ + tracker: tracker, + migration: await tracker.getMigration(address, currentImageHash, currentVersion, chainId) + }))) + const trackerMigration = trackerMigrations.find(tm => tm.migration !== undefined)?.migration + if (!trackerMigration) return { signedMigrations: migs, missing: true, lastImageHash: currentImageHash, lastVersion: currentVersion } + // Ensure all trackers are tracking this migration + for (const tm of trackerMigrations) { + if (tm.migration === undefined) { + // Save it + await tm.tracker.saveMigration(address, trackerMigration, this.contexts) + } else { + // Compare it matches the expected migration (using a quick JSON stringify equal) + if (JSON.stringify(tm.migration) !== JSON.stringify(trackerMigration)) { + throw new Error(`Tracker migrations do not match`) + } + } + } + + migs.push(trackerMigration) + if (trackerMigration.fromVersion !== migration.fromVersion || trackerMigration.toVersion !== migration.toVersion) { + throw new Error(`Tracker migration version does not match expected version: ${trackerMigration.fromVersion} -> ${trackerMigration.toVersion} !== ${migration.fromVersion} -> ${migration.toVersion}`) + } + const decoded = await migration.decodeTransactions(trackerMigration.transactions) + if (decoded.address !== address) { + throw new Error(`Migration transaction address does not match expected address: ${decoded.address} !== ${address}`) + } + + currentImageHash = decoded.toImageHash + currentVersion = migration.toVersion + } + + return { signedMigrations: migs, missing: false, lastImageHash: currentImageHash, lastVersion: currentVersion } + } + + // async signAllMigrations( + // address: Address.Address, + // fromVersion: number, + // wallet: V2Wallet + // ): Promise { + // const migrations = this.migrations.filter(m => m.fromVersion === fromVersion) + // if (migrations.length === 0) { + // throw new Error(`No migrations found for version: ${fromVersion}`) + // } + + // return Promise.all(migrations.map(async (migration): Promise => { + // const nextConfig = await migration.convertConfig(fromConfig, options) + // const unsignedMigration = await migration.prepareMigration(address, this.contexts, nextConfig) + // return migration.signMigration(unsignedMigration, wallet) + // })) + // } +} +*/ diff --git a/packages/utils/migration/test/migration_v1_v3.test.ts b/packages/utils/migration/test/migration_v1_v3.test.ts new file mode 100644 index 000000000..bf4c89d57 --- /dev/null +++ b/packages/utils/migration/test/migration_v1_v3.test.ts @@ -0,0 +1,658 @@ +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, 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 { assert, beforeEach, describe, expect, it } from 'vitest' +import { MIGRATION_V1_V3_NONCE_SPACE, Migration_v1v3 } from '../src/migrations/v1/migration_v1_v3.js' +import { UnsignedMigration, VersionedContext } from '../src/migrator.js' +import { createMultiSigner, MultiSigner, V1WalletType } from './testUtils.js' +import { fromRpcStatus } from 'ox/TransactionReceipt' + +describe('Migration_v1v3', () => { + let anvilSigner: MultiSigner + let testSigner: MultiSigner + + let providers: { + v2: ethers.Provider + v3: Provider.Provider + } + let chainId: number + + let migration: Migration_v1v3 + + let v1Config: v1.config.WalletConfig + let v1Wallet: V1WalletType + let testAddress: Address.Address + + beforeEach(async () => { + migration = new Migration_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' })) + const anvilPk = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80' + anvilSigner = createMultiSigner(anvilPk, providers.v2) + testAddress = '0x742d35cc6635c0532925a3b8d563a6b35b7f05f1' + testSigner = createMultiSigner(Secp256k1.randomPrivateKey(), providers.v2) + console.log('testSigner', testSigner.address) + v1Config = { + version: 1, + threshold: 1, + signers: [ + { + weight: 1, + address: testSigner.address, + }, + ], + } + const orchestrator = new Orchestrator([testSigner.v2]) + v1Wallet = await V1Wallet.newWallet< + v1.config.WalletConfig, + v1.signature.Signature, + v1.signature.UnrecoveredSignature + >({ + context: v1.DeployedWalletContext, + chainId: 42161, + coders: { + config: v1.config.ConfigCoder, + signature: v1.signature.SignatureCoder, + }, + orchestrator, + config: v1Config, + relayer: new LocalRelayer(anvilSigner.v2), + }) + }) + + describe('convertConfig', () => { + 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', () => { + it('should prepare migration transactions correctly', async () => { + const walletAddress = testAddress + const contexts: VersionedContext = { + 3: V3Context.Rc3, + } + + 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, contexts, v3Config) + + expect(migrationResult.fromVersion).toBe(1) + expect(migrationResult.toVersion).toBe(3) + expect(migrationResult.transactions).toHaveLength(2) + expect(migrationResult.nonce).toBeDefined() + + // Check first transaction (update implementation) + const updateImplTx = migrationResult.transactions[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.transactions[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 contexts: VersionedContext = { + 3: customContext, + } + + 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, contexts, v3Config) + + const updateImplTx = migrationResult.transactions[0] + const updateImplementationAbi = AbiFunction.from('function updateImplementation(address implementation)') + const decodedImplArgs = AbiFunction.decodeData(updateImplementationAbi, updateImplTx.data) + expect(decodedImplArgs[0]).toBe(customContext.stage2) + }) + + it('should throw error for invalid context', async () => { + const walletAddress = testAddress + const contexts: VersionedContext = { + 3: 'invalid-context' as any, + } + + const v3Config: V3Config.Config = { + threshold: 1n, + checkpoint: 0n, + topology: { + type: 'signer', + address: testSigner.address, + weight: 1n, + }, + } + + await expect(migration.prepareMigration(walletAddress, contexts, v3Config)).rejects.toThrow('Invalid context') + }) + }) + + describe('signMigration', () => { + it('should sign migration correctly', async () => { + const updateImplementationAbi = AbiFunction.from('function updateImplementation(address implementation)') + const updateImageHashAbi = AbiFunction.from('function updateImageHash(bytes32 imageHash)') + + const unsignedMigration: UnsignedMigration = { + transactions: [ + { + to: Address.from(v1Wallet.address), + data: AbiFunction.encodeData(updateImplementationAbi, [V3Context.Rc3.stage2]), + }, + { + to: Address.from(v1Wallet.address), + data: AbiFunction.encodeData(updateImageHashAbi, [ + '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + ]), + }, + ], + nonce: 123n, + fromVersion: 1, + toVersion: 3, + } + + const signedMigration = await migration.signMigration(unsignedMigration, v1Wallet) + + expect(signedMigration.signature).toBeDefined() + expect(signedMigration.fromVersion).toBe(1) + expect(signedMigration.toVersion).toBe(3) + expect(signedMigration.transactions).toEqual(unsignedMigration.transactions) + expect(signedMigration.nonce).toBe(123n) + + // Note: We can't easily mock the internal signTransactionBundle call since it's part of the wallet + // The test verifies that the migration was signed successfully + }) + + it('should throw error when wallet address does not match migration address', async () => { + const differentAddress = '0x9999999999999999999999999999999999999999' + const updateImplementationAbi = AbiFunction.from('function updateImplementation(address implementation)') + const updateImageHashAbi = AbiFunction.from('function updateImageHash(bytes32 imageHash)') + + const unsignedMigration: UnsignedMigration = { + transactions: [ + { + to: differentAddress, + data: AbiFunction.encodeData(updateImplementationAbi, [V3Context.Rc3.stage2]), + }, + { + to: differentAddress, + data: AbiFunction.encodeData(updateImageHashAbi, [ + '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + ]), + }, + ], + nonce: 123n, + fromVersion: 1, + toVersion: 3, + } + + await expect(migration.signMigration(unsignedMigration, v1Wallet)).rejects.toThrow( + 'Wallet address does not match migration address', + ) + }) + }) + + describe('decodeTransactions', () => { + 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 transactions = [ + { + to: walletAddress, + data: AbiFunction.encodeData(updateImplementationAbi, [V3Context.Rc3.stage2]), + }, + { + to: walletAddress, + data: AbiFunction.encodeData(updateImageHashAbi, [imageHash]), + }, + ] + + const decoded = await migration.decodeTransactions(transactions) + + expect(decoded.address).toBe(walletAddress) + expect(decoded.toImageHash).toBe(imageHash) + }) + + it('should throw error for invalid number of transactions', async () => { + const transactions: UnsignedMigration['transactions'] = [ + { + to: testAddress, + data: '0x1234567890abcdef', + }, + ] + + await expect(migration.decodeTransactions(transactions)).rejects.toThrow('Invalid transactions') + }) + + it('should throw error when transaction 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 transactions: UnsignedMigration['transactions'] = [ + { + to: testAddress, + data: AbiFunction.encodeData(updateImplementationAbi, [V3Context.Rc3.stage2]), + }, + { + to: differentAddress, + data: AbiFunction.encodeData(updateImageHashAbi, [ + '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + ]), + }, + ] + + await expect(migration.decodeTransactions(transactions)).rejects.toThrow('Invalid to address') + }) + + it('should throw error for invalid transaction data', async () => { + const transactions: UnsignedMigration['transactions'] = [ + { + to: testAddress, + data: '0xinvalid', + }, + { + to: testAddress, + data: '0xalsoinvalid', + }, + ] + + await expect(migration.decodeTransactions(transactions)).rejects.toThrow() + }) + }) + + describe('constants', () => { + 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', () => { + it('should perform complete migration flow', async () => { + // Create v1 config + const v1Config: v1.config.WalletConfig = { + version: 1, + threshold: 1, + signers: [ + { + weight: 1, + address: testSigner.address, + }, + ], + } + + // Convert to v3 config + const options = { + loginSigner: { + address: testSigner.address, + }, + } + const v3Config = await migration.convertConfig(v1Config, options) + + // Prepare migration + const contexts: VersionedContext = { + 3: V3Context.Rc3, + } + const unsignedMigration = await migration.prepareMigration(Address.from(v1Wallet.address), contexts, v3Config) + + // Sign migration + const signedMigration = await migration.signMigration(unsignedMigration, v1Wallet) + + // Verify signed migration + expect(signedMigration.signature).toBeDefined() + expect(signedMigration.fromVersion).toBe(1) + expect(signedMigration.toVersion).toBe(3) + expect(signedMigration.transactions).toHaveLength(2) + + // Decode transactions + const decoded = await migration.decodeTransactions(signedMigration.transactions) + expect(decoded.address).toBe(v1Wallet.address) + expect(decoded.toImageHash).toBe(Hex.fromBytes(V3Config.hashConfiguration(v3Config))) + + // Send it + const signedTxBundle: v2commons.transaction.IntendedTransactionBundle = { + entrypoint: v1Wallet.address, + transactions: signedMigration.transactions, + nonce: signedMigration.nonce, + chainId, + intent: { + id: '1', + wallet: v1Wallet.address, + }, + } + const tx = await v1Wallet.sendSignedTransaction(signedTxBundle) + console.log('tx', tx) + const receipt = await tx.wait() + console.log('receipt', receipt) + expect(receipt?.status).toBe(1) + + // Test the wallet works as a v3 wallet now with a test transaction + const v3Wallet = await V3Wallet.fromConfiguration(v3Config) + 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) + console.log(`V3 transaction: ${signedTx.to} ${signedTx.data}`) + const testTx = await providers.v3.request({ + method: 'eth_sendTransaction', + params: [signedTx], + }) + console.log(`V3 transaction sent ${testTx}`) + const testReceipt = await providers.v3.request({ + method: 'eth_getTransactionReceipt', + params: [testTx], + }) + console.log(`V3 transaction successful! ${JSON.stringify(testReceipt)}`) + assert(testReceipt?.status, 'Receipt status is undefined') + expect(fromRpcStatus[testReceipt.status]).toBe('success') + }) + }, 30_000) +}) diff --git a/packages/utils/migration/test/migrator_v1_v3.test.ts b/packages/utils/migration/test/migrator_v1_v3.test.ts new file mode 100644 index 000000000..4ad440669 --- /dev/null +++ b/packages/utils/migration/test/migrator_v1_v3.test.ts @@ -0,0 +1,125 @@ +import { LocalRelayer } from '@0xsequence/relayerv2' +import { Orchestrator } from '@0xsequence/signhubv2' +import { v1 } from '@0xsequence/v2core' +import { trackers as v2trackers } from '@0xsequence/v2sessions' +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 { assert, beforeEach, describe, expect, it } from 'vitest' +import { Migrator_v1v3, MigratorV1V3Options } from '../src/migrations/v1/migrator_v1_v3.js' +import { createMultiSigner, type MultiSigner, type V1WalletType } from './testUtils.js' +import { fromRpcStatus } from 'ox/TransactionReceipt' + +describe('Migration_v1v3', () => { + let anvilSigner: MultiSigner + let testSigner: MultiSigner + + let providers: { + v2: ethers.Provider + v3: Provider.Provider + } + let chainId: number + + let tracker: v2trackers.local.LocalConfigTracker + let stateProvider: State.Provider + let migrator: Migrator_v1v3 + + let v1Config: v1.config.WalletConfig + let v1Wallet: V1WalletType + let testAddress: Address.Address + + 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' })) + + tracker = new v2trackers.local.LocalConfigTracker(providers.v2) + stateProvider = new State.Local.Provider() + migrator = new Migrator_v1v3() //tracker, stateProvider) + + const anvilPk = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80' + anvilSigner = createMultiSigner(anvilPk, providers.v2) + testAddress = '0x742d35cc6635c0532925a3b8d563a6b35b7f05f1' + testSigner = createMultiSigner(Secp256k1.randomPrivateKey(), providers.v2) + console.log('testSigner', testSigner.address) + v1Config = { + version: 1, + threshold: 1, + signers: [ + { + weight: 1, + address: testSigner.address, + }, + ], + } + const orchestrator = new Orchestrator([testSigner.v2]) + v1Wallet = await V1Wallet.newWallet< + v1.config.WalletConfig, + v1.signature.Signature, + v1.signature.UnrecoveredSignature + >({ + context: v1.DeployedWalletContext, + chainId: 42161, + coders: { + config: v1.config.ConfigCoder, + signature: v1.signature.SignatureCoder, + }, + orchestrator, + config: v1Config, + relayer: new LocalRelayer(anvilSigner.v2), + }) + }) + + describe('convertWallet', () => { + it('should convert a v1 wallet to a v3 wallet', async () => { + 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) + console.log(`V3 transaction: ${signedTx.to} ${signedTx.data}`) + const testTx = await providers.v3.request({ + method: 'eth_sendTransaction', + params: [signedTx], + }) + console.log(`V3 transaction sent ${testTx}`) + const receipt = await providers.v3.request({ + method: 'eth_getTransactionReceipt', + params: [testTx], + }) + console.log(`V3 transaction successful! ${JSON.stringify(receipt)}`) + assert(receipt?.status, 'Receipt status is undefined') + expect(fromRpcStatus[receipt.status]).toBe('success') + }) + }, 30_000) +}) diff --git a/packages/utils/migration/test/testUtils.ts b/packages/utils/migration/test/testUtils.ts new file mode 100644 index 000000000..e3a714a67 --- /dev/null +++ b/packages/utils/migration/test/testUtils.ts @@ -0,0 +1,24 @@ +import { Wallet as V1Wallet } from '@0xsequence/v2wallet' +import { v1 } from '@0xsequence/v2core' +import { Hex, Address } from 'ox' +import { ethers } from 'ethers' +import { Signers as V3Signers } from '@0xsequence/wallet-core' +import { Secp256k1 } from 'ox' + +export type MultiSigner = { + pk: Hex.Hex + address: Address.Address + v2: ethers.Signer + v3: V3Signers.Pk.Pk +} + +export type V1WalletType = V1Wallet + +export const createMultiSigner = (pk: Hex.Hex, provider: ethers.Provider): MultiSigner => { + return { + pk, + address: Address.fromPublicKey(Secp256k1.getPublicKey({ privateKey: pk })), + v2: new ethers.Wallet(pk, provider), + v3: new V3Signers.Pk.Pk(pk), + } +} 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..0b2f7c6c7 --- /dev/null +++ b/packages/utils/migration/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + poolOptions: { + singleThread: 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..5d12e4f40 100644 --- a/packages/wallet/primitives/src/context.ts +++ b/packages/wallet/primitives/src/context.ts @@ -72,6 +72,15 @@ 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 } 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: {} From 2fbca25617f392c966f8ecc1fca4ea821ab04bd0 Mon Sep 17 00:00:00 2001 From: Michael Standen Date: Wed, 22 Oct 2025 20:25:31 +1300 Subject: [PATCH 02/13] Migrations work with v3 wallet --- packages/utils/migration/src/index.ts | 2 +- .../utils/migration/src/migrations/index.ts | 43 ++- .../src/migrations/v1/migration_v1_v3.ts | 124 ++++--- .../src/migrations/v1/migrator_v1_v3.ts | 102 +++--- .../migration/src/migrations/v3/config.ts | 5 + packages/utils/migration/src/migrator.ts | 114 ------ packages/utils/migration/src/types.ts | 9 + .../migration/test/migration_v1_v3.test.ts | 345 +++++++++--------- .../migration/test/migrator_v1_v3.test.ts | 75 ++-- packages/utils/migration/test/testUtils.ts | 8 +- packages/wallet/core/src/state/cached.ts | 24 +- packages/wallet/core/src/state/index.ts | 19 + packages/wallet/core/src/state/local/index.ts | 24 +- .../wallet/core/src/state/local/indexed-db.ts | 29 ++ .../wallet/core/src/state/local/memory.ts | 28 ++ .../wallet/core/src/state/remote/dev-http.ts | 15 +- .../wallet/core/src/state/sequence/index.ts | 179 +++++++-- .../src/utils/migration/migration-encoder.ts | 81 ++++ packages/wallet/core/src/wallet.ts | 205 +++++++---- packages/wallet/primitives/src/context.ts | 17 + 20 files changed, 914 insertions(+), 534 deletions(-) delete mode 100644 packages/utils/migration/src/migrator.ts create mode 100644 packages/utils/migration/src/types.ts create mode 100644 packages/wallet/core/src/utils/migration/migration-encoder.ts diff --git a/packages/utils/migration/src/index.ts b/packages/utils/migration/src/index.ts index 8e3c1ecef..71b5e3e3b 100644 --- a/packages/utils/migration/src/index.ts +++ b/packages/utils/migration/src/index.ts @@ -1,2 +1,2 @@ export * as migration from './migrations/index.js' -export * as migrator from './migrator.js' +export * from './types.js' diff --git a/packages/utils/migration/src/migrations/index.ts b/packages/utils/migration/src/migrations/index.ts index 278fc930f..37534483b 100644 --- a/packages/utils/migration/src/migrations/index.ts +++ b/packages/utils/migration/src/migrations/index.ts @@ -1,8 +1,10 @@ +import { State } from '@0xsequence/wallet-core' +import { Payload } from '@0xsequence/wallet-primitives' import { Address, Hex } from 'ox' -import { UnsignedMigration, VersionedContext } from '../migrator.js' -import { Migration_v1v3 } from './v1/migration_v1_v3.js' +import { UnsignedMigration, VersionedContext } from '../types.js' +import { MigrationEncoder_v1v3 } from './v1/migration_v1_v3.js' -export interface Migration { +export interface MigrationEncoder { fromVersion: number toVersion: number @@ -19,20 +21,32 @@ export interface Migration { * @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 - * @returns The prepared migration + * @param options The prepare options + * @returns The migration payload to be signed */ prepareMigration: ( walletAddress: Address.Address, contexts: VersionedContext, toConfig: ToConfigType, + options: PrepareOptionsType, ) => Promise /** - * Decodes the transactions from a migration - * @param transactions The transactions to decode - * @returns The decoded address and resulting image hash for the migration transactions + * Encodes the a transaction for a given migration + * @param migration The migration to encode the transaction for + * @returns The encoded transaction */ - decodeTransactions: (transactions: UnsignedMigration['transactions']) => Promise<{ + 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 }> @@ -45,4 +59,15 @@ export interface Migrator { convertWallet: (fromWallet: FromWallet, options: ConvertOptionsType) => Promise } -export const v1v3 = new Migration_v1v3() +export const encoders: MigrationEncoder[] = [new MigrationEncoder_v1v3()] + +export function getMigrationEncoder( + 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/migration_v1_v3.ts b/packages/utils/migration/src/migrations/v1/migration_v1_v3.ts index e07dc2bbc..c3cc0133b 100644 --- a/packages/utils/migration/src/migrations/v1/migration_v1_v3.ts +++ b/packages/utils/migration/src/migrations/v1/migration_v1_v3.ts @@ -1,9 +1,14 @@ import { v1, commons as v2commons } from '@0xsequence/v2core' -import { WalletV1 } from '@0xsequence/v2wallet' -import { Config as V3Config, Context as V3Context, Extensions as V3Extensions } from '@0xsequence/wallet-primitives' +import { State } from '@0xsequence/wallet-core' +import { + Payload, + Config as V3Config, + Context as V3Context, + Extensions as V3Extensions, +} from '@0xsequence/wallet-primitives' import { AbiFunction, Address, Hex } from 'ox' -import { SignedMigration, UnsignedMigration, VersionedContext } from '../../migrator.js' -import { Migration } from '../index.js' +import { UnsignedMigration, VersionedContext } from '../../types.js' +import { MigrationEncoder } from '../index.js' import { createDefaultV3Topology } from '../v3/config.js' // uint160(keccak256("org.sequence.sdk.migration.v1v3.space.nonce")) @@ -17,25 +22,31 @@ export type ConvertOptions = { extensions?: V3Extensions.Extensions } -export class Migration_v1v3 implements Migration { +export type PrepareOptions = { + space?: bigint +} + +export class MigrationEncoder_v1v3 + implements MigrationEncoder +{ fromVersion = 1 toVersion = 3 - async convertConfig(v1Config: v1.config.WalletConfig, options: ConvertOptions): Promise { - const signerLeaves: V3Config.SignerLeaf[] = v1Config.signers.map((signer) => ({ + async convertConfig(fromConfig: v1.config.WalletConfig, options: ConvertOptions): Promise { + const signerLeaves: V3Config.SignerLeaf[] = fromConfig.signers.map((signer) => ({ type: 'signer', address: Address.from(signer.address), weight: BigInt(signer.weight), })) const v1NestedTopology = V3Config.flatLeavesToTopology(signerLeaves) - const v3Config: V3Config.Config = { + return { threshold: 1n, checkpoint: 0n, topology: [ { type: 'nested', weight: 1n, - threshold: BigInt(v1Config.threshold), + threshold: BigInt(fromConfig.threshold), tree: v1NestedTopology, }, { @@ -46,80 +57,101 @@ export class Migration_v1v3 implements Migration { const v3Context = contexts[3] || V3Context.Rc3 if (!V3Context.isContext(v3Context)) { throw new Error('Invalid context') } - const nonce = v2commons.transaction.encodeNonce(MIGRATION_V1_V3_NONCE_SPACE, 0) + const space = options?.space ?? BigInt(MIGRATION_V1_V3_NONCE_SPACE) + const nonce = 0n // Nonce must be unused + // const v2Nonce = v2commons.transaction.encodeNonce(space, nonce) // Update implementation to v3 const updateImplementationAbi = AbiFunction.from('function updateImplementation(address implementation)') - const updateImplementationTx = { + const updateImplementationTx: Payload.Call = { to: walletAddress, data: AbiFunction.encodeData(updateImplementationAbi, [v3Context.stage2]), + value: 0n, + gasLimit: 0n, + delegateCall: false, + onlyFallback: false, + behaviorOnError: 'revert', } // Update configuration to v3 - const v3ImageHash = Hex.fromBytes(V3Config.hashConfiguration(toConfig)) + const toImageHash = Hex.fromBytes(V3Config.hashConfiguration(toConfig)) const updateImageHashAbi = AbiFunction.from('function updateImageHash(bytes32 imageHash)') - const updateImageHashTx = { + const updateImageHashTx: Payload.Call = { to: walletAddress, - data: AbiFunction.encodeData(updateImageHashAbi, [v3ImageHash]), + 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 { - transactions: [updateImplementationTx, updateImageHashTx], + payload, fromVersion: this.fromVersion, toVersion: this.toVersion, - nonce, + toConfig, } } - /** - * Signs a migration with a wallet - * @notice V1 Wallets must call this method for each chain they are migrating on - * @param migration The unsigned migration to sign - * @param wallet The wallet to sign the migration with - * @returns The signed migration - */ - //FIXME Remove this function. Signing is not a responsibility of the migration class. - async signMigration(migration: UnsignedMigration, wallet: WalletV1): Promise { - const { address } = await this.decodeTransactions(migration.transactions) - if (address !== wallet.address) { - throw new Error('Wallet address does not match migration address') + 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 txBundle: v2commons.transaction.TransactionBundle = { - entrypoint: wallet.address, - transactions: migration.transactions.map((tx) => ({ - to: tx.to, - data: tx.data, - gasLimit: 0n, - revertOnError: true, - })), - nonce: migration.nonce, + const encodedData = v2commons.transaction.encodeBundleExecData(txBundle) + Hex.assert(encodedData) + return { + to: walletAddress, + data: encodedData, } - const { signature } = await wallet.signTransactionBundle(txBundle) - Hex.assert(signature) - return { ...migration, signature } } - async decodeTransactions(transactions: UnsignedMigration['transactions']): Promise<{ + async decodePayload(payload: Payload.Calls): Promise<{ address: Address.Address toImageHash: Hex.Hex }> { - if (transactions.length !== 2) { - throw new Error('Invalid transactions') + if (payload.calls.length !== 2) { + throw new Error('Invalid calls') } - const tx1 = transactions[0]! - const tx2 = transactions[1]! + const tx1 = payload.calls[0]! + const tx2 = payload.calls[1]! if (tx1.to !== tx2.to) { throw new Error('Invalid to address') } diff --git a/packages/utils/migration/src/migrations/v1/migrator_v1_v3.ts b/packages/utils/migration/src/migrations/v1/migrator_v1_v3.ts index bf8c8e2ec..975555dfd 100644 --- a/packages/utils/migration/src/migrations/v1/migrator_v1_v3.ts +++ b/packages/utils/migration/src/migrations/v1/migrator_v1_v3.ts @@ -1,77 +1,89 @@ -import { commons as v2commons } from '@0xsequence/v2core' -import { migrator as v2migrator } from '@0xsequence/v2migration' +import { v1, commons as v2commons } from '@0xsequence/v2core' import { WalletV1 } from '@0xsequence/v2wallet' import { State, Wallet as WalletV3 } from '@0xsequence/wallet-core' -import { Constants, Context as V3Context } from '@0xsequence/wallet-primitives' -import { Address } from 'ox' +import { Payload, Context as V3Context } from '@0xsequence/wallet-primitives' +import { Address, Hex } from 'ox' import { Migrator } from '../index.js' -import { ConvertOptions, Migration_v1v3 } from './migration_v1_v3.js' +import { ConvertOptions, MigrationEncoder_v1v3, PrepareOptions } from './migration_v1_v3.js' -export type MigratorV1V3Options = ConvertOptions & { - v3Context?: V3Context.Context -} +export type MigratorV1V3Options = ConvertOptions & + PrepareOptions & { + v3Context?: V3Context.Context + } export class Migrator_v1v3 implements Migrator { fromVersion = 1 toVersion = 3 constructor( - private readonly v1Tracker?: v2migrator.PresignedMigrationTracker, - private readonly v3StateProvider?: State.Provider, - public readonly migration: Migration_v1v3 = new Migration_v1v3(), + 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 migration + // Prepare configuration + const walletAddress = Address.from(v1Wallet.address) const v3Context = options.v3Context || V3Context.Rc3 const v1Config = v1Wallet.config - const v3Config = await this.migration.convertConfig(v1Config, options) - await this.v3StateProvider?.saveConfiguration(v3Config) - const unsignedMigration = await this.migration.prepareMigration( - Address.from(v1Wallet.address), - { [3]: v3Context }, - v3Config, - ) + 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, 1) + } + await this.v3StateProvider.saveDeploy(v1ImageHash, this.convertV1Context(v1Wallet.context)) + await this.v3StateProvider.saveConfiguration(v3Config) + + // Prepare migration + const unsignedMigration = await this.encoder.prepareMigration(walletAddress, { [3]: 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: v1Wallet.address, - transactions: unsignedMigration.transactions.map((tx) => ({ + entrypoint: walletAddress, + transactions: unsignedMigration.payload.calls.map((tx: Payload.Call) => ({ to: tx.to, data: tx.data, gasLimit: 0n, revertOnError: true, })), - nonce: unsignedMigration.nonce, + nonce: v2Nonce, } - const signedTxBundle = await v1Wallet.signTransactionBundle(txBundle) + const { signature } = await v1Wallet.signTransactionBundle(txBundle) + Hex.assert(signature) // Save to tracker - const v2SignedMigration: v2migrator.SignedMigration = { - fromVersion: this.fromVersion, - toVersion: this.toVersion, - toConfig: { - version: 3, - ...v3Config, - }, - tx: signedTxBundle, - } - const versionedContext: v2commons.context.VersionedContext = { - [3]: { - version: 3, - mainModule: v3Context.stage1, - mainModuleUpgradable: v3Context.stage2, - factory: v3Context.factory, - guestModule: Constants.DefaultGuestAddress, - walletCreationCode: v3Context.creationCode, - }, + const signedMigration: State.Migration = { + ...unsignedMigration, + fromImageHash: v1ImageHash, + chainId: Number(chainId), + signature, } - await this.v1Tracker?.saveMigration(v1Wallet.address, v2SignedMigration, versionedContext) - //FIXME State provider should be aware of migrations too + await this.v3StateProvider.saveMigration(walletAddress, signedMigration) // Return v3 wallet - return WalletV3.fromConfiguration(v3Config, { - context: v3Context, + 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 index 768afb0ba..de9173841 100644 --- a/packages/utils/migration/src/migrations/v3/config.ts +++ b/packages/utils/migration/src/migrations/v3/config.ts @@ -26,12 +26,14 @@ export const createDefaultV3Topology = ( address: loginSigner.address, weight: 1n, } + // Wallet guard topology const walletGuardTopology: V3Config.SignerLeaf = { type: 'signer', address: '0xa2e70CeaB3Eb145F32d110383B75B330fA4e288a', // Guard wallet signer weight: 1n, } + // Placeholder recovery topology const recoveryTopology: V3Config.SapientSignerLeaf = { type: 'sapient-signer', @@ -39,6 +41,7 @@ export const createDefaultV3Topology = ( weight: 255n, imageHash: '0x0000000000000000000000000000000000000000000000000000000000000000', } + // Session topology let sessionsImageHash: Hex.Hex = '0x0000000000000000000000000000000000000000000000000000000000000000' if (!loginSigner.imageHash) { @@ -53,6 +56,7 @@ export const createDefaultV3Topology = ( weight: 1n, imageHash: sessionsImageHash, } + // Sessions are protected by a guard signer const sessionGuardTopology: V3Config.SignerLeaf = { type: 'signer', @@ -65,6 +69,7 @@ export const createDefaultV3Topology = ( threshold: 2n, tree: [sessionTopology, sessionGuardTopology], } + // Return the wallet topology return [ [loginTopology, walletGuardTopology], diff --git a/packages/utils/migration/src/migrator.ts b/packages/utils/migration/src/migrator.ts deleted file mode 100644 index 162dbf92c..000000000 --- a/packages/utils/migration/src/migrator.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { commons as v2commons } from '@0xsequence/v2core' -import { Context as V3Context } from '@0xsequence/wallet-primitives' -import { Address, Hex } from 'ox' - -export type VersionedContext = { [key: number]: v2commons.context.WalletContext | V3Context.Context } - -export type UnsignedMigration = { - transactions: { - to: Address.Address - data: Hex.Hex - }[] - nonce: bigint - fromVersion: number - toVersion: number -} - -export type SignedMigration = UnsignedMigration & { - signature: Hex.Hex -} - -export interface PresignedMigrationTracker { - getMigration( - address: Address.Address, - fromImageHash: Hex.Hex, - fromVersion: number, - chainId: number, - ): Promise - - saveMigration(address: Address.Address, signed: SignedMigration, contexts: VersionedContext): Promise -} - -/* - -// FIXME This class doesn't work because we need to cater for multiple wallet types and multiple chains -export class Migrator { - constructor( - public readonly trackers: PresignedMigrationTracker[], - public readonly migrations: Migration[], - public readonly contexts: VersionedContext - ) { - validateMigrations(migrations) - } - - async getAllMigratePresignedTransactions(args: { - address: Address.Address - fromImageHash: Hex.Hex - fromVersion: number - chainId: number - }): Promise<{ - signedMigrations: SignedMigration[] - lastVersion: number - lastImageHash: Hex.Hex - missing: boolean - }> { - const { address, fromImageHash, fromVersion, chainId } = args - - let currentImageHash = fromImageHash - let currentVersion = fromVersion - - const migs: SignedMigration[] = [] - for (const migration of this.migrations) { - const trackerMigrations = await Promise.all(this.trackers.map(async tracker => ({ - tracker: tracker, - migration: await tracker.getMigration(address, currentImageHash, currentVersion, chainId) - }))) - const trackerMigration = trackerMigrations.find(tm => tm.migration !== undefined)?.migration - if (!trackerMigration) return { signedMigrations: migs, missing: true, lastImageHash: currentImageHash, lastVersion: currentVersion } - // Ensure all trackers are tracking this migration - for (const tm of trackerMigrations) { - if (tm.migration === undefined) { - // Save it - await tm.tracker.saveMigration(address, trackerMigration, this.contexts) - } else { - // Compare it matches the expected migration (using a quick JSON stringify equal) - if (JSON.stringify(tm.migration) !== JSON.stringify(trackerMigration)) { - throw new Error(`Tracker migrations do not match`) - } - } - } - - migs.push(trackerMigration) - if (trackerMigration.fromVersion !== migration.fromVersion || trackerMigration.toVersion !== migration.toVersion) { - throw new Error(`Tracker migration version does not match expected version: ${trackerMigration.fromVersion} -> ${trackerMigration.toVersion} !== ${migration.fromVersion} -> ${migration.toVersion}`) - } - const decoded = await migration.decodeTransactions(trackerMigration.transactions) - if (decoded.address !== address) { - throw new Error(`Migration transaction address does not match expected address: ${decoded.address} !== ${address}`) - } - - currentImageHash = decoded.toImageHash - currentVersion = migration.toVersion - } - - return { signedMigrations: migs, missing: false, lastImageHash: currentImageHash, lastVersion: currentVersion } - } - - // async signAllMigrations( - // address: Address.Address, - // fromVersion: number, - // wallet: V2Wallet - // ): Promise { - // const migrations = this.migrations.filter(m => m.fromVersion === fromVersion) - // if (migrations.length === 0) { - // throw new Error(`No migrations found for version: ${fromVersion}`) - // } - - // return Promise.all(migrations.map(async (migration): Promise => { - // const nextConfig = await migration.convertConfig(fromConfig, options) - // const unsignedMigration = await migration.prepareMigration(address, this.contexts, nextConfig) - // return migration.signMigration(unsignedMigration, wallet) - // })) - // } -} -*/ diff --git a/packages/utils/migration/src/types.ts b/packages/utils/migration/src/types.ts new file mode 100644 index 000000000..8ad407956 --- /dev/null +++ b/packages/utils/migration/src/types.ts @@ -0,0 +1,9 @@ +import { commons as v2commons } from '@0xsequence/v2core' +import { State } from '@0xsequence/wallet-core' +import { Context as V3Context } from '@0xsequence/wallet-primitives' + +export type VersionedContext = { [key: number]: v2commons.context.WalletContext | V3Context.Context } + +export type UnsignedMigration = Omit & { + chainId?: number +} diff --git a/packages/utils/migration/test/migration_v1_v3.test.ts b/packages/utils/migration/test/migration_v1_v3.test.ts index bf4c89d57..b87d308e6 100644 --- a/packages/utils/migration/test/migration_v1_v3.test.ts +++ b/packages/utils/migration/test/migration_v1_v3.test.ts @@ -2,7 +2,7 @@ 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, Wallet as V3Wallet } from '@0xsequence/wallet-core' +import { Envelope, State, Wallet as V3Wallet } from '@0xsequence/wallet-core' import { Payload, Config as V3Config, @@ -11,11 +11,22 @@ import { } 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, Migration_v1v3 } from '../src/migrations/v1/migration_v1_v3.js' -import { UnsignedMigration, VersionedContext } from '../src/migrator.js' -import { createMultiSigner, MultiSigner, V1WalletType } from './testUtils.js' -import { fromRpcStatus } from 'ox/TransactionReceipt' +import { VersionedContext } from '../src/types.js' +import { createMultiSigner, MultiSigner } from './testUtils.js' + +const convertContextToV3Context = (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, + } +} describe('Migration_v1v3', () => { let anvilSigner: MultiSigner @@ -29,8 +40,6 @@ describe('Migration_v1v3', () => { let migration: Migration_v1v3 - let v1Config: v1.config.WalletConfig - let v1Wallet: V1WalletType let testAddress: Address.Address beforeEach(async () => { @@ -44,34 +53,8 @@ describe('Migration_v1v3', () => { const anvilPk = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80' anvilSigner = createMultiSigner(anvilPk, providers.v2) testAddress = '0x742d35cc6635c0532925a3b8d563a6b35b7f05f1' - testSigner = createMultiSigner(Secp256k1.randomPrivateKey(), providers.v2) - console.log('testSigner', testSigner.address) - v1Config = { - version: 1, - threshold: 1, - signers: [ - { - weight: 1, - address: testSigner.address, - }, - ], - } - const orchestrator = new Orchestrator([testSigner.v2]) - v1Wallet = await V1Wallet.newWallet< - v1.config.WalletConfig, - v1.signature.Signature, - v1.signature.UnrecoveredSignature - >({ - context: v1.DeployedWalletContext, - chainId: 42161, - coders: { - config: v1.config.ConfigCoder, - signature: v1.signature.SignatureCoder, - }, - orchestrator, - config: v1Config, - relayer: new LocalRelayer(anvilSigner.v2), - }) + const testSignerPk = Secp256k1.randomPrivateKey() + testSigner = createMultiSigner(testSignerPk, providers.v2) }) describe('convertConfig', () => { @@ -309,15 +292,19 @@ describe('Migration_v1v3', () => { ], } - const migrationResult = await migration.prepareMigration(walletAddress, contexts, v3Config) + const randomSpace = BigInt(Math.floor(Math.random() * 10000000000)) + const migrationResult = await migration.prepareMigration(walletAddress, contexts, v3Config, { + space: BigInt(randomSpace), + }) expect(migrationResult.fromVersion).toBe(1) expect(migrationResult.toVersion).toBe(3) - expect(migrationResult.transactions).toHaveLength(2) - expect(migrationResult.nonce).toBeDefined() + 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.transactions[0] + const updateImplTx = migrationResult.payload.calls[0] expect(updateImplTx.to).toBe(walletAddress) const updateImplementationAbi = AbiFunction.from('function updateImplementation(address implementation)') @@ -325,7 +312,7 @@ describe('Migration_v1v3', () => { expect(decodedImplArgs[0].toLowerCase()).toBe(V3Context.Rc3.stage2.toLowerCase()) // Check second transaction (update image hash) - const updateImageHashTx = migrationResult.transactions[1] + const updateImageHashTx = migrationResult.payload.calls[1] expect(updateImageHashTx.to).toBe(walletAddress) const updateImageHashAbi = AbiFunction.from('function updateImageHash(bytes32 imageHash)') @@ -381,9 +368,9 @@ describe('Migration_v1v3', () => { ], } - const migrationResult = await migration.prepareMigration(walletAddress, contexts, v3Config) + const migrationResult = await migration.prepareMigration(walletAddress, contexts, v3Config, {}) - const updateImplTx = migrationResult.transactions[0] + 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) @@ -405,144 +392,135 @@ describe('Migration_v1v3', () => { }, } - await expect(migration.prepareMigration(walletAddress, contexts, v3Config)).rejects.toThrow('Invalid context') + await expect(migration.prepareMigration(walletAddress, contexts, v3Config, {})).rejects.toThrow('Invalid context') }) }) - describe('signMigration', () => { - it('should sign migration correctly', async () => { + describe('decodeTransactions', () => { + 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 unsignedMigration: UnsignedMigration = { - transactions: [ + const payload: Payload.Calls = { + type: 'call', + space: 0n, + nonce: 0n, + calls: [ { - to: Address.from(v1Wallet.address), + to: walletAddress, + value: 0n, + gasLimit: 0n, + delegateCall: false, + onlyFallback: false, + behaviorOnError: 'revert', data: AbiFunction.encodeData(updateImplementationAbi, [V3Context.Rc3.stage2]), }, { - to: Address.from(v1Wallet.address), - data: AbiFunction.encodeData(updateImageHashAbi, [ - '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', - ]), + to: walletAddress, + value: 0n, + gasLimit: 0n, + delegateCall: false, + onlyFallback: false, + behaviorOnError: 'revert', + data: AbiFunction.encodeData(updateImageHashAbi, [imageHash]), }, ], - nonce: 123n, - fromVersion: 1, - toVersion: 3, } - const signedMigration = await migration.signMigration(unsignedMigration, v1Wallet) + const decoded = await migration.decodePayload(payload) + + expect(decoded.address).toBe(walletAddress) + expect(decoded.toImageHash).toBe(imageHash) + }) - expect(signedMigration.signature).toBeDefined() - expect(signedMigration.fromVersion).toBe(1) - expect(signedMigration.toVersion).toBe(3) - expect(signedMigration.transactions).toEqual(unsignedMigration.transactions) - expect(signedMigration.nonce).toBe(123n) + 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', + }, + ], + } - // Note: We can't easily mock the internal signTransactionBundle call since it's part of the wallet - // The test verifies that the migration was signed successfully + await expect(migration.decodePayload(payload)).rejects.toThrow('Invalid calls') }) - it('should throw error when wallet address does not match migration address', async () => { + 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 unsignedMigration: UnsignedMigration = { - transactions: [ + const payload: Payload.Calls = { + type: 'call', + space: 0n, + nonce: 0n, + calls: [ { - to: differentAddress, + 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', }, ], - nonce: 123n, - fromVersion: 1, - toVersion: 3, } - await expect(migration.signMigration(unsignedMigration, v1Wallet)).rejects.toThrow( - 'Wallet address does not match migration address', - ) + await expect(migration.decodePayload(payload)).rejects.toThrow('Invalid to address') }) - }) - - describe('decodeTransactions', () => { - 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 transactions = [ - { - to: walletAddress, - data: AbiFunction.encodeData(updateImplementationAbi, [V3Context.Rc3.stage2]), - }, - { - to: walletAddress, - data: AbiFunction.encodeData(updateImageHashAbi, [imageHash]), - }, - ] - - const decoded = await migration.decodeTransactions(transactions) - expect(decoded.address).toBe(walletAddress) - expect(decoded.toImageHash).toBe(imageHash) - }) - - it('should throw error for invalid number of transactions', async () => { - const transactions: UnsignedMigration['transactions'] = [ - { - to: testAddress, - data: '0x1234567890abcdef', - }, - ] - - await expect(migration.decodeTransactions(transactions)).rejects.toThrow('Invalid transactions') - }) - - it('should throw error when transaction 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 transactions: UnsignedMigration['transactions'] = [ - { - to: testAddress, - data: AbiFunction.encodeData(updateImplementationAbi, [V3Context.Rc3.stage2]), - }, - { - to: differentAddress, - data: AbiFunction.encodeData(updateImageHashAbi, [ - '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', - ]), - }, - ] - - await expect(migration.decodeTransactions(transactions)).rejects.toThrow('Invalid to address') - }) - - it('should throw error for invalid transaction data', async () => { - const transactions: UnsignedMigration['transactions'] = [ - { - to: testAddress, - data: '0xinvalid', - }, - { - to: testAddress, - data: '0xalsoinvalid', - }, - ] + 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.decodeTransactions(transactions)).rejects.toThrow() + await expect(migration.decodePayload(payload)).rejects.toThrow(/^Invalid byte sequence/) }) }) @@ -558,7 +536,7 @@ describe('Migration_v1v3', () => { }) describe('integration test', () => { - it('should perform complete migration flow', async () => { + it('should use migration ', async () => { // Create v1 config const v1Config: v1.config.WalletConfig = { version: 1, @@ -570,6 +548,26 @@ describe('Migration_v1v3', () => { }, ], } + const v1ImageHash = v1.config.ConfigCoder.imageHashOf(v1Config) + Hex.assert(v1ImageHash) + const orchestrator = new Orchestrator([testSigner.v2]) + 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 = { @@ -583,41 +581,47 @@ describe('Migration_v1v3', () => { const contexts: VersionedContext = { 3: V3Context.Rc3, } - const unsignedMigration = await migration.prepareMigration(Address.from(v1Wallet.address), contexts, v3Config) - - // Sign migration - const signedMigration = await migration.signMigration(unsignedMigration, v1Wallet) - - // Verify signed migration - expect(signedMigration.signature).toBeDefined() - expect(signedMigration.fromVersion).toBe(1) - expect(signedMigration.toVersion).toBe(3) - expect(signedMigration.transactions).toHaveLength(2) + const unsignedMigration = await migration.prepareMigration(walletAddress, contexts, v3Config, {}) // Decode transactions - const decoded = await migration.decodeTransactions(signedMigration.transactions) - expect(decoded.address).toBe(v1Wallet.address) + const decoded = await migration.decodePayload(unsignedMigration.payload) + expect(decoded.address).toBe(walletAddress) expect(decoded.toImageHash).toBe(Hex.fromBytes(V3Config.hashConfiguration(v3Config))) - // Send it - const signedTxBundle: v2commons.transaction.IntendedTransactionBundle = { - entrypoint: v1Wallet.address, - transactions: signedMigration.transactions, - nonce: signedMigration.nonce, - chainId, - intent: { - id: '1', - wallet: v1Wallet.address, - }, + // 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 tx = await v1Wallet.sendSignedTransaction(signedTxBundle) - console.log('tx', tx) + 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() - console.log('receipt', receipt) 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, convertContextToV3Context(v1.DeployedWalletContext)) + await stateProvider.saveConfiguration(v3Config) // Test the wallet works as a v3 wallet now with a test transaction - const v3Wallet = await V3Wallet.fromConfiguration(v3Config) + const v3Wallet = new V3Wallet(walletAddress, { stateProvider }) const call: Payload.Call = { to: Address.from('0x0000000000000000000000000000000000000000'), data: Hex.fromString('0x'), @@ -640,17 +644,14 @@ describe('Migration_v1v3', () => { }, ]) const signedTx = await v3Wallet.buildTransaction(providers.v3, signedEnvelope) - console.log(`V3 transaction: ${signedTx.to} ${signedTx.data}`) const testTx = await providers.v3.request({ method: 'eth_sendTransaction', params: [signedTx], }) - console.log(`V3 transaction sent ${testTx}`) const testReceipt = await providers.v3.request({ method: 'eth_getTransactionReceipt', params: [testTx], }) - console.log(`V3 transaction successful! ${JSON.stringify(testReceipt)}`) assert(testReceipt?.status, 'Receipt status is undefined') expect(fromRpcStatus[testReceipt.status]).toBe('success') }) diff --git a/packages/utils/migration/test/migrator_v1_v3.test.ts b/packages/utils/migration/test/migrator_v1_v3.test.ts index 4ad440669..4631d3bd5 100644 --- a/packages/utils/migration/test/migrator_v1_v3.test.ts +++ b/packages/utils/migration/test/migrator_v1_v3.test.ts @@ -1,18 +1,17 @@ import { LocalRelayer } from '@0xsequence/relayerv2' import { Orchestrator } from '@0xsequence/signhubv2' import { v1 } from '@0xsequence/v2core' -import { trackers as v2trackers } from '@0xsequence/v2sessions' 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 { createMultiSigner, type MultiSigner, type V1WalletType } from './testUtils.js' -import { fromRpcStatus } from 'ox/TransactionReceipt' -describe('Migration_v1v3', () => { +describe('Migrator_v1v3', () => { let anvilSigner: MultiSigner let testSigner: MultiSigner @@ -22,13 +21,9 @@ describe('Migration_v1v3', () => { } let chainId: number - let tracker: v2trackers.local.LocalConfigTracker let stateProvider: State.Provider - let migrator: Migrator_v1v3 - let v1Config: v1.config.WalletConfig - let v1Wallet: V1WalletType - let testAddress: Address.Address + let migrator: Migrator_v1v3 beforeEach(async () => { const url = 'http://127.0.0.1:8545' @@ -38,45 +33,44 @@ describe('Migration_v1v3', () => { } chainId = Number(await providers.v3.request({ method: 'eth_chainId' })) - tracker = new v2trackers.local.LocalConfigTracker(providers.v2) - stateProvider = new State.Local.Provider() - migrator = new Migrator_v1v3() //tracker, stateProvider) + stateProvider = new State.Sequence.Provider('http://127.0.0.1:36261') + migrator = new Migrator_v1v3(stateProvider) const anvilPk = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80' anvilSigner = createMultiSigner(anvilPk, providers.v2) - testAddress = '0x742d35cc6635c0532925a3b8d563a6b35b7f05f1' testSigner = createMultiSigner(Secp256k1.randomPrivateKey(), providers.v2) - console.log('testSigner', testSigner.address) - v1Config = { - version: 1, - threshold: 1, - signers: [ - { - weight: 1, - address: testSigner.address, - }, - ], - } - const orchestrator = new Orchestrator([testSigner.v2]) - v1Wallet = await V1Wallet.newWallet< - v1.config.WalletConfig, - v1.signature.Signature, - v1.signature.UnrecoveredSignature - >({ - context: v1.DeployedWalletContext, - chainId: 42161, - coders: { - config: v1.config.ConfigCoder, - signature: v1.signature.SignatureCoder, - }, - orchestrator, - config: v1Config, - relayer: new LocalRelayer(anvilSigner.v2), - }) }) describe('convertWallet', () => { it('should convert a v1 wallet to a v3 wallet', async () => { + const v1Config = { + version: 1, + threshold: 1, + signers: [ + { + weight: 1, + address: testSigner.address, + }, + ], + } + const orchestrator = new Orchestrator([testSigner.v2]) + 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, @@ -107,17 +101,14 @@ describe('Migration_v1v3', () => { }, ]) const signedTx = await v3Wallet.buildTransaction(providers.v3, signedEnvelope) - console.log(`V3 transaction: ${signedTx.to} ${signedTx.data}`) const testTx = await providers.v3.request({ method: 'eth_sendTransaction', params: [signedTx], }) - console.log(`V3 transaction sent ${testTx}`) const receipt = await providers.v3.request({ method: 'eth_getTransactionReceipt', params: [testTx], }) - console.log(`V3 transaction successful! ${JSON.stringify(receipt)}`) assert(receipt?.status, 'Receipt status is undefined') expect(fromRpcStatus[receipt.status]).toBe('success') }) diff --git a/packages/utils/migration/test/testUtils.ts b/packages/utils/migration/test/testUtils.ts index e3a714a67..37ed3b4c3 100644 --- a/packages/utils/migration/test/testUtils.ts +++ b/packages/utils/migration/test/testUtils.ts @@ -15,10 +15,16 @@ export type MultiSigner = { export type V1WalletType = V1Wallet 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: new ethers.Wallet(pk, provider), + v2, v3: new V3Signers.Pk.Pk(pk), } } 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..49c2bcb93 100644 --- a/packages/wallet/core/src/state/sequence/index.ts +++ b/packages/wallet/core/src/state/sequence/index.ts @@ -8,8 +8,16 @@ 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 { + SaveConfigArgs, + 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 +37,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 +271,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 +383,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 +406,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 +738,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..8a050c991 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,8 @@ 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 chainId?: number counterFactual: { context: Context.KnownContext | Context.Context @@ -49,7 +52,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, @@ -172,6 +175,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 +186,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,25 +207,40 @@ 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 + }) + }), ]) - 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 if (!context) { + // Add to status throw new Error(`cannot find context for ${this.address}`) } @@ -242,20 +262,45 @@ 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) + if (detectedContextVersion !== 3) { + // TODO Cater for 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)) + } } + // 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 +312,7 @@ export class Wallet { configuration, imageHash, pendingUpdates: [...updates].reverse(), + pendingMigrations: [...migrations], chainId, onChainImageHash: onChainImageHash!, context, @@ -279,10 +325,11 @@ export class Wallet { configuration, imageHash, pendingUpdates: [...updates].reverse(), + pendingMigrations: [...migrations], chainId, counterFactual: { context: counterFactualContext, - imageHash: deployInformation.imageHash, + imageHash: deployInformation?.imageHash ?? '', }, } as T extends Provider.Provider ? WalletStatusWithOnchain : WalletStatus } @@ -442,6 +489,7 @@ export class Wallet { options?: { space?: bigint noConfigUpdate?: boolean + noMigration?: boolean unsafe?: boolean }, ): Promise> { @@ -494,7 +542,13 @@ export class Wallet { } } - 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 +556,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', + })), + }), + ), } } @@ -585,6 +635,9 @@ export class Wallet { provider?: Provider.Provider, ): Promise { const status = await this.getStatus(provider) + if (status.pendingMigrations.length > 0) { + throw new Error('execute pending migrations before signing a message') + } const signature = Envelope.encodeSignature(envelope) if (!status.isDeployed) { const deployTransaction = await this.buildDeployTransaction() diff --git a/packages/wallet/primitives/src/context.ts b/packages/wallet/primitives/src/context.ts index 5d12e4f40..f397cf6a7 100644 --- a/packages/wallet/primitives/src/context.ts +++ b/packages/wallet/primitives/src/context.ts @@ -84,3 +84,20 @@ export function isContext(context: any): context is Context { 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 +} From c2c7ca6245776e56da2026630bdf09f8f3505582 Mon Sep 17 00:00:00 2001 From: Michael Standen Date: Wed, 22 Oct 2025 21:22:28 +1300 Subject: [PATCH 03/13] Ensure wallet is v3 --- packages/wallet/core/src/wallet.ts | 49 +++++++++++++++++++++--------- 1 file changed, 35 insertions(+), 14 deletions(-) diff --git a/packages/wallet/core/src/wallet.ts b/packages/wallet/core/src/wallet.ts index 8a050c991..5e3b9b79e 100644 --- a/packages/wallet/core/src/wallet.ts +++ b/packages/wallet/core/src/wallet.ts @@ -36,6 +36,7 @@ export type WalletStatus = { 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 @@ -125,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 { @@ -272,8 +276,9 @@ export class Wallet { // Get migrations const detectedContextVersion = Context.getVersionFromContext(context) + let version = detectedContextVersion if (detectedContextVersion !== 3) { - // TODO Cater for v3 -> v3 migrations + // TODO Cater for pending v3 -> v3 migrations const migration = await this.stateProvider.getMigration( this.address, fromImageHash, @@ -290,6 +295,7 @@ export class Wallet { migrations.push(migration) // We will perform the migration and update configurations from there. fromImageHash = Bytes.toHex(Config.hashConfiguration(migration.toConfig)) + version = migration.toVersion } } @@ -313,6 +319,7 @@ export class Wallet { imageHash, pendingUpdates: [...updates].reverse(), pendingMigrations: [...migrations], + version, chainId, onChainImageHash: onChainImageHash!, context, @@ -326,6 +333,7 @@ export class Wallet { imageHash, pendingUpdates: [...updates].reverse(), pendingMigrations: [...migrations], + version, chainId, counterFactual: { context: counterFactualContext, @@ -396,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) { @@ -449,7 +458,7 @@ export class Wallet { factory, factoryData, }, - ...(await this.prepareBlankEnvelope(Number(chainId), provider)), + ...(await this.prepareBlankEnvelope(Number(chainId), status)), } } @@ -514,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) + if (!options?.noConfigUpdate) { - const status = await this.getStatus(provider) if (status.imageHash !== status.onChainImageHash) { calls.push({ to: this.address, @@ -538,7 +549,7 @@ export class Wallet { nonce, calls, }, - ...(await this.prepareBlankEnvelope(Number(chainId), provider)), + ...(await this.prepareBlankEnvelope(Number(chainId), status)), } } @@ -624,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), } } @@ -635,9 +649,8 @@ export class Wallet { provider?: Provider.Provider, ): Promise { const status = await this.getStatus(provider) - if (status.pendingMigrations.length > 0) { - throw new Error('execute pending migrations before signing a message') - } + this.requireV3Wallet(status, true) + const signature = Envelope.encodeSignature(envelope) if (!status.isDeployed) { const deployTransaction = await this.buildDeployTransaction() @@ -650,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 + } } From 1680b175b7220b0c84ab2585f9bd89040b4ee6d6 Mon Sep 17 00:00:00 2001 From: Michael Standen Date: Wed, 22 Oct 2025 21:23:53 +1300 Subject: [PATCH 04/13] Fix test --- packages/utils/migration/src/migrations/index.ts | 2 +- .../v1/{migration_v1_v3.ts => encoder_v1_v3.ts} | 0 .../migration/src/migrations/v1/migrator_v1_v3.ts | 2 +- ...igration_v1_v3.test.ts => encoder_v1_v3.test.ts} | 13 +++++++++---- .../utils/migration/test/migrator_v1_v3.test.ts | 8 +++++++- 5 files changed, 18 insertions(+), 7 deletions(-) rename packages/utils/migration/src/migrations/v1/{migration_v1_v3.ts => encoder_v1_v3.ts} (100%) rename packages/utils/migration/test/{migration_v1_v3.test.ts => encoder_v1_v3.test.ts} (97%) diff --git a/packages/utils/migration/src/migrations/index.ts b/packages/utils/migration/src/migrations/index.ts index 37534483b..86944004d 100644 --- a/packages/utils/migration/src/migrations/index.ts +++ b/packages/utils/migration/src/migrations/index.ts @@ -2,7 +2,7 @@ import { State } from '@0xsequence/wallet-core' import { Payload } from '@0xsequence/wallet-primitives' import { Address, Hex } from 'ox' import { UnsignedMigration, VersionedContext } from '../types.js' -import { MigrationEncoder_v1v3 } from './v1/migration_v1_v3.js' +import { MigrationEncoder_v1v3 } from './v1/encoder_v1_v3.js' export interface MigrationEncoder { fromVersion: number diff --git a/packages/utils/migration/src/migrations/v1/migration_v1_v3.ts b/packages/utils/migration/src/migrations/v1/encoder_v1_v3.ts similarity index 100% rename from packages/utils/migration/src/migrations/v1/migration_v1_v3.ts rename to packages/utils/migration/src/migrations/v1/encoder_v1_v3.ts diff --git a/packages/utils/migration/src/migrations/v1/migrator_v1_v3.ts b/packages/utils/migration/src/migrations/v1/migrator_v1_v3.ts index 975555dfd..0efceba86 100644 --- a/packages/utils/migration/src/migrations/v1/migrator_v1_v3.ts +++ b/packages/utils/migration/src/migrations/v1/migrator_v1_v3.ts @@ -4,7 +4,7 @@ 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 './migration_v1_v3.js' +import { ConvertOptions, MigrationEncoder_v1v3, PrepareOptions } from './encoder_v1_v3.js' export type MigratorV1V3Options = ConvertOptions & PrepareOptions & { diff --git a/packages/utils/migration/test/migration_v1_v3.test.ts b/packages/utils/migration/test/encoder_v1_v3.test.ts similarity index 97% rename from packages/utils/migration/test/migration_v1_v3.test.ts rename to packages/utils/migration/test/encoder_v1_v3.test.ts index b87d308e6..762803dc9 100644 --- a/packages/utils/migration/test/migration_v1_v3.test.ts +++ b/packages/utils/migration/test/encoder_v1_v3.test.ts @@ -13,7 +13,7 @@ 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, Migration_v1v3 } from '../src/migrations/v1/migration_v1_v3.js' +import { MIGRATION_V1_V3_NONCE_SPACE, MigrationEncoder_v1v3 } from '../src/migrations/v1/encoder_v1_v3.js' import { VersionedContext } from '../src/types.js' import { createMultiSigner, MultiSigner } from './testUtils.js' @@ -28,7 +28,7 @@ const convertContextToV3Context = (context: v2commons.context.WalletContext): V3 } } -describe('Migration_v1v3', () => { +describe('MigrationEncoder_v1v3', () => { let anvilSigner: MultiSigner let testSigner: MultiSigner @@ -38,12 +38,12 @@ describe('Migration_v1v3', () => { } let chainId: number - let migration: Migration_v1v3 + let migration: MigrationEncoder_v1v3 let testAddress: Address.Address beforeEach(async () => { - migration = new Migration_v1v3() + migration = new MigrationEncoder_v1v3() const url = 'http://127.0.0.1:8545' providers = { v2: ethers.getDefaultProvider(url), @@ -546,6 +546,11 @@ describe('Migration_v1v3', () => { 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) diff --git a/packages/utils/migration/test/migrator_v1_v3.test.ts b/packages/utils/migration/test/migrator_v1_v3.test.ts index 4631d3bd5..c60a88479 100644 --- a/packages/utils/migration/test/migrator_v1_v3.test.ts +++ b/packages/utils/migration/test/migrator_v1_v3.test.ts @@ -33,7 +33,8 @@ describe('Migrator_v1v3', () => { } chainId = Number(await providers.v3.request({ method: 'eth_chainId' })) - stateProvider = new State.Sequence.Provider('http://127.0.0.1:36261') + stateProvider = new State.Local.Provider() + // stateProvider = new State.Sequence.Provider('http://127.0.0.1:36261') migrator = new Migrator_v1v3(stateProvider) const anvilPk = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80' @@ -51,6 +52,11 @@ describe('Migrator_v1v3', () => { 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]) From 1c082c93e1397a16157b2e52370795773831ccfd Mon Sep 17 00:00:00 2001 From: Michael Standen Date: Thu, 23 Oct 2025 10:55:30 +1300 Subject: [PATCH 05/13] More v3 convert config options --- .../src/migrations/v1/encoder_v1_v3.ts | 14 +--- .../migration/src/migrations/v3/config.ts | 80 +++++++++++-------- 2 files changed, 49 insertions(+), 45 deletions(-) diff --git a/packages/utils/migration/src/migrations/v1/encoder_v1_v3.ts b/packages/utils/migration/src/migrations/v1/encoder_v1_v3.ts index c3cc0133b..e55495a01 100644 --- a/packages/utils/migration/src/migrations/v1/encoder_v1_v3.ts +++ b/packages/utils/migration/src/migrations/v1/encoder_v1_v3.ts @@ -9,19 +9,13 @@ import { import { AbiFunction, Address, Hex } from 'ox' import { UnsignedMigration, VersionedContext } from '../../types.js' import { MigrationEncoder } from '../index.js' -import { createDefaultV3Topology } from '../v3/config.js' +import { ConvertOptions as V3ConvertOptions, createDefaultV3Topology } from '../v3/config.js' + +export type ConvertOptions = V3ConvertOptions // uint160(keccak256("org.sequence.sdk.migration.v1v3.space.nonce")) export const MIGRATION_V1_V3_NONCE_SPACE = '0x9e4d5bdafd978baf1290aff23057245a2a62bef5' -export type ConvertOptions = { - loginSigner: { - address: Address.Address - imageHash?: Hex.Hex - } - extensions?: V3Extensions.Extensions -} - export type PrepareOptions = { space?: bigint } @@ -53,7 +47,7 @@ export class MigrationEncoder_v1v3 type: 'nested', weight: 1n, threshold: 2n, - tree: createDefaultV3Topology(options.loginSigner, options.extensions), + tree: createDefaultV3Topology(options), }, ], } diff --git a/packages/utils/migration/src/migrations/v3/config.ts b/packages/utils/migration/src/migrations/v3/config.ts index de9173841..f44c331e5 100644 --- a/packages/utils/migration/src/migrations/v3/config.ts +++ b/packages/utils/migration/src/migrations/v3/config.ts @@ -6,13 +6,18 @@ import { } from '@0xsequence/wallet-primitives' import { Address, Hex } from 'ox' -export const createDefaultV3Topology = ( +export type ConvertOptions = { loginSigner: { address: Address.Address imageHash?: Hex.Hex - }, - extensions?: V3Extensions.Extensions, -): V3Config.Topology => { + } + 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 ? { @@ -28,11 +33,13 @@ export const createDefaultV3Topology = ( } // Wallet guard topology - const walletGuardTopology: V3Config.SignerLeaf = { - type: 'signer', - address: '0xa2e70CeaB3Eb145F32d110383B75B330fA4e288a', // Guard wallet signer - weight: 1n, - } + const walletGuardTopology: V3Config.SignerLeaf | undefined = options.noWalletGuard + ? undefined + : { + type: 'signer', + address: '0xa2e70CeaB3Eb145F32d110383B75B330fA4e288a', // Guard wallet signer + weight: 1n, + } // Placeholder recovery topology const recoveryTopology: V3Config.SapientSignerLeaf = { @@ -43,36 +50,39 @@ export const createDefaultV3Topology = ( } // Session topology - 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, - } + 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, - } - const nestedSessionTopology: V3Config.NestedLeaf = { - type: 'nested', - weight: 255n, - threshold: 2n, - tree: [sessionTopology, sessionGuardTopology], + // 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 [ - [loginTopology, walletGuardTopology], - [recoveryTopology, nestedSessionTopology], + walletGuardTopology ? [loginTopology, walletGuardTopology] : loginTopology, + nestedSessionTopology ? [recoveryTopology, nestedSessionTopology] : recoveryTopology, ] } From e50559453590451810878a23d0cdfc4bcef45f4b Mon Sep 17 00:00:00 2001 From: Michael Standen Date: Thu, 23 Oct 2025 11:00:19 +1300 Subject: [PATCH 06/13] Error handling --- packages/wallet/core/src/wallet.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/wallet/core/src/wallet.ts b/packages/wallet/core/src/wallet.ts index 5e3b9b79e..ef0bd2ebf 100644 --- a/packages/wallet/core/src/wallet.ts +++ b/packages/wallet/core/src/wallet.ts @@ -229,6 +229,7 @@ export class Wallet { } return implementationAddress }) + .catch(() => undefined) }), ]) From e796abc5c9c8b6e4e24ae2c8d73d9275a97eb16f Mon Sep 17 00:00:00 2001 From: Michael Standen Date: Thu, 23 Oct 2025 11:01:49 +1300 Subject: [PATCH 07/13] Remove dud comment --- packages/wallet/core/src/wallet.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/wallet/core/src/wallet.ts b/packages/wallet/core/src/wallet.ts index ef0bd2ebf..38104fdb8 100644 --- a/packages/wallet/core/src/wallet.ts +++ b/packages/wallet/core/src/wallet.ts @@ -245,7 +245,6 @@ export class Wallet { : counterFactualContext if (!context) { - // Add to status throw new Error(`cannot find context for ${this.address}`) } From 5ebc1f2e5a47e3ee89c8f695c471247cab6f837b Mon Sep 17 00:00:00 2001 From: Michael Standen Date: Thu, 23 Oct 2025 11:55:37 +1300 Subject: [PATCH 08/13] Test stability --- .../src/migrations/v1/encoder_v1_v3.ts | 1 - .../utils/migration/test/encoder_v1_v3.test.ts | 17 +++++++++++------ .../utils/migration/test/migrator_v1_v3.test.ts | 9 +++++++-- 3 files changed, 18 insertions(+), 9 deletions(-) diff --git a/packages/utils/migration/src/migrations/v1/encoder_v1_v3.ts b/packages/utils/migration/src/migrations/v1/encoder_v1_v3.ts index e55495a01..56f19d190 100644 --- a/packages/utils/migration/src/migrations/v1/encoder_v1_v3.ts +++ b/packages/utils/migration/src/migrations/v1/encoder_v1_v3.ts @@ -66,7 +66,6 @@ export class MigrationEncoder_v1v3 const space = options?.space ?? BigInt(MIGRATION_V1_V3_NONCE_SPACE) const nonce = 0n // Nonce must be unused - // const v2Nonce = v2commons.transaction.encodeNonce(space, nonce) // Update implementation to v3 const updateImplementationAbi = AbiFunction.from('function updateImplementation(address implementation)') diff --git a/packages/utils/migration/test/encoder_v1_v3.test.ts b/packages/utils/migration/test/encoder_v1_v3.test.ts index 762803dc9..4a2ea51bb 100644 --- a/packages/utils/migration/test/encoder_v1_v3.test.ts +++ b/packages/utils/migration/test/encoder_v1_v3.test.ts @@ -28,7 +28,7 @@ const convertContextToV3Context = (context: v2commons.context.WalletContext): V3 } } -describe('MigrationEncoder_v1v3', () => { +describe('MigrationEncoder_v1v3', async () => { let anvilSigner: MultiSigner let testSigner: MultiSigner @@ -57,7 +57,7 @@ describe('MigrationEncoder_v1v3', () => { testSigner = createMultiSigner(testSignerPk, providers.v2) }) - describe('convertConfig', () => { + describe('convertConfig', async () => { it('should convert v1 config to v3 config with single signer', async () => { const v1Config: v1.config.WalletConfig = { version: 1, @@ -251,7 +251,7 @@ describe('MigrationEncoder_v1v3', () => { }) }) - describe('prepareMigration', () => { + describe('prepareMigration', async () => { it('should prepare migration transactions correctly', async () => { const walletAddress = testAddress const contexts: VersionedContext = { @@ -396,7 +396,7 @@ describe('MigrationEncoder_v1v3', () => { }) }) - describe('decodeTransactions', () => { + describe('decodeTransactions', async () => { it('should decode transactions correctly', async () => { const walletAddress = testAddress const imageHash = '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef' @@ -524,7 +524,7 @@ describe('MigrationEncoder_v1v3', () => { }) }) - describe('constants', () => { + describe('constants', async () => { it('should have correct nonce space', () => { expect(MIGRATION_V1_V3_NONCE_SPACE).toBe('0x9e4d5bdafd978baf1290aff23057245a2a62bef5') }) @@ -535,7 +535,7 @@ describe('MigrationEncoder_v1v3', () => { }) }) - describe('integration test', () => { + describe('integration test', async () => { it('should use migration ', async () => { // Create v1 config const v1Config: v1.config.WalletConfig = { @@ -620,6 +620,9 @@ describe('MigrationEncoder_v1v3', () => { expect(receipt?.status).toBe(1) // This should now be a v3 wallet on chain + // Wait for any pending transactions to fully settle + await new Promise((resolve) => setTimeout(resolve, 100)) + // Save the wallet information to the state provider const stateProvider = new State.Local.Provider() await stateProvider.saveDeploy(v1ImageHash, convertContextToV3Context(v1.DeployedWalletContext)) @@ -653,6 +656,8 @@ describe('MigrationEncoder_v1v3', () => { method: 'eth_sendTransaction', params: [signedTx], }) + // Wait a bit for the transaction to be mined + await new Promise((resolve) => setTimeout(resolve, 3000)) const testReceipt = await providers.v3.request({ method: 'eth_getTransactionReceipt', params: [testTx], diff --git a/packages/utils/migration/test/migrator_v1_v3.test.ts b/packages/utils/migration/test/migrator_v1_v3.test.ts index c60a88479..7c5cc0571 100644 --- a/packages/utils/migration/test/migrator_v1_v3.test.ts +++ b/packages/utils/migration/test/migrator_v1_v3.test.ts @@ -11,7 +11,7 @@ import { assert, beforeEach, describe, expect, it } from 'vitest' import { Migrator_v1v3, MigratorV1V3Options } from '../src/migrations/v1/migrator_v1_v3.js' import { createMultiSigner, type MultiSigner, type V1WalletType } from './testUtils.js' -describe('Migrator_v1v3', () => { +describe('Migrator_v1v3', async () => { let anvilSigner: MultiSigner let testSigner: MultiSigner @@ -42,7 +42,7 @@ describe('Migrator_v1v3', () => { testSigner = createMultiSigner(Secp256k1.randomPrivateKey(), providers.v2) }) - describe('convertWallet', () => { + describe('convertWallet', async () => { it('should convert a v1 wallet to a v3 wallet', async () => { const v1Config = { version: 1, @@ -84,6 +84,9 @@ describe('Migrator_v1v3', () => { } const v3Wallet = await migrator.convertWallet(v1Wallet, options) + // Wait for any pending transactions from v1 wallet operations to settle + await new Promise((resolve) => setTimeout(resolve, 100)) + // Test the wallet works as a v3 wallet now with a test transaction const call: Payload.Call = { to: Address.from('0x0000000000000000000000000000000000000000'), @@ -111,6 +114,8 @@ describe('Migrator_v1v3', () => { method: 'eth_sendTransaction', params: [signedTx], }) + // Wait a bit for the transaction to be mined + await new Promise((resolve) => setTimeout(resolve, 3000)) const receipt = await providers.v3.request({ method: 'eth_getTransactionReceipt', params: [testTx], From c5fa0a9b0f62e19ec8cc68c52bf306672f163751 Mon Sep 17 00:00:00 2001 From: Michael Standen Date: Thu, 23 Oct 2025 12:04:55 +1300 Subject: [PATCH 09/13] Fix tests and mocks --- .../utils/migration/test/encoder_v1_v3.test.ts | 2 +- .../utils/migration/test/migrator_v1_v3.test.ts | 2 +- packages/wallet/wdk/test/sessions.test.ts | 16 ++++++++++++++++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/packages/utils/migration/test/encoder_v1_v3.test.ts b/packages/utils/migration/test/encoder_v1_v3.test.ts index 4a2ea51bb..fce430f0b 100644 --- a/packages/utils/migration/test/encoder_v1_v3.test.ts +++ b/packages/utils/migration/test/encoder_v1_v3.test.ts @@ -621,7 +621,7 @@ describe('MigrationEncoder_v1v3', async () => { // This should now be a v3 wallet on chain // Wait for any pending transactions to fully settle - await new Promise((resolve) => setTimeout(resolve, 100)) + await new Promise((resolve) => setTimeout(resolve, 1000)) // Save the wallet information to the state provider const stateProvider = new State.Local.Provider() diff --git a/packages/utils/migration/test/migrator_v1_v3.test.ts b/packages/utils/migration/test/migrator_v1_v3.test.ts index 7c5cc0571..d12bda228 100644 --- a/packages/utils/migration/test/migrator_v1_v3.test.ts +++ b/packages/utils/migration/test/migrator_v1_v3.test.ts @@ -85,7 +85,7 @@ describe('Migrator_v1v3', async () => { const v3Wallet = await migrator.convertWallet(v1Wallet, options) // Wait for any pending transactions from v1 wallet operations to settle - await new Promise((resolve) => setTimeout(resolve, 100)) + await new Promise((resolve) => setTimeout(resolve, 1000)) // Test the wallet works as a v3 wallet now with a test transaction const call: Payload.Call = { 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') From e5e6a42b54d184899452e91f09b34cc50665c7a8 Mon Sep 17 00:00:00 2001 From: Michael Standen Date: Fri, 24 Oct 2025 10:38:20 +1300 Subject: [PATCH 10/13] Add v2->v3 migration --- .../src/migrations/v1/encoder_v1_v3.ts | 115 +--- .../src/migrations/v1/migrator_v1_v3.ts | 9 +- .../utils/migration/src/migrations/v2/base.ts | 115 ++++ .../migration/src/migrations/v2/config.ts | 32 + .../src/migrations/v2/encoder_v2_v3.ts | 63 ++ .../src/migrations/v2/migrator_v2_v3.ts | 115 ++++ packages/utils/migration/test/testUtils.ts | 38 +- .../test/{ => v1}/encoder_v1_v3.test.ts | 28 +- .../test/{ => v1}/migrator_v1_v3.test.ts | 14 +- .../migration/test/v2/encoder_v2_v3.test.ts | 651 ++++++++++++++++++ .../migration/test/v2/migrator_v2_v3.test.ts | 121 ++++ packages/utils/migration/vitest.config.ts | 5 +- .../wallet/core/src/state/sequence/index.ts | 8 +- 13 files changed, 1161 insertions(+), 153 deletions(-) create mode 100644 packages/utils/migration/src/migrations/v2/base.ts create mode 100644 packages/utils/migration/src/migrations/v2/config.ts create mode 100644 packages/utils/migration/src/migrations/v2/encoder_v2_v3.ts create mode 100644 packages/utils/migration/src/migrations/v2/migrator_v2_v3.ts rename packages/utils/migration/test/{ => v1}/encoder_v1_v3.test.ts (95%) rename packages/utils/migration/test/{ => v1}/migrator_v1_v3.test.ts (87%) create mode 100644 packages/utils/migration/test/v2/encoder_v2_v3.test.ts create mode 100644 packages/utils/migration/test/v2/migrator_v2_v3.test.ts diff --git a/packages/utils/migration/src/migrations/v1/encoder_v1_v3.ts b/packages/utils/migration/src/migrations/v1/encoder_v1_v3.ts index 56f19d190..46aa18883 100644 --- a/packages/utils/migration/src/migrations/v1/encoder_v1_v3.ts +++ b/packages/utils/migration/src/migrations/v1/encoder_v1_v3.ts @@ -1,32 +1,28 @@ -import { v1, commons as v2commons } from '@0xsequence/v2core' -import { State } from '@0xsequence/wallet-core' -import { - Payload, - Config as V3Config, - Context as V3Context, - Extensions as V3Extensions, -} from '@0xsequence/wallet-primitives' -import { AbiFunction, Address, Hex } from 'ox' +import { v1 } from '@0xsequence/v2core' +import { Config as V3Config, Context as V3Context } from '@0xsequence/wallet-primitives' +import { Address } from 'ox' import { UnsignedMigration, VersionedContext } 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 type PrepareOptions = { - space?: bigint -} - 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), @@ -64,97 +60,8 @@ export class MigrationEncoder_v1v3 throw new Error('Invalid context') } - const space = options?.space ?? BigInt(MIGRATION_V1_V3_NONCE_SPACE) - 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, [v3Context.stage2]), - 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, - } - } + options.space = options.space ?? BigInt(MIGRATION_V1_V3_NONCE_SPACE) - 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], - } + return super.prepareMigrationToImplementation(walletAddress, v3Context.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 index 0efceba86..9fb548d90 100644 --- a/packages/utils/migration/src/migrations/v1/migrator_v1_v3.ts +++ b/packages/utils/migration/src/migrations/v1/migrator_v1_v3.ts @@ -47,13 +47,18 @@ export class Migrator_v1v3 implements Migrator ({ weight: Number(weight), address })), } - await this.v3StateProvider.forceSaveConfiguration(v1ServiceConfig, 1) + 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, { [3]: v3Context }, v3Config, options) + const unsignedMigration = await this.encoder.prepareMigration( + walletAddress, + { [this.toVersion]: v3Context }, + v3Config, + options, + ) // Sign migration const chainId = v1Wallet.chainId 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..468064f03 --- /dev/null +++ b/packages/utils/migration/src/migrations/v2/encoder_v2_v3.ts @@ -0,0 +1,63 @@ +import { v2 } from '@0xsequence/v2core' +import { Config as V3Config, Context as V3Context } from '@0xsequence/wallet-primitives' +import { Address } from 'ox' +import { UnsignedMigration, VersionedContext } 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, + contexts: VersionedContext, + toConfig: V3Config.Config, + options: PrepareOptions, + ): Promise { + const v3Context = contexts[3] || V3Context.Rc3 + if (!V3Context.isContext(v3Context)) { + throw new Error('Invalid context') + } + + options.space = options.space ?? BigInt(MIGRATION_V2_V3_NONCE_SPACE) + + return super.prepareMigrationToImplementation(walletAddress, v3Context.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..554ed4e8a --- /dev/null +++ b/packages/utils/migration/src/migrations/v2/migrator_v2_v3.ts @@ -0,0 +1,115 @@ +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, + { [this.toVersion]: 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/test/testUtils.ts b/packages/utils/migration/test/testUtils.ts index 37ed3b4c3..4c9ff5a9a 100644 --- a/packages/utils/migration/test/testUtils.ts +++ b/packages/utils/migration/test/testUtils.ts @@ -1,9 +1,8 @@ -import { Wallet as V1Wallet } from '@0xsequence/v2wallet' -import { v1 } from '@0xsequence/v2core' -import { Hex, Address } from 'ox' -import { ethers } from 'ethers' +import { commons as v2commons } from '@0xsequence/v2core' import { Signers as V3Signers } from '@0xsequence/wallet-core' -import { Secp256k1 } from 'ox' +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 @@ -12,8 +11,6 @@ export type MultiSigner = { v3: V3Signers.Pk.Pk } -export type V1WalletType = V1Wallet - 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 @@ -28,3 +25,30 @@ export const createMultiSigner = (pk: Hex.Hex, provider: ethers.Provider): Multi 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/encoder_v1_v3.test.ts b/packages/utils/migration/test/v1/encoder_v1_v3.test.ts similarity index 95% rename from packages/utils/migration/test/encoder_v1_v3.test.ts rename to packages/utils/migration/test/v1/encoder_v1_v3.test.ts index fce430f0b..561c1dfab 100644 --- a/packages/utils/migration/test/encoder_v1_v3.test.ts +++ b/packages/utils/migration/test/v1/encoder_v1_v3.test.ts @@ -13,20 +13,9 @@ 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 { VersionedContext } from '../src/types.js' -import { createMultiSigner, MultiSigner } from './testUtils.js' - -const convertContextToV3Context = (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, - } -} +import { MIGRATION_V1_V3_NONCE_SPACE, MigrationEncoder_v1v3 } from '../../src/migrations/v1/encoder_v1_v3.js' +import { VersionedContext } from '../../src/types.js' +import { convertV2ContextToV3Context, createAnvilSigner, createMultiSigner, MultiSigner } from '../testUtils.js' describe('MigrationEncoder_v1v3', async () => { let anvilSigner: MultiSigner @@ -50,8 +39,6 @@ describe('MigrationEncoder_v1v3', async () => { v3: Provider.from(RpcTransport.fromHttp(url)), } chainId = Number(await providers.v3.request({ method: 'eth_chainId' })) - const anvilPk = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80' - anvilSigner = createMultiSigner(anvilPk, providers.v2) testAddress = '0x742d35cc6635c0532925a3b8d563a6b35b7f05f1' const testSignerPk = Secp256k1.randomPrivateKey() testSigner = createMultiSigner(testSignerPk, providers.v2) @@ -556,6 +543,7 @@ describe('MigrationEncoder_v1v3', async () => { 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, @@ -620,12 +608,9 @@ describe('MigrationEncoder_v1v3', async () => { expect(receipt?.status).toBe(1) // This should now be a v3 wallet on chain - // Wait for any pending transactions to fully settle - await new Promise((resolve) => setTimeout(resolve, 1000)) - // Save the wallet information to the state provider const stateProvider = new State.Local.Provider() - await stateProvider.saveDeploy(v1ImageHash, convertContextToV3Context(v1.DeployedWalletContext)) + await stateProvider.saveDeploy(v1ImageHash, convertV2ContextToV3Context(v1.DeployedWalletContext)) await stateProvider.saveConfiguration(v3Config) // Test the wallet works as a v3 wallet now with a test transaction @@ -656,8 +641,7 @@ describe('MigrationEncoder_v1v3', async () => { method: 'eth_sendTransaction', params: [signedTx], }) - // Wait a bit for the transaction to be mined - await new Promise((resolve) => setTimeout(resolve, 3000)) + await new Promise((resolve) => setTimeout(resolve, 1000)) const testReceipt = await providers.v3.request({ method: 'eth_getTransactionReceipt', params: [testTx], diff --git a/packages/utils/migration/test/migrator_v1_v3.test.ts b/packages/utils/migration/test/v1/migrator_v1_v3.test.ts similarity index 87% rename from packages/utils/migration/test/migrator_v1_v3.test.ts rename to packages/utils/migration/test/v1/migrator_v1_v3.test.ts index d12bda228..a1ceda1da 100644 --- a/packages/utils/migration/test/migrator_v1_v3.test.ts +++ b/packages/utils/migration/test/v1/migrator_v1_v3.test.ts @@ -8,11 +8,10 @@ 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 { createMultiSigner, type MultiSigner, type V1WalletType } from './testUtils.js' +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 anvilSigner: MultiSigner let testSigner: MultiSigner let providers: { @@ -37,8 +36,6 @@ describe('Migrator_v1v3', async () => { // stateProvider = new State.Sequence.Provider('http://127.0.0.1:36261') migrator = new Migrator_v1v3(stateProvider) - const anvilPk = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80' - anvilSigner = createMultiSigner(anvilPk, providers.v2) testSigner = createMultiSigner(Secp256k1.randomPrivateKey(), providers.v2) }) @@ -60,6 +57,7 @@ describe('Migrator_v1v3', async () => { ], } 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, @@ -84,9 +82,6 @@ describe('Migrator_v1v3', async () => { } const v3Wallet = await migrator.convertWallet(v1Wallet, options) - // Wait for any pending transactions from v1 wallet operations to settle - await new Promise((resolve) => setTimeout(resolve, 1000)) - // Test the wallet works as a v3 wallet now with a test transaction const call: Payload.Call = { to: Address.from('0x0000000000000000000000000000000000000000'), @@ -114,8 +109,7 @@ describe('Migrator_v1v3', async () => { method: 'eth_sendTransaction', params: [signedTx], }) - // Wait a bit for the transaction to be mined - await new Promise((resolve) => setTimeout(resolve, 3000)) + await new Promise((resolve) => setTimeout(resolve, 1000)) const receipt = await providers.v3.request({ method: 'eth_getTransactionReceipt', params: [testTx], 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..0bef7ccb4 --- /dev/null +++ b/packages/utils/migration/test/v2/encoder_v2_v3.test.ts @@ -0,0 +1,651 @@ +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 { VersionedContext } from '../../src/types.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 contexts: VersionedContext = { + 3: V3Context.Rc3, + } + + 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, contexts, 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 contexts: VersionedContext = { + 3: customContext, + } + + 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, contexts, 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) + }) + + it('should throw error for invalid context', async () => { + const walletAddress = testAddress + const contexts: VersionedContext = { + 3: 'invalid-context' as any, + } + + const v3Config: V3Config.Config = { + threshold: 1n, + checkpoint: 0n, + topology: { + type: 'signer', + address: testSigner.address, + weight: 1n, + }, + } + + await expect(migration.prepareMigration(walletAddress, contexts, v3Config, {})).rejects.toThrow('Invalid context') + }) + }) + + 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 contexts: VersionedContext = { + 3: V3Context.Rc3, + } + const unsignedMigration = await migration.prepareMigration(walletAddress, contexts, 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/vitest.config.ts b/packages/utils/migration/vitest.config.ts index 0b2f7c6c7..a9025a388 100644 --- a/packages/utils/migration/vitest.config.ts +++ b/packages/utils/migration/vitest.config.ts @@ -2,8 +2,11 @@ import { defineConfig } from 'vitest/config' export default defineConfig({ test: { + fileParallelism: false, poolOptions: { - singleThread: true, + threads: { + singleThread: true, + }, }, }, }) diff --git a/packages/wallet/core/src/state/sequence/index.ts b/packages/wallet/core/src/state/sequence/index.ts index 49c2bcb93..1dd67428a 100644 --- a/packages/wallet/core/src/state/sequence/index.ts +++ b/packages/wallet/core/src/state/sequence/index.ts @@ -9,13 +9,7 @@ import { TransactionRequest, } from 'ox' import { Migration, normalizeAddressKeys, Provider as ProviderInterface } from '../index.js' -import { - SaveConfigArgs, - Context as ServiceContext, - Sessions, - SignatureType, - TransactionBundle, -} from './sessions.gen.js' +import { Context as ServiceContext, Sessions, SignatureType, TransactionBundle } from './sessions.gen.js' type ContextWithGuest = Context.Context & { guest?: Address.Address } From 4bbc460610dcb7cb11f0e6c5c930284db81ead7f Mon Sep 17 00:00:00 2001 From: Michael Standen Date: Fri, 24 Oct 2025 13:59:46 +1300 Subject: [PATCH 11/13] Remove versionedcontext --- .../utils/migration/src/migrations/index.ts | 18 ++++++---- .../src/migrations/v1/encoder_v1_v3.ts | 14 +++----- .../src/migrations/v1/migrator_v1_v3.ts | 7 +--- .../src/migrations/v2/encoder_v2_v3.ts | 14 +++----- .../src/migrations/v2/migrator_v2_v3.ts | 7 +--- packages/utils/migration/src/types.ts | 4 --- .../migration/test/v1/encoder_v1_v3.test.ts | 36 ++----------------- .../migration/test/v2/encoder_v2_v3.test.ts | 36 ++----------------- 8 files changed, 30 insertions(+), 106 deletions(-) diff --git a/packages/utils/migration/src/migrations/index.ts b/packages/utils/migration/src/migrations/index.ts index 86944004d..f66d2753c 100644 --- a/packages/utils/migration/src/migrations/index.ts +++ b/packages/utils/migration/src/migrations/index.ts @@ -1,10 +1,10 @@ import { State } from '@0xsequence/wallet-core' import { Payload } from '@0xsequence/wallet-primitives' import { Address, Hex } from 'ox' -import { UnsignedMigration, VersionedContext } from '../types.js' +import { UnsignedMigration } from '../types.js' import { MigrationEncoder_v1v3 } from './v1/encoder_v1_v3.js' -export interface MigrationEncoder { +export interface MigrationEncoder { fromVersion: number toVersion: number @@ -26,7 +26,7 @@ export interface MigrationEncoder Promise @@ -59,12 +59,18 @@ export interface Migrator { convertWallet: (fromWallet: FromWallet, options: ConvertOptionsType) => Promise } -export const encoders: MigrationEncoder[] = [new MigrationEncoder_v1v3()] +export const encoders: MigrationEncoder[] = [new MigrationEncoder_v1v3()] -export function getMigrationEncoder( +export function getMigrationEncoder< + FromConfigType, + ToConfigType, + ToContextType, + ConvertOptionsType, + PrepareOptionsType, +>( fromVersion: number, toVersion: number, -): MigrationEncoder { +): MigrationEncoder { const encoder = encoders.find((encoder) => encoder.fromVersion === fromVersion && encoder.toVersion === toVersion) if (!encoder) { throw new Error(`Unsupported from version: ${fromVersion} to version: ${toVersion}`) diff --git a/packages/utils/migration/src/migrations/v1/encoder_v1_v3.ts b/packages/utils/migration/src/migrations/v1/encoder_v1_v3.ts index 46aa18883..a7e13347f 100644 --- a/packages/utils/migration/src/migrations/v1/encoder_v1_v3.ts +++ b/packages/utils/migration/src/migrations/v1/encoder_v1_v3.ts @@ -1,7 +1,7 @@ import { v1 } from '@0xsequence/v2core' import { Config as V3Config, Context as V3Context } from '@0xsequence/wallet-primitives' import { Address } from 'ox' -import { UnsignedMigration, VersionedContext } from '../../types.js' +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' @@ -14,7 +14,8 @@ export const MIGRATION_V1_V3_NONCE_SPACE = '0x9e4d5bdafd978baf1290aff23057245a2a export class MigrationEncoder_v1v3 extends BaseMigrationEncoder_v1v2 - implements MigrationEncoder + implements + MigrationEncoder { fromVersion = 1 toVersion = 3 @@ -51,17 +52,12 @@ export class MigrationEncoder_v1v3 async prepareMigration( walletAddress: Address.Address, - contexts: VersionedContext, + toContext: V3Context.Context, toConfig: V3Config.Config, options: PrepareOptions, ): Promise { - const v3Context = contexts[3] || V3Context.Rc3 - if (!V3Context.isContext(v3Context)) { - throw new Error('Invalid context') - } - options.space = options.space ?? BigInt(MIGRATION_V1_V3_NONCE_SPACE) - return super.prepareMigrationToImplementation(walletAddress, v3Context.stage2, toConfig, options) + 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 index 9fb548d90..c27a65d18 100644 --- a/packages/utils/migration/src/migrations/v1/migrator_v1_v3.ts +++ b/packages/utils/migration/src/migrations/v1/migrator_v1_v3.ts @@ -53,12 +53,7 @@ export class Migrator_v1v3 implements Migrator { diff --git a/packages/utils/migration/src/migrations/v2/migrator_v2_v3.ts b/packages/utils/migration/src/migrations/v2/migrator_v2_v3.ts index 554ed4e8a..0f5613b92 100644 --- a/packages/utils/migration/src/migrations/v2/migrator_v2_v3.ts +++ b/packages/utils/migration/src/migrations/v2/migrator_v2_v3.ts @@ -74,12 +74,7 @@ export class Migrator_v2v3 implements Migrator & { chainId?: number diff --git a/packages/utils/migration/test/v1/encoder_v1_v3.test.ts b/packages/utils/migration/test/v1/encoder_v1_v3.test.ts index 561c1dfab..be7f31a9c 100644 --- a/packages/utils/migration/test/v1/encoder_v1_v3.test.ts +++ b/packages/utils/migration/test/v1/encoder_v1_v3.test.ts @@ -14,7 +14,6 @@ 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 { VersionedContext } from '../../src/types.js' import { convertV2ContextToV3Context, createAnvilSigner, createMultiSigner, MultiSigner } from '../testUtils.js' describe('MigrationEncoder_v1v3', async () => { @@ -241,9 +240,6 @@ describe('MigrationEncoder_v1v3', async () => { describe('prepareMigration', async () => { it('should prepare migration transactions correctly', async () => { const walletAddress = testAddress - const contexts: VersionedContext = { - 3: V3Context.Rc3, - } const v3Config: V3Config.Config = { threshold: 1n, @@ -280,7 +276,7 @@ describe('MigrationEncoder_v1v3', async () => { } const randomSpace = BigInt(Math.floor(Math.random() * 10000000000)) - const migrationResult = await migration.prepareMigration(walletAddress, contexts, v3Config, { + const migrationResult = await migration.prepareMigration(walletAddress, V3Context.Rc3, v3Config, { space: BigInt(randomSpace), }) @@ -317,10 +313,6 @@ describe('MigrationEncoder_v1v3', async () => { factory: '0x4444444444444444444444444444444444444444', } - const contexts: VersionedContext = { - 3: customContext, - } - const v3Config: V3Config.Config = { threshold: 1n, checkpoint: 0n, @@ -355,32 +347,13 @@ describe('MigrationEncoder_v1v3', async () => { ], } - const migrationResult = await migration.prepareMigration(walletAddress, contexts, v3Config, {}) + 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) }) - - it('should throw error for invalid context', async () => { - const walletAddress = testAddress - const contexts: VersionedContext = { - 3: 'invalid-context' as any, - } - - const v3Config: V3Config.Config = { - threshold: 1n, - checkpoint: 0n, - topology: { - type: 'signer', - address: testSigner.address, - weight: 1n, - }, - } - - await expect(migration.prepareMigration(walletAddress, contexts, v3Config, {})).rejects.toThrow('Invalid context') - }) }) describe('decodeTransactions', async () => { @@ -571,10 +544,7 @@ describe('MigrationEncoder_v1v3', async () => { const v3Config = await migration.convertConfig(v1Config, options) // Prepare migration - const contexts: VersionedContext = { - 3: V3Context.Rc3, - } - const unsignedMigration = await migration.prepareMigration(walletAddress, contexts, v3Config, {}) + const unsignedMigration = await migration.prepareMigration(walletAddress, V3Context.Rc3, v3Config, {}) // Decode transactions const decoded = await migration.decodePayload(unsignedMigration.payload) diff --git a/packages/utils/migration/test/v2/encoder_v2_v3.test.ts b/packages/utils/migration/test/v2/encoder_v2_v3.test.ts index 0bef7ccb4..a8513962e 100644 --- a/packages/utils/migration/test/v2/encoder_v2_v3.test.ts +++ b/packages/utils/migration/test/v2/encoder_v2_v3.test.ts @@ -14,7 +14,6 @@ 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 { VersionedContext } from '../../src/types.js' import { convertV2ContextToV3Context, createAnvilSigner, createMultiSigner, MultiSigner } from '../testUtils.js' describe('MigrationEncoder_v2v3', async () => { @@ -238,9 +237,6 @@ describe('MigrationEncoder_v2v3', async () => { describe('prepareMigration', async () => { it('should prepare migration transactions correctly', async () => { const walletAddress = testAddress - const contexts: VersionedContext = { - 3: V3Context.Rc3, - } const v3Config: V3Config.Config = { threshold: 1n, @@ -277,7 +273,7 @@ describe('MigrationEncoder_v2v3', async () => { } const randomSpace = BigInt(Math.floor(Math.random() * 10000000000)) - const migrationResult = await migration.prepareMigration(walletAddress, contexts, v3Config, { + const migrationResult = await migration.prepareMigration(walletAddress, V3Context.Rc3, v3Config, { space: BigInt(randomSpace), }) @@ -314,10 +310,6 @@ describe('MigrationEncoder_v2v3', async () => { factory: '0x4444444444444444444444444444444444444444', } - const contexts: VersionedContext = { - 3: customContext, - } - const v3Config: V3Config.Config = { threshold: 1n, checkpoint: 0n, @@ -352,32 +344,13 @@ describe('MigrationEncoder_v2v3', async () => { ], } - const migrationResult = await migration.prepareMigration(walletAddress, contexts, v3Config, {}) + 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) }) - - it('should throw error for invalid context', async () => { - const walletAddress = testAddress - const contexts: VersionedContext = { - 3: 'invalid-context' as any, - } - - const v3Config: V3Config.Config = { - threshold: 1n, - checkpoint: 0n, - topology: { - type: 'signer', - address: testSigner.address, - weight: 1n, - }, - } - - await expect(migration.prepareMigration(walletAddress, contexts, v3Config, {})).rejects.toThrow('Invalid context') - }) }) describe('decodeTransactions', async () => { @@ -569,10 +542,7 @@ describe('MigrationEncoder_v2v3', async () => { const v3Config = await migration.convertConfig(v2Config, options) // Prepare migration - const contexts: VersionedContext = { - 3: V3Context.Rc3, - } - const unsignedMigration = await migration.prepareMigration(walletAddress, contexts, v3Config, {}) + const unsignedMigration = await migration.prepareMigration(walletAddress, V3Context.Rc3, v3Config, {}) // Decode transactions const decoded = await migration.decodePayload(unsignedMigration.payload) From 2455b4e285ac5a816979a2fb9dd477b94daabd5b Mon Sep 17 00:00:00 2001 From: Michael Standen Date: Fri, 24 Oct 2025 14:06:32 +1300 Subject: [PATCH 12/13] Use noPendingMigrations flag --- packages/wallet/core/src/wallet.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/wallet/core/src/wallet.ts b/packages/wallet/core/src/wallet.ts index 38104fdb8..b6edc9a34 100644 --- a/packages/wallet/core/src/wallet.ts +++ b/packages/wallet/core/src/wallet.ts @@ -498,7 +498,7 @@ export class Wallet { options?: { space?: bigint noConfigUpdate?: boolean - noMigration?: boolean + noPendingMigrations?: boolean unsafe?: boolean }, ): Promise> { @@ -526,7 +526,7 @@ export class Wallet { // 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) + this.requireV3Wallet(status, options?.noPendingMigrations) if (!options?.noConfigUpdate) { if (status.imageHash !== status.onChainImageHash) { From d9a3efce19086b8d69fa389f46af6cc2d1de2b94 Mon Sep 17 00:00:00 2001 From: Michael Standen Date: Thu, 30 Oct 2025 12:23:34 +1300 Subject: [PATCH 13/13] Add README --- packages/utils/migration/README.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 packages/utils/migration/README.md 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).