Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions rpc/jsonrpc/check_prune_gates_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
}
}
138 changes: 127 additions & 11 deletions rpc/jsonrpc/prune_gating_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
"encoding/binary"
"math/big"
"testing"
"time"

"github.com/holiman/uint256"
"github.com/stretchr/testify/require"
Expand All @@ -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"
Expand Down Expand Up @@ -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 {
Expand All @@ -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.
Expand All @@ -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) {
Expand Down
Loading