Skip to content

Commit 5a2e814

Browse files
authored
dd: Limiting mempools (#57)
* dd: Limiting mempools * Address comments by Lasse * Address comments from Mitch
1 parent 5a9d2dd commit 5a2e814

File tree

1 file changed

+118
-0
lines changed

1 file changed

+118
-0
lines changed

docs/mempools/dd.md

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
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

Comments
 (0)