Site icon Chainstack

Solana Anchor Accounts: Seeds, Bumps and PDAs

Solana Post Pda logo

How Solana Anchor Accounts and Constraints Work

In this post, we’ll walk through a minimal Solana Anchor program that creates, updates, and closes a user-owned PDA, and then call it end-to-end from a TypeScript script. Along the way, you’ll learn how Anchor derives accounts, when you need to pass them manually, why storing the bump matters, and how the client auto-fills everything based on your constraints.

This post walks through building a small Anchor program that creates, initializes, and reads a user-owned PDA derived from predictable seeds. You’ll see how Anchor enforces account constraints, allocates on-chain space with InitSpace, and stores the bump for safe future validation.

Anchor is the most popular framework for Solana programs. Think of it as:

In practice, Anchor gives you:

More about the Anchor directory layout can be found in the Anchor documentation. We will cover only the interesting part: How the program behaves on-chain.

Rust code

use anchor_lang::prelude::*;

declare_id!("<YOUR-PROGRAM-ID>");

#[program]
pub mod solana_accounts {
use super::*;

/// Create a PDA for the user and store their name + creation time.
pub fn create_user(ctx: Context<CreateUser>, name: String) -> Result<()> {
// Enforce max length in BYTES (UTF-8). Emojis count as multiple bytes.
require!(
name.as_bytes().len() <= UserAccount::MAX_NAME,
ErrorCode::NameTooLong
);

let user = &mut ctx.accounts.user_account;
user.owner = ctx.accounts.authority.key();
user.name = name.clone(); // fits because we sized with MAX_NAME
user.created_at = Clock::get()?.unix_timestamp;
user.bump = ctx.bumps.user_account;

Ok(())
}
}

#[account]
#[derive(InitSpace)]
pub struct UserAccount {
pub owner: Pubkey, // 32
#[max_len(32)]
pub name: String, // 4 + up to 32 bytes
pub created_at: i64, // 8
pub bump: u8, // 1
}

impl UserAccount {
pub const MAX_NAME: usize = 32;
// Total space to allocate at init time:
// 8 (discriminator) + INIT_SPACE computed by Anchor from the struct
pub const SPACE: usize = 8 + Self::INIT_SPACE;
}

#[derive(Accounts)]
pub struct CreateUser<'info> {
#[account(mut)]
pub authority: Signer<'info>,

/// PDA: seeds = ["user", authority]
#[account(
init,
payer = authority,
space = UserAccount::SPACE,
seeds = [b"user", authority.key().as_ref()],
bump
)]
pub user_account: Account<'info, UserAccount>,

pub system_program: Program<'info, System>,
}

#[error_code]
pub enum ErrorCode {
#[msg("Name too long (max 32 bytes).")]
NameTooLong,
}

Overview

What it stores: A per-wallet UserAccount at a PDA derived from [“user”, authority].

Instructions (will be covered in next blog post)

Program Id (why it matters)

declare_id!("<YOUR-PROGRAM-ID>");

The account: layout & size

#[account]
#[derive(InitSpace)]
pub struct UserAccount {
pub owner: Pubkey, // 32
#[max_len(32)]
pub name: String, // 4 + up to 32 bytes
pub created_at: i64, // 8
pub bump: u8, // 1
}

PDAs, seeds, and bump

PDA derivation:

seeds = [b"user", authority.key().as_ref()], bump = user_account.bump

Instruction: create_user

object overview

#[derive(Accounts)]
pub struct CreateUser<'info> {
#[account(mut)]
pub authority: Signer<'info>,

/// PDA: seeds = ["user", authority]
#[account(
init,
payer = authority,
space = UserAccount::SPACE,
seeds = [b"user", authority.key().as_ref()],
bump
)]
pub user_account: Account<'info, UserAccount>,
pub system_program: Program<'info, System>,
}

pub authority: Signer<'info> : This field represents the caller of the instruction, and the Signer represents that this account must sign the transaction, it have #[account(mut)] because this signer will pay for the account creation fee, so their balance will change.

pub user_account: Account<'info, UserAccount> : This is the PDA account that we are creating. It is the on-chain data structure it will store what was declared in UserAccount object.

Notes

pub system_program: Program<'info, System> : Any init must call the system program under the hood which allocate new account, assign ownership, transfer lamports from payer. This field is required for account creation, lamport transfers, PDA initialization

Handler overview

pub fn create_user(ctx: Context<CreateUser>, name: String) -> Result<()> {
// Enforce max length in BYTES (UTF-8). Emojis count as multiple bytes.
require!(
name.as_bytes().len() <= UserAccount::MAX_NAME,
ErrorCode::NameTooLong
);

let user = &mut ctx.accounts.user_account;
// Stores the wallet that created this profile
user.owner = ctx.accounts.authority.key();
// This assigns into the fixed allocated space Anchor reserved via #[max_len]
user.name = name.clone(); // fits because we sized with MAX_NAME
// Clock sysvar contains the current cluster time
user.created_at = Clock::get()?.unix_timestamp;
// Why store the bump:
// we used: PDA = find_program_address(["user", authority], bump)
// We don't want to recompute bump manually later
// update_name and close_user enforce (We will see it later)
user.bump = ctx.bumps.user_account;

Ok(())
}

Context<CreateUser>: Gives you validated access to all accounts declared in the CreateUser struct. Anchor has already validated all constraints and created/allocated required accounts

Client side (Ts)

Save this file in scripts/solana_accounts.ts
Note: For this code to run we need to export 2 env variables:
export ANCHOR_PROVIDER_URL=”http://127.0.0.1:8899”
export ANCHOR_WALLET=”$HOME/.config/solana/id.json”

import * as anchor from "@coral-xyz/anchor";
import type { Program } from "@coral-xyz/anchor";
import { LAMPORTS_PER_SOL, PublicKey } from "@solana/web3.js";
import { SolanaAccounts } from "../target/types/solana_accounts";

(async () => {
const provider = anchor.AnchorProvider.env();
anchor.setProvider(provider);

// Use Anchor workspace (uses generated IDL/types under target/)
const program = anchor.workspace.solanaAccounts as Program<SolanaAccounts>;
const wallet = provider.wallet as anchor.Wallet;

// PDA: seeds = ["user", authority]
// Seeds must match the program's #[account(seeds = [b"user", authority])]. From Rust!
const [userPda] = PublicKey.findProgramAddressSync([Buffer.from("user"), wallet.publicKey.toBuffer()], program.programId);
console.log("Wallet:", wallet.publicKey.toBase58());
console.log("Program:", program.programId.toBase58());
console.log("User PDA:", userPda.toBase58());

// 1) createUser
// Derivable accounts (PDAs) are autofilled by Anchor
// No need to pass programId or PDA here! it knows from the context.
const sig1 = await program.methods
.createUser("ByteBeetle")
.accounts({ authority: wallet.publicKey }) // derivable accounts are autofilled
.rpc();
console.log("createUser tx:", sig1);

// Fethch and log the created account
const acct1 = await program.account.userAccount.fetch(userPda);
const createdAtBn = (acct1 as any).createdAt ?? (acct1 as any).created_at;
const createdAt = new Date(createdAtBn.toNumber() * 1000).toISOString();
console.log("After create:", {
owner: acct1.owner.toBase58(),
name: acct1.name,
created_at: createdAt,
bump: acct1.bump,
});

console.log("Done ✅");
})().catch((e) => {
console.error(e);
process.exit(1);
});

Run it all together

# In separate terminal
solana-test-validator -r

# New tab or new terminal
solana config set --url http://127.0.0.1:8899
solana config set --keypair ~/.config/solana/id.json
# Run this in project root
solana-keygen new -o target/deploy/solana_accounts-keypair.json --no-bip39-passphrase
solana-keygen pubkey target/deploy/solana_accounts-keypair.json
# Paste this pubkey into:
# - programs/solana_accounts/src/lib.rs: declare_id!("...")
# - Anchor.toml: [programs.localnet].solana_accounts = "..."

anchor clean
anchor build
anchor deploy

# Reminder: you need to run this before:
# export ANCHOR_PROVIDER_URL="http://127.0.0.1:8899"
# export ANCHOR_WALLET="$HOME/.config/solana/id.json"

pnpm ts-node scripts/solana_accounts.ts

You should see something like this after deployment:

You should see something like this after running the client:

Summary

In this post, we built a minimal Anchor program that creates a per-user PDA and interacted with it end-to-end using the TypeScript client.
You learned how Anchor:

By understanding how Anchor resolves accounts and constraints behind the scenes, you can reason better about what actually happens on-chain and debug or extend your Solana programs with confidence.

Learn more about Solana architecture from our articles

Reliable Solana RPC infrastructure

Getting started with Solana on Chainstack is fast and straightforward. Developers can deploy a reliable Solana node within seconds through an intuitive Console — no complex setup or hardware management required. 

Chainstack provides low-latency Solana RPC access and real-time gRPC data streaming via Yellowstone Geyser Plugin, ensuring seamless connectivity for building, testing, and scaling DeFi, analytics, and trading applications. With Solana low-latency endpoints powered by global infrastructure, you can achieve lightning-fast response times and consistent performance across regions. 

Start for free, connect your app to a reliable Solana RPC endpoint, and experience how easy it is to build and scale on Solana with Chainstack – one of the best RPC providers.

Exit mobile version