Skip to content

evm: gas of checking a leanXMSS proof of possession in the EIP-8483 add (Experiment, DO NOT MERGE) - #750

Draft
jsign wants to merge 13 commits into
leanEthereum:mainfrom
jsign:evm-xmss-verifier
Draft

jsign wants to merge 13 commits into
leanEthereum:mainfrom
jsign:evm-xmss-verifier

Conversation

@jsign

@jsign jsign commented Nov 12, 2026 •

Copy link
Copy Markdown
Contributor

(Note: this is experimental, the optimized bytecode could be potentially FVed -- now passes all test vectors in the codebase)

This PR measures how much gas it takes to verify a leanXMSS proof of possession in an EVM contract. It's for the EIP-8483 discussion of whether the PQ registration predeploy could check the proof itself, with the signature in calldata and never stored or sent to the CL.

This PR:

  • Adds two leanXMSS verifiers, A in plain Solidity and B with its hash inputs built in assembly. Both pass the 21 EthLib vectors their signature can take.
  • Adds a wrapper that uses B to check the proof, a signature at leaf 0 of M = keccak256(chainid | bls_pubkey | xmss_public_key), and then DELEGATECALLs the real EIP-8483 bytecode. A second wrapper, C0, skips the check, to separate the cost of the extra calldata from the cost of verifying.
  • Adds measure.py, which runs everything on anvil on Amsterdam, as Foundry 1.9.0 implements it, checks each receipt against its intrinsic gas, calldata floor and traced execution, and prints every number.

Everything lives in evm/xmss_verifier, outside the Cargo workspace.

Gas of one call to verify, from the lowest to the highest over the 4 valid vectors:

A B
Execution, ABI decoding included 289244 to 289596 43902 to 43972
Transaction calling it (gasUsed) 344007 to 344420 179096

B's transaction pays the calldata floor, so its gasUsed is the same on every vector. B spends 7566 gas in its 172 KECCAK256s.

Gas of one registration, without the proof and with it:

Without With Proof adds
S1, the first request ever 907459 993121 9%
S2, the first request of a block 222019 307681 39%
S3, a later request in the same block 114030 199692 75%

Most of an add's gas in S1 is the state gas EIP-8037 charges for each new storage slot, which is why the proof is a smaller share there.

The proof adds the same gas in every scenario:

Gas
The signature's 2404 bytes of calldata 38284
Checking it: B's verify, M and the leaf index 44193
The wrapper's DELEGATECALL 3185
Total 85662
In a predeploy that checks the proof itself (estimate) 82477

In USD, with ETH at $2,054.82 (now). The proof alone is 0.0017 ETH at 20 gwei.

1 gwei 5 gwei 20 gwei
S1 $1.86 (+$0.18 with proof, +9%) $9.32 (+$0.88 with proof, +9%) $37.29 (+$3.52 with proof, +9%)
S2 $0.46 (+$0.18 with proof, +39%) $2.28 (+$0.88 with proof, +39%) $9.12 (+$3.52 with proof, +39%)
S3 $0.23 (+$0.18 with proof, +75%) $1.17 (+$0.88 with proof, +75%) $4.69 (+$3.52 with proof, +75%)

M isn't EIP-8450's signing root, which would need per-network constants in the bytecode. Using the root would cost only about 591 more gas (estimate), and M lets one bytecode serve every network. Since leaf 0 can sign only once, this proposes that EIP-8450's possession message become M.

jsign added 13 commits November 11, 2026 19:49
… of the first-leaf vector and the registration fixture
… the four valid vectors

Forge 1.9.0 runs a test's calls as separate transactions by default, but only CALLs, and verify is view, so the test calls it with a low-level call. The transaction snapshot is then the receipt's gasUsed, and a nested call's frame gives the execution on its own, ABI decoding included. On both schedules each transaction snapshot equals max(base + standard calldata + execution, floor) over its 2564 bytes of calldata: A pays the standard intrinsic gas and B the floor.

Min and max over the four vectors:

                   Amsterdam            Osaka
A execution        289244 to 289596     289244 to 289596
A transaction      344007 to 344420     350007 to 350420
B execution        43902 to 43972       43902 to 43972
B transaction      179096               120380 to 120590

The base is 15000 on Amsterdam and 21000 on Osaka. Amsterdam's floor is 64 gas per byte whatever the byte, so B's transaction costs the same 179096 on every vector, against Osaka's 10 per zero and 40 per nonzero byte.
… of possession, and through one that does not
…n its place, and print every number per scenario
…umber in this message

This adds the README. measure.py reproduces every number below, all measured with Foundry 1.9.0 except the two marked as estimates.

The proof of possession adds the same gas to an add in every scenario: 85662 on Amsterdam and 85262 on Osaka. That's 38284 for the signature's 2404 bytes of calldata, 44193 to check it (B's verify, M and the leaf index) and 3185 on Amsterdam or 2785 on Osaka for the wrapper's DELEGATECALL. A predeploy that checked the proof itself wouldn't pay the DELEGATECALL, so it would add about 82477 on both (estimate). The intrinsic base of every add is 21000 on both hardforks: Amsterdam charges 15000 for a call without value, but 21000 with the 1-wei fee. Only C0 in S3 pays the calldata floor, which is why C - C0 there is smaller than the 44193 of execution between them.

S1, the first request ever:

                                  Amsterdam      Osaka
add without the proof                907459     203799
C0, the proof unchecked              948928     244868
C, the proof checked                 993121     289061
ETH without the proof, 1 gwei      0.000907   0.000204
ETH without the proof, 5 gwei      0.004537   0.001019
ETH without the proof, 20 gwei     0.018149   0.004076
ETH with the proof, 1 gwei         0.000993   0.000289
ETH with the proof, 5 gwei         0.004966   0.001445
ETH with the proof, 20 gwei        0.019862   0.005781

S2, the first request of a block, on a queue used before:

                                  Amsterdam      Osaka
add without the proof                222019      84099
C0, the proof unchecked              263488     125168
C, the proof checked                 307681     169361
ETH without the proof, 1 gwei      0.000222   0.000084
ETH without the proof, 5 gwei      0.001110   0.000420
ETH without the proof, 20 gwei     0.004440   0.001682
ETH with the proof, 1 gwei         0.000308   0.000169
ETH with the proof, 5 gwei         0.001538   0.000847
ETH with the proof, 20 gwei        0.006154   0.003387

S3, a later request in the same block:

                                  Amsterdam      Osaka
add without the proof                114030      64130
C0, the proof unchecked              182024     121190
C, the proof checked                 199692     149392
ETH without the proof, 1 gwei      0.000114   0.000064
ETH without the proof, 5 gwei      0.000570   0.000321
ETH without the proof, 20 gwei     0.002281   0.001283
ETH with the proof, 1 gwei         0.000200   0.000149
ETH with the proof, 5 gwei         0.000998   0.000747
ETH with the proof, 20 gwei        0.003994   0.002988

Verify alone, from the lowest to the highest of the 4 valid vectors:

                                  Amsterdam               Osaka
A, the transaction           344007 to 344420    350007 to 350420
A, its execution             289244 to 289596    289244 to 289596
B, the transaction                     179096    120380 to 120590
B, its execution               43902 to 43972      43902 to 43972

The transaction is the receipt's gasUsed, with the intrinsic gas or the calldata floor; the execution includes ABI decoding. A pays the standard intrinsic gas and B the floor, on both hardforks.

B's verify of the fixture executes in 43952 gas on both hardforks: 7566 in its 172 KECCAK256 opcodes (169 over 64 bytes for 7098, one over 80 for 48 and two over 960 for 420, each exactly 30 plus 6 per word) and 36386 in everything else.

Computing M costs 121 gas on both. EIP-8450's signing root costs 712 in test/SigningRoot.sol, so using it instead would add about 591 (estimate).

This branch has not been deployed

No deployments
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.

1 participant