What Codama is
Anchor already gives you an IDL: a JSON description of every instruction, account and type
in your program. @coral-xyz/anchor reads that IDL at runtime and builds
program.methods.contribute() for you dynamically. Codama does the same job at
build time. It reads the IDL once, turns it into a tree of nodes, and renders real
TypeScript files: a decoder per account, a builder per instruction, a finder per PDA. No
Anchor dependency, no any. Codama is the generator behind many modern Solana
program clients, including the @solana-program/* clients; Metaplex uses
closely related IDL-driven tooling (Kinobi, Codama's predecessor) for its Umi clients.
anchor build ──▶ target/idl/fundraiser.json ──▶ codama run js ──▶ clients/js/src/generated/
├── accounts/ getFundraiserDecoder()
├── instructions/ getContributeInstruction()
├── pdas/ findContributorAccountPda()
└── programs/ FUNDRAISER_PROGRAM_ADDRESS
What is different about this assignment
Nothing here is open-ended. There is one right codama.json, three TODOs with
one obvious way to fill each, and a single question to answer in prose. If you get the
config right, the generated client does most of the work; the tests are there to make you
read what it generated rather than trust it.
The files you will touch
| File | What it is |
|---|---|
| codama.json | Does not exist yet. Checkpoint 2 creates it. Commit it. |
| clients/js/ | Does not exist yet. Checkpoint 3 generates it. Gitignored; the config is what you submit, not its output. |
| tests/codama.ts | Setup written, three TODOs empty, one bonus skipped. Your work. |
| tests/helpers/kit-adapter.ts | Twenty lines that turn a Kit instruction into a web3.js one. Read it for the bonus. |
| tests/fundraiser.ts | The original suite, plus time-window*.ts. Must stay green. |
| NOTES.md | Does not exist yet. Your answer to the checkpoint 6 question, plus versions. |
| programs/fundraiser/ | Unchanged. You will not edit a .rs file, only read seeds in two of them. |
What you will be working with
codama init writes the config, codama run js renders the client.@solana/kit, not web3.js. That gap is the whole bonus.Address instead of PublicKey, bigint instead of BN.@coral-xyz/anchor 0.32.1, same as last week; the tests compare it against Codama's.Every step has hints you can open when you want them. The same material is in the repo's README, so you can work from either.
Fork & set up
1 · Fork on GitHub
Open github.com/decentra1ized/solana-fall-fundraiser-codama and click Fork.
You now own github.com/<you>/solana-fall-fundraiser-codama.
2 · Clone your fork, not the original
git clone https://github.com/<you>/solana-fall-fundraiser-codama.git
cd solana-fall-fundraiser-codama
Everything you do lands on main of your fork, and pushing there is how you
submit. Push after each checkpoint so your progress is saved.
3 · Check your toolchain
Exact pins this time. Anchor 1.x is picky about all three.
anchor --version # anchor-cli 1.1.2
solana --version # solana-cli 3.1.10
node --version # v20.18 or newer
surfpool --version # or plan to use: anchor test --validator legacy
If Anchor is wrong: avm install 1.1.2 && avm use 1.1.2. The program
pins anchor-lang to exactly =1.1.2, so the CLI has to match.
Anchor 1.x targets Solana 3.x and recommends 3.1.10, and its tooling requires Node 20.18
or newer.
anchor test against Surfpool
by default, not solana-test-validator. Either install Surfpool now, or add
--validator legacy to every anchor test in this guide. Pick one
and use it for the whole assignment.
4 · Install
yarn install
This pulls the Codama toolchain and @solana/kit at pinned versions.
codama init would offer to install them for you, but it would install whatever
is newest, and newest is not what the tests were written against.
@codama/renderers-js minor releases raise the minimum @solana/kit
version, and Kit majors change the type of an instruction. The lockfile in this repo pairs
renderers-js 2.5.0 with kit 8.3.0; that pair is what the answer
key was run against. Do not bump one without the other.
@coral-xyz/anchor
0.32.1, the last release under the old package name; Anchor 1.0 renamed it to
@anchor-lang/core with the same API and the same IDL format. This repo keeps
0.32.1 to stay identical to the fundraiser assignment you forked last week, so nothing about
the Anchor side of the tests is new.
npx codama --version prints 1.6.3. If it prints anything else, or
offers to install something, yarn install did not finish.
Build, and find the IDL
anchor build
anchor keys sync # first time only
anchor build # yes, again. See below.
anchor test # or: anchor test --validator legacy
anchor build mints a different one and anchor keys sync rewrites
declare_id! and Anchor.toml to match; you did this last time.
What is new: the IDL at target/idl/fundraiser.json carries the program address
too, and Codama copies it straight into the generated client. An IDL built before
keys sync gives you a client that points at a program that does not exist.
Rebuild after syncing, every time the id changes.
anchor test should end with the fundraiser suite green and three red
tests in codama, each failing with a message that starts
TODO. That is the correct starting state. If fundraiser.ts is red
too, fix that before you go on.
Open the IDL
Before you hand it to a tool, read the thing you are handing over.
target/idl/fundraiser.json is a few hundred lines; you care about three shapes.
| Key | Holds | Codama turns it into |
|---|---|---|
| address | The program id | FUNDRAISER_PROGRAM_ADDRESS |
| instructions[] | Name, 8-byte discriminator, accounts with pda seeds, args | getContributeInstruction() and friends |
| accounts[] + types[] | Name, discriminator, field layout | getFundraiserDecoder(), fetchFundraiser() |
Find the contribute instruction and look at the fundraiser account
inside it. Its seeds look like this:
"pda": {
"seeds": [
{ "kind": "const", "value": [102, 117, 110, 100, 114, 97, 105, 115, 101, 114] },
{ "kind": "account", "path": "fundraiser.maker", "account": "Fundraiser" }
]
}
Now find the same account in initialize. The second seed there is
{ "kind": "account", "path": "maker" }, with no dot. Keep that difference in
your head; checkpoint 6 is entirely about it.
Hint Where the discriminators come from
Every instruction and account starts with 8 bytes Anchor derives from its name:
sha256("global:contribute")[..8] for instructions,
sha256("account:Fundraiser")[..8] for accounts. They are written into the
IDL as plain arrays, and Codama copies them into constants like
CONTRIBUTE_DISCRIMINATOR. Those are the first 8 of the 16 bytes you will
compare in checkpoint 5.
anchor test runs, fundraiser tests are green, the three TODO
tests are red, and you can point at the line in the IDL where contribute's
fundraiser seed says fundraiser.maker.
codama init
npx codama init
Four prompts. Three of the defaults are wrong for this repo.
| Prompt | Default | Answer |
|---|---|---|
| Where is your IDL located? | program/idl.json | target/idl/fundraiser.json |
| Which script preset would you like to use? | JS and Rust selected | JS only. Press space on Rust to deselect, then enter |
| [js] Where is the JavaScript client package located? | clients/js | clients/js (keep it) |
The Rust prompt only appears if you left Rust selected. If it does, you have gone one step
too far: Ctrl-C, delete codama.json, start over.
--default
It answers every prompt with the default, which means it points at an IDL that does not
exist, selects the Rust renderer, and then tries to install
@codama/renderers-rust, which is not in the lockfile. You will wait a long time
for something you did not want.
You should end up with this file at the repo root:
{
"idl": "target/idl/fundraiser.json",
"before": [],
"scripts": {
"js": {
"from": "@codama/renderers-js",
"args": ["clients/js"]
}
}
}
Read it as a pipeline: load idl, run every visitor in before (none
yet), then for each script run its visitor with its args. from is a package
whose default export is a visitor; args are passed to it. That is the entire
configuration model.
Hint It says "Configuration file already exists"
There is a codama.json from a previous attempt. Either
npx codama init --force or delete the file.
cat codama.json matches the block above. Commit it: it is the proof you did the
assignment, and it is small. The generated output in clients/ is gitignored;
anyone can regenerate it from your config.
codama run, and read what came out
npx codama run js
find clients -type f | sort
clients/js/package.json
clients/js/src/generated/accounts/contributor.ts
clients/js/src/generated/accounts/fundraiser.ts
clients/js/src/generated/errors/fundraiser.ts
clients/js/src/generated/index.ts
clients/js/src/generated/instructions/checkContributions.ts
clients/js/src/generated/instructions/contribute.ts
clients/js/src/generated/instructions/initialize.ts
clients/js/src/generated/instructions/refund.ts
clients/js/src/generated/pdas/contributorAccount.ts
clients/js/src/generated/pdas/fundraiser.ts
clients/js/src/generated/programs/fundraiser.ts
(plus an index.ts per folder)
Seventeen files from one JSON. Do not treat them as a black box; you will need four exports from them, and knowing where each lives is most of the work.
| Folder | One file per | You will use |
|---|---|---|
| accounts/ | #[account] struct | getFundraiserDecoder(): bytes in, typed object out |
| instructions/ | instruction | getContributeInstruction() and getContributeInstructionAsync() |
| pdas/ | PDA whose seeds Codama could fully express | Nothing directly; the Async builder calls these for you |
| programs/ | program | FUNDRAISER_PROGRAM_ADDRESS |
| errors/ | program | Nothing today, but note every #[msg] is there |
1 · Open instructions/contribute.ts
Find the type ContributeAsyncInput. Some of its fields end in ?.
Write down which. Then find the block that starts // Resolve default values.
That is the code that fills the optional ones in. Each if (!accounts.X.value)
corresponds to one ?.
2 · Open pdas/
Two files: fundraiser.ts and contributorAccount.ts. The vault is
not there, because it is an associated token account and Codama inlines that derivation
into the instruction rather than giving it a finder. Nothing for contribute's
fundraiser either, in that instruction. Hold the thought.
3 · Uncomment the import
At the top of tests/codama.ts:
import {
getFundraiserDecoder,
getContributeInstruction,
getContributeInstructionAsync,
FUNDRAISER_PROGRAM_ADDRESS,
} from "../clients/js/src/generated";
@solana/kit. Your tests import from
@solana/web3.js and @coral-xyz/anchor. They do not share types:
Kit's Address is a branded string, web3's PublicKey
is a class; Kit decodes u64 as bigint, Anchor as BN.
You will cross this border in every TODO, always in the same two ways:
address(pk.toBase58()) going in, and BigInt(n) when comparing
numbers.
Hint What is @solana/program-client-core?
The generated code imports helper types from it (InstructionAccountInput,
ResolvedInstructionAccount). It is a dependency of @solana/kit
at the same version, so yarn install already gave it to you. If TypeScript
says it cannot find the module, your node_modules predates the pinned Kit;
run yarn install again.
anchor test. The three TODO tests still fail with TODO
messages, but they compile. If instead you see
Cannot find module '../clients/js/src/generated', the generator did not run,
or your codama.json points somewhere else.
Decode an account
The before() hook has already opened a campaign and made one contribution using
program.methods, the Anchor client. The account exists on the local validator.
Your job is to read it with the other client.
The plan
- Assert
FUNDRAISER_PROGRAM_ADDRESS === program.programId.toBase58(). One line, and it catches the "built the IDL before keys sync" mistake before it costs you an hour in checkpoint 5. - Get the raw bytes with web3.js:
provider.connection.getAccountInfo(fundraiser). You want.data. - Hand them to
getFundraiserDecoder().decode(...). - Read the same account with Anchor,
program.account.fundraiser.fetch(fundraiser), and assert the two agree.
The types you get back
| Field | Anchor fetch() | Codama decode() | Compare with |
|---|---|---|---|
| maker | PublicKey | Address (a base58 string) | maker.publicKey.toBase58() |
| amountToRaise | BN | bigint | BigInt(TARGET) |
| duration, bump | number | number | directly |
Both clients camel-case the Rust field names, so the property names match. Only the value types differ.
Hint The decoder skips nothing
getFundraiserDecoder() expects the whole account, discriminator
included; it reads the 8-byte prefix and moves on. Do not slice it off. If you see a
garbage maker or a byte-count error, you trimmed something.
Hint Why not fetchFundraiser()?
It exists, and it is the function you would use in a real Kit app:
fetchFundraiser(rpc, address). But it wants a Kit Rpc object,
and your test has a web3.js Connection. Standing up a second RPC client for
one fetch is more ceremony than the assignment deserves. The decoder is the layer
underneath fetchFundraiser; using it directly shows you that the
interesting part, the byte layout, has nothing to do with the transport.
BigInt(TARGET) to TARGET and watch it go
red: 30000000n is not 30000000 to strictEqual. Change
it back.
Encode an instruction
Two clients, two code paths, one wire format. Build a contribute instruction
with each and prove they are the same bytes.
The Anchor half is already in the test:
program.methods.contribute(...).accountsPartial({...}).instruction() gives you a
TransactionInstruction with .data and .keys. Your half
is getContributeInstruction(), the synchronous one, where you pass
every account.
Crossing the border
The generated function will not accept a PublicKey. Two conversions, and you
will use both in every Kit call from now on:
import { address, createNoopSigner } from "@solana/kit";
fundraiser: address(fundraiser.toBase58()), // any account
contributor: createNoopSigner(address(provider.publicKey.toBase58())), // a signer
A "noop signer" is a signer that never signs; it just carries an address so the account
gets the right role. Nobody sends this instruction; you are comparing bytes.
The amount arg accepts number | bigint, so AMOUNT
as-is is fine.
What to assert
assert.isTrue(Buffer.from(kitIx.data).equals(anchorIx.data), "instruction data differs");
assert.deepStrictEqual(
kitIx.accounts.map((a) => a.address),
anchorIx.keys.map((k) => k.pubkey.toBase58()),
"account order differs",
);
Sixteen bytes of data: 8 for the discriminator, 8 for the little-endian u64.
Eight accounts in IDL order. If both pass, the client Codama generated and the client Anchor
builds at runtime produce transactions the program cannot tell apart.
Hint "Type 'PublicKey' is not assignable to type 'InstructionSignerInput'"
That is the contributor field. Kit's signer input wants a
TransactionSigner, not an address, because in a real Kit app the signer
object is what later signs the transaction. createNoopSigner(address(...))
satisfies the type without owning a key.
Hint Data matches, accounts don't
You passed a different address for the same role, usually contributorAta
derived for the wrong owner or vault without
allowOwnerOffCurve = true. The test file already has the correct values in
scope (contributorAccount, contributorAta, vault);
use those variables rather than re-deriving.
amount: AMOUNT to amount: AMOUNT + 1;
exactly one byte should differ and the data assertion should fail. Change it back.
Let it resolve accounts
Now the Async variant. It has the same shape as the sync one, except some
inputs are optional, and when you leave them out it derives them from the IDL's seeds. This
is what a generated client buys you over hand-written findProgramAddressSync
calls: the derivation lives in one place, and that place is the program's own IDL.
1 · Find the minimum input
Call getContributeInstructionAsync() with the fewest inputs TypeScript will
accept. Start with just contributor and amount, read the error,
add what it demands, repeat. You will end up with exactly four accounts.
2 · Assert on what it filled in
const got = ix.accounts.map((a) => a.address);
assert.strictEqual(got[3], contributorAccount.toBase58()); // a PDA, from pdas/
assert.strictEqual(got[4], contributorAta.toBase58()); // an ATA, inlined
assert.strictEqual(got[6], TOKEN_PROGRAM_ID.toBase58()); // a constant
3 · The question
You had to pass fundraiser and vault. You did not have to pass
contributorAccount or contributorAta. All four are PDAs with seeds
in the IDL. Why the difference?
Go back to contribute.rs and read the seeds of each. Then read the seeds of
fundraiser in initialize.rs, and open
ContributeAsyncInput next to InitializeAsyncInput. The same account
is optional in one and required in the other. Write your answer, two or three sentences, in
a NOTES.md at the repo root, along with your tool versions.
NOTES.md cannot.
It is the one place in the assignment where you have to understand something rather than
type it, and it is what the reviewer reads first.
Hint If you have stared at the seeds and it isn't clicking
Ask, for each seed: is this something the caller already has in hand, or is it something inside the account we are trying to find? A finder can only use inputs it has. If the address of X depends on a field of X, you need X to find X.
Hint Is this a Codama limitation or a program-design choice?
Both, and the second is the more useful reading. Look at how refund.rs
declares the same fundraiser account. It takes maker as an
explicit account and seeds off maker.key(), so in the generated
RefundAsyncInput, fundraiser is optional again.
contribute chose not to take maker, saving one account in the
transaction, and paid for it in every client that ever tries to derive the PDA. Neither
choice is wrong. Knowing it is a choice is the point.
Bonus · actually send it
Everything so far compares bytes without touching the chain. The bonus is to send a
Codama-built instruction through the Anchor provider and watch the vault grow. The obstacle
is the border again: a Kit Instruction is
{ programAddress, accounts: [{ address, role }], data }; web3.js wants
{ programId, keys: [{ pubkey, isSigner, isWritable }], data }.
tests/helpers/kit-adapter.ts converts one into the other; read it, it is twenty
lines. Then:
const ix = await getContributeInstructionAsync({ /* same four inputs as TODO 3 */ });
await provider.sendAndConfirm(new anchor.web3.Transaction().add(toWeb3Instruction(ix)));
The noop signer's address is the provider wallet, so the provider signs for real. Change
it.skip to it. The cap is 10% of the target per contributor and you
are at one token of a possible three, so the second contribution is legal.
fundraiser is required here and optional
in initialize. If you can, you are done with the hard part.
Push it
There is no pull request this time. Your fork is your submission: what is on its
main when the deadline passes is what gets read.
1 · Write NOTES.md
At the repo root. Short, four parts:
## Versions
anchor-cli 1.1.2 · node 22.x · @codama/cli 1.6.3
## TODO 3
Required: fundraiser, vault. Optional: contributorAccount, contributorAta,
tokenProgram, systemProgram. `contribute` seeds the fundraiser PDA on
`fundraiser.maker`, a field of the account being derived, so the finder
would need the account to find the account. `initialize` seeds it on the
`maker` account, which the caller has, so there it is optional.
## Bonus
[attempted / not attempted]
## One thing that surprised me
...
2 · Look before you stage
git status
git diff
codama.json, tests/codama.ts, NOTES.md. Out:
clients/. It is gitignored; if git status shows it, your
.gitignore is wrong. The program id that anchor keys sync rewrote
in lib.rs and Anchor.toml is yours alone and it is fine on your
fork, but name your files explicitly anyway so the diff stays reviewable.
3 · Commit and push
git add codama.json tests/codama.ts NOTES.md
git commit -m "Codama client: three TODOs and notes"
git push
Definition of done
codama.jsoncommitted, pointing attarget/idl/fundraiser.json, JS renderer only.tests/codama.ts: import uncommented, TODO 1, 2 and 3 pass underanchor test.tests/fundraiser.tsandtests/time-window*.tsstill pass.NOTES.mdanswers the TODO 3 question in terms of seeds, and lists your versions.clients/is not committed.- Bonus, optional: the fifth test un-skipped and green.
NOTES.md explains the two required accounts in
terms of their seeds, and codama.json is in the commit. Push it.
Troubleshooting
Error Configuration file already exists.
A codama.json is already at the root, from a previous attempt.
npx codama init --force to overwrite it, or delete it.
Error codama init hangs, or asks to install @codama/renderers-rust
You left "Generate Rust client" selected on the second prompt, or used
--default, which does the same. The Rust renderer is not in the lockfile,
so init tries to install it. Ctrl-C, delete
codama.json if it was written, run init again and press
space on the Rust option before enter.
Error Cannot find module '../clients/js/src/generated'
Either npx codama run js never ran, or codama.json's
args is not ["clients/js"]. Run
find clients -name index.ts; the path it prints must be exactly
clients/js/src/generated/index.ts.
Error FUNDRAISER_PROGRAM_ADDRESS does not equal program.programId
The IDL Codama read was built before anchor keys sync, so it carries the
old address. anchor build, then npx codama run js. The
generator does not watch the IDL; rerun it whenever the IDL changes.
Error Type 'PublicKey' is not assignable to type 'Address<string>'
Kit and web3.js do not share a public-key type. Wrap every PublicKey you
pass into a generated function: address(pk.toBase58()). Going the other
way, new PublicKey(addr) accepts the base58 string directly.
Error Type 'PublicKey' is not assignable to type 'InstructionSignerInput'
The contributor field is a signer, and Kit wants a
TransactionSigner there, not an address.
createNoopSigner(address(pk.toBase58())) from @solana/kit
gives the builder what it needs without holding a key. When you send for real (bonus),
the provider signs anyway.
Error AssertionError: expected 30000000n to equal 30000000
Kit decodes u64 and i64 as bigint;
strictEqual does not coerce. Compare against BigInt(TARGET),
or write the literal with an n suffix.
Error Cannot find module '@solana/program-client-core'
The generated code imports it; it ships as a dependency of @solana/kit at
the same version. Your node_modules predates the pin.
yarn install.
Error decode() returns garbage, or complains about the byte count
Pass info.data whole. The decoder consumes the 8-byte discriminator itself;
slicing it off shifts every field.
Error fundraiser.ts tests started failing after I touched codama.ts
Every suite in tests/ has independent setup (its own maker, mint and
campaign inside its own before()), so file order does not matter and
nothing should leak between them. If you moved setup to the top level, reused the
provider wallet as the maker, or shared a mint across files, put it back.
Error anchor test says it cannot find Surfpool
Anchor 1.x boots Surfpool for anchor test by default. Install it from
surfpool.run, or run
anchor test --validator legacy to use solana-test-validator
instead.
Error ANCHOR_PROVIDER_URL is not defined
Same as last time: run anchor test, not ts-mocha directly. Or
export them yourself against a validator you started:
export ANCHOR_PROVIDER_URL=http://127.0.0.1:8899
export ANCHOR_WALLET=~/.config/solana/id.json
Solana Fall · fundraiser × codama · anchor 1.1.2 · @codama/cli 1.6.3 · renderers-js 2.5.0 · @solana/kit 8.3.0 · submit by pushing to your fork