diff --git a/core/vm/runtime/runtime.go b/core/vm/runtime/runtime.go index febae4f98fc..a3fee98d645 100644 --- a/core/vm/runtime/runtime.go +++ b/core/vm/runtime/runtime.go @@ -208,7 +208,10 @@ func Call(address libcommon.Address, input []byte, cfg *Config) ([]byte, uint64, vmenv := NewEnv(cfg) - sender := cfg.State.GetOrNewStateObject(cfg.Origin) + // Hack for firehose + // we don't want to track the origin account creation + // sender := cfg.State.GetOrNewStateObject(cfg.Origin) + sender := vm.AccountRef(cfg.Origin) statedb := cfg.State rules := vmenv.ChainRules() if cfg.EVMConfig.Tracer != nil && cfg.EVMConfig.Tracer.OnTxStart != nil { diff --git a/eth/tracers/live/firehose.go b/eth/tracers/live/firehose.go index e445ec8f1dc..f8feee407f6 100644 --- a/eth/tracers/live/firehose.go +++ b/eth/tracers/live/firehose.go @@ -12,15 +12,18 @@ import ( "math/big" "os" "regexp" + "runtime" "runtime/debug" "strconv" "strings" + "sync" "sync/atomic" "time" "github.com/ledgerwatch/erigon-lib/chain" libcommon "github.com/ledgerwatch/erigon-lib/common" types2 "github.com/ledgerwatch/erigon-lib/types" + "github.com/ledgerwatch/log/v3" "github.com/ledgerwatch/erigon/common" "github.com/ledgerwatch/erigon/common/math" @@ -40,9 +43,25 @@ import ( "google.golang.org/protobuf/types/known/timestamppb" ) +const ( + firehoseTraceLevel = "trace" + firehoseDebugLevel = "debug" + firehoseInfoLevel = "info" +) + +const ( + callSourceRoot = "root" + callSourceChild = "child" +) + +// Here what you can expect from the debugging levels: +// - Info == block start/end + trx start/end +// - Debug == Info + call start/end + error +// - Trace == Debug + state db changes, log, balance, nonce, code, storage, gas var firehoseTracerLogLevel = strings.ToLower(os.Getenv("FIREHOSE_ETHEREUM_TRACER_LOG_LEVEL")) -var isFirehoseDebugEnabled = firehoseTracerLogLevel == "debug" || firehoseTracerLogLevel == "trace" -var isFirehoseTracerEnabled = firehoseTracerLogLevel == "trace" +var isFirehoseInfoEnabled = firehoseTracerLogLevel == firehoseInfoLevel || firehoseTracerLogLevel == firehoseDebugLevel || firehoseTracerLogLevel == firehoseTraceLevel +var isFirehoseDebugEnabled = firehoseTracerLogLevel == firehoseDebugLevel || firehoseTracerLogLevel == firehoseTraceLevel +var isFirehoseTracerEnabled = firehoseTracerLogLevel == firehoseTraceLevel var emptyCommonAddress = libcommon.Address{} var emptyCommonHash = libcommon.Hash{} @@ -54,8 +73,6 @@ func init() { } func newFirehoseTracer(ctx *tracers.Context, cfg json.RawMessage) (*tracers.Tracer, error) { - firehoseDebug("New firehose tracer") - var config FirehoseConfig if len([]byte(cfg)) > 0 { if err := json.Unmarshal(cfg, &config); err != nil { @@ -63,7 +80,7 @@ func newFirehoseTracer(ctx *tracers.Context, cfg json.RawMessage) (*tracers.Trac } } - tracer := NewFirehose() + tracer := NewFirehose(&config) return &tracers.Tracer{ Hooks: &tracing.Hooks{ @@ -88,13 +105,29 @@ func newFirehoseTracer(ctx *tracers.Context, cfg json.RawMessage) (*tracers.Trac OnGasChange: tracer.OnGasChange, OnLog: tracer.OnLog, + // This should actually be conditional but it's not possible to do it in the hooks + // directly because the chain ID will be known only after the `OnBlockchainInit` call. + // So we register it unconditionally and the actual `OnNewAccount` hook will decide + // what it needs to do. OnNewAccount: tracer.OnNewAccount, }, }, nil } type FirehoseConfig struct { - // Nothing for now + ApplyBackwardCompatibility *bool `json:"applyBackwardCompatibility"` +} + +// LogKeValues returns a list of key-values to be logged when the config is printed. +func (c *FirehoseConfig) LogKeyValues() []any { + applyBackwardCompatibility := "" + if c.ApplyBackwardCompatibility != nil { + applyBackwardCompatibility = strconv.FormatBool(*c.ApplyBackwardCompatibility) + } + + return []any{ + "config.applyBackwardCompatibility", applyBackwardCompatibility, + } } type Firehose struct { @@ -104,13 +137,26 @@ type Firehose struct { chainConfig *chain.Config hasher crypto.KeccakState // Keccak256 hasher instance shared across tracer needs (non-concurrent safe) hasherBuf libcommon.Hash // Keccak256 hasher result array shared across tracer needs (non-concurrent safe) + tracerID string + // The FirehoseTracer is used in multiple chains, some for which were produced using a legacy version + // of the whole tracing infrastructure. This legacy version had many small bugs here and there that + // we must "reproduce" on some chain to ensure that the FirehoseTracer produces the same output + // as the legacy version. + // + // This value is fed from the tracer configuration. If explicitly set, the value set will be used + // here. If not set in the config, then we inspect `OnBlockchainInit` the chain config to determine + // if it's a network for which we must reproduce the legacy bugs. + applyBackwardCompatibility *bool // Block state - block *pbeth.Block - blockBaseFee *big.Int - blockOrdinal *Ordinal - blockFinality *FinalityStatus - blockRules *chain.Rules + block *pbeth.Block + blockBaseFee *big.Int + blockOrdinal *Ordinal + blockFinality *FinalityStatus + blockRules *chain.Rules + blockReorderOrdinal bool + blockReorderOrdinalSnapshot uint64 + blockReorderOrdinalOnce sync.Once // Transaction state evm *tracing.VMContext @@ -118,6 +164,8 @@ type Firehose struct { transactionLogIndex uint32 inSystemCall bool blockIsPrecompiledAddr func(addr libcommon.Address) bool + transactionIsolated bool + transactionTransient *pbeth.TransactionTrace // Call state callStack *CallStack @@ -127,20 +175,55 @@ type Firehose struct { const FirehoseProtocolVersion = "3.0" -func NewFirehose() *Firehose { +func NewFirehose(config *FirehoseConfig) *Firehose { + log.Info("Firehose tracer created", config.LogKeyValues()...) + + return &Firehose{ + // Global state + outputBuffer: bytes.NewBuffer(make([]byte, 0, 100*1024*1024)), + initSent: new(atomic.Bool), + chainConfig: nil, + hasher: crypto.NewKeccakState(), + tracerID: "global", + applyBackwardCompatibility: config.ApplyBackwardCompatibility, + + // Block state + blockOrdinal: &Ordinal{}, + blockFinality: &FinalityStatus{}, + blockReorderOrdinal: false, + + // Transaction state + transactionLogIndex: 0, + + // Call state + callStack: NewCallStack(), + deferredCallState: NewDeferredCallState(), + latestCallEnterSuicided: false, + } +} + +func (f *Firehose) newIsolatedTransactionTracer(tracerID string) *Firehose { + f.ensureInBlock(0) + return &Firehose{ // Global state - outputBuffer: bytes.NewBuffer(make([]byte, 0, 100*1024*1024)), - initSent: new(atomic.Bool), - chainConfig: nil, - hasher: crypto.NewKeccakState(), + initSent: f.initSent, + chainConfig: f.chainConfig, + hasher: crypto.NewKeccakState(), + hasherBuf: libcommon.Hash{}, + tracerID: tracerID, // Block state - blockOrdinal: &Ordinal{}, - blockFinality: &FinalityStatus{}, + block: f.block, + blockBaseFee: f.blockBaseFee, + blockOrdinal: &Ordinal{}, + blockFinality: f.blockFinality, + blockIsPrecompiledAddr: f.blockIsPrecompiledAddr, + blockRules: f.blockRules, // Transaction state transactionLogIndex: 0, + transactionIsolated: true, // Call state callStack: NewCallStack(), @@ -157,6 +240,9 @@ func (f *Firehose) resetBlock() { f.blockFinality.Reset() f.blockIsPrecompiledAddr = nil f.blockRules = &chain.Rules{} + f.blockReorderOrdinal = false + f.blockReorderOrdinalSnapshot = 0 + f.blockReorderOrdinalOnce = sync.Once{} } // resetTransaction resets the transaction state and the call state in one shot @@ -165,6 +251,7 @@ func (f *Firehose) resetTransaction() { f.evm = nil f.transactionLogIndex = 0 f.inSystemCall = false + f.transactionTransient = nil f.callStack.Reset() f.latestCallEnterSuicided = false @@ -177,13 +264,41 @@ func (f *Firehose) OnBlockchainInit(chainConfig *chain.Config) { if wasNeverSent := f.initSent.CompareAndSwap(false, true); wasNeverSent { printToFirehose("INIT", FirehoseProtocolVersion, "geth", params.Version) } else { - f.panicInvalidState("The OnBlockchainInit callback was called more than once") + f.panicInvalidState("The OnBlockchainInit callback was called more than once", 0) + } + + if f.applyBackwardCompatibility == nil { + f.applyBackwardCompatibility = ptr(chainNeedsLegacyBackwardCompatibility(chainConfig.ChainID)) } + + log.Info("Firehose tracer initialized", "chain_id", chainConfig.ChainID, "apply_backward_compatibility", *f.applyBackwardCompatibility, "protocol_version", FirehoseProtocolVersion) +} + +var mainnetChainID = big.NewInt(1) +var goerliChainID = big.NewInt(5) +var sepoliaChainID = big.NewInt(11155111) +var holeskyChainID = big.NewInt(17000) +var polygonMainnetChainID = big.NewInt(137) +var polygonMumbaiChainID = big.NewInt(80001) +var polygonAmoyChainID = big.NewInt(80002) +var bscMainnetChainID = big.NewInt(56) +var bscTestnetChainID = big.NewInt(97) + +func chainNeedsLegacyBackwardCompatibility(id *big.Int) bool { + return id.Cmp(mainnetChainID) == 0 || + id.Cmp(goerliChainID) == 0 || + id.Cmp(sepoliaChainID) == 0 || + id.Cmp(holeskyChainID) == 0 || + id.Cmp(polygonMainnetChainID) == 0 || + id.Cmp(polygonMumbaiChainID) == 0 || + id.Cmp(polygonAmoyChainID) == 0 || + id.Cmp(bscMainnetChainID) == 0 || + id.Cmp(bscTestnetChainID) == 0 } func (f *Firehose) OnBlockStart(event tracing.BlockEvent) { b := event.Block - firehoseDebug("block start number=%d hash=%s", b.NumberU64(), b.Hash()) + firehoseInfo("block start (number=%d hash=%s)", b.NumberU64(), b.Hash()) f.ensureBlockChainInit() @@ -196,7 +311,11 @@ func (f *Firehose) OnBlockStart(event tracing.BlockEvent) { Header: newBlockHeaderFromChainHeader(b.Header(), firehoseBigIntFromNative(new(big.Int).Add(event.TD, b.Difficulty()))), Size: uint64(b.Size()), // Known Firehose issue: If you fix all known Firehose issue for a new chain, don't forget to bump `Ver` to `4`! - Ver: 3, + Ver: 4, + } + + if *f.applyBackwardCompatibility { + f.block.Ver = 3 } for _, uncle := range b.Uncles() { @@ -226,24 +345,115 @@ func getActivePrecompilesChecker(rules *chain.Rules) func(addr libcommon.Address } func (f *Firehose) OnBlockEnd(err error) { - firehoseDebug("block ending err=%s", errorView(err)) + firehoseInfo("block ending (err=%s)", errorView(err)) if err == nil { + if f.blockReorderOrdinal { + f.reorderIsolatedTransactionsAndOrdinals() + } + f.ensureInBlockAndNotInTrx() f.printBlockToFirehose(f.block, f.blockFinality) } else { // An error occurred, could have happen in transaction/call context, we must not check if in trx/call, only check in block - f.ensureInBlock() + f.ensureInBlock(0) } f.resetBlock() f.resetTransaction() - firehoseDebug("block end") + firehoseInfo("block end") +} + +// reorderIsolatedTransactionsAndOrdinals is called right after all transactions have completed execution. It will sort transactions +// according to their index. +// +// But most importantly, will re-assign all the ordinals of each transaction recursively. When the parallel execution happened, +// all ordinal were made relative to the transaction they were contained in. But now, we are going to re-assign them to the +// global block ordinal by getting the current ordinal and ad it to the transaction ordinal and so forth. +func (f *Firehose) reorderIsolatedTransactionsAndOrdinals() { + if !f.blockReorderOrdinal { + firehoseInfo("post process isolated transactions skipped (block_reorder_ordinals=false)") + return + } + + ordinalBase := f.blockReorderOrdinalSnapshot + firehoseInfo("post processing isolated transactions sorting & re-assigning ordinals (ordinal_base=%d)", ordinalBase) + + slices.SortStableFunc(f.block.TransactionTraces, func(i, j *pbeth.TransactionTrace) int { + return int(i.Index) - int(j.Index) + }) + + baseline := ordinalBase + for _, trx := range f.block.TransactionTraces { + trx.BeginOrdinal += baseline + for _, call := range trx.Calls { + f.reorderCallOrdinals(call, baseline) + } + + for _, log := range trx.Receipt.Logs { + log.Ordinal += baseline + } + + trx.EndOrdinal += baseline + baseline = trx.EndOrdinal + } + + for _, ch := range f.block.BalanceChanges { + if ch.Ordinal >= ordinalBase { + ch.Ordinal += baseline + } + } + for _, ch := range f.block.CodeChanges { + if ch.Ordinal >= ordinalBase { + ch.Ordinal += baseline + } + } + for _, call := range f.block.SystemCalls { + if call.BeginOrdinal >= ordinalBase { + f.reorderCallOrdinals(call, baseline) + } + } +} + +func (f *Firehose) reorderCallOrdinals(call *pbeth.Call, ordinalBase uint64) (ordinalEnd uint64) { + if *f.applyBackwardCompatibility { + if call.BeginOrdinal != 0 { + call.BeginOrdinal += ordinalBase // consistent with a known small bug: root call has beginOrdinal set to 0 + } + } else { + call.BeginOrdinal += ordinalBase + } + + for _, log := range call.Logs { + log.Ordinal += ordinalBase + } + for _, act := range call.AccountCreations { + act.Ordinal += ordinalBase + } + for _, ch := range call.BalanceChanges { + ch.Ordinal += ordinalBase + } + for _, ch := range call.GasChanges { + ch.Ordinal += ordinalBase + } + for _, ch := range call.NonceChanges { + ch.Ordinal += ordinalBase + } + for _, ch := range call.StorageChanges { + ch.Ordinal += ordinalBase + } + for _, ch := range call.CodeChanges { + ch.Ordinal += ordinalBase + } + + call.EndOrdinal += ordinalBase + + return call.EndOrdinal } func (f *Firehose) OnBeaconBlockRootStart(root libcommon.Hash) { - firehoseDebug("system call start for=%s", "beacon_block_root") + firehoseInfo("system call start (for=%s)", "beacon_block_root") f.ensureInBlockAndNotInTrx() f.inSystemCall = true @@ -260,7 +470,7 @@ func (f *Firehose) OnBeaconBlockRootEnd() { } func (f *Firehose) OnTxStart(evm *tracing.VMContext, tx types.Transaction, from libcommon.Address) { - firehoseDebug("trx start hash=%s type=%d gas=%d input=%s", tx.Hash(), tx.Type(), tx.GetGas(), inputView(tx.GetData())) + firehoseInfo("trx start (tracer=%s hash=%s type=%d gas=%d isolated=%t input=%s)", f.tracerID, tx.Hash(), tx.Type(), tx.GetGas(), f.transactionIsolated, inputView(tx.GetData())) f.ensureInBlockAndNotInTrxAndNotInCall() @@ -302,20 +512,33 @@ func (f *Firehose) onTxStart(tx types.Transaction, hash libcommon.Hash, from, to } func (f *Firehose) OnTxEnd(receipt *types.Receipt, err error) { - firehoseDebug("trx ending") + firehoseInfo("trx ending (tracer=%s)", f.tracerID) f.ensureInBlockAndInTrx() - f.block.TransactionTraces = append(f.block.TransactionTraces, f.completeTransaction(receipt)) + trxTrace := f.completeTransaction(receipt) + + // In this case, we are in some kind of parallel processing and we must simply add the transaction + // to a transient storage (and not in the block directly). Adding it to the block will be done by the + // `OnTxCommit` callback. + if f.transactionIsolated { + f.transactionTransient = trxTrace + // We must not reset transaction here. In the isolated transaction tracer, the transaction is reset + // by the `OnTxReset` callback which comes from outside the tracer. Second, resetting the transaction + // also resets the [f.transactionTransient] field which is the one we want to keep on completion + // of an isolated transaction. + } else { + f.block.TransactionTraces = append(f.block.TransactionTraces, trxTrace) - // 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() + // 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") + firehoseInfo("trx end (tracer=%s)", f.tracerID) } func (f *Firehose) completeTransaction(receipt *types.Receipt) *pbeth.TransactionTrace { - firehoseDebug("completing transaction call_count=%d receipt=%s", len(f.transaction.Calls), (*receiptView)(receipt)) + firehoseInfo("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) int { @@ -325,7 +548,9 @@ func (f *Firehose) completeTransaction(receipt *types.Receipt) *pbeth.Transactio rootCall := f.transaction.Calls[0] if !f.deferredCallState.IsEmpty() { - f.deferredCallState.MaybePopulateCallAndReset("root", rootCall) + if err := f.deferredCallState.MaybePopulateCallAndReset(callSourceRoot, rootCall); err != nil { + panic(err) + } } // Receipt can be nil if an error occurred during the transaction execution, right now we don't have it @@ -347,8 +572,12 @@ func (f *Firehose) completeTransaction(receipt *types.Receipt) *pbeth.Transactio f.removeLogBlockIndexOnStateRevertedCalls() f.assignOrdinalAndIndexToReceiptLogs() - // Known Firehose issue: This field has never been populated in the old Firehose instrumentation, so it's the same thing for now - // f.transaction.ReturnData = rootCall.ReturnData + if *f.applyBackwardCompatibility { + // Known Firehose issue: This field has never been populated in the old Firehose instrumentation + } else { + f.transaction.ReturnData = rootCall.ReturnData + } + f.transaction.EndOrdinal = f.blockOrdinal.Next() return f.transaction @@ -395,11 +624,9 @@ func (f *Firehose) assignOrdinalAndIndexToReceiptLogs() { trx := f.transaction - receiptsLogs := trx.Receipt.Logs - callLogs := []*pbeth.Log{} for _, call := range trx.Calls { - firehoseTrace("checking call reverted=%t logs=%d", call.StateReverted, len(call.Logs)) + firehoseTrace("checking call (reverted=%t logs=%d)", call.StateReverted, len(call.Logs)) if call.StateReverted { continue } @@ -411,6 +638,24 @@ func (f *Firehose) assignOrdinalAndIndexToReceiptLogs() { return cmp.Compare(i.Ordinal, j.Ordinal) }) + // When a transaction failed the receipt can be nil, so we need to deal with this + var receiptsLogs []*pbeth.Log + if trx.Receipt == nil { + if len(callLogs) == 0 { + // No logs in the transaction (nor in calls), nothing to do + return + } + + panic(fmt.Errorf( + "mismatch between Firehose call logs and Ethereum transaction %s receipt logs at block #%d, the transaction has no receipt (failed) so there is no logs but it exists %d Firehose call logs", + hex.EncodeToString(trx.Hash), + f.block.Number, + len(callLogs), + )) + } else { + receiptsLogs = trx.Receipt.Logs + } + if len(callLogs) != len(receiptsLogs) { panic(fmt.Errorf( "mismatch between Firehose call logs and Ethereum transaction %s receipt logs at block #%d, transaction receipt has %d logs but there is %d Firehose call logs", @@ -480,11 +725,11 @@ func (f *Firehose) OnCallExit(depth int, output []byte, gasUsed uint64, err erro // OnOpcode implements the EVMLogger interface to trace a single step of VM execution. func (f *Firehose) OnOpcode(pc uint64, op byte, gas, cost uint64, scope tracing.OpContext, rData []byte, depth int, err error) { - firehoseTrace("on opcode op=%s gas=%d cost=%d, err=%s", op, gas, cost, errorView(err)) + firehoseTrace("on opcode (op=%s gas=%d cost=%d, err=%s)", vm.OpCode(op), gas, cost, errorView(err)) if activeCall := f.callStack.Peek(); activeCall != nil { opCode := vm.OpCode(op) - f.captureInterpreterStep(activeCall, pc, opCode, gas, cost, scope, rData, depth, err) + f.captureInterpreterStep(activeCall) // The rest of the logic expects that a call succeeded, nothing to do more here if the interpreter failed on this OpCode if err != nil { @@ -497,6 +742,8 @@ func (f *Firehose) OnOpcode(pc uint64, op byte, gas, cost uint64, scope tracing. // // The gas change recording in the previous Firehose patch was done before calling `OnKeccakPreimage` so // we must do the same here. + // + // No need to wrap in apply backward compatibility, the old behavior is fine in all cases. if cost > 0 { if reason, found := opCodeToGasChangeReasonMap[opCode]; found { activeCall.GasChanges = append(activeCall.GasChanges, f.newGasChange("state", gas, gas-cost, reason)) @@ -523,19 +770,22 @@ func (f *Firehose) onOpcodeKeccak256(call *pbeth.Call, stack []uint256.Int, memo // We should have exclusive access to the hasher, we can safely reset it. f.hasher.Reset() f.hasher.Write(preImage) - f.hasher.Read(f.hasherBuf[:]) + if _, err := f.hasher.Read(f.hasherBuf[:]); err != nil { + panic(fmt.Errorf("failed to read keccak256 hash: %w", err)) + } - // Known Firehose issue: It appears the old Firehose instrumentation have a bug - // where when the keccak256 preimage is empty, it is written as "." which is - // completely wrong. - // - // To keep the same behavior, we will write the preimage as a "." when the encoded - // data is an empty string. - // - // For new chain, this code should be remove so that we just keep `hex.EncodeToString(data)`. encodedData := hex.EncodeToString(preImage) - if encodedData == "" { - encodedData = "." + + if *f.applyBackwardCompatibility { + // Known Firehose issue: It appears the old Firehose instrumentation have a bug + // where when the keccak256 preimage is empty, it is written as "." which is + // completely wrong. + // + // To keep the same behavior, we will write the preimage as a "." when the encoded + // data is an empty string. + if encodedData == "" { + encodedData = "." + } } call.KeccakPreimages[hex.EncodeToString(f.hasherBuf[:])] = encodedData @@ -565,39 +815,37 @@ var opCodeToGasChangeReasonMap = map[vm.OpCode]pbeth.GasChange_Reason{ // OnOpcodeFault implements the EVMLogger interface to trace an execution fault. func (f *Firehose) OnOpcodeFault(pc uint64, op byte, gas, cost uint64, scope tracing.OpContext, depth int, err error) { if activeCall := f.callStack.Peek(); activeCall != nil { - f.captureInterpreterStep(activeCall, pc, vm.OpCode(op), gas, cost, scope, nil, depth, err) + f.captureInterpreterStep(activeCall) } } -func (f *Firehose) captureInterpreterStep(activeCall *pbeth.Call, pc uint64, op vm.OpCode, gas, cost uint64, _ tracing.OpContext, rData []byte, depth int, err error) { - _, _, _, _, _, _, _ = pc, op, gas, cost, rData, depth, err - - // for call, we need to process the executed code here - // since in old firehose executed code calculation depends if the code exist - if activeCall.CallType == pbeth.CallType_CALL && !activeCall.ExecutedCode { - firehoseTrace("Intepreter step for callType_CALL") - activeCall.ExecutedCode = len(activeCall.Input) > 0 +func (f *Firehose) captureInterpreterStep(activeCall *pbeth.Call) { + if *f.applyBackwardCompatibility { + // for call, we need to process the executed code here + // since in old firehose executed code calculation depends if the code exist + if activeCall.CallType == pbeth.CallType_CALL && !activeCall.ExecutedCode { + firehoseTrace("Intepreter step for callType_CALL") + activeCall.ExecutedCode = len(activeCall.Input) > 0 + } + } else { + activeCall.ExecutedCode = true } } func (f *Firehose) callStart(source string, callType pbeth.CallType, from libcommon.Address, to libcommon.Address, precomile bool, input []byte, gas uint64, value *uint256.Int, code []byte) { - firehoseDebug("call start source=%s index=%d type=%s input=%s", source, f.callStack.NextIndex(), callType, inputView(input)) + firehoseDebug("call start (source=%s index=%d type=%s input=%s)", source, f.callStack.NextIndex(), callType, inputView(input)) f.ensureInBlockAndInTrx() - // Known Firehose issue: Contract creation call's input is always `nil` in old Firehose patch - // due to an oversight that having it in `CodeChange` would be sufficient but this is wrong - // as constructor's input are not part of the code change but part of the call input. - // - // New chain integration should remove this `if` statement completely. - if callType == pbeth.CallType_CREATE { - input = nil + if *f.applyBackwardCompatibility { + // Known Firehose issue: Contract creation call's input is always `nil` in old Firehose patch + // due to an oversight that having it in `CodeChange` would be sufficient but this is wrong + // as constructor's input are not part of the code change but part of the call input. + if callType == pbeth.CallType_CREATE { + input = nil + } } call := &pbeth.Call{ - // Known Firehose issue: Ref 042a2ff03fd623f151d7726314b8aad6 (see below) - // - // New chain integration should uncomment the code below and remove the `if` statement of the the other ref - // BeginOrdinal: f.blockOrdinal.Next(), CallType: callType, Depth: 0, Caller: from.Bytes(), @@ -608,16 +856,18 @@ func (f *Firehose) callStart(source string, callType pbeth.CallType, from libcom GasLimit: gas, } - call.ExecutedCode = f.getExecutedCode(f.evm, call, code) + if *f.applyBackwardCompatibility { + // Known Firehose issue: The BeginOrdinal of the genesis block root call is never actually + // incremented and it's always 0. + // + // Ref 042a2ff03fd623f151d7726314b8aad6 + call.BeginOrdinal = 0 + call.ExecutedCode = f.getExecutedCode(f.evm, call, code) - // Known Firehose issue: The BeginOrdinal of the genesis block root call is never actually - // incremented and it's always 0. - // - // New chain integration should remove this `if` statement and uncomment code of other ref - // above. - // - // Ref 042a2ff03fd623f151d7726314b8aad6 - if f.block.Number != 0 { + if f.block.Number != 0 { + call.BeginOrdinal = f.blockOrdinal.Next() + } + } else { call.BeginOrdinal = f.blockOrdinal.Next() } @@ -625,17 +875,40 @@ func (f *Firehose) callStart(source string, callType pbeth.CallType, from libcom panic(err) } - // Known Firehose issue: The `BeginOrdinal` of the root call is incremented but must - // be assigned back to 0 because of a bug in the console reader. remove on new chain. - // - // New chain integration should remove this `if` statement - if source == "root" { - call.BeginOrdinal = 0 + if *f.applyBackwardCompatibility { + // Known Firehose issue: The `BeginOrdinal` of the root call is incremented but must + // be assigned back to 0 because of a bug in the console reader. remove on new chain. + // + // New chain integration should remove this `if` statement + if source == callSourceRoot { + call.BeginOrdinal = 0 + } } f.callStack.Push(call) } +// Known Firehose issue: How we computed `executed_code` before was not working for contract's that only +// deal with ETH transfer through Solidity `receive()` built-in since those call have `len(input) == 0` +// +// Older comment keeping for future review: +// +// For precompiled address however, interpreter does not run so determine there was a bug in Firehose instrumentation where we would +// +// if call.ExecutedCode || (f.isPrecompiledAddr != nil && f.isPrecompiledAddr(common.BytesToAddress(call.Address))) { +// // In this case, we are sure that some code executed. This translates in the old Firehose instrumentation +// // that it would have **never** emitted an `account_without_code`. +// // +// // When no `account_without_code` was executed in the previous Firehose instrumentation, +// // the `call.ExecutedCode` defaulted to the condition below +// call.ExecutedCode = call.CallType != pbeth.CallType_CREATE && len(call.Input) > 0 +// } else { +// +// // In all other cases, we are sure that no code executed. This translates in the old Firehose instrumentation +// // that it would have emitted an `account_without_code` and it would have then forced set the `call.ExecutedCode` +// // to `false`. +// call.ExecutedCode = false +// } func (f *Firehose) getExecutedCode(evm *tracing.VMContext, call *pbeth.Call, code []byte) bool { precompile := f.blockIsPrecompiledAddr(libcommon.BytesToAddress(call.Address)) @@ -643,30 +916,30 @@ func (f *Firehose) getExecutedCode(evm *tracing.VMContext, call *pbeth.Call, cod if !evm.IntraBlockState.Exist(libcommon.BytesToAddress(call.Address)) && !precompile && f.blockRules.IsSpuriousDragon && (call.Value == nil || call.Value.Native().Sign() == 0) { - firehoseDebug("executed code IsSpuriousDragon callTyp=%s inputLength=%d", call.CallType.String(), len(call.Input) > 0) + firehoseTrace("executed code IsSpuriousDragon callTyp=%s inputLength=%d", call.CallType.String(), len(call.Input) > 0) return call.CallType != pbeth.CallType_CREATE && len(call.Input) > 0 } } if precompile { - firehoseDebug("executed code isprecompile callTyp=%s inputLength=%d", call.CallType.String(), len(call.Input) > 0) + firehoseTrace("executed code isprecompile callTyp=%s inputLength=%d", call.CallType.String(), len(call.Input) > 0) return call.CallType != pbeth.CallType_CREATE && len(call.Input) > 0 } if len(code) == 0 && call.CallType == pbeth.CallType_CALL { - firehoseDebug("executed code call_witnout_code") + firehoseTrace("executed code call_witnout_code") return false } - firehoseDebug("executed code default callTyp=%s inputLength=%d", call.CallType.String(), len(call.Input) > 0) + firehoseTrace("executed code default callTyp=%s inputLength=%d", call.CallType.String(), len(call.Input) > 0) return call.CallType != pbeth.CallType_CREATE && len(call.Input) > 0 } func (f *Firehose) callEnd(source string, output []byte, gasUsed uint64, err error, reverted bool) { - firehoseDebug("call end source=%s index=%d output=%s gasUsed=%d err=%s reverted=%t", source, f.callStack.ActiveIndex(), outputView(output), gasUsed, errorView(err), reverted) + firehoseDebug("call end (source=%s index=%d output=%s gasUsed=%d err=%s reverted=%t)", source, f.callStack.ActiveIndex(), outputView(output), gasUsed, errorView(err), reverted) if f.latestCallEnterSuicided { - if source != "child" { + if source != callSourceChild { panic(fmt.Errorf("unexpected source for suicided call end, expected child but got %s, suicide are always produced on a 'child' source", source)) } @@ -686,37 +959,6 @@ func (f *Firehose) callEnd(source string, output []byte, gasUsed uint64, err err call.ReturnData = bytes.Clone(output) } - // Known Firehose issue: How we computed `executed_code` before was not working for contract's that only - // deal with ETH transfer through Solidity `receive()` built-in since those call have `len(input) == 0` - // - // New chain should turn the logic into: - // - // if !call.ExecutedCode && f.isPrecompiledAddr(common.BytesToAddress(call.Address)) { - // call.ExecutedCode = true - // } - // - // At this point, `call.ExecutedCode` is tied to `EVMInterpreter#Run` execution (in `core/vm/interpreter.go`) - // and is `true` if the run/loop of the interpreter executed. - // - // This means that if `false` the interpreter did not run at all and we would had emitted a - // `account_without_code` event in the old Firehose patch which you have set `call.ExecutecCode` - // to false - // - // For precompiled address however, interpreter does not run so determine there was a bug in Firehose instrumentation where we would - // if call.ExecutedCode || (f.isPrecompiledAddr != nil && f.isPrecompiledAddr(common.BytesToAddress(call.Address))) { - // // In this case, we are sure that some code executed. This translates in the old Firehose instrumentation - // // that it would have **never** emitted an `account_without_code`. - // // - // // When no `account_without_code` was executed in the previous Firehose instrumentation, - // // the `call.ExecutedCode` defaulted to the condition below - // call.ExecutedCode = call.CallType != pbeth.CallType_CREATE && len(call.Input) > 0 - // } else { - // // In all other cases, we are sure that no code executed. This translates in the old Firehose instrumentation - // // that it would have emitted an `account_without_code` and it would have then forced set the `call.ExecutedCode` - // // to `false`. - // call.ExecutedCode = false - // } - if reverted { failureReason := "" if err != nil { @@ -730,21 +972,21 @@ func (f *Firehose) callEnd(source string, output []byte, gasUsed uint64, err err // because they do not cost any gas. call.StatusReverted = errors.Is(err, vm.ErrExecutionReverted) || errors.Is(err, vm.ErrInsufficientBalance) || errors.Is(err, vm.ErrDepth) - // Known Firehose issue: FIXME Document! - if !call.ExecutedCode && (errors.Is(err, vm.ErrInsufficientBalance) || errors.Is(err, vm.ErrDepth)) { - call.ExecutedCode = call.CallType != pbeth.CallType_CREATE && len(call.Input) > 0 + if *f.applyBackwardCompatibility { + // Known Firehose issue: FIXME Document! + if !call.ExecutedCode && (errors.Is(err, vm.ErrInsufficientBalance) || errors.Is(err, vm.ErrDepth)) { + call.ExecutedCode = call.CallType != pbeth.CallType_CREATE && len(call.Input) > 0 + } } } - // Known Firehose issue: The EndOrdinal of the genesis block root call is never actually - // incremented and it's always 0. - // - // New chain should turn the logic into: - // - // call.EndOrdinal = f.blockOrdinal.Next() - // - // Removing the condition around the `EndOrdinal` assignment (keeping it!) - if f.block.Number != 0 { + if *f.applyBackwardCompatibility { + // Known Firehose issue: The EndOrdinal of the genesis block root call is never actually + // incremented and it's always 0. + if f.block.Number != 0 { + call.EndOrdinal = f.blockOrdinal.Next() + } + } else { call.EndOrdinal = f.blockOrdinal.Next() } @@ -753,10 +995,10 @@ func (f *Firehose) callEnd(source string, output []byte, gasUsed uint64, err err func computeCallSource(depth int) string { if depth == 0 { - return "root" + return callSourceRoot } - return "child" + return callSourceChild } func (f *Firehose) OnGenesisBlock(b *types.Block, alloc types.GenesisAlloc) { @@ -769,7 +1011,9 @@ func (f *Firehose) OnGenesisBlock(b *types.Block, alloc types.GenesisAlloc) { for _, addr := range sortedKeys(alloc) { account := alloc[addr] - f.OnNewAccount(addr, false) + if *f.applyBackwardCompatibility { + f.OnNewAccount(addr, false) + } if account.Balance != nil && account.Balance.Sign() != 0 { activeCall := f.callStack.Peek() @@ -817,13 +1061,13 @@ func (f *Firehose) OnBalanceChange(a libcommon.Address, prev, new *uint256.Int, return } - // Known Firehose issue: It's possible to burn Ether by sending some ether to a suicided account. In those case, - // at theend of block producing, StateDB finalize the block by burning ether from the account. This is something - // we were not tracking in the old Firehose instrumentation. - // - // New chain integration should remove this `if` statement all along. - if reason == tracing.BalanceDecreaseSelfdestructBurn { - return + if *f.applyBackwardCompatibility { + // Known Firehose issue: It's possible to burn Ether by sending some ether to a suicided account. In those case, + // at theend of block producing, StateDB finalize the block by burning ether from the account. This is something + // we were not tracking in the old Firehose instrumentation. + if reason == tracing.BalanceDecreaseSelfdestructBurn { + return + } } f.ensureInBlockOrTrx() @@ -846,7 +1090,7 @@ func (f *Firehose) OnBalanceChange(a libcommon.Address, prev, new *uint256.Int, } func (f *Firehose) newBalanceChange(tag string, address libcommon.Address, oldValue, newValue *big.Int, reason pbeth.BalanceChange_Reason) *pbeth.BalanceChange { - firehoseTrace("balance changed tag=%s before=%d after=%d reason=%s", tag, oldValue, newValue, reason) + firehoseTrace("balance changed (tag=%s before=%d after=%d reason=%s)", tag, oldValue, newValue, reason) if reason == pbeth.BalanceChange_REASON_UNKNOWN { panic(fmt.Errorf("received unknown balance change reason %s", reason)) @@ -896,7 +1140,7 @@ func (f *Firehose) OnCodeChange(a libcommon.Address, prevCodeHash libcommon.Hash if f.transaction != nil { activeCall := f.callStack.Peek() if activeCall == nil { - f.panicInvalidState("caller expected to be in call state but we were not, this is a bug") + f.panicInvalidState("caller expected to be in call state but we were not, this is a bug", 0) } activeCall.CodeChanges = append(activeCall.CodeChanges, change) @@ -927,7 +1171,7 @@ func (f *Firehose) OnLog(l *types.Log) { } activeCall := f.callStack.Peek() - firehoseTrace("adding log to call address=%s call=%d (has already %d logs)", l.Address, activeCall.Index, len(activeCall.Logs)) + firehoseTrace("adding log to call (address=%s call=%d [has already %d logs])", l.Address, activeCall.Index, len(activeCall.Logs)) activeCall.Logs = append(activeCall.Logs, &pbeth.Log{ Address: l.Address.Bytes(), @@ -942,6 +1186,17 @@ func (f *Firehose) OnLog(l *types.Log) { } func (f *Firehose) OnNewAccount(a libcommon.Address, previousDataExists bool) { + // Newer Firehose instrumentation does not track OnNewAccount anymore since it's bogus + // and was removed from the Geth live tracer. + if !*f.applyBackwardCompatibility { + return + } + + // Known Firehose issue: The current Firehose instrumentation emits multiple + // time the same `OnNewAccount` event for the same account when such account + // exists in the past. For now, do nothing and keep the legacy behavior. + _ = previousDataExists + f.ensureInBlockOrTrx() if f.transaction == nil { // We receive OnNewAccount on finalization of the block which means there is no @@ -956,11 +1211,6 @@ func (f *Firehose) OnNewAccount(a libcommon.Address, previousDataExists bool) { return } - // Known Firehose issue: The current Firehose instrumentation emits multiple - // time the same `OnNewAccount` event for the same account when such account - // exists in the past. For now, do nothing and keep the legacy behavior. - _ = previousDataExists - if call := f.callStack.Peek(); call != nil && call.CallType == pbeth.CallType_STATIC && f.blockIsPrecompiledAddr(libcommon.Address(call.Address)) { // Old Firehose ignore those, we do the same return @@ -992,18 +1242,18 @@ func (f *Firehose) OnGasChange(old, new uint64, reason tracing.GasChangeReason) return } - // Known Firehose issue: New geth native tracer added more gas change, some that we were indeed missing and - // should have included in our previous patch. - // - // For new chain, this code should be remove so that they are included and useful to user. - // - // Ref eb1916a67d9bea03df16a7a3e2cfac72 - if reason == tracing.GasChangeTxInitialBalance || - reason == tracing.GasChangeTxRefunds || - reason == tracing.GasChangeTxLeftOverReturned || - reason == tracing.GasChangeCallInitialBalance || - reason == tracing.GasChangeCallLeftOverReturned { - return + if *f.applyBackwardCompatibility { + // Known Firehose issue: New geth native tracer added more gas change, some that we were indeed missing and + // should have included in our previous patch. + // + // Ref eb1916a67d9bea03df16a7a3e2cfac72 + if reason == tracing.GasChangeTxInitialBalance || + reason == tracing.GasChangeTxRefunds || + reason == tracing.GasChangeTxLeftOverReturned || + reason == tracing.GasChangeCallInitialBalance || + reason == tracing.GasChangeCallLeftOverReturned { + return + } } activeCall := f.callStack.Peek() @@ -1019,7 +1269,7 @@ func (f *Firehose) OnGasChange(old, new uint64, reason tracing.GasChangeReason) } func (f *Firehose) newGasChange(tag string, oldValue, newValue uint64, reason pbeth.GasChange_Reason) *pbeth.GasChange { - firehoseTrace("gas consumed tag=%s before=%d after=%d reason=%s", tag, oldValue, newValue, reason) + firehoseTrace("gas consumed (tag=%s before=%d after=%d reason=%s)", tag, oldValue, newValue, reason) // Should already be checked by the caller, but we keep it here for safety if the code ever change if reason == pbeth.GasChange_REASON_UNKNOWN { @@ -1036,23 +1286,23 @@ func (f *Firehose) newGasChange(tag string, oldValue, newValue uint64, reason pb func (f *Firehose) ensureBlockChainInit() { if f.chainConfig == nil { - f.panicInvalidState("the OnBlockchainInit hook should have been called at this point") + f.panicInvalidState("the OnBlockchainInit hook should have been called at this point", 2) } } -func (f *Firehose) ensureInBlock() { +func (f *Firehose) ensureInBlock(callerSkip int) { if f.block == nil { - f.panicInvalidState("caller expected to be in block state but we were not, this is a bug") + f.panicInvalidState("caller expected to be in block state but we were not, this is a bug", callerSkip+1) } if f.chainConfig == nil { - f.panicInvalidState("the OnBlockchainInit hook should have been called at this point") + f.panicInvalidState("the OnBlockchainInit hook should have been called at this point", callerSkip+1) } } -func (f *Firehose) ensureNotInBlock() { +func (f *Firehose) ensureNotInBlock(callerSkip int) { if f.block != nil { - f.panicInvalidState("caller expected to not be in block state but we were, this is a bug") + f.panicInvalidState("caller expected to not be in block state but we were, this is a bug", callerSkip+1) } } @@ -1061,63 +1311,76 @@ func (f *Firehose) ensureNotInBlock() { var _ = new(Firehose).ensureNotInBlock func (f *Firehose) ensureInBlockAndInTrx() { - f.ensureInBlock() + f.ensureInBlock(2) if f.transaction == nil { - f.panicInvalidState("caller expected to be in transaction state but we were not, this is a bug") + f.panicInvalidState("caller expected to be in transaction state but we were not, this is a bug", 2) } } func (f *Firehose) ensureInBlockAndNotInTrx() { - f.ensureInBlock() + f.ensureInBlock(2) if f.transaction != nil { - f.panicInvalidState("caller expected to not be in transaction state but we were, this is a bug") + f.panicInvalidState("caller expected to not be in transaction state but we were, this is a bug", 2) } } func (f *Firehose) ensureInBlockAndNotInTrxAndNotInCall() { - f.ensureInBlock() + f.ensureInBlock(2) if f.transaction != nil { - f.panicInvalidState("caller expected to not be in transaction state but we were, this is a bug") + f.panicInvalidState("caller expected to not be in transaction state but we were, this is a bug", 2) } if f.callStack.HasActiveCall() { - f.panicInvalidState("caller expected to not be in call state but we were, this is a bug") + f.panicInvalidState("caller expected to not be in call state but we were, this is a bug", 2) } } func (f *Firehose) ensureInBlockOrTrx() { if f.transaction == nil && f.block == nil { - f.panicInvalidState("caller expected to be in either block or transaction state but we were not, this is a bug") + f.panicInvalidState("caller expected to be in either block or transaction state but we were not, this is a bug", 2) } } func (f *Firehose) ensureInBlockAndInTrxAndInCall() { if f.transaction == nil || f.block == nil { - f.panicInvalidState("caller expected to be in block and in transaction but we were not, this is a bug") + f.panicInvalidState("caller expected to be in block and in transaction but we were not, this is a bug", 2) } if !f.callStack.HasActiveCall() { - f.panicInvalidState("caller expected to be in call state but we were not, this is a bug") + f.panicInvalidState("caller expected to be in call state but we were not, this is a bug", 2) } } func (f *Firehose) ensureInCall() { if f.block == nil { - f.panicInvalidState("caller expected to be in call state but we were not, this is a bug") + f.panicInvalidState("caller expected to be in call state but we were not, this is a bug", 2) } } func (f *Firehose) ensureInSystemCall() { if !f.inSystemCall { - f.panicInvalidState("call expected to be in system call state but we were not, this is a bug") + f.panicInvalidState("call expected to be in system call state but we were not, this is a bug", 2) } } -func (f *Firehose) panicInvalidState(msg string) string { - panic(fmt.Errorf("%s (init=%t, inBlock=%t, inTransaction=%t, inCall=%t)", msg, f.chainConfig != nil, f.block != nil, f.transaction != nil, f.callStack.HasActiveCall())) +func (f *Firehose) panicInvalidState(msg string, callerSkip int) string { + caller := "N/A" + if _, file, line, ok := runtime.Caller(callerSkip); ok { + caller = fmt.Sprintf("%s:%d", file, line) + } + + if f.block != nil { + msg += fmt.Sprintf(" at block #%d (%s)", f.block.Number, hex.EncodeToString(f.block.Hash)) + } + + if f.transaction != nil { + msg += fmt.Sprintf(" in transaction %s", hex.EncodeToString(f.transaction.Hash)) + } + + panic(fmt.Errorf("%s (caller=%s, init=%t, inBlock=%t, inTransaction=%t, inCall=%t)", msg, caller, f.chainConfig != nil, f.block != nil, f.transaction != nil, f.callStack.HasActiveCall())) } // printToFirehose is an easy way to print to Firehose format, it essentially @@ -1200,7 +1463,10 @@ func flushToFirehose(in []byte, writer io.Writer) { } errstr := fmt.Sprintf("\nFIREHOSE FAILED WRITING %dx: %s\n", loops, err) - os.WriteFile("/tmp/firehose_writer_failed_print.log", []byte(errstr), 0644) + if err := os.WriteFile("./firehose_writer_failed_print.log", []byte(errstr), 0600); err != nil { + fmt.Println(errstr) + } + fmt.Fprint(writer, errstr) } @@ -1402,22 +1668,11 @@ func balanceChangeReasonFromChain(reason tracing.BalanceChangeReason) pbeth.Bala } var gasChangeReasonToPb = map[tracing.GasChangeReason]pbeth.GasChange_Reason{ - // Known Firehose issue: Those are new gas change trace that we were missing initially in our old - // Firehose patch. See Known Firehose issue referenced eb1916a67d9bea03df16a7a3e2cfac72 for details - // search for the id within this project to find back all links). - // - // New chain should uncomment the code below and remove the same assigments to UNKNOWN - // - // tracing.GasChangeTxInitialBalance: pbeth.GasChange_REASON_TX_INITIAL_BALANCE, - // tracing.GasChangeTxRefunds: pbeth.GasChange_REASON_TX_REFUNDS, - // tracing.GasChangeTxLeftOverReturned: pbeth.GasChange_REASON_TX_LEFT_OVER_RETURNED, - // tracing.GasChangeCallInitialBalance: pbeth.GasChange_REASON_CALL_INITIAL_BALANCE, - // tracing.GasChangeCallLeftOverReturned: pbeth.GasChange_REASON_CALL_LEFT_OVER_RETURNED, - tracing.GasChangeTxInitialBalance: pbeth.GasChange_REASON_UNKNOWN, - tracing.GasChangeTxRefunds: pbeth.GasChange_REASON_UNKNOWN, - tracing.GasChangeTxLeftOverReturned: pbeth.GasChange_REASON_UNKNOWN, - tracing.GasChangeCallInitialBalance: pbeth.GasChange_REASON_UNKNOWN, - tracing.GasChangeCallLeftOverReturned: pbeth.GasChange_REASON_UNKNOWN, + tracing.GasChangeTxInitialBalance: pbeth.GasChange_REASON_TX_INITIAL_BALANCE, + tracing.GasChangeTxRefunds: pbeth.GasChange_REASON_TX_REFUNDS, + tracing.GasChangeTxLeftOverReturned: pbeth.GasChange_REASON_TX_LEFT_OVER_RETURNED, + tracing.GasChangeCallInitialBalance: pbeth.GasChange_REASON_CALL_INITIAL_BALANCE, + tracing.GasChangeCallLeftOverReturned: pbeth.GasChange_REASON_CALL_LEFT_OVER_RETURNED, tracing.GasChangeTxIntrinsicGas: pbeth.GasChange_REASON_INTRINSIC_GAS, tracing.GasChangeCallContractCreation: pbeth.GasChange_REASON_CONTRACT_CREATION, @@ -1485,22 +1740,32 @@ func gasPrice(tx types.Transaction, baseFee *big.Int) *pbeth.BigInt { panic(errUnhandledTransactionType("gasPrice", tx.Type())) } -func FirehoseDebug(msg string, args ...interface{}) { +func firehoseInfo(msg string, args ...any) { + if isFirehoseInfoEnabled { + firehoseLog(msg, args) + } +} + +func FirehoseDebug(msg string, args ...any) { firehoseDebug(msg, args...) } -func firehoseDebug(msg string, args ...interface{}) { +func firehoseDebug(msg string, args ...any) { if isFirehoseDebugEnabled { - fmt.Fprintf(os.Stderr, "[Firehose] "+msg+"\n", args...) + firehoseLog(msg, args) } } -func firehoseTrace(msg string, args ...interface{}) { +func firehoseTrace(msg string, args ...any) { if isFirehoseTracerEnabled { - fmt.Fprintf(os.Stderr, "[Firehose] "+msg+"\n", args...) + firehoseLog(msg, args) } } +func firehoseLog(msg string, args []any) { + fmt.Fprintf(os.Stderr, "[Firehose] "+msg+"\n", args...) +} + // Ignore unused, we keep it around for debugging purposes var _ = firehoseDebugPrintStack @@ -1667,6 +1932,16 @@ func (e _errorView) String() string { return e.err.Error() } +type boolPtrView bool + +func (b *boolPtrView) String() string { + if b == nil { + return "" + } + + return strconv.FormatBool(*(*bool)(b)) +} + type inputView []byte func (b inputView) String() string { @@ -1756,6 +2031,11 @@ type FinalityStatus struct { LastIrreversibleBlockHash []byte } +func (s *FinalityStatus) populate(finalNumber uint64, finalHash []byte) { + s.LastIrreversibleBlockNumber = finalNumber + s.LastIrreversibleBlockHash = finalHash +} + func (s *FinalityStatus) populateFromChain(finalHeader *types.Header) { if finalHeader == nil { s.Reset() @@ -1945,9 +2225,17 @@ func (m Memory) GetPtr(offset, size int64) []byte { return nil } - if len(m) > int(offset) { + if len(m) >= (int(offset) + int(size)) { return m[offset : offset+size] } - return nil + // The EVM does memory expansion **after** notifying us about OnOpcode which we use + // to compute Keccak256 pre-images now. This creates problem when we want to retrieve + // the preimage data because the memory is not expanded yet but in the EVM is going to + // work because the memory is going to be expanded before the operation is actually + // executed so the memory will be of the correct size. + // + // In this situtation, we must pad with zeroes when the memory is not big enough. + reminder := m[offset:] + return append(reminder, make([]byte, int(size)-len(reminder))...) } diff --git a/eth/tracers/live/firehose_test.go b/eth/tracers/live/firehose_test.go index dbb7b3e33b3..1a1e3321e4d 100644 --- a/eth/tracers/live/firehose_test.go +++ b/eth/tracers/live/firehose_test.go @@ -1,10 +1,28 @@ package live import ( + "encoding/json" + "fmt" + "math/big" + "os" + "reflect" "testing" + "github.com/holiman/uint256" + "github.com/ledgerwatch/erigon-lib/common" + "github.com/ledgerwatch/erigon/core/tracing" + "github.com/ledgerwatch/erigon/core/types" + "github.com/ledgerwatch/erigon/core/vm" + "github.com/ledgerwatch/erigon/params" pbeth "github.com/ledgerwatch/erigon/pb/sf/ethereum/type/v2" + + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "golang.org/x/exp/maps" + "golang.org/x/exp/slices" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/timestamppb" ) func TestFirehoseCallStack_Push(t *testing.T) { @@ -82,3 +100,377 @@ func Test_validateKnownTransactionTypes(t *testing.T) { }) } } + +var ignorePbFieldNames = map[string]bool{ + "Hash": true, + "TotalDifficulty": true, + "state": true, + "unknownFields": true, + "sizeCache": true, + // This was a Polygon specific field that existed for a while and has since been + // removed. It can be safely ignored in all protocols now. + "TxDependency": true, +} +var pbFieldNameToGethMapping = map[string]string{ + "WithdrawalsRoot": "WithdrawalsHash", + "MixHash": "MixDigest", + "BaseFeePerGas": "BaseFee", + "StateRoot": "Root", + "ExtraData": "Extra", + "Timestamp": "Time", + "ReceiptRoot": "ReceiptHash", + "TransactionsRoot": "TxHash", + "LogsBloom": "Bloom", +} +var ( + pbHeaderType = reflect.TypeFor[pbeth.BlockHeader]() + gethHeaderType = reflect.TypeFor[types.Header]() +) + +func Test_TypesHeader_AllConsensusFieldsAreKnown(t *testing.T) { + t.Skip() + // This exact hash varies from protocol to protocol and also sometimes from one version to the other. + // When adding support for a new hard-fork that adds new block header fields, it's normal that this value + // changes. If you are sure the two struct are the same, then you can update the expected hash below + // to the new value. + expectedHash := common.HexToHash("5341947c531e5c9cf38202784b16ac66484fe1838aa6e825436b22321b927296") + gethHeaderValue := reflect.New(gethHeaderType) + fillAllFieldsWithNonEmptyValues(t, gethHeaderValue, reflect.VisibleFields(gethHeaderType)) + gethHeader := gethHeaderValue.Interface().(*types.Header) + // If you hit this assertion, it means that the fields `types.Header` of go-ethereum differs now + // versus last time this test was edited. + // + // It's important to understand that in Ethereum Block Header (e.g. `*types.Header`), the `Hash` is + // actually a computed value based on the other fields in the struct, so if you change any field, + // the hash will change also. + // + // On hard-fork, it happens that new fields are added, this test serves as a way to "detect" in codde + // that the expected fields of `types.Header` changed + require.Equal(t, expectedHash, gethHeader.Hash(), + "Geth Header Hash mistmatch, got %q but expecting %q on *types.Header:\n\nGeth Header (from fillNonDefault(new(*types.Header)))\n%s", + gethHeader.Hash().Hex(), + expectedHash, + asIndentedJSON(t, gethHeader), + ) +} + +func Test_FirehoseAndGethHeaderFieldMatches(t *testing.T) { + t.Skip() + pbFields := filter(reflect.VisibleFields(pbHeaderType), func(f reflect.StructField) bool { + return !ignorePbFieldNames[f.Name] + }) + gethFields := reflect.VisibleFields(gethHeaderType) + pbFieldCount := len(pbFields) + gethFieldCount := len(gethFields) + pbFieldNames := extractStructFieldNames(pbFields) + gethFieldNames := extractStructFieldNames(gethFields) + // If you reach this assertion, it means that the fields count in the protobuf and go-ethereum are different. + // It is super important that you properly update the mapping from pbeth.BlockHeader to go-ethereum/core/types.Header + // that is done in `codecHeaderToGethHeader` function in `executor/provider_statedb.go`. + require.Equal( + t, + pbFieldCount, + gethFieldCount, + fieldsCountMistmatchMessage(t, pbFieldNames, gethFieldNames)) + for pbFieldName := range pbFieldNames { + pbFieldRenamedName, found := pbFieldNameToGethMapping[pbFieldName] + if !found { + pbFieldRenamedName = pbFieldName + } + assert.Contains(t, gethFieldNames, pbFieldRenamedName, "pbField.Name=%q (original %q) not found in gethFieldNames", pbFieldRenamedName, pbFieldName) + } +} +func fillAllFieldsWithNonEmptyValues(t *testing.T, structValue reflect.Value, fields []reflect.StructField) { + t.Helper() + for _, field := range fields { + fieldValue := structValue.Elem().FieldByName(field.Name) + require.True(t, fieldValue.IsValid(), "field %q not found", field.Name) + switch fieldValue.Interface().(type) { + case []byte: + fieldValue.Set(reflect.ValueOf([]byte{1})) + case uint64: + fieldValue.Set(reflect.ValueOf(uint64(1))) + case *uint64: + var mockValue uint64 = 1 + fieldValue.Set(reflect.ValueOf(&mockValue)) + case *common.Hash: + var mockValue common.Hash = common.HexToHash("0x01") + fieldValue.Set(reflect.ValueOf(&mockValue)) + case common.Hash: + fieldValue.Set(reflect.ValueOf(common.HexToHash("0x01"))) + case common.Address: + fieldValue.Set(reflect.ValueOf(common.HexToAddress("0x01"))) + case types.Bloom: + fieldValue.Set(reflect.ValueOf(types.BytesToBloom([]byte{1}))) + case types.BlockNonce: + fieldValue.Set(reflect.ValueOf(types.EncodeNonce(1))) + case *big.Int: + fieldValue.Set(reflect.ValueOf(big.NewInt(1))) + case *pbeth.BigInt: + fieldValue.Set(reflect.ValueOf(&pbeth.BigInt{Bytes: []byte{1}})) + case *timestamppb.Timestamp: + fieldValue.Set(reflect.ValueOf(×tamppb.Timestamp{Seconds: 1})) + default: + // If you reach this panic in test, simply add a case above with a sane non-default + // value for the type in question. + t.Fatalf("unsupported type %T", fieldValue.Interface()) + } + } +} +func fieldsCountMistmatchMessage(t *testing.T, pbFieldNames map[string]bool, gethFieldNames map[string]bool) string { + t.Helper() + pbRemappedFieldNames := make(map[string]bool, len(pbFieldNames)) + for pbFieldName := range pbFieldNames { + pbFieldRenamedName, found := pbFieldNameToGethMapping[pbFieldName] + if !found { + pbFieldRenamedName = pbFieldName + } + pbRemappedFieldNames[pbFieldRenamedName] = true + } + return fmt.Sprintf( + "Field count mistmatch between `pbeth.BlockHeader` (has %d fields) and `*types.Header` (has %d fields)\n\n"+ + "Fields in `pbeth.Blockheader`:\n%s\n\n"+ + "Fields in `*types.Header`:\n%s\n\n"+ + "Missing in `pbeth.BlockHeader`:\n%s\n\n"+ + "Missing in `*types.Header`:\n%s", + len(pbRemappedFieldNames), + len(gethFieldNames), + asIndentedJSON(t, maps.Keys(pbRemappedFieldNames)), + asIndentedJSON(t, maps.Keys(gethFieldNames)), + asIndentedJSON(t, missingInSet(gethFieldNames, pbRemappedFieldNames)), + asIndentedJSON(t, missingInSet(pbRemappedFieldNames, gethFieldNames)), + ) +} +func asIndentedJSON(t *testing.T, v any) string { + t.Helper() + out, err := json.MarshalIndent(v, "", " ") + require.NoError(t, err) + return string(out) +} +func missingInSet(a, b map[string]bool) []string { + missing := make([]string, 0) + for name := range a { + if !b[name] { + missing = append(missing, name) + } + } + return missing +} +func extractStructFieldNames(fields []reflect.StructField) map[string]bool { + result := make(map[string]bool, len(fields)) + for _, field := range fields { + result[field.Name] = true + } + return result +} +func filter[S ~[]T, T any](s S, f func(T) bool) (out S) { + out = make(S, 0, len(s)/4) + for i, v := range s { + if f(v) { + out = append(out, s[i]) + } + } + + return out +} + +func TestFirehose_reorderIsolatedTransactionsAndOrdinals(t *testing.T) { + tests := []struct { + name string + populate func(t *Firehose) + expectedBlockFile string + }{ + { + name: "empty", + populate: func(t *Firehose) { + t.OnBlockStart(blockEvent(1)) + + // Simulated GetTxTracer being called + t.blockReorderOrdinalOnce.Do(func() { + t.blockReorderOrdinal = true + t.blockReorderOrdinalSnapshot = t.blockOrdinal.value + }) + + t.blockOrdinal.Reset() + t.onTxStart(txEvent(), hex2Hash("CC"), from, to) + t.OnCallEnter(0, byte(vm.CALL), from, to, false, nil, 0, nil, nil) + t.OnBalanceChange(empty, u(1), u(2), 0) + t.OnCallExit(0, nil, 0, nil, false) + t.OnTxEnd(txReceiptEvent(2), nil) + + t.blockOrdinal.Reset() + t.onTxStart(txEvent(), hex2Hash("AA"), from, to) + t.OnCallEnter(0, byte(vm.CALL), from, to, false, nil, 0, nil, nil) + t.OnBalanceChange(empty, u(1), u(2), 0) + t.OnCallExit(0, nil, 0, nil, false) + t.OnTxEnd(txReceiptEvent(0), nil) + + t.blockOrdinal.Reset() + t.onTxStart(txEvent(), hex2Hash("BB"), from, to) + t.OnCallEnter(0, byte(vm.CALL), from, to, false, nil, 0, nil, nil) + t.OnBalanceChange(empty, u(1), u(2), 0) + t.OnCallExit(0, nil, 0, nil, false) + t.OnTxEnd(txReceiptEvent(1), nil) + }, + expectedBlockFile: "testdata/firehose/reorder-ordinals-empty.golden.json", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f := NewFirehose(&FirehoseConfig{ + ApplyBackwardCompatibility: ptr(false), + }) + f.OnBlockchainInit(params.AllProtocolChanges) + + tt.populate(f) + + f.reorderIsolatedTransactionsAndOrdinals() + + goldenUpdate := os.Getenv("GOLDEN_UPDATE") == "true" + goldenPath := tt.expectedBlockFile + + if !goldenUpdate && !fileExits(t, goldenPath) { + t.Fatalf("the golden file %q does not exist, re-run with 'GOLDEN_UPDATE=true go test ./... -run %q' to generate the intial version", goldenPath, t.Name()) + } + + content, err := protojson.MarshalOptions{Indent: " "}.Marshal(f.block) + require.NoError(t, err) + + if goldenUpdate { + require.NoError(t, os.WriteFile(goldenPath, content, os.ModePerm)) + } + + expected, err := os.ReadFile(goldenPath) + require.NoError(t, err) + + expectedBlock := &pbeth.Block{} + protojson.Unmarshal(expected, expectedBlock) + + if !proto.Equal(expectedBlock, f.block) { + assert.Equal(t, expectedBlock, f.block, "Run 'GOLDEN_UPDATE=true go test ./... -run %q' to update golden file", t.Name()) + } + + seenOrdinals := make(map[uint64]int) + + walkChanges(f.block.BalanceChanges, seenOrdinals) + walkChanges(f.block.CodeChanges, seenOrdinals) + walkCalls(f.block.SystemCalls, seenOrdinals) + + for _, trx := range f.block.TransactionTraces { + seenOrdinals[trx.BeginOrdinal] = seenOrdinals[trx.BeginOrdinal] + 1 + seenOrdinals[trx.EndOrdinal] = seenOrdinals[trx.EndOrdinal] + 1 + walkCalls(trx.Calls, seenOrdinals) + } + + // No ordinal should be seen more than once + for ordinal, count := range seenOrdinals { + assert.Equal(t, 1, count, "Ordinal %d seen %d times", ordinal, count) + } + + ordinals := maps.Keys(seenOrdinals) + slices.Sort(ordinals) + + // All ordinals should be in stricly increasing order + prev := -1 + for _, ordinal := range ordinals { + if prev != -1 { + assert.Equal(t, prev+1, int(ordinal), "Ordinal %d is not in sequence", ordinal) + } + } + }) + } +} + +func walkCalls(calls []*pbeth.Call, ordinals map[uint64]int) { + for _, call := range calls { + walkCall(call, ordinals) + } +} + +func walkCall(call *pbeth.Call, ordinals map[uint64]int) { + ordinals[call.BeginOrdinal] = ordinals[call.BeginOrdinal] + 1 + ordinals[call.EndOrdinal] = ordinals[call.EndOrdinal] + 1 + + walkChanges(call.BalanceChanges, ordinals) + walkChanges(call.CodeChanges, ordinals) + walkChanges(call.Logs, ordinals) + walkChanges(call.StorageChanges, ordinals) + walkChanges(call.NonceChanges, ordinals) + walkChanges(call.GasChanges, ordinals) +} + +func walkChanges[T any](changes []T, ordinals map[uint64]int) { + for _, change := range changes { + var x any = change + if v, ok := x.(interface{ GetOrdinal() uint64 }); ok { + ordinals[v.GetOrdinal()] = ordinals[v.GetOrdinal()] + 1 + } + } +} + +var b = big.NewInt +var u = uint256.NewInt +var empty, from, to = common.HexToAddress("00"), common.HexToAddress("01"), common.HexToAddress("02") +var hex2Hash = common.HexToHash + +func fileExits(t *testing.T, path string) bool { + t.Helper() + stat, err := os.Stat(path) + return err == nil && !stat.IsDir() +} + +func txEvent() types.Transaction { + return &types.LegacyTx{ + CommonTx: types.CommonTx{ + Nonce: 0, + Gas: 1, + To: &to, + Value: uint256.NewInt(1), + Data: nil, + V: *uint256.NewInt(1), + R: *uint256.NewInt(1), + S: *uint256.NewInt(1), + }, + + GasPrice: uint256.NewInt(1), + } +} + +func txReceiptEvent(txIndex uint) *types.Receipt { + return &types.Receipt{ + Status: 1, + TransactionIndex: txIndex, + } +} + +func blockEvent(height uint64) tracing.BlockEvent { + return tracing.BlockEvent{ + Block: types.NewBlock(&types.Header{ + Number: b(int64(height)), + }, nil, nil, nil, nil), + TD: b(1), + } +} + +func TestMemory_GetPtr(t *testing.T) { + type args struct { + offset int64 + size int64 + } + tests := []struct { + name string + m Memory + args args + want []byte + }{ + {"memory is just a bit too small", Memory([]byte{1, 2, 3}), args{0, 4}, []byte{1, 2, 3, 0}}, + {"memory is flushed with request", Memory([]byte{1, 2, 3, 4}), args{0, 4}, []byte{1, 2, 3, 4}}, + {"memory is just a bit too big", Memory([]byte{1, 2, 3, 4, 5}), args{0, 4}, []byte{1, 2, 3, 4}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, tt.m.GetPtr(tt.args.offset, tt.args.size)) + }) + } +} diff --git a/eth/tracers/live/testdata/firehose/reorder-ordinals-empty.golden.json b/eth/tracers/live/testdata/firehose/reorder-ordinals-empty.golden.json new file mode 100644 index 00000000000..b2ac4a9e58f --- /dev/null +++ b/eth/tracers/live/testdata/firehose/reorder-ordinals-empty.golden.json @@ -0,0 +1,122 @@ +{ + "hash": "sr0uqeYWyrLV1vijvIG6fe+0mu8LRG6FLMHv5UmUxK8=", + "number": "1", + "size": "501", + "header": { + "parentHash": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "uncleHash": "HcxN6N7HXXqrhbVntszUGtMSRRuUinQT8KFC/UDUk0c=", + "coinbase": "AAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "stateRoot": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "transactionsRoot": "VugfFxvMVab/g0XmksD4bltI4BuZbK3AAWIvteNjtCE=", + "receiptRoot": "VugfFxvMVab/g0XmksD4bltI4BuZbK3AAWIvteNjtCE=", + "logsBloom": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", + "difficulty": { + "bytes": "AA==" + }, + "totalDifficulty": { + "bytes": "AQ==" + }, + "number": "1", + "timestamp": "1970-01-01T00:00:00Z", + "mixHash": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "hash": "sr0uqeYWyrLV1vijvIG6fe+0mu8LRG6FLMHv5UmUxK8=" + }, + "transactionTraces": [ + { + "to": "AAAAAAAAAAAAAAAAAAAAAAAAAAI=", + "gasPrice": { + "bytes": "AQ==" + }, + "gasLimit": "1", + "value": { + "bytes": "AQ==" + }, + "v": "AQ==", + "r": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAE=", + "s": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAE=", + "hash": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAKo=", + "from": "AAAAAAAAAAAAAAAAAAAAAAAAAAE=", + "beginOrdinal": "1", + "endOrdinal": "4", + "status": "SUCCEEDED", + "receipt": { + "logsBloom": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" + }, + "calls": [ + { + "index": 1, + "callType": "CALL", + "caller": "AAAAAAAAAAAAAAAAAAAAAAAAAAE=", + "address": "AAAAAAAAAAAAAAAAAAAAAAAAAAI=", + "beginOrdinal": "2", + "endOrdinal": "3" + } + ] + }, + { + "to": "AAAAAAAAAAAAAAAAAAAAAAAAAAI=", + "gasPrice": { + "bytes": "AQ==" + }, + "gasLimit": "1", + "value": { + "bytes": "AQ==" + }, + "v": "AQ==", + "r": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAE=", + "s": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAE=", + "index": 1, + "hash": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAALs=", + "from": "AAAAAAAAAAAAAAAAAAAAAAAAAAE=", + "beginOrdinal": "5", + "endOrdinal": "8", + "status": "SUCCEEDED", + "receipt": { + "logsBloom": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" + }, + "calls": [ + { + "index": 1, + "callType": "CALL", + "caller": "AAAAAAAAAAAAAAAAAAAAAAAAAAE=", + "address": "AAAAAAAAAAAAAAAAAAAAAAAAAAI=", + "beginOrdinal": "6", + "endOrdinal": "7" + } + ] + }, + { + "to": "AAAAAAAAAAAAAAAAAAAAAAAAAAI=", + "gasPrice": { + "bytes": "AQ==" + }, + "gasLimit": "1", + "value": { + "bytes": "AQ==" + }, + "v": "AQ==", + "r": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAE=", + "s": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAE=", + "index": 2, + "hash": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMw=", + "from": "AAAAAAAAAAAAAAAAAAAAAAAAAAE=", + "beginOrdinal": "9", + "endOrdinal": "12", + "status": "SUCCEEDED", + "receipt": { + "logsBloom": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" + }, + "calls": [ + { + "index": 1, + "callType": "CALL", + "caller": "AAAAAAAAAAAAAAAAAAAAAAAAAAE=", + "address": "AAAAAAAAAAAAAAAAAAAAAAAAAAI=", + "beginOrdinal": "10", + "endOrdinal": "11" + } + ] + } + ], + "ver": 4 +} \ No newline at end of file