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
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
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
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
// 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
// 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.