All articles
solanastakingstake-programclivalidatorsweb3js

Solana Stake Program: stake, unstake, and merge

Practical guide to Solana’s native Stake program: create and delegate (stake), deactivate and withdraw (unstake), and merge (plus split). CLI, @solana/web3.js, RPC reads, and docs.

Share
devrels.xyz/a/174short link

Solana Stake Program: native stake, unstake, and merge — without an LST.

Solana staking is not a single “stake button.” It is a small state machine on a native program. If you are building a wallet, a validator dashboard, a custody flow, or a bot that rebalances stake, three operations show up constantly: stake, unstake, and merge (with split as the reverse gear).

Program: Stake11111111111111111111111111111111111111. Deeper account layout and warmup math: Solana staking technical reference. This piece is the operator/builder path — what to call, in what order, with which tools.

Mental model in one screen

text
System wallet (liquid SOL)
        │  create-stake-account + fund
        ▼
Stake account (initialized, undelegated)
        │  delegate-stake → vote account
        ▼
Activating → Active   (earns rewards while active)
        │  deactivate-stake
        ▼
Deactivating → Inactive
        │  withdraw-stake
        ▼
SOL back in a system account

merge-stake:  B ──into──► A   (A grows, B closes when empty)
split-stake:  A ──part──► new account

Two authorities matter on every stake account:

  • Stake authority — can delegate, deactivate, split, merge, and re-authorize the staker
  • Withdraw authority — can withdraw lamports and re-authorize the withdrawer (often the “owner” key in product UX)

They can be the same pubkey. Custody designs often separate them.

1. Stake (create + delegate)

“Staking” is two steps: put SOL in a stake account, then delegate that account to a validator’s vote account (not the validator identity key).

CLI

From Anza’s Staking SOL with the Solana CLI:

bash
# one-time throwaway key for the stake account address
solana-keygen new --no-passphrase -o stake-account.json

# fund + initialize (stake + withdraw authority default to your CLI keypair)
solana create-stake-account stake-account.json 10 \
  --stake-authority ~/.config/solana/id.json \
  --withdraw-authority ~/.config/solana/id.json

# pick a vote account (second column of `solana validators`)
solana validators | head
solana delegate-stake <STAKE_ACCOUNT_ADDRESS> <VOTE_ACCOUNT_ADDRESS>

solana stake-account <STAKE_ACCOUNT_ADDRESS>

After delegate you should see delegated stake, vote account, and an activation epoch. For normal-sized stakes, full activation is typically one epoch (~2 days at current mainnet timing) — see the warmup math notes.

Multiple validators ⇒ multiple stake accounts. Seeds help:

bash
solana create-stake-account stake-base.json 5 --seed 0 ...
solana create-address-with-seed --from <BASE_PUBKEY> 0 STAKE

TypeScript (@solana/web3.js legacy StakeProgram)

typescript
import {
  Connection, Keypair, PublicKey, StakeProgram, Authorized, Lockup,
  Transaction, sendAndConfirmTransaction, LAMPORTS_PER_SOL,
} from "@solana/web3.js"

const connection = new Connection("https://api.mainnet-beta.solana.com")
const payer = /* Keypair */
const stakeAccount = Keypair.generate()
const vote = new PublicKey("<VALIDATOR_VOTE_ACCOUNT>")
const lamports = 10 * LAMPORTS_PER_SOL
const rent = await connection.getMinimumBalanceForRentExemption(StakeProgram.space)

const tx = new Transaction().add(
  StakeProgram.createAccount({
    fromPubkey: payer.publicKey,
    stakePubkey: stakeAccount.publicKey,
    authorized: new Authorized(payer.publicKey, payer.publicKey),
    lockup: Lockup.default,
    lamports: lamports + rent,
  }),
  StakeProgram.delegate({
    stakePubkey: stakeAccount.publicKey,
    authorizedPubkey: payer.publicKey,
    votePubkey: vote,
  }),
)

await sendAndConfirmTransaction(connection, tx, [payer, stakeAccount])

StakeProgram.createAccount bundles system create + initialize. You still co-sign with the new stake account keypair once.

2. Unstake (deactivate + withdraw)

Native unstake is not instant. Two instructions, one wait:

  1. Deactivate — stake authority starts cooldown
  2. Wait until the stake is inactive (usually next epoch boundary for normal sizes)
  3. Withdraw — withdraw authority moves lamports out

CLI

bash
solana deactivate-stake <STAKE_ACCOUNT_ADDRESS>

# after inactive:
solana stake-account <STAKE_ACCOUNT_ADDRESS>   # confirm inactive / withdrawable
solana withdraw-stake <STAKE_ACCOUNT_ADDRESS> <RECIPIENT> ALL
# or: AVAILABLE  — only the currently withdrawable portion

Delinquent validator path (when the vote account is delinquent long enough): solana deactivate-stake --delinquent <STAKE> — see the same Anza CLI guide.

TypeScript

typescript
// start cooldown
await sendAndConfirmTransaction(
  connection,
  new Transaction().add(
    StakeProgram.deactivate({
      stakePubkey: stakeAccount.publicKey,
      authorizedPubkey: payer.publicKey,
    }),
  ),
  [payer],
)

// later — after inactive — withdraw
const info = await connection.getAccountInfo(stakeAccount.publicKey)
// leave rent-exempt reserve if you keep the account open; or withdraw max
await sendAndConfirmTransaction(
  connection,
  new Transaction().add(
    StakeProgram.withdraw({
      stakePubkey: stakeAccount.publicKey,
      authorizedPubkey: payer.publicKey,
      toPubkey: payer.publicKey,
      lamports: /* computed withdrawable */,
    }),
  ),
  [payer],
)

Reading status

Prefer RPC helpers over guessing epochs:

typescript
// legacy web3 helper (where available on your connection stack)
const activation = await connection.getStakeActivation(stakePubkey)
// { state: "active" | "activating" | "deactivating" | "inactive",
//   active, inactive }

// always works: parse stake account + solana stake-account CLI
// solana stake-account <ADDR> --output json

Product rule: show users deactivating until epoch N, not “unstake failed” while cooldown is working.

3. Merge

Merge folds a source stake account into a destination. Destination keeps the address; source is drained. Use it to clean up many small accounts on the same validator, or to consolidate after splits.

CLI

bash
# merge SOURCE into DESTINATION (destination address is first arg)
solana merge-stake <DESTINATION_STAKE_ACCOUNT> <SOURCE_STAKE_ACCOUNT> \
  --stake-authority <KEYPAIR>

TypeScript

typescript
await sendAndConfirmTransaction(
  connection,
  new Transaction().add(
    StakeProgram.merge({
      stakePubkey: destinationStake,      // kept
      sourceStakePubKey: sourceStake,     // merged away
      authorizedPubkey: payer.publicKey,
    }),
  ),
  [payer],
)

When merge works (compatibility checklist)

The Stake program rejects merges that would break activation accounting. In practice builders enforce:

  • Same stake authority (and lockup rules must allow it)
  • Compatible delegation: both inactive, or both delegated to the same vote account with compatible activation/deactivation epochs (two fully active stakes on one validator is the common happy path)
  • You are not trying to merge two stakes that are mid-flight in conflicting warmup/cooldown states

If merge fails with a custom program error, split the problem: print both solana stake-account dumps and compare vote account, activation epoch, deactivation epoch, and authorities. Source of truth: Stake program merge checks in agave/programs/stake.

Split (the other half of merge)

Need two validators, or to free part of a stake while the rest stays active? Split moves lamports into a new stake account and copies delegation metadata when applicable.

bash
solana split-stake <STAKE_ACCOUNT_ADDRESS> new-stake.json 3 \
  --stake-authority <KEYPAIR>
# amount must respect cluster minimum delegation + rent-exempt on the new account
typescript
const newStake = Keypair.generate()
const tx = StakeProgram.split(
  {
    stakePubkey: existingStake,
    authorizedPubkey: payer.publicKey,
    splitStakePubkey: newStake.publicKey,
    lamports: 3 * LAMPORTS_PER_SOL,
  },
  rentExemptForStakeAccount,
)
await sendAndConfirmTransaction(connection, tx, [payer, newStake])

Newer cluster features also expose MoveStake / MoveLamports for more atomic reallocation — treat them as advanced variants once you need them; merge/split cover most product UX. Instruction list overview: existing stake reference.

Builder checklist

  1. Resolve vote account addresses with getVoteAccounts / solana validators — never confuse identity vs vote.
  2. Track stake authority vs withdraw authority in your account model.
  3. After delegate/deactivate, poll activation state before offering withdraw or re-delegate.
  4. Merge only same-validator active pairs (or both inactive) unless you have tested the edge states.
  5. Respect minimum delegation on splits (GetMinimumDelegation instruction / cluster constant).
  6. For liquid UX (instant exit), use an LST — native path always has a cooldown. See Sanctum LST mechanics.

Related RPC / APIs

  • getProgramAccounts(Stake1111…) with memcmp filters on withdrawer/staker — portfolio indexers
  • getVoteAccounts — validator list + activated stake
  • getStakeActivation / stake account parse — UI state
  • getInflationReward — historical epoch rewards per stake account
  • CLI config JSON: solana stake-account <ADDR> --output json

Resources

Bottom line

Native Solana stake UX is three verbs: stake (create + delegate to a vote account), unstake (deactivate, wait, withdraw), and merge (plus split when you need another account). Everything else — LSTs, stake pools, restaking UIs — sits on top of that program. Learn the CLI once, mirror it in StakeProgram.*, and your wallet or agent will speak the same language as the runtime.

Keep reading

Get new articles in your inbox

Technical deep-dives on Solana tooling, infrastructure, and ecosystem. No noise.

Solana Stake Program: stake, unstake, and merge | devrels.xyz