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