Skip to content
Draft
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
48 changes: 48 additions & 0 deletions packages/utils/migration/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
2 changes: 2 additions & 0 deletions packages/utils/migration/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * as migration from './migrations/index.js'
export * from './types.js'
73 changes: 73 additions & 0 deletions packages/utils/migration/src/migrations/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
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/encoder_v1_v3.js'

export interface MigrationEncoder<FromConfigType, ToConfigType, ConvertOptionsType, PrepareOptionsType> {
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<ToConfigType>

/**
* Prepares a migration for a given wallet address and context
* @param walletAddress The wallet address to prepare the migration for
* @param contexts The contexts to prepare the migration for
* @param toConfig The configuration to prepare the migration for
* @param options The prepare options
* @returns The migration payload to be signed
*/
prepareMigration: (
walletAddress: Address.Address,
contexts: VersionedContext,
toConfig: ToConfigType,
options: PrepareOptionsType,
) => Promise<UnsignedMigration>

/**
* Encodes the a transaction for a given migration
* @param migration The migration to encode the transaction for
* @returns The encoded transaction
*/
toTransactionData: (migration: State.Migration) => Promise<{
to: Address.Address
data: Hex.Hex
}>

/**
* Decodes the payload from a migration
* @param payload The payload to decode
* @returns The decoded address and resulting image hash for the migration payload
*/
decodePayload: (payload: Payload.Calls) => Promise<{
address: Address.Address
toImageHash: Hex.Hex
}>
}

export interface Migrator<FromWallet, ToWallet, ConvertOptionsType> {
fromVersion: number
toVersion: number

convertWallet: (fromWallet: FromWallet, options: ConvertOptionsType) => Promise<ToWallet>
}

export const encoders: MigrationEncoder<any, any, any, any>[] = [new MigrationEncoder_v1v3()]

export function getMigrationEncoder<FromConfigType, ToConfigType, ConvertOptionsType, PrepareOptionsType>(
fromVersion: number,
toVersion: number,
): MigrationEncoder<FromConfigType, ToConfigType, ConvertOptionsType, PrepareOptionsType> {
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
}
160 changes: 160 additions & 0 deletions packages/utils/migration/src/migrations/v1/encoder_v1_v3.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
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 { UnsignedMigration, VersionedContext } from '../../types.js'
import { MigrationEncoder } from '../index.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 PrepareOptions = {
space?: bigint
}

export class MigrationEncoder_v1v3
implements MigrationEncoder<v1.config.WalletConfig, V3Config.Config, ConvertOptions, PrepareOptions>
{
fromVersion = 1
toVersion = 3

async convertConfig(fromConfig: v1.config.WalletConfig, options: ConvertOptions): Promise<V3Config.Config> {
const signerLeaves: V3Config.SignerLeaf[] = fromConfig.signers.map((signer) => ({
type: 'signer',
address: Address.from(signer.address),
weight: BigInt(signer.weight),
}))
const v1NestedTopology = V3Config.flatLeavesToTopology(signerLeaves)
return {
threshold: 1n,
checkpoint: 0n,
topology: [
{
type: 'nested',
weight: 1n,
threshold: BigInt(fromConfig.threshold),
tree: v1NestedTopology,
},
{
type: 'nested',
weight: 1n,
threshold: 2n,
tree: createDefaultV3Topology(options),
},
],
}
}

async prepareMigration(
walletAddress: Address.Address,
contexts: VersionedContext,
toConfig: V3Config.Config,
options: PrepareOptions,
): Promise<UnsignedMigration> {
const v3Context = contexts[3] || V3Context.Rc3
if (!V3Context.isContext(v3Context)) {
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,
}
}

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],
}
}
}
89 changes: 89 additions & 0 deletions packages/utils/migration/src/migrations/v1/migrator_v1_v3.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { v1, commons as v2commons } from '@0xsequence/v2core'
import { WalletV1 } from '@0xsequence/v2wallet'
import { State, Wallet as WalletV3 } from '@0xsequence/wallet-core'
import { Payload, Context as V3Context } from '@0xsequence/wallet-primitives'
import { Address, Hex } from 'ox'
import { Migrator } from '../index.js'
import { ConvertOptions, MigrationEncoder_v1v3, PrepareOptions } from './encoder_v1_v3.js'

export type MigratorV1V3Options = ConvertOptions &
PrepareOptions & {
v3Context?: V3Context.Context
}

export class Migrator_v1v3 implements Migrator<WalletV1, WalletV3, MigratorV1V3Options> {
fromVersion = 1
toVersion = 3

constructor(
private readonly v3StateProvider: State.Provider,
private readonly encoder: MigrationEncoder_v1v3 = new MigrationEncoder_v1v3(),
) {}

private convertV1Context(v1Wallet: v2commons.context.WalletContext): V3Context.Context & { guest?: Address.Address } {
Hex.assert(v1Wallet.walletCreationCode)
return {
factory: Address.from(v1Wallet.factory),
stage1: Address.from(v1Wallet.mainModule),
stage2: Address.from(v1Wallet.mainModuleUpgradable),
creationCode: v1Wallet.walletCreationCode,
guest: Address.from(v1Wallet.guestModule),
}
}

async convertWallet(v1Wallet: WalletV1, options: MigratorV1V3Options): Promise<WalletV3> {
// Prepare configuration
const walletAddress = Address.from(v1Wallet.address)
const v3Context = options.v3Context || V3Context.Rc3
const v1Config = v1Wallet.config
const v3Config = await this.encoder.convertConfig(v1Config, options)

// Save v1 wallet information to v3 state provider
const v1ImageHash = v1.config.ConfigCoder.imageHashOf(v1Config)
Hex.assert(v1ImageHash)
if (this.v3StateProvider instanceof State.Sequence.Provider) {
// Force save the v1 configuration to key machine
const v1ServiceConfig = {
threshold: Number(v1Config.threshold),
signers: v1Config.signers.map(({ weight, address }) => ({ weight: Number(weight), address })),
}
await this.v3StateProvider.forceSaveConfiguration(v1ServiceConfig, 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: walletAddress,
transactions: unsignedMigration.payload.calls.map((tx: Payload.Call) => ({
to: tx.to,
data: tx.data,
gasLimit: 0n,
revertOnError: true,
})),
nonce: v2Nonce,
}
const { signature } = await v1Wallet.signTransactionBundle(txBundle)
Hex.assert(signature)

// Save to tracker
const signedMigration: State.Migration = {
...unsignedMigration,
fromImageHash: v1ImageHash,
chainId: Number(chainId),
signature,
}
await this.v3StateProvider.saveMigration(walletAddress, signedMigration)

// Return v3 wallet
return new WalletV3(walletAddress, {
knownContexts: [{ name: 'v3', development: false, ...v3Context }],
stateProvider: this.v3StateProvider,
})
}
}
Loading