Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/abstractionkit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ export {
ENTRYPOINT_V7,
ENTRYPOINT_V8,
ENTRYPOINT_V9,
SAFE_FALLBACK_HANDLER_STORAGE_SLOT,
ZeroAddress,
} from "./constants";
export { AbstractionKitError } from "./errors";
Expand Down
218 changes: 218 additions & 0 deletions src/account/Safe/SafeAccount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
ENTRYPOINT_V6,
ENTRYPOINT_V7,
ENTRYPOINT_V9,
SAFE_FALLBACK_HANDLER_STORAGE_SLOT,
Safe_L2_V1_4_1,
ZeroAddress,
} from "../../constants";
Expand Down Expand Up @@ -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<string> {
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<string> {
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
Expand Down Expand Up @@ -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<MetaTransaction[]> {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suggest verifying the safe version is compatible with the target Class

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will add a check for minimum version

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<void> {
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
Expand Down
34 changes: 8 additions & 26 deletions src/account/Safe/SafeAccountV0_2_0.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<MetaTransaction[]> {
const moduleV06Address =
Expand All @@ -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,
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
);

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,
];
}

/**
Expand Down
45 changes: 45 additions & 0 deletions src/account/Safe/SafeAccountV0_3_0.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<MetaTransaction[]> {
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,
},
);
}
}

/**
Expand Down
Loading
Loading