An on-chain SPL token staking program for Solana, written in Anchor 1.0 with a Synthetix-style reward distribution model.
Users lock their SPL tokens into a pool and earn reward tokens proportional to their share of the total stake and the time elapsed. A configurable lock period prevents early withdrawal, while an emergency unstake path allows exiting early at a penalty.
- Pool initialization — admin creates a staking pool with separate stake and reward token mints, a reward rate (tokens/sec), lock period, and early-withdraw penalty (basis points).
- Stake — users deposit stake tokens into the vault; a
StakeAccountPDA tracks their position. Re-staking accrues pending rewards without losing them. - Unstake — after the lock period expires, the user withdraws all staked tokens plus accrued rewards. The stake account is closed and rent refunded.
- Claim rewards — claim accumulated rewards without unstaking.
- Fund rewards — admin tops up the reward vault (admin-only, enforced via
has_one). - Emergency unstake — exit before the lock period with a penalty
(
early_withdraw_penaltybps deducted from the returned amount; penalty stays in the stake vault). - Sweep penalty — admin collects accumulated penalties (everything in the stake
vault above
total_staked) into their own token account.
A global accumulator reward_per_token (u128, scaled by 1e9) is updated before
every action:
elapsed = current_time - last_update_time
reward_per_token += (elapsed * reward_rate * 1e9) / total_staked
last_update_time = current_time
A user's pending rewards:
pending = (stake_amount * (reward_per_token - reward_debt)) / 1e9
After any settle (stake, unstake, claim), reward_debt is advanced to the current
reward_per_token. This correctly distributes rewards across multiple stakers with
different entry times.
| Account | Seeds | Description |
|---|---|---|
StakingPool |
["staking_pool", admin, stake_mint] |
Global pool state |
StakeAccount |
["stake_account", pool, user] |
Per-user position |
StakeVault |
ATA of pool PDA, stake mint | Holds staked tokens |
RewardVault |
ATA of pool PDA, reward mint | Holds reward tokens |
| Instruction | Args | Access |
|---|---|---|
initialize_pool |
reward_rate, lock_period, penalty |
admin |
stake |
amount |
user |
unstake |
— | user |
claim_rewards |
— | user |
fund_rewards |
amount |
admin |
emergency_unstake |
— | user |
sweep_penalty |
— | admin |
| Component | Technology |
|---|---|
| Program | Anchor 1.0.2, anchor-spl 1.0.2 |
| Language | Rust (edition 2021) |
| Testing | LiteSVM 0.10 (no solana-test-validator) |
| Client | Codama / @solana/kit (generated, clients/ts/) |
programs/magican-spl-staking/
├── src/
│ ├── lib.rs # Program entry, instruction dispatch
│ ├── state.rs # StakingPool, StakeAccount, reward math
│ ├── constants.rs # Seeds, REWARD_PRECISION, MAX_PENALTY_BPS
│ ├── error.rs # StakingError enum
│ └── instructions/
│ ├── initialize_pool.rs
│ ├── stake.rs
│ ├── unstake.rs
│ ├── claim_rewards.rs
│ ├── fund_rewards.rs
│ ├── emergency_unstake.rs
│ └── sweep_penalty.rs
├── tests/
│ ├── litesvm.rs # 14 integration tests
│ └── common/mod.rs # LiteSVM test harness & helpers
└── Cargo.toml
clients/
└── ts/ # Generated TypeScript client (Codama + @solana/kit)
docs/
├── IDEA.md # Original specification
└── PROGRESS.md # Stage-by-stage progress tracker
Tests run entirely in-process via LiteSVM — no local validator required. The test harness deploys the SBF-compiled program, seeds SPL Token mints and ATAs directly, and exercises every instruction against the real SPL Token builtin.
# Build the SBF binary
cargo build-sbf
# Run the LiteSVM test suite
cargo test --test litesvm| Test | Verifies |
|---|---|
test_initialize_pool |
Pool fields set correctly |
test_stake |
Tokens move to vault, stake account created |
test_stake_twice |
Re-staking preserves accumulated rewards |
test_claim_rewards |
Rewards proportional to time staked |
test_unstake_after_lock |
Full withdrawal + rewards after lock expires |
test_unstake_before_lock |
Rejected with LockPeriodNotExpired |
test_emergency_unstake |
Penalty deducted, balance correct |
test_sweep_penalty |
Admin collects vault surplus after emergency unstake |
test_sweep_penalty_non_admin |
Non-admin rejected with Unauthorized |
test_sweep_penalty_nothing_to_sweep |
Rejected when vault holds only staked funds |
test_fund_rewards_admin |
Reward vault topped up |
test_fund_rewards_non_admin |
Non-admin rejected with Unauthorized |
test_reward_math_single_staker |
Single staker receives 100% of rewards |
test_reward_math_two_stakers |
Two stakers split rewards proportionally |
All 14 tests pass.
cargo build-sbf # Compile SBF binary
cargo check # Type-check host code
cargo test --test litesvm # Run integration testsA TypeScript client generated from the program IDL via Codama, targeting
@solana/kit, lives in clients/ts/. It provides typed
instruction builders for all seven instructions, PDA helpers, account
decoders (StakingPool, StakeAccount), and typed program errors.
cd clients/ts
npm install
npm run generate # regenerate from target/idl/*.json
npm run typecheck
npm run smoke # read-only devnet checkDeployed on Solana devnet:
B5ZofijbzwTpCtjLr6SeUKSKoTJSXKaC5vZqyBfBBryC