Solana Fall · Fundraiser × Codama Challenge

You wrote the program. Now let a machine write the client.

The fundraiser program does not change at all this time. You hand its IDL to Codama, get a TypeScript client you did not write, and prove in three short tests that it reads the same bytes and produces the same instructions as the Anchor client you have been using all along.

Level
Easy
Time
1–2 hours
Stack
Anchor 1.1.2 · Codama · @solana/kit
Tests
TypeScript · Mocha
Submit
Push to your fork

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 shape of the task Generate the client. Decode one account with it. Build one instruction with it and show the bytes match Anchor's. Then let it fill in accounts for you, and explain, in two sentences, why it could fill in some and not others. That last part is the only place you have to think, and it is the part that gets graded.

The files you will touch

FileWhat it is
codama.jsonDoes 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.tsSetup written, three TODOs empty, one bonus skipped. Your work.
tests/helpers/kit-adapter.tsTwenty lines that turn a Kit instruction into a web3.js one. Read it for the bonus.
tests/fundraiser.tsThe original suite, plus time-window*.ts. Must stay green.
NOTES.mdDoes 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/clicodama init writes the config, codama run js renders the client.
renderers-jsThe renderer. Its output targets @solana/kit, not web3.js. That gap is the whole bonus.
@solana/kitWhat the generated code imports. Address instead of PublicKey, bigint instead of BN.
Anchor 1.1.2Program unchanged. The TypeScript client is @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.

00
Setup

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.

Surfpool Anchor 1.x runs 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.

Why the versions are pinned @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.
One package name, two eras The program is Anchor 1.1.2. The TypeScript client is @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.
Check yourself npx codama --version prints 1.6.3. If it prints anything else, or offers to install something, yarn install did not finish.
01
Orientation

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
Build twice, on purpose The repo declares a program id whose keypair it does not ship. Your first 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.

KeyHoldsCodama turns it into
addressThe program idFUNDRAISER_PROGRAM_ADDRESS
instructions[]Name, 8-byte discriminator, accounts with pda seeds, argsgetContributeInstruction() and friends
accounts[] + types[]Name, discriminator, field layoutgetFundraiserDecoder(), 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.

Check yourself 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.
02
Configure

codama init

npx codama init

Four prompts. Three of the defaults are wrong for this repo.

PromptDefaultAnswer
Where is your IDL located?program/idl.jsontarget/idl/fundraiser.json
Which script preset would you like to use?JS and Rust selectedJS only. Press space on Rust to deselect, then enter
[js] Where is the JavaScript client package located?clients/jsclients/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.

Do not use --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.

Check yourself 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.
03
Generate

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.

FolderOne file perYou will use
accounts/#[account] structgetFundraiserDecoder(): bytes in, typed object out
instructions/instructiongetContributeInstruction() and getContributeInstructionAsync()
pdas/PDA whose seeds Codama could fully expressNothing directly; the Async builder calls these for you
programs/programFUNDRAISER_PROGRAM_ADDRESS
errors/programNothing 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";
Two libraries, one program Every generated file imports from @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.

Check yourself Run 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.
04
TODO 1

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

  1. 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.
  2. Get the raw bytes with web3.js: provider.connection.getAccountInfo(fundraiser). You want .data.
  3. Hand them to getFundraiserDecoder().decode(...).
  4. Read the same account with Anchor, program.account.fundraiser.fetch(fundraiser), and assert the two agree.

The types you get back

FieldAnchor fetch()Codama decode()Compare with
makerPublicKeyAddress (a base58 string)maker.publicKey.toBase58()
amountToRaiseBNbigintBigInt(TARGET)
duration, bumpnumbernumberdirectly

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.

Check yourself TODO 1 is green. Change BigInt(TARGET) to TARGET and watch it go red: 30000000n is not 30000000 to strictEqual. Change it back.
05
TODO 2

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.

Check yourself TODO 2 is green. Change amount: AMOUNT to amount: AMOUNT + 1; exactly one byte should differ and the data assertion should fail. Change it back.
06
TODO 3

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.

This is the graded part The tests can be filled in from the hints. The paragraph in 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.

Check yourself TODO 3 is green. In your head, name the four optional and two required accounts without looking. Then say in one sentence why fundraiser is required here and optional in initialize. If you can, you are done with the hard part.
07
Submit

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
What should and should not be in the commit In: 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.json committed, pointing at target/idl/fundraiser.json, JS renderer only.
  • tests/codama.ts: import uncommented, TODO 1, 2 and 3 pass under anchor test.
  • tests/fundraiser.ts and tests/time-window*.ts still pass.
  • NOTES.md answers 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.
You are done when the three TODOs are green, NOTES.md explains the two required accounts in terms of their seeds, and codama.json is in the commit. Push it.
?
When it breaks

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