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}