Create your own
Lesson illustration

Building Program Instructions with Anchor Clients

Hello! Let's dive into our next lesson.

In our last session, you mastered the art of manually constructing transactions with @solana/web3.js v2. You learned how to assemble instructions, define account metadata, and package everything into a transaction. While powerful, you probably noticed it's quite verbose and requires you to manage many low-level details.

Today, we'll explore a much more streamlined and productive workflow. You will learn how to use the client generated by the Anchor framework to build and send transactions. Think of the manual method as writing raw database queries, whereas the Anchor client is like using a well-documented ORM (Object-Relational Mapper) or an auto-generated SDK for a web API. It abstracts away the boilerplate, reduces errors, and lets you focus on your application's logic.

1. The Bridge Between On-Chain and Off-Chain: The IDL

The magic behind Anchor's client-side tooling is the Interface Description Language (IDL). When you build an Anchor program, it generates a JSON file that completely describes your program's public interface.

  • What it is: The IDL is a machine-readable contract that lists all your program's instructions, the arguments they expect, the accounts they require, and any custom data structures (structs and enums) you've defined.
  • The Analogy: For you as a web developer, the best analogy is an OpenAPI (Swagger) specification. Just as an OpenAPI spec allows tools to auto-generate client libraries for a REST API, the Anchor IDL allows @coral-xyz/anchor to create a typed, user-friendly client for your on-chain program.

When you run anchor build, two crucial files are created in your project's target/ directory:

  1. target/idl/<program_name>.json: The IDL file.
  2. target/types/<program_name>.ts: TypeScript type definitions generated from the IDL.

Let's see this in action.

Solana Bytes - Intro to Anchor

This short clip from a Solana Bytes video provides a perfect visual of how the IDL is generated and then immediately put to use in a client-side test script.

Watch from 03:54 to 05:40. Focus on how the .json file is exported and then used to create a program object. Observe the client-side syntax: program.methods.hello()..., which is what we'll be learning today.

As you saw, the IDL enables the client library to know that a hello method exists and what accounts it needs. This eliminates the manual work of constructing the instruction byte-for-byte.

2. Setting Up the Anchor Client

To interact with an Anchor program, your client-side project needs the @coral-xyz/anchor library. We will also set up a Program object, which is the main entry point for all our interactions.

Client-Side Development with Anchor

The official Anchor documentation and the 'Anchor for Dummies' guide explain this setup process well. Let's review the key concepts from these resources.

Read the sections 'Anchor Client SDK' and 'Program'. Focus on understanding the three key components: the Connection, the Wallet, and the Provider, and how they are used to create the central 'Program' object. Don't worry about the React hooks (useConnection, useAnchorWallet); we will adapt this for our script-based environment.

Let's synthesize this into a practical script. The core components are:

  • Connection: The RPC connection to a Solana cluster, which you're already familiar with.
  • Wallet: An object that can sign transactions. We'll use our file-based keypair for this.
  • AnchorProvider: A convenience object from Anchor that bundles the Connection and Wallet.
  • Program: The main object for interacting with our specific on-chain program, created using the provider, the program's ID, and its IDL.

Here is a boilerplate setup you can use. For this lesson, we will interact with a simple, pre-deployed "counter" program on devnet.

Project Setup

  1. Continue in the same project directory from the last lesson.

  2. Install the anchor client library:
    npm install @coral-xyz/anchor

  3. Create a new directory src/idl and save the following IDL file as src/idl/counter.json. This is the "API spec" for our on-chain program.

    {
      "version": "0.1.0",
      "name": "counter",
      "instructions": [
        {
          "name": "initialize",
          "accounts": [
            { "name": "counter", "isMut": true, "isSigner": true },
            { "name": "user", "isMut": true, "isSigner": true },
            { "name": "systemProgram", "isMut": false, "isSigner": false }
          ],
          "args": []
        },
        {
          "name": "increment",
          "accounts": [
            { "name": "counter", "isMut": true, "isSigner": false }
          ],
          "args": []
        }
      ],
      "accounts": [
        {
          "name": "Counter",
          "type": {
            "kind": "struct",
            "fields": [
              { "name": "count", "type": "u64" }
            ]
          }
        }
      ]
    }
    
  4. Replace the contents of your src/index.ts with the following setup code:

    // src/index.ts
    import {
      Connection,
      Keypair,
      PublicKey,
      SystemProgram,
      clusterApiUrl,
    } from '@solana/web3.js';
    import { Program, AnchorProvider, Wallet } from '@coral-xyz/anchor';
    import { createKeypairFromSecretKey } from '@solana/keys';
    
    // Import the IDL
    import idl from './idl/counter.json';
    
    // Your local keypair that will pay for transactions
    const secret = new Uint8Array([
      /* PASTE YOUR 64-BYTE SECRET KEY ARRAY HERE */
    ]);
    const payer = createKeypairFromSecretKey(secret);
    
    // The address of our deployed program on devnet.
    const PROGRAM_ID = new PublicKey("CounrRXJn1R2hn5hVfV2QdYV9dmsiUP55TfMmsvJmjbS");
    
    async function main() {
      // 1. Setup connection and provider
      const connection = new Connection(clusterApiUrl('devnet'), 'confirmed');
      const wallet = new Wallet(payer);
      const provider = new AnchorProvider(connection, wallet, {
        commitment: 'confirmed',
      });
    
      // 2. Create the Program object
      // We can use the 'any' type here since we're in a script and don't have the generated types.
      // In a full dApp, you'd import the program-specific type from the target/types file.
      const program = new Program(idl as any, PROGRAM_ID, provider);
    
      console.log(`Program loaded. Program ID: ${program.programId}`);
      console.log(`Payer: ${payer.publicKey.toBase58()}`);
    
      // We will interact with the program here
    
      try {
        // Airdrop if needed
        const balance = await connection.getBalance(payer.publicKey);
        if (balance < 1 * 1e9) { // 1 SOL
          console.log('Airdropping 1 SOL...');
          await connection.requestAirdrop(payer.publicKey, 1 * 1e9);
        }
    
        // The rest of the logic will go here
    
      } catch (error) {
        console.error('❌ An error occurred:', error);
      }
    }
    
    main();
    

Action Required: Just like last time, paste your secret key into the secret array.

3. Building and Sending Transactions with MethodsBuilder

Now for the main event. Anchor's MethodsBuilder provides a fluent, chainable API to construct and send transactions. It reads the IDL and exposes your program's instructions as methods.

The basic pattern is:
program.methods.instructionName(args...).accounts({ ... }).rpc()

Let's break this down by calling our counter program's initialize instruction. This instruction creates a new on-chain account to hold our counter's value.

Step 1: Initialize the Counter

The initialize instruction needs a new account to store the counter data. We must create a keypair for this new account on the client-side first.

Add the following code inside the try block of your main function:

// (Continuing inside the `try` block)

// 3. Generate a keypair for the new counter account
const counterAccount = Keypair.generate();
console.log(`New counter account address: ${counterAccount.publicKey.toBase58()}`);

// 4. Call the 'initialize' instruction
console.log("Calling the 'initialize' instruction...");

const txSignature = await program.methods
  .initialize() // The instruction name from the IDL (in camelCase)
  .accounts({
    counter: counterAccount.publicKey, // The new account's public key
    user: payer.publicKey,             // The payer's public key
    systemProgram: SystemProgram.programId, // Required for account creation
  })
  .signers([counterAccount]) // The new account must also sign to be created
  .rpc();

console.log(`✅ Transaction successful!`);
console.log(`   Signature: ${txSignature}`);
console.log(`   You can view the transaction on Solana Explorer:`);
console.log(`   https://explorer.solana.com/tx/${txSignature}?cluster=devnet`);

// We'll add the increment logic next

What's happening here?

  • .methods.initialize(): We call our instruction by its name. It takes no arguments, so the parentheses are empty.
  • .accounts({...}): We provide the public keys for the accounts listed in the IDL. The keys in this object (counter, user, systemProgram) match the name fields in the IDL's accounts array for the initialize instruction. Anchor handles the rest (isMut, isSigner flags).
  • .signers([counterAccount]): The initialize instruction creates a new account. The Solana runtime requires that the keypair for any new account being created must sign the transaction. The payer (from our provider) is automatically included as a signer, but we must explicitly add the counterAccount keypair.
  • .rpc(): This is the final step. It builds the transaction, signs it with the provider's wallet and any extra signers, sends it to the network, and awaits confirmation. It returns the transaction signature.

Run the script (npx esrun src/index.ts). You should see a success message and a transaction link!

Step 2: Increment the Counter

Now let's call the increment instruction on the account we just created. This instruction only needs to know the address of the counter account it should modify.

Add this code after the initialize call:

// (Continuing after the initialize call logs)

// Wait a moment for the chain to update
await new Promise(resolve => setTimeout(resolve, 1000));

console.log("\nCalling the 'increment' instruction...");

const incrementTx = await program.methods
  .increment()
  .accounts({
    counter: counterAccount.publicKey,
  })
  .rpc();

console.log(`✅ Increment transaction successful!`);
console.log(`   Signature: ${incrementTx}`);

// Finally, let's fetch the account data to see the new value
const accountData = await program.account.counter.fetch(counterAccount.publicKey);
console.log(`\nCounter account data:`);
console.log(`   Current count: ${accountData.count.toString()}`);

Notice how much simpler this call is. The increment instruction in our IDL only requires one account (counter) and no extra signers, so our client-side call reflects that perfectly.

We also use program.account.counter.fetch() to read the data from the account after the transaction. This is another powerful feature of the Anchor client—it automatically deserializes the on-chain data into a typed object based on the Counter struct defined in the IDL.

Run the full script again. You should see both transactions succeed and the final count printed as 1.

Test your understanding!

Imagine our IDL also contained a decrement instruction with the same account requirements as increment. Add a call to this hypothetical decrement instruction after the increment call. What would the code look like?

Show answer

The code would be nearly identical to the increment call, just with the method name changed.

// (After the increment call)

console.log("\nCalling the 'decrement' instruction...");

const decrementTx = await program.methods
  .decrement() // The only change is here
  .accounts({
    counter: counterAccount.publicKey,
  })
  .rpc();

console.log(`✅ Decrement transaction successful!`);
console.log(`   Signature: ${decrementTx}`);

// Fetching the data again would show the count as 0.
const finalAccountData = await program.account.counter.fetch(counterAccount.publicKey);
console.log(`\nFinal count after decrement: ${finalAccountData.count.toString()}`);

This demonstrates the predictable and consistent API that the Anchor client provides.

4. Composing Multiple Anchor Instructions

In the last lesson, you learned to bundle multiple instructions into a single, atomic transaction. .rpc() is a convenient shortcut for sending a single instruction, but what if you want to compose several?

The MethodsBuilder provides .instruction() and .transaction() for this exact purpose. Instead of sending the transaction, .instruction() returns a TransactionInstruction object compatible with @solana/web3.js.

Let's modify our script to perform the initialize and increment operations in a single atomic transaction.

// You can replace your entire `try` block with this new version.
// It achieves the same result but in one transaction instead of two.

try {
  // Airdrop if needed (same as before)
  const balance = await connection.getBalance(payer.publicKey);
  if (balance < 1 * 1e9) {
    console.log('Airdropping 1 SOL...');
    await connection.requestAirdrop(payer.publicKey, 1 * 1e9);
  }

  // Generate a keypair for the new counter account
  const counterAccount = Keypair.generate();
  console.log(`New counter account address: ${counterAccount.publicKey.toBase58()}`);

  console.log("Building a single transaction for 'initialize' and 'increment'...");

  const txSignature = await program.methods
    .initialize()
    .accounts({
      counter: counterAccount.publicKey,
      user: payer.publicKey,
      systemProgram: SystemProgram.programId,
    })
    .postInstructions([ // We can chain instructions this way too!
      await program.methods
        .increment()
        .accounts({ counter: counterAccount.publicKey })
        .instruction(),
    ])
    .signers([counterAccount])
    .rpc();

  console.log(`✅ Atomic transaction successful!`);
  console.log(`   Signature: ${txSignature}`);

  // Fetch the account data to see the result of both instructions
  const accountData = await program.account.counter.fetch(counterAccount.publicKey);
  console.log(`\nCounter account data after atomic tx:`);
  console.log(`   Current count: ${accountData.count.toString()}`);

} catch (error) {
  console.error('❌ An error occurred:', error);
}

In this elegant solution, we use postInstructions to chain a second instruction onto the first. Anchor's MethodsBuilder constructs a single transaction containing both. When you run this and check the transaction on the explorer, you will see both the initialize and increment instructions listed inside one transaction. This powerfully combines the simplicity of the Anchor client with the atomic composition you learned in the previous lesson.

Conclusion

You've now seen how the Anchor framework drastically simplifies client-side development. The days of manually managing AccountMeta arrays and serializing instruction data are behind you for most use cases.

Here are the key takeaways:

  • The IDL is the cornerstone of Anchor's client tooling, acting as an API contract for your on-chain program.
  • The Program object, configured with a Provider, Program ID, and IDL, is your main gateway to program interaction.
  • The MethodsBuilder (program.methods...) provides a type-safe, fluent API for building instructions that mirrors your Rust code.
  • .rpc() is the one-shot method to build, sign, and send a single-instruction transaction.
  • Using methods like .instruction() or chaining with .postInstructions allows you to compose multiple Anchor-built instructions into a single atomic transaction, giving you the best of both worlds.

In our next lesson, we will zoom in on what happens after you call .rpc(). We'll explore how to confirm a transaction and handle different commitment levels, ensuring your dApp can reliably track the state of a transaction from the moment it's sent until it's permanently finalized on the blockchain.

Can't find a good explanation? Sign up and we'll make it for you

Sign up