|
| 1 | +# Limiting Client Mempools Design Document |
| 2 | + |
| 3 | +Our client tx mempool today grows unbounded. We need to place a limit on how many txs it absorbs. |
| 4 | + |
| 5 | +This addresses how to limit the total number of valid txs. It does **not** address how to protect against DOS attacks that cause the client to validate more txs than it can process. |
| 6 | + |
| 7 | +## Tx validity |
| 8 | + |
| 9 | +A tx in Aztec requires the following checks to be valid. Static checks can be done just once, dynamic ones are done when the tx is received and again when the sequencer is building a block. If the tx fails validation during block building, it gets evicted, unless it failed due to gas fees lower than the block base fee. |
| 10 | + |
| 11 | +### Static |
| 12 | + |
| 13 | +- L1 chain id and L2 version |
| 14 | +- Setup function |
| 15 | +- Correct public execution requests and logs |
| 16 | +- Minimum gas fees (base and priority) |
| 17 | +- Reasonable gas limit (currently missing) |
| 18 | +- Valid ClientIVC proof |
| 19 | + |
| 20 | +### Dynamic |
| 21 | + |
| 22 | +- Max block number for inclusion |
| 23 | +- Double spend (repeated nullifiers) |
| 24 | +- Archive root exists (can become invalid after a reorg) |
| 25 | +- Fee juice balance for fee payer |
| 26 | +- Gas fees over current block base fee |
| 27 | +- Gas limit below current block limit |
| 28 | + |
| 29 | +## Geth |
| 30 | + |
| 31 | +What does Geth do? Following is based on geth's [legacy (non-blob) pool implementation](https://github.com/ethereum/go-ethereum/blob/master/core/txpool/legacypool/legacypool.go) |
| 32 | + |
| 33 | +- Geth runs checks by looping over its mempool at regular intervals. This includes evicting txs, and promoting/demoting txs between executable and non-executable. |
| 34 | + - A tx is considered "executable" if it has non nonce gaps and sender has enough balance to pay for its gas. |
| 35 | + - Txs are evicted based on mempool capacity, based on time, and based on the current sender balance (vs the tx max cost) and current max block size in gas (vs tx gas limit). |
| 36 | +- Geth enqueues txs per account (sender), and splits txs into executable and non-executable. Geth defines the concept of "slots", where each tx takes up a number of slots depending on its size in bytes. Defaults: |
| 37 | + - `AccountSlots` Number of executable transaction slots guaranteed per account: 16 |
| 38 | + - `GlobalSlots` Maximum number of executable transaction slots for all accounts: 4096 + 1024 |
| 39 | + - `AccountQueue` Maximum number of non-executable transaction slots permitted per account: 64 |
| 40 | + - `GlobalQueue` Maximum number of non-executable transaction slots for all accounts: 1024 |
| 41 | + - `Lifetime` Maximum amount of time non-executable transaction are queued: 3 hours |
| 42 | +- Geth checks that txs have a minimum gas price before being accepted. For replacements (ie two txs with same sender and nonce), it checks that price bumps are at least of a given %. |
| 43 | + - `PriceLimit` Minimum gas price to enforce for acceptance into the pool: 1 |
| 44 | + - `PriceBump` Minimum price bump percentage to replace an already existing transaction by nonce: 10% |
| 45 | +- When adding a new tx to the pool, after static validations, geth enqueues the tx as non-executable, and waits for the loop to promote it. |
| 46 | + - [If the tx pool is full](https://github.com/ethereum/go-ethereum/blob/80b8d7a13c20254a9cfb9f7cbca1ab00aa6a3b50/core/txpool/legacypool/legacypool.go#L691-L692), it discards cheaper txs based on gas tip (ie priority fees). Only the global slots seem to be considered here. |
| 47 | +- When cleaning up the pool in a loop, loops over pending (executable?) txs for every sender that has gone over AccountSlots, and drops txs (based on nonce) form them. Also loops over future (non-executable?) txs based on **heartbeats**: accounts with the most time without any activity get their txs pruned first. |
| 48 | + |
| 49 | +## Difficulties |
| 50 | + |
| 51 | +In addition to all complications that Ethereum has, we also have the issue that a tx public execution can invalidate an arbitrary number of existing txs just by emitting nullifiers. We have no way of knowing that in advance. |
| 52 | + |
| 53 | +Also, while for Ethereum a "replacement" is just a tx with the same nonce and sender as an existing one, for us any tx that shares a nullifier can technically be a replacement. This also means that tx A may be a replacement for B and C, but B and C may be unrelated to each other. |
| 54 | + |
| 55 | +Also, while our `fee_payer` slightly matches Ethereum's `sender`, it's possible that many users (if not all) use the same very few fee payers (in our case, FPCs), so there is likely no point in setting limits per sender as Ethereum does. Remember that, thanks to privacy, we cannot know the sender of a tx. On the flip side, we know that two txs do come from the same user if they share a private-land nullifier. |
| 56 | + |
| 57 | +## Design |
| 58 | + |
| 59 | +To recap, we need to consider: |
| 60 | + |
| 61 | +- Balance of fee-payers |
| 62 | +- Conflicting nullifiers |
| 63 | +- Max block number |
| 64 | +- Gas fees and limit vs current base fees and limits |
| 65 | +- Archive root (only on reorgs) |
| 66 | + |
| 67 | +We propose keeping the following indices for all txs. These indices are implemented as mappings from the given keys to the tx identifier in the backing LMDB store: |
| 68 | + |
| 69 | +- priority fee |
| 70 | +- fee-payer |
| 71 | +- nullifiers (indexes a tx by all of its nullifiers) |
| 72 | +- base fee |
| 73 | +- gas limit |
| 74 | +- max block number |
| 75 | + |
| 76 | +When adding a tx, we first run the trivial checks: |
| 77 | + |
| 78 | +- Correct L1 chain id and L2 version |
| 79 | +- Public setup function is acceptable |
| 80 | +- Correct public execution requests and logs |
| 81 | +- Gas fees (base and priority) above a given minimum |
| 82 | +- Valid ClientIVC proof |
| 83 | +- Max block number for inclusion is in the future |
| 84 | +- Double spend (repeated nullifiers) against existing state |
| 85 | +- Gas limit is below the current block gas limit |
| 86 | +- Archive root exists |
| 87 | + |
| 88 | +And then: |
| 89 | + |
| 90 | +- We check if the current balance of the fee payer, minus the max cost of all pending txs for that fee payer, is enough to pay for this tx. If it is not, we try evicting other txs with a lower priority fee. If that works, and all other checks pass, we include the tx dropping the others. |
| 91 | +- We check if it shares a nullifier with any existing pending tx (we already checked duplicates against current state at this point). If it pays more than all of the conflicting ones, and it passes all other checks, we include it and drop the other ones. |
| 92 | +- We check if the tx fees are above the current base fees. If not, we drop it. Note that we could save it for later in case fees drop in the future, but this means tracking two different pools (executable and non-executable, as geth does). |
| 93 | +- We check if we are below a configurable size/number of pending txs. If we are not, start dropping txs with lower priority fee (sorted by priority fee) until we get again below the threshold. |
| 94 | +- If we do add the tx, we index its max block number as the minimum of the tx's max-block-number and the current block number plus a configurable number. This allows us to evict txs after they'd been sitting in the pool for a very long time. |
| 95 | + |
| 96 | +When a new block is mined: |
| 97 | + |
| 98 | +- We drop all txs that share nullifiers with nullifiers from the mined blocks |
| 99 | +- We update the balance of fee payers and drop txs that can no longer be paid |
| 100 | +- We drop all txs with a computed max-block-number equal or lower than the mined one |
| 101 | + |
| 102 | +Note that we should not be dropping them, but rather pushing them to the side to reincorporate them in case of a reorg. But we will dismiss this for now. |
| 103 | + |
| 104 | +When a reorg happens, we crawl through all txs and evict the ones with a no-longer-valid archive root. We could also do this via an index, depending on how frequent we think reorgs will be. |
| 105 | + |
| 106 | +When building a block, for each tx we pick up: |
| 107 | + |
| 108 | +- We re-check nullifiers since public execution of previous txs in the block could invalidate the current one. If we fail validation, we do not drop the tx from the pool immediately; instead, we wait for the block to be mined, and for the p2p sync to evict the tx. |
| 109 | + - Note that, if we check duplicates against existing nullifiers on every block we add, we only need to check against nullifiers emitted during the block being built. |
| 110 | +- We check gas fees and limits against the current block base gas fees and limits. If we fail, we just skip the tx. |
| 111 | + |
| 112 | +## Alternative approaches |
| 113 | + |
| 114 | +We can rely heavily on the fact that spamming txs in Aztec is expensive due to the ClientIVC proofs, keep only a global limit on the total number/size of txs, and simply evict based on total mempool size using priority fees, plus re-checking on every block mined. This is much easier to implement than the above. |
| 115 | + |
| 116 | +An attacker can still spam txs with a shared set of nullifiers to flood the pool with just their txs, but if the priority fee is high enough (if it's too low, the attacker's txs get replaced by other txs), one of those txs will be picked up soon enough and invalidate the others; assuming we filter out the invalid ones fast enough, the sequencer eventually get to other valid txs in the pool. The main assumption is that an attacker cannot produce client proofs at a pace that lets them completely fill the mempool before the next block gets built. |
| 117 | + |
| 118 | +This approach still requires rejecting txs with an ineligible base fee or too large a gas limit, otherwise the attacker could flood the tx with non-executable txs. It also requires reviewing all txs on the mempool whenever a block is mined to drop them based on shared nullifiers, insufficient balance, or max-block-age. |
0 commit comments