diff --git a/consensus/bor/bor.go b/consensus/bor/bor.go
index bee17fcb0e..6de5cd7f03 100644
--- a/consensus/bor/bor.go
+++ b/consensus/bor/bor.go
@@ -1069,26 +1069,29 @@ func (c *Bor) giuglianoExtraFields(header *types.Header, parent *types.Header) (
return >, &bfcd
}
-// reservedGasUsedPlaceholder returns the zero-valued reserved-gas field for
-// post-ReservedBlockspace headers, so the field is present (non-nil) from
-// Prepare on and the header passes the verifyReservedFields presence check
-// even when there are no reserved transactions. The miner overwrites it with
-// the block's real reserved gas total at the end of block building
-// (worker.writeReservedGasUsed). Nil pre-fork, keeping the field off the wire.
-func (c *Bor) reservedGasUsedPlaceholder(header *types.Header) *uint64 {
+// reservedFieldsPlaceholder returns zero-valued reserved-gas and capacity
+// fields for post-ReservedBlockspace headers, so both are present (non-nil)
+// from Prepare on and the header passes the verifyReservedFields presence
+// check even when there are no reserved transactions or the registry carves
+// out no capacity yet. The miner overwrites both with the block's real values
+// at the end of block building (worker.writeReservedFields). Nil pre-fork,
+// keeping the fields off the wire.
+func (c *Bor) reservedFieldsPlaceholder(header *types.Header) (gasUsed, capacity *uint64) {
if !c.config.IsReservedBlockspace(header.Number) {
- return nil
+ return nil, nil
}
- var zeroGas uint64
+ var zeroGas, zeroCapacity uint64
- return &zeroGas
+ return &zeroGas, &zeroCapacity
}
// verifyReservedFields checks that post-ReservedBlockspace headers carry the
-// reserved-region gas field, plus a header-only bound on its value. Value
-// correctness (the sum over the block's reserved transactions, per-client
-// quota) is a body-level check that lands with the block-validation slice.
+// reserved-region fields, plus a header-only bound on ReservedGasUsed. Value
+// correctness for both fields (the sum over the block's reserved transactions
+// for gas used, the registry snapshot's effective capacity for capacity) is a
+// body-level check that lands with the block-validation slice
+// (validateReservedFields).
func (c *Bor) verifyReservedFields(header *types.Header) error {
if !c.config.IsReservedBlockspace(header.Number) {
return nil
@@ -1097,12 +1100,23 @@ func (c *Bor) verifyReservedFields(header *types.Header) error {
if reservedGasUsed == nil {
return errMissingReservedBlockspaceFields
}
+ if header.GetReservedCapacity(c.chainConfig) == nil {
+ return errMissingReservedBlockspaceFields
+ }
// The reserved region is a subset of the block, so its gas cannot exceed the
// block's gas used. Reject the impossible value rather than letting CalcBaseFee
// silently clamp it (the base fee consumes parent.ReservedGasUsed).
if *reservedGasUsed > header.GasUsed {
return errReservedGasUsedExceedsBlock
}
+ // No bound here ties ReservedCapacity to GasLimit: governance (setLimits,
+ // quota updates) has no tie to the block gas limit, which is itself
+ // operator-tunable per block, so a registry state whose effective
+ // capacity meets or exceeds GasLimit is a reachable, valid state.
+ // Rejecting it at the header would leave no valid next block and halt the
+ // chain; the exact value is enforced against the registry by
+ // validateReservedFields instead, and CalcBaseFee's reserved-aware target
+ // falls back deterministically when capacity >= GasLimit.
return nil
}
@@ -1188,7 +1202,9 @@ func (c *Bor) Prepare(chain consensus.ChainHeaderReader, header *types.Header, w
gasTarget, baseFeeChangeDenom := c.giuglianoExtraFields(header, parent)
- blockExtraDataBytes, err := types.EncodeBlockExtraData(c.chainConfig, header.Number, tempValidatorBytes, gasTarget, baseFeeChangeDenom, c.reservedGasUsedPlaceholder(header))
+ reservedGasUsed, reservedCapacity := c.reservedFieldsPlaceholder(header)
+
+ blockExtraDataBytes, err := types.EncodeBlockExtraData(c.chainConfig, header.Number, tempValidatorBytes, gasTarget, baseFeeChangeDenom, reservedGasUsed, reservedCapacity)
if err != nil {
log.Error("error while encoding block extra data", "err", err)
return fmt.Errorf("error while encoding block extra data: %v", err)
@@ -1203,7 +1219,9 @@ func (c *Bor) Prepare(chain consensus.ChainHeaderReader, header *types.Header, w
} else if c.chainConfig.IsCancun(header.Number) {
gasTarget, baseFeeChangeDenom := c.giuglianoExtraFields(header, parent)
- blockExtraDataBytes, err := types.EncodeBlockExtraData(c.chainConfig, header.Number, nil, gasTarget, baseFeeChangeDenom, c.reservedGasUsedPlaceholder(header))
+ reservedGasUsed, reservedCapacity := c.reservedFieldsPlaceholder(header)
+
+ blockExtraDataBytes, err := types.EncodeBlockExtraData(c.chainConfig, header.Number, nil, gasTarget, baseFeeChangeDenom, reservedGasUsed, reservedCapacity)
if err != nil {
log.Error("error while encoding block extra data", "err", err)
return fmt.Errorf("error while encoding block extra data: %v", err)
@@ -1848,24 +1866,11 @@ func (c *Bor) checkAndCommitSpan(
var ctx = context.Background()
headerNumber := header.Number.Uint64()
- tempState := state.Inner().Copy()
- tempState.ResetPrefetcher()
- tempState.StartPrefetcher("bor", state.Witness(), nil)
-
- span, err := c.spanner.GetCurrentSpan(ctx, header.ParentHash, tempState)
+ span, err := c.readCurrentSpan(ctx, state.Inner(), header.ParentHash)
if err != nil {
return err
}
- tempState.IntermediateRoot(false)
-
- // Propagate addresses accessed during GetCurrentSpan back to the original
- // state so they appear in the FlatDiff ReadSet. Without this, the pipelined
- // SRC goroutine's witness won't capture their trie proof nodes (the copy's
- // reads aren't tracked on the original), causing stateless execution to fail
- // with missing trie nodes for the validator contract.
- tempState.PropagateReadsTo(state.Inner())
-
if c.needToCommitSpan(span, headerNumber) {
return c.FetchAndCommitSpan(ctx, span.Id+1, state, header, chain)
}
@@ -1873,6 +1878,39 @@ func (c *Bor) checkAndCommitSpan(
return nil
}
+// readCurrentSpan reads the span contract isolated from the live block state,
+// while keeping the read's trie nodes in any witness being produced - a
+// stateless verifier repeats this read, so the witness must carry them (see
+// state.ReadIsolated).
+func (c *Bor) readCurrentSpan(ctx context.Context, stateDB *state.StateDB, parentHash common.Hash) (*borTypes.Span, error) {
+ var span *borTypes.Span
+ err := stateDB.ReadIsolated(func(tmp *state.StateDB) error {
+ var err error
+ span, err = c.spanner.GetCurrentSpan(ctx, parentHash, tmp)
+ return err
+ })
+ if err != nil {
+ return nil, err
+ }
+ return span, nil
+}
+
+// readLastStateID reads the state-receiver contract's last state-sync id
+// isolated from the live block state, with the same witness guarantees as
+// readCurrentSpan.
+func (c *Bor) readLastStateID(stateDB *state.StateDB, number uint64, hash common.Hash) (*big.Int, error) {
+ var id *big.Int
+ err := stateDB.ReadIsolated(func(tmp *state.StateDB) error {
+ var err error
+ id, err = c.GenesisContractsClient.LastStateId(tmp, number, hash)
+ return err
+ })
+ if err != nil {
+ return nil, err
+ }
+ return id, nil
+}
+
func (c *Bor) needToCommitSpan(currentSpan *borTypes.Span, headerNumber uint64) bool {
// If span is nil, return false.
if currentSpan == nil {
@@ -2000,23 +2038,11 @@ func (c *Bor) CommitStates(
if c.config.IsIndore(header.Number) {
// Fetch the LastStateId from contract via current state instance
- tempState := state.Inner().Copy()
- tempState.ResetPrefetcher()
- tempState.StartPrefetcher("bor", state.Witness(), nil)
-
- lastStateIDBig, err = c.GenesisContractsClient.LastStateId(tempState, number-1, header.ParentHash)
+ lastStateIDBig, err = c.readLastStateID(state.Inner(), number-1, header.ParentHash)
if err != nil {
return nil, err
}
- tempState.IntermediateRoot(false)
-
- // Propagate addresses accessed during LastStateId back to the original
- // state so they appear in the FlatDiff ReadSet. Without this, the
- // pipelined SRC goroutine's witness won't capture their trie proof
- // nodes, causing stateless execution to fail with missing trie nodes.
- tempState.PropagateReadsTo(state.Inner())
-
stateSyncDelay := c.config.CalculateStateSyncDelay(number)
to = time.Unix(int64(header.Time-stateSyncDelay), 0)
} else {
diff --git a/consensus/bor/bor_test.go b/consensus/bor/bor_test.go
index 08d60b1a34..07300de645 100644
--- a/consensus/bor/bor_test.go
+++ b/consensus/bor/bor_test.go
@@ -63,12 +63,15 @@ func (s *fakeSpanner) GetCurrentSpan(ctx context.Context, headerHash common.Hash
spanID := s.spanID
return &borTypes.Span{Id: spanID, StartBlock: 0, EndBlock: endBlock}, nil
}
+
func (s *fakeSpanner) GetCurrentValidatorsByHash(ctx context.Context, headerHash common.Hash, blockNumber uint64) ([]*valset.Validator, error) {
return s.vals, nil
}
+
func (s *fakeSpanner) GetCurrentValidatorsByBlockNrOrHash(ctx context.Context, _ rpc.BlockNumberOrHash, _ uint64) ([]*valset.Validator, error) {
return s.vals, nil
}
+
func (s *fakeSpanner) CommitSpan(ctx context.Context, _ borTypes.Span, _ []stakeTypes.MinimalVal, _ []stakeTypes.MinimalVal, _ vm.StateDB, _ *types.Header, _ core.ChainContext, _ vm.Config) error {
if s.shouldFailCommit {
return errors.New("span commit failed")
@@ -94,30 +97,39 @@ func (f *failingHeimdallClient) Close() {}
func (f *failingHeimdallClient) FetchStateSyncEvents(ctx context.Context, fromID uint64, to int64, limit int) ([]*types.StateSyncData, error) {
return nil, errors.New("state sync failed")
}
+
func (f *failingHeimdallClient) FetchStateSyncEvent(ctx context.Context, id uint64) (*types.StateSyncData, error) {
return nil, errors.New("state sync failed")
}
+
func (f *failingHeimdallClient) StateSyncEvents(ctx context.Context, fromID uint64, to int64) ([]*clerk.EventRecordWithTime, error) {
return nil, errors.New("state sync failed")
}
+
func (f *failingHeimdallClient) GetSpan(ctx context.Context, spanID uint64) (*borTypes.Span, error) {
return nil, errors.New("get span failed")
}
+
func (f *failingHeimdallClient) GetLatestSpan(ctx context.Context) (*borTypes.Span, error) {
return nil, errors.New("get latest span failed")
}
+
func (f *failingHeimdallClient) FetchCheckpoint(ctx context.Context, number int64) (*checkpoint.Checkpoint, error) {
return nil, errors.New("fetch checkpoint failed")
}
+
func (f *failingHeimdallClient) FetchCheckpointCount(ctx context.Context) (int64, error) {
return 0, errors.New("fetch checkpoint count failed")
}
+
func (f *failingHeimdallClient) FetchMilestone(ctx context.Context) (*milestone.Milestone, error) {
return nil, errors.New("fetch milestone failed")
}
+
func (f *failingHeimdallClient) FetchMilestoneCount(ctx context.Context) (int64, error) {
return 0, errors.New("fetch milestone count failed")
}
+
func (f *failingHeimdallClient) FetchStatus(ctx context.Context) (*ctypes.SyncInfo, error) {
return nil, errors.New("fetch status failed")
}
@@ -1520,6 +1532,7 @@ func TestBor_PurgeCache(t *testing.T) {
borObj.recents.Set(hash1, snapshot1, ttlcache.DefaultTTL)
require.Equal(t, 1, borObj.recents.Len(), "should be able to add to recents cache after purge")
}
+
func TestValidatorContains_Found(t *testing.T) {
t.Parallel()
vals := []*valset.Validator{
@@ -1680,6 +1693,7 @@ func TestBorRLP(t *testing.T) {
result := BorRLP(h, borCfg)
require.NotEmpty(t, result)
}
+
func TestValidateHeaderExtraField(t *testing.T) {
t.Parallel()
@@ -2008,6 +2022,7 @@ func TestVerifyUncles_WithUncles(t *testing.T) {
err := b.VerifyUncles(nil, block)
require.Equal(t, errUncleDetected, err)
}
+
func TestAuthorize(t *testing.T) {
t.Parallel()
sp := &fakeSpanner{vals: []*valset.Validator{{Address: common.HexToAddress("0x1"), VotingPower: 1}}}
@@ -2097,6 +2112,7 @@ func TestSeal_ZeroPeriodEmptyBlock(t *testing.T) {
case <-time.After(100 * time.Millisecond):
}
}
+
func TestAPIs_ReturnsBorNamespace(t *testing.T) {
t.Parallel()
sp := &fakeSpanner{vals: []*valset.Validator{{Address: common.HexToAddress("0x1"), VotingPower: 1}}}
@@ -2182,6 +2198,7 @@ func TestGetCurrentValidators_DelegatesToSpanner(t *testing.T) {
require.Len(t, vals, 1)
require.Equal(t, addr1, vals[0].Address)
}
+
func TestNeedToCommitSpan(t *testing.T) {
t.Parallel()
@@ -2214,6 +2231,7 @@ func TestNeedToCommitSpan(t *testing.T) {
require.False(t, b.needToCommitSpan(span, 1000))
})
}
+
func TestVerifySeal_GenesisBlock(t *testing.T) {
t.Parallel()
sp := &fakeSpanner{vals: []*valset.Validator{{Address: common.HexToAddress("0x1"), VotingPower: 1}}}
@@ -2224,6 +2242,7 @@ func TestVerifySeal_GenesisBlock(t *testing.T) {
err := b.VerifySeal(chain.HeaderChain(), h)
require.Equal(t, errUnknownBlock, err)
}
+
func TestCalcDifficulty(t *testing.T) {
t.Parallel()
addr1 := common.HexToAddress("0x1")
@@ -2238,6 +2257,7 @@ func TestCalcDifficulty(t *testing.T) {
require.NotNil(t, diff)
require.True(t, diff.Uint64() > 0)
}
+
func TestInsertStateSyncTransactionAndCalculateReceipt(t *testing.T) {
t.Parallel()
@@ -2293,6 +2313,7 @@ func (m *mockStateDB) Logs() []*types.Log {
func (m *mockStateDB) Inner() *state.StateDB {
return nil
}
+
func TestIsBlockEarly(t *testing.T) {
t.Parallel()
borCfg := borConfigWithDelays(64)
@@ -2314,6 +2335,7 @@ func TestIsBlockEarly(t *testing.T) {
require.False(t, IsBlockEarly(parent, h, 1, 0, borCfg))
})
}
+
func TestIsSprintStart(t *testing.T) {
t.Parallel()
require.True(t, IsSprintStart(0, 64))
@@ -2322,6 +2344,7 @@ func TestIsSprintStart(t *testing.T) {
require.False(t, IsSprintStart(1, 64))
require.False(t, IsSprintStart(63, 64))
}
+
func TestDifficulty(t *testing.T) {
t.Parallel()
@@ -2342,6 +2365,7 @@ func TestDifficulty(t *testing.T) {
require.Equal(t, uint64(len(vals)), diff)
})
}
+
func TestEcrecover(t *testing.T) {
t.Parallel()
privKey, err := crypto.GenerateKey()
@@ -2385,6 +2409,7 @@ func TestEcrecover_MissingSignature(t *testing.T) {
_, err := ecrecover(h, sigcache, borCfg)
require.Equal(t, errMissingSignature, err)
}
+
func TestFinalize_WithdrawalsRejection(t *testing.T) {
t.Parallel()
sp := &fakeSpanner{vals: []*valset.Validator{{Address: common.HexToAddress("0x1"), VotingPower: 1}}}
@@ -2419,6 +2444,7 @@ func TestFinalize_RequestsHashRejection(t *testing.T) {
require.Nil(t, result)
require.ErrorIs(t, err, consensus.ErrUnexpectedRequests)
}
+
func TestNew(t *testing.T) {
t.Parallel()
borCfg := ¶ms.BorConfig{
@@ -2987,6 +3013,7 @@ func TestSeal_UnauthorizedSigner(t *testing.T) {
var authErr *UnauthorizedSignerError
require.ErrorAs(t, err, &authErr)
}
+
func TestFinalize_NonSprintBlock(t *testing.T) {
t.Parallel()
addr1 := common.HexToAddress("0x1")
@@ -3027,6 +3054,7 @@ func TestFinalize_SprintBlockWithoutHeimdall(t *testing.T) {
require.NoError(t, err)
require.Nil(t, result) // nil receipts expected
}
+
func TestFetchAndCommitSpan_WithHeimdallClient(t *testing.T) {
t.Parallel()
addr1 := common.HexToAddress("0x1")
@@ -3251,30 +3279,37 @@ func (m *mockHeimdallClient) StateSyncEvents(ctx context.Context, fromID uint64,
}
return m.events, nil
}
+
func (m *mockHeimdallClient) GetSpan(ctx context.Context, spanID uint64) (*borTypes.Span, error) {
if m.span == nil {
return nil, errors.New("span not found")
}
return m.span, nil
}
+
func (m *mockHeimdallClient) GetLatestSpan(ctx context.Context) (*borTypes.Span, error) {
if m.span == nil {
return nil, errors.New("no span")
}
return m.span, nil
}
+
func (m *mockHeimdallClient) FetchCheckpoint(ctx context.Context, number int64) (*checkpoint.Checkpoint, error) {
return nil, nil
}
+
func (m *mockHeimdallClient) FetchCheckpointCount(ctx context.Context) (int64, error) {
return 0, nil
}
+
func (m *mockHeimdallClient) FetchMilestone(ctx context.Context) (*milestone.Milestone, error) {
return nil, nil
}
+
func (m *mockHeimdallClient) FetchMilestoneCount(ctx context.Context) (int64, error) {
return 0, nil
}
+
func (m *mockHeimdallClient) FetchStatus(ctx context.Context) (*ctypes.SyncInfo, error) {
return &ctypes.SyncInfo{CatchingUp: false}, nil
}
@@ -3297,6 +3332,7 @@ func TestEncodeSigHeader_WithBaseFee(t *testing.T) {
require.NotEqual(t, common.Hash{}, hash2)
require.NotEqual(t, hash, hash2) // different because BaseFee is included in one
}
+
func TestClose_WithHeimdallClient(t *testing.T) {
t.Parallel()
sp := &fakeSpanner{vals: []*valset.Validator{{Address: common.HexToAddress("0x1"), VotingPower: 1}}}
@@ -3309,6 +3345,7 @@ func TestClose_WithHeimdallClient(t *testing.T) {
require.NoError(t, b.Close())
}
+
func TestPrepare_NonSprintBlock(t *testing.T) {
t.Parallel()
setup := newSignedChainSetup(t)
@@ -3354,6 +3391,7 @@ func TestPrepare_SprintStartBlock(t *testing.T) {
// Extra should contain vanity + validator bytes + seal
require.True(t, len(h.Extra) > types.ExtraVanityLength+types.ExtraSealLength)
}
+
func TestSnapshot_NonDevFakeAuthor_GenesisCheckpoint(t *testing.T) {
t.Parallel()
addr1 := common.HexToAddress("0x1")
@@ -3400,6 +3438,7 @@ func TestSnapshot_NonDevFakeAuthor_GenesisCheckpoint(t *testing.T) {
require.Equal(t, uint64(0), snap.Number)
require.True(t, snap.ValidatorSet.HasAddress(addr1))
}
+
func TestVerifyCascadingFields_PreLondonGasLimit(t *testing.T) {
t.Parallel()
setup := newSignedChainSetup(t)
@@ -3450,6 +3489,7 @@ func TestVerifyCascadingFields_BaseFeeBeforeLondon(t *testing.T) {
require.Error(t, err)
require.Contains(t, err.Error(), "invalid baseFee")
}
+
func TestVerifyHeader_BhilaiEarlyBlock(t *testing.T) {
t.Parallel()
addr1 := common.HexToAddress("0x1")
@@ -3472,6 +3512,7 @@ func TestVerifyHeader_BhilaiEarlyBlock(t *testing.T) {
err := b.verifyHeader(nil, h, nil)
require.ErrorIs(t, err, consensus.ErrFutureBlock)
}
+
func TestFinalize_SprintBlockWithCommitSpan(t *testing.T) {
t.Parallel()
addr1 := common.HexToAddress("0x1")
@@ -3495,6 +3536,7 @@ func TestFinalize_SprintBlockWithCommitSpan(t *testing.T) {
require.NoError(t, err)
require.NotNil(t, receipts)
}
+
func TestCalcDifficulty_WithSnapshot(t *testing.T) {
t.Parallel()
setup := newSignedChainSetup(t)
@@ -3506,6 +3548,7 @@ func TestCalcDifficulty_WithSnapshot(t *testing.T) {
diff := b.CalcDifficulty(setup.chain.HeaderChain(), 0, setup.genesis)
require.NotNil(t, diff)
}
+
func TestSign_ErrorPath(t *testing.T) {
t.Parallel()
borCfg := defaultBorConfig()
@@ -3524,6 +3567,7 @@ func TestSign_ErrorPath(t *testing.T) {
require.Error(t, err)
require.Contains(t, err.Error(), "sign failed")
}
+
func TestSnapshotApply_SprintEndValidatorChange(t *testing.T) {
t.Parallel()
@@ -3672,6 +3716,7 @@ func TestCommitStates_WithIndore_EventProcessing(t *testing.T) {
require.NoError(t, err)
require.Len(t, result, 2) // both events should be processed
}
+
func TestCommitStates_NonIndore(t *testing.T) {
t.Parallel()
addr1 := common.HexToAddress("0x1")
@@ -3926,6 +3971,7 @@ func TestEncodeSigHeader_WithLondonBaseFee(t *testing.T) {
// Different BaseFee should produce different seal hashes
require.NotEqual(t, hash1, hash2)
}
+
func TestFinalize_NonSprintBlockNoStateSync(t *testing.T) {
t.Parallel()
addr1 := common.HexToAddress("0x1")
@@ -3949,6 +3995,7 @@ func TestFinalize_NonSprintBlockNoStateSync(t *testing.T) {
require.NoError(t, err)
require.NotNil(t, receipts)
}
+
func TestVerifySeal_BhilaiNonPrimaryFutureBlock(t *testing.T) {
t.Parallel()
privKey, _ := crypto.GenerateKey()
@@ -4002,6 +4049,7 @@ func TestVerifySeal_BhilaiNonPrimaryFutureBlock(t *testing.T) {
err = b.verifySeal(chain.HeaderChain(), h, []*types.Header{genesis})
require.ErrorIs(t, err, consensus.ErrFutureBlock)
}
+
func TestFinalizeAndAssemble_WithdrawalsRejected(t *testing.T) {
t.Parallel()
b := &Bor{config: ¶ms.BorConfig{Sprint: map[string]uint64{"0": 64}}}
@@ -4024,6 +4072,7 @@ func TestFinalizeAndAssemble_RequestsHashRejected(t *testing.T) {
_, _, _, err := b.FinalizeAndAssemble(nil, h, nil, body, nil)
require.ErrorIs(t, err, consensus.ErrUnexpectedRequests)
}
+
func TestVerifySeal_BlockTooSoon(t *testing.T) {
t.Parallel()
setup := newSignedChainSetup(t)
@@ -4050,6 +4099,7 @@ func TestVerifySeal_BlockTooSoon(t *testing.T) {
var tooSoonErr *BlockTooSoonError
require.ErrorAs(t, err, &tooSoonErr)
}
+
func TestPrepare_CancunEncoding(t *testing.T) {
t.Parallel()
addr1 := common.HexToAddress("0x1")
@@ -4443,6 +4493,7 @@ func TestSnapshot_HeaderTraversal(t *testing.T) {
require.NotNil(t, snap2)
require.Equal(t, uint64(1), snap2.Number)
}
+
func TestVerifyCascadingFields_EIP1559(t *testing.T) {
t.Parallel()
addr1 := common.HexToAddress("0x1")
@@ -4486,6 +4537,7 @@ func TestVerifyCascadingFields_EIP1559(t *testing.T) {
require.Contains(t, err.Error(), "base fee")
}
}
+
func TestNew_WithHeimdallClient(t *testing.T) {
t.Parallel()
cfg := ¶ms.ChainConfig{ChainID: big.NewInt(1), Bor: borConfigWithDelays(64)}
@@ -4500,6 +4552,7 @@ func TestNew_WithHeimdallClient(t *testing.T) {
require.NotNil(t, bor.HeimdallClient)
require.NoError(t, bor.Close())
}
+
func TestVerifyCascadingFields_SprintStartValidatorCheck(t *testing.T) {
t.Parallel()
// This test exercises the sprint-start validator byte verification path (lines 590-604).
@@ -4585,6 +4638,7 @@ func TestVerifyCascadingFields_SprintStartValidatorCheck(t *testing.T) {
// Either passes or fails on validator check - exercises the code path
_ = err
}
+
func TestAPI_GetRootHash_Valid(t *testing.T) {
t.Parallel()
api, _, _ := newAPIForTest(t)
@@ -4631,6 +4685,7 @@ func TestAPI_GetRootHash_MaxCheckpointExceeded(t *testing.T) {
_, err := api.GetRootHash(0, MaxCheckpointLength+1)
require.Error(t, err)
}
+
func TestCommitStates_WithOverrideStateSyncRecords(t *testing.T) {
t.Parallel()
addr1 := common.HexToAddress("0x1")
@@ -4675,6 +4730,7 @@ func TestCommitStates_WithOverrideStateSyncRecords(t *testing.T) {
// With OverrideStateSyncRecords truncating to 0, should get empty data
require.Empty(t, data)
}
+
func TestPrepare_UnknownParent(t *testing.T) {
t.Parallel()
setup := newSignedChainSetup(t)
@@ -4690,6 +4746,7 @@ func TestPrepare_UnknownParent(t *testing.T) {
err := b.Prepare(setup.chain.HeaderChain(), h, false)
require.Error(t, err)
}
+
func TestSeal_SignError(t *testing.T) {
t.Parallel()
setup := newSignedChainSetup(t)
@@ -4712,6 +4769,7 @@ func TestSeal_SignError(t *testing.T) {
require.Error(t, err)
require.Contains(t, err.Error(), "signing failed")
}
+
func TestVerifyHeader_InvalidSprintEndValidatorBytes(t *testing.T) {
t.Parallel()
@@ -4746,6 +4804,7 @@ func TestVerifyHeader_InvalidSprintEndValidatorBytes(t *testing.T) {
require.Error(t, err)
require.Equal(t, errInvalidSpanValidators, err)
}
+
func TestCalcDifficulty_NonSigner(t *testing.T) {
t.Parallel()
setup := newSignedChainSetup(t)
@@ -4755,6 +4814,7 @@ func TestCalcDifficulty_NonSigner(t *testing.T) {
diff := b.CalcDifficulty(setup.chain.HeaderChain(), 0, setup.genesis)
require.NotNil(t, diff)
}
+
func TestSnapshotApply_BadSignature(t *testing.T) {
t.Parallel()
@@ -4777,6 +4837,7 @@ func TestSnapshotApply_BadSignature(t *testing.T) {
_, err := snap.apply([]*types.Header{h}, nil)
require.Error(t, err)
}
+
func TestPrepare_ValidatorsByHashError(t *testing.T) {
t.Parallel()
@@ -4820,6 +4881,7 @@ func TestPrepare_ValidatorsByHashError(t *testing.T) {
// Should get errUnknownValidators since GetCurrentValidatorsByHash returns empty/nil
require.Error(t, err)
}
+
func TestSnapshot_Difficulty_NotFound(t *testing.T) {
t.Parallel()
@@ -4838,6 +4900,7 @@ func TestSnapshot_Difficulty_NotFound(t *testing.T) {
diff := Difficulty(snap.ValidatorSet, common.HexToAddress("0x99"))
require.Greater(t, diff, uint64(0))
}
+
func TestVerifySeal_BhilaiErrorPaths(t *testing.T) {
t.Parallel()
setup := newSignedChainSetup(t)
@@ -4856,6 +4919,7 @@ func TestVerifySeal_BhilaiErrorPaths(t *testing.T) {
err := b.verifySeal(setup.chain.HeaderChain(), h, nil)
require.Error(t, err)
}
+
func TestFinalize_WithBlockAlloc(t *testing.T) {
t.Parallel()
addr1 := common.HexToAddress("0x1")
@@ -4890,6 +4954,7 @@ func TestFinalize_WithBlockAlloc(t *testing.T) {
require.NoError(t, err)
require.NotNil(t, result)
}
+
func TestCommitStates_WithOverrideStateSyncRecordsInRange(t *testing.T) {
t.Parallel()
addr1 := common.HexToAddress("0x1")
@@ -4974,6 +5039,7 @@ func TestCommitStates_StateSyncEventsError(t *testing.T) {
require.NoError(t, err) // error is logged but returns empty data
require.Empty(t, data)
}
+
func TestCommitStates_EventIdLessThanLastStateId(t *testing.T) {
t.Parallel()
addr1 := common.HexToAddress("0x1")
@@ -5026,6 +5092,7 @@ func TestCommitStates_EventIdLessThanLastStateId(t *testing.T) {
require.Len(t, data, 1)
require.Equal(t, uint64(6), data[0].ID)
}
+
func TestCommitStates_EventValidationError(t *testing.T) {
t.Parallel()
addr1 := common.HexToAddress("0x1")
@@ -5073,6 +5140,7 @@ func TestCommitStates_EventValidationError(t *testing.T) {
require.NoError(t, err) // validation error is logged but returned data should be empty
require.Empty(t, data)
}
+
func TestFinalize_CheckAndCommitSpanError(t *testing.T) {
t.Parallel()
addr1 := common.HexToAddress("0x1")
@@ -5941,19 +6009,23 @@ func TestVerifyHeader_PreGiugliano_NoCheck(t *testing.T) {
}
}
-func TestReservedGasUsedPlaceholder(t *testing.T) {
+func TestReservedFieldsPlaceholder(t *testing.T) {
t.Parallel()
b := &Bor{config: ¶ms.BorConfig{ReservedBlockspaceBlock: big.NewInt(100)}}
- // Pre-fork: no placeholder, keeping the field off the wire.
- require.Nil(t, b.reservedGasUsedPlaceholder(&types.Header{Number: big.NewInt(99)}))
+ // Pre-fork: no placeholders, keeping the fields off the wire.
+ gasUsed, capacity := b.reservedFieldsPlaceholder(&types.Header{Number: big.NewInt(99)})
+ require.Nil(t, gasUsed)
+ require.Nil(t, capacity)
- // Post-fork: non-nil zero so the header is valid even before the miner
- // writes the real value.
- got := b.reservedGasUsedPlaceholder(&types.Header{Number: big.NewInt(100)})
- require.NotNil(t, got)
- require.Equal(t, uint64(0), *got)
+ // Post-fork: both non-nil zero so the header is valid even before the
+ // miner writes the real values.
+ gasUsed, capacity = b.reservedFieldsPlaceholder(&types.Header{Number: big.NewInt(100)})
+ require.NotNil(t, gasUsed)
+ require.Equal(t, uint64(0), *gasUsed)
+ require.NotNil(t, capacity)
+ require.Equal(t, uint64(0), *capacity)
}
func TestVerifyHeader_ReservedBlockspaceMissingFields(t *testing.T) {
@@ -5975,6 +6047,29 @@ func TestVerifyHeader_ReservedBlockspaceMissingFields(t *testing.T) {
require.ErrorIs(t, err, errMissingReservedBlockspaceFields)
}
+// TestVerifyHeader_ReservedBlockspaceCapacityMissing pins that presence is
+// required for BOTH reserved fields: ReservedGasUsed alone is not enough.
+func TestVerifyHeader_ReservedBlockspaceCapacityMissing(t *testing.T) {
+ t.Parallel()
+ s := newGiuglianoVerifySetup(t, true)
+ s.b.config.ReservedBlockspaceBlock = big.NewInt(0)
+
+ gasTarget := uint64(15_000_000)
+ bfcd := uint64(64)
+ gasUsed := uint64(0)
+ extra := buildBlockExtraBytes(&types.BlockExtraData{
+ GasTarget: &gasTarget,
+ BaseFeeChangeDenominator: &bfcd,
+ ReservedGasUsed: &gasUsed,
+ // ReservedCapacity deliberately absent.
+ })
+ h := s.makeSignedChild(t, extra, big.NewInt(params.InitialBaseFee))
+
+ chain := newRawDBChain(s.db, s.cfg, h, nil, nil)
+ err := s.b.verifyHeader(chain, h, nil)
+ require.ErrorIs(t, err, errMissingReservedBlockspaceFields)
+}
+
func TestVerifyHeader_ReservedBlockspaceFieldsPresent(t *testing.T) {
t.Parallel()
s := newGiuglianoVerifySetup(t, true)
@@ -5983,10 +6078,12 @@ func TestVerifyHeader_ReservedBlockspaceFieldsPresent(t *testing.T) {
gasTarget := uint64(15_000_000)
bfcd := uint64(64)
gasUsed := uint64(0)
+ capacity := uint64(10_000_000)
extra := buildBlockExtraBytes(&types.BlockExtraData{
GasTarget: &gasTarget,
BaseFeeChangeDenominator: &bfcd,
ReservedGasUsed: &gasUsed,
+ ReservedCapacity: &capacity,
})
h := s.makeSignedChild(t, extra, big.NewInt(params.InitialBaseFee))
@@ -6006,10 +6103,12 @@ func TestVerifyReservedFields_GasUsedBound(t *testing.T) {
gasTarget := uint64(15_000_000)
bfcd := uint64(64)
reservedGasUsed := uint64(5_000)
+ capacity := uint64(10_000_000)
extra := buildBlockExtraBytes(&types.BlockExtraData{
GasTarget: &gasTarget,
BaseFeeChangeDenominator: &bfcd,
ReservedGasUsed: &reservedGasUsed,
+ ReservedCapacity: &capacity,
})
// Reserved gas used exceeds the block's gas used — impossible, must be rejected.
@@ -6021,6 +6120,30 @@ func TestVerifyReservedFields_GasUsedBound(t *testing.T) {
require.NoError(t, s.b.verifyReservedFields(h))
}
+// TestVerifyReservedFields_CapacityAboveGasLimitAccepted pins the liveness
+// rule from §2.2: governance can validly push effective capacity to or past
+// the block gas limit (the two have no protocol tie), so verifyReservedFields
+// must accept it — rejecting it at the header would leave no valid next block.
+func TestVerifyReservedFields_CapacityAboveGasLimitAccepted(t *testing.T) {
+ t.Parallel()
+ s := newGiuglianoVerifySetup(t, true)
+ s.b.config.ReservedBlockspaceBlock = big.NewInt(0)
+
+ gasTarget := uint64(15_000_000)
+ bfcd := uint64(64)
+ reservedGasUsed := uint64(0)
+ capacity := uint64(60_000_000) // above the block's gas limit
+ extra := buildBlockExtraBytes(&types.BlockExtraData{
+ GasTarget: &gasTarget,
+ BaseFeeChangeDenominator: &bfcd,
+ ReservedGasUsed: &reservedGasUsed,
+ ReservedCapacity: &capacity,
+ })
+
+ h := &types.Header{Number: big.NewInt(1), GasLimit: 30_000_000, GasUsed: 0, Extra: extra}
+ require.NoError(t, s.b.verifyReservedFields(h))
+}
+
// TestApplyMessage_StateSyncTxContext validates if TxContext is correctly
// set for state-sync transactions.
func TestApplyMessage_StateSyncTxContext(t *testing.T) {
diff --git a/consensus/bor/contract/registrytest/harness.go b/consensus/bor/contract/registrytest/harness.go
index 512f05831a..6c057bf2cb 100644
--- a/consensus/bor/contract/registrytest/harness.go
+++ b/consensus/bor/contract/registrytest/harness.go
@@ -39,6 +39,10 @@ type Harness struct {
Reader registryreader.Reader
ReservedAddr common.Address
UnreservedAddr common.Address
+
+ reader *evmReader
+ owner common.Address
+ writeAbi abi.ABI
}
// NewHarness deploys the registry, initializes it under a fresh owner, and
@@ -49,6 +53,17 @@ func NewHarness(t *testing.T) *Harness {
statedb, err := state.New(types.EmptyRootHash, state.NewDatabaseForTesting())
require.NoError(t, err)
+ return NewHarnessOn(t, statedb)
+}
+
+// NewHarnessOn deploys the registry into the caller's statedb, which may be
+// backed by a real, committable trie database (the witness-completeness tests
+// need the registry state in a committed trie). The returned Reader runs each
+// call against the statedb that call receives, falling back to this one when
+// the call passes nil.
+func NewHarnessOn(t *testing.T, statedb *state.StateDB) *Harness {
+ t.Helper()
+
contractAddr := common.HexToAddress(params.DefaultReservedRegistryContract)
owner := common.HexToAddress("0x00000000000000000000000000000000000000aa")
clientAdmin := common.HexToAddress("0x00000000000000000000000000000000000000bb")
@@ -71,17 +86,32 @@ func NewHarness(t *testing.T) *Harness {
require.NoError(t, err)
callContract(t, statedb, owner, contractAddr, createData)
+ r := &evmReader{
+ state: statedb,
+ contract: contractAddr,
+ readerAB: borabi.ReservedBlockspaceRegistry(),
+ }
+
return &Harness{
- Reader: &evmReader{
- state: statedb,
- contract: contractAddr,
- readerAB: borabi.ReservedBlockspaceRegistry(),
- },
+ Reader: r,
ReservedAddr: reserved,
UnreservedAddr: unreserved,
+ reader: r,
+ owner: owner,
+ writeAbi: writeAbi,
}
}
+// CreateClient registers another client with the given fee mode and whitelisted
+// addresses, for tests exercising registry states beyond NewHarness's default
+// single free-mode client.
+func (h *Harness) CreateClient(t *testing.T, admin common.Address, gasQuota uint64, feeMode uint8, addresses []common.Address) {
+ t.Helper()
+ createData, err := h.writeAbi.Pack("createClient", admin, gasQuota, feeMode, uint64(0), "test", addresses)
+ require.NoError(t, err)
+ callContract(t, h.reader.state, h.owner, h.reader.contract, createData)
+}
+
func callContract(t *testing.T, statedb *state.StateDB, from, to common.Address, data []byte) {
t.Helper()
evm := newEVM(statedb, from)
@@ -150,8 +180,8 @@ func (r *evmReader) HasReservedRegistry() bool {
return r != nil && r.contract != (common.Address{})
}
-func (r *evmReader) IsReservedAddress(_ *state.StateDB, _ uint64, _ common.Hash, account common.Address) (bool, error) {
- values, err := r.call("isReservedAddress", account)
+func (r *evmReader) IsReservedAddress(statedb *state.StateDB, _ uint64, _ common.Hash, account common.Address) (bool, error) {
+ values, err := r.call(statedb, "isReservedAddress", account)
if err != nil {
return false, err
}
@@ -165,8 +195,8 @@ func (r *evmReader) IsReservedAddress(_ *state.StateDB, _ uint64, _ common.Hash,
return reserved, nil
}
-func (r *evmReader) ReservedClientForAddress(_ *state.StateDB, _ uint64, _ common.Hash, account common.Address) (registryreader.ClientLookup, error) {
- values, err := r.call("getClientForAddress", account)
+func (r *evmReader) ReservedClientForAddress(statedb *state.StateDB, _ uint64, _ common.Hash, account common.Address) (registryreader.ClientLookup, error) {
+ values, err := r.call(statedb, "getClientForAddress", account)
if err != nil {
return registryreader.ClientLookup{}, err
}
@@ -192,8 +222,8 @@ func (r *evmReader) ReservedClientForAddress(_ *state.StateDB, _ uint64, _ commo
}, nil
}
-func (r *evmReader) Root(_ *state.StateDB, _ uint64, _ common.Hash) (common.Hash, error) {
- values, err := r.call("root")
+func (r *evmReader) Root(statedb *state.StateDB, _ uint64, _ common.Hash) (common.Hash, error) {
+ values, err := r.call(statedb, "root")
if err != nil {
return common.Hash{}, err
}
@@ -207,8 +237,8 @@ func (r *evmReader) Root(_ *state.StateDB, _ uint64, _ common.Hash) (common.Hash
return common.Hash(root), nil
}
-func (r *evmReader) WhitelistedAddresses(_ *state.StateDB, _ uint64, _ common.Hash) ([]common.Address, error) {
- values, err := r.call("getWhitelistedAddresses")
+func (r *evmReader) WhitelistedAddresses(statedb *state.StateDB, _ uint64, _ common.Hash) ([]common.Address, error) {
+ values, err := r.call(statedb, "getWhitelistedAddresses")
if err != nil {
return nil, err
}
@@ -222,8 +252,8 @@ func (r *evmReader) WhitelistedAddresses(_ *state.StateDB, _ uint64, _ common.Ha
return addrs, nil
}
-func (r *evmReader) TotalReservedGas(_ *state.StateDB, _ uint64, _ common.Hash) (uint64, error) {
- values, err := r.call("totalReservedGas")
+func (r *evmReader) TotalReservedGas(statedb *state.StateDB, _ uint64, _ common.Hash) (uint64, error) {
+ values, err := r.call(statedb, "totalReservedGas")
if err != nil {
return 0, err
}
@@ -237,21 +267,30 @@ func (r *evmReader) TotalReservedGas(_ *state.StateDB, _ uint64, _ common.Hash)
return total, nil
}
-func (r *evmReader) call(method string, args ...interface{}) ([]interface{}, error) {
+func (r *evmReader) call(statedb *state.StateDB, method string, args ...interface{}) ([]interface{}, error) {
+ if statedb == nil {
+ statedb = r.state
+ }
data, err := r.readerAB.Pack(method, args...)
if err != nil {
return nil, err
}
// View methods don't mutate state, but the EVM still consumes
// finalisation cycles; snapshot/revert keeps the harness DB pristine.
- snap := r.state.Snapshot()
- defer r.state.RevertToSnapshot(snap)
+ snap := statedb.Snapshot()
+ defer statedb.RevertToSnapshot(snap)
caller := common.HexToAddress("0x00000000000000000000000000000000deadbeef")
- evm := newEVM(r.state, caller)
+ evm := newEVM(statedb, caller)
ret, _, err := evm.Call(caller, r.contract, data, 30_000_000, uint256.NewInt(0))
if err != nil {
return nil, err
}
+ if err := statedb.Error(); err != nil {
+ // A read that fell off the backing trie (e.g. a witness-backed state
+ // missing a node) is recorded on the statedb, not returned by the EVM;
+ // surface it so snapshot builds fail loudly like the production reader.
+ return nil, err
+ }
return r.readerAB.Unpack(method, ret)
}
diff --git a/consensus/bor/contract/registrytest/harness_test.go b/consensus/bor/contract/registrytest/harness_test.go
index 076c4b0da1..8608edfb95 100644
--- a/consensus/bor/contract/registrytest/harness_test.go
+++ b/consensus/bor/contract/registrytest/harness_test.go
@@ -40,9 +40,8 @@ func TestSnapshot_BuildsFromRegistry(t *testing.T) {
require.True(t, snap.IsReserved(h.ReservedAddr))
require.False(t, snap.IsReserved(h.UnreservedAddr))
- // Snapshot mirrors the registry: feeMode free (0), capacity = the one
- // client's quota, and a non-zero root it can be cached against.
- require.Equal(t, uint8(0), snap.FeeMode(h.ReservedAddr))
+ // Snapshot mirrors the registry: capacity = the one client's quota, and a
+ // non-zero root it can be cached against.
require.Equal(t, uint64(10_000_000), snap.Capacity())
require.NotEqual(t, common.Hash{}, snap.Root())
@@ -51,3 +50,24 @@ func TestSnapshot_BuildsFromRegistry(t *testing.T) {
require.False(t, none.IsReserved(h.ReservedAddr))
require.Equal(t, uint64(0), none.Capacity())
}
+
+// TestSnapshot_ExcludesRoutedFeeModeClient pins the fee-mode gate against the
+// real registry bytecode: a feeMode 1 client counts toward the contract's raw
+// totalReservedGas but never enters the effective set (see
+// registryreader.FeeModeFree).
+func TestSnapshot_ExcludesRoutedFeeModeClient(t *testing.T) {
+ h := NewHarness(t)
+
+ routedAdmin := common.HexToAddress("0x00000000000000000000000000000000000000ee")
+ routedSender := common.HexToAddress("0x00000000000000000000000000000000000000ff")
+ h.CreateClient(t, routedAdmin, 5_000_000, 1, []common.Address{routedSender})
+
+ snap, err := registryreader.BuildSnapshot(h.Reader, nil, 1, common.Hash{}, 1)
+ require.NoError(t, err)
+ require.NotNil(t, snap)
+
+ require.True(t, snap.IsReserved(h.ReservedAddr), "free-mode client stays reserved")
+ require.False(t, snap.IsReserved(routedSender), "routed-mode client must not classify reserved")
+ require.Equal(t, uint64(15_000_000), snap.Capacity(), "raw totalReservedGas counts both clients")
+ require.Equal(t, uint64(10_000_000), snap.EffectiveCapacity(), "effective capacity excludes the routed client")
+}
diff --git a/consensus/bor/contract/registrytest/witness_test.go b/consensus/bor/contract/registrytest/witness_test.go
new file mode 100644
index 0000000000..eeb1db4e52
--- /dev/null
+++ b/consensus/bor/contract/registrytest/witness_test.go
@@ -0,0 +1,100 @@
+package registrytest
+
+import (
+ "math/big"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/ethereum/go-ethereum/common"
+ "github.com/ethereum/go-ethereum/consensus/bor/registryreader"
+ "github.com/ethereum/go-ethereum/core/rawdb"
+ "github.com/ethereum/go-ethereum/core/state"
+ "github.com/ethereum/go-ethereum/core/stateless"
+ "github.com/ethereum/go-ethereum/core/types"
+ "github.com/ethereum/go-ethereum/crypto"
+ "github.com/ethereum/go-ethereum/ethdb"
+ "github.com/ethereum/go-ethereum/params"
+ "github.com/ethereum/go-ethereum/triedb"
+)
+
+// diskWithRegistryCode returns a fresh disk database holding only the registry
+// bytecode, modeling a stateless node's local code store.
+func diskWithRegistryCode(t *testing.T) ethdb.Database {
+ t.Helper()
+ disk := rawdb.NewMemoryDatabase()
+ code := common.FromHex(params.ReservedBlockspaceRegistryCode)
+ rawdb.WriteCode(disk, crypto.Keccak256Hash(code), code)
+ return disk
+}
+
+// TestWitness_SelfSufficientForSnapshotRebuild pins the witness-completeness
+// contract for reserved blockspace: a witness produced while building the
+// registry snapshot must carry every trie node a stateless verifier needs to
+// rebuild that same snapshot, even when the block's own transactions never
+// touch the registry (BuildSnapshot's reads run against a throwaway state
+// copy, so nothing else would put those nodes into the witness). The consumer
+// side below is exactly ExecuteStateless's setup: a state backed only by the
+// witness's node set, with an empty disk behind it.
+func TestWitness_SelfSufficientForSnapshotRebuild(t *testing.T) {
+ // Deploy the real registry bytecode into a committable trie database and
+ // commit it, so the registry state lives in a real MPT a witness can prove.
+ disk := rawdb.NewMemoryDatabase()
+ tdb := triedb.NewDatabase(disk, triedb.HashDefaults)
+ sdb := state.NewDatabase(tdb, nil)
+ st, err := state.New(types.EmptyRootHash, sdb)
+ require.NoError(t, err)
+
+ h := NewHarnessOn(t, st)
+
+ root, err := st.Commit(0, true, false)
+ require.NoError(t, err)
+ require.NoError(t, tdb.Commit(root, false))
+
+ parent := &types.Header{Number: big.NewInt(0), Root: root}
+ context := &types.Header{Number: big.NewInt(1), ParentHash: parent.Hash()}
+ headers := stateless.NewMockHeaderReader()
+ headers.AddHeader(parent)
+
+ // Producer: a witness-attached state at the committed root, mirroring how
+ // block processing attaches the block witness before the snapshot build.
+ prod, err := state.New(root, sdb)
+ require.NoError(t, err)
+ witness, err := stateless.NewWitness(context, headers)
+ require.NoError(t, err)
+ prod.StartPrefetcher("chain", witness, nil)
+ defer prod.StopPrefetcher()
+
+ snap, err := registryreader.BuildSnapshot(h.Reader, prod, 0, parent.Hash(), 1)
+ require.NoError(t, err)
+ require.True(t, snap.IsReserved(h.ReservedAddr))
+
+ // Consumer: rebuild the snapshot from the witness alone. The disk behind
+ // MakeHashDB carries only the registry bytecode - the Bor witness wire
+ // format excludes code (stateless nodes source it from genesis alloc and
+ // bytecode sync) - so every trie node must come from the witness. A missing
+ // one fails the build, which is the chain-wedging failure mode on stateless
+ // nodes.
+ memdb := witness.MakeHashDB(diskWithRegistryCode(t))
+ cons, err := state.New(root, state.NewDatabase(triedb.NewDatabase(memdb, triedb.HashDefaults), nil))
+ require.NoError(t, err)
+
+ rebuilt, err := registryreader.BuildSnapshot(h.Reader, cons, 0, parent.Hash(), 1)
+ require.NoError(t, err, "witness must be self-sufficient for the reserved snapshot rebuild")
+ require.Equal(t, snap.Root(), rebuilt.Root())
+ require.Equal(t, snap.Capacity(), rebuilt.Capacity())
+ require.Equal(t, snap.EffectiveCapacity(), rebuilt.EffectiveCapacity())
+ require.True(t, rebuilt.IsReserved(h.ReservedAddr))
+ require.False(t, rebuilt.IsReserved(h.UnreservedAddr))
+
+ // Control: a witness the snapshot build never collected into cannot serve
+ // the rebuild - pins that the positive case above is not vacuous.
+ empty, err := stateless.NewWitness(context, headers)
+ require.NoError(t, err)
+ emptyDB := empty.MakeHashDB(diskWithRegistryCode(t))
+ starved, err := state.New(root, state.NewDatabase(triedb.NewDatabase(emptyDB, triedb.HashDefaults), nil))
+ if err == nil {
+ _, err = registryreader.BuildSnapshot(h.Reader, starved, 0, parent.Hash(), 1)
+ }
+ require.Error(t, err, "an uncollected witness must not be able to serve the rebuild")
+}
diff --git a/consensus/bor/registryreader/classify.go b/consensus/bor/registryreader/classify.go
index b1dd66c5b6..a6291bb5bc 100644
--- a/consensus/bor/registryreader/classify.go
+++ b/consensus/bor/registryreader/classify.go
@@ -116,27 +116,50 @@ func (w *ReservedWalk) Reserved(from common.Address, gas uint64) bool {
return reserved
}
-// ClassifyReserved returns the set of transactions in txs (in final block order)
-// that are reserved — i.e. execute fee-free — under snap, keyed by (sender,
-// nonce). It runs a single ReservedWalk over the block, so a verifier and the
-// producer (which advances the same walk as it commits) derive the identical
-// split by construction. A nil or empty snapshot classifies nothing.
+// ClientUsage reports one registry client's reserved-region gas usage for a
+// single block, alongside its quota. Used is declared gas (tx.Gas()) summed
+// over that client's committed reserved transactions - the same basis
+// ReservedWalk.Peek/Commit charges quota against, not the executed gas a
+// receipt reports afterward.
+type ClientUsage struct {
+ Used uint64
+ Quota uint64
+}
+
+// ClassifyReserved returns the set of transactions in txs (in final block
+// order) that are reserved - i.e. execute fee-free - under snap, keyed by
+// (sender, nonce), together with per-client usage for every client in snap's
+// effective set (idle clients included, with Used 0). It runs a single
+// ReservedWalk over the block, so a verifier and the producer (which advances
+// the same walk as it commits) derive the identical split by construction. A
+// nil or empty snapshot classifies nothing and returns (nil, nil). An empty
+// txs classifies nothing but still reports every effective client at zero
+// usage, so per-block gauges built from the map reset on empty blocks instead
+// of holding the previous block's values.
//
// signer must be the same signer execution uses, so sender recovery agrees.
-func ClassifyReserved(txs []*types.Transaction, signer types.Signer, snap *Snapshot) map[ReservedKey]struct{} {
- if snap == nil || len(snap.clientIDs) == 0 || len(txs) == 0 {
- return nil
+func ClassifyReserved(txs []*types.Transaction, signer types.Signer, snap *Snapshot) (map[ReservedKey]struct{}, map[uint64]ClientUsage) {
+ if snap == nil || len(snap.clientIDs) == 0 {
+ return nil, nil
}
walk := NewReservedWalk(snap)
- reserved := make(map[ReservedKey]struct{})
- for _, tx := range txs {
- from, err := types.Sender(signer, tx)
- if err != nil {
- continue
- }
- if walk.Reserved(from, tx.Gas()) {
- reserved[ReservedKey{From: from, Nonce: tx.Nonce()}] = struct{}{}
+ var reserved map[ReservedKey]struct{}
+ if len(txs) > 0 {
+ reserved = make(map[ReservedKey]struct{})
+ for _, tx := range txs {
+ from, err := types.Sender(signer, tx)
+ if err != nil {
+ continue
+ }
+ if walk.Reserved(from, tx.Gas()) {
+ reserved[ReservedKey{From: from, Nonce: tx.Nonce()}] = struct{}{}
+ }
}
}
- return reserved
+ clients := snap.Clients()
+ usage := make(map[uint64]ClientUsage, len(clients))
+ for _, id := range clients {
+ usage[id] = ClientUsage{Used: walk.used[id], Quota: snap.Quota(id)}
+ }
+ return reserved, usage
}
diff --git a/consensus/bor/registryreader/classify_test.go b/consensus/bor/registryreader/classify_test.go
index 26b42e7e61..336c55d85b 100644
--- a/consensus/bor/registryreader/classify_test.go
+++ b/consensus/bor/registryreader/classify_test.go
@@ -65,26 +65,42 @@ func TestClassifyReserved(t *testing.T) {
t.Run("nil snapshot classifies nothing", func(t *testing.T) {
t.Parallel()
- require.Nil(t, ClassifyReserved([]*types.Transaction{signedTx(t, keyA1, 0, 21000)}, signer, nil))
+ got, usage := ClassifyReserved([]*types.Transaction{signedTx(t, keyA1, 0, 21000)}, signer, nil)
+ require.Nil(t, got)
+ require.Nil(t, usage)
})
t.Run("empty snapshot classifies nothing", func(t *testing.T) {
t.Parallel()
empty := NewSnapshot(common.Hash{}, 0, map[common.Address]Client{})
- require.Nil(t, ClassifyReserved([]*types.Transaction{signedTx(t, keyA1, 0, 21000)}, signer, empty))
+ got, usage := ClassifyReserved([]*types.Transaction{signedTx(t, keyA1, 0, 21000)}, signer, empty)
+ require.Nil(t, got)
+ require.Nil(t, usage)
+ })
+
+ t.Run("empty txs still reports zero usage per client", func(t *testing.T) {
+ t.Parallel()
+ snap := snapOf(1_500_000, map[uint64][]common.Address{1: {a1}, 2: {b1}}, map[uint64]uint64{1: 1_000_000, 2: 500_000})
+ got, usage := ClassifyReserved(nil, signer, snap)
+ require.Nil(t, got)
+ require.Equal(t, map[uint64]ClientUsage{
+ 1: {Used: 0, Quota: 1_000_000},
+ 2: {Used: 0, Quota: 500_000},
+ }, usage)
})
t.Run("no registered senders present", func(t *testing.T) {
t.Parallel()
snap := snapOf(1_000_000, map[uint64][]common.Address{1: {a1}}, map[uint64]uint64{1: 1_000_000})
- require.Empty(t, ClassifyReserved([]*types.Transaction{signedTx(t, keyX, 0, 21000)}, signer, snap))
+ got, _ := ClassifyReserved([]*types.Transaction{signedTx(t, keyX, 0, 21000)}, signer, snap)
+ require.Empty(t, got)
})
t.Run("all under quota are reserved", func(t *testing.T) {
t.Parallel()
snap := snapOf(1_000_000, map[uint64][]common.Address{1: {a1}}, map[uint64]uint64{1: 1_000_000})
txs := []*types.Transaction{signedTx(t, keyA1, 0, 100), signedTx(t, keyA1, 1, 100)}
- got := ClassifyReserved(txs, signer, snap)
+ got, _ := ClassifyReserved(txs, signer, snap)
require.Len(t, got, 2)
require.True(t, has(got, a1, 0))
require.True(t, has(got, a1, 1))
@@ -98,7 +114,7 @@ func TestClassifyReserved(t *testing.T) {
signedTx(t, keyA1, 1, 600),
signedTx(t, keyA1, 2, 600),
}
- got := ClassifyReserved(txs, signer, snap)
+ got, _ := ClassifyReserved(txs, signer, snap)
require.Len(t, got, 1)
require.True(t, has(got, a1, 0))
require.False(t, has(got, a1, 1))
@@ -108,14 +124,15 @@ func TestClassifyReserved(t *testing.T) {
t.Run("zero quota overflows everything", func(t *testing.T) {
t.Parallel()
snap := snapOf(0, map[uint64][]common.Address{1: {a1}}, map[uint64]uint64{1: 0})
- require.Empty(t, ClassifyReserved([]*types.Transaction{signedTx(t, keyA1, 0, 1)}, signer, snap))
+ got, _ := ClassifyReserved([]*types.Transaction{signedTx(t, keyA1, 0, 1)}, signer, snap)
+ require.Empty(t, got)
})
t.Run("non-registered sender txs stay normal", func(t *testing.T) {
t.Parallel()
snap := snapOf(1_000_000, map[uint64][]common.Address{1: {a1}}, map[uint64]uint64{1: 1_000_000})
txs := []*types.Transaction{signedTx(t, keyA1, 0, 100), signedTx(t, keyX, 0, 100)}
- got := ClassifyReserved(txs, signer, snap)
+ got, _ := ClassifyReserved(txs, signer, snap)
require.Len(t, got, 1)
require.True(t, has(got, a1, 0))
})
@@ -123,12 +140,12 @@ func TestClassifyReserved(t *testing.T) {
t.Run("per-client quota is independent", func(t *testing.T) {
t.Parallel()
// Two clients, each quota 100, capacity = sum (200, the registry
- // invariant). Both single 100-gas txs are reserved — no global ceiling.
+ // invariant). Both single 100-gas txs are reserved - no global ceiling.
snap := snapOf(200,
map[uint64][]common.Address{1: {a1}, 2: {b1}},
map[uint64]uint64{1: 100, 2: 100})
txs := []*types.Transaction{signedTx(t, keyA1, 0, 100), signedTx(t, keyB1, 0, 100)}
- got := ClassifyReserved(txs, signer, snap)
+ got, _ := ClassifyReserved(txs, signer, snap)
require.Len(t, got, 2)
require.True(t, has(got, a1, 0))
require.True(t, has(got, b1, 0))
@@ -139,8 +156,8 @@ func TestClassifyReserved(t *testing.T) {
snap := snapOf(200,
map[uint64][]common.Address{1: {a1}, 2: {b1}},
map[uint64]uint64{1: 100, 2: 100})
- ab := ClassifyReserved([]*types.Transaction{signedTx(t, keyA1, 0, 100), signedTx(t, keyB1, 0, 100)}, signer, snap)
- ba := ClassifyReserved([]*types.Transaction{signedTx(t, keyB1, 0, 100), signedTx(t, keyA1, 0, 100)}, signer, snap)
+ ab, _ := ClassifyReserved([]*types.Transaction{signedTx(t, keyA1, 0, 100), signedTx(t, keyB1, 0, 100)}, signer, snap)
+ ba, _ := ClassifyReserved([]*types.Transaction{signedTx(t, keyB1, 0, 100), signedTx(t, keyA1, 0, 100)}, signer, snap)
require.Len(t, ab, 2)
require.Len(t, ba, 2)
require.Equal(t, ab, ba)
@@ -155,15 +172,46 @@ func TestClassifyReserved(t *testing.T) {
// identical walk over committed txs, so it reaches the same conclusion.
snap := snapOf(100, map[uint64][]common.Address{1: {a1, a2}}, map[uint64]uint64{1: 100})
- withBoth := ClassifyReserved([]*types.Transaction{signedTx(t, keyA1, 0, 100), signedTx(t, keyA2, 0, 100)}, signer, snap)
+ withBoth, _ := ClassifyReserved([]*types.Transaction{signedTx(t, keyA1, 0, 100), signedTx(t, keyA2, 0, 100)}, signer, snap)
require.Len(t, withBoth, 1)
require.True(t, has(withBoth, a1, 0), "first tx wins the shared client quota")
require.False(t, has(withBoth, a2, 0))
- withoutFirst := ClassifyReserved([]*types.Transaction{signedTx(t, keyA2, 0, 100)}, signer, snap)
+ withoutFirst, _ := ClassifyReserved([]*types.Transaction{signedTx(t, keyA2, 0, 100)}, signer, snap)
require.Len(t, withoutFirst, 1)
require.True(t, has(withoutFirst, a2, 0), "freed quota reclassifies the survivor")
})
+
+ t.Run("usage attributes declared gas per client across multiple clients", func(t *testing.T) {
+ t.Parallel()
+ snap := snapOf(300,
+ map[uint64][]common.Address{1: {a1}, 2: {b1}},
+ map[uint64]uint64{1: 100, 2: 200})
+ txs := []*types.Transaction{signedTx(t, keyA1, 0, 60), signedTx(t, keyB1, 0, 150)}
+ _, usage := ClassifyReserved(txs, signer, snap)
+ require.Equal(t, ClientUsage{Used: 60, Quota: 100}, usage[1])
+ require.Equal(t, ClientUsage{Used: 150, Quota: 200}, usage[2])
+ })
+
+ t.Run("an overflowed transaction's declared gas is not counted toward usage", func(t *testing.T) {
+ t.Parallel()
+ snap := snapOf(100, map[uint64][]common.Address{1: {a1}}, map[uint64]uint64{1: 100})
+ txs := []*types.Transaction{signedTx(t, keyA1, 0, 60), signedTx(t, keyA1, 1, 60)}
+ got, usage := ClassifyReserved(txs, signer, snap)
+ require.Len(t, got, 1, "only the first tx fits under quota")
+ require.Equal(t, ClientUsage{Used: 60, Quota: 100}, usage[1], "the breaching second tx must not add to Used")
+ })
+
+ t.Run("an idle client with no transactions in the block reports zero Used", func(t *testing.T) {
+ t.Parallel()
+ snap := snapOf(150,
+ map[uint64][]common.Address{1: {a1}, 2: {b1}},
+ map[uint64]uint64{1: 100, 2: 50})
+ txs := []*types.Transaction{signedTx(t, keyA1, 0, 50)}
+ _, usage := ClassifyReserved(txs, signer, snap)
+ require.Equal(t, ClientUsage{Used: 50, Quota: 100}, usage[1])
+ require.Equal(t, ClientUsage{Used: 0, Quota: 50}, usage[2], "idle client still appears, with zero Used")
+ })
}
// TestClassifyReservedMatrix is the exhaustive case matrix: 1..n clients, in
@@ -283,7 +331,7 @@ func TestClassifyReservedMatrix(t *testing.T) {
block = append(block, signedTx(t, keys[x.who], x.nonce, x.gas))
}
- got := ClassifyReserved(block, signer, snap)
+ got, _ := ClassifyReserved(block, signer, snap)
require.Len(t, got, len(tc.want), "reserved count")
for _, w := range tc.want {
_, ok := got[ReservedKey{From: addrs[w.who], Nonce: w.nonce}]
diff --git a/consensus/bor/registryreader/reader.go b/consensus/bor/registryreader/reader.go
index 66eacd242a..734f1e0b51 100644
--- a/consensus/bor/registryreader/reader.go
+++ b/consensus/bor/registryreader/reader.go
@@ -14,6 +14,14 @@ import (
"github.com/ethereum/go-ethereum/core/state"
)
+// FeeModeFree is the only fee mode the protocol acts on: reserved transactions
+// from a free-mode client pay zero in-protocol fee. The registry also defines
+// feeMode 1 ("routed": fee paid, credited to the producer), reserved for a
+// future external-block-producer world and not implemented - clients with any
+// non-free fee mode are excluded from the effective set at snapshot build, so
+// their senders pay standard fees like normal transactions.
+const FeeModeFree uint8 = 0
+
// ClientLookup mirrors the slim "client for address" view returned by the
// registry contract. Defined here (not in consensus/bor/contract) so the
// interface is self-contained in this leaf package.
@@ -22,7 +30,7 @@ type ClientLookup struct {
GasQuota uint64
Admin common.Address
Active bool
- // FeeMode: 0 = free (zero in-protocol fee), 1 = routed (fee credited to the producer).
+ // FeeMode: see FeeModeFree. Only free-mode clients enter the effective set.
FeeMode uint8
// EffectiveFrom: block from which the client's reserved status applies.
// Callers gate on Active && EffectiveFrom <= number.
@@ -48,16 +56,14 @@ type Reader interface {
// Client is the slim per-sender record a Snapshot stores: just what the hot
// classification and sequencing paths need. Activation state (active,
-// effectiveFrom) is resolved at snapshot build time, so it never appears here.
+// effectiveFrom, feeMode) is resolved at snapshot build time, so it never
+// appears here: every stored client is active, effective, and free-mode.
type Client struct {
// ID is the registry contract's incremental clientId.
ID uint64
// GasQuota is the client's per-block reserved gas allowance, charged
// against declared transaction gas limits.
GasQuota uint64
- // FeeMode: 0 = free (zero in-protocol fee), 1 = routed (fee credited to
- // the producer; reserved for a future mode, unused today).
- FeeMode uint8
}
// Snapshot is an immutable, pure-lookup view of the reserved set effective for
@@ -68,11 +74,19 @@ type Client struct {
// per block (gated on the fork height). A nil *Snapshot classifies nothing
// (no registry / non-bor chain), so all methods are nil-safe.
type Snapshot struct {
- root common.Hash
- capacity uint64
- byAddress map[common.Address]Client
- clientIDs []uint64
- quotas map[uint64]uint64
+ root common.Hash
+ capacity uint64
+ // effectiveCapacity is Σ over quotas (the effective client set for this
+ // block), distinct from capacity (the contract's raw totalReservedGas,
+ // which createClient bumps immediately even for a client whose
+ // effectiveFrom is still in the future). The base fee's reserved carve-out
+ // is priced against effectiveCapacity: it equals exactly what this
+ // snapshot's classification walk can admit, so the per-client-only quota
+ // rule can never admit more reserved gas than the carve-out accounts for.
+ effectiveCapacity uint64
+ byAddress map[common.Address]Client
+ clientIDs []uint64
+ quotas map[uint64]uint64
}
// BuildSnapshot reads the full active reserved set from the registry at the
@@ -83,13 +97,31 @@ func BuildSnapshot(r Reader, statedb *state.StateDB, number uint64, hash common.
if r == nil || !r.HasReservedRegistry() {
return nil, nil
}
+ if statedb == nil {
+ return readSnapshot(r, nil, number, hash, effectiveAt)
+ }
// Registry reads run through the EVM, which mutates the statedb it executes
- // against. On the execution path the caller passes the live block state, so
- // read against a throwaway copy to keep the build state-neutral — reading
- // against the live state would leak into the block and change the post-state.
- if statedb != nil {
- statedb = statedb.Copy()
+ // against, so on the execution path they run isolated from the live block
+ // state. The isolation must not drop them from a witness being produced: a
+ // stateless verifier rebuilds this snapshot from the witness before
+ // executing the block, and the block's own transactions don't necessarily
+ // touch the registry (the first transaction-free block after a registry
+ // change wouldn't).
+ var snap *Snapshot
+ err := statedb.ReadIsolated(func(tmp *state.StateDB) error {
+ var err error
+ snap, err = readSnapshot(r, tmp, number, hash, effectiveAt)
+ return err
+ })
+ if err != nil {
+ return nil, err
}
+ return snap, nil
+}
+
+// readSnapshot performs the registry reads against statedb and assembles the
+// snapshot.
+func readSnapshot(r Reader, statedb *state.StateDB, number uint64, hash common.Hash, effectiveAt uint64) (*Snapshot, error) {
root, err := r.Root(statedb, number, hash)
if err != nil {
return nil, err
@@ -113,7 +145,8 @@ func BuildSnapshot(r Reader, statedb *state.StateDB, number uint64, hash common.
}
// resolveClients reads each whitelisted address's client record and keeps only
-// those effective for effectiveAt (active and past their effectiveFrom delay).
+// those effective for effectiveAt: active, past their effectiveFrom delay, and
+// in free fee mode (see FeeModeFree for why non-free modes are excluded).
func resolveClients(r Reader, statedb *state.StateDB, number uint64, hash common.Hash, addrs []common.Address, effectiveAt uint64) (map[common.Address]Client, error) {
clients := make(map[common.Address]Client, len(addrs))
for _, a := range addrs {
@@ -121,13 +154,13 @@ func resolveClients(r Reader, statedb *state.StateDB, number uint64, hash common
if err != nil {
return nil, err
}
- if !c.Active || c.EffectiveFrom > effectiveAt {
+ if !c.Active || c.EffectiveFrom > effectiveAt || c.FeeMode != FeeModeFree {
continue
}
if c.ClientID == nil || !c.ClientID.IsUint64() {
return nil, fmt.Errorf("reserved registry returned invalid client id %v for %s", c.ClientID, a)
}
- clients[a] = Client{ID: c.ClientID.Uint64(), GasQuota: c.GasQuota, FeeMode: c.FeeMode}
+ clients[a] = Client{ID: c.ClientID.Uint64(), GasQuota: c.GasQuota}
}
return clients, nil
}
@@ -163,11 +196,20 @@ func NewSnapshot(root common.Hash, capacity uint64, clients map[common.Address]C
quotas[c.ID] = c.GasQuota
}
ids := make([]uint64, 0, len(quotas))
- for id := range quotas {
+ var effectiveCapacity uint64
+ for id, q := range quotas {
ids = append(ids, id)
+ effectiveCapacity += q
}
slices.Sort(ids)
- return &Snapshot{root: root, capacity: capacity, byAddress: clients, clientIDs: ids, quotas: quotas}
+ return &Snapshot{
+ root: root,
+ capacity: capacity,
+ effectiveCapacity: effectiveCapacity,
+ byAddress: clients,
+ clientIDs: ids,
+ quotas: quotas,
+ }
}
// Root is the registry root this snapshot was built at; callers reuse the
@@ -216,18 +258,26 @@ func (s *Snapshot) Clients() []uint64 {
return slices.Clone(s.clientIDs)
}
-// FeeMode returns the fee mode of account's client (0 = free) or 0 if not reserved.
-func (s *Snapshot) FeeMode(account common.Address) uint8 {
+// Capacity returns the registry's raw totalReservedGas: the sum of active
+// client quotas, including clients whose effectiveFrom hasn't been reached
+// yet for this snapshot. Used solely for the build-time invariant check
+// against effective quotas; base-fee pricing and header stamping use
+// EffectiveCapacity instead.
+func (s *Snapshot) Capacity() uint64 {
if s == nil {
return 0
}
- return s.byAddress[account].FeeMode
+ return s.capacity
}
-// Capacity returns the reserved capacity (sum of active client quotas).
-func (s *Snapshot) Capacity() uint64 {
+// EffectiveCapacity returns Σ over this snapshot's effective client quotas —
+// exactly what the block's classification walk (ReservedWalk/ClassifyReserved)
+// can admit. This is the value stamped into the header and priced against by
+// the base fee; it excludes clients not yet effective for this block, unlike
+// Capacity.
+func (s *Snapshot) EffectiveCapacity() uint64 {
if s == nil {
return 0
}
- return s.capacity
+ return s.effectiveCapacity
}
diff --git a/consensus/bor/registryreader/reader_test.go b/consensus/bor/registryreader/reader_test.go
index ae44f5d3d0..0138ca10ea 100644
--- a/consensus/bor/registryreader/reader_test.go
+++ b/consensus/bor/registryreader/reader_test.go
@@ -94,6 +94,9 @@ func TestBuildSnapshot(t *testing.T) {
if snap.Capacity() != 60_000_000 {
t.Errorf("capacity=%d, want 60000000", snap.Capacity())
}
+ if snap.EffectiveCapacity() != 60_000_000 {
+ t.Errorf("effectiveCapacity=%d, want 60000000 (no future-effective client in this fixture)", snap.EffectiveCapacity())
+ }
if r.clientCalls != len(r.whitelist) {
t.Errorf("client lookups=%d, want %d", r.clientCalls, len(r.whitelist))
}
@@ -181,6 +184,57 @@ func TestBuildSnapshotEffectiveFiltering(t *testing.T) {
}
}
+// TestBuildSnapshotEffectiveCapacityExcludesFutureClient pins the capacity
+// split from §2.2: the registry's totalReservedGas (Capacity) is bumped by
+// createClient immediately, including for a client whose effectiveFrom is
+// still ahead, while EffectiveCapacity — the value the header stamps — only
+// sums quotas of clients this snapshot actually classifies.
+func TestBuildSnapshotEffectiveCapacityExcludesFutureClient(t *testing.T) {
+ a1, a2 := addr(1), addr(2)
+ r := &mockReader{
+ has: true,
+ root: common.HexToHash("0xabc"),
+ whitelist: []common.Address{a1, a2},
+ totalGas: 50_000_000, // raw total already counts both clients.
+ clients: map[common.Address]ClientLookup{
+ a1: {ClientID: big.NewInt(1), GasQuota: 30_000_000, Active: true},
+ a2: {ClientID: big.NewInt(2), GasQuota: 20_000_000, Active: true, EffectiveFrom: 100},
+ },
+ }
+
+ // Before a2's effectiveFrom: raw capacity still counts it, effective
+ // capacity does not.
+ snap, err := BuildSnapshot(r, nil, 49, common.Hash{}, 50)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := snap.Capacity(); got != 50_000_000 {
+ t.Errorf("Capacity()=%d, want 50000000 (raw total includes the future client)", got)
+ }
+ if got := snap.EffectiveCapacity(); got != 30_000_000 {
+ t.Errorf("EffectiveCapacity()=%d, want 30000000 (future client excluded)", got)
+ }
+ if snap.IsReserved(a2) {
+ t.Error("a2 must not classify as reserved before its effectiveFrom")
+ }
+
+ // At and after the boundary: a2 joins the effective set without any new
+ // registry transaction landing in the block.
+ snap, err = BuildSnapshot(r, nil, 99, common.Hash{}, 100)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := snap.Capacity(); got != 50_000_000 {
+ t.Errorf("Capacity()=%d, want 50000000", got)
+ }
+ if got := snap.EffectiveCapacity(); got != 50_000_000 {
+ t.Errorf("EffectiveCapacity()=%d, want 50000000 (future client now effective)", got)
+ }
+ if !snap.IsReserved(a2) {
+ t.Error("a2 must classify as reserved at its effectiveFrom boundary")
+ }
+}
+
func TestBuildSnapshotRejectsInvalidClientID(t *testing.T) {
a1 := addr(1)
r := &mockReader{
@@ -209,29 +263,54 @@ func TestSnapshotNilSafe(t *testing.T) {
if snap.Clients() != nil {
t.Error("nil snapshot Clients must be nil")
}
- if snap.FeeMode(addr(1)) != 0 {
- t.Error("nil snapshot FeeMode must be 0")
- }
if snap.Capacity() != 0 {
t.Error("nil snapshot Capacity must be 0")
}
+ if snap.EffectiveCapacity() != 0 {
+ t.Error("nil snapshot EffectiveCapacity must be 0")
+ }
if snap.Root() != (common.Hash{}) {
t.Error("nil snapshot Root must be zero hash")
}
}
-func TestSnapshotFeeModeAndCapacity(t *testing.T) {
- a := addr(1)
- snap := NewSnapshot(common.HexToHash("0x2"), 12_345, map[common.Address]Client{
- a: {ID: 1, GasQuota: 12_345, FeeMode: 1},
- })
- if snap.FeeMode(a) != 1 {
- t.Errorf("FeeMode=%d, want 1", snap.FeeMode(a))
+// TestBuildSnapshotExcludesNonFreeFeeMode pins the fee-mode gate: only
+// free-mode (feeMode 0) clients enter the effective set; routed (1) and any
+// future mode classify as normal senders (see FeeModeFree).
+func TestBuildSnapshotExcludesNonFreeFeeMode(t *testing.T) {
+ a1, a2, a3 := addr(1), addr(2), addr(3)
+ r := &mockReader{
+ has: true,
+ root: common.HexToHash("0xabc"),
+ whitelist: []common.Address{a1, a2, a3},
+ totalGas: 60_000_000, // raw total counts every active client, any fee mode.
+ clients: map[common.Address]ClientLookup{
+ a1: {ClientID: big.NewInt(1), GasQuota: 30_000_000, Active: true, FeeMode: FeeModeFree},
+ a2: {ClientID: big.NewInt(2), GasQuota: 20_000_000, Active: true, FeeMode: 1},
+ a3: {ClientID: big.NewInt(3), GasQuota: 10_000_000, Active: true, FeeMode: 7},
+ },
+ }
+
+ snap, err := BuildSnapshot(r, nil, 7, common.Hash{}, 8)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !snap.IsReserved(a1) {
+ t.Error("free-mode client must be reserved")
+ }
+ if snap.IsReserved(a2) {
+ t.Error("routed-mode client must not be reserved")
+ }
+ if snap.IsReserved(a3) {
+ t.Error("unknown-fee-mode client must not be reserved")
+ }
+ if got := snap.Capacity(); got != 60_000_000 {
+ t.Errorf("Capacity()=%d, want 60000000 (raw total unchanged)", got)
}
- if snap.FeeMode(addr(9)) != 0 {
- t.Errorf("FeeMode(unknown)=%d, want 0", snap.FeeMode(addr(9)))
+ if got := snap.EffectiveCapacity(); got != 30_000_000 {
+ t.Errorf("EffectiveCapacity()=%d, want 30000000 (non-free clients excluded)", got)
}
- if snap.Capacity() != 12_345 {
- t.Errorf("Capacity=%d, want 12345", snap.Capacity())
+ if ids := snap.Clients(); len(ids) != 1 || ids[0] != 1 {
+ t.Errorf("Clients()=%v, want [1]", ids)
}
}
diff --git a/consensus/misc/eip1559/eip1559.go b/consensus/misc/eip1559/eip1559.go
index d66e5ff8cc..fa24d945a8 100644
--- a/consensus/misc/eip1559/eip1559.go
+++ b/consensus/misc/eip1559/eip1559.go
@@ -127,8 +127,11 @@ func CalcBaseFee(config *params.ChainConfig, parent *types.Header) *big.Int {
// until then the public base fee is priced against reserved capacity alone,
// deterministically across nodes, so no change is needed here.
if config.Bor != nil && config.Bor.IsReservedBlockspace(parent.Number) {
- parentGasTarget = reservedAwareGasTarget(config, parent)
- parentGasUsed = publicGasUsed(config, parent)
+ // One decode of both reserved fields, shared by target and used-side
+ // netting, instead of each calling its own GetReservedX getter.
+ reservedGasUsed, reservedCapacity := parent.GetReservedFields(config)
+ parentGasTarget = reservedAwareGasTarget(config, parent, reservedCapacity)
+ parentGasUsed = publicGasUsed(parent, reservedGasUsed)
}
// If the parent gasUsed is the same as the target, the baseFee remains unchanged.
@@ -216,30 +219,36 @@ func gasTargetForLimit(config *params.ChainConfig, parent *types.Header, gasLimi
return gasLimit / config.ElasticityMultiplier()
}
-// reservedAwareGasTarget returns the EIP-1559 target for the public region: the
-// standard curve applied to capacity that excludes the reserved quotas (Σ active
-// client quotas). Header.GasLimit is left untouched — a formula-only input.
+// reservedAwareGasTarget returns the EIP-1559 target for the public region:
+// the standard curve applied to capacity that excludes the reserved quotas
+// (the parent header's stamped ReservedCapacity — Σ over the registry
+// snapshot's effective client set at the parent block). Header.GasLimit is
+// left untouched — a formula-only input. capacity is the caller's single
+// decode of parent's reserved fields (see GetReservedFields), shared with
+// publicGasUsed so CalcBaseFee decodes the header's Extra once per call.
//
-// The capacity source is the config rather than the registry on purpose:
-// CalcBaseFee is a pure (config, parent) function called from paths that have no
-// parent state to read the registry from (RPC, txpool, historical headers), so a
-// state read here would break that contract. The registry-derived capacity
-// reaches base fee via a producer-stamped header field instead.
-func reservedAwareGasTarget(config *params.ChainConfig, parent *types.Header) uint64 {
- reservedCapacity := config.Bor.ReservedCapacity()
- if reservedCapacity >= parent.GasLimit {
- // Misconfiguration guard: reserved capacity is capped well below the
- // block limit by design. Fall back to the full target rather than
- // producing a zero or negative public target.
+// The capacity is a header field, not a state read: CalcBaseFee stays a pure
+// (config, parent header) function callable from paths with no parent state
+// (RPC, txpool, historical headers), while the registry-derived value still
+// reaches it via the producer-stamped field, validated exactly against the
+// registry by core.validateReservedFields.
+func reservedAwareGasTarget(config *params.ChainConfig, parent *types.Header, capacity *uint64) uint64 {
+ if capacity == nil || *capacity >= parent.GasLimit {
+ // nil is defensive (validated post-fork headers always carry the
+ // field). capacity >= limit is a reachable registry state: governance
+ // (setLimits, quota updates) has no tie to the block gas limit, which
+ // is itself operator-tunable per block, so price against the full
+ // target rather than a zero or negative one.
return calcParentGasTarget(config, parent)
}
- return gasTargetForLimit(config, parent, parent.GasLimit-reservedCapacity)
+ return gasTargetForLimit(config, parent, parent.GasLimit-*capacity)
}
// publicGasUsed nets the parent's reserved gas out of its total gas used, so
-// the base-fee controller tracks only normal-region demand.
-func publicGasUsed(config *params.ChainConfig, parent *types.Header) uint64 {
- reservedGasUsed := parent.GetReservedGasUsed(config)
+// the base-fee controller tracks only normal-region demand. reservedGasUsed
+// is the caller's single decode of parent's reserved fields (see
+// reservedAwareGasTarget).
+func publicGasUsed(parent *types.Header, reservedGasUsed *uint64) uint64 {
if reservedGasUsed == nil || *reservedGasUsed > parent.GasUsed {
return parent.GasUsed
}
diff --git a/consensus/misc/eip1559/eip1559_reserved_test.go b/consensus/misc/eip1559/eip1559_reserved_test.go
index 961a042e85..a19801692d 100644
--- a/consensus/misc/eip1559/eip1559_reserved_test.go
+++ b/consensus/misc/eip1559/eip1559_reserved_test.go
@@ -6,18 +6,19 @@ import (
"github.com/stretchr/testify/require"
- "github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/params"
"github.com/ethereum/go-ethereum/rlp"
)
// reservedFeeConfig builds a London+Cancun bor config with the reserved fork
-// gated at reservedBlock (nil = never) and a single reserved client holding
-// `capacity` gas. Pre-Dandeli, so the gas target is gasLimit/elasticity (÷2),
-// which keeps the arithmetic in the tests exact and readable.
-func reservedFeeConfig(reservedBlock *big.Int, capacity uint64) *params.ChainConfig {
- cc := ¶ms.ChainConfig{
+// gated at reservedBlock (nil = never). Pre-Dandeli, so the gas target is
+// gasLimit/elasticity (÷2), which keeps the arithmetic in the tests exact and
+// readable. Capacity is no longer a config concern (§2.2/§2.3): it arrives via
+// the parent header's ReservedCapacity field, set by the caller on the parent
+// passed to CalcBaseFee.
+func reservedFeeConfig(reservedBlock *big.Int) *params.ChainConfig {
+ return ¶ms.ChainConfig{
ChainID: big.NewInt(80003),
HomesteadBlock: big.NewInt(0),
EIP150Block: big.NewInt(0),
@@ -38,24 +39,22 @@ func reservedFeeConfig(reservedBlock *big.Int, capacity uint64) *params.ChainCon
ReservedBlockspaceBlock: reservedBlock,
},
}
- if capacity > 0 {
- cc.Bor.ReservedClients = []params.ReservedClient{
- {Addresses: []common.Address{{0x01}}, QuotaGas: capacity},
- }
- }
- return cc
}
-// extraWithReserved encodes a header Extra carrying the reserved-region field.
-// The two preceding optional fields (GasTarget, BaseFeeChangeDenominator) must
-// be non-nil for RLP to emit the trailing reserved optional.
-func extraWithReserved(t *testing.T, reservedGasUsed uint64) []byte {
+// extraWithReservedFields encodes a header Extra carrying the reserved-region
+// fields. The two preceding optional fields (GasTarget, BaseFeeChangeDenominator)
+// must be non-nil for RLP to emit the trailing reserved optionals. capacity nil
+// means ReservedCapacity is absent from the wire (mirroring a pre-activation
+// or otherwise malformed header); gasUsed is always written when capacity is,
+// since the two are stamped together in production.
+func extraWithReservedFields(t *testing.T, gasUsed uint64, capacity *uint64) []byte {
t.Helper()
zero := uint64(0)
enc, err := rlp.EncodeToBytes(&types.BlockExtraData{
GasTarget: &zero,
BaseFeeChangeDenominator: &zero,
- ReservedGasUsed: &reservedGasUsed,
+ ReservedGasUsed: &gasUsed,
+ ReservedCapacity: capacity,
})
require.NoError(t, err)
@@ -65,10 +64,18 @@ func extraWithReserved(t *testing.T, reservedGasUsed uint64) []byte {
return extra
}
+// extraWithReserved is the capacity-only convenience form used by tests that
+// don't care about ReservedGasUsed netting.
+func extraWithReserved(t *testing.T, capacity uint64) []byte {
+ t.Helper()
+ return extraWithReservedFields(t, 0, &capacity)
+}
+
// TestReservedBaseFee_CapacityReducesTarget pins the capacity anchor: the
-// public gas target excludes the reserved quotas, so a block whose usage lands
-// exactly on the reduced target holds the base fee steady — whereas with the
-// fork inactive the same usage sits below the full target and the fee drops.
+// public gas target excludes the reserved quotas (read from the parent
+// header), so a block whose usage lands exactly on the reduced target holds
+// the base fee steady — whereas with the fork inactive the same usage sits
+// below the full target and the fee drops.
func TestReservedBaseFee_CapacityReducesTarget(t *testing.T) {
t.Parallel()
@@ -82,16 +89,17 @@ func TestReservedBaseFee_CapacityReducesTarget(t *testing.T) {
GasLimit: gasLimit,
GasUsed: (gasLimit - capacity) / 2,
BaseFee: baseFee,
+ Extra: extraWithReserved(t, capacity),
}
- active := reservedFeeConfig(big.NewInt(0), capacity)
+ active := reservedFeeConfig(big.NewInt(0))
if got := CalcBaseFee(active, parent); got.Cmp(baseFee) != 0 {
t.Errorf("reserved-active base fee = %s, want unchanged %s (usage == public target)", got, baseFee)
}
// Fork inactive: full target = gasLimit/2 = 30M, so 20M usage is below
// target and the base fee must fall.
- inactive := reservedFeeConfig(nil, capacity)
+ inactive := reservedFeeConfig(nil)
if got := CalcBaseFee(inactive, parent); got.Cmp(baseFee) >= 0 {
t.Errorf("reserved-inactive base fee = %s, want < %s (usage below full target)", got, baseFee)
}
@@ -106,7 +114,7 @@ func TestReservedBaseFee_NetsReservedGasUsed(t *testing.T) {
const gasLimit = 60_000_000
const capacity = 20_000_000
baseFee := big.NewInt(params.InitialBaseFee)
- cfg := reservedFeeConfig(big.NewInt(0), capacity)
+ cfg := reservedFeeConfig(big.NewInt(0))
// Public target = (60M - 20M)/2 = 20M. Total used 35M, of which 15M is
// reserved → public used = 20M == target → base fee unchanged.
@@ -115,7 +123,7 @@ func TestReservedBaseFee_NetsReservedGasUsed(t *testing.T) {
GasLimit: gasLimit,
GasUsed: 35_000_000,
BaseFee: baseFee,
- Extra: extraWithReserved(t, 15_000_000),
+ Extra: extraWithReservedFields(t, 15_000_000, ptr(uint64(capacity))),
}
if got := CalcBaseFee(cfg, parent); got.Cmp(baseFee) != 0 {
t.Errorf("base fee = %s, want unchanged %s (public used == target after netting)", got, baseFee)
@@ -128,29 +136,30 @@ func TestReservedBaseFee_NetsReservedGasUsed(t *testing.T) {
GasLimit: gasLimit,
GasUsed: 35_000_000,
BaseFee: baseFee,
- Extra: extraWithReserved(t, 0),
+ Extra: extraWithReservedFields(t, 0, ptr(uint64(capacity))),
}
if got := CalcBaseFee(cfg, parentNoReserved); got.Cmp(baseFee) <= 0 {
t.Errorf("base fee = %s, want > %s (full usage above target without netting)", got, baseFee)
}
}
-// TestReservedBaseFee_CapacityExceedsLimitFallsBack guards the misconfiguration
-// path: if reserved capacity is mis-set at or above the block gas limit, the
-// target falls back to the full-limit curve instead of going to zero (which
-// would divide by zero) or negative.
+// TestReservedBaseFee_CapacityExceedsLimitFallsBack guards the liveness path
+// (§2.2): if the registry's effective capacity is validly at or above the
+// block gas limit, the target falls back to the full-limit curve instead of
+// going to zero (which would divide by zero) or negative.
func TestReservedBaseFee_CapacityExceedsLimitFallsBack(t *testing.T) {
t.Parallel()
const gasLimit = 30_000_000
baseFee := big.NewInt(params.InitialBaseFee)
- cfg := reservedFeeConfig(big.NewInt(0), gasLimit+1) // capacity > limit
+ cfg := reservedFeeConfig(big.NewInt(0))
parent := &types.Header{
Number: big.NewInt(1),
GasLimit: gasLimit,
GasUsed: gasLimit / 2, // == full target → unchanged
BaseFee: baseFee,
+ Extra: extraWithReserved(t, gasLimit+1), // capacity > limit
}
var got *big.Int
@@ -170,13 +179,14 @@ func TestReservedBaseFee_CapacityEqualsLimitFallsBack(t *testing.T) {
const gasLimit = 30_000_000
baseFee := big.NewInt(params.InitialBaseFee)
- cfg := reservedFeeConfig(big.NewInt(0), gasLimit) // capacity == limit
+ cfg := reservedFeeConfig(big.NewInt(0))
parent := &types.Header{
Number: big.NewInt(1),
GasLimit: gasLimit,
GasUsed: gasLimit / 2, // == full target → unchanged
BaseFee: baseFee,
+ Extra: extraWithReserved(t, gasLimit), // capacity == limit
}
var got *big.Int
@@ -187,19 +197,141 @@ func TestReservedBaseFee_CapacityEqualsLimitFallsBack(t *testing.T) {
}
}
+// TestReservedBaseFee_NilCapacityFallsBack covers the defensive nil branch:
+// a post-fork parent that (abnormally) carries no ReservedCapacity field
+// prices against the full target rather than panicking on a nil dereference.
+// Validated headers always carry the field post-fork; this only guards
+// CalcBaseFee itself, which must stay total for any header it's handed.
+func TestReservedBaseFee_NilCapacityFallsBack(t *testing.T) {
+ t.Parallel()
+
+ const gasLimit = 60_000_000
+ baseFee := big.NewInt(params.InitialBaseFee)
+ cfg := reservedFeeConfig(big.NewInt(0))
+
+ parent := &types.Header{
+ Number: big.NewInt(1),
+ GasLimit: gasLimit,
+ GasUsed: gasLimit / 2, // == full target → unchanged
+ BaseFee: baseFee,
+ Extra: extraWithReservedFields(t, 0, nil), // ReservedCapacity absent
+ }
+
+ var got *big.Int
+ require.NotPanics(t, func() { got = CalcBaseFee(cfg, parent) },
+ "CalcBaseFee must not panic on a missing ReservedCapacity field")
+ if got.Cmp(baseFee) != 0 {
+ t.Errorf("fallback base fee = %s, want unchanged %s (full target)", got, baseFee)
+ }
+}
+
+// TestReservedBaseFee_ZeroCapacity pins capacity = 0: the public target
+// equals the full-limit curve (excluding nothing), same result as the fork
+// being inactive, but exercised through the active reserved-aware branch.
+func TestReservedBaseFee_ZeroCapacity(t *testing.T) {
+ t.Parallel()
+
+ const gasLimit = 60_000_000
+ baseFee := big.NewInt(params.InitialBaseFee)
+ cfg := reservedFeeConfig(big.NewInt(0))
+
+ parent := &types.Header{
+ Number: big.NewInt(1),
+ GasLimit: gasLimit,
+ GasUsed: gasLimit / 2, // == full target (gasLimit/2) → unchanged
+ BaseFee: baseFee,
+ Extra: extraWithReserved(t, 0),
+ }
+
+ if got := CalcBaseFee(cfg, parent); got.Cmp(baseFee) != 0 {
+ t.Errorf("base fee = %s, want unchanged %s (zero capacity excludes nothing)", got, baseFee)
+ }
+}
+
+// TestReservedBaseFee_ForkBoundary is the N-1/N/N+1 matrix, from the
+// perspective of the block being PRICED (not its parent): CalcBaseFee for
+// block M reads parent(M-1)'s reserved fields, gated on
+// IsReservedBlockspace(parent.Number). Pricing the fork-activation block
+// itself (M = fork) uses the full target since its parent (fork-1, pre-fork)
+// carries no reserved fields; only pricing fork+1 onward reads the parent's
+// stamped capacity.
+func TestReservedBaseFee_ForkBoundary(t *testing.T) {
+ t.Parallel()
+
+ const gasLimit = 60_000_000
+ const capacity = 20_000_000
+ const forkBlock = 100
+ baseFee := big.NewInt(params.InitialBaseFee)
+ cfg := reservedFeeConfig(big.NewInt(forkBlock))
+
+ // Usage that lands on the REDUCED (capacity-aware) target: (60M-20M)/2 = 20M.
+ // Under the full-limit curve (fork inactive for this parent) that usage is
+ // below target (30M), so the fee would fall; under the reduced target it's
+ // exactly on target, so the fee holds steady. This makes the two regimes
+ // observably different.
+ usage := uint64((gasLimit - capacity) / 2)
+
+ tests := []struct {
+ name string
+ pricedBlock int64 // the block CalcBaseFee computes the fee FOR
+ parentExtra []byte
+ wantSteady bool // true: base fee unchanged; false: base fee must fall
+ }{
+ {
+ name: "priced block N (=fork): parent N-1 is pre-fork, full target",
+ pricedBlock: forkBlock,
+ parentExtra: nil,
+ wantSteady: false,
+ },
+ {
+ name: "priced block N+1: parent N is the fork-activation block, reserved-aware",
+ pricedBlock: forkBlock + 1,
+ parentExtra: extraWithReserved(t, capacity),
+ wantSteady: true,
+ },
+ {
+ name: "priced block N+2: parent N+1 is post-fork, reserved-aware",
+ pricedBlock: forkBlock + 2,
+ parentExtra: extraWithReserved(t, capacity),
+ wantSteady: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ parent := &types.Header{
+ Number: big.NewInt(tt.pricedBlock - 1),
+ GasLimit: gasLimit,
+ GasUsed: usage,
+ BaseFee: baseFee,
+ Extra: tt.parentExtra,
+ }
+ got := CalcBaseFee(cfg, parent)
+ if tt.wantSteady {
+ if got.Cmp(baseFee) != 0 {
+ t.Errorf("base fee = %s, want unchanged %s (reserved-aware target)", got, baseFee)
+ }
+ } else if got.Cmp(baseFee) >= 0 {
+ t.Errorf("base fee = %s, want < %s (full target, usage below it)", got, baseFee)
+ }
+ })
+ }
+}
+
// TestReservedBaseFee_VerifyAcceptsProducerValue checks that the reserved-aware
// base fee a producer computes passes strict (pre-Lisovo) header verification,
// since VerifyEIP1559Header recomputes via CalcBaseFee on that path.
func TestReservedBaseFee_VerifyAcceptsProducerValue(t *testing.T) {
t.Parallel()
- cfg := reservedFeeConfig(big.NewInt(0), 20_000_000) // Lisovo nil → strict verify
+ cfg := reservedFeeConfig(big.NewInt(0)) // Lisovo nil → strict verify
baseFee := big.NewInt(params.InitialBaseFee)
parent := &types.Header{
Number: big.NewInt(1),
GasLimit: 60_000_000,
GasUsed: 50_000_000,
BaseFee: baseFee,
+ Extra: extraWithReserved(t, 20_000_000),
}
expected := CalcBaseFee(cfg, parent)
child := &types.Header{
@@ -211,3 +343,5 @@ func TestReservedBaseFee_VerifyAcceptsProducerValue(t *testing.T) {
require.NoError(t, VerifyEIP1559Header(cfg, parent, child),
"strict verification must accept the reserved-aware base fee")
}
+
+func ptr(v uint64) *uint64 { return &v }
diff --git a/core/block_validator.go b/core/block_validator.go
index ac81b3baeb..4cbe9c7420 100644
--- a/core/block_validator.go
+++ b/core/block_validator.go
@@ -148,9 +148,9 @@ func (v *BlockValidator) ValidateStateCheap(block *types.Block, statedb *state.S
}
// The reserved-blockspace check is header-vs-execution comparison and so
// belongs in the cheap tier too: the pipelined import path validates only
- // through here, and the header's ReservedGasUsed feeds the next block's
- // base fee, so it must never be accepted unchecked.
- if err := v.validateReservedGasUsed(header, res); err != nil {
+ // through here, and the header's reserved fields feed the next block's
+ // base fee, so they must never be accepted unchecked.
+ if err := v.validateReservedFields(header, res); err != nil {
return err
}
rbloom := types.MergeBloom(res.Receipts)
@@ -193,10 +193,11 @@ func (v *BlockValidator) ValidateState(block *types.Block, statedb *state.StateD
return fmt.Errorf("%w (remote: %x local: %x)", ErrBloomMismatch, header.Bloom, rbloom)
}
// Reserved-blockspace check (before the stateless early-return, so stateless
- // verifiers enforce it too): the header's ReservedGasUsed must equal the gas
- // actually used by reserved (fee-free) transactions. res.ReservedGasUsed is
+ // verifiers enforce it too): the header's ReservedGasUsed and ReservedCapacity
+ // must equal, respectively, the gas actually used by reserved (fee-free)
+ // transactions and the registry snapshot's effective capacity. Both are
// populated on every Process path, including stateless execution.
- if err := v.validateReservedGasUsed(header, res); err != nil {
+ if err := v.validateReservedFields(header, res); err != nil {
return err
}
// In stateless mode, return early because the receipt and state root are not
@@ -230,21 +231,34 @@ func (v *BlockValidator) ValidateState(block *types.Block, statedb *state.StateD
return nil
}
-// validateReservedGasUsed enforces a reserved-blockspace invariant: post-fork,
-// the header's ReservedGasUsed must equal the gas actually used by reserved
-// (fee-free) transactions, recomputed during execution. This stops a producer
-// from stamping a value that disagrees with execution to skew the next block's
+// validateReservedFields enforces the two reserved-blockspace header/execution
+// invariants: post-fork, the header's ReservedGasUsed must equal the gas
+// actually used by reserved (fee-free) transactions, and its ReservedCapacity
+// must equal the registry snapshot's effective capacity, both recomputed
+// during execution. This stops a producer from stamping a value that
+// disagrees with execution — either would skew the next block's
// (reserved-aware) base fee.
-func (v *BlockValidator) validateReservedGasUsed(header *types.Header, res *ProcessResult) error {
+func (v *BlockValidator) validateReservedFields(header *types.Header, res *ProcessResult) error {
if v.config.Bor == nil || !v.config.Bor.IsReservedBlockspace(header.Number) {
return nil
}
- var got uint64
- if rg := header.GetReservedGasUsed(v.config); rg != nil {
- got = *rg
+ gotGasUsed, gotCapacity := header.GetReservedFields(v.config)
+ if err := compareReservedField(gotGasUsed, res.ReservedGasUsed, ErrReservedGasUsedMismatch); err != nil {
+ return err
+ }
+ return compareReservedField(gotCapacity, res.ReservedCapacity, ErrReservedCapacityMismatch)
+}
+
+// compareReservedField compares a header-optional reserved field (nil, i.e.
+// absent from the wire, treated as 0) against the value execution
+// recomputed, wrapping sentinel with both sides on mismatch.
+func compareReservedField(got *uint64, want uint64, sentinel error) error {
+ var gotVal uint64
+ if got != nil {
+ gotVal = *got
}
- if got != res.ReservedGasUsed {
- return fmt.Errorf("%w (remote: %d local: %d)", ErrReservedGasUsedMismatch, got, res.ReservedGasUsed)
+ if gotVal != want {
+ return fmt.Errorf("%w (remote: %d local: %d)", sentinel, gotVal, want)
}
return nil
}
diff --git a/core/blockchain.go b/core/blockchain.go
index d0dae4c769..0cc3b7da82 100644
--- a/core/blockchain.go
+++ b/core/blockchain.go
@@ -159,6 +159,15 @@ var (
blockBatchWriteTimer = metrics.NewRegisteredTimer("chain/batch/write", nil) // time to flush the block batch to disk (blockBatch.Write) — spikes indicate DB compaction stalls
stateCommitTimer = metrics.NewRegisteredTimer("chain/state/commit", nil) // time for statedb.CommitWithUpdate — in pathdb mode, spikes indicate diff layer flushes
+ // Reserved-blockspace import-path metrics. Producer-side signals
+ // (worker/reserved/*, miner/sequencing.go) already cover the block-building
+ // decision point; these cover the corresponding import/verification side,
+ // so both a node's own sealed blocks (once re-verified) and every block it
+ // imports from a peer are observable through the same series.
+ chainReservedGasUsedGauge = metrics.NewRegisteredGauge("chain/reserved/gasused", nil)
+ chainReservedCapacityGauge = metrics.NewRegisteredGauge("chain/reserved/capacity", nil)
+ chainReservedTxsMeter = metrics.NewRegisteredMeter("chain/reserved/txs", nil)
+
// Pipelined import SRC metrics
pipelineImportBlocksCounter = metrics.NewRegisteredCounter("chain/imports/pipelined/blocks", nil)
pipelineImportTotalTimer = metrics.NewRegisteredTimer("chain/imports/pipelined/total", nil)
@@ -1304,7 +1313,7 @@ func (bc *BlockChain) startPrefetchGoroutine(block *types.Block, throwaway *stat
}(time.Now())
}
-func (bc *BlockChain) ProcessBlock(block *types.Block, parent *types.Header, witness *stateless.Witness, followupInterrupt *atomic.Bool, pipeOpts *PipelineImportOpts) (_ types.Receipts, _ []*types.Log, _ uint64, _ *state.StateDB, vtime time.Duration, blockEndErr error) {
+func (bc *BlockChain) ProcessBlock(block *types.Block, parent *types.Header, witness *stateless.Witness, followupInterrupt *atomic.Bool, pipeOpts *PipelineImportOpts) (_ types.Receipts, _ []*types.Log, _ uint64, _ *state.StateDB, vtime time.Duration, _ []uint64, _ map[uint64]registryreader.ClientUsage, blockEndErr error) {
// Process the block using processor and parallelProcessor at the same time, take the one which finishes first, cancel the other, and return the result
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
@@ -1319,7 +1328,7 @@ func (bc *BlockChain) ProcessBlock(block *types.Block, parent *types.Header, wit
throwaway, statedb, parallelStatedb, prefetch, process, parallel, err := bc.setupBlockReaders(parent, pipeOpts)
if err != nil {
- return nil, nil, 0, nil, 0, err
+ return nil, nil, 0, nil, 0, nil, nil, err
}
defer reportReaderStats(prefetch, process, parallel)
@@ -1328,16 +1337,18 @@ func (bc *BlockChain) ProcessBlock(block *types.Block, parent *types.Header, wit
bc.startPrefetchGoroutine(block, throwaway, sharedCaches, followupInterrupt)
type Result struct {
- receipts types.Receipts
- logs []*types.Log
- usedGas uint64
- err error
- statedb *state.StateDB
- counter *metrics.Counter
- parallel bool
- vtime time.Duration
- execFrom time.Time
- execTo time.Time
+ receipts types.Receipts
+ logs []*types.Log
+ usedGas uint64
+ err error
+ statedb *state.StateDB
+ counter *metrics.Counter
+ parallel bool
+ vtime time.Duration
+ execFrom time.Time
+ execTo time.Time
+ reservedTxIndexes []uint64
+ reservedClientUsage map[uint64]registryreader.ClientUsage
}
var resultChanLen int = 2
@@ -1380,16 +1391,18 @@ func (bc *BlockChain) ProcessBlock(block *types.Block, parent *types.Header, wit
res = &ProcessResult{}
}
resultChan <- Result{
- receipts: res.Receipts,
- logs: res.Logs,
- usedGas: res.GasUsed,
- err: err,
- statedb: parallelStatedb,
- counter: blockExecutionParallelCounter,
- parallel: true,
- vtime: localVtime,
- execFrom: pstart,
- execTo: pend,
+ receipts: res.Receipts,
+ logs: res.Logs,
+ usedGas: res.GasUsed,
+ err: err,
+ statedb: parallelStatedb,
+ counter: blockExecutionParallelCounter,
+ parallel: true,
+ vtime: localVtime,
+ execFrom: pstart,
+ execTo: pend,
+ reservedTxIndexes: res.ReservedTxIndexes,
+ reservedClientUsage: res.ReservedClientUsage,
}
}()
}
@@ -1416,16 +1429,18 @@ func (bc *BlockChain) ProcessBlock(block *types.Block, parent *types.Header, wit
res = &ProcessResult{}
}
resultChan <- Result{
- receipts: res.Receipts,
- logs: res.Logs,
- usedGas: res.GasUsed,
- err: err,
- statedb: statedb,
- counter: blockExecutionSerialCounter,
- parallel: false,
- vtime: localVtime,
- execFrom: pstart,
- execTo: pend,
+ receipts: res.Receipts,
+ logs: res.Logs,
+ usedGas: res.GasUsed,
+ err: err,
+ statedb: statedb,
+ counter: blockExecutionSerialCounter,
+ parallel: false,
+ vtime: localVtime,
+ execFrom: pstart,
+ execTo: pend,
+ reservedTxIndexes: res.ReservedTxIndexes,
+ reservedClientUsage: res.ReservedClientUsage,
}
}()
}
@@ -1499,7 +1514,7 @@ func (bc *BlockChain) ProcessBlock(block *types.Block, parent *types.Header, wit
second_result.statedb.StopPrefetcher()
}
- return result.receipts, result.logs, result.usedGas, result.statedb, result.vtime, result.err
+ return result.receipts, result.logs, result.usedGas, result.statedb, result.vtime, result.reservedTxIndexes, result.reservedClientUsage, result.err
}
func (bc *BlockChain) setupSnapshot() {
@@ -2589,7 +2604,7 @@ func (bc *BlockChain) InsertReceiptChain(blockChain types.Blocks, receiptChain [
if first.NumberU64() == 1 {
if frozen, _ := bc.db.Ancients(); frozen == 0 {
td := bc.genesisBlock.Difficulty()
- writeSize, err := rawdb.WriteAncientBlocks(bc.db, []*types.Block{bc.genesisBlock}, []rlp.RawValue{rlp.EmptyList}, []rlp.RawValue{rlp.EmptyList}, td)
+ writeSize, err := rawdb.WriteAncientBlocks(bc.db, []*types.Block{bc.genesisBlock}, []rlp.RawValue{rlp.EmptyList}, []rlp.RawValue{rlp.EmptyList}, []rlp.RawValue{rlp.EmptyList}, td)
if err != nil {
log.Error("Error writing genesis to ancients", "err", err)
return 0, err
@@ -2612,9 +2627,12 @@ func (bc *BlockChain) InsertReceiptChain(blockChain types.Blocks, receiptChain [
headers = append(headers, block.Header())
}
- // Write all chain data to ancients.
+ // Write all chain data to ancients. These blocks arrive via receipt
+ // sync rather than local execution, so there is no reserved-tx
+ // classification to carry; the nil entries make writeAncientBlock
+ // record that explicitly.
td := bc.GetTd(first.Hash(), first.NumberU64())
- writeSize, err := rawdb.WriteAncientBlocks(bc.db, blockChain, receiptChain, borReceipts, td)
+ writeSize, err := rawdb.WriteAncientBlocks(bc.db, blockChain, receiptChain, borReceipts, make([]rlp.RawValue, len(blockChain)), td)
if err != nil {
log.Error("Error importing chain data to ancients", "err", err)
return 0, err
@@ -2859,9 +2877,42 @@ func (bc *BlockChain) writeKnownBlock(block *types.Block) error {
return nil
}
+// updateReservedChainMetrics records reserved-blockspace metrics for a block
+// that just became the canonical head: header-derived gasused/capacity, the
+// count of transactions classified reserved, and a per-client quota/quotaused
+// breakdown. It returns immediately pre-fork and when metrics are disabled,
+// so it costs nothing on chains that never activate reserved blockspace. It
+// takes the block rather than a header so the Block.Header copy is only made
+// past those gates.
+func updateReservedChainMetrics(config *params.ChainConfig, block *types.Block, reservedTxIndexes []uint64, clientUsage map[uint64]registryreader.ClientUsage) {
+ if !metrics.Enabled() || config.Bor == nil || !config.Bor.IsReservedBlockspace(block.Number()) {
+ return
+ }
+ gasUsed, capacity := block.Header().GetReservedFields(config)
+ var gasUsedVal, capacityVal uint64
+ if gasUsed != nil {
+ gasUsedVal = *gasUsed
+ }
+ if capacity != nil {
+ capacityVal = *capacity
+ }
+ chainReservedGasUsedGauge.Update(int64(gasUsedVal))
+ chainReservedCapacityGauge.Update(int64(capacityVal))
+ chainReservedTxsMeter.Mark(int64(len(reservedTxIndexes)))
+
+ // quotaused is declared gas (the quota's own admission basis, matching
+ // the producer-side worker/reserved accounting); gasused above is
+ // executed gas. Client ids come from the governance-controlled registry,
+ // so the dynamic gauge set this creates has the same bounded cardinality.
+ for id, usage := range clientUsage {
+ metrics.GetOrRegisterGauge(fmt.Sprintf("chain/reserved/client/%d/quotaused", id), nil).Update(int64(usage.Used))
+ metrics.GetOrRegisterGauge(fmt.Sprintf("chain/reserved/client/%d/quota", id), nil).Update(int64(usage.Quota))
+ }
+}
+
// writeBlockWithState writes block, metadata and corresponding state data to the
// database.
-func (bc *BlockChain) writeBlockWithState(block *types.Block, receipts []*types.Receipt, logs []*types.Log, statedb *state.StateDB) ([]*types.Log, error) {
+func (bc *BlockChain) writeBlockWithState(block *types.Block, receipts []*types.Receipt, logs []*types.Log, statedb *state.StateDB, reservedTxIndexes []uint64) ([]*types.Log, error) {
// Calculate the total difficulty of the block
ptd := bc.GetTd(block.ParentHash(), block.NumberU64()-1)
var externTd *big.Int
@@ -2880,6 +2931,16 @@ func (bc *BlockChain) writeBlockWithState(block *types.Block, receipts []*types.
rawdb.WriteTd(blockBatch, block.Hash(), block.NumberU64(), externTd)
rawdb.WriteBlock(blockBatch, block)
rawdb.WriteReceipts(blockBatch, block.Hash(), block.NumberU64(), receipts)
+ // Reserved-tx indexes are non-consensus, derived data annotating the
+ // receipts written above; write them in the same batch, and only where
+ // receipts are actually being persisted, so the two can never be
+ // observed out of sync (see rawdb.WriteReservedTxIndexes). This guard is
+ // what lets the stateless writers, which pass nil receipts, also pass
+ // their real classification through mechanically: it is a no-op here
+ // regardless, rather than relying on every such caller to remember nil.
+ if len(receipts) > 0 {
+ rawdb.WriteReservedTxIndexes(blockBatch, block.Hash(), block.NumberU64(), reservedTxIndexes)
+ }
// Bor state-sync logs: system calls append state-sync logs into state, so
// state.Logs() may exceed the transaction-produced logs. Pre-Madhugiri we
@@ -3012,19 +3073,19 @@ func (bc *BlockChain) writeBlockWithState(block *types.Block, receipts []*types.
// WriteBlockAndSetHead writes the given block and all associated state to the database,
// and applies the block as the new chain head.
-func (bc *BlockChain) WriteBlockAndSetHead(block *types.Block, receipts []*types.Receipt, logs []*types.Log, state *state.StateDB, emitHeadEvent bool) (status WriteStatus, err error) {
+func (bc *BlockChain) WriteBlockAndSetHead(block *types.Block, receipts []*types.Receipt, logs []*types.Log, state *state.StateDB, reservedTxIndexes []uint64, reservedClientUsage map[uint64]registryreader.ClientUsage, emitHeadEvent bool) (status WriteStatus, err error) {
if !bc.chainmu.TryLock() {
return NonStatTy, errChainStopped
}
defer bc.chainmu.Unlock()
- return bc.writeBlockAndSetHead(block, receipts, logs, state, emitHeadEvent, false)
+ return bc.writeBlockAndSetHead(block, receipts, logs, state, reservedTxIndexes, reservedClientUsage, emitHeadEvent, false)
}
// writeBlockAndSetHead is the internal implementation of WriteBlockAndSetHead.
// This function expects the chain mutex to be held.
-func (bc *BlockChain) writeBlockAndSetHead(block *types.Block, receipts []*types.Receipt, logs []*types.Log, state *state.StateDB, emitHeadEvent bool, stateless bool) (status WriteStatus, err error) {
- stateSyncLogs, err := bc.writeBlockWithState(block, receipts, logs, state)
+func (bc *BlockChain) writeBlockAndSetHead(block *types.Block, receipts []*types.Receipt, logs []*types.Log, state *state.StateDB, reservedTxIndexes []uint64, reservedClientUsage map[uint64]registryreader.ClientUsage, emitHeadEvent bool, stateless bool) (status WriteStatus, err error) {
+ stateSyncLogs, err := bc.writeBlockWithState(block, receipts, logs, state, reservedTxIndexes)
if err != nil {
return NonStatTy, err
}
@@ -3032,6 +3093,12 @@ func (bc *BlockChain) writeBlockAndSetHead(block *types.Block, receipts []*types
if err != nil {
return NonStatTy, err
}
+ if status == CanonStatTy {
+ // Reserved-region metrics describe the canonical chain, so they sit
+ // with the other canonical-only effects (emitPostWriteEvents) rather
+ // than in writeBlockWithState, which also runs for side-chain writes.
+ updateReservedChainMetrics(bc.chainConfig, block, reservedTxIndexes, reservedClientUsage)
+ }
bc.emitPostWriteEvents(block, receipts, logs, stateSyncLogs, status, emitHeadEvent)
return status, nil
}
@@ -3165,6 +3232,14 @@ func (bc *BlockChain) insertChainStatelessParallel(chain types.Blocks, witnesses
err error
needsRetry bool
gasUsed uint64
+ // reservedTxIndexes and reservedClientUsage carry the block's
+ // classification through to the writeBlockAndSetHead calls below.
+ // Those calls pass nil receipts on this path, so writeBlockWithState's
+ // own guard suppresses the side-table write for reservedTxIndexes;
+ // reservedClientUsage is unaffected by that guard and feeds the
+ // canonical-head reserved metrics normally.
+ reservedTxIndexes []uint64
+ reservedClientUsage map[uint64]registryreader.ClientUsage
}
results := make([]execResult, len(chain))
defer func() {
@@ -3223,6 +3298,8 @@ func (bc *BlockChain) insertChainStatelessParallel(chain types.Blocks, witnesses
}
results[idx].sdb = sdb
results[idx].gasUsed = res.GasUsed
+ results[idx].reservedTxIndexes = res.ReservedTxIndexes
+ results[idx].reservedClientUsage = res.ReservedClientUsage
}
}()
}
@@ -3286,7 +3363,7 @@ func (bc *BlockChain) insertChainStatelessParallel(chain types.Blocks, witnesses
// Only commit blocks that don't need retry
if !results[i].needsRetry {
- if _, werr := bc.writeBlockAndSetHead(block, nil, nil, results[i].sdb, true, true); werr != nil {
+ if _, werr := bc.writeBlockAndSetHead(block, nil, nil, results[i].sdb, results[i].reservedTxIndexes, results[i].reservedClientUsage, true, true); werr != nil {
stopHeaders()
return int(processed.Load()), werr
}
@@ -3314,9 +3391,11 @@ func (bc *BlockChain) insertChainStatelessParallel(chain types.Blocks, witnesses
sdb.SetWitness(witness)
}
results[i].gasUsed = res.GasUsed
+ results[i].reservedTxIndexes = res.ReservedTxIndexes
+ results[i].reservedClientUsage = res.ReservedClientUsage
// Commit the block after successful retry
- if _, werr := bc.writeBlockAndSetHead(block, nil, nil, sdb, true, true); werr != nil {
+ if _, werr := bc.writeBlockAndSetHead(block, nil, nil, sdb, results[i].reservedTxIndexes, results[i].reservedClientUsage, true, true); werr != nil {
stopHeaders()
return int(processed.Load()), werr
}
@@ -3422,7 +3501,11 @@ func (bc *BlockChain) insertChainStatelessSequential(chain types.Blocks, witness
}
}
- if _, werr := bc.writeBlockAndSetHead(block, nil, nil, statedb, true, true); werr != nil {
+ // receipts stay nil on this path; writeBlockWithState's own guard
+ // suppresses the reserved-tx side-table write to match. The usage map
+ // is unaffected by that guard and feeds the canonical-head reserved
+ // metrics normally.
+ if _, werr := bc.writeBlockAndSetHead(block, nil, nil, statedb, res.ReservedTxIndexes, res.ReservedClientUsage, true, true); werr != nil {
return int(processed.Load()), werr
}
processed.Add(1)
@@ -3785,7 +3868,7 @@ func (bc *BlockChain) insertChainWithWitnesses(chain types.Blocks, setHead bool,
}
cheapExecStart := time.Now()
- receipts, logs, usedGas, statedb, vtime, err := bc.ProcessBlock(block, parent, witness, &followupInterrupt, pipeOpts)
+ receipts, logs, usedGas, statedb, vtime, reservedTxIndexes, reservedClientUsage, err := bc.ProcessBlock(block, parent, witness, &followupInterrupt, pipeOpts)
cheapExecElapsed := time.Since(cheapExecStart)
if pipelineActive {
pipelineImportCheapExecTimer.Update(cheapExecElapsed)
@@ -3829,7 +3912,7 @@ func (bc *BlockChain) insertChainWithWitnesses(chain types.Blocks, setHead bool,
// --- Pipelined import: extract FlatDiff, collect previous SRC, write metadata, spawn SRC ---
if pipelineActive {
- adjustBack, err := bc.persistPipelinedImport(block, parent, statedb, receipts, logs, start, cheapExecElapsed, vtime, computeWitness)
+ adjustBack, err := bc.persistPipelinedImport(block, parent, statedb, receipts, logs, reservedTxIndexes, reservedClientUsage, start, cheapExecElapsed, vtime, computeWitness)
if err != nil {
followupInterrupt.Store(true)
// adjustBack attributes the failure to the previous block,
@@ -3917,9 +4000,9 @@ func (bc *BlockChain) insertChainWithWitnesses(chain types.Blocks, setHead bool,
if !setHead {
// Don't set the head, only insert the block
- _, err = bc.writeBlockWithState(block, receipts, logs, statedb)
+ _, err = bc.writeBlockWithState(block, receipts, logs, statedb, reservedTxIndexes)
} else {
- status, err = bc.writeBlockAndSetHead(block, receipts, logs, statedb, false, false)
+ status, err = bc.writeBlockAndSetHead(block, receipts, logs, statedb, reservedTxIndexes, reservedClientUsage, false, false)
}
writeElapsed := time.Since(wstart)
normalImportWriteTimer.Update(writeElapsed)
@@ -4134,9 +4217,9 @@ func (bc *BlockChain) processBlock(block *types.Block, statedb *state.StateDB, s
)
if !setHead {
// Don't set the head, only insert the block
- _, err = bc.writeBlockWithState(block, res.Receipts, res.Logs, statedb)
+ _, err = bc.writeBlockWithState(block, res.Receipts, res.Logs, statedb, res.ReservedTxIndexes)
} else {
- status, err = bc.writeBlockAndSetHead(block, res.Receipts, res.Logs, statedb, false, false)
+ status, err = bc.writeBlockAndSetHead(block, res.Receipts, res.Logs, statedb, res.ReservedTxIndexes, res.ReservedClientUsage, false, false)
}
if err != nil {
return nil, err
@@ -4400,7 +4483,10 @@ func (bc *BlockChain) collectReceiptsAndLogs(b *types.Block, removed bool) ([]*t
receipts = append(receipts, borReceipt)
}
- if err := receipts.DeriveFields(bc.chainConfig, b.Hash(), b.NumberU64(), b.Time(), b.BaseFee(), blobGasPrice, b.Transactions()); err != nil {
+ // Reorg log-removal events surface only logs, not the reserved-aware
+ // effective price, so no side-table read here; see rawdb.ReadReceipts for
+ // the path that does.
+ if err := receipts.DeriveFields(bc.chainConfig, b.Hash(), b.NumberU64(), b.Time(), b.BaseFee(), blobGasPrice, b.Transactions(), nil); err != nil {
log.Error("Failed to derive block receipts fields", "hash", b.Hash(), "number", b.NumberU64(), "err", err)
}
var logs []*types.Log
@@ -4883,13 +4969,13 @@ func (bc *BlockChain) SubscribeChain2HeadEvent(ch chan<- Chain2HeadEvent) event.
// when two CommitWithUpdate calls diverge from the same parent root.
// WriteBlockAndSetHeadPipelined is the public variant that acquires the chain mutex.
// Used by the miner pipeline (resultLoop) where the mutex is not already held.
-func (bc *BlockChain) WriteBlockAndSetHeadPipelined(block *types.Block, receipts []*types.Receipt, logs []*types.Log, statedb *state.StateDB, emitHeadEvent bool, witnessBytes []byte) (WriteStatus, error) {
+func (bc *BlockChain) WriteBlockAndSetHeadPipelined(block *types.Block, receipts []*types.Receipt, logs []*types.Log, statedb *state.StateDB, reservedTxIndexes []uint64, reservedClientUsage map[uint64]registryreader.ClientUsage, emitHeadEvent bool, witnessBytes []byte) (WriteStatus, error) {
if !bc.chainmu.TryLock() {
return NonStatTy, errChainStopped
}
defer bc.chainmu.Unlock()
- return bc.writeBlockAndSetHeadPipelined(block, receipts, logs, statedb, emitHeadEvent, witnessBytes)
+ return bc.writeBlockAndSetHeadPipelined(block, receipts, logs, statedb, reservedTxIndexes, reservedClientUsage, emitHeadEvent, witnessBytes)
}
// writeBlockAndSetHeadPipelined is the internal implementation. It writes block
@@ -4897,21 +4983,25 @@ func (bc *BlockChain) WriteBlockAndSetHeadPipelined(block *types.Block, receipts
// WITHOUT committing trie state. The state commit is handled by the SRC goroutine.
// This function does NOT acquire the chain mutex — the caller must ensure
// proper synchronization (e.g., called from insertChainWithWitnesses).
-func (bc *BlockChain) writeBlockAndSetHeadPipelined(block *types.Block, receipts []*types.Receipt, logs []*types.Log, statedb *state.StateDB, emitHeadEvent bool, witnessBytes []byte) (WriteStatus, error) {
- status, stateSyncLogs, err := bc.writePipelinedBlockAndResolveStatus(block, receipts, logs, statedb, witnessBytes)
+func (bc *BlockChain) writeBlockAndSetHeadPipelined(block *types.Block, receipts []*types.Receipt, logs []*types.Log, statedb *state.StateDB, reservedTxIndexes []uint64, reservedClientUsage map[uint64]registryreader.ClientUsage, emitHeadEvent bool, witnessBytes []byte) (WriteStatus, error) {
+ status, stateSyncLogs, err := bc.writePipelinedBlockAndResolveStatus(block, receipts, logs, statedb, reservedTxIndexes, witnessBytes)
if err != nil {
return NonStatTy, err
}
+ if status == CanonStatTy {
+ // Same canonical-only reserved metrics hook as writeBlockAndSetHead.
+ updateReservedChainMetrics(bc.chainConfig, block, reservedTxIndexes, reservedClientUsage)
+ }
bc.emitPostWriteEvents(block, receipts, logs, stateSyncLogs, status, emitHeadEvent)
return status, nil
}
-func (bc *BlockChain) writePipelinedBlockAndResolveStatus(block *types.Block, receipts []*types.Receipt, logs []*types.Log, statedb *state.StateDB, witnessBytes []byte) (WriteStatus, []*types.Log, error) {
+func (bc *BlockChain) writePipelinedBlockAndResolveStatus(block *types.Block, receipts []*types.Receipt, logs []*types.Log, statedb *state.StateDB, reservedTxIndexes []uint64, witnessBytes []byte) (WriteStatus, []*types.Log, error) {
ptd := bc.GetTd(block.ParentHash(), block.NumberU64()-1)
if ptd == nil {
return NonStatTy, nil, consensus.ErrUnknownAncestor
}
- stateSyncLogs, err := bc.writePipelinedBlockBatch(block, receipts, logs, statedb, witnessBytes, new(big.Int).Add(block.Difficulty(), ptd))
+ stateSyncLogs, err := bc.writePipelinedBlockBatch(block, receipts, logs, statedb, reservedTxIndexes, witnessBytes, new(big.Int).Add(block.Difficulty(), ptd))
if err != nil {
return NonStatTy, nil, err
}
@@ -4929,11 +5019,16 @@ func (bc *BlockChain) writePipelinedBlockAndResolveStatus(block *types.Block, re
// The SRC witness replaces the execution-side witness because FlatDiff
// overlay accounts bypass the trie during speculative execution, so their
// MPT proof nodes are only captured during SRC's CommitWithUpdate.
-func (bc *BlockChain) writePipelinedBlockBatch(block *types.Block, receipts []*types.Receipt, logs []*types.Log, statedb *state.StateDB, witnessBytes []byte, externTd *big.Int) ([]*types.Log, error) {
+func (bc *BlockChain) writePipelinedBlockBatch(block *types.Block, receipts []*types.Receipt, logs []*types.Log, statedb *state.StateDB, reservedTxIndexes []uint64, witnessBytes []byte, externTd *big.Int) ([]*types.Log, error) {
blockBatch := bc.db.NewBatch()
rawdb.WriteTd(blockBatch, block.Hash(), block.NumberU64(), externTd)
rawdb.WriteBlock(blockBatch, block)
rawdb.WriteReceipts(blockBatch, block.Hash(), block.NumberU64(), receipts)
+ // Same receipts-guarded reserved side-table write as writeBlockWithState:
+ // the two must never be observed out of sync.
+ if len(receipts) > 0 {
+ rawdb.WriteReservedTxIndexes(blockBatch, block.Hash(), block.NumberU64(), reservedTxIndexes)
+ }
stateSyncLogs := bc.writeBorStateSyncLogs(blockBatch, block, receipts, logs, statedb)
rawdb.WritePreimages(blockBatch, statedb.Preimages())
if len(witnessBytes) > 0 {
@@ -5713,7 +5808,7 @@ func (t pipelinedImportPersistTimings) accounted() time.Duration {
// start auto-collection. adjustBack=true signals the caller to decrement
// it.index when returning the error (because the failure belongs to the
// previously pending block, not the current one).
-func (bc *BlockChain) persistPipelinedImport(block *types.Block, parent *types.Header, statedb *state.StateDB, receipts []*types.Receipt, logs []*types.Log, start time.Time, cheapExec, validation time.Duration, makeWitness bool) (adjustBack bool, err error) {
+func (bc *BlockChain) persistPipelinedImport(block *types.Block, parent *types.Header, statedb *state.StateDB, receipts []*types.Receipt, logs []*types.Log, reservedTxIndexes []uint64, reservedClientUsage map[uint64]registryreader.ClientUsage, start time.Time, cheapExec, validation time.Duration, makeWitness bool) (adjustBack bool, err error) {
persistStart := time.Now()
timings := pipelinedImportPersistTimings{}
defer func() {
@@ -5806,7 +5901,7 @@ func (bc *BlockChain) persistPipelinedImport(block *types.Block, parent *types.H
// starts, then let SRC overlap the synchronous head/event publication tail.
writeHeadStart := time.Now()
bc.markPendingImportHeadState(block)
- status, stateSyncLogs, err := bc.writePipelinedBlockAndResolveStatus(block, receipts, logs, statedb, nil)
+ status, stateSyncLogs, err := bc.writePipelinedBlockAndResolveStatus(block, receipts, logs, statedb, reservedTxIndexes, nil)
writePrepareElapsed := time.Since(writeHeadStart)
if err != nil {
bc.clearPendingImportHeadState(block)
@@ -5833,6 +5928,10 @@ func (bc *BlockChain) persistPipelinedImport(block *types.Block, parent *types.H
pipelineImportSpawnSRCTimer.Update(timings.spawnSRC)
publishStart := time.Now()
+ if status == CanonStatTy {
+ // Same canonical-only reserved metrics hook as writeBlockAndSetHead.
+ updateReservedChainMetrics(bc.chainConfig, block, reservedTxIndexes, reservedClientUsage)
+ }
bc.emitPostWriteEvents(block, receipts, logs, stateSyncLogs, status, false)
timings.writeHead = writePrepareElapsed + time.Since(publishStart)
pipelineImportWriteHeadTimer.Update(timings.writeHead)
diff --git a/core/blockchain_pipeline_helpers_test.go b/core/blockchain_pipeline_helpers_test.go
index 985ed19a28..4ef8425494 100644
--- a/core/blockchain_pipeline_helpers_test.go
+++ b/core/blockchain_pipeline_helpers_test.go
@@ -394,14 +394,14 @@ func TestPipelinedBlockWritePaths(t *testing.T) {
statedb, err := chain.StateAt(chain.CurrentBlock().Root)
require.NoError(t, err)
- status, err := chain.WriteBlockAndSetHeadPipelined(blocks[0], nil, nil, statedb, false, nil)
+ status, err := chain.WriteBlockAndSetHeadPipelined(blocks[0], nil, nil, statedb, nil, nil, false, nil)
require.NoError(t, err)
require.Equal(t, CanonStatTy, status)
require.Equal(t, blocks[0].Hash(), chain.CurrentBlock().Hash())
closedChain := &BlockChain{chainmu: syncx.NewClosableMutex()}
closedChain.chainmu.Close()
- status, err = closedChain.WriteBlockAndSetHeadPipelined(nil, nil, nil, nil, false, nil)
+ status, err = closedChain.WriteBlockAndSetHeadPipelined(nil, nil, nil, nil, nil, nil, false, nil)
require.ErrorIs(t, err, errChainStopped)
require.Equal(t, NonStatTy, status)
@@ -410,11 +410,11 @@ func TestPipelinedBlockWritePaths(t *testing.T) {
Number: big.NewInt(3),
Difficulty: common.Big1,
})
- status, _, err = chain.writePipelinedBlockAndResolveStatus(unknown, nil, nil, statedb, nil)
+ status, _, err = chain.writePipelinedBlockAndResolveStatus(unknown, nil, nil, statedb, nil, nil)
require.ErrorIs(t, err, consensus.ErrUnknownAncestor)
require.Equal(t, NonStatTy, status)
- status, err = chain.writeBlockAndSetHeadPipelined(unknown, nil, nil, statedb, false, nil)
+ status, err = chain.writeBlockAndSetHeadPipelined(unknown, nil, nil, statedb, nil, nil, false, nil)
require.ErrorIs(t, err, consensus.ErrUnknownAncestor)
require.Equal(t, NonStatTy, status)
}
@@ -425,7 +425,7 @@ func TestPipelinedBlockBatchStoresWitness(t *testing.T) {
require.NoError(t, err)
witness := []byte{1, 2, 3, 4}
- status, err := chain.WriteBlockAndSetHeadPipelined(blocks[0], nil, nil, statedb, false, witness)
+ status, err := chain.WriteBlockAndSetHeadPipelined(blocks[0], nil, nil, statedb, nil, nil, false, witness)
require.NoError(t, err)
require.Equal(t, CanonStatTy, status)
require.Equal(t, witness, chain.GetWitness(blocks[0].Hash()))
diff --git a/core/blockchain_pipeline_lifecycle_test.go b/core/blockchain_pipeline_lifecycle_test.go
index 7254c65b0f..fc47503b1a 100644
--- a/core/blockchain_pipeline_lifecycle_test.go
+++ b/core/blockchain_pipeline_lifecycle_test.go
@@ -151,7 +151,7 @@ func TestPipelinePersistFailureBranches(t *testing.T) {
}
adjustBack, err := chain.persistPipelinedImport(
- blocks[1], blocks[0].Header(), newState(t, chain), nil, nil, time.Now(), 0, 0, false,
+ blocks[1], blocks[0].Header(), newState(t, chain), nil, nil, nil, nil, time.Now(), 0, 0, false,
)
require.True(t, adjustBack)
require.EqualError(t, err, "collect failed")
@@ -165,7 +165,7 @@ func TestPipelinePersistFailureBranches(t *testing.T) {
}))
adjustBack, err := chain.persistPipelinedImport(
- blocks[0], chain.CurrentHeader(), newState(t, chain), nil, nil, time.Now(), 0, 0, false,
+ blocks[0], chain.CurrentHeader(), newState(t, chain), nil, nil, nil, nil, time.Now(), 0, 0, false,
)
require.False(t, adjustBack)
require.ErrorIs(t, err, validateErr)
@@ -178,7 +178,7 @@ func TestPipelinePersistFailureBranches(t *testing.T) {
}))
adjustBack, err := chain.persistPipelinedImport(
- blocks[0], chain.CurrentHeader(), newState(t, chain), nil, nil, time.Now(), 0, 0, false,
+ blocks[0], chain.CurrentHeader(), newState(t, chain), nil, nil, nil, nil, time.Now(), 0, 0, false,
)
require.False(t, adjustBack)
require.ErrorIs(t, err, whitelist.ErrMismatch)
@@ -191,7 +191,7 @@ func TestPipelinePersistFailureBranches(t *testing.T) {
}), nil, nil)
adjustBack, err := chain.persistPipelinedImport(
- blocks[0], chain.CurrentHeader(), newState(t, chain), nil, nil, time.Now(), 0, 0, false,
+ blocks[0], chain.CurrentHeader(), newState(t, chain), nil, nil, nil, nil, time.Now(), 0, 0, false,
)
require.False(t, adjustBack)
require.ErrorContains(t, err, "missing td")
diff --git a/core/blockchain_reader.go b/core/blockchain_reader.go
index ba19942af6..88f35d8d7f 100644
--- a/core/blockchain_reader.go
+++ b/core/blockchain_reader.go
@@ -441,6 +441,9 @@ func (bc *BlockChain) GetCanonicalReceipt(tx *types.Transaction, blockHash commo
return nil, err
}
signer := types.MakeSigner(bc.chainConfig, new(big.Int).SetUint64(blockNumber), header.Time)
+ // Fork-gate before touching the DB: this is a single-tx hot read path.
+ reserved := bc.chainConfig.Bor != nil && bc.chainConfig.Bor.IsReservedBlockspace(header.Number) &&
+ rawdb.IsReservedTxIndex(bc.db, blockHash, blockNumber, txIndex)
receipt.DeriveFields(signer, types.DeriveReceiptContext{
BlockHash: blockHash,
BlockNumber: blockNumber,
@@ -451,6 +454,7 @@ func (bc *BlockChain) GetCanonicalReceipt(tx *types.Transaction, blockHash commo
LogIndex: ctx.LogIndex,
Tx: tx,
TxIndex: uint(txIndex),
+ Reserved: reserved,
})
return receipt, nil
}
diff --git a/core/blockchain_test.go b/core/blockchain_test.go
index 2990f0a272..0786e0f34f 100644
--- a/core/blockchain_test.go
+++ b/core/blockchain_test.go
@@ -186,11 +186,12 @@ func testBlockChainImport(chain types.Blocks, blockchain *BlockChain) error {
if err != nil {
return err
}
- receipts, logs, usedGas, statedb, _, err := blockchain.ProcessBlock(block, blockchain.GetBlockByHash(block.ParentHash()).Header(), nil, nil, nil)
+ receipts, logs, usedGas, statedb, _, reservedTxIndexes, _, err := blockchain.ProcessBlock(block, blockchain.GetBlockByHash(block.ParentHash()).Header(), nil, nil, nil)
res := &ProcessResult{
- Receipts: receipts,
- Logs: logs,
- GasUsed: usedGas,
+ Receipts: receipts,
+ Logs: logs,
+ GasUsed: usedGas,
+ ReservedTxIndexes: reservedTxIndexes,
}
if err != nil {
blockchain.reportBlock(block, res, err)
diff --git a/core/chain_makers.go b/core/chain_makers.go
index b504b598ed..9be27982e5 100644
--- a/core/chain_makers.go
+++ b/core/chain_makers.go
@@ -468,7 +468,7 @@ func GenerateChain(config *params.ChainConfig, parent *types.Block, engine conse
if block.ExcessBlobGas() != nil && cm.config.BlobScheduleConfig != nil {
blobGasPrice = eip4844.CalcBlobFee(cm.config, block.Header())
}
- if err := receipts.DeriveFields(config, block.Hash(), block.NumberU64(), block.Time(), block.BaseFee(), blobGasPrice, txs); err != nil {
+ if err := receipts.DeriveFields(config, block.Hash(), block.NumberU64(), block.Time(), block.BaseFee(), blobGasPrice, txs, nil); err != nil {
panic(err)
}
@@ -583,7 +583,7 @@ func GenerateVerkleChain(config *params.ChainConfig, parent *types.Block, engine
if block.ExcessBlobGas() != nil && cm.config.BlobScheduleConfig != nil {
blobGasPrice = eip4844.CalcBlobFee(cm.config, block.Header())
}
- if err := receipts.DeriveFields(config, block.Hash(), block.NumberU64(), block.Time(), block.BaseFee(), blobGasPrice, txs); err != nil {
+ if err := receipts.DeriveFields(config, block.Hash(), block.NumberU64(), block.Time(), block.BaseFee(), blobGasPrice, txs, nil); err != nil {
panic(err)
}
diff --git a/core/error.go b/core/error.go
index 9cf49efb6b..3f315089c4 100644
--- a/core/error.go
+++ b/core/error.go
@@ -48,6 +48,11 @@ var (
// transactions during validation.
ErrReservedGasUsedMismatch = errors.New("invalid reserved gas used")
+ // ErrReservedCapacityMismatch indicates the header's reserved-blockspace
+ // capacity does not match the registry snapshot's effective capacity
+ // recomputed during validation.
+ ErrReservedCapacityMismatch = errors.New("invalid reserved capacity")
+
// ErrBloomMismatch indicates a mismatch between locally computed
// bloom filter and the block's bloom during validation.
ErrBloomMismatch = errors.New("invalid bloom")
diff --git a/core/parallel_state_processor.go b/core/parallel_state_processor.go
index 46c1820bee..c8f3456327 100644
--- a/core/parallel_state_processor.go
+++ b/core/parallel_state_processor.go
@@ -448,7 +448,8 @@ func (p *ParallelStateProcessor) Process(block *types.Block, statedb *state.Stat
// Quota-aware reserved set, derived once from the ordered body and shared by
// every task's block context (map reference), so parallel classification
// matches serial and produce (consensus parity).
- blockContext.ReservedTxs = registryreader.ClassifyReserved(txs, signer, blockContext.ReservedSnapshot)
+ var clientUsage map[uint64]registryreader.ClientUsage
+ blockContext.ReservedTxs, clientUsage = registryreader.ClassifyReserved(txs, signer, blockContext.ReservedSnapshot)
for i, tx := range txs {
if tx.Type() == types.StateSyncTxType {
continue
@@ -543,12 +544,17 @@ func (p *ParallelStateProcessor) Process(block *types.Block, statedb *state.Stat
}
}
+ reservedGasUsed, reservedTxIndexes := sumReservedGasUsed(block.Transactions(), receipts, signer, blockContext.ReservedTxs)
+
return &ProcessResult{
- Receipts: receipts,
- Requests: requests,
- Logs: allLogs,
- GasUsed: *usedGas,
- ReservedGasUsed: sumReservedGasUsed(block.Transactions(), receipts, signer, blockContext.ReservedTxs),
+ Receipts: receipts,
+ Requests: requests,
+ Logs: allLogs,
+ GasUsed: *usedGas,
+ ReservedGasUsed: reservedGasUsed,
+ ReservedCapacity: blockContext.ReservedSnapshot.EffectiveCapacity(),
+ ReservedTxIndexes: reservedTxIndexes,
+ ReservedClientUsage: clientUsage,
}, nil
}
@@ -1121,7 +1127,8 @@ func (p *V2StateProcessor) Process(block *types.Block, statedb *state.StateDB, c
return nil, err
}
blockCtx.ReservedSnapshot = reservedSnapshot
- blockCtx.ReservedTxs = registryreader.ClassifyReserved(block.Transactions(), types.MakeSigner(config, header.Number, header.Time), blockCtx.ReservedSnapshot)
+ var clientUsage map[uint64]registryreader.ClientUsage
+ blockCtx.ReservedTxs, clientUsage = registryreader.ClassifyReserved(block.Transactions(), types.MakeSigner(config, header.Number, header.Time), blockCtx.ReservedSnapshot)
applyV2PreExecSystemCalls(block, statedb, config, cfg, blockCtx)
tasks, err := buildV2Tasks(block, config, header, interruptCtx)
@@ -1178,7 +1185,7 @@ func (p *V2StateProcessor) Process(block *types.Block, statedb *state.StateDB, c
}
return p.finalizeV2Block(block, statedb, header, config, tasks, result,
- blockCtx.ReservedTxs, tProcess, tSetup, tCopy, tExec)
+ blockCtx.ReservedTxs, blockCtx.ReservedSnapshot.EffectiveCapacity(), clientUsage, tProcess, tSetup, tCopy, tExec)
}
// finalizeV2Block runs consensus-engine finalization, merges state-sync logs,
@@ -1187,7 +1194,8 @@ func (p *V2StateProcessor) Process(block *types.Block, statedb *state.StateDB, c
func (p *V2StateProcessor) finalizeV2Block(block *types.Block, statedb *state.StateDB,
header *types.Header, config *params.ChainConfig,
tasks []V2Task, result *V2ExecutionResult,
- reservedTxs map[registryreader.ReservedKey]struct{},
+ reservedTxs map[registryreader.ReservedKey]struct{}, reservedCapacity uint64,
+ reservedClientUsage map[uint64]registryreader.ClientUsage,
tProcess, tSetup, tCopy, tExec time.Time,
) (*ProcessResult, error) {
receiptsCountBeforeFinalize := len(result.Receipts)
@@ -1246,12 +1254,17 @@ func (p *V2StateProcessor) finalizeV2Block(block *types.Block, statedb *state.St
}
}
+ reservedGasUsed, reservedTxIndexes := sumReservedGasUsed(block.Transactions(), receipts, types.MakeSigner(config, header.Number, header.Time), reservedTxs)
+
return &ProcessResult{
- Receipts: receipts,
- Requests: requests,
- Logs: allLogs,
- GasUsed: result.GasUsed,
- ReservedGasUsed: sumReservedGasUsed(block.Transactions(), receipts, types.MakeSigner(config, header.Number, header.Time), reservedTxs),
+ Receipts: receipts,
+ Requests: requests,
+ Logs: allLogs,
+ GasUsed: result.GasUsed,
+ ReservedGasUsed: reservedGasUsed,
+ ReservedCapacity: reservedCapacity,
+ ReservedTxIndexes: reservedTxIndexes,
+ ReservedClientUsage: reservedClientUsage,
}, nil
}
diff --git a/core/rawdb/accessors_chain.go b/core/rawdb/accessors_chain.go
index d8de5a4f26..1f44227d05 100644
--- a/core/rawdb/accessors_chain.go
+++ b/core/rawdb/accessors_chain.go
@@ -787,7 +787,14 @@ func ReadReceipts(db ethdb.Reader, hash common.Hash, number uint64, time uint64,
if header != nil && header.ExcessBlobGas != nil {
blobGasPrice = nil
}
- if err := receipts.DeriveFields(config, hash, number, time, baseFee, blobGasPrice, body.Transactions); err != nil {
+ // Fork-gate before touching the DB: this path feeds feeHistory, which
+ // walks up to 1024 blocks, so an unconditional read here is a hot-path
+ // cost that is pure overhead everywhere the fork is inactive.
+ var reservedTxIndexes []uint64
+ if config.Bor != nil && config.Bor.IsReservedBlockspace(new(big.Int).SetUint64(number)) {
+ reservedTxIndexes = ReadReservedTxIndexesBounded(db, hash, number, len(body.Transactions))
+ }
+ if err := receipts.DeriveFields(config, hash, number, time, baseFee, blobGasPrice, body.Transactions, reservedTxIndexes); err != nil {
log.Error("Failed to derive block receipts fields", "hash", hash, "number", number, "err", err)
return nil
}
@@ -892,8 +899,13 @@ func WriteBlock(db ethdb.KeyValueWriter, block *types.Block) {
WriteHeader(db, block.Header())
}
-// WriteAncientBlocks writes entire block data into ancient store and returns the total written size.
-func WriteAncientBlocks(db ethdb.AncientWriter, blocks []*types.Block, receipts []rlp.RawValue, borReceipts []rlp.RawValue, td *big.Int) (int64, error) {
+// WriteAncientBlocks writes entire block data into ancient store and returns
+// the total written size. reservedTxIndexes carries each block's reserved-tx
+// classification RLP; a nil or empty entry means the caller genuinely has no
+// classification to record (e.g. snap-sync receipt import, genesis ancient
+// init), which writeAncientBlock encodes as an explicit empty list rather
+// than leaving the entry unset.
+func WriteAncientBlocks(db ethdb.AncientWriter, blocks []*types.Block, receipts []rlp.RawValue, borReceipts []rlp.RawValue, reservedTxIndexes []rlp.RawValue, td *big.Int) (int64, error) {
var tdSum = new(big.Int).Set(td)
return db.ModifyAncients(func(op ethdb.AncientWriteOp) error {
for i, block := range blocks {
@@ -902,7 +914,7 @@ func WriteAncientBlocks(db ethdb.AncientWriter, blocks []*types.Block, receipts
if i > 0 {
tdSum.Add(tdSum, header.Difficulty)
}
- if err := writeAncientBlock(op, block, header, receipts[i], borReceipts[i], tdSum); err != nil {
+ if err := writeAncientBlock(op, block, header, receipts[i], borReceipts[i], reservedTxIndexes[i], tdSum); err != nil {
return err
}
}
@@ -911,7 +923,7 @@ func WriteAncientBlocks(db ethdb.AncientWriter, blocks []*types.Block, receipts
})
}
-func writeAncientBlock(op ethdb.AncientWriteOp, block *types.Block, header *types.Header, receipt, borReceipt rlp.RawValue, td *big.Int) error {
+func writeAncientBlock(op ethdb.AncientWriteOp, block *types.Block, header *types.Header, receipt, borReceipt, reservedTxIndexes rlp.RawValue, td *big.Int) error {
num := block.NumberU64()
if err := op.AppendRaw(ChainFreezerHashTable, num, block.Hash().Bytes()); err != nil {
return fmt.Errorf("can't add block %d hash: %v", num, err)
@@ -933,6 +945,23 @@ func writeAncientBlock(op ethdb.AncientWriteOp, block *types.Block, header *type
return fmt.Errorf("can't append block %d bor receipts: %v", num, err)
}
+ // Most callers of this path write blocks straight into the freezer
+ // (snap-sync pivot import, genesis ancient init) without having executed
+ // them, so they have no classification to record and pass a nil entry
+ // here. An empty entry keeps the table aligned with the others and
+ // encodes "no classification available" directly in the data, which
+ // read-side derivation already treats the same as "nothing reserved".
+ // The offline block pruner is the one caller with a real entry to carry
+ // forward (read from the old freezer before it is discarded), which it
+ // passes through unchanged.
+ reserved := reservedTxIndexes
+ if len(reserved) == 0 {
+ reserved = rlp.EmptyList
+ }
+ if err := op.AppendRaw(ChainFreezerReservedTxsTable, num, reserved); err != nil {
+ return fmt.Errorf("can't append block %d reserved-tx indexes: %v", num, err)
+ }
+
if err := op.Append(ChainFreezerDifficultyTable, num, td); err != nil {
return fmt.Errorf("can't append block %d total difficulty: %v", num, err)
}
@@ -960,6 +989,9 @@ func DeleteBlockWithoutNumber(db ethdb.KeyValueWriter, hash common.Hash, number
// delete bor receipt
DeleteBorReceipt(db, hash, number)
+
+ // delete reserved-tx indexes
+ DeleteReservedTxIndexes(db, hash, number)
}
const badBlockToKeep = 10
diff --git a/core/rawdb/accessors_chain_test.go b/core/rawdb/accessors_chain_test.go
index 28e5b92f1c..3d1e865da6 100644
--- a/core/rawdb/accessors_chain_test.go
+++ b/core/rawdb/accessors_chain_test.go
@@ -512,7 +512,7 @@ func TestAncientStorage(t *testing.T) {
}
// Write and verify the header in the database
- WriteAncientBlocks(db, []*types.Block{block}, types.EncodeBlockReceiptLists([]types.Receipts{nil}), types.EncodeBlockReceiptLists([]types.Receipts{nil}), big.NewInt(100))
+ WriteAncientBlocks(db, []*types.Block{block}, types.EncodeBlockReceiptLists([]types.Receipts{nil}), types.EncodeBlockReceiptLists([]types.Receipts{nil}), []rlp.RawValue{nil}, big.NewInt(100))
if blob := ReadHeaderRLP(db, hash, number); len(blob) == 0 {
t.Fatalf("no header returned")
@@ -553,6 +553,67 @@ func TestAncientStorage(t *testing.T) {
}
}
+// TestWriteAncientBlocks_ReservedTxIndexes covers the two ways
+// WriteAncientBlocks' reservedTxIndexes parameter is used in practice: the
+// offline block pruner supplies a real, previously classified entry read
+// from the freezer it is backing up, which must round-trip unchanged, while
+// every other caller (genesis ancient init, snap-sync receipt import) has no
+// classification and passes nil, which must resolve to an explicit empty
+// list rather than leaving the entry unset.
+func TestWriteAncientBlocks_ReservedTxIndexes(t *testing.T) {
+ newBlock := func(num int64, extra string) *types.Block {
+ return types.NewBlockWithHeader(&types.Header{
+ Number: big.NewInt(num),
+ Extra: []byte(extra),
+ UncleHash: types.EmptyUncleHash,
+ TxHash: types.EmptyTxsHash,
+ ReceiptHash: types.EmptyReceiptsHash,
+ })
+ }
+
+ t.Run("SuppliedEntrySurvives", func(t *testing.T) {
+ db, err := Open(NewMemoryDatabase(), OpenOptions{Ancient: t.TempDir()})
+ if err != nil {
+ t.Fatalf("failed to create database with ancient backend: %v", err)
+ }
+ defer db.Close()
+
+ block := newBlock(0, "reserved entry block")
+ supplied, err := rlp.EncodeToBytes([]uint64{1, 3})
+ if err != nil {
+ t.Fatalf("failed to encode reserved-tx indexes: %v", err)
+ }
+
+ if _, err := WriteAncientBlocks(db, []*types.Block{block}, types.EncodeBlockReceiptLists([]types.Receipts{nil}), types.EncodeBlockReceiptLists([]types.Receipts{nil}), []rlp.RawValue{supplied}, big.NewInt(100)); err != nil {
+ t.Fatalf("failed to write ancient block: %v", err)
+ }
+
+ got := ReadReservedTxIndexesRLP(db, block.Hash(), block.NumberU64())
+ if !bytes.Equal(got, supplied) {
+ t.Fatalf("reserved-tx indexes not preserved: have %x, want %x", got, supplied)
+ }
+ })
+
+ t.Run("NilEntryDefaultsToEmptyList", func(t *testing.T) {
+ db, err := Open(NewMemoryDatabase(), OpenOptions{Ancient: t.TempDir()})
+ if err != nil {
+ t.Fatalf("failed to create database with ancient backend: %v", err)
+ }
+ defer db.Close()
+
+ block := newBlock(0, "no classification block")
+
+ if _, err := WriteAncientBlocks(db, []*types.Block{block}, types.EncodeBlockReceiptLists([]types.Receipts{nil}), types.EncodeBlockReceiptLists([]types.Receipts{nil}), []rlp.RawValue{nil}, big.NewInt(100)); err != nil {
+ t.Fatalf("failed to write ancient block: %v", err)
+ }
+
+ got := ReadReservedTxIndexesRLP(db, block.Hash(), block.NumberU64())
+ if !bytes.Equal(got, rlp.EmptyList) {
+ t.Fatalf("nil entry did not default to an empty list: got %x", got)
+ }
+ })
+}
+
// TestRecentReadsBypassFreezerLock verifies that lookups for recent (key-value
// resident) blocks do not block on the freezer write-lock held by an in-progress
// freeze. Reads of recent canonical hashes, headers and TDs must take the
@@ -642,7 +703,7 @@ func TestReadCanonicalHashAncientFallback(t *testing.T) {
TxHash: types.EmptyTxsHash,
ReceiptHash: types.EmptyReceiptsHash,
})
- WriteAncientBlocks(db, []*types.Block{block}, types.EncodeBlockReceiptLists([]types.Receipts{nil}), types.EncodeBlockReceiptLists([]types.Receipts{nil}), big.NewInt(100))
+ WriteAncientBlocks(db, []*types.Block{block}, types.EncodeBlockReceiptLists([]types.Receipts{nil}), types.EncodeBlockReceiptLists([]types.Receipts{nil}), []rlp.RawValue{nil}, big.NewInt(100))
// The canonical hash lives only in the ancient store (never written to
// leveldb), so the fast path must miss and fall through to it.
@@ -776,7 +837,7 @@ func BenchmarkWriteAncientBlocks(b *testing.B) {
blocks := allBlocks[i : i+length]
receipts := batchReceipts[:length]
- writeSize, err := WriteAncientBlocks(db, blocks, types.EncodeBlockReceiptLists(receipts), types.EncodeBlockReceiptLists(nil), td)
+ writeSize, err := WriteAncientBlocks(db, blocks, types.EncodeBlockReceiptLists(receipts), types.EncodeBlockReceiptLists(nil), make([]rlp.RawValue, len(blocks)), td)
if err != nil {
b.Fatal(err)
}
@@ -1011,7 +1072,7 @@ func TestDeriveLogFields(t *testing.T) {
// Derive log metadata fields
number := big.NewInt(1)
hash := common.BytesToHash([]byte{0x03, 0x14})
- types.Receipts(receipts).DeriveFields(params.TestChainConfig, hash, number.Uint64(), 12, big.NewInt(0), big.NewInt(0), txs)
+ types.Receipts(receipts).DeriveFields(params.TestChainConfig, hash, number.Uint64(), 12, big.NewInt(0), big.NewInt(0), txs, nil)
// Iterate over all the computed fields and check that they're correct
logIndex := uint(0)
@@ -1106,7 +1167,7 @@ func TestHeadersRLPStorage(t *testing.T) {
var borReceipts []types.Receipts = make([]types.Receipts, 100)
// Write first half to ancients
- WriteAncientBlocks(db, chain[:50], types.EncodeBlockReceiptLists(receipts[:50]), types.EncodeBlockReceiptLists(borReceipts[:50]), big.NewInt(100))
+ WriteAncientBlocks(db, chain[:50], types.EncodeBlockReceiptLists(receipts[:50]), types.EncodeBlockReceiptLists(borReceipts[:50]), make([]rlp.RawValue, 50), big.NewInt(100))
// Write second half to db
for i := 50; i < 100; i++ {
WriteCanonicalHash(db, chain[i].Hash(), chain[i].NumberU64())
diff --git a/core/rawdb/ancient_scheme.go b/core/rawdb/ancient_scheme.go
index b4f6a175c7..ead01de1ca 100644
--- a/core/rawdb/ancient_scheme.go
+++ b/core/rawdb/ancient_scheme.go
@@ -38,6 +38,10 @@ const (
// ChainFreezerDifficultyTable indicates the name of the freezer total difficulty table.
ChainFreezerDifficultyTable = "diffs"
+
+ // ChainFreezerReservedTxsTable indicates the name of the freezer table
+ // holding the per-block reserved-tx index list (see core/rawdb/reserved_txs.go).
+ ChainFreezerReservedTxsTable = "matic-reserved-txs"
)
// chainFreezerTableConfigs configures the settings for tables in the chain freezer.
@@ -45,12 +49,13 @@ const (
// tail truncation is disabled for the header and hash tables, as these are intended
// to be retained long-term.
var chainFreezerTableConfigs = map[string]freezerTableConfig{
- ChainFreezerHeaderTable: {noSnappy: false, prunable: false},
- ChainFreezerHashTable: {noSnappy: true, prunable: false},
- ChainFreezerBodiesTable: {noSnappy: false, prunable: true},
- ChainFreezerReceiptTable: {noSnappy: false, prunable: true},
- ChainFreezerDifficultyTable: {noSnappy: true, prunable: true},
- freezerBorReceiptTable: {noSnappy: false, prunable: true},
+ ChainFreezerHeaderTable: {noSnappy: false, prunable: false},
+ ChainFreezerHashTable: {noSnappy: true, prunable: false},
+ ChainFreezerBodiesTable: {noSnappy: false, prunable: true},
+ ChainFreezerReceiptTable: {noSnappy: false, prunable: true},
+ ChainFreezerDifficultyTable: {noSnappy: true, prunable: true},
+ freezerBorReceiptTable: {noSnappy: false, prunable: true},
+ ChainFreezerReservedTxsTable: {noSnappy: false, prunable: true},
}
// freezerTableConfig contains the settings for a freezer table.
diff --git a/core/rawdb/chain_freezer.go b/core/rawdb/chain_freezer.go
index 0ff9c59635..95cef0e640 100644
--- a/core/rawdb/chain_freezer.go
+++ b/core/rawdb/chain_freezer.go
@@ -66,7 +66,13 @@ func NewChainFreezer(datadir string, namespace string, readonly bool, offset uin
// state freezer (e.g. dev mode).
// - if non-empty directory is given, initializes the regular file-based
// state freezer.
-func newChainFreezer(datadir string, eraDir string, namespace string, readonly bool, offset uint64) (*chainFreezer, error) {
+//
+// db is the key-value store paired with this freezer. It is used solely to
+// drive the one-time reserved-tx freezer table migration (see
+// reserved_txs_freezer.go): recording the durable pad-target marker and, once
+// migrated, telling apart an ordinary crash-mid-freeze from a downgrade gap.
+// It may be nil (e.g. the in-memory dev freezer never needs it).
+func newChainFreezer(datadir string, eraDir string, namespace string, readonly bool, offset uint64, db ethdb.KeyValueStore) (*chainFreezer, error) {
if datadir == "" {
return &chainFreezer{
ancients: NewMemoryFreezer(readonly, chainFreezerTableConfigs),
@@ -74,7 +80,7 @@ func newChainFreezer(datadir string, eraDir string, namespace string, readonly b
trigger: make(chan chan struct{}),
}, nil
}
- freezer, err := NewFreezer(datadir, namespace, readonly, offset, freezerTableSize, chainFreezerTableConfigs)
+ freezer, err := newFreezer(datadir, namespace, readonly, offset, freezerTableSize, chainFreezerTableConfigs, newReservedTxsMigrationHook(db))
if err != nil {
return nil, err
}
@@ -386,6 +392,14 @@ func (f *chainFreezer) freezeRange(nfdb *nofreezedb, number, limit uint64) (hash
return fmt.Errorf("can't write bor-receipt to freezer: %v", err)
}
+ // reserved-tx indexes: empty is a valid, common value (most blocks
+ // have no reserved transactions), so every frozen block gets an
+ // entry here exactly as it does in the bor receipt table above.
+ reservedTxIndexes := ReadReservedTxIndexesRLP(nfdb, hash, number)
+ if err := op.AppendRaw(ChainFreezerReservedTxsTable, number, reservedTxIndexes); err != nil {
+ return fmt.Errorf("can't write reserved-tx indexes to freezer: %v", err)
+ }
+
if err := op.AppendRaw(ChainFreezerDifficultyTable, number, td); err != nil {
return fmt.Errorf("can't write td to Freezer: %v", err)
}
diff --git a/core/rawdb/chain_freezer_reserved_test.go b/core/rawdb/chain_freezer_reserved_test.go
new file mode 100644
index 0000000000..9e93b0d9de
--- /dev/null
+++ b/core/rawdb/chain_freezer_reserved_test.go
@@ -0,0 +1,85 @@
+package rawdb
+
+import (
+ "math/big"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/ethereum/go-ethereum/common"
+ "github.com/ethereum/go-ethereum/core/types"
+ "github.com/ethereum/go-ethereum/ethdb"
+ "github.com/ethereum/go-ethereum/ethdb/memorydb"
+)
+
+// writeMinimalBlock writes just enough hot data (header, canonical hash,
+// body, receipts, td, and optionally reserved-tx indexes) for freezeRange to
+// accept the block, and returns its hash.
+func writeMinimalBlock(t *testing.T, db ethdb.KeyValueStore, number uint64, parentHash common.Hash, reservedIdx []uint64) common.Hash {
+ t.Helper()
+
+ header := &types.Header{
+ ParentHash: parentHash,
+ Number: new(big.Int).SetUint64(number),
+ GasLimit: 8_000_000,
+ Extra: []byte{byte(number)},
+ }
+ hash := header.Hash()
+
+ WriteHeader(db, header)
+ WriteCanonicalHash(db, hash, number)
+ WriteBody(db, hash, number, &types.Body{})
+ WriteReceipts(db, hash, number, types.Receipts{})
+ WriteTd(db, hash, number, new(big.Int).SetUint64(number+1))
+ if len(reservedIdx) > 0 {
+ WriteReservedTxIndexes(db, hash, number, reservedIdx)
+ }
+
+ return hash
+}
+
+// canonReader combines a key-value store with a chain freezer's real ancient
+// data, unlike nofreezedb (whose Ancient always errors - it exists only to
+// let freezeRange read the *hot* side while writing to the freezer). isCanon
+// needs to see genuine ancient hash-table entries, so reads that must cross
+// the hot/ancient boundary use this instead.
+type canonReader struct {
+ ethdb.KeyValueStore
+ *chainFreezer
+}
+
+// TestChainFreezerReservedTxs_FreezeAndReadViaAncients exercises the real
+// chainFreezer.freezeRange path: it must append the hot reserved-tx entry
+// (empty or not) for every frozen block, exactly as it already does for the
+// bor receipt table, and the block reading it back afterwards must see the
+// same value via ancients (hot KV entry is gone at that point in production,
+// which this test reproduces by deleting it before reading back).
+func TestChainFreezerReservedTxs_FreezeAndReadViaAncients(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ kv := memorydb.New()
+ nfdb := &nofreezedb{KeyValueStore: kv}
+
+ cf, err := newChainFreezer(dir, "", "", false, 0, kv)
+ require.NoError(t, err)
+ defer cf.Close()
+
+ genesisHash := writeMinimalBlock(t, kv, 0, common.Hash{}, nil)
+ hash1 := writeMinimalBlock(t, kv, 1, genesisHash, []uint64{0, 2})
+ hash2 := writeMinimalBlock(t, kv, 2, hash1, nil)
+
+ hashes, err := cf.freezeRange(nfdb, 0, 2)
+ require.NoError(t, err)
+ require.Len(t, hashes, 3)
+
+ // Delete the hot entries the way the real freeze() background loop does
+ // post-freeze, so the reads below can only be satisfied from ancients.
+ DeleteReservedTxIndexes(kv, hash1, 1)
+ DeleteReservedTxIndexes(kv, hash2, 2)
+
+ reader := &canonReader{KeyValueStore: kv, chainFreezer: cf}
+ require.Equal(t, []uint64{0, 2}, ReadReservedTxIndexes(reader, hash1, 1))
+ require.Nil(t, ReadReservedTxIndexes(reader, hash2, 2))
+ require.Nil(t, ReadReservedTxIndexes(reader, genesisHash, 0))
+}
diff --git a/core/rawdb/database.go b/core/rawdb/database.go
index 234a373537..d829e16559 100644
--- a/core/rawdb/database.go
+++ b/core/rawdb/database.go
@@ -360,7 +360,7 @@ func Open(db ethdb.KeyValueStore, opts OpenOptions) (ethdb.Database, error) {
if chainFreezerDir != "" {
chainFreezerDir = resolveChainFreezerDir(chainFreezerDir)
}
- frdb, err := newChainFreezer(chainFreezerDir, opts.Era, opts.MetricsNamespace, opts.ReadOnly, offset)
+ frdb, err := newChainFreezer(chainFreezerDir, opts.Era, opts.MetricsNamespace, opts.ReadOnly, offset, db)
if err != nil {
printChainMetadata(db)
return nil, err
diff --git a/core/rawdb/freezer.go b/core/rawdb/freezer.go
index 0624139344..fe22b63258 100644
--- a/core/rawdb/freezer.go
+++ b/core/rawdb/freezer.go
@@ -85,6 +85,22 @@ type Freezer struct {
// The 'tables' argument defines the data tables. If the value of a map
// entry is true, snappy compression is disabled for the table.
func NewFreezer(datadir string, namespace string, readonly bool, offset uint64, maxTableSize uint32, tables map[string]freezerTableConfig) (*Freezer, error) {
+ return newFreezer(datadir, namespace, readonly, offset, maxTableSize, tables, nil)
+}
+
+// freezerPreRepairFn runs after a freezer's tables have been opened (so each
+// table's on-disk item and tail counts are known) but before the freezer
+// reconciles table lengths via repair (read-write) or validate (read-only).
+// It exists so a table introduced after other tables already hold history
+// (the reserved-tx table; see reserved_txs_freezer.go) can reconcile itself
+// against that history first - repair's common-length truncation would
+// otherwise silently erase the pre-existing tables down to the new table's
+// (zero) length. Only invoked in read-write mode.
+type freezerPreRepairFn func(f *Freezer) error
+
+// newFreezer is NewFreezer plus an optional hook run just before repair's
+// common-length reconciliation. See freezerPreRepairFn.
+func newFreezer(datadir string, namespace string, readonly bool, offset uint64, maxTableSize uint32, tables map[string]freezerTableConfig, preRepair freezerPreRepairFn) (*Freezer, error) {
// Create the initial freezer object
var (
readMeter = metrics.NewRegisteredMeter(namespace+"ancient/read", nil)
@@ -146,8 +162,13 @@ func NewFreezer(datadir string, namespace string, readonly bool, offset uint64,
// validate also sets `freezer.frozen`.
err = freezer.validate()
} else {
- // Truncate all tables to common length.
- err = freezer.repair()
+ if preRepair != nil {
+ err = preRepair(freezer)
+ }
+ if err == nil {
+ // Truncate all tables to common length.
+ err = freezer.repair()
+ }
}
if err != nil {
for _, table := range freezer.tables {
diff --git a/core/rawdb/reserved_txs.go b/core/rawdb/reserved_txs.go
new file mode 100644
index 0000000000..b8325ada62
--- /dev/null
+++ b/core/rawdb/reserved_txs.go
@@ -0,0 +1,156 @@
+package rawdb
+
+import (
+ "encoding/binary"
+ "fmt"
+ "slices"
+
+ "github.com/ethereum/go-ethereum/common"
+ "github.com/ethereum/go-ethereum/ethdb"
+ "github.com/ethereum/go-ethereum/log"
+ "github.com/ethereum/go-ethereum/rlp"
+)
+
+// reservedTxsPrefix + num (uint64 big endian) + hash -> RLP []uint64 of
+// reserved (fee-free) transaction indexes within the block, strictly
+// ascending. This is derived, non-consensus data: it lets receipt reads
+// report the correct effective gas price for reserved transactions without
+// re-deriving the classification from the registry at read time (see
+// core/types/receipt.go DeriveFields).
+var reservedTxsPrefix = []byte("matic-reserved-txs-")
+
+// maxReservedTxIndexesEncodedSize caps the encoded size accepted before
+// decoding a reserved-tx index entry. A block holds at most a few thousand
+// transactions, so the legitimate encoding is a few bytes per index; this cap
+// is generous headroom against a corrupt or hand-edited database entry.
+const maxReservedTxIndexesEncodedSize = 64 * 1024
+
+// reservedTxIndexesKey = reservedTxsPrefix + num (uint64 big endian) + hash
+func reservedTxIndexesKey(number uint64, hash common.Hash) []byte {
+ enc := make([]byte, 8)
+ binary.BigEndian.PutUint64(enc, number)
+
+ return append(append(reservedTxsPrefix, enc...), hash.Bytes()...)
+}
+
+// ReadReservedTxIndexesRLP retrieves the RLP-encoded reserved-tx index list
+// for a block: hot KV first, then ancients gated on canonical status,
+// mirroring ReadBorReceiptRLP.
+func ReadReservedTxIndexesRLP(db ethdb.Reader, hash common.Hash, number uint64) rlp.RawValue {
+ data, _ := db.Get(reservedTxIndexesKey(number, hash))
+ if len(data) != 0 {
+ return data
+ }
+
+ err := db.ReadAncients(func(reader ethdb.AncientReaderOp) error {
+ if isCanon(reader, number, hash) {
+ data, _ = reader.Ancient(ChainFreezerReservedTxsTable, number)
+ }
+ return nil
+ })
+ if err != nil {
+ log.Warn("Unable to read reserved-tx indexes rlp", "number", number, "hash", hash, "err", err)
+ }
+
+ return data
+}
+
+// decodeReservedTxIndexes decodes and structurally validates an encoded
+// reserved-tx index list: capped size, and strictly ascending (which also
+// rules out duplicates). It does not check indexes against the block's
+// transaction count, since it has no way to know it; see
+// ReadReservedTxIndexesBounded for that.
+func decodeReservedTxIndexes(data []byte) ([]uint64, error) {
+ if len(data) > maxReservedTxIndexesEncodedSize {
+ return nil, fmt.Errorf("encoded size %d exceeds cap %d", len(data), maxReservedTxIndexesEncodedSize)
+ }
+
+ var indexes []uint64
+ if err := rlp.DecodeBytes(data, &indexes); err != nil {
+ return nil, err
+ }
+
+ for i := 1; i < len(indexes); i++ {
+ if indexes[i] <= indexes[i-1] {
+ return nil, fmt.Errorf("indexes not strictly ascending at position %d: %d <= %d", i, indexes[i], indexes[i-1])
+ }
+ }
+
+ return indexes, nil
+}
+
+// ReadReservedTxIndexes retrieves the reserved (fee-free) transaction indexes
+// for a block. A structurally malformed entry (oversized, unsorted, or
+// duplicated) is treated as absent and logged, rather than trusted into
+// deriving reserved status for the wrong transaction.
+func ReadReservedTxIndexes(db ethdb.Reader, hash common.Hash, number uint64) []uint64 {
+ data := ReadReservedTxIndexesRLP(db, hash, number)
+ if len(data) == 0 {
+ return nil
+ }
+
+ indexes, err := decodeReservedTxIndexes(data)
+ if err != nil {
+ log.Warn("Invalid reserved-tx index entry, treating as absent", "number", number, "hash", hash, "err", err)
+ return nil
+ }
+
+ return indexes
+}
+
+// ReadReservedTxIndexesBounded is ReadReservedTxIndexes plus a check against
+// txCount, the one validation ReadReservedTxIndexes cannot make on its own
+// since it has no way to know the block's transaction count. Indexes are
+// strictly ascending by the time this runs, so checking the last one is
+// sufficient. Callers that already hold the block body (and so the count for
+// free) should prefer this over the unbounded variant.
+func ReadReservedTxIndexesBounded(db ethdb.Reader, hash common.Hash, number uint64, txCount int) []uint64 {
+ indexes := ReadReservedTxIndexes(db, hash, number)
+ if len(indexes) == 0 {
+ return indexes
+ }
+
+ if indexes[len(indexes)-1] >= uint64(txCount) {
+ log.Warn("Reserved-tx index entry out of range, treating as absent", "number", number, "hash", hash, "txCount", txCount, "maxIndex", indexes[len(indexes)-1])
+ return nil
+ }
+
+ return indexes
+}
+
+// IsReservedTxIndex reports whether idx is among the block's reserved
+// (fee-free) transaction indexes. It is the single owner of the membership
+// check: indexes are only guaranteed sorted after ReadReservedTxIndexes'
+// validation, so callers must not binary-search the raw slice themselves.
+func IsReservedTxIndex(db ethdb.Reader, hash common.Hash, number uint64, idx uint64) bool {
+ _, ok := slices.BinarySearch(ReadReservedTxIndexes(db, hash, number), idx)
+ return ok
+}
+
+// WriteReservedTxIndexes stores the reserved (fee-free) transaction indexes
+// for a block. Indexes must be sorted ascending and unique (guaranteed by the
+// classification producing them); it is a no-op for an empty list, so hot
+// storage only grows for blocks that actually have reserved transactions.
+func WriteReservedTxIndexes(db ethdb.KeyValueWriter, hash common.Hash, number uint64, indexes []uint64) {
+ if len(indexes) == 0 {
+ return
+ }
+
+ data, err := rlp.EncodeToBytes(indexes)
+ if err != nil {
+ log.Crit("Failed to encode reserved-tx indexes", "err", err)
+ }
+
+ if err := db.Put(reservedTxIndexesKey(number, hash), data); err != nil {
+ log.Crit("Failed to store reserved-tx indexes", "err", err)
+ }
+}
+
+// DeleteReservedTxIndexes removes the reserved-tx index entry associated with
+// a block hash. It is a no-op if no entry exists, matching WriteReservedTxIndexes
+// only ever writing a non-empty one.
+func DeleteReservedTxIndexes(db ethdb.KeyValueWriter, hash common.Hash, number uint64) {
+ if err := db.Delete(reservedTxIndexesKey(number, hash)); err != nil {
+ log.Crit("Failed to delete reserved-tx indexes", "err", err)
+ }
+}
diff --git a/core/rawdb/reserved_txs_freezer.go b/core/rawdb/reserved_txs_freezer.go
new file mode 100644
index 0000000000..1dfdd6d573
--- /dev/null
+++ b/core/rawdb/reserved_txs_freezer.go
@@ -0,0 +1,249 @@
+package rawdb
+
+import (
+ "fmt"
+ "math"
+
+ "github.com/ethereum/go-ethereum/ethdb"
+ "github.com/ethereum/go-ethereum/log"
+ "github.com/ethereum/go-ethereum/rlp"
+)
+
+// reservedTxsMigrationKey is the durable marker recording the reserved-tx
+// freezer table's pad target and completion state across restarts. It lives
+// in the key-value store paired with the chain freezer, not in the freezer
+// itself, since it must survive independently of the ancient tables it
+// governs (in particular, it must still be readable after a crash truncates
+// those tables back down during repair).
+var reservedTxsMigrationKey = []byte("borReservedTxsFreezerMigration")
+
+// reservedTxsMigrationMarker records the reserved-tx freezer table migration's
+// progress. Target is fixed once recorded and is never re-derived on resume
+// (see newReservedTxsMigrationHook).
+type reservedTxsMigrationMarker struct {
+ Target uint64
+ Done bool
+}
+
+func readReservedTxsMigrationMarker(db ethdb.KeyValueReader) (reservedTxsMigrationMarker, bool) {
+ data, err := db.Get(reservedTxsMigrationKey)
+ if err != nil || len(data) == 0 {
+ return reservedTxsMigrationMarker{}, false
+ }
+
+ var marker reservedTxsMigrationMarker
+ if err := rlp.DecodeBytes(data, &marker); err != nil {
+ log.Error("Invalid reserved-tx freezer migration marker, treating as absent", "err", err)
+ return reservedTxsMigrationMarker{}, false
+ }
+
+ return marker, true
+}
+
+func writeReservedTxsMigrationMarker(db ethdb.KeyValueWriter, marker reservedTxsMigrationMarker) error {
+ data, err := rlp.EncodeToBytes(marker)
+ if err != nil {
+ return fmt.Errorf("failed to encode reserved-tx freezer migration marker: %w", err)
+ }
+ if err := db.Put(reservedTxsMigrationKey, data); err != nil {
+ return fmt.Errorf("failed to persist reserved-tx freezer migration marker: %w", err)
+ }
+ return nil
+}
+
+// newReservedTxsMigrationHook returns the freezerPreRepairFn that reconciles
+// the reserved-tx freezer table against pre-existing chain history. db is the
+// key-value store holding the durable migration marker; a nil db (the
+// in-memory dev freezer) disables the migration entirely, since that freezer
+// never has pre-existing on-disk history to reconcile against.
+func newReservedTxsMigrationHook(db ethdb.KeyValueStore) freezerPreRepairFn {
+ if db == nil {
+ return nil
+ }
+ return func(f *Freezer) error {
+ return migrateReservedTxsFreezer(f, db)
+ }
+}
+
+// migrateReservedTxsFreezer is the entry point run before repair(). See the
+// package doc on reservedTxsMigrationMarker and the cases below for the full
+// protocol; in short: pad the reserved-tx table up to a durably recorded
+// target so introducing it never truncates pre-existing history, and once
+// that one-time migration is done, tell an ordinary crash-mid-freeze (still
+// recoverable via repair) apart from a downgrade gap (the classification is
+// permanently gone; pad with empties and say so loudly).
+//
+// repair() truncates every table - reserved included - down to the global
+// minimum item count. That means coreMin, not coreMax, is what the reserved
+// table must never fall behind without disambiguation: if it did, repair()
+// would truncate the core tables (headers, hashes, bodies, receipts,
+// difficulty, bor-receipts) down to the reserved table's stale head even
+// when those core tables only disagree with each other by an ordinary,
+// unrelated crash. Both the resume branch and healReservedTxsGap are
+// therefore always anchored to coreMin; any residual coreMin..coreMax
+// disagreement among the core tables themselves is left for repair()'s own
+// common-length truncation to resolve, which is safe once reserved is no
+// longer the thing dragging the global minimum down.
+func migrateReservedTxsFreezer(f *Freezer, db ethdb.KeyValueStore) error {
+ reserved, ok := f.tables[ChainFreezerReservedTxsTable]
+ if !ok {
+ return nil
+ }
+
+ coreMin, _, ok := coreTablesHeadRange(f)
+ if !ok {
+ return nil
+ }
+
+ marker, found := readReservedTxsMigrationMarker(db)
+ switch {
+ case !found:
+ return startReservedTxsMigration(f, db, reserved, coreMin)
+ case !marker.Done:
+ return resumeReservedTxsMigration(f, db, reserved, marker.Target, coreMin)
+ default:
+ return healReservedTxsGap(f, db, reserved, coreMin)
+ }
+}
+
+// coreTablesHeadRange returns the min and max item counts across every table
+// except the reserved-tx one. ok is false only if the reserved-tx table is
+// somehow the sole table in f, which never happens for the chain freezer.
+func coreTablesHeadRange(f *Freezer) (min, max uint64, ok bool) {
+ min = math.MaxUint64
+ for name, table := range f.tables {
+ if name == ChainFreezerReservedTxsTable {
+ continue
+ }
+ items := table.items.Load()
+ if items < min {
+ min = items
+ }
+ if items > max {
+ max = items
+ }
+ ok = true
+ }
+ if !ok {
+ min = 0
+ }
+ return min, max, ok
+}
+
+// startReservedTxsMigration handles the marker-absent case: a fresh node (no
+// pre-existing core-table history) marks done immediately with nothing to
+// pad, while a node upgrading over existing history records the pad target
+// durably before padding, so a crash mid-pad resumes to that fixed target
+// rather than re-deriving one from whatever the core tables happen to be at
+// on the next start.
+func startReservedTxsMigration(f *Freezer, db ethdb.KeyValueStore, reserved *freezerTable, coreHead uint64) error {
+ if coreHead == 0 {
+ return writeReservedTxsMigrationMarker(db, reservedTxsMigrationMarker{Done: true})
+ }
+ if err := writeReservedTxsMigrationMarker(db, reservedTxsMigrationMarker{Target: coreHead}); err != nil {
+ return err
+ }
+ return padReservedTxsTable(f, db, reserved, coreHead)
+}
+
+// padReservedTxsTable pads reserved up to target and then marks the migration
+// done. It is used for a fresh migration's initial pad and wherever a target
+// is already known to be safe to pad to outright; the target itself is never
+// recomputed here.
+func padReservedTxsTable(f *Freezer, db ethdb.KeyValueStore, reserved *freezerTable, target uint64) error {
+ if err := padReservedTxsTableTo(f, reserved, target); err != nil {
+ return err
+ }
+ return writeReservedTxsMigrationMarker(db, reservedTxsMigrationMarker{Target: target, Done: true})
+}
+
+// resumeReservedTxsMigration resumes a migration interrupted mid-pad. The
+// recorded target is honored as-is first - it is never re-derived downward -
+// but the core tables may have advanced past it since the crash: an older
+// bor with no knowledge of the reserved-tx table or its marker can freeze
+// further blocks into the core tables alone during a downgrade window. Any
+// such residual gap up to the current coreMin is therefore handed to
+// healReservedTxsGap for the same hot-data disambiguation the post-done path
+// uses, so a stale recorded target can never leave repair() looking at an
+// unbacked reserved-table deficit either.
+func resumeReservedTxsMigration(f *Freezer, db ethdb.KeyValueStore, reserved *freezerTable, target, coreMin uint64) error {
+ if err := padReservedTxsTable(f, db, reserved, target); err != nil {
+ return err
+ }
+ return healReservedTxsGap(f, db, reserved, coreMin)
+}
+
+// healReservedTxsGap disambiguates a reserved-tx table that has fallen
+// behind coreHead (always coreMin - see migrateReservedTxsFreezer). The two
+// possible causes need opposite handling: a freeze that crashed before the
+// post-freeze hot-KV cleanup ran (hot data for the gap still present, so
+// repair()'s truncate-and-refreeze recovers the true classifications, and
+// touching the reserved table here would only fight that) versus a downgrade
+// window during which an older bor froze blocks with no reserved-tx table at
+// all (hot data for the gap already cleaned up, so the classification is
+// gone for good and the gap is padded with empties instead, before repair()
+// ever gets a chance to see the deficit and truncate every other table down
+// to it).
+func healReservedTxsGap(f *Freezer, db ethdb.KeyValueStore, reserved *freezerTable, coreHead uint64) error {
+ reservedHead := reserved.items.Load()
+ if reservedHead >= coreHead {
+ return nil
+ }
+
+ gapStart := f.offset.Load() + reservedHead
+ if reservedTxsGapHotDataPresent(db, gapStart) {
+ return nil
+ }
+
+ log.Error("Reserved transaction classification lost for frozen blocks written without the reserved-tx freezer table; padding with empty entries",
+ "fromBlock", gapStart, "toBlock", f.offset.Load()+coreHead-1)
+
+ if err := writeReservedTxsMigrationMarker(db, reservedTxsMigrationMarker{Target: coreHead}); err != nil {
+ return err
+ }
+ return padReservedTxsTable(f, db, reserved, coreHead)
+}
+
+// reservedTxsGapHotDataPresent reports whether the hot canonical-hash mapping
+// for blockNumber is still in the key-value store. That mapping (like the
+// rest of a block's hot data) is deleted only after a freeze cycle commits
+// successfully, so its presence pins the gap to a crash mid-freeze rather
+// than a downgrade window; checking the earliest gap block is decisive for
+// the whole gap because a freeze batch's hot cleanup is all-or-nothing.
+func reservedTxsGapHotDataPresent(db ethdb.KeyValueStore, blockNumber uint64) bool {
+ present, err := db.Has(headerHashKey(blockNumber))
+ if err != nil {
+ // Lookup failure: prefer the safe branch that defers to repair()'s
+ // crash recovery instead of permanently discarding classification we
+ // could not confirm is actually gone.
+ log.Warn("Failed to check hot chain data while migrating reserved-tx freezer table", "block", blockNumber, "err", err)
+ return true
+ }
+ return present
+}
+
+// padReservedTxsTableTo appends empty reserved-tx entries to reserved until
+// its item count reaches target (a no-op if it is already there), then syncs
+// the table so the pad is durable before the caller records the marker done.
+//
+// freezerTable.Fill (freezer_table.go) looks similar but does not fit: it
+// hardcodes newBatch(0), ignoring the freezer's global offset (wrong for a
+// pruned/offset freezer), and appends via Append(item, nil), RLP-encoding a
+// bare nil rather than an empty list.
+func padReservedTxsTableTo(f *Freezer, reserved *freezerTable, target uint64) error {
+ if reserved.items.Load() >= target {
+ return nil
+ }
+
+ targetGlobal := f.offset.Load() + target
+ batch := reserved.newBatch(f.offset.Load())
+ for batch.curItem < targetGlobal {
+ if err := batch.AppendRaw(batch.curItem, rlp.EmptyList); err != nil {
+ return fmt.Errorf("failed to pad reserved-tx freezer table to item %d: %w", targetGlobal, err)
+ }
+ }
+ if err := batch.commit(); err != nil {
+ return fmt.Errorf("failed to commit padded reserved-tx freezer table: %w", err)
+ }
+ return reserved.Sync()
+}
diff --git a/core/rawdb/reserved_txs_freezer_test.go b/core/rawdb/reserved_txs_freezer_test.go
new file mode 100644
index 0000000000..cd4160005d
--- /dev/null
+++ b/core/rawdb/reserved_txs_freezer_test.go
@@ -0,0 +1,464 @@
+package rawdb
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/ethereum/go-ethereum/common"
+ "github.com/ethereum/go-ethereum/ethdb"
+ "github.com/ethereum/go-ethereum/ethdb/memorydb"
+ "github.com/ethereum/go-ethereum/rlp"
+)
+
+// newTestReservedFreezerTables is a minimal two-table stand-in for
+// chainFreezerTableConfigs: "core" represents every pre-existing chain
+// freezer table collectively (the migration logic treats them uniformly),
+// and the reserved-tx table is configured exactly as in production.
+func newTestReservedFreezerTables() map[string]freezerTableConfig {
+ return map[string]freezerTableConfig{
+ "core": {noSnappy: true, prunable: true},
+ ChainFreezerReservedTxsTable: {noSnappy: false, prunable: true},
+ }
+}
+
+// buildCoreOnlyHistory creates n items of "core"-table-only history at dir,
+// with no reserved-tx table at all, simulating a freezer written by a
+// pre-upgrade bor version.
+func buildCoreOnlyHistory(t *testing.T, dir string, n uint64) {
+ t.Helper()
+
+ f, err := newFreezer(dir, "", false, 0, 2049, map[string]freezerTableConfig{"core": {noSnappy: true, prunable: true}}, nil)
+ require.NoError(t, err)
+
+ _, err = f.ModifyAncients(func(op ethdb.AncientWriteOp) error {
+ for i := uint64(0); i < n; i++ {
+ if err := op.AppendRaw("core", i, []byte{byte(i)}); err != nil {
+ return err
+ }
+ }
+ return nil
+ })
+ require.NoError(t, err)
+ require.NoError(t, f.Close())
+}
+
+// padStandaloneReservedTable creates the reserved-tx table file at dir
+// directly (bypassing the full Freezer/repair machinery) and appends n empty
+// entries to it, letting a test hand-construct a specific pre-migration disk
+// state (e.g. "already padded partway, then crashed").
+func padStandaloneReservedTable(t *testing.T, dir string, n uint64) {
+ t.Helper()
+
+ cfg := newTestReservedFreezerTables()[ChainFreezerReservedTxsTable]
+ rt, err := newFreezerTable(dir, ChainFreezerReservedTxsTable, cfg, false)
+ require.NoError(t, err)
+
+ batch := rt.newBatch(0)
+ for i := uint64(0); i < n; i++ {
+ require.NoError(t, batch.AppendRaw(i, rlp.EmptyList))
+ }
+ require.NoError(t, batch.commit())
+ require.NoError(t, rt.Close())
+}
+
+// newTestReservedFreezerTablesMultiCore is a two-core-table stand-in for
+// chainFreezerTableConfigs. newTestReservedFreezerTables' single "core"
+// table always has coreMin == coreMax by construction; reaching the
+// coreMin != coreMax branch of the migration (two pre-existing tables left
+// at different lengths by an ordinary crash) needs at least two.
+func newTestReservedFreezerTablesMultiCore() map[string]freezerTableConfig {
+ return map[string]freezerTableConfig{
+ "coreA": {noSnappy: true, prunable: true},
+ "coreB": {noSnappy: true, prunable: true},
+ ChainFreezerReservedTxsTable: {noSnappy: false, prunable: true},
+ }
+}
+
+// buildStandaloneCoreTable creates a single named core table file at dir,
+// bypassing Freezer.ModifyAncients (which requires every table in one commit
+// to land on the same item count, so it cannot itself produce two core
+// tables of different lengths), and appends n raw items to it.
+func buildStandaloneCoreTable(t *testing.T, dir, name string, n uint64) {
+ t.Helper()
+
+ ct, err := newFreezerTable(dir, name, freezerTableConfig{noSnappy: true, prunable: true}, false)
+ require.NoError(t, err)
+
+ batch := ct.newBatch(0)
+ for i := uint64(0); i < n; i++ {
+ require.NoError(t, batch.AppendRaw(i, []byte{byte(i)}))
+ }
+ require.NoError(t, batch.commit())
+ require.NoError(t, ct.Close())
+}
+
+func TestReservedTxsFreezerMigration_FreshNode(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ db := memorydb.New()
+
+ f, err := newFreezer(dir, "", false, 0, 2049, newTestReservedFreezerTables(), newReservedTxsMigrationHook(db))
+ require.NoError(t, err)
+ defer f.Close()
+
+ head, err := f.Ancients()
+ require.NoError(t, err)
+ require.Zero(t, head)
+
+ marker, found := readReservedTxsMigrationMarker(db)
+ require.True(t, found)
+ require.True(t, marker.Done)
+ require.Zero(t, marker.Target)
+}
+
+func TestReservedTxsFreezerMigration_PadsPreExistingHistory(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ buildCoreOnlyHistory(t, dir, 10)
+
+ db := memorydb.New()
+ f, err := newFreezer(dir, "", false, 0, 2049, newTestReservedFreezerTables(), newReservedTxsMigrationHook(db))
+ require.NoError(t, err)
+ defer f.Close()
+
+ head, err := f.Ancients()
+ require.NoError(t, err)
+ require.Equal(t, uint64(10), head, "pre-existing core-table history must not be truncated")
+ require.Equal(t, uint64(10), f.tables[ChainFreezerReservedTxsTable].items.Load())
+
+ for i := uint64(0); i < 10; i++ {
+ data, err := f.Ancient(ChainFreezerReservedTxsTable, i)
+ require.NoError(t, err)
+ require.Equal(t, rlp.EmptyList, data)
+ }
+
+ marker, found := readReservedTxsMigrationMarker(db)
+ require.True(t, found)
+ require.True(t, marker.Done)
+ require.Equal(t, uint64(10), marker.Target)
+}
+
+func TestReservedTxsFreezerMigration_CrashMidPad_ResumesToRecordedTarget(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ buildCoreOnlyHistory(t, dir, 10)
+
+ // Hand-simulate a crash partway through the initial pad: 4 of the
+ // eventual 10 empty entries already landed on disk, and the marker was
+ // durably written (at migration start, before any padding) recording
+ // target 10.
+ padStandaloneReservedTable(t, dir, 4)
+
+ db := memorydb.New()
+ require.NoError(t, writeReservedTxsMigrationMarker(db, reservedTxsMigrationMarker{Target: 10}))
+
+ f, err := newFreezer(dir, "", false, 0, 2049, newTestReservedFreezerTables(), newReservedTxsMigrationHook(db))
+ require.NoError(t, err)
+ defer f.Close()
+
+ head, err := f.Ancients()
+ require.NoError(t, err)
+ require.Equal(t, uint64(10), head)
+ require.Equal(t, uint64(10), f.tables[ChainFreezerReservedTxsTable].items.Load())
+
+ marker, found := readReservedTxsMigrationMarker(db)
+ require.True(t, found)
+ require.True(t, marker.Done)
+ require.Equal(t, uint64(10), marker.Target)
+}
+
+// TestReservedTxsFreezerMigration_PostDone_CrashMidFreeze_FallsThroughToRepair
+// covers the disambiguation's first branch: once migrated, a reserved table
+// that has fallen behind mutually-consistent core tables, with the gap
+// blocks' hot data still present, is left alone so repair()'s ordinary
+// crash-recovery truncates every table back to the common head instead of
+// inventing empty classifications for blocks that may have reserved
+// transactions.
+func TestReservedTxsFreezerMigration_PostDone_CrashMidFreeze_FallsThroughToRepair(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ buildCoreOnlyHistory(t, dir, 12)
+ padStandaloneReservedTable(t, dir, 10)
+
+ db := memorydb.New()
+ require.NoError(t, writeReservedTxsMigrationMarker(db, reservedTxsMigrationMarker{Target: 10, Done: true}))
+ // Hot data for the gap blocks (10, 11) still present: post-freeze cleanup
+ // only runs after a successful freeze.
+ require.NoError(t, db.Put(headerHashKey(10), common.BytesToHash([]byte{0xaa}).Bytes()))
+ require.NoError(t, db.Put(headerHashKey(11), common.BytesToHash([]byte{0xbb}).Bytes()))
+
+ f, err := newFreezer(dir, "", false, 0, 2049, newTestReservedFreezerTables(), newReservedTxsMigrationHook(db))
+ require.NoError(t, err)
+ defer f.Close()
+
+ head, err := f.Ancients()
+ require.NoError(t, err)
+ require.Equal(t, uint64(10), head, "repair() truncates every table to the common head, not just reserved")
+ require.Equal(t, uint64(10), f.tables[ChainFreezerReservedTxsTable].items.Load())
+
+ // The marker is untouched: this path never re-records or re-pads.
+ marker, found := readReservedTxsMigrationMarker(db)
+ require.True(t, found)
+ require.Equal(t, uint64(10), marker.Target)
+}
+
+// TestReservedTxsFreezerMigration_DowngradeGap_PadsAndHeals covers the
+// disambiguation's second branch: the gap blocks' hot data is gone (an older
+// bor froze them successfully with no reserved-tx table at all), so the gap
+// is permanently unrecoverable and gets healed with empty entries instead of
+// truncating the otherwise-healthy core tables.
+func TestReservedTxsFreezerMigration_DowngradeGap_PadsAndHeals(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ buildCoreOnlyHistory(t, dir, 12)
+ padStandaloneReservedTable(t, dir, 10)
+
+ db := memorydb.New()
+ require.NoError(t, writeReservedTxsMigrationMarker(db, reservedTxsMigrationMarker{Target: 10, Done: true}))
+ // No hot data for blocks 10, 11: cleaned up by a successful, older-bor freeze.
+
+ f, err := newFreezer(dir, "", false, 0, 2049, newTestReservedFreezerTables(), newReservedTxsMigrationHook(db))
+ require.NoError(t, err)
+ defer f.Close()
+
+ head, err := f.Ancients()
+ require.NoError(t, err)
+ require.Equal(t, uint64(12), head, "core-table history is preserved, not truncated to reserved's lagging head")
+ require.Equal(t, uint64(12), f.tables[ChainFreezerReservedTxsTable].items.Load())
+
+ for i := uint64(10); i < 12; i++ {
+ data, err := f.Ancient(ChainFreezerReservedTxsTable, i)
+ require.NoError(t, err)
+ require.Equal(t, rlp.EmptyList, data)
+ }
+
+ marker, found := readReservedTxsMigrationMarker(db)
+ require.True(t, found)
+ require.True(t, marker.Done)
+ require.Equal(t, uint64(12), marker.Target)
+}
+
+// TestReservedTxsFreezerMigration_MultiCoreCrashRepro reproduces the
+// confirmed data-loss scenario: the migration completed at head 5, then a
+// downgrade let an old bor with no knowledge of the reserved-tx table freeze
+// blocks 5..10 into the core tables alone and clean up their hot data,
+// before dying uncleanly and leaving the two core tables themselves at
+// unequal lengths (12 and 11). Before the fix, marker.Done && coreMin !=
+// coreMax returned nil unconditionally, so repair() computed the head as the
+// minimum over every table including the stale reserved one (5) and
+// truncated every core table down to 5, destroying blocks 5..10 for good.
+// The fix must pad reserved up to coreMin (11) first, leaving only the
+// ordinary 11-vs-12 disagreement between the core tables for repair() to
+// resolve on its own.
+func TestReservedTxsFreezerMigration_MultiCoreCrashRepro(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ buildStandaloneCoreTable(t, dir, "coreA", 12)
+ buildStandaloneCoreTable(t, dir, "coreB", 11)
+ padStandaloneReservedTable(t, dir, 5)
+
+ db := memorydb.New()
+ require.NoError(t, writeReservedTxsMigrationMarker(db, reservedTxsMigrationMarker{Target: 5, Done: true}))
+ // Hot data for block 11 only: blocks 5..10 were already frozen and
+ // hot-cleaned by the old bor before it crashed.
+ require.NoError(t, db.Put(headerHashKey(11), common.BytesToHash([]byte{0xcc}).Bytes()))
+
+ f, err := newFreezer(dir, "", false, 0, 2049, newTestReservedFreezerTablesMultiCore(), newReservedTxsMigrationHook(db))
+ require.NoError(t, err)
+ defer f.Close()
+
+ head, err := f.Ancients()
+ require.NoError(t, err)
+ require.Equal(t, uint64(11), head, "must resolve to the ordinary 11-vs-12 crash range, not truncate to the stale reserved head 5")
+ require.Equal(t, uint64(11), f.tables["coreA"].items.Load())
+ require.Equal(t, uint64(11), f.tables["coreB"].items.Load())
+ require.Equal(t, uint64(11), f.tables[ChainFreezerReservedTxsTable].items.Load())
+
+ for i := uint64(5); i < 11; i++ {
+ data, err := f.Ancient(ChainFreezerReservedTxsTable, i)
+ require.NoError(t, err)
+ require.Equal(t, rlp.EmptyList, data, "blocks 5..10 lost their classification to the downgrade and must be padded, not left to drag the core tables down with them")
+ }
+
+ marker, found := readReservedTxsMigrationMarker(db)
+ require.True(t, found)
+ require.True(t, marker.Done)
+ require.Equal(t, uint64(11), marker.Target)
+}
+
+// TestReservedTxsFreezerMigration_MultiCoreCrashMidFreeze_NoPadding covers an
+// ordinary crash-mid-freeze even when the core tables themselves disagree:
+// the reserved table's one-block deficit is fully backed by still-present
+// hot data, so it must be left alone (no padding, marker untouched) and
+// folded into repair()'s single common-length truncation together with the
+// core tables' own 11-vs-12 disagreement.
+func TestReservedTxsFreezerMigration_MultiCoreCrashMidFreeze_NoPadding(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ buildStandaloneCoreTable(t, dir, "coreA", 12)
+ buildStandaloneCoreTable(t, dir, "coreB", 11)
+ padStandaloneReservedTable(t, dir, 10)
+
+ db := memorydb.New()
+ require.NoError(t, writeReservedTxsMigrationMarker(db, reservedTxsMigrationMarker{Target: 10, Done: true}))
+ // Hot data for block 10 still present: the freeze that would have
+ // written it (and cleaned it up) never completed.
+ require.NoError(t, db.Put(headerHashKey(10), common.BytesToHash([]byte{0xdd}).Bytes()))
+
+ f, err := newFreezer(dir, "", false, 0, 2049, newTestReservedFreezerTablesMultiCore(), newReservedTxsMigrationHook(db))
+ require.NoError(t, err)
+ defer f.Close()
+
+ head, err := f.Ancients()
+ require.NoError(t, err)
+ require.Equal(t, uint64(10), head, "repair() truncates every table to the common head, reserved's hot-data-backed deficit included")
+ require.Equal(t, uint64(10), f.tables["coreA"].items.Load())
+ require.Equal(t, uint64(10), f.tables["coreB"].items.Load())
+ require.Equal(t, uint64(10), f.tables[ChainFreezerReservedTxsTable].items.Load())
+
+ marker, found := readReservedTxsMigrationMarker(db)
+ require.True(t, found)
+ require.Equal(t, uint64(10), marker.Target, "no padding happened, so the marker must be untouched")
+}
+
+// TestReservedTxsFreezerMigration_ResumeStaleTarget_PadsToCoreMin covers the
+// resume branch's counterpart to the crash repro above: the migration was
+// interrupted mid-pad with recorded target 8, then a downgrade froze further
+// blocks into the core tables alone (which also end up disagreeing with each
+// other, 15 vs 14) before an unclean crash. Resuming must honor the recorded
+// target first (padding to 8, never re-derived downward), then detect the
+// additional, no-longer-hot-data-backed gap up to the current coreMin (14)
+// and pad that too, rather than marking done at the stale target 8 and
+// leaving repair() to see an unbacked reserved deficit again.
+func TestReservedTxsFreezerMigration_ResumeStaleTarget_PadsToCoreMin(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ buildStandaloneCoreTable(t, dir, "coreA", 15)
+ buildStandaloneCoreTable(t, dir, "coreB", 14)
+ padStandaloneReservedTable(t, dir, 3)
+
+ db := memorydb.New()
+ require.NoError(t, writeReservedTxsMigrationMarker(db, reservedTxsMigrationMarker{Target: 8}))
+ // No hot data anywhere in 3..13: the original crash's own gap (3..8) and
+ // the later downgrade's gap (8..14) were both cleaned up by successful
+ // freezes.
+
+ f, err := newFreezer(dir, "", false, 0, 2049, newTestReservedFreezerTablesMultiCore(), newReservedTxsMigrationHook(db))
+ require.NoError(t, err)
+ defer f.Close()
+
+ head, err := f.Ancients()
+ require.NoError(t, err)
+ require.Equal(t, uint64(14), head)
+ require.Equal(t, uint64(14), f.tables["coreA"].items.Load())
+ require.Equal(t, uint64(14), f.tables["coreB"].items.Load())
+ require.Equal(t, uint64(14), f.tables[ChainFreezerReservedTxsTable].items.Load())
+
+ for i := uint64(3); i < 14; i++ {
+ data, err := f.Ancient(ChainFreezerReservedTxsTable, i)
+ require.NoError(t, err)
+ require.Equal(t, rlp.EmptyList, data)
+ }
+
+ marker, found := readReservedTxsMigrationMarker(db)
+ require.True(t, found)
+ require.True(t, marker.Done)
+ require.Equal(t, uint64(14), marker.Target, "resume must not leave the stale recorded target as the final one")
+}
+
+// TestReservedTxsFreezerMigration_ReadOnlyOpenBeforeMigrationFails documents
+// the accepted limitation: a read-only open of a freezer that has not yet
+// run the read-write migration fails validate() with a differing-head error,
+// exactly as opening any newly introduced table read-only against existing
+// history already would. The remedy is one read-write start.
+func TestReservedTxsFreezerMigration_ReadOnlyOpenBeforeMigrationFails(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ buildCoreOnlyHistory(t, dir, 5)
+
+ _, err := newFreezer(dir, "", true, 0, 2049, newTestReservedFreezerTables(), nil)
+ require.Error(t, err)
+}
+
+// TestReservedTxsFreezerMigration_ReadOnlyOpenAfterMigrationSucceeds is the
+// counterpart: once a read-write start has completed the migration, a
+// subsequent read-only open validates cleanly.
+func TestReservedTxsFreezerMigration_ReadOnlyOpenAfterMigrationSucceeds(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ buildCoreOnlyHistory(t, dir, 5)
+
+ db := memorydb.New()
+ f, err := newFreezer(dir, "", false, 0, 2049, newTestReservedFreezerTables(), newReservedTxsMigrationHook(db))
+ require.NoError(t, err)
+ require.NoError(t, f.Close())
+
+ f2, err := newFreezer(dir, "", true, 0, 2049, newTestReservedFreezerTables(), nil)
+ require.NoError(t, err)
+ defer f2.Close()
+
+ head, err := f2.Ancients()
+ require.NoError(t, err)
+ require.Equal(t, uint64(5), head)
+}
+
+// TestReservedTxsFreezerMigration_PrunedOffsetFreezer covers a freezer whose
+// items don't start at global block 0 (e.g. after ancient-store pruning
+// rebased it), confirming the pad target and appended item numbers are
+// computed consistently with the freezer's offset.
+func TestReservedTxsFreezerMigration_PrunedOffsetFreezer(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ const offset = uint64(1000)
+
+ f0, err := newFreezer(dir, "", false, offset, 2049, map[string]freezerTableConfig{"core": {noSnappy: true, prunable: true}}, nil)
+ require.NoError(t, err)
+ _, err = f0.ModifyAncients(func(op ethdb.AncientWriteOp) error {
+ for i := uint64(0); i < 5; i++ {
+ if err := op.AppendRaw("core", offset+i, []byte{byte(i)}); err != nil {
+ return err
+ }
+ }
+ return nil
+ })
+ require.NoError(t, err)
+ require.NoError(t, f0.Close())
+
+ db := memorydb.New()
+ f, err := newFreezer(dir, "", false, offset, 2049, newTestReservedFreezerTables(), newReservedTxsMigrationHook(db))
+ require.NoError(t, err)
+ defer f.Close()
+
+ require.Equal(t, uint64(5), f.tables[ChainFreezerReservedTxsTable].items.Load())
+ for i := uint64(0); i < 5; i++ {
+ data, err := f.Ancient(ChainFreezerReservedTxsTable, offset+i)
+ require.NoError(t, err)
+ require.Equal(t, rlp.EmptyList, data)
+ }
+}
+
+// TestReservedTxsMigrationMarker_CorruptTreatedAsAbsent covers a marker value
+// that fails to RLP-decode: it must be treated as absent (triggering a fresh
+// migration attempt) rather than propagating a decode error up through
+// freezer construction.
+func TestReservedTxsMigrationMarker_CorruptTreatedAsAbsent(t *testing.T) {
+ t.Parallel()
+
+ db := memorydb.New()
+ require.NoError(t, db.Put(reservedTxsMigrationKey, []byte{0x01, 0x02, 0x03}))
+
+ _, found := readReservedTxsMigrationMarker(db)
+ require.False(t, found)
+}
diff --git a/core/rawdb/reserved_txs_test.go b/core/rawdb/reserved_txs_test.go
new file mode 100644
index 0000000000..172d4aeb0b
--- /dev/null
+++ b/core/rawdb/reserved_txs_test.go
@@ -0,0 +1,128 @@
+package rawdb
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/ethereum/go-ethereum/common"
+ "github.com/ethereum/go-ethereum/rlp"
+)
+
+func TestWriteReadReservedTxIndexes_RoundTrip(t *testing.T) {
+ t.Parallel()
+
+ db := NewMemoryDatabase()
+ hash := common.BytesToHash([]byte{0x01})
+ number := uint64(7)
+
+ WriteReservedTxIndexes(db, hash, number, []uint64{0, 2, 5})
+
+ got := ReadReservedTxIndexes(db, hash, number)
+ require.Equal(t, []uint64{0, 2, 5}, got)
+}
+
+func TestWriteReservedTxIndexes_EmptyIsNoop(t *testing.T) {
+ t.Parallel()
+
+ db := NewMemoryDatabase()
+ hash := common.BytesToHash([]byte{0x02})
+ number := uint64(1)
+
+ WriteReservedTxIndexes(db, hash, number, nil)
+
+ has, err := db.Has(reservedTxIndexesKey(number, hash))
+ require.NoError(t, err)
+ require.False(t, has, "writing an empty index list must not grow hot storage")
+
+ WriteReservedTxIndexes(db, hash, number, []uint64{})
+ has, err = db.Has(reservedTxIndexesKey(number, hash))
+ require.NoError(t, err)
+ require.False(t, has)
+}
+
+func TestReadReservedTxIndexes_Absent(t *testing.T) {
+ t.Parallel()
+
+ db := NewMemoryDatabase()
+ got := ReadReservedTxIndexes(db, common.BytesToHash([]byte{0x03}), 9)
+ require.Nil(t, got)
+}
+
+func TestDeleteReservedTxIndexes(t *testing.T) {
+ t.Parallel()
+
+ db := NewMemoryDatabase()
+ hash := common.BytesToHash([]byte{0x04})
+ number := uint64(3)
+
+ WriteReservedTxIndexes(db, hash, number, []uint64{1})
+ require.NotNil(t, ReadReservedTxIndexes(db, hash, number))
+
+ DeleteReservedTxIndexes(db, hash, number)
+ require.Nil(t, ReadReservedTxIndexes(db, hash, number))
+
+ // Deleting an already-absent entry must not error.
+ DeleteReservedTxIndexes(db, hash, number)
+}
+
+// TestReadReservedTxIndexes_Malformed covers the read-side validation matrix:
+// a structurally invalid entry is treated as absent (nil), never trusted into
+// deriving reserved status for the wrong transaction.
+func TestReadReservedTxIndexes_Malformed(t *testing.T) {
+ t.Parallel()
+
+ cases := []struct {
+ name string
+ data []byte
+ }{
+ {"unsorted", mustEncodeUint64s(t, []uint64{2, 1})},
+ {"duplicate", mustEncodeUint64s(t, []uint64{1, 1, 2})},
+ {"oversized", make([]byte, maxReservedTxIndexesEncodedSize+1)},
+ {"not RLP list of uint64", []byte{0x01, 0x02, 0x03}},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ db := NewMemoryDatabase()
+ hash := common.BytesToHash([]byte{0x05})
+ number := uint64(11)
+
+ require.NoError(t, db.Put(reservedTxIndexesKey(number, hash), tc.data))
+ require.Nil(t, ReadReservedTxIndexes(db, hash, number))
+ })
+ }
+}
+
+// TestReadReservedTxIndexesBounded covers the out-of-range check that only a
+// caller holding the block's transaction count can make.
+func TestReadReservedTxIndexesBounded(t *testing.T) {
+ t.Parallel()
+
+ db := NewMemoryDatabase()
+ hash := common.BytesToHash([]byte{0x06})
+ number := uint64(4)
+
+ WriteReservedTxIndexes(db, hash, number, []uint64{0, 3})
+
+ // In range: txCount 4 covers indexes [0,3].
+ require.Equal(t, []uint64{0, 3}, ReadReservedTxIndexesBounded(db, hash, number, 4))
+
+ // Out of range: txCount 3 does not cover index 3; the whole entry is
+ // treated as absent, not just the offending index.
+ require.Nil(t, ReadReservedTxIndexesBounded(db, hash, number, 3))
+
+ // Absent entry stays absent regardless of txCount.
+ require.Nil(t, ReadReservedTxIndexesBounded(db, common.BytesToHash([]byte{0x07}), number, 100))
+}
+
+func mustEncodeUint64s(t *testing.T, v []uint64) []byte {
+ t.Helper()
+
+ data, err := rlp.EncodeToBytes(v)
+ require.NoError(t, err)
+
+ return data
+}
diff --git a/core/reserved_boundary_test.go b/core/reserved_boundary_test.go
new file mode 100644
index 0000000000..973e9a2696
--- /dev/null
+++ b/core/reserved_boundary_test.go
@@ -0,0 +1,331 @@
+package core
+
+import (
+ "context"
+ "crypto/ecdsa"
+ "fmt"
+ "math/big"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/ethereum/go-ethereum/common"
+ "github.com/ethereum/go-ethereum/consensus/ethash"
+ "github.com/ethereum/go-ethereum/core/state"
+ "github.com/ethereum/go-ethereum/core/types"
+ "github.com/ethereum/go-ethereum/core/vm"
+ "github.com/ethereum/go-ethereum/crypto"
+ "github.com/ethereum/go-ethereum/params"
+ "github.com/ethereum/go-ethereum/trie"
+)
+
+// boundaryParentRoot returns the parent state root for the i-th generated
+// block: the genesis root for the first block, the previous block's root
+// otherwise. Shared by both boundary tests so the parent-state walk cannot
+// drift between them.
+func boundaryParentRoot(bc *BlockChain, blocks types.Blocks, i int) common.Hash {
+ if i == 0 {
+ return bc.Genesis().Root()
+ }
+ return blocks[i-1].Root()
+}
+
+// boundaryStateAt resolves a fresh StateDB at root, failing the test on error.
+func boundaryStateAt(t *testing.T, bc *BlockChain, root common.Hash) *state.StateDB {
+ t.Helper()
+ st, err := bc.StateAt(root)
+ require.NoError(t, err)
+ return st
+}
+
+// cloneReceiptsForDerive returns a deep-enough copy of rs (each *Receipt and
+// each of its *Log dereferenced and copied) so that Receipts.DeriveFields -
+// which mutates its receiver's fields, including nested Log fields, in place
+// - can run against the copy without disturbing the original ProcessResult.
+// Callers compare the roots/blooms/gas of the original res.Receipts before
+// this runs, so mutating a clone rather than the original is precautionary,
+// not required for correctness, but keeps each processor's ProcessResult
+// intact for any later inspection in the same test.
+func cloneReceiptsForDerive(rs types.Receipts) types.Receipts {
+ out := make(types.Receipts, len(rs))
+ for i, r := range rs {
+ cp := *r
+ cp.Logs = make([]*types.Log, len(r.Logs))
+ for j, l := range r.Logs {
+ lc := *l
+ cp.Logs[j] = &lc
+ }
+ out[i] = &cp
+ }
+ return out
+}
+
+// TestReservedBoundaryParity_ProcessorsAgree extends the single-block parity
+// pattern in TestReservedTxIndexesParity_ProcessorsAgree across the
+// reserved-blockspace fork boundary itself: a 5-block chain with the fork
+// activating at block 3, so blocks 1-2 exercise the pre-fork (classification
+// off) path and blocks 3-5 exercise the post-fork path, on the same chain and
+// the same registry wiring. Every block is re-executed from its own parent
+// state by all three ProcessResult assembly sites - serial, parallel V1
+// (ParallelStateProcessor), and parallel V2 (BlockSTM) - and asserted
+// identical on every field ProcessResult carries plus the derived receipt
+// roots, bloom, and state root that ValidateState checks downstream. A
+// divergence at exactly the fork-transition block (3) is the failure mode
+// most likely to survive a single-block test but not this one.
+//
+// As in the precedent, block generation uses an ethash faker chain purely as
+// a body vehicle (GenerateChain never touches header.Extra or runs
+// reserved-aware execution - see core/evm.go's ReservedSnapshotForBlock and
+// the miner's writeReservedFields, neither of which GenerateChain calls); the
+// reserved senders' transactions carry ordinary fallback fees so they are
+// valid to generate under vanilla fee rules; the registry wiring on the
+// re-executing chain then reclassifies them fee-free from block 3 onward.
+func TestReservedBoundaryParity_ProcessorsAgree(t *testing.T) {
+ var (
+ keyReservedA, _ = crypto.GenerateKey()
+ keyReservedB, _ = crypto.GenerateKey()
+ keyNormal, _ = crypto.GenerateKey()
+ addrReservedA = crypto.PubkeyToAddress(keyReservedA.PublicKey)
+ addrReservedB = crypto.PubkeyToAddress(keyReservedB.PublicKey)
+ addrNormal = crypto.PubkeyToAddress(keyNormal.PublicKey)
+ recipient = common.HexToAddress("0xbb")
+ )
+
+ const (
+ forkBlock = 3
+ numBlocks = 5
+ quota = 100_000
+ perTxGas = 21000
+ reservedTx = 2 * perTxGas // addrReservedA + addrReservedB, one tx each per block
+ )
+
+ config := reservedActiveConfig(t)
+ config.Bor.ReservedBlockspaceBlock = big.NewInt(forkBlock)
+ // checkReservedBlockspaceForkOrder requires ReservedBlockspaceBlock to be
+ // at or after CancunBlock/GiuglianoBlock; BorUnittestChainConfig
+ // (reservedActiveConfig's base) doesn't schedule Cancun/Shanghai, so
+ // activate the full pre-Cancun fork ladder from genesis too, mirroring
+ // reserved_parity_test.go's mutation of the same shared config helper.
+ config.LondonBlock = big.NewInt(0)
+ config.ShanghaiBlock = big.NewInt(0)
+ config.CancunBlock = big.NewInt(0)
+ config.Bor.GiuglianoBlock = big.NewInt(0)
+ signer := types.LatestSigner(config)
+
+ genesis := &Genesis{
+ Config: config,
+ BaseFee: big.NewInt(params.InitialBaseFee),
+ Alloc: types.GenesisAlloc{
+ addrReservedA: {Balance: big.NewInt(1e18)},
+ addrReservedB: {Balance: big.NewInt(1e18)},
+ addrNormal: {Balance: big.NewInt(1e18)},
+ },
+ }
+
+ mkTx := func(key *ecdsa.PrivateKey, nonce uint64) *types.Transaction {
+ tx, err := types.SignNewTx(key, signer, &types.LegacyTx{
+ Nonce: nonce, To: &recipient, Value: big.NewInt(1),
+ Gas: perTxGas, GasPrice: big.NewInt(params.InitialBaseFee * 2),
+ })
+ require.NoError(t, err)
+ return tx
+ }
+
+ // Each block carries one ordinary-fee tx from each of the two reserved
+ // senders and the normal sender, always in the same [reservedA, normal,
+ // reservedB] order, so the expected reserved indexes ([0, 2]) are
+ // constant across every post-fork block.
+ genDb, blocks, _ := GenerateChainWithGenesis(genesis, ethash.NewFaker(), numBlocks, func(i int, b *BlockGen) {
+ n := uint64(i)
+ b.AddTx(mkTx(keyReservedA, n))
+ b.AddTx(mkTx(keyNormal, n))
+ b.AddTx(mkTx(keyReservedB, n))
+ })
+ require.Len(t, blocks, numBlocks)
+
+ // NewParallelBlockChain (not NewBlockChain) so bc.parallelSpeculativeProcesses
+ // is actually set: ParallelStateProcessor.Process reads it at call time, and
+ // leaving it at its zero value hangs blockstm.ExecuteParallel forever.
+ bc, err := NewParallelBlockChain(genDb, genesis, ethash.NewFaker(), DefaultConfig(), 2, false)
+ require.NoError(t, err)
+ defer bc.Stop()
+
+ bc.SetReservedRegistry(&fakeParityReader{
+ addrs: map[common.Address]struct{}{addrReservedA: {}, addrReservedB: {}},
+ quota: quota,
+ })
+
+ wantReservedIdx := []uint64{0, 2}
+
+ for i, block := range blocks {
+ blockNum := block.NumberU64()
+ t.Run(fmt.Sprintf("block_%d", blockNum), func(t *testing.T) {
+ root := boundaryParentRoot(bc, blocks, i)
+
+ serialSdb := boundaryStateAt(t, bc, root)
+ v1Sdb := boundaryStateAt(t, bc, root)
+ v2Sdb := boundaryStateAt(t, bc, root)
+
+ serialRes, err := bc.processor.Process(block, serialSdb, vm.Config{}, nil, context.Background())
+ require.NoError(t, err, "serial processor")
+
+ v1 := NewParallelStateProcessor(bc.hc, bc)
+ v1Res, err := v1.Process(block, v1Sdb, vm.Config{}, nil, context.Background())
+ require.NoError(t, err, "parallel-v1 (ParallelStateProcessor)")
+
+ v2 := NewV2StateProcessor(bc.hc, bc, 2)
+ v2Res, err := v2.Process(block, v2Sdb, vm.Config{}, nil, context.Background())
+ require.NoError(t, err, "parallel-v2 (BlockSTM)")
+
+ // 1. Receipt root.
+ serialReceiptRoot := types.DeriveSha(serialRes.Receipts, trie.NewStackTrie(nil))
+ require.Equal(t, serialReceiptRoot, types.DeriveSha(v1Res.Receipts, trie.NewStackTrie(nil)), "receipt root: serial vs v1")
+ require.Equal(t, serialReceiptRoot, types.DeriveSha(v2Res.Receipts, trie.NewStackTrie(nil)), "receipt root: serial vs v2")
+
+ // 2. Bloom.
+ serialBloom := types.MergeBloom(serialRes.Receipts)
+ require.Equal(t, serialBloom, types.MergeBloom(v1Res.Receipts), "bloom: serial vs v1")
+ require.Equal(t, serialBloom, types.MergeBloom(v2Res.Receipts), "bloom: serial vs v2")
+
+ // 3. Gas used.
+ require.Equal(t, serialRes.GasUsed, v1Res.GasUsed, "gas used: serial vs v1")
+ require.Equal(t, serialRes.GasUsed, v2Res.GasUsed, "gas used: serial vs v2")
+
+ // 4. Post-execution state root.
+ serialRoot := serialSdb.IntermediateRoot(config.IsEIP158(block.Number()))
+ v1Root := v1Sdb.IntermediateRoot(config.IsEIP158(block.Number()))
+ v2Root := v2Sdb.IntermediateRoot(config.IsEIP158(block.Number()))
+ require.Equal(t, serialRoot, v1Root, "state root: serial vs v1")
+ require.Equal(t, serialRoot, v2Root, "state root: serial vs v2")
+
+ // 5. Reserved fields.
+ require.Equal(t, serialRes.ReservedGasUsed, v1Res.ReservedGasUsed, "ReservedGasUsed: serial vs v1")
+ require.Equal(t, serialRes.ReservedGasUsed, v2Res.ReservedGasUsed, "ReservedGasUsed: serial vs v2")
+ require.Equal(t, serialRes.ReservedCapacity, v1Res.ReservedCapacity, "ReservedCapacity: serial vs v1")
+ require.Equal(t, serialRes.ReservedCapacity, v2Res.ReservedCapacity, "ReservedCapacity: serial vs v2")
+ require.Equal(t, serialRes.ReservedTxIndexes, v1Res.ReservedTxIndexes, "ReservedTxIndexes: serial vs v1")
+ require.Equal(t, serialRes.ReservedTxIndexes, v2Res.ReservedTxIndexes, "ReservedTxIndexes: serial vs v2")
+
+ // Boundary shape: the fork gate itself, not just cross-processor
+ // agreement on whatever it produces.
+ if blockNum < forkBlock {
+ require.Empty(t, serialRes.ReservedTxIndexes, "pre-fork block %d must classify nothing", blockNum)
+ require.Zero(t, serialRes.ReservedGasUsed, "pre-fork block %d must report zero reserved gas", blockNum)
+ require.Zero(t, serialRes.ReservedCapacity, "pre-fork block %d must report zero reserved capacity", blockNum)
+ } else {
+ require.Equal(t, wantReservedIdx, serialRes.ReservedTxIndexes, "post-fork block %d reserved indexes", blockNum)
+ require.EqualValues(t, reservedTx, serialRes.ReservedGasUsed, "post-fork block %d reserved gas", blockNum)
+ require.EqualValues(t, quota, serialRes.ReservedCapacity, "post-fork block %d reserved capacity", blockNum)
+ }
+
+ // Receipt-derivation determinism: rederive on a clone of each
+ // processor's own receipts (using that processor's own
+ // ReservedTxIndexes, already proven identical above) and check
+ // the resulting EffectiveGasPrice per transaction agrees across
+ // all three, and matches the reserved/fee-paying split.
+ derive := func(res *ProcessResult) types.Receipts {
+ cloned := cloneReceiptsForDerive(res.Receipts)
+ require.NoError(t, cloned.DeriveFields(config, block.Hash(), block.NumberU64(), block.Time(), block.BaseFee(), nil, block.Transactions(), res.ReservedTxIndexes))
+ return cloned
+ }
+ serialDerived := derive(serialRes)
+ v1Derived := derive(v1Res)
+ v2Derived := derive(v2Res)
+
+ reservedIdx := make(map[int]bool, len(serialRes.ReservedTxIndexes))
+ for _, idx := range serialRes.ReservedTxIndexes {
+ reservedIdx[int(idx)] = true
+ }
+ for idx := range block.Transactions() {
+ sEGP := serialDerived[idx].EffectiveGasPrice
+ v1EGP := v1Derived[idx].EffectiveGasPrice
+ v2EGP := v2Derived[idx].EffectiveGasPrice
+
+ if reservedIdx[idx] {
+ require.Zero(t, sEGP.Sign(), "tx %d: reserved txs must derive EffectiveGasPrice 0, got %s", idx, sEGP)
+ } else {
+ require.NotZero(t, sEGP.Sign(), "tx %d: fee-paying txs must derive a nonzero EffectiveGasPrice, got %s", idx, sEGP)
+ }
+ require.Zero(t, sEGP.Cmp(v1EGP), "tx %d EffectiveGasPrice: serial=%s vs v1=%s", idx, sEGP, v1EGP)
+ require.Zero(t, sEGP.Cmp(v2EGP), "tx %d EffectiveGasPrice: serial=%s vs v2=%s", idx, sEGP, v2EGP)
+ }
+ })
+ }
+}
+
+// TestReservedBoundaryNoReaderFailsClosed pins the fail-closed contract at
+// ReservedSnapshotForBlock (core/evm.go): once the fork is active and a
+// registry contract is configured, a chain with no reader wired must refuse
+// to process the block rather than silently classify nothing - a silent
+// empty set would disagree with any peer that does have a reader wired, since
+// the reserved senders would pay real fees on this node and none on that one.
+// The same 5-block, fork-at-3 setup as the parity test above is reused
+// (unwired here), so block 2 (pre-fork, no registry read needed) must still
+// succeed on all three processors while block 3 (first post-fork block) must
+// hard-error on all three.
+func TestReservedBoundaryNoReaderFailsClosed(t *testing.T) {
+ var (
+ key, _ = crypto.GenerateKey()
+ addr = crypto.PubkeyToAddress(key.PublicKey)
+ recipient = common.HexToAddress("0xcc")
+ )
+
+ const forkBlock = 3
+
+ config := reservedActiveConfig(t)
+ config.Bor.ReservedBlockspaceBlock = big.NewInt(forkBlock)
+ config.LondonBlock = big.NewInt(0)
+ config.ShanghaiBlock = big.NewInt(0)
+ config.CancunBlock = big.NewInt(0)
+ config.Bor.GiuglianoBlock = big.NewInt(0)
+ signer := types.LatestSigner(config)
+
+ genesis := &Genesis{
+ Config: config,
+ BaseFee: big.NewInt(params.InitialBaseFee),
+ Alloc: types.GenesisAlloc{
+ addr: {Balance: big.NewInt(1e18)},
+ },
+ }
+
+ mkTx := func(nonce uint64) *types.Transaction {
+ tx, err := types.SignNewTx(key, signer, &types.LegacyTx{
+ Nonce: nonce, To: &recipient, Value: big.NewInt(1),
+ Gas: 21000, GasPrice: big.NewInt(params.InitialBaseFee * 2),
+ })
+ require.NoError(t, err)
+ return tx
+ }
+
+ genDb, blocks, _ := GenerateChainWithGenesis(genesis, ethash.NewFaker(), forkBlock, func(i int, b *BlockGen) {
+ b.AddTx(mkTx(uint64(i)))
+ })
+ require.Len(t, blocks, forkBlock)
+
+ // No SetReservedRegistry call: this is exactly the condition under test.
+ bc, err := NewParallelBlockChain(genDb, genesis, ethash.NewFaker(), DefaultConfig(), 2, false)
+ require.NoError(t, err)
+ defer bc.Stop()
+
+ preFork := blocks[1] // block number 2, still below forkBlock (3)
+ require.EqualValues(t, 2, preFork.NumberU64())
+ preForkRoot := boundaryParentRoot(bc, blocks, 1)
+
+ _, err = bc.processor.Process(preFork, boundaryStateAt(t, bc, preForkRoot), vm.Config{}, nil, context.Background())
+ require.NoError(t, err, "serial must process the pre-fork block without a reader")
+ _, err = NewParallelStateProcessor(bc.hc, bc).Process(preFork, boundaryStateAt(t, bc, preForkRoot), vm.Config{}, nil, context.Background())
+ require.NoError(t, err, "parallel-v1 must process the pre-fork block without a reader")
+ _, err = NewV2StateProcessor(bc.hc, bc, 2).Process(preFork, boundaryStateAt(t, bc, preForkRoot), vm.Config{}, nil, context.Background())
+ require.NoError(t, err, "parallel-v2 must process the pre-fork block without a reader")
+
+ atFork := blocks[2] // block number 3, the fork's first active block
+ require.EqualValues(t, forkBlock, atFork.NumberU64())
+ atForkRoot := boundaryParentRoot(bc, blocks, 2)
+
+ _, err = bc.processor.Process(atFork, boundaryStateAt(t, bc, atForkRoot), vm.Config{}, nil, context.Background())
+ require.Error(t, err, "serial must fail closed at the fork boundary without a reader")
+ _, err = NewParallelStateProcessor(bc.hc, bc).Process(atFork, boundaryStateAt(t, bc, atForkRoot), vm.Config{}, nil, context.Background())
+ require.Error(t, err, "parallel-v1 must fail closed at the fork boundary without a reader")
+ _, err = NewV2StateProcessor(bc.hc, bc, 2).Process(atFork, boundaryStateAt(t, bc, atForkRoot), vm.Config{}, nil, context.Background())
+ require.Error(t, err, "parallel-v2 must fail closed at the fork boundary without a reader")
+}
diff --git a/core/reserved_fee_test.go b/core/reserved_fee_test.go
index 1bc65199bb..6f10f4448a 100644
--- a/core/reserved_fee_test.go
+++ b/core/reserved_fee_test.go
@@ -2,6 +2,7 @@ package core
import (
"context"
+ "errors"
"math/big"
"testing"
@@ -22,18 +23,16 @@ import (
var reservedBurntAddr = common.HexToAddress("0x00000000000000000000000000000000000000dd")
// reservedTestConfig clones BorUnittestChainConfig (London active at 0) and
-// layers the reserved-blockspace fork + an optional reserved client on top,
-// without mutating the shared global config.
+// layers the reserved-blockspace fork on top, without mutating the shared
+// global config. reservedSenders is accepted for call-site symmetry with
+// reservedBlockCtx (which builds the actual classification snapshot from
+// them); the config itself carries no reserved-client data — classification
+// is sourced from the registry snapshot on the block context, not config.
func reservedTestConfig(forkBlock *big.Int, reservedSenders ...common.Address) *params.ChainConfig {
cc := *params.BorUnittestChainConfig
bor := *cc.Bor
bor.BurntContract = map[string]string{"0": reservedBurntAddr.Hex()}
bor.ReservedBlockspaceBlock = forkBlock
- if len(reservedSenders) > 0 {
- bor.ReservedClients = []params.ReservedClient{
- {Addresses: reservedSenders, QuotaGas: 30_000_000},
- }
- }
cc.Bor = &bor
return &cc
}
@@ -309,3 +308,104 @@ func TestReservedTxSerialParallelParity(t *testing.T) {
}
}
}
+
+// TestReservedFallbackFeeWithinQuotaExecutesFeeFree pins POS-3671's execution
+// half end to end. Only nonce 0 is classified reserved below (modelling a
+// quota that's exhausted by it, as ClassifyReserved would decide for a real
+// block); nonce 1 is deliberately absent from ReservedTxs to model the
+// overflow case. The sender's balance covers the tx value many times over
+// but is nowhere near gas*feeCap, so:
+// - the within-quota tx (nonce 0) executes fee-free (buyGas waives the gas
+// debit entirely, same as TestReservedTxSkipsFees);
+// - the overflow tx (nonce 1), priced on the normal fee path since it isn't
+// in ReservedTxs, fails buyGas with ErrInsufficientFunds before touching
+// any balance — the same clean, pre-execution error any other underfunded
+// sender gets, which is exactly what lets a block builder exclude it
+// without invalidating the block being assembled (mirrors the pool's
+// admission-time waiver in core/txpool/legacypool being quota-unaware:
+// execution is the arbiter for overflow).
+func TestReservedFallbackFeeWithinQuotaExecutesFeeFree(t *testing.T) {
+ key, _ := crypto.GenerateKey()
+ sender := crypto.PubkeyToAddress(key.PublicKey)
+ coinbase := common.HexToAddress("0x000000000000000000000000000000000000c0b0")
+ recipient := common.HexToAddress("0x1111111111111111111111111111111111111111")
+
+ cc := reservedTestConfig(big.NewInt(0), sender)
+ baseFee := big.NewInt(1_000_000_000)
+
+ clients := map[common.Address]registryreader.Client{sender: {ID: 1, GasQuota: 100_000}}
+ snap := registryreader.NewSnapshot(common.HexToHash("0x1"), 100_000, clients)
+ blockCtx := vm.BlockContext{
+ CanTransfer: CanTransfer,
+ Transfer: Transfer,
+ GetHash: func(n uint64) common.Hash { return common.Hash{} },
+ Coinbase: coinbase,
+ GasLimit: 30_000_000,
+ BlockNumber: big.NewInt(1),
+ Time: 1,
+ BaseFee: baseFee,
+ ReservedSnapshot: snap,
+ ReservedTxs: map[registryreader.ReservedKey]struct{}{{From: sender, Nonce: 0}: {}},
+ }
+
+ // gas*feeCap = 21000 * 30 gwei = 6.3e14, far above the funded balance;
+ // only the value (1000 wei) is affordable.
+ feeCap := big.NewInt(30_000_000_000)
+ value := big.NewInt(1_000)
+ balance := uint256.NewInt(1_000_000)
+ sdb := fundedState(t, sender, balance)
+ signer := types.NewLondonSigner(cc.ChainID)
+
+ buildTx := func(nonce uint64) *Message {
+ tx, err := types.SignTx(types.NewTx(&types.DynamicFeeTx{
+ ChainID: cc.ChainID,
+ Nonce: nonce,
+ GasTipCap: feeCap,
+ GasFeeCap: feeCap,
+ Gas: 21000,
+ To: &recipient,
+ Value: value,
+ }), signer, key)
+ if err != nil {
+ t.Fatal(err)
+ }
+ msg, err := TransactionToMessage(tx, signer, baseFee)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return msg
+ }
+
+ // nonce 0: within quota, executes fee-free despite the gas-starved balance.
+ msg0 := buildTx(0)
+ evm := vm.NewEVM(blockCtx, sdb, cc, vm.Config{})
+ evm.SetTxContext(NewEVMTxContext(msg0))
+ result, err := ApplyMessage(evm, msg0, new(GasPool).AddGas(blockCtx.GasLimit))
+ if err != nil {
+ t.Fatalf("within-quota fallback-fee tx failed: %v", err)
+ }
+ if result.Failed() {
+ t.Fatalf("within-quota fallback-fee tx reverted: %v", result.Err)
+ }
+ wantBalance := new(uint256.Int).Sub(balance, uint256.MustFromBig(value))
+ if got := sdb.GetBalance(sender); got.Cmp(wantBalance) != 0 {
+ t.Fatalf("sender balance=%s, want %s (value only, no gas)", got, wantBalance)
+ }
+
+ // nonce 1: overflow (absent from ReservedTxs), priced on the normal fee
+ // path where the balance can't cover gas*feeCap. buyGas must reject it
+ // cleanly rather than partially applying it.
+ balanceBeforeOverflow := sdb.GetBalance(sender)
+ msg1 := buildTx(1)
+ evm2 := vm.NewEVM(blockCtx, sdb, cc, vm.Config{})
+ evm2.SetTxContext(NewEVMTxContext(msg1))
+ if _, err := ApplyMessage(evm2, msg1, new(GasPool).AddGas(blockCtx.GasLimit)); !errors.Is(err, ErrInsufficientFunds) {
+ t.Fatalf("overflow tx error = %v, want %v", err, ErrInsufficientFunds)
+ }
+ if got := sdb.GetBalance(sender); got.Cmp(balanceBeforeOverflow) != 0 {
+ t.Fatalf("sender balance changed by a failed, excluded tx: got %s, want unchanged %s", got, balanceBeforeOverflow)
+ }
+ if got := sdb.GetBalance(recipient); got.Cmp(uint256.MustFromBig(value)) != 0 {
+ t.Fatalf("recipient balance=%s, want %s (only the within-quota tx's value landed)", got, value)
+ }
+}
diff --git a/core/reserved_parity_test.go b/core/reserved_parity_test.go
new file mode 100644
index 0000000000..62c1d276d5
--- /dev/null
+++ b/core/reserved_parity_test.go
@@ -0,0 +1,200 @@
+package core
+
+import (
+ "context"
+ "crypto/ecdsa"
+ "math/big"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/ethereum/go-ethereum/common"
+ "github.com/ethereum/go-ethereum/consensus/bor/registryreader"
+ "github.com/ethereum/go-ethereum/consensus/ethash"
+ "github.com/ethereum/go-ethereum/core/rawdb"
+ "github.com/ethereum/go-ethereum/core/state"
+ "github.com/ethereum/go-ethereum/core/types"
+ "github.com/ethereum/go-ethereum/core/vm"
+ "github.com/ethereum/go-ethereum/crypto"
+ "github.com/ethereum/go-ethereum/params"
+)
+
+// fakeParityReader is a state-independent registryreader.Reader with a fixed
+// whitelist, so TestReservedTxIndexesParity_ProcessorsAgree can wire a real
+// classification (registryreader.BuildSnapshot -> ClassifyReserved) without a
+// deployed registry contract.
+type fakeParityReader struct {
+ addrs map[common.Address]struct{}
+ quota uint64
+}
+
+func (f *fakeParityReader) HasReservedRegistry() bool { return true }
+
+func (f *fakeParityReader) IsReservedAddress(_ *state.StateDB, _ uint64, _ common.Hash, addr common.Address) (bool, error) {
+ _, ok := f.addrs[addr]
+ return ok, nil
+}
+
+func (f *fakeParityReader) ReservedClientForAddress(_ *state.StateDB, _ uint64, _ common.Hash, addr common.Address) (registryreader.ClientLookup, error) {
+ if _, ok := f.addrs[addr]; !ok {
+ return registryreader.ClientLookup{}, nil
+ }
+ return registryreader.ClientLookup{ClientID: big.NewInt(1), GasQuota: f.quota, Active: true}, nil
+}
+
+func (f *fakeParityReader) Root(_ *state.StateDB, _ uint64, _ common.Hash) (common.Hash, error) {
+ return common.HexToHash("0x1"), nil
+}
+
+func (f *fakeParityReader) WhitelistedAddresses(_ *state.StateDB, _ uint64, _ common.Hash) ([]common.Address, error) {
+ out := make([]common.Address, 0, len(f.addrs))
+ for a := range f.addrs {
+ out = append(out, a)
+ }
+ return out, nil
+}
+
+func (f *fakeParityReader) TotalReservedGas(_ *state.StateDB, _ uint64, _ common.Hash) (uint64, error) {
+ return f.quota, nil
+}
+
+// TestReservedTxIndexesParity_ProcessorsAgree builds one block with a mix of
+// reserved and non-reserved senders (all sharing one client, all within
+// quota) and confirms the serial, parallel-v1, and parallel-v2 (BlockSTM)
+// processors - the three independent ProcessResult assembly sites - derive
+// identical ReservedTxIndexes and ReservedClientUsage for it. A registry
+// reader is wired via BlockChain.SetReservedRegistry with a fixed in-memory
+// fake (real registryreader.BuildSnapshot/ClassifyReserved, no deployed
+// contract), and each processor re-executes the same block from the same
+// parent state.
+//
+// This is a real run of each processor's Process method - including a real
+// registryreader.BuildSnapshot/ClassifyReserved classification pass, just
+// against a fixed fake reader instead of a deployed contract - not a
+// re-derivation of their inputs, so it would catch a future divergence in
+// how a site builds its (txs, signer, ReservedTxs) triple. It does not
+// exercise a real on-chain registry contract, base-fee capacity, or the
+// miner's sealing path - those are covered by
+// tests/bor/reserved_receipts_test.go end-to-end (for the default processor
+// pairing) and by TestSumReservedGasUsed/TestDeriveFieldsReserved at the
+// unit level.
+func TestReservedTxIndexesParity_ProcessorsAgree(t *testing.T) {
+ var (
+ keyReservedA, _ = crypto.GenerateKey()
+ keyReservedB, _ = crypto.GenerateKey()
+ keyNormal, _ = crypto.GenerateKey()
+ addrReservedA = crypto.PubkeyToAddress(keyReservedA.PublicKey)
+ addrReservedB = crypto.PubkeyToAddress(keyReservedB.PublicKey)
+ addrNormal = crypto.PubkeyToAddress(keyNormal.PublicKey)
+ recipient = common.HexToAddress("0xaa")
+ )
+
+ config := reservedActiveConfig(t)
+ // checkReservedBlockspaceForkOrder requires ReservedBlockspaceBlock to be
+ // at or after CancunBlock; BorUnittestChainConfig (reservedActiveConfig's
+ // base) doesn't schedule Cancun/Shanghai, so activate the full pre-Cancun
+ // fork ladder from genesis too.
+ config.LondonBlock = big.NewInt(0)
+ config.ShanghaiBlock = big.NewInt(0)
+ config.CancunBlock = big.NewInt(0)
+ config.Bor.GiuglianoBlock = big.NewInt(0)
+ signer := types.LatestSigner(config)
+
+ genesis := &Genesis{
+ Config: config,
+ BaseFee: big.NewInt(params.InitialBaseFee),
+ Alloc: types.GenesisAlloc{
+ addrReservedA: {Balance: big.NewInt(1e18)},
+ addrReservedB: {Balance: big.NewInt(1e18)},
+ addrNormal: {Balance: big.NewInt(1e18)},
+ },
+ }
+
+ mkTx := func(key *ecdsa.PrivateKey, nonce uint64) *types.Transaction {
+ tx, err := types.SignNewTx(key, signer, &types.LegacyTx{
+ Nonce: nonce, To: &recipient, Value: big.NewInt(1),
+ Gas: 21000, GasPrice: big.NewInt(params.InitialBaseFee * 2),
+ })
+ require.NoError(t, err)
+ return tx
+ }
+
+ // Block generation doesn't need reserved-aware execution - the generated
+ // block is only a vehicle for a valid header/body; each processor below
+ // re-executes it from the parent state with the registry wired in.
+ genDb, blocks, _ := GenerateChainWithGenesis(genesis, ethash.NewFaker(), 1, func(i int, b *BlockGen) {
+ b.AddTx(mkTx(keyReservedA, 0))
+ b.AddTx(mkTx(keyNormal, 0))
+ b.AddTx(mkTx(keyReservedB, 0))
+ })
+ require.Len(t, blocks, 1)
+ block := blocks[0]
+
+ // NewParallelBlockChain (not NewBlockChain) so bc.parallelSpeculativeProcesses
+ // is actually set: ParallelStateProcessor.Process reads it at call time, and
+ // leaving it at its zero value hangs blockstm.ExecuteParallel forever.
+ bc, err := NewParallelBlockChain(genDb, genesis, ethash.NewFaker(), DefaultConfig(), 2, false)
+ require.NoError(t, err)
+ defer bc.Stop()
+
+ bc.SetReservedRegistry(&fakeParityReader{
+ addrs: map[common.Address]struct{}{addrReservedA: {}, addrReservedB: {}},
+ quota: 100_000,
+ })
+
+ parentRoot := bc.Genesis().Root()
+ freshState := func() *state.StateDB {
+ st, err := bc.StateAt(parentRoot)
+ require.NoError(t, err)
+ return st
+ }
+
+ want := []uint64{0, 2} // addrReservedA at index 0, addrReservedB at index 2
+ // Both reserved senders share fakeParityReader's single client (id 1), so
+ // Used is the summed declared gas of both reserved transactions (21000
+ // each, from mkTx's Gas field) and Quota is the fake reader's quota.
+ wantUsage := map[uint64]registryreader.ClientUsage{1: {Used: 42000, Quota: 100_000}}
+
+ serialRes, err := bc.processor.Process(block, freshState(), vm.Config{}, nil, context.Background())
+ require.NoError(t, err)
+ require.Equal(t, want, serialRes.ReservedTxIndexes, "serial processor")
+ require.Equal(t, wantUsage, serialRes.ReservedClientUsage, "serial processor usage")
+
+ v1 := NewParallelStateProcessor(bc.hc, bc)
+ v1Res, err := v1.Process(block, freshState(), vm.Config{}, nil, context.Background())
+ require.NoError(t, err)
+ require.Equal(t, want, v1Res.ReservedTxIndexes, "parallel-v1 (ParallelStateProcessor)")
+ require.Equal(t, wantUsage, v1Res.ReservedClientUsage, "parallel-v1 (ParallelStateProcessor) usage")
+
+ v2 := NewV2StateProcessor(bc.hc, bc, 2)
+ v2Res, err := v2.Process(block, freshState(), vm.Config{}, nil, context.Background())
+ require.NoError(t, err)
+ require.Equal(t, want, v2Res.ReservedTxIndexes, "parallel-v2 (BlockSTM)")
+ require.Equal(t, wantUsage, v2Res.ReservedClientUsage, "parallel-v2 (BlockSTM) usage")
+}
+
+// TestWriteBlockWithState_NilReceiptsSuppressesReservedWrite is the focused
+// unit-level check for the stateless writers' invariant: writeBlockWithState
+// must not persist a reserved-tx side-table entry when receipts is nil, even
+// when a non-nil index list is passed alongside it - exactly the call shape
+// the stateless sequential/parallel/deferred-retry writers now use, since
+// they thread their real ReservedTxIndexes through rather than special-casing
+// a literal nil at each call site. A full stateless-mode run (witness
+// generation and verification) is disproportionate for this one invariant,
+// so this drives writeBlockWithState directly.
+func TestWriteBlockWithState_NilReceiptsSuppressesReservedWrite(t *testing.T) {
+ _, _, bc, err := newCanonical(ethash.NewFaker(), 1, true, rawdb.HashScheme)
+ require.NoError(t, err)
+ defer bc.Stop()
+
+ block := bc.GetBlockByNumber(1)
+ require.NotNil(t, block)
+ statedb, err := bc.StateAt(block.Root())
+ require.NoError(t, err)
+
+ _, err = bc.writeBlockWithState(block, nil, nil, statedb, []uint64{0, 1})
+ require.NoError(t, err)
+
+ require.Nil(t, rawdb.ReadReservedTxIndexes(bc.db, block.Hash(), block.NumberU64()),
+ "nil receipts must suppress the reserved-tx write regardless of what indexes are passed")
+}
diff --git a/core/reserved_stateless_test.go b/core/reserved_stateless_test.go
index c564acecaa..2c9b4c2206 100644
--- a/core/reserved_stateless_test.go
+++ b/core/reserved_stateless_test.go
@@ -1,6 +1,7 @@
package core
import (
+ "context"
"math/big"
"testing"
@@ -8,30 +9,68 @@ import (
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/consensus/bor/registryreader"
+ "github.com/ethereum/go-ethereum/consensus/ethash"
+ "github.com/ethereum/go-ethereum/core/rawdb"
"github.com/ethereum/go-ethereum/core/state"
"github.com/ethereum/go-ethereum/core/types"
+ "github.com/ethereum/go-ethereum/core/vm"
"github.com/ethereum/go-ethereum/params"
)
-// fakeReservedReader is a state-independent registryreader.Reader for the
-// stateless-path tests: it reports an empty (but present) reserved set.
-type fakeReservedReader struct{ has bool }
+// fakeReservedReader is a state-independent registryreader.Reader for tests
+// that don't need a real registry contract. has gates HasReservedRegistry;
+// clients (keyed by address) drives the whitelisted set, per-address lookups,
+// and the raw total, so the zero value ("empty but present" reserved set)
+// and a populated fixture (capacity/effectiveFrom behavior) share one type.
+type fakeReservedReader struct {
+ has bool
+ clients map[common.Address]registryreader.ClientLookup
+}
func (f fakeReservedReader) HasReservedRegistry() bool { return f.has }
-func (f fakeReservedReader) IsReservedAddress(*state.StateDB, uint64, common.Hash, common.Address) (bool, error) {
- return false, nil
+
+func (f fakeReservedReader) IsReservedAddress(_ *state.StateDB, _ uint64, _ common.Hash, a common.Address) (bool, error) {
+ _, ok := f.clients[a]
+ return ok, nil
}
-func (f fakeReservedReader) ReservedClientForAddress(*state.StateDB, uint64, common.Hash, common.Address) (registryreader.ClientLookup, error) {
- return registryreader.ClientLookup{}, nil
+
+func (f fakeReservedReader) ReservedClientForAddress(_ *state.StateDB, _ uint64, _ common.Hash, a common.Address) (registryreader.ClientLookup, error) {
+ return f.clients[a], nil
}
+
func (f fakeReservedReader) Root(*state.StateDB, uint64, common.Hash) (common.Hash, error) {
return common.Hash{}, nil
}
+
func (f fakeReservedReader) WhitelistedAddresses(*state.StateDB, uint64, common.Hash) ([]common.Address, error) {
- return nil, nil
+ addrs := make([]common.Address, 0, len(f.clients))
+ for a := range f.clients {
+ addrs = append(addrs, a)
+ }
+ return addrs, nil
}
+
func (f fakeReservedReader) TotalReservedGas(*state.StateDB, uint64, common.Hash) (uint64, error) {
- return 0, nil
+ var total uint64
+ for _, c := range f.clients {
+ total += c.GasQuota
+ }
+ return total, nil
+}
+
+// twoClientFakeReader builds a fakeReservedReader with one client effective
+// immediately and one whose effectiveFrom is in the future, so
+// EffectiveCapacity (what every processor/getter must report) differs from
+// the raw registry total (TotalReservedGas) — the split the header-capacity
+// work (§2.2) exists to price correctly.
+func twoClientFakeReader(immediate, future common.Address, immediateQuota, futureQuota, futureEffectiveFrom uint64) fakeReservedReader {
+ return fakeReservedReader{
+ has: true,
+ clients: map[common.Address]registryreader.ClientLookup{
+ immediate: {ClientID: big.NewInt(1), GasQuota: immediateQuota, Active: true},
+ future: {ClientID: big.NewInt(2), GasQuota: futureQuota, Active: true, EffectiveFrom: futureEffectiveFrom},
+ },
+ }
}
func reservedActiveConfig(t *testing.T) *params.ChainConfig {
@@ -95,3 +134,105 @@ func TestReservedSnapshotForBlock_StatelessHeaderChain(t *testing.T) {
require.Nil(t, snap)
})
}
+
+// TestReservedSnapshotForBlock_StatelessEffectiveCapacity pins that the
+// stateless (HeaderChain-only) execution path computes the identical
+// EffectiveCapacity a full BlockChain would: both route through the same
+// registryreader.BuildSnapshot given the same reader and parent state, so
+// there is exactly one implementation to get right, not one per chain type.
+func TestReservedSnapshotForBlock_StatelessEffectiveCapacity(t *testing.T) {
+ t.Parallel()
+
+ immediate := common.HexToAddress("0x00000000000000000000000000000000001111")
+ future := common.HexToAddress("0x00000000000000000000000000000000002222")
+ const immediateQuota, futureQuota, futureEffectiveFrom = 10_000_000, 5_000_000, 1_000
+ reader := twoClientFakeReader(immediate, future, immediateQuota, futureQuota, futureEffectiveFrom)
+
+ activeCfg := reservedActiveConfig(t)
+ header := &types.Header{Number: big.NewInt(1)}
+
+ hc := &HeaderChain{config: activeCfg, reservedRegistry: reader}
+ snap, err := ReservedSnapshotForBlock(hc, nil, header)
+ require.NoError(t, err)
+ require.NotNil(t, snap)
+ require.Equal(t, uint64(immediateQuota), snap.EffectiveCapacity(),
+ "future-effective client must be excluded from effective capacity")
+ require.Equal(t, uint64(immediateQuota+futureQuota), snap.Capacity(),
+ "raw capacity (totalReservedGas) includes the future client")
+}
+
+// reservedCapacityParityChain builds a real *BlockChain (ethash faker engine,
+// so no Bor validator/signing setup is needed) with the reserved fork active
+// from genesis and a fake registry reader wired in. Cancun and Giugliano are
+// co-activated at genesis to satisfy checkReservedBlockspaceForkOrder.
+func reservedCapacityParityChain(t *testing.T, reader registryreader.Reader) *BlockChain {
+ t.Helper()
+ cfg := reservedActiveConfig(t)
+ cfg.ShanghaiBlock = big.NewInt(0)
+ cfg.CancunBlock = big.NewInt(0)
+ bor := *cfg.Bor
+ bor.GiuglianoBlock = big.NewInt(0)
+ cfg.Bor = &bor
+
+ db := rawdb.NewMemoryDatabase()
+ gspec := &Genesis{BaseFee: big.NewInt(params.InitialBaseFee), Config: cfg}
+ chain, err := NewBlockChain(db, gspec, ethash.NewFullFaker(), DefaultConfig().WithStateScheme(rawdb.HashScheme))
+ require.NoError(t, err)
+ chain.SetReservedRegistry(reader)
+ return chain
+}
+
+// TestReservedCapacityParity_ProcessorsAgree pins the wiring at the three
+// ProcessResult-assembly sites named in §2.3: serial (state_processor.go),
+// parallel V1 (parallel_state_processor.go, ParallelStateProcessor) and
+// parallel V2 BlockSTM (parallel_state_processor.go, V2StateProcessor). All
+// three must populate ReservedCapacity from the SAME registry snapshot's
+// EffectiveCapacity(), read at the parent state — independent of the block's
+// transactions (there are none in this block; capacity is a snapshot
+// property, not a function of what executed). The future-effective client's
+// quota is part of the raw registry total but not yet part of
+// EffectiveCapacity, so a processor that accidentally read the raw total
+// instead would be caught here.
+func TestReservedCapacityParity_ProcessorsAgree(t *testing.T) {
+ t.Parallel()
+
+ immediate := common.HexToAddress("0x00000000000000000000000000000000001111")
+ future := common.HexToAddress("0x00000000000000000000000000000000002222")
+ const immediateQuota, futureQuota, futureEffectiveFrom = 10_000_000, 5_000_000, 1_000 // far beyond block 1
+ reader := twoClientFakeReader(immediate, future, immediateQuota, futureQuota, futureEffectiveFrom)
+ const wantCapacity = uint64(immediateQuota)
+
+ chain := reservedCapacityParityChain(t, reader)
+ defer chain.Stop()
+
+ genesis := chain.Genesis()
+ header := &types.Header{
+ Number: big.NewInt(1),
+ ParentHash: genesis.Hash(),
+ GasLimit: genesis.GasLimit(),
+ BaseFee: genesis.BaseFee(),
+ Time: genesis.Time() + 1,
+ }
+ block := types.NewBlockWithHeader(header).WithBody(types.Body{})
+ author := common.HexToAddress("0x00000000000000000000000000000000000c0b0")
+
+ freshState := func(t *testing.T) *state.StateDB {
+ t.Helper()
+ st, err := chain.StateAt(genesis.Root())
+ require.NoError(t, err)
+ return st
+ }
+
+ serialRes, err := NewStateProcessor(chain).Process(block, freshState(t), vm.Config{}, &author, context.Background())
+ require.NoError(t, err)
+ require.Equal(t, wantCapacity, serialRes.ReservedCapacity,
+ "serial processor must report the effective capacity, not the raw total")
+
+ v1Res, err := NewParallelStateProcessor(chain, chain).Process(block, freshState(t), vm.Config{}, &author, context.Background())
+ require.NoError(t, err)
+ require.Equal(t, wantCapacity, v1Res.ReservedCapacity, "parallel (V1) processor must agree with serial")
+
+ v2Res, err := NewV2StateProcessor(chain, chain, 1).Process(block, freshState(t), vm.Config{}, &author, context.Background())
+ require.NoError(t, err)
+ require.Equal(t, wantCapacity, v2Res.ReservedCapacity, "parallel (V2 BlockSTM) processor must agree with serial")
+}
diff --git a/core/reserved_validation_test.go b/core/reserved_validation_test.go
index 473641a224..23043a0ce6 100644
--- a/core/reserved_validation_test.go
+++ b/core/reserved_validation_test.go
@@ -25,9 +25,11 @@ func reservedValidationConfig(forkBlock *big.Int) *params.ChainConfig {
return &cc
}
-// headerWithReservedGasUsed builds a post-Cancun header whose Extra encodes a
-// BlockExtraData carrying the given ReservedGasUsed (nil = field absent).
-func headerWithReservedGasUsed(t *testing.T, number int64, reserved *uint64) *types.Header {
+// headerWithReservedFields builds a post-Cancun header whose Extra encodes a
+// BlockExtraData carrying the given ReservedGasUsed/ReservedCapacity (nil =
+// field absent; SetReservedFields only writes when gasUsed is non-nil, since
+// the two fields are always stamped together).
+func headerWithReservedFields(t *testing.T, number int64, gasUsed, capacity *uint64) *types.Header {
t.Helper()
enc, err := rlp.EncodeToBytes(&types.BlockExtraData{TxDependency: [][]uint64{}})
require.NoError(t, err)
@@ -35,47 +37,64 @@ func headerWithReservedGasUsed(t *testing.T, number int64, reserved *uint64) *ty
extra = append(extra, enc...)
extra = append(extra, make([]byte, types.ExtraSealLength)...)
h := &types.Header{Number: big.NewInt(number), Extra: extra}
- if reserved != nil {
- require.NoError(t, h.SetReservedGasUsed(¶ms.ChainConfig{ChainID: big.NewInt(137), CancunBlock: big.NewInt(0)}, *reserved))
+ if gasUsed != nil {
+ var c uint64
+ if capacity != nil {
+ c = *capacity
+ }
+ require.NoError(t, h.SetReservedFields(¶ms.ChainConfig{ChainID: big.NewInt(137), CancunBlock: big.NewInt(0)}, *gasUsed, c))
}
return h
}
-// TestValidateReservedGasUsed covers the anti-cheat guard: the header's
-// stamped ReservedGasUsed must equal the gas the reserved (fee-free) txs
-// actually used (res.ReservedGasUsed), post-fork; pre-fork the check is skipped.
-func TestValidateReservedGasUsed(t *testing.T) {
+// TestValidateReservedFields covers the anti-cheat guard: the header's
+// stamped ReservedGasUsed and ReservedCapacity must equal, respectively, the
+// gas the reserved (fee-free) txs actually used and the registry snapshot's
+// effective capacity (res.ReservedGasUsed / res.ReservedCapacity), post-fork;
+// pre-fork the check is skipped.
+func TestValidateReservedFields(t *testing.T) {
t.Parallel()
fork := reservedValidationConfig(big.NewInt(0))
n := uint64(50_000)
+ cap10m := uint64(10_000_000)
+ cap20m := uint64(20_000_000)
cases := []struct {
- name string
- cfg *params.ChainConfig
- number int64
- headerReserved *uint64 // nil = field absent
- resReserved uint64
- wantMismatch bool
+ name string
+ cfg *params.ChainConfig
+ number int64
+ headerGasUsed *uint64 // nil = field absent
+ headerCapacity *uint64
+ resReservedGas uint64
+ resReservedCap uint64
+ wantGasMismatch bool
+ wantCapMismatch bool
}{
- {"match", fork, 100, &n, 50_000, false},
- {"mismatch is rejected", fork, 100, &n, 40_000, true},
- {"absent header field compares as 0 — matches res 0", fork, 100, nil, 0, false},
- {"absent header field, res nonzero — mismatch", fork, 100, nil, 30_000, true},
+ {"match", fork, 100, &n, &cap10m, 50_000, 10_000_000, false, false},
+ {"gas mismatch is rejected", fork, 100, &n, &cap10m, 40_000, 10_000_000, true, false},
+ {"capacity mismatch is rejected", fork, 100, &n, &cap10m, 50_000, 20_000_000, false, true},
+ {"both mismatch: gas checked first", fork, 100, &n, &cap10m, 40_000, 20_000_000, true, false},
+ {"absent header fields compare as 0 — matches res 0", fork, 100, nil, nil, 0, 0, false, false},
+ {"absent header fields, res nonzero gas — mismatch", fork, 100, nil, nil, 30_000, 0, true, false},
+ {"absent header fields, res nonzero capacity — mismatch", fork, 100, nil, nil, 0, 5_000, false, true},
// Boundary: fork at block 100.
- {"pre-fork (N-1) skips the check regardless", reservedValidationConfig(big.NewInt(100)), 99, nil, 99_999, false},
- {"at fork (N) enforces", reservedValidationConfig(big.NewInt(100)), 100, &n, 40_000, true},
- {"post-fork (N+1) enforces", reservedValidationConfig(big.NewInt(100)), 101, &n, 40_000, true},
+ {"pre-fork (N-1) skips the check regardless", reservedValidationConfig(big.NewInt(100)), 99, nil, nil, 99_999, 99_999, false, false},
+ {"at fork (N) enforces", reservedValidationConfig(big.NewInt(100)), 100, &n, &cap10m, 40_000, 10_000_000, true, false},
+ {"post-fork (N+1) enforces", reservedValidationConfig(big.NewInt(100)), 101, &n, &cap20m, 50_000, 10_000_000, false, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
v := &BlockValidator{config: tc.cfg}
- h := headerWithReservedGasUsed(t, tc.number, tc.headerReserved)
- err := v.validateReservedGasUsed(h, &ProcessResult{ReservedGasUsed: tc.resReserved})
- if tc.wantMismatch {
+ h := headerWithReservedFields(t, tc.number, tc.headerGasUsed, tc.headerCapacity)
+ err := v.validateReservedFields(h, &ProcessResult{ReservedGasUsed: tc.resReservedGas, ReservedCapacity: tc.resReservedCap})
+ switch {
+ case tc.wantGasMismatch:
require.ErrorIs(t, err, ErrReservedGasUsedMismatch)
- } else {
+ case tc.wantCapMismatch:
+ require.ErrorIs(t, err, ErrReservedCapacityMismatch)
+ default:
require.NoError(t, err)
}
})
@@ -111,19 +130,30 @@ func TestSumReservedGasUsed(t *testing.T) {
{TxHash: txB0.Hash(), GasUsed: 40_000},
}
- // a0 and b0 reserved; a1 not in the set. Matched by hash regardless of order.
+ // a0 and b0 reserved; a1 not in the set. Matched by hash regardless of
+ // order, but indexes are positions within txs (txA0=0, txA1=1, txB0=2).
reserved := map[registryreader.ReservedKey]struct{}{
{From: a, Nonce: 0}: {},
{From: b, Nonce: 0}: {},
}
- require.Equal(t, uint64(21_000+40_000), sumReservedGasUsed(txs, receipts, signer, reserved))
+ gas, idx := sumReservedGasUsed(txs, receipts, signer, reserved)
+ require.Equal(t, uint64(21_000+40_000), gas)
+ require.Equal(t, []uint64{0, 2}, idx)
- // Empty set short-circuits to 0.
- require.Zero(t, sumReservedGasUsed(txs, receipts, signer, nil))
- require.Zero(t, sumReservedGasUsed(txs, receipts, signer, map[registryreader.ReservedKey]struct{}{}))
+ // Empty set short-circuits to (0, nil).
+ gas, idx = sumReservedGasUsed(txs, receipts, signer, nil)
+ require.Zero(t, gas)
+ require.Nil(t, idx)
+ gas, idx = sumReservedGasUsed(txs, receipts, signer, map[registryreader.ReservedKey]struct{}{})
+ require.Zero(t, gas)
+ require.Nil(t, idx)
- // A reserved key whose tx has no receipt contributes 0 (cannot happen in a
- // valid block; guards against a silent nonzero on a malformed input).
+ // A reserved key whose tx has no receipt contributes 0 gas (cannot happen
+ // in a valid block; guards against a silent nonzero on a malformed input)
+ // but is still reported as reserved by index - classification does not
+ // depend on receipt presence.
onlyA1 := map[registryreader.ReservedKey]struct{}{{From: a, Nonce: 1}: {}}
- require.Zero(t, sumReservedGasUsed(txs, types.Receipts{{TxHash: txA0.Hash(), GasUsed: 21_000}}, signer, onlyA1))
+ gas, idx = sumReservedGasUsed(txs, types.Receipts{{TxHash: txA0.Hash(), GasUsed: 21_000}}, signer, onlyA1)
+ require.Zero(t, gas)
+ require.Equal(t, []uint64{1}, idx)
}
diff --git a/core/state/pruner/pruner.go b/core/state/pruner/pruner.go
index a4d2d8cf2b..39c5198533 100644
--- a/core/state/pruner/pruner.go
+++ b/core/state/pruner/pruner.go
@@ -428,6 +428,11 @@ func (p *BlockPruner) backupOldDb(name string, cache, handles int, namespace str
block := rawdb.ReadBlock(chainDb, blockHash, blockNumber)
receipts := rawdb.ReadReceiptsRLP(chainDb, blockHash, blockNumber)
borReceipts := rawdb.ReadBorReceiptRLP(chainDb, blockHash, blockNumber)
+ // The block was already classified when it was first frozen; carry
+ // that classification into the backup instead of defaulting to
+ // "nothing reserved", or a pruned node would answer receipt RPCs
+ // differently than an unpruned one for the blocks it retains.
+ reservedTxIndexes := rawdb.ReadReservedTxIndexesRLP(chainDb, blockHash, blockNumber)
// Calculate the total difficulty of the block
td := rawdb.ReadTd(chainDb, blockHash, blockNumber)
@@ -436,7 +441,7 @@ func (p *BlockPruner) backupOldDb(name string, cache, handles int, namespace str
}
// Write into new ancient_back db.
- if _, err := rawdb.WriteAncientBlocks(frdbBack, []*types.Block{block}, []rlp.RawValue{receipts}, []rlp.RawValue{borReceipts}, td); err != nil {
+ if _, err := rawdb.WriteAncientBlocks(frdbBack, []*types.Block{block}, []rlp.RawValue{receipts}, []rlp.RawValue{borReceipts}, []rlp.RawValue{reservedTxIndexes}, td); err != nil {
return fmt.Errorf("failed to write new ancient error: %v", err)
}
diff --git a/core/state/statedb.go b/core/state/statedb.go
index 17ff082be7..a7e09751cd 100644
--- a/core/state/statedb.go
+++ b/core/state/statedb.go
@@ -3338,3 +3338,50 @@ func (s *StateDB) PropagateReadsTo(dst *StateDB) {
}
}
}
+
+// ReadIsolated runs fn against a throwaway copy of s, so EVM-backed reads
+// (bor system calls, reserved-registry lookups) cannot leak state mutations
+// into s, while keeping their trie reads part of any witness s is producing:
+// the copy records into s's witness directly, IntermediateRoot pulls the
+// touched trie nodes into it, and the read addresses/slots are re-registered
+// on s so they enter its FlatDiff read surface (see PropagateReadsTo). A
+// stateless verifier replays these same reads from the witness, so a produced
+// witness must carry them even when nothing else in the block touches the
+// same state.
+//
+// Both branches reset the copy (the reads resolve fresh parent state, not this
+// block's in-flight caches) and re-register the reads on s, so read semantics
+// don't depend on whether a witness happens to be attached; only the witness
+// collection machinery does. The witness is detached while copying: Copy would
+// deep-clone it only for StartPrefetcher to immediately replace the clone with
+// the shared reference. Callers sit on the consensus path, serial with respect
+// to s. The "bor" namespace only names the throwaway prefetcher's metrics.
+func (s *StateDB) ReadIsolated(fn func(tmp *StateDB) error) error {
+ if s.witness == nil {
+ tmp := s.Copy()
+ tmp.ResetPrefetcher()
+ if err := fn(tmp); err != nil {
+ return err
+ }
+ tmp.PropagateReadsTo(s)
+ return nil
+ }
+ w := s.witness
+ s.witness = nil
+ tmp := s.Copy()
+ s.witness = w
+ // ResetPrefetcher first: the copy shares s's prefetcher, which is not ours
+ // to stop, and it also drops the inherited object caches so the reads below
+ // resolve freshly and IntermediateRoot collects only them.
+ tmp.ResetPrefetcher()
+ tmp.StartPrefetcher("bor", w, nil)
+ // IntermediateRoot consumes the prefetcher on the success path; this
+ // reclaims its goroutines when fn errors out before that.
+ defer tmp.StopPrefetcher()
+ if err := fn(tmp); err != nil {
+ return err
+ }
+ tmp.IntermediateRoot(false)
+ tmp.PropagateReadsTo(s)
+ return nil
+}
diff --git a/core/state/v2_method_parity_test.go b/core/state/v2_method_parity_test.go
index 831a3ca778..a220a2b1a6 100644
--- a/core/state/v2_method_parity_test.go
+++ b/core/state/v2_method_parity_test.go
@@ -103,6 +103,10 @@ var pdbExemptMethods = map[string]pdbExemptCategory{
"StopPrefetcher": catLifecycle,
"ResetPrefetcher": catLifecycle,
"Copy": catLifecycle,
+ // Isolated system-call reads (reserved-registry snapshot, span,
+ // state-sync id) run on the underlying StateDB around block execution,
+ // never on a per-tx worker.
+ "ReadIsolated": catLifecycle,
// Pipelined SRC import — FlatDiff capture/replay, read propagation,
// and detached prefetcher handoff are block-level StateDB operations.
diff --git a/core/state_processor.go b/core/state_processor.go
index de87754fa6..4c14b562b8 100644
--- a/core/state_processor.go
+++ b/core/state_processor.go
@@ -98,7 +98,8 @@ func (p *StateProcessor) Process(block *types.Block, statedb *state.StateDB, cfg
tracingStateDB = state.NewHookedState(statedb, hooks)
}
context = NewEVMBlockContext(header, p.chain, author)
- if err := p.applyReservedClassification(&context, statedb, header, block, signer); err != nil {
+ clientUsage, err := p.applyReservedClassification(&context, statedb, header, block, signer)
+ if err != nil {
return nil, err
}
evm := vm.NewEVM(context, tracingStateDB, p.chainConfig(), cfg)
@@ -205,11 +206,14 @@ func (p *StateProcessor) Process(block *types.Block, statedb *state.StateDB, cfg
}
return &ProcessResult{
- Receipts: receipts,
- Requests: requests,
- Logs: allLogs,
- GasUsed: *usedGas,
- ReservedGasUsed: reservedGasUsed,
+ Receipts: receipts,
+ Requests: requests,
+ Logs: allLogs,
+ GasUsed: *usedGas,
+ ReservedGasUsed: reservedGasUsed,
+ ReservedCapacity: context.ReservedSnapshot.EffectiveCapacity(),
+ ReservedTxIndexes: ReservedTxIndexes(txs, signer, context.ReservedTxs),
+ ReservedClientUsage: clientUsage,
}, nil
}
@@ -217,41 +221,63 @@ func (p *StateProcessor) Process(block *types.Block, statedb *state.StateDB, cfg
// the parent post-state, before block execution) and stores the resulting
// snapshot plus the per-block reserved transaction set on blockCtx. The
// quota-aware reserved set is derived once from the ordered body so every
-// transaction's fee-free decision is fixed before (parallel) execution.
-func (p *StateProcessor) applyReservedClassification(blockCtx *vm.BlockContext, statedb *state.StateDB, header *types.Header, block *types.Block, signer types.Signer) error {
+// transaction's fee-free decision is fixed before (parallel) execution. The
+// returned usage map is the same classification walk's per-client tally,
+// handed back to the caller for ProcessResult rather than stored on blockCtx.
+func (p *StateProcessor) applyReservedClassification(blockCtx *vm.BlockContext, statedb *state.StateDB, header *types.Header, block *types.Block, signer types.Signer) (map[uint64]registryreader.ClientUsage, error) {
reservedSnapshot, err := ReservedSnapshotForBlock(p.chain, statedb, header)
if err != nil {
- return err
+ return nil, err
}
blockCtx.ReservedSnapshot = reservedSnapshot
- blockCtx.ReservedTxs = registryreader.ClassifyReserved(block.Transactions(), signer, blockCtx.ReservedSnapshot)
- return nil
+ reservedTxs, clientUsage := registryreader.ClassifyReserved(block.Transactions(), signer, blockCtx.ReservedSnapshot)
+ blockCtx.ReservedTxs = reservedTxs
+ return clientUsage, nil
}
-// sumReservedGasUsed totals the actual gas used by transactions classified
-// reserved (fee-free) in set. It matches receipts to transactions by hash, so
-// it is independent of receipt/transaction ordering and ignores the trailing
-// state-sync receipt (whose sender is never registered). Returns 0 for an empty
-// set (pre-fork, no registry, or nothing reserved).
-func sumReservedGasUsed(txs types.Transactions, receipts types.Receipts, signer types.Signer, set map[registryreader.ReservedKey]struct{}) uint64 {
+// ReservedTxIndexes returns the ascending positions within txs whose
+// (sender, nonce) is in set (txs is the final block order, so a plain index
+// loop already yields ascending positions). This is the single derivation
+// shared by every processor path (serial, both parallel implementations) and
+// the miner - see miner/worker.go's use at task-creation time - so a
+// divergence between independent copies of this matching loop can't recur.
+// Returns nil for an empty set (pre-fork, no registry, or nothing reserved).
+func ReservedTxIndexes(txs types.Transactions, signer types.Signer, set map[registryreader.ReservedKey]struct{}) []uint64 {
if len(set) == 0 {
- return 0
- }
- gasByHash := make(map[common.Hash]uint64, len(receipts))
- for _, r := range receipts {
- gasByHash[r.TxHash] = r.GasUsed
+ return nil
}
- var total uint64
- for _, tx := range txs {
+ var indexes []uint64
+ for i, tx := range txs {
from, err := types.Sender(signer, tx)
if err != nil {
continue
}
if _, ok := set[registryreader.ReservedKey{From: from, Nonce: tx.Nonce()}]; ok {
- total += gasByHash[tx.Hash()]
+ indexes = append(indexes, uint64(i))
}
}
- return total
+ return indexes
+}
+
+// sumReservedGasUsed totals the actual gas used by transactions classified
+// reserved (fee-free) in set. Gas is matched to transactions by hash, so it
+// is independent of receipt/transaction ordering and ignores the trailing
+// state-sync receipt (whose sender is never registered). Returns (0, nil)
+// for an empty set (pre-fork, no registry, or nothing reserved).
+func sumReservedGasUsed(txs types.Transactions, receipts types.Receipts, signer types.Signer, set map[registryreader.ReservedKey]struct{}) (uint64, []uint64) {
+ indexes := ReservedTxIndexes(txs, signer, set)
+ if len(indexes) == 0 {
+ return 0, nil
+ }
+ gasByHash := make(map[common.Hash]uint64, len(receipts))
+ for _, r := range receipts {
+ gasByHash[r.TxHash] = r.GasUsed
+ }
+ var total uint64
+ for _, idx := range indexes {
+ total += gasByHash[txs[idx].Hash()]
+ }
+ return total, indexes
}
// ApplyTransactionWithEVM attempts to apply a transaction to the given state database
diff --git a/core/txindexer_test.go b/core/txindexer_test.go
index e50210fe50..80caf2ef0c 100644
--- a/core/txindexer_test.go
+++ b/core/txindexer_test.go
@@ -28,6 +28,7 @@ import (
"github.com/ethereum/go-ethereum/crypto"
"github.com/ethereum/go-ethereum/ethdb"
"github.com/ethereum/go-ethereum/params"
+ "github.com/ethereum/go-ethereum/rlp"
)
func verifyIndexes(t *testing.T, db ethdb.Database, block *types.Block, exist bool) {
@@ -121,7 +122,7 @@ func TestTxIndexer(t *testing.T) {
for _, c := range cases {
db, _ := rawdb.Open(rawdb.NewMemoryDatabase(), rawdb.OpenOptions{})
- rawdb.WriteAncientBlocks(db, append([]*types.Block{gspec.ToBlock()}, blocks...), types.EncodeBlockReceiptLists(append([]types.Receipts{{}}, receipts...)), types.EncodeBlockReceiptLists(append([]types.Receipts{{}}, borReceipts...)), big.NewInt(0))
+ rawdb.WriteAncientBlocks(db, append([]*types.Block{gspec.ToBlock()}, blocks...), types.EncodeBlockReceiptLists(append([]types.Receipts{{}}, receipts...)), types.EncodeBlockReceiptLists(append([]types.Receipts{{}}, borReceipts...)), make([]rlp.RawValue, len(blocks)+1), big.NewInt(0))
// Index the initial blocks from ancient store
indexer := &txIndexer{
@@ -245,7 +246,7 @@ func TestTxIndexerRepair(t *testing.T) {
db, _ := rawdb.Open(rawdb.NewMemoryDatabase(), rawdb.OpenOptions{})
encReceipts := types.EncodeBlockReceiptLists(append([]types.Receipts{{}}, receipts...))
encBorReceipts := types.EncodeBlockReceiptLists(append([]types.Receipts{{}}, borReceipts...))
- rawdb.WriteAncientBlocks(db, append([]*types.Block{gspec.ToBlock()}, blocks...), encReceipts, encBorReceipts, big.NewInt(0))
+ rawdb.WriteAncientBlocks(db, append([]*types.Block{gspec.ToBlock()}, blocks...), encReceipts, encBorReceipts, make([]rlp.RawValue, len(blocks)+1), big.NewInt(0))
// Index the initial blocks from ancient store
indexer := &txIndexer{
@@ -440,7 +441,7 @@ func TestTxIndexerReport(t *testing.T) {
db, _ := rawdb.Open(rawdb.NewMemoryDatabase(), rawdb.OpenOptions{})
encReceipts := types.EncodeBlockReceiptLists(append([]types.Receipts{{}}, receipts...))
encBorReceipts := types.EncodeBlockReceiptLists(append([]types.Receipts{{}}, borReceipts...))
- rawdb.WriteAncientBlocks(db, append([]*types.Block{gspec.ToBlock()}, blocks...), encReceipts, encBorReceipts, big.NewInt(0))
+ rawdb.WriteAncientBlocks(db, append([]*types.Block{gspec.ToBlock()}, blocks...), encReceipts, encBorReceipts, make([]rlp.RawValue, len(blocks)+1), big.NewInt(0))
// Index the initial blocks from ancient store
indexer := &txIndexer{
diff --git a/core/txpool/legacypool/legacypool.go b/core/txpool/legacypool/legacypool.go
index c98127af43..638e7097ff 100644
--- a/core/txpool/legacypool/legacypool.go
+++ b/core/txpool/legacypool/legacypool.go
@@ -78,6 +78,13 @@ var (
// ErrTxFiltered is returned if a transaction is from a filtered address.
ErrTxFiltered = errors.New("transaction from filtered address")
+
+ // ErrReservedOccupancyExceeded is returned when admitting a reserved-blockspace
+ // sender's transaction would push aggregate reserved occupancy over its cap,
+ // independent of overall pool fullness. Distinct from ErrTxPoolOverflow (which
+ // fires only once the entire pool is full): the operational fix differs, since
+ // this fires purely on the reserved-specific ceiling.
+ ErrReservedOccupancyExceeded = errors.New("reserved-sender pool occupancy exceeded")
)
var (
@@ -124,6 +131,10 @@ var (
queuedGauge = metrics.NewRegisteredGauge("txpool/queued", nil)
slotsGauge = metrics.NewRegisteredGauge("txpool/slots", nil)
+ // reservedOccupancyGauge mirrors pendingGauge/queuedGauge for the combined
+ // (pending+queued) occupancy of reserved-blockspace senders.
+ reservedOccupancyGauge = metrics.NewRegisteredGauge("txpool/reserved/occupancy", nil)
+
resetCacheGauge = metrics.NewRegisteredGauge("txpool/resetcache", nil)
reheapTimer = metrics.NewRegisteredTimer("txpool/reheap", nil)
urgentHeapInitTimer = metrics.NewRegisteredTimer("txpool/heapinit/urgent", nil)
@@ -194,6 +205,12 @@ type Config struct {
AccountQueue uint64 // Maximum number of non-executable transaction slots permitted per account
GlobalQueue uint64 // Maximum number of non-executable transaction slots for all accounts
+ // ReservedMaxOccupancyPercent bounds the percentage of GlobalSlots+GlobalQueue
+ // that reserved-blockspace senders may occupy in aggregate, combined across
+ // pending and queued. Guarantees normal senders at least (100-this)% of the
+ // pool regardless of how many addresses a reserved client whitelists.
+ ReservedMaxOccupancyPercent uint64
+
Lifetime time.Duration // Maximum amount of time non-executable transaction are queued
AllowUnprotectedTxs bool // Allow non-EIP-155 transactions
@@ -220,6 +237,8 @@ var DefaultConfig = Config{
AccountQueue: 64,
GlobalQueue: 1024,
+ ReservedMaxOccupancyPercent: 50, // normal senders always keep at least half the pool
+
Lifetime: 3 * time.Hour,
AllowUnprotectedTxs: false,
@@ -258,6 +277,10 @@ func (config *Config) sanitize() Config {
log.Warn("Sanitizing invalid txpool global queue", "provided", conf.GlobalQueue, "updated", DefaultConfig.GlobalQueue)
conf.GlobalQueue = DefaultConfig.GlobalQueue
}
+ if conf.ReservedMaxOccupancyPercent < 1 || conf.ReservedMaxOccupancyPercent > 100 {
+ log.Warn("Sanitizing invalid reserved occupancy percent", "provided", conf.ReservedMaxOccupancyPercent, "updated", DefaultConfig.ReservedMaxOccupancyPercent)
+ conf.ReservedMaxOccupancyPercent = DefaultConfig.ReservedMaxOccupancyPercent
+ }
if conf.Lifetime < 1 {
log.Warn("Sanitizing invalid txpool lifetime", "provided", conf.Lifetime, "updated", DefaultConfig.Lifetime)
conf.Lifetime = DefaultConfig.Lifetime
@@ -338,6 +361,14 @@ type LegacyPool struct {
reservedRegistry registryreader.Reader
reservedSnapshot atomic.Pointer[registryreader.Snapshot]
+ // reservedOccupancy is the combined (pending+queued) transaction count
+ // currently held by reserved-blockspace senders, guarded by pool.mu like
+ // every other pool field. Maintained incrementally at each mutation site
+ // (Layer 1) and recomputed from scratch once per reorg cycle in reset()
+ // (Layer 2) so a missed touchpoint can't drift the figure indefinitely —
+ // see reservedOccupancyCap and recomputeReservedOccupancy.
+ reservedOccupancy int
+
// Rebroadcast tracking
rebroadcastTxFeed event.Feed // Feed for stuck transaction events
lastRebroadcast map[common.Hash]time.Time // Track last rebroadcast time per tx hash
@@ -823,16 +854,24 @@ func (pool *LegacyPool) validateTx(tx *types.Transaction) error {
FirstNonceGap: nil, // Pool allows arbitrary arrival order, don't invalidate nonce gaps
UsedAndLeftSlots: nil, // Pool has own mechanism to limit the number of transactions
+ EffectiveCost: pool.effectiveCost,
ExistingExpenditure: func(addr common.Address) *big.Int {
- if list := pool.pending[addr]; list != nil {
- return list.totalcost.ToBig()
+ list := pool.pending[addr]
+ if list == nil {
+ return new(big.Int)
+ }
+ // Basis-consistent with EffectiveCost: a reserved sender's queued
+ // expenditure is tracked and read on the value basis, same as its
+ // incoming tx is priced above.
+ if pool.isReserved(addr) {
+ return list.totalvalue.ToBig()
}
- return new(big.Int)
+ return list.totalcost.ToBig()
},
ExistingCost: func(addr common.Address, nonce uint64) *big.Int {
if list := pool.pending[addr]; list != nil {
if tx := list.txs.Get(nonce); tx != nil {
- return tx.Cost()
+ return pool.effectiveCost(addr, tx)
}
}
return nil
@@ -1008,12 +1047,33 @@ func (pool *LegacyPool) add(tx *types.Transaction, async bool) (replaced bool, e
}
// already validated by this point
from, _ := types.Sender(pool.signer, tx)
+ // Resolved once and reused below (the pending-replace branch, the
+ // Underpriced exemption, and the post-enqueue occupancy bump): isReserved
+ // and a pending map lookup are each cheap individually, but this tx is
+ // looked at from several angles below and every one of them would
+ // otherwise re-derive the same answer.
+ reserved := pool.isReserved(from)
+ pendingList := pool.pending[from]
+
+ // Reserved-blockspace occupancy cap: reject outright, before any
+ // Discard/eviction attempt, if this sender is reserved and admitting the
+ // transaction would occupy a genuinely new slot (as opposed to a same-
+ // nonce replacement, which leaves combined occupancy unchanged) that
+ // pushes aggregate reserved occupancy over its cap. Runs unconditionally,
+ // independent of overall pool fullness — unlike the Underpriced exemption
+ // below, which only applies once the pool is already globally full.
+ if reserved && pool.isNewReservedSlot(pendingList, from, tx) {
+ if pool.reservedOccupancy+1 > pool.reservedOccupancyCap() {
+ stage0Duration = time.Since(stage0Time)
+ return false, ErrReservedOccupancyExceeded
+ }
+ }
// If the address is not yet known, request exclusivity to track the account
// only by this subpool until all transactions are evicted
var (
- _, hasPending = pool.pending[from]
- _, hasQueued = pool.queue.get(from)
+ hasPending = pendingList != nil
+ _, hasQueued = pool.queue.get(from)
)
if !hasPending && !hasQueued {
if err := pool.reserver.Hold(from); err != nil {
@@ -1055,7 +1115,7 @@ func (pool *LegacyPool) add(tx *types.Transaction, async bool) (replaced bool, e
// If the new transaction is underpriced, don't accept it. Reserved-blockspace
// senders pay zero fee, so they always look underpriced; exempt them (they
// are also protected from eviction in the priced list's Discard).
- if !pool.isReserved(from) && pool.priced.Underpriced(tx) {
+ if !reserved && pool.priced.Underpriced(tx) {
log.Trace("Discarding underpriced transaction", "hash", hash, "gasTipCap", tx.GasTipCap(), "gasFeeCap", tx.GasFeeCap())
underpricedTxMeter.Mark(1)
stage1Duration = time.Since(stage1Time)
@@ -1142,8 +1202,12 @@ func (pool *LegacyPool) add(tx *types.Transaction, async bool) (replaced bool, e
// increment stage, stage2 time already captured above
currentStage = 2
- // Try to replace an existing transaction in the pending pool
- if list := pool.pending[from]; list != nil && list.Contains(tx.Nonce()) {
+ // Try to replace an existing transaction in the pending pool. pendingList
+ // (resolved once, above) is the same object pool.pending[from] would
+ // still resolve to here: nothing in the full-pool branch above replaces
+ // it wholesale, only mutates it in place or deletes the map entry once
+ // it's empty, both visible through the reference already held.
+ if list := pendingList; list != nil && list.Contains(tx.Nonce()) {
// Nonce already pending, check if required price bump is met
inserted, old := list.Add(tx, pool.config.PriceBump)
if !inserted {
@@ -1185,6 +1249,13 @@ func (pool *LegacyPool) add(tx *types.Transaction, async bool) (replaced bool, e
stage2Duration = time.Since(stage2Time)
return false, err
}
+ // A genuinely new queue slot (not a same-nonce queue replacement): the
+ // only addAll=true caller of enqueueTx, so the increment belongs here
+ // rather than inside enqueueTx, reusing the reserved-ness already
+ // resolved above instead of re-deriving the sender and re-checking it.
+ if !replaced {
+ pool.bumpReservedOccupancy(reserved, 1)
+ }
stage2Duration = time.Since(stage2Time)
log.Trace("Pooled new future transaction", "hash", hash, "from", from, "to", tx.To())
@@ -1215,6 +1286,61 @@ func (pool *LegacyPool) isGapped(from common.Address, tx *types.Transaction) boo
return false
}
+// isNewReservedSlot reports whether tx would occupy a genuinely new pending
+// or queued slot for its sender, as opposed to replacing an existing
+// transaction at the same nonce. Reserved-ness is a property of the sender,
+// invariant across a same-sender replacement, so only a genuinely new slot
+// changes combined reserved occupancy. pending is the caller's own
+// pool.pending[from] lookup (add's pending-replace branch needs the identical
+// check moments later, so callers share one lookup rather than each doing
+// their own).
+func (pool *LegacyPool) isNewReservedSlot(pending *list, from common.Address, tx *types.Transaction) bool {
+ if pending != nil && pending.Contains(tx.Nonce()) {
+ return false
+ }
+ if queued, ok := pool.queue.get(from); ok && queued.Contains(tx.Nonce()) {
+ return false
+ }
+ return true
+}
+
+// reservedOccupancyCap returns the current combined pending+queued occupancy
+// ceiling for reserved-blockspace senders: ReservedMaxOccupancyPercent of the
+// pool's own combined slot ceiling, so normal senders always keep at least
+// the complementary share of the pool.
+func (pool *LegacyPool) reservedOccupancyCap() int {
+ total := pool.config.GlobalSlots + pool.config.GlobalQueue
+ return int(total * pool.config.ReservedMaxOccupancyPercent / 100)
+}
+
+// addReservedOccupancy adjusts the combined reserved-occupancy counter by
+// delta and keeps its gauge in sync, so every mutation site updates both
+// through a single call rather than risking the two drifting apart.
+func (pool *LegacyPool) addReservedOccupancy(delta int) {
+ pool.reservedOccupancy += delta
+ reservedOccupancyGauge.Update(int64(pool.reservedOccupancy))
+}
+
+// bumpReservedOccupancy applies delta to the combined reserved-occupancy
+// counter if reserved is true, a no-op otherwise. Centralizes the
+// isReserved-then-addReservedOccupancy guard repeated at every mutation site;
+// callers that already know an address's reserved-ness (typically because
+// they needed it for something else moments earlier) pass it directly rather
+// than paying for isReserved's atomic load and big.Int allocation again.
+func (pool *LegacyPool) bumpReservedOccupancy(reserved bool, delta int) {
+ if reserved {
+ pool.addReservedOccupancy(delta)
+ }
+}
+
+// bumpReservedOccupancyForTx is bumpReservedOccupancy for a tx whose sender
+// and reserved-ness the caller hasn't already resolved, doing both once.
+func (pool *LegacyPool) bumpReservedOccupancyForTx(tx *types.Transaction, delta int) {
+ if from, err := types.Sender(pool.signer, tx); err == nil {
+ pool.bumpReservedOccupancy(pool.isReserved(from), delta)
+ }
+}
+
// enqueueTx inserts a new transaction into the non-executable transaction queue.
//
// Note, this method assumes the pool lock is held!
@@ -1226,6 +1352,12 @@ func (pool *LegacyPool) enqueueTx(hash common.Hash, tx *types.Transaction, addAl
if replaced != nil {
pool.removeTx(*replaced, true, true)
}
+ // A genuinely new (non-replacing) queue slot's reserved-occupancy
+ // increment is applied by add(), the only addAll=true caller: it already
+ // has the sender's reserved-ness in hand, so there's no need to re-derive
+ // it here. Internal reshuffles (demoteUnexecutables/removeTx postponing a
+ // tx back into the queue) call this with addAll=false and are net-zero
+ // for combined occupancy regardless: the tx already counted while pending.
// If the transaction isn't in lookup set but it's expected to be there,
// show the error log.
if pool.all.Get(hash) == nil && !addAll {
@@ -1251,11 +1383,16 @@ func (pool *LegacyPool) promoteTx(addr common.Address, hash common.Hash, tx *typ
inserted, old := list.Add(tx, pool.config.PriceBump)
if !inserted {
- // An older transaction was better, discard this
+ // An older transaction was better, discard this. Every promotion
+ // candidate came from the queue's readies (see promoteExecutables),
+ // so this is a genuine loss for combined occupancy rather than a
+ // queue->pending move: the tx already left the queue bucket but never
+ // entered the pending one.
pool.all.Remove(hash)
pool.priced.Removed(1)
pendingDiscardMeter.Mark(1)
delete(pool.lastRebroadcast, hash)
+ pool.bumpReservedOccupancy(pool.isReserved(addr), -1)
return false
}
// Otherwise discard any previous transaction and mark this
@@ -1343,7 +1480,7 @@ func (pool *LegacyPool) Add(txs []*types.Transaction, sync bool) []error {
// locking here as we'll use the global lock optimally.
newErrs, dirtyAddrs := pool.addTxs(news, true)
- var nilSlot = 0
+ nilSlot := 0
for _, err := range newErrs {
for errs[nilSlot] != nil {
nilSlot++
@@ -1464,6 +1601,9 @@ func (pool *LegacyPool) removeTx(hash common.Hash, outofbound bool, unreserve bo
return 0
}
addr, _ := types.Sender(pool.signer, tx) // already validated during insertion
+ // Resolved once and reused below for whichever of the pending/queue
+ // branches turns out to be the genuine removal.
+ reserved := pool.isReserved(addr)
// If after deletion there are no more transactions belonging to this account,
// relinquish the address reservation. It's a bit convoluted do this, via a
@@ -1502,10 +1642,21 @@ func (pool *LegacyPool) removeTx(hash common.Hash, outofbound bool, unreserve bo
pool.pendingNonces.setIfLower(addr, tx.Nonce())
// Reduce the pending counter
pendingGauge.Dec(int64(1 + len(invalids)))
+ // Only the target tx is a genuine loss for combined occupancy;
+ // each invalid was just re-enqueued above (pending->queue, net
+ // zero), matching enqueueTx's addAll=false handling.
+ pool.bumpReservedOccupancy(reserved, -1)
return 1 + len(invalids)
}
}
- // Transaction is in the future queue
+ // Transaction is in the future queue. Mirror queue.remove's own
+ // stale-hash guard: only a tx that's actually still at this nonce (not
+ // already superseded by a replacement) is a genuine removal.
+ if list, ok := pool.queue.get(addr); ok {
+ if existing := list.txs.Get(tx.Nonce()); existing != nil && existing.Hash() == tx.Hash() {
+ pool.bumpReservedOccupancy(reserved, -1)
+ }
+ }
pool.queue.remove(addr, tx)
return 0
}
@@ -1679,6 +1830,11 @@ func (pool *LegacyPool) runReorg(done chan struct{}, reset *txpoolResetRequest,
// Ensure pool.queue and pool.pending sizes stay within the configured limits.
pool.truncatePending()
pool.truncateQueue()
+ // Defense-in-depth backstop for the reserved-occupancy cap; see
+ // truncateReservedOccupancy. Ordered after both truncations above since it
+ // spans both buckets and is a no-op unless something upstream let the
+ // combined figure drift over the (possibly newly-lowered) cap.
+ pool.truncateReservedOccupancy()
// Update metrics
dropBetweenReorgHistogram.Update(int64(pool.changesSinceReorg))
@@ -1820,6 +1976,12 @@ func (pool *LegacyPool) reset(oldHead, newHead *types.Header) {
// Add transactions synchronously as we're already holding the lock
pool.addTxs(reinject, false)
+
+ // Layer 2 of the reserved-occupancy counter: recompute it from scratch
+ // once per reorg cycle. This is the self-correcting anchor for the
+ // incremental Layer-1 updates applied at each mutation site above — any
+ // drift from a missed touchpoint cannot accumulate past one reorg cycle.
+ pool.reconcileReservedOccupancy()
}
// SetSpeculativeState updates the pool's internal state to reflect a new
@@ -1861,7 +2023,7 @@ func (pool *LegacyPool) SetSpeculativeState(newHead *types.Header, statedb *stat
// invalidated transactions (low nonce, low balance) are deleted.
func (pool *LegacyPool) promoteExecutables(accounts []common.Address) []*types.Transaction {
gasLimit := pool.currentHead.Load().GasLimit
- promotable, dropped, removedAddresses := pool.queue.promoteExecutables(accounts, gasLimit, pool.currentState, pool.pendingNonces)
+ promotable, dropped, removedAddresses := pool.queue.promoteExecutables(accounts, gasLimit, pool.currentState, pool.pendingNonces, pool.isReserved)
// promote all promotable transactions
promoted := make([]*types.Transaction, 0, len(promotable))
@@ -1872,8 +2034,15 @@ func (pool *LegacyPool) promoteExecutables(accounts []common.Address) []*types.T
}
}
- // remove all removable transactions
+ // remove all removable transactions. dropped merges the queue's forwards
+ // (stale nonce), drops (unpayable/over gas limit) and caps (over
+ // AccountQueue) — every one of these is an outright removal from the
+ // queue bucket, never a move, so each reserved-sender hash here is a
+ // genuine -1. Resolve sender before removing from pool.all.
for _, hash := range dropped {
+ if tx := pool.all.Get(hash); tx != nil {
+ pool.bumpReservedOccupancyForTx(tx, -1)
+ }
pool.all.Remove(hash)
delete(pool.lastRebroadcast, hash)
}
@@ -1940,6 +2109,7 @@ func (pool *LegacyPool) truncatePending() {
}
pool.priced.Removed(len(caps))
pendingGauge.Dec(int64(len(caps)))
+ pool.bumpReservedOccupancy(pool.isReserved(offenders[i]), -len(caps))
pending--
}
@@ -1966,6 +2136,7 @@ func (pool *LegacyPool) truncatePending() {
}
pool.priced.Removed(len(caps))
pendingGauge.Dec(int64(len(caps)))
+ pool.bumpReservedOccupancy(pool.isReserved(addr), -len(caps))
pending--
}
}
@@ -1977,8 +2148,14 @@ func (pool *LegacyPool) truncatePending() {
func (pool *LegacyPool) truncateQueue() {
removed, removedAddresses := pool.queue.truncate()
- // Remove all removable transactions from the lookup and global price list
+ // Remove all removable transactions from the lookup and global price list.
+ // Resolve each hash's sender before removing it from pool.all, so reserved
+ // drops can be attributed even for accounts that were only partially
+ // truncated (and so don't appear in removedAddresses).
for _, hash := range removed {
+ if tx := pool.all.Get(hash); tx != nil {
+ pool.bumpReservedOccupancyForTx(tx, -1)
+ }
pool.all.Remove(hash)
delete(pool.lastRebroadcast, hash)
}
@@ -2007,6 +2184,7 @@ func (pool *LegacyPool) demoteUnexecutables() {
gasLimit := currentHeader.GasLimit
for addr, list := range pool.pending {
nonce := pool.currentState.GetNonce(addr)
+ reserved := pool.isReserved(addr)
// Drop all transactions that are deemed too old (low nonce)
olds := list.Forward(nonce)
@@ -2017,7 +2195,7 @@ func (pool *LegacyPool) demoteUnexecutables() {
log.Trace("Removed old pending transaction", "hash", hash)
}
// Drop all transactions that are too costly (low balance or out of gas), and queue any invalids back for later
- drops, invalids := list.Filter(pool.currentState.GetBalance(addr), gasLimit)
+ drops, invalids := list.Filter(pool.currentState.GetBalance(addr), gasLimit, reserved)
for _, tx := range drops {
hash := tx.Hash()
pool.all.Remove(hash)
@@ -2044,6 +2222,9 @@ func (pool *LegacyPool) demoteUnexecutables() {
}
pendingGauge.Dec(int64(len(olds) + len(drops) + len(invalids) + len(txConditionalsRemoved)))
+ // invalids move to the queue (enqueueTx above, addAll=false) and net
+ // zero for combined occupancy; only outright removals are a loss.
+ pool.bumpReservedOccupancy(reserved, -(len(olds) + len(drops) + len(txConditionalsRemoved)))
// If there's a gap in front, alert (should never happen) and postpone all transactions
if list.Len() > 0 && list.txs.Get(nonce) == nil {
gapped := list.Cap(0)
@@ -2323,6 +2504,7 @@ func (pool *LegacyPool) Clear() {
pool.pending = make(map[common.Address]*list)
pool.queue = newQueue(pool.config, pool.signer)
pool.pendingNonces = newNoncer(pool.currentState)
+ pool.addReservedOccupancy(-pool.reservedOccupancy)
}
// HasPendingAuth returns a flag indicating whether there are pending
@@ -2360,6 +2542,21 @@ func (pool *LegacyPool) isReserved(addr common.Address) bool {
return pool.reservedSnapshot.Load().IsReserved(addr)
}
+// effectiveCost prices tx for the pool's balance checks: value alone for a
+// reserved-blockspace sender (execution waives the gas debit up to quota, see
+// reservedZeroFeeGas in core/state_transition.go), full cost otherwise. The
+// pool is quota-unaware, so this is a superset of what will actually execute
+// fee-free; an overflowing tx that doesn't fit is caught at execution instead
+// (see the design note on isReserved). addr is the caller's already-recovered
+// sender; unlike reservedTx (the priced heap's bare-tx predicate), this is
+// never called without one, so it has no signer-recovery fallback.
+func (pool *LegacyPool) effectiveCost(addr common.Address, tx *types.Transaction) *big.Int {
+ if pool.isReserved(addr) {
+ return tx.Value()
+ }
+ return tx.Cost()
+}
+
// reservedTx is the tx-level reserved predicate handed to the priced list so it
// can protect reserved-blockspace transactions from price-based eviction. The
// sender is read from the tx's cached recovery (set during admission), so it
@@ -2398,3 +2595,138 @@ func (pool *LegacyPool) rebuildReservedSnapshot(statedb *state.StateDB, head *ty
}
pool.reservedSnapshot.Store(snap)
}
+
+// reconcileReservedOccupancy recomputes reservedOccupancy from scratch and
+// overwrites the incrementally-tracked value with it, logging any disagreement
+// first so a Layer-1 touchpoint bug is observable rather than silently
+// self-correcting into invisibility. Called once per reorg cycle from reset.
+func (pool *LegacyPool) reconcileReservedOccupancy() {
+ recomputed := pool.recomputeReservedOccupancy()
+ if delta := recomputed - pool.reservedOccupancy; delta != 0 {
+ log.Warn("Reserved pool occupancy drifted from its incremental tally, correcting",
+ "incremental", pool.reservedOccupancy, "recomputed", recomputed, "delta", delta)
+ }
+ pool.reservedOccupancy = recomputed
+ reservedOccupancyGauge.Update(int64(recomputed))
+}
+
+// reservedCount returns addr's combined pending+queued transaction count,
+// regardless of whether addr is currently reserved — callers gate on
+// isReserved themselves, since the two places this is used (a from-scratch
+// recompute and the reorg-time backstop's spam ordering) each need to combine
+// that gate with the count differently.
+func (pool *LegacyPool) reservedCount(addr common.Address) int {
+ count := 0
+ if list := pool.pending[addr]; list != nil {
+ count += list.Len()
+ }
+ if list, ok := pool.queue.get(addr); ok {
+ count += list.Len()
+ }
+ return count
+}
+
+// recomputeReservedOccupancy walks the addresses currently tracked by the
+// pool (pending and queued) and re-sums combined occupancy for whichever of
+// them are reserved. An address that is reserved but holds nothing in the
+// pool contributes zero either way, so this is equivalent to walking the full
+// registry-reserved address set, but costs only O(distinct addresses
+// currently in the pool) — the same bound truncatePending/demoteUnexecutables
+// already pay once per reorg cycle — with no extra registry state read.
+//
+// pending and queue are disjoint maps, so summing reservedCount once per
+// address across the two loops below can never double-count; the only care
+// needed is not visiting an address (and so its already-combined count)
+// twice, which the pool.pending membership check in the second loop handles
+// without a separate address set.
+func (pool *LegacyPool) recomputeReservedOccupancy() int {
+ var occupancy int
+ for addr := range pool.pending {
+ if pool.isReserved(addr) {
+ occupancy += pool.reservedCount(addr)
+ }
+ }
+ for _, addr := range pool.queue.addresses() {
+ if _, ok := pool.pending[addr]; ok {
+ continue // already counted above
+ }
+ if pool.isReserved(addr) {
+ occupancy += pool.reservedCount(addr)
+ }
+ }
+ return occupancy
+}
+
+// truncateReservedOccupancy is the reorg-time backstop for the reserved
+// occupancy cap, mirroring truncatePending's own "assemble a spam order,
+// penalize large transactors first" shape (down to reusing the same prque):
+// build a priority queue of reserved addresses keyed by combined
+// pending+queue count once, then repeatedly pop the largest, evict its
+// newest transaction, and re-push it with its updated count. This is
+// O(D log D + N log D) — D distinct reserved addresses, N evicted — rather
+// than rescanning every reserved address per eviction.
+//
+// The synchronous admission gate in add() is the primary defense; this
+// defense-in-depth pass only acts if occupancy is ever found over cap here
+// regardless — e.g. because ReservedMaxOccupancyPercent was just lowered, or
+// a registry change shrank the cap.
+func (pool *LegacyPool) truncateReservedOccupancy() {
+ limit := pool.reservedOccupancyCap()
+ if pool.reservedOccupancy <= limit {
+ return
+ }
+
+ spammers := prque.New[int, common.Address](nil)
+ push := func(addr common.Address) {
+ if !pool.isReserved(addr) {
+ return
+ }
+ if count := pool.reservedCount(addr); count > 0 {
+ spammers.Push(addr, count)
+ }
+ }
+ for addr := range pool.pending {
+ push(addr)
+ }
+ for _, addr := range pool.queue.addresses() {
+ if _, ok := pool.pending[addr]; ok {
+ continue // already pushed above with its full combined count
+ }
+ push(addr)
+ }
+
+ before := pool.reservedOccupancy
+ for pool.reservedOccupancy > limit && !spammers.Empty() {
+ addr, _ := spammers.Pop()
+ hash, ok := pool.newestReservedTx(addr)
+ if !ok {
+ continue // this account's transactions were already accounted for elsewhere
+ }
+ pool.removeTx(hash, true, true)
+ if count := pool.reservedCount(addr); count > 0 {
+ spammers.Push(addr, count)
+ }
+ }
+ if dropped := before - pool.reservedOccupancy; dropped > 0 {
+ log.Warn("Trimmed reserved-sender transactions to enforce the occupancy cap", "dropped", dropped, "cap", limit)
+ }
+}
+
+// newestReservedTx returns the hash of addr's highest-nonce transaction across
+// both pending and queued, or ok=false if addr holds none. Trimming the
+// highest nonce first never opens a gap in either list.
+func (pool *LegacyPool) newestReservedTx(addr common.Address) (hash common.Hash, ok bool) {
+ var newest *types.Transaction
+ if list := pool.pending[addr]; list != nil && !list.Empty() {
+ newest = list.LastElement()
+ }
+ if list, has := pool.queue.get(addr); has && !list.Empty() {
+ if candidate := list.LastElement(); newest == nil || candidate.Nonce() > newest.Nonce() {
+ newest = candidate
+ }
+ }
+ if newest == nil {
+ return common.Hash{}, false
+ }
+ return newest.Hash(), true
+}
diff --git a/core/txpool/legacypool/legacypool_test.go b/core/txpool/legacypool/legacypool_test.go
index e5819ffd3e..ecaeef4839 100644
--- a/core/txpool/legacypool/legacypool_test.go
+++ b/core/txpool/legacypool/legacypool_test.go
@@ -27,6 +27,7 @@ import (
"os"
"runtime"
"slices"
+ "strings"
"sync"
"sync/atomic"
"testing"
@@ -35,8 +36,6 @@ import (
crand2 "github.com/0xPolygon/crand"
"github.com/stretchr/testify/require"
- "strings"
-
"github.com/holiman/uint256"
"github.com/ethereum/go-ethereum/common"
@@ -130,8 +129,7 @@ func transaction(nonce uint64, gaslimit uint64, key *ecdsa.PrivateKey) *types.Tr
}
func pricedTransaction(nonce uint64, gaslimit uint64, gasprice *big.Int, key *ecdsa.PrivateKey) *types.Transaction {
- tx, _ := types.SignTx(types.NewTransaction(nonce, common.Address{0x01}, big.NewInt(100), gaslimit, gasprice, nil), types.HomesteadSigner{}, key)
- return tx
+ return costValueTx(nonce, gaslimit, gasprice, big.NewInt(100), key)
}
// pricedDataTransaction generates a signed transaction with fixed-size data,
@@ -150,7 +148,7 @@ func pricedDataTransaction(nonce uint64, gaslimit uint64, gasprice *big.Int, key
var tx *types.Transaction
// 10 attempts is statistically sufficient since leading zeros in ECDSA signatures are rare and randomly distributed.
- var retryTimes = 10
+ retryTimes := 10
for i := 0; i < retryTimes; i++ {
data := make([]byte, dataBytes)
crand.Read(data)
diff --git a/core/txpool/legacypool/list.go b/core/txpool/legacypool/list.go
index a62f81ba72..091086e612 100644
--- a/core/txpool/legacypool/list.go
+++ b/core/txpool/legacypool/list.go
@@ -304,19 +304,32 @@ type list struct {
strict bool // Whether nonces are strictly continuous or not
txs *SortedMap // Heap indexed sorted hash map of the transactions
- costcap *uint256.Int // Price of the highest costing transaction (reset only if exceeds balance)
- gascap uint64 // Gas limit of the highest spending transaction (reset only if exceeds block limit)
- totalcost *uint256.Int // Total cost of all transactions in the list
+ costcap *uint256.Int // Price of the highest costing transaction (reset only if exceeds balance)
+ valuecap *uint256.Int // Value of the highest valued transaction (reset only if exceeds balance)
+ gascap uint64 // Gas limit of the highest spending transaction (reset only if exceeds block limit)
+
+ totalcost *uint256.Int // Total cost (value + gas*feeCap, ...) of all transactions in the list
+ totalvalue *uint256.Int // Total value (tx.Value() alone) of all transactions in the list
+
+ // costcap/totalcost and valuecap/totalvalue are two independent bases kept
+ // in lockstep by every mutation below. Reserved-blockspace senders are
+ // priced on the value basis for pool balance checks (their execution
+ // waives the gas debit up to quota); everyone else is priced on the full-
+ // cost basis. Keeping both always in sync, rather than deriving one from
+ // the other on demand, lets Filter operate on whichever basis the caller
+ // selects without one basis's cap corrupting the other's (see Filter).
}
// newList creates a new transaction list for maintaining nonce-indexable fast,
// gapped, sortable transaction lists.
func newList(strict bool) *list {
return &list{
- strict: strict,
- txs: NewSortedMap(),
- costcap: new(uint256.Int),
- totalcost: new(uint256.Int),
+ strict: strict,
+ txs: NewSortedMap(),
+ costcap: new(uint256.Int),
+ valuecap: new(uint256.Int),
+ totalcost: new(uint256.Int),
+ totalvalue: new(uint256.Int),
}
}
@@ -359,20 +372,29 @@ func (l *list) Add(tx *types.Transaction, priceBump uint64) (bool, *types.Transa
return false, nil
}
}
- // Add new tx cost to totalcost
+ // Add new tx cost and value to the running totals
cost, overflow := uint256.FromBig(tx.Cost())
if overflow {
return false, nil
}
- total, overflow := new(uint256.Int).AddOverflow(l.totalcost, cost)
+ value, overflow := uint256.FromBig(tx.Value())
+ if overflow {
+ return false, nil
+ }
+ totalCost, overflow := new(uint256.Int).AddOverflow(l.totalcost, cost)
+ if overflow {
+ return false, nil
+ }
+ totalValue, overflow := new(uint256.Int).AddOverflow(l.totalvalue, value)
if overflow {
return false, nil
}
- l.totalcost = total
+ l.totalcost = totalCost
+ l.totalvalue = totalValue
- // Old is being replaced, subtract old cost
+ // Old is being replaced, subtract old cost and value
if old != nil {
- l.subTotalCost([]*types.Transaction{old})
+ l.subTotals([]*types.Transaction{old})
}
// Otherwise overwrite the old transaction with the current one
@@ -380,6 +402,9 @@ func (l *list) Add(tx *types.Transaction, priceBump uint64) (bool, *types.Transa
if l.costcap.Cmp(cost) < 0 {
l.costcap = cost
}
+ if l.valuecap.Cmp(value) < 0 {
+ l.valuecap = value
+ }
if gas := tx.Gas(); l.gascap < gas {
l.gascap = gas
}
@@ -391,7 +416,7 @@ func (l *list) Add(tx *types.Transaction, priceBump uint64) (bool, *types.Transa
// maintenance.
func (l *list) Forward(threshold uint64) types.Transactions {
txs := l.txs.Forward(threshold)
- l.subTotalCost(txs)
+ l.subTotals(txs)
return txs
}
@@ -401,21 +426,50 @@ func (l *list) Forward(threshold uint64) types.Transactions {
// post-removal maintenance. Strict-mode invalidated transactions are also
// returned.
//
-// This method uses the cached costcap and gascap to quickly decide if there's even
-// a point in calculating all the costs or if the balance covers all. If the threshold
-// is lower than the costgas cap, the caps will be reset to a new high after removing
-// the newly invalidated transactions.
-func (l *list) Filter(costLimit *uint256.Int, gasLimit uint64) (types.Transactions, types.Transactions) {
+// valueBasis selects which basis costLimit is priced against: false (the
+// default) is today's full-cost basis — costLimit is a balance compared
+// against tx.Cost(), and only costcap is consulted/lowered. true is the
+// value basis for reserved-blockspace senders, whose execution waives the gas
+// debit up to quota — costLimit is compared against tx.Value() alone, and
+// only valuecap is consulted/lowered.
+//
+// The two caps must stay independent. A shared cap would let a value-basis
+// pass over a reserved sender lower it to the sender's balance while
+// removing nothing (the sender's txs already satisfy the value bound), and
+// once the sender is later priced on the full-cost basis again (e.g. after
+// deregistration) that pass would short-circuit on the value-lowered cap and
+// leave unpayable transactions stranded in the pool. Because each pass only
+// ever lowers the cap it actually enforced, costcap and valuecap each stay a
+// true upper bound for their own basis across any number of basis switches.
+//
+// This method uses the cached cost/value cap and gascap to quickly decide if
+// there's even a point in calculating all the costs or if the balance covers
+// all. If the threshold is lower than the relevant cap, that cap is reset to
+// the new high after removing the newly invalidated transactions.
+func (l *list) Filter(costLimit *uint256.Int, gasLimit uint64, valueBasis bool) (types.Transactions, types.Transactions) {
+ activeCap := l.costcap
+ if valueBasis {
+ activeCap = l.valuecap
+ }
// If all transactions are below the threshold, short circuit
- if l.costcap.Cmp(costLimit) <= 0 && l.gascap <= gasLimit {
+ if activeCap.Cmp(costLimit) <= 0 && l.gascap <= gasLimit {
return nil, nil
}
- l.costcap = new(uint256.Int).Set(costLimit) // Lower the caps to the thresholds
+ if valueBasis {
+ l.valuecap = new(uint256.Int).Set(costLimit)
+ } else {
+ l.costcap = new(uint256.Int).Set(costLimit)
+ }
l.gascap = gasLimit
- // Filter out all the transactions above the account's funds
+ // Filter out all the transactions above the account's funds on the
+ // selected basis.
removed := l.txs.Filter(func(tx *types.Transaction) bool {
- return tx.Gas() > gasLimit || tx.Cost().Cmp(costLimit.ToBig()) > 0
+ basisCost := tx.Cost()
+ if valueBasis {
+ basisCost = tx.Value()
+ }
+ return tx.Gas() > gasLimit || basisCost.Cmp(costLimit.ToBig()) > 0
})
if len(removed) == 0 {
@@ -432,9 +486,9 @@ func (l *list) Filter(costLimit *uint256.Int, gasLimit uint64) (types.Transactio
}
invalids = l.txs.filter(func(tx *types.Transaction) bool { return tx.Nonce() > lowest })
}
- // Reset total cost
- l.subTotalCost(removed)
- l.subTotalCost(invalids)
+ // Reset total cost and value
+ l.subTotals(removed)
+ l.subTotals(invalids)
l.txs.reheap()
return removed, invalids
}
@@ -481,7 +535,7 @@ func (l *list) FilterTxConditional(state *state.StateDB, header *types.Header) t
// exceeding that limit.
func (l *list) Cap(threshold int) types.Transactions {
txs := l.txs.Cap(threshold)
- l.subTotalCost(txs)
+ l.subTotals(txs)
return txs
}
@@ -496,11 +550,11 @@ func (l *list) Remove(tx *types.Transaction) (bool, types.Transactions) {
return false, nil
}
- l.subTotalCost([]*types.Transaction{tx})
+ l.subTotals([]*types.Transaction{tx})
// In strict mode, filter out non-executable transactions
if l.strict {
txs := l.txs.Filter(func(tx *types.Transaction) bool { return tx.Nonce() > nonce })
- l.subTotalCost(txs)
+ l.subTotals(txs)
return true, txs
}
@@ -517,7 +571,7 @@ func (l *list) Remove(tx *types.Transaction) (bool, types.Transactions) {
// happen but better to be self correcting than failing!
func (l *list) Ready(start uint64) types.Transactions {
txs := l.txs.Ready(start)
- l.subTotalCost(txs)
+ l.subTotals(txs)
return txs
}
@@ -549,14 +603,20 @@ func (l *list) Has(nonce uint64) bool {
return l != nil && l.txs.items[nonce] != nil
}
-// subTotalCost subtracts the cost of the given transactions from the
-// total cost of all transactions.
-func (l *list) subTotalCost(txs []*types.Transaction) {
+// subTotals subtracts the cost and value of the given transactions from the
+// list's cost and value totals. The two aggregates are always maintained
+// together so either basis is available at read time regardless of which
+// basis (if any) drove the removal.
+func (l *list) subTotals(txs []*types.Transaction) {
for _, tx := range txs {
_, underflow := l.totalcost.SubOverflow(l.totalcost, uint256.MustFromBig(tx.Cost()))
if underflow {
panic("totalcost underflow")
}
+ _, underflow = l.totalvalue.SubOverflow(l.totalvalue, uint256.MustFromBig(tx.Value()))
+ if underflow {
+ panic("totalvalue underflow")
+ }
}
}
diff --git a/core/txpool/legacypool/list_test.go b/core/txpool/legacypool/list_test.go
index ce325faaeb..7946c6227a 100644
--- a/core/txpool/legacypool/list_test.go
+++ b/core/txpool/legacypool/list_test.go
@@ -17,6 +17,7 @@
package legacypool
import (
+ "crypto/ecdsa"
"math/big"
"math/rand"
"testing"
@@ -34,6 +35,15 @@ import (
"github.com/ethereum/go-ethereum/triedb"
)
+// costValueTx builds a signed legacy transaction with an independently
+// controllable value and gas price, so Cost() (value + gas*gasPrice) and
+// Value() diverge by a known amount. The fixed-value transaction() helper
+// can't exercise that divergence, which every test below depends on.
+func costValueTx(nonce, gaslimit uint64, gasPrice, value *big.Int, key *ecdsa.PrivateKey) *types.Transaction {
+ tx, _ := types.SignTx(types.NewTransaction(nonce, common.Address{0x01}, value, gaslimit, gasPrice, nil), types.HomesteadSigner{}, key)
+ return tx
+}
+
// Tests that transactions can be added to strict lists and list contents and
// nonce boundaries are correctly maintained.
func TestStrictListAdd(t *testing.T) {
@@ -93,7 +103,7 @@ func BenchmarkListAdd(b *testing.B) {
list := newList(true)
for _, v := range rand.Perm(len(txs)) {
list.Add(txs[v], DefaultConfig.PriceBump)
- list.Filter(priceLimit, DefaultConfig.PriceBump)
+ list.Filter(priceLimit, DefaultConfig.PriceBump, false)
}
}
}
@@ -381,3 +391,180 @@ func BenchmarkListCapOneTx(b *testing.B) {
b.StopTimer()
}
}
+
+// TestListAggregatesConsistency drives Add (including a fee-bumped
+// replacement), Forward, Cap, and Remove in sequence on a single list and
+// checks totalcost/totalvalue against the sum of the surviving transactions'
+// Cost()/Value() after every step. The two aggregates are meant to be
+// maintained in lockstep regardless of which operation touched the list.
+func TestListAggregatesConsistency(t *testing.T) {
+ t.Parallel()
+
+ key, _ := crypto.GenerateKey()
+ l := newList(true)
+
+ sum := func(txs ...*types.Transaction) (cost, value *big.Int) {
+ cost, value = new(big.Int), new(big.Int)
+ for _, tx := range txs {
+ cost.Add(cost, tx.Cost())
+ value.Add(value, tx.Value())
+ }
+ return cost, value
+ }
+ assertTotals := func(t *testing.T, txs ...*types.Transaction) {
+ t.Helper()
+ wantCost, wantValue := sum(txs...)
+ // big.Int's zero value distinguishes a nil internal slice from an
+ // empty one under reflect.DeepEqual, so compare via Cmp rather than
+ // require.Equal on the *big.Int values themselves.
+ require.Zero(t, wantCost.Cmp(l.totalcost.ToBig()), "totalcost: want %s, have %s", wantCost, l.totalcost)
+ require.Zero(t, wantValue.Cmp(l.totalvalue.ToBig()), "totalvalue: want %s, have %s", wantValue, l.totalvalue)
+ }
+
+ tx0 := costValueTx(0, 100, big.NewInt(1000), big.NewInt(500), key) // cost 100500
+ tx1 := costValueTx(1, 200, big.NewInt(1000), big.NewInt(700), key) // cost 200700
+ tx2 := costValueTx(2, 50, big.NewInt(1000), big.NewInt(300), key) // cost 50300
+ inserted, _ := l.Add(tx0, DefaultConfig.PriceBump)
+ require.True(t, inserted)
+ inserted, _ = l.Add(tx1, DefaultConfig.PriceBump)
+ require.True(t, inserted)
+ inserted, _ = l.Add(tx2, DefaultConfig.PriceBump)
+ require.True(t, inserted)
+ assertTotals(t, tx0, tx1, tx2)
+ require.Equal(t, tx1.Cost(), l.costcap.ToBig(), "costcap after Add")
+ require.Equal(t, tx1.Value(), l.valuecap.ToBig(), "valuecap after Add")
+
+ // Replace tx1 with a strictly higher fee (required for Add to accept the
+ // replacement) and a different value/gas, so both aggregates must reflect
+ // the swap, not an addition.
+ tx1b := costValueTx(1, 200, big.NewInt(1200), big.NewInt(900), key) // cost 240900
+ inserted, old := l.Add(tx1b, DefaultConfig.PriceBump)
+ require.True(t, inserted)
+ require.Equal(t, tx1.Hash(), old.Hash())
+ assertTotals(t, tx0, tx1b, tx2)
+ require.Equal(t, tx1b.Cost(), l.costcap.ToBig(), "costcap grows to the replacement's higher cost")
+ require.Equal(t, tx1b.Value(), l.valuecap.ToBig(), "valuecap grows to the replacement's higher value")
+
+ // Forward past tx0's nonce.
+ forwarded := l.Forward(1)
+ require.Len(t, forwarded, 1)
+ assertTotals(t, tx1b, tx2)
+
+ // Cap down to the single lowest remaining nonce (tx1b), dropping tx2.
+ capped := l.Cap(1)
+ require.Len(t, capped, 1)
+ require.Equal(t, tx2.Hash(), capped[0].Hash())
+ assertTotals(t, tx1b)
+
+ // Remove the last transaction; both aggregates return to zero.
+ removed, _ := l.Remove(tx1b)
+ require.True(t, removed)
+ assertTotals(t)
+}
+
+// TestListFilterBothBases pins Filter's basis independence: a full-cost pass
+// (valueBasis=false) only ever mutates costcap and drops on tx.Cost(); a
+// value pass (valueBasis=true) only ever mutates valuecap and drops on
+// tx.Value(). Neither pass touches the other basis's cap.
+func TestListFilterBothBases(t *testing.T) {
+ t.Parallel()
+
+ key, _ := crypto.GenerateKey()
+ const bigGasLimit = 1_000_000_000
+
+ tests := []struct {
+ name string
+ valueBasis bool
+ survivor *types.Transaction // stays: below the limit on the basis under test
+ dropped *types.Transaction // removed: above the limit on the basis under test
+ }{
+ {
+ name: "full-cost basis mutates only costcap",
+ valueBasis: false,
+ survivor: costValueTx(0, 100, big.NewInt(1), big.NewInt(50), key), // cost 150
+ dropped: costValueTx(1, 100, big.NewInt(20), big.NewInt(50), key), // cost 2050
+ },
+ {
+ name: "value basis mutates only valuecap",
+ valueBasis: true,
+ survivor: costValueTx(0, 100, big.NewInt(1000), big.NewInt(100), key), // value 100
+ dropped: costValueTx(1, 100, big.NewInt(1000), big.NewInt(2000), key), // value 2000
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ l := newList(true)
+ l.Add(tt.survivor, DefaultConfig.PriceBump)
+ l.Add(tt.dropped, DefaultConfig.PriceBump)
+
+ // capNow/otherCapNow re-read the fields (rather than aliasing the
+ // *uint256.Int pointers up front) since Filter rebinds whichever
+ // one it lowers to a new object.
+ capNow := func() *big.Int {
+ if tt.valueBasis {
+ return l.valuecap.ToBig()
+ }
+ return l.costcap.ToBig()
+ }
+ otherCapNow := func() *big.Int {
+ if tt.valueBasis {
+ return l.costcap.ToBig()
+ }
+ return l.valuecap.ToBig()
+ }
+ otherWant := otherCapNow()
+
+ removed, _ := l.Filter(uint256.NewInt(500), bigGasLimit, tt.valueBasis)
+ require.Len(t, removed, 1)
+ require.Equal(t, tt.dropped.Hash(), removed[0].Hash())
+ require.Equal(t, big.NewInt(500), capNow(), "active cap lowered to the limit")
+ require.Equal(t, otherWant, otherCapNow(), "other basis's cap untouched")
+ })
+ }
+}
+
+// TestListFilterCapCorruptionRegression is the regression pinned in
+// core/txpool/legacypool/list.go's Filter doc comment: a value-basis pass
+// over a reserved sender must never leave the list unable to shed the same
+// transactions once priced at full cost again (e.g. after deregistration).
+//
+// Before the dual-cap fix, both bases shared a single costcap. A value-basis
+// pass that didn't short-circuit would unconditionally lower that shared cap
+// to the balance (list.go:413 in the pre-fix code) without removing anything
+// (the transaction's value fits); the next full-cost pass at the same limit
+// would then short-circuit on the already-lowered cap (list.go:410) and
+// never re-examine the transaction's real (unpayable) cost, stranding it in
+// the pool forever.
+func TestListFilterCapCorruptionRegression(t *testing.T) {
+ t.Parallel()
+
+ key, _ := crypto.GenerateKey()
+ l := newList(true)
+
+ // A fallback-fee-shaped transaction: affordable value, unaffordable full
+ // cost (gas*gasPrice alone vastly exceeds the balance below).
+ tx := costValueTx(0, 100_000, big.NewInt(1_000_000), big.NewInt(500), key)
+ l.Add(tx, DefaultConfig.PriceBump)
+
+ balance := uint256.NewInt(1000)
+ const gasLimit = 30_000_000
+
+ // The reserved-sender pass: prices on value alone. The transaction's
+ // value (500) fits, so nothing is removed.
+ removed, invalid := l.Filter(balance, gasLimit, true)
+ require.Empty(t, removed)
+ require.Empty(t, invalid)
+ require.True(t, l.Contains(0), "tx must survive the value-basis pass")
+
+ // Simulated deregistration: no list-level action, just a later full-cost
+ // pass at the identical limit.
+ removed, invalid = l.Filter(balance, gasLimit, false)
+ require.Empty(t, invalid)
+ require.Len(t, removed, 1, "the full-cost pass must still see and drop the unpayable tx")
+ require.Equal(t, tx.Hash(), removed[0].Hash())
+ require.True(t, l.Empty())
+ require.True(t, l.totalcost.IsZero())
+ require.True(t, l.totalvalue.IsZero())
+}
diff --git a/core/txpool/legacypool/queue.go b/core/txpool/legacypool/queue.go
index b1ea0b050c..69c34f5fc4 100644
--- a/core/txpool/legacypool/queue.go
+++ b/core/txpool/legacypool/queue.go
@@ -151,11 +151,15 @@ func (q *queue) add(tx *types.Transaction) (*common.Hash, error) {
// for promotion any that are now executable. It also drops any transactions that are
// deemed too old (nonce too low) or too costly (insufficient funds or over gas limit).
//
+// isReserved reports whether addr is a reserved-blockspace sender for the
+// block being built, so the balance revalidation below prices such senders on
+// the value basis, matching the pending-side check in demoteUnexecutables.
+//
// Returns three lists:
// - all transactions that were removed from the queue and selected for promotion;
// - all other transactions that were removed from the queue and dropped;
// - the list of addresses removed.
-func (q *queue) promoteExecutables(accounts []common.Address, gasLimit uint64, currentState *state.StateDB, nonces *noncer) ([]*types.Transaction, []common.Hash, []common.Address) {
+func (q *queue) promoteExecutables(accounts []common.Address, gasLimit uint64, currentState *state.StateDB, nonces *noncer, isReserved func(common.Address) bool) ([]*types.Transaction, []common.Hash, []common.Address) {
// Track the promotable transactions to broadcast them at once
var (
promotable []*types.Transaction
@@ -176,7 +180,7 @@ func (q *queue) promoteExecutables(accounts []common.Address, gasLimit uint64, c
log.Trace("Removing old queued transactions", "count", len(forwards))
// Drop all transactions that are too costly (low balance or out of gas)
- drops, _ := list.Filter(currentState.GetBalance(addr), gasLimit)
+ drops, _ := list.Filter(currentState.GetBalance(addr), gasLimit, isReserved(addr))
for _, tx := range drops {
dropped = append(dropped, tx.Hash())
}
@@ -190,7 +194,7 @@ func (q *queue) promoteExecutables(accounts []common.Address, gasLimit uint64, c
queuedGauge.Dec(int64(len(readies)))
// Drop all transactions over the allowed limit
- var caps = list.Cap(int(q.config.AccountQueue))
+ caps := list.Cap(int(q.config.AccountQueue))
for _, tx := range caps {
hash := tx.Hash()
dropped = append(dropped, hash)
diff --git a/core/txpool/legacypool/reserved_occupancy_test.go b/core/txpool/legacypool/reserved_occupancy_test.go
new file mode 100644
index 0000000000..1b0e3eab6c
--- /dev/null
+++ b/core/txpool/legacypool/reserved_occupancy_test.go
@@ -0,0 +1,430 @@
+// Copyright 2025 The go-ethereum Authors
+// This file is part of the go-ethereum library.
+//
+// The go-ethereum library is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Lesser General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// The go-ethereum library is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Lesser General Public License for more details.
+//
+// You should have received a copy of the GNU Lesser General Public License
+// along with the go-ethereum library. If not, see .
+
+package legacypool
+
+import (
+ "crypto/ecdsa"
+ "errors"
+ "math/big"
+ "sync"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/ethereum/go-ethereum/common"
+ "github.com/ethereum/go-ethereum/core/txpool"
+ "github.com/ethereum/go-ethereum/core/types"
+ "github.com/ethereum/go-ethereum/crypto"
+)
+
+// smallOccupancyConfig is testTxPoolConfig with a small combined slot ceiling,
+// so the reserved-occupancy cap (a percentage of GlobalSlots+GlobalQueue) is
+// cheap to reach with a handful of test transactions instead of thousands.
+func smallOccupancyConfig(percent uint64) Config {
+ cfg := testTxPoolConfig
+ cfg.GlobalSlots = 20
+ cfg.GlobalQueue = 20
+ cfg.ReservedMaxOccupancyPercent = percent
+ return cfg
+}
+
+// TestReservedOccupancyCapRejectsFloodPreservingNormalHeadroom is the
+// regression this whole design exists to prevent: a reserved client with many
+// whitelisted addresses (well beyond any single client's realistic
+// whitelist, but cheap to construct here) floods the pool with zero-fee
+// transactions. Once combined reserved occupancy hits
+// ReservedMaxOccupancyPercent of GlobalSlots+GlobalQueue, further reserved
+// transactions must be rejected with ErrReservedOccupancyExceeded — and a
+// normal fee-paying sender must still be admitted throughout, proving
+// normal senders' headroom is genuinely held rather than merely documented.
+func TestReservedOccupancyCapRejectsFloodPreservingNormalHeadroom(t *testing.T) {
+ t.Parallel()
+
+ const numReserved = 200
+ keys := make([]*ecdsa.PrivateKey, numReserved)
+ addrs := make([]common.Address, numReserved)
+ for i := range keys {
+ keys[i], _ = crypto.GenerateKey()
+ addrs[i] = crypto.PubkeyToAddress(keys[i].PublicKey)
+ }
+
+ cfg := smallOccupancyConfig(50) // cap = (20+20)*50/100 = 20
+ pool := setupReservedPoolWithConfig(cfg, big.NewInt(0), addrs...)
+ defer pool.Close()
+
+ wantCap := pool.reservedOccupancyCap()
+ require.Equal(t, 20, wantCap)
+
+ var admitted, rejected int
+ for i, key := range keys {
+ tx := zeroFeeTx(t, pool.chainconfig, key, 0, common.Address{0x42})
+ err := pool.Add([]*types.Transaction{tx}, true)[0]
+ switch {
+ case err == nil:
+ admitted++
+ case errors.Is(err, ErrReservedOccupancyExceeded):
+ rejected++
+ default:
+ t.Fatalf("reserved tx %d: unexpected error %v", i, err)
+ }
+ }
+ require.Equal(t, wantCap, admitted, "admitted reserved transactions must exactly fill the cap")
+ require.Positive(t, rejected, "the flood must eventually be rejected once the cap is hit")
+
+ pool.mu.RLock()
+ occupancy := pool.reservedOccupancy
+ pool.mu.RUnlock()
+ require.Equal(t, wantCap, occupancy)
+
+ // A normal, fee-paying sender must still be admitted with reserved
+ // occupancy pinned at the cap: the reserved flood never touches the
+ // pool's non-reserved headroom.
+ otherKey, _ := crypto.GenerateKey()
+ otherAddr := crypto.PubkeyToAddress(otherKey.PublicKey)
+ testAddBalance(pool, otherAddr, big.NewInt(1_000_000_000_000_000_000))
+
+ normalTx, err := types.SignNewTx(otherKey, types.LatestSigner(pool.chainconfig), &types.DynamicFeeTx{
+ ChainID: pool.chainconfig.ChainID,
+ Nonce: 0,
+ GasTipCap: big.NewInt(30_000_000_000),
+ GasFeeCap: big.NewInt(30_000_000_000),
+ Gas: 21_000,
+ To: &common.Address{0x99},
+ Value: big.NewInt(0),
+ })
+ require.NoError(t, err)
+ require.NoError(t, pool.Add([]*types.Transaction{normalTx}, true)[0])
+ require.NotNil(t, pool.Get(normalTx.Hash()), "normal sender's transaction must be admitted while reserved occupancy is saturated")
+}
+
+// TestReservedOccupancyLayerAgreement is the direct counterpart of
+// TestListAggregatesConsistency for the pool-wide reserved-occupancy
+// counter: after a mixed sequence of admissions, a same-nonce replacement,
+// a gap-filling promotion, a demotion, and an unrelated normal-sender
+// admission, the incrementally-tracked counter (Layer 1) must agree with a
+// from-scratch recompute (Layer 2) at every step.
+func TestReservedOccupancyLayerAgreement(t *testing.T) {
+ t.Parallel()
+
+ r1Key, _ := crypto.GenerateKey()
+ r1 := crypto.PubkeyToAddress(r1Key.PublicKey)
+ r2Key, _ := crypto.GenerateKey()
+ r2 := crypto.PubkeyToAddress(r2Key.PublicKey)
+
+ pool := setupReservedPoolWithConfig(testTxPoolConfig, big.NewInt(0), r1, r2)
+ defer pool.Close()
+
+ n1Key, _ := crypto.GenerateKey()
+ n1Addr := crypto.PubkeyToAddress(n1Key.PublicKey)
+ testAddBalance(pool, n1Addr, big.NewInt(1_000_000_000_000_000_000))
+ // A materialized (non-zero-balance) account is required for both reserved
+ // senders below: an account the state has never touched at all reports
+ // GetCodeHash as the zero hash rather than types.EmptyCodeHash, which
+ // otherwise misclassifies it as delegated and caps it at one in-flight
+ // transaction — unrelated to reserved-occupancy tracking, but this test
+ // needs several in-flight transactions per sender to exercise it.
+ testAddBalance(pool, r1, big.NewInt(1))
+ testAddBalance(pool, r2, big.NewInt(1))
+
+ cfg := pool.chainconfig
+
+ assertAgreement := func(step string) {
+ pool.mu.Lock()
+ incremental := pool.reservedOccupancy
+ recomputed := pool.recomputeReservedOccupancy()
+ pool.mu.Unlock()
+ require.Equal(t, recomputed, incremental, "reservedOccupancy drifted from ground truth after %s", step)
+ }
+
+ // r1: pending nonce0, then a gapped nonce2 (queued), then nonce1 fills
+ // the gap and promotes both nonce1 and nonce2 to pending.
+ tx0 := zeroFeeTx(t, cfg, r1Key, 0, common.Address{0x1})
+ require.NoError(t, pool.Add([]*types.Transaction{tx0}, true)[0])
+ assertAgreement("r1 nonce0 admission")
+
+ tx2 := zeroFeeTx(t, cfg, r1Key, 2, common.Address{0x1})
+ require.NoError(t, pool.Add([]*types.Transaction{tx2}, true)[0])
+ assertAgreement("r1 nonce2 admission (gapped, queued)")
+
+ tx1 := zeroFeeTx(t, cfg, r1Key, 1, common.Address{0x1})
+ require.NoError(t, pool.Add([]*types.Transaction{tx1}, true)[0])
+ assertAgreement("r1 nonce1 admission (fills the gap, promotes nonce1+nonce2)")
+
+ // r1: same-nonce replacement of nonce0 with a positive fallback fee.
+ replacement, err := types.SignNewTx(r1Key, types.LatestSigner(cfg), &types.DynamicFeeTx{
+ ChainID: cfg.ChainID, Nonce: 0, GasTipCap: big.NewInt(1), GasFeeCap: big.NewInt(1),
+ Gas: 100_000, To: &common.Address{0x2}, Value: big.NewInt(0),
+ })
+ require.NoError(t, err)
+ require.NoError(t, pool.Add([]*types.Transaction{replacement}, true)[0])
+ assertAgreement("r1 nonce0 replacement")
+
+ // r2: two pending transactions, then simulate nonce0 having been mined
+ // (advance the on-chain nonce) and run demoteUnexecutables directly —
+ // nonce0 is forwarded away outright while nonce1 stays pending.
+ txR2a := zeroFeeTx(t, cfg, r2Key, 0, common.Address{0x3})
+ txR2b := zeroFeeTx(t, cfg, r2Key, 1, common.Address{0x3})
+ errs := pool.Add([]*types.Transaction{txR2a, txR2b}, true)
+ require.NoError(t, errs[0])
+ require.NoError(t, errs[1])
+ assertAgreement("r2 pending admissions")
+
+ testSetNonce(pool, r2, 1)
+ pool.mu.Lock()
+ pool.demoteUnexecutables()
+ pool.mu.Unlock()
+ assertAgreement("r2 demoteUnexecutables (forwards nonce0 away)")
+
+ // An unrelated normal sender's admission must not perturb reserved
+ // bookkeeping either way.
+ normalTx, err := types.SignNewTx(n1Key, types.LatestSigner(cfg), &types.DynamicFeeTx{
+ ChainID: cfg.ChainID, Nonce: 0, GasTipCap: big.NewInt(30_000_000_000), GasFeeCap: big.NewInt(30_000_000_000),
+ Gas: 21_000, To: &common.Address{0x4}, Value: big.NewInt(0),
+ })
+ require.NoError(t, err)
+ require.NoError(t, pool.Add([]*types.Transaction{normalTx}, true)[0])
+ assertAgreement("unrelated normal sender admission")
+
+ // Deregistering r1 and forcing the Layer-2 recompute (mirroring what the
+ // next real head event does) must also leave the two layers agreeing.
+ deregisterReserved(t, pool, r1)
+ pool.mu.Lock()
+ pool.reconcileReservedOccupancy()
+ pool.mu.Unlock()
+ assertAgreement("r1 deregistration + Layer-2 recompute")
+}
+
+// TestReservedOccupancyPromotionRejectionDecrementsOnce pins §3.3's trickiest
+// row: a queued transaction handed to promoteTx (as queue.promoteExecutables
+// hands it every one of its readies) but rejected because a better
+// transaction already occupies that pending nonce is a genuine loss for
+// combined occupancy — it left the queue bucket but never entered the
+// pending one — so the counter must decrement by exactly one, not zero.
+func TestReservedOccupancyPromotionRejectionDecrementsOnce(t *testing.T) {
+ t.Parallel()
+
+ key, _ := crypto.GenerateKey()
+ addr := crypto.PubkeyToAddress(key.PublicKey)
+ pool := setupReservedPool(addr)
+ defer pool.Close()
+
+ cfg := pool.chainconfig
+
+ better, err := types.SignNewTx(key, types.LatestSigner(cfg), &types.DynamicFeeTx{
+ ChainID: cfg.ChainID, Nonce: 0, GasTipCap: big.NewInt(10), GasFeeCap: big.NewInt(10),
+ Gas: 100_000, To: &common.Address{0x1}, Value: big.NewInt(0),
+ })
+ require.NoError(t, err)
+ require.NoError(t, pool.Add([]*types.Transaction{better}, true)[0])
+
+ worse, err := types.SignNewTx(key, types.LatestSigner(cfg), &types.DynamicFeeTx{
+ ChainID: cfg.ChainID, Nonce: 0, GasTipCap: big.NewInt(1), GasFeeCap: big.NewInt(1),
+ Gas: 100_000, To: &common.Address{0x2}, Value: big.NewInt(0),
+ })
+ require.NoError(t, err)
+
+ pool.mu.Lock()
+ defer pool.mu.Unlock()
+
+ before := pool.reservedOccupancy
+ require.Equal(t, 1, before)
+
+ // promoteTx assumes its tx is already tracked in `all` — exactly the
+ // invariant queue.promoteExecutables' readies satisfy in the real path.
+ pool.all.Add(worse)
+ inserted := pool.promoteTx(addr, worse.Hash(), worse)
+ require.False(t, inserted, "the worse transaction must be rejected in favor of the pending incumbent")
+ require.Equal(t, before-1, pool.reservedOccupancy, "a promotion rejection must decrement the counter by exactly one")
+}
+
+// TestReservedOccupancyPendingQueueRoundTripIsNetZero pins that a pending->
+// queue demotion and its queue->pending re-promotion never move
+// reservedOccupancy, at any point in the round trip: the transaction is
+// always in exactly one of the two buckets the combined counter tracks
+// together.
+func TestReservedOccupancyPendingQueueRoundTripIsNetZero(t *testing.T) {
+ t.Parallel()
+
+ key, _ := crypto.GenerateKey()
+ addr := crypto.PubkeyToAddress(key.PublicKey)
+ pool := setupReservedPool(addr)
+ defer pool.Close()
+
+ tx := zeroFeeTx(t, pool.chainconfig, key, 0, common.Address{0x1})
+ require.NoError(t, pool.Add([]*types.Transaction{tx}, true)[0])
+
+ pool.mu.Lock()
+ before := pool.reservedOccupancy
+ require.Equal(t, 1, before)
+
+ // Demote: pending -> queue via the same addAll=false path
+ // demoteUnexecutables/removeTx use for invalids/gapped transactions.
+ pending := pool.pending[addr]
+ removed, _ := pending.Remove(tx)
+ require.True(t, removed)
+ if pending.Empty() {
+ delete(pool.pending, addr)
+ }
+ pool.enqueueTx(tx.Hash(), tx, false)
+ require.Equal(t, before, pool.reservedOccupancy, "pending->queue demotion must be net zero")
+ pool.mu.Unlock()
+
+ // Re-promote: queue -> pending via the normal promotion pipeline.
+ pool.pendingNonces.setIfLower(addr, tx.Nonce())
+ pool.mu.Lock()
+ pool.promoteExecutables([]common.Address{addr})
+ require.Equal(t, before, pool.reservedOccupancy, "queue->pending re-promotion must be net zero")
+ pool.mu.Unlock()
+
+ require.NotNil(t, pool.Get(tx.Hash()))
+ require.Equal(t, txpool.TxStatusPending, pool.Status(tx.Hash()))
+}
+
+// TestReservedOccupancyDeregistrationPurgedByRecompute pins the
+// deregistration edge case: a sender's occupied slots stop counting toward
+// the cap only once purged by the next reset()'s Layer-2 recompute (the
+// admission-time counter doesn't retroactively change just because the
+// registry snapshot flipped), and once purged, a still-reserved sender is
+// not double-penalized by the deregistered one's stale contribution.
+func TestReservedOccupancyDeregistrationPurgedByRecompute(t *testing.T) {
+ t.Parallel()
+
+ r1Key, _ := crypto.GenerateKey()
+ r1 := crypto.PubkeyToAddress(r1Key.PublicKey)
+ r2Key, _ := crypto.GenerateKey()
+ r2 := crypto.PubkeyToAddress(r2Key.PublicKey)
+
+ pool := setupReservedPoolWithConfig(testTxPoolConfig, big.NewInt(0), r1, r2)
+ defer pool.Close()
+ // r2 needs a materialized account: it gets a second in-flight
+ // transaction below, and an untouched account misclassifies as
+ // delegated (see the identical note in TestReservedOccupancyLayerAgreement).
+ testAddBalance(pool, r2, big.NewInt(1))
+
+ tx1 := zeroFeeTx(t, pool.chainconfig, r1Key, 0, common.Address{0x1})
+ require.NoError(t, pool.Add([]*types.Transaction{tx1}, true)[0])
+ tx2 := zeroFeeTx(t, pool.chainconfig, r2Key, 0, common.Address{0x2})
+ require.NoError(t, pool.Add([]*types.Transaction{tx2}, true)[0])
+
+ pool.mu.RLock()
+ before := pool.reservedOccupancy
+ pool.mu.RUnlock()
+ require.Equal(t, 2, before)
+
+ // Deregister r1: the registry snapshot flips immediately, but tx1 is
+ // still physically sitting in the pool (eviction hasn't caught up) —
+ // admission-time bookkeeping is unaffected until the next recompute.
+ deregisterReserved(t, pool, r1)
+
+ pool.mu.Lock()
+ require.Equal(t, before, pool.reservedOccupancy, "deregistration alone must not change the counter before a recompute")
+ pool.reconcileReservedOccupancy()
+ after := pool.reservedOccupancy
+ pool.mu.Unlock()
+ require.Equal(t, 1, after, "r1's slot must be purged from the cap once it is no longer reserved")
+
+ // r2 must not be double-penalized by r1's stale contribution: a fresh
+ // reserved transaction for r2 is still admitted against the corrected figure.
+ tx2b := zeroFeeTx(t, pool.chainconfig, r2Key, 1, common.Address{0x2})
+ require.NoError(t, pool.Add([]*types.Transaction{tx2b}, true)[0])
+}
+
+// TestSanitizeReservedMaxOccupancyPercent pins sanitize()'s clamp: zero
+// (whether explicitly set or left at its unset zero value), and any value
+// over 100, both fall back to the default; any value in (0,100] is honored
+// unchanged.
+func TestSanitizeReservedMaxOccupancyPercent(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ in uint64
+ want uint64
+ }{
+ {"unset (zero value) falls back to default", 0, DefaultConfig.ReservedMaxOccupancyPercent},
+ {"explicit zero falls back to default", 0, DefaultConfig.ReservedMaxOccupancyPercent},
+ {"over 100 falls back to default", 150, DefaultConfig.ReservedMaxOccupancyPercent},
+ {"boundary value 1 is honored", 1, 1},
+ {"boundary value 100 is honored", 100, 100},
+ {"typical valid value is honored", 75, 75},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ cfg := Config{ReservedMaxOccupancyPercent: tt.in}
+ got := cfg.sanitize()
+ require.Equal(t, tt.want, got.ReservedMaxOccupancyPercent)
+ })
+ }
+}
+
+// TestReservedOccupancyConcurrentAdmissionRace extends the reserved -race
+// suite to the new counter: many reserved senders admit transactions
+// concurrently while a separate goroutine repeatedly rebuilds the reserved
+// snapshot (mirroring a real head event racing concurrent pool.Add calls,
+// as TestReservedSnapshotSwapRace already does for the snapshot pointer
+// alone), exercising reservedOccupancy's read/write sites under -race.
+func TestReservedOccupancyConcurrentAdmissionRace(t *testing.T) {
+ const numSenders = 8
+
+ keys := make([]*ecdsa.PrivateKey, numSenders)
+ addrs := make([]common.Address, numSenders)
+ for i := range keys {
+ keys[i], _ = crypto.GenerateKey()
+ addrs[i] = crypto.PubkeyToAddress(keys[i].PublicKey)
+ }
+
+ cfg := smallOccupancyConfig(50)
+ pool := setupReservedPoolWithConfig(cfg, big.NewInt(0), addrs...)
+ defer pool.Close()
+ for _, addr := range addrs {
+ // Materialize each account: each sender submits many sequential
+ // in-flight nonces below, and an untouched account misclassifies as
+ // delegated (see the identical note in TestReservedOccupancyLayerAgreement).
+ testAddBalance(pool, addr, big.NewInt(1))
+ }
+
+ var refresherWG sync.WaitGroup
+ stop := make(chan struct{})
+ refresherWG.Add(1)
+ go func() {
+ defer refresherWG.Done()
+ for {
+ select {
+ case <-stop:
+ return
+ default:
+ refreshReservedSnapshot(pool)
+ }
+ }
+ }()
+
+ var sendersWG sync.WaitGroup
+ for i, key := range keys {
+ sendersWG.Add(1)
+ go func(i int, key *ecdsa.PrivateKey) {
+ defer sendersWG.Done()
+ for n := 0; n < 50; n++ {
+ tx := zeroFeeTx(t, pool.chainconfig, key, uint64(n), common.Address{byte(i)})
+ pool.Add([]*types.Transaction{tx}, false)
+ }
+ }(i, key)
+ }
+ sendersWG.Wait()
+ close(stop)
+ refresherWG.Wait()
+}
diff --git a/core/txpool/legacypool/reserved_test.go b/core/txpool/legacypool/reserved_test.go
index 049bf59f6f..e82391edf7 100644
--- a/core/txpool/legacypool/reserved_test.go
+++ b/core/txpool/legacypool/reserved_test.go
@@ -3,6 +3,7 @@ package legacypool
import (
"crypto/ecdsa"
"math/big"
+ "sync"
"testing"
"github.com/holiman/uint256"
@@ -41,12 +42,15 @@ func (f *fakeRegistry) IsReservedAddress(_ *state.StateDB, _ uint64, _ common.Ha
c, ok := f.reserved[a]
return ok && c.Active, nil
}
+
func (f *fakeRegistry) ReservedClientForAddress(_ *state.StateDB, _ uint64, _ common.Hash, a common.Address) (registryreader.ClientLookup, error) {
return f.reserved[a], nil
}
+
func (f *fakeRegistry) Root(_ *state.StateDB, _ uint64, _ common.Hash) (common.Hash, error) {
return common.HexToHash("0x1"), nil
}
+
func (f *fakeRegistry) WhitelistedAddresses(_ *state.StateDB, _ uint64, _ common.Hash) ([]common.Address, error) {
addrs := make([]common.Address, 0, len(f.reserved))
for a := range f.reserved {
@@ -54,24 +58,91 @@ func (f *fakeRegistry) WhitelistedAddresses(_ *state.StateDB, _ uint64, _ common
}
return addrs, nil
}
+
func (f *fakeRegistry) TotalReservedGas(_ *state.StateDB, _ uint64, _ common.Hash) (uint64, error) {
return f.capacity, nil
}
-func reservedChainConfig() *params.ChainConfig {
+// register and deregister simulate registry governance (a client being added
+// to or removed from the registry) between pool admissions or promotion
+// passes. Real deregistration takes effect for the pool only once the next
+// head event rebuilds the snapshot (see refreshReservedSnapshot).
+func (f *fakeRegistry) register(addr common.Address) {
+ if _, ok := f.reserved[addr]; ok {
+ return
+ }
+ f.reserved[addr] = registryreader.ClientLookup{
+ ClientID: big.NewInt(int64(len(f.reserved) + 1)), GasQuota: 30_000_000, Active: true,
+ }
+ f.capacity += 30_000_000
+}
+
+func (f *fakeRegistry) deregister(addr common.Address) {
+ if _, ok := f.reserved[addr]; !ok {
+ return
+ }
+ delete(f.reserved, addr)
+ f.capacity -= 30_000_000
+}
+
+// refreshReservedSnapshot forces the pool to rebuild its reserved-set
+// snapshot from the current registry state, mirroring what every real head
+// event does (see LegacyPool.reset). Tests use it to observe the effect of
+// mutating the fake registry without driving a full chain reorg.
+func refreshReservedSnapshot(pool *LegacyPool) {
+ pool.mu.Lock()
+ statedb, head := pool.currentState, pool.currentHead.Load()
+ pool.mu.Unlock()
+ pool.rebuildReservedSnapshot(statedb, head)
+}
+
+// deregisterReserved deregisters addr from the pool's fake registry and
+// forces the snapshot rebuild that a real head event would perform,
+// simulating registry governance followed by the next block. Returns the
+// fake registry so a caller that needs to mutate it again (e.g. to
+// re-register) doesn't have to repeat the type assertion.
+func deregisterReserved(t *testing.T, pool *LegacyPool, addr common.Address) *fakeRegistry {
+ t.Helper()
+ fr, ok := pool.reservedRegistry.(*fakeRegistry)
+ if !ok {
+ t.Fatalf("unexpected registry type %T", pool.reservedRegistry)
+ }
+ fr.deregister(addr)
+ refreshReservedSnapshot(pool)
+ return fr
+}
+
+// reservedChainConfigAt builds a chain config with the reserved-blockspace
+// fork at an explicit block, so tests can exercise the pre-fork/post-fork
+// boundary.
+func reservedChainConfigAt(forkBlock *big.Int) *params.ChainConfig {
cfg := *params.BorUnittestChainConfig // London active at 0
bor := *cfg.Bor
- bor.ReservedBlockspaceBlock = big.NewInt(0) // fork gate; the reserved set comes from the registry
+ bor.ReservedBlockspaceBlock = forkBlock // fork gate; the reserved set comes from the registry
cfg.Bor = &bor
return &cfg
}
func setupReservedPool(reserved common.Address) *LegacyPool {
+ return setupReservedPoolAt(reserved, big.NewInt(0))
+}
+
+// setupReservedPoolAt is setupReservedPool with an explicit fork block.
+func setupReservedPoolAt(reserved common.Address, forkBlock *big.Int) *LegacyPool {
+ return setupReservedPoolWithConfig(testTxPoolConfig, forkBlock, reserved)
+}
+
+// setupReservedPoolWithConfig is setupReservedPoolAt generalized to a
+// caller-supplied pool config and an arbitrary number of reserved addresses,
+// so occupancy-cap tests can shrink GlobalSlots/GlobalQueue/
+// ReservedMaxOccupancyPercent to make the cap cheap to reach without waiting
+// on the package-level testTxPoolConfig defaults.
+func setupReservedPoolWithConfig(cfg Config, forkBlock *big.Int, reserved ...common.Address) *LegacyPool {
statedb, _ := state.New(types.EmptyRootHash, state.NewDatabaseForTesting())
- cfg := reservedChainConfig()
- bc := newTestBlockChain(cfg, 10_000_000, statedb, new(event.Feed))
- pool := New(testTxPoolConfig, bc)
- if err := pool.Init(testTxPoolConfig.PriceLimit, bc.CurrentBlock(), newReserver()); err != nil {
+ chainCfg := reservedChainConfigAt(forkBlock)
+ bc := newTestBlockChain(chainCfg, 10_000_000, statedb, new(event.Feed))
+ pool := New(cfg, bc)
+ if err := pool.Init(cfg.PriceLimit, bc.CurrentBlock(), newReserver()); err != nil {
panic(err)
}
<-pool.initDoneCh
@@ -79,7 +150,7 @@ func setupReservedPool(reserved common.Address) *LegacyPool {
// bypass it; everyone else is held to it.
pool.SetGasTip(big.NewInt(30_000_000_000))
// Wire the registry source (post-Init, as the backend does) and build the snapshot.
- pool.SetReservedRegistry(newFakeRegistry(reserved))
+ pool.SetReservedRegistry(newFakeRegistry(reserved...))
return pool
}
@@ -100,6 +171,29 @@ func zeroFeeTx(t *testing.T, cfg *params.ChainConfig, key *ecdsa.PrivateKey, non
return tx
}
+// fallbackFeeTx builds a signed dynamic-fee tx carrying a positive fallback
+// fee (Primer §6.3): a reserved sender uses this fee only if its tx overflows
+// quota, but it must clear the pool's tip floor regardless of classification.
+// gas is fixed at 100_000 so gas*feeCap dwarfs any small test balance,
+// modelling a sender that holds value but not gas headroom.
+func fallbackFeeTx(t *testing.T, cfg *params.ChainConfig, key *ecdsa.PrivateKey, nonce uint64, value, feeCap *big.Int) *types.Transaction {
+ t.Helper()
+ to := common.Address{0x42}
+ tx, err := types.SignNewTx(key, types.LatestSigner(cfg), &types.DynamicFeeTx{
+ ChainID: cfg.ChainID,
+ Nonce: nonce,
+ GasTipCap: feeCap,
+ GasFeeCap: feeCap,
+ Gas: 100_000,
+ To: &to,
+ Value: value,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ return tx
+}
+
// TestReservedZeroFeeTxAdmittedAndPending verifies the core behaviour:
// a zero-fee tx from a reserved sender is admitted past the tip floor, kept in
// the pool, and surfaced by Pending even under a high miner MinTip — whereas the
@@ -208,3 +302,297 @@ func TestReservedZeroFeeReplacement(t *testing.T) {
t.Fatal("zero-fee tx must not replace a fallback-fee incumbent")
}
}
+
+// reservedFallbackFeeBalance is a balance that covers a fallbackFeeTx's value
+// (1000 wei) many times over but is dwarfed by its full cost (value +
+// 100_000 gas * a >=30 gwei feeCap, i.e. >= 3e15). It models a reserved
+// sender that holds value but not gas headroom (Primer §6.3, §8.1).
+var reservedFallbackFeeBalance = big.NewInt(5_000)
+
+// TestReservedFallbackFeeAdmissionForkGate pins POS-3671's admission gate: a
+// fallback-fee tx from a sender whose balance covers only the tx's value is
+// rejected pre-fork (priced at full cost, same as any other sender) and
+// admitted post-fork (priced at value alone via EffectiveCost).
+func TestReservedFallbackFeeAdmissionForkGate(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ forkBlock *big.Int
+ wantErr bool
+ }{
+ {name: "pre-fork rejected", forkBlock: big.NewInt(100), wantErr: true}, // fork in the future
+ {name: "post-fork admitted", forkBlock: big.NewInt(0), wantErr: false}, // fork already active
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ key, _ := crypto.GenerateKey()
+ addr := crypto.PubkeyToAddress(key.PublicKey)
+ pool := setupReservedPoolAt(addr, tt.forkBlock)
+ defer pool.Close()
+
+ testAddBalance(pool, addr, reservedFallbackFeeBalance)
+ tx := fallbackFeeTx(t, pool.chainconfig, key, 0, big.NewInt(1000), big.NewInt(30_000_000_000))
+ err := pool.Add([]*types.Transaction{tx}, true)[0]
+ if tt.wantErr {
+ if err == nil {
+ t.Fatal("fallback-fee tx from a value-only-balance sender should be rejected")
+ }
+ return
+ }
+ if err != nil {
+ t.Fatalf("fallback-fee tx from a value-only-balance reserved sender rejected: %v", err)
+ }
+ if pool.Get(tx.Hash()) == nil {
+ t.Fatal("admitted fallback-fee tx not kept in pool")
+ }
+ })
+ }
+}
+
+// TestReservedFallbackFeePendingFilterKeepsValueOnlyBalance pins the pending-
+// side half of §4.3: demoteUnexecutables' balance-driven list.Filter call
+// prices a reserved sender's pending fallback-fee tx on the value basis, so
+// it survives repeated per-head revalidation despite never covering its full
+// cost.
+func TestReservedFallbackFeePendingFilterKeepsValueOnlyBalance(t *testing.T) {
+ t.Parallel()
+
+ key, _ := crypto.GenerateKey()
+ addr := crypto.PubkeyToAddress(key.PublicKey)
+ pool := setupReservedPool(addr)
+ defer pool.Close()
+
+ testAddBalance(pool, addr, reservedFallbackFeeBalance)
+ tx := fallbackFeeTx(t, pool.chainconfig, key, 0, big.NewInt(1000), big.NewInt(30_000_000_000))
+ if err := pool.Add([]*types.Transaction{tx}, true)[0]; err != nil {
+ t.Fatalf("admission rejected: %v", err)
+ }
+ if pool.Status(tx.Hash()) != txpool.TxStatusPending {
+ t.Fatalf("tx should be pending, got status %v", pool.Status(tx.Hash()))
+ }
+
+ // Repeated pending-side revalidation must not drop it: its full cost never
+ // changes and always exceeds the balance, so only the value basis keeps it.
+ for i := 0; i < 3; i++ {
+ pool.mu.Lock()
+ pool.demoteUnexecutables()
+ pool.mu.Unlock()
+ if pool.Get(tx.Hash()) == nil {
+ t.Fatalf("fallback-fee tx dropped by pending Filter on pass %d", i)
+ }
+ if pool.Status(tx.Hash()) != txpool.TxStatusPending {
+ t.Fatalf("tx should remain pending on pass %d, got status %v", i, pool.Status(tx.Hash()))
+ }
+ }
+}
+
+// TestReservedFallbackFeeDeregistrationDropsPending pins the self-healing
+// half of §4.2: once a sender is deregistered, the next head's pending-side
+// Filter prices it at full cost again and drops what the balance can no
+// longer cover, with one block of lag (the snapshot rebuild).
+func TestReservedFallbackFeeDeregistrationDropsPending(t *testing.T) {
+ t.Parallel()
+
+ key, _ := crypto.GenerateKey()
+ addr := crypto.PubkeyToAddress(key.PublicKey)
+ pool := setupReservedPool(addr)
+ defer pool.Close()
+
+ testAddBalance(pool, addr, reservedFallbackFeeBalance)
+ tx := fallbackFeeTx(t, pool.chainconfig, key, 0, big.NewInt(1000), big.NewInt(30_000_000_000))
+ if err := pool.Add([]*types.Transaction{tx}, true)[0]; err != nil {
+ t.Fatalf("admission rejected: %v", err)
+ }
+
+ // One Filter pass while still reserved: survives (value basis).
+ pool.mu.Lock()
+ pool.demoteUnexecutables()
+ pool.mu.Unlock()
+ if pool.Get(tx.Hash()) == nil {
+ t.Fatal("fallback-fee tx dropped while sender still reserved")
+ }
+
+ deregisterReserved(t, pool, addr) // simulates the next head's snapshot rebuild
+
+ // Next Filter pass: sender is now priced at full cost, which the balance
+ // never covered, so it must be dropped.
+ pool.mu.Lock()
+ pool.demoteUnexecutables()
+ pool.mu.Unlock()
+ if pool.Get(tx.Hash()) != nil {
+ t.Fatal("fallback-fee tx should have been dropped after deregistration")
+ }
+}
+
+// TestReservedSnapshotSwapRace exercises the atomic snapshot pointer under
+// -race: admissions read it (via isReserved/effectiveCost) concurrently with
+// repeated snapshot rebuilds, mirroring how a real head event's
+// rebuildReservedSnapshot races against concurrent pool.Add calls.
+func TestReservedSnapshotSwapRace(t *testing.T) {
+ key, _ := crypto.GenerateKey()
+ addr := crypto.PubkeyToAddress(key.PublicKey)
+ pool := setupReservedPool(addr)
+ defer pool.Close()
+
+ testAddBalance(pool, addr, big.NewInt(1_000_000))
+ cfg := pool.chainconfig
+
+ var wg sync.WaitGroup
+ stop := make(chan struct{})
+
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ for {
+ select {
+ case <-stop:
+ return
+ default:
+ refreshReservedSnapshot(pool)
+ }
+ }
+ }()
+
+ for i := 0; i < 100; i++ {
+ tx := zeroFeeTx(t, cfg, key, uint64(i), common.Address{0x42})
+ pool.Add([]*types.Transaction{tx}, false)
+ }
+ close(stop)
+ wg.Wait()
+}
+
+// TestExistingExpenditureBasisConsistency pins that the pool's
+// ExistingExpenditure callback picks its basis (value vs full cost) from the
+// sender's *current* classification at each admission, not a basis cached
+// from an earlier one, when classification flips between two admissions of
+// the same sender.
+func TestExistingExpenditureBasisConsistency(t *testing.T) {
+ t.Parallel()
+
+ key, _ := crypto.GenerateKey()
+ addr := crypto.PubkeyToAddress(key.PublicKey)
+ pool := setupReservedPool(addr)
+ defer pool.Close()
+
+ // Affords one tx's full cost (value(1000) + 100_000 gas * 30 gwei) with
+ // room to spare, but not two of them: balance is one wei short of 2x
+ // that cost, so a same-basis overdraft check for a second, identically
+ // priced tx must reject it.
+ const gas = 100_000
+ feeCapInt := big.NewInt(30_000_000_000)
+ cost1 := new(big.Int).Add(big.NewInt(1000), new(big.Int).Mul(big.NewInt(gas), feeCapInt))
+ balance := new(big.Int).Sub(new(big.Int).Mul(big.NewInt(2), cost1), big.NewInt(1))
+ testAddBalance(pool, addr, balance)
+
+ feeCap := feeCapInt // exactly the pool's tip floor
+ tx1 := fallbackFeeTx(t, pool.chainconfig, key, 0, big.NewInt(1000), feeCap)
+ if err := pool.Add([]*types.Transaction{tx1}, true)[0]; err != nil {
+ t.Fatalf("tx1 admission while reserved rejected: %v", err)
+ }
+
+ fr := deregisterReserved(t, pool, addr)
+
+ // tx2's own cost fits the balance alone; only an overdraft check that
+ // correctly switched ExistingExpenditure to the full-cost basis (now
+ // including tx1's *full* cost, not its admitted value) overshoots by the
+ // value/cost gap and rejects it. A basis stuck on tx1's value would wrongly
+ // admit it (1000 + tx2's cost fits comfortably).
+ tx2 := fallbackFeeTx(t, pool.chainconfig, key, 1, big.NewInt(1000), feeCap)
+ if err := pool.Add([]*types.Transaction{tx2}, true)[0]; err == nil {
+ t.Fatal("tx2 should be rejected: overdraft check must price tx1 at full cost once non-reserved")
+ }
+
+ // Flipping back to reserved and retrying the identical tx must now
+ // succeed: ExistingExpenditure is back on the value basis (tx1's value
+ // alone), which comfortably fits alongside tx2's own value.
+ fr.register(addr)
+ refreshReservedSnapshot(pool)
+ if err := pool.Add([]*types.Transaction{tx2}, true)[0]; err != nil {
+ t.Fatalf("tx2 admission after re-registering rejected: %v", err)
+ }
+}
+
+// TestReservedQueueLifecyclePromotesOnGapFill pins §4.3's queue-side half: a
+// nonce-gapped fallback-fee tx from a value-only-balance reserved sender is
+// admitted into the future queue, survives promotion passes while the gap
+// remains (queue.promoteExecutables' Filter call on the value basis), and
+// promotes to pending once the gap fills.
+func TestReservedQueueLifecyclePromotesOnGapFill(t *testing.T) {
+ t.Parallel()
+
+ key, _ := crypto.GenerateKey()
+ addr := crypto.PubkeyToAddress(key.PublicKey)
+ pool := setupReservedPool(addr)
+ defer pool.Close()
+
+ testAddBalance(pool, addr, reservedFallbackFeeBalance)
+ feeCap := big.NewInt(30_000_000_000)
+
+ gapped := fallbackFeeTx(t, pool.chainconfig, key, 1, big.NewInt(1000), feeCap) // nonce 1, nonce 0 missing
+ if err := pool.Add([]*types.Transaction{gapped}, true)[0]; err != nil {
+ t.Fatalf("gapped fallback-fee tx rejected: %v", err)
+ }
+ if pool.Status(gapped.Hash()) != txpool.TxStatusQueued {
+ t.Fatalf("gapped tx should be queued, got status %v", pool.Status(gapped.Hash()))
+ }
+
+ // A promotion pass with the gap still open must not drop it: the queue's
+ // balance-driven Filter prices the reserved sender on the value basis.
+ pool.mu.Lock()
+ pool.promoteExecutables([]common.Address{addr})
+ pool.mu.Unlock()
+ if pool.Get(gapped.Hash()) == nil {
+ t.Fatal("gapped fallback-fee tx dropped by queue Filter while still reserved")
+ }
+ if pool.Status(gapped.Hash()) != txpool.TxStatusQueued {
+ t.Fatalf("tx should remain queued, got status %v", pool.Status(gapped.Hash()))
+ }
+
+ // Fill the gap: nonce 0, small and easily affordable even without the
+ // reserved waiver. Admission (sync) drives the reorg that promotes both.
+ filler := fallbackFeeTx(t, pool.chainconfig, key, 0, big.NewInt(1000), feeCap)
+ if err := pool.Add([]*types.Transaction{filler}, true)[0]; err != nil {
+ t.Fatalf("gap-filling tx rejected: %v", err)
+ }
+ if pool.Status(filler.Hash()) != txpool.TxStatusPending {
+ t.Fatalf("gap-filling tx should be pending, got status %v", pool.Status(filler.Hash()))
+ }
+ if pool.Status(gapped.Hash()) != txpool.TxStatusPending {
+ t.Fatalf("previously gapped tx should have promoted to pending, got status %v", pool.Status(gapped.Hash()))
+ }
+}
+
+// TestReservedQueueLifecycleDropsAfterDeregistration pins §4.2's self-healing
+// claim for the queue side: a queued fallback-fee tx from a sender that gets
+// deregistered while still gapped is dropped at the next promotion pass, once
+// priced at full cost.
+func TestReservedQueueLifecycleDropsAfterDeregistration(t *testing.T) {
+ t.Parallel()
+
+ key, _ := crypto.GenerateKey()
+ addr := crypto.PubkeyToAddress(key.PublicKey)
+ pool := setupReservedPool(addr)
+ defer pool.Close()
+
+ testAddBalance(pool, addr, reservedFallbackFeeBalance)
+ feeCap := big.NewInt(30_000_000_000)
+
+ gapped := fallbackFeeTx(t, pool.chainconfig, key, 1, big.NewInt(1000), feeCap)
+ if err := pool.Add([]*types.Transaction{gapped}, true)[0]; err != nil {
+ t.Fatalf("gapped fallback-fee tx rejected: %v", err)
+ }
+
+ deregisterReserved(t, pool, addr)
+
+ pool.mu.Lock()
+ pool.promoteExecutables([]common.Address{addr})
+ pool.mu.Unlock()
+ if pool.Get(gapped.Hash()) != nil {
+ t.Fatal("gapped fallback-fee tx should have been dropped after deregistration")
+ }
+ if pool.Status(gapped.Hash()) != txpool.TxStatusUnknown {
+ t.Fatalf("dropped tx should be unknown, got status %v", pool.Status(gapped.Hash()))
+ }
+}
diff --git a/core/txpool/validation.go b/core/txpool/validation.go
index 1a21d520e5..ecb86a3a56 100644
--- a/core/txpool/validation.go
+++ b/core/txpool/validation.go
@@ -31,11 +31,9 @@ import (
"github.com/ethereum/go-ethereum/params"
)
-var (
- // blobTxMinBlobGasPrice is the big.Int version of the configured protocol
- // parameter to avoid constructing a new big integer for every transaction.
- blobTxMinBlobGasPrice = big.NewInt(params.BlobTxMinBlobGasprice)
-)
+// blobTxMinBlobGasPrice is the big.Int version of the configured protocol
+// parameter to avoid constructing a new big integer for every transaction.
+var blobTxMinBlobGasPrice = big.NewInt(params.BlobTxMinBlobGasprice)
// ValidationOptions define certain differences between transaction validation
// across the different pools without having to duplicate those checks.
@@ -267,6 +265,14 @@ type ValidationOptionsWithState struct {
// ExistingCost is a mandatory callback to retrieve an already pooled
// transaction's cost with the given nonce to check for overdrafts.
ExistingCost func(addr common.Address, nonce uint64) *big.Int
+
+ // EffectiveCost prices a transaction for balance checks. Nil (either the
+ // field itself, or its return value for a given tx) means tx.Cost().
+ // Pools with a reserved-blockspace registry supply a reserved-aware
+ // implementation; ExistingExpenditure and ExistingCost must be priced on
+ // the same basis. Takes the already-recovered sender, matching the other
+ // callbacks below.
+ EffectiveCost func(addr common.Address, tx *types.Transaction) *big.Int
}
// ValidateTransactionWithState is a helper method to check whether a transaction
@@ -293,10 +299,14 @@ func ValidateTransactionWithState(tx *types.Transaction, signer types.Signer, op
}
}
// Ensure the transactor has enough funds to cover the transaction costs
- var (
- balance = opts.State.GetBalance(from).ToBig()
- cost = tx.Cost()
- )
+ balance := opts.State.GetBalance(from).ToBig()
+ var cost *big.Int
+ if opts.EffectiveCost != nil {
+ cost = opts.EffectiveCost(from, tx)
+ }
+ if cost == nil {
+ cost = tx.Cost()
+ }
if balance.Cmp(cost) < 0 {
return fmt.Errorf("%w: balance %v, tx cost %v, overshot %v", core.ErrInsufficientFunds, balance, cost, new(big.Int).Sub(cost, balance))
}
diff --git a/core/txpool/validation_test.go b/core/txpool/validation_test.go
index 3945b548c1..2f32bc3976 100644
--- a/core/txpool/validation_test.go
+++ b/core/txpool/validation_test.go
@@ -23,8 +23,12 @@ import (
"math/big"
"testing"
+ "github.com/holiman/uint256"
+
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core"
+ "github.com/ethereum/go-ethereum/core/state"
+ "github.com/ethereum/go-ethereum/core/tracing"
"github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/crypto"
"github.com/ethereum/go-ethereum/params"
@@ -113,3 +117,201 @@ func createTestTransaction(key *ecdsa.PrivateKey, nonce uint64) *types.Transacti
signedTx, _ := types.SignTx(tx, types.HomesteadSigner{}, key)
return signedTx
}
+
+// fallbackFeeTestTx builds a dynamic-fee transaction whose full cost (value +
+// gas*feeCap) vastly exceeds any balance used in the tests below, while its
+// value alone is affordable. It models a reserved-blockspace sender that
+// holds value but not gas headroom (Primer §6.3).
+func fallbackFeeTestTx(key *ecdsa.PrivateKey, nonce uint64, value *big.Int) *types.Transaction {
+ to := common.HexToAddress("0x0000000000000000000000000000000000000001")
+ tx, _ := types.SignNewTx(key, types.LatestSigner(params.TestChainConfig), &types.DynamicFeeTx{
+ ChainID: params.TestChainConfig.ChainID,
+ Nonce: nonce,
+ GasTipCap: big.NewInt(1_000_000_000),
+ GasFeeCap: big.NewInt(1_000_000_000),
+ Gas: 100_000, // gas*feeCap = 1e14, far above every balance below
+ To: &to,
+ Value: value,
+ })
+ return tx
+}
+
+// stateWithBalance returns an in-memory StateDB with addr funded at balance.
+func stateWithBalance(t *testing.T, addr common.Address, balance int64) *state.StateDB {
+ t.Helper()
+ sdb, err := state.New(types.EmptyRootHash, state.NewDatabaseForTesting())
+ if err != nil {
+ t.Fatal(err)
+ }
+ sdb.AddBalance(addr, uint256.NewInt(uint64(balance)), tracing.BalanceChangeUnspecified)
+ return sdb
+}
+
+// noPriorTx is a ValidationOptionsWithState.ExistingCost that always reports
+// no pooled transaction at the given nonce, i.e. every admission below is a
+// fresh nonce rather than a replacement.
+func noPriorTx(common.Address, uint64) *big.Int { return nil }
+
+// TestValidateTransactionWithStateEffectiveCost pins EffectiveCost's balance-
+// check override: a fallback-fee transaction whose balance covers only its
+// value (not its full cost) is admitted when EffectiveCost prices it at
+// value, and rejected with the ordinary tx.Cost() pricing when it doesn't.
+func TestValidateTransactionWithStateEffectiveCost(t *testing.T) {
+ t.Parallel()
+
+ key, err := crypto.GenerateKey()
+ if err != nil {
+ t.Fatal(err)
+ }
+ addr := crypto.PubkeyToAddress(key.PublicKey)
+ signer := types.LatestSigner(params.TestChainConfig)
+
+ value := big.NewInt(1_000)
+ tx := fallbackFeeTestTx(key, 0, value)
+ if tx.Cost().Cmp(big.NewInt(2_000)) <= 0 {
+ t.Fatalf("test fixture must have a full cost far above the value: cost=%s", tx.Cost())
+ }
+
+ tests := []struct {
+ name string
+ effectiveCost func(common.Address, *types.Transaction) *big.Int
+ wantErr error
+ }{
+ {
+ name: "reserved-aware EffectiveCost admits a value-only balance",
+ effectiveCost: func(_ common.Address, tx *types.Transaction) *big.Int { return tx.Value() },
+ wantErr: nil,
+ },
+ {
+ name: "nil EffectiveCost falls back to tx.Cost() and rejects",
+ effectiveCost: nil,
+ wantErr: core.ErrInsufficientFunds,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ // Balance covers the value with room to spare, but nowhere near
+ // the full cost (gas*feeCap alone is 1e14).
+ sdb := stateWithBalance(t, addr, 2_000)
+ opts := &ValidationOptionsWithState{
+ State: sdb,
+ EffectiveCost: tt.effectiveCost,
+ ExistingExpenditure: func(common.Address) *big.Int { return new(big.Int) },
+ ExistingCost: noPriorTx,
+ }
+ err := ValidateTransactionWithState(tx, signer, opts)
+ if tt.wantErr == nil {
+ if err != nil {
+ t.Fatalf("ValidateTransactionWithState() error = %v, want nil", err)
+ }
+ } else if !errors.Is(err, tt.wantErr) {
+ t.Fatalf("ValidateTransactionWithState() error = %v, want %v", err, tt.wantErr)
+ }
+ })
+ }
+}
+
+// TestValidateTransactionWithStateEffectiveCostOverdraft pins overdraft
+// accounting across multiple pooled fallback-fee transactions: each one's own
+// value fits the balance, and the pool-side ExistingExpenditure implementation
+// sums prior *effective* costs (as legacypool's does for a reserved sender),
+// so admission must fail once the sum of values, not the sum of full costs,
+// first exceeds the balance.
+func TestValidateTransactionWithStateEffectiveCostOverdraft(t *testing.T) {
+ t.Parallel()
+
+ key, err := crypto.GenerateKey()
+ if err != nil {
+ t.Fatal(err)
+ }
+ addr := crypto.PubkeyToAddress(key.PublicKey)
+ signer := types.LatestSigner(params.TestChainConfig)
+
+ const balance = 2_500
+ sdb := stateWithBalance(t, addr, balance)
+
+ var pooledValue big.Int // running sum of admitted txs' effective (value) cost
+ opts := &ValidationOptionsWithState{
+ State: sdb,
+ EffectiveCost: func(_ common.Address, tx *types.Transaction) *big.Int { return tx.Value() },
+ ExistingExpenditure: func(common.Address) *big.Int { return new(big.Int).Set(&pooledValue) },
+ ExistingCost: noPriorTx,
+ }
+
+ // Two 1000-value txs fit (1000, then 2000 <= 2500); the third pushes the
+ // running sum to 3000, over the 2500 balance, even though its own value
+ // (1000) is individually affordable and its full cost is never reached.
+ wantErrs := []error{nil, nil, core.ErrInsufficientFunds}
+ for i, wantErr := range wantErrs {
+ tx := fallbackFeeTestTx(key, uint64(i), big.NewInt(1_000))
+ err := ValidateTransactionWithState(tx, signer, opts)
+ if wantErr == nil {
+ if err != nil {
+ t.Fatalf("tx %d: error = %v, want nil", i, err)
+ }
+ pooledValue.Add(&pooledValue, tx.Value())
+ } else if !errors.Is(err, wantErr) {
+ t.Fatalf("tx %d: error = %v, want %v", i, err, wantErr)
+ }
+ }
+}
+
+// TestValidateTransactionWithStateEffectiveCostReplacement pins that the
+// replacement overdraft math (the cost-delta "bump" between a new tx and the
+// pooled one it targets) is computed on the effective-cost basis end to end:
+// both ExistingCost's prior value and EffectiveCost's new value, not
+// tx.Cost(). This is distinct from list.Add's fee-field replacement threshold
+// (which stays fee-priced and is covered in legacypool's list tests) — this
+// bump is the pool's balance-overdraft accounting for the nonce being
+// replaced.
+func TestValidateTransactionWithStateEffectiveCostReplacement(t *testing.T) {
+ t.Parallel()
+
+ key, err := crypto.GenerateKey()
+ if err != nil {
+ t.Fatal(err)
+ }
+ addr := crypto.PubkeyToAddress(key.PublicKey)
+ signer := types.LatestSigner(params.TestChainConfig)
+
+ // A single pooled tx at nonce 0 whose effective (value) cost was 1000.
+ prevCost := big.NewInt(1_000)
+ baseOpts := func(balance int64) *ValidationOptionsWithState {
+ return &ValidationOptionsWithState{
+ State: stateWithBalance(t, addr, balance),
+ EffectiveCost: func(_ common.Address, tx *types.Transaction) *big.Int { return tx.Value() },
+ ExistingExpenditure: func(common.Address) *big.Int { return new(big.Int).Set(prevCost) },
+ ExistingCost: func(common.Address, uint64) *big.Int {
+ return new(big.Int).Set(prevCost)
+ },
+ }
+ }
+
+ tests := []struct {
+ name string
+ balance int64
+ wantErr error
+ }{
+ // need = spent(1000) + bump(1500-1000=500) = 1500 <= 2000.
+ {name: "bump fits the balance", balance: 2_000, wantErr: nil},
+ // need = 1500 > 1400.
+ {name: "bump overdraws the balance", balance: 1_400, wantErr: core.ErrInsufficientFunds},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ replacement := fallbackFeeTestTx(key, 0, big.NewInt(1_500))
+ err := ValidateTransactionWithState(replacement, signer, baseOpts(tt.balance))
+ if tt.wantErr == nil {
+ if err != nil {
+ t.Fatalf("error = %v, want nil", err)
+ }
+ } else if !errors.Is(err, tt.wantErr) {
+ t.Fatalf("error = %v, want %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/core/types.go b/core/types.go
index 72de5ed0fb..7ccc0351fe 100644
--- a/core/types.go
+++ b/core/types.go
@@ -21,6 +21,7 @@ import (
"sync/atomic"
"github.com/ethereum/go-ethereum/common"
+ "github.com/ethereum/go-ethereum/consensus/bor/registryreader"
"github.com/ethereum/go-ethereum/core/state"
"github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/core/vm"
@@ -69,4 +70,23 @@ type ProcessResult struct {
// the header's ReservedGasUsed post-fork, so a producer cannot stamp a value
// that disagrees with execution (which would skew the next block's base fee).
ReservedGasUsed uint64
+ // ReservedCapacity is the reserved-blockspace registry snapshot's effective
+ // capacity (Σ quotas of the client set effective for this block) used to
+ // classify this block's reserved region. ValidateState checks it against
+ // the header's ReservedCapacity post-fork, mirroring ReservedGasUsed.
+ ReservedCapacity uint64
+ // ReservedTxIndexes lists the positions within the block's transactions
+ // classified reserved (fee-free), strictly ascending. Persisted alongside
+ // receipts so reads can report the correct effective gas price for
+ // reserved transactions without re-deriving the classification.
+ ReservedTxIndexes []uint64
+ // ReservedClientUsage reports, per registry client id, the declared gas
+ // consumed by this block's reserved transactions against that client's
+ // quota. It is derived observability data assembled from the same
+ // classification walk as ReservedTxIndexes, not a consensus-checked
+ // value - ValidateState never compares it against the header, which
+ // carries no per-client breakdown. The Used basis is declared gas
+ // (tx.Gas()) - the same basis quota admission itself is charged against -
+ // not the executed gas ReservedGasUsed reports. Nil pre-fork.
+ ReservedClientUsage map[uint64]registryreader.ClientUsage
}
diff --git a/core/types/block.go b/core/types/block.go
index 0112d0d384..dadd130621 100644
--- a/core/types/block.go
+++ b/core/types/block.go
@@ -147,6 +147,14 @@ type BlockExtraData struct {
// (reserved blockspace active, no reserved gas used) stays distinct from
// the field being absent on the wire (pre-activation blocks).
ReservedGasUsed *uint64 `rlp:"optional"`
+
+ // ReservedCapacity is the sum of the per-block gas quotas of the reserved
+ // clients effective for this block (the registry snapshot's effective
+ // set, read at the parent state). Consensus input to the next block's
+ // base fee (see consensus/misc/eip1559.reservedAwareGasTarget). Must stay
+ // the last field: rlp:"optional" fields decode in order, and every
+ // earlier optional here is always written once this one is.
+ ReservedCapacity *uint64 `rlp:"optional"`
}
// BlockExtraDataPostAustin drops TxDependency, which sat before the optional
@@ -162,6 +170,13 @@ type BlockExtraDataPostAustin struct {
// (reserved blockspace active, no reserved gas used) stays distinct from
// the field being absent on the wire (pre-activation blocks).
ReservedGasUsed *uint64 `rlp:"optional"`
+
+ // ReservedCapacity is the reserved-blockspace registry snapshot's
+ // effective capacity used to classify this block's reserved region, and
+ // the next block's base-fee carve-out input. Same pointer semantics as
+ // ReservedGasUsed. Must stay the last field: rlp:"optional" only trims a
+ // trailing run.
+ ReservedCapacity *uint64 `rlp:"optional"`
}
// blockExtraDataRawTxDeps mirrors BlockExtraData but keeps TxDependency as an
@@ -176,19 +191,21 @@ type blockExtraDataRawTxDeps struct {
GasTarget *uint64 `rlp:"optional"`
BaseFeeChangeDenominator *uint64 `rlp:"optional"`
ReservedGasUsed *uint64 `rlp:"optional"`
+ ReservedCapacity *uint64 `rlp:"optional"`
}
// EncodeBlockExtraData picks the fork-appropriate wire shape for number and
// RLP-encodes validatorBytes, the post-Giugliano base-fee params, and the
// post-ReservedBlockspace reserved-gas field into it. Callers elsewhere
// should use this rather than reimplementing the fork check.
-func EncodeBlockExtraData(chainConfig *params.ChainConfig, number *big.Int, validatorBytes []byte, gasTarget, baseFeeChangeDenom, reservedGasUsed *uint64) ([]byte, error) {
+func EncodeBlockExtraData(chainConfig *params.ChainConfig, number *big.Int, validatorBytes []byte, gasTarget, baseFeeChangeDenom, reservedGasUsed, reservedCapacity *uint64) ([]byte, error) {
if chainConfig.Bor != nil && chainConfig.Bor.IsAustin(number) {
return rlp.EncodeToBytes(&BlockExtraDataPostAustin{
ValidatorBytes: validatorBytes,
GasTarget: gasTarget,
BaseFeeChangeDenominator: baseFeeChangeDenom,
ReservedGasUsed: reservedGasUsed,
+ ReservedCapacity: reservedCapacity,
})
}
@@ -197,6 +214,7 @@ func EncodeBlockExtraData(chainConfig *params.ChainConfig, number *big.Int, vali
GasTarget: gasTarget,
BaseFeeChangeDenominator: baseFeeChangeDenom,
ReservedGasUsed: reservedGasUsed,
+ ReservedCapacity: reservedCapacity,
})
}
@@ -599,6 +617,45 @@ func (h *Header) GetValidatorBytes(chainConfig *params.ChainConfig) []byte {
return validatorBytes
}
+// decodeReservedAwareExtra decodes the reserved-blockspace fields from the
+// header's Extra, applying the gate every reserved accessor shares:
+// post-Cancun only (BlockExtraData is only RLP-encoded from Cancun on),
+// vanity/seal length, a fork-appropriate decode (post-Austin headers carry no
+// TxDependency), and pre-Valencia TxDependency validity on the legacy shape.
+// ok is false when any gate fails or the decode errors; callers then return
+// their own nil/zero result rather than a partially decoded one.
+func (h *Header) decodeReservedAwareExtra(chainConfig *params.ChainConfig) (reservedGasUsed, reservedCapacity *uint64, ok bool) {
+ if !chainConfig.IsCancun(h.Number) {
+ return nil, nil, false
+ }
+
+ if len(h.Extra) < ExtraVanityLength+ExtraSealLength {
+ return nil, nil, false
+ }
+
+ payload := h.Extra[ExtraVanityLength : len(h.Extra)-ExtraSealLength]
+
+ if chainConfig.Bor != nil && chainConfig.Bor.IsAustin(h.Number) {
+ var blockExtraData BlockExtraDataPostAustin
+ if err := rlp.DecodeBytes(payload, &blockExtraData); err != nil {
+ return nil, nil, false
+ }
+
+ return blockExtraData.ReservedGasUsed, blockExtraData.ReservedCapacity, true
+ }
+
+ var blockExtraData blockExtraDataRawTxDeps
+ if err := rlp.DecodeBytes(payload, &blockExtraData); err != nil {
+ return nil, nil, false
+ }
+
+ if !txDependencyValidPreValencia(chainConfig, h.Number, blockExtraData.TxDependency) {
+ return nil, nil, false
+ }
+
+ return blockExtraData.ReservedGasUsed, blockExtraData.ReservedCapacity, true
+}
+
// GetBaseFeeParams extracts the EIP-1559 gas target and base fee change denominator
// from the block header's extra field. If you need multiple fields from BlockExtraData,
// prefer DecodeBlockExtraData to avoid redundant RLP decodes.
@@ -643,47 +700,53 @@ func (h *Header) GetValidatorBytesAndBaseFeeParams(chainConfig *params.ChainConf
// GetReservedGasUsed extracts the reserved-region gas used from the header's
// Extra field. Returns nil for pre-Cancun blocks, on decode error, or when the
-// field is absent (blocks produced before reserved blockspace activated). The
-// decode is picked per fork: post-Austin headers carry no TxDependency.
+// field is absent (blocks produced before reserved blockspace activated). If
+// you also need ReservedCapacity, prefer GetReservedFields to avoid a
+// redundant RLP decode.
func (h *Header) GetReservedGasUsed(chainConfig *params.ChainConfig) *uint64 {
- if !chainConfig.IsCancun(h.Number) {
- return nil
- }
-
- if len(h.Extra) < ExtraVanityLength+ExtraSealLength {
+ reservedGasUsed, _, ok := h.decodeReservedAwareExtra(chainConfig)
+ if !ok {
return nil
}
+ return reservedGasUsed
+}
- payload := h.Extra[ExtraVanityLength : len(h.Extra)-ExtraSealLength]
-
- if chainConfig.Bor != nil && chainConfig.Bor.IsAustin(h.Number) {
- var blockExtraData BlockExtraDataPostAustin
- if err := rlp.DecodeBytes(payload, &blockExtraData); err != nil {
- return nil
- }
-
- return blockExtraData.ReservedGasUsed
- }
-
- var blockExtraData blockExtraDataRawTxDeps
- if err := rlp.DecodeBytes(payload, &blockExtraData); err != nil {
+// GetReservedCapacity extracts the reserved-region capacity (the base fee's
+// carve-out for block N+1, per parent header N) from the header's Extra
+// field. Returns nil for pre-Cancun blocks, on decode error, or when the
+// field is absent (blocks produced before reserved blockspace activated). If
+// you also need ReservedGasUsed, prefer GetReservedFields to avoid a
+// redundant RLP decode.
+func (h *Header) GetReservedCapacity(chainConfig *params.ChainConfig) *uint64 {
+ _, reservedCapacity, ok := h.decodeReservedAwareExtra(chainConfig)
+ if !ok {
return nil
}
+ return reservedCapacity
+}
- if !txDependencyValidPreValencia(chainConfig, h.Number, blockExtraData.TxDependency) {
- return nil
+// GetReservedFields decodes both reserved-blockspace header fields -
+// ReservedGasUsed and ReservedCapacity - in a single RLP pass, mirroring the
+// GetValidatorBytesAndBaseFeeParams precedent for combined accessors. Prefer
+// this over calling GetReservedGasUsed and GetReservedCapacity separately
+// when a caller needs both; each is nil/non-nil independently of the other,
+// following the same rules documented on the single-field getters.
+func (h *Header) GetReservedFields(chainConfig *params.ChainConfig) (gasUsed *uint64, capacity *uint64) {
+ reservedGasUsed, reservedCapacity, ok := h.decodeReservedAwareExtra(chainConfig)
+ if !ok {
+ return nil, nil
}
-
- return blockExtraData.ReservedGasUsed
+ return reservedGasUsed, reservedCapacity
}
-// SetReservedGasUsed records the reserved-region gas total in the header's
-// BlockExtraData, re-encoding Header.Extra in place using the
-// fork-appropriate wire shape. Pre-Austin, TxDependency is carried through as
-// a raw value, so the rewrite doesn't expand it. The field is
-// consensus-visible: callers gate the write on reserved blockspace being
-// active, this only performs the encoding.
-func (h *Header) SetReservedGasUsed(chainConfig *params.ChainConfig, reservedGasUsed uint64) error {
+// SetReservedFields records the reserved-region gas used and capacity in the
+// header's BlockExtraData in a single decode/encode round-trip, re-encoding
+// Header.Extra in place using the fork-appropriate wire shape. Pre-Austin,
+// TxDependency is carried through as a raw value, so the rewrite doesn't
+// expand it. Both fields are consensus-visible and always written together:
+// callers gate the write on reserved blockspace being active, this only
+// performs the encoding.
+func (h *Header) SetReservedFields(chainConfig *params.ChainConfig, reservedGasUsed, reservedCapacity uint64) error {
if len(h.Extra) < ExtraVanityLength+ExtraSealLength {
return fmt.Errorf("header extra data too short to carry BlockExtraData: %d bytes", len(h.Extra))
}
@@ -701,6 +764,7 @@ func (h *Header) SetReservedGasUsed(chainConfig *params.ChainConfig, reservedGas
}
blockExtraData.ReservedGasUsed = &reservedGasUsed
+ blockExtraData.ReservedCapacity = &reservedCapacity
var err error
if encoded, err = rlp.EncodeToBytes(&blockExtraData); err != nil {
@@ -713,6 +777,7 @@ func (h *Header) SetReservedGasUsed(chainConfig *params.ChainConfig, reservedGas
}
blockExtraData.ReservedGasUsed = &reservedGasUsed
+ blockExtraData.ReservedCapacity = &reservedCapacity
var err error
if encoded, err = rlp.EncodeToBytes(&blockExtraData); err != nil {
@@ -753,15 +818,17 @@ func (h *Header) DecodeBlockExtraData(chainConfig *params.ChainConfig) *BlockExt
}
if chainConfig.Bor != nil && chainConfig.Bor.IsAustin(h.Number) {
- validatorBytes, gasTarget, baseFeeChangeDenom, ok := h.decodeExtraFieldsFast(chainConfig)
- if !ok {
+ var postAustin BlockExtraDataPostAustin
+ if err := rlp.DecodeBytes(h.Extra[ExtraVanityLength:len(h.Extra)-ExtraSealLength], &postAustin); err != nil {
return nil
}
return &BlockExtraData{
- ValidatorBytes: validatorBytes,
- GasTarget: gasTarget,
- BaseFeeChangeDenominator: baseFeeChangeDenom,
+ ValidatorBytes: postAustin.ValidatorBytes,
+ GasTarget: postAustin.GasTarget,
+ BaseFeeChangeDenominator: postAustin.BaseFeeChangeDenominator,
+ ReservedGasUsed: postAustin.ReservedGasUsed,
+ ReservedCapacity: postAustin.ReservedCapacity,
}
}
diff --git a/core/types/block_test.go b/core/types/block_test.go
index d84d4cda47..b9f7362c69 100644
--- a/core/types/block_test.go
+++ b/core/types/block_test.go
@@ -160,7 +160,6 @@ func TestValidatorBytesBlockDecoding(t *testing.T) {
check("Transactions[1].Type", block.Transactions()[1].Type(), tx2.Type())
ourBlockEnc, err := rlp.EncodeToBytes(&block)
-
if err != nil {
t.Fatal("encode error: ", err)
}
@@ -788,6 +787,9 @@ func TestBlockExtraDataRLPBackwardCompatibility(t *testing.T) {
if decoded2.ReservedGasUsed != nil {
t.Errorf("ReservedGasUsed should be nil when unset, got %d", *decoded2.ReservedGasUsed)
}
+ if decoded2.ReservedCapacity != nil {
+ t.Errorf("ReservedCapacity should be nil when unset, got %d", *decoded2.ReservedCapacity)
+ }
}
func TestBlockExtraDataReservedGasUsedRLPCompat(t *testing.T) {
@@ -900,7 +902,139 @@ func TestBlockExtraDataReservedGasUsedRLPCompat(t *testing.T) {
}
}
-func TestGetReservedGasUsed(t *testing.T) {
+// TestBlockExtraDataReservedCapacityRLPCompat covers ReservedCapacity, the
+// sixth and last BlockExtraData field: absent-vs-explicit-zero distinction,
+// wire-neutrality while unset, and — the compatibility direction that matters
+// for a field added after ReservedGasUsed already shipped — a pre-existing
+// 5-field payload (older branch, no ReservedCapacity) still decodes cleanly
+// into the current 6-field struct with ReservedCapacity nil.
+func TestBlockExtraDataReservedCapacityRLPCompat(t *testing.T) {
+ t.Parallel()
+
+ gasTarget := uint64(15000000)
+ bfcd := uint64(64)
+ reservedGasUsed := uint64(1_000)
+
+ roundtrip := func(in *BlockExtraData) (BlockExtraData, blockExtraDataRawTxDeps) {
+ t.Helper()
+ encoded, err := rlp.EncodeToBytes(in)
+ if err != nil {
+ t.Fatalf("encode: %v", err)
+ }
+ var full BlockExtraData
+ if err := rlp.DecodeBytes(encoded, &full); err != nil {
+ t.Fatalf("decode full: %v", err)
+ }
+ var raw blockExtraDataRawTxDeps
+ if err := rlp.DecodeBytes(encoded, &raw); err != nil {
+ t.Fatalf("decode raw mirror: %v", err)
+ }
+ return full, raw
+ }
+
+ // Older 5-field payload (pre-ReservedCapacity): decodes with the field nil.
+ old5Field, err := rlp.EncodeToBytes(&struct {
+ ValidatorBytes []byte
+ TxDependency [][]uint64
+ GasTarget *uint64 `rlp:"optional"`
+ BaseFeeChangeDenominator *uint64 `rlp:"optional"`
+ ReservedGasUsed *uint64 `rlp:"optional"`
+ }{TxDependency: [][]uint64{{1}}, GasTarget: &gasTarget, BaseFeeChangeDenominator: &bfcd, ReservedGasUsed: &reservedGasUsed})
+ if err != nil {
+ t.Fatalf("encode 5-field payload: %v", err)
+ }
+ var decodedOld BlockExtraData
+ if err := rlp.DecodeBytes(old5Field, &decodedOld); err != nil {
+ t.Fatalf("decode 5-field payload into 6-field struct: %v", err)
+ }
+ if decodedOld.ReservedGasUsed == nil || *decodedOld.ReservedGasUsed != reservedGasUsed {
+ t.Errorf("ReservedGasUsed lost decoding an older 5-field payload: got %v", decodedOld.ReservedGasUsed)
+ }
+ if decodedOld.ReservedCapacity != nil {
+ t.Errorf("ReservedCapacity must be nil decoding an older 5-field payload, got %v", decodedOld.ReservedCapacity)
+ }
+
+ // Populated field survives the roundtrip in both structs.
+ capacity := uint64(20_000_000)
+ full, raw := roundtrip(&BlockExtraData{
+ ValidatorBytes: []byte{0x01},
+ TxDependency: [][]uint64{{1}},
+ GasTarget: &gasTarget,
+ BaseFeeChangeDenominator: &bfcd,
+ ReservedGasUsed: &reservedGasUsed,
+ ReservedCapacity: &capacity,
+ })
+ if full.ReservedCapacity == nil || *full.ReservedCapacity != capacity {
+ t.Errorf("ReservedCapacity mismatch in full decode: got %v, want %d", full.ReservedCapacity, capacity)
+ }
+ if raw.ReservedCapacity == nil || *raw.ReservedCapacity != capacity {
+ t.Errorf("ReservedCapacity mismatch in raw-mirror decode: got %v, want %d", raw.ReservedCapacity, capacity)
+ }
+
+ // Explicit zero must survive distinctly from absent.
+ zero := uint64(0)
+ full, raw = roundtrip(&BlockExtraData{
+ ValidatorBytes: []byte{0x01},
+ TxDependency: [][]uint64{{1}},
+ GasTarget: &gasTarget,
+ BaseFeeChangeDenominator: &bfcd,
+ ReservedGasUsed: &reservedGasUsed,
+ ReservedCapacity: &zero,
+ })
+ if full.ReservedCapacity == nil || *full.ReservedCapacity != 0 {
+ t.Errorf("explicit zero ReservedCapacity must survive the wire, got %v", full.ReservedCapacity)
+ }
+ if raw.ReservedCapacity == nil || *raw.ReservedCapacity != 0 {
+ t.Errorf("explicit zero ReservedCapacity must survive the raw mirror, got %v", raw.ReservedCapacity)
+ }
+
+ // Wire-neutrality: while ReservedCapacity is nil, the encoding is the same
+ // 5-element list as before this field existed.
+ countElems := func(bed *BlockExtraData) int {
+ t.Helper()
+ encoded, err := rlp.EncodeToBytes(bed)
+ if err != nil {
+ t.Fatalf("encode: %v", err)
+ }
+ content, _, err := rlp.SplitList(encoded)
+ if err != nil {
+ t.Fatalf("split list: %v", err)
+ }
+ n, err := rlp.CountValues(content)
+ if err != nil {
+ t.Fatalf("count values: %v", err)
+ }
+ return n
+ }
+ unwritten := &BlockExtraData{
+ ValidatorBytes: []byte{0x01},
+ TxDependency: [][]uint64{{1}},
+ GasTarget: &gasTarget,
+ BaseFeeChangeDenominator: &bfcd,
+ ReservedGasUsed: &reservedGasUsed,
+ }
+ if n := countElems(unwritten); n != 5 {
+ t.Errorf("nil ReservedCapacity must encode as a 5-element list, got %d elements", n)
+ }
+ written := &BlockExtraData{
+ ValidatorBytes: []byte{0x01},
+ TxDependency: [][]uint64{{1}},
+ GasTarget: &gasTarget,
+ BaseFeeChangeDenominator: &bfcd,
+ ReservedGasUsed: &reservedGasUsed,
+ ReservedCapacity: &capacity,
+ }
+ if n := countElems(written); n != 6 {
+ t.Errorf("set ReservedCapacity must encode as a 6-element list, got %d elements", n)
+ }
+}
+
+// TestReservedHeaderGetters covers GetReservedGasUsed and GetReservedCapacity
+// together: both share the same decodeReservedAwareExtra plumbing (Cancun
+// gate, vanity/seal guard, decode error, pre-Valencia TxDependency), so one
+// table of header fixtures exercised against both getters pins the shared
+// behavior without duplicating it per field.
+func TestReservedHeaderGetters(t *testing.T) {
t.Parallel()
chainConfig := ¶ms.ChainConfig{
@@ -916,75 +1050,135 @@ func TestGetReservedGasUsed(t *testing.T) {
extra = append(extra, seal...)
return extra
}
-
- reserved := uint64(5_000_000)
- header := &Header{
- Number: big.NewInt(200),
- Extra: buildExtra(&BlockExtraData{ReservedGasUsed: &reserved}),
- }
- if got := header.GetReservedGasUsed(chainConfig); got == nil || *got != reserved {
- t.Errorf("expected ReservedGasUsed %d, got %v", reserved, got)
- }
-
- // Pre-Cancun header returns nil.
- preCancun := &Header{Number: big.NewInt(50), Extra: header.Extra}
- if got := preCancun.GetReservedGasUsed(chainConfig); got != nil {
- t.Errorf("expected nil pre-Cancun, got %v", got)
+ wrap := func(payload []byte) []byte {
+ extra := make([]byte, ExtraVanityLength, ExtraVanityLength+len(payload)+ExtraSealLength)
+ extra = append(extra, payload...)
+ return append(extra, make([]byte, ExtraSealLength)...)
}
- // Field absent (pre-activation block) returns nil.
- absent := &Header{Number: big.NewInt(200), Extra: buildExtra(&BlockExtraData{})}
- if got := absent.GetReservedGasUsed(chainConfig); got != nil {
- t.Errorf("expected nil for absent field, got %v", got)
- }
+ reservedGasUsed := uint64(1_000_000)
+ capacity := uint64(5_000_000)
- // Short extra data returns nil.
- short := &Header{Number: big.NewInt(200), Extra: []byte{0x01}}
- if got := short.GetReservedGasUsed(chainConfig); got != nil {
- t.Errorf("expected nil for short extra, got %v", got)
+ populated := &Header{
+ Number: big.NewInt(200),
+ Extra: buildExtra(&BlockExtraData{ReservedGasUsed: &reservedGasUsed, ReservedCapacity: &capacity}),
}
- // A decode error must return nil even when the field itself was already
- // decoded: this payload carries all five fields plus a sixth junk element,
- // so decoding fails only after ReservedGasUsed has been populated.
- sixFields, err := rlp.EncodeToBytes(&struct {
+ // A decode error must return nil even when earlier fields were already
+ // populated: this payload carries all six real fields plus a seventh
+ // junk element, so decoding fails only after both reserved fields have
+ // been read into the target struct.
+ sevenFields, err := rlp.EncodeToBytes(&struct {
ValidatorBytes []byte
TxDependency [][]uint64
GasTarget *uint64
BaseFeeChangeDenominator *uint64
ReservedGasUsed *uint64
+ ReservedCapacity *uint64
Junk uint64
- }{TxDependency: [][]uint64{{1}}, ReservedGasUsed: &reserved, Junk: 1})
+ }{TxDependency: [][]uint64{{1}}, ReservedGasUsed: &reservedGasUsed, ReservedCapacity: &capacity, Junk: 1})
if err != nil {
- t.Fatalf("encode six-field payload: %v", err)
- }
- wrap := func(payload []byte) []byte {
- extra := make([]byte, ExtraVanityLength, ExtraVanityLength+len(payload)+ExtraSealLength)
- extra = append(extra, payload...)
- return append(extra, make([]byte, ExtraSealLength)...)
- }
- tooMany := &Header{Number: big.NewInt(200), Extra: wrap(sixFields)}
- if got := tooMany.GetReservedGasUsed(chainConfig); got != nil {
- t.Errorf("expected nil on decode error, got %v", got)
+ t.Fatalf("encode seven-field payload: %v", err)
}
+ decodeError := &Header{Number: big.NewInt(200), Extra: wrap(sevenFields)}
// Pre-Valencia, a malformed TxDependency invalidates the whole payload —
- // the field must not be returned from it. chainConfig has no Bor config,
+ // neither field must be returned from it. chainConfig has no Bor config,
// so the pre-Valencia strictness applies.
badDeps, err := rlp.EncodeToBytes(&blockExtraDataRawTxDeps{
- TxDependency: rlp.RawValue{0x81, 0xff},
- ReservedGasUsed: &reserved,
+ TxDependency: rlp.RawValue{0x81, 0xff},
+ ReservedGasUsed: &reservedGasUsed,
+ ReservedCapacity: &capacity,
})
if err != nil {
t.Fatalf("encode malformed-txdep payload: %v", err)
}
- malformed := &Header{Number: big.NewInt(200), Extra: wrap(badDeps)}
- if got := malformed.GetReservedGasUsed(chainConfig); got != nil {
- t.Errorf("expected nil for malformed TxDependency, got %v", got)
+
+ cases := []struct {
+ name string
+ header *Header
+ wantNil bool
+ }{
+ {"populated", populated, false},
+ {"pre-Cancun header returns nil", &Header{Number: big.NewInt(50), Extra: populated.Extra}, true},
+ {"field absent (pre-activation block) returns nil", &Header{Number: big.NewInt(200), Extra: buildExtra(&BlockExtraData{})}, true},
+ {"short extra data returns nil", &Header{Number: big.NewInt(200), Extra: []byte{0x01}}, true},
+ {"decode error returns nil", decodeError, true},
+ {"malformed pre-Valencia TxDependency returns nil", &Header{Number: big.NewInt(200), Extra: wrap(badDeps)}, true},
+ }
+
+ getters := []struct {
+ name string
+ get func(*Header) *uint64
+ want uint64
+ }{
+ {"GetReservedGasUsed", func(h *Header) *uint64 { return h.GetReservedGasUsed(chainConfig) }, reservedGasUsed},
+ {"GetReservedCapacity", func(h *Header) *uint64 { return h.GetReservedCapacity(chainConfig) }, capacity},
+ }
+
+ for _, g := range getters {
+ t.Run(g.name, func(t *testing.T) {
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ got := g.get(tc.header)
+ if tc.wantNil {
+ if got != nil {
+ t.Errorf("got %v, want nil", got)
+ }
+ return
+ }
+ if got == nil || *got != g.want {
+ t.Errorf("got %v, want %d", got, g.want)
+ }
+ })
+ }
+ })
}
}
-func TestSetReservedGasUsed(t *testing.T) {
+// TestGetReservedFields pins the combined getter's single-decode contract:
+// it must agree with GetReservedGasUsed and GetReservedCapacity called
+// separately, both when populated and when every gate nils the whole result.
+func TestGetReservedFields(t *testing.T) {
+ t.Parallel()
+
+ chainConfig := ¶ms.ChainConfig{ChainID: big.NewInt(137), CancunBlock: big.NewInt(100)}
+ buildExtra := func(bed *BlockExtraData) []byte {
+ vanity := make([]byte, ExtraVanityLength)
+ seal := make([]byte, ExtraSealLength)
+ encoded, _ := rlp.EncodeToBytes(bed)
+ extra := append(vanity, encoded...)
+ return append(extra, seal...)
+ }
+
+ reservedGasUsed := uint64(2_500_000)
+ capacity := uint64(9_000_000)
+ populated := &Header{
+ Number: big.NewInt(200),
+ Extra: buildExtra(&BlockExtraData{ReservedGasUsed: &reservedGasUsed, ReservedCapacity: &capacity}),
+ }
+
+ gotGasUsed, gotCapacity := populated.GetReservedFields(chainConfig)
+ if gotGasUsed == nil || *gotGasUsed != reservedGasUsed {
+ t.Errorf("gasUsed = %v, want %d", gotGasUsed, reservedGasUsed)
+ }
+ if gotCapacity == nil || *gotCapacity != capacity {
+ t.Errorf("capacity = %v, want %d", gotCapacity, capacity)
+ }
+ if want := populated.GetReservedGasUsed(chainConfig); gotGasUsed == nil || want == nil || *gotGasUsed != *want {
+ t.Errorf("GetReservedFields gasUsed disagrees with GetReservedGasUsed: %v vs %v", gotGasUsed, want)
+ }
+ if want := populated.GetReservedCapacity(chainConfig); gotCapacity == nil || want == nil || *gotCapacity != *want {
+ t.Errorf("GetReservedFields capacity disagrees with GetReservedCapacity: %v vs %v", gotCapacity, want)
+ }
+
+ preCancun := &Header{Number: big.NewInt(50), Extra: populated.Extra}
+ if gasUsed, cap := preCancun.GetReservedFields(chainConfig); gasUsed != nil || cap != nil {
+ t.Errorf("pre-Cancun: got (%v, %v), want (nil, nil)", gasUsed, cap)
+ }
+}
+
+func TestSetReservedFields(t *testing.T) {
t.Parallel()
chainConfig := ¶ms.ChainConfig{
@@ -1008,12 +1202,15 @@ func TestSetReservedGasUsed(t *testing.T) {
extra = append(extra, make([]byte, ExtraSealLength)...)
header := &Header{Number: big.NewInt(1), Extra: extra}
- if err := header.SetReservedGasUsed(chainConfig, 7_500_000); err != nil {
- t.Fatalf("SetReservedGasUsed: %v", err)
+ if err := header.SetReservedFields(chainConfig, 7_500_000, 20_000_000); err != nil {
+ t.Fatalf("SetReservedFields: %v", err)
}
if got := header.GetReservedGasUsed(chainConfig); got == nil || *got != 7_500_000 {
- t.Errorf("round-trip mismatch: got %v, want 7500000", got)
+ t.Errorf("ReservedGasUsed round-trip mismatch: got %v, want 7500000", got)
+ }
+ if got := header.GetReservedCapacity(chainConfig); got == nil || *got != 20_000_000 {
+ t.Errorf("ReservedCapacity round-trip mismatch: got %v, want 20000000", got)
}
// Every sibling field must survive the rewrite untouched.
@@ -1031,17 +1228,21 @@ func TestSetReservedGasUsed(t *testing.T) {
t.Errorf("Giugliano fields changed: gt=%v bfcd=%v", decoded.GasTarget, decoded.BaseFeeChangeDenominator)
}
- // The write is idempotent-by-overwrite: setting again replaces the value.
- if err := header.SetReservedGasUsed(chainConfig, 0); err != nil {
- t.Fatalf("second SetReservedGasUsed: %v", err)
+ // The write is idempotent-by-overwrite: setting again replaces both values
+ // in the same round-trip.
+ if err := header.SetReservedFields(chainConfig, 0, 0); err != nil {
+ t.Fatalf("second SetReservedFields: %v", err)
}
if got := header.GetReservedGasUsed(chainConfig); got == nil || *got != 0 {
- t.Errorf("overwrite mismatch: got %v, want explicit 0", got)
+ t.Errorf("ReservedGasUsed overwrite mismatch: got %v, want explicit 0", got)
+ }
+ if got := header.GetReservedCapacity(chainConfig); got == nil || *got != 0 {
+ t.Errorf("ReservedCapacity overwrite mismatch: got %v, want explicit 0", got)
}
// Too-short extra is rejected without touching the header.
short := &Header{Number: big.NewInt(1), Extra: []byte{0x01}}
- if err := short.SetReservedGasUsed(chainConfig, 1); err == nil {
+ if err := short.SetReservedFields(chainConfig, 1, 1); err == nil {
t.Error("expected error for short extra data")
}
if !bytes.Equal(short.Extra, []byte{0x01}) {
@@ -1264,12 +1465,12 @@ func TestGetValidatorBytesShortExtra(t *testing.T) {
}
}
-// TestReservedGasUsedPostAustin pins the reserved-gas accessors on the
+// TestReservedFieldsPostAustin pins the reserved-field accessors on the
// post-Austin wire shape (no TxDependency), which is the format every network
// activating reserved blockspace after Austin will carry. The pre-Austin
-// shape is covered by TestSetReservedGasUsed above; together they hold the
+// shape is covered by TestSetReservedFields above; together they hold the
// accessors to both encodings across the Austin boundary.
-func TestReservedGasUsedPostAustin(t *testing.T) {
+func TestReservedFieldsPostAustin(t *testing.T) {
t.Parallel()
austinAt := func(block int64) *params.ChainConfig {
@@ -1283,7 +1484,7 @@ func TestReservedGasUsedPostAustin(t *testing.T) {
gasTarget := uint64(30_000_000)
bfcd := uint64(64)
- body, err := EncodeBlockExtraData(chainConfig, big.NewInt(1), []byte("validator-bytes"), &gasTarget, &bfcd, nil)
+ body, err := EncodeBlockExtraData(chainConfig, big.NewInt(1), []byte("validator-bytes"), &gasTarget, &bfcd, nil, nil)
if err != nil {
t.Fatalf("encode: %v", err)
}
@@ -1296,11 +1497,14 @@ func TestReservedGasUsedPostAustin(t *testing.T) {
t.Errorf("field absent on the wire, want nil, got %v", got)
}
- if err := header.SetReservedGasUsed(chainConfig, 7_500_000); err != nil {
- t.Fatalf("SetReservedGasUsed: %v", err)
+ if err := header.SetReservedFields(chainConfig, 7_500_000, 20_000_000); err != nil {
+ t.Fatalf("SetReservedFields: %v", err)
}
if got := header.GetReservedGasUsed(chainConfig); got == nil || *got != 7_500_000 {
- t.Errorf("round-trip mismatch: got %v, want 7500000", got)
+ t.Errorf("ReservedGasUsed round-trip mismatch: got %v, want 7500000", got)
+ }
+ if got := header.GetReservedCapacity(chainConfig); got == nil || *got != 20_000_000 {
+ t.Errorf("ReservedCapacity round-trip mismatch: got %v, want 20000000", got)
}
// Sibling fields must survive the rewrite untouched.
@@ -1328,7 +1532,7 @@ func TestReservedGasUsedPostAustin(t *testing.T) {
{"post-austin", austinAt(5), 6},
} {
t.Run(tc.name, func(t *testing.T) {
- body, err := EncodeBlockExtraData(tc.config, big.NewInt(tc.number), nil, nil, nil, &reserved)
+ body, err := EncodeBlockExtraData(tc.config, big.NewInt(tc.number), nil, nil, nil, &reserved, &reserved)
if err != nil {
t.Fatalf("encode: %v", err)
}
@@ -1336,8 +1540,9 @@ func TestReservedGasUsedPostAustin(t *testing.T) {
extra = append(extra, body...)
extra = append(extra, make([]byte, ExtraSealLength)...)
h := &Header{Number: big.NewInt(tc.number), Extra: extra}
- if got := h.GetReservedGasUsed(tc.config); got == nil || *got != reserved {
- t.Fatalf("round-trip through EncodeBlockExtraData: got %v, want %d", got, reserved)
+ gasUsed, capacity := h.GetReservedFields(tc.config)
+ if gasUsed == nil || *gasUsed != reserved || capacity == nil || *capacity != reserved {
+ t.Fatalf("round-trip through EncodeBlockExtraData: gasUsed=%v capacity=%v, want both %d", gasUsed, capacity, reserved)
}
})
}
diff --git a/core/types/receipt.go b/core/types/receipt.go
index f22cb5c184..6b7d174ccd 100644
--- a/core/types/receipt.go
+++ b/core/types/receipt.go
@@ -290,6 +290,12 @@ type DeriveReceiptContext struct {
LogIndex uint // Number of logs in the block until this receipt
Tx *Transaction
TxIndex uint
+ // Reserved marks the transaction as classified reserved (fee-free) by the
+ // reserved-blockspace registry. Reserved transactions execute without
+ // paying a fee regardless of the fee fields they carry (see
+ // core/state_transition.go buyGas), so their effective gas price is
+ // always zero rather than the fee-derived value below.
+ Reserved bool
}
// DeriveFields fills the receipt with computed fields based on consensus
@@ -299,7 +305,11 @@ func (r *Receipt) DeriveFields(signer Signer, context DeriveReceiptContext) {
r.Type = context.Tx.Type()
r.TxHash = context.Tx.Hash()
r.GasUsed = context.GasUsed
- r.EffectiveGasPrice = context.Tx.inner.effectiveGasPrice(new(big.Int), context.BaseFee)
+ if context.Reserved {
+ r.EffectiveGasPrice = new(big.Int)
+ } else {
+ r.EffectiveGasPrice = context.Tx.inner.effectiveGasPrice(new(big.Int), context.BaseFee)
+ }
// EIP-4844 blob transaction fields
if context.Tx.Type() == BlobTxType {
@@ -403,9 +413,13 @@ func (rs Receipts) EncodeIndex(i int, w *bytes.Buffer) {
}
}
-// DeriveFields fills the receipts with their computed fields based on consensus
-// data and contextual infos like containing block and transactions.
-func (rs Receipts) DeriveFields(config *params.ChainConfig, blockHash common.Hash, blockNumber uint64, blockTime uint64, baseFee *big.Int, blobGasPrice *big.Int, txs []*Transaction) error {
+// DeriveFields fills the receipts with their computed fields based on
+// consensus data and contextual infos like containing block and
+// transactions. reservedTxIndexes lists positions within txs classified
+// reserved (fee-free) by the reserved-blockspace registry, strictly
+// ascending; nil/empty means none (pre-fork, no registry, or nothing
+// reserved for this block).
+func (rs Receipts) DeriveFields(config *params.ChainConfig, blockHash common.Hash, blockNumber uint64, blockTime uint64, baseFee *big.Int, blobGasPrice *big.Int, txs []*Transaction, reservedTxIndexes []uint64) error {
signer := MakeSigner(config, new(big.Int).SetUint64(blockNumber), blockTime)
logIndex := uint(0)
@@ -414,11 +428,17 @@ func (rs Receipts) DeriveFields(config *params.ChainConfig, blockHash common.Has
return errors.New("transaction and receipt count mismatch")
}
+ reservedPos := 0
for i := 0; i < len(rs); i++ {
var cumulativeGasUsed uint64
if i > 0 {
cumulativeGasUsed = rs[i-1].CumulativeGasUsed
}
+ reserved := false
+ if reservedPos < len(reservedTxIndexes) && reservedTxIndexes[reservedPos] == uint64(i) {
+ reserved = true
+ reservedPos++
+ }
rs[i].DeriveFields(signer, DeriveReceiptContext{
BlockHash: blockHash,
BlockNumber: blockNumber,
@@ -429,6 +449,7 @@ func (rs Receipts) DeriveFields(config *params.ChainConfig, blockHash common.Has
LogIndex: logIndex,
Tx: txs[i],
TxIndex: uint(i),
+ Reserved: reserved,
})
logIndex += uint(len(rs[i].Logs))
}
diff --git a/core/types/receipt_test.go b/core/types/receipt_test.go
index 6fcb7821ac..04184e0142 100644
--- a/core/types/receipt_test.go
+++ b/core/types/receipt_test.go
@@ -332,7 +332,7 @@ func TestDeriveFields(t *testing.T) {
blobGasPrice := big.NewInt(920)
receipts := getTestReceipts()
derivedReceipts := clearComputedFieldsOnReceipts(receipts)
- err := Receipts(derivedReceipts).DeriveFields(params.TestChainConfig, blockHash, blockNumber.Uint64(), blockTime, basefee, blobGasPrice, txs)
+ err := Receipts(derivedReceipts).DeriveFields(params.TestChainConfig, blockHash, blockNumber.Uint64(), blockTime, basefee, blobGasPrice, txs, nil)
if err != nil {
t.Fatalf("DeriveFields(...) = %v, want ", err)
}
@@ -354,6 +354,56 @@ func TestDeriveFields(t *testing.T) {
}
}
+// TestDeriveFieldsReserved covers the Reserved-classification override: a
+// transaction whose index is passed in reservedTxIndexes must report
+// EffectiveGasPrice 0 regardless of the fee it actually carries (zero-fee or
+// fallback-fee), for both legacy and dynamic-fee transaction types, while an
+// unlisted transaction keeps the ordinary fee-derived price.
+func TestDeriveFieldsReserved(t *testing.T) {
+ to := common.HexToAddress("0xaa")
+ baseFee := big.NewInt(100)
+
+ // legacy fallback-fee, dynamic-fee fallback-fee, dynamic-fee zero-fee.
+ reservedTxs := Transactions{
+ NewTx(&LegacyTx{To: &to, Nonce: 0, Gas: 21000, GasPrice: big.NewInt(500)}),
+ NewTx(&DynamicFeeTx{To: &to, Nonce: 1, Gas: 21000, GasTipCap: big.NewInt(50), GasFeeCap: big.NewInt(700)}),
+ NewTx(&DynamicFeeTx{To: &to, Nonce: 2, Gas: 21000, GasTipCap: big.NewInt(0), GasFeeCap: big.NewInt(0)}),
+ }
+ newReceipts := func() Receipts {
+ return Receipts{
+ {Type: LegacyTxType, CumulativeGasUsed: 21000},
+ {Type: DynamicFeeTxType, CumulativeGasUsed: 42000},
+ {Type: DynamicFeeTxType, CumulativeGasUsed: 63000},
+ }
+ }
+
+ cases := []struct {
+ name string
+ indexes []uint64
+ want []int64 // expected EffectiveGasPrice per tx, by position
+ }{
+ {"none reserved", nil, []int64{500, 150, 0}},
+ {"legacy fallback-fee reserved", []uint64{0}, []int64{0, 150, 0}},
+ {"dynamic fallback-fee reserved", []uint64{1}, []int64{500, 0, 0}},
+ {"dynamic zero-fee reserved", []uint64{2}, []int64{500, 150, 0}},
+ {"all reserved", []uint64{0, 1, 2}, []int64{0, 0, 0}},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ receipts := newReceipts()
+ if err := receipts.DeriveFields(params.TestChainConfig, blockHash, blockNumber.Uint64(), blockTime, baseFee, nil, reservedTxs, tc.indexes); err != nil {
+ t.Fatalf("DeriveFields(...) = %v, want ", err)
+ }
+ for i, want := range tc.want {
+ if got := receipts[i].EffectiveGasPrice; got.Cmp(big.NewInt(want)) != 0 {
+ t.Errorf("tx %d: EffectiveGasPrice = %v, want %v", i, got, want)
+ }
+ }
+ })
+ }
+}
+
// Test that we can marshal/unmarshal receipts to/from json without errors.
// This also confirms that our test receipts contain all the required fields.
func TestReceiptJSON(t *testing.T) {
diff --git a/docs/cli/default_config.toml b/docs/cli/default_config.toml
index c14e6e9af3..2528e3d829 100644
--- a/docs/cli/default_config.toml
+++ b/docs/cli/default_config.toml
@@ -77,6 +77,7 @@ devfakeauthor = false
globalslots = 131072
accountqueue = 64
globalqueue = 131072
+ reservedmaxoccupancypercent = 50
lifetime = "3h0m0s"
filtered-addresses = ""
rebroadcast = true
diff --git a/docs/cli/server.md b/docs/cli/server.md
index 832eed9a3f..f0ea4cbf4d 100644
--- a/docs/cli/server.md
+++ b/docs/cli/server.md
@@ -446,4 +446,6 @@ The ```bor server``` command runs the Bor client.
- ```txpool.rebroadcast-max-age```: Maximum age for a transaction to be eligible for rebroadcast (default: 10m0s)
-- ```txpool.rejournal```: Time interval to regenerate the local transaction journal (default: 1h0m0s)
\ No newline at end of file
+- ```txpool.rejournal```: Time interval to regenerate the local transaction journal (default: 1h0m0s)
+
+- ```txpool.reservedmaxoccupancypercent```: Percentage of globalslots+globalqueue that reserved-blockspace senders may occupy in aggregate (default: 50)
\ No newline at end of file
diff --git a/eth/api_backend.go b/eth/api_backend.go
index 3a8be66735..f2900e98ca 100644
--- a/eth/api_backend.go
+++ b/eth/api_backend.go
@@ -528,7 +528,7 @@ func (b *EthAPIBackend) SuggestGasTipCap(ctx context.Context) (*big.Int, error)
return b.gpo.SuggestTipCap(ctx)
}
-func (b *EthAPIBackend) FeeHistory(ctx context.Context, blockCount uint64, lastBlock rpc.BlockNumber, rewardPercentiles []float64) (firstBlock *big.Int, reward [][]*big.Int, baseFee []*big.Int, gasUsedRatio []float64, baseFeePerBlobGas []*big.Int, blobGasUsedRatio []float64, err error) {
+func (b *EthAPIBackend) FeeHistory(ctx context.Context, blockCount uint64, lastBlock rpc.BlockNumber, rewardPercentiles []float64) (firstBlock *big.Int, reward [][]*big.Int, baseFee []*big.Int, gasUsedRatio []float64, normalGasUsedRatio []float64, baseFeePerBlobGas []*big.Int, blobGasUsedRatio []float64, err error) {
return b.gpo.FeeHistory(ctx, blockCount, lastBlock, rewardPercentiles)
}
diff --git a/eth/api_debug.go b/eth/api_debug.go
index 2c914d0316..6e5bc1f821 100644
--- a/eth/api_debug.go
+++ b/eth/api_debug.go
@@ -513,7 +513,7 @@ func (api *DebugAPI) ExecutionWitness(bn rpc.BlockNumber) (*stateless.ExtWitness
}
parentBlock := bc.GetBlockByHash(block.ParentHash())
- _, _, _, statedb, _, err := bc.ProcessBlock(parentBlock, block.Header(), nil, nil, nil)
+ _, _, _, statedb, _, _, _, err := bc.ProcessBlock(parentBlock, block.Header(), nil, nil, nil)
if err != nil {
return nil, err
}
@@ -534,7 +534,7 @@ func (api *DebugAPI) ExecutionWitnessByHash(hash common.Hash) (*stateless.ExtWit
}
parentBlock := bc.GetBlockByHash(block.ParentHash())
- _, _, _, statedb, _, err := bc.ProcessBlock(parentBlock, block.Header(), nil, nil, nil)
+ _, _, _, statedb, _, _, _, err := bc.ProcessBlock(parentBlock, block.Header(), nil, nil, nil)
if err != nil {
return nil, err
}
diff --git a/eth/gasprice/feehistory.go b/eth/gasprice/feehistory.go
index 678046df95..9fea4545f6 100644
--- a/eth/gasprice/feehistory.go
+++ b/eth/gasprice/feehistory.go
@@ -69,6 +69,7 @@ type processedFees struct {
reward []*big.Int
baseFee, nextBaseFee *big.Int
gasUsedRatio float64
+ normalGasUsedRatio float64
blobGasUsedRatio float64
blobBaseFee, nextBlobBaseFee *big.Int
}
@@ -113,6 +114,41 @@ func (oracle *Oracle) processBlock(bf *blockFees, percentiles []float64) {
}
}
+ // reservedGasUsed is read once for both the normal-region ratio below
+ // and the reward-percentile exclusion further down. The fork gate
+ // mirrors validateReservedFields: reserved header fields are only
+ // consensus-checked once the fork is active, so pre-fork header content
+ // must not influence either computation.
+ var reservedGasUsed *uint64
+ if config.Bor != nil && config.Bor.IsReservedBlockspace(bf.header.Number) {
+ reservedGasUsed, _ = bf.header.GetReservedFields(config)
+ }
+
+ // normalGasUsedRatio is the fee-paying region's utilization. Reserved
+ // capacity is a base-fee target carve-out, not a partition of the gas
+ // limit - the gas pool is the full block limit and unused reserved
+ // capacity is spillable to normal transactions - so only gas the
+ // reserved region actually consumed narrows the denominator. This keeps
+ // the ratio bounded in [0, 1] by construction. Pre-fork, or on a block
+ // that used no reserved gas, this equals gasUsedRatio exactly.
+ if reservedGasUsed == nil {
+ bf.results.normalGasUsedRatio = bf.results.gasUsedRatio
+ } else {
+ reserved := *reservedGasUsed
+
+ num := bf.header.GasUsed - min(reserved, bf.header.GasUsed)
+ den := bf.header.GasLimit - min(reserved, bf.header.GasLimit)
+ if den == 0 {
+ // The reserved region consumed the entire limit, leaving no
+ // public gas used - the same empty-public-region case that
+ // makes the base fee fall (eip1559.CalcBaseFee's reserved-aware
+ // target).
+ bf.results.normalGasUsedRatio = 0.0
+ } else {
+ bf.results.normalGasUsedRatio = float64(num) / float64(den)
+ }
+ }
+
if len(percentiles) == 0 {
// rewards were not requested, return null
return
@@ -124,8 +160,31 @@ func (oracle *Oracle) processBlock(bf *blockFees, percentiles []float64) {
}
bf.results.reward = make([]*big.Int, len(percentiles))
- if len(bf.block.Transactions()) == 0 {
- // return an all zero row if there are no transactions to gather data from
+
+ // Reserved (fee-free) transactions carry no market signal for reward
+ // percentiles: excluding them from the sorter and netting their gas out
+ // of the threshold base keeps the percentile walk aligned with the
+ // remaining fee-paying gas. Guarded by the same fork-gated header read
+ // as the ratio above, so pre-fork blocks (or blocks that used no
+ // reserved gas) build the sorter exactly as before. Pending-block
+ // receipts have nil EffectiveGasPrice for every transaction, so pending
+ // rewards are unchanged by construction.
+ excludeReserved := reservedGasUsed != nil && *reservedGasUsed > 0
+
+ var excludedGas uint64
+ sorter := make([]txGasAndReward, 0, len(bf.block.Transactions()))
+ for i, tx := range bf.block.Transactions() {
+ if excludeReserved && isReservedReceipt(bf.receipts[i]) {
+ excludedGas += bf.receipts[i].GasUsed
+ continue
+ }
+ reward, _ := tx.EffectiveGasTip(bf.block.BaseFee())
+ sorter = append(sorter, txGasAndReward{gasUsed: bf.receipts[i].GasUsed, reward: reward})
+ }
+ if len(sorter) == 0 {
+ // No fee-paying transactions to gather data from: the block was
+ // empty, or every transaction in it was reserved. Return an all-zero
+ // row either way.
for i := range bf.results.reward {
bf.results.reward[i] = new(big.Int)
}
@@ -133,11 +192,6 @@ func (oracle *Oracle) processBlock(bf *blockFees, percentiles []float64) {
return
}
- sorter := make([]txGasAndReward, len(bf.block.Transactions()))
- for i, tx := range bf.block.Transactions() {
- reward, _ := tx.EffectiveGasTip(bf.block.BaseFee())
- sorter[i] = txGasAndReward{gasUsed: bf.receipts[i].GasUsed, reward: reward}
- }
slices.SortStableFunc(sorter, func(a, b txGasAndReward) int {
return a.reward.Cmp(b.reward)
})
@@ -147,8 +201,8 @@ func (oracle *Oracle) processBlock(bf *blockFees, percentiles []float64) {
sumGasUsed := sorter[0].gasUsed
for i, p := range percentiles {
- thresholdGasUsed := uint64(float64(bf.block.GasUsed()) * p / 100)
- for sumGasUsed < thresholdGasUsed && txIndex < len(bf.block.Transactions())-1 {
+ thresholdGasUsed := uint64(float64(bf.block.GasUsed()-excludedGas) * p / 100)
+ for sumGasUsed < thresholdGasUsed && txIndex < len(sorter)-1 {
txIndex++
sumGasUsed += sorter[txIndex].gasUsed
}
@@ -236,19 +290,21 @@ func (oracle *Oracle) resolveBlockRange(ctx context.Context, reqEnd rpc.BlockNum
// or blocks older than a certain age (specified in maxHistory). The first block of the
// actually processed range is returned to avoid ambiguity when parts of the requested range
// are not available or when the head has changed during processing this request.
-// Five arrays are returned based on the processed blocks:
+// Six arrays are returned based on the processed blocks:
// - reward: the requested percentiles of effective priority fees per gas of transactions in each
// block, sorted in ascending order and weighted by gas used.
// - baseFee: base fee per gas in the given block
// - gasUsedRatio: gasUsed/gasLimit in the given block
+// - normalGasUsedRatio: gas used ratio of the fee-paying region only, excluding reserved
+// (fee-free) blockspace; equal to gasUsedRatio pre-fork or on blocks that used no reserved gas.
// - blobBaseFee: the blob base fee per gas in the given block
// - blobGasUsedRatio: blobGasUsed/blobGasLimit in the given block
//
// Note: baseFee and blobBaseFee both include the next block after the newest of the returned range,
// because this value can be derived from the newest block.
-func (oracle *Oracle) FeeHistory(ctx context.Context, blocks uint64, unresolvedLastBlock rpc.BlockNumber, rewardPercentiles []float64) (*big.Int, [][]*big.Int, []*big.Int, []float64, []*big.Int, []float64, error) {
+func (oracle *Oracle) FeeHistory(ctx context.Context, blocks uint64, unresolvedLastBlock rpc.BlockNumber, rewardPercentiles []float64) (*big.Int, [][]*big.Int, []*big.Int, []float64, []float64, []*big.Int, []float64, error) {
if blocks < 1 {
- return common.Big0, nil, nil, nil, nil, nil, nil // returning with no data and no error means there are no retrievable blocks
+ return common.Big0, nil, nil, nil, nil, nil, nil, nil // returning with no data and no error means there are no retrievable blocks
}
maxFeeHistory := oracle.maxHeaderHistory
@@ -256,7 +312,7 @@ func (oracle *Oracle) FeeHistory(ctx context.Context, blocks uint64, unresolvedL
maxFeeHistory = oracle.maxBlockHistory
}
if len(rewardPercentiles) > maxQueryLimit {
- return common.Big0, nil, nil, nil, nil, nil, fmt.Errorf("%w: over the query limit %d", errInvalidPercentile, maxQueryLimit)
+ return common.Big0, nil, nil, nil, nil, nil, nil, fmt.Errorf("%w: over the query limit %d", errInvalidPercentile, maxQueryLimit)
}
if blocks > maxFeeHistory {
log.Warn("Sanitizing fee history length", "requested", blocks, "truncated", maxFeeHistory)
@@ -265,10 +321,10 @@ func (oracle *Oracle) FeeHistory(ctx context.Context, blocks uint64, unresolvedL
for i, p := range rewardPercentiles {
if p < 0 || p > 100 {
- return common.Big0, nil, nil, nil, nil, nil, fmt.Errorf("%w: %f", errInvalidPercentile, p)
+ return common.Big0, nil, nil, nil, nil, nil, nil, fmt.Errorf("%w: %f", errInvalidPercentile, p)
}
if i > 0 && p <= rewardPercentiles[i-1] {
- return common.Big0, nil, nil, nil, nil, nil, fmt.Errorf("%w: #%d:%f >= #%d:%f", errInvalidPercentile, i-1, rewardPercentiles[i-1], i, p)
+ return common.Big0, nil, nil, nil, nil, nil, nil, fmt.Errorf("%w: #%d:%f >= #%d:%f", errInvalidPercentile, i-1, rewardPercentiles[i-1], i, p)
}
}
@@ -280,7 +336,7 @@ func (oracle *Oracle) FeeHistory(ctx context.Context, blocks uint64, unresolvedL
pendingBlock, pendingReceipts, lastBlock, blocks, err := oracle.resolveBlockRange(ctx, unresolvedLastBlock, blocks)
if err != nil || blocks == 0 {
- return common.Big0, nil, nil, nil, nil, nil, err
+ return common.Big0, nil, nil, nil, nil, nil, nil, err
}
oldestBlock := lastBlock + 1 - blocks
@@ -342,24 +398,26 @@ func (oracle *Oracle) FeeHistory(ctx context.Context, blocks uint64, unresolvedL
}
var (
- reward = make([][]*big.Int, blocks)
- baseFee = make([]*big.Int, blocks+1)
- gasUsedRatio = make([]float64, blocks)
- blobGasUsedRatio = make([]float64, blocks)
- blobBaseFee = make([]*big.Int, blocks+1)
- firstMissing = blocks
+ reward = make([][]*big.Int, blocks)
+ baseFee = make([]*big.Int, blocks+1)
+ gasUsedRatio = make([]float64, blocks)
+ normalGasUsedRatio = make([]float64, blocks)
+ blobGasUsedRatio = make([]float64, blocks)
+ blobBaseFee = make([]*big.Int, blocks+1)
+ firstMissing = blocks
)
for ; blocks > 0; blocks-- {
fees := <-results
if fees.err != nil {
- return common.Big0, nil, nil, nil, nil, nil, fees.err
+ return common.Big0, nil, nil, nil, nil, nil, nil, fees.err
}
i := fees.blockNumber - oldestBlock
if fees.results.baseFee != nil {
reward[i], baseFee[i], baseFee[i+1], gasUsedRatio[i] = fees.results.reward, fees.results.baseFee, fees.results.nextBaseFee, fees.results.gasUsedRatio
+ normalGasUsedRatio[i] = fees.results.normalGasUsedRatio
blobGasUsedRatio[i], blobBaseFee[i], blobBaseFee[i+1] = fees.results.blobGasUsedRatio, fees.results.blobBaseFee, fees.results.nextBlobBaseFee
} else {
// getting no block and no error means we are requesting into the future (might happen because of a reorg)
@@ -370,7 +428,7 @@ func (oracle *Oracle) FeeHistory(ctx context.Context, blocks uint64, unresolvedL
}
if firstMissing == 0 {
- return common.Big0, nil, nil, nil, nil, nil, nil
+ return common.Big0, nil, nil, nil, nil, nil, nil, nil
}
if len(rewardPercentiles) != 0 {
@@ -380,6 +438,7 @@ func (oracle *Oracle) FeeHistory(ctx context.Context, blocks uint64, unresolvedL
}
baseFee, gasUsedRatio = baseFee[:firstMissing+1], gasUsedRatio[:firstMissing]
+ normalGasUsedRatio = normalGasUsedRatio[:firstMissing]
blobBaseFee, blobGasUsedRatio = blobBaseFee[:firstMissing+1], blobGasUsedRatio[:firstMissing]
- return new(big.Int).SetUint64(oldestBlock), reward, baseFee, gasUsedRatio, blobBaseFee, blobGasUsedRatio, nil
+ return new(big.Int).SetUint64(oldestBlock), reward, baseFee, gasUsedRatio, normalGasUsedRatio, blobBaseFee, blobGasUsedRatio, nil
}
diff --git a/eth/gasprice/feehistory_test.go b/eth/gasprice/feehistory_test.go
index b25d42ed02..9abb899934 100644
--- a/eth/gasprice/feehistory_test.go
+++ b/eth/gasprice/feehistory_test.go
@@ -61,7 +61,7 @@ func TestFeeHistory(t *testing.T) {
backend := newTestBackend(t, big.NewInt(16), big.NewInt(28), c.pending)
oracle := NewOracle(backend, config, nil)
- first, reward, baseFee, ratio, blobBaseFee, blobRatio, err := oracle.FeeHistory(t.Context(), c.count, c.last, c.percent)
+ first, reward, baseFee, ratio, _, blobBaseFee, blobRatio, err := oracle.FeeHistory(t.Context(), c.count, c.last, c.percent)
backend.teardown()
expReward := c.expCount
diff --git a/eth/gasprice/gasprice.go b/eth/gasprice/gasprice.go
index 4b7ef60d9b..a393267f54 100644
--- a/eth/gasprice/gasprice.go
+++ b/eth/gasprice/gasprice.go
@@ -285,7 +285,27 @@ func (oracle *Oracle) getBlockValues(ctx context.Context, blockNum uint64, limit
return
}
- signer := types.MakeSigner(oracle.backend.ChainConfig(), block.Number(), block.Time())
+ chainConfig := oracle.backend.ChainConfig()
+ signer := types.MakeSigner(chainConfig, block.Number(), block.Time())
+
+ // Reserved (fee-free) transactions carry no fee-market signal: their
+ // effective gas price is zero regardless of the tip they declared, so
+ // sampling them would pull the suggestion toward a price nobody paid.
+ // The 25 gwei ignore price below already drops declared-zero-fee
+ // transactions; this excludes fallback-fee transactions that declared a
+ // real tip but executed fee-free. The fork gate mirrors
+ // validateReservedFields: reserved header fields are only
+ // consensus-checked once the fork is active, so pre-fork header content
+ // must not influence sampling. Post-fork, GetReservedGasUsed is a single
+ // RLP decode of the header and is 0 on every block that used no reserved
+ // gas, so this stays free wherever the reserved region is inactive or
+ // idle.
+ var reservedTxHashes map[common.Hash]struct{}
+ if chainConfig.Bor != nil && chainConfig.Bor.IsReservedBlockspace(block.Number()) {
+ if reservedGasUsed := block.Header().GetReservedGasUsed(chainConfig); reservedGasUsed != nil && *reservedGasUsed > 0 {
+ reservedTxHashes = oracle.reservedTxHashes(ctx, block)
+ }
+ }
// Sort the transaction by effective tip in ascending sort.
txs := block.Transactions()
@@ -302,6 +322,12 @@ func (oracle *Oracle) getBlockValues(ctx context.Context, blockNum uint64, limit
var prices []*big.Int
for _, tx := range sortedTxs {
+ if reservedTxHashes != nil {
+ if _, ok := reservedTxHashes[tx.Hash()]; ok {
+ continue
+ }
+ }
+
tip, _ := tx.EffectiveGasTip(baseFee)
if ignoreUnder != nil && tip.Cmp(ignoreUnder) == -1 {
continue
@@ -320,3 +346,39 @@ func (oracle *Oracle) getBlockValues(ctx context.Context, blockNum uint64, limit
case <-quit:
}
}
+
+// reservedTxHashes returns the transaction hashes in block that were
+// classified reserved (fee-free) by the reserved-blockspace registry, keyed
+// by receipt.TxHash which DeriveFields populates. Receipts are the persisted
+// classification threaded in at read time (ReadReceipts / DeriveFields), so
+// EffectiveGasPrice == 0 is the exact classification for a canonical block.
+// A degraded receipt load (error, or a count that doesn't match the block's
+// transactions) returns nil rather than a partially-built set: the oracle is
+// advisory, and a wider sample beats a distorted one.
+func (oracle *Oracle) reservedTxHashes(ctx context.Context, block *types.Block) map[common.Hash]struct{} {
+ receipts, err := oracle.backend.GetReceipts(ctx, block.Hash())
+ if err != nil || len(receipts) != len(block.Transactions()) {
+ log.Debug("Gasprice oracle degraded to no reserved-tx exclusion", "block", block.NumberU64(), "err", err)
+ return nil
+ }
+
+ var reserved map[common.Hash]struct{}
+ for _, receipt := range receipts {
+ if isReservedReceipt(receipt) {
+ if reserved == nil {
+ reserved = make(map[common.Hash]struct{})
+ }
+ reserved[receipt.TxHash] = struct{}{}
+ }
+ }
+
+ return reserved
+}
+
+// isReservedReceipt reports whether a derived receipt marks its transaction
+// as included fee-free in the reserved region: read-time derivation sets
+// EffectiveGasPrice to exactly zero for reserved transactions. Pending
+// receipts are never derived, carry nil, and never match.
+func isReservedReceipt(r *types.Receipt) bool {
+ return r.EffectiveGasPrice != nil && r.EffectiveGasPrice.Sign() == 0
+}
diff --git a/eth/gasprice/reserved_test.go b/eth/gasprice/reserved_test.go
new file mode 100644
index 0000000000..b2a1a3458b
--- /dev/null
+++ b/eth/gasprice/reserved_test.go
@@ -0,0 +1,623 @@
+// Copyright 2024 The go-ethereum Authors
+// This file is part of the go-ethereum library.
+//
+// The go-ethereum library is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Lesser General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// The go-ethereum library is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Lesser General Public License for more details.
+//
+// You should have received a copy of the GNU Lesser General Public License
+// along with the go-ethereum library. If not, see .
+
+package gasprice
+
+import (
+ "context"
+ "errors"
+ "math/big"
+ "testing"
+
+ "github.com/ethereum/go-ethereum/common"
+ "github.com/ethereum/go-ethereum/core"
+ "github.com/ethereum/go-ethereum/core/state"
+ "github.com/ethereum/go-ethereum/core/types"
+ "github.com/ethereum/go-ethereum/crypto"
+ "github.com/ethereum/go-ethereum/event"
+ "github.com/ethereum/go-ethereum/params"
+ "github.com/ethereum/go-ethereum/rlp"
+ "github.com/ethereum/go-ethereum/rpc"
+)
+
+// reservedGasPriceConfig clones BorUnittestChainConfig and layers the
+// reserved-blockspace fork on top at forkBlock, satisfying
+// checkReservedBlockspaceForkOrder (Cancun and Giugliano at genesis,
+// ReservedRegistryContract inherited non-empty). Mirrors the shape of
+// core/reserved_fee_test.go's reservedTestConfig without importing core's
+// test-only helpers.
+func reservedGasPriceConfig(forkBlock *big.Int) *params.ChainConfig {
+ cc := *params.BorUnittestChainConfig
+ bor := *cc.Bor
+ cc.CancunBlock = big.NewInt(0)
+ bor.GiuglianoBlock = big.NewInt(0)
+ bor.ReservedBlockspaceBlock = forkBlock
+ cc.Bor = &bor
+ return &cc
+}
+
+// buildReservedHeader constructs a post-Cancun header (the RLP-encoded
+// BlockExtraData layout carrying the reserved-blockspace fields is only
+// active from Cancun on) with the given gas accounting and, when
+// reservedGasUsed is non-nil, the reserved-blockspace header fields. Mirrors
+// core/reserved_validation_test.go's headerWithReservedFields without
+// importing core's test-only helpers.
+func buildReservedHeader(t *testing.T, number, gasLimit, gasUsed uint64, reservedGasUsed, reservedCapacity *uint64) *types.Header {
+ t.Helper()
+
+ enc, err := rlp.EncodeToBytes(&types.BlockExtraData{TxDependency: [][]uint64{}})
+ if err != nil {
+ t.Fatalf("encode block extra data: %v", err)
+ }
+
+ extra := make([]byte, types.ExtraVanityLength)
+ extra = append(extra, enc...)
+ extra = append(extra, make([]byte, types.ExtraSealLength)...)
+
+ h := &types.Header{
+ Number: new(big.Int).SetUint64(number),
+ GasLimit: gasLimit,
+ GasUsed: gasUsed,
+ BaseFee: new(big.Int),
+ Extra: extra,
+ }
+
+ if reservedGasUsed != nil {
+ var capacity uint64
+ if reservedCapacity != nil {
+ capacity = *reservedCapacity
+ }
+ // The headers this helper builds carry the pre-Austin wire shape, so
+ // the write goes through a config without Austin scheduled.
+ if err := h.SetReservedFields(¶ms.ChainConfig{ChainID: big.NewInt(137), CancunBlock: big.NewInt(0)}, *reservedGasUsed, capacity); err != nil {
+ t.Fatalf("set reserved fields: %v", err)
+ }
+ }
+
+ return h
+}
+
+func ptrUint64(v uint64) *uint64 { return &v }
+
+// receiptsFor builds Receipts for txs via DeriveFields, marking reservedIdx
+// positions reserved (fee-free, EffectiveGasPrice 0) exactly as the
+// persisted classification does at read time.
+func receiptsFor(t *testing.T, config *params.ChainConfig, block *types.Block, txs []*types.Transaction, reservedIdx []uint64) types.Receipts {
+ t.Helper()
+
+ receipts := make(types.Receipts, len(txs))
+ var cumulative uint64
+ for i, tx := range txs {
+ cumulative += tx.Gas()
+ receipts[i] = &types.Receipt{Type: tx.Type(), CumulativeGasUsed: cumulative}
+ }
+
+ if err := receipts.DeriveFields(config, block.Hash(), block.NumberU64(), block.Time(), block.BaseFee(), nil, txs, reservedIdx); err != nil {
+ t.Fatalf("derive fields: %v", err)
+ }
+
+ return receipts
+}
+
+// fakeOracleBackend is a hand-rolled OracleBackend over synthetic data: the
+// existing testBackend in gasprice_test.go builds its chain with
+// consensus/ethash and core.GenerateChainWithGenesis, which has no hook to
+// stamp the reserved-blockspace header fields, so it cannot carry RBS
+// headers.
+type fakeOracleBackend struct {
+ config *params.ChainConfig
+ blocks map[uint64]*types.Block
+ head uint64
+ receipts map[common.Hash]types.Receipts
+ receiptsErr map[common.Hash]error
+ getReceiptsCalls int
+}
+
+func (b *fakeOracleBackend) HeaderByNumber(ctx context.Context, number rpc.BlockNumber) (*types.Header, error) {
+ block, err := b.BlockByNumber(ctx, number)
+ if block == nil {
+ return nil, err
+ }
+ return block.Header(), nil
+}
+
+func (b *fakeOracleBackend) BlockByNumber(ctx context.Context, number rpc.BlockNumber) (*types.Block, error) {
+ n := uint64(number)
+ if number == rpc.LatestBlockNumber {
+ n = b.head
+ }
+ block, ok := b.blocks[n]
+ if !ok {
+ return nil, nil
+ }
+ return block, nil
+}
+
+func (b *fakeOracleBackend) GetReceipts(ctx context.Context, hash common.Hash) (types.Receipts, error) {
+ b.getReceiptsCalls++
+ if err, ok := b.receiptsErr[hash]; ok {
+ return nil, err
+ }
+ return b.receipts[hash], nil
+}
+
+func (b *fakeOracleBackend) Pending() (*types.Block, types.Receipts, *state.StateDB) {
+ return nil, nil, nil
+}
+
+func (b *fakeOracleBackend) ChainConfig() *params.ChainConfig {
+ return b.config
+}
+
+func (b *fakeOracleBackend) SubscribeChainHeadEvent(ch chan<- core.ChainHeadEvent) event.Subscription {
+ return nil
+}
+
+// --- Case 1: SuggestTipCap excludes a fallback-fee reserved transaction. ---
+
+func TestSuggestTipCapExcludesReservedTx(t *testing.T) {
+ t.Parallel()
+
+ config := reservedGasPriceConfig(big.NewInt(1))
+ keyReserved, _ := crypto.GenerateKey()
+ keyNormal, _ := crypto.GenerateKey()
+ to := common.HexToAddress("0xaa")
+
+ buildBlock := func(includeReserved bool) (*types.Block, types.Receipts) {
+ gasUsed := uint64(21000)
+ var reservedGasUsed *uint64
+ if includeReserved {
+ reservedGasUsed = ptrUint64(21000)
+ gasUsed += 21000
+ }
+ header := buildReservedHeader(t, 1, 30_000_000, gasUsed, reservedGasUsed, nil)
+ signer := types.MakeSigner(config, header.Number, header.Time)
+
+ var txs []*types.Transaction
+ var reservedIdx []uint64
+ if includeReserved {
+ // Fallback-fee reserved tx: declares a high tip (well above the
+ // hard-enforced 25 gwei ignore price) but executed fee-free, so
+ // its receipt carries EffectiveGasPrice 0.
+ reservedTx := types.MustSignNewTx(keyReserved, signer, &types.DynamicFeeTx{
+ ChainID: config.ChainID,
+ Nonce: 0,
+ To: &to,
+ Gas: 21000,
+ GasFeeCap: big.NewInt(2000 * params.GWei),
+ GasTipCap: big.NewInt(1000 * params.GWei),
+ })
+ txs = append(txs, reservedTx)
+ reservedIdx = []uint64{0}
+ }
+ normalTx := types.MustSignNewTx(keyNormal, signer, &types.DynamicFeeTx{
+ ChainID: config.ChainID,
+ Nonce: 0,
+ To: &to,
+ Gas: 21000,
+ GasFeeCap: big.NewInt(60 * params.GWei),
+ GasTipCap: big.NewInt(30 * params.GWei),
+ })
+ txs = append(txs, normalTx)
+
+ block := types.NewBlockWithHeader(header).WithBody(types.Body{Transactions: txs})
+ receipts := receiptsFor(t, config, block, txs, reservedIdx)
+ return block, receipts
+ }
+
+ withReserved, receiptsWithReserved := buildBlock(true)
+ withoutReserved, receiptsWithoutReserved := buildBlock(false)
+
+ backendA := &fakeOracleBackend{
+ config: config,
+ blocks: map[uint64]*types.Block{1: withReserved},
+ head: 1,
+ receipts: map[common.Hash]types.Receipts{withReserved.Hash(): receiptsWithReserved},
+ }
+ backendB := &fakeOracleBackend{
+ config: config,
+ blocks: map[uint64]*types.Block{1: withoutReserved},
+ head: 1,
+ receipts: map[common.Hash]types.Receipts{withoutReserved.Hash(): receiptsWithoutReserved},
+ }
+
+ // Percentile 100 with two unfiltered values [30, 1000] gwei would pick
+ // the max (1000); this only matches the no-reserved-tx run if the
+ // reserved tx was actually excluded from the sample.
+ oracleCfg := Config{Blocks: 1, Percentile: 100}
+ oracleA := NewOracle(backendA, oracleCfg, big.NewInt(0))
+ oracleB := NewOracle(backendB, oracleCfg, big.NewInt(0))
+
+ gotA, err := oracleA.SuggestTipCap(t.Context())
+ if err != nil {
+ t.Fatalf("oracleA.SuggestTipCap: %v", err)
+ }
+ gotB, err := oracleB.SuggestTipCap(t.Context())
+ if err != nil {
+ t.Fatalf("oracleB.SuggestTipCap: %v", err)
+ }
+ if gotA.Cmp(gotB) != 0 {
+ t.Fatalf("reserved tx polluted the sample: with-reserved=%v without-reserved=%v", gotA, gotB)
+ }
+ if want := big.NewInt(30 * params.GWei); gotA.Cmp(want) != 0 {
+ t.Fatalf("suggestion = %v, want %v", gotA, want)
+ }
+}
+
+// --- Case 2: the header fast gate must not trigger GetReceipts. ---
+
+func TestGetBlockValuesFastGateSkipsReceipts(t *testing.T) {
+ t.Parallel()
+
+ config := reservedGasPriceConfig(big.NewInt(1))
+ to := common.HexToAddress("0xaa")
+
+ // preForkConfig activates the fork far above the sampled block: even a
+ // header that carries reserved fields must not trigger a receipts load
+ // pre-fork, because the fields are only consensus-checked once the fork
+ // is active.
+ preForkConfig := reservedGasPriceConfig(big.NewInt(1000))
+
+ cases := []struct {
+ name string
+ config *params.ChainConfig
+ reservedGasUsed *uint64
+ }{
+ {"absent", config, nil},
+ {"zero", config, ptrUint64(0)},
+ {"pre-fork header content ignored", preForkConfig, ptrUint64(21000)},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ header := buildReservedHeader(t, 1, 30_000_000, 21000, tc.reservedGasUsed, nil)
+ signer := types.MakeSigner(tc.config, header.Number, header.Time)
+ key, _ := crypto.GenerateKey()
+ tx := types.MustSignNewTx(key, signer, &types.DynamicFeeTx{
+ ChainID: tc.config.ChainID,
+ Nonce: 0,
+ To: &to,
+ Gas: 21000,
+ GasFeeCap: big.NewInt(60 * params.GWei),
+ GasTipCap: big.NewInt(30 * params.GWei),
+ })
+ block := types.NewBlockWithHeader(header).WithBody(types.Body{Transactions: []*types.Transaction{tx}})
+
+ // No receipts registered: if GetReceipts is ever called, the
+ // backend still returns cleanly (nil, nil), so the assertion
+ // below on the call counter is what actually pins the gate.
+ backend := &fakeOracleBackend{
+ config: tc.config,
+ blocks: map[uint64]*types.Block{1: block},
+ head: 1,
+ }
+ oracle := NewOracle(backend, Config{Blocks: 1, Percentile: 60}, big.NewInt(0))
+
+ result := make(chan results, 1)
+ quit := make(chan struct{})
+ oracle.getBlockValues(t.Context(), 1, sampleNumber, oracle.ignorePrice, result, quit)
+ res := <-result
+ if res.err != nil {
+ t.Fatalf("getBlockValues error: %v", res.err)
+ }
+ if backend.getReceiptsCalls != 0 {
+ t.Fatalf("GetReceipts called %d times, want 0", backend.getReceiptsCalls)
+ }
+ if want := big.NewInt(30 * params.GWei); len(res.values) != 1 || res.values[0].Cmp(want) != 0 {
+ t.Fatalf("values = %v, want [%v]", res.values, want)
+ }
+ })
+ }
+}
+
+// --- Case 3: an overflowed reserved transaction is sampled normally. ---
+
+func TestGetBlockValuesIncludesOverflowedReservedTx(t *testing.T) {
+ t.Parallel()
+
+ config := reservedGasPriceConfig(big.NewInt(1))
+ to := common.HexToAddress("0xaa")
+
+ header := buildReservedHeader(t, 1, 30_000_000, 42000, ptrUint64(21000), nil)
+ signer := types.MakeSigner(config, header.Number, header.Time)
+
+ keyReserved, _ := crypto.GenerateKey()
+ keyOverflow, _ := crypto.GenerateKey()
+ reservedTx := types.MustSignNewTx(keyReserved, signer, &types.DynamicFeeTx{
+ ChainID: config.ChainID, Nonce: 0, To: &to, Gas: 21000,
+ GasFeeCap: big.NewInt(2000 * params.GWei), GasTipCap: big.NewInt(1000 * params.GWei),
+ })
+ // Overflowed: sent by a reserved-registry client, but the quota was
+ // exhausted so it executed in the normal region and paid a real fee -
+ // its receipt carries a nonzero EffectiveGasPrice.
+ overflowTx := types.MustSignNewTx(keyOverflow, signer, &types.DynamicFeeTx{
+ ChainID: config.ChainID, Nonce: 0, To: &to, Gas: 21000,
+ GasFeeCap: big.NewInt(80 * params.GWei), GasTipCap: big.NewInt(40 * params.GWei),
+ })
+ txs := []*types.Transaction{reservedTx, overflowTx}
+ block := types.NewBlockWithHeader(header).WithBody(types.Body{Transactions: txs})
+ receipts := receiptsFor(t, config, block, txs, []uint64{0})
+
+ backend := &fakeOracleBackend{
+ config: config,
+ blocks: map[uint64]*types.Block{1: block},
+ head: 1,
+ receipts: map[common.Hash]types.Receipts{block.Hash(): receipts},
+ }
+ oracle := NewOracle(backend, Config{Blocks: 1, Percentile: 60}, big.NewInt(0))
+
+ result := make(chan results, 1)
+ quit := make(chan struct{})
+ oracle.getBlockValues(t.Context(), 1, sampleNumber, oracle.ignorePrice, result, quit)
+ res := <-result
+ if res.err != nil {
+ t.Fatalf("getBlockValues error: %v", res.err)
+ }
+ if want := big.NewInt(40 * params.GWei); len(res.values) != 1 || res.values[0].Cmp(want) != 0 {
+ t.Fatalf("values = %v, want [%v] (overflowed tx only)", res.values, want)
+ }
+}
+
+// --- Case 4: a receipts error or count mismatch degrades to no-exclusion. ---
+
+func TestGetBlockValuesDegradesOnReceiptProblems(t *testing.T) {
+ t.Parallel()
+
+ config := reservedGasPriceConfig(big.NewInt(1))
+ to := common.HexToAddress("0xaa")
+
+ header := buildReservedHeader(t, 1, 30_000_000, 42000, ptrUint64(21000), nil)
+ signer := types.MakeSigner(config, header.Number, header.Time)
+
+ keyReserved, _ := crypto.GenerateKey()
+ keyNormal, _ := crypto.GenerateKey()
+ reservedTx := types.MustSignNewTx(keyReserved, signer, &types.DynamicFeeTx{
+ ChainID: config.ChainID, Nonce: 0, To: &to, Gas: 21000,
+ GasFeeCap: big.NewInt(2000 * params.GWei), GasTipCap: big.NewInt(1000 * params.GWei),
+ })
+ normalTx := types.MustSignNewTx(keyNormal, signer, &types.DynamicFeeTx{
+ ChainID: config.ChainID, Nonce: 0, To: &to, Gas: 21000,
+ GasFeeCap: big.NewInt(60 * params.GWei), GasTipCap: big.NewInt(30 * params.GWei),
+ })
+ txs := []*types.Transaction{reservedTx, normalTx}
+ block := types.NewBlockWithHeader(header).WithBody(types.Body{Transactions: txs})
+
+ assertUnfiltered := func(t *testing.T, backend *fakeOracleBackend) {
+ t.Helper()
+ oracle := NewOracle(backend, Config{Blocks: 1, Percentile: 60}, big.NewInt(0))
+ result := make(chan results, 1)
+ quit := make(chan struct{})
+ oracle.getBlockValues(t.Context(), 1, sampleNumber, oracle.ignorePrice, result, quit)
+ res := <-result
+ if res.err != nil {
+ t.Fatalf("getBlockValues error: %v", res.err)
+ }
+ // Degraded to no-exclusion: both the reserved and the normal tip
+ // are sampled, ascending, matching the fully unfiltered set.
+ want := []int64{30, 1000}
+ if len(res.values) != len(want) {
+ t.Fatalf("values = %v, want %d entries", res.values, len(want))
+ }
+ for i, w := range want {
+ if wantVal := big.NewInt(w * params.GWei); res.values[i].Cmp(wantVal) != 0 {
+ t.Fatalf("values[%d] = %v, want %v", i, res.values[i], wantVal)
+ }
+ }
+ }
+
+ t.Run("GetReceipts error", func(t *testing.T) {
+ t.Parallel()
+ backend := &fakeOracleBackend{
+ config: config,
+ blocks: map[uint64]*types.Block{1: block},
+ head: 1,
+ receiptsErr: map[common.Hash]error{block.Hash(): errors.New("boom")},
+ }
+ assertUnfiltered(t, backend)
+ })
+
+ t.Run("receipt count mismatch", func(t *testing.T) {
+ t.Parallel()
+ // One short of len(txs); even though the single receipt present
+ // looks reserved, the count check must short-circuit before it is
+ // ever examined.
+ receipts := types.Receipts{
+ {Type: reservedTx.Type(), CumulativeGasUsed: 21000, EffectiveGasPrice: new(big.Int)},
+ }
+ backend := &fakeOracleBackend{
+ config: config,
+ blocks: map[uint64]*types.Block{1: block},
+ head: 1,
+ receipts: map[common.Hash]types.Receipts{block.Hash(): receipts},
+ }
+ assertUnfiltered(t, backend)
+ })
+}
+
+// --- Case 5: FeeHistory's normalGasUsedRatio. ---
+
+func TestProcessBlockNormalGasUsedRatio(t *testing.T) {
+ t.Parallel()
+
+ config := reservedGasPriceConfig(big.NewInt(10))
+ backend := &fakeOracleBackend{config: config}
+ oracle := NewOracle(backend, Config{Blocks: 1, Percentile: 60}, big.NewInt(0))
+
+ t.Run("pre-fork equals gasUsedRatio", func(t *testing.T) {
+ header := &types.Header{Number: big.NewInt(1), GasLimit: 1_000_000, GasUsed: 600_000, BaseFee: new(big.Int)}
+ bf := &blockFees{blockNumber: 1, header: header}
+ oracle.processBlock(bf, nil)
+
+ if bf.results.gasUsedRatio != 0.6 {
+ t.Fatalf("gasUsedRatio = %v, want 0.6", bf.results.gasUsedRatio)
+ }
+ if bf.results.normalGasUsedRatio != bf.results.gasUsedRatio {
+ t.Fatalf("normalGasUsedRatio = %v, want gasUsedRatio %v", bf.results.normalGasUsedRatio, bf.results.gasUsedRatio)
+ }
+ })
+
+ t.Run("post-fork matches the formula", func(t *testing.T) {
+ header := buildReservedHeader(t, 10, 1_000_000, 600_000, ptrUint64(200_000), nil)
+ bf := &blockFees{blockNumber: 10, header: header}
+ oracle.processBlock(bf, nil)
+
+ if bf.results.gasUsedRatio != 0.6 {
+ t.Fatalf("gasUsedRatio = %v, want 0.6", bf.results.gasUsedRatio)
+ }
+ const want = 0.5 // (600_000-200_000) / (1_000_000-200_000)
+ if bf.results.normalGasUsedRatio != want {
+ t.Fatalf("normalGasUsedRatio = %v, want %v", bf.results.normalGasUsedRatio, want)
+ }
+ })
+
+ t.Run("reserved gas equal to gas limit reports 0", func(t *testing.T) {
+ header := buildReservedHeader(t, 10, 1_000_000, 1_000_000, ptrUint64(1_000_000), nil)
+ bf := &blockFees{blockNumber: 10, header: header}
+ oracle.processBlock(bf, nil)
+
+ if bf.results.normalGasUsedRatio != 0.0 {
+ t.Fatalf("normalGasUsedRatio = %v, want 0", bf.results.normalGasUsedRatio)
+ }
+ })
+}
+
+// --- Case 6: FeeHistory rewards exclude reserved transactions. ---
+
+func TestProcessBlockRewardExcludesReserved(t *testing.T) {
+ t.Parallel()
+
+ config := reservedGasPriceConfig(big.NewInt(1))
+ backend := &fakeOracleBackend{config: config}
+ oracle := NewOracle(backend, Config{Blocks: 1, Percentile: 60}, big.NewInt(0))
+
+ keyReserved, _ := crypto.GenerateKey()
+ keyNormal, _ := crypto.GenerateKey()
+ to := common.HexToAddress("0xaa")
+
+ t.Run("excludes reserved and reduces the threshold base", func(t *testing.T) {
+ header := buildReservedHeader(t, 1, 1_000_000, 200_000, ptrUint64(100_000), nil)
+ signer := types.MakeSigner(config, header.Number, header.Time)
+ reservedTx := types.MustSignNewTx(keyReserved, signer, &types.DynamicFeeTx{
+ ChainID: config.ChainID, Nonce: 0, To: &to, Gas: 100_000,
+ GasFeeCap: big.NewInt(2000 * params.GWei), GasTipCap: big.NewInt(1000 * params.GWei),
+ })
+ normalTx := types.MustSignNewTx(keyNormal, signer, &types.DynamicFeeTx{
+ ChainID: config.ChainID, Nonce: 0, To: &to, Gas: 100_000,
+ GasFeeCap: big.NewInt(80 * params.GWei), GasTipCap: big.NewInt(40 * params.GWei),
+ })
+ txs := []*types.Transaction{reservedTx, normalTx}
+ block := types.NewBlockWithHeader(header).WithBody(types.Body{Transactions: txs})
+ receipts := receiptsFor(t, config, block, txs, []uint64{0})
+
+ bf := &blockFees{blockNumber: 1, header: block.Header(), block: block, receipts: receipts}
+ oracle.processBlock(bf, []float64{100})
+
+ if len(bf.results.reward) != 1 {
+ t.Fatalf("reward length = %d, want 1", len(bf.results.reward))
+ }
+ if want := big.NewInt(40 * params.GWei); bf.results.reward[0].Cmp(want) != 0 {
+ t.Fatalf("reward = %v, want %v (the normal tx only)", bf.results.reward[0], want)
+ }
+ })
+
+ t.Run("all reserved returns the all-zero row", func(t *testing.T) {
+ header := buildReservedHeader(t, 1, 1_000_000, 200_000, ptrUint64(200_000), nil)
+ signer := types.MakeSigner(config, header.Number, header.Time)
+ tx0 := types.MustSignNewTx(keyReserved, signer, &types.DynamicFeeTx{
+ ChainID: config.ChainID, Nonce: 0, To: &to, Gas: 100_000,
+ GasFeeCap: big.NewInt(2000 * params.GWei), GasTipCap: big.NewInt(1000 * params.GWei),
+ })
+ tx1 := types.MustSignNewTx(keyNormal, signer, &types.DynamicFeeTx{
+ ChainID: config.ChainID, Nonce: 0, To: &to, Gas: 100_000,
+ GasFeeCap: big.NewInt(80 * params.GWei), GasTipCap: big.NewInt(40 * params.GWei),
+ })
+ txs := []*types.Transaction{tx0, tx1}
+ block := types.NewBlockWithHeader(header).WithBody(types.Body{Transactions: txs})
+ receipts := receiptsFor(t, config, block, txs, []uint64{0, 1})
+
+ bf := &blockFees{blockNumber: 1, header: block.Header(), block: block, receipts: receipts}
+ oracle.processBlock(bf, []float64{50})
+
+ if len(bf.results.reward) != 1 || bf.results.reward[0].Sign() != 0 {
+ t.Fatalf("reward = %v, want a single all-zero entry", bf.results.reward)
+ }
+ })
+}
+
+// --- Case 7: nil EffectiveGasPrice (pending shape) is treated as not-reserved. ---
+
+func TestNilEffectiveGasPriceTreatedAsNotReserved(t *testing.T) {
+ t.Parallel()
+
+ config := reservedGasPriceConfig(big.NewInt(1))
+ header := buildReservedHeader(t, 1, 1_000_000, 200_000, ptrUint64(100_000), nil)
+ signer := types.MakeSigner(config, header.Number, header.Time)
+ to := common.HexToAddress("0xaa")
+
+ keyA, _ := crypto.GenerateKey()
+ keyB, _ := crypto.GenerateKey()
+ txA := types.MustSignNewTx(keyA, signer, &types.DynamicFeeTx{
+ ChainID: config.ChainID, Nonce: 0, To: &to, Gas: 100_000,
+ GasFeeCap: big.NewInt(2000 * params.GWei), GasTipCap: big.NewInt(1000 * params.GWei),
+ })
+ txB := types.MustSignNewTx(keyB, signer, &types.DynamicFeeTx{
+ ChainID: config.ChainID, Nonce: 0, To: &to, Gas: 100_000,
+ GasFeeCap: big.NewInt(80 * params.GWei), GasTipCap: big.NewInt(40 * params.GWei),
+ })
+ txs := []*types.Transaction{txA, txB}
+ block := types.NewBlockWithHeader(header).WithBody(types.Body{Transactions: txs})
+
+ // Pending-shape receipts: never derived, so EffectiveGasPrice stays nil
+ // for every transaction, exactly as the miner leaves them.
+ receipts := types.Receipts{
+ {Type: txA.Type(), TxHash: txA.Hash(), GasUsed: 100_000},
+ {Type: txB.Type(), TxHash: txB.Hash(), GasUsed: 100_000},
+ }
+
+ backend := &fakeOracleBackend{
+ config: config,
+ blocks: map[uint64]*types.Block{1: block},
+ head: 1,
+ receipts: map[common.Hash]types.Receipts{block.Hash(): receipts},
+ }
+ oracle := NewOracle(backend, Config{Blocks: 1, Percentile: 100}, big.NewInt(0))
+
+ t.Run("sampling", func(t *testing.T) {
+ result := make(chan results, 1)
+ quit := make(chan struct{})
+ oracle.getBlockValues(t.Context(), 1, sampleNumber, oracle.ignorePrice, result, quit)
+ res := <-result
+ if res.err != nil {
+ t.Fatalf("getBlockValues error: %v", res.err)
+ }
+ if len(res.values) != 2 {
+ t.Fatalf("values = %v, want both transactions sampled", res.values)
+ }
+ })
+
+ t.Run("reward", func(t *testing.T) {
+ bf := &blockFees{blockNumber: 1, header: header, block: block, receipts: receipts}
+ oracle.processBlock(bf, []float64{100})
+ // Neither tx excluded: the percentile walk covers both, landing on
+ // txA's 1000 gwei reward. If txA had wrongly been treated as
+ // reserved, only txB (40 gwei) would remain.
+ if want := big.NewInt(1000 * params.GWei); bf.results.reward[0].Cmp(want) != 0 {
+ t.Fatalf("reward = %v, want %v", bf.results.reward[0], want)
+ }
+ })
+}
diff --git a/internal/cli/server/config.go b/internal/cli/server/config.go
index 820a379eb7..ff3da6c2b2 100644
--- a/internal/cli/server/config.go
+++ b/internal/cli/server/config.go
@@ -379,6 +379,10 @@ type TxPoolConfig struct {
// GlobalQueueis the maximum number of non-executable transaction slots for all accounts
GlobalQueue uint64 `hcl:"globalqueue,optional" toml:"globalqueue,optional"`
+ // ReservedMaxOccupancyPercent bounds the percentage of GlobalSlots+GlobalQueue that
+ // reserved-blockspace senders may occupy in aggregate, combined across pending and queued
+ ReservedMaxOccupancyPercent uint64 `hcl:"reservedmaxoccupancypercent,optional" toml:"reservedmaxoccupancypercent,optional"`
+
// lifetime is the maximum amount of time non-executable transaction are queued
LifeTime time.Duration `hcl:"-,optional" toml:"-"`
LifeTimeRaw string `hcl:"lifetime,optional" toml:"lifetime,optional"`
@@ -906,21 +910,22 @@ func DefaultConfig() *Config {
WSAddress: "",
},
TxPool: &TxPoolConfig{
- Locals: []string{},
- NoLocals: false,
- Journal: "transactions.rlp",
- Rejournal: 1 * time.Hour,
- PriceLimit: params.BorDefaultTxPoolPriceLimit, // bor's default
- PriceBump: 10,
- AccountSlots: 16,
- GlobalSlots: 131072,
- AccountQueue: 64,
- GlobalQueue: 131072,
- LifeTime: 3 * time.Hour,
- Rebroadcast: true,
- RebroadcastInterval: 30 * time.Second,
- RebroadcastMaxAge: 10 * time.Minute,
- RebroadcastBatchSize: 200,
+ Locals: []string{},
+ NoLocals: false,
+ Journal: "transactions.rlp",
+ Rejournal: 1 * time.Hour,
+ PriceLimit: params.BorDefaultTxPoolPriceLimit, // bor's default
+ PriceBump: 10,
+ AccountSlots: 16,
+ GlobalSlots: 131072,
+ AccountQueue: 64,
+ GlobalQueue: 131072,
+ ReservedMaxOccupancyPercent: 50,
+ LifeTime: 3 * time.Hour,
+ Rebroadcast: true,
+ RebroadcastInterval: 30 * time.Second,
+ RebroadcastMaxAge: 10 * time.Minute,
+ RebroadcastBatchSize: 200,
},
Sealer: &SealerConfig{
Enabled: false,
@@ -1296,6 +1301,7 @@ func (c *Config) buildEth(stack *node.Node, accountManager *accounts.Manager) (*
n.TxPool.GlobalSlots = c.TxPool.GlobalSlots
n.TxPool.AccountQueue = c.TxPool.AccountQueue
n.TxPool.GlobalQueue = c.TxPool.GlobalQueue
+ n.TxPool.ReservedMaxOccupancyPercent = c.TxPool.ReservedMaxOccupancyPercent
n.TxPool.Lifetime = c.TxPool.LifeTime
// Load filtered addresses during config initialization
diff --git a/internal/cli/server/flags.go b/internal/cli/server/flags.go
index 04dc260c3f..669202cdcd 100644
--- a/internal/cli/server/flags.go
+++ b/internal/cli/server/flags.go
@@ -317,6 +317,13 @@ func (c *Command) Flags(config *Config) *flagset.Flagset {
Default: c.cliConfig.TxPool.GlobalQueue,
Group: "Transaction Pool",
})
+ f.Uint64Flag(&flagset.Uint64Flag{
+ Name: "txpool.reservedmaxoccupancypercent",
+ Usage: "Percentage of globalslots+globalqueue that reserved-blockspace senders may occupy in aggregate",
+ Value: &c.cliConfig.TxPool.ReservedMaxOccupancyPercent,
+ Default: c.cliConfig.TxPool.ReservedMaxOccupancyPercent,
+ Group: "Transaction Pool",
+ })
f.DurationFlag(&flagset.DurationFlag{
Name: "txpool.lifetime",
Usage: "Maximum amount of time non-executable transaction are queued",
diff --git a/internal/ethapi/api.go b/internal/ethapi/api.go
index c3a1af2479..0a8722fa80 100644
--- a/internal/ethapi/api.go
+++ b/internal/ethapi/api.go
@@ -161,17 +161,18 @@ func (api *EthereumAPI) MaxPriorityFeePerGas(ctx context.Context) (*hexutil.Big,
}
type feeHistoryResult struct {
- OldestBlock *hexutil.Big `json:"oldestBlock"`
- Reward [][]*hexutil.Big `json:"reward,omitempty"`
- BaseFee []*hexutil.Big `json:"baseFeePerGas,omitempty"`
- GasUsedRatio []float64 `json:"gasUsedRatio"`
- BlobBaseFee []*hexutil.Big `json:"baseFeePerBlobGas,omitempty"`
- BlobGasUsedRatio []float64 `json:"blobGasUsedRatio,omitempty"`
+ OldestBlock *hexutil.Big `json:"oldestBlock"`
+ Reward [][]*hexutil.Big `json:"reward,omitempty"`
+ BaseFee []*hexutil.Big `json:"baseFeePerGas,omitempty"`
+ GasUsedRatio []float64 `json:"gasUsedRatio"`
+ NormalGasUsedRatio []float64 `json:"normalGasUsedRatio,omitempty"`
+ BlobBaseFee []*hexutil.Big `json:"baseFeePerBlobGas,omitempty"`
+ BlobGasUsedRatio []float64 `json:"blobGasUsedRatio,omitempty"`
}
// FeeHistory returns the fee market history.
func (api *EthereumAPI) FeeHistory(ctx context.Context, blockCount math.HexOrDecimal64, lastBlock rpc.BlockNumber, rewardPercentiles []float64) (*feeHistoryResult, error) {
- oldest, reward, baseFee, gasUsed, blobBaseFee, blobGasUsed, err := api.b.FeeHistory(ctx, uint64(blockCount), lastBlock, rewardPercentiles)
+ oldest, reward, baseFee, gasUsed, normalGasUsed, blobBaseFee, blobGasUsed, err := api.b.FeeHistory(ctx, uint64(blockCount), lastBlock, rewardPercentiles)
if err != nil {
return nil, err
}
@@ -196,6 +197,9 @@ func (api *EthereumAPI) FeeHistory(ctx context.Context, blockCount math.HexOrDec
results.BaseFee[i] = (*hexutil.Big)(v)
}
}
+ if normalGasUsed != nil {
+ results.NormalGasUsedRatio = normalGasUsed
+ }
if blobBaseFee != nil {
results.BlobBaseFee = make([]*hexutil.Big, len(blobBaseFee))
for i, v := range blobBaseFee {
@@ -1298,8 +1302,13 @@ func RPCMarshalBlock(block *types.Block, inclTx bool, fullTx bool, config *param
return tx.Hash()
}
if fullTx {
+ // Read the block's reserved-tx index list once and reuse it as an
+ // O(1) membership set, instead of one DB read (and RLP decode) per
+ // transaction below.
+ reservedSet := reservedTxIndexSet(db, config, block.Hash(), block.NumberU64(), len(block.Transactions()))
formatTx = func(idx int, tx *types.Transaction) interface{} {
- return newRPCTransactionFromBlockIndex(block, uint64(idx), config, db)
+ _, reserved := reservedSet[uint64(idx)]
+ return newRPCTransactionFromBlockIndex(block, uint64(idx), config, db, reserved)
}
}
@@ -1402,9 +1411,55 @@ type RPCTransaction struct {
YParity *hexutil.Uint64 `json:"yParity,omitempty"`
}
+// reservedBlockspaceActive reports whether the reserved-blockspace fork is
+// live at number, the fork-gate every reserved-tx side-table lookup below
+// applies before touching the DB: an unconditional read here would be pure
+// overhead on every block/tx fetched on a chain (or in a block range) where
+// the fork never activated.
+func reservedBlockspaceActive(config *params.ChainConfig, number uint64) bool {
+ return config.Bor != nil && config.Bor.IsReservedBlockspace(new(big.Int).SetUint64(number))
+}
+
+// reservedTxIndexSet reads a block's reserved-tx index side table once and
+// returns it as a membership set, bounded against txCount, so per-transaction
+// marshaling can do an O(1) lookup instead of repeating the DB read and RLP
+// decode for every transaction in the block. A nil db (some callers pass one
+// for tests/tools) disables the lookup rather than panicking.
+func reservedTxIndexSet(db ethdb.Reader, config *params.ChainConfig, hash common.Hash, number uint64, txCount int) map[uint64]struct{} {
+ if db == nil || !reservedBlockspaceActive(config, number) {
+ return nil
+ }
+ indexes := rawdb.ReadReservedTxIndexesBounded(db, hash, number, txCount)
+ if len(indexes) == 0 {
+ return nil
+ }
+ set := make(map[uint64]struct{}, len(indexes))
+ for _, idx := range indexes {
+ set[idx] = struct{}{}
+ }
+ return set
+}
+
+// reservedTxAtIndex reports whether the transaction at position idx in the
+// block (hash, number) is in the reserved-tx side table, for single-
+// transaction endpoints that need one lookup rather than a whole block's
+// membership set. The membership check itself (sortedness-dependent binary
+// search) is owned by rawdb.IsReservedTxIndex, not reimplemented here.
+func reservedTxAtIndex(db ethdb.Reader, config *params.ChainConfig, hash common.Hash, number, idx uint64) bool {
+ if db == nil || !reservedBlockspaceActive(config, number) {
+ return false
+ }
+ return rawdb.IsReservedTxIndex(db, hash, number, idx)
+}
+
// newRPCTransaction returns a transaction that will serialize to the RPC
// representation, with the given location metadata set (if available).
-func newRPCTransaction(tx *types.Transaction, blockHash common.Hash, blockNumber uint64, blockTime uint64, index uint64, baseFee *big.Int, config *params.ChainConfig) *RPCTransaction {
+// reserved marks tx as classified reserved (fee-free) by the
+// reserved-blockspace registry; when true and the tx is included, GasPrice is
+// reported as 0 regardless of type, since that is what the sender actually
+// paid (GasFeeCap/GasTipCap are left as submitted - they are tx fields, not
+// the paid price).
+func newRPCTransaction(tx *types.Transaction, blockHash common.Hash, blockNumber uint64, blockTime uint64, index uint64, baseFee *big.Int, config *params.ChainConfig, reserved bool) *RPCTransaction {
signer := types.MakeSigner(config, new(big.Int).SetUint64(blockNumber), blockTime)
from, _ := types.Sender(signer, tx)
v, r, s := tx.RawSignatureValues()
@@ -1499,6 +1554,10 @@ func newRPCTransaction(tx *types.Transaction, blockHash common.Hash, blockNumber
result.GasTipCap = (*hexutil.Big)(tx.GasTipCap())
}
+ if reserved && blockHash != (common.Hash{}) {
+ result.GasPrice = (*hexutil.Big)(new(big.Int))
+ }
+
return result
}
@@ -1526,29 +1585,33 @@ func NewRPCPendingTransaction(tx *types.Transaction, current *types.Header, conf
blockNumber = current.Number.Uint64()
blockTime = current.Time
}
- return newRPCTransaction(tx, common.Hash{}, blockNumber, blockTime, 0, baseFee, config)
+ return newRPCTransaction(tx, common.Hash{}, blockNumber, blockTime, 0, baseFee, config, false)
}
-// newRPCTransactionFromBlockIndex returns a transaction that will serialize to the RPC representation.
-func newRPCTransactionFromBlockIndex(b *types.Block, index uint64, config *params.ChainConfig, db ethdb.Database) *RPCTransaction {
+// newRPCTransactionFromBlockIndex returns a transaction that will serialize to
+// the RPC representation. reserved is the caller-supplied reserved-tx
+// classification for index; it is ignored for the synthetic bor state-sync
+// transaction (append beyond realTxCount below), which is never reserved.
+func newRPCTransactionFromBlockIndex(b *types.Block, index uint64, config *params.ChainConfig, db ethdb.Database, reserved bool) *RPCTransaction {
txs := b.Transactions()
+ realTxCount := uint64(len(txs))
// State-sync transaction is part of block body post Madhugiri HF so skip fetching it separately.
if config.Bor != nil && config.Bor.IsMadhugiri(b.Number()) {
- if index >= uint64(len(txs)) {
+ if index >= realTxCount {
return nil
}
- return newRPCTransaction(txs[index], b.Hash(), b.NumberU64(), b.Time(), index, b.BaseFee(), config)
+ return newRPCTransaction(txs[index], b.Hash(), b.NumberU64(), b.Time(), index, b.BaseFee(), config, reserved)
}
- if index >= uint64(len(txs)+1) {
+ if index >= realTxCount+1 {
return nil
}
var borReceipt *types.Receipt
// Read bor receipts if a state-sync transaction is requested
- if index == uint64(len(txs)) {
+ if index == realTxCount {
borReceipt = rawdb.ReadBorReceipt(db, b.Hash(), b.NumberU64(), config)
if borReceipt != nil {
if borReceipt.TxHash != (common.Hash{}) {
@@ -1565,7 +1628,7 @@ func newRPCTransactionFromBlockIndex(b *types.Block, index uint64, config *param
return nil
}
- rpcTx := newRPCTransaction(txs[index], b.Hash(), b.NumberU64(), b.Time(), index, b.BaseFee(), config)
+ rpcTx := newRPCTransaction(txs[index], b.Hash(), b.NumberU64(), b.Time(), index, b.BaseFee(), config, reserved && index < realTxCount)
// If the transaction is a bor transaction, we need to set the hash to the derived bor tx hash. BorTx is always the last index.
if borReceipt != nil && index == uint64(len(txs)-1) {
@@ -1862,7 +1925,8 @@ func (api *TransactionAPI) GetBlockTransactionCountByHash(ctx context.Context, b
func (api *TransactionAPI) GetTransactionByBlockNumberAndIndex(ctx context.Context, blockNr rpc.BlockNumber, index hexutil.Uint) (*RPCTransaction, error) {
block, err := api.b.BlockByNumber(ctx, blockNr)
if block != nil {
- return newRPCTransactionFromBlockIndex(block, uint64(index), api.b.ChainConfig(), api.b.ChainDb()), nil
+ reserved := reservedTxAtIndex(api.b.ChainDb(), api.b.ChainConfig(), block.Hash(), block.NumberU64(), uint64(index))
+ return newRPCTransactionFromBlockIndex(block, uint64(index), api.b.ChainConfig(), api.b.ChainDb(), reserved), nil
}
return nil, err
@@ -1872,7 +1936,8 @@ func (api *TransactionAPI) GetTransactionByBlockNumberAndIndex(ctx context.Conte
func (api *TransactionAPI) GetTransactionByBlockHashAndIndex(ctx context.Context, blockHash common.Hash, index hexutil.Uint) (*RPCTransaction, error) {
block, err := api.b.BlockByHash(ctx, blockHash)
if block != nil {
- return newRPCTransactionFromBlockIndex(block, uint64(index), api.b.ChainConfig(), api.b.ChainDb()), nil
+ reserved := reservedTxAtIndex(api.b.ChainDb(), api.b.ChainConfig(), block.Hash(), block.NumberU64(), uint64(index))
+ return newRPCTransactionFromBlockIndex(block, uint64(index), api.b.ChainConfig(), api.b.ChainDb(), reserved), nil
}
return nil, err
@@ -1935,7 +2000,9 @@ func (api *TransactionAPI) GetTransactionByHash(ctx context.Context, hash common
return nil, err
}
- resultTx := newRPCTransaction(tx, blockHash, blockNumber, header.Time, index, header.BaseFee, api.b.ChainConfig())
+ // The bor state-sync (pseudo) transaction is never reserved.
+ reserved := !borTx && reservedTxAtIndex(api.b.ChainDb(), api.b.ChainConfig(), blockHash, blockNumber, index)
+ resultTx := newRPCTransaction(tx, blockHash, blockNumber, header.Time, index, header.BaseFee, api.b.ChainConfig(), reserved)
// Skip handling state-sync tx separately post Madhugiri HF
if api.b.ChainConfig().Bor != nil && api.b.ChainConfig().Bor.IsMadhugiri(header.Number) {
diff --git a/internal/ethapi/api_test.go b/internal/ethapi/api_test.go
index 119751bbd8..c1cf99e123 100644
--- a/internal/ethapi/api_test.go
+++ b/internal/ethapi/api_test.go
@@ -84,7 +84,7 @@ func testTransactionMarshal(t *testing.T, tests []txData, config *params.ChainCo
}
// rpcTransaction
- rpcTx := newRPCTransaction(tx, common.Hash{}, 0, 0, 0, nil, config)
+ rpcTx := newRPCTransaction(tx, common.Hash{}, 0, 0, 0, nil, config, false)
if data, err := json.Marshal(rpcTx); err != nil {
t.Fatalf("test %d: marshalling failed; %v", i, err)
} else if err = tx2.UnmarshalJSON(data); err != nil {
@@ -542,8 +542,8 @@ func (b testBackend) SyncProgress(ctx context.Context) ethereum.SyncProgress {
func (b testBackend) SuggestGasTipCap(ctx context.Context) (*big.Int, error) {
return big.NewInt(0), nil
}
-func (b testBackend) FeeHistory(ctx context.Context, blockCount uint64, lastBlock rpc.BlockNumber, rewardPercentiles []float64) (*big.Int, [][]*big.Int, []*big.Int, []float64, []*big.Int, []float64, error) {
- return nil, nil, nil, nil, nil, nil, nil
+func (b testBackend) FeeHistory(ctx context.Context, blockCount uint64, lastBlock rpc.BlockNumber, rewardPercentiles []float64) (*big.Int, [][]*big.Int, []*big.Int, []float64, []float64, []*big.Int, []float64, error) {
+ return nil, nil, nil, nil, nil, nil, nil, nil
}
func (b testBackend) BlobBaseFee(ctx context.Context) *big.Int { return new(big.Int) }
func (b testBackend) BaseFee(ctx context.Context) *big.Int { return new(big.Int) }
diff --git a/internal/ethapi/backend.go b/internal/ethapi/backend.go
index aec925dec1..b6bf4aa3f3 100644
--- a/internal/ethapi/backend.go
+++ b/internal/ethapi/backend.go
@@ -50,7 +50,7 @@ type Backend interface {
ProtocolVersion() uint
SuggestGasTipCap(ctx context.Context) (*big.Int, error)
- FeeHistory(ctx context.Context, blockCount uint64, lastBlock rpc.BlockNumber, rewardPercentiles []float64) (*big.Int, [][]*big.Int, []*big.Int, []float64, []*big.Int, []float64, error)
+ FeeHistory(ctx context.Context, blockCount uint64, lastBlock rpc.BlockNumber, rewardPercentiles []float64) (*big.Int, [][]*big.Int, []*big.Int, []float64, []float64, []*big.Int, []float64, error)
BlobBaseFee(ctx context.Context) *big.Int
BaseFee(ctx context.Context) *big.Int
ChainDb() ethdb.Database
diff --git a/internal/ethapi/bor_api.go b/internal/ethapi/bor_api.go
index 6a3f829bb2..4e8df6acab 100644
--- a/internal/ethapi/bor_api.go
+++ b/internal/ethapi/bor_api.go
@@ -117,7 +117,8 @@ func (api *BlockChainAPI) appendRPCMarshalBorTransaction(ctx context.Context, bl
formattedTxs := fields["transactions"].([]interface{})
if fullTx {
- marshalledTx := newRPCTransaction(borTx, blockHash, blockNumber, block.Time(), txIndex, block.BaseFee(), api.b.ChainConfig())
+ // The bor state-sync (pseudo) transaction is never reserved.
+ marshalledTx := newRPCTransaction(borTx, blockHash, blockNumber, block.Time(), txIndex, block.BaseFee(), api.b.ChainConfig(), false)
// newRPCTransaction calculates hash based on RLP of the transaction data.
// In the case of bor block tx, we need simple derived tx hash (same as function argument) instead of RLP hash
marshalledTx.Hash = txHash
@@ -343,6 +344,8 @@ type RPCBlockExtraData struct {
GasTarget *hexutil.Uint64 `json:"gasTarget"`
BaseFeeChangeDenominator *hexutil.Uint64 `json:"baseFeeChangeDenominator"`
TxDependency [][]uint64 `json:"txDependency"`
+ ReservedGasUsed *hexutil.Uint64 `json:"reservedGasUsed"`
+ ReservedCapacity *hexutil.Uint64 `json:"reservedCapacity"`
}
// marshalBlockExtraData decodes the BlockExtraData from a header's Extra field
@@ -365,6 +368,16 @@ func marshalBlockExtraData(header *types.Header, chainConfig *params.ChainConfig
result.BaseFeeChangeDenominator = &d
}
+ if bed.ReservedGasUsed != nil {
+ rg := hexutil.Uint64(*bed.ReservedGasUsed)
+ result.ReservedGasUsed = &rg
+ }
+
+ if bed.ReservedCapacity != nil {
+ rc := hexutil.Uint64(*bed.ReservedCapacity)
+ result.ReservedCapacity = &rc
+ }
+
return result
}
@@ -383,6 +396,8 @@ func appendBorExtraData(response map[string]interface{}, block *types.Block, bor
type BlockGasParamsResult struct {
GasTarget *hexutil.Uint64 `json:"gasTarget"`
BaseFeeChangeDenominator *hexutil.Uint64 `json:"baseFeeChangeDenominator"`
+ ReservedGasUsed *hexutil.Uint64 `json:"reservedGasUsed"`
+ ReservedCapacity *hexutil.Uint64 `json:"reservedCapacity"`
}
// GetBlockGasParams returns the EIP-1559 gas target and base fee change denominator
@@ -408,7 +423,9 @@ func (api *BorAPI) GetBlockGasParams(ctx context.Context, blockNrOrHash rpc.Bloc
return nil, fmt.Errorf("header not found")
}
- gasTarget, bfcd := header.GetBaseFeeParams(api.b.ChainConfig())
+ chainConfig := api.b.ChainConfig()
+ gasTarget, bfcd := header.GetBaseFeeParams(chainConfig)
+ reservedGasUsed, reservedCapacity := header.GetReservedFields(chainConfig)
result := &BlockGasParamsResult{}
if gasTarget != nil {
@@ -419,6 +436,14 @@ func (api *BorAPI) GetBlockGasParams(ctx context.Context, blockNrOrHash rpc.Bloc
d := hexutil.Uint64(*bfcd)
result.BaseFeeChangeDenominator = &d
}
+ if reservedGasUsed != nil {
+ rg := hexutil.Uint64(*reservedGasUsed)
+ result.ReservedGasUsed = &rg
+ }
+ if reservedCapacity != nil {
+ rc := hexutil.Uint64(*reservedCapacity)
+ result.ReservedCapacity = &rc
+ }
return result, nil
}
diff --git a/internal/ethapi/transaction_args_test.go b/internal/ethapi/transaction_args_test.go
index 62b3380c23..f5f03a98b1 100644
--- a/internal/ethapi/transaction_args_test.go
+++ b/internal/ethapi/transaction_args_test.go
@@ -342,8 +342,8 @@ func (b *backendMock) ChainConfig() *params.ChainConfig { return b.config }
func (b *backendMock) SyncProgress(ctx context.Context) ethereum.SyncProgress {
return ethereum.SyncProgress{}
}
-func (b *backendMock) FeeHistory(ctx context.Context, blockCount uint64, lastBlock rpc.BlockNumber, rewardPercentiles []float64) (*big.Int, [][]*big.Int, []*big.Int, []float64, []*big.Int, []float64, error) {
- return nil, nil, nil, nil, nil, nil, nil
+func (b *backendMock) FeeHistory(ctx context.Context, blockCount uint64, lastBlock rpc.BlockNumber, rewardPercentiles []float64) (*big.Int, [][]*big.Int, []*big.Int, []float64, []float64, []*big.Int, []float64, error) {
+ return nil, nil, nil, nil, nil, nil, nil, nil
}
func (b *backendMock) ChainDb() ethdb.Database { return nil }
func (b *backendMock) AccountManager() *accounts.Manager { return nil }
diff --git a/miner/pipeline.go b/miner/pipeline.go
index 2abc9382b1..da0847c583 100644
--- a/miner/pipeline.go
+++ b/miner/pipeline.go
@@ -12,6 +12,7 @@ import (
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/consensus"
"github.com/ethereum/go-ethereum/consensus/bor"
+ "github.com/ethereum/go-ethereum/consensus/bor/registryreader"
"github.com/ethereum/go-ethereum/consensus/misc/eip1559"
"github.com/ethereum/go-ethereum/core"
"github.com/ethereum/go-ethereum/core/state"
@@ -87,6 +88,13 @@ func (w *worker) isPipelineEligible(_ uint64) bool {
// reservedBlockspaceBlock scheduled, integrate the reserved pass or gate
// eligibility on !IsReservedBlockspace(nextBlockNumber).
//
+ // Witness completeness: this path executes on a witness-less state and
+ // lets SRC build the shipped witness from the FlatDiff read surface
+ // alone. Isolated system-call reads (span, state-sync id, reserved
+ // registry) reach that surface via state.ReadIsolated's PropagateReadsTo;
+ // verify that invariant still holds before re-enabling, or stateless
+ // verifiers fail on those reads at every sprint boundary.
+ //
// if !w.config.EnablePipelinedSRC {
// return false
// }
@@ -956,7 +964,14 @@ func (s *specSession) sealCurrentAndAdvance(finalSpecHeader *types.Header, state
if exit := s.waitForParentAnnounceTime(finalSpecHeader, next.fillDone); exit {
return nil, true, false
}
- sealedBlock, dbWriteDone, err := s.w.inlineSealAndBroadcast(blockSpec, receiptsSpec, s.specState, witnessSpec, s.curBuildStart)
+ // Reserved data is re-derived from the final body with the same walk a
+ // verifier runs, mirroring commit(). The speculative fill has no reserved
+ // sequencing yet (see isPipelineEligible's re-enable reference), so these
+ // are empty until that integration lands; deriving them here keeps the
+ // write path correct either way.
+ reservedTxIndexes := core.ReservedTxIndexes(blockSpec.Transactions(), s.specEnv.signer, reservedTxsOf(s.specEnv))
+ _, reservedClientUsage := registryreader.ClassifyReserved(blockSpec.Transactions(), s.specEnv.signer, reservedSnapshotOf(s.specEnv))
+ sealedBlock, dbWriteDone, err := s.w.inlineSealAndBroadcast(blockSpec, receiptsSpec, s.specState, reservedTxIndexes, reservedClientUsage, witnessSpec, s.curBuildStart)
if err != nil {
log.Error("Pipelined SRC: inline seal failed", "block", s.nextBlockNumber, "err", err)
<-next.fillDone
@@ -1125,7 +1140,7 @@ func (w *worker) earlyAnnounceTime(header *types.Header) time.Time {
// Uses emitHeadEvent=false to avoid a deadlock: mainLoop is blocked in
// commitSpeculativeWork, so chainHeadFeed.Send would eventually block when
// newWorkLoop's channel fills up.
-func (w *worker) inlineSealAndBroadcast(block *types.Block, receipts []*types.Receipt, statedb *state.StateDB, witnessBytes []byte, buildStart time.Time) (*types.Block, chan struct{}, error) {
+func (w *worker) inlineSealAndBroadcast(block *types.Block, receipts []*types.Receipt, statedb *state.StateDB, reservedTxIndexes []uint64, reservedClientUsage map[uint64]registryreader.ClientUsage, witnessBytes []byte, buildStart time.Time) (*types.Block, chan struct{}, error) {
sealedBlock, err := w.sealViaPrivateChannel(block)
if err != nil {
return nil, nil, err
@@ -1152,7 +1167,7 @@ func (w *worker) inlineSealAndBroadcast(block *types.Block, receipts []*types.Re
go func() {
defer close(writeDone)
writeStart := time.Now()
- _, err := w.chain.WriteBlockAndSetHeadPipelined(sealedBlock, sealedReceipts, logs, statedb, false, witnessBytes)
+ _, err := w.chain.WriteBlockAndSetHeadPipelined(sealedBlock, sealedReceipts, logs, statedb, reservedTxIndexes, reservedClientUsage, false, witnessBytes)
writeBlockAndSetHeadTimer.UpdateSince(writeStart)
if err != nil {
log.Error("Pipelined SRC: async DB write failed", "block", sealedBlock.Number(), "err", err)
diff --git a/miner/pipeline_session_test.go b/miner/pipeline_session_test.go
index a2a9cb15f3..efaad69fb5 100644
--- a/miner/pipeline_session_test.go
+++ b/miner/pipeline_session_test.go
@@ -3,6 +3,7 @@ package miner
import (
"errors"
"math/big"
+ "sync"
"sync/atomic"
"testing"
"time"
@@ -20,6 +21,18 @@ import (
"github.com/ethereum/go-ethereum/params"
)
+// stopWorkerOnces tracks workers stopped mid-test so the fixture cleanup does
+// not close the same worker a second time (worker.close does not support it).
+var stopWorkerOnces sync.Map // *worker -> *sync.Once
+
+// stopWorker shuts down the worker's background goroutines and closes exitCh.
+// Tests must call it before reassigning worker fields those goroutines read
+// (engine, speculativeWorkCh): reassigning them on a live worker is a data race.
+func stopWorker(w *worker) {
+ once, _ := stopWorkerOnces.LoadOrStore(w, new(sync.Once))
+ once.(*sync.Once).Do(w.close)
+}
+
func newPipelineWorkerFixture(t *testing.T, configure func(*params.ChainConfig)) (*worker, *testWorkerBackend) {
t.Helper()
@@ -35,8 +48,8 @@ func newPipelineWorkerFixture(t *testing.T, configure func(*params.ChainConfig))
t.Cleanup(ctrl.Finish)
t.Cleanup(func() { require.NoError(t, engine.Close()) })
- w, backend, cleanup := newTestWorker(t, DefaultTestConfig(), &chainConfig, engine, rawdb.NewMemoryDatabase(), false, 0)
- t.Cleanup(cleanup)
+ w, backend, _ := newTestWorker(t, DefaultTestConfig(), &chainConfig, engine, rawdb.NewMemoryDatabase(), false, 0)
+ t.Cleanup(func() { stopWorker(w) })
return w, backend
}
@@ -116,6 +129,7 @@ func TestPipelineSessionFailureAndExitBranches(t *testing.T) {
t.Run("non Bor engine rejects pipeline operations", func(t *testing.T) {
w, _, session := newPipelineSessionFixture(t, nil)
<-session.initialFillDone
+ stopWorker(w)
wrapped := &pipelineSealEngine{Engine: w.engine, seal: func(consensus.ChainHeaderReader, *types.Block, *stateless.Witness, chan<- *consensus.NewSealedBlockEvent, <-chan struct{}) error {
return nil
}}
@@ -279,6 +293,7 @@ func TestPipelineSessionAdditionalRecoveryBranches(t *testing.T) {
t.Run("inline broadcast returns seal error", func(t *testing.T) {
w, _, _ := newPipelineRequestFixture(t, nil)
+ stopWorker(w)
sealErr := errors.New("seal failed")
w.engine = &pipelineSealEngine{
Engine: w.engine,
@@ -287,7 +302,7 @@ func TestPipelineSessionAdditionalRecoveryBranches(t *testing.T) {
},
}
block := types.NewBlockWithHeader(&types.Header{Number: big.NewInt(1)})
- sealed, done, err := w.inlineSealAndBroadcast(block, nil, nil, nil, time.Now())
+ sealed, done, err := w.inlineSealAndBroadcast(block, nil, nil, nil, nil, nil, time.Now())
require.ErrorIs(t, err, sealErr)
require.Nil(t, sealed)
require.Nil(t, done)
@@ -317,13 +332,11 @@ func TestCommitPipelinedAdditionalBranches(t *testing.T) {
t.Run("worker exit cancels handoff", func(t *testing.T) {
w, _, req := newPipelineRequestFixture(t, nil)
+ // stopWorker closes exitCh; the unbuffered channel forces the handoff
+ // select onto that exit branch.
+ stopWorker(w)
w.running.Store(true)
- originalExit := w.exitCh
- stopped := make(chan struct{})
- close(stopped)
- w.exitCh = stopped
w.speculativeWorkCh = make(chan *speculativeWorkReq)
- t.Cleanup(func() { w.exitCh = originalExit })
require.NoError(t, w.commitPipelined(req.blockNEnv, time.Now()))
})
@@ -342,11 +355,7 @@ func TestSpecSessionMoreFailureBranches(t *testing.T) {
t.Run("initial setup requires grandparent", func(t *testing.T) {
w, _, req := newPipelineRequestFixture(t, nil)
req.parentHeader.ParentHash = common.HexToHash("0xdead")
- originalExit := w.exitCh
- stopped := make(chan struct{})
- close(stopped)
- w.exitCh = stopped
- t.Cleanup(func() { w.exitCh = originalExit })
+ stopWorker(w)
require.False(t, newSpecSession(w, req).setupInitial())
})
@@ -396,6 +405,7 @@ func TestSpecSessionMoreFailureBranches(t *testing.T) {
finalHeader, flatDiff, syncData, ok := session.finalizeCurrent()
require.True(t, ok)
+ stopWorker(w)
prepareErr := errors.New("prepare failed")
w.engine = &pipelinePrepareEngine{
Engine: w.engine,
@@ -425,11 +435,7 @@ func TestSealCurrentAndAdvanceExitAndSealFailure(t *testing.T) {
t.Run("worker exit interrupts announce wait", func(t *testing.T) {
w, session, finalHeader, syncData, next := newPreparedSession(t)
finalHeader.ActualTime = time.Now().Add(time.Hour)
- originalExit := w.exitCh
- stopped := make(chan struct{})
- close(stopped)
- w.exitCh = stopped
- t.Cleanup(func() { w.exitCh = originalExit })
+ stopWorker(w)
sealed, exitEarly, ok := session.sealCurrentAndAdvance(finalHeader, syncData, next)
require.False(t, ok)
@@ -439,6 +445,7 @@ func TestSealCurrentAndAdvanceExitAndSealFailure(t *testing.T) {
t.Run("inline seal error stops iteration", func(t *testing.T) {
w, session, finalHeader, syncData, next := newPreparedSession(t)
+ stopWorker(w)
sealErr := errors.New("seal failed")
w.engine = &pipelineSealEngine{
Engine: w.engine,
diff --git a/miner/pipeline_test.go b/miner/pipeline_test.go
index 17944fd373..e80a7dc8a1 100644
--- a/miner/pipeline_test.go
+++ b/miner/pipeline_test.go
@@ -310,8 +310,7 @@ func TestPipelineTimingAndChainHelpers(t *testing.T) {
defer ctrl.Finish()
defer borEngine.(*bor.Bor).Close()
- w, backend, cleanup := newTestWorker(t, DefaultTestConfig(), &chainConfig, borEngine, rawdb.NewMemoryDatabase(), false, 0)
- defer cleanup()
+ backend := newTestWorkerBackend(t, &chainConfig, borEngine, rawdb.NewMemoryDatabase())
parent := backend.chain.CurrentHeader()
wrappedEngine := &pipelineSealEngine{
@@ -320,7 +319,15 @@ func TestPipelineTimingAndChainHelpers(t *testing.T) {
return nil
},
}
- w.engine = wrappedEngine
+ // The helpers under test only read chain, engine, and exitCh. A bare
+ // worker has no background goroutines, so it can be built directly with
+ // the wrapped engine (swapping the engine on a live worker is a data race).
+ w := &worker{
+ chainConfig: &chainConfig,
+ engine: wrappedEngine,
+ chain: backend.chain,
+ exitCh: make(chan struct{}),
+ }
t.Run("early announce uses parent time", func(t *testing.T) {
header := &types.Header{
diff --git a/miner/reserved_test.go b/miner/reserved_test.go
index d5703ea04f..7a9ab56e37 100644
--- a/miner/reserved_test.go
+++ b/miner/reserved_test.go
@@ -675,6 +675,87 @@ func TestReservedBuild_HeaderGasUsed(t *testing.T) {
require.Equal(t, got.GasUsed(), *reserved, "all txs are reserved, so reserved gas equals block gas used")
}
+// TestReservedBuild_HeaderCapacity confirms the producer stamps the sequencing
+// snapshot's EFFECTIVE capacity (Σ quotas of the classified client set) into
+// the header, not the registry's raw total. newTestSnapshot's raw-capacity
+// argument is set to a value far from the client's quota specifically so a
+// producer bug that stamped the raw total instead would be caught here.
+func TestReservedBuild_HeaderCapacity(t *testing.T) {
+ chainConfig := borUnittestReservedConfig()
+ engine, ctrl := getFakeBorFromConfig(t, &chainConfig)
+ defer engine.Close()
+ defer ctrl.Finish()
+
+ w, b, _ := newTestWorker(t, DefaultTestConfig(), &chainConfig, engine, rawdb.NewMemoryDatabase(), true, 0)
+ defer w.close()
+
+ const rawCapacity = 999_000_000
+ const clientQuota = 10_000_000
+ w.setReservedSnapshot(newTestSnapshot(rawCapacity, []testClient{
+ {ID: 1, Senders: []common.Address{testBankAddress}, QuotaGas: clientQuota},
+ }))
+
+ sub := w.mux.Subscribe(core.NewMinedBlockEvent{})
+ defer sub.Unsubscribe()
+
+ errs := b.txPool.Add([]*types.Transaction{
+ b.newRandomTxWithNonce(false, 0),
+ }, false)
+ for _, err := range errs {
+ require.NoError(t, err)
+ }
+ w.start()
+
+ got := waitForBlockWithTxs(t, sub, 1, 15*time.Second)
+
+ capacity := got.Header().GetReservedCapacity(&chainConfig)
+ require.NotNil(t, capacity, "reserved pass active: header must carry ReservedCapacity")
+ require.Equal(t, uint64(clientQuota), *capacity,
+ "capacity must be the snapshot's effective capacity (Σ quotas), not the raw registry total")
+}
+
+// TestReservedBuild_NoTxsHeaderCapacity pins the noTxs (eth/catalyst
+// Engine-API payload) build path: generateWork's noTxs branch skips
+// fillTransactions entirely, so writeReservedFields must still run there —
+// otherwise the header would keep Prepare's placeholder ReservedCapacity=0,
+// which fails validateReservedFields as soon as the registry carves out any
+// capacity, even for a genuinely empty payload with nothing to disagree about.
+func TestReservedBuild_NoTxsHeaderCapacity(t *testing.T) {
+ chainConfig := borUnittestReservedConfig()
+ engine, ctrl := getFakeBorFromConfig(t, &chainConfig)
+ defer engine.Close()
+ defer ctrl.Finish()
+
+ w, b, _ := newTestWorker(t, DefaultTestConfig(), &chainConfig, engine, rawdb.NewMemoryDatabase(), false, 0)
+ defer w.close()
+
+ const clientQuota = uint64(10_000_000)
+ w.setReservedSnapshot(newTestSnapshot(0, []testClient{
+ {ID: 1, Senders: []common.Address{testBankAddress}, QuotaGas: clientQuota},
+ }))
+
+ parent := b.chain.CurrentBlock()
+ r := w.getSealingBlock(&generateParams{
+ parentHash: parent.Hash(),
+ timestamp: parent.Time + 1,
+ coinbase: testBankAddress,
+ forceTime: true,
+ noTxs: true,
+ })
+ require.NoError(t, r.err)
+ require.NotNil(t, r.block)
+ require.Empty(t, r.block.Transactions(), "this is the no-tx payload build path")
+
+ capacity := r.block.Header().GetReservedCapacity(&chainConfig)
+ require.NotNil(t, capacity, "noTxs build must still carry ReservedCapacity")
+ require.Equal(t, clientQuota, *capacity,
+ "capacity must be the snapshot's effective capacity even when fillTransactions never ran")
+
+ gasUsed := r.block.Header().GetReservedGasUsed(&chainConfig)
+ require.NotNil(t, gasUsed, "noTxs build must still carry ReservedGasUsed")
+ require.Equal(t, uint64(0), *gasUsed, "no transactions were included")
+}
+
// TestReservedBuild_HeaderAbsentPreFork pins wire compatibility: before the
// ReservedBlockspace fork, post-Cancun blocks must not carry the
// ReservedGasUsed field at all — their Extra encoding stays byte-identical to
@@ -698,6 +779,7 @@ func TestReservedBuild_HeaderAbsentPreFork(t *testing.T) {
got := waitForBlockWithTxs(t, sub, 1, 15*time.Second)
require.Nil(t, got.Header().GetReservedGasUsed(&chainConfig), "pre-fork: field must be absent from the wire")
+ require.Nil(t, got.Header().GetReservedCapacity(&chainConfig), "pre-fork: field must be absent from the wire")
}
// TestReservedBuild_Positional proves the reserved-first builder preference
@@ -800,3 +882,41 @@ func TestReservedBuild_Overflow(t *testing.T) {
// Both are included in the block.
require.Len(t, got.Transactions(), 2, "both txs included (1 reserved + 1 normal overflow)")
}
+
+// TestReservedBuild_PersistsReservedTxIndexes locks in the miner-sealed write
+// path for the reserved-tx side table: commit() reads the reserved set off
+// the pre-copy environment (environment.copy() does not carry the evm field
+// over), never off the copy it and both of its callers pass around, or every
+// miner-sealed block would silently lose its reserved classification for the
+// on-disk side table regardless of what state_transition executed.
+func TestReservedBuild_PersistsReservedTxIndexes(t *testing.T) {
+ chainConfig := borUnittestReservedConfig()
+ engine, ctrl := getFakeBorFromConfig(t, &chainConfig)
+ defer engine.Close()
+ defer ctrl.Finish()
+
+ db := rawdb.NewMemoryDatabase()
+ w, b, _ := newTestWorker(t, DefaultTestConfig(), &chainConfig, engine, db, true, 0)
+ defer w.close()
+
+ w.setReservedSnapshot(newTestSnapshot(0, []testClient{
+ {ID: 1, Senders: []common.Address{testBankAddress}, QuotaGas: 10_000_000},
+ }))
+
+ sub := w.mux.Subscribe(core.NewMinedBlockEvent{})
+ defer sub.Unsubscribe()
+
+ errs := b.txPool.Add([]*types.Transaction{
+ b.newRandomTxWithNonce(false, 0),
+ b.newRandomTxWithNonce(false, 1),
+ }, false)
+ for _, err := range errs {
+ require.NoError(t, err)
+ }
+ w.start()
+
+ got := waitForBlockWithTxs(t, sub, 2, 15*time.Second)
+
+ indexes := rawdb.ReadReservedTxIndexes(db, got.Hash(), got.NumberU64())
+ require.Equal(t, []uint64{0, 1}, indexes, "both txs are from the sole reserved sender and must be recorded")
+}
diff --git a/miner/sequencing.go b/miner/sequencing.go
index 806979c159..8634387030 100644
--- a/miner/sequencing.go
+++ b/miner/sequencing.go
@@ -16,6 +16,9 @@ var (
// reservedGasUsedGauge records each build's reserved-region gas total
// (the value written into BlockExtraData.ReservedGasUsed).
reservedGasUsedGauge = metrics.NewRegisteredGauge("worker/reserved/gasused", nil)
+ // reservedCapacityGauge records each build's effective reserved capacity
+ // (the value written into BlockExtraData.ReservedCapacity).
+ reservedCapacityGauge = metrics.NewRegisteredGauge("worker/reserved/capacity", nil)
// reservedOverflowMeter counts reserved-eligible transactions diverted to
// the normal pass because they exceeded their client's per-client quota.
reservedOverflowMeter = metrics.NewRegisteredMeter("worker/reserved/overflow", nil)
diff --git a/miner/worker.go b/miner/worker.go
index 1839caa020..f1c1362404 100644
--- a/miner/worker.go
+++ b/miner/worker.go
@@ -383,8 +383,19 @@ type task struct {
productionStart time.Time // wall clock at build begin — used for worker/build_to_announce (fires from resultLoop at mux.Post)
productionElapsed time.Duration // elapsed from after prepareWork to task submission (excludes sealing wait); used for workerMgaspsTimer and workerBlockExecutionTimer
intermediateRootTime time.Duration // time spent in IntermediateRoot inside FinalizeAndAssemble; subtracted when computing workerBlockExecutionTimer
- pipelined bool // If true, state was already committed by SRC goroutine — skip CommitWithUpdate in writeBlockWithState
- witnessBytes []byte // RLP-encoded witness from SRC goroutine (for pipelined blocks)
+ // reservedTxIndexes lists positions within block.Transactions() classified
+ // reserved (fee-free), strictly ascending. Persisted alongside receipts by
+ // the result loop so reads report the correct effective gas price for
+ // reserved transactions.
+ reservedTxIndexes []uint64
+ // reservedClientUsage is the block's per-client reserved-region usage,
+ // re-derived from the final body with the same walk a verifier runs, so
+ // the producer's chain/reserved client gauges match what every importing
+ // node reports for this block.
+ reservedClientUsage map[uint64]registryreader.ClientUsage
+
+ pipelined bool // If true, state was already committed by SRC goroutine — skip CommitWithUpdate in writeBlockWithState
+ witnessBytes []byte // RLP-encoded witness from SRC goroutine (for pipelined blocks)
}
// stateSyncReserveFor returns the block-size budget to hold back for the state-sync
@@ -1564,9 +1575,9 @@ func emitExecutionMetrics(task *task) {
// path. Returns the write status for parity with the original inline call.
func (w *worker) writeTaskBlock(task *task, block *types.Block, receipts []*types.Receipt, logs []*types.Log) (core.WriteStatus, error) {
if task.pipelined {
- return w.chain.WriteBlockAndSetHeadPipelined(block, receipts, logs, task.state, true, task.witnessBytes)
+ return w.chain.WriteBlockAndSetHeadPipelined(block, receipts, logs, task.state, task.reservedTxIndexes, task.reservedClientUsage, true, task.witnessBytes)
}
- return w.chain.WriteBlockAndSetHead(block, receipts, logs, task.state, true)
+ return w.chain.WriteBlockAndSetHead(block, receipts, logs, task.state, task.reservedTxIndexes, task.reservedClientUsage, true)
}
// emitCommitMetrics reports the task's post-write statedb timers, mgas/s,
@@ -1737,7 +1748,7 @@ func markReservedTentative(env *environment, walk *registryreader.ReservedWalk,
// commitReserved advances the reserved walk for a just-committed tx. Reserved
// txs count toward the header's ReservedGasUsed by actual gas used; the verifier
-// recomputes the same sum from the block (validateReservedGasUsed).
+// recomputes the same sum from the block (validateReservedFields).
func commitReserved(env *environment, walk *registryreader.ReservedWalk, from common.Address, tx *types.Transaction, reserved bool) {
walk.Commit(from, tx.Gas(), reserved)
if !reserved {
@@ -2455,7 +2466,7 @@ func (w *worker) fillTransactions(interrupt *atomic.Int32, env *environment, gen
// Record reserved gas even when a commit pass was interrupted: callers
// seal the partial block, and its header must account for the reserved
// transactions that did commit.
- if err := w.writeReservedGasUsed(env); err != nil {
+ if err := w.writeReservedFields(env); err != nil {
return err
}
@@ -2492,20 +2503,25 @@ func (w *worker) sequencingSnapshot(env *environment) *registryreader.Snapshot {
return env.evm.Context.ReservedSnapshot
}
-// writeReservedGasUsed records the build's reserved-region gas total in the
-// header's BlockExtraData, overwriting the placeholder zero that Prepare
-// stamped. Post-fork headers must carry the field (verifyReservedFields), so
-// it is written on every post-fork build — including interrupted ones, whose
-// partial blocks still seal.
-func (w *worker) writeReservedGasUsed(env *environment) error {
+// writeReservedFields records the build's reserved-region gas total and the
+// sequencing snapshot's effective capacity in the header's BlockExtraData,
+// overwriting the placeholder zeros that Prepare stamped. Post-fork headers
+// must carry both fields (verifyReservedFields), so this runs on every
+// post-fork build — including interrupted ones, whose partial blocks still
+// seal. The capacity comes from the same snapshot sequencing and execution
+// classified with, so a producer can never stamp a value execution disagrees
+// with.
+func (w *worker) writeReservedFields(env *environment) error {
if w.chainConfig.Bor == nil || !w.chainConfig.Bor.IsReservedBlockspace(env.header.Number) {
return nil
}
+ capacity := w.sequencingSnapshot(env).EffectiveCapacity()
reservedGasUsedGauge.Update(int64(env.reservedGasUsed))
+ reservedCapacityGauge.Update(int64(capacity))
- if err := env.header.SetReservedGasUsed(w.chainConfig, env.reservedGasUsed); err != nil {
- log.Error("error while writing reserved gas used into block extra data", "err", err)
+ if err := env.header.SetReservedFields(w.chainConfig, env.reservedGasUsed, capacity); err != nil {
+ log.Error("error while writing reserved fields into block extra data", "err", err)
return err
}
@@ -2532,6 +2548,13 @@ func (w *worker) generateWork(params *generateParams, witness bool) *newPayloadR
if errors.Is(err, errBlockInterruptedByTimeout) {
log.Warn("Block building is interrupted", "allowance", common.PrettyDuration(w.newpayloadTimeout))
}
+ } else {
+ // fillTransactions (which stamps the reserved header fields at the
+ // end of a normal build) is skipped for a no-tx payload build, so
+ // without this the header would keep Prepare's placeholder
+ // ReservedCapacity — wrong as soon as the registry carves out any
+ // capacity, even for a genuinely empty block.
+ _ = w.writeReservedFields(work)
}
body := types.Body{Transactions: work.txs, Withdrawals: params.withdrawals}
@@ -2684,7 +2707,7 @@ func (w *worker) submitForSealing(work *environment, start time.Time, genParams
_ = w.commitPipelined(work, start)
return
}
- _ = w.commit(work.copy(), w.fullTaskHook, true, start, genParams)
+ _ = w.commit(work.copy(), w.fullTaskHook, true, start, genParams, reservedTxsOf(work), reservedSnapshotOf(work))
}
func (w *worker) buildAndCommitBlock(interrupt *atomic.Int32, noempty bool, genParams *generateParams, interruptPrefetch *atomic.Bool) {
@@ -2766,7 +2789,13 @@ func (w *worker) buildAndCommitBlock(interrupt *atomic.Int32, noempty bool, genP
if !noempty && !w.noempty.Load() && !isRio {
emptyWork := work.copy()
emptyWork.state.ResetPrefetcher()
- _ = w.commit(emptyWork, nil, false, start, genParams)
+ // This copy is taken before fillTransactions runs, so it never gets
+ // fillTransactions's end-of-build reserved-field write; stamp them
+ // here too. The placeholder ReservedCapacity Prepare set is only
+ // correct while the registry carves out zero capacity. The reserved
+ // set is read from the pre-copy env: copy() does not carry evm over.
+ _ = w.writeReservedFields(emptyWork)
+ _ = w.commit(emptyWork, nil, false, start, genParams, reservedTxsOf(work), reservedSnapshotOf(work))
}
// Mark the start of full-block building. Set after the optional empty pre-seal commit so that
// productionElapsed for the full block does not include empty-block overhead.
@@ -2816,6 +2845,7 @@ func (w *worker) buildAndCommitBlock(interrupt *atomic.Int32, noempty bool, genP
work.discard()
return
}
+ // Submit the generated block for consensus sealing.
w.submitForSealing(work, start, genParams)
// Swap out the old work with the new one, terminating any leftover
@@ -3245,11 +3275,36 @@ func createInterruptTimer(number uint64, actualTimestamp time.Time, interruptBlo
return cancel
}
+// reservedTxsOf reads the reserved-region tx set off env's evm block context,
+// nil-safe for an environment that never had one attached (e.g. pre-fork, or
+// a shape that was never wired one up).
+func reservedTxsOf(env *environment) map[registryreader.ReservedKey]struct{} {
+ if env.evm == nil {
+ return nil
+ }
+ return env.evm.Context.ReservedTxs
+}
+
+// reservedSnapshotOf returns the env's registry snapshot, or nil when the
+// build has no reserved-aware EVM context.
+func reservedSnapshotOf(env *environment) *registryreader.Snapshot {
+ if env.evm == nil {
+ return nil
+ }
+ return env.evm.Context.ReservedSnapshot
+}
+
// commit runs any post-transaction state modifications, assembles the final block
// and commits new work if consensus engine is running.
// Note the assumption is held that the mutation is allowed to the passed env, do
// the deep copy first.
-func (w *worker) commit(env *environment, interval func(), update bool, start time.Time, genParams *generateParams) error {
+//
+// reservedTxs is the build's reserved-region tx set (keyed by sender+nonce)
+// and reservedSnap the registry snapshot that classified it, both read by the
+// caller off the pre-copy environment's evm.Context: env.copy() (below, and
+// at both call sites before this function even sees env) does not carry the
+// evm field over, so neither is recoverable from env itself here.
+func (w *worker) commit(env *environment, interval func(), update bool, start time.Time, genParams *generateParams, reservedTxs map[registryreader.ReservedKey]struct{}, reservedSnap *registryreader.Snapshot) error {
// Track total block building time and report metrics at the end of the commit cycle.
defer func() {
// Update total commit timer (matches the "elapsed" time in log)
@@ -3336,8 +3391,14 @@ func (w *worker) commit(env *environment, interval func(), update bool, start ti
return err
}
+ reservedTxIndexes := core.ReservedTxIndexes(block.Transactions(), env.signer, reservedTxs)
+ // Per-client usage is re-derived from the final body with the same
+ // walk a verifier runs, so the producer's chain/reserved client
+ // gauges report the identical numbers every importing node derives.
+ _, reservedClientUsage := registryreader.ClassifyReserved(block.Transactions(), env.signer, reservedSnap)
+
select {
- case w.taskCh <- &task{receipts: env.receipts, state: env.state, block: block, createdAt: time.Now(), productionStart: firstNonZeroTime(productionStartFrom(genParams), start), productionElapsed: time.Since(firstNonZeroTime(productionStartFrom(genParams), start)), intermediateRootTime: commitTime}:
+ case w.taskCh <- &task{receipts: env.receipts, state: env.state, block: block, createdAt: time.Now(), productionStart: firstNonZeroTime(productionStartFrom(genParams), start), productionElapsed: time.Since(firstNonZeroTime(productionStartFrom(genParams), start)), intermediateRootTime: commitTime, reservedTxIndexes: reservedTxIndexes, reservedClientUsage: reservedClientUsage}:
fees := totalFees(block, env.receipts)
feesInEther := new(big.Float).Quo(new(big.Float).SetInt(fees), big.NewFloat(params.Ether))
log.Info("Commit new sealing work",
diff --git a/params/config.go b/params/config.go
index 016b2b40a9..c8ffa0dbee 100644
--- a/params/config.go
+++ b/params/config.go
@@ -991,20 +991,6 @@ type BorConfig struct {
AustinBlock *big.Int `json:"austinBlock"` // Austin switch block (nil = no fork, 0 = already on austin)
HampiBlock *big.Int `json:"hampiBlock"` // Hampi switch block (nil = no fork, 0 = already on hampi)
ReservedBlockspaceBlock *big.Int `json:"reservedBlockspaceBlock"` // ReservedBlockspace switch block (nil = no fork, 0 = already on reservedBlockspace)
-
- // ReservedClients feeds only the EIP-1559 base-fee capacity carve-out
- // (ReservedCapacity), since CalcBaseFee is pure (config, parent) and has no
- // parent state to read the registry from. Reserved-sender classification is
- // sourced from the registry contract, not this field; the two are kept
- // consistent until the capacity arrives via a producer-stamped header field.
- ReservedClients []ReservedClient `json:"reservedClients,omitempty"`
-}
-
-// ReservedClient is one reserved-blockspace client in the config-backed stub
-// registry: whitelisted sender addresses sharing a single per-block gas quota.
-type ReservedClient struct {
- Addresses []common.Address `json:"addresses"`
- QuotaGas uint64 `json:"quotaGas"`
}
// String implements the stringer interface, returning the consensus engine details.
@@ -1101,16 +1087,6 @@ func (c *BorConfig) IsReservedBlockspace(number *big.Int) bool {
return isBlockForked(c.ReservedBlockspaceBlock, number)
}
-// ReservedCapacity returns the sum of all reserved clients' per-block gas
-// quotas — the capacity removed from the EIP-1559 normal region.
-func (c *BorConfig) ReservedCapacity() uint64 {
- var total uint64
- for i := range c.ReservedClients {
- total += c.ReservedClients[i].QuotaGas
- }
- return total
-}
-
// GetTargetGasPercentage returns the target gas percentage for gas limit calculation.
// After Lisovo hard fork, this value can be configured via CLI flags (stored in BorConfig at runtime).
// It validates the configured value and falls back to defaults if invalid or nil.
@@ -1687,17 +1663,18 @@ func (c *ChainConfig) CheckConfigForkOrder() error {
}
// checkReservedBlockspaceForkOrder enforces the rollout preconditions for the
-// reserved-blockspace fork. The producer writes the reserved header fields only
-// in the post-Cancun BlockExtraData format, while verifyHeader requires them on
-// every post-fork block — so activating reserved blockspace at or before Cancun
-// would make block production unverifiable at the boundary. The reserved set
-// also has no source without the registry contract configured.
+// reserved-blockspace fork. The producer writes the reserved header fields —
+// ReservedGasUsed and ReservedCapacity — only in the post-Cancun BlockExtraData
+// format, while verifyHeader requires both of them on every post-fork block —
+// so activating reserved blockspace at or before Cancun would make block
+// production unverifiable at the boundary. The reserved set also has no
+// source without the registry contract configured.
//
// Reserved blockspace must also not activate before Giugliano: ReservedGasUsed
-// is a later rlp:"optional" field of BlockExtraData than Giugliano's GasTarget
-// and BaseFeeChangeDenominator, so stamping ReservedGasUsed while those are
-// still nil forces them onto the wire as non-nil zero, corrupting the base-fee
-// params a peer decodes.
+// and ReservedCapacity are later rlp:"optional" fields of BlockExtraData than
+// Giugliano's GasTarget and BaseFeeChangeDenominator, so stamping them while
+// those are still nil forces them onto the wire as non-nil zero, corrupting
+// the base-fee params a peer decodes.
func (c *ChainConfig) checkReservedBlockspaceForkOrder() error {
if c.Bor == nil || c.Bor.ReservedBlockspaceBlock == nil {
return nil
diff --git a/params/reserved_test.go b/params/reserved_test.go
index 5c84723ba3..7588b12439 100644
--- a/params/reserved_test.go
+++ b/params/reserved_test.go
@@ -4,8 +4,6 @@ import (
"math/big"
"strings"
"testing"
-
- "github.com/ethereum/go-ethereum/common"
)
func TestIsReservedBlockspace(t *testing.T) {
@@ -29,32 +27,6 @@ func TestIsReservedBlockspace(t *testing.T) {
}
}
-// TestReservedCapacity covers the only live use of the ReservedClients config
-// stub: the EIP-1559 base-fee capacity carve-out (Σ per-client quotas).
-// Reserved-sender classification is sourced from the registry, not this config.
-func TestReservedCapacity(t *testing.T) {
- t.Parallel()
-
- a := common.HexToAddress("0x00000000000000000000000000000000000000Aa")
- b := common.HexToAddress("0x00000000000000000000000000000000000000Bb")
- c := common.HexToAddress("0x00000000000000000000000000000000000000Cc")
-
- cfg := &BorConfig{
- ReservedClients: []ReservedClient{
- {Addresses: []common.Address{a, b}, QuotaGas: 20_000_000},
- {Addresses: []common.Address{c}, QuotaGas: 10_000_000},
- },
- }
- if got := cfg.ReservedCapacity(); got != 30_000_000 {
- t.Errorf("capacity: got %d want 30000000", got)
- }
-
- // Empty config carves out nothing.
- if got := (&BorConfig{}).ReservedCapacity(); got != 0 {
- t.Errorf("empty config capacity: got %d want 0", got)
- }
-}
-
func TestReservedBlockspaceForkOrder(t *testing.T) {
t.Parallel()
diff --git a/registry-contract/src/ReservedBlockspaceRegistry.sol b/registry-contract/src/ReservedBlockspaceRegistry.sol
index 87cd451126..ee63512c33 100644
--- a/registry-contract/src/ReservedBlockspaceRegistry.sol
+++ b/registry-contract/src/ReservedBlockspaceRegistry.sol
@@ -26,7 +26,9 @@ contract ReservedBlockspaceRegistry {
uint64 gasQuota;
bool active;
// feeMode: 0 = free (zero in-protocol fee), 1 = routed (fee paid but
- // credited to the producer).
+ // credited to the producer). Routed mode is not implemented by the
+ // execution client: it excludes non-free clients from the reserved
+ // set, so their senders pay standard fees like normal transactions.
uint8 feeMode;
// effectiveFrom: block number from which this client's reserved status
// applies. Lets governance schedule/announce a change at a future height
diff --git a/tests/bor/helper.go b/tests/bor/helper.go
index e37e604afc..05e252115d 100644
--- a/tests/bor/helper.go
+++ b/tests/bor/helper.go
@@ -184,7 +184,7 @@ type modifyBlockFunc func(block *types.Block, receipts []*types.Receipt) *types.
// encodeBlockExtraDataForTest wraps types.EncodeBlockExtraData for callers in
// this file that never populate the Giugliano gas-target fields.
func encodeBlockExtraDataForTest(chainConfig *params.ChainConfig, number *big.Int, validatorBytes []byte) ([]byte, error) {
- return types.EncodeBlockExtraData(chainConfig, number, validatorBytes, nil, nil, nil)
+ return types.EncodeBlockExtraData(chainConfig, number, validatorBytes, nil, nil, nil, nil)
}
func buildHeader(t *testing.T, chain *core.BlockChain, parentBlock *types.Block, signer []byte, borConfig *params.BorConfig, currentValidators []*valset.Validator, modifyHeader []modifyHeaderFunc) *types.Header {
diff --git a/tests/bor/reserved_capacity_test.go b/tests/bor/reserved_capacity_test.go
new file mode 100644
index 0000000000..b9b11a5aac
--- /dev/null
+++ b/tests/bor/reserved_capacity_test.go
@@ -0,0 +1,411 @@
+//go:build integration
+// +build integration
+
+package bor
+
+import (
+ "context"
+ "crypto/ecdsa"
+ "math/big"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/ethereum/go-ethereum/accounts/abi"
+ "github.com/ethereum/go-ethereum/common"
+ "github.com/ethereum/go-ethereum/consensus/misc/eip1559"
+ "github.com/ethereum/go-ethereum/core"
+ "github.com/ethereum/go-ethereum/core/types"
+ "github.com/ethereum/go-ethereum/crypto"
+ "github.com/ethereum/go-ethereum/eth"
+ "github.com/ethereum/go-ethereum/params"
+)
+
+// reservedRegistryGovernanceABI is the write surface this file drives beyond
+// what reservedRegistrySetupABI (reserved_blockspace_test.go) covers:
+// governance calls that change an already-initialized registry's limits and
+// an existing client's quota, exercised mid-run against a live two-node
+// network (POS-3669 §2.5 governance-transition cases).
+const reservedRegistryGovernanceABI = `[
+ {"inputs":[{"name":"initialOwner","type":"address"},{"name":"maxTotalGas","type":"uint64"},{"name":"maxClientGas","type":"uint64"}],"name":"initialize","outputs":[],"stateMutability":"nonpayable","type":"function"},
+ {"inputs":[{"name":"admin","type":"address"},{"name":"gasQuota","type":"uint64"},{"name":"feeMode","type":"uint8"},{"name":"effectiveFrom","type":"uint64"},{"name":"metadata","type":"string"},{"name":"addresses","type":"address[]"}],"name":"createClient","outputs":[{"name":"clientId","type":"uint256"}],"stateMutability":"nonpayable","type":"function"},
+ {"inputs":[{"name":"maxTotalGas","type":"uint64"},{"name":"maxClientGas","type":"uint64"}],"name":"setLimits","outputs":[],"stateMutability":"nonpayable","type":"function"},
+ {"inputs":[{"name":"clientId","type":"uint256"},{"name":"newQuota","type":"uint64"}],"name":"setClientQuota","outputs":[],"stateMutability":"nonpayable","type":"function"}
+]`
+
+// waitForBorBlockHeight polls node0's chain head until it reaches target.
+func waitForBorBlockHeight(t *testing.T, nodes []*eth.Ethereum, target uint64, timeout time.Duration) {
+ t.Helper()
+ deadline := time.After(timeout)
+ for {
+ if nodes[0].BlockChain().CurrentBlock().Number.Uint64() >= target {
+ return
+ }
+ select {
+ case <-deadline:
+ t.Fatalf("timeout waiting for block %d (at %d)", target, nodes[0].BlockChain().CurrentBlock().Number.Uint64())
+ case <-time.After(200 * time.Millisecond):
+ }
+ }
+}
+
+// waitForBorTxMined blocks until tx is found in a canonical block on node0
+// and returns that block.
+func waitForBorTxMined(t *testing.T, nodes []*eth.Ethereum, txHash common.Hash, what string) *types.Block {
+ t.Helper()
+ deadline := time.After(60 * time.Second)
+ for {
+ head := nodes[0].BlockChain().CurrentBlock().Number.Uint64()
+ for n := uint64(0); n <= head; n++ {
+ blk := nodes[0].BlockChain().GetBlockByNumber(n)
+ if blk == nil {
+ continue
+ }
+ for _, tx := range blk.Transactions() {
+ if tx.Hash() == txHash {
+ return blk
+ }
+ }
+ }
+ select {
+ case <-deadline:
+ t.Fatalf("timeout waiting for %s to be mined", what)
+ case <-time.After(200 * time.Millisecond):
+ }
+ }
+}
+
+// reservedCapacityAt reads the reserved capacity stamped in the canonical
+// header at height n on the given node, failing the test if the block or the
+// field is missing.
+func reservedCapacityAt(t *testing.T, n *eth.Ethereum, cfg *params.ChainConfig, height uint64) uint64 {
+ t.Helper()
+ blk := n.BlockChain().GetBlockByNumber(height)
+ if blk == nil {
+ t.Fatalf("block %d not found", height)
+ }
+ capacity := blk.Header().GetReservedCapacity(cfg)
+ if capacity == nil {
+ t.Fatalf("block %d missing ReservedCapacity header field", height)
+ }
+ return *capacity
+}
+
+// newReservedCapacityGenesis builds the shared genesis for this file's tests:
+// a fresh 2-validator network with the reserved-blockspace fork activated
+// early, the registry deployed with empty storage (governance seeds it at
+// runtime, mirroring production), and Giugliano/Shanghai co-activated as the
+// fork ordering and the registry's PUSH0 opcode require. Mirrors the setup in
+// reserved_blockspace_test.go.
+func newReservedCapacityGenesis(t *testing.T, faucets []*ecdsa.PrivateKey, ownerAddr common.Address) (*core.Genesis, common.Address) {
+ t.Helper()
+ registryAddr := common.HexToAddress(params.DefaultReservedRegistryContract)
+ genesis := InitGenesis(t, faucets, "./testdata/genesis_2val.json", 8)
+ const reservedFork = uint64(5)
+ genesis.Config.Bor.ReservedBlockspaceBlock = new(big.Int).SetUint64(reservedFork)
+ genesis.Config.Bor.GiuglianoBlock = new(big.Int).SetUint64(reservedFork)
+ genesis.Config.ShanghaiBlock = big.NewInt(2)
+ genesis.Config.Bor.ReservedRegistryContract = params.DefaultReservedRegistryContract
+ genesis.Alloc[registryAddr] = types.Account{
+ Balance: new(big.Int),
+ Code: common.FromHex(params.ReservedBlockspaceRegistryCode),
+ }
+ startBalance := new(big.Int).SetUint64(1_000_000_000_000_000_000)
+ genesis.Alloc[ownerAddr] = types.Account{Balance: new(big.Int).Set(startBalance)}
+ return genesis, registryAddr
+}
+
+// TestReservedCapacityGovernanceTransition_FutureEffectiveFrom is the
+// governance-transition case (a) from POS-3669 §2.5: a client created with a
+// future effectiveFrom. The stamped capacity must exclude it until the
+// boundary block, then include it exactly at the crossing block even though
+// no registry transaction lands in that block, with both nodes (producer and
+// verifier across the run) agreeing throughout.
+//
+// Run with: go test -tags=integration -run TestReservedCapacityGovernanceTransition_FutureEffectiveFrom ./tests/bor/
+func TestReservedCapacityGovernanceTransition_FutureEffectiveFrom(t *testing.T) {
+ faucets := make([]*ecdsa.PrivateKey, 4)
+ for i := range faucets {
+ faucets[i], _ = crypto.GenerateKey()
+ }
+ ownerKey := faucets[0]
+ ownerAddr := crypto.PubkeyToAddress(ownerKey.PublicKey)
+ clientAddr := crypto.PubkeyToAddress(faucets[1].PublicKey)
+
+ govABI, err := abi.JSON(strings.NewReader(reservedRegistryGovernanceABI))
+ if err != nil {
+ t.Fatal(err)
+ }
+ genesis, registryAddr := newReservedCapacityGenesis(t, faucets, ownerAddr)
+
+ stacks, nodes, _ := setupMiner(t, 2, genesis)
+ defer func() {
+ for _, stack := range stacks {
+ stack.Close()
+ }
+ }()
+ for _, node := range nodes {
+ if err := node.StartMining(); err != nil {
+ t.Fatal("start mining:", err)
+ }
+ }
+
+ waitForBorBlockHeight(t, nodes, 2, 60*time.Second)
+
+ signer := types.LatestSigner(genesis.Config)
+ sendOwnerTx := func(nonce uint64, data []byte) *types.Transaction {
+ t.Helper()
+ tx, err := types.SignNewTx(ownerKey, signer, &types.DynamicFeeTx{
+ ChainID: genesis.Config.ChainID,
+ Nonce: nonce,
+ GasTipCap: big.NewInt(30_000_000_000),
+ GasFeeCap: big.NewInt(100_000_000_000),
+ Gas: 1_000_000,
+ To: ®istryAddr,
+ Value: big.NewInt(0),
+ Data: data,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := nodes[0].APIBackend.SendTx(context.Background(), tx); err != nil {
+ t.Fatalf("governance tx rejected: %v", err)
+ }
+ return tx
+ }
+
+ const clientQuota = uint64(4_000_000)
+ const effectiveFrom = uint64(20) // comfortably past createClient's inclusion block
+
+ initData, err := govABI.Pack("initialize", ownerAddr, uint64(8_000_000), uint64(5_000_000))
+ if err != nil {
+ t.Fatal(err)
+ }
+ createData, err := govABI.Pack("createClient",
+ ownerAddr, clientQuota, uint8(0), effectiveFrom, "future-client", []common.Address{clientAddr})
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ oNonce, err := nodes[0].APIBackend.GetPoolNonce(context.Background(), ownerAddr)
+ if err != nil {
+ t.Fatal(err)
+ }
+ sendOwnerTx(oNonce, initData)
+ createTx := sendOwnerTx(oNonce+1, createData)
+ seedBlock := waitForBorTxMined(t, nodes, createTx.Hash(), "createClient")
+ if seedBlock.NumberU64() >= effectiveFrom-2 {
+ t.Fatalf("createClient seeded too late (block %d) to observe the pre-boundary window before block %d", seedBlock.NumberU64(), effectiveFrom)
+ }
+ t.Logf("createClient (effectiveFrom=%d) mined in block %d", effectiveFrom, seedBlock.NumberU64())
+
+ // Settle a few blocks past the boundary on both nodes.
+ waitForBorBlockHeight(t, nodes, effectiveFrom+3, 120*time.Second)
+
+ // Both nodes must present an identical canonical chain over the whole
+ // window (produce/verify parity across the boundary).
+ for h := seedBlock.NumberU64() + 1; h <= effectiveFrom+3; h++ {
+ b0 := nodes[0].BlockChain().GetBlockByNumber(h)
+ b1 := nodes[1].BlockChain().GetBlockByNumber(h)
+ if b0 == nil || b1 == nil {
+ t.Fatalf("block %d missing on a node (node0=%v node1=%v)", h, b0 != nil, b1 != nil)
+ }
+ if b0.Hash() != b1.Hash() {
+ t.Fatalf("chains diverge at block %d: node0=%s node1=%s", h, b0.Hash(), b1.Hash())
+ }
+ }
+
+ // Before the boundary: the future client's quota is part of the raw
+ // registry total (createClient bumped it immediately) but not yet part
+ // of the effective set, so the stamped capacity is 0.
+ for h := seedBlock.NumberU64() + 1; h < effectiveFrom; h++ {
+ for _, n := range nodes {
+ if got := reservedCapacityAt(t, n, genesis.Config, h); got != 0 {
+ t.Fatalf("block %d capacity = %d, want 0 (client not yet effective)", h, got)
+ }
+ }
+ }
+
+ // At and after the boundary: the client is effective even though no
+ // registry transaction landed in the crossing block itself.
+ for h := effectiveFrom; h <= effectiveFrom+3; h++ {
+ for i, n := range nodes {
+ if got := reservedCapacityAt(t, n, genesis.Config, h); got != clientQuota {
+ t.Fatalf("node%d block %d capacity = %d, want %d (client now effective)", i, h, got, clientQuota)
+ }
+ }
+ }
+}
+
+// TestReservedCapacityLiveness_ExceedsGasLimit is the governance-transition
+// case (b) from POS-3669 §2.5: quotas raised so effective capacity meets or
+// exceeds the block gas limit mid-run. Blocks must keep producing, headers
+// must carry the exact over-limit value (no silent clamping), the base fee
+// must price at the reserved-aware target's full-target fallback, and the
+// chain must recover (continue producing/verifying) once capacity drops back
+// below the limit.
+//
+// Run with: go test -tags=integration -run TestReservedCapacityLiveness_ExceedsGasLimit ./tests/bor/
+func TestReservedCapacityLiveness_ExceedsGasLimit(t *testing.T) {
+ faucets := make([]*ecdsa.PrivateKey, 4)
+ for i := range faucets {
+ faucets[i], _ = crypto.GenerateKey()
+ }
+ ownerKey := faucets[0]
+ ownerAddr := crypto.PubkeyToAddress(ownerKey.PublicKey)
+ clientAddr := crypto.PubkeyToAddress(faucets[1].PublicKey)
+
+ govABI, err := abi.JSON(strings.NewReader(reservedRegistryGovernanceABI))
+ if err != nil {
+ t.Fatal(err)
+ }
+ genesis, registryAddr := newReservedCapacityGenesis(t, faucets, ownerAddr)
+
+ // genesis_2val.json sets gasLimit = 0x989680 = 10_000_000.
+ const gasLimit = uint64(10_000_000)
+ const clientID = 1 // first (and only) client created below
+ const initialQuota = uint64(4_000_000)
+ const overLimitQuota = uint64(12_000_000) // > gasLimit
+ const recoveredQuota = uint64(3_000_000)
+
+ stacks, nodes, _ := setupMiner(t, 2, genesis)
+ defer func() {
+ for _, stack := range stacks {
+ stack.Close()
+ }
+ }()
+ for _, node := range nodes {
+ if err := node.StartMining(); err != nil {
+ t.Fatal("start mining:", err)
+ }
+ }
+
+ waitForBorBlockHeight(t, nodes, 2, 60*time.Second)
+
+ signer := types.LatestSigner(genesis.Config)
+ nextNonce, err := nodes[0].APIBackend.GetPoolNonce(context.Background(), ownerAddr)
+ if err != nil {
+ t.Fatal(err)
+ }
+ sendOwnerTx := func(data []byte) *types.Transaction {
+ t.Helper()
+ tx, err := types.SignNewTx(ownerKey, signer, &types.DynamicFeeTx{
+ ChainID: genesis.Config.ChainID,
+ Nonce: nextNonce,
+ GasTipCap: big.NewInt(30_000_000_000),
+ GasFeeCap: big.NewInt(100_000_000_000),
+ Gas: 1_000_000,
+ To: ®istryAddr,
+ Value: big.NewInt(0),
+ Data: data,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := nodes[0].APIBackend.SendTx(context.Background(), tx); err != nil {
+ t.Fatalf("governance tx rejected: %v", err)
+ }
+ nextNonce++
+ return tx
+ }
+
+ initData, err := govABI.Pack("initialize", ownerAddr, uint64(8_000_000), uint64(5_000_000))
+ if err != nil {
+ t.Fatal(err)
+ }
+ createData, err := govABI.Pack("createClient",
+ ownerAddr, initialQuota, uint8(0), uint64(0), "liveness-client", []common.Address{clientAddr})
+ if err != nil {
+ t.Fatal(err)
+ }
+ sendOwnerTx(initData)
+ createTx := sendOwnerTx(createData)
+ seedBlock := waitForBorTxMined(t, nodes, createTx.Hash(), "createClient")
+
+ // Baseline: capacity reflects the initial (under-limit) quota once the
+ // registry state has propagated into the next block's parent snapshot.
+ waitForBorBlockHeight(t, nodes, seedBlock.NumberU64()+2, 60*time.Second)
+ if got := reservedCapacityAt(t, nodes[0], genesis.Config, seedBlock.NumberU64()+2); got != initialQuota {
+ t.Fatalf("baseline capacity = %d, want %d", got, initialQuota)
+ }
+
+ // Raise limits then the client's quota above the block gas limit. Two
+ // governance txs: setLimits must land before setClientQuota can validate
+ // the raised quota against it.
+ setLimitsData, err := govABI.Pack("setLimits", overLimitQuota+1_000_000, overLimitQuota)
+ if err != nil {
+ t.Fatal(err)
+ }
+ setQuotaUpData, err := govABI.Pack("setClientQuota", big.NewInt(clientID), overLimitQuota)
+ if err != nil {
+ t.Fatal(err)
+ }
+ sendOwnerTx(setLimitsData)
+ raiseTx := sendOwnerTx(setQuotaUpData)
+ raiseBlock := waitForBorTxMined(t, nodes, raiseTx.Hash(), "setClientQuota (raise)")
+
+ // Give the chain room to produce several more blocks past the raise —
+ // the liveness assertion is that nothing halts.
+ target := raiseBlock.NumberU64() + 4
+ waitForBorBlockHeight(t, nodes, target, 120*time.Second)
+
+ for h := raiseBlock.NumberU64() + 2; h <= target; h++ {
+ for i, n := range nodes {
+ got := reservedCapacityAt(t, n, genesis.Config, h)
+ if got != overLimitQuota {
+ t.Fatalf("node%d block %d capacity = %d, want exact over-limit value %d", i, h, got, overLimitQuota)
+ }
+ if got < gasLimit {
+ t.Fatalf("node%d block %d capacity %d unexpectedly below gas limit %d", i, h, got, gasLimit)
+ }
+ }
+ // Base fee must follow the reserved-aware target's full-target
+ // fallback (capacity >= parent.GasLimit): recompute it independently
+ // from the parent header and compare against what was actually mined.
+ child := nodes[0].BlockChain().GetBlockByNumber(h)
+ parent := nodes[0].BlockChain().GetBlockByNumber(h - 1)
+ if child == nil || parent == nil {
+ t.Fatalf("missing block for base-fee check at height %d", h)
+ }
+ want := eip1559.CalcBaseFee(genesis.Config, parent.Header())
+ if child.BaseFee().Cmp(want) != 0 {
+ t.Fatalf("block %d baseFee = %s, want %s (full-target fallback recomputed from parent %d)",
+ h, child.BaseFee(), want, h-1)
+ }
+ }
+
+ // Nodes still agree throughout the over-limit window.
+ for h := raiseBlock.NumberU64(); h <= target; h++ {
+ b0 := nodes[0].BlockChain().GetBlockByNumber(h)
+ b1 := nodes[1].BlockChain().GetBlockByNumber(h)
+ if b0 == nil || b1 == nil || b0.Hash() != b1.Hash() {
+ t.Fatalf("chains diverge at block %d during the over-limit window", h)
+ }
+ }
+
+ // Recovery: drop the quota back below the gas limit and confirm the
+ // chain keeps producing and verifying with the reduced value.
+ setQuotaDownData, err := govABI.Pack("setClientQuota", big.NewInt(clientID), recoveredQuota)
+ if err != nil {
+ t.Fatal(err)
+ }
+ dropTx := sendOwnerTx(setQuotaDownData)
+ dropBlock := waitForBorTxMined(t, nodes, dropTx.Hash(), "setClientQuota (recover)")
+
+ recoverTarget := dropBlock.NumberU64() + 3
+ waitForBorBlockHeight(t, nodes, recoverTarget, 60*time.Second)
+
+ for h := dropBlock.NumberU64() + 2; h <= recoverTarget; h++ {
+ for i, n := range nodes {
+ got := reservedCapacityAt(t, n, genesis.Config, h)
+ if got != recoveredQuota {
+ t.Fatalf("node%d block %d capacity after recovery = %d, want %d", i, h, got, recoveredQuota)
+ }
+ }
+ b0 := nodes[0].BlockChain().GetBlockByNumber(h)
+ b1 := nodes[1].BlockChain().GetBlockByNumber(h)
+ if b0 == nil || b1 == nil || b0.Hash() != b1.Hash() {
+ t.Fatalf("chains diverge at block %d after recovery", h)
+ }
+ }
+}
diff --git a/tests/bor/reserved_determinism_test.go b/tests/bor/reserved_determinism_test.go
new file mode 100644
index 0000000000..871b71799d
--- /dev/null
+++ b/tests/bor/reserved_determinism_test.go
@@ -0,0 +1,331 @@
+//go:build integration
+// +build integration
+
+package bor
+
+import (
+ "context"
+ "crypto/ecdsa"
+ "math/big"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/ethereum/go-ethereum/accounts/abi"
+ "github.com/ethereum/go-ethereum/common"
+ "github.com/ethereum/go-ethereum/core/rawdb"
+ "github.com/ethereum/go-ethereum/core/types"
+ "github.com/ethereum/go-ethereum/crypto"
+ "github.com/ethereum/go-ethereum/params"
+)
+
+// TestReservedProduceImportFieldParity is the produce-vs-import counterpart to
+// the sibling reserved-blockspace integration tests (reserved_blockspace_test.go,
+// reserved_capacity_test.go, reserved_receipts_test.go), which all assert
+// parity only by convergence: node0 (the producer) and node1 (the
+// verifier/importer) end up agreeing on the same canonical chain. Convergence
+// is a real consensus-parity signal - a genuine classification split would
+// make node1 reject node0's block outright, never converge - but it doesn't
+// pin down which persisted fields were recomputed identically, only that the
+// two sides landed on the same hash by whatever means. This test instead
+// recomputes and diffs, field by field, on both nodes: the header's stamped
+// ReservedGasUsed/ReservedCapacity, the on-disk reserved-tx index side table
+// (rawdb.ReadReservedTxIndexes), and each transaction's derived effective gas
+// price - turning "they converged" into an explicit produce-vs-import field
+// diff.
+//
+// Three registry clients exercise the three classification outcomes in one
+// run: a sole-member zero-fee client (always reserved), a sole-member
+// fallback-fee client whose quota exactly covers its one transaction
+// (reserved despite carrying a real fee, per POS-3671), and a two-member
+// client whose quota fits only one transaction (mirroring
+// TestReservedBlockspaceQuotaOverflowPaysNormalFees's arithmetic): the
+// zero-fee member wins the shared quota and the fallback-fee member overflows
+// into the normal, fee-paying region.
+//
+// Run with: go test -tags=integration -run TestReservedProduceImportFieldParity ./tests/bor/
+func TestReservedProduceImportFieldParity(t *testing.T) {
+ faucets := make([]*ecdsa.PrivateKey, 10)
+ for i := range faucets {
+ faucets[i], _ = crypto.GenerateKey()
+ }
+ zeroKey := faucets[0] // client A (sole member): reserved, zero-fee, in quota
+ fallbackKey := faucets[1] // client B (sole member): reserved, fallback-fee, in quota
+ overflowWinnerKey := faucets[2] // client C member 1: zero-fee, wins the shared quota
+ overflowKey := faucets[3] // client C member 2: fallback-fee, overflows to normal fees
+ ownerKey := faucets[4]
+
+ zeroAddr := crypto.PubkeyToAddress(zeroKey.PublicKey)
+ fallbackAddr := crypto.PubkeyToAddress(fallbackKey.PublicKey)
+ overflowWinnerAddr := crypto.PubkeyToAddress(overflowWinnerKey.PublicKey)
+ overflowAddr := crypto.PubkeyToAddress(overflowKey.PublicKey)
+ ownerAddr := crypto.PubkeyToAddress(ownerKey.PublicKey)
+
+ registryAddr := common.HexToAddress(params.DefaultReservedRegistryContract)
+ setupABI, err := abi.JSON(strings.NewReader(reservedRegistrySetupABI))
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ genesis := InitGenesis(t, faucets, "./testdata/genesis_2val.json", 8)
+ const reservedFork = uint64(5)
+ genesis.Config.Bor.ReservedBlockspaceBlock = new(big.Int).SetUint64(reservedFork)
+ // Giugliano must not activate after reserved blockspace (its base-fee params
+ // are earlier optional BlockExtraData fields than ReservedGasUsed); co-activate
+ // them so post-fork blocks stamp all the optional fields together.
+ genesis.Config.Bor.GiuglianoBlock = new(big.Int).SetUint64(reservedFork)
+ // The registry runtime bytecode uses PUSH0, a Shanghai opcode; activate
+ // Shanghai before the fork so the contract is callable in time to seed it.
+ genesis.Config.ShanghaiBlock = big.NewInt(2)
+ genesis.Config.Bor.ReservedRegistryContract = params.DefaultReservedRegistryContract
+ genesis.Alloc[registryAddr] = types.Account{
+ Balance: new(big.Int),
+ Code: common.FromHex(params.ReservedBlockspaceRegistryCode),
+ }
+
+ startBalance := new(big.Int).SetUint64(1_000_000_000_000_000_000) // 1 ETH
+ for _, a := range []common.Address{zeroAddr, fallbackAddr, overflowWinnerAddr, overflowAddr, ownerAddr} {
+ genesis.Alloc[a] = types.Account{Balance: new(big.Int).Set(startBalance)}
+ }
+
+ stacks, nodes, _ := setupMiner(t, 2, genesis)
+ defer func() {
+ for _, stack := range stacks {
+ stack.Close()
+ }
+ }()
+ for _, node := range nodes {
+ if err := node.StartMining(); err != nil {
+ t.Fatal("start mining:", err)
+ }
+ }
+
+ // London activates at block 1 in this genesis; the dynamic-fee setup txs
+ // are rejected before then ("pool not yet in London").
+ waitForBorBlockHeight(t, nodes, 2, 60*time.Second)
+
+ signer := types.LatestSigner(genesis.Config)
+ sendOwnerTx := func(nonce uint64, data []byte) *types.Transaction {
+ t.Helper()
+ tx, err := types.SignNewTx(ownerKey, signer, &types.DynamicFeeTx{
+ ChainID: genesis.Config.ChainID,
+ Nonce: nonce,
+ GasTipCap: big.NewInt(30_000_000_000),
+ GasFeeCap: big.NewInt(100_000_000_000),
+ Gas: 1_000_000,
+ To: ®istryAddr,
+ Value: big.NewInt(0),
+ Data: data,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := nodes[0].APIBackend.SendTx(context.Background(), tx); err != nil {
+ t.Fatalf("registry setup tx rejected: %v", err)
+ }
+ return tx
+ }
+
+ initData, err := setupABI.Pack("initialize", ownerAddr, uint64(8_000_000), uint64(5_000_000))
+ if err != nil {
+ t.Fatal(err)
+ }
+ packCreate := func(quota uint64, metadata string, addrs []common.Address) []byte {
+ t.Helper()
+ data, err := setupABI.Pack("createClient", ownerAddr, quota, uint8(0), uint64(0), metadata, addrs)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return data
+ }
+ const clientQuota = uint64(21_000) // exactly one simple transfer per client
+ createA := packCreate(clientQuota, "A-zero", []common.Address{zeroAddr})
+ createB := packCreate(clientQuota, "B-fallback", []common.Address{fallbackAddr})
+ // Client C: quota fits exactly one of its two members' transactions, so
+ // the second unavoidably overflows to the normal fee-paying region.
+ createC := packCreate(clientQuota, "C-overflow", []common.Address{overflowWinnerAddr, overflowAddr})
+
+ oNonce, err := nodes[0].APIBackend.GetPoolNonce(context.Background(), ownerAddr)
+ if err != nil {
+ t.Fatal(err)
+ }
+ sendOwnerTx(oNonce, initData)
+ sendOwnerTx(oNonce+1, createA)
+ sendOwnerTx(oNonce+2, createB)
+ lastCreate := sendOwnerTx(oNonce+3, createC)
+ seedBlock := waitForBorTxMined(t, nodes, lastCreate.Hash(), "createClient C")
+
+ // Land the test transactions inside node0's primary-producer window (see
+ // the sibling files' identical reasoning: sprint=8 with two validators
+ // gives node0 blocks 1-8 and 17-24, node1 blocks 9-16), so the chain
+ // doesn't reorg them out at a producer handover. The registry must be
+ // fully seeded well before that window opens.
+ if seedBlock.NumberU64() >= 16 {
+ t.Fatalf("registry seeded too late (block %d) for the 17-24 producer window", seedBlock.NumberU64())
+ }
+ waitForBorBlockHeight(t, nodes, 17, 120*time.Second)
+
+ recipient := common.HexToAddress("0x00000000000000000000000000000000000000aa")
+
+ zeroTx, err := types.SignNewTx(zeroKey, signer, &types.DynamicFeeTx{
+ ChainID: genesis.Config.ChainID, Nonce: 0,
+ GasTipCap: big.NewInt(0), GasFeeCap: big.NewInt(0),
+ Gas: 21000, To: &recipient, Value: big.NewInt(100),
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ fallbackTx, err := types.SignNewTx(fallbackKey, signer, &types.DynamicFeeTx{
+ ChainID: genesis.Config.ChainID, Nonce: 0,
+ GasTipCap: big.NewInt(1_000_000_000), GasFeeCap: big.NewInt(2_000_000_000),
+ Gas: 21000, To: &recipient, Value: big.NewInt(100),
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ // Client C's two members: overflowWinnerTx is zero-fee (always wins the
+ // ascending-price reserved selection), overflowTx carries a real fee and
+ // overflows the one-transaction quota to the normal, fee-paying region.
+ overflowWinnerTx, err := types.SignNewTx(overflowWinnerKey, signer, &types.DynamicFeeTx{
+ ChainID: genesis.Config.ChainID, Nonce: 0,
+ GasTipCap: big.NewInt(0), GasFeeCap: big.NewInt(0),
+ Gas: 21000, To: &recipient, Value: big.NewInt(100),
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ overflowTx, err := types.SignNewTx(overflowKey, signer, &types.DynamicFeeTx{
+ ChainID: genesis.Config.ChainID, Nonce: 0,
+ GasTipCap: big.NewInt(5_000_000_000), GasFeeCap: big.NewInt(10_000_000_000),
+ Gas: 21000, To: &recipient, Value: big.NewInt(100),
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // inQuotaTxs pay effectiveGasPrice 0 (reserved, admitted within their
+ // client's quota); overflowTx is the one exception that must pay real
+ // fees despite belonging to a registered client.
+ inQuotaTxs := []*types.Transaction{zeroTx, fallbackTx, overflowWinnerTx}
+ allTxs := []*types.Transaction{zeroTx, fallbackTx, overflowWinnerTx, overflowTx}
+ for _, node := range nodes {
+ for _, tx := range allTxs {
+ if err := node.APIBackend.SendTx(context.Background(), tx); err != nil &&
+ !strings.Contains(err.Error(), "already known") {
+ t.Fatalf("tx %s rejected by pool: %v", tx.Hash(), err)
+ }
+ }
+ }
+
+ var maxBlk uint64
+ for _, tx := range allTxs {
+ if bn := waitForBorTxMined(t, nodes, tx.Hash(), "produce-import parity tx").NumberU64(); bn > maxBlk {
+ maxBlk = bn
+ }
+ }
+
+ // Settle: wait for both nodes to converge on the tx blocks with a few
+ // confirmations, so fork choice has resolved before recomputing fields.
+ settleDeadline := time.After(60 * time.Second)
+ for {
+ h0 := nodes[0].BlockChain().CurrentBlock().Number.Uint64()
+ h1 := nodes[1].BlockChain().CurrentBlock().Number.Uint64()
+ if h0 >= maxBlk+3 && h1 >= maxBlk+3 &&
+ nodes[0].BlockChain().GetBlockByNumber(maxBlk+2).Hash() == nodes[1].BlockChain().GetBlockByNumber(maxBlk+2).Hash() {
+ break
+ }
+ select {
+ case <-settleDeadline:
+ t.Fatal("nodes never converged on the produce-import parity tx blocks")
+ case <-time.After(200 * time.Millisecond):
+ }
+ }
+
+ // The four transactions may share one canonical block or spread across a
+ // couple depending on pool/miner timing; collect the distinct inclusion
+ // blocks and diff every field this test cares about on both nodes for
+ // each of them.
+ inclusionBlocks := map[uint64]common.Hash{}
+ reservedGasByBlock := map[uint64]uint64{}
+ for _, tx := range allTxs {
+ blk := findBlockContaining(t, nodes[0], tx.Hash())
+ inclusionBlocks[blk.NumberU64()] = blk.Hash()
+ }
+ for _, tx := range inQuotaTxs {
+ blk := findBlockContaining(t, nodes[0], tx.Hash())
+ reservedGasByBlock[blk.NumberU64()] += 21000
+ }
+
+ for number, hash := range inclusionBlocks {
+ b0 := nodes[0].BlockChain().GetBlockByNumber(number)
+ b1 := nodes[1].BlockChain().GetBlockByNumber(number)
+ if b0 == nil || b1 == nil {
+ t.Fatalf("block %d missing on a node (node0=%v node1=%v)", number, b0 != nil, b1 != nil)
+ }
+ if b0.Hash() != hash || b1.Hash() != hash {
+ t.Fatalf("block %d hash mismatch: node0=%s node1=%s want=%s", number, b0.Hash(), b1.Hash(), hash)
+ }
+
+ // (1) Header field parity: the stamped reserved gas/capacity must be
+ // identical between producer and importer, and must count only the
+ // in-quota gas actually landing in this block - the overflow tx's gas
+ // is never reserved gas, wherever it lands.
+ gasUsed0, capacity0 := b0.Header().GetReservedFields(genesis.Config)
+ gasUsed1, capacity1 := b1.Header().GetReservedFields(genesis.Config)
+ if gasUsed0 == nil || gasUsed1 == nil {
+ t.Fatalf("block %d missing ReservedGasUsed header field (node0=%v node1=%v)", number, gasUsed0, gasUsed1)
+ }
+ if *gasUsed0 != *gasUsed1 {
+ t.Fatalf("block %d ReservedGasUsed producer=%d importer=%d", number, *gasUsed0, *gasUsed1)
+ }
+ wantGas := reservedGasByBlock[number]
+ if *gasUsed0 != wantGas {
+ t.Fatalf("block %d ReservedGasUsed = %d, want %d (in-quota gas only, overflow excluded)", number, *gasUsed0, wantGas)
+ }
+ if capacity0 == nil || capacity1 == nil {
+ t.Fatalf("block %d missing ReservedCapacity header field (node0=%v node1=%v)", number, capacity0, capacity1)
+ }
+ if *capacity0 != *capacity1 {
+ t.Fatalf("block %d ReservedCapacity producer=%d importer=%d", number, *capacity0, *capacity1)
+ }
+ const wantCapacity = 3 * clientQuota // clients A, B, C, all immediately effective
+ if *capacity0 != wantCapacity {
+ t.Fatalf("block %d ReservedCapacity = %d, want %d", number, *capacity0, wantCapacity)
+ }
+
+ // (2) On-disk reserved-tx index side table parity: the persisted
+ // classification a fresh read (RPC, re-derivation) relies on must
+ // agree byte-for-byte between the block that produced it and the
+ // block that only verified it.
+ idx0 := rawdb.ReadReservedTxIndexes(nodes[0].ChainDb(), hash, number)
+ idx1 := rawdb.ReadReservedTxIndexes(nodes[1].ChainDb(), hash, number)
+ if len(idx0) != len(idx1) {
+ t.Fatalf("block %d reserved-tx indexes length: producer=%v importer=%v", number, idx0, idx1)
+ }
+ for i := range idx0 {
+ if idx0[i] != idx1[i] {
+ t.Fatalf("block %d reserved-tx indexes: producer=%v importer=%v", number, idx0, idx1)
+ }
+ }
+ }
+
+ // (3) Per-transaction receipt effective gas price parity: exactly zero
+ // for every in-quota transaction, non-zero for the overflowed one,
+ // identical on both nodes.
+ inQuotaSet := make(map[common.Hash]bool, len(inQuotaTxs))
+ for _, tx := range inQuotaTxs {
+ inQuotaSet[tx.Hash()] = true
+ }
+ for _, tx := range allTxs {
+ r0 := findReceipt(t, nodes[0], tx.Hash())
+ r1 := findReceipt(t, nodes[1], tx.Hash())
+ if r0.EffectiveGasPrice.Cmp(r1.EffectiveGasPrice) != 0 {
+ t.Fatalf("tx %s effectiveGasPrice: producer=%s importer=%s", tx.Hash(), r0.EffectiveGasPrice, r1.EffectiveGasPrice)
+ }
+ wantZero := inQuotaSet[tx.Hash()]
+ gotZero := r0.EffectiveGasPrice.Sign() == 0
+ if gotZero != wantZero {
+ t.Fatalf("tx %s effectiveGasPrice = %s, want zero=%v", tx.Hash(), r0.EffectiveGasPrice, wantZero)
+ }
+ }
+}
diff --git a/tests/bor/reserved_pool_balance_test.go b/tests/bor/reserved_pool_balance_test.go
new file mode 100644
index 0000000000..5e899cfd63
--- /dev/null
+++ b/tests/bor/reserved_pool_balance_test.go
@@ -0,0 +1,366 @@
+//go:build integration
+// +build integration
+
+package bor
+
+import (
+ "context"
+ "crypto/ecdsa"
+ "errors"
+ "math/big"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/ethereum/go-ethereum/accounts/abi"
+ "github.com/ethereum/go-ethereum/common"
+ "github.com/ethereum/go-ethereum/core/txpool"
+ "github.com/ethereum/go-ethereum/core/types"
+ "github.com/ethereum/go-ethereum/crypto"
+ "github.com/ethereum/go-ethereum/eth"
+ "github.com/ethereum/go-ethereum/params"
+)
+
+// reservedPoolBalanceQuota is the client's per-block gas quota, sized to fit
+// exactly one of the 21000-gas transfers below. Any second transfer from the
+// same sender that's simultaneously pending competes for the same quota and
+// overflows for that block.
+const reservedPoolBalanceQuota = 21_000
+
+// TestReservedBlockspacePoolAdmitsValueOnlyBalance is POS-3671's end-to-end
+// validation against the real registry contract and a live two-node
+// network. A registered reserved sender is funded with only enough balance
+// to cover a handful of call values, never gas*feeCap, and submits several
+// fallback-fee transactions at once against a client whose quota fits
+// exactly one of them per block.
+//
+// Before this task, the pool priced every sender at full cost (value +
+// gas*feeCap) for every balance check, so these transactions would either be
+// rejected outright at admission or, if somehow admitted, evicted the moment
+// a head event revalidated pending balances — regardless of whether they
+// would eventually get their quota turn. With the reserved-aware
+// EffectiveCost wired through admission and the pending-side balance
+// Filter, all of them are admitted and none are ever dropped while they
+// wait: the quota lets exactly one per block execute fee-free, and the
+// pool's value-only pricing (this task) keeps the rest pending — never
+// evicted for lacking gas balance — until it's their turn. Since the
+// sender's balance can never cover a real EIP-1559 payment, every one of
+// them landing in the canonical chain at all is itself proof it went
+// through the fee-free reserved path. A second node that only verifies in
+// this window accepts the same chain, preserving produce/verify parity.
+//
+// Run with: go test -tags=integration -run TestReservedBlockspacePoolAdmitsValueOnlyBalance ./tests/bor/
+func TestReservedBlockspacePoolAdmitsValueOnlyBalance(t *testing.T) {
+ faucets := make([]*ecdsa.PrivateKey, 10)
+ for i := range faucets {
+ faucets[i], _ = crypto.GenerateKey()
+ }
+ reservedKey := faucets[0]
+ reservedAddr := crypto.PubkeyToAddress(reservedKey.PublicKey)
+ ownerKey := faucets[2]
+ ownerAddr := crypto.PubkeyToAddress(ownerKey.PublicKey)
+
+ registryAddr := common.HexToAddress(params.DefaultReservedRegistryContract)
+ setupABI, err := abi.JSON(strings.NewReader(reservedRegistrySetupABI))
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ genesis := InitGenesis(t, faucets, "./testdata/genesis_2val.json", 8)
+ // Reserved fork at block 5 (Cancun is active from block 3, so the reserved
+ // header fields encode in the post-Cancun BlockExtraData format).
+ reservedFork := uint64(5)
+ genesis.Config.Bor.ReservedBlockspaceBlock = new(big.Int).SetUint64(reservedFork)
+ // Giugliano must not activate after reserved blockspace (its base-fee params
+ // are earlier optional BlockExtraData fields than ReservedGasUsed); co-activate
+ // them so post-fork blocks stamp all the optional fields together.
+ genesis.Config.Bor.GiuglianoBlock = new(big.Int).SetUint64(reservedFork)
+ // The registry runtime bytecode (solc 0.8.33) uses PUSH0, a Shanghai opcode.
+ genesis.Config.ShanghaiBlock = big.NewInt(2)
+ genesis.Config.Bor.ReservedRegistryContract = params.DefaultReservedRegistryContract
+ genesis.Alloc[registryAddr] = types.Account{
+ Balance: new(big.Int),
+ Code: common.FromHex(params.ReservedBlockspaceRegistryCode),
+ }
+
+ const (
+ numTxs = 4
+ value = 100
+ )
+ // Enough POL for numTxs call values, nowhere near 21000 gas at any
+ // realistic fee cap (~1.05e15 wei at the 50 gwei fee cap used below).
+ valueOnlyBalance := big.NewInt(numTxs * value * 10) // generous headroom, still dwarfed by gas*feeCap
+ startBalance := new(big.Int).SetUint64(1_000_000_000_000_000_000)
+ genesis.Alloc[reservedAddr] = types.Account{Balance: new(big.Int).Set(valueOnlyBalance)}
+ genesis.Alloc[ownerAddr] = types.Account{Balance: new(big.Int).Set(startBalance)}
+
+ stacks, nodes, _ := setupMiner(t, 2, genesis)
+ defer func() {
+ for _, stack := range stacks {
+ stack.Close()
+ }
+ }()
+ for _, node := range nodes {
+ if err := node.StartMining(); err != nil {
+ t.Fatal("start mining:", err)
+ }
+ }
+
+ waitForBlock := func(target uint64) {
+ t.Helper()
+ deadline := time.After(120 * time.Second)
+ for {
+ if nodes[0].BlockChain().CurrentBlock().Number.Uint64() >= target {
+ return
+ }
+ select {
+ case <-deadline:
+ t.Fatalf("timeout waiting for block %d (at %d)", target, nodes[0].BlockChain().CurrentBlock().Number.Uint64())
+ case <-time.After(200 * time.Millisecond):
+ }
+ }
+ }
+
+ // waitMined blocks until tx is found in a canonical block and returns it.
+ waitMined := func(txHash common.Hash, what string) *types.Block {
+ t.Helper()
+ deadline := time.After(60 * time.Second)
+ for {
+ head := nodes[0].BlockChain().CurrentBlock().Number.Uint64()
+ for n := uint64(0); n <= head; n++ {
+ blk := nodes[0].BlockChain().GetBlockByNumber(n)
+ if blk == nil {
+ continue
+ }
+ for _, tx := range blk.Transactions() {
+ if tx.Hash() == txHash {
+ return blk
+ }
+ }
+ }
+ select {
+ case <-deadline:
+ t.Fatalf("timeout waiting for %s to be mined", what)
+ case <-time.After(200 * time.Millisecond):
+ }
+ }
+ }
+
+ signer := types.LatestSigner(genesis.Config)
+
+ // Seed the registry exactly as the sibling reserved-blockspace tests do:
+ // initialize() claims ownership, createClient() registers reservedAddr as
+ // the sole whitelisted address of a client whose quota fits exactly one
+ // 21000-gas transfer.
+ sendOwnerTx := func(nonce uint64, data []byte) *types.Transaction {
+ t.Helper()
+ tx, err := types.SignNewTx(ownerKey, signer, &types.DynamicFeeTx{
+ ChainID: genesis.Config.ChainID,
+ Nonce: nonce,
+ GasTipCap: big.NewInt(30_000_000_000),
+ GasFeeCap: big.NewInt(100_000_000_000),
+ Gas: 1_000_000,
+ To: ®istryAddr,
+ Value: big.NewInt(0),
+ Data: data,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := nodes[0].APIBackend.SendTx(context.Background(), tx); err != nil {
+ t.Fatalf("registry setup tx rejected: %v", err)
+ }
+ return tx
+ }
+
+ initData, err := setupABI.Pack("initialize", ownerAddr, uint64(8_000_000), uint64(5_000_000))
+ if err != nil {
+ t.Fatal(err)
+ }
+ createData, err := setupABI.Pack("createClient",
+ ownerAddr, uint64(reservedPoolBalanceQuota), uint8(0), uint64(0), "pool-balance", []common.Address{reservedAddr})
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // London activates at block 1 in this genesis; the dynamic-fee setup txs
+ // are rejected before then ("pool not yet in London").
+ waitForBlock(2)
+
+ oNonce, err := nodes[0].APIBackend.GetPoolNonce(context.Background(), ownerAddr)
+ if err != nil {
+ t.Fatal(err)
+ }
+ sendOwnerTx(oNonce, initData)
+ createTx := sendOwnerTx(oNonce+1, createData)
+ seedBlock := waitMined(createTx.Hash(), "createClient")
+ t.Logf("registry seeded: createClient mined in block %d", seedBlock.NumberU64())
+
+ // Give the registry state a few confirmations past both seeding and the
+ // reserved fork before submitting, so the per-head snapshot both nodes
+ // build for the next block already sees the client.
+ waitForBlock(seedBlock.NumberU64() + 3)
+
+ recipient := common.HexToAddress("0x00000000000000000000000000000000000000aa")
+ // Every transaction carries a real (fallback) fee: gas*feeCap alone
+ // (21000 * 50 gwei ≈ 1.05e15 wei) dwarfs the sender's total balance.
+ // Only the reserved waiver (within quota, execution-side) or the pool's
+ // value-only pricing (overflow, this task) lets any of them be admitted
+ // and stay in the pool at all.
+ txs := make([]*types.Transaction, numTxs)
+ for i := range txs {
+ tx, err := types.SignNewTx(reservedKey, signer, &types.DynamicFeeTx{
+ ChainID: genesis.Config.ChainID,
+ Nonce: uint64(i),
+ GasTipCap: big.NewInt(1_000_000_000),
+ GasFeeCap: big.NewInt(50_000_000_000),
+ Gas: reservedPoolBalanceQuota,
+ To: &recipient,
+ Value: big.NewInt(value),
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ txs[i] = tx
+ }
+
+ // Submit the whole sequence atomically to each node's pool so all
+ // numTxs nonces are simultaneously pending before the next block is
+ // built, guaranteeing genuine per-block quota contention rather than
+ // each nonce quietly getting its own turn one node-restart apart.
+ for _, node := range nodes {
+ for i, err := range node.TxPool().Add(txs, true) {
+ if err != nil && !errors.Is(err, txpool.ErrAlreadyKnown) {
+ t.Fatalf("value-only-balance fallback-fee tx %d rejected by pool: %v", i, err)
+ }
+ }
+ }
+
+ // minedIn reports, for each tx, the block it's currently canonical in on
+ // node0 (nil if not yet mined there).
+ minedIn := func() []*types.Block {
+ head := nodes[0].BlockChain().CurrentBlock().Number.Uint64()
+ found := make([]*types.Block, len(txs))
+ remaining := len(txs)
+ for n := seedBlock.NumberU64(); n <= head && remaining > 0; n++ {
+ blk := nodes[0].BlockChain().GetBlockByNumber(n)
+ if blk == nil {
+ continue
+ }
+ for _, btx := range blk.Transactions() {
+ for i, want := range txs {
+ if found[i] == nil && btx.Hash() == want.Hash() {
+ found[i] = blk
+ remaining--
+ }
+ }
+ }
+ }
+ return found
+ }
+
+ // minedOnNode reports whether txHash is canonical on node's own chain.
+ // The producing node drops a tx from its pool the moment its own head
+ // includes it, which can be several hundred ms before the other node
+ // imports that block, so "missing from the pool" is only meaningful
+ // against the same node's chain, not node0's.
+ minedOnNode := func(node *eth.Ethereum, txHash common.Hash) bool {
+ head := node.BlockChain().CurrentBlock().Number.Uint64()
+ for n := seedBlock.NumberU64(); n <= head; n++ {
+ blk := node.BlockChain().GetBlockByNumber(n)
+ if blk == nil {
+ continue
+ }
+ for _, btx := range blk.Transactions() {
+ if btx.Hash() == txHash {
+ return true
+ }
+ }
+ }
+ return false
+ }
+
+ // Poll until every tx is mined. At every step, any tx not yet mined must
+ // still be sitting in both pools — the invariant this task guarantees:
+ // an overflowing (quota-contended) fallback-fee tx from a value-only
+ // balance is never dropped while it waits its turn. A tx can transiently
+ // be neither pending in a node's pool nor canonical there (tip-level fork
+ // resolution reinjects it a moment after the losing block is unwound), so
+ // a drop only counts once it persists across consecutive polls; a genuine
+ // eviction is permanent and always crosses the threshold.
+ deadline := time.After(150 * time.Second)
+ var lastMinedBlock uint64
+ misses := make([][]int, len(nodes))
+ for ni := range misses {
+ misses[ni] = make([]int, len(txs))
+ }
+ const maxConsecutiveMisses = 25 // 5s of 200ms polls
+ for {
+ found := minedIn()
+ allMined := true
+ for i, blk := range found {
+ if blk == nil {
+ allMined = false
+ for ni, node := range nodes {
+ pending := node.TxPool().Has(txs[i].Hash()) &&
+ node.TxPool().Status(txs[i].Hash()) == txpool.TxStatusPending
+ if pending || minedOnNode(node, txs[i].Hash()) {
+ misses[ni][i] = 0
+ continue
+ }
+ misses[ni][i]++
+ if misses[ni][i] >= maxConsecutiveMisses {
+ t.Fatalf("node %d: tx %d (nonce %d) was dropped from the pool (status %v) before being mined",
+ ni, i, i, node.TxPool().Status(txs[i].Hash()))
+ }
+ }
+ } else if blk.NumberU64() > lastMinedBlock {
+ lastMinedBlock = blk.NumberU64()
+ }
+ }
+ if allMined {
+ break
+ }
+ select {
+ case <-deadline:
+ t.Fatalf("timeout waiting for all %d value-only-balance fallback-fee txs to be mined", numTxs)
+ case <-time.After(200 * time.Millisecond):
+ }
+ }
+ t.Logf("all %d value-only-balance fallback-fee txs mined by block %d", numTxs, lastMinedBlock)
+
+ // Let a few confirmations settle so any tip-level fork resolves, then
+ // compare canonical chains for produce/verify parity: the second node
+ // (which did not exclusively produce this window) must agree.
+ settleTarget := lastMinedBlock + 3
+ waitForBlock(settleTarget)
+ deadline = time.After(60 * time.Second)
+ for {
+ h0 := nodes[0].BlockChain().GetBlockByNumber(settleTarget)
+ h1 := nodes[1].BlockChain().GetBlockByNumber(settleTarget)
+ if h0 != nil && h1 != nil && h0.Hash() == h1.Hash() {
+ break
+ }
+ select {
+ case <-deadline:
+ t.Fatalf("nodes never converged on block %d", settleTarget)
+ case <-time.After(200 * time.Millisecond):
+ }
+ }
+
+ // Every one of the numTxs transactions landing in the canonical chain at
+ // all is proof each went through the fee-free reserved path: the
+ // sender's balance could never cover a real EIP-1559 payment. Confirm it
+ // directly too: the total balance decrease is exactly numTxs*value, no
+ // gas was ever debited.
+ tip := nodes[0].BlockChain().CurrentBlock()
+ st, err := nodes[0].BlockChain().StateAt(tip.Root)
+ if err != nil {
+ t.Fatal(err)
+ }
+ got := st.GetBalance(reservedAddr).ToBig()
+ want := new(big.Int).Sub(valueOnlyBalance, big.NewInt(numTxs*value))
+ if got.Cmp(want) != 0 {
+ t.Fatalf("reserved sender balance = %s, want %s (call values only, no gas ever debited)", got, want)
+ }
+}
diff --git a/tests/bor/reserved_pool_occupancy_test.go b/tests/bor/reserved_pool_occupancy_test.go
new file mode 100644
index 0000000000..9b30d53be9
--- /dev/null
+++ b/tests/bor/reserved_pool_occupancy_test.go
@@ -0,0 +1,388 @@
+//go:build integration
+// +build integration
+
+package bor
+
+import (
+ "context"
+ "crypto/ecdsa"
+ "errors"
+ "math/big"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/ethereum/go-ethereum/accounts/abi"
+ "github.com/ethereum/go-ethereum/common"
+ "github.com/ethereum/go-ethereum/core/txpool/legacypool"
+ "github.com/ethereum/go-ethereum/core/types"
+ "github.com/ethereum/go-ethereum/crypto"
+ "github.com/ethereum/go-ethereum/params"
+)
+
+// reservedPoolOccupancySetupABI is the registry setter surface this test
+// drives, kept local rather than reusing reservedRegistrySetupABI
+// (reserved_blockspace_test.go) because it additionally needs
+// setClientActive to deterministically free reserved occupancy (see below).
+const reservedPoolOccupancySetupABI = `[
+ {"inputs":[{"name":"initialOwner","type":"address"},{"name":"maxTotalGas","type":"uint64"},{"name":"maxClientGas","type":"uint64"}],"name":"initialize","outputs":[],"stateMutability":"nonpayable","type":"function"},
+ {"inputs":[{"name":"admin","type":"address"},{"name":"gasQuota","type":"uint64"},{"name":"feeMode","type":"uint8"},{"name":"effectiveFrom","type":"uint64"},{"name":"metadata","type":"string"},{"name":"addresses","type":"address[]"}],"name":"createClient","outputs":[{"name":"clientId","type":"uint256"}],"stateMutability":"nonpayable","type":"function"},
+ {"inputs":[{"name":"clientId","type":"uint256"},{"name":"active","type":"bool"}],"name":"setClientActive","outputs":[],"stateMutability":"nonpayable","type":"function"}
+]`
+
+// reservedPoolOccupancyGas is the gas limit carried by every reserved
+// transaction below, matching a realistic transfer.
+const reservedPoolOccupancyGas = 21_000
+
+// TestReservedPoolOccupancy is POS-3681's end-to-end validation against the
+// real registry contract, a live two-node network, and the pool's default
+// occupancy cap (no config overrides): a reserved client whose whitelist is
+// sized to threaten pool exhaustion floods zero-fee transactions from every
+// one of its addresses. Before this task, that flood could grow until it
+// consumed the entire pool — eviction-immune — leaving no admission room for
+// any other sender.
+//
+// With the occupancy cap in place: the flood is bounded at
+// ReservedMaxOccupancyPercent of the pool's own combined slot ceiling; an
+// unrelated normal fee-paying sender's transaction is admitted and mined
+// throughout, proving normal senders' headroom is genuinely held; and once
+// the flooding client is deregistered (a realistic governance action, and
+// the only deterministic way to free occupancy that this flood — entirely
+// permanently-gapped, so never mined on its own — would not otherwise free),
+// a different, still-whitelisted reserved sender's own transaction is
+// admitted and goes on to be mined — the cap bounds occupancy, it does not
+// permanently ban a reserved sender.
+//
+// Run with: go test -tags=integration -run TestReservedPoolOccupancy ./tests/bor/
+func TestReservedPoolOccupancy(t *testing.T) {
+ // wantCap is exact for the current defaults (GlobalSlots=5120,
+ // GlobalQueue=1024, 50%): 3072. perFillerChainLen is just a convenient
+ // per-address chunk size (reusing AccountQueue's value, though these
+ // transactions end up pending rather than queued — see the flood
+ // construction below for why that distinction matters here).
+ wantCap := int((legacypool.DefaultConfig.GlobalSlots + legacypool.DefaultConfig.GlobalQueue) *
+ legacypool.DefaultConfig.ReservedMaxOccupancyPercent / 100)
+ perFillerChainLen := int(legacypool.DefaultConfig.AccountQueue)
+ // One address beyond the exact ceiling so the flood itself overflows the
+ // cap (some of its own transactions are rejected), rather than landing
+ // exactly on the boundary with nothing left to reject.
+ numFillers := (wantCap+perFillerChainLen-1)/perFillerChainLen + 1
+
+ faucets := make([]*ecdsa.PrivateKey, 4)
+ for i := range faucets {
+ faucets[i], _ = crypto.GenerateKey()
+ }
+ ownerKey := faucets[0]
+ ownerAddr := crypto.PubkeyToAddress(ownerKey.PublicKey)
+ normalKey := faucets[1]
+ normalAddr := crypto.PubkeyToAddress(normalKey.PublicKey)
+
+ fillerKeys := make([]*ecdsa.PrivateKey, numFillers)
+ fillerAddrs := make([]common.Address, numFillers)
+ for i := range fillerKeys {
+ fillerKeys[i], _ = crypto.GenerateKey()
+ fillerAddrs[i] = crypto.PubkeyToAddress(fillerKeys[i].PublicKey)
+ }
+ // probeAddr belongs to a *separate* client from the fillers, so
+ // deregistering (deactivating) the flooding client below doesn't also
+ // declassify probeAddr — the point is that a still-reserved sender
+ // recovers headroom, not that every reserved sender does.
+ probeKey, _ := crypto.GenerateKey()
+ probeAddr := crypto.PubkeyToAddress(probeKey.PublicKey)
+
+ registryAddr := common.HexToAddress(params.DefaultReservedRegistryContract)
+ setupABI, err := abi.JSON(strings.NewReader(reservedPoolOccupancySetupABI))
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ genesis := InitGenesis(t, faucets, "./testdata/genesis_2val.json", 8)
+ // Reserved fork at block 5 (Cancun is active from block 3, so the reserved
+ // header fields encode in the post-Cancun BlockExtraData format).
+ reservedFork := uint64(5)
+ genesis.Config.Bor.ReservedBlockspaceBlock = new(big.Int).SetUint64(reservedFork)
+ genesis.Config.Bor.GiuglianoBlock = new(big.Int).SetUint64(reservedFork)
+ // The registry runtime bytecode (solc 0.8.33) uses PUSH0, a Shanghai opcode.
+ genesis.Config.ShanghaiBlock = big.NewInt(2)
+ genesis.Config.Bor.ReservedRegistryContract = params.DefaultReservedRegistryContract
+ genesis.Alloc[registryAddr] = types.Account{
+ Balance: new(big.Int),
+ Code: common.FromHex(params.ReservedBlockspaceRegistryCode),
+ }
+
+ startBalance := new(big.Int).SetUint64(10_000_000_000_000_000_000)
+ genesis.Alloc[ownerAddr] = types.Account{Balance: new(big.Int).Set(startBalance)}
+ genesis.Alloc[normalAddr] = types.Account{Balance: new(big.Int).Set(startBalance)}
+ // Every reserved-sender transaction below carries zero value and zero
+ // fee: the pool prices a reserved sender's balance check on value alone
+ // (EffectiveCost), so a real balance is never required. Each address
+ // still needs a materialized account (a nonzero genesis balance is the
+ // simplest way): an account the state has never touched at all reports
+ // GetCodeHash as the zero hash rather than types.EmptyCodeHash, which
+ // misclassifies it as a delegated account and caps it at one in-flight
+ // transaction — unrelated to reserved-occupancy tracking, but every
+ // address below needs more than one in-flight transaction to exercise it.
+ for _, addr := range fillerAddrs {
+ genesis.Alloc[addr] = types.Account{Balance: big.NewInt(1)}
+ }
+ genesis.Alloc[probeAddr] = types.Account{Balance: big.NewInt(1)}
+
+ stacks, nodes, _ := setupMiner(t, 2, genesis)
+ defer func() {
+ for _, stack := range stacks {
+ stack.Close()
+ }
+ }()
+ for _, node := range nodes {
+ if err := node.StartMining(); err != nil {
+ t.Fatal("start mining:", err)
+ }
+ }
+
+ waitForBlock := func(target uint64) {
+ t.Helper()
+ waitForBorBlockHeight(t, nodes, target, 120*time.Second)
+ }
+
+ // waitMined is waitForBorTxMined (reserved_capacity_test.go) plus a
+ // revert check: this test's registry-seeding transactions carry
+ // hand-picked gas limits, and a silent out-of-gas revert there would
+ // otherwise surface as a much more confusing failure much later.
+ waitMined := func(txHash common.Hash, what string) *types.Block {
+ t.Helper()
+ blk := waitForBorTxMined(t, nodes, txHash, what)
+ for _, r := range nodes[0].BlockChain().GetReceiptsByHash(blk.Hash()) {
+ if r.TxHash == txHash && r.Status != types.ReceiptStatusSuccessful {
+ t.Fatalf("%s reverted in block %d", what, blk.NumberU64())
+ }
+ }
+ return blk
+ }
+
+ signer := types.LatestSigner(genesis.Config)
+
+ sendOwnerTx := func(nonce, gas uint64, data []byte) *types.Transaction {
+ t.Helper()
+ tx, err := types.SignNewTx(ownerKey, signer, &types.DynamicFeeTx{
+ ChainID: genesis.Config.ChainID,
+ Nonce: nonce,
+ GasTipCap: big.NewInt(30_000_000_000),
+ GasFeeCap: big.NewInt(100_000_000_000),
+ Gas: gas,
+ To: ®istryAddr,
+ Value: big.NewInt(0),
+ Data: data,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := nodes[0].APIBackend.SendTx(context.Background(), tx); err != nil {
+ t.Fatalf("registry setup tx rejected: %v", err)
+ }
+ return tx
+ }
+
+ // Two clients: the fillers (clientId 1, deactivated later) and the probe
+ // sender alone (clientId 2, stays active throughout).
+ initData, err := setupABI.Pack("initialize", ownerAddr, uint64(8_000_000), uint64(5_000_000))
+ if err != nil {
+ t.Fatal(err)
+ }
+ fillerClientData, err := setupABI.Pack("createClient",
+ ownerAddr, uint64(reservedPoolOccupancyGas), uint8(0), uint64(0), "fillers", fillerAddrs)
+ if err != nil {
+ t.Fatal(err)
+ }
+ probeClientData, err := setupABI.Pack("createClient",
+ ownerAddr, uint64(reservedPoolOccupancyGas), uint8(0), uint64(0), "probe", []common.Address{probeAddr})
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // London activates at block 1 in this genesis; the dynamic-fee setup txs
+ // are rejected before then ("pool not yet in London").
+ waitForBlock(2)
+
+ oNonce, err := nodes[0].APIBackend.GetPoolNonce(context.Background(), ownerAddr)
+ if err != nil {
+ t.Fatal(err)
+ }
+ // Ordered so the fillers become clientId 1 (deactivated later) and the
+ // probe sender becomes clientId 2 (stays active throughout).
+ initTx := sendOwnerTx(oNonce, 200_000, initData)
+ fillerClientTx := sendOwnerTx(oNonce+1, 9_000_000, fillerClientData)
+ probeClientTx := sendOwnerTx(oNonce+2, 500_000, probeClientData)
+ waitMined(initTx.Hash(), "initialize")
+ waitMined(fillerClientTx.Hash(), "the fillers' createClient")
+ seedBlock := waitMined(probeClientTx.Hash(), "the probe's createClient")
+ t.Logf("registry seeded with %d filler addresses (clientId 1) and 1 probe address (clientId 2): mined by block %d", numFillers, seedBlock.NumberU64())
+
+ // Give the registry state a few confirmations past both seeding and the
+ // reserved fork before submitting, so the per-head snapshot both nodes
+ // build for the next block already sees both clients.
+ waitForBlock(seedBlock.NumberU64() + 3)
+
+ recipient := common.HexToAddress("0x00000000000000000000000000000000000000aa")
+
+ buildReservedTx := func(key *ecdsa.PrivateKey, nonce uint64) *types.Transaction {
+ tx, err := types.SignNewTx(key, signer, &types.DynamicFeeTx{
+ ChainID: genesis.Config.ChainID,
+ Nonce: nonce,
+ GasTipCap: big.NewInt(0),
+ GasFeeCap: big.NewInt(0),
+ Gas: reservedPoolOccupancyGas,
+ To: &recipient,
+ Value: big.NewInt(0),
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ return tx
+ }
+
+ // The flood: numFillers addresses, each with a contiguous chain of
+ // zero-fee transactions starting at nonce 0 — immediately executable
+ // (pending), not queued. This matters: the pool's pre-existing
+ // GlobalQueue limit (1024, far smaller than the reserved cap this task
+ // adds) bounds queued transactions pool-wide regardless of reserved
+ // status, so a purely-queued (gapped) flood would be truncated by that
+ // existing mechanism long before threatening the reserved-occupancy cap
+ // at all. A pending flood is instead bounded by GlobalSlots (5120),
+ // comfortably above the cap. The fillers' own client quota
+ // (reservedPoolOccupancyGas, one transaction's worth) makes mining drain
+ // this backlog at most one transaction per block, slow enough that the
+ // checks immediately below see it still at the cap. Submitted only to
+ // node0: whichever of these the miner does pick per block still needs to
+ // reach consensus, but the bulk of the backlog staying resident is a
+ // local pool-state concern, so it only needs to exist in one node's pool
+ // to prove the local admission gate holds.
+ var floodTxs []*types.Transaction
+ for _, key := range fillerKeys {
+ for n := uint64(0); n < uint64(perFillerChainLen); n++ {
+ floodTxs = append(floodTxs, buildReservedTx(key, n))
+ }
+ }
+
+ var admitted, capRejected int
+ for i, err := range nodes[0].TxPool().Add(floodTxs, true) {
+ switch {
+ case err == nil:
+ admitted++
+ case errors.Is(err, legacypool.ErrReservedOccupancyExceeded):
+ capRejected++
+ default:
+ t.Fatalf("flood tx %d: unexpected error %v", i, err)
+ }
+ }
+ t.Logf("flood: %d/%d admitted, %d rejected for exceeding reserved occupancy (cap %d)", admitted, len(floodTxs), capRejected, wantCap)
+ if capRejected == 0 {
+ t.Fatalf("expected some flood transactions to be rejected once occupancy hit the cap (%d); got %d admitted, %d rejected", wantCap, admitted, capRejected)
+ }
+ if admitted > wantCap {
+ t.Fatalf("admitted reserved occupancy (%d) exceeds the cap (%d)", admitted, wantCap)
+ }
+
+ // A different reserved sender's transaction (whitelisted, but under a
+ // separate, otherwise-unused client) must be rejected purely on
+ // occupancy grounds: classification isn't the reason, aggregate reserved
+ // occupancy is. This is deterministic regardless of block timing —
+ // nothing admitted above is ever executable, so occupancy cannot drop on
+ // its own while we check.
+ probeTx := buildReservedTx(probeKey, 0)
+ if err := nodes[0].TxPool().Add([]*types.Transaction{probeTx}, true)[0]; err == nil {
+ t.Fatal("probe reserved tx should have been rejected while occupancy is at the cap")
+ } else if !errors.Is(err, legacypool.ErrReservedOccupancyExceeded) {
+ t.Fatalf("probe reserved tx rejected with unexpected error: %v", err)
+ }
+
+ // A normal, fee-paying sender unrelated to either reserved client must
+ // still be admitted and mined throughout — headroom genuinely held.
+ normalTx, err := types.SignNewTx(normalKey, signer, &types.DynamicFeeTx{
+ ChainID: genesis.Config.ChainID,
+ Nonce: 0,
+ GasTipCap: big.NewInt(30_000_000_000),
+ GasFeeCap: big.NewInt(100_000_000_000),
+ Gas: reservedPoolOccupancyGas,
+ To: &recipient,
+ Value: big.NewInt(0),
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, node := range nodes {
+ for i, err := range node.TxPool().Add([]*types.Transaction{normalTx}, true) {
+ if err != nil {
+ t.Fatalf("normal sender tx %d rejected: %v", i, err)
+ }
+ }
+ }
+ normalBlock := waitMined(normalTx.Hash(), "normal sender's transaction")
+ t.Logf("normal sender's transaction mined in block %d while reserved occupancy was saturated", normalBlock.NumberU64())
+
+ // Deregister the flooding client (a realistic governance action: suspend
+ // a client entirely). Its transactions stay physically in node0's pool —
+ // nothing here evicts them — but they stop counting toward the cap once
+ // purged by the next head's Layer-2 recompute, freeing the entire flood's
+ // occupancy at once for every other reserved sender, including probeAddr.
+ deactivateTx := sendOwnerTx(oNonce+3, 200_000, mustPack(t, setupABI, "setClientActive", big.NewInt(1), false))
+ deactivateBlock := waitMined(deactivateTx.Hash(), "setClientActive(1, false)")
+ t.Logf("fillers' client deactivated in block %d", deactivateBlock.NumberU64())
+
+ // No permanent starvation: once occupancy has freed up, the
+ // previously-rejected probe transaction is admitted. The snapshot
+ // rebuild and Layer-2 recompute both run per new head, so poll for a
+ // few blocks rather than assuming the very next one already reflects it.
+ deadline := time.After(60 * time.Second)
+ for {
+ err := nodes[0].TxPool().Add([]*types.Transaction{probeTx}, true)[0]
+ if err == nil {
+ break
+ }
+ if !errors.Is(err, legacypool.ErrReservedOccupancyExceeded) {
+ t.Fatalf("probe reserved tx rejected with unexpected error after deregistration: %v", err)
+ }
+ select {
+ case <-deadline:
+ t.Fatal("probe reserved tx was never admitted after the flooding client was deregistered")
+ case <-time.After(200 * time.Millisecond):
+ }
+ }
+ // Submit to node1 too, so it's the whole network's canonical chain (not
+ // just node0's private pool state) that ends up including it.
+ if err := nodes[1].TxPool().Add([]*types.Transaction{probeTx}, true)[0]; err != nil {
+ t.Fatalf("probe reserved tx rejected by node1: %v", err)
+ }
+ probeBlock := waitMined(probeTx.Hash(), "the probe reserved tx")
+ t.Logf("probe reserved tx mined in block %d after the flooding client was deregistered", probeBlock.NumberU64())
+
+ // Settle a few confirmations past the last event of interest, then
+ // compare canonical chains for produce/verify parity: the second node
+ // (which did not exclusively produce this window) must agree.
+ settleTarget := probeBlock.NumberU64() + 3
+ waitForBlock(settleTarget)
+ deadline = time.After(60 * time.Second)
+ for {
+ h0 := nodes[0].BlockChain().GetBlockByNumber(settleTarget)
+ h1 := nodes[1].BlockChain().GetBlockByNumber(settleTarget)
+ if h0 != nil && h1 != nil && h0.Hash() == h1.Hash() {
+ break
+ }
+ select {
+ case <-deadline:
+ t.Fatalf("nodes never converged on block %d", settleTarget)
+ case <-time.After(200 * time.Millisecond):
+ }
+ }
+}
+
+// mustPack packs an ABI call, failing the test on error. Kept separate from
+// the inline setupABI.Pack calls above only because setClientActive's second
+// argument is a bare bool literal, which reads awkwardly inline.
+func mustPack(t *testing.T, a abi.ABI, method string, args ...interface{}) []byte {
+ t.Helper()
+ data, err := a.Pack(method, args...)
+ if err != nil {
+ t.Fatalf("pack %s: %v", method, err)
+ }
+ return data
+}
diff --git a/tests/bor/reserved_receipts_test.go b/tests/bor/reserved_receipts_test.go
new file mode 100644
index 0000000000..dcd5de88bc
--- /dev/null
+++ b/tests/bor/reserved_receipts_test.go
@@ -0,0 +1,419 @@
+//go:build integration
+// +build integration
+
+package bor
+
+import (
+ "context"
+ "crypto/ecdsa"
+ "math/big"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/ethereum/go-ethereum/accounts/abi"
+ "github.com/ethereum/go-ethereum/common"
+ "github.com/ethereum/go-ethereum/common/hexutil"
+ "github.com/ethereum/go-ethereum/core/types"
+ "github.com/ethereum/go-ethereum/crypto"
+ "github.com/ethereum/go-ethereum/eth"
+ "github.com/ethereum/go-ethereum/internal/ethapi"
+ "github.com/ethereum/go-ethereum/params"
+ "github.com/ethereum/go-ethereum/rpc"
+)
+
+// TestReservedBlockspaceEffectiveGasPriceRPC is the POS-3670 end-to-end
+// validation: every reserved-region receipt and transaction view reports
+// effectiveGasPrice/gasPrice 0, including for a reserved sender that carries a
+// real (fallback) fee but executes fee-free within its quota - the case the
+// fee-derived formula gets wrong without the persisted classification this
+// task adds.
+//
+// Four independent single-member clients avoid any cross-sender quota
+// ordering ambiguity for the three "reserved" cases: a zero-fee sender, a
+// fallback-fee dynamic-fee sender, and a fallback-fee legacy sender, each the
+// sole member of a client whose quota fits exactly its one transaction, so
+// each is unambiguously reserved. A fifth client has two members sharing a
+// quota that fits only one transaction - a zero-fee sender (which always
+// wins the ascending-price reserved selection) and a fallback-fee sender -
+// exercising a genuine quota overflow into the normal, fee-paying region
+// (mirroring TestReservedBlockspaceQuotaOverflowPaysNormalFees's proven
+// pattern) for the fourth case.
+//
+// Assertions go through eth_getTransactionReceipt, eth_getBlockReceipts, and
+// the three eth_getTransactionBy* views (via the same ethapi handlers the
+// JSON-RPC server dispatches to), on both the producing node and a peer that
+// only verified the block, so the check also pins produce/verify parity for
+// the persisted side table.
+//
+// Run with: go test -tags=integration -run TestReservedBlockspaceEffectiveGasPriceRPC ./tests/bor/
+func TestReservedBlockspaceEffectiveGasPriceRPC(t *testing.T) {
+ faucets := make([]*ecdsa.PrivateKey, 10)
+ for i := range faucets {
+ faucets[i], _ = crypto.GenerateKey()
+ }
+ zeroKey := faucets[0] // client A (sole member): reserved, zero-fee
+ dynKey := faucets[1] // client B (sole member): reserved, fallback-fee (dynamic-fee type)
+ legacyKey := faucets[2] // client C (sole member): reserved, fallback-fee (legacy type)
+ overflowWinnerKey := faucets[3] // client D member 1: zero-fee, wins the shared quota
+ overflowKey := faucets[4] // client D member 2: fallback-fee, overflows to normal
+ ownerKey := faucets[5]
+
+ zeroAddr := crypto.PubkeyToAddress(zeroKey.PublicKey)
+ dynAddr := crypto.PubkeyToAddress(dynKey.PublicKey)
+ legacyAddr := crypto.PubkeyToAddress(legacyKey.PublicKey)
+ overflowWinnerAddr := crypto.PubkeyToAddress(overflowWinnerKey.PublicKey)
+ overflowAddr := crypto.PubkeyToAddress(overflowKey.PublicKey)
+ ownerAddr := crypto.PubkeyToAddress(ownerKey.PublicKey)
+
+ registryAddr := common.HexToAddress(params.DefaultReservedRegistryContract)
+ setupABI, err := abi.JSON(strings.NewReader(reservedRegistrySetupABI))
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ genesis := InitGenesis(t, faucets, "./testdata/genesis_2val.json", 8)
+ reservedFork := uint64(5)
+ genesis.Config.Bor.ReservedBlockspaceBlock = new(big.Int).SetUint64(reservedFork)
+ genesis.Config.Bor.GiuglianoBlock = new(big.Int).SetUint64(reservedFork)
+ genesis.Config.ShanghaiBlock = big.NewInt(2)
+ genesis.Config.Bor.ReservedRegistryContract = params.DefaultReservedRegistryContract
+ genesis.Alloc[registryAddr] = types.Account{
+ Balance: new(big.Int),
+ Code: common.FromHex(params.ReservedBlockspaceRegistryCode),
+ }
+
+ startBalance := new(big.Int).SetUint64(1_000_000_000_000_000_000) // 1 ETH
+ for _, a := range []common.Address{zeroAddr, dynAddr, legacyAddr, overflowWinnerAddr, overflowAddr, ownerAddr} {
+ genesis.Alloc[a] = types.Account{Balance: new(big.Int).Set(startBalance)}
+ }
+
+ stacks, nodes, _ := setupMiner(t, 2, genesis)
+ defer func() {
+ for _, stack := range stacks {
+ stack.Close()
+ }
+ }()
+ for _, node := range nodes {
+ if err := node.StartMining(); err != nil {
+ t.Fatal("start mining:", err)
+ }
+ }
+
+ waitForBlock := func(target uint64) {
+ t.Helper()
+ deadline := time.After(120 * time.Second)
+ for {
+ if nodes[0].BlockChain().CurrentBlock().Number.Uint64() >= target {
+ return
+ }
+ select {
+ case <-deadline:
+ t.Fatalf("timeout waiting for block %d (at %d)", target, nodes[0].BlockChain().CurrentBlock().Number.Uint64())
+ case <-time.After(200 * time.Millisecond):
+ }
+ }
+ }
+ waitMined := func(txHash common.Hash, what string) *types.Block {
+ t.Helper()
+ deadline := time.After(60 * time.Second)
+ for {
+ if blk := blockContaining(nodes[0], txHash); blk != nil {
+ return blk
+ }
+ select {
+ case <-deadline:
+ t.Fatalf("timeout waiting for %s to be mined", what)
+ case <-time.After(200 * time.Millisecond):
+ }
+ }
+ }
+
+ signer := types.LatestSigner(genesis.Config)
+
+ sendOwnerTx := func(nonce uint64, data []byte) *types.Transaction {
+ t.Helper()
+ tx, err := types.SignNewTx(ownerKey, signer, &types.DynamicFeeTx{
+ ChainID: genesis.Config.ChainID,
+ Nonce: nonce,
+ GasTipCap: big.NewInt(30_000_000_000),
+ GasFeeCap: big.NewInt(100_000_000_000),
+ Gas: 1_000_000,
+ To: ®istryAddr,
+ Value: big.NewInt(0),
+ Data: data,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := nodes[0].APIBackend.SendTx(context.Background(), tx); err != nil {
+ t.Fatalf("registry setup tx rejected: %v", err)
+ }
+ return tx
+ }
+
+ initData, err := setupABI.Pack("initialize", ownerAddr, uint64(8_000_000), uint64(5_000_000))
+ if err != nil {
+ t.Fatal(err)
+ }
+ packCreate := func(quota uint64, metadata string, addrs []common.Address) []byte {
+ t.Helper()
+ data, err := setupABI.Pack("createClient", ownerAddr, quota, uint8(0), uint64(0), metadata, addrs)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return data
+ }
+ createA := packCreate(21_000, "A-zero", []common.Address{zeroAddr})
+ createB := packCreate(21_000, "B-dyn-fallback", []common.Address{dynAddr})
+ createC := packCreate(21_000, "C-legacy-fallback", []common.Address{legacyAddr})
+ // Client D: quota fits exactly one of its two members' transactions.
+ createD := packCreate(21_000, "D-overflow", []common.Address{overflowWinnerAddr, overflowAddr})
+
+ waitForBlock(2)
+ oNonce, err := nodes[0].APIBackend.GetPoolNonce(context.Background(), ownerAddr)
+ if err != nil {
+ t.Fatal(err)
+ }
+ sendOwnerTx(oNonce, initData)
+ sendOwnerTx(oNonce+1, createA)
+ sendOwnerTx(oNonce+2, createB)
+ sendOwnerTx(oNonce+3, createC)
+ lastCreate := sendOwnerTx(oNonce+4, createD)
+ seedBlock := waitMined(lastCreate.Hash(), "createClient D")
+ if seedBlock.NumberU64() >= 16 {
+ t.Fatalf("registry seeded too late (block %d) for the 17-24 producer window", seedBlock.NumberU64())
+ }
+ waitForBlock(17)
+
+ recipient := common.HexToAddress("0x00000000000000000000000000000000000000aa")
+
+ zeroTx, err := types.SignNewTx(zeroKey, signer, &types.DynamicFeeTx{
+ ChainID: genesis.Config.ChainID, Nonce: 0,
+ GasTipCap: big.NewInt(0), GasFeeCap: big.NewInt(0),
+ Gas: 21000, To: &recipient, Value: big.NewInt(100),
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ dynTx, err := types.SignNewTx(dynKey, signer, &types.DynamicFeeTx{
+ ChainID: genesis.Config.ChainID, Nonce: 0,
+ GasTipCap: big.NewInt(1_000_000_000), GasFeeCap: big.NewInt(2_000_000_000),
+ Gas: 21000, To: &recipient, Value: big.NewInt(100),
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ legacyTx, err := types.SignNewTx(legacyKey, signer, &types.LegacyTx{
+ Nonce: 0, GasPrice: big.NewInt(2_000_000_000),
+ Gas: 21000, To: &recipient, Value: big.NewInt(100),
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ // Client D's two members: overflowWinnerTx is zero-fee (always wins the
+ // ascending-price reserved selection), overflowTx carries a real fee and
+ // overflows the one-transaction quota to the normal, fee-paying region.
+ overflowWinnerTx, err := types.SignNewTx(overflowWinnerKey, signer, &types.DynamicFeeTx{
+ ChainID: genesis.Config.ChainID, Nonce: 0,
+ GasTipCap: big.NewInt(0), GasFeeCap: big.NewInt(0),
+ Gas: 21000, To: &recipient, Value: big.NewInt(100),
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ overflowTx, err := types.SignNewTx(overflowKey, signer, &types.DynamicFeeTx{
+ ChainID: genesis.Config.ChainID, Nonce: 0,
+ GasTipCap: big.NewInt(5_000_000_000), GasFeeCap: big.NewInt(10_000_000_000),
+ Gas: 21000, To: &recipient, Value: big.NewInt(100),
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ allTxs := []*types.Transaction{zeroTx, dynTx, legacyTx, overflowWinnerTx, overflowTx}
+ for _, node := range nodes {
+ for _, tx := range allTxs {
+ if err := node.APIBackend.SendTx(context.Background(), tx); err != nil &&
+ !strings.Contains(err.Error(), "already known") {
+ t.Fatalf("tx %s rejected by pool: %v", tx.Hash(), err)
+ }
+ }
+ }
+
+ var maxBlk uint64
+ for _, tx := range allTxs {
+ if bn := waitMined(tx.Hash(), "reserved-receipts tx").NumberU64(); bn > maxBlk {
+ maxBlk = bn
+ }
+ }
+
+ // Settle: wait for both nodes to converge on the tx blocks with a few
+ // confirmations, so fork choice has resolved.
+ settleDeadline := time.After(60 * time.Second)
+ for {
+ h0 := nodes[0].BlockChain().CurrentBlock().Number.Uint64()
+ h1 := nodes[1].BlockChain().CurrentBlock().Number.Uint64()
+ if h0 >= maxBlk+3 && h1 >= maxBlk+3 &&
+ nodes[0].BlockChain().GetBlockByNumber(maxBlk+2).Hash() == nodes[1].BlockChain().GetBlockByNumber(maxBlk+2).Hash() {
+ break
+ }
+ select {
+ case <-settleDeadline:
+ t.Fatal("nodes never converged")
+ case <-time.After(200 * time.Millisecond):
+ }
+ }
+
+ // want maps each tx to its expected effectiveGasPrice/gasPrice: 0 for the
+ // three reserved ones, the real market price for the overflow one.
+ want := map[common.Hash]*big.Int{
+ zeroTx.Hash(): big.NewInt(0),
+ dynTx.Hash(): big.NewInt(0),
+ legacyTx.Hash(): big.NewInt(0),
+ overflowWinnerTx.Hash(): big.NewInt(0),
+ overflowTx.Hash(): nil, // filled in from the overflow receipt itself below
+ }
+
+ assertNode := func(t *testing.T, n *eth.Ethereum, label string) {
+ t.Helper()
+
+ nonceLock := new(ethapi.AddrLocker)
+ txAPI := ethapi.NewTransactionAPI(n.APIBackend, nonceLock)
+ chainAPI := ethapi.NewBlockChainAPI(n.APIBackend)
+ ctx := context.Background()
+
+ // Fill in the overflow tx's expected price from its own receipt: it
+ // must be non-zero (it paid real fees), and every other view must
+ // agree with whatever that receipt says.
+ overflowReceipt, err := txAPI.GetTransactionReceipt(ctx, overflowTx.Hash())
+ if err != nil || overflowReceipt == nil {
+ t.Fatalf("[%s] overflow tx receipt not found: %v", label, err)
+ }
+ gotOverflowPrice := effectiveGasPriceOf(t, overflowReceipt)
+ if gotOverflowPrice.Sign() == 0 {
+ t.Fatalf("[%s] overflow tx effectiveGasPrice = 0, want non-zero (quota overflow must pay normal fees)", label)
+ }
+ want[overflowTx.Hash()] = gotOverflowPrice
+
+ for _, tx := range allTxs {
+ wantPrice := want[tx.Hash()]
+
+ // eth_getTransactionReceipt
+ receipt, err := txAPI.GetTransactionReceipt(ctx, tx.Hash())
+ if err != nil || receipt == nil {
+ t.Fatalf("[%s] GetTransactionReceipt(%s) failed: %v", label, tx.Hash(), err)
+ }
+ if got := effectiveGasPriceOf(t, receipt); got.Cmp(wantPrice) != 0 {
+ t.Errorf("[%s] GetTransactionReceipt(%s).effectiveGasPrice = %s, want %s", label, tx.Hash(), got, wantPrice)
+ }
+
+ // eth_getTransactionByHash
+ byHash, err := txAPI.GetTransactionByHash(ctx, tx.Hash())
+ if err != nil || byHash == nil {
+ t.Fatalf("[%s] GetTransactionByHash(%s) failed: %v", label, tx.Hash(), err)
+ }
+ if got := (*big.Int)(byHash.GasPrice); got.Cmp(wantPrice) != 0 {
+ t.Errorf("[%s] GetTransactionByHash(%s).gasPrice = %s, want %s", label, tx.Hash(), got, wantPrice)
+ }
+
+ blk := findBlockContaining(t, n, tx.Hash())
+ idx := indexInBlock(t, blk, tx.Hash())
+
+ // eth_getTransactionByBlockHashAndIndex
+ byBlockHash, err := txAPI.GetTransactionByBlockHashAndIndex(ctx, blk.Hash(), hexutil.Uint(idx))
+ if err != nil || byBlockHash == nil {
+ t.Fatalf("[%s] GetTransactionByBlockHashAndIndex(%s, %d) failed: %v", label, blk.Hash(), idx, err)
+ }
+ if got := (*big.Int)(byBlockHash.GasPrice); got.Cmp(wantPrice) != 0 {
+ t.Errorf("[%s] GetTransactionByBlockHashAndIndex(%s, %d).gasPrice = %s, want %s", label, blk.Hash(), idx, got, wantPrice)
+ }
+
+ // eth_getTransactionByBlockNumberAndIndex
+ byBlockNumber, err := txAPI.GetTransactionByBlockNumberAndIndex(ctx, rpc.BlockNumber(blk.NumberU64()), hexutil.Uint(idx))
+ if err != nil || byBlockNumber == nil {
+ t.Fatalf("[%s] GetTransactionByBlockNumberAndIndex(%d, %d) failed: %v", label, blk.NumberU64(), idx, err)
+ }
+ if got := (*big.Int)(byBlockNumber.GasPrice); got.Cmp(wantPrice) != 0 {
+ t.Errorf("[%s] GetTransactionByBlockNumberAndIndex(%d, %d).gasPrice = %s, want %s", label, blk.NumberU64(), idx, got, wantPrice)
+ }
+
+ // eth_getBlockReceipts
+ blockReceipts, err := chainAPI.GetBlockReceipts(ctx, rpc.BlockNumberOrHashWithHash(blk.Hash(), false))
+ if err != nil {
+ t.Fatalf("[%s] GetBlockReceipts(%s) failed: %v", label, blk.Hash(), err)
+ }
+ found := false
+ for _, r := range blockReceipts {
+ if r["transactionHash"].(common.Hash) != tx.Hash() {
+ continue
+ }
+ found = true
+ if got := effectiveGasPriceOf(t, r); got.Cmp(wantPrice) != 0 {
+ t.Errorf("[%s] GetBlockReceipts(%s)[%s].effectiveGasPrice = %s, want %s", label, blk.Hash(), tx.Hash(), got, wantPrice)
+ }
+ }
+ if !found {
+ t.Fatalf("[%s] GetBlockReceipts(%s) did not contain tx %s", label, blk.Hash(), tx.Hash())
+ }
+ }
+ }
+
+ // Assert on the producing node and on a peer that only imported and
+ // verified the block: the persisted side table must agree everywhere.
+ assertNode(t, nodes[0], "producer")
+ assertNode(t, nodes[1], "verifier")
+}
+
+// blockContaining returns the canonical block holding txHash on node n, or
+// nil if none does (yet). The single scan shared by waitMined (which retries
+// it until a deadline) and findBlockContaining (which expects it to already
+// be there, past the settle point, and fails hard on a miss).
+func blockContaining(n *eth.Ethereum, txHash common.Hash) *types.Block {
+ head := n.BlockChain().CurrentBlock().Number.Uint64()
+ for h := uint64(0); h <= head; h++ {
+ blk := n.BlockChain().GetBlockByNumber(h)
+ if blk == nil {
+ continue
+ }
+ for _, tx := range blk.Transactions() {
+ if tx.Hash() == txHash {
+ return blk
+ }
+ }
+ }
+ return nil
+}
+
+// findBlockContaining is blockContaining with a hard failure on a miss.
+func findBlockContaining(t *testing.T, n *eth.Ethereum, txHash common.Hash) *types.Block {
+ t.Helper()
+ blk := blockContaining(n, txHash)
+ if blk == nil {
+ t.Fatalf("block containing tx %s not found", txHash)
+ }
+ return blk
+}
+
+// indexInBlock returns txHash's position within block.Transactions().
+func indexInBlock(t *testing.T, block *types.Block, txHash common.Hash) int {
+ t.Helper()
+ for i, tx := range block.Transactions() {
+ if tx.Hash() == txHash {
+ return i
+ }
+ }
+ t.Fatalf("tx %s not found in block %s", txHash, block.Hash())
+ return -1
+}
+
+// effectiveGasPriceOf extracts effectiveGasPrice from a marshalled receipt map
+// (as returned by ethapi's GetTransactionReceipt/GetBlockReceipts) as *big.Int.
+func effectiveGasPriceOf(t *testing.T, receipt map[string]interface{}) *big.Int {
+ t.Helper()
+ v, ok := receipt["effectiveGasPrice"].(*hexutil.Big)
+ if !ok || v == nil {
+ t.Fatalf("receipt missing effectiveGasPrice: %+v", receipt)
+ }
+ return (*big.Int)(v)
+}