Skip to main content

tempo_precompiles/storage_credits/
accounting.rs

1//! Backend-agnostic TIP-1060 SSTORE storage credits accounting.
2//!
3//! [`sstore_storage_credits`] implements the storage credits policy that runs
4//! after a storage slot is written. It is driven through the [`StorageCreditsBackend`]
5//! trait so the exact same logic can be reused from two places:
6//!
7//! - the opcode-level SSTORE hook in `tempo-revm` (`TempoGasState`), and
8//! - [`EvmPrecompileStorageProvider`](crate::storage::evm::EvmPrecompileStorageProvider)
9//!   so precompile-driven storage writes honor the same accounting.
10
11use super::{CreditMode, StorageCredits, TransientState};
12use crate::storage::FromWord;
13use alloy::primitives::{Address, U256};
14use revm::{
15    context_interface::cfg::GasParams,
16    interpreter::{InstructionResult, SStoreResult, StateLoad, gas::GasTracker},
17};
18use tempo_chainspec::constants::gas::STORAGE_CREDIT_VALUE;
19use tempo_contracts::precompiles::STORAGE_CREDITS_ADDRESS;
20
21/// Error mapping required by storage credit accounting.
22pub trait StorageCreditsErr: Sized {
23    fn out_of_gas() -> Self;
24    fn fatal_external() -> Self;
25}
26
27impl StorageCreditsErr for InstructionResult {
28    fn out_of_gas() -> Self {
29        Self::OutOfGas
30    }
31
32    fn fatal_external() -> Self {
33        Self::FatalExternalError
34    }
35}
36
37/// Minimal journal/gas operations required by storage credit accounting.
38pub trait StorageCreditsBackend {
39    type Error: StorageCreditsErr;
40
41    /// Gas parameters for the active spec.
42    fn gas_params(&self) -> &GasParams;
43
44    /// Gas tracker for the active execution context.
45    fn gas_tracker(&mut self) -> &mut GasTracker;
46
47    /// Charges `cost` regular gas, returning [`out_of_gas`](StorageCreditsErr::out_of_gas) if insufficient.
48    #[inline]
49    fn charge_gas(&mut self, cost: u64) -> Result<(), Self::Error> {
50        self.gas_tracker()
51            .record_regular_cost(cost)
52            .then_some(())
53            .ok_or_else(Self::Error::out_of_gas)
54    }
55
56    /// SLOAD `address[key]`, optionally skipping the cold load.
57    fn sload(
58        &mut self,
59        address: Address,
60        key: U256,
61        skip_cold_load: bool,
62    ) -> Result<StateLoad<U256>, Self::Error>;
63
64    /// SSTORE `address[key]`.
65    fn sstore(
66        &mut self,
67        address: Address,
68        key: U256,
69        value: U256,
70        skip_cold_load: bool,
71    ) -> Result<StateLoad<SStoreResult>, Self::Error>;
72
73    /// TLOAD `address[key]`.
74    fn tload(&mut self, address: Address, key: U256) -> U256;
75
76    /// TSTORE `address[key] = value`.
77    fn tstore(&mut self, address: Address, key: U256, value: U256);
78
79    /// Returns whether x→0 storage clears should mint a persistent storage credit.
80    #[inline]
81    fn tip1060_storage_credit_minting_enabled(&self) -> bool {
82        true
83    }
84}
85
86#[inline]
87fn store_credit_state<B: StorageCreditsBackend>(
88    backend: &mut B,
89    key: U256,
90    state: TransientState,
91) -> Result<(), B::Error> {
92    backend.tstore(STORAGE_CREDITS_ADDRESS, key, state.into());
93    Ok(())
94}
95
96/// Applies TIP-1060 storage credits after a single SSTORE has been journaled.
97///
98/// Returns whether to skip normal dynamic/state gas and/or refund accounting.
99pub fn sstore_storage_credits<B: StorageCreditsBackend>(
100    backend: &mut B,
101    owner: Address,
102    caller_state_load: &StateLoad<SStoreResult>,
103) -> Result<(), B::Error> {
104    let values = &caller_state_load.data;
105
106    // Only account for storage credits when the slot crosses the zero boundary (x→0 or 0→x).
107    // If both values are zero or non-zero, slot occupancy is unchanged, so skip credits accounting.
108    if values.is_present_zero() == values.is_new_zero() {
109        return Ok(());
110    }
111
112    // Storage-credit precompile state is used for protocol bookkeeping. Because of that,
113    // always skips TIP-1000 + TIP-1060 self-accounting and charge only update gas.
114    if owner == STORAGE_CREDITS_ADDRESS {
115        return Ok(());
116    }
117
118    // Load the persistent storage credit balance for the storage-owning account.
119    let warm_storage_read_cost = backend.gas_params().warm_storage_read_cost();
120    backend.charge_gas(warm_storage_read_cost)?;
121
122    let account_slot = StorageCredits::slot(owner);
123    let additional_cold_cost = backend.gas_params().cold_storage_additional_cost();
124    let skip_cold = backend.gas_tracker().remaining() < additional_cold_cost;
125    let storage_credit_state_load =
126        backend.sload(STORAGE_CREDITS_ADDRESS, account_slot, skip_cold)?;
127    if storage_credit_state_load.is_cold {
128        backend.charge_gas(additional_cold_cost)?;
129    }
130
131    let mut credit =
132        u64::from_word(storage_credit_state_load.data).map_err(|_| B::Error::fatal_external())?;
133
134    let mut was_changed = false;
135    if values.is_new_zero() {
136        // x→0: storage deletion always mints a new credit.
137        if backend.tip1060_storage_credit_minting_enabled() {
138            credit = credit.saturating_add(1);
139            was_changed = true;
140        }
141    } else {
142        // 0→x: storage creation.
143        // This hook manages the 245k creditable gas, independent of the original value.
144        // revm's SSTORE function adds the 5k residual for clean writes (`original == present == 0`).
145        let mut transient_state: TransientState = backend
146            .tload(STORAGE_CREDITS_ADDRESS, account_slot)
147            .try_into()
148            .map_err(|_| B::Error::fatal_external())?;
149
150        match transient_state.mode {
151            CreditMode::Direct if credit > 0 && transient_state.budget > 0 => {
152                // Use one to cover the 245k creditable portion.
153                credit -= 1;
154                was_changed = true;
155
156                // An unlimited budget is never decremented.
157                if transient_state.budget != u64::MAX {
158                    transient_state.budget -= 1;
159                    store_credit_state(backend, account_slot, transient_state)?;
160                }
161            }
162            CreditMode::Direct | CreditMode::Preserve => {
163                // Direct without spendable credits, or Preserve, pays the creditable portion as gas.
164                backend.charge_gas(STORAGE_CREDIT_VALUE)?;
165            }
166            CreditMode::Refund => {
167                // Charge the 245k creditable portion upfront and record a pending refund-eligible
168                // creation, settled at end-of-transaction.
169                backend.charge_gas(STORAGE_CREDIT_VALUE)?;
170                transient_state.pending_refunds = transient_state.pending_refunds.saturating_add(1);
171                store_credit_state(backend, account_slot, transient_state)?;
172            }
173        }
174    }
175
176    if was_changed {
177        // Cold load is already checked above when we loaded the storage credits account.
178        let result = backend
179            .sstore(
180                STORAGE_CREDITS_ADDRESS,
181                account_slot,
182                U256::from(credit),
183                false,
184            )?
185            .data;
186
187        // Only when change happens charge additional gas.
188        if result.new_values_changes_present() && result.is_original_eq_present() {
189            backend.charge_gas(backend.gas_params().sstore_reset_without_cold_load_cost())?;
190        };
191    }
192
193    Ok(())
194}