From 5ab5e7af1b382ff0cd07ac9a40fb3aa9a57fd79d Mon Sep 17 00:00:00 2001 From: Gary Rong Date: Wed, 29 Apr 2026 15:32:49 +0800 Subject: [PATCH] core: calculate net state bytes --- core/state/journal.go | 161 +++++++++++++++-- core/state/journal_test.go | 332 +++++++++++++++++++++++++++++++++++ core/state/state_object.go | 4 +- core/state/statedb.go | 24 ++- core/state/statedb_hooked.go | 8 +- core/vm/interface.go | 12 +- 6 files changed, 516 insertions(+), 25 deletions(-) diff --git a/core/state/journal.go b/core/state/journal.go index acb7a2b7ab..2b77461714 100644 --- a/core/state/journal.go +++ b/core/state/journal.go @@ -27,6 +27,15 @@ import ( "github.com/holiman/uint256" ) +// stateBytesPerSlot is the number of "state-creation bytes" billed for a slot +// transitioning from zero to non-zero within a call frame, and refunded when +// such a slot is cleared back to zero whose tx-original value was also zero. +const stateBytesPerSlot = 64 + +// stateBytesPerAccount is the per-account overhead billed when a brand-new +// account is created in a call frame. +const stateBytesPerAccount = 120 + // frameRange is a half-open interval [start, end) of journal entry indices, // used to record the slice of entries occupied by a closed child call frame. type frameRange struct { @@ -45,6 +54,13 @@ type revision struct { // Invariant: ranges are appended in increasing order, are non-overlapping, // and lie entirely within [journalIndex, len(entries)). closedChildren []frameRange + // childStateBytes is the sum of state-creation bytes that this frame's + // successful child frames (and their successful descendants, transitively) + // produced via closeSnapshot. It is propagated upwards each time a child + // closes, so that if THIS frame is later reverted, the caller can recover + // the total amount that was emitted for state changes which the revert is + // now throwing away. + childStateBytes int } // journalEntry is a modification entry in the state change journal that can be @@ -98,7 +114,13 @@ func (j *journal) snapshot() int { } // revertToSnapshot reverts all state changes made since the given revision. -func (j *journal) revertToSnapshot(revid int, s *StateDB) { +// +// It returns the sum of state-creation bytes that successful child frames +// nested within the reverted scope(s) had previously emitted via +// closeSnapshot. The caller can use this figure to undo whatever bookkeeping +// (e.g. gas charging) it did at the time those bytes were reported, since the +// state changes those bytes were paying for are now being thrown away. +func (j *journal) revertToSnapshot(revid int, s *StateDB) int { // Find the snapshot in the stack of valid snapshots. idx := sort.Search(len(j.validRevisions), func(i int) bool { return j.validRevisions[i].id >= revid @@ -108,9 +130,20 @@ func (j *journal) revertToSnapshot(revid int, s *StateDB) { } snapshot := j.validRevisions[idx].journalIndex + // Sum the child-state-bytes carried by every revision being unwound. When + // revertToSnapshot tears down multiple stacked frames at once, each of + // them may itself have closed children whose bytes were inherited but + // never bubbled further up; collecting all of them here lets the caller + // undo the full subtree's emissions in one go. + var refund int + for i := idx; i < len(j.validRevisions); i++ { + refund += j.validRevisions[i].childStateBytes + } + // Replay the journal to undo changes and remove invalidated snapshots j.revert(s, snapshot) j.validRevisions = j.validRevisions[:idx] + return refund } // closeSnapshot marks the end of the call frame identified by revid without @@ -123,7 +156,21 @@ func (j *journal) revertToSnapshot(revid int, s *StateDB) { // closeSnapshot must be invoked in LIFO order: revid must identify the topmost // snapshot. It panics otherwise. The corresponding revision is popped, so a // subsequent revertToSnapshot on the same id is no longer valid. -func (j *journal) closeSnapshot(revid int) { +// +// It returns the net state-creation bytes attributable to THIS frame's own +// storage changes (descendant frames' contributions are excluded — they were +// already reported when the descendants closed). For each storage slot that +// this frame touched directly: +// - if the slot is non-zero now and was zero when the frame first touched +// it, +stateBytesPerSlot is accumulated; +// - if the slot is zero now, was non-zero when the frame first touched it, +// and was zero at the start of the transaction, -stateBytesPerSlot is +// accumulated. +// +// The returned value is also folded into the parent's childStateBytes (along +// with this frame's own childStateBytes) so a future revertToSnapshot on the +// parent can recover the entire subtree's accumulated bytes. +func (j *journal) closeSnapshot(revid int) int { if len(j.validRevisions) == 0 { panic(fmt.Errorf("revision id %v cannot be closed: no open snapshot", revid)) } @@ -132,20 +179,101 @@ func (j *journal) closeSnapshot(revid int) { panic(fmt.Errorf("revision id %v cannot be closed: top is %v", revid, j.validRevisions[top].id)) } + rev := &j.validRevisions[top] + + // Compute net state-creation bytes for THIS frame's own slot changes, + // skipping any entries that lie inside a closed child's range. + thisBytes := j.computeFrameStateBytes(rev) + + // Record this frame's range and propagate accumulated bytes to the + // parent. The propagated total is "this frame's own bytes" + "this + // frame's already-accumulated child bytes": from the parent's vantage + // point the whole subtree is now a single closed child. closed := frameRange{ - start: j.validRevisions[top].journalIndex, + start: rev.journalIndex, end: len(j.entries), } - // Only propagate non-empty ranges, and only if there is a parent frame to - // receive them. The outermost frame has nothing to bubble up to. - if closed.start < closed.end && top > 0 { + if top > 0 { parent := &j.validRevisions[top-1] - parent.closedChildren = append(parent.closedChildren, closed) + if closed.start < closed.end { + parent.closedChildren = append(parent.closedChildren, closed) + } + parent.childStateBytes += thisBytes + rev.childStateBytes } // Drop this revision's bookkeeping. The slice is reused by the parent so // avoid pinning it via the popped tail. - j.validRevisions[top].closedChildren = nil + rev.closedChildren = nil + rev.childStateBytes = 0 j.validRevisions = j.validRevisions[:top] + return thisBytes +} + +// computeFrameStateBytes walks the entries that belong directly to rev (skipping +// any closed-child ranges) and sums the per-step state-creation contribution of +// each individual SSTORE. +// +// State-creation accounting is the per-step sum of three independent +// contributions, each applied locally to its own journal entry: +// +// 1. storageChange (slot SSTORE): +// - origin != 0 → 0 (rearranging pre-existing storage) +// - prev == 0 && new != 0 → +stateBytesPerSlot (new slot created) +// - prev != 0 && new == 0 → -stateBytesPerSlot (in-tx creation undone) +// +// 2. codeChange (SetCode on an account): a brand-new contract publishes its +// bytecode for the first time. Origin code is implicitly empty in this +// accounting — we treat the prev-empty/new-non-empty transition as the +// creation event and bill its byte size, with the inverse transition +// refunding it for symmetry. +// - len(prev) == 0 && len(new) > 0 → +len(new) (code committed) +// - len(prev) > 0 && len(new) == 0 → -len(prev) (in-tx code committed then cleared) +// +// 3. createObjectChange (account materialised in state): each event adds +// +stateBytesPerAccount of per-account overhead. +// +// The per-step formulation composes naturally: a frame's bytes is the sum of +// deltas of its own entries, and the sum of every frame's bytes across the +// subtree equals the sum of deltas across all entries — i.e. the same number +// you would get from a single whole-frame walk. Slots/code/accounts whose +// intermediate values bounce across frame boundaries reconcile automatically +// without any need to dedup by "first touch". +func (j *journal) computeFrameStateBytes(rev *revision) int { + var total int + zero := common.Hash{} + visit := func(e journalEntry) { + switch ch := e.(type) { + case storageChange: + switch { + case ch.origvalue != zero: + // Slot was already populated at tx-start; any in-tx + // transition is rearranging existing storage. + case ch.prevvalue == zero && ch.newvalue != zero: + total += stateBytesPerSlot + case ch.prevvalue != zero && ch.newvalue == zero: + total -= stateBytesPerSlot + } + case codeChange: + switch { + case len(ch.prevCode) == 0 && len(ch.newCode) > 0: + total += len(ch.newCode) + case len(ch.prevCode) > 0 && len(ch.newCode) == 0: + total -= len(ch.prevCode) + } + case createObjectChange: + total += stateBytesPerAccount + } + } + idx := rev.journalIndex + for _, child := range rev.closedChildren { + for ; idx < child.start; idx++ { + visit(j.entries[idx]) + } + idx = child.end + } + for ; idx < len(j.entries); idx++ { + visit(j.entries[idx]) + } + return total } // frameEntries invokes visit for each entry that belongs directly to the @@ -215,9 +343,10 @@ func (j *journal) copy() *journal { revisions := make([]revision, len(j.validRevisions)) for i, r := range j.validRevisions { revisions[i] = revision{ - id: r.id, - journalIndex: r.journalIndex, - closedChildren: slices.Clone(r.closedChildren), + id: r.id, + journalIndex: r.journalIndex, + closedChildren: slices.Clone(r.closedChildren), + childStateBytes: r.childStateBytes, } } return &journal{ @@ -244,11 +373,12 @@ func (j *journal) destruct(addr common.Address) { j.append(selfDestructChange{account: addr}) } -func (j *journal) storageChange(addr common.Address, key, prev, origin common.Hash) { +func (j *journal) storageChange(addr common.Address, key, prev, newval, origin common.Hash) { j.append(storageChange{ account: addr, key: key, prevvalue: prev, + newvalue: newval, origvalue: origin, }) } @@ -272,10 +402,11 @@ func (j *journal) balanceChange(addr common.Address, previous *uint256.Int) { }) } -func (j *journal) setCode(address common.Address, prevCode []byte) { +func (j *journal) setCode(address common.Address, prevCode, newCode []byte) { j.append(codeChange{ account: address, prevCode: prevCode, + newCode: newCode, }) } @@ -336,11 +467,13 @@ type ( account common.Address key common.Hash prevvalue common.Hash + newvalue common.Hash origvalue common.Hash } codeChange struct { account common.Address prevCode []byte + newCode []byte } // Changes to other state values. @@ -472,6 +605,7 @@ func (ch codeChange) copy() journalEntry { return codeChange{ account: ch.account, prevCode: ch.prevCode, + newCode: ch.newCode, } } @@ -488,6 +622,7 @@ func (ch storageChange) copy() journalEntry { account: ch.account, key: ch.key, prevvalue: ch.prevvalue, + newvalue: ch.newvalue, origvalue: ch.origvalue, } } diff --git a/core/state/journal_test.go b/core/state/journal_test.go index 0e0e2b55b1..19303c1063 100644 --- a/core/state/journal_test.go +++ b/core/state/journal_test.go @@ -184,6 +184,338 @@ func TestJournalRevertInteractions(t *testing.T) { }) } +// TestJournalStateCreationBytes exercises the slot-creation accounting in +// closeSnapshot and the matching refund returned by revertToSnapshot. +// +// It uses a real StateDB (so SetState/GetState are wired up) and walks +// through the cases the docstring promises: +// - a slot transitioning 0→X within a frame contributes +stateBytesPerSlot; +// - a slot transitioning X→0 within a frame whose tx-original was 0 +// contributes -stateBytesPerSlot; +// - bytes attributed to a successful child frame are NOT re-counted by the +// parent's own closeSnapshot (descendant filtering); +// - when a parent is reverted, RevertToSnapshot returns the cumulative +// bytes that successful children inside the reverted scope had emitted, +// so the caller can undo whatever bookkeeping it kept. +func TestJournalStateCreationBytes(t *testing.T) { + addr := common.HexToAddress("0xaa") + keyA := common.HexToHash("0x1") + keyB := common.HexToHash("0x2") + nonZero := common.HexToHash("0x42") + otherNonZero := common.HexToHash("0x99") + + // seedExistingAccount returns a fresh StateDB whose `addr` already exists + // (so subsequent SetState calls won't journal a createObjectChange). Used + // by storage-focused subtests so the account-creation contribution does + // not bleed into the slot-accounting assertions. + seedExistingAccount := func() *StateDB { + st := newStateEnv().state + st.getOrNewStateObject(addr) + // The createObjectChange just journaled is at index 0; the upcoming + // st.Snapshot() starts at index 1, so the createObject entry sits + // outside any test scope and contributes nothing. + return st + } + + t.Run("slotCreationInDirectFrame", func(t *testing.T) { + st := seedExistingAccount() + p := st.Snapshot() + st.SetState(addr, keyA, nonZero) + if got := st.CloseSnapshot(p); got != stateBytesPerSlot { + t.Fatalf("0→X creation: have %d, want %d", got, stateBytesPerSlot) + } + }) + + t.Run("slotClearingRefundsCreation", func(t *testing.T) { + st := seedExistingAccount() + // Set the slot once so it has a non-zero value to clear, but make + // the tx-original 0 by doing the set inside the tested scope. + p := st.Snapshot() + st.SetState(addr, keyA, nonZero) // 0 → X (creation) + st.SetState(addr, keyA, common.Hash{}) // X → 0 (clear) + // Net: nothing changed; the in-frame creation was undone, so + // closeSnapshot must report -stateBytesPerSlot to refund the + // would-be creation, but since +stateBytesPerSlot is also + // counted... wait: the journal stores only the FIRST prevvalue + // per slot, which is 0 for this slot in this frame. Current + // state is 0. Per the rules: prev==0 && current==0 — neither + // rule fires, so 0 bytes net. That correctly reflects no growth. + if got := st.CloseSnapshot(p); got != 0 { + t.Fatalf("0→X→0 net: have %d, want 0", got) + } + }) + + t.Run("childContributionNotDoubleCounted", func(t *testing.T) { + st := seedExistingAccount() + p := st.Snapshot() + // Child creates slot A. + c := st.Snapshot() + st.SetState(addr, keyA, nonZero) + childBytes := st.CloseSnapshot(c) + if childBytes != stateBytesPerSlot { + t.Fatalf("child closeSnapshot: have %d, want %d", childBytes, stateBytesPerSlot) + } + // Parent itself does not touch any slot. Its own closeSnapshot + // must NOT re-count slot A — that contribution was already + // reported by the child and lives in childStateBytes for the + // purpose of revert refunds. + parentBytes := st.CloseSnapshot(p) + if parentBytes != 0 { + t.Fatalf("parent closeSnapshot (no direct slots): have %d, want 0", parentBytes) + } + }) + + t.Run("parentSlotChangeIndependentOfChild", func(t *testing.T) { + st := seedExistingAccount() + p := st.Snapshot() + // Parent creates slot A directly. + st.SetState(addr, keyA, nonZero) + // Child creates a different slot B. + c := st.Snapshot() + st.SetState(addr, keyB, otherNonZero) + if got := st.CloseSnapshot(c); got != stateBytesPerSlot { + t.Fatalf("child slot B creation: have %d, want %d", got, stateBytesPerSlot) + } + // Parent's own closeSnapshot must report only slot A (the child's + // slot B was filtered via the closed-child range). + if got := st.CloseSnapshot(p); got != stateBytesPerSlot { + t.Fatalf("parent slot A creation: have %d, want %d", got, stateBytesPerSlot) + } + }) + + t.Run("revertReturnsAccumulatedChildBytes", func(t *testing.T) { + st := seedExistingAccount() + p := st.Snapshot() + // Two successful children, each creating one slot. + c1 := st.Snapshot() + st.SetState(addr, keyA, nonZero) + st.CloseSnapshot(c1) + c2 := st.Snapshot() + st.SetState(addr, keyB, otherNonZero) + st.CloseSnapshot(c2) + // Now revert the parent. The two children together emitted + // 2 * stateBytesPerSlot, all of which should come back so the + // caller can undo whatever was billed at close time. + refund := st.RevertToSnapshot(p) + want := 2 * stateBytesPerSlot + if refund != want { + t.Fatalf("revert refund: have %d, want %d", refund, want) + } + }) + + t.Run("perStepComposesWhenParentAndChildShareSlot", func(t *testing.T) { + // The interleaved-slot case that used to diverge under the + // "first-touch + current state" rule. Per-step accounting makes + // each SSTORE carry its own delta, so the per-frame numbers may + // look bigger but their sum is exactly what a whole-frame walk + // over the entire subtree would produce. + // + // parent SSTORE S = X → entry: prev=0, new=X → +stateBytesPerSlot + // child SSTORE S = 0 → entry: prev=X, new=0, ori=0 → -stateBytesPerSlot + // parent SSTORE S = Y → entry: prev=0, new=Y → +stateBytesPerSlot + // + // child bytes = -stateBytesPerSlot + // parent bytes = +2 * stateBytesPerSlot (two parent SSTOREs) + // sum = +stateBytesPerSlot (= net 0→Y) + st := seedExistingAccount() + p := st.Snapshot() + st.SetState(addr, keyA, nonZero) // parent direct: 0 → X + c := st.Snapshot() + st.SetState(addr, keyA, common.Hash{}) // child: X → 0 + childBytes := st.CloseSnapshot(c) + st.SetState(addr, keyA, otherNonZero) // parent direct: 0 → Y + parentBytes := st.CloseSnapshot(p) + + if childBytes != -stateBytesPerSlot { + t.Fatalf("child bytes (X→0 with origin 0): have %d, want %d", + childBytes, -stateBytesPerSlot) + } + if parentBytes != 2*stateBytesPerSlot { + t.Fatalf("parent bytes (two 0→nonZero SSTOREs): have %d, want %d", + parentBytes, 2*stateBytesPerSlot) + } + if sum := childBytes + parentBytes; sum != stateBytesPerSlot { + t.Fatalf("per-frame sum: have %d, want %d (= whole-frame net 0→Y)", + sum, stateBytesPerSlot) + } + }) + + t.Run("perStepComposesAcrossSiblings", func(t *testing.T) { + // Two siblings sharing a slot: A creates, B clears. Per-step, + // each sibling's delta is independent and the parent's sum + // matches the whole-frame net (0). + st := seedExistingAccount() + p := st.Snapshot() + + a := st.Snapshot() + st.SetState(addr, keyA, nonZero) // 0 → X + aBytes := st.CloseSnapshot(a) + + b := st.Snapshot() + st.SetState(addr, keyA, common.Hash{}) // X → 0 + bBytes := st.CloseSnapshot(b) + + pBytes := st.CloseSnapshot(p) + + if aBytes != stateBytesPerSlot { + t.Fatalf("sibling A bytes: have %d, want %d", aBytes, stateBytesPerSlot) + } + if bBytes != -stateBytesPerSlot { + t.Fatalf("sibling B bytes: have %d, want %d", bBytes, -stateBytesPerSlot) + } + if pBytes != 0 { + t.Fatalf("parent bytes (no own slots): have %d, want 0", pBytes) + } + if sum := aBytes + bBytes + pBytes; sum != 0 { + t.Fatalf("per-frame sum: have %d, want 0", sum) + } + }) + + t.Run("perStepComposesAcrossDeepNesting", func(t *testing.T) { + // Three-deep version of the divergence: grandparent SSTOREs S + // before and after a child clears it. Each SSTORE contributes + // independently and the sum equals the whole-frame net. + st := seedExistingAccount() + gp := st.Snapshot() + st.SetState(addr, keyA, nonZero) // grandparent: 0 → X + p := st.Snapshot() + c := st.Snapshot() + st.SetState(addr, keyA, common.Hash{}) // child: X → 0 + cBytes := st.CloseSnapshot(c) + pBytes := st.CloseSnapshot(p) + st.SetState(addr, keyA, otherNonZero) // grandparent: 0 → Y + gpBytes := st.CloseSnapshot(gp) + + if cBytes != -stateBytesPerSlot { + t.Fatalf("child bytes: have %d, want %d", cBytes, -stateBytesPerSlot) + } + if pBytes != 0 { + t.Fatalf("parent (no own SSTORE) bytes: have %d, want 0", pBytes) + } + if gpBytes != 2*stateBytesPerSlot { + t.Fatalf("grandparent bytes: have %d, want %d", gpBytes, 2*stateBytesPerSlot) + } + if sum := cBytes + pBytes + gpBytes; sum != stateBytesPerSlot { + t.Fatalf("per-frame sum: have %d, want %d", sum, stateBytesPerSlot) + } + }) + + t.Run("nonZeroOriginBouncesContributeNothing", func(t *testing.T) { + // A slot whose tx-original is non-zero is not subject to creation + // accounting at all: any in-tx transitions (X → 0 in parent, then + // 0 → X in child) are merely rearranging pre-existing storage. + // Both per-step deltas must be 0, and so must the per-frame sum. + st := newStateEnv().state + + // Seed the slot with a non-zero tx-original by writing directly + // into the origin cache, simulating storage that was committed + // before this transaction began. + obj := st.getOrNewStateObject(addr) + obj.originStorage[keyA] = nonZero + + p := st.Snapshot() + st.SetState(addr, keyA, common.Hash{}) // parent: X → 0 (origin = X) + c := st.Snapshot() + st.SetState(addr, keyA, nonZero) // child: 0 → X (origin = X) + cBytes := st.CloseSnapshot(c) + pBytes := st.CloseSnapshot(p) + + if cBytes != 0 { + t.Fatalf("child bytes (origin non-zero): have %d, want 0", cBytes) + } + if pBytes != 0 { + t.Fatalf("parent bytes (origin non-zero): have %d, want 0", pBytes) + } + if sum := cBytes + pBytes; sum != 0 { + t.Fatalf("sum (net X→X): have %d, want 0", sum) + } + }) + + t.Run("nestedDescendantsBubbleUp", func(t *testing.T) { + st := seedExistingAccount() + p := st.Snapshot() + c := st.Snapshot() + gc := st.Snapshot() + // Grandchild creates a slot. + st.SetState(addr, keyA, nonZero) + if got := st.CloseSnapshot(gc); got != stateBytesPerSlot { + t.Fatalf("grandchild close: have %d, want %d", got, stateBytesPerSlot) + } + // Child closes with no own direct slot work. + if got := st.CloseSnapshot(c); got != 0 { + t.Fatalf("child close (no own slots): have %d, want 0", got) + } + // If the parent is reverted now, the grandchild's bytes should + // surface even though they were inherited via the intermediate + // child. + if refund := st.RevertToSnapshot(p); refund != stateBytesPerSlot { + t.Fatalf("nested revert refund: have %d, want %d", refund, stateBytesPerSlot) + } + }) + + t.Run("accountCreationContributesPerAccountOverhead", func(t *testing.T) { + // CreateAccount on a fresh address journals a createObjectChange, + // which contributes +stateBytesPerAccount. + st := newStateEnv().state + p := st.Snapshot() + st.CreateAccount(addr) + if got := st.CloseSnapshot(p); got != stateBytesPerAccount { + t.Fatalf("account creation: have %d, want %d", got, stateBytesPerAccount) + } + }) + + t.Run("codeCreationContributesCodeLength", func(t *testing.T) { + // SetCode on an account whose previous code is empty contributes + // +len(newCode); the inverse transition (non-empty → empty) refunds. + st := seedExistingAccount() + code := []byte{0x60, 0x00, 0x60, 0x00, 0xfd} // arbitrary 5 bytes + + p := st.Snapshot() + st.SetCode(addr, code, 0) + if got := st.CloseSnapshot(p); got != len(code) { + t.Fatalf("code creation (empty → %d bytes): have %d, want %d", + len(code), got, len(code)) + } + + // Now clear it again in a fresh frame: -len(code). + p2 := st.Snapshot() + st.SetCode(addr, nil, 0) + if got := st.CloseSnapshot(p2); got != -len(code) { + t.Fatalf("code clear (%d → empty bytes): have %d, want %d", + len(code), got, -len(code)) + } + }) + + t.Run("createAndDeployComposesAcrossFrames", func(t *testing.T) { + // A typical CREATE: outer frame allocates an account in a child + // frame, the child writes code and a slot. Per-step bytes: + // child: +stateBytesPerAccount + len(code) + stateBytesPerSlot + // outer: 0 (no own direct entries) + // sum = child total + st := newStateEnv().state + code := []byte{0x60, 0x42, 0x60, 0x00, 0x55} // arbitrary 5 bytes + p := st.Snapshot() + c := st.Snapshot() + st.CreateAccount(addr) + st.SetCode(addr, code, 0) + st.SetState(addr, keyA, nonZero) + childBytes := st.CloseSnapshot(c) + parentBytes := st.CloseSnapshot(p) + + want := stateBytesPerAccount + len(code) + stateBytesPerSlot + if childBytes != want { + t.Fatalf("child bytes (account+code+slot): have %d, want %d", + childBytes, want) + } + if parentBytes != 0 { + t.Fatalf("parent bytes (no own work): have %d, want 0", parentBytes) + } + if sum := childBytes + parentBytes; sum != want { + t.Fatalf("sum: have %d, want %d", sum, want) + } + }) +} + // TestJournalCopyAndReset checks that the bookkeeping for closed-child ranges // participates in journal.copy (deep-copied, not aliased) and journal.reset // (cleared along with everything else). diff --git a/core/state/state_object.go b/core/state/state_object.go index 8e72486825..6f6d2f43ff 100644 --- a/core/state/state_object.go +++ b/core/state/state_object.go @@ -233,7 +233,7 @@ func (s *stateObject) SetState(key, value common.Hash) common.Hash { return prev } // New value is different, update and journal the change - s.db.journal.storageChange(s.address, key, prev, origin) + s.db.journal.storageChange(s.address, key, prev, value, origin) s.setState(key, value, origin) return prev } @@ -588,7 +588,7 @@ func (s *stateObject) CodeSize() int { func (s *stateObject) SetCode(codeHash common.Hash, code []byte) (prev []byte) { prev = slices.Clone(s.code) - s.db.journal.setCode(s.address, prev) + s.db.journal.setCode(s.address, prev, code) s.setCode(codeHash, code) return prev } diff --git a/core/state/statedb.go b/core/state/statedb.go index 59a53d81e8..d48b4ddb7d 100644 --- a/core/state/statedb.go +++ b/core/state/statedb.go @@ -749,8 +749,14 @@ func (s *StateDB) Snapshot() int { } // RevertToSnapshot reverts all state changes made since the given revision. -func (s *StateDB) RevertToSnapshot(revid int) { - s.journal.revertToSnapshot(revid, s) +// +// It returns the sum of state-creation bytes that successful child frames +// nested within the reverted scope(s) had previously emitted via +// CloseSnapshot. The caller can use this figure to undo bookkeeping done at +// the time those bytes were reported, since the state changes those bytes +// were paying for are now being thrown away. +func (s *StateDB) RevertToSnapshot(revid int) int { + return s.journal.revertToSnapshot(revid, s) } // CloseSnapshot marks the call frame identified by revid as completed without @@ -758,8 +764,18 @@ func (s *StateDB) RevertToSnapshot(revid int) { // frame so the parent can later iterate its own entries while skipping over // closed children. revid must identify the topmost open snapshot (i.e. frames // must be closed in LIFO order). It panics otherwise. -func (s *StateDB) CloseSnapshot(revid int) { - s.journal.closeSnapshot(revid) +// +// It returns the net state-creation bytes attributable to this frame's own +// storage changes (descendant frames' contributions are excluded — they were +// already reported when the descendants closed). The contribution is summed +// per individual SSTORE: each storage entry independently scores +1 slot for a +// 0→non-zero transition and -1 for a non-zero→0 transition whose tx-original +// was 0. Per-step accounting composes naturally — the sum across all frames +// in a subtree equals the sum across all individual SSTORE deltas, so multiple +// SSTOREs to the same slot at different nesting levels reconcile without any +// "first touch" deduplication. +func (s *StateDB) CloseSnapshot(revid int) int { + return s.journal.closeSnapshot(revid) } // GetRefund returns the current value of the refund counter. diff --git a/core/state/statedb_hooked.go b/core/state/statedb_hooked.go index 9fc6b4f36b..8764e14846 100644 --- a/core/state/statedb_hooked.go +++ b/core/state/statedb_hooked.go @@ -143,12 +143,12 @@ func (s *hookedStateDB) Prepare(rules params.Rules, sender, coinbase common.Addr s.inner.Prepare(rules, sender, coinbase, dest, precompiles, txAccesses) } -func (s *hookedStateDB) RevertToSnapshot(i int) { - s.inner.RevertToSnapshot(i) +func (s *hookedStateDB) RevertToSnapshot(i int) int { + return s.inner.RevertToSnapshot(i) } -func (s *hookedStateDB) CloseSnapshot(i int) { - s.inner.CloseSnapshot(i) +func (s *hookedStateDB) CloseSnapshot(i int) int { + return s.inner.CloseSnapshot(i) } func (s *hookedStateDB) Snapshot() int { diff --git a/core/vm/interface.go b/core/vm/interface.go index ebbd70e55b..6c318aa0bb 100644 --- a/core/vm/interface.go +++ b/core/vm/interface.go @@ -98,11 +98,19 @@ type StateDB interface { // reverting any state. The call frame's entry range is recorded on the // parent frame so the parent can later iterate its own entries while // skipping over closed children. Snapshots must be closed in LIFO order. - CloseSnapshot(int) + // + // It returns the net state-creation bytes for this frame's own slot + // changes (descendant frames' contributions are excluded — they were + // reported when the descendants closed). + CloseSnapshot(int) int // RevertToSnapshot rolls back all state changes within the current frame // and discards the corresponding journal entries. - RevertToSnapshot(int) + // + // It returns the sum of state-creation bytes that successful child frames + // inside the reverted scope(s) had previously reported via CloseSnapshot, + // so the caller can undo any bookkeeping it performed at that time. + RevertToSnapshot(int) int AddLog(*types.Log) LogsForBurnAccounts() []*types.Log