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/// How a storage access is priced.
110#[cfg_attr(test, derive(PartialEq, Eq))]
111#[derive(Clone, Copy, Debug)]
112pub enum StorageAccessKind {
113	/// Persistent storage, priced by its access-list warmth.
114	Persistent(Warmth),
115	/// Transient storage, every access costs the same.
116	Transient,
117}
118
119/// The operation a storage access performs.
120#[derive(Clone, Copy, Debug, Eq, PartialEq)]
121pub enum StorageOp {
122	/// Reads the slot.
123	Read,
124	/// Writes the slot.
125	Write,
126}
127
128impl StorageOp {
129	/// Whether charging `self` also pays for `op`.
130	pub fn covers(self, op: StorageOp) -> bool {
131		match self {
132			StorageOp::Write => true,
133			StorageOp::Read => matches!(op, StorageOp::Read),
134		}
135	}
136}
137
138/// Warmth of an access-list entry, as it stood **before** the access.
139#[derive(Clone, Copy, Debug, Eq, PartialEq)]
140pub enum Warmth {
141	/// Entry is in the access list; `charged` is the operation it has paid for.
142	Hot { charged: StorageOp },
143	/// Entry is not in the access list; when `revertible` is true, the touch
144	/// rolls back with the current frame.
145	Cold { revertible: bool },
146}
147
148impl Warmth {
149	/// Whether the access billed cold: the entry was not tracked.
150	#[cfg(any(test, feature = "runtime-benchmarks"))]
151	pub(crate) fn is_cold(&self) -> bool {
152		matches!(self, Self::Cold { .. })
153	}
154}
155
156/// Snapshot of per-transaction access-list counters.
157#[derive(Clone, Copy, Debug, Eq, PartialEq)]
158pub struct AccessListMetrics {
159	/// Currently-hot entries (across all open frames).
160	pub size: usize,
161	/// Total cold touches across the transaction, including ones later rolled back.
162	pub cold: u32,
163	/// Total hot touches across the transaction, including ones later rolled back.
164	pub hot: u32,
165}
166
167/// One entry per `(storage slot, contract address)` accessed in the current tx.
168///
169/// Field order is `slot, address` so the derived `Ord` decides on `slot`
170/// first, the most-discriminating field in the typical access pattern (one
171/// contract touching many slots within a transaction).
172#[derive(Ord, PartialOrd, Eq, PartialEq, Debug, Clone)]
173pub struct AccessEntry {
174	/// Slot identifier.
175	pub slot: Slot,
176	/// Contract whose child trie is being touched.
177	pub address: H160,
178}
179
180/// Per-transaction access list with per-frame rollback support. Layout
181/// follows [`crate::transient_storage::TransientStorage`]: a current-state
182/// map, flat journals of insertions and of upgrades, and checkpoints holding
183/// both journals' lengths at frame entry. Two journals instead of one with
184/// tagged entries: an upgrade needs its own entry either way, and untagged
185/// entries use less memory.
186///
187/// # Safety invariant
188///
189/// Callers touch the `AccessList` before charging gas, so reverts must roll back the touches
190/// they made. Without that, an out-of-gas at the cold charge after the touch would leave the slot
191/// warm without the cold charge being paid, and a later access would then be billed hot. The same
192/// way, a `Read` to `Write` upgrade left by a failed write charge would let later writes skip the
193/// surcharge, so rollbacks downgrade the frame's upgrades too.
194
195#[derive(Default)]
196pub struct AccessList {
197	/// All currently-hot entries with the cost each has paid.
198	///
199	/// Not a `BoundedBTreeMap` because it has no `entry` API, which would make a
200	/// cold touch search the map twice.
201	accessed: BTreeMap<AccessEntry, StorageOp>,
202	/// Flat journal of insertions (in order); each entry was added by exactly
203	/// one frame, and `checkpoints` marks the frame boundaries inside this journal.
204	journal: BoundedVec<AccessEntry, ConstU32<{ MAX_ACCESS_LIST_ENTRIES as u32 }>>,
205	/// Flat journal of `Read` to `Write` upgrades (in order).
206	upgrades: BoundedVec<AccessEntry, ConstU32<{ MAX_ACCESS_LIST_ENTRIES as u32 }>>,
207	/// Stack of `(journal, upgrades)` lengths at frame entry.
208	checkpoints: Vec<(usize, usize)>,
209	/// Total cold touches across the transaction. Includes touches in
210	/// frames that later rolled back.
211	cold_count: u32,
212	/// Total hot touches across the transaction. Includes touches in
213	/// frames that later rolled back.
214	hot_count: u32,
215}
216
217impl AccessList {
218	/// Create an empty access list for a new transaction.
219	pub fn new() -> Self {
220		Self::default()
221	}
222
223	/// Open a new nested frame.
224	///
225	/// This allows to either commit or roll back all touches that are made
226	/// after this call. For every `enter_frame` there must be a matching call
227	/// to either `commit_frame` or `rollback_frame`.
228	pub fn enter_frame(&mut self) {
229		self.checkpoints.push((self.journal.len(), self.upgrades.len()));
230	}
231
232	/// Commit the top frame.
233	///
234	/// Touches made during that frame stay, but may still be rolled back if a
235	/// parent frame later reverts.
236	///
237	/// # Panics
238	///
239	/// Will panic if there is no open frame.
240	pub fn commit_frame(&mut self) {
241		self.checkpoints.pop().expect(
242			"A call to commit_frame must be preceded by a corresponding call to enter_frame;
243			Stack::run closes every checkpoint it opens; qed",
244		);
245	}
246
247	/// Rollback the top frame.
248	///
249	/// Entries inserted during that frame are removed from the access list;
250	/// its `Read` to `Write` upgrades are downgraded.
251	///
252	/// # Panics
253	///
254	/// Will panic if there is no open frame.
255	pub fn rollback_frame(&mut self) {
256		let (journal_checkpoint, upgrades_checkpoint) = self.checkpoints.pop().expect(
257			"A call to rollback_frame must be preceded by a corresponding call to enter_frame;
258			Stack::run closes every checkpoint it opens; qed",
259		);
260		for entry in self.journal.drain(journal_checkpoint..) {
261			self.accessed.remove(&entry);
262		}
263		for entry in self.upgrades.drain(upgrades_checkpoint..) {
264			// Removed already if the same frame also inserted the entry.
265			if let Some(charged) = self.accessed.get_mut(&entry) {
266				*charged = StorageOp::Read;
267			}
268		}
269	}
270
271	/// Non-mutating sibling of [`Self::touch`]. The two agree on a slot's warmth,
272	/// so an access priced from a peek never disagrees with one priced from a touch.
273	pub fn peek(&self, entry: &AccessEntry) -> Warmth {
274		match self.accessed.get(entry) {
275			Some(charged) => Warmth::Hot { charged: *charged },
276			None if self.is_full() => Warmth::Cold { revertible: false },
277			None => Warmth::Cold { revertible: self.in_nested_frame() },
278		}
279	}
280
281	/// Whether the map is at the entry cap.
282	fn is_full(&self) -> bool {
283		self.accessed.len() >= MAX_ACCESS_LIST_ENTRIES
284	}
285
286	/// Whether a nested-frame checkpoint is open.
287	fn in_nested_frame(&self) -> bool {
288		!self.checkpoints.is_empty()
289	}
290
291	/// Register the entry, returning its warmth. `op` is the operation being
292	/// performed on the slot.
293	///
294	/// Past [`MAX_ACCESS_LIST_ENTRIES`], new entries are billed cold without
295	/// being journaled; previously-hot slots continue to bill hot.
296	pub fn touch(&mut self, access_entry: AccessEntry, op: StorageOp) -> Warmth {
297		let at_cap = self.is_full();
298		match self.accessed.entry(access_entry) {
299			Entry::Occupied(mut tree_entry) => {
300				self.hot_count = self.hot_count.saturating_add(1);
301				let prev_charged = *tree_entry.get();
302				if !prev_charged.covers(op) {
303					// Defensive: one upgrade per tracked slot, so the journal
304					// cannot fill. If it does, later writes just pay the surcharge again.
305					let journaled = self.upgrades.try_push(tree_entry.key().clone());
306					debug_assert!(journaled.is_ok(), "at most one live upgrade per tracked slot");
307					if journaled.is_ok() {
308						*tree_entry.get_mut() = StorageOp::Write;
309					}
310				}
311				Warmth::Hot { charged: prev_charged }
312			},
313			Entry::Vacant(tree_entry) => {
314				self.cold_count = self.cold_count.saturating_add(1);
315				if at_cap {
316					return Warmth::Cold { revertible: false };
317				}
318				self.journal
319					.try_push(tree_entry.key().clone())
320					.expect("journal grows in lockstep with accessed and shares its bound; qed");
321				tree_entry.insert(op);
322				Warmth::Cold { revertible: self.in_nested_frame() }
323			},
324		}
325	}
326
327	/// Per-transaction metrics snapshot.
328	pub fn metrics(&self) -> AccessListMetrics {
329		AccessListMetrics { size: self.accessed.len(), cold: self.cold_count, hot: self.hot_count }
330	}
331
332	/// Returns the number of open checkpoints.
333	pub fn frame_depth(&self) -> usize {
334		self.checkpoints.len()
335	}
336}
337
338#[cfg(test)]
339mod tests {
340	use super::*;
341
342	#[test]
343	fn nested_commit_then_parent_rollback_drops_all() {
344		let mut al = AccessList::new();
345		let (a, b, c, d) = (
346			AccessEntry { address: H160::zero(), slot: Slot::Fix([0xA; 32]) },
347			AccessEntry { address: H160::zero(), slot: Slot::Fix([0xB; 32]) },
348			AccessEntry { address: H160::zero(), slot: Slot::Fix([0xC; 32]) },
349			AccessEntry { address: H160::zero(), slot: Slot::Fix([0xD; 32]) },
350		);
351
352		// Root frame: cold, but no checkpoint covers it, so it is not revertible.
353		assert_eq!(
354			al.touch(a.clone(), StorageOp::Read),
355			Warmth::Cold { revertible: false },
356			"A: first touch cold"
357		);
358		assert!(!al.touch(a.clone(), StorageOp::Read).is_cold(), "A: second touch hot");
359
360		al.enter_frame();
361		assert_eq!(al.frame_depth(), 1);
362
363		// Inside F1: journaled under the open checkpoint, so it is revertible.
364		assert_eq!(
365			al.touch(b.clone(), StorageOp::Read),
366			Warmth::Cold { revertible: true },
367			"B in F1: cold"
368		);
369		assert!(!al.touch(a.clone(), StorageOp::Read).is_cold(), "A in F1: hot via parent");
370
371		al.enter_frame();
372		assert!(al.touch(c.clone(), StorageOp::Read).is_cold(), "C in F2: cold");
373
374		al.commit_frame();
375		assert_eq!(al.frame_depth(), 1);
376		assert!(!al.peek(&c).is_cold(), "C: survives F2 commit");
377
378		assert!(al.touch(d.clone(), StorageOp::Read).is_cold(), "D in F1: cold");
379		assert_eq!(al.metrics().size, 4);
380
381		al.rollback_frame();
382		assert_eq!(al.frame_depth(), 0);
383		assert!(!al.peek(&a).is_cold(), "A: first frame, survives F1 revert");
384		assert!(al.peek(&b).is_cold(), "B: inserted by F1, rolled back");
385		assert!(al.peek(&c).is_cold(), "C: F2-committed-into-F1, gone when F1 reverts");
386		assert!(al.peek(&d).is_cold(), "D: inserted by F1, rolled back");
387
388		// Counters never decrement, even for entries that later roll back:
389		// A (cold) + B,C,D (cold) -> 4 cold; A,A (hot) -> 2 hot. Only A still hot,
390		// so `size` is 1.
391		assert_eq!(
392			al.metrics(),
393			AccessListMetrics { size: 1, cold: 4, hot: 2 },
394			"counters must include rolled-back touches",
395		);
396	}
397
398	/// Touch read-paid entries with distinct addresses until the map is full.
399	fn fill_to_cap(al: &mut AccessList) {
400		for i in 0..MAX_ACCESS_LIST_ENTRIES {
401			let address = H160::from_low_u64_be(i as u64);
402			let entry = AccessEntry { address, slot: Slot::Fix([0; 32]) };
403			assert!(al.touch(entry, StorageOp::Read).is_cold(), "fill entries must be new");
404		}
405		assert_eq!(al.metrics().size, MAX_ACCESS_LIST_ENTRIES, "map filled to the cap");
406	}
407
408	#[test]
409	fn touch_caps_at_max_entries() {
410		let mut al = AccessList::new();
411		fill_to_cap(&mut al);
412
413		let new_entry = AccessEntry {
414			address: H160::from_low_u64_be(MAX_ACCESS_LIST_ENTRIES as u64),
415			slot: Slot::Fix([0; 32]),
416		};
417		al.enter_frame();
418		assert_eq!(
419			al.touch(new_entry.clone(), StorageOp::Read),
420			Warmth::Cold { revertible: false },
421			"past cap: bills cold, not revertible",
422		);
423		al.commit_frame();
424		assert_eq!(al.metrics().size, MAX_ACCESS_LIST_ENTRIES, "map size stays at cap");
425		assert!(al.peek(&new_entry).is_cold(), "past-cap entry is not tracked");
426
427		assert!(
428			al.touch(new_entry, StorageOp::Read).is_cold(),
429			"past cap re-touch: still cold (not tracked)"
430		);
431
432		let existing = AccessEntry { address: H160::zero(), slot: Slot::Fix([0; 32]) };
433		assert!(
434			!al.touch(existing.clone(), StorageOp::Read).is_cold(),
435			"existing entry still hot at cap"
436		);
437
438		// A write can still upgrade a tracked slot once the map is full.
439		assert_eq!(
440			al.touch(existing.clone(), StorageOp::Write),
441			Warmth::Hot { charged: StorageOp::Read },
442			"first write at cap: was read-paid",
443		);
444		assert_eq!(
445			al.touch(existing, StorageOp::Write),
446			Warmth::Hot { charged: StorageOp::Write },
447			"write at cap: upgraded",
448		);
449
450		assert_eq!(
451			al.metrics().size,
452			MAX_ACCESS_LIST_ENTRIES,
453			"the cap holds across past-cap touches and upgrades",
454		);
455	}
456
457	#[test]
458	fn peek_does_not_mutate() {
459		let mut al = AccessList::new();
460		let entry = AccessEntry { address: H160::zero(), slot: Slot::Fix([1; 32]) };
461
462		assert!(al.peek(&entry).is_cold(), "untouched entry: cold");
463		assert!(al.peek(&entry).is_cold(), "repeated query: still cold");
464		assert_eq!(
465			al.metrics(),
466			AccessListMetrics { size: 0, cold: 0, hot: 0 },
467			"peek must not bump counters",
468		);
469
470		al.touch(entry.clone(), StorageOp::Read);
471
472		let read_paid = Warmth::Hot { charged: StorageOp::Read };
473		assert_eq!(al.peek(&entry), read_paid, "peek reports the paid level");
474		assert_eq!(al.peek(&entry), read_paid, "peek must not upgrade");
475		assert_eq!(
476			al.metrics(),
477			AccessListMetrics { size: 1, cold: 1, hot: 0 },
478			"peek must not bump the hot counter",
479		);
480	}
481
482	#[test]
483	fn touches_never_downgrade_the_paid_level() {
484		let mut al = AccessList::new();
485		let entry = AccessEntry { address: H160::zero(), slot: Slot::Fix([2; 32]) };
486
487		let read_paid = Warmth::Hot { charged: StorageOp::Read };
488		let write_paid = Warmth::Hot { charged: StorageOp::Write };
489
490		assert!(al.touch(entry.clone(), StorageOp::Read).is_cold(), "first read: cold");
491		assert_eq!(al.touch(entry.clone(), StorageOp::Read), read_paid, "read after read");
492		assert_eq!(
493			al.touch(entry.clone(), StorageOp::Write),
494			read_paid,
495			"first write: was read-paid"
496		);
497		assert_eq!(al.touch(entry.clone(), StorageOp::Write), write_paid, "write after write");
498		assert_eq!(al.touch(entry.clone(), StorageOp::Read), write_paid, "read after write");
499		assert_eq!(
500			al.touch(entry, StorageOp::Write),
501			write_paid,
502			"a read never downgrades the level"
503		);
504
505		let written = AccessEntry { address: H160::zero(), slot: Slot::Fix([3; 32]) };
506		assert!(al.touch(written.clone(), StorageOp::Write).is_cold(), "first write: cold");
507		assert_eq!(al.touch(written, StorageOp::Write), write_paid, "cold write starts at Write");
508	}
509
510	#[test]
511	fn peek_agrees_with_touch() {
512		fn agree(al: &mut AccessList, entry: AccessEntry, op: StorageOp, expected: Warmth) {
513			assert_eq!(al.peek(&entry), expected, "peek must report like touch");
514			assert_eq!(al.touch(entry, op), expected, "touch must report like peek");
515		}
516
517		let entry = |i: u8| AccessEntry { address: H160::zero(), slot: Slot::Fix([i; 32]) };
518		let mut al = AccessList::new();
519
520		agree(&mut al, entry(1), StorageOp::Read, Warmth::Cold { revertible: false });
521		agree(&mut al, entry(1), StorageOp::Read, Warmth::Hot { charged: StorageOp::Read });
522		agree(&mut al, entry(1), StorageOp::Write, Warmth::Hot { charged: StorageOp::Read });
523		agree(&mut al, entry(1), StorageOp::Write, Warmth::Hot { charged: StorageOp::Write });
524		agree(&mut al, entry(1), StorageOp::Read, Warmth::Hot { charged: StorageOp::Write });
525
526		al.enter_frame();
527		agree(&mut al, entry(2), StorageOp::Write, Warmth::Cold { revertible: true });
528		al.rollback_frame();
529
530		fill_to_cap(&mut al);
531
532		al.enter_frame();
533		// Peek's own past-cap arm must agree with touch too.
534		agree(&mut al, entry(3), StorageOp::Write, Warmth::Cold { revertible: false });
535		// A tracked read-paid slot still upgrades at the cap.
536		let filled = AccessEntry { address: H160::from_low_u64_be(0), slot: Slot::Fix([0; 32]) };
537		agree(&mut al, filled.clone(), StorageOp::Write, Warmth::Hot { charged: StorageOp::Read });
538		al.rollback_frame();
539		assert_eq!(
540			al.peek(&filled),
541			Warmth::Hot { charged: StorageOp::Read },
542			"an at-cap upgrade rolls back with its frame"
543		);
544	}
545
546	#[test]
547	fn upgrade_rolls_back_with_the_reverting_frame() {
548		let mut al = AccessList::new();
549		let entry = AccessEntry { address: H160::zero(), slot: Slot::Fix([9; 32]) };
550		al.touch(entry.clone(), StorageOp::Read);
551
552		al.enter_frame();
553		assert_eq!(
554			al.touch(entry.clone(), StorageOp::Write),
555			Warmth::Hot { charged: StorageOp::Read }
556		);
557		al.rollback_frame();
558		assert_eq!(
559			al.peek(&entry),
560			Warmth::Hot { charged: StorageOp::Read },
561			"the reverted frame's write was undone, so the next write pays again"
562		);
563
564		al.enter_frame();
565		al.enter_frame();
566		assert_eq!(
567			al.touch(entry.clone(), StorageOp::Write),
568			Warmth::Hot { charged: StorageOp::Read }
569		);
570		al.commit_frame();
571		assert_eq!(
572			al.peek(&entry),
573			Warmth::Hot { charged: StorageOp::Write },
574			"a committed upgrade belongs to the parent frame"
575		);
576		al.rollback_frame();
577		assert_eq!(
578			al.peek(&entry),
579			Warmth::Hot { charged: StorageOp::Read },
580			"the parent's revert drops the committed upgrade"
581		);
582	}
583
584	#[test]
585	fn upgrade_survives_a_nested_frames_rollback() {
586		let mut al = AccessList::new();
587		let upgraded = AccessEntry { address: H160::zero(), slot: Slot::Fix([7; 32]) };
588		al.touch(upgraded.clone(), StorageOp::Read);
589		al.touch(upgraded.clone(), StorageOp::Write);
590
591		al.enter_frame();
592		al.touch(AccessEntry { address: H160::zero(), slot: Slot::Fix([6; 32]) }, StorageOp::Write);
593		al.rollback_frame();
594
595		assert_eq!(
596			al.peek(&upgraded),
597			Warmth::Hot { charged: StorageOp::Write },
598			"a rollback must only drop its own frame's upgrades"
599		);
600	}
601
602	#[test]
603	fn same_frame_insert_and_upgrade_roll_back_together() {
604		let mut al = AccessList::new();
605		let entry = AccessEntry { address: H160::zero(), slot: Slot::Fix([8; 32]) };
606		al.enter_frame();
607		al.touch(entry.clone(), StorageOp::Read);
608		al.touch(entry.clone(), StorageOp::Write);
609		al.rollback_frame();
610		assert!(al.peek(&entry).is_cold(), "the entry and its upgrade are both gone");
611	}
612}