From c5eb5faa5f28db1e7a2446d4fc1328ff7f734b1b Mon Sep 17 00:00:00 2001 From: Matthieu Vachon Date: Fri, 28 Jul 2023 17:05:21 -0400 Subject: [PATCH] Further work towards having a compatible Firehose code (at battlefied block #15) --- eth/tracers/firehose.go | 163 +++++++++++++++++++++++++++-------- eth/tracers/firehose_test.go | 2 +- 2 files changed, 128 insertions(+), 37 deletions(-) diff --git a/eth/tracers/firehose.go b/eth/tracers/firehose.go index b77b384eed..2c76499708 100644 --- a/eth/tracers/firehose.go +++ b/eth/tracers/firehose.go @@ -30,6 +30,7 @@ import ( ) var isFirehoseDebugEnabled = os.Getenv("GETH_FIREHOSE_TRACER_DEBUG") != "" +var isFirehoseTracerEnabled = os.Getenv("GETH_FIREHOSE_TRACER_TRACE") != "" var _ core.BlockchainLogger = (*Firehose)(nil) @@ -51,6 +52,7 @@ type Firehose struct { inTransaction *atomic.Bool transaction *pbeth.TransactionTrace transactionLogIndex uint32 + isPrecompiledAddr func(addr common.Address) bool // Call state callStack *CallStack @@ -73,8 +75,8 @@ func NewFirehoseLogger() *Firehose { inTransaction: atomic.NewBool(false), transactionLogIndex: 0, - callStack: &CallStack{}, - deferredCallState: &DeferredCallState{}, + callStack: NewCallStack(), + deferredCallState: NewDeferredCallState(), latestCallStartSuicided: false, } } @@ -92,6 +94,7 @@ func (f *Firehose) resetBlock() { func (f *Firehose) resetTransaction() { f.inTransaction.Store(false) f.transactionLogIndex = 0 + f.isPrecompiledAddr = nil f.callStack.Reset() f.latestCallStartSuicided = false @@ -137,13 +140,15 @@ func (f *Firehose) OnBlockEnd(err error) { firehoseDebug("block end") } -func (f *Firehose) CaptureTxStart(env *vm.EVM, tx *types.Transaction) { - firehoseDebug("trx start hash=%s type=%d input=%s", tx.Hash(), tx.Type(), inputView(tx.Data())) +func (f *Firehose) CaptureTxStart(evm *vm.EVM, tx *types.Transaction) { + firehoseDebug("trx start hash=%s type=%d gas=%d input=%s", tx.Hash(), tx.Type(), tx.Gas(), inputView(tx.Data())) + f.ensureInBlockAndNotInTrxAndNotInCall() f.inTransaction.Store(true) + f.isPrecompiledAddr = evm.IsPrecompileAddr - signer := types.MakeSigner(env.ChainConfig(), env.Context.BlockNumber, env.Context.Time) + signer := types.MakeSigner(evm.ChainConfig(), evm.Context.BlockNumber, evm.Context.Time) from, err := types.Sender(signer, tx) if err != nil { @@ -152,7 +157,7 @@ func (f *Firehose) CaptureTxStart(env *vm.EVM, tx *types.Transaction) { var to common.Address if tx.To() == nil { - to = crypto.CreateAddress(from, env.StateDB.GetNonce(from)) + to = crypto.CreateAddress(from, evm.StateDB.GetNonce(from)) } else { to = *tx.To() } @@ -185,6 +190,8 @@ func (f *Firehose) CaptureTxEnd(receipt *types.Receipt, err error) { f.block.TransactionTraces = append(f.block.TransactionTraces, f.completeTransaction(receipt)) + // The reset must be done as the very last thing as the CallStack needs to be + // properly populated for the `completeTransaction` call above to complete correctly. f.resetTransaction() firehoseDebug("trx end") @@ -193,12 +200,15 @@ func (f *Firehose) CaptureTxEnd(receipt *types.Receipt, err error) { func (f *Firehose) completeTransaction(receipt *types.Receipt) *pbeth.TransactionTrace { firehoseDebug("completing transaction call_count=%d receipt=%s", len(f.transaction.Calls), (*receiptView)(receipt)) + // Sorting needs to happen first, before we populate the state reverted slices.SortFunc(f.transaction.Calls, func(i, j *pbeth.Call) bool { return i.Index < j.Index }) + rootCall := f.transaction.Calls[0] + if !f.deferredCallState.IsEmpty() { - f.deferredCallState.MaybePopulateCallAndReset("root", f.transaction.Calls[0]) + f.deferredCallState.MaybePopulateCallAndReset("root", rootCall) } // Receipt can be nil if an error occurred during the transaction execution, right now we don't have it @@ -207,21 +217,58 @@ func (f *Firehose) completeTransaction(receipt *types.Receipt) *pbeth.Transactio f.transaction.GasUsed = receipt.GasUsed f.transaction.Receipt = newTxReceiptFromChain(receipt) f.transaction.Status = transactionStatusFromChainTxReceipt(receipt.Status) - } else { - // FIXME: How are we going to decide about a reverted transaction? - f.transaction.Status = pbeth.TransactionTraceStatus_FAILED } - // FIXME: There should always be a Call in a transaction, maybe we need to synthetize one here, need to check - if len(f.transaction.Calls) > 0 { - f.transaction.ReturnData = f.transaction.Calls[0].ReturnData + // It's possible that the transaction was reverted, but we still have a receipt, in that case, we must + // check the root call + if rootCall.StatusReverted { + f.transaction.Status = pbeth.TransactionTraceStatus_REVERTED } + // Order is important, we must populate the state reverted before we remove the log block index + f.populateStateReverted() + f.removeLogBlockIndexOnStateRevertedCalls() + + // I think this was never used in Firehose instrumentation actually + // f.transaction.ReturnData = rootCall.ReturnData f.transaction.EndOrdinal = f.blockOrdinal.Next() return f.transaction } +func (f *Firehose) populateStateReverted() { + // Calls are ordered by execution index. So the algo is quite simple. + // We loop through the flat calls, at each call, if the parent is present + // and reverted, the current call is reverted. Otherwise, if the current call + // is failed, the state is reverted. In all other cases, we simply continue + // our iteration loop. + // + // This works because we see the parent before its children, and since we + // trickle down the state reverted value down the children, checking the parent + // of a call will always tell us if the whole chain of parent/child should + // be reverted + // + calls := f.transaction.Calls + for _, call := range f.transaction.Calls { + var parent *pbeth.Call + if call.ParentIndex > 0 { + parent = calls[call.ParentIndex-1] + } + + call.StateReverted = (parent != nil && parent.StateReverted) || call.StatusFailed + } +} + +func (f *Firehose) removeLogBlockIndexOnStateRevertedCalls() { + for _, call := range f.transaction.Calls { + if call.StateReverted { + for _, log := range call.Logs { + log.BlockIndex = 0 + } + } + } +} + // CaptureStart implements the EVMLogger interface to initialize the tracing operation. func (f *Firehose) CaptureStart(from common.Address, to common.Address, create bool, input []byte, gas uint64, value *big.Int) { f.callStart("root", rootCallType(create), from, to, input, gas, value) @@ -234,18 +281,28 @@ func (f *Firehose) CaptureEnd(output []byte, gasUsed uint64, err error) { // CaptureState implements the EVMLogger interface to trace a single step of VM execution. func (f *Firehose) CaptureState(pc uint64, op vm.OpCode, gas, cost uint64, scope *vm.ScopeContext, rData []byte, depth int, err error) { - if activeCall := f.callStack.Peek(); activeCall != nil && activeCall.CallType != pbeth.CallType_CREATE { - activeCall.ExecutedCode = true - } + f.captureInterpreterStep(pc, op, gas, cost, scope, rData, depth, err) } // CaptureFault implements the EVMLogger interface to trace an execution fault. -func (f *Firehose) CaptureFault(pc uint64, op vm.OpCode, gas, cost uint64, _ *vm.ScopeContext, depth int, err error) { - if activeCall := f.callStack.Peek(); activeCall != nil && activeCall.CallType != pbeth.CallType_CREATE { - activeCall.ExecutedCode = true +func (f *Firehose) CaptureFault(pc uint64, op vm.OpCode, gas, cost uint64, scope *vm.ScopeContext, depth int, err error) { + f.captureInterpreterStep(pc, op, gas, cost, scope, nil, depth, err) +} + +func (f *Firehose) captureInterpreterStep(pc uint64, op vm.OpCode, gas, cost uint64, _ *vm.ScopeContext, rData []byte, depth int, err error) { + activeCall := f.callStack.Peek() + if activeCall == nil { + return } - // FIXME: Maybe we will need this to properly track status failed/reverted of + // Firehose model don't today flag contract creation as having executed code. But it we + // keep like that for backward compatibility. + // + // However, if at some point we bump to fixes the small bug here and there, we should + // actually flag contract creation as having executed code only if a constructor was called. + if activeCall.CallType != pbeth.CallType_CREATE { + activeCall.ExecutedCode = true + } } func (f *Firehose) CaptureEnter(typ vm.OpCode, from common.Address, to common.Address, input []byte, gas uint64, value *big.Int) { @@ -276,16 +333,24 @@ func (f *Firehose) CaptureExit(output []byte, gasUsed uint64, err error) { } func (f *Firehose) callStart(source string, callType pbeth.CallType, from common.Address, to common.Address, input []byte, gas uint64, value *big.Int) { - firehoseDebug("call start source=%s type=%s", source, callType) + firehoseDebug("call start source=%s index=%d type=%s input=%s", source, f.callStack.NextIndex(), callType, inputView(input)) f.ensureInBlockAndInTrx() + // First to avoid paying the `bytes.Clone` below + if callType == pbeth.CallType_CREATE { + // Replicates a previous bug in Firehose instrumentation where create calls input is always + // set to `nil` (this was done on purpose but it missed the fact that we are missing the + // actual input data of the constuctor call, if present and invoked). + input = nil + } + call := &pbeth.Call{ BeginOrdinal: f.blockOrdinal.Next(), CallType: callType, Depth: 0, Caller: from.Bytes(), Address: to.Bytes(), - Input: input, + Input: bytes.Clone(input), Value: firehoseBigIntFromNative(value), GasLimit: gas, } @@ -299,18 +364,11 @@ func (f *Firehose) callStart(source string, callType pbeth.CallType, from common call.BeginOrdinal = 0 } - if callType == pbeth.CallType_CREATE { - // Replicates a previous bug in Firehose instrumentation where create calls input is always - // set to `nil` (this was done on purpose but it missed the fact that we are missing the - // actual input data of the constuctor call, if present and invoked). - call.Input = nil - } - f.callStack.Push(call) } func (f *Firehose) callEnd(source string, output []byte, gasUsed uint64, err error) { - firehoseDebug("call end source=%s output=%s gasUsed=%d err=%s", source, outputView(output), gasUsed, errorView(err)) + firehoseDebug("call end source=%s index=%d output=%s gasUsed=%d err=%s", source, f.callStack.ActiveIndex(), outputView(output), gasUsed, errorView(err)) if f.latestCallStartSuicided { if source != "child" { @@ -330,16 +388,21 @@ func (f *Firehose) callEnd(source string, output []byte, gasUsed uint64, err err // For create call, we do not save the returned value which is the actual contract's code if call.CallType != pbeth.CallType_CREATE { - call.ReturnData = output - } + call.ReturnData = bytes.Clone(output) - // FIXME: Unset field for now, need to ensure they are tracked somewere - // call.StateReverted + // Pre-compiled addresses always execute code + if f.isPrecompiledAddr(common.BytesToAddress(call.Address)) { + call.ExecutedCode = true + } + } if err != nil { call.FailureReason = err.Error() call.StatusFailed = true - call.StatusReverted = errors.Is(err, vm.ErrExecutionReverted) + + // We also treat ErrInsufficientBalance and ErrDepth as reverted in Firehose model + // because they do not cost any gas. + call.StatusReverted = errors.Is(err, vm.ErrExecutionReverted) || errors.Is(err, vm.ErrInsufficientBalance) || errors.Is(err, vm.ErrDepth) } call.EndOrdinal = f.blockOrdinal.Next() @@ -605,10 +668,12 @@ func (f *Firehose) OnGasConsumed(gas, amount uint64) { return } + firehoseTrace("gas consumed before=%d after=%d", gas, gas-amount) + activeCall := f.callStack.Peek() change := &pbeth.GasChange{ OldValue: gas, - NewValue: gas + amount, + NewValue: gas - amount, Ordinal: f.blockOrdinal.Next(), } @@ -979,6 +1044,12 @@ func firehoseDebug(msg string, args ...interface{}) { } } +func firehoseTrace(msg string, args ...interface{}) { + if isFirehoseTracerEnabled { + fmt.Fprintf(os.Stderr, "[Firehose] "+msg+"\n", args...) + } +} + // Ignore unused, we keep it around for debugging purposes var _ = firehoseDebugPrintStack @@ -1018,6 +1089,10 @@ type CallStack struct { depth int } +func NewCallStack() *CallStack { + return &CallStack{} +} + func (s *CallStack) Reset() { s.index = 0 s.stack = s.stack[:0] @@ -1046,6 +1121,18 @@ func (s *CallStack) Push(call *pbeth.Call) { s.stack = append(s.stack, call) } +func (s *CallStack) ActiveIndex() uint32 { + if len(s.stack) == 0 { + return 0 + } + + return s.stack[len(s.stack)-1].Index +} + +func (s *CallStack) NextIndex() uint32 { + return s.index + 1 +} + func (s *CallStack) Pop() (out *pbeth.Call) { if len(s.stack) == 0 { panic(fmt.Errorf("pop from empty call stack")) @@ -1077,6 +1164,10 @@ type DeferredCallState struct { nonceChanges []*pbeth.NonceChange } +func NewDeferredCallState() *DeferredCallState { + return &DeferredCallState{} +} + func (d *DeferredCallState) MaybePopulateCallAndReset(source string, call *pbeth.Call) error { if d.IsEmpty() { return nil diff --git a/eth/tracers/firehose_test.go b/eth/tracers/firehose_test.go index e33fb6a606..f18f947c09 100644 --- a/eth/tracers/firehose_test.go +++ b/eth/tracers/firehose_test.go @@ -49,7 +49,7 @@ func TestFirehoseCallStack_Push(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - s := &CallStack{} + s := NewCallStack() for _, action := range tt.actions { action(t, s)