PDA Counter
Build a simple on-chain counter program using Anchor and PDAs, with a React frontend to interact with it. This is a classic introductory Solana project.
Project Overview
Text
┌─────────────────────────────────────────────────────────────────┐
│ PDA Counter Architecture │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Frontend (React) │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ ┌───────────────────────┐ │ │
│ │ │ Counter: 42 │ │ │
│ │ └───────────────────────┘ │ │
│ │ │ │
│ │ [ Initialize ] [ + Increment ] [ - Decrement ] │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ On-Chain Program (Anchor) │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ Counter PDA │ │
│ │ ┌─────────────────────────────────────────────────┐ │ │
│ │ │ Seeds: ["counter", user.pubkey] │ │ │
│ │ │ Data: { count: u64, bump: u8 } │ │ │
│ │ └─────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ Instructions: │ │
│ │ • initialize() - Create counter PDA │ │
│ │ • increment() - Add 1 to count │ │
│ │ • decrement() - Subtract 1 from count │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
Project Setup
Bash
# Create Anchor project
anchor init pda-counter
cd pda-counter
# Build the program
anchor build
# Get program ID
solana address -k target/deploy/pda_counter-keypair.json
# Update lib.rs with program ID, then rebuild
anchor build
# Deploy to devnet
anchor deploy --provider.cluster devnet
Anchor Program
Rust
// programs/pda-counter/src/lib.rs
use anchor_lang::prelude::*;
declare_id!("YOUR_PROGRAM_ID_HERE");
#[program]
pub mod pda_counter {
use super::*;
/// Initialize a new counter for the user
pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
let counter = &mut ctx.accounts.counter;
counter.count = 0;
counter.bump = ctx.bumps.counter;
counter.authority = ctx.accounts.user.key();
msg!("Counter initialized for user: {}", ctx.accounts.user.key());
Ok(())
}
/// Increment the counter by 1
pub fn increment(ctx: Context<Update>) -> Result<()> {
let counter = &mut ctx.accounts.counter;
counter.count = counter.count.checked_add(1)
.ok_or(ErrorCode::Overflow)?;
msg!("Counter incremented to: {}", counter.count);
Ok(())
}
/// Decrement the counter by 1
pub fn decrement(ctx: Context<Update>) -> Result<()> {
let counter = &mut ctx.accounts.counter;
counter.count = counter.count.checked_sub(1)
.ok_or(ErrorCode::Underflow)?;
msg!("Counter decremented to: {}", counter.count);
Ok(())
}
/// Set counter to specific value (only authority)
pub fn set(ctx: Context<Update>, value: u64) -> Result<()> {
let counter = &mut ctx.accounts.counter;
counter.count = value;
msg!("Counter set to: {}", value);
Ok(())
}
/// Close the counter account and reclaim rent
pub fn close(_ctx: Context<Close>) -> Result<()> {
msg!("Counter account closed");
Ok(())
}
}
#[derive(Accounts)]
pub struct Initialize<'info> {
#[account(mut)]
pub user: Signer<'info>,
#[account(
init,
payer = user,
space = Counter::SIZE,
seeds = [b"counter", user.key().as_ref()],
bump
)]
pub counter: Account<'info, Counter>,
pub system_program: Program<'info, System>,
}
#[derive(Accounts)]
pub struct Update<'info> {
#[account(
constraint = counter.authority == user.key() @ ErrorCode::Unauthorized
)]
pub user: Signer<'info>,
#[account(
mut,
seeds = [b"counter", user.key().as_ref()],
bump = counter.bump
)]
pub counter: Account<'info, Counter>,
}
#[derive(Accounts)]
pub struct Close<'info> {
#[account(
constraint = counter.authority == user.key() @ ErrorCode::Unauthorized
)]
pub user: Signer<'info>,
#[account(
mut,
close = user,
seeds = [b"counter", user.key().as_ref()],
bump = counter.bump
)]
pub counter: Account<'info, Counter>,
}
#[account]
pub struct Counter {
pub authority: Pubkey,
pub count: u64,
pub bump: u8,
}
impl Counter {
pub const SIZE: usize = 8 + // discriminator
32 + // authority pubkey
8 + // count u64
1; // bump u8
}
#[error_code]
pub enum ErrorCode {
#[msg("Counter overflow")]
Overflow,
#[msg("Counter underflow")]
Underflow,
#[msg("Unauthorized")]
Unauthorized,
}
Program Tests
TypeScript
// tests/pda-counter.ts
import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { PdaCounter } from "../target/types/pda_counter";
import { expect } from "chai";
describe("pda-counter", () => {
const provider = anchor.AnchorProvider.env();
anchor.setProvider(provider);
const program = anchor.workspace.PdaCounter as Program<PdaCounter>;
const user = provider.wallet;
const [counterPda] = anchor.web3.PublicKey.findProgramAddressSync(
[Buffer.from("counter"), user.publicKey.toBuffer()],
program.programId,
);
it("Initializes the counter", async () => {
const tx = await program.methods
.initialize()
.accounts({
user: user.publicKey,
counter: counterPda,
systemProgram: anchor.web3.SystemProgram.programId,
})
.rpc();
console.log("Initialize tx:", tx);
const counter = await program.account.counter.fetch(counterPda);
expect(counter.count.toNumber()).to.equal(0);
expect(counter.authority.toBase58()).to.equal(user.publicKey.toBase58());
});
it("Increments the counter", async () => {
await program.methods
.increment()
.accounts({
user: user.publicKey,
counter: counterPda,
})
.rpc();
const counter = await program.account.counter.fetch(counterPda);
expect(counter.count.toNumber()).to.equal(1);
});
it("Increments again", async () => {
await program.methods
.increment()
.accounts({
user: user.publicKey,
counter: counterPda,
})
.rpc();
const counter = await program.account.counter.fetch(counterPda);
expect(counter.count.toNumber()).to.equal(2);
});
it("Decrements the counter", async () => {
await program.methods
.decrement()
.accounts({
user: user.publicKey,
counter: counterPda,
})
.rpc();
const counter = await program.account.counter.fetch(counterPda);
expect(counter.count.toNumber()).to.equal(1);
});
it("Sets counter to specific value", async () => {
await program.methods
.set(new anchor.BN(100))
.accounts({
user: user.publicKey,
counter: counterPda,
})
.rpc();
const counter = await program.account.counter.fetch(counterPda);
expect(counter.count.toNumber()).to.equal(100);
});
it("Fails to decrement below zero", async () => {
// Set to 0 first
await program.methods
.set(new anchor.BN(0))
.accounts({
user: user.publicKey,
counter: counterPda,
})
.rpc();
try {
await program.methods
.decrement()
.accounts({
user: user.publicKey,
counter: counterPda,
})
.rpc();
expect.fail("Should have thrown");
} catch (err: any) {
expect(err.error.errorCode.code).to.equal("Underflow");
}
});
it("Closes the counter account", async () => {
const balanceBefore = await provider.connection.getBalance(user.publicKey);
await program.methods
.close()
.accounts({
user: user.publicKey,
counter: counterPda,
})
.rpc();
const balanceAfter = await provider.connection.getBalance(user.publicKey);
// Should have reclaimed rent
expect(balanceAfter).to.be.greaterThan(balanceBefore - 5000); // minus tx fee
// Account should not exist
const account = await provider.connection.getAccountInfo(counterPda);
expect(account).to.be.null;
});
});
Frontend Setup
Bash
# Create Next.js frontend
cd app # or create new: bunx create-next-app@latest frontend
bun add @solana/web3.js @coral-xyz/anchor @solana/wallet-adapter-react \
@solana/wallet-adapter-react-ui @solana/wallet-adapter-wallets
# Copy IDL
cp ../target/idl/pda_counter.json ./src/idl/
Frontend Hook
TypeScript
// hooks/useCounter.ts
import { useConnection, useWallet } from "@solana/wallet-adapter-react";
import { Program, AnchorProvider, BN, setProvider } from "@coral-xyz/anchor";
import { PublicKey } from "@solana/web3.js";
import { useMemo, useCallback } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import idl from "@/idl/pda_counter.json";
const PROGRAM_ID = new PublicKey("YOUR_PROGRAM_ID_HERE");
interface Counter {
authority: PublicKey;
count: BN;
bump: number;
}
export function useCounter() {
const { connection } = useConnection();
const wallet = useWallet();
const queryClient = useQueryClient();
// Setup program
const program = useMemo(() => {
if (!wallet.publicKey) return null;
const provider = new AnchorProvider(connection, wallet as any, {
commitment: "confirmed",
});
setProvider(provider);
return new Program(idl as any, PROGRAM_ID, provider);
}, [connection, wallet]);
// Derive PDA
const counterPda = useMemo(() => {
if (!wallet.publicKey) return null;
const [pda] = PublicKey.findProgramAddressSync(
[Buffer.from("counter"), wallet.publicKey.toBuffer()],
PROGRAM_ID,
);
return pda;
}, [wallet.publicKey]);
// Fetch counter data
const {
data: counter,
isLoading,
error,
} = useQuery({
queryKey: ["counter", counterPda?.toBase58()],
queryFn: async (): Promise<Counter | null> => {
if (!program || !counterPda) return null;
try {
const account = await program.account.counter.fetch(counterPda);
return account as Counter;
} catch (err) {
// Account doesn't exist yet
return null;
}
},
enabled: !!program && !!counterPda,
refetchInterval: 5000,
});
// Initialize mutation
const initializeMutation = useMutation({
mutationFn: async () => {
if (!program || !wallet.publicKey || !counterPda) {
throw new Error("Wallet not connected");
}
const tx = await program.methods
.initialize()
.accounts({
user: wallet.publicKey,
counter: counterPda,
})
.rpc();
return tx;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["counter"] });
},
});
// Increment mutation
const incrementMutation = useMutation({
mutationFn: async () => {
if (!program || !wallet.publicKey || !counterPda) {
throw new Error("Wallet not connected");
}
const tx = await program.methods
.increment()
.accounts({
user: wallet.publicKey,
counter: counterPda,
})
.rpc();
return tx;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["counter"] });
},
});
// Decrement mutation
const decrementMutation = useMutation({
mutationFn: async () => {
if (!program || !wallet.publicKey || !counterPda) {
throw new Error("Wallet not connected");
}
const tx = await program.methods
.decrement()
.accounts({
user: wallet.publicKey,
counter: counterPda,
})
.rpc();
return tx;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["counter"] });
},
});
// Set value mutation
const setValueMutation = useMutation({
mutationFn: async (value: number) => {
if (!program || !wallet.publicKey || !counterPda) {
throw new Error("Wallet not connected");
}
const tx = await program.methods
.set(new BN(value))
.accounts({
user: wallet.publicKey,
counter: counterPda,
})
.rpc();
return tx;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["counter"] });
},
});
return {
counter,
counterPda,
isLoading,
error,
isInitialized: !!counter,
initialize: initializeMutation.mutateAsync,
increment: incrementMutation.mutateAsync,
decrement: decrementMutation.mutateAsync,
setValue: setValueMutation.mutateAsync,
isInitializing: initializeMutation.isPending,
isIncrementing: incrementMutation.isPending,
isDecrementing: decrementMutation.isPending,
isSetting: setValueMutation.isPending,
};
}
Counter Component
TypeScript
// components/Counter.tsx
"use client";
import { useCounter } from "@/hooks/useCounter";
import { useWallet } from "@solana/wallet-adapter-react";
export function Counter() {
const { connected } = useWallet();
const {
counter,
counterPda,
isLoading,
isInitialized,
initialize,
increment,
decrement,
isInitializing,
isIncrementing,
isDecrementing,
} = useCounter();
if (!connected) {
return (
<div className="text-center py-8 text-gray-500">
Connect your wallet to use the counter
</div>
);
}
if (isLoading) {
return (
<div className="text-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto" />
<p className="mt-2 text-gray-500">Loading counter...</p>
</div>
);
}
if (!isInitialized) {
return (
<div className="text-center py-8">
<p className="text-gray-600 mb-4">
You don't have a counter yet
</p>
<button
onClick={() => initialize()}
disabled={isInitializing}
className="px-6 py-3 bg-blue-600 text-white rounded-lg font-medium
hover:bg-blue-700 disabled:bg-gray-400"
>
{isInitializing ? "Creating..." : "Create Counter"}
</button>
{counterPda && (
<p className="mt-4 text-xs text-gray-400">
PDA: {counterPda.toBase58().slice(0, 20)}...
</p>
)}
</div>
);
}
const count = counter?.count.toNumber() ?? 0;
return (
<div className="text-center">
{/* Counter Display */}
<div className="mb-8">
<div className="text-8xl font-bold text-gray-900 dark:text-white mb-2">
{count}
</div>
<p className="text-gray-500">Current Count</p>
</div>
{/* Control Buttons */}
<div className="flex items-center justify-center gap-4">
<button
onClick={() => decrement()}
disabled={isDecrementing || count === 0}
className="w-16 h-16 rounded-full bg-red-100 text-red-600 text-2xl font-bold
hover:bg-red-200 disabled:bg-gray-100 disabled:text-gray-400
transition-colors flex items-center justify-center"
>
{isDecrementing ? "..." : "−"}
</button>
<button
onClick={() => increment()}
disabled={isIncrementing}
className="w-20 h-20 rounded-full bg-green-100 text-green-600 text-3xl font-bold
hover:bg-green-200 disabled:bg-gray-100 disabled:text-gray-400
transition-colors flex items-center justify-center"
>
{isIncrementing ? "..." : "+"}
</button>
</div>
{/* Counter Info */}
<div className="mt-8 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg text-sm">
<p className="text-gray-500">
Counter PDA:{" "}
<code className="text-xs">
{counterPda?.toBase58().slice(0, 20)}...
</code>
</p>
</div>
</div>
);
}
Main Page
TypeScript
// app/page.tsx
"use client";
import { WalletMultiButton } from "@solana/wallet-adapter-react-ui";
import { Counter } from "@/components/Counter";
export default function CounterPage() {
return (
<div className="min-h-screen bg-gradient-to-b from-purple-50 to-white dark:from-gray-900 dark:to-gray-950">
<header className="border-b dark:border-gray-800 bg-white/80 dark:bg-gray-950/80 backdrop-blur-sm sticky top-0">
<div className="max-w-4xl mx-auto px-4 py-4 flex items-center justify-between">
<h1 className="text-xl font-bold">🔢 PDA Counter</h1>
<WalletMultiButton />
</div>
</header>
<main className="max-w-2xl mx-auto px-4 py-12">
<div className="text-center mb-12">
<h2 className="text-4xl font-bold mb-2">On-Chain Counter</h2>
<p className="text-gray-600 dark:text-gray-400">
A simple counter stored on Solana using PDAs
</p>
</div>
<div className="bg-white dark:bg-gray-900 rounded-2xl shadow-lg p-8">
<Counter />
</div>
<div className="mt-8 grid grid-cols-3 gap-4 text-center text-sm">
<div className="p-4 bg-white dark:bg-gray-900 rounded-lg">
<p className="font-semibold">Technology</p>
<p className="text-gray-500">Anchor + React</p>
</div>
<div className="p-4 bg-white dark:bg-gray-900 rounded-lg">
<p className="font-semibold">Storage</p>
<p className="text-gray-500">PDA Account</p>
</div>
<div className="p-4 bg-white dark:bg-gray-900 rounded-lg">
<p className="font-semibold">Network</p>
<p className="text-gray-500">Devnet</p>
</div>
</div>
</main>
</div>
);
}
Key Concepts Demonstrated
| Concept | Implementation |
|---|---|
| PDA Derivation | Seeds: ["counter", user_pubkey] |
| Account Initialization | init constraint with space calculation |
| Authority Checks | constraint = counter.authority == user.key() |
| Safe Math | checked_add and checked_sub |
| Account Closing | close = user returns rent to user |
| Frontend Integration | Anchor + React Query hooks |
What You'll Learn
- Writing Anchor programs with PDAs
- Account initialization and validation
- Error handling in Solana programs
- Connecting frontend to on-chain programs
- Testing Anchor programs
- Transaction management in React
Next: AMM Simulator - Build a constant product AMM simulation.