Skip to content
This repository was archived by the owner on Sep 8, 2025. It is now read-only.

Commit e3ccf92

Browse files
authored
Merge pull request #1913 from carver/fix-1912-consensus-bug
Fix consensus bug from deleting account in same block that creates it
2 parents f5c9341 + 14a0785 commit e3ccf92

File tree

7 files changed

+285
-16
lines changed

7 files changed

+285
-16
lines changed

eth/chains/base.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -471,7 +471,11 @@ def import_block(self,
471471

472472
# Validate the imported block.
473473
if perform_validation:
474-
validate_imported_block_unchanged(imported_block, block)
474+
try:
475+
validate_imported_block_unchanged(imported_block, block)
476+
except ValidationError:
477+
self.logger.warning("Proposed %s doesn't follow EVM rules, rejecting...", block)
478+
raise
475479
self.validate_block(imported_block)
476480

477481
(

eth/db/account.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -384,7 +384,8 @@ def make_state_root(self) -> Hash32:
384384
address.hex(),
385385
storage_root.hex(),
386386
)
387-
self._set_storage_root(address, storage_root)
387+
if self.account_exists(address) or storage_root != BLANK_ROOT_HASH:
388+
self._set_storage_root(address, storage_root)
388389

389390
self._journaldb.persist()
390391

@@ -409,7 +410,12 @@ def persist(self) -> None:
409410
store.persist(write_batch)
410411

411412
for address, new_root in self._get_changed_roots():
412-
if new_root not in self._raw_store_db and new_root != BLANK_ROOT_HASH:
413+
if new_root is None:
414+
raise ValidationError(
415+
f"Cannot validate new root of account 0x{address.hex()} "
416+
f"which has a new root hash of None"
417+
)
418+
elif new_root not in self._raw_store_db and new_root != BLANK_ROOT_HASH:
413419
raise ValidationError(
414420
"After persisting storage trie, a root node was not found. "
415421
f"State root for account 0x{address.hex()} "

eth/db/storage.py

Lines changed: 159 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,20 @@
1+
from typing import (
2+
List,
3+
NamedTuple,
4+
)
5+
16
from eth_hash.auto import keccak
27
from eth_typing import (
38
Address,
4-
Hash32
9+
Hash32,
510
)
611
from eth_utils import (
712
ValidationError,
13+
encode_hex,
814
get_extended_debug_logger,
915
int_to_big_endian,
16+
to_bytes,
17+
to_int,
1018
)
1119
import rlp
1220
from trie import (
@@ -22,9 +30,15 @@
2230
AtomicDatabaseAPI,
2331
DatabaseAPI,
2432
)
33+
from eth.constants import (
34+
BLANK_ROOT_HASH,
35+
)
2536
from eth.db.backends.base import (
2637
BaseDB,
2738
)
39+
from eth.db.backends.memory import (
40+
MemoryDB,
41+
)
2842
from eth.db.batch import (
2943
BatchDB,
3044
)
@@ -42,6 +56,17 @@
4256
)
4357

4458

59+
class PendingWrites(NamedTuple):
60+
"""
61+
A set of variables captured just before account storage deletion.
62+
The variables are used to revive storage if the EVM reverts to a point
63+
prior to deletion.
64+
"""
65+
write_trie: HexaryTrie # The write trie at the time of deletion
66+
trie_nodes_batch: BatchDB # A batch of all trie nodes written to the trie
67+
starting_root_hash: Hash32 # The starting root hash
68+
69+
4570
class StorageLookup(BaseDB):
4671
"""
4772
This lookup converts lookups of storage slot integers into the appropriate trie lookup.
@@ -51,12 +76,23 @@ class StorageLookup(BaseDB):
5176
"""
5277
logger = get_extended_debug_logger("eth.db.storage.StorageLookup")
5378

79+
# The trie that is modified in-place, used to calculate storage root on-demand
80+
_write_trie: HexaryTrie
81+
82+
# These are the new trie nodes, waiting to be committed to disk
83+
_trie_nodes_batch: BatchDB
84+
85+
# When deleting an account, push the pending write info onto this stack.
86+
# This stack can get as big as the number of transactions per block: one for each delete.
87+
_historical_write_tries: List[PendingWrites]
88+
5489
def __init__(self, db: DatabaseAPI, storage_root: Hash32, address: Address) -> None:
5590
self._db = db
56-
self._starting_root_hash = storage_root
91+
92+
# Set the starting root hash, to be used for on-disk storage read lookups
93+
self._initialize_to_root_hash(storage_root)
94+
5795
self._address = address
58-
self._write_trie = None
59-
self._trie_nodes_batch: BatchDB = None
6096

6197
def _get_write_trie(self) -> HexaryTrie:
6298
if self._trie_nodes_batch is None:
@@ -120,18 +156,21 @@ def __delitem__(self, key: bytes) -> None:
120156

121157
@property
122158
def has_changed_root(self) -> bool:
123-
return self._write_trie and self._write_trie.root_hash != self._starting_root_hash
159+
return self._write_trie is not None
124160

125161
def get_changed_root(self) -> Hash32:
126162
if self._write_trie is not None:
127163
return self._write_trie.root_hash
128164
else:
129165
raise ValidationError("Asked for changed root when no writes have been made")
130166

131-
def _clear_changed_root(self) -> None:
167+
def _initialize_to_root_hash(self, root_hash: Hash32) -> None:
168+
self._starting_root_hash = root_hash
132169
self._write_trie = None
133170
self._trie_nodes_batch = None
134-
self._starting_root_hash = None
171+
172+
# Reset the historical writes, which can't be reverted after committing
173+
self._historical_write_tries = []
135174

136175
def commit_to(self, db: DatabaseAPI) -> None:
137176
"""
@@ -142,10 +181,67 @@ def commit_to(self, db: DatabaseAPI) -> None:
142181
if self._trie_nodes_batch is None:
143182
raise ValidationError(
144183
"It is invalid to commit an account's storage if it has no pending changes. "
145-
"Always check storage_lookup.has_changed_root before attempting to commit."
184+
"Always check storage_lookup.has_changed_root before attempting to commit. "
185+
f"Write tries on stack = {len(self._historical_write_tries)}; Root hash = "
186+
f"{encode_hex(self._starting_root_hash)}"
146187
)
147188
self._trie_nodes_batch.commit_to(db, apply_deletes=False)
148-
self._clear_changed_root()
189+
190+
# Mark the trie as having been all written out to the database.
191+
# It removes the 'dirty' flag and clears out any pending writes.
192+
self._initialize_to_root_hash(self._write_trie.root_hash)
193+
194+
def new_trie(self) -> int:
195+
"""
196+
Switch to an empty trie. Save the old trie, and pending writes, in
197+
case of a revert.
198+
199+
:return: index for reviving the previous trie
200+
"""
201+
write_trie = self._get_write_trie()
202+
203+
# Write the previous trie into a historical stack
204+
self._historical_write_tries.append(PendingWrites(
205+
write_trie,
206+
self._trie_nodes_batch,
207+
self._starting_root_hash,
208+
))
209+
210+
new_idx = len(self._historical_write_tries)
211+
self._starting_root_hash = BLANK_ROOT_HASH
212+
self._write_trie = None
213+
self._trie_nodes_batch = None
214+
215+
return new_idx
216+
217+
def rollback_trie(self, trie_index: int) -> None:
218+
"""
219+
Revert back to the previous trie, using the index returned by a
220+
:meth:`~new_trie` call. The index returned by that call returns you
221+
to the trie in place *before* the call.
222+
223+
:param trie_index: index for reviving the previous trie
224+
"""
225+
226+
if trie_index >= len(self._historical_write_tries):
227+
raise ValidationError(
228+
f"Trying to roll back a delete to index {trie_index}, but there are only"
229+
f" {len(self._historical_write_tries)} indices available."
230+
)
231+
232+
(
233+
self._write_trie,
234+
self._trie_nodes_batch,
235+
self._starting_root_hash,
236+
) = self._historical_write_tries[trie_index]
237+
238+
# Cannot roll forward after a rollback, so remove created/ignored tries.
239+
# This also deletes the trie that you just reverted to. It will be re-added
240+
# to the stack when the next new_trie() is called.
241+
del self._historical_write_tries[trie_index:]
242+
243+
244+
CLEAR_COUNT_KEY_NAME = b'clear-count'
149245

150246

151247
class AccountStorageDB(AccountStorageDatabaseAPI):
@@ -187,9 +283,15 @@ def __init__(self, db: AtomicDatabaseAPI, storage_root: Hash32, address: Address
187283
self._address = address
188284
self._storage_lookup = StorageLookup(db, storage_root, address)
189285
self._storage_cache = CacheDB(self._storage_lookup)
190-
self._locked_changes = BatchDB(self._storage_cache)
286+
self._locked_changes = JournalDB(self._storage_cache)
191287
self._journal_storage = JournalDB(self._locked_changes)
192288

289+
# Track how many times we have cleared the storage. This is journaled
290+
# in lockstep with other storage changes. That way, we can detect if a revert
291+
# causes use to revert past the previous storage deletion. The clear count is used
292+
# as an index to find the base trie from before the revert.
293+
self._clear_count = JournalDB(MemoryDB({CLEAR_COUNT_KEY_NAME: to_bytes(0)}))
294+
193295
def get(self, slot: int, from_journal: bool=True) -> int:
194296
key = int_to_big_endian(slot)
195297
lookup_db = self._journal_storage if from_journal else self._locked_changes
@@ -222,40 +324,84 @@ def set(self, slot: int, value: int) -> None:
222324

223325
def delete(self) -> None:
224326
self.logger.debug2(
225-
"Deleting all storage in account 0x%s, hashed 0x%s",
327+
"Deleting all storage in account 0x%s",
226328
self._address.hex(),
227-
keccak(self._address).hex(),
228329
)
229330
self._journal_storage.clear()
230331
self._storage_cache.reset_cache()
231332

333+
# Empty out the storage lookup trie (keeping history, in case of a revert)
334+
new_clear_count = self._storage_lookup.new_trie()
335+
336+
# Look up the previous count of how many times the account has been deleted.
337+
# This can happen multiple times in one block, via CREATE2.
338+
old_clear_count = to_int(self._clear_count[CLEAR_COUNT_KEY_NAME])
339+
340+
# Gut check that we have incremented correctly
341+
if new_clear_count != old_clear_count + 1:
342+
raise ValidationError(
343+
f"Must increase clear count by one on each delete. Instead, went from"
344+
f" {old_clear_count} -> {new_clear_count} in account 0x{self._address.hex()}"
345+
)
346+
347+
# Save the new count, ie~ the index used for a future revert.
348+
self._clear_count[CLEAR_COUNT_KEY_NAME] = to_bytes(new_clear_count)
349+
232350
def record(self, checkpoint: JournalDBCheckpoint) -> None:
233351
self._journal_storage.record(checkpoint)
352+
self._clear_count.record(checkpoint)
234353

235354
def discard(self, checkpoint: JournalDBCheckpoint) -> None:
236355
self.logger.debug2('discard checkpoint %r', checkpoint)
356+
latest_clear_count = to_int(self._clear_count[CLEAR_COUNT_KEY_NAME])
357+
237358
if self._journal_storage.has_checkpoint(checkpoint):
238359
self._journal_storage.discard(checkpoint)
360+
self._clear_count.discard(checkpoint)
239361
else:
240362
# if the checkpoint comes before this account started tracking,
241363
# then simply reset to the beginning
242364
self._journal_storage.reset()
365+
self._clear_count.reset()
243366
self._storage_cache.reset_cache()
244367

368+
reverted_clear_count = to_int(self._clear_count[CLEAR_COUNT_KEY_NAME])
369+
370+
if reverted_clear_count == latest_clear_count - 1:
371+
# This revert rewinds past a trie deletion, so roll back to the trie at
372+
# that point. We use the clear count as an index to get back to the
373+
# old base trie.
374+
self._storage_lookup.rollback_trie(reverted_clear_count)
375+
elif reverted_clear_count == latest_clear_count:
376+
# No change in the base trie, take no action
377+
pass
378+
else:
379+
# Although CREATE2 permits multiple creates and deletes in a single block,
380+
# you can still only revert across a single delete. That's because delete
381+
# is only triggered at the end of the transaction.
382+
raise ValidationError(
383+
f"This revert has changed the clear count in an invalid way, from"
384+
f" {latest_clear_count} to {reverted_clear_count}, in 0x{self._address.hex()}"
385+
)
386+
245387
def commit(self, checkpoint: JournalDBCheckpoint) -> None:
246388
if self._journal_storage.has_checkpoint(checkpoint):
247389
self._journal_storage.commit(checkpoint)
390+
self._clear_count.commit(checkpoint)
248391
else:
249392
# if the checkpoint comes before this account started tracking,
250393
# then flatten all changes, without persisting
251394
self._journal_storage.flatten()
395+
self._clear_count.flatten()
252396

253397
def lock_changes(self) -> None:
398+
if self._journal_storage.has_clear():
399+
self._locked_changes.clear()
254400
self._journal_storage.persist()
255401

256402
def make_storage_root(self) -> None:
257403
self.lock_changes()
258-
self._locked_changes.commit(apply_deletes=True)
404+
self._locked_changes.persist()
259405

260406
def _validate_flushed(self) -> None:
261407
"""

newsfragments/1912.bugfix.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Fixed a consensus-critical bug for contracts that are created and destroyed in the same block,
2+
especially pre-Byzantium.

tests/core/vm/test_vm_state.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,73 @@ def test_revert_selfdestruct(state, read_storage_before_snapshot):
103103
assert state.get_storage(ADDRESS, 1) == 2
104104

105105

106+
@pytest.mark.parametrize('make_state_root_after_create', [True, False])
107+
def test_delete_after_create_in_same_block(state, make_state_root_after_create):
108+
# create account with storage in one "transaction"
109+
state.set_storage(ADDRESS, 0, 1)
110+
state.lock_changes()
111+
112+
if make_state_root_after_create:
113+
state.make_state_root()
114+
115+
# delete account in next "transaction"
116+
state.delete_account(ADDRESS)
117+
state.lock_changes()
118+
119+
# deleted account should not exist
120+
assert not state.account_exists(ADDRESS)
121+
122+
state.persist()
123+
124+
# deleted account should not exist, even after persisting
125+
assert not state.account_exists(ADDRESS)
126+
127+
128+
@pytest.mark.parametrize('make_state_root_after_lock', [True, False])
129+
@pytest.mark.parametrize('persist_after_first_create', [True, False])
130+
def test_delete_and_revive_in_same_block(
131+
state,
132+
make_state_root_after_lock,
133+
persist_after_first_create):
134+
135+
# create account with storage in one "transaction"
136+
state.set_storage(ADDRESS, 0, 1)
137+
state.lock_changes()
138+
139+
if persist_after_first_create:
140+
state.persist()
141+
elif make_state_root_after_lock:
142+
state.make_state_root()
143+
144+
# delete account in next "transaction"
145+
state.delete_account(ADDRESS)
146+
assert state.get_storage(ADDRESS, 0) == 0
147+
state.lock_changes()
148+
149+
assert state.get_storage(ADDRESS, 0) == 0
150+
151+
if make_state_root_after_lock:
152+
state.make_state_root()
153+
154+
assert state.get_storage(ADDRESS, 0) == 0
155+
156+
# revive account in next "transaction"
157+
state.set_storage(ADDRESS, 2, 3)
158+
state.lock_changes()
159+
160+
# make sure original value stays deleted
161+
assert state.get_storage(ADDRESS, 0) == 0
162+
# but new value is saved
163+
assert state.get_storage(ADDRESS, 2) == 3
164+
165+
state.persist()
166+
167+
# make sure original value stays deleted
168+
assert state.get_storage(ADDRESS, 0) == 0
169+
# but new value is saved
170+
assert state.get_storage(ADDRESS, 2) == 3
171+
172+
106173
def test_lock_state(state):
107174
assert state.get_storage(ADDRESS, 1, from_journal=False) == 0
108175

0 commit comments

Comments
 (0)