diff --git a/rpc/v10/adapt_trace.go b/rpc/v10/adapt_trace.go index 801d8eee65..673889dcca 100644 --- a/rpc/v10/adapt_trace.go +++ b/rpc/v10/adapt_trace.go @@ -247,16 +247,8 @@ func AdaptVMStateDiff(vmStateDiff *vm.StateDiff) StateDiff { } } +// adaptVMInitialReads requires non-nil VM output; callers decide how missing reads are handled. func adaptVMInitialReads(vmInitialReads *vm.InitialReads) InitialReads { - if vmInitialReads == nil { - return InitialReads{ - Storage: []StorageEntry{}, - Nonces: []NonceEntry{}, - ClassHashes: []ClassHashEntry{}, - DeclaredContracts: []DeclaredContractEntry{}, - } - } - storage := make([]StorageEntry, len(vmInitialReads.Storage)) for i, s := range vmInitialReads.Storage { storage[i] = StorageEntry{ diff --git a/rpc/v10/handlers.go b/rpc/v10/handlers.go index 018665e9c1..4949d0d9e6 100644 --- a/rpc/v10/handlers.go +++ b/rpc/v10/handlers.go @@ -11,7 +11,6 @@ import ( "github.com/NethermindEth/juno/blockchain" "github.com/NethermindEth/juno/clients/feeder" "github.com/NethermindEth/juno/core" - "github.com/NethermindEth/juno/core/felt" "github.com/NethermindEth/juno/core/pending" "github.com/NethermindEth/juno/feed" "github.com/NethermindEth/juno/jsonrpc" @@ -20,7 +19,6 @@ import ( "github.com/NethermindEth/juno/starknet/compiler" "github.com/NethermindEth/juno/sync" "github.com/NethermindEth/juno/utils/log" - "github.com/NethermindEth/juno/utils/lru" "github.com/NethermindEth/juno/vm" "github.com/sourcegraph/conc" ) @@ -43,8 +41,8 @@ type Handler struct { idgen func() string subscriptions stdsync.Map // map[string]*subscription - blockTraceCache *lru.Cache[felt.Felt, TraceBlockTransactionsResponse] - // todo(rdr): Can this cache be genericified and can it be applied to the `blockTraceCache` + blockTraceCache *blockTraceCache + // submittedTransactionsCache is a TTL membership set, unlike the coordinated block trace LRU. submittedTransactionsCache *rpccore.TransactionCache filterLimit uint @@ -84,11 +82,8 @@ func New( preConfirmedFeed: feed.New[*pending.PreConfirmed](), l1Heads: feed.New[*core.L1Head](), - blockTraceCache: lru.New[ - felt.Felt, - TraceBlockTransactionsResponse, - ](rpccore.TraceCacheSize), - filterLimit: math.MaxUint, + blockTraceCache: newBlockTraceCache(rpccore.TraceCacheSize), + filterLimit: math.MaxUint, } } diff --git a/rpc/v10/simulation.go b/rpc/v10/simulation.go index 828663e827..c49cc1783b 100644 --- a/rpc/v10/simulation.go +++ b/rpc/v10/simulation.go @@ -160,6 +160,15 @@ type InitialReads struct { DeclaredContracts []DeclaredContractEntry `json:"declared_contracts"` } +func emptyInitialReads() *InitialReads { + return &InitialReads{ + Storage: []StorageEntry{}, + Nonces: []NonceEntry{}, + ClassHashes: []ClassHashEntry{}, + DeclaredContracts: []DeclaredContractEntry{}, + } +} + type BroadcastedTransactionInputs = rpccore.LimitSlice[ BroadcastedTransaction, rpccore.SimulationLimit, diff --git a/rpc/v10/trace.go b/rpc/v10/trace.go index b9e81b440c..edaac6b859 100644 --- a/rpc/v10/trace.go +++ b/rpc/v10/trace.go @@ -8,7 +8,6 @@ import ( "slices" "strconv" - "github.com/NethermindEth/juno/blockchain" "github.com/NethermindEth/juno/blockchain/networks" "github.com/NethermindEth/juno/core" "github.com/NethermindEth/juno/core/felt" @@ -143,36 +142,27 @@ func (h *Handler) TraceBlockTransactions( } returnInitialReads := slices.Contains(traceFlags, TraceReturnInitialReadsFlag) - return h.traceFinalisedBlock(ctx, header, returnInitialReads) + response, responseHeader, rpcErr := h.traceFinalisedBlock(ctx, header, nil, returnInitialReads) + if !returnInitialReads { + response.InitialReads = nil + } + return response, responseHeader, rpcErr } /**************************************************** Core Tracing Logic *****************************************************/ -// traceTransactionsWithState traces a set of transactions using the provided VM and state readers. -// -// Parameters: -// -// - vm: The virtual machine used for execution -// -// - transactions: The transactions to trace -// -// - executionState: The state used for transaction execution -// -// - classLookupState: The state used for class definition lookups. -// This should be at least the state that includes the target block or transaction. -// -// - blockInfo: Block context for execution -// -// - returnInitialReads: Whether to return initial reads in the response +// traceTransactionsWithState traces transactions against executionState. classLookupState must +// include any classes declared by the traced block or transaction. func traceTransactionsWithState( runner vm.VM, transactions []core.Transaction, executionState core.StateReader, classLookupState core.StateReader, blockInfo *vm.BlockInfo, - returnInitialReads bool, + opts vm.TraceOptions, + errorIndexOffset uint64, ) ([]TracedBlockTransaction, *vm.InitialReads, http.Header, *jsonrpc.Error) { httpHeader := defaultExecutionHeader() @@ -190,8 +180,9 @@ func traceTransactionsWithState( paidFeesOnL1, blockInfo, executionState, - vm.TraceOptions{ReturnInitialReads: returnInitialReads}, + opts, ) + vmErr = offsetTransactionExecutionErrorIndex(vmErr, errorIndexOffset) httpHeader.Set(ExecutionStepsHeader, strconv.FormatUint(executionResult.NumSteps, 10)) @@ -203,6 +194,18 @@ func traceTransactionsWithState( } // Adapt traces + if len(executionResult.Traces) != len(transactions) { + return nil, nil, httpHeader, rpccore.ErrUnexpectedError.CloneWithData( + "VM returned an unexpected number of transaction traces", + ) + } + + if len(executionResult.GasConsumed) != len(executionResult.Traces) { + return nil, nil, httpHeader, rpccore.ErrUnexpectedError.CloneWithData( + "VM returned an unexpected number of gas results", + ) + } + traces := make([]TracedBlockTransaction, len(executionResult.Traces)) for index := range executionResult.Traces { // Adapt vm transaction trace to rpc v10 trace and add root level execution resources @@ -226,6 +229,19 @@ func traceTransactionsWithState( return traces, executionResult.InitialReads, httpHeader, nil } +// offsetTransactionExecutionErrorIndex translates a suffix-local VM index to its block index. +func offsetTransactionExecutionErrorIndex(err error, offset uint64) error { + if err == nil || offset == 0 { + return err + } + var transactionErr vm.TransactionExecutionError + if !errors.As(err, &transactionErr) { + return err + } + transactionErr.Index += offset + return transactionErr +} + // fetchDeclaredClassesAndL1Fees collects class declarations and L1Handler placeholder fees. func fetchDeclaredClassesAndL1Fees( transactions []core.Transaction, state core.StateReader, @@ -275,20 +291,32 @@ func (h *Handler) findAndTraceFinalisedTransaction( return TransactionTrace{}, nil, rpccore.ErrInternal.CloneWithData(err) } - blockTracesResp, httpHeader, rpcErr := h.traceFinalisedBlock(ctx, header, false) - if rpcErr != nil { - return TransactionTrace{}, nil, rpcErr + if cached, found := h.blockTraceCache.traceAt(*header.Hash, txIndex); found { + return transactionTraceResponse(cached, hash, defaultExecutionHeader()) } - // txIndex comes from the tx-hash index while the traces come from a later read of the block, so - // confirm the trace at that index really is the transaction that was asked for. - blockTraces := blockTracesResp.Traces - if txIndex >= uint64(len(blockTraces)) || - !blockTraces[txIndex].TransactionHash.Equal((*felt.Felt)(hash)) { + response, responseHeader, rpcErr := h.traceFinalisedBlock( + ctx, header, &traceTarget{index: txIndex, hash: *hash}, false, + ) + if rpcErr != nil { + return TransactionTrace{}, responseHeader, rpcErr + } + if txIndex >= uint64(len(response.Traces)) { return TransactionTrace{}, nil, rpccore.ErrTxnHashNotFound } + return transactionTraceResponse(response.Traces[txIndex], hash, responseHeader) +} - return *blockTraces[txIndex].TraceRoot, httpHeader, nil +func transactionTraceResponse( + traced TracedBlockTransaction, + hash *felt.TransactionHash, + responseHeader http.Header, +) (TransactionTrace, http.Header, *jsonrpc.Error) { + if traced.TransactionHash == nil || traced.TraceRoot == nil || + !traced.TransactionHash.Equal((*felt.Felt)(hash)) { + return TransactionTrace{}, nil, rpccore.ErrTxnHashNotFound + } + return *traced.TraceRoot, responseHeader, nil } // findAndTraceInPreConfirmed traces a transaction located in any block of the @@ -335,7 +363,8 @@ func (h *Handler) findAndTraceInPreConfirmed( state, // execution state state, // class lookup state (same for preconfirmed) &blockInfo, - false, // returnInitialReads + vm.TraceOptions{}, + 0, ) if rpcErr != nil { return TransactionTrace{}, httpHeader, rpcErr @@ -349,54 +378,41 @@ func (h *Handler) findAndTraceInPreConfirmed( Block Tracing Helpers *****************************************************/ -// traceFinalisedBlock gets the trace for a block. The block will always be traced locally except -// on specific case such as with Starknet version 0.13.2 or lower or when it is certain range +// traceTarget identifies the last transaction to trace and the hash expected at that index. +type traceTarget struct { + index uint64 + hash felt.TransactionHash +} + +// traceFinalisedBlock returns traces through target, or the whole block when target is nil. +// Transaction callers check their prefix cache before entering this shared path. func (h *Handler) traceFinalisedBlock( ctx context.Context, header *core.Header, + target *traceTarget, returnInitialReads bool, ) (TraceBlockTransactionsResponse, http.Header, *jsonrpc.Error) { - // Check if it was already traced. If the caller requested initial reads but - // the cached entry was produced without them, fall through to re-trace so we - // can populate them (cache gets overwritten below). cacheKey := *header.Hash - cachedResponse, hit := h.blockTraceCache.Get(cacheKey) - if hit && (!returnInitialReads || cachedResponse.InitialReads != nil) { - if returnInitialReads { - return cachedResponse, defaultExecutionHeader(), nil + if target == nil { + response, complete := h.blockTraceCache.completeResponse(cacheKey, returnInitialReads) + if complete { + return response, defaultExecutionHeader(), nil } - return TraceBlockTransactionsResponse{ - Traces: cachedResponse.Traces, - InitialReads: nil, - }, defaultExecutionHeader(), nil } fetchFromFeederGW, err := shouldFetchTracesFromFeederGateway(header, h.bcReader.Network()) if err != nil { - return TraceBlockTransactionsResponse{}, - defaultExecutionHeader(), + return TraceBlockTransactionsResponse{}, defaultExecutionHeader(), rpccore.ErrUnexpectedError.CloneWithData(err.Error()) } - if fetchFromFeederGW { traces, rpcErr := h.fetchTracesFromFeederGateway(ctx, header) if rpcErr != nil { return TraceBlockTransactionsResponse{}, defaultExecutionHeader(), rpcErr } - - // The gateway never supplies initial reads, so an empty set is the final answer for these - // blocks. Caching it that way lets a later call with the flag be served from the cache. - cached := TraceBlockTransactionsResponse{ - Traces: traces, - InitialReads: &InitialReads{}, - } - h.blockTraceCache.Add(cacheKey, cached) - - response := cached - if !returnInitialReads { - response.InitialReads = nil - } - + // The gateway never supplies initial reads. Preserve its historical null-slice wire shape. + response := TraceBlockTransactionsResponse{Traces: traces, InitialReads: &InitialReads{}} + h.blockTraceCache.storeComplete(cacheKey, response) return response, defaultExecutionHeader(), nil } @@ -405,81 +421,29 @@ func (h *Handler) traceFinalisedBlock( if errors.Is(err, db.ErrKeyNotFound) { return TraceBlockTransactionsResponse{}, defaultExecutionHeader(), rpccore.ErrBlockNotFound } - - return TraceBlockTransactionsResponse{}, - defaultExecutionHeader(), + return TraceBlockTransactionsResponse{}, defaultExecutionHeader(), rpccore.ErrInternal.CloneWithData(err) } - response, httpHeader, rpcErr := h.traceBlockWithVM(header, transactions, returnInitialReads) - if rpcErr != nil { - return TraceBlockTransactionsResponse{}, httpHeader, rpcErr - } - h.blockTraceCache.Add(cacheKey, response) - - return response, httpHeader, nil -} - -// traceBlockWithVM traces a block using the local VM. -func (h *Handler) traceBlockWithVM( - header *core.Header, - transactions []core.Transaction, - returnInitialReads bool, -) (TraceBlockTransactionsResponse, http.Header, *jsonrpc.Error) { - // Prepare execution state - state, closer, err := h.bcReader.StateAtBlockHash(header.ParentHash) - if err != nil { - if errors.Is(err, db.ErrKeyNotFound) { - return TraceBlockTransactionsResponse{}, defaultExecutionHeader(), rpccore.ErrBlockNotFound + if target != nil { + // The tx-hash index and transaction list come from separate reads; validate before execution. + if target.index >= uint64(len(transactions)) || + !transactions[target.index].Hash().Equal((*felt.Felt)(&target.hash)) { + return TraceBlockTransactionsResponse{}, defaultExecutionHeader(), rpccore.ErrTxnHashNotFound } - - return TraceBlockTransactionsResponse{}, - defaultExecutionHeader(), - rpccore.ErrInternal.CloneWithData(err) + return h.traceProgressiveBlock(ctx, header, transactions, target.index, returnInitialReads) } - defer h.callAndLogErr(closer, "Failed to close state in traceBlockTransactions") - - // Get state to read class definitions for declare transactions - var ( - headState core.StateReader - headStateCloser blockchain.StateCloser - ) - - headState, headStateCloser, err = h.bcReader.HeadState() - if err != nil { - return TraceBlockTransactionsResponse{}, - defaultExecutionHeader(), - jsonrpc.Err(jsonrpc.InternalError, err.Error()) - } - defer h.callAndLogErr(headStateCloser, "Failed to close head state in traceBlockTransactions") - - // Create block info - blockInfo, rpcErr := h.buildBlockInfo(header) - if rpcErr != nil { - return TraceBlockTransactionsResponse{}, defaultExecutionHeader(), rpcErr - } - - traces, vmInitialReads, httpHeader, rpcErr := traceTransactionsWithState( - h.vm, - transactions, - state, - headState, - &blockInfo, - returnInitialReads, - ) - if rpcErr != nil { - return TraceBlockTransactionsResponse{}, httpHeader, rpcErr - } - - var adaptedInitialReads *InitialReads - if vmInitialReads != nil && returnInitialReads { - adaptedInitialReads = new(adaptVMInitialReads(vmInitialReads)) + if len(transactions) > 0 { + return h.traceProgressiveBlock( + ctx, header, transactions, uint64(len(transactions)-1), returnInitialReads, + ) } + // Empty local blocks produce no traces or initial reads and are not cached. return TraceBlockTransactionsResponse{ - Traces: traces, - InitialReads: adaptedInitialReads, - }, httpHeader, nil + Traces: []TracedBlockTransaction{}, + InitialReads: emptyInitialReads(), + }, defaultExecutionHeader(), nil } // fetchTracesFromFeederGateway fetches block traces from the feeder gateway diff --git a/rpc/v10/trace_cache.go b/rpc/v10/trace_cache.go new file mode 100644 index 0000000000..44c8f5b874 --- /dev/null +++ b/rpc/v10/trace_cache.go @@ -0,0 +1,164 @@ +package rpcv10 + +import ( + "sync" + + "github.com/NethermindEth/juno/core/felt" + "github.com/NethermindEth/juno/utils/lru" +) + +// blockTraceCache publishes immutable trace prefixes and permits one active execution per block. +// mu protects cache and flight membership; execution and trace appends happen outside the lock. +type blockTraceCache struct { + mu sync.Mutex + records *lru.SimpleCache[felt.Felt, *blockTraceRecord] + flights map[felt.Felt]chan struct{} +} + +// Published records are immutable. The flight owner may append beyond the prefix's length, +// but must publish a new record. Responses borrow read-only data with capacity-limited slices. +type blockTraceRecord struct { + traces []TracedBlockTransaction + initialReads *InitialReads // only complete records may contain initial reads + complete bool +} + +type traceCacheLookupKind uint8 + +const ( + traceCacheHit traceCacheLookupKind = iota + 1 + traceCacheWait + traceCacheExecute +) + +type traceCacheLookup struct { + kind traceCacheLookupKind + response TraceBlockTransactionsResponse + done <-chan struct{} + work *traceCacheWork +} + +// Work retains its prefix across eviction and publishes only after successful execution. +type traceCacheWork struct { + cache *blockTraceCache + hash felt.Felt + flight chan struct{} + record *blockTraceRecord +} + +func newBlockTraceCache(limit int) *blockTraceCache { + return &blockTraceCache{ + records: lru.NewSimple[felt.Felt, *blockTraceRecord](limit), + flights: make(map[felt.Felt]chan struct{}), + } +} + +func (c *blockTraceCache) completeResponse( + blockHash felt.Felt, + requireInitialReads bool, +) (TraceBlockTransactionsResponse, bool) { + record, found := c.record(blockHash) + if !found || !record.complete || requireInitialReads && record.initialReads == nil { + return TraceBlockTransactionsResponse{}, false + } + return record.response(), true +} + +func (c *blockTraceCache) traceAt( + blockHash felt.Felt, + index uint64, +) (TracedBlockTransaction, bool) { + record, found := c.record(blockHash) + if !found || index >= uint64(len(record.traces)) { + return TracedBlockTransaction{}, false + } + return record.traces[index], true +} + +func (c *blockTraceCache) lookupOrStart( + blockHash felt.Felt, + target uint64, + requireInitialReads bool, +) traceCacheLookup { + c.mu.Lock() + defer c.mu.Unlock() + record, _ := c.records.Get(blockHash) + if record != nil && target < uint64(len(record.traces)) && + (!requireInitialReads || record.initialReads != nil) { + return traceCacheLookup{kind: traceCacheHit, response: record.response()} + } + if flight, found := c.flights[blockHash]; found { + return traceCacheLookup{kind: traceCacheWait, done: flight} + } + // Missing initial reads require a full replay. Keep the old record available until commit. + if record == nil || requireInitialReads { + record = &blockTraceRecord{} + } + flight := make(chan struct{}) + c.flights[blockHash] = flight + return traceCacheLookup{ + kind: traceCacheExecute, + work: &traceCacheWork{cache: c, hash: blockHash, flight: flight, record: record}, + } +} + +// storeComplete takes ownership of the response containers. InitialReads must be non-nil. +func (c *blockTraceCache) storeComplete( + blockHash felt.Felt, + response TraceBlockTransactionsResponse, +) { + record := &blockTraceRecord{ + traces: response.Traces, + initialReads: response.InitialReads, + complete: true, + } + c.mu.Lock() + defer c.mu.Unlock() + c.records.Add(blockHash, record) +} + +func (c *blockTraceCache) record(blockHash felt.Felt) (*blockTraceRecord, bool) { + c.mu.Lock() + defer c.mu.Unlock() + return c.records.Get(blockHash) +} + +func (c *blockTraceCache) finishLocked(blockHash felt.Felt, flight chan struct{}) { + delete(c.flights, blockHash) + close(flight) +} + +func (r *blockTraceRecord) response() TraceBlockTransactionsResponse { + return TraceBlockTransactionsResponse{ + Traces: r.traces[:len(r.traces):len(r.traces)], + InitialReads: r.initialReads, + } +} + +func (w *traceCacheWork) commit( + executed TraceBlockTransactionsResponse, + totalTransactions int, +) TraceBlockTransactionsResponse { + record := &blockTraceRecord{ + traces: append(w.record.traces, executed.Traces...), + initialReads: executed.InitialReads, + } + record.complete = len(record.traces) == totalTransactions + + cache := w.cache + cache.mu.Lock() + defer cache.mu.Unlock() + cache.records.Add(w.hash, record) + cache.finishLocked(w.hash, w.flight) + return record.response() +} + +func (w *traceCacheWork) abort() { + cache := w.cache + cache.mu.Lock() + defer cache.mu.Unlock() + if cache.flights[w.hash] != w.flight { + return + } + cache.finishLocked(w.hash, w.flight) +} diff --git a/rpc/v10/trace_cache_test.go b/rpc/v10/trace_cache_test.go new file mode 100644 index 0000000000..7aa0dd051b --- /dev/null +++ b/rpc/v10/trace_cache_test.go @@ -0,0 +1,103 @@ +package rpcv10 + +import ( + "testing" + + "github.com/NethermindEth/juno/core/felt" + "github.com/stretchr/testify/require" +) + +func blockTraceCacheState( + cache *blockTraceCache, + blockHash felt.Felt, +) (*blockTraceRecord, bool, bool) { + cache.mu.Lock() + defer cache.mu.Unlock() + record, found := cache.records.Get(blockHash) + _, inflight := cache.flights[blockHash] + return record, found, inflight +} + +func TestBlockTraceRecordAppendPreservesPublishedPrefix(t *testing.T) { + cache := newBlockTraceCache(1) + blockHash := felt.FromUint64[felt.Felt](1) + secondHash := felt.FromUint64[felt.Felt](2) + // Spare capacity exercises appending to a shared backing array. + traces := make([]TracedBlockTransaction, 1, 4) + traces[0].TransactionHash = &blockHash + original := &blockTraceRecord{traces: traces} + cache.records.Add(blockHash, original) + partial := original.response() + require.Equal(t, len(partial.Traces), cap(partial.Traces)) + + work := cache.lookupOrStart(blockHash, 1, false).work + response := work.commit(TraceBlockTransactionsResponse{ + Traces: []TracedBlockTransaction{{TransactionHash: &secondHash}}, + }, 2) + require.Len(t, response.Traces, 2) + require.Len(t, partial.Traces, 1) + require.Len(t, original.traces, 1) + require.Equal(t, blockHash, *partial.Traces[0].TransactionHash) + require.Equal(t, secondHash, *response.Traces[1].TransactionHash) + require.False(t, original.complete) + published, _, _ := blockTraceCacheState(cache, blockHash) + require.True(t, published.complete) +} + +func TestBlockTraceCacheLookupTransitions(t *testing.T) { + cache := newBlockTraceCache(1) + blockHash := felt.FromUint64[felt.Felt](1) + firstHash := felt.FromUint64[felt.Felt](2) + secondHash := felt.FromUint64[felt.Felt](3) + + first := cache.lookupOrStart(blockHash, 0, false) + require.Equal(t, traceCacheExecute, first.kind) + + for _, target := range []uint64{0, 1} { + waiting := cache.lookupOrStart(blockHash, target, false) + require.Equal(t, traceCacheWait, waiting.kind) + require.True(t, first.work.flight == waiting.done) + } + + prefix := first.work.commit(TraceBlockTransactionsResponse{ + Traces: []TracedBlockTransaction{{TransactionHash: &firstHash}}, + }, 2) + require.Len(t, prefix.Traces, 1) + require.Nil(t, prefix.InitialReads) + select { + case <-first.work.flight: + default: + t.Fatal("commit must wake flight waiters") + } + + hit := cache.lookupOrStart(blockHash, 0, false) + require.Equal(t, traceCacheHit, hit.kind) + require.Len(t, hit.response.Traces, 1) + + second := cache.lookupOrStart(blockHash, 1, false) + require.Equal(t, traceCacheExecute, second.kind) + require.Len(t, second.work.record.traces, 1) + complete := second.work.commit(TraceBlockTransactionsResponse{ + Traces: []TracedBlockTransaction{{TransactionHash: &secondHash}}, + }, 2) + require.Len(t, complete.Traces, 2) + require.Nil(t, complete.InitialReads) + + withReads := cache.lookupOrStart(blockHash, 1, true) + require.Equal(t, traceCacheExecute, withReads.kind) + require.Empty(t, withReads.work.record.traces) + require.Equal(t, traceCacheHit, cache.lookupOrStart(blockHash, 1, false).kind) + withReads.work.abort() + require.Equal(t, traceCacheHit, cache.lookupOrStart(blockHash, 1, false).kind) + + withReads = cache.lookupOrStart(blockHash, 1, true) + complete = withReads.work.commit(TraceBlockTransactionsResponse{ + Traces: []TracedBlockTransaction{ + {TransactionHash: &firstHash}, + {TransactionHash: &secondHash}, + }, + InitialReads: emptyInitialReads(), + }, 2) + require.NotNil(t, complete.InitialReads) + require.Equal(t, traceCacheHit, cache.lookupOrStart(blockHash, 1, true).kind) +} diff --git a/rpc/v10/trace_progressive.go b/rpc/v10/trace_progressive.go new file mode 100644 index 0000000000..2f67a1f58a --- /dev/null +++ b/rpc/v10/trace_progressive.go @@ -0,0 +1,208 @@ +package rpcv10 + +import ( + "context" + "errors" + "fmt" + "net/http" + + "github.com/NethermindEth/juno/core" + "github.com/NethermindEth/juno/core/felt" + "github.com/NethermindEth/juno/core/pending" + "github.com/NethermindEth/juno/db" + "github.com/NethermindEth/juno/jsonrpc" + "github.com/NethermindEth/juno/rpc/rpccore" + "github.com/NethermindEth/juno/vm" +) + +func (h *Handler) traceProgressiveBlock( + ctx context.Context, + header *core.Header, + transactions []core.Transaction, + target uint64, + returnInitialReads bool, +) (TraceBlockTransactionsResponse, http.Header, *jsonrpc.Error) { + if target >= uint64(len(transactions)) { + return TraceBlockTransactionsResponse{}, defaultExecutionHeader(), + rpccore.ErrUnexpectedError.CloneWithData(fmt.Sprintf( + "trace target index %d out of range for %d transactions", target, len(transactions), + )) + } + if returnInitialReads && target != uint64(len(transactions)-1) { + return TraceBlockTransactionsResponse{}, defaultExecutionHeader(), + rpccore.ErrUnexpectedError.CloneWithData("initial reads require a complete block trace") + } + + blockHash := *header.Hash + for { + lookup := h.blockTraceCache.lookupOrStart(blockHash, target, returnInitialReads) + switch lookup.kind { + case traceCacheHit: + return lookup.response, defaultExecutionHeader(), nil + case traceCacheWait: + select { + case <-ctx.Done(): + return TraceBlockTransactionsResponse{}, defaultExecutionHeader(), + rpccore.ErrUnexpectedError.CloneWithData(ctx.Err().Error()) + case <-lookup.done: + continue + } + case traceCacheExecute: + work := lookup.work + //nolint:gocritic // safe to defer in loop: every execution path below returns or panics. + defer work.abort() + response, responseHeader, rpcErr := h.executeTraceRange( + header, transactions, work.record.traces, target, returnInitialReads, + ) + if rpcErr != nil { + return TraceBlockTransactionsResponse{}, responseHeader, rpcErr + } + return work.commit(response, len(transactions)), responseHeader, nil + default: + panic("unknown trace cache lookup result") + } + } +} + +// executeTraceRange traces from the end of cachedPrefix through target, inclusive. +// An empty prefix starts at the parent state, including when replaying to collect initial reads. +func (h *Handler) executeTraceRange( + header *core.Header, + transactions []core.Transaction, + cachedPrefix []TracedBlockTransaction, + target uint64, + returnInitialReads bool, +) (TraceBlockTransactionsResponse, http.Header, *jsonrpc.Error) { + start := uint64(len(cachedPrefix)) + parentState, parentCloser, err := h.bcReader.StateAtBlockHash(header.ParentHash) + if err != nil { + if errors.Is(err, db.ErrKeyNotFound) { + return TraceBlockTransactionsResponse{}, defaultExecutionHeader(), rpccore.ErrBlockNotFound + } + return TraceBlockTransactionsResponse{}, defaultExecutionHeader(), + rpccore.ErrInternal.CloneWithData(err) + } + defer h.callAndLogErr(parentCloser, "Failed to close parent state after trace execution") + + headState, headCloser, err := h.bcReader.HeadState() + if err != nil { + return TraceBlockTransactionsResponse{}, defaultExecutionHeader(), + jsonrpc.Err(jsonrpc.InternalError, err.Error()) + } + defer h.callAndLogErr(headCloser, "Failed to close head state after trace execution") + + executionState := parentState + if start > 0 { + checkpoint := checkpointFromTraces(cachedPrefix) + declaredClasses, rpcErr := loadCheckpointClasses(&checkpoint, headState) + if rpcErr != nil { + return TraceBlockTransactionsResponse{}, defaultExecutionHeader(), rpcErr + } + executionState = pending.NewState(&checkpoint, declaredClasses, parentState, header.Number) + } + + blockInfo, rpcErr := h.buildBlockInfo(header) + if rpcErr != nil { + return TraceBlockTransactionsResponse{}, defaultExecutionHeader(), rpcErr + } + traces, vmInitialReads, responseHeader, rpcErr := traceTransactionsWithState( + h.vm, + transactions[start:target+1], + executionState, + headState, + &blockInfo, + vm.TraceOptions{ReturnInitialReads: returnInitialReads}, + start, + ) + if rpcErr != nil { + return TraceBlockTransactionsResponse{}, responseHeader, rpcErr + } + // The production Rust VM serialises state_diff as a required field for every + // successful transaction trace. Enforce that contract before a trace becomes + // an extendable cache record. + for index := range traces { + if traces[index].TraceRoot.StateDiff == nil { + return TraceBlockTransactionsResponse{}, responseHeader, + rpccore.ErrUnexpectedError.CloneWithData(fmt.Sprintf( + "VM omitted state diff for transaction trace %d", start+uint64(index), + )) + } + } + response := TraceBlockTransactionsResponse{Traces: traces} + if returnInitialReads { + if vmInitialReads == nil { + return TraceBlockTransactionsResponse{}, responseHeader, + rpccore.ErrUnexpectedError.CloneWithData("VM omitted initial reads for block trace") + } + adaptedReads := adaptVMInitialReads(vmInitialReads) + response.InitialReads = &adaptedReads + } + return response, responseHeader, nil +} + +func loadCheckpointClasses( + diff *core.StateDiff, + classLookup core.StateReader, +) (map[felt.Felt]core.ClassDefinition, *jsonrpc.Error) { + classes := make( + map[felt.Felt]core.ClassDefinition, + len(diff.DeclaredV0Classes)+len(diff.DeclaredV1Classes), + ) + for _, hash := range diff.DeclaredV0Classes { + classes[*hash] = nil + } + for hash := range diff.DeclaredV1Classes { + classes[hash] = nil + } + for hash := range classes { + declared, err := classLookup.Class(&hash) + if err != nil { + return nil, jsonrpc.Err(jsonrpc.InternalError, err.Error()) + } + classes[hash] = declared.Class + } + return classes, nil +} + +// checkpointFromTraces rebuilds the continuation checkpoint from cached per-transaction state +// diffs. Progressive records only contain traces with non-nil state diffs; executeTraceRange +// enforces this invariant before publishing an extension. The small recomputation cost avoids +// retaining a duplicate cumulative state diff in the cache. +func checkpointFromTraces(traces []TracedBlockTransaction) core.StateDiff { + result := core.EmptyStateDiff() + for index := range traces { + mergeRPCStateDiff(&result, traces[index].TraceRoot.StateDiff) + } + return result +} + +func mergeRPCStateDiff(result *core.StateDiff, diff *StateDiff) { + for _, storage := range diff.StorageDiffs { + entries, found := result.StorageDiffs[storage.Address] + if !found { + entries = make(map[felt.Felt]*felt.Felt, len(storage.StorageEntries)) + result.StorageDiffs[storage.Address] = entries + } + for _, entry := range storage.StorageEntries { + entries[entry.Key] = entry.Value.Clone() + } + } + for _, nonce := range diff.Nonces { + result.Nonces[nonce.ContractAddress] = nonce.Nonce.Clone() + } + for _, deployed := range diff.DeployedContracts { + result.DeployedContracts[deployed.Address] = deployed.ClassHash.Clone() + } + for _, hash := range diff.DeprecatedDeclaredClasses { + result.DeclaredV0Classes = append(result.DeclaredV0Classes, hash.Clone()) + } + for _, declared := range diff.DeclaredClasses { + result.DeclaredV1Classes[declared.ClassHash] = declared.CompiledClassHash.Clone() + } + for _, replaced := range diff.ReplacedClasses { + result.ReplacedClasses[replaced.ContractAddress] = replaced.ClassHash.Clone() + } + for _, migrated := range diff.MigratedCompiledClasses { + result.MigratedClasses[migrated.ClassHash] = migrated.CompiledClassHash + } +} diff --git a/rpc/v10/trace_progressive_test.go b/rpc/v10/trace_progressive_test.go new file mode 100644 index 0000000000..a3727e33ce --- /dev/null +++ b/rpc/v10/trace_progressive_test.go @@ -0,0 +1,441 @@ +package rpcv10 + +import ( + "context" + "encoding/json" + "fmt" + "sync/atomic" + "testing" + + "github.com/NethermindEth/juno/blockchain/networks" + "github.com/NethermindEth/juno/core" + "github.com/NethermindEth/juno/core/felt" + "github.com/NethermindEth/juno/jsonrpc" + "github.com/NethermindEth/juno/mocks" + "github.com/NethermindEth/juno/rpc/rpccore" + "github.com/NethermindEth/juno/utils/log" + "github.com/NethermindEth/juno/vm" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +type progressiveTestVM struct { + vm.VM + trace func([]core.Transaction, core.StateReader) (vm.ExecutionResults, error) +} + +func (v *progressiveTestVM) Trace( + transactions []core.Transaction, + _ []core.ClassDefinition, + _ []*felt.Felt, + _ *vm.BlockInfo, + state core.StateReader, + _ vm.TraceOptions, +) (vm.ExecutionResults, error) { + return v.trace(transactions, state) +} + +func progressiveTestTransactions(count int) []core.Transaction { + transactions := make([]core.Transaction, count) + for index := range transactions { + transactions[index] = &core.InvokeTransaction{ + TransactionHash: felt.NewFromUint64[felt.Felt](uint64(index + 1)), + } + } + return transactions +} + +func progressiveTestResults(transactions []core.Transaction) vm.ExecutionResults { + traces := make([]vm.TransactionTrace, len(transactions)) + gas := make([]core.GasConsumed, len(transactions)) + for index := range transactions { + traces[index].StateDiff = &vm.StateDiff{} + gas[index].L1Gas = transactions[index].Hash().Uint64() + } + return vm.ExecutionResults{ + Traces: traces, + GasConsumed: gas, + NumSteps: uint64(len(transactions)), + } +} + +func newProgressiveTestHandler( + t *testing.T, + virtualMachine vm.VM, +) (*Handler, *core.Header, []core.Transaction, *mocks.MockStateReader) { + t.Helper() + ctrl := gomock.NewController(t) + reader := mocks.NewMockReader(ctrl) + parentState := mocks.NewMockStateReader(ctrl) + headState := mocks.NewMockStateReader(ctrl) + header := &core.Header{ + Hash: felt.NewFromUint64[felt.Felt](100), + ParentHash: felt.NewFromUint64[felt.Felt](99), + SequencerAddress: felt.NewFromUint64[felt.Felt](98), + L1GasPriceETH: felt.NewFromUint64[felt.Felt](1), + Number: 1, + ProtocolVersion: "99.12.3", + } + reader.EXPECT().StateAtBlockHash(header.ParentHash). + Return(parentState, func() error { return nil }, nil).AnyTimes() + reader.EXPECT().HeadState().Return(headState, func() error { return nil }, nil).AnyTimes() + handler := New(reader, nil, virtualMachine, log.NewNopZapLogger()) + return handler, header, progressiveTestTransactions(3), headState +} + +func TestMergeRPCStateDiff(t *testing.T) { + address := felt.FromUint64[felt.Felt](1) + key := felt.FromUint64[felt.Felt](2) + value := felt.FromUint64[felt.Felt](3) + nonce := felt.FromUint64[felt.Felt](4) + classHash := felt.FromUint64[felt.Felt](5) + compiledHash := felt.FromUint64[felt.Felt](6) + replacement := felt.FromUint64[felt.Felt](7) + migratedClass := felt.FromUint64[felt.SierraClassHash](8) + migratedCompiled := felt.FromUint64[felt.CasmClassHash](9) + + diff := StateDiff{ + StorageDiffs: []StorageDiff{{ + Address: address, StorageEntries: []Entry{{Key: key, Value: value}}, + }}, + Nonces: []Nonce{{ContractAddress: address, Nonce: nonce}}, + DeployedContracts: []DeployedContract{{Address: address, ClassHash: classHash}}, + DeprecatedDeclaredClasses: []*felt.Felt{&classHash}, + DeclaredClasses: []DeclaredClass{{ + ClassHash: classHash, CompiledClassHash: compiledHash, + }}, + ReplacedClasses: []ReplacedClass{{ContractAddress: address, ClassHash: replacement}}, + MigratedCompiledClasses: []MigratedCompiledClass{{ + ClassHash: migratedClass, CompiledClassHash: migratedCompiled, + }}, + } + converted := core.EmptyStateDiff() + mergeRPCStateDiff(&converted, &diff) + + require.Equal(t, value, *converted.StorageDiffs[address][key]) + require.Equal(t, nonce, *converted.Nonces[address]) + require.Equal(t, classHash, *converted.DeployedContracts[address]) + require.Equal(t, classHash, *converted.DeclaredV0Classes[0]) + require.Equal(t, compiledHash, *converted.DeclaredV1Classes[classHash]) + require.Equal(t, replacement, *converted.ReplacedClasses[address]) + require.Equal(t, migratedCompiled, converted.MigratedClasses[migratedClass]) + + diff.StorageDiffs[0].StorageEntries[0].Value.SetUint64(99) + require.Equal(t, uint64(3), converted.StorageDiffs[address][key].Uint64()) +} + +func TestProgressiveTraceCacheWaiterCancellationDoesNotCancelExtension(t *testing.T) { + entered := make(chan struct{}) + release := make(chan struct{}) + var calls atomic.Uint64 + virtualMachine := &progressiveTestVM{trace: func( + transactions []core.Transaction, + _ core.StateReader, + ) (vm.ExecutionResults, error) { + calls.Add(1) + close(entered) + <-release + return progressiveTestResults(transactions), nil + }} + handler, header, transactions, _ := newProgressiveTestHandler(t, virtualMachine) + + ownerDone := make(chan *jsonrpc.Error, 1) + go func() { + _, _, rpcErr := handler.traceProgressiveBlock(t.Context(), header, transactions, 1, false) + ownerDone <- rpcErr + }() + <-entered + cancelled, cancel := context.WithCancel(t.Context()) + cancel() + _, _, rpcErr := handler.traceProgressiveBlock(cancelled, header, transactions, 1, false) + require.NotNil(t, rpcErr) + require.Contains(t, rpcErr.Data, context.Canceled.Error()) + + close(release) + require.Nil(t, <-ownerDone) + response, _, rpcErr := handler.traceProgressiveBlock(t.Context(), header, transactions, 1, false) + require.Nil(t, rpcErr) + require.Len(t, response.Traces, 2) + require.Equal(t, uint64(1), calls.Load()) +} + +func TestProgressiveTraceCacheFailureRetainsPublishedPrefix(t *testing.T) { + var calls atomic.Uint64 + virtualMachine := &progressiveTestVM{trace: func( + transactions []core.Transaction, + _ core.StateReader, + ) (vm.ExecutionResults, error) { + switch calls.Add(1) { + case 2: + transactionErr := vm.TransactionExecutionError{ + Index: 0, + Cause: json.RawMessage(`"extension failed"`), + } + return vm.ExecutionResults{NumSteps: 9}, fmt.Errorf("VM decorator: %w", transactionErr) + default: + return progressiveTestResults(transactions), nil + } + }} + handler, header, transactions, _ := newProgressiveTestHandler(t, virtualMachine) + + response, _, rpcErr := handler.traceProgressiveBlock(t.Context(), header, transactions, 0, false) + require.Nil(t, rpcErr) + require.Len(t, response.Traces, 1) + _, responseHeader, rpcErr := handler.traceProgressiveBlock( + t.Context(), header, transactions, 1, false, + ) + require.NotNil(t, rpcErr) + require.Equal(t, "9", responseHeader.Get(ExecutionStepsHeader)) + require.Contains(t, rpcErr.Data, "transaction #1") + + response, responseHeader, rpcErr = handler.traceProgressiveBlock( + t.Context(), header, transactions, 0, false, + ) + require.Nil(t, rpcErr) + require.Equal(t, "0", responseHeader.Get(ExecutionStepsHeader)) + require.Len(t, response.Traces, 1) + require.Equal(t, uint64(2), calls.Load()) + + response, _, rpcErr = handler.traceProgressiveBlock(t.Context(), header, transactions, 1, false) + require.Nil(t, rpcErr) + require.Len(t, response.Traces, 2) + require.Equal(t, uint64(3), calls.Load()) +} + +func TestProgressiveTraceCacheDoesNotCacheFailedFirstExtension(t *testing.T) { + virtualMachine := &progressiveTestVM{trace: func( + []core.Transaction, + core.StateReader, + ) (vm.ExecutionResults, error) { + return vm.ExecutionResults{}, vm.TransactionExecutionError{ + Index: 0, + Cause: json.RawMessage(`"extension failed"`), + } + }} + handler, header, transactions, _ := newProgressiveTestHandler(t, virtualMachine) + + _, _, rpcErr := handler.traceProgressiveBlock(t.Context(), header, transactions, 0, false) + require.NotNil(t, rpcErr) + _, cached, inflight := blockTraceCacheState(handler.blockTraceCache, *header.Hash) + require.False(t, cached) + require.False(t, inflight) +} + +func TestProgressiveTraceCacheRejectsMalformedVMResults(t *testing.T) { + tests := []struct { + name string + traceCount int + gasCount int + want string + }{ + {"too few traces", 1, 2, "unexpected number of transaction traces"}, + {"too few gas results", 2, 1, "unexpected number of gas results"}, + {"missing state diff", 2, 2, "VM omitted state diff for transaction trace 0"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + virtualMachine := &progressiveTestVM{trace: func( + []core.Transaction, core.StateReader, + ) (vm.ExecutionResults, error) { + return vm.ExecutionResults{ + Traces: make([]vm.TransactionTrace, test.traceCount), + GasConsumed: make([]core.GasConsumed, test.gasCount), + }, nil + }} + handler, header, transactions, _ := newProgressiveTestHandler(t, virtualMachine) + _, _, rpcErr := handler.traceProgressiveBlock(t.Context(), header, transactions, 1, false) + require.NotNil(t, rpcErr) + require.Contains(t, rpcErr.Data, test.want) + _, cached, inflight := blockTraceCacheState(handler.blockTraceCache, *header.Hash) + require.False(t, cached) + require.False(t, inflight) + }) + } +} + +func TestProgressiveTraceCachePanicClearsInflight(t *testing.T) { + var calls atomic.Uint64 + virtualMachine := &progressiveTestVM{trace: func( + transactions []core.Transaction, + _ core.StateReader, + ) (vm.ExecutionResults, error) { + if calls.Add(1) == 1 { + panic("trace panic") + } + return progressiveTestResults(transactions), nil + }} + handler, header, transactions, _ := newProgressiveTestHandler(t, virtualMachine) + + require.PanicsWithValue(t, "trace panic", func() { + _, _, _ = handler.traceProgressiveBlock(t.Context(), header, transactions, 0, false) + }) + + response, _, rpcErr := handler.traceProgressiveBlock( + t.Context(), header, transactions, 0, false, + ) + require.Nil(t, rpcErr) + require.Len(t, response.Traces, 1) + require.Equal(t, uint64(2), calls.Load()) +} + +func TestTraceFinalisedBlockRejectsInvalidTargetBeforeExecution(t *testing.T) { + ctrl := gomock.NewController(t) + reader := mocks.NewMockReader(ctrl) + virtualMachine := mocks.NewMockVM(ctrl) + header := &core.Header{Hash: felt.NewFromUint64[felt.Felt](100), ProtocolVersion: "99.12.3"} + transactions := progressiveTestTransactions(1) + reader.EXPECT().Network().Return(&networks.Mainnet).Times(2) + reader.EXPECT().TransactionsByBlockNumber(header.Number).Return(transactions, nil).Times(2) + handler := New(reader, nil, virtualMachine, log.NewNopZapLogger()) + + for name, target := range map[string]traceTarget{ + "index out of range": {index: 1, hash: felt.TransactionHash(*transactions[0].Hash())}, + "hash mismatch": {index: 0, hash: felt.FromUint64[felt.TransactionHash](999)}, + } { + t.Run(name, func(t *testing.T) { + _, _, rpcErr := handler.traceFinalisedBlock(t.Context(), header, &target, false) + require.Equal(t, rpccore.ErrTxnHashNotFound, rpcErr) + }) + } +} + +func TestTraceFinalisedEmptyBlockReturnsWithoutCaching(t *testing.T) { + ctrl := gomock.NewController(t) + reader := mocks.NewMockReader(ctrl) + virtualMachine := mocks.NewMockVM(ctrl) + header := &core.Header{ + Hash: felt.NewFromUint64[felt.Felt](100), + ProtocolVersion: "99.12.3", + } + + reader.EXPECT().Network().Return(&networks.Mainnet).Times(2) + reader.EXPECT().TransactionsByBlockNumber(header.Number).Return(nil, nil).Times(2) + handler := New(reader, nil, virtualMachine, log.NewNopZapLogger()) + + response, responseHeader, rpcErr := handler.traceFinalisedBlock( + t.Context(), header, nil, true, + ) + require.Nil(t, rpcErr) + require.Empty(t, response.Traces) + require.NotNil(t, response.InitialReads) + require.Empty(t, response.InitialReads.Storage) + require.Empty(t, response.InitialReads.Nonces) + require.Empty(t, response.InitialReads.ClassHashes) + require.Empty(t, response.InitialReads.DeclaredContracts) + require.Equal(t, "0", responseHeader.Get(ExecutionStepsHeader)) + + response, responseHeader, rpcErr = handler.traceFinalisedBlock(t.Context(), header, nil, false) + require.Nil(t, rpcErr) + require.Empty(t, response.Traces) + require.NotNil(t, response.InitialReads, "internal responses remain canonical") + require.Equal(t, "0", responseHeader.Get(ExecutionStepsHeader)) +} + +func TestProgressiveTraceCacheAllowsRecordEvictionDuringFlight(t *testing.T) { + entered := make(chan struct{}) + release := make(chan struct{}) + var calls atomic.Uint64 + virtualMachine := &progressiveTestVM{trace: func( + transactions []core.Transaction, + _ core.StateReader, + ) (vm.ExecutionResults, error) { + if calls.Add(1) == 2 { + close(entered) + <-release + } + return progressiveTestResults(transactions), nil + }} + handler, header, transactions, _ := newProgressiveTestHandler(t, virtualMachine) + prefix, _, rpcErr := handler.traceProgressiveBlock(t.Context(), header, transactions, 0, false) + require.Nil(t, rpcErr) + require.Len(t, prefix.Traces, 1) + + ownerDone := make(chan *jsonrpc.Error, 1) + go func() { + _, _, rpcErr := handler.traceProgressiveBlock(t.Context(), header, transactions, 1, false) + ownerDone <- rpcErr + }() + <-entered + for index := range rpccore.TraceCacheSize { + handler.blockTraceCache.storeComplete( + felt.FromUint64[felt.Felt](uint64(1_000+index)), + TraceBlockTransactionsResponse{InitialReads: emptyInitialReads()}, + ) + } + _, found, inflight := blockTraceCacheState(handler.blockTraceCache, *header.Hash) + require.False(t, found, "the base record may be evicted while its owner retains it") + require.True(t, inflight, "active work must remain discoverable through its flight") + + waiting := handler.blockTraceCache.lookupOrStart(*header.Hash, 1, false) + require.Equal(t, traceCacheWait, waiting.kind) + close(release) + + require.Nil(t, <-ownerDone) + select { + case <-waiting.done: + default: + t.Fatal("commit must wake waiters after republishing the evicted record") + } + require.Equal(t, uint64(2), calls.Load()) + record, found, inflight := blockTraceCacheState(handler.blockTraceCache, *header.Hash) + require.True(t, found) + require.False(t, inflight) + require.Len(t, record.traces, 2, "success should republish the extended record") +} + +func TestProgressiveTraceCacheMakesPrefixDeclarationsAvailableToSuffix(t *testing.T) { + classHash := felt.FromUint64[felt.Felt](44) + classDefinition := &core.DeprecatedCairoClass{} + var calls atomic.Uint64 + virtualMachine := &progressiveTestVM{trace: func( + transactions []core.Transaction, + state core.StateReader, + ) (vm.ExecutionResults, error) { + results := progressiveTestResults(transactions) + switch calls.Add(1) { + case 1: + results.Traces[0].StateDiff.DeprecatedDeclaredClasses = []*felt.Felt{&classHash} + case 2: + declared, err := state.Class(&classHash) + require.NoError(t, err) + require.Same(t, classDefinition, declared.Class) + } + return results, nil + }} + handler, header, transactions, headState := newProgressiveTestHandler(t, virtualMachine) + headState.EXPECT().Class(&classHash).Return(&core.DeclaredClassDefinition{ + Class: classDefinition, + }, nil) + + _, _, rpcErr := handler.traceProgressiveBlock(t.Context(), header, transactions, 0, false) + require.Nil(t, rpcErr) + response, _, rpcErr := handler.traceProgressiveBlock(t.Context(), header, transactions, 1, false) + require.Nil(t, rpcErr) + require.Len(t, response.Traces, 2) + require.Equal(t, uint64(2), calls.Load()) +} + +func TestTransactionTraceResponseValidatesEntry(t *testing.T) { + hash := felt.FromUint64[felt.TransactionHash](1) + otherHash := felt.FromUint64[felt.Felt](2) + trace := &TransactionTrace{} + tests := map[string]TracedBlockTransaction{ + "nil hash": {TraceRoot: trace}, + "nil trace": {TransactionHash: (*felt.Felt)(&hash)}, + "hash mismatch": {TransactionHash: &otherHash, TraceRoot: trace}, + } + for name, cached := range tests { + t.Run(name, func(t *testing.T) { + _, header, rpcErr := transactionTraceResponse(cached, &hash, defaultExecutionHeader()) + require.Equal(t, rpccore.ErrTxnHashNotFound, rpcErr) + require.Nil(t, header) + }) + } + + result, header, rpcErr := transactionTraceResponse(TracedBlockTransaction{ + TransactionHash: (*felt.Felt)(&hash), TraceRoot: trace, + }, &hash, defaultExecutionHeader()) + require.Nil(t, rpcErr) + require.Equal(t, "0", header.Get(ExecutionStepsHeader)) + require.Equal(t, *trace, result) +} diff --git a/rpc/v10/trace_test.go b/rpc/v10/trace_test.go index 3ffed0b521..dbb4b2447f 100644 --- a/rpc/v10/trace_test.go +++ b/rpc/v10/trace_test.go @@ -190,6 +190,12 @@ func AssertTracedBlockTransactions( require.Nil(t, err) require.Equal(t, httpHeader.Get(rpcv10.ExecutionStepsHeader), "0") require.Equal(t, test.wantTrace, traces) + + withReads, _, err := handler.TraceBlockTransactions( + t.Context(), &blockID, []rpcv10.TraceFlag{rpcv10.TraceReturnInitialReadsFlag}, + ) + require.Nil(t, err) + require.Equal(t, &rpcv10.InitialReads{}, withReads.InitialReads) }) } } @@ -632,6 +638,105 @@ func TestTraceTransaction(t *testing.T) { }) } +func TestProgressiveTraceCacheExtendsPrefix(t *testing.T) { + mockCtrl := gomock.NewController(t) + mockReader := mocks.NewMockReader(mockCtrl) + mockVM := mocks.NewMockVM(mockCtrl) + baseState := mocks.NewMockStateReader(mockCtrl) + headState := mocks.NewMockStateReader(mockCtrl) + + header := &core.Header{ + Hash: felt.NewFromUint64[felt.Felt](100), + ParentHash: felt.NewFromUint64[felt.Felt](99), + SequencerAddress: felt.NewFromUint64[felt.Felt](98), + L1GasPriceETH: felt.NewFromUint64[felt.Felt](1), + ProtocolVersion: "99.12.3", + } + transactions := []core.Transaction{ + &core.InvokeTransaction{TransactionHash: felt.NewFromUint64[felt.Felt](10)}, + &core.InvokeTransaction{TransactionHash: felt.NewFromUint64[felt.Felt](11)}, + &core.InvokeTransaction{TransactionHash: felt.NewFromUint64[felt.Felt](12)}, + } + hashes := make([]*felt.TransactionHash, len(transactions)) + for index := range transactions { + hashes[index] = (*felt.TransactionHash)(transactions[index].Hash()) + } + + address := felt.FromUint64[felt.Felt](20) + key1 := felt.FromUint64[felt.Felt](21) + value1 := felt.FromUint64[felt.Felt](31) + nonce1 := felt.FromUint64[felt.Felt](1) + + prefixTraces := []vm.TransactionTrace{ + {StateDiff: &vm.StateDiff{StorageDiffs: []vm.StorageDiff{{ + Address: address, StorageEntries: []vm.Entry{{Key: key1, Value: value1}}, + }}}}, + {StateDiff: &vm.StateDiff{Nonces: []vm.Nonce{{ContractAddress: address, Nonce: nonce1}}}}, + } + mockReader.EXPECT().Network().Return(&networks.Mainnet).AnyTimes() + for _, target := range []uint64{1, 0, 2} { + mockReader.EXPECT().BlockNumberAndIndexByTxHash(hashes[target]).Return(header.Number, target, nil) + mockReader.EXPECT().BlockHeaderByNumber(header.Number).Return(header, nil) + } + mockReader.EXPECT().TransactionsByBlockNumber(header.Number).Return(transactions, nil).Times(2) + mockReader.EXPECT().BlockHeaderByHash(header.Hash).Return(header, nil) + mockReader.EXPECT().StateAtBlockHash(header.ParentHash).Return(baseState, nopCloser, nil).Times(2) + mockReader.EXPECT().HeadState().Return(headState, nopCloser, nil).Times(2) + + gomock.InOrder( + mockVM.EXPECT().Trace( + transactions[:2], []core.ClassDefinition(nil), []*felt.Felt{}, + &vm.BlockInfo{Header: header}, baseState, vm.TraceOptions{}, + ).Return(vm.ExecutionResults{ + Traces: prefixTraces, GasConsumed: []core.GasConsumed{{L1Gas: 1}, {L1Gas: 2}}, + NumSteps: 10, + }, nil), + mockVM.EXPECT().Trace( + transactions[2:], []core.ClassDefinition(nil), []*felt.Felt{}, + &vm.BlockInfo{Header: header}, + gomock.Cond(func(state core.StateReader) bool { + storage, err := state.ContractStorage(&address, &key1) + if err != nil || !storage.Equal(&value1) { + return false + } + nonce, err := state.ContractNonce(&address) + return err == nil && nonce.Equal(&nonce1) + }), + vm.TraceOptions{}, + ).Return(vm.ExecutionResults{ + Traces: []vm.TransactionTrace{{StateDiff: &vm.StateDiff{}}}, + GasConsumed: []core.GasConsumed{{L1Gas: 3}}, + NumSteps: 20, + }, nil), + ) + + handler := rpcv10.New(mockReader, nil, mockVM, log.NewNopZapLogger()) + + trace, responseHeader, rpcErr := handler.TraceTransaction(t.Context(), hashes[1]) + require.Nil(t, rpcErr) + require.Equal(t, "10", responseHeader.Get(rpcv10.ExecutionStepsHeader)) + require.Equal(t, uint64(2), trace.ExecutionResources.L1Gas) + + trace, responseHeader, rpcErr = handler.TraceTransaction(t.Context(), hashes[0]) + require.Nil(t, rpcErr) + require.Equal(t, "0", responseHeader.Get(rpcv10.ExecutionStepsHeader)) + require.Equal(t, uint64(1), trace.ExecutionResources.L1Gas) + + trace, responseHeader, rpcErr = handler.TraceTransaction(t.Context(), hashes[2]) + require.Nil(t, rpcErr) + require.Equal(t, "20", responseHeader.Get(rpcv10.ExecutionStepsHeader)) + require.Equal(t, uint64(3), trace.ExecutionResources.L1Gas) + + blockID := rpcv10.BlockIDFromHash(header.Hash) + blockResponse, responseHeader, rpcErr := handler.TraceBlockTransactions( + t.Context(), &blockID, nil, + ) + require.Nil(t, rpcErr) + require.Equal(t, "0", responseHeader.Get(rpcv10.ExecutionStepsHeader)) + require.Len(t, blockResponse.Traces, 3) + require.Nil(t, blockResponse.InitialReads) +} + func TestTraceBlockTransactions(t *testing.T) { errTests := map[string]rpcv10.BlockID{ "latest": rpcv10.BlockIDLatest(), @@ -731,7 +836,7 @@ func TestTraceBlockTransactions(t *testing.T) { Return(vm.ExecutionResults{ OverallFees: nil, DataAvailability: []core.DataAvailability{{}, {}}, - GasConsumed: []core.GasConsumed{{}, {}}, + GasConsumed: []core.GasConsumed{{}}, Traces: []vm.TransactionTrace{vmTrace}, NumSteps: stepsUsed, }, nil) @@ -1402,15 +1507,14 @@ func TestTraceBlockTransactionsWithReturnInitialReads(t *testing.T) { mockReader.EXPECT().L1Head().Return(core.L1Head{}, db.ErrKeyNotFound).AnyTimes() mockReader.EXPECT().BlockHeaderHashByNumber(uint64(90)).Return(revealedHeader.Hash, nil) - returnInitialReads := slices.Contains(test.simulationFlags, rpcv10.ReturnInitialReadsFlag) - + returnInitialReads := slices.Contains(test.traceFlags, rpcv10.TraceReturnInitialReadsFlag) mockVM.EXPECT().Trace(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), mockState, vm.TraceOptions{ReturnInitialReads: returnInitialReads}, ).Return(vm.ExecutionResults{ OverallFees: []*felt.Felt{&felt.Zero}, DataAvailability: []core.DataAvailability{{L1Gas: 0}}, GasConsumed: []core.GasConsumed{{L1Gas: 0, L1DataGas: 0, L2Gas: 0}}, - Traces: []vm.TransactionTrace{{}}, + Traces: []vm.TransactionTrace{{StateDiff: &vm.StateDiff{}}}, NumSteps: 100, InitialReads: test.initialReads, }, nil) @@ -1424,6 +1528,11 @@ func TestTraceBlockTransactionsWithReturnInitialReads(t *testing.T) { test.traceFlags, ) + if test.initialReads == nil { + require.NotNil(t, err) + require.Contains(t, err.Data, "VM omitted initial reads") + return + } require.Nil(t, err) require.Equal(t, test.expectedInitialReads, traces.InitialReads) @@ -1431,10 +1540,9 @@ func TestTraceBlockTransactionsWithReturnInitialReads(t *testing.T) { } } -// TestTraceBlockTransactionsInitialReadsCacheCoherence verifies that the -// block-trace cache does not poison RETURN_INITIAL_READS responses. The cache -// key is the block hash only, so a prior call without the flag used to cause -// subsequent calls with the flag to return empty initial reads. +// TestTraceBlockTransactionsInitialReadsCacheCoherence verifies that read-less cache entries are +// replayed when RETURN_INITIAL_READS is later requested and that read-populated entries serve both +// response shapes. func TestTraceBlockTransactionsInitialReadsCacheCoherence(t *testing.T) { t.Parallel() n := &networks.Mainnet @@ -1467,6 +1575,7 @@ func TestTraceBlockTransactionsInitialReadsCacheCoherence(t *testing.T) { parentHash := felt.FromUint64[felt.Felt](998) revealedHash := felt.FromUint64[felt.Felt](90) txHash := felt.FromUint64[felt.Felt](888) + secondTxHash := felt.FromUint64[felt.Felt](889) header := &core.Header{ SequencerAddress: n.BlockHashMetaInfo.FallBackSequencerAddress, L1GasPriceETH: &felt.Zero, @@ -1480,28 +1589,32 @@ func TestTraceBlockTransactionsInitialReadsCacheCoherence(t *testing.T) { ProtocolVersion: "0.13.2", } block := &core.Block{ - Header: header, - Transactions: []core.Transaction{&core.InvokeTransaction{TransactionHash: &txHash}}, + Header: header, + Transactions: []core.Transaction{ + &core.InvokeTransaction{TransactionHash: &txHash}, + &core.InvokeTransaction{TransactionHash: &secondTxHash}, + }, } return block, &blockHash, &txHash, &core.Header{Hash: &revealedHash} } - execResultWithReads := func(reads *vm.InitialReads) vm.ExecutionResults { + execResultWithReads := func(transactionCount int, reads *vm.InitialReads) vm.ExecutionResults { + traces := make([]vm.TransactionTrace, transactionCount) + for i := range transactionCount { + traces[i].StateDiff = &vm.StateDiff{} + } + return vm.ExecutionResults{ - OverallFees: []*felt.Felt{&felt.Zero}, - DataAvailability: []core.DataAvailability{{L1Gas: 0}}, - GasConsumed: []core.GasConsumed{{L1Gas: 0, L1DataGas: 0, L2Gas: 0}}, - Traces: []vm.TransactionTrace{{}}, - NumSteps: 100, - InitialReads: reads, + GasConsumed: make([]core.GasConsumed, transactionCount), + Traces: traces, + NumSteps: 100, + InitialReads: reads, } } - // After TraceTransaction has cached the block without initial reads, - // a follow-up TraceBlockTransactions with RETURN_INITIAL_READS must - // re-execute the VM and return populated reads — not the empty struct - // that was served from the poisoned cache. - t.Run("TraceTransaction does not poison RETURN_INITIAL_READS", func(t *testing.T) { + // TraceTransaction caches no reads. A follow-up block trace with RETURN_INITIAL_READS must + // replay the block and replace the read-less entry. + t.Run("flagged request replays read-less cache entry", func(t *testing.T) { t.Parallel() mockCtrl := gomock.NewController(t) t.Cleanup(mockCtrl.Finish) @@ -1523,18 +1636,18 @@ func TestTraceBlockTransactionsInitialReadsCacheCoherence(t *testing.T) { mockReader.EXPECT().BlockHeaderByNumber(block.Number).Return(block.Header, nil) mockReader.EXPECT().TransactionsByBlockNumber(block.Number). Return(block.Transactions, nil).Times(2) - mockReader.EXPECT().StateAtBlockHash(block.ParentHash).Return(mockState, nopCloser, nil).Times(2) + mockReader.EXPECT().StateAtBlockHash(block.ParentHash). + Return(mockState, nopCloser, nil).Times(2) mockReader.EXPECT().HeadState().Return(mockState, nopCloser, nil).Times(2) - // First VM call: no initial reads requested. gomock.InOrder( - mockVM.EXPECT().Trace(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), mockState, + mockVM.EXPECT().Trace( + block.Transactions[:1], gomock.Any(), gomock.Any(), gomock.Any(), mockState, vm.TraceOptions{}, - ).Return(execResultWithReads(nil), nil), - // Second VM call: flag set, VM produces populated reads. - mockVM.EXPECT().Trace(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), mockState, + ).Return(execResultWithReads(1, nil), nil), + mockVM.EXPECT().Trace(block.Transactions, gomock.Any(), gomock.Any(), gomock.Any(), mockState, vm.TraceOptions{ReturnInitialReads: true}, - ).Return(execResultWithReads(populatedVMReads()), nil), + ).Return(execResultWithReads(len(block.Transactions), populatedVMReads()), nil), ) handler := rpcv10.New(mockReader, nil, mockVM, log.NewNopZapLogger()) @@ -1551,9 +1664,8 @@ func TestTraceBlockTransactionsInitialReadsCacheCoherence(t *testing.T) { require.Equal(t, expectedPopulatedReads, result.InitialReads) }) - // With the flag first, the cached entry already has initial reads, so a - // repeat call must be served from cache (VM invoked exactly once). - t.Run("cache reused when initial reads already cached", func(t *testing.T) { + // One VM call serves both response shapes without stripping reads from the cache. + t.Run("cache reused across flag changes", func(t *testing.T) { t.Parallel() mockCtrl := gomock.NewController(t) t.Cleanup(mockCtrl.Finish) @@ -1569,7 +1681,7 @@ func TestTraceBlockTransactionsInitialReadsCacheCoherence(t *testing.T) { BlockHeaderHashByNumber(uint64(90)). Return(revealedHeader.Hash, nil). AnyTimes() - mockReader.EXPECT().BlockHeaderByHash(blockHash).Return(block.Header, nil).Times(2) + mockReader.EXPECT().BlockHeaderByHash(blockHash).Return(block.Header, nil).Times(4) // The cached follow-up serves from the header alone, so transactions are read once. mockReader.EXPECT().TransactionsByBlockNumber(block.Number). Return(block.Transactions, nil) @@ -1578,62 +1690,23 @@ func TestTraceBlockTransactionsInitialReadsCacheCoherence(t *testing.T) { mockVM.EXPECT().Trace(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), mockState, vm.TraceOptions{ReturnInitialReads: true}, - ).Return(execResultWithReads(populatedVMReads()), nil) + ).Return(execResultWithReads(len(block.Transactions), populatedVMReads()), nil) handler := rpcv10.New(mockReader, nil, mockVM, log.NewNopZapLogger()) blockID := rpcv10.BlockIDFromHash(blockHash) - for range 2 { - result, _, err := handler.TraceBlockTransactions( - t.Context(), &blockID, - []rpcv10.TraceFlag{rpcv10.TraceReturnInitialReadsFlag}, - ) + for _, withReads := range []bool{true, true, false, true} { + var flags []rpcv10.TraceFlag + if withReads { + flags = []rpcv10.TraceFlag{rpcv10.TraceReturnInitialReadsFlag} + } + result, _, err := handler.TraceBlockTransactions(t.Context(), &blockID, flags) require.Nil(t, err) - require.Equal(t, expectedPopulatedReads, result.InitialReads) + if withReads { + require.Equal(t, expectedPopulatedReads, result.InitialReads) + } else { + require.Nil(t, result.InitialReads) + } } }) - - // With the flag first, then without: the second call must be served from - // cache and strip initial reads from the response. - t.Run("cache reused for flag-less follow-up", func(t *testing.T) { - t.Parallel() - mockCtrl := gomock.NewController(t) - t.Cleanup(mockCtrl.Finish) - - mockReader := mocks.NewMockReader(mockCtrl) - mockVM := mocks.NewMockVM(mockCtrl) - mockState := mocks.NewMockStateReader(mockCtrl) - block, blockHash, _, revealedHeader := buildBlock() - - mockReader.EXPECT().Network().Return(n).AnyTimes() - mockReader.EXPECT().L1Head().Return(core.L1Head{}, db.ErrKeyNotFound).AnyTimes() - mockReader.EXPECT(). - BlockHeaderHashByNumber(uint64(90)). - Return(revealedHeader.Hash, nil). - AnyTimes() - mockReader.EXPECT().BlockHeaderByHash(blockHash).Return(block.Header, nil).Times(2) - // The cached follow-up serves from the header alone, so transactions are read once. - mockReader.EXPECT().TransactionsByBlockNumber(block.Number). - Return(block.Transactions, nil) - mockReader.EXPECT().StateAtBlockHash(block.ParentHash).Return(mockState, nopCloser, nil) - mockReader.EXPECT().HeadState().Return(mockState, nopCloser, nil) - - mockVM.EXPECT().Trace(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), mockState, - vm.TraceOptions{ReturnInitialReads: true}, - ).Return(execResultWithReads(populatedVMReads()), nil) - - handler := rpcv10.New(mockReader, nil, mockVM, log.NewNopZapLogger()) - - blockID := rpcv10.BlockIDFromHash(blockHash) - first, _, err := handler.TraceBlockTransactions( - t.Context(), &blockID, - []rpcv10.TraceFlag{rpcv10.TraceReturnInitialReadsFlag}, - ) - require.Nil(t, err) - require.Equal(t, expectedPopulatedReads, first.InitialReads) - - second, _, err := handler.TraceBlockTransactions(t.Context(), &blockID, nil) - require.Nil(t, err) - require.Nil(t, second.InitialReads) - }) }