Skip to content

Conversation

@PhilWindle
Copy link
Contributor

No description provided.

Copy link
Contributor

@spalladino spalladino left a comment

Choose a reason for hiding this comment

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

Just checked the code and the flow when receive a tx is:

  • Validate it (of course)
  • Add it to the local mempool
  • Evict txs from the mempool if needed
  • Broadcast it via p2p

So if we (as a node) receive a tx that we add and evict immediately, we still broadcast it. In other words, if we receive a tx that we ourselves would not keep, we are still pushing it to the world.

I think an easy fix to prevent diverging mempools and flooding the network with useless txs would be to only re-broadcast a tx if we'd accept it (and keep it) in our own mempool.


The only way to moderate the flow of transactions around the network is to control their ingress at source. Once a tx is being propagated it can't be impeded, to do so would exacerbate the problem of divergent mempools.

So the proposed changes are to track the average rate of transaction delivery at the p2p network, say over the last `N` seconds where `N` is configurable. If this goes beyond a configured threshold, transactions will be rejected at the RPC layer until it reduces. No newline at end of file
Copy link
Contributor

Choose a reason for hiding this comment

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

I didn't understand what's tracked exactly here. By transaction delivery, does it mean the number of txs received via JSON RPC API that this node injected into the p2p network? Or all txs that were broadcasted, including the ones received via p2p?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I would see it as ('Transactions I received over the P2P network' + 'Transactions I introduced to the P2P network' / 'N'. My observed TPS of the network.

@PhilWindle
Copy link
Contributor Author

I think an easy fix to prevent diverging mempools and flooding the network with useless txs would be to only re-broadcast a tx if we'd accept it (and keep it) in our own mempool.

This doesn't seem right to me. Just because a transaction isn't of interest to me, or doesn't fit in my local store, doesn't mean it isn't of interest to you, so I shouldn't withhold it.

The ideal situation in my view is that nodes don't evict transactions at all until they become expired. Now of course presumably there needs to be some limit, but hopefully it could be huge. This is just part of the responsibility of contributing to tx availability which I think everyone running a node needs to do.

Lets say the expectation was that nodes store up to 1 million pending transactions. That a little more than 1 day's worth of backlog at 10TPS. Max transaction expiry is 24 hours. That's about 60GB assuming compressed proofs. Which doesn't seem so bad. But maybe that could lead to problems with people spamming low value transactions so need more thought.

@spalladino
Copy link
Contributor

spalladino commented Nov 7, 2025

Disagree. I think that we should strive to minimize divergence across node tx pools, and not broadcasting txs that are interesting to me is a good way to do it, while at the same time minimizing low-value traffic on the p2p network. Even if the limits for expiry are huge, they are still there, so we cannot assume that node tx pools will always have enough room.

FWIW I asked Claude what geth does, by looking at the codebase. It seems Geth only broadcasts txs that were accepted in its own tx pool.


No, if a transaction doesn't fit in the mempool because it's full and there's no lower-priority tx to evict, the new transaction does NOT get broadcasted.

Transaction Flow from JSON-RPC to Broadcasting

1. JSON-RPC Submission

Location: internal/ethapi/api.go:1560

When you call eth_sendRawTransaction, it goes through:

SubmitTransaction() → b.SendTx(ctx, tx)

2. TxPool Admission

Location: eth/api_backend.go:323

The transaction is added to the pool:

b.eth.txPool.Add([]*types.Transaction{signedTx}, false)[0]

3. Legacy Pool Processing

Location: core/txpool/legacypool/legacypool.go:935-983

The pool calls pool.add(tx) which performs validation and capacity checks.

Critical check at lines 713-738:

if uint64(pool.all.Slots()+numSlots(tx)) > pool.config.GlobalSlots+pool.config.GlobalQueue {
    // If the new transaction is underpriced, don't accept it
    if pool.priced.Underpriced(tx) {
        return false, txpool.ErrUnderpriced
    }
    // Try to evict lower-priced transactions...
    drop, success := pool.priced.Discard(...)

    if !success {
        return false, ErrTxPoolOverflow  // ← REJECTED HERE
    }
}

4. Broadcasting Mechanism

Location: core/txpool/legacypool/legacypool.go:1287-1331

  • Broadcasting only happens for transactions promoted to the pending/executable state
  • The NewTxsEvent is triggered at line 1331, which sends promoted transactions
  • The handler subscribes to this event at eth/handler.go:436
  • Broadcasts them in txBroadcastLoop() at eth/handler.go:533-543

Why Rejected Transactions Don't Get Broadcasted

The key insight is the separation between admission and broadcasting:

Admission Happens First

At core/txpool/legacypool/legacypool.go:713-738, when the pool is full, the transaction is rejected with ErrTxPoolOverflow if it cannot evict lower-priority transactions.

Broadcasting Happens Later

Only after a transaction is:

  1. Successfully added to the pool AND
  2. Promoted to executable/pending state

Does it trigger a NewTxsEvent at core/txpool/legacypool/legacypool.go:1331.

The Transaction Never Reaches the Broadcast Stage

If rejected during admission, it never enters the pool, never gets promoted, and never triggers the broadcast event.

Special Case: Local Transactions

Location: eth/api_backend.go:327-341

// If the local transaction tracker is not configured, returns whatever
// returned from the txpool.
if b.eth.localTxTracker == nil {
    return err
}
// No error will be returned to user if the transaction fails with a temporary
// error and might be accepted later (e.g., the transaction pool is full).
// Locally submitted transactions will be resubmitted later via the local tracker.
b.eth.localTxTracker.Track(signedTx)
return nil

For transactions submitted locally via JSON-RPC, even if rejected with ErrTxPoolOverflow, they may be tracked and retried later. However, they're still not broadcasted at the time of initial rejection.

Summary

A transaction that doesn't fit in the mempool due to being full (and unable to evict lower-priority transactions) is:

  • Not added to the pool
  • Not promoted to executable/pending state
  • Not broadcasted to peers
  • Possibly tracked locally for retry (if local tracker is enabled)

Key Architectural Point

The broadcasting mechanism in geth is pull-based from the txpool, not push-based from the API layer. Only transactions that successfully enter the pool and become executable trigger broadcast events.

@PhilWindle
Copy link
Contributor Author

FWIW I asked Claude what geth does, by looking at the codebase. It seems Geth only broadcasts txs that were accepted in its own tx pool.

Very interesting

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants