Skip to content

Commit 0c92352

Browse files
committed
test(l1): bal-devnet-4 follow-up regression guards + doc polish
Follow-up to PR #6518 addressing the test-gap list documented in the session-3 review. Covers every remaining item in TODO.md except the upstream zkevm@v0.4.x fixture re-enable (tracked externally). Tests (10 new): - `test_cpsb_clamp_to_one_for_tiny_gas_limit`, `test_cpsb_30m_bin_boundary` — cpsb quantization boundaries. Guards against an off-by-one in the `if quantized > CPSB_OFFSET` branch and against bin boundary regressions in the 5M-30M range. - `test_change_variants_rlp_roundtrip_index_above_u16_max` — RLP round-trip for all 4 BAL change variants at index 70_000, guarding against an accidental revert to the pre-devnet-4 `u16` type that would silently truncate high indices. - `amsterdam_create_intrinsic_matches_vm_dimensions` — mempool admission for Amsterdam CREATE txs must match the VM's `(regular, state)` split (TX_BASE + REGULAR_GAS_CREATE + STATE_BYTES_PER_NEW_ACCOUNT * cpsb), not the legacy 53000. - `test_intrinsic_parity_plain_transfer` / `test_intrinsic_parity_create_tx` / `test_intrinsic_parity_with_calldata_and_access_list` / `test_intrinsic_parity_eip7702_auth_list` — parity between the standalone `intrinsic_gas_dimensions` helper (used by mempool and payload builder) and `VM::get_intrinsic_gas` (used during execution). Run across Prague / Osaka / Amsterdam at 30M and 120M block gas limits. - `test_call_to_empty_account_with_value_retains_parent_state_gas` — EIP-8037 CALL-to-empty-with-value charges new-account state gas in the caller's frame, retained across successful parent continuation. Pairs with the existing `test_child_charge_then_revert_returns_state_gas_to_parent` for the revert direction. Code polish: - Clarifying comment on the `frame_outstanding_delta` invariant in `credit_state_gas_refund` (`crates/vm/levm/src/vm.rs`). The subtraction is fragile — documenting why it must read `state_gas_spill_outstanding` and not `state_gas_spill`. - `debug_assert!` guards on tx count vs `u32::MAX` at each block-exec entry (`execute_block`, `execute_block_pipeline`), keeping the EIP-7928 `BlockAccessIndex` invariant explicit rather than implicit in the ~10 downstream `u32::try_from(...).unwrap_or(u32::MAX)` sites. Docs: - `docs/roadmaps/forks-roadmap.md` — EIP-7976 / EIP-7981 flipped 🔴→✅, EIP-8037 status line expanded (dynamic cpsb, clamp-and-spill, 2D inclusion, same-tx SELFDESTRUCT refund), priority note updated for bal-devnet-4 + PR #6518. All 478 tests pass. No behavior changes — these are regression guards and documentation for the bal-devnet-4 work landed in PR #6518.
1 parent 94d27a3 commit 0c92352

7 files changed

Lines changed: 418 additions & 5 deletions

File tree

crates/vm/backends/levm/mod.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,15 @@ impl LEVM {
158158
let chain_config = db.store.get_chain_config()?;
159159
let is_amsterdam = chain_config.is_amsterdam_activated(block.header.timestamp);
160160

161+
// EIP-7928 BlockAccessIndex is uint32. Block validity forbids >= 2^32 txs
162+
// long before we'd reach this point, but guard the invariant explicitly
163+
// so any upstream bug that inflates tx counts panics in debug instead of
164+
// silently producing a `u32::MAX` index.
165+
debug_assert!(
166+
block.body.transactions.len() < u32::MAX as usize,
167+
"tx count overflows u32 BlockAccessIndex"
168+
);
169+
161170
// Enable BAL recording for Amsterdam+ forks
162171
if is_amsterdam {
163172
db.enable_bal_recording();
@@ -332,6 +341,12 @@ impl LEVM {
332341
let chain_config = db.store.get_chain_config()?;
333342
let is_amsterdam = chain_config.is_amsterdam_activated(block.header.timestamp);
334343

344+
// EIP-7928 BlockAccessIndex invariant — see `execute_block` for rationale.
345+
debug_assert!(
346+
block.body.transactions.len() < u32::MAX as usize,
347+
"tx count overflows u32 BlockAccessIndex"
348+
);
349+
335350
let transactions_with_sender =
336351
block
337352
.body

crates/vm/levm/src/vm.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -704,6 +704,15 @@ impl<'a> VM<'a> {
704704
// so a grandparent revert's reservoir math sees only un-cancelled spill. The
705705
// second portion accumulates into `state_gas_credit_against_drain` and appears
706706
// in the revert formula as the subtraction term.
707+
//
708+
// Invariant (crucial for reservoir correctness):
709+
// `state_gas_spill_outstanding - snapshot` counts only spill increments that
710+
// happened INSIDE the current frame (or its subtree, propagated up on revert).
711+
// It excludes the parent's pre-child spills because those are baked into the
712+
// snapshot captured at child-frame entry. Therefore `applied_to_spill` never
713+
// double-cancels a spill that's already been accounted for at a grandparent
714+
// boundary. Changing this subtraction, or reading `state_gas_spill` instead,
715+
// breaks `sstore_restoration_create_init_revert`.
707716
let frame_outstanding_delta = self
708717
.state_gas_spill_outstanding
709718
.saturating_sub(self.current_call_frame.state_gas_spill_outstanding_snapshot);

docs/roadmaps/forks-roadmap.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,12 @@
3333
| **2780** | Reduce Intrinsic Transaction Gas | 🔴 Not implemented (21000 → 4500) · [exec-specs tracking](https://github.com/ethereum/execution-specs/issues/1940) | 🔴 | 🔴 | CFI |
3434
| **7904** | General Repricing | 🔴 Not implemented · [exec-specs tracking](https://github.com/ethereum/execution-specs/issues/1879) | ⚠️ PR #9619 (Draft) | 🔴 | CFI |
3535
| **7954** | Increase Max Contract Size | 🔴 Not implemented (24KiB → 32KiB) · [exec-specs tracking](https://github.com/ethereum/execution-specs/issues/2028) | ⚠️ PR #8760 (Draft) | 🔴 | CFI |
36-
| **7976** | Increase Calldata Floor Cost | 🔴 Not implemented · [exec-specs tracking](https://github.com/ethereum/execution-specs/issues/1942) | 🔴 | 🔴 | CFI |
37-
| **7981** | Increase Access List Cost | 🔴 Not implemented · [exec-specs tracking](https://github.com/ethereum/execution-specs/issues/1943) | 🔴 | 🔴 | CFI |
38-
| **8037** | State Creation Gas Cost Increase | ✅ Implemented ([#6271] merged, PR [#6216] open) · [exec-specs tracking](https://github.com/ethereum/execution-specs/issues/2040) | ✅ bal@v5.4.0 | ⚠️ PR [#6216] | CFI |
36+
| **7976** | Increase Calldata Floor Cost | ✅ Implemented (PR #6518, bal@v5.7.0) · [exec-specs tracking](https://github.com/ethereum/execution-specs/issues/1942) | 🔴 | 🔴 | CFI |
37+
| **7981** | Increase Access List Cost | ✅ Implemented (PR #6518, bal@v5.7.0) · [exec-specs tracking](https://github.com/ethereum/execution-specs/issues/1943) | 🔴 | 🔴 | CFI |
38+
| **8037** | State Creation Gas Cost Increase | ✅ Implemented (dynamic cpsb, clamp-and-spill, 2D inclusion, same-tx SELFDESTRUCT refund — PR #6518 on bal@v5.7.0) · [exec-specs tracking](https://github.com/ethereum/execution-specs/issues/2040) | ✅ bal@v5.4.0 | ⚠️ PR [#6216] | CFI |
3939
| **8038** | State-Access Gas Cost Update | 🔴 Not implemented · [exec-specs tracking](https://github.com/ethereum/execution-specs/issues/1941) | 🔴 | 🔴 | CFI |
4040

41-
> **Priority note:** All core devnet EIPs are merged. EIP-8037 fully implemented with reservoir model, nested revert fixes, and CREATE collision escrow. BAL optimizations shipped: parallel execution ([#6233]), batched reads + parallel state root ([#6227]). bal-devnet-3 tracking PR [#6216] open with bal@v5.4.0 fixtures, Amsterdam consume-engine hive tests in CI. **Up next:** merge PR [#6216], EIP-7954 ([#6214]). Remaining gas repricing EIPs are **low priority** — no other client has started them. Monitor CFI decisions at ACDE calls.
41+
> **Priority note:** All core devnet EIPs are merged. EIP-8037 fully implemented with reservoir model, clamp-and-spill refunds, 2D inclusion check, and same-tx SELFDESTRUCT refund. EIP-7976 + EIP-7981 shipped with bal-devnet-4 rollup. BAL optimizations shipped: parallel execution ([#6233]), batched reads + parallel state root ([#6227]), shadow-recorder missing-entry detection (PR #6518). bal-devnet-4 tracking PR #6518 open with bal@v5.7.0 fixtures, Amsterdam consume-engine hive 1342/1342 passing. **Up next:** merge PR #6518, EIP-7954 ([#6214]). Remaining gas repricing EIPs are **low priority** — no other client has started them. Monitor CFI decisions at ACDE calls.
4242
4343
### Other Amsterdam EIPs
4444

test/tests/blockchain/mempool_tests.rs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,57 @@ fn create_transaction_intrinsic_gas() {
9797
assert_eq!(intrinsic_gas, expected_gas_cost);
9898
}
9999

100+
/// EIP-8037 / bal-devnet-4: Amsterdam CREATE tx intrinsic must match the VM
101+
/// charge, not the legacy `TX_CREATE_GAS_COST = 53000`. The regular portion
102+
/// drops to `TX_GAS_COST + REGULAR_GAS_CREATE = 30000` and a state portion
103+
/// (`STATE_BYTES_PER_NEW_ACCOUNT * cpsb`) is folded in. Mempool admission
104+
/// must return the total so txs whose `gas_limit` is below the VM intrinsic
105+
/// are rejected before they enter the pool, and txs above it aren't
106+
/// spuriously rejected.
107+
#[test]
108+
fn amsterdam_create_intrinsic_matches_vm_dimensions() {
109+
use ethrex_levm::gas_cost::{
110+
REGULAR_GAS_CREATE, STATE_BYTES_PER_NEW_ACCOUNT, cost_per_state_byte,
111+
};
112+
113+
let (mut config, header) = build_basic_config_and_header(true, true);
114+
// Activate Amsterdam at genesis. Intermediate forks must also be active
115+
// so `config.fork(timestamp)` returns Amsterdam, not an earlier variant.
116+
config.cancun_time = Some(0);
117+
config.prague_time = Some(0);
118+
config.osaka_time = Some(0);
119+
config.bpo1_time = Some(0);
120+
config.bpo2_time = Some(0);
121+
config.amsterdam_time = Some(0);
122+
123+
let tx = Transaction::EIP1559Transaction(EIP1559Transaction {
124+
nonce: 0,
125+
max_priority_fee_per_gas: 0,
126+
max_fee_per_gas: 0,
127+
gas_limit: 1_000_000,
128+
to: TxKind::Create,
129+
value: U256::zero(),
130+
data: Bytes::default(),
131+
access_list: Default::default(),
132+
..Default::default()
133+
});
134+
135+
let cpsb = cost_per_state_byte(header.gas_limit);
136+
let expected = TX_GAS_COST + REGULAR_GAS_CREATE + STATE_BYTES_PER_NEW_ACCOUNT * cpsb;
137+
138+
let intrinsic_gas = transaction_intrinsic_gas(&tx, &header, &config).expect("intrinsic gas");
139+
assert_eq!(
140+
intrinsic_gas, expected,
141+
"Amsterdam CREATE intrinsic must be TX_BASE + REGULAR_GAS_CREATE + \
142+
STATE_BYTES_PER_NEW_ACCOUNT * cpsb, not the legacy 53000"
143+
);
144+
// Guard against regression to the legacy 53000 constant.
145+
assert_ne!(
146+
intrinsic_gas, TX_CREATE_GAS_COST,
147+
"Amsterdam CREATE must NOT use legacy TX_CREATE_GAS_COST"
148+
);
149+
}
150+
100151
#[test]
101152
fn transaction_intrinsic_data_gas_pre_istanbul() {
102153
let (config, header) = build_basic_config_and_header(false, false);

test/tests/levm/eip7928_tests.rs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -456,6 +456,35 @@ fn test_code_change_rlp_roundtrip() {
456456
assert_eq!(change, decoded);
457457
}
458458

459+
/// EIP-7928 widened `BlockAccessIndex` from `uint16` to `uint32`. Round-trip
460+
/// each change variant at an index above `u16::MAX` to guard against an
461+
/// accidental revert to the old narrower type (would silently truncate
462+
/// indices for blocks with > 65535 slots referenced).
463+
#[test]
464+
fn test_change_variants_rlp_roundtrip_index_above_u16_max() {
465+
use ethrex_rlp::{decode::RLPDecode, encode::RLPEncode};
466+
let idx: u32 = 70_000;
467+
assert!(idx > u32::from(u16::MAX));
468+
469+
let storage = StorageChange::new(idx, U256::from(0xdead_beef_u64));
470+
assert_eq!(
471+
StorageChange::decode(&storage.encode_to_vec()).unwrap(),
472+
storage
473+
);
474+
475+
let balance = BalanceChange::new(idx, U256::from(1u64) << 128);
476+
assert_eq!(
477+
BalanceChange::decode(&balance.encode_to_vec()).unwrap(),
478+
balance
479+
);
480+
481+
let nonce = NonceChange::new(idx, u64::MAX);
482+
assert_eq!(NonceChange::decode(&nonce.encode_to_vec()).unwrap(), nonce);
483+
484+
let code = CodeChange::new(idx, bytes::Bytes::from_static(&[0xde, 0xad]));
485+
assert_eq!(CodeChange::decode(&code.encode_to_vec()).unwrap(), code);
486+
}
487+
459488
// ==================== RLP Encoding Hex Validation Tests ====================
460489
// These tests verify specific RLP hex encodings for cross-implementation compatibility
461490

test/tests/levm/eip8037_refund_tests.rs

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,21 @@ fn call_bytecode(target: Address) -> Vec<u8> {
148148
b
149149
}
150150

151+
/// CALL to `target` transferring `value` wei. No args, no return capture.
152+
/// When `target` doesn't exist in pre-state and `value > 0`, Amsterdam charges
153+
/// `state_gas_new_account` in the caller's frame.
154+
fn call_with_value_bytecode(target: Address, value: u8) -> Vec<u8> {
155+
// retLen retOffset argsLen argsOffset value target GAS CALL POP
156+
let mut b = vec![0x60, 0x00, 0x60, 0x00, 0x60, 0x00, 0x60, 0x00]; // 4x PUSH1 0
157+
b.extend_from_slice(&[0x60, value]); // PUSH1 <value>
158+
b.push(0x73); // PUSH20
159+
b.extend_from_slice(target.as_bytes());
160+
b.push(0x5a); // GAS
161+
b.push(0xf1); // CALL
162+
b.push(0x50); // POP
163+
b
164+
}
165+
151166
// ==================== Test runner ====================
152167

153168
struct TestRunner {
@@ -553,6 +568,58 @@ fn test_ancestor_absorbed_refund_refills_reservoir() {
553568
/// parent.state_gas_left += child.state_gas_used - child.state_gas_refund
554569
/// Tx succeeds at top level (parent returns from CALL with FAIL and STOPs). The
555570
/// parent must reclaim B's state-gas consumption so it's not burned.
571+
/// EIP-8037 CALL-to-empty-account with value transfer charges
572+
/// `state_gas_new_account` in the CALLER's frame (parent). When the parent
573+
/// continues and the transaction succeeds, that state gas is retained in net
574+
/// `state_gas_used`. The child frame has no code and returns success
575+
/// immediately, so no child revert is involved — this test guards the
576+
/// "parent charged, parent succeeds" path against regressions that would
577+
/// incorrectly refund new-account state gas on child return.
578+
#[test]
579+
fn test_call_to_empty_account_with_value_retains_parent_state_gas() {
580+
use ethrex_levm::gas_cost::{STATE_BYTES_PER_NEW_ACCOUNT, cost_per_state_byte};
581+
582+
let addr_a = Address::from_low_u64_be(CONTRACT_A);
583+
let empty_target = Address::from_low_u64_be(0xDEAD); // not in pre-state
584+
585+
// A: CALL(value=1, target=empty_addr) then STOP.
586+
let mut code_a = call_with_value_bytecode(empty_target, 1);
587+
code_a.extend(stop());
588+
589+
let report = TestRunner::new(addr_a)
590+
.with_account(
591+
Address::from_low_u64_be(SENDER),
592+
eoa(U256::from(10u64).pow(18.into())),
593+
)
594+
// A must have balance to transfer.
595+
.with_account(
596+
addr_a,
597+
Account::new(
598+
U256::from(10u64).pow(18.into()),
599+
Code::from_bytecode(Bytes::from(code_a), &NativeCrypto),
600+
1,
601+
FxHashMap::default(),
602+
),
603+
)
604+
.run();
605+
606+
assert!(
607+
report.is_success(),
608+
"top-level tx must succeed: {:?}",
609+
report.result
610+
);
611+
612+
let cpsb = cost_per_state_byte(GAS_LIMIT * 2);
613+
let expected_state_gas = STATE_BYTES_PER_NEW_ACCOUNT * cpsb;
614+
615+
assert_eq!(
616+
report.state_gas_used, expected_state_gas,
617+
"parent frame must retain state_gas_new_account after CALL-to-empty + success \
618+
(got {}, expected {})",
619+
report.state_gas_used, expected_state_gas
620+
);
621+
}
622+
556623
#[test]
557624
fn test_child_charge_then_revert_returns_state_gas_to_parent() {
558625
use ethrex_levm::gas_cost::{STATE_BYTES_PER_STORAGE_SET, cost_per_state_byte};

0 commit comments

Comments
 (0)