diff --git a/cmd/keeper/go.mod b/cmd/keeper/go.mod
index 9842ebee56..4f13be0ff6 100644
--- a/cmd/keeper/go.mod
+++ b/cmd/keeper/go.mod
@@ -48,9 +48,9 @@ require (
github.com/tklauser/go-sysconf v0.3.16 // indirect
github.com/tklauser/numcpus v0.11.0 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
- golang.org/x/crypto v0.50.0 // indirect
+ golang.org/x/crypto v0.51.0 // indirect
golang.org/x/sync v0.20.0 // indirect
- golang.org/x/sys v0.43.0 // indirect
+ golang.org/x/sys v0.45.0 // indirect
golang.org/x/time v0.12.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
)
diff --git a/consensus/bor/abi/common.go b/consensus/bor/abi/common.go
index 01aa0938f2..9a2818ca97 100644
--- a/consensus/bor/abi/common.go
+++ b/consensus/bor/abi/common.go
@@ -15,6 +15,7 @@ var SystemTxGas = (hexutil.Uint64)(math.MaxUint64 / 2)
var (
vABI = mustParseABI(validatorsetABI)
sABI = mustParseABI(stateReceiverABI)
+ rABI = mustParseABI(reservedRegistryABI)
)
func ValidatorSet() abi.ABI {
@@ -25,9 +26,14 @@ func StateReceiver() abi.ABI {
return sABI
}
+func ReservedBlockspaceRegistry() abi.ABI {
+ return rABI
+}
+
const (
- validatorsetABI = `[{"constant":true,"inputs":[],"name":"SPRINT","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[],"name":"SYSTEM_ADDRESS","outputs":[{"internalType":"address","name":"","type":"address"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[],"name":"CHAIN","outputs":[{"internalType":"bytes32","name":"","type":"bytes32"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[],"name":"FIRST_END_BLOCK","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[{"internalType":"uint256","name":"","type":"uint256"},{"internalType":"uint256","name":"","type":"uint256"}],"name":"producers","outputs":[{"internalType":"uint256","name":"id","type":"uint256"},{"internalType":"uint256","name":"power","type":"uint256"},{"internalType":"address","name":"signer","type":"address"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[],"name":"ROUND_TYPE","outputs":[{"internalType":"bytes32","name":"","type":"bytes32"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[],"name":"BOR_ID","outputs":[{"internalType":"bytes32","name":"","type":"bytes32"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[{"internalType":"uint256","name":"","type":"uint256"}],"name":"spanNumbers","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[],"name":"VOTE_TYPE","outputs":[{"internalType":"uint8","name":"","type":"uint8"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[{"internalType":"uint256","name":"","type":"uint256"},{"internalType":"uint256","name":"","type":"uint256"}],"name":"validators","outputs":[{"internalType":"uint256","name":"id","type":"uint256"},{"internalType":"uint256","name":"power","type":"uint256"},{"internalType":"address","name":"signer","type":"address"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[{"internalType":"uint256","name":"","type":"uint256"}],"name":"spans","outputs":[{"internalType":"uint256","name":"number","type":"uint256"},{"internalType":"uint256","name":"startBlock","type":"uint256"},{"internalType":"uint256","name":"endBlock","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"inputs":[],"payable":false,"stateMutability":"nonpayable","type":"constructor"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"uint256","name":"id","type":"uint256"},{"indexed":true,"internalType":"uint256","name":"startBlock","type":"uint256"},{"indexed":true,"internalType":"uint256","name":"endBlock","type":"uint256"}],"name":"NewSpan","type":"event"},{"constant":true,"inputs":[],"name":"currentSprint","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[{"internalType":"uint256","name":"span","type":"uint256"}],"name":"getSpan","outputs":[{"internalType":"uint256","name":"number","type":"uint256"},{"internalType":"uint256","name":"startBlock","type":"uint256"},{"internalType":"uint256","name":"endBlock","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[],"name":"getCurrentSpan","outputs":[{"internalType":"uint256","name":"number","type":"uint256"},{"internalType":"uint256","name":"startBlock","type":"uint256"},{"internalType":"uint256","name":"endBlock","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[],"name":"getNextSpan","outputs":[{"internalType":"uint256","name":"number","type":"uint256"},{"internalType":"uint256","name":"startBlock","type":"uint256"},{"internalType":"uint256","name":"endBlock","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[{"internalType":"uint256","name":"number","type":"uint256"}],"name":"getSpanByBlock","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[],"name":"currentSpanNumber","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[{"internalType":"uint256","name":"span","type":"uint256"}],"name":"getValidatorsTotalStakeBySpan","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[{"internalType":"uint256","name":"span","type":"uint256"}],"name":"getProducersTotalStakeBySpan","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[{"internalType":"uint256","name":"span","type":"uint256"},{"internalType":"address","name":"signer","type":"address"}],"name":"getValidatorBySigner","outputs":[{"components":[{"internalType":"uint256","name":"id","type":"uint256"},{"internalType":"uint256","name":"power","type":"uint256"},{"internalType":"address","name":"signer","type":"address"}],"internalType":"struct BorValidatorSet.Validator","name":"result","type":"tuple"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[{"internalType":"uint256","name":"span","type":"uint256"},{"internalType":"address","name":"signer","type":"address"}],"name":"isValidator","outputs":[{"internalType":"bool","name":"","type":"bool"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[{"internalType":"uint256","name":"span","type":"uint256"},{"internalType":"address","name":"signer","type":"address"}],"name":"isProducer","outputs":[{"internalType":"bool","name":"","type":"bool"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[{"internalType":"address","name":"signer","type":"address"}],"name":"isCurrentValidator","outputs":[{"internalType":"bool","name":"","type":"bool"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[{"internalType":"address","name":"signer","type":"address"}],"name":"isCurrentProducer","outputs":[{"internalType":"bool","name":"","type":"bool"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[{"internalType":"uint256","name":"number","type":"uint256"}],"name":"getBorValidators","outputs":[{"internalType":"address[]","name":"","type":"address[]"},{"internalType":"uint256[]","name":"","type":"uint256[]"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[],"name":"getInitialValidators","outputs":[{"internalType":"address[]","name":"","type":"address[]"},{"internalType":"uint256[]","name":"","type":"uint256[]"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[],"name":"getValidators","outputs":[{"internalType":"address[]","name":"","type":"address[]"},{"internalType":"uint256[]","name":"","type":"uint256[]"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":false,"inputs":[{"internalType":"uint256","name":"newSpan","type":"uint256"},{"internalType":"uint256","name":"startBlock","type":"uint256"},{"internalType":"uint256","name":"endBlock","type":"uint256"},{"internalType":"bytes","name":"validatorBytes","type":"bytes"},{"internalType":"bytes","name":"producerBytes","type":"bytes"}],"name":"commitSpan","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":true,"inputs":[{"internalType":"uint256","name":"span","type":"uint256"},{"internalType":"bytes32","name":"dataHash","type":"bytes32"},{"internalType":"bytes","name":"sigs","type":"bytes"}],"name":"getStakePowerBySigs","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[{"internalType":"bytes32","name":"rootHash","type":"bytes32"},{"internalType":"bytes32","name":"leaf","type":"bytes32"},{"internalType":"bytes","name":"proof","type":"bytes"}],"name":"checkMembership","outputs":[{"internalType":"bool","name":"","type":"bool"}],"payable":false,"stateMutability":"pure","type":"function"},{"constant":true,"inputs":[{"internalType":"bytes32","name":"d","type":"bytes32"}],"name":"leafNode","outputs":[{"internalType":"bytes32","name":"","type":"bytes32"}],"payable":false,"stateMutability":"pure","type":"function"},{"constant":true,"inputs":[{"internalType":"bytes32","name":"left","type":"bytes32"},{"internalType":"bytes32","name":"right","type":"bytes32"}],"name":"innerNode","outputs":[{"internalType":"bytes32","name":"","type":"bytes32"}],"payable":false,"stateMutability":"pure","type":"function"}]`
- stateReceiverABI = `[{"constant":true,"inputs":[],"name":"SYSTEM_ADDRESS","outputs":[{"internalType":"address","name":"","type":"address"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[],"name":"lastStateId","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":false,"inputs":[{"internalType":"uint256","name":"syncTime","type":"uint256"},{"internalType":"bytes","name":"recordBytes","type":"bytes"}],"name":"commitState","outputs":[{"internalType":"bool","name":"success","type":"bool"}],"payable":false,"stateMutability":"nonpayable","type":"function"}]`
+ validatorsetABI = `[{"constant":true,"inputs":[],"name":"SPRINT","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[],"name":"SYSTEM_ADDRESS","outputs":[{"internalType":"address","name":"","type":"address"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[],"name":"CHAIN","outputs":[{"internalType":"bytes32","name":"","type":"bytes32"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[],"name":"FIRST_END_BLOCK","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[{"internalType":"uint256","name":"","type":"uint256"},{"internalType":"uint256","name":"","type":"uint256"}],"name":"producers","outputs":[{"internalType":"uint256","name":"id","type":"uint256"},{"internalType":"uint256","name":"power","type":"uint256"},{"internalType":"address","name":"signer","type":"address"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[],"name":"ROUND_TYPE","outputs":[{"internalType":"bytes32","name":"","type":"bytes32"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[],"name":"BOR_ID","outputs":[{"internalType":"bytes32","name":"","type":"bytes32"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[{"internalType":"uint256","name":"","type":"uint256"}],"name":"spanNumbers","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[],"name":"VOTE_TYPE","outputs":[{"internalType":"uint8","name":"","type":"uint8"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[{"internalType":"uint256","name":"","type":"uint256"},{"internalType":"uint256","name":"","type":"uint256"}],"name":"validators","outputs":[{"internalType":"uint256","name":"id","type":"uint256"},{"internalType":"uint256","name":"power","type":"uint256"},{"internalType":"address","name":"signer","type":"address"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[{"internalType":"uint256","name":"","type":"uint256"}],"name":"spans","outputs":[{"internalType":"uint256","name":"number","type":"uint256"},{"internalType":"uint256","name":"startBlock","type":"uint256"},{"internalType":"uint256","name":"endBlock","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"inputs":[],"payable":false,"stateMutability":"nonpayable","type":"constructor"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"uint256","name":"id","type":"uint256"},{"indexed":true,"internalType":"uint256","name":"startBlock","type":"uint256"},{"indexed":true,"internalType":"uint256","name":"endBlock","type":"uint256"}],"name":"NewSpan","type":"event"},{"constant":true,"inputs":[],"name":"currentSprint","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[{"internalType":"uint256","name":"span","type":"uint256"}],"name":"getSpan","outputs":[{"internalType":"uint256","name":"number","type":"uint256"},{"internalType":"uint256","name":"startBlock","type":"uint256"},{"internalType":"uint256","name":"endBlock","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[],"name":"getCurrentSpan","outputs":[{"internalType":"uint256","name":"number","type":"uint256"},{"internalType":"uint256","name":"startBlock","type":"uint256"},{"internalType":"uint256","name":"endBlock","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[],"name":"getNextSpan","outputs":[{"internalType":"uint256","name":"number","type":"uint256"},{"internalType":"uint256","name":"startBlock","type":"uint256"},{"internalType":"uint256","name":"endBlock","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[{"internalType":"uint256","name":"number","type":"uint256"}],"name":"getSpanByBlock","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[],"name":"currentSpanNumber","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[{"internalType":"uint256","name":"span","type":"uint256"}],"name":"getValidatorsTotalStakeBySpan","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[{"internalType":"uint256","name":"span","type":"uint256"}],"name":"getProducersTotalStakeBySpan","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[{"internalType":"uint256","name":"span","type":"uint256"},{"internalType":"address","name":"signer","type":"address"}],"name":"getValidatorBySigner","outputs":[{"components":[{"internalType":"uint256","name":"id","type":"uint256"},{"internalType":"uint256","name":"power","type":"uint256"},{"internalType":"address","name":"signer","type":"address"}],"internalType":"struct BorValidatorSet.Validator","name":"result","type":"tuple"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[{"internalType":"uint256","name":"span","type":"uint256"},{"internalType":"address","name":"signer","type":"address"}],"name":"isValidator","outputs":[{"internalType":"bool","name":"","type":"bool"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[{"internalType":"uint256","name":"span","type":"uint256"},{"internalType":"address","name":"signer","type":"address"}],"name":"isProducer","outputs":[{"internalType":"bool","name":"","type":"bool"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[{"internalType":"address","name":"signer","type":"address"}],"name":"isCurrentValidator","outputs":[{"internalType":"bool","name":"","type":"bool"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[{"internalType":"address","name":"signer","type":"address"}],"name":"isCurrentProducer","outputs":[{"internalType":"bool","name":"","type":"bool"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[{"internalType":"uint256","name":"number","type":"uint256"}],"name":"getBorValidators","outputs":[{"internalType":"address[]","name":"","type":"address[]"},{"internalType":"uint256[]","name":"","type":"uint256[]"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[],"name":"getInitialValidators","outputs":[{"internalType":"address[]","name":"","type":"address[]"},{"internalType":"uint256[]","name":"","type":"uint256[]"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[],"name":"getValidators","outputs":[{"internalType":"address[]","name":"","type":"address[]"},{"internalType":"uint256[]","name":"","type":"uint256[]"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":false,"inputs":[{"internalType":"uint256","name":"newSpan","type":"uint256"},{"internalType":"uint256","name":"startBlock","type":"uint256"},{"internalType":"uint256","name":"endBlock","type":"uint256"},{"internalType":"bytes","name":"validatorBytes","type":"bytes"},{"internalType":"bytes","name":"producerBytes","type":"bytes"}],"name":"commitSpan","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":true,"inputs":[{"internalType":"uint256","name":"span","type":"uint256"},{"internalType":"bytes32","name":"dataHash","type":"bytes32"},{"internalType":"bytes","name":"sigs","type":"bytes"}],"name":"getStakePowerBySigs","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[{"internalType":"bytes32","name":"rootHash","type":"bytes32"},{"internalType":"bytes32","name":"leaf","type":"bytes32"},{"internalType":"bytes","name":"proof","type":"bytes"}],"name":"checkMembership","outputs":[{"internalType":"bool","name":"","type":"bool"}],"payable":false,"stateMutability":"pure","type":"function"},{"constant":true,"inputs":[{"internalType":"bytes32","name":"d","type":"bytes32"}],"name":"leafNode","outputs":[{"internalType":"bytes32","name":"","type":"bytes32"}],"payable":false,"stateMutability":"pure","type":"function"},{"constant":true,"inputs":[{"internalType":"bytes32","name":"left","type":"bytes32"},{"internalType":"bytes32","name":"right","type":"bytes32"}],"name":"innerNode","outputs":[{"internalType":"bytes32","name":"","type":"bytes32"}],"payable":false,"stateMutability":"pure","type":"function"}]`
+ stateReceiverABI = `[{"constant":true,"inputs":[],"name":"SYSTEM_ADDRESS","outputs":[{"internalType":"address","name":"","type":"address"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[],"name":"lastStateId","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":false,"inputs":[{"internalType":"uint256","name":"syncTime","type":"uint256"},{"internalType":"bytes","name":"recordBytes","type":"bytes"}],"name":"commitState","outputs":[{"internalType":"bool","name":"success","type":"bool"}],"payable":false,"stateMutability":"nonpayable","type":"function"}]`
+ reservedRegistryABI = `[{"inputs":[{"name":"account","type":"address"}],"name":"isReservedAddress","outputs":[{"name":"","type":"bool"}],"stateMutability":"view","type":"function"},{"inputs":[{"name":"account","type":"address"}],"name":"getClientForAddress","outputs":[{"name":"clientId","type":"uint256"},{"name":"gasQuota","type":"uint64"},{"name":"admin","type":"address"},{"name":"active","type":"bool"},{"name":"feeMode","type":"uint8"},{"name":"effectiveFrom","type":"uint64"}],"stateMutability":"view","type":"function"},{"inputs":[{"name":"clientId","type":"uint256"}],"name":"getClient","outputs":[{"name":"admin","type":"address"},{"name":"gasQuota","type":"uint64"},{"name":"active","type":"bool"},{"name":"metadata","type":"string"},{"name":"addressCount","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[{"name":"clientId","type":"uint256"}],"name":"getClientAddresses","outputs":[{"name":"","type":"address[]"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"getWhitelistedAddresses","outputs":[{"name":"","type":"address[]"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"getReservedClients","outputs":[{"name":"clientIds","type":"uint256[]"},{"name":"admins","type":"address[]"},{"name":"gasQuotas","type":"uint64[]"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"totalReservedGas","outputs":[{"name":"","type":"uint64"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"root","outputs":[{"name":"","type":"bytes32"}],"stateMutability":"view","type":"function"}]`
)
func mustParseABI(json string) abi.ABI {
diff --git a/consensus/bor/bor.go b/consensus/bor/bor.go
index a7e2ab416b..b6d3ddc81f 100644
--- a/consensus/bor/bor.go
+++ b/consensus/bor/bor.go
@@ -32,6 +32,7 @@ import (
"github.com/ethereum/go-ethereum/consensus/bor/api"
"github.com/ethereum/go-ethereum/consensus/bor/clerk"
borSpan "github.com/ethereum/go-ethereum/consensus/bor/heimdall/span"
+ "github.com/ethereum/go-ethereum/consensus/bor/registryreader"
"github.com/ethereum/go-ethereum/consensus/bor/statefull"
"github.com/ethereum/go-ethereum/consensus/bor/valset"
"github.com/ethereum/go-ethereum/consensus/misc"
@@ -106,6 +107,15 @@ var (
// the gas target or base fee change denominator in its extra data.
errMissingGiuglianoFields = errors.New("missing gas target or base fee change denominator in extra data")
+ // errMissingReservedBlockspaceFields is returned if a post-ReservedBlockspace
+ // block is missing the reserved tx count or reserved gas used in its extra data.
+ errMissingReservedBlockspaceFields = errors.New("missing reserved tx count or reserved gas used in extra data")
+
+ // errReservedGasUsedExceedsBlock is returned if a header's reserved gas used
+ // is larger than the block's total gas used — an impossible value, since the
+ // reserved region is a subset of the block.
+ errReservedGasUsedExceedsBlock = errors.New("reserved gas used exceeds block gas used")
+
// errInvalidMixDigest is returned if a block's mix digest is non-zero.
errInvalidMixDigest = errors.New("non-zero mix digest")
@@ -393,6 +403,17 @@ func (c *Bor) GetSpanner() Spanner {
return c.spanner
}
+// ReservedRegistry returns the read-only handle to the reserved blockspace
+// registry, or nil when the GenesisContractsClient does not implement the
+// reader (e.g. test mocks). Callers must tolerate a nil return.
+func (c *Bor) ReservedRegistry() registryreader.Reader {
+ if c == nil {
+ return nil
+ }
+ reader, _ := c.GenesisContractsClient.(registryreader.Reader)
+ return reader
+}
+
func (c *Bor) SetSpanner(spanner Spanner) {
c.spanner = spanner
}
@@ -506,6 +527,10 @@ func (c *Bor) verifyHeader(chain consensus.ChainHeaderReader, header *types.Head
}
}
+ if err := c.verifyReservedFields(header); err != nil {
+ return err
+ }
+
// Ensure that the mix digest is zero as we don't have fork protection currently
if header.MixDigest != (common.Hash{}) {
return errInvalidMixDigest
@@ -1021,6 +1046,40 @@ func (c *Bor) setGiuglianoExtraFields(header *types.Header, parent *types.Header
}
}
+// setReservedBlockspaceExtraFields initializes the reserved-region header field
+// for post-ReservedBlockspace blocks: a placeholder zero so the field is
+// present (non-nil) 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).
+func (c *Bor) setReservedBlockspaceExtraFields(header *types.Header, blockExtraData *types.BlockExtraData) {
+ if c.config.IsReservedBlockspace(header.Number) {
+ var zeroGas uint64
+ blockExtraData.ReservedGasUsed = &zeroGas
+ }
+}
+
+// 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.
+func (c *Bor) verifyReservedFields(header *types.Header) error {
+ if !c.config.IsReservedBlockspace(header.Number) {
+ return nil
+ }
+ reservedGasUsed := header.GetReservedGasUsed(c.chainConfig)
+ if reservedGasUsed == 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
+ }
+ return nil
+}
+
// Prepare implements consensus.Engine, preparing all the consensus fields of the
// header for running the transactions on top.
func (c *Bor) Prepare(chain consensus.ChainHeaderReader, header *types.Header, waitOnPrepare bool) error {
@@ -1076,6 +1135,7 @@ func (c *Bor) Prepare(chain consensus.ChainHeaderReader, header *types.Header, w
}
c.setGiuglianoExtraFields(header, parent, blockExtraData)
+ c.setReservedBlockspaceExtraFields(header, blockExtraData)
blockExtraDataBytes, err := rlp.EncodeToBytes(blockExtraData)
if err != nil {
@@ -1096,6 +1156,7 @@ func (c *Bor) Prepare(chain consensus.ChainHeaderReader, header *types.Header, w
}
c.setGiuglianoExtraFields(header, parent, blockExtraData)
+ c.setReservedBlockspaceExtraFields(header, blockExtraData)
blockExtraDataBytes, err := rlp.EncodeToBytes(blockExtraData)
if err != nil {
diff --git a/consensus/bor/bor_test.go b/consensus/bor/bor_test.go
index 3af6708d04..f686f3abb0 100644
--- a/consensus/bor/bor_test.go
+++ b/consensus/bor/bor_test.go
@@ -5911,6 +5911,89 @@ func TestVerifyHeader_PreGiugliano_NoCheck(t *testing.T) {
}
}
+func TestSetReservedBlockspaceExtraFields(t *testing.T) {
+ t.Parallel()
+
+ // Pre-fork: the reserved field is left nil.
+ bPre := &Bor{config: ¶ms.BorConfig{ReservedBlockspaceBlock: big.NewInt(100)}}
+ bedPre := &types.BlockExtraData{}
+ bPre.setReservedBlockspaceExtraFields(&types.Header{Number: big.NewInt(99)}, bedPre)
+ require.Nil(t, bedPre.ReservedGasUsed)
+
+ // Post-fork: the field is initialized to non-nil zero so the block is valid
+ // even before the miner writes the real value.
+ bPost := &Bor{config: ¶ms.BorConfig{ReservedBlockspaceBlock: big.NewInt(100)}}
+ bedPost := &types.BlockExtraData{}
+ bPost.setReservedBlockspaceExtraFields(&types.Header{Number: big.NewInt(100)}, bedPost)
+ require.NotNil(t, bedPost.ReservedGasUsed)
+ require.Equal(t, uint64(0), *bedPost.ReservedGasUsed)
+}
+
+func TestVerifyHeader_ReservedBlockspaceMissingFields(t *testing.T) {
+ t.Parallel()
+ s := newGiuglianoVerifySetup(t, true)
+ s.b.config.ReservedBlockspaceBlock = big.NewInt(0) // reserved-blockspace active from genesis
+
+ // Giugliano fields present (passes that check) but reserved fields absent.
+ gasTarget := uint64(15_000_000)
+ bfcd := uint64(64)
+ extra := buildBlockExtraBytes(&types.BlockExtraData{
+ GasTarget: &gasTarget,
+ BaseFeeChangeDenominator: &bfcd,
+ })
+ 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)
+ 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,
+ })
+ 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)
+ if err != nil {
+ require.NotErrorIs(t, err, errMissingReservedBlockspaceFields)
+ require.NotErrorIs(t, err, errMissingGiuglianoFields)
+ }
+}
+
+func TestVerifyReservedFields_GasUsedBound(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(5_000)
+ extra := buildBlockExtraBytes(&types.BlockExtraData{
+ GasTarget: &gasTarget,
+ BaseFeeChangeDenominator: &bfcd,
+ ReservedGasUsed: &reservedGasUsed,
+ })
+
+ // Reserved gas used exceeds the block's gas used — impossible, must be rejected.
+ h := &types.Header{Number: big.NewInt(1), GasUsed: 4_000, Extra: extra}
+ require.ErrorIs(t, s.b.verifyReservedFields(h), errReservedGasUsedExceedsBlock)
+
+ // Equal-or-below the block's gas used passes the bound.
+ h.GasUsed = 5_000
+ 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/client.go b/consensus/bor/contract/client.go
index af22624534..c2806ec22f 100644
--- a/consensus/bor/contract/client.go
+++ b/consensus/bor/contract/client.go
@@ -22,12 +22,14 @@ import (
)
type GenesisContractsClient struct {
- validatorSetABI abi.ABI
- stateReceiverABI abi.ABI
- ValidatorContract string
- StateReceiverContract string
- chainConfig *params.ChainConfig
- ethAPI api.Caller
+ validatorSetABI abi.ABI
+ stateReceiverABI abi.ABI
+ reservedRegistryABI abi.ABI
+ ValidatorContract string
+ StateReceiverContract string
+ ReservedRegistryContract string
+ chainConfig *params.ChainConfig
+ ethAPI api.Caller
}
func NewGenesisContractsClient(
@@ -37,12 +39,14 @@ func NewGenesisContractsClient(
ethAPI api.Caller,
) *GenesisContractsClient {
return &GenesisContractsClient{
- validatorSetABI: borabi.ValidatorSet(),
- stateReceiverABI: borabi.StateReceiver(),
- ValidatorContract: validatorContract,
- StateReceiverContract: stateReceiverContract,
- chainConfig: chainConfig,
- ethAPI: ethAPI,
+ validatorSetABI: borabi.ValidatorSet(),
+ stateReceiverABI: borabi.StateReceiver(),
+ reservedRegistryABI: borabi.ReservedBlockspaceRegistry(),
+ ValidatorContract: validatorContract,
+ StateReceiverContract: stateReceiverContract,
+ ReservedRegistryContract: chainConfig.Bor.ReservedRegistryContract,
+ chainConfig: chainConfig,
+ ethAPI: ethAPI,
}
}
diff --git a/consensus/bor/contract/registrytest/harness.go b/consensus/bor/contract/registrytest/harness.go
new file mode 100644
index 0000000000..512f05831a
--- /dev/null
+++ b/consensus/bor/contract/registrytest/harness.go
@@ -0,0 +1,257 @@
+// Package registrytest builds a working reserved blockspace registry inside an
+// in-memory state and returns a registryreader.Reader that talks to it. It is
+// shared across module-level tests (core, core/txpool, miner) so the deploy +
+// initialize + createClient sequence isn't duplicated three times.
+package registrytest
+
+import (
+ "fmt"
+ "math/big"
+ "strings"
+ "testing"
+
+ "github.com/holiman/uint256"
+ "github.com/stretchr/testify/require"
+
+ "github.com/ethereum/go-ethereum/accounts/abi"
+ "github.com/ethereum/go-ethereum/common"
+ borabi "github.com/ethereum/go-ethereum/consensus/bor/abi"
+ "github.com/ethereum/go-ethereum/consensus/bor/registryreader"
+ "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/core/vm"
+ "github.com/ethereum/go-ethereum/params"
+)
+
+// writeABI exposes the contract write methods that tests need to drive
+// (initialize, createClient). The shared bor/abi package only ships the read
+// side because production code never invokes the write side from Go — admins
+// mutate the registry via normal user transactions.
+const writeABI = `[
+ {"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"}
+]`
+
+// Harness exposes a Reader bound to an in-memory state with the registry
+// deployed and one client registered, plus the addresses tests assert on.
+type Harness struct {
+ Reader registryreader.Reader
+ ReservedAddr common.Address
+ UnreservedAddr common.Address
+}
+
+// NewHarness deploys the registry, initializes it under a fresh owner, and
+// registers one client with one whitelisted address.
+func NewHarness(t *testing.T) *Harness {
+ t.Helper()
+
+ statedb, err := state.New(types.EmptyRootHash, state.NewDatabaseForTesting())
+ require.NoError(t, err)
+
+ contractAddr := common.HexToAddress(params.DefaultReservedRegistryContract)
+ owner := common.HexToAddress("0x00000000000000000000000000000000000000aa")
+ clientAdmin := common.HexToAddress("0x00000000000000000000000000000000000000bb")
+ reserved := common.HexToAddress("0x00000000000000000000000000000000000000cc")
+ unreserved := common.HexToAddress("0x00000000000000000000000000000000000000dd")
+
+ // Install runtime bytecode directly — the Solidity contract has no
+ // constructor (see ReservedBlockspaceRegistry.sol).
+ statedb.SetCode(contractAddr, common.FromHex(params.ReservedBlockspaceRegistryCode), tracing.CodeChangeGenesis)
+ statedb.AddBalance(owner, uint256.NewInt(params.Ether), tracing.BalanceChangeUnspecified)
+
+ writeAbi, err := abi.JSON(strings.NewReader(writeABI))
+ require.NoError(t, err)
+
+ initData, err := writeAbi.Pack("initialize", owner, uint64(200_000_000), uint64(100_000_000))
+ require.NoError(t, err)
+ callContract(t, statedb, owner, contractAddr, initData)
+
+ createData, err := writeAbi.Pack("createClient", clientAdmin, uint64(10_000_000), uint8(0), uint64(0), "test", []common.Address{reserved})
+ require.NoError(t, err)
+ callContract(t, statedb, owner, contractAddr, createData)
+
+ return &Harness{
+ Reader: &evmReader{
+ state: statedb,
+ contract: contractAddr,
+ readerAB: borabi.ReservedBlockspaceRegistry(),
+ },
+ ReservedAddr: reserved,
+ UnreservedAddr: unreserved,
+ }
+}
+
+func callContract(t *testing.T, statedb *state.StateDB, from, to common.Address, data []byte) {
+ t.Helper()
+ evm := newEVM(statedb, from)
+ ret, _, err := evm.Call(from, to, data, 30_000_000, uint256.NewInt(0))
+ if err != nil {
+ t.Fatalf("registry call reverted: err=%v ret=0x%x", err, ret)
+ }
+}
+
+func newEVM(statedb *state.StateDB, author common.Address) *vm.EVM {
+ // Built without importing core/ — the harness is shared across module
+ // tests including core's own tests, so we can't import core here without
+ // creating an import cycle.
+ blockCtx := vm.BlockContext{
+ CanTransfer: canTransfer,
+ Transfer: transfer,
+ GetHash: func(uint64) common.Hash { return common.Hash{} },
+ Coinbase: author,
+ BlockNumber: big.NewInt(1),
+ Time: 1700000000,
+ Difficulty: big.NewInt(1),
+ GasLimit: 30_000_000,
+ }
+ return vm.NewEVM(blockCtx, statedb, testChainConfig(), vm.Config{})
+}
+
+func canTransfer(db vm.StateDB, addr common.Address, amount *uint256.Int) bool {
+ return db.GetBalance(addr).Cmp(amount) >= 0
+}
+
+func transfer(db vm.StateDB, sender, recipient common.Address, amount *uint256.Int) {
+ db.SubBalance(sender, amount, tracing.BalanceChangeTransfer)
+ db.AddBalance(recipient, amount, tracing.BalanceChangeTransfer)
+}
+
+// testChainConfig enables every Ethereum fork the registry bytecode could
+// depend on. The contract uses PUSH0 (Shanghai) so ShanghaiBlock must be 0.
+func testChainConfig() *params.ChainConfig {
+ return ¶ms.ChainConfig{
+ ChainID: big.NewInt(137),
+ HomesteadBlock: big.NewInt(0),
+ EIP150Block: big.NewInt(0),
+ EIP155Block: big.NewInt(0),
+ EIP158Block: big.NewInt(0),
+ ByzantiumBlock: big.NewInt(0),
+ ConstantinopleBlock: big.NewInt(0),
+ PetersburgBlock: big.NewInt(0),
+ IstanbulBlock: big.NewInt(0),
+ MuirGlacierBlock: big.NewInt(0),
+ BerlinBlock: big.NewInt(0),
+ LondonBlock: big.NewInt(0),
+ ShanghaiBlock: big.NewInt(0),
+ }
+}
+
+// evmReader satisfies registryreader.Reader by running the registry's view
+// methods against a fixed in-memory state. Uses a snapshot/revert pair per call
+// so view executions never leak state mutations into the harness DB.
+type evmReader struct {
+ state *state.StateDB
+ contract common.Address
+ readerAB abi.ABI
+}
+
+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)
+ if err != nil {
+ return false, err
+ }
+ if len(values) != 1 {
+ return false, fmt.Errorf("isReservedAddress returned %d values", len(values))
+ }
+ reserved, ok := values[0].(bool)
+ if !ok {
+ return false, fmt.Errorf("isReservedAddress returned %T", values[0])
+ }
+ return reserved, nil
+}
+
+func (r *evmReader) ReservedClientForAddress(_ *state.StateDB, _ uint64, _ common.Hash, account common.Address) (registryreader.ClientLookup, error) {
+ values, err := r.call("getClientForAddress", account)
+ if err != nil {
+ return registryreader.ClientLookup{}, err
+ }
+ if len(values) != 6 {
+ return registryreader.ClientLookup{}, fmt.Errorf("getClientForAddress returned %d values", len(values))
+ }
+ clientID, _ := values[0].(*big.Int)
+ gasQuota, _ := values[1].(uint64)
+ admin, _ := values[2].(common.Address)
+ active, _ := values[3].(bool)
+ feeMode, _ := values[4].(uint8)
+ effectiveFrom, _ := values[5].(uint64)
+ if clientID == nil {
+ return registryreader.ClientLookup{}, fmt.Errorf("getClientForAddress returned nil clientID")
+ }
+ return registryreader.ClientLookup{
+ ClientID: new(big.Int).Set(clientID),
+ GasQuota: gasQuota,
+ Admin: admin,
+ Active: active,
+ FeeMode: feeMode,
+ EffectiveFrom: effectiveFrom,
+ }, nil
+}
+
+func (r *evmReader) Root(_ *state.StateDB, _ uint64, _ common.Hash) (common.Hash, error) {
+ values, err := r.call("root")
+ if err != nil {
+ return common.Hash{}, err
+ }
+ if len(values) != 1 {
+ return common.Hash{}, fmt.Errorf("root returned %d values", len(values))
+ }
+ root, ok := values[0].([32]byte)
+ if !ok {
+ return common.Hash{}, fmt.Errorf("root returned %T", values[0])
+ }
+ return common.Hash(root), nil
+}
+
+func (r *evmReader) WhitelistedAddresses(_ *state.StateDB, _ uint64, _ common.Hash) ([]common.Address, error) {
+ values, err := r.call("getWhitelistedAddresses")
+ if err != nil {
+ return nil, err
+ }
+ if len(values) != 1 {
+ return nil, fmt.Errorf("getWhitelistedAddresses returned %d values", len(values))
+ }
+ addrs, ok := values[0].([]common.Address)
+ if !ok {
+ return nil, fmt.Errorf("getWhitelistedAddresses returned %T", values[0])
+ }
+ return addrs, nil
+}
+
+func (r *evmReader) TotalReservedGas(_ *state.StateDB, _ uint64, _ common.Hash) (uint64, error) {
+ values, err := r.call("totalReservedGas")
+ if err != nil {
+ return 0, err
+ }
+ if len(values) != 1 {
+ return 0, fmt.Errorf("totalReservedGas returned %d values", len(values))
+ }
+ total, ok := values[0].(uint64)
+ if !ok {
+ return 0, fmt.Errorf("totalReservedGas returned %T", values[0])
+ }
+ return total, nil
+}
+
+func (r *evmReader) call(method string, args ...interface{}) ([]interface{}, error) {
+ 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)
+
+ caller := common.HexToAddress("0x00000000000000000000000000000000deadbeef")
+ evm := newEVM(r.state, caller)
+ ret, _, err := evm.Call(caller, r.contract, data, 30_000_000, uint256.NewInt(0))
+ if err != nil {
+ 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
new file mode 100644
index 0000000000..076c4b0da1
--- /dev/null
+++ b/consensus/bor/contract/registrytest/harness_test.go
@@ -0,0 +1,53 @@
+package registrytest
+
+import (
+ "testing"
+
+ "github.com/ethereum/go-ethereum/common"
+ "github.com/ethereum/go-ethereum/consensus/bor/registryreader"
+ "github.com/stretchr/testify/require"
+)
+
+func TestHarness_DeploysAndAnswersQueries(t *testing.T) {
+ h := NewHarness(t)
+
+ require.True(t, h.Reader.HasReservedRegistry(), "deployed registry must report HasReservedRegistry")
+
+ got, err := h.Reader.IsReservedAddress(nil, 0, common.Hash{}, h.ReservedAddr)
+ require.NoError(t, err)
+ require.True(t, got, "address registered via createClient must be reserved")
+
+ got, err = h.Reader.IsReservedAddress(nil, 0, common.Hash{}, h.UnreservedAddr)
+ require.NoError(t, err)
+ require.False(t, got, "unregistered address must not be reserved")
+
+ lookup, err := h.Reader.ReservedClientForAddress(nil, 0, common.Hash{}, h.ReservedAddr)
+ require.NoError(t, err)
+ require.True(t, lookup.Active)
+ require.Equal(t, uint64(10_000_000), lookup.GasQuota)
+ require.NotNil(t, lookup.ClientID)
+ require.Equal(t, int64(1), lookup.ClientID.Int64(), "createClient assigns id starting at 1")
+}
+
+func TestSnapshot_BuildsFromRegistry(t *testing.T) {
+ h := NewHarness(t)
+
+ snap, err := registryreader.BuildSnapshot(h.Reader, nil, 1, common.Hash{}, 1)
+ require.NoError(t, err)
+ require.NotNil(t, snap)
+
+ // The whitelisted address classifies reserved; the other does not.
+ 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))
+ require.Equal(t, uint64(10_000_000), snap.Capacity())
+ require.NotEqual(t, common.Hash{}, snap.Root())
+
+ // nil snapshot (no registry) classifies nothing and is safe.
+ var none *registryreader.Snapshot
+ require.False(t, none.IsReserved(h.ReservedAddr))
+ require.Equal(t, uint64(0), none.Capacity())
+}
diff --git a/consensus/bor/contract/reserved_registry.go b/consensus/bor/contract/reserved_registry.go
new file mode 100644
index 0000000000..1d5b0014d6
--- /dev/null
+++ b/consensus/bor/contract/reserved_registry.go
@@ -0,0 +1,360 @@
+package contract
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "math/big"
+
+ "github.com/ethereum/go-ethereum/common"
+ "github.com/ethereum/go-ethereum/common/hexutil"
+ borabi "github.com/ethereum/go-ethereum/consensus/bor/abi"
+ "github.com/ethereum/go-ethereum/consensus/bor/registryreader"
+ "github.com/ethereum/go-ethereum/core/state"
+ "github.com/ethereum/go-ethereum/internal/ethapi"
+ "github.com/ethereum/go-ethereum/rpc"
+)
+
+var errReservedRegistryNotConfigured = errors.New("reserved blockspace registry contract is not configured")
+
+// ReservedClientLookup is the slim "client for address" view returned by the
+// registry contract. Aliased to registryreader.ClientLookup so the type lives
+// in a leaf package that core/, miner/, and core/txpool/ can import without
+// triggering an import cycle through consensus/bor/statefull.
+type ReservedClientLookup = registryreader.ClientLookup
+
+var _ registryreader.Reader = (*GenesisContractsClient)(nil)
+
+type ReservedClient struct {
+ ClientID *big.Int
+ Admin common.Address
+ GasQuota uint64
+ Active bool
+ Metadata string
+ Addresses []common.Address
+}
+
+func (gc *GenesisContractsClient) HasReservedRegistry() bool {
+ if gc == nil || gc.ReservedRegistryContract == "" {
+ return false
+ }
+ return common.HexToAddress(gc.ReservedRegistryContract) != (common.Address{})
+}
+
+func (gc *GenesisContractsClient) IsReservedAddress(
+ state *state.StateDB,
+ number uint64,
+ hash common.Hash,
+ account common.Address,
+) (bool, error) {
+ if !gc.HasReservedRegistry() {
+ return false, nil
+ }
+
+ values, err := gc.callReservedRegistry(state, number, hash, "isReservedAddress", account)
+ if err != nil {
+ return false, err
+ }
+ if len(values) != 1 {
+ return false, fmt.Errorf("reserved registry isReservedAddress returned %d values", len(values))
+ }
+
+ reserved, ok := values[0].(bool)
+ if !ok {
+ return false, fmt.Errorf("reserved registry isReservedAddress returned %T", values[0])
+ }
+
+ return reserved, nil
+}
+
+func (gc *GenesisContractsClient) ReservedClientForAddress(
+ state *state.StateDB,
+ number uint64,
+ hash common.Hash,
+ account common.Address,
+) (ReservedClientLookup, error) {
+ if !gc.HasReservedRegistry() {
+ return ReservedClientLookup{ClientID: new(big.Int)}, nil
+ }
+
+ values, err := gc.callReservedRegistry(state, number, hash, "getClientForAddress", account)
+ if err != nil {
+ return ReservedClientLookup{}, err
+ }
+ if len(values) != 6 {
+ return ReservedClientLookup{}, fmt.Errorf("reserved registry getClientForAddress returned %d values", len(values))
+ }
+
+ clientID, ok := values[0].(*big.Int)
+ if !ok {
+ return ReservedClientLookup{}, fmt.Errorf("reserved registry client id has type %T", values[0])
+ }
+ gasQuota, ok := values[1].(uint64)
+ if !ok {
+ return ReservedClientLookup{}, fmt.Errorf("reserved registry gas quota has type %T", values[1])
+ }
+ admin, ok := values[2].(common.Address)
+ if !ok {
+ return ReservedClientLookup{}, fmt.Errorf("reserved registry admin has type %T", values[2])
+ }
+ active, ok := values[3].(bool)
+ if !ok {
+ return ReservedClientLookup{}, fmt.Errorf("reserved registry active flag has type %T", values[3])
+ }
+ feeMode, ok := values[4].(uint8)
+ if !ok {
+ return ReservedClientLookup{}, fmt.Errorf("reserved registry fee mode has type %T", values[4])
+ }
+ effectiveFrom, ok := values[5].(uint64)
+ if !ok {
+ return ReservedClientLookup{}, fmt.Errorf("reserved registry effective-from has type %T", values[5])
+ }
+
+ return ReservedClientLookup{
+ ClientID: new(big.Int).Set(clientID),
+ GasQuota: gasQuota,
+ Admin: admin,
+ Active: active,
+ FeeMode: feeMode,
+ EffectiveFrom: effectiveFrom,
+ }, nil
+}
+
+// Root reads the registry's configVersion-derived root. Callers rebuild their
+// reserved-set snapshot only when this value changes.
+func (gc *GenesisContractsClient) Root(
+ state *state.StateDB,
+ number uint64,
+ hash common.Hash,
+) (common.Hash, error) {
+ if !gc.HasReservedRegistry() {
+ return common.Hash{}, nil
+ }
+
+ values, err := gc.callReservedRegistry(state, number, hash, "root")
+ if err != nil {
+ return common.Hash{}, err
+ }
+ if len(values) != 1 {
+ return common.Hash{}, fmt.Errorf("reserved registry root returned %d values", len(values))
+ }
+
+ root, ok := values[0].([32]byte)
+ if !ok {
+ return common.Hash{}, fmt.Errorf("reserved registry root has type %T", values[0])
+ }
+
+ return common.Hash(root), nil
+}
+
+func (gc *GenesisContractsClient) ReservedClientByID(
+ state *state.StateDB,
+ number uint64,
+ hash common.Hash,
+ clientID *big.Int,
+) (ReservedClient, error) {
+ if !gc.HasReservedRegistry() {
+ return ReservedClient{}, errReservedRegistryNotConfigured
+ }
+ if clientID == nil {
+ return ReservedClient{}, errors.New("reserved registry client id is nil")
+ }
+
+ values, err := gc.callReservedRegistry(state, number, hash, "getClient", clientID)
+ if err != nil {
+ return ReservedClient{}, err
+ }
+ if len(values) != 5 {
+ return ReservedClient{}, fmt.Errorf("reserved registry getClient returned %d values", len(values))
+ }
+
+ admin, ok := values[0].(common.Address)
+ if !ok {
+ return ReservedClient{}, fmt.Errorf("reserved registry admin has type %T", values[0])
+ }
+ gasQuota, ok := values[1].(uint64)
+ if !ok {
+ return ReservedClient{}, fmt.Errorf("reserved registry gas quota has type %T", values[1])
+ }
+ active, ok := values[2].(bool)
+ if !ok {
+ return ReservedClient{}, fmt.Errorf("reserved registry active flag has type %T", values[2])
+ }
+ metadata, ok := values[3].(string)
+ if !ok {
+ return ReservedClient{}, fmt.Errorf("reserved registry metadata has type %T", values[3])
+ }
+
+ addresses, err := gc.ClientAddresses(state, number, hash, clientID)
+ if err != nil {
+ return ReservedClient{}, err
+ }
+
+ return ReservedClient{
+ ClientID: new(big.Int).Set(clientID),
+ Admin: admin,
+ GasQuota: gasQuota,
+ Active: active,
+ Metadata: metadata,
+ Addresses: addresses,
+ }, nil
+}
+
+func (gc *GenesisContractsClient) ClientAddresses(
+ state *state.StateDB,
+ number uint64,
+ hash common.Hash,
+ clientID *big.Int,
+) ([]common.Address, error) {
+ if !gc.HasReservedRegistry() {
+ return nil, errReservedRegistryNotConfigured
+ }
+ if clientID == nil {
+ return nil, errors.New("reserved registry client id is nil")
+ }
+
+ values, err := gc.callReservedRegistry(state, number, hash, "getClientAddresses", clientID)
+ if err != nil {
+ return nil, err
+ }
+ if len(values) != 1 {
+ return nil, fmt.Errorf("reserved registry getClientAddresses returned %d values", len(values))
+ }
+
+ addresses, ok := values[0].([]common.Address)
+ if !ok {
+ return nil, fmt.Errorf("reserved registry addresses have type %T", values[0])
+ }
+
+ return addresses, nil
+}
+
+func (gc *GenesisContractsClient) WhitelistedAddresses(
+ state *state.StateDB,
+ number uint64,
+ hash common.Hash,
+) ([]common.Address, error) {
+ if !gc.HasReservedRegistry() {
+ return nil, nil
+ }
+
+ values, err := gc.callReservedRegistry(state, number, hash, "getWhitelistedAddresses")
+ if err != nil {
+ return nil, err
+ }
+ if len(values) != 1 {
+ return nil, fmt.Errorf("reserved registry getWhitelistedAddresses returned %d values", len(values))
+ }
+
+ addresses, ok := values[0].([]common.Address)
+ if !ok {
+ return nil, fmt.Errorf("reserved registry whitelist has type %T", values[0])
+ }
+
+ return addresses, nil
+}
+
+func (gc *GenesisContractsClient) ReservedClients(
+ state *state.StateDB,
+ number uint64,
+ hash common.Hash,
+) ([]ReservedClientLookup, error) {
+ if !gc.HasReservedRegistry() {
+ return nil, nil
+ }
+
+ values, err := gc.callReservedRegistry(state, number, hash, "getReservedClients")
+ if err != nil {
+ return nil, err
+ }
+ if len(values) != 3 {
+ return nil, fmt.Errorf("reserved registry getReservedClients returned %d values", len(values))
+ }
+
+ clientIDs, ok := values[0].([]*big.Int)
+ if !ok {
+ return nil, fmt.Errorf("reserved registry client ids have type %T", values[0])
+ }
+ admins, ok := values[1].([]common.Address)
+ if !ok {
+ return nil, fmt.Errorf("reserved registry admins have type %T", values[1])
+ }
+ gasQuotas, ok := values[2].([]uint64)
+ if !ok {
+ return nil, fmt.Errorf("reserved registry quotas have type %T", values[2])
+ }
+ if len(clientIDs) != len(admins) || len(clientIDs) != len(gasQuotas) {
+ return nil, fmt.Errorf("reserved registry returned mismatched client slices: ids=%d admins=%d quotas=%d", len(clientIDs), len(admins), len(gasQuotas))
+ }
+
+ clients := make([]ReservedClientLookup, len(clientIDs))
+ for i := range clientIDs {
+ clients[i] = ReservedClientLookup{
+ ClientID: new(big.Int).Set(clientIDs[i]),
+ GasQuota: gasQuotas[i],
+ Admin: admins[i],
+ Active: true,
+ }
+ }
+
+ return clients, nil
+}
+
+func (gc *GenesisContractsClient) TotalReservedGas(
+ state *state.StateDB,
+ number uint64,
+ hash common.Hash,
+) (uint64, error) {
+ if !gc.HasReservedRegistry() {
+ return 0, nil
+ }
+
+ values, err := gc.callReservedRegistry(state, number, hash, "totalReservedGas")
+ if err != nil {
+ return 0, err
+ }
+ if len(values) != 1 {
+ return 0, fmt.Errorf("reserved registry totalReservedGas returned %d values", len(values))
+ }
+
+ total, ok := values[0].(uint64)
+ if !ok {
+ return 0, fmt.Errorf("reserved registry totalReservedGas returned %T", values[0])
+ }
+
+ return total, nil
+}
+
+func (gc *GenesisContractsClient) callReservedRegistry(
+ state *state.StateDB,
+ number uint64,
+ hash common.Hash,
+ method string,
+ args ...interface{},
+) ([]interface{}, error) {
+ if !gc.HasReservedRegistry() {
+ return nil, errReservedRegistryNotConfigured
+ }
+ if gc.ethAPI == nil {
+ return nil, errors.New("reserved registry eth api is nil")
+ }
+
+ data, err := gc.reservedRegistryABI.Pack(method, args...)
+ if err != nil {
+ return nil, err
+ }
+
+ msgData := (hexutil.Bytes)(data)
+ toAddress := common.HexToAddress(gc.ReservedRegistryContract)
+ blockNr := rpc.BlockNumber(number)
+ result, err := gc.ethAPI.CallWithState(ethapi.WithBorInternalCall(context.Background()), ethapi.TransactionArgs{
+ Gas: &borabi.SystemTxGas,
+ To: &toAddress,
+ Data: &msgData,
+ }, &rpc.BlockNumberOrHash{BlockNumber: &blockNr, BlockHash: &hash}, state, nil, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ return gc.reservedRegistryABI.Unpack(method, result)
+}
diff --git a/consensus/bor/contract/reserved_registry_test.go b/consensus/bor/contract/reserved_registry_test.go
new file mode 100644
index 0000000000..03f4a2ac7b
--- /dev/null
+++ b/consensus/bor/contract/reserved_registry_test.go
@@ -0,0 +1,47 @@
+package contract
+
+import (
+ "math/big"
+ "testing"
+
+ "github.com/ethereum/go-ethereum/common"
+ borabi "github.com/ethereum/go-ethereum/consensus/bor/abi"
+)
+
+func TestReservedRegistryABIUnpacksBorFacingViews(t *testing.T) {
+ registryABI := borabi.ReservedBlockspaceRegistry()
+
+ clientOutput, err := registryABI.Methods["getClient"].Outputs.Pack(
+ common.HexToAddress("0x1"),
+ uint64(20_000_000),
+ true,
+ "Polymarket",
+ big.NewInt(3),
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ clientValues, err := registryABI.Unpack("getClient", clientOutput)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, ok := clientValues[1].(uint64); !ok {
+ t.Fatalf("expected uint64 gas quota, got %T", clientValues[1])
+ }
+
+ clientsOutput, err := registryABI.Methods["getReservedClients"].Outputs.Pack(
+ []*big.Int{big.NewInt(1)},
+ []common.Address{common.HexToAddress("0x2")},
+ []uint64{20_000_000},
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ clientsValues, err := registryABI.Unpack("getReservedClients", clientsOutput)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, ok := clientsValues[2].([]uint64); !ok {
+ t.Fatalf("expected []uint64 gas quotas, got %T", clientsValues[2])
+ }
+}
diff --git a/consensus/bor/registryreader/reader.go b/consensus/bor/registryreader/reader.go
new file mode 100644
index 0000000000..ee60303222
--- /dev/null
+++ b/consensus/bor/registryreader/reader.go
@@ -0,0 +1,198 @@
+// Package registryreader exposes the minimal read-only surface of the reserved
+// blockspace registry that filtering modules (txpool, miner, block validator)
+// need. It lives in a leaf package so core/, miner/, and core/txpool/ can
+// import it without pulling in consensus/bor/contract → consensus/bor/statefull
+// → core/, which would form an import cycle.
+package registryreader
+
+import (
+ "fmt"
+ "math/big"
+ "slices"
+
+ "github.com/ethereum/go-ethereum/common"
+ "github.com/ethereum/go-ethereum/core/state"
+)
+
+// 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.
+type ClientLookup struct {
+ ClientID *big.Int
+ GasQuota uint64
+ Admin common.Address
+ Active bool
+ // FeeMode: 0 = free (zero in-protocol fee), 1 = routed (fee credited to the producer).
+ FeeMode uint8
+ // EffectiveFrom: block from which the client's reserved status applies.
+ // Callers gate on Active && EffectiveFrom <= number.
+ EffectiveFrom uint64
+}
+
+// Reader is the read-only view of the reserved blockspace registry consumed by
+// transaction filtering paths. Callers must nil-check the interface before
+// invoking — chain/txpool/miner expose a nil Reader when the chain has no
+// registry configured (non-bor engines, devnets without the contract).
+type Reader interface {
+ HasReservedRegistry() bool
+ IsReservedAddress(state *state.StateDB, number uint64, hash common.Hash, account common.Address) (bool, error)
+ ReservedClientForAddress(state *state.StateDB, number uint64, hash common.Hash, account common.Address) (ClientLookup, error)
+ // Root returns a value that changes whenever the reserved set or its limits
+ // change, so a snapshot keyed on it can be reused until it moves.
+ Root(state *state.StateDB, number uint64, hash common.Hash) (common.Hash, error)
+ // WhitelistedAddresses returns every currently-active reserved address.
+ WhitelistedAddresses(state *state.StateDB, number uint64, hash common.Hash) ([]common.Address, error)
+ // TotalReservedGas returns the sum of active client quotas (reserved capacity).
+ TotalReservedGas(state *state.StateDB, number uint64, hash common.Hash) (uint64, error)
+}
+
+// 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.
+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
+// one block, so the hot classification paths never do a per-transaction state
+// read. Activation (active flag, effectiveFrom delay) is resolved once at
+// build time: every stored entry is effective, which is why lookups take no
+// block number. The txpool rebuilds it once per head; the execution path once
+// 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
+}
+
+// BuildSnapshot reads the full active reserved set from the registry at the
+// given block state and returns an immutable Snapshot classifying senders for
+// block effectiveAt (clients with effectiveFrom > effectiveAt are excluded).
+// Returns nil (no error) when no registry is configured.
+func BuildSnapshot(r Reader, statedb *state.StateDB, number uint64, hash common.Hash, effectiveAt uint64) (*Snapshot, error) {
+ if r == nil || !r.HasReservedRegistry() {
+ return nil, nil
+ }
+ // 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 split the state root.
+ if statedb != nil {
+ statedb = statedb.Copy()
+ }
+ root, err := r.Root(statedb, number, hash)
+ if err != nil {
+ return nil, err
+ }
+ addrs, err := r.WhitelistedAddresses(statedb, number, hash)
+ if err != nil {
+ return nil, err
+ }
+ capacity, err := r.TotalReservedGas(statedb, number, hash)
+ if err != nil {
+ return nil, err
+ }
+ clients := make(map[common.Address]Client, len(addrs))
+ for _, a := range addrs {
+ c, err := r.ReservedClientForAddress(statedb, number, hash, a)
+ if err != nil {
+ return nil, err
+ }
+ if !c.Active || c.EffectiveFrom > effectiveAt {
+ 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}
+ }
+ return NewSnapshot(root, capacity, clients), nil
+}
+
+// NewSnapshot constructs a Snapshot from an explicit, already-effective client
+// set. Used by tests and by callers that source the reserved set outside the
+// registry contract.
+func NewSnapshot(root common.Hash, capacity uint64, clients map[common.Address]Client) *Snapshot {
+ quotas := make(map[uint64]uint64, len(clients))
+ for _, c := range clients {
+ quotas[c.ID] = c.GasQuota
+ }
+ ids := make([]uint64, 0, len(quotas))
+ for id := range quotas {
+ ids = append(ids, id)
+ }
+ slices.Sort(ids)
+ return &Snapshot{root: root, capacity: capacity, byAddress: clients, clientIDs: ids, quotas: quotas}
+}
+
+// Root is the registry root this snapshot was built at; callers reuse the
+// snapshot while the live root is unchanged.
+func (s *Snapshot) Root() common.Hash {
+ if s == nil {
+ return common.Hash{}
+ }
+ return s.root
+}
+
+// IsReserved reports whether account is a reserved sender in this snapshot's
+// effective set.
+func (s *Snapshot) IsReserved(account common.Address) bool {
+ if s == nil {
+ return false
+ }
+ _, ok := s.byAddress[account]
+ return ok
+}
+
+// Lookup returns the client ID that owns account, or (_, false) if account is
+// not in the effective reserved set.
+func (s *Snapshot) Lookup(account common.Address) (uint64, bool) {
+ if s == nil {
+ return 0, false
+ }
+ c, ok := s.byAddress[account]
+ return c.ID, ok
+}
+
+// Quota returns the per-block reserved gas allowance of clientID, or 0 for an
+// unknown client.
+func (s *Snapshot) Quota(clientID uint64) uint64 {
+ if s == nil {
+ return 0
+ }
+ return s.quotas[clientID]
+}
+
+// Clients returns the effective client IDs, sorted ascending.
+func (s *Snapshot) Clients() []uint64 {
+ if s == nil {
+ return nil
+ }
+ 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 {
+ if s == nil {
+ return 0
+ }
+ return s.byAddress[account].FeeMode
+}
+
+// Capacity returns the reserved capacity (sum of active client quotas).
+func (s *Snapshot) Capacity() uint64 {
+ if s == nil {
+ return 0
+ }
+ return s.capacity
+}
diff --git a/consensus/bor/registryreader/reader_test.go b/consensus/bor/registryreader/reader_test.go
new file mode 100644
index 0000000000..ae44f5d3d0
--- /dev/null
+++ b/consensus/bor/registryreader/reader_test.go
@@ -0,0 +1,237 @@
+package registryreader
+
+import (
+ "errors"
+ "math/big"
+ "testing"
+
+ "github.com/ethereum/go-ethereum/common"
+ "github.com/ethereum/go-ethereum/core/state"
+)
+
+// mockReader is a controllable Reader for exercising BuildSnapshot and the
+// Snapshot accessors without an EVM-backed registry.
+type mockReader struct {
+ has bool
+ root common.Hash
+ whitelist []common.Address
+ totalGas uint64
+ clients map[common.Address]ClientLookup
+ rootErr error
+ wlErr error
+ totalErr error
+ clientErr error
+ clientCalls int
+}
+
+func (m *mockReader) HasReservedRegistry() bool { return m.has }
+
+func (m *mockReader) IsReservedAddress(_ *state.StateDB, _ uint64, _ common.Hash, _ common.Address) (bool, error) {
+ return false, nil
+}
+
+func (m *mockReader) ReservedClientForAddress(_ *state.StateDB, _ uint64, _ common.Hash, a common.Address) (ClientLookup, error) {
+ m.clientCalls++
+ if m.clientErr != nil {
+ return ClientLookup{}, m.clientErr
+ }
+ return m.clients[a], nil
+}
+
+func (m *mockReader) Root(_ *state.StateDB, _ uint64, _ common.Hash) (common.Hash, error) {
+ return m.root, m.rootErr
+}
+
+func (m *mockReader) WhitelistedAddresses(_ *state.StateDB, _ uint64, _ common.Hash) ([]common.Address, error) {
+ return m.whitelist, m.wlErr
+}
+
+func (m *mockReader) TotalReservedGas(_ *state.StateDB, _ uint64, _ common.Hash) (uint64, error) {
+ return m.totalGas, m.totalErr
+}
+
+func addr(b byte) common.Address { return common.Address{19: b} }
+
+func TestBuildSnapshot(t *testing.T) {
+ errBoom := errors.New("boom")
+ a1, a2 := addr(1), addr(2)
+ base := func() *mockReader {
+ return &mockReader{
+ has: true,
+ root: common.HexToHash("0xabc"),
+ whitelist: []common.Address{a1, a2},
+ totalGas: 60_000_000,
+ clients: map[common.Address]ClientLookup{
+ a1: {ClientID: big.NewInt(1), GasQuota: 30_000_000, Active: true},
+ a2: {ClientID: big.NewInt(2), GasQuota: 30_000_000, Active: true},
+ },
+ }
+ }
+
+ t.Run("nil reader yields nil snapshot", func(t *testing.T) {
+ snap, err := BuildSnapshot(nil, nil, 1, common.Hash{}, 2)
+ if err != nil || snap != nil {
+ t.Fatalf("snap=%v err=%v, want nil,nil", snap, err)
+ }
+ })
+
+ t.Run("registry not configured yields nil snapshot", func(t *testing.T) {
+ snap, err := BuildSnapshot(&mockReader{has: false}, nil, 1, common.Hash{}, 2)
+ if err != nil || snap != nil {
+ t.Fatalf("snap=%v err=%v, want nil,nil", snap, err)
+ }
+ })
+
+ t.Run("happy path populates root, capacity and clients", func(t *testing.T) {
+ r := base()
+ snap, err := BuildSnapshot(r, nil, 7, common.Hash{}, 8)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if snap.Root() != r.root {
+ t.Errorf("root=%s, want %s", snap.Root(), r.root)
+ }
+ if snap.Capacity() != 60_000_000 {
+ t.Errorf("capacity=%d, want 60000000", snap.Capacity())
+ }
+ if r.clientCalls != len(r.whitelist) {
+ t.Errorf("client lookups=%d, want %d", r.clientCalls, len(r.whitelist))
+ }
+ if !snap.IsReserved(a1) || !snap.IsReserved(a2) {
+ t.Error("both whitelisted addresses should be reserved")
+ }
+ if id, ok := snap.Lookup(a1); !ok || id != 1 {
+ t.Errorf("Lookup(a1)=(%d,%v), want (1,true)", id, ok)
+ }
+ if got := snap.Quota(2); got != 30_000_000 {
+ t.Errorf("Quota(2)=%d, want 30000000", got)
+ }
+ if ids := snap.Clients(); len(ids) != 2 || ids[0] != 1 || ids[1] != 2 {
+ t.Errorf("Clients()=%v, want [1 2] sorted", ids)
+ }
+ })
+
+ for _, tc := range []struct {
+ name string
+ mutta func(*mockReader)
+ }{
+ {"root error", func(m *mockReader) { m.rootErr = errBoom }},
+ {"whitelist error", func(m *mockReader) { m.wlErr = errBoom }},
+ {"total gas error", func(m *mockReader) { m.totalErr = errBoom }},
+ {"per-client error", func(m *mockReader) { m.clientErr = errBoom }},
+ } {
+ t.Run(tc.name+" propagates", func(t *testing.T) {
+ r := base()
+ tc.mutta(r)
+ snap, err := BuildSnapshot(r, nil, 7, common.Hash{}, 8)
+ if !errors.Is(err, errBoom) {
+ t.Fatalf("err=%v, want boom", err)
+ }
+ if snap != nil {
+ t.Errorf("snap=%v, want nil on error", snap)
+ }
+ })
+ }
+}
+
+// TestBuildSnapshotEffectiveFiltering pins the build-time activation
+// resolution: inactive clients and clients whose effectiveFrom is beyond the
+// snapshot's effectiveAt block never enter the stored set, so lookups need no
+// block number.
+func TestBuildSnapshotEffectiveFiltering(t *testing.T) {
+ a1, a2 := addr(1), addr(2)
+ reader := func() *mockReader {
+ return &mockReader{
+ has: true,
+ root: common.HexToHash("0xabc"),
+ whitelist: []common.Address{a1, a2},
+ totalGas: 60_000_000,
+ clients: map[common.Address]ClientLookup{
+ a1: {ClientID: big.NewInt(1), GasQuota: 30_000_000, Active: true, EffectiveFrom: 100},
+ a2: {ClientID: big.NewInt(2), GasQuota: 30_000_000, Active: false},
+ },
+ }
+ }
+
+ tests := []struct {
+ name string
+ effectiveAt uint64
+ wantA1 bool
+ }{
+ {"before effectiveFrom", 99, false},
+ {"exactly at effectiveFrom", 100, true},
+ {"after effectiveFrom", 101, true},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ snap, err := BuildSnapshot(reader(), nil, tt.effectiveAt-1, common.Hash{}, tt.effectiveAt)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := snap.IsReserved(a1); got != tt.wantA1 {
+ t.Errorf("IsReserved(a1) at %d = %v, want %v", tt.effectiveAt, got, tt.wantA1)
+ }
+ if snap.IsReserved(a2) {
+ t.Error("inactive client must never be reserved")
+ }
+ if snap.IsReserved(addr(9)) {
+ t.Error("unknown account must never be reserved")
+ }
+ })
+ }
+}
+
+func TestBuildSnapshotRejectsInvalidClientID(t *testing.T) {
+ a1 := addr(1)
+ r := &mockReader{
+ has: true,
+ whitelist: []common.Address{a1},
+ clients: map[common.Address]ClientLookup{
+ a1: {ClientID: nil, GasQuota: 1, Active: true},
+ },
+ }
+ if _, err := BuildSnapshot(r, nil, 1, common.Hash{}, 2); err == nil {
+ t.Fatal("expected error for nil client id")
+ }
+}
+
+func TestSnapshotNilSafe(t *testing.T) {
+ var snap *Snapshot
+ if snap.IsReserved(addr(1)) {
+ t.Error("nil snapshot must not classify anything as reserved")
+ }
+ if _, ok := snap.Lookup(addr(1)); ok {
+ t.Error("nil snapshot Lookup must miss")
+ }
+ if snap.Quota(1) != 0 {
+ t.Error("nil snapshot Quota must be 0")
+ }
+ 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.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))
+ }
+ if snap.FeeMode(addr(9)) != 0 {
+ t.Errorf("FeeMode(unknown)=%d, want 0", snap.FeeMode(addr(9)))
+ }
+ if snap.Capacity() != 12_345 {
+ t.Errorf("Capacity=%d, want 12345", snap.Capacity())
+ }
+}
diff --git a/consensus/misc/eip1559/eip1559.go b/consensus/misc/eip1559/eip1559.go
index 69f96865aa..4380e7ff8e 100644
--- a/consensus/misc/eip1559/eip1559.go
+++ b/consensus/misc/eip1559/eip1559.go
@@ -116,8 +116,23 @@ func CalcBaseFee(config *params.ChainConfig, parent *types.Header) *big.Int {
// Modified for bor to derive gas target by percentage instead of using elasticity multiplier post dandeli HF
parentGasTarget := calcParentGasTarget(config, parent)
+ parentGasUsed := parent.GasUsed
+
+ // Reserved blockspace prices the public fee market on the normal region only:
+ // hold reserved capacity out of the target and net the parent's reserved gas
+ // out of the used figure, so a reserved client paying zero fee can't move the
+ // public base fee with its own usage. Anchoring the target on capacity keeps
+ // the public market stable and isolated from reserved behaviour. The used-side
+ // netting stays inert until the producer populates parent.ReservedGasUsed;
+ // until then the public base fee runs slightly hot — deterministic (a fee
+ // accuracy gap, not a consensus split), so no change is needed here.
+ if config.Bor != nil && config.Bor.IsReservedBlockspace(parent.Number) {
+ parentGasTarget = reservedAwareGasTarget(config, parent)
+ parentGasUsed = publicGasUsed(config, parent)
+ }
+
// If the parent gasUsed is the same as the target, the baseFee remains unchanged.
- if parent.GasUsed == parentGasTarget {
+ if parentGasUsed == parentGasTarget {
return new(big.Int).Set(parent.BaseFee)
}
@@ -142,10 +157,10 @@ func CalcBaseFee(config *params.ChainConfig, parent *types.Header) *big.Int {
}
}
- if parent.GasUsed > parentGasTarget {
+ if parentGasUsed > parentGasTarget {
// If the parent block used more gas than its target, the baseFee should increase.
// max(1, parentBaseFee * gasUsedDelta / parentGasTarget / baseFeeChangeDenominator)
- num.SetUint64(parent.GasUsed - parentGasTarget)
+ num.SetUint64(parentGasUsed - parentGasTarget)
num.Mul(num, parent.BaseFee)
num.Div(num, denom.SetUint64(parentGasTarget))
num.Div(num, denom.SetUint64(baseFeeChangeDenominatorUint64))
@@ -162,7 +177,7 @@ func CalcBaseFee(config *params.ChainConfig, parent *types.Header) *big.Int {
} else {
// Otherwise if the parent block used less gas than its target, the baseFee should decrease.
// max(0, parentBaseFee * gasUsedDelta / parentGasTarget / baseFeeChangeDenominator)
- num.SetUint64(parentGasTarget - parent.GasUsed)
+ num.SetUint64(parentGasTarget - parentGasUsed)
num.Mul(num, parent.BaseFee)
num.Div(num, denom.SetUint64(parentGasTarget))
num.Div(num, denom.SetUint64(baseFeeChangeDenominatorUint64))
@@ -185,12 +200,50 @@ func CalcBaseFee(config *params.ChainConfig, parent *types.Header) *big.Int {
// Dandeli HF, a percentage value is used to calculate the gas target (validated with fallback to default).
// Post-Lisovo, if EnableDynamicTargetGas is configured, the percentage adjusts dynamically based on parent base fee.
func calcParentGasTarget(config *params.ChainConfig, parent *types.Header) uint64 {
+ return gasTargetForLimit(config, parent, parent.GasLimit)
+}
+
+// gasTargetForLimit applies the EIP-1559 gas-target curve to an arbitrary gas
+// limit. calcParentGasTarget passes the full parent.GasLimit; the reserved
+// path passes the public capacity (limit minus reserved quotas). The target
+// percentage is independent of the limit, so the same curve composes cleanly.
+func gasTargetForLimit(config *params.ChainConfig, parent *types.Header, gasLimit uint64) uint64 {
if config.Bor != nil && config.Bor.IsDandeli(parent.Number) {
// Use dynamic helper which falls back to static GetTargetGasPercentage when feature is disabled
targetPercentage := config.Bor.GetDynamicTargetGasPercentage(parent.BaseFee, parent.Number)
- return parent.GasLimit * targetPercentage / 100
+ return gasLimit * targetPercentage / 100
+ }
+ 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.
+//
+// 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.
+ return calcParentGasTarget(config, parent)
+ }
+ return gasTargetForLimit(config, parent, parent.GasLimit-reservedCapacity)
+}
+
+// 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)
+ if reservedGasUsed == nil || *reservedGasUsed > parent.GasUsed {
+ return parent.GasUsed
}
- return parent.GasLimit / config.ElasticityMultiplier()
+ return parent.GasUsed - *reservedGasUsed
}
// CalcGasTarget exports calcParentGasTarget for use by consensus code (e.g. Prepare).
diff --git a/consensus/misc/eip1559/eip1559_reserved_test.go b/consensus/misc/eip1559/eip1559_reserved_test.go
new file mode 100644
index 0000000000..27c4b9aad6
--- /dev/null
+++ b/consensus/misc/eip1559/eip1559_reserved_test.go
@@ -0,0 +1,229 @@
+// 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 eip1559
+
+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/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{
+ ChainID: big.NewInt(80003),
+ HomesteadBlock: big.NewInt(0),
+ EIP150Block: big.NewInt(0),
+ EIP155Block: big.NewInt(0),
+ EIP158Block: big.NewInt(0),
+ ByzantiumBlock: big.NewInt(0),
+ ConstantinopleBlock: big.NewInt(0),
+ PetersburgBlock: big.NewInt(0),
+ IstanbulBlock: big.NewInt(0),
+ MuirGlacierBlock: big.NewInt(0),
+ BerlinBlock: big.NewInt(0),
+ LondonBlock: big.NewInt(0),
+ ShanghaiBlock: big.NewInt(0),
+ CancunBlock: big.NewInt(0),
+ Bor: ¶ms.BorConfig{
+ Period: map[string]uint64{"0": 1},
+ Sprint: map[string]uint64{"0": 64},
+ 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 {
+ t.Helper()
+ zero := uint64(0)
+ enc, err := rlp.EncodeToBytes(&types.BlockExtraData{
+ GasTarget: &zero,
+ BaseFeeChangeDenominator: &zero,
+ ReservedGasUsed: &reservedGasUsed,
+ })
+ require.NoError(t, err)
+
+ extra := make([]byte, types.ExtraVanityLength)
+ extra = append(extra, enc...)
+ extra = append(extra, make([]byte, types.ExtraSealLength)...)
+ return extra
+}
+
+// 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.
+func TestReservedBaseFee_CapacityReducesTarget(t *testing.T) {
+ t.Parallel()
+
+ const gasLimit = 60_000_000
+ const capacity = 20_000_000
+ baseFee := big.NewInt(params.InitialBaseFee)
+
+ // Public target = (gasLimit - capacity) / 2 = 20M.
+ parent := &types.Header{
+ Number: big.NewInt(1),
+ GasLimit: gasLimit,
+ GasUsed: (gasLimit - capacity) / 2,
+ BaseFee: baseFee,
+ }
+
+ active := reservedFeeConfig(big.NewInt(0), capacity)
+ 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)
+ if got := CalcBaseFee(inactive, parent); got.Cmp(baseFee) >= 0 {
+ t.Errorf("reserved-inactive base fee = %s, want < %s (usage below full target)", got, baseFee)
+ }
+}
+
+// TestReservedBaseFee_NetsReservedGasUsed pins the used-side netting: the
+// parent's ReservedGasUsed is subtracted from gas used before the controller
+// runs, so reserved consumption doesn't move the public base fee.
+func TestReservedBaseFee_NetsReservedGasUsed(t *testing.T) {
+ t.Parallel()
+
+ const gasLimit = 60_000_000
+ const capacity = 20_000_000
+ baseFee := big.NewInt(params.InitialBaseFee)
+ cfg := reservedFeeConfig(big.NewInt(0), capacity)
+
+ // Public target = (60M - 20M)/2 = 20M. Total used 35M, of which 15M is
+ // reserved → public used = 20M == target → base fee unchanged.
+ parent := &types.Header{
+ Number: big.NewInt(1),
+ GasLimit: gasLimit,
+ GasUsed: 35_000_000,
+ BaseFee: baseFee,
+ Extra: extraWithReserved(t, 15_000_000),
+ }
+ if got := CalcBaseFee(cfg, parent); got.Cmp(baseFee) != 0 {
+ t.Errorf("base fee = %s, want unchanged %s (public used == target after netting)", got, baseFee)
+ }
+
+ // Same total usage but zero reserved gas: public used = 35M > 20M target,
+ // so the base fee must rise. Proves the netting is what held it steady above.
+ parentNoReserved := &types.Header{
+ Number: big.NewInt(1),
+ GasLimit: gasLimit,
+ GasUsed: 35_000_000,
+ BaseFee: baseFee,
+ Extra: extraWithReserved(t, 0),
+ }
+ 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.
+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
+
+ parent := &types.Header{
+ Number: big.NewInt(1),
+ GasLimit: gasLimit,
+ GasUsed: gasLimit / 2, // == full target → unchanged
+ BaseFee: baseFee,
+ }
+
+ var got *big.Int
+ require.NotPanics(t, func() { got = CalcBaseFee(cfg, parent) },
+ "CalcBaseFee must not panic when reserved capacity >= gas limit")
+ if got.Cmp(baseFee) != 0 {
+ t.Errorf("fallback base fee = %s, want unchanged %s (full target)", got, baseFee)
+ }
+}
+
+// TestReservedBaseFee_CapacityEqualsLimitFallsBack pins the >= boundary in
+// reservedAwareGasTarget: when reserved capacity equals the gas limit the public
+// target would be zero, so the guard must fall back to the full target (not
+// divide by a zero target). Distinguishes >= from > at the exact boundary.
+func TestReservedBaseFee_CapacityEqualsLimitFallsBack(t *testing.T) {
+ t.Parallel()
+
+ const gasLimit = 30_000_000
+ baseFee := big.NewInt(params.InitialBaseFee)
+ cfg := reservedFeeConfig(big.NewInt(0), gasLimit) // capacity == limit
+
+ parent := &types.Header{
+ Number: big.NewInt(1),
+ GasLimit: gasLimit,
+ GasUsed: gasLimit / 2, // == full target → unchanged
+ BaseFee: baseFee,
+ }
+
+ var got *big.Int
+ require.NotPanics(t, func() { got = CalcBaseFee(cfg, parent) },
+ "CalcBaseFee must not panic when reserved capacity == gas limit")
+ if got.Cmp(baseFee) != 0 {
+ t.Errorf("fallback base fee = %s, want unchanged %s (full target)", 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
+ baseFee := big.NewInt(params.InitialBaseFee)
+ parent := &types.Header{
+ Number: big.NewInt(1),
+ GasLimit: 60_000_000,
+ GasUsed: 50_000_000,
+ BaseFee: baseFee,
+ }
+ expected := CalcBaseFee(cfg, parent)
+ child := &types.Header{
+ Number: big.NewInt(2),
+ GasLimit: 60_000_000,
+ GasUsed: 0,
+ BaseFee: expected,
+ }
+ require.NoError(t, VerifyEIP1559Header(cfg, parent, child),
+ "strict verification must accept the reserved-aware base fee")
+}
diff --git a/consensus/misc/eip1559/eip1559_test.go b/consensus/misc/eip1559/eip1559_test.go
index a8af74efe8..40db856e02 100644
--- a/consensus/misc/eip1559/eip1559_test.go
+++ b/consensus/misc/eip1559/eip1559_test.go
@@ -56,6 +56,7 @@ func copyConfig(original *params.ChainConfig) *params.ChainConfig {
BackupMultiplier: original.Bor.BackupMultiplier,
ValidatorContract: original.Bor.ValidatorContract,
StateReceiverContract: original.Bor.StateReceiverContract,
+ ReservedRegistryContract: original.Bor.ReservedRegistryContract,
OverrideStateSyncRecords: original.Bor.OverrideStateSyncRecords,
OverrideStateSyncRecordsInRange: original.Bor.OverrideStateSyncRecordsInRange,
BlockAlloc: original.Bor.BlockAlloc,
diff --git a/core/blockchain.go b/core/blockchain.go
index f6873cdb1a..2880231c5d 100644
--- a/core/blockchain.go
+++ b/core/blockchain.go
@@ -38,6 +38,7 @@ import (
"github.com/ethereum/go-ethereum/common/mclock"
"github.com/ethereum/go-ethereum/common/prque"
"github.com/ethereum/go-ethereum/consensus"
+ "github.com/ethereum/go-ethereum/consensus/bor/registryreader"
"github.com/ethereum/go-ethereum/consensus/misc/eip4844"
"github.com/ethereum/go-ethereum/core/history"
"github.com/ethereum/go-ethereum/core/rawdb"
@@ -430,6 +431,11 @@ type BlockChain struct {
chain2HeadFeed event.Feed // Reorg/NewHead/Fork data feed
chainSideFeed event.Feed // Side chain data feed (removed from geth but needed in bor)
milestoneFetcher func(ctx context.Context) (uint64, error) // Function to fetch the latest milestone end block from Heimdall.
+
+ // reservedRegistry is the read-only handle to the reserved blockspace
+ // registry used by block validation / filtering paths. Nil when the chain
+ // has no registry configured or runs under a non-bor consensus engine.
+ reservedRegistry registryreader.Reader
}
// NewBlockChain returns a fully initialised block chain using information
diff --git a/core/blockchain_reader.go b/core/blockchain_reader.go
index bcb9476219..cd3814d2d1 100644
--- a/core/blockchain_reader.go
+++ b/core/blockchain_reader.go
@@ -23,6 +23,7 @@ import (
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/consensus"
+ "github.com/ethereum/go-ethereum/consensus/bor/registryreader"
"github.com/ethereum/go-ethereum/consensus/misc/eip4844"
"github.com/ethereum/go-ethereum/core/rawdb"
"github.com/ethereum/go-ethereum/core/state"
@@ -564,6 +565,21 @@ func (bc *BlockChain) Config() *params.ChainConfig {
// Engine retrieves the blockchain's consensus engine.
func (bc *BlockChain) Engine() consensus.Engine { return bc.engine }
+// ReservedRegistry returns the reserved blockspace registry reader configured
+// for this chain, or nil when none has been wired (non-bor engines, tests, or
+// chains without the registry contract).
+func (bc *BlockChain) ReservedRegistry() registryreader.Reader { return bc.reservedRegistry }
+
+// SetReservedRegistry wires the reserved blockspace registry reader into the
+// chain. Called once at startup from the backend after the consensus engine is
+// ready. Passing nil is valid and disables reserved-tx awareness.
+func (bc *BlockChain) SetReservedRegistry(r registryreader.Reader) {
+ bc.reservedRegistry = r
+ // Mirror onto the header chain so the serial/V2 processors and the prefetcher
+ // (which only hold a *HeaderChain context) classify against the same reader.
+ bc.hc.SetReservedRegistry(r)
+}
+
// Snapshots returns the blockchain snapshot tree.
func (bc *BlockChain) Snapshots() *snapshot.Tree {
return bc.snaps
diff --git a/core/evm.go b/core/evm.go
index a74c098e00..aebe94c2b7 100644
--- a/core/evm.go
+++ b/core/evm.go
@@ -24,10 +24,13 @@ import (
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/consensus"
+ "github.com/ethereum/go-ethereum/consensus/bor/registryreader"
"github.com/ethereum/go-ethereum/consensus/misc/eip4844"
+ "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/core/vm"
+ "github.com/ethereum/go-ethereum/log"
)
// ChainContext supports retrieving headers and consensus parameters from the
@@ -39,6 +42,42 @@ type ChainContext interface {
Engine() consensus.Engine
}
+// ReservedSnapshotForBlock builds the reserved-blockspace snapshot used to
+// classify reserved senders while executing `header`, read from the PARENT
+// registry state (statedb is the parent post-state). Reading at the parent makes
+// classification deterministic across produce and verify and immune to a
+// governance tx landing in the same block. Returns nil when the chain exposes no
+// registry reader / none is configured, or on a read error (deterministic across
+// nodes since the parent state is fixed) — a nil snapshot classifies nothing.
+func ReservedSnapshotForBlock(chain ChainContext, statedb *state.StateDB, header *types.Header) *registryreader.Snapshot {
+ rr, ok := chain.(interface {
+ ReservedRegistry() registryreader.Reader
+ })
+ if !ok {
+ return nil
+ }
+ reader := rr.ReservedRegistry()
+ if reader == nil || !reader.HasReservedRegistry() {
+ return nil
+ }
+ // Classification is fork-gated, so there is nothing to classify before the
+ // reserved-blockspace fork. Skipping the build pre-fork avoids a per-block
+ // state copy and registry read on every node syncing from genesis.
+ if cfg := chain.Config(); cfg.Bor == nil || !cfg.Bor.IsReservedBlockspace(header.Number) {
+ return nil
+ }
+ parentNumber := uint64(0)
+ if header.Number.Uint64() > 0 {
+ parentNumber = header.Number.Uint64() - 1
+ }
+ snap, err := registryreader.BuildSnapshot(reader, statedb, parentNumber, header.ParentHash, header.Number.Uint64())
+ if err != nil {
+ log.Warn("Failed to build reserved-blockspace snapshot", "number", header.Number, "err", err)
+ return nil
+ }
+ return snap
+}
+
// NewEVMBlockContext creates a new context for use in the EVM.
func NewEVMBlockContext(header *types.Header, chain ChainContext, author *common.Address) vm.BlockContext {
var (
diff --git a/core/forkid/forkid.go b/core/forkid/forkid.go
index c7286140e9..85fc4d55a6 100644
--- a/core/forkid/forkid.go
+++ b/core/forkid/forkid.go
@@ -337,6 +337,7 @@ func GatherForks(config *params.ChainConfig, genesisTime uint64) (heightForks []
config.Bor.GiuglianoBlock,
config.Bor.ChicagoBlock,
config.Bor.ValenciaBlock,
+ config.Bor.ReservedBlockspaceBlock,
} {
if fork != nil {
heightForks = append(heightForks, fork.Uint64())
diff --git a/core/genesis.go b/core/genesis.go
index 20e3d8e8a2..283c34c70a 100644
--- a/core/genesis.go
+++ b/core/genesis.go
@@ -480,13 +480,49 @@ func (g *Genesis) IsVerkle() bool {
// ToBlock returns the genesis block according to genesis specification.
func (g *Genesis) ToBlock() *types.Block {
- root, err := hashAlloc(&g.Alloc, g.IsVerkle())
+ alloc, err := g.allocWithBlockZeroAlloc()
+ if err != nil {
+ panic(err)
+ }
+ root, err := hashAlloc(&alloc, g.IsVerkle())
if err != nil {
panic(err)
}
return g.toBlockWithRoot(root)
}
+func (g *Genesis) allocWithBlockZeroAlloc() (types.GenesisAlloc, error) {
+ if g == nil || g.Config == nil || g.Config.Bor == nil || g.Config.Bor.BlockAlloc == nil {
+ return g.Alloc, nil
+ }
+
+ blockZeroAlloc, ok := g.Config.Bor.BlockAlloc["0"]
+ if !ok {
+ return g.Alloc, nil
+ }
+
+ merged := make(types.GenesisAlloc, len(g.Alloc))
+ for address, account := range g.Alloc {
+ merged[address] = account
+ }
+
+ blob, err := json.Marshal(blockZeroAlloc)
+ if err != nil {
+ return nil, err
+ }
+
+ var alloc types.GenesisAlloc
+ if err := json.Unmarshal(blob, &alloc); err != nil {
+ return nil, err
+ }
+
+ for address, account := range alloc {
+ merged[address] = account
+ }
+
+ return merged, nil
+}
+
// toBlockWithRoot constructs the genesis block with the given genesis state root.
func (g *Genesis) toBlockWithRoot(root common.Hash) *types.Block {
head := &types.Header{
@@ -575,15 +611,19 @@ func (g *Genesis) Commit(db ethdb.Database, triedb *triedb.Database) (*types.Blo
if config.Clique != nil && len(g.ExtraData) < 32+crypto.SignatureLength {
return nil, errors.New("can't start clique chain without signers")
}
+ alloc, err := g.allocWithBlockZeroAlloc()
+ if err != nil {
+ return nil, err
+ }
// flush the data to disk and compute the state root
- root, err := flushAlloc(&g.Alloc, triedb)
+ root, err := flushAlloc(&alloc, triedb)
if err != nil {
return nil, err
}
block := g.toBlockWithRoot(root)
// Marshal the genesis state specification and persist.
- blob, err := json.Marshal(g.Alloc)
+ blob, err := json.Marshal(alloc)
if err != nil {
return nil, err
}
diff --git a/core/genesis_test.go b/core/genesis_test.go
index 0502e252c7..c1bc13649c 100644
--- a/core/genesis_test.go
+++ b/core/genesis_test.go
@@ -40,6 +40,45 @@ func TestSetupGenesis(t *testing.T) {
testSetupGenesis(t, rawdb.PathScheme)
}
+func TestGenesisBlockZeroBlockAlloc(t *testing.T) {
+ db := rawdb.NewMemoryDatabase()
+ tdb := triedb.NewDatabase(db, newDbConfig(rawdb.HashScheme))
+ addr := common.HexToAddress(params.DefaultReservedRegistryContract)
+
+ genesis := &Genesis{
+ Config: ¶ms.ChainConfig{
+ ChainID: big.NewInt(1337),
+ Bor: ¶ms.BorConfig{
+ BlockAlloc: map[string]interface{}{
+ "0": map[string]interface{}{
+ addr.Hex(): map[string]interface{}{
+ "balance": "0x0",
+ "code": "0x6000",
+ },
+ },
+ },
+ },
+ },
+ }
+
+ block := genesis.MustCommit(db, tdb)
+ if block.Hash() != genesis.ToBlock().Hash() {
+ t.Fatalf("genesis hash mismatch after applying block zero alloc")
+ }
+
+ stored, err := ReadGenesis(db)
+ if err != nil {
+ t.Fatal(err)
+ }
+ account, ok := stored.Alloc[addr]
+ if !ok {
+ t.Fatalf("expected block zero alloc account %s in stored genesis", addr)
+ }
+ if !bytes.Equal(account.Code, common.FromHex("0x6000")) {
+ t.Fatalf("unexpected block zero alloc code: %x", account.Code)
+ }
+}
+
func testSetupGenesis(t *testing.T, scheme string) {
var (
customghash = common.HexToHash("0x89c99d90b79719238d2645c7642f2c9295246e80775b38cfd162b696817fbd50")
diff --git a/core/headerchain.go b/core/headerchain.go
index 08362e4daa..ab00d05347 100644
--- a/core/headerchain.go
+++ b/core/headerchain.go
@@ -26,6 +26,7 @@ import (
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/common/lru"
"github.com/ethereum/go-ethereum/consensus"
+ "github.com/ethereum/go-ethereum/consensus/bor/registryreader"
"github.com/ethereum/go-ethereum/core/rawdb"
"github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/ethdb"
@@ -72,8 +73,23 @@ type HeaderChain struct {
engine consensus.Engine
stateSyncData []*types.StateSyncData // State sync data
+
+ // reservedRegistry mirrors the BlockChain's reserved-blockspace registry
+ // reader so block-execution paths that only hold a *HeaderChain context
+ // (the serial and V2 processors, the prefetcher) can resolve the same
+ // reserved set as the miner. Wired by BlockChain.SetReservedRegistry.
+ reservedRegistry registryreader.Reader
}
+// ReservedRegistry returns the reserved-blockspace registry reader, or nil when
+// none is wired. Lets ReservedSnapshotForBlock classify on hc-based execution
+// paths, matching the miner.
+func (hc *HeaderChain) ReservedRegistry() registryreader.Reader { return hc.reservedRegistry }
+
+// SetReservedRegistry wires the reserved-blockspace registry reader. Propagated
+// from BlockChain.SetReservedRegistry so producer and verifier share one source.
+func (hc *HeaderChain) SetReservedRegistry(r registryreader.Reader) { hc.reservedRegistry = r }
+
// NewHeaderChain creates a new HeaderChain structure. ProcInterrupt points
// to the parent's interrupt semaphore.
func NewHeaderChain(chainDb ethdb.Database, config *params.ChainConfig, engine consensus.Engine, procInterrupt func() bool) (*HeaderChain, error) {
diff --git a/core/parallel_state_processor.go b/core/parallel_state_processor.go
index 83951a2219..5f37b7269d 100644
--- a/core/parallel_state_processor.go
+++ b/core/parallel_state_processor.go
@@ -439,6 +439,9 @@ func (p *ParallelStateProcessor) Process(block *types.Block, statedb *state.Stat
}
blockContext := NewEVMBlockContext(header, p.bc, author)
+ // Same parent-state reserved snapshot the serial processor uses, so parallel
+ // and serial classify identically (consensus parity).
+ blockContext.ReservedSnapshot = ReservedSnapshotForBlock(p.bc, statedb, header)
coinbase := blockContext.Coinbase
context := NewEVMBlockContext(header, p.bc.hc, author)
@@ -1116,6 +1119,9 @@ func (p *V2StateProcessor) Process(block *types.Block, statedb *state.StateDB, c
misc.ApplyDAOHardFork(statedb)
}
blockCtx := NewEVMBlockContext(header, p.chain, author)
+ // Reserved snapshot from the parent state, matching the serial path so V2
+ // BlockSTM classification is identical (consensus parity).
+ blockCtx.ReservedSnapshot = ReservedSnapshotForBlock(p.chain, statedb, header)
applyV2PreExecSystemCalls(block, statedb, config, cfg, blockCtx)
tasks, err := buildV2Tasks(block, config, header, interruptCtx)
diff --git a/core/reserved_fee_test.go b/core/reserved_fee_test.go
new file mode 100644
index 0000000000..76b5e561a6
--- /dev/null
+++ b/core/reserved_fee_test.go
@@ -0,0 +1,321 @@
+// 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 core
+
+import (
+ "context"
+ "math/big"
+ "testing"
+
+ "github.com/holiman/uint256"
+
+ "github.com/ethereum/go-ethereum/common"
+ "github.com/ethereum/go-ethereum/consensus/bor/registryreader"
+ "github.com/ethereum/go-ethereum/core/blockstm"
+ "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"
+ "github.com/ethereum/go-ethereum/triedb"
+)
+
+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.
+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
+}
+
+// reservedBlockCtx builds a block context with a reserved-set snapshot covering
+// reservedSenders (the registry-backed source the EVM now classifies from).
+func reservedBlockCtx(coinbase common.Address, blockNumber *big.Int, baseFee *big.Int, reservedSenders ...common.Address) vm.BlockContext {
+ ctx := vm.BlockContext{
+ CanTransfer: CanTransfer,
+ Transfer: Transfer,
+ GetHash: func(n uint64) common.Hash { return common.Hash{} },
+ Coinbase: coinbase,
+ GasLimit: 30_000_000,
+ BlockNumber: blockNumber,
+ Time: 1,
+ BaseFee: baseFee,
+ }
+ if len(reservedSenders) > 0 {
+ clients := make(map[common.Address]registryreader.Client, len(reservedSenders))
+ for i, a := range reservedSenders {
+ clients[a] = registryreader.Client{ID: uint64(i + 1), GasQuota: 30_000_000}
+ }
+ ctx.ReservedSnapshot = registryreader.NewSnapshot(common.HexToHash("0x1"), uint64(len(reservedSenders))*30_000_000, clients)
+ }
+ return ctx
+}
+
+// fundedState returns an in-memory StateDB with `sender` funded and nonce 0.
+func fundedState(t *testing.T, sender common.Address, balance *uint256.Int) *state.StateDB {
+ t.Helper()
+ memdb := rawdb.NewMemoryDatabase()
+ tdb := triedb.NewDatabase(memdb, triedb.HashDefaults)
+ sdb, err := state.New(types.EmptyRootHash, state.NewDatabase(tdb, nil))
+ if err != nil {
+ t.Fatal(err)
+ }
+ sdb.AddBalance(sender, balance, 0)
+ sdb.SetNonce(sender, 0, 0)
+ return sdb
+}
+
+// TestReservedTxSkipsFees pins the core ticket-B behaviour: a reserved-sender
+// tx executes fee-free past the fork — no gas debit, no base-fee floor, no
+// producer tip, no burn. Only msg.Value moves.
+func TestReservedTxSkipsFees(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)
+ blockCtx := reservedBlockCtx(coinbase, big.NewInt(1), baseFee, sender)
+
+ initial := uint256.NewInt(1e18)
+ sdb := fundedState(t, sender, initial)
+
+ value := big.NewInt(5)
+ signer := types.NewLondonSigner(cc.ChainID)
+ tx, err := types.SignTx(types.NewTx(&types.DynamicFeeTx{
+ ChainID: cc.ChainID,
+ Nonce: 0,
+ GasTipCap: big.NewInt(0),
+ GasFeeCap: big.NewInt(0), // below baseFee — would be ErrFeeCapTooLow if not reserved
+ 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)
+ }
+
+ evm := vm.NewEVM(blockCtx, sdb, cc, vm.Config{})
+ evm.SetTxContext(NewEVMTxContext(msg))
+ result, err := ApplyMessage(evm, msg, new(GasPool).AddGas(blockCtx.GasLimit))
+ if err != nil {
+ t.Fatalf("reserved tx failed: %v", err)
+ }
+ if result.Failed() {
+ t.Fatalf("reserved tx reverted: %v", result.Err)
+ }
+
+ // No fee was applied in-protocol.
+ if result.FeeBurnt.Sign() != 0 {
+ t.Errorf("FeeBurnt=%s, want 0", result.FeeBurnt)
+ }
+ if result.FeeTipped.Sign() != 0 {
+ t.Errorf("FeeTipped=%s, want 0", result.FeeTipped)
+ }
+ if got := sdb.GetBalance(coinbase); !got.IsZero() {
+ t.Errorf("coinbase tip=%s, want 0", got)
+ }
+ if got := sdb.GetBalance(reservedBurntAddr); !got.IsZero() {
+ t.Errorf("burnt-contract balance=%s, want 0", got)
+ }
+
+ // Sender paid only value — no gas was debited.
+ wantSender := new(uint256.Int).Sub(initial, uint256.MustFromBig(value))
+ if got := sdb.GetBalance(sender); got.Cmp(wantSender) != 0 {
+ t.Errorf("sender balance=%s, want %s (value only, no gas)", got, wantSender)
+ }
+ if got := sdb.GetBalance(recipient); got.Cmp(uint256.MustFromBig(value)) != 0 {
+ t.Errorf("recipient balance=%s, want %s", got, value)
+ }
+ if result.UsedGas != 21000 {
+ t.Errorf("UsedGas=%d, want 21000", result.UsedGas)
+ }
+}
+
+// TestReservedTxGatedByFork verifies the fork gate: before the activation
+// block a reserved sender gets no exemption, so a zero-fee tx is rejected
+// with ErrFeeCapTooLow exactly as any other underpriced tx.
+func TestReservedTxGatedByFork(t *testing.T) {
+ key, _ := crypto.GenerateKey()
+ sender := crypto.PubkeyToAddress(key.PublicKey)
+ recipient := common.HexToAddress("0x1111111111111111111111111111111111111111")
+
+ cc := reservedTestConfig(big.NewInt(100), sender) // fork at 100
+ baseFee := big.NewInt(1_000_000_000)
+ blockCtx := reservedBlockCtx(common.HexToAddress("0xc0b0"), big.NewInt(1), baseFee, sender) // pre-fork
+
+ sdb := fundedState(t, sender, uint256.NewInt(1e18))
+ signer := types.NewLondonSigner(cc.ChainID)
+ tx, _ := types.SignTx(types.NewTx(&types.DynamicFeeTx{
+ ChainID: cc.ChainID,
+ Nonce: 0,
+ GasTipCap: big.NewInt(0),
+ GasFeeCap: big.NewInt(0),
+ Gas: 21000,
+ To: &recipient,
+ Value: big.NewInt(1),
+ }), signer, key)
+ msg, _ := TransactionToMessage(tx, signer, baseFee)
+
+ evm := vm.NewEVM(blockCtx, sdb, cc, vm.Config{})
+ evm.SetTxContext(NewEVMTxContext(msg))
+ _, err := ApplyMessage(evm, msg, new(GasPool).AddGas(blockCtx.GasLimit))
+ if err == nil {
+ t.Fatal("pre-fork zero-fee tx should be rejected, got nil error")
+ }
+}
+
+// TestNonReservedSenderPaysNormally verifies the negative case: with the fork
+// active but the sender NOT a reserved client, fees apply as usual (tip to
+// coinbase, base fee burnt).
+func TestNonReservedSenderPaysNormally(t *testing.T) {
+ key, _ := crypto.GenerateKey()
+ sender := crypto.PubkeyToAddress(key.PublicKey)
+ coinbase := common.HexToAddress("0x000000000000000000000000000000000000c0b0")
+ recipient := common.HexToAddress("0x1111111111111111111111111111111111111111")
+
+ // Fork active, but the reserved client is a DIFFERENT address.
+ reserved := common.HexToAddress("0x00000000000000000000000000000000000000ee")
+ cc := reservedTestConfig(big.NewInt(0), reserved)
+ baseFee := big.NewInt(1_000_000_000)
+ blockCtx := reservedBlockCtx(coinbase, big.NewInt(1), baseFee, reserved)
+
+ sdb := fundedState(t, sender, uint256.NewInt(1e18))
+ signer := types.NewLondonSigner(cc.ChainID)
+ tx, _ := types.SignTx(types.NewTx(&types.DynamicFeeTx{
+ ChainID: cc.ChainID,
+ Nonce: 0,
+ GasTipCap: big.NewInt(1),
+ GasFeeCap: big.NewInt(2_000_000_000),
+ Gas: 21000,
+ To: &recipient,
+ Value: big.NewInt(1),
+ }), signer, key)
+ msg, _ := TransactionToMessage(tx, signer, baseFee)
+
+ evm := vm.NewEVM(blockCtx, sdb, cc, vm.Config{})
+ evm.SetTxContext(NewEVMTxContext(msg))
+ result, err := ApplyMessage(evm, msg, new(GasPool).AddGas(blockCtx.GasLimit))
+ if err != nil {
+ t.Fatalf("non-reserved tx failed: %v", err)
+ }
+ if result.FeeBurnt == nil || result.FeeBurnt.Sign() == 0 {
+ t.Errorf("expected non-zero burn for non-reserved sender, got %v", result.FeeBurnt)
+ }
+ if got := sdb.GetBalance(coinbase); got.IsZero() {
+ t.Error("expected non-zero coinbase tip for non-reserved sender")
+ }
+ if got := sdb.GetBalance(reservedBurntAddr); got.IsZero() {
+ t.Error("expected non-zero burnt-contract balance for non-reserved sender")
+ }
+}
+
+// TestReservedTxSerialParallelParity is the consensus-critical check: the
+// reserved fee path must produce a byte-identical post-state whether the tx
+// runs through the serial executor (ApplyMessage) or BlockSTM
+// (ExecuteV2BlockSTM). Any divergence is a chain split.
+func TestReservedTxSerialParallelParity(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)
+ blockCtx := reservedBlockCtx(coinbase, big.NewInt(1), baseFee, sender)
+
+ // Committed base state shared by both paths.
+ memdb := rawdb.NewMemoryDatabase()
+ tdb := triedb.NewDatabase(memdb, triedb.HashDefaults)
+ sdb, _ := state.New(types.EmptyRootHash, state.NewDatabase(tdb, nil))
+ sdb.AddBalance(sender, uint256.NewInt(1e18), 0)
+ sdb.SetNonce(sender, 0, 0)
+ root, _ := sdb.Commit(0, false, false)
+ tdb.Commit(root, false)
+
+ signer := types.NewLondonSigner(cc.ChainID)
+ tx, _ := types.SignTx(types.NewTx(&types.DynamicFeeTx{
+ ChainID: cc.ChainID,
+ Nonce: 0,
+ GasTipCap: big.NewInt(0),
+ GasFeeCap: big.NewInt(0),
+ Gas: 21000,
+ To: &recipient,
+ Value: big.NewInt(7),
+ }), signer, key)
+ msg, _ := TransactionToMessage(tx, signer, baseFee)
+
+ // Serial path.
+ serialDB, _ := state.New(root, state.NewDatabase(tdb, nil))
+ evm := vm.NewEVM(blockCtx, serialDB, cc, vm.Config{})
+ evm.SetTxContext(NewEVMTxContext(msg))
+ if _, err := ApplyMessage(evm, msg, new(GasPool).AddGas(blockCtx.GasLimit)); err != nil {
+ t.Fatalf("serial reserved tx failed: %v", err)
+ }
+ serialRoot := serialDB.IntermediateRoot(true)
+
+ // Parallel (BlockSTM) path.
+ base, _ := state.New(root, state.NewDatabase(tdb, nil))
+ finalDB := base.Copy()
+ finalDB.StartPrefetcher("test", nil, nil)
+ defer finalDB.StopPrefetcher()
+ tasks := []V2Task{{Index: 0, Tx: tx, Msg: msg}}
+ result := ExecuteV2BlockSTM(context.Background(), tasks, base,
+ blockstm.NewMVStore(), blockstm.NewMVBalanceStore(),
+ blockCtx, common.Hash{}, vm.Config{}, cc, blockCtx.GasLimit, 1, finalDB, nil)
+ if result.ExecErrIdx >= 0 {
+ t.Fatalf("parallel reserved tx errored at %d: %v", result.ExecErrIdx, result.ExecErr)
+ }
+ if result.PanickedIdx >= 0 {
+ t.Fatalf("parallel reserved tx panicked at %d", result.PanickedIdx)
+ }
+ parallelRoot := finalDB.IntermediateRoot(true)
+
+ if serialRoot != parallelRoot {
+ t.Fatalf("state root divergence: serial=%s parallel=%s", serialRoot.Hex(), parallelRoot.Hex())
+ }
+
+ // And the balances landed where expected on both.
+ for _, db := range []*state.StateDB{serialDB, finalDB} {
+ if got := db.GetBalance(coinbase); !got.IsZero() {
+ t.Errorf("coinbase tip=%s, want 0", got)
+ }
+ if got := db.GetBalance(reservedBurntAddr); !got.IsZero() {
+ t.Errorf("burnt balance=%s, want 0", got)
+ }
+ if got := db.GetBalance(recipient); got.Uint64() != 7 {
+ t.Errorf("recipient=%s, want 7", got)
+ }
+ }
+}
diff --git a/core/reserved_registry_test.go b/core/reserved_registry_test.go
new file mode 100644
index 0000000000..9c3112f2df
--- /dev/null
+++ b/core/reserved_registry_test.go
@@ -0,0 +1,60 @@
+package core
+
+import (
+ "math/big"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/ethereum/go-ethereum/common"
+ "github.com/ethereum/go-ethereum/consensus/bor/contract/registrytest"
+ "github.com/ethereum/go-ethereum/consensus/ethash"
+ "github.com/ethereum/go-ethereum/core/rawdb"
+ "github.com/ethereum/go-ethereum/params"
+)
+
+// TestBlockChain_ReservedRegistry_NilByDefault asserts that a chain built
+// without wiring a registry returns nil from the accessor — block validation
+// paths must tolerate this rather than dereferencing.
+func TestBlockChain_ReservedRegistry_NilByDefault(t *testing.T) {
+ chain := newBareBlockChain(t)
+ defer chain.Stop()
+
+ require.Nil(t, chain.ReservedRegistry(), "fresh BlockChain must report no registry until SetReservedRegistry is called")
+}
+
+// TestBlockChain_ReservedRegistry_SeesContractState wires a registry-backed
+// reader into the chain and confirms the chain's accessor surfaces the same
+// view as the harness reader.
+func TestBlockChain_ReservedRegistry_SeesContractState(t *testing.T) {
+ h := registrytest.NewHarness(t)
+ chain := newBareBlockChain(t)
+ defer chain.Stop()
+
+ chain.SetReservedRegistry(h.Reader)
+
+ reader := chain.ReservedRegistry()
+ require.NotNil(t, reader)
+ require.True(t, reader.HasReservedRegistry())
+
+ reserved, err := reader.IsReservedAddress(nil, 0, common.Hash{}, h.ReservedAddr)
+ require.NoError(t, err)
+ require.True(t, reserved)
+
+ reserved, err = reader.IsReservedAddress(nil, 0, common.Hash{}, h.UnreservedAddr)
+ require.NoError(t, err)
+ require.False(t, reserved)
+}
+
+func newBareBlockChain(t *testing.T) *BlockChain {
+ t.Helper()
+ db := rawdb.NewMemoryDatabase()
+ gspec := &Genesis{
+ BaseFee: big.NewInt(params.InitialBaseFee),
+ Config: params.AllEthashProtocolChanges,
+ }
+ engine := ethash.NewFullFaker()
+ chain, err := NewBlockChain(db, gspec, engine, DefaultConfig().WithStateScheme(rawdb.HashScheme))
+ require.NoError(t, err)
+ return chain
+}
diff --git a/core/state_processor.go b/core/state_processor.go
index 0ac0af66e6..24d9e11f3b 100644
--- a/core/state_processor.go
+++ b/core/state_processor.go
@@ -95,6 +95,9 @@ func (p *StateProcessor) Process(block *types.Block, statedb *state.StateDB, cfg
tracingStateDB = state.NewHookedState(statedb, hooks)
}
context = NewEVMBlockContext(header, p.chain, author)
+ // Reserved-blockspace classification reads the registry at the parent state
+ // (statedb is the parent post-state here, before block execution).
+ context.ReservedSnapshot = ReservedSnapshotForBlock(p.chain, statedb, header)
evm := vm.NewEVM(context, tracingStateDB, p.chainConfig(), cfg)
if beaconRoot := block.BeaconRoot(); beaconRoot != nil {
diff --git a/core/state_transition.go b/core/state_transition.go
index 0893fef662..9ce667ee76 100644
--- a/core/state_transition.go
+++ b/core/state_transition.go
@@ -282,15 +282,36 @@ type stateTransition struct {
// This is useful during parallel state transition, where the common account read/write should be minimized.
noFeeBurnAndTip bool
noFeeLog bool // If true, skip fee transfer log and coinbase balance read (for parallel execution)
+
+ // reserved is true for a reserved-blockspace transaction (post-fork, from a
+ // whitelisted client). Such a tx pays zero in-protocol fee: no gas debit, no
+ // gas refund, no producer tip, no base-fee burn. Computed once in
+ // newStateTransition so serial and parallel execution agree.
+ reserved bool
}
// newStateTransition initialises and returns a new state transition object.
func newStateTransition(evm *vm.EVM, msg *Message, gp *GasPool) *stateTransition {
+ // Classify the message as reserved-blockspace here — the single point every
+ // execution path (serial, parallel, ApplyMessage*) funnels through — so the
+ // zero-fee decision is identical across executors. The fork gate comes from
+ // chain config; the reserved set from the parent registry snapshot on the
+ // block context (set per block by the consensus paths, nil elsewhere),
+ // matching the txpool and base-fee sources. Classification is sender-based;
+ // per-client quota enforcement is layered on by the producer's reserved
+ // pass and the block-validation slice.
+ var reserved bool
+ if cfg := evm.ChainConfig(); cfg.Bor != nil &&
+ cfg.Bor.IsReservedBlockspace(evm.Context.BlockNumber) &&
+ evm.Context.ReservedSnapshot.IsReserved(msg.From) {
+ reserved = true
+ }
return &stateTransition{
- gp: gp,
- evm: evm,
- msg: msg,
- state: evm.StateDB,
+ gp: gp,
+ evm: evm,
+ msg: msg,
+ state: evm.StateDB,
+ reserved: reserved,
}
}
@@ -325,6 +346,16 @@ func (st *stateTransition) buyGas() error {
mgval.Add(mgval, blobFee)
}
}
+
+ if st.reserved {
+ // Zero in-protocol fee: don't require or debit gas, fund only the call
+ // value. This overwrites mgval/balanceCheck after the blob branch above,
+ // so a reserved blob tx also skips its blob-fee debit and blob-fee balance
+ // requirement. Reserved clients are not a blob-tx use case today; if that
+ // changes, revisit whether the blob fee (a separate burn) should be waived.
+ mgval, balanceCheck = reservedZeroFeeGas(st.msg.Value)
+ }
+
balanceCheckU256, overflow := uint256.FromBig(balanceCheck)
if overflow {
return fmt.Errorf("%w: address %v required balance exceeds 256 bits", ErrInsufficientFunds, st.msg.From.Hex())
@@ -348,6 +379,35 @@ func (st *stateTransition) buyGas() error {
return nil
}
+// reservedZeroFeeGas returns the (mgval, balanceCheck) for a reserved-blockspace
+// tx: zero gas cost to debit, only the call value to fund. Block gas is still
+// consumed by the caller, so gas accounting is unchanged.
+func reservedZeroFeeGas(value *big.Int) (mgval, balanceCheck *big.Int) {
+ return new(big.Int), new(big.Int).Set(value)
+}
+
+// calcBaseFeeBurn returns the base-fee burn amount and the configured burnt-
+// contract address. Bor redirects the burned base fee to a "burnt contract"
+// (a dead address on mainnet/amoy); the credit happens here unless fees are
+// deferred (noFeeBurnAndTip). Reserved-blockspace txs burn nothing. On non-Bor
+// configs (Bor == nil) the base fee is implicitly burned the upstream way and no
+// credit happens, matching Ethereum mainnet semantics and Ethereum-spec fixtures.
+func (st *stateTransition) calcBaseFeeBurn(rules params.Rules) (*big.Int, common.Address) {
+ bor := st.evm.ChainConfig().Bor
+ if !rules.IsLondon || bor == nil {
+ return nil, common.Address{}
+ }
+ addr := common.HexToAddress(bor.CalculateBurntContract(st.evm.Context.BlockNumber.Uint64()))
+ if st.reserved {
+ return new(big.Int), addr
+ }
+ burn := new(big.Int).Mul(new(big.Int).SetUint64(st.gasUsed()), st.evm.Context.BaseFee)
+ if !st.noFeeBurnAndTip {
+ st.state.AddBalance(addr, cmath.BigIntToUint256Int(burn), tracing.BalanceChangeTransfer)
+ }
+ return burn, addr
+}
+
func (st *stateTransition) preCheck() error {
// Only check transactions that are not fake
msg := st.msg
@@ -401,7 +461,9 @@ func (st *stateTransition) preCheck() error {
}
// This will panic if baseFee is nil, but basefee presence is verified
// as part of header validation.
- if msg.GasFeeCap.Cmp(st.evm.Context.BaseFee) < 0 {
+ // Reserved-blockspace txs are exempt from the base-fee floor: they may
+ // carry maxFeePerGas below baseFee (or zero) and still execute fee-free.
+ if msg.GasFeeCap.Cmp(st.evm.Context.BaseFee) < 0 && !st.reserved {
return fmt.Errorf("%w: address %v, maxFeePerGas: %s, baseFee: %s", ErrFeeCapTooLow,
msg.From.Hex(), msg.GasFeeCap, st.evm.Context.BaseFee)
}
@@ -603,6 +665,13 @@ func (st *stateTransition) execute() (*ExecutionResult, error) {
effectiveTip = new(big.Int).Sub(msg.GasPrice, st.evm.Context.BaseFee)
}
+ if st.reserved {
+ // Reserved-blockspace tx pays zero in-protocol fee: no producer tip and no
+ // base-fee burn (the burn is skipped below). The tx's fee fields stay
+ // readable for off-chain settlement but are not applied in-protocol.
+ effectiveTip = new(big.Int)
+ }
+
// TODO(raneet10): Double check. We might want to inculcate this fix in a separate condition
// if st.evm.Config.NoBaseFee && msg.GasFeeCap.Sign() == 0 && msg.GasTipCap.Sign() == 0 {
// // Skip fee payment when NoBaseFee is set and the fee fields
@@ -616,24 +685,7 @@ func (st *stateTransition) execute() (*ExecutionResult, error) {
amount := new(big.Int).Mul(new(big.Int).SetUint64(st.gasUsed()), effectiveTip)
- var burnAmount *big.Int
- var burntContractAddress common.Address
-
- if rules.IsLondon {
- // Bor-specific behavior: redirect the burned base fee to a configured
- // "burnt contract" (a dead address on bor mainnet/amoy). On non-Bor
- // chain configs (Bor == nil), no credit happens — the base fee is
- // implicitly burned the upstream go-ethereum way, matching Ethereum
- // mainnet semantics. Without the nil-guard, Ethereum-spec test
- // fixtures with Bor == nil panic here.
- if bor := st.evm.ChainConfig().Bor; bor != nil {
- burntContractAddress = common.HexToAddress(bor.CalculateBurntContract(st.evm.Context.BlockNumber.Uint64()))
- burnAmount = new(big.Int).Mul(new(big.Int).SetUint64(st.gasUsed()), st.evm.Context.BaseFee)
- if !st.noFeeBurnAndTip {
- st.state.AddBalance(burntContractAddress, cmath.BigIntToUint256Int(burnAmount), tracing.BalanceChangeTransfer)
- }
- }
- }
+ burnAmount, burntContractAddress := st.calcBaseFeeBurn(rules)
if !st.noFeeBurnAndTip {
st.state.AddBalance(st.evm.Context.Coinbase, cmath.BigIntToUint256Int(amount), tracing.BalanceIncreaseRewardTransactionFee)
@@ -758,9 +810,13 @@ func (st *stateTransition) calcRefund() uint64 {
// returnGas returns ETH for remaining gas,
// exchanged at the original rate.
func (st *stateTransition) returnGas() {
- remaining := uint256.NewInt(st.gasRemaining)
- remaining.Mul(remaining, uint256.MustFromBig(st.msg.GasPrice))
- st.state.AddBalance(st.msg.From, remaining, tracing.BalanceIncreaseGasReturn)
+ // Reserved-blockspace txs were never charged for gas (see buyGas), so there is
+ // nothing to refund. Unused gas is still returned to the block gas pool below.
+ if !st.reserved {
+ remaining := uint256.NewInt(st.gasRemaining)
+ remaining.Mul(remaining, uint256.MustFromBig(st.msg.GasPrice))
+ st.state.AddBalance(st.msg.From, remaining, tracing.BalanceIncreaseGasReturn)
+ }
if st.evm.Config.Tracer != nil && st.evm.Config.Tracer.OnGasChange != nil && st.gasRemaining > 0 {
st.evm.Config.Tracer.OnGasChange(st.gasRemaining, 0, tracing.GasChangeTxLeftOverReturned)
diff --git a/core/txpool/legacypool/legacypool.go b/core/txpool/legacypool/legacypool.go
index ea90e641a0..a2f09f2f6b 100644
--- a/core/txpool/legacypool/legacypool.go
+++ b/core/txpool/legacypool/legacypool.go
@@ -31,6 +31,7 @@ import (
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/common/prque"
+ "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"
@@ -326,6 +327,12 @@ type LegacyPool struct {
filteredAddrs map[common.Address]struct{} // Map of addresses to filter
+ // Reserved-blockspace registry: reader is wired post-Init from the backend;
+ // the snapshot is rebuilt at each reset from the new head's state so the
+ // per-tx admission path never reads contract state.
+ reservedRegistry registryreader.Reader
+ reservedSnapshot atomic.Pointer[registryreader.Snapshot]
+
// Rebroadcast tracking
rebroadcastTxFeed event.Feed // Feed for stuck transaction events
lastRebroadcast map[common.Hash]time.Time // Track last rebroadcast time per tx hash
@@ -729,10 +736,14 @@ func (pool *LegacyPool) Pending(filter txpool.PendingFilter, interrupt *atomic.B
txs := list.Flatten()
+ // Reserved-blockspace senders pay zero in-protocol fee; their txs must not
+ // be capped by the tip floor or they would never reach the miner.
+ reserved := pool.isReserved(addr)
+
// If the miner requests tip enforcement, cap the lists now
if filter.MinTip != nil || filter.GasLimitCap != 0 {
for i, tx := range txs {
- if filter.MinTip != nil {
+ if filter.MinTip != nil && !reserved {
if tx.EffectiveGasTipIntCmp(filter.MinTip, filter.BaseFee) < 0 {
txs = txs[:i]
break
@@ -779,8 +790,9 @@ func (pool *LegacyPool) ValidateTxBasics(tx *types.Transaction) error {
1<= 0 || old.GasTipCapCmp(tx) >= 0 {
diff --git a/core/txpool/legacypool/queue.go b/core/txpool/legacypool/queue.go
index a889debe37..b1ea0b050c 100644
--- a/core/txpool/legacypool/queue.go
+++ b/core/txpool/legacypool/queue.go
@@ -124,6 +124,8 @@ func (q *queue) add(tx *types.Transaction) (*common.Hash, error) {
if q.queued[from] == nil {
q.queued[from] = newList(false)
}
+ // Reserved replacement-by-arrival applies to the pending pool; the future
+ // queue keeps the standard fee-bump rule.
inserted, old := q.queued[from].Add(tx, q.config.PriceBump)
if !inserted {
// An older transaction was better, discard this
diff --git a/core/txpool/legacypool/reserved_test.go b/core/txpool/legacypool/reserved_test.go
new file mode 100644
index 0000000000..8181638d5c
--- /dev/null
+++ b/core/txpool/legacypool/reserved_test.go
@@ -0,0 +1,226 @@
+// 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 legacypool
+
+import (
+ "crypto/ecdsa"
+ "math/big"
+ "testing"
+
+ "github.com/holiman/uint256"
+
+ "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/txpool"
+ "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"
+)
+
+// fakeRegistry is a state-independent registryreader.Reader: the reserved set is
+// fixed at construction. It lets the pool build a snapshot without deploying the
+// real contract into the test state, exercising the snapshot→isReserved path.
+type fakeRegistry struct {
+ reserved map[common.Address]registryreader.ClientLookup
+ capacity uint64
+}
+
+func newFakeRegistry(reserved ...common.Address) *fakeRegistry {
+ f := &fakeRegistry{reserved: make(map[common.Address]registryreader.ClientLookup)}
+ for i, a := range reserved {
+ f.reserved[a] = registryreader.ClientLookup{
+ ClientID: big.NewInt(int64(i + 1)), GasQuota: 30_000_000, Active: true,
+ }
+ f.capacity += 30_000_000
+ }
+ return f
+}
+
+func (f *fakeRegistry) HasReservedRegistry() bool { return true }
+func (f *fakeRegistry) IsReservedAddress(_ *state.StateDB, _ uint64, _ common.Hash, a common.Address) (bool, error) {
+ 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 {
+ addrs = append(addrs, a)
+ }
+ return addrs, nil
+}
+func (f *fakeRegistry) TotalReservedGas(_ *state.StateDB, _ uint64, _ common.Hash) (uint64, error) {
+ return f.capacity, nil
+}
+
+func reservedChainConfig() *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
+ cfg.Bor = &bor
+ return &cfg
+}
+
+func setupReservedPool(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 {
+ panic(err)
+ }
+ <-pool.initDoneCh
+ // A realistic positive tip floor (PIP-35 ~25 gwei). Reserved senders must
+ // 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))
+ return pool
+}
+
+func zeroFeeTx(t *testing.T, cfg *params.ChainConfig, key *ecdsa.PrivateKey, nonce uint64, to common.Address) *types.Transaction {
+ t.Helper()
+ tx, err := types.SignNewTx(key, types.LatestSigner(cfg), &types.DynamicFeeTx{
+ ChainID: cfg.ChainID,
+ Nonce: nonce,
+ GasTipCap: big.NewInt(0),
+ GasFeeCap: big.NewInt(0),
+ Gas: 100_000,
+ To: &to,
+ Value: big.NewInt(0),
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ return tx
+}
+
+// TestReservedZeroFeeTxAdmittedAndPending verifies the core ticket-D 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
+// same tx from a non-reserved sender is rejected at admission.
+func TestReservedZeroFeeTxAdmittedAndPending(t *testing.T) {
+ t.Parallel()
+
+ reservedKey, _ := crypto.GenerateKey()
+ reservedAddr := crypto.PubkeyToAddress(reservedKey.PublicKey)
+ pool := setupReservedPool(reservedAddr)
+ defer pool.Close()
+
+ otherKey, _ := crypto.GenerateKey()
+ otherAddr := crypto.PubkeyToAddress(otherKey.PublicKey)
+
+ testAddBalance(pool, reservedAddr, big.NewInt(1_000_000))
+ testAddBalance(pool, otherAddr, big.NewInt(1_000_000))
+
+ cfg := pool.chainconfig
+
+ // Reserved sender: zero-fee tx must be admitted.
+ rtx := zeroFeeTx(t, cfg, reservedKey, 0, common.Address{0x42})
+ if err := pool.Add([]*types.Transaction{rtx}, true)[0]; err != nil {
+ t.Fatalf("reserved zero-fee tx rejected: %v", err)
+ }
+ if pool.Get(rtx.Hash()) == nil {
+ t.Fatal("reserved zero-fee tx not kept in pool")
+ }
+
+ // Non-reserved sender: identical zero-fee tx must be rejected at the floor.
+ otx := zeroFeeTx(t, cfg, otherKey, 0, common.Address{0x42})
+ if err := pool.Add([]*types.Transaction{otx}, true)[0]; err == nil {
+ t.Fatal("non-reserved zero-fee tx should be rejected at the tip floor")
+ }
+
+ // The reserved tx must survive a high miner tip filter so it reaches the miner.
+ pending := pool.Pending(txpool.PendingFilter{
+ MinTip: uint256.NewInt(30_000_000_000),
+ BaseFee: uint256.NewInt(1_000_000_000),
+ }, nil)
+ if got := pending[reservedAddr]; len(got) != 1 || got[0].Hash != rtx.Hash() {
+ t.Fatalf("reserved zero-fee tx missing from Pending under high MinTip: %v", got)
+ }
+}
+
+// TestReservedZeroFeeReplacement pins the spec's replacement rule (§8.2):
+// same-nonce replacement is priced entirely through the fallback-fee fields
+// under the standard bump rule. A zero-fee tx can never replace a zero-fee tx
+// ("10% over zero is still zero" — the strict-increase check rejects it),
+// while strictly positive fallback fees win the slot. This keeps same-nonce
+// pool content convergent across nodes regardless of arrival order.
+func TestReservedZeroFeeReplacement(t *testing.T) {
+ t.Parallel()
+
+ reservedKey, _ := crypto.GenerateKey()
+ reservedAddr := crypto.PubkeyToAddress(reservedKey.PublicKey)
+ pool := setupReservedPool(reservedAddr)
+ defer pool.Close()
+
+ testAddBalance(pool, reservedAddr, big.NewInt(1_000_000))
+ cfg := pool.chainconfig
+
+ first := zeroFeeTx(t, cfg, reservedKey, 0, common.Address{0xaa})
+ if err := pool.Add([]*types.Transaction{first}, true)[0]; err != nil {
+ t.Fatalf("first reserved tx rejected: %v", err)
+ }
+
+ // Same nonce, still zero fee: rejected — zero cannot out-bid zero.
+ second := zeroFeeTx(t, cfg, reservedKey, 0, common.Address{0xbb})
+ if err := pool.Add([]*types.Transaction{second}, true)[0]; err == nil {
+ t.Fatal("zero-fee replacement of a zero-fee tx must be rejected")
+ }
+ if pool.Get(first.Hash()) == nil {
+ t.Fatal("incumbent zero-fee tx must remain in the pool")
+ }
+
+ // Same nonce with positive fallback fees: wins the slot under the standard
+ // bump rule (strictly above zero clears both the strict-increase check and
+ // the zero threshold). The fallback fees stay below the 30 gwei tip floor,
+ // which reserved senders bypass at admission.
+ third, err := types.SignNewTx(reservedKey, types.LatestSigner(cfg), &types.DynamicFeeTx{
+ ChainID: cfg.ChainID,
+ Nonce: 0,
+ GasTipCap: big.NewInt(1),
+ GasFeeCap: big.NewInt(1),
+ Gas: 100_000,
+ To: &common.Address{0xcc},
+ Value: big.NewInt(0),
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := pool.Add([]*types.Transaction{third}, true)[0]; err != nil {
+ t.Fatalf("fallback-fee replacement rejected: %v", err)
+ }
+ if pool.Get(third.Hash()) == nil {
+ t.Fatal("fallback-fee replacement not in pool")
+ }
+ if pool.Get(first.Hash()) != nil {
+ t.Fatal("replaced zero-fee tx should have been evicted")
+ }
+
+ // And a zero-fee tx cannot displace the fallback-fee incumbent either.
+ fourth := zeroFeeTx(t, cfg, reservedKey, 0, common.Address{0xdd})
+ if err := pool.Add([]*types.Transaction{fourth}, true)[0]; err == nil {
+ t.Fatal("zero-fee tx must not replace a fallback-fee incumbent")
+ }
+}
diff --git a/core/txpool/reserved_registry_test.go b/core/txpool/reserved_registry_test.go
new file mode 100644
index 0000000000..7988a812fb
--- /dev/null
+++ b/core/txpool/reserved_registry_test.go
@@ -0,0 +1,42 @@
+package txpool
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/ethereum/go-ethereum/common"
+ "github.com/ethereum/go-ethereum/consensus/bor/contract/registrytest"
+)
+
+// TestTxPool_ReservedRegistry_NilByDefault asserts the accessor on a freshly
+// zeroed TxPool returns nil so nil-handle consumers don't panic.
+func TestTxPool_ReservedRegistry_NilByDefault(t *testing.T) {
+ var pool *TxPool
+ require.Nil(t, pool.ReservedRegistry())
+
+ pool = &TxPool{}
+ require.Nil(t, pool.ReservedRegistry())
+}
+
+// TestTxPool_ReservedRegistry_SeesContractState wires a real registry-backed
+// reader into the pool and verifies the pool reports the same view as the
+// underlying contract for both registered and unregistered addresses.
+func TestTxPool_ReservedRegistry_SeesContractState(t *testing.T) {
+ h := registrytest.NewHarness(t)
+
+ pool := &TxPool{}
+ pool.SetReservedRegistry(h.Reader)
+
+ reader := pool.ReservedRegistry()
+ require.NotNil(t, reader)
+ require.True(t, reader.HasReservedRegistry())
+
+ reserved, err := reader.IsReservedAddress(nil, 0, common.Hash{}, h.ReservedAddr)
+ require.NoError(t, err)
+ require.True(t, reserved, "txpool must see registered address as reserved")
+
+ reserved, err = reader.IsReservedAddress(nil, 0, common.Hash{}, h.UnreservedAddr)
+ require.NoError(t, err)
+ require.False(t, reserved)
+}
diff --git a/core/txpool/txpool.go b/core/txpool/txpool.go
index 33ffb7e550..401f0165c3 100644
--- a/core/txpool/txpool.go
+++ b/core/txpool/txpool.go
@@ -24,6 +24,7 @@ import (
"sync/atomic"
"github.com/ethereum/go-ethereum/common"
+ "github.com/ethereum/go-ethereum/consensus/bor/registryreader"
"github.com/ethereum/go-ethereum/core"
"github.com/ethereum/go-ethereum/core/state"
"github.com/ethereum/go-ethereum/core/types"
@@ -70,6 +71,11 @@ type TxPool struct {
stateLock sync.RWMutex // The lock for protecting state instance
state *state.StateDB // Current state at the blockchain head
+ // reservedRegistry is the read-only handle to the reserved blockspace
+ // registry. Nil when no registry is configured; subpools may pull it via
+ // ReservedRegistry() to classify reserved transactions.
+ reservedRegistry registryreader.Reader
+
subs event.SubscriptionScope // Subscription scope to unsubscribe all on shutdown
quit chan chan error // Quit channel to tear down the head updater
term chan struct{} // Termination channel to detect a closed pool
@@ -262,6 +268,34 @@ func (p *TxPool) loop(head *types.Header) {
errc <- nil
}
+// ReservedRegistry returns the reserved blockspace registry reader wired into
+// the pool. Subpools that need to classify reserved txs should read it through
+// this accessor — it tolerates a nil pool by returning nil.
+func (p *TxPool) ReservedRegistry() registryreader.Reader {
+ if p == nil {
+ return nil
+ }
+ return p.reservedRegistry
+}
+
+// SetReservedRegistry installs the reserved blockspace registry reader.
+// Called once at startup from the backend, after the consensus engine is
+// available. Passing nil is valid.
+func (p *TxPool) SetReservedRegistry(r registryreader.Reader) {
+ if p == nil {
+ return
+ }
+ p.reservedRegistry = r
+ // Propagate to any subpool that classifies reserved transactions.
+ for _, sp := range p.subpools {
+ if setter, ok := sp.(interface {
+ SetReservedRegistry(registryreader.Reader)
+ }); ok {
+ setter.SetReservedRegistry(r)
+ }
+ }
+}
+
// SetGasTip updates the minimum gas tip required by the transaction pool for a
// new transaction, and drops all transactions below this threshold.
func (p *TxPool) SetGasTip(tip *big.Int) {
diff --git a/core/txpool/validation.go b/core/txpool/validation.go
index 73ab55bd97..1a21d520e5 100644
--- a/core/txpool/validation.go
+++ b/core/txpool/validation.go
@@ -22,6 +22,7 @@ import (
"math/big"
"github.com/ethereum/go-ethereum/common"
+ "github.com/ethereum/go-ethereum/consensus/bor/registryreader"
"github.com/ethereum/go-ethereum/core"
"github.com/ethereum/go-ethereum/core/state"
"github.com/ethereum/go-ethereum/core/types"
@@ -47,6 +48,11 @@ type ValidationOptions struct {
MaxSize uint64 // Maximum size of a transaction that the caller can meaningfully handle
MaxBlobCount int // Maximum number of blobs allowed per transaction
MinTip *big.Int // Minimum gas tip needed to allow a transaction into the caller pool
+
+ // ReservedSnapshot is the caller pool's per-head reserved-set snapshot, used
+ // to waive fee floors for reserved senders. Nil for pools without a registry
+ // (blobpool, or no registry configured) — classification then returns false.
+ ReservedSnapshot *registryreader.Snapshot
}
// ValidationFunction is an method type which the pools use to perform the tx-validations which do not
@@ -143,8 +149,11 @@ func ValidateTransaction(tx *types.Transaction, head *types.Header, signer types
return fmt.Errorf("%w: gas %v, minimum needed %v", core.ErrFloorDataGas, tx.Gas(), floorDataGas)
}
}
- // Ensure the gasprice is high enough to cover the requirement of the calling pool
- if tx.GasTipCapIntCmp(opts.MinTip) < 0 {
+ // Ensure the gasprice is high enough to cover the requirement of the calling
+ // pool. Reserved-blockspace senders are exempt: they pay zero in-protocol fee,
+ // so enforcing the tip floor would reject their transactions at admission and
+ // they would never reach the miner.
+ if tx.GasTipCapIntCmp(opts.MinTip) < 0 && !isReservedSender(opts, head, signer, tx) {
return fmt.Errorf("%w: gas tip cap %v, minimum needed %v", ErrTxGasPriceTooLow, tx.GasTipCap(), opts.MinTip)
}
if tx.Type() == types.BlobTxType {
@@ -158,6 +167,28 @@ func ValidateTransaction(tx *types.Transaction, head *types.Header, signer types
return nil
}
+// isReservedSender reports whether tx's recovered sender is a reserved-blockspace
+// client active at head. Reserved senders bypass the pool's fee floors so their
+// zero-fee transactions are admitted and kept. Classification is sender-based and
+// consensus-uniform (the reserved set comes from the chain config), matching the
+// EVM fee path in core/state_transition.go.
+func isReservedSender(opts *ValidationOptions, head *types.Header, signer types.Signer, tx *types.Transaction) bool {
+ cfg := opts.Config
+ if cfg == nil || cfg.Bor == nil || head == nil {
+ return false
+ }
+ // Classify for the block this tx targets (the one after head).
+ number := new(big.Int).Add(head.Number, big.NewInt(1))
+ if !cfg.Bor.IsReservedBlockspace(number) {
+ return false
+ }
+ from, err := types.Sender(signer, tx)
+ if err != nil {
+ return false
+ }
+ return opts.ReservedSnapshot.IsReserved(from)
+}
+
// validateBlobTx implements the blob-transaction specific validations.
func validateBlobTx(tx *types.Transaction, head *types.Header, opts *ValidationOptions) error {
sidecar := tx.BlobTxSidecar()
diff --git a/core/vm/evm.go b/core/vm/evm.go
index 5f19d0e5b5..a240730a08 100644
--- a/core/vm/evm.go
+++ b/core/vm/evm.go
@@ -25,6 +25,7 @@ import (
"github.com/holiman/uint256"
"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/tracing"
"github.com/ethereum/go-ethereum/core/types"
@@ -120,6 +121,14 @@ type BlockContext struct {
BaseFee *big.Int // Provides information for BASEFEE (0 if vm runs with NoBaseFee flag and 0 gas price)
BlobBaseFee *big.Int // Provides information for BLOBBASEFEE (0 if vm runs with NoBaseFee flag and 0 blob gas price)
Random *common.Hash // Provides information for PREVRANDAO
+
+ // ReservedSnapshot is the reserved-blockspace set as of the parent block,
+ // used to classify reserved senders for the in-protocol fee skip. Set once
+ // per block by the consensus execution paths (serial + parallel processors,
+ // miner) from the parent state, so produce and verify agree; nil elsewhere
+ // (eth_call, prefetch) and on non-reserved chains. A nil snapshot classifies
+ // nothing, so the fee skip is inert until a registry is configured.
+ ReservedSnapshot *registryreader.Snapshot
}
// TxContext provides the EVM with information about a transaction.
diff --git a/eth/backend.go b/eth/backend.go
index d6414dd0b1..67f2fb949b 100644
--- a/eth/backend.go
+++ b/eth/backend.go
@@ -474,6 +474,16 @@ func New(stack *node.Node, config *ethconfig.Config) (*Ethereum, error) {
eth.miner.SetPrioAddresses(config.TxPool.Locals)
}
+ // Wire the reserved blockspace registry into the chain and txpool. Only Bor
+ // exposes a registry; non-Bor engines silently leave nil handles in place.
+ // The miner needs no handle of its own: block building classifies against
+ // the per-block snapshot the chain builds for execution (makeEnv).
+ if borEngine, ok := eth.engine.(*bor.Bor); ok {
+ reg := borEngine.ReservedRegistry()
+ eth.blockchain.SetReservedRegistry(reg)
+ eth.txPool.SetReservedRegistry(reg)
+ }
+
// 1.14.8: NewOracle function definition was changed to accept (startPrice *big.Int) param.
eth.APIBackend.gpo = gasprice.NewOracle(eth.APIBackend, config.GPO, config.Miner.GasPrice)
eth.APIBackend.gpo.ProcessCache()
diff --git a/internal/ethapi/api.go b/internal/ethapi/api.go
index 0cdabc10d3..bfe481835a 100644
--- a/internal/ethapi/api.go
+++ b/internal/ethapi/api.go
@@ -906,10 +906,16 @@ func doCall(ctx context.Context, b Backend, args TransactionArgs, state *state.S
return nil, err
}
+ // Internal consensus reads of system contracts must be deterministic across
+ // nodes. The RPC gas cap and the wall-clock EVM timeout are both node-local
+ // and load-dependent, so a read that timed out on one node but not another
+ // would diverge state — bypass both for these calls.
+ internalSystemCall := isBorInternalCall(ctx) && isBorSystemTx(b.ChainConfig().Bor, args.To)
+
// Setup context so it may be cancelled the call has completed
// or, in case of unmetered gas, setup a context with a timeout.
var cancel context.CancelFunc
- if timeout > 0 {
+ if timeout > 0 && !internalSystemCall {
ctx, cancel = context.WithTimeout(ctx, timeout)
} else {
ctx, cancel = context.WithCancel(ctx)
@@ -918,9 +924,7 @@ func doCall(ctx context.Context, b Backend, args TransactionArgs, state *state.S
// this makes sure resources are cleaned up.
defer cancel()
- // Only bypass the RPC gas cap for system contract calls that originate from
- // internal consensus code (marked via WithBorInternalCall ctx).
- if isBorInternalCall(ctx) && isBorSystemTx(b.ChainConfig().Bor, args.To) {
+ if internalSystemCall {
globalGasCap = 0
}
gp := new(core.GasPool)
diff --git a/internal/ethapi/bor_api.go b/internal/ethapi/bor_api.go
index cb3377bdbd..ac6a538e26 100644
--- a/internal/ethapi/bor_api.go
+++ b/internal/ethapi/bor_api.go
@@ -76,7 +76,10 @@ func isBorSystemTx(borCfg *params.BorConfig, to *common.Address) bool {
validatorContract := common.HexToAddress(borCfg.ValidatorContract)
stateReceiverContract := common.HexToAddress(borCfg.StateReceiverContract)
- if to.Cmp(validatorContract) == 0 || to.Cmp(stateReceiverContract) == 0 {
+ reservedRegistryContract := common.HexToAddress(borCfg.ReservedRegistryContract)
+ if to.Cmp(validatorContract) == 0 ||
+ to.Cmp(stateReceiverContract) == 0 ||
+ (borCfg.ReservedRegistryContract != "" && to.Cmp(reservedRegistryContract) == 0) {
return true
}
diff --git a/miner/miner.go b/miner/miner.go
index bffaccc5f5..dbd4607542 100644
--- a/miner/miner.go
+++ b/miner/miner.go
@@ -285,15 +285,6 @@ func (miner *Miner) SetPrioAddresses(prio []common.Address) {
miner.worker.setPrio(prio)
}
-// SetReservedRegistry installs the reserved-blockspace registry the miner
-// consults during block building. Production callers leave it unset (nil) until
-// the registry module and its hardfork gating land — the reserved pass is a
-// no-op in that case and block building is byte-identical to before
-// reserved-blockspace existed. Tests inject a *MockRegistry.
-func (miner *Miner) SetReservedRegistry(r ReservedRegistry) {
- miner.worker.setReservedRegistry(r)
-}
-
// SetGasCeil sets the gaslimit to strive for when mining blocks post 1559.
// For pre-1559 blocks, it sets the ceiling.
func (miner *Miner) SetGasCeil(ceil uint64) {
diff --git a/miner/reserved_registry.go b/miner/reserved_registry.go
deleted file mode 100644
index e6466ece85..0000000000
--- a/miner/reserved_registry.go
+++ /dev/null
@@ -1,121 +0,0 @@
-package miner
-
-import (
- "fmt"
- "sort"
-
- "github.com/ethereum/go-ethereum/common"
-)
-
-// reservedRegistry is the worker's local view of the reserved-blockspace
-// registry. Production wiring leaves the worker's registry nil; tests inject a
-// *MockRegistry via SetReservedRegistry. The contract-backed implementation owned by
-// the registry-module work lands behind the same shape later — a swap at the
-// setter site.
-type reservedRegistry interface {
- // Lookup returns the client ID that owns addr, or (_, false) if addr is
- // not registered. Only active clients are visible, and a sender belongs
- // to at most one client — uniqueness is the implementation's invariant
- // (MockRegistry panics on construction; the registry contract enforces it).
- Lookup(addr common.Address) (clientID uint64, ok bool)
- // Quota returns the per-block reserved-region gas allowance for clientID,
- // measured against declared transaction gas limits (tx.Gas), not actual
- // gas used.
- Quota(clientID uint64) uint64
- // CeilingGas returns the global reserved-region gas cap across all
- // clients (the registry contract's ceilingGas). Zero means uncapped.
- CeilingGas() uint64
- // Clients returns every registered client ID.
- Clients() []uint64
- // Snapshot returns an immutable copy of the registry state effective for
- // the child block of parent. The contract-backed implementation resolves
- // parent state and the effectiveFrom activation delay internally; callers
- // pin one snapshot per build so every pass sees a consistent view.
- Snapshot(parent common.Hash) reservedRegistry
-}
-
-// ReservedRegistry is the exported alias callers use with
-// Miner.SetReservedRegistry.
-type ReservedRegistry = reservedRegistry
-
-// ReservedClient describes one reserved-blockspace client for NewMockRegistry. Each whitelisted
-// sender belongs to exactly one client.
-type ReservedClient struct {
- ID uint64
- Senders []common.Address
- QuotaGas uint64
-}
-
-// MockRegistry is a throwaway in-memory reservedRegistry for tests and as a placeholder
-// until the registry module lands. Immutable after construction; lock-free.
-type MockRegistry struct {
- addrToClient map[common.Address]uint64
- clientToQuota map[uint64]uint64
- clientIDs []uint64
- ceiling uint64
-}
-
-// NewMockRegistry builds a MockRegistry. Panics on a duplicate client ID or a sender claimed by
-// two clients — both are test-setup bugs, not runtime conditions.
-func NewMockRegistry(clients []ReservedClient) *MockRegistry {
- m := &MockRegistry{
- addrToClient: make(map[common.Address]uint64),
- clientToQuota: make(map[uint64]uint64),
- clientIDs: make([]uint64, 0, len(clients)),
- }
- for _, c := range clients {
- if _, dup := m.clientToQuota[c.ID]; dup {
- panic(fmt.Sprintf("miner.NewMockRegistry: duplicate client ID %d", c.ID))
- }
- m.clientToQuota[c.ID] = c.QuotaGas
- m.clientIDs = append(m.clientIDs, c.ID)
- for _, addr := range c.Senders {
- if existing, ok := m.addrToClient[addr]; ok {
- panic(fmt.Sprintf("miner.NewMockRegistry: address %s belongs to clients %d and %d", addr.Hex(), existing, c.ID))
- }
- m.addrToClient[addr] = c.ID
- }
- }
- sort.Slice(m.clientIDs, func(i, j int) bool { return m.clientIDs[i] < m.clientIDs[j] })
- return m
-}
-
-// WithCeiling sets the global reserved-region gas cap (zero = uncapped) and
-// returns m for construction chaining. Set before handing the MockRegistry to a
-// worker; MockRegistry is treated as immutable once in use.
-func (m *MockRegistry) WithCeiling(gas uint64) *MockRegistry {
- m.ceiling = gas
- return m
-}
-
-func (m *MockRegistry) Lookup(addr common.Address) (uint64, bool) {
- cid, ok := m.addrToClient[addr]
- return cid, ok
-}
-
-func (m *MockRegistry) Quota(clientID uint64) uint64 { return m.clientToQuota[clientID] }
-
-func (m *MockRegistry) CeilingGas() uint64 { return m.ceiling }
-
-func (m *MockRegistry) Clients() []uint64 {
- out := make([]uint64, len(m.clientIDs))
- copy(out, m.clientIDs)
- return out
-}
-
-func (m *MockRegistry) Snapshot(common.Hash) reservedRegistry {
- // The MockRegistry has no chain state to resolve, so the parent hash is unused;
- // the copy below just guarantees snapshot isolation.
- snapshot := *m
- snapshot.addrToClient = make(map[common.Address]uint64, len(m.addrToClient))
- for k, v := range m.addrToClient {
- snapshot.addrToClient[k] = v
- }
- snapshot.clientToQuota = make(map[uint64]uint64, len(m.clientToQuota))
- for k, v := range m.clientToQuota {
- snapshot.clientToQuota[k] = v
- }
- snapshot.clientIDs = make([]uint64, len(m.clientIDs))
- copy(snapshot.clientIDs, m.clientIDs)
- return &snapshot
-}
diff --git a/miner/reserved_test.go b/miner/reserved_test.go
index 3a0fb2c8d3..84b3e8d151 100644
--- a/miner/reserved_test.go
+++ b/miner/reserved_test.go
@@ -10,6 +10,7 @@ import (
"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"
"github.com/ethereum/go-ethereum/core/rawdb"
"github.com/ethereum/go-ethereum/core/txpool"
@@ -18,9 +19,24 @@ import (
"github.com/ethereum/go-ethereum/params"
)
-// Compile-time guard: MockRegistry must satisfy the exported registry interface so the
-// Miner.SetReservedRegistry wiring can't silently break.
-var _ ReservedRegistry = (*MockRegistry)(nil)
+// testClient describes one reserved client for newTestSnapshot.
+type testClient struct {
+ ID uint64
+ Senders []common.Address
+ QuotaGas uint64
+}
+
+// newTestSnapshot builds a registry snapshot for sequencing tests. capacity 0
+// means uncapped (effectiveCeilingGas normalizes it to MaxUint64).
+func newTestSnapshot(capacity uint64, clients []testClient) *registryreader.Snapshot {
+ m := make(map[common.Address]registryreader.Client)
+ for _, c := range clients {
+ for _, a := range c.Senders {
+ m[a] = registryreader.Client{ID: c.ID, GasQuota: c.QuotaGas}
+ }
+ }
+ return registryreader.NewSnapshot(common.Hash{}, capacity, m)
+}
func TestOrderClients(t *testing.T) {
t.Parallel()
@@ -63,64 +79,6 @@ func TestOrderClients(t *testing.T) {
"different parent hashes should reorder >2 clients")
}
-func TestNewMockRegistry(t *testing.T) {
- t.Parallel()
-
- a := common.HexToAddress("0xAa")
- b := common.HexToAddress("0xBb")
- c := common.HexToAddress("0xCc")
-
- m := NewMockRegistry([]ReservedClient{
- {ID: 7, Senders: []common.Address{a, b}, QuotaGas: 10_000_000},
- {ID: 3, Senders: []common.Address{c}, QuotaGas: 5_000_000},
- })
-
- cid, ok := m.Lookup(a)
- require.True(t, ok)
- require.Equal(t, uint64(7), cid)
-
- _, ok = m.Lookup(common.HexToAddress("0xDd"))
- require.False(t, ok)
-
- require.Equal(t, uint64(10_000_000), m.Quota(7))
- require.Equal(t, uint64(0), m.Quota(99))
- require.Equal(t, []uint64{3, 7}, m.Clients(), "Clients sorted")
-}
-
-func TestNewMockRegistry_PanicsOnDuplicateSender(t *testing.T) {
- t.Parallel()
- defer func() { require.NotNil(t, recover(), "expected panic on duplicate sender") }()
-
- addr := common.HexToAddress("0x01")
- _ = NewMockRegistry([]ReservedClient{
- {ID: 1, Senders: []common.Address{addr}, QuotaGas: 1},
- {ID: 2, Senders: []common.Address{addr}, QuotaGas: 1},
- })
-}
-
-// TestMockRegistrySnapshot verifies Snapshot returns an independent deep copy: mutating
-// the original after snapshotting must not be observable through the snapshot.
-func TestMockRegistrySnapshot(t *testing.T) {
- t.Parallel()
-
- a := common.HexToAddress("0x0a")
- m := NewMockRegistry([]ReservedClient{{ID: 1, Senders: []common.Address{a}, QuotaGas: 100}}).WithCeiling(500)
- snap := m.Snapshot(common.Hash{})
-
- // Mutate the original's internal maps; the snapshot must be unaffected.
- m.addrToClient[a] = 999
- m.clientToQuota[1] = 0
- m.clientIDs[0] = 999
- m.ceiling = 0
-
- cid, ok := snap.Lookup(a)
- require.True(t, ok)
- require.Equal(t, uint64(1), cid)
- require.Equal(t, uint64(100), snap.Quota(1))
- require.Equal(t, []uint64{1}, snap.Clients())
- require.Equal(t, uint64(500), snap.CeilingGas())
-}
-
func TestFilterReservedTxs(t *testing.T) {
t.Parallel()
@@ -131,20 +89,20 @@ func TestFilterReservedTxs(t *testing.T) {
cases := []struct {
name string
- registry *MockRegistry
+ registry *registryreader.Snapshot
input map[common.Address][]*txpool.LazyTransaction
wantClients map[uint64][]common.Address
wantRemaining []common.Address
}{
{
name: "no reserved senders present",
- registry: NewMockRegistry([]ReservedClient{{ID: 1, Senders: []common.Address{common.HexToAddress("0x99")}, QuotaGas: 1}}),
+ registry: newTestSnapshot(0, []testClient{{ID: 1, Senders: []common.Address{common.HexToAddress("0x99")}, QuotaGas: 1}}),
input: map[common.Address][]*txpool.LazyTransaction{a: stubTxs(1)},
wantRemaining: []common.Address{a},
},
{
name: "filters reserved senders out",
- registry: NewMockRegistry([]ReservedClient{
+ registry: newTestSnapshot(0, []testClient{
{ID: 7, Senders: []common.Address{a, b}, QuotaGas: 1_000_000},
{ID: 3, Senders: []common.Address{c}, QuotaGas: 500_000},
}),
@@ -371,13 +329,13 @@ func TestExtractReservedTxs(t *testing.T) {
t.Run("empty registry is a no-op", func(t *testing.T) {
t.Parallel()
pending := map[common.Address][]*txpool.LazyTransaction{a: {gasTx(100)}}
- require.Nil(t, extractReservedTxs(NewMockRegistry(nil), common.Hash{}, pending, fn))
+ require.Nil(t, extractReservedTxs(newTestSnapshot(0, nil), common.Hash{}, pending, fn))
require.Contains(t, pending, a, "pending untouched")
})
t.Run("client within quota; normal sender untouched", func(t *testing.T) {
t.Parallel()
- registry := NewMockRegistry([]ReservedClient{{ID: 1, Senders: []common.Address{a}, QuotaGas: 1_000_000}})
+ registry := newTestSnapshot(0, []testClient{{ID: 1, Senders: []common.Address{a}, QuotaGas: 1_000_000}})
pending := map[common.Address][]*txpool.LazyTransaction{a: {gasTx(100), gasTx(100)}, n: {gasTx(100)}}
groups := extractReservedTxs(registry, common.Hash{}, pending, fn)
@@ -390,7 +348,7 @@ func TestExtractReservedTxs(t *testing.T) {
t.Run("quota overflow re-added to pending", func(t *testing.T) {
t.Parallel()
- registry := NewMockRegistry([]ReservedClient{{ID: 1, Senders: []common.Address{a}, QuotaGas: 100}})
+ registry := newTestSnapshot(0, []testClient{{ID: 1, Senders: []common.Address{a}, QuotaGas: 100}})
pending := map[common.Address][]*txpool.LazyTransaction{a: {gasTx(100), gasTx(100)}}
groups := extractReservedTxs(registry, common.Hash{}, pending, fn)
@@ -420,10 +378,10 @@ func TestExtractReservedTxs_Ceiling(t *testing.T) {
t.Run("ceiling caps the second client in visit order", func(t *testing.T) {
t.Parallel()
- registry := NewMockRegistry([]ReservedClient{
+ registry := newTestSnapshot(800, []testClient{
{ID: 1, Senders: []common.Address{a}, QuotaGas: 600},
{ID: 2, Senders: []common.Address{b}, QuotaGas: 600},
- }).WithCeiling(800)
+ })
pending := newPending()
// The first-visited client fits its full 600; the second is left
@@ -439,7 +397,7 @@ func TestExtractReservedTxs_Ceiling(t *testing.T) {
t.Run("zero ceiling means uncapped", func(t *testing.T) {
t.Parallel()
- registry := NewMockRegistry([]ReservedClient{
+ registry := newTestSnapshot(0, []testClient{
{ID: 1, Senders: []common.Address{a}, QuotaGas: 600},
{ID: 2, Senders: []common.Address{b}, QuotaGas: 600},
})
@@ -492,10 +450,10 @@ func TestSequenceTxs(t *testing.T) {
t.Run("priority, reserved and normal groups in order", func(t *testing.T) {
t.Parallel()
- w := &worker{prio: []common.Address{p}, reservedRegistry: NewMockRegistry([]ReservedClient{{ID: 1, Senders: []common.Address{r}, QuotaGas: 1_000_000}})}
+ w := &worker{prio: []common.Address{p}, reservedSnapshotOverride: newTestSnapshot(0, []testClient{{ID: 1, Senders: []common.Address{r}, QuotaGas: 1_000_000}})}
pending := map[common.Address][]*txpool.LazyTransaction{p: {gasTx(100)}, r: {gasTx(100)}, n: {gasTx(100)}}
- seq := w.sequenceTxs(newEnv(), w.reservedRegistrySnapshot(common.Hash{}), pending)
+ seq := w.sequenceTxs(newEnv(), w.reservedSnapshotOverride, pending)
require.Len(t, seq, 3)
require.Equal(t, map[common.Address]int{p: 1}, drainBySender(seq[0]), "priority group first")
require.Equal(t, map[common.Address]int{r: 1}, drainBySender(seq[1]), "reserved group next")
@@ -507,24 +465,24 @@ func TestSequenceTxs(t *testing.T) {
w := &worker{}
pending := map[common.Address][]*txpool.LazyTransaction{n: {gasTx(100)}}
- seq := w.sequenceTxs(newEnv(), w.reservedRegistrySnapshot(common.Hash{}), pending)
+ seq := w.sequenceTxs(newEnv(), w.reservedSnapshotOverride, pending)
require.Len(t, seq, 1)
require.Equal(t, map[common.Address]int{n: 1}, drainBySender(seq[0]))
})
t.Run("empty pending yields no groups", func(t *testing.T) {
t.Parallel()
- w := &worker{prio: []common.Address{p}, reservedRegistry: NewMockRegistry([]ReservedClient{{ID: 1, Senders: []common.Address{r}, QuotaGas: 100}})}
- seq := w.sequenceTxs(newEnv(), w.reservedRegistrySnapshot(common.Hash{}), map[common.Address][]*txpool.LazyTransaction{})
+ w := &worker{prio: []common.Address{p}, reservedSnapshotOverride: newTestSnapshot(0, []testClient{{ID: 1, Senders: []common.Address{r}, QuotaGas: 100}})}
+ seq := w.sequenceTxs(newEnv(), w.reservedSnapshotOverride, map[common.Address][]*txpool.LazyTransaction{})
require.Empty(t, seq)
})
t.Run("reserved overflow joins the normal group", func(t *testing.T) {
t.Parallel()
- w := &worker{reservedRegistry: NewMockRegistry([]ReservedClient{{ID: 1, Senders: []common.Address{r}, QuotaGas: 100}})}
+ w := &worker{reservedSnapshotOverride: newTestSnapshot(0, []testClient{{ID: 1, Senders: []common.Address{r}, QuotaGas: 100}})}
pending := map[common.Address][]*txpool.LazyTransaction{r: {gasTx(100), gasTx(100)}, n: {gasTx(100)}}
- seq := w.sequenceTxs(newEnv(), w.reservedRegistrySnapshot(common.Hash{}), pending)
+ seq := w.sequenceTxs(newEnv(), w.reservedSnapshotOverride, pending)
require.Len(t, seq, 2)
require.Equal(t, map[common.Address]int{r: 1}, drainBySender(seq[0]), "reserved group: one tx fits quota")
require.Equal(t, map[common.Address]int{n: 1, r: 1}, drainBySender(seq[1]), "normal group: normal sender + reserved overflow")
@@ -534,7 +492,7 @@ func TestSequenceTxs(t *testing.T) {
t.Parallel()
r2 := common.HexToAddress("0x0d")
senderOf := map[uint64]common.Address{1: r, 2: r2}
- w := &worker{reservedRegistry: NewMockRegistry([]ReservedClient{
+ w := &worker{reservedSnapshotOverride: newTestSnapshot(0, []testClient{
{ID: 1, Senders: []common.Address{r}, QuotaGas: 1_000_000},
{ID: 2, Senders: []common.Address{r2}, QuotaGas: 1_000_000},
})}
@@ -543,7 +501,7 @@ func TestSequenceTxs(t *testing.T) {
env := newEnv()
order := orderClients(env.header.ParentHash, []uint64{1, 2})
- seq := w.sequenceTxs(env, w.reservedRegistrySnapshot(env.header.ParentHash), pending)
+ seq := w.sequenceTxs(env, w.reservedSnapshotOverride, pending)
require.Len(t, seq, 3)
require.Equal(t, map[common.Address]int{senderOf[order[0]]: 1}, drainBySender(seq[0]), "first client in visit order")
require.Equal(t, map[common.Address]int{senderOf[order[1]]: 1}, drainBySender(seq[1]), "second client in visit order")
@@ -556,7 +514,7 @@ func TestSequenceTxs(t *testing.T) {
// the normal pass, where standard EIP-1559 admission drops it
// (GasFeeCap < BaseFee) — per spec it stays in the pool for a later
// block instead of entering this one.
- w := &worker{reservedRegistry: NewMockRegistry([]ReservedClient{{ID: 1, Senders: []common.Address{r}, QuotaGas: 100}})}
+ w := &worker{reservedSnapshotOverride: newTestSnapshot(0, []testClient{{ID: 1, Senders: []common.Address{r}, QuotaGas: 100}})}
pending := map[common.Address][]*txpool.LazyTransaction{
r: {feeGasTx(0, 0, 100), feeGasTx(0, 0, 100)},
n: {feeGasTx(200, 100, 100)},
@@ -565,7 +523,7 @@ func TestSequenceTxs(t *testing.T) {
env := newEnv()
env.header.BaseFee = big.NewInt(100)
- seq := w.sequenceTxs(env, w.reservedRegistrySnapshot(env.header.ParentHash), pending)
+ seq := w.sequenceTxs(env, w.reservedSnapshotOverride, pending)
require.Len(t, seq, 2)
require.Equal(t, map[common.Address]int{r: 1}, drainBySender(seq[0]), "reserved group: one zero-fee tx within quota")
require.Equal(t, map[common.Address]int{n: 1}, drainBySender(seq[1]), "normal group: zero-fee overflow not admitted")
@@ -577,10 +535,10 @@ func TestSequenceTxs(t *testing.T) {
// first, so a prioritized registered sender bypasses reserved quota
// accounting and pays normal fees. Operators should not prioritize
// registered senders.
- w := &worker{prio: []common.Address{r}, reservedRegistry: NewMockRegistry([]ReservedClient{{ID: 1, Senders: []common.Address{r}, QuotaGas: 100}})}
+ w := &worker{prio: []common.Address{r}, reservedSnapshotOverride: newTestSnapshot(0, []testClient{{ID: 1, Senders: []common.Address{r}, QuotaGas: 100}})}
pending := map[common.Address][]*txpool.LazyTransaction{r: {gasTx(100), gasTx(100)}}
- seq := w.sequenceTxs(newEnv(), w.reservedRegistrySnapshot(common.Hash{}), pending)
+ seq := w.sequenceTxs(newEnv(), w.reservedSnapshotOverride, pending)
require.Len(t, seq, 1, "single priority group; no reserved group")
require.False(t, seq[0].reserved, "priority group uses normal ordering")
require.Equal(t, map[common.Address]int{r: 2}, drainBySender(seq[0]), "both txs taken despite quota of 100")
@@ -598,7 +556,7 @@ func TestReservedBuild_NilRegistry(t *testing.T) {
w, b, _ := newTestWorker(t, DefaultTestConfig(), &chainConfig, engine, rawdb.NewMemoryDatabase(), false, 0)
defer w.close()
- require.Nil(t, w.reservedRegistry, "registry defaults to nil")
+ require.Nil(t, w.reservedSnapshotOverride, "snapshot override defaults to nil")
sub := w.mux.Subscribe(core.NewMinedBlockEvent{})
defer sub.Unsubscribe()
@@ -624,7 +582,7 @@ func TestReservedBuild_HappyPath(t *testing.T) {
w, b, _ := newTestWorker(t, DefaultTestConfig(), &chainConfig, engine, rawdb.NewMemoryDatabase(), false, 0)
defer w.close()
- w.setReservedRegistry(NewMockRegistry([]ReservedClient{
+ w.setReservedSnapshot(newTestSnapshot(0, []testClient{
{ID: 1, Senders: []common.Address{testBankAddress}, QuotaGas: 10_000_000},
}))
@@ -661,12 +619,23 @@ func borUnittestCancunConfig() params.ChainConfig {
return chainConfig
}
+// borUnittestReservedConfig returns a Cancun-enabled unittest config with the
+// ReservedBlockspace fork active from genesis. The BorConfig is deep-copied so
+// scheduling the fork can't leak into the shared BorUnittestChainConfig.
+func borUnittestReservedConfig() params.ChainConfig {
+ chainConfig := borUnittestCancunConfig()
+ borCopy := *chainConfig.Bor
+ borCopy.ReservedBlockspaceBlock = big.NewInt(0)
+ chainConfig.Bor = &borCopy
+ return chainConfig
+}
+
// TestReservedBuild_HeaderGasUsed confirms the producer records the reserved
// pass's actual gas total in BlockExtraData.ReservedGasUsed. With every block
// transaction committed through the reserved group, the total must equal the
// header's GasUsed.
func TestReservedBuild_HeaderGasUsed(t *testing.T) {
- chainConfig := borUnittestCancunConfig()
+ chainConfig := borUnittestReservedConfig()
engine, ctrl := getFakeBorFromConfig(t, &chainConfig)
defer engine.Close()
defer ctrl.Finish()
@@ -674,7 +643,7 @@ func TestReservedBuild_HeaderGasUsed(t *testing.T) {
w, b, _ := newTestWorker(t, DefaultTestConfig(), &chainConfig, engine, rawdb.NewMemoryDatabase(), false, 0)
defer w.close()
- w.setReservedRegistry(NewMockRegistry([]ReservedClient{
+ w.setReservedSnapshot(newTestSnapshot(0, []testClient{
{ID: 1, Senders: []common.Address{testBankAddress}, QuotaGas: 10_000_000},
}))
@@ -697,11 +666,11 @@ func TestReservedBuild_HeaderGasUsed(t *testing.T) {
require.Equal(t, got.GasUsed(), *reserved, "all txs are reserved, so reserved gas equals block gas used")
}
-// TestReservedBuild_HeaderAbsentWithoutRegistry pins wire compatibility: with no
-// registry wired (production default), post-Cancun blocks must not carry the
+// 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
// pre-reserved-blockspace blocks.
-func TestReservedBuild_HeaderAbsentWithoutRegistry(t *testing.T) {
+func TestReservedBuild_HeaderAbsentPreFork(t *testing.T) {
chainConfig := borUnittestCancunConfig()
engine, ctrl := getFakeBorFromConfig(t, &chainConfig)
defer engine.Close()
@@ -710,7 +679,7 @@ func TestReservedBuild_HeaderAbsentWithoutRegistry(t *testing.T) {
w, b, _ := newTestWorker(t, DefaultTestConfig(), &chainConfig, engine, rawdb.NewMemoryDatabase(), false, 0)
defer w.close()
- require.Nil(t, w.reservedRegistry, "registry defaults to nil")
+ require.Nil(t, w.reservedSnapshotOverride, "snapshot override defaults to nil")
sub := w.mux.Subscribe(core.NewMinedBlockEvent{})
defer sub.Unsubscribe()
@@ -719,7 +688,7 @@ func TestReservedBuild_HeaderAbsentWithoutRegistry(t *testing.T) {
require.NoError(t, b.txPool.Add([]*types.Transaction{b.newRandomTxWithNonce(false, 0)}, false)[0])
got := waitForBlockWithTxs(t, sub, 1, 15*time.Second)
- require.Nil(t, got.Header().GetReservedGasUsed(&chainConfig), "no registry: field must be absent from the wire")
+ require.Nil(t, got.Header().GetReservedGasUsed(&chainConfig), "pre-fork: field must be absent from the wire")
}
// TestReservedBuild_Positional proves the reserved-first builder preference
@@ -735,7 +704,7 @@ func TestReservedBuild_Positional(t *testing.T) {
w, b, _ := newTestWorker(t, DefaultTestConfig(), &chainConfig, engine, rawdb.NewMemoryDatabase(), false, 0)
defer w.close()
- w.setReservedRegistry(NewMockRegistry([]ReservedClient{
+ w.setReservedSnapshot(newTestSnapshot(0, []testClient{
{ID: 1, Senders: []common.Address{testUserAddress}, QuotaGas: 10_000_000},
}))
@@ -801,7 +770,7 @@ func TestReservedBuild_Overflow(t *testing.T) {
defer w.close()
// Quota fits exactly one value-transfer (gas limit params.TxGas = 21000).
- w.setReservedRegistry(NewMockRegistry([]ReservedClient{
+ w.setReservedSnapshot(newTestSnapshot(0, []testClient{
{ID: 1, Senders: []common.Address{testBankAddress}, QuotaGas: params.TxGas},
}))
diff --git a/miner/sequencing.go b/miner/sequencing.go
index 7676cfff22..5b753e8d44 100644
--- a/miner/sequencing.go
+++ b/miner/sequencing.go
@@ -8,6 +8,7 @@ import (
"time"
"github.com/ethereum/go-ethereum/common"
+ "github.com/ethereum/go-ethereum/consensus/bor/registryreader"
"github.com/ethereum/go-ethereum/core/txpool"
"github.com/ethereum/go-ethereum/crypto"
"github.com/ethereum/go-ethereum/metrics"
@@ -42,10 +43,10 @@ func extractPriorityTxs(prio []common.Address, pendingTxs map[common.Address][]*
// filterReservedTxs removes the transactions from reserved-eligible senders from
// pending transactions and returns them grouped by client ID.
-func filterReservedTxs(pendingTxs map[common.Address][]*txpool.LazyTransaction, registry reservedRegistry) map[uint64]map[common.Address][]*txpool.LazyTransaction {
+func filterReservedTxs(pendingTxs map[common.Address][]*txpool.LazyTransaction, snap *registryreader.Snapshot) map[uint64]map[common.Address][]*txpool.LazyTransaction {
var reserved = make(map[uint64]map[common.Address][]*txpool.LazyTransaction)
for addr, txs := range pendingTxs {
- cid, ok := registry.Lookup(addr)
+ cid, ok := snap.Lookup(addr)
if !ok {
continue
}
@@ -139,10 +140,13 @@ func selectReservedTxs(
return selected, used, overflow
}
-// effectiveCeilingGas returns the registry's global reserved-region cap, normalizing
-// zero (uncapped) to MaxUint64 so the selection loop needs no special case.
-func effectiveCeilingGas(registry reservedRegistry) uint64 {
- if c := registry.CeilingGas(); c != 0 {
+// effectiveCeilingGas returns the snapshot's reserved capacity (Σ effective
+// client quotas) as a global cap on the reserved pass, normalizing zero
+// (no clients) to MaxUint64 so the selection loop needs no special case.
+// Defense-in-depth: per-client quotas already bound the total, and the
+// registry contract enforces Σ quotas ≤ its ceiling at write time.
+func effectiveCeilingGas(snap *registryreader.Snapshot) uint64 {
+ if c := snap.Capacity(); c != 0 {
return c
}
return math.MaxUint64
@@ -150,15 +154,15 @@ func effectiveCeilingGas(registry reservedRegistry) uint64 {
// extractReservedTxs pulls reserved-eligible transactions out of pendingTxs and
// returns one quota-trimmed, ordered group per client, in the deterministic
-// parent-hash-keyed client order. registry is a per-build snapshot taken by the
-// caller; a nil registry (production default until the registry module lands)
-// or one with no clients is a no-op. Quota overflow — per-client and global
-// ceiling alike — is re-added to pendingTxs for the normal pass.
-func extractReservedTxs(registry reservedRegistry, parentHash common.Hash, pendingTxs map[common.Address][]*txpool.LazyTransaction, newReservedTxSet transactionsByPriceAndNonceFn) []*transactionsByPriceAndNonce {
- if registry == nil || len(registry.Clients()) == 0 {
+// parent-hash-keyed client order. snap is the caller's per-build registry
+// snapshot; nil (pre-fork, or no registry configured) or one with no clients
+// is a no-op. Quota overflow — per-client and capacity alike — is re-added to
+// pendingTxs for the normal pass.
+func extractReservedTxs(snap *registryreader.Snapshot, parentHash common.Hash, pendingTxs map[common.Address][]*txpool.LazyTransaction, newReservedTxSet transactionsByPriceAndNonceFn) []*transactionsByPriceAndNonce {
+ if snap == nil || len(snap.Clients()) == 0 {
return nil
}
- reservedTxs := filterReservedTxs(pendingTxs, registry)
+ reservedTxs := filterReservedTxs(pendingTxs, snap)
if len(reservedTxs) == 0 {
return nil
}
@@ -166,9 +170,9 @@ func extractReservedTxs(registry reservedRegistry, parentHash common.Hash, pendi
// The global ceiling bounds the summed declared gas selected across all
// clients. Like per-client quota, it is charged against declared gas
// limits, not actual gas used.
- ceilingLeft := effectiveCeilingGas(registry)
+ ceilingLeft := effectiveCeilingGas(snap)
- clientOrder := orderClients(parentHash, registry.Clients())
+ clientOrder := orderClients(parentHash, snap.Clients())
var clientGroups = make([]*transactionsByPriceAndNonce, 0, len(clientOrder))
for _, cid := range clientOrder {
clientTxs := reservedTxs[cid]
@@ -176,7 +180,7 @@ func extractReservedTxs(registry reservedRegistry, parentHash common.Hash, pendi
continue
}
- selected, used, overflow := selectReservedTxs(clientTxs, min(registry.Quota(cid), ceilingLeft), newReservedTxSet)
+ selected, used, overflow := selectReservedTxs(clientTxs, min(snap.Quota(cid), ceilingLeft), newReservedTxSet)
ceilingLeft -= used
for addr, txs := range overflow {
pendingTxs[addr] = append(pendingTxs[addr], txs...)
@@ -196,12 +200,12 @@ func extractReservedTxs(registry reservedRegistry, parentHash common.Hash, pendi
// client (deterministic, parent-hash-keyed order), then the remaining normal
// transactions. Each group is an independently-ordered transactionsByPriceAndNonce
// the caller commits in turn. Empty groups are omitted so the caller never spins
-// up a commit pass for nothing. registry is the caller's per-build snapshot
-// (see worker.reservedRegistrySnapshot); nil disables the reserved pass.
+// up a commit pass for nothing. snap is the caller's per-build registry
+// snapshot (see worker.sequencingSnapshot); nil disables the reserved pass.
//
// There is no error path — sequencing only groups and orders an in-memory
// snapshot; interruption is surfaced later, by commitTransactions.
-func (w *worker) sequenceTxs(env *environment, registry reservedRegistry, pendingTxs map[common.Address][]*txpool.LazyTransaction) []*transactionsByPriceAndNonce {
+func (w *worker) sequenceTxs(env *environment, snap *registryreader.Snapshot, pendingTxs map[common.Address][]*txpool.LazyTransaction) []*transactionsByPriceAndNonce {
w.mu.RLock()
prio := w.prio
w.mu.RUnlock()
@@ -226,7 +230,7 @@ func (w *worker) sequenceTxs(env *environment, registry reservedRegistry, pendin
// Reserved transactions, one ordered group per client. No emptiness check
// here, unlike the neighbouring groups: extractReservedTxs already omits
// empty groups from the slice it returns, so every element is committable.
- txBatches = append(txBatches, extractReservedTxs(registry, env.header.ParentHash, pendingTxs, newReservedTxSet)...)
+ txBatches = append(txBatches, extractReservedTxs(snap, env.header.ParentHash, pendingTxs, newReservedTxSet)...)
// Everything left (including reserved quota overflow added back above) is
// normal. Heap-init time is recorded for the normal batch only, keeping
diff --git a/miner/worker.go b/miner/worker.go
index 95cbeb0b5e..b226fb723f 100644
--- a/miner/worker.go
+++ b/miner/worker.go
@@ -33,6 +33,7 @@ import (
"github.com/ethereum/go-ethereum/common/tracing"
"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/consensus/misc/eip4844"
"github.com/ethereum/go-ethereum/core"
@@ -414,10 +415,10 @@ type worker struct {
prio []common.Address // A list of senders to prioritize
- // reservedRegistry is the source of truth for reserved-blockspace clients
- // and their per-block gas quotas. nil disables the reserved pass — block
- // building is byte-identical to before reserved-blockspace existed.
- reservedRegistry reservedRegistry
+ // reservedSnapshotOverride replaces the env's registry snapshot during
+ // block building. Test seam only — production sequencing reads the
+ // snapshot makeEnv pins on the EVM block context.
+ reservedSnapshotOverride *registryreader.Snapshot
// Feeds
pendingLogsFeed event.Feed
@@ -683,12 +684,13 @@ func (w *worker) getCurrent() *environment {
return w.current
}
-// setReservedRegistry installs the reserved-blockspace registry the worker
-// consults during block building. A nil registry disables the reserved pass.
-func (w *worker) setReservedRegistry(r reservedRegistry) {
+// setReservedSnapshot installs a fixed reserved-registry snapshot the
+// sequencing pass classifies against, bypassing the env's snapshot. Test seam
+// only.
+func (w *worker) setReservedSnapshot(snap *registryreader.Snapshot) {
w.mu.Lock()
defer w.mu.Unlock()
- w.reservedRegistry = r
+ w.reservedSnapshotOverride = snap
}
// setRecommitInterval updates the interval for miner sealing work recommitting.
@@ -1320,6 +1322,11 @@ func (w *worker) makeEnv(header *types.Header, coinbase common.Address, witness
state.StartPrefetcher("miner", nil, nil)
}
+ // Build the block context once and attach the parent-state reserved snapshot
+ // so the producer classifies reserved senders the same way verifiers do.
+ blockCtx := core.NewEVMBlockContext(header, w.chain, &coinbase)
+ blockCtx.ReservedSnapshot = core.ReservedSnapshotForBlock(w.chain, state, header)
+
// Note the passed coinbase may be different with header.Coinbase.
env := &environment{
signer: types.MakeSigner(w.chainConfig, header.Number, header.Time),
@@ -1328,7 +1335,7 @@ func (w *worker) makeEnv(header *types.Header, coinbase common.Address, witness
coinbase: coinbase,
header: header,
witness: state.Witness(),
- evm: vm.NewEVM(core.NewEVMBlockContext(header, w.chain, &coinbase), state, w.chainConfig, w.vmConfig()),
+ evm: vm.NewEVM(blockCtx, state, w.chainConfig, w.vmConfig()),
prefetchReader: genParams.prefetchReader,
processReader: genParams.processReader,
prefetchedTxHashes: genParams.prefetchedTxHashes,
@@ -2132,15 +2139,16 @@ func (w *worker) fillTransactions(interrupt *atomic.Int32, env *environment, gen
env.pendingDuration = time.Since(pendingStart)
pendingTimer.Update(env.pendingDuration)
- // Pin one registry snapshot for the whole build so client order, quotas
- // and membership stay consistent across groups. A future multi-pass
- // (mini-block) builder must take the snapshot once per block, outside its
- // pass loop.
- registry := w.reservedRegistrySnapshot(env.header.ParentHash)
+ // One registry snapshot serves the whole build — the same parent-state
+ // snapshot makeEnv pinned for execution — so client order, quotas and
+ // membership stay consistent across batches and match what the executor
+ // classifies. A future multi-pass (mini-block) builder inherits this for
+ // free: the snapshot lives on the env, outside any pass loop.
+ snap := w.sequencingSnapshot(env)
// Order transactions based on priority into 3 buckets - priority, reserved
// and normal transactions.
- txBatches := w.sequenceTxs(env, registry, pendingTxs)
+ txBatches := w.sequenceTxs(env, snap, pendingTxs)
// Shared channels used during builder mode. Both are nil when there is no prefetcher.
var builderPlanCh chan<- *types.Transaction
@@ -2167,7 +2175,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, registry); err != nil {
+ if err := w.writeReservedGasUsed(env); err != nil {
return err
}
@@ -2184,32 +2192,33 @@ func remainingGas(env *environment) uint64 {
return env.gasPool.Gas()
}
-// reservedRegistrySnapshot pins one registry snapshot for a build, keyed by
-// the parent block. Returns nil when no registry is wired — the production
-// default until the registry module lands — which disables the reserved pass
-// and the header write.
-func (w *worker) reservedRegistrySnapshot(parent common.Hash) reservedRegistry {
+// sequencingSnapshot returns the reserved-registry snapshot the sequencing
+// pass classifies against: the one makeEnv pinned on the EVM block context,
+// i.e. the same snapshot execution classifies with, so the sequencer and the
+// executor can never disagree within one build. Nil (pre-fork, or no registry
+// configured) disables the reserved pass.
+func (w *worker) sequencingSnapshot(env *environment) *registryreader.Snapshot {
w.mu.RLock()
- registry := w.reservedRegistry
+ override := w.reservedSnapshotOverride
w.mu.RUnlock()
- if registry == nil {
+ if override != nil {
+ return override
+ }
+ if env.evm == nil {
return nil
}
- return registry.Snapshot(parent)
+ return env.evm.Context.ReservedSnapshot
}
// writeReservedGasUsed records the build's reserved-region gas total in the
-// header's BlockExtraData. The field is consensus-visible, so it is written
-// only while the reserved pass is active (registry wired); an explicit zero is
-// still written then, marking "reserved active, none used" on the wire.
-//
-// TODO(reserved-blockspace): gate on the reserved hardfork instead of the
-// registry handle once the fork is defined (follow setGiuglianoExtraFields).
-func (w *worker) writeReservedGasUsed(env *environment, registry reservedRegistry) error {
- // BlockExtraData is only RLP-encoded into Header.Extra post-Cancun.
- if registry == nil || !w.chainConfig.IsCancun(env.header.Number) {
+// 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 {
+ if w.chainConfig.Bor == nil || !w.chainConfig.Bor.IsReservedBlockspace(env.header.Number) {
return nil
}
diff --git a/params/config.go b/params/config.go
index 5961b417ac..f0dccc73fe 100644
--- a/params/config.go
+++ b/params/config.go
@@ -191,11 +191,20 @@ var (
BackupMultiplier: map[string]uint64{
"0": 2,
},
- ValidatorContract: "0x0000000000000000000000000000000000001000",
- StateReceiverContract: "0x0000000000000000000000000000000000001001",
+ ValidatorContract: "0x0000000000000000000000000000000000001000",
+ StateReceiverContract: "0x0000000000000000000000000000000000001001",
+ ReservedRegistryContract: DefaultReservedRegistryContract,
BurntContract: map[string]string{
"0": "0x00000000000000000000000000000000000000000",
},
+ BlockAlloc: map[string]interface{}{
+ "0": map[string]interface{}{
+ DefaultReservedRegistryContract: map[string]interface{}{
+ "balance": "0x0",
+ "code": ReservedBlockspaceRegistryCode,
+ },
+ },
+ },
},
ShanghaiBlock: big.NewInt(0),
CancunBlock: big.NewInt(0),
@@ -231,11 +240,20 @@ var (
Coinbase: map[string]string{
"0": "0x000000000000000000000000000000000000ba5e",
},
- ValidatorContract: "0x0000000000000000000000000000000000001000",
- StateReceiverContract: "0x0000000000000000000000000000000000001001",
+ ValidatorContract: "0x0000000000000000000000000000000000001000",
+ StateReceiverContract: "0x0000000000000000000000000000000000001001",
+ ReservedRegistryContract: DefaultReservedRegistryContract,
BurntContract: map[string]string{
"0": "0x00000000000000000000000000000000000000000",
},
+ BlockAlloc: map[string]interface{}{
+ "0": map[string]interface{}{
+ DefaultReservedRegistryContract: map[string]interface{}{
+ "balance": "0x0",
+ "code": ReservedBlockspaceRegistryCode,
+ },
+ },
+ },
},
}
@@ -925,6 +943,7 @@ type BorConfig struct {
BackupMultiplier map[string]uint64 `json:"backupMultiplier"` // Backup multiplier to determine the wiggle time
ValidatorContract string `json:"validatorContract"` // Validator set contract
StateReceiverContract string `json:"stateReceiverContract"` // State receiver contract
+ ReservedRegistryContract string `json:"reservedRegistryContract"` // Reserved blockspace registry contract
OverrideStateSyncRecords map[string]int `json:"overrideStateSyncRecords"` // override state records count
OverrideStateSyncRecordsInRange []BlockRangeOverride `json:"overrideStateSyncRecordsInRange"` // override state records count in a given block range
OverrideValidatorSetInRange []BlockRangeOverrideValidatorSet `json:"overrideValidatorSetInRange"` // override validator set in a given block range
@@ -962,6 +981,21 @@ type BorConfig struct {
GiuglianoBlock *big.Int `json:"giuglianoBlock"` // Giugliano switch block (nil = no fork, 0 = already on giugliano)
ChicagoBlock *big.Int `json:"chicagoBlock"` // Chicago switch block (nil = no fork, 0 = already on chicago)
ValenciaBlock *big.Int `json:"valenciaBlock"` // Valencia switch block (nil = no fork, 0 = already on valencia)
+ 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.
@@ -1045,6 +1079,20 @@ func (c *BorConfig) IsValencia(number *big.Int) bool {
return isBlockForked(c.ValenciaBlock, number)
}
+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.
@@ -1259,6 +1307,9 @@ func (c *ChainConfig) Description() string {
if c.Bor.ValenciaBlock != nil {
banner += fmt.Sprintf(" - Valencia: #%-8v\n", c.Bor.ValenciaBlock)
}
+ if c.Bor.ReservedBlockspaceBlock != nil {
+ banner += fmt.Sprintf(" - ReservedBlockspace: #%-8v\n", c.Bor.ReservedBlockspaceBlock)
+ }
return banner
}
@@ -1604,6 +1655,30 @@ func (c *ChainConfig) CheckConfigForkOrder() error {
}
}
}
+
+ if err := c.checkReservedBlockspaceForkOrder(); err != nil {
+ return err
+ }
+ return nil
+}
+
+// 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.
+func (c *ChainConfig) checkReservedBlockspaceForkOrder() error {
+ if c.Bor == nil || c.Bor.ReservedBlockspaceBlock == nil {
+ return nil
+ }
+ if c.CancunBlock == nil || c.CancunBlock.Cmp(c.Bor.ReservedBlockspaceBlock) > 0 {
+ return fmt.Errorf("unsupported fork ordering: reservedBlockspaceBlock %v must be at or after cancunBlock %v",
+ c.Bor.ReservedBlockspaceBlock, c.CancunBlock)
+ }
+ if c.Bor.ReservedRegistryContract == "" {
+ return errors.New("invalid chain configuration: reservedBlockspaceBlock is scheduled but reservedRegistryContract is unset")
+ }
return nil
}
@@ -1898,6 +1973,7 @@ type Rules struct {
IsLisovo bool
IsLisovoPro bool
IsChicago bool
+ IsReservedBlockspace bool
}
// Rules ensures c's ChainID is not nil.
@@ -1910,29 +1986,30 @@ func (c *ChainConfig) Rules(num *big.Int, isMerge bool, _ uint64) Rules {
isMerge = isMerge && c.IsLondon(num)
isVerkle := isMerge && c.IsVerkle(num)
return Rules{
- ChainID: new(big.Int).Set(chainID),
- IsHomestead: c.IsHomestead(num),
- IsEIP150: c.IsEIP150(num),
- IsEIP155: c.IsEIP155(num),
- IsEIP158: c.IsEIP158(num),
- IsByzantium: c.IsByzantium(num),
- IsConstantinople: c.IsConstantinople(num),
- IsPetersburg: c.IsPetersburg(num),
- IsIstanbul: c.IsIstanbul(num),
- IsBerlin: c.IsBerlin(num),
- IsEIP2929: c.IsBerlin(num) && !isVerkle,
- IsLondon: c.IsLondon(num),
- IsMerge: isMerge,
- IsShanghai: c.IsShanghai(num),
- IsCancun: c.IsCancun(num),
- IsPrague: c.IsPrague(num),
- IsVerkle: c.IsVerkle(num),
- IsOsaka: c.IsOsaka(num),
- IsEIP4762: c.IsVerkle(num),
- IsMadhugiri: c.Bor != nil && c.Bor.IsMadhugiri(num),
- IsMadhugiriPro: c.Bor != nil && c.Bor.IsMadhugiriPro(num),
- IsLisovo: c.Bor != nil && c.Bor.IsLisovo(num),
- IsLisovoPro: c.Bor != nil && c.Bor.IsLisovoPro(num),
- IsChicago: c.Bor != nil && c.Bor.IsChicago(num),
+ ChainID: new(big.Int).Set(chainID),
+ IsHomestead: c.IsHomestead(num),
+ IsEIP150: c.IsEIP150(num),
+ IsEIP155: c.IsEIP155(num),
+ IsEIP158: c.IsEIP158(num),
+ IsByzantium: c.IsByzantium(num),
+ IsConstantinople: c.IsConstantinople(num),
+ IsPetersburg: c.IsPetersburg(num),
+ IsIstanbul: c.IsIstanbul(num),
+ IsBerlin: c.IsBerlin(num),
+ IsEIP2929: c.IsBerlin(num) && !isVerkle,
+ IsLondon: c.IsLondon(num),
+ IsMerge: isMerge,
+ IsShanghai: c.IsShanghai(num),
+ IsCancun: c.IsCancun(num),
+ IsPrague: c.IsPrague(num),
+ IsVerkle: c.IsVerkle(num),
+ IsOsaka: c.IsOsaka(num),
+ IsEIP4762: c.IsVerkle(num),
+ IsMadhugiri: c.Bor != nil && c.Bor.IsMadhugiri(num),
+ IsMadhugiriPro: c.Bor != nil && c.Bor.IsMadhugiriPro(num),
+ IsLisovo: c.Bor != nil && c.Bor.IsLisovo(num),
+ IsLisovoPro: c.Bor != nil && c.Bor.IsLisovoPro(num),
+ IsChicago: c.Bor != nil && c.Bor.IsChicago(num),
+ IsReservedBlockspace: c.Bor != nil && c.Bor.IsReservedBlockspace(num),
}
}
diff --git a/params/reserved_blockspace.go b/params/reserved_blockspace.go
new file mode 100644
index 0000000000..528fd0c2a9
--- /dev/null
+++ b/params/reserved_blockspace.go
@@ -0,0 +1,291 @@
+package params
+
+const (
+ DefaultReservedRegistryContract = "0x0000000000000000000000000000000000001002"
+
+ // ReservedBlockspaceRegistryCode is the deployed runtime bytecode of
+ // registry-contract/src/ReservedBlockspaceRegistry.sol (solc 0.8.33, optimizer 200).
+ // Regenerate with: forge build (in registry-contract/) then re-extract
+ // .deployedBytecode.object. Do not hand-edit.
+ ReservedBlockspaceRegistryCode = "0x" +
+ "608060405234801561000f575f5ffd5b50600436106101bb575f3560e01c806393dc10" +
+ "52116100f3578063e3ab7a2911610093578063ebf0c7171161006e578063ebf0c71714" +
+ "610435578063f2fde38b1461043d578063f339c03c14610450578063feafde83146104" +
+ "63575f5ffd5b8063e3ab7a29146103f7578063e541c71e1461041a578063ea38cd5314" +
+ "610422575f5ffd5b8063bbae06db116100ce578063bbae06db146103a4578063c24286" +
+ "7b146103b7578063d0068f80146103ca578063dd64d24d146103ee575f5ffd5b806393" +
+ "dc105214610367578063989a83661461037a578063b98330e61461038d575f5ffd5b80" +
+ "63469d1e381161015e5780636f78bb36116101395780636f78bb361461030e57806378" +
+ "16e2ed1461032157806388b45de51461032a5780638da5cb5b1461033d575f5ffd5b80" +
+ "63469d1e38146102be57806362ae1e94146102d15780636d028027146102f9575f5ffd" +
+ "5b8063151d6b0c11610199578063151d6b0c1461022957806318d93a34146102835780" +
+ "63386bd8c0146102985780633f34c514146102ab575f5ffd5b80630315818f146101bf" +
+ "5780630ee67c8b146101f55780631159d12f14610208575b5f5ffd5b5f546101d89060" +
+ "0160a01b90046001600160401b031681565b6040516001600160401b03909116815260" +
+ "20015b60405180910390f35b6001546101d8906001600160401b031681565b61021b61" +
+ "0216366004611f9a565b610476565b6040519081526020016101ec565b61023c610237" +
+ "366004612048565b610786565b604080519687526001600160401b0395861660208801" +
+ "526001600160a01b0390941693860193909352901515606085015260ff166080840152" +
+ "1660a082015260c0016101ec565b610296610291366004612061565b610817565b005b" +
+ "6102966102a63660046120a8565b6108ec565b6102966102b93660046120d2565b6109" +
+ "ce565b6102966102cc3660046120fa565b610b54565b61021b6102df36600461204856" +
+ "5b6001600160a01b03165f9081526006602052604090205490565b610301610c27565b" +
+ "6040516101ec9190612177565b61030161031c366004612189565b610da2565b61021b" +
+ "60025481565b6102966103383660046121a0565b610e0e565b5f5461034f9060016001" +
+ "60a01b031681565b6040516001600160a01b0390911681526020016101ec565b600354" +
+ "6101d8906001600160401b031681565b6102966103883660046121d2565b610fc1565b" +
+ "6103956110aa565b6040516101ec93929190612212565b6102966103b23660046122ae" +
+ "565b6112ad565b6102966103c53660046120a8565b611384565b6103dd6103d8366004" +
+ "612189565b6114fd565b6040516101ec9594939291906122cf565b61021b6004548156" +
+ "5b61040a610405366004612048565b6115e2565b60405190151581526020016101ec56" +
+ "5b61021b611624565b6102966104303660046122ae565b611647565b60045461021b56" +
+ "5b61029661044b366004612048565b611796565b61029661045e366004612333565b61" +
+ "1868565b6102966104713660046122ae565b611934565b5f80546001600160a01b0316" +
+ "61049f576040516321c4e35760e21b815260040160405180910390fd5b5f5460016001" +
+ "60a01b031633146104c9576040516330cd747160e01b815260040160405180910390fd" +
+ "5b6001600160a01b0389166104f05760405163d92e233d60e01b815260040160405180" +
+ "910390fd5b600160ff8816111561051557604051632e573a3f60e01b81526004016040" +
+ "5180910390fd5b61051f885f6119de565b60028054905f61052e83612368565b919050" +
+ "5590505f60055f8381526020019081526020015f20905089815f015f6101000a815481" +
+ "6001600160a01b0302191690836001600160a01b0316021790555088815f0160146101" +
+ "000a8154816001600160401b0302191690836001600160401b03160217905550600181" +
+ "5f01601c6101000a81548160ff02191690831515021790555087815f01601d6101000a" +
+ "81548160ff021916908360ff16021790555086816001015f6101000a81548160016001" +
+ "60401b0302191690836001600160401b03160217905550858582600201918261060a92" +
+ "9190612423565b50600380548a91905f906106289084906001600160401b03166124dd" +
+ "565b92506101000a8154816001600160401b0302191690836001600160401b03160217" +
+ "9055505f5f90505b83811015610691576106898386868481811061066f5761066f6125" +
+ "02565b90506020020160208101906106849190612048565b611a93565b600101610651" +
+ "565b50896001600160a01b0316827f09a706e40d9ad723a2810180344b3c848de39708" +
+ "7ce3da3fead0d39b2c809d4c8b89896040516106d09392919061253e565b6040518091" +
+ "0390a360405160ff8916815282907f314809947a390d762614db0e47ab34380ad55ab7" +
+ "6465eae94a8121c707148cdc9060200160405180910390a26040516001600160401b03" +
+ "8816815282907f41a37a9425042b4dfae0283bee3672cc13fce972d860f8ad764bc8a5" +
+ "50df99b39060200160405180910390a25060048054600101908190556040519081525f" +
+ "5160206125de5f395f51905f529060200160405180910390a198975050505050505050" +
+ "565b6001600160a01b0381165f90815260066020526040812054908080808085810361" +
+ "07c057505f94508493508392508291508190508061080e565b5050505f838152600560" +
+ "20526040902080546001909101546001600160401b03600160a01b8304811694506001" +
+ "600160a01b038316935060ff600160e01b8404811693600160e81b90041691165b9193" +
+ "9550919395565b5f546001600160a01b031661083f576040516321c4e35760e21b8152" +
+ "60040160405180910390fd5b825f61084a82611bd8565b5f549091506001600160a01b" +
+ "03163314801590610871575080546001600160a01b03163314155b1561088f57604051" +
+ "6301940db560e51b815260040160405180910390fd5b5f858152600560205260409020" +
+ "6002016108aa848683612423565b50847f3cf32b7851a39f3e6ced7e2df88eee3224b2" +
+ "7dbf667c3c92c9cd43a6a91ca39085856040516108dd929190612569565b6040518091" +
+ "0390a25050505050565b5f546001600160a01b0316610914576040516321c4e35760e2" +
+ "1b815260040160405180910390fd5b5f546001600160a01b0316331461093e57604051" +
+ "6330cd747160e01b815260040160405180910390fd5b8061094883611bd8565b600101" +
+ "805467ffffffffffffffff19166001600160401b039283161790556040519082168152" +
+ "82907f41a37a9425042b4dfae0283bee3672cc13fce972d860f8ad764bc8a550df99b3" +
+ "906020015b60405180910390a260048054600101908190556040519081525f51602061" +
+ "25de5f395f51905f52906020015b60405180910390a15050565b5f546001600160a01b" +
+ "03166109f6576040516321c4e35760e21b815260040160405180910390fd5b5f546001" +
+ "600160a01b03163314610a20576040516330cd747160e01b8152600401604051809103" +
+ "90fd5b610a2a8282611c12565b6003546001600160401b0380841691161115610a5957" +
+ "60405163ad7e403f60e01b815260040160405180910390fd5b60015b60025481101561" +
+ "0aaf575f818152600560205260409020546001600160401b03808416600160a01b9092" +
+ "04161115610aa75760405163dbf8c09d60e01b815260040160405180910390fd5b6001" +
+ "01610a5c565b505f805467ffffffffffffffff60a01b1916600160a01b600160016040" +
+ "1b03858116918202929092179092556001805467ffffffffffffffff19169184169182" +
+ "1790556040805192835260208301919091527f24d48e187a24f49e3551ce9d77a89e2a" +
+ "5959da2a1932f86ebab7b3168483ac1b910160405180910390a1600480546001019081" +
+ "90556040519081525f5160206125de5f395f51905f52906020016109c2565b5f546001" +
+ "600160a01b0316610b7c576040516321c4e35760e21b815260040160405180910390fd" +
+ "5b825f610b8782611bd8565b5f549091506001600160a01b03163314801590610bae57" +
+ "5080546001600160a01b03163314155b15610bcc576040516301940db560e51b815260" +
+ "040160405180910390fd5b5f5b83811015610bf457610bec8686868481811061066f57" +
+ "61066f612502565b600101610bce565b5060048054600101908190556040519081525f" +
+ "5160206125de5f395f51905f529060200160405180910390a15050505050565b60605f" +
+ "805b600754811015610caa5760055f60065f60078581548110610c4f57610c4f612502" +
+ "565b5f9182526020808320909101546001600160a01b03168352828101939093526040" +
+ "918201812054845291830193909352910190205460ff600160e01b9091041615610ca2" +
+ "5781610c9e81612368565b9250505b600101610c2c565b505f816001600160401b0381" +
+ "1115610cc457610cc4612380565b604051908082528060200260200182016040528015" +
+ "610ced578160200160208202803683370190505b5090505f805b600754811015610d99" +
+ "575f60078281548110610d1157610d11612502565b5f91825260208083209091015460" +
+ "01600160a01b0316808352600682526040808420548452600590925291205490915060" +
+ "ff600160e01b9091041615610d9057808484610d5d81612368565b955081518110610d" +
+ "6f57610d6f612502565b60200260200101906001600160a01b031690816001600160a0" +
+ "1b0316815250505b50600101610cf3565b50909392505050565b6060610dad82611bd8" +
+ "565b600301805480602002602001604051908101604052809291908181526020018280" +
+ "548015610e0257602002820191905f5260205f20905b81546001600160a01b03168152" +
+ "600190910190602001808311610de4575b50505050509050919050565b5f5460016001" +
+ "60a01b0316610e36576040516321c4e35760e21b815260040160405180910390fd5b5f" +
+ "546001600160a01b03163314610e60576040516330cd747160e01b8152600401604051" +
+ "80910390fd5b5f610e6a83611bd8565b8054909150821515600160e01b90910460ff16" +
+ "151503610e8a5750610f99565b8115610efc578054610eac90600160a01b9004600160" +
+ "0160401b03165f6119de565b8054600380546001600160401b03600160a01b90930483" +
+ "16925f91610ed3918591166124dd565b92506101000a8154816001600160401b030219" +
+ "1690836001600160401b03160217905550610f48565b8054600380546001600160401b" +
+ "03600160a01b9093048316925f91610f2391859116612584565b92506101000a815481" +
+ "6001600160401b0302191690836001600160401b031602179055505b805460ff60e01b" +
+ "1916600160e01b83151590810291909117825560405190815283907f81cdb3516fac5f" +
+ "a15a5e6738774f6eab2657ef4b6f6486451655eb882dc202cd90602001604051809103" +
+ "90a2505b60048054600101908190556040519081525f5160206125de5f395f51905f52" +
+ "906020016109c2565b5f546001600160a01b031615610fe95760405162dc149f60e41b" +
+ "815260040160405180910390fd5b6001600160a01b0383166110105760405163d92e23" +
+ "3d60e01b815260040160405180910390fd5b61101a8282611c12565b5f805460016001" +
+ "60a01b0385166001600160e01b03199091168117600160a01b6001600160401b038681" +
+ "16918202929092179093556001805467ffffffffffffffff1916918516918217815560" +
+ "025560408051938452602084019190915290917f6fe9ef307ea9c9e4c7e9dd8df7afa1" +
+ "a49139e88d2273cbb5b2c3a939bc19eb02910160405180910390a2505050565b606080" +
+ "805f60015b6002548110156110ef575f81815260056020526040902054600160e01b90" +
+ "0460ff16156110e757816110e381612368565b9250505b6001016110b2565b50806001" +
+ "600160401b0381111561110857611108612380565b6040519080825280602002602001" +
+ "82016040528015611131578160200160208202803683370190505b5093508060016001" +
+ "60401b0381111561114c5761114c612380565b60405190808252806020026020018201" +
+ "6040528015611175578160200160208202803683370190505b50925080600160016040" +
+ "1b0381111561119057611190612380565b604051908082528060200260200182016040" +
+ "5280156111b9578160200160208202803683370190505b5091505f60015b6002548110" +
+ "156112a5575f8181526005602052604090208054600160e01b900460ff166111ed5750" +
+ "61129d565b8187848151811061120057611200612502565b6020908102919091010152" +
+ "805486516001600160a01b039091169087908590811061122d5761122d612502565b60" +
+ "200260200101906001600160a01b031690816001600160a01b031681525050805f0160" +
+ "149054906101000a90046001600160401b031685848151811061127657611276612502" +
+ "565b6001600160401b0390921660209283029190910190910152826112988161236856" +
+ "5b935050505b6001016111c0565b505050909192565b5f546001600160a01b03166112" +
+ "d5576040516321c4e35760e21b815260040160405180910390fd5b5f546001600160a0" +
+ "1b031633146112ff576040516330cd747160e01b815260040160405180910390fd5b60" +
+ "01600160a01b0381166113265760405163d92e233d60e01b8152600401604051809103" +
+ "90fd5b5f61133083611bd8565b80546001600160a01b038481166001600160a01b0319" +
+ "831681178455604051939450911691829086907fc5be841a0df05495e1b0c1417dd3e4" +
+ "7b706482e380cf1878d41adbb2ec27d43c905f90a450505050565b5f546001600160a0" +
+ "1b03166113ac576040516321c4e35760e21b815260040160405180910390fd5b5f5460" +
+ "01600160a01b031633146113d6576040516330cd747160e01b81526004016040518091" +
+ "0390fd5b5f6113e083611bd8565b8054909150600160a01b81046001600160401b0316" +
+ "90611416908490600160e01b900460ff16611410575f6119de565b826119de565b8154" +
+ "600160e01b900460ff161561146c5760035483906114409083906001600160401b0316" +
+ "612584565b61144a91906124dd565b6003805467ffffffffffffffff19166001600160" +
+ "401b03929092169190911790555b815467ffffffffffffffff60a01b1916600160a01b" +
+ "6001600160401b03858116918202929092178455604080519284168352602083019190" +
+ "915285917f27e19ef4fd95b0e09b35f3a0160fbc4c0adeb8a03d5353f1e0fcee66e9ed" +
+ "41e2910160405180910390a2505060048054600101908190556040519081525f516020" +
+ "6125de5f395f51905f52906020016109c2565b5f5f5f60605f5f61150d87611bd8565b" +
+ "805460038201546002830180549394506001600160a01b03831693600160a01b840460" +
+ "01600160401b031693600160e01b900460ff169290829061155090612394565b80601f" +
+ "016020809104026020016040519081016040528092919081815260200182805461157c" +
+ "90612394565b80156115c75780601f1061159e57610100808354040283529160200191" +
+ "6115c7565b820191905f5260205f20905b8154815290600101906020018083116115aa" +
+ "57829003601f168201915b505050505091509550955095509550955050919395909294" +
+ "50565b6001600160a01b0381165f90815260066020526040812054801580159061161d" +
+ "57505f81815260056020526040902054600160e01b900460ff165b9392505050565b5f" +
+ "6002545f0361163357505f90565b600160025461164291906125a3565b905090565b5f" +
+ "546001600160a01b031661166f576040516321c4e35760e21b81526004016040518091" +
+ "0390fd5b815f61167a82611bd8565b5f549091506001600160a01b0316331480159061" +
+ "16a1575080546001600160a01b03163314155b156116bf576040516301940db560e51b" +
+ "815260040160405180910390fd5b5f6116c985611bd8565b6001600160a01b0385165f" +
+ "908152600660205260409020549091508514611703576040516343876cef60e11b8152" +
+ "60040160405180910390fd5b6001600160a01b0384165f908152600660205260408120" +
+ "556117258185611c69565b61172e84611d94565b6040516001600160a01b0385169086" +
+ "907f1dba51362ce159be9df822b10a7257119c8852ec78713df99e4072ab10842e1e90" +
+ "5f90a35060048054600101908190556040519081525f5160206125de5f395f51905f52" +
+ "906020015b60405180910390a150505050565b5f546001600160a01b03166117be5760" +
+ "40516321c4e35760e21b815260040160405180910390fd5b5f546001600160a01b0316" +
+ "33146117e8576040516330cd747160e01b815260040160405180910390fd5b60016001" +
+ "60a01b03811661180f5760405163d92e233d60e01b815260040160405180910390fd5b" +
+ "5f80546040516001600160a01b03808516939216917f8be0079c531659141344cd1fd0" +
+ "a4f28419497f9722a3daafe3b4186f6b6457e091a35f80546001600160a01b03191660" +
+ "01600160a01b0392909216919091179055565b5f546001600160a01b03166118905760" +
+ "40516321c4e35760e21b815260040160405180910390fd5b5f546001600160a01b0316" +
+ "33146118ba576040516330cd747160e01b815260040160405180910390fd5b600160ff" +
+ "821611156118df57604051632e573a3f60e01b815260040160405180910390fd5b8061" +
+ "18e983611bd8565b805460ff60e81b1916600160e81b60ff9384160217905560405190" +
+ "8216815282907f314809947a390d762614db0e47ab34380ad55ab76465eae94a8121c7" +
+ "07148cdc90602001610996565b5f546001600160a01b031661195c576040516321c4e3" +
+ "5760e21b815260040160405180910390fd5b815f61196782611bd8565b5f5490915060" +
+ "01600160a01b0316331480159061198e575080546001600160a01b03163314155b1561" +
+ "19ac576040516301940db560e51b815260040160405180910390fd5b6119b68484611a" +
+ "93565b60048054600101908190556040519081525f5160206125de5f395f51905f5290" +
+ "602001611788565b816001600160401b03165f03611a0757604051634377d4dd60e11b" +
+ "815260040160405180910390fd5b6001546001600160401b039081169083161115611a" +
+ "375760405163dbf8c09d60e01b815260040160405180910390fd5b5f54600354600160" +
+ "0160401b03600160a01b9092048216918491611a5d91859116612584565b611a679190" +
+ "6124dd565b6001600160401b03161115611a8f5760405163ad7e403f60e01b81526004" +
+ "0160405180910390fd5b5050565b6001600160a01b038116611aba5760405163d92e23" +
+ "3d60e01b815260040160405180910390fd5b6001600160a01b0381165f908152600660" +
+ "2052604090205415611af0576040516316a163b960e11b815260040160405180910390" +
+ "fd5b5f611afa83611bd8565b600381018054600180820183555f928352602080842090" +
+ "920180546001600160a01b0319166001600160a01b0388169081179091558352600690" +
+ "91526040909120859055600754919250611b4e91906125b6565b6001600160a01b0383" +
+ "165f8181526008602052604080822093909355600780546001810182559082527fa66c" +
+ "c928b5edb82af9bd49922954155ab7b0942694bea4ce44661d9a8736c6880180546001" +
+ "600160a01b031916831790559151909185917f80e74249b1abc4956ba12c76ad9d1ffd" +
+ "febe41c1df74308c8d5f9f6a437bc9599190a3505050565b5f81815260056020526040" +
+ "902080546001600160a01b0316611c0d576040516366f1f3b160e01b81526004016040" +
+ "5180910390fd5b919050565b6001600160401b0382161580611c2f5750600160016040" +
+ "1b038116155b80611c4b5750816001600160401b0316816001600160401b0316115b15" +
+ "611a8f57604051634377d4dd60e11b815260040160405180910390fd5b60038201545f" +
+ "5b81811015611d7a57826001600160a01b0316846003018281548110611c9757611c97" +
+ "612502565b5f918252602090912001546001600160a01b031603611d7257611cbb6001" +
+ "836125a3565b8114611d395760038401611cd06001846125a3565b81548110611ce057" +
+ "611ce0612502565b5f918252602090912001546003850180546001600160a01b039092" +
+ "169183908110611d0d57611d0d612502565b905f5260205f20015f6101000a81548160" +
+ "01600160a01b0302191690836001600160a01b031602179055505b8360030180548061" +
+ "1d4c57611d4c6125c9565b5f8281526020902081015f1990810180546001600160a01b" +
+ "031916905501905550505050565b600101611c70565b506040516343876cef60e11b81" +
+ "5260040160405180910390fd5b6001600160a01b0381165f9081526008602052604081" +
+ "205490819003611dcd576040516343876cef60e11b815260040160405180910390fd5b" +
+ "5f611dd96001836125a3565b6007549091505f90611ded906001906125a3565b905080" +
+ "8214611e85575f60078281548110611e0a57611e0a612502565b5f9182526020909120" +
+ "0154600780546001600160a01b039092169250829185908110611e3857611e38612502" +
+ "565b5f91825260209091200180546001600160a01b0319166001600160a01b03929092" +
+ "16919091179055611e6b8360016125b6565b6001600160a01b039091165f9081526008" +
+ "60205260409020555b6007805480611e9657611e966125c9565b5f8281526020808220" +
+ "83015f1990810180546001600160a01b03191690559092019092556001600160a01b03" +
+ "95909516815260089094525050604082209190915550565b80356001600160a01b0381" +
+ "168114611c0d575f5ffd5b80356001600160401b0381168114611c0d575f5ffd5b8035" +
+ "60ff81168114611c0d575f5ffd5b5f5f83601f840112611f26575f5ffd5b5081356001" +
+ "600160401b03811115611f3c575f5ffd5b602083019150836020828501011115611f53" +
+ "575f5ffd5b9250929050565b5f5f83601f840112611f6a575f5ffd5b50813560016001" +
+ "60401b03811115611f80575f5ffd5b6020830191508360208260051b8501011115611f" +
+ "53575f5ffd5b5f5f5f5f5f5f5f5f60c0898b031215611fb1575f5ffd5b611fba89611e" +
+ "da565b9750611fc860208a01611ef0565b9650611fd660408a01611f06565b9550611f" +
+ "e460608a01611ef0565b945060808901356001600160401b03811115611ffe575f5ffd" +
+ "5b61200a8b828c01611f16565b90955093505060a08901356001600160401b03811115" +
+ "612028575f5ffd5b6120348b828c01611f5a565b999c989b5096995094979396929594" +
+ "505050565b5f60208284031215612058575f5ffd5b61161d82611eda565b5f5f5f6040" +
+ "8486031215612073575f5ffd5b8335925060208401356001600160401b038111156120" +
+ "8f575f5ffd5b61209b86828701611f16565b9497909650939450505050565b5f5f6040" +
+ "83850312156120b9575f5ffd5b823591506120c960208401611ef0565b905092509290" +
+ "50565b5f5f604083850312156120e3575f5ffd5b6120ec83611ef0565b91506120c960" +
+ "208401611ef0565b5f5f5f6040848603121561210c575f5ffd5b833592506020840135" +
+ "6001600160401b03811115612128575f5ffd5b61209b86828701611f5a565b5f815180" +
+ "8452602084019350602083015f5b8281101561216d5781516001600160a01b03168652" +
+ "60209586019590910190600101612146565b5093949350505050565b602081525f6116" +
+ "1d6020830184612134565b5f60208284031215612199575f5ffd5b5035919050565b5f" +
+ "5f604083850312156121b1575f5ffd5b82359150602083013580151581146121c7575f" +
+ "5ffd5b809150509250929050565b5f5f5f606084860312156121e4575f5ffd5b6121ed" +
+ "84611eda565b92506121fb60208501611ef0565b915061220960408501611ef0565b90" +
+ "509250925092565b606080825284519082018190525f9060208601906080840190835b" +
+ "8181101561224b57835183526020938401939092019160010161222d565b5050838103" +
+ "602085015261225f8187612134565b8481036040860152855180825260208088019450" +
+ "90910191505f5b818110156122a15783516001600160401b0316835260209384019390" +
+ "92019160010161227a565b5090979650505050505050565b5f5f604083850312156122" +
+ "bf575f5ffd5b823591506120c960208401611eda565b60018060a01b03861681526001" +
+ "600160401b0385166020820152831515604082015260a060608201525f83518060a084" +
+ "0152806020860160c085015e5f60c0828501015260c0601f19601f8301168401019150" +
+ "508260808301529695505050505050565b5f5f60408385031215612344575f5ffd5b82" +
+ "3591506120c960208401611f06565b634e487b7160e01b5f52601160045260245ffd5b" +
+ "5f6001820161237957612379612354565b5060010190565b634e487b7160e01b5f5260" +
+ "4160045260245ffd5b600181811c908216806123a857607f821691505b602082108103" +
+ "6123c657634e487b7160e01b5f52602260045260245ffd5b50919050565b601f821115" +
+ "61241e578282111561241e57805f5260205f20601f840160051c60208510156123f757" +
+ "505f5b90810190601f840160051c035f5b8181101561241a575f838201556001016124" +
+ "05565b5050505b505050565b6001600160401b0383111561243a5761243a612380565b" +
+ "61244e836124488354612394565b836123cc565b5f601f84116001811461247f575f85" +
+ "156124685750838201355b5f19600387901b1c1916600186901b1783556124d6565b5f" +
+ "83815260208120601f198716915b828110156124ae5786850135825560209485019460" +
+ "01909201910161248e565b50868210156124ca575f1960f88860031b161c1984870135" +
+ "1681555b505060018560011b0183555b5050505050565b6001600160401b0381811683" +
+ "821601908111156124fc576124fc612354565b92915050565b634e487b7160e01b5f52" +
+ "603260045260245ffd5b81835281816020850137505f82820160209081019190915260" +
+ "1f909101601f19169091010190565b6001600160401b0384168152604060208201525f" +
+ "612560604083018486612516565b95945050505050565b602081525f61257c60208301" +
+ "8486612516565b949350505050565b6001600160401b03828116828216039081111561" +
+ "24fc576124fc612354565b818103818111156124fc576124fc612354565b8082018082" +
+ "11156124fc576124fc612354565b634e487b7160e01b5f52603160045260245ffdfe61" +
+ "26ab0a9aadc9f729174668b7965114b5198740cc236df82aa4435b33d3626aa2646970" +
+ "6673582212207c3ab7fc7e401b4dc67275cd3c9117f3361164f9ff9c5dc761635fd63e" +
+ "c15fa364736f6c63430008210033"
+)
diff --git a/params/reserved_test.go b/params/reserved_test.go
new file mode 100644
index 0000000000..6b44891c84
--- /dev/null
+++ b/params/reserved_test.go
@@ -0,0 +1,118 @@
+// 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 params
+
+import (
+ "math/big"
+ "strings"
+ "testing"
+
+ "github.com/ethereum/go-ethereum/common"
+)
+
+func TestIsReservedBlockspace(t *testing.T) {
+ t.Parallel()
+
+ cfg := &BorConfig{ReservedBlockspaceBlock: big.NewInt(100)}
+ if cfg.IsReservedBlockspace(big.NewInt(99)) {
+ t.Error("should not be active at N-1")
+ }
+ if !cfg.IsReservedBlockspace(big.NewInt(100)) {
+ t.Error("should be active at N")
+ }
+ if !cfg.IsReservedBlockspace(big.NewInt(101)) {
+ t.Error("should be active at N+1")
+ }
+
+ // nil fork block = never active.
+ none := &BorConfig{}
+ if none.IsReservedBlockspace(big.NewInt(1_000_000)) {
+ t.Error("nil ReservedBlockspaceBlock should never be active")
+ }
+}
+
+// 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()
+
+ const reg = "0x0000000000000000000000000000000000001002"
+ tests := []struct {
+ name string
+ cfg *ChainConfig
+ wantErr bool
+ }{
+ {"nil bor", &ChainConfig{}, false},
+ {"unscheduled", &ChainConfig{Bor: &BorConfig{}}, false},
+ {"reserved before cancun", &ChainConfig{CancunBlock: big.NewInt(100), Bor: &BorConfig{ReservedBlockspaceBlock: big.NewInt(50), ReservedRegistryContract: reg}}, true},
+ {"reserved without cancun", &ChainConfig{Bor: &BorConfig{ReservedBlockspaceBlock: big.NewInt(50), ReservedRegistryContract: reg}}, true},
+ {"reserved at cancun", &ChainConfig{CancunBlock: big.NewInt(50), Bor: &BorConfig{ReservedBlockspaceBlock: big.NewInt(50), ReservedRegistryContract: reg}}, false},
+ {"reserved after cancun", &ChainConfig{CancunBlock: big.NewInt(10), Bor: &BorConfig{ReservedBlockspaceBlock: big.NewInt(50), ReservedRegistryContract: reg}}, false},
+ {"scheduled without registry", &ChainConfig{CancunBlock: big.NewInt(10), Bor: &BorConfig{ReservedBlockspaceBlock: big.NewInt(50)}}, true},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if err := tt.cfg.checkReservedBlockspaceForkOrder(); (err != nil) != tt.wantErr {
+ t.Errorf("checkReservedBlockspaceForkOrder() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
+
+func TestDescriptionReservedBlockspace(t *testing.T) {
+ t.Parallel()
+
+ // When scheduled, the startup banner must print the fork (hardfork-rollout
+ // review requires a visible activation height).
+ scheduled := &ChainConfig{
+ ChainID: big.NewInt(137),
+ Bor: &BorConfig{ReservedBlockspaceBlock: big.NewInt(500)},
+ }
+ if got := scheduled.Description(); !strings.Contains(got, "ReservedBlockspace") || !strings.Contains(got, "500") {
+ t.Errorf("banner should advertise ReservedBlockspace #500, got:\n%s", got)
+ }
+
+ // Unscheduled (nil) must not print the line.
+ unscheduled := &ChainConfig{ChainID: big.NewInt(137), Bor: &BorConfig{}}
+ if strings.Contains(unscheduled.Description(), "ReservedBlockspace") {
+ t.Error("banner must omit ReservedBlockspace when unscheduled")
+ }
+}
diff --git a/registry-contract/.gitignore b/registry-contract/.gitignore
new file mode 100644
index 0000000000..85b77a6019
--- /dev/null
+++ b/registry-contract/.gitignore
@@ -0,0 +1,16 @@
+# Compiler files
+cache/
+out/
+
+# Ignores development broadcast logs
+!/broadcast
+/broadcast/*/31337/
+/broadcast/**/dry-run/
+
+# Docs
+docs/
+
+# Dotenv file
+.env
+
+lib/
diff --git a/registry-contract/.gitmodules b/registry-contract/.gitmodules
new file mode 100644
index 0000000000..888d42dcd9
--- /dev/null
+++ b/registry-contract/.gitmodules
@@ -0,0 +1,3 @@
+[submodule "lib/forge-std"]
+ path = lib/forge-std
+ url = https://github.com/foundry-rs/forge-std
diff --git a/registry-contract/foundry.lock b/registry-contract/foundry.lock
new file mode 100644
index 0000000000..7521bfd030
--- /dev/null
+++ b/registry-contract/foundry.lock
@@ -0,0 +1,8 @@
+{
+ "lib/forge-std": {
+ "tag": {
+ "name": "v1.16.1",
+ "rev": "620536fa5277db4e3fd46772d5cbc1ea0696fb43"
+ }
+ }
+}
\ No newline at end of file
diff --git a/registry-contract/foundry.toml b/registry-contract/foundry.toml
new file mode 100644
index 0000000000..0699035cb9
--- /dev/null
+++ b/registry-contract/foundry.toml
@@ -0,0 +1,9 @@
+[profile.default]
+src = "src"
+out = "out"
+libs = ["lib"]
+solc = "0.8.33"
+optimizer = true
+optimizer_runs = 200
+
+# See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options
diff --git a/registry-contract/src/ReservedBlockspaceRegistry.sol b/registry-contract/src/ReservedBlockspaceRegistry.sol
new file mode 100644
index 0000000000..6aae75b57e
--- /dev/null
+++ b/registry-contract/src/ReservedBlockspaceRegistry.sol
@@ -0,0 +1,403 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.20;
+
+/// @title ReservedBlockspaceRegistry
+/// @notice Registry for reserved blockspace clients and their whitelisted sender addresses.
+/// @dev This contract has no constructor so its deployed runtime bytecode can be embedded directly in genesis.
+contract ReservedBlockspaceRegistry {
+ error AlreadyInitialized();
+ error NotInitialized();
+ error NotOwner();
+ error NotClientAdmin();
+ error ZeroAddress();
+ error UnknownClient();
+ error AddressAlreadyRegistered();
+ error AddressNotRegistered();
+ error InvalidQuota();
+ error InvalidFeeMode();
+ error MaxTotalReservedGasExceeded();
+ error MaxClientReservedGasExceeded();
+
+ struct Client {
+ address admin;
+ uint64 gasQuota;
+ bool active;
+ // feeMode: 0 = free (zero in-protocol fee), 1 = routed (fee paid but
+ // credited to the producer).
+ uint8 feeMode;
+ // effectiveFrom: block number from which this client's reserved status
+ // applies. Lets governance schedule/announce a change at a future height
+ // (a kill-switch via setClientActive(false) is still immediate).
+ uint64 effectiveFrom;
+ string metadata;
+ address[] addresses;
+ }
+
+ uint8 internal constant FEE_MODE_FREE = 0;
+ uint8 internal constant FEE_MODE_ROUTED = 1;
+
+ address public owner;
+ uint64 public maxTotalReservedGas;
+ uint64 public maxClientReservedGas;
+ uint256 public nextClientId;
+ uint64 public totalReservedGas;
+
+ // configVersion increments on every change to the reserved set or its
+ // limits. Bor reads root() once per block and only rebuilds its cached
+ // snapshot when the value changes, avoiding a per-transaction state read.
+ uint256 public configVersion;
+
+ mapping(uint256 clientId => Client client) private clients;
+ mapping(address account => uint256 clientId) private clientIdByAddress;
+ address[] private allClientAddresses;
+ mapping(address account => uint256 indexPlusOne) private allAddressIndex;
+
+ event Initialized(address indexed owner, uint64 maxTotalReservedGas, uint64 maxClientReservedGas);
+ event OwnershipTransferred(address indexed previousOwner, address indexed newOwner);
+ event LimitsUpdated(uint64 maxTotalReservedGas, uint64 maxClientReservedGas);
+ event ClientCreated(uint256 indexed clientId, address indexed admin, uint64 gasQuota, string metadata);
+ event ClientAdminUpdated(uint256 indexed clientId, address indexed previousAdmin, address indexed newAdmin);
+ event ClientQuotaUpdated(uint256 indexed clientId, uint64 previousQuota, uint64 newQuota);
+ event ClientActiveUpdated(uint256 indexed clientId, bool active);
+ event ClientMetadataUpdated(uint256 indexed clientId, string metadata);
+ event ClientAddressAdded(uint256 indexed clientId, address indexed account);
+ event ClientAddressRemoved(uint256 indexed clientId, address indexed account);
+ event ClientFeeModeUpdated(uint256 indexed clientId, uint8 feeMode);
+ event ClientEffectiveFromUpdated(uint256 indexed clientId, uint64 effectiveFrom);
+ event ConfigVersionUpdated(uint256 version);
+
+ modifier onlyInitialized() {
+ if (owner == address(0)) revert NotInitialized();
+ _;
+ }
+
+ // bumpsVersion increments configVersion after a mutation so Bor's cached
+ // snapshot (keyed on root()) is invalidated. Applied to every function that
+ // changes the reserved set, quotas, fee modes, effective heights, or limits.
+ modifier bumpsVersion() {
+ _;
+ unchecked {
+ configVersion++;
+ }
+ emit ConfigVersionUpdated(configVersion);
+ }
+
+ modifier onlyOwner() {
+ if (msg.sender != owner) revert NotOwner();
+ _;
+ }
+
+ modifier onlyClientAdminOrOwner(uint256 clientId) {
+ Client storage client = _client(clientId);
+ if (msg.sender != owner && msg.sender != client.admin) revert NotClientAdmin();
+ _;
+ }
+
+ function initialize(address initialOwner, uint64 maxTotalGas, uint64 maxClientGas) external {
+ if (owner != address(0)) revert AlreadyInitialized();
+ if (initialOwner == address(0)) revert ZeroAddress();
+ _validateLimits(maxTotalGas, maxClientGas);
+
+ owner = initialOwner;
+ maxTotalReservedGas = maxTotalGas;
+ maxClientReservedGas = maxClientGas;
+ nextClientId = 1;
+
+ emit Initialized(initialOwner, maxTotalGas, maxClientGas);
+ }
+
+ function transferOwnership(address newOwner) external onlyInitialized onlyOwner {
+ if (newOwner == address(0)) revert ZeroAddress();
+ emit OwnershipTransferred(owner, newOwner);
+ owner = newOwner;
+ }
+
+ function setLimits(uint64 maxTotalGas, uint64 maxClientGas) external onlyInitialized onlyOwner bumpsVersion {
+ _validateLimits(maxTotalGas, maxClientGas);
+ if (totalReservedGas > maxTotalGas) revert MaxTotalReservedGasExceeded();
+ for (uint256 clientId = 1; clientId < nextClientId; clientId++) {
+ if (clients[clientId].gasQuota > maxClientGas) revert MaxClientReservedGasExceeded();
+ }
+
+ maxTotalReservedGas = maxTotalGas;
+ maxClientReservedGas = maxClientGas;
+
+ emit LimitsUpdated(maxTotalGas, maxClientGas);
+ }
+
+ function createClient(
+ address admin,
+ uint64 gasQuota,
+ uint8 feeMode,
+ uint64 effectiveFrom,
+ string calldata metadata,
+ address[] calldata addresses
+ ) external onlyInitialized onlyOwner bumpsVersion returns (uint256 clientId) {
+ if (admin == address(0)) revert ZeroAddress();
+ if (feeMode > FEE_MODE_ROUTED) revert InvalidFeeMode();
+ _validateQuota(gasQuota, 0);
+
+ clientId = nextClientId++;
+ Client storage client = clients[clientId];
+ client.admin = admin;
+ client.gasQuota = gasQuota;
+ client.active = true;
+ client.feeMode = feeMode;
+ client.effectiveFrom = effectiveFrom;
+ client.metadata = metadata;
+ totalReservedGas += gasQuota;
+
+ for (uint256 i = 0; i < addresses.length; i++) {
+ _addAddress(clientId, addresses[i]);
+ }
+
+ emit ClientCreated(clientId, admin, gasQuota, metadata);
+ emit ClientFeeModeUpdated(clientId, feeMode);
+ emit ClientEffectiveFromUpdated(clientId, effectiveFrom);
+ }
+
+ function setClientAdmin(uint256 clientId, address newAdmin) external onlyInitialized onlyOwner {
+ if (newAdmin == address(0)) revert ZeroAddress();
+ Client storage client = _client(clientId);
+ address previousAdmin = client.admin;
+ client.admin = newAdmin;
+
+ emit ClientAdminUpdated(clientId, previousAdmin, newAdmin);
+ }
+
+ function setClientQuota(uint256 clientId, uint64 newQuota) external onlyInitialized onlyOwner bumpsVersion {
+ Client storage client = _client(clientId);
+ uint64 previousQuota = client.gasQuota;
+ _validateQuota(newQuota, client.active ? previousQuota : 0);
+
+ if (client.active) {
+ totalReservedGas = totalReservedGas - previousQuota + newQuota;
+ }
+ client.gasQuota = newQuota;
+
+ emit ClientQuotaUpdated(clientId, previousQuota, newQuota);
+ }
+
+ function setClientActive(uint256 clientId, bool active) external onlyInitialized onlyOwner bumpsVersion {
+ Client storage client = _client(clientId);
+ if (client.active == active) return;
+
+ if (active) {
+ _validateQuota(client.gasQuota, 0);
+ totalReservedGas += client.gasQuota;
+ } else {
+ totalReservedGas -= client.gasQuota;
+ }
+ client.active = active;
+
+ emit ClientActiveUpdated(clientId, active);
+ }
+
+ function setClientMetadata(uint256 clientId, string calldata metadata)
+ external
+ onlyInitialized
+ onlyClientAdminOrOwner(clientId)
+ {
+ clients[clientId].metadata = metadata;
+ emit ClientMetadataUpdated(clientId, metadata);
+ }
+
+ function addClientAddress(uint256 clientId, address account)
+ external
+ onlyInitialized
+ onlyClientAdminOrOwner(clientId)
+ bumpsVersion
+ {
+ _addAddress(clientId, account);
+ }
+
+ function addClientAddresses(uint256 clientId, address[] calldata accounts)
+ external
+ onlyInitialized
+ onlyClientAdminOrOwner(clientId)
+ bumpsVersion
+ {
+ for (uint256 i = 0; i < accounts.length; i++) {
+ _addAddress(clientId, accounts[i]);
+ }
+ }
+
+ function removeClientAddress(uint256 clientId, address account)
+ external
+ onlyInitialized
+ onlyClientAdminOrOwner(clientId)
+ bumpsVersion
+ {
+ Client storage client = _client(clientId);
+ if (clientIdByAddress[account] != clientId) revert AddressNotRegistered();
+
+ delete clientIdByAddress[account];
+ _removeFromClient(client, account);
+ _removeFromGlobal(account);
+
+ emit ClientAddressRemoved(clientId, account);
+ }
+
+ function clientCount() external view returns (uint256) {
+ if (nextClientId == 0) return 0;
+ return nextClientId - 1;
+ }
+
+ function getClient(uint256 clientId)
+ external
+ view
+ returns (address admin, uint64 gasQuota, bool active, string memory metadata, uint256 addressCount)
+ {
+ Client storage client = _client(clientId);
+ return (client.admin, client.gasQuota, client.active, client.metadata, client.addresses.length);
+ }
+
+ function getClientAddresses(uint256 clientId) external view returns (address[] memory) {
+ return _client(clientId).addresses;
+ }
+
+ function getClientId(address account) external view returns (uint256) {
+ return clientIdByAddress[account];
+ }
+
+ function getClientForAddress(address account)
+ external
+ view
+ returns (uint256 clientId, uint64 gasQuota, address admin, bool active, uint8 feeMode, uint64 effectiveFrom)
+ {
+ clientId = clientIdByAddress[account];
+ if (clientId == 0) return (0, 0, address(0), false, 0, 0);
+
+ Client storage client = clients[clientId];
+ return (clientId, client.gasQuota, client.admin, client.active, client.feeMode, client.effectiveFrom);
+ }
+
+ function isReservedAddress(address account) external view returns (bool) {
+ uint256 clientId = clientIdByAddress[account];
+ return clientId != 0 && clients[clientId].active;
+ }
+
+ // root returns a value that changes whenever the reserved set, quotas, fee
+ // modes, effective heights, or limits change. Bor caches its snapshot keyed
+ // on this and only rebuilds when it moves.
+ function root() external view returns (bytes32) {
+ return bytes32(configVersion);
+ }
+
+ function setClientFeeMode(uint256 clientId, uint8 feeMode) external onlyInitialized onlyOwner bumpsVersion {
+ if (feeMode > FEE_MODE_ROUTED) revert InvalidFeeMode();
+ _client(clientId).feeMode = feeMode;
+ emit ClientFeeModeUpdated(clientId, feeMode);
+ }
+
+ function setClientEffectiveFrom(uint256 clientId, uint64 effectiveFrom)
+ external
+ onlyInitialized
+ onlyOwner
+ bumpsVersion
+ {
+ _client(clientId).effectiveFrom = effectiveFrom;
+ emit ClientEffectiveFromUpdated(clientId, effectiveFrom);
+ }
+
+ function getWhitelistedAddresses() external view returns (address[] memory) {
+ uint256 count;
+ for (uint256 i = 0; i < allClientAddresses.length; i++) {
+ if (clients[clientIdByAddress[allClientAddresses[i]]].active) count++;
+ }
+
+ address[] memory activeAddresses = new address[](count);
+ uint256 out;
+ for (uint256 i = 0; i < allClientAddresses.length; i++) {
+ address account = allClientAddresses[i];
+ if (clients[clientIdByAddress[account]].active) {
+ activeAddresses[out++] = account;
+ }
+ }
+ return activeAddresses;
+ }
+
+ function getReservedClients()
+ external
+ view
+ returns (uint256[] memory clientIds, address[] memory admins, uint64[] memory gasQuotas)
+ {
+ uint256 count;
+ for (uint256 clientId = 1; clientId < nextClientId; clientId++) {
+ if (clients[clientId].active) count++;
+ }
+
+ clientIds = new uint256[](count);
+ admins = new address[](count);
+ gasQuotas = new uint64[](count);
+
+ uint256 out;
+ for (uint256 clientId = 1; clientId < nextClientId; clientId++) {
+ Client storage client = clients[clientId];
+ if (!client.active) continue;
+ clientIds[out] = clientId;
+ admins[out] = client.admin;
+ gasQuotas[out] = client.gasQuota;
+ out++;
+ }
+ }
+
+ function _client(uint256 clientId) private view returns (Client storage client) {
+ client = clients[clientId];
+ if (client.admin == address(0)) revert UnknownClient();
+ }
+
+ function _addAddress(uint256 clientId, address account) private {
+ if (account == address(0)) revert ZeroAddress();
+ if (clientIdByAddress[account] != 0) revert AddressAlreadyRegistered();
+
+ Client storage client = _client(clientId);
+ client.addresses.push(account);
+ clientIdByAddress[account] = clientId;
+ allAddressIndex[account] = allClientAddresses.length + 1;
+ allClientAddresses.push(account);
+
+ emit ClientAddressAdded(clientId, account);
+ }
+
+ function _removeFromClient(Client storage client, address account) private {
+ uint256 length = client.addresses.length;
+ for (uint256 i = 0; i < length; i++) {
+ if (client.addresses[i] == account) {
+ if (i != length - 1) {
+ client.addresses[i] = client.addresses[length - 1];
+ }
+ client.addresses.pop();
+ return;
+ }
+ }
+ revert AddressNotRegistered();
+ }
+
+ function _removeFromGlobal(address account) private {
+ uint256 indexPlusOne = allAddressIndex[account];
+ if (indexPlusOne == 0) revert AddressNotRegistered();
+
+ uint256 index = indexPlusOne - 1;
+ uint256 lastIndex = allClientAddresses.length - 1;
+ if (index != lastIndex) {
+ address moved = allClientAddresses[lastIndex];
+ allClientAddresses[index] = moved;
+ allAddressIndex[moved] = index + 1;
+ }
+ allClientAddresses.pop();
+ delete allAddressIndex[account];
+ }
+
+ function _validateLimits(uint64 maxTotalGas, uint64 maxClientGas) private pure {
+ if (maxTotalGas == 0 || maxClientGas == 0 || maxClientGas > maxTotalGas) revert InvalidQuota();
+ }
+
+ function _validateQuota(uint64 newQuota, uint64 previousActiveQuota) private view {
+ if (newQuota == 0) revert InvalidQuota();
+ if (newQuota > maxClientReservedGas) revert MaxClientReservedGasExceeded();
+ if (totalReservedGas - previousActiveQuota + newQuota > maxTotalReservedGas) {
+ revert MaxTotalReservedGasExceeded();
+ }
+ }
+}
diff --git a/registry-contract/test/ReservedBlockspaceRegistry.t.sol b/registry-contract/test/ReservedBlockspaceRegistry.t.sol
new file mode 100644
index 0000000000..98231f2c6e
--- /dev/null
+++ b/registry-contract/test/ReservedBlockspaceRegistry.t.sol
@@ -0,0 +1,238 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.20;
+
+import {Test} from "forge-std/Test.sol";
+import {ReservedBlockspaceRegistry} from "../src/ReservedBlockspaceRegistry.sol";
+
+contract ReservedBlockspaceRegistryTest is Test {
+ ReservedBlockspaceRegistry internal registry;
+
+ address internal owner = address(0xA11CE);
+ address internal admin = address(0xB0B);
+ address internal other = address(0xCAFE);
+ address internal senderA = address(0x100);
+ address internal senderB = address(0x200);
+ address internal senderC = address(0x300);
+
+ uint8 internal constant FEE_FREE = 0;
+ uint8 internal constant FEE_ROUTED = 1;
+
+ function setUp() public {
+ registry = new ReservedBlockspaceRegistry();
+ registry.initialize(owner, 80_000_000, 30_000_000);
+ }
+
+ function testInitializeCanOnlyRunOnce() public {
+ vm.expectRevert(ReservedBlockspaceRegistry.AlreadyInitialized.selector);
+ registry.initialize(owner, 80_000_000, 30_000_000);
+ }
+
+ function testOwnerCreatesClientAndBorFacingReadsWork() public {
+ address[] memory addresses = new address[](2);
+ addresses[0] = senderA;
+ addresses[1] = senderB;
+
+ vm.prank(owner);
+ uint256 clientId = registry.createClient(admin, 20_000_000, FEE_FREE, 0, "Polymarket", addresses);
+
+ assertEq(clientId, 1);
+ assertEq(registry.totalReservedGas(), 20_000_000);
+ assertTrue(registry.isReservedAddress(senderA));
+ assertTrue(registry.isReservedAddress(senderB));
+ assertFalse(registry.isReservedAddress(senderC));
+
+ (uint256 resolvedClientId, uint64 gasQuota, address resolvedAdmin, bool active, uint8 feeMode, uint64 effFrom) =
+ registry.getClientForAddress(senderA);
+ assertEq(resolvedClientId, clientId);
+ assertEq(gasQuota, 20_000_000);
+ assertEq(resolvedAdmin, admin);
+ assertTrue(active);
+ assertEq(feeMode, FEE_FREE);
+ assertEq(effFrom, 0);
+
+ (address returnedAdmin, uint64 returnedQuota, bool returnedActive, string memory metadata, uint256 count) =
+ registry.getClient(clientId);
+ assertEq(returnedAdmin, admin);
+ assertEq(returnedQuota, 20_000_000);
+ assertTrue(returnedActive);
+ assertEq(metadata, "Polymarket");
+ assertEq(count, 2);
+ }
+
+ function testClientAdminCanManageAddresses() public {
+ uint256 clientId = _createSingleAddressClient(senderA);
+
+ vm.prank(admin);
+ registry.addClientAddress(clientId, senderB);
+ assertTrue(registry.isReservedAddress(senderB));
+
+ vm.prank(admin);
+ registry.removeClientAddress(clientId, senderA);
+ assertFalse(registry.isReservedAddress(senderA));
+ assertTrue(registry.isReservedAddress(senderB));
+
+ address[] memory addresses = registry.getClientAddresses(clientId);
+ assertEq(addresses.length, 1);
+ assertEq(addresses[0], senderB);
+ }
+
+ function testNonAdminCannotManageAddresses() public {
+ uint256 clientId = _createSingleAddressClient(senderA);
+
+ vm.prank(other);
+ vm.expectRevert(ReservedBlockspaceRegistry.NotClientAdmin.selector);
+ registry.addClientAddress(clientId, senderB);
+ }
+
+ function testQuotaLimitsAreEnforced() public {
+ _createSingleAddressClient(senderA);
+
+ vm.prank(owner);
+ vm.expectRevert(ReservedBlockspaceRegistry.MaxClientReservedGasExceeded.selector);
+ registry.setLimits(60_000_000, 19_999_999);
+
+ vm.prank(owner);
+ registry.setLimits(60_000_000, 30_000_000);
+
+ address[] memory addresses = new address[](1);
+ addresses[0] = senderB;
+
+ vm.prank(owner);
+ vm.expectRevert(ReservedBlockspaceRegistry.MaxClientReservedGasExceeded.selector);
+ registry.createClient(admin, 30_000_001, FEE_FREE, 0, "too-large-client", addresses);
+
+ vm.prank(owner);
+ registry.createClient(admin, 30_000_000, FEE_FREE, 0, "Courtyard", addresses);
+
+ address[] memory moreAddresses = new address[](1);
+ moreAddresses[0] = senderC;
+
+ vm.prank(owner);
+ vm.expectRevert(ReservedBlockspaceRegistry.MaxTotalReservedGasExceeded.selector);
+ registry.createClient(admin, 20_000_000, FEE_FREE, 0, "would-exceed-total", moreAddresses);
+ }
+
+ function testInactiveClientIsNotReservedButKeepsAddressMapping() public {
+ uint256 clientId = _createSingleAddressClient(senderA);
+
+ vm.prank(owner);
+ registry.setClientActive(clientId, false);
+
+ assertFalse(registry.isReservedAddress(senderA));
+ assertEq(registry.getClientId(senderA), clientId);
+ assertEq(registry.totalReservedGas(), 0);
+
+ (uint256 resolvedClientId, uint64 gasQuota, address resolvedAdmin, bool active,,) =
+ registry.getClientForAddress(senderA);
+ assertEq(resolvedClientId, clientId);
+ assertEq(gasQuota, 20_000_000);
+ assertEq(resolvedAdmin, admin);
+ assertFalse(active);
+ }
+
+ function testReservedClientListOnlyReturnsActiveClients() public {
+ uint256 firstClientId = _createSingleAddressClient(senderA);
+
+ address[] memory addresses = new address[](1);
+ addresses[0] = senderB;
+
+ vm.prank(owner);
+ uint256 secondClientId = registry.createClient(admin, 10_000_000, FEE_FREE, 0, "Courtyard", addresses);
+
+ vm.prank(owner);
+ registry.setClientActive(firstClientId, false);
+
+ (uint256[] memory clientIds, address[] memory admins, uint64[] memory quotas) = registry.getReservedClients();
+ assertEq(clientIds.length, 1);
+ assertEq(admins.length, 1);
+ assertEq(quotas.length, 1);
+ assertEq(clientIds[0], secondClientId);
+ assertEq(admins[0], admin);
+ assertEq(quotas[0], 10_000_000);
+
+ address[] memory whitelisted = registry.getWhitelistedAddresses();
+ assertEq(whitelisted.length, 1);
+ assertEq(whitelisted[0], senderB);
+ }
+
+ // --- new: feeMode ---
+
+ function testCreateClientStoresFeeMode() public {
+ address[] memory addresses = new address[](1);
+ addresses[0] = senderA;
+
+ vm.prank(owner);
+ uint256 clientId = registry.createClient(admin, 20_000_000, FEE_ROUTED, 0, "routed-client", addresses);
+
+ (,,,, uint8 feeMode,) = registry.getClientForAddress(senderA);
+ assertEq(feeMode, FEE_ROUTED);
+
+ vm.prank(owner);
+ registry.setClientFeeMode(clientId, FEE_FREE);
+ (,,,, feeMode,) = registry.getClientForAddress(senderA);
+ assertEq(feeMode, FEE_FREE);
+ }
+
+ function testInvalidFeeModeReverts() public {
+ address[] memory addresses = new address[](1);
+ addresses[0] = senderA;
+
+ vm.prank(owner);
+ vm.expectRevert(ReservedBlockspaceRegistry.InvalidFeeMode.selector);
+ registry.createClient(admin, 20_000_000, 2, 0, "bad-fee-mode", addresses);
+ }
+
+ // --- new: effectiveFrom ---
+
+ function testEffectiveFromStoredAndUpdatable() public {
+ address[] memory addresses = new address[](1);
+ addresses[0] = senderA;
+
+ vm.prank(owner);
+ uint256 clientId = registry.createClient(admin, 20_000_000, FEE_FREE, 1000, "scheduled-client", addresses);
+
+ (,,,,, uint64 effFrom) = registry.getClientForAddress(senderA);
+ assertEq(effFrom, 1000);
+
+ vm.prank(owner);
+ registry.setClientEffectiveFrom(clientId, 2000);
+ (,,,,, effFrom) = registry.getClientForAddress(senderA);
+ assertEq(effFrom, 2000);
+ }
+
+ // --- new: root() change-epoch ---
+
+ function testRootChangesOnEveryMutation() public {
+ bytes32 r0 = registry.root();
+
+ address[] memory addresses = new address[](1);
+ addresses[0] = senderA;
+ vm.prank(owner);
+ uint256 clientId = registry.createClient(admin, 20_000_000, FEE_FREE, 0, "c", addresses);
+ bytes32 r1 = registry.root();
+ assertTrue(r1 != r0, "root must change after createClient");
+
+ vm.prank(owner);
+ registry.setClientQuota(clientId, 15_000_000);
+ bytes32 r2 = registry.root();
+ assertTrue(r2 != r1, "root must change after setClientQuota");
+
+ vm.prank(owner);
+ registry.setClientFeeMode(clientId, FEE_ROUTED);
+ bytes32 r3 = registry.root();
+ assertTrue(r3 != r2, "root must change after setClientFeeMode");
+
+ vm.prank(owner);
+ registry.setClientActive(clientId, false);
+ bytes32 r4 = registry.root();
+ assertTrue(r4 != r3, "root must change after setClientActive");
+ }
+
+ function _createSingleAddressClient(address account) internal returns (uint256 clientId) {
+ address[] memory addresses = new address[](1);
+ addresses[0] = account;
+
+ vm.prank(owner);
+ clientId = registry.createClient(admin, 20_000_000, FEE_FREE, 0, "Polymarket", addresses);
+ }
+}
diff --git a/tests/bor/reserved_blockspace_test.go b/tests/bor/reserved_blockspace_test.go
new file mode 100644
index 0000000000..51d4a6b4d1
--- /dev/null
+++ b/tests/bor/reserved_blockspace_test.go
@@ -0,0 +1,324 @@
+//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/types"
+ "github.com/ethereum/go-ethereum/crypto"
+ "github.com/ethereum/go-ethereum/eth"
+ "github.com/ethereum/go-ethereum/params"
+)
+
+// reservedRegistrySetupABI is the minimal setter surface the test drives to seed
+// the genesis-deployed registry: claim ownership, then register one client with a
+// whitelisted address. Bor's own embedded ABI is read-only, so the writes live here.
+const reservedRegistrySetupABI = `[
+ {"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"}
+]`
+
+// TestReservedBlockspaceZeroFeeProduction is the end-to-end (in-process devnet)
+// validation of the reserved-blockspace feature against the real registry contract.
+// A two-node bor network produces and cross-verifies blocks; the registry contract
+// is deployed in genesis and seeded at runtime via initialize()+createClient() so a
+// client's address becomes reserved through actual contract state — the same source
+// (registry → Go reader → per-block snapshot) the txpool and EVM classify from in
+// production. A zero-fee tx from that registered address is admitted to the pool,
+// mined past the ReservedBlockspace fork with the reserved header fields set by
+// the real Prepare path, and executes fee-free so the sender pays only its call
+// value with no gas debit. An identical zero-fee tx from an address absent from
+// the registry is rejected at admission.
+//
+// Run with: go test -tags=integration -run TestReservedBlockspaceZeroFeeProduction ./tests/bor/
+func TestReservedBlockspaceZeroFeeProduction(t *testing.T) {
+ faucets := make([]*ecdsa.PrivateKey, 10)
+ for i := range faucets {
+ faucets[i], _ = crypto.GenerateKey()
+ }
+ reservedAddr := crypto.PubkeyToAddress(faucets[0].PublicKey)
+ nonReservedAddr := crypto.PubkeyToAddress(faucets[1].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)
+ // The registry runtime bytecode (solc 0.8.33) uses PUSH0, a Shanghai opcode.
+ // This genesis activates Cancun at 3 but omits Shanghai; activate it at 2 so
+ // the contract is callable before the reserved fork.
+ genesis.Config.ShanghaiBlock = big.NewInt(2)
+ // Deploy the registry contract in genesis (empty storage — ownership and
+ // clients are established at runtime, exactly as on a real network) and point
+ // the chain config at it so the engine wires the reader into txpool/EVM/miner.
+ 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
+ genesis.Alloc[reservedAddr] = types.Account{Balance: new(big.Int).Set(startBalance)}
+ genesis.Alloc[nonReservedAddr] = types.Account{Balance: new(big.Int).Set(startBalance)}
+ 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: initialize() claims ownership, createClient() registers
+ // reservedAddr as the sole whitelisted address of a fee-free client. Both are
+ // ordinary fee-paying txs from the owner; they execute in nonce order.
+ 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)
+ }
+ // Submit to the producer only; p2p propagates to the peer.
+ 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(5_000_000), uint8(0), uint64(0), "e2e", []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())
+
+ // Land the reserved tx inside node0's primary-producer window. With sprint=8
+ // and two validators, node0 is the in-turn producer for blocks 1-8 and 17-24
+ // while node1 produces 9-16 (see bor_test.go). Submitting at the start of the
+ // 17-24 window lets node0 mine the tx canonically with room to spare, so the
+ // chain doesn't reorg it out at the 9/17 producer handover. The parent state by
+ // then long carries the client (createClient mined at block ~4), and the block
+ // is well past the reserved fork (5).
+ 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")
+ zeroFeeTx := func(from *ecdsa.PrivateKey, nonce uint64) *types.Transaction {
+ tx, err := types.SignNewTx(from, signer, &types.DynamicFeeTx{
+ ChainID: genesis.Config.ChainID,
+ Nonce: nonce,
+ GasTipCap: big.NewInt(0),
+ GasFeeCap: big.NewInt(0),
+ Gas: 21000,
+ To: &recipient,
+ Value: big.NewInt(100),
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ return tx
+ }
+
+ // Reserved sender: zero-fee tx must be accepted by the pool.
+ rNonce, err := nodes[0].APIBackend.GetPoolNonce(context.Background(), reservedAddr)
+ if err != nil {
+ t.Fatal(err)
+ }
+ rtx := zeroFeeTx(faucets[0], rNonce)
+ for _, node := range nodes {
+ // Tolerate "already known" from the peer that received it via p2p — what
+ // matters is that admission classified the reserved sender, not rejected it.
+ if err := node.APIBackend.SendTx(context.Background(), rtx); err != nil &&
+ !strings.Contains(err.Error(), "already known") {
+ t.Fatalf("reserved zero-fee tx rejected by pool: %v", err)
+ }
+ }
+
+ // Non-reserved sender: identical zero-fee tx must be rejected at admission.
+ nrNonce, err := nodes[0].APIBackend.GetPoolNonce(context.Background(), nonReservedAddr)
+ if err != nil {
+ t.Fatal(err)
+ }
+ otx := zeroFeeTx(faucets[1], nrNonce)
+ if err := nodes[0].APIBackend.SendTx(context.Background(), otx); err == nil {
+ t.Fatal("non-reserved zero-fee tx should be rejected at admission, but was accepted")
+ }
+
+ firstSeen := waitMined(rtx.Hash(), "reserved zero-fee tx")
+ t.Logf("reserved zero-fee tx first seen in block %d", firstSeen.NumberU64())
+
+ // Let the tip settle: both miners may briefly fork at the tip (the peer can
+ // build a competing block before the reserved tx propagates). Wait for several
+ // confirmations so fork choice converges, then read the tx's settled canonical
+ // block. A real classification split would never converge — the validator would
+ // reject the producer's block as a bad block — so convergence here is itself the
+ // consensus-parity assertion.
+ canonicalTxBlock := func(n *eth.Ethereum) *types.Block {
+ head := n.BlockChain().CurrentBlock().Number.Uint64()
+ for h := firstSeen.NumberU64(); h <= head; h++ {
+ blk := n.BlockChain().GetBlockByNumber(h)
+ if blk == nil {
+ continue
+ }
+ for _, tx := range blk.Transactions() {
+ if tx.Hash() == rtx.Hash() {
+ return blk
+ }
+ }
+ }
+ return nil
+ }
+
+ var includedBlock, peerBlock *types.Block
+ settleDeadline := time.After(60 * time.Second)
+ for {
+ includedBlock = canonicalTxBlock(nodes[0])
+ peerBlock = canonicalTxBlock(nodes[1])
+ if includedBlock != nil && peerBlock != nil &&
+ includedBlock.Hash() == peerBlock.Hash() &&
+ nodes[1].BlockChain().CurrentBlock().Number.Uint64() >= includedBlock.NumberU64()+3 {
+ break
+ }
+ select {
+ case <-settleDeadline:
+ h0 := nodes[0].BlockChain().CurrentBlock()
+ h1 := nodes[1].BlockChain().CurrentBlock()
+ t.Logf("node0 head=%d %s | node1 head=%d %s", h0.Number.Uint64(), h0.Hash(), h1.Number.Uint64(), h1.Hash())
+ if includedBlock != nil {
+ t.Logf("node0 tx-block=%d %s; node1 has it by hash: %v",
+ includedBlock.NumberU64(), includedBlock.Hash(),
+ nodes[1].BlockChain().GetBlockByHash(includedBlock.Hash()) != nil)
+ }
+ if peerBlock != nil {
+ t.Logf("node1 tx-block=%d %s; node0 has it by hash: %v",
+ peerBlock.NumberU64(), peerBlock.Hash(),
+ nodes[0].BlockChain().GetBlockByHash(peerBlock.Hash()) != nil)
+ }
+ // Compare canonical hashes at each height up to the lower head to find
+ // where (if anywhere) the chains forked.
+ lo := h0.Number.Uint64()
+ if h1.Number.Uint64() < lo {
+ lo = h1.Number.Uint64()
+ }
+ for n := uint64(1); n <= lo; n++ {
+ b0 := nodes[0].BlockChain().GetBlockByNumber(n)
+ b1 := nodes[1].BlockChain().GetBlockByNumber(n)
+ if b0 == nil || b1 == nil || b0.Hash() != b1.Hash() {
+ t.Logf("chains first differ at height %d: node0=%v node1=%v", n, b0.Hash(), b1.Hash())
+ break
+ }
+ }
+ t.Fatalf("nodes never converged on the reserved tx block")
+ case <-time.After(200 * time.Millisecond):
+ }
+ }
+ t.Logf("reserved zero-fee tx settled in canonical block %d on both nodes", includedBlock.NumberU64())
+
+ // The producing block must carry the reserved header field (set by Prepare).
+ if rg := includedBlock.Header().GetReservedGasUsed(genesis.Config); rg == nil {
+ t.Fatalf("block %d missing reserved gas used header field", includedBlock.NumberU64())
+ }
+
+ // The reserved sender paid only its call value — no gas was debited — and both
+ // nodes computed the same post-state for the block (identical hash above).
+ state, err := nodes[0].BlockChain().StateAt(includedBlock.Root())
+ if err != nil {
+ t.Fatal(err)
+ }
+ got := state.GetBalance(reservedAddr).ToBig()
+ want := new(big.Int).Sub(startBalance, big.NewInt(100))
+ if got.Cmp(want) != 0 {
+ t.Fatalf("reserved sender balance = %s, want %s (call value only, no gas debit)", got, want)
+ }
+}