Skip to main content

tempo_precompiles/storage/
thread_local.rs

1use alloy::{
2    primitives::{Address, B256, Bytes, LogData, U256},
3    sol_types::SolInterface,
4};
5use alloy_evm::{Database, EvmInternals};
6use revm::{
7    context::{
8        Block, CfgEnv, ContextTr, JournalTr, Transaction, journaled_state::JournalCheckpoint,
9    },
10    precompile::{PrecompileHalt, PrecompileOutput, PrecompileResult},
11    state::{AccountInfo, Bytecode},
12};
13use scoped_tls::scoped_thread_local;
14use std::{cell::RefCell, fmt::Debug};
15use tempo_chainspec::hardfork::TempoHardfork;
16
17use crate::{
18    Precompile,
19    error::{Result, TempoPrecompileError},
20    storage::{PrecompileStorageProvider, evm::EvmPrecompileStorageProvider},
21};
22
23scoped_thread_local!(static STORAGE: RefCell<&mut dyn PrecompileStorageProvider>);
24
25/// Thread-local storage accessor that implements `PrecompileStorageProvider` without the trait bound.
26///
27/// This is the only type that exposes access to the thread-local `STORAGE` static.
28///
29/// # Important
30///
31/// Since it provides access to the current thread-local storage context, it MUST be used within
32/// a `StorageCtx::enter` closure.
33///
34/// # Sync with `PrecompileStorageProvider`
35///
36/// This type mirrors `PrecompileStorageProvider` methods but with split mutability:
37/// - Read operations (staticcall) take `&self`
38/// - Write operations take `&mut self`
39#[derive(Debug, Default, Clone, Copy)]
40pub struct StorageCtx;
41
42impl StorageCtx {
43    /// Enter storage context. All storage operations must happen within the closure.
44    ///
45    /// # IMPORTANT
46    ///
47    /// The caller must ensure that:
48    /// 1. Only one `enter` call is active at a time, in the same thread.
49    /// 2. If multiple storage providers are instantiated in parallel threads,
50    ///    they CANNOT point to the same storage addresses.
51    pub fn enter<S, R>(storage: &mut S, f: impl FnOnce() -> R) -> R
52    where
53        S: PrecompileStorageProvider,
54    {
55        // SAFETY: `scoped_tls` ensures the pointer is only accessible within the closure scope.
56        let storage: &mut dyn PrecompileStorageProvider = storage;
57        let storage_static: &mut (dyn PrecompileStorageProvider + 'static) =
58            unsafe { std::mem::transmute(storage) };
59        let cell = RefCell::new(storage_static);
60        STORAGE.set(&cell, f)
61    }
62
63    /// Execute an infallible function with access to the current thread-local storage provider.
64    ///
65    /// # Panics
66    /// Panics if no storage context is set.
67    fn with_storage<F, R>(f: F) -> R
68    where
69        F: FnOnce(&mut dyn PrecompileStorageProvider) -> R,
70    {
71        assert!(
72            STORAGE.is_set(),
73            "No storage context. 'StorageCtx::enter' must be called first"
74        );
75        STORAGE.with(|cell| {
76            // SAFETY: `scoped_tls` ensures the pointer is only accessible within the closure scope.
77            // Holding the guard prevents re-entrant borrows.
78            let mut guard = cell.borrow_mut();
79            f(&mut **guard)
80        })
81    }
82
83    /// Execute a (fallible) function with access to the current thread-local storage provider.
84    fn try_with_storage<F, R>(f: F) -> Result<R>
85    where
86        F: FnOnce(&mut dyn PrecompileStorageProvider) -> Result<R>,
87    {
88        if !STORAGE.is_set() {
89            return Err(TempoPrecompileError::Fatal(
90                "No storage context. 'StorageCtx::enter' must be called first".to_string(),
91            ));
92        }
93        STORAGE.with(|cell| {
94            // SAFETY: `scoped_tls` ensures the pointer is only accessible within the closure scope.
95            // Holding the guard prevents re-entrant borrows.
96            let mut guard = cell.borrow_mut();
97            f(&mut **guard)
98        })
99    }
100
101    // `PrecompileStorageProvider` methods (with modified mutability for read-only methods)
102
103    /// Executes a closure with access to the account info, returning the closure's result.
104    ///
105    /// This is an ergonomic wrapper that flattens the Result, avoiding double `?`.
106    pub fn with_account_info<T>(
107        &self,
108        address: Address,
109        mut f: impl FnMut(&AccountInfo) -> Result<T>,
110    ) -> Result<T> {
111        let mut result: Option<Result<T>> = None;
112        Self::try_with_storage(|s| {
113            s.with_account_info(address, &mut |info| {
114                result = Some(f(info));
115            })
116        })?;
117        result.unwrap()
118    }
119
120    /// Returns the chain ID.
121    pub fn chain_id(&self) -> u64 {
122        Self::with_storage(|s| s.chain_id())
123    }
124
125    /// Returns the current block timestamp.
126    pub fn timestamp(&self) -> U256 {
127        Self::with_storage(|s| s.timestamp())
128    }
129
130    /// Returns the current block beneficiary (coinbase).
131    pub fn beneficiary(&self) -> Address {
132        Self::with_storage(|s| s.beneficiary())
133    }
134
135    /// Returns the current block number.
136    pub fn block_number(&self) -> u64 {
137        Self::with_storage(|s| s.block_number())
138    }
139
140    /// Sets the bytecode at the given address.
141    pub fn set_code(&mut self, address: Address, code: Bytecode) -> Result<()> {
142        Self::try_with_storage(|s| s.set_code(address, code))
143    }
144
145    /// Performs an SLOAD operation (persistent storage read).
146    pub fn sload(&self, address: Address, key: U256) -> Result<U256> {
147        Self::try_with_storage(|s| s.sload(address, key))
148    }
149
150    /// Performs a TLOAD operation (transient storage read).
151    pub fn tload(&self, address: Address, key: U256) -> Result<U256> {
152        Self::try_with_storage(|s| s.tload(address, key))
153    }
154
155    /// Performs an SSTORE operation (persistent storage write).
156    pub fn sstore(&mut self, address: Address, key: U256, value: U256) -> Result<()> {
157        Self::try_with_storage(|s| s.sstore(address, key, value))
158    }
159
160    /// Performs a TSTORE operation (transient storage write).
161    pub fn tstore(&mut self, address: Address, key: U256, value: U256) -> Result<()> {
162        Self::try_with_storage(|s| s.tstore(address, key, value))
163    }
164
165    /// Emits an event from the given contract address.
166    pub fn emit_event(&mut self, address: Address, event: LogData) -> Result<()> {
167        Self::try_with_storage(|s| s.emit_event(address, event))
168    }
169
170    /// Adds refund to the gas refund counter.
171    pub fn refund_gas(&mut self, gas: i64) {
172        Self::with_storage(|s| s.refund_gas(gas))
173    }
174
175    /// Returns the gas limit for this precompile call.
176    pub fn gas_limit(&self) -> u64 {
177        Self::with_storage(|s| s.gas_limit())
178    }
179
180    /// Returns the gas used so far.
181    pub fn gas_used(&self) -> u64 {
182        Self::with_storage(|s| s.gas_used())
183    }
184
185    /// Returns the state-creating gas used so far (cold SSTORE zero->non-zero, code deposit).
186    pub fn state_gas_used(&self) -> u64 {
187        Self::with_storage(|s| s.state_gas_used())
188    }
189
190    /// Returns the gas refunded so far.
191    pub fn gas_refunded(&self) -> i64 {
192        Self::with_storage(|s| s.gas_refunded())
193    }
194
195    /// Returns the reservoir gas.
196    pub fn reservoir(&self) -> u64 {
197        Self::with_storage(|s| s.reservoir())
198    }
199
200    /// Returns the currently active hardfork.
201    pub fn spec(&self) -> TempoHardfork {
202        Self::with_storage(|s| s.spec())
203    }
204
205    /// Mirrors `CfgEnv::enable_amsterdam_eip8037`. Used by precompiles to gate the TIP-1016
206    /// regular/state gas split independently of the active hardfork.
207    pub fn amsterdam_eip8037_enabled(&self) -> bool {
208        Self::with_storage(|s| s.amsterdam_eip8037_enabled())
209    }
210
211    /// Returns whether the current call context is static.
212    pub fn is_static(&self) -> bool {
213        Self::with_storage(|s| s.is_static())
214    }
215
216    /// Enables or disables TIP-1060 storage-credit accounting for subsequent storage writes.
217    pub fn set_tip1060_storage_credits(&mut self, enabled: bool) {
218        Self::with_storage(|s| s.set_tip1060_storage_credits(enabled))
219    }
220
221    /// Creates a journal checkpoint and returns a RAII guard.
222    ///
223    /// All state mutations after this call will be atomically
224    /// reverted if the guard is dropped without calling
225    /// [`CheckpointGuard::commit`].
226    ///
227    /// # Panics
228    ///
229    /// Panics if no storage context is set.
230    pub fn checkpoint(&mut self) -> CheckpointGuard {
231        // spec: only available +T1C. Prior to that checkpoints are a no-op.
232        let checkpoint = Self::with_storage(|s| {
233            if s.spec().is_t1c() {
234                Some(s.checkpoint())
235            } else {
236                None
237            }
238        });
239
240        CheckpointGuard { checkpoint }
241    }
242
243    /// Deducts gas from the remaining gas and returns an error if insufficient.
244    pub fn deduct_gas(&mut self, gas: u64) -> Result<()> {
245        Self::try_with_storage(|s| s.deduct_gas(gas))
246    }
247
248    /// Computes keccak256 and charges the appropriate gas.
249    ///
250    /// Prefer this over naked `keccak256` to ensure gas is accounted for.
251    pub fn keccak256(&self, data: &[u8]) -> Result<B256> {
252        Self::try_with_storage(|s| s.keccak256(data))
253    }
254
255    /// Recovers the signer address from an ECDSA signature and charges ecrecover gas.
256    /// As per [TIP-1004], it only accepts `v` values of `27` or `28` (no `0`/`1` normalization).
257    ///
258    /// Returns `Ok(None)` on invalid signatures; callers map to domain-specific errors.
259    ///
260    /// [TIP-1004]: <https://github.com/tempoxyz/tempo/blob/main/tips/tip-1004.md#signature-validation>
261    pub fn recover_signer(&self, digest: B256, v: u8, r: B256, s: B256) -> Result<Option<Address>> {
262        Self::try_with_storage(|storage| storage.recover_signer(digest, v, r, s))
263    }
264
265    /// Returns a [`PrecompileOutput`] with [`revm::precompile::PrecompileStatus::Success`] and the current gas values.
266    pub fn success_output(&self, output: Bytes) -> PrecompileOutput {
267        PrecompileOutput::new(self.gas_used(), output, self.reservoir())
268    }
269
270    /// Returns an ABI-encoded success output.
271    pub fn abi_success(&self, output: impl SolInterface) -> PrecompileOutput {
272        self.success_output(output.abi_encode().into())
273    }
274
275    /// Returns a [`PrecompileOutput`] with [`revm::precompile::PrecompileStatus::Revert`] and the current gas values.
276    pub fn revert_output(&self, output: Bytes) -> PrecompileOutput {
277        PrecompileOutput::revert(self.gas_used(), output, self.reservoir())
278    }
279
280    /// Reverts with an ABI-encoded error.
281    pub fn abi_revert(&self, error: impl SolInterface) -> PrecompileOutput {
282        self.revert_output(error.abi_encode().into())
283    }
284
285    /// Returns a [`PrecompileOutput`] with [`revm::precompile::PrecompileStatus::Halt`] and the current gas values.
286    pub fn halt_output(&self, halt: PrecompileHalt) -> PrecompileOutput {
287        PrecompileOutput::halt(halt, self.reservoir())
288    }
289
290    /// Returns a [`PrecompileResult`] constructed from the given [`TempoPrecompileError`].
291    pub fn error_result(&self, error: impl Into<TempoPrecompileError>) -> PrecompileResult {
292        error
293            .into()
294            .into_precompile_result(self.gas_used(), self.reservoir())
295    }
296}
297
298/// RAII guard for atomic state mutation batching.
299///
300/// On drop, automatically reverts all state changes made since the checkpoint
301/// unless [`commit`](CheckpointGuard::commit) was called.
302///
303/// # SPEC
304/// Only active +T1C, previously it is a no-op (no checkpoint is created).
305///
306/// # Examples
307///
308/// ```ignore
309/// let guard = self.storage.checkpoint();
310/// self.sstore(addr, key, value)?;  // reverted on drop (T1C+)
311/// self.emit_event(...)?;
312/// guard.commit();  // finalizes all mutations
313/// ```
314pub struct CheckpointGuard {
315    checkpoint: Option<JournalCheckpoint>,
316}
317
318impl CheckpointGuard {
319    /// Commits all state changes since the checkpoint.
320    pub fn commit(mut self) {
321        if let Some(cp) = self.checkpoint.take() {
322            StorageCtx::with_storage(|s| s.checkpoint_commit(cp));
323        }
324    }
325}
326
327impl Drop for CheckpointGuard {
328    fn drop(&mut self) {
329        if let Some(cp) = self.checkpoint.take() {
330            StorageCtx::with_storage(|s| s.checkpoint_revert(cp));
331        }
332    }
333}
334
335impl<'evm> StorageCtx {
336    /// Generic entry point for EVM-like environments.
337    /// Sets up the storage provider and executes a closure within that context.
338    pub fn enter_evm<J, R>(
339        journal: &'evm mut J,
340        block_env: &'evm dyn Block,
341        cfg: &CfgEnv<TempoHardfork>,
342        tx_env: &'evm impl Transaction,
343        f: impl FnOnce() -> R,
344    ) -> R
345    where
346        J: JournalTr<Database: Database> + Debug,
347    {
348        let internals = EvmInternals::new(journal, block_env, cfg, tx_env);
349        let mut provider = EvmPrecompileStorageProvider::new_max_gas(internals, cfg);
350
351        // The core logic of setting up thread-local storage is here.
352        Self::enter(&mut provider, f)
353    }
354
355    /// Enters storage with TIP-1060 storage-credit accounting disabled.
356    ///
357    /// Use when provider gas is not charged, or is charged externally, and the writes must not
358    /// mint, consume, or settle storage credits. If those writes create persistent storage, the
359    /// external charge must include `STORAGE_CREDIT_VALUE` unless exempt.
360    pub fn enter_evm_without_tip1060_accounting<J, R>(
361        journal: &'evm mut J,
362        block_env: &'evm dyn Block,
363        cfg: &CfgEnv<TempoHardfork>,
364        tx_env: &'evm impl Transaction,
365        f: impl FnOnce() -> R,
366    ) -> R
367    where
368        J: JournalTr<Database: Database> + Debug,
369    {
370        let internals = EvmInternals::new(journal, block_env, cfg, tx_env);
371        let mut provider = EvmPrecompileStorageProvider::new_max_gas(internals, cfg);
372        provider.set_tip1060_storage_credits(false);
373
374        Self::enter(&mut provider, f)
375    }
376
377    /// Like [`enter_evm`](Self::enter_evm), but takes a `&mut impl ContextTr`
378    /// directly instead of requiring the caller to destructure the context.
379    pub fn enter_ctx<C, R>(ctx: &mut C, f: impl FnOnce() -> R) -> R
380    where
381        C: ContextTr<Cfg = CfgEnv<TempoHardfork>, Journal: Debug, Db: Database>,
382    {
383        let (tx, block, cfg, journal) = ctx.tx_block_cfg_journal_mut();
384        Self::enter_evm(journal, block, cfg, tx, f)
385    }
386
387    /// Like [`enter_ctx`](Self::enter_ctx), but meters storage access under `gas_limit`
388    /// and returns both the closure result and gas consumed.
389    pub fn enter_ctx_with_gas_limit<C, R>(
390        ctx: &mut C,
391        gas_limit: u64,
392        reservoir: u64,
393        f: impl FnOnce() -> R,
394    ) -> (R, u64)
395    where
396        C: ContextTr<Cfg = CfgEnv<TempoHardfork>, Journal: Debug, Db: Database>,
397    {
398        let (tx, block, cfg, journal) = ctx.tx_block_cfg_journal_mut();
399        let internals = EvmInternals::new(journal, block, cfg, tx);
400        let mut provider =
401            EvmPrecompileStorageProvider::new_with_gas_limit(internals, cfg, gas_limit, reservoir);
402        let result = Self::enter(&mut provider, f);
403        let gas_used = provider.gas_used();
404        (result, gas_used)
405    }
406
407    /// Entry point for a "canonical" precompile (with unique known address).
408    pub fn enter_precompile<J, P, R>(
409        journal: &'evm mut J,
410        block_env: &'evm dyn Block,
411        cfg: &CfgEnv<TempoHardfork>,
412        tx_env: &'evm impl Transaction,
413        f: impl FnOnce(P) -> R,
414    ) -> R
415    where
416        J: JournalTr<Database: Database> + Debug,
417        P: Precompile + Default,
418    {
419        // Delegate all the setup logic to `enter_evm`.
420        // We just need to provide a closure that `enter_evm` expects.
421        Self::enter_evm(journal, block_env, cfg, tx_env, || f(P::default()))
422    }
423}
424
425#[cfg(any(test, feature = "test-utils"))]
426use crate::storage::hashmap::HashMapStorageProvider;
427
428#[cfg(any(test, feature = "test-utils"))]
429impl StorageCtx {
430    /// Returns a mutable reference to the underlying `HashMapStorageProvider`.
431    ///
432    /// NOTE: takes a non-mutable reference because it's internal. The mutability
433    /// of the storage operation is determined by the public function.
434    #[allow(clippy::mut_from_ref)]
435    fn as_hashmap(&self) -> &mut HashMapStorageProvider {
436        Self::with_storage(|s| {
437            // SAFETY: Test code always uses HashMapStorageProvider.
438            // Reference valid for duration of StorageCtx::enter closure.
439            unsafe {
440                extend_lifetime_mut(
441                    &mut *(s as *mut dyn PrecompileStorageProvider as *mut HashMapStorageProvider),
442                )
443            }
444        })
445    }
446
447    /// NOTE: assumes storage tests always use the `HashMapStorageProvider`
448    pub fn get_account_info(&self, address: Address) -> Option<&AccountInfo> {
449        self.as_hashmap().get_account_info(address)
450    }
451
452    /// NOTE: assumes storage tests always use the `HashMapStorageProvider`
453    pub fn get_events(&self, address: Address) -> &Vec<LogData> {
454        self.as_hashmap().get_events(address)
455    }
456
457    /// NOTE: assumes storage tests always use the `HashMapStorageProvider`
458    pub fn set_nonce(&mut self, address: Address, nonce: u64) {
459        self.as_hashmap().set_nonce(address, nonce)
460    }
461
462    /// NOTE: assumes storage tests always use the `HashMapStorageProvider`
463    pub fn set_timestamp(&mut self, timestamp: U256) {
464        self.as_hashmap().set_timestamp(timestamp)
465    }
466
467    /// NOTE: assumes storage tests always use the `HashMapStorageProvider`
468    pub fn set_beneficiary(&mut self, beneficiary: Address) {
469        self.as_hashmap().set_beneficiary(beneficiary)
470    }
471
472    /// NOTE: assumes storage tests always use the `HashMapStorageProvider`
473    pub fn set_block_number(&mut self, block_number: u64) {
474        self.as_hashmap().set_block_number(block_number)
475    }
476
477    /// NOTE: assumes storage tests always use the `HashMapStorageProvider`
478    pub fn set_spec(&mut self, spec: TempoHardfork) {
479        self.as_hashmap().set_spec(spec)
480    }
481
482    /// NOTE: assumes storage tests always use the `HashMapStorageProvider`
483    pub fn clear_transient(&mut self) {
484        self.as_hashmap().clear_transient()
485    }
486
487    /// NOTE: assumes storage tests always use the `HashMapStorageProvider`
488    ///
489    /// USAGE: `TIP20Setup` clears events of the configured contract when
490    /// `apply()` is called only if `clear_events()` was explicitly set.
491    pub fn clear_events(&mut self, address: Address) {
492        self.as_hashmap().clear_events(address);
493    }
494
495    /// NOTE: assumes storage tests always use the `HashMapStorageProvider`
496    pub fn counter_sload(&self) -> u64 {
497        self.as_hashmap().counter_sload()
498    }
499
500    /// NOTE: assumes storage tests always use the `HashMapStorageProvider`
501    pub fn counter_sstore(&self) -> u64 {
502        self.as_hashmap().counter_sstore()
503    }
504
505    /// NOTE: assumes storage tests always use the `HashMapStorageProvider`
506    pub fn reset_counters(&mut self) {
507        self.as_hashmap().reset_counters()
508    }
509
510    /// Checks if a contract at the given address has bytecode deployed.
511    pub fn has_bytecode(&self, address: Address) -> Result<bool> {
512        self.with_account_info(address, |info| Ok(!info.is_empty_code_hash()))
513    }
514}
515
516/// Extends the lifetime of a mutable reference: `&'a mut T -> &'b mut T`
517///
518/// SAFETY: the caller must ensure the reference remains valid for the extended lifetime.
519#[cfg(any(test, feature = "test-utils"))]
520unsafe fn extend_lifetime_mut<'b, T: ?Sized>(r: &mut T) -> &'b mut T {
521    unsafe { &mut *(r as *mut T) }
522}
523
524#[cfg(test)]
525mod tests {
526    use super::*;
527    use alloy::primitives::U256;
528    use tempo_chainspec::hardfork::TempoHardfork;
529
530    fn t1c_storage() -> HashMapStorageProvider {
531        HashMapStorageProvider::new_with_spec(1, TempoHardfork::T1C)
532    }
533
534    #[test]
535    #[should_panic(expected = "already borrowed")]
536    fn test_reentrant_with_storage_panics() {
537        let mut storage = HashMapStorageProvider::new(1);
538        StorageCtx::enter(&mut storage, || {
539            // first borrow
540            StorageCtx::with_storage(|_| {
541                // re-entrant call should panic
542                StorageCtx::with_storage(|_| ())
543            })
544        });
545    }
546
547    #[test]
548    fn test_checkpoint_commit_and_revert() {
549        let mut storage = t1c_storage();
550        let addr = Address::ZERO;
551        let key = U256::from(1);
552
553        StorageCtx::enter(&mut storage, || {
554            let mut ctx = StorageCtx;
555
556            // commit persists state
557            ctx.sstore(addr, key, U256::from(42)).unwrap();
558            let guard = ctx.checkpoint();
559            ctx.sstore(addr, key, U256::from(99)).unwrap();
560            guard.commit();
561            assert_eq!(ctx.sload(addr, key).unwrap(), U256::from(99));
562
563            // drop reverts state
564            {
565                let _guard = ctx.checkpoint();
566                ctx.sstore(addr, key, U256::from(1)).unwrap();
567            }
568            assert_eq!(ctx.sload(addr, key).unwrap(), U256::from(99));
569        });
570    }
571
572    #[test]
573    fn test_nested_checkpoints_lifo() {
574        let mut storage = t1c_storage();
575        let addr = Address::ZERO;
576        let key = U256::from(1);
577
578        StorageCtx::enter(&mut storage, || {
579            let mut ctx = StorageCtx;
580            ctx.sstore(addr, key, U256::from(10)).unwrap();
581
582            // both committed in LIFO order
583            let outer = ctx.checkpoint();
584            ctx.sstore(addr, key, U256::from(20)).unwrap();
585            let inner = ctx.checkpoint();
586            ctx.sstore(addr, key, U256::from(30)).unwrap();
587            inner.commit();
588            outer.commit();
589            assert_eq!(ctx.sload(addr, key).unwrap(), U256::from(30));
590
591            // inner reverts, outer commits
592            let outer = ctx.checkpoint();
593            ctx.sstore(addr, key, U256::from(40)).unwrap();
594            {
595                let _inner = ctx.checkpoint();
596                ctx.sstore(addr, key, U256::from(50)).unwrap();
597            }
598            outer.commit();
599            assert_eq!(ctx.sload(addr, key).unwrap(), U256::from(40));
600        });
601    }
602
603    #[test]
604    #[should_panic(expected = "out-of-order")]
605    fn test_nested_checkpoints_out_of_order_commit_panics() {
606        let mut storage = t1c_storage();
607
608        StorageCtx::enter(&mut storage, || {
609            let mut ctx = StorageCtx;
610
611            let outer = ctx.checkpoint();
612            let _inner = ctx.checkpoint();
613
614            // Wrong order: committing outer while inner is still active
615            outer.commit();
616        });
617    }
618
619    #[test]
620    fn test_checkpoint_noop_pre_t1c() {
621        let mut storage = HashMapStorageProvider::new(1); // default = T0
622        let addr = Address::ZERO;
623        let key = U256::from(1);
624
625        StorageCtx::enter(&mut storage, || {
626            let mut ctx = StorageCtx;
627
628            ctx.sstore(addr, key, U256::from(42)).unwrap();
629            {
630                let _guard = ctx.checkpoint(); // no-op pre-T1C
631                ctx.sstore(addr, key, U256::from(99)).unwrap();
632                // drop does nothing — no checkpoint was created
633            }
634            // state is NOT reverted because checkpoints are disabled pre-T1C
635            assert_eq!(ctx.sload(addr, key).unwrap(), U256::from(99));
636        });
637    }
638}