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        let rent = hopper_native::sysvar::get_rent()?;
75        Ok(rent.minimum_balance(data_len))
76    }
77    #[cfg(not(target_os = "solana"))]
78    {
79        Ok(minimum_balance(data_len))
80    }
81}
82
83/// Assert that `account` holds enough lamports to be rent-exempt for
84/// its current data length. Used by the `#[account(rent_exempt =
85/// enforce)]` constraint lowering in `hopper-derive`.
86///
87/// Returns `ProgramError::AccountNotRentExempt` on underrun (builtin
88/// index 14 in Hopper's error ABI, matching Solana's canonical
89/// `AccountNotRentExempt` code).
90#[inline]
91pub fn check_rent_exempt(account: &AccountView<'_>) -> ProgramResult {
92    let data_len = account.data_len();
93    let required = minimum_balance_live(data_len)?;
94    if account.lamports() >= required {
95        Ok(())
96    } else {
97        Err(ProgramError::AccountNotRentExempt)
98    }
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104
105    #[test]
106    fn minimum_balance_matches_launch_snapshot() {
107        // Historical empty-account minimum before SIMD-0437:
108        // (0 + 128) * 3480 * 2 = 890,880 lamports.
109        assert_eq!(minimum_balance(0), 890_880);
110    }
111
112    #[test]
113    fn minimum_balance_scales_linearly() {
114        let base = minimum_balance(0);
115        let with_100 = minimum_balance(100);
116        let with_200 = minimum_balance(200);
117        // Adding 100 bytes adds 100 * 3480 * 2 = 696_000 lamports.
118        assert_eq!(with_100 - base, 696_000);
119        assert_eq!(with_200 - with_100, 696_000);
120    }
121
122    #[test]
123    fn minimum_balance_on_typical_vault_state() {
124        // 56-byte account (16-byte Hopper header + 40-byte body, as
125        // used by the parity vault and the transfer-hook vault).
126        // (56 + 128) * 3480 * 2 = 1_280_640 lamports = ~0.00128 SOL.
127        assert_eq!(minimum_balance(56), 1_280_640);
128    }
129
130    #[test]
131    fn host_live_minimum_uses_the_documented_snapshot() {
132        assert_eq!(minimum_balance_live(56), Ok(minimum_balance(56)));
133    }
134}