From 5f7ebba8a124ae87225f81a1a9c827f8a534f2b7 Mon Sep 17 00:00:00 2001 From: Hamdi Allam Date: Wed, 26 Jun 2024 18:00:06 -0400 Subject: [PATCH] Implement `eth_sendRawTransactionConditional` (#330) --- cmd/geth/main.go | 2 + cmd/utils/flags.go | 17 ++ core/state/statedb.go | 34 +++ core/state/statedb_test.go | 218 +++++++++++++++ core/txpool/legacypool/legacypool.go | 14 +- core/txpool/legacypool/legacypool_test.go | 33 +++ core/types/block.go | 17 ++ core/types/block_test.go | 106 ++++++++ .../types/gen_transaction_conditional_json.go | 66 +++++ core/types/transaction.go | 32 ++- core/types/transaction_conditional.go | 126 +++++++++ core/types/transaction_conditional_test.go | 253 ++++++++++++++++++ eth/backend.go | 7 + eth/ethconfig/config.go | 1 + eth/protocols/eth/broadcast.go | 5 +- fork.yaml | 26 ++ internal/ethapi/api.go | 5 + internal/sequencerapi/api.go | 116 ++++++++ miner/miner.go | 12 +- miner/miner_test.go | 55 +++- miner/worker.go | 48 ++++ params/conditional_tx_params.go | 9 + rpc/json.go | 2 + 23 files changed, 1194 insertions(+), 10 deletions(-) create mode 100644 core/types/gen_transaction_conditional_json.go create mode 100644 core/types/transaction_conditional.go create mode 100644 core/types/transaction_conditional_test.go create mode 100644 internal/sequencerapi/api.go create mode 100644 params/conditional_tx_params.go diff --git a/cmd/geth/main.go b/cmd/geth/main.go index 085b892059..3ac62eb355 100644 --- a/cmd/geth/main.go +++ b/cmd/geth/main.go @@ -155,6 +155,8 @@ var ( utils.GpoIgnoreGasPriceFlag, utils.GpoMinSuggestedPriorityFeeFlag, utils.RollupSequencerHTTPFlag, + utils.RollupSequencerEnableTxConditionalFlag, + utils.RollupSequencerTxConditionalRateLimitFlag, utils.RollupHistoricalRPCFlag, utils.RollupHistoricalRPCTimeoutFlag, utils.RollupDisableTxPoolGossipFlag, diff --git a/cmd/utils/flags.go b/cmd/utils/flags.go index f894a11834..74de8611e1 100644 --- a/cmd/utils/flags.go +++ b/cmd/utils/flags.go @@ -976,6 +976,18 @@ var ( Category: flags.RollupCategory, Value: true, } + RollupSequencerEnableTxConditionalFlag = &cli.BoolFlag{ + Name: "rollup.sequencerenabletxconditional", + Usage: "Serve the eth_sendRawTransactionConditional endpoint and apply the conditional constraints on mempool inclusion & block building", + Category: flags.RollupCategory, + Value: false, + } + RollupSequencerTxConditionalRateLimitFlag = &cli.IntFlag{ + Name: "rollup.sequencertxconditionalratelimit", + Usage: "Maximum cost -- storage lookups -- allowed for conditional transactions in a given second", + Category: flags.RollupCategory, + Value: 5000, + } // Metrics flags MetricsEnabledFlag = &cli.BoolFlag{ @@ -1717,6 +1729,9 @@ func setMiner(ctx *cli.Context, cfg *miner.Config) { if ctx.IsSet(RollupComputePendingBlock.Name) { cfg.RollupComputePendingBlock = ctx.Bool(RollupComputePendingBlock.Name) } + + // This flag has a default rate limit so always set + cfg.RollupTransactionConditionalRateLimit = ctx.Int(RollupSequencerTxConditionalRateLimitFlag.Name) } func setRequiredBlocks(ctx *cli.Context, cfg *ethconfig.Config) { @@ -1955,6 +1970,8 @@ func SetEthConfig(ctx *cli.Context, stack *node.Node, cfg *ethconfig.Config) { cfg.RollupDisableTxPoolAdmission = cfg.RollupSequencerHTTP != "" && !ctx.Bool(RollupEnableTxPoolAdmissionFlag.Name) cfg.RollupHaltOnIncompatibleProtocolVersion = ctx.String(RollupHaltOnIncompatibleProtocolVersionFlag.Name) cfg.ApplySuperchainUpgrades = ctx.Bool(RollupSuperchainUpgradesFlag.Name) + cfg.RollupSequencerEnableTxConditional = ctx.Bool(RollupSequencerEnableTxConditionalFlag.Name) + // Override any default configs for hard coded networks. switch { case ctx.Bool(MainnetFlag.Name): diff --git a/core/state/statedb.go b/core/state/statedb.go index 20994d6cfe..160774e441 100644 --- a/core/state/statedb.go +++ b/core/state/statedb.go @@ -425,6 +425,40 @@ func (s *StateDB) HasSelfDestructed(addr common.Address) bool { return false } +// CheckTransactionConditional validates the account preconditions against the statedb. +// +// NOTE: A lock is not held on the db while the conditional is checked. The caller must +// ensure no state changes occur while this check is executed. +func (s *StateDB) CheckTransactionConditional(cond *types.TransactionConditional) error { + cost := cond.Cost() + + // The max cost is an inclusive limit. + if cost > params.TransactionConditionalMaxCost { + return fmt.Errorf("conditional cost, %d, exceeded max: %d", cost, params.TransactionConditionalMaxCost) + } + + for addr, acct := range cond.KnownAccounts { + if root, isRoot := acct.Root(); isRoot { + storageRoot := s.GetStorageRoot(addr) + if storageRoot == (common.Hash{}) { // if the root is not found, replace with the empty root hash + storageRoot = types.EmptyRootHash + } + if root != storageRoot { + return fmt.Errorf("failed account storage root constraint. Got %s, Expected %s", storageRoot, root) + } + } + if slots, isSlots := acct.Slots(); isSlots { + for key, state := range slots { + accState := s.GetState(addr, key) + if state != accState { + return fmt.Errorf("failed account storage slot key %s constraint. Got %s, Expected %s", key, accState, state) + } + } + } + } + return nil +} + /* * SETTERS */ diff --git a/core/state/statedb_test.go b/core/state/statedb_test.go index 2ce2b868fa..7e16e0b58e 100644 --- a/core/state/statedb_test.go +++ b/core/state/statedb_test.go @@ -1373,3 +1373,221 @@ func TestStorageDirtiness(t *testing.T) { state.RevertToSnapshot(snap) checkDirty(common.Hash{0x1}, common.Hash{0x1}, true) } + +func TestCheckTransactionConditional(t *testing.T) { + type preAction struct { + Account common.Address + Slots map[common.Hash]common.Hash + } + + tests := []struct { + name string + preActions []preAction + cond types.TransactionConditional + valid bool + }{ + { + // Clean Prestate, no defined cond + name: "clean prestate", + preActions: []preAction{}, + cond: types.TransactionConditional{}, + valid: true, + }, + { + // Prestate: + // - address(1) + // - bytes32(0): bytes32(1) + // cond: + // - address(1) + // - bytes32(0): bytes32(1) + name: "matching storage slots", + preActions: []preAction{ + { + Account: common.Address{19: 1}, + Slots: map[common.Hash]common.Hash{ + common.Hash{}: common.Hash{31: 1}, + }, + }, + }, + cond: types.TransactionConditional{ + KnownAccounts: map[common.Address]types.KnownAccount{ + common.Address{19: 1}: types.KnownAccount{ + StorageSlots: map[common.Hash]common.Hash{ + common.Hash{}: common.Hash{31: 1}, + }, + }, + }, + }, + valid: true, + }, + { + // Prestate: + // - address(1) + // - bytes32(0): bytes32(1) + // cond: + // - address(1) + // - bytes32(0): bytes32(2) + name: "mismatched storage slots", + preActions: []preAction{ + { + Account: common.Address{19: 1}, + Slots: map[common.Hash]common.Hash{ + common.Hash{}: common.Hash{31: 1}, + }, + }, + }, + cond: types.TransactionConditional{ + KnownAccounts: map[common.Address]types.KnownAccount{ + common.Address{19: 1}: types.KnownAccount{ + StorageSlots: map[common.Hash]common.Hash{ + common.Hash{}: common.Hash{31: 2}, + }, + }, + }, + }, + valid: false, + }, + { + // Clean Prestate + // cond: + // - address(1) + // - emptyRoot + name: "matching storage root", + preActions: []preAction{}, + cond: types.TransactionConditional{ + KnownAccounts: map[common.Address]types.KnownAccount{ + common.Address{19: 1}: types.KnownAccount{ + StorageRoot: &types.EmptyRootHash, + }, + }, + }, + valid: true, + }, + { + // Prestate: + // - address(1) + // - bytes32(0): bytes32(1) + // cond: + // - address(1) + // - emptyRoot + name: "mismatched storage root", + preActions: []preAction{ + { + Account: common.Address{19: 1}, + Slots: map[common.Hash]common.Hash{ + common.Hash{}: common.Hash{31: 1}, + }, + }, + }, + cond: types.TransactionConditional{ + KnownAccounts: map[common.Address]types.KnownAccount{ + common.Address{19: 1}: types.KnownAccount{ + StorageRoot: &types.EmptyRootHash, + }, + }, + }, + valid: false, + }, + { + // Prestate: + // - address(1) + // - bytes32(0): bytes32(1) + // - address(2) + // - bytes32(0): bytes32(2) + // cond: + // - address(1) + // - bytes32(0): bytes32(1) + // - address(2) + // - bytes32(0): bytes32(2) + name: "multiple matching", + preActions: []preAction{ + { + Account: common.Address{19: 1}, + Slots: map[common.Hash]common.Hash{ + common.Hash{}: common.Hash{31: 1}, + }, + }, + { + Account: common.Address{19: 2}, + Slots: map[common.Hash]common.Hash{ + common.Hash{}: common.Hash{31: 2}, + }, + }, + }, + cond: types.TransactionConditional{ + KnownAccounts: map[common.Address]types.KnownAccount{ + common.Address{19: 1}: types.KnownAccount{ + StorageSlots: map[common.Hash]common.Hash{ + common.Hash{}: common.Hash{31: 1}, + }, + }, + common.Address{19: 2}: types.KnownAccount{ + StorageSlots: map[common.Hash]common.Hash{ + common.Hash{}: common.Hash{31: 2}, + }, + }, + }, + }, + valid: true, + }, + { + // Prestate: + // - address(1) + // - bytes32(0): bytes32(1) + // - address(2) + // - bytes32(0): bytes32(3) + // cond: + // - address(1) + // - bytes32(0): bytes32(1) + // - address(2) + // - bytes32(0): bytes32(2) + name: "multiple mismatch single", + preActions: []preAction{ + { + Account: common.Address{19: 1}, + Slots: map[common.Hash]common.Hash{ + common.Hash{}: common.Hash{31: 1}, + }, + }, + { + Account: common.Address{19: 2}, + Slots: map[common.Hash]common.Hash{ + common.Hash{}: common.Hash{31: 3}, + }, + }, + }, + cond: types.TransactionConditional{ + KnownAccounts: map[common.Address]types.KnownAccount{ + common.Address{19: 1}: types.KnownAccount{ + StorageSlots: map[common.Hash]common.Hash{ + common.Hash{}: common.Hash{31: 1}, + }, + }, + common.Address{19: 2}: types.KnownAccount{ + StorageSlots: map[common.Hash]common.Hash{ + common.Hash{}: common.Hash{31: 2}, + }, + }, + }, + }, + valid: false, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + state, _ := New(types.EmptyRootHash, NewDatabase(rawdb.NewMemoryDatabase()), nil) + for _, action := range test.preActions { + for key, value := range action.Slots { + state.SetState(action.Account, key, value) + } + } + + // write modifications to the trie + state.IntermediateRoot(false) + if err := state.CheckTransactionConditional(&test.cond); err == nil && !test.valid { + t.Errorf("Test %s got unvalid value: want %v, got err %v", test.name, test.valid, err) + } + }) + } +} diff --git a/core/txpool/legacypool/legacypool.go b/core/txpool/legacypool/legacypool.go index 6e004ed356..5b43f97415 100644 --- a/core/txpool/legacypool/legacypool.go +++ b/core/txpool/legacypool/legacypool.go @@ -1733,6 +1733,16 @@ func (pool *LegacyPool) demoteUnexecutables() { } pendingNofundsMeter.Mark(int64(len(drops))) + // Drop all transactions that were rejected by the miner + rejectedDrops := list.txs.Filter(func(tx *types.Transaction) bool { + return tx.Rejected() + }) + for _, tx := range rejectedDrops { + hash := tx.Hash() + pool.all.Remove(hash) + log.Trace("Removed rejected transaction", "hash", hash) + } + for _, tx := range invalids { hash := tx.Hash() log.Trace("Demoting pending transaction", "hash", hash) @@ -1740,9 +1750,9 @@ func (pool *LegacyPool) demoteUnexecutables() { // Internal shuffle shouldn't touch the lookup set. pool.enqueueTx(hash, tx, false, false) } - pendingGauge.Dec(int64(len(olds) + len(drops) + len(invalids))) + pendingGauge.Dec(int64(len(olds) + len(drops) + len(invalids) + len(rejectedDrops))) if pool.locals.contains(addr) { - localGauge.Dec(int64(len(olds) + len(drops) + len(invalids))) + localGauge.Dec(int64(len(olds) + len(drops) + len(invalids) + len(rejectedDrops))) } // If there's a gap in front, alert (should never happen) and postpone all transactions if list.Len() > 0 && list.txs.Get(nonce) == nil { diff --git a/core/txpool/legacypool/legacypool_test.go b/core/txpool/legacypool/legacypool_test.go index c71544b48c..0240d93e14 100644 --- a/core/txpool/legacypool/legacypool_test.go +++ b/core/txpool/legacypool/legacypool_test.go @@ -690,6 +690,39 @@ func TestDropping(t *testing.T) { } } +// Tests that transactions marked as reject (by the miner in practice) +// are removed from the pool +func TestRejectedDropping(t *testing.T) { + t.Parallel() + + pool, key := setupPool() + defer pool.Close() + + account := crypto.PubkeyToAddress(key.PublicKey) + testAddBalance(pool, account, big.NewInt(1000)) + + // create some txs. tx0 has a conditional + tx0, tx1 := transaction(0, 100, key), transaction(1, 200, key) + + pool.all.Add(tx0, false) + pool.all.Add(tx1, false) + pool.promoteTx(account, tx0.Hash(), tx0) + pool.promoteTx(account, tx1.Hash(), tx1) + + // pool state is unchanged + <-pool.requestReset(nil, nil) + if pool.all.Count() != 2 { + t.Errorf("total transaction mismatch: have %d, want %d", pool.all.Count(), 2) + } + + // tx0 conditional is marked as rejected and should be removed + tx0.SetRejected() + <-pool.requestReset(nil, nil) + if pool.all.Count() != 1 { + t.Errorf("total transaction mismatch: have %d, want %d", pool.all.Count(), 1) + } +} + // Tests that if a transaction is dropped from the current pending pool (e.g. out // of fund), all consecutive (still valid, but not executable) transactions are // postponed back into the future queue to prevent broadcasting them. diff --git a/core/types/block.go b/core/types/block.go index 4857cd6e50..3773a12853 100644 --- a/core/types/block.go +++ b/core/types/block.go @@ -166,6 +166,23 @@ func (h *Header) EmptyReceipts() bool { return h.ReceiptHash == EmptyReceiptsHash } +// CheckTransactionConditional validates the block preconditions against the header +func (h *Header) CheckTransactionConditional(cond *TransactionConditional) error { + if cond.BlockNumberMin != nil && cond.BlockNumberMin.Cmp(h.Number) > 0 { + return fmt.Errorf("failed block number minimum constraint") + } + if cond.BlockNumberMax != nil && cond.BlockNumberMax.Cmp(h.Number) < 0 { + return fmt.Errorf("failed block number maximmum constraint") + } + if cond.TimestampMin != nil && *cond.TimestampMin > h.Time { + return fmt.Errorf("failed timestamp minimum constraint") + } + if cond.TimestampMax != nil && *cond.TimestampMax < h.Time { + return fmt.Errorf("failed timestamp maximum constraint") + } + return nil +} + // Body is a simple (mutable, non-safe) data container for storing and moving // a block's data contents (transactions and uncles) together. type Body struct { diff --git a/core/types/block_test.go b/core/types/block_test.go index 1af5b9d7bf..16cb64fa78 100644 --- a/core/types/block_test.go +++ b/core/types/block_test.go @@ -317,3 +317,109 @@ func TestRlpDecodeParentHash(t *testing.T) { } } } + +func TestCheckTransactionConditional(t *testing.T) { + u64Ptr := func(n uint64) *uint64 { + return &n + } + + tests := []struct { + name string + header Header + cond TransactionConditional + valid bool + }{ + { + "BlockNumberMaxFails", + Header{Number: big.NewInt(2)}, + TransactionConditional{BlockNumberMax: big.NewInt(1)}, + false, + }, + { + "BlockNumberMaxEqualSucceeds", + Header{Number: big.NewInt(2)}, + TransactionConditional{BlockNumberMax: big.NewInt(2)}, + true, + }, + { + "BlockNumberMaxSucceeds", + Header{Number: big.NewInt(1)}, + TransactionConditional{BlockNumberMax: big.NewInt(2)}, + true, + }, + { + "BlockNumberMinFails", + Header{Number: big.NewInt(1)}, + TransactionConditional{BlockNumberMin: big.NewInt(2)}, + false, + }, + { + "BlockNumberMinEqualSuccess", + Header{Number: big.NewInt(2)}, + TransactionConditional{BlockNumberMin: big.NewInt(2)}, + true, + }, + { + "BlockNumberMinSuccess", + Header{Number: big.NewInt(4)}, + TransactionConditional{BlockNumberMin: big.NewInt(3)}, + true, + }, + { + "BlockNumberRangeSucceeds", + Header{Number: big.NewInt(5)}, + TransactionConditional{BlockNumberMin: big.NewInt(1), BlockNumberMax: big.NewInt(10)}, + true, + }, + { + "BlockNumberRangeFails", + Header{Number: big.NewInt(15)}, + TransactionConditional{BlockNumberMin: big.NewInt(1), BlockNumberMax: big.NewInt(10)}, + false, + }, + { + "BlockTimestampMinFails", + Header{Time: 1}, + TransactionConditional{TimestampMin: u64Ptr(2)}, + false, + }, + { + "BlockTimestampMinSucceeds", + Header{Time: 2}, + TransactionConditional{TimestampMin: u64Ptr(1)}, + true, + }, + { + "BlockTimestampMinEqualSucceeds", + Header{Time: 1}, + TransactionConditional{TimestampMin: u64Ptr(1)}, + true, + }, + { + "BlockTimestampMaxFails", + Header{Time: 2}, + TransactionConditional{TimestampMax: u64Ptr(1)}, + false, + }, + { + "BlockTimestampMaxSucceeds", + Header{Time: 1}, + TransactionConditional{TimestampMax: u64Ptr(2)}, + true, + }, + { + "BlockTimestampMaxEqualSucceeds", + Header{Time: 2}, + TransactionConditional{TimestampMax: u64Ptr(2)}, + true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if err := test.header.CheckTransactionConditional(&test.cond); err == nil && !test.valid { + t.Errorf("Test %s got unvalid value, want %v, got err %v", test.name, test.valid, err) + } + }) + } +} diff --git a/core/types/gen_transaction_conditional_json.go b/core/types/gen_transaction_conditional_json.go new file mode 100644 index 0000000000..b168b60206 --- /dev/null +++ b/core/types/gen_transaction_conditional_json.go @@ -0,0 +1,66 @@ +// Code generated by github.com/fjl/gencodec. DO NOT EDIT. + +package types + +import ( + "encoding/json" + "math/big" + "bytes" + + "github.com/ethereum/go-ethereum/common/hexutil" +) + +var _ = (*transactionConditionalMarshalling)(nil) + +// MarshalJSON marshals as JSON. +func (t TransactionConditional) MarshalJSON() ([]byte, error) { + type TransactionConditional struct { + KnownAccounts KnownAccounts `json:"knownAccounts"` + BlockNumberMin *hexutil.Big `json:"blockNumberMin,omitempty"` + BlockNumberMax *hexutil.Big `json:"blockNumberMax,omitempty"` + TimestampMin *hexutil.Uint64 `json:"timestampMin,omitempty"` + TimestampMax *hexutil.Uint64 `json:"timestampMax,omitempty"` + } + var enc TransactionConditional + enc.KnownAccounts = t.KnownAccounts + enc.BlockNumberMin = (*hexutil.Big)(t.BlockNumberMin) + enc.BlockNumberMax = (*hexutil.Big)(t.BlockNumberMax) + enc.TimestampMin = (*hexutil.Uint64)(t.TimestampMin) + enc.TimestampMax = (*hexutil.Uint64)(t.TimestampMax) + return json.Marshal(&enc) +} + +// UnmarshalJSON unmarshals from JSON. +func (t *TransactionConditional) UnmarshalJSON(input []byte) error { + type TransactionConditional struct { + KnownAccounts *KnownAccounts `json:"knownAccounts"` + BlockNumberMin *hexutil.Big `json:"blockNumberMin,omitempty"` + BlockNumberMax *hexutil.Big `json:"blockNumberMax,omitempty"` + TimestampMin *hexutil.Uint64 `json:"timestampMin,omitempty"` + TimestampMax *hexutil.Uint64 `json:"timestampMax,omitempty"` + } + var dec TransactionConditional + // --- Not Generated. Disallow unknown fields + decoder := json.NewDecoder(bytes.NewReader(input)) + decoder.DisallowUnknownFields() // Force errors + // --- + if err := decoder.Decode(&dec); err != nil { + return err + } + if dec.KnownAccounts != nil { + t.KnownAccounts = *dec.KnownAccounts + } + if dec.BlockNumberMin != nil { + t.BlockNumberMin = (*big.Int)(dec.BlockNumberMin) + } + if dec.BlockNumberMax != nil { + t.BlockNumberMax = (*big.Int)(dec.BlockNumberMax) + } + if dec.TimestampMin != nil { + t.TimestampMin = (*uint64)(dec.TimestampMin) + } + if dec.TimestampMax != nil { + t.TimestampMax = (*uint64)(dec.TimestampMax) + } + return nil +} diff --git a/core/types/transaction.go b/core/types/transaction.go index d0fa5308f0..f685bb46cc 100644 --- a/core/types/transaction.go +++ b/core/types/transaction.go @@ -64,6 +64,12 @@ type Transaction struct { // cache of details to compute the data availability fee rollupCostData atomic.Value + + // optional preconditions for inclusion + conditional atomic.Pointer[TransactionConditional] + + // an indicator if this transaction is rejected during block building + rejected atomic.Bool } // NewTx creates a new transaction. @@ -387,6 +393,26 @@ func (tx *Transaction) RollupCostData() RollupCostData { return out } +// Conditional returns the conditional attached to the transaction +func (tx *Transaction) Conditional() *TransactionConditional { + return tx.conditional.Load() +} + +// SetConditional attaches a conditional to the transaction +func (tx *Transaction) SetConditional(cond *TransactionConditional) { + tx.conditional.Store(cond) +} + +// Rejected will mark this transaction as rejected. +func (tx *Transaction) SetRejected() { + tx.rejected.Store(true) +} + +// Rejected returns the rejected status of this tx +func (tx *Transaction) Rejected() bool { + return tx.rejected.Load() +} + // RawSignatureValues returns the V, R, S signature values of the transaction. // The return values should not be modified by the caller. // The return values may be nil or zero, if the transaction is unsigned. @@ -549,9 +575,9 @@ func (tx *Transaction) WithBlobTxSidecar(sideCar *BlobTxSidecar) *Transaction { return cpy } -// SetTime sets the decoding time of a transaction. This is used by tests to set -// arbitrary times and by persistent transaction pools when loading old txs from -// disk. +// SetTime sets the decoding time of a transaction. Used by the sequencer API to +// determine mempool time spent by conditional txs and by tests to set arbitrary +// times and by persistent transaction pools when loading old txs from disk. func (tx *Transaction) SetTime(t time.Time) { tx.time = t } diff --git a/core/types/transaction_conditional.go b/core/types/transaction_conditional.go new file mode 100644 index 0000000000..90a77c7d81 --- /dev/null +++ b/core/types/transaction_conditional.go @@ -0,0 +1,126 @@ +package types + +import ( + "encoding/json" + "fmt" + "math/big" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" +) + +// KnownAccounts represents a set of KnownAccounts +type KnownAccounts map[common.Address]KnownAccount + +// KnownAccount allows for a user to express their preference of a known +// prestate at a particular account. Only one of the storage root or +// storage slots is allowed to be set. If the storage root is set, then +// the user prefers their transaction to only be included in a block if +// the account's storage root matches. If the storage slots are set, +// then the user prefers their transaction to only be included if the +// particular storage slot values from state match. +type KnownAccount struct { + StorageRoot *common.Hash + StorageSlots map[common.Hash]common.Hash +} + +// UnmarshalJSON will parse the JSON bytes into a KnownAccount struct. +func (ka *KnownAccount) UnmarshalJSON(data []byte) error { + var hash common.Hash + if err := json.Unmarshal(data, &hash); err == nil { + ka.StorageRoot = &hash + ka.StorageSlots = make(map[common.Hash]common.Hash) + return nil + } + + var mapping map[common.Hash]common.Hash + if err := json.Unmarshal(data, &mapping); err != nil { + return err + } + ka.StorageSlots = mapping + return nil +} + +// MarshalJSON will serialize the KnownAccount into JSON bytes. +func (ka *KnownAccount) MarshalJSON() ([]byte, error) { + if ka.StorageRoot != nil { + return json.Marshal(ka.StorageRoot) + } + return json.Marshal(ka.StorageSlots) +} + +// Root will return the storage root and true when the user prefers +// execution against an account's storage root, otherwise it will +// return false. +func (ka *KnownAccount) Root() (common.Hash, bool) { + if ka.StorageRoot == nil { + return common.Hash{}, false + } + return *ka.StorageRoot, true +} + +// Slots will return the storage slots and true when the user prefers +// execution against an account's particular storage slots, StorageRoot == nil, +// otherwise it will return false. +func (ka *KnownAccount) Slots() (map[common.Hash]common.Hash, bool) { + if ka.StorageRoot != nil { + return ka.StorageSlots, false + } + return ka.StorageSlots, true +} + +//go:generate go run github.com/fjl/gencodec -type TransactionConditional -field-override transactionConditionalMarshalling -out gen_transaction_conditional_json.go + +// TransactionConditional represents the preconditions that determine the +// inclusion of the transaction, enforced out-of-protocol by the sequencer. +type TransactionConditional struct { + // KnownAccounts represents account prestate conditions + KnownAccounts KnownAccounts `json:"knownAccounts"` + + // Header state conditionals (inclusive ranges) + BlockNumberMin *big.Int `json:"blockNumberMin,omitempty"` + BlockNumberMax *big.Int `json:"blockNumberMax,omitempty"` + TimestampMin *uint64 `json:"timestampMin,omitempty"` + TimestampMax *uint64 `json:"timestampMax,omitempty"` +} + +// field type overrides for gencodec +type transactionConditionalMarshalling struct { + BlockNumberMax *hexutil.Big + BlockNumberMin *hexutil.Big + TimestampMin *hexutil.Uint64 + TimestampMax *hexutil.Uint64 +} + +// Validate will perform sanity checks on the preconditions. This does not check the aggregate cost of the preconditions. +func (cond *TransactionConditional) Validate() error { + if cond.BlockNumberMin != nil && cond.BlockNumberMax != nil && cond.BlockNumberMin.Cmp(cond.BlockNumberMax) > 0 { + return fmt.Errorf("block number minimum constraint must be less than the maximum") + } + if cond.TimestampMin != nil && cond.TimestampMax != nil && *cond.TimestampMin > *cond.TimestampMax { + return fmt.Errorf("timestamp minimum constraint must be less than the maximum") + } + return nil +} + +// Cost computes the aggregate cost of the preconditions; total number of storage lookups required +func (cond *TransactionConditional) Cost() int { + cost := 0 + for _, account := range cond.KnownAccounts { + // default cost to handle empty accounts + cost += 1 + if _, isRoot := account.Root(); isRoot { + cost += 1 + } + if slots, isSlots := account.Slots(); isSlots { + cost += len(slots) + } + } + if cond.BlockNumberMin != nil || cond.BlockNumberMax != nil { + cost += 1 + } + if cond.TimestampMin != nil || cond.TimestampMax != nil { + cost += 1 + } + return cost +} diff --git a/core/types/transaction_conditional_test.go b/core/types/transaction_conditional_test.go new file mode 100644 index 0000000000..f7e3bb7ef1 --- /dev/null +++ b/core/types/transaction_conditional_test.go @@ -0,0 +1,253 @@ +package types + +import ( + "encoding/json" + "math/big" + "reflect" + "testing" + + "github.com/ethereum/go-ethereum/common" +) + +func TestTransactionConditionalCost(t *testing.T) { + uint64Ptr := func(num uint64) *uint64 { + return &num + } + + tests := []struct { + name string + cond TransactionConditional + cost int + }{ + { + name: "empty conditional", + cond: TransactionConditional{}, + cost: 0, + }, + { + name: "block number lookup counts once", + cond: TransactionConditional{BlockNumberMin: big.NewInt(1), BlockNumberMax: big.NewInt(2)}, + cost: 1, + }, + { + name: "timestamp lookup counts once", + cond: TransactionConditional{TimestampMin: uint64Ptr(0), TimestampMax: uint64Ptr(5)}, + cost: 1, + }, + { + name: "default cost per account", + cond: TransactionConditional{KnownAccounts: map[common.Address]KnownAccount{ + common.Address{19: 1}: KnownAccount{}, + common.Address{19: 2}: KnownAccount{}, + }}, + cost: 2, + }, + { + name: "storage root lookup", + cond: TransactionConditional{KnownAccounts: map[common.Address]KnownAccount{ + common.Address{19: 1}: KnownAccount{ + StorageRoot: &EmptyRootHash, + }}}, + cost: 2, + }, + { + name: "cost per storage slot lookup", + cond: TransactionConditional{KnownAccounts: map[common.Address]KnownAccount{ + common.Address{19: 1}: KnownAccount{ + StorageSlots: map[common.Hash]common.Hash{ + common.Hash{}: common.Hash{31: 1}, + common.Hash{31: 1}: common.Hash{31: 1}, + }, + }}}, + cost: 3, + }, + { + name: "cost summed together", + cond: TransactionConditional{ + BlockNumberMin: big.NewInt(1), + TimestampMin: uint64Ptr(1), + KnownAccounts: map[common.Address]KnownAccount{ + common.Address{19: 1}: KnownAccount{StorageRoot: &EmptyRootHash}, + common.Address{19: 2}: KnownAccount{ + StorageSlots: map[common.Hash]common.Hash{ + common.Hash{}: common.Hash{31: 1}, + common.Hash{31: 1}: common.Hash{31: 1}, + }, + }}}, + cost: 7, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + cost := test.cond.Cost() + if cost != test.cost { + t.Errorf("Test %s mismatch in TransactionConditional cost. Got %d, Expected: %d", test.name, cost, test.cost) + } + }) + } +} + +func TestTransactionConditionalValidation(t *testing.T) { + uint64Ptr := func(num uint64) *uint64 { + return &num + } + + tests := []struct { + name string + cond TransactionConditional + mustFail bool + }{ + { + name: "empty conditional", + cond: TransactionConditional{}, + mustFail: false, + }, + { + name: "equal block constraint", + cond: TransactionConditional{BlockNumberMin: big.NewInt(1), BlockNumberMax: big.NewInt(1)}, + mustFail: false, + }, + { + name: "block min greater than max", + cond: TransactionConditional{BlockNumberMin: big.NewInt(2), BlockNumberMax: big.NewInt(1)}, + mustFail: true, + }, + { + name: "equal timestamp constraint", + cond: TransactionConditional{TimestampMin: uint64Ptr(1), TimestampMax: uint64Ptr(1)}, + mustFail: false, + }, + { + name: "timestamp min greater than max", + cond: TransactionConditional{TimestampMin: uint64Ptr(2), TimestampMax: uint64Ptr(1)}, + mustFail: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := test.cond.Validate() + if test.mustFail && err == nil { + t.Errorf("Test %s should fail", test.name) + } + if !test.mustFail && err != nil { + t.Errorf("Test %s should pass but got err: %v", test.name, err) + } + }) + } +} + +func TestTransactionConditionalSerDeser(t *testing.T) { + uint64Ptr := func(num uint64) *uint64 { + return &num + } + hashPtr := func(hash common.Hash) *common.Hash { + return &hash + } + + tests := []struct { + name string + input string + mustFail bool + expected TransactionConditional + }{ + { + name: "StateRoot", + input: `{"knownAccounts":{"0x6b3A8798E5Fb9fC5603F3aB5eA2e8136694e55d0":"0x290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e563"}}`, + mustFail: false, + expected: TransactionConditional{ + KnownAccounts: map[common.Address]KnownAccount{ + common.HexToAddress("0x6b3A8798E5Fb9fC5603F3aB5eA2e8136694e55d0"): KnownAccount{ + StorageRoot: hashPtr(common.HexToHash("0x290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e563")), + StorageSlots: make(map[common.Hash]common.Hash), + }, + }, + }, + }, + { + name: "StorageSlots", + input: `{"knownAccounts":{"0x6b3A8798E5Fb9fC5603F3aB5eA2e8136694e55d0":{"0xc65a7bb8d6351c1cf70c95a316cc6a92839c986682d98bc35f958f4883f9d2a8":"0x0000000000000000000000000000000000000000000000000000000000000000"}}}`, + mustFail: false, + expected: TransactionConditional{ + KnownAccounts: map[common.Address]KnownAccount{ + common.HexToAddress("0x6b3A8798E5Fb9fC5603F3aB5eA2e8136694e55d0"): KnownAccount{ + StorageRoot: nil, + StorageSlots: map[common.Hash]common.Hash{ + common.HexToHash("0xc65a7bb8d6351c1cf70c95a316cc6a92839c986682d98bc35f958f4883f9d2a8"): common.HexToHash("0x"), + }, + }, + }, + }, + }, + { + name: "EmptyObject", + input: `{"knownAccounts":{}}`, + mustFail: false, + expected: TransactionConditional{ + KnownAccounts: make(map[common.Address]KnownAccount), + }, + }, + { + name: "EmptyStrings", + input: `{"knownAccounts":{"":""}}`, + mustFail: true, + expected: TransactionConditional{KnownAccounts: nil}, + }, + { + name: "BlockNumberMin", + input: `{"blockNumberMin":"0x1"}`, + mustFail: false, + expected: TransactionConditional{ + BlockNumberMin: big.NewInt(1), + }, + }, + { + name: "BlockNumberMax", + input: `{"blockNumberMin":"0x1", "blockNumberMax":"0x2"}`, + mustFail: false, + expected: TransactionConditional{ + BlockNumberMin: big.NewInt(1), + BlockNumberMax: big.NewInt(2), + }, + }, + { + name: "TimestampMin", + input: `{"timestampMin":"0xffff"}`, + mustFail: false, + expected: TransactionConditional{ + TimestampMin: uint64Ptr(uint64(0xffff)), + }, + }, + { + name: "TimestampMax", + input: `{"timestampMax":"0xffffff"}`, + mustFail: false, + expected: TransactionConditional{ + TimestampMax: uint64Ptr(uint64(0xffffff)), + }, + }, + { + name: "UnknownField", + input: `{"foobarbaz": 1234}`, + mustFail: true, + expected: TransactionConditional{KnownAccounts: nil}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var cond TransactionConditional + err := json.Unmarshal([]byte(test.input), &cond) + if test.mustFail && err == nil { + t.Errorf("Test %s should fail", test.name) + } + if !test.mustFail && err != nil { + t.Errorf("Test %s should pass but got err: %v", test.name, err) + } + if !reflect.DeepEqual(cond, test.expected) { + t.Errorf("Test %s got unexpected value, want %#v, got %#v", test.name, test.expected, cond) + } + }) + } +} diff --git a/eth/backend.go b/eth/backend.go index 7081c6cfb5..d0dd93ec4a 100644 --- a/eth/backend.go +++ b/eth/backend.go @@ -48,6 +48,7 @@ import ( "github.com/ethereum/go-ethereum/ethdb" "github.com/ethereum/go-ethereum/event" "github.com/ethereum/go-ethereum/internal/ethapi" + "github.com/ethereum/go-ethereum/internal/sequencerapi" "github.com/ethereum/go-ethereum/internal/shutdowncheck" "github.com/ethereum/go-ethereum/log" "github.com/ethereum/go-ethereum/miner" @@ -374,6 +375,12 @@ func (s *Ethereum) APIs() []rpc.API { // Append any APIs exposed explicitly by the consensus engine apis = append(apis, s.engine.APIs(s.BlockChain())...) + // Append any Sequencer APIs as enabled + if s.config.RollupSequencerEnableTxConditional { + log.Info("Enabling eth_sendRawTransactionConditional endpoint support") + apis = append(apis, sequencerapi.GetSendRawTxConditionalAPI(s.APIBackend, s.seqRPCService)) + } + // Append all the local APIs and return return append(apis, []rpc.API{ { diff --git a/eth/ethconfig/config.go b/eth/ethconfig/config.go index 2dc03013d5..28849a8519 100644 --- a/eth/ethconfig/config.go +++ b/eth/ethconfig/config.go @@ -184,6 +184,7 @@ type Config struct { ApplySuperchainUpgrades bool `toml:",omitempty"` RollupSequencerHTTP string + RollupSequencerEnableTxConditional bool RollupHistoricalRPC string RollupHistoricalRPCTimeout time.Duration RollupDisableTxPoolGossip bool diff --git a/eth/protocols/eth/broadcast.go b/eth/protocols/eth/broadcast.go index f0ed1d6bc9..e41e0aaf74 100644 --- a/eth/protocols/eth/broadcast.go +++ b/eth/protocols/eth/broadcast.go @@ -47,7 +47,10 @@ func (p *Peer) broadcastTransactions() { size common.StorageSize ) for i := 0; i < len(queue) && size < maxTxPacketSize; i++ { - if tx := p.txpool.Get(queue[i]); tx != nil { + // Transaction conditionals are tied to the block builder it was + // submitted to. Thus we do not broadcast transactions to peers that + // have a conditional attached to them. + if tx := p.txpool.Get(queue[i]); tx != nil && tx.Conditional() == nil { txs = append(txs, tx) size += common.StorageSize(tx.Size()) } diff --git a/fork.yaml b/fork.yaml index 134b463ca3..1f8f790bde 100644 --- a/fork.yaml +++ b/fork.yaml @@ -275,6 +275,32 @@ def: and the chain ID formula on signature data must not be used, or an underflow happens. globs: - "internal/ethapi/testdata/eth_getBlockByNumber-tag-pending-fullTx.json" + - title: "4337 Improvements" + description: "" + sub: + - title: eth_sendRawTransactionConditional + description: sequencer api for conditional transaction inclusion enforced out of protocol + globs: + - "cmd/geth/main.go" + - "cmd/utils/flags.go" + - "core/state/statedb.go" + - "core/state/statedb_test.go" + - "core/types/block.go" + - "core/types/block_test.go" + - "core/types/transaction.go" + - "core/types/transaction_conditional.go" + - "core/types/transaction_conditional.go" + - "core/types/transaction_conditional_test.go" + - "core/types/gen_transaction_conditional_json.go" + - "eth/backend.go" + - "eth/ethconfig/config.go" + - "eth/protocols/eth/broadcast.go" + - "internal/sequencerapi/api.go" + - "miner/miner.go" + - "miner/miner_test.go" + - "miner/worker.go" + - "params/conditional_tx_params.go" + - "rpc/json.go" - title: "Geth extras" description: Extend the tools available in geth to improve external testing and tooling. sub: diff --git a/internal/ethapi/api.go b/internal/ethapi/api.go index f6d8cef15f..fa652d0cdd 100644 --- a/internal/ethapi/api.go +++ b/internal/ethapi/api.go @@ -2415,3 +2415,8 @@ func checkTxFee(gasPrice *big.Int, gas uint64, cap float64) error { } return nil } + +// CheckTxFee exports a helper function used to check whether the fee is reasonable +func CheckTxFee(gasPrice *big.Int, gas uint64, cap float64) error { + return checkTxFee(gasPrice, gas, cap) +} diff --git a/internal/sequencerapi/api.go b/internal/sequencerapi/api.go new file mode 100644 index 0000000000..7d262590ac --- /dev/null +++ b/internal/sequencerapi/api.go @@ -0,0 +1,116 @@ +package sequencerapi + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/internal/ethapi" + "github.com/ethereum/go-ethereum/metrics" + "github.com/ethereum/go-ethereum/params" + "github.com/ethereum/go-ethereum/rpc" +) + +var ( + sendRawTxConditionalCostMeter = metrics.NewRegisteredMeter("sequencer/sendRawTransactionConditional/cost", nil) + sendRawTxConditionalRequestsCounter = metrics.NewRegisteredCounter("sequencer/sendRawTransactionConditional/requests", nil) + sendRawTxConditionalAcceptedCounter = metrics.NewRegisteredCounter("sequencer/sendRawTransactionConditional/accepted", nil) +) + +type sendRawTxCond struct { + b ethapi.Backend + seqRPC *rpc.Client +} + +func GetSendRawTxConditionalAPI(b ethapi.Backend, seqRPC *rpc.Client) rpc.API { + return rpc.API{ + Namespace: "eth", + Service: &sendRawTxCond{b, seqRPC}, + } +} + +func (s *sendRawTxCond) SendRawTransactionConditional(ctx context.Context, txBytes hexutil.Bytes, cond types.TransactionConditional) (common.Hash, error) { + sendRawTxConditionalRequestsCounter.Inc(1) + + cost := cond.Cost() + sendRawTxConditionalCostMeter.Mark(int64(cost)) + if cost > params.TransactionConditionalMaxCost { + return common.Hash{}, &rpc.JsonError{ + Message: fmt.Sprintf("conditional cost, %d, exceeded max: %d", cost, params.TransactionConditionalMaxCost), + Code: params.TransactionConditionalCostExceededMaxErrCode, + } + } + + // Perform sanity validation prior to state lookups + if err := cond.Validate(); err != nil { + return common.Hash{}, &rpc.JsonError{ + Message: fmt.Sprintf("failed conditional validation: %s", err), + Code: params.TransactionConditionalRejectedErrCode, + } + } + + state, header, err := s.b.StateAndHeaderByNumber(ctx, rpc.LatestBlockNumber) + if err != nil { + return common.Hash{}, err + } + if err := header.CheckTransactionConditional(&cond); err != nil { + return common.Hash{}, &rpc.JsonError{ + Message: fmt.Sprintf("failed header check: %s", err), + Code: params.TransactionConditionalRejectedErrCode, + } + } + if err := state.CheckTransactionConditional(&cond); err != nil { + return common.Hash{}, &rpc.JsonError{ + Message: fmt.Sprintf("failed state check: %s", err), + Code: params.TransactionConditionalRejectedErrCode, + } + } + + // State is checked against an older block to remove the MEV incentive for this endpoint compared with sendRawTransaction + parentBlock := rpc.BlockNumberOrHash{BlockHash: &header.ParentHash} + parentState, _, err := s.b.StateAndHeaderByNumberOrHash(ctx, parentBlock) + if err != nil { + return common.Hash{}, err + } + if err := parentState.CheckTransactionConditional(&cond); err != nil { + return common.Hash{}, &rpc.JsonError{ + Message: fmt.Sprintf("failed parent block %s state check: %s", header.ParentHash, err), + Code: params.TransactionConditionalRejectedErrCode, + } + } + + tx := new(types.Transaction) + if err := tx.UnmarshalBinary(txBytes); err != nil { + return common.Hash{}, err + } + + // forward if seqRPC is set, otherwise submit the tx + if s.seqRPC != nil { + // Some precondition checks done by `ethapi.SubmitTransaction` that are good to also check here + if err := ethapi.CheckTxFee(tx.GasPrice(), tx.Gas(), s.b.RPCTxFeeCap()); err != nil { + return common.Hash{}, err + } + if !s.b.UnprotectedAllowed() && !tx.Protected() { + // Ensure only eip155 signed transactions are submitted if EIP155Required is set. + return common.Hash{}, errors.New("only replay-protected (EIP-155) transactions allowed over RPC") + } + + var hash common.Hash + err := s.seqRPC.CallContext(ctx, &hash, "eth_sendRawTransactionConditional", txBytes, cond) + return hash, err + } else { + // Set out-of-consensus internal tx fields + tx.SetTime(time.Now()) + tx.SetConditional(&cond) + + // `SubmitTransaction` which forwards to `b.SendTx` also checks if its internal `seqRPC` client is + // set. Since both of these client are constructed when `RollupSequencerHTTP` is supplied, the above + // block ensures that we're only adding to the txpool for this node. + sendRawTxConditionalAcceptedCounter.Inc(1) + return ethapi.SubmitTransaction(ctx, s.b, tx) + } +} diff --git a/miner/miner.go b/miner/miner.go index 8368b96e15..2e14bb10fe 100644 --- a/miner/miner.go +++ b/miner/miner.go @@ -33,6 +33,7 @@ import ( "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/eth/tracers" "github.com/ethereum/go-ethereum/params" + "golang.org/x/time/rate" ) // Backend wraps all methods required for mining. Only full node is capable @@ -55,8 +56,10 @@ type Config struct { GasPrice *big.Int // Minimum gas price for mining a transaction Recommit time.Duration // The time interval for miner to re-create mining work. - RollupComputePendingBlock bool // Compute the pending block from tx-pool, instead of copying the latest-block - EffectiveGasCeil uint64 // if non-zero, a gas ceiling to apply independent of the header's gaslimit value + RollupComputePendingBlock bool // Compute the pending block from tx-pool, instead of copying the latest-block + RollupTransactionConditionalRateLimit int // Total number of conditional cost units allowed in a second + + EffectiveGasCeil uint64 // if non-zero, a gas ceiling to apply independent of the header's gaslimit value } // DefaultConfig contains default settings for miner. @@ -84,6 +87,9 @@ type Miner struct { pendingMu sync.Mutex // Lock protects the pending block backend Backend + + // TransactionConditional safegaurds + conditionalLimiter *rate.Limiter } // New creates a new miner with provided config. @@ -96,6 +102,8 @@ func New(eth Backend, config Config, engine consensus.Engine) *Miner { txpool: eth.TxPool(), chain: eth.BlockChain(), pending: &pending{}, + // setup the rate limit imposed on conditional transactions when block building + conditionalLimiter: rate.NewLimiter(rate.Limit(config.RollupTransactionConditionalRateLimit), params.TransactionConditionalMaxCost), } } diff --git a/miner/miner_test.go b/miner/miner_test.go index da133ad8d0..88a9c58b02 100644 --- a/miner/miner_test.go +++ b/miner/miner_test.go @@ -21,6 +21,7 @@ import ( "math/big" "sync" "testing" + "time" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/consensus/clique" @@ -74,6 +75,7 @@ func (bc *testBlockChain) CurrentBlock() *types.Header { return &types.Header{ Number: new(big.Int), GasLimit: bc.gasLimit, + BaseFee: big.NewInt(params.InitialBaseFee), } } @@ -139,16 +141,18 @@ func minerTestGenesisBlock(period uint64, gasLimit uint64, faucet common.Address func createMiner(t *testing.T) *Miner { // Create Ethash config config := Config{ - PendingFeeRecipient: common.HexToAddress("123456789"), + PendingFeeRecipient: common.HexToAddress("123456789"), + RollupTransactionConditionalRateLimit: params.TransactionConditionalMaxCost, } // Create chainConfig chainDB := rawdb.NewMemoryDatabase() triedb := triedb.NewDatabase(chainDB, nil) - genesis := minerTestGenesisBlock(15, 11_500_000, common.HexToAddress("12345")) + genesis := minerTestGenesisBlock(15, 11_500_000, testBankAddress) chainConfig, _, err := core.SetupGenesisBlock(chainDB, triedb, genesis) if err != nil { t.Fatalf("can't create new chain config: %v", err) } + // Create consensus engine engine := clique.New(chainConfig.Clique, chainDB) // Create Ethereum backend @@ -167,3 +171,50 @@ func createMiner(t *testing.T) *Miner { miner := New(backend, config, engine) return miner } + +func TestRejectedConditionalTx(t *testing.T) { + miner := createMiner(t) + timestamp := uint64(time.Now().Unix()) + uint64Ptr := func(num uint64) *uint64 { return &num } + + // add a conditional transaction to be rejected + signer := types.LatestSigner(miner.chainConfig) + tx := types.MustSignNewTx(testBankKey, signer, &types.LegacyTx{ + Nonce: 0, + To: &testUserAddress, + Value: big.NewInt(1000), + Gas: params.TxGas, + GasPrice: big.NewInt(params.InitialBaseFee), + }) + tx.SetConditional(&types.TransactionConditional{TimestampMax: uint64Ptr(timestamp - 1)}) + + // 1 pending tx + miner.txpool.Add(types.Transactions{tx}, true, false) + if !miner.txpool.Has(tx.Hash()) { + t.Fatalf("conditional tx is not in the mempool") + } + + // request block + r := miner.generateWork(&generateParams{ + parentHash: miner.chain.CurrentBlock().Hash(), + timestamp: timestamp, + random: common.HexToHash("0xcafebabe"), + noTxs: false, + forceTime: true, + }) + + if len(r.block.Transactions()) != 0 { + t.Fatalf("block should be empty") + } + + // tx is rejected + if !tx.Rejected() { + t.Fatalf("conditional tx is not marked as rejected") + } + + // rejected conditional is evicted from the txpool + miner.txpool.Sync() + if miner.txpool.Has(tx.Hash()) { + t.Fatalf("conditional tx is still in the mempool") + } +} diff --git a/miner/worker.go b/miner/worker.go index a408852273..d17199b89f 100644 --- a/miner/worker.go +++ b/miner/worker.go @@ -35,6 +35,7 @@ import ( "github.com/ethereum/go-ethereum/core/vm" "github.com/ethereum/go-ethereum/eth/tracers" "github.com/ethereum/go-ethereum/log" + "github.com/ethereum/go-ethereum/metrics" "github.com/ethereum/go-ethereum/params" "github.com/holiman/uint256" ) @@ -46,10 +47,16 @@ const ( ) var ( + errTxConditionalInvalid = errors.New("transaction conditional failed") + errTxConditionalRateLimited = errors.New("transaction conditional rate limited") + errBlockInterruptedByNewHead = errors.New("new head arrived while building block") errBlockInterruptedByRecommit = errors.New("recommit interrupt while building block") errBlockInterruptedByTimeout = errors.New("timeout while building block") errBlockInterruptedByResolve = errors.New("payload resolution while building block") + + txConditionalRejectedCounter = metrics.NewRegisteredCounter("miner/transactionConditional/rejected", nil) + txConditionalMinedTimer = metrics.NewRegisteredTimer("miner/transactionConditional/elapsedtime", nil) ) // environment is the worker's current environment and holds all @@ -287,6 +294,30 @@ func (miner *Miner) commitTransaction(env *environment, tx *types.Transaction) e if tx.Type() == types.BlobTxType { return miner.commitBlobTransaction(env, tx) } + + // If a conditional is set, check prior to applying + if conditional := tx.Conditional(); conditional != nil { + now, cost := time.Now(), conditional.Cost() + res := miner.conditionalLimiter.ReserveN(now, cost) + if !res.OK() { + return fmt.Errorf("exceeded rate limiter burst: cost %d, burst %d: %w", cost, miner.conditionalLimiter.Burst(), errTxConditionalInvalid) + } + if res.Delay() > 0 { + res.Cancel() + return fmt.Errorf("exceeded rate limit: cost %d, available tokens %f: %w", cost, miner.conditionalLimiter.Tokens(), errTxConditionalRateLimited) + } + + txConditionalMinedTimer.UpdateSince(tx.Time()) + + // check the conditional + if err := env.header.CheckTransactionConditional(conditional); err != nil { + return fmt.Errorf("failed header check: %s: %w", err, errTxConditionalInvalid) + } + if err := env.state.CheckTransactionConditional(conditional); err != nil { + return fmt.Errorf("failed state check: %s: %w", err, errTxConditionalInvalid) + } + } + receipt, err := miner.applyTransaction(env, tx) if err != nil { return err @@ -422,6 +453,23 @@ func (miner *Miner) commitTransactions(env *environment, plainTxs, blobTxs *tran log.Trace("Skipping transaction with low nonce", "hash", ltx.Hash, "sender", from, "nonce", tx.Nonce()) txs.Shift() + case errors.Is(err, errTxConditionalInvalid): + // err contains contextual info on the failed conditional. + txConditionalRejectedCounter.Inc(1) + + // mark as rejected so that it can be ejected from the mempool + tx.SetRejected() + log.Warn("Skipping account, transaction with failed conditional", "sender", from, "hash", ltx.Hash, "err", err) + txs.Pop() + + case errors.Is(err, errTxConditionalRateLimited): + // err contains contextual info of the cost and limiter tokens available + txConditionalRejectedCounter.Inc(1) + + // note: we do not mark the tx as rejected as it is still eligible for inclusion at a later time + log.Warn("Skipping account, transaction with conditional rate limited", "sender", from, "hash", ltx.Hash, "err", err) + txs.Pop() + case errors.Is(err, nil): // Everything ok, collect the logs and shift in the next transaction from the same account txs.Shift() diff --git a/params/conditional_tx_params.go b/params/conditional_tx_params.go new file mode 100644 index 0000000000..a3d27a9837 --- /dev/null +++ b/params/conditional_tx_params.go @@ -0,0 +1,9 @@ +package params + +const ( + // An inclusive limit on the max cost for the conditional attached to a tx + TransactionConditionalMaxCost = 1000 + + TransactionConditionalRejectedErrCode = -32003 + TransactionConditionalCostExceededMaxErrCode = -32005 +) diff --git a/rpc/json.go b/rpc/json.go index e932389d17..bc8d69f143 100644 --- a/rpc/json.go +++ b/rpc/json.go @@ -41,6 +41,8 @@ const ( var null = json.RawMessage("null") +type JsonError = jsonError + type subscriptionResult struct { ID string `json:"subscription"` Result json.RawMessage `json:"result,omitempty"`