A Solana Anchor program that provides a simple liquid staking mechanism: users stake SOL into a program-controlled vault and receive a 1:1-pegged token (pSOL) minted to their token account. The program supports: staking SOL, requesting an unstake (starts a cooldown), claiming unstaked SOL after the cooldown (burn pSOL), and updating yield which adjusts the exchange rate.
This repository contains the on-chain program, migrations, test harness, and generated TypeScript types.
programs/lst-platform/src/lib.rs— Anchor program implementation (instructions, accounts, events, errors).Anchor.toml— Anchor configuration.migrations/deploy.ts— deployment migration script.tests/lst-platform.ts— TypeScript test file / integration tests.idl/lst_platform.json— program IDL (generated by Anchor build).types/lst_platform.ts— generated typescript types for the IDL.
- Stake SOL: user transfers SOL to the program vault PDA; the program mints pSOL tokens to the user.
- Request unstake: user declares how many pSOL to redeem and starts a cooldown timer.
- Claim unstake: after cooldown, user burns pSOL and receives SOL from the vault.
- Update yield: admin can deposit reward SOL which updates the exchange rate between SOL and pSOL.
The program uses a few PDAs and account structs:
- global state PDA (seed:
b"globalState") —GlobalStateholds admin, total_staked_sol, total_psol_supply, exchange_rate, cooldown_period. - vault PDA (seed:
b"vault") — a system account that holds SOL deposited by stakers and acts as mint authority for the pSOL mint. - pSOL mint — mint account whose authority is the
vaultPDA. - user state PDA (seed:
[b"user", user_pubkey]) —UserStatetracks per-user stake, pending_unstake, cooldown_start.
See programs/lst-platform/src/lib.rs for exact types and layouts.
-
initialiseglobalstate(ctx, cooldown_period: i64)— initializeGlobalState, sets admin, exchange_rate (initially 1_000_000_000), cooldown_period, and total counters. -
stakesol(ctx, amount: u64)— user sends native SOL to the vault; the program mints pSOL based on currentexchange_rate. Validatesamount > 0. -
requestunstake(ctx, psol_amount: u64)— mark user'spending_unstakeand setcooldown_start= now. Prevents immediate claim. -
claimunstake(ctx)— after cooldown finished, burnspending_unstakepSOL from user token account and transfers corresponding SOL from the vault to the user. -
updateyield(ctx, new_reward: u64)— admin depositsnew_rewardSOL (or the value is added tototal_staked_sol), recalculatesexchange_rate= total_staked_sol * 1e9 / total_psol_supply.
The program emits events for monitoring:
StakeEvent { user, sol_amount, psol_minted }UnstakeRequestedevent { user, psol_amount, unloack_time }ClaimUnstakedEvent { user, sol_amount }YieldUpdateEvent { admin, new_exchange_rate, total_staked_sol }
Custom program errors exposed by the contract (see LstError):
InvalidAmount— amount must be > 0.Overflow— arithmetic overflow on checked operations.NoStake— user has no active stake.NothingToClaim— user has no pending_unstake.CooldownNotFinished— cooldown period not passed yet.VaultInsufficient— vault does not have enough SOL to satisfy an unstake.
Prerequisites
- Rust toolchain (stable or the one specified in
rust-toolchain.toml) - Solana CLI
- Anchor CLI (install via
cargo install --git https://github.com/coral-xyz/anchor --tag <version>or recommended install) - Node.js (for tests and migrations)
- Yarn or npm
Common commands
Build the program (Anchor):
anchor buildRun tests (this will start a local test validator and run the TypeScript integration tests):
anchor testDeploy to a local validator or configured cluster (make sure Anchor.toml has correct cluster/provider):
anchor deployYou can also run the Solana test validator manually:
solana-test-validator --reset
# then in another terminal
anchor deployAfter anchor build, the IDL is available at idl/lst_platform.json. Generated TypeScript types are in types/lst_platform.ts.
A typical interaction flow using Anchor/TypeScript (pseudo):
- Call
initialiseglobalstateas admin to createGlobalState, mint pSOL and vault PDAs. - User calls
stakesolwith SOL lamports. - User receives pSOL in their token account.
- User calls
requestunstake(psol_amount)to begin cooldown. - After cooldown passes, user calls
claimunstake()which burns pSOL and receives SOL.
Refer to tests/lst-platform.ts for concrete examples of using the Anchor test client.
The snippets below use Anchor's TypeScript client (v0.24+ method-style) and the generated types in types/lst_platform.ts.
They assume you already built the program (anchor build) and have a local provider.
Note: SOL amounts are in lamports (1 SOL = 1_000_000_000 lamports). pSOL uses 9 decimals (mint decimals = 9), so token amounts are the raw integer representation.
import * as anchor from '@project-serum/anchor';
import { TOKEN_PROGRAM_ID } from '@solana/spl-token';
import { LstPlatform } from './types/lst_platform';
const provider = anchor.AnchorProvider.local();
anchor.setProvider(provider);
const program = anchor.workspace.LstPlatform as anchor.Program<LstPlatform>;
// helper: derive PDAs
const [globalStatePDA] = await anchor.web3.PublicKey.findProgramAddress([
Buffer.from('globalState'),
], program.programId);
const [vaultPDA] = await anchor.web3.PublicKey.findProgramAddress([
Buffer.from('vault'),
], program.programId);
// 1) Initialise global state (admin)
const cooldownSeconds = 60 * 60 * 24; // 1 day
await program.methods
.initialiseglobalstate(new anchor.BN(cooldownSeconds))
.accounts({
globalstate: globalStatePDA,
vault: vaultPDA,
psolMint: /* pubkey for the pSOL mint (created by init) */,
admin: provider.wallet.publicKey,
systemProgram: anchor.web3.SystemProgram.programId,
tokenProgram: TOKEN_PROGRAM_ID,
rent: anchor.web3.SYSVAR_RENT_PUBKEY,
})
.rpc();
// 2) Stake SOL (user)
// Make sure the user has an associated token account for pSOL (ATA) before staking.
const user = provider.wallet.publicKey;
const userPsolAta = /* create/get ATA for psolMint for `user` */;
const oneSol = anchor.web3.LAMPORTS_PER_SOL;
await program.methods
.stakesol(new anchor.BN(oneSol))
.accounts({
user,
globalstate: globalStatePDA,
vault: vaultPDA,
psolMint: /* psol mint pubkey */, // `psol_minted` account in ctx
userstake: /* derived user state PDA: [b"user", user.toBuffer()] */,
userPsolAccount: userPsolAta,
systemProgram: anchor.web3.SystemProgram.programId,
tokenProgram: TOKEN_PROGRAM_ID,
rent: anchor.web3.SYSVAR_RENT_PUBKEY,
})
.rpc();
// 3) Request unstake (start cooldown)
const psolToUnstake = new anchor.BN(/* amount in pSOL base units (u64) */);
await program.methods
.requestunstake(psolToUnstake)
.accounts({
user,
globalstate: globalStatePDA,
userstake: /* user state PDA */,
})
.rpc();
// 4) Claim unstake (after cooldown)
await program.methods
.claimunstake()
.accounts({
user,
userstake: /* user state PDA */,
globalstate: globalStatePDA,
vault: vaultPDA,
psolMint: /* psol mint pubkey */,
userPsolAccount: userPsolAta,
systemProgram: anchor.web3.SystemProgram.programId,
tokenProgram: TOKEN_PROGRAM_ID,
})
.rpc();
// 5) Admin: update yield (deposit reward and recalculate exchange rate)
const rewardLamports = new anchor.BN(0.5 * anchor.web3.LAMPORTS_PER_SOL); // 0.5 SOL
await program.methods
.updateyield(rewardLamports)
.accounts({
globalstate: globalStatePDA,
vault: vaultPDA,
admin: provider.wallet.publicKey,
})
.rpc();Small notes:
- Use
@solana/spl-tokenhelpers to create/get the user's associated token account for the pSOL mint before staking. - When deriving PDAs, include the same seeds used in the program (
b"globalState",b"vault",b"user"). - Amounts: SOL values are lamports; pSOL token amounts use the mint's decimals (9).
- Arithmetic uses checked operations and returns
Overflowon wrapping. Always validate inputs at the client layer. - The pSOL mint authority is the program
vaultPDA. Keep the PDA derivation and seeds consistent when interacting. - The program stores SOL in the vault PDA lamports — ensure the vault has enough SOL before allowing claims.
- Carefully manage the
adminsigner key. Admin-only instruction isupdateyield(andinitialiseglobalstateon setup).
- Extend tests in
tests/to cover edge cases: concurrent requests, fractional exchange rates, admin misuse, and insufficient vault balance. - Add CI to run
anchor teston PRs. - Add a security audit checklist and fuzz tests before production deployment.