From c36bbc45a67426cae1412949d9c9b9c0e0eb4b0a Mon Sep 17 00:00:00 2001 From: Dror Tirosh Date: Sat, 24 Dec 2022 12:48:08 +0200 Subject: [PATCH] AA-68 bundler rpc calls (#24) * eth_estimateUserOp * getUserOperationReceipt --- aabundler-launcher | 118 +++++++ launcher | 56 ---- package.json | 2 +- .../contracts/tests/TestRulesAccount.sol | 118 +++++++ packages/bundler/deploy/1-deploy-helper.ts | 23 -- .../bundler/deploy/2-deploy-entrypoint.ts | 5 - packages/bundler/deploy/3-fund-signer.ts | 1 - packages/bundler/package.json | 3 +- .../bundler/src/BundlerCollectorTracer.ts | 68 ++-- packages/bundler/src/BundlerServer.ts | 7 +- packages/bundler/src/GethTracer.ts | 6 +- packages/bundler/src/MockTracer.ts | 10 +- packages/bundler/src/UserOpMethodHandler.ts | 309 ++++++++++++++---- packages/bundler/src/opcodeScanner.ts | 120 +++++++ packages/bundler/src/runBundler.ts | 2 +- packages/bundler/src/utils.ts | 29 +- packages/bundler/test/BundlerServer.test.ts | 13 +- packages/bundler/test/OpcodeScanner.test.ts | 94 ++++++ .../bundler/test/UserOpMethodHandler.test.ts | 164 +++++++++- packages/bundler/test/opcodes.test.ts | 3 + packages/bundler/test/runBundler.test.ts | 4 +- packages/bundler/test/tracer.test.ts | 18 +- packages/bundler/test/utils.test.ts | 2 +- packages/sdk/src/HttpRpcClient.ts | 34 +- packages/utils/src/ERC4337Utils.ts | 25 +- 25 files changed, 956 insertions(+), 278 deletions(-) create mode 100755 aabundler-launcher delete mode 100755 launcher create mode 100644 packages/bundler/contracts/tests/TestRulesAccount.sol delete mode 100644 packages/bundler/deploy/1-deploy-helper.ts create mode 100644 packages/bundler/src/opcodeScanner.ts create mode 100644 packages/bundler/test/OpcodeScanner.test.ts create mode 100644 packages/bundler/test/opcodes.test.ts diff --git a/aabundler-launcher b/aabundler-launcher new file mode 100755 index 0000000..4ed583a --- /dev/null +++ b/aabundler-launcher @@ -0,0 +1,118 @@ +#!/bin/bash -e +# launch bundler: also start geth, and deploy entrypoint. +cd `dirname $0` + +GETH=geth +GETHPORT=8545 +BUNDLERPORT=3000 +GETHPID=/tmp/aabundler.geth.pid +BUNDLERPID=/tmp/aabundler.node.pid +VERSION="aabundler-js-0.1" + +BUNDLERLOG=/tmp/aabundler.log + +BUNDLERURL=http://localhost:$BUNDLERPORT/rpc +NODEURL=http://localhost:$GETHPORT + +function fatal { + echo "$@" 1>&2 + exit 1 +} + +function isPortFree { + port=$1 + curl http://localhost:$port 2>&1 | grep -q Connection.refused +} + + +function waitForPort { + port=$1 + while isPortFree $port; do true; done +} + +function startBundler { + +isPortFree $GETHPORT || fatal port $GETHPORT not free +isPortFree $BUNDLERPORT || fatal port $BUNDLERPORT not free + +echo == starting geth 1>&2 +$GETH version | grep ^Version: 1>&2 + +$GETH --dev --http.port $GETHPORT \ + --http.api personal,eth,net,web3,debug \ + --ignore-legacy-receipts \ + --http \ + --http.addr "0.0.0.0" \ + --rpc.allow-unprotected-txs \ + --allow-insecure-unlock \ + --verbosity 1 & echo $! > $GETHPID + +waitForPort $GETHPORT + +cd packages/bundler +echo == Deploying entrypoint 1>&2 +export TS_NODE_TRANSPILE_ONLY=1 +npx hardhat deploy --network localhost +echo == Starting bundler 1>&2 +ts-node -T ./src/exec.ts --config ./localconfig/bundler.config.json --port $BUNDLERPORT --network http://localhost:$GETHPORT & echo $! > $BUNDLERPID +waitForPort $BUNDLERPORT +} + +function start { + isPortFree $GETPORTPORT || fatal port $GETHPORT not free + isPortFree $BUNDLERPORT || fatal port $BUNDLERPORT not free + startBundler > $BUNDLERLOG + echo == Bundler, Geth started. log to $BUNDLERLOG +} + +function stop { + echo == stopping bundler + test -r $BUNDLERPID && kill -9 `cat $BUNDLERPID` + test -r $GETHPID && kill -9 `cat $GETHPID` + rm $BUNDLERPID $GETHPID + echo == bundler, geth stopped +} + +function jsoncurl { + method=$1 + params=$2 + url=$3 + data="{\"method\":\"$method\",\"params\":$params,\"id\":1,\"jsonrpc\":\"2.0\"}" + curl -s -H content-type:application/json -d $data $url +} + +function info { + entrypoint=`jsoncurl eth_supportedEntryPoints [] $BUNDLERURL | jq -r .result["0"]` + echo "BUNDLER_ENTRYPOINT=$entrypoint" + status="down"; test -n "$entrypoint" && status="active" + echo "BUNDLER_URL=$BUNDLERURL" + echo "BUNDLER_NODE_URL=$NODEURL" + echo "BUNDLER_LOG=$BUNDLERLOG" + echo "BUNDLER_VERSION=$VERSION" + echo "BUNDLER_STATUS=$status" +} + +case $1 in + + start) + start + ;; + stop) + stop + ;; + + restart) + echo == restarting bundler + stop + start + ;; + + info) + info + ;; + + *) echo "usage: $0 {start|stop|restart|info}" + exit 1 ;; + + +esac diff --git a/launcher b/launcher deleted file mode 100755 index e5d0307..0000000 --- a/launcher +++ /dev/null @@ -1,56 +0,0 @@ -#!/bin/bash - -function portFree { - port=$1 - curl http://localhost:$port 2>&1 | grep -q Connection.refused -} - -function launcher { - port=$1 - name=$2 - shift; shift - cmd="$*" - - if portFree $port; then true - else - echo == FATAL: cannot start $name: port $port in use. - exit 1 - fi - - echo == starting $name - - $cmd & ID=$! - - trap "echo == killing $name; kill -9 $ID" EXIT - - echo waiting for port $port - while portFree $port; do sleep 1; done - echo started $name -} - - -GETH="/usr/local/bin/geth --dev \ - --http.port ${PORT:=8545} \ - --nousb \ - --miner.gaslimit 12000000 \ - --http \ - --http.api personal,eth,net,web3,debug \ - --allow-insecure-unlock \ - --rpc.allow-unprotected-txs \ - --http.vhosts '*,localhost,host.docker.internal' \ - --http.corsdomain '*' \ - --http.addr "0.0.0.0" \ - --dev \ - --nodiscover --maxpeers 0 --mine \ - --miner.threads 1 \ - --verbosity 2 \ - --networkid ${NETWORKID:=1337} \ - --allow-insecure-unlock \ - --http \ - --verbosity 1 \ - --ignore-legacy-receipts" - -launcher 8545 geth $GETH -launcher 3000 node "$@" -echo == started -sleep 1 diff --git a/package.json b/package.json index d769c2f..d50002c 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "clear": "lerna run clear", "hardhat-compile": "lerna run hardhat-compile", "preprocess": "yarn lerna-clear && yarn hardhat-compile && yarn lerna-tsc", - "runop-self": "yarn runop --deployDeployer --selfBundler" + "runop-self": "ts-node ./packages/bundler/src/runner/runop.ts --deployDeployer --selfBundler" }, "dependencies": { "@typescript-eslint/eslint-plugin": "^5.33.0", diff --git a/packages/bundler/contracts/tests/TestRulesAccount.sol b/packages/bundler/contracts/tests/TestRulesAccount.sol new file mode 100644 index 0000000..0e7161d --- /dev/null +++ b/packages/bundler/contracts/tests/TestRulesAccount.sol @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.15; + +import "@account-abstraction/contracts/interfaces/IAccount.sol"; +import "@account-abstraction/contracts/interfaces/IPaymaster.sol"; +import "@account-abstraction/contracts/interfaces/IEntryPoint.sol"; + +contract Dummy { + uint public value = 1; +} + +contract TestCoin { + mapping(address => uint) balances; + + function balanceOf(address addr) public returns (uint) { + return balances[addr]; + } + + function mint(address addr) public returns (uint) { + return balances[addr] += 100; + } + + //unrelated to token: testing inner object revert + function reverting() public returns (uint) { + revert("inner-revert"); + } + + function wasteGas() public returns (uint) { + string memory buffer = "string to be duplicated"; + while (true) { + buffer = string.concat(buffer, buffer); + } + return 0; + } +} + +contract TestRulesAccount is IAccount, IPaymaster { + + uint state; + TestCoin public coin; + + event State(uint oldState, uint newState); + + function setState(uint _state) external { + emit State(state, _state); + state = _state; + } + + function setCoin(TestCoin _coin) public returns (uint){ + coin = _coin; + return 0; + } + + function eq(string memory a, string memory b) internal returns (bool) { + return keccak256(bytes(a)) == keccak256(bytes(b)); + } + + event TestMessage(address eventSender); + + function runRule(string memory rule) public returns (uint) { + if (eq(rule, "")) return 0; + else if (eq(rule, "number")) return block.number; + else if (eq(rule, "coinbase")) return uint160(address(block.coinbase)); + else if (eq(rule, "blockhash")) return uint(blockhash(0)); + else if (eq(rule, "create2")) return new Dummy{salt : bytes32(uint(0x1))}().value(); + else if (eq(rule, "balance-self")) return coin.balanceOf(address(this)); + else if (eq(rule, "mint-self")) return coin.mint(address(this)); + else if (eq(rule, "balance-1")) return coin.balanceOf(address(1)); + else if (eq(rule, "mint-1")) return coin.mint(address(1)); + + else if (eq(rule, "inner-revert")) return coin.reverting(); + else if (eq(rule, "oog")) return coin.wasteGas(); + else if (eq(rule, "emit-msg")) { + emit TestMessage(address(this)); + return 0;} + + revert(string.concat("unknown rule: ", rule)); + } + + function addStake(IEntryPoint entryPoint) public payable { + entryPoint.addStake{value : msg.value}(1); + } + + function validateUserOp(UserOperation calldata userOp, bytes32, address, uint256 missingAccountFunds) + external override returns (uint256 ) { + if (missingAccountFunds > 0) { + /* solhint-disable-next-line avoid-low-level-calls */ + (bool success,) = msg.sender.call{value : missingAccountFunds}(""); + success; + } + if (userOp.signature.length == 4) { + uint32 deadline = uint32(bytes4(userOp.signature)); + return deadline; + } + runRule(string(userOp.signature)); + return 0; + } + + function validatePaymasterUserOp(UserOperation calldata userOp, bytes32 userOpHash, uint256 maxCost) + external returns (bytes memory context, uint256 deadline) { + string memory rule = string(userOp.paymasterAndData[20 :]); + runRule(rule); + return ("", 0); + } + + function postOp(PostOpMode, bytes calldata, uint256) external {} + +} + +contract TestRulesAccountDeployer { + function create(string memory rule, TestCoin coin) public returns (TestRulesAccount) { + TestRulesAccount a = new TestRulesAccount{salt : bytes32(uint(0))}(); + a.setCoin(coin); + a.runRule(rule); + return a; + } + +} diff --git a/packages/bundler/deploy/1-deploy-helper.ts b/packages/bundler/deploy/1-deploy-helper.ts deleted file mode 100644 index 25e3081..0000000 --- a/packages/bundler/deploy/1-deploy-helper.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { HardhatRuntimeEnvironment } from 'hardhat/types' -import { DeployFunction } from 'hardhat-deploy/types' - -const func: DeployFunction = async function (hre: HardhatRuntimeEnvironment) { - const { deploy } = hre.deployments - const accounts = await hre.ethers.provider.listAccounts() - console.log('Available accounts:', accounts) - const deployer = accounts[0] - console.log('Will deploy from account:', deployer) - - if (deployer == null) { - throw new Error('no deployer. missing MNEMONIC_FILE ?') - } - await deploy('BundlerHelper', { - from: deployer, - args: [], - log: true, - deterministicDeployment: true - }) -} - -export default func -func.tags = ['BundlerHelper'] diff --git a/packages/bundler/deploy/2-deploy-entrypoint.ts b/packages/bundler/deploy/2-deploy-entrypoint.ts index 5905232..411e6d4 100644 --- a/packages/bundler/deploy/2-deploy-entrypoint.ts +++ b/packages/bundler/deploy/2-deploy-entrypoint.ts @@ -2,9 +2,6 @@ import { HardhatRuntimeEnvironment } from 'hardhat/types' import { DeployFunction } from 'hardhat-deploy/types' import { ethers } from 'hardhat' -const UNSTAKE_DELAY_SEC = 100 -const PAYMASTER_STAKE = ethers.utils.parseEther('1') - // deploy entrypoint - but only on debug network.. const deployEP: DeployFunction = async function (hre: HardhatRuntimeEnvironment) { // first verify if already deployed: @@ -12,7 +9,6 @@ const deployEP: DeployFunction = async function (hre: HardhatRuntimeEnvironment) await hre.deployments.deploy( 'EntryPoint', { from: ethers.constants.AddressZero, - args: [PAYMASTER_STAKE, UNSTAKE_DELAY_SEC], deterministicDeployment: true, log: true }) @@ -35,7 +31,6 @@ const deployEP: DeployFunction = async function (hre: HardhatRuntimeEnvironment) 'EntryPoint', { // from: ethers.constants.AddressZero, from: deployer, - // args: [PAYMASTER_STAKE, UNSTAKE_DELAY_SEC], gasLimit: 4e6, deterministicDeployment: true, log: true diff --git a/packages/bundler/deploy/3-fund-signer.ts b/packages/bundler/deploy/3-fund-signer.ts index 3f6deaf..97fff39 100644 --- a/packages/bundler/deploy/3-fund-signer.ts +++ b/packages/bundler/deploy/3-fund-signer.ts @@ -2,7 +2,6 @@ import { HardhatRuntimeEnvironment } from 'hardhat/types' import { DeployFunction } from 'hardhat-deploy/types' import { parseEther } from 'ethers/lib/utils' -// deploy entrypoint - but only on debug network.. const fundsigner: DeployFunction = async function (hre: HardhatRuntimeEnvironment) { // on geth, fund the default "hardhat node" account. diff --git a/packages/bundler/package.json b/packages/bundler/package.json index d46880d..86e9786 100644 --- a/packages/bundler/package.json +++ b/packages/bundler/package.json @@ -38,8 +38,7 @@ "ethers": "^5.7.0", "express": "^4.18.1", "hardhat-gas-reporter": "^1.0.8", - "ow": "^0.28.1", - "source-map-support": "^0.5.21" + "ow": "^0.28.1" }, "devDependencies": { "@nomicfoundation/hardhat-chai-matchers": "^1.0.3", diff --git a/packages/bundler/src/BundlerCollectorTracer.ts b/packages/bundler/src/BundlerCollectorTracer.ts index a4177e7..bdcfb0e 100644 --- a/packages/bundler/src/BundlerCollectorTracer.ts +++ b/packages/bundler/src/BundlerCollectorTracer.ts @@ -22,14 +22,29 @@ export interface BundlerCollectorReturn { * values passed into KECCAK opcode */ keccak: string[] - calls: Array<{ type: string, from: string, to: string, value: any }> + calls: Array logs: LogInfo[] debug: any[] } +export interface MethodInfo { + type: string + from: string + to: string + method: string + value: any + gas: number +} + +export interface ExitInfo { + type: 'REVERT' | 'RETURN' + gasUsed: number + data: string +} + export interface NumberLevelInfo { - opcodes: { [opcode: string]: number | undefined } - access: { [address: string]: AccessInfo | undefined } + opcodes: { [opcode: string]: number } + access: { [address: string]: AccessInfo } } export interface AccessInfo { @@ -75,7 +90,7 @@ export function bundlerCollectorTracer (): BundlerCollectorTracer { numberCounter: 0, fault (log: LogStep, db: LogDb): void { - this.debug.push(['fault', log.getError()]) + this.debug.push(`fault depth=${log.getDepth()} gas=${log.getGas()} cost=${log.getCost()} err=${log.getError() ?? ''}`) }, result (ctx: LogContext, db: LogDb): any { @@ -89,16 +104,22 @@ export function bundlerCollectorTracer (): BundlerCollectorTracer { }, enter (frame: LogCallFrame): void { - this.debug.push(['enter ' + frame.getType() + ' ' + toHex(frame.getTo()) + ' ' + toHex(frame.getInput()).slice(0, 100)]) + this.debug.push(`enter gas=${frame.getGas()} type=${frame.getType()} to=${toHex(frame.getTo())} in=${toHex(frame.getInput()).slice(0, 500)}`) this.calls.push({ type: frame.getType(), from: toHex(frame.getFrom()), to: toHex(frame.getTo()), + method: toHex(frame.getInput()).slice(0, 10), + gas: frame.getGas(), value: frame.getValue() }) }, exit (frame: LogFrameResult): void { - this.debug.push(`exit err=${frame.getError() as string}, gas=${frame.getGasUsed()}`) + this.calls.push({ + type: frame.getError() != null ? 'REVERT' : 'RETURN', + gasUsed: frame.getGasUsed(), + data: toHex(frame.getOutput()).slice(0, 500) + }) }, // increment the "key" in the list. if the key is not defined yet, then set it to "1" @@ -108,15 +129,28 @@ export function bundlerCollectorTracer (): BundlerCollectorTracer { step (log: LogStep, db: LogDb): any { const opcode = log.op.toString() // this.debug.push(this.lastOp + '-' + opcode + '-' + log.getDepth()) - if (opcode === 'NUMBER') this.numberCounter++ - if (this.numberLevels[this.numberCounter] == null) { - this.currentLevel = this.numberLevels[this.numberCounter] = { - access: {}, - opcodes: {} - } + + if (opcode === 'REVERT' || opcode === 'RETURN') { + const ofs = parseInt(log.stack.peek(0).toString()) + const len = parseInt(log.stack.peek(1).toString()) + const data = toHex(log.memory.slice(ofs, ofs + len)).slice(0, 500) + this.debug.push(opcode + ' ' + data) + this.calls.push({ + type: opcode, + gasUsed: 0, + data + }) } if (log.getDepth() === 1) { + // NUMBER opcode at top level split levels + if (opcode === 'NUMBER') this.numberCounter++ + if (this.numberLevels[this.numberCounter] == null) { + this.currentLevel = this.numberLevels[this.numberCounter] = { + access: {}, + opcodes: {} + } + } return } @@ -145,18 +179,14 @@ export function bundlerCollectorTracer (): BundlerCollectorTracer { this.countSlot(opcode === 'SLOAD' ? access.reads : access.writes, slot) } - if (opcode === 'REVERT' || opcode === 'RETURN') { - const ofs = parseInt(log.stack.peek(0).toString()) - const len = parseInt(log.stack.peek(1).toString()) - this.debug.push(opcode + ' ' + toHex(log.memory.slice(ofs, ofs + len)).slice(0, 100)) - } else if (opcode === 'KECCAK256') { + if (opcode === 'KECCAK256') { // collect keccak on 64-byte blocks const ofs = parseInt(log.stack.peek(0).toString()) const len = parseInt(log.stack.peek(1).toString()) // currently, solidity uses only 2-word (6-byte) for a key. this might change.. // still, no need to return too much - if (len < 512) { - // if (len == 64) { + if (len > 20 && len < 512) { + // if (len === 64) { this.keccak.push(toHex(log.memory.slice(ofs, ofs + len))) } } else if (opcode.startsWith('LOG')) { diff --git a/packages/bundler/src/BundlerServer.ts b/packages/bundler/src/BundlerServer.ts index 026e9ee..3801f8f 100644 --- a/packages/bundler/src/BundlerServer.ts +++ b/packages/bundler/src/BundlerServer.ts @@ -113,8 +113,11 @@ export class BundlerServer { case 'eth_sendUserOperation': result = await this.methodHandler.sendUserOperation(params[0], params[1]) break - case 'eth_simulateUserOperation': - result = await this.methodHandler.simulateUserOp(params[0], params[1]) + case 'eth_callUserOperation': + result = await this.methodHandler.callUserOperation(params[0], params[1]) + break + case 'eth_estimateUserOperationGas': + result = await this.methodHandler.estimateUserOperationGas(params[0], params[1]) break case 'eth_getUserOperationReceipt': result = await this.methodHandler.getUserOperationReceipt(params[0]) diff --git a/packages/bundler/src/GethTracer.ts b/packages/bundler/src/GethTracer.ts index 5e3df80..e8bf07d 100644 --- a/packages/bundler/src/GethTracer.ts +++ b/packages/bundler/src/GethTracer.ts @@ -141,7 +141,7 @@ export class LogCallFrame { readonly address: string, readonly value: BigNumber, readonly input: string, - readonly gas: BigNumber + readonly gas: number ) { } @@ -161,7 +161,7 @@ export class LogCallFrame { return this.input } // - returns the input as a buffer - getGas (): BigNumber { + getGas (): number { return this.gas } // - returns a Number which has the amount of gas provided for the frame @@ -211,7 +211,7 @@ export interface LogStep { getCost: () => number // returns the cost of the opcode as a Number getDepth: () => number // returns the execution depth as a Number getRefund: () => number // returns the amount to be refunded as a Number - getError: () => any // returns information about the error if one occured, otherwise returns undefined + getError: () => string | undefined // returns information about the error if one occured, otherwise returns undefined // If error is non-empty, all other fields should be ignored. } diff --git a/packages/bundler/src/MockTracer.ts b/packages/bundler/src/MockTracer.ts index 082a121..dc92275 100644 --- a/packages/bundler/src/MockTracer.ts +++ b/packages/bundler/src/MockTracer.ts @@ -76,7 +76,7 @@ export function MockTracer (tx: Transaction, res: TraceResult, options: TraceOpt tx.to!, tx.value, tx.data, - tx.gasPrice! + tx.gasPrice!.toNumber() )) const step: LogStep = { @@ -186,7 +186,7 @@ export function MockTracer (tx: Transaction, res: TraceResult, options: TraceOpt addr, BigNumber.from(value), 'todo: extract input from memory', - BigNumber.from(log.gas) + log.gas )) break } @@ -197,7 +197,7 @@ export function MockTracer (tx: Transaction, res: TraceResult, options: TraceOpt addr, BigNumber.from(value), 'todo: extract input from memory', - BigNumber.from(gas) + parseInt(gas) )) break } @@ -208,7 +208,7 @@ export function MockTracer (tx: Transaction, res: TraceResult, options: TraceOpt addr, BigNumber.from(0), 'todo: extract input from memory', - BigNumber.from(gas) + parseInt(gas) )) break } @@ -219,7 +219,7 @@ export function MockTracer (tx: Transaction, res: TraceResult, options: TraceOpt callstack.top().address, BigNumber.from(0), 'todo: extract input from memory', - BigNumber.from(gas) + parseInt(gas) )) break } diff --git a/packages/bundler/src/UserOpMethodHandler.ts b/packages/bundler/src/UserOpMethodHandler.ts index eb43a6f..676850f 100644 --- a/packages/bundler/src/UserOpMethodHandler.ts +++ b/packages/bundler/src/UserOpMethodHandler.ts @@ -1,22 +1,93 @@ -import { BigNumber, ethers, Wallet } from 'ethers' -import { JsonRpcProvider, JsonRpcSigner, Provider } from '@ethersproject/providers' +import { BigNumber, BigNumberish, ethers, Wallet } from 'ethers' +import { JsonRpcProvider, JsonRpcSigner, Log, Provider, TransactionReceipt } from '@ethersproject/providers' import { BundlerConfig } from './BundlerConfig' import { EntryPoint } from './types' import { hexValue, resolveProperties } from 'ethers/lib/utils' -import { AddressZero, rethrowError } from '@account-abstraction/utils' +import { AddressZero, decodeErrorReason, deepHexlify, rethrowError } from '@account-abstraction/utils' import { debug_traceCall } from './GethTracer' import { BundlerCollectorReturn, bundlerCollectorTracer } from './BundlerCollectorTracer' -import { UserOperationStruct } from '@account-abstraction/contracts' +import { EntryPoint__factory, UserOperationStruct } from '@account-abstraction/contracts' import { UserOperationEventEvent } from '@account-abstraction/contracts/dist/types/EntryPoint' import { calcPreVerificationGas } from '@account-abstraction/sdk' -import { deepHexlify, requireCond, RpcError } from './utils' +import { requireCond, RpcError } from './utils' import Debug from 'debug' +import { isGeth, opcodeScanner } from './opcodeScanner' const debug = Debug('aa.handler.userop') const HEX_REGEX = /^0x[a-fA-F\d]*$/i +/** + * return value from estimateUserOpGas + */ +export interface EstimateUserOpGasResult { + /** + * the preVerification gas used by this UserOperation. + */ + preVerificationGas: BigNumberish + /** + * gas used for validation of this UserOperation, including account creation + */ + verificationGas: BigNumberish + /** + * the deadline after which this UserOperation is invalid (not a gas estimation parameter, but returned by validation + */ + deadline?: BigNumberish + /** + * estimated cost of calling the account with the given callData + */ + callGasLimit: BigNumberish +} + +export interface CallUserOperationResult extends EstimateUserOpGasResult { + + /** + * true/false whether this userOp execution succeeds + */ + success: boolean + + /** + * optional: in case the execution fails, attempt to return the revert reason code + */ + reason?: string + + /** + * the total amount to be paid for this execution (including validation) + */ + + actualGasCost?: number + + /** + * the gas price used to calculate gas cost (depends on the UserOp's priorityFee, maxFeePerGas and also on the network's basefee) + */ + actualGasPrice?: number +} + +export interface UserOperationReceipt { + /// the request hash + userOpHash: string + /// the account sending this UserOperation + sender: string + /// account nonce + nonce: BigNumberish + /// the paymaster used for this userOp (or empty) + paymaster?: string + /// actual payment for this UserOperation (by either paymaster or account) + actualGasCost: BigNumberish + /// gas price used for payment (based on UserOp gas parameters and basefee) + actualGasPrice: BigNumberish + /// did this execution completed without revert + success: boolean + /// in case of revert, this is the revert reason + reason?: string + /// the logs generated by this UserOperation (not including logs of other UserOperations in the same bundle) + logs: any[] + + // the transaction receipt for this transaction (of entire bundle, not only this UserOperation) + receipt: TransactionReceipt +} + export class UserOpMethodHandler { constructor ( readonly provider: Provider, @@ -27,16 +98,6 @@ export class UserOpMethodHandler { ) { } - clientVersion?: string - - async isGeth (): Promise { - if (this.clientVersion == null) { - this.clientVersion = await (this.provider as JsonRpcProvider).send('web3_clientVersion', []) - } - debug('client version', this.clientVersion) - return this.clientVersion?.match('Geth') != null - } - async getSupportedEntryPoints (): Promise { return [this.config.entryPoint] } @@ -52,16 +113,24 @@ export class UserOpMethodHandler { return beneficiary } - async validateUserOperation (userOp1: UserOperationStruct, requireSignature = true): Promise { + async _validateParameters (userOp1: UserOperationStruct, entryPointInput: string, requireSignature = true, requireGasParams = true): Promise { + requireCond(entryPointInput != null, 'No entryPoint param', -32602) + + if (entryPointInput.toLowerCase() !== this.config.entryPoint.toLowerCase()) { + throw new Error(`The EntryPoint at "${entryPointInput}" is not supported. This bundler uses ${this.config.entryPoint}`) + } + // minimal sanity check: userOp exists, and all members are hex requireCond(userOp1 != null, 'No UserOperation param') const userOp = await resolveProperties(userOp1) as any - const fieldNames = 'sender,nonce,initCode,callData,callGasLimit,verificationGasLimit,preVerificationGas,maxFeePerGas,maxPriorityFeePerGas,paymasterAndData' - const fields = fieldNames.split(',') + const fields = ['sender', 'nonce', 'initCode', 'callData', 'paymasterAndData'] if (requireSignature) { fields.push('signature') } + if (requireGasParams) { + fields.push('preVerificationGas', 'verificationGasLimit', 'callGasLimit', 'maxFeePerGas', 'maxPriorityFeePerGas') + } fields.forEach(key => { requireCond(userOp[key] != null, 'Missing userOp field: ' + key + JSON.stringify(userOp), -32602) const value: string = userOp[key].toString() @@ -69,6 +138,112 @@ export class UserOpMethodHandler { }) } + /** + * eth_callUserOperation RPC api. + * @param userOp1 + * @param entryPointInput + */ + async callUserOperation (userOp1: UserOperationStruct, entryPointInput: string): Promise { + const userOp = await resolveProperties(userOp1) + // TODO: currently performs separately the validation and execution. + // should attempt to execute entire UserOp, so it can detect execution code dependency on validatiokn step. + const ret = await this.estimateUserOperationGas(userOp1, entryPointInput) + let success: boolean + let reason: string | undefined + try { + await this.provider.call({ + from: entryPointInput, + to: userOp.sender, + data: userOp.callData, + gasLimit: userOp.callGasLimit + }) + success = true + } catch (e: any) { + success = false + reason = e.error?.message ?? e.message + } + + return { + ...ret as any, + success, + reason + } + } + + /** + * eth_estimateUserOperationGas RPC api. + * @param userOp1 + * @param entryPointInput + */ + async estimateUserOperationGas (userOp1: UserOperationStruct, entryPointInput: string): Promise { + const provider = this.provider as JsonRpcProvider + + const userOp = { + ...await resolveProperties(userOp1), + paymasterAndData: '0x', + signature: '0x'.padEnd(66 * 2, '1b'), // TODO: each wallet has to put in a signature in the correct size + maxFeePerGas: 0, + maxPriorityFeePerGas: 0, + preVerificationGas: 0, + verificationGasLimit: 10e6 + } + + // todo: checks the existence of parameters, but since we hexlify the inputs, it fails to validate + await this._validateParameters(deepHexlify(userOp), entryPointInput) + + const entryPointFromAddrZero = EntryPoint__factory.connect(entryPointInput, provider.getSigner(AddressZero)) + const errorResult = await entryPointFromAddrZero.callStatic.simulateValidation(userOp).catch(e => e) + if (errorResult.errorName !== 'SimulationResult') { + throw errorResult + } + + let { + preOpGas, + deadline + } = errorResult.errorArgs + const callGasLimit = await this.provider.estimateGas({ + from: this.entryPoint.address, + to: userOp.sender, + data: userOp.callData + }).then(b => b.toNumber()) + deadline = BigNumber.from(deadline) + if (deadline === 0) { + deadline = undefined + } + const preVerificationGas = calcPreVerificationGas(userOp) + const verificationGas = BigNumber.from(preOpGas).toNumber() + return { + preVerificationGas, + verificationGas, + deadline, + callGasLimit + } + } + + // attempt "callUserOp" by using traceCall on handleOps, and parse the trace result. + // can only report gas if real gas values are put (so it is not good for estimateGas + async callUserOp_usingtraceCall (userOp: UserOperationStruct): Promise { + const provider = this.provider as JsonRpcProvider + + const handleOpsCallData = this.entryPoint.interface.encodeFunctionData('handleOps', [[deepHexlify(userOp)], await this.selectBeneficiary()]) + requireCond(await isGeth(this.provider as JsonRpcProvider), 'Implemented only for GETH', -32000) + const result: BundlerCollectorReturn = await debug_traceCall(provider, { + from: ethers.constants.AddressZero, + to: this.entryPoint.address, + data: handleOpsCallData, + gasLimit: 10e6 + }, { tracer: bundlerCollectorTracer }) + result.debug = result.debug.map(err => { + // err = replaceMethodSig(err) + const m = err.toString().match(/REVERT (.*)/) + if (m == null) return err + const r = decodeErrorReason(m[1]) + if (r == null) return err + return `REVERT with "${r.message}" ${r.paymaster ?? ''}` + }) + console.log('result=', result, result.logs) + } + /** * simulate UserOperation. * Note that simulation requires debug API: @@ -76,19 +251,13 @@ export class UserOpMethodHandler { * @param userOp1 * @param entryPointInput */ - async simulateUserOp (userOp1: UserOperationStruct, entryPointInput: string): Promise { + async _simulateUserOp (userOp1: UserOperationStruct, entryPointInput: string): Promise { const userOp = deepHexlify(await resolveProperties(userOp1)) - await this.validateUserOperation(userOp, false) - requireCond(entryPointInput != null, 'No entryPoint param') - - if (entryPointInput.toLowerCase() !== this.config.entryPoint.toLowerCase()) { - throw new Error(`The EntryPoint at "${entryPointInput}" is not supported. This bundler uses ${this.config.entryPoint}`) - } - const simulateCall = this.entryPoint.interface.encodeFunctionData('simulateValidation', [userOp]) + await this._validateParameters(userOp, entryPointInput, true) const revert = await this.entryPoint.callStatic.simulateValidation(userOp, { gasLimit: 10e6 }).catch(e => e) - // simulation always reverts... + // simulation always reverts. SimulateResult is a valid response with no error if (revert.errorName === 'FailedOp') { let data: any if (revert.errorArgs.paymaster !== AddressZero) { @@ -96,44 +265,14 @@ export class UserOpMethodHandler { } throw new RpcError(revert.errorArgs.reason, -32500, data) } - const provider = this.provider as JsonRpcProvider - if (await this.isGeth()) { - debug('=== sending simulate') - const simulationGas = BigNumber.from(50000).add(userOp.verificationGasLimit) - - const result: BundlerCollectorReturn = await debug_traceCall(provider, { - from: ethers.constants.AddressZero, - to: this.entryPoint.address, - data: simulateCall, - gasLimit: simulationGas - }, { tracer: bundlerCollectorTracer }) - - debug('=== simulation result:', result) - // todo: validate keccak, access - // todo: block access to no-code addresses (might need update to tracer) - - const bannedOpCodes = new Set(['GASPRICE', 'GASLIMIT', 'DIFFICULTY', 'TIMESTAMP', 'BASEFEE', 'BLOCKHASH', 'NUMBER', 'SELFBALANCE', 'BALANCE', 'ORIGIN', 'GAS', 'CREATE', 'COINBASE']) - - const paymaster = (userOp.paymasterAndData?.length ?? 0) >= 42 ? userOp.paymasterAndData.toString().slice(0, 42) : undefined - const validateOpcodes = result.numberLevels['0'].opcodes - const validatePaymasterOpcodes = result.numberLevels['1'].opcodes - // console.log('debug=', result.debug.join('\n- ')) - Object.keys(validateOpcodes).forEach(opcode => - requireCond(!bannedOpCodes.has(opcode), `account uses banned opcode: ${opcode}`, 32501) - ) - Object.keys(validatePaymasterOpcodes).forEach(opcode => - requireCond(!bannedOpCodes.has(opcode), `paymaster uses banned opcode: ${opcode}`, 32501, { paymaster }) - ) - if (userOp.initCode.length > 2) { - requireCond((validateOpcodes.CREATE2 ?? 0) <= 1, 'initCode with too many CREATE2', 32501) - } else { - requireCond((validateOpcodes.CREATE2 ?? 0) < 1, 'banned opcode: CREATE2', 32501) - } - requireCond((validatePaymasterOpcodes.CREATE2 ?? 0) < 1, 'paymaster uses banned opcode: CREATE2', 32501, { paymaster }) + if (await isGeth(this.provider as JsonRpcProvider)) { + await opcodeScanner(userOp1, this.entryPoint) } } async sendUserOperation (userOp1: UserOperationStruct, entryPointInput: string): Promise { + await this._validateParameters(userOp1, entryPointInput, true) + const userOp = await resolveProperties(userOp1) if (entryPointInput.toLowerCase() !== this.config.entryPoint.toLowerCase()) { throw new Error(`The EntryPoint at "${entryPointInput}" is not supported. This bundler uses ${this.config.entryPoint}`) @@ -141,7 +280,7 @@ export class UserOpMethodHandler { console.log(`UserOperation: Sender=${userOp.sender} EntryPoint=${entryPointInput} Paymaster=${hexValue(userOp.paymasterAndData)}`) - await this.simulateUserOp(userOp1, entryPointInput) + await this._simulateUserOp(userOp, entryPointInput) const beneficiary = await this.selectBeneficiary() const userOpHash = await this.entryPoint.getUserOpHash(userOp) @@ -163,20 +302,56 @@ export class UserOpMethodHandler { } async _getUserOperationEvent (userOpHash: string): Promise { + // TODO: eth_getLogs is throttled. must be acceptable for finding a UserOperation by hash const event = await this.entryPoint.queryFilter(this.entryPoint.filters.UserOperationEvent(userOpHash)) return event[0] } - async getUserOperationReceipt (userOpHash: string): Promise { + // filter full bundle logs, and leave only logs for the given userOpHash + // @param userOpEvent - the event of our UserOp (known to exist in the logs) + // @param logs - full bundle logs. after each group of logs there is a single UserOperationEvent with unique hash. + _filterLogs (userOpEvent: UserOperationEventEvent, logs: Log[]): Log[] { + let startIndex = -1 + let endIndex = -1 + logs.forEach((log, index) => { + if (log?.topics[0] === userOpEvent.topics[0]) { + // process UserOperationEvent + if (log.topics[1] === userOpEvent.topics[1]) { + // it's our userOpHash. save as end of logs array + endIndex = index + } else { + // it's a different hash. remember it as beginning index, but only if we didn't find our end index yet. + if (endIndex === -1) { + startIndex = index + } + } + } + }) + if (endIndex === -1) { + throw new Error('fatal: no UserOperationEvent in logs') + } + return logs.slice(startIndex + 1, endIndex) + } + + async getUserOperationReceipt (userOpHash: string): Promise { requireCond(userOpHash?.toString()?.match(HEX_REGEX) != null, 'Missing/invalid userOpHash', -32601) const event = await this._getUserOperationEvent(userOpHash) if (event == null) { return null } - const receipt = await event.getTransactionReceipt() as any - receipt.status = event.args.success ? 1 : 0 - receipt.userOpHash = userOpHash - return deepHexlify(receipt) + + const receipt = await event.getTransactionReceipt() + const logs = this._filterLogs(event, receipt.logs) + return { + userOpHash, + sender: event.args.sender, + nonce: event.args.nonce, + actualGasCost: event.args.actualGasCost, + actualGasPrice: event.args.actualGasPrice, + success: event.args.success, + logs, + receipt + } } async getUserOperationTransactionByHash (userOpHash: string): Promise { diff --git a/packages/bundler/src/opcodeScanner.ts b/packages/bundler/src/opcodeScanner.ts new file mode 100644 index 0000000..310fc06 --- /dev/null +++ b/packages/bundler/src/opcodeScanner.ts @@ -0,0 +1,120 @@ +import { EntryPoint, UserOperationStruct } from '@account-abstraction/contracts' +import { JsonRpcProvider } from '@ethersproject/providers' +import { hexlify, hexZeroPad, keccak256, resolveProperties } from 'ethers/lib/utils' +import { BigNumber, ethers } from 'ethers' +import { BundlerCollectorReturn, bundlerCollectorTracer, ExitInfo } from './BundlerCollectorTracer' +import { debug_traceCall } from './GethTracer' +import { decodeErrorReason } from '@account-abstraction/utils' +import { requireCond } from './utils' + +import Debug from 'debug' +import { inspect } from 'util' + +const debug = Debug('aa.handler.opcodes') + +export async function isGeth (provider: JsonRpcProvider): Promise { + const p = provider.send as any + if (p._clientVersion == null) { + p._clientVersion = await provider.send('web3_clientVersion', []) + } + + debug('client version', p._clientVersion) + return p._clientVersion?.match('Geth') != null +} + +/** + * perform opcode scanning rules on the given UserOperation. + * throw a detailed exception on failure. + * Uses eth_traceCall of geth + */ +export async function opcodeScanner (userOp1: UserOperationStruct, entryPoint: EntryPoint): Promise { + const provider = entryPoint.provider as JsonRpcProvider + const userOp = await resolveProperties(userOp1) + const simulateCall = entryPoint.interface.encodeFunctionData('simulateValidation', [userOp]) + + const simulationGas = BigNumber.from(userOp.preVerificationGas).add(userOp.verificationGasLimit) + + const result: BundlerCollectorReturn = await debug_traceCall(provider, { + from: ethers.constants.AddressZero, + to: entryPoint.address, + data: simulateCall, + gasLimit: simulationGas + }, { tracer: bundlerCollectorTracer }) + + if (result.calls.length >= 1) { + const last = result.calls[result.calls.length - 1] + if (last.type === 'REVERT') { + const data = (last as ExitInfo).data + const sighash = data.slice(0, 10) + const errorSig = Object.keys(entryPoint.interface.errors).find(err => keccak256(Buffer.from(err)).startsWith(sighash)) + if (errorSig != null) { + const errorFragment = entryPoint.interface.errors[errorSig] + const errParams = entryPoint.interface.decodeErrorResult(errorFragment, data) + const errName = `${errorFragment.name}(${errParams.toString()})` + if (!errorSig.includes('Result')) { + // a real error, not a result. + throw new Error(errName) + } + } else { + // not a known error of EntryPoint (probably, only Error(string), since FailedOp is handled above) + const err = decodeErrorReason(data) + console.log('=== revert reason=', err) + throw new Error(err != null ? err.message : data) + } + } + } + + debug('=== simulation result:', inspect(result, true, 10, true)) + // todo: block access to no-code addresses (might need update to tracer) + + const bannedOpCodes = new Set(['GASPRICE', 'GASLIMIT', 'DIFFICULTY', 'TIMESTAMP', 'BASEFEE', 'BLOCKHASH', 'NUMBER', 'SELFBALANCE', 'BALANCE', 'ORIGIN', 'GAS', 'CREATE', 'COINBASE']) + + const paymaster = (userOp.paymasterAndData?.length ?? 0) >= 42 ? hexlify(userOp.paymasterAndData).slice(0, 42) : undefined + if (Object.values(result.numberLevels).length < 2) { + // console.log('calls=', result.calls.map(x=>JSON.stringify(x)).join('\n')) + // console.log('debug=', result.debug) + throw new Error('Unexpected traceCall result: no NUMBER opcodes, and not REVERT') + } + const validateOpcodes = result.numberLevels['0'].opcodes + const validatePaymasterOpcodes = result.numberLevels['1'].opcodes + // console.log('debug=', result.debug.join('\n- ')) + Object.keys(validateOpcodes).forEach(opcode => + requireCond(!bannedOpCodes.has(opcode), `account uses banned opcode: ${opcode}`, -32501) + ) + Object.keys(validatePaymasterOpcodes).forEach(opcode => + requireCond(!bannedOpCodes.has(opcode), `paymaster uses banned opcode: ${opcode}`, -32501, { paymaster }) + ) + if (userOp.initCode.length > 2) { + requireCond((validateOpcodes.CREATE2 ?? 0) <= 1, 'initCode with too many CREATE2', -32501) + } else { + requireCond((validateOpcodes.CREATE2 ?? 0) < 1, 'account uses banned opcode: CREATE2', -32501) + } + requireCond((validatePaymasterOpcodes.CREATE2 ?? 0) < 1, 'paymaster uses banned opcode: CREATE2', -32501, { paymaster }) + + const accountSlots = new Set() + const senderPadded = hexZeroPad(userOp.sender, 32).toLowerCase() + result.keccak.forEach(k => { + const value = keccak256(k).slice(2) + if (k.startsWith(senderPadded)) { + // console.log('added mapping (balance) slot', value) + accountSlots.add(value) + } + if (k.length === 130 && accountSlots.has(k.slice(-64))) { + // console.log('added double-mapping (allowance) slot', value) + accountSlots.add(value) + } + }) + Object.entries(result.numberLevels[0].access).forEach(([addr, { + reads, + writes + }]) => { + // console.log('testing access addr', addr, 'op.sender=', userOp.sender) + if (addr === userOp.sender.toLowerCase()) { + // allowed to access itself + return + } + Object.keys(writes).forEach(slot => requireCond(accountSlots.has(slot), `forbidden write to addr ${addr} slot ${slot}`, -32501)) + Object.keys(reads).forEach(slot => requireCond(accountSlots.has(slot), `forbidden read from addr ${addr} slot ${slot}`, -32501)) + }) + return result +} diff --git a/packages/bundler/src/runBundler.ts b/packages/bundler/src/runBundler.ts index 4cdd849..4bc8efc 100644 --- a/packages/bundler/src/runBundler.ts +++ b/packages/bundler/src/runBundler.ts @@ -108,7 +108,7 @@ export async function runBundler (argv: string[], overrideExit = true): Promise< } const newMnemonic = Wallet.createRandom().mnemonic.phrase fs.writeFileSync(mnemonicFile, newMnemonic) - console.log('creaed mnemonic file', mnemonicFile) + console.log('created mnemonic file', mnemonicFile) process.exit(1) } const provider: BaseProvider = diff --git a/packages/bundler/src/utils.ts b/packages/bundler/src/utils.ts index acd7d30..bd205be 100644 --- a/packages/bundler/src/utils.ts +++ b/packages/bundler/src/utils.ts @@ -1,34 +1,13 @@ -import { hexlify } from 'ethers/lib/utils' - -/** - * hexlify all members of object, recursively - * @param obj - */ -export function deepHexlify (obj: any): any { - if (typeof obj === 'function') { - return undefined - } - if (obj == null || typeof obj === 'string' || typeof obj === 'boolean') { - return obj - } else if (obj._isBigNumber != null || typeof obj !== 'object') { - return hexlify(obj) - } - if (Array.isArray(obj)) { - return obj.map(member => deepHexlify(member)) - } - return Object.keys(obj) - .reduce((set, key) => ({ - ...set, - [key]: deepHexlify(obj[key]) - }), {}) -} export class RpcError extends Error { + // error codes from: https://eips.ethereum.org/EIPS/eip-1474 constructor (msg: string, readonly code?: number, readonly data: any = undefined) { super(msg) } } export function requireCond (cond: boolean, msg: string, code?: number, data: any = undefined): void { - if (!cond) throw new RpcError(msg, code, data) + if (!cond) { + throw new RpcError(msg, code, data) + } } diff --git a/packages/bundler/test/BundlerServer.test.ts b/packages/bundler/test/BundlerServer.test.ts index b9b909c..1b0c01f 100644 --- a/packages/bundler/test/BundlerServer.test.ts +++ b/packages/bundler/test/BundlerServer.test.ts @@ -1,14 +1,3 @@ describe('BundleServer', function () { - describe('preflightCheck', function () { - it('') - }) - describe('', function () { - it('') - }) - describe('', function () { - it('') - }) - describe('', function () { - it('') - }) + it('preflightCheck') }) diff --git a/packages/bundler/test/OpcodeScanner.test.ts b/packages/bundler/test/OpcodeScanner.test.ts new file mode 100644 index 0000000..0787670 --- /dev/null +++ b/packages/bundler/test/OpcodeScanner.test.ts @@ -0,0 +1,94 @@ +import { EntryPoint, EntryPoint__factory, UserOperationStruct } from '@account-abstraction/contracts' +import { hexConcat, hexlify, parseEther } from 'ethers/lib/utils' +import { ethers } from 'hardhat' +import { expect } from 'chai' +import { TestCoin, TestCoin__factory, TestRulesAccount, TestRulesAccountDeployer, TestRulesAccountDeployer__factory, TestRulesAccount__factory } from '../src/types' +import { isGeth, opcodeScanner } from '../src/opcodeScanner' + +describe('opcode banning', () => { + let deployer: TestRulesAccountDeployer + let paymaster: TestRulesAccount + let entryPoint: EntryPoint + let token: TestCoin + + async function testUserOp (validateRule: string = '', initFunc?: string, pmRule?: string): Promise { + return await opcodeScanner(await createTestUserOp(validateRule, initFunc, pmRule), entryPoint) + } + + async function createTestUserOp (validateRule: string = '', initFunc?: string, pmRule?: string): Promise { + if (initFunc === undefined) { + initFunc = deployer.interface.encodeFunctionData('create', ['', token.address]) + } + + const initCode = hexConcat([ + deployer.address, + initFunc + ]) + const paymasterAndData = pmRule == null ? '0x' : hexConcat([paymaster.address, Buffer.from(pmRule)]) + let signature: string + if (validateRule.startsWith('deadline:')) { + signature = hexlify(validateRule.slice(9)) + } else { + signature = hexlify(Buffer.from(validateRule)) + } + const sender = await deployer.callStatic.create('', token.address) + return { + sender, + initCode, + signature, + nonce: 0, + paymasterAndData, + callData: '0x', + callGasLimit: 1e6, + verificationGasLimit: 1e6, + preVerificationGas: 50000, + maxFeePerGas: 0, + maxPriorityFeePerGas: 0 + } + } + + before(async function () { + const ethersSigner = ethers.provider.getSigner() + entryPoint = await new EntryPoint__factory(ethersSigner).deploy() + paymaster = await new TestRulesAccount__factory(ethersSigner).deploy() + await entryPoint.depositTo(paymaster.address, { value: parseEther('0.1') }) + await paymaster.addStake(entryPoint.address, { value: parseEther('0.1') }) + deployer = await new TestRulesAccountDeployer__factory(ethersSigner).deploy() + token = await new TestCoin__factory(ethersSigner).deploy() + + if (!await isGeth(ethers.provider)) { + console.log('opcode banning tests can only run with geth') + this.skip() + } + }) + it('should accept plain request', async () => { + await testUserOp() + }) + it('test sanity: reject unknown rule', async () => { + expect(await testUserOp('') + .catch(e => e.message)).to.match(/unknown rule/) + }) + it('should fail with bad opcode in ctr', async () => { + expect(await testUserOp('', + deployer.interface.encodeFunctionData('create', ['coinbase', token.address])) + .catch(e => e.message)).to.match(/account uses banned opcode: COINBASE/) + }) + it('should fail with bad opcode in paymaster', async () => { + expect(await testUserOp('', undefined, 'coinbase') + .catch(e => e.message)).to.match(/paymaster uses banned opcode: COINBASE/) + }) + it('should fail with bad opcode in validation', async () => { + expect(await testUserOp('blockhash') + .catch(e => e.message)).to.match(/account uses banned opcode: BLOCKHASH/) + }) + it('should fail if creating too many', async () => { + expect(await testUserOp('create2') + .catch(e => e.message)).to.match(/initCode with too many CREATE2/) + }) + it('should succeed if referencing self token balance', async () => { + await testUserOp('balance-self') + }) + it('should fail if referencing other token balance', async () => { + expect(await testUserOp('balance-1').catch(e => e)).to.match(/forbidden read/) + }) +}) diff --git a/packages/bundler/test/UserOpMethodHandler.test.ts b/packages/bundler/test/UserOpMethodHandler.test.ts index 9f0bb90..c11b96b 100644 --- a/packages/bundler/test/UserOpMethodHandler.test.ts +++ b/packages/bundler/test/UserOpMethodHandler.test.ts @@ -1,26 +1,33 @@ -import 'source-map-support/register' import { BaseProvider, JsonRpcSigner } from '@ethersproject/providers' import { assert, expect } from 'chai' import { ethers } from 'hardhat' -import { parseEther } from 'ethers/lib/utils' +import { parseEther, resolveProperties } from 'ethers/lib/utils' -import { UserOpMethodHandler } from '../src/UserOpMethodHandler' +import { UserOperationReceipt, UserOpMethodHandler } from '../src/UserOpMethodHandler' import { BundlerConfig } from '../src/BundlerConfig' -import { - EntryPoint, - SimpleAccountDeployer__factory, - UserOperationStruct -} from '@account-abstraction/contracts' +import { EntryPoint, SimpleAccountDeployer__factory, UserOperationStruct } from '@account-abstraction/contracts' import { Wallet } from 'ethers' import { DeterministicDeployer, SimpleAccountAPI } from '@account-abstraction/sdk' import { postExecutionDump } from '@account-abstraction/utils/dist/src/postExecCheck' -import { BundlerHelper, SampleRecipient } from '../src/types' +import { + BundlerHelper, SampleRecipient, TestRulesAccount__factory, TestRulesAccount +} from '../src/types' +import { deepHexlify } from '@account-abstraction/utils' +import { UserOperationEventEvent } from '@account-abstraction/contracts/dist/types/EntryPoint' + +// resolve all property and hexlify. +// (UserOpMethodHandler receives data from the network, so we need to pack our generated values) +async function resolveHexlify (a: any): Promise { + return deepHexlify(await resolveProperties(a)) +} describe('UserOpMethodHandler', function () { const helloWorld = 'hello world' + let accountDeployerAddress: string + let methodHandler: UserOpMethodHandler let provider: BaseProvider let signer: JsonRpcSigner @@ -34,6 +41,9 @@ describe('UserOpMethodHandler', function () { provider = ethers.provider signer = ethers.provider.getSigner() + DeterministicDeployer.init(ethers.provider) + accountDeployerAddress = await DeterministicDeployer.deploy(SimpleAccountDeployer__factory.bytecode) + const EntryPointFactory = await ethers.getContractFactory('EntryPoint') entryPoint = await EntryPointFactory.deploy() @@ -68,6 +78,45 @@ describe('UserOpMethodHandler', function () { }) }) + describe('query rpc calls: eth_estimateUserOperationGas, eth_callUserOperation', function () { + let owner: Wallet + let smartAccountAPI: SimpleAccountAPI + let target: string + before('init', async () => { + owner = Wallet.createRandom() + target = await Wallet.createRandom().getAddress() + smartAccountAPI = new SimpleAccountAPI({ + provider, + entryPointAddress: entryPoint.address, + owner, + factoryAddress: accountDeployerAddress + }) + }) + it('estimateUserOperationGas should estimate even without eth', async () => { + const op = await smartAccountAPI.createSignedUserOp({ + target, + data: '0xdeadface' + }) + const ret = await methodHandler.estimateUserOperationGas(await resolveHexlify(op), entryPoint.address) + // verification gas should be high - it creates this wallet + expect(ret.verificationGas).to.be.closeTo(1e6, 300000) + // execution should be quite low. + // (NOTE: actual execution should revert: it only succeeds because the wallet is NOT deployed yet, + // and estimation doesn't perform full deploy-validate-execute cycle) + expect(ret.callGasLimit).to.be.closeTo(25000, 10000) + }) + it('callUserOperation should work without eth', async () => { + const op = await resolveProperties(await smartAccountAPI.createSignedUserOp({ + target, + data: '0xdeadface' + })) + const ret = await methodHandler.callUserOperation(await resolveHexlify(op), entryPoint.address) + // (NOTE: actual execution should revert: it only succeeds because the wallet is NOT deployed yet, + // and view-call doesn't perform full deploy-validate-execute cycle) + expect(ret.success).to.equal(true, ret as any) + }) + }) + describe('sendUserOperation', function () { let userOperation: UserOperationStruct let accountAddress: string @@ -89,14 +138,14 @@ describe('UserOpMethodHandler', function () { value: parseEther('1') }) - userOperation = await smartAccountAPI.createSignedUserOp({ + userOperation = await resolveProperties(await smartAccountAPI.createSignedUserOp({ data: sampleRecipient.interface.encodeFunctionData('something', [helloWorld]), target: sampleRecipient.address - }) + })) }) it('should send UserOperation transaction to BundlerHelper', async function () { - const userOpHash = await methodHandler.sendUserOperation(userOperation, entryPoint.address) + const userOpHash = await methodHandler.sendUserOperation(await resolveHexlify(userOperation), entryPoint.address) const req = await entryPoint.queryFilter(entryPoint.filters.UserOperationEvent(userOpHash)) const transactionReceipt = await req[0].getTransactionReceipt() @@ -129,7 +178,7 @@ describe('UserOpMethodHandler', function () { }) try { - await methodHandler.sendUserOperation(op, entryPoint.address) + await methodHandler.sendUserOperation(await resolveHexlify(op), entryPoint.address) throw Error('expected fail') } catch (e: any) { expect(e.message).to.match(/account didn't pay prefund/) @@ -149,7 +198,7 @@ describe('UserOpMethodHandler', function () { target: sampleRecipient.address, gasLimit: 1e6 }) - const id = await methodHandler.sendUserOperation(op, entryPoint.address) + const id = await methodHandler.sendUserOperation(await resolveHexlify(op), entryPoint.address) // { // console.log('wrong method') @@ -187,7 +236,7 @@ describe('UserOpMethodHandler', function () { target: sampleRecipient.address }) try { - await methodHandler.sendUserOperation(op, entryPoint.address) + await methodHandler.sendUserOperation(await resolveHexlify(op), entryPoint.address) throw new Error('expected to revert') } catch (e: any) { expect(e.message).to.match(/preVerificationGas too low/) @@ -195,4 +244,89 @@ describe('UserOpMethodHandler', function () { }) }) }) + + describe('#_filterLogs', function () { + // test events, good enough for _filterLogs + function userOpEv (hash: any): any { + return { + topics: ['userOpTopic', hash] + } as any + } + + function ev (topic: any): UserOperationEventEvent { + return { + topics: [topic] + } as any + } + + const ev1 = ev(1) + const ev2 = ev(2) + const ev3 = ev(3) + const u1 = userOpEv(10) + const u2 = userOpEv(20) + const u3 = userOpEv(30) + it('should fail if no UserOperationEvent', async () => { + expect(() => methodHandler._filterLogs(u1, [ev1])).to.throw('no UserOperationEvent in logs') + }) + it('should return empty array for single-op bundle with no events', async () => { + expect(methodHandler._filterLogs(u1, [u1])).to.eql([]) + }) + it('should return events for single-op bundle', async () => { + expect(methodHandler._filterLogs(u1, [ev1, ev2, u1])).to.eql([ev1, ev2]) + }) + it('should return events for middle userOp in a bundle', async () => { + expect(methodHandler._filterLogs(u1, [ev2, u2, ev1, u1, ev3, u3])).to.eql([ev1]) + }) + }) + + describe('#getUserOperationReceipt', function () { + let userOpHash: string + let receipt: UserOperationReceipt + let acc: TestRulesAccount + before(async () => { + acc = await new TestRulesAccount__factory(signer).deploy() + const op: UserOperationStruct = { + sender: acc.address, + initCode: '0x', + nonce: 0, + callData: '0x', + callGasLimit: 1e6, + verificationGasLimit: 1e6, + preVerificationGas: 50000, + maxFeePerGas: 1e6, + maxPriorityFeePerGas: 1e6, + paymasterAndData: '0x', + signature: Buffer.from('emit-msg') + } + await entryPoint.depositTo(acc.address, { value: parseEther('1') }) + // await signer.sendTransaction({to:acc.address, value: parseEther('1')}) + console.log(2) + userOpHash = await entryPoint.getUserOpHash(op) + const beneficiary = signer.getAddress() + await entryPoint.handleOps([op], beneficiary).then(async ret => await ret.wait()) + const rcpt = await methodHandler.getUserOperationReceipt(userOpHash) + if (rcpt == null) { + throw new Error('getUserOperationReceipt returns null') + } + receipt = rcpt + }) + + it('should return null for nonexistent hash', async () => { + expect(await methodHandler.getUserOperationReceipt(ethers.constants.HashZero)).to.equal(null) + }) + + it('receipt should contain only userOp-specific events..', async () => { + expect(receipt.logs.length).to.equal(1) + const evParams = acc.interface.decodeEventLog('TestMessage', receipt.logs[0].data, receipt.logs[0].topics) + expect(evParams.eventSender).to.equal(acc.address) + }) + it('general receipt fields', () => { + expect(receipt.success).to.equal(true) + expect(receipt.sender).to.equal(acc.address) + }) + it('receipt should carry transaction receipt', () => { + // one UserOperationEvent, and one op-specific event. + expect(receipt.receipt.logs.length).to.equal(2) + }) + }) }) diff --git a/packages/bundler/test/opcodes.test.ts b/packages/bundler/test/opcodes.test.ts new file mode 100644 index 0000000..29a074e --- /dev/null +++ b/packages/bundler/test/opcodes.test.ts @@ -0,0 +1,3 @@ +describe('opcode banning', () => { + +}) diff --git a/packages/bundler/test/runBundler.test.ts b/packages/bundler/test/runBundler.test.ts index 22031fe..7b64650 100644 --- a/packages/bundler/test/runBundler.test.ts +++ b/packages/bundler/test/runBundler.test.ts @@ -1,5 +1,3 @@ describe('runBundler', function () { - describe('resolveConfiguration', function () { - it('') - }) + it('resolveConfiguration') }) diff --git a/packages/bundler/test/tracer.test.ts b/packages/bundler/test/tracer.test.ts index 7d39d71..dafda88 100644 --- a/packages/bundler/test/tracer.test.ts +++ b/packages/bundler/test/tracer.test.ts @@ -23,7 +23,6 @@ describe('#bundlerCollectorTracer', () => { it('should count opcodes on depth>1', async () => { const ret = await traceExecSelf(tester.interface.encodeFunctionData('callTimeStamp'), false, true) - console.log('ret=', ret, ret.numberLevels) const execEvent = tester.interface.decodeEventLog('ExecSelfResult', ret.logs[0].data, ret.logs[0].topics) expect(execEvent.success).to.equal(true) expect(ret.numberLevels[0].opcodes.TIMESTAMP).to.equal(1) @@ -77,7 +76,7 @@ describe('#bundlerCollectorTracer', () => { }) }) }) - 4 + it('should report direct use of GAS opcode', async () => { const ret = await traceExecSelf(tester.interface.encodeFunctionData('testCallGas'), false) expect(ret.numberLevels['0'].opcodes.GAS).to.eq(1) @@ -90,19 +89,4 @@ describe('#bundlerCollectorTracer', () => { const ret = await traceExecSelf(callDoNothing, false) expect(ret.numberLevels['0'].opcodes.GAS).to.be.undefined }) - - it.skip('should collect reverted call info', async () => { - const revertingCallData = tester.interface.encodeFunctionData('callRevertingFunction', [true]) - - const tracer = bundlerCollectorTracer - const ret = await debug_traceCall(provider, { - to: tester.address, - data: revertingCallData - }, { - tracer - }) as BundlerCollectorReturn - - expect(ret.debug[0]).to.include(['fault']) - // todo: tests for failures. (e.g. detect oog) - }) }) diff --git a/packages/bundler/test/utils.test.ts b/packages/bundler/test/utils.test.ts index 9d6762e..ef9c1dd 100644 --- a/packages/bundler/test/utils.test.ts +++ b/packages/bundler/test/utils.test.ts @@ -1,6 +1,6 @@ import { expect } from 'chai' import { BigNumber } from 'ethers' -import { deepHexlify } from '../src/utils' +import { deepHexlify } from '@account-abstraction/utils' describe('#deepHexlify', function () { it('empty', () => { diff --git a/packages/sdk/src/HttpRpcClient.ts b/packages/sdk/src/HttpRpcClient.ts index 796de1b..918279d 100644 --- a/packages/sdk/src/HttpRpcClient.ts +++ b/packages/sdk/src/HttpRpcClient.ts @@ -1,8 +1,9 @@ import { JsonRpcProvider } from '@ethersproject/providers' import { ethers } from 'ethers' -import { hexValue, resolveProperties } from 'ethers/lib/utils' +import { resolveProperties } from 'ethers/lib/utils' import { UserOperationStruct } from '@account-abstraction/contracts' import Debug from 'debug' +import { deepHexlify } from '@account-abstraction/utils' const debug = Debug('aa.rpc') @@ -39,30 +40,25 @@ export class HttpRpcClient { */ async sendUserOpToBundler (userOp1: UserOperationStruct): Promise { await this.initializing - const userOp = await resolveProperties(userOp1) - const hexifiedUserOp: any = - Object.keys(userOp) - .map(key => { - let val = (userOp as any)[key] - if (typeof val !== 'string' || !val.startsWith('0x')) { - val = hexValue(val) - } - return [key, val] - }) - .reduce((set, [k, v]) => ({ - ...set, - [k]: v - }), {}) - + const hexifiedUserOp = deepHexlify(await resolveProperties(userOp1)) const jsonRequestData: [UserOperationStruct, string] = [hexifiedUserOp, this.entryPointAddress] - await this.printUserOperation(jsonRequestData) + await this.printUserOperation('eth_sendUserOperation', jsonRequestData) return await this.userOpJsonRpcProvider .send('eth_sendUserOperation', [hexifiedUserOp, this.entryPointAddress]) } - private async printUserOperation ([userOp1, entryPointAddress]: [UserOperationStruct, string]): Promise { + async estimateUserOpGas (userOp1: Partial): Promise { + await this.initializing + const hexifiedUserOp = deepHexlify(await resolveProperties(userOp1)) + const jsonRequestData: [UserOperationStruct, string] = [hexifiedUserOp, this.entryPointAddress] + await this.printUserOperation('eth_estimateUserOperationGas', jsonRequestData) + return await this.userOpJsonRpcProvider + .send('eth_estimateUserOperationGas', [hexifiedUserOp, this.entryPointAddress]) + } + + private async printUserOperation (method: string, [userOp1, entryPointAddress]: [UserOperationStruct, string]): Promise { const userOp = await resolveProperties(userOp1) - debug('sending eth_sendUserOperation', { + debug('sending', method, { ...userOp // initCode: (userOp.initCode ?? '').length, // callData: (userOp.callData ?? '').length diff --git a/packages/utils/src/ERC4337Utils.ts b/packages/utils/src/ERC4337Utils.ts index 3da7210..8a9d46f 100644 --- a/packages/utils/src/ERC4337Utils.ts +++ b/packages/utils/src/ERC4337Utils.ts @@ -1,4 +1,4 @@ -import { defaultAbiCoder, hexConcat, keccak256 } from 'ethers/lib/utils' +import { defaultAbiCoder, hexConcat, hexlify, keccak256 } from 'ethers/lib/utils' import { UserOperationStruct } from '@account-abstraction/contracts' import { abi as entryPointAbi } from '@account-abstraction/contracts/artifacts/IEntryPoint.json' import { ethers } from 'ethers' @@ -173,3 +173,26 @@ export function rethrowError (e: any): any { } throw e } + +/** + * hexlify all members of object, recursively + * @param obj + */ +export function deepHexlify (obj: any): any { + if (typeof obj === 'function') { + return undefined + } + if (obj == null || typeof obj === 'string' || typeof obj === 'boolean') { + return obj + } else if (obj._isBigNumber != null || typeof obj !== 'object') { + return hexlify(obj) + } + if (Array.isArray(obj)) { + return obj.map(member => deepHexlify(member)) + } + return Object.keys(obj) + .reduce((set, key) => ({ + ...set, + [key]: deepHexlify(obj[key]) + }), {}) +}