Skip to content

Commit 47c5fd4

Browse files
committed
Base v1->v3 wallet migration
1 parent f59aeb9 commit 47c5fd4

16 files changed

Lines changed: 1674 additions & 4 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
"turbo": "^2.5.4",
2626
"typescript": "5.8.3"
2727
},
28-
"packageManager": "pnpm@10.14.0",
28+
"packageManager": "pnpm@10.18.3",
2929
"engines": {
3030
"node": ">=18"
3131
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
{
2+
"name": "@0xsequence/wallet-migration",
3+
"version": "0.0.0",
4+
"license": "Apache-2.0",
5+
"type": "module",
6+
"publishConfig": {
7+
"access": "public"
8+
},
9+
"private": false,
10+
"scripts": {
11+
"build": "tsc",
12+
"dev": "tsc --watch",
13+
"test": "vitest run",
14+
"test:coverage": "vitest run --coverage",
15+
"typecheck": "tsc --noEmit",
16+
"clean": "rimraf dist"
17+
},
18+
"exports": {
19+
".": {
20+
"types": "./dist/index.d.ts",
21+
"default": "./dist/index.js"
22+
}
23+
},
24+
"devDependencies": {
25+
"@0xsequence/relayerv2": "npm:@0xsequence/relayer@^2.3.29",
26+
"@0xsequence/signhubv2": "npm:@0xsequence/signhub@^2.3.29",
27+
"@repo/typescript-config": "workspace:^",
28+
"@types/node": "^22.15.29",
29+
"@vitest/coverage-v8": "^3.2.4",
30+
"dotenv": "^16.5.0",
31+
"ethers": "6.15.0",
32+
"fake-indexeddb": "^6.0.1",
33+
"typescript": "^5.8.3",
34+
"vitest": "^3.2.1"
35+
},
36+
"dependencies": {
37+
"@0xsequence/abi": "workspace:^",
38+
"@0xsequence/v2core": "npm:@0xsequence/core@^2.3.29",
39+
"@0xsequence/v2sessions": "npm:@0xsequence/sessions@^2.3.29",
40+
"@0xsequence/v2migration": "npm:@0xsequence/migration@^2.3.29",
41+
"@0xsequence/v2wallet": "npm:@0xsequence/wallet@^2.3.29",
42+
"@0xsequence/wallet-core": "workspace:^",
43+
"@0xsequence/wallet-primitives": "workspace:^",
44+
"mipd": "^0.0.7",
45+
"ox": "^0.7.2",
46+
"viem": "^2.30.6"
47+
}
48+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * as migration from './migrations/index.js'
2+
export * as migrator from './migrator.js'
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { Address, Hex } from 'ox'
2+
import { UnsignedMigration, VersionedContext } from '../migrator.js'
3+
import { Migration_v1v3 } from './v1/migration_v1_v3.js'
4+
5+
export interface Migration<FromConfigType, ToConfigType, ConvertOptionsType> {
6+
fromVersion: number
7+
toVersion: number
8+
9+
/**
10+
* Converts from `FromConfigType` to `ToConfigType`
11+
* @param fromConfig The configuration to convert from
12+
* @param options The convert options
13+
* @returns The converted configuration
14+
*/
15+
convertConfig: (fromConfig: FromConfigType, options: ConvertOptionsType) => Promise<ToConfigType>
16+
17+
/**
18+
* Prepares a migration for a given wallet address and context
19+
* @param walletAddress The wallet address to prepare the migration for
20+
* @param contexts The contexts to prepare the migration for
21+
* @param toConfig The configuration to prepare the migration for
22+
* @returns The prepared migration
23+
*/
24+
prepareMigration: (
25+
walletAddress: Address.Address,
26+
contexts: VersionedContext,
27+
toConfig: ToConfigType,
28+
) => Promise<UnsignedMigration>
29+
30+
/**
31+
* Decodes the transactions from a migration
32+
* @param transactions The transactions to decode
33+
* @returns The decoded address and resulting image hash for the migration transactions
34+
*/
35+
decodeTransactions: (transactions: UnsignedMigration['transactions']) => Promise<{
36+
address: Address.Address
37+
toImageHash: Hex.Hex
38+
}>
39+
}
40+
41+
export interface Migrator<FromWallet, ToWallet, ConvertOptionsType> {
42+
fromVersion: number
43+
toVersion: number
44+
45+
convertWallet: (fromWallet: FromWallet, options: ConvertOptionsType) => Promise<ToWallet>
46+
}
47+
48+
export const v1v3 = new Migration_v1v3()
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import { v1, commons as v2commons } from '@0xsequence/v2core'
2+
import { WalletV1 } from '@0xsequence/v2wallet'
3+
import { Config as V3Config, Context as V3Context, Extensions as V3Extensions } from '@0xsequence/wallet-primitives'
4+
import { AbiFunction, Address, Hex } from 'ox'
5+
import { SignedMigration, UnsignedMigration, VersionedContext } from '../../migrator.js'
6+
import { Migration } from '../index.js'
7+
import { createDefaultV3Topology } from '../v3/config.js'
8+
9+
// uint160(keccak256("org.sequence.sdk.migration.v1v3.space.nonce"))
10+
export const MIGRATION_V1_V3_NONCE_SPACE = '0x9e4d5bdafd978baf1290aff23057245a2a62bef5'
11+
12+
export type ConvertOptions = {
13+
loginSigner: {
14+
address: Address.Address
15+
imageHash?: Hex.Hex
16+
}
17+
extensions?: V3Extensions.Extensions
18+
}
19+
20+
export class Migration_v1v3 implements Migration<v1.config.WalletConfig, V3Config.Config, ConvertOptions> {
21+
fromVersion = 1
22+
toVersion = 3
23+
24+
async convertConfig(v1Config: v1.config.WalletConfig, options: ConvertOptions): Promise<V3Config.Config> {
25+
const signerLeaves: V3Config.SignerLeaf[] = v1Config.signers.map((signer) => ({
26+
type: 'signer',
27+
address: Address.from(signer.address),
28+
weight: BigInt(signer.weight),
29+
}))
30+
const v1NestedTopology = V3Config.flatLeavesToTopology(signerLeaves)
31+
const v3Config: V3Config.Config = {
32+
threshold: 1n,
33+
checkpoint: 0n,
34+
topology: [
35+
{
36+
type: 'nested',
37+
weight: 1n,
38+
threshold: BigInt(v1Config.threshold),
39+
tree: v1NestedTopology,
40+
},
41+
{
42+
type: 'nested',
43+
weight: 1n,
44+
threshold: 2n,
45+
tree: createDefaultV3Topology(options.loginSigner, options.extensions),
46+
},
47+
],
48+
}
49+
return v3Config
50+
}
51+
52+
async prepareMigration(
53+
walletAddress: Address.Address,
54+
contexts: VersionedContext,
55+
toConfig: V3Config.Config,
56+
): Promise<UnsignedMigration> {
57+
const v3Context = contexts[3] || V3Context.Rc3
58+
if (!V3Context.isContext(v3Context)) {
59+
throw new Error('Invalid context')
60+
}
61+
62+
const nonce = v2commons.transaction.encodeNonce(MIGRATION_V1_V3_NONCE_SPACE, 0)
63+
64+
// Update implementation to v3
65+
const updateImplementationAbi = AbiFunction.from('function updateImplementation(address implementation)')
66+
const updateImplementationTx = {
67+
to: walletAddress,
68+
data: AbiFunction.encodeData(updateImplementationAbi, [v3Context.stage2]),
69+
}
70+
// Update configuration to v3
71+
const v3ImageHash = Hex.fromBytes(V3Config.hashConfiguration(toConfig))
72+
const updateImageHashAbi = AbiFunction.from('function updateImageHash(bytes32 imageHash)')
73+
const updateImageHashTx = {
74+
to: walletAddress,
75+
data: AbiFunction.encodeData(updateImageHashAbi, [v3ImageHash]),
76+
}
77+
78+
return {
79+
transactions: [updateImplementationTx, updateImageHashTx],
80+
fromVersion: this.fromVersion,
81+
toVersion: this.toVersion,
82+
nonce,
83+
}
84+
}
85+
86+
/**
87+
* Signs a migration with a wallet
88+
* @notice V1 Wallets must call this method for each chain they are migrating on
89+
* @param migration The unsigned migration to sign
90+
* @param wallet The wallet to sign the migration with
91+
* @returns The signed migration
92+
*/
93+
//FIXME Remove this function. Signing is not a responsibility of the migration class.
94+
async signMigration(migration: UnsignedMigration, wallet: WalletV1): Promise<SignedMigration> {
95+
const { address } = await this.decodeTransactions(migration.transactions)
96+
if (address !== wallet.address) {
97+
throw new Error('Wallet address does not match migration address')
98+
}
99+
const txBundle: v2commons.transaction.TransactionBundle = {
100+
entrypoint: wallet.address,
101+
transactions: migration.transactions.map((tx) => ({
102+
to: tx.to,
103+
data: tx.data,
104+
gasLimit: 0n,
105+
revertOnError: true,
106+
})),
107+
nonce: migration.nonce,
108+
}
109+
const { signature } = await wallet.signTransactionBundle(txBundle)
110+
Hex.assert(signature)
111+
return { ...migration, signature }
112+
}
113+
114+
async decodeTransactions(transactions: UnsignedMigration['transactions']): Promise<{
115+
address: Address.Address
116+
toImageHash: Hex.Hex
117+
}> {
118+
if (transactions.length !== 2) {
119+
throw new Error('Invalid transactions')
120+
}
121+
const tx1 = transactions[0]!
122+
const tx2 = transactions[1]!
123+
if (tx1.to !== tx2.to) {
124+
throw new Error('Invalid to address')
125+
}
126+
const updateImplementationAbi = AbiFunction.from('function updateImplementation(address implementation)')
127+
AbiFunction.decodeData(updateImplementationAbi, tx1.data) // Check decoding works for update implementation
128+
const updateImageHashAbi = AbiFunction.from('function updateImageHash(bytes32 imageHash)')
129+
const updateImageHashArgs = AbiFunction.decodeData(updateImageHashAbi, tx2.data)
130+
return {
131+
address: tx1.to,
132+
toImageHash: updateImageHashArgs[0],
133+
}
134+
}
135+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { commons as v2commons } from '@0xsequence/v2core'
2+
import { migrator as v2migrator } from '@0xsequence/v2migration'
3+
import { WalletV1 } from '@0xsequence/v2wallet'
4+
import { State, Wallet as WalletV3 } from '@0xsequence/wallet-core'
5+
import { Constants, Context as V3Context } from '@0xsequence/wallet-primitives'
6+
import { Address } from 'ox'
7+
import { Migrator } from '../index.js'
8+
import { ConvertOptions, Migration_v1v3 } from './migration_v1_v3.js'
9+
10+
export type MigratorV1V3Options = ConvertOptions & {
11+
v3Context?: V3Context.Context
12+
}
13+
14+
export class Migrator_v1v3 implements Migrator<WalletV1, WalletV3, MigratorV1V3Options> {
15+
fromVersion = 1
16+
toVersion = 3
17+
18+
constructor(
19+
private readonly v1Tracker?: v2migrator.PresignedMigrationTracker,
20+
private readonly v3StateProvider?: State.Provider,
21+
public readonly migration: Migration_v1v3 = new Migration_v1v3(),
22+
) {}
23+
24+
async convertWallet(v1Wallet: WalletV1, options: MigratorV1V3Options): Promise<WalletV3> {
25+
// Prepare migration
26+
const v3Context = options.v3Context || V3Context.Rc3
27+
const v1Config = v1Wallet.config
28+
const v3Config = await this.migration.convertConfig(v1Config, options)
29+
await this.v3StateProvider?.saveConfiguration(v3Config)
30+
const unsignedMigration = await this.migration.prepareMigration(
31+
Address.from(v1Wallet.address),
32+
{ [3]: v3Context },
33+
v3Config,
34+
)
35+
36+
// Sign migration
37+
const txBundle: v2commons.transaction.TransactionBundle = {
38+
entrypoint: v1Wallet.address,
39+
transactions: unsignedMigration.transactions.map((tx) => ({
40+
to: tx.to,
41+
data: tx.data,
42+
gasLimit: 0n,
43+
revertOnError: true,
44+
})),
45+
nonce: unsignedMigration.nonce,
46+
}
47+
const signedTxBundle = await v1Wallet.signTransactionBundle(txBundle)
48+
49+
// Save to tracker
50+
const v2SignedMigration: v2migrator.SignedMigration = {
51+
fromVersion: this.fromVersion,
52+
toVersion: this.toVersion,
53+
toConfig: {
54+
version: 3,
55+
...v3Config,
56+
},
57+
tx: signedTxBundle,
58+
}
59+
const versionedContext: v2commons.context.VersionedContext = {
60+
[3]: {
61+
version: 3,
62+
mainModule: v3Context.stage1,
63+
mainModuleUpgradable: v3Context.stage2,
64+
factory: v3Context.factory,
65+
guestModule: Constants.DefaultGuestAddress,
66+
walletCreationCode: v3Context.creationCode,
67+
},
68+
}
69+
await this.v1Tracker?.saveMigration(v1Wallet.address, v2SignedMigration, versionedContext)
70+
//FIXME State provider should be aware of migrations too
71+
72+
// Return v3 wallet
73+
return WalletV3.fromConfiguration(v3Config, {
74+
context: v3Context,
75+
})
76+
}
77+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import {
2+
Config as V3Config,
3+
Extensions as V3Extensions,
4+
GenericTree as V3GenericTree,
5+
SessionConfig as V3SessionConfig,
6+
} from '@0xsequence/wallet-primitives'
7+
import { Address, Hex } from 'ox'
8+
9+
export const createDefaultV3Topology = (
10+
loginSigner: {
11+
address: Address.Address
12+
imageHash?: Hex.Hex
13+
},
14+
extensions?: V3Extensions.Extensions,
15+
): V3Config.Topology => {
16+
// Login topology
17+
const loginTopology: V3Config.SapientSignerLeaf | V3Config.SignerLeaf = loginSigner.imageHash
18+
? {
19+
type: 'sapient-signer',
20+
address: loginSigner.address,
21+
weight: 1n,
22+
imageHash: loginSigner.imageHash,
23+
}
24+
: {
25+
type: 'signer',
26+
address: loginSigner.address,
27+
weight: 1n,
28+
}
29+
// Wallet guard topology
30+
const walletGuardTopology: V3Config.SignerLeaf = {
31+
type: 'signer',
32+
address: '0xa2e70CeaB3Eb145F32d110383B75B330fA4e288a', // Guard wallet signer
33+
weight: 1n,
34+
}
35+
// Placeholder recovery topology
36+
const recoveryTopology: V3Config.SapientSignerLeaf = {
37+
type: 'sapient-signer',
38+
address: (extensions ?? V3Extensions.Rc3).recovery,
39+
weight: 255n,
40+
imageHash: '0x0000000000000000000000000000000000000000000000000000000000000000',
41+
}
42+
// Session topology
43+
let sessionsImageHash: Hex.Hex = '0x0000000000000000000000000000000000000000000000000000000000000000'
44+
if (!loginSigner.imageHash) {
45+
// We can't use the login signer with sessions if it is a sapient signer
46+
const sessionsTopology = V3SessionConfig.emptySessionsTopology(loginSigner.address)
47+
const sessionsConfig = V3SessionConfig.sessionsTopologyToConfigurationTree(sessionsTopology)
48+
sessionsImageHash = V3GenericTree.hash(sessionsConfig)
49+
}
50+
const sessionTopology: V3Config.SapientSignerLeaf = {
51+
type: 'sapient-signer',
52+
address: (extensions ?? V3Extensions.Rc3).sessions,
53+
weight: 1n,
54+
imageHash: sessionsImageHash,
55+
}
56+
// Sessions are protected by a guard signer
57+
const sessionGuardTopology: V3Config.SignerLeaf = {
58+
type: 'signer',
59+
address: '0x18002Fc09deF9A47437cc64e270843dE094f5984', // Guard session signer
60+
weight: 1n,
61+
}
62+
const nestedSessionTopology: V3Config.NestedLeaf = {
63+
type: 'nested',
64+
weight: 255n,
65+
threshold: 2n,
66+
tree: [sessionTopology, sessionGuardTopology],
67+
}
68+
// Return the wallet topology
69+
return [
70+
[loginTopology, walletGuardTopology],
71+
[recoveryTopology, nestedSessionTopology],
72+
]
73+
}

0 commit comments

Comments
 (0)