A privacy pool on frame transactions: what works, and the case that does not yet

Daniil Ankushin, Nethermind. Verify-budget figures: method by Daniil Ankushin, benchmark and measurements by Manuel Arto.

On frame transactions a shielded pool pays its own gas, so a private withdrawal needs no relayer. We ran a real BN254 Groth16 pool this way on current Nethermind master: a withdrawal costs 679,322 gas. With EIP-8250 nonce keys, many withdrawals can come from the one pool address at once. Two things do not work yet. The proof check is over the spec cap on validation gas, and a pool cannot pay for a call from the user’s own address.

The pool we ran is soispoke’s minimal-shielded-pool (GitHub - soispoke/minimal-shielded-pool · GitHub).

How a withdrawal works

In a withdrawal the pool is the sender. Its VERIFY code checks the zk proof and approves execution and payment. The user needs no funded account and does not appear in the transaction.

Two drafts make this safe in a public mempool. With EIP-8250 the pool uses the note’s nullifiers as nonce keys. Each withdrawal has its own nonce key, so withdrawals with the pool as sender do not compete for one nonce. A side effect: a nullifier that was already used is a used nonce key, so the protocol refuses a double spend before the pool’s code runs. With EIP-8272 the transaction checks the pool’s Merkle root against a contract of recent roots before the pool’s code runs, so a mempool node never reads the pool’s storage.

What it costs

All values are from mined receipts on Nethermind master 4bcb9e30, single node.

Step Gas
Deposit a note, frame tx 990,503
of which state gas 195,840
Deposit a note, plain EIP-1559 call 958,555
Withdrawal, whole transaction 679,322
recent-root VERIFY frame 5,515
pool VERIFY frame (proof check and APPROVE) 405,979
of which execution 210,139
of which state gas for two nullifier keys 195,840
settle SENDER frame 25,526
claim frame 198,338
outside the frames (intrinsic, calldata, key access) 43,964
Bare Groth16 check, as its own transaction, including the 21,000 base 222,991
Transfer, two notes in and two out, whole transaction 1,333,766
of which state gas 293,760

The two nullifier keys cost 195,840 state gas, exactly two times the EIP-8250 price: “each fresh key costs 97920 state gas”. EIP-8250 also charges 12,100 execution gas per key and adds the same amount to the calldata floor.

The proof does not fit the shared budget. The validation prefix declares 233,000 execution gas and uses 215,654, over the EIP-8141 cap MAX_VERIFY_GAS. An open change (Update EIP-8141: define MAX_VERIFY_GAS as a shared mempool floor by AnkushinDaniil · Pull Request #12301 · ethereum/EIPs · GitHub) would make that cap a shared mempool floor, with each node free to run higher, and set its value from measurements. Nethermind’s default node budget is already 300,000 (fix(txpool): separate verification floor from node headroom by SIDDHANTCOOKIE · Pull Request #14992 · NethermindEth/nethermind · GitHub), so a default node admitted our withdrawal; a node at the floor would not. The published measurement note (Sizing MAX_VERIFY_GAS: how much unpaid work a node can absorb - HackMD) measured Nethermind d804d33b on an AMD EPYC 4344P and a GCE c2-standard-8, the two local builder machines. There a 300,000 budget held one attacker peer on Nethermind at 100 failing transactions per second when block building may slow by 25 ms, and at 117 at 50 ms. We re-ran the same suites on current Nethermind master (1f6469e64ee) on one shared 12 vCPU AMD EPYC 9645 host. There a 250,000 budget holds one attacker peer on Nethermind at 25 ms with no margin (100 failing transactions per second, against about 100 that one peer gets processed). A 300,000 budget holds 83 at 25 ms, so it holds one peer only at 50 ms (117). The about 100 per peer comes from Nethermind’s flood controller; other clients bound a peer less. Both comparisons cross machines, so these runs cannot tell whether the change comes from the code or the machine. Our 233,000 fits under both. For a pool to work on every node, either the floor rises from measurement, or proof checks leave the per-node budget, for example through proof aggregation.

New storage is a large part of the cost. In the withdrawal, state gas is 379,440 of 679,322: 195,840 for the two nullifier keys and 183,600 in the claim frame.

Double spends

We sent a second withdrawal with the same nullifier keys and a new body. The node refused it at admission: “keyed nonce sequence not current”. When we forced it into a block through the builder, the block included zero transactions. After the first withdrawal, both nullifier slots in the nonce manager read 1.

The open question: a pool that only pays

Above, the call comes from the pool. Often the user wants to act from their own address while the pool pays the gas out of a note.

Here EIP-8250 stops helping. It stores each key under (sender, key). With the user as sender, the same nullifier sent from two addresses is two different keys, and the pool gets no spent-once mark from the protocol.

The pool cannot use its own storage instead. Under the EIP-8141 public mempool rules, “SLOAD can be used only to access tx.sender storage”, and here the pool is not the sender. The same rules allow MAX_PENDING_TXS_USING_NON_CANONICAL_PAYMASTER = 1 pending transaction per non-canonical paymaster, so a pool that only pays serves one user at a time on each node. jstinhw raised the case in the MATCHA thread (Mempool Account Transaction Capacity from Historical Activity (MATCHA)): 100 transactions from 100 senders, each with a valid proof for the same note. mmjahanara’s reply in the same thread reaches the same conclusion.

We proposed a spec change for this: let a transaction place its keys under the account that approves payment (Update EIP-8250: let a transaction scope its nonce keys to the payer by AnkushinDaniil · Pull Request #12422 · ethereum/EIPs · GitHub). nerolation, an author of EIP-8250, answered:

I’m wondering whether improving that app-level nullifier tracking is worth adding a new field and weakening the invariant that all transaction nonces belong to tx.sender. My feeling is no and we may not need the 2d nonces for payers when tx.sender != payer.

We closed the PR. The invariant is worth more than one saved transaction.

We also tried this shape on master with the unchanged pool: the user’s account approves execution, and the pool’s VERIFY frame approves payment. The node refused it at admission: “frame transaction validation-prefix simulation failed, validation prefix frame reverted”. The pool’s VERIFY code assumes the pool is the sender, so this refusal says nothing about the spec.

So today two shapes work in the public mempool: the pool is the sender, as measured above, or two transactions, where the user withdraws to an address that then pays for itself. The third shape stays open: a pool that pays for a call from the user’s own address, in one transaction, through the public mempool. We see no way with the current drafts and have no gas number for it.

How to reproduce

Nethermind master 4bcb9e30986a86061fc8b7efe33abeeaa31f745a, one node, EIP-8141, EIP-8250, EIP-8272 and EIP-7906 active from block 1. Genesis holds the EIP-8272 recent-root code and the EIP-8250 nonce manager code. Pool: GitHub - soispoke/minimal-shielded-pool · GitHub at bb45b3b9, with the recent-root contract address and the fee parameter index updated to the current specs. Proofs come from the repository’s own tooling.