core: calculate net state bytes

This commit is contained in:
Gary Rong 2026-04-29 15:32:49 +08:00
parent f8eb88e94a
commit 5ab5e7af1b
6 changed files with 516 additions and 25 deletions

View file

@ -27,6 +27,15 @@ import (
"github.com/holiman/uint256" "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, // 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. // used to record the slice of entries occupied by a closed child call frame.
type frameRange struct { type frameRange struct {
@ -45,6 +54,13 @@ type revision struct {
// Invariant: ranges are appended in increasing order, are non-overlapping, // Invariant: ranges are appended in increasing order, are non-overlapping,
// and lie entirely within [journalIndex, len(entries)). // and lie entirely within [journalIndex, len(entries)).
closedChildren []frameRange 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 // 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. // 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. // Find the snapshot in the stack of valid snapshots.
idx := sort.Search(len(j.validRevisions), func(i int) bool { idx := sort.Search(len(j.validRevisions), func(i int) bool {
return j.validRevisions[i].id >= revid return j.validRevisions[i].id >= revid
@ -108,9 +130,20 @@ func (j *journal) revertToSnapshot(revid int, s *StateDB) {
} }
snapshot := j.validRevisions[idx].journalIndex 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 // Replay the journal to undo changes and remove invalidated snapshots
j.revert(s, snapshot) j.revert(s, snapshot)
j.validRevisions = j.validRevisions[:idx] j.validRevisions = j.validRevisions[:idx]
return refund
} }
// closeSnapshot marks the end of the call frame identified by revid without // 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 // closeSnapshot must be invoked in LIFO order: revid must identify the topmost
// snapshot. It panics otherwise. The corresponding revision is popped, so a // snapshot. It panics otherwise. The corresponding revision is popped, so a
// subsequent revertToSnapshot on the same id is no longer valid. // 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 { if len(j.validRevisions) == 0 {
panic(fmt.Errorf("revision id %v cannot be closed: no open snapshot", revid)) 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", panic(fmt.Errorf("revision id %v cannot be closed: top is %v",
revid, j.validRevisions[top].id)) 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{ closed := frameRange{
start: j.validRevisions[top].journalIndex, start: rev.journalIndex,
end: len(j.entries), end: len(j.entries),
} }
// Only propagate non-empty ranges, and only if there is a parent frame to if top > 0 {
// receive them. The outermost frame has nothing to bubble up to.
if closed.start < closed.end && top > 0 {
parent := &j.validRevisions[top-1] 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 // Drop this revision's bookkeeping. The slice is reused by the parent so
// avoid pinning it via the popped tail. // avoid pinning it via the popped tail.
j.validRevisions[top].closedChildren = nil rev.closedChildren = nil
rev.childStateBytes = 0
j.validRevisions = j.validRevisions[:top] 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 // 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)) revisions := make([]revision, len(j.validRevisions))
for i, r := range j.validRevisions { for i, r := range j.validRevisions {
revisions[i] = revision{ revisions[i] = revision{
id: r.id, id: r.id,
journalIndex: r.journalIndex, journalIndex: r.journalIndex,
closedChildren: slices.Clone(r.closedChildren), closedChildren: slices.Clone(r.closedChildren),
childStateBytes: r.childStateBytes,
} }
} }
return &journal{ return &journal{
@ -244,11 +373,12 @@ func (j *journal) destruct(addr common.Address) {
j.append(selfDestructChange{account: addr}) 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{ j.append(storageChange{
account: addr, account: addr,
key: key, key: key,
prevvalue: prev, prevvalue: prev,
newvalue: newval,
origvalue: origin, 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{ j.append(codeChange{
account: address, account: address,
prevCode: prevCode, prevCode: prevCode,
newCode: newCode,
}) })
} }
@ -336,11 +467,13 @@ type (
account common.Address account common.Address
key common.Hash key common.Hash
prevvalue common.Hash prevvalue common.Hash
newvalue common.Hash
origvalue common.Hash origvalue common.Hash
} }
codeChange struct { codeChange struct {
account common.Address account common.Address
prevCode []byte prevCode []byte
newCode []byte
} }
// Changes to other state values. // Changes to other state values.
@ -472,6 +605,7 @@ func (ch codeChange) copy() journalEntry {
return codeChange{ return codeChange{
account: ch.account, account: ch.account,
prevCode: ch.prevCode, prevCode: ch.prevCode,
newCode: ch.newCode,
} }
} }
@ -488,6 +622,7 @@ func (ch storageChange) copy() journalEntry {
account: ch.account, account: ch.account,
key: ch.key, key: ch.key,
prevvalue: ch.prevvalue, prevvalue: ch.prevvalue,
newvalue: ch.newvalue,
origvalue: ch.origvalue, origvalue: ch.origvalue,
} }
} }

View file

@ -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 // TestJournalCopyAndReset checks that the bookkeeping for closed-child ranges
// participates in journal.copy (deep-copied, not aliased) and journal.reset // participates in journal.copy (deep-copied, not aliased) and journal.reset
// (cleared along with everything else). // (cleared along with everything else).

View file

@ -233,7 +233,7 @@ func (s *stateObject) SetState(key, value common.Hash) common.Hash {
return prev return prev
} }
// New value is different, update and journal the change // 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) s.setState(key, value, origin)
return prev return prev
} }
@ -588,7 +588,7 @@ func (s *stateObject) CodeSize() int {
func (s *stateObject) SetCode(codeHash common.Hash, code []byte) (prev []byte) { func (s *stateObject) SetCode(codeHash common.Hash, code []byte) (prev []byte) {
prev = slices.Clone(s.code) prev = slices.Clone(s.code)
s.db.journal.setCode(s.address, prev) s.db.journal.setCode(s.address, prev, code)
s.setCode(codeHash, code) s.setCode(codeHash, code)
return prev return prev
} }

View file

@ -749,8 +749,14 @@ func (s *StateDB) Snapshot() int {
} }
// RevertToSnapshot reverts all state changes made since the given revision. // 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 // 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 // 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 // closed children. revid must identify the topmost open snapshot (i.e. frames
// must be closed in LIFO order). It panics otherwise. // 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. // GetRefund returns the current value of the refund counter.

View file

@ -143,12 +143,12 @@ func (s *hookedStateDB) Prepare(rules params.Rules, sender, coinbase common.Addr
s.inner.Prepare(rules, sender, coinbase, dest, precompiles, txAccesses) s.inner.Prepare(rules, sender, coinbase, dest, precompiles, txAccesses)
} }
func (s *hookedStateDB) RevertToSnapshot(i int) { func (s *hookedStateDB) RevertToSnapshot(i int) int {
s.inner.RevertToSnapshot(i) return s.inner.RevertToSnapshot(i)
} }
func (s *hookedStateDB) CloseSnapshot(i int) { func (s *hookedStateDB) CloseSnapshot(i int) int {
s.inner.CloseSnapshot(i) return s.inner.CloseSnapshot(i)
} }
func (s *hookedStateDB) Snapshot() int { func (s *hookedStateDB) Snapshot() int {

View file

@ -98,11 +98,19 @@ type StateDB interface {
// reverting any state. The call frame's entry range is recorded on the // 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 // parent frame so the parent can later iterate its own entries while
// skipping over closed children. Snapshots must be closed in LIFO order. // 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 // RevertToSnapshot rolls back all state changes within the current frame
// and discards the corresponding journal entries. // 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) AddLog(*types.Log)
LogsForBurnAccounts() []*types.Log LogsForBurnAccounts() []*types.Log