Severity: HIGH
This example demonstrates the Missing Rent Exemption Check vulnerability, where withdrawing lamports without verifying the remaining balance stays above rent-exempt minimum can cause accounts to be garbage collected.
Solana accounts must maintain a minimum balance (rent-exempt threshold) to persist on-chain. If an account's lamports fall below this threshold:
- The account becomes eligible for garbage collection
- The runtime may purge the account
- All account data is permanently lost
- Any remaining lamports are lost
This vulnerability can lead to:
- Lost funds - Remaining lamports in the account are gone
- Lost data - All account state is wiped
- Protocol breakage - Critical accounts disappear
- User confusion - Accounts vanish unexpectedly
- How Solana's rent system works
- Why rent-exemption is important
- How to calculate rent-exempt minimum
- Properly checking before withdrawing lamports
initialize_vault- Creates a vault accountdeposit- Deposits lamports into the vaultwithdraw_vulnerable- Withdraws without rent check (VULNERABLE)withdraw_secure- Withdraws with rent check (SECURE)
Located in: src/instructions/withdraw_vulnerable.rs
pub fn handler(ctx: Context<WithdrawVulnerable>, amount: u64) -> Result<()> {
let vault_info = ctx.accounts.vault.to_account_info();
let vault_balance = vault_info.lamports();
// ⚠️ VULNERABLE: Only checks if we have enough lamports
// Does NOT check if remaining balance stays rent-exempt!
require!(
vault_balance >= amount,
ErrorCode::InsufficientFunds
);
// Transfer lamports out
**vault_info.try_borrow_mut_lamports()? -= amount;
**ctx.accounts.owner.try_borrow_mut_lamports()? += amount;
// ⚠️ If remaining < rent_exempt_minimum, account could be PURGED!
Ok(())
}ACCOUNT PURGE SCENARIO
======================
Initial State:
┌─────────────────────────────────────────┐
│ Vault Account │
│ ├─ lamports: 1,500,000 (0.0015 SOL) │
│ ├─ data: [owner, bump, ...] │
│ └─ rent_exempt_min: 890,880 │
└─────────────────────────────────────────┘
User calls withdraw_vulnerable(1,000,000):
┌─────────────────────────────────────────┐
│ Check: 1,500,000 >= 1,000,000? ✓ │
│ Withdraw executes... │
│ │
│ NEW STATE: │
│ ├─ lamports: 500,000 │
│ ├─ rent_exempt_min: 890,880 │
│ └─ 500,000 < 890,880 ❌ │
└─────────────────────────────────────────┘
│
│ End of transaction / epoch boundary
▼
┌─────────────────────────────────────────┐
│ Runtime Garbage Collection: │
│ │
│ Account lamports < rent exempt? │
│ 500,000 < 890,880? YES │
│ │
│ ⚠️ ACCOUNT PURGED! │
│ ├─ 500,000 lamports: LOST │
│ └─ All account data: LOST │
└─────────────────────────────────────────┘
Located in: src/instructions/withdraw_secure.rs
pub fn handler(ctx: Context<WithdrawSecure>, amount: u64) -> Result<()> {
let vault_info = ctx.accounts.vault.to_account_info();
let vault_balance = vault_info.lamports();
// ✅ SECURE: Calculate rent-exempt minimum for this account
let rent = Rent::get()?;
let rent_exempt_minimum = rent.minimum_balance(vault_info.data_len());
// ✅ SECURE: Calculate maximum withdrawable amount
let max_withdrawable = vault_balance
.checked_sub(rent_exempt_minimum)
.unwrap_or(0);
// ✅ SECURE: Check that withdrawal won't violate rent exemption
require!(
amount <= max_withdrawable,
ErrorCode::WouldViolateRentExemption
);
// Safe to transfer
**vault_info.try_borrow_mut_lamports()? -= amount;
**ctx.accounts.owner.try_borrow_mut_lamports()? += amount;
Ok(())
}| Aspect | Vulnerable | Secure |
|---|---|---|
| Checks balance | Only total | Against rent minimum |
| Allows draining | Yes | No (protects minimum) |
| Account persists | Maybe not | Always |
| Data safety | At risk | Protected |
// Get the Rent sysvar
let rent = Rent::get()?;
// Calculate minimum for account's data size
let minimum = rent.minimum_balance(account.data_len());
// Typical values (as of 2024):
// - Base: ~890,880 lamports for 0 bytes
// - Per byte: ~6,960 lamports
// - 100 byte account: ~1,586,880 lamports// ✅ Always calculate max withdrawable
let max_withdrawable = current_balance
.saturating_sub(rent_exempt_minimum);
// ✅ Validate before withdrawing
require!(amount <= max_withdrawable, Error);
// ✅ Or provide a "withdraw_max" function
pub fn withdraw_max(ctx: Context<Withdraw>) -> Result<()> {
let rent = Rent::get()?;
let minimum = rent.minimum_balance(data_len);
let withdrawable = balance.saturating_sub(minimum);
// Transfer withdrawable amount
}| Aspect | Vulnerable | Secure |
|---|---|---|
| Rent check | None | Rent::get()?.minimum_balance() |
| Max withdrawal | Full balance | Balance - rent_exempt |
| Account fate | May be purged | Always persists |
| Data safety | At risk | Protected |
If you want to fully drain an account, use Anchor's close constraint:
#[account(mut, close = recipient)]
pub account_to_close: Account<'info, MyAccount>,If account size can change, recalculate rent minimum:
// After resizing
account.realloc(new_size, false)?;
let new_minimum = rent.minimum_balance(new_size);Modern Solana requires rent-exemption for all accounts. The rent-paying model is deprecated.
- Withdraw lamports without checking rent exemption
- Assume accounts will persist after draining
- Forget that remaining lamports can be lost
- Always calculate
rent.minimum_balance(data_len) - Check
withdrawal <= balance - rent_minimum - Use Anchor's
closeconstraint to fully drain accounts - Provide clear error messages about rent limits
cd 10-missing-rent-check
anchor buildRemember: Always check rent exemption before withdrawing lamports. Accounts below the threshold can be garbage collected, losing both data and remaining funds!