Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
31dd49b
wip
pileks Jan 8, 2026
a303f5c
wip
pileks Jan 9, 2026
81064fd
implementation of initiation of new optimistic governance proposal
pileks Jan 9, 2026
2e85455
add finalize optimistic proposal ix
pileks Jan 9, 2026
7f458ea
launch_proposal should only be able to challenge an optimistic propos…
pileks Jan 9, 2026
63ed333
minor cleanups
pileks Jan 9, 2026
8606360
tests for optimistic vault transaction proposal initiation
pileks Jan 10, 2026
6702eaf
optimistic proposal finalization tests
pileks Jan 10, 2026
69a4b61
prevent initialization of futarchy proposal when optimistic governanc…
pileks Jan 10, 2026
27962d0
add missing check
pileks Jan 10, 2026
2aa4420
team can't sponsor a challenge to an optimistic governance proposal
pileks Jan 10, 2026
0d55d11
work-in-progress PR comments
pileks Jan 16, 2026
5d012b1
Merge remote-tracking branch 'origin/develop' into pileks/met-5-optim…
pileks Jan 16, 2026
c98ce2e
address review comments
pileks Jan 17, 2026
484e7ad
reintroduce resize_dao crankable instruction with scripts
pileks Jan 17, 2026
62e4d8a
reintroduce resize_dao crankable instruction with scripts (#400)
pileks Jan 17, 2026
a1acfaf
Merge branch 'pileks/met-5-optimistic-governance' of github.com:metaD…
pileks Jan 17, 2026
4731cf6
Merge remote-tracking branch 'origin/develop' into pileks/met-5-optim…
pileks Jan 17, 2026
d05c432
Merge remote-tracking branch 'origin/develop' into pileks/met-5-optim…
pileks Jan 18, 2026
98dfe15
remove duplicate optimistic proposal check
pileks Jan 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Anchor.toml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ v06-create-dao = "yarn run tsx scripts/v0.6/createDao.ts"
v06-provide-liquidity = "yarn run tsx scripts/v0.6/provideLiquidity.ts"
v06-collect-meteora-damm-fees = "yarn run tsx scripts/v0.6/collectMeteoraDammFees.ts"
v06-return-funds = "yarn run tsx scripts/v0.6/returnFunds.ts"
v06-dump-daos = "yarn run tsx scripts/v0.6/dumpDaos.ts"
v06-migrate-daos = "yarn run tsx scripts/v0.6/migrateDaos.ts"
v07-collect-meteora-damm-fees = "yarn run tsx scripts/v0.7/collectMeteoraDammFees.ts"
v07-launch-template = "yarn run tsx scripts/v0.7/launchTemplate.ts"
v07-start-launch = "yarn run tsx scripts/v0.7/startLaunch.ts"
Expand Down
14 changes: 14 additions & 0 deletions programs/futarchy/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,4 +76,18 @@ pub enum FutarchyError {
InvalidTargetK,
#[msg("Failed to compile transaction message for Squads vault transaction")]
InvalidTransactionMessage,
#[msg("Invalid recipient")]
InvalidRecipient,
#[msg("Optimistic governance is disabled")]
OptimisticGovernanceDisabled,
#[msg("An active optimistic proposal is already enqueued")]
ActiveOptimisticProposalAlreadyEnqueued,
#[msg("No active optimistic proposal")]
NoActiveOptimisticProposal,
#[msg("Optimistic proposal has already passed")]
OptimisticProposalAlreadyPassed,
#[msg("Team cannot sponsor a challenge to an optimistic proposal")]
CannotSponsorOptimisticProposalChallenge,
#[msg("Invalid spending limit mint. Must be the same as the DAO's quote mint")]
InvalidSpendingLimitMint,
}
23 changes: 23 additions & 0 deletions programs/futarchy/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ pub struct UpdateDaoEvent {
pub base_to_stake: u64,
pub team_sponsored_pass_threshold_bps: i16,
pub team_address: Pubkey,
pub is_optimistic_governance_enabled: bool,
}

#[event]
Expand Down Expand Up @@ -203,3 +204,25 @@ pub struct CollectMeteoraDammFeesEvent {
pub quote_fees_collected: u64,
pub base_fees_collected: u64,
}

#[event]
pub struct InitiateVaultSpendOptimisticProposalEvent {
pub common: CommonFields,
pub dao: Pubkey,
pub proposer: Pubkey,
pub squads_proposal: Pubkey,
pub squads_multisig: Pubkey,
pub squads_multisig_vault: Pubkey,
pub amount: u64,
pub recipient: Pubkey,
pub dao_quote_vault_account: Pubkey,
pub recipient_quote_account: Pubkey,
pub enqueued_timestamp: i64,
}

#[event]
pub struct FinalizeOptimisticProposalEvent {
pub common: CommonFields,
pub dao: Pubkey,
pub squads_proposal: Pubkey,
}
105 changes: 1 addition & 104 deletions programs/futarchy/src/instructions/collect_meteora_damm_fees.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
use anchor_lang::AnchorSerialize;
use anchor_lang::InstructionData;
use damm_v2_cpi::program::DammV2Cpi;
use std::collections::BTreeMap;

use super::*;

Expand Down Expand Up @@ -231,7 +230,7 @@ impl CollectMeteoraDammFees<'_> {
// This correctly sets num_writable_signers and num_writable_non_signers
// instead of the inverted readonly counts from Solana's Message::serialize()
let transaction_message =
compile_transaction_message(&ctx.accounts.squads_multisig_vault.key(), &[ix])?;
compile_squads_transaction_message(&ctx.accounts.squads_multisig_vault.key(), &[ix])?;

let transaction_message_bytes = transaction_message.try_to_vec()?;

Expand Down Expand Up @@ -399,105 +398,3 @@ impl CollectMeteoraDammFees<'_> {
Ok(())
}
}

/// Compiles a Solana instruction into a Squads TransactionMessage format.
/// This is necessary because Solana's Message::serialize() uses a different header format
/// (num_readonly_signed_accounts, num_readonly_unsigned_accounts) than Squads expects
/// (num_writable_signers, num_writable_non_signers).
fn compile_transaction_message(
vault_key: &Pubkey,
instructions: &[anchor_lang::solana_program::instruction::Instruction],
) -> Result<squads_multisig_program::TransactionMessage> {
// Track account metadata: (is_signer, is_writable)
let mut key_meta_map: BTreeMap<Pubkey, (bool, bool)> = BTreeMap::new();

// Add vault as a signer (it will sign the vault transaction)
// Writability is determined by whether it appears as writable in instruction accounts
key_meta_map.insert(*vault_key, (true, false));

// Collect all accounts from instructions, merging their flags with OR
for ix in instructions {
// Program ID is a non-signer, non-writable account
key_meta_map.entry(ix.program_id).or_insert((false, false));

for meta in &ix.accounts {
let entry = key_meta_map.entry(meta.pubkey).or_insert((false, false));
entry.0 |= meta.is_signer;
entry.1 |= meta.is_writable;
}
}

// Sort accounts into: writable signers, readonly signers, writable non-signers, readonly non-signers
let mut writable_signers: Vec<Pubkey> = Vec::new();
let mut readonly_signers: Vec<Pubkey> = Vec::new();
let mut writable_non_signers: Vec<Pubkey> = Vec::new();
let mut readonly_non_signers: Vec<Pubkey> = Vec::new();

for (pubkey, (is_signer, is_writable)) in &key_meta_map {
if *is_signer && *is_writable {
writable_signers.push(*pubkey);
} else if *is_signer {
// Vault key should be first among readonly signers
if *pubkey == *vault_key {
readonly_signers.insert(0, *pubkey);
} else {
readonly_signers.push(*pubkey);
}
} else if *is_writable {
writable_non_signers.push(*pubkey);
} else {
readonly_non_signers.push(*pubkey);
}
}

// Build the final account keys list in sorted order
let mut account_keys: Vec<Pubkey> = Vec::new();
account_keys.extend(&writable_signers);
account_keys.extend(&readonly_signers);
account_keys.extend(&writable_non_signers);
account_keys.extend(&readonly_non_signers);

// Calculate counts
let num_signers = (writable_signers.len() + readonly_signers.len()) as u8;
let num_writable_signers = writable_signers.len() as u8;
let num_writable_non_signers = writable_non_signers.len() as u8;

// Build account key index lookup
let key_to_index: BTreeMap<Pubkey, u8> = account_keys
.iter()
.enumerate()
.map(|(i, k)| (*k, i as u8))
.collect();

// Compile instructions with new indices
let mut compiled_instructions: Vec<squads_multisig_program::CompiledInstruction> = Vec::new();
for ix in instructions {
let program_id_index = *key_to_index
.get(&ix.program_id)
.ok_or(FutarchyError::InvalidTransactionMessage)?;

let account_indexes: Vec<u8> = ix
.accounts
.iter()
.map(|meta| key_to_index.get(&meta.pubkey).copied())
.collect::<Option<Vec<u8>>>()
.ok_or(FutarchyError::InvalidTransactionMessage)?;

compiled_instructions.push(squads_multisig_program::CompiledInstruction {
program_id_index,
account_indexes: squads_multisig_program::SmallVec::from(account_indexes),
data: squads_multisig_program::SmallVec::from(ix.data.clone()),
});
}

Ok(squads_multisig_program::TransactionMessage {
num_signers,
num_writable_signers,
num_writable_non_signers,
account_keys: squads_multisig_program::SmallVec::from(account_keys),
instructions: squads_multisig_program::SmallVec::from(compiled_instructions),
address_table_lookups: squads_multisig_program::SmallVec::from(Vec::<
squads_multisig_program::MessageAddressTableLookup,
>::new()),
})
}
85 changes: 85 additions & 0 deletions programs/futarchy/src/instructions/finalize_optimistic_proposal.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
use super::*;

#[derive(Accounts)]
#[event_cpi]
pub struct FinalizeOptimisticProposal<'info> {
#[account(mut, seeds = [squads_multisig_program::SEED_PREFIX, squads_multisig_program::SEED_MULTISIG, dao.key().as_ref()], bump, seeds::program = squads_program)]
pub squads_multisig: Account<'info, squads_multisig_program::Multisig>,
#[account(mut, address = dao.optimistic_proposal.as_ref().unwrap().squads_proposal)]
pub squads_proposal: Box<Account<'info, squads_multisig_program::Proposal>>,

#[account(mut, has_one = squads_multisig)]
pub dao: Box<Account<'info, Dao>>,

pub squads_program: Program<'info, squads_multisig_program::program::SquadsMultisigProgram>,
}

impl FinalizeOptimisticProposal<'_> {
pub fn validate(&self) -> Result<()> {
require_keys_eq!(self.squads_proposal.multisig, self.dao.squads_multisig);

// A minimum of proposal duration must have passed since the the optimistic proposal was enqueued
// We know that the optimistic proposal is not None, because the address constraint would have already panicked
require_gte!(
Clock::get()?.unix_timestamp,
self.dao
.optimistic_proposal
.as_ref()
.unwrap()
.enqueued_timestamp
+ self.dao.seconds_per_proposal as i64,
FutarchyError::ProposalTooYoung
);

// Pool must be in spot state - no active proposals
// This should never be hit, but it's here for completeness
require!(
matches!(self.dao.amm.state, PoolState::Spot { .. }),
FutarchyError::PoolNotInSpotState
);

Ok(())
}

pub fn handle(ctx: Context<Self>) -> Result<()> {
let Self {
squads_multisig,
squads_proposal,
dao,
event_authority: _,
program: _,
squads_program,
} = ctx.accounts;

let dao_nonce = &dao.nonce.to_le_bytes();
let dao_creator_key = dao.dao_creator.as_ref();
let dao_seeds = &[b"dao".as_ref(), dao_creator_key, dao_nonce, &[dao.pda_bump]];

let dao_signer = &[&dao_seeds[..]];

squads_multisig_program::cpi::proposal_approve(
CpiContext::new_with_signer(
squads_program.to_account_info(),
squads_multisig_program::cpi::accounts::ProposalVote {
proposal: squads_proposal.to_account_info(),
multisig: squads_multisig.to_account_info(),
member: dao.to_account_info(), // DAO can approve the proposal
},
dao_signer,
),
squads_multisig_program::ProposalVoteArgs { memo: None },
)?;

// Update the DAO state
dao.optimistic_proposal = None;
dao.seq_num += 1;

emit_cpi!(FinalizeOptimisticProposalEvent {
common: CommonFields::new(&Clock::get()?, dao.seq_num),
dao: dao.key(),
squads_proposal: squads_proposal.key(),
});

Ok(())
}
}
2 changes: 2 additions & 0 deletions programs/futarchy/src/instructions/initialize_dao.rs
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,8 @@ impl InitializeDao<'_> {
},
team_sponsored_pass_threshold_bps,
team_address,
optimistic_proposal: None,
is_optimistic_governance_enabled: false,
Comment on lines +215 to +216
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

don't we need to add some update account instructions again here

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

theoretically this could be a separate PR but I prefer it in this one

Copy link
Contributor Author

@pileks pileks Jan 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

update_dao handles updating already. We decided to go with false as a default on creation as we don't want it on when a new DAO gets created. We can add a param to creation if you want to.

});

dao.invariant()?;
Expand Down
13 changes: 13 additions & 0 deletions programs/futarchy/src/instructions/initialize_proposal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,19 @@ pub struct InitializeProposal<'info> {

impl InitializeProposal<'_> {
pub fn validate(&self) -> Result<()> {
// If we're trying to challenge an optimistic proposal that has already passed due to age, we should error
// In the case of an already-optimistically-passed proposal, the optimistic proposal can be cleared
// from the DAO state by finalizing the optimistic proposal (finalize_optimistic_proposal)
if let Some(ref optimistic_proposal) = self.dao.optimistic_proposal {
if optimistic_proposal.squads_proposal == self.squads_proposal.key() {
require_gt!(
optimistic_proposal.enqueued_timestamp + self.dao.seconds_per_proposal as i64,
Clock::get()?.unix_timestamp,
FutarchyError::OptimisticProposalAlreadyPassed
);
}
}

require_eq!(
self.question.num_outcomes(),
2,
Expand Down
Loading
Loading