diff --git a/ethereum/core/src/main/java/org/hyperledger/besu/ethereum/mainnet/MainnetTransactionProcessor.java b/ethereum/core/src/main/java/org/hyperledger/besu/ethereum/mainnet/MainnetTransactionProcessor.java index ed2f66eb618..6d0ff35a2a9 100644 --- a/ethereum/core/src/main/java/org/hyperledger/besu/ethereum/mainnet/MainnetTransactionProcessor.java +++ b/ethereum/core/src/main/java/org/hyperledger/besu/ethereum/mainnet/MainnetTransactionProcessor.java @@ -529,6 +529,7 @@ public TransactionProcessingResult processTransaction( .stateGasUsed(initialFrame.getStateGasUsed()) .initialFrameStateGasSpill(initialFrameStateGasSpill) .stateGasSpillBurned(initialFrame.getStateGasSpillBurned()) + .initialFrameRegularHaltBurn(initialFrame.getInitialFrameRegularHaltBurn()) .refundedGas(refundedGas) .floorCost(floorCost) .regularGasLimitExceeded(regularGasLimitExceeded) diff --git a/ethereum/core/src/main/java/org/hyperledger/besu/ethereum/mainnet/TransactionGasAccounting.java b/ethereum/core/src/main/java/org/hyperledger/besu/ethereum/mainnet/TransactionGasAccounting.java index 08e4722c6c7..3b055548881 100644 --- a/ethereum/core/src/main/java/org/hyperledger/besu/ethereum/mainnet/TransactionGasAccounting.java +++ b/ethereum/core/src/main/java/org/hyperledger/besu/ethereum/mainnet/TransactionGasAccounting.java @@ -55,6 +55,16 @@ public record GasResult(long effectiveStateGas, long gasUsedByTransaction, long /** Total state gas spilled into gasRemaining from reverted frames. */ public abstract long stateGasSpillBurned(); + /** + * Gas that was sitting unused in the initial frame's gasRemaining at the moment of an exceptional + * halt (EIP-7778/EIP-8037). Paid by the sender (receipts) but must be excluded from block regular + * gas since no operation consumed it. + */ + @Value.Default + public long initialFrameRegularHaltBurn() { + return 0L; + } + /** Gas refunded to the sender. */ public abstract long refundedGas(); @@ -101,7 +111,10 @@ public GasResult calculate() { // initialFrameStateGasSpill is already included in spillBurned AND stateGas, // so subtract it from spillBurned to avoid double-counting. final long regularGas = - executionGas - stateGas - (stateGasSpillBurned() - initialFrameStateGasSpill()); + executionGas + - stateGas + - (stateGasSpillBurned() - initialFrameStateGasSpill()) + - initialFrameRegularHaltBurn(); if (regularGas < 0) { // This should not happen under normal circumstances. A negative regularGas indicates a // bug in gas accounting — log at error level to ensure visibility. diff --git a/ethereum/core/src/test/java/org/hyperledger/besu/ethereum/mainnet/TransactionGasAccountingTest.java b/ethereum/core/src/test/java/org/hyperledger/besu/ethereum/mainnet/TransactionGasAccountingTest.java index 99082dd2408..5ff9c8df0ba 100644 --- a/ethereum/core/src/test/java/org/hyperledger/besu/ethereum/mainnet/TransactionGasAccountingTest.java +++ b/ethereum/core/src/test/java/org/hyperledger/besu/ethereum/mainnet/TransactionGasAccountingTest.java @@ -159,6 +159,45 @@ public void zeroStateGas_preAmsterdamEquivalent() { assertThat(result.usedGas()).isEqualTo(90_000L); } + @Test + public void initialFrameRegularHaltBurn_excludedFromRegularGas() { + // EIP-3607 collision scenario: CREATE tx with gas_limit=600k halts at + // ContractCreationProcessor.start(). chargeCreateStateGas charged 131488 state gas + // (spilled into gasRemaining). At halt, gasRemaining=438012 was cleared by + // clearGasRemaining() and captured into initialFrameRegularHaltBurn. + // The sender still pays the full 600k via receipts, but block regular gas must + // only reflect intrinsic regular (i.e. 0 executionGas attributable to the frame + // beyond state gas and halt burn). + final var result = + baseBuilder() + .txGasLimit(600_000L) + .remainingGas(0L) + .stateGasReservoir(0L) + .stateGasUsed(131_488L) + .initialFrameRegularHaltBurn(438_012L) + .build() + .calculate(); + + // executionGas = 600k - 0 - 0 = 600000 + // stateGas = 131_488 + 0 = 131_488 + // regularGas = 600_000 - 131_488 - 0 - 438_012 = 30_500 + // gasUsedByTransaction = max(30_500, 0) + 131_488 = 161_988 + // usedGas = 600_000 - 0 = 600_000 (sender pays full gas_limit) + assertThat(result.effectiveStateGas()).isEqualTo(131_488L); + assertThat(result.gasUsedByTransaction()).isEqualTo(161_988L); + assertThat(result.usedGas()).isEqualTo(600_000L); + } + + @Test + public void initialFrameRegularHaltBurn_defaultsToZero() { + // When not set (pre-Amsterdam or non-halt paths), the field should default to 0 + // and have no effect on the calculation. + final var result = baseBuilder().txGasLimit(100_000L).remainingGas(30_000L).build().calculate(); + + // Same as normalPath_regularGasComputedCorrectly (without refund) + assertThat(result.gasUsedByTransaction()).isEqualTo(70_000L); + } + @Test public void build_failsWhenFieldMissing() { assertThatThrownBy(() -> TransactionGasAccounting.builder().txGasLimit(100_000L).build()) diff --git a/evm/src/main/java/org/hyperledger/besu/evm/frame/MessageFrame.java b/evm/src/main/java/org/hyperledger/besu/evm/frame/MessageFrame.java index a90da960e38..954f4e1e955 100644 --- a/evm/src/main/java/org/hyperledger/besu/evm/frame/MessageFrame.java +++ b/evm/src/main/java/org/hyperledger/besu/evm/frame/MessageFrame.java @@ -198,6 +198,10 @@ public enum Type { // Metadata fields. private final Type type; private State state = State.NOT_STARTED; + // EIP-7778/EIP-8037: Flipped to true once code execution starts; used to distinguish a halt + // that fires during opcode execution (halt-burn counts toward block regular gas) from a halt + // raised pre-execution in the processor's start() (halt-burn must be excluded). + private boolean codeExecuted = false; // Machine state fields. private long gasRemaining; @@ -969,6 +973,46 @@ public long getStateGasSpillBurned() { return txValues.stateGasSpillBurned()[0]; } + /** + * Accumulates gas that was sitting unused in the initial frame's gasRemaining at the moment of an + * exceptional halt (EIP-7778/EIP-8037). The sender still pays for this gas via receipts, but it + * did not correspond to any executed regular or state gas, so it must be excluded from the block + * regular gas total. Not undone on revert. + * + * @param amount the gasRemaining snapshot taken immediately before clearGasRemaining on the + * initial frame's exceptional halt + */ + public void accumulateInitialFrameRegularHaltBurn(final long amount) { + txValues.initialFrameRegularHaltBurn()[0] += amount; + } + + /** + * Returns the gas burned on the initial frame's exceptional halt. + * + * @return accumulated halt-burned gas + */ + public long getInitialFrameRegularHaltBurn() { + return txValues.initialFrameRegularHaltBurn()[0]; + } + + /** + * Marks that opcode execution has started on this frame. Once set, an exceptional halt is + * classified as "during code execution" (halt-burned gas counts toward block regular gas) rather + * than pre-execution (halt-burned gas is excluded). + */ + public void markCodeExecuted() { + this.codeExecuted = true; + } + + /** + * Returns whether opcode execution has started on this frame. + * + * @return true if {@link #markCodeExecuted()} was invoked + */ + public boolean isCodeExecuted() { + return codeExecuted; + } + /** * Add recipient to the self-destruct set if not already present. * diff --git a/evm/src/main/java/org/hyperledger/besu/evm/frame/TxValues.java b/evm/src/main/java/org/hyperledger/besu/evm/frame/TxValues.java index 679402f2315..d4c9edad39b 100644 --- a/evm/src/main/java/org/hyperledger/besu/evm/frame/TxValues.java +++ b/evm/src/main/java/org/hyperledger/besu/evm/frame/TxValues.java @@ -56,6 +56,9 @@ * undone on revert * @param stateGasSpillBurned EIP-8037 accumulated state gas that spilled from reverted child * frames; NOT undone on revert (permanent burn counter for block accounting) + * @param initialFrameRegularHaltBurn EIP-7778/EIP-8037 gas burned when the initial frame halts + * exceptionally (gasRemaining at halt time). Paid by the sender via receipts, but must be + * excluded from block regular gas. Single-element long[] so it is NOT undone on revert. */ public record TxValues( BlockHashLookup blockHashLookup, @@ -75,7 +78,8 @@ public record TxValues( UndoScalar gasRefunds, UndoScalar stateGasUsed, UndoScalar stateGasReservoir, - long[] stateGasSpillBurned) { + long[] stateGasSpillBurned, + long[] initialFrameRegularHaltBurn) { /** * Creates a new TxValues for the initial (depth-0) frame of a transaction. EIP-8037 gas tracking @@ -120,6 +124,7 @@ public static TxValues forTransaction( new UndoScalar<>(0L), new UndoScalar<>(0L), new UndoScalar<>(0L), + new long[] {0L}, new long[] {0L}); } diff --git a/evm/src/main/java/org/hyperledger/besu/evm/gascalculator/Eip8037StateGasCostCalculator.java b/evm/src/main/java/org/hyperledger/besu/evm/gascalculator/Eip8037StateGasCostCalculator.java index 5946226cc22..af0733fe8cb 100644 --- a/evm/src/main/java/org/hyperledger/besu/evm/gascalculator/Eip8037StateGasCostCalculator.java +++ b/evm/src/main/java/org/hyperledger/besu/evm/gascalculator/Eip8037StateGasCostCalculator.java @@ -239,9 +239,24 @@ public boolean chargeCodeDelegationStateGas( } // New empty accounts incur additional state gas (112 * cpsb) final long newEmptyAccounts = totalDelegations - alreadyExistingDelegators; - if (newEmptyAccounts > 0) { - return frame.consumeStateGas( - emptyAccountDelegationStateGas(blockGasLimit) * newEmptyAccounts); + if (newEmptyAccounts > 0 + && !frame.consumeStateGas( + emptyAccountDelegationStateGas(blockGasLimit) * newEmptyAccounts)) { + return false; + } + // EIP-8037: the intrinsic state gas is sized assuming every authority is a new empty + // account (emptyAccountDelegationStateGas per auth). For authorities that already exist, + // that pre-charge was not consumed, park it in the state gas reservoir so it is returned + // to the sender alongside any unused gas on halt/revert, matching the specification's + // set_delegation behavior (intrinsic_state_gas -= refund; state_gas_reservoir += refund). + if (alreadyExistingDelegators > 0) { + final long reservoirCredit = + emptyAccountDelegationStateGas(blockGasLimit) * alreadyExistingDelegators; + if (frame.getRemainingGas() < reservoirCredit) { + return false; + } + frame.decrementRemainingGas(reservoirCredit); + frame.incrementStateGasReservoir(reservoirCredit); } return true; } diff --git a/evm/src/main/java/org/hyperledger/besu/evm/processor/AbstractMessageProcessor.java b/evm/src/main/java/org/hyperledger/besu/evm/processor/AbstractMessageProcessor.java index d18f62610ba..de4d4d87a5a 100644 --- a/evm/src/main/java/org/hyperledger/besu/evm/processor/AbstractMessageProcessor.java +++ b/evm/src/main/java/org/hyperledger/besu/evm/processor/AbstractMessageProcessor.java @@ -152,10 +152,9 @@ private void clearAccumulatedStateBesidesGasAndOutput(final MessageFrame frame) * * @param frame The message frame */ - private void handleStateGasSpill(final MessageFrame frame) { + private void handleStateGasSpill(final MessageFrame frame, final boolean isInitialFrame) { final long stateGasUsedBefore = frame.getStateGasUsed(); final long reservoirBefore = frame.getStateGasReservoir(); - final boolean isInitialFrame = frame.getMessageFrameStack().size() == 1; clearAccumulatedStateBesidesGasAndOutput(frame); @@ -163,14 +162,17 @@ private void handleStateGasSpill(final MessageFrame frame) { final long reservoirRestored = frame.getStateGasReservoir() - reservoirBefore; if (isInitialFrame) { - // EIP-8037: Preserve the reservoir for top-level refund. Use the max of the pre-rollback - // value (which may include child frame refunds that must not be lost) and the post-rollback - // value (which reflects any reservoir drain rollback has already restored). - final long reservoirPostRollback = frame.getStateGasReservoir(); - final long preservedReservoir = Math.max(reservoirPostRollback, reservoirBefore); - if (preservedReservoir != reservoirPostRollback) { - frame.setStateGasReservoir(preservedReservoir); + // EIP-8037: For initial-frame halt/revert, state gas consumed by ops is final for block + // accounting (spec: `tx_state_gas = intrinsic_state_gas + state_gas_used`). The portion + // that spilled from gasRemaining is already accounted via stateGasSpillBurned below; the + // portion drained from the reservoir (reservoirRestored) was rolled back by the undo but + // must still count as consumed state gas, so add it back to stateGasUsed. Then preserve + // the actual pre-rollback reservoir value so drain is reflected in total gas returned to + // the sender. + if (reservoirRestored > 0) { + frame.incrementStateGasUsed(reservoirRestored); } + frame.setStateGasReservoir(reservoirBefore); // Only burn the portion of state gas that actually spilled into gasRemaining (not the // portion that was drawn from the reservoir and has already been restored, and not the // portion that child frames had refunded to the reservoir). @@ -187,13 +189,39 @@ private void handleStateGasSpill(final MessageFrame frame) { } } + /** + * Snapshots the initial frame's gasRemaining into {@code initialFrameRegularHaltBurn} when a + * pre-execution halt fires on the initial frame (e.g. EIP-684 CREATE collision) so that gas paid + * by the sender but never spent on regular or state work is excluded from block regular gas. When + * opcode execution has already run on the frame, the halt-burn must remain in block regular gas + * (no-op here). + * + * @param frame the initial (depth-0) message frame + */ + private static void recordInitialFrameRegularHaltBurn(final MessageFrame frame) { + if (frame.isCodeExecuted()) { + return; + } + final long haltBurn = frame.getRemainingGas(); + if (haltBurn > 0) { + frame.accumulateInitialFrameRegularHaltBurn(haltBurn); + } + } + /** * Gets called when the message frame encounters an exceptional halt. * * @param frame The message frame */ private void exceptionalHalt(final MessageFrame frame) { - handleStateGasSpill(frame); + final boolean isInitialFrame = frame.getMessageFrameStack().size() == 1; + + handleStateGasSpill(frame, isInitialFrame); + + if (isInitialFrame) { + recordInitialFrameRegularHaltBurn(frame); + } + frame.clearGasRemaining(); frame.clearOutputData(); frame.setState(MessageFrame.State.COMPLETED_FAILED); @@ -205,7 +233,9 @@ private void exceptionalHalt(final MessageFrame frame) { * @param frame The message frame */ protected void revert(final MessageFrame frame) { - handleStateGasSpill(frame); + final boolean isInitialFrame = frame.getMessageFrameStack().size() == 1; + handleStateGasSpill(frame, isInitialFrame); + frame.setState(MessageFrame.State.COMPLETED_FAILED); } @@ -237,6 +267,7 @@ private void completedFailed(final MessageFrame frame) { * @param operationTracer The tracer recording execution */ private void codeExecute(final MessageFrame frame, final OperationTracer operationTracer) { + frame.markCodeExecuted(); try { evm.runToHalt(frame, operationTracer); } catch (final ModificationNotAllowedException e) { diff --git a/evm/src/main/java/org/hyperledger/besu/evm/processor/MessageCallProcessor.java b/evm/src/main/java/org/hyperledger/besu/evm/processor/MessageCallProcessor.java index e80e10ba4b4..0fad097bab2 100644 --- a/evm/src/main/java/org/hyperledger/besu/evm/processor/MessageCallProcessor.java +++ b/evm/src/main/java/org/hyperledger/besu/evm/processor/MessageCallProcessor.java @@ -181,6 +181,10 @@ private void executePrecompile( final PrecompiledContract contract, final MessageFrame frame, final OperationTracer operationTracer) { + // EIP-7778/EIP-8037: precompile execution counts as code execution for the purpose of + // halt-burn classification — an OOG halt below should NOT be treated as a pre-execution + // halt and excluded from block regular gas. + frame.markCodeExecuted(); final long gasRequirement = contract.gasRequirement(frame.getInputData()); final Bytes output; if (frame.getRemainingGas() < gasRequirement) {