referrerpolicy=no-referrer-when-downgrade

pallet_revive/
access_list.rs

1// This file is part of Substrate.
2
3// Copyright (C) Parity Technologies (UK) Ltd.
4// SPDX-License-Identifier: Apache-2.0
5
6// Licensed under the Apache License, Version 2.0 (the "License");
7// you may not use this file except in compliance with the License.
8// You may obtain a copy of the License at
9//
10// 	http://www.apache.org/licenses/LICENSE-2.0
11//
12// Unless required by applicable law or agreed to in writing, software
13// distributed under the License is distributed on an "AS IS" BASIS,
14// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15// See the License for the specific language governing permissions and
16// limitations under the License.
17
18//! Per-transaction cold/hot access list.
19//!
20//! The per-frame rollback machinery here (flat journals + checkpoint stack, with
21//! `enter_frame` / `commit_frame` / `rollback_frame` wired into `Stack::run`)
22//! mirrors [`crate::transient_storage::TransientStorage`].
23
24use alloc::{
25	collections::btree_map::{BTreeMap, Entry},
26	vec::Vec,
27};
28use frame_support::BoundedVec;
29use sp_core::{ConstU32, H160};
30
31use crate::{exec::Key, limits};
32
33/// Inline-storage cap for `Slot::VarInline`. Covers word-sized keys (`H160`,
34/// `H256`, `AccountId32`). `Slot` stays 40 bytes for any cap up to ~38, at no
35/// memory cost.
36pub const MAX_INLINE_KEY_LEN: usize = 36;
37
38/// Maximum number of distinct `(address, slot)` entries tracked in the
39/// access list within a single transaction.
40///
41/// Bounds the working memory `AccessList` can allocate per transaction.
42/// EIP-2929 does not specify a structural cap; Ethereum relies on gas to
43/// implicitly bound growth.
44///
45/// Memory grows discontinuously due to the runtime allocator (sc-allocator)
46/// rounding allocations up to power-of-2 size classes.
47///
48/// All figures below are approximate order-of-magnitude estimates; every slot
49/// includes an upgrade. The Ethereum-gas column shows the EIP-2929 cost of
50/// filling the map to that size via cold SLOADs (2 100 gas each).
51///
52/// | Entries | Fix/Inline (Best) | VarLong (Worst) |     Gas (Ethereum) |
53/// |---------|-------------------|-----------------|--------------------|
54/// |       1 |      ~1.5 KB      |     ~1.8 KB     |          2.1 k gas |
55/// |       2 |      ~1.5 KB      |     ~2.2 KB     |          4.2 k gas |
56/// |       8 |      ~2.3 KB      |     ~5.3 KB     |         16.8 k gas |
57/// |      32 |      ~11 KB       |      ~23 KB     |         67.2 k gas |
58/// |     128 |      ~45 KB       |      ~96 KB     |          269 k gas |
59/// |   2 048 |      ~730 KB      |     ~1.5 MB     |          4.3 M gas |
60///
61/// Set ~2× above the current PoV-reachable ceiling as a backstop: each
62/// cold access charges ~10 KB `proof_size`, capping a transaction
63/// (~7.5 MiB PoV) at ~770 cold touches.
64pub const MAX_ACCESS_LIST_ENTRIES: usize = 2_048;
65
66/// Worst-case per-entry memory in the `BTreeMap` + journals, measured
67/// against sc-allocator (8-byte headers, power-of-2 buckets). `Slot::Fix` and
68/// `Slot::VarInline` measure ~366 B; `Slot::VarLong` ~502 B. An entry in the
69/// `upgrades` journal adds up to ~200 B on top. Rounded up to 768 for
70/// headroom.
71const MAX_ACCESS_LIST_ENTRY_BYTES: usize = 768;
72
73/// Worst-case total memory the access list can hold per transaction.
74pub const MAX_ACCESS_LIST_BYTES: u32 =
75	MAX_ACCESS_LIST_ENTRIES.saturating_mul(MAX_ACCESS_LIST_ENTRY_BYTES) as u32;
76
77/// Storage slot identifier for an access-list entry.
78#[derive(Ord, PartialOrd, Eq, PartialEq, Debug, Clone)]
79pub enum Slot {
80	/// Fixed 32-byte storage key.
81	Fix([u8; 32]),
82	/// Variable-length key up to [`MAX_INLINE_KEY_LEN`], stored inline to
83	/// avoid the per-entry heap allocation `VarLong` requires, while keeping
84	/// `Slot` size bounded.
85	VarInline { bytes: [u8; MAX_INLINE_KEY_LEN], len: u8 },
86	/// Variable-length key longer than [`MAX_INLINE_KEY_LEN`], up to
87	/// `limits::STORAGE_KEY_BYTES`.
88	VarLong(BoundedVec<u8, ConstU32<{ limits::STORAGE_KEY_BYTES }>>),
89}
90
91impl From<&Key> for Slot {
92	fn from(key: &Key) -> Self {
93		match key {
94			Key::Fix(v) => Slot::Fix(*v),
95			Key::Var(v) => {
96				let raw: &[u8] = v.as_ref();
97				if raw.len() <= MAX_INLINE_KEY_LEN {
98					let mut bytes = [0u8; MAX_INLINE_KEY_LEN];
99					bytes[..raw.len()].copy_from_slice(raw);
100					Slot::VarInline { bytes, len: raw.len() as u8 }
101				} else {
102					Slot::VarLong(v.clone())
103				}
104			},
105		}
106	}
107}
108
109/// The operation a storage access performs.
110#[derive(Clone, Copy, Debug, Eq, PartialEq)]
111pub enum StorageOp {
112	/// Reads the slot.
113	Read,
114	/// Writes the slot.
115	Write,
116}
117
118impl StorageOp {
119	/// Whether charging `self` also pays for `op`.
120	pub fn covers(self, op: StorageOp) -> bool {
121		match self {
122			StorageOp::Write => true,
123			StorageOp::Read => matches!(op, StorageOp::Read),
124		}
125	}
126}
127
128/// Warmth of an access-list entry, as it stood **before** the access.
129#[derive(Clone, Copy, Debug, Eq, PartialEq)]
130pub enum Warmth {
131	/// Entry is in the access list; `charged` is the operation it has paid for.
132	Hot { charged: StorageOp },
133	/// Entry is not in the access list; when `revertible` is true, the touch
134	/// rolls back with the current frame.
135	Cold { revertible: bool },
136}
137
138impl Warmth {
139	/// Whether the access billed cold: the entry was not tracked.
140	#[cfg(any(test, feature = "runtime-benchmarks"))]
141	pub(crate) fn is_cold(&self) -> bool {
142		matches!(self, Self::Cold { .. })
143	}
144}
145
146/// Snapshot of per-transaction access-list counters.
147#[derive(Clone, Copy, Debug, Eq, PartialEq)]
148pub struct AccessListMetrics {
149	/// Currently-hot entries (across all open frames).
150	pub size: usize,
151	/// Total cold touches across the transaction, including ones later rolled back.
152	pub cold: u32,
153	/// Total hot touches across the transaction, including ones later rolled back.
154	pub hot: u32,
155}
156
157/// One entry per `(storage slot, contract address)` accessed in the current tx.
158///
159/// Field order is `slot, address` so the derived `Ord` decides on `slot`
160/// first, the most-discriminating field in the typical access pattern (one
161/// contract touching many slots within a transaction).
162#[derive(Ord, PartialOrd, Eq, PartialEq, Debug, Clone)]
163pub struct AccessEntry {
164	/// Slot identifier.
165	pub slot: Slot,
166	/// Contract whose child trie is being touched.
167	pub address: H160,
168}
169
170/// Per-transaction access list with per-frame rollback support. Layout
171/// follows [`crate::transient_storage::TransientStorage`]: a current-state
172/// map, flat journals of insertions and of upgrades, and checkpoints holding
173/// both journals' lengths at frame entry. Two journals instead of one with
174/// tagged entries: an upgrade needs its own entry either way, and untagged
175/// entries use less memory.
176///
177/// # Safety invariant
178///
179/// Callers touch the `AccessList` before charging gas, so reverts must roll back the touches
180/// they made. Without that, an out-of-gas at the cold charge after the touch would leave the slot
181/// warm without the cold charge being paid, and a later access would then be billed hot. The same
182/// way, a `Read` to `Write` upgrade left by a failed write charge would let later writes skip the
183/// surcharge, so rollbacks downgrade the frame's upgrades too.
184
185#[derive(Default)]
186pub struct AccessList {
187	/// All currently-hot entries with the cost each has paid.
188	///
189	/// Not a `BoundedBTreeMap` because it has no `entry` API, which would make a
190	/// cold touch search the map twice.
191	accessed: BTreeMap<AccessEntry, StorageOp>,
192	/// Flat journal of insertions (in order); each entry was added by exactly
193	/// one frame, and `checkpoints` marks the frame boundaries inside this journal.
194	journal: BoundedVec<AccessEntry, ConstU32<{ MAX_ACCESS_LIST_ENTRIES as u32 }>>,
195	/// Flat journal of `Read` to `Write` upgrades (in order).
196	upgrades: BoundedVec<AccessEntry, ConstU32<{ MAX_ACCESS_LIST_ENTRIES as u32 }>>,
197	/// Stack of `(journal, upgrades)` lengths at frame entry.
198	checkpoints: Vec<(usize, usize)>,
199	/// Total cold touches across the transaction. Includes touches in
200	/// frames that later rolled back.
201	cold_count: u32,
202	/// Total hot touches across the transaction. Includes touches in
203	/// frames that later rolled back.
204	hot_count: u32,
205}
206
207impl AccessList {
208	/// Create an empty access list for a new transaction.
209	pub fn new() -> Self {
210		Self::default()
211	}
212
213	/// Open a new nested frame.
214	///
215	/// This allows to either commit or roll back all touches that are made
216	/// after this call. For every `enter_frame` there must be a matching call
217	/// to either `commit_frame` or `rollback_frame`.
218	pub fn enter_frame(&mut self) {
219		self.checkpoints.push((self.journal.len(), self.upgrades.len()));
220	}
221
222	/// Commit the top frame.
223	///
224	/// Touches made during that frame stay, but may still be rolled back if a
225	/// parent frame later reverts.
226	///
227	/// # Panics
228	///
229	/// Will panic if there is no open frame.
230	pub fn commit_frame(&mut self) {
231		self.checkpoints.pop().expect(
232			"A call to commit_frame must be preceded by a corresponding call to enter_frame;
233			Stack::run closes every checkpoint it opens; qed",
234		);
235	}
236
237	/// Rollback the top frame.
238	///
239	/// Entries inserted during that frame are removed from the access list;
240	/// its `Read` to `Write` upgrades are downgraded.
241	///
242	/// # Panics
243	///
244	/// Will panic if there is no open frame.
245	pub fn rollback_frame(&mut self) {
246		let (journal_checkpoint, upgrades_checkpoint) = self.checkpoints.pop().expect(
247			"A call to rollback_frame must be preceded by a corresponding call to enter_frame;
248			Stack::run closes every checkpoint it opens; qed",
249		);
250		for entry in self.journal.drain(journal_checkpoint..) {
251			self.accessed.remove(&entry);
252		}
253		for entry in self.upgrades.drain(upgrades_checkpoint..) {
254			// Removed already if the same frame also inserted the entry.
255			if let Some(charged) = self.accessed.get_mut(&entry) {
256				*charged = StorageOp::Read;
257			}
258		}
259	}
260
261	/// Non-mutating sibling of [`Self::touch`]. The two agree on a slot's warmth,
262	/// so an access priced from a peek never disagrees with one priced from a touch.
263	pub fn peek(&self, entry: &AccessEntry) -> Warmth {
264		match self.accessed.get(entry) {
265			Some(charged) => Warmth::Hot { charged: *charged },
266			None if self.is_full() => Warmth::Cold { revertible: false },
267			None => Warmth::Cold { revertible: self.in_nested_frame() },
268		}
269	}
270
271	/// Whether the map is at the entry cap.
272	fn is_full(&self) -> bool {
273		self.accessed.len() >= MAX_ACCESS_LIST_ENTRIES
274	}
275
276	/// Whether a nested-frame checkpoint is open.
277	fn in_nested_frame(&self) -> bool {
278		!self.checkpoints.is_empty()
279	}
280
281	/// Register the entry, returning its warmth. `op` is the operation being
282	/// performed on the slot.
283	///
284	/// Past [`MAX_ACCESS_LIST_ENTRIES`], new entries are billed cold without
285	/// being journaled; previously-hot slots continue to bill hot.
286	pub fn touch(&mut self, access_entry: AccessEntry, op: StorageOp) -> Warmth {
287		let at_cap = self.is_full();
288		match self.accessed.entry(access_entry) {
289			Entry::Occupied(mut tree_entry) => {
290				self.hot_count = self.hot_count.saturating_add(1);
291				let prev_charged = *tree_entry.get();
292				if !prev_charged.covers(op) {
293					// Defensive: one upgrade per tracked slot, so the journal
294					// cannot fill. If it does, later writes just pay the surcharge again.
295					let journaled = self.upgrades.try_push(tree_entry.key().clone());
296					debug_assert!(journaled.is_ok(), "at most one live upgrade per tracked slot");
297					if journaled.is_ok() {
298						*tree_entry.get_mut() = StorageOp::Write;
299					}
300				}
301				Warmth::Hot { charged: prev_charged }
302			},
303			Entry::Vacant(tree_entry) => {
304				self.cold_count = self.cold_count.saturating_add(1);
305				if at_cap {
306					return Warmth::Cold { revertible: false };
307				}
308				self.journal
309					.try_push(tree_entry.key().clone())
310					.expect("journal grows in lockstep with accessed and shares its bound; qed");
311				tree_entry.insert(op);
312				Warmth::Cold { revertible: self.in_nested_frame() }
313			},
314		}
315	}
316
317	/// Per-transaction metrics snapshot.
318	pub fn metrics(&self) -> AccessListMetrics {
319		AccessListMetrics { size: self.accessed.len(), cold: self.cold_count, hot: self.hot_count }
320	}
321
322	/// Returns the number of open checkpoints.
323	pub fn frame_depth(&self) -> usize {
324		self.checkpoints.len()
325	}
326}
327
328#[cfg(test)]
329mod tests {
330	use super::*;
331
332	#[test]
333	fn nested_commit_then_parent_rollback_drops_all() {
334		let mut al = AccessList::new();
335		let (a, b, c, d) = (
336			AccessEntry { address: H160::zero(), slot: Slot::Fix([0xA; 32]) },
337			AccessEntry { address: H160::zero(), slot: Slot::Fix([0xB; 32]) },
338			AccessEntry { address: H160::zero(), slot: Slot::Fix([0xC; 32]) },
339			AccessEntry { address: H160::zero(), slot: Slot::Fix([0xD; 32]) },
340		);
341
342		// Root frame: cold, but no checkpoint covers it, so it is not revertible.
343		assert_eq!(
344			al.touch(a.clone(), StorageOp::Read),
345			Warmth::Cold { revertible: false },
346			"A: first touch cold"
347		);
348		assert!(!al.touch(a.clone(), StorageOp::Read).is_cold(), "A: second touch hot");
349
350		al.enter_frame();
351		assert_eq!(al.frame_depth(), 1);
352
353		// Inside F1: journaled under the open checkpoint, so it is revertible.
354		assert_eq!(
355			al.touch(b.clone(), StorageOp::Read),
356			Warmth::Cold { revertible: true },
357			"B in F1: cold"
358		);
359		assert!(!al.touch(a.clone(), StorageOp::Read).is_cold(), "A in F1: hot via parent");
360
361		al.enter_frame();
362		assert!(al.touch(c.clone(), StorageOp::Read).is_cold(), "C in F2: cold");
363
364		al.commit_frame();
365		assert_eq!(al.frame_depth(), 1);
366		assert!(!al.peek(&c).is_cold(), "C: survives F2 commit");
367
368		assert!(al.touch(d.clone(), StorageOp::Read).is_cold(), "D in F1: cold");
369		assert_eq!(al.metrics().size, 4);
370
371		al.rollback_frame();
372		assert_eq!(al.frame_depth(), 0);
373		assert!(!al.peek(&a).is_cold(), "A: first frame, survives F1 revert");
374		assert!(al.peek(&b).is_cold(), "B: inserted by F1, rolled back");
375		assert!(al.peek(&c).is_cold(), "C: F2-committed-into-F1, gone when F1 reverts");
376		assert!(al.peek(&d).is_cold(), "D: inserted by F1, rolled back");
377
378		// Counters never decrement, even for entries that later roll back:
379		// A (cold) + B,C,D (cold) -> 4 cold; A,A (hot) -> 2 hot. Only A still hot,
380		// so `size` is 1.
381		assert_eq!(
382			al.metrics(),
383			AccessListMetrics { size: 1, cold: 4, hot: 2 },
384			"counters must include rolled-back touches",
385		);
386	}
387
388	/// Touch read-paid entries with distinct addresses until the map is full.
389	fn fill_to_cap(al: &mut AccessList) {
390		for i in 0..MAX_ACCESS_LIST_ENTRIES {
391			let address = H160::from_low_u64_be(i as u64);
392			let entry = AccessEntry { address, slot: Slot::Fix([0; 32]) };
393			assert!(al.touch(entry, StorageOp::Read).is_cold(), "fill entries must be new");
394		}
395		assert_eq!(al.metrics().size, MAX_ACCESS_LIST_ENTRIES, "map filled to the cap");
396	}
397
398	#[test]
399	fn touch_caps_at_max_entries() {
400		let mut al = AccessList::new();
401		fill_to_cap(&mut al);
402
403		let new_entry = AccessEntry {
404			address: H160::from_low_u64_be(MAX_ACCESS_LIST_ENTRIES as u64),
405			slot: Slot::Fix([0; 32]),
406		};
407		al.enter_frame();
408		assert_eq!(
409			al.touch(new_entry.clone(), StorageOp::Read),
410			Warmth::Cold { revertible: false },
411			"past cap: bills cold, not revertible",
412		);
413		al.commit_frame();
414		assert_eq!(al.metrics().size, MAX_ACCESS_LIST_ENTRIES, "map size stays at cap");
415		assert!(al.peek(&new_entry).is_cold(), "past-cap entry is not tracked");
416
417		assert!(
418			al.touch(new_entry, StorageOp::Read).is_cold(),
419			"past cap re-touch: still cold (not tracked)"
420		);
421
422		let existing = AccessEntry { address: H160::zero(), slot: Slot::Fix([0; 32]) };
423		assert!(
424			!al.touch(existing.clone(), StorageOp::Read).is_cold(),
425			"existing entry still hot at cap"
426		);
427
428		// A write can still upgrade a tracked slot once the map is full.
429		assert_eq!(
430			al.touch(existing.clone(), StorageOp::Write),
431			Warmth::Hot { charged: StorageOp::Read },
432			"first write at cap: was read-paid",
433		);
434		assert_eq!(
435			al.touch(existing, StorageOp::Write),
436			Warmth::Hot { charged: StorageOp::Write },
437			"write at cap: upgraded",
438		);
439
440		assert_eq!(
441			al.metrics().size,
442			MAX_ACCESS_LIST_ENTRIES,
443			"the cap holds across past-cap touches and upgrades",
444		);
445	}
446
447	#[test]
448	fn peek_does_not_mutate() {
449		let mut al = AccessList::new();
450		let entry = AccessEntry { address: H160::zero(), slot: Slot::Fix([1; 32]) };
451
452		assert!(al.peek(&entry).is_cold(), "untouched entry: cold");
453		assert!(al.peek(&entry).is_cold(), "repeated query: still cold");
454		assert_eq!(
455			al.metrics(),
456			AccessListMetrics { size: 0, cold: 0, hot: 0 },
457			"peek must not bump counters",
458		);
459
460		al.touch(entry.clone(), StorageOp::Read);
461
462		let read_paid = Warmth::Hot { charged: StorageOp::Read };
463		assert_eq!(al.peek(&entry), read_paid, "peek reports the paid level");
464		assert_eq!(al.peek(&entry), read_paid, "peek must not upgrade");
465		assert_eq!(
466			al.metrics(),
467			AccessListMetrics { size: 1, cold: 1, hot: 0 },
468			"peek must not bump the hot counter",
469		);
470	}
471
472	#[test]
473	fn touches_never_downgrade_the_paid_level() {
474		let mut al = AccessList::new();
475		let entry = AccessEntry { address: H160::zero(), slot: Slot::Fix([2; 32]) };
476
477		let read_paid = Warmth::Hot { charged: StorageOp::Read };
478		let write_paid = Warmth::Hot { charged: StorageOp::Write };
479
480		assert!(al.touch(entry.clone(), StorageOp::Read).is_cold(), "first read: cold");
481		assert_eq!(al.touch(entry.clone(), StorageOp::Read), read_paid, "read after read");
482		assert_eq!(
483			al.touch(entry.clone(), StorageOp::Write),
484			read_paid,
485			"first write: was read-paid"
486		);
487		assert_eq!(al.touch(entry.clone(), StorageOp::Write), write_paid, "write after write");
488		assert_eq!(al.touch(entry.clone(), StorageOp::Read), write_paid, "read after write");
489		assert_eq!(
490			al.touch(entry, StorageOp::Write),
491			write_paid,
492			"a read never downgrades the level"
493		);
494
495		let written = AccessEntry { address: H160::zero(), slot: Slot::Fix([3; 32]) };
496		assert!(al.touch(written.clone(), StorageOp::Write).is_cold(), "first write: cold");
497		assert_eq!(al.touch(written, StorageOp::Write), write_paid, "cold write starts at Write");
498	}
499
500	#[test]
501	fn peek_agrees_with_touch() {
502		fn agree(al: &mut AccessList, entry: AccessEntry, op: StorageOp, expected: Warmth) {
503			assert_eq!(al.peek(&entry), expected, "peek must report like touch");
504			assert_eq!(al.touch(entry, op), expected, "touch must report like peek");
505		}
506
507		let entry = |i: u8| AccessEntry { address: H160::zero(), slot: Slot::Fix([i; 32]) };
508		let mut al = AccessList::new();
509
510		agree(&mut al, entry(1), StorageOp::Read, Warmth::Cold { revertible: false });
511		agree(&mut al, entry(1), StorageOp::Read, Warmth::Hot { charged: StorageOp::Read });
512		agree(&mut al, entry(1), StorageOp::Write, Warmth::Hot { charged: StorageOp::Read });
513		agree(&mut al, entry(1), StorageOp::Write, Warmth::Hot { charged: StorageOp::Write });
514		agree(&mut al, entry(1), StorageOp::Read, Warmth::Hot { charged: StorageOp::Write });
515
516		al.enter_frame();
517		agree(&mut al, entry(2), StorageOp::Write, Warmth::Cold { revertible: true });
518		al.rollback_frame();
519
520		fill_to_cap(&mut al);
521
522		al.enter_frame();
523		// Peek's own past-cap arm must agree with touch too.
524		agree(&mut al, entry(3), StorageOp::Write, Warmth::Cold { revertible: false });
525		// A tracked read-paid slot still upgrades at the cap.
526		let filled = AccessEntry { address: H160::from_low_u64_be(0), slot: Slot::Fix([0; 32]) };
527		agree(&mut al, filled.clone(), StorageOp::Write, Warmth::Hot { charged: StorageOp::Read });
528		al.rollback_frame();
529		assert_eq!(
530			al.peek(&filled),
531			Warmth::Hot { charged: StorageOp::Read },
532			"an at-cap upgrade rolls back with its frame"
533		);
534	}
535
536	#[test]
537	fn upgrade_rolls_back_with_the_reverting_frame() {
538		let mut al = AccessList::new();
539		let entry = AccessEntry { address: H160::zero(), slot: Slot::Fix([9; 32]) };
540		al.touch(entry.clone(), StorageOp::Read);
541
542		al.enter_frame();
543		assert_eq!(
544			al.touch(entry.clone(), StorageOp::Write),
545			Warmth::Hot { charged: StorageOp::Read }
546		);
547		al.rollback_frame();
548		assert_eq!(
549			al.peek(&entry),
550			Warmth::Hot { charged: StorageOp::Read },
551			"the reverted frame's write was undone, so the next write pays again"
552		);
553
554		al.enter_frame();
555		al.enter_frame();
556		assert_eq!(
557			al.touch(entry.clone(), StorageOp::Write),
558			Warmth::Hot { charged: StorageOp::Read }
559		);
560		al.commit_frame();
561		assert_eq!(
562			al.peek(&entry),
563			Warmth::Hot { charged: StorageOp::Write },
564			"a committed upgrade belongs to the parent frame"
565		);
566		al.rollback_frame();
567		assert_eq!(
568			al.peek(&entry),
569			Warmth::Hot { charged: StorageOp::Read },
570			"the parent's revert drops the committed upgrade"
571		);
572	}
573
574	#[test]
575	fn upgrade_survives_a_nested_frames_rollback() {
576		let mut al = AccessList::new();
577		let upgraded = AccessEntry { address: H160::zero(), slot: Slot::Fix([7; 32]) };
578		al.touch(upgraded.clone(), StorageOp::Read);
579		al.touch(upgraded.clone(), StorageOp::Write);
580
581		al.enter_frame();
582		al.touch(AccessEntry { address: H160::zero(), slot: Slot::Fix([6; 32]) }, StorageOp::Write);
583		al.rollback_frame();
584
585		assert_eq!(
586			al.peek(&upgraded),
587			Warmth::Hot { charged: StorageOp::Write },
588			"a rollback must only drop its own frame's upgrades"
589		);
590	}
591
592	#[test]
593	fn same_frame_insert_and_upgrade_roll_back_together() {
594		let mut al = AccessList::new();
595		let entry = AccessEntry { address: H160::zero(), slot: Slot::Fix([8; 32]) };
596		al.enter_frame();
597		al.touch(entry.clone(), StorageOp::Read);
598		al.touch(entry.clone(), StorageOp::Write);
599		al.rollback_frame();
600		assert!(al.peek(&entry).is_cold(), "the entry and its upgrade are both gone");
601	}
602}