← All posts
8 min read research

Fuzzing for a Solana Governance Bug

By Kai Parker

In August, Solana stakeholders voted on the network's constitution, its token supply, and its fee structure. These were some of the most consequential votes in the network's history, and the first time Solana used an on-chain program, svmgov, to handle them.

For its first six weeks on mainnet, Solana's official voting program had a bug: any validator could silently erase its own delegators' dissenting votes. If left unpatched, this flaw would have quietly skewed governance results.

The bug was fixed three weeks before voting opened. This post explains the bug and shows how you can use Crucible to detect this same class of bugs in your Solana protocol.

The Silent Veto

Solana's validator governance program, svmgov, exists so validators and delegators can use their stake to vote on Solana's proposals. These votes are weighted by the amount of SOL staked. A validator votes with cast_vote, using the full weight of the stake delegated to it, but delegators can choose to override their validator with cast_vote_override and vote their own stake directly. So even though you delegate, you still control where your vote goes.

An override should work no matter what the validator does, so cast_vote_override handles it in two ways. If the validator has already voted, the override takes effect immediately: the delegator's lamports move out of the validator's tally and into the delegator's chosen option. If the validator hasn't voted yet, there's nothing to subtract from, so the program parks the override in a per-validator VoteOverrideCache and waits:

// cast_vote_override.rs (pre-fix, trimmed; checked arithmetic shown as +=)
if self.validator_vote.data_len() > 0 && self.validator_vote.owner == &crate::ID {
    // Path 1: the validator already voted. Re-split its vote in the totals right now.
    self.proposal.sub_vote_lamports(/* the validator's previous vote */)?;
    self.proposal.add_vote_lamports(for_votes_lamports, against_votes_lamports, abstain_votes_lamports)?;
    self.proposal.add_vote_lamports(/* the validator's vote, minus the overridden stake */)?;
} else {
    // Path 2: the validator hasn't voted. Record the override in the cache and wait.
    self.vote_override_cache.for_votes_lamports += for_votes_lamports;
    self.vote_override_cache.against_votes_lamports += against_votes_lamports;
    self.vote_override_cache.abstain_votes_lamports += abstain_votes_lamports;
    self.vote_override_cache.total_stake += delegator_stake;
    // The proposal totals are not touched on this path.
}

When the validator eventually calls cast_vote, the program applies the cache: it adds the delegators' votes to the totals and reduces the validator's weight by the overridden stake. When voting ends, anyone can call finalize_proposal to close the proposal.

The Bug

Both paths look reasonable. What happens if the validator never actually calls cast_vote?

Cached overrides reach the proposal totals in exactly one place: the validator's own cast_vote.

// cast_vote.rs (pre-fix, trimmed): the only code that reads the cache
if self.vote_override_cache.data_len() > 0 && self.vote_override_cache.owner == &crate::ID {
    let override_cache: VoteOverrideCache = /* deserialize the cache account */;

    // Fold the delegators' cached votes into the totals...
    self.proposal.add_vote_lamports(
        override_cache.for_votes_lamports,
        override_cache.against_votes_lamports,
        override_cache.abstain_votes_lamports,
    )?;

    // ...then add the validator's own vote, minus the stake its delegators took back.
    let new_validator_stake = voter_stake - override_cache.total_stake;
    self.proposal.add_vote_lamports(/* new_validator_stake, split by the validator's choice */)?;
}

finalize_proposal will read the proposal's running totals, emit them, and mark the proposal finalized. It will never look at VoteOverrideCache, and it has no way to; it only takes the proposal account. So all the delegate votes are ignored.

// finalize_proposal.rs (pre-fix)
#[derive(Accounts)]
pub struct FinalizeProposal<'info> {
    pub signer: Signer<'info>, // Anyone can finalize after voting period ends
    #[account(mut, constraint = !proposal.finalized @ GovernanceError::ProposalFinalized)]
    pub proposal: Account<'info, Proposal>, // no VoteOverrideCache account here
}

pub fn finalize_proposal(&mut self) -> Result<()> {
    require!(clock.epoch >= self.proposal.end_epoch, GovernanceError::VotingPeriodNotEnded);
    emit!(ProposalFinalized {
        total_for_votes: self.proposal.for_votes_lamports,
        total_against_votes: self.proposal.against_votes_lamports,
        total_abstain_votes: self.proposal.abstain_votes_lamports,
        // ...
    });
    self.proposal.finalized = true; // every vote instruction now rejects this proposal
    Ok(())
}

The only code path that folds cached overrides into the totals is the validator's own cast_vote. So the validator decides whether its delegates' early dissent exists. If a validator's delegators override early and against its position, the validator can stay silent. It gives up its own vote, but it takes their votes with it. When the dissenting bloc would hurt the validator's preferred outcome more than its own missing vote would help, silence is the rational move.

Flow of a delegator override: if the validator has voted, the override goes straight to the proposal totals; if not, it is parked in VoteOverrideCache and only reaches the totals if the validator later calls cast_vote. finalize_proposal never reads the cache, so the override is never counted.

The delegators who override their votes are left with an on-chain VoteOverride receipt for a vote that was never counted.

The SGP-0002 Near Miss

The fix shipped three weeks before Solana's August votes, preventing vote loss. On-chain data reveals how close SGP-0002 came to failing under the bug.

Passing SGP-0002 (Double Disinflation) required a two-thirds (66.67%) supermajority. It passed with 67.00%, just barely clearing the threshold by 0.33%.

This narrow victory depended on delegators overriding non-voting validators. Across 102 non-voting validators, delegators cast 212 overrides totaling 2.81M SOL (99.5% Yes). JitoSOL holders alone submitted 294 overrides worth 9.86M SOL, 2.68M SOL of which sat with silent validators.

Without the patch, these cached overrides would have been discarded upon finalization, and the discarded Yes votes would have dropped to 66.65%, causing the proposal to fail by 0.02%.

Across all three August proposals, silent validators would have caused 8.45M SOL in delegate votes to be discarded. While the outcomes of SGP-0001 and SGP-0003 would not have changed, this loss would have caused SGP-0002 to fail completely silently.

Discovering with Crucible

Crucible is AR's Solana program fuzzer. It conducts high-performance, stateful invariant fuzzing: a combination of edge feedback, state feedback, smart scheduling, and snapshotting allows it to effectively explore a program's state space while testing invariants.

You could write a unit test for this exact ordering, but only once you already knew about the bug. Instead, the svmgov harness states one property that must hold no matter what happens:

Every vote cast is counted, exactly once.

Once you write the setup and actions as described in the original Crucible post, you add this invariant. This single invariant stands in for every test you could write about vote accounting: every ordering of votes and overrides, every modification, every validator that shows up late or never shows up at all. In code, it's this:

#[invariant_test]
fn vote_conservation(fixture: &mut Governance) {
    for (proposal, validators) in fixture.proposals_with_receipts() {
        let (mut counted, mut pending) = (Tally::default(), Tally::default());
        for (vote, overrides) in &validators {
            let overridden: u64 = overrides.iter().map(|o| o.stake_amount).sum();
            if let Some(vote) = vote {
                counted += Tally::weigh(vote.stake - overridden, vote_bps(vote));
            }
            for o in overrides {
                let w = Tally::weigh(o.stake_amount, override_bps(o));
                if vote.is_some() { counted += w } else { pending += w }
            }
        }
        let totals = Tally::of(&proposal);
        fuzz_assert!(counted.le(totals) && totals.le(counted + pending),
            "vote totals {totals:?} outside receipts [{counted:?}, +pending {pending:?}]");
        fuzz_assert!(!proposal.finalized || totals == counted + pending,
            "finalized totals {totals:?} != signed receipts {:?}", counted + pending);
    }
}

The invariant is a basic conservation check. It adds up what every signed vote (validator votes and delegator overrides) says should be counted, and compares that with the proposal's totals. While voting is open, a vote may still be pending. Once the proposal is finalized, every vote has to be in.

The invariant doesn't mention caches, orderings, or silent validators. Finding a sequence that breaks it is Crucible's job. From an empty corpus, in stateful mode on a single CPU core running at ~8,500 executions per second, Crucible discovers a violating sequence in a median of 0.4 seconds. The same harness on 10 cores runs at ~60,000-80,000 executions per second.

Using Crucible's crash sequence minimization, the following minimal 7-action sequence can be generated:

[FUZZ_FINDING] crash:crash_466a68325835256d reproduces:true summary:finalized totals Tally([0, 0, 0]) != signed receipts Tally([751480250, 371976545, 0])
  1. create_proposal(p=0) -> OK
  2. support_proposal(p=0, v=3) -> OK
  3. support_proposal(p=0, v=2) -> OK
  4. warp_epochs(n=3) -> OK
  5. cast_vote_override(against_bp=8590, d=0, for_bp=6689, p=0, v=1) -> OK
  6. warp_epochs(n=3) -> OK
  7. finalize_proposal(p=0) -> OK [VIOLATION]

Two validators support a proposal, and voting opens. A delegate of validator 1 overrides (about two-thirds for, one-third against) before validator 1 has voted, so the vote goes into the cache. The clock runs past the end of voting, and validator 1 never votes. Then anyone finalizes. The proposal closes, and validator 1's overridden votes are never counted.

Why Fuzzing Is Necessary

Before this bug was discovered through fuzzing, svmgov had been audited and had shipped three releases without anyone noticing the issue.

Unit tests leave blind spots. Unit tests only check scenarios the author manually anticipates. Catching this bug required a very specific sequence of events: a delegator overrides early, the validator never votes, the voting period ends, and the proposal finalizes. Standard test cases where the validator votes before or after pass without issue.

Stateful fuzzing explores complex sequences. By automatically generating stateful instruction sequences, Crucible thoroughly tests system invariants. It excels at uncovering entire classes of edge cases where state is stored for a subsequent step that may never execute.

Invariants continuously protect against regressions. When additional updates modified the override path weeks later, fuzzed invariants continuously verified core properties across all possible states, preventing silent regressions before code hit mainnet.

STRIDE Fuzzing

This bug was discovered through STRIDE, our initiative to secure the Solana ecosystem. Continuous fuzzing with Crucible is just one component of STRIDE, of which we are actively onboarding protocols. Once integrated, every pull request is automatically fuzzed in CI and uploaded to our fuzzing service. This continuous fuzzing approach helps identify similar logic bugs and prevents regressions long before code ever reaches mainnet.

Reach out to us if you're interested in this setup for your protocol.

Disclosure Timeline

Date Event
2026-07-10 Reported to the Solana Foundation
2026-07-30 Fix merged (solana-governance#110): overrides now count toward proposal totals immediately, whether or not the validator has voted
2026-08-02 Fix released in v0.4.0
2026-08-03 Upgraded program deployed to mainnet
2026-08-24 Governance votes on the constitution, token supply, and fees open

Special thanks to Tim Garcia of the Solana Foundation for initiating this effort and for his ongoing collaboration, and to Tiago Carvalho for a fast fix turnaround well ahead of the August votes.

Subscribe to be notified whenever we publish new security research.

Get the latest