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) +}