How to Build an Expense Tracker on Solana with Anchor

John FáwọléJohn Fáwọlé
9 min read

How would you feel if there were an application on Solana for you to track your expenses? Great, right? That is the goal of this tutorial – to build an expense tracking program with Anchor.

Anchor is quite a dynamic language for any developer to express their ideas and build them into products on Solana.

Apart from the use case, this tutorial will also help you become more comfortable with Anchor and also learn more about the language along the way.

Scope of this Tutorial

This tutorial is more of a CRUD onchain application. It enables users to create an expense, modify, or delete it.

In the process of creating this application, you will also learn more about both Anchor and how SVM works.

For instance, you'd get to know more about the practical application of seed bumps, contexts, PDA and so on.

After writing the program, you'd also test it with a TypeScript client to be sure the program works as expected.

Obviously, this will also make you more comfortable with TypeScript.

Setting Up Anchor

Before you can build a program with Anchor, you obviously should have it running on your machine. It’s so simple to setup.

Simply run this command in your Ubuntu [or perhaps Bash]:

curl --proto '=https' --tlsv1.2 -sSfL https://solana-install.solana.workers.dev | bash

It will not only install Anchor, but every other dependency you need to have it run. This includes:

  • Solana CLI

Once you are done running this, you should have this printed on your terminal:

Installed Versions:
Rust: rustc 1.85.0 (4d91de4e4 2025-02-17)
Solana CLI: solana-cli 2.1.15 (src:53545685; feat:3271415109, client:Agave)
Anchor CLI: anchor-cli 0.31.0
Node.js: v23.9.0
Yarn: 1.22.1

Installation complete. Please restart your terminal to apply all changes.

Step-by-step Guide on Building An Expense Tracker with Anchor

Now that you have Anchor setup, it’s time to commence writing our program. For this tutorial, I’ll recommend using the Solana playground online IDE.

You can create a program file with lib.rs In this program, we will be utilizing structs to pack our application's features.

Step 1: Importing Anchor and Kickstarting the Program

use anchor_lang::prelude::*;

declare_id!("");

#[program]

pub mod etracker {

    use super::*;

In the first line above, we imported the prelude of Anchor. Essentially, this acts more like a library that contains everything we need to build our program with Anchor.

Remember that we are not using native Rust, so prelude importation gives us access to types and macros we will need to use Anchor conveniently and without have an unduly verbose program.

Some of the types and macros in prelude include #[program], Context, system and so on.

Immediately after importing Anchor, we declared [or rather set] our program ID – this is the public key of your program. If it were to be in the EVM world, program ID could have been called contract address.

Hope you get it.

Now, your program ID will be needed to generate Program Derived Address (PDA) and also for validation when you are writing a client test.

In a test project like this, Solana playground automatically supplies one. All you have to do is just put the ””.

The #[program] macro we wrote next tells the compiler [and SVM by extension] that the block of code next to this should be treated as the core part of the code.

These are the functions that users can call and interact with.

Once that was done, we went ahead to create the etracker public module. Meanwhile, the way pub works in SVM is similar to public functions on Ethereum.

Mod there refers to a module, which is more like a packet for functions and or instructions.

And here is where you must pay attention:

use super::* has a close connection with the anchor prelude we set at the beginning of the program. It allows our local functions to use the types that anchor prelude imported.

Step 2: Creating the Initialize Expense Function

pub fn initialize_expense(

        ctx: Context<InitializeExpense>,

        id: u64,

        merchant_name: String,

        amount: u64,

    ) -> Result<()> {

        let expense_account: &mut Account<ExpenseAccount> = &mut ctx.accounts.expense_account;



        expense_account.id = id;

        expense_account.merchant_name = merchant_name;

        expense_account.amount = amount;

        expense_account.owner = *ctx.accounts.authority.key;

        Ok(())

    }

This is a public function named initialize_expense. It has 2 main parameters: context and ID.

The work of Context above is deeper than it appears. It is an Anchor type, which works on account validation and access security constraints before a user can call a public function.

NB: You can read more about Context here or I might write another technical blog on it soon.

The id contains the details a user must pass into the function as arguments. In the instant case, we demand id, name, and amount.

We want this function to return something, hence the reason we included Result. This is why we have Ok(()) at the end of the program.

We could have gone on another route of error handling, where we set the error in case anything in the function fails.

Moving on, we created a variable called expense_account within the initialize_expense function, and made it mutable so we can modify it later as we want.

Subsequently, we assigned the parameters of the function to their respective types.

But if you notice, we set the owner to *ctx.accounts.authority.key. This simply means the owner has access to the authority key of the context accounts.

Step 3: Creating a Modification Function

   pub fn modify_expense(

    ctx:Context<ModifyExpense>,

    _id: u64,

    merchant_name:String,

    amount: u64,

   ) -> Result<()> {

    let expense_account: &mut Account<ExpenseAccount> = &mut ctx.accounts.expense_account;

    expense_account.merchant_name = merchant_name;

    expense_account.amount = amount;

    Ok(())

   }

}

This is a function that modifies expenses. It takes in some parameters like id, name, and amount. Then these parameters were assigned in the body of the function.

For context, this function makes it possible to change anything on an already initialized expense.

Step 4: Creating the Initialize Expense Struct

We have created an initialize expense function above. Nonetheless, we will also create an initialize expense struct.

Don’t confuse both of them.

The first one defines what accounts can do when they call the function, while the latter is more about the accounts that can interact in the first place.

#[derive(Accounts)]

#[instruction(id: u64)]

pub struct InitializeExpense<'info> {

    #[account(mut)]

    pub authority: Signer<'info>,

    #[account(

        init,

        payer = authority,

        space = 8 + 8 + 32 + (4 + 12) + 8 + 1,

        seeds = [b"expense", authority.key().as_ref(), id.to_le_bytes().as_ref()],

        bump

    )]

    pub expense_account: Account<'info, ExpenseAccount>,

    pub system_program: Program<'info, System>,

}

#[derive(Accounts)] is a macro in Anchor that deals more with the cryptographic security of the interacting accounts. It validates accounts, verifies signatures, and does general safety-check.

#[instruction(id: u64)] essentially passes args into the #[derive] struct. The id that the instruction passes will be used in the struct of accounts.

Then we created a public struct called InitializeExpense and passed info as a lifetime annotation.

Since info has quite an infinite duration, it makes the struct enjoy the same attribute.

Then we created a mutable account, meaning we can change its state, and gave the Signer public authority.

Once that was done, we initialized our account and assign various account authority variables.

Step 5: Modify and Delete Expenses Struct

#[derive(Accounts)]

#[instruction(id : u64)]

pub struct ModifyExpense<'info>{

    #[account(mut)]

    pub authority: Signer<'info>,

    #[account(

        mut,

        seeds = [b"expense", authority.key().as_ref(), id.to_le_bytes().as_ref()],

        bump

    )]

    pub expense_account: Account<'info, ExpenseAccount>,

    pub system_program: Program<'info, System>,

}

#[derive(Accounts)]

#[instruction(id: u64)]

pub struct DeleteExpense<'info> {

    #[account(mut)]

    pub authority: Signer<'info>,

    #[account(

        mut,

        close = authority,

        seeds = [b"expense", authority.key().as_ref(), id.to_le_bytes().as_ref()],

        bump

    )]

    pub expense_account: Account<'info, ExpenseAccount>,

    pub system_program: Program<'info, System>,

}

This is similar to what we explained above. Only that, in the instant case, it is for expenses modification.

Step 6: Expense Account Struct

#[account]

#[derive(Default)]

pub struct ExpenseAccount{

    pub id: u64,

    pub owner: Pubkey,

    pub merchant_name: String,

    pub amount: u64,

}

The #[derive(Default)] macro here creates default values for the struct even when no tangible has been passed into them. This will be quite useful when we are testing.

Then we created some public variables, such as id, owner, name, and amount.

Testing the Expense Tracker Application with TypeScript

It is not enough to write a program, we should also write end-to-end tests to ensure it works as expected and catches low-hanging security vulnerabilities.

Create a index.test.ts file.

Step 1: Setting Up The PDA

describe("Expense Tracker", async () => {

  let merchantName = "test";

  let amount = 100;

  let id = 1;

  let merchantName2 = "test 2";

  let amount2 = 200;

  let [expense_account] = anchor.web3.PublicKey.findProgramAddressSync(

    [

      Buffer.from("expense"),

      pg.wallet.publicKey.toBuffer(),

      new BN(id).toArrayLike(Buffer, "le", 8),

    ],

    pg.program.programId

  );

Here, we setup [or rather describe] the sample variables along with their assigned values.

Up next, we simulated a Program Derived Address (PDA)j we will later use in our test.

Step 2: Initialize Expense Test

  it("Initialize Expense", async () => {

    await pg.program.methods

      .initializeExpense(new anchor.BN(id), merchantName, new anchor.BN(amount))

      .accounts({

        expenseAccount: expense_account,

        authority: pg.wallet.publicKey,

      })

      .rpc();

  });

This test calls the initializeExpense function in our program and passes the parameters it needs. Once that was set, we initialized an expense by calling .rpc().

Step 3: Modify Expense Test

  it("Modify Expense", async () => {

    await pg.program.methods

      .modifyExpense(new anchor.BN(id), merchantName2, new anchor.BN(amount2))

      .accounts({

        expenseAccount: expense_account,

        authority: pg.wallet.publicKey,

      })

      .rpc();

  });

This is similar to the test above. In this case, we are trying to modify something and want to test if it will be successful.

Step 4: Delete Expense Test

  it("Delete Expense", async () => {

    await pg.program.methods

      .deleteExpense(new anchor.BN(id))

      .accounts({

        expenseAccount: expense_account,

        authority: pg.wallet.publicKey,

      })

      .rpc();

  });

If you remember, we passed 3 values in our initializeExpense:

  • Id

  • Expense_account

  • Authority

We will try to delete for a particular user.

Demo

Load up your lib.rs program by clicking the Build button:. You should get something like this:

Loading Anchor CLI...
Success.

Then run the test by clicking the Test button. You should get something like this:

Running tests...
  index.test.ts:
  Expense Tracker
    1) Initialize Expense
    ✔ Modify Expense (1593ms)
    2) Delete Expense
  1 passing (3s)
  2 failing
  1) Expense Tracker
       Initialize Expense:
     failed to send transaction: Transaction simulation failed: Error processing Instruction 0: custom program error: 0x0


  2) Expense Tracker
       Delete Expense:
     TypeError: pg.program.methods.deleteExpense is not a function

As you can see, some of our tests failed probably due to network errors.

Conclusion

This Expense Tracker project is a great one for anyone trying to program on Solana with Anchor.

Apart from the use cases of the project, you get to encounter concepts—such as PDA—that will spur you to learn more about how the Solana Virtual Machine works.

The more you write friendly programs like this, the more fluent you become with Anchor. If you enjoyed reading this, share it on Twitter and tag me – @jofawole.

Keep hacking!

0
Subscribe to my newsletter

Read articles from John Fáwọlé directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

John Fáwọlé
John Fáwọlé

Web3 Technical Writer | Content Lead | Marketing Strategist | For Devs