Skip to main content
Sigvex

SPL Governance Attack Remediation

How to protect governance implementations from flash governance, quorum bypass, and timelock circumvention.

SPL Governance Attack Remediation

Overview

Related Detector: SPL Governance Attack

Governance attack vulnerabilities allow proposal manipulation, flash governance, and quorum bypass. The fix requires enforcing on-chain timelocks between proposal creation and execution, validating quorum thresholds, and verifying voting power against locked (not flash-loanable) token balances.

Before (Vulnerable)

pub fn execute_proposal(accounts: &[AccountInfo]) -> ProgramResult {
    let proposal = &accounts[0];
    let data = proposal.try_borrow_data()?;
    let state = Proposal::try_from_slice(&data[8..])?;

    // No timelock, no quorum check
    if state.votes_for > state.votes_against {
        // Execute immediately
        perform_proposal_action(accounts, &state)?;
    }
    Ok(())
}

After (Fixed)

pub fn execute_proposal(accounts: &[AccountInfo]) -> ProgramResult {
    let proposal = &accounts[0];
    let clock = Clock::get()?;
    let data = proposal.try_borrow_data()?;
    let state = Proposal::try_from_slice(&data[8..])?;

    // FIXED: enforce timelock
    let earliest_execution = state.voting_end + TIMELOCK_DELAY;
    if clock.unix_timestamp < earliest_execution {
        msg!("Timelock active until {}", earliest_execution);
        return Err(ProgramError::InvalidArgument);
    }

    // FIXED: verify quorum
    let total_votes = state.votes_for + state.votes_against;
    if total_votes < QUORUM_THRESHOLD {
        return Err(ProgramError::InvalidArgument);
    }

    // FIXED: verify proposal passed
    if state.votes_for <= state.votes_against {
        return Err(ProgramError::InvalidArgument);
    }

    // FIXED: verify proposal not already executed
    if state.status == ProposalStatus::Executed {
        return Err(ProgramError::InvalidArgument);
    }

    perform_proposal_action(accounts, &state)?;
    Ok(())
}

Alternative Mitigations

1. Voting escrow (ve-token) pattern

Prevent flash loan attacks by requiring tokens to be locked before voting:

#[account]
pub struct VotingEscrow {
    pub owner: Pubkey,
    pub amount: u64,
    pub lock_end: i64,      // Tokens locked until this timestamp
    pub voting_power: u64,  // Calculated at lock time, not at vote time
}

pub fn cast_vote(ctx: Context<CastVote>, vote: bool) -> Result<()> {
    let escrow = &ctx.accounts.escrow;
    let clock = Clock::get()?;

    // Tokens must be locked through the end of the voting period
    require!(
        escrow.lock_end > ctx.accounts.proposal.voting_end,
        ErrorCode::InsufficientLockPeriod
    );

    // Use pre-committed voting power, not current balance
    let weight = escrow.voting_power;
    // ... apply vote with weight
    Ok(())
}

2. Snapshot-based voting power

Record token balances at proposal creation time and use those for voting, preventing mid-vote balance manipulation:

pub fn create_proposal(ctx: Context<CreateProposal>) -> Result<()> {
    let proposal = &mut ctx.accounts.proposal;
    proposal.snapshot_slot = Clock::get()?.slot;
    // Voters must prove their balance at snapshot_slot
    Ok(())
}

pub fn cast_vote(ctx: Context<CastVote>, vote: bool) -> Result<()> {
    // Verify balance proof at snapshot_slot
    // This prevents flash-loan voting since the loan
    // wasn't present at snapshot time
    Ok(())
}

3. Anchor governance constraints

#[derive(Accounts)]
pub struct ExecuteProposal<'info> {
    #[account(
        mut,
        constraint = proposal.status == ProposalStatus::Approved,
        constraint = Clock::get()?.unix_timestamp >= proposal.voting_end + TIMELOCK,
        constraint = proposal.votes_for + proposal.votes_against >= QUORUM,
        constraint = proposal.votes_for > proposal.votes_against,
    )]
    pub proposal: Account<'info, Proposal>,
    pub executor: Signer<'info>,
}

Common Mistakes

Mistake 1: Using current token balance for voting weight

// WRONG: balance can be flash-loaned
let voting_power = token_account.amount;

Use locked or escrowed balances, or snapshot balances at proposal creation time.

Mistake 2: Timelock that can be set to zero

// WRONG: admin can set timelock to 0, then flash-govern
pub fn set_timelock(accounts: &[AccountInfo], delay: i64) -> ProgramResult {
    governance_config.timelock_seconds = delay;  // Could be 0
    Ok(())
}

Enforce a minimum timelock that cannot be reduced below a safe threshold.

Mistake 3: No replay protection on proposal execution

// WRONG: proposal can be executed multiple times
if state.votes_for > state.votes_against {
    perform_action(accounts)?;
    // Forgot to mark as executed
}

Always mark proposals as executed after performing their action and check the status before execution.

References