Skip to content

Commit 89daa9d

Browse files
fix(levm): apply frame transaction gas refunds
1 parent cee872d commit 89daa9d

2 files changed

Lines changed: 275 additions & 20 deletions

File tree

crates/vm/levm/src/vm.rs

Lines changed: 15 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2166,15 +2166,14 @@ impl<'a> VM<'a> {
21662166
)))?;
21672167
let payer = ctx.payer_address.unwrap_or(sender);
21682168

2169-
// Gas refunds: the payer was debited the transaction's MAXIMUM cost at
2170-
// APPROVE (max_fee-based gas + max-rate blob cost, `compute_tx_max_cost`,
2171-
// spec line 387). What the payer owes is the effective-rate cost of the
2172-
// gas actually used plus the base-rate blob burn (EIP-4844 semantics);
2173-
// everything above that is returned here. Intrinsic gas is inside
2174-
// `total_gas_used`, so it stays non-refundable. When max_fee ==
2175-
// effective_gas_price and max_fee_per_blob_gas == base_blob_fee this
2176-
// reduces exactly to the old unused-frame-gas refund:
2177-
// max·T + B − e·U − B = e·(T − U).
2169+
// EIP-8141: substate checkpoints discard refund deltas from reverted frames and batches.
2170+
let applied_refund = self
2171+
.substate
2172+
.refunded_gas
2173+
.min(total_gas_used / crate::hooks::default_hook::MAX_REFUND_QUOTIENT);
2174+
let charged_gas_used = total_gas_used.saturating_sub(applied_refund);
2175+
2176+
// Gas refunds: the payer was debited the transaction's maximum cost at APPROVE.
21782177
let effective_gas_price = self.env.gas_price;
21792178
let charged = crate::opcode_handlers::frame_tx::compute_tx_max_cost(&ctx)
21802179
.map_err(|_| VMError::Internal(InternalError::Overflow))?;
@@ -2183,12 +2182,13 @@ impl<'a> VM<'a> {
21832182
self.env.base_blob_fee_per_gas,
21842183
)?;
21852184
let owed = effective_gas_price
2186-
.checked_mul(U256::from(total_gas_used))
2185+
.checked_mul(U256::from(charged_gas_used))
21872186
.and_then(|gas_owed| gas_owed.checked_add(blob_burn))
21882187
.ok_or(VMError::Internal(InternalError::Overflow))?;
21892188
// charged >= owed always: effective <= max_fee (by construction of the
21902189
// effective price), base_blob <= max_blob (blob-fee validity check), and
2191-
// total_gas_used <= total_gas_limit (frames are bounded by their limits).
2190+
// charged_gas_used <= total_gas_used <= total_gas_limit (frames are
2191+
// bounded by their limits).
21922192
let refund_amount = charged
21932193
.checked_sub(owed)
21942194
.ok_or(VMError::Internal(InternalError::Underflow))?;
@@ -2204,7 +2204,7 @@ impl<'a> VM<'a> {
22042204
// effective gas price), unlike a system call.
22052205
let priority_fee = effective_gas_price.saturating_sub(self.env.base_fee_per_gas);
22062206
let coinbase_fee = priority_fee
2207-
.checked_mul(U256::from(total_gas_used))
2207+
.checked_mul(U256::from(charged_gas_used))
22082208
.ok_or(VMError::Internal(InternalError::Overflow))?;
22092209
if !effective_gas_price.is_zero()
22102210
&& let Some(recorder) = self.db.bal_recorder.as_mut()
@@ -2301,16 +2301,11 @@ impl<'a> VM<'a> {
23012301
let state_gas_used =
23022302
u64::try_from(self.state_gas_used.max(0)).map_err(|_| InternalError::Overflow)?;
23032303

2304-
// Unused frame gas in GAS UNITS for the report — distinct from the wei
2305-
// refund above (which also returns the max-vs-effective fee delta).
2306-
let frame_gas_used = total_gas_used.saturating_sub(intrinsic_gas);
2307-
let gas_refund = sum_frame_gas_limits.saturating_sub(frame_gas_used);
2308-
23092304
let report = ExecutionReport {
23102305
result,
2311-
gas_used: total_gas_used,
2312-
gas_spent: total_gas_used,
2313-
gas_refunded: gas_refund,
2306+
gas_used: charged_gas_used,
2307+
gas_spent: charged_gas_used,
2308+
gas_refunded: applied_refund,
23142309
state_gas_used,
23152310
output: Bytes::new(),
23162311
logs: all_logs,

test/tests/levm/eip8141_tests.rs

Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1212,6 +1212,266 @@ fn state_gas_reservoir_does_not_leak_across_frames() {
12121212
);
12131213
}
12141214

1215+
const SSTORE_SET_THEN_CLEAR_CODE: &[u8] = &[
1216+
0x60, 0x01, 0x60, 0x00, 0x55, 0x60, 0x00, 0x60, 0x00, 0x55, 0x00,
1217+
];
1218+
const SSTORE_SET_THEN_CLEAR_THEN_REVERT_CODE: &[u8] = &[
1219+
0x60, 0x01, 0x60, 0x00, 0x55, 0x60, 0x00, 0x60, 0x00, 0x55, 0x60, 0x00, 0x60, 0x00, 0xFD,
1220+
];
1221+
const SSTORE_RESTORE_EMPTY_REFUND: u64 = 10_000;
1222+
1223+
fn cold_sload_burner(slots: std::ops::Range<u8>) -> Vec<u8> {
1224+
let mut code = Vec::new();
1225+
for slot in slots {
1226+
code.extend_from_slice(&[0x60, slot, 0x54, 0x50]);
1227+
}
1228+
code
1229+
}
1230+
1231+
fn default_frame(target: Address, flags: u8, gas_limit: u64) -> Frame {
1232+
Frame {
1233+
mode: u8::from(FrameMode::Default),
1234+
flags,
1235+
target: Some(target),
1236+
gas_limit,
1237+
value: U256::zero(),
1238+
data: Bytes::new(),
1239+
}
1240+
}
1241+
1242+
fn gross_gas_used(tx: &FrameTransaction, report: &ExecutionReport) -> u64 {
1243+
let sum_frame_limits: u64 = tx.frames.iter().map(|f| f.gas_limit).sum();
1244+
let frame_gas: u64 = report
1245+
.frame_results
1246+
.as_ref()
1247+
.expect("frame tx report must carry per-frame results")
1248+
.iter()
1249+
.map(|(_, gas, _)| *gas)
1250+
.sum();
1251+
tx.total_gas_limit() - sum_frame_limits + frame_gas
1252+
}
1253+
1254+
#[test]
1255+
fn successful_frame_refund_nets_payer_and_block_gas() {
1256+
let wallet = Address::from_low_u64_be(0x3529);
1257+
let writer = Address::from_low_u64_be(0x352A);
1258+
let wallet_initial = U256::from(10u64).pow(U256::from(18u64));
1259+
let mut writer_code = cold_sload_burner(0x10..0x28);
1260+
writer_code.extend_from_slice(SSTORE_SET_THEN_CLEAR_CODE);
1261+
let mut tx = frame_tx_with_frames(vec![
1262+
verify_frame(FUNDED_SENDER),
1263+
verify_frame(wallet),
1264+
Frame {
1265+
mode: u8::from(FrameMode::Sender),
1266+
flags: 0,
1267+
target: Some(writer),
1268+
gas_limit: 300_000,
1269+
value: U256::zero(),
1270+
data: Bytes::new(),
1271+
},
1272+
]);
1273+
tx.max_fee_per_gas = 100_000_000_000;
1274+
tx.max_priority_fee_per_gas = 2_000_000_000;
1275+
let (result, db) = run_frame_tx_with_fees(
1276+
&[
1277+
(
1278+
FUNDED_SENDER,
1279+
AUTO_SEED_SENDER_BALANCE,
1280+
0,
1281+
Bytes::from(APPROVE_EXECUTION_CODE.to_vec()),
1282+
),
1283+
(
1284+
wallet,
1285+
wallet_initial,
1286+
0,
1287+
Bytes::from(APPROVE_PAYMENT_CODE.to_vec()),
1288+
),
1289+
(writer, U256::zero(), 0, Bytes::from(writer_code)),
1290+
],
1291+
tx.clone(),
1292+
10_000_000_000,
1293+
);
1294+
let report = result.expect("valid frame tx (sender approved, payer set)");
1295+
assert!(matches!(report.result, TxResult::Success));
1296+
let gross = gross_gas_used(&tx, &report);
1297+
assert!(
1298+
gross / 5 > SSTORE_RESTORE_EMPTY_REFUND,
1299+
"test setup must keep the refund cap non-binding (gross = {gross})"
1300+
);
1301+
let net = gross - SSTORE_RESTORE_EMPTY_REFUND;
1302+
assert_eq!(report.gas_used, net, "gas_used must be net of the refund");
1303+
assert_eq!(report.gas_spent, net, "gas_spent must be net of the refund");
1304+
let effective = U256::from(12_000_000_000u64);
1305+
let payer_delta = wallet_initial - balance_of(&db, wallet);
1306+
assert_eq!(
1307+
payer_delta,
1308+
effective * U256::from(net),
1309+
"payer must be charged for net (post-refund) gas"
1310+
);
1311+
let coinbase_gain = balance_of(&db, COINBASE_ADDR);
1312+
let base_burn = U256::from(10_000_000_000u64) * U256::from(net);
1313+
assert_eq!(
1314+
payer_delta,
1315+
coinbase_gain + base_burn,
1316+
"coinbase fee must be paid on net (post-refund) gas"
1317+
);
1318+
}
1319+
1320+
#[test]
1321+
fn reverted_frame_refund_contribution_is_discarded() {
1322+
let writer_ok = Address::from_low_u64_be(0x352B);
1323+
let writer_rev = Address::from_low_u64_be(0x352C);
1324+
let mut ok_code = cold_sload_burner(0x10..0x28);
1325+
ok_code.extend_from_slice(SSTORE_SET_THEN_CLEAR_CODE);
1326+
let tx = frame_tx_with_frames(vec![
1327+
verify_frame(FUNDED_SENDER),
1328+
default_frame(writer_ok, 0x00, 300_000),
1329+
default_frame(writer_rev, 0x00, 300_000),
1330+
]);
1331+
let (result, _db) = run_frame_tx(
1332+
&[
1333+
(
1334+
FUNDED_SENDER,
1335+
AUTO_SEED_SENDER_BALANCE,
1336+
0,
1337+
Bytes::from(APPROVE_BOTH_CODE.to_vec()),
1338+
),
1339+
(writer_ok, U256::zero(), 0, Bytes::from(ok_code)),
1340+
(
1341+
writer_rev,
1342+
U256::zero(),
1343+
0,
1344+
Bytes::from(SSTORE_SET_THEN_CLEAR_THEN_REVERT_CODE.to_vec()),
1345+
),
1346+
],
1347+
tx.clone(),
1348+
);
1349+
let report = result.expect("valid frame tx (reverted DEFAULT frame, payer approved)");
1350+
let fr = report
1351+
.frame_results
1352+
.as_ref()
1353+
.expect("frame results present");
1354+
assert_eq!(
1355+
fr[2].0,
1356+
ethrex_common::types::FRAME_RECEIPT_STATUS_FAILURE,
1357+
"the reverting frame must be reported as failed"
1358+
);
1359+
let gross = gross_gas_used(&tx, &report);
1360+
assert!(
1361+
gross / 5 > SSTORE_RESTORE_EMPTY_REFUND,
1362+
"test setup must keep the refund cap non-binding (gross = {gross})"
1363+
);
1364+
assert_eq!(
1365+
report.gas_used,
1366+
gross - SSTORE_RESTORE_EMPTY_REFUND,
1367+
"only the successful frame's refund may be applied"
1368+
);
1369+
}
1370+
1371+
#[test]
1372+
fn reverted_atomic_batch_refund_contributions_are_discarded() {
1373+
let writer_ok = Address::from_low_u64_be(0x352D);
1374+
let writer_batched = Address::from_low_u64_be(0x352E);
1375+
let reverter = Address::from_low_u64_be(0x352F);
1376+
let mut ok_code = cold_sload_burner(0x10..0x28);
1377+
ok_code.extend_from_slice(SSTORE_SET_THEN_CLEAR_CODE);
1378+
let tx = frame_tx_with_frames(vec![
1379+
verify_frame(FUNDED_SENDER),
1380+
default_frame(writer_ok, 0x00, 300_000),
1381+
default_frame(writer_batched, 0x04, 200_000),
1382+
default_frame(reverter, 0x00, 30_000),
1383+
]);
1384+
let (result, _db) = run_frame_tx(
1385+
&[
1386+
(
1387+
FUNDED_SENDER,
1388+
AUTO_SEED_SENDER_BALANCE,
1389+
0,
1390+
Bytes::from(APPROVE_BOTH_CODE.to_vec()),
1391+
),
1392+
(writer_ok, U256::zero(), 0, Bytes::from(ok_code)),
1393+
(
1394+
writer_batched,
1395+
U256::zero(),
1396+
0,
1397+
Bytes::from(SSTORE_SET_THEN_CLEAR_CODE.to_vec()),
1398+
),
1399+
(
1400+
reverter,
1401+
U256::zero(),
1402+
0,
1403+
Bytes::from(PURE_REVERT_CODE.to_vec()),
1404+
),
1405+
],
1406+
tx.clone(),
1407+
);
1408+
let report = result.expect("valid frame tx (failed batch, payer approved)");
1409+
let fr = report
1410+
.frame_results
1411+
.as_ref()
1412+
.expect("frame results present");
1413+
assert_eq!(
1414+
(fr[2].0, fr[2].1),
1415+
(ethrex_common::types::FRAME_RECEIPT_STATUS_FAILURE, 200_000),
1416+
"batched frame must be failed and charged its full gas_limit"
1417+
);
1418+
assert_eq!(
1419+
(fr[3].0, fr[3].1),
1420+
(ethrex_common::types::FRAME_RECEIPT_STATUS_FAILURE, 30_000),
1421+
"batch terminator must be failed and charged its full gas_limit"
1422+
);
1423+
let gross = gross_gas_used(&tx, &report);
1424+
assert!(
1425+
gross / 5 > SSTORE_RESTORE_EMPTY_REFUND,
1426+
"test setup must keep the refund cap non-binding (gross = {gross})"
1427+
);
1428+
assert_eq!(
1429+
report.gas_used,
1430+
gross - SSTORE_RESTORE_EMPTY_REFUND,
1431+
"the reverted batch's refund contributions must be discarded"
1432+
);
1433+
}
1434+
1435+
#[test]
1436+
fn refund_capped_at_one_fifth_of_gross_gas() {
1437+
let writer = Address::from_low_u64_be(0x3530);
1438+
let mut writer_code = Vec::new();
1439+
for slot in 0u8..20 {
1440+
writer_code.extend_from_slice(&[0x60, 0x01, 0x60, slot, 0x55]);
1441+
writer_code.extend_from_slice(&[0x60, 0x00, 0x60, slot, 0x55]);
1442+
}
1443+
writer_code.push(0x00);
1444+
let tx = frame_tx_with_frames(vec![
1445+
verify_frame(FUNDED_SENDER),
1446+
default_frame(writer, 0x00, 1_000_000),
1447+
]);
1448+
let (result, _db) = run_frame_tx(
1449+
&[
1450+
(
1451+
FUNDED_SENDER,
1452+
AUTO_SEED_SENDER_BALANCE,
1453+
0,
1454+
Bytes::from(APPROVE_BOTH_CODE.to_vec()),
1455+
),
1456+
(writer, U256::zero(), 0, Bytes::from(writer_code)),
1457+
],
1458+
tx.clone(),
1459+
);
1460+
let report = result.expect("valid frame tx (payer approved)");
1461+
assert!(matches!(report.result, TxResult::Success));
1462+
let gross = gross_gas_used(&tx, &report);
1463+
assert!(
1464+
20 * SSTORE_RESTORE_EMPTY_REFUND > gross / 5,
1465+
"test setup must make the refund counter exceed the cap (gross = {gross})"
1466+
);
1467+
assert_eq!(
1468+
report.gas_used,
1469+
gross - gross / 5,
1470+
"the applied refund must be capped at gross/5"
1471+
);
1472+
assert_eq!(report.gas_spent, report.gas_used);
1473+
}
1474+
12151475
// ==================== frame_tx opcode handler unit tests ====================
12161476
// (migrated from crates/vm/levm/src/opcode_handlers/frame_tx.rs)
12171477

0 commit comments

Comments
 (0)