Skip to content

Commit 176d0a9

Browse files
fix(common): charge EIP-8141 intrinsic gas over payload bytes only
FrameTransaction::total_gas_limit previously RLP-encoded the whole frames and signatures lists and charged calldata gas over every byte, including RLP length prefixes and structural fields (frame gas_limit, value, to, etc.). EIP-8141 defines the intrinsic calldata cost over the payload byte-set only: each frame's data plus each signature's signer, msg, and signature bytes. This over-charging surfaced in cross-client interop replay against Nethermind: for the reference 162-byte low-s type-0x06 transaction ethrex computed intrinsic 20662 vs the spec-correct 19778 (delta 884), causing exact-gas block rejection. Replace whole-RLP charging with per-field charging over the payload byte arrays, preserving saturating arithmetic. Add regression tests pinning the exact reference-tx intrinsic and asserting insensitivity to structural frame fields (gas_limit, value). Verified: 2/2 focused, 80/80 EIP-8141 regression, cargo fmt --check, cargo clippy -D warnings, git diff --check.
1 parent cee872d commit 176d0a9

2 files changed

Lines changed: 91 additions & 12 deletions

File tree

crates/common/types/transaction.rs

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2038,21 +2038,24 @@ impl FrameTransaction {
20382038
.sum()
20392039
}
20402040

2041-
/// Compute total gas limit: intrinsic + calldata cost (frames + signatures)
2042-
/// + signature verification cost + sum of frame gas limits.
2041+
/// Computes the total gas limit defined by EIP-8141.
20432042
pub fn total_gas_limit(&self) -> u64 {
2043+
fn add_calldata_gas(calldata_gas: u64, bytes: &[u8]) -> u64 {
2044+
bytes.iter().fold(calldata_gas, |acc, byte| {
2045+
acc.saturating_add(if *byte == 0 { 4 } else { 16 })
2046+
})
2047+
}
2048+
20442049
let mut calldata_gas: u64 = 0;
2045-
// RLP-encode frames to compute calldata cost
2046-
let mut frames_buf = Vec::new();
2047-
self.frames.encode(&mut frames_buf);
2048-
for byte in &frames_buf {
2049-
calldata_gas = calldata_gas.saturating_add(if *byte == 0 { 4 } else { 16 });
2050+
for frame in &self.frames {
2051+
calldata_gas = add_calldata_gas(calldata_gas, &frame.data);
20502052
}
2051-
// RLP-encode signatures to compute their calldata cost
2052-
let mut sigs_buf = Vec::new();
2053-
self.signatures.encode(&mut sigs_buf);
2054-
for byte in &sigs_buf {
2055-
calldata_gas = calldata_gas.saturating_add(if *byte == 0 { 4 } else { 16 });
2053+
for signature in &self.signatures {
2054+
if let Some(signer) = signature.signer {
2055+
calldata_gas = add_calldata_gas(calldata_gas, signer.as_bytes());
2056+
}
2057+
calldata_gas = add_calldata_gas(calldata_gas, &signature.msg);
2058+
calldata_gas = add_calldata_gas(calldata_gas, &signature.signature);
20562059
}
20572060
FRAME_TX_INTRINSIC_COST
20582061
.saturating_add((self.frames.len() as u64).saturating_mul(FRAME_TX_PER_FRAME_COST))

test/tests/levm/eip8141_tests.rs

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3193,3 +3193,79 @@ mod expiry_verifier_tests {
31933193
);
31943194
}
31953195
}
3196+
3197+
mod intrinsic_gas_accounting_tests {
3198+
use ethrex_common::U256;
3199+
use ethrex_common::types::{
3200+
FRAME_TX_INTRINSIC_COST, FRAME_TX_PER_FRAME_COST, FrameTransaction, Transaction,
3201+
};
3202+
3203+
fn interop_reference_tx() -> FrameTransaction {
3204+
let raw = hex::decode(
3205+
"06f89f833018248094e25583099ba105d9ec0a67f5ae86d90e50036425eec801038082c3508080e40280941111111111111111111111111111111111111111830186a08701c6bf5263400080f848f846018080b84100376dfea0d3368d1e0e7914b727a934f20e4fb134752d4bbcd994db1246c2197553a566aec5941b337df2fb25be8fd2c367ad19b34526656704997b25eda17a80843b9aca00847735940080c0",
3206+
)
3207+
.unwrap();
3208+
match Transaction::decode_canonical(&raw).unwrap() {
3209+
Transaction::FrameTransaction(tx) => tx,
3210+
_ => panic!("expected frame transaction"),
3211+
}
3212+
}
3213+
3214+
fn intrinsic_gas(tx: &FrameTransaction) -> u64 {
3215+
let frame_gas = tx
3216+
.frames
3217+
.iter()
3218+
.map(|f| f.gas_limit)
3219+
.fold(0u64, |acc, g| acc.saturating_add(g));
3220+
tx.total_gas_limit().saturating_sub(frame_gas)
3221+
}
3222+
3223+
#[test]
3224+
fn interop_reference_tx_intrinsic_gas_charges_payload_bytes_only() {
3225+
const ZERO_BYTE_GAS: u64 = 4;
3226+
const NONZERO_BYTE_GAS: u64 = 16;
3227+
const SECP256K1_VERIFY_GAS: u64 = 2_800;
3228+
const FRAME_COUNT: u64 = 2;
3229+
const SIGNATURE_ZERO_BYTES: u64 = 1;
3230+
const SIGNATURE_NONZERO_BYTES: u64 = 64;
3231+
const FRAME_GAS_LIMITS: u64 = 50_000 + 100_000;
3232+
3233+
let payload_calldata_gas =
3234+
SIGNATURE_NONZERO_BYTES * NONZERO_BYTE_GAS + SIGNATURE_ZERO_BYTES * ZERO_BYTE_GAS;
3235+
let expected_intrinsic_gas = FRAME_TX_INTRINSIC_COST
3236+
+ FRAME_COUNT * FRAME_TX_PER_FRAME_COST
3237+
+ SECP256K1_VERIFY_GAS
3238+
+ payload_calldata_gas;
3239+
3240+
let tx = interop_reference_tx();
3241+
assert_eq!(tx.frames.len() as u64, FRAME_COUNT);
3242+
assert_eq!(intrinsic_gas(&tx), expected_intrinsic_gas);
3243+
assert_eq!(
3244+
tx.total_gas_limit(),
3245+
expected_intrinsic_gas + FRAME_GAS_LIMITS
3246+
);
3247+
}
3248+
3249+
#[test]
3250+
fn intrinsic_gas_is_insensitive_to_structural_frame_fields() {
3251+
let baseline = interop_reference_tx();
3252+
3253+
let mut restructured = interop_reference_tx();
3254+
restructured.frames[0].gas_limit = 1;
3255+
restructured.frames[1].gas_limit = 4_000_000_000;
3256+
restructured.frames[1].value = U256::from(1u64);
3257+
3258+
assert_eq!(
3259+
restructured.frames[0].data, baseline.frames[0].data,
3260+
"payload bytes must be unchanged for this test to be meaningful",
3261+
);
3262+
assert_eq!(restructured.frames[1].data, baseline.frames[1].data);
3263+
assert_eq!(restructured.signatures, baseline.signatures);
3264+
3265+
assert_eq!(
3266+
intrinsic_gas(&restructured),
3267+
intrinsic_gas(&baseline),
3268+
"structural frame fields (gas_limit, value) must not change intrinsic gas",
3269+
);
3270+
}
3271+
}

0 commit comments

Comments
 (0)