Production-quality, intentionally simple SPL token faucet on Solana (Anchor).
Users claim a fixed amount of a configured SPL token. The program enforces a per-user cooldown and/or a rolling 24-hour daily limit, supports both mint and transfer modes, and is designed for clarity, safety, and easy auditing.
Design philosophy: minimal moving parts, fixed-size accounts, state-before-effects, no unbounded growth, descriptive errors and events.
The program (drip_token) maintains two persistent account types:
| Account | Seeds | Purpose |
|---|---|---|
| Config | [b"config"] |
Singleton global faucet configuration |
| UserState | [b"user", user.key().as_ref()] |
Per-user rate-limit state (init_if_needed) |
Token movement occurs in one of two modes (set in Config):
- Mint mode (
mode = 0) – Config PDA is the mint authority;claimcallstoken_interface::mint_to. - Transfer mode (
mode = 1) – Config PDA owns a vault token account;claimcallstoken_interface::transfer_checked. Vault balance must be strictly greater thanclaim_amount.
All critical UserState updates happen before any token CPI. Both cooldown and daily limit are enforced when their respective values are > 0. A global paused flag can disable claims.
An admin helper mint_to_vault exists to fund the vault by minting into it (admin-only).
Primary token path uses anchor_spl::token_interface (Token-2022 compatible). Users receive tokens into their Associated Token Account for the configured mint.
User signs claim
│
▼
Load Config + UserState (init_if_needed)
│
▼
paused == false ?
│
▼
Cooldown elapsed? (if cooldown_seconds > 0)
│
▼
Daily window check / reset (if daily_limit > 0)
│
▼
Update UserState (last_claim_ts, claimed_today, last_day_ts)
│
▼
Mint or Transfer (exactly config.claim_amount)
│
▼
Emit ClaimEvent
Config PDA seeds = ["config"]
┌─────────────────────────────────────────────────────────────┐
│ admin: Pubkey │
│ mint: Pubkey (immutable after initialize) │
│ vault: Pubkey (Pubkey::default() allowed in Mint) │
│ claim_amount: u64 │
│ cooldown_seconds: u64 (0 = disabled) │
│ daily_limit: u64 (0 = disabled) │
│ mode: u8 (0 = Mint, 1 = Transfer) │
│ paused: bool │
│ bump: u8 │
└─────────────────────────────────────────────────────────────┘
UserState PDA seeds = ["user", user]
┌─────────────────────────────────────────────────────────────┐
│ last_claim_ts: i64 │
│ claimed_today: u64 │
│ last_day_ts: i64 (start of current rolling 24 h window)│
│ bump: u8 │
└─────────────────────────────────────────────────────────────┘
Vault (Transfer mode only)
Token account whose authority = Config PDA, mint = Config.mint
erDiagram
CONFIG ||--o{ USER_STATE : "rate-limits"
CONFIG ||--o| VAULT : "owns (Transfer mode)"
CONFIG ||--|| MINT : "mint authority (Mint mode) / referenced"
USER ||--|| USER_STATE : "one per user"
USER ||--|| USER_ATA : "receives tokens"
CONFIG {
pubkey admin
pubkey mint
pubkey vault
u64 claim_amount
u64 cooldown_seconds
u64 daily_limit
u8 mode
bool paused
u8 bump
}
USER_STATE {
i64 last_claim_ts
u64 claimed_today
i64 last_day_ts
u8 bump
}
sequenceDiagram
participant U as User
participant P as drip_token
participant C as Config PDA
participant S as UserState PDA
participant T as Token Program
U->>P: claim()
P->>C: load
P->>S: load / init_if_needed
P->>P: check paused, cooldown, daily limit
P->>S: update last_claim_ts, claimed_today, last_day_ts
alt Mint mode
P->>T: mint_to(user ATA, claim_amount)
else Transfer mode
P->>T: transfer_checked(vault → user ATA, claim_amount)
end
P-->>U: ClaimEvent
| Instruction | Admin only? | Description |
|---|---|---|
initialize |
Yes | Creates Config PDA. Sets admin, mint, claim_amount, limits, mode, paused. Mint becomes immutable. |
claim |
No | User claims exactly config.claim_amount. Enforces pause + both limits. Updates UserState before CPI. |
update_config |
Yes | Updates claim_amount, cooldown_seconds, daily_limit, mode, paused, admin. Cannot change mint. |
set_vault |
Yes | Sets or replaces the vault token account. New vault must match mint and have Config PDA as authority. |
close_user_state |
No | User closes own UserState and recovers rent. Allowed only when rate-limit state is fully clean. |
mint_to_vault |
Yes | Admin helper: mints a caller-supplied amount into the vault (Config PDA signs as mint authority). |
initialize
- Signer: future admin
- Config PDA (init)
- Mint account
- System program
claim
- Signer: user
- Config PDA
- UserState PDA (
init_if_needed) - User’s ATA (
init_if_neededwhere appropriate) - Vault token account (always required by current constraints, even in Mint mode)
- Mint
- Token program / Associated Token program / System program as needed
update_config
- Signer: current
config.admin - Config PDA
set_vault
- Signer: current
config.admin - Config PDA
- New vault token account (must be for
config.mint, authority = Config PDA)
close_user_state
- Signer: user (owner of the PDA)
- UserState PDA
- Conditions:
claimed_today == 0and cooldown fully elapsed (orcooldown_seconds == 0)
mint_to_vault
- Signer: current
config.admin - Config PDA
- Vault (must already be authorised by Config PDA)
- Mint
- Token program
The program assumes hostile clients. Key invariants that must always hold:
- Singleton Config – seeds =
[b"config"]. - Unique UserState – seeds =
[b"user", user.key().as_ref()]. - State-before-effects – UserState is fully updated before any mint or transfer CPI inside
claim. - Fixed claim amount –
claimnever uses a client-supplied amount; alwaysconfig.claim_amount. - Pause is absolute for claims –
paused == truerejects every claim. - Dual limit enforcement – both cooldown and daily limit are checked when > 0; a claim must satisfy both.
- Rolling 24-hour window – exactly 86_400 seconds from
last_day_ts. - Mode safety
- Mint: Config PDA must be mint authority.
- Transfer: vault authority = Config PDA and balance strictly greater than
claim_amount.
- Mint immutability – set only in
initialize; never changed afterwards. - Admin-only mutations – only the stored
admincan call admin instructions. - No debt / no over-mint on claim – Transfer mode rejects on insufficient vault; Mint mode relies on mint authority + supply rules.
- No account reallocation – fixed-size accounts only.
- Clean close only –
close_user_staterequiresclaimed_today == 0and fully expired cooldown (prevents close + re-init bypass). - Fixed
update_configsurface – only the listed fields are mutable; mint and bump are immutable. mint_to_vaultis admin-only – operational helper, not part of the user claim path.
Full threat model, residual risks, and error-code mapping live in 03-security-model.md.
Trust assumptions
- Solana Clock sysvar is trusted for time-based limits.
- Admin key compromise is out of scope for the initial design (recommended production posture: multisig or renounce after audit).
- No on-chain governance in v1.
| Event | Emitted by | Fields |
|---|---|---|
ClaimEvent |
claim |
user, amount, timestamp, mode |
UserStateClosed |
close_user_state |
user, timestamp |
Events are the primary interface for indexers and explorers. Schema is intentionally minimal and stable.
| Code | Meaning |
|---|---|
FaucetPaused |
Claim (or mint_to_vault) attempted while paused |
CooldownNotElapsed |
Cooldown still active |
DailyLimitExceeded |
Claim would exceed daily limit |
ArithmeticOverflow |
checked_add / checked_sub failed |
InvalidMode |
Mode is not 0 or 1 |
InsufficientVaultBalance |
Transfer mode: vault balance ≤ claim_amount |
InvalidMint |
Provided mint does not match Config |
InvalidVault |
Vault invalid for mint / wrong authority |
Unauthorized |
Caller is not the current admin |
AlreadyInitialized |
Config already exists |
CannotCloseWithClaimedToday |
UserState still has claimed_today > 0 |
CannotCloseDuringCooldown |
Cooldown has not fully expired |
All failure paths return distinct, descriptive ErrorCode variants. No panics in instruction handlers.
| Field | Type | Meaning |
|---|---|---|
admin |
Pubkey | Authority for admin instructions |
mint |
Pubkey | SPL token mint (immutable after initialize) |
vault |
Pubkey | Vault token account (Transfer mode). May be default in Mint mode |
claim_amount |
u64 | Exact tokens delivered per successful claim |
cooldown_seconds |
u64 | Minimum seconds between claims (0 = disabled) |
daily_limit |
u64 | Max tokens claimable per rolling 24 h window (0 = disabled) |
mode |
u8 | 0 = Mint, 1 = Transfer |
paused |
bool | Emergency switch – true disables claims |
bump |
u8 | Config PDA bump |
Limit logic
- Claim allowed only when
paused == falseand (cooldown elapsed or disabled) and (daily limit not exceeded or disabled). - Daily window is a rolling 86_400-second period measured from
last_day_ts. - Even when
daily_limit == 0, the implementation still writesclaimed_today = claim_amount(the field is unused for enforcement in that case).
Development is expected in GitHub Codespaces (or any environment with the Solana / Anchor toolchain).
# Install / update toolchain as needed (Codespaces usually pre-configured)
avm use latest # or pin a specific Anchor version
anchor --version
# Build
anchor build
# Run the full TypeScript test suite (local validator)
anchor test
# Run a specific test file if desired
anchor test --skip-local-validator -- --grep "cooldown"Test coverage expectations (see 05-testing.md):
- Happy path (Mint + Transfer)
- Cooldown, daily limit, dual limits, day-boundary edge cases
- Pause
- Admin authority checks
- Wrong mint / vault / insufficient vault balance
init_if_neededand first-time users- Clean-close rules for
close_user_state - Clock sysvar manipulation for deterministic time tests
- Trident fuzzing harness (when configured under
trident/)
Helper utilities live under tests/utils/ (setup.ts, time.ts, accounts.ts).
Current scope: localnet → devnet only.
Mainnet is intentionally out of scope for the foreseeable future.
- Keep a single controlled upgrade authority key while iterating on devnet.
- Record the key location and the Program ID after every deploy.
- If the program is later promoted, the preferred production posture is to renounce upgrade authority (immutable) or move it to a multisig. That decision is deferred.
All day-to-day commands live under scripts/.
See scripts/README.md for the full reference. Summary:
| Script | Purpose |
|---|---|
./scripts/build.sh |
Clean anchor build |
./scripts/deploy-devnet.sh |
Deploy / upgrade to devnet |
ts-node scripts/initialize.ts … |
Create Config PDA |
ts-node scripts/setup-mint-mode.ts … |
Transfer mint authority → Config PDA |
ts-node scripts/setup-transfer-mode.ts … |
Create vault + set_vault (+ optional fund) |
ts-node scripts/health-check.ts |
Print Config (+ optional UserState) |
ts-node scripts/update-config.ts … |
Change claim amount / limits / mode / admin / pause |
ts-node scripts/pause.ts / --unpause |
Convenience pause helpers |
ts-node scripts/claim.ts |
Smoke-test claim |
Environment overrides: CLUSTER, RPC_URL, PROGRAM_ID, ANCHOR_WALLET.
# 0. Prerequisites
# - Funded wallet on devnet
# - yarn install && anchor build
# 1. Build
./scripts/build.sh
# 2. Deploy
./scripts/deploy-devnet.sh
# → note the Program ID printed by Anchor
# → optionally update sdk/src/constants.ts and Anchor.toml [programs.devnet]
# 3. Create (or reuse) an SPL mint
spl-token create-token --decimals 6 --url devnet
# → MINT=<address>
# 4. Initialize Config (admin = current wallet)
ts-node scripts/initialize.ts \
--mint $MINT \
--claim-amount 1000000 \
--cooldown 60 \
--daily-limit 5000000 \
--mode 0 # 0 = Mint, 1 = Transfer
# 5a. Mint mode – give Config PDA mint authority
ts-node scripts/setup-mint-mode.ts --mint $MINT
# 5b. Transfer mode alternative
# ts-node scripts/setup-transfer-mode.ts --mint $MINT --fund 100000000
# 6. Verify
ts-node scripts/health-check.ts
# 7. Smoke-test claim
ts-node scripts/claim.ts-
Pause / unpause
ts-node scripts/pause.tsorts-node scripts/pause.ts --unpause
(or callupdate-config.tswith the desiredpausedvalue) -
Change parameters (claim amount, cooldown, daily limit, mode, admin)
Always viaupdate_config– usescripts/update-config.ts.
The mint is immutable afterinitialize. -
Vault management (Transfer mode)
Monitor vault balance. Re-fund with a normal token transfer or with
mint_to_vault(admin-only, Config PDA must be mint authority).
No automated refill in v1. -
Key management
Loss of the admin key means loss of ability to pause or change config.
Keep the admin key secure even on devnet.
After the first successful devnet deploy, add:
[programs.devnet]
drip_token = "<PROGRAM_ID_FROM_DEPLOY>"Keep [programs.localnet] for local testing.
The SDK defaults to the localnet Program ID; override with PROGRAM_ID env var or by updating sdk/src/constants.ts when working against devnet.
A minimal TypeScript client lives under sdk/.
import { AnchorProvider, Program } from "@coral-xyz/anchor";
import { Connection, PublicKey } from "@solana/web3.js";
import { getConfig } from "./sdk/src/config";
import { getUserState } from "./sdk/src/user";
import { claim } from "./sdk/src/claim";
import { initialize, updateConfig, setVault, mintToVault } from "./sdk/src/admin";
import { DEFAULT_PROGRAM_ID } from "./sdk/src/constants";
import { mapProgramError } from "./sdk/src/errors";
// Fetch global config
const config = await getConfig(connection, program, programId);
// Fetch user state (null if never claimed)
const userState = await getUserState(connection, program, user, programId);
// Claim (provider wallet must sign as the user)
try {
const result = await claim(provider, program, user);
console.log("claimed", result.amount, "mode", result.mode);
} catch (e) {
console.error(mapProgramError(e));
}Public helpers
| Function | Purpose |
|---|---|
getConfig |
Fetch & deserialize Config PDA |
getUserState |
Fetch UserState (or null) |
claim |
Build + send claim instruction |
initialize |
Admin – create Config |
updateConfig |
Admin – update mutable fields |
setVault |
Admin – set/replace vault |
mintToVault |
Admin – mint into vault |
mapProgramError |
Human-readable error messages |
PDA derivation, seeds, and mode constants are centralized in sdk/src/constants.ts and sdk/src/pdas.ts. They must stay in sync with on-chain constants.rs.
programs/drip_token/
├── Cargo.toml
└── src/
├── lib.rs
├── state.rs
├── errors.rs
├── events.rs
├── constants.rs
└── instructions/
├── mod.rs
├── initialize.rs
├── claim.rs
├── update_config.rs
├── set_vault.rs
├── close_user_state.rs
└── mint_to_vault.rs
sdk/src/ # TypeScript client helpers
tests/ # Anchor TypeScript tests + utils
docs/ # Optional deeper notes (architecture, security)
Phase documents (01-… through 09-…) record the locked decisions that this README reflects. Any change to account layout, authority model, or security invariants requires an explicit update to those documents first.
Implementation status is tracked against the phase documents.
Current implementation matches the locked architecture and security model on the feature-claim branch (see 02-architecture-and-account-design.md and 03-security-model.md).
This README is the primary entry point for developers and auditors.