Skip to content

fix: F-2026-18140 | [Dual Defense] Underpriced Ed25519 Raw-Message Precompile Gas - #346

Merged
0xNilesh merged 1 commit into
audit-fixesfrom
F-2026-18140
Aug 26, 2026
Merged

fix: F-2026-18140 | [Dual Defense] Underpriced Ed25519 Raw-Message Precompile Gas#346
0xNilesh merged 1 commit into
audit-fixesfrom
F-2026-18140

Conversation

@0xNilesh

Copy link
Copy Markdown
Member

F-2026-18140 — Underpriced Ed25519 Raw-Message Precompile Gas

precompiles/usigverifier charged a flat 4000 gas for verifyEd25519RawMessage(bytes,bytes,bytes)
regardless of message length, while Run calls ed25519.Verify(pub, message, sig) over the whole
slice. Ed25519 hashes the entire message, so CPU scales with length and gas did not.

Measured on a live node (median of 9 reps): ~58 µs at 32 B → ~922 µs at 1 MB — 16x the work for the
same 4000 gas.
Because the method is view, the realistic abuse is a contract holding one large
message in memory and looping STATICCALLs: the calldata is paid once, the verification repeats
at the flat price.

Fix — dual defense

1. Price the message per 32-byte word

gas = 4000 + ceil(len(message)/32) * 12

12 gas per 32-byte word is the rate the EVM SHA-256 precompile charges, and Ed25519's marginal
cost past the fixed curve arithmetic is the same kind of hashing work. Below ~8 KiB the flat portion
still dominates, which is why the base stays at 4000.

len(message) gas was
0 4,000 4,000
32 B 4,012 4,000
1 KiB 4,384 4,000
8 KiB 7,072 4,000
64 KiB 28,576 4,000
128 KiB (cap) 53,152 4,000
> 128 KiB reverts 4,000

RequiredGas receives the raw calldata, so the length is recovered straight out of the ABI head/tail
(rawMessageLen) with bounds-checked word reads — no big.Int, no decoding, and it cannot panic on
malformed input. Calldata that does not parse prices at the base; a declared length too large for a
uint64 prices at the cap, so lying about the size is not the cheap path.

2. Hard-cap the message at 128 KiB

MaxEd25519MessageBytes = 128 * 1024; anything larger reverts with message too large. On a
fee-exempt view path a price curve alone is not the defence — a hard limit is. 128 KiB matches the
gateway payload cap from F-2026-18146 so there is one size limit to reason about, not two.

Net effect on the abuse shape: at a 100M block gas limit, a cap-sized message buys 1,881
verifications per block instead of 25,000 — a 13x reduction, and messages past 128 KiB cannot be
verified at all.

Why only the raw method changes

VerifyEd25519Gas and VerifyEd25519RawMessageBaseGas now deliberately diverge:

  • abi.json declares verifyEd25519's message argument as bytes32, and query.go type-asserts
    args[1].([32]byte) — a fixed-size array the caller cannot lengthen.
  • It then verifies "0x" + hex(msgDigest), i.e. exactly 66 bytes on every call, whatever the
    calldata contains.

So the legacy method's verification cost genuinely is constant, and a flat 4000 remains the honest
price. Only the raw method verifies caller-sized bytes.

Tests

precompiles/usigverifier/gas_test.go (new):

  • TestRequiredGas_RawMessageScalesWithMessageLength — exact price at 0 / 1 / 32 / 33 B / 1 / 8 / 64 / 128 KiB
  • TestRequiredGas_RawMessageIsStrictlyIncreasing — every extra word costs more
  • TestRequiredGas_SmallMessagesKeepASaneCost — ordinary calls stay within a rounding error of the old price
  • TestRequiredGas_AboveCapIsClampedNotUnbounded — oversized prices at the cap, never above
  • TestRequiredGas_LegacyMethodStaysFlat — the divergence, incl. an oversized pubKey
  • TestRequiredGas_MalformedCalldataIsPanicFreeAndBounded — 9 malformed calldatas: no panic, bounded charge
  • TestVerifyEd25519RawMessage_RejectsOversizedMessage / _AcceptsMessageAtCap — the cap is inclusive
  • TestRun_OversizedMessageReverts — the same cap through Run, on real ABI calldata
  • TestLargeMessageLoopIsGasProhibitive — the loop shape: ≥10x fewer verifications per block than the flat price bought

Benchmarks BenchmarkVerifyEd25519RawMessage (per-size, reporting gas/op and gas/us) and
BenchmarkRequiredGas. On an M1 the benchmark reproduces the reported curve:

BenchmarkVerifyEd25519RawMessage/msg=32B-8       22879   52676 ns/op    4012 gas/op   76.16 gas/us
BenchmarkVerifyEd25519RawMessage/msg=1024B-8     21207   53745 ns/op    4384 gas/op   81.57 gas/us
BenchmarkVerifyEd25519RawMessage/msg=8192B-8     19300   62630 ns/op    7072 gas/op   112.9 gas/us
BenchmarkVerifyEd25519RawMessage/msg=65536B-8    12042  100237 ns/op   28576 gas/op   285.1 gas/us
BenchmarkVerifyEd25519RawMessage/msg=131072B-8    8072  146130 ns/op   53152 gas/op   363.7 gas/us
BenchmarkRequiredGas-8                          8100620   136.6 ns/op   176 B/op   1 allocs/op

gas/us rising with size means the new schedule slightly over-charges large messages relative to
CPU — intentional, the per-word rate follows the SHA-256 precedent and errs toward deterrence.

Mutation check

With the fix reverted behaviourally (flat price restored, cap check removed) and the tests kept,
8 of the 9 new tests fail (19 assertions across subtests). The oversized-message tests fail on
their value assertion before reaching require.Error, i.e. the pre-fix code really did verify a
message past the cap and return true:

--- FAIL: TestVerifyEd25519RawMessage_RejectsOversizedMessage
    Error: Expected nil, but got: []byte{0x0, ..., 0x1}
    Messages: an oversized message must not produce a verification result
--- FAIL: TestLargeMessageLoopIsGasProhibitive
    Error: "25000" is not less than "2500"
    Messages: a cap-sized message must buy at least 10x fewer verifications per block
              than the old flat price did (now 25000, before 25000)

TestRequiredGas_LegacyMethodStaysFlat is the one that still passes — correctly, since the legacy
price is unchanged by design.

Notes

  • VerifyEd25519RawMessageGas is renamed to VerifyEd25519RawMessageBaseGas: it is no longer the
    gas cost, only its fixed part. No caller in the tree referenced it (grep over the repo:
    precompiles/usigverifier only).
  • No state migration or upgrade handler: this is a fresh-genesis branch and the schedule is code, not
    params.
  • Docs updated: precompiles/usigverifier/README.md (gas table, size cap, the divergence) and
    app/README.md.

verifyEd25519RawMessage charged a flat 4000 gas no matter how long the
message was, while ed25519.Verify hashes the whole slice (~58us at 32B,
~922us at 1MB). Charge 4000 + 12 per 32-byte word instead, matching the
SHA-256 precompile's per-word rate, and hard-cap the message at 128 KiB
since a view method can be looped from memory without re-paying calldata.

verifyEd25519 stays flat: it always verifies the 66-byte hex form of a
bytes32 digest, so its cost cannot vary with the calldata.
@0xNilesh
0xNilesh merged commit 7473e48 into audit-fixes Aug 26, 2026
7 checks passed
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