The Solana Vote Program: schema, instructions, and how to read it
The Solana Vote Program's account layout, instruction enum, TowerSync wire format, and how to decode vote state in code. Pure technical reference.
The Solana Vote Program is a native builtin at address Vote111111111111111111111111111111111111111, compiled into every validator binary. It owns one account per active validator: the Vote Account. This article is the technical reference for what's actually inside, instruction by instruction, byte by byte.
VoteState — the on-chain layout
The current schema is VoteStateVersions::V3 (VoteStateV3 in the Agave source). Approximate Rust definition:
pub struct VoteStateV3 {
pub node_pubkey: Pubkey, // 32 bytes — validator identity
pub authorized_withdrawer: Pubkey, // 32 bytes — withdraw authority
pub commission: u8, // 1 byte — 0-100
pub votes: VecDeque<LandedVote>, // up to 31 entries × ~13 bytes
pub root_slot: Option<Slot>, // 9 bytes — Option<u64>
pub authorized_voters: AuthorizedVoters, // per-epoch voter authority map
pub prior_voters: CircBuf<(Pubkey,Epoch,Epoch)>, // last 32 prior authorities
pub epoch_credits: Vec<(Epoch,u64,u64)>, // (epoch, credits, prev_credits)
pub last_timestamp: BlockTimestamp, // (slot, unix_timestamp)
}
pub struct LandedVote {
pub latency: u8, // slots between vote slot and landing
pub lockout: Lockout,
}
pub struct Lockout {
slot: Slot, // u64
confirmation_count: u32, // doubles the lockout
}Account size: ~3,762 bytes when full (31 votes + 64 epochs of credits + full prior voters buffer). Rent-exempt minimum is around 0.027 SOL.
The discriminator is the 4-byte version enum tag at offset 0 (V3 = [2,0,0,0] because it's the third variant of VoteStateVersions). Everything after is bincode-serialized in the field order above.
The instruction enum
Defined in solana_vote_program::vote_instruction::VoteInstruction:
pub enum VoteInstruction {
InitializeAccount(VoteInit), // 0
Authorize(Pubkey, VoteAuthorize), // 1 — deprecated form
Vote(Vote), // 2 — deprecated form
Withdraw(u64), // 3 — lamports
UpdateValidatorIdentity, // 4
UpdateCommission(u8), // 5
VoteSwitch(Vote, Hash), // 6 — vote with fork-switch proof
AuthorizeChecked(VoteAuthorize), // 7
UpdateVoteState(VoteStateUpdate), // 8 — superseded by 12
UpdateVoteStateSwitch(VoteStateUpdate, Hash), // 9
AuthorizeWithSeed(VoteAuthorizeWithSeedArgs), // 10
AuthorizeCheckedWithSeed(...), // 11
CompactUpdateVoteState(VoteStateUpdate), // 12
CompactUpdateVoteStateSwitch(...), // 13
TowerSync(TowerSync), // 14 — current
TowerSyncSwitch(TowerSync, Hash), // 15 — current, with switch
}In 2026 mainnet traffic, ~95% of vote transactions are variant 14 (TowerSync) or 15 (TowerSyncSwitch). Variants 2 and 6 (the old Vote form) still work but no current validator client emits them.
TowerSync — what's actually on the wire
pub struct TowerSync {
pub lockouts: VecDeque<Lockout>, // current tower state, up to 31 entries
pub root: Option<Slot>, // most recent rooted slot
pub hash: Hash, // bank hash of last vote's slot
pub timestamp: Option<UnixTimestamp>,
pub block_id: Hash, // new in TowerSync vs UpdateVoteState
}The key change from earlier forms: block_id. Earlier formats only included the bank hash; TowerSync separates the bank hash (the validator's view of state after the slot) from the block ID (the canonical block identifier), which matters for correctly reconciling fork switches under Firedancer's stricter consensus pipeline.
Wire encoding: bincode-serialized, ~150-300 bytes per vote tx depending on tower depth and root presence. Compact relative to the legacy Vote form which carried the full vote history every time.
Reading a vote account in code
import { Connection, PublicKey, VoteAccount } from "@solana/web3.js"
const conn = new Connection("https://api.mainnet-beta.solana.com")
const voteAccountPubkey = new PublicKey("9QU2QSxhb24FUX3Tu2FpczXjpK3VYrvRudywSZaM29mF")
const info = await conn.getAccountInfo(voteAccountPubkey)
if (!info) throw new Error("vote account not found")
const vote = VoteAccount.fromAccountData(info.data)
console.log({
identity: vote.nodePubkey.toBase58(),
withdrawer: vote.authorizedWithdrawer.toBase58(),
commission: vote.commission, // 0-100
rootSlot: vote.rootSlot,
recentVotes: vote.votes.length,
recentCredits: vote.epochCredits.at(-1), // [epoch, credits, prev]
authorizedVoter: vote.authorizedVoters.last,
})For low-level work, getProgramAccounts filters work directly — dataSize: 3762 matches active V3 vote accounts; use memcmp against offset 4 for identity pubkey filtering.
Lockout math, concretely
confirmation_count → lockout period (slots)
1 → 2
2 → 4
3 → 8
4 → 16
…
n → 2^n
32 (MAX) → 2^32 ≈ rooted (finality)When a validator votes on slot S, the new vote enters their tower with confirmation_count = 1. Every subsequent vote on a descendant of S increments S's confirmation count by 1, doubling its lockout. When confirmation_count reaches 32, that slot becomes the validator's root_slot and falls off the tower.
The active tower depth maxes at 31 votes (deeper votes have been rooted). Switching forks while a slot has lockout L > 0 requires either waiting L slots or attaching a Hash switch proof showing the new fork has accumulated more than 1/3 of stake voting for it.
Authority separation, in instructions
// Initial state (from InitializeAccount):
// identity → node_pubkey (validator hot key)
// voter authority → vote_authority (epoch-keyed in authorized_voters)
// withdrawer → authorized_withdrawer
// Rotate voter authority for an upcoming epoch:
VoteInstruction::AuthorizeChecked(VoteAuthorize::Voter)
// accounts: [vote_account (w), clock (r), current_voter (s), new_voter (s)]
// Rotate withdraw authority:
VoteInstruction::AuthorizeChecked(VoteAuthorize::Withdrawer)
// accounts: [vote_account (w), clock (r), current_withdrawer (s), new_withdrawer (s)]
// Withdraw lamports (must leave rent-exempt unless closing):
VoteInstruction::Withdraw(lamports)
// accounts: [vote_account (w), recipient (w), withdrawer (s)]
// Change commission (subject to per-epoch rate limits):
VoteInstruction::UpdateCommission(new_commission)
// accounts: [vote_account (w), withdrawer (s)]Note that UpdateCommission is gated by the withdraw authority, not the voter. That's deliberate — commission is an economic lever, withdrawers are typically the team's treasury keys, voters are the operations keys. The separation makes sense if you assume voter keys live on a hot validator machine.
Vote transactions in the mempool
Concrete numbers from current mainnet:
Slot duration: ~400 ms
Active validators: ~1,800
Vote tx per validator: 1 per slot it's online
Vote tx/sec network-wide: ~4,000-4,500
Avg vote tx size: ~200 bytes
% of all txs: 12-15%
Fee model: Special ingress path; no priority fee competition
with user transactions, no compute unit billing.The Agave/Jito-Solana scheduler treats vote txs as a separate queue. The recent votes_only_mode (proposed in SIMD-0033 era discussions, partially landed) lets validators temporarily prioritize vote txs over user txs during congestion — relevant context for any application that's seen "my tx didn't land but the network is fine" during liveness pressure.
Integration for LSTs
A liquid-staking protocol monitoring its delegated validators reads three vote-account fields on every epoch boundary:
function detectAnomalies(vote: VoteAccount, history: PriorState) {
const [epoch, credits] = vote.epochCredits.at(-1)!
const delinquencyRatio = credits / EXPECTED_CREDITS_PER_EPOCH
return {
commissionChanged: vote.commission !== history.commission,
withdrawerChanged: !vote.authorizedWithdrawer.equals(history.withdrawer),
underperforming: delinquencyRatio < 0.97, // missed >3% of slots
delinquent: delinquencyRatio < 0.75, // unstake target
}
}A sudden commission jump from 5% to 100% on the last day of an epoch is the classic "rug pull" pattern for delegators; SIMD-0096-era rate limits help but don't fully eliminate the attack. Production LSTs run this check every epoch and rebalance off offending validators.
SIMD-tracked changes
- SIMD-0096 — per-epoch commission change rate limits
- SIMD-0123 — block-reward distribution adjustments through vote accounts
- SIMD-0033 family — vote transaction prioritization and
votes_only_mode - SIMD-0204 (in flight) — full slashing implementation; the Vote Program's vote history becomes the evidence source
Following solana-foundation/solana-improvement-documents is the only reliable way to track Vote Program semantics — the native programs don't ship release notes.
References
- Source: anza-xyz/agave →
programs/vote/src/, in particularvote_state/mod.rsandvote_instruction.rs - VoteStateV3 type:
solana-vote-programcrate,vote_statemodule - Web3.js VoteAccount helper:
@solana/web3.jsexportsVoteAccountwithfromAccountData - TowerSync background: Anza engineering blog posts plus the Firedancer team's writeups on consensus path changes
That's everything observable, encodable, decodable, and exploitable about the Vote Program in one place. Bookmark this when you next need to write code against it — you won't find another reference at this resolution.