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.
Recommended Fix
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.