May 14, 2025

Solana: The hidden dangers of lamport transfers

Solana’s lamport transfer logic hides dangerous edge cases — from rent-exemption quirks to write-demotion traps. We dissect a deceptively simple smart contract game to expose how transfers to arbitrary accounts can silently fail, brick your program, or crown an eternal king.

Heading image of Solana: The hidden dangers of lamport transfers

Introduction

Is it safe to transfer lamports to an arbitrary address on Solana? The answer might surprise you.

In this post, we explore a deceptively simple smart contract game inspired by King of the Ether. Through it, we’ll highlight subtle pitfalls in Solana’s account model that can brick your program — especially when it comes to transferring lamports.

The Game: King of the SOL

The game works like this:

  • Anyone can become the king by bidding at least 2× the previous bid.
  • The old king is reimbursed 95% of their bid.
  • The remaining 5% goes into a prize pot.
  • If the reigning king survives for 10 days without being dethroned, they can claim the entire pot.

Simple, right?

This is the core logic:

#[derive(Accounts)]
pub struct ChangeKing<'info> {
    #[account(mut)]
    pub throne: Account<'info, Throne>,

    /// CHECK: old_king gets a 95% refund, so ensure its writable.
    #[account(mut, constraint = old_king.key() == throne.king)]
    pub old_king: AccountInfo<'info>,

    /// CHECK: any writable account is allowed as a new king.
    #[account(mut)]
    pub new_king: AccountInfo<'info>,

    #[account(mut)]
    pub payer: Signer<'info>,
}

#[program]
pub mod king_of_the_sol {
    pub fn change_king(ctx: Context<ChangeKing>, bid_amount: u64) -> Result<()> {
        // Check that bid_amount is at least 2x last_bid_amount
        assert!(bid_amount >= ctx.accounts.throne.last_bid_amount * 2);
        transfer_from_signer(
            &ctx.accounts.payer,
            &ctx.accounts.throne.to_account_info(),
            bid_amount,
        )?;

        // Reimburse 95% of the last bid to the old king
        let to_reimburse = (ctx.accounts.throne.last_bid_amount * 9500) / 10000;
        transfer_from_pda(
            &ctx.accounts.throne.to_account_info(),
            &ctx.accounts.old_king,
            to_reimburse,
        )?;

        // Set new king
        ctx.accounts.throne.king = ctx.accounts.new_king.key();
        ctx.accounts.throne.last_bid_amount = bid_amount;
        ctx.accounts.throne.last_time = Clock::get()?.unix_timestamp as u64;

        Ok(())
    }
}

Note this comment:

any writable account is allowed as a new king.

...Is our assumption correct?

The Bugs Lurking Beneath

Bug 1: The Rent-Exemption Trap

On Solana, all accounts must maintain a minimum balance of lamports to remain rent-exempt. Specifically, an account can be in one of two states:

  • Uninitialized: lamports = 0
  • Initialized: lamports >= rent-exempt threshold

This rent model exists to prevent low-cost DoS attacks on validators. The key idea is that even an account with no data (i.e., zero-length data buffer) still consumes on-chain resources; specifically, account metadata like its public key, owner, or lamport balance. That metadata must be stored persistently by validators, and that storage isn't free.

So “persistent state” on Solana doesn’t just mean your program's data — it includes the base account structure itself. Even accounts with data.len() == 0 must meet a minimum rent threshold to remain alive and avoid garbage collection by the runtime.

This is enforced at the runtime level, and the relevant logic can be found here.

    fn transition_allowed(&self, pre_rent_state: &RentState, post_rent_state: &RentState) -> bool {
        match post_rent_state {
            RentState::Uninitialized | RentState::RentExempt => true,
            RentState::RentPaying {
                data_size: post_data_size,
                lamports: post_lamports,
            } => {
                match pre_rent_state {
                    RentState::Uninitialized | RentState::RentExempt => false,
                    RentState::RentPaying {
                        data_size: pre_data_size,
                        lamports: pre_lamports,
                    } => {
                        // Cannot remain RentPaying if resized or credited.
                        post_data_size == pre_data_size && post_lamports <= pre_lamports
                    }
                }
            }
        }
    }

You can check the rent-exemption threshold for a zero-data account with the CLI:

solana rent 0
Rent-exempt minimum: 0.00089088 SOL

Fix 1: Only Reimburse if Rent-Exempt

We don't want to donate anything to an unfair king! So let's update our program to reimburse only if the old king will be rent-exempt after the transfer:

let to_reimburse = (ctx.accounts.throne.last_bid_amount * 9500) / 10000;
+let rent = Rent::get()?;
+let balance_after = ctx.accounts.old_king.lamports() + to_reimburse;
+if rent.is_exempt(balance_after, ctx.accounts.old_king.data_len()) {
    transfer_from_pda(
        &ctx.accounts.throne.to_account_info(),
        &ctx.accounts.old_king,
        to_reimburse,
    )?;
+}

But is rent-exemption the only thing that can cause a lamport transfer to fail? Not quite.

Bug 2: Writable but Untouchable — set_lamports Fails

Let's look at BorrowedAccount::set_lamports.

/// Overwrites the number of lamports of this account (transaction wide)
#[cfg(not(target_os = "solana"))]
pub fn set_lamports(&mut self, lamports: u64) -> Result<(), InstructionError> {
    // An account not owned by the program cannot have its balance decrease
    if !self.is_owned_by_current_program() && lamports < self.get_lamports() {
        return Err(InstructionError::ExternalAccountLamportSpend);
    }
    // The balance of read-only may not change
    if !self.is_writable() {
        return Err(InstructionError::ReadonlyLamportChange);
    }
    // The balance of executable accounts may not change
    if self.is_executable_internal() {
        return Err(InstructionError::ExecutableLamportChange);
    }
    // don't touch the account if the lamports do not change
    if self.get_lamports() == lamports {
        return Ok(());
    }
    self.touch()?;
    self.account.set_lamports(lamports);
    Ok(())
}

/// Feature gating to remove `is_executable` flag related checks
#[cfg(not(target_os = "solana"))]
#[inline]
fn is_executable_internal(&self) -> bool {
    !self
        .transaction_context
        .remove_accounts_executable_flag_checks
        && self.account.executable()
}

Turns out: even writable, rent-exempt accounts can still reject lamport transfers.

Specifically, executable accounts cannot receive or send lamports — the runtime treats them as immutable.

The executable flag is a legacy mechanism marking accounts that hold program code. Historically, an account with this flag was assumed to either contain immutable BPF bytecode or was a proxy to a built-in program, and therefore it made sense to consider it read-only for performance reasons.

This behavior became problematic with the introduction of the Upgradeable BPF Loader. A workaround was used to maintain compatibility with the existing runtime logic. The program data containing bpf bytecode was split into a separate account, ProgramData, with the program account now only containing an address pointing to the ProgramData account:

Program {
    /// Address of the ProgramData account.
    programdata_address: Pubkey,
},
ProgramData {
    /// Slot that the program was last modified.
    slot: u64,
    /// Address of the Program's upgrade authority.
    upgrade_authority_address: Option<Pubkey>,
    // The raw program data follows this serialized structure in the
    // account's data.
},

Eventually, the executable flag will be removed entirely as proposed in SIMD-0162. The reasoning is simple: an account's owner and its content are sufficient to determine if it's a valid program — the executable flag is redundant.

This change is also a hard requirement for supporting the new loader-v4. Unlike the upgradable loader, which relies on a separate ProgramData proxy account, loader-v4 stores all program data directly in the program account itself.

As a result, it becomes impossible to modify the account's size after deployment, or to migrate from the upgradable loader to loader-v4 — without hitting the ExecutableLamportChange restriction.

Fix 2: Reject Program Accounts

To avoid this footgun, let’s explicitly skip any executable account:

pub fn can_transfer_lamports(account: &AccountInfo, lamports: u64) -> Result<bool> {
fn is_program(account: &AccountInfo) -> bool {
    account.executable
}
let rent = Rent::get()?;
let balance_after = account.lamports() + lamports;
Ok(account.is_writable
    && rent.is_exempt(balance_after, account.data_len())
    && !is_program(account))
}

Now we’re safe...right?

Bug 3: The Write-Demotion Trap

On Solana, accounts passed as writable in a transaction can be silently downgraded to read-only. This behavior occurs during message sanitization — even before your program runs.

Let’s walk through the logic for legacy messages (note: the same rules apply to MessageV0, but legacy is simpler to follow):

// https://github.com/anza-xyz/solana-sdk/blob/master/message/src/sanitized.rs#L39-L55
impl LegacyMessage<'_> {
    pub fn new(message: legacy::Message, reserved_account_keys: &HashSet<Pubkey>) -> Self {
        let is_writable_account_cache = message
            .account_keys
            .iter()
            .enumerate()
            .map(|(i, _key)| {
                message.is_writable_index(i)
                    && !reserved_account_keys.contains(&message.account_keys[i])
                    && !message.demote_program_id(i)
            })
            .collect::<Vec<_>>();
        Self {
            message: Cow::Owned(message),
            is_writable_account_cache,
        }
    }
}

// https://github.com/anza-xyz/solana-sdk/blob/master/message/src/legacy.rs#L642-L644
pub fn demote_program_id(&self, i: usize) -> bool {
    self.is_key_called_as_program(i) && !self.is_upgradeable_loader_present()
}

As you can see, there are two main causes of write-demotion:

  1. The account appears in the reserved account list
  2. The account is invoked as a program without the upgradable loader being present in the transaction.

The second case is generally covered by the executable check implemented previously.

The first case, however, is far more dangerous — it can silently break your program logic without any obvious cause. Let’s dig deeper into that.

The Reserved Account List

The Solana runtime maintains a reserved account list, which includes addresses with special semantics — such as built-in programs, precompiles, and sysvars.

These accounts may initially behave like normal accounts. However, once they become reserved after a feature gate is actived, the runtime will automatically demote them to read-only, even if the transaction marked them as writable.

// https://github.com/anza-xyz/agave/blob/0e6d9bf8c81cd94dfdedb500af4ac17328cf7a43/runtime/src/bank.rs#L6469-L6474
// Update active set of reserved account keys which are not allowed to be write locked
self.reserved_account_keys = {
    let mut reserved_keys = ReservedAccountKeys::clone(&self.reserved_account_keys);
    reserved_keys.update_active_set(&self.feature_set);
    Arc::new(reserved_keys)
};

Consequences: Silent Failures and Bricked Programs

This behavior is especially dangerous when you constrain a program to be writable, for example, with anchor, it's pretty common to use the account(mut) constraint:

#[derive(Accounts)]
pub struct ChangeKing<'info> {
    #[account(mut)]
    pub throne: Account<'info, Throne>,

    #[account(mut, constraint = old_king.key() == throne.king)]
    pub old_king: AccountInfo<'info>,

    #[account(mut)]
    pub new_king: AccountInfo<'info>,

    #[account(mut)]
    pub payer: Signer<'info>,
}

This works fine — until one day, old_king is silently demoted. Suddenly, the #[account(mut)] constraint fails, and your program is bricked. Even though you're passing a writable account in the transaction, the runtime has made a unilateral decision to override that.

Real-World Example: Write-Demotion with secp256r1_program

Here’s a concrete example of the write-demotion trap playing out on mainnet — involving secp256r1_program, a precompiled program gated behind a feature flag:

ReservedAccount::new_pending(
    secp256r1_program::id(),
    feature_set::enable_secp256r1_precompile::id(),
)

Before the enable_secp256r1_precompile feature is activated, this account behaves like any ordinary one. You can assign secp256r1_program::id() as the king in a contract.

But once the feature is flipped on, the runtime silently marks it as read-only, blocking any future writes. As a result, secp256r1_program::id() becomes the eternal king, and no one can dethrone it.

Fix 3: Preventing Write-Demotion Pitfalls

Alright, let’s try to fix this yet another edge case — and hopefully close the book on it.

Attempt 1: Block Known Reserved Accounts

One naive solution is to reject any known reserved account, for example:

    pub fn change_king(ctx: Context<ChangeKing>, bid_amount: u64) -> Result<()> {
+       assert!(ctx.accounts.new_king.key() != secp256r1_program::id());

This works in the short term, but doesn’t scale — you can’t predict all future additions to the ReservedAccount list. The moment a new reserved account is introduced, your program becomes vulnerable again.

Attempt 2: Use a PDA Vault

A more future-proof fix is to avoid transferring lamports to arbitrary accounts altogether.

A clean approach would be to store the refund lamports in a PDA vault owned by your program. This prevents your logic from depending on accounts you don’t have complete control over, and sidesteps any risk of write-demotion or future account restrictions.

Final Thoughts

Transferring lamports on Solana is not always straightforward and carries potential risks. Account constraints alone are insufficient to ensure safety, especially when dealing with runtime-specific edge cases.

We can safely transfer lamports to an account under the following conditions:

  • It's not executable.
  • Its balance, after the transfer, remains rent-exempt.
  • It's not a reserved account.

This issue is not purely theoretical; it has impacted real-world programs. One significant case was recently reported to Jito via the bug bounty, which could have resulted in incorrect tip payments.