All articles
solanavalidatorsconsensusvote-programtower-bft

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:

rust
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:

rust
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

rust
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

typescript
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

text
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

rust
// 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:

text
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:

typescript
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 particular vote_state/mod.rs and vote_instruction.rs
  • VoteStateV3 type: solana-vote-program crate, vote_state module
  • Web3.js VoteAccount helper: @solana/web3.js exports VoteAccount with fromAccountData
  • 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.

The Solana Vote Program: schema, instructions, and how to read it | devrels.xyz