Back to all posts
SolanaDevelopmentDeep Dive

Solana SDK Deep Dive: PDAs, Transactions & Program Architecture

Go beyond Hello World tutorials. Learn how PDAs are mathematically derived, why transactions need blockhashes, and the account validation patterns that prevent exploits.

InfraxaInfraxa Team
January 31, 202512 min read

I spent three weeks reverse-engineering how Anchor abstracts away Solana's complexity. Then I actually read the solana-sdk source code. Turns out, the "magic" is just well-designed primitives that most tutorials skip over.

This isn't another "Hello World" Solana tutorial. We're going deep into the SDK internals — PDAs, transaction anatomy, and the account model. By the end, you'll understand why things work, not just how.

What You'll Actually Learn

Most Solana tutorials throw code at you. Here's what we're covering differently:

  • How PDAs are mathematically derived (and why bump seeds exist)
  • The exact structure of a transaction — headers, signatures, instructions
  • Why programs don't have main() and what the entrypoint macro actually does
  • Account management patterns that won't get your program exploited

Let's start with the concept that confuses most developers.

Program-Derived Addresses (PDAs)

PDAs are addresses that don't have private keys. Programs can "sign" for them without needing a secret. This is the foundation of on-chain authority.

Seeds Go In

You provide arbitrary byte arrays — could be a string like b"vault", a user's pubkey, or any data. Max 16 seeds, each up to 32 bytes.

Hash Gets Computed

The SDK hashes your seeds + program ID + a "bump seed" (0-255) using SHA256.

Curve Check Happens

If the result lands ON the Ed25519 curve, it would have a private key. We don't want that. The bump seed shifts it OFF the curve.

PDA Emerges

The first bump (starting from 255, going down) that produces an off-curve address wins. That's your PDA.

The SDK tries bump=255 first because statistically, most seeds produce valid PDAs on the first try. Going down is just a convention — it could go up. What matters is determinism: same seeds + same program = same PDA, always.

The Code That Makes It Click

Client-side derivation looks like this:

rust
let seeds = [b"vault", user_pubkey.as_ref()];
let (pda, bump_seed) = Pubkey::find_program_address(&seeds, &program_id);

On-chain verification — this is the part most tutorials skip:

rust
// The program MUST verify the PDA matches
let expected_pda = Pubkey::create_program_address(
    &[b"vault", user_pubkey.as_ref(), &[bump_seed]],
    &program_id
)?;
assert_eq!(pda, expected_pda);

Hidden gem: Always pass the bump seed as part of your instruction data. Recalculating it on-chain with find_program_address burns ~10,000 compute units. Passing it directly and verifying with create_program_address costs ~1,500.

Quiz

Why do PDAs need to be OFF the Ed25519 curve?

Anatomy of a Transaction

Transactions in Solana aren't just "send data to program." They're carefully structured packets with specific fields.

A transaction has two main parts:

  1. Signatures — Vector of Ed25519 signatures from all required signers
  2. Message — The actual payload containing:
    • Header (counts of signers, readonly accounts)
    • Account addresses (flat array, ordered specifically)
    • Recent blockhash (prevents replay attacks)
    • Instructions (the actual program calls)

Building a Transaction Step-by-Step

Here's the flow that actually happens:

rust
// 1. Define your instruction data
let bank_instruction = BankInstruction::Initialize;

// 2. Create the instruction with program ID and accounts
let instruction = Instruction::new_with_borsh(
    program_id,
    &bank_instruction,
    vec![
        AccountMeta::new(payer.pubkey(), true),  // writable, signer
        AccountMeta::new(vault_pda, false),       // writable, not signer
    ],
);

// 3. Bundle into a message
let message = Message::new(
    &[instruction],
    Some(&payer.pubkey()),  // fee payer
);

// 4. Create unsigned transaction
let mut tx = Transaction::new_unsigned(message);

// 5. Get fresh blockhash and sign
let blockhash = client.get_latest_blockhash()?;
tx.sign(&[&payer], blockhash);

// 6. Send it
client.send_and_confirm_transaction(&tx)?;

Pro pattern: Use Transaction::partial_sign for multi-sig scenarios. Sign with available keys first, pass the partially-signed transaction around, then complete signing.

The Entrypoint Pattern

Here's something that trips up developers coming from other languages: Solana programs don't have a main() function.

The standard pattern looks like this:

rust
use solana_program::{
    account_info::AccountInfo,
    entrypoint,
    entrypoint::ProgramResult,
    pubkey::Pubkey,
};

entrypoint!(process_instruction);

pub fn process_instruction(
    program_id: &Pubkey,
    accounts: &[AccountInfo],
    instruction_data: &[u8],
) -> ProgramResult {
    // Your logic here
    Ok(())
}

program_id

The public key of your deployed program. Use this to derive PDAs.

accounts

Array of ALL accounts your instruction might touch. Order matters — it must match what your client sends.

instruction_data

Raw bytes. You decide the format. Most use Borsh serialization.

Account Management That Won't Get You Hacked

Here's where 90% of Solana exploits come from: improper account validation.

rust
fn process_instruction(
    program_id: &Pubkey,
    accounts: &[AccountInfo],
    instruction_data: &[u8],
) -> ProgramResult {
    let account_info_iter = &mut accounts.iter();

    let payer = next_account_info(account_info_iter)?;
    let vault = next_account_info(account_info_iter)?;

    // THE CHECKS THAT SAVE YOU:
    assert!(payer.is_writable);      // Can we modify it?
    assert!(payer.is_signer);        // Did they actually sign?
    assert!(vault.is_writable);
    assert_eq!(vault.owner, program_id);  // Do WE own this account?

    // Now you're safe to proceed
    Ok(())
}

Critical: Always verify owner on accounts you read data from. An attacker can create a fake account with the same data layout and pass it in. If you don't check ownership, you'll read their malicious data.

Quiz

A user passes an account to your program. You read balance data from it without checking the owner. What can go wrong?

Cross-Program Invocation (CPI)

Programs calling programs. This is where invoke and invoke_signed come in.

Use when calling another program with accounts that have normal signers:

rust
invoke(
    &system_instruction::transfer(from.key, to.key, lamports),
    &[from.clone(), to.clone()],
)?;

The from account must have signed the original transaction.

Next Steps

You now understand the core primitives. Here's where to go from here:

  1. Build a token vault — Use PDAs to hold SPL tokens, practice CPI with the Token program
  2. Study Anchor's codegen — Now that you know the raw SDK, see how Anchor abstracts it
  3. Read exploit post-mortems — Solana security Discord has breakdowns of real hacks

The Solana SDK is maintained by Anza (formerly Solana Labs). The anza-xyz/solana-sdk repo is the canonical source. Reading it directly taught me more than any tutorial.


Building something on Solana? I'd love to hear what SDK patterns you're using. Hit me up on Twitter.