Solana Summer · Fundraiser Challenge

Here is a working fundraiser. Now make it yours.

The program collects tokens toward a target, pays out if it gets there, and refunds if it does not. That part is done. Your job is to design and ship one feature of your own choosing on top of it — a reward for contributors, something that fires at the quarter marks, or an idea nobody has had yet.

Level
Open-ended
Time
6–10 hours
Stack
Anchor 1.1.2 · SPL Token
Tests
TypeScript · Mocha
Submit
Pull request

What the program already does

A maker opens a fundraiser: "I want to raise 30,000 USDC, and I have 14 days." Anyone can contribute that token, and every contribution lands in a vault the program controls. Nobody, including the maker, can touch it while the clock is running.

When the window closes there are exactly two outcomes. If the vault reached the target, the maker calls check_contributions and takes the whole pot. If it did not, every contributor calls refund and takes their own money back. The program is the thing that makes "all or nothing" true rather than a promise.

What is different about this assignment

The first three assignments had one correct answer. This one does not. You are being asked to design something and then defend it — which means the interesting work happens before you write any Rust.

The shape of the task Pick one feature. Make it real on-chain. Write a test that fails if you delete it. Write a README that explains what you built, why, and how someone could abuse it. That last part is not padding — knowing the weaknesses of your own design is most of what separates a protocol engineer from someone who can write Rust.

The rules your feature has to satisfy

  1. Enforced on-chain. A rule that only your TypeScript obeys is not a rule. If a contributor can get the reward by crafting their own transaction, you have not built anything.
  2. Provably present. At least one test that passes with your feature and fails without it.
  3. Nothing else breaks. All four original instructions still work, and the original tests still pass.
  4. Money math is checked. No bare +, - or * on token amounts. checked_add, checked_mul, or you explain why it cannot overflow.
  5. Written down. A README section covering what it does, what it costs in rent and compute, and how you would attack it.

What you will be working with

Anchor 1.1.2Back to a framework after the Pinocchio detour — and you will appreciate it.
SPL TokenTransfers today. Minting, if your feature rewards people.
PDA signingThe fundraiser PDA already signs payouts. Yours may need it to sign more.
Your own designState layout, seeds, error cases, failure modes. All yours.

Every step has hints you can open when you want them. The repo has a genuinely good README that walks through every account and constraint — read it before checkpoint 1.

00
Setup

Fork & set up

1 · Fork on GitHub

Open github.com/JavierBonill4/anchor-fundraiser and click Fork. You now own github.com/<you>/anchor-fundraiser.

2 · Clone your fork — not the original

git clone https://github.com/<you>/anchor-fundraiser.git
cd anchor-fundraiser

3 · Add the original as upstream

git remote add upstream https://github.com/JavierBonill4/anchor-fundraiser.git
git remote -v

# origin    https://github.com/<you>/anchor-fundraiser.git   (yours — you push here)
# upstream  https://github.com/JavierBonill4/anchor-fundraiser.git (theirs — you pull from here)

4 · Branch, and name it after your feature

git switch -c feat/milestone-unlocks

Everyone's branch will be different this time. Name yours for what you are actually building — it is the first thing a reviewer reads.

5 · Check your toolchain

This repo is pinned. If your versions do not match, fix that before anything else — a version mismatch here produces errors that look like bugs in your code.

anchor --version    # anchor-cli 1.1.2
solana --version    # 3.1 or newer
node --version      # 20 or newer

If Anchor is wrong: avm install 1.1.2 && avm use 1.1.2. The program pins anchor-lang and anchor-spl to exactly =1.1.2, so the CLI has to match.

6 · Install, build, sync, test

yarn install
anchor build
anchor keys sync   # first time only
anchor build
anchor test
Why anchor keys sync The repo declares a program id whose keypair it does not ship, so your first anchor build generates a different one and anchor test fails with DeclaredProgramIdMismatch. keys sync rewrites declare_id! and Anchor.toml to match the keypair you just generated. Run it once, rebuild, and forget about it.

That id change is yours alone — leave it out of your pull request.
The existing tests do not assert anything Read tests/fundraiser.ts before you trust it. Every test is a console.log, and the two that could fail are wrapped in try/catch that swallows the error and prints it. The suite goes green whether the program works or not.

That is normal for an example repo and it is a problem for you, because it means "the tests pass" tells you nothing. Checkpoint 6 is where you fix that for your own code.
Hint If anchor test fails before it starts

anchor test builds, boots a local validator, deploys, and runs Mocha. Any of those four can be what broke:

  • ANCHOR_PROVIDER_URL is not defined — you ran ts-mocha directly instead of through anchor test. Use anchor test, or export ANCHOR_PROVIDER_URL and ANCHOR_WALLET yourself.
  • Address already in use — a validator is still running from last time. Kill it, or use anchor test --skip-local-validator against one you started yourself.
  • A missing target/types/fundraiser.tsanchor build generates it. Build before you test, every time.
Check yourself anchor build && anchor test runs to the end and prints a vault balance. Do not start writing until it does; you cannot tell your breakage from theirs otherwise.
01
Orientation

Read the fundraiser

You are about to graft something onto this program. Graft it onto the wrong place and you will fight it for a day.

The files

FileHolds
state/fundraiser.rsThe campaign: maker, mint, target, running total, clock, bump
state/contributor.rsOne u64. How much this person has put in.
instructions/initialize.rsOpens a campaign and creates the vault
instructions/contribute.rsThe only instruction money flows in through
instructions/checker.rsSuccess path: maker takes the pot, campaign closes
instructions/refund.rsFailure path: one contributor takes their money back
constants.rsThe magic numbers, already named
error.rsEight errors. You will add to this.

The two accounts and their seeds

// one per maker — a maker runs one campaign at a time
[b"fundraiser", maker]

// one per person per campaign
[b"contributor", fundraiser, contributor]

The vault is an ordinary associated token account whose authority is the fundraiser PDA. That is the whole trick: a program-derived address can own a token account, so the tokens are held by code rather than by a person.

What each instruction already enforces

InstructionRefuses when
initializethe target is not more than 3 whole tokens
contributethe amount is under one whole token; above 10% of the target in one go; the cumulative total for this contributor would pass 10%; the window has closed
check_contributionsthe vault is below the target
refundthe window is still open; the target was met

Note the symmetry: contribute works while the window is open, refund works once it has closed, and they can never both be legal at the same moment. Whatever you build, keep that property — a feature that lets someone contribute and refund in the same breath is a feature that lets them do it a thousand times.

Where the money can move

Three transfers exist in the whole program, and it is worth knowing which are signed by a human and which by the PDA:

WhereFrom → ToSigned by
contributecontributor → vaultthe contributor's keypair
check_contributionsvault → makerthe fundraiser PDA, by seeds
refundvault → contributorthe fundraiser PDA, by seeds

If your feature moves or mints anything, it will be a fourth row in that table, and it will almost certainly be signed by the PDA. Checkpoint 5 has the pattern.

02
Design

Choose your feature

Pick one. Not two. A single feature built properly — validated, tested, documented, with its failure modes understood — is worth more than three half-features, and it is what you will be graded on.

Four worked options are below, each with the state it needs, where it hooks in, and the specific thing that will go wrong. Read all four even if you already know which one you want; the traps generalise.

Option A Milestones at a quarter, a half, three quarters

The idea. Something happens the moment the vault crosses 25%, 50% and 75% of the target. What "something" is, is up to you: release a tranche of the money to the maker early, raise or lower the per-contributor cap, unlock a bonus for everyone who got in before the halfway mark, or simply emit an event a front end can listen for.

Difficulty: moderate. Best for: most people — it is the only option here with no external dependency and no clock problem.

State you need. One byte on Fundraiser. A bitmask is the tidy version:

pub milestones_fired: u8,   // bit 0 = 25%, bit 1 = 50%, bit 2 = 75%

Where it hooks. The end of contribute, after current_amount has been updated. Nothing else changes the total upward.

The trap: a contribution can jump past a mark Someone contributes 10% of the target when the vault is at 20%. You are now past the 25% mark but the vault was never at 25%. Your check has to be "which milestones are now satisfied and have not fired yet", not "did this contribution land exactly on a boundary". Loop the thresholds; do not write three if statements that each assume they run alone.

Worth knowing: contribute caps any one person at 10% of the target, so a single contribution moves the total by at most 10 percentage points and can never skip a whole quarter. Write the loop anyway — it is the correct shape, and it stays correct if someone changes the cap.

Second trap. refund reduces current_amount. Decide deliberately whether a milestone can un-fire, and write your decision in the README. ("Milestones are one-way; a refund does not retract one" is a perfectly good answer, and it is a better answer than not having thought about it.)

Option B A lottery ticket for every contribution

The idea. Contributing buys tickets in proportion to the amount. When the fundraiser succeeds, one ticket wins — a slice of the pot, a special NFT, bragging rights.

Difficulty: hard, and the hard part is not the code.

State you need. A running total_tickets on Fundraiser, and on Contributor the range of ticket numbers they own:

pub ticket_start: u64,
pub ticket_end: u64,     // exclusive
Why ranges, and not a list of entrants A program cannot enumerate accounts. There is no way to loop over every Contributor PDA on-chain and pick one. Ranges invert the problem: you draw a number, and the winner proves they won by passing their own account, which you check contains the number. The program never needs to know who is playing.

The real problem: randomness. Solana has none. Every value a program can read is a value the caller could read first, off-chain, before deciding whether to send the transaction at all. A naive draw from Clock means anyone can simulate, see that they lose, and simply not submit — then try again next slot.

Three answers, in increasing order of honesty:

  • SlotHashes sysvar. Readable on-chain, contains recent block hashes, much better than Clock. Still influenced by whoever is producing blocks. Cheap and standard for low-stakes draws.
  • Commit–reveal. The maker commits hash(secret) at initialize and reveals secret at draw time; the winner is derived from the secret combined with something the maker cannot control. The maker can refuse to reveal, so pair it with a deadline.
  • A VRF — Switchboard or ORAO. This is what production does, and integrating one is a whole assignment by itself.

Pick one, implement it, and write down in your README exactly how it can be gamed. A correct description of your own scheme's weakness will score better than a scheme presented as secure that is not.

Option C An NFT receipt for contributing

The idea. Your first contribution mints you a one-of-one token — proof you backed this campaign, held in your wallet, transferable or not as you choose.

Difficulty: moderate if you keep it simple, hard if you reach for Metaplex.

The simple version, and the one to start with: an NFT is just a mint with 0 decimals and a supply of exactly 1. Derive the mint as a PDA so you never have to store its address:

[b"receipt", fundraiser.key(), contributor.key()]

Mint authority is the fundraiser PDA. On a contributor's first contribution, create the mint and their ATA with init_if_needed, mint 1, and — if you want it to be a true one-of-one — set the mint authority to None afterwards so no more can ever exist.

The better version. You already did Token-2022 metadata in the last assignment. A receipt with a metadata_pointer back to itself, carrying a name and a URI, is a genuinely nice artifact and reuses work you have already done.

The ambitious version. Metaplex Token Metadata via CPI. Be warned that mpl-token-metadata and anchor-spl have version opinions about each other, and the program's dependencies are pinned to exactly =1.1.2 — so you may spend your afternoon in Cargo.toml rather than in your program.

The trap: what happens on refund? A contributor refunds. Do they keep the receipt for a campaign they are no longer backing? If not, you need to burn it — which means they have to still hold it, which means they can defeat you by sending it elsewhere first. There is no clean answer; there is only a decision, defended in your README.
Option D A reward token, minted as you contribute

The idea. Contribute 100 USDC, receive 100 (or 10, or 1,000) CAMPAIGN tokens. A governance token, a points balance, a claim on something later.

Difficulty: moderate, and the gentlest introduction to a PDA acting as a mint authority.

State you need. A reward_mint: Pubkey on Fundraiser, chosen at initialize, whose mint authority is the fundraiser PDA. Optionally a reward_rate if you do not want to hardcode it.

Where it hooks. contribute, right after the transfer into the vault succeeds.

The trap: decimals, and then refunds The mint you are raising might have 6 decimals and your reward mint 9. "One reward per token contributed" is then a factor of 1,000 out, and integer division will quietly round your rate to zero for small contributions. Work out the arithmetic on paper first.

Then: if someone contributes, receives rewards, and refunds, they have been paid for nothing. Burn on refund, or make rewards claimable only after the campaign succeeds. Both are defensible; minting eagerly and ignoring the problem is not.
Option E Something else entirely

Encouraged, and it has to clear the same bar. Ideas that have worked well:

  • Matching funds. A sponsor deposits a pool at the start that matches contributions 1:1 up to a limit.
  • Refund insurance. A small fee on each contribution funds a pool that pays contributors a little extra if the campaign fails.
  • Tiers. Contribute above a threshold and you are a "founding backer" — recorded on-chain and unforgeable.
  • An early-bird curve. Rewards that decay with time or with how full the vault already is.
  • Multiple campaigns per maker. Add a campaign id to the seeds and work out everything that breaks.

Run it past your TA before you start. Not for permission — to catch the case where the idea is secretly three assignments, which is the most common way this goes wrong.

Write it down before you write code One paragraph, in your README, before checkpoint 3: what the feature is, who it benefits, and what a dishonest user would try. If you cannot write that paragraph, you do not have a design yet — you have a vibe, and vibes do not survive the borrow checker.
03
State

Design the state

Almost every feature needs to remember something. Where you put it decides how much rent it costs, who pays, and how painful it is to change later.

Three places it can live

WhereGood forCosts
A field on FundraiserAnything campaign-wide: milestone flags, total tickets, a reward mintRent paid once by the maker
A field on ContributorAnything per-person: ticket ranges, tier, rewards claimedRent paid by each contributor
A brand-new PDAThings with their own lifecycle — a receipt mint, a milestone record, a claim ticketAnother account, another set of seeds, another thing to validate

Start with a field. Reach for a new PDA only when the thing genuinely has a life of its own — it gets created and closed at different times from its parent, or there can be many of them per parent.

Adding a field

#[derive(InitSpace)] recalculates the account size for you, and space = ANCHOR_DISCRIMINATOR + Fundraiser::INIT_SPACE picks it up with no further edits. Add the field, rebuild, done.

Append. Do not insert. Borsh lays fields out in declaration order, so a field inserted in the middle shifts every byte after it. Accounts created before your change will still deserialize — into garbage — and nothing will warn you. In a fresh test everything is new so you will never see it; on a devnet deployment you would have corrupted every live campaign.

New fields go at the end. Always. Even when the middle is where they logically belong.
Hint Choosing seeds for a new PDA

Seeds are how your program answers "which one of these is this?". The existing ones are a good model to copy:

[b"fundraiser", maker]                       // one per maker
[b"contributor", fundraiser, contributor]    // one per person per campaign

Read them as a sentence. "One X per Y per Z" — and the seeds are literally [b"x", y, z]. If you want one receipt per contributor per campaign, your seeds write themselves.

Include a literal prefix so two different kinds of account can never collide, and store the bump in the account so you are not paying for find_program_address on every instruction.

Hint init_if_needed is already enabled

The feature is on in Cargo.toml, and contribute already uses it for the contributor account — which is how a second contribution from the same person reuses the first account instead of failing.

Use it, and remember what it does not do: it will not re-run your initialisation. An account that already exists arrives with its old contents, so any "set this up" logic you write has to be safe to skip, or guarded by a flag you check yourself. This is the single most common source of "it works the first time" bugs in Anchor.

Check yourself anchor build is clean and the original tests still pass. You have changed the shape of an account and nothing else; if something broke, it broke now, while the change is one line and you remember making it.
04
Design

Pick your moment

Your feature fires at some moment. There are only three kinds of moment on Solana, and choosing the wrong one is the most expensive mistake available to you here.

PatternWhat it meansUse when
InlineExtra logic at the end of an existing instructionThe trigger is something that instruction just did
A new instructionSomeone calls it deliberatelyThe user is claiming something, or the maker is settling up
A crankA new instruction anyone may call, that checks a condition and actsThe trigger is time, or nobody in particular owns it
There is no "when" on Solana Programs do not run on their own. Nothing fires at midnight, nothing fires when a total crosses a line, unless somebody sends a transaction that makes it happen. If your design includes the words "and then the program automatically…", you are describing a crank — an instruction someone has to call — and you should decide now who calls it and what they get out of doing so.

The worked example: a milestone, inline

Milestones are the easiest case because the trigger is a number that only contribute changes. So the logic goes at the end of contribute, after the total has moved:

// what fraction are we at now, in quarters? 0, 1, 2, 3 or 4
let quarters = self.fundraiser.current_amount
    .checked_mul(4).ok_or(FundraiserError::Overflow)?
    / self.fundraiser.amount_to_raise;

for i in 0..quarters.min(3) {
    let flag = 1u8 << i;
    if self.fundraiser.milestones_fired & flag == 0 {
        self.fundraiser.milestones_fired |= flag;
        // ... whatever the milestone actually does ...
    }
}

Read what that does and does not do. It computes where you are, not what changed — so a contribution that leaps from 20% to 60% fires both the quarter and the half. And the flag check means each one fires once, ever, no matter how many times the loop runs.

Integer arithmetic, since there is no other kind

  • There are no floats in a Solana program. 0.25 * target is not available to you; target / 4 is.
  • Multiply before you divide. current * 4 / target keeps precision; current / target * 4 is zero until the very end.
  • Multiply-before-divide is also where overflow lives. checked_mul, and a real error variant, not unwrap().
  • overflow-checks = true is already set in this repo's release profile, so a bare + that overflows panics rather than wrapping. A panic is a failed transaction with an unhelpful log — better than silent corruption, worse than a named error.
Hint Making something happen exactly once

Anything triggered by a condition rather than by a caller needs a "has this already happened" flag, because the condition stays true after the first time. A bool per event, or one u8 used as a bitmask for up to eight of them.

Set the flag before you do the work, not after. If the work is a CPI that fails, the whole instruction unwinds and the flag goes back with it — so ordering costs you nothing and protects you from the case where the work partially succeeds in some future version of your code.

Hint If your trigger is time, read this before checkpoint 6

Time-based features are harder to test than amount-based ones, and the difficulty has nothing to do with your program. anchor test runs against a real local validator whose clock is the real clock, and there is no RPC call that moves it. You cannot wait out a 14-day campaign in CI.

Your options are to trigger on amounts instead, to use a duration short enough to sit through, or to switch your tests to solana-bankrun, which can set the clock directly. Checkpoint 6 covers the third one. Decide now, because it is much cheaper than discovering it at the end.

05
Build

Build it

You know what it is, where the state lives and when it fires. This checkpoint is the mechanics you are most likely to need, in the order you will need them.

Adding an instruction

Four edits, the same four every time:

  1. A file in instructions/ with an #[derive(Accounts)] struct and an impl block.
  2. pub mod and pub use lines in instructions/mod.rs.
  3. A handler in the #[program] module in lib.rs.
  4. An error variant or two in error.rs, with a #[msg] a human can read.

Making the PDA sign

If your feature mints, transfers or closes anything owned by the fundraiser, the PDA has to authorise it. checker.rs already does this and is worth copying from:

let signer_seeds: [&[&[u8]]; 1] = [&[
    b"fundraiser".as_ref(),
    self.maker.to_account_info().key.as_ref(),
    &[self.fundraiser.bump],
]];

let cpi_ctx = CpiContext::new_with_signer(
    self.token_program.key(),   // an address, not an AccountInfo
    cpi_accounts,
    &signer_seeds,
);

The seeds must be exactly the derivation, in order, bump last. Get one element wrong and you do not get an error explaining that — you get a different address, and the runtime declines to sign for an account you never asked about.

Changed in Anchor 1.0 CpiContext::new and new_with_signer take the program's addressself.token_program.key() — where older code passed self.token_program.to_account_info(). Every tutorial and StackOverflow answer written before Anchor 1.0 has the old form, and the compiler error it produces (a type mismatch on Pubkey) does not explain why.
Hint Minting tokens from the program (options C and D)

Create the mint in initialize (for a campaign-wide reward token) or with init_if_needed in contribute (for a per-contributor receipt), with mint::authority = fundraiser. Then:

use anchor_spl::token::{mint_to, MintTo};

mint_to(
    CpiContext::new_with_signer(
        self.token_program.key(),
        MintTo {
            mint: self.reward_mint.to_account_info(),
            to: self.contributor_reward_ata.to_account_info(),
            authority: self.fundraiser.to_account_info(),
        },
        &signer_seeds,
    ),
    reward_amount,
)?;

For a true one-of-one, follow the mint with set_authority to None. An NFT whose mint authority still exists is a promise, not a guarantee.

Hint Reading SlotHashes for a draw (option B)

The sysvar is too large for Anchor to deserialize, so you take it as an unchecked account pinned to the right address and read the bytes yourself:

/// CHECK: address-checked below; read as raw bytes
#[account(address = anchor_lang::solana_program::sysvar::slot_hashes::ID)]
pub slot_hashes: UncheckedAccount<'info>,

The first 8 bytes are the entry count; each entry after that is a slot (u64) followed by a 32-byte hash, most recent first. Take the bytes you want, hash them with something else, reduce modulo total_tickets.

And then write the paragraph in your README explaining that a block producer can influence this. The paragraph is the deliverable.

Hint Emitting an event so a front end can react

Milestones are much more useful if something can listen for them:

#[event]
pub struct MilestoneReached {
    pub fundraiser: Pubkey,
    pub quarter: u8,
    pub amount: u64,
}

emit!(MilestoneReached { fundraiser: self.fundraiser.key(), quarter, amount });

Events are program logs, not state. They are cheap, they show up in the transaction, and they are gone once nobody is indexing them — so emit them as well as recording what matters on-chain, never instead of it.

Check yourself anchor build is clean, the original tests still run, and you can describe out loud what happens if your new instruction is called twice in a row. If you cannot answer that last one, go back to the idempotency hint in checkpoint 4.
06
Tests

Prove it

The suite you inherited logs and never asserts. Yours will not, because an assertion is the only part of a test that can tell you that you are wrong.

Three tests, minimum

  1. The happy path. Do the thing that should trigger your feature. Assert on the resulting on-chain state — a balance, a flag, a new account — not on the transaction succeeding.
  2. The boundary. One unit below the trigger, nothing happens. Exactly at it, it does. This is the test that distinguishes > from >=, and it is the one that catches real bugs.
  3. The abuse case. Try to get the reward twice, or without qualifying, or as the wrong person. Assert that it fails with your error.
A failing test has to fail for the right reason try { ... } catch { console.log(e) } passes whatever happens, which is why the shipped suite is green. Even expect(tx).to.be.rejected passes when your transaction fails because you mistyped an account name.

Assert on the error code Anchor gives you:
try {
  await program.methods.claimReward()/* ... */.rpc();
  assert.fail("expected the second claim to be rejected");
} catch (err) {
  assert.strictEqual(err.error.errorCode.code, "RewardAlreadyClaimed");
}

Note the assert.fail inside the try. Without it, a test that should have thrown and did not still passes — the most common broken test in the world.

Hint Testing anything that depends on time

anchor test cannot move the clock. solana-bankrun can — it runs the program against an in-process bank instead of a validator, and lets you set the sysvar directly.

Both packages are already in package.json, so yarn install is all you need.

import { startAnchor, BankrunProvider } from "anchor-bankrun";
import { Clock } from "solana-bankrun";

const context = await startAnchor("", [], []);
const provider = new BankrunProvider(context);
anchor.setProvider(provider);

// ... run initialize and a contribution ...

const clock = await context.banksClient.getClock();
context.setClock(new Clock(
  clock.slot,
  clock.epochStartTimestamp,
  clock.epoch,
  clock.leaderScheduleEpoch,
  clock.unixTimestamp + 15n * 86_400n,   // fifteen days later
));

Every field is a bigint. Changing the clock affects everything from that point on, so create the campaign first and then move time — moving it first only changes what gets stamped into time_started.

Hint Reading your new state from TypeScript

Anchor regenerates the IDL on every build, so a field you added in Rust is available in TypeScript with no client work at all:

const account = await program.account.fundraiser.fetch(fundraiser);
assert.strictEqual(account.milestonesFired, 3);   // 25% and 50%

Anchor camel-cases field names on the way out. If the field is missing or undefined, you are running against a stale IDL — rebuild.

Check yourself Comment out your feature's logic and run the suite. At least one test must go red. If everything still passes, your tests are describing the program you already had, not the one you built.
07
Submit

Open the PR

Everyone's submission is different this time, so the description does more work than usual. A reviewer opening your PR has no idea what you decided to build.

1 · Write the README section first

This is graded, and it is where most of the thinking shows. Four short sections:

  • What it does — in plain language, for someone who has not read your code.
  • How it works — the state you added, where it fires, the accounts involved.
  • What it costs — extra rent, extra accounts, roughly what it added to compute.
  • How you would attack it — the honest one. Every design has a soft spot; naming yours is the point of the exercise.

2 · Look before you stage

git status
git diff
Read the diff before you stage it The .gitignore covers target/, node_modules/ and .anchor/, so git add . will not drown your pull request. It will still happily include things you did not mean to send — the program id anchor keys sync rewrote, a scratch test, a commented-out experiment.

Name your files explicitly. It takes ten seconds and it is the difference between a reviewable diff and one nobody wants to open.

3 · Commit in readable pieces

git add programs/fundraiser/src/state programs/fundraiser/src/error.rs
git commit -m "Add milestone tracking to the fundraiser account"

git add programs/fundraiser/src/instructions programs/fundraiser/src/lib.rs
git commit -m "Fire milestone events at the quarter marks"

git add tests readme.MD
git commit -m "Test milestone firing and document the design"

Imperative mood — "Add", not "Added". State, behaviour, tests and docs are separate ideas, and a reviewer should be able to read any one of them alone.

4 · Sync, then push

git fetch upstream
git rebase upstream/main
git push -u origin feat/milestone-unlocks

5 · Open it in the right direction

FieldValue
base repositoryJavierBonill4/anchor-fundraiser
base branchmain
head repository<you>/anchor-fundraiser
compare branchfeat/<your-feature>

6 · Write a description worth reading

## What I built
Milestone events at 25%, 50% and 75% of the target. Each fires once,
even when a single contribution crosses two of them, and a refund does
not retract one.

## Why
A campaign at 60% looks very different to a prospective backer than one
at 10%, and right now nothing on-chain marks the difference. A front end
can subscribe to the event instead of polling the vault balance.

## How
- `milestones_fired: u8` appended to `Fundraiser` — one bit per mark
- computed at the end of `contribute` as `current * 4 / target`
- `emit!(MilestoneReached { .. })` guarded by the bit, so it is idempotent
- new error `Overflow` for the checked multiplication

## Where it is weak
`current_amount` can fall after a refund, so `milestones_fired` can claim
a mark the vault no longer justifies. I chose one-way milestones on
purpose — a mark that flickers is worse than one that is generous — but a
front end should read the vault, not the flags, for a live percentage.

## Testing
`anchor test` — the original tests still pass, plus three new ones: a
contribution that crosses two marks at once, the exact boundary at 25%,
and a repeat contribution that must not fire anything twice.

7 · After you submit

  • Leave "Allow edits by maintainers" checked.
  • Respond to review by pushing more commits to the same branch — the PR updates itself. Never open a second one.
  • Do not force-push once review has started unless asked; it invalidates comments people already left.
  • Reply to every comment, even just to say you have done it. Silence reads as disagreement.
?
When it breaks

Troubleshooting

Error ANCHOR_PROVIDER_URL is not defined

AnchorProvider.env() reads two environment variables that anchor test sets for you. Run anchor test rather than ts-mocha directly, or export them yourself:

export ANCHOR_PROVIDER_URL=http://127.0.0.1:8899
export ANCHOR_WALLET=~/.config/solana/id.json
Error does not exist in type ResolvedAccounts<…>

Anchor resolves some accounts for you and refuses to let .accounts() set them. Use .accountsPartial({ ... }), which the shipped tests already do. If you copied a snippet from pre-0.30 docs, this is why it does not compile.

Error A field you added is undefined in TypeScript

The IDL is regenerated by anchor build and the TypeScript types come from it. Rebuild, and check target/types/fundraiser.ts actually contains your field. Remember Anchor camel-cases on the way out: milestones_fired becomes milestonesFired.

Error AccountDidNotDeserialize, or garbage in your new field

You inserted a field in the middle of an account struct rather than appending it, and you are reading an account created before the change. Every byte after the insertion point has shifted.

Append the field instead. In tests, a fresh validator makes this invisible; on devnet it is permanent.

Error Cross-program invocation with unauthorized signer

The seeds you passed do not derive the account you are signing for. Compare them against the derivation one element at a time — b"fundraiser", the maker's key, the bump — in that order, bump last, nothing extra.

If you are minting, check the mint's authority is actually the fundraiser PDA and not the maker.

Error ContributionTooSmall or InvalidAmount in your own tests

Both minimums are in whole tokens, scaled by the mint's decimals. On a 6-decimal mint that is 1_000_000 raw units per token — so a contribution must be at least 1_000_000, and a target must be more than 3_000_000.

Contributions are also capped at 10% of the target per contributor, so a target of 30 tokens allows at most 3 tokens from any one person. If you are writing a test that needs to fill the vault, use several contributors.

Error Your feature fires twice

The condition that triggered it is still true afterwards. Conditions do not consume themselves — you need a flag, checked and set in the same instruction. Checkpoint 4.

Error Your test passes with the feature commented out

Then it is testing the program you already had. Usually one of: asserting the transaction succeeded rather than asserting on state; a try/catch with no assert.fail in the try; or reading a value that would have been that number anyway.

Deleting your own logic and watching the suite go red is the only way to know. Do it before you open the PR, not after someone asks.

Error The clock will not move

It will not — anchor test uses a real validator and there is no RPC to set the time. Either make your trigger amount-based, use a duration short enough to wait out, or move the suite to solana-bankrun. Checkpoint 6 has the setup.

Solana Summer · fundraiser · program Eoiuq1dXvHxh6dLx3wh9gj8kSAUpga11krTrbfF5XYsC · submit by pull request