Solana Smart Contracts: Common Pitfalls and How to Avoid Them

·

As the Solana ecosystem continues to expand at a rapid pace, developers are building increasingly sophisticated decentralized applications (dApps) powered by smart contracts. However, with innovation comes risk — and the unique architecture of Solana introduces specific security challenges that, if overlooked, can lead to catastrophic exploits.

In this guide, we’ll explore the five most common vulnerabilities found in Solana smart contracts, based on real-world audits conducted by seasoned security researchers. Each pitfall is explained with a clear description, code example, and practical solution — helping you write more secure, robust programs on Solana.

Whether you're a developer, auditor, or blockchain enthusiast, understanding these core issues is essential for contributing safely to the Solana ecosystem.

Missing Ownership Check

TL;DR

Always verify the AccountInfo::owner field for any account that should not be user-controlled. Use trusted types for verified accounts to prevent misuse.

Why It Matters

In Solana, every account has an owner — the program authorized to modify its data. Trusting an account without confirming its owner opens the door to spoofing attacks where malicious actors inject fake data structures.

For instance, a configuration account meant to store critical settings like admin addresses must be owned by your program. Otherwise, attackers can supply their own account with forged values, tricking your contract into executing unauthorized actions.

Example of the Vulnerability

Consider a withdraw_token_restricted instruction that checks admin rights via a config account:

let config = ConfigAccount::unpack(next_account_info(account_iter)?)?;
if config.admin != admin.pubkey() {
    return Err(ProgramError::InvalidAdminAccount);
}

Without verifying that config.owner == program_id, an attacker can pass a counterfeit config with any admin value. The contract accepts it as legitimate — leading to fund theft.

The Fix

Add an ownership check:

if config.owner != program_id {
    return Err(ProgramError::InvalidConfigAccount);
}

👉 Learn how secure smart contract deployment starts with proper ownership validation.

Better yet, design your code to return strongly-typed, verified accounts only after all checks are passed — reducing the chance of future mistakes.

Missing Signer Check

TL;DR

Always confirm that sensitive operations are signed by required parties using AccountInfo::is_signer.

Understanding Signer Requirements

Just because an account is passed into an instruction doesn’t mean it has consented to the action. Without checking is_signer, attackers can impersonate privileged roles simply by including their public key in the account list.

This is especially dangerous for admin functions like updating ownership or withdrawing funds.

Example of the Risk

An update_admin function compares public keys but forgets to check if the current admin actually signed:

if admin.pubkey() != config.admin {
    return Err(ProgramError::InvalidAdminAccount);
}
config.admin = new_admin.pubkey(); // No signer check!

An attacker supplies the real admin’s address as input but doesn’t need their signature — effectively hijacking control.

The Fix

Enforce signing:

if !admin.is_signer {
    return Err(ProgramError::MissingSigner);
}

This ensures only someone with cryptographic proof of identity can trigger sensitive changes.

Integer Overflow and Underflow

TL;DR

Use checked arithmetic (checked_add, checked_sub) and safe type conversions (TryFrom) to prevent numeric wraparounds.

The Hidden Danger in Rust Math

Unlike many languages, Rust does not panic on integer overflow in release mode — which is what Solana uses during deployment (cargo build-bpf). Instead, values wrap around silently.

An attacker can exploit this by crafting inputs that cause arithmetic checks to pass when they shouldn’t — enabling excessive withdrawals or balance manipulation.

Real-World Exploit Scenario

if amount + FEE > user_balance { ... }

If amount is near u32::MAX, adding FEE wraps it to a small number — bypassing the balance check entirely.

The Solution

Replace raw operators with checked methods:

if amount.checked_add(FEE).ok_or(ProgramError::InvalidArgument)? > user_balance {
    return Err(ProgramError::AttemptToWithdrawTooMuch);
}

Also avoid unsafe casts like as u32. Prefer u64::try_into() or TryFrom for safer conversions.

👉 Discover tools and practices that help catch numeric bugs before deployment.

Arbitrary Signed Program Invocation

TL;DR

Always validate the pubkey of any program you invoke via invoke_signed().

The Risk of Unverified Cross-Program Calls

Solana allows programs to call other programs — such as SPL Token for token transfers. But users provide the program account, meaning they could substitute a malicious version.

Even if your logic is sound, calling a rogue program undermines everything.

Example of Exploitation

invoke_signed(&transfer_instruction, &[...], &[&seeds])?;

No check on token_program.key. An attacker supplies a fake SPL program that pretends to transfer tokens while draining the vault elsewhere.

The Fix

Explicitly validate:

if token_program.key != &spl_token::id() {
    return Err(ProgramError::InvalidTokenProgram);
}

While newer SPL versions include built-in protections, never assume safety — especially when integrating third-party programs.

Solana Account Confusions

TL;DR

Account ownership isn’t enough — always validate data type and structure using type discriminators.

Why Type Safety Is Critical

Solana stores data as raw bytes. Your contract interprets them into structs — but there’s no runtime enforcement of types. A User account can be misinterpreted as a Config if both have similar layouts.

Attackers exploit this by passing one account type where another is expected — bypassing logic guards.

Attack Walkthrough

A User account with:

Passed as a Config becomes:

The contract thinks it's talking to a valid config — but it's under attacker control.

Prevention Strategy

Add a type discriminator field:

pub struct Config {
    pub account_type: u8, // e.g., 1 for Config
    pub admin: Pubkey,
    // ...
}

pub struct User {
    pub account_type: u8, // e.g., 2 for User
    pub user_authority: Pubkey,
    // ...
}

Validate on unpack:

fn unpack_config(info: &AccountInfo) -> Result<Config> {
    let config = Config::try_from_slice(&info.data.borrow())?;
    if config.account_type != ACCOUNT_TYPE_CONFIG {
        return Err(ProgramError::InvalidAccountType);
    }
    Ok(config)
}

This prevents cross-type confusion and supports safe schema evolution via versioning.


Frequently Asked Questions (FAQ)

Q: Can Solana’s runtime detect type mismatches automatically?
A: No. Solana treats account data as untyped byte arrays. Type safety must be enforced by your program logic.

Q: Is checking is_signer enough to secure admin functions?
A: Not alone. You must also verify ownership, correct account types, and ensure no arithmetic vulnerabilities exist.

Q: Do newer versions of SPL Token eliminate all invocation risks?
A: They reduce risk by hardcoding checks, but custom or outdated programs still require manual validation.

Q: What’s the best way to prevent integer overflows?
A: Use checked_add, checked_sub, and similar methods consistently — never rely on default operators in financial calculations.

Q: How do I handle schema upgrades safely?
A: Introduce a version field or new struct type, and validate it during deserialization to avoid mixing old and new formats.

Q: Are these issues unique to Solana?
A: While some concepts appear across blockchains, Solana’s account model and BPF runtime make ownership, type safety, and cross-program calls particularly critical.


👉 Start building securely with best-in-class infrastructure and insights.

By addressing these five common pitfalls — missing ownership checks, signer verification gaps, integer overflows, unverified program invocations, and account type confusion — you significantly reduce the risk of exploits in your Solana smart contracts. Stay vigilant, test thoroughly, and always assume user inputs are adversarial.