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/error.rs b/programs/futarchy/src/error.rs index e40223c5..97823dfe 100644 --- a/programs/futarchy/src/error.rs +++ b/programs/futarchy/src/error.rs @@ -76,4 +76,18 @@ pub enum FutarchyError { InvalidTargetK, #[msg("Failed to compile transaction message for Squads vault transaction")] InvalidTransactionMessage, + #[msg("Invalid recipient")] + InvalidRecipient, + #[msg("Optimistic governance is disabled")] + OptimisticGovernanceDisabled, + #[msg("An active optimistic proposal is already enqueued")] + ActiveOptimisticProposalAlreadyEnqueued, + #[msg("No active optimistic proposal")] + NoActiveOptimisticProposal, + #[msg("Optimistic proposal has already passed")] + OptimisticProposalAlreadyPassed, + #[msg("Team cannot sponsor a challenge to an optimistic proposal")] + CannotSponsorOptimisticProposalChallenge, + #[msg("Invalid spending limit mint. Must be the same as the DAO's quote mint")] + InvalidSpendingLimitMint, } diff --git a/programs/futarchy/src/events.rs b/programs/futarchy/src/events.rs index 5e96aedf..7bddfb42 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] @@ -203,3 +204,25 @@ pub struct CollectMeteoraDammFeesEvent { pub quote_fees_collected: u64, pub base_fees_collected: u64, } + +#[event] +pub struct InitiateVaultSpendOptimisticProposalEvent { + pub common: CommonFields, + pub dao: Pubkey, + pub proposer: Pubkey, + pub squads_proposal: Pubkey, + pub squads_multisig: Pubkey, + pub squads_multisig_vault: Pubkey, + pub amount: u64, + pub recipient: Pubkey, + pub dao_quote_vault_account: Pubkey, + pub recipient_quote_account: Pubkey, + pub enqueued_timestamp: i64, +} + +#[event] +pub struct FinalizeOptimisticProposalEvent { + pub common: CommonFields, + pub dao: Pubkey, + pub squads_proposal: Pubkey, +} diff --git a/programs/futarchy/src/instructions/collect_meteora_damm_fees.rs b/programs/futarchy/src/instructions/collect_meteora_damm_fees.rs index d068f5e2..c15846bc 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::*; @@ -231,7 +230,7 @@ impl CollectMeteoraDammFees<'_> { // This correctly sets num_writable_signers and num_writable_non_signers // instead of the inverted readonly counts from Solana's Message::serialize() let transaction_message = - compile_transaction_message(&ctx.accounts.squads_multisig_vault.key(), &[ix])?; + compile_squads_transaction_message(&ctx.accounts.squads_multisig_vault.key(), &[ix])?; let transaction_message_bytes = transaction_message.try_to_vec()?; @@ -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/finalize_optimistic_proposal.rs b/programs/futarchy/src/instructions/finalize_optimistic_proposal.rs new file mode 100644 index 00000000..164e3d66 --- /dev/null +++ b/programs/futarchy/src/instructions/finalize_optimistic_proposal.rs @@ -0,0 +1,85 @@ +use super::*; + +#[derive(Accounts)] +#[event_cpi] +pub struct FinalizeOptimisticProposal<'info> { + #[account(mut, seeds = [squads_multisig_program::SEED_PREFIX, squads_multisig_program::SEED_MULTISIG, dao.key().as_ref()], bump, seeds::program = squads_program)] + pub squads_multisig: Account<'info, squads_multisig_program::Multisig>, + #[account(mut, address = dao.optimistic_proposal.as_ref().unwrap().squads_proposal)] + pub squads_proposal: Box>, + + #[account(mut, has_one = squads_multisig)] + 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); + + // A minimum of proposal duration must have passed since the the optimistic proposal was enqueued + // We know that the optimistic proposal is not None, because the address constraint would have already panicked + require_gte!( + Clock::get()?.unix_timestamp, + self.dao + .optimistic_proposal + .as_ref() + .unwrap() + .enqueued_timestamp + + self.dao.seconds_per_proposal as i64, + FutarchyError::ProposalTooYoung + ); + + // Pool must be in spot state - no active proposals + // This should never be hit, but it's here for completeness + require!( + matches!(self.dao.amm.state, PoolState::Spot { .. }), + FutarchyError::PoolNotInSpotState + ); + + Ok(()) + } + + pub fn handle(ctx: Context) -> 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 a2503459..2ec22bb4 100644 --- a/programs/futarchy/src/instructions/initialize_dao.rs +++ b/programs/futarchy/src/instructions/initialize_dao.rs @@ -212,6 +212,8 @@ impl InitializeDao<'_> { }, team_sponsored_pass_threshold_bps, team_address, + optimistic_proposal: None, + is_optimistic_governance_enabled: false, }); dao.invariant()?; diff --git a/programs/futarchy/src/instructions/initialize_proposal.rs b/programs/futarchy/src/instructions/initialize_proposal.rs index daf61287..81156b69 100644 --- a/programs/futarchy/src/instructions/initialize_proposal.rs +++ b/programs/futarchy/src/instructions/initialize_proposal.rs @@ -37,6 +37,19 @@ pub struct InitializeProposal<'info> { impl InitializeProposal<'_> { pub fn validate(&self) -> Result<()> { + // If we're trying to challenge an optimistic proposal that has already passed due to age, we should error + // In the case of an already-optimistically-passed proposal, the optimistic proposal can be cleared + // from the DAO state by finalizing the optimistic proposal (finalize_optimistic_proposal) + if let Some(ref optimistic_proposal) = self.dao.optimistic_proposal { + if optimistic_proposal.squads_proposal == self.squads_proposal.key() { + require_gt!( + optimistic_proposal.enqueued_timestamp + self.dao.seconds_per_proposal as i64, + Clock::get()?.unix_timestamp, + FutarchyError::OptimisticProposalAlreadyPassed + ); + } + } + require_eq!( self.question.num_outcomes(), 2, 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..94dd55e3 --- /dev/null +++ b/programs/futarchy/src/instructions/initiate_vault_spend_optimistic_proposal.rs @@ -0,0 +1,205 @@ +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>, + + /// 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(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>, + + /// CHECK: Squads multisig proposal, initialized by squads multisig program, checked by squads multisig program + #[account(mut)] + pub squads_proposal: UncheckedAccount<'info>, + + /// CHECK: Squads multisig vault transaction, initialized by squads multisig program, checked by squads multisig program + #[account(mut)] + 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)] + 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<()> { + // Optimistic governance must be enabled + require!( + self.dao.is_optimistic_governance_enabled, + FutarchyError::OptimisticGovernanceDisabled + ); + + // Pool must be in spot state - no active proposal + require!( + matches!(self.dao.amm.state, PoolState::Spot { spot: _ }), + FutarchyError::PoolNotInSpotState + ); + + // 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::ActiveOptimisticProposalAlreadyEnqueued + ); + + // 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 * 3, + 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, + 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; + + // Prepare the transfer instruction + let transfer_ix = anchor_spl::token::spl_token::instruction::transfer( + &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_squads_transaction_message(&squads_multisig_vault.key(), &[transfer_ix])?; + + let transaction_message_bytes = transaction_message.try_to_vec()?; + + 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( + squads_program.to_account_info(), + squads_multisig_program::cpi::accounts::VaultTransactionCreate { + 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 { + ephemeral_signers: 0, + vault_index: 0, + transaction_message: transaction_message_bytes, + 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 { + 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(), + proposal: squads_proposal.to_account_info(), + }, + dao_signer, + ), + squads_multisig_program::ProposalCreateArgs { + transaction_index, + draft: false, + }, + )?; + + // Update the DAO state + let clock = Clock::get()?; + + dao.optimistic_proposal = Some(OptimisticProposal { + squads_proposal: squads_proposal.key(), + enqueued_timestamp: 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/programs/futarchy/src/instructions/launch_proposal.rs b/programs/futarchy/src/instructions/launch_proposal.rs index 24df0c98..f6f87ef1 100644 --- a/programs/futarchy/src/instructions/launch_proposal.rs +++ b/programs/futarchy/src/instructions/launch_proposal.rs @@ -55,6 +55,34 @@ 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 + ); + + // 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 + ); + } + + // 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, @@ -151,6 +179,16 @@ impl LaunchProposal<'_> { proposal.state = ProposalState::Pending; proposal.timestamp_enqueued = clock.unix_timestamp; + // 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() { + 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/mod.rs b/programs/futarchy/src/instructions/mod.rs index 40a9efd7..90e20456 100644 --- a/programs/futarchy/src/instructions/mod.rs +++ b/programs/futarchy/src/instructions/mod.rs @@ -5,11 +5,14 @@ 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; +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; @@ -22,11 +25,14 @@ 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::*; +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/instructions/update_dao.rs b/programs/futarchy/src/instructions/update_dao.rs index 3d3babb3..5e3cca48 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,10 @@ 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), + optimistic_proposal: dao.optimistic_proposal.clone(), + is_optimistic_governance_enabled: dao_params + .is_optimistic_governance_enabled + .unwrap_or(dao.is_optimistic_governance_enabled), }); dao.seq_num += 1; @@ -84,6 +89,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/lib.rs b/programs/futarchy/src/lib.rs index 4f68ecf3..0eaaa4c7 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"))] @@ -98,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<()> { @@ -148,6 +154,19 @@ pub mod futarchy { 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) + } + + #[access_control(ctx.accounts.validate())] + pub fn finalize_optimistic_proposal(ctx: Context) -> Result<()> { + FinalizeOptimisticProposal::handle(ctx) + } + #[access_control(ctx.accounts.validate())] pub fn admin_approve_execute_multisig_proposal<'c: 'info, 'info>( ctx: Context<'_, '_, 'c, 'info, AdminApproveExecuteMultisigProposal<'info>>, diff --git a/programs/futarchy/src/squads.rs b/programs/futarchy/src/squads.rs new file mode 100644 index 00000000..9a56b9a2 --- /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_squads_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/state/dao.rs b/programs/futarchy/src/state/dao.rs index 24e066ca..0d898f63 100644 --- a/programs/futarchy/src/state/dao.rs +++ b/programs/futarchy/src/state/dao.rs @@ -56,6 +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 squads_proposal: Pubkey, + /// The timestamp when the active optimistic squads proposal was enqueued. + pub enqueued_timestamp: i64, } #[derive(AnchorSerialize, AnchorDeserialize, Debug, Clone, PartialEq, Eq, InitSpace)] @@ -100,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.5/squads/executeProposal.ts b/scripts/v0.5/squads/executeProposal.ts index 12d796ac..2f3aadc7 100644 --- a/scripts/v0.5/squads/executeProposal.ts +++ b/scripts/v0.5/squads/executeProposal.ts @@ -36,9 +36,7 @@ async function main() { // Add both instructions to create the proposal const vaultTxExecuteIxResolved = await vaultTxExecuteIx; - const tx = new Transaction().add( - vaultTxExecuteIxResolved.instruction, - ); + const tx = new Transaction().add(vaultTxExecuteIxResolved.instruction); tx.recentBlockhash = ( await provider.connection.getLatestBlockhash() ).blockhash; 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/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), 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 19b1d17f..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: [ @@ -1170,6 +1206,186 @@ 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: []; + }, + { + 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: [ { @@ -1325,6 +1541,154 @@ export type Futarchy = { name: "teamAddress"; type: "publicKey"; }, + { + name: "optimisticProposal"; + type: { + option: { + defined: "OptimisticProposal"; + }; + }; + }, + { + name: "isOptimisticGovernanceEnabled"; + type: "bool"; + }, + ]; + }; + }, + { + 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"; + }, ]; }; }, @@ -1536,6 +1900,18 @@ export type Futarchy = { ]; }; }, + { + name: "InitiateVaultSpendOptimisticProposalParams"; + type: { + kind: "struct"; + fields: [ + { + name: "amount"; + type: "u64"; + }, + ]; + }; + }, { name: "ProvideLiquidityParams"; type: { @@ -1678,6 +2054,12 @@ export type Futarchy = { option: "publicKey"; }; }, + { + name: "isOptimisticGovernanceEnabled"; + type: { + option: "bool"; + }; + }, ]; }; }, @@ -1704,6 +2086,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 +2602,11 @@ export type Futarchy = { type: "publicKey"; index: false; }, + { + name: "isOptimisticGovernanceEnabled"; + type: "bool"; + index: false; + }, ]; }, { @@ -2726,18 +3135,102 @@ export type Futarchy = { index: false; }, { - name: "baseMint"; + name: "baseMint"; + type: "publicKey"; + index: false; + }, + { + name: "quoteFeesCollected"; + type: "u64"; + index: false; + }, + { + name: "baseFeesCollected"; + type: "u64"; + index: false; + }, + ]; + }, + { + 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: "quoteFeesCollected"; - type: "u64"; - index: false; - }, - { - name: "baseFeesCollected"; - type: "u64"; + name: "squadsProposal"; + type: "publicKey"; index: false; }, ]; @@ -2924,6 +3417,41 @@ 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"; + }, + { + 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"; + }, ]; }; @@ -3047,6 +3575,11 @@ export const IDL: Futarchy = { isMut: false, isSigner: false, }, + { + name: "squadsMultisig", + isMut: false, + isSigner: false, + }, { name: "dao", isMut: true, @@ -3299,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, @@ -3491,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: [ @@ -4099,6 +4663,186 @@ export const IDL: 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: [], + }, + { + 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: [ { @@ -4107,22 +4851,170 @@ export const IDL: Futarchy = { kind: "struct", fields: [ { - name: "dao", + name: "dao", + type: "publicKey", + }, + { + name: "positionAuthority", + type: "publicKey", + }, + { + name: "liquidity", + type: "u128", + }, + ], + }, + }, + { + name: "dao", + 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: "positionAuthority", + name: "pdaBump", + type: "u8", + }, + { + name: "squadsMultisig", type: "publicKey", }, { - name: "liquidity", + 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: "optimisticProposal", + type: { + option: { + defined: "OptimisticProposal", + }, + }, + }, + { + name: "isOptimisticGovernanceEnabled", + type: "bool", + }, ], }, }, { - name: "dao", + name: "oldDao", type: { kind: "struct", fields: [ @@ -4465,6 +5357,18 @@ export const IDL: Futarchy = { ], }, }, + { + name: "InitiateVaultSpendOptimisticProposalParams", + type: { + kind: "struct", + fields: [ + { + name: "amount", + type: "u64", + }, + ], + }, + }, { name: "ProvideLiquidityParams", type: { @@ -4607,6 +5511,12 @@ export const IDL: Futarchy = { option: "publicKey", }, }, + { + name: "isOptimisticGovernanceEnabled", + type: { + option: "bool", + }, + }, ], }, }, @@ -4633,6 +5543,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 +6059,11 @@ export const IDL: Futarchy = { type: "publicKey", index: false, }, + { + name: "isOptimisticGovernanceEnabled", + type: "bool", + index: false, + }, ], }, { @@ -5671,6 +6608,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 +6874,40 @@ 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", + }, + { + 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/FutarchyClient.ts b/sdk/src/v0.7/FutarchyClient.ts index 0f22606f..3a79387b 100644 --- a/sdk/src/v0.7/FutarchyClient.ts +++ b/sdk/src/v0.7/FutarchyClient.ts @@ -1124,4 +1124,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/sdk/src/v0.7/types/futarchy.ts b/sdk/src/v0.7/types/futarchy.ts index 0e882f61..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: [ @@ -1185,6 +1206,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: []; + }, { name: "adminApproveExecuteMultisigProposal"; accounts: [ @@ -1386,6 +1541,154 @@ export type Futarchy = { name: "teamAddress"; type: "publicKey"; }, + { + name: "optimisticProposal"; + type: { + option: { + defined: "OptimisticProposal"; + }; + }; + }, + { + name: "isOptimisticGovernanceEnabled"; + type: "bool"; + }, + ]; + }; + }, + { + 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"; + }, ]; }; }, @@ -1597,6 +1900,18 @@ export type Futarchy = { ]; }; }, + { + name: "InitiateVaultSpendOptimisticProposalParams"; + type: { + kind: "struct"; + fields: [ + { + name: "amount"; + type: "u64"; + }, + ]; + }; + }, { name: "ProvideLiquidityParams"; type: { @@ -1739,6 +2054,12 @@ export type Futarchy = { option: "publicKey"; }; }, + { + name: "isOptimisticGovernanceEnabled"; + type: { + option: "bool"; + }; + }, ]; }; }, @@ -1765,6 +2086,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: { @@ -2259,6 +2602,11 @@ export type Futarchy = { type: "publicKey"; index: false; }, + { + name: "isOptimisticGovernanceEnabled"; + type: "bool"; + index: false; + }, ]; }, { @@ -2696,36 +3044,115 @@ export type Futarchy = { index: false; }, { - name: "minBaseAmount"; - type: "u64"; + name: "minBaseAmount"; + type: "u64"; + index: false; + }, + { + name: "minQuoteAmount"; + type: "u64"; + index: false; + }, + { + name: "baseAmount"; + type: "u64"; + index: false; + }, + { + name: "quoteAmount"; + type: "u64"; + index: false; + }, + { + name: "postAmmState"; + type: { + defined: "FutarchyAmm"; + }; + index: false; + }, + ]; + }, + { + name: "SponsorProposalEvent"; + fields: [ + { + name: "common"; + type: { + defined: "CommonFields"; + }; + index: false; + }, + { + name: "proposal"; + type: "publicKey"; + index: false; + }, + { + name: "dao"; + type: "publicKey"; + index: false; + }, + { + name: "teamAddress"; + type: "publicKey"; + index: false; + }, + ]; + }, + { + name: "CollectMeteoraDammFeesEvent"; + fields: [ + { + name: "common"; + type: { + defined: "CommonFields"; + }; + index: false; + }, + { + name: "dao"; + type: "publicKey"; + index: false; + }, + { + name: "pool"; + type: "publicKey"; + index: false; + }, + { + name: "baseTokenAccount"; + type: "publicKey"; + index: false; + }, + { + name: "quoteTokenAccount"; + type: "publicKey"; index: false; }, { - name: "minQuoteAmount"; - type: "u64"; + name: "quoteMint"; + type: "publicKey"; index: false; }, { - name: "baseAmount"; - type: "u64"; + name: "baseMint"; + type: "publicKey"; index: false; }, { - name: "quoteAmount"; + name: "quoteFeesCollected"; type: "u64"; index: false; }, { - name: "postAmmState"; - type: { - defined: "FutarchyAmm"; - }; + name: "baseFeesCollected"; + type: "u64"; index: false; }, ]; }, { - name: "SponsorProposalEvent"; + name: "InitiateVaultSpendOptimisticProposalEvent"; fields: [ { name: "common"; @@ -2735,70 +3162,75 @@ export type Futarchy = { index: false; }, { - name: "proposal"; + name: "dao"; type: "publicKey"; index: false; }, { - name: "dao"; + name: "proposer"; type: "publicKey"; index: false; }, { - name: "teamAddress"; + name: "squadsProposal"; type: "publicKey"; index: false; }, - ]; - }, - { - name: "CollectMeteoraDammFeesEvent"; - fields: [ { - name: "common"; - type: { - defined: "CommonFields"; - }; + name: "squadsMultisig"; + type: "publicKey"; index: false; }, { - name: "dao"; + name: "squadsMultisigVault"; type: "publicKey"; index: false; }, { - name: "pool"; - type: "publicKey"; + name: "amount"; + type: "u64"; index: false; }, { - name: "baseTokenAccount"; + name: "recipient"; type: "publicKey"; index: false; }, { - name: "quoteTokenAccount"; + name: "daoQuoteVaultAccount"; type: "publicKey"; index: false; }, { - name: "quoteMint"; + name: "recipientQuoteAccount"; type: "publicKey"; index: false; }, { - name: "baseMint"; - type: "publicKey"; + name: "enqueuedTimestamp"; + type: "i64"; index: false; }, + ]; + }, + { + name: "FinalizeOptimisticProposalEvent"; + fields: [ { - name: "quoteFeesCollected"; - type: "u64"; + name: "common"; + type: { + defined: "CommonFields"; + }; index: false; }, { - name: "baseFeesCollected"; - type: "u64"; + name: "dao"; + type: "publicKey"; + index: false; + }, + { + name: "squadsProposal"; + type: "publicKey"; index: false; }, ]; @@ -2985,6 +3417,41 @@ 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"; + }, + { + 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"; + }, ]; }; @@ -3567,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: [ @@ -4175,6 +4663,140 @@ export const IDL: 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: [], + }, { name: "adminApproveExecuteMultisigProposal", accounts: [ @@ -4229,22 +4851,170 @@ export const IDL: Futarchy = { kind: "struct", fields: [ { - name: "dao", + name: "dao", + type: "publicKey", + }, + { + name: "positionAuthority", + type: "publicKey", + }, + { + name: "liquidity", + type: "u128", + }, + ], + }, + }, + { + name: "dao", + 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: "positionAuthority", + name: "pdaBump", + type: "u8", + }, + { + name: "squadsMultisig", type: "publicKey", }, { - name: "liquidity", + 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: "optimisticProposal", + type: { + option: { + defined: "OptimisticProposal", + }, + }, + }, + { + name: "isOptimisticGovernanceEnabled", + type: "bool", + }, ], }, }, { - name: "dao", + name: "oldDao", type: { kind: "struct", fields: [ @@ -4587,6 +5357,18 @@ export const IDL: Futarchy = { ], }, }, + { + name: "InitiateVaultSpendOptimisticProposalParams", + type: { + kind: "struct", + fields: [ + { + name: "amount", + type: "u64", + }, + ], + }, + }, { name: "ProvideLiquidityParams", type: { @@ -4729,6 +5511,12 @@ export const IDL: Futarchy = { option: "publicKey", }, }, + { + name: "isOptimisticGovernanceEnabled", + type: { + option: "bool", + }, + }, ], }, }, @@ -4755,6 +5543,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: { @@ -5249,6 +6059,11 @@ export const IDL: Futarchy = { type: "publicKey", index: false, }, + { + name: "isOptimisticGovernanceEnabled", + type: "bool", + index: false, + }, ], }, { @@ -5793,6 +6608,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: [ { @@ -5975,5 +6874,40 @@ 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", + }, + { + 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/tests/futarchy/main.test.ts b/tests/futarchy/main.test.ts index 1b654bcb..4730565d 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"; @@ -10,6 +11,9 @@ 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 adminApproveProposal from "./unit/adminApproveExecuteMultisigProposal.test.js"; import { PublicKey } from "@solana/web3.js"; @@ -44,6 +48,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); @@ -52,6 +57,11 @@ export default function suite() { describe("#collect_meteora_damm_fees", collectMeteoraDammFees); + describe( + "#initiate_vault_spend_optimistic_proposal", + initiateVaultSpendOptimisticProposal, + ); + describe("#finalize_optimistic_proposal", finalizeOptimisticProposal); describe("#admin_approve_proposal", adminApproveProposal); // 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/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/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/initiateVaultSpendOptimisticProposal.test.ts b/tests/futarchy/unit/initiateVaultSpendOptimisticProposal.test.ts new file mode 100644 index 00000000..5d9333b2 --- /dev/null +++ b/tests/futarchy/unit/initiateVaultSpendOptimisticProposal.test.ts @@ -0,0 +1,443 @@ +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 { 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(1), + minBaseFutarchicLiquidity: new BN(1), + 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 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); + }); + + it("can initiate a vault spend 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 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 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); + + 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/futarchy/unit/launchProposal.test.ts b/tests/futarchy/unit/launchProposal.test.ts new file mode 100644 index 00000000..f2209a3c --- /dev/null +++ b/tests/futarchy/unit/launchProposal.test.ts @@ -0,0 +1,226 @@ +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, + squadsProposal, + }) + .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, + squadsProposal, + }) + .rpc() + .then(callbacks[0], callbacks[1]); + }); +} diff --git a/tests/integration/fullLaunch.test.ts b/tests/integration/fullLaunch.test.ts index 398f923c..8d9c18a4 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 4cf37a9a..67fc6e10 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: true, }, }) .instruction(); @@ -579,6 +580,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);