From 5c2f528c2a4819a96ec78f84e527568ebdda6e76 Mon Sep 17 00:00:00 2001 From: Benjamin Smith Date: Tue, 17 Sep 2024 12:45:08 +0200 Subject: [PATCH] Migrate SafePack to Viem (#53) We no longer require ethers in the lib/utils directory. This is a move toward a bit more type safety, consistency and detanglement from the network client and the contract instances. Last step to remove ethers entirely will be to replace the ethers.JsonRpcProvider with PublicClient in lib/bundler. Then we can move ethers to a dev dependency and use it for unit testing our encodings. --- examples/send-tx.ts | 7 +- src/lib/bundler.ts | 4 +- src/lib/safe.ts | 287 ++++++++++++++++++++++++----------------- src/tx-manager.ts | 46 ++++--- src/util.ts | 16 ++- tests/ethers-safe.ts | 234 +++++++++++++++++++++++++++++++++ tests/lib.safe.spec.ts | 68 ++++++++++ tests/utils.spec.ts | 6 +- 8 files changed, 518 insertions(+), 150 deletions(-) create mode 100644 tests/ethers-safe.ts create mode 100644 tests/lib.safe.spec.ts diff --git a/examples/send-tx.ts b/examples/send-tx.ts index 4dbf114..f0ff075 100644 --- a/examples/send-tx.ts +++ b/examples/send-tx.ts @@ -1,5 +1,6 @@ import dotenv from "dotenv"; import { ethers } from "ethers"; +import { isAddress } from "viem"; import { loadArgs, loadEnv } from "./cli"; import { TransactionManager } from "../src"; @@ -29,7 +30,11 @@ async function main(): Promise { }, ]; // Add Recovery if safe not deployed & recoveryAddress was provided. - if (!(await txManager.safeDeployed(chainId)) && recoveryAddress) { + if ( + !(await txManager.safeDeployed(chainId)) && + recoveryAddress && + isAddress(recoveryAddress) + ) { const recoveryTx = txManager.addOwnerTx(recoveryAddress); // This would happen (sequentially) after the userTx, but all executed in a single transactions.push(recoveryTx); diff --git a/src/lib/bundler.ts b/src/lib/bundler.ts index 9637e9c..65cd44b 100644 --- a/src/lib/bundler.ts +++ b/src/lib/bundler.ts @@ -25,7 +25,9 @@ export class Erc4337Bundler { this.entryPointAddress = entryPointAddress; this.apiKey = apiKey; this.chainId = chainId; - this.provider = new ethers.JsonRpcProvider(bundlerUrl(chainId, this.apiKey)); + this.provider = new ethers.JsonRpcProvider( + bundlerUrl(chainId, this.apiKey) + ); } async getPaymasterData( diff --git a/src/lib/safe.ts b/src/lib/safe.ts index 5821956..078b552 100644 --- a/src/lib/safe.ts +++ b/src/lib/safe.ts @@ -6,8 +6,20 @@ import { getSafe4337ModuleDeployment, getSafeModuleSetupDeployment, } from "@safe-global/safe-modules-deployments"; -import { ethers } from "ethers"; -import { Address, Hash, Hex } from "viem"; +import { + Address, + encodeFunctionData, + encodePacked, + getCreate2Address, + Hash, + Hex, + keccak256, + ParseAbi, + parseAbi, + PublicClient, + toHex, + zeroAddress, +} from "viem"; import { GasPrice, @@ -15,28 +27,39 @@ import { UnsignedUserOperation, UserOperation, } from "../types"; -import { PLACEHOLDER_SIG, packGas, packPaymasterData } from "../util"; +import { + PLACEHOLDER_SIG, + getClient, + packGas, + packPaymasterData, +} from "../util"; + +interface DeploymentData { + abi: unknown[] | ParseAbi; + address: `0x${string}`; +} /** * All contracts used in account creation & execution */ export class ContractSuite { - provider: ethers.JsonRpcProvider; - singleton: ethers.Contract; - proxyFactory: ethers.Contract; - m4337: ethers.Contract; - moduleSetup: ethers.Contract; - entryPoint: ethers.Contract; + // Used only for stateless contract reads. + dummyClient: PublicClient; + singleton: DeploymentData; + proxyFactory: DeploymentData; + m4337: DeploymentData; + moduleSetup: DeploymentData; + entryPoint: DeploymentData; constructor( - provider: ethers.JsonRpcProvider, - singleton: ethers.Contract, - proxyFactory: ethers.Contract, - m4337: ethers.Contract, - moduleSetup: ethers.Contract, - entryPoint: ethers.Contract + client: PublicClient, + singleton: DeploymentData, + proxyFactory: DeploymentData, + m4337: DeploymentData, + moduleSetup: DeploymentData, + entryPoint: DeploymentData ) { - this.provider = provider; + this.dummyClient = client; this.singleton = singleton; this.proxyFactory = proxyFactory; this.m4337 = m4337; @@ -46,89 +69,106 @@ export class ContractSuite { static async init(): Promise { // TODO - this is a cheeky hack. - const provider = new ethers.JsonRpcProvider("https://rpc2.sepolia.org"); - const safeDeployment = (fn: DeploymentFunction): Promise => - getDeployment(fn, { provider, version: "1.4.1" }); + const client = getClient(11155111); + const safeDeployment = (fn: DeploymentFunction): Promise => + getDeployment(fn, { version: "1.4.1" }); const m4337Deployment = async ( fn: DeploymentFunction - ): Promise => { - return getDeployment(fn, { provider, version: "0.3.0" }); + ): Promise => { + return getDeployment(fn, { version: "0.3.0" }); }; - // Need this first to get entryPoint address - const m4337 = await m4337Deployment(getSafe4337ModuleDeployment); - - const [singleton, proxyFactory, moduleSetup, supportedEntryPoint] = - await Promise.all([ - safeDeployment(getSafeL2SingletonDeployment), - safeDeployment(getProxyFactoryDeployment), - m4337Deployment(getSafeModuleSetupDeployment), - m4337.SUPPORTED_ENTRYPOINT(), - ]); - const entryPoint = new ethers.Contract( - supportedEntryPoint, - ["function getNonce(address, uint192 key) view returns (uint256 nonce)"], - provider - ); - console.log("Initialized ERC4337 & Safe Module Contracts:", { - singleton: await singleton.getAddress(), - proxyFactory: await proxyFactory.getAddress(), - m4337: await m4337.getAddress(), - moduleSetup: await moduleSetup.getAddress(), - entryPoint: await entryPoint.getAddress(), - }); + + const [singleton, proxyFactory, moduleSetup, m4337] = await Promise.all([ + safeDeployment(getSafeL2SingletonDeployment), + safeDeployment(getProxyFactoryDeployment), + m4337Deployment(getSafeModuleSetupDeployment), + m4337Deployment(getSafe4337ModuleDeployment), + ]); + + // console.log("Initialized ERC4337 & Safe Module Contracts:", { + // singleton: await singleton.getAddress(), + // proxyFactory: await proxyFactory.getAddress(), + // m4337: await m4337.getAddress(), + // moduleSetup: await moduleSetup.getAddress(), + // entryPoint: await entryPoint.getAddress(), + // }); return new ContractSuite( - provider, + client, singleton, proxyFactory, m4337, moduleSetup, - entryPoint + // EntryPoint: + { + address: (await client.readContract({ + address: m4337.address, + abi: m4337.abi, + functionName: "SUPPORTED_ENTRYPOINT", + })) as Address, + abi: parseAbi([ + "function getNonce(address, uint192 key) view returns (uint256 nonce)", + ]), + } ); } - async addressForSetup( - setup: ethers.BytesLike, - saltNonce?: string - ): Promise
{ + async addressForSetup(setup: Hex, saltNonce?: string): Promise
{ // bytes32 salt = keccak256(abi.encodePacked(keccak256(initializer), saltNonce)); // cf: https://github.com/safe-global/safe-smart-account/blob/499b17ad0191b575fcadc5cb5b8e3faeae5391ae/contracts/proxies/SafeProxyFactory.sol#L58 - const salt = ethers.keccak256( - ethers.solidityPacked( + const salt = keccak256( + encodePacked( ["bytes32", "uint256"], - [ethers.keccak256(setup), saltNonce || 0] + [keccak256(setup), BigInt(saltNonce || "0")] ) ); // abi.encodePacked(type(SafeProxy).creationCode, uint256(uint160(_singleton))); // cf: https://github.com/safe-global/safe-smart-account/blob/499b17ad0191b575fcadc5cb5b8e3faeae5391ae/contracts/proxies/SafeProxyFactory.sol#L29 - const initCode = ethers.solidityPacked( + const initCode = encodePacked( ["bytes", "uint256"], [ - await this.proxyFactory.proxyCreationCode(), - await this.singleton.getAddress(), + (await this.dummyClient.readContract({ + address: this.proxyFactory.address, + abi: this.proxyFactory.abi, + functionName: "proxyCreationCode", + })) as Hex, + BigInt(this.singleton.address), ] ); - return ethers.getCreate2Address( - await this.proxyFactory.getAddress(), + return getCreate2Address({ + from: this.proxyFactory.address, salt, - ethers.keccak256(initCode) - ) as Address; + bytecodeHash: keccak256(initCode), + }); } - async getSetup(owners: string[]): Promise { - const setup = await this.singleton.interface.encodeFunctionData("setup", [ - owners, - 1, // We use sign threshold of 1. - this.moduleSetup.target, - this.moduleSetup.interface.encodeFunctionData("enableModules", [ - [this.m4337.target], - ]), - this.m4337.target, - ethers.ZeroAddress, - 0, - ethers.ZeroAddress, - ]); - return setup as Hex; + getSetup(owners: string[]): Hex { + return encodeFunctionData({ + abi: this.singleton.abi, + functionName: "setup", + args: [ + owners, + 1, // We use sign threshold of 1. + this.moduleSetup.address, + encodeFunctionData({ + abi: this.moduleSetup.abi, + functionName: "enableModules", + args: [[this.m4337.address]], + }), + this.m4337.address, + zeroAddress, + 0, + zeroAddress, + ], + }); + } + + addOwnerData(newOwner: Address): Hex { + return encodeFunctionData({ + abi: this.singleton.abi, + functionName: "addOwnerWithThreshold", + args: [newOwner, 1], + }); } async getOpHash(unsignedUserOp: UserOperation): Promise { @@ -140,35 +180,45 @@ export class ContractSuite { maxPriorityFeePerGas, maxFeePerGas, } = unsignedUserOp; - return this.m4337.getOperationHash({ - ...unsignedUserOp, - initCode: factory - ? ethers.solidityPacked(["address", "bytes"], [factory, factoryData]) - : "0x", - accountGasLimits: packGas(verificationGasLimit, callGasLimit), - gasFees: packGas(maxPriorityFeePerGas, maxFeePerGas), - paymasterAndData: packPaymasterData(unsignedUserOp), - signature: PLACEHOLDER_SIG, + const opHash = await this.dummyClient.readContract({ + address: this.m4337.address, + abi: this.m4337.abi, + functionName: "getOperationHash", + args: [ + { + ...unsignedUserOp, + initCode: factory + ? encodePacked(["address", "bytes"], [factory, factoryData!]) + : "0x", + accountGasLimits: packGas(verificationGasLimit, callGasLimit), + gasFees: packGas(maxPriorityFeePerGas, maxFeePerGas), + paymasterAndData: packPaymasterData(unsignedUserOp), + signature: PLACEHOLDER_SIG, + }, + ], }); + return opHash as Hash; } - factoryDataForSetup( + private factoryDataForSetup( safeNotDeployed: boolean, setup: string, safeSaltNonce: string ): { factory?: Address; factoryData?: Hex } { return safeNotDeployed ? { - factory: this.proxyFactory.target as Address, - factoryData: this.proxyFactory.interface.encodeFunctionData( - "createProxyWithNonce", - [this.singleton.target, setup, safeSaltNonce] - ) as Hex, + factory: this.proxyFactory.address, + factoryData: encodeFunctionData({ + abi: this.proxyFactory.abi, + functionName: "createProxyWithNonce", + args: [this.singleton.address, setup, safeSaltNonce], + }), } : {}; } async buildUserOp( + nonce: bigint, txData: MetaTransaction, safeAddress: Address, feeData: GasPrice, @@ -176,20 +226,33 @@ export class ContractSuite { safeNotDeployed: boolean, safeSaltNonce: string ): Promise { - const rawUserOp = { + return { sender: safeAddress, - nonce: ethers.toBeHex(await this.entryPoint.getNonce(safeAddress, 0)), + nonce: toHex(nonce), ...this.factoryDataForSetup(safeNotDeployed, setup, safeSaltNonce), // - callData: this.m4337.interface.encodeFunctionData("executeUserOp", [ - txData.to, - BigInt(txData.value), - txData.data, - txData.operation || 0, - ]) as Hex, + callData: encodeFunctionData({ + abi: this.m4337.abi, + functionName: "executeUserOp", + args: [ + txData.to, + BigInt(txData.value), + txData.data, + txData.operation || 0, + ], + }), ...feeData, }; - return rawUserOp; + } + + async getNonce(address: Address, chainId: number): Promise { + const nonce = (await getClient(chainId).readContract({ + abi: this.entryPoint.abi, + address: this.entryPoint.address, + functionName: "getNonce", + args: [address, 0], + })) as bigint; + return nonce; } } @@ -198,31 +261,19 @@ type DeploymentFunction = (filter?: { }) => | { networkAddresses: { [chainId: string]: string }; abi: unknown[] } | undefined; -type DeploymentArgs = { provider: ethers.JsonRpcProvider; version: string }; +type DeploymentArgs = { version: string }; async function getDeployment( fn: DeploymentFunction, - { provider, version }: DeploymentArgs -): Promise { - const { chainId } = await provider.getNetwork(); + { version }: DeploymentArgs +): Promise { const deployment = fn({ version }); if (!deployment) { - throw new Error( - `Deployment not found for ${fn.name} version ${version} on chainId ${chainId}` - ); - } - let address = deployment.networkAddresses[`${chainId}`]; - if (!address) { - // console.warn( - // `Deployment asset ${fn.name} not listed on chainId ${chainId}, using likely fallback. For more info visit https://github.com/safe-global/safe-modules-deployments` - // ); - // TODO: This is a cheeky hack. Real solution proposed in - // https://github.com/Mintbase/near-safe/issues/42 - address = deployment.networkAddresses["11155111"]; + throw new Error(`Deployment not found for ${fn.name} version ${version}`); } - return new ethers.Contract( - address, - deployment.abi as ethers.Fragment[], - provider - ); + // TODO: maybe call parseAbi on deployment.abi here. + return { + address: deployment.networkAddresses["11155111"] as Address, + abi: deployment.abi, + }; } diff --git a/src/tx-manager.ts b/src/tx-manager.ts index 1685247..a28a439 100644 --- a/src/tx-manager.ts +++ b/src/tx-manager.ts @@ -3,7 +3,6 @@ import { NearEthAdapter, NearEthTxData, BaseTx, - Network, setupAdapter, signatureFromOutcome, } from "near-ca"; @@ -13,12 +12,11 @@ import { Erc4337Bundler } from "./lib/bundler"; import { encodeMulti } from "./lib/multisend"; import { ContractSuite } from "./lib/safe"; import { MetaTransaction, UserOperation, UserOperationReceipt } from "./types"; -import { isContract, packSignature } from "./util"; +import { getClient, isContract, packSignature } from "./util"; export class TransactionManager { readonly nearAdapter: NearEthAdapter; readonly address: Address; - readonly entryPointAddress: Address; private safePack: ContractSuite; private setup: string; @@ -32,13 +30,11 @@ export class TransactionManager { pimlicoKey: string, setup: string, safeAddress: Address, - entryPointAddress: Address, safeSaltNonce: string ) { this.nearAdapter = nearAdapter; this.safePack = safePack; this.pimlicoKey = pimlicoKey; - this.entryPointAddress = entryPointAddress; this.setup = setup; this.address = safeAddress; this.safeSaltNonce = safeSaltNonce; @@ -60,13 +56,11 @@ export class TransactionManager { console.log( `Near Adapter: ${nearAdapter.nearAccountId()} <> ${nearAdapter.address}` ); - const setup = await safePack.getSetup([nearAdapter.address]); + const setup = safePack.getSetup([nearAdapter.address]); const safeAddress = await safePack.addressForSetup( setup, config.safeSaltNonce ); - const entryPointAddress = - (await safePack.entryPoint.getAddress()) as Address; console.log(`Safe Address: ${safeAddress}`); return new TransactionManager( nearAdapter, @@ -74,7 +68,6 @@ export class TransactionManager { pimlicoKey, setup, safeAddress, - entryPointAddress, config.safeSaltNonce || "0" ); } @@ -84,12 +77,15 @@ export class TransactionManager { } async getBalance(chainId: number): Promise { - const provider = Network.fromChainId(chainId).client; - return await provider.getBalance({ address: this.address }); + return await getClient(chainId).getBalance({ address: this.address }); } bundlerForChainId(chainId: number): Erc4337Bundler { - return new Erc4337Bundler(this.entryPointAddress, this.pimlicoKey, chainId); + return new Erc4337Bundler( + this.safePack.entryPoint.address, + this.pimlicoKey, + chainId + ); } async buildTransaction(args: { @@ -98,28 +94,33 @@ export class TransactionManager { usePaymaster: boolean; }): Promise { const { transactions, usePaymaster, chainId } = args; - const bundler = this.bundlerForChainId(chainId); - const gasFees = (await bundler.getGasPrice()).fast; - // Build Singular MetaTransaction for Multisend from transaction list. if (transactions.length === 0) { throw new Error("Empty transaction set!"); } + const bundler = this.bundlerForChainId(chainId); + const [gasFees, nonce, safeDeployed] = await Promise.all([ + bundler.getGasPrice(), + this.safePack.getNonce(this.address, chainId), + this.safeDeployed(chainId), + ]); + // Build Singular MetaTransaction for Multisend from transaction list. const tx = transactions.length > 1 ? encodeMulti(transactions) : transactions[0]!; - const safeNotDeployed = !(await this.safeDeployed(chainId)); + const rawUserOp = await this.safePack.buildUserOp( + nonce, tx, this.address, - gasFees, + gasFees.fast, this.setup, - safeNotDeployed, + !safeDeployed, this.safeSaltNonce ); const paymasterData = await bundler.getPaymasterData( rawUserOp, usePaymaster, - safeNotDeployed + !safeDeployed ); const unsignedUserOp = { ...rawUserOp, ...paymasterData }; @@ -188,14 +189,11 @@ export class TransactionManager { return deployed; } - addOwnerTx(address: string): MetaTransaction { + addOwnerTx(address: Address): MetaTransaction { return { to: this.address, value: "0", - data: this.safePack.singleton.interface.encodeFunctionData( - "addOwnerWithThreshold", - [address, 1] - ), + data: this.safePack.addOwnerData(address), }; } diff --git a/src/util.ts b/src/util.ts index 62dfa9f..2a0055c 100644 --- a/src/util.ts +++ b/src/util.ts @@ -1,5 +1,12 @@ import { Network } from "near-ca"; -import { Address, Hex, concatHex, encodePacked, toHex } from "viem"; +import { + Address, + Hex, + concatHex, + encodePacked, + toHex, + PublicClient, +} from "viem"; import { PaymasterData, MetaTransaction } from "./types"; @@ -42,6 +49,9 @@ export async function isContract( address: Address, chainId: number ): Promise { - const client = Network.fromChainId(chainId).client; - return !!(await client.getCode({ address })); + return (await getClient(chainId).getCode({ address })) !== undefined; +} + +export function getClient(chainId: number): PublicClient { + return Network.fromChainId(chainId).client; } diff --git a/tests/ethers-safe.ts b/tests/ethers-safe.ts new file mode 100644 index 0000000..3e37b94 --- /dev/null +++ b/tests/ethers-safe.ts @@ -0,0 +1,234 @@ +import { + getProxyFactoryDeployment, + getSafeL2SingletonDeployment, +} from "@safe-global/safe-deployments"; +import { + getSafe4337ModuleDeployment, + getSafeModuleSetupDeployment, +} from "@safe-global/safe-modules-deployments"; +import { ethers } from "ethers"; +import { Address, Hash, Hex } from "viem"; + +import { + GasPrice, + MetaTransaction, + UnsignedUserOperation, + UserOperation, +} from "../src/types"; +import { PLACEHOLDER_SIG, packGas, packPaymasterData } from "../src/util"; + +/** + * All contracts used in account creation & execution + */ +export class ContractSuite { + provider: ethers.JsonRpcProvider; + singleton: ethers.Contract; + proxyFactory: ethers.Contract; + m4337: ethers.Contract; + moduleSetup: ethers.Contract; + entryPoint: ethers.Contract; + + constructor( + provider: ethers.JsonRpcProvider, + singleton: ethers.Contract, + proxyFactory: ethers.Contract, + m4337: ethers.Contract, + moduleSetup: ethers.Contract, + entryPoint: ethers.Contract + ) { + this.provider = provider; + this.singleton = singleton; + this.proxyFactory = proxyFactory; + this.m4337 = m4337; + this.moduleSetup = moduleSetup; + this.entryPoint = entryPoint; + } + + static async init(): Promise { + // TODO - this is a cheeky hack. + const provider = new ethers.JsonRpcProvider("https://rpc2.sepolia.org"); + const safeDeployment = (fn: DeploymentFunction): Promise => + getDeployment(fn, { provider, version: "1.4.1" }); + const m4337Deployment = async ( + fn: DeploymentFunction + ): Promise => { + return getDeployment(fn, { provider, version: "0.3.0" }); + }; + // Need this first to get entryPoint address + const m4337 = await m4337Deployment(getSafe4337ModuleDeployment); + + const [singleton, proxyFactory, moduleSetup, supportedEntryPoint] = + await Promise.all([ + safeDeployment(getSafeL2SingletonDeployment), + safeDeployment(getProxyFactoryDeployment), + m4337Deployment(getSafeModuleSetupDeployment), + m4337.SUPPORTED_ENTRYPOINT(), + ]); + const entryPoint = new ethers.Contract( + supportedEntryPoint, + ["function getNonce(address, uint192 key) view returns (uint256 nonce)"], + provider + ); + console.log("Initialized ERC4337 & Safe Module Contracts:", { + singleton: await singleton.getAddress(), + proxyFactory: await proxyFactory.getAddress(), + m4337: await m4337.getAddress(), + moduleSetup: await moduleSetup.getAddress(), + entryPoint: await entryPoint.getAddress(), + }); + return new ContractSuite( + provider, + singleton, + proxyFactory, + m4337, + moduleSetup, + entryPoint + ); + } + + async addressForSetup( + setup: ethers.BytesLike, + saltNonce?: string + ): Promise
{ + // bytes32 salt = keccak256(abi.encodePacked(keccak256(initializer), saltNonce)); + // cf: https://github.com/safe-global/safe-smart-account/blob/499b17ad0191b575fcadc5cb5b8e3faeae5391ae/contracts/proxies/SafeProxyFactory.sol#L58 + const salt = ethers.keccak256( + ethers.solidityPacked( + ["bytes32", "uint256"], + [ethers.keccak256(setup), saltNonce || 0] + ) + ); + + // abi.encodePacked(type(SafeProxy).creationCode, uint256(uint160(_singleton))); + // cf: https://github.com/safe-global/safe-smart-account/blob/499b17ad0191b575fcadc5cb5b8e3faeae5391ae/contracts/proxies/SafeProxyFactory.sol#L29 + const initCode = ethers.solidityPacked( + ["bytes", "uint256"], + [ + await this.proxyFactory.proxyCreationCode(), + await this.singleton.getAddress(), + ] + ); + return ethers.getCreate2Address( + await this.proxyFactory.getAddress(), + salt, + ethers.keccak256(initCode) + ) as Address; + } + + getSetup(owners: string[]): Hex { + return this.singleton.interface.encodeFunctionData("setup", [ + owners, + 1, // We use sign threshold of 1. + this.moduleSetup.target, + this.moduleSetup.interface.encodeFunctionData("enableModules", [ + [this.m4337.target], + ]), + this.m4337.target, + ethers.ZeroAddress, + 0, + ethers.ZeroAddress, + ]) as Hex; + } + + addOwnerData(newOwner: Address): Hex { + return this.singleton.interface.encodeFunctionData( + "addOwnerWithThreshold", + [newOwner, 1] + ) as Hex; + } + + async getOpHash(unsignedUserOp: UserOperation): Promise { + const { + factory, + factoryData, + verificationGasLimit, + callGasLimit, + maxPriorityFeePerGas, + maxFeePerGas, + } = unsignedUserOp; + return this.m4337.getOperationHash({ + ...unsignedUserOp, + initCode: factory + ? ethers.solidityPacked(["address", "bytes"], [factory, factoryData]) + : "0x", + accountGasLimits: packGas(verificationGasLimit, callGasLimit), + gasFees: packGas(maxPriorityFeePerGas, maxFeePerGas), + paymasterAndData: packPaymasterData(unsignedUserOp), + signature: PLACEHOLDER_SIG, + }); + } + + factoryDataForSetup( + safeNotDeployed: boolean, + setup: string, + safeSaltNonce: string + ): { factory?: Address; factoryData?: Hex } { + return safeNotDeployed + ? { + factory: this.proxyFactory.target as Address, + factoryData: this.proxyFactory.interface.encodeFunctionData( + "createProxyWithNonce", + [this.singleton.target, setup, safeSaltNonce] + ) as Hex, + } + : {}; + } + + async buildUserOp( + txData: MetaTransaction, + safeAddress: Address, + feeData: GasPrice, + setup: string, + safeNotDeployed: boolean, + safeSaltNonce: string + ): Promise { + const rawUserOp = { + sender: safeAddress, + nonce: ethers.toBeHex(await this.entryPoint.getNonce(safeAddress, 0)), + ...this.factoryDataForSetup(safeNotDeployed, setup, safeSaltNonce), + // + callData: this.m4337.interface.encodeFunctionData("executeUserOp", [ + txData.to, + BigInt(txData.value), + txData.data, + txData.operation || 0, + ]) as Hex, + ...feeData, + }; + return rawUserOp; + } +} + +type DeploymentFunction = (filter?: { + version: string; +}) => + | { networkAddresses: { [chainId: string]: string }; abi: unknown[] } + | undefined; +type DeploymentArgs = { provider: ethers.JsonRpcProvider; version: string }; + +async function getDeployment( + fn: DeploymentFunction, + { provider, version }: DeploymentArgs +): Promise { + const { chainId } = await provider.getNetwork(); + const deployment = fn({ version }); + if (!deployment) { + throw new Error( + `Deployment not found for ${fn.name} version ${version} on chainId ${chainId}` + ); + } + let address = deployment.networkAddresses[`${chainId}`]; + if (!address) { + // console.warn( + // `Deployment asset ${fn.name} not listed on chainId ${chainId}, using likely fallback. For more info visit https://github.com/safe-global/safe-modules-deployments` + // ); + // TODO: This is a cheeky hack. Real solution proposed in + // https://github.com/Mintbase/near-safe/issues/42 + address = deployment.networkAddresses["11155111"]; + } + return new ethers.Contract( + address, + deployment.abi as ethers.Fragment[], + provider + ); +} diff --git a/tests/lib.safe.spec.ts b/tests/lib.safe.spec.ts new file mode 100644 index 0000000..0650543 --- /dev/null +++ b/tests/lib.safe.spec.ts @@ -0,0 +1,68 @@ +import { zeroAddress } from "viem"; + +import { ContractSuite as EthPack } from "./ethers-safe"; +import { ContractSuite as ViemPack } from "../src/lib/safe"; + +describe("Safe Pack", () => { + let ethersPack: EthPack; + let viemPack: ViemPack; + beforeAll(async () => { + ethersPack = await EthPack.init(); + viemPack = await ViemPack.init(); + }); + + it("init", async () => { + expect(ethersPack.singleton.target).toEqual(viemPack.singleton.address); + expect(await ethersPack.singleton.getAddress()).toEqual( + viemPack.singleton.address + ); + + expect(ethersPack.m4337.target).toEqual(viemPack.m4337.address); + expect(await ethersPack.m4337.getAddress()).toEqual(viemPack.m4337.address); + + expect(ethersPack.moduleSetup.target).toEqual(viemPack.moduleSetup.address); + expect(await ethersPack.moduleSetup.getAddress()).toEqual( + viemPack.moduleSetup.address + ); + + expect(ethersPack.moduleSetup.target).toEqual(viemPack.moduleSetup.address); + expect(await ethersPack.moduleSetup.getAddress()).toEqual( + viemPack.moduleSetup.address + ); + + expect(ethersPack.entryPoint.target).toEqual(viemPack.entryPoint.address); + expect(await ethersPack.entryPoint.getAddress()).toEqual( + viemPack.entryPoint.address + ); + + expect(ethersPack.proxyFactory.target).toEqual( + viemPack.proxyFactory.address + ); + expect(await ethersPack.proxyFactory.getAddress()).toEqual( + viemPack.proxyFactory.address + ); + }); + + it("addOwnerData", () => { + expect(ethersPack.addOwnerData(zeroAddress)).toEqual( + viemPack.addOwnerData(zeroAddress) + ); + }); + it("getSetup", () => { + expect(ethersPack.getSetup([zeroAddress])).toEqual( + viemPack.getSetup([zeroAddress]) + ); + }); + + it("getSetup", async () => { + const setup = viemPack.getSetup([zeroAddress]); + const [eps0, vps0, eps1, vps1] = await Promise.all([ + ethersPack.addressForSetup(setup), + viemPack.addressForSetup(setup), + ethersPack.addressForSetup(setup, "1"), + viemPack.addressForSetup(setup, "1"), + ]); + expect(eps0).toEqual(vps0); + expect(eps1).toEqual(vps1); + }); +}); diff --git a/tests/utils.spec.ts b/tests/utils.spec.ts index 3a7c91d..11a089c 100644 --- a/tests/utils.spec.ts +++ b/tests/utils.spec.ts @@ -68,11 +68,11 @@ describe("Utility Functions (mostly byte packing)", () => { expect(containsValue([VALUE_TX, NO_VALUE_TX])).toBe(true); }); - it("isContract", () => { + it("isContract", async () => { const chainId = 11155111; - expect(isContract(zeroAddress, chainId)).toBe(false); + expect(await isContract(zeroAddress, chainId)).toBe(false); expect( - isContract("0x9008D19f58AAbD9eD0D60971565AA8510560ab41", chainId) + await isContract("0x9008D19f58AAbD9eD0D60971565AA8510560ab41", chainId) ).toBe(true); }); });