From 4c05497e4572b5a2ba1b8a974d3186d625ed4f67 Mon Sep 17 00:00:00 2001 From: Venstein Date: Fri, 17 May 2024 19:46:24 +0900 Subject: [PATCH 1/2] feat: add erc4337 tracer --- eth/tracers/native/bundler_collector.go | 398 ++++++++++++++++++++++++ eth/tracers/native/bundler_executor.go | 242 ++++++++++++++ 2 files changed, 640 insertions(+) create mode 100644 eth/tracers/native/bundler_collector.go create mode 100644 eth/tracers/native/bundler_executor.go diff --git a/eth/tracers/native/bundler_collector.go b/eth/tracers/native/bundler_collector.go new file mode 100644 index 0000000000..d90656b3ff --- /dev/null +++ b/eth/tracers/native/bundler_collector.go @@ -0,0 +1,398 @@ +package native + +import ( + "encoding/json" + "math/big" + "regexp" + "strconv" + "strings" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/vm" + "github.com/ethereum/go-ethereum/eth/tracers" + "github.com/holiman/uint256" +) + +func init() { + tracers.DefaultDirectory.Register("bundlerCollectorTracer", newBundlerCollector, false) +} + +type partialStack = []*uint256.Int + +type contractSizeVal struct { + ContractSize int `json:"contractSize"` + Opcode string `json:"opcode"` +} + +type access struct { + Reads map[string]string `json:"reads"` + Writes map[string]uint64 `json:"writes"` +} + +type entryPointCall struct { + TopLevelMethodSig hexutil.Bytes `json:"topLevelMethodSig"` + TopLevelTargetAddress common.Address `json:"topLevelTargetAddress"` + Access map[common.Address]*access `json:"access"` + Opcodes map[string]uint64 `json:"opcodes"` + ExtCodeAccessInfo map[common.Address]string `json:"extCodeAccessInfo"` + ContractSize map[common.Address]*contractSizeVal `json:"contractSize"` + OOG bool `json:"oog"` +} + +type callsItem struct { + // Common + Type string `json:"type"` + + // Enter info + From common.Address `json:"from"` + To common.Address `json:"to"` + Method hexutil.Bytes `json:"method"` + Value *hexutil.Big `json:"value"` + Gas uint64 `json:"gas"` + + // Exit info + GasUsed uint64 `json:"gasUsed"` + Data hexutil.Bytes `json:"data"` +} + +type logsItem struct { + Data hexutil.Bytes `json:"data"` + Topic []hexutil.Bytes `json:"topic"` +} + +type lastThreeOpCodesItem struct { + Opcode string + StackTop3 partialStack +} + +type bundlerCollectorResults struct { + CallsFromEntryPoint []*entryPointCall `json:"callsFromEntryPoint"` + Keccak []hexutil.Bytes `json:"keccak"` + Logs []*logsItem `json:"logs"` + Calls []*callsItem `json:"calls"` +} + +type bundlerCollector struct { + env *vm.EVM + + CallsFromEntryPoint []*entryPointCall + CurrentLevel *entryPointCall + Keccak []hexutil.Bytes + Calls []*callsItem + Logs []*logsItem + lastOp string + lastThreeOpCodes []*lastThreeOpCodesItem + allowedOpcodeRegex *regexp.Regexp + stopCollectingTopic string + stopCollecting bool +} + +func newBundlerCollector(ctx *tracers.Context, cfg json.RawMessage) (tracers.Tracer, error) { + rgx, err := regexp.Compile( + `^(DUP\d+|PUSH\d+|SWAP\d+|POP|ADD|SUB|MUL|DIV|EQ|LTE?|S?GTE?|SLT|SH[LR]|AND|OR|NOT|ISZERO)$`, + ) + if err != nil { + return nil, err + } + // event sent after all validations are done: keccak("BeforeExecution()") + stopCollectingTopic := "0xbb47ee3e183a558b1a2ff0874b079f3fc5478b7454eacf2bfc5af2ff5878f972" + + return &bundlerCollector{ + CallsFromEntryPoint: []*entryPointCall{}, + CurrentLevel: nil, + Keccak: []hexutil.Bytes{}, + Calls: []*callsItem{}, + Logs: []*logsItem{}, + lastOp: "", + lastThreeOpCodes: []*lastThreeOpCodesItem{}, + allowedOpcodeRegex: rgx, + stopCollectingTopic: stopCollectingTopic, + stopCollecting: false, + }, nil +} + +func (b *bundlerCollector) isEXTorCALL(opcode string) bool { + return strings.HasPrefix(opcode, "EXT") || + opcode == "CALL" || + opcode == "CALLCODE" || + opcode == "DELEGATECALL" || + opcode == "STATICCALL" +} + +// not using 'isPrecompiled' to only allow the ones defined by the ERC-4337 as stateless precompiles +// [OP-062] +func (b *bundlerCollector) isAllowedPrecompile(addr common.Address) bool { + addrInt := addr.Big() + return addrInt.Cmp(big.NewInt(0)) == 1 && addrInt.Cmp(big.NewInt(10)) == -1 +} + +func (b *bundlerCollector) incrementCount(m map[string]uint64, k string) { + if _, ok := m[k]; !ok { + m[k] = 0 + } + m[k]++ +} + +// CaptureStart implements the EVMLogger interface to initialize the tracing operation. +func (b *bundlerCollector) CaptureStart( + env *vm.EVM, + from common.Address, + to common.Address, + create bool, + input []byte, + gas uint64, + value *big.Int, +) { + b.env = env +} + +// CaptureEnd is called after the call finishes to finalize the tracing. +func (b *bundlerCollector) CaptureEnd(output []byte, gasUsed uint64, err error) {} + +// CaptureFault implements the EVMLogger interface to trace an execution fault. +func (b *bundlerCollector) CaptureFault( + pc uint64, + op vm.OpCode, + gas, cost uint64, + scope *vm.ScopeContext, + depth int, + err error, +) { +} + +// GetResult returns an empty json object. +func (b *bundlerCollector) GetResult() (json.RawMessage, error) { + bcr := bundlerCollectorResults{ + CallsFromEntryPoint: b.CallsFromEntryPoint, + Keccak: b.Keccak, + Logs: b.Logs, + Calls: b.Calls, + } + + r, err := json.Marshal(bcr) + if err != nil { + return nil, err + } + return r, nil +} + +// CaptureEnter is called when EVM enters a new scope (via call, create or selfdestruct). +func (b *bundlerCollector) CaptureEnter( + op vm.OpCode, + from common.Address, + to common.Address, + input []byte, + gas uint64, + value *big.Int, +) { + if b.stopCollecting { + return + } + + method := []byte{} + if len(input) >= 4 { + method = append(method, input[:4]...) + } + b.Calls = append(b.Calls, &callsItem{ + Type: op.String(), + From: from, + To: to, + Method: method, + Gas: gas, + Value: (*hexutil.Big)(value), + }) +} + +// CaptureExit is called when EVM exits a scope, even if the scope didn't +// execute any code. +func (b *bundlerCollector) CaptureExit(output []byte, gasUsed uint64, err error) { + if b.stopCollecting { + return + } + + rt := "RETURN" + if err != nil { + rt = "REVERT" + } + b.Calls = append(b.Calls, &callsItem{ + Type: rt, + GasUsed: gasUsed, + Data: output, + }) +} + +// CaptureState implements the EVMLogger interface to trace a single step of VM execution. +func (b *bundlerCollector) CaptureState( + pc uint64, + op vm.OpCode, + gas, cost uint64, + scope *vm.ScopeContext, + rData []byte, + depth int, + err error, +) { + if b.stopCollecting { + return + } + opcode := op.String() + + stackSize := len(scope.Stack.Data()) + stackTop3 := partialStack{} + for i := 0; i < 3 && i < stackSize; i++ { + stackTop3 = append(stackTop3, scope.Stack.Back(i).Clone()) + } + b.lastThreeOpCodes = append(b.lastThreeOpCodes, &lastThreeOpCodesItem{ + Opcode: opcode, + StackTop3: stackTop3, + }) + if len(b.lastThreeOpCodes) > 3 { + b.lastThreeOpCodes = b.lastThreeOpCodes[1:] + } + + if gas < cost || (opcode == "SSTORE" && gas < 2300) { + b.CurrentLevel.OOG = true + } + + if opcode == "REVERT" || opcode == "RETURN" { + // exit() is not called on top-level return/revert, so we reconstruct it from opcode + if depth == 1 { + ofs := scope.Stack.Back(0).ToBig().Int64() + len := scope.Stack.Back(1).ToBig().Int64() + data := scope.Memory.GetCopy(ofs, len) + b.Calls = append(b.Calls, &callsItem{ + Type: opcode, + GasUsed: 0, + Data: data, + }) + } + // NOTE: flushing all history after RETURN + b.lastThreeOpCodes = []*lastThreeOpCodesItem{} + } + + if depth == 1 { + if opcode == "CALL" || opcode == "STATICCALL" { + addr := common.HexToAddress(scope.Stack.Back(1).Hex()) + ofs := scope.Stack.Back(3).ToBig().Int64() + sig := scope.Memory.GetCopy(ofs, 4) + + b.CurrentLevel = &entryPointCall{ + TopLevelMethodSig: sig, + TopLevelTargetAddress: addr, + Access: map[common.Address]*access{}, + Opcodes: map[string]uint64{}, + ExtCodeAccessInfo: map[common.Address]string{}, + ContractSize: map[common.Address]*contractSizeVal{}, + OOG: false, + } + b.CallsFromEntryPoint = append(b.CallsFromEntryPoint, b.CurrentLevel) + } else if opcode == "LOG1" && scope.Stack.Back(2).Hex() == b.stopCollectingTopic { + b.stopCollecting = true + } + b.lastOp = "" + return + } + + var lastOpInfo *lastThreeOpCodesItem + if len(b.lastThreeOpCodes) >= 2 { + lastOpInfo = b.lastThreeOpCodes[len(b.lastThreeOpCodes)-2] + } + // store all addresses touched by EXTCODE* opcodes + if lastOpInfo != nil && strings.HasPrefix(lastOpInfo.Opcode, "EXT") { + addr := common.HexToAddress(lastOpInfo.StackTop3[0].Hex()) + ops := []string{} + for _, item := range b.lastThreeOpCodes { + ops = append(ops, item.Opcode) + } + last3OpcodeStr := strings.Join(ops, ",") + + // only store the last EXTCODE* opcode per address - could even be a boolean for our current use-case + // [OP-051] + if !strings.Contains(last3OpcodeStr, ",EXTCODESIZE,ISZERO") { + b.CurrentLevel.ExtCodeAccessInfo[addr] = opcode + } + } + + // [OP-041] + if b.isEXTorCALL(opcode) { + n := 0 + if !strings.HasPrefix(opcode, "EXT") { + n = 1 + } + addr := common.BytesToAddress(scope.Stack.Back(n).Bytes()) + + if _, ok := b.CurrentLevel.ContractSize[addr]; !ok && !b.isAllowedPrecompile(addr) { + b.CurrentLevel.ContractSize[addr] = &contractSizeVal{ + ContractSize: b.env.StateDB.GetCodeSize(addr), + Opcode: opcode, + } + } + } + + // [OP-012] + if b.lastOp == "GAS" && !strings.Contains(opcode, "CALL") { + b.incrementCount(b.CurrentLevel.Opcodes, "GAS") + } + // ignore "unimportant" opcodes + if opcode != "GAS" && !b.allowedOpcodeRegex.MatchString(opcode) { + b.incrementCount(b.CurrentLevel.Opcodes, opcode) + } + b.lastOp = opcode + + if opcode == "SLOAD" || opcode == "SSTORE" { + slot := common.BytesToHash(scope.Stack.Back(0).Bytes()) + slotHex := slot.Hex() + addr := scope.Contract.Address() + if _, ok := b.CurrentLevel.Access[addr]; !ok { + b.CurrentLevel.Access[addr] = &access{ + Reads: map[string]string{}, + Writes: map[string]uint64{}, + } + } + access := *b.CurrentLevel.Access[addr] + + if opcode == "SLOAD" { + // read slot values before this UserOp was created + // (so saving it if it was written before the first read) + _, rOk := access.Reads[slotHex] + _, wOk := access.Writes[slotHex] + if !rOk && !wOk { + access.Reads[slotHex] = string(b.env.StateDB.GetState(addr, slot).Hex()) + } + } else { + b.incrementCount(access.Writes, slotHex) + } + } + + if opcode == "KECCAK256" { + // collect keccak on 64-byte blocks + ofs := scope.Stack.Back(0).ToBig().Int64() + len := scope.Stack.Back(1).ToBig().Int64() + // currently, solidity uses only 2-word (6-byte) for a key. this might change..still, no need to + // return too much + if len > 20 && len < 512 { + b.Keccak = append(b.Keccak, scope.Memory.GetCopy(ofs, len)) + } + } else if strings.HasPrefix(opcode, "LOG") { + count, _ := strconv.Atoi(opcode[3:]) + ofs := scope.Stack.Back(0).ToBig().Int64() + len := scope.Stack.Back(1).ToBig().Int64() + topics := []hexutil.Bytes{} + for i := 0; i < count; i++ { + topics = append(topics, scope.Stack.Back(2+i).Bytes()) + } + + b.Logs = append(b.Logs, &logsItem{ + Data: scope.Memory.GetCopy(ofs, len), + Topic: topics, + }) + } +} + +func (b *bundlerCollector) CaptureTxStart(gasLimit uint64) {} + +func (b *bundlerCollector) CaptureTxEnd(restGas uint64) {} + +// Stop terminates execution of the tracer at the first opportune moment. +func (b *bundlerCollector) Stop(err error) { +} diff --git a/eth/tracers/native/bundler_executor.go b/eth/tracers/native/bundler_executor.go new file mode 100644 index 0000000000..4076273afb --- /dev/null +++ b/eth/tracers/native/bundler_executor.go @@ -0,0 +1,242 @@ +package native + +import ( + "encoding/json" + "math" + "math/big" + "strconv" + "strings" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/vm" + "github.com/ethereum/go-ethereum/eth/tracers" +) + +func init() { + tracers.DefaultDirectory.Register("bundlerExecutorTracer", newBundlerExecutor, false) +} + +type userOperationEvent struct { + Data hexutil.Bytes `json:"data"` + Topics []hexutil.Bytes `json:"topics"` +} + +type bundlerExecutorResults struct { + Reverts []hexutil.Bytes `json:"reverts"` + ValidationOOG bool `json:"validationOOG"` + ExecutionOOG bool `json:"executionOOG"` + ExecutionGasLimit uint64 `json:"executionGasLimit"` + UserOperationEvent *userOperationEvent `json:"userOperationEvent,omitempty"` + Output hexutil.Bytes `json:"output"` + Error string `json:"error"` +} + +type gasStackItem struct { + used uint64 + required uint64 +} + +type bundlerExecutor struct { + env *vm.EVM + + Reverts []hexutil.Bytes + ValidationOOG bool + ExecutionOOG bool + ExecutionGasLimit uint64 + UserOperationEvent *userOperationEvent + Output hexutil.Bytes + Error string + + depth int + executionGasStack map[int]*gasStackItem + marker int + validationMarker int + executionMarker int + userOperationEventTopics0 string +} + +func newBundlerExecutor(ctx *tracers.Context, cfg json.RawMessage) (tracers.Tracer, error) { + userOperationEventTopics0 := "0x49628fd1471006c1482da88028e9ce4dbb080b815c9b0344d39e5a8e6ec1419f" + + return &bundlerExecutor{ + Reverts: []hexutil.Bytes{}, + ValidationOOG: false, + ExecutionOOG: false, + ExecutionGasLimit: 0, + UserOperationEvent: nil, + Output: []byte{}, + Error: "", + + depth: 0, + executionGasStack: map[int]*gasStackItem{}, + marker: 0, + validationMarker: 1, + executionMarker: 3, + userOperationEventTopics0: userOperationEventTopics0, + }, nil +} + +func (b *bundlerExecutor) isValidation() bool { + return b.marker >= b.validationMarker && b.marker < b.executionMarker +} + +func (b *bundlerExecutor) isExecution() bool { + return b.marker == b.executionMarker +} + +func (b *bundlerExecutor) isUserOperationEvent(scope *vm.ScopeContext) bool { + return scope.Stack.Back(2).Hex() == b.userOperationEventTopics0 +} + +func (b *bundlerExecutor) setUserOperationEvent(opcode string, scope *vm.ScopeContext) { + count, _ := strconv.Atoi(opcode[3:]) + ofs := scope.Stack.Back(0).ToBig().Int64() + len := scope.Stack.Back(1).ToBig().Int64() + topics := []hexutil.Bytes{} + for i := 0; i < count; i++ { + topics = append(topics, scope.Stack.Back(2+i).Bytes()) + } + + b.UserOperationEvent = &userOperationEvent{ + Data: scope.Memory.GetCopy(ofs, len), + Topics: topics, + } +} + +// CaptureStart implements the EVMLogger interface to initialize the tracing operation. +func (b *bundlerExecutor) CaptureStart( + env *vm.EVM, + from common.Address, + to common.Address, + create bool, + input []byte, + gas uint64, + value *big.Int, +) { + b.env = env +} + +// CaptureEnd is called after the call finishes to finalize the tracing. +func (b *bundlerExecutor) CaptureEnd(output []byte, gasUsed uint64, err error) { + b.Output = output + b.Error = err.Error() +} + +// CaptureFault implements the EVMLogger interface to trace an execution fault. +func (b *bundlerExecutor) CaptureFault( + pc uint64, + op vm.OpCode, + gas, cost uint64, + scope *vm.ScopeContext, + depth int, + err error, +) { +} + +// GetResult returns an empty json object. +func (b *bundlerExecutor) GetResult() (json.RawMessage, error) { + ber := bundlerExecutorResults{ + Reverts: b.Reverts, + ValidationOOG: b.ValidationOOG, + ExecutionOOG: b.ExecutionOOG, + ExecutionGasLimit: b.ExecutionGasLimit, + UserOperationEvent: b.UserOperationEvent, + Output: b.Output, + Error: b.Error, + } + + r, err := json.Marshal(ber) + if err != nil { + return nil, err + } + return r, nil +} + +// CaptureEnter is called when EVM enters a new scope (via call, create or selfdestruct). +func (b *bundlerExecutor) CaptureEnter( + op vm.OpCode, + from common.Address, + to common.Address, + input []byte, + gas uint64, + value *big.Int, +) { + if b.isExecution() { + next := b.depth + 1 + if _, ok := b.executionGasStack[next]; !ok { + b.executionGasStack[next] = &gasStackItem{used: 0, required: 0} + } + } +} + +// CaptureExit is called when EVM exits a scope, even if the scope didn't +// execute any code. +func (b *bundlerExecutor) CaptureExit(output []byte, gasUsed uint64, err error) { + if b.isExecution() { + if err != nil { + b.Reverts = append(b.Reverts, output) + } + + if b.depth >= 2 { + // Get the final gas item for the nested frame. + nd := b.depth + 1 + if _, ok := b.executionGasStack[nd]; !ok { + b.executionGasStack[nd] = &gasStackItem{used: 0, required: 0} + } + nested := b.executionGasStack[nd] + + // Reset the nested gas item to prevent double counting on re-entry. + b.executionGasStack[nd] = &gasStackItem{used: 0, required: 0} + + // Keep track of the total gas used by all frames at this depth. + // This does not account for the gas required due to the 63/64 rule. + b.executionGasStack[b.depth].used += gasUsed + + // Keep track of the total gas required by all frames at this depth. + // This accounts for additional gas needed due to the 63/64 rule. + b.executionGasStack[b.depth].required += + gasUsed - nested.used + uint64(math.Ceil(float64(nested.required)*64/63)) + + // Keep track of the final gas limit. + b.ExecutionGasLimit = b.executionGasStack[b.depth].required + } + } +} + +// CaptureState implements the EVMLogger interface to trace a single step of VM execution. +func (b *bundlerExecutor) CaptureState( + pc uint64, + op vm.OpCode, + gas, cost uint64, + scope *vm.ScopeContext, + rData []byte, + depth int, + err error, +) { + opcode := op.String() + b.depth = depth + if b.depth == 1 && opcode == "NUMBER" { + b.marker++ + } + + if b.depth <= 2 && strings.HasPrefix(opcode, "LOG") && b.isUserOperationEvent(scope) { + b.setUserOperationEvent(opcode, scope) + } + + if gas < cost && b.isValidation() { + b.ValidationOOG = true + } + + if gas < cost && b.isExecution() { + b.ExecutionOOG = true + } +} + +func (b *bundlerExecutor) CaptureTxStart(gasLimit uint64) {} + +func (b *bundlerExecutor) CaptureTxEnd(restGas uint64) {} + +// Stop terminates execution of the tracer at the first opportune moment. +func (b *bundlerExecutor) Stop(err error) { +} From 5335dcfbf92b25dd339a894bc4c59c38458380dc Mon Sep 17 00:00:00 2001 From: Venstein Date: Mon, 27 May 2024 17:55:56 +0900 Subject: [PATCH 2/2] add comment to acknowledge code source from external repository --- eth/tracers/native/bundler_collector.go | 4 ++++ eth/tracers/native/bundler_executor.go | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/eth/tracers/native/bundler_collector.go b/eth/tracers/native/bundler_collector.go index d90656b3ff..44733d9d6f 100644 --- a/eth/tracers/native/bundler_collector.go +++ b/eth/tracers/native/bundler_collector.go @@ -1,3 +1,7 @@ +// This code is adapted from the following repository: +// Repository: https://github.com/stackup-wallet/erc-4337-execution-clients +// Original file: tracers/bundler_collector.go.template + package native import ( diff --git a/eth/tracers/native/bundler_executor.go b/eth/tracers/native/bundler_executor.go index 4076273afb..f589e24f0b 100644 --- a/eth/tracers/native/bundler_executor.go +++ b/eth/tracers/native/bundler_executor.go @@ -1,3 +1,7 @@ +// This code is adapted from the following repository: +// Repository: https://github.com/stackup-wallet/erc-4337-execution-clients +// Original file: tracers/bundler_executor.go.template + package native import (