diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a9e982..c71fd36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ## [UNRELEASED] +### New Features + +- **Safe ERC-4337 module migration helpers.** `SafeAccountV0_3_0.createMigrateToSafeMultiChainSigAccountV1MetaTransactions(nodeRpcUrl, overrides?)` builds the `disableModule` + `enableModule` + `setFallbackHandler` batch that migrates a deployed Safe from the EntryPoint v0.7 module to the v0.9 `Safe4337MultiChainSignatureModule`. Both modules are stateless, so no storage clearing is required. Unless `{ skipPreflight: true }` is passed, it first verifies on-chain that the account is actually a Safe running the old module (the module is enabled **and** is the current fallback handler) on a Safe version `>= 1.4.1`, turning a would-be cryptic on-chain `AA23`/`AA24` into a clear up-front error. +- **New Safe / transport readers.** `SafeAccount.getFallbackHandler(nodeRpcUrl)` (the active 4337 module), `SafeAccount.getSafeVersion(nodeRpcUrl)` (reads `VERSION()`), `JsonRpcNode.getStorageAt(address, slot, blockTag?)`, and the exported `SAFE_FALLBACK_HANDLER_STORAGE_SLOT` constant. + +### Bug Fixes + +- **`SafeAccountV0_2_0.createMigrateToSafeAccountV0_3_0MetaTransactions` predecessor wiring.** When `safeV06ModuleAddress` was set explicitly, the v0.6 → v0.7 migration passed the module being disabled as its own linked-list predecessor, producing `disableModule(prev = module, module)`. It now exposes a dedicated `prevModuleAddress` override (defaulting to the on-chain lookup) and shares the generic migration implementation. Default callers are unaffected. + ## 0.3.8 ### New Features diff --git a/src/abstractionkit.ts b/src/abstractionkit.ts index 3d58aba..b84c363 100644 --- a/src/abstractionkit.ts +++ b/src/abstractionkit.ts @@ -79,6 +79,7 @@ export { ENTRYPOINT_V7, ENTRYPOINT_V8, ENTRYPOINT_V9, + SAFE_FALLBACK_HANDLER_STORAGE_SLOT, ZeroAddress, } from "./constants"; export { AbstractionKitError } from "./errors"; diff --git a/src/account/Safe/SafeAccount.ts b/src/account/Safe/SafeAccount.ts index 01b9fcd..03a8a7b 100644 --- a/src/account/Safe/SafeAccount.ts +++ b/src/account/Safe/SafeAccount.ts @@ -25,6 +25,7 @@ import { ENTRYPOINT_V6, ENTRYPOINT_V7, ENTRYPOINT_V9, + SAFE_FALLBACK_HANDLER_STORAGE_SLOT, Safe_L2_V1_4_1, ZeroAddress, } from "../../constants"; @@ -2739,6 +2740,42 @@ export class SafeAccount extends SmartAccount { return decodedCalldata[0]; } + /** + * read the Safe's current fallback handler address from storage. + * For Safe ERC-4337 accounts the fallback handler is the 4337 module, so this + * is the canonical way to confirm which module/EntryPoint version an account + * is on (e.g. after a module migration). + * @param nodeRpcUrl - The JSON-RPC API url for the target chain + * @returns a promise of the fallback handler address (checksummed) + */ + public async getFallbackHandler( + nodeRpcUrl: string | Transport | JsonRpcNode, + ): Promise { + const word = await JsonRpcNode.from(nodeRpcUrl).getStorageAt( + this.accountAddress, + SAFE_FALLBACK_HANDLER_STORAGE_SLOT, + "latest", + ); + return getAddress("0x" + word.slice(-40)); + } + + /** + * read the Safe's version string via `VERSION()` (e.g. "1.4.1"). + * @param nodeRpcUrl - The JSON-RPC API url for the target chain + * @returns a promise of the Safe singleton version string + */ + public async getSafeVersion( + nodeRpcUrl: string | Transport | JsonRpcNode, + ): Promise { + const callData = createCallData(getFunctionSelector("VERSION()"), [], []); + const result = await JsonRpcNode.from(nodeRpcUrl).call( + { to: this.accountAddress, data: callData }, + "latest", + ); + const [version] = decodeAbiParameters<[string]>(["string"], result); + return version; + } + /** * create a list of dummy signer/signature pairs for gas estimation based on the expected signers. * @param expectedSigners - signers whose signatures will be produced at sign time @@ -3009,6 +3046,187 @@ export class SafeAccount extends SmartAccount { }; } + /** + * create the MetaTransactions that migrate a DEPLOYED Safe from one ERC-4337 + * module (and EntryPoint) to another. For Safe 4337 accounts the module is + * both the enabled module and the fallback handler, so a migration is exactly: + * 1. disableModule(oldModule) + * 2. enableModule(newModule) + * 3. setFallbackHandler(newModule) + * + * @note Both the v0.6/v0.7 `Safe4337Module` and the v0.9 + * `Safe4337MultiChainSignatureModule` are stateless (no per-account storage), + * so there is NO storage to clear when swapping modules — these three + * transactions are the whole migration. The batch is validated and executed + * by the OLD module on the OLD EntryPoint; disabling that module mid-batch is + * safe because validation has already completed. + * + * Unless `skipPreflight` is set, this verifies on-chain that the account is + * actually a Safe running `oldModuleAddress` (the module is enabled AND is the + * current fallback handler) and that its Safe version meets the module minimum + * (>= 1.4.1) — turning a would-be cryptic on-chain `AA23`/`AA24` into a clear + * up-front error. + * + * @param nodeRpcUrl - The JSON-RPC API url for the target chain (used to find + * the previous module in the linked list when not provided, and for preflight) + * @param oldModuleAddress - the currently-enabled 4337 module to disable + * @param newModuleAddress - the 4337 module to enable and set as fallback handler + * @param overrides - previous-module lookup overrides and `skipPreflight` + * @returns a promise of [disableOld, enableNew, setFallbackHandler] MetaTransactions + * + * @remarks Shared implementation behind the version-specific migration helpers + * (e.g. {@link SafeAccountV0_3_0.createMigrateToSafeMultiChainSigAccountV1MetaTransactions}). + * It is `protected` on purpose: those wrappers pin the correct module addresses, + * so callers reach migration through them rather than supplying raw module + * addresses directly. + */ + protected async createModuleMigrationMetaTransactions( + nodeRpcUrl: string | Transport | JsonRpcNode, + oldModuleAddress: string, + newModuleAddress: string, + overrides: { + prevModuleAddress?: string; + modulesStart?: string; + modulesPageSize?: bigint; + skipPreflight?: boolean; + } = {}, + ): Promise { + if (overrides.skipPreflight !== true) { + await this.assertMigratableFromModule(nodeRpcUrl, oldModuleAddress); + } + + const disableOldModule = await this.createDisableModuleMetaTransaction( + nodeRpcUrl, + oldModuleAddress, + this.accountAddress, + overrides, + ); + + const enableNewModule = SafeAccount.createEnableModuleMetaTransaction( + newModuleAddress, + this.accountAddress, + ); + + const setFallbackHandler: MetaTransaction = { + to: this.accountAddress, + value: 0n, + data: createCallData( + "0xf08a0323", //setFallbackHandler(address) + ["address"], + [newModuleAddress], + ), + }; + + return [disableOldModule, enableNewModule, setFallbackHandler]; + } + + /** + * Minimum Safe singleton version required by the ERC-4337 modules. + */ + private static readonly MIN_SAFE_4337_VERSION = "1.4.1"; + + /** + * Assert that this account is a deployed Safe currently running `oldModuleAddress` + * as both its enabled module and its fallback handler, on a Safe version that + * meets the 4337 module minimum. Throws a descriptive `BAD_DATA` error otherwise. + */ + private async assertMigratableFromModule( + nodeRpcUrl: string | Transport | JsonRpcNode, + oldModuleAddress: string, + ): Promise { + const node = JsonRpcNode.from(nodeRpcUrl); + + // The migration UserOperation is validated through the Safe's fallback + // handler on the old EntryPoint, so the old module must be the fallback + // handler for the migration to be processable at all. + let fallbackHandler: string; + try { + fallbackHandler = await this.getFallbackHandler(node); + } catch (err) { + throw new AbstractionKitError( + "BAD_DATA", + `Could not read the fallback handler of ${this.accountAddress} — is it a deployed Safe? ` + + "Pass { skipPreflight: true } to bypass this check.", + { cause: ensureError(err) }, + ); + } + if (fallbackHandler.toLowerCase() !== oldModuleAddress.toLowerCase()) { + throw new AbstractionKitError( + "BAD_DATA", + `Safe ${this.accountAddress} fallback handler is ${fallbackHandler}, expected the ` + + `old 4337 module ${oldModuleAddress}. This account is not a Safe running that ` + + "module; pass { skipPreflight: true } to bypass.", + ); + } + + // The old module must also be enabled so execTransactionFromModule (which + // executes the migration batch) is authorized. + let moduleEnabled: boolean; + try { + moduleEnabled = await this.isModuleEnabled(node, oldModuleAddress); + } catch (err) { + throw new AbstractionKitError( + "BAD_DATA", + `Could not check whether module ${oldModuleAddress} is enabled on Safe ` + + `${this.accountAddress} — is it a deployed Safe? Pass { skipPreflight: true } to bypass.`, + { cause: ensureError(err) }, + ); + } + if (!moduleEnabled) { + throw new AbstractionKitError( + "BAD_DATA", + `The 4337 module ${oldModuleAddress} is not enabled on Safe ${this.accountAddress}. ` + + "Pass { skipPreflight: true } to bypass.", + ); + } + + // Both modules require Safe >= 1.4.1. Treat read/decode failures and + // empty/invalid version strings as a failed preflight. + let version: string; + try { + version = await this.getSafeVersion(node); + } catch (err) { + throw new AbstractionKitError( + "BAD_DATA", + `Could not read the Safe version (VERSION()) of ${this.accountAddress} — is it a ` + + `deployed Safe running module ${oldModuleAddress}? Pass { skipPreflight: true } to bypass.`, + { cause: ensureError(err) }, + ); + } + if ( + typeof version !== "string" || + version.trim() === "" || + !SafeAccount.isVersionAtLeast(version, SafeAccount.MIN_SAFE_4337_VERSION) + ) { + throw new AbstractionKitError( + "BAD_DATA", + `Safe ${this.accountAddress} reported version "${version}", which does not meet the ` + + `minimum ${SafeAccount.MIN_SAFE_4337_VERSION} required by the 4337 modules ` + + `(module ${oldModuleAddress}). Pass { skipPreflight: true } to bypass.`, + ); + } + } + + /** + * Compare dotted version strings numerically (ignoring any "+suffix"), e.g. + * isVersionAtLeast("1.4.1", "1.4.1") === true, ("1.5.0", "1.4.1") === true. + */ + private static isVersionAtLeast(version: string, minimum: string): boolean { + const parse = (v: string): number[] => + v + .split("+")[0] + .split(".") + .map((n) => parseInt(n, 10) || 0); + const a = parse(version); + const b = parse(minimum); + for (let i = 0; i < Math.max(a.length, b.length); i++) { + const x = a[i] ?? 0; + const y = b[i] ?? 0; + if (x !== y) return x > y; + } + return true; + } + /** * Simulate the encoded calldata for this account on Tenderly and optionally return a share link. * When `isInit` isn't provided, nonce is fetched via `nodeRpcUrl` to decide whether to include diff --git a/src/account/Safe/SafeAccountV0_2_0.ts b/src/account/Safe/SafeAccountV0_2_0.ts index 8749fa3..a588cb1 100644 --- a/src/account/Safe/SafeAccountV0_2_0.ts +++ b/src/account/Safe/SafeAccountV0_2_0.ts @@ -2,7 +2,6 @@ import type {Bundler} from "src/Bundler"; import {ENTRYPOINT_V6} from "src/constants"; import type {SignContext, Signer as AkSigner} from "src/signer/types"; import type {JsonRpcNode, Transport} from "src/transport"; -import {createCallData} from "src/utils"; import type {MetaTransaction, OnChainIdentifierParamsType, StateOverrideSet, UserOperationV6,} from "../../types"; import {SafeAccount} from "./SafeAccount"; import {SafeAccountV0_3_0} from "./SafeAccountV0_3_0"; @@ -370,8 +369,10 @@ export class SafeAccountV0_2_0 extends SafeAccount { overrides: { safeV06ModuleAddress?: string; safeV07ModuleAddress?: string; + prevModuleAddress?: string; pageSize?: bigint; modulesStart?: string; + skipPreflight?: boolean; } = {}, ): Promise { const moduleV06Address = @@ -380,38 +381,19 @@ export class SafeAccountV0_2_0 extends SafeAccount { const moduleV07Address = overrides.safeV07ModuleAddress ?? SafeAccountV0_3_0.DEFAULT_SAFE_4337_MODULE_ADDRESS; - const disableModuleMetaTransaction = await this.createDisableModuleMetaTransaction( + return this.createModuleMigrationMetaTransactions( nodeRpcUrl, moduleV06Address, - this.accountAddress, + moduleV07Address, { - prevModuleAddress: overrides.safeV06ModuleAddress, + // previous module in the linked list (skips the RPC lookup when set); + // left undefined by default so the predecessor is fetched on-chain. + prevModuleAddress: overrides.prevModuleAddress, modulesPageSize: overrides.pageSize, modulesStart: overrides.modulesStart, + skipPreflight: overrides.skipPreflight, }, ); - - const enableModuleMetaTransaction = SafeAccount.createEnableModuleMetaTransaction( - moduleV07Address, - this.accountAddress, - ); - - const setFallbackHandlerCallData = createCallData( - "0xf08a0323", //setFallbackHandler(address) - ["address"], - [moduleV07Address], - ); - const setFallbackHandlerMetaTransaction: MetaTransaction = { - to: this.accountAddress, - value: 0n, - data: setFallbackHandlerCallData, - }; - - return [ - disableModuleMetaTransaction, - enableModuleMetaTransaction, - setFallbackHandlerMetaTransaction, - ]; } /** diff --git a/src/account/Safe/SafeAccountV0_3_0.ts b/src/account/Safe/SafeAccountV0_3_0.ts index 44d77f8..369749d 100644 --- a/src/account/Safe/SafeAccountV0_3_0.ts +++ b/src/account/Safe/SafeAccountV0_3_0.ts @@ -5,6 +5,7 @@ import type {JsonRpcNode, Transport} from "src/transport"; import type {MetaTransaction, OnChainIdentifierParamsType, StateOverrideSet, UserOperationV7,} from "../../types"; import {SafeAccount} from "./SafeAccount"; +import {SafeMultiChainSigAccountV1} from "./SafeMultiChainSigAccount"; import type { CreateUserOperationV7Overrides, InitCodeOverrides, @@ -409,6 +410,50 @@ export class SafeAccountV0_3_0 extends SafeAccount { options, }); } + + /** + * Create the MetaTransactions that migrate this DEPLOYED Safe from EntryPoint + * v0.7 (this account's `Safe4337Module`) to EntryPoint v0.9 + * (`SafeMultiChainSigAccountV1`'s `Safe4337MultiChainSignatureModule`). + * + * The returned batch must be sent as a UserOperation FROM THIS v0.7 account + * (it is validated/executed by the v0.7 module on the v0.7 EntryPoint). After + * it lands, attach the same account address to `SafeMultiChainSigAccountV1` to + * operate on EntryPoint v0.9. Both modules are stateless, so no storage + * clearing is required. See {@link createModuleMigrationMetaTransactions}. + * + * @param nodeRpcUrl - The JSON-RPC API url for the target chain + * @param overrides - override the source/target module addresses or module lookup + * @returns a promise of [disableV07, enableV09, setFallbackHandler] MetaTransactions + */ + public async createMigrateToSafeMultiChainSigAccountV1MetaTransactions( + nodeRpcUrl: string | Transport | JsonRpcNode, + overrides: { + safeV07ModuleAddress?: string; + safeV09ModuleAddress?: string; + prevModuleAddress?: string; + modulesStart?: string; + modulesPageSize?: bigint; + skipPreflight?: boolean; + } = {}, + ): Promise { + const moduleV07Address = overrides.safeV07ModuleAddress ?? this.safe4337ModuleAddress; + const moduleV09Address = + overrides.safeV09ModuleAddress ?? + SafeMultiChainSigAccountV1.DEFAULT_SAFE_4337_MODULE_ADDRESS; + + return this.createModuleMigrationMetaTransactions( + nodeRpcUrl, + moduleV07Address, + moduleV09Address, + { + prevModuleAddress: overrides.prevModuleAddress, + modulesStart: overrides.modulesStart, + modulesPageSize: overrides.modulesPageSize, + skipPreflight: overrides.skipPreflight, + }, + ); + } } /** diff --git a/src/constants.ts b/src/constants.ts index 3c388fb..45f8383 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -12,6 +12,14 @@ export const ENTRYPOINT_V7 = "0x0000000071727De22E5E9d8BAf0edAc6f37da032"; /** EntryPoint v0.6 contract address */ export const ENTRYPOINT_V6 = "0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789"; +/** + * Safe fallback-handler storage slot: keccak256("fallback_manager.handler.address"). + * The address of a Safe's fallback handler (which is the 4337 module) is stored + * in the lower 20 bytes of the word at this slot. + */ +export const SAFE_FALLBACK_HANDLER_STORAGE_SLOT = + "0x6c9a6c4a39284e37ed1cf53d337577d14212a4870fb976a4366c693b939918d5"; + /** Safe L2 singleton v1.5.0 address and init hash */ export const Safe_L2_V1_5_0: SafeAccountSingleton = { singletonAddress: "0xEdd160fEBBD92E350D4D398fb636302fccd67C7e", diff --git a/src/transport/JsonRpcNode.ts b/src/transport/JsonRpcNode.ts index 8cc3e5e..e83b41c 100644 --- a/src/transport/JsonRpcNode.ts +++ b/src/transport/JsonRpcNode.ts @@ -155,6 +155,32 @@ export class JsonRpcNode implements Transport { } } + /** + * `eth_getStorageAt`. Returns the 32-byte storage word at `slot` for + * `address` at the given block tag (default `"latest"`), as a hex string. + */ + async getStorageAt( + address: string, + slot: string, + blockTag: string | bigint = "latest", + options?: RequestOptions, + ): Promise { + try { + const result = await this.outbound.request( + {method: "eth_getStorageAt", params: [address, slot, blockTag]}, + options, + ); + if (typeof result !== "string") { + throw new AbstractionKitError("BAD_DATA", "eth_getStorageAt returned ill formed data", { + context: JSON.stringify(result), + }); + } + return result; + } catch (err) { + throw translateNodeError(err, "eth_getStorageAt"); + } + } + /** * `eth_call`. Executes a read-only call against `to` and returns the raw * return data as a hex string. Supports state overrides via the optional diff --git a/test/integration/e2e/migrate-safe-v07-to-v09/migrate.test.js b/test/integration/e2e/migrate-safe-v07-to-v09/migrate.test.js new file mode 100644 index 0000000..4271604 --- /dev/null +++ b/test/integration/e2e/migrate-safe-v07-to-v09/migrate.test.js @@ -0,0 +1,97 @@ +// e2e: migrate a DEPLOYED Safe from EntryPoint v0.7 (SafeAccountV0_3_0) to +// EntryPoint v0.9 (SafeMultiChainSigAccountV1) by swapping the 4337 module + +// fallback handler, then prove the upgraded account operates on v0.9. +// +// Cross-EntryPoint: the deploy + migrate ops are validated by the v0.7 module +// and routed through the v7 bundler; the post-migration op runs through the v9 +// bundler against the same anvil node. Self-funded via anvil_setBalance — no +// paymaster needed. + +const { Wallet } = require('ethers'); +const { + SafeAccountV0_3_0, + SafeMultiChainSigAccountV1, + sendJsonRpcRequest, +} = require('../../../../dist/index.cjs'); +const { runnable, unrunnable, nodeUrl, bundlerUrl } = require('../../_runnable.cjs'); + +jest.setTimeout(300000); + +const TEN_ETH_HEX = '0x8AC7230489E80000'; +const V07_MODULE = SafeAccountV0_3_0.DEFAULT_SAFE_4337_MODULE_ADDRESS; +const V09_MODULE = SafeMultiChainSigAccountV1.DEFAULT_SAFE_4337_MODULE_ADDRESS; + +describe.each(runnable)('Safe v0.7 -> v0.9 migration e2e: $name (chainId $chainId)', (chain) => { + const node = nodeUrl(chain); + const v07Bundler = bundlerUrl({ ...chain, entrypoint: 'v7' }); + const v09Bundler = bundlerUrl({ ...chain, entrypoint: 'v9' }); + const chainId = BigInt(chain.chainId); + + const owner = Wallet.createRandom(); + + let oldAccount; // SafeAccountV0_3_0 view (EP v0.7) + let accountAddress; + + async function sendUserOp(account, bundler, transactions) { + const userOp = await account.createUserOperation(transactions, node, bundler, { + verificationGasLimitPercentageMultiplier: 200, + }); + userOp.signature = account.signUserOperation(userOp, [owner.privateKey], chainId); + return (await account.sendUserOperation(userOp, bundler)).included(); + } + + beforeAll(async () => { + oldAccount = SafeAccountV0_3_0.initializeNewAccount([owner.address]); + accountAddress = oldAccount.accountAddress; + await sendJsonRpcRequest(node, 'anvil_setBalance', [accountAddress, TEN_ETH_HEX]); + }); + + test('deploy the Safe on EntryPoint v0.7', async () => { + const receipt = await sendUserOp(oldAccount, v07Bundler, [ + { to: owner.address, value: 0n, data: '0x' }, + ]); + expect(receipt?.success).toBe(true); + + const code = await sendJsonRpcRequest(node, 'eth_getCode', [accountAddress, 'latest']); + expect(code).not.toBe('0x'); + // The v0.7 module is the active module on the fresh Safe. + expect(await oldAccount.isModuleEnabled(node, V07_MODULE)).toBe(true); + }); + + test('migrate to the v0.9 multi-chain module (validated by the v0.7 module)', async () => { + oldAccount = new SafeAccountV0_3_0(accountAddress); + const batch = await oldAccount.createMigrateToSafeMultiChainSigAccountV1MetaTransactions(node); + expect(batch).toHaveLength(3); + + const receipt = await sendUserOp(oldAccount, v07Bundler, batch); + expect(receipt?.success).toBe(true); + }); + + test('on-chain state reflects the upgrade', async () => { + expect(await oldAccount.isModuleEnabled(node, V09_MODULE)).toBe(true); + expect(await oldAccount.isModuleEnabled(node, V07_MODULE)).toBe(false); + + const fallbackHandler = await oldAccount.getFallbackHandler(node); + expect(fallbackHandler.toLowerCase()).toBe(V09_MODULE.toLowerCase()); + }); + + test('the upgraded account executes a UserOperation on EntryPoint v0.9', async () => { + const newAccount = new SafeMultiChainSigAccountV1(accountAddress); + const recipient = Wallet.createRandom().address; + const value = 1_000_000_000_000_000n; + + const receipt = await sendUserOp(newAccount, v09Bundler, [ + { to: recipient, value, data: '0x' }, + ]); + expect(receipt?.success).toBe(true); + + const bal = await sendJsonRpcRequest(node, 'eth_getBalance', [recipient, 'latest']); + expect(BigInt(bal)).toBe(value); + }); +}); + +if (unrunnable.length > 0) { + describe.skip.each(unrunnable)('Safe v0.7 -> v0.9 migration e2e: $name (setup failed)', () => { + test('skipped', () => {}); + }); +} diff --git a/test/safe/moduleMigration.test.js b/test/safe/moduleMigration.test.js new file mode 100644 index 0000000..52fe8aa --- /dev/null +++ b/test/safe/moduleMigration.test.js @@ -0,0 +1,276 @@ +// Unit tests for the Safe 4337 module-migration helpers, the migration preflight, +// and the fallback-handler / version readers. Deterministic and offline: a +// hand-rolled mock Transport stands in for the node. Builder-shape tests pass +// `skipPreflight: true` so they exercise only calldata construction; the preflight +// is covered by its own block. + +const { AbiCoder, getAddress } = require('ethers'); +const { + SafeAccountV0_2_0, + SafeAccountV0_3_0, + SafeMultiChainSigAccountV1, + SAFE_FALLBACK_HANDLER_STORAGE_SLOT, + getFunctionSelector, +} = require('../../dist/index.cjs'); + +const DISABLE_MODULE = '0xe009cfde'; // disableModule(address,address) +const ENABLE_MODULE = '0x610b5925'; // enableModule(address) +const SET_FALLBACK_HANDLER = '0xf08a0323'; // setFallbackHandler(address) +const SENTINEL = '0x0000000000000000000000000000000000000001'; + +const ACCOUNT = '0x1111111111111111111111111111111111111111'; +const V07_MODULE = SafeAccountV0_3_0.DEFAULT_SAFE_4337_MODULE_ADDRESS; +const V09_MODULE = SafeMultiChainSigAccountV1.DEFAULT_SAFE_4337_MODULE_ADDRESS; +const V06_MODULE = SafeAccountV0_2_0.DEFAULT_SAFE_4337_MODULE_ADDRESS; + +const VERSION_SELECTOR = getFunctionSelector('VERSION()'); +const IS_MODULE_ENABLED_SELECTOR = getFunctionSelector('isModuleEnabled(address)'); + +// Decode the i-th 32-byte address argument (after the 4-byte selector). +function addrArg(data, i) { + const start = 10 + i * 64; + return getAddress('0x' + data.slice(start, start + 64).slice(-40)); +} + +function leftPadAddress(address) { + return '0x' + '0'.repeat(24) + address.toLowerCase().replace(/^0x/, '').padStart(40, '0'); +} + +function mockTransport(handler) { + const calls = []; + return { + request: async (args) => { + calls.push(args); + return handler(args); + }, + calls, + }; +} + +// A transport simulating a deployed Safe: fallback handler, module-enabled +// answer, version string, and the module list for predecessor lookups. +function safeMock({ fallback = V07_MODULE, moduleEnabled = true, version = '1.4.1', modules = [V07_MODULE] } = {}) { + return mockTransport(({ method, params }) => { + if (method === 'eth_getStorageAt') return leftPadAddress(fallback); + if (method === 'eth_call') { + const data = params[0].data; + if (data.startsWith(VERSION_SELECTOR)) { + return AbiCoder.defaultAbiCoder().encode(['string'], [version]); + } + if (data.startsWith(IS_MODULE_ENABLED_SELECTOR)) { + return AbiCoder.defaultAbiCoder().encode(['bool'], [moduleEnabled]); + } + // getModulesPaginated(address,uint256) + return AbiCoder.defaultAbiCoder().encode(['address[]', 'address'], [modules, SENTINEL]); + } + throw new Error(`unexpected method ${method}`); + }); +} + +// createModuleMigrationMetaTransactions is protected; its shape and predecessor +// lookup are exercised through the public version-specific wrapper. +describe('SafeAccountV0_3_0.createMigrateToSafeMultiChainSigAccountV1MetaTransactions', () => { + test('returns [disable, enable, setFallbackHandler] with correct selectors and targets', async () => { + const account = new SafeAccountV0_3_0(ACCOUNT); + const batch = await account.createMigrateToSafeMultiChainSigAccountV1MetaTransactions( + 'https://unused.invalid', + { prevModuleAddress: SENTINEL, skipPreflight: true }, + ); + + expect(batch).toHaveLength(3); + for (const tx of batch) { + expect(getAddress(tx.to)).toBe(getAddress(ACCOUNT)); + expect(tx.value).toBe(0n); + } + + // 1. disableModule(prev, oldModule = v0.7) + expect(batch[0].data.slice(0, 10)).toBe(DISABLE_MODULE); + expect(addrArg(batch[0].data, 0)).toBe(getAddress(SENTINEL)); + expect(addrArg(batch[0].data, 1)).toBe(getAddress(V07_MODULE)); + // 2. enableModule(newModule = v0.9) + expect(batch[1].data.slice(0, 10)).toBe(ENABLE_MODULE); + expect(addrArg(batch[1].data, 0)).toBe(getAddress(V09_MODULE)); + // 3. setFallbackHandler(newModule = v0.9) + expect(batch[2].data.slice(0, 10)).toBe(SET_FALLBACK_HANDLER); + expect(addrArg(batch[2].data, 0)).toBe(getAddress(V09_MODULE)); + }); + + test('looks up the predecessor on-chain when prevModuleAddress is omitted', async () => { + const transport = safeMock(); + const account = new SafeAccountV0_3_0(ACCOUNT); + const batch = await account.createMigrateToSafeMultiChainSigAccountV1MetaTransactions( + transport, + { skipPreflight: true }, + ); + + expect(transport.calls.some((c) => c.method === 'eth_call')).toBe(true); + expect(batch[0].data.slice(0, 10)).toBe(DISABLE_MODULE); + expect(addrArg(batch[0].data, 0)).toBe(getAddress(SENTINEL)); // predecessor + expect(addrArg(batch[0].data, 1)).toBe(getAddress(V07_MODULE)); // module disabled + }); + + test('honors explicit module overrides', async () => { + const customOld = '0x00000000000000000000000000000000000000a7'; + const customNew = '0x00000000000000000000000000000000000000a9'; + const account = new SafeAccountV0_3_0(ACCOUNT); + const batch = await account.createMigrateToSafeMultiChainSigAccountV1MetaTransactions( + 'https://unused.invalid', + { safeV07ModuleAddress: customOld, safeV09ModuleAddress: customNew, prevModuleAddress: SENTINEL, skipPreflight: true }, + ); + expect(addrArg(batch[0].data, 1)).toBe(getAddress(customOld)); + expect(addrArg(batch[1].data, 0)).toBe(getAddress(customNew)); + expect(addrArg(batch[2].data, 0)).toBe(getAddress(customNew)); + }); +}); + +describe('SafeAccountV0_2_0.createMigrateToSafeAccountV0_3_0MetaTransactions (prevModuleAddress regression)', () => { + test('defaults to migrating the v0.6 module to the v0.7 module', async () => { + const account = new SafeAccountV0_2_0(ACCOUNT); + const batch = await account.createMigrateToSafeAccountV0_3_0MetaTransactions( + safeMock({ fallback: V06_MODULE, modules: [V06_MODULE] }), + { skipPreflight: true }, + ); + expect(addrArg(batch[0].data, 1)).toBe(getAddress(V06_MODULE)); + expect(addrArg(batch[1].data, 0)).toBe(getAddress(V07_MODULE)); + expect(addrArg(batch[2].data, 0)).toBe(getAddress(V07_MODULE)); + }); + + test('explicit prevModuleAddress is used as the predecessor, NOT the module being disabled', async () => { + // Regression: previously safeV06ModuleAddress was mis-wired into + // prevModuleAddress, producing disableModule(prev=module, module). + const predecessor = '0x00000000000000000000000000000000000000aa'; + const moduleToDisable = '0x00000000000000000000000000000000000000bb'; + const account = new SafeAccountV0_2_0(ACCOUNT); + const batch = await account.createMigrateToSafeAccountV0_3_0MetaTransactions( + 'https://unused.invalid', + { safeV06ModuleAddress: moduleToDisable, prevModuleAddress: predecessor, skipPreflight: true }, + ); + + expect(batch[0].data.slice(0, 10)).toBe(DISABLE_MODULE); + expect(addrArg(batch[0].data, 0)).toBe(getAddress(predecessor)); // prev + expect(addrArg(batch[0].data, 1)).toBe(getAddress(moduleToDisable)); // module + expect(addrArg(batch[0].data, 0)).not.toBe(addrArg(batch[0].data, 1)); + }); +}); + +describe('migration preflight', () => { + test('passes when the old module is the fallback handler, enabled, and version >= 1.4.1', async () => { + const account = new SafeAccountV0_3_0(ACCOUNT); + const batch = await account.createMigrateToSafeMultiChainSigAccountV1MetaTransactions( + safeMock({ fallback: V07_MODULE, moduleEnabled: true, version: '1.4.1' }), + ); + expect(batch).toHaveLength(3); + }); + + test('passes on a newer Safe version (1.5.0)', async () => { + const account = new SafeAccountV0_3_0(ACCOUNT); + const batch = await account.createMigrateToSafeMultiChainSigAccountV1MetaTransactions( + safeMock({ version: '1.5.0' }), + ); + expect(batch).toHaveLength(3); + }); + + test('throws when the fallback handler is not the old module', async () => { + const account = new SafeAccountV0_3_0(ACCOUNT); + await expect( + account.createMigrateToSafeMultiChainSigAccountV1MetaTransactions( + safeMock({ fallback: '0x00000000000000000000000000000000000000cc' }), + ), + ).rejects.toMatchObject({ code: 'BAD_DATA' }); + }); + + test('throws when the old module is not enabled', async () => { + const account = new SafeAccountV0_3_0(ACCOUNT); + await expect( + account.createMigrateToSafeMultiChainSigAccountV1MetaTransactions( + safeMock({ fallback: V07_MODULE, moduleEnabled: false }), + ), + ).rejects.toMatchObject({ code: 'BAD_DATA' }); + }); + + test('throws when the Safe version is below 1.4.1', async () => { + const account = new SafeAccountV0_3_0(ACCOUNT); + await expect( + account.createMigrateToSafeMultiChainSigAccountV1MetaTransactions( + safeMock({ version: '1.3.0' }), + ), + ).rejects.toMatchObject({ code: 'BAD_DATA' }); + }); + + test('skipPreflight bypasses all checks (no storage read, builds anyway)', async () => { + // A mock that would FAIL preflight (wrong fallback) still produces a batch + // and never reads the fallback-handler slot. + const transport = safeMock({ fallback: '0x00000000000000000000000000000000000000cc' }); + const account = new SafeAccountV0_3_0(ACCOUNT); + const batch = await account.createMigrateToSafeMultiChainSigAccountV1MetaTransactions( + transport, + { prevModuleAddress: SENTINEL, skipPreflight: true }, + ); + expect(batch).toHaveLength(3); + expect(transport.calls.some((c) => c.method === 'eth_getStorageAt')).toBe(false); + }); + + test('normalizes a raw error from the module-enabled check to BAD_DATA', async () => { + // fallback handler passes, but the isModuleEnabled eth_call reverts. + const transport = mockTransport(({ method, params }) => { + if (method === 'eth_getStorageAt') return leftPadAddress(V07_MODULE); + if (method === 'eth_call' && params[0].data.startsWith(IS_MODULE_ENABLED_SELECTOR)) { + throw new Error('execution reverted'); + } + return AbiCoder.defaultAbiCoder().encode(['address[]', 'address'], [[V07_MODULE], SENTINEL]); + }); + const account = new SafeAccountV0_3_0(ACCOUNT); + await expect( + account.createMigrateToSafeMultiChainSigAccountV1MetaTransactions(transport), + ).rejects.toMatchObject({ name: 'AbstractionKitError', code: 'BAD_DATA' }); + }); + + test('normalizes a raw error from the VERSION() read to BAD_DATA', async () => { + const transport = mockTransport(({ method, params }) => { + if (method === 'eth_getStorageAt') return leftPadAddress(V07_MODULE); + if (method === 'eth_call') { + if (params[0].data.startsWith(VERSION_SELECTOR)) throw new Error('execution reverted'); + if (params[0].data.startsWith(IS_MODULE_ENABLED_SELECTOR)) { + return AbiCoder.defaultAbiCoder().encode(['bool'], [true]); + } + } + return AbiCoder.defaultAbiCoder().encode(['address[]', 'address'], [[V07_MODULE], SENTINEL]); + }); + const account = new SafeAccountV0_3_0(ACCOUNT); + await expect( + account.createMigrateToSafeMultiChainSigAccountV1MetaTransactions(transport), + ).rejects.toMatchObject({ name: 'AbstractionKitError', code: 'BAD_DATA' }); + }); + + test('treats an empty VERSION() string as a failed preflight', async () => { + const account = new SafeAccountV0_3_0(ACCOUNT); + await expect( + account.createMigrateToSafeMultiChainSigAccountV1MetaTransactions( + safeMock({ version: '' }), + ), + ).rejects.toMatchObject({ code: 'BAD_DATA' }); + }); +}); + +describe('SafeAccount readers', () => { + test('getFallbackHandler returns a checksummed address from the slot', async () => { + const transport = mockTransport(({ method, params }) => { + expect(method).toBe('eth_getStorageAt'); + expect(params[0]).toBe(ACCOUNT); + expect(params[1]).toBe(SAFE_FALLBACK_HANDLER_STORAGE_SLOT); + return leftPadAddress(V09_MODULE); + }); + const account = new SafeAccountV0_3_0(ACCOUNT); + expect(await account.getFallbackHandler(transport)).toBe(getAddress(V09_MODULE)); + }); + + test('getSafeVersion decodes the VERSION() string', async () => { + const transport = mockTransport(({ method, params }) => { + expect(method).toBe('eth_call'); + expect(params[0].data.startsWith(VERSION_SELECTOR)).toBe(true); + return AbiCoder.defaultAbiCoder().encode(['string'], ['1.4.1']); + }); + const account = new SafeAccountV0_3_0(ACCOUNT); + expect(await account.getSafeVersion(transport)).toBe('1.4.1'); + }); +}); diff --git a/test/transport/JsonRpcNode.test.js b/test/transport/JsonRpcNode.test.js index ea05eb2..c33e308 100644 --- a/test/transport/JsonRpcNode.test.js +++ b/test/transport/JsonRpcNode.test.js @@ -95,6 +95,36 @@ describe("JsonRpcNode", () => { expect(typeof code).toBe("string"); }); + test("getStorageAt() sends eth_getStorageAt with the expected params", async () => { + const slot = "0x6c9a6c4a39284e37ed1cf53d337577d14212a4870fb976a4366c693b939918d5"; + const mock = tx(({ method, params }) => { + expect(method).toBe("eth_getStorageAt"); + expect(params).toEqual(["0xdead", slot, "latest"]); + return "0x" + "0".repeat(24) + "22939e839e3c0f479b713eaf95e0df128554aead"; + }); + const node = new ak.JsonRpcNode(mock); + const word = await node.getStorageAt("0xdead", slot); + expect(typeof word).toBe("string"); + expect(word.endsWith("22939e839e3c0f479b713eaf95e0df128554aead")).toBe(true); + }); + + test("getStorageAt() forwards an explicit block tag", async () => { + const mock = tx(({ params }) => { + expect(params[2]).toBe("0x10"); + return "0x" + "0".repeat(64); + }); + const node = new ak.JsonRpcNode(mock); + await node.getStorageAt("0xdead", "0x0", "0x10"); + }); + + test("getStorageAt() throws BAD_DATA when the transport returns a non-string", async () => { + const mock = tx(() => 12345); + const node = new ak.JsonRpcNode(mock); + await expect(node.getStorageAt("0xdead", "0x0")).rejects.toMatchObject({ + code: "BAD_DATA", + }); + }); + test("call() sends eth_call without state overrides", async () => { const mock = tx(({ method, params }) => { expect(method).toBe("eth_call");