Repository navigation
Conversation
A value transfer inside a reverted EVM sub-call was removed from the receipt but left in the coins account cache, so a later transfer in the same transaction could spend the refunded balance and commit a credit that no debit backed (native-coin mint). #1348 fixed this by undoing each transfer by hand (preState capture plus StateDB.RevertSet), gated behind ForkEVMTransferRevert. This replaces that with automatic rollback: MemoryStateDB wraps the executor StateDB in journalKV, a write buffer with nested rollback points aligned with Snapshot versions, in the spirit of chain33's txcache. The coins account is a copy bound to the buffer, so coins and token transfers, SELFDESTRUCT payouts and the paracross ExecTransfer path are all undone by RevertToSnapshot without per-path revert code. The surviving writes go into the receipt and back to the StateDB in GetChangedData; nothing reaches the executor StateDB during execution, so checkKV holds by construction. Snapshot.revert unwinds entries in reverse order and Create snapshots before the value transfer, now unconditionally. Not fork-gated: history was checked for exploitation of the mint. ForkEVMTransferRevert is no longer read; its registration and config keys stay because initForkConfig panics on an unregistered key in shipped configs. Removed with the #1348 machinery: transferChange.preState, revertAccountKey, RevertError/ResetRevertError and their tests. The behaviour tests from #1348 (production-StateDB checkKV, token precompile, failed and successful CREATE, transaction-level mint) now run without the fork and pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
vipwzw
force-pushed
the
fix-evm-journal-kv-revert
branch
from
November 4, 2026 10:11
ee5b30f to
5f3e1ac
Compare
Below ForkEVMTransferRevert MemoryStateDB writes straight to the executor StateDB, transferChange carries the receipt KV, Snapshot.revert runs forward and Create transfers before taking its snapshot, exactly as before #1348, so historical blocks replay unchanged. From the fork on writes go through journalKV, Snapshot.revert runs in reverse and Create snapshots first. The fork keeps the registration shipped in 1.76.4 (MaxHeight in code, -1 in the configs); the activation height is set at release time. The #1348 tests that pin both sides of the fork are restored and pass against the buffer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Collaborator
Author
|
Closing: the reverted-transfer mint is already fixed by #1348. Nested rollback will be added to chain33 itself instead. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
When an EVM sub-call with value reverts, its transfer is dropped from the receipt but stays in the coins account cache. A later transfer in the same transaction can then spend the refunded balance and mint native coin.
#1348 (merged, released in 1.76.4) fixed this behind
ForkEVMTransferRevertby undoing each transfer by hand: it captures the account KV before every transfer and writes it back through chain33'sStateDB.RevertSet. This PR keeps the fork but replaces the mechanism behind it with automatic rollback, so a new write path cannot bring the bug back.From
ForkEVMTransferReverton,MemoryStateDBwraps the executor StateDB injournalKV, a write buffer with nested rollback points that line up withSnapshotversions. It works like chain33'stxcache, but with one level per EVM call frame instead of one per transaction:RevertToSnapshotunwinds the buffer to that snapshot's rollback point.GetChangedDataputs the surviving writes into the receipt, sorted by key, and writes them back to the StateDB so later transactions in the block can read them.CoinsAccountis a copy bound to the buffer, so the executor's shared object is not modified. Coins and token transfers, SELFDESTRUCT payouts, paracrossExecTransferand contract account loads all go through the buffer and are undone with no per-path revert code. Nothing reaches the executor StateDB while the EVM runs, socheckKVholds by construction, andRevertSetis no longer used.After the fork,
Snapshot.revertalso unwinds entries in reverse order, andCreatetakes its snapshot before the value transfer, as in #1348.Flow
How one transaction runs through
MemoryStateDBafter this PR:flowchart TD nExec["Exec: NewMemoryStateDB"] --> nFork{"ForkEVMTransferRevert active?"} nFork -- "no" --> nLegacy["Legacy path, unchanged<br/>writes go straight to the executor StateDB<br/>transferChange carries the receipt KV"] nFork -- "yes" --> nWrap["Wrap the StateDB in journalKV<br/>bind a CoinsAccount copy to it"] nWrap --> nFrame["CALL / CREATE: Snapshot(v)<br/>journalKV records rollback point v"] nFrame --> nWrite["Transfer, token transfer, SELFDESTRUCT<br/>journalKV.Set buffers the write<br/>and logs the previous value"] nWrite --> nFailed{"Frame fails?"} nFailed -- "yes" --> nRevert["RevertToSnapshot(v)<br/>unwind the log back to v"] nRevert --> nMore{"More frames?"} nFailed -- "no" --> nMore nMore -- "yes" --> nFrame nMore -- "no" --> nDone{"Transaction succeeded?"} nDone -- "yes" --> nFlush["GetChangedData<br/>surviving writes go into the receipt KV<br/>and back to the executor StateDB"] nDone -- "no" --> nDrop["Buffer discarded<br/>executor StateDB untouched"] nFlush --> nCheck["checkKV passes:<br/>only receipt keys were written"]The mint from the report, before and after the fork:
sequenceDiagram participant A as Attacker participant P as Contract P participant Q as Contract Q participant O as Payout address A->>P: CALL with 100 BTY (committed) P->>Q: CALL with 100 BTY (committed) Q->>P: refund 100 BTY Note over P: P reverts any plain value call Note over Q,P: Before the fork: the receipt drops the refund,<br/>but the cache still credits P with 100 Note over Q,P: After the fork: journalKV unwinds the refund,<br/>P = 0 and Q = 100 in both buffer and receipt P->>O: CALL with 100 BTY Note over P,O: Before the fork: succeeds, 100 BTY minted<br/>After the fork: P has 0, the call failsFork
Below the fork the code is the same as before #1348: writes go straight to the StateDB,
transferChangecarries the receipt KV,Snapshot.revertruns forward, andCreatetransfers before snapshotting. Historical blocks replay unchanged.The fork keeps the registration shipped in 1.76.4:
MaxHeightinInitFork,-1inchain33.fork.toml,chain33.para.tomlandchain33.proxyminer.toml. Until a chain configures an activation height, the vulnerability stays live there. The height is set at release time.Removed from #1348:
transferChange.preState,capturePreState,revertAccountKey,isForkActive,RevertError/ResetRevertErrorwith the guard ininnerExec,snapshot_revert_failure_test.go, and the tests of those internals. A buffered revert cannot fail, so the error plumbing has nothing left to carry.Tests
The #1348 tests that pin both sides of the fork are kept and pass against the buffer:
TestRevertedCallValueTxSucceedsUnderCheckKV/TestRevertedCallValueMintTxCreatesNoValue: the productionexecutor.StateDBunderForkExecRollback, checked with chain33'scheckKVrule.TestTransferToTokenRevertCheckKVPair: the token precompile on the same harness.TestRevertedCallValueCannotBeSpent: a transaction-level repro throughExec. Before the fork it mints; after the fork it does not.TestFailedNestedCreateReturnsValue,TestSuccessfulCreateWithValueCommitsBalances,TestTransferRevertRestoresBalances,TestSnapshotRevertSameSlotReverseOrder: before/after pairs.New:
TestRevertedValueTransferMints: the report's two-contract refund trap. Coin supply is conserved.TestRevertedDoubleSStoreLeaks: the parent frame cannot read a value written by a reverted frame.TestJournalKVRevertedTransfer: a reverted transfer never reaches the StateDB, and every key written to the StateDB appears in the receipt.TestJournalKVNestedRevert.go test ./plugin/dapp/evm/... ./plugin/dapp/evmxgo/...passes.🤖 Generated with Claude Code