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

ConceptImplementation
PDA DerivationSeeds: ["counter", user_pubkey]
Account Initializationinit constraint with space calculation
Authority Checksconstraint = counter.authority == user.key()
Safe Mathchecked_add and checked_sub
Account Closingclose = user returns rent to user
Frontend IntegrationAnchor + 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.