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 function computing the rent-exempt
12//! threshold for a given `data_len`, using the cluster constants
13//! that have been in effect on Solana mainnet since launch
14//! (`lamports_per_byte_year = 3480`, `exemption_threshold = 2 years`,
15//! `account_storage_overhead = 128 bytes`). These values are
16//! governed on-chain but have never been changed. If the cluster
17//! ever re-governs them, the check will be conservative
18//! (strictly requiring at least the pre-governance threshold) —
19//! still safe, just not tight.
20//!
21//! 2. [`check_rent_exempt`] — the runtime guard backing the
22//! `#[account(rent_exempt = enforce)]` field keyword emitted by
23//! `#[hopper::context]`. Compares `account.lamports()` to
24//! `minimum_balance(account.data_len())` and returns
25//! `ProgramError::AccountNotRentExempt` (Solana's canonical error
26//! code, routed through `ProgramError::Custom`) on failure.
27//!
28//! ## Why not use `sol_get_rent_sysvar`?
29//!
30//! The syscall is ~100 CU and returns the same values this module
31//! hard-codes. Using the constants inline lets the check run at
32//! zero additional CU beyond the comparison. Programs that need to
33//! read the live Rent sysvar for other reasons (rent-collection
34//! scheduling, etc.) can still invoke the syscall directly; this
35//! helper is specifically for the rent-exemption gate where the
36//! constants suffice.
37
38use crate::account::AccountView;
39use crate::error::ProgramError;
40use crate::ProgramResult;
41
42/// Lamports charged per byte of account storage per year.
43///
44/// Fixed at 3480 since Solana mainnet launch and unchanged through
45/// 2026. The value is governed on-chain via the Rent sysvar but no
46/// cluster vote has ever modified it.
47pub const LAMPORTS_PER_BYTE_YEAR: u64 = 3_480;
48
49/// Years of rent an account must prepay to be exempt.
50///
51/// Fixed at 2.0 since launch. Represented as an integer here because
52/// the multiplication always lands on an integer result for the
53/// given `LAMPORTS_PER_BYTE_YEAR`.
54pub const EXEMPTION_THRESHOLD_YEARS: u64 = 2;
55
56/// Fixed per-account storage overhead the cluster charges on top of
57/// user data. 128 bytes (header + metadata).
58pub const ACCOUNT_STORAGE_OVERHEAD: u64 = 128;
59
60/// Minimum lamport balance for an account with `data_len` bytes of
61/// data to be rent-exempt under the current Solana cluster constants.
62///
63/// `(data_len + 128) * 3480 * 2` — constant-folded at the call site
64/// when `data_len` is a `const`.
65#[inline]
66pub const fn minimum_balance(data_len: usize) -> u64 {
67 (data_len as u64 + ACCOUNT_STORAGE_OVERHEAD)
68 * LAMPORTS_PER_BYTE_YEAR
69 * EXEMPTION_THRESHOLD_YEARS
70}
71
72/// Assert that `account` holds enough lamports to be rent-exempt for
73/// its current data length. Used by the `#[account(rent_exempt =
74/// enforce)]` constraint lowering in `hopper-macros-proc`.
75///
76/// Returns `ProgramError::AccountNotRentExempt` on underrun. The error
77/// code maps to Solana's canonical `InstructionError::RentEpoch`
78/// (built-in 29) when surfaced through the runtime.
79#[inline]
80pub fn check_rent_exempt(account: &AccountView) -> ProgramResult {
81 let data_len = account.data_len();
82 let required = minimum_balance(data_len);
83 if account.lamports() >= required {
84 Ok(())
85 } else {
86 Err(ProgramError::AccountNotRentExempt)
87 }
88}
89
90#[cfg(test)]
91mod tests {
92 use super::*;
93
94 #[test]
95 fn minimum_balance_matches_mainnet_constants() {
96 // A 0-byte account: (0 + 128) * 3480 * 2 = 890,880 lamports.
97 // This is the well-known "empty account rent-exempt minimum"
98 // every Solana developer internalises; the constant must
99 // match.
100 assert_eq!(minimum_balance(0), 890_880);
101 }
102
103 #[test]
104 fn minimum_balance_scales_linearly() {
105 let base = minimum_balance(0);
106 let with_100 = minimum_balance(100);
107 let with_200 = minimum_balance(200);
108 // Adding 100 bytes adds 100 * 3480 * 2 = 696_000 lamports.
109 assert_eq!(with_100 - base, 696_000);
110 assert_eq!(with_200 - with_100, 696_000);
111 }
112
113 #[test]
114 fn minimum_balance_on_typical_vault_state() {
115 // 56-byte account (16-byte Hopper header + 40-byte body, as
116 // used by the parity vault and the transfer-hook vault).
117 // (56 + 128) * 3480 * 2 = 1_280_640 lamports = ~0.00128 SOL.
118 assert_eq!(minimum_balance(56), 1_280_640);
119 }
120}