AA-68 bundler rpc calls (#24)

* eth_estimateUserOp
* getUserOperationReceipt
This commit is contained in:
Dror Tirosh
2022-12-24 12:48:08 +02:00
committed by GitHub
parent 286eddb841
commit c36bbc45a6
25 changed files with 956 additions and 278 deletions

118
aabundler-launcher Executable file
View File

@@ -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

View File

@@ -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

View File

@@ -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",

View File

@@ -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;
}
}

View File

@@ -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']

View File

@@ -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

View File

@@ -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.

View File

@@ -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",

View File

@@ -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<ExitInfo | MethodInfo>
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')) {

View File

@@ -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])

View File

@@ -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.
}

View File

@@ -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
}

View File

@@ -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<boolean> {
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<string[]> {
return [this.config.entryPoint]
}
@@ -52,16 +113,24 @@ export class UserOpMethodHandler {
return beneficiary
}
async validateUserOperation (userOp1: UserOperationStruct, requireSignature = true): Promise<void> {
async _validateParameters (userOp1: UserOperationStruct, entryPointInput: string, requireSignature = true, requireGasParams = true): Promise<void> {
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<CallUserOperationResult> {
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<EstimateUserOpGasResult> {
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<void> {
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<void> {
async _simulateUserOp (userOp1: UserOperationStruct, entryPointInput: string): Promise<void> {
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<string> {
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<UserOperationEventEvent> {
// 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<any> {
// 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<UserOperationReceipt | null> {
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<any> {

View File

@@ -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<boolean> {
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<BundlerCollectorReturn> {
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<string>()
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
}

View File

@@ -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 =

View File

@@ -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)
}
}

View File

@@ -1,14 +1,3 @@
describe('BundleServer', function () {
describe('preflightCheck', function () {
it('')
})
describe('', function () {
it('')
})
describe('', function () {
it('')
})
describe('', function () {
it('')
})
it('preflightCheck')
})

View File

@@ -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<any> {
return await opcodeScanner(await createTestUserOp(validateRule, initFunc, pmRule), entryPoint)
}
async function createTestUserOp (validateRule: string = '', initFunc?: string, pmRule?: string): Promise<UserOperationStruct> {
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('<unknown-rule>')
.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/)
})
})

View File

@@ -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<any> {
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)
})
})
})

View File

@@ -0,0 +1,3 @@
describe('opcode banning', () => {
})

View File

@@ -1,5 +1,3 @@
describe('runBundler', function () {
describe('resolveConfiguration', function () {
it('')
})
it('resolveConfiguration')
})

View File

@@ -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)
})
})

View File

@@ -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', () => {

View File

@@ -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<string> {
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<void> {
async estimateUserOpGas (userOp1: Partial<UserOperationStruct>): Promise<string> {
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<void> {
const userOp = await resolveProperties(userOp1)
debug('sending eth_sendUserOperation', {
debug('sending', method, {
...userOp
// initCode: (userOp.initCode ?? '').length,
// callData: (userOp.callData ?? '').length

View File

@@ -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])
}), {})
}