diff --git a/rpc/jsonrpc/check_prune_gates_test.go b/rpc/jsonrpc/check_prune_gates_test.go index 7c5256644c1..5949f06b706 100644 --- a/rpc/jsonrpc/check_prune_gates_test.go +++ b/rpc/jsonrpc/check_prune_gates_test.go @@ -1727,3 +1727,59 @@ func TestFeeHistoryGateTakesTheOldestBlockOfTheRange(t *testing.T) { require.NoError(t, err) require.Equal(t, oldest, res.OldestBlock.ToInt().Uint64()) } + +// TestReceiptCacheServesBlocksWhoseHistoryIsRetired pins that a keep-all receipt retention +// is served from the cache and not by re-execution: state history is retired on disk above +// the block, so an endpoint that still answers can only be reading the cache. The shared +// fixture cannot show this — it keeps every history and stores the prune mode afterwards. +func TestReceiptCacheServesBlocksWhoseHistoryIsRetired(t *testing.T) { + t.Parallel() + + apis, chainInfo := setupPhysicallyPrunedHistory(t, prunedHistoryConfig{ + mode: prune.Mode{ + Initialised: true, + History: prunedHistoryDistance, + Blocks: prune.KeepAllBlocksPruneMode, + Receipts: prune.KeepAllReceiptsPruneMode, + }, + receiptCache: true, + }) + ctx := t.Context() + + bnh := rpc.BlockNumberOrHashWithNumber(rpc.BlockNumber(chainInfo.old.num)) + _, err := apis.eth.GetBalance(ctx, testAddr, &bnh) + require.ErrorIs(t, err, state.PrunedError, "the history window must already refuse state for this block") + + for _, ep := range receiptGatedEndpoints() { + t.Run(ep.name, func(t *testing.T) { + res, err := ep.call(ctx, apis, chainInfo.old) + require.NoError(t, err) + require.NotNil(t, res) + }) + } +} + +// TestReceiptsWithoutCacheStopAtRetiredHistory is the control for the test above: the +// same fixture with the cache off refuses the block, which is what attributes the answers +// there to the cache. The refusal here is the history-window comparison, not a read of +// the retired files. +func TestReceiptsWithoutCacheStopAtRetiredHistory(t *testing.T) { + t.Parallel() + + apis, chainInfo := setupPhysicallyPrunedHistory(t, prunedHistoryConfig{ + mode: prune.Mode{ + Initialised: true, + History: prunedHistoryDistance, + Blocks: prune.KeepAllBlocksPruneMode, + Receipts: prune.KeepAllReceiptsPruneMode, + }, + }) + ctx := t.Context() + + for _, ep := range receiptGatedEndpoints() { + t.Run(ep.name, func(t *testing.T) { + _, err := ep.call(ctx, apis, chainInfo.old) + require.ErrorIs(t, err, state.PrunedError) + }) + } +} diff --git a/rpc/jsonrpc/prune_gating_test.go b/rpc/jsonrpc/prune_gating_test.go index da77e0be9d1..252bf6f5dcd 100644 --- a/rpc/jsonrpc/prune_gating_test.go +++ b/rpc/jsonrpc/prune_gating_test.go @@ -22,6 +22,7 @@ import ( "encoding/binary" "math/big" "testing" + "time" "github.com/holiman/uint256" "github.com/stretchr/testify/require" @@ -34,6 +35,7 @@ import ( "github.com/erigontech/erigon/db/rawdb" "github.com/erigontech/erigon/execution/chain" "github.com/erigontech/erigon/execution/execmodule/execmoduletester" + "github.com/erigontech/erigon/execution/protocol/params" "github.com/erigontech/erigon/execution/state" "github.com/erigontech/erigon/execution/tests/blockgen" tracersConfig "github.com/erigontech/erigon/execution/tracing/tracers/config" @@ -544,17 +546,7 @@ func setupPruneGating(t *testing.T, cfg pruneGatingConfig) (pruneGatingAPIs, pru b := c.Blocks[idx] return pruneGatingRef{num: b.NumberU64(), hash: b.Hash(), txHash: b.Transactions()[0].Hash(), time: b.Time()} } - apis := pruneGatingAPIs{ - eth: newEthApiForTest(newBaseApiForTest(m), m.DB, nil, nil), - erigon: NewErigonAPI(newBaseApiForTest(m), m.DB, nil), - } - apis.debug = NewPrivateDebugAPI(newBaseApiForTest(m), m.DB, nil, &rpccfg.DebugApiConfig{}) - apis.graphql = NewGraphQLAPI(newBaseApiForTest(m), m.DB, apis.eth, nil, &rpccfg.GraphQLApiConfig{}) - otsBase := newBaseApiForTest(m) - apis.ots = NewOtterscanAPI(otsBase, m.DB, 25) - apis.overlay = NewOverlayAPI(otsBase, m.DB, &rpccfg.OverlayApiConfig{GasCap: 1_000_000}, apis.ots) - apis.trace = NewTraceAPI(newBaseApiForTest(m), m.DB, &rpccfg.TraceApiConfig{}) - apis.rwDB = m.DB + apis := newPruneGatingAPIs(m) empty := c.Blocks[pruneGatingEmptyBlockIdx] require.Empty(t, empty.Transactions(), "the empty-block leg needs a block without transactions") if cfg.persistReceipts { @@ -568,6 +560,21 @@ func setupPruneGating(t *testing.T, cfg pruneGatingConfig) (pruneGatingAPIs, pru } } +func newPruneGatingAPIs(m *execmoduletester.ExecModuleTester) pruneGatingAPIs { + apis := pruneGatingAPIs{ + eth: newEthApiForTest(newBaseApiForTest(m), m.DB, nil, nil), + erigon: NewErigonAPI(newBaseApiForTest(m), m.DB, nil), + } + apis.debug = NewPrivateDebugAPI(newBaseApiForTest(m), m.DB, nil, &rpccfg.DebugApiConfig{}) + apis.graphql = NewGraphQLAPI(newBaseApiForTest(m), m.DB, apis.eth, nil, &rpccfg.GraphQLApiConfig{}) + otsBase := newBaseApiForTest(m) + apis.ots = NewOtterscanAPI(otsBase, m.DB, 25) + apis.overlay = NewOverlayAPI(otsBase, m.DB, &rpccfg.OverlayApiConfig{GasCap: 1_000_000}, apis.ots) + apis.trace = NewTraceAPI(newBaseApiForTest(m), m.DB, &rpccfg.TraceApiConfig{}) + apis.rwDB = m.DB + return apis +} + // requirePersistedReceipts asserts the fixture holds on disk what a receipt retention // promises. Without it a cell asserting availability passes by re-execution, which the // retention says nothing about, and a regression in the cache path goes unnoticed. @@ -581,6 +588,115 @@ func requirePersistedReceipts(t *testing.T, m *execmoduletester.ExecModuleTester require.Len(t, got, len(block.Transactions())) } +const ( + // One transaction plus two system transactions per block, so a step holds one block + // and the retirement cutoff can land inside the chain. + prunedHistoryStepSize = 3 + prunedHistoryMaxReorgDepth = 2 + prunedHistoryChainLen = 24 + prunedHistoryDistance = prune.Distance(6) + prunedHistoryOldBlockIdx = 3 +) + +type prunedHistoryConfig struct { + mode prune.Mode + // receiptCache runs the chain with the receipt cache enabled and persisted, the only + // source of receipts left once history is retired. + receiptCache bool +} + +// setupPhysicallyPrunedHistory retires state history on disk instead of only storing the +// prune mode, which setupPruneGating deliberately does not do. Files are built as finality +// advances, so the chain is PoS and the forkchoice is driven block by block. +func setupPhysicallyPrunedHistory(t *testing.T, cfg prunedHistoryConfig) (pruneGatingAPIs, pruneGatingChain) { + t.Helper() + ctx, cancel := context.WithTimeout(t.Context(), time.Minute) + defer cancel() + opts := []execmoduletester.Option{ + execmoduletester.WithChainConfig(chain.AllProtocolChanges), + execmoduletester.WithMaxReorgDepth(prunedHistoryMaxReorgDepth), + execmoduletester.WithStepSize(prunedHistoryStepSize), + execmoduletester.WithPruneMode(cfg.mode), + } + if cfg.receiptCache { + opts = append(opts, execmoduletester.WithEnableDomain(kv.RCacheDomain)) + } + m := execmoduletester.New(t, opts...) + require.NoError(t, m.WaitForBlockRetirement(ctx)) + require.NoError(t, m.WaitForStateRetirement(ctx)) + + signer := types.LatestSignerForChainID(m.ChainConfig.ChainID) + c, err := m.GenerateChain(prunedHistoryChainLen, func(_ int, block *blockgen.BlockGen) { + txn, err := types.SignTx(types.NewTransaction(block.TxNonce(m.Address), common.Address{1}, + uint256.NewInt(10_000), params.TxGas, uint256.NewInt(m.Genesis.BaseFee().Uint64()), nil), *signer, m.Key) + require.NoError(t, err) + block.AddTx(txn) + }) + require.NoError(t, err) + + fcuOpts := make([][]execmoduletester.UFCOpt, len(c.Blocks)) + for i := range c.Blocks { + if i < prunedHistoryMaxReorgDepth { + continue + } + finalised := c.Headers[i-prunedHistoryMaxReorgDepth].Hash() + fcuOpts[i] = []execmoduletester.UFCOpt{ + execmoduletester.WithSafeHash(finalised), + execmoduletester.WithFinalisedHash(finalised), + } + } + require.NoError(t, m.InsertValidateAndUfc1By1(ctx, c.Blocks, + execmoduletester.WithFcuOptSeq(fcuOpts), + execmoduletester.WithWaitForStateFiles())) + + tx, err := m.DB.BeginTemporalRw(ctx) + require.NoError(t, err) + defer tx.Rollback() + _, err = prune.EnsureNotChanged(tx, cfg.mode) + require.NoError(t, err) + require.NoError(t, kvcfg.PersistReceipts.ForceWrite(tx, cfg.receiptCache)) + require.NoError(t, tx.Commit()) + + old := c.Blocks[prunedHistoryOldBlockIdx] + requireRetiredAbove(t, m, kv.AccountsDomain, old.NumberU64()) + if cfg.receiptCache { + requirePersistedReceipts(t, m, old) + } + + ref := func(b *types.Block) pruneGatingRef { + return pruneGatingRef{num: b.NumberU64(), hash: b.Hash(), txHash: b.Transactions()[0].Hash(), time: b.Time()} + } + return newPruneGatingAPIs(m), pruneGatingChain{ + head: prunedHistoryChainLen, + old: ref(old), + recent: ref(c.Blocks[prunedHistoryChainLen-1]), + } +} + +// requireRetiredAbove asserts the domain holds nothing for the block any more, so what an +// endpoint answers for it comes from somewhere else and a refusal is not just a gate. +func requireRetiredAbove(t *testing.T, m *execmoduletester.ExecModuleTester, domain kv.Domain, blockNum uint64) { + t.Helper() + tx, err := m.DB.BeginTemporalRo(t.Context()) + require.NoError(t, err) + defer tx.Rollback() + maxTxNum, err := m.BlockReader.TxnumReader().Max(t.Context(), tx, blockNum) + require.NoError(t, err) + require.Greater(t, tx.Debug().HistoryStartFrom(domain), maxTxNum, + "%s must be retired above block %d for the fixture to mean anything", domain, blockNum) +} + +// receiptGatedEndpoints are the endpoints whose availability a receipt retention can widen. +func receiptGatedEndpoints() []pruneGatingEndpoint { + out := make([]pruneGatingEndpoint, 0, len(pruneGatingEndpoints)) + for _, ep := range pruneGatingEndpoints { + if ep.boundary == gatedByBlockReceipts { + out = append(out, ep) + } + } + return out +} + // dropTransactions removes the transactions of every block in [from, to), leaving the // headers and bodies in place. That is what chain history expiry looks like on disk. func dropTransactions(t *testing.T, db kv.TemporalRwDB, from, to uint64) {