An on-chain points system for a token presale:
balances live in PDA accounts, read directly via RPC — no indexer, no backend
A points system where the balance itself — not a database row, not a cached API response — is
the source of truth. A frontend reads a wallet's points with one getAccountInfo call, the same
way it reads a native stake account. Four pieces:
- Program (
programs/points-system/) — the Anchor program. Daily claims, a SOL-balance bonus, referrals, and social-account claims, all writing to one PDA per wallet; a Merkle-based claim for settling points into a real token later. - SDK (
packages/sdk/) — thin TypeScript client, shared by the frontend and the verifier. PDA derivation, direct-read helpers, and a wrapper over the generated AnchorProgramclient for the write path. - Verifier (
apps/verifier/) — the one off-chain piece, and it's optional: Discord/Twitter OAuth, because that's the one thing that structurally can't be checked inside a Solana program. Node + Express + Postgres. - Frontend (
apps/web/) — the dApp that drives the program: connect a wallet, see points/streak, run every claim.
Everything that can be verified inside the program without external data is: daily-claim timing reads the Clock sysvar, the SOL bonus reads the caller's own wallet in the same instruction, referrals are bound once directly on a PDA with no off-chain registry. Social claims are the sole exception — verified via an operator-signed Ed25519 message, checked on-chain through instruction introspection rather than trusted from the client.
Anchor program in programs/points-system/, workspace member
points-system.
| Instruction | Role |
|---|---|
claim_daily |
Clock-gated, once per UTC calendar day. Extends the streak on a consecutive day, resets it on a missed one, adds a bonus every 7th day. |
claim_sol_balance |
One-time bonus for holding a minimum SOL balance, read from the caller's own wallet already in the instruction's account list. |
connect_referral |
Bind-once, modeled on Drift Protocol's UserStats.referrer — the referrer is written into the referred user's own PDA the first time only, and referrer_points not being init_if_needed means Anchor's own deserialization rejects an unbound wallet as a referrer. |
claim_social |
Verifies an Ed25519 signature via instruction introspection before crediting a Twitter/Discord connection — the only instruction with an external dependency. |
submit_merkle_root / claim_tokens |
Cumulative-leaf Merkle claim for settling points into a real token, modeled on Saber's merkle-distributor / the Jito fork's pattern, reimplemented rather than copied (both are GPL/AGPL). |
Each season gets its own isolated MerkleDistributor PDA, derived from the season number itself.
UserPointsState is a single PDA per wallet, derived from the wallet address; every instruction
that touches it uses init_if_needed against those same seeds, so the account is created
automatically on whichever instruction a wallet happens to call first.
Built with avm, pinned to anchor_version = "1.1.2" in Anchor.toml.
anchor build
cargo testTypeScript package in packages/sdk/, @points-system/sdk.
| File | Role |
|---|---|
pda.ts |
PDA derivation, mirrors the Rust seeds exactly. |
read.ts |
getUserPoints/getGlobalState/getMerkleDistributor — raw getAccountInfo + Borsh decode. No wallet, no provider, no indexer. |
social.ts |
buildSocialClaimMessage — the message-byte-layout contract shared by the verifier (what to sign) and the client (what to submit). |
client.ts |
PointsClient, one method per instruction, over the generated Anchor Program client. |
anchor build # regenerates target/idl, which packages/sdk/src/idl.ts imports statically
yarn workspace @points-system/sdk run typecheckNode + Express + Postgres in apps/verifier/, @points-system/verifier.
| Route | Role |
|---|---|
GET /auth/:provider |
Starts the OAuth flow, redirects to Discord/Twitter's authorize page. |
GET /auth/:provider/callback |
Exchanges the code, checks Postgres for cross-wallet dedup on that social account, signs buildSocialClaimMessage with the operator key, hands it back to the opener window via postMessage. |
GET /health |
{ discord, twitter } — which providers are actually configured. The frontend's socials panel only renders for what this reports live. |
Discord and Twitter are independently optional — set credentials for either, both, or neither.
See apps/verifier/README.md for creating the OAuth apps.
createdb points_verifier
cd apps/verifier
cp .env.example .env # OPERATOR_SECRET_KEY same as a wallet export
yarn devVite + React SPA in apps/web/, @points-system/web. Connect a wallet, see
points/streak, one panel per claim. Never assumes a transaction succeeded from local state — every
action re-fetches the real account after confirming, and the socials panel simply doesn't render
unless the verifier reports a provider configured. See
apps/web/README.md for the full breakdown.
cd apps/web
cp .env.example .env.local
yarn devMIT © RedDuck Limited