EIP-7906 Verifying transaction results with state-change opcodes (Transaction Assertions via State Diff Opcode)
Hegota Considered for Inclusion · Security & privacy · EIP status Draft · created 2025-02-21
A read-only check stage (the ASSERT frame) is attached at the very end of the transaction. Inside it, the new opcodes TXTRACE and EVENTDATACOPY read the balance, storage, code and nonce changes and the events the transaction left behind, and if a condition is violated, the execution result is reverted.
Why it is needed
The total value of crypto assets stolen so far exceeds the annual GDP of a mid-sized country. Yet ordinary users and wallet apps have very limited ability to find, collect, review and analyze the EVM code a transaction will actually execute. After signing, there is no means at all to restrict what the transaction actually does. As a result, every time users interact with Ethereum they effectively sign without knowing the contents (blind signing) and are exposed to major risk. Instead of reading the code in advance, this proposal opens a way to look at the transaction's result and restrict it. If wallets and dapps attach an onchain check to the transaction and discard the transaction's effects when the check fails, users can define for themselves the range of what can happen to their account.
What changes
Today
- Users cannot read and judge all the code a transaction will execute, so they effectively sign without knowing the contents
- After signing, there is no way to restrict what the transaction actually does
- A contract cannot know during execution what changes the transaction it belongs to has left overall
After EIP-7906
- A wallet or dapp attaches an ASSERT frame at the end of the transaction to check the result onchain
- If the check fails, all effects of the execution part are reverted, and the transaction remains valid with only gas paid
- TXTRACE reads balance, storage, code and nonce changes and events, and EVENTDATACOPY reads event data
- Multiple assertion providers can each attach their own ASSERT frame and check together without coordinating with one another
Who is affected
- Users: Restrictions can be placed on transaction results, which reduces the risk of blind signing. However, an assertion with a narrow check scope is the same as having none, so it is not grounds for reassurance.
- Wallet and dapp developers: They must build the assertion contract and design the smart account's VERIFY frame so that it always requires the inclusion of its own ASSERT frame. The assertion target must be a non-upgradeable contract.
- Client teams: They must implement the two new opcodes and the ASSERT frame mode, and build an index over the transaction's state diff and logs so that each TXTRACE call is answered at constant cost.
- Assertion providers: Multiple ASSERT frames can be included, so each provider can check independently in its own frame and revert the execution body.
In depth
Rules of the ASSERT frame mode
ASSERT (value 3) is added to the mode values of an EIP-8141 frame transaction. It is the fourth mode, alongside the existing DEFAULT, VERIFY and SENDER. ASSERT frames must come contiguously at the very end of tx.frames, and once an ASSERT appears, all frames after it must also be ASSERT. If this is violated, the transaction itself is invalid. The caller is ENTRY_POINT and the frame runs as a STATICCALL, so it cannot change state. Unlike VERIFY frames, it has no APPROVE exception either, so calling APPROVE inside an ASSERT call tree aborts with an exception due to a STATICCALL violation. An approval scope flag may be set but has no effect, and the atomic batch flag cannot be set on an ASSERT frame or on the frame immediately before it. If an ASSERT frame reverts or aborts with an exception, the entire execution body is unconditionally reverted, and state and approval context are restored to the point immediately after the frame that set the payer. If it fails when no frame has set the payer, the transaction is invalid under EIP-8141 rules. Remaining ASSERT frames are not executed and are left with receipt status 2 and gas 0, and that gas is refunded at the end of the transaction. Even on failure, the transaction is valid and is included in the block. The failed ASSERT frame's receipt has status 0, and the receipts of the reverted execution body frames keep their status and execution gas, but their logs are discarded and state gas is removed. Changes from the validation prefix (gas payment through APPROVE, account creation by a deploy frame) are finalized as is, and the payer pays for all gas used up to the point of failure. The EIP-8141 default code treats ASSERT the same as SENDER or DEFAULT.
TXTRACE opcode (0xb6) and parameter table
TXTRACE reads the net difference between the state before the transaction started and the state at the time of the call. The top of the stack is param, below it is in2, and it pushes one 256-bit value as the result. It uses the same selector and index scheme as EIP-8141's FRAMEPARAM, but the operand order is reversed. The count parameters are 0x00 TXTRACE_BALANCES_CHANGED, 0x01 TXTRACE_SLOTS_CHANGED, 0x02 TXTRACE_CODES_CHANGED, 0x03 TXTRACE_NONCES_CHANGED and 0x11 TXTRACE_EVENTS_COUNT, and in2 must be 0. Each state change table has the same fields in the order address, before, after. Balances are 0x04 to 0x06, storage is 0x07 to 0x09 with slot key at 0x0A, code is 0x0B to 0x0D, and nonces are 0x0E to 0x10. For events, 0x12 is the emitting address, 0x13 is the topic count (0 to 4), 0x14 to 0x17 are topic0 to topic3 (aborts with an exception if the topic does not exist), and 0x18 is the length of the non-indexed data. 0x19 TXTRACE_GAS_PRE_CHARGE is the max_cost from EIP-8141, the amount collected from the payer when payment was approved. Depending on the block base fee it may be smaller than max_fee, and it includes blob fees. 0x1A TXTRACE_GAS_PAYER is the target of the frame that called APPROVE(APPROVE_PAYMENT) or APPROVE(APPROVE_EXECUTION_AND_PAYMENT), that is, the payer, which may not be the sender. All params cost the same, EIP-2929's WARM_STORAGE_READ_COST. Outside an ASSERT frame, it aborts with an exception everywhere, whether in legacy, EIP-1559 or other frame modes.
How the state diff is counted
The before values are the prestate before the transaction executes, and the after values are the current state at the time of the TXTRACE call. Intermediate writes are not visible separately. balances_changed includes every address whose balance changed, including the gas prepayment applied to the payer. To get the pure ETH movement, find the payer with 0x1A and subtract the 0x19 value from that address's balance difference. slots_changed is per (address, slot) pair, and multiple writes to the same slot are merged into one entry. Cases where an entire storage is cleared (CREATE onto an address that has only storage, removal of an empty account that has storage) are not reported, because that would require walking the whole prestate storage, so the scope is unbounded. codes_changed compares hashes of the code exactly as stored, like EXTCODEHASH, so an EIP-7702 delegation designator is hashed as itself and the code it points to is not followed. A nonexistent account is treated as having the empty code hash. As a result, newly created contracts and the installation, change and removal of delegation designators are all caught, and a CREATE/CREATE2 that leaves empty code is not caught. nonces_changed includes the tx.sender whose nonce APPROVE raised, the accounts that executed CREATE/CREATE2, and the newly created contracts. If replay protection uses storage instead of the account nonce, it shows up in slots_changed. EIP-6780 deletion happens after the last frame, so a contract that was created and destroyed in the same transaction also remains in codes_changed and nonces_changed. Logs emitted by the protocol, such as EIP-7708 transfer logs, are also counted as events, so ETH transfers can be seen in the event table as well.
EVENTDATACOPY opcode (0xb7)
TXTRACE gives only the topics and the data length of an event, and the variable-length data itself cannot be returned as a single stack word. EVENTDATACOPY copies that data into memory. The stack, from the top, is event_index, memOffset, dataOffset, length, and there is no output. It copies length bytes of the event's non-indexed data, starting at dataOffset, to memOffset, and the copy and memory expansion are the same as CALLDATACOPY. Gas is also the same as CALLDATACOPY: a fixed 3 plus memory expansion and copy costs. However, the source range check is stricter. If event_index is greater than or equal to events_count, execution aborts with an exception, and if dataOffset + length exceeds the event data length, execution aborts with an exception even when length is 0. The difference is that CALLDATACOPY does not abort because of the source range. The addition is computed without 256-bit wrapping.
Gas, access lists, input checks, ordering
The two opcodes read only the state differences and logs that have already been recorded. They do not read the actual state, do not interact with the EIP-2929 access list, and do not add accesses to the block-level access list. Gas follows the schedule of the active fork, and when EIP-8038 is active, WARM_STORAGE_READ_COST is named WARM_ACCESS and has the value 100. When EIP-7928 is active and the execution body is reverted because an ASSERT failed, the reverted frames are treated like reverted calls. State changes are discarded, but the addresses and slots that were accessed remain in the list, and a slot that the body wrote and then restored has no net change, so it is recorded in storage_reads unless the verification prefix or another transaction in the same block changed it. Accesses by the ASSERT frame itself also remain, and changes made by the verification prefix are recorded at the transaction's block_access_index. Input checks are strict. Giving a nonzero value to in2, which must be 0, aborts with an exception, and a param greater than 0x1A also aborts with an exception, and the value is not truncated but is read as the full 256 bits. For the index parameters (0x04 to 0x10, 0x12 to 0x18), if in2 is greater than or equal to the count in the corresponding table, execution aborts with an exception. Ordering for balance, slot, code and nonce changes is ascending by address read as a uint160 number, and slots within one address are ascending by key read as a uint256. Events are in order of occurrence, that is, by the global log index within the transaction.
Why it is designed this way
TXTRACE and EVENTDATACOPY are lookup capabilities strong enough to break code encapsulation. They are allowed only inside an ASSERT frame so that they are used only to judge the validity of the result and to revert the execution body. Because ASSERT must come at the very end, the difference TXTRACE sees is always the final result, and because a failure reverts the entire execution body, it cannot be partly avoided with an atomic batch or an earlier frame. Multiple ASSERT frames can be present, so different assertion providers can each check in their own frame without coordinating. If an ASSERT failure removed the transaction from the block and also reverted the gas payment, it would become a DoS path where an attacker spends up to the block gas limit and reverts for free, so the transaction is kept valid and the gas is still charged. No new gas constants were added. The slots and accounts in the difference were already written by the transaction and are therefore warm, and reading them with SLOAD or BALANCE costs WARM_STORAGE_READ_COST, so the same value is charged, and values such as counts or addresses were also priced uniformly. A client can build an index of the difference and logs once, when the ASSERT frame first reads, and reuse it, and that cost is covered by the access costs and LOG costs the transaction has already paid. The reason events are used as the transfer channel is that EIP-8141 discards transient storage between frames and does not provide the return data of earlier frames, so length-capable events are the only way to pass pre-execution values to the ASSERT frame. For state changes, a slot written many times through reentrant calls is still one entry, so execution order cannot give a deterministic order, and they are sorted by address and slot key, while events have a log index and use the order of occurrence.
Security considerations
The biggest risk is false reassurance. An assertion with a narrow scope of checking leads people to believe a dangerous transaction is safe, so an incomplete assertion should be treated as the same as having none. The protocol does not force the inclusion of an ASSERT frame. A smart account must check in the VERIFY frame that its own ASSERT frame is present, and since ASSERT frames are gathered at the end, it can scan from the back. The target of an assertion must be an immutable contract that cannot be upgraded. Because the execution body runs before the assertion, the state the body can write can be chosen by an attacker. If the result of a call to an oracle, balanceOf, a registry or a proxy, or an after value, is used as the reference value, it can be manipulated, and a called contract can even run TXTRACE, notice that it is inside an ASSERT, and answer differently. The reference value must come from the data of an ASSERT frame fixed at signing time, or from a before value. An assertion constrains only that one transaction. An existing allowance or an off-chain permit is used in other transactions, and a pending withdrawal or a bridge deposit completes elsewhere. Changes by the verification prefix are visible but cannot be reverted, and events show only the fact that code emitted them, so storage and balances are the source of truth. Gas exhaustion is also a problem. Under the current settings a single transaction can emit about 42,600 events, and a malicious dapp can inflate the cost with cheap logs. An assertion must read the count first and check the limit, a framework must pass gas proportional to the expected count, and an assertion's OOG must always be treated as a revert. Sorted tables allow binary search, but events must be scanned in full. The verification prefix is not reverted, so untrusted execution must not be placed there, and a deploy frame must not be used for anything other than installing tx.sender code.
Current status and fork discussion
This EIP was created on 2025-02-21 and its status is Draft. It presupposes EIP-2929 and EIP-8141. Because it is in the Draft stage, the opcode numbers, the parameter table and the ASSERT frame rules may change. In the core developer calls, it was accepted as a Hegotá candidate (PFI) at 07.30 ACDE #242, and at 09.24 ACDE #246 it was raised to Considered for Inclusion (CFI) for Hegotá together with EIP-8250, EIP-8272, EIP-7668, EIP-8253, EIP-3298, EIP-8131 and others. At 10.12 ACDT #100, it was confirmed that EIP-7906 would be included in bogota-devnet-1 together with EIP-8141 (frames), EIP-8250 (keyed nonces) and EIP-8272 (recent roots). This means the execution layer Hegotá candidates are being bundled and verified together.