Skip to content

fix(tn-reth): price blsVerify on the raw calldata length before the decode (#1332) - #1333

Open
MavenRain wants to merge 1 commit into
mainfrom
fix/1332-bls-precompile-calldata-gas
Open

fix(tn-reth): price blsVerify on the raw calldata length before the decode (#1332)#1333
MavenRain wants to merge 1 commit into
mainfrom
fix/1332-bls-precompile-calldata-gas

Conversation

@MavenRain

Copy link
Copy Markdown
Contributor

Closes #1332.

Problem

blsVerify checked a flat BLS_VERIFY_GAS_COST floor of 150,000 gas before the ABI decode, then
computed the real charge from the decoded message length. The floor is a constant. The work it
guards is not.

The decode copies all three bytes fields out of calldata into owned Bytes. signature and
pubkey are unbounded bytes, so MAX_BLS_VERIFY_MESSAGE_LEN never bounded that copy.

What this is, exactly, is a rate gap the precompile owns. The caller does pay for the bytes it
sends: calldata is 4 gas per zero byte and 16 per non-zero byte, so 1 MiB of zero bytes already
costs the caller about 4.2M gas, plus the memory expansion for the buffer it staticcalls from.
Those charges pay for the transaction data and for the caller's own memory. They do not pay for
the precompile's own allocation and copy of the same bytes on every executing node. Every other
input-length precompile prices that itself: identity charges 3 gas per word, sha256 12,
ripemd160 120. blsVerify priced only the decoded message, so the precompile's own charge stayed
at the 150,000 floor however large the bytes it copied, and the post-decode charge was the same
150,000 plus a few words of message.

No attacker is needed to reach this. Any contract that forwards a large buffer to the precompile
hits it. The precompile address is warm and a STATICCALL forwards all remaining gas by default,
so a normally funded transaction already carries enough gas to clear the floor and then hand the
node an arbitrarily large buffer to copy.

Root cause

The gate ordering was cheap-first, but the cheap gate was the wrong quantity. #1215 added the floor
specifically so that gas_used stayed byte-identical for every accepted call, which meant the only
gate that could run before the decode had to be a constant. A constant bounds how many decodes a
block can buy. It does not bound how large each decode is. The size-dependent charge ran after the
copy it was meant to pay for.

Fix

  • Price the raw calldata, not the decoded message. handle_bls_verify now computes
    bls_verify_gas_cost(calldata.len()) and checks gas_limit against it once, before any byte is
    decoded. This is the only gas gate in the function. Each of the three bytes fields lies inside
    the raw arguments, so each copy is at most the raw argument length and the three copies together
    are at most three times it: abi_decode_raw is the non-validating decoder, so all three heads may
    point at one tail. The raw length is known before the decode, and 12 gas per word is four times
    the EVM's 3-gas copy rate, so the surcharge covers even the three-way aliased copy with margin.
  • crates/tn-reth/src/evm/bls_precompile/mod.rs: bls_verify_gas_cost takes calldata_len
    instead of message_len. The formula is unchanged: base plus BLS_VERIFY_PER_WORD_GAS_COST per
    32-byte word, all checked. It stays total, so an overflow the arithmetic cannot represent is
    metered as OutOfGas rather than a panic. That case is unreachable for any calldata a block can
    carry.
  • The rate is unchanged and it is derived, not picked. 12 gas per 32-byte word is the
    SHA256 precompile rate for the same variable-length hashing, and it is four times the EVM's
    3-gas-per-word copy rate (CALLDATACOPY, the identity precompile). The accounting is per class
    of word, not one rate charged twice. signature and pubkey words are unbounded and are never
    hashed, so their 12 gas pays for a copy that costs at most 9 gas per word even when the three
    heads alias. Message words pay the same 12 gas for expand_message_xmd, which is the SHA256
    precompile's own rate for that hashing; the copy of the message on top of it is bounded by the
    4096-byte cap, at most 139 raw words at 3 gas per word, under 1,300 gas even three-way aliased,
    which the 150,000-gas base absorbs. Under the old message-priced charge a message word paid this
    same 12 gas, so no call pays less than it did before.
  • No ceiling on the raw length, only a charge. The issue sketch reused
    MAX_BLS_VERIFY_MESSAGE_LEN as a raw ceiling. That would reject canonical calls: the canonical
    envelope around a message adds 352 bytes (three head words of 96, the signature tail of 32 plus
    64, the pubkey tail of 32 plus 96, the message length word of 32), so a 4096-byte raw ceiling
    refuses every canonical message above 3744 bytes. fix(tn-reth): gate blsVerify on the O(1) gas floor before decoding calldata #1215 also recorded that a raw ceiling turns
    today's Ok(false) for an oversized pubkey into a halt. Pricing the length instead makes the
    copy pay for itself, the way sha256 and identity price their input length without capping it.
  • The message cap stays, after the decode. It is now a bare check with no arithmetic
    attached. It bounds the hash-to-curve input of a generic primitive. It was never the thing that
    bounded the copy.
  • crates/tn-reth/README.md: the "for a flat 150,000 gas" sentence was already stale after
    per-word pricing landed in tn-reth: meter blsVerify by message length and cap it #1051. It now reads 150,000 gas plus 12 gas per 32-byte word of the
    ABI-encoded arguments, charged before the arguments are decoded.
  • Docs rewritten on handle_bls_verify, bls_verify_gas_cost, BLS_VERIFY_GAS_COST,
    BLS_VERIFY_PER_WORD_GAS_COST, and MAX_BLS_VERIFY_MESSAGE_LEN, including the reason a raw
    ceiling was rejected.

Threat model

The price is now fixed by the raw bytes before anything is parsed, so it is attacker-independent: a
caller cannot shape calldata that decodes cheaply but copies expensively, because the charge is read
off the length and not off the decode. The check is still cheap-first, and now the cheap quantity
is the one that scales with the work.

Observable change

gas_used follows the raw argument length. The ConsensusRegistry proof-of-possession call (a
48-byte signature, a 96-byte pubkey, a 119-byte message, 480 argument bytes, 15 words) goes from
150,048 gas to 150,180 gas. Its Solidity staticcall forwards all remaining gas, so it is
unaffected.

A call funded at or above the new raw-priced cost behaves exactly as before: same decode, same cap,
same verification, same accepted set. A call funded in the band between the old message-priced cost
and the new raw-priced cost is now OutOfGas. An under-funded call is OutOfGas before the decode
whatever its bytes hold. An over-cap message with the cost funded is still Other.

#1215 chose the floor to keep gas_used byte-identical. This PR takes the trade the reviewer asked
for: a gas_used change in exchange for a charge that actually bounds the work.

Testing

Unit: cargo +1.94 test -p tn-reth --lib evm::bls_precompile -j 2 gives 17 passed, 0 failed, 0
ignored.

Integration: cargo +1.94 test -p tn-reth --test it -j 2 -- bls_precompile gives 11 passed, 0
failed, 0 ignored. crates/tn-reth/tests/it/bls_precompile_props.rs is unchanged: its 1,000,000
gas VERIFY_GAS still funds the new charge, and its STATICCALL relay test proves the registry
path at the new price.

Format: cargo +nightly fmt -p tn-reth -- --check is clean.
Lint: cargo +nightly clippy -p tn-reth --all-features --all-targets --no-deps -- -D warnings is
clean.

New and reworked tests:

  • dispatch_meters_by_raw_calldata_length holds a 32-byte message fixed and varies only bytes the
    old charge ignored: a 368-byte signature, a 416-byte pubkey, and 320 trailing bytes the
    decoder discards. Each delta is exactly 10 words of surcharge. All four calls, the canonical one
    included, cost the same under the old pricing.
  • dispatch_charges_raw_calldata_before_decode replaces dispatch_gas_floor_precedes_decode. A
    canonically encoded 1 MiB message is 32,779 argument words. Funded at BLS_VERIFY_GAS_COST, the
    old floor let it through to the decoder. It is now OutOfGas. Funded at the charge it reaches
    the decode and the cap, which is the positive control.
  • dispatch_verify_valid_returns_true funds the proof-of-possession call at exactly
    BLS_VERIFY_GAS_COST + 15 * BLS_VERIFY_PER_WORD_GAS_COST and asserts gas_used equals it.
  • dispatch_prices_aliased_heads_by_raw_length is the aliasing case. abi_decode_raw is the
    non-validating decoder, so three bytes heads of 0x60 over one 32-byte tail decode into three
    equal fields: 160 argument bytes, 96 bytes copied. The call is Ok(false) and gas_used is
    BLS_VERIFY_GAS_COST + 5 * BLS_VERIFY_PER_WORD_GAS_COST, the charge on the five raw words, which
    at four times the EVM copy rate covers the tripled copy.

Mutation testing, both polarities observed. Both runs predate
dispatch_prices_aliased_heads_by_raw_length, so they count the 16 tests of that suite:

  • M1, origin/main's handle_bls_verify body spliced back in with the new tests kept: 10 passed, 6
    failed. The failures are dispatch_meters_by_raw_calldata_length,
    dispatch_charges_raw_calldata_before_decode, dispatch_verify_valid_returns_true,
    dispatch_verify_invalid_returns_false_not_revert, dispatch_verify_out_of_gas, and
    dispatch_rejects_oversized_message. Restored: 16 passed, 0 failed.
  • M2, gas_limit >= *cost flipped to gas_limit > *cost: 12 passed, 4 failed, including
    dispatch_verify_valid_returns_true, which funds at exactly the cost. Restored: 16 passed, 0
    failed.

…ecode (#1332)

The precompile checked a flat BLS_VERIFY_GAS_COST floor before the ABI
decode, then computed the real charge from the decoded message length.
The floor bounded how many decodes a block could buy, not how large each
one was.  The caller pays calldata gas and memory expansion for the
bytes it sends, but those pay for the transaction data and the caller's
own memory, not for the precompile's allocation and copy of the same
bytes on every executing node.  Every other input-length precompile
prices its own input; blsVerify priced only the decoded message, so the
precompile's own charge stayed at the 150,000 floor however large the
signature or pubkey it copied.

handle_bls_verify now computes bls_verify_gas_cost over the raw argument
length and checks it once, before any byte is decoded.  The rate is
unchanged at 12 gas per 32-byte word, the SHA256 precompile rate and
four times the EVM copy rate.  The message cap stays after the decode,
because it bounds the hash-to-curve input and not the copy.  The
ConsensusRegistry proof-of-possession call moves from 150,048 to 150,180
gas and still fits its forwarded budget.

Closes #1332

Signed-off-by: Onyeka Obi <softwareengineerasaservant@isurvivable.cv>
@MavenRain MavenRain self-assigned this Sep 5, 2026
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.

Fix review: charge dynamic gas based on raw calldata before allocation

1 participant