Repository: kaiachain/kaia
Pull request #271: blockchain/txpool: improve validation of Authorities(EIP-7702: SetCodeTx)
Author: login: tnasu
Created: 2025-03-05T03:47:20Z | Merged: 2025-03-07T01:05:09Z

## Proposed changes

Validate “SetCodeAuthentication.Authorities” in `txpool`
1. Accounts with delegation set can only have one in-flight transaction.
2. Setcode tx should be rejected if any authority has a known pooled tx.
3. New txs from senders with pooled delegations should not be accepted.
4. Ensure setcode tx can replace itself provided the fee bump is enough.
5. Make sure that if a setcode tx is replaced, the auths associated with
the tx are removed.
5.1. This should also work when a self-sponsored setcode tx attempts
to replace itself.
6. Verify nonces in setcode tx are at least valid when initially added to
the pool, otherwise someone might submit old validations to deny
service to unsuspecting users.

Changes:
* Implement `validateAuth` in `TxPool` for above validations 
* Implement `addAuthorities/removeAuthorities` in `txLookup` for conflict checking

## Types of changes

Please put an x in the boxes related to your change.

- [ ] Bugfix
- [x] New feature or enhancement
- [ ] Others

## Checklist

_Put an x in the boxes that apply. You can also fill these out after creating the PR. If you're unsure about any of them, don't hesitate to ask. We're here to help! This is simply a reminder of what we are going to look for before merging your code._

- [x] I have read the [CONTRIBUTING GUIDELINES](https://github.com/kaiachain/kaia/blob/main/CONTRIBUTING.md) doc
- [x] I have read the [CLA](https://gist.github.com/kaiachain-dev/bbf65cc330275c057463c4c94ce787a6) and signed by comment `I have read the CLA Document and I hereby sign the CLA` in first time contribute
- [x] Lint and unit tests pass locally with my changes (`$ make test`)
- [x] I have added tests that prove my fix is effective or that my feature works
- [ ] I have added necessary documentation (if appropriate)
- [ ] Any dependent changes have been merged and published in downstream modules

## Related issues

- https://github.com/kaiachain/kaia/issues/106
- https://github.com/kaiachain/kaia/pull/216
- https://github.com/kaiachain/kaia/pull/233
- https://github.com/kaiachain/kaia/pull/242

## Further comments



Diff:
diff --git a/blockchain/tx_list.go b/blockchain/tx_list.go
index 5edcfbe35..d77c0fae8 100644
--- a/blockchain/tx_list.go
+++ b/blockchain/tx_list.go
@@ -305,6 +305,12 @@ func (l *txList) Overlaps(tx *types.Transaction) bool {
 	return l.txs.Get(tx.Nonce()) != nil
 }
 
+// Contains returns whether the  list contains a transaction
+// with the provided nonce.
+func (l *txList) Contains(nonce uint64) bool {
+	return l.txs.Get(nonce) != nil
+}
+
 // Add tries to insert a new transaction into the list, returning whether the
 // transaction was accepted, and if yes, any previous transaction it replaced.
 //
diff --git a/blockchain/tx_pool.go b/blockchain/tx_pool.go
index b0ffe3b1a..9e0b136f3 100644
--- a/blockchain/tx_pool.go
+++ b/blockchain/tx_pool.go
@@ -27,6 +27,7 @@ import (
 	"fmt"
 	"math"
 	"math/big"
+	"slices"
 	"sort"
 	"sync"
 	"time"
@@ -72,9 +73,24 @@ var (
 	evictionInterval    = time.Minute     // Time interval to check for evictable transactions
 	statsReportInterval = 8 * time.Second // Time interval to report transaction pool stats
 
-	txPoolIsFullErr = fmt.Errorf("txpool is full")
-
 	errNotAllowedAnchoringTx = errors.New("locally anchoring chaindata tx is not allowed in this node")
+
+	// ErrTxPoolOverflow is returned if the transaction pool is full and can't accept
+	// another remote transaction.
+	ErrTxPoolOverflow = errors.New("txpool is full")
+
+	// ErrInflightTxLimitReached is returned when the maximum number of in-flight
+	// transactions is reached for specific accounts.
+	ErrInflightTxLimitReached = errors.New("in-flight transaction limit reached for delegated accounts")
+
+	// ErrAuthorityReserved is returned if a transaction has an authorization
+	// signed by an address which already has in-flight transactions known to the
+	// pool.
+	ErrAuthorityReserved = errors.New("authority already reserved")
+
+	// ErrFutureReplacePending is returned if a future transaction replaces a pending
+	// one. Future transactions should only be able to replace other future transactions.
+	ErrFutureReplacePending = errors.New("future transaction tries to replace pending")
 )
 
 var (
@@ -855,6 +871,16 @@ func (pool *TxPool) validateTx(tx *types.Transaction) error {
 		}
 	}
 
+	if tx.Type() == types.TxTypeEthereumSetCode {
+		if len(tx.AuthList()) == 0 {
+			return errors.New("set code tx must have at least one authorization tuple")
+		}
+	}
+
+	if err := pool.validateAuth(tx); err != nil {
+		return err
+	}
+
 	// "tx.Validate()" conducts additional validation for each new txType.
 	// Validate humanReadable address when this tx has "true" in the humanReadable field.
 	// Validate accountKey when the this create or update an account
@@ -867,6 +893,43 @@ func (pool *TxPool) validateTx(tx *types.Transaction) error {
 	return nil
 }
 
+// validateAuth verifies that the transaction complies with code authorization
+// restrictions brought by SetCode transaction type.
+func (pool *TxPool) validateAuth(tx *types.Transaction) error {
+	from, _ := types.Sender(pool.signer, tx) // validated
+
+	// Allow at most one in-flight tx for delegated accounts or those with a
+	// pending authorization.
+	if pool.currentState.GetCodeHash(from) != types.EmptyCodeHash || len(pool.all.auths[from]) != 0 {
+		var (
+			count  int
+			exists bool
+		)
+		pending := pool.pending[from]
+		if pending != nil {
+			count += pending.Len()
+			exists = pending.Contains(tx.Nonce())
+		}
+		queue := pool.queue[from]
+		if queue != nil {
+			count += queue.Len()
+			exists = exists || queue.Contains(tx.Nonce())
+		}
+		// Replace the existing in-flight transaction for delegated accounts
+		// are still supported
+		if count >= 1 && !exists {
+			return ErrInflightTxLimitReached
+		}
+	}
+	// Authorities cannot conflict with any pending or queued transactions.
+	for _, auth := range tx.SetCodeAuthorities() {
+		if pool.pending[auth] != nil || pool.queue[auth] != nil {
+			return ErrAuthorityReserved
+		}
+	}
+	return nil
+}
+
 // getMaxTxFromQueueWhenNonceIsMissing finds and returns a trasaction with max nonce in queue when a given Tx has missing nonce.
 // Otherwise it returns a given Tx itself.
 func (pool *TxPool) getMaxTxFromQueueWhenNonceIsMissing(tx *types.Transaction, from *common.Address) *types.Transaction {
@@ -1269,7 +1332,7 @@ func (pool *TxPool) checkAndAddTxs(txs []*types.Transaction, local bool) []error
 
 	if poolCapacity < numTxs {
 		for i := 0; i < numTxs-poolCapacity; i++ {
-			errs = append(errs, txPoolIsFullErr)
+			errs = append(errs, ErrTxPoolOverflow)
 		}
 	}
 
@@ -1772,16 +1835,19 @@ func (as *accountSet) add(addr common.Address) {
 // peeking into the pool in TxPool.Get without having to acquire the widely scoped
 // TxPool.mu mutex.
 type txLookup struct {
-	all   map[common.Hash]*types.Transaction
 	slots int
 	lock  sync.RWMutex
+	txs   map[common.Hash]*types.Transaction
+
+	auths map[common.Address][]common.Hash // All accounts with a pooled authorization
 }
 
 // newTxLookup returns a new txLookup structure.
 func newTxLookup() *txLookup {
 	slotsGauge.Update(int64(0))
 	return &txLookup{
-		all: make(map[common.Hash]*types.Transaction),
+		txs:   make(map[common.Hash]*types.Transaction),
+		auths: make(map[common.Address][]common.Hash),
 	}
 }
 
@@ -1798,7 +1864,7 @@ func (t *txLookup) Range(f func(hash common.Hash, tx *types.Transaction) bool) {
 	t.lock.RLock()
 	defer t.lock.RUnlock()
 
-	for key, value := range t.all {
+	for key, value := range t.txs {
 		if !f(key, value) {
 			break
 		}
@@ -1810,7 +1876,7 @@ func (t *txLookup) Get(hash common.Hash) *types.Transaction {
 	t.lock.RLock()
 	defer t.lock.RUnlock()
 
-	return t.all[hash]
+	return t.txs[hash]
 }
 
 // Count returns the current number of items in the lookup.
@@ -1818,7 +1884,7 @@ func (t *txLookup) Count() int {
 	t.lock.RLock()
 	defer t.lock.RUnlock()
 
-	return len(t.all)
+	return len(t.txs)
 }
 
 // Add adds a transaction to the lookup.
@@ -1829,7 +1895,8 @@ func (t *txLookup) Add(tx *types.Transaction) {
 	t.slots += numSlots(tx)
 	slotsGauge.Update(int64(t.slots))
 
-	t.all[tx.Hash()] = tx
+	t.txs[tx.Hash()] = tx
+	t.addAuthorities(tx)
 }
 
 // Remove removes a transaction from the lookup.
@@ -1837,13 +1904,69 @@ func (t *txLookup) Remove(hash common.Hash) {
 	t.lock.Lock()
 	defer t.lock.Unlock()
 
-	t.slots -= numSlots(t.all[hash])
+	tx, ok := t.txs[hash]
+	if !ok {
+		logger.Error("No transaction found to be deleted", "hash", hash)
+		return
+	}
+
+	t.removeAuthorities(tx)
+	t.slots -= numSlots(tx)
 	slotsGauge.Update(int64(t.slots))
 
-	delete(t.all, hash)
+	delete(t.txs, hash)
+}
+
+// addAuthorities tracks the supplied tx in relation to each authority it
+// specifies.
+func (t *txLookup) addAuthorities(tx *types.Transaction) {
+	for _, addr := range tx.SetCodeAuthorities() {
+		list, ok := t.auths[addr]
+		if !ok {
+			list = []common.Hash{}
+		}
+		if slices.Contains(list, tx.Hash()) {
+			// Don't add duplicates.
+			continue
+		}
+		t.auths[addr] = append(list, tx.Hash())
+	}
+}
+
+// removeAuthorities stops tracking the supplied tx in relation to its
+// authorities.
+func (t *txLookup) removeAuthorities(tx *types.Transaction) {
+	hash := tx.Hash()
+	for _, addr := range tx.SetCodeAuthorities() {
+		list := t.auths[addr]
+		// Remove tx from tracker.
+		if i := slices.Index(list, hash); i >= 0 {
+			list = append(list[:i], list[i+1:]...)
+		} else {
+			logger.Error("Authority with untracked tx", "addr", addr, "hash", hash)
+		}
+		if len(list) == 0 {
+			// If list is newly empty, delete it entirely.
+			delete(t.auths, addr)
+			continue
+		}
+		t.auths[addr] = list
+	}
 }
 
 // numSlots calculates the number of slots needed for a single transaction.
 func numSlots(tx *types.Transaction) int {
 	return int((tx.Size() + txSlotSize - 1) / txSlotSize)
 }
+
+// Clear implements removing all tracked txs from the pool
+func (pool *TxPool) Clear() {
+	pool.mu.Lock()
+	defer pool.mu.Unlock()
+
+	pool.all = newTxLookup()
+	pool.priced = newTxPricedList(pool.all)
+	pool.pending = make(map[common.Address]*txList)
+	pool.queue = make(map[common.Address]*txList)
+	pool.pendingNonce = make(map[common.Address]uint64)
+}
diff --git a/blockchain/tx_pool_test.go b/blockchain/tx_pool_test.go
index 0cb19cafb..f4d2b8ca6 100644
--- a/blockchain/tx_pool_test.go
+++ b/blockchain/tx_pool_test.go
@@ -25,17 +25,21 @@ package blockchain
 import (
 	"crypto/ecdsa"
 	crand "crypto/rand"
+	"errors"
 	"fmt"
 	"io"
 	"math/big"
 	"math/rand"
 	"os"
 	"reflect"
+	"slices"
 	"testing"
 	"time"
 
+	"github.com/holiman/uint256"
 	"github.com/kaiachain/kaia/blockchain/state"
 	"github.com/kaiachain/kaia/blockchain/types"
+	"github.com/kaiachain/kaia/blockchain/vm"
 	"github.com/kaiachain/kaia/common"
 	"github.com/kaiachain/kaia/crypto"
 	"github.com/kaiachain/kaia/event"
@@ -149,6 +153,41 @@ func dynamicFeeTx(nonce uint64, gaslimit uint64, gasFee *big.Int, tip *big.Int,
 	return signedTx
 }
 
+type unsignedAuth struct {
+	nonce uint64
+	key   *ecdsa.PrivateKey
+}
+
+func setCodeTx(nonce uint64, key *ecdsa.PrivateKey, unsigned []unsignedAuth) *types.Transaction {
+	return pricedSetCodeTx(nonce, 250000, big.NewInt(1000), big.NewInt(1), key, unsigned)
+}
+
+func pricedSetCodeTx(nonce uint64, gaslimit uint64, gasFee, tip *big.Int, key *ecdsa.PrivateKey, unsigned []unsignedAuth) *types.Transaction {
+	authList := make([]types.SetCodeAuthorization, 0, len(unsigned))
+	for _, u := range unsigned {
+		auth, _ := types.SignSetCode(u.key, types.SetCodeAuthorization{
+			ChainID: *uint256.MustFromBig(params.TestChainConfig.ChainID),
+			Address: common.Address{0x42},
+			Nonce:   u.nonce,
+		})
+		authList = append(authList, auth)
+	}
+	setCodeTx := types.NewTx(&types.TxInternalDataEthereumSetCode{
+		ChainID:           uint256.MustFromBig(params.TestChainConfig.ChainID),
+		AccountNonce:      nonce,
+		GasTipCap:         tip,
+		GasFeeCap:         gasFee,
+		GasLimit:          gaslimit,
+		Recipient:         common.Address{},
+		Amount:            big.NewInt(100),
+		Payload:           nil,
+		AccessList:        nil,
+		AuthorizationList: authList,
+	})
+	signedTx, _ := types.SignTx(setCodeTx, types.LatestSignerForChainID(params.TestChainConfig.ChainID), key)
+	return signedTx
+}
+
 func cancelTx(nonce uint64, gasLimit uint64, gasPrice *big.Int, from common.Address, key *ecdsa.PrivateKey) *types.Transaction {
 	d, err := types.NewTxInternalDataWithMap(types.TxTypeCancel, map[types.TxValueKeyType]interface{}{
 		types.TxValueKeyNonce:    nonce,
@@ -238,6 +277,23 @@ func validateTxPoolInternals(pool *TxPool) error {
 			return fmt.Errorf("pending nonce mismatch: have %v, want %v", nonce, last+1)
 		}
 	}
+	// Ensure all auths in pool are tracked
+	for _, tx := range pool.all.txs {
+		for _, addr := range tx.SetCodeAuthorities() {
+			list := pool.all.auths[addr]
+			if i := slices.Index(list, tx.Hash()); i < 0 {
+				return fmt.Errorf("authority not tracked: addr %s, tx %s", addr, tx.Hash())
+			}
+		}
+	}
+	// Ensure all auths in pool have an associated tx.
+	for addr, hashes := range pool.all.auths {
+		for _, hash := range hashes {
+			if _, ok := pool.all.txs[hash]; !ok {
+				return fmt.Errorf("dangling authority, missing originating tx: addr %s, hash %s", addr, hash.Hex())
+			}
+		}
+	}
 	return nil
 }
 
@@ -1923,6 +1979,226 @@ func TestTransactionReplacement(t *testing.T) {
 }
 */
 
+// TestSetCodeTransactions tests a few scenarios regarding the EIP-7702
+// SetCodeTx.
+func TestSetCodeTransactions(t *testing.T) {
+	t.Parallel()
+
+	// Create the pool to test the status retrievals with
+	statedb, _ := state.New(common.Hash{}, state.NewDatabase(database.NewMemoryDBManager()), nil, nil)
+	blockchain := &testBlockChain{statedb, 1000000, new(event.Feed)}
+
+	pool := NewTxPool(testTxPoolConfig, kip71Config, blockchain, &dummyGovModule{chainConfig: kip71Config})
+	defer pool.Stop()
+
+	// Create the test accounts
+	var (
+		keyA, _ = crypto.GenerateKey()
+		keyB, _ = crypto.GenerateKey()
+		keyC, _ = crypto.GenerateKey()
+		addrA   = crypto.PubkeyToAddress(keyA.PublicKey)
+		addrB   = crypto.PubkeyToAddress(keyB.PublicKey)
+		addrC   = crypto.PubkeyToAddress(keyC.PublicKey)
+	)
+	testAddBalance(pool, addrA, big.NewInt(params.KAIA))
+	testAddBalance(pool, addrB, big.NewInt(params.KAIA))
+	testAddBalance(pool, addrC, big.NewInt(params.KAIA))
+
+	for _, tt := range []struct {
+		name    string
+		pending int
+		queued  int
+		run     func(string)
+	}{
+		{
+			// Check that only one in-flight transaction is allowed for accounts
+			// with delegation set. Also verify the accepted transaction can be
+			// replaced by fee.
+			name:    "only-one-in-flight",
+			pending: 1,
+			run: func(name string) {
+				aa := common.Address{0xaa, 0xaa}
+				statedb.SetCode(addrA, append(types.DelegationPrefix, aa.Bytes()...))
+				statedb.SetCode(aa, []byte{byte(vm.ADDRESS), byte(vm.PUSH0), byte(vm.SSTORE)})
+				// Send transactions. First is accepted, second is rejected.
+				if err := pool.addTx(pricedTransaction(0, 100000, big.NewInt(1), keyA), false); err != nil {
+					t.Fatalf("%s: failed to add remote transaction: %v", name, err)
+				}
+				if err := pool.addTx(pricedTransaction(1, 100000, big.NewInt(1), keyA), false); !errors.Is(err, ErrInflightTxLimitReached) {
+					t.Fatalf("%s: error mismatch: want %v, have %v", name, ErrInflightTxLimitReached, err)
+				}
+				// Also check gapped transaction.
+				if err := pool.addTx(pricedTransaction(2, 100000, big.NewInt(1), keyA), false); !errors.Is(err, ErrInflightTxLimitReached) {
+					t.Fatalf("%s: error mismatch: want %v, have %v", name, ErrInflightTxLimitReached, err)
+				}
+				// Replace by fee.
+				if err := pool.addTx(pricedTransaction(0, 100000, big.NewInt(10), keyA), false); err != nil {
+					t.Fatalf("%s: failed to replace with remote transaction: %v", name, err)
+				}
+			},
+		},
+		{
+			name:    "allow-setcode-tx-with-pending-authority-tx",
+			pending: 2,
+			run: func(name string) {
+				// Send two transactions where the first has no conflicting delegations and
+				// the second should be allowed despite conflicting with the authorities in 1).
+				if err := pool.addTx(setCodeTx(0, keyA, []unsignedAuth{{1, keyC}}), false); err != nil {
+					t.Fatalf("%s: failed to add with remote setcode transaction: %v", name, err)
+				}
+				if err := pool.addTx(setCodeTx(0, keyB, []unsignedAuth{{1, keyC}}), false); err != nil {
+					t.Fatalf("%s: failed to add conflicting delegation: %v", name, err)
+				}
+			},
+		},
+		{
+			name:    "allow-one-tx-from-pooled-delegation",
+			pending: 2,
+			run: func(name string) {
+				// Verify C cannot originate another transaction when it has a pooled delegation.
+				if err := pool.addTx(setCodeTx(0, keyA, []unsignedAuth{{0, keyC}}), false); err != nil {
+					t.Fatalf("%s: failed to add with remote setcode transaction: %v", name, err)
+				}
+				if err := pool.addTx(pricedTransaction(0, 100000, big.NewInt(1), keyC), false); err != nil {
+					t.Fatalf("%s: failed to add with pending delegatio: %v", name, err)
+				}
+				// Also check gapped transaction is rejected.
+				if err := pool.addTx(pricedTransaction(1, 100000, big.NewInt(1), keyC), false); !errors.Is(err, ErrInflightTxLimitReached) {
+					t.Fatalf("%s: error mismatch: want %v, have %v", name, ErrInflightTxLimitReached, err)
+				}
+			},
+		},
+		{
+			name:    "replace-by-fee-setcode-tx",
+			pending: 1,
+			run: func(name string) {
+				// 4. Fee bump the setcode tx send.
+				if err := pool.addTx(setCodeTx(0, keyB, []unsignedAuth{{1, keyC}}), false); err != nil {
+					t.Fatalf("%s: failed to add with remote setcode transaction: %v", name, err)
+				}
+				if err := pool.addTx(pricedSetCodeTx(0, 250000, big.NewInt(2000), big.NewInt(2), keyB, []unsignedAuth{{0, keyC}}), false); err != nil {
+					t.Fatalf("%s: failed to add with remote setcode transaction: %v", name, err)
+				}
+			},
+		},
+		{
+			name:    "allow-tx-from-replaced-authority",
+			pending: 2,
+			run: func(name string) {
+				// Fee bump with a different auth list. Make sure that unlocks the authorities.
+				if err := pool.addTx(pricedSetCodeTx(0, 250000, big.NewInt(10), big.NewInt(3), keyA, []unsignedAuth{{0, keyB}}), false); err != nil {
+					t.Fatalf("%s: failed to add with remote setcode transaction: %v", name, err)
+				}
+				if err := pool.addTx(pricedSetCodeTx(0, 250000, big.NewInt(3000), big.NewInt(300), keyA, []unsignedAuth{{0, keyC}}), false); err != nil {
+					t.Fatalf("%s: failed to add with remote setcode transaction: %v", name, err)
+				}
+				// Now send a regular tx from B.
+				if err := pool.addTx(pricedTransaction(0, 100000, big.NewInt(10), keyB), false); err != nil {
+					t.Fatalf("%s: failed to replace with remote transaction: %v", name, err)
+				}
+			},
+		},
+		{
+			name:    "allow-tx-from-replaced-self-sponsor-authority",
+			pending: 2,
+			run: func(name string) {
+				//
+				if err := pool.addTx(pricedSetCodeTx(0, 250000, big.NewInt(10), big.NewInt(3), keyA, []unsignedAuth{{0, keyA}}), false); err != nil {
+					t.Fatalf("%s: failed to add with remote setcode transaction: %v", name, err)
+				}
+				if err := pool.addTx(pricedSetCodeTx(0, 250000, big.NewInt(30), big.NewInt(30), keyA, []unsignedAuth{{0, keyB}}), false); err != nil {
+					t.Fatalf("%s: failed to add with remote setcode transaction: %v", name, err)
+				}
+				// Now send a regular tx from keyA.
+				if err := pool.addTx(pricedTransaction(0, 100000, big.NewInt(1000), keyA), false); err != nil {
+					t.Fatalf("%s: failed to replace with remote transaction: %v", name, err)
+				}
+				// Make sure we can still send from keyB.
+				if err := pool.addTx(pricedTransaction(0, 100000, big.NewInt(1000), keyB), false); err != nil {
+					t.Fatalf("%s: failed to replace with remote transaction: %v", name, err)
+				}
+			},
+		},
+		{
+			name:    "track-multiple-conflicting-delegations",
+			pending: 3,
+			run: func(name string) {
+				// Send two setcode txs both with C as an authority.
+				if err := pool.addTx(pricedSetCodeTx(0, 250000, big.NewInt(10), big.NewInt(3), keyA, []unsignedAuth{{0, keyC}}), false); err != nil {
+					t.Fatalf("%s: failed to add with remote setcode transaction: %v", name, err)
+				}
+				if err := pool.addTx(pricedSetCodeTx(0, 250000, big.NewInt(30), big.NewInt(30), keyB, []unsignedAuth{{0, keyC}}), false); err != nil {
+					t.Fatalf("%s: failed to add with remote setcode transaction: %v", name, err)
+				}
+				// Replace the tx from A with a non-setcode tx.
+				if err := pool.addTx(pricedTransaction(0, 100000, big.NewInt(1000), keyA), false); err != nil {
+					t.Fatalf("%s: failed to replace with remote transaction: %v", name, err)
+				}
+				// Make sure we can only pool one tx from keyC since it is still a
+				// pending authority.
+				if err := pool.addTx(pricedTransaction(0, 100000, big.NewInt(1000), keyC), false); err != nil {
+					t.Fatalf("%s: failed to added single pooled for account with pending delegation: %v", name, err)
+				}
+				if err, want := pool.addTx(pricedTransaction(1, 100000, big.NewInt(1000), keyC), false), ErrInflightTxLimitReached; !errors.Is(err, want) {
+					t.Fatalf("%s: error mismatch: want %v, have %v", name, want, err)
+				}
+			},
+		},
+		{
+			name:    "reject-delegation-from-pending-account",
+			pending: 1,
+			run: func(name string) {
+				// Attempt to submit a delegation from an account with a pending tx.
+				if err := pool.addTx(pricedTransaction(0, 100000, big.NewInt(1000), keyC), false); err != nil {
+					t.Fatalf("%s: failed to add with remote setcode transaction: %v", name, err)
+				}
+				if err, want := pool.addTx(setCodeTx(0, keyA, []unsignedAuth{{1, keyC}}), false), ErrAuthorityReserved; !errors.Is(err, want) {
+					t.Fatalf("%s: error mismatch: want %v, have %v", name, want, err)
+				}
+			},
+		},
+		{
+			name:    "remove-hash-from-authority-tracker",
+			pending: 10,
+			run: func(name string) {
+				var keys []*ecdsa.PrivateKey
+				for i := 0; i < 30; i++ {
+					key, _ := crypto.GenerateKey()
+					keys = append(keys, key)
+					addr := crypto.PubkeyToAddress(key.PublicKey)
+					testAddBalance(pool, addr, big.NewInt(params.KAIA))
+				}
+				// Create a transactions with 3 unique auths so the lookup's auth map is
+				// filled with addresses.
+				for i := 0; i < 30; i += 3 {
+					if err := pool.addTx(pricedSetCodeTx(0, 250000, big.NewInt(10), big.NewInt(3), keys[i], []unsignedAuth{{0, keys[i]}, {0, keys[i+1]}, {0, keys[i+2]}}), false); err != nil {
+						t.Fatalf("%s: failed to add with remote setcode transaction: %v", name, err)
+					}
+				}
+				// Replace one of the transactions with a normal transaction so that the
+				// original hash is removed from the tracker. The hash should be
+				// associated with 3 different authorities.
+				if err := pool.addTx(pricedTransaction(0, 100000, big.NewInt(1000), keys[0]), false); err != nil {
+					t.Fatalf("%s: failed to replace with remote transaction: %v", name, err)
+				}
+			},
+		},
+	} {
+		tt.run(tt.name)
+		pending, queued := pool.Stats()
+		if pending != tt.pending {
+			t.Fatalf("%s: pending transactions mismatched: have %d, want %d", tt.name, pending, tt.pending)
+		}
+		if queued != tt.queued {
+			t.Fatalf("%s: queued transactions mismatched: have %d, want %d", tt.name, queued, tt.queued)
+		}
+		if err := validateTxPoolInternals(pool); err != nil {
+			t.Fatalf("%s: pool internal state corrupted: %v", tt.name, err)
+		}
+		pool.Clear()
+	}
+}
+
 // Tests that local transactions are journaled to disk, but remote transactions
 // get discarded between restarts.
 func TestTransactionJournaling(t *testing.T)         { testTransactionJournaling(t, false) }
diff --git a/blockchain/types/transaction.go b/blockchain/types/transaction.go
index 22d11bf78..cc8754b0d 100644
--- a/blockchain/types/transaction.go
+++ b/blockchain/types/transaction.go
@@ -352,6 +352,28 @@ func (tx *Transaction) AuthList() []SetCodeAuthorization {
 	return nil
 }
 
+// SetCodeAuthorities returns a list of unique authorities from the
+// authorization list.
+func (tx *Transaction) SetCodeAuthorities() []common.Address {
+	if tx.Type() != TxTypeEthereumSetCode {
+		return nil
+	}
+	var (
+		marks = make(map[common.Address]bool)
+		auths = make([]common.Address, 0, len(tx.AuthList()))
+	)
+	for _, auth := range tx.AuthList() {
+		if addr, err := auth.Authority(); err == nil {
+			if marks[addr] {
+				continue
+			}
+			marks[addr] = true
+			auths = append(auths, addr)
+		}
+	}
+	return auths
+}
+
 func (tx *Transaction) Value() *big.Int { return new(big.Int).Set(tx.data.GetAmount()) }
 func (tx *Transaction) Nonce() uint64   { return tx.data.GetAccountNonce() }
 func (tx *Transaction) CheckNonce() bool {


Reviews:
user: login: hyeonLewis
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-05T07:28:54Z
user: login: ulbqb
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-05T07:45:03Z
user: login: tnasu
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-05T09:55:56Z
user: login: tnasu
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-05T09:59:52Z
user: login: tnasu
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-05T10:15:25Z
user: login: hyeonLewis
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-05T11:02:13Z
user: login: ulbqb
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T01:16:17Z
user: login: ulbqb
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T01:16:59Z
user: login: ulbqb
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T01:20:27Z
user: login: yoomee1313
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T02:45:41Z
user: login: ulbqb
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T10:27:15Z
user: login: hyeonLewis
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T13:02:52Z
user: login: yoomee1313
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-07T01:03:19Z

Review comments:
diff_hunk: @@ -855,6 +871,16 @@ func (pool *TxPool) validateTx(tx *types.Transaction) error {
 		}
 	}
 
+	if tx.Type() == types.TxTypeEthereumSetCode {
+		if len(tx.AuthList()) == 0 {
+			return errors.New("set code tx must have at least one authorization tuple")
+		}
path: blockchain/tx_pool.go
user: login: ulbqb
body: If auth list is empty array, it's certainly invalid. I'm curious about behavior when auth list is nil. If it's nil, the transaction should be valid.
created_at: 2025-03-05T06:48:22Z
author_association: CONTRIBUTOR
diff_hunk: @@ -352,6 +352,28 @@ func (tx *Transaction) AuthList() []SetCodeAuthorization {
 	return nil
 }
 
+// SetCodeAuthorities returns a list of unique authorities from the
+// authorization list.
+func (tx *Transaction) SetCodeAuthorities() []common.Address {
path: blockchain/types/transaction.go
user: login: ulbqb
body: This looks like a setter function.
created_at: 2025-03-05T06:51:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1829,21 +1897,94 @@ func (t *txLookup) Add(tx *types.Transaction) {
 	t.slots += numSlots(tx)
 	slotsGauge.Update(int64(t.slots))
 
-	t.all[tx.Hash()] = tx
+	t.txs[tx.Hash()] = tx
+	t.addAuthorities(tx)
 }
 
 // Remove removes a transaction from the lookup.
 func (t *txLookup) Remove(hash common.Hash) {
 	t.lock.Lock()
 	defer t.lock.Unlock()
 
-	t.slots -= numSlots(t.all[hash])
+	tx, ok := t.txs[hash]
+	if !ok {
+		logger.Error("No transaction found to be deleted", "hash", hash)
+		return
+	}
+
+	t.removeAuthorities(tx)
+	t.slots -= numSlots(tx)
 	slotsGauge.Update(int64(t.slots))
 
-	delete(t.all, hash)
+	delete(t.txs, hash)
+}
+
+// addAuthorities tracks the supplied tx in relation to each authority it
+// specifies.
+func (t *txLookup) addAuthorities(tx *types.Transaction) {
+	for _, addr := range tx.SetCodeAuthorities() {
+		list, ok := t.auths[addr]
+		if !ok {
+			list = []common.Hash{}
+		}
+		if slices.Contains(list, tx.Hash()) {
+			// Don't add duplicates.
+			continue
+		}
+		list = append(list, tx.Hash())
+		t.auths[addr] = list
+	}
+}
+
+// removeAuthorities stops tracking the supplied tx in relation to its
+// authorities.
+func (t *txLookup) removeAuthorities(tx *types.Transaction) {
+	hash := tx.Hash()
+	for _, addr := range tx.SetCodeAuthorities() {
+		list := t.auths[addr]
+		// Remove tx from tracker.
+		if i := slices.Index(list, hash); i >= 0 {
+			list = append(list[:i], list[i+1:]...)
+		} else {
+			logger.Error("Authority with untracked tx", "addr", addr, "hash", hash)
+		}
+		if len(list) == 0 {
+			// If list is newly empty, delete it entirely.
+			delete(t.auths, addr)
+			continue
+		}
+		t.auths[addr] = list
+	}
 }
 
 // numSlots calculates the number of slots needed for a single transaction.
 func numSlots(tx *types.Transaction) int {
 	return int((tx.Size() + txSlotSize - 1) / txSlotSize)
 }
+
+// Clear implements txpool.SubPool, removing all tracked txs from the pool
+// and rotating the journal.
path: blockchain/tx_pool.go
user: login: hyeonLewis
body: We need to update comment since we do not have a concept of SubPool and it seems `Clear()` is only for testing. Is it correct?
created_at: 2025-03-05T07:28:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -867,6 +893,45 @@ func (pool *TxPool) validateTx(tx *types.Transaction) error {
 	return nil
 }
 
+// validateAuth verifies that the transaction complies with code authorization
+// restrictions brought by SetCode transaction type.
+func (pool *TxPool) validateAuth(tx *types.Transaction) error {
+	from, _ := types.Sender(pool.signer, tx) // validated
+
+	// Allow at most one in-flight tx for delegated accounts or those with a
+	// pending authorization.
+	if pool.currentState.GetCodeHash(from) != types.EmptyCodeHash || len(pool.all.auths[from]) != 0 {
+		var (
+			count  int
+			exists bool
+		)
+		pending := pool.pending[from]
+		if pending != nil {
+			count += pending.Len()
+			exists = pending.Contains(tx.Nonce())
+		}
+		queue := pool.queue[from]
+		if queue != nil {
+			count += queue.Len()
+			exists = exists || queue.Contains(tx.Nonce())
+		}
+		// Replace the existing in-flight transaction for delegated accounts
+		// are still supported
+		if count >= 1 && !exists {
+			return ErrInflightTxLimitReached
+		}
+	}
+	// Authorities cannot conflict with any pending or queued transactions.
+	if auths := tx.SetCodeAuthorities(); len(auths) > 0 {
+		for _, auth := range auths {
+			if pool.pending[auth] != nil || pool.queue[auth] != nil {
+				return ErrAuthorityReserved
+			}
+		}
+	}
path: blockchain/tx_pool.go
user: login: ulbqb
body: ```suggestion
	for _, auth := range tx.SetCodeAuthorities() {
		if pool.pending[auth] != nil || pool.queue[auth] != nil {
			return ErrAuthorityReserved
		}
	}
```
created_at: 2025-03-05T07:43:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -855,6 +871,16 @@ func (pool *TxPool) validateTx(tx *types.Transaction) error {
 		}
 	}
 
+	if tx.Type() == types.TxTypeEthereumSetCode {
+		if len(tx.AuthList()) == 0 {
+			return errors.New("set code tx must have at least one authorization tuple")
+		}
path: blockchain/tx_pool.go
user: login: tnasu
body: > If it's nil, the transaction should be valid.

Why? I think it's the same as an empty list. Then it's an invalid tx.
Basically, `SetCodeTxType` sets `Authority` of `SetCodeAuthorizations` into the EOA's `code`.

If it's a valid tx, then what should we do?

* https://github.com/ethereum/EIPs/blob/85e2e7a0d37dabe54d8d78fb755dc5c1c09f925d/EIPS/eip-7702.md?plain=1#L50
created_at: 2025-03-05T09:55:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -867,6 +893,45 @@ func (pool *TxPool) validateTx(tx *types.Transaction) error {
 	return nil
 }
 
+// validateAuth verifies that the transaction complies with code authorization
+// restrictions brought by SetCode transaction type.
+func (pool *TxPool) validateAuth(tx *types.Transaction) error {
+	from, _ := types.Sender(pool.signer, tx) // validated
+
+	// Allow at most one in-flight tx for delegated accounts or those with a
+	// pending authorization.
+	if pool.currentState.GetCodeHash(from) != types.EmptyCodeHash || len(pool.all.auths[from]) != 0 {
+		var (
+			count  int
+			exists bool
+		)
+		pending := pool.pending[from]
+		if pending != nil {
+			count += pending.Len()
+			exists = pending.Contains(tx.Nonce())
+		}
+		queue := pool.queue[from]
+		if queue != nil {
+			count += queue.Len()
+			exists = exists || queue.Contains(tx.Nonce())
+		}
+		// Replace the existing in-flight transaction for delegated accounts
+		// are still supported
+		if count >= 1 && !exists {
+			return ErrInflightTxLimitReached
+		}
+	}
+	// Authorities cannot conflict with any pending or queued transactions.
+	if auths := tx.SetCodeAuthorities(); len(auths) > 0 {
+		for _, auth := range auths {
+			if pool.pending[auth] != nil || pool.queue[auth] != nil {
+				return ErrAuthorityReserved
+			}
+		}
+	}
path: blockchain/tx_pool.go
user: login: tnasu
body: Um, it seems to be unable to avoid `nil pointer access`.
created_at: 2025-03-05T09:59:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -352,6 +352,28 @@ func (tx *Transaction) AuthList() []SetCodeAuthorization {
 	return nil
 }
 
+// SetCodeAuthorities returns a list of unique authorities from the
+// authorization list.
+func (tx *Transaction) SetCodeAuthorities() []common.Address {
path: blockchain/types/transaction.go
user: login: tnasu
body: Have you a good idea? `GetSetCodeAuthorities`?
created_at: 2025-03-05T10:15:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -352,6 +352,28 @@ func (tx *Transaction) AuthList() []SetCodeAuthorization {
 	return nil
 }
 
+// SetCodeAuthorities returns a list of unique authorities from the
+// authorization list.
+func (tx *Transaction) SetCodeAuthorities() []common.Address {
path: blockchain/types/transaction.go
user: login: hyeonLewis
body: I think it's better to keep the naming since geth uses same name.
created_at: 2025-03-05T11:02:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -855,6 +871,16 @@ func (pool *TxPool) validateTx(tx *types.Transaction) error {
 		}
 	}
 
+	if tx.Type() == types.TxTypeEthereumSetCode {
+		if len(tx.AuthList()) == 0 {
+			return errors.New("set code tx must have at least one authorization tuple")
+		}
path: blockchain/tx_pool.go
user: login: ulbqb
body: https://github.com/ethereum/go-ethereum/blob/d219e9b005358f96d85f4eb66e1418c605b2bb0f/core/txpool/validation.go#L161-L163
Oh, It become to be rejected when auth list is nil as well.
created_at: 2025-03-06T01:16:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -352,6 +352,28 @@ func (tx *Transaction) AuthList() []SetCodeAuthorization {
 	return nil
 }
 
+// SetCodeAuthorities returns a list of unique authorities from the
+// authorization list.
+func (tx *Transaction) SetCodeAuthorities() []common.Address {
path: blockchain/types/transaction.go
user: login: ulbqb
body: I see. It's better to keep it.
created_at: 2025-03-06T01:16:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -867,6 +893,45 @@ func (pool *TxPool) validateTx(tx *types.Transaction) error {
 	return nil
 }
 
+// validateAuth verifies that the transaction complies with code authorization
+// restrictions brought by SetCode transaction type.
+func (pool *TxPool) validateAuth(tx *types.Transaction) error {
+	from, _ := types.Sender(pool.signer, tx) // validated
+
+	// Allow at most one in-flight tx for delegated accounts or those with a
+	// pending authorization.
+	if pool.currentState.GetCodeHash(from) != types.EmptyCodeHash || len(pool.all.auths[from]) != 0 {
+		var (
+			count  int
+			exists bool
+		)
+		pending := pool.pending[from]
+		if pending != nil {
+			count += pending.Len()
+			exists = pending.Contains(tx.Nonce())
+		}
+		queue := pool.queue[from]
+		if queue != nil {
+			count += queue.Len()
+			exists = exists || queue.Contains(tx.Nonce())
+		}
+		// Replace the existing in-flight transaction for delegated accounts
+		// are still supported
+		if count >= 1 && !exists {
+			return ErrInflightTxLimitReached
+		}
+	}
+	// Authorities cannot conflict with any pending or queued transactions.
+	if auths := tx.SetCodeAuthorities(); len(auths) > 0 {
+		for _, auth := range auths {
+			if pool.pending[auth] != nil || pool.queue[auth] != nil {
+				return ErrAuthorityReserved
+			}
+		}
+	}
path: blockchain/tx_pool.go
user: login: ulbqb
body: It seems to handle it properly even if slice value is nil.
created_at: 2025-03-06T01:20:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1829,21 +1897,79 @@ func (t *txLookup) Add(tx *types.Transaction) {
 	t.slots += numSlots(tx)
 	slotsGauge.Update(int64(t.slots))
 
-	t.all[tx.Hash()] = tx
+	t.txs[tx.Hash()] = tx
+	t.addAuthorities(tx)
 }
 
 // Remove removes a transaction from the lookup.
 func (t *txLookup) Remove(hash common.Hash) {
 	t.lock.Lock()
 	defer t.lock.Unlock()
 
-	t.slots -= numSlots(t.all[hash])
+	tx, ok := t.txs[hash]
+	if !ok {
+		logger.Error("No transaction found to be deleted", "hash", hash)
+		return
+	}
+
+	t.removeAuthorities(tx)
+	t.slots -= numSlots(tx)
 	slotsGauge.Update(int64(t.slots))
 
-	delete(t.all, hash)
+	delete(t.txs, hash)
+}
+
+// addAuthorities tracks the supplied tx in relation to each authority it
+// specifies.
+func (t *txLookup) addAuthorities(tx *types.Transaction) {
+	for _, addr := range tx.SetCodeAuthorities() {
+		list, ok := t.auths[addr]
+		if !ok {
+			list = []common.Hash{}
+		}
+		if slices.Contains(list, tx.Hash()) {
+			// Don't add duplicates.
+			continue
+		}
+		list = append(list, tx.Hash())
+		t.auths[addr] = list
path: blockchain/tx_pool.go
user: login: yoomee1313
body: It can be simply done like below.
```suggestion
		t.auths[addr] = append(list, tx.Hash())
```
created_at: 2025-03-06T01:59:38Z
author_association: CONTRIBUTOR

PR comments:
user: login: ulbqb
created_at: 2025-03-05T07:50:33Z
body: What kind of problem can this solve?
author_association: CONTRIBUTOR
user: login: tnasu
created_at: 2025-03-05T10:13:31Z
body: @ulbqb 
> What kind of problem can this solve?

* The balance and nonce of an account can change during regular transaction execution when they have a deployed delegation.
  * The solution of simply limiting the number of in-flight pending transactions an account can have to one. This puts a reasonable bound on transaction cancellation. Normally to cancel, you would need to spend 21,000 gas. Now it's possible to cancel for around the cost of warming the account and sending value (2,600+9,000=11,600). So 50% cheaper.
* The nonce and code of an account can change without any EVM execution at all.
  * Since authorizations are not bound to a specific transaction, we cannot drop
transactions with conflicting authorizations. Otherwise, it might be possible to cherry-pick authorizations from txs and front run them with different txs at much lower fee amounts, effectively DoSing the authority. Fortunately, conflicting
authorizations do not affect the underlying validity of the transaction so we can just accept both.

author_association: CONTRIBUTOR