From 31dd49b2758f68f72c8adb6a90cec6f8320a6c63 Mon Sep 17 00:00:00 2001 From: Pileks Date: Thu, 8 Jan 2026 22:34:17 +0100 Subject: [PATCH 01/16] wip --- programs/futarchy/src/events.rs | 1 + programs/futarchy/src/instructions/initialize_dao.rs | 3 +++ programs/futarchy/src/instructions/update_dao.rs | 8 ++++++++ programs/futarchy/src/state/dao.rs | 6 ++++++ scripts/v0.6/launchSOLO.ts | 3 +-- 5 files changed, 19 insertions(+), 2 deletions(-) diff --git a/programs/futarchy/src/events.rs b/programs/futarchy/src/events.rs index 5e96aedf..a718a725 100644 --- a/programs/futarchy/src/events.rs +++ b/programs/futarchy/src/events.rs @@ -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] diff --git a/programs/futarchy/src/instructions/initialize_dao.rs b/programs/futarchy/src/instructions/initialize_dao.rs index a2503459..4e8afc59 100644 --- a/programs/futarchy/src/instructions/initialize_dao.rs +++ b/programs/futarchy/src/instructions/initialize_dao.rs @@ -212,6 +212,9 @@ impl InitializeDao<'_> { }, team_sponsored_pass_threshold_bps, team_address, + active_optimistic_squads_proposal: None, + active_optimistic_squads_proposal_enqueued_timestamp: None, + is_optimistic_governance_enabled: false, }); dao.invariant()?; diff --git a/programs/futarchy/src/instructions/update_dao.rs b/programs/futarchy/src/instructions/update_dao.rs index 3d3babb3..2a2e5e66 100644 --- a/programs/futarchy/src/instructions/update_dao.rs +++ b/programs/futarchy/src/instructions/update_dao.rs @@ -12,6 +12,7 @@ pub struct UpdateDaoParams { pub base_to_stake: Option, pub team_sponsored_pass_threshold_bps: Option, pub team_address: Option, + pub is_optimistic_governance_enabled: Option, } #[derive(Accounts)] @@ -64,6 +65,12 @@ impl UpdateDao<'_> { .team_sponsored_pass_threshold_bps .unwrap_or(dao.team_sponsored_pass_threshold_bps), team_address: dao_params.team_address.unwrap_or(dao.team_address), + active_optimistic_squads_proposal: dao.active_optimistic_squads_proposal, + active_optimistic_squads_proposal_enqueued_timestamp: dao + .active_optimistic_squads_proposal_enqueued_timestamp, + is_optimistic_governance_enabled: dao_params + .is_optimistic_governance_enabled + .unwrap_or(dao.is_optimistic_governance_enabled), }); dao.seq_num += 1; @@ -84,6 +91,7 @@ impl UpdateDao<'_> { base_to_stake: dao.base_to_stake, team_sponsored_pass_threshold_bps: dao.team_sponsored_pass_threshold_bps, team_address: dao.team_address, + is_optimistic_governance_enabled: dao.is_optimistic_governance_enabled, }); Ok(()) diff --git a/programs/futarchy/src/state/dao.rs b/programs/futarchy/src/state/dao.rs index 24e066ca..61248646 100644 --- a/programs/futarchy/src/state/dao.rs +++ b/programs/futarchy/src/state/dao.rs @@ -56,6 +56,12 @@ pub struct Dao { /// Can be negative to allow for team-sponsored proposals to pass by default. pub team_sponsored_pass_threshold_bps: i16, pub team_address: Pubkey, + /// The squads proposal currently enqueued for execution if not challenged by a new proposal. + pub active_optimistic_squads_proposal: Option, + /// The timestamp when the active optimistic squads proposal was enqueued. + pub active_optimistic_squads_proposal_enqueued_timestamp: Option, + /// Whether optimistic governance is enabled for this DAO. + pub is_optimistic_governance_enabled: bool, } #[derive(AnchorSerialize, AnchorDeserialize, Debug, Clone, PartialEq, Eq, InitSpace)] diff --git a/scripts/v0.6/launchSOLO.ts b/scripts/v0.6/launchSOLO.ts index 37e12926..0ec11ec5 100644 --- a/scripts/v0.6/launchSOLO.ts +++ b/scripts/v0.6/launchSOLO.ts @@ -74,8 +74,7 @@ export const launch = async () => { .initializeLaunchIx({ tokenName: "Solomon", tokenSymbol: "SOLO", - tokenUri: - "https://solomonlabs.org/assets/solo.json", + tokenUri: "https://solomonlabs.org/assets/solo.json", minimumRaiseAmount: new BN(MIN_GOAL * 10 ** 6), baseMint: TOKEN, monthlySpendingLimitAmount: new BN(SPENDING_LIMIT * 10 ** 6), From a303f5c7ec42a5f13a69c9426b7ffb286a30d52a Mon Sep 17 00:00:00 2001 From: Pileks Date: Fri, 9 Jan 2026 01:25:55 +0100 Subject: [PATCH 02/16] wip --- programs/futarchy/src/error.rs | 4 + .../instructions/collect_meteora_damm_fees.rs | 103 ---------- ...nitiate_vault_spend_optimistic_proposal.rs | 188 ++++++++++++++++++ programs/futarchy/src/instructions/mod.rs | 2 + programs/futarchy/src/lib.rs | 10 + programs/futarchy/src/squads.rs | 107 ++++++++++ sdk/src/v0.7/types/futarchy.ts | 68 +++++++ 7 files changed, 379 insertions(+), 103 deletions(-) create mode 100644 programs/futarchy/src/instructions/initiate_vault_spend_optimistic_proposal.rs create mode 100644 programs/futarchy/src/squads.rs diff --git a/programs/futarchy/src/error.rs b/programs/futarchy/src/error.rs index e40223c5..3a6f26b2 100644 --- a/programs/futarchy/src/error.rs +++ b/programs/futarchy/src/error.rs @@ -76,4 +76,8 @@ 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, } diff --git a/programs/futarchy/src/instructions/collect_meteora_damm_fees.rs b/programs/futarchy/src/instructions/collect_meteora_damm_fees.rs index d068f5e2..d4125ef0 100644 --- a/programs/futarchy/src/instructions/collect_meteora_damm_fees.rs +++ b/programs/futarchy/src/instructions/collect_meteora_damm_fees.rs @@ -1,7 +1,6 @@ use anchor_lang::AnchorSerialize; use anchor_lang::InstructionData; use damm_v2_cpi::program::DammV2Cpi; -use std::collections::BTreeMap; use super::*; @@ -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 { - // Track account metadata: (is_signer, is_writable) - let mut key_meta_map: BTreeMap = 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 = Vec::new(); - let mut readonly_signers: Vec = Vec::new(); - let mut writable_non_signers: Vec = Vec::new(); - let mut readonly_non_signers: Vec = 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 = 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 = account_keys - .iter() - .enumerate() - .map(|(i, k)| (*k, i as u8)) - .collect(); - - // Compile instructions with new indices - let mut compiled_instructions: Vec = 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 = ix - .accounts - .iter() - .map(|meta| key_to_index.get(&meta.pubkey).copied()) - .collect::>>() - .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()), - }) -} diff --git a/programs/futarchy/src/instructions/initiate_vault_spend_optimistic_proposal.rs b/programs/futarchy/src/instructions/initiate_vault_spend_optimistic_proposal.rs new file mode 100644 index 00000000..241efdc5 --- /dev/null +++ b/programs/futarchy/src/instructions/initiate_vault_spend_optimistic_proposal.rs @@ -0,0 +1,188 @@ +use super::*; + +#[derive(Debug, Clone, AnchorSerialize, AnchorDeserialize, PartialEq, Eq)] +pub struct InitiateVaultSpendOptimisticProposalParams { + pub amount: u64, +} + +#[derive(Accounts)] +#[event_cpi] +pub struct InitiateVaultSpendOptimisticProposal<'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(seeds = [squads_multisig_program::SEED_PREFIX, squads_multisig.key().as_ref(), squads_multisig_program::SEED_VAULT, 0_u8.to_le_bytes().as_ref()], bump, seeds::program = squads_program)] + pub squads_multisig_vault: UncheckedAccount<'info>, + #[account(mut, seeds = [squads_multisig_program::SEED_PREFIX, squads_multisig.key().as_ref(), squads_multisig_program::SEED_SPENDING_LIMIT, dao.key().as_ref()], bump, seeds::program = squads_program)] + pub squads_spending_limit: Account<'info, squads_multisig_program::SpendingLimit>, + // Probably need to use unchecked account, as these are not yet initialized + #[account(mut)] + pub squads_proposal: Box>, + #[account(mut)] + pub squads_vault_transaction: Box>, + + #[account(mut)] + pub dao: Box>, + #[account(mut, address = dao.team_address)] + pub proposer: Signer<'info>, + + #[account(address = permissionless_account::id())] + pub squads_multisig_permissionless_account: Signer<'info>, + + pub recipient: UncheckedAccount<'info>, + #[account(mut, associated_token::mint = dao.quote_mint, associated_token::authority = recipient)] + pub recipient_quote_account: Account<'info, TokenAccount>, + + #[account(mut, associated_token::mint = dao.quote_mint, associated_token::authority = dao.squads_multisig_vault)] + pub dao_quote_vault_account: Account<'info, TokenAccount>, + + #[account(mut)] + pub payer: Signer<'info>, + pub system_program: Program<'info, System>, + pub squads_program: Program<'info, squads_multisig_program::program::SquadsMultisigProgram>, + pub token_program: Program<'info, Token>, +} + +impl InitiateVaultSpendOptimisticProposal<'_> { + pub fn validate(&self, params: &InitiateVaultSpendOptimisticProposalParams) -> Result<()> { + require_keys_eq!(self.squads_proposal.multisig, self.dao.squads_multisig); + + // Optimistic governance must be enabled + require!( + self.dao.is_optimistic_governance_enabled, + FutarchyError::OptimisticGovernanceDisabled + ); + + // Pool must be in spot state - no active proposals + match self.dao.amm.state { + PoolState::Spot { spot: _ } => {} + _ => { + return Err(FutarchyError::PoolNotInSpotState.into()); + } + } + + // A minimum of proposal duration must have passed since the last optimistic proposal was enqueued + match self + .dao + .active_optimistic_squads_proposal_enqueued_timestamp + { + Some(enqueued_timestamp) => { + require_gte!( + Clock::get()?.unix_timestamp, + enqueued_timestamp + self.dao.seconds_per_proposal as i64, + FutarchyError::ProposalDurationTooShort + ); + } + None => {} + }; + + // Amount must be less than or equal to 3 times the spending limit + require_gte!( + self.squads_spending_limit.amount.checked_mul(3).unwrap(), + params.amount, + FutarchyError::InvalidAmount + ); + + Ok(()) + } + + pub fn handle( + ctx: Context, + params: InitiateVaultSpendOptimisticProposalParams, + ) -> Result<()> { + let Self { + squads_multisig, + squads_multisig_vault, + squads_spending_limit, + squads_proposal, + squads_vault_transaction: squads_transaction, + dao, + payer: _, + system_program: _, + event_authority: _, + program: _, + squads_program: _, + proposer, + recipient, + recipient_quote_account, + squads_multisig_permissionless_account, + token_program, + dao_quote_vault_account, + } = ctx.accounts; + + let ix = anchor_spl::token::spl_token::instruction::transfer( + &ctx.accounts.token_program.key(), + &ctx.accounts.dao_quote_vault_account.key(), + &ctx.accounts.recipient_quote_account.key(), + &ctx.accounts.dao.squads_multisig_vault.key(), + &[&ctx.accounts.squads_multisig_vault.key()], + params.amount, + )?; + + // Compile the transaction message in Squads' format + let transaction_message = + compile_transaction_message(&ctx.accounts.squads_multisig_vault.key(), &[ix])?; + + let transaction_message_bytes = transaction_message.try_to_vec()?; + + let dao_nonce = &ctx.accounts.dao.nonce.to_le_bytes(); + let dao_creator_key = ctx.accounts.dao.dao_creator.as_ref(); + let dao_seeds = &[ + b"dao".as_ref(), + dao_creator_key, + dao_nonce, + &[ctx.accounts.dao.pda_bump], + ]; + + let dao_signer = &[&dao_seeds[..]]; + + // Create the squads transaction + squads_multisig_program::cpi::vault_transaction_create( + CpiContext::new( + ctx.accounts.squads_program.to_account_info(), + squads_multisig_program::cpi::accounts::VaultTransactionCreate { + creator: ctx + .accounts + .squads_multisig_permissionless_account + .to_account_info(), + multisig: ctx.accounts.squads_multisig.to_account_info(), + rent_payer: ctx.accounts.proposer.to_account_info(), + system_program: ctx.accounts.system_program.to_account_info(), + transaction: ctx.accounts.squads_vault_transaction.to_account_info(), + }, + ), + squads_multisig_program::VaultTransactionCreateArgs { + ephemeral_signers: 0, + vault_index: 0, + transaction_message: transaction_message_bytes, + memo: None, + }, + )?; + // Create the squads proposal + + // Update the DAO state + let clock = Clock::get()?; + + ctx.accounts.dao.active_optimistic_squads_proposal = Some(squads_proposal.key()); + ctx.accounts + .dao + .active_optimistic_squads_proposal_enqueued_timestamp = Some(clock.unix_timestamp); + + // emit_cpi!(InitializeProposalEvent { + // common: CommonFields::new(&clock, dao.seq_num), + // proposal: proposal.key(), + // dao: dao.key(), + // question: question.key(), + // base_vault: base_vault.key(), + // quote_vault: quote_vault.key(), + // proposer: proposer.key(), + // number: dao.proposal_count, + // pda_bump: ctx.bumps.proposal, + // duration_in_seconds: proposal.duration_in_seconds, + // squads_proposal: squads_proposal.key(), + // squads_multisig: dao.squads_multisig, + // squads_multisig_vault: dao.squads_multisig_vault, + // }); + + Ok(()) + } +} diff --git a/programs/futarchy/src/instructions/mod.rs b/programs/futarchy/src/instructions/mod.rs index a43513a6..4f088605 100644 --- a/programs/futarchy/src/instructions/mod.rs +++ b/programs/futarchy/src/instructions/mod.rs @@ -7,6 +7,7 @@ pub mod execute_spending_limit_change; pub mod finalize_proposal; pub mod initialize_dao; pub mod initialize_proposal; +pub mod initiate_vault_spend_optimistic_proposal; pub mod launch_proposal; pub mod provide_liquidity; pub mod sponsor_proposal; @@ -23,6 +24,7 @@ pub use execute_spending_limit_change::*; pub use finalize_proposal::*; pub use initialize_dao::*; pub use initialize_proposal::*; +pub use initiate_vault_spend_optimistic_proposal::*; pub use launch_proposal::*; pub use provide_liquidity::*; pub use sponsor_proposal::*; diff --git a/programs/futarchy/src/lib.rs b/programs/futarchy/src/lib.rs index 6971fb52..37494c8f 100644 --- a/programs/futarchy/src/lib.rs +++ b/programs/futarchy/src/lib.rs @@ -7,11 +7,13 @@ use conditional_vault::{ConditionalVault, Question}; pub mod error; pub mod events; pub mod instructions; +pub mod squads; pub mod state; pub use error::FutarchyError; pub use events::*; pub use instructions::*; +pub use squads::*; pub use state::*; #[cfg(not(feature = "no-entrypoint"))] @@ -147,4 +149,12 @@ pub mod futarchy { pub fn collect_meteora_damm_fees(ctx: Context) -> Result<()> { CollectMeteoraDammFees::handle(ctx) } + + #[access_control(ctx.accounts.validate(¶ms))] + pub fn initiate_vault_spend_optimistic_proposal( + ctx: Context, + params: InitiateVaultSpendOptimisticProposalParams, + ) -> Result<()> { + InitiateVaultSpendOptimisticProposal::handle(ctx, params) + } } diff --git a/programs/futarchy/src/squads.rs b/programs/futarchy/src/squads.rs new file mode 100644 index 00000000..ef85474c --- /dev/null +++ b/programs/futarchy/src/squads.rs @@ -0,0 +1,107 @@ +use anchor_lang::prelude::*; + +use std::collections::BTreeMap; + +use crate::FutarchyError; + +/// 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). +pub fn compile_transaction_message( + vault_key: &Pubkey, + instructions: &[anchor_lang::solana_program::instruction::Instruction], +) -> Result { + // Track account metadata: (is_signer, is_writable) + let mut key_meta_map: BTreeMap = 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 = Vec::new(); + let mut readonly_signers: Vec = Vec::new(); + let mut writable_non_signers: Vec = Vec::new(); + let mut readonly_non_signers: Vec = 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 = 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 = account_keys + .iter() + .enumerate() + .map(|(i, k)| (*k, i as u8)) + .collect(); + + // Compile instructions with new indices + let mut compiled_instructions: Vec = 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 = ix + .accounts + .iter() + .map(|meta| key_to_index.get(&meta.pubkey).copied()) + .collect::>>() + .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()), + }) +} diff --git a/sdk/src/v0.7/types/futarchy.ts b/sdk/src/v0.7/types/futarchy.ts index 19b1d17f..adde546c 100644 --- a/sdk/src/v0.7/types/futarchy.ts +++ b/sdk/src/v0.7/types/futarchy.ts @@ -1325,6 +1325,29 @@ export type Futarchy = { name: "teamAddress"; type: "publicKey"; }, + { + name: "activeOptimisticSquadsProposal"; + docs: [ + "The squads proposal currently enqueued for execution if not challenged by a new proposal.", + ]; + type: { + option: "publicKey"; + }; + }, + { + name: "activeOptimisticSquadsProposalEnqueuedTimestamp"; + docs: [ + "The timestamp when the active optimistic squads proposal was enqueued.", + ]; + type: { + option: "i64"; + }; + }, + { + name: "isOptimisticGovernanceEnabled"; + docs: ["Whether optimistic governance is enabled for this DAO."]; + type: "bool"; + }, ]; }; }, @@ -1678,6 +1701,12 @@ export type Futarchy = { option: "publicKey"; }; }, + { + name: "isOptimisticGovernanceEnabled"; + type: { + option: "bool"; + }; + }, ]; }; }, @@ -2198,6 +2227,11 @@ export type Futarchy = { type: "publicKey"; index: false; }, + { + name: "isOptimisticGovernanceEnabled"; + type: "bool"; + index: false; + }, ]; }, { @@ -4254,6 +4288,29 @@ export const IDL: Futarchy = { name: "teamAddress", type: "publicKey", }, + { + name: "activeOptimisticSquadsProposal", + docs: [ + "The squads proposal currently enqueued for execution if not challenged by a new proposal.", + ], + type: { + option: "publicKey", + }, + }, + { + name: "activeOptimisticSquadsProposalEnqueuedTimestamp", + docs: [ + "The timestamp when the active optimistic squads proposal was enqueued.", + ], + type: { + option: "i64", + }, + }, + { + name: "isOptimisticGovernanceEnabled", + docs: ["Whether optimistic governance is enabled for this DAO."], + type: "bool", + }, ], }, }, @@ -4607,6 +4664,12 @@ export const IDL: Futarchy = { option: "publicKey", }, }, + { + name: "isOptimisticGovernanceEnabled", + type: { + option: "bool", + }, + }, ], }, }, @@ -5127,6 +5190,11 @@ export const IDL: Futarchy = { type: "publicKey", index: false, }, + { + name: "isOptimisticGovernanceEnabled", + type: "bool", + index: false, + }, ], }, { From 81064fd10c43ad796e3d758ac7fc4cb58509502e Mon Sep 17 00:00:00 2001 From: Pileks Date: Fri, 9 Jan 2026 02:10:06 +0100 Subject: [PATCH 03/16] implementation of initiation of new optimistic governance proposal --- programs/futarchy/src/events.rs | 15 + ...nitiate_vault_spend_optimistic_proposal.rs | 111 +++--- sdk/src/v0.7/types/futarchy.ts | 364 ++++++++++++++++++ 3 files changed, 441 insertions(+), 49 deletions(-) diff --git a/programs/futarchy/src/events.rs b/programs/futarchy/src/events.rs index a718a725..61f6d409 100644 --- a/programs/futarchy/src/events.rs +++ b/programs/futarchy/src/events.rs @@ -204,3 +204,18 @@ 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, +} diff --git a/programs/futarchy/src/instructions/initiate_vault_spend_optimistic_proposal.rs b/programs/futarchy/src/instructions/initiate_vault_spend_optimistic_proposal.rs index 241efdc5..5c05efb3 100644 --- a/programs/futarchy/src/instructions/initiate_vault_spend_optimistic_proposal.rs +++ b/programs/futarchy/src/instructions/initiate_vault_spend_optimistic_proposal.rs @@ -10,6 +10,7 @@ pub struct InitiateVaultSpendOptimisticProposalParams { pub struct InitiateVaultSpendOptimisticProposal<'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>, + /// CHECK: The squads multisig vault that executes the transaction #[account(seeds = [squads_multisig_program::SEED_PREFIX, squads_multisig.key().as_ref(), squads_multisig_program::SEED_VAULT, 0_u8.to_le_bytes().as_ref()], bump, seeds::program = squads_program)] pub squads_multisig_vault: UncheckedAccount<'info>, #[account(mut, seeds = [squads_multisig_program::SEED_PREFIX, squads_multisig.key().as_ref(), squads_multisig_program::SEED_SPENDING_LIMIT, dao.key().as_ref()], bump, seeds::program = squads_program)] @@ -28,6 +29,7 @@ pub struct InitiateVaultSpendOptimisticProposal<'info> { #[account(address = permissionless_account::id())] pub squads_multisig_permissionless_account: Signer<'info>, + /// CHECK: Used for constraints pub recipient: UncheckedAccount<'info>, #[account(mut, associated_token::mint = dao.quote_mint, associated_token::authority = recipient)] pub recipient_quote_account: Account<'info, TokenAccount>, @@ -92,17 +94,17 @@ impl InitiateVaultSpendOptimisticProposal<'_> { let Self { squads_multisig, squads_multisig_vault, - squads_spending_limit, + squads_spending_limit: _, squads_proposal, - squads_vault_transaction: squads_transaction, + squads_vault_transaction, dao, payer: _, - system_program: _, + system_program, event_authority: _, program: _, - squads_program: _, + squads_program, proposer, - recipient, + recipient: _, recipient_quote_account, squads_multisig_permissionless_account, token_program, @@ -110,44 +112,35 @@ impl InitiateVaultSpendOptimisticProposal<'_> { } = ctx.accounts; let ix = anchor_spl::token::spl_token::instruction::transfer( - &ctx.accounts.token_program.key(), - &ctx.accounts.dao_quote_vault_account.key(), - &ctx.accounts.recipient_quote_account.key(), - &ctx.accounts.dao.squads_multisig_vault.key(), - &[&ctx.accounts.squads_multisig_vault.key()], + &token_program.key(), + &dao_quote_vault_account.key(), + &recipient_quote_account.key(), + &squads_multisig_vault.key(), + &[&squads_multisig_vault.key()], params.amount, )?; // Compile the transaction message in Squads' format - let transaction_message = - compile_transaction_message(&ctx.accounts.squads_multisig_vault.key(), &[ix])?; + let transaction_message = compile_transaction_message(&squads_multisig_vault.key(), &[ix])?; let transaction_message_bytes = transaction_message.try_to_vec()?; - let dao_nonce = &ctx.accounts.dao.nonce.to_le_bytes(); - let dao_creator_key = ctx.accounts.dao.dao_creator.as_ref(); - let dao_seeds = &[ - b"dao".as_ref(), - dao_creator_key, - dao_nonce, - &[ctx.accounts.dao.pda_bump], - ]; + 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[..]]; // Create the squads transaction squads_multisig_program::cpi::vault_transaction_create( CpiContext::new( - ctx.accounts.squads_program.to_account_info(), + squads_program.to_account_info(), squads_multisig_program::cpi::accounts::VaultTransactionCreate { - creator: ctx - .accounts - .squads_multisig_permissionless_account - .to_account_info(), - multisig: ctx.accounts.squads_multisig.to_account_info(), - rent_payer: ctx.accounts.proposer.to_account_info(), - system_program: ctx.accounts.system_program.to_account_info(), - transaction: ctx.accounts.squads_vault_transaction.to_account_info(), + creator: squads_multisig_permissionless_account.to_account_info(), + multisig: squads_multisig.to_account_info(), + rent_payer: proposer.to_account_info(), + system_program: system_program.to_account_info(), + transaction: squads_vault_transaction.to_account_info(), }, ), squads_multisig_program::VaultTransactionCreateArgs { @@ -157,31 +150,51 @@ impl InitiateVaultSpendOptimisticProposal<'_> { memo: None, }, )?; + + // Reload the squads multisig account to get the latest transaction index + squads_multisig.reload()?; + let transaction_index = squads_multisig.transaction_index; + // Create the squads proposal + squads_multisig_program::cpi::proposal_create( + CpiContext::new_with_signer( + squads_program.to_account_info(), + squads_multisig_program::cpi::accounts::ProposalCreate { + // DAO is the config authority - maybe this needs to be the permissionless account instead? + creator: dao.to_account_info(), + multisig: squads_multisig.to_account_info(), + rent_payer: proposer.to_account_info(), + system_program: system_program.to_account_info(), + proposal: squads_proposal.to_account_info(), + }, + dao_signer, + ), + squads_multisig_program::ProposalCreateArgs { + transaction_index, + draft: false, + }, + )?; // Update the DAO state let clock = Clock::get()?; - ctx.accounts.dao.active_optimistic_squads_proposal = Some(squads_proposal.key()); - ctx.accounts - .dao - .active_optimistic_squads_proposal_enqueued_timestamp = Some(clock.unix_timestamp); - - // emit_cpi!(InitializeProposalEvent { - // common: CommonFields::new(&clock, dao.seq_num), - // proposal: proposal.key(), - // dao: dao.key(), - // question: question.key(), - // base_vault: base_vault.key(), - // quote_vault: quote_vault.key(), - // proposer: proposer.key(), - // number: dao.proposal_count, - // pda_bump: ctx.bumps.proposal, - // duration_in_seconds: proposal.duration_in_seconds, - // squads_proposal: squads_proposal.key(), - // squads_multisig: dao.squads_multisig, - // squads_multisig_vault: dao.squads_multisig_vault, - // }); + dao.active_optimistic_squads_proposal = Some(squads_proposal.key()); + dao.active_optimistic_squads_proposal_enqueued_timestamp = Some(clock.unix_timestamp); + dao.seq_num += 1; + + emit_cpi!(InitiateVaultSpendOptimisticProposalEvent { + common: CommonFields::new(&clock, dao.seq_num), + dao: dao.key(), + proposer: proposer.key(), + squads_proposal: squads_proposal.key(), + squads_multisig: squads_multisig.key(), + squads_multisig_vault: squads_multisig_vault.key(), + amount: params.amount, + recipient: ctx.accounts.recipient.key(), + dao_quote_vault_account: dao_quote_vault_account.key(), + recipient_quote_account: recipient_quote_account.key(), + enqueued_timestamp: clock.unix_timestamp, + }); Ok(()) } diff --git a/sdk/src/v0.7/types/futarchy.ts b/sdk/src/v0.7/types/futarchy.ts index adde546c..5c18c7ea 100644 --- a/sdk/src/v0.7/types/futarchy.ts +++ b/sdk/src/v0.7/types/futarchy.ts @@ -1170,6 +1170,104 @@ export type Futarchy = { ]; args: []; }, + { + name: "initiateVaultSpendOptimisticProposal"; + accounts: [ + { + name: "squadsMultisig"; + isMut: true; + isSigner: false; + }, + { + name: "squadsMultisigVault"; + isMut: false; + isSigner: false; + }, + { + name: "squadsSpendingLimit"; + isMut: true; + isSigner: false; + }, + { + name: "squadsProposal"; + isMut: true; + isSigner: false; + }, + { + name: "squadsVaultTransaction"; + isMut: true; + isSigner: false; + }, + { + name: "dao"; + isMut: true; + isSigner: false; + }, + { + name: "proposer"; + isMut: true; + isSigner: true; + }, + { + name: "squadsMultisigPermissionlessAccount"; + isMut: false; + isSigner: true; + }, + { + name: "recipient"; + isMut: false; + isSigner: false; + }, + { + name: "recipientQuoteAccount"; + isMut: true; + isSigner: false; + }, + { + name: "daoQuoteVaultAccount"; + isMut: true; + isSigner: false; + }, + { + name: "payer"; + isMut: true; + isSigner: true; + }, + { + name: "systemProgram"; + isMut: false; + isSigner: false; + }, + { + name: "squadsProgram"; + isMut: false; + isSigner: false; + }, + { + name: "tokenProgram"; + isMut: false; + isSigner: false; + }, + { + name: "eventAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "program"; + isMut: false; + isSigner: false; + }, + ]; + args: [ + { + name: "params"; + type: { + defined: "InitiateVaultSpendOptimisticProposalParams"; + }; + }, + ]; + }, ]; accounts: [ { @@ -1559,6 +1657,18 @@ export type Futarchy = { ]; }; }, + { + name: "InitiateVaultSpendOptimisticProposalParams"; + type: { + kind: "struct"; + fields: [ + { + name: "amount"; + type: "u64"; + }, + ]; + }; + }, { name: "ProvideLiquidityParams"; type: { @@ -2776,6 +2886,68 @@ export type Futarchy = { }, ]; }, + { + name: "InitiateVaultSpendOptimisticProposalEvent"; + fields: [ + { + name: "common"; + type: { + defined: "CommonFields"; + }; + index: false; + }, + { + name: "dao"; + type: "publicKey"; + index: false; + }, + { + name: "proposer"; + type: "publicKey"; + index: false; + }, + { + name: "squadsProposal"; + type: "publicKey"; + index: false; + }, + { + name: "squadsMultisig"; + type: "publicKey"; + index: false; + }, + { + name: "squadsMultisigVault"; + type: "publicKey"; + index: false; + }, + { + name: "amount"; + type: "u64"; + index: false; + }, + { + name: "recipient"; + type: "publicKey"; + index: false; + }, + { + name: "daoQuoteVaultAccount"; + type: "publicKey"; + index: false; + }, + { + name: "recipientQuoteAccount"; + type: "publicKey"; + index: false; + }, + { + name: "enqueuedTimestamp"; + type: "i64"; + index: false; + }, + ]; + }, ]; errors: [ { @@ -2958,6 +3130,16 @@ export type Futarchy = { name: "InvalidTransactionMessage"; msg: "Failed to compile transaction message for Squads vault transaction"; }, + { + code: 6036; + name: "InvalidRecipient"; + msg: "Invalid recipient"; + }, + { + code: 6037; + name: "OptimisticGovernanceDisabled"; + msg: "Optimistic governance is disabled"; + }, ]; }; @@ -4133,6 +4315,104 @@ export const IDL: Futarchy = { ], args: [], }, + { + name: "initiateVaultSpendOptimisticProposal", + accounts: [ + { + name: "squadsMultisig", + isMut: true, + isSigner: false, + }, + { + name: "squadsMultisigVault", + isMut: false, + isSigner: false, + }, + { + name: "squadsSpendingLimit", + isMut: true, + isSigner: false, + }, + { + name: "squadsProposal", + isMut: true, + isSigner: false, + }, + { + name: "squadsVaultTransaction", + isMut: true, + isSigner: false, + }, + { + name: "dao", + isMut: true, + isSigner: false, + }, + { + name: "proposer", + isMut: true, + isSigner: true, + }, + { + name: "squadsMultisigPermissionlessAccount", + isMut: false, + isSigner: true, + }, + { + name: "recipient", + isMut: false, + isSigner: false, + }, + { + name: "recipientQuoteAccount", + isMut: true, + isSigner: false, + }, + { + name: "daoQuoteVaultAccount", + isMut: true, + isSigner: false, + }, + { + name: "payer", + isMut: true, + isSigner: true, + }, + { + name: "systemProgram", + isMut: false, + isSigner: false, + }, + { + name: "squadsProgram", + isMut: false, + isSigner: false, + }, + { + name: "tokenProgram", + isMut: false, + isSigner: false, + }, + { + name: "eventAuthority", + isMut: false, + isSigner: false, + }, + { + name: "program", + isMut: false, + isSigner: false, + }, + ], + args: [ + { + name: "params", + type: { + defined: "InitiateVaultSpendOptimisticProposalParams", + }, + }, + ], + }, ], accounts: [ { @@ -4522,6 +4802,18 @@ export const IDL: Futarchy = { ], }, }, + { + name: "InitiateVaultSpendOptimisticProposalParams", + type: { + kind: "struct", + fields: [ + { + name: "amount", + type: "u64", + }, + ], + }, + }, { name: "ProvideLiquidityParams", type: { @@ -5739,6 +6031,68 @@ export const IDL: Futarchy = { }, ], }, + { + name: "InitiateVaultSpendOptimisticProposalEvent", + fields: [ + { + name: "common", + type: { + defined: "CommonFields", + }, + index: false, + }, + { + name: "dao", + type: "publicKey", + index: false, + }, + { + name: "proposer", + type: "publicKey", + index: false, + }, + { + name: "squadsProposal", + type: "publicKey", + index: false, + }, + { + name: "squadsMultisig", + type: "publicKey", + index: false, + }, + { + name: "squadsMultisigVault", + type: "publicKey", + index: false, + }, + { + name: "amount", + type: "u64", + index: false, + }, + { + name: "recipient", + type: "publicKey", + index: false, + }, + { + name: "daoQuoteVaultAccount", + type: "publicKey", + index: false, + }, + { + name: "recipientQuoteAccount", + type: "publicKey", + index: false, + }, + { + name: "enqueuedTimestamp", + type: "i64", + index: false, + }, + ], + }, ], errors: [ { @@ -5921,5 +6275,15 @@ export const IDL: Futarchy = { name: "InvalidTransactionMessage", msg: "Failed to compile transaction message for Squads vault transaction", }, + { + code: 6036, + name: "InvalidRecipient", + msg: "Invalid recipient", + }, + { + code: 6037, + name: "OptimisticGovernanceDisabled", + msg: "Optimistic governance is disabled", + }, ], }; From 2e8545599eb24a2b6e1796f5a0e05a8214cbe702 Mon Sep 17 00:00:00 2001 From: Pileks Date: Fri, 9 Jan 2026 20:22:57 +0100 Subject: [PATCH 04/16] add finalize optimistic proposal ix --- programs/futarchy/src/error.rs | 4 + programs/futarchy/src/events.rs | 7 ++ .../instructions/collect_meteora_damm_fees.rs | 2 +- .../finalize_optimistic_proposal.rs | 89 +++++++++++++++++++ .../src/instructions/initialize_dao.rs | 3 +- ...nitiate_vault_spend_optimistic_proposal.rs | 28 +++--- programs/futarchy/src/instructions/mod.rs | 2 + .../futarchy/src/instructions/update_dao.rs | 4 +- programs/futarchy/src/lib.rs | 5 ++ programs/futarchy/src/squads.rs | 2 +- programs/futarchy/src/state/dao.rs | 12 ++- sdk/src/v0.7/types/futarchy.ts | 10 +++ 12 files changed, 146 insertions(+), 22 deletions(-) create mode 100644 programs/futarchy/src/instructions/finalize_optimistic_proposal.rs diff --git a/programs/futarchy/src/error.rs b/programs/futarchy/src/error.rs index 3a6f26b2..ffb92cee 100644 --- a/programs/futarchy/src/error.rs +++ b/programs/futarchy/src/error.rs @@ -80,4 +80,8 @@ pub enum FutarchyError { InvalidRecipient, #[msg("Optimistic governance is disabled")] OptimisticGovernanceDisabled, + #[msg("An active optimistic proposal is already enqueued")] + ActiveOptimisticProposalAlreadyEnqueued, + #[msg("No active optimistic proposal")] + NoActiveOptimisticProposal, } diff --git a/programs/futarchy/src/events.rs b/programs/futarchy/src/events.rs index 61f6d409..7bddfb42 100644 --- a/programs/futarchy/src/events.rs +++ b/programs/futarchy/src/events.rs @@ -219,3 +219,10 @@ pub struct InitiateVaultSpendOptimisticProposalEvent { pub recipient_quote_account: Pubkey, pub enqueued_timestamp: i64, } + +#[event] +pub struct FinalizeOptimisticProposalEvent { + pub common: CommonFields, + pub dao: Pubkey, + pub squads_proposal: Pubkey, +} diff --git a/programs/futarchy/src/instructions/collect_meteora_damm_fees.rs b/programs/futarchy/src/instructions/collect_meteora_damm_fees.rs index d4125ef0..c15846bc 100644 --- a/programs/futarchy/src/instructions/collect_meteora_damm_fees.rs +++ b/programs/futarchy/src/instructions/collect_meteora_damm_fees.rs @@ -230,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()?; diff --git a/programs/futarchy/src/instructions/finalize_optimistic_proposal.rs b/programs/futarchy/src/instructions/finalize_optimistic_proposal.rs new file mode 100644 index 00000000..a057df87 --- /dev/null +++ b/programs/futarchy/src/instructions/finalize_optimistic_proposal.rs @@ -0,0 +1,89 @@ +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(mut)] + pub dao: Box>, + + 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); + + // There should be an active optimistic proposal + let optimistic_proposal = match self.dao.optimistic_proposal { + Some(ref optimistic_proposal) => optimistic_proposal, + None => { + return Err(FutarchyError::NoActiveOptimisticProposal.into()); + } + }; + + // A minimum of proposal duration must have passed since the the optimistic proposal was enqueued + require_gte!( + Clock::get()?.unix_timestamp, + optimistic_proposal.enqueued_timestamp + self.dao.seconds_per_proposal as i64, + FutarchyError::ProposalDurationTooShort + ); + + // Pool must be in spot state - no active proposals + // Realistically, this should never be hit, but it's here for completeness + match self.dao.amm.state { + PoolState::Spot { spot: _ } => {} + _ => { + return Err(FutarchyError::PoolNotInSpotState.into()); + } + } + + Ok(()) + } + + pub fn handle(ctx: Context) -> 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(()) + } +} diff --git a/programs/futarchy/src/instructions/initialize_dao.rs b/programs/futarchy/src/instructions/initialize_dao.rs index 4e8afc59..2ec22bb4 100644 --- a/programs/futarchy/src/instructions/initialize_dao.rs +++ b/programs/futarchy/src/instructions/initialize_dao.rs @@ -212,8 +212,7 @@ impl InitializeDao<'_> { }, team_sponsored_pass_threshold_bps, team_address, - active_optimistic_squads_proposal: None, - active_optimistic_squads_proposal_enqueued_timestamp: None, + optimistic_proposal: None, is_optimistic_governance_enabled: false, }); diff --git a/programs/futarchy/src/instructions/initiate_vault_spend_optimistic_proposal.rs b/programs/futarchy/src/instructions/initiate_vault_spend_optimistic_proposal.rs index 5c05efb3..f591a749 100644 --- a/programs/futarchy/src/instructions/initiate_vault_spend_optimistic_proposal.rs +++ b/programs/futarchy/src/instructions/initiate_vault_spend_optimistic_proposal.rs @@ -62,15 +62,18 @@ impl InitiateVaultSpendOptimisticProposal<'_> { } } + // There should be no active optimistic proposal + require!( + self.dao.optimistic_proposal.is_none(), + FutarchyError::ActiveOptimisticProposalAlreadyEnqueued + ); + // A minimum of proposal duration must have passed since the last optimistic proposal was enqueued - match self - .dao - .active_optimistic_squads_proposal_enqueued_timestamp - { - Some(enqueued_timestamp) => { + match self.dao.optimistic_proposal { + Some(ref optimistic_proposal) => { require_gte!( Clock::get()?.unix_timestamp, - enqueued_timestamp + self.dao.seconds_per_proposal as i64, + optimistic_proposal.enqueued_timestamp + self.dao.seconds_per_proposal as i64, FutarchyError::ProposalDurationTooShort ); } @@ -111,6 +114,7 @@ impl InitiateVaultSpendOptimisticProposal<'_> { dao_quote_vault_account, } = ctx.accounts; + // Prepare the transfer instruction let ix = anchor_spl::token::spl_token::instruction::transfer( &token_program.key(), &dao_quote_vault_account.key(), @@ -121,7 +125,8 @@ impl InitiateVaultSpendOptimisticProposal<'_> { )?; // Compile the transaction message in Squads' format - let transaction_message = compile_transaction_message(&squads_multisig_vault.key(), &[ix])?; + let transaction_message = + compile_squads_transaction_message(&squads_multisig_vault.key(), &[ix])?; let transaction_message_bytes = transaction_message.try_to_vec()?; @@ -160,8 +165,7 @@ impl InitiateVaultSpendOptimisticProposal<'_> { CpiContext::new_with_signer( squads_program.to_account_info(), squads_multisig_program::cpi::accounts::ProposalCreate { - // DAO is the config authority - maybe this needs to be the permissionless account instead? - creator: dao.to_account_info(), + creator: squads_multisig_permissionless_account.to_account_info(), multisig: squads_multisig.to_account_info(), rent_payer: proposer.to_account_info(), system_program: system_program.to_account_info(), @@ -178,8 +182,10 @@ impl InitiateVaultSpendOptimisticProposal<'_> { // Update the DAO state let clock = Clock::get()?; - dao.active_optimistic_squads_proposal = Some(squads_proposal.key()); - dao.active_optimistic_squads_proposal_enqueued_timestamp = Some(clock.unix_timestamp); + dao.optimistic_proposal = Some(OptimisticProposal { + squads_proposal: squads_proposal.key(), + enqueued_timestamp: clock.unix_timestamp, + }); dao.seq_num += 1; emit_cpi!(InitiateVaultSpendOptimisticProposalEvent { diff --git a/programs/futarchy/src/instructions/mod.rs b/programs/futarchy/src/instructions/mod.rs index 4f088605..0737a521 100644 --- a/programs/futarchy/src/instructions/mod.rs +++ b/programs/futarchy/src/instructions/mod.rs @@ -4,6 +4,7 @@ pub mod collect_fees; pub mod collect_meteora_damm_fees; pub mod conditional_swap; pub mod execute_spending_limit_change; +pub mod finalize_optimistic_proposal; pub mod finalize_proposal; pub mod initialize_dao; pub mod initialize_proposal; @@ -21,6 +22,7 @@ pub use collect_fees::*; pub use collect_meteora_damm_fees::*; pub use conditional_swap::*; pub use execute_spending_limit_change::*; +pub use finalize_optimistic_proposal::*; pub use finalize_proposal::*; pub use initialize_dao::*; pub use initialize_proposal::*; diff --git a/programs/futarchy/src/instructions/update_dao.rs b/programs/futarchy/src/instructions/update_dao.rs index 2a2e5e66..5e3cca48 100644 --- a/programs/futarchy/src/instructions/update_dao.rs +++ b/programs/futarchy/src/instructions/update_dao.rs @@ -65,9 +65,7 @@ impl UpdateDao<'_> { .team_sponsored_pass_threshold_bps .unwrap_or(dao.team_sponsored_pass_threshold_bps), team_address: dao_params.team_address.unwrap_or(dao.team_address), - active_optimistic_squads_proposal: dao.active_optimistic_squads_proposal, - active_optimistic_squads_proposal_enqueued_timestamp: dao - .active_optimistic_squads_proposal_enqueued_timestamp, + optimistic_proposal: dao.optimistic_proposal.clone(), is_optimistic_governance_enabled: dao_params .is_optimistic_governance_enabled .unwrap_or(dao.is_optimistic_governance_enabled), diff --git a/programs/futarchy/src/lib.rs b/programs/futarchy/src/lib.rs index 37494c8f..89d2ab90 100644 --- a/programs/futarchy/src/lib.rs +++ b/programs/futarchy/src/lib.rs @@ -157,4 +157,9 @@ pub mod futarchy { ) -> Result<()> { InitiateVaultSpendOptimisticProposal::handle(ctx, params) } + + #[access_control(ctx.accounts.validate())] + pub fn finalize_optimistic_proposal(ctx: Context) -> Result<()> { + FinalizeOptimisticProposal::handle(ctx) + } } diff --git a/programs/futarchy/src/squads.rs b/programs/futarchy/src/squads.rs index ef85474c..9a56b9a2 100644 --- a/programs/futarchy/src/squads.rs +++ b/programs/futarchy/src/squads.rs @@ -8,7 +8,7 @@ use crate::FutarchyError; /// 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). -pub fn compile_transaction_message( +pub fn compile_squads_transaction_message( vault_key: &Pubkey, instructions: &[anchor_lang::solana_program::instruction::Instruction], ) -> Result { diff --git a/programs/futarchy/src/state/dao.rs b/programs/futarchy/src/state/dao.rs index 61248646..dfed8ac6 100644 --- a/programs/futarchy/src/state/dao.rs +++ b/programs/futarchy/src/state/dao.rs @@ -56,12 +56,16 @@ pub struct Dao { /// Can be negative to allow for team-sponsored proposals to pass by default. pub team_sponsored_pass_threshold_bps: i16, pub team_address: Pubkey, + pub optimistic_proposal: Option, + pub is_optimistic_governance_enabled: bool, +} + +#[derive(AnchorSerialize, AnchorDeserialize, Debug, Clone, PartialEq, Eq, InitSpace)] +pub struct OptimisticProposal { /// The squads proposal currently enqueued for execution if not challenged by a new proposal. - pub active_optimistic_squads_proposal: Option, + pub squads_proposal: Pubkey, /// The timestamp when the active optimistic squads proposal was enqueued. - pub active_optimistic_squads_proposal_enqueued_timestamp: Option, - /// Whether optimistic governance is enabled for this DAO. - pub is_optimistic_governance_enabled: bool, + pub enqueued_timestamp: i64, } #[derive(AnchorSerialize, AnchorDeserialize, Debug, Clone, PartialEq, Eq, InitSpace)] diff --git a/sdk/src/v0.7/types/futarchy.ts b/sdk/src/v0.7/types/futarchy.ts index 5c18c7ea..10c59cee 100644 --- a/sdk/src/v0.7/types/futarchy.ts +++ b/sdk/src/v0.7/types/futarchy.ts @@ -3140,6 +3140,11 @@ export type Futarchy = { name: "OptimisticGovernanceDisabled"; msg: "Optimistic governance is disabled"; }, + { + code: 6038; + name: "ActiveOptimisticProposalAlreadyEnqueued"; + msg: "An active optimistic proposal is already enqueued"; + }, ]; }; @@ -6285,5 +6290,10 @@ export const IDL: Futarchy = { name: "OptimisticGovernanceDisabled", msg: "Optimistic governance is disabled", }, + { + code: 6038, + name: "ActiveOptimisticProposalAlreadyEnqueued", + msg: "An active optimistic proposal is already enqueued", + }, ], }; From 7f458eae0355a94d8e0b26e1e0632fdddd41aa76 Mon Sep 17 00:00:00 2001 From: Pileks Date: Fri, 9 Jan 2026 20:47:39 +0100 Subject: [PATCH 05/16] launch_proposal should only be able to challenge an optimistic proposal if one exists --- .../src/instructions/launch_proposal.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/programs/futarchy/src/instructions/launch_proposal.rs b/programs/futarchy/src/instructions/launch_proposal.rs index 3d4d793d..68f290f4 100644 --- a/programs/futarchy/src/instructions/launch_proposal.rs +++ b/programs/futarchy/src/instructions/launch_proposal.rs @@ -51,6 +51,16 @@ impl LaunchProposal<'_> { ); } + // If there is an active optimistic proposal, it must be for the same squads proposal + // as the futarchy proposal we're launching, thus challenging the optimistic proposal. + // This follows the logic that a DAO can have only one proposal active at a time. + if let Some(optimistic_proposal) = &self.dao.optimistic_proposal { + require_keys_eq!( + optimistic_proposal.squads_proposal, + self.proposal.squads_proposal + ); + } + Ok(()) } @@ -139,6 +149,14 @@ impl LaunchProposal<'_> { proposal.state = ProposalState::Pending; proposal.timestamp_enqueued = clock.unix_timestamp; + // Update the DAO state + if dao.optimistic_proposal.is_some() { + // The optimistic proposal is being challenged, so we are moving it into the futarchy proposal + // This means that the optimistic proposal now has to pass a decision market in order to be approved/executed + // verify() ensures that the optimistic proposal and squads proposal are the same + dao.optimistic_proposal = None; + } + dao.seq_num += 1; emit_cpi!(LaunchProposalEvent { From 63ed3338dc1d66715edaa55c3d811861ffe1e02d Mon Sep 17 00:00:00 2001 From: Pileks Date: Fri, 9 Jan 2026 22:25:48 +0100 Subject: [PATCH 06/16] minor cleanups --- .../finalize_optimistic_proposal.rs | 2 +- ...nitiate_vault_spend_optimistic_proposal.rs | 37 +-- sdk/src/v0.7/types/futarchy.ts | 248 ++++++++++++++---- 3 files changed, 220 insertions(+), 67 deletions(-) diff --git a/programs/futarchy/src/instructions/finalize_optimistic_proposal.rs b/programs/futarchy/src/instructions/finalize_optimistic_proposal.rs index a057df87..fac40a2b 100644 --- a/programs/futarchy/src/instructions/finalize_optimistic_proposal.rs +++ b/programs/futarchy/src/instructions/finalize_optimistic_proposal.rs @@ -8,7 +8,7 @@ pub struct FinalizeOptimisticProposal<'info> { #[account(mut, address = dao.optimistic_proposal.as_ref().unwrap().squads_proposal)] pub squads_proposal: Box>, - #[account(mut)] + #[account(mut, has_one = squads_multisig)] pub dao: Box>, pub squads_program: Program<'info, squads_multisig_program::program::SquadsMultisigProgram>, diff --git a/programs/futarchy/src/instructions/initiate_vault_spend_optimistic_proposal.rs b/programs/futarchy/src/instructions/initiate_vault_spend_optimistic_proposal.rs index f591a749..e463d3ee 100644 --- a/programs/futarchy/src/instructions/initiate_vault_spend_optimistic_proposal.rs +++ b/programs/futarchy/src/instructions/initiate_vault_spend_optimistic_proposal.rs @@ -10,35 +10,42 @@ pub struct InitiateVaultSpendOptimisticProposalParams { pub struct InitiateVaultSpendOptimisticProposal<'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>, + /// CHECK: The squads multisig vault that executes the transaction #[account(seeds = [squads_multisig_program::SEED_PREFIX, squads_multisig.key().as_ref(), squads_multisig_program::SEED_VAULT, 0_u8.to_le_bytes().as_ref()], bump, seeds::program = squads_program)] pub squads_multisig_vault: UncheckedAccount<'info>, - #[account(mut, seeds = [squads_multisig_program::SEED_PREFIX, squads_multisig.key().as_ref(), squads_multisig_program::SEED_SPENDING_LIMIT, dao.key().as_ref()], bump, seeds::program = squads_program)] + + #[account(seeds = [squads_multisig_program::SEED_PREFIX, squads_multisig.key().as_ref(), squads_multisig_program::SEED_SPENDING_LIMIT, dao.key().as_ref()], bump, seeds::program = squads_program)] pub squads_spending_limit: Account<'info, squads_multisig_program::SpendingLimit>, - // Probably need to use unchecked account, as these are not yet initialized - #[account(mut)] - pub squads_proposal: Box>, + + /// CHECK: Squads multisig proposal, initialized by squads multisig program, checked by squads multisig program #[account(mut)] - pub squads_vault_transaction: Box>, + pub squads_proposal: UncheckedAccount<'info>, + /// CHECK: Squads multisig vault transaction, initialized by squads multisig program, checked by squads multisig program #[account(mut)] - pub dao: Box>, - #[account(mut, address = dao.team_address)] - pub proposer: Signer<'info>, + pub squads_vault_transaction: UncheckedAccount<'info>, #[account(address = permissionless_account::id())] pub squads_multisig_permissionless_account: Signer<'info>, + #[account(mut, has_one = squads_multisig, has_one = squads_multisig_vault)] + pub dao: Box>, + #[account(mut, associated_token::mint = dao.quote_mint, associated_token::authority = dao.squads_multisig_vault)] + pub dao_quote_vault_account: Account<'info, TokenAccount>, + + // Only the team can initiate an optimistic proposal + #[account(mut, address = dao.team_address)] + pub proposer: Signer<'info>, + /// CHECK: Used for constraints pub recipient: UncheckedAccount<'info>, #[account(mut, associated_token::mint = dao.quote_mint, associated_token::authority = recipient)] pub recipient_quote_account: Account<'info, TokenAccount>, - #[account(mut, associated_token::mint = dao.quote_mint, associated_token::authority = dao.squads_multisig_vault)] - pub dao_quote_vault_account: Account<'info, TokenAccount>, - #[account(mut)] pub payer: Signer<'info>, + pub system_program: Program<'info, System>, pub squads_program: Program<'info, squads_multisig_program::program::SquadsMultisigProgram>, pub token_program: Program<'info, Token>, @@ -46,15 +53,13 @@ pub struct InitiateVaultSpendOptimisticProposal<'info> { impl InitiateVaultSpendOptimisticProposal<'_> { pub fn validate(&self, params: &InitiateVaultSpendOptimisticProposalParams) -> Result<()> { - require_keys_eq!(self.squads_proposal.multisig, self.dao.squads_multisig); - // Optimistic governance must be enabled require!( self.dao.is_optimistic_governance_enabled, FutarchyError::OptimisticGovernanceDisabled ); - // Pool must be in spot state - no active proposals + // Pool must be in spot state - no active proposal match self.dao.amm.state { PoolState::Spot { spot: _ } => {} _ => { @@ -115,7 +120,7 @@ impl InitiateVaultSpendOptimisticProposal<'_> { } = ctx.accounts; // Prepare the transfer instruction - let ix = anchor_spl::token::spl_token::instruction::transfer( + let transfer_ix = anchor_spl::token::spl_token::instruction::transfer( &token_program.key(), &dao_quote_vault_account.key(), &recipient_quote_account.key(), @@ -126,7 +131,7 @@ impl InitiateVaultSpendOptimisticProposal<'_> { // Compile the transaction message in Squads' format let transaction_message = - compile_squads_transaction_message(&squads_multisig_vault.key(), &[ix])?; + compile_squads_transaction_message(&squads_multisig_vault.key(), &[transfer_ix])?; let transaction_message_bytes = transaction_message.try_to_vec()?; diff --git a/sdk/src/v0.7/types/futarchy.ts b/sdk/src/v0.7/types/futarchy.ts index 10c59cee..d9d95cdc 100644 --- a/sdk/src/v0.7/types/futarchy.ts +++ b/sdk/src/v0.7/types/futarchy.ts @@ -1185,7 +1185,7 @@ export type Futarchy = { }, { name: "squadsSpendingLimit"; - isMut: true; + isMut: false; isSigner: false; }, { @@ -1198,19 +1198,24 @@ export type Futarchy = { isMut: true; isSigner: false; }, + { + name: "squadsMultisigPermissionlessAccount"; + isMut: false; + isSigner: true; + }, { name: "dao"; isMut: true; isSigner: false; }, { - name: "proposer"; + name: "daoQuoteVaultAccount"; isMut: true; - isSigner: true; + isSigner: false; }, { - name: "squadsMultisigPermissionlessAccount"; - isMut: false; + name: "proposer"; + isMut: true; isSigner: true; }, { @@ -1223,11 +1228,6 @@ export type Futarchy = { isMut: true; isSigner: false; }, - { - name: "daoQuoteVaultAccount"; - isMut: true; - isSigner: false; - }, { name: "payer"; isMut: true; @@ -1268,6 +1268,42 @@ export type Futarchy = { }, ]; }, + { + name: "finalizeOptimisticProposal"; + accounts: [ + { + name: "squadsMultisig"; + isMut: true; + isSigner: false; + }, + { + name: "squadsProposal"; + isMut: true; + isSigner: false; + }, + { + name: "dao"; + isMut: true; + isSigner: false; + }, + { + name: "squadsProgram"; + isMut: false; + isSigner: false; + }, + { + name: "eventAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "program"; + isMut: false; + isSigner: false; + }, + ]; + args: []; + }, ]; accounts: [ { @@ -1424,26 +1460,15 @@ export type Futarchy = { type: "publicKey"; }, { - name: "activeOptimisticSquadsProposal"; - docs: [ - "The squads proposal currently enqueued for execution if not challenged by a new proposal.", - ]; - type: { - option: "publicKey"; - }; - }, - { - name: "activeOptimisticSquadsProposalEnqueuedTimestamp"; - docs: [ - "The timestamp when the active optimistic squads proposal was enqueued.", - ]; + name: "optimisticProposal"; type: { - option: "i64"; + option: { + defined: "OptimisticProposal"; + }; }; }, { name: "isOptimisticGovernanceEnabled"; - docs: ["Whether optimistic governance is enabled for this DAO."]; type: "bool"; }, ]; @@ -1843,6 +1868,28 @@ export type Futarchy = { ]; }; }, + { + name: "OptimisticProposal"; + type: { + kind: "struct"; + fields: [ + { + name: "squadsProposal"; + docs: [ + "The squads proposal currently enqueued for execution if not challenged by a new proposal.", + ]; + type: "publicKey"; + }, + { + name: "enqueuedTimestamp"; + docs: [ + "The timestamp when the active optimistic squads proposal was enqueued.", + ]; + type: "i64"; + }, + ]; + }; + }, { name: "InitialSpendingLimit"; type: { @@ -2948,6 +2995,28 @@ export type Futarchy = { }, ]; }, + { + name: "FinalizeOptimisticProposalEvent"; + fields: [ + { + name: "common"; + type: { + defined: "CommonFields"; + }; + index: false; + }, + { + name: "dao"; + type: "publicKey"; + index: false; + }, + { + name: "squadsProposal"; + type: "publicKey"; + index: false; + }, + ]; + }, ]; errors: [ { @@ -3145,6 +3214,11 @@ export type Futarchy = { name: "ActiveOptimisticProposalAlreadyEnqueued"; msg: "An active optimistic proposal is already enqueued"; }, + { + code: 6039; + name: "NoActiveOptimisticProposal"; + msg: "No active optimistic proposal"; + }, ]; }; @@ -4335,7 +4409,7 @@ export const IDL: Futarchy = { }, { name: "squadsSpendingLimit", - isMut: true, + isMut: false, isSigner: false, }, { @@ -4348,19 +4422,24 @@ export const IDL: Futarchy = { isMut: true, isSigner: false, }, + { + name: "squadsMultisigPermissionlessAccount", + isMut: false, + isSigner: true, + }, { name: "dao", isMut: true, isSigner: false, }, { - name: "proposer", + name: "daoQuoteVaultAccount", isMut: true, - isSigner: true, + isSigner: false, }, { - name: "squadsMultisigPermissionlessAccount", - isMut: false, + name: "proposer", + isMut: true, isSigner: true, }, { @@ -4373,11 +4452,6 @@ export const IDL: Futarchy = { isMut: true, isSigner: false, }, - { - name: "daoQuoteVaultAccount", - isMut: true, - isSigner: false, - }, { name: "payer", isMut: true, @@ -4418,6 +4492,42 @@ export const IDL: Futarchy = { }, ], }, + { + name: "finalizeOptimisticProposal", + accounts: [ + { + name: "squadsMultisig", + isMut: true, + isSigner: false, + }, + { + name: "squadsProposal", + isMut: true, + isSigner: false, + }, + { + name: "dao", + isMut: true, + isSigner: false, + }, + { + name: "squadsProgram", + isMut: false, + isSigner: false, + }, + { + name: "eventAuthority", + isMut: false, + isSigner: false, + }, + { + name: "program", + isMut: false, + isSigner: false, + }, + ], + args: [], + }, ], accounts: [ { @@ -4574,26 +4684,15 @@ export const IDL: Futarchy = { type: "publicKey", }, { - name: "activeOptimisticSquadsProposal", - docs: [ - "The squads proposal currently enqueued for execution if not challenged by a new proposal.", - ], - type: { - option: "publicKey", - }, - }, - { - name: "activeOptimisticSquadsProposalEnqueuedTimestamp", - docs: [ - "The timestamp when the active optimistic squads proposal was enqueued.", - ], + name: "optimisticProposal", type: { - option: "i64", + option: { + defined: "OptimisticProposal", + }, }, }, { name: "isOptimisticGovernanceEnabled", - docs: ["Whether optimistic governance is enabled for this DAO."], type: "bool", }, ], @@ -4993,6 +5092,28 @@ export const IDL: Futarchy = { ], }, }, + { + name: "OptimisticProposal", + type: { + kind: "struct", + fields: [ + { + name: "squadsProposal", + docs: [ + "The squads proposal currently enqueued for execution if not challenged by a new proposal.", + ], + type: "publicKey", + }, + { + name: "enqueuedTimestamp", + docs: [ + "The timestamp when the active optimistic squads proposal was enqueued.", + ], + type: "i64", + }, + ], + }, + }, { name: "InitialSpendingLimit", type: { @@ -6098,6 +6219,28 @@ export const IDL: Futarchy = { }, ], }, + { + name: "FinalizeOptimisticProposalEvent", + fields: [ + { + name: "common", + type: { + defined: "CommonFields", + }, + index: false, + }, + { + name: "dao", + type: "publicKey", + index: false, + }, + { + name: "squadsProposal", + type: "publicKey", + index: false, + }, + ], + }, ], errors: [ { @@ -6295,5 +6438,10 @@ export const IDL: Futarchy = { name: "ActiveOptimisticProposalAlreadyEnqueued", msg: "An active optimistic proposal is already enqueued", }, + { + code: 6039, + name: "NoActiveOptimisticProposal", + msg: "No active optimistic proposal", + }, ], }; From 8606360e1b2bce4a40fee4e76f12909ff4b06257 Mon Sep 17 00:00:00 2001 From: Pileks Date: Sat, 10 Jan 2026 01:52:47 +0100 Subject: [PATCH 07/16] tests for optimistic vault transaction proposal initiation --- sdk/src/v0.6/types/futarchy.ts | 780 +++++++++++++++--- sdk/src/v0.7/FutarchyClient.ts | 89 ++ tests/futarchy/main.test.ts | 7 + tests/futarchy/unit/initializeDao.test.ts | 17 + ...itiateVaultSpendOptimisticProposal.test.ts | 276 +++++++ tests/integration/fullLaunch.test.ts | 1 + tests/integration/fullLaunch_v7.test.ts | 1 + 7 files changed, 1076 insertions(+), 95 deletions(-) create mode 100644 tests/futarchy/unit/initiateVaultSpendOptimisticProposal.test.ts diff --git a/sdk/src/v0.6/types/futarchy.ts b/sdk/src/v0.6/types/futarchy.ts index 19b1d17f..d9d95cdc 100644 --- a/sdk/src/v0.6/types/futarchy.ts +++ b/sdk/src/v0.6/types/futarchy.ts @@ -1170,6 +1170,140 @@ export type Futarchy = { ]; args: []; }, + { + name: "initiateVaultSpendOptimisticProposal"; + accounts: [ + { + name: "squadsMultisig"; + isMut: true; + isSigner: false; + }, + { + name: "squadsMultisigVault"; + isMut: false; + isSigner: false; + }, + { + name: "squadsSpendingLimit"; + isMut: false; + isSigner: false; + }, + { + name: "squadsProposal"; + isMut: true; + isSigner: false; + }, + { + name: "squadsVaultTransaction"; + isMut: true; + isSigner: false; + }, + { + name: "squadsMultisigPermissionlessAccount"; + isMut: false; + isSigner: true; + }, + { + name: "dao"; + isMut: true; + isSigner: false; + }, + { + name: "daoQuoteVaultAccount"; + isMut: true; + isSigner: false; + }, + { + name: "proposer"; + isMut: true; + isSigner: true; + }, + { + name: "recipient"; + isMut: false; + isSigner: false; + }, + { + name: "recipientQuoteAccount"; + isMut: true; + isSigner: false; + }, + { + name: "payer"; + isMut: true; + isSigner: true; + }, + { + name: "systemProgram"; + isMut: false; + isSigner: false; + }, + { + name: "squadsProgram"; + isMut: false; + isSigner: false; + }, + { + name: "tokenProgram"; + isMut: false; + isSigner: false; + }, + { + name: "eventAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "program"; + isMut: false; + isSigner: false; + }, + ]; + args: [ + { + name: "params"; + type: { + defined: "InitiateVaultSpendOptimisticProposalParams"; + }; + }, + ]; + }, + { + name: "finalizeOptimisticProposal"; + accounts: [ + { + name: "squadsMultisig"; + isMut: true; + isSigner: false; + }, + { + name: "squadsProposal"; + isMut: true; + isSigner: false; + }, + { + name: "dao"; + isMut: true; + isSigner: false; + }, + { + name: "squadsProgram"; + isMut: false; + isSigner: false; + }, + { + name: "eventAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "program"; + isMut: false; + isSigner: false; + }, + ]; + args: []; + }, ]; accounts: [ { @@ -1325,6 +1459,18 @@ export type Futarchy = { name: "teamAddress"; type: "publicKey"; }, + { + name: "optimisticProposal"; + type: { + option: { + defined: "OptimisticProposal"; + }; + }; + }, + { + name: "isOptimisticGovernanceEnabled"; + type: "bool"; + }, ]; }; }, @@ -1536,6 +1682,18 @@ export type Futarchy = { ]; }; }, + { + name: "InitiateVaultSpendOptimisticProposalParams"; + type: { + kind: "struct"; + fields: [ + { + name: "amount"; + type: "u64"; + }, + ]; + }; + }, { name: "ProvideLiquidityParams"; type: { @@ -1678,6 +1836,12 @@ export type Futarchy = { option: "publicKey"; }; }, + { + name: "isOptimisticGovernanceEnabled"; + type: { + option: "bool"; + }; + }, ]; }; }, @@ -1704,6 +1868,28 @@ export type Futarchy = { ]; }; }, + { + name: "OptimisticProposal"; + type: { + kind: "struct"; + fields: [ + { + name: "squadsProposal"; + docs: [ + "The squads proposal currently enqueued for execution if not challenged by a new proposal.", + ]; + type: "publicKey"; + }, + { + name: "enqueuedTimestamp"; + docs: [ + "The timestamp when the active optimistic squads proposal was enqueued.", + ]; + type: "i64"; + }, + ]; + }; + }, { name: "InitialSpendingLimit"; type: { @@ -2198,6 +2384,11 @@ export type Futarchy = { type: "publicKey"; index: false; }, + { + name: "isOptimisticGovernanceEnabled"; + type: "bool"; + index: false; + }, ]; }, { @@ -2742,6 +2933,90 @@ export type Futarchy = { }, ]; }, + { + name: "InitiateVaultSpendOptimisticProposalEvent"; + fields: [ + { + name: "common"; + type: { + defined: "CommonFields"; + }; + index: false; + }, + { + name: "dao"; + type: "publicKey"; + index: false; + }, + { + name: "proposer"; + type: "publicKey"; + index: false; + }, + { + name: "squadsProposal"; + type: "publicKey"; + index: false; + }, + { + name: "squadsMultisig"; + type: "publicKey"; + index: false; + }, + { + name: "squadsMultisigVault"; + type: "publicKey"; + index: false; + }, + { + name: "amount"; + type: "u64"; + index: false; + }, + { + name: "recipient"; + type: "publicKey"; + index: false; + }, + { + name: "daoQuoteVaultAccount"; + type: "publicKey"; + index: false; + }, + { + name: "recipientQuoteAccount"; + type: "publicKey"; + index: false; + }, + { + name: "enqueuedTimestamp"; + type: "i64"; + index: false; + }, + ]; + }, + { + name: "FinalizeOptimisticProposalEvent"; + fields: [ + { + name: "common"; + type: { + defined: "CommonFields"; + }; + index: false; + }, + { + name: "dao"; + type: "publicKey"; + index: false; + }, + { + name: "squadsProposal"; + type: "publicKey"; + index: false; + }, + ]; + }, ]; errors: [ { @@ -2924,6 +3199,26 @@ export type Futarchy = { name: "InvalidTransactionMessage"; msg: "Failed to compile transaction message for Squads vault transaction"; }, + { + code: 6036; + name: "InvalidRecipient"; + msg: "Invalid recipient"; + }, + { + code: 6037; + name: "OptimisticGovernanceDisabled"; + msg: "Optimistic governance is disabled"; + }, + { + code: 6038; + name: "ActiveOptimisticProposalAlreadyEnqueued"; + msg: "An active optimistic proposal is already enqueued"; + }, + { + code: 6039; + name: "NoActiveOptimisticProposal"; + msg: "No active optimistic proposal"; + }, ]; }; @@ -3955,130 +4250,264 @@ export const IDL: Futarchy = { name: "collectMeteoraDammFees", accounts: [ { - name: "dao", + name: "dao", + isMut: true, + isSigner: false, + }, + { + name: "admin", + isMut: true, + isSigner: true, + }, + { + name: "squadsMultisig", + isMut: true, + isSigner: false, + }, + { + name: "squadsMultisigVault", + isMut: false, + isSigner: false, + }, + { + name: "squadsMultisigVaultTransaction", + isMut: true, + isSigner: false, + }, + { + name: "squadsMultisigProposal", + isMut: true, + isSigner: false, + }, + { + name: "squadsMultisigPermissionlessAccount", + isMut: false, + isSigner: true, + }, + { + name: "meteoraClaimPositionFeesAccounts", + accounts: [ + { + name: "dammV2Program", + isMut: false, + isSigner: false, + }, + { + name: "dammV2EventAuthority", + isMut: false, + isSigner: false, + }, + { + name: "poolAuthority", + isMut: false, + isSigner: false, + }, + { + name: "pool", + isMut: false, + isSigner: false, + }, + { + name: "position", + isMut: true, + isSigner: false, + }, + { + name: "tokenAAccount", + isMut: true, + isSigner: false, + docs: ["Token account of base tokens recipient"], + }, + { + name: "tokenBAccount", + isMut: true, + isSigner: false, + docs: ["Token account of quote tokens recipient"], + }, + { + name: "tokenAVault", + isMut: true, + isSigner: false, + }, + { + name: "tokenBVault", + isMut: true, + isSigner: false, + }, + { + name: "tokenAMint", + isMut: false, + isSigner: false, + }, + { + name: "tokenBMint", + isMut: false, + isSigner: false, + }, + { + name: "positionNftAccount", + isMut: false, + isSigner: false, + }, + { + name: "owner", + isMut: false, + isSigner: false, + }, + { + name: "tokenAProgram", + isMut: false, + isSigner: false, + }, + { + name: "tokenBProgram", + isMut: false, + isSigner: false, + }, + ], + }, + { + name: "systemProgram", + isMut: false, + isSigner: false, + }, + { + name: "tokenProgram", + isMut: false, + isSigner: false, + }, + { + name: "squadsProgram", + isMut: false, + isSigner: false, + }, + { + name: "eventAuthority", + isMut: false, + isSigner: false, + }, + { + name: "program", + isMut: false, + isSigner: false, + }, + ], + args: [], + }, + { + name: "initiateVaultSpendOptimisticProposal", + accounts: [ + { + name: "squadsMultisig", + isMut: true, + isSigner: false, + }, + { + name: "squadsMultisigVault", + isMut: false, + isSigner: false, + }, + { + name: "squadsSpendingLimit", + isMut: false, + isSigner: false, + }, + { + name: "squadsProposal", + isMut: true, + isSigner: false, + }, + { + name: "squadsVaultTransaction", + isMut: true, + isSigner: false, + }, + { + name: "squadsMultisigPermissionlessAccount", + isMut: false, + isSigner: true, + }, + { + name: "dao", + isMut: true, + isSigner: false, + }, + { + name: "daoQuoteVaultAccount", + isMut: true, + isSigner: false, + }, + { + name: "proposer", + isMut: true, + isSigner: true, + }, + { + name: "recipient", + isMut: false, + isSigner: false, + }, + { + name: "recipientQuoteAccount", isMut: true, isSigner: false, }, { - name: "admin", + name: "payer", isMut: true, isSigner: true, }, { - name: "squadsMultisig", - isMut: true, + name: "systemProgram", + isMut: false, isSigner: false, }, { - name: "squadsMultisigVault", + name: "squadsProgram", isMut: false, isSigner: false, }, { - name: "squadsMultisigVaultTransaction", - isMut: true, + name: "tokenProgram", + isMut: false, isSigner: false, }, { - name: "squadsMultisigProposal", - isMut: true, + name: "eventAuthority", + isMut: false, isSigner: false, }, { - name: "squadsMultisigPermissionlessAccount", + name: "program", isMut: false, - isSigner: true, + isSigner: false, }, + ], + args: [ { - name: "meteoraClaimPositionFeesAccounts", - accounts: [ - { - name: "dammV2Program", - isMut: false, - isSigner: false, - }, - { - name: "dammV2EventAuthority", - isMut: false, - isSigner: false, - }, - { - name: "poolAuthority", - isMut: false, - isSigner: false, - }, - { - name: "pool", - isMut: false, - isSigner: false, - }, - { - name: "position", - isMut: true, - isSigner: false, - }, - { - name: "tokenAAccount", - isMut: true, - isSigner: false, - docs: ["Token account of base tokens recipient"], - }, - { - name: "tokenBAccount", - isMut: true, - isSigner: false, - docs: ["Token account of quote tokens recipient"], - }, - { - name: "tokenAVault", - isMut: true, - isSigner: false, - }, - { - name: "tokenBVault", - isMut: true, - isSigner: false, - }, - { - name: "tokenAMint", - isMut: false, - isSigner: false, - }, - { - name: "tokenBMint", - isMut: false, - isSigner: false, - }, - { - name: "positionNftAccount", - isMut: false, - isSigner: false, - }, - { - name: "owner", - isMut: false, - isSigner: false, - }, - { - name: "tokenAProgram", - isMut: false, - isSigner: false, - }, - { - name: "tokenBProgram", - isMut: false, - isSigner: false, - }, - ], + name: "params", + type: { + defined: "InitiateVaultSpendOptimisticProposalParams", + }, }, + ], + }, + { + name: "finalizeOptimisticProposal", + accounts: [ { - name: "systemProgram", - isMut: false, + name: "squadsMultisig", + isMut: true, isSigner: false, }, { - name: "tokenProgram", - isMut: false, + name: "squadsProposal", + isMut: true, + isSigner: false, + }, + { + name: "dao", + isMut: true, isSigner: false, }, { @@ -4254,6 +4683,18 @@ export const IDL: Futarchy = { name: "teamAddress", type: "publicKey", }, + { + name: "optimisticProposal", + type: { + option: { + defined: "OptimisticProposal", + }, + }, + }, + { + name: "isOptimisticGovernanceEnabled", + type: "bool", + }, ], }, }, @@ -4465,6 +4906,18 @@ export const IDL: Futarchy = { ], }, }, + { + name: "InitiateVaultSpendOptimisticProposalParams", + type: { + kind: "struct", + fields: [ + { + name: "amount", + type: "u64", + }, + ], + }, + }, { name: "ProvideLiquidityParams", type: { @@ -4607,6 +5060,12 @@ export const IDL: Futarchy = { option: "publicKey", }, }, + { + name: "isOptimisticGovernanceEnabled", + type: { + option: "bool", + }, + }, ], }, }, @@ -4633,6 +5092,28 @@ export const IDL: Futarchy = { ], }, }, + { + name: "OptimisticProposal", + type: { + kind: "struct", + fields: [ + { + name: "squadsProposal", + docs: [ + "The squads proposal currently enqueued for execution if not challenged by a new proposal.", + ], + type: "publicKey", + }, + { + name: "enqueuedTimestamp", + docs: [ + "The timestamp when the active optimistic squads proposal was enqueued.", + ], + type: "i64", + }, + ], + }, + }, { name: "InitialSpendingLimit", type: { @@ -5127,6 +5608,11 @@ export const IDL: Futarchy = { type: "publicKey", index: false, }, + { + name: "isOptimisticGovernanceEnabled", + type: "bool", + index: false, + }, ], }, { @@ -5671,6 +6157,90 @@ export const IDL: Futarchy = { }, ], }, + { + name: "InitiateVaultSpendOptimisticProposalEvent", + fields: [ + { + name: "common", + type: { + defined: "CommonFields", + }, + index: false, + }, + { + name: "dao", + type: "publicKey", + index: false, + }, + { + name: "proposer", + type: "publicKey", + index: false, + }, + { + name: "squadsProposal", + type: "publicKey", + index: false, + }, + { + name: "squadsMultisig", + type: "publicKey", + index: false, + }, + { + name: "squadsMultisigVault", + type: "publicKey", + index: false, + }, + { + name: "amount", + type: "u64", + index: false, + }, + { + name: "recipient", + type: "publicKey", + index: false, + }, + { + name: "daoQuoteVaultAccount", + type: "publicKey", + index: false, + }, + { + name: "recipientQuoteAccount", + type: "publicKey", + index: false, + }, + { + name: "enqueuedTimestamp", + type: "i64", + index: false, + }, + ], + }, + { + name: "FinalizeOptimisticProposalEvent", + fields: [ + { + name: "common", + type: { + defined: "CommonFields", + }, + index: false, + }, + { + name: "dao", + type: "publicKey", + index: false, + }, + { + name: "squadsProposal", + type: "publicKey", + index: false, + }, + ], + }, ], errors: [ { @@ -5853,5 +6423,25 @@ export const IDL: Futarchy = { name: "InvalidTransactionMessage", msg: "Failed to compile transaction message for Squads vault transaction", }, + { + code: 6036, + name: "InvalidRecipient", + msg: "Invalid recipient", + }, + { + code: 6037, + name: "OptimisticGovernanceDisabled", + msg: "Optimistic governance is disabled", + }, + { + code: 6038, + name: "ActiveOptimisticProposalAlreadyEnqueued", + msg: "An active optimistic proposal is already enqueued", + }, + { + code: 6039, + name: "NoActiveOptimisticProposal", + msg: "No active optimistic proposal", + }, ], }; diff --git a/sdk/src/v0.7/FutarchyClient.ts b/sdk/src/v0.7/FutarchyClient.ts index a9e73931..1c600ad8 100644 --- a/sdk/src/v0.7/FutarchyClient.ts +++ b/sdk/src/v0.7/FutarchyClient.ts @@ -1115,4 +1115,93 @@ export class FutarchyClient { squadsProgram: SQUADS_PROGRAM_ID, }); } + + initiateVaultSpendOptimisticProposalIx({ + dao, + quoteMint = MAINNET_USDC, + amount, + recipient, + transactionIndex, + proposer = this.provider.publicKey, + payer = this.provider.publicKey, + }: { + dao: PublicKey; + quoteMint?: PublicKey; + amount: BN; + recipient: PublicKey; + transactionIndex: bigint; + proposer?: PublicKey; + payer?: PublicKey; + }) { + const multisigPda = multisig.getMultisigPda({ createKey: dao })[0]; + const squadsMultisigVault = multisig.getVaultPda({ + multisigPda, + index: 0, + })[0]; + const squadsSpendingLimit = multisig.getSpendingLimitPda({ + multisigPda, + createKey: dao, + })[0]; + const squadsProposal = multisig.getProposalPda({ + multisigPda, + transactionIndex, + })[0]; + const squadsVaultTransaction = multisig.getTransactionPda({ + multisigPda, + index: transactionIndex, + })[0]; + + return this.autocrat.methods + .initiateVaultSpendOptimisticProposal({ amount }) + .accounts({ + squadsMultisig: multisigPda, + squadsMultisigVault, + squadsSpendingLimit, + squadsProposal, + squadsVaultTransaction, + squadsMultisigPermissionlessAccount: PERMISSIONLESS_ACCOUNT.publicKey, + dao, + daoQuoteVaultAccount: getAssociatedTokenAddressSync( + quoteMint, + squadsMultisigVault, + true, + ), + proposer, + recipient, + recipientQuoteAccount: getAssociatedTokenAddressSync( + quoteMint, + recipient, + true, + ), + payer, + systemProgram: SystemProgram.programId, + squadsProgram: SQUADS_PROGRAM_ID, + tokenProgram: TOKEN_PROGRAM_ID, + }) + .preInstructions([ + createAssociatedTokenAccountIdempotentInstruction( + payer, + getAssociatedTokenAddressSync(quoteMint, recipient, true), + recipient, + quoteMint, + ), + ]); + } + + finalizeOptimisticProposalIx({ + dao, + squadsProposal, + }: { + dao: PublicKey; + squadsProposal: PublicKey; + }) { + const multisigPda = multisig.getMultisigPda({ createKey: dao })[0]; + + return this.autocrat.methods.finalizeOptimisticProposal().accounts({ + squadsMultisig: multisigPda, + squadsProposal, + dao, + squadsProgram: SQUADS_PROGRAM_ID, + }); + } } diff --git a/tests/futarchy/main.test.ts b/tests/futarchy/main.test.ts index 37478c3a..e651d148 100644 --- a/tests/futarchy/main.test.ts +++ b/tests/futarchy/main.test.ts @@ -9,6 +9,8 @@ import conditionalSwap from "./unit/conditionalSwap.test.js"; import executeSpendingLimitChange from "./unit/executeSpendingLimitChange.test.js"; +import initiateVaultSpendOptimisticProposal from "./unit/initiateVaultSpendOptimisticProposal.test.js"; + import collectMeteoraDammFees from "./unit/collectMeteoraDammFees.test.js"; import { PublicKey } from "@solana/web3.js"; import { @@ -50,6 +52,11 @@ export default function suite() { describe("#collect_meteora_damm_fees", collectMeteoraDammFees); + describe( + "#initiate_vault_spend_optimistic_proposal", + initiateVaultSpendOptimisticProposal, + ); + // describe("full proposal", fullProposal); // describe("proposal with a squads batch tx", proposalBatchTx); describe("futarchy amm", futarchyAmm); diff --git a/tests/futarchy/unit/initializeDao.test.ts b/tests/futarchy/unit/initializeDao.test.ts index 06948538..ebf69601 100644 --- a/tests/futarchy/unit/initializeDao.test.ts +++ b/tests/futarchy/unit/initializeDao.test.ts @@ -36,6 +36,8 @@ export default function suite() { passThresholdBps: 300, nonce: new BN(1337), initialSpendingLimit: null, + teamAddress: this.payer.publicKey, + teamSponsoredPassThresholdBps: 123, }, }) .preInstructions([ @@ -71,6 +73,12 @@ export default function suite() { assert.equal(storedDao.passThresholdBps, 300); assert.isNull(storedDao.initialSpendingLimit); + assert.isTrue(storedDao.teamAddress.equals(this.payer.publicKey)); + assert.equal(storedDao.teamSponsoredPassThresholdBps, 123); + + assert.isNull(storedDao.optimisticProposal); + assert.isFalse(storedDao.isOptimisticGovernanceEnabled); + const multisigPda = multisig.getMultisigPda({ createKey: dao })[0]; const squadsMultisigVault = multisig.getVaultPda({ multisigPda, @@ -131,6 +139,8 @@ export default function suite() { amountPerMonth: new BN(10_000 * 10 ** 6), members: [spender.publicKey], }, + teamSponsoredPassThresholdBps: 123, + teamAddress: this.payer.publicKey, }, }) .rpc(); @@ -175,6 +185,10 @@ export default function suite() { assert.equal(storedSpendingLimit.members.length, 1); assert.ok(storedSpendingLimit.members[0].equals(spender.publicKey)); assert.equal(storedSpendingLimit.destinations.length, 0); + assert.isTrue(storedDao.teamAddress.equals(this.payer.publicKey)); + assert.equal(storedDao.teamSponsoredPassThresholdBps, 123); + assert.isNull(storedDao.optimisticProposal); + assert.isFalse(storedDao.isOptimisticGovernanceEnabled); }); it("doesn't allow DAOs with proposal duration less than TWAP start delay", async function () { @@ -197,6 +211,9 @@ export default function suite() { secondsPerProposal: 5000, nonce: new BN(1338), initialSpendingLimit: null, + baseToStake: new BN(1000), + teamSponsoredPassThresholdBps: 123, + teamAddress: this.payer.publicKey, }, }) .rpc() diff --git a/tests/futarchy/unit/initiateVaultSpendOptimisticProposal.test.ts b/tests/futarchy/unit/initiateVaultSpendOptimisticProposal.test.ts new file mode 100644 index 00000000..97df1ede --- /dev/null +++ b/tests/futarchy/unit/initiateVaultSpendOptimisticProposal.test.ts @@ -0,0 +1,276 @@ +import { + PERMISSIONLESS_ACCOUNT, + PriceMath, +} from "@metadaoproject/futarchy/v0.7"; +import { ComputeBudgetProgram, PublicKey } from "@solana/web3.js"; +import BN from "bn.js"; +import { expectError } from "../../utils.js"; +import { getDaoAddr, MAINNET_USDC } from "@metadaoproject/futarchy/v0.7"; +import { getAssociatedTokenAddressSync } from "@solana/spl-token"; +import { assert } from "chai"; +import * as squads from "@sqds/multisig"; + +const THOUSAND_BUCK_PRICE = PriceMath.getAmmPrice(1000, 9, 6); + +export default function suite() { + let META: PublicKey, dao: PublicKey, spendingLimit: BN; + let setOptimisticGovernanceEnabled: ( + dao: PublicKey, + enabled: boolean, + ) => Promise; + + beforeEach(async function () { + setOptimisticGovernanceEnabled = async ( + dao: PublicKey, + enabled: boolean, + ) => { + const daoAccount = await this.futarchy.getDao(dao); + daoAccount.isOptimisticGovernanceEnabled = enabled; + const daoAccountBuffer = + await this.futarchy.autocrat.account.dao.coder.accounts.encode( + "dao", + daoAccount, + ); + + const daoBanksAccount = await this.banksClient.getAccount(dao); + daoBanksAccount.data.set(daoAccountBuffer, 0); + this.context.setAccount(dao, daoBanksAccount); + }; + META = await this.createMint(this.payer.publicKey, 9); + spendingLimit = new BN(10_000); + // Create payer's token accounts for both mints + await this.createTokenAccount(META, this.payer.publicKey); + + // Mint tokens to payer's accounts + await this.mintTo(META, this.payer.publicKey, this.payer, 100 * 10 ** 9); + + const nonce = new BN(Math.floor(Math.random() * 1000000)); + + await this.futarchy + .initializeDaoIx({ + baseMint: META, + quoteMint: MAINNET_USDC, + params: { + secondsPerProposal: 60 * 60 * 24 * 3, + twapStartDelaySeconds: 60 * 60 * 24, + twapInitialObservation: THOUSAND_BUCK_PRICE, + twapMaxObservationChangePerUpdate: THOUSAND_BUCK_PRICE.divn(100), + minQuoteFutarchicLiquidity: new BN(10_000), + minBaseFutarchicLiquidity: new BN(10_000), + passThresholdBps: 300, + nonce, + initialSpendingLimit: { + amountPerMonth: spendingLimit, + members: [this.payer.publicKey], + }, + baseToStake: new BN(0), + teamSponsoredPassThresholdBps: 0, + teamAddress: this.payer.publicKey, + }, + provideLiquidity: true, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), + ]) + .rpc(); + + [dao] = getDaoAddr({ + nonce, + daoCreator: this.payer.publicKey, + }); + + const daoAccount = await this.futarchy.getDao(dao); + + console.log("daoAccount", JSON.stringify(daoAccount, null, 2)); + + const daoQuoteVaultAddress = getAssociatedTokenAddressSync( + MAINNET_USDC, + daoAccount.squadsMultisigVault, + true, + ); + + await this.createTokenAccount(MAINNET_USDC, daoAccount.squadsMultisigVault); + + // const balance = await this.getTokenBalance(MAINNET_USDC, daoQuoteVaultAddress); + // console.log("balance", balance.toString()); + + await this.transfer( + MAINNET_USDC, + this.payer, + daoAccount.squadsMultisigVault, + 100_000 * 1_000_000, + ); + + await setOptimisticGovernanceEnabled(dao, true); + }); + + it("can initiate a vault spend optimistic proposal if the DAO has optimistic governance enabled", async function () { + await this.futarchy + .initiateVaultSpendOptimisticProposalIx({ + dao, + amount: new BN(1000), + recipient: this.payer.publicKey, + transactionIndex: 1n, + }) + .signers([this.payer, PERMISSIONLESS_ACCOUNT]) + .rpc(); + + const daoAccount = await this.futarchy.getDao(dao); + + assert.exists(daoAccount.optimisticProposal); + + const clock = await this.banksClient.getClock(); + assert.equal( + daoAccount.optimisticProposal.enqueuedTimestamp.toString(), + clock.unixTimestamp.toString(), + ); + + const [expectedSquadsProposal] = squads.getProposalPda({ + multisigPda: daoAccount.squadsMultisig, + transactionIndex: 1n, + }); + assert.equal( + expectedSquadsProposal.toBase58(), + daoAccount.optimisticProposal.squadsProposal.toBase58(), + ); + }); + + it("can't initiate a vault spend optimistic proposal if the DAO doesn't have optimistic governance enabled", async function () { + await setOptimisticGovernanceEnabled(dao, false); + + const callbacks = expectError( + "OptimisticGovernanceDisabled", + "DAO doesn't have optimistic governance enabled", + ); + + await this.futarchy + .initiateVaultSpendOptimisticProposalIx({ + dao, + amount: new BN(1000), + recipient: this.payer.publicKey, + transactionIndex: 0n, + }) + .signers([this.payer, PERMISSIONLESS_ACCOUNT]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("can't initiate a vault spend optimistic proposal if the DAO is not in spot state", async function () { + const daoAccount = await this.futarchy.getDao(dao); + const dummyMarket = { + baseProtocolFeeBalance: new BN(0), + quoteProtocolFeeBalance: new BN(0), + baseReserves: new BN(0), + quoteReserves: new BN(0), + oracle: { + aggregator: new BN(0), + lastUpdatedTimestamp: new BN(0), + createdAtTimestamp: new BN(0), + lastPrice: new BN(0), + lastObservation: new BN(0), + maxObservationChangePerUpdate: new BN(0), + initialObservation: new BN(0), + startDelaySeconds: 0, + }, + }; + + daoAccount.amm.state = { + futarchy: { + spot: dummyMarket, + pass: dummyMarket, + fail: dummyMarket, + }, + }; + + const daoAccountBuffer = + await this.futarchy.autocrat.account.dao.coder.accounts.encode( + "dao", + daoAccount, + ); + const daoBanksAccount = await this.banksClient.getAccount(dao); + daoBanksAccount.data.set(daoAccountBuffer, 0); + this.context.setAccount(dao, daoBanksAccount); + + const callbacks = expectError( + "PoolNotInSpotState", + "Pool is not in spot state", + ); + + await this.futarchy + .initiateVaultSpendOptimisticProposalIx({ + dao, + amount: new BN(1000), + recipient: this.payer.publicKey, + transactionIndex: 1n, + }) + .signers([this.payer, PERMISSIONLESS_ACCOUNT]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("can't initialize a vault spend optimistic proposal if the DAO has an active optimistic proposal", async function () { + await this.futarchy + .initiateVaultSpendOptimisticProposalIx({ + dao, + amount: new BN(1000), + recipient: this.payer.publicKey, + transactionIndex: 1n, + }) + .signers([this.payer, PERMISSIONLESS_ACCOUNT]) + .rpc(); + + const callbacks = expectError( + "ActiveOptimisticProposalAlreadyEnqueued", + "An active optimistic proposal is already enqueued", + ); + + await this.futarchy + .initiateVaultSpendOptimisticProposalIx({ + dao, + amount: new BN(1000), + recipient: this.payer.publicKey, + transactionIndex: 1n, + }) + .preInstructions([ + // Add any instruction to prevent banksClient from reverting the transaction - compute budget is perfectly fine + ComputeBudgetProgram.setComputeUnitLimit({ units: 200_000 }), + ]) + .signers([this.payer, PERMISSIONLESS_ACCOUNT]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("can initialize a vault spend optimistic proposal if the amount is less than or equal to 3 times the spending limit", async function () { + await this.futarchy + .initiateVaultSpendOptimisticProposalIx({ + dao, + amount: spendingLimit.muln(3), + recipient: this.payer.publicKey, + transactionIndex: 1n, + }) + .signers([this.payer, PERMISSIONLESS_ACCOUNT]) + .rpc(); + }); + + it("can't initialize a vault spend optimistic proposal if the amount is greater than 3 times the spending limit", async function () { + const callbacks = expectError( + "InvalidAmount", + "Amount is greater than 3 times the spending limit", + ); + + await this.futarchy + .initiateVaultSpendOptimisticProposalIx({ + dao, + amount: spendingLimit.muln(3).addn(1), + recipient: this.payer.publicKey, + transactionIndex: 1n, + }) + .preInstructions([ + // Add any instruction to prevent banksClient from reverting the transaction - compute budget is perfectly fine + ComputeBudgetProgram.setComputeUnitLimit({ units: 200_000 }), + ]) + .signers([this.payer, PERMISSIONLESS_ACCOUNT]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); +} diff --git a/tests/integration/fullLaunch.test.ts b/tests/integration/fullLaunch.test.ts index 5e44ba32..a6b8ad68 100644 --- a/tests/integration/fullLaunch.test.ts +++ b/tests/integration/fullLaunch.test.ts @@ -380,6 +380,7 @@ export default async function suite() { minBaseFutarchicLiquidity: null, teamSponsoredPassThresholdBps: null, teamAddress: null, + isOptimisticGovernanceEnabled: false, }, }) .instruction(); diff --git a/tests/integration/fullLaunch_v7.test.ts b/tests/integration/fullLaunch_v7.test.ts index af8af6e0..4a0adeb1 100644 --- a/tests/integration/fullLaunch_v7.test.ts +++ b/tests/integration/fullLaunch_v7.test.ts @@ -424,6 +424,7 @@ export default async function suite() { minBaseFutarchicLiquidity: null, teamSponsoredPassThresholdBps: null, teamAddress: null, + isOptimisticGovernanceEnabled: false, }, }) .instruction(); From 6702eaf5fa333fe5e1574dc63c74122db2303f08 Mon Sep 17 00:00:00 2001 From: Pileks Date: Sat, 10 Jan 2026 02:41:55 +0100 Subject: [PATCH 08/16] optimistic proposal finalization tests --- .../finalize_optimistic_proposal.rs | 20 +- tests/futarchy/main.test.ts | 5 +- .../unit/finalizeOptimisticProposal.test.ts | 281 ++++++++++++++++++ ...itiateVaultSpendOptimisticProposal.test.ts | 14 +- 4 files changed, 295 insertions(+), 25 deletions(-) create mode 100644 tests/futarchy/unit/finalizeOptimisticProposal.test.ts diff --git a/programs/futarchy/src/instructions/finalize_optimistic_proposal.rs b/programs/futarchy/src/instructions/finalize_optimistic_proposal.rs index fac40a2b..a8dcbb09 100644 --- a/programs/futarchy/src/instructions/finalize_optimistic_proposal.rs +++ b/programs/futarchy/src/instructions/finalize_optimistic_proposal.rs @@ -18,23 +18,21 @@ impl FinalizeOptimisticProposal<'_> { pub fn validate(&self) -> Result<()> { require_keys_eq!(self.squads_proposal.multisig, self.dao.squads_multisig); - // There should be an active optimistic proposal - let optimistic_proposal = match self.dao.optimistic_proposal { - Some(ref optimistic_proposal) => optimistic_proposal, - None => { - return Err(FutarchyError::NoActiveOptimisticProposal.into()); - } - }; - // 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, - optimistic_proposal.enqueued_timestamp + self.dao.seconds_per_proposal as i64, - FutarchyError::ProposalDurationTooShort + 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 - // Realistically, this should never be hit, but it's here for completeness + // This should never be hit, but it's here for completeness match self.dao.amm.state { PoolState::Spot { spot: _ } => {} _ => { diff --git a/tests/futarchy/main.test.ts b/tests/futarchy/main.test.ts index e651d148..48005924 100644 --- a/tests/futarchy/main.test.ts +++ b/tests/futarchy/main.test.ts @@ -9,9 +9,11 @@ import conditionalSwap from "./unit/conditionalSwap.test.js"; import executeSpendingLimitChange from "./unit/executeSpendingLimitChange.test.js"; +import collectMeteoraDammFees from "./unit/collectMeteoraDammFees.test.js"; + import initiateVaultSpendOptimisticProposal from "./unit/initiateVaultSpendOptimisticProposal.test.js"; +import finalizeOptimisticProposal from "./unit/finalizeOptimisticProposal.test.js"; -import collectMeteoraDammFees from "./unit/collectMeteoraDammFees.test.js"; import { PublicKey } from "@solana/web3.js"; import { LAUNCHPAD_PROGRAM_ID, @@ -56,6 +58,7 @@ export default function suite() { "#initiate_vault_spend_optimistic_proposal", initiateVaultSpendOptimisticProposal, ); + describe.only("#finalize_optimistic_proposal", finalizeOptimisticProposal); // describe("full proposal", fullProposal); // describe("proposal with a squads batch tx", proposalBatchTx); diff --git a/tests/futarchy/unit/finalizeOptimisticProposal.test.ts b/tests/futarchy/unit/finalizeOptimisticProposal.test.ts new file mode 100644 index 00000000..a2548188 --- /dev/null +++ b/tests/futarchy/unit/finalizeOptimisticProposal.test.ts @@ -0,0 +1,281 @@ +import { + PERMISSIONLESS_ACCOUNT, + PriceMath, +} from "@metadaoproject/futarchy/v0.7"; +import { + ComputeBudgetProgram, + PublicKey, + Transaction, + TransactionMessage, +} from "@solana/web3.js"; +import BN from "bn.js"; +import { expectError } from "../../utils.js"; +import { getDaoAddr, MAINNET_USDC } from "@metadaoproject/futarchy/v0.7"; +import { + createTransferInstruction, + getAssociatedTokenAddressSync, +} from "@solana/spl-token"; +import { assert } from "chai"; +import * as squads from "@sqds/multisig"; + +const THOUSAND_BUCK_PRICE = PriceMath.getAmmPrice(1000, 9, 6); + +export default function suite() { + let META: PublicKey, + dao: PublicKey, + spendingLimit: BN, + transferAmount: bigint; + let setOptimisticGovernanceEnabled: ( + dao: PublicKey, + enabled: boolean, + ) => Promise; + + beforeEach(async function () { + setOptimisticGovernanceEnabled = async ( + dao: PublicKey, + enabled: boolean, + ) => { + const daoAccount = await this.futarchy.getDao(dao); + daoAccount.isOptimisticGovernanceEnabled = enabled; + const daoAccountBuffer = + await this.futarchy.autocrat.account.dao.coder.accounts.encode( + "dao", + daoAccount, + ); + + const daoBanksAccount = await this.banksClient.getAccount(dao); + daoBanksAccount.data.set(daoAccountBuffer, 0); + this.context.setAccount(dao, daoBanksAccount); + }; + META = await this.createMint(this.payer.publicKey, 9); + spendingLimit = new BN(10_000); + transferAmount = 1000n; + // Create payer's token accounts for both mints + await this.createTokenAccount(META, this.payer.publicKey); + + // Mint tokens to payer's accounts + await this.mintTo(META, this.payer.publicKey, this.payer, 100 * 10 ** 9); + + const nonce = new BN(Math.floor(Math.random() * 1000000)); + + await this.futarchy + .initializeDaoIx({ + baseMint: META, + quoteMint: MAINNET_USDC, + params: { + secondsPerProposal: 60 * 60 * 24 * 3, + twapStartDelaySeconds: 60 * 60 * 24, + twapInitialObservation: THOUSAND_BUCK_PRICE, + twapMaxObservationChangePerUpdate: THOUSAND_BUCK_PRICE.divn(100), + minQuoteFutarchicLiquidity: new BN(10_000), + minBaseFutarchicLiquidity: new BN(10_000), + passThresholdBps: 300, + nonce, + initialSpendingLimit: { + amountPerMonth: spendingLimit, + members: [this.payer.publicKey], + }, + baseToStake: new BN(0), + teamSponsoredPassThresholdBps: 0, + teamAddress: this.payer.publicKey, + }, + provideLiquidity: true, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), + ]) + .rpc(); + + [dao] = getDaoAddr({ + nonce, + daoCreator: this.payer.publicKey, + }); + + const daoAccount = await this.futarchy.getDao(dao); + + await this.createTokenAccount(MAINNET_USDC, daoAccount.squadsMultisigVault); + + await this.transfer( + MAINNET_USDC, + this.payer, + daoAccount.squadsMultisigVault, + 100_000 * 1_000_000, + ); + + await setOptimisticGovernanceEnabled(dao, true); + + await this.futarchy + .initiateVaultSpendOptimisticProposalIx({ + dao, + amount: new BN(transferAmount), + recipient: this.payer.publicKey, + transactionIndex: 1n, + }) + .signers([this.payer, PERMISSIONLESS_ACCOUNT]) + .rpc(); + }); + + it("can finalize a vault spend optimistic proposal and execute the squads proposal afterwards", async function () { + this.advanceBySeconds(60 * 60 * 24 * 3); + + let daoAccount = await this.futarchy.getDao(dao); + + await this.futarchy + .finalizeOptimisticProposalIx({ + dao, + squadsProposal: daoAccount.optimisticProposal.squadsProposal, + }) + .rpc(); + + daoAccount = await this.futarchy.getDao(dao); + + assert.notExists(daoAccount.optimisticProposal); + + const payerUsdcBalanceBefore = await this.getTokenBalance( + MAINNET_USDC, + this.payer.publicKey, + ); + + // Confirm that we can execute the squads proposal + const txExecuteIx = await squads.instructions.vaultTransactionExecute({ + connection: this.squadsConnection, + multisigPda: daoAccount.squadsMultisig, + transactionIndex: 1n, + member: PERMISSIONLESS_ACCOUNT.publicKey, + }); + + const txExecute = new Transaction().add(txExecuteIx.instruction); + txExecute.recentBlockhash = ( + await this.banksClient.getLatestBlockhash() + )[0]; + txExecute.feePayer = this.payer.publicKey; + txExecute.sign(this.payer, PERMISSIONLESS_ACCOUNT); + + await this.banksClient.processTransaction(txExecute); + + const payerUsdcBalanceAfter = await this.getTokenBalance( + MAINNET_USDC, + this.payer.publicKey, + ); + assert.equal( + payerUsdcBalanceAfter, + payerUsdcBalanceBefore + transferAmount, + ); + }); + + it("can't finalize a vault spend optimistic proposal if the proposal account is not the same as the optimistic proposal", async function () { + const daoAccount = await this.futarchy.getDao(dao); + + let transferIx = createTransferInstruction( + getAssociatedTokenAddressSync( + MAINNET_USDC, + daoAccount.squadsMultisigVault, + true, + ), + getAssociatedTokenAddressSync(MAINNET_USDC, this.payer.publicKey), + daoAccount.squadsMultisigVault, + 123, + ); + + let transactionMessage = new TransactionMessage({ + payerKey: this.payer.publicKey, + recentBlockhash: (await this.banksClient.getLatestBlockhash())[0], + instructions: [transferIx], + }); + + const dupeProposalTx = new Transaction().add( + squads.instructions.vaultTransactionCreate({ + multisigPda: daoAccount.squadsMultisig, + transactionIndex: 2n, + creator: PERMISSIONLESS_ACCOUNT.publicKey, + rentPayer: this.payer.publicKey, + vaultIndex: 0, + ephemeralSigners: 0, + transactionMessage: transactionMessage, + }), + squads.instructions.proposalCreate({ + multisigPda: daoAccount.squadsMultisig, + creator: PERMISSIONLESS_ACCOUNT.publicKey, + rentPayer: this.payer.publicKey, + transactionIndex: 2n, + isDraft: false, + }), + ); + + dupeProposalTx.recentBlockhash = ( + await this.banksClient.getLatestBlockhash() + )[0]; + dupeProposalTx.feePayer = this.payer.publicKey; + dupeProposalTx.sign(this.payer, PERMISSIONLESS_ACCOUNT); + + await this.banksClient.processTransaction(dupeProposalTx); + + const callbacks = expectError( + "ConstraintAddress", + "An address constraint was violated", + ); + + await this.futarchy + .finalizeOptimisticProposalIx({ + dao, + squadsProposal: squads.getProposalPda({ + multisigPda: daoAccount.squadsMultisig, + transactionIndex: 2n, + })[0], + }) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("can't finalize a vault spend optimistic proposal if the proposal is too young", async function () { + const daoAccount = await this.futarchy.getDao(dao); + + const callbacks = expectError( + "ProposalTooYoung", + "Proposal is too young to be executed or rejected", + ); + + await this.futarchy + .finalizeOptimisticProposalIx({ + dao, + squadsProposal: daoAccount.optimisticProposal.squadsProposal, + }) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("can't finalize a vault spend optimistic proposal if there is no active optimistic proposal", async function () { + this.advanceBySeconds(60 * 60 * 24 * 3); + + const daoAccount = await this.futarchy.getDao(dao); + + // Finalize the running optimistic proposal + await this.futarchy + .finalizeOptimisticProposalIx({ + dao, + squadsProposal: daoAccount.optimisticProposal.squadsProposal, + }) + .rpc(); + + try { + await this.futarchy + .finalizeOptimisticProposalIx({ + dao, + squadsProposal: daoAccount.optimisticProposal.squadsProposal, + }) + .preInstructions([ + // Add any instruction to prevent banksClient from reverting the transaction - compute budget is perfectly fine + ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), + ]) + .rpc(); + + assert.fail("Should have thrown error"); + } catch (error) { + const logsAggregated = error.logs.map((log: string) => log).join("\n"); + assert.include( + logsAggregated, + "panicked at 'called `Option::unwrap()` on a `None` value'", + ); + } + }); +} diff --git a/tests/futarchy/unit/initiateVaultSpendOptimisticProposal.test.ts b/tests/futarchy/unit/initiateVaultSpendOptimisticProposal.test.ts index 97df1ede..f19a643d 100644 --- a/tests/futarchy/unit/initiateVaultSpendOptimisticProposal.test.ts +++ b/tests/futarchy/unit/initiateVaultSpendOptimisticProposal.test.ts @@ -6,7 +6,6 @@ import { ComputeBudgetProgram, PublicKey } from "@solana/web3.js"; import BN from "bn.js"; import { expectError } from "../../utils.js"; import { getDaoAddr, MAINNET_USDC } from "@metadaoproject/futarchy/v0.7"; -import { getAssociatedTokenAddressSync } from "@solana/spl-token"; import { assert } from "chai"; import * as squads from "@sqds/multisig"; @@ -81,19 +80,8 @@ export default function suite() { const daoAccount = await this.futarchy.getDao(dao); - console.log("daoAccount", JSON.stringify(daoAccount, null, 2)); - - const daoQuoteVaultAddress = getAssociatedTokenAddressSync( - MAINNET_USDC, - daoAccount.squadsMultisigVault, - true, - ); - await this.createTokenAccount(MAINNET_USDC, daoAccount.squadsMultisigVault); - // const balance = await this.getTokenBalance(MAINNET_USDC, daoQuoteVaultAddress); - // console.log("balance", balance.toString()); - await this.transfer( MAINNET_USDC, this.payer, @@ -104,7 +92,7 @@ export default function suite() { await setOptimisticGovernanceEnabled(dao, true); }); - it("can initiate a vault spend optimistic proposal if the DAO has optimistic governance enabled", async function () { + it("can initiate a vault spend optimistic proposal", async function () { await this.futarchy .initiateVaultSpendOptimisticProposalIx({ dao, From 69a4b618a1abe01dda2c1621f4147f9f549793fb Mon Sep 17 00:00:00 2001 From: Pileks Date: Sat, 10 Jan 2026 04:03:01 +0100 Subject: [PATCH 09/16] prevent initialization of futarchy proposal when optimistic governance proposal has already passed but not been finalized yet --- programs/futarchy/src/error.rs | 2 + .../src/instructions/initialize_proposal.rs | 13 + .../src/instructions/launch_proposal.rs | 7 + sdk/src/v0.7/types/futarchy.ts | 10 + tests/futarchy/main.test.ts | 5 +- .../futarchy/unit/initializeProposal.test.ts | 107 ++++++++- tests/futarchy/unit/launchProposal.test.ts | 224 ++++++++++++++++++ tests/integration/fullLaunch_v7.test.ts | 3 +- 8 files changed, 361 insertions(+), 10 deletions(-) create mode 100644 tests/futarchy/unit/launchProposal.test.ts diff --git a/programs/futarchy/src/error.rs b/programs/futarchy/src/error.rs index ffb92cee..2e62d84e 100644 --- a/programs/futarchy/src/error.rs +++ b/programs/futarchy/src/error.rs @@ -84,4 +84,6 @@ pub enum FutarchyError { ActiveOptimisticProposalAlreadyEnqueued, #[msg("No active optimistic proposal")] NoActiveOptimisticProposal, + #[msg("Optimistic proposal has already passed")] + OptimisticProposalAlreadyPassed, } diff --git a/programs/futarchy/src/instructions/initialize_proposal.rs b/programs/futarchy/src/instructions/initialize_proposal.rs index 9393d25c..694a526c 100644 --- a/programs/futarchy/src/instructions/initialize_proposal.rs +++ b/programs/futarchy/src/instructions/initialize_proposal.rs @@ -36,6 +36,19 @@ pub struct InitializeProposal<'info> { impl InitializeProposal<'_> { pub fn validate(&self) -> Result<()> { + // If we're trying to initialize a proposal for an optimistic proposal that has already passed due to age, we should error + // This is because the optimistic proposal's Squads proposal will eventually have to be executed + match self.dao.optimistic_proposal { + Some(ref optimistic_proposal) => { + require_gt!( + optimistic_proposal.enqueued_timestamp + self.dao.seconds_per_proposal as i64, + Clock::get()?.unix_timestamp, + FutarchyError::OptimisticProposalAlreadyPassed + ); + } + None => {} + } + require_eq!( self.question.num_outcomes(), 2, diff --git a/programs/futarchy/src/instructions/launch_proposal.rs b/programs/futarchy/src/instructions/launch_proposal.rs index 68f290f4..abd5d58e 100644 --- a/programs/futarchy/src/instructions/launch_proposal.rs +++ b/programs/futarchy/src/instructions/launch_proposal.rs @@ -59,6 +59,13 @@ impl LaunchProposal<'_> { optimistic_proposal.squads_proposal, self.proposal.squads_proposal ); + + // The optimistic proposal must be younger than seconds_per_proposal, otherwise it is considered passed and must be finalized + require_gt!( + optimistic_proposal.enqueued_timestamp + self.dao.seconds_per_proposal as i64, + Clock::get()?.unix_timestamp, + FutarchyError::OptimisticProposalAlreadyPassed + ); } Ok(()) diff --git a/sdk/src/v0.7/types/futarchy.ts b/sdk/src/v0.7/types/futarchy.ts index d9d95cdc..26165b29 100644 --- a/sdk/src/v0.7/types/futarchy.ts +++ b/sdk/src/v0.7/types/futarchy.ts @@ -3219,6 +3219,11 @@ export type Futarchy = { name: "NoActiveOptimisticProposal"; msg: "No active optimistic proposal"; }, + { + code: 6040; + name: "OptimisticProposalAlreadyPassed"; + msg: "Optimistic proposal has already passed"; + }, ]; }; @@ -6443,5 +6448,10 @@ export const IDL: Futarchy = { name: "NoActiveOptimisticProposal", msg: "No active optimistic proposal", }, + { + code: 6040, + name: "OptimisticProposalAlreadyPassed", + msg: "Optimistic proposal has already passed", + }, ], }; diff --git a/tests/futarchy/main.test.ts b/tests/futarchy/main.test.ts index 48005924..bae7c48a 100644 --- a/tests/futarchy/main.test.ts +++ b/tests/futarchy/main.test.ts @@ -2,6 +2,7 @@ import futarchyAmm from "./integration/futarchyAmm.test.js"; import initializeDao from "./unit/initializeDao.test.js"; import initializeProposal from "./unit/initializeProposal.test.js"; +import launchProposal from "./unit/launchProposal.test.js"; import finalizeProposal from "./unit/finalizeProposal.test.js"; import collectFees from "./unit/collectFees.test.js"; @@ -46,6 +47,7 @@ export default function suite() { }); describe("#initialize_dao", initializeDao); describe("#initialize_proposal", initializeProposal); + describe("#launch_proposal", launchProposal); describe("#finalize_proposal", finalizeProposal); describe("#collect_fees", collectFees); @@ -58,8 +60,7 @@ export default function suite() { "#initiate_vault_spend_optimistic_proposal", initiateVaultSpendOptimisticProposal, ); - describe.only("#finalize_optimistic_proposal", finalizeOptimisticProposal); - + describe("#finalize_optimistic_proposal", finalizeOptimisticProposal); // describe("full proposal", fullProposal); // describe("proposal with a squads batch tx", proposalBatchTx); describe("futarchy amm", futarchyAmm); diff --git a/tests/futarchy/unit/initializeProposal.test.ts b/tests/futarchy/unit/initializeProposal.test.ts index 56514e70..6f452565 100644 --- a/tests/futarchy/unit/initializeProposal.test.ts +++ b/tests/futarchy/unit/initializeProposal.test.ts @@ -1,10 +1,16 @@ import { + getDaoAddr, PERMISSIONLESS_ACCOUNT, PriceMath, -} from "@metadaoproject/futarchy/v0.6"; -import { PublicKey, Transaction, TransactionMessage } from "@solana/web3.js"; +} from "@metadaoproject/futarchy/v0.7"; +import { + ComputeBudgetProgram, + PublicKey, + Transaction, + TransactionMessage, +} from "@solana/web3.js"; import BN from "bn.js"; -import { setupBasicDao } from "../../utils.js"; +import { expectError, setupBasicDao } from "../../utils.js"; import { assert } from "chai"; import * as multisig from "@sqds/multisig"; const { Permissions, Permission } = multisig.types; @@ -14,7 +20,29 @@ const THOUSAND_BUCK_PRICE = PriceMath.getAmmPrice(1000, 9, 6); export default function suite() { let META: PublicKey, USDC: PublicKey, dao: PublicKey; + let setOptimisticGovernanceEnabled: ( + dao: PublicKey, + enabled: boolean, + ) => Promise; + beforeEach(async function () { + setOptimisticGovernanceEnabled = async ( + dao: PublicKey, + enabled: boolean, + ) => { + const daoAccount = await this.futarchy.getDao(dao); + daoAccount.isOptimisticGovernanceEnabled = enabled; + const daoAccountBuffer = + await this.futarchy.autocrat.account.dao.coder.accounts.encode( + "dao", + daoAccount, + ); + + const daoBanksAccount = await this.banksClient.getAccount(dao); + daoBanksAccount.data.set(daoAccountBuffer, 0); + this.context.setAccount(dao, daoBanksAccount); + }; + META = await this.createMint(this.payer.publicKey, 9); USDC = await this.createMint(this.payer.publicKey, 6); @@ -31,10 +59,39 @@ export default function suite() { 100_000 * 1_000_000, ); - dao = await setupBasicDao({ - context: this, - baseMint: META, - quoteMint: USDC, + const nonce = new BN(Math.floor(Math.random() * 1000000)); + + await this.futarchy + .initializeDaoIx({ + baseMint: META, + quoteMint: USDC, + params: { + secondsPerProposal: 60 * 60 * 24 * 3, + twapStartDelaySeconds: 60 * 60 * 24, + twapInitialObservation: THOUSAND_BUCK_PRICE, + twapMaxObservationChangePerUpdate: THOUSAND_BUCK_PRICE.divn(100), + minQuoteFutarchicLiquidity: new BN(10_000), + minBaseFutarchicLiquidity: new BN(10_000), + passThresholdBps: 300, + nonce, + initialSpendingLimit: { + amountPerMonth: new BN(10_000), + members: [this.payer.publicKey], + }, + baseToStake: new BN(0), + teamSponsoredPassThresholdBps: 0, + teamAddress: this.payer.publicKey, + }, + provideLiquidity: true, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), + ]) + .rpc(); + + [dao] = getDaoAddr({ + nonce, + daoCreator: this.payer.publicKey, }); }); @@ -51,6 +108,10 @@ export default function suite() { twapMaxObservationChangePerUpdate: null, minQuoteFutarchicLiquidity: null, minBaseFutarchicLiquidity: null, + twapStartDelaySeconds: null, + teamSponsoredPassThresholdBps: null, + teamAddress: null, + isOptimisticGovernanceEnabled: null, }, }) .instruction(); @@ -124,4 +185,36 @@ export default function suite() { const storedDao = await this.futarchy.getDao(dao); assert.equal(storedDao.proposalCount, 1); }); + + it("doesn't allow challenging an optimistic proposal which has already passed due to age", async function () { + let daoAccount = await this.futarchy.getDao(dao); + + await this.createTokenAccount(USDC, daoAccount.squadsMultisigVault); + + await setOptimisticGovernanceEnabled(dao, true); + + await this.futarchy + .initiateVaultSpendOptimisticProposalIx({ + dao, + amount: new BN(1000), + recipient: this.payer.publicKey, + transactionIndex: 1n, + quoteMint: USDC, + }) + .signers([this.payer, PERMISSIONLESS_ACCOUNT]) + .rpc(); + + daoAccount = await this.futarchy.getDao(dao); + + this.advanceBySeconds(daoAccount.secondsPerProposal); + + const callbacks = expectError( + "OptimisticProposalAlreadyPassed", + "Optimistic proposal has already passed", + ); + + await this.futarchy + .initializeProposal(dao, daoAccount.optimisticProposal.squadsProposal) + .then(callbacks[0], callbacks[1]); + }); } diff --git a/tests/futarchy/unit/launchProposal.test.ts b/tests/futarchy/unit/launchProposal.test.ts new file mode 100644 index 00000000..3cf4e3f6 --- /dev/null +++ b/tests/futarchy/unit/launchProposal.test.ts @@ -0,0 +1,224 @@ +import { + getProposalAddrV2, + PERMISSIONLESS_ACCOUNT, + PriceMath, +} from "@metadaoproject/futarchy/v0.7"; +import { + ComputeBudgetProgram, + PublicKey, + Transaction, + TransactionMessage, +} from "@solana/web3.js"; +import BN from "bn.js"; +import { expectError } from "../../utils.js"; +import { getDaoAddr, MAINNET_USDC } from "@metadaoproject/futarchy/v0.7"; +import { + createTransferInstruction, + getAssociatedTokenAddressSync, +} from "@solana/spl-token"; +import { assert } from "chai"; +import * as squads from "@sqds/multisig"; +import { mintToOverride } from "spl-token-bankrun"; + +const THOUSAND_BUCK_PRICE = PriceMath.getAmmPrice(1000, 9, 6); + +export default function suite() { + let META: PublicKey, + dao: PublicKey, + spendingLimit: BN, + transferAmount: bigint; + let setOptimisticGovernanceEnabled: ( + dao: PublicKey, + enabled: boolean, + ) => Promise; + + beforeEach(async function () { + setOptimisticGovernanceEnabled = async ( + dao: PublicKey, + enabled: boolean, + ) => { + const daoAccount = await this.futarchy.getDao(dao); + daoAccount.isOptimisticGovernanceEnabled = enabled; + const daoAccountBuffer = + await this.futarchy.autocrat.account.dao.coder.accounts.encode( + "dao", + daoAccount, + ); + + const daoBanksAccount = await this.banksClient.getAccount(dao); + daoBanksAccount.data.set(daoAccountBuffer, 0); + this.context.setAccount(dao, daoBanksAccount); + }; + META = await this.createMint(this.payer.publicKey, 9); + spendingLimit = new BN(10_000); + transferAmount = 1000n; + // Create payer's token accounts for both mints + await this.createTokenAccount(META, this.payer.publicKey); + + // Mint tokens to payer's accounts + await this.mintTo(META, this.payer.publicKey, this.payer, 100 * 10 ** 9); + + const nonce = new BN(Math.floor(Math.random() * 1000000)); + + await this.futarchy + .initializeDaoIx({ + baseMint: META, + quoteMint: MAINNET_USDC, + params: { + secondsPerProposal: 60 * 60 * 24 * 3, + twapStartDelaySeconds: 60 * 60 * 24, + twapInitialObservation: THOUSAND_BUCK_PRICE, + twapMaxObservationChangePerUpdate: THOUSAND_BUCK_PRICE.divn(100), + minQuoteFutarchicLiquidity: new BN(0), + minBaseFutarchicLiquidity: new BN(0), + passThresholdBps: 300, + nonce, + initialSpendingLimit: { + amountPerMonth: spendingLimit, + members: [this.payer.publicKey], + }, + baseToStake: new BN(0), + teamSponsoredPassThresholdBps: 0, + teamAddress: this.payer.publicKey, + }, + provideLiquidity: true, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), + ]) + .rpc(); + + [dao] = getDaoAddr({ + nonce, + daoCreator: this.payer.publicKey, + }); + + const daoAccount = await this.futarchy.getDao(dao); + + await this.createTokenAccount(MAINNET_USDC, daoAccount.squadsMultisigVault); + + await this.transfer( + MAINNET_USDC, + this.payer, + daoAccount.squadsMultisigVault, + 100_000 * 1_000_000, + ); + + // Mint an extra 1M META tokens to the payer's account for staking to proposals + await mintToOverride( + this.context, + getAssociatedTokenAddressSync(META, this.payer.publicKey), + 1_000_000n * 10n ** 6n, + ); + }); + + it("can challenge an optimistic proposal by launching a new futarchy proposal using the same squads proposal", async function () { + await setOptimisticGovernanceEnabled(dao, true); + + await this.futarchy + .initiateVaultSpendOptimisticProposalIx({ + dao, + amount: new BN(transferAmount), + recipient: this.payer.publicKey, + transactionIndex: 1n, + }) + .signers([this.payer, PERMISSIONLESS_ACCOUNT]) + .rpc(); + + let daoAccount = await this.futarchy.getDao(dao); + + assert.exists(daoAccount.optimisticProposal); + + const [squadsProposal] = squads.getProposalPda({ + multisigPda: daoAccount.squadsMultisig, + transactionIndex: 1n, + }); + + await this.futarchy.initializeProposal(dao, squadsProposal); + + const [proposal] = getProposalAddrV2({ squadsProposal }); + + await this.futarchy + .stakeToProposalIx({ + amount: new BN(1_000_000 * 10 ** 6), + proposal, + dao, + baseMint: META, + }) + .rpc(); + + await this.futarchy + .launchProposalIx({ + proposal, + dao, + baseMint: META, + quoteMint: MAINNET_USDC, + }) + .rpc(); + + // Assert that the optimistic proposal has been migrated to the futarchy proposal + daoAccount = await this.futarchy.getDao(dao); + assert.notExists(daoAccount.optimisticProposal); + + const proposalAccount = await this.futarchy.getProposal(proposal); + assert.exists(proposalAccount.state.pending); + assert.equal( + proposalAccount.squadsProposal.toBase58(), + squadsProposal.toBase58(), + ); + }); + + it("can't challenge an optimistic proposal if it has already passed due to age", async function () { + await setOptimisticGovernanceEnabled(dao, true); + + await this.futarchy + .initiateVaultSpendOptimisticProposalIx({ + dao, + amount: new BN(transferAmount), + recipient: this.payer.publicKey, + transactionIndex: 1n, + }) + .signers([this.payer, PERMISSIONLESS_ACCOUNT]) + .rpc(); + + let daoAccount = await this.futarchy.getDao(dao); + + assert.exists(daoAccount.optimisticProposal); + + const [squadsProposal] = squads.getProposalPda({ + multisigPda: daoAccount.squadsMultisig, + transactionIndex: 1n, + }); + + // Initialize the futarchy proposal before the optimistic proposal is auto-approved + await this.futarchy.initializeProposal(dao, squadsProposal); + + this.advanceBySeconds(daoAccount.secondsPerProposal); + + const [proposal] = getProposalAddrV2({ squadsProposal }); + + await this.futarchy + .stakeToProposalIx({ + amount: new BN(1_000_000 * 10 ** 6), + proposal, + dao, + baseMint: META, + }) + .rpc(); + + const callbacks = expectError( + "OptimisticProposalAlreadyPassed", + "Optimistic proposal has already passed", + ); + + await this.futarchy + .launchProposalIx({ + proposal, + dao, + baseMint: META, + quoteMint: MAINNET_USDC, + }) + .rpc() + .then(callbacks[0], callbacks[1]); + }); +} diff --git a/tests/integration/fullLaunch_v7.test.ts b/tests/integration/fullLaunch_v7.test.ts index 4a0adeb1..c393e93b 100644 --- a/tests/integration/fullLaunch_v7.test.ts +++ b/tests/integration/fullLaunch_v7.test.ts @@ -424,7 +424,7 @@ export default async function suite() { minBaseFutarchicLiquidity: null, teamSponsoredPassThresholdBps: null, teamAddress: null, - isOptimisticGovernanceEnabled: false, + isOptimisticGovernanceEnabled: true, }, }) .instruction(); @@ -579,6 +579,7 @@ export default async function suite() { const storedDao2 = await this.futarchy.getDao(dao); assert.equal(storedDao2.passThresholdBps, 500); + assert.isTrue(storedDao2.isOptimisticGovernanceEnabled); const storedMeta = await this.getMint(META); From 27962d092218ea1030d599fdd10a9b9c9d9d1f5b Mon Sep 17 00:00:00 2001 From: Pileks Date: Sat, 10 Jan 2026 04:05:46 +0100 Subject: [PATCH 10/16] add missing check --- .../src/instructions/initialize_proposal.rs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/programs/futarchy/src/instructions/initialize_proposal.rs b/programs/futarchy/src/instructions/initialize_proposal.rs index 694a526c..05737295 100644 --- a/programs/futarchy/src/instructions/initialize_proposal.rs +++ b/programs/futarchy/src/instructions/initialize_proposal.rs @@ -36,15 +36,18 @@ pub struct InitializeProposal<'info> { impl InitializeProposal<'_> { pub fn validate(&self) -> Result<()> { - // If we're trying to initialize a proposal for an optimistic proposal that has already passed due to age, we should error + // If we're trying to challenge an optimistic proposal that has already passed due to age, we should error // This is because the optimistic proposal's Squads proposal will eventually have to be executed match self.dao.optimistic_proposal { Some(ref optimistic_proposal) => { - require_gt!( - optimistic_proposal.enqueued_timestamp + self.dao.seconds_per_proposal as i64, - Clock::get()?.unix_timestamp, - FutarchyError::OptimisticProposalAlreadyPassed - ); + 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 + ); + } } None => {} } From 2aa4420301249ca1d1299a329d6171d557b40d30 Mon Sep 17 00:00:00 2001 From: Pileks Date: Sat, 10 Jan 2026 15:35:31 +0100 Subject: [PATCH 11/16] team can't sponsor a challenge to an optimistic governance proposal --- programs/futarchy/src/error.rs | 2 ++ programs/futarchy/src/instructions/sponsor_proposal.rs | 9 +++++++++ sdk/src/v0.7/types/futarchy.ts | 10 ++++++++++ 3 files changed, 21 insertions(+) diff --git a/programs/futarchy/src/error.rs b/programs/futarchy/src/error.rs index 2e62d84e..8c38acbc 100644 --- a/programs/futarchy/src/error.rs +++ b/programs/futarchy/src/error.rs @@ -86,4 +86,6 @@ pub enum FutarchyError { NoActiveOptimisticProposal, #[msg("Optimistic proposal has already passed")] OptimisticProposalAlreadyPassed, + #[msg("Team cannot sponsor a challenge to an optimistic proposal")] + CannotSponsorOptimisticProposalChallenge, } diff --git a/programs/futarchy/src/instructions/sponsor_proposal.rs b/programs/futarchy/src/instructions/sponsor_proposal.rs index 725aa629..9e2a73a2 100644 --- a/programs/futarchy/src/instructions/sponsor_proposal.rs +++ b/programs/futarchy/src/instructions/sponsor_proposal.rs @@ -23,6 +23,15 @@ impl SponsorProposal<'_> { FutarchyError::ProposalAlreadySponsored ); + // Team cannot sponsor a challenge to an optimistic proposal + if let Some(optimistic_proposal) = &self.dao.optimistic_proposal { + require_keys_neq!( + optimistic_proposal.squads_proposal, + self.proposal.squads_proposal, + FutarchyError::CannotSponsorOptimisticProposalChallenge + ); + } + Ok(()) } diff --git a/sdk/src/v0.7/types/futarchy.ts b/sdk/src/v0.7/types/futarchy.ts index 26165b29..8a78913c 100644 --- a/sdk/src/v0.7/types/futarchy.ts +++ b/sdk/src/v0.7/types/futarchy.ts @@ -3224,6 +3224,11 @@ export type Futarchy = { name: "OptimisticProposalAlreadyPassed"; msg: "Optimistic proposal has already passed"; }, + { + code: 6041; + name: "CannotSponsorOptimisticProposalChallenge"; + msg: "Team cannot sponsor a challenge to an optimistic proposal"; + }, ]; }; @@ -6453,5 +6458,10 @@ export const IDL: Futarchy = { name: "OptimisticProposalAlreadyPassed", msg: "Optimistic proposal has already passed", }, + { + code: 6041, + name: "CannotSponsorOptimisticProposalChallenge", + msg: "Team cannot sponsor a challenge to an optimistic proposal", + }, ], }; From 0d55d111123318ba6794420c859430ff628b1108 Mon Sep 17 00:00:00 2001 From: Pileks Date: Fri, 16 Jan 2026 15:24:42 -0800 Subject: [PATCH 12/16] work-in-progress PR comments --- programs/futarchy/src/error.rs | 2 ++ .../finalize_optimistic_proposal.rs | 10 +++--- .../src/instructions/initialize_proposal.rs | 21 +++++------ ...nitiate_vault_spend_optimistic_proposal.rs | 35 +++++++++---------- sdk/src/v0.7/types/futarchy.ts | 10 ++++++ 5 files changed, 42 insertions(+), 36 deletions(-) diff --git a/programs/futarchy/src/error.rs b/programs/futarchy/src/error.rs index 8c38acbc..97823dfe 100644 --- a/programs/futarchy/src/error.rs +++ b/programs/futarchy/src/error.rs @@ -88,4 +88,6 @@ pub enum FutarchyError { 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, } diff --git a/programs/futarchy/src/instructions/finalize_optimistic_proposal.rs b/programs/futarchy/src/instructions/finalize_optimistic_proposal.rs index a8dcbb09..164e3d66 100644 --- a/programs/futarchy/src/instructions/finalize_optimistic_proposal.rs +++ b/programs/futarchy/src/instructions/finalize_optimistic_proposal.rs @@ -33,12 +33,10 @@ impl FinalizeOptimisticProposal<'_> { // Pool must be in spot state - no active proposals // This should never be hit, but it's here for completeness - match self.dao.amm.state { - PoolState::Spot { spot: _ } => {} - _ => { - return Err(FutarchyError::PoolNotInSpotState.into()); - } - } + require!( + matches!(self.dao.amm.state, PoolState::Spot { .. }), + FutarchyError::PoolNotInSpotState + ); Ok(()) } diff --git a/programs/futarchy/src/instructions/initialize_proposal.rs b/programs/futarchy/src/instructions/initialize_proposal.rs index 05737295..19265fd5 100644 --- a/programs/futarchy/src/instructions/initialize_proposal.rs +++ b/programs/futarchy/src/instructions/initialize_proposal.rs @@ -37,19 +37,16 @@ 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 - // This is because the optimistic proposal's Squads proposal will eventually have to be executed - match self.dao.optimistic_proposal { - Some(ref 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 - ); - } + // 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 + ); } - None => {} } require_eq!( diff --git a/programs/futarchy/src/instructions/initiate_vault_spend_optimistic_proposal.rs b/programs/futarchy/src/instructions/initiate_vault_spend_optimistic_proposal.rs index e463d3ee..28b6e074 100644 --- a/programs/futarchy/src/instructions/initiate_vault_spend_optimistic_proposal.rs +++ b/programs/futarchy/src/instructions/initiate_vault_spend_optimistic_proposal.rs @@ -60,12 +60,10 @@ impl InitiateVaultSpendOptimisticProposal<'_> { ); // Pool must be in spot state - no active proposal - match self.dao.amm.state { - PoolState::Spot { spot: _ } => {} - _ => { - return Err(FutarchyError::PoolNotInSpotState.into()); - } - } + require!( + matches!(self.dao.amm.state, PoolState::Spot { spot: _ }), + FutarchyError::PoolNotInSpotState + ); // There should be no active optimistic proposal require!( @@ -73,21 +71,22 @@ impl InitiateVaultSpendOptimisticProposal<'_> { FutarchyError::ActiveOptimisticProposalAlreadyEnqueued ); - // A minimum of proposal duration must have passed since the last optimistic proposal was enqueued - match self.dao.optimistic_proposal { - Some(ref optimistic_proposal) => { - require_gte!( - Clock::get()?.unix_timestamp, - optimistic_proposal.enqueued_timestamp + self.dao.seconds_per_proposal as i64, - FutarchyError::ProposalDurationTooShort - ); - } - None => {} - }; + // No existing optimistic proposal can be present. If one has passed, it can be finalized (finalize_optimistic_proposal) + require!( + self.dao.optimistic_proposal.is_none(), + FutarchyError::ProposalDurationTooShort + ); + + // Spending limit mint must be the same as the DAO's quote mint + require_eq!( + self.squads_spending_limit.mint, + self.dao.quote_mint, + FutarchyError::InvalidSpendingLimitMint + ); // Amount must be less than or equal to 3 times the spending limit require_gte!( - self.squads_spending_limit.amount.checked_mul(3).unwrap(), + self.squads_spending_limit.amount * 3, params.amount, FutarchyError::InvalidAmount ); diff --git a/sdk/src/v0.7/types/futarchy.ts b/sdk/src/v0.7/types/futarchy.ts index 8a78913c..d932e3b4 100644 --- a/sdk/src/v0.7/types/futarchy.ts +++ b/sdk/src/v0.7/types/futarchy.ts @@ -3229,6 +3229,11 @@ export type Futarchy = { name: "CannotSponsorOptimisticProposalChallenge"; msg: "Team cannot sponsor a challenge to an optimistic proposal"; }, + { + code: 6042; + name: "InvalidSpendingLimitMint"; + msg: "Invalid spending limit mint. Must be the same as the DAO's quote mint"; + }, ]; }; @@ -6463,5 +6468,10 @@ export const IDL: Futarchy = { name: "CannotSponsorOptimisticProposalChallenge", msg: "Team cannot sponsor a challenge to an optimistic proposal", }, + { + code: 6042, + name: "InvalidSpendingLimitMint", + msg: "Invalid spending limit mint. Must be the same as the DAO's quote mint", + }, ], }; From c98ce2e13da18bf16fd3fdf667d6dd7a9c72de91 Mon Sep 17 00:00:00 2001 From: Pileks Date: Fri, 16 Jan 2026 16:03:48 -0800 Subject: [PATCH 13/16] address review comments --- .../src/instructions/launch_proposal.rs | 23 ++- .../src/instructions/sponsor_proposal.rs | 9 - ...itiateVaultSpendOptimisticProposal.test.ts | 183 +++++++++++++++++- tests/futarchy/unit/launchProposal.test.ts | 2 + 4 files changed, 201 insertions(+), 16 deletions(-) diff --git a/programs/futarchy/src/instructions/launch_proposal.rs b/programs/futarchy/src/instructions/launch_proposal.rs index 9db37fca..f6f87ef1 100644 --- a/programs/futarchy/src/instructions/launch_proposal.rs +++ b/programs/futarchy/src/instructions/launch_proposal.rs @@ -72,6 +72,17 @@ impl LaunchProposal<'_> { ); } + // Can only launch a proposal if the underlying squads proposal is active + // This check exists mainly to prevent a situation where we try to launch a proposal for a passed optimistic proposal. + // However, it also applies in general, to prevent a situation where we enter futarchy with an invalid squads proposal state, thus bricking it. + require!( + matches!( + self.squads_proposal.status, + squads_multisig_program::ProposalStatus::Active { .. } + ), + FutarchyError::InvalidSquadsProposalStatus + ); + // Ensure the squads proposal is not invalidated by a previous config transaction require_gt!( self.squads_proposal.transaction_index, @@ -168,14 +179,16 @@ impl LaunchProposal<'_> { proposal.state = ProposalState::Pending; proposal.timestamp_enqueued = clock.unix_timestamp; - // Update the DAO state + // If this is moving an optimistic proposal into the futarchy proposal, the futarchy proposal will be treated as team-sponsored (lower pass threshold) if dao.optimistic_proposal.is_some() { - // The optimistic proposal is being challenged, so we are moving it into the futarchy proposal - // This means that the optimistic proposal now has to pass a decision market in order to be approved/executed - // verify() ensures that the optimistic proposal and squads proposal are the same - dao.optimistic_proposal = None; + proposal.is_team_sponsored = true; } + // Update the DAO state + // There either is no optimistic proposal, or the optimistic proposal is being moved into the futarchy proposal + // This means that the optimistic proposal now has to pass a decision market in order to be approved/executed + dao.optimistic_proposal = None; + dao.seq_num += 1; emit_cpi!(LaunchProposalEvent { diff --git a/programs/futarchy/src/instructions/sponsor_proposal.rs b/programs/futarchy/src/instructions/sponsor_proposal.rs index 9e2a73a2..725aa629 100644 --- a/programs/futarchy/src/instructions/sponsor_proposal.rs +++ b/programs/futarchy/src/instructions/sponsor_proposal.rs @@ -23,15 +23,6 @@ impl SponsorProposal<'_> { FutarchyError::ProposalAlreadySponsored ); - // Team cannot sponsor a challenge to an optimistic proposal - if let Some(optimistic_proposal) = &self.dao.optimistic_proposal { - require_keys_neq!( - optimistic_proposal.squads_proposal, - self.proposal.squads_proposal, - FutarchyError::CannotSponsorOptimisticProposalChallenge - ); - } - Ok(()) } diff --git a/tests/futarchy/unit/initiateVaultSpendOptimisticProposal.test.ts b/tests/futarchy/unit/initiateVaultSpendOptimisticProposal.test.ts index f19a643d..5d9333b2 100644 --- a/tests/futarchy/unit/initiateVaultSpendOptimisticProposal.test.ts +++ b/tests/futarchy/unit/initiateVaultSpendOptimisticProposal.test.ts @@ -54,8 +54,8 @@ export default function suite() { twapStartDelaySeconds: 60 * 60 * 24, twapInitialObservation: THOUSAND_BUCK_PRICE, twapMaxObservationChangePerUpdate: THOUSAND_BUCK_PRICE.divn(100), - minQuoteFutarchicLiquidity: new BN(10_000), - minBaseFutarchicLiquidity: new BN(10_000), + minQuoteFutarchicLiquidity: new BN(1), + minBaseFutarchicLiquidity: new BN(1), passThresholdBps: 300, nonce, initialSpendingLimit: { @@ -89,6 +89,16 @@ export default function suite() { 100_000 * 1_000_000, ); + await this.futarchy + .provideLiquidityIx({ + dao, + baseMint: META, + quoteMint: MAINNET_USDC, + maxBaseAmount: new BN(100_000 * 10 ** 6), + quoteAmount: new BN(100_000 * 10 ** 6), + }) + .rpc(); + await setOptimisticGovernanceEnabled(dao, true); }); @@ -123,6 +133,175 @@ export default function suite() { ); }); + it("can still initiate a vault spend optimistic proposal after a DAO changes their spending limit", async function () { + const multisigPda = squads.getMultisigPda({ createKey: dao })[0]; + const daoSpendingLimitPda = squads.getSpendingLimitPda({ + multisigPda, + createKey: dao, + })[0]; + + const removeSpendingLimitIx = + squads.instructions.multisigRemoveSpendingLimit({ + multisigPda, + configAuthority: dao, + spendingLimit: daoSpendingLimitPda, + rentCollector: this.payer.publicKey, + memo: "", + }); + + const addSpendingLimitIx = squads.instructions.multisigAddSpendingLimit({ + multisigPda, + spendingLimit: daoSpendingLimitPda, + configAuthority: dao, + rentPayer: this.payer.publicKey, + createKey: dao, + vaultIndex: 0, + mint: MAINNET_USDC, + amount: BigInt(spendingLimit.muln(3).toString()), // 30,000 USDC + period: squads.types.Period.Month, + members: [this.payer.publicKey], // Only the DAO can use this spending limit + destinations: [], // No specific destinations + memo: "", + }); + + const { proposal, squadsProposal } = await this.initializeAndLaunchProposal( + { + dao, + instructions: [removeSpendingLimitIx, addSpendingLimitIx], + }, + ); + + const { question, quoteVault } = this.futarchy.getProposalPdas( + proposal, + META, + MAINNET_USDC, + dao, + ); + + await this.conditionalVault + .splitTokensIx( + question, + quoteVault, + MAINNET_USDC, + new BN(11_000 * 1_000_000), + 2, + ) + .rpc(); + + // Trade heavily on pass market to make it pass + await this.futarchy + .conditionalSwapIx({ + dao, + baseMint: META, + quoteMint: MAINNET_USDC, + proposal, + market: "pass", + swapType: "buy", + inputAmount: new BN(10_000 * 1_000_000), + minOutputAmount: new BN(0), + }) + .rpc(); + + // Crank TWAP to build up price history + for (let i = 0; i < 100; i++) { + this.advanceBySeconds(10_000); + + await this.futarchy + .conditionalSwapIx({ + dao, + baseMint: META, + quoteMint: MAINNET_USDC, + proposal, + market: "pass", + swapType: "buy", + inputAmount: new BN(10), + minOutputAmount: new BN(0), + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitPrice({ microLamports: i }), + ]) + .rpc(); + } + + // Finalize the proposal + await this.futarchy.finalizeProposal(proposal); + + const storedProposal = await this.futarchy.getProposal(proposal); + assert.exists(storedProposal.state.passed); + + const [vaultTransactionPda] = squads.getTransactionPda({ + multisigPda: multisigPda, + index: 1n, + }); + + const transactionAccount = + await squads.accounts.VaultTransaction.fromAccountAddress( + this.squadsConnection, + vaultTransactionPda, + ); + + const [vaultPda] = squads.getVaultPda({ + multisigPda, + index: transactionAccount.vaultIndex, + programId: squads.PROGRAM_ID, + }); + + const { accountMetas } = await squads.utils.accountsForTransactionExecute({ + connection: this.squadsConnection, + message: transactionAccount.message, + ephemeralSignerBumps: [...transactionAccount.ephemeralSignerBumps], + vaultPda, + transactionPda: vaultTransactionPda, + programId: squads.PROGRAM_ID, + }); + + await this.futarchy.autocrat.methods + .executeSpendingLimitChange() + .accounts({ + squadsMultisig: multisigPda, + proposal, + dao, + squadsProposal, + squadsMultisigProgram: squads.PROGRAM_ID, + vaultTransaction: vaultTransactionPda, + }) + .remainingAccounts( + accountMetas.map((meta) => + meta.pubkey.equals(dao) ? { ...meta, isSigner: false } : meta, + ), + ) + .rpc(); + + await this.futarchy + .initiateVaultSpendOptimisticProposalIx({ + dao, + amount: new BN(1000), + recipient: this.payer.publicKey, + transactionIndex: 2n, + }) + .signers([this.payer, PERMISSIONLESS_ACCOUNT]) + .rpc(); + + const daoAccount = await this.futarchy.getDao(dao); + + assert.exists(daoAccount.optimisticProposal); + + const clock = await this.banksClient.getClock(); + assert.equal( + daoAccount.optimisticProposal.enqueuedTimestamp.toString(), + clock.unixTimestamp.toString(), + ); + + const [expectedSquadsProposal] = squads.getProposalPda({ + multisigPda: daoAccount.squadsMultisig, + transactionIndex: 2n, + }); + assert.equal( + expectedSquadsProposal.toBase58(), + daoAccount.optimisticProposal.squadsProposal.toBase58(), + ); + }); + it("can't initiate a vault spend optimistic proposal if the DAO doesn't have optimistic governance enabled", async function () { await setOptimisticGovernanceEnabled(dao, false); diff --git a/tests/futarchy/unit/launchProposal.test.ts b/tests/futarchy/unit/launchProposal.test.ts index 3cf4e3f6..f2209a3c 100644 --- a/tests/futarchy/unit/launchProposal.test.ts +++ b/tests/futarchy/unit/launchProposal.test.ts @@ -153,6 +153,7 @@ export default function suite() { dao, baseMint: META, quoteMint: MAINNET_USDC, + squadsProposal, }) .rpc(); @@ -217,6 +218,7 @@ export default function suite() { dao, baseMint: META, quoteMint: MAINNET_USDC, + squadsProposal, }) .rpc() .then(callbacks[0], callbacks[1]); From 484e7adc263ebffda9e581a128aed0da2726dfa2 Mon Sep 17 00:00:00 2001 From: Pileks Date: Fri, 16 Jan 2026 17:02:19 -0800 Subject: [PATCH 14/16] reintroduce resize_dao crankable instruction with scripts --- Anchor.toml | 2 + programs/futarchy/src/instructions/mod.rs | 2 + .../futarchy/src/instructions/resize_dao.rs | 83 ++++ programs/futarchy/src/lib.rs | 4 + programs/futarchy/src/state/dao.rs | 56 +++ scripts/v0.6/dumpDaos.ts | 90 ++++ scripts/v0.6/migrateDaos.ts | 133 +++++ sdk/src/v0.6/types/futarchy.ts | 466 ++++++++++++++++++ sdk/src/v0.7/types/futarchy.ts | 314 ++++++++++++ 9 files changed, 1150 insertions(+) create mode 100644 programs/futarchy/src/instructions/resize_dao.rs create mode 100644 scripts/v0.6/dumpDaos.ts create mode 100644 scripts/v0.6/migrateDaos.ts diff --git a/Anchor.toml b/Anchor.toml index 10a9ab03..1ade3f21 100644 --- a/Anchor.toml +++ b/Anchor.toml @@ -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" diff --git a/programs/futarchy/src/instructions/mod.rs b/programs/futarchy/src/instructions/mod.rs index 85b8023e..90e20456 100644 --- a/programs/futarchy/src/instructions/mod.rs +++ b/programs/futarchy/src/instructions/mod.rs @@ -12,6 +12,7 @@ pub mod initialize_proposal; pub mod initiate_vault_spend_optimistic_proposal; pub mod launch_proposal; pub mod provide_liquidity; +pub mod resize_dao; pub mod sponsor_proposal; pub mod spot_swap; pub mod stake_to_proposal; @@ -31,6 +32,7 @@ pub use initialize_proposal::*; pub use initiate_vault_spend_optimistic_proposal::*; pub use launch_proposal::*; pub use provide_liquidity::*; +pub use resize_dao::*; pub use sponsor_proposal::*; pub use spot_swap::*; pub use stake_to_proposal::*; diff --git a/programs/futarchy/src/instructions/resize_dao.rs b/programs/futarchy/src/instructions/resize_dao.rs new file mode 100644 index 00000000..6352ac11 --- /dev/null +++ b/programs/futarchy/src/instructions/resize_dao.rs @@ -0,0 +1,83 @@ +use anchor_lang::{system_program, Discriminator}; + +use super::*; + +#[derive(Accounts)] +pub struct ResizeDao<'info> { + /// CHECK: we check the discriminator + #[account(mut)] + pub dao: UncheckedAccount<'info>, + #[account(mut)] + pub payer: Signer<'info>, + pub system_program: Program<'info, System>, +} + +impl ResizeDao<'_> { + pub fn handle(ctx: Context) -> Result<()> { + let dao = &ctx.accounts.dao; + + require_eq!(dao.owner, &crate::ID); + let is_discriminator_correct = dao.try_borrow_data().unwrap()[..8] == Dao::discriminator(); + require_eq!(is_discriminator_correct, true); + + const AFTER_REALLOC_SIZE: usize = Dao::INIT_SPACE + 8; + // 42 bytes: 1 (Option discriminant) + 32 (Pubkey) + 8 (i64) + 1 (bool) + const BEFORE_REALLOC_SIZE: usize = AFTER_REALLOC_SIZE - 42; + + if dao.data_len() != BEFORE_REALLOC_SIZE { + // already realloced + require_eq!(dao.data_len(), AFTER_REALLOC_SIZE); + return Ok(()); + } + + let old_dao_data = OldDao::deserialize(&mut &dao.try_borrow_data().unwrap()[8..])?; + + let new_dao_data = Dao { + amm: old_dao_data.amm, + nonce: old_dao_data.nonce, + dao_creator: old_dao_data.dao_creator, + pda_bump: old_dao_data.pda_bump, + squads_multisig: old_dao_data.squads_multisig, + squads_multisig_vault: old_dao_data.squads_multisig_vault, + base_mint: old_dao_data.base_mint, + quote_mint: old_dao_data.quote_mint, + proposal_count: old_dao_data.proposal_count, + pass_threshold_bps: old_dao_data.pass_threshold_bps, + seconds_per_proposal: old_dao_data.seconds_per_proposal, + twap_initial_observation: old_dao_data.twap_initial_observation, + twap_max_observation_change_per_update: old_dao_data + .twap_max_observation_change_per_update, + twap_start_delay_seconds: old_dao_data.twap_start_delay_seconds, + min_quote_futarchic_liquidity: old_dao_data.min_quote_futarchic_liquidity, + min_base_futarchic_liquidity: old_dao_data.min_base_futarchic_liquidity, + base_to_stake: old_dao_data.base_to_stake, + seq_num: old_dao_data.seq_num, + initial_spending_limit: old_dao_data.initial_spending_limit, + team_sponsored_pass_threshold_bps: old_dao_data.team_sponsored_pass_threshold_bps, + team_address: old_dao_data.team_address, + optimistic_proposal: None, + is_optimistic_governance_enabled: false, + }; + + dao.realloc(AFTER_REALLOC_SIZE, true)?; + + let lamports_needed = Rent::get()?.minimum_balance(AFTER_REALLOC_SIZE); + + if lamports_needed > dao.lamports() { + system_program::transfer( + CpiContext::new( + ctx.accounts.system_program.to_account_info(), + system_program::Transfer { + from: ctx.accounts.payer.to_account_info(), + to: dao.to_account_info(), + }, + ), + lamports_needed - dao.lamports(), + )?; + } + + new_dao_data.serialize(&mut &mut dao.try_borrow_mut_data().unwrap()[8..])?; + + Ok(()) + } +} diff --git a/programs/futarchy/src/lib.rs b/programs/futarchy/src/lib.rs index 1c0f4eba..0eaaa4c7 100644 --- a/programs/futarchy/src/lib.rs +++ b/programs/futarchy/src/lib.rs @@ -100,6 +100,10 @@ pub mod futarchy { UpdateDao::handle(ctx, dao_params) } + pub fn resize_dao(ctx: Context) -> Result<()> { + ResizeDao::handle(ctx) + } + // AMM instructions pub fn spot_swap(ctx: Context, params: SpotSwapParams) -> Result<()> { diff --git a/programs/futarchy/src/state/dao.rs b/programs/futarchy/src/state/dao.rs index dfed8ac6..0d898f63 100644 --- a/programs/futarchy/src/state/dao.rs +++ b/programs/futarchy/src/state/dao.rs @@ -110,3 +110,59 @@ impl Dao { Ok(()) } } + +#[account] +#[derive(InitSpace)] +pub struct OldDao { + /// Embedded FutarchyAmm - 1:1 relationship + pub amm: FutarchyAmm, + /// `nonce` + `dao_creator` are PDA seeds + pub nonce: u64, + pub dao_creator: Pubkey, + pub pda_bump: u8, + pub squads_multisig: Pubkey, + pub squads_multisig_vault: Pubkey, + pub base_mint: Pubkey, + pub quote_mint: Pubkey, + pub proposal_count: u32, + // the percentage, in basis points, the pass price needs to be above the + // fail price in order for the proposal to pass + pub pass_threshold_bps: u16, + pub seconds_per_proposal: u32, + /// For manipulation-resistance the TWAP is a time-weighted average observation, + /// where observation tries to approximate price but can only move by + /// `twap_max_observation_change_per_update` per update. Because it can only move + /// a little bit per update, you need to check that it has a good initial observation. + /// Otherwise, an attacker could create a very high initial observation in the pass + /// market and a very low one in the fail market to force the proposal to pass. + /// + /// We recommend setting an initial observation around the spot price of the token, + /// and max observation change per update around 2% the spot price of the token. + /// For example, if the spot price of META is $400, we'd recommend setting an initial + /// observation of 400 (converted into the AMM prices) and a max observation change per + /// update of 8 (also converted into the AMM prices). Observations can be updated once + /// a minute, so 2% allows the proposal market to reach double the spot price or 0 + /// in 50 minutes. + pub twap_initial_observation: u128, + pub twap_max_observation_change_per_update: u128, + /// Forces TWAP calculation to start after `twap_start_delay_seconds` seconds + pub twap_start_delay_seconds: u32, + /// As an anti-spam measure and to help liquidity, you need to lock up some liquidity + /// in both futarchic markets in order to create a proposal. + /// + /// For example, for META, we can use a `min_quote_futarchic_liquidity` of + /// 5000 * 1_000_000 (5000 USDC) and a `min_base_futarchic_liquidity` of + /// 10 * 1_000_000_000 (10 META). + pub min_quote_futarchic_liquidity: u64, + pub min_base_futarchic_liquidity: u64, + /// Minimum amount of base tokens that must be staked to launch a proposal + pub base_to_stake: u64, + pub seq_num: u64, + pub initial_spending_limit: Option, + /// The percentage, in basis points, the pass price needs to be above the + /// fail price in order for the proposal to pass for team-sponsored proposals. + /// + /// Can be negative to allow for team-sponsored proposals to pass by default. + pub team_sponsored_pass_threshold_bps: i16, + pub team_address: Pubkey, +} diff --git a/scripts/v0.6/dumpDaos.ts b/scripts/v0.6/dumpDaos.ts new file mode 100644 index 00000000..33bb5d55 --- /dev/null +++ b/scripts/v0.6/dumpDaos.ts @@ -0,0 +1,90 @@ +import { PublicKey } from "@solana/web3.js"; +import * as anchor from "@coral-xyz/anchor"; +import { FutarchyClient } from "@metadaoproject/futarchy/v0.6"; +import dotenv from "dotenv"; +import * as fs from "fs"; +import * as path from "path"; +import bs58 from "bs58"; + +dotenv.config(); + +const provider = anchor.AnchorProvider.env(); + +function getDiscriminator(accountName: string): Buffer { + return Buffer.from( + anchor.BorshAccountsCoder.accountDiscriminator(accountName), + ); +} + +async function dumpAccount( + publicKey: PublicKey, + outputDir: string, + accountType: string, +) { + const accountInfo = await provider.connection.getAccountInfo(publicKey); + + if (!accountInfo) { + console.error(`Account ${publicKey.toBase58()} not found`); + return; + } + + const accountData = { + pubkey: publicKey.toBase58(), + account: { + lamports: accountInfo.lamports, + data: [accountInfo.data.toString("base64"), "base64"], + owner: accountInfo.owner.toBase58(), + executable: accountInfo.executable, + rentEpoch: "U64_MAX_PLACEHOLDER", + }, + }; + + const filename = path.join(outputDir, `${publicKey.toBase58()}.json`); + fs.writeFileSync( + filename, + JSON.stringify(accountData, null, 2).replace( + '"U64_MAX_PLACEHOLDER"', + "18446744073709551615", + ), + ); + + console.log(`Dumped ${accountType}: ${publicKey.toBase58()}`); +} + +async function main() { + const futarchy = FutarchyClient.createClient({ provider }); + + const daosDir = "daos"; + if (!fs.existsSync(daosDir)) { + fs.mkdirSync(daosDir); + } + + const daoDiscriminator = getDiscriminator("Dao"); + console.log(`DAO discriminator (hex): ${daoDiscriminator.toString("hex")}`); + console.log(`DAO discriminator (base58): ${bs58.encode(daoDiscriminator)}`); + console.log(`Program ID: ${futarchy.futarchy.programId.toBase58()}\n`); + + const daoAccounts = await provider.connection.getProgramAccounts( + futarchy.futarchy.programId, + { + filters: [ + { + memcmp: { + offset: 0, + bytes: bs58.encode(daoDiscriminator), + }, + }, + ], + }, + ); + + console.log(`Found ${daoAccounts.length} DAOs`); + for (const { pubkey } of daoAccounts) { + await dumpAccount(pubkey, daosDir, "DAO"); + } +} + +main().catch((error) => { + console.error("Fatal error:", error); + process.exit(1); +}); diff --git a/scripts/v0.6/migrateDaos.ts b/scripts/v0.6/migrateDaos.ts new file mode 100644 index 00000000..427b3f7a --- /dev/null +++ b/scripts/v0.6/migrateDaos.ts @@ -0,0 +1,133 @@ +import { + ComputeBudgetProgram, + Keypair, + TransactionInstruction, + VersionedTransaction, + TransactionMessage, +} from "@solana/web3.js"; +import * as anchor from "@coral-xyz/anchor"; +import { FutarchyClient } from "@metadaoproject/futarchy/v0.6"; +import dotenv from "dotenv"; +import bs58 from "bs58"; + +dotenv.config(); + +const provider = anchor.AnchorProvider.env(); +const payer = provider.wallet["payer"]; + +function getDiscriminator(accountName: string): Buffer { + return Buffer.from( + anchor.BorshAccountsCoder.accountDiscriminator(accountName), + ); +} + +async function sendAndConfirmTransaction( + ixs: TransactionInstruction[], + label: string, + signers: Keypair[] = [], +) { + const { blockhash } = await provider.connection.getLatestBlockhash(); + + const messageV0 = new TransactionMessage({ + instructions: ixs, + payerKey: payer.publicKey, + recentBlockhash: blockhash, + }).compileToV0Message(); + const simulationTx = new VersionedTransaction(messageV0); + simulationTx.sign([payer, ...signers]); + + const simulationResult = + await provider.connection.simulateTransaction(simulationTx); + + const computeBudgetIx = ComputeBudgetProgram.setComputeUnitLimit({ + units: Math.ceil(simulationResult.value.unitsConsumed! * 1.15), + }); + + const finalMessageV0 = new TransactionMessage({ + instructions: [computeBudgetIx, ...ixs], + payerKey: payer.publicKey, + recentBlockhash: blockhash, + }).compileToV0Message(); + const tx = new VersionedTransaction(finalMessageV0); + tx.sign([payer, ...signers]); + + const txHash = await provider.connection.sendRawTransaction(tx.serialize()); + console.log(`${label} transaction sent:`, txHash); + + await provider.connection.confirmTransaction(txHash, "confirmed"); + const txStatus = await provider.connection.getTransaction(txHash, { + maxSupportedTransactionVersion: 0, + commitment: "confirmed", + }); + if (txStatus?.meta?.err) { + throw new Error( + `Transaction failed: ${txHash}\nError: ${JSON.stringify( + txStatus?.meta?.err, + )}\n\n${txStatus?.meta?.logMessages?.join("\n")}`, + ); + } + console.log(`${label} transaction confirmed`); + return txHash; +} + +async function main() { + const futarchy = FutarchyClient.createClient({ provider }); + + const daoDiscriminator = getDiscriminator("Dao"); + const daoBatchSize = 15; + + console.log(`DAO discriminator (hex): ${daoDiscriminator.toString("hex")}`); + console.log(`DAO discriminator (base58): ${bs58.encode(daoDiscriminator)}`); + console.log(`Program ID: ${futarchy.futarchy.programId.toBase58()}\n`); + + const daoAccounts = await provider.connection.getProgramAccounts( + futarchy.futarchy.programId, + { + filters: [ + { + memcmp: { + offset: 0, + bytes: bs58.encode(daoDiscriminator), + }, + }, + ], + }, + ); + + console.log(`Found ${daoAccounts.length} DAOs`); + for (let i = 0; i < daoAccounts.length; i += daoBatchSize) { + const batch = daoAccounts.slice( + i, + Math.min(i + daoBatchSize, daoAccounts.length), + ); + console.log( + `Processing batch ${Math.floor(i / daoBatchSize) + 1} with ${batch.length} DAOs`, + ); + + const ixs = await Promise.all( + batch.map(async ({ pubkey }) => { + return await futarchy.futarchy.methods + .resizeDao() + .accounts({ + dao: pubkey, + payer: payer.publicKey, + }) + .instruction(); + }), + ); + + await sendAndConfirmTransaction( + ixs, + `Resize DAOs batch ${Math.floor(i / daoBatchSize) + 1}`, + ); + } + + console.log("Confirming DAOs can be loaded through SDK..."); + const daos = await futarchy.futarchy.account.dao.all(); + console.log(`Confirmed ${daos.length} DAOs`); +} + +main().catch((error) => { + console.error("Fatal error:", error); + process.exit(1); +}); diff --git a/sdk/src/v0.6/types/futarchy.ts b/sdk/src/v0.6/types/futarchy.ts index d9d95cdc..99270d12 100644 --- a/sdk/src/v0.6/types/futarchy.ts +++ b/sdk/src/v0.6/types/futarchy.ts @@ -118,6 +118,11 @@ export type Futarchy = { isMut: false; isSigner: false; }, + { + name: "squadsMultisig"; + isMut: false; + isSigner: false; + }, { name: "dao"; isMut: true; @@ -370,6 +375,16 @@ export type Futarchy = { isMut: true; isSigner: false; }, + { + name: "squadsMultisig"; + isMut: false; + isSigner: false; + }, + { + name: "squadsProposal"; + isMut: false; + isSigner: false; + }, { name: "systemProgram"; isMut: false; @@ -562,6 +577,27 @@ export type Futarchy = { }, ]; }, + { + name: "resizeDao"; + accounts: [ + { + name: "dao"; + isMut: true; + isSigner: false; + }, + { + name: "payer"; + isMut: true; + isSigner: true; + }, + { + name: "systemProgram"; + isMut: false; + isSigner: false; + }, + ]; + args: []; + }, { name: "spotSwap"; accounts: [ @@ -1304,6 +1340,52 @@ export type Futarchy = { ]; args: []; }, + { + name: "adminApproveExecuteMultisigProposal"; + accounts: [ + { + name: "dao"; + isMut: true; + isSigner: false; + }, + { + name: "admin"; + isMut: true; + isSigner: true; + }, + { + name: "squadsMultisig"; + isMut: true; + isSigner: false; + }, + { + name: "squadsMultisigProposal"; + isMut: true; + isSigner: false; + }, + { + name: "squadsMultisigVaultTransaction"; + isMut: true; + isSigner: false; + }, + { + name: "squadsMultisigProgram"; + isMut: false; + isSigner: false; + }, + { + name: "eventAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "program"; + isMut: false; + isSigner: false; + }, + ]; + args: []; + }, ]; accounts: [ { @@ -1474,6 +1556,142 @@ export type Futarchy = { ]; }; }, + { + name: "oldDao"; + type: { + kind: "struct"; + fields: [ + { + name: "amm"; + docs: ["Embedded FutarchyAmm - 1:1 relationship"]; + type: { + defined: "FutarchyAmm"; + }; + }, + { + name: "nonce"; + docs: ["`nonce` + `dao_creator` are PDA seeds"]; + type: "u64"; + }, + { + name: "daoCreator"; + type: "publicKey"; + }, + { + name: "pdaBump"; + type: "u8"; + }, + { + name: "squadsMultisig"; + type: "publicKey"; + }, + { + name: "squadsMultisigVault"; + type: "publicKey"; + }, + { + name: "baseMint"; + type: "publicKey"; + }, + { + name: "quoteMint"; + type: "publicKey"; + }, + { + name: "proposalCount"; + type: "u32"; + }, + { + name: "passThresholdBps"; + type: "u16"; + }, + { + name: "secondsPerProposal"; + type: "u32"; + }, + { + name: "twapInitialObservation"; + docs: [ + "For manipulation-resistance the TWAP is a time-weighted average observation,", + "where observation tries to approximate price but can only move by", + "`twap_max_observation_change_per_update` per update. Because it can only move", + "a little bit per update, you need to check that it has a good initial observation.", + "Otherwise, an attacker could create a very high initial observation in the pass", + "market and a very low one in the fail market to force the proposal to pass.", + "", + "We recommend setting an initial observation around the spot price of the token,", + "and max observation change per update around 2% the spot price of the token.", + "For example, if the spot price of META is $400, we'd recommend setting an initial", + "observation of 400 (converted into the AMM prices) and a max observation change per", + "update of 8 (also converted into the AMM prices). Observations can be updated once", + "a minute, so 2% allows the proposal market to reach double the spot price or 0", + "in 50 minutes.", + ]; + type: "u128"; + }, + { + name: "twapMaxObservationChangePerUpdate"; + type: "u128"; + }, + { + name: "twapStartDelaySeconds"; + docs: [ + "Forces TWAP calculation to start after `twap_start_delay_seconds` seconds", + ]; + type: "u32"; + }, + { + name: "minQuoteFutarchicLiquidity"; + docs: [ + "As an anti-spam measure and to help liquidity, you need to lock up some liquidity", + "in both futarchic markets in order to create a proposal.", + "", + "For example, for META, we can use a `min_quote_futarchic_liquidity` of", + "5000 * 1_000_000 (5000 USDC) and a `min_base_futarchic_liquidity` of", + "10 * 1_000_000_000 (10 META).", + ]; + type: "u64"; + }, + { + name: "minBaseFutarchicLiquidity"; + type: "u64"; + }, + { + name: "baseToStake"; + docs: [ + "Minimum amount of base tokens that must be staked to launch a proposal", + ]; + type: "u64"; + }, + { + name: "seqNum"; + type: "u64"; + }, + { + name: "initialSpendingLimit"; + type: { + option: { + defined: "InitialSpendingLimit"; + }; + }; + }, + { + name: "teamSponsoredPassThresholdBps"; + docs: [ + "The percentage, in basis points, the pass price needs to be above the", + "fail price in order for the proposal to pass for team-sponsored proposals.", + "", + "Can be negative to allow for team-sponsored proposals to pass by default.", + ]; + type: "i16"; + }, + { + name: "teamAddress"; + type: "publicKey"; + }, + ]; + }; + }, { name: "proposal"; type: { @@ -3219,6 +3437,21 @@ export type Futarchy = { name: "NoActiveOptimisticProposal"; msg: "No active optimistic proposal"; }, + { + code: 6040; + name: "OptimisticProposalAlreadyPassed"; + msg: "Optimistic proposal has already passed"; + }, + { + code: 6041; + name: "CannotSponsorOptimisticProposalChallenge"; + msg: "Team cannot sponsor a challenge to an optimistic proposal"; + }, + { + code: 6042; + name: "InvalidSpendingLimitMint"; + msg: "Invalid spending limit mint. Must be the same as the DAO's quote mint"; + }, ]; }; @@ -3342,6 +3575,11 @@ export const IDL: Futarchy = { isMut: false, isSigner: false, }, + { + name: "squadsMultisig", + isMut: false, + isSigner: false, + }, { name: "dao", isMut: true, @@ -3594,6 +3832,16 @@ export const IDL: Futarchy = { isMut: true, isSigner: false, }, + { + name: "squadsMultisig", + isMut: false, + isSigner: false, + }, + { + name: "squadsProposal", + isMut: false, + isSigner: false, + }, { name: "systemProgram", isMut: false, @@ -3786,6 +4034,27 @@ export const IDL: Futarchy = { }, ], }, + { + name: "resizeDao", + accounts: [ + { + name: "dao", + isMut: true, + isSigner: false, + }, + { + name: "payer", + isMut: true, + isSigner: true, + }, + { + name: "systemProgram", + isMut: false, + isSigner: false, + }, + ], + args: [], + }, { name: "spotSwap", accounts: [ @@ -4528,6 +4797,52 @@ export const IDL: Futarchy = { ], args: [], }, + { + name: "adminApproveExecuteMultisigProposal", + accounts: [ + { + name: "dao", + isMut: true, + isSigner: false, + }, + { + name: "admin", + isMut: true, + isSigner: true, + }, + { + name: "squadsMultisig", + isMut: true, + isSigner: false, + }, + { + name: "squadsMultisigProposal", + isMut: true, + isSigner: false, + }, + { + name: "squadsMultisigVaultTransaction", + isMut: true, + isSigner: false, + }, + { + name: "squadsMultisigProgram", + isMut: false, + isSigner: false, + }, + { + name: "eventAuthority", + isMut: false, + isSigner: false, + }, + { + name: "program", + isMut: false, + isSigner: false, + }, + ], + args: [], + }, ], accounts: [ { @@ -4698,6 +5013,142 @@ export const IDL: Futarchy = { ], }, }, + { + name: "oldDao", + type: { + kind: "struct", + fields: [ + { + name: "amm", + docs: ["Embedded FutarchyAmm - 1:1 relationship"], + type: { + defined: "FutarchyAmm", + }, + }, + { + name: "nonce", + docs: ["`nonce` + `dao_creator` are PDA seeds"], + type: "u64", + }, + { + name: "daoCreator", + type: "publicKey", + }, + { + name: "pdaBump", + type: "u8", + }, + { + name: "squadsMultisig", + type: "publicKey", + }, + { + name: "squadsMultisigVault", + type: "publicKey", + }, + { + name: "baseMint", + type: "publicKey", + }, + { + name: "quoteMint", + type: "publicKey", + }, + { + name: "proposalCount", + type: "u32", + }, + { + name: "passThresholdBps", + type: "u16", + }, + { + name: "secondsPerProposal", + type: "u32", + }, + { + name: "twapInitialObservation", + docs: [ + "For manipulation-resistance the TWAP is a time-weighted average observation,", + "where observation tries to approximate price but can only move by", + "`twap_max_observation_change_per_update` per update. Because it can only move", + "a little bit per update, you need to check that it has a good initial observation.", + "Otherwise, an attacker could create a very high initial observation in the pass", + "market and a very low one in the fail market to force the proposal to pass.", + "", + "We recommend setting an initial observation around the spot price of the token,", + "and max observation change per update around 2% the spot price of the token.", + "For example, if the spot price of META is $400, we'd recommend setting an initial", + "observation of 400 (converted into the AMM prices) and a max observation change per", + "update of 8 (also converted into the AMM prices). Observations can be updated once", + "a minute, so 2% allows the proposal market to reach double the spot price or 0", + "in 50 minutes.", + ], + type: "u128", + }, + { + name: "twapMaxObservationChangePerUpdate", + type: "u128", + }, + { + name: "twapStartDelaySeconds", + docs: [ + "Forces TWAP calculation to start after `twap_start_delay_seconds` seconds", + ], + type: "u32", + }, + { + name: "minQuoteFutarchicLiquidity", + docs: [ + "As an anti-spam measure and to help liquidity, you need to lock up some liquidity", + "in both futarchic markets in order to create a proposal.", + "", + "For example, for META, we can use a `min_quote_futarchic_liquidity` of", + "5000 * 1_000_000 (5000 USDC) and a `min_base_futarchic_liquidity` of", + "10 * 1_000_000_000 (10 META).", + ], + type: "u64", + }, + { + name: "minBaseFutarchicLiquidity", + type: "u64", + }, + { + name: "baseToStake", + docs: [ + "Minimum amount of base tokens that must be staked to launch a proposal", + ], + type: "u64", + }, + { + name: "seqNum", + type: "u64", + }, + { + name: "initialSpendingLimit", + type: { + option: { + defined: "InitialSpendingLimit", + }, + }, + }, + { + name: "teamSponsoredPassThresholdBps", + docs: [ + "The percentage, in basis points, the pass price needs to be above the", + "fail price in order for the proposal to pass for team-sponsored proposals.", + "", + "Can be negative to allow for team-sponsored proposals to pass by default.", + ], + type: "i16", + }, + { + name: "teamAddress", + type: "publicKey", + }, + ], + }, + }, { name: "proposal", type: { @@ -6443,5 +6894,20 @@ export const IDL: Futarchy = { name: "NoActiveOptimisticProposal", msg: "No active optimistic proposal", }, + { + code: 6040, + name: "OptimisticProposalAlreadyPassed", + msg: "Optimistic proposal has already passed", + }, + { + code: 6041, + name: "CannotSponsorOptimisticProposalChallenge", + msg: "Team cannot sponsor a challenge to an optimistic proposal", + }, + { + code: 6042, + name: "InvalidSpendingLimitMint", + msg: "Invalid spending limit mint. Must be the same as the DAO's quote mint", + }, ], }; diff --git a/sdk/src/v0.7/types/futarchy.ts b/sdk/src/v0.7/types/futarchy.ts index b9540ec5..99270d12 100644 --- a/sdk/src/v0.7/types/futarchy.ts +++ b/sdk/src/v0.7/types/futarchy.ts @@ -577,6 +577,27 @@ export type Futarchy = { }, ]; }, + { + name: "resizeDao"; + accounts: [ + { + name: "dao"; + isMut: true; + isSigner: false; + }, + { + name: "payer"; + isMut: true; + isSigner: true; + }, + { + name: "systemProgram"; + isMut: false; + isSigner: false; + }, + ]; + args: []; + }, { name: "spotSwap"; accounts: [ @@ -1535,6 +1556,142 @@ export type Futarchy = { ]; }; }, + { + name: "oldDao"; + type: { + kind: "struct"; + fields: [ + { + name: "amm"; + docs: ["Embedded FutarchyAmm - 1:1 relationship"]; + type: { + defined: "FutarchyAmm"; + }; + }, + { + name: "nonce"; + docs: ["`nonce` + `dao_creator` are PDA seeds"]; + type: "u64"; + }, + { + name: "daoCreator"; + type: "publicKey"; + }, + { + name: "pdaBump"; + type: "u8"; + }, + { + name: "squadsMultisig"; + type: "publicKey"; + }, + { + name: "squadsMultisigVault"; + type: "publicKey"; + }, + { + name: "baseMint"; + type: "publicKey"; + }, + { + name: "quoteMint"; + type: "publicKey"; + }, + { + name: "proposalCount"; + type: "u32"; + }, + { + name: "passThresholdBps"; + type: "u16"; + }, + { + name: "secondsPerProposal"; + type: "u32"; + }, + { + name: "twapInitialObservation"; + docs: [ + "For manipulation-resistance the TWAP is a time-weighted average observation,", + "where observation tries to approximate price but can only move by", + "`twap_max_observation_change_per_update` per update. Because it can only move", + "a little bit per update, you need to check that it has a good initial observation.", + "Otherwise, an attacker could create a very high initial observation in the pass", + "market and a very low one in the fail market to force the proposal to pass.", + "", + "We recommend setting an initial observation around the spot price of the token,", + "and max observation change per update around 2% the spot price of the token.", + "For example, if the spot price of META is $400, we'd recommend setting an initial", + "observation of 400 (converted into the AMM prices) and a max observation change per", + "update of 8 (also converted into the AMM prices). Observations can be updated once", + "a minute, so 2% allows the proposal market to reach double the spot price or 0", + "in 50 minutes.", + ]; + type: "u128"; + }, + { + name: "twapMaxObservationChangePerUpdate"; + type: "u128"; + }, + { + name: "twapStartDelaySeconds"; + docs: [ + "Forces TWAP calculation to start after `twap_start_delay_seconds` seconds", + ]; + type: "u32"; + }, + { + name: "minQuoteFutarchicLiquidity"; + docs: [ + "As an anti-spam measure and to help liquidity, you need to lock up some liquidity", + "in both futarchic markets in order to create a proposal.", + "", + "For example, for META, we can use a `min_quote_futarchic_liquidity` of", + "5000 * 1_000_000 (5000 USDC) and a `min_base_futarchic_liquidity` of", + "10 * 1_000_000_000 (10 META).", + ]; + type: "u64"; + }, + { + name: "minBaseFutarchicLiquidity"; + type: "u64"; + }, + { + name: "baseToStake"; + docs: [ + "Minimum amount of base tokens that must be staked to launch a proposal", + ]; + type: "u64"; + }, + { + name: "seqNum"; + type: "u64"; + }, + { + name: "initialSpendingLimit"; + type: { + option: { + defined: "InitialSpendingLimit"; + }; + }; + }, + { + name: "teamSponsoredPassThresholdBps"; + docs: [ + "The percentage, in basis points, the pass price needs to be above the", + "fail price in order for the proposal to pass for team-sponsored proposals.", + "", + "Can be negative to allow for team-sponsored proposals to pass by default.", + ]; + type: "i16"; + }, + { + name: "teamAddress"; + type: "publicKey"; + }, + ]; + }; + }, { name: "proposal"; type: { @@ -3877,6 +4034,27 @@ export const IDL: Futarchy = { }, ], }, + { + name: "resizeDao", + accounts: [ + { + name: "dao", + isMut: true, + isSigner: false, + }, + { + name: "payer", + isMut: true, + isSigner: true, + }, + { + name: "systemProgram", + isMut: false, + isSigner: false, + }, + ], + args: [], + }, { name: "spotSwap", accounts: [ @@ -4835,6 +5013,142 @@ export const IDL: Futarchy = { ], }, }, + { + name: "oldDao", + type: { + kind: "struct", + fields: [ + { + name: "amm", + docs: ["Embedded FutarchyAmm - 1:1 relationship"], + type: { + defined: "FutarchyAmm", + }, + }, + { + name: "nonce", + docs: ["`nonce` + `dao_creator` are PDA seeds"], + type: "u64", + }, + { + name: "daoCreator", + type: "publicKey", + }, + { + name: "pdaBump", + type: "u8", + }, + { + name: "squadsMultisig", + type: "publicKey", + }, + { + name: "squadsMultisigVault", + type: "publicKey", + }, + { + name: "baseMint", + type: "publicKey", + }, + { + name: "quoteMint", + type: "publicKey", + }, + { + name: "proposalCount", + type: "u32", + }, + { + name: "passThresholdBps", + type: "u16", + }, + { + name: "secondsPerProposal", + type: "u32", + }, + { + name: "twapInitialObservation", + docs: [ + "For manipulation-resistance the TWAP is a time-weighted average observation,", + "where observation tries to approximate price but can only move by", + "`twap_max_observation_change_per_update` per update. Because it can only move", + "a little bit per update, you need to check that it has a good initial observation.", + "Otherwise, an attacker could create a very high initial observation in the pass", + "market and a very low one in the fail market to force the proposal to pass.", + "", + "We recommend setting an initial observation around the spot price of the token,", + "and max observation change per update around 2% the spot price of the token.", + "For example, if the spot price of META is $400, we'd recommend setting an initial", + "observation of 400 (converted into the AMM prices) and a max observation change per", + "update of 8 (also converted into the AMM prices). Observations can be updated once", + "a minute, so 2% allows the proposal market to reach double the spot price or 0", + "in 50 minutes.", + ], + type: "u128", + }, + { + name: "twapMaxObservationChangePerUpdate", + type: "u128", + }, + { + name: "twapStartDelaySeconds", + docs: [ + "Forces TWAP calculation to start after `twap_start_delay_seconds` seconds", + ], + type: "u32", + }, + { + name: "minQuoteFutarchicLiquidity", + docs: [ + "As an anti-spam measure and to help liquidity, you need to lock up some liquidity", + "in both futarchic markets in order to create a proposal.", + "", + "For example, for META, we can use a `min_quote_futarchic_liquidity` of", + "5000 * 1_000_000 (5000 USDC) and a `min_base_futarchic_liquidity` of", + "10 * 1_000_000_000 (10 META).", + ], + type: "u64", + }, + { + name: "minBaseFutarchicLiquidity", + type: "u64", + }, + { + name: "baseToStake", + docs: [ + "Minimum amount of base tokens that must be staked to launch a proposal", + ], + type: "u64", + }, + { + name: "seqNum", + type: "u64", + }, + { + name: "initialSpendingLimit", + type: { + option: { + defined: "InitialSpendingLimit", + }, + }, + }, + { + name: "teamSponsoredPassThresholdBps", + docs: [ + "The percentage, in basis points, the pass price needs to be above the", + "fail price in order for the proposal to pass for team-sponsored proposals.", + "", + "Can be negative to allow for team-sponsored proposals to pass by default.", + ], + type: "i16", + }, + { + name: "teamAddress", + type: "publicKey", + }, + ], + }, + }, { name: "proposal", type: { From 62e4d8ac9543889105948c0703d64e669facbbb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jure=20Grani=C4=87=20Skender?= <3357638+pileks@users.noreply.github.com> Date: Fri, 16 Jan 2026 17:06:50 -0800 Subject: [PATCH 15/16] reintroduce resize_dao crankable instruction with scripts (#400) --- Anchor.toml | 2 + programs/futarchy/src/instructions/mod.rs | 2 + .../futarchy/src/instructions/resize_dao.rs | 83 ++++ programs/futarchy/src/lib.rs | 4 + programs/futarchy/src/state/dao.rs | 56 +++ scripts/v0.6/dumpDaos.ts | 90 ++++ scripts/v0.6/migrateDaos.ts | 133 +++++ sdk/src/v0.6/types/futarchy.ts | 466 ++++++++++++++++++ sdk/src/v0.7/types/futarchy.ts | 314 ++++++++++++ 9 files changed, 1150 insertions(+) create mode 100644 programs/futarchy/src/instructions/resize_dao.rs create mode 100644 scripts/v0.6/dumpDaos.ts create mode 100644 scripts/v0.6/migrateDaos.ts diff --git a/Anchor.toml b/Anchor.toml index 10a9ab03..1ade3f21 100644 --- a/Anchor.toml +++ b/Anchor.toml @@ -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" diff --git a/programs/futarchy/src/instructions/mod.rs b/programs/futarchy/src/instructions/mod.rs index 85b8023e..90e20456 100644 --- a/programs/futarchy/src/instructions/mod.rs +++ b/programs/futarchy/src/instructions/mod.rs @@ -12,6 +12,7 @@ pub mod initialize_proposal; pub mod initiate_vault_spend_optimistic_proposal; pub mod launch_proposal; pub mod provide_liquidity; +pub mod resize_dao; pub mod sponsor_proposal; pub mod spot_swap; pub mod stake_to_proposal; @@ -31,6 +32,7 @@ pub use initialize_proposal::*; pub use initiate_vault_spend_optimistic_proposal::*; pub use launch_proposal::*; pub use provide_liquidity::*; +pub use resize_dao::*; pub use sponsor_proposal::*; pub use spot_swap::*; pub use stake_to_proposal::*; diff --git a/programs/futarchy/src/instructions/resize_dao.rs b/programs/futarchy/src/instructions/resize_dao.rs new file mode 100644 index 00000000..6352ac11 --- /dev/null +++ b/programs/futarchy/src/instructions/resize_dao.rs @@ -0,0 +1,83 @@ +use anchor_lang::{system_program, Discriminator}; + +use super::*; + +#[derive(Accounts)] +pub struct ResizeDao<'info> { + /// CHECK: we check the discriminator + #[account(mut)] + pub dao: UncheckedAccount<'info>, + #[account(mut)] + pub payer: Signer<'info>, + pub system_program: Program<'info, System>, +} + +impl ResizeDao<'_> { + pub fn handle(ctx: Context) -> Result<()> { + let dao = &ctx.accounts.dao; + + require_eq!(dao.owner, &crate::ID); + let is_discriminator_correct = dao.try_borrow_data().unwrap()[..8] == Dao::discriminator(); + require_eq!(is_discriminator_correct, true); + + const AFTER_REALLOC_SIZE: usize = Dao::INIT_SPACE + 8; + // 42 bytes: 1 (Option discriminant) + 32 (Pubkey) + 8 (i64) + 1 (bool) + const BEFORE_REALLOC_SIZE: usize = AFTER_REALLOC_SIZE - 42; + + if dao.data_len() != BEFORE_REALLOC_SIZE { + // already realloced + require_eq!(dao.data_len(), AFTER_REALLOC_SIZE); + return Ok(()); + } + + let old_dao_data = OldDao::deserialize(&mut &dao.try_borrow_data().unwrap()[8..])?; + + let new_dao_data = Dao { + amm: old_dao_data.amm, + nonce: old_dao_data.nonce, + dao_creator: old_dao_data.dao_creator, + pda_bump: old_dao_data.pda_bump, + squads_multisig: old_dao_data.squads_multisig, + squads_multisig_vault: old_dao_data.squads_multisig_vault, + base_mint: old_dao_data.base_mint, + quote_mint: old_dao_data.quote_mint, + proposal_count: old_dao_data.proposal_count, + pass_threshold_bps: old_dao_data.pass_threshold_bps, + seconds_per_proposal: old_dao_data.seconds_per_proposal, + twap_initial_observation: old_dao_data.twap_initial_observation, + twap_max_observation_change_per_update: old_dao_data + .twap_max_observation_change_per_update, + twap_start_delay_seconds: old_dao_data.twap_start_delay_seconds, + min_quote_futarchic_liquidity: old_dao_data.min_quote_futarchic_liquidity, + min_base_futarchic_liquidity: old_dao_data.min_base_futarchic_liquidity, + base_to_stake: old_dao_data.base_to_stake, + seq_num: old_dao_data.seq_num, + initial_spending_limit: old_dao_data.initial_spending_limit, + team_sponsored_pass_threshold_bps: old_dao_data.team_sponsored_pass_threshold_bps, + team_address: old_dao_data.team_address, + optimistic_proposal: None, + is_optimistic_governance_enabled: false, + }; + + dao.realloc(AFTER_REALLOC_SIZE, true)?; + + let lamports_needed = Rent::get()?.minimum_balance(AFTER_REALLOC_SIZE); + + if lamports_needed > dao.lamports() { + system_program::transfer( + CpiContext::new( + ctx.accounts.system_program.to_account_info(), + system_program::Transfer { + from: ctx.accounts.payer.to_account_info(), + to: dao.to_account_info(), + }, + ), + lamports_needed - dao.lamports(), + )?; + } + + new_dao_data.serialize(&mut &mut dao.try_borrow_mut_data().unwrap()[8..])?; + + Ok(()) + } +} diff --git a/programs/futarchy/src/lib.rs b/programs/futarchy/src/lib.rs index 1c0f4eba..0eaaa4c7 100644 --- a/programs/futarchy/src/lib.rs +++ b/programs/futarchy/src/lib.rs @@ -100,6 +100,10 @@ pub mod futarchy { UpdateDao::handle(ctx, dao_params) } + pub fn resize_dao(ctx: Context) -> Result<()> { + ResizeDao::handle(ctx) + } + // AMM instructions pub fn spot_swap(ctx: Context, params: SpotSwapParams) -> Result<()> { diff --git a/programs/futarchy/src/state/dao.rs b/programs/futarchy/src/state/dao.rs index dfed8ac6..0d898f63 100644 --- a/programs/futarchy/src/state/dao.rs +++ b/programs/futarchy/src/state/dao.rs @@ -110,3 +110,59 @@ impl Dao { Ok(()) } } + +#[account] +#[derive(InitSpace)] +pub struct OldDao { + /// Embedded FutarchyAmm - 1:1 relationship + pub amm: FutarchyAmm, + /// `nonce` + `dao_creator` are PDA seeds + pub nonce: u64, + pub dao_creator: Pubkey, + pub pda_bump: u8, + pub squads_multisig: Pubkey, + pub squads_multisig_vault: Pubkey, + pub base_mint: Pubkey, + pub quote_mint: Pubkey, + pub proposal_count: u32, + // the percentage, in basis points, the pass price needs to be above the + // fail price in order for the proposal to pass + pub pass_threshold_bps: u16, + pub seconds_per_proposal: u32, + /// For manipulation-resistance the TWAP is a time-weighted average observation, + /// where observation tries to approximate price but can only move by + /// `twap_max_observation_change_per_update` per update. Because it can only move + /// a little bit per update, you need to check that it has a good initial observation. + /// Otherwise, an attacker could create a very high initial observation in the pass + /// market and a very low one in the fail market to force the proposal to pass. + /// + /// We recommend setting an initial observation around the spot price of the token, + /// and max observation change per update around 2% the spot price of the token. + /// For example, if the spot price of META is $400, we'd recommend setting an initial + /// observation of 400 (converted into the AMM prices) and a max observation change per + /// update of 8 (also converted into the AMM prices). Observations can be updated once + /// a minute, so 2% allows the proposal market to reach double the spot price or 0 + /// in 50 minutes. + pub twap_initial_observation: u128, + pub twap_max_observation_change_per_update: u128, + /// Forces TWAP calculation to start after `twap_start_delay_seconds` seconds + pub twap_start_delay_seconds: u32, + /// As an anti-spam measure and to help liquidity, you need to lock up some liquidity + /// in both futarchic markets in order to create a proposal. + /// + /// For example, for META, we can use a `min_quote_futarchic_liquidity` of + /// 5000 * 1_000_000 (5000 USDC) and a `min_base_futarchic_liquidity` of + /// 10 * 1_000_000_000 (10 META). + pub min_quote_futarchic_liquidity: u64, + pub min_base_futarchic_liquidity: u64, + /// Minimum amount of base tokens that must be staked to launch a proposal + pub base_to_stake: u64, + pub seq_num: u64, + pub initial_spending_limit: Option, + /// The percentage, in basis points, the pass price needs to be above the + /// fail price in order for the proposal to pass for team-sponsored proposals. + /// + /// Can be negative to allow for team-sponsored proposals to pass by default. + pub team_sponsored_pass_threshold_bps: i16, + pub team_address: Pubkey, +} diff --git a/scripts/v0.6/dumpDaos.ts b/scripts/v0.6/dumpDaos.ts new file mode 100644 index 00000000..33bb5d55 --- /dev/null +++ b/scripts/v0.6/dumpDaos.ts @@ -0,0 +1,90 @@ +import { PublicKey } from "@solana/web3.js"; +import * as anchor from "@coral-xyz/anchor"; +import { FutarchyClient } from "@metadaoproject/futarchy/v0.6"; +import dotenv from "dotenv"; +import * as fs from "fs"; +import * as path from "path"; +import bs58 from "bs58"; + +dotenv.config(); + +const provider = anchor.AnchorProvider.env(); + +function getDiscriminator(accountName: string): Buffer { + return Buffer.from( + anchor.BorshAccountsCoder.accountDiscriminator(accountName), + ); +} + +async function dumpAccount( + publicKey: PublicKey, + outputDir: string, + accountType: string, +) { + const accountInfo = await provider.connection.getAccountInfo(publicKey); + + if (!accountInfo) { + console.error(`Account ${publicKey.toBase58()} not found`); + return; + } + + const accountData = { + pubkey: publicKey.toBase58(), + account: { + lamports: accountInfo.lamports, + data: [accountInfo.data.toString("base64"), "base64"], + owner: accountInfo.owner.toBase58(), + executable: accountInfo.executable, + rentEpoch: "U64_MAX_PLACEHOLDER", + }, + }; + + const filename = path.join(outputDir, `${publicKey.toBase58()}.json`); + fs.writeFileSync( + filename, + JSON.stringify(accountData, null, 2).replace( + '"U64_MAX_PLACEHOLDER"', + "18446744073709551615", + ), + ); + + console.log(`Dumped ${accountType}: ${publicKey.toBase58()}`); +} + +async function main() { + const futarchy = FutarchyClient.createClient({ provider }); + + const daosDir = "daos"; + if (!fs.existsSync(daosDir)) { + fs.mkdirSync(daosDir); + } + + const daoDiscriminator = getDiscriminator("Dao"); + console.log(`DAO discriminator (hex): ${daoDiscriminator.toString("hex")}`); + console.log(`DAO discriminator (base58): ${bs58.encode(daoDiscriminator)}`); + console.log(`Program ID: ${futarchy.futarchy.programId.toBase58()}\n`); + + const daoAccounts = await provider.connection.getProgramAccounts( + futarchy.futarchy.programId, + { + filters: [ + { + memcmp: { + offset: 0, + bytes: bs58.encode(daoDiscriminator), + }, + }, + ], + }, + ); + + console.log(`Found ${daoAccounts.length} DAOs`); + for (const { pubkey } of daoAccounts) { + await dumpAccount(pubkey, daosDir, "DAO"); + } +} + +main().catch((error) => { + console.error("Fatal error:", error); + process.exit(1); +}); diff --git a/scripts/v0.6/migrateDaos.ts b/scripts/v0.6/migrateDaos.ts new file mode 100644 index 00000000..427b3f7a --- /dev/null +++ b/scripts/v0.6/migrateDaos.ts @@ -0,0 +1,133 @@ +import { + ComputeBudgetProgram, + Keypair, + TransactionInstruction, + VersionedTransaction, + TransactionMessage, +} from "@solana/web3.js"; +import * as anchor from "@coral-xyz/anchor"; +import { FutarchyClient } from "@metadaoproject/futarchy/v0.6"; +import dotenv from "dotenv"; +import bs58 from "bs58"; + +dotenv.config(); + +const provider = anchor.AnchorProvider.env(); +const payer = provider.wallet["payer"]; + +function getDiscriminator(accountName: string): Buffer { + return Buffer.from( + anchor.BorshAccountsCoder.accountDiscriminator(accountName), + ); +} + +async function sendAndConfirmTransaction( + ixs: TransactionInstruction[], + label: string, + signers: Keypair[] = [], +) { + const { blockhash } = await provider.connection.getLatestBlockhash(); + + const messageV0 = new TransactionMessage({ + instructions: ixs, + payerKey: payer.publicKey, + recentBlockhash: blockhash, + }).compileToV0Message(); + const simulationTx = new VersionedTransaction(messageV0); + simulationTx.sign([payer, ...signers]); + + const simulationResult = + await provider.connection.simulateTransaction(simulationTx); + + const computeBudgetIx = ComputeBudgetProgram.setComputeUnitLimit({ + units: Math.ceil(simulationResult.value.unitsConsumed! * 1.15), + }); + + const finalMessageV0 = new TransactionMessage({ + instructions: [computeBudgetIx, ...ixs], + payerKey: payer.publicKey, + recentBlockhash: blockhash, + }).compileToV0Message(); + const tx = new VersionedTransaction(finalMessageV0); + tx.sign([payer, ...signers]); + + const txHash = await provider.connection.sendRawTransaction(tx.serialize()); + console.log(`${label} transaction sent:`, txHash); + + await provider.connection.confirmTransaction(txHash, "confirmed"); + const txStatus = await provider.connection.getTransaction(txHash, { + maxSupportedTransactionVersion: 0, + commitment: "confirmed", + }); + if (txStatus?.meta?.err) { + throw new Error( + `Transaction failed: ${txHash}\nError: ${JSON.stringify( + txStatus?.meta?.err, + )}\n\n${txStatus?.meta?.logMessages?.join("\n")}`, + ); + } + console.log(`${label} transaction confirmed`); + return txHash; +} + +async function main() { + const futarchy = FutarchyClient.createClient({ provider }); + + const daoDiscriminator = getDiscriminator("Dao"); + const daoBatchSize = 15; + + console.log(`DAO discriminator (hex): ${daoDiscriminator.toString("hex")}`); + console.log(`DAO discriminator (base58): ${bs58.encode(daoDiscriminator)}`); + console.log(`Program ID: ${futarchy.futarchy.programId.toBase58()}\n`); + + const daoAccounts = await provider.connection.getProgramAccounts( + futarchy.futarchy.programId, + { + filters: [ + { + memcmp: { + offset: 0, + bytes: bs58.encode(daoDiscriminator), + }, + }, + ], + }, + ); + + console.log(`Found ${daoAccounts.length} DAOs`); + for (let i = 0; i < daoAccounts.length; i += daoBatchSize) { + const batch = daoAccounts.slice( + i, + Math.min(i + daoBatchSize, daoAccounts.length), + ); + console.log( + `Processing batch ${Math.floor(i / daoBatchSize) + 1} with ${batch.length} DAOs`, + ); + + const ixs = await Promise.all( + batch.map(async ({ pubkey }) => { + return await futarchy.futarchy.methods + .resizeDao() + .accounts({ + dao: pubkey, + payer: payer.publicKey, + }) + .instruction(); + }), + ); + + await sendAndConfirmTransaction( + ixs, + `Resize DAOs batch ${Math.floor(i / daoBatchSize) + 1}`, + ); + } + + console.log("Confirming DAOs can be loaded through SDK..."); + const daos = await futarchy.futarchy.account.dao.all(); + console.log(`Confirmed ${daos.length} DAOs`); +} + +main().catch((error) => { + console.error("Fatal error:", error); + process.exit(1); +}); diff --git a/sdk/src/v0.6/types/futarchy.ts b/sdk/src/v0.6/types/futarchy.ts index d9d95cdc..99270d12 100644 --- a/sdk/src/v0.6/types/futarchy.ts +++ b/sdk/src/v0.6/types/futarchy.ts @@ -118,6 +118,11 @@ export type Futarchy = { isMut: false; isSigner: false; }, + { + name: "squadsMultisig"; + isMut: false; + isSigner: false; + }, { name: "dao"; isMut: true; @@ -370,6 +375,16 @@ export type Futarchy = { isMut: true; isSigner: false; }, + { + name: "squadsMultisig"; + isMut: false; + isSigner: false; + }, + { + name: "squadsProposal"; + isMut: false; + isSigner: false; + }, { name: "systemProgram"; isMut: false; @@ -562,6 +577,27 @@ export type Futarchy = { }, ]; }, + { + name: "resizeDao"; + accounts: [ + { + name: "dao"; + isMut: true; + isSigner: false; + }, + { + name: "payer"; + isMut: true; + isSigner: true; + }, + { + name: "systemProgram"; + isMut: false; + isSigner: false; + }, + ]; + args: []; + }, { name: "spotSwap"; accounts: [ @@ -1304,6 +1340,52 @@ export type Futarchy = { ]; args: []; }, + { + name: "adminApproveExecuteMultisigProposal"; + accounts: [ + { + name: "dao"; + isMut: true; + isSigner: false; + }, + { + name: "admin"; + isMut: true; + isSigner: true; + }, + { + name: "squadsMultisig"; + isMut: true; + isSigner: false; + }, + { + name: "squadsMultisigProposal"; + isMut: true; + isSigner: false; + }, + { + name: "squadsMultisigVaultTransaction"; + isMut: true; + isSigner: false; + }, + { + name: "squadsMultisigProgram"; + isMut: false; + isSigner: false; + }, + { + name: "eventAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "program"; + isMut: false; + isSigner: false; + }, + ]; + args: []; + }, ]; accounts: [ { @@ -1474,6 +1556,142 @@ export type Futarchy = { ]; }; }, + { + name: "oldDao"; + type: { + kind: "struct"; + fields: [ + { + name: "amm"; + docs: ["Embedded FutarchyAmm - 1:1 relationship"]; + type: { + defined: "FutarchyAmm"; + }; + }, + { + name: "nonce"; + docs: ["`nonce` + `dao_creator` are PDA seeds"]; + type: "u64"; + }, + { + name: "daoCreator"; + type: "publicKey"; + }, + { + name: "pdaBump"; + type: "u8"; + }, + { + name: "squadsMultisig"; + type: "publicKey"; + }, + { + name: "squadsMultisigVault"; + type: "publicKey"; + }, + { + name: "baseMint"; + type: "publicKey"; + }, + { + name: "quoteMint"; + type: "publicKey"; + }, + { + name: "proposalCount"; + type: "u32"; + }, + { + name: "passThresholdBps"; + type: "u16"; + }, + { + name: "secondsPerProposal"; + type: "u32"; + }, + { + name: "twapInitialObservation"; + docs: [ + "For manipulation-resistance the TWAP is a time-weighted average observation,", + "where observation tries to approximate price but can only move by", + "`twap_max_observation_change_per_update` per update. Because it can only move", + "a little bit per update, you need to check that it has a good initial observation.", + "Otherwise, an attacker could create a very high initial observation in the pass", + "market and a very low one in the fail market to force the proposal to pass.", + "", + "We recommend setting an initial observation around the spot price of the token,", + "and max observation change per update around 2% the spot price of the token.", + "For example, if the spot price of META is $400, we'd recommend setting an initial", + "observation of 400 (converted into the AMM prices) and a max observation change per", + "update of 8 (also converted into the AMM prices). Observations can be updated once", + "a minute, so 2% allows the proposal market to reach double the spot price or 0", + "in 50 minutes.", + ]; + type: "u128"; + }, + { + name: "twapMaxObservationChangePerUpdate"; + type: "u128"; + }, + { + name: "twapStartDelaySeconds"; + docs: [ + "Forces TWAP calculation to start after `twap_start_delay_seconds` seconds", + ]; + type: "u32"; + }, + { + name: "minQuoteFutarchicLiquidity"; + docs: [ + "As an anti-spam measure and to help liquidity, you need to lock up some liquidity", + "in both futarchic markets in order to create a proposal.", + "", + "For example, for META, we can use a `min_quote_futarchic_liquidity` of", + "5000 * 1_000_000 (5000 USDC) and a `min_base_futarchic_liquidity` of", + "10 * 1_000_000_000 (10 META).", + ]; + type: "u64"; + }, + { + name: "minBaseFutarchicLiquidity"; + type: "u64"; + }, + { + name: "baseToStake"; + docs: [ + "Minimum amount of base tokens that must be staked to launch a proposal", + ]; + type: "u64"; + }, + { + name: "seqNum"; + type: "u64"; + }, + { + name: "initialSpendingLimit"; + type: { + option: { + defined: "InitialSpendingLimit"; + }; + }; + }, + { + name: "teamSponsoredPassThresholdBps"; + docs: [ + "The percentage, in basis points, the pass price needs to be above the", + "fail price in order for the proposal to pass for team-sponsored proposals.", + "", + "Can be negative to allow for team-sponsored proposals to pass by default.", + ]; + type: "i16"; + }, + { + name: "teamAddress"; + type: "publicKey"; + }, + ]; + }; + }, { name: "proposal"; type: { @@ -3219,6 +3437,21 @@ export type Futarchy = { name: "NoActiveOptimisticProposal"; msg: "No active optimistic proposal"; }, + { + code: 6040; + name: "OptimisticProposalAlreadyPassed"; + msg: "Optimistic proposal has already passed"; + }, + { + code: 6041; + name: "CannotSponsorOptimisticProposalChallenge"; + msg: "Team cannot sponsor a challenge to an optimistic proposal"; + }, + { + code: 6042; + name: "InvalidSpendingLimitMint"; + msg: "Invalid spending limit mint. Must be the same as the DAO's quote mint"; + }, ]; }; @@ -3342,6 +3575,11 @@ export const IDL: Futarchy = { isMut: false, isSigner: false, }, + { + name: "squadsMultisig", + isMut: false, + isSigner: false, + }, { name: "dao", isMut: true, @@ -3594,6 +3832,16 @@ export const IDL: Futarchy = { isMut: true, isSigner: false, }, + { + name: "squadsMultisig", + isMut: false, + isSigner: false, + }, + { + name: "squadsProposal", + isMut: false, + isSigner: false, + }, { name: "systemProgram", isMut: false, @@ -3786,6 +4034,27 @@ export const IDL: Futarchy = { }, ], }, + { + name: "resizeDao", + accounts: [ + { + name: "dao", + isMut: true, + isSigner: false, + }, + { + name: "payer", + isMut: true, + isSigner: true, + }, + { + name: "systemProgram", + isMut: false, + isSigner: false, + }, + ], + args: [], + }, { name: "spotSwap", accounts: [ @@ -4528,6 +4797,52 @@ export const IDL: Futarchy = { ], args: [], }, + { + name: "adminApproveExecuteMultisigProposal", + accounts: [ + { + name: "dao", + isMut: true, + isSigner: false, + }, + { + name: "admin", + isMut: true, + isSigner: true, + }, + { + name: "squadsMultisig", + isMut: true, + isSigner: false, + }, + { + name: "squadsMultisigProposal", + isMut: true, + isSigner: false, + }, + { + name: "squadsMultisigVaultTransaction", + isMut: true, + isSigner: false, + }, + { + name: "squadsMultisigProgram", + isMut: false, + isSigner: false, + }, + { + name: "eventAuthority", + isMut: false, + isSigner: false, + }, + { + name: "program", + isMut: false, + isSigner: false, + }, + ], + args: [], + }, ], accounts: [ { @@ -4698,6 +5013,142 @@ export const IDL: Futarchy = { ], }, }, + { + name: "oldDao", + type: { + kind: "struct", + fields: [ + { + name: "amm", + docs: ["Embedded FutarchyAmm - 1:1 relationship"], + type: { + defined: "FutarchyAmm", + }, + }, + { + name: "nonce", + docs: ["`nonce` + `dao_creator` are PDA seeds"], + type: "u64", + }, + { + name: "daoCreator", + type: "publicKey", + }, + { + name: "pdaBump", + type: "u8", + }, + { + name: "squadsMultisig", + type: "publicKey", + }, + { + name: "squadsMultisigVault", + type: "publicKey", + }, + { + name: "baseMint", + type: "publicKey", + }, + { + name: "quoteMint", + type: "publicKey", + }, + { + name: "proposalCount", + type: "u32", + }, + { + name: "passThresholdBps", + type: "u16", + }, + { + name: "secondsPerProposal", + type: "u32", + }, + { + name: "twapInitialObservation", + docs: [ + "For manipulation-resistance the TWAP is a time-weighted average observation,", + "where observation tries to approximate price but can only move by", + "`twap_max_observation_change_per_update` per update. Because it can only move", + "a little bit per update, you need to check that it has a good initial observation.", + "Otherwise, an attacker could create a very high initial observation in the pass", + "market and a very low one in the fail market to force the proposal to pass.", + "", + "We recommend setting an initial observation around the spot price of the token,", + "and max observation change per update around 2% the spot price of the token.", + "For example, if the spot price of META is $400, we'd recommend setting an initial", + "observation of 400 (converted into the AMM prices) and a max observation change per", + "update of 8 (also converted into the AMM prices). Observations can be updated once", + "a minute, so 2% allows the proposal market to reach double the spot price or 0", + "in 50 minutes.", + ], + type: "u128", + }, + { + name: "twapMaxObservationChangePerUpdate", + type: "u128", + }, + { + name: "twapStartDelaySeconds", + docs: [ + "Forces TWAP calculation to start after `twap_start_delay_seconds` seconds", + ], + type: "u32", + }, + { + name: "minQuoteFutarchicLiquidity", + docs: [ + "As an anti-spam measure and to help liquidity, you need to lock up some liquidity", + "in both futarchic markets in order to create a proposal.", + "", + "For example, for META, we can use a `min_quote_futarchic_liquidity` of", + "5000 * 1_000_000 (5000 USDC) and a `min_base_futarchic_liquidity` of", + "10 * 1_000_000_000 (10 META).", + ], + type: "u64", + }, + { + name: "minBaseFutarchicLiquidity", + type: "u64", + }, + { + name: "baseToStake", + docs: [ + "Minimum amount of base tokens that must be staked to launch a proposal", + ], + type: "u64", + }, + { + name: "seqNum", + type: "u64", + }, + { + name: "initialSpendingLimit", + type: { + option: { + defined: "InitialSpendingLimit", + }, + }, + }, + { + name: "teamSponsoredPassThresholdBps", + docs: [ + "The percentage, in basis points, the pass price needs to be above the", + "fail price in order for the proposal to pass for team-sponsored proposals.", + "", + "Can be negative to allow for team-sponsored proposals to pass by default.", + ], + type: "i16", + }, + { + name: "teamAddress", + type: "publicKey", + }, + ], + }, + }, { name: "proposal", type: { @@ -6443,5 +6894,20 @@ export const IDL: Futarchy = { name: "NoActiveOptimisticProposal", msg: "No active optimistic proposal", }, + { + code: 6040, + name: "OptimisticProposalAlreadyPassed", + msg: "Optimistic proposal has already passed", + }, + { + code: 6041, + name: "CannotSponsorOptimisticProposalChallenge", + msg: "Team cannot sponsor a challenge to an optimistic proposal", + }, + { + code: 6042, + name: "InvalidSpendingLimitMint", + msg: "Invalid spending limit mint. Must be the same as the DAO's quote mint", + }, ], }; diff --git a/sdk/src/v0.7/types/futarchy.ts b/sdk/src/v0.7/types/futarchy.ts index b9540ec5..99270d12 100644 --- a/sdk/src/v0.7/types/futarchy.ts +++ b/sdk/src/v0.7/types/futarchy.ts @@ -577,6 +577,27 @@ export type Futarchy = { }, ]; }, + { + name: "resizeDao"; + accounts: [ + { + name: "dao"; + isMut: true; + isSigner: false; + }, + { + name: "payer"; + isMut: true; + isSigner: true; + }, + { + name: "systemProgram"; + isMut: false; + isSigner: false; + }, + ]; + args: []; + }, { name: "spotSwap"; accounts: [ @@ -1535,6 +1556,142 @@ export type Futarchy = { ]; }; }, + { + name: "oldDao"; + type: { + kind: "struct"; + fields: [ + { + name: "amm"; + docs: ["Embedded FutarchyAmm - 1:1 relationship"]; + type: { + defined: "FutarchyAmm"; + }; + }, + { + name: "nonce"; + docs: ["`nonce` + `dao_creator` are PDA seeds"]; + type: "u64"; + }, + { + name: "daoCreator"; + type: "publicKey"; + }, + { + name: "pdaBump"; + type: "u8"; + }, + { + name: "squadsMultisig"; + type: "publicKey"; + }, + { + name: "squadsMultisigVault"; + type: "publicKey"; + }, + { + name: "baseMint"; + type: "publicKey"; + }, + { + name: "quoteMint"; + type: "publicKey"; + }, + { + name: "proposalCount"; + type: "u32"; + }, + { + name: "passThresholdBps"; + type: "u16"; + }, + { + name: "secondsPerProposal"; + type: "u32"; + }, + { + name: "twapInitialObservation"; + docs: [ + "For manipulation-resistance the TWAP is a time-weighted average observation,", + "where observation tries to approximate price but can only move by", + "`twap_max_observation_change_per_update` per update. Because it can only move", + "a little bit per update, you need to check that it has a good initial observation.", + "Otherwise, an attacker could create a very high initial observation in the pass", + "market and a very low one in the fail market to force the proposal to pass.", + "", + "We recommend setting an initial observation around the spot price of the token,", + "and max observation change per update around 2% the spot price of the token.", + "For example, if the spot price of META is $400, we'd recommend setting an initial", + "observation of 400 (converted into the AMM prices) and a max observation change per", + "update of 8 (also converted into the AMM prices). Observations can be updated once", + "a minute, so 2% allows the proposal market to reach double the spot price or 0", + "in 50 minutes.", + ]; + type: "u128"; + }, + { + name: "twapMaxObservationChangePerUpdate"; + type: "u128"; + }, + { + name: "twapStartDelaySeconds"; + docs: [ + "Forces TWAP calculation to start after `twap_start_delay_seconds` seconds", + ]; + type: "u32"; + }, + { + name: "minQuoteFutarchicLiquidity"; + docs: [ + "As an anti-spam measure and to help liquidity, you need to lock up some liquidity", + "in both futarchic markets in order to create a proposal.", + "", + "For example, for META, we can use a `min_quote_futarchic_liquidity` of", + "5000 * 1_000_000 (5000 USDC) and a `min_base_futarchic_liquidity` of", + "10 * 1_000_000_000 (10 META).", + ]; + type: "u64"; + }, + { + name: "minBaseFutarchicLiquidity"; + type: "u64"; + }, + { + name: "baseToStake"; + docs: [ + "Minimum amount of base tokens that must be staked to launch a proposal", + ]; + type: "u64"; + }, + { + name: "seqNum"; + type: "u64"; + }, + { + name: "initialSpendingLimit"; + type: { + option: { + defined: "InitialSpendingLimit"; + }; + }; + }, + { + name: "teamSponsoredPassThresholdBps"; + docs: [ + "The percentage, in basis points, the pass price needs to be above the", + "fail price in order for the proposal to pass for team-sponsored proposals.", + "", + "Can be negative to allow for team-sponsored proposals to pass by default.", + ]; + type: "i16"; + }, + { + name: "teamAddress"; + type: "publicKey"; + }, + ]; + }; + }, { name: "proposal"; type: { @@ -3877,6 +4034,27 @@ export const IDL: Futarchy = { }, ], }, + { + name: "resizeDao", + accounts: [ + { + name: "dao", + isMut: true, + isSigner: false, + }, + { + name: "payer", + isMut: true, + isSigner: true, + }, + { + name: "systemProgram", + isMut: false, + isSigner: false, + }, + ], + args: [], + }, { name: "spotSwap", accounts: [ @@ -4835,6 +5013,142 @@ export const IDL: Futarchy = { ], }, }, + { + name: "oldDao", + type: { + kind: "struct", + fields: [ + { + name: "amm", + docs: ["Embedded FutarchyAmm - 1:1 relationship"], + type: { + defined: "FutarchyAmm", + }, + }, + { + name: "nonce", + docs: ["`nonce` + `dao_creator` are PDA seeds"], + type: "u64", + }, + { + name: "daoCreator", + type: "publicKey", + }, + { + name: "pdaBump", + type: "u8", + }, + { + name: "squadsMultisig", + type: "publicKey", + }, + { + name: "squadsMultisigVault", + type: "publicKey", + }, + { + name: "baseMint", + type: "publicKey", + }, + { + name: "quoteMint", + type: "publicKey", + }, + { + name: "proposalCount", + type: "u32", + }, + { + name: "passThresholdBps", + type: "u16", + }, + { + name: "secondsPerProposal", + type: "u32", + }, + { + name: "twapInitialObservation", + docs: [ + "For manipulation-resistance the TWAP is a time-weighted average observation,", + "where observation tries to approximate price but can only move by", + "`twap_max_observation_change_per_update` per update. Because it can only move", + "a little bit per update, you need to check that it has a good initial observation.", + "Otherwise, an attacker could create a very high initial observation in the pass", + "market and a very low one in the fail market to force the proposal to pass.", + "", + "We recommend setting an initial observation around the spot price of the token,", + "and max observation change per update around 2% the spot price of the token.", + "For example, if the spot price of META is $400, we'd recommend setting an initial", + "observation of 400 (converted into the AMM prices) and a max observation change per", + "update of 8 (also converted into the AMM prices). Observations can be updated once", + "a minute, so 2% allows the proposal market to reach double the spot price or 0", + "in 50 minutes.", + ], + type: "u128", + }, + { + name: "twapMaxObservationChangePerUpdate", + type: "u128", + }, + { + name: "twapStartDelaySeconds", + docs: [ + "Forces TWAP calculation to start after `twap_start_delay_seconds` seconds", + ], + type: "u32", + }, + { + name: "minQuoteFutarchicLiquidity", + docs: [ + "As an anti-spam measure and to help liquidity, you need to lock up some liquidity", + "in both futarchic markets in order to create a proposal.", + "", + "For example, for META, we can use a `min_quote_futarchic_liquidity` of", + "5000 * 1_000_000 (5000 USDC) and a `min_base_futarchic_liquidity` of", + "10 * 1_000_000_000 (10 META).", + ], + type: "u64", + }, + { + name: "minBaseFutarchicLiquidity", + type: "u64", + }, + { + name: "baseToStake", + docs: [ + "Minimum amount of base tokens that must be staked to launch a proposal", + ], + type: "u64", + }, + { + name: "seqNum", + type: "u64", + }, + { + name: "initialSpendingLimit", + type: { + option: { + defined: "InitialSpendingLimit", + }, + }, + }, + { + name: "teamSponsoredPassThresholdBps", + docs: [ + "The percentage, in basis points, the pass price needs to be above the", + "fail price in order for the proposal to pass for team-sponsored proposals.", + "", + "Can be negative to allow for team-sponsored proposals to pass by default.", + ], + type: "i16", + }, + { + name: "teamAddress", + type: "publicKey", + }, + ], + }, + }, { name: "proposal", type: { From 98dfe15ae1d0c89ac494659f2029f18072f0f373 Mon Sep 17 00:00:00 2001 From: Pileks Date: Fri, 23 Jan 2026 15:49:11 -0800 Subject: [PATCH 16/16] remove duplicate optimistic proposal check --- .../initiate_vault_spend_optimistic_proposal.rs | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/programs/futarchy/src/instructions/initiate_vault_spend_optimistic_proposal.rs b/programs/futarchy/src/instructions/initiate_vault_spend_optimistic_proposal.rs index 28b6e074..94dd55e3 100644 --- a/programs/futarchy/src/instructions/initiate_vault_spend_optimistic_proposal.rs +++ b/programs/futarchy/src/instructions/initiate_vault_spend_optimistic_proposal.rs @@ -65,16 +65,10 @@ impl InitiateVaultSpendOptimisticProposal<'_> { FutarchyError::PoolNotInSpotState ); - // There should be no active optimistic proposal - require!( - self.dao.optimistic_proposal.is_none(), - FutarchyError::ActiveOptimisticProposalAlreadyEnqueued - ); - // No existing optimistic proposal can be present. If one has passed, it can be finalized (finalize_optimistic_proposal) require!( self.dao.optimistic_proposal.is_none(), - FutarchyError::ProposalDurationTooShort + FutarchyError::ActiveOptimisticProposalAlreadyEnqueued ); // Spending limit mint must be the same as the DAO's quote mint