SOLSTACKAcademy

Builder· Lesson 5 of 8· 4 min

Calling a program: IDL clients, PDAs and account reads

With an IDL a program becomes a typed client. Derive the addresses, build the instruction, read the state back, filter every account the program owns, and listen for its events — using Solstack's own locker client.

A Program object

src/solstack/lib/tokenLocker.jsJavaScript
import { AnchorProvider, BN, Program } from "@coral-xyz/anchor";
import { PublicKey } from "@solana/web3.js";
import IDL from "./tokenLockerIdl.json";           // copied from target/idl by scripts/onchain.sh

export const TOKEN_LOCKER_PROGRAM_ID = new PublicKey(IDL.address);

// For writes: the wallet adapter is the provider's wallet.
const provider = new AnchorProvider(connection, wallet, { commitment: "confirmed" });
const program = new Program(IDL, provider);

// For reads: no wallet needed. A dummy public key satisfies the type.
const readOnly = new AnchorProvider(
  connection,
  { publicKey: PublicKey.default, signTransaction: async (t) => t, signAllTransactions: async (t) => t },
  { commitment: "confirmed" }
);
const reader = new Program(IDL, readOnly);

Deriving addresses

JavaScript
import { getAssociatedTokenAddressSync } from "@solana/spl-token";

const u64le = (n) => {
  const buf = Buffer.alloc(8);
  buf.writeBigUInt64LE(BigInt(n.toString()));
  return buf;
};

// seeds = [LOCK_SEED, mint, creator, lock_id.to_le_bytes()] — byte for byte what the program declares
export function lockAddress(mint, creator, lockId) {
  return PublicKey.findProgramAddressSync(
    [Buffer.from("lock"), new PublicKey(mint).toBuffer(), new PublicKey(creator).toBuffer(), u64le(lockId)],
    TOKEN_LOCKER_PROGRAM_ID
  )[0];
}

// the vault is the lock PDA's associated token account; `true` allows an off-curve owner
const vault = getAssociatedTokenAddressSync(mint, lockAddress(mint, creator, lockId), true, tokenProgramId);

A u64 seed must be little-endian, eight bytes, matching to_le_bytes() on the Rust side; a string seed is its UTF-8 bytes. Get one byte wrong and the derivation lands on a different address and the program rejects it with ConstraintSeeds (2006). Anchor 0.30+ clients can resolve seeds from the IDL automatically, but deriving them yourself is how you verify an address you did not create.

Building an instruction

JavaScript
const params = {
  lockId: new BN(lockId),
  amount: new BN(amountBaseUnits.toString()),   // u64 → BN; never a JS number above 2^53
  cliffTs: new BN(cliffTs),
  endTs: new BN(endTs),
  period: new BN(86_400),
  cliffAmount: new BN(0),
  cancelable: false,
  transferable: true,
  name: encodeName("Team allocation"),         // [u8; 32] → number[] of length 32
};

const ix = await program.methods
  .createLock(params)
  .accounts({
    creator: wallet.publicKey,
    recipient,
    mint,
    creatorTokenAccount,
    lock: lockAddress(mint, wallet.publicKey, lockId),
    vault,
    recipientTokenAccount,
    tokenProgram: tokenProgramId,
  })
  .instruction();          // an instruction to put in your own transaction, not .rpc()

// Solstack appends its fee transfer and sends through the shared TxFlow;
// .rpc() would sign and send immediately with the provider's wallet instead.

Field names are camelCase on the client and snake_case in Rust; the IDL maps them. Programs listed in the IDL (associatedTokenProgram, systemProgram) are filled in for you. Anything u64/i64 is a BN, and fixed byte arrays are plain arrays of numbers.

Reading accounts back

JavaScript
// one account, decoded with the IDL's Lock type
const lock = await reader.account.lock.fetch(lockAddress(mint, creator, lockId));
console.log(lock.recipient.toBase58(), lock.totalAmount.toString(), lock.endTs.toNumber());

// every lock for one mint: a server-side filter on the bytes at offset 72
export const LOCK_OFFSET = { creator: 8, recipient: 40, mint: 72, vault: 104 };

const locksForMint = await reader.account.lock.all([
  { memcmp: { offset: LOCK_OFFSET.mint, bytes: mint.toBase58() } },
]);
// → [{ publicKey, account: { creator, recipient, mint, ... } }, ...]

// the same query without Anchor, fetching only the bytes you need
const raw = await connection.getProgramAccounts(TOKEN_LOCKER_PROGRAM_ID, {
  filters: [
    { dataSize: LOCK_SPACE },                                     // only Lock accounts
    { memcmp: { offset: LOCK_OFFSET.mint, bytes: mint.toBase58() } },
  ],
  dataSlice: { offset: LOCK_OFFSET.recipient, length: 32 },       // just the recipient field
});

account.lock.all adds the 8-byte discriminator filter for you, so a Sale from another program with the same size can never come back as a Lock. This query is how Token Lock Verifier lists every active lock for a mint; Reading chain data at scale covers when it stops being enough.

Events and errors

JavaScript
// live: websocket subscription to the program's logs, decoded by the IDL
const id = program.addEventListener("tokensReleased", (event, slot) => {
  console.log(slot, event.lock.toBase58(), event.amount.toString(), event.completed);
});
// later: await program.removeEventListener(id);

// after the fact: parse a confirmed transaction's logs
import { EventParser, BorshCoder } from "@coral-xyz/anchor";
const parser = new EventParser(TOKEN_LOCKER_PROGRAM_ID, new BorshCoder(IDL));
const tx = await connection.getTransaction(signature, { maxSupportedTransactionVersion: 0, commitment: "confirmed" });
for (const ev of parser.parseLogs(tx.meta.logMessages)) console.log(ev.name, ev.data);

// a failed call throws an AnchorError with the code from the IDL
try {
  await program.methods.release().accounts({ /* ... */ }).rpc();
} catch (e) {
  if (e.error?.errorCode?.code === "NothingToRelease") console.log("too early:", e.error.errorMessage);
  else throw e;
}

The crank in worker/locker.js is nothing more than the last two blocks in a loop: find locks whose vested amount exceeds what was released, send release for each, and confirm by the TokensReleased event.

What to remember

  • new Program(IDL, provider) gives typed methods, account decoders and event parsers from one JSON file.
  • Derive PDAs with the exact seed bytes the program declares: strings as UTF-8, u64 as eight little-endian bytes.
  • Numbers over 2^53 are BN; fixed byte arrays are number[]; names are camelCase on the client.
  • .instruction() returns an instruction for your own transaction; .rpc() signs and sends immediately.
  • account.<type>.all with memcmp filters queries by field; the discriminator filter is added for you.

Try it

0 of 32 lessons done