diff --git a/contracts/mocks/Imports.sol b/contracts/mocks/Imports.sol index d2a4197..3eb785e 100644 --- a/contracts/mocks/Imports.sol +++ b/contracts/mocks/Imports.sol @@ -4,3 +4,8 @@ pragma solidity ^0.8.24; /* solhint-disable reason-string */ import "account-abstraction/contracts/core/EntryPoint.sol"; +import "account-abstraction/contracts/core/EntryPointSimulations.sol"; + +import "@biconomy-devx/erc7579-msa/contracts/SmartAccount.sol"; +import "@biconomy-devx/erc7579-msa/contracts/factory/AccountFactory.sol"; + diff --git a/contracts/mocks/MockValidator.sol b/contracts/mocks/MockValidator.sol new file mode 100644 index 0000000..5f7bdd9 --- /dev/null +++ b/contracts/mocks/MockValidator.sol @@ -0,0 +1,3 @@ +pragma solidity ^0.8.24; + +import "@biconomy-devx/erc7579-msa/test/foundry/mocks/MockValidator.sol"; \ No newline at end of file diff --git a/package.json b/package.json index eaefe02..1227124 100644 --- a/package.json +++ b/package.json @@ -7,34 +7,40 @@ "url": "https://github.com/bcnmy" }, "dependencies": { + "@biconomy-devx/erc7579-msa": "^0.0.4", "@openzeppelin/contracts": "^5.0.1", "hardhat": "^2.20.1" }, "devDependencies": { "@bonadocs/docgen": "^1.0.1-alpha.1", + "@ethersproject/abstract-provider": "^5.7.0", "@nomicfoundation/hardhat-chai-matchers": "^2.0.6", "@nomicfoundation/hardhat-ethers": "^3.0.5", "@nomicfoundation/hardhat-foundry": "^1.1.1", "@nomicfoundation/hardhat-network-helpers": "^1.0.10", "@nomicfoundation/hardhat-toolbox": "^4.0.0", "@nomicfoundation/hardhat-verify": "^2.0.4", + "@nomiclabs/hardhat-ethers": "^2.2.3", "@prb/test": "^0.6.4", "@typechain/ethers-v6": "^0.5.1", "@typechain/hardhat": "^9.1.0", "@types/chai": "^4.3.11", "@types/mocha": ">=10.0.6", "@types/node": ">=20.11.19", + "account-abstraction": "github:eth-infinitism/account-abstraction#develop", "chai": "^4.3.7", "codecov": "^3.8.3", "ethers": "^6.11.1", "forge-std": "github:foundry-rs/forge-std#v1.7.6", - "modulekit": "github:rhinestonewtf/modulekit", - "solady": "github:vectorized/solady", - "account-abstraction": "github:eth-infinitism/account-abstraction#develop", + "hardhat-deploy": "^0.11.45", + "hardhat-deploy-ethers": "^0.4.1", "hardhat-gas-reporter": "^1.0.10", "hardhat-storage-layout": "^0.1.7", + "modulekit": "github:rhinestonewtf/modulekit", "prettier": "^3.2.5", "prettier-plugin-solidity": "^1.3.1", + "sentinellist": "github:zeroknots/sentinellist", + "solady": "github:vectorized/solady", "solhint": "^4.1.1", "solhint-plugin-prettier": "^0.1.0", "solidity-coverage": "^0.8.7", diff --git a/remappings.txt b/remappings.txt index e91ca09..af5268a 100644 --- a/remappings.txt +++ b/remappings.txt @@ -1,4 +1,7 @@ @openzeppelin/contracts/=node_modules/@openzeppelin/contracts/ @prb/test/=node_modules/@prb/test/ forge-std/=node_modules/forge-std/ -modulekit/=node_modules/modulekit/src/ \ No newline at end of file +account-abstraction=node_modules/account-abstraction/ +modulekit/=node_modules/modulekit/src/ +sentinellist/=node_modules/sentinellist/ +solady/=node_modules/solady \ No newline at end of file diff --git a/test/hardhat/Lock.ts b/test/hardhat/Lock.ts index 98693fe..8e49635 100644 --- a/test/hardhat/Lock.ts +++ b/test/hardhat/Lock.ts @@ -23,6 +23,9 @@ describe("Lock", function () { const Lock = await ethers.getContractFactory("Lock"); const lock = await Lock.deploy(unlockTime, { value: lockedAmount }); + const SmartAccount = await ethers.getContractFactory("SmartAccount"); + const smartAccount = await SmartAccount.deploy(); + return { lock, unlockTime, lockedAmount, owner, otherAccount }; } diff --git a/test/hardhat/biconomy-sponsorship-paymaster-specs.ts b/test/hardhat/biconomy-sponsorship-paymaster-specs.ts new file mode 100644 index 0000000..0db6c81 --- /dev/null +++ b/test/hardhat/biconomy-sponsorship-paymaster-specs.ts @@ -0,0 +1,166 @@ +import { ethers } from "hardhat"; +import { expect } from "chai"; +import { AbiCoder, AddressLike, BytesLike, Signer, parseEther, toBeHex } from "ethers"; +import { + EntryPoint, + EntryPoint__factory, + MockValidator, + MockValidator__factory, + SmartAccount, + SmartAccount__factory, + AccountFactory, + AccountFactory__factory, + BiconomySponsorshipPaymaster, + BiconomySponsorshipPaymaster__factory +} from "../../typechain-types"; + +import { DefaultsForUserOp, fillAndSign, fillSignAndPack, packUserOp, simulateValidation } from './utils/userOpHelpers' +import { parseValidationData } from "./utils/testUtils"; + + +export const AddressZero = ethers.ZeroAddress; + +const MOCK_VALID_UNTIL = "0x00000000deadbeef"; +const MOCK_VALID_AFTER = "0x0000000000001234"; +const MARKUP = 1100000; +export const ENTRY_POINT_V7 = "0x0000000071727De22E5E9d8BAf0edAc6f37da032"; + +const coder = AbiCoder.defaultAbiCoder() + +export async function deployEntryPoint( + provider = ethers.provider + ): Promise { + const epf = await (await ethers.getContractFactory("EntryPoint")).deploy(); + // Retrieve the deployed contract bytecode + const deployedCode = await ethers.provider.getCode( + await epf.getAddress(), + ); + + // Use hardhat_setCode to set the contract code at the specified address + await ethers.provider.send("hardhat_setCode", [ENTRY_POINT_V7, deployedCode]); + + return epf.attach(ENTRY_POINT_V7) as EntryPoint; +} + +describe("EntryPoint with Biconomy Sponsorship Paymaster", function () { + let entryPoint: EntryPoint; + let depositorSigner: Signer; + let walletOwner: Signer; + let walletAddress: string, paymasterAddress: string; + let paymasterDepositorId: string; + let ethersSigner: Signer[]; + let offchainSigner: Signer, deployer: Signer, feeCollector: Signer; + let paymaster: BiconomySponsorshipPaymaster; + let smartWalletImp: SmartAccount; + let ecdsaModule: MockValidator; + let walletFactory: AccountFactory; + + beforeEach(async function () { + ethersSigner = await ethers.getSigners(); + entryPoint = await deployEntryPoint(); + + deployer = ethersSigner[0]; + offchainSigner = ethersSigner[1]; + depositorSigner = ethersSigner[2]; + feeCollector = ethersSigner[3]; + walletOwner = deployer; + + paymasterDepositorId = await depositorSigner.getAddress(); + + const offchainSignerAddress = await offchainSigner.getAddress(); + const walletOwnerAddress = await walletOwner.getAddress(); + const feeCollectorAddess = await feeCollector.getAddress(); + + ecdsaModule = await new MockValidator__factory( + deployer + ).deploy(); + + paymaster = + await new BiconomySponsorshipPaymaster__factory(deployer).deploy( + await deployer.getAddress(), + await entryPoint.getAddress(), + offchainSignerAddress, + feeCollectorAddess + ); + + smartWalletImp = await new SmartAccount__factory( + deployer + ).deploy(); + + walletFactory = await new AccountFactory__factory(deployer).deploy( + await smartWalletImp.getAddress(), + ); + + await walletFactory + .connect(deployer) + .addStake( 86400, { value: parseEther("2") }); + + const smartAccountDeploymentIndex = 0; + + // Module initialization data, encoded + const moduleInstallData = ethers.solidityPacked(["address"], [walletOwnerAddress]); + + await walletFactory.createAccount( + await ecdsaModule.getAddress(), + moduleInstallData, + smartAccountDeploymentIndex + ); + + const expected = await walletFactory.getCounterFactualAddress( + await ecdsaModule.getAddress(), + moduleInstallData, + smartAccountDeploymentIndex + ); + + walletAddress = expected; + + paymasterAddress = await paymaster.getAddress(); + + await paymaster + .connect(deployer) + .addStake(86400, { value: parseEther("2") }); + + await paymaster.depositFor(paymasterDepositorId, { value: parseEther("1") }); + + await entryPoint.depositTo(paymasterAddress, { value: parseEther("1") }); + }); + + describe("#validatePaymasterUserOp and #sendSponsoredTx", () => { + it("succeed with valid signature", async () => { + const nonceKey = ethers.zeroPadBytes(await ecdsaModule.getAddress(), 24); + const userOp1 = await fillAndSign({ + sender: walletAddress, + paymaster: paymasterAddress, + paymasterData: ethers.concat([ + ethers.zeroPadValue(paymasterDepositorId, 20), + ethers.zeroPadValue(toBeHex(MOCK_VALID_UNTIL), 6), + ethers.zeroPadValue(toBeHex(MOCK_VALID_AFTER), 6), + ethers.zeroPadValue(toBeHex(MARKUP), 4), + '0x' + '00'.repeat(65) + ]) + }, walletOwner, entryPoint, 'getNonce', nonceKey) + const hash = await paymaster.getHash(packUserOp(userOp1), paymasterDepositorId, MOCK_VALID_UNTIL, MOCK_VALID_AFTER, MARKUP) + const sig = await offchainSigner.signMessage(ethers.getBytes(hash)) + const userOp = await fillSignAndPack({ + ...userOp1, + paymaster: paymasterAddress, + paymasterData: ethers.concat([ + ethers.zeroPadValue(paymasterDepositorId, 20), + ethers.zeroPadValue(toBeHex(MOCK_VALID_UNTIL), 6), + ethers.zeroPadValue(toBeHex(MOCK_VALID_AFTER), 6), + ethers.zeroPadValue(toBeHex(MARKUP), 4), + sig + ]) + }, walletOwner, entryPoint, 'getNonce', nonceKey) + console.log("userOp: ", userOp); + const res = await simulateValidation(userOp, await entryPoint.getAddress()) + const validationData = parseValidationData(res.returnInfo.paymasterValidationData) + expect(validationData).to.eql({ + aggregator: AddressZero, + validAfter: parseInt(MOCK_VALID_AFTER), + validUntil: parseInt(MOCK_VALID_UNTIL) + }) + }); + }); +}) + diff --git a/test/hardhat/utils/deployment.ts b/test/hardhat/utils/deployment.ts new file mode 100644 index 0000000..282831d --- /dev/null +++ b/test/hardhat/utils/deployment.ts @@ -0,0 +1,141 @@ +import { BytesLike, HDNodeWallet, Signer } from "ethers"; +import { deployments, ethers } from "hardhat"; +import { AccountFactory, BiconomySponsorshipPaymaster, EntryPoint, MockValidator, SmartAccount } from "../../../typechain-types"; +import { TASK_DEPLOY } from "hardhat-deploy"; +import { DeployResult } from "hardhat-deploy/dist/types"; + +export const ENTRY_POINT_V7 = "0x0000000071727De22E5E9d8BAf0edAc6f37da032"; + +/** + * Generic function to deploy a contract using ethers.js. + * + * @param contractName The name of the contract to deploy. + * @param deployer The Signer object representing the deployer account. + * @returns A promise that resolves to the deployed contract instance. + */ +export async function deployContract( + contractName: string, + deployer: Signer, + ): Promise { + const ContractFactory = await ethers.getContractFactory( + contractName, + deployer, + ); + const contract = await ContractFactory.deploy(); + await contract.waitForDeployment(); + return contract as T; +} + +/** + * Deploys the EntryPoint contract with a deterministic deployment. + * @returns A promise that resolves to the deployed EntryPoint contract instance. + */ +export async function getDeployedEntrypoint() : Promise { + const [deployer] = await ethers.getSigners(); + + // Deploy the contract normally to get its bytecode + const EntryPoint = await ethers.getContractFactory("EntryPoint"); + const entryPoint = await EntryPoint.deploy(); + await entryPoint.waitForDeployment(); + + // Retrieve the deployed contract bytecode + const deployedCode = await ethers.provider.getCode( + await entryPoint.getAddress(), + ); + + // Use hardhat_setCode to set the contract code at the specified address + await ethers.provider.send("hardhat_setCode", [ENTRY_POINT_V7, deployedCode]); + + return EntryPoint.attach(ENTRY_POINT_V7) as EntryPoint; +} + +/** + * Deploys the (MSA) Smart Account implementation contract with a deterministic deployment. + * @returns A promise that resolves to the deployed SA implementation contract instance. + */ +export async function getDeployedMSAImplementation(): Promise { + const accounts: Signer[] = await ethers.getSigners(); + const addresses = await Promise.all( + accounts.map((account) => account.getAddress()), + ); + + const SmartAccount = await ethers.getContractFactory("SmartAccount"); + const deterministicMSAImpl = await deployments.deploy("SmartAccount", { + from: addresses[0], + deterministicDeployment: true, + }); + + return SmartAccount.attach(deterministicMSAImpl.address) as SmartAccount; +} + +/** + * Deploys the AccountFactory contract with a deterministic deployment. + * @returns A promise that resolves to the deployed EntryPoint contract instance. + */ +export async function getDeployedAccountFactory( + implementationAddress: string, + // Note: this could be converted to dto so that additional args can easily be passed + ): Promise { + const accounts: Signer[] = await ethers.getSigners(); + const addresses = await Promise.all( + accounts.map((account) => account.getAddress()), + ); + + const AccountFactory = await ethers.getContractFactory("AccountFactory"); + const deterministicAccountFactory = await deployments.deploy( + "AccountFactory", + { + from: addresses[0], + deterministicDeployment: true, + args: [implementationAddress], + }, + ); + + return AccountFactory.attach( + deterministicAccountFactory.address, + ) as AccountFactory; +} + +/** + * Deploys the MockValidator contract with a deterministic deployment. + * @returns A promise that resolves to the deployed MockValidator contract instance. + */ +export async function getDeployedMockValidator(): Promise { + const accounts: Signer[] = await ethers.getSigners(); + const addresses = await Promise.all( + accounts.map((account) => account.getAddress()), + ); + + const MockValidator = await ethers.getContractFactory("MockValidator"); + const deterministicMockValidator = await deployments.deploy("MockValidator", { + from: addresses[0], + deterministicDeployment: true, + }); + + return MockValidator.attach( + deterministicMockValidator.address, + ) as MockValidator; +} + +/** + * Deploys the MockValidator contract with a deterministic deployment. + * @returns A promise that resolves to the deployed MockValidator contract instance. + */ +export async function getDeployedSponsorshipPaymaster(owner: string, entryPoint: string, verifyingSigner: string, feeCollector: string): Promise { + const accounts: Signer[] = await ethers.getSigners(); + const addresses = await Promise.all( + accounts.map((account) => account.getAddress()), + ); + + const BiconomySponsorshipPaymaster = await ethers.getContractFactory("BiconomySponsorshipPaymaster"); + const deterministicSponsorshipPaymaster = await deployments.deploy("BiconomySponsorshipPaymaster", { + from: addresses[0], + deterministicDeployment: true, + args: [owner, entryPoint, verifyingSigner, feeCollector], + }); + + return BiconomySponsorshipPaymaster.attach( + deterministicSponsorshipPaymaster.address, + ) as BiconomySponsorshipPaymaster; +} + diff --git a/test/hardhat/utils/general.ts b/test/hardhat/utils/general.ts new file mode 100644 index 0000000..7e9e596 --- /dev/null +++ b/test/hardhat/utils/general.ts @@ -0,0 +1,60 @@ +import { BigNumberish } from "ethers"; +import { ethers } from "hardhat"; + +/** + * Encodes data using the defaultAbiCoder from ethers.AbiCoder. + * @param types The types of the values being encoded. + * @param values The values to encode. + * @returns The encoded data. + */ +export function encodeData(types: string[], values: any[]): string { + return ethers.AbiCoder.defaultAbiCoder().encode(types, values); +} + +/** + * Converts a regular string to a bytes32 string. + * + * @param text The regular string to convert. + * @returns The converted bytes32 string. + */ +export const toBytes32 = (text: string): string => { + return ethers.encodeBytes32String(text); +}; + +/** + * Converts a bytes32 string to a regular string. + * + * @param bytes32 The bytes32 string to convert. + * @returns The converted regular string. + */ +export const fromBytes32 = (bytes32: string): string => { + return ethers.decodeBytes32String(bytes32); +}; + +/** + * Converts a numeric value to its equivalent in 18 decimal places. + * @param value The numeric value to convert. + * @returns The equivalent value in 18 decimal places as a bigint. + */ +export const to18 = (value: BigNumberish): bigint => { + return ethers.parseUnits(value.toString(), 18); +}; + +/** + * Converts a value from 18 decimal places to a string representation. + * + * @param value The value to convert. + * @returns The string representation of the converted value. + */ +export const from18 = (value: bigint): string => { + return ethers.formatUnits(value, 18); +}; + +/** + * Converts the given amount to Gwei. + * @param amount - The amount to convert. + * @returns The converted amount in Gwei. + */ +export function toGwei(amount: BigNumberish): BigNumberish { + return ethers.parseUnits(amount.toString(), "gwei"); +} diff --git a/test/hardhat/utils/testUtils.ts b/test/hardhat/utils/testUtils.ts new file mode 100644 index 0000000..06c4218 --- /dev/null +++ b/test/hardhat/utils/testUtils.ts @@ -0,0 +1,229 @@ +import { AbiCoder, AddressLike, BigNumberish, Contract, Interface, dataSlice, parseEther, toBeHex } from 'ethers'; +import { ethers } from 'hardhat' +import { EntryPoint__factory, IERC20 } from '../../../typechain-types'; + +// define mode and exec type enums +export const CALLTYPE_SINGLE = "0x00"; // 1 byte +export const CALLTYPE_BATCH = "0x01"; // 1 byte +export const EXECTYPE_DEFAULT = "0x00"; // 1 byte +export const EXECTYPE_TRY = "0x01"; // 1 byte +export const EXECTYPE_DELEGATE = "0xFF"; // 1 byte +export const MODE_DEFAULT = "0x00000000"; // 4 bytes +export const UNUSED = "0x00000000"; // 4 bytes +export const MODE_PAYLOAD = "0x00000000000000000000000000000000000000000000"; // 22 bytes + +export const AddressZero = ethers.ZeroAddress; +export const HashZero = ethers.ZeroHash +export const ONE_ETH = parseEther('1') +export const TWO_ETH = parseEther('2') +export const FIVE_ETH = parseEther('5') +export const maxUint48 = (2 ** 48) - 1 + +export const tostr = (x: any): string => x != null ? x.toString() : 'null' + +const coder = AbiCoder.defaultAbiCoder() + +export interface ValidationData { + aggregator: string + validAfter: number + validUntil: number +} + +export const panicCodes: { [key: number]: string } = { + // from https://docs.soliditylang.org/en/v0.8.0/control-structures.html + 0x01: 'assert(false)', + 0x11: 'arithmetic overflow/underflow', + 0x12: 'divide by zero', + 0x21: 'invalid enum value', + 0x22: 'storage byte array that is incorrectly encoded', + 0x31: '.pop() on an empty array.', + 0x32: 'array sout-of-bounds or negative index', + 0x41: 'memory overflow', + 0x51: 'zero-initialized variable of internal function type' +} +export const Erc20 = [ + "function transfer(address _receiver, uint256 _value) public returns (bool success)", + "function transferFrom(address, address, uint256) public returns (bool)", + "function approve(address _spender, uint256 _value) public returns (bool success)", + "function allowance(address _owner, address _spender) public view returns (uint256 remaining)", + "function balanceOf(address _owner) public view returns (uint256 balance)", + "event Approval(address indexed _owner, address indexed _spender, uint256 _value)", + ]; + +export const Erc20Interface = new ethers.Interface(Erc20); + +export const encodeTransfer = ( + target: string, + amount: string | number + ): string => { + return Erc20Interface.encodeFunctionData("transfer", [target, amount]); +}; + +export const encodeTransferFrom = ( + from: string, + target: string, + amount: string | number + ): string => { + return Erc20Interface.encodeFunctionData("transferFrom", [ + from, + target, + amount, + ]); +}; + +// rethrow "cleaned up" exception. +// - stack trace goes back to method (or catch) line, not inner provider +// - attempt to parse revert data (needed for geth) +// use with ".catch(rethrow())", so that current source file/line is meaningful. +export function rethrow (): (e: Error) => void { + const callerStack = new Error().stack!.replace(/Error.*\n.*at.*\n/, '').replace(/.*at.* \(internal[\s\S]*/, '') + + if (arguments[0] != null) { + throw new Error('must use .catch(rethrow()), and NOT .catch(rethrow)') + } + return function (e: Error) { + const solstack = e.stack!.match(/((?:.* at .*\.sol.*\n)+)/) + const stack = (solstack != null ? solstack[1] : '') + callerStack + // const regex = new RegExp('error=.*"data":"(.*?)"').compile() + const found = /error=.*?"data":"(.*?)"/.exec(e.message) + let message: string + if (found != null) { + const data = found[1] + message = decodeRevertReason(data) ?? e.message + ' - ' + data.slice(0, 100) + } else { + message = e.message + } + const err = new Error(message) + err.stack = 'Error: ' + message + '\n' + stack + throw err + } +} + +const decodeRevertReasonContracts = new Interface([ + ...EntryPoint__factory.createInterface().fragments, + 'error ECDSAInvalidSignature()' +]) // .filter(f => f.type === 'error')) + +export function decodeRevertReason (data: string | Error, nullIfNoMatch = true): string | null { + if (typeof data !== 'string') { + const err = data as any + data = (err.data ?? err.error?.data) as string + if (typeof data !== 'string') throw err + } + + const methodSig = data.slice(0, 10) + const dataParams = '0x' + data.slice(10) + + // can't add Error(string) to xface... + if (methodSig === '0x08c379a0') { + const [err] = coder.decode(['string'], dataParams) + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + return `Error(${err})` + } else if (methodSig === '0x4e487b71') { + const [code] = coder.decode(['uint256'], dataParams) + return `Panic(${panicCodes[code] ?? code} + ')` + } + + try { + const err = decodeRevertReasonContracts.parseError(data) + // treat any error "bytes" argument as possible error to decode (e.g. FailedOpWithRevert, PostOpReverted) + const args = err!.args.map((arg: any, index) => { + switch (err?.fragment.inputs[index].type) { + case 'bytes' : return decodeRevertReason(arg) + case 'string': return `"${(arg as string)}"` + default: return arg + } + }) + return `${err!.name}(${args.join(',')})` + } catch (e) { + // throw new Error('unsupported errorSig ' + data) + if (!nullIfNoMatch) { + return data + } + return null + } +} + +export function tonumber (x: any): number { + try { + return parseFloat(x.toString()) + } catch (e: any) { + console.log('=== failed to parseFloat:', x, (e).message) + return NaN + } +} + +// just throw 1eth from account[0] to the given address (or contract instance) +export async function fund (contractOrAddress: string | Contract, amountEth = '1'): Promise { + let address: string + if (typeof contractOrAddress === 'string') { + address = contractOrAddress + } else { + address = await contractOrAddress.getAddress() + } + const [firstSigner] = await ethers.getSigners(); + await firstSigner.sendTransaction({ to: address, value: parseEther(amountEth) }) +} + +export async function getBalance (address: string): Promise { + const balance = await ethers.provider.getBalance(address) + return parseInt(balance.toString()) +} + +export async function getTokenBalance (token: IERC20, address: string): Promise { + const balance = await token.balanceOf(address) + return parseInt(balance.toString()) +} + +export async function isDeployed (addr: string): Promise { + const code = await ethers.provider.getCode(addr) + return code.length > 2 +} + +// Getting initcode for AccountFactory which accepts one validator (with ECDSA owner required for installation) +export async function getInitCode( + ownerAddress: AddressLike, + factoryAddress: AddressLike, + validatorAddress: AddressLike, + saDeploymentIndex: number = 0, +): Promise { + const AccountFactory = await ethers.getContractFactory("AccountFactory"); + const moduleInstallData = ethers.solidityPacked(["address"], [ownerAddress]); + + // Encode the createAccount function call with the provided parameters + const factoryDeploymentData = AccountFactory.interface + .encodeFunctionData("createAccount", [ + validatorAddress, + moduleInstallData, + saDeploymentIndex, + ]) + .slice(2); + + return factoryAddress + factoryDeploymentData; +} + +export function callDataCost (data: string): number { + return ethers.getBytes(data) + .map(x => x === 0 ? 4 : 16) + .reduce((sum, x) => sum + x) +} + +export function parseValidationData (validationData: BigNumberish): ValidationData { + const data = ethers.zeroPadValue(toBeHex(validationData), 32) + + // string offsets start from left (msb) + const aggregator = dataSlice(data, 32 - 20) + let validUntil = parseInt(dataSlice(data, 32 - 26, 32 - 20)) + if (validUntil === 0) { + validUntil = maxUint48 + } + const validAfter = parseInt(dataSlice(data, 0, 6)) + + return { + aggregator, + validAfter, + validUntil + } +} + + diff --git a/test/hardhat/utils/types.ts b/test/hardhat/utils/types.ts new file mode 100644 index 0000000..791fc10 --- /dev/null +++ b/test/hardhat/utils/types.ts @@ -0,0 +1,34 @@ +import { + AddressLike, + BigNumberish, + BytesLike, + } from "ethers"; + +export interface UserOperation { + sender: AddressLike; // Or string + nonce?: BigNumberish; + initCode?: BytesLike; + callData?: BytesLike; + callGasLimit?: BigNumberish; + verificationGasLimit?: BigNumberish; + preVerificationGas?: BigNumberish; + maxFeePerGas?: BigNumberish; + maxPriorityFeePerGas?: BigNumberish; + paymaster?: AddressLike; // Or string + paymasterVerificationGasLimit?: BigNumberish; + paymasterPostOpGasLimit?: BigNumberish; + paymasterData?: BytesLike; + signature?: BytesLike; + } + + export interface PackedUserOperation { + sender: AddressLike; // Or string + nonce: BigNumberish; + initCode: BytesLike; + callData: BytesLike; + accountGasLimits: BytesLike; + preVerificationGas: BigNumberish; + gasFees: BytesLike; + paymasterAndData: BytesLike; + signature: BytesLike; + } \ No newline at end of file diff --git a/test/hardhat/utils/userOpHelpers.ts b/test/hardhat/utils/userOpHelpers.ts new file mode 100644 index 0000000..8dc582c --- /dev/null +++ b/test/hardhat/utils/userOpHelpers.ts @@ -0,0 +1,347 @@ +import { ethers } from "hardhat"; +import { EntryPoint, EntryPointSimulations__factory, IEntryPointSimulations } from "../../../typechain-types"; +import { PackedUserOperation, UserOperation } from "./types"; +import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers"; +import { TransactionRequest } from '@ethersproject/abstract-provider' +import { AbiCoder, BigNumberish, BytesLike, Contract, Signer, dataSlice, keccak256, toBeHex } from "ethers"; +import { toGwei } from "./general"; +import { callDataCost, decodeRevertReason, rethrow } from "./testUtils"; +import EntryPointSimulationsJson from '../../../artifacts/account-abstraction/contracts/core/EntryPointSimulations.sol/EntryPointSimulations.json' + +const AddressZero = ethers.ZeroAddress; +const coder = AbiCoder.defaultAbiCoder() + +export function packUserOp (userOp: UserOperation): PackedUserOperation { + + const { + sender, + nonce, + initCode = "0x", + callData = "0x", + callGasLimit = 1_500_000, + verificationGasLimit = 1_500_000, + preVerificationGas = 2_000_000, + maxFeePerGas = toGwei("20"), + maxPriorityFeePerGas = toGwei("10"), + paymaster = ethers.ZeroAddress, + paymasterData = "0x", + paymasterVerificationGasLimit = 3_00_000, + paymasterPostOpGasLimit = 0, + signature = "0x", + } = userOp; + + const accountGasLimits = packAccountGasLimits(verificationGasLimit, callGasLimit) + const gasFees = packAccountGasLimits(maxPriorityFeePerGas, maxFeePerGas) + let paymasterAndData = '0x' + if (paymaster.toString().length >= 20 && paymaster !== ethers.ZeroAddress) { + paymasterAndData = packPaymasterData( + userOp.paymaster as string, + paymasterVerificationGasLimit, + paymasterPostOpGasLimit, + paymasterData as string, + ) as string; + } + return { + sender: userOp.sender, + nonce: userOp.nonce || 0, + callData: userOp.callData || '0x', + accountGasLimits, + initCode: userOp.initCode || '0x', + preVerificationGas: userOp.preVerificationGas || 50000, + gasFees, + paymasterAndData, + signature: userOp.signature || '0x' + } +} + +export function encodeUserOp (userOp: UserOperation, forSignature = true): string { + const packedUserOp = packUserOp(userOp) + if (forSignature) { + return coder.encode( + ['address', 'uint256', 'bytes32', 'bytes32', + 'bytes32', 'uint256', 'bytes32', + 'bytes32'], + [packedUserOp.sender, packedUserOp.nonce, keccak256(packedUserOp.initCode), keccak256(packedUserOp.callData), + packedUserOp.accountGasLimits, packedUserOp.preVerificationGas, packedUserOp.gasFees, + keccak256(packedUserOp.paymasterAndData)]) + } else { + // for the purpose of calculating gas cost encode also signature (and no keccak of bytes) + return coder.encode( + ['address', 'uint256', 'bytes', 'bytes', + 'bytes32', 'uint256', 'bytes32', + 'bytes', 'bytes'], + [packedUserOp.sender, packedUserOp.nonce, packedUserOp.initCode, packedUserOp.callData, + packedUserOp.accountGasLimits, packedUserOp.preVerificationGas, packedUserOp.gasFees, + packedUserOp.paymasterAndData, packedUserOp.signature]) + } +} + +// Can be moved to testUtils +export function packPaymasterData( + paymaster: string, + paymasterVerificationGasLimit: BigNumberish, + postOpGasLimit: BigNumberish, + paymasterData: BytesLike, + ): BytesLike { + return ethers.concat([ + paymaster, + ethers.zeroPadValue(toBeHex(Number(paymasterVerificationGasLimit)), 16), + ethers.zeroPadValue(toBeHex(Number(postOpGasLimit)), 16), + paymasterData, + ]); +} + +// Can be moved to testUtils +export function packAccountGasLimits (verificationGasLimit: BigNumberish, callGasLimit: BigNumberish): string { + return ethers.concat([ + ethers.zeroPadValue(toBeHex(Number(verificationGasLimit)), 16), ethers.zeroPadValue(toBeHex(Number(callGasLimit)), 16) + ]) +} + +// Can be moved to testUtils +export function unpackAccountGasLimits (accountGasLimits: string): { verificationGasLimit: number, callGasLimit: number } { + return { verificationGasLimit: parseInt(accountGasLimits.slice(2, 34), 16), callGasLimit: parseInt(accountGasLimits.slice(34), 16) } +} + +export function getUserOpHash (op: UserOperation, entryPoint: string, chainId: number): string { + const userOpHash = keccak256(encodeUserOp(op, true)) + const enc = coder.encode( + ['bytes32', 'address', 'uint256'], + [userOpHash, entryPoint, chainId]) + return keccak256(enc) +} + +export const DefaultsForUserOp: UserOperation = { + sender: AddressZero, + nonce: 0, + initCode: '0x', + callData: '0x', + callGasLimit: 0, + verificationGasLimit: 150000, // default verification gas. will add create2 cost (3200+200*length) if initCode exists + preVerificationGas: 21000, // should also cover calldata cost. + maxFeePerGas: 0, + maxPriorityFeePerGas: 1e9, + paymaster: AddressZero, + paymasterData: '0x', + paymasterVerificationGasLimit: 3e5, + paymasterPostOpGasLimit: 0, + signature: '0x' +} + +// Different compared to infinitism utils +export async function signUserOp (op: UserOperation, signer: Signer, entryPoint: string, chainId: number): Promise { + const message = getUserOpHash(op, entryPoint, chainId) + + const signature = await signer.signMessage(ethers.getBytes(message)); + + return { + ...op, + signature: signature + } +} + +export function fillUserOpDefaults (op: Partial, defaults = DefaultsForUserOp): UserOperation { + const partial: any = { ...op } + // we want "item:undefined" to be used from defaults, and not override defaults, so we must explicitly + // remove those so "merge" will succeed. + for (const key in partial) { + if (partial[key] == null) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete partial[key] + } + } + const filled = { ...defaults, ...partial } + return filled +} + +// helper to fill structure: +// - default callGasLimit to estimate call from entryPoint to account (TODO: add overhead) +// if there is initCode: +// - calculate sender by eth_call the deployment code +// - default verificationGasLimit estimateGas of deployment code plus default 100000 +// no initCode: +// - update nonce from account.getNonce() +// entryPoint param is only required to fill in "sender address when specifying "initCode" +// nonce: assume contract as "getNonce()" function, and fill in. +// sender - only in case of construction: fill sender from initCode. +// callGasLimit: VERY crude estimation (by estimating call to account, and add rough entryPoint overhead +// verificationGasLimit: hard-code default at 100k. should add "create2" cost +export async function fillUserOp (op: Partial, entryPoint?: EntryPoint, getNonceFunction = 'getNonce', nonceKey = "0"): Promise { + const op1 = { ...op } + const provider = ethers.provider + if (op.initCode != null && op.initCode !== "0x" ) { + const initAddr = dataSlice(op1.initCode!, 0, 20) + const initCallData = dataSlice(op1.initCode!, 20) + if (op1.nonce == null) op1.nonce = 0 + if (op1.sender == null) { + if (provider == null) throw new Error('no entrypoint/provider') + op1.sender = await entryPoint!.getSenderAddress(op1.initCode!).catch(e => e.errorArgs.sender) + } + if (op1.verificationGasLimit == null) { + if (provider == null) throw new Error('no entrypoint/provider') + const initEstimate = await provider.estimateGas({ + from: await entryPoint?.getAddress(), + to: initAddr, + data: initCallData, + gasLimit: 10e6 + }) + op1.verificationGasLimit = Number(DefaultsForUserOp.verificationGasLimit!) + Number(initEstimate) + } + } + if (op1.nonce == null) { + // TODO: nonce should be fetched from entrypoint based on key + // if (provider == null) throw new Error('must have entryPoint to autofill nonce') + // const c = new Contract(op.sender! as string, [`function ${getNonceFunction}() view returns(uint256)`], provider) + // op1.nonce = await c[getNonceFunction]().catch(rethrow()) + const nonce = await entryPoint?.getNonce(op1.sender!, nonceKey); + op1.nonce = nonce ?? 0n; + } + if (op1.callGasLimit == null && op.callData != null) { + if (provider == null) throw new Error('must have entryPoint for callGasLimit estimate') + const gasEtimated = await provider.estimateGas({ + from: await entryPoint?.getAddress(), + to: op1.sender, + data: op1.callData as string + }) + + // console.log('estim', op1.sender,'len=', op1.callData!.length, 'res=', gasEtimated) + // estimateGas assumes direct call from entryPoint. add wrapper cost. + op1.callGasLimit = gasEtimated // .add(55000) + } + if (op1.paymaster != null) { + if (op1.paymasterVerificationGasLimit == null) { + op1.paymasterVerificationGasLimit = DefaultsForUserOp.paymasterVerificationGasLimit + } + if (op1.paymasterPostOpGasLimit == null) { + op1.paymasterPostOpGasLimit = DefaultsForUserOp.paymasterPostOpGasLimit + } + } + if (op1.maxFeePerGas == null) { + if (provider == null) throw new Error('must have entryPoint to autofill maxFeePerGas') + const block = await provider.getBlock('latest') + op1.maxFeePerGas = Number(block!.baseFeePerGas!) + Number(op1.maxPriorityFeePerGas ?? DefaultsForUserOp.maxPriorityFeePerGas) + } + // TODO: this is exactly what fillUserOp below should do - but it doesn't. + // adding this manually + if (op1.maxPriorityFeePerGas == null) { + op1.maxPriorityFeePerGas = DefaultsForUserOp.maxPriorityFeePerGas + } + const op2 = fillUserOpDefaults(op1) + // if(op2 === undefined || op2 === null) { + // throw new Error('op2 is undefined or null') + // } + // eslint-disable-next-line @typescript-eslint/no-base-to-string + if (op2?.preVerificationGas?.toString() === '0') { + // TODO: we don't add overhead, which is ~21000 for a single TX, but much lower in a batch. + op2.preVerificationGas = callDataCost(encodeUserOp(op2, false)) + } + return op2; +} + +export async function fillAndPack (op: Partial, entryPoint?: EntryPoint, getNonceFunction = 'getNonce'): Promise { + const userOp = await fillUserOp(op, entryPoint, getNonceFunction); + if(userOp === undefined) { + throw new Error('userOp is undefined') + } + return packUserOp(userOp) +} + +export async function fillAndSign (op: Partial, signer: Signer | Signer, entryPoint?: EntryPoint, getNonceFunction = 'getNonce', nonceKey = "0"): Promise { + const provider = ethers.provider + const op2 = await fillUserOp(op, entryPoint, getNonceFunction, nonceKey) + if(op2 === undefined) { + throw new Error('op2 is undefined') + } + + const chainId = await provider!.getNetwork().then(net => net.chainId) + const message = ethers.getBytes(getUserOpHash(op2, await entryPoint!.getAddress(), Number(chainId))) + + let signature + try { + signature = await signer.signMessage(message) + } catch (err: any) { + // attempt to use 'eth_sign' instead of 'personal_sign' which is not supported by Foundry Anvil + signature = await (signer as any)._legacySignMessage(message) + } + return { + ...op2, + signature + } +} + + export async function fillSignAndPack (op: Partial, signer: Signer | Signer, entryPoint?: EntryPoint, getNonceFunction = 'getNonce', nonceKey = "0"): Promise { + const filledAndSignedOp = await fillAndSign(op, signer, entryPoint, getNonceFunction, nonceKey) + return packUserOp(filledAndSignedOp) +} + +/** + * This function relies on a "state override" functionality of the 'eth_call' RPC method + * in order to provide the details of a simulated validation call to the bundler + * @param userOp + * @param entryPointAddress + * @param txOverrides + */ +export async function simulateValidation ( + userOp: PackedUserOperation, + entryPointAddress: string, + txOverrides?: any): Promise { + const entryPointSimulations = EntryPointSimulations__factory.createInterface() + const data = entryPointSimulations.encodeFunctionData('simulateValidation', [userOp]) + const tx: TransactionRequest = { + to: entryPointAddress, + data, + ...txOverrides + } + const stateOverride = { + [entryPointAddress]: { + code: EntryPointSimulationsJson.deployedBytecode + } + } + try { + const simulationResult = await ethers.provider.send('eth_call', [tx, 'latest', stateOverride]) + const res = entryPointSimulations.decodeFunctionResult('simulateValidation', simulationResult) + // note: here collapsing the returned "tuple of one" into a single value - will break for returning actual tuples + return res[0] + } catch (error: any) { + const revertData = error?.data + if (revertData != null) { + // note: this line throws the revert reason instead of returning it + entryPointSimulations.decodeFunctionResult('simulateValidation', revertData) + } + throw error + } +} + +// TODO: this code is very much duplicated but "encodeFunctionData" is based on 20 overloads +// TypeScript is not able to resolve overloads with variables: https://github.com/microsoft/TypeScript/issues/14107 +export async function simulateHandleOp ( + userOp: PackedUserOperation, + target: string, + targetCallData: string, + entryPointAddress: string, + txOverrides?: any): Promise { + const entryPointSimulations = EntryPointSimulations__factory.createInterface() + const data = entryPointSimulations.encodeFunctionData('simulateHandleOp', [userOp, target, targetCallData]) + const tx: TransactionRequest = { + to: entryPointAddress, + data, + ...txOverrides + } + const stateOverride = { + [entryPointAddress]: { + code: EntryPointSimulationsJson.deployedBytecode + } + } + try { + const simulationResult = await ethers.provider.send('eth_call', [tx, 'latest', stateOverride]) + const res = entryPointSimulations.decodeFunctionResult('simulateHandleOp', simulationResult) + // note: here collapsing the returned "tuple of one" into a single value - will break for returning actual tuples + return res[0] + } catch (error: any) { + const err = decodeRevertReason(error) + if (err != null) { + throw new Error(err) + } + throw error + } + }