Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

core/txpool/legacypool: add support for SetCode transactions #31073

Merged
merged 15 commits into from
Feb 11, 2025

Conversation

lightclient
Copy link
Member

@lightclient lightclient commented Jan 23, 2025

The new SetCode transaction type introduces some additional complexity when handling the transaction pool.

This complexity stems from two new account behaviors:

  1. The balance and nonce of an account can change during regular transaction execution when they have a deployed delegation.
  2. The nonce and code of an account can change without any EVM execution at all. This is the "set code" mechanism introduced by EIP-7702.

The first issue has already been considered extensively during the design of ERC-4337, and we're relatively confident in 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 second issue is more novel and needs further consideration.

There are a few issues we must carefully design around:

  • 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.
  • TODO

A few situations to test:

  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.

maybe try to write out each possible scenario for all types of txs entering the
pool +different 7702 situations and consider outcomes?

@lightclient lightclient force-pushed the eip-7702-txpool branch 2 times, most recently from 77301e7 to b2ece7b Compare January 25, 2025 22:32
@rjl493456442
Copy link
Member

We have to add the SetCodeType support in the txpool, https://github.com/ethereum/go-ethereum/blob/master/core/txpool/legacypool/legacypool.go#L280

@sebastianst
Copy link
Contributor

Hey @lightclient happy to see you working on this. While writing end-2-end tests for SetCode transaction support in the OP Stack, we realized that the geth tx pool doesn't support them yet. Is there an ETA for this PR to land?

@lightclient lightclient marked this pull request as ready for review February 4, 2025 20:33
@lightclient lightclient requested a review from holiman as a code owner February 4, 2025 20:33
@rjl493456442
Copy link
Member

I’m fine with adding the restriction to the txpool, ensuring that delegation and normal transactions cannot coexist simultaneously.

However, I’m curious about the typical transaction flow in real-world usage and unsure if it’s valid to first have a delegation and then follow it up with a normal transaction.

In the legacyPool, we maintain a pendingNoncer to ensure the multiple inflight transactions from the same sender could be accepted and bundled in a single block. We can extend this noncer a bit by considering the auths as well in the future, to have more flexible/loose rules on txpool

// Verify no authorizations will invalidate existing transactions known to
// the pool.
if opts.KnownConflicts != nil {
if conflicts := opts.KnownConflicts(from, tx.Authorities()); len(conflicts) > 0 {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IIUC we only check authority conflicts between "normal" transactions and ignore blob transactions. I think thats fine, I did a benchmark back then which showed that our blobpool can handle removals and reorgs quite well. I will try to find that benchmark again to add it

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It probably makes sense to reserve all seen the tx authorities to the legacy pool to totally avoid this conflict?

@lightclient
Copy link
Member Author

I’m curious about the typical transaction flow in real-world usage and unsure if it’s valid to first have a delegation and then follow it up with a normal transaction.

The real world use case is almost certain:

  1. User submits 7702 delegation to upgrade their account
  2. From there on, a 3rd party relayer submits the user's operations to the chain (likely in a bundle)
  3. (optional) eventually the user might decide to remove / update their delegation

It's possible there is a small cohort of users who self-sponsor to their delegated account, but in those cases we don't need to handle multiple pending txs from the account since they can batch their calls in a single tx.

In the legacyPool, we maintain a pendingNoncer to ensure the multiple inflight transactions from the same sender could be accepted and bundled in a single block. We can extend this noncer a bit by considering the auths as well in the future, to have more flexible/loose rules on txpool

We can see how usage plays out and if there is demand. Since it's possible to set a delegation for an account and in the same tx make batched calls on behalf, I don't see a reason that user would want to stack a tx on a delegation.

@fjl fjl changed the title txpool: add support for set code transactions core/txpool/legacypool: add support for SetCode transactions Feb 6, 2025
// KnownConflicts is an optional callback which iterates over the list of
// addresses and returns all addresses known to the pool with in-flight
// transactions.
KnownConflicts func(sender common.Address, authorizers []common.Address) []common.Address
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this add to blobTxPool too?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An authority account still can send blobTxs even if he has a conflict nonce in the legacyPool.

I think you can add a checkConflict method to subpools, and move the check to txpool.ValidateTransaction

Comment on lines 592 to 595
// The transaction sender cannot have an in-flight authorization.
if _, ok := pool.all.auths[from]; ok {
conflicts = append(conflicts, from)
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I may ask simply out of curiosity, what is the reason for this rule?
It seemed to me that an "in-flight" (meaning "currently in the mempool", right?) authorization is not very different from an authorization already on-chain. At least, not in how it can suddently invalidate some "legacy" transaction from the mempool.
And if I understand this code correctly, at this point the authorizations in pool.all.auths are not even checked to have a valid nonce, so even an invalid authorization in a mempool prevents a "legacy" transaction from entering the mempool.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The attack I was trying to avoid here is many EOA accounts send the max number of pending txs possible and all of those accounts get invalidated with a single 7702 tx. There also doesn't seem to be a good reason for an honest user to have both a pending delegation and an unrelated pending tx originating from their account.

This policy is probably too draconian and can be used to deny service from regular users. Maybe a better thing to do is verify the auths as they enter the pool, then when txs are included on chain, purge all pending txs with that auth.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I ended up relaxing the requirement slightly so accounts with a pending delegation can originate 1 tx. This matches what is offered for accounts with deployed delegations while avoiding the costly state lookup for every account in a 7702 tx.

@islishude
Copy link
Contributor

islishude commented Feb 8, 2025

I think here should check the continuity of nonces

index d11e9c0b5..30fa3a2b1 100644
--- a/core/txpool/validation.go
+++ b/core/txpool/validation.go
 
@@ -227,6 +231,40 @@ func ValidateTransactionWithState(tx *types.Transaction, signer types.Signer, op
        if next > tx.Nonce() {
                return fmt.Errorf("%w: next nonce %v, tx nonce %v", core.ErrNonceTooLow, next, tx.Nonce())
        }
+       // If the transaction is of type SetCodeTxType, validate the authorizations
+       if tx.Type() == types.SetCodeTxType {
+               // Initialize a map to track nonces for each authority
+               nonces := map[common.Address]uint64{from: tx.Nonce()}
+               // Iterate through each authorization in the transaction
+               for _, auth := range tx.SetCodeAuthorizations() {
+                       // Check if the chain ID in the authorization matches the signer's chain ID
+                       if auth.ChainID.CmpBig(signer.ChainID()) != 0 && !auth.ChainID.IsZero() {
+                               return core.ErrAuthorizationWrongChainID
+                       }
+                       // Retrieve the authority address from the authorization
+                       authority, err := auth.Authority()
+                       if err != nil {
+                               return errors.Join(core.ErrAuthorizationInvalidSignature, err)
+                       }
+                       // Validate the nonce in the authorization
+                       if nonce, ok := nonces[authority]; ok {
+                               if auth.Nonce != nonce+1 {
+                                       return fmt.Errorf("invalid nonce in authorization for %s: expect %d but got %d", authority, nonce+1, auth.Nonce)
+                               }
+                       } else {
+                               // Retrieve the next expected nonce for the authority from the state
+                               next := opts.State.GetNonce(authority)
+                               if next != auth.Nonce {
+                                       return fmt.Errorf("invalid nonce in authorization for %s: expect %d but got %d", authority, next, auth.Nonce)
+                               }
+                               // Verify the code of authority is either empty or already delegated
+                               code := opts.State.GetCode(authority)
+                               if _, ok := types.ParseDelegation(code); len(code) != 0 && !ok {
+                                       return core.ErrAuthorizationDestinationHasCode
+                               }
+                       }
+                       // Update the nonce for the authority in the map
+                       nonces[authority] = auth.Nonce
+               }
+       }        
        // Ensure the transaction doesn't produce a nonce gap in pools that do not
        // support arbitrary orderings
        if opts.FirstNonceGap != nil {

@fjl
Copy link
Contributor

fjl commented Feb 8, 2025

It's not necessary to validate authorization nonces in the txpool. Authorizations are pre-paid by the fee of the transaction, and their validity will be checked when applying the transaction in a block (invalid authorizations will be ignored). In the txpool, we do not care whether transactions 'work' or not, we just care about whether the fees are high enough and the sender account can cover them.

@islishude
Copy link
Contributor

It's not necessary to validate authorization nonces in the txpool.

But this invalid transaction will remain in txpool and be propagated by p2p until timeout

@fjl
Copy link
Contributor

fjl commented Feb 8, 2025

That's not true. The transaction can be applied anyway. Since invalid authorizations are ignored when processing a SetCode transaction, the transaction itself is valid even if it contains authorizations with wrong nonce.

if msg.SetCodeAuthorizations != nil {
for _, auth := range msg.SetCodeAuthorizations {
// Note errors are ignored, we simply skip invalid authorizations here.
st.applyAuthorization(&auth)
}

@fjl
Copy link
Contributor

fjl commented Feb 8, 2025

The problem with validating authorizations in txpool is that it's too expensive. We'd have to access the state for each authorization, and there can be many of them in a single transaction. These accesses are not paid for by the transaction (the gas fee only covers state access in block), so if the transaction turns out invalid or gets removed from the pool, we did all these checks for nothing. It'd be a griefing vector.

@islishude
Copy link
Contributor

thanks for the head up, that's a good design.

@drortirosh
Copy link

got Error: intrinsic gas too low: gas 25899, minimum needed 46000
which looks like eth_estimateGas doesn't take into account the cost of authorizationList
(when manually passing gas, it passes ok, which means the actual estimation code does honor the authorizationList, just doesn't check its cost)

@@ -565,6 +567,11 @@ func (pool *LegacyPool) validateTx(tx *types.Transaction) error {
if list := pool.queue[addr]; list != nil {
have += list.Len()
}
if pool.currentState.GetCodeHash(addr) != types.EmptyCodeHash || len(pool.all.auths[addr]) != 0 {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you think len(pool.all.auths[addr]) != 0 could become an attack vector? Consider the following scenario:

  • An authorization w is created and included in transaction x.
  • An attacker copies the same authorization w and includes it in a new transaction y, submitting y to the network with a relatively lower gas price.
  • If a node receives transaction y first, it will reject transaction x.
  • Furthermore, if x contains multiple authorizations, constructing and submitting a conflicting transaction like y could effectively prevent the entire authorization list in x from being included.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this case, the len(pool.all.auths[addr]) != 0 is used to limit the number of txs an account can queue when they have the ability to sweep their balance from underneath the txpool w/o originating a tx to do so. Without this check, what is possible:

A submits a tx which sets code in B. During the tx, B sweeps its balance. Immediately after A submits tx1, B submits 16 txs to the pool. When A is mined first, it invalidates all txs from B. You can expand the A's tx to include many accounts, now A can invalidate many txs from many accounts.

By only allowing a single tx from accounts who have code / will potentially have code soon, we can reduce the potency of this attack.

Originally I blocked this attack, by not allowing a tx from an account with a pending delegation, but this leads to the attack Gary described. Now that we allow 1 tx from the account with a pending delegation, we can avoid that account getting deadlocked.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this example, X and Y are from different senders, say A an B respectively. Even if X is from A and includes the delegation for A, when B copies the auth and puts it in Y, the node will still accept X because although it has a pending delegation, it will still have 1 free slot for the account A. This does mean B can limit the number of txs A can queue.

The only scenario the tx will be rejected is as follows: suppose A submits an auth to bundler B. Say B submits the auth in tx Y. If A is able to queue a tx X before nodes start recieving Y then Y will be rejected.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A submits a tx which sets code in B. During the tx, B sweeps its balance. Immediately after A submits tx1, B submits 16 txs to the pool. When A is mined first, it invalidates all txs from B. You can expand the A's tx to include many accounts, now A can invalidate many txs from many accounts.

Will this have a serious impact? I think we have the similar issues nowadays in the legacy pool. Let's say account a has 16 transactions with continuous nonces. If the first transaction drains the balance of A and the following 15 transactions will be invalidated later.

In the scenario you mentioned, tx can set code for a list of accounts [B, C, D, ..., Z] and meantime these accounts can submit a tons of pending transactions. However, these accounts must sign an auth in which the balance will be drained by the code. Although the scale of this invalidation seems to be magnified, but it should be feasible to submit legacy pending transactions from multiple accounts and invalidate the remaining by using balance in the first tx?

Maybe I miss something

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if I understand this. The quote you have here was me explaining an attack that we avoid by limiting the account to 1 pending tx? I don't think you can easily replace all the txs pending for an account because we check the total value of the stack?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think you can easily replace all the txs pending for an account because we check the total value of the stack?

Yeah right. There is no way to drain the balance of an EOA without the deployed code!

@fjl fjl merged commit cdb66c8 into ethereum:master Feb 11, 2025
3 of 4 checks passed
tokeyg pushed a commit to tokeyg/go-ethereum that referenced this pull request Feb 13, 2025
…m#31073)

The new SetCode transaction type introduces some additional complexity
when handling the transaction pool.

This complexity stems from two new account behaviors:

1. The balance and nonce of an account can change during regular
   transaction execution *when they have a deployed delegation*.
2. The nonce and code of an account can change without any EVM execution
   at all. This is the "set code" mechanism introduced by EIP-7702.

The first issue has already been considered extensively during the design
of ERC-4337, and we're relatively confident in 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 second issue is more novel and needs further consideration.
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.

---------

Co-authored-by: Marius van der Wijden <[email protected]>
Co-authored-by: Felix Lange <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

9 participants