Build a Tip Jar Program on Solana

Write, test, and deploy a Solana smart contract using Rust and Anchor.

Introduction

30 Second Summary

Ever tossed a coin into a tip jar at your favorite coffee shop? On the Solana blockchain, you can build a digital version that collects tips from anyone in the world.

In this project, you will build a Tip Jar program on Solana using the Anchor framework. You'll write three core instructions that let anyone create a tip jar, send SOL tips to it, and withdraw collected funds.

What You'll Build

A Solana program (smart contract) written in Rust that accepts tips in SOL, tracks totals on-chain, and lets only the owner withdraw.

By the end of this project, you'll have:

  • A Solana program with three instructions: initialize, send_tip, and withdraw.
  • A PDA-based tip jar account that stores the owner's public key, total tips received, and tip count.
  • Automated tests that verify all three instructions against a local Solana validator.
  • Secret Mission: Deploy your Tip Jar to Solana devnet and verify it on Solana Explorer.

Are there any prerequisites?

This project assumes you're familiar with basic blockchain concepts like accounts, transactions, and smart contracts. Experience with Solidity is helpful but not required.

You'll also need WSL2 (Windows Subsystem for Linux) and VS Code with the WSL extension already installed. Solana development on Windows runs inside the Ubuntu terminal through WSL.

Before We Start

Before We Start...

Install the Solana Development Toolchain

Solana programs are written in Rust and compiled into a format that runs on the Solana runtime. To write, build, and test them, you need a specific set of tools installed inside your WSL2 environment.

In this step, you'll install the full Solana development toolchain using a single official installer. You'll also configure the Solana CLI for local development and generate your first wallet keypair.

In this step, get ready to:
  • Install system build dependencies and the Solana development toolchain.
  • Configure the Solana CLI to connect to your local validator.
  • Generate a development wallet keypair.
Update WSL packages and run the Solana installer

The Solana toolchain depends on several C/C++ libraries to compile native code. You'll install these system-level packages first, then run the official one-command installer that bundles everything else.

  • Update your system package list and install the required build dependencies by running these commands:
sudo apt-get update
sudo apt-get install -y build-essential pkg-config libudev-dev llvm libclang-dev protobuf-compiler libssl-dev

What are these packages?

These are C/C++ compilers, linkers, and development libraries that Rust and the Solana toolchain need to build from source. Without them, the Solana installer would fail during the compilation step.

You should see output ending with lines like Processing triggers or Setting up confirming the packages installed successfully.

Seeing permission errors?

Make sure you included sudo at the start of both commands. If prompted for a password, enter your WSL user password (the one you set when you first installed Ubuntu).

Help me troubleshoot this apt-get error in my WSL2 terminal.

  • Install the complete Solana development toolchain by running this command:
curl --proto '=https' --tlsv1.2 -sSfL https://solana-install.solana.workers.dev | bash

What does this installer include?

This single command installs six tools: Rust (the language your program is written in), Solana CLI (manages your local validator and wallet), Anchor CLI (the framework that simplifies Solana development), Surfpool CLI (a local test validator), Node.js, and Yarn (for running your test suite later).

Bundling everything into one installer means you don't have to worry about version compatibility between tools.

The installer takes a few minutes to download and compile everything. You'll see a lot of output scrolling by.

  • Once the installer finishes, close your terminal panel by clicking the trash icon or pressing Ctrl+D.
  • Open a new terminal by pressing Ctrl+` (backtick key, top-left of your keyboard).

Why restart the terminal?

The installer adds new entries to your shell's PATH so your terminal can find the newly installed commands. These PATH changes only take effect in a new terminal session.

Installer failed or timed out?

Check your internet connection and try running the curl command again. The installer is safe to re-run.

If you see errors about missing packages, make sure the apt-get install command from the previous step completed successfully.

Help me troubleshoot the Solana installer error in WSL2.

Verify installations and configure Solana CLI

Now that the tools are installed, you need to confirm they're all accessible from your terminal. Then you'll configure the Solana CLI for local development.

  • Verify that Rust, Solana CLI, and Anchor CLI are all installed by running this command:
rustc --version && solana --version && anchor --version

You should see three version numbers printed, one per line. The exact numbers may vary, but expect something similar to rustc 1.85.0, solana-cli 3.1.10, and anchor-cli 1.0.1.

Seeing 'command not found'?

This means your terminal hasn't loaded the updated PATH yet. Close this terminal and open a fresh one with Ctrl+`.

If a fresh terminal still doesn't find the commands, try running source ~/.bashrc and then re-run the version check.

Help me fix 'command not found' errors after running the Solana installer.

  • Point your Solana CLI to your local validator by running this command:
solana config set -u localhost

Why localhost?

The Solana CLI can connect to different clusters: mainnet (real money), devnet (test network), or localhost (your own machine). For development, localhost is the fastest and cheapest option because everything runs on your computer with no network latency or rate limits.

  • Confirm your configuration is correct by running:
solana config get

You should see RPC URL: http://localhost:8899 in the output.

  • Generate a development wallet keypair by running this command:
solana-keygen new --no-bip39-passphrase

What is a keypair?

A keypair is your identity on the Solana network. It consists of a public key (your wallet address that others can see) and a private key (stored at ~/.config/solana/id.json).

The --no-bip39-passphrase flag skips the passphrase prompt since this keypair is for local development only.

You should see output that includes Wrote new keypair to followed by the file path, and a pubkey: line showing your new wallet address.

Seeing a seed phrase warning?

The command prints a seed phrase (a list of random words). For local development, you don't need to save it. Never use this keypair for real funds on mainnet.

If the command says a keypair already exists and asks to overwrite, type y and press Enter to continue.

Help me troubleshoot my Solana keypair generation.

Your development environment is ready. Next up, you'll scaffold your first Anchor project and see the program structure you'll build on.

Scaffold the Anchor Project

Your toolchain is installed and your Solana CLI is configured to use your local validator. Time to create the actual project.

Anchor provides a scaffolding command that generates a complete project workspace for you. You'll use the single-file template, which keeps all your program code in one lib.rs file instead of splitting it across multiple files.

In this step, get ready to:
  • Initialize a new Anchor project using the single-file template.
  • Build the default program to confirm the toolchain works.
  • Sync the generated program keys into your source code.
Initialize the project and explore the structure

Anchor's default template splits code into multiple files for instructions, state, and errors. For a first project, the single-file template is simpler to follow because everything lives in one place.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create a new Anchor project by running this command:
anchor init tip-jar --template single

This command downloads dependencies and sets up the project. It may take a couple of minutes.

What does this command create?

The --template single flag tells Anchor to put all your program code in a single lib.rs file. The default (--template multiple) splits code across separate files for instructions, state, constants, and errors.

The command generates a full workspace including:

  • Anchor.toml for project configuration.
  • programs/tip-jar/src/lib.rs for your program code.
  • tests/ for integration tests.
  • Cargo.toml for Rust workspace configuration.
  • Move into the project folder by running:
cd tip-jar
  • Confirm the project files exist by running:
ls

You should see folders like programs/, tests/, and app/, plus files like Anchor.toml and Cargo.toml.

Don't see these files?

Make sure you ran cd tip-jar after the init command. If you're still in ~/Desktop, you'll only see the tip-jar folder, not its contents.

If the init command failed entirely, check that you have a stable internet connection. Anchor needs to download Rust crates during initialization.

Help me troubleshoot my Anchor project initialization

Now let's look at the default program code that Anchor generated for you.

  • Open programs/tip-jar/src/lib.rs in VS Code by clicking on it in the Explorer sidebar.

You should see the default scaffold code that looks like this:

use anchor_lang::prelude::*;

// Your program's unique on-chain address (placeholder for now)
declare_id!("11111111111111111111111111111111");

// All program instructions live inside this module
#[program]
pub mod tip_jar {
    use super::*;

    // A placeholder instruction that does nothing yet
    pub fn initialize(_ctx: Context<Initialize>) -> Result<()> {
        Ok(())
    }
}

// Defines the accounts required for the initialize instruction
#[derive(Accounts)]
pub struct Initialize {}

What's in this file?

This is a minimal Anchor program with four key parts:

  • declare_id! sets the program's on-chain address. It currently has a placeholder value that you'll update shortly.
  • #[program] marks the module containing all your instruction handlers. Every public function inside becomes an invocable instruction.
  • #[derive(Accounts)] defines which accounts each instruction needs. Right now Initialize is empty because the placeholder instruction doesn't use any accounts.

In Step 3, you'll replace this placeholder with your actual Tip Jar program logic.

Build the default project to verify everything works

Before writing any custom code, confirm the toolchain can compile the default program. This catches any setup issues early.

  • Compile the default program by running:
anchor build

The first build downloads and compiles all Rust dependencies, so it will take several minutes. Subsequent builds are much faster.

What does anchor build do?

This command compiles your Rust program into a .so binary that can run on the Solana runtime. The compiled output lands in target/deploy/tip_jar.so.

It also generates a keypair for your program at target/deploy/tip_jar-keypair.json. This keypair determines the on-chain address where your program will be deployed.

  • You should see compilation output ending with no errors. Confirm the compiled binary exists by running:
ls target/deploy/

You should see tip_jar.so and tip_jar-keypair.json listed in the output.

Seeing build errors?

If you see an error about missing tools or a corrupted toolchain, run this command to reinstall the build tools, then retry anchor build:

cargo build-sbf --force-tools-install

If the build hangs or fails repeatedly, make sure you have at least 3 GB of free disk space. The build tools require significant storage.

Help me fix my Anchor build errors

Sync the program keys

The build generated a keypair for your program, but the declare_id! macro in your source code still has the placeholder value. You need to sync them so the program knows its own address.

  • Sync the generated program ID into your source code and config by running:
anchor keys sync

What does this do?

This command reads the public key from target/deploy/tip_jar-keypair.json and writes it into two places:

  • The declare_id! macro in programs/tip-jar/src/lib.rs.
  • The [programs.localnet] section in Anchor.toml.

This ensures your program's source code and deployment config both reference the same on-chain address.

  • Open programs/tip-jar/src/lib.rs again.
  • The declare_id! value should now contain a long base58 string (your unique program ID) instead of 11111111111111111111111111111111.

Still seeing the placeholder ID?

Make sure you ran anchor build before anchor keys sync. The sync command needs the keypair file that the build generates. If that file doesn't exist, the sync has nothing to read from.

Help me sync my Anchor program keys

Your project scaffolding is complete and the toolchain is working. Next up, you'll replace the placeholder code with the actual Tip Jar program.

Define the Tip Jar Account and Initialize Instruction

Your Anchor project is scaffolded and the default program compiled successfully. Now it's time to replace that placeholder with real Tip Jar logic.

In this step, you'll define the on-chain data structure that stores tip jar information. You'll also write the initialize instruction that creates a new tip jar for any wallet.

In this step, get ready to:
  • Define a TipJar account struct that stores the owner, total tips, and tip count.
  • Write an initialize instruction that creates a PDA-based tip jar account.
  • Build the program to verify everything compiles.
Define the TipJar account struct

On Solana, programs don't store data inside themselves. Data lives in separate accounts on the blockchain.

Your Tip Jar program needs an account to store three pieces of information: who owns the tip jar, how much SOL has been tipped, and how many tips have been received.

  • Open lib.rs at programs/tip-jar/src/lib.rs.
  • Find the first line use anchor_lang::prelude::*;.
  • Add this import directly below it:
use anchor_lang::system_program::{transfer, Transfer};

Why add this import now?

This import brings in helpers for transferring SOL between accounts. You won't use them until the next step, so the Rust compiler may show an unused import warning. That's expected and harmless.

  • Scroll to the bottom of lib.rs, after the closing } of pub struct Initialize {}.
  • Add the TipJar account struct by pasting this code:
// Defines the data stored in each tip jar account on-chain
#[account]
#[derive(InitSpace)]
pub struct TipJar {
    // The wallet address of the tip jar creator
    pub owner: Pubkey,
    // Running total of lamports received as tips
    pub total_tips: u64,
    // Number of individual tips received
    pub tip_count: u64,
}

What does this code do?

#[account] tells Anchor to handle serialization and add an 8-byte discriminator that identifies this account type on-chain. #[derive(InitSpace)] automatically calculates how many bytes this struct needs: 32 (Pubkey) + 8 (u64) + 8 (u64) = 48 bytes.

owner stores which wallet created the tip jar. total_tips tracks the cumulative lamports received. tip_count tracks how many individual tips were sent.

What are lamports?

Lamports are the smallest unit of SOL. 1 SOL = 1,000,000,000 lamports. Storing amounts in lamports avoids floating-point math entirely.

You won't see a visible change yet. The next substep connects this struct to your initialize instruction.

Write the initialize instruction and accounts context

Every Anchor instruction has two parts: an accounts struct that defines what accounts the instruction needs, and a handler function that contains the logic.

The initialize instruction creates a new TipJar account using a Program Derived Address.

What is a Program Derived Address (PDA)?

A PDA is a special account address deterministically derived from a set of seeds. For your tip jar, the seeds are the string "tip-jar" and the owner's wallet address.

This means each wallet can only have one tip jar. Anyone can calculate the address without storing it separately.

  • In lib.rs, find this code below the closing } of the #[program] module:
#[derive(Accounts)]
pub struct Initialize {}
  • Replace the entire Initialize struct with this version:
#[derive(Accounts)]
pub struct Initialize<'info> {
    // Create a new PDA account for the tip jar
    #[account(
        init,
        payer = owner,
        space = 8 + TipJar::INIT_SPACE,
        seeds = [b"tip-jar", owner.key().as_ref()],
        bump
    )]
    pub tip_jar: Account<'info, TipJar>,
    // The wallet creating the tip jar (pays for account rent)
    #[account(mut)]
    pub owner: Signer<'info>,
    // Required for creating new accounts on Solana
    pub system_program: Program<'info, System>,
}

What do these constraints mean?

  • init tells Anchor to create a new account. payer = owner means the owner's wallet pays for the account's storage rent.
  • space = 8 + TipJar::INIT_SPACE allocates exactly 56 bytes: 8 for the Anchor discriminator plus 48 for the TipJar fields.
  • seeds derives the PDA address from the string "tip-jar" and the owner's public key. bump lets Anchor find the valid PDA bump seed automatically.
  • Signer<'info> validates that the owner actually signed the transaction. Program<'info, System> gives access to the System Program, which handles account creation on Solana.
  • Now scroll up to the initialize function inside the #[program] module.
  • Find this code:
    pub fn initialize(_ctx: Context<Initialize>) -> Result<()> {
        Ok(())
    }
  • Replace it with this updated version:
    pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
        // Get a mutable reference to the new tip jar account
        let tip_jar = &mut ctx.accounts.tip_jar;
        // Set the owner to the wallet that called this instruction
        tip_jar.owner = ctx.accounts.owner.key();
        // Initialize counters to zero
        tip_jar.total_tips = 0;
        tip_jar.tip_count = 0;
        msg!("Tip jar initialized for owner: {}", tip_jar.owner);
        Ok(())
    }

What does this function do?

  • &mut ctx.accounts.tip_jar gives you a mutable reference to the newly created PDA account so you can write data into it.
  • ctx.accounts.owner.key() grabs the public key of the wallet that signed the transaction and stores it as the tip jar owner.
  • msg!() emits a log message that appears in transaction logs. This is Solana's version of Solidity events for debugging.

You won't see a change yet. The next substep builds the program to verify your code compiles.

✔️ Awesome, I've got everything!

Make sure you've saved lib.rs before moving on to the build step.

ⓧ I'd like to double check the full code

Here's the full lib.rs at this point. Your declare_id! value will be different from the one shown here.

use anchor_lang::prelude::*;
use anchor_lang::system_program::{transfer, Transfer};

declare_id!("11111111111111111111111111111111");

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

    pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
        let tip_jar = &mut ctx.accounts.tip_jar;
        tip_jar.owner = ctx.accounts.owner.key();
        tip_jar.total_tips = 0;
        tip_jar.tip_count = 0;
        msg!("Tip jar initialized for owner: {}", tip_jar.owner);
        Ok(())
    }
}

#[derive(Accounts)]
pub struct Initialize<'info> {
    #[account(
        init,
        payer = owner,
        space = 8 + TipJar::INIT_SPACE,
        seeds = [b"tip-jar", owner.key().as_ref()],
        bump
    )]
    pub tip_jar: Account<'info, TipJar>,
    #[account(mut)]
    pub owner: Signer<'info>,
    pub system_program: Program<'info, System>,
}

#[account]
#[derive(InitSpace)]
pub struct TipJar {
    pub owner: Pubkey,
    pub total_tips: u64,
    pub tip_count: u64,
}
Build and verify the program compiles

Time to check that all your Rust code is valid. Building the program compiles your code into a deployable .so file.

  • Build the program by running this command:
anchor build
  • You should see compilation output ending with no errors. You may see warnings about unused imports for transfer and Transfer. These warnings are expected and will go away once you use them in the next step.
  • Sync the program keys to keep the program ID consistent by running:
anchor keys sync

Why sync keys after every build?

anchor keys sync makes sure the declare_id! value in your source code matches the keypair in target/deploy/. If these get out of sync, your program will fail to deploy.

Seeing compilation errors?

  • If you see cannot find type TipJar, make sure the TipJar struct is defined outside the #[program] module, at the bottom of the file.
  • If you see mismatched types or expected struct Initialize, check that you added the lifetime parameter <'info> to pub struct Initialize<'info>.
  • If the build fails with missing tools, run cargo build-sbf --force-tools-install and retry.
  • Use the double check tab above to compare your full lib.rs against the reference code.

Help me debug my Anchor build error

Your Tip Jar account and initialize instruction are locked in. Next up, you'll add the send_tip and withdraw instructions that let anyone tip SOL and let the owner collect it.

Add Send Tip and Withdraw Instructions

Your tip jar account exists on-chain, but nobody can interact with it yet. There is no way for someone to send SOL into the jar, and no way for the owner to collect tips.

In this step, you'll add two new instructions to your program. send_tip lets anyone transfer SOL into a tip jar using a Cross-Program Invocation. withdraw lets only the owner pull SOL back out.

In this step, get ready to:
  • Write a send_tip instruction that transfers SOL via CPI to the System Program.
  • Write a withdraw instruction restricted to the tip jar owner.
  • Add custom error types for meaningful failure messages.
Write the send_tip instruction

The send_tip instruction is the Solana equivalent of a payable function in Solidity. It transfers SOL from a tipper's wallet into the tip jar's PDA account.

  • In lib.rs, find the closing } of the initialize function (the line with Ok(()) just above it).
  • Add the send_tip function below the initialize function, but still inside the pub mod tip_jar module, by copying the following code:
    pub fn send_tip(ctx: Context<SendTip>, amount: u64) -> Result<()> {
        // Reject zero-value tips
        require!(amount > 0, TipJarError::ZeroAmount);

        // Build a CPI context targeting the System Program's transfer instruction
        let cpi_context = CpiContext::new(
            ctx.accounts.system_program.to_account_info(),
            Transfer {
                from: ctx.accounts.tipper.to_account_info(),
                to: ctx.accounts.tip_jar.to_account_info(),
            },
        );
        // Execute the SOL transfer via CPI
        transfer(cpi_context, amount)?;

        // Update the tip jar's running totals
        let tip_jar = &mut ctx.accounts.tip_jar;
        tip_jar.total_tips = tip_jar.total_tips.checked_add(amount).unwrap();
        tip_jar.tip_count = tip_jar.tip_count.checked_add(1).unwrap();

        msg!(
            "Tip of {} lamports received. Total tips: {}",
            amount,
            tip_jar.total_tips
        );
        Ok(())
    }

What does this code do?

require!(amount > 0, TipJarError::ZeroAmount) rejects tips of zero lamports with a custom error. Lamports are the smallest unit of SOL (1 SOL = 1 billion lamports).

CpiContext::new builds a CPI call to the System Program's transfer instruction. This is how Solana programs move SOL between accounts. The tipper signs the transaction, so the System Program can debit their wallet.

checked_add safely increments total_tips and tip_count without risking integer overflow.

Every Anchor instruction needs an accounts struct that defines which accounts the instruction requires. The SendTip struct goes outside the #[program] module, alongside the existing Initialize struct.

  • Scroll down past the closing } of the Initialize struct.
  • Add the SendTip accounts struct below it by copying this code:
#[derive(Accounts)]
pub struct SendTip<'info> {
    // PDA for the tip jar, derived from the jar owner's pubkey
    #[account(
        mut,
        seeds = [b"tip-jar", tip_jar.owner.as_ref()],
        bump
    )]
    pub tip_jar: Account<'info, TipJar>,
    // The wallet sending the tip
    #[account(mut)]
    pub tipper: Signer<'info>,
    // Required for the SOL transfer CPI
    pub system_program: Program<'info, System>,
}

What does this struct do?

The seeds constraint re-derives the PDA to make sure the client passed the correct tip jar account. Notice the seeds use tip_jar.owner (data already stored on the account) instead of the signer's key. This is how anyone can send a tip to any owner's jar.

The tipper is marked mut because SOL will be debited from their account. The system_program is required because the CPI targets it.

You won't be able to compile yet. The send_tip function references TipJarError::ZeroAmount, which you'll define at the end of this step.

Write the withdraw instruction

The withdraw instruction lets the owner pull accumulated SOL out of the tip jar. It uses a different transfer pattern than send_tip because the tip jar PDA is owned by your program, not the System Program.

  • Back inside the pub mod tip_jar module, find the closing } of the send_tip function you just added.
  • Add the withdraw function below it by copying this code:
    pub fn withdraw(ctx: Context<Withdraw>, amount: u64) -> Result<()> {
        let tip_jar = &mut ctx.accounts.tip_jar;

        // Calculate the minimum balance needed to keep the account rent-exempt
        let rent = Rent::get()?;
        let min_balance = rent.minimum_balance(TipJar::INIT_SPACE + 8);
        let available = tip_jar
            .to_account_info()
            .lamports()
            .checked_sub(min_balance)
            .unwrap_or(0);

        // Prevent withdrawing more than what's available above rent
        require!(amount <= available, TipJarError::InsufficientFunds);

        // Move lamports directly (no CPI needed for program-owned accounts)
        tip_jar.sub_lamports(amount)?;
        ctx.accounts.owner.add_lamports(amount)?;

        msg!("Withdrew {} lamports to owner", amount);
        Ok(())
    }

Why is this different from send_tip?

In send_tip, the tipper's wallet is owned by the System Program, so you need a CPI for the System Program to authorize the debit. In withdraw, the tip jar PDA is owned by your program. Your program can directly manipulate its lamports using sub_lamports and add_lamports.

The rent check prevents the owner from draining the account below its rent-exempt minimum. On Solana, every account must hold enough lamports to cover its storage costs. If the balance drops below this threshold, the runtime will garbage-collect the account.

Now add the Withdraw accounts struct outside the #[program] module.

  • Scroll down past the SendTip struct you added earlier.
  • Add the Withdraw accounts struct below it by copying this code:
#[derive(Accounts)]
pub struct Withdraw<'info> {
    // PDA with has_one constraint to enforce owner-only access
    #[account(
        mut,
        seeds = [b"tip-jar", owner.key().as_ref()],
        bump,
        has_one = owner
    )]
    pub tip_jar: Account<'info, TipJar>,
    // Only the original tip jar creator can withdraw
    #[account(mut)]
    pub owner: Signer<'info>,
}

What does has_one = owner do?

The has_one = owner constraint checks that the owner field stored inside the TipJar account matches the public key of the owner account in the transaction. If someone else tries to call withdraw, Anchor rejects the transaction before your code even runs.

This is the Solana equivalent of an onlyOwner modifier in Solidity.

The program still won't compile because both instructions reference TipJarError, which doesn't exist yet. You'll fix that next.

Add a custom error enum

Anchor lets you define custom errors using the #[error_code] attribute. Each variant gets a readable message that shows up in test output and transaction logs.

  • Scroll to the very bottom of lib.rs, after the TipJar struct.
  • Add the error enum by copying this code at the end of the file:
#[error_code]
pub enum TipJarError {
    #[msg("Insufficient funds in tip jar")]
    InsufficientFunds,
    #[msg("Tip amount must be greater than zero")]
    ZeroAmount,
}

What does this code do?

InsufficientFunds triggers when someone tries to withdraw more lamports than are available above the rent-exempt minimum. ZeroAmount triggers when someone sends a tip of zero.

The #[msg(...)] attribute attaches a human-readable message to each error. Without custom errors, Anchor would return a generic error code that is much harder to debug.

  • Save lib.rs.
  • Build the program and sync the keys by running these commands:
anchor build
anchor keys sync
  • You should see the build complete with no errors, producing target/deploy/tip_jar.so.

Seeing a compile error?

If you see cannot find value TipJarError, make sure you added the #[error_code] enum at the bottom of the file, after the TipJar struct.

If you see cannot find type SendTip or cannot find type Withdraw, check that the accounts structs are placed outside the #[program] module. They should be at the same level as the Initialize struct.

Double check your full code against the tab below. Help me debug my Anchor build error for the Tip Jar send_tip and withdraw instructions.

✔️ Awesome, I've got everything!

Great. Double check you've saved lib.rs and that anchor build completed with no errors.

ⓧ I'd like to double check the full code

Here is the complete lib.rs file at this point. Compare it against yours to spot any differences.

use anchor_lang::prelude::*;
use anchor_lang::system_program::{transfer, Transfer};

declare_id!("11111111111111111111111111111111");

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

    pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
        let tip_jar = &mut ctx.accounts.tip_jar;
        tip_jar.owner = ctx.accounts.owner.key();
        tip_jar.total_tips = 0;
        tip_jar.tip_count = 0;
        msg!("Tip jar initialized for owner: {}", tip_jar.owner);
        Ok(())
    }

    pub fn send_tip(ctx: Context<SendTip>, amount: u64) -> Result<()> {
        require!(amount > 0, TipJarError::ZeroAmount);

        let cpi_context = CpiContext::new(
            ctx.accounts.system_program.to_account_info(),
            Transfer {
                from: ctx.accounts.tipper.to_account_info(),
                to: ctx.accounts.tip_jar.to_account_info(),
            },
        );
        transfer(cpi_context, amount)?;

        let tip_jar = &mut ctx.accounts.tip_jar;
        tip_jar.total_tips = tip_jar.total_tips.checked_add(amount).unwrap();
        tip_jar.tip_count = tip_jar.tip_count.checked_add(1).unwrap();

        msg!(
            "Tip of {} lamports received. Total tips: {}",
            amount,
            tip_jar.total_tips
        );
        Ok(())
    }

    pub fn withdraw(ctx: Context<Withdraw>, amount: u64) -> Result<()> {
        let tip_jar = &mut ctx.accounts.tip_jar;
        let rent = Rent::get()?;
        let min_balance = rent.minimum_balance(TipJar::INIT_SPACE + 8);
        let available = tip_jar
            .to_account_info()
            .lamports()
            .checked_sub(min_balance)
            .unwrap_or(0);

        require!(amount <= available, TipJarError::InsufficientFunds);

        tip_jar.sub_lamports(amount)?;
        ctx.accounts.owner.add_lamports(amount)?;

        msg!("Withdrew {} lamports to owner", amount);
        Ok(())
    }
}

#[derive(Accounts)]
pub struct Initialize<'info> {
    #[account(
        init,
        payer = owner,
        space = 8 + TipJar::INIT_SPACE,
        seeds = [b"tip-jar", owner.key().as_ref()],
        bump
    )]
    pub tip_jar: Account<'info, TipJar>,
    #[account(mut)]
    pub owner: Signer<'info>,
    pub system_program: Program<'info, System>,
}

#[derive(Accounts)]
pub struct SendTip<'info> {
    #[account(
        mut,
        seeds = [b"tip-jar", tip_jar.owner.as_ref()],
        bump
    )]
    pub tip_jar: Account<'info, TipJar>,
    #[account(mut)]
    pub tipper: Signer<'info>,
    pub system_program: Program<'info, System>,
}

#[derive(Accounts)]
pub struct Withdraw<'info> {
    #[account(
        mut,
        seeds = [b"tip-jar", owner.key().as_ref()],
        bump,
        has_one = owner
    )]
    pub tip_jar: Account<'info, TipJar>,
    #[account(mut)]
    pub owner: Signer<'info>,
}

#[account]
#[derive(InitSpace)]
pub struct TipJar {
    pub owner: Pubkey,
    pub total_tips: u64,
    pub tip_count: u64,
}

#[error_code]
pub enum TipJarError {
    #[msg("Insufficient funds in tip jar")]
    InsufficientFunds,
    #[msg("Tip amount must be greater than zero")]
    ZeroAmount,
}

Your Tip Jar program is feature-complete. It can create jars, receive tips, and withdraw funds. Next up, you'll write automated tests and run them against a local Solana validator to prove everything works.

Write Tests and Run Against a Local Validator

Your Tip Jar program compiles, but you have no proof it actually works on-chain. A passing build only means the Rust syntax is valid. It does not guarantee that your initialize instruction creates a PDA correctly, or that send_tip transfers SOL, or that withdraw enforces the owner check.

In this step, you will write TypeScript tests that call each instruction and assert the results. Anchor makes this easy. A single anchor test command spins up a local validator, deploys your program, runs the tests, and tears everything down automatically.

In this step, get ready to:
  • Write four TypeScript tests covering initialize, send tip, withdraw, and error handling.
  • Run the full test suite against a local Solana validator.
  • Inspect on-chain program logs to see your msg!() output.
Write the test scaffold and initialize test

Your tests need to derive the PDA address on the client side using the same seeds as your program. This lets you pass the correct account address into each instruction call.

  • Open tests/tip-jar.ts in VS Code.
  • Clear the default placeholder code and paste the following:
import * as anchor from "@anchor-lang/core";
import { Program } from "@anchor-lang/core";
import { TipJar } from "../target/types/tip_jar";
import { PublicKey, SystemProgram, LAMPORTS_PER_SOL } from "@solana/kit";
import { assert } from "chai";

describe("tip-jar", () => {
  // Set up the Anchor provider and program reference
  const provider = anchor.AnchorProvider.env();
  anchor.setProvider(provider);

  const program = anchor.workspace.tipJar as Program<TipJar>;
  const owner = provider.wallet.publicKey;

  // Derive the PDA address using the same seeds as the program
  const [tipJarPda] = PublicKey.findProgramAddressSync(
    [Buffer.from("tip-jar"), owner.toBuffer()],
    program.programId
  );

  it("Initializes a tip jar", async () => {
    // Call the initialize instruction
    await program.methods
      .initialize()
      .accounts({
        tipJar: tipJarPda,
        owner: owner,
        systemProgram: SystemProgram.programId,
      })
      .rpc();

    // Fetch the account and verify its initial state
    const tipJarAccount = await program.account.tipJar.fetch(tipJarPda);
    assert.ok(tipJarAccount.owner.equals(owner));
    assert.equal(tipJarAccount.totalTips.toNumber(), 0);
    assert.equal(tipJarAccount.tipCount.toNumber(), 0);
  });

What does this code do?

The imports pull in @anchor-lang/core (Anchor's TypeScript client for 1.0) and @solana/kit (Solana's web3 library). The TipJar type import comes from the auto-generated types that anchor build created in your target/types/ folder.

PublicKey.findProgramAddressSync derives the PDA address client-side using ["tip-jar", owner_pubkey] as seeds. These seeds must match the ones in your Rust #[account] constraint exactly, or the test will fail with an account mismatch error.

The initialize test calls the instruction, then fetches the on-chain account data and asserts that owner, totalTips, and tipCount are all set to their expected initial values.

  • You will not see any changes yet. The remaining tests go directly below this code.
Add the send tip, withdraw, and error tests

The remaining three tests exercise the core flows: tipping, withdrawing, and rejecting invalid input.

  • In tests/tip-jar.ts, directly below the initialize test you just wrote, paste the following three tests and the closing brackets:
  it("Sends a tip", async () => {
    // Send 0.5 SOL as a tip
    const tipAmount = 0.5 * LAMPORTS_PER_SOL;

    await program.methods
      .sendTip(new anchor.BN(tipAmount))
      .accounts({
        tipJar: tipJarPda,
        tipper: owner,
        systemProgram: SystemProgram.programId,
      })
      .rpc();

    // Verify the tip jar updated its running totals
    const tipJarAccount = await program.account.tipJar.fetch(tipJarPda);
    assert.equal(tipJarAccount.totalTips.toNumber(), tipAmount);
    assert.equal(tipJarAccount.tipCount.toNumber(), 1);
  });

  it("Withdraws tips", async () => {
    // Withdraw 0.25 SOL from the tip jar
    const withdrawAmount = 0.25 * LAMPORTS_PER_SOL;

    await program.methods
      .withdraw(new anchor.BN(withdrawAmount))
      .accounts({
        tipJar: tipJarPda,
        owner: owner,
      })
      .rpc();

    // Log the remaining balance for manual inspection
    const tipJarAccount = await program.account.tipJar.fetch(tipJarPda);
    console.log(
      "Remaining lamports in tip jar account:",
      (await provider.connection.getAccountInfo(tipJarPda)).lamports
    );
  });

  it("Rejects zero-amount tips", async () => {
    // Attempt to send 0 lamports, expecting a ZeroAmount error
    try {
      await program.methods
        .sendTip(new anchor.BN(0))
        .accounts({
          tipJar: tipJarPda,
          tipper: owner,
          systemProgram: SystemProgram.programId,
        })
        .rpc();
      assert.fail("Should have thrown an error");
    } catch (err) {
      assert.include(err.message, "ZeroAmount");
    }
  });
});

What do these tests cover?

The "Sends a tip" test transfers 0.5 SOL (in lamports) to the tip jar using a CPI to the System Program, then asserts that totalTips and tipCount updated correctly.

The "Withdraws tips" test pulls 0.25 SOL back to the owner and logs the remaining balance. This exercises the has_one = owner constraint that enforces only the original creator can withdraw.

The "Rejects zero-amount tips" test confirms your ZeroAmount custom error fires when someone tries to tip 0 lamports. The test expects the transaction to fail and checks the error message.

  • Save tests/tip-jar.ts.

✔️ Awesome, I've got everything!

Great. Double check you have saved tests/tip-jar.ts before moving on.

ⓧ I'd like to double check the full code

Here is the complete tests/tip-jar.ts file. Compare it against yours:

import * as anchor from "@anchor-lang/core";
import { Program } from "@anchor-lang/core";
import { TipJar } from "../target/types/tip_jar";
import { PublicKey, SystemProgram, LAMPORTS_PER_SOL } from "@solana/kit";
import { assert } from "chai";

describe("tip-jar", () => {
  const provider = anchor.AnchorProvider.env();
  anchor.setProvider(provider);

  const program = anchor.workspace.tipJar as Program<TipJar>;
  const owner = provider.wallet.publicKey;

  const [tipJarPda] = PublicKey.findProgramAddressSync(
    [Buffer.from("tip-jar"), owner.toBuffer()],
    program.programId
  );

  it("Initializes a tip jar", async () => {
    await program.methods
      .initialize()
      .accounts({
        tipJar: tipJarPda,
        owner: owner,
        systemProgram: SystemProgram.programId,
      })
      .rpc();

    const tipJarAccount = await program.account.tipJar.fetch(tipJarPda);
    assert.ok(tipJarAccount.owner.equals(owner));
    assert.equal(tipJarAccount.totalTips.toNumber(), 0);
    assert.equal(tipJarAccount.tipCount.toNumber(), 0);
  });

  it("Sends a tip", async () => {
    const tipAmount = 0.5 * LAMPORTS_PER_SOL;

    await program.methods
      .sendTip(new anchor.BN(tipAmount))
      .accounts({
        tipJar: tipJarPda,
        tipper: owner,
        systemProgram: SystemProgram.programId,
      })
      .rpc();

    const tipJarAccount = await program.account.tipJar.fetch(tipJarPda);
    assert.equal(tipJarAccount.totalTips.toNumber(), tipAmount);
    assert.equal(tipJarAccount.tipCount.toNumber(), 1);
  });

  it("Withdraws tips", async () => {
    const withdrawAmount = 0.25 * LAMPORTS_PER_SOL;

    await program.methods
      .withdraw(new anchor.BN(withdrawAmount))
      .accounts({
        tipJar: tipJarPda,
        owner: owner,
      })
      .rpc();

    const tipJarAccount = await program.account.tipJar.fetch(tipJarPda);
    console.log(
      "Remaining lamports in tip jar account:",
      (await provider.connection.getAccountInfo(tipJarPda)).lamports
    );
  });

  it("Rejects zero-amount tips", async () => {
    try {
      await program.methods
        .sendTip(new anchor.BN(0))
        .accounts({
          tipJar: tipJarPda,
          tipper: owner,
          systemProgram: SystemProgram.programId,
        })
        .rpc();
      assert.fail("Should have thrown an error");
    } catch (err) {
      assert.include(err.message, "ZeroAmount");
    }
  });
});
Run the tests

Anchor's test runner handles the entire lifecycle for you. It starts a local validator via Surfpool, builds and deploys your program, runs every it() block, and shuts the validator down when done.

  • Run the full test suite by running this command:
anchor test

What does this command do?

Behind the scenes, anchor test performs four steps automatically: (1) starts a local Solana validator using Surfpool, (2) builds and deploys your program to that validator, (3) runs all test files in the tests/ directory, and (4) shuts down the validator when tests finish.

  • Wait for the command to complete. You should see output showing four passing tests:
  tip-jar
    ✔ Initializes a tip jar
    ✔ Sends a tip
    ✔ Withdraws tips
    ✔ Rejects zero-amount tips

  4 passing

Tests failing or not running?

If you see Error: No such file or directory (os error 2), Node.js or Yarn may not be installed correctly. Verify by running node -v and yarn -v.

If you see a port conflict error, make sure no other local validator is running. Close any solana-test-validator processes before retrying.

If a test fails with an account mismatch, double check that the PDA seeds in your test (Buffer.from("tip-jar")) match the seeds in lib.rs (b"tip-jar") exactly.

Help me debug my failing Anchor tests.

Inspect the program logs

Every msg!() call in your Rust program emits a log entry during execution. Anchor streams these logs to a file after each test run. This is your debugging tool for understanding what happened on-chain.

  • List the log files by running this command:
ls .anchor/program-logs/
  • You should see a .log file named after your program ID.
  • View the log contents by running this command (replace the filename with what you see from ls):
cat .anchor/program-logs/*.log

What should I see in the logs?

You should see log entries for each instruction your tests called. Look for lines like Tip jar initialized for owner:, Tip of ... lamports received, and Withdrew ... lamports to owner. These come from the msg!() macros you wrote in lib.rs.

These logs are the Solana equivalent of Solidity events for debugging. Whenever something goes wrong in a future project, check this file first.

Secret mission

Deploy to Solana Devnet

Your Tip Jar works on your local machine. But Solana programs are meant to live on real networks where anyone can interact with them. Deploy your Tip Jar to Solana devnet and verify it on the public blockchain.

Before you go

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally (plus free devnet SOL), so there are no ongoing costs.

Resources you used:

  • Solana development tools installed in WSL (Rust, Solana CLI, Anchor CLI, Node.js, Yarn).
  • The tip-jar Anchor project folder on your Desktop.
  • Solana keypair stored at ~/.config/solana/id.json.
  • Build artifacts in the target/ directory inside your project.
  • (Secret Mission) Program deployed to Solana devnet.

Keep everything running

No action needed. Choose this if you are still actively building on Solana or plan to extend your Tip Jar program.

  • Your tip-jar project folder stays on your Desktop, ready for future development.
  • Your Solana keypair and CLI configuration remain in place for your next project.
  • All installed tools (Rust, Solana CLI, Anchor CLI) stay available in your WSL environment.

Pause - I'll come back to this later

Free up disk space from build artifacts while keeping your project files and tools installed.

  • Delete the target/ directory inside your tip-jar folder to reclaim disk space. Run rm -rf target from inside your project folder.
  • Keep the rest of the project folder. You can rebuild anytime by running anchor build.
  • Leave your Solana CLI configuration and keypair in place for when you come back.

Delete - I don't want to use this again

Remove all project resources and optionally uninstall the Solana toolchain.

  • Delete the entire tip-jar project folder from your Desktop.
  • Delete your Solana keypair by running rm ~/.config/solana/id.json in your WSL terminal.
  • To uninstall the Solana CLI, run rm -rf ~/.local/share/solana and remove the Solana PATH entry from your shell profile.
  • To uninstall Rust, run rustup self uninstall in your WSL terminal.
  • To uninstall Anchor CLI, run cargo uninstall anchor-cli before uninstalling Rust.

Mission accomplished

That's a Wrap!

Nice work! You just built your first Solana program in Rust. That is a huge step into the Solana ecosystem.

You've learned how to:

  • Built a Tip Jar program with three instructions (initialize, send_tip, and withdraw) using the Anchor framework and Anchor macros like #[program], #[account], and #[derive(Accounts)].
  • Used Program Derived Addresses (PDAs) to create deterministic, program-owned accounts and performed a Cross-Program Invocation (CPI) to the System Program for SOL transfers.
  • Wrote automated TypeScript tests and ran them against a local Solana validator to verify all three instructions work correctly.
  • Deployed your program to Solana devnet and verified it on Solana Explorer.

Ready to quiz yourself?