Imported from bilalafzal97/my-agent-skills (
.cursor/skills/anchor-idl-testkit-generator/SKILL.md). Install upstream withnpx skills add bilalafzal97/my-agent-skills --skill anchor-idl-testkit-generator. Copyright stays with the author.
Anchor IDL Testkit Generator (Generic)
Generate four TypeScript files from an Anchor IDL JSON (Anchor v0.29+ IDL format) and Rust source:
<program>-enum.ts<program>-event.ts<program>-assert.ts<program>-pda.ts
The generator MUST be generic:
- Do NOT assume any enums file exists
- Do NOT assume any PDA helper exists
- All
assert*Accountfunctions accept exactly:program: Program<IDLType>, expected: ExpectedX, pda: PublicKey - Fetch accounts via
program.account.<camelAccountAccessor>.fetch(pda) - Log functions use
fetchNullable(pda)and fallback togetAccountInfo(pda) - ALWAYS include the utility helper block (fmtBN/fmtPk/eq/eqPk) verbatim logic at the bottom of
<program>-assert.ts
Inputs
User provides:
programName: string (e.g. "vault")idlPath: string (e.g. "target/idl/vault.json")- Optional:
idlTypeImportPath: string (default../target/types/<programName>) - Optional:
preferAssertLib: "chai" | "node:assert" (default "chai") - Optional:
emitTokenHelper: boolean (default false)
Output Paths
Given programName = vault:
./tests/<programName>-enum.ts=>./tests/vault-enum.ts./tests/<programName>-event.ts=>./tests/vault-event.ts./tests/<programName>-assert.ts=>./tests/vault-assert.ts./tests/<programName>-pda.ts=>./tests/vault-pda.ts
If user gives a different folder, respect it.
Parsing Rules (IDL)
Read IDL JSON:
idl.types[]contains type definitions (name,type)idl.accounts[]contains accounts (name,type)idl.events[]contains events (name,fields[])idl.types[].type.kind === "enum"indicates a union-like enum variant setidl.types[].type.kind === "struct"indicates a struct- Field types may include:
- primitives: "bool", "u8", "u16", "u32", "u64", "u128", "i8", "i16", "i32", "i64", "i128", "string", "bytes"
- "publicKey"
- option:
{ option: <type> } - vec:
{ vec: <type> } - array:
{ array: [<type>, <len>] } - defined:
{ defined: "<TypeName>" }
Type mappings in TS:
- publicKey ->
PublicKey - u64/u128/i64/i128 and large ints ->
any(so BN/bigint works) in Expected interfaces; log uses fmtBN. - u8/u16/u32/i32 ->
number - bool ->
boolean - string ->
string - bytes / array ->
Uint8Arrayornumber[](preferUint8Arrayin interfaces) - option ->
T | null - vec ->
T[] - defined types:
- if defined refers to an enum => use that enum type in Expected
- if defined refers to a struct => inline interface or reference interface depending on context:
- for event fields: reference interface
- for account fields: reference interface
- if cannot resolve:
anywith comment
Account accessor mapping:
- For account name
ProgramConfigAccount:- accessor is camelCase, anchor usually exposes
program.account.programConfigAccount - generator should use
lowerFirst(accountName)with "Account" stripped ONLY if needed? (default: use exactaccount.namelowerCamelCase) - safest: generate accessor as
program.account[<lowerCamel(account.name)>]if unsure. - If the IDL
accounts[]exists, useaccount.name=> lowerCamelCase for accessor.
- accessor is camelCase, anchor usually exposes
File 1: <program>-enum.ts
Generate only for IDL enums (from idl.types[] where kind = "enum"):
For each enum <EnumName>:
- Export a union type
<EnumName>Typewith object variants:- variant with no fields:
{ variantNameLowerCamel: {} } - variant with named fields:
{ variantNameLowerCamel: { field: Type, ... } }
- variant with no fields:
- Export a class
<EnumName>with static readonly members for each variant:- no fields:
{ variantLowerCamel: {} } - with fields: provide a function instead:
static <VariantPascal>(args: { ... }): <EnumName>Type- (because you can't create arbitrary values as readonly constants)
- no fields:
Example format (match user style):
export type ProgramStatusType =
| { normal: {} }
| { halted: {} };
export class ProgramStatus {
static readonly Normal: ProgramStatusType = { normal: {} };
static readonly Halted: ProgramStatusType = { halted: {} };
}
Also generate interfaces for structs that are used as nested types (e.g., FeeReceiverDetail):
export interface FeeReceiverDetail {
receiver: PublicKey;
basePoint: number;
}
File 2: <program>-event.ts
For each event in idl.events[]:
- Export event name constant:
export const <EventName>Name = "<EventName>"; - Export event interface with all fields typed appropriately
- Export handler function that logs the event
Example:
export const DepositEventName = "DepositEvent";
export interface DepositEvent {
timestamp: any;
name: string;
user: PublicKey;
amount: any;
batchIndex: number;
}
export function handleDepositEvent(event: DepositEvent, slot: number) {
console.log("DepositEvent:", {
slot,
timestamp: event.timestamp?.toString?.() ?? event.timestamp,
name: event.name,
user: event.user?.toBase58?.() ?? event.user,
amount: event.amount?.toString?.() ?? event.amount,
batchIndex: event.batchIndex,
});
}
File 3: <program>-assert.ts
For each account in idl.accounts[]:
- Export
Expected<AccountName>interface with all fields from the account struct - Export
log<AccountName>function that fetches and logs account data - Export
assert<AccountName>function with signature:(program: Program<IDLType>, expected: Expected<AccountName>, pda: PublicKey)
Key rules:
- All assert functions accept PDA as third parameter (no PDA derivation inside)
- Use
chaiassert by default - Include utility helpers at bottom:
fmtBN,fmtPk,eq,eqPk
IMPORTANT:
- All fields in Expected interfaces MUST be required (no
?optional marker) EXCEPTlastBlockTimestamp lastBlockTimestampis OPTIONAL (?):- If provided: compare with actual value
- If not provided: just verify actual value is not 0 (ensures timestamp was set)
Example:
export interface ExpectedProgramConfigAccount {
lastBlockTimestamp?: any; // OPTIONAL - special handling
mainSigningAuthority: PublicKey;
programStatus: ProgramStatusType;
pendingMainSigningAuthority: PublicKey | null;
}
export async function logProgramConfigAccount(
program: Program<Vault>,
pda: PublicKey
) {
const data = await program.account.programConfigAccount.fetch(pda);
console.log("ProgramConfigAccount:", {
lastBlockTimestamp: fmtBN(data.lastBlockTimestamp),
mainSigningAuthority: data.mainSigningAuthority?.toBase58(),
programStatus: data.programStatus,
pendingMainSigningAuthority: data.pendingMainSigningAuthority?.toBase58() ?? null,
});
return data;
}
export async function assertProgramConfigAccount(
program: Program<Vault>,
expected: ExpectedProgramConfigAccount,
pda: PublicKey
) {
const data = await logProgramConfigAccount(program, pda);
// lastBlockTimestamp: if provided compare, else just check not 0
if (expected.lastBlockTimestamp !== undefined) {
assert(eq(data.lastBlockTimestamp, expected.lastBlockTimestamp), "lastBlockTimestamp mismatch");
} else {
assert(!eq(data.lastBlockTimestamp, 0), "lastBlockTimestamp should not be 0");
}
// All other fields are required - no undefined checks needed
assert(eqPk(data.mainSigningAuthority, expected.mainSigningAuthority), "mainSigningAuthority mismatch");
assert.deepEqual(data.programStatus, expected.programStatus, "programStatus mismatch");
if (expected.pendingMainSigningAuthority === null) {
assert.isNull(data.pendingMainSigningAuthority, "pendingMainSigningAuthority should be null");
} else {
assert(eqPk(data.pendingMainSigningAuthority, expected.pendingMainSigningAuthority), "pendingMainSigningAuthority mismatch");
}
}
Utility helpers block (always include at bottom):
function fmtBN(v: any): string {
return v?.toString?.() ?? String(v);
}
function fmtPk(v: any): string {
return v?.toBase58?.() ?? String(v);
}
function eq(a: any, b: any): boolean {
// Use string comparison for consistent BN/number handling
const aStr = a?.toString?.() ?? String(a);
const bStr = b?.toString?.() ?? String(b);
return aStr === bStr;
}
function eqPk(a: any, b: any): boolean {
return fmtPk(a) === fmtPk(b);
}
Token and SOL Balance Helpers
ALWAYS include these balance assertion helpers in the assert file:
import { Connection, PublicKey, LAMPORTS_PER_SOL } from "@solana/web3.js";
import { getAssociatedTokenAddress } from "@solana/spl-token";
/**
* Assert SPL token balance for an owner
*/
export async function assertTokenBalance(
connection: Connection,
mintAccount: PublicKey,
owner: PublicKey,
expectedBalance: number,
message: string,
tokenProgram: PublicKey,
associatedTokenProgram: PublicKey
) {
const ata = await getAssociatedTokenAddress(
mintAccount,
owner,
true,
tokenProgram,
associatedTokenProgram
);
console.log("ATA:", ata.toBase58());
const ataBalance = await connection.getTokenAccountBalance(ata);
console.log(message);
console.log("Token Balance:", ataBalance.value.amount);
assert(
Number(ataBalance.value.amount) === expectedBalance,
`${message}: expected ${expectedBalance}, got ${ataBalance.value.amount}`
);
}
/**
* Assert SOL balance for an account
*/
export async function assertSolBalance(
connection: Connection,
account: PublicKey,
expectedLamports: number,
message: string
) {
const balance = await connection.getBalance(account);
console.log(message);
console.log("SOL Balance (lamports):", balance);
console.log("SOL Balance:", balance / LAMPORTS_PER_SOL);
assert(
balance === expectedLamports,
`${message}: expected ${expectedLamports} lamports, got ${balance}`
);
}
/**
* Assert SOL balance is at least a minimum amount
*/
export async function assertSolBalanceAtLeast(
connection: Connection,
account: PublicKey,
minLamports: number,
message: string
) {
const balance = await connection.getBalance(account);
console.log(message);
console.log("SOL Balance (lamports):", balance);
console.log("SOL Balance:", balance / LAMPORTS_PER_SOL);
assert(
balance >= minLamports,
`${message}: expected at least ${minLamports} lamports, got ${balance}`
);
}
/**
* Get token balance (returns 0 if account doesn't exist)
*/
export async function getTokenBalance(
connection: Connection,
mintAccount: PublicKey,
owner: PublicKey,
tokenProgram: PublicKey,
associatedTokenProgram: PublicKey
): Promise<number> {
try {
const ata = await getAssociatedTokenAddress(
mintAccount,
owner,
true,
tokenProgram,
associatedTokenProgram
);
const ataBalance = await connection.getTokenAccountBalance(ata);
return Number(ataBalance.value.amount);
} catch {
return 0;
}
}
/**
* Log token balance
*/
export async function logTokenBalance(
connection: Connection,
mintAccount: PublicKey,
owner: PublicKey,
label: string,
tokenProgram: PublicKey,
associatedTokenProgram: PublicKey
) {
const ata = await getAssociatedTokenAddress(
mintAccount,
owner,
true,
tokenProgram,
associatedTokenProgram
);
try {
const ataBalance = await connection.getTokenAccountBalance(ata);
console.log(`${label}:`, {
ata: ata.toBase58(),
owner: owner.toBase58(),
balance: ataBalance.value.amount,
uiAmount: ataBalance.value.uiAmount,
});
} catch {
console.log(`${label}: Account does not exist`, {
ata: ata.toBase58(),
owner: owner.toBase58(),
});
}
}
/**
* Log SOL balance
*/
export async function logSolBalance(
connection: Connection,
account: PublicKey,
label: string
) {
const balance = await connection.getBalance(account);
console.log(`${label}:`, {
account: account.toBase58(),
lamports: balance,
sol: balance / LAMPORTS_PER_SOL,
});
}
Required imports for assert file with balance helpers:
import { assert } from "chai";
import { Program } from "@coral-xyz/anchor";
import { Connection, PublicKey, LAMPORTS_PER_SOL } from "@solana/web3.js";
import { getAssociatedTokenAddress } from "@solana/spl-token";
File 4: <program>-pda.ts
Generate PDA derivation functions by analyzing Rust source code in programs/<program>/src/.
Step 1: Extract PDA Prefixes from Rust State Files
Search for constants in programs/<program>/src/states/*.rs:
grep "_PREFIX.*=.*\"" programs/<program>/src/**/*.rs
Pattern: pub const {NAME}_PREFIX: &str = "{VALUE}";
Step 2: Extract Seed Patterns from Instruction Files
Read instruction files in programs/<program>/src/instructions/*.rs and find:
seeds = [...]patterns in#[account(...)]attributes- Extract the exact seed composition for each account type
Step 3: Generate TypeScript File
Structure:
import { PublicKey } from "@solana/web3.js";
import { Program } from "@coral-xyz/anchor";
import { <ProgramType> } from "../target/types/<program>";
// ============================================================================
// PDA PREFIXES
// ============================================================================
export const PROGRAM_CONFIG_ACCOUNT_PREFIX: string = "CONFIG";
export const VAULT_DETAIL_ACCOUNT_PREFIX: string = "VDAP";
// ... all prefixes from Rust
// ============================================================================
// UTILITY FUNCTIONS
// ============================================================================
export function toU32Bytes(value: number): Buffer {
const buffer = Buffer.alloc(4);
buffer.writeUInt32LE(value, 0);
return buffer;
}
export function toU64Bytes(value: number | bigint): Buffer {
const buffer = Buffer.alloc(8);
buffer.writeBigUInt64LE(BigInt(value), 0);
return buffer;
}
// ============================================================================
// PDA DERIVATION FUNCTIONS
// ============================================================================
// --- 1 Seed PDAs (Global Singleton) ---
export function getProgramConfigAccountPdaAndBump(
programId: PublicKey
): [PublicKey, number] {
return PublicKey.findProgramAddressSync(
[Buffer.from(PROGRAM_CONFIG_ACCOUNT_PREFIX)],
programId
);
}
// --- 2 Seed PDAs (Keyed by Name) ---
export function getVaultDetailAccountPdaAndBump(
programId: PublicKey,
vaultName: string
): [PublicKey, number] {
return PublicKey.findProgramAddressSync(
[
Buffer.from(VAULT_DETAIL_ACCOUNT_PREFIX),
Buffer.from(vaultName),
],
programId
);
}
// --- 2 Seed PDAs (Keyed by Parent PDA) ---
export function getVaultDepositTreasureAccountPdaAndBump(
programId: PublicKey,
vaultDetailPda: PublicKey
): [PublicKey, number] {
return PublicKey.findProgramAddressSync(
[
Buffer.from(VAULT_DEPOSIT_TREASURE_ACCOUNT_PREFIX),
vaultDetailPda.toBuffer(),
],
programId
);
}
// --- 3 Seed PDAs (Keyed by Parent PDA + Index) ---
export function getVaultBatchDetailAccountPdaAndBump(
programId: PublicKey,
vaultDetailPda: PublicKey,
batchIndex: number
): [PublicKey, number] {
return PublicKey.findProgramAddressSync(
[
Buffer.from(VAULT_BATCH_DETAIL_ACCOUNT_PREFIX),
vaultDetailPda.toBuffer(),
toU32Bytes(batchIndex),
],
programId
);
}
// --- 3 Seed PDAs (Keyed by Parent PDA + User) ---
export function getUserDetailAccountPdaAndBump(
programId: PublicKey,
vaultDetailPda: PublicKey,
userPubkey: PublicKey
): [PublicKey, number] {
return PublicKey.findProgramAddressSync(
[
Buffer.from(USER_DETAIL_ACCOUNT_PREFIX),
vaultDetailPda.toBuffer(),
userPubkey.toBuffer(),
],
programId
);
}
// ============================================================================
// DATA FETCHER FUNCTIONS
// ============================================================================
export async function getProgramConfigAccountData(program: Program<Vault>) {
const [pda] = getProgramConfigAccountPdaAndBump(program.programId);
return await program.account.programConfigAccount.fetch(pda);
}
export async function getVaultDetailAccountData(
program: Program<Vault>,
vaultName: string
) {
const [pda] = getVaultDetailAccountPdaAndBump(program.programId, vaultName);
return await program.account.vaultDetailAccount.fetch(pda);
}
// ... more data fetchers
Seed Type Mappings
| Rust Type | TypeScript Conversion |
|---|---|
&str / String |
Buffer.from(value) |
Pubkey |
pubkey.toBuffer() |
u32 (index) |
toU32Bytes(value) |
u64 |
toU64Bytes(value) |
Function Naming Convention
- Prefix:
get - Account name in PascalCase
- Suffix:
PdaAndBump - Example:
getVaultDetailAccountPdaAndBump
Parameter Naming Convention
programId: The program's public keyvaultName: String identifier (for name-keyed accounts)vaultDetailPda: Parent PDA for vault-level accountsuserDetailPda: Parent PDA for user-level accountsbatchIndex: u32 index for batch accountsdepositIndex: u32 index for deposit accountswithdrawIndex: u32 index for withdraw accountsuserPubkey: User's wallet public key
Validation Checklist
After generation, verify:
- All enum variants from IDL are present in
-enum.ts - All events from IDL have handlers in
-event.ts - All accounts from IDL have assert/log functions in
-assert.ts - All PREFIX constants from Rust are in
-pda.ts - All seed patterns match Rust
seeds = [...]exactly - TypeScript compiles without errors
- Assert function signatures are consistent:
(program, expected, pda) - All Expected interface fields are REQUIRED (no
?) EXCEPTlastBlockTimestamp -
lastBlockTimestampis optional (?) with special handling:- If provided: compare with actual value
- If not provided: verify actual value is not 0
- Assert functions validate ALL other fields directly (no
if !== undefinedchecks)