Skip to main content

hopper_runtime/
rent.rs

1//! Rent-exemption helpers.
2//!
3//! Solana's rent model charges accounts for storage on a per-byte-year
4//! basis. An account that holds at least
5//! `(data_len + ACCOUNT_STORAGE_OVERHEAD) * LAMPORTS_PER_BYTE_YEAR *
6//! EXEMPTION_THRESHOLD` lamports is *rent-exempt* and never loses
7//! balance to rent collection.
8//!
9//! This module exposes two things:
10//!
11//! 1. [`minimum_balance`] - a pure snapshot calculation using the launch-era
12//!    constants (`lamports_per_byte_year = 3480`, `exemption_threshold = 2
13//!    years`, `account_storage_overhead = 128 bytes`). It is useful for host
14//!    tests and fixed-config calculations, but is not authoritative after an
15//!    on-chain rent reprice.
16//!
17//! 2. [`check_rent_exempt`] - the runtime guard backing the
18//!    `#[account(rent_exempt = enforce)]` field keyword emitted by
19//!    `#[hopper::context]`. Compares `account.lamports()` to the live Rent
20//!    sysvar minimum and returns
21//!    `ProgramError::AccountNotRentExempt` (a builtin variant mapping
22//!    to Solana's canonical code) on failure.
23//!
24//! The enforcement path deliberately reads `sol_get_rent_sysvar`. Rent is a
25//! runtime-owned parameter, so a safety gate must fail closed if that read
26//! fails rather than accepting an account against stale constants.
27
28use crate::account::AccountView;
29use crate::error::ProgramError;
30use crate::ProgramResult;
31
32/// Lamports charged per byte of account storage per year.
33///
34/// Launch-era snapshot. SIMD-0194 moved the full effective price into the
35/// first Rent-sysvar field, and SIMD-0437 began repricing it in September
36/// 2026. Runtime decisions must use [`minimum_balance_live`].
37pub const LAMPORTS_PER_BYTE_YEAR: u64 = 3_480;
38
39/// Years of rent an account must prepay to be exempt.
40///
41/// Launch-era snapshot. SIMD-0194 deprecated the threshold and changed its
42/// live wire marker to `1.0`; this constant exists only for the paired legacy
43/// calculation below.
44pub const EXEMPTION_THRESHOLD_YEARS: u64 = 2;
45
46/// Fixed per-account storage overhead the cluster charges on top of
47/// user data. 128 bytes (header + metadata).
48pub const ACCOUNT_STORAGE_OVERHEAD: u64 = 128;
49
50/// Minimum lamport balance for an account with `data_len` bytes of data under
51/// Solana's launch-era rent snapshot.
52///
53/// `(data_len + 128) * 3480 * 2` - constant-folded at the call site
54/// when `data_len` is a `const`.
55#[inline]
56pub const fn minimum_balance(data_len: usize) -> u64 {
57    (data_len as u64 + ACCOUNT_STORAGE_OVERHEAD)
58        * LAMPORTS_PER_BYTE_YEAR
59        * EXEMPTION_THRESHOLD_YEARS
60}
61
62/// Rent-exempt minimum read from the **live** Rent sysvar on-chain.
63/// Host tests use the compile-time snapshot because no runtime sysvar exists.
64///
65/// Use this for value-bearing decisions, funding a new account, the
66/// realloc top-up; so that if the cluster ever re-governs the rent
67/// parameters, Hopper charges the live amount rather than a stale
68/// hard-coded one. An on-chain sysvar read failure is returned to the caller;
69/// value-bearing checks must not silently fall back to stale constants.
70#[inline]
71pub fn minimum_balance_live(data_len: usize) -> Result<u64, ProgramError> {
72    #[cfg(target_os = "solana")]
73    {
74        Ok(live_rent()?.minimum_balance(data_len))
75    }
76    #[cfg(not(target_os = "solana"))]
77    {
78        Ok(minimum_balance(data_len))
79    }
80}
81
82/// Per-invocation cache of the Rent sysvar, in the reserved heap scratch
83/// (`hopper_native::RENT_CACHE_HEAP_OFFSET`). All-zero is the empty cache,
84/// which is what the VM's zeroed heap gives every invocation; a CPI callee
85/// runs in its own VM with its own heap, so no state crosses frames.
86#[cfg(target_os = "solana")]
87#[repr(C)]
88struct RentCache {
89    /// Nonzero once loaded: the rate is the loaded flag, so the hot path is
90    /// one load and one branch. (A cluster whose rate is zero would read
91    /// the sysvar on every call, which is still correct.)
92    lamports_per_byte_year: u64,
93    threshold_bits: u64,
94    burn_percent: u64,
95    _spare: u64,
96}
97
98#[cfg(target_os = "solana")]
99const _: () = assert!(core::mem::size_of::<RentCache>() == hopper_native::RENT_CACHE_BYTES);
100
101/// The live Rent sysvar, read once per invocation.
102///
103/// The first call reads the sysvar (110 CU through `sol_get_sysvar`) and
104/// stores it in the reserved heap scratch; every later call in the same
105/// invocation is one load and a branch. An instruction that creates two
106/// accounts, tops one up, and checks another's exemption used to pay for
107/// four syscalls; it now pays for one. Off-chain this is the documented
108/// launch snapshot (3,480 lamports per byte-year at threshold 2.0), the
109/// same values [`minimum_balance`] uses.
110#[inline]
111pub fn live_rent() -> Result<hopper_native::sysvar::Rent, ProgramError> {
112    #[cfg(target_os = "solana")]
113    {
114        let cache = (hopper_native::HEAP_START_ADDRESS + hopper_native::RENT_CACHE_HEAP_OFFSET)
115            as *mut RentCache;
116        // SAFETY: SBF execution is single-threaded; the cache lies inside
117        // `HEAP_RUNTIME_RESERVED`, a range the bump allocator never hands
118        // out and that the gate store and touch log stop short of
119        // (const-asserted at their definitions); the VM zeroes the heap on
120        // every invocation and all-zero is the empty cache; the address is
121        // 8-aligned (a multiple of 32 above the 8-aligned heap start).
122        unsafe {
123            let rate = (*cache).lamports_per_byte_year;
124            if rate != 0 {
125                return Ok(hopper_native::sysvar::Rent {
126                    lamports_per_byte_year: rate,
127                    exemption_threshold: f64::from_bits((*cache).threshold_bits),
128                    burn_percent: (*cache).burn_percent as u8,
129                });
130            }
131            let rent = hopper_native::sysvar::get_rent()?;
132            (*cache).threshold_bits = rent.exemption_threshold.to_bits();
133            (*cache).burn_percent = rent.burn_percent as u64;
134            // The rate last: it is the loaded flag.
135            (*cache).lamports_per_byte_year = rent.lamports_per_byte_year;
136            Ok(rent)
137        }
138    }
139    #[cfg(not(target_os = "solana"))]
140    {
141        Ok(hopper_native::sysvar::Rent {
142            lamports_per_byte_year: LAMPORTS_PER_BYTE_YEAR,
143            exemption_threshold: EXEMPTION_THRESHOLD_YEARS as f64,
144            burn_percent: 50,
145        })
146    }
147}
148
149/// Assert that `account` holds enough lamports to be rent-exempt for
150/// its current data length. Used by the `#[account(rent_exempt =
151/// enforce)]` constraint lowering in `hopper-derive`.
152///
153/// Returns `ProgramError::AccountNotRentExempt` on underrun (builtin
154/// index 14 in Hopper's error ABI, matching Solana's canonical
155/// `AccountNotRentExempt` code).
156#[inline]
157pub fn check_rent_exempt(account: &AccountView<'_>) -> ProgramResult {
158    let data_len = account.data_len();
159    let required = minimum_balance_live(data_len)?;
160    if account.lamports() >= required {
161        Ok(())
162    } else {
163        Err(ProgramError::AccountNotRentExempt)
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    #[test]
172    fn minimum_balance_matches_launch_snapshot() {
173        // Historical empty-account minimum before SIMD-0437:
174        // (0 + 128) * 3480 * 2 = 890,880 lamports.
175        assert_eq!(minimum_balance(0), 890_880);
176    }
177
178    #[test]
179    fn minimum_balance_scales_linearly() {
180        let base = minimum_balance(0);
181        let with_100 = minimum_balance(100);
182        let with_200 = minimum_balance(200);
183        // Adding 100 bytes adds 100 * 3480 * 2 = 696_000 lamports.
184        assert_eq!(with_100 - base, 696_000);
185        assert_eq!(with_200 - with_100, 696_000);
186    }
187
188    #[test]
189    fn minimum_balance_on_typical_vault_state() {
190        // 56-byte account (16-byte Hopper header + 40-byte body, as
191        // used by the parity vault and the transfer-hook vault).
192        // (56 + 128) * 3480 * 2 = 1_280_640 lamports = ~0.00128 SOL.
193        assert_eq!(minimum_balance(56), 1_280_640);
194    }
195
196    #[test]
197    fn host_live_minimum_uses_the_documented_snapshot() {
198        assert_eq!(minimum_balance_live(56), Ok(minimum_balance(56)));
199    }
200}