diff --git a/Anchor.toml b/Anchor.toml index 1faca827..14ef26fc 100644 --- a/Anchor.toml +++ b/Anchor.toml @@ -47,7 +47,6 @@ v04-initialize-proposal = "yarn run tsx scripts/v0.4/initializeProposal.ts" v05-execute-proposal = "yarn run tsx scripts/v0.5/executeProposal.ts" v06-launch = "yarn run tsx scripts/v0.6/launch.ts" claim-launch = "yarn run tsx scripts/v0.6/claimAllLaunch.ts" -v06-execute-proposal = "yarn run tsx scripts/v0.6/executeProposal.ts" v06-execute-general-proposal = "yarn run tsx scripts/v0.6/executeGeneralProposal.ts" v06-dump-daos-proposals = "yarn run tsx scripts/v0.6/dumpDaosProposals.ts" v06-migrate-daos-proposals = "yarn run tsx scripts/v0.6/migrateDaosProposals.ts" diff --git a/Cargo.lock b/Cargo.lock index 83881e26..a515b577 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -937,12 +937,13 @@ checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] name = "futarchy" -version = "0.6.1" +version = "0.6.2" dependencies = [ "anchor-lang", "anchor-spl", "conditional_vault", "damm_v2_cpi", + "mint_governor", "solana-security-txt", "squads-multisig-program", ] diff --git a/programs/futarchy/Cargo.toml b/programs/futarchy/Cargo.toml index d95d7029..eb561870 100644 --- a/programs/futarchy/Cargo.toml +++ b/programs/futarchy/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "futarchy" -version = "0.6.1" +version = "0.6.2" description = "SVM-based program for running futarchy" edition = "2021" @@ -23,3 +23,4 @@ solana-security-txt = "=1.1.1" conditional_vault = { path = "../conditional_vault", features = ["cpi"] } squads-multisig-program = { git = "https://github.com/Squads-Protocol/v4", package = "squads-multisig-program", rev = "6d5235da621a2e9b7379ea358e48760e981053be", features = ["cpi"] } damm_v2_cpi = { path = "../damm_v2_cpi", features = ["cpi"] } +mint_governor = { path = "../mint_governor", features = ["cpi"] } diff --git a/programs/futarchy/src/error.rs b/programs/futarchy/src/error.rs index d822ac67..1ea264dd 100644 --- a/programs/futarchy/src/error.rs +++ b/programs/futarchy/src/error.rs @@ -90,4 +90,26 @@ pub enum FutarchyError { InvalidSpendingLimitMint, #[msg("No active optimistic proposal")] NoActiveOptimisticProposal, + #[msg("This DAO has been liquidated")] + DaoLiquidated, + #[msg("A hostile proposal of this kind failed recently, so the cooldown must elapse first")] + HostileCooldownActive, + #[msg("The DAO has no spending limit")] + NoSpendingLimit, + #[msg("Amount exceeds the cap of 3x the monthly spending limit")] + SpendCapExceeded, + #[msg("The base mint's authority is neither the treasury vault nor a mint governor")] + UnknownMintAuthority, + #[msg("This proposal kind must be team-sponsored before it can launch")] + ProposalNotTeamSponsored, + #[msg("The spending limit record hasn't changed, so there is nothing to sync")] + SpendingLimitNotDirty, + #[msg("Wrong proposal kind for this instruction")] + InvalidProposalKind, + #[msg("This DAO has already been liquidated")] + AlreadyLiquidated, + #[msg("A spending limit can have at most 10 members")] + TooManySpendingLimitMembers, + #[msg("Invalid liquidator")] + InvalidLiquidator, } diff --git a/programs/futarchy/src/events.rs b/programs/futarchy/src/events.rs index 0479eddc..7af501f2 100644 --- a/programs/futarchy/src/events.rs +++ b/programs/futarchy/src/events.rs @@ -1,6 +1,6 @@ use anchor_lang::prelude::*; -use crate::{FutarchyAmm, InitialSpendingLimit, Market, ProposalState, SwapType}; +use crate::{FutarchyAmm, InitialSpendingLimit, Market, ProposalAction, ProposalState, SwapType}; #[derive(AnchorSerialize, AnchorDeserialize)] pub struct CommonFields { @@ -87,6 +87,7 @@ pub struct InitializeProposalEvent { pub squads_proposal: Pubkey, pub squads_multisig: Pubkey, pub squads_multisig_vault: Pubkey, + pub action: ProposalAction, } #[event] @@ -233,23 +234,30 @@ pub struct AdminFixPositionAuthorityEvent { } #[event] -pub struct InitiateVaultSpendOptimisticProposalEvent { +pub struct SetSpendingLimitEvent { 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, + pub config: Option, } #[event] -pub struct FinalizeOptimisticProposalEvent { +pub struct SyncSpendingLimitEvent { pub common: CommonFields, pub dao: Pubkey, - pub squads_proposal: Pubkey, + /// The Squads-side SpendingLimit PDA the record was projected onto. + pub spending_limit: Pubkey, + /// The projected record — the Squads-side end-state after the sync. + /// `None` = no limit (removed or never existed). + pub config: Option, +} + +#[event] +pub struct ApplyLiquidationEvent { + pub common: CommonFields, + pub dao: Pubkey, + pub proposal: Pubkey, + pub liquidator: Pubkey, + pub base_swept: u64, + pub quote_swept: u64, + pub post_amm_state: FutarchyAmm, } diff --git a/programs/futarchy/src/instructions/admin_cancel_proposal.rs b/programs/futarchy/src/instructions/admin_cancel_proposal.rs index 36ae6406..ce38d2e9 100644 --- a/programs/futarchy/src/instructions/admin_cancel_proposal.rs +++ b/programs/futarchy/src/instructions/admin_cancel_proposal.rs @@ -69,6 +69,14 @@ pub struct AdminCancelProposal<'info> { impl AdminCancelProposal<'_> { pub fn validate(&self) -> Result<()> { + // Unblockable proposals are censorship-proof once live: nobody, including + // the council, can cancel them. Reads the create-time snapshot so a + // live proposal keeps the flag it launched with. + require!( + self.proposal.council_can_block, + FutarchyError::InvalidProposalKind + ); + #[cfg(feature = "production")] require_keys_eq!(self.admin.key(), admin::ID, FutarchyError::InvalidAdmin); diff --git a/programs/futarchy/src/instructions/admin_enqueue_multisig_proposal_approval.rs b/programs/futarchy/src/instructions/admin_enqueue_multisig_proposal_approval.rs index 4cc60356..9d6ecaff 100644 --- a/programs/futarchy/src/instructions/admin_enqueue_multisig_proposal_approval.rs +++ b/programs/futarchy/src/instructions/admin_enqueue_multisig_proposal_approval.rs @@ -62,17 +62,28 @@ pub struct AdminEnqueueMultisigProposalApproval<'info> { impl AdminEnqueueMultisigProposalApproval<'_> { pub fn validate(&self, _args: &AdminEnqueueMultisigProposalApprovalArgs) -> Result<()> { - #[cfg(feature = "production")] - require_keys_eq!(self.admin.key(), admin::ID, FutarchyError::InvalidAdmin); + // On a liquidated DAO the liquidator replaces the admin id as the + // required signer. Enqueueing is the only capability the liquidator + // gains: the approve leg stays permissionless and execution is + // ordinary top-level Squads execution. + match self.dao.liquidator { + Some(liquidator) => { + require_keys_eq!( + self.admin.key(), + liquidator, + FutarchyError::InvalidLiquidator + ); + } + None => { + #[cfg(feature = "production")] + require_keys_eq!(self.admin.key(), admin::ID, FutarchyError::InvalidAdmin); + } + } if !matches!(self.dao.amm.state, PoolState::Spot { .. }) { return Err(FutarchyError::PoolNotInSpotState.into()); } - if self.dao.optimistic_proposal.is_some() { - return Err(FutarchyError::ActiveOptimisticProposalAlreadyEnqueued.into()); - } - validate_squads_proposal( &self.squads_multisig_proposal, &self.squads_multisig, diff --git a/programs/futarchy/src/instructions/admin_execute_multisig_proposal.rs b/programs/futarchy/src/instructions/admin_execute_multisig_proposal.rs index 7a2e6e61..0a93e81c 100644 --- a/programs/futarchy/src/instructions/admin_execute_multisig_proposal.rs +++ b/programs/futarchy/src/instructions/admin_execute_multisig_proposal.rs @@ -67,10 +67,6 @@ impl<'info, 'c: 'info> AdminExecuteMultisigProposal<'info> { return Err(FutarchyError::PoolNotInSpotState.into()); } - if self.dao.optimistic_proposal.is_some() { - return Err(FutarchyError::ActiveOptimisticProposalAlreadyEnqueued.into()); - } - Ok(()) } diff --git a/programs/futarchy/src/instructions/apply_liquidation.rs b/programs/futarchy/src/instructions/apply_liquidation.rs new file mode 100644 index 00000000..35f59399 --- /dev/null +++ b/programs/futarchy/src/instructions/apply_liquidation.rs @@ -0,0 +1,197 @@ +use super::*; + +#[derive(Accounts)] +#[event_cpi] +pub struct ApplyLiquidation<'info> { + /// The linked liquidation proposal, baked into the payload at create. + #[account(has_one = dao)] + pub proposal: Box>, + #[account(mut, has_one = squads_multisig_vault)] + pub dao: Box>, + /// The vault's signature is only obtainable through a Squads vault + /// transaction execution, so the caller is a passed proposal's payload. + pub squads_multisig_vault: Signer<'info>, + /// CHECK: the treasury's own LP position. The address is pinned by the + /// seeds, but whether the account exists at execution is unknowable at + /// create, so it is parsed manually — a passed liquidation must never + /// brick on treasury shape. + #[account( + mut, + seeds = [SEED_AMM_POSITION, dao.key().as_ref(), squads_multisig_vault.key().as_ref()], + bump, + )] + pub amm_position: UncheckedAccount<'info>, + #[account( + mut, + associated_token::mint = dao.base_mint, + associated_token::authority = dao, + )] + pub amm_base_vault: Account<'info, TokenAccount>, + #[account( + mut, + associated_token::mint = dao.quote_mint, + associated_token::authority = dao, + )] + pub amm_quote_vault: Account<'info, TokenAccount>, + #[account( + mut, + associated_token::mint = dao.base_mint, + associated_token::authority = squads_multisig_vault, + )] + pub vault_base_account: Account<'info, TokenAccount>, + #[account( + mut, + associated_token::mint = dao.quote_mint, + associated_token::authority = squads_multisig_vault, + )] + pub vault_quote_account: Account<'info, TokenAccount>, + pub token_program: Program<'info, Token>, +} + +impl ApplyLiquidation<'_> { + pub fn validate(&self) -> Result<()> { + // Like every payload instruction that mutates the DAO, only lands in + // Spot — the sweep always computes against a whole spot pool. + require!( + matches!(self.dao.amm.state, PoolState::Spot { .. }), + FutarchyError::PoolNotInSpotState + ); + + require!( + self.proposal.state == ProposalState::Passed, + FutarchyError::ProposalNotPassed + ); + + // Execution is permissionless and a second passed liquidation can + // exist, so replay must be refused, not double-applied. + require!( + self.dao.liquidator.is_none(), + FutarchyError::AlreadyLiquidated + ); + + Ok(()) + } + + pub fn handle(ctx: Context) -> Result<()> { + let Self { + proposal, + dao, + squads_multisig_vault: _, + amm_position, + amm_base_vault, + amm_quote_vault, + vault_base_account, + vault_quote_account, + token_program, + event_authority: _, + program: _, + } = ctx.accounts; + + // The destructure is the kind check: the vault's signature alone is + // kind-blind, so without it an execute_arbitrary payload could invoke + // liquidation at a different duration/threshold. + let ProposalAction::HostileLiquidate { liquidator } = &proposal.action else { + return err!(FutarchyError::InvalidProposalKind); + }; + let liquidator = *liquidator; + + // `Some` is the liquidated flag, and it is terminal. + dao.liquidator = Some(liquidator); + + // Zero the record; the next sync removes the Squads-side limit, so + // the outgoing team's pull rights end. + dao.initial_spending_limit = None; + dao.spending_limit_dirty = true; + + // Sweep the treasury's own AMM position pro-rata into the vault's + // token accounts. Third-party positions are untouched — they exit on + // their own schedule via withdraw_liquidity. A missing or empty + // position is skipped, never a failure. + let mut base_swept = 0u64; + let mut quote_swept = 0u64; + + if !amm_position.data_is_empty() { + require_keys_eq!( + *amm_position.owner, + crate::ID, + anchor_lang::error::ErrorCode::AccountOwnedByWrongProgram + ); + + let mut position: AmmPosition = { + let data = amm_position.try_borrow_data()?; + AmmPosition::try_deserialize(&mut &data[..])? + }; + + if position.liquidity > 0 { + let liquidity_to_sweep = position.liquidity; + let total_liquidity = dao.amm.total_liquidity; + require_gt!(total_liquidity, 0, FutarchyError::AssertFailed); + + { + let PoolState::Spot { ref mut spot } = dao.amm.state else { + return err!(FutarchyError::PoolNotInSpotState); + }; + + let (base_to_sweep, quote_to_sweep) = + spot.get_base_and_quote_withdrawable(liquidity_to_sweep, total_liquidity); + spot.base_reserves -= base_to_sweep; + spot.quote_reserves -= quote_to_sweep; + + base_swept = base_to_sweep; + quote_swept = quote_to_sweep; + } + + dao.amm.total_liquidity -= liquidity_to_sweep; + + position.liquidity = 0; + { + let mut data = amm_position.try_borrow_mut_data()?; + let mut writer: &mut [u8] = &mut data; + position.try_serialize(&mut writer)?; + } + + let dao_creator = dao.dao_creator; + let nonce = dao.nonce.to_le_bytes(); + let signer_seeds = &[ + SEED_DAO, + dao_creator.as_ref(), + nonce.as_ref(), + &[dao.pda_bump], + ]; + + for (amount_to_sweep, from, to) in [ + (base_swept, amm_base_vault, vault_base_account), + (quote_swept, amm_quote_vault, vault_quote_account), + ] { + token::transfer( + CpiContext::new_with_signer( + token_program.to_account_info(), + Transfer { + from: from.to_account_info(), + to: to.to_account_info(), + authority: dao.to_account_info(), + }, + &[&signer_seeds[..]], + ), + amount_to_sweep, + )?; + } + } + } + + dao.seq_num += 1; + + let clock = Clock::get()?; + emit_cpi!(ApplyLiquidationEvent { + common: CommonFields::new(&clock, dao.seq_num), + dao: dao.key(), + proposal: proposal.key(), + liquidator, + base_swept, + quote_swept, + post_amm_state: dao.amm.clone(), + }); + + Ok(()) + } +} diff --git a/programs/futarchy/src/instructions/conditional_swap.rs b/programs/futarchy/src/instructions/conditional_swap.rs index 06c04b9e..79af5278 100644 --- a/programs/futarchy/src/instructions/conditional_swap.rs +++ b/programs/futarchy/src/instructions/conditional_swap.rs @@ -74,6 +74,8 @@ pub struct ConditionalSwap<'info> { impl ConditionalSwap<'_> { pub fn validate(&self, params: &ConditionalSwapParams) -> Result<()> { + require!(self.dao.liquidator.is_none(), FutarchyError::DaoLiquidated); + require_neq!(params.market, Market::Spot); require_gte!( diff --git a/programs/futarchy/src/instructions/execute_multisig_proposal_approval.rs b/programs/futarchy/src/instructions/execute_multisig_proposal_approval.rs index 05f64163..d44e1252 100644 --- a/programs/futarchy/src/instructions/execute_multisig_proposal_approval.rs +++ b/programs/futarchy/src/instructions/execute_multisig_proposal_approval.rs @@ -57,10 +57,6 @@ impl ExecuteMultisigProposalApproval<'_> { return Err(FutarchyError::PoolNotInSpotState.into()); } - if self.dao.optimistic_proposal.is_some() { - return Err(FutarchyError::ActiveOptimisticProposalAlreadyEnqueued.into()); - } - validate_squads_proposal( &self.squads_multisig_proposal, &self.squads_multisig, diff --git a/programs/futarchy/src/instructions/execute_spending_limit_change.rs b/programs/futarchy/src/instructions/execute_spending_limit_change.rs deleted file mode 100644 index 35d67204..00000000 --- a/programs/futarchy/src/instructions/execute_spending_limit_change.rs +++ /dev/null @@ -1,96 +0,0 @@ -use super::*; - -use anchor_lang::Discriminator; -use squads_multisig_program::program::SquadsMultisigProgram; - -#[derive(Accounts)] -pub struct ExecuteSpendingLimitChange<'info> { - #[account( - mut, has_one = dao, has_one = squads_proposal, - )] - pub proposal: Box>, - #[account(mut, has_one = squads_multisig)] - pub dao: Box>, - /// CHECK: checked by squads multisig program - #[account(mut)] - pub squads_proposal: Account<'info, squads_multisig_program::Proposal>, - #[account(address = vault_transaction.multisig)] - pub squads_multisig: Account<'info, squads_multisig_program::Multisig>, - pub squads_multisig_program: Program<'info, SquadsMultisigProgram>, - pub vault_transaction: Account<'info, squads_multisig_program::VaultTransaction>, -} - -impl<'info, 'c: 'info> ExecuteSpendingLimitChange<'info> { - pub fn validate(&self) -> Result<()> { - require_eq!(self.proposal.state, ProposalState::Passed); - - if !matches!(self.dao.amm.state, PoolState::Spot { .. }) { - return Err(FutarchyError::PoolNotInSpotState.into()); - } - - if self.dao.optimistic_proposal.is_some() { - return Err(FutarchyError::ActiveOptimisticProposalAlreadyEnqueued.into()); - } - - Ok(()) - } - - pub fn handle(ctx: Context<'_, '_, 'c, 'info, Self>) -> Result<()> { - let Self { - proposal: _, - dao, - squads_proposal, - squads_multisig, - squads_multisig_program, - vault_transaction, - } = ctx.accounts; - - let message = &vault_transaction.message; - - // it would be bad if we signed with the Dao and then they ran off and did something else - // with it. so we verify that the only instructions are calls to update spending limits on - // the squads program - let squads_program_key_index: u8 = message - .account_keys - .iter() - .position(|key| key == &squads_multisig_program.key()) - .ok_or(error!(FutarchyError::InvalidTransaction))? - .try_into() - .or_else(|_| Err(error!(FutarchyError::InvalidTransaction)))?; - - for instruction in message.instructions.iter() { - require_eq!( - instruction.program_id_index, - squads_program_key_index, - FutarchyError::InvalidTransaction - ); - - let discriminator: [u8; 8] = instruction.data[0..8].try_into().unwrap(); - if discriminator != squads_multisig_program::instruction::MultisigAddSpendingLimit::DISCRIMINATOR && - discriminator != squads_multisig_program::instruction::MultisigRemoveSpendingLimit::DISCRIMINATOR { - return Err(error!(FutarchyError::InvalidTransaction)); - } - } - - let dao_nonce = &dao.nonce.to_le_bytes(); - let dao_creator_key = &dao.dao_creator.as_ref(); - let dao_seeds = &[SEED_DAO, dao_creator_key, dao_nonce, &[dao.pda_bump]]; - let dao_signer = &[&dao_seeds[..]]; - - squads_multisig_program::cpi::vault_transaction_execute( - CpiContext::new_with_signer( - squads_multisig_program.to_account_info(), - squads_multisig_program::cpi::accounts::VaultTransactionExecute { - multisig: squads_multisig.to_account_info(), - proposal: squads_proposal.to_account_info(), - transaction: vault_transaction.to_account_info(), - member: dao.to_account_info(), - }, - dao_signer, - ) - .with_remaining_accounts((&ctx.remaining_accounts).to_vec()), - )?; - - Ok(()) - } -} diff --git a/programs/futarchy/src/instructions/finalize_optimistic_proposal.rs b/programs/futarchy/src/instructions/finalize_optimistic_proposal.rs deleted file mode 100644 index 08fa5e34..00000000 --- a/programs/futarchy/src/instructions/finalize_optimistic_proposal.rs +++ /dev/null @@ -1,89 +0,0 @@ -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)] - 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!( - self.dao.optimistic_proposal.is_some(), - FutarchyError::NoActiveOptimisticProposal - ); - let optimistic_proposal = self.dao.optimistic_proposal.as_ref().unwrap(); - - require_keys_eq!( - self.squads_proposal.key(), - optimistic_proposal.squads_proposal - ); - require_keys_eq!(self.squads_proposal.multisig, self.dao.squads_multisig); - - // A minimum of proposal duration must have passed since the optimistic proposal was enqueued - require_gte!( - Clock::get()?.unix_timestamp, - optimistic_proposal.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/finalize_proposal.rs b/programs/futarchy/src/instructions/finalize_proposal.rs index 3da644f0..6a3eb050 100644 --- a/programs/futarchy/src/instructions/finalize_proposal.rs +++ b/programs/futarchy/src/instructions/finalize_proposal.rs @@ -142,12 +142,8 @@ impl FinalizeProposal<'_> { let pass_market_twap = calculate_twap(&pass)?; let fail_market_twap = calculate_twap(&fail)?; - let threshold_bps = if proposal.is_team_sponsored { - dao.team_sponsored_pass_threshold_bps - } else { - // Thanks to invariants this will never error - still it's better to be safe here. - i16::try_from(dao.pass_threshold_bps).map_err(|_| FutarchyError::CastingOverflow)? - }; + // Take the pass threshold from the proposal. DAO pass threshold is legacy. + let threshold_bps = proposal.pass_threshold_bps; // this can't overflow because each twap can only be MAX_PRICE (~1e31), // MAX_BPS + pass_threshold_bps is at most 1e5, and a u128 can hold @@ -164,6 +160,18 @@ impl FinalizeProposal<'_> { proposal.state = new_proposal_state; + if new_proposal_state == ProposalState::Failed { + match proposal.action { + ProposalAction::HostileTakeover { .. } => { + dao.last_failed_takeover_at = clock.unix_timestamp + } + ProposalAction::HostileLiquidate { .. } => { + dao.last_failed_liquidation_at = clock.unix_timestamp + } + _ => {} + } + } + let cpi_accounts = ResolveQuestion { question: question.to_account_info(), oracle: proposal.to_account_info(), diff --git a/programs/futarchy/src/instructions/initialize_dao.rs b/programs/futarchy/src/instructions/initialize_dao.rs index 64fe30be..99b273d6 100644 --- a/programs/futarchy/src/instructions/initialize_dao.rs +++ b/programs/futarchy/src/instructions/initialize_dao.rs @@ -223,6 +223,10 @@ impl InitializeDao<'_> { team_address, optimistic_proposal: None, is_optimistic_governance_enabled: false, + liquidator: None, + last_failed_takeover_at: 0, + last_failed_liquidation_at: 0, + spending_limit_dirty: false, }); dao.invariant()?; diff --git a/programs/futarchy/src/instructions/initialize_hostile_liquidate_proposal.rs b/programs/futarchy/src/instructions/initialize_hostile_liquidate_proposal.rs new file mode 100644 index 00000000..1cc5e7e9 --- /dev/null +++ b/programs/futarchy/src/instructions/initialize_hostile_liquidate_proposal.rs @@ -0,0 +1,81 @@ +use anchor_lang::solana_program::instruction::Instruction; +use anchor_lang::InstructionData; + +use super::*; + +#[derive(AnchorSerialize, AnchorDeserialize, Clone)] +pub struct InitializeHostileLiquidateProposalArgs { + pub liquidator: Pubkey, +} + +#[derive(Accounts)] +#[event_cpi] +pub struct InitializeHostileLiquidateProposal<'info> { + pub create: TypedCreateAccounts<'info>, +} + +impl InitializeHostileLiquidateProposal<'_> { + pub fn validate(&self) -> Result<()> { + self.create.validate() + } + + pub fn handle(ctx: Context, args: InitializeHostileLiquidateProposalArgs) -> Result<()> { + let create = &mut ctx.accounts.create; + let dao = &create.dao; + + let (event_authority, _) = + Pubkey::find_program_address(&[b"__event_authority"], &crate::ID); + + // The treasury's own LP position: the Squads vault is the position + // authority. May not exist — apply_liquidation tolerates that. + let (amm_position, _) = Pubkey::find_program_address( + &[ + SEED_AMM_POSITION, + dao.key().as_ref(), + dao.squads_multisig_vault.as_ref(), + ], + &crate::ID, + ); + + // The payload calls back into this program. The first account is this + // proposal's own PDA — knowable here because it is seeded on the + // Squads proposal this instruction creates at the next transaction + // index (the proposal_create CPI enforces that address). + let apply_liquidation_ix = Instruction { + program_id: crate::ID, + accounts: crate::accounts::ApplyLiquidation { + proposal: create.proposal.key(), + dao: dao.key(), + squads_multisig_vault: dao.squads_multisig_vault, + amm_position, + amm_base_vault: dao.amm.amm_base_vault, + amm_quote_vault: dao.amm.amm_quote_vault, + vault_base_account: anchor_spl::associated_token::get_associated_token_address( + &dao.squads_multisig_vault, + &dao.base_mint, + ), + vault_quote_account: anchor_spl::associated_token::get_associated_token_address( + &dao.squads_multisig_vault, + &dao.quote_mint, + ), + token_program: token::ID, + event_authority, + program: crate::ID, + } + .to_account_metas(None), + data: crate::instruction::ApplyLiquidation.data(), + }; + + let event = create.create_proposal( + &[apply_liquidation_ix], + ProposalAction::HostileLiquidate { + liquidator: args.liquidator, + }, + ctx.bumps.create.proposal, + )?; + + emit_cpi!(event); + + Ok(()) + } +} diff --git a/programs/futarchy/src/instructions/initialize_hostile_takeover_proposal.rs b/programs/futarchy/src/instructions/initialize_hostile_takeover_proposal.rs new file mode 100644 index 00000000..b78deab2 --- /dev/null +++ b/programs/futarchy/src/instructions/initialize_hostile_takeover_proposal.rs @@ -0,0 +1,108 @@ +use anchor_lang::solana_program::instruction::Instruction; +use anchor_lang::InstructionData; + +use super::*; + +#[derive(AnchorSerialize, AnchorDeserialize, Clone)] +pub struct InitializeHostileTakeoverProposalArgs { + pub new_team_address: Pubkey, + pub spending_limit_action: SpendingLimitAction, +} + +#[derive(Accounts)] +#[event_cpi] +pub struct InitializeHostileTakeoverProposal<'info> { + pub create: TypedCreateAccounts<'info>, +} + +impl InitializeHostileTakeoverProposal<'_> { + pub fn validate(&self, args: &InitializeHostileTakeoverProposalArgs) -> Result<()> { + self.create.validate()?; + + if let SpendingLimitAction::Set(config) = &args.spending_limit_action { + require_gte!( + MAX_SPENDING_LIMIT_MEMBERS, + config.members.len(), + FutarchyError::TooManySpendingLimitMembers + ); + } + + Ok(()) + } + + pub fn handle(ctx: Context, args: InitializeHostileTakeoverProposalArgs) -> Result<()> { + let create = &mut ctx.accounts.create; + + let (event_authority, _) = + Pubkey::find_program_address(&[b"__event_authority"], &crate::ID); + + let update_dao_ix = Instruction { + program_id: crate::ID, + accounts: crate::accounts::UpdateDao { + dao: create.dao.key(), + squads_multisig_vault: create.dao.squads_multisig_vault, + event_authority, + program: crate::ID, + } + .to_account_metas(None), + data: crate::instruction::UpdateDao { + dao_params: UpdateDaoParams { + pass_threshold_bps: None, + seconds_per_proposal: None, + twap_initial_observation: None, + twap_max_observation_change_per_update: None, + twap_start_delay_seconds: None, + min_quote_futarchic_liquidity: None, + min_base_futarchic_liquidity: None, + base_to_stake: None, + team_sponsored_pass_threshold_bps: None, + team_address: Some(args.new_team_address), + is_optimistic_governance_enabled: None, + }, + } + .data(), + }; + + let mut instructions = vec![update_dao_ix]; + + // `Keep` bakes no second instruction; `Remove`/`Set` append a + // vault-signed set_spending_limit carrying the declared end state. + let spending_limit_config = match &args.spending_limit_action { + SpendingLimitAction::Keep => None, + SpendingLimitAction::Remove => Some(SetSpendingLimitArgs { config: None }), + SpendingLimitAction::Set(limit) => Some(SetSpendingLimitArgs { + config: Some(limit.clone()), + }), + }; + + if let Some(set_spending_limit_args) = spending_limit_config { + instructions.push(Instruction { + program_id: crate::ID, + accounts: crate::accounts::SetSpendingLimit { + dao: create.dao.key(), + squads_multisig_vault: create.dao.squads_multisig_vault, + event_authority, + program: crate::ID, + } + .to_account_metas(None), + data: crate::instruction::SetSpendingLimit { + args: set_spending_limit_args, + } + .data(), + }); + } + + let event = create.create_proposal( + &instructions, + ProposalAction::HostileTakeover { + new_team_address: args.new_team_address, + spending_limit_action: args.spending_limit_action, + }, + ctx.bumps.create.proposal, + )?; + + emit_cpi!(event); + + Ok(()) + } +} diff --git a/programs/futarchy/src/instructions/initialize_large_spend_proposal.rs b/programs/futarchy/src/instructions/initialize_large_spend_proposal.rs new file mode 100644 index 00000000..22b9af63 --- /dev/null +++ b/programs/futarchy/src/instructions/initialize_large_spend_proposal.rs @@ -0,0 +1,67 @@ +use super::*; + +#[derive(AnchorSerialize, AnchorDeserialize, Clone)] +pub struct InitializeLargeSpendProposalArgs { + pub amount: u64, +} + +#[derive(Accounts)] +#[event_cpi] +pub struct InitializeLargeSpendProposal<'info> { + pub create: TypedCreateAccounts<'info>, +} + +impl InitializeLargeSpendProposal<'_> { + pub fn validate(&self, args: &InitializeLargeSpendProposalArgs) -> Result<()> { + self.create.validate()?; + + let record = self + .create + .dao + .initial_spending_limit + .as_ref() + .ok_or(FutarchyError::NoSpendingLimit)?; + + require_gte!( + record.amount_per_month.saturating_mul(3), + args.amount, + FutarchyError::SpendCapExceeded + ); + + Ok(()) + } + + pub fn handle(ctx: Context, args: InitializeLargeSpendProposalArgs) -> Result<()> { + let create = &mut ctx.accounts.create; + let dao = &create.dao; + + // The recipient is pinned to the DAO's team address at create; a later + // team change does not re-point it. + let transfer_ix = token::spl_token::instruction::transfer( + &token::ID, + &anchor_spl::associated_token::get_associated_token_address( + &dao.squads_multisig_vault, + &dao.quote_mint, + ), + &anchor_spl::associated_token::get_associated_token_address( + &dao.team_address, + &dao.quote_mint, + ), + &dao.squads_multisig_vault, + &[], + args.amount, + )?; + + let event = create.create_proposal( + &[transfer_ix], + ProposalAction::LargeSpend { + amount: args.amount, + }, + ctx.bumps.create.proposal, + )?; + + emit_cpi!(event); + + Ok(()) + } +} diff --git a/programs/futarchy/src/instructions/initialize_mint_tokens_proposal.rs b/programs/futarchy/src/instructions/initialize_mint_tokens_proposal.rs new file mode 100644 index 00000000..5b47c214 --- /dev/null +++ b/programs/futarchy/src/instructions/initialize_mint_tokens_proposal.rs @@ -0,0 +1,129 @@ +use anchor_lang::solana_program::instruction::Instruction; +use anchor_lang::solana_program::program_option::COption; +use anchor_lang::InstructionData; + +use super::*; + +#[derive(AnchorSerialize, AnchorDeserialize, Clone)] +pub struct InitializeMintTokensProposalArgs { + pub amount: u64, + pub recipient: Pubkey, +} + +#[derive(Accounts)] +#[event_cpi] +pub struct InitializeMintTokensProposal<'info> { + pub create: TypedCreateAccounts<'info>, + #[account(address = create.dao.base_mint)] + pub base_mint: Box>, + /// Only for governed mints (v0.8 launches): the `MintGovernor` holding the + /// base mint's authority. + pub mint_governor: Option>>, + /// Only for governed mints: the vault's minting rights on `mint_governor`. + pub mint_authority: Option>>, +} + +impl InitializeMintTokensProposal<'_> { + pub fn validate(&self) -> Result<()> { + self.create.validate()?; + + let vault = self.create.dao.squads_multisig_vault; + + if self.base_mint.mint_authority == COption::Some(vault) { + return Ok(()); + } + + // Not the vault: the only other authority the vault can mint through + // is a MintGovernor on which the vault is an authorized minter. + let governor = self + .mint_governor + .as_ref() + .ok_or(FutarchyError::UnknownMintAuthority)?; + require!( + self.base_mint.mint_authority == COption::Some(governor.key()), + FutarchyError::UnknownMintAuthority + ); + require_keys_eq!( + governor.mint, + self.base_mint.key(), + FutarchyError::UnknownMintAuthority + ); + + let mint_authority = self + .mint_authority + .as_ref() + .ok_or(FutarchyError::UnknownMintAuthority)?; + require_keys_eq!( + mint_authority.mint_governor, + governor.key(), + FutarchyError::UnknownMintAuthority + ); + require_keys_eq!( + mint_authority.authorized_minter, + vault, + FutarchyError::UnknownMintAuthority + ); + + Ok(()) + } + + pub fn handle(ctx: Context, args: InitializeMintTokensProposalArgs) -> Result<()> { + let base_mint = ctx.accounts.base_mint.key(); + let vault = ctx.accounts.create.dao.squads_multisig_vault; + + let recipient_ata = + anchor_spl::associated_token::get_associated_token_address(&args.recipient, &base_mint); + + let mint_ix = if ctx.accounts.base_mint.mint_authority == COption::Some(vault) { + token::spl_token::instruction::mint_to( + &token::ID, + &base_mint, + &recipient_ata, + &vault, + &[], + args.amount, + )? + } else { + // validate() has pinned both governor-branch accounts + let governor = ctx.accounts.mint_governor.as_ref().unwrap(); + let mint_authority = ctx.accounts.mint_authority.as_ref().unwrap(); + + let (event_authority, _) = + Pubkey::find_program_address(&[b"__event_authority"], &mint_governor::ID); + + Instruction { + program_id: mint_governor::ID, + accounts: mint_governor::accounts::MintTokens { + mint_governor: governor.key(), + mint_authority: mint_authority.key(), + mint: base_mint, + destination_ata: recipient_ata, + authorized_minter: vault, + token_program: token::ID, + event_authority, + program: mint_governor::ID, + } + .to_account_metas(None), + data: mint_governor::instruction::MintTokens { + args: mint_governor::MintTokensArgs { + amount: args.amount, + }, + } + .data(), + } + }; + + let event = ctx.accounts.create.create_proposal( + &[mint_ix], + ProposalAction::MintTokens { + amount: args.amount, + recipient: args.recipient, + }, + ctx.bumps.create.proposal, + )?; + + emit_cpi!(event); + + Ok(()) + } +} diff --git a/programs/futarchy/src/instructions/initialize_proposal.rs b/programs/futarchy/src/instructions/initialize_proposal.rs index a59736a5..d1a945b0 100644 --- a/programs/futarchy/src/instructions/initialize_proposal.rs +++ b/programs/futarchy/src/instructions/initialize_proposal.rs @@ -37,18 +37,7 @@ 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!(self.dao.liquidator.is_none(), FutarchyError::DaoLiquidated); require_eq!( self.question.num_outcomes(), @@ -98,6 +87,9 @@ impl InitializeProposal<'_> { dao.proposal_count += 1; + let action = ProposalAction::ExecuteArbitrary; + let params = action.params(); + proposal.set_inner(Proposal { number: dao.proposal_count, squads_proposal: squads_proposal.key(), @@ -109,12 +101,15 @@ impl InitializeProposal<'_> { dao: dao.key(), pda_bump: ctx.bumps.proposal, question: question.key(), - duration_in_seconds: dao.seconds_per_proposal, + duration_in_seconds: params.duration_seconds, pass_base_mint: base_vault.conditional_token_mints[1], fail_base_mint: base_vault.conditional_token_mints[0], pass_quote_mint: quote_vault.conditional_token_mints[1], fail_quote_mint: quote_vault.conditional_token_mints[0], is_team_sponsored: false, + pass_threshold_bps: params.pass_threshold_bps, + council_can_block: params.council_can_block, + action, }); dao.seq_num += 1; @@ -133,6 +128,7 @@ impl InitializeProposal<'_> { squads_proposal: squads_proposal.key(), squads_multisig: dao.squads_multisig, squads_multisig_vault: dao.squads_multisig_vault, + action: proposal.action.clone(), }); Ok(()) diff --git a/programs/futarchy/src/instructions/initialize_spending_limit_change_proposal.rs b/programs/futarchy/src/instructions/initialize_spending_limit_change_proposal.rs new file mode 100644 index 00000000..d31a4721 --- /dev/null +++ b/programs/futarchy/src/instructions/initialize_spending_limit_change_proposal.rs @@ -0,0 +1,71 @@ +use anchor_lang::solana_program::instruction::Instruction; +use anchor_lang::InstructionData; + +use super::*; + +#[derive(AnchorSerialize, AnchorDeserialize, Clone)] +pub struct InitializeSpendingLimitChangeProposalArgs { + /// `Some` replaces the record, `None` removes it. + pub config: Option, +} + +#[derive(Accounts)] +#[event_cpi] +pub struct InitializeSpendingLimitChangeProposal<'info> { + pub create: TypedCreateAccounts<'info>, +} + +impl InitializeSpendingLimitChangeProposal<'_> { + pub fn validate(&self, args: &InitializeSpendingLimitChangeProposalArgs) -> Result<()> { + self.create.validate()?; + + if let Some(config) = &args.config { + require_gte!( + MAX_SPENDING_LIMIT_MEMBERS, + config.members.len(), + FutarchyError::TooManySpendingLimitMembers + ); + } + + Ok(()) + } + + pub fn handle( + ctx: Context, + args: InitializeSpendingLimitChangeProposalArgs, + ) -> Result<()> { + let create = &mut ctx.accounts.create; + + let (event_authority, _) = + Pubkey::find_program_address(&[b"__event_authority"], &crate::ID); + + let set_spending_limit_ix = Instruction { + program_id: crate::ID, + accounts: crate::accounts::SetSpendingLimit { + dao: create.dao.key(), + squads_multisig_vault: create.dao.squads_multisig_vault, + event_authority, + program: crate::ID, + } + .to_account_metas(None), + data: crate::instruction::SetSpendingLimit { + args: SetSpendingLimitArgs { + config: args.config.clone(), + }, + } + .data(), + }; + + let event = create.create_proposal( + &[set_spending_limit_ix], + ProposalAction::SpendingLimitChange { + config: args.config, + }, + ctx.bumps.create.proposal, + )?; + + emit_cpi!(event); + + Ok(()) + } +} diff --git a/programs/futarchy/src/instructions/initiate_vault_spend_optimistic_proposal.rs b/programs/futarchy/src/instructions/initiate_vault_spend_optimistic_proposal.rs deleted file mode 100644 index ed80464d..00000000 --- a/programs/futarchy/src/instructions/initiate_vault_spend_optimistic_proposal.rs +++ /dev/null @@ -1,295 +0,0 @@ -use super::*; - -#[derive(Debug, Clone, AnchorSerialize, AnchorDeserialize, PartialEq, Eq)] -pub struct InitiateVaultSpendOptimisticProposalParams { - pub amount: u64, -} - -#[derive(Accounts)] -#[event_cpi] -pub struct InitiateVaultSpendOptimisticProposal<'info> { - #[account(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>, - - #[account(constraint = squads_proposal.multisig == squads_multisig.key() @ FutarchyError::InvalidTransaction)] - pub squads_proposal: Account<'info, squads_multisig_program::Proposal>, - - #[account(constraint = squads_vault_transaction.multisig == squads_multisig.key() @ FutarchyError::InvalidTransaction)] - pub squads_vault_transaction: Account<'info, squads_multisig_program::VaultTransaction>, - - #[account(mut, has_one = squads_multisig, has_one = squads_multisig_vault)] - pub dao: Box>, - #[account(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(address = dao.team_address)] - pub proposer: Signer<'info>, - - /// CHECK: Used for constraints - pub recipient: UncheckedAccount<'info>, - #[account(associated_token::mint = dao.quote_mint, associated_token::authority = recipient)] - pub recipient_quote_account: Account<'info, TokenAccount>, - - 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.saturating_mul(3), - params.amount, - FutarchyError::InvalidAmount - ); - - // Validate the squads proposal is Active - require!( - matches!( - self.squads_proposal.status, - squads_multisig_program::ProposalStatus::Active { .. } - ), - FutarchyError::InvalidSquadsProposalStatus - ); - - // Proposal must be fresh: no votes recorded yet - require!( - self.squads_proposal.approved.is_empty(), - FutarchyError::InvalidTransaction - ); - require!( - self.squads_proposal.rejected.is_empty(), - FutarchyError::InvalidTransaction - ); - require!( - self.squads_proposal.cancelled.is_empty(), - FutarchyError::InvalidTransaction - ); - - // Ensure the squads proposal is not invalidated by a previous config transaction - require_gt!( - self.squads_proposal.transaction_index, - self.squads_multisig.stale_transaction_index - ); - - // Ensure the vault transaction was created by the permissionless account - require_keys_eq!( - self.squads_vault_transaction.creator, - permissionless_account::id(), - FutarchyError::InvalidTransaction - ); - - // Validate the proposal references the vault transaction - require_eq!( - self.squads_proposal.transaction_index, - self.squads_vault_transaction.index, - FutarchyError::InvalidTransaction - ); - - // Must target vault index 0 - require_eq!( - self.squads_vault_transaction.vault_index, - 0, - FutarchyError::InvalidTransaction - ); - - // No ephemeral signers for SPL transfer - require!( - self.squads_vault_transaction - .ephemeral_signer_bumps - .is_empty(), - FutarchyError::InvalidTransaction - ); - - // Validate the vault transaction message contains exactly the expected SPL transfer - let message = &self.squads_vault_transaction.message; - - // Exactly 1 signer (the vault, which is both the payer and the SPL Transfer authority) - require_eq!(message.num_signers, 1_u8, FutarchyError::InvalidTransaction); - - // First account key (sole signer) must be the vault - let first_signer = message - .account_keys - .first() - .ok_or(error!(FutarchyError::InvalidTransaction))?; - require_keys_eq!( - *first_signer, - self.squads_multisig_vault.key(), - FutarchyError::InvalidTransaction - ); - - // Vault is a writable signer (TransactionMessage always marks the payerKey as writable) - require_eq!( - message.num_writable_signers, - 1_u8, - FutarchyError::InvalidTransaction - ); - - // Source + destination are writable non-signers - require_eq!( - message.num_writable_non_signers, - 2_u8, - FutarchyError::InvalidTransaction - ); - - // No address table lookups - require!( - message.address_table_lookups.is_empty(), - FutarchyError::InvalidTransaction - ); - - // Must have exactly 1 instruction - require!( - message.instructions.len() == 1, - FutarchyError::InvalidTransaction - ); - - let instruction = &message.instructions[0]; - - // Program ID must be SPL Token - let program_id = message - .account_keys - .get(instruction.program_id_index as usize) - .ok_or(error!(FutarchyError::InvalidTransaction))?; - require_keys_eq!( - *program_id, - self.token_program.key(), - FutarchyError::InvalidTransaction - ); - - // Instruction data must be a Transfer: discriminator [3] + amount as u64 LE (9 bytes total) - require!( - instruction.data.len() == 9, - FutarchyError::InvalidTransaction - ); - require!(instruction.data[0] == 3, FutarchyError::InvalidTransaction); - let encoded_amount = u64::from_le_bytes( - instruction.data[1..9] - .try_into() - .map_err(|_| error!(FutarchyError::InvalidTransaction))?, - ); - require_eq!( - encoded_amount, - params.amount, - FutarchyError::InvalidTransaction - ); - - // Validate the transfer accounts: source, destination, authority - require!( - instruction.account_indexes.len() >= 3, - FutarchyError::InvalidTransaction - ); - - let source = message - .account_keys - .get(instruction.account_indexes[0] as usize) - .ok_or(error!(FutarchyError::InvalidTransaction))?; - require_keys_eq!( - *source, - self.dao_quote_vault_account.key(), - FutarchyError::InvalidTransaction - ); - - let destination = message - .account_keys - .get(instruction.account_indexes[1] as usize) - .ok_or(error!(FutarchyError::InvalidTransaction))?; - require_keys_eq!( - *destination, - self.recipient_quote_account.key(), - FutarchyError::InvalidTransaction - ); - - let authority = message - .account_keys - .get(instruction.account_indexes[2] as usize) - .ok_or(error!(FutarchyError::InvalidTransaction))?; - require_keys_eq!( - *authority, - self.squads_multisig_vault.key(), - FutarchyError::InvalidTransaction - ); - - 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, - event_authority: _, - program: _, - squads_program: _, - proposer, - recipient: _, - recipient_quote_account, - token_program: _, - dao_quote_vault_account, - } = ctx.accounts; - - // 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 7816215c..bcc9e8ab 100644 --- a/programs/futarchy/src/instructions/launch_proposal.rs +++ b/programs/futarchy/src/instructions/launch_proposal.rs @@ -38,6 +38,8 @@ pub struct LaunchProposal<'info> { impl LaunchProposal<'_> { pub fn validate(&self) -> Result<()> { + require!(self.dao.liquidator.is_none(), FutarchyError::DaoLiquidated); + msg!("proposal state: {:?}", self.proposal.state); require!( matches!(self.proposal.state, ProposalState::Draft { .. }), @@ -57,26 +59,32 @@ 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 + // Kind gates, checked at launch rather than create so that pre-created + // drafts can't bypass them + let params = self.proposal.action.params(); + + if params.requires_team_sponsorship { + require!( + self.proposal.is_team_sponsored, + FutarchyError::ProposalNotTeamSponsored ); + } - // 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, + let last_failed_at = match self.proposal.action { + ProposalAction::HostileTakeover { .. } => Some(self.dao.last_failed_takeover_at), + ProposalAction::HostileLiquidate { .. } => Some(self.dao.last_failed_liquidation_at), + _ => None, + }; + if let Some(last_failed_at) = last_failed_at { + require_gte!( Clock::get()?.unix_timestamp, - FutarchyError::OptimisticProposalAlreadyPassed + last_failed_at + params.cooldown_seconds as i64, + FutarchyError::HostileCooldownActive ); } // 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. + // This prevents a situation where we enter futarchy with an invalid squads proposal state, thus bricking it. require!( matches!( self.squads_proposal.status, @@ -185,18 +193,6 @@ impl LaunchProposal<'_> { // Update proposal state to Pending and set timestamp enqueued proposal.state = ProposalState::Pending; proposal.timestamp_enqueued = clock.unix_timestamp; - // Additionally, set the duration once more in case it was updated since the proposal was created - proposal.duration_in_seconds = dao.seconds_per_proposal; - - // 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; diff --git a/programs/futarchy/src/instructions/mod.rs b/programs/futarchy/src/instructions/mod.rs index 0076cb2a..393586e5 100644 --- a/programs/futarchy/src/instructions/mod.rs +++ b/programs/futarchy/src/instructions/mod.rs @@ -4,22 +4,29 @@ pub mod admin_cancel_proposal; pub mod admin_enqueue_multisig_proposal_approval; pub mod admin_execute_multisig_proposal; pub mod admin_remove_proposal; +pub mod apply_liquidation; pub mod collect_fees; pub mod collect_meteora_damm_fees; pub mod conditional_swap; pub mod execute_multisig_proposal_approval; -pub mod execute_spending_limit_change; -pub mod finalize_optimistic_proposal; pub mod finalize_proposal; pub mod initialize_dao; +pub mod initialize_hostile_liquidate_proposal; +pub mod initialize_hostile_takeover_proposal; +pub mod initialize_large_spend_proposal; +pub mod initialize_mint_tokens_proposal; pub mod initialize_proposal; -pub mod initiate_vault_spend_optimistic_proposal; +pub mod initialize_spending_limit_change_proposal; pub mod launch_proposal; pub mod provide_liquidity; pub mod resize_dao; +pub mod resize_proposal; +pub mod set_spending_limit; pub mod sponsor_proposal; pub mod spot_swap; pub mod stake_to_proposal; +pub mod sync_spending_limit; +pub mod typed_create; pub mod unstake_from_proposal; pub mod update_dao; pub mod withdraw_liquidity; @@ -28,22 +35,29 @@ pub use admin_cancel_proposal::*; pub use admin_enqueue_multisig_proposal_approval::*; pub use admin_execute_multisig_proposal::*; pub use admin_remove_proposal::*; +pub use apply_liquidation::*; pub use collect_fees::*; pub use collect_meteora_damm_fees::*; pub use conditional_swap::*; pub use execute_multisig_proposal_approval::*; -pub use execute_spending_limit_change::*; -pub use finalize_optimistic_proposal::*; pub use finalize_proposal::*; pub use initialize_dao::*; +pub use initialize_hostile_liquidate_proposal::*; +pub use initialize_hostile_takeover_proposal::*; +pub use initialize_large_spend_proposal::*; +pub use initialize_mint_tokens_proposal::*; pub use initialize_proposal::*; -pub use initiate_vault_spend_optimistic_proposal::*; +pub use initialize_spending_limit_change_proposal::*; pub use launch_proposal::*; pub use provide_liquidity::*; pub use resize_dao::*; +pub use resize_proposal::*; +pub use set_spending_limit::*; pub use sponsor_proposal::*; pub use spot_swap::*; pub use stake_to_proposal::*; +pub use sync_spending_limit::*; +pub use typed_create::*; pub use unstake_from_proposal::*; pub use update_dao::*; pub use withdraw_liquidity::*; diff --git a/programs/futarchy/src/instructions/provide_liquidity.rs b/programs/futarchy/src/instructions/provide_liquidity.rs index bcf0503a..c6937977 100644 --- a/programs/futarchy/src/instructions/provide_liquidity.rs +++ b/programs/futarchy/src/instructions/provide_liquidity.rs @@ -59,6 +59,12 @@ pub struct ProvideLiquidity<'info> { } impl ProvideLiquidity<'_> { + pub fn validate(&self) -> Result<()> { + require!(self.dao.liquidator.is_none(), FutarchyError::DaoLiquidated); + + Ok(()) + } + pub fn handle(ctx: Context, params: ProvideLiquidityParams) -> Result<()> { let Self { dao, diff --git a/programs/futarchy/src/instructions/resize_dao.rs b/programs/futarchy/src/instructions/resize_dao.rs index 6352ac11..b16ce3a6 100644 --- a/programs/futarchy/src/instructions/resize_dao.rs +++ b/programs/futarchy/src/instructions/resize_dao.rs @@ -21,8 +21,8 @@ impl ResizeDao<'_> { 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; + // 50 bytes: 33 (Option liquidator) + 8 (i64) + 8 (i64) + 1 (bool) + const BEFORE_REALLOC_SIZE: usize = AFTER_REALLOC_SIZE - 50; if dao.data_len() != BEFORE_REALLOC_SIZE { // already realloced @@ -55,8 +55,14 @@ impl ResizeDao<'_> { 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, + // The optimistic execution machinery is gone; any in-flight + // optimistic spend is cleared rather than carried over. optimistic_proposal: None, - is_optimistic_governance_enabled: false, + is_optimistic_governance_enabled: old_dao_data.is_optimistic_governance_enabled, + liquidator: None, + last_failed_takeover_at: 0, + last_failed_liquidation_at: 0, + spending_limit_dirty: false, }; dao.realloc(AFTER_REALLOC_SIZE, true)?; diff --git a/programs/futarchy/src/instructions/resize_proposal.rs b/programs/futarchy/src/instructions/resize_proposal.rs new file mode 100644 index 00000000..d8ff70b6 --- /dev/null +++ b/programs/futarchy/src/instructions/resize_proposal.rs @@ -0,0 +1,95 @@ +use anchor_lang::{system_program, Discriminator}; + +use super::*; + +#[derive(Accounts)] +pub struct ResizeProposal<'info> { + /// CHECK: we check the discriminator + #[account(mut)] + pub proposal: UncheckedAccount<'info>, + /// The proposal's DAO, checked against the deserialized proposal in the + /// handler. Must already be migrated to the new layout (crank DAOs first). + pub dao: Account<'info, Dao>, + #[account(mut)] + pub payer: Signer<'info>, + pub system_program: Program<'info, System>, +} + +impl ResizeProposal<'_> { + pub fn handle(ctx: Context) -> Result<()> { + let proposal = &ctx.accounts.proposal; + let dao = &ctx.accounts.dao; + + require_eq!(proposal.owner, &crate::ID); + let is_discriminator_correct = + proposal.try_borrow_data().unwrap()[..8] == Proposal::discriminator(); + require_eq!(is_discriminator_correct, true); + + const AFTER_REALLOC_SIZE: usize = Proposal::INIT_SPACE + 8; + // 369 bytes: 2 (i16 pass_threshold_bps) + 1 (bool council_can_block) + // + 366 (ProposalAction) + const BEFORE_REALLOC_SIZE: usize = AFTER_REALLOC_SIZE - 369; + + if proposal.data_len() != BEFORE_REALLOC_SIZE { + // already realloced + require_eq!(proposal.data_len(), AFTER_REALLOC_SIZE); + return Ok(()); + } + + let old_proposal_data = + OldProposal::deserialize(&mut &proposal.try_borrow_data().unwrap()[8..])?; + + require_keys_eq!(old_proposal_data.dao, dao.key()); + + // The one and only read of the vestigial per-DAO threshold fields: + // live markets keep the rules they were created and staked under. + let pass_threshold_bps = if old_proposal_data.is_team_sponsored { + dao.team_sponsored_pass_threshold_bps + } else { + dao.pass_threshold_bps as i16 + }; + + let new_proposal_data = Proposal { + number: old_proposal_data.number, + proposer: old_proposal_data.proposer, + timestamp_enqueued: old_proposal_data.timestamp_enqueued, + state: old_proposal_data.state, + base_vault: old_proposal_data.base_vault, + quote_vault: old_proposal_data.quote_vault, + dao: old_proposal_data.dao, + pda_bump: old_proposal_data.pda_bump, + question: old_proposal_data.question, + duration_in_seconds: old_proposal_data.duration_in_seconds, + squads_proposal: old_proposal_data.squads_proposal, + pass_base_mint: old_proposal_data.pass_base_mint, + pass_quote_mint: old_proposal_data.pass_quote_mint, + fail_base_mint: old_proposal_data.fail_base_mint, + fail_quote_mint: old_proposal_data.fail_quote_mint, + is_team_sponsored: old_proposal_data.is_team_sponsored, + pass_threshold_bps, + council_can_block: true, + action: ProposalAction::ExecuteArbitrary, + }; + + proposal.realloc(AFTER_REALLOC_SIZE, true)?; + + let lamports_needed = Rent::get()?.minimum_balance(AFTER_REALLOC_SIZE); + + if lamports_needed > proposal.lamports() { + system_program::transfer( + CpiContext::new( + ctx.accounts.system_program.to_account_info(), + system_program::Transfer { + from: ctx.accounts.payer.to_account_info(), + to: proposal.to_account_info(), + }, + ), + lamports_needed - proposal.lamports(), + )?; + } + + new_proposal_data.serialize(&mut &mut proposal.try_borrow_mut_data().unwrap()[8..])?; + + Ok(()) + } +} diff --git a/programs/futarchy/src/instructions/set_spending_limit.rs b/programs/futarchy/src/instructions/set_spending_limit.rs new file mode 100644 index 00000000..b2c37bdb --- /dev/null +++ b/programs/futarchy/src/instructions/set_spending_limit.rs @@ -0,0 +1,53 @@ +use super::*; + +#[derive(Debug, Clone, AnchorSerialize, AnchorDeserialize, PartialEq, Eq)] +pub struct SetSpendingLimitArgs { + /// `Some` becomes the new record verbatim; `None` deletes it. + pub config: Option, +} + +#[derive(Accounts)] +#[event_cpi] +pub struct SetSpendingLimit<'info> { + #[account(mut, has_one = squads_multisig_vault)] + pub dao: Box>, + pub squads_multisig_vault: Signer<'info>, +} + +impl SetSpendingLimit<'_> { + pub fn validate(&self, args: &SetSpendingLimitArgs) -> Result<()> { + // Prevent config changes during active futarchy markets + if !matches!(self.dao.amm.state, PoolState::Spot { .. }) { + return Err(FutarchyError::PoolNotInSpotState.into()); + } + + require!(self.dao.liquidator.is_none(), FutarchyError::DaoLiquidated); + + if let Some(config) = &args.config { + require_gte!( + MAX_SPENDING_LIMIT_MEMBERS, + config.members.len(), + FutarchyError::TooManySpendingLimitMembers + ); + } + + Ok(()) + } + + pub fn handle(ctx: Context, args: SetSpendingLimitArgs) -> Result<()> { + let dao = &mut ctx.accounts.dao; + + dao.initial_spending_limit = args.config; + dao.spending_limit_dirty = true; + dao.seq_num += 1; + + let clock = Clock::get()?; + emit_cpi!(SetSpendingLimitEvent { + common: CommonFields::new(&clock, dao.seq_num), + dao: dao.key(), + config: dao.initial_spending_limit.clone(), + }); + + Ok(()) + } +} diff --git a/programs/futarchy/src/instructions/spot_swap.rs b/programs/futarchy/src/instructions/spot_swap.rs index 09aa2f80..9f45b508 100644 --- a/programs/futarchy/src/instructions/spot_swap.rs +++ b/programs/futarchy/src/instructions/spot_swap.rs @@ -43,6 +43,12 @@ pub struct SpotSwap<'info> { } impl SpotSwap<'_> { + pub fn validate(&self) -> Result<()> { + require!(self.dao.liquidator.is_none(), FutarchyError::DaoLiquidated); + + Ok(()) + } + pub fn handle(ctx: Context, params: SpotSwapParams) -> Result<()> { let SpotSwapParams { swap_type, diff --git a/programs/futarchy/src/instructions/stake_to_proposal.rs b/programs/futarchy/src/instructions/stake_to_proposal.rs index 333075b4..e5cb0868 100644 --- a/programs/futarchy/src/instructions/stake_to_proposal.rs +++ b/programs/futarchy/src/instructions/stake_to_proposal.rs @@ -42,6 +42,8 @@ pub struct StakeToProposal<'info> { impl StakeToProposal<'_> { pub fn validate(&self, params: &StakeToProposalParams) -> Result<()> { + require!(self.dao.liquidator.is_none(), FutarchyError::DaoLiquidated); + require!( matches!(self.proposal.state, ProposalState::Draft { .. }), FutarchyError::ProposalNotInDraftState diff --git a/programs/futarchy/src/instructions/sync_spending_limit.rs b/programs/futarchy/src/instructions/sync_spending_limit.rs new file mode 100644 index 00000000..fe66a02f --- /dev/null +++ b/programs/futarchy/src/instructions/sync_spending_limit.rs @@ -0,0 +1,106 @@ +use super::*; + +use squads_multisig_program::{program::SquadsMultisigProgram, Period}; + +#[derive(Accounts)] +#[event_cpi] +pub struct SyncSpendingLimit<'info> { + #[account(mut, has_one = squads_multisig)] + pub dao: Box>, + #[account(mut)] + pub squads_multisig: Box>, + /// CHECK: the Squads spending-limit PDA (`create_key` is always the DAO); may not exist + #[account(mut, seeds = [squads_multisig_program::SEED_PREFIX, squads_multisig.key().as_ref(), squads_multisig_program::SEED_SPENDING_LIMIT, dao.key().as_ref()], bump, seeds::program = squads_program)] + pub spending_limit: UncheckedAccount<'info>, + /// Pays rent when the limit is recreated and receives freed rent when it is removed. + #[account(mut)] + pub rent_payer: Signer<'info>, + pub squads_program: Program<'info, SquadsMultisigProgram>, + pub system_program: Program<'info, System>, +} + +impl SyncSpendingLimit<'_> { + pub fn validate(&self) -> Result<()> { + // Remove-recreate resets the period's spent amount, so an ungated sync + // would let a team refresh its own monthly budget at will. + require!( + self.dao.spending_limit_dirty, + FutarchyError::SpendingLimitNotDirty + ); + + Ok(()) + } + + pub fn handle(ctx: Context) -> Result<()> { + let dao = &ctx.accounts.dao; + + let dao_nonce = &dao.nonce.to_le_bytes(); + let dao_creator_key = &dao.dao_creator.as_ref(); + let dao_seeds = &[SEED_DAO, dao_creator_key, dao_nonce, &[dao.pda_bump]]; + let dao_signer = &[&dao_seeds[..]]; + + if !ctx.accounts.spending_limit.data_is_empty() { + squads_multisig_program::cpi::multisig_remove_spending_limit( + CpiContext::new_with_signer( + ctx.accounts.squads_program.to_account_info(), + squads_multisig_program::cpi::accounts::MultisigRemoveSpendingLimit { + multisig: ctx.accounts.squads_multisig.to_account_info(), + config_authority: dao.to_account_info(), + spending_limit: ctx.accounts.spending_limit.to_account_info(), + rent_collector: ctx.accounts.rent_payer.to_account_info(), + }, + dao_signer, + ), + squads_multisig_program::MultisigRemoveSpendingLimitArgs { memo: None }, + )?; + } + + // Liquidation zeroes the record, but guard anyway so a liquidated DAO + // always projects "no limit" + let projected_config = if dao.liquidator.is_none() { + dao.initial_spending_limit.clone() + } else { + None + }; + + if let Some(config) = &projected_config { + squads_multisig_program::cpi::multisig_add_spending_limit( + CpiContext::new_with_signer( + ctx.accounts.squads_program.to_account_info(), + squads_multisig_program::cpi::accounts::MultisigAddSpendingLimit { + multisig: ctx.accounts.squads_multisig.to_account_info(), + system_program: ctx.accounts.system_program.to_account_info(), + rent_payer: ctx.accounts.rent_payer.to_account_info(), + config_authority: dao.to_account_info(), + spending_limit: ctx.accounts.spending_limit.to_account_info(), + }, + dao_signer, + ), + squads_multisig_program::MultisigAddSpendingLimitArgs { + create_key: dao.key(), + vault_index: 0, + mint: dao.quote_mint, + amount: config.amount_per_month, + period: Period::Month, + members: config.members.clone(), + destinations: vec![], + memo: None, + }, + )?; + } + + let dao = &mut ctx.accounts.dao; + dao.spending_limit_dirty = false; + dao.seq_num += 1; + + let clock = Clock::get()?; + emit_cpi!(SyncSpendingLimitEvent { + common: CommonFields::new(&clock, dao.seq_num), + dao: dao.key(), + spending_limit: ctx.accounts.spending_limit.key(), + config: projected_config, + }); + + Ok(()) + } +} diff --git a/programs/futarchy/src/instructions/typed_create.rs b/programs/futarchy/src/instructions/typed_create.rs new file mode 100644 index 00000000..4f7cc7d8 --- /dev/null +++ b/programs/futarchy/src/instructions/typed_create.rs @@ -0,0 +1,175 @@ +use anchor_lang::solana_program::instruction::Instruction; + +use super::*; + +/// The accounts shared by the typed proposal create instructions +/// (`initialize_*_proposal`), embedded as a composite field. Per-type extra +/// accounts live on the embedding struct. +#[derive(Accounts)] +pub struct TypedCreateAccounts<'info> { + #[account( + init, + payer = payer, + space = 8 + Proposal::INIT_SPACE, + seeds = [SEED_PROPOSAL, squads_proposal.key().as_ref()], + bump + )] + pub proposal: Box>, + #[account(mut, has_one = squads_multisig)] + pub dao: Box>, + #[account(mut)] + pub squads_multisig: Box>, + /// CHECK: empty on entry; the vault_transaction_create CPI initializes it + /// and enforces that it is the transaction PDA for the next transaction index + #[account(mut)] + pub squads_transaction: UncheckedAccount<'info>, + /// CHECK: empty on entry; the proposal_create CPI initializes it and + /// enforces that it is the proposal PDA for the next transaction index + #[account(mut)] + pub squads_proposal: UncheckedAccount<'info>, + #[account( + constraint = question.oracle == proposal.key() + )] + pub question: Box>, + #[account( + constraint = base_vault.underlying_token_mint == dao.base_mint, + has_one = question, + )] + pub base_vault: Box>, + #[account( + constraint = quote_vault.underlying_token_mint == dao.quote_mint, + has_one = question, + )] + pub quote_vault: Box>, + pub proposer: Signer<'info>, + #[account(mut)] + pub payer: Signer<'info>, + /// The Squads-side creator of the vault transaction and proposal, an + /// Initiate | Execute member of every DAO multisig. Its keypair ships in + /// the SDK, so anyone can provide this signature. + #[account(address = permissionless_account::id())] + pub permissionless_account: Signer<'info>, + pub squads_program: Program<'info, squads_multisig_program::program::SquadsMultisigProgram>, + pub system_program: Program<'info, System>, +} + +impl TypedCreateAccounts<'_> { + /// The DAO and market-plumbing checks shared by every typed create. + /// Per-type creation rules live on each create's own `validate`. + pub fn validate(&self) -> Result<()> { + require!(self.dao.liquidator.is_none(), FutarchyError::DaoLiquidated); + + require_eq!( + self.question.num_outcomes(), + 2, + FutarchyError::QuestionMustBeBinary + ); + + // Should never be the case because the oracle is the proposal account, and you can't re-initialize a proposal + assert!(!self.question.is_resolved()); + + Ok(()) + } + + /// Wraps `instructions` in a Squads vault transaction, creates its Squads + /// proposal (`draft: false`), and initializes the futarchy proposal with + /// `action` and its per-kind snapshots. Returns the event so the embedding + /// instruction can `emit_cpi!` it. + pub fn create_proposal( + &mut self, + instructions: &[Instruction], + action: ProposalAction, + proposal_bump: u8, + ) -> Result { + let transaction_message = + compile_squads_transaction_message(&self.dao.squads_multisig_vault, instructions)?; + let transaction_message_bytes = transaction_message.try_to_vec()?; + + squads_multisig_program::cpi::vault_transaction_create( + CpiContext::new( + self.squads_program.to_account_info(), + squads_multisig_program::cpi::accounts::VaultTransactionCreate { + creator: self.permissionless_account.to_account_info(), + multisig: self.squads_multisig.to_account_info(), + rent_payer: self.payer.to_account_info(), + system_program: self.system_program.to_account_info(), + transaction: self.squads_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 + self.squads_multisig.reload()?; + let transaction_index = self.squads_multisig.transaction_index; + + squads_multisig_program::cpi::proposal_create( + CpiContext::new( + self.squads_program.to_account_info(), + squads_multisig_program::cpi::accounts::ProposalCreate { + creator: self.permissionless_account.to_account_info(), + multisig: self.squads_multisig.to_account_info(), + rent_payer: self.payer.to_account_info(), + system_program: self.system_program.to_account_info(), + proposal: self.squads_proposal.to_account_info(), + }, + ), + squads_multisig_program::ProposalCreateArgs { + transaction_index, + draft: false, + }, + )?; + + let clock = Clock::get()?; + let params = action.params(); + + self.dao.proposal_count += 1; + + let proposal = Proposal { + number: self.dao.proposal_count, + squads_proposal: self.squads_proposal.key(), + proposer: self.proposer.key(), + timestamp_enqueued: 0, + state: ProposalState::Draft { amount_staked: 0 }, + base_vault: self.base_vault.key(), + quote_vault: self.quote_vault.key(), + dao: self.dao.key(), + pda_bump: proposal_bump, + question: self.question.key(), + duration_in_seconds: params.duration_seconds, + pass_base_mint: self.base_vault.conditional_token_mints[1], + fail_base_mint: self.base_vault.conditional_token_mints[0], + pass_quote_mint: self.quote_vault.conditional_token_mints[1], + fail_quote_mint: self.quote_vault.conditional_token_mints[0], + is_team_sponsored: false, + pass_threshold_bps: params.pass_threshold_bps, + council_can_block: params.council_can_block, + action, + }; + self.proposal.set_inner(proposal); + + self.dao.seq_num += 1; + + Ok(InitializeProposalEvent { + common: CommonFields::new(&clock, self.dao.seq_num), + proposal: self.proposal.key(), + dao: self.dao.key(), + question: self.question.key(), + base_vault: self.base_vault.key(), + quote_vault: self.quote_vault.key(), + proposer: self.proposer.key(), + number: self.dao.proposal_count, + pda_bump: proposal_bump, + duration_in_seconds: self.proposal.duration_in_seconds, + squads_proposal: self.squads_proposal.key(), + squads_multisig: self.dao.squads_multisig, + squads_multisig_vault: self.dao.squads_multisig_vault, + action: self.proposal.action.clone(), + }) + } +} diff --git a/programs/futarchy/src/instructions/update_dao.rs b/programs/futarchy/src/instructions/update_dao.rs index d5d5d5d7..2b4c8e00 100644 --- a/programs/futarchy/src/instructions/update_dao.rs +++ b/programs/futarchy/src/instructions/update_dao.rs @@ -25,16 +25,13 @@ pub struct UpdateDao<'info> { impl UpdateDao<'_> { pub fn validate(&self) -> Result<()> { + require!(self.dao.liquidator.is_none(), FutarchyError::DaoLiquidated); + // Prevent parameter updates during active futarchy markets if !matches!(self.dao.amm.state, PoolState::Spot { .. }) { return Err(FutarchyError::PoolNotInSpotState.into()); } - // Prevent updates to DAO parameters if an optimistic proposal is enqueued - if self.dao.optimistic_proposal.is_some() { - return Err(FutarchyError::ActiveOptimisticProposalAlreadyEnqueued.into()); - } - Ok(()) } @@ -83,6 +80,10 @@ impl UpdateDao<'_> { is_optimistic_governance_enabled: dao_params .is_optimistic_governance_enabled .unwrap_or(dao.is_optimistic_governance_enabled), + liquidator: dao.liquidator, + last_failed_takeover_at: dao.last_failed_takeover_at, + last_failed_liquidation_at: dao.last_failed_liquidation_at, + spending_limit_dirty: dao.spending_limit_dirty, }); dao.seq_num += 1; diff --git a/programs/futarchy/src/lib.rs b/programs/futarchy/src/lib.rs index 7b5dc5e5..def48a44 100644 --- a/programs/futarchy/src/lib.rs +++ b/programs/futarchy/src/lib.rs @@ -74,6 +74,46 @@ pub mod futarchy { InitializeProposal::handle(ctx) } + #[access_control(ctx.accounts.validate(&args))] + pub fn initialize_large_spend_proposal( + ctx: Context, + args: InitializeLargeSpendProposalArgs, + ) -> Result<()> { + InitializeLargeSpendProposal::handle(ctx, args) + } + + #[access_control(ctx.accounts.validate())] + pub fn initialize_mint_tokens_proposal( + ctx: Context, + args: InitializeMintTokensProposalArgs, + ) -> Result<()> { + InitializeMintTokensProposal::handle(ctx, args) + } + + #[access_control(ctx.accounts.validate(&args))] + pub fn initialize_spending_limit_change_proposal( + ctx: Context, + args: InitializeSpendingLimitChangeProposalArgs, + ) -> Result<()> { + InitializeSpendingLimitChangeProposal::handle(ctx, args) + } + + #[access_control(ctx.accounts.validate(&args))] + pub fn initialize_hostile_takeover_proposal( + ctx: Context, + args: InitializeHostileTakeoverProposalArgs, + ) -> Result<()> { + InitializeHostileTakeoverProposal::handle(ctx, args) + } + + #[access_control(ctx.accounts.validate())] + pub fn initialize_hostile_liquidate_proposal( + ctx: Context, + args: InitializeHostileLiquidateProposalArgs, + ) -> Result<()> { + InitializeHostileLiquidateProposal::handle(ctx, args) + } + #[access_control(ctx.accounts.validate(¶ms))] pub fn stake_to_proposal( ctx: Context, @@ -105,12 +145,35 @@ pub mod futarchy { UpdateDao::handle(ctx, dao_params) } + #[access_control(ctx.accounts.validate(&args))] + pub fn set_spending_limit( + ctx: Context, + args: SetSpendingLimitArgs, + ) -> Result<()> { + SetSpendingLimit::handle(ctx, args) + } + + #[access_control(ctx.accounts.validate())] + pub fn sync_spending_limit(ctx: Context) -> Result<()> { + SyncSpendingLimit::handle(ctx) + } + + #[access_control(ctx.accounts.validate())] + pub fn apply_liquidation(ctx: Context) -> Result<()> { + ApplyLiquidation::handle(ctx) + } + pub fn resize_dao(ctx: Context) -> Result<()> { ResizeDao::handle(ctx) } + pub fn resize_proposal(ctx: Context) -> Result<()> { + ResizeProposal::handle(ctx) + } + // AMM instructions + #[access_control(ctx.accounts.validate())] pub fn spot_swap(ctx: Context, params: SpotSwapParams) -> Result<()> { SpotSwap::handle(ctx, params) } @@ -123,6 +186,7 @@ pub mod futarchy { ConditionalSwap::handle(ctx, params) } + #[access_control(ctx.accounts.validate())] pub fn provide_liquidity( ctx: Context, params: ProvideLiquidityParams, @@ -142,13 +206,6 @@ pub mod futarchy { CollectFees::handle(ctx) } - #[access_control(ctx.accounts.validate())] - pub fn execute_spending_limit_change<'c: 'info, 'info>( - ctx: Context<'_, '_, 'c, 'info, ExecuteSpendingLimitChange<'info>>, - ) -> Result<()> { - ExecuteSpendingLimitChange::handle(ctx) - } - #[access_control(ctx.accounts.validate())] pub fn sponsor_proposal(ctx: Context) -> Result<()> { SponsorProposal::handle(ctx) @@ -159,19 +216,6 @@ 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(&args))] pub fn admin_enqueue_multisig_proposal_approval( ctx: Context, diff --git a/programs/futarchy/src/state/dao.rs b/programs/futarchy/src/state/dao.rs index 62cfd536..965e3bce 100644 --- a/programs/futarchy/src/state/dao.rs +++ b/programs/futarchy/src/state/dao.rs @@ -57,6 +57,9 @@ pub struct Dao { /// Minimum amount of base tokens that must be staked to launch a proposal pub base_to_stake: u64, pub seq_num: u64, + /// The authoritative record of what the Squads spending limit should be. + /// `None` = no limit. Kept in sync with the Squads-side account by + /// `sync_spending_limit`. (Named for its original init-only role) 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. @@ -66,6 +69,16 @@ pub struct Dao { pub team_address: Pubkey, pub optimistic_proposal: Option, pub is_optimistic_governance_enabled: bool, + /// `Some` means the DAO has been liquidated, and holds who runs the estate. + /// Set once by `apply_liquidation`, never cleared. + pub liquidator: Option, + /// Unix time of the last failed hostile takeover. 0 = never. + pub last_failed_takeover_at: i64, + /// Unix time of the last failed hostile liquidation. 0 = never. + pub last_failed_liquidation_at: i64, + /// Set by every write to the spending-limit record (`initial_spending_limit`), + /// consumed by `sync_spending_limit`. + pub spending_limit_dirty: bool, } #[derive(AnchorSerialize, AnchorDeserialize, Debug, Clone, PartialEq, Eq, InitSpace)] @@ -191,4 +204,6 @@ pub struct OldDao { /// 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, } diff --git a/programs/futarchy/src/state/mod.rs b/programs/futarchy/src/state/mod.rs index b261e139..6726cecf 100644 --- a/programs/futarchy/src/state/mod.rs +++ b/programs/futarchy/src/state/mod.rs @@ -3,6 +3,7 @@ pub mod dao; pub mod enqueued_multisig_proposal_approval; pub mod futarchy_amm; pub mod proposal; +pub mod proposal_action; pub mod stake_account; pub use amm_position::*; @@ -10,6 +11,7 @@ pub use dao::*; pub use enqueued_multisig_proposal_approval::*; pub use futarchy_amm::*; pub use proposal::*; +pub use proposal_action::*; pub use stake_account::*; pub use super::*; diff --git a/programs/futarchy/src/state/proposal.rs b/programs/futarchy/src/state/proposal.rs index 331bf9e2..816603e4 100644 --- a/programs/futarchy/src/state/proposal.rs +++ b/programs/futarchy/src/state/proposal.rs @@ -36,4 +36,31 @@ pub struct Proposal { pub fail_base_mint: Pubkey, pub fail_quote_mint: Pubkey, pub is_team_sponsored: bool, + /// Snapshot of the kind's threshold at create. + pub pass_threshold_bps: i16, + /// Snapshot of the kind's blockable flag at create. + pub council_can_block: bool, + /// The typed action parameters. + pub action: ProposalAction, +} + +#[account] +#[derive(InitSpace)] +pub struct OldProposal { + pub number: u32, + pub proposer: Pubkey, + pub timestamp_enqueued: i64, + pub state: ProposalState, + pub base_vault: Pubkey, + pub quote_vault: Pubkey, + pub dao: Pubkey, + pub pda_bump: u8, + pub question: Pubkey, + pub duration_in_seconds: u32, + pub squads_proposal: Pubkey, + pub pass_base_mint: Pubkey, + pub pass_quote_mint: Pubkey, + pub fail_base_mint: Pubkey, + pub fail_quote_mint: Pubkey, + pub is_team_sponsored: bool, } diff --git a/programs/futarchy/src/state/proposal_action.rs b/programs/futarchy/src/state/proposal_action.rs new file mode 100644 index 00000000..41269062 --- /dev/null +++ b/programs/futarchy/src/state/proposal_action.rs @@ -0,0 +1,101 @@ +use super::*; + +pub const DAY_SECONDS: u32 = 24 * 60 * 60; + +#[derive(AnchorSerialize, AnchorDeserialize, Debug, Clone, Copy, PartialEq, Eq, InitSpace)] +pub struct InstructionParams { + pub duration_seconds: u32, + /// Signed: a negative threshold lets a proposal pass even when the pass + /// price is below the fail price. + pub pass_threshold_bps: i16, + /// Launch condition: the proposal must be team-sponsored to launch. + pub requires_team_sponsorship: bool, + pub council_can_block: bool, + /// Failure-triggered cooldown, checked at launch. 0 = none. + pub cooldown_seconds: u32, +} + +/// What a hostile takeover declares for the spending limit. +#[derive(AnchorSerialize, AnchorDeserialize, Debug, Clone, PartialEq, Eq, InitSpace)] +pub enum SpendingLimitAction { + Keep, + Remove, + Set(InitialSpendingLimit), +} + +/// The typed action parameters, stored on the proposal. The borsh variant tag +/// is the proposal's kind discriminator, so variants are append-only — the +/// variant index is the wire tag. +#[derive(AnchorSerialize, AnchorDeserialize, Debug, Clone, PartialEq, Eq, InitSpace)] +pub enum ProposalAction { + LargeSpend { + amount: u64, + }, + MintTokens { + amount: u64, + recipient: Pubkey, + }, + /// `Some` = replace the spending limit, `None` = remove it. + SpendingLimitChange { + config: Option, + }, + ExecuteArbitrary, + HostileTakeover { + new_team_address: Pubkey, + spending_limit_action: SpendingLimitAction, + }, + HostileLiquidate { + liquidator: Pubkey, + }, +} + +impl ProposalAction { + /// The protocol-wide parameters of each proposal kind. Hardcoded: there is + /// no per-DAO tuning. + pub fn params(&self) -> InstructionParams { + match self { + ProposalAction::LargeSpend { .. } => InstructionParams { + duration_seconds: DAY_SECONDS * 3 / 2, // 1.5 days + pass_threshold_bps: -1000, + requires_team_sponsorship: true, + council_can_block: true, + cooldown_seconds: 0, + }, + ProposalAction::MintTokens { .. } => InstructionParams { + duration_seconds: DAY_SECONDS * 5, + pass_threshold_bps: 500, + requires_team_sponsorship: false, + council_can_block: true, + cooldown_seconds: 0, + }, + ProposalAction::SpendingLimitChange { .. } => InstructionParams { + duration_seconds: DAY_SECONDS * 5, + pass_threshold_bps: 500, + requires_team_sponsorship: true, + council_can_block: true, + cooldown_seconds: 0, + }, + ProposalAction::ExecuteArbitrary => InstructionParams { + duration_seconds: DAY_SECONDS * 10, + pass_threshold_bps: 1000, + requires_team_sponsorship: false, + council_can_block: true, + cooldown_seconds: 0, + }, + ProposalAction::HostileTakeover { .. } => InstructionParams { + duration_seconds: DAY_SECONDS * 20, + pass_threshold_bps: 1000, + requires_team_sponsorship: false, + council_can_block: false, + cooldown_seconds: DAY_SECONDS * 20, + }, + ProposalAction::HostileLiquidate { .. } => InstructionParams { + duration_seconds: DAY_SECONDS * 10, + pass_threshold_bps: 2500, + requires_team_sponsorship: false, + council_can_block: false, + cooldown_seconds: DAY_SECONDS * 10, + }, + } + } +} diff --git a/scripts/v0.6/executeProposal.ts b/scripts/v0.6/executeProposal.ts deleted file mode 100644 index b46ab5e9..00000000 --- a/scripts/v0.6/executeProposal.ts +++ /dev/null @@ -1,68 +0,0 @@ -import * as anchor from "@coral-xyz/anchor"; -import { FutarchyClient } from "@metadaoproject/programs/futarchy/v0.6"; -import { PublicKey, Transaction } from "@solana/web3.js"; -import * as multisig from "@sqds/multisig"; - -const provider = anchor.AnchorProvider.env(); -const payer = provider.wallet["payer"]; -const futarchy = FutarchyClient.createClient({ provider }); - -const PROPOSAL = new PublicKey("FUTARCHY_PROPOSAL_HERE"); - -const executeSpendingLimit = async () => { - const proposal = await futarchy.getProposal(PROPOSAL); - const squadsProposal = proposal.squadsProposal; - - const proposalAccount = await multisig.accounts.Proposal.fromAccountAddress( - provider.connection, - squadsProposal, - ); - - const dao = await futarchy.getDao(proposal.dao); - const multisigPda = dao.squadsMultisig; - - const [vaultTransactionPda] = await multisig.getTransactionPda({ - multisigPda, - index: BigInt(proposalAccount.transactionIndex.toString()), - }); - - const vaultTransaction = - await multisig.accounts.VaultTransaction.fromAccountAddress( - provider.connection, - vaultTransactionPda, - ); - - // Get remaining accounts from the vault transaction - const remainingAccounts = vaultTransaction.message.accountKeys.map((key) => ({ - pubkey: key, - isWritable: true, - isSigner: false, - })); - - const executeIx = await futarchy.futarchy.methods - .executeSpendingLimitChange() - .accounts({ - proposal: PROPOSAL, - dao: proposal.dao, - squadsProposal: squadsProposal, - squadsMultisig: multisigPda, - squadsMultisigProgram: multisig.PROGRAM_ID, - vaultTransaction: vaultTransactionPda, - }) - .remainingAccounts(remainingAccounts) - .instruction(); - - const tx = new Transaction().add(executeIx); - tx.recentBlockhash = ( - await provider.connection.getLatestBlockhash() - ).blockhash; - tx.feePayer = payer.publicKey; - tx.sign(payer); - - const txHash = await provider.connection.sendRawTransaction(tx.serialize()); - await provider.connection.confirmTransaction(txHash, "confirmed"); - - console.log(`Transaction: ${txHash}`); -}; - -executeSpendingLimit().catch(console.error); diff --git a/scripts/v0.6/migrateDaosProposals.ts b/scripts/v0.6/migrateDaosProposals.ts new file mode 100644 index 00000000..301f7858 --- /dev/null +++ b/scripts/v0.6/migrateDaosProposals.ts @@ -0,0 +1,206 @@ +import { + ComputeBudgetProgram, + Keypair, + PublicKey, + TransactionInstruction, + VersionedTransaction, + TransactionMessage, +} from "@solana/web3.js"; +import * as anchor from "@coral-xyz/anchor"; +import { FutarchyClient } from "@metadaoproject/programs/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), + ); +} + +// Reads the proposal's dao pubkey straight from account data, so it works on +// both the old and new layouts (they share the prefix). ProposalState is a +// borsh enum whose Draft variant (tag 0) carries a u64, so everything after +// it shifts by 8 bytes on draft proposals. +function getProposalDao(data: Buffer): PublicKey { + const stateOffset = 8 + 4 + 32 + 8; // discriminator, number, proposer, timestamp_enqueued + const stateSize = data[stateOffset] === 0 ? 9 : 1; + const daoOffset = stateOffset + stateSize + 32 + 32; // base_vault, quote_vault + return new PublicKey(data.subarray(daoOffset, daoOffset + 32)); +} + +async function main() { + const futarchy = FutarchyClient.createClient({ provider }); + + const daoDiscriminator = getDiscriminator("Dao"); + const proposalDiscriminator = getDiscriminator("Proposal"); + + const daoBatchSize = 15; + // Each resize_proposal also references the proposal's dao, so the worst + // case is two unique account keys per instruction; 10 keeps the batch under + // the transaction size limit. + const proposalBatchSize = 10; + + console.log(`DAO discriminator (hex): ${daoDiscriminator.toString("hex")}`); + console.log(`DAO discriminator (base58): ${bs58.encode(daoDiscriminator)}`); + console.log( + `Proposal discriminator (hex): ${proposalDiscriminator.toString("hex")}`, + ); + console.log( + `Proposal discriminator (base58): ${bs58.encode(proposalDiscriminator)}`, + ); + console.log(`Program ID: ${futarchy.futarchy.programId.toBase58()}\n`); + + // DAOs must be fully cranked before proposals: resize_proposal deserializes + // the proposal's dao in the new layout. + 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}`, + ); + } + + const proposalAccounts = await provider.connection.getProgramAccounts( + futarchy.futarchy.programId, + { + filters: [ + { + memcmp: { + offset: 0, + bytes: bs58.encode(proposalDiscriminator), + }, + }, + ], + }, + ); + + console.log(`Found ${proposalAccounts.length} Proposals`); + for (let i = 0; i < proposalAccounts.length; i += proposalBatchSize) { + const batch = proposalAccounts.slice( + i, + Math.min(i + proposalBatchSize, proposalAccounts.length), + ); + console.log( + `Processing batch ${Math.floor(i / proposalBatchSize) + 1} with ${batch.length} Proposals`, + ); + + const ixs = await Promise.all( + batch.map(async ({ pubkey, account }) => { + return await futarchy.futarchy.methods + .resizeProposal() + .accounts({ + proposal: pubkey, + dao: getProposalDao(account.data), + payer: payer.publicKey, + }) + .instruction(); + }), + ); + + await sendAndConfirmTransaction( + ixs, + `Resize Proposals batch ${Math.floor(i / proposalBatchSize) + 1}`, + ); + } + + console.log("Confirming daos and proposals can be loaded through SDK..."); + const daos = await futarchy.futarchy.account.dao.all(); + console.log(`Confirmed ${daos.length} DAOs`); + const proposals = await futarchy.futarchy.account.proposal.all(); + console.log(`Confirmed ${proposals.length} Proposals`); +} + +main().catch((error) => { + console.error("Fatal error:", error); + process.exit(1); +}); + +async function sendAndConfirmTransaction( + ixs: TransactionInstruction[], + label: string, + signers: Keypair[] = [], +) { + const { blockhash } = await provider.connection.getLatestBlockhash(); + + // Simulate without compute budget to get units consumed + 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), + }); + + // Rebuild transaction with compute budget instruction prepended + 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; +} diff --git a/sdk/src/futarchy/v0.6/FutarchyClient.ts b/sdk/src/futarchy/v0.6/FutarchyClient.ts index 2468db64..de130e19 100644 --- a/sdk/src/futarchy/v0.6/FutarchyClient.ts +++ b/sdk/src/futarchy/v0.6/FutarchyClient.ts @@ -11,8 +11,8 @@ import { } from "@solana/web3.js"; import { createAssociatedTokenAccountIdempotentInstruction, - createTransferInstruction, getAssociatedTokenAddressSync, + getMint, TOKEN_PROGRAM_ID, } from "@solana/spl-token"; import { sha256 } from "@noble/hashes/sha256"; @@ -23,6 +23,7 @@ import { FUTARCHY_V0_6_PROGRAM_ID, CONDITIONAL_VAULT_V0_4_PROGRAM_ID, MAINNET_USDC, + MINT_GOVERNOR_V0_7_PROGRAM_ID, PERMISSIONLESS_ACCOUNT, SQUADS_PROGRAM_CONFIG, SQUADS_PROGRAM_CONFIG_TREASURY, @@ -33,6 +34,7 @@ import { LAUNCHPAD_V0_7_PROGRAM_ID, DAMM_V2_POOL_AUTHORITY, } from "../../constants.js"; +import { getMintAuthorityAddr } from "../../mint_governor/v0.7/pda.js"; import { getEventAuthorityAddr } from "../../pda.js"; import { InstructionUtils } from "../../utils.js"; @@ -41,16 +43,24 @@ import { Proposal, InitializeDaoParams, UpdateDaoParams, + SetSpendingLimitArgs, + SpendingLimitAction, } from "./types/index.js"; import { Futarchy, IDL as FutarchyIDL } from "./types/futarchy.js"; import { Futarchy as v0_6_0_futarchy, IDL as v0_6_0_futarchyIDL, } from "./types/v0.6.0-futarchy.js"; +import { + Futarchy as v0_6_1_futarchy, + IDL as v0_6_1_futarchyIDL, +} from "./types/v0.6.1-futarchy.js"; import { getDaoAddr, getProposalAddr, getProposalAddrV2, + getProposalAddrsForTransactionIndex, + getSpendingLimitAddr, getStakeAddr, } from "./pda.js"; @@ -72,10 +82,18 @@ export type ProposalVaults = { quoteVault: PublicKey; }; +// The slice of Anchor's MethodsBuilder the typed-create orchestrator drives. +type TypedCreateMethodsBuilder = { + preInstructions(ixs: TransactionInstruction[]): { + rpc(): Promise; + }; +}; + export class FutarchyClient { public readonly provider: AnchorProvider; public readonly futarchy: Program; public readonly v0_6_0_futarchy: Program; + public readonly v0_6_1_futarchy: Program; public readonly vaultClient: ConditionalVaultClient; public readonly luts: AddressLookupTableAccount[]; @@ -96,6 +114,11 @@ export class FutarchyClient { futarchyProgramId, provider, ); + this.v0_6_1_futarchy = new Program( + v0_6_1_futarchyIDL, + futarchyProgramId, + provider, + ); this.vaultClient = ConditionalVaultClient.createClient({ provider, conditionalVaultProgramId, @@ -737,6 +760,477 @@ export class FutarchyClient { ]); } + // The PDA set for the proposal a typed create would make next: reads the + // multisig's current transaction index and derives the Squads transaction, + // Squads proposal, and futarchy proposal addresses for index + 1. + async getNextProposalAddrs(dao: PublicKey): Promise<{ + transactionIndex: bigint; + squadsMultisig: PublicKey; + squadsTransaction: PublicKey; + squadsProposal: PublicKey; + proposal: PublicKey; + }> { + const multisigPda = multisig.getMultisigPda({ createKey: dao })[0]; + const multisigAccount = await multisig.accounts.Multisig.fromAccountAddress( + this.provider.connection, + multisigPda, + ); + const transactionIndex = + BigInt(multisigAccount.transactionIndex.toString()) + 1n; + + return { + transactionIndex, + ...getProposalAddrsForTransactionIndex({ + dao, + transactionIndex, + programId: this.futarchy.programId, + }), + }; + } + + // The shared orchestration of every typed create: create the question and + // both conditional vaults (same flow as `initializeProposal`), then send the + // per-type create instruction built by `buildCreateIx` — the instruction + // itself creates the Squads transaction and proposal at the next + // transaction index. + private async createTypedProposal({ + dao, + buildCreateIx, + }: { + dao: PublicKey; + buildCreateIx: (params: { + storedDao: Dao; + transactionIndex: bigint; + }) => TypedCreateMethodsBuilder | Promise; + }): Promise<{ + proposal: PublicKey; + squadsProposal: PublicKey; + squadsTransaction: PublicKey; + }> { + const storedDao = await this.getDao(dao); + const { transactionIndex, squadsTransaction, squadsProposal, proposal } = + await this.getNextProposalAddrs(dao); + + await this.vaultClient.initializeQuestion( + sha256(`Will ${proposal} pass?/FAIL/PASS`), + proposal, + 2, + ); + + const { question } = this.getProposalPdas( + proposal, + storedDao.baseMint, + storedDao.quoteMint, + dao, + ); + + // it's important that these happen in a single atomic transaction + await this.vaultClient + .initializeVaultIx(question, storedDao.baseMint, 2) + .postInstructions( + await InstructionUtils.getInstructions( + this.vaultClient.initializeVaultIx(question, storedDao.quoteMint, 2), + ), + ) + .rpc(); + + await (await buildCreateIx({ storedDao, transactionIndex })) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 400_000 }), + ]) + .rpc(); + + return { proposal, squadsProposal, squadsTransaction }; + } + + // The `create` composite accounts shared by every typed create instruction, + // for the proposal at the given transaction index. + private typedCreateAccounts({ + dao, + baseMint, + quoteMint, + transactionIndex, + proposer, + payer, + }: { + dao: PublicKey; + baseMint: PublicKey; + quoteMint: PublicKey; + transactionIndex: bigint; + proposer: PublicKey; + payer: PublicKey; + }) { + const { squadsMultisig, squadsTransaction, squadsProposal, proposal } = + getProposalAddrsForTransactionIndex({ + dao, + transactionIndex, + programId: this.futarchy.programId, + }); + const { question, baseVault, quoteVault } = this.getProposalPdas( + proposal, + baseMint, + quoteMint, + dao, + ); + + return { + proposal, + dao, + squadsMultisig, + squadsTransaction, + squadsProposal, + question, + baseVault, + quoteVault, + proposer, + payer, + permissionlessAccount: PERMISSIONLESS_ACCOUNT.publicKey, + squadsProgram: SQUADS_PROGRAM_ID, + systemProgram: SystemProgram.programId, + }; + } + + async initializeLargeSpendProposal({ + dao, + amount, + }: { + dao: PublicKey; + amount: BN; + }): Promise<{ + proposal: PublicKey; + squadsProposal: PublicKey; + squadsTransaction: PublicKey; + }> { + return this.createTypedProposal({ + dao, + buildCreateIx: ({ storedDao, transactionIndex }) => + this.initializeLargeSpendProposalIx({ + dao, + baseMint: storedDao.baseMint, + quoteMint: storedDao.quoteMint, + amount, + transactionIndex, + }), + }); + } + + initializeLargeSpendProposalIx({ + dao, + baseMint, + quoteMint, + amount, + transactionIndex, + proposer = this.provider.publicKey, + payer = this.provider.publicKey, + }: { + dao: PublicKey; + baseMint: PublicKey; + quoteMint: PublicKey; + amount: BN; + transactionIndex: bigint; + proposer?: PublicKey; + payer?: PublicKey; + }) { + return this.futarchy.methods + .initializeLargeSpendProposal({ amount }) + .accounts({ + create: this.typedCreateAccounts({ + dao, + baseMint, + quoteMint, + transactionIndex, + proposer, + payer, + }), + }) + .signers([PERMISSIONLESS_ACCOUNT]); + } + + // Reads the base mint's authority to pick the template branch: the Squads + // vault → SPL MintTo, a MintGovernor → mint_governor::mint_tokens; anything + // else is refused by the program. + async initializeMintTokensProposal({ + dao, + amount, + recipient, + }: { + dao: PublicKey; + amount: BN; + recipient: PublicKey; + }): Promise<{ + proposal: PublicKey; + squadsProposal: PublicKey; + squadsTransaction: PublicKey; + }> { + return this.createTypedProposal({ + dao, + buildCreateIx: async ({ storedDao, transactionIndex }) => { + const baseMintInfo = await getMint( + this.provider.connection, + storedDao.baseMint, + ); + let mintGovernor: PublicKey | null = null; + if ( + baseMintInfo.mintAuthority && + !baseMintInfo.mintAuthority.equals(storedDao.squadsMultisigVault) + ) { + const authorityInfo = await this.provider.connection.getAccountInfo( + baseMintInfo.mintAuthority, + ); + if (authorityInfo?.owner.equals(MINT_GOVERNOR_V0_7_PROGRAM_ID)) { + mintGovernor = baseMintInfo.mintAuthority; + } + } + + return this.initializeMintTokensProposalIx({ + dao, + baseMint: storedDao.baseMint, + quoteMint: storedDao.quoteMint, + amount, + recipient, + transactionIndex, + mintGovernor, + }); + }, + }); + } + + initializeMintTokensProposalIx({ + dao, + baseMint, + quoteMint, + amount, + recipient, + transactionIndex, + mintGovernor = null, + proposer = this.provider.publicKey, + payer = this.provider.publicKey, + }: { + dao: PublicKey; + baseMint: PublicKey; + quoteMint: PublicKey; + amount: BN; + recipient: PublicKey; + transactionIndex: bigint; + mintGovernor?: PublicKey | null; + proposer?: PublicKey; + payer?: PublicKey; + }) { + const multisigPda = multisig.getMultisigPda({ createKey: dao })[0]; + const squadsMultisigVault = multisig.getVaultPda({ + multisigPda, + index: 0, + })[0]; + + const mintAuthority = mintGovernor + ? getMintAuthorityAddr({ + mintGovernor, + authorizedMinter: squadsMultisigVault, + })[0] + : null; + + return this.futarchy.methods + .initializeMintTokensProposal({ amount, recipient }) + .accounts({ + create: this.typedCreateAccounts({ + dao, + baseMint, + quoteMint, + transactionIndex, + proposer, + payer, + }), + baseMint, + mintGovernor, + mintAuthority, + }) + .signers([PERMISSIONLESS_ACCOUNT]); + } + + // The payload is one vault-signed set_spending_limit: `config` replaces the + // record verbatim, null removes it. + async initializeSpendingLimitChangeProposal({ + dao, + config, + }: { + dao: PublicKey; + config: SetSpendingLimitArgs["config"]; + }): Promise<{ + proposal: PublicKey; + squadsProposal: PublicKey; + squadsTransaction: PublicKey; + }> { + return this.createTypedProposal({ + dao, + buildCreateIx: ({ storedDao, transactionIndex }) => + this.initializeSpendingLimitChangeProposalIx({ + dao, + baseMint: storedDao.baseMint, + quoteMint: storedDao.quoteMint, + config, + transactionIndex, + }), + }); + } + + initializeSpendingLimitChangeProposalIx({ + dao, + baseMint, + quoteMint, + config, + transactionIndex, + proposer = this.provider.publicKey, + payer = this.provider.publicKey, + }: { + dao: PublicKey; + baseMint: PublicKey; + quoteMint: PublicKey; + config: SetSpendingLimitArgs["config"]; + transactionIndex: bigint; + proposer?: PublicKey; + payer?: PublicKey; + }) { + return this.futarchy.methods + .initializeSpendingLimitChangeProposal({ config }) + .accounts({ + create: this.typedCreateAccounts({ + dao, + baseMint, + quoteMint, + transactionIndex, + proposer, + payer, + }), + }) + .signers([PERMISSIONLESS_ACCOUNT]); + } + + // The payload declares the complete post-takeover regime: update_dao + // re-points the team, and unless the action is `keep`, set_spending_limit + // carries the declared limit end state. + async initializeHostileTakeoverProposal({ + dao, + newTeamAddress, + spendingLimitAction, + }: { + dao: PublicKey; + newTeamAddress: PublicKey; + spendingLimitAction: SpendingLimitAction; + }): Promise<{ + proposal: PublicKey; + squadsProposal: PublicKey; + squadsTransaction: PublicKey; + }> { + return this.createTypedProposal({ + dao, + buildCreateIx: ({ storedDao, transactionIndex }) => + this.initializeHostileTakeoverProposalIx({ + dao, + baseMint: storedDao.baseMint, + quoteMint: storedDao.quoteMint, + newTeamAddress, + spendingLimitAction, + transactionIndex, + }), + }); + } + + initializeHostileTakeoverProposalIx({ + dao, + baseMint, + quoteMint, + newTeamAddress, + spendingLimitAction, + transactionIndex, + proposer = this.provider.publicKey, + payer = this.provider.publicKey, + }: { + dao: PublicKey; + baseMint: PublicKey; + quoteMint: PublicKey; + newTeamAddress: PublicKey; + spendingLimitAction: SpendingLimitAction; + transactionIndex: bigint; + proposer?: PublicKey; + payer?: PublicKey; + }) { + return this.futarchy.methods + .initializeHostileTakeoverProposal({ + newTeamAddress, + spendingLimitAction, + }) + .accounts({ + create: this.typedCreateAccounts({ + dao, + baseMint, + quoteMint, + transactionIndex, + proposer, + payer, + }), + }) + .signers([PERMISSIONLESS_ACCOUNT]); + } + + // The payload is one apply_liquidation call whose accounts — including this + // proposal's own not-yet-created PDA — the program bakes by derivation from + // the next transaction index. The liquidator is stored in `action`. + async initializeHostileLiquidateProposal({ + dao, + liquidator, + }: { + dao: PublicKey; + liquidator: PublicKey; + }): Promise<{ + proposal: PublicKey; + squadsProposal: PublicKey; + squadsTransaction: PublicKey; + }> { + return this.createTypedProposal({ + dao, + buildCreateIx: ({ storedDao, transactionIndex }) => + this.initializeHostileLiquidateProposalIx({ + dao, + baseMint: storedDao.baseMint, + quoteMint: storedDao.quoteMint, + liquidator, + transactionIndex, + }), + }); + } + + initializeHostileLiquidateProposalIx({ + dao, + baseMint, + quoteMint, + liquidator, + transactionIndex, + proposer = this.provider.publicKey, + payer = this.provider.publicKey, + }: { + dao: PublicKey; + baseMint: PublicKey; + quoteMint: PublicKey; + liquidator: PublicKey; + transactionIndex: bigint; + proposer?: PublicKey; + payer?: PublicKey; + }) { + return this.futarchy.methods + .initializeHostileLiquidateProposal({ liquidator }) + .accounts({ + create: this.typedCreateAccounts({ + dao, + baseMint, + quoteMint, + transactionIndex, + proposer, + payer, + }), + }) + .signers([PERMISSIONLESS_ACCOUNT]); + } + async finalizeProposal(proposal: PublicKey) { let storedProposal = await this.getProposal(proposal); let storedDao = await this.getDao(storedProposal.dao); @@ -865,6 +1359,44 @@ export class FutarchyClient { }); } + setSpendingLimitIx({ + dao, + config, + }: { + dao: PublicKey; + config: SetSpendingLimitArgs["config"]; + }) { + const multisigPda = multisig.getMultisigPda({ createKey: dao })[0]; + const squadsMultisigVault = multisig.getVaultPda({ + multisigPda, + index: 0, + })[0]; + + return this.futarchy.methods.setSpendingLimit({ config }).accounts({ + dao, + squadsMultisigVault, + }); + } + + syncSpendingLimitIx({ + dao, + rentPayer = this.provider.publicKey, + }: { + dao: PublicKey; + rentPayer?: PublicKey; + }) { + const multisigPda = multisig.getMultisigPda({ createKey: dao })[0]; + const [spendingLimit] = getSpendingLimitAddr({ dao }); + + return this.futarchy.methods.syncSpendingLimit().accounts({ + dao, + squadsMultisig: multisigPda, + spendingLimit, + rentPayer, + squadsProgram: SQUADS_PROGRAM_ID, + }); + } + stakeToProposalIx({ proposal, dao, @@ -1148,128 +1680,4 @@ 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]; - - const daoQuoteVaultAccount = getAssociatedTokenAddressSync( - quoteMint, - squadsMultisigVault, - true, - ); - const recipientQuoteAccount = getAssociatedTokenAddressSync( - quoteMint, - recipient, - true, - ); - - // Build the SPL token transfer instruction for the vault transaction - const transferIx = createTransferInstruction( - daoQuoteVaultAccount, - recipientQuoteAccount, - squadsMultisigVault, - BigInt(amount.toString()), - ); - - // Use the vault as the payerKey so it deduplicates with the transfer authority, - // producing a clean message with exactly 1 signer (the vault). - const transactionMessage = new TransactionMessage({ - payerKey: squadsMultisigVault, - recentBlockhash: "", - instructions: [transferIx], - }); - - const vaultTxCreate = multisig.instructions.vaultTransactionCreate({ - multisigPda, - transactionIndex, - creator: PERMISSIONLESS_ACCOUNT.publicKey, - rentPayer: payer, - vaultIndex: 0, - ephemeralSigners: 0, - transactionMessage, - }); - - const proposalCreate = multisig.instructions.proposalCreate({ - multisigPda, - transactionIndex, - creator: PERMISSIONLESS_ACCOUNT.publicKey, - rentPayer: payer, - }); - - return this.futarchy.methods - .initiateVaultSpendOptimisticProposal({ amount }) - .accounts({ - squadsMultisig: multisigPda, - squadsMultisigVault, - squadsSpendingLimit, - squadsProposal, - squadsVaultTransaction, - dao, - daoQuoteVaultAccount, - proposer, - recipient, - recipientQuoteAccount, - squadsProgram: SQUADS_PROGRAM_ID, - tokenProgram: TOKEN_PROGRAM_ID, - }) - .preInstructions([ - createAssociatedTokenAccountIdempotentInstruction( - payer, - recipientQuoteAccount, - recipient, - quoteMint, - ), - vaultTxCreate, - proposalCreate, - ]); - } - - finalizeOptimisticProposalIx({ - dao, - squadsProposal, - }: { - dao: PublicKey; - squadsProposal: PublicKey; - }) { - const multisigPda = multisig.getMultisigPda({ createKey: dao })[0]; - - return this.futarchy.methods.finalizeOptimisticProposal().accounts({ - squadsMultisig: multisigPda, - squadsProposal, - dao, - squadsProgram: SQUADS_PROGRAM_ID, - }); - } } diff --git a/sdk/src/futarchy/v0.6/pda.ts b/sdk/src/futarchy/v0.6/pda.ts index 000e4386..b2796fb4 100644 --- a/sdk/src/futarchy/v0.6/pda.ts +++ b/sdk/src/futarchy/v0.6/pda.ts @@ -1,5 +1,6 @@ import { BN, utils } from "@coral-xyz/anchor"; import { PublicKey } from "@solana/web3.js"; +import * as multisig from "@sqds/multisig"; import { FUTARCHY_V0_6_PROGRAM_ID } from "../../constants.js"; @@ -42,6 +43,55 @@ export const getProposalAddrV2 = ({ return getProposalAddr(programId, squadsProposal); }; +// The Squads transaction + proposal PDAs for a given transaction index, and +// the futarchy proposal PDA seeded on that Squads proposal. All three are +// derivable before anything exists — the typed create instructions create the +// Squads accounts at the next index themselves, so the client pins every +// address up front. The client-side twin of the on-chain derivation. +export const getProposalAddrsForTransactionIndex = ({ + dao, + transactionIndex, + programId = FUTARCHY_V0_6_PROGRAM_ID, +}: { + dao: PublicKey; + transactionIndex: bigint; + programId?: PublicKey; +}): { + squadsMultisig: PublicKey; + squadsTransaction: PublicKey; + squadsProposal: PublicKey; + proposal: PublicKey; +} => { + const multisigPda = multisig.getMultisigPda({ createKey: dao })[0]; + const [squadsTransaction] = multisig.getTransactionPda({ + multisigPda, + index: transactionIndex, + }); + const [squadsProposal] = multisig.getProposalPda({ + multisigPda, + transactionIndex, + }); + const [proposal] = getProposalAddr(programId, squadsProposal); + + return { + squadsMultisig: multisigPda, + squadsTransaction, + squadsProposal, + proposal, + }; +}; + +// The Squads spending-limit PDA — `create_key` is always the DAO, so the +// address is derivable from the DAO alone. +export const getSpendingLimitAddr = ({ + dao, +}: { + dao: PublicKey; +}): [PublicKey, number] => { + const multisigPda = multisig.getMultisigPda({ createKey: dao })[0]; + return multisig.getSpendingLimitPda({ multisigPda, createKey: dao }); +}; + export const getStakeAddr = ( programId: PublicKey = FUTARCHY_V0_6_PROGRAM_ID, draftProposal: PublicKey, diff --git a/sdk/src/futarchy/v0.6/types/futarchy.ts b/sdk/src/futarchy/v0.6/types/futarchy.ts index 9af98c32..30759895 100644 --- a/sdk/src/futarchy/v0.6/types/futarchy.ts +++ b/sdk/src/futarchy/v0.6/types/futarchy.ts @@ -1,5 +1,5 @@ export type Futarchy = { - version: "0.6.1"; + version: "0.6.2"; name: "futarchy"; instructions: [ { @@ -171,6 +171,550 @@ export type Futarchy = { ]; args: []; }, + { + name: "initializeLargeSpendProposal"; + accounts: [ + { + name: "create"; + accounts: [ + { + name: "proposal"; + isMut: true; + isSigner: false; + }, + { + name: "dao"; + isMut: true; + isSigner: false; + }, + { + name: "squadsMultisig"; + isMut: true; + isSigner: false; + }, + { + name: "squadsTransaction"; + isMut: true; + isSigner: false; + docs: [ + "and enforces that it is the transaction PDA for the next transaction index", + ]; + }, + { + name: "squadsProposal"; + isMut: true; + isSigner: false; + docs: [ + "enforces that it is the proposal PDA for the next transaction index", + ]; + }, + { + name: "question"; + isMut: false; + isSigner: false; + }, + { + name: "baseVault"; + isMut: false; + isSigner: false; + }, + { + name: "quoteVault"; + isMut: false; + isSigner: false; + }, + { + name: "proposer"; + isMut: false; + isSigner: true; + }, + { + name: "payer"; + isMut: true; + isSigner: true; + }, + { + name: "permissionlessAccount"; + isMut: false; + isSigner: true; + docs: [ + "The Squads-side creator of the vault transaction and proposal, an", + "Initiate | Execute member of every DAO multisig. Its keypair ships in", + "the SDK, so anyone can provide this signature.", + ]; + }, + { + name: "squadsProgram"; + isMut: false; + isSigner: false; + }, + { + name: "systemProgram"; + isMut: false; + isSigner: false; + }, + ]; + }, + { + name: "eventAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "program"; + isMut: false; + isSigner: false; + }, + ]; + args: [ + { + name: "args"; + type: { + defined: "InitializeLargeSpendProposalArgs"; + }; + }, + ]; + }, + { + name: "initializeMintTokensProposal"; + accounts: [ + { + name: "create"; + accounts: [ + { + name: "proposal"; + isMut: true; + isSigner: false; + }, + { + name: "dao"; + isMut: true; + isSigner: false; + }, + { + name: "squadsMultisig"; + isMut: true; + isSigner: false; + }, + { + name: "squadsTransaction"; + isMut: true; + isSigner: false; + docs: [ + "and enforces that it is the transaction PDA for the next transaction index", + ]; + }, + { + name: "squadsProposal"; + isMut: true; + isSigner: false; + docs: [ + "enforces that it is the proposal PDA for the next transaction index", + ]; + }, + { + name: "question"; + isMut: false; + isSigner: false; + }, + { + name: "baseVault"; + isMut: false; + isSigner: false; + }, + { + name: "quoteVault"; + isMut: false; + isSigner: false; + }, + { + name: "proposer"; + isMut: false; + isSigner: true; + }, + { + name: "payer"; + isMut: true; + isSigner: true; + }, + { + name: "permissionlessAccount"; + isMut: false; + isSigner: true; + docs: [ + "The Squads-side creator of the vault transaction and proposal, an", + "Initiate | Execute member of every DAO multisig. Its keypair ships in", + "the SDK, so anyone can provide this signature.", + ]; + }, + { + name: "squadsProgram"; + isMut: false; + isSigner: false; + }, + { + name: "systemProgram"; + isMut: false; + isSigner: false; + }, + ]; + }, + { + name: "baseMint"; + isMut: false; + isSigner: false; + }, + { + name: "mintGovernor"; + isMut: false; + isSigner: false; + isOptional: true; + docs: [ + "Only for governed mints (v0.8 launches): the `MintGovernor` holding the", + "base mint's authority.", + ]; + }, + { + name: "mintAuthority"; + isMut: false; + isSigner: false; + isOptional: true; + docs: [ + "Only for governed mints: the vault's minting rights on `mint_governor`.", + ]; + }, + { + name: "eventAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "program"; + isMut: false; + isSigner: false; + }, + ]; + args: [ + { + name: "args"; + type: { + defined: "InitializeMintTokensProposalArgs"; + }; + }, + ]; + }, + { + name: "initializeSpendingLimitChangeProposal"; + accounts: [ + { + name: "create"; + accounts: [ + { + name: "proposal"; + isMut: true; + isSigner: false; + }, + { + name: "dao"; + isMut: true; + isSigner: false; + }, + { + name: "squadsMultisig"; + isMut: true; + isSigner: false; + }, + { + name: "squadsTransaction"; + isMut: true; + isSigner: false; + docs: [ + "and enforces that it is the transaction PDA for the next transaction index", + ]; + }, + { + name: "squadsProposal"; + isMut: true; + isSigner: false; + docs: [ + "enforces that it is the proposal PDA for the next transaction index", + ]; + }, + { + name: "question"; + isMut: false; + isSigner: false; + }, + { + name: "baseVault"; + isMut: false; + isSigner: false; + }, + { + name: "quoteVault"; + isMut: false; + isSigner: false; + }, + { + name: "proposer"; + isMut: false; + isSigner: true; + }, + { + name: "payer"; + isMut: true; + isSigner: true; + }, + { + name: "permissionlessAccount"; + isMut: false; + isSigner: true; + docs: [ + "The Squads-side creator of the vault transaction and proposal, an", + "Initiate | Execute member of every DAO multisig. Its keypair ships in", + "the SDK, so anyone can provide this signature.", + ]; + }, + { + name: "squadsProgram"; + isMut: false; + isSigner: false; + }, + { + name: "systemProgram"; + isMut: false; + isSigner: false; + }, + ]; + }, + { + name: "eventAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "program"; + isMut: false; + isSigner: false; + }, + ]; + args: [ + { + name: "args"; + type: { + defined: "InitializeSpendingLimitChangeProposalArgs"; + }; + }, + ]; + }, + { + name: "initializeHostileTakeoverProposal"; + accounts: [ + { + name: "create"; + accounts: [ + { + name: "proposal"; + isMut: true; + isSigner: false; + }, + { + name: "dao"; + isMut: true; + isSigner: false; + }, + { + name: "squadsMultisig"; + isMut: true; + isSigner: false; + }, + { + name: "squadsTransaction"; + isMut: true; + isSigner: false; + docs: [ + "and enforces that it is the transaction PDA for the next transaction index", + ]; + }, + { + name: "squadsProposal"; + isMut: true; + isSigner: false; + docs: [ + "enforces that it is the proposal PDA for the next transaction index", + ]; + }, + { + name: "question"; + isMut: false; + isSigner: false; + }, + { + name: "baseVault"; + isMut: false; + isSigner: false; + }, + { + name: "quoteVault"; + isMut: false; + isSigner: false; + }, + { + name: "proposer"; + isMut: false; + isSigner: true; + }, + { + name: "payer"; + isMut: true; + isSigner: true; + }, + { + name: "permissionlessAccount"; + isMut: false; + isSigner: true; + docs: [ + "The Squads-side creator of the vault transaction and proposal, an", + "Initiate | Execute member of every DAO multisig. Its keypair ships in", + "the SDK, so anyone can provide this signature.", + ]; + }, + { + name: "squadsProgram"; + isMut: false; + isSigner: false; + }, + { + name: "systemProgram"; + isMut: false; + isSigner: false; + }, + ]; + }, + { + name: "eventAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "program"; + isMut: false; + isSigner: false; + }, + ]; + args: [ + { + name: "args"; + type: { + defined: "InitializeHostileTakeoverProposalArgs"; + }; + }, + ]; + }, + { + name: "initializeHostileLiquidateProposal"; + accounts: [ + { + name: "create"; + accounts: [ + { + name: "proposal"; + isMut: true; + isSigner: false; + }, + { + name: "dao"; + isMut: true; + isSigner: false; + }, + { + name: "squadsMultisig"; + isMut: true; + isSigner: false; + }, + { + name: "squadsTransaction"; + isMut: true; + isSigner: false; + docs: [ + "and enforces that it is the transaction PDA for the next transaction index", + ]; + }, + { + name: "squadsProposal"; + isMut: true; + isSigner: false; + docs: [ + "enforces that it is the proposal PDA for the next transaction index", + ]; + }, + { + name: "question"; + isMut: false; + isSigner: false; + }, + { + name: "baseVault"; + isMut: false; + isSigner: false; + }, + { + name: "quoteVault"; + isMut: false; + isSigner: false; + }, + { + name: "proposer"; + isMut: false; + isSigner: true; + }, + { + name: "payer"; + isMut: true; + isSigner: true; + }, + { + name: "permissionlessAccount"; + isMut: false; + isSigner: true; + docs: [ + "The Squads-side creator of the vault transaction and proposal, an", + "Initiate | Execute member of every DAO multisig. Its keypair ships in", + "the SDK, so anyone can provide this signature.", + ]; + }, + { + name: "squadsProgram"; + isMut: false; + isSigner: false; + }, + { + name: "systemProgram"; + isMut: false; + isSigner: false; + }, + ]; + }, + { + name: "eventAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "program"; + isMut: false; + isSigner: false; + }, + ]; + args: [ + { + name: "args"; + type: { + defined: "InitializeHostileLiquidateProposalArgs"; + }; + }, + ]; + }, { name: "stakeToProposal"; accounts: [ @@ -582,6 +1126,161 @@ export type Futarchy = { }, ]; }, + { + name: "setSpendingLimit"; + accounts: [ + { + name: "dao"; + isMut: true; + isSigner: false; + }, + { + name: "squadsMultisigVault"; + isMut: false; + isSigner: true; + }, + { + name: "eventAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "program"; + isMut: false; + isSigner: false; + }, + ]; + args: [ + { + name: "args"; + type: { + defined: "SetSpendingLimitArgs"; + }; + }, + ]; + }, + { + name: "syncSpendingLimit"; + accounts: [ + { + name: "dao"; + isMut: true; + isSigner: false; + }, + { + name: "squadsMultisig"; + isMut: true; + isSigner: false; + }, + { + name: "spendingLimit"; + isMut: true; + isSigner: false; + }, + { + name: "rentPayer"; + isMut: true; + isSigner: true; + docs: [ + "Pays rent when the limit is recreated and receives freed rent when it is removed.", + ]; + }, + { + name: "squadsProgram"; + isMut: false; + isSigner: false; + }, + { + name: "systemProgram"; + isMut: false; + isSigner: false; + }, + { + name: "eventAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "program"; + isMut: false; + isSigner: false; + }, + ]; + args: []; + }, + { + name: "applyLiquidation"; + accounts: [ + { + name: "proposal"; + isMut: false; + isSigner: false; + docs: [ + "The linked liquidation proposal, baked into the payload at create.", + ]; + }, + { + name: "dao"; + isMut: true; + isSigner: false; + }, + { + name: "squadsMultisigVault"; + isMut: false; + isSigner: true; + docs: [ + "The vault's signature is only obtainable through a Squads vault", + "transaction execution, so the caller is a passed proposal's payload.", + ]; + }, + { + name: "ammPosition"; + isMut: true; + isSigner: false; + docs: [ + "seeds, but whether the account exists at execution is unknowable at", + "create, so it is parsed manually — a passed liquidation must never", + "brick on treasury shape.", + ]; + }, + { + name: "ammBaseVault"; + isMut: true; + isSigner: false; + }, + { + name: "ammQuoteVault"; + isMut: true; + isSigner: false; + }, + { + name: "vaultBaseAccount"; + isMut: true; + isSigner: false; + }, + { + name: "vaultQuoteAccount"; + isMut: true; + isSigner: false; + }, + { + name: "tokenProgram"; + isMut: false; + isSigner: false; + }, + { + name: "eventAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "program"; + isMut: false; + isSigner: false; + }, + ]; + args: []; + }, { name: "resizeDao"; accounts: [ @@ -603,6 +1302,36 @@ export type Futarchy = { ]; args: []; }, + { + name: "resizeProposal"; + accounts: [ + { + name: "proposal"; + isMut: true; + isSigner: false; + }, + { + name: "dao"; + isMut: false; + isSigner: false; + docs: [ + "The proposal's DAO, checked against the deserialized proposal in the", + "handler. Must already be migrated to the new layout (crank DAOs first).", + ]; + }, + { + name: "payer"; + isMut: true; + isSigner: true; + }, + { + name: "systemProgram"; + isMut: false; + isSigner: false; + }, + ]; + args: []; + }, { name: "spotSwap"; accounts: [ @@ -950,72 +1679,36 @@ export type Futarchy = { }, { name: "baseTokenAccount"; - isMut: true; - isSigner: false; - }, - { - name: "quoteTokenAccount"; - isMut: true; - isSigner: false; - }, - { - name: "ammBaseVault"; - isMut: true; - isSigner: false; - }, - { - name: "ammQuoteVault"; - isMut: true; - isSigner: false; - }, - { - name: "tokenProgram"; - isMut: false; - isSigner: false; - }, - { - name: "eventAuthority"; - isMut: false; - isSigner: false; - }, - { - name: "program"; - isMut: false; - isSigner: false; - }, - ]; - args: []; - }, - { - name: "executeSpendingLimitChange"; - accounts: [ + isMut: true; + isSigner: false; + }, { - name: "proposal"; + name: "quoteTokenAccount"; isMut: true; isSigner: false; }, { - name: "dao"; + name: "ammBaseVault"; isMut: true; isSigner: false; }, { - name: "squadsProposal"; + name: "ammQuoteVault"; isMut: true; isSigner: false; }, { - name: "squadsMultisig"; + name: "tokenProgram"; isMut: false; isSigner: false; }, { - name: "squadsMultisigProgram"; + name: "eventAuthority"; isMut: false; isSigner: false; }, { - name: "vaultTransaction"; + name: "program"; isMut: false; isSigner: false; }, @@ -1201,125 +1894,6 @@ export type Futarchy = { ]; args: []; }, - { - name: "initiateVaultSpendOptimisticProposal"; - accounts: [ - { - name: "squadsMultisig"; - isMut: false; - isSigner: false; - }, - { - name: "squadsMultisigVault"; - isMut: false; - isSigner: false; - }, - { - name: "squadsSpendingLimit"; - isMut: false; - isSigner: false; - }, - { - name: "squadsProposal"; - isMut: false; - isSigner: false; - }, - { - name: "squadsVaultTransaction"; - isMut: false; - isSigner: false; - }, - { - name: "dao"; - isMut: true; - isSigner: false; - }, - { - name: "daoQuoteVaultAccount"; - isMut: false; - isSigner: false; - }, - { - name: "proposer"; - isMut: false; - isSigner: true; - }, - { - name: "recipient"; - isMut: false; - isSigner: false; - }, - { - name: "recipientQuoteAccount"; - 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: "adminEnqueueMultisigProposalApproval"; accounts: [ @@ -1737,6 +2311,11 @@ export type Futarchy = { }, { name: "initialSpendingLimit"; + docs: [ + "The authoritative record of what the Squads spending limit should be.", + "`None` = no limit. Kept in sync with the Squads-side account by", + "`sync_spending_limit`. (Named for its original init-only role)", + ]; type: { option: { defined: "InitialSpendingLimit"; @@ -1769,6 +2348,36 @@ export type Futarchy = { name: "isOptimisticGovernanceEnabled"; type: "bool"; }, + { + name: "liquidator"; + docs: [ + "`Some` means the DAO has been liquidated, and holds who runs the estate.", + "Set once by `apply_liquidation`, never cleared.", + ]; + type: { + option: "publicKey"; + }; + }, + { + name: "lastFailedTakeoverAt"; + docs: ["Unix time of the last failed hostile takeover. 0 = never."]; + type: "i64"; + }, + { + name: "lastFailedLiquidationAt"; + docs: [ + "Unix time of the last failed hostile liquidation. 0 = never.", + ]; + type: "i64"; + }, + { + name: "spendingLimitDirty"; + docs: [ + "Set by every write to the spending-limit record (`initial_spending_limit`),", + "consumed by `sync_spending_limit`.", + ]; + type: "bool"; + }, ]; }; }, @@ -1869,67 +2478,170 @@ export type Futarchy = { type: "u64"; }, { - name: "minBaseFutarchicLiquidity"; - 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: "enqueuedMultisigProposalApproval"; + type: { + kind: "struct"; + fields: [ + { + name: "dao"; + type: "publicKey"; + }, + { + name: "transactionIndex"; + type: "u64"; + }, + { + name: "pdaBump"; + type: "u8"; + }, + ]; + }; + }, + { + name: "proposal"; + type: { + kind: "struct"; + fields: [ + { + name: "number"; + type: "u32"; + }, + { + name: "proposer"; + type: "publicKey"; + }, + { + name: "timestampEnqueued"; + type: "i64"; + }, + { + name: "state"; + type: { + defined: "ProposalState"; + }; + }, + { + name: "baseVault"; + type: "publicKey"; + }, + { + name: "quoteVault"; + type: "publicKey"; + }, + { + name: "dao"; + type: "publicKey"; + }, + { + name: "pdaBump"; + type: "u8"; + }, + { + name: "question"; + type: "publicKey"; }, { - name: "baseToStake"; - docs: [ - "Minimum amount of base tokens that must be staked to launch a proposal", - ]; - type: "u64"; + name: "durationInSeconds"; + type: "u32"; }, { - name: "seqNum"; - type: "u64"; + name: "squadsProposal"; + type: "publicKey"; }, { - name: "initialSpendingLimit"; - type: { - option: { - defined: "InitialSpendingLimit"; - }; - }; + name: "passBaseMint"; + type: "publicKey"; }, { - 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: "passQuoteMint"; + type: "publicKey"; }, { - name: "teamAddress"; + name: "failBaseMint"; type: "publicKey"; }, - ]; - }; - }, - { - name: "enqueuedMultisigProposalApproval"; - type: { - kind: "struct"; - fields: [ { - name: "dao"; + name: "failQuoteMint"; type: "publicKey"; }, { - name: "transactionIndex"; - type: "u64"; + name: "isTeamSponsored"; + type: "bool"; }, { - name: "pdaBump"; - type: "u8"; + name: "passThresholdBps"; + docs: ["Snapshot of the kind's threshold at create."]; + type: "i16"; + }, + { + name: "councilCanBlock"; + docs: ["Snapshot of the kind's blockable flag at create."]; + type: "bool"; + }, + { + name: "action"; + docs: ["The typed action parameters."]; + type: { + defined: "ProposalAction"; + }; }, ]; }; }, { - name: "proposal"; + name: "oldProposal"; type: { kind: "struct"; fields: [ @@ -2149,7 +2861,49 @@ export type Futarchy = { }; }, { - name: "InitiateVaultSpendOptimisticProposalParams"; + name: "InitializeHostileLiquidateProposalArgs"; + type: { + kind: "struct"; + fields: [ + { + name: "liquidator"; + type: "publicKey"; + }, + ]; + }; + }, + { + name: "InitializeHostileTakeoverProposalArgs"; + type: { + kind: "struct"; + fields: [ + { + name: "newTeamAddress"; + type: "publicKey"; + }, + { + name: "spendingLimitAction"; + type: { + defined: "SpendingLimitAction"; + }; + }, + ]; + }; + }, + { + name: "InitializeLargeSpendProposalArgs"; + type: { + kind: "struct"; + fields: [ + { + name: "amount"; + type: "u64"; + }, + ]; + }; + }, + { + name: "InitializeMintTokensProposalArgs"; type: { kind: "struct"; fields: [ @@ -2157,6 +2911,27 @@ export type Futarchy = { name: "amount"; type: "u64"; }, + { + name: "recipient"; + type: "publicKey"; + }, + ]; + }; + }, + { + name: "InitializeSpendingLimitChangeProposalArgs"; + type: { + kind: "struct"; + fields: [ + { + name: "config"; + docs: ["`Some` replaces the record, `None` removes it."]; + type: { + option: { + defined: "InitialSpendingLimit"; + }; + }; + }, ]; }; }, @@ -2191,6 +2966,25 @@ export type Futarchy = { ]; }; }, + { + name: "SetSpendingLimitArgs"; + type: { + kind: "struct"; + fields: [ + { + name: "config"; + docs: [ + "`Some` becomes the new record verbatim; `None` deletes it.", + ]; + type: { + option: { + defined: "InitialSpendingLimit"; + }; + }; + }, + ]; + }; + }, { name: "SpotSwapParams"; type: { @@ -2510,6 +3304,42 @@ export type Futarchy = { ]; }; }, + { + name: "InstructionParams"; + type: { + kind: "struct"; + fields: [ + { + name: "durationSeconds"; + type: "u32"; + }, + { + name: "passThresholdBps"; + docs: [ + "Signed: a negative threshold lets a proposal pass even when the pass", + "price is below the fail price.", + ]; + type: "i16"; + }, + { + name: "requiresTeamSponsorship"; + docs: [ + "Launch condition: the proposal must be team-sponsored to launch.", + ]; + type: "bool"; + }, + { + name: "councilCanBlock"; + type: "bool"; + }, + { + name: "cooldownSeconds"; + docs: ["Failure-triggered cooldown, checked at launch. 0 = none."]; + type: "u32"; + }, + ]; + }; + }, { name: "PoolState"; type: { @@ -2597,6 +3427,104 @@ export type Futarchy = { ]; }; }, + { + name: "SpendingLimitAction"; + docs: ["What a hostile takeover declares for the spending limit."]; + type: { + kind: "enum"; + variants: [ + { + name: "Keep"; + }, + { + name: "Remove"; + }, + { + name: "Set"; + fields: [ + { + defined: "InitialSpendingLimit"; + }, + ]; + }, + ]; + }; + }, + { + name: "ProposalAction"; + docs: [ + "The typed action parameters, stored on the proposal. The borsh variant tag", + "is the proposal's kind discriminator, so variants are append-only — the", + "variant index is the wire tag.", + ]; + type: { + kind: "enum"; + variants: [ + { + name: "LargeSpend"; + fields: [ + { + name: "amount"; + type: "u64"; + }, + ]; + }, + { + name: "MintTokens"; + fields: [ + { + name: "amount"; + type: "u64"; + }, + { + name: "recipient"; + type: "publicKey"; + }, + ]; + }, + { + name: "SpendingLimitChange"; + fields: [ + { + name: "config"; + type: { + option: { + defined: "InitialSpendingLimit"; + }; + }; + }, + ]; + }, + { + name: "ExecuteArbitrary"; + }, + { + name: "HostileTakeover"; + fields: [ + { + name: "newTeamAddress"; + type: "publicKey"; + }, + { + name: "spendingLimitAction"; + type: { + defined: "SpendingLimitAction"; + }; + }, + ]; + }, + { + name: "HostileLiquidate"; + fields: [ + { + name: "liquidator"; + type: "publicKey"; + }, + ]; + }, + ]; + }; + }, { name: "ProposalState"; type: { @@ -2930,6 +3858,13 @@ export type Futarchy = { type: "publicKey"; index: false; }, + { + name: "action"; + type: { + defined: "ProposalAction"; + }; + index: false; + }, ]; }, { @@ -3501,7 +4436,7 @@ export type Futarchy = { ]; }, { - name: "InitiateVaultSpendOptimisticProposalEvent"; + name: "SetSpendingLimitEvent"; fields: [ { name: "common"; @@ -3516,54 +4451,49 @@ export type Futarchy = { 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"; + name: "config"; + type: { + option: { + defined: "InitialSpendingLimit"; + }; + }; index: false; }, + ]; + }, + { + name: "SyncSpendingLimitEvent"; + fields: [ { - name: "recipient"; - type: "publicKey"; + name: "common"; + type: { + defined: "CommonFields"; + }; index: false; }, { - name: "daoQuoteVaultAccount"; + name: "dao"; type: "publicKey"; index: false; }, { - name: "recipientQuoteAccount"; + name: "spendingLimit"; type: "publicKey"; index: false; }, { - name: "enqueuedTimestamp"; - type: "i64"; + name: "config"; + type: { + option: { + defined: "InitialSpendingLimit"; + }; + }; index: false; }, ]; }, { - name: "FinalizeOptimisticProposalEvent"; + name: "ApplyLiquidationEvent"; fields: [ { name: "common"; @@ -3578,10 +4508,32 @@ export type Futarchy = { index: false; }, { - name: "squadsProposal"; + name: "proposal"; + type: "publicKey"; + index: false; + }, + { + name: "liquidator"; type: "publicKey"; index: false; }, + { + name: "baseSwept"; + type: "u64"; + index: false; + }, + { + name: "quoteSwept"; + type: "u64"; + index: false; + }, + { + name: "postAmmState"; + type: { + defined: "FutarchyAmm"; + }; + index: false; + }, ]; }, ]; @@ -3801,11 +4753,66 @@ export type Futarchy = { name: "NoActiveOptimisticProposal"; msg: "No active optimistic proposal"; }, + { + code: 6043; + name: "DaoLiquidated"; + msg: "This DAO has been liquidated"; + }, + { + code: 6044; + name: "HostileCooldownActive"; + msg: "A hostile proposal of this kind failed recently, so the cooldown must elapse first"; + }, + { + code: 6045; + name: "NoSpendingLimit"; + msg: "The DAO has no spending limit"; + }, + { + code: 6046; + name: "SpendCapExceeded"; + msg: "Amount exceeds the cap of 3x the monthly spending limit"; + }, + { + code: 6047; + name: "UnknownMintAuthority"; + msg: "The base mint's authority is neither the treasury vault nor a mint governor"; + }, + { + code: 6048; + name: "ProposalNotTeamSponsored"; + msg: "This proposal kind must be team-sponsored before it can launch"; + }, + { + code: 6049; + name: "SpendingLimitNotDirty"; + msg: "The spending limit record hasn't changed, so there is nothing to sync"; + }, + { + code: 6050; + name: "InvalidProposalKind"; + msg: "Wrong proposal kind for this instruction"; + }, + { + code: 6051; + name: "AlreadyLiquidated"; + msg: "This DAO has already been liquidated"; + }, + { + code: 6052; + name: "TooManySpendingLimitMembers"; + msg: "A spending limit can have at most 10 members"; + }, + { + code: 6053; + name: "InvalidLiquidator"; + msg: "Invalid liquidator"; + }, ]; }; export const IDL: Futarchy = { - version: "0.6.1", + version: "0.6.2", name: "futarchy", instructions: [ { @@ -3832,64 +4839,362 @@ export const IDL: Futarchy = { isSigner: false, }, { - name: "baseMint", + name: "baseMint", + isMut: false, + isSigner: false, + }, + { + name: "quoteMint", + isMut: false, + isSigner: false, + }, + { + name: "squadsMultisig", + isMut: true, + isSigner: false, + }, + { + name: "squadsMultisigVault", + isMut: false, + isSigner: false, + }, + { + name: "squadsProgram", + isMut: false, + isSigner: false, + }, + { + name: "squadsProgramConfig", + isMut: false, + isSigner: false, + }, + { + name: "squadsProgramConfigTreasury", + isMut: true, + isSigner: false, + }, + { + name: "spendingLimit", + isMut: true, + isSigner: false, + }, + { + name: "futarchyAmmBaseVault", + isMut: true, + isSigner: false, + }, + { + name: "futarchyAmmQuoteVault", + isMut: true, + isSigner: false, + }, + { + name: "tokenProgram", + isMut: false, + isSigner: false, + }, + { + name: "associatedTokenProgram", + isMut: false, + isSigner: false, + }, + { + name: "eventAuthority", + isMut: false, + isSigner: false, + }, + { + name: "program", + isMut: false, + isSigner: false, + }, + ], + args: [ + { + name: "params", + type: { + defined: "InitializeDaoParams", + }, + }, + ], + }, + { + name: "initializeProposal", + accounts: [ + { + name: "proposal", + isMut: true, + isSigner: false, + }, + { + name: "squadsProposal", + isMut: false, + isSigner: false, + }, + { + name: "squadsMultisig", + isMut: false, + isSigner: false, + }, + { + name: "dao", + isMut: true, + isSigner: false, + }, + { + name: "question", + isMut: false, + isSigner: false, + }, + { + name: "quoteVault", + isMut: false, + isSigner: false, + }, + { + name: "baseVault", + isMut: false, + isSigner: false, + }, + { + name: "proposer", + isMut: false, + isSigner: true, + }, + { + name: "payer", + isMut: true, + isSigner: true, + }, + { + name: "systemProgram", + isMut: false, + isSigner: false, + }, + { + name: "eventAuthority", + isMut: false, + isSigner: false, + }, + { + name: "program", + isMut: false, + isSigner: false, + }, + ], + args: [], + }, + { + name: "initializeLargeSpendProposal", + accounts: [ + { + name: "create", + accounts: [ + { + name: "proposal", + isMut: true, + isSigner: false, + }, + { + name: "dao", + isMut: true, + isSigner: false, + }, + { + name: "squadsMultisig", + isMut: true, + isSigner: false, + }, + { + name: "squadsTransaction", + isMut: true, + isSigner: false, + docs: [ + "and enforces that it is the transaction PDA for the next transaction index", + ], + }, + { + name: "squadsProposal", + isMut: true, + isSigner: false, + docs: [ + "enforces that it is the proposal PDA for the next transaction index", + ], + }, + { + name: "question", + isMut: false, + isSigner: false, + }, + { + name: "baseVault", + isMut: false, + isSigner: false, + }, + { + name: "quoteVault", + isMut: false, + isSigner: false, + }, + { + name: "proposer", + isMut: false, + isSigner: true, + }, + { + name: "payer", + isMut: true, + isSigner: true, + }, + { + name: "permissionlessAccount", + isMut: false, + isSigner: true, + docs: [ + "The Squads-side creator of the vault transaction and proposal, an", + "Initiate | Execute member of every DAO multisig. Its keypair ships in", + "the SDK, so anyone can provide this signature.", + ], + }, + { + name: "squadsProgram", + isMut: false, + isSigner: false, + }, + { + name: "systemProgram", + isMut: false, + isSigner: false, + }, + ], + }, + { + name: "eventAuthority", isMut: false, isSigner: false, }, { - name: "quoteMint", + name: "program", isMut: false, isSigner: false, }, + ], + args: [ { - name: "squadsMultisig", - isMut: true, - isSigner: false, - }, - { - name: "squadsMultisigVault", - isMut: false, - isSigner: false, + name: "args", + type: { + defined: "InitializeLargeSpendProposalArgs", + }, }, + ], + }, + { + name: "initializeMintTokensProposal", + accounts: [ { - name: "squadsProgram", - isMut: false, - isSigner: false, + name: "create", + accounts: [ + { + name: "proposal", + isMut: true, + isSigner: false, + }, + { + name: "dao", + isMut: true, + isSigner: false, + }, + { + name: "squadsMultisig", + isMut: true, + isSigner: false, + }, + { + name: "squadsTransaction", + isMut: true, + isSigner: false, + docs: [ + "and enforces that it is the transaction PDA for the next transaction index", + ], + }, + { + name: "squadsProposal", + isMut: true, + isSigner: false, + docs: [ + "enforces that it is the proposal PDA for the next transaction index", + ], + }, + { + name: "question", + isMut: false, + isSigner: false, + }, + { + name: "baseVault", + isMut: false, + isSigner: false, + }, + { + name: "quoteVault", + isMut: false, + isSigner: false, + }, + { + name: "proposer", + isMut: false, + isSigner: true, + }, + { + name: "payer", + isMut: true, + isSigner: true, + }, + { + name: "permissionlessAccount", + isMut: false, + isSigner: true, + docs: [ + "The Squads-side creator of the vault transaction and proposal, an", + "Initiate | Execute member of every DAO multisig. Its keypair ships in", + "the SDK, so anyone can provide this signature.", + ], + }, + { + name: "squadsProgram", + isMut: false, + isSigner: false, + }, + { + name: "systemProgram", + isMut: false, + isSigner: false, + }, + ], }, { - name: "squadsProgramConfig", + name: "baseMint", isMut: false, isSigner: false, }, { - name: "squadsProgramConfigTreasury", - isMut: true, - isSigner: false, - }, - { - name: "spendingLimit", - isMut: true, - isSigner: false, - }, - { - name: "futarchyAmmBaseVault", - isMut: true, - isSigner: false, - }, - { - name: "futarchyAmmQuoteVault", - isMut: true, - isSigner: false, - }, - { - name: "tokenProgram", + name: "mintGovernor", isMut: false, isSigner: false, + isOptional: true, + docs: [ + "Only for governed mints (v0.8 launches): the `MintGovernor` holding the", + "base mint's authority.", + ], }, { - name: "associatedTokenProgram", + name: "mintAuthority", isMut: false, isSigner: false, + isOptional: true, + docs: [ + "Only for governed mints: the vault's minting rights on `mint_governor`.", + ], }, { name: "eventAuthority", @@ -3904,65 +5209,304 @@ export const IDL: Futarchy = { ], args: [ { - name: "params", + name: "args", type: { - defined: "InitializeDaoParams", + defined: "InitializeMintTokensProposalArgs", }, }, ], }, { - name: "initializeProposal", + name: "initializeSpendingLimitChangeProposal", accounts: [ { - name: "proposal", - isMut: true, - isSigner: false, + name: "create", + accounts: [ + { + name: "proposal", + isMut: true, + isSigner: false, + }, + { + name: "dao", + isMut: true, + isSigner: false, + }, + { + name: "squadsMultisig", + isMut: true, + isSigner: false, + }, + { + name: "squadsTransaction", + isMut: true, + isSigner: false, + docs: [ + "and enforces that it is the transaction PDA for the next transaction index", + ], + }, + { + name: "squadsProposal", + isMut: true, + isSigner: false, + docs: [ + "enforces that it is the proposal PDA for the next transaction index", + ], + }, + { + name: "question", + isMut: false, + isSigner: false, + }, + { + name: "baseVault", + isMut: false, + isSigner: false, + }, + { + name: "quoteVault", + isMut: false, + isSigner: false, + }, + { + name: "proposer", + isMut: false, + isSigner: true, + }, + { + name: "payer", + isMut: true, + isSigner: true, + }, + { + name: "permissionlessAccount", + isMut: false, + isSigner: true, + docs: [ + "The Squads-side creator of the vault transaction and proposal, an", + "Initiate | Execute member of every DAO multisig. Its keypair ships in", + "the SDK, so anyone can provide this signature.", + ], + }, + { + name: "squadsProgram", + isMut: false, + isSigner: false, + }, + { + name: "systemProgram", + isMut: false, + isSigner: false, + }, + ], }, { - name: "squadsProposal", + name: "eventAuthority", isMut: false, isSigner: false, }, { - name: "squadsMultisig", + name: "program", isMut: false, isSigner: false, }, + ], + args: [ { - name: "dao", - isMut: true, - isSigner: false, + name: "args", + type: { + defined: "InitializeSpendingLimitChangeProposalArgs", + }, }, + ], + }, + { + name: "initializeHostileTakeoverProposal", + accounts: [ { - name: "question", - isMut: false, - isSigner: false, + name: "create", + accounts: [ + { + name: "proposal", + isMut: true, + isSigner: false, + }, + { + name: "dao", + isMut: true, + isSigner: false, + }, + { + name: "squadsMultisig", + isMut: true, + isSigner: false, + }, + { + name: "squadsTransaction", + isMut: true, + isSigner: false, + docs: [ + "and enforces that it is the transaction PDA for the next transaction index", + ], + }, + { + name: "squadsProposal", + isMut: true, + isSigner: false, + docs: [ + "enforces that it is the proposal PDA for the next transaction index", + ], + }, + { + name: "question", + isMut: false, + isSigner: false, + }, + { + name: "baseVault", + isMut: false, + isSigner: false, + }, + { + name: "quoteVault", + isMut: false, + isSigner: false, + }, + { + name: "proposer", + isMut: false, + isSigner: true, + }, + { + name: "payer", + isMut: true, + isSigner: true, + }, + { + name: "permissionlessAccount", + isMut: false, + isSigner: true, + docs: [ + "The Squads-side creator of the vault transaction and proposal, an", + "Initiate | Execute member of every DAO multisig. Its keypair ships in", + "the SDK, so anyone can provide this signature.", + ], + }, + { + name: "squadsProgram", + isMut: false, + isSigner: false, + }, + { + name: "systemProgram", + isMut: false, + isSigner: false, + }, + ], }, { - name: "quoteVault", + name: "eventAuthority", isMut: false, isSigner: false, }, { - name: "baseVault", + name: "program", isMut: false, isSigner: false, }, + ], + args: [ { - name: "proposer", - isMut: false, - isSigner: true, - }, - { - name: "payer", - isMut: true, - isSigner: true, + name: "args", + type: { + defined: "InitializeHostileTakeoverProposalArgs", + }, }, + ], + }, + { + name: "initializeHostileLiquidateProposal", + accounts: [ { - name: "systemProgram", - isMut: false, - isSigner: false, + name: "create", + accounts: [ + { + name: "proposal", + isMut: true, + isSigner: false, + }, + { + name: "dao", + isMut: true, + isSigner: false, + }, + { + name: "squadsMultisig", + isMut: true, + isSigner: false, + }, + { + name: "squadsTransaction", + isMut: true, + isSigner: false, + docs: [ + "and enforces that it is the transaction PDA for the next transaction index", + ], + }, + { + name: "squadsProposal", + isMut: true, + isSigner: false, + docs: [ + "enforces that it is the proposal PDA for the next transaction index", + ], + }, + { + name: "question", + isMut: false, + isSigner: false, + }, + { + name: "baseVault", + isMut: false, + isSigner: false, + }, + { + name: "quoteVault", + isMut: false, + isSigner: false, + }, + { + name: "proposer", + isMut: false, + isSigner: true, + }, + { + name: "payer", + isMut: true, + isSigner: true, + }, + { + name: "permissionlessAccount", + isMut: false, + isSigner: true, + docs: [ + "The Squads-side creator of the vault transaction and proposal, an", + "Initiate | Execute member of every DAO multisig. Its keypair ships in", + "the SDK, so anyone can provide this signature.", + ], + }, + { + name: "squadsProgram", + isMut: false, + isSigner: false, + }, + { + name: "systemProgram", + isMut: false, + isSigner: false, + }, + ], }, { name: "eventAuthority", @@ -3975,7 +5519,14 @@ export const IDL: Futarchy = { isSigner: false, }, ], - args: [], + args: [ + { + name: "args", + type: { + defined: "InitializeHostileLiquidateProposalArgs", + }, + }, + ], }, { name: "stakeToProposal", @@ -4388,6 +5939,161 @@ export const IDL: Futarchy = { }, ], }, + { + name: "setSpendingLimit", + accounts: [ + { + name: "dao", + isMut: true, + isSigner: false, + }, + { + name: "squadsMultisigVault", + isMut: false, + isSigner: true, + }, + { + name: "eventAuthority", + isMut: false, + isSigner: false, + }, + { + name: "program", + isMut: false, + isSigner: false, + }, + ], + args: [ + { + name: "args", + type: { + defined: "SetSpendingLimitArgs", + }, + }, + ], + }, + { + name: "syncSpendingLimit", + accounts: [ + { + name: "dao", + isMut: true, + isSigner: false, + }, + { + name: "squadsMultisig", + isMut: true, + isSigner: false, + }, + { + name: "spendingLimit", + isMut: true, + isSigner: false, + }, + { + name: "rentPayer", + isMut: true, + isSigner: true, + docs: [ + "Pays rent when the limit is recreated and receives freed rent when it is removed.", + ], + }, + { + name: "squadsProgram", + isMut: false, + isSigner: false, + }, + { + name: "systemProgram", + isMut: false, + isSigner: false, + }, + { + name: "eventAuthority", + isMut: false, + isSigner: false, + }, + { + name: "program", + isMut: false, + isSigner: false, + }, + ], + args: [], + }, + { + name: "applyLiquidation", + accounts: [ + { + name: "proposal", + isMut: false, + isSigner: false, + docs: [ + "The linked liquidation proposal, baked into the payload at create.", + ], + }, + { + name: "dao", + isMut: true, + isSigner: false, + }, + { + name: "squadsMultisigVault", + isMut: false, + isSigner: true, + docs: [ + "The vault's signature is only obtainable through a Squads vault", + "transaction execution, so the caller is a passed proposal's payload.", + ], + }, + { + name: "ammPosition", + isMut: true, + isSigner: false, + docs: [ + "seeds, but whether the account exists at execution is unknowable at", + "create, so it is parsed manually — a passed liquidation must never", + "brick on treasury shape.", + ], + }, + { + name: "ammBaseVault", + isMut: true, + isSigner: false, + }, + { + name: "ammQuoteVault", + isMut: true, + isSigner: false, + }, + { + name: "vaultBaseAccount", + isMut: true, + isSigner: false, + }, + { + name: "vaultQuoteAccount", + isMut: true, + isSigner: false, + }, + { + name: "tokenProgram", + isMut: false, + isSigner: false, + }, + { + name: "eventAuthority", + isMut: false, + isSigner: false, + }, + { + name: "program", + isMut: false, + isSigner: false, + }, + ], + args: [], + }, { name: "resizeDao", accounts: [ @@ -4409,6 +6115,36 @@ export const IDL: Futarchy = { ], args: [], }, + { + name: "resizeProposal", + accounts: [ + { + name: "proposal", + isMut: true, + isSigner: false, + }, + { + name: "dao", + isMut: false, + isSigner: false, + docs: [ + "The proposal's DAO, checked against the deserialized proposal in the", + "handler. Must already be migrated to the new layout (crank DAOs first).", + ], + }, + { + name: "payer", + isMut: true, + isSigner: true, + }, + { + name: "systemProgram", + isMut: false, + isSigner: false, + }, + ], + args: [], + }, { name: "spotSwap", accounts: [ @@ -4745,83 +6481,47 @@ export const IDL: Futarchy = { name: "collectFees", accounts: [ { - name: "dao", - isMut: true, - isSigner: false, - }, - { - name: "admin", - isMut: false, - isSigner: true, - }, - { - name: "baseTokenAccount", - isMut: true, - isSigner: false, - }, - { - name: "quoteTokenAccount", - isMut: true, - isSigner: false, - }, - { - name: "ammBaseVault", - isMut: true, - isSigner: false, - }, - { - name: "ammQuoteVault", - isMut: true, - isSigner: false, - }, - { - name: "tokenProgram", - isMut: false, + name: "dao", + isMut: true, isSigner: false, }, { - name: "eventAuthority", + name: "admin", isMut: false, - isSigner: false, + isSigner: true, }, { - name: "program", - isMut: false, + name: "baseTokenAccount", + isMut: true, isSigner: false, }, - ], - args: [], - }, - { - name: "executeSpendingLimitChange", - accounts: [ { - name: "proposal", + name: "quoteTokenAccount", isMut: true, isSigner: false, }, { - name: "dao", + name: "ammBaseVault", isMut: true, isSigner: false, }, { - name: "squadsProposal", + name: "ammQuoteVault", isMut: true, isSigner: false, }, { - name: "squadsMultisig", + name: "tokenProgram", isMut: false, isSigner: false, }, { - name: "squadsMultisigProgram", + name: "eventAuthority", isMut: false, isSigner: false, }, { - name: "vaultTransaction", + name: "program", isMut: false, isSigner: false, }, @@ -5007,125 +6707,6 @@ export const IDL: Futarchy = { ], args: [], }, - { - name: "initiateVaultSpendOptimisticProposal", - accounts: [ - { - name: "squadsMultisig", - isMut: false, - isSigner: false, - }, - { - name: "squadsMultisigVault", - isMut: false, - isSigner: false, - }, - { - name: "squadsSpendingLimit", - isMut: false, - isSigner: false, - }, - { - name: "squadsProposal", - isMut: false, - isSigner: false, - }, - { - name: "squadsVaultTransaction", - isMut: false, - isSigner: false, - }, - { - name: "dao", - isMut: true, - isSigner: false, - }, - { - name: "daoQuoteVaultAccount", - isMut: false, - isSigner: false, - }, - { - name: "proposer", - isMut: false, - isSigner: true, - }, - { - name: "recipient", - isMut: false, - isSigner: false, - }, - { - name: "recipientQuoteAccount", - 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: "adminEnqueueMultisigProposalApproval", accounts: [ @@ -5543,6 +7124,11 @@ export const IDL: Futarchy = { }, { name: "initialSpendingLimit", + docs: [ + "The authoritative record of what the Squads spending limit should be.", + "`None` = no limit. Kept in sync with the Squads-side account by", + "`sync_spending_limit`. (Named for its original init-only role)", + ], type: { option: { defined: "InitialSpendingLimit", @@ -5575,6 +7161,36 @@ export const IDL: Futarchy = { name: "isOptimisticGovernanceEnabled", type: "bool", }, + { + name: "liquidator", + docs: [ + "`Some` means the DAO has been liquidated, and holds who runs the estate.", + "Set once by `apply_liquidation`, never cleared.", + ], + type: { + option: "publicKey", + }, + }, + { + name: "lastFailedTakeoverAt", + docs: ["Unix time of the last failed hostile takeover. 0 = never."], + type: "i64", + }, + { + name: "lastFailedLiquidationAt", + docs: [ + "Unix time of the last failed hostile liquidation. 0 = never.", + ], + type: "i64", + }, + { + name: "spendingLimitDirty", + docs: [ + "Set by every write to the spending-limit record (`initial_spending_limit`),", + "consumed by `sync_spending_limit`.", + ], + type: "bool", + }, ], }, }, @@ -5711,6 +7327,18 @@ export const IDL: Futarchy = { name: "teamAddress", type: "publicKey", }, + { + name: "optimisticProposal", + type: { + option: { + defined: "OptimisticProposal", + }, + }, + }, + { + name: "isOptimisticGovernanceEnabled", + type: "bool", + }, ], }, }, @@ -5728,14 +7356,105 @@ export const IDL: Futarchy = { type: "u64", }, { - name: "pdaBump", - type: "u8", + name: "pdaBump", + type: "u8", + }, + ], + }, + }, + { + name: "proposal", + type: { + kind: "struct", + fields: [ + { + name: "number", + type: "u32", + }, + { + name: "proposer", + type: "publicKey", + }, + { + name: "timestampEnqueued", + type: "i64", + }, + { + name: "state", + type: { + defined: "ProposalState", + }, + }, + { + name: "baseVault", + type: "publicKey", + }, + { + name: "quoteVault", + type: "publicKey", + }, + { + name: "dao", + type: "publicKey", + }, + { + name: "pdaBump", + type: "u8", + }, + { + name: "question", + type: "publicKey", + }, + { + name: "durationInSeconds", + type: "u32", + }, + { + name: "squadsProposal", + type: "publicKey", + }, + { + name: "passBaseMint", + type: "publicKey", + }, + { + name: "passQuoteMint", + type: "publicKey", + }, + { + name: "failBaseMint", + type: "publicKey", + }, + { + name: "failQuoteMint", + type: "publicKey", + }, + { + name: "isTeamSponsored", + type: "bool", + }, + { + name: "passThresholdBps", + docs: ["Snapshot of the kind's threshold at create."], + type: "i16", + }, + { + name: "councilCanBlock", + docs: ["Snapshot of the kind's blockable flag at create."], + type: "bool", + }, + { + name: "action", + docs: ["The typed action parameters."], + type: { + defined: "ProposalAction", + }, }, ], }, }, { - name: "proposal", + name: "oldProposal", type: { kind: "struct", fields: [ @@ -5955,7 +7674,49 @@ export const IDL: Futarchy = { }, }, { - name: "InitiateVaultSpendOptimisticProposalParams", + name: "InitializeHostileLiquidateProposalArgs", + type: { + kind: "struct", + fields: [ + { + name: "liquidator", + type: "publicKey", + }, + ], + }, + }, + { + name: "InitializeHostileTakeoverProposalArgs", + type: { + kind: "struct", + fields: [ + { + name: "newTeamAddress", + type: "publicKey", + }, + { + name: "spendingLimitAction", + type: { + defined: "SpendingLimitAction", + }, + }, + ], + }, + }, + { + name: "InitializeLargeSpendProposalArgs", + type: { + kind: "struct", + fields: [ + { + name: "amount", + type: "u64", + }, + ], + }, + }, + { + name: "InitializeMintTokensProposalArgs", type: { kind: "struct", fields: [ @@ -5963,6 +7724,27 @@ export const IDL: Futarchy = { name: "amount", type: "u64", }, + { + name: "recipient", + type: "publicKey", + }, + ], + }, + }, + { + name: "InitializeSpendingLimitChangeProposalArgs", + type: { + kind: "struct", + fields: [ + { + name: "config", + docs: ["`Some` replaces the record, `None` removes it."], + type: { + option: { + defined: "InitialSpendingLimit", + }, + }, + }, ], }, }, @@ -5997,6 +7779,25 @@ export const IDL: Futarchy = { ], }, }, + { + name: "SetSpendingLimitArgs", + type: { + kind: "struct", + fields: [ + { + name: "config", + docs: [ + "`Some` becomes the new record verbatim; `None` deletes it.", + ], + type: { + option: { + defined: "InitialSpendingLimit", + }, + }, + }, + ], + }, + }, { name: "SpotSwapParams", type: { @@ -6316,6 +8117,42 @@ export const IDL: Futarchy = { ], }, }, + { + name: "InstructionParams", + type: { + kind: "struct", + fields: [ + { + name: "durationSeconds", + type: "u32", + }, + { + name: "passThresholdBps", + docs: [ + "Signed: a negative threshold lets a proposal pass even when the pass", + "price is below the fail price.", + ], + type: "i16", + }, + { + name: "requiresTeamSponsorship", + docs: [ + "Launch condition: the proposal must be team-sponsored to launch.", + ], + type: "bool", + }, + { + name: "councilCanBlock", + type: "bool", + }, + { + name: "cooldownSeconds", + docs: ["Failure-triggered cooldown, checked at launch. 0 = none."], + type: "u32", + }, + ], + }, + }, { name: "PoolState", type: { @@ -6403,6 +8240,104 @@ export const IDL: Futarchy = { ], }, }, + { + name: "SpendingLimitAction", + docs: ["What a hostile takeover declares for the spending limit."], + type: { + kind: "enum", + variants: [ + { + name: "Keep", + }, + { + name: "Remove", + }, + { + name: "Set", + fields: [ + { + defined: "InitialSpendingLimit", + }, + ], + }, + ], + }, + }, + { + name: "ProposalAction", + docs: [ + "The typed action parameters, stored on the proposal. The borsh variant tag", + "is the proposal's kind discriminator, so variants are append-only — the", + "variant index is the wire tag.", + ], + type: { + kind: "enum", + variants: [ + { + name: "LargeSpend", + fields: [ + { + name: "amount", + type: "u64", + }, + ], + }, + { + name: "MintTokens", + fields: [ + { + name: "amount", + type: "u64", + }, + { + name: "recipient", + type: "publicKey", + }, + ], + }, + { + name: "SpendingLimitChange", + fields: [ + { + name: "config", + type: { + option: { + defined: "InitialSpendingLimit", + }, + }, + }, + ], + }, + { + name: "ExecuteArbitrary", + }, + { + name: "HostileTakeover", + fields: [ + { + name: "newTeamAddress", + type: "publicKey", + }, + { + name: "spendingLimitAction", + type: { + defined: "SpendingLimitAction", + }, + }, + ], + }, + { + name: "HostileLiquidate", + fields: [ + { + name: "liquidator", + type: "publicKey", + }, + ], + }, + ], + }, + }, { name: "ProposalState", type: { @@ -6736,6 +8671,13 @@ export const IDL: Futarchy = { type: "publicKey", index: false, }, + { + name: "action", + type: { + defined: "ProposalAction", + }, + index: false, + }, ], }, { @@ -7307,7 +9249,7 @@ export const IDL: Futarchy = { ], }, { - name: "InitiateVaultSpendOptimisticProposalEvent", + name: "SetSpendingLimitEvent", fields: [ { name: "common", @@ -7322,70 +9264,87 @@ export const IDL: Futarchy = { index: false, }, { - name: "proposer", - type: "publicKey", + name: "config", + type: { + option: { + defined: "InitialSpendingLimit", + }, + }, index: false, }, + ], + }, + { + name: "SyncSpendingLimitEvent", + fields: [ { - name: "squadsProposal", - type: "publicKey", + name: "common", + type: { + defined: "CommonFields", + }, index: false, }, { - name: "squadsMultisig", + name: "dao", type: "publicKey", index: false, }, { - name: "squadsMultisigVault", + name: "spendingLimit", type: "publicKey", index: false, }, { - name: "amount", - type: "u64", + name: "config", + type: { + option: { + defined: "InitialSpendingLimit", + }, + }, index: false, }, + ], + }, + { + name: "ApplyLiquidationEvent", + fields: [ { - name: "recipient", - type: "publicKey", + name: "common", + type: { + defined: "CommonFields", + }, index: false, }, { - name: "daoQuoteVaultAccount", + name: "dao", type: "publicKey", index: false, }, { - name: "recipientQuoteAccount", + name: "proposal", type: "publicKey", index: false, }, { - name: "enqueuedTimestamp", - type: "i64", + name: "liquidator", + type: "publicKey", index: false, }, - ], - }, - { - name: "FinalizeOptimisticProposalEvent", - fields: [ { - name: "common", - type: { - defined: "CommonFields", - }, + name: "baseSwept", + type: "u64", index: false, }, { - name: "dao", - type: "publicKey", + name: "quoteSwept", + type: "u64", index: false, }, { - name: "squadsProposal", - type: "publicKey", + name: "postAmmState", + type: { + defined: "FutarchyAmm", + }, index: false, }, ], @@ -7607,5 +9566,60 @@ export const IDL: Futarchy = { name: "NoActiveOptimisticProposal", msg: "No active optimistic proposal", }, + { + code: 6043, + name: "DaoLiquidated", + msg: "This DAO has been liquidated", + }, + { + code: 6044, + name: "HostileCooldownActive", + msg: "A hostile proposal of this kind failed recently, so the cooldown must elapse first", + }, + { + code: 6045, + name: "NoSpendingLimit", + msg: "The DAO has no spending limit", + }, + { + code: 6046, + name: "SpendCapExceeded", + msg: "Amount exceeds the cap of 3x the monthly spending limit", + }, + { + code: 6047, + name: "UnknownMintAuthority", + msg: "The base mint's authority is neither the treasury vault nor a mint governor", + }, + { + code: 6048, + name: "ProposalNotTeamSponsored", + msg: "This proposal kind must be team-sponsored before it can launch", + }, + { + code: 6049, + name: "SpendingLimitNotDirty", + msg: "The spending limit record hasn't changed, so there is nothing to sync", + }, + { + code: 6050, + name: "InvalidProposalKind", + msg: "Wrong proposal kind for this instruction", + }, + { + code: 6051, + name: "AlreadyLiquidated", + msg: "This DAO has already been liquidated", + }, + { + code: 6052, + name: "TooManySpendingLimitMembers", + msg: "A spending limit can have at most 10 members", + }, + { + code: 6053, + name: "InvalidLiquidator", + msg: "Invalid liquidator", + }, ], }; diff --git a/sdk/src/futarchy/v0.6/types/index.ts b/sdk/src/futarchy/v0.6/types/index.ts index 7738fb8f..6bc088b0 100644 --- a/sdk/src/futarchy/v0.6/types/index.ts +++ b/sdk/src/futarchy/v0.6/types/index.ts @@ -7,6 +7,12 @@ import { } from "./v0.6.0-futarchy.js"; export { v0_6_0_Futarchy, v0_6_0_FutarchyIDL }; +import { + Futarchy as v0_6_1_Futarchy, + IDL as v0_6_1_FutarchyIDL, +} from "./v0.6.1-futarchy.js"; +export { v0_6_1_Futarchy, v0_6_1_FutarchyIDL }; + export { LowercaseKeys } from "../../../utils.js"; import type { IdlAccounts, IdlTypes, IdlEvents } from "@coral-xyz/anchor"; @@ -14,6 +20,20 @@ import type { IdlAccounts, IdlTypes, IdlEvents } from "@coral-xyz/anchor"; export type InitializeDaoParams = IdlTypes["InitializeDaoParams"]; export type UpdateDaoParams = IdlTypes["UpdateDaoParams"]; +export type SetSpendingLimitArgs = + IdlTypes["SetSpendingLimitArgs"]; +export type InitializeLargeSpendProposalArgs = + IdlTypes["InitializeLargeSpendProposalArgs"]; +export type InitializeMintTokensProposalArgs = + IdlTypes["InitializeMintTokensProposalArgs"]; +export type InitializeSpendingLimitChangeProposalArgs = + IdlTypes["InitializeSpendingLimitChangeProposalArgs"]; +export type InitializeHostileTakeoverProposalArgs = + IdlTypes["InitializeHostileTakeoverProposalArgs"]; +export type InstructionParams = IdlTypes["InstructionParams"]; +export type SpendingLimitAction = + IdlTypes["SpendingLimitAction"]; +export type ProposalAction = IdlTypes["ProposalAction"]; export type Dao = IdlAccounts["dao"]; export type Proposal = IdlAccounts["proposal"]; @@ -41,10 +61,6 @@ export type WithdrawLiquidityEvent = IdlEvents["WithdrawLiquidityEvent"]; export type SponsorProposalEvent = IdlEvents["SponsorProposalEvent"]; -export type InitiateVaultSpendOptimisticProposalEvent = - IdlEvents["InitiateVaultSpendOptimisticProposalEvent"]; -export type FinalizeOptimisticProposalEvent = - IdlEvents["FinalizeOptimisticProposalEvent"]; export type FutarchyEvent = | CollectFeesEvent | InitializeDaoEvent @@ -58,9 +74,7 @@ export type FutarchyEvent = | ConditionalSwapEvent | ProvideLiquidityEvent | WithdrawLiquidityEvent - | SponsorProposalEvent - | InitiateVaultSpendOptimisticProposalEvent - | FinalizeOptimisticProposalEvent; + | SponsorProposalEvent; export type v0_6_0_CollectFeesEvent = IdlEvents["CollectFeesEvent"]; @@ -98,3 +112,61 @@ export type v0_6_0_FutarchyEvent = | v0_6_0_ConditionalSwapEvent | v0_6_0_ProvideLiquidityEvent | v0_6_0_WithdrawLiquidityEvent; + +export type v0_6_1_CollectFeesEvent = + IdlEvents["CollectFeesEvent"]; +export type v0_6_1_InitializeDaoEvent = + IdlEvents["InitializeDaoEvent"]; +export type v0_6_1_UpdateDaoEvent = + IdlEvents["UpdateDaoEvent"]; +export type v0_6_1_InitializeProposalEvent = + IdlEvents["InitializeProposalEvent"]; +export type v0_6_1_StakeToProposalEvent = + IdlEvents["StakeToProposalEvent"]; +export type v0_6_1_UnstakeFromProposalEvent = + IdlEvents["UnstakeFromProposalEvent"]; +export type v0_6_1_LaunchProposalEvent = + IdlEvents["LaunchProposalEvent"]; +export type v0_6_1_FinalizeProposalEvent = + IdlEvents["FinalizeProposalEvent"]; +export type v0_6_1_SpotSwapEvent = IdlEvents["SpotSwapEvent"]; +export type v0_6_1_ConditionalSwapEvent = + IdlEvents["ConditionalSwapEvent"]; +export type v0_6_1_ProvideLiquidityEvent = + IdlEvents["ProvideLiquidityEvent"]; +export type v0_6_1_WithdrawLiquidityEvent = + IdlEvents["WithdrawLiquidityEvent"]; +export type v0_6_1_SponsorProposalEvent = + IdlEvents["SponsorProposalEvent"]; +export type v0_6_1_RemoveProposalEvent = + IdlEvents["RemoveProposalEvent"]; +export type v0_6_1_AdminCancelProposalEvent = + IdlEvents["AdminCancelProposalEvent"]; +export type v0_6_1_CollectMeteoraDammFeesEvent = + IdlEvents["CollectMeteoraDammFeesEvent"]; +export type v0_6_1_AdminFixPositionAuthorityEvent = + IdlEvents["AdminFixPositionAuthorityEvent"]; +export type v0_6_1_InitiateVaultSpendOptimisticProposalEvent = + IdlEvents["InitiateVaultSpendOptimisticProposalEvent"]; +export type v0_6_1_FinalizeOptimisticProposalEvent = + IdlEvents["FinalizeOptimisticProposalEvent"]; +export type v0_6_1_FutarchyEvent = + | v0_6_1_CollectFeesEvent + | v0_6_1_InitializeDaoEvent + | v0_6_1_UpdateDaoEvent + | v0_6_1_InitializeProposalEvent + | v0_6_1_StakeToProposalEvent + | v0_6_1_UnstakeFromProposalEvent + | v0_6_1_LaunchProposalEvent + | v0_6_1_FinalizeProposalEvent + | v0_6_1_SpotSwapEvent + | v0_6_1_ConditionalSwapEvent + | v0_6_1_ProvideLiquidityEvent + | v0_6_1_WithdrawLiquidityEvent + | v0_6_1_SponsorProposalEvent + | v0_6_1_RemoveProposalEvent + | v0_6_1_AdminCancelProposalEvent + | v0_6_1_CollectMeteoraDammFeesEvent + | v0_6_1_AdminFixPositionAuthorityEvent + | v0_6_1_InitiateVaultSpendOptimisticProposalEvent + | v0_6_1_FinalizeOptimisticProposalEvent; diff --git a/sdk/src/futarchy/v0.6/types/v0.6.1-futarchy.ts b/sdk/src/futarchy/v0.6/types/v0.6.1-futarchy.ts new file mode 100644 index 00000000..9af98c32 --- /dev/null +++ b/sdk/src/futarchy/v0.6/types/v0.6.1-futarchy.ts @@ -0,0 +1,7611 @@ +export type Futarchy = { + version: "0.6.1"; + name: "futarchy"; + instructions: [ + { + name: "initializeDao"; + accounts: [ + { + name: "dao"; + isMut: true; + isSigner: false; + }, + { + name: "daoCreator"; + isMut: false; + isSigner: true; + }, + { + name: "payer"; + isMut: true; + isSigner: true; + }, + { + name: "systemProgram"; + isMut: false; + isSigner: false; + }, + { + name: "baseMint"; + isMut: false; + isSigner: false; + }, + { + name: "quoteMint"; + isMut: false; + isSigner: false; + }, + { + name: "squadsMultisig"; + isMut: true; + isSigner: false; + }, + { + name: "squadsMultisigVault"; + isMut: false; + isSigner: false; + }, + { + name: "squadsProgram"; + isMut: false; + isSigner: false; + }, + { + name: "squadsProgramConfig"; + isMut: false; + isSigner: false; + }, + { + name: "squadsProgramConfigTreasury"; + isMut: true; + isSigner: false; + }, + { + name: "spendingLimit"; + isMut: true; + isSigner: false; + }, + { + name: "futarchyAmmBaseVault"; + isMut: true; + isSigner: false; + }, + { + name: "futarchyAmmQuoteVault"; + isMut: true; + isSigner: false; + }, + { + name: "tokenProgram"; + isMut: false; + isSigner: false; + }, + { + name: "associatedTokenProgram"; + isMut: false; + isSigner: false; + }, + { + name: "eventAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "program"; + isMut: false; + isSigner: false; + }, + ]; + args: [ + { + name: "params"; + type: { + defined: "InitializeDaoParams"; + }; + }, + ]; + }, + { + name: "initializeProposal"; + accounts: [ + { + name: "proposal"; + isMut: true; + isSigner: false; + }, + { + name: "squadsProposal"; + isMut: false; + isSigner: false; + }, + { + name: "squadsMultisig"; + isMut: false; + isSigner: false; + }, + { + name: "dao"; + isMut: true; + isSigner: false; + }, + { + name: "question"; + isMut: false; + isSigner: false; + }, + { + name: "quoteVault"; + isMut: false; + isSigner: false; + }, + { + name: "baseVault"; + isMut: false; + isSigner: false; + }, + { + name: "proposer"; + isMut: false; + isSigner: true; + }, + { + name: "payer"; + isMut: true; + isSigner: true; + }, + { + name: "systemProgram"; + isMut: false; + isSigner: false; + }, + { + name: "eventAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "program"; + isMut: false; + isSigner: false; + }, + ]; + args: []; + }, + { + name: "stakeToProposal"; + accounts: [ + { + name: "proposal"; + isMut: true; + isSigner: false; + }, + { + name: "dao"; + isMut: true; + isSigner: false; + }, + { + name: "stakerBaseAccount"; + isMut: true; + isSigner: false; + }, + { + name: "proposalBaseAccount"; + isMut: true; + isSigner: false; + }, + { + name: "stakeAccount"; + isMut: true; + isSigner: false; + }, + { + name: "staker"; + isMut: false; + isSigner: true; + }, + { + name: "payer"; + isMut: true; + isSigner: true; + }, + { + name: "tokenProgram"; + isMut: false; + isSigner: false; + }, + { + name: "systemProgram"; + isMut: false; + isSigner: false; + }, + { + name: "eventAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "program"; + isMut: false; + isSigner: false; + }, + ]; + args: [ + { + name: "params"; + type: { + defined: "StakeToProposalParams"; + }; + }, + ]; + }, + { + name: "unstakeFromProposal"; + accounts: [ + { + name: "proposal"; + isMut: true; + isSigner: false; + }, + { + name: "dao"; + isMut: true; + isSigner: false; + }, + { + name: "stakerBaseAccount"; + isMut: true; + isSigner: false; + }, + { + name: "proposalBaseAccount"; + isMut: true; + isSigner: false; + }, + { + name: "stakeAccount"; + isMut: true; + isSigner: false; + }, + { + name: "baseMint"; + isMut: false; + isSigner: false; + }, + { + name: "staker"; + isMut: true; + isSigner: true; + }, + { + name: "tokenProgram"; + isMut: false; + isSigner: false; + }, + { + name: "systemProgram"; + isMut: false; + isSigner: false; + }, + { + name: "associatedTokenProgram"; + isMut: false; + isSigner: false; + }, + { + name: "eventAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "program"; + isMut: false; + isSigner: false; + }, + ]; + args: [ + { + name: "params"; + type: { + defined: "UnstakeFromProposalParams"; + }; + }, + ]; + }, + { + name: "launchProposal"; + accounts: [ + { + name: "proposal"; + isMut: true; + isSigner: false; + }, + { + name: "baseVault"; + isMut: false; + isSigner: false; + }, + { + name: "quoteVault"; + isMut: false; + isSigner: false; + }, + { + name: "passBaseMint"; + isMut: false; + isSigner: false; + }, + { + name: "passQuoteMint"; + isMut: false; + isSigner: false; + }, + { + name: "failBaseMint"; + isMut: false; + isSigner: false; + }, + { + name: "failQuoteMint"; + isMut: false; + isSigner: false; + }, + { + name: "dao"; + isMut: true; + isSigner: false; + }, + { + name: "payer"; + isMut: true; + isSigner: true; + }, + { + name: "ammPassBaseVault"; + isMut: true; + isSigner: false; + }, + { + name: "ammPassQuoteVault"; + isMut: true; + isSigner: false; + }, + { + name: "ammFailBaseVault"; + isMut: true; + isSigner: false; + }, + { + name: "ammFailQuoteVault"; + isMut: true; + isSigner: false; + }, + { + name: "squadsMultisig"; + isMut: false; + isSigner: false; + }, + { + name: "squadsProposal"; + isMut: false; + isSigner: false; + }, + { + name: "systemProgram"; + isMut: false; + isSigner: false; + }, + { + name: "tokenProgram"; + isMut: false; + isSigner: false; + }, + { + name: "associatedTokenProgram"; + isMut: false; + isSigner: false; + }, + { + name: "eventAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "program"; + isMut: false; + isSigner: false; + }, + ]; + args: []; + }, + { + name: "finalizeProposal"; + accounts: [ + { + name: "proposal"; + isMut: true; + isSigner: false; + }, + { + name: "dao"; + isMut: true; + isSigner: false; + }, + { + name: "question"; + isMut: true; + isSigner: false; + }, + { + name: "squadsProposal"; + isMut: true; + isSigner: false; + }, + { + name: "squadsMultisig"; + isMut: false; + isSigner: false; + }, + { + name: "squadsMultisigProgram"; + isMut: false; + isSigner: false; + }, + { + name: "ammPassBaseVault"; + isMut: true; + isSigner: false; + }, + { + name: "ammPassQuoteVault"; + isMut: true; + isSigner: false; + }, + { + name: "ammFailBaseVault"; + isMut: true; + isSigner: false; + }, + { + name: "ammFailQuoteVault"; + isMut: true; + isSigner: false; + }, + { + name: "ammBaseVault"; + isMut: true; + isSigner: false; + }, + { + name: "ammQuoteVault"; + isMut: true; + isSigner: false; + }, + { + name: "vaultProgram"; + isMut: false; + isSigner: false; + }, + { + name: "vaultEventAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "tokenProgram"; + isMut: false; + isSigner: false; + }, + { + name: "quoteVault"; + isMut: true; + isSigner: false; + }, + { + name: "quoteVaultUnderlyingTokenAccount"; + isMut: true; + isSigner: false; + }, + { + name: "passQuoteMint"; + isMut: true; + isSigner: false; + }, + { + name: "failQuoteMint"; + isMut: true; + isSigner: false; + }, + { + name: "passBaseMint"; + isMut: true; + isSigner: false; + }, + { + name: "failBaseMint"; + isMut: true; + isSigner: false; + }, + { + name: "baseVault"; + isMut: true; + isSigner: false; + }, + { + name: "baseVaultUnderlyingTokenAccount"; + isMut: true; + isSigner: false; + }, + { + name: "eventAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "program"; + isMut: false; + isSigner: false; + }, + ]; + args: []; + }, + { + name: "updateDao"; + accounts: [ + { + name: "dao"; + isMut: true; + isSigner: false; + }, + { + name: "squadsMultisigVault"; + isMut: false; + isSigner: true; + }, + { + name: "eventAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "program"; + isMut: false; + isSigner: false; + }, + ]; + args: [ + { + name: "daoParams"; + type: { + defined: "UpdateDaoParams"; + }; + }, + ]; + }, + { + 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: [ + { + name: "dao"; + isMut: true; + isSigner: false; + }, + { + name: "userBaseAccount"; + isMut: true; + isSigner: false; + }, + { + name: "userQuoteAccount"; + isMut: true; + isSigner: false; + }, + { + name: "ammBaseVault"; + isMut: true; + isSigner: false; + }, + { + name: "ammQuoteVault"; + isMut: true; + isSigner: false; + }, + { + name: "user"; + isMut: false; + isSigner: true; + }, + { + name: "tokenProgram"; + isMut: false; + isSigner: false; + }, + { + name: "eventAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "program"; + isMut: false; + isSigner: false; + }, + ]; + args: [ + { + name: "params"; + type: { + defined: "SpotSwapParams"; + }; + }, + ]; + }, + { + name: "conditionalSwap"; + accounts: [ + { + name: "dao"; + isMut: true; + isSigner: false; + }, + { + name: "ammBaseVault"; + isMut: true; + isSigner: false; + }, + { + name: "ammQuoteVault"; + isMut: true; + isSigner: false; + }, + { + name: "proposal"; + isMut: false; + isSigner: false; + }, + { + name: "ammPassBaseVault"; + isMut: true; + isSigner: false; + }, + { + name: "ammPassQuoteVault"; + isMut: true; + isSigner: false; + }, + { + name: "ammFailBaseVault"; + isMut: true; + isSigner: false; + }, + { + name: "ammFailQuoteVault"; + isMut: true; + isSigner: false; + }, + { + name: "trader"; + isMut: false; + isSigner: true; + }, + { + name: "userInputAccount"; + isMut: true; + isSigner: false; + }, + { + name: "userOutputAccount"; + isMut: true; + isSigner: false; + }, + { + name: "baseVault"; + isMut: true; + isSigner: false; + }, + { + name: "baseVaultUnderlyingTokenAccount"; + isMut: true; + isSigner: false; + }, + { + name: "quoteVault"; + isMut: true; + isSigner: false; + }, + { + name: "quoteVaultUnderlyingTokenAccount"; + isMut: true; + isSigner: false; + }, + { + name: "passBaseMint"; + isMut: true; + isSigner: false; + }, + { + name: "failBaseMint"; + isMut: true; + isSigner: false; + }, + { + name: "passQuoteMint"; + isMut: true; + isSigner: false; + }, + { + name: "failQuoteMint"; + isMut: true; + isSigner: false; + }, + { + name: "conditionalVaultProgram"; + isMut: false; + isSigner: false; + }, + { + name: "vaultEventAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "question"; + 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: "ConditionalSwapParams"; + }; + }, + ]; + }, + { + name: "provideLiquidity"; + accounts: [ + { + name: "dao"; + isMut: true; + isSigner: false; + }, + { + name: "liquidityProvider"; + isMut: false; + isSigner: true; + }, + { + name: "liquidityProviderBaseAccount"; + isMut: true; + isSigner: false; + }, + { + name: "liquidityProviderQuoteAccount"; + isMut: true; + isSigner: false; + }, + { + name: "payer"; + isMut: true; + isSigner: true; + }, + { + name: "systemProgram"; + isMut: false; + isSigner: false; + }, + { + name: "ammBaseVault"; + isMut: true; + isSigner: false; + }, + { + name: "ammQuoteVault"; + isMut: true; + isSigner: false; + }, + { + name: "ammPosition"; + isMut: true; + 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: "ProvideLiquidityParams"; + }; + }, + ]; + }, + { + name: "withdrawLiquidity"; + accounts: [ + { + name: "dao"; + isMut: true; + isSigner: false; + }, + { + name: "positionAuthority"; + isMut: false; + isSigner: true; + }, + { + name: "liquidityProviderBaseAccount"; + isMut: true; + isSigner: false; + }, + { + name: "liquidityProviderQuoteAccount"; + isMut: true; + isSigner: false; + }, + { + name: "ammBaseVault"; + isMut: true; + isSigner: false; + }, + { + name: "ammQuoteVault"; + isMut: true; + isSigner: false; + }, + { + name: "ammPosition"; + isMut: true; + 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: "WithdrawLiquidityParams"; + }; + }, + ]; + }, + { + name: "collectFees"; + accounts: [ + { + name: "dao"; + isMut: true; + isSigner: false; + }, + { + name: "admin"; + isMut: false; + isSigner: true; + }, + { + name: "baseTokenAccount"; + isMut: true; + isSigner: false; + }, + { + name: "quoteTokenAccount"; + isMut: true; + isSigner: false; + }, + { + name: "ammBaseVault"; + isMut: true; + isSigner: false; + }, + { + name: "ammQuoteVault"; + isMut: true; + isSigner: false; + }, + { + name: "tokenProgram"; + isMut: false; + isSigner: false; + }, + { + name: "eventAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "program"; + isMut: false; + isSigner: false; + }, + ]; + args: []; + }, + { + name: "executeSpendingLimitChange"; + accounts: [ + { + name: "proposal"; + isMut: true; + isSigner: false; + }, + { + name: "dao"; + isMut: true; + isSigner: false; + }, + { + name: "squadsProposal"; + isMut: true; + isSigner: false; + }, + { + name: "squadsMultisig"; + isMut: false; + isSigner: false; + }, + { + name: "squadsMultisigProgram"; + isMut: false; + isSigner: false; + }, + { + name: "vaultTransaction"; + isMut: false; + isSigner: false; + }, + ]; + args: []; + }, + { + name: "sponsorProposal"; + accounts: [ + { + name: "proposal"; + isMut: true; + isSigner: false; + }, + { + name: "dao"; + isMut: true; + isSigner: false; + }, + { + name: "teamAddress"; + isMut: false; + isSigner: true; + }, + { + name: "eventAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "program"; + isMut: false; + isSigner: false; + }, + ]; + args: []; + }, + { + name: "collectMeteoraDammFees"; + accounts: [ + { + name: "dao"; + isMut: true; + isSigner: false; + }, + { + name: "admin"; + isMut: true; + isSigner: true; + }, + { + name: "squadsMultisig"; + isMut: true; + isSigner: false; + }, + { + name: "squadsMultisigVault"; + isMut: true; + isSigner: false; + }, + { + name: "squadsMultisigVaultTransaction"; + isMut: true; + isSigner: false; + }, + { + name: "squadsMultisigProposal"; + isMut: true; + isSigner: false; + }, + { + name: "squadsMultisigPermissionlessAccount"; + isMut: false; + isSigner: true; + }, + { + name: "meteoraClaimPositionFeesAccounts"; + accounts: [ + { + name: "dammV2Program"; + isMut: false; + isSigner: false; + }, + { + name: "dammV2EventAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "poolAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "pool"; + isMut: false; + isSigner: false; + }, + { + name: "position"; + isMut: true; + isSigner: false; + }, + { + name: "tokenAAccount"; + isMut: true; + isSigner: false; + docs: ["Token account of base tokens recipient"]; + }, + { + name: "tokenBAccount"; + isMut: true; + isSigner: false; + docs: ["Token account of quote tokens recipient"]; + }, + { + name: "tokenAVault"; + isMut: true; + isSigner: false; + }, + { + name: "tokenBVault"; + isMut: true; + isSigner: false; + }, + { + name: "tokenAMint"; + isMut: false; + isSigner: false; + }, + { + name: "tokenBMint"; + isMut: false; + isSigner: false; + }, + { + name: "positionNftAccount"; + isMut: false; + isSigner: false; + }, + { + name: "owner"; + isMut: false; + isSigner: false; + }, + { + name: "tokenAProgram"; + isMut: false; + isSigner: false; + }, + { + name: "tokenBProgram"; + isMut: false; + isSigner: false; + }, + ]; + }, + { + name: "systemProgram"; + isMut: false; + isSigner: false; + }, + { + name: "tokenProgram"; + isMut: false; + isSigner: false; + }, + { + name: "squadsProgram"; + isMut: false; + isSigner: false; + }, + { + name: "eventAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "program"; + isMut: false; + isSigner: false; + }, + ]; + args: []; + }, + { + name: "initiateVaultSpendOptimisticProposal"; + accounts: [ + { + name: "squadsMultisig"; + isMut: false; + isSigner: false; + }, + { + name: "squadsMultisigVault"; + isMut: false; + isSigner: false; + }, + { + name: "squadsSpendingLimit"; + isMut: false; + isSigner: false; + }, + { + name: "squadsProposal"; + isMut: false; + isSigner: false; + }, + { + name: "squadsVaultTransaction"; + isMut: false; + isSigner: false; + }, + { + name: "dao"; + isMut: true; + isSigner: false; + }, + { + name: "daoQuoteVaultAccount"; + isMut: false; + isSigner: false; + }, + { + name: "proposer"; + isMut: false; + isSigner: true; + }, + { + name: "recipient"; + isMut: false; + isSigner: false; + }, + { + name: "recipientQuoteAccount"; + 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: "adminEnqueueMultisigProposalApproval"; + accounts: [ + { + name: "dao"; + isMut: false; + isSigner: false; + }, + { + name: "admin"; + isMut: true; + isSigner: true; + }, + { + name: "squadsMultisig"; + isMut: false; + isSigner: false; + }, + { + name: "squadsMultisigProposal"; + isMut: false; + isSigner: false; + }, + { + name: "enqueuedApproval"; + isMut: true; + isSigner: false; + }, + { + name: "systemProgram"; + isMut: false; + isSigner: false; + }, + ]; + args: [ + { + name: "args"; + type: { + defined: "AdminEnqueueMultisigProposalApprovalArgs"; + }; + }, + ]; + }, + { + name: "executeMultisigProposalApproval"; + accounts: [ + { + name: "dao"; + isMut: true; + isSigner: false; + }, + { + name: "rentReceiver"; + isMut: true; + isSigner: true; + }, + { + name: "squadsMultisig"; + isMut: true; + isSigner: false; + }, + { + name: "squadsMultisigProposal"; + isMut: true; + isSigner: false; + }, + { + name: "enqueuedApproval"; + isMut: true; + isSigner: false; + }, + { + name: "squadsMultisigProgram"; + isMut: false; + isSigner: false; + }, + ]; + args: []; + }, + { + name: "adminExecuteMultisigProposal"; + 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; + }, + ]; + args: []; + }, + { + name: "adminCancelProposal"; + accounts: [ + { + name: "proposal"; + isMut: true; + isSigner: false; + }, + { + name: "dao"; + isMut: true; + isSigner: false; + }, + { + name: "question"; + isMut: true; + isSigner: false; + }, + { + name: "squadsProposal"; + isMut: true; + isSigner: false; + }, + { + name: "squadsMultisig"; + isMut: false; + isSigner: false; + }, + { + name: "squadsMultisigProgram"; + isMut: false; + isSigner: false; + }, + { + name: "ammPassBaseVault"; + isMut: true; + isSigner: false; + }, + { + name: "ammPassQuoteVault"; + isMut: true; + isSigner: false; + }, + { + name: "ammFailBaseVault"; + isMut: true; + isSigner: false; + }, + { + name: "ammFailQuoteVault"; + isMut: true; + isSigner: false; + }, + { + name: "ammBaseVault"; + isMut: true; + isSigner: false; + }, + { + name: "ammQuoteVault"; + isMut: true; + isSigner: false; + }, + { + name: "vaultProgram"; + isMut: false; + isSigner: false; + }, + { + name: "vaultEventAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "tokenProgram"; + isMut: false; + isSigner: false; + }, + { + name: "quoteVault"; + isMut: true; + isSigner: false; + }, + { + name: "quoteVaultUnderlyingTokenAccount"; + isMut: true; + isSigner: false; + }, + { + name: "passQuoteMint"; + isMut: true; + isSigner: false; + }, + { + name: "failQuoteMint"; + isMut: true; + isSigner: false; + }, + { + name: "passBaseMint"; + isMut: true; + isSigner: false; + }, + { + name: "failBaseMint"; + isMut: true; + isSigner: false; + }, + { + name: "baseVault"; + isMut: true; + isSigner: false; + }, + { + name: "baseVaultUnderlyingTokenAccount"; + isMut: true; + isSigner: false; + }, + { + name: "admin"; + isMut: false; + isSigner: true; + }, + { + name: "eventAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "program"; + isMut: false; + isSigner: false; + }, + ]; + args: []; + }, + { + name: "adminRemoveProposal"; + accounts: [ + { + name: "proposal"; + isMut: true; + isSigner: false; + }, + { + name: "dao"; + isMut: true; + isSigner: false; + }, + { + name: "admin"; + isMut: true; + isSigner: true; + }, + { + name: "eventAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "program"; + isMut: false; + isSigner: false; + }, + ]; + args: []; + }, + ]; + accounts: [ + { + name: "ammPosition"; + type: { + kind: "struct"; + fields: [ + { + 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: "pdaBump"; + type: "u8"; + }, + { + name: "squadsMultisig"; + type: "publicKey"; + }, + { + name: "squadsMultisigVault"; + type: "publicKey"; + }, + { + name: "baseMint"; + type: "publicKey"; + }, + { + name: "quoteMint"; + type: "publicKey"; + }, + { + name: "proposalCount"; + type: "u32"; + }, + { + name: "passThresholdBps"; + type: "u16"; + }, + { + name: "secondsPerProposal"; + type: "u32"; + }, + { + name: "twapInitialObservation"; + docs: [ + "For manipulation-resistance the TWAP is a time-weighted average observation,", + "where observation tries to approximate price but can only move by", + "`twap_max_observation_change_per_update` per update. Because it can only move", + "a little bit per update, you need to check that it has a good initial observation.", + "Otherwise, an attacker could create a very high initial observation in the pass", + "market and a very low one in the fail market to force the proposal to pass.", + "", + "We recommend setting an initial observation around the spot price of the token,", + "and max observation change per update around 2% the spot price of the token.", + "For example, if the spot price of META is $400, we'd recommend setting an initial", + "observation of 400 (converted into the AMM prices) and a max observation change per", + "update of 8 (also converted into the AMM prices). Observations can be updated once", + "a minute, so 2% allows the proposal market to reach double the spot price or 0", + "in 50 minutes.", + ]; + type: "u128"; + }, + { + name: "twapMaxObservationChangePerUpdate"; + type: "u128"; + }, + { + name: "twapStartDelaySeconds"; + docs: [ + "Forces TWAP calculation to start after `twap_start_delay_seconds` seconds", + ]; + type: "u32"; + }, + { + name: "minQuoteFutarchicLiquidity"; + docs: [ + "As an anti-spam measure and to help liquidity, you need to lock up some liquidity", + "in both futarchic markets in order to create a proposal.", + "", + "For example, for META, we can use a `min_quote_futarchic_liquidity` of", + "5000 * 1_000_000 (5000 USDC) and a `min_base_futarchic_liquidity` of", + "10 * 1_000_000_000 (10 META).", + ]; + type: "u64"; + }, + { + name: "minBaseFutarchicLiquidity"; + type: "u64"; + }, + { + name: "baseToStake"; + docs: [ + "Minimum amount of base tokens that must be staked to launch a proposal", + ]; + type: "u64"; + }, + { + name: "seqNum"; + type: "u64"; + }, + { + name: "initialSpendingLimit"; + type: { + option: { + defined: "InitialSpendingLimit"; + }; + }; + }, + { + name: "teamSponsoredPassThresholdBps"; + docs: [ + "The percentage, in basis points, the pass price needs to be above the", + "fail price in order for the proposal to pass for team-sponsored proposals.", + "", + "Can be negative to allow for team-sponsored proposals to pass by default.", + ]; + type: "i16"; + }, + { + name: "teamAddress"; + type: "publicKey"; + }, + { + name: "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"; + }, + ]; + }; + }, + { + name: "enqueuedMultisigProposalApproval"; + type: { + kind: "struct"; + fields: [ + { + name: "dao"; + type: "publicKey"; + }, + { + name: "transactionIndex"; + type: "u64"; + }, + { + name: "pdaBump"; + type: "u8"; + }, + ]; + }; + }, + { + name: "proposal"; + type: { + kind: "struct"; + fields: [ + { + name: "number"; + type: "u32"; + }, + { + name: "proposer"; + type: "publicKey"; + }, + { + name: "timestampEnqueued"; + type: "i64"; + }, + { + name: "state"; + type: { + defined: "ProposalState"; + }; + }, + { + name: "baseVault"; + type: "publicKey"; + }, + { + name: "quoteVault"; + type: "publicKey"; + }, + { + name: "dao"; + type: "publicKey"; + }, + { + name: "pdaBump"; + type: "u8"; + }, + { + name: "question"; + type: "publicKey"; + }, + { + name: "durationInSeconds"; + type: "u32"; + }, + { + name: "squadsProposal"; + type: "publicKey"; + }, + { + name: "passBaseMint"; + type: "publicKey"; + }, + { + name: "passQuoteMint"; + type: "publicKey"; + }, + { + name: "failBaseMint"; + type: "publicKey"; + }, + { + name: "failQuoteMint"; + type: "publicKey"; + }, + { + name: "isTeamSponsored"; + type: "bool"; + }, + ]; + }; + }, + { + name: "stakeAccount"; + type: { + kind: "struct"; + fields: [ + { + name: "proposal"; + type: "publicKey"; + }, + { + name: "staker"; + type: "publicKey"; + }, + { + name: "amount"; + type: "u64"; + }, + { + name: "bump"; + type: "u8"; + }, + ]; + }; + }, + ]; + types: [ + { + name: "CommonFields"; + type: { + kind: "struct"; + fields: [ + { + name: "slot"; + type: "u64"; + }, + { + name: "unixTimestamp"; + type: "i64"; + }, + { + name: "daoSeqNum"; + type: "u64"; + }, + ]; + }; + }, + { + name: "AdminEnqueueMultisigProposalApprovalArgs"; + type: { + kind: "struct"; + fields: [ + { + name: "transactionIndex"; + type: "u64"; + }, + ]; + }; + }, + { + name: "ConditionalSwapParams"; + type: { + kind: "struct"; + fields: [ + { + name: "market"; + type: { + defined: "Market"; + }; + }, + { + name: "swapType"; + type: { + defined: "SwapType"; + }; + }, + { + name: "inputAmount"; + type: "u64"; + }, + { + name: "minOutputAmount"; + type: "u64"; + }, + ]; + }; + }, + { + name: "InitializeDaoParams"; + type: { + kind: "struct"; + fields: [ + { + name: "twapInitialObservation"; + type: "u128"; + }, + { + name: "twapMaxObservationChangePerUpdate"; + type: "u128"; + }, + { + name: "twapStartDelaySeconds"; + type: "u32"; + }, + { + name: "minQuoteFutarchicLiquidity"; + type: "u64"; + }, + { + name: "minBaseFutarchicLiquidity"; + type: "u64"; + }, + { + name: "baseToStake"; + type: "u64"; + }, + { + name: "passThresholdBps"; + type: "u16"; + }, + { + name: "secondsPerProposal"; + type: "u32"; + }, + { + name: "nonce"; + type: "u64"; + }, + { + name: "initialSpendingLimit"; + type: { + option: { + defined: "InitialSpendingLimit"; + }; + }; + }, + { + name: "teamSponsoredPassThresholdBps"; + type: "i16"; + }, + { + name: "teamAddress"; + type: "publicKey"; + }, + ]; + }; + }, + { + name: "InitiateVaultSpendOptimisticProposalParams"; + type: { + kind: "struct"; + fields: [ + { + name: "amount"; + type: "u64"; + }, + ]; + }; + }, + { + name: "ProvideLiquidityParams"; + type: { + kind: "struct"; + fields: [ + { + name: "quoteAmount"; + docs: ["How much quote token you will deposit to the pool"]; + type: "u64"; + }, + { + name: "maxBaseAmount"; + docs: ["The maximum base token you will deposit to the pool"]; + type: "u64"; + }, + { + name: "minLiquidity"; + docs: ["The minimum liquidity you will be assigned"]; + type: "u128"; + }, + { + name: "positionAuthority"; + docs: [ + "The account that will own the LP position, usually the same as the", + "liquidity provider", + ]; + type: "publicKey"; + }, + ]; + }; + }, + { + name: "SpotSwapParams"; + type: { + kind: "struct"; + fields: [ + { + name: "inputAmount"; + type: "u64"; + }, + { + name: "swapType"; + type: { + defined: "SwapType"; + }; + }, + { + name: "minOutputAmount"; + type: "u64"; + }, + ]; + }; + }, + { + name: "StakeToProposalParams"; + type: { + kind: "struct"; + fields: [ + { + name: "amount"; + type: "u64"; + }, + ]; + }; + }, + { + name: "UnstakeFromProposalParams"; + type: { + kind: "struct"; + fields: [ + { + name: "amount"; + type: "u64"; + }, + ]; + }; + }, + { + name: "UpdateDaoParams"; + type: { + kind: "struct"; + fields: [ + { + name: "passThresholdBps"; + type: { + option: "u16"; + }; + }, + { + name: "secondsPerProposal"; + type: { + option: "u32"; + }; + }, + { + name: "twapInitialObservation"; + type: { + option: "u128"; + }; + }, + { + name: "twapMaxObservationChangePerUpdate"; + type: { + option: "u128"; + }; + }, + { + name: "twapStartDelaySeconds"; + type: { + option: "u32"; + }; + }, + { + name: "minQuoteFutarchicLiquidity"; + type: { + option: "u64"; + }; + }, + { + name: "minBaseFutarchicLiquidity"; + type: { + option: "u64"; + }; + }, + { + name: "baseToStake"; + type: { + option: "u64"; + }; + }, + { + name: "teamSponsoredPassThresholdBps"; + type: { + option: "i16"; + }; + }, + { + name: "teamAddress"; + type: { + option: "publicKey"; + }; + }, + { + name: "isOptimisticGovernanceEnabled"; + type: { + option: "bool"; + }; + }, + ]; + }; + }, + { + name: "WithdrawLiquidityParams"; + type: { + kind: "struct"; + fields: [ + { + name: "liquidityToWithdraw"; + docs: ["How much liquidity to withdraw"]; + type: "u128"; + }, + { + name: "minBaseAmount"; + docs: ["Minimum base tokens to receive"]; + type: "u64"; + }, + { + name: "minQuoteAmount"; + docs: ["Minimum quote tokens to receive"]; + type: "u64"; + }, + ]; + }; + }, + { + 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: { + kind: "struct"; + fields: [ + { + name: "amountPerMonth"; + type: "u64"; + }, + { + name: "members"; + type: { + vec: "publicKey"; + }; + }, + ]; + }; + }, + { + name: "FutarchyAmm"; + type: { + kind: "struct"; + fields: [ + { + name: "state"; + type: { + defined: "PoolState"; + }; + }, + { + name: "totalLiquidity"; + type: "u128"; + }, + { + name: "baseMint"; + type: "publicKey"; + }, + { + name: "quoteMint"; + type: "publicKey"; + }, + { + name: "ammBaseVault"; + type: "publicKey"; + }, + { + name: "ammQuoteVault"; + type: "publicKey"; + }, + ]; + }; + }, + { + name: "TwapOracle"; + type: { + kind: "struct"; + fields: [ + { + name: "aggregator"; + docs: [ + "Running sum of seconds_since_last_update * last_observation.", + "", + "Assuming latest observations are as big as possible (u64::MAX * 1e12),", + "we can store 18 million seconds worth of observations, which turns out to", + "be ~213 days.", + "", + "Assuming that latest observations are 100x smaller than they could theoretically", + "be, we can store ~57 years worth of them. Even this is a very", + "very conservative assumption - META/USDC prices should be between 1e9 and", + "1e15, which would overflow after 1e15 years.", + "", + "So in the case of an overflow, the aggregator rolls back to 0. It's the", + "client's responsibility to sanity check the assets or to handle an", + "aggregator at T2 being smaller than an aggregator at T1.", + ]; + type: "u128"; + }, + { + name: "lastUpdatedTimestamp"; + type: "i64"; + }, + { + name: "createdAtTimestamp"; + type: "i64"; + }, + { + name: "lastPrice"; + docs: [ + "A price is the number of quote units per base unit multiplied by 1e12.", + "You cannot simply divide by 1e12 to get a price you can display in the UI", + "because the base and quote decimals may be different. Instead, do:", + "ui_price = (price * (10**(base_decimals - quote_decimals))) / 1e12", + ]; + type: "u128"; + }, + { + name: "lastObservation"; + docs: [ + "If we did a raw TWAP over prices, someone could push the TWAP heavily with", + "a few extremely large outliers. So we use observations, which can only move", + "by `max_observation_change_per_update` per update.", + ]; + type: "u128"; + }, + { + name: "maxObservationChangePerUpdate"; + docs: ["The most that an observation can change per update."]; + type: "u128"; + }, + { + name: "initialObservation"; + docs: ["What the initial `latest_observation` is set to."]; + type: "u128"; + }, + { + name: "startDelaySeconds"; + docs: [ + "Number of seconds after amm.created_at_slot to start recording TWAP", + ]; + type: "u32"; + }, + ]; + }; + }, + { + name: "Pool"; + type: { + kind: "struct"; + fields: [ + { + name: "oracle"; + type: { + defined: "TwapOracle"; + }; + }, + { + name: "quoteReserves"; + type: "u64"; + }, + { + name: "baseReserves"; + type: "u64"; + }, + { + name: "quoteProtocolFeeBalance"; + type: "u64"; + }, + { + name: "baseProtocolFeeBalance"; + type: "u64"; + }, + ]; + }; + }, + { + name: "PoolState"; + type: { + kind: "enum"; + variants: [ + { + name: "Spot"; + fields: [ + { + name: "spot"; + type: { + defined: "Pool"; + }; + }, + ]; + }, + { + name: "Futarchy"; + fields: [ + { + name: "spot"; + type: { + defined: "Pool"; + }; + }, + { + name: "pass"; + type: { + defined: "Pool"; + }; + }, + { + name: "fail"; + type: { + defined: "Pool"; + }; + }, + ]; + }, + ]; + }; + }, + { + name: "Market"; + type: { + kind: "enum"; + variants: [ + { + name: "Spot"; + }, + { + name: "Pass"; + }, + { + name: "Fail"; + }, + ]; + }; + }, + { + name: "SwapType"; + type: { + kind: "enum"; + variants: [ + { + name: "Buy"; + }, + { + name: "Sell"; + }, + ]; + }; + }, + { + name: "Token"; + type: { + kind: "enum"; + variants: [ + { + name: "Base"; + }, + { + name: "Quote"; + }, + ]; + }; + }, + { + name: "ProposalState"; + type: { + kind: "enum"; + variants: [ + { + name: "Draft"; + fields: [ + { + name: "amountStaked"; + type: "u64"; + }, + ]; + }, + { + name: "Pending"; + }, + { + name: "Passed"; + }, + { + name: "Failed"; + }, + { + name: "Removed"; + }, + ]; + }; + }, + ]; + events: [ + { + name: "CollectFeesEvent"; + fields: [ + { + name: "common"; + type: { + defined: "CommonFields"; + }; + index: false; + }, + { + name: "dao"; + type: "publicKey"; + index: false; + }, + { + name: "baseTokenAccount"; + type: "publicKey"; + index: false; + }, + { + name: "quoteTokenAccount"; + type: "publicKey"; + index: false; + }, + { + name: "ammBaseVault"; + type: "publicKey"; + index: false; + }, + { + name: "ammQuoteVault"; + type: "publicKey"; + index: false; + }, + { + name: "quoteMint"; + type: "publicKey"; + index: false; + }, + { + name: "baseMint"; + type: "publicKey"; + index: false; + }, + { + name: "quoteFeesCollected"; + type: "u64"; + index: false; + }, + { + name: "baseFeesCollected"; + type: "u64"; + index: false; + }, + { + name: "postAmmState"; + type: { + defined: "FutarchyAmm"; + }; + index: false; + }, + ]; + }, + { + name: "InitializeDaoEvent"; + fields: [ + { + name: "common"; + type: { + defined: "CommonFields"; + }; + index: false; + }, + { + name: "dao"; + type: "publicKey"; + index: false; + }, + { + name: "baseMint"; + type: "publicKey"; + index: false; + }, + { + name: "quoteMint"; + type: "publicKey"; + index: false; + }, + { + name: "passThresholdBps"; + type: "u16"; + index: false; + }, + { + name: "secondsPerProposal"; + type: "u32"; + index: false; + }, + { + name: "twapInitialObservation"; + type: "u128"; + index: false; + }, + { + name: "twapMaxObservationChangePerUpdate"; + type: "u128"; + index: false; + }, + { + name: "twapStartDelaySeconds"; + type: "u32"; + index: false; + }, + { + name: "minQuoteFutarchicLiquidity"; + type: "u64"; + index: false; + }, + { + name: "minBaseFutarchicLiquidity"; + type: "u64"; + index: false; + }, + { + name: "baseToStake"; + type: "u64"; + index: false; + }, + { + name: "initialSpendingLimit"; + type: { + option: { + defined: "InitialSpendingLimit"; + }; + }; + index: false; + }, + { + name: "squadsMultisig"; + type: "publicKey"; + index: false; + }, + { + name: "squadsMultisigVault"; + type: "publicKey"; + index: false; + }, + { + name: "teamSponsoredPassThresholdBps"; + type: "i16"; + index: false; + }, + { + name: "teamAddress"; + type: "publicKey"; + index: false; + }, + ]; + }, + { + name: "UpdateDaoEvent"; + fields: [ + { + name: "common"; + type: { + defined: "CommonFields"; + }; + index: false; + }, + { + name: "dao"; + type: "publicKey"; + index: false; + }, + { + name: "passThresholdBps"; + type: "u16"; + index: false; + }, + { + name: "secondsPerProposal"; + type: "u32"; + index: false; + }, + { + name: "twapInitialObservation"; + type: "u128"; + index: false; + }, + { + name: "twapMaxObservationChangePerUpdate"; + type: "u128"; + index: false; + }, + { + name: "twapStartDelaySeconds"; + type: "u32"; + index: false; + }, + { + name: "minQuoteFutarchicLiquidity"; + type: "u64"; + index: false; + }, + { + name: "minBaseFutarchicLiquidity"; + type: "u64"; + index: false; + }, + { + name: "baseToStake"; + type: "u64"; + index: false; + }, + { + name: "teamSponsoredPassThresholdBps"; + type: "i16"; + index: false; + }, + { + name: "teamAddress"; + type: "publicKey"; + index: false; + }, + { + name: "isOptimisticGovernanceEnabled"; + type: "bool"; + index: false; + }, + ]; + }, + { + name: "InitializeProposalEvent"; + fields: [ + { + name: "common"; + type: { + defined: "CommonFields"; + }; + index: false; + }, + { + name: "proposal"; + type: "publicKey"; + index: false; + }, + { + name: "dao"; + type: "publicKey"; + index: false; + }, + { + name: "question"; + type: "publicKey"; + index: false; + }, + { + name: "quoteVault"; + type: "publicKey"; + index: false; + }, + { + name: "baseVault"; + type: "publicKey"; + index: false; + }, + { + name: "proposer"; + type: "publicKey"; + index: false; + }, + { + name: "number"; + type: "u32"; + index: false; + }, + { + name: "pdaBump"; + type: "u8"; + index: false; + }, + { + name: "durationInSeconds"; + type: "u32"; + index: false; + }, + { + name: "squadsProposal"; + type: "publicKey"; + index: false; + }, + { + name: "squadsMultisig"; + type: "publicKey"; + index: false; + }, + { + name: "squadsMultisigVault"; + type: "publicKey"; + index: false; + }, + ]; + }, + { + name: "StakeToProposalEvent"; + fields: [ + { + name: "common"; + type: { + defined: "CommonFields"; + }; + index: false; + }, + { + name: "proposal"; + type: "publicKey"; + index: false; + }, + { + name: "staker"; + type: "publicKey"; + index: false; + }, + { + name: "amount"; + type: "u64"; + index: false; + }, + { + name: "totalStaked"; + type: "u64"; + index: false; + }, + ]; + }, + { + name: "UnstakeFromProposalEvent"; + fields: [ + { + name: "common"; + type: { + defined: "CommonFields"; + }; + index: false; + }, + { + name: "proposal"; + type: "publicKey"; + index: false; + }, + { + name: "staker"; + type: "publicKey"; + index: false; + }, + { + name: "amount"; + type: "u64"; + index: false; + }, + { + name: "totalStaked"; + type: "u64"; + index: false; + }, + ]; + }, + { + name: "LaunchProposalEvent"; + fields: [ + { + name: "common"; + type: { + defined: "CommonFields"; + }; + index: false; + }, + { + name: "proposal"; + type: "publicKey"; + index: false; + }, + { + name: "dao"; + type: "publicKey"; + index: false; + }, + { + name: "timestampEnqueued"; + type: "i64"; + index: false; + }, + { + name: "totalStaked"; + type: "u64"; + index: false; + }, + { + name: "postAmmState"; + type: { + defined: "FutarchyAmm"; + }; + index: false; + }, + ]; + }, + { + name: "FinalizeProposalEvent"; + fields: [ + { + name: "common"; + type: { + defined: "CommonFields"; + }; + index: false; + }, + { + name: "proposal"; + type: "publicKey"; + index: false; + }, + { + name: "dao"; + type: "publicKey"; + index: false; + }, + { + name: "passMarketTwap"; + type: "u128"; + index: false; + }, + { + name: "failMarketTwap"; + type: "u128"; + index: false; + }, + { + name: "threshold"; + type: "u128"; + index: false; + }, + { + name: "state"; + type: { + defined: "ProposalState"; + }; + index: false; + }, + { + name: "squadsProposal"; + type: "publicKey"; + index: false; + }, + { + name: "squadsMultisig"; + type: "publicKey"; + index: false; + }, + { + name: "postAmmState"; + type: { + defined: "FutarchyAmm"; + }; + index: false; + }, + { + name: "isTeamSponsored"; + type: "bool"; + index: false; + }, + ]; + }, + { + name: "SpotSwapEvent"; + fields: [ + { + name: "common"; + type: { + defined: "CommonFields"; + }; + index: false; + }, + { + name: "dao"; + type: "publicKey"; + index: false; + }, + { + name: "user"; + type: "publicKey"; + index: false; + }, + { + name: "swapType"; + type: { + defined: "SwapType"; + }; + index: false; + }, + { + name: "inputAmount"; + type: "u64"; + index: false; + }, + { + name: "outputAmount"; + type: "u64"; + index: false; + }, + { + name: "minOutputAmount"; + type: "u64"; + index: false; + }, + { + name: "postAmmState"; + type: { + defined: "FutarchyAmm"; + }; + index: false; + }, + ]; + }, + { + name: "ConditionalSwapEvent"; + fields: [ + { + name: "common"; + type: { + defined: "CommonFields"; + }; + index: false; + }, + { + name: "dao"; + type: "publicKey"; + index: false; + }, + { + name: "proposal"; + type: "publicKey"; + index: false; + }, + { + name: "trader"; + type: "publicKey"; + index: false; + }, + { + name: "market"; + type: { + defined: "Market"; + }; + index: false; + }, + { + name: "swapType"; + type: { + defined: "SwapType"; + }; + index: false; + }, + { + name: "inputAmount"; + type: "u64"; + index: false; + }, + { + name: "outputAmount"; + type: "u64"; + index: false; + }, + { + name: "minOutputAmount"; + type: "u64"; + index: false; + }, + { + name: "postAmmState"; + type: { + defined: "FutarchyAmm"; + }; + index: false; + }, + ]; + }, + { + name: "ProvideLiquidityEvent"; + fields: [ + { + name: "common"; + type: { + defined: "CommonFields"; + }; + index: false; + }, + { + name: "dao"; + type: "publicKey"; + index: false; + }, + { + name: "liquidityProvider"; + type: "publicKey"; + index: false; + }, + { + name: "positionAuthority"; + type: "publicKey"; + index: false; + }, + { + name: "quoteAmount"; + type: "u64"; + index: false; + }, + { + name: "baseAmount"; + type: "u64"; + index: false; + }, + { + name: "liquidityMinted"; + type: "u128"; + index: false; + }, + { + name: "minLiquidity"; + type: "u128"; + index: false; + }, + { + name: "postAmmState"; + type: { + defined: "FutarchyAmm"; + }; + index: false; + }, + ]; + }, + { + name: "WithdrawLiquidityEvent"; + fields: [ + { + name: "common"; + type: { + defined: "CommonFields"; + }; + index: false; + }, + { + name: "dao"; + type: "publicKey"; + index: false; + }, + { + name: "liquidityProvider"; + type: "publicKey"; + index: false; + }, + { + name: "liquidityWithdrawn"; + type: "u128"; + index: false; + }, + { + 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: "RemoveProposalEvent"; + fields: [ + { + name: "common"; + type: { + defined: "CommonFields"; + }; + index: false; + }, + { + name: "proposal"; + type: "publicKey"; + index: false; + }, + { + name: "dao"; + type: "publicKey"; + index: false; + }, + { + name: "admin"; + type: "publicKey"; + index: false; + }, + ]; + }, + { + name: "AdminCancelProposalEvent"; + fields: [ + { + name: "common"; + type: { + defined: "CommonFields"; + }; + index: false; + }, + { + name: "proposal"; + type: "publicKey"; + index: false; + }, + { + name: "dao"; + type: "publicKey"; + index: false; + }, + { + name: "admin"; + type: "publicKey"; + index: false; + }, + { + name: "postAmmState"; + type: { + defined: "FutarchyAmm"; + }; + 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: "quoteMint"; + type: "publicKey"; + index: false; + }, + { + name: "baseMint"; + type: "publicKey"; + index: false; + }, + { + name: "quoteFeesCollected"; + type: "u64"; + index: false; + }, + { + name: "baseFeesCollected"; + type: "u64"; + index: false; + }, + ]; + }, + { + name: "AdminFixPositionAuthorityEvent"; + fields: [ + { + name: "common"; + type: { + defined: "CommonFields"; + }; + index: false; + }, + { + name: "dao"; + type: "publicKey"; + index: false; + }, + { + name: "admin"; + type: "publicKey"; + index: false; + }, + { + name: "ammPosition"; + type: "publicKey"; + index: false; + }, + { + name: "oldAuthority"; + type: "publicKey"; + index: false; + }, + { + name: "newAuthority"; + type: "publicKey"; + 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: "squadsProposal"; + type: "publicKey"; + index: false; + }, + ]; + }, + ]; + errors: [ + { + code: 6000; + name: "AmmTooOld"; + msg: "Amms must have been created within 5 minutes (counted in slots) of proposal initialization"; + }, + { + code: 6001; + name: "InvalidInitialObservation"; + msg: "An amm has an `initial_observation` that doesn't match the `dao`'s config"; + }, + { + code: 6002; + name: "InvalidMaxObservationChange"; + msg: "An amm has a `max_observation_change_per_update` that doesn't match the `dao`'s config"; + }, + { + code: 6003; + name: "InvalidStartDelaySlots"; + msg: "An amm has a `start_delay_slots` that doesn't match the `dao`'s config"; + }, + { + code: 6004; + name: "InvalidSettlementAuthority"; + msg: "One of the vaults has an invalid `settlement_authority`"; + }, + { + code: 6005; + name: "ProposalTooYoung"; + msg: "Proposal is too young to be executed or rejected"; + }, + { + code: 6006; + name: "MarketsTooYoung"; + msg: "Markets too young for proposal to be finalized. TWAP might need to be cranked"; + }, + { + code: 6007; + name: "ProposalAlreadyFinalized"; + msg: "This proposal has already been finalized"; + }, + { + code: 6008; + name: "InvalidVaultNonce"; + msg: "A conditional vault has an invalid nonce. A nonce should encode the proposal number"; + }, + { + code: 6009; + name: "ProposalNotPassed"; + msg: "This proposal can't be executed because it isn't in the passed state"; + }, + { + code: 6010; + name: "InsufficientLiquidity"; + msg: "More liquidity needs to be in the AMM to launch this proposal"; + }, + { + code: 6011; + name: "ProposalDurationTooShort"; + msg: "Proposal duration must be longer 1 day and longer than 2 times the TWAP start delay"; + }, + { + code: 6012; + name: "PassThresholdTooHigh"; + msg: "Pass threshold must be less than 10%"; + }, + { + code: 6013; + name: "QuestionMustBeBinary"; + msg: "Question must have exactly 2 outcomes for binary futarchy"; + }, + { + code: 6014; + name: "InvalidSquadsProposalStatus"; + msg: "Squads proposal must be in Active status"; + }, + { + code: 6015; + name: "CastingOverflow"; + msg: "Casting overflow. If you're seeing this, please report this"; + }, + { + code: 6016; + name: "InsufficientBalance"; + msg: "Insufficient balance"; + }, + { + code: 6017; + name: "ZeroLiquidityRemove"; + msg: "Cannot remove zero liquidity"; + }, + { + code: 6018; + name: "SwapSlippageExceeded"; + msg: "Swap slippage exceeded"; + }, + { + code: 6019; + name: "AssertFailed"; + msg: "Assert failed"; + }, + { + code: 6020; + name: "InvalidAdmin"; + msg: "Invalid admin"; + }, + { + code: 6021; + name: "ProposalNotInDraftState"; + msg: "Proposal is not in draft state"; + }, + { + code: 6022; + name: "InsufficientTokenBalance"; + msg: "Insufficient token balance"; + }, + { + code: 6023; + name: "InvalidAmount"; + msg: "Invalid amount"; + }, + { + code: 6024; + name: "InsufficientStakeToLaunch"; + msg: "Insufficient stake to launch proposal"; + }, + { + code: 6025; + name: "StakerNotFound"; + msg: "Staker not found in proposal"; + }, + { + code: 6026; + name: "PoolNotInSpotState"; + msg: "Pool must be in spot state"; + }, + { + code: 6027; + name: "InvalidDaoCreateLiquidity"; + msg: "If you're providing liquidity, you must provide both base and quote token accounts"; + }, + { + code: 6028; + name: "InvalidStakeAccount"; + msg: "Invalid stake account"; + }, + { + code: 6029; + name: "InvariantViolated"; + msg: "An invariant was violated. You should get in contact with the MetaDAO team if you see this"; + }, + { + code: 6030; + name: "ProposalNotActive"; + msg: "Proposal needs to be active to perform a conditional swap"; + }, + { + code: 6031; + name: "InvalidTransaction"; + msg: "This Squads transaction should only contain calls to update spending limits"; + }, + { + code: 6032; + name: "ProposalAlreadySponsored"; + msg: "Proposal has already been sponsored"; + }, + { + code: 6033; + name: "InvalidTeamSponsoredPassThreshold"; + msg: "Team sponsored pass threshold must be between -10% and 10%"; + }, + { + code: 6034; + name: "InvalidTargetK"; + msg: "Target K must be greater than the current K"; + }, + { + code: 6035; + name: "InvalidTransactionMessage"; + msg: "Failed to compile transaction message for Squads vault transaction"; + }, + { + code: 6036; + name: "InvalidMint"; + msg: "Base mint and quote mint must be different"; + }, + { + code: 6037; + name: "ProposalNotReadyToUnstake"; + msg: "Proposal is not ready to be unstaked"; + }, + { + code: 6038; + name: "OptimisticGovernanceDisabled"; + msg: "Optimistic governance is disabled"; + }, + { + code: 6039; + name: "ActiveOptimisticProposalAlreadyEnqueued"; + msg: "An active optimistic proposal is already enqueued"; + }, + { + code: 6040; + name: "OptimisticProposalAlreadyPassed"; + msg: "Optimistic proposal has already passed"; + }, + { + code: 6041; + name: "InvalidSpendingLimitMint"; + msg: "Invalid spending limit mint. Must be the same as the DAO's quote mint"; + }, + { + code: 6042; + name: "NoActiveOptimisticProposal"; + msg: "No active optimistic proposal"; + }, + ]; +}; + +export const IDL: Futarchy = { + version: "0.6.1", + name: "futarchy", + instructions: [ + { + name: "initializeDao", + accounts: [ + { + name: "dao", + isMut: true, + isSigner: false, + }, + { + name: "daoCreator", + isMut: false, + isSigner: true, + }, + { + name: "payer", + isMut: true, + isSigner: true, + }, + { + name: "systemProgram", + isMut: false, + isSigner: false, + }, + { + name: "baseMint", + isMut: false, + isSigner: false, + }, + { + name: "quoteMint", + isMut: false, + isSigner: false, + }, + { + name: "squadsMultisig", + isMut: true, + isSigner: false, + }, + { + name: "squadsMultisigVault", + isMut: false, + isSigner: false, + }, + { + name: "squadsProgram", + isMut: false, + isSigner: false, + }, + { + name: "squadsProgramConfig", + isMut: false, + isSigner: false, + }, + { + name: "squadsProgramConfigTreasury", + isMut: true, + isSigner: false, + }, + { + name: "spendingLimit", + isMut: true, + isSigner: false, + }, + { + name: "futarchyAmmBaseVault", + isMut: true, + isSigner: false, + }, + { + name: "futarchyAmmQuoteVault", + isMut: true, + isSigner: false, + }, + { + name: "tokenProgram", + isMut: false, + isSigner: false, + }, + { + name: "associatedTokenProgram", + isMut: false, + isSigner: false, + }, + { + name: "eventAuthority", + isMut: false, + isSigner: false, + }, + { + name: "program", + isMut: false, + isSigner: false, + }, + ], + args: [ + { + name: "params", + type: { + defined: "InitializeDaoParams", + }, + }, + ], + }, + { + name: "initializeProposal", + accounts: [ + { + name: "proposal", + isMut: true, + isSigner: false, + }, + { + name: "squadsProposal", + isMut: false, + isSigner: false, + }, + { + name: "squadsMultisig", + isMut: false, + isSigner: false, + }, + { + name: "dao", + isMut: true, + isSigner: false, + }, + { + name: "question", + isMut: false, + isSigner: false, + }, + { + name: "quoteVault", + isMut: false, + isSigner: false, + }, + { + name: "baseVault", + isMut: false, + isSigner: false, + }, + { + name: "proposer", + isMut: false, + isSigner: true, + }, + { + name: "payer", + isMut: true, + isSigner: true, + }, + { + name: "systemProgram", + isMut: false, + isSigner: false, + }, + { + name: "eventAuthority", + isMut: false, + isSigner: false, + }, + { + name: "program", + isMut: false, + isSigner: false, + }, + ], + args: [], + }, + { + name: "stakeToProposal", + accounts: [ + { + name: "proposal", + isMut: true, + isSigner: false, + }, + { + name: "dao", + isMut: true, + isSigner: false, + }, + { + name: "stakerBaseAccount", + isMut: true, + isSigner: false, + }, + { + name: "proposalBaseAccount", + isMut: true, + isSigner: false, + }, + { + name: "stakeAccount", + isMut: true, + isSigner: false, + }, + { + name: "staker", + isMut: false, + isSigner: true, + }, + { + name: "payer", + isMut: true, + isSigner: true, + }, + { + name: "tokenProgram", + isMut: false, + isSigner: false, + }, + { + name: "systemProgram", + isMut: false, + isSigner: false, + }, + { + name: "eventAuthority", + isMut: false, + isSigner: false, + }, + { + name: "program", + isMut: false, + isSigner: false, + }, + ], + args: [ + { + name: "params", + type: { + defined: "StakeToProposalParams", + }, + }, + ], + }, + { + name: "unstakeFromProposal", + accounts: [ + { + name: "proposal", + isMut: true, + isSigner: false, + }, + { + name: "dao", + isMut: true, + isSigner: false, + }, + { + name: "stakerBaseAccount", + isMut: true, + isSigner: false, + }, + { + name: "proposalBaseAccount", + isMut: true, + isSigner: false, + }, + { + name: "stakeAccount", + isMut: true, + isSigner: false, + }, + { + name: "baseMint", + isMut: false, + isSigner: false, + }, + { + name: "staker", + isMut: true, + isSigner: true, + }, + { + name: "tokenProgram", + isMut: false, + isSigner: false, + }, + { + name: "systemProgram", + isMut: false, + isSigner: false, + }, + { + name: "associatedTokenProgram", + isMut: false, + isSigner: false, + }, + { + name: "eventAuthority", + isMut: false, + isSigner: false, + }, + { + name: "program", + isMut: false, + isSigner: false, + }, + ], + args: [ + { + name: "params", + type: { + defined: "UnstakeFromProposalParams", + }, + }, + ], + }, + { + name: "launchProposal", + accounts: [ + { + name: "proposal", + isMut: true, + isSigner: false, + }, + { + name: "baseVault", + isMut: false, + isSigner: false, + }, + { + name: "quoteVault", + isMut: false, + isSigner: false, + }, + { + name: "passBaseMint", + isMut: false, + isSigner: false, + }, + { + name: "passQuoteMint", + isMut: false, + isSigner: false, + }, + { + name: "failBaseMint", + isMut: false, + isSigner: false, + }, + { + name: "failQuoteMint", + isMut: false, + isSigner: false, + }, + { + name: "dao", + isMut: true, + isSigner: false, + }, + { + name: "payer", + isMut: true, + isSigner: true, + }, + { + name: "ammPassBaseVault", + isMut: true, + isSigner: false, + }, + { + name: "ammPassQuoteVault", + isMut: true, + isSigner: false, + }, + { + name: "ammFailBaseVault", + isMut: true, + isSigner: false, + }, + { + name: "ammFailQuoteVault", + isMut: true, + isSigner: false, + }, + { + name: "squadsMultisig", + isMut: false, + isSigner: false, + }, + { + name: "squadsProposal", + isMut: false, + isSigner: false, + }, + { + name: "systemProgram", + isMut: false, + isSigner: false, + }, + { + name: "tokenProgram", + isMut: false, + isSigner: false, + }, + { + name: "associatedTokenProgram", + isMut: false, + isSigner: false, + }, + { + name: "eventAuthority", + isMut: false, + isSigner: false, + }, + { + name: "program", + isMut: false, + isSigner: false, + }, + ], + args: [], + }, + { + name: "finalizeProposal", + accounts: [ + { + name: "proposal", + isMut: true, + isSigner: false, + }, + { + name: "dao", + isMut: true, + isSigner: false, + }, + { + name: "question", + isMut: true, + isSigner: false, + }, + { + name: "squadsProposal", + isMut: true, + isSigner: false, + }, + { + name: "squadsMultisig", + isMut: false, + isSigner: false, + }, + { + name: "squadsMultisigProgram", + isMut: false, + isSigner: false, + }, + { + name: "ammPassBaseVault", + isMut: true, + isSigner: false, + }, + { + name: "ammPassQuoteVault", + isMut: true, + isSigner: false, + }, + { + name: "ammFailBaseVault", + isMut: true, + isSigner: false, + }, + { + name: "ammFailQuoteVault", + isMut: true, + isSigner: false, + }, + { + name: "ammBaseVault", + isMut: true, + isSigner: false, + }, + { + name: "ammQuoteVault", + isMut: true, + isSigner: false, + }, + { + name: "vaultProgram", + isMut: false, + isSigner: false, + }, + { + name: "vaultEventAuthority", + isMut: false, + isSigner: false, + }, + { + name: "tokenProgram", + isMut: false, + isSigner: false, + }, + { + name: "quoteVault", + isMut: true, + isSigner: false, + }, + { + name: "quoteVaultUnderlyingTokenAccount", + isMut: true, + isSigner: false, + }, + { + name: "passQuoteMint", + isMut: true, + isSigner: false, + }, + { + name: "failQuoteMint", + isMut: true, + isSigner: false, + }, + { + name: "passBaseMint", + isMut: true, + isSigner: false, + }, + { + name: "failBaseMint", + isMut: true, + isSigner: false, + }, + { + name: "baseVault", + isMut: true, + isSigner: false, + }, + { + name: "baseVaultUnderlyingTokenAccount", + isMut: true, + isSigner: false, + }, + { + name: "eventAuthority", + isMut: false, + isSigner: false, + }, + { + name: "program", + isMut: false, + isSigner: false, + }, + ], + args: [], + }, + { + name: "updateDao", + accounts: [ + { + name: "dao", + isMut: true, + isSigner: false, + }, + { + name: "squadsMultisigVault", + isMut: false, + isSigner: true, + }, + { + name: "eventAuthority", + isMut: false, + isSigner: false, + }, + { + name: "program", + isMut: false, + isSigner: false, + }, + ], + args: [ + { + name: "daoParams", + type: { + defined: "UpdateDaoParams", + }, + }, + ], + }, + { + 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: [ + { + name: "dao", + isMut: true, + isSigner: false, + }, + { + name: "userBaseAccount", + isMut: true, + isSigner: false, + }, + { + name: "userQuoteAccount", + isMut: true, + isSigner: false, + }, + { + name: "ammBaseVault", + isMut: true, + isSigner: false, + }, + { + name: "ammQuoteVault", + isMut: true, + isSigner: false, + }, + { + name: "user", + isMut: false, + isSigner: true, + }, + { + name: "tokenProgram", + isMut: false, + isSigner: false, + }, + { + name: "eventAuthority", + isMut: false, + isSigner: false, + }, + { + name: "program", + isMut: false, + isSigner: false, + }, + ], + args: [ + { + name: "params", + type: { + defined: "SpotSwapParams", + }, + }, + ], + }, + { + name: "conditionalSwap", + accounts: [ + { + name: "dao", + isMut: true, + isSigner: false, + }, + { + name: "ammBaseVault", + isMut: true, + isSigner: false, + }, + { + name: "ammQuoteVault", + isMut: true, + isSigner: false, + }, + { + name: "proposal", + isMut: false, + isSigner: false, + }, + { + name: "ammPassBaseVault", + isMut: true, + isSigner: false, + }, + { + name: "ammPassQuoteVault", + isMut: true, + isSigner: false, + }, + { + name: "ammFailBaseVault", + isMut: true, + isSigner: false, + }, + { + name: "ammFailQuoteVault", + isMut: true, + isSigner: false, + }, + { + name: "trader", + isMut: false, + isSigner: true, + }, + { + name: "userInputAccount", + isMut: true, + isSigner: false, + }, + { + name: "userOutputAccount", + isMut: true, + isSigner: false, + }, + { + name: "baseVault", + isMut: true, + isSigner: false, + }, + { + name: "baseVaultUnderlyingTokenAccount", + isMut: true, + isSigner: false, + }, + { + name: "quoteVault", + isMut: true, + isSigner: false, + }, + { + name: "quoteVaultUnderlyingTokenAccount", + isMut: true, + isSigner: false, + }, + { + name: "passBaseMint", + isMut: true, + isSigner: false, + }, + { + name: "failBaseMint", + isMut: true, + isSigner: false, + }, + { + name: "passQuoteMint", + isMut: true, + isSigner: false, + }, + { + name: "failQuoteMint", + isMut: true, + isSigner: false, + }, + { + name: "conditionalVaultProgram", + isMut: false, + isSigner: false, + }, + { + name: "vaultEventAuthority", + isMut: false, + isSigner: false, + }, + { + name: "question", + 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: "ConditionalSwapParams", + }, + }, + ], + }, + { + name: "provideLiquidity", + accounts: [ + { + name: "dao", + isMut: true, + isSigner: false, + }, + { + name: "liquidityProvider", + isMut: false, + isSigner: true, + }, + { + name: "liquidityProviderBaseAccount", + isMut: true, + isSigner: false, + }, + { + name: "liquidityProviderQuoteAccount", + isMut: true, + isSigner: false, + }, + { + name: "payer", + isMut: true, + isSigner: true, + }, + { + name: "systemProgram", + isMut: false, + isSigner: false, + }, + { + name: "ammBaseVault", + isMut: true, + isSigner: false, + }, + { + name: "ammQuoteVault", + isMut: true, + isSigner: false, + }, + { + name: "ammPosition", + isMut: true, + 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: "ProvideLiquidityParams", + }, + }, + ], + }, + { + name: "withdrawLiquidity", + accounts: [ + { + name: "dao", + isMut: true, + isSigner: false, + }, + { + name: "positionAuthority", + isMut: false, + isSigner: true, + }, + { + name: "liquidityProviderBaseAccount", + isMut: true, + isSigner: false, + }, + { + name: "liquidityProviderQuoteAccount", + isMut: true, + isSigner: false, + }, + { + name: "ammBaseVault", + isMut: true, + isSigner: false, + }, + { + name: "ammQuoteVault", + isMut: true, + isSigner: false, + }, + { + name: "ammPosition", + isMut: true, + 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: "WithdrawLiquidityParams", + }, + }, + ], + }, + { + name: "collectFees", + accounts: [ + { + name: "dao", + isMut: true, + isSigner: false, + }, + { + name: "admin", + isMut: false, + isSigner: true, + }, + { + name: "baseTokenAccount", + isMut: true, + isSigner: false, + }, + { + name: "quoteTokenAccount", + isMut: true, + isSigner: false, + }, + { + name: "ammBaseVault", + isMut: true, + isSigner: false, + }, + { + name: "ammQuoteVault", + isMut: true, + isSigner: false, + }, + { + name: "tokenProgram", + isMut: false, + isSigner: false, + }, + { + name: "eventAuthority", + isMut: false, + isSigner: false, + }, + { + name: "program", + isMut: false, + isSigner: false, + }, + ], + args: [], + }, + { + name: "executeSpendingLimitChange", + accounts: [ + { + name: "proposal", + isMut: true, + isSigner: false, + }, + { + name: "dao", + isMut: true, + isSigner: false, + }, + { + name: "squadsProposal", + isMut: true, + isSigner: false, + }, + { + name: "squadsMultisig", + isMut: false, + isSigner: false, + }, + { + name: "squadsMultisigProgram", + isMut: false, + isSigner: false, + }, + { + name: "vaultTransaction", + isMut: false, + isSigner: false, + }, + ], + args: [], + }, + { + name: "sponsorProposal", + accounts: [ + { + name: "proposal", + isMut: true, + isSigner: false, + }, + { + name: "dao", + isMut: true, + isSigner: false, + }, + { + name: "teamAddress", + isMut: false, + isSigner: true, + }, + { + name: "eventAuthority", + isMut: false, + isSigner: false, + }, + { + name: "program", + isMut: false, + isSigner: false, + }, + ], + args: [], + }, + { + name: "collectMeteoraDammFees", + accounts: [ + { + name: "dao", + isMut: true, + isSigner: false, + }, + { + name: "admin", + isMut: true, + isSigner: true, + }, + { + name: "squadsMultisig", + isMut: true, + isSigner: false, + }, + { + name: "squadsMultisigVault", + isMut: true, + isSigner: false, + }, + { + name: "squadsMultisigVaultTransaction", + isMut: true, + isSigner: false, + }, + { + name: "squadsMultisigProposal", + isMut: true, + isSigner: false, + }, + { + name: "squadsMultisigPermissionlessAccount", + isMut: false, + isSigner: true, + }, + { + name: "meteoraClaimPositionFeesAccounts", + accounts: [ + { + name: "dammV2Program", + isMut: false, + isSigner: false, + }, + { + name: "dammV2EventAuthority", + isMut: false, + isSigner: false, + }, + { + name: "poolAuthority", + isMut: false, + isSigner: false, + }, + { + name: "pool", + isMut: false, + isSigner: false, + }, + { + name: "position", + isMut: true, + isSigner: false, + }, + { + name: "tokenAAccount", + isMut: true, + isSigner: false, + docs: ["Token account of base tokens recipient"], + }, + { + name: "tokenBAccount", + isMut: true, + isSigner: false, + docs: ["Token account of quote tokens recipient"], + }, + { + name: "tokenAVault", + isMut: true, + isSigner: false, + }, + { + name: "tokenBVault", + isMut: true, + isSigner: false, + }, + { + name: "tokenAMint", + isMut: false, + isSigner: false, + }, + { + name: "tokenBMint", + isMut: false, + isSigner: false, + }, + { + name: "positionNftAccount", + isMut: false, + isSigner: false, + }, + { + name: "owner", + isMut: false, + isSigner: false, + }, + { + name: "tokenAProgram", + isMut: false, + isSigner: false, + }, + { + name: "tokenBProgram", + isMut: false, + isSigner: false, + }, + ], + }, + { + name: "systemProgram", + isMut: false, + isSigner: false, + }, + { + name: "tokenProgram", + isMut: false, + isSigner: false, + }, + { + name: "squadsProgram", + isMut: false, + isSigner: false, + }, + { + name: "eventAuthority", + isMut: false, + isSigner: false, + }, + { + name: "program", + isMut: false, + isSigner: false, + }, + ], + args: [], + }, + { + name: "initiateVaultSpendOptimisticProposal", + accounts: [ + { + name: "squadsMultisig", + isMut: false, + isSigner: false, + }, + { + name: "squadsMultisigVault", + isMut: false, + isSigner: false, + }, + { + name: "squadsSpendingLimit", + isMut: false, + isSigner: false, + }, + { + name: "squadsProposal", + isMut: false, + isSigner: false, + }, + { + name: "squadsVaultTransaction", + isMut: false, + isSigner: false, + }, + { + name: "dao", + isMut: true, + isSigner: false, + }, + { + name: "daoQuoteVaultAccount", + isMut: false, + isSigner: false, + }, + { + name: "proposer", + isMut: false, + isSigner: true, + }, + { + name: "recipient", + isMut: false, + isSigner: false, + }, + { + name: "recipientQuoteAccount", + 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: "adminEnqueueMultisigProposalApproval", + accounts: [ + { + name: "dao", + isMut: false, + isSigner: false, + }, + { + name: "admin", + isMut: true, + isSigner: true, + }, + { + name: "squadsMultisig", + isMut: false, + isSigner: false, + }, + { + name: "squadsMultisigProposal", + isMut: false, + isSigner: false, + }, + { + name: "enqueuedApproval", + isMut: true, + isSigner: false, + }, + { + name: "systemProgram", + isMut: false, + isSigner: false, + }, + ], + args: [ + { + name: "args", + type: { + defined: "AdminEnqueueMultisigProposalApprovalArgs", + }, + }, + ], + }, + { + name: "executeMultisigProposalApproval", + accounts: [ + { + name: "dao", + isMut: true, + isSigner: false, + }, + { + name: "rentReceiver", + isMut: true, + isSigner: true, + }, + { + name: "squadsMultisig", + isMut: true, + isSigner: false, + }, + { + name: "squadsMultisigProposal", + isMut: true, + isSigner: false, + }, + { + name: "enqueuedApproval", + isMut: true, + isSigner: false, + }, + { + name: "squadsMultisigProgram", + isMut: false, + isSigner: false, + }, + ], + args: [], + }, + { + name: "adminExecuteMultisigProposal", + 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, + }, + ], + args: [], + }, + { + name: "adminCancelProposal", + accounts: [ + { + name: "proposal", + isMut: true, + isSigner: false, + }, + { + name: "dao", + isMut: true, + isSigner: false, + }, + { + name: "question", + isMut: true, + isSigner: false, + }, + { + name: "squadsProposal", + isMut: true, + isSigner: false, + }, + { + name: "squadsMultisig", + isMut: false, + isSigner: false, + }, + { + name: "squadsMultisigProgram", + isMut: false, + isSigner: false, + }, + { + name: "ammPassBaseVault", + isMut: true, + isSigner: false, + }, + { + name: "ammPassQuoteVault", + isMut: true, + isSigner: false, + }, + { + name: "ammFailBaseVault", + isMut: true, + isSigner: false, + }, + { + name: "ammFailQuoteVault", + isMut: true, + isSigner: false, + }, + { + name: "ammBaseVault", + isMut: true, + isSigner: false, + }, + { + name: "ammQuoteVault", + isMut: true, + isSigner: false, + }, + { + name: "vaultProgram", + isMut: false, + isSigner: false, + }, + { + name: "vaultEventAuthority", + isMut: false, + isSigner: false, + }, + { + name: "tokenProgram", + isMut: false, + isSigner: false, + }, + { + name: "quoteVault", + isMut: true, + isSigner: false, + }, + { + name: "quoteVaultUnderlyingTokenAccount", + isMut: true, + isSigner: false, + }, + { + name: "passQuoteMint", + isMut: true, + isSigner: false, + }, + { + name: "failQuoteMint", + isMut: true, + isSigner: false, + }, + { + name: "passBaseMint", + isMut: true, + isSigner: false, + }, + { + name: "failBaseMint", + isMut: true, + isSigner: false, + }, + { + name: "baseVault", + isMut: true, + isSigner: false, + }, + { + name: "baseVaultUnderlyingTokenAccount", + isMut: true, + isSigner: false, + }, + { + name: "admin", + isMut: false, + isSigner: true, + }, + { + name: "eventAuthority", + isMut: false, + isSigner: false, + }, + { + name: "program", + isMut: false, + isSigner: false, + }, + ], + args: [], + }, + { + name: "adminRemoveProposal", + accounts: [ + { + name: "proposal", + isMut: true, + isSigner: false, + }, + { + name: "dao", + isMut: true, + isSigner: false, + }, + { + name: "admin", + isMut: true, + isSigner: true, + }, + { + name: "eventAuthority", + isMut: false, + isSigner: false, + }, + { + name: "program", + isMut: false, + isSigner: false, + }, + ], + args: [], + }, + ], + accounts: [ + { + name: "ammPosition", + type: { + kind: "struct", + fields: [ + { + 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: "pdaBump", + type: "u8", + }, + { + name: "squadsMultisig", + type: "publicKey", + }, + { + name: "squadsMultisigVault", + type: "publicKey", + }, + { + name: "baseMint", + type: "publicKey", + }, + { + name: "quoteMint", + type: "publicKey", + }, + { + name: "proposalCount", + type: "u32", + }, + { + name: "passThresholdBps", + type: "u16", + }, + { + name: "secondsPerProposal", + type: "u32", + }, + { + name: "twapInitialObservation", + docs: [ + "For manipulation-resistance the TWAP is a time-weighted average observation,", + "where observation tries to approximate price but can only move by", + "`twap_max_observation_change_per_update` per update. Because it can only move", + "a little bit per update, you need to check that it has a good initial observation.", + "Otherwise, an attacker could create a very high initial observation in the pass", + "market and a very low one in the fail market to force the proposal to pass.", + "", + "We recommend setting an initial observation around the spot price of the token,", + "and max observation change per update around 2% the spot price of the token.", + "For example, if the spot price of META is $400, we'd recommend setting an initial", + "observation of 400 (converted into the AMM prices) and a max observation change per", + "update of 8 (also converted into the AMM prices). Observations can be updated once", + "a minute, so 2% allows the proposal market to reach double the spot price or 0", + "in 50 minutes.", + ], + type: "u128", + }, + { + name: "twapMaxObservationChangePerUpdate", + type: "u128", + }, + { + name: "twapStartDelaySeconds", + docs: [ + "Forces TWAP calculation to start after `twap_start_delay_seconds` seconds", + ], + type: "u32", + }, + { + name: "minQuoteFutarchicLiquidity", + docs: [ + "As an anti-spam measure and to help liquidity, you need to lock up some liquidity", + "in both futarchic markets in order to create a proposal.", + "", + "For example, for META, we can use a `min_quote_futarchic_liquidity` of", + "5000 * 1_000_000 (5000 USDC) and a `min_base_futarchic_liquidity` of", + "10 * 1_000_000_000 (10 META).", + ], + type: "u64", + }, + { + name: "minBaseFutarchicLiquidity", + type: "u64", + }, + { + name: "baseToStake", + docs: [ + "Minimum amount of base tokens that must be staked to launch a proposal", + ], + type: "u64", + }, + { + name: "seqNum", + type: "u64", + }, + { + name: "initialSpendingLimit", + type: { + option: { + defined: "InitialSpendingLimit", + }, + }, + }, + { + name: "teamSponsoredPassThresholdBps", + docs: [ + "The percentage, in basis points, the pass price needs to be above the", + "fail price in order for the proposal to pass for team-sponsored proposals.", + "", + "Can be negative to allow for team-sponsored proposals to pass by default.", + ], + type: "i16", + }, + { + name: "teamAddress", + type: "publicKey", + }, + { + name: "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", + }, + ], + }, + }, + { + name: "enqueuedMultisigProposalApproval", + type: { + kind: "struct", + fields: [ + { + name: "dao", + type: "publicKey", + }, + { + name: "transactionIndex", + type: "u64", + }, + { + name: "pdaBump", + type: "u8", + }, + ], + }, + }, + { + name: "proposal", + type: { + kind: "struct", + fields: [ + { + name: "number", + type: "u32", + }, + { + name: "proposer", + type: "publicKey", + }, + { + name: "timestampEnqueued", + type: "i64", + }, + { + name: "state", + type: { + defined: "ProposalState", + }, + }, + { + name: "baseVault", + type: "publicKey", + }, + { + name: "quoteVault", + type: "publicKey", + }, + { + name: "dao", + type: "publicKey", + }, + { + name: "pdaBump", + type: "u8", + }, + { + name: "question", + type: "publicKey", + }, + { + name: "durationInSeconds", + type: "u32", + }, + { + name: "squadsProposal", + type: "publicKey", + }, + { + name: "passBaseMint", + type: "publicKey", + }, + { + name: "passQuoteMint", + type: "publicKey", + }, + { + name: "failBaseMint", + type: "publicKey", + }, + { + name: "failQuoteMint", + type: "publicKey", + }, + { + name: "isTeamSponsored", + type: "bool", + }, + ], + }, + }, + { + name: "stakeAccount", + type: { + kind: "struct", + fields: [ + { + name: "proposal", + type: "publicKey", + }, + { + name: "staker", + type: "publicKey", + }, + { + name: "amount", + type: "u64", + }, + { + name: "bump", + type: "u8", + }, + ], + }, + }, + ], + types: [ + { + name: "CommonFields", + type: { + kind: "struct", + fields: [ + { + name: "slot", + type: "u64", + }, + { + name: "unixTimestamp", + type: "i64", + }, + { + name: "daoSeqNum", + type: "u64", + }, + ], + }, + }, + { + name: "AdminEnqueueMultisigProposalApprovalArgs", + type: { + kind: "struct", + fields: [ + { + name: "transactionIndex", + type: "u64", + }, + ], + }, + }, + { + name: "ConditionalSwapParams", + type: { + kind: "struct", + fields: [ + { + name: "market", + type: { + defined: "Market", + }, + }, + { + name: "swapType", + type: { + defined: "SwapType", + }, + }, + { + name: "inputAmount", + type: "u64", + }, + { + name: "minOutputAmount", + type: "u64", + }, + ], + }, + }, + { + name: "InitializeDaoParams", + type: { + kind: "struct", + fields: [ + { + name: "twapInitialObservation", + type: "u128", + }, + { + name: "twapMaxObservationChangePerUpdate", + type: "u128", + }, + { + name: "twapStartDelaySeconds", + type: "u32", + }, + { + name: "minQuoteFutarchicLiquidity", + type: "u64", + }, + { + name: "minBaseFutarchicLiquidity", + type: "u64", + }, + { + name: "baseToStake", + type: "u64", + }, + { + name: "passThresholdBps", + type: "u16", + }, + { + name: "secondsPerProposal", + type: "u32", + }, + { + name: "nonce", + type: "u64", + }, + { + name: "initialSpendingLimit", + type: { + option: { + defined: "InitialSpendingLimit", + }, + }, + }, + { + name: "teamSponsoredPassThresholdBps", + type: "i16", + }, + { + name: "teamAddress", + type: "publicKey", + }, + ], + }, + }, + { + name: "InitiateVaultSpendOptimisticProposalParams", + type: { + kind: "struct", + fields: [ + { + name: "amount", + type: "u64", + }, + ], + }, + }, + { + name: "ProvideLiquidityParams", + type: { + kind: "struct", + fields: [ + { + name: "quoteAmount", + docs: ["How much quote token you will deposit to the pool"], + type: "u64", + }, + { + name: "maxBaseAmount", + docs: ["The maximum base token you will deposit to the pool"], + type: "u64", + }, + { + name: "minLiquidity", + docs: ["The minimum liquidity you will be assigned"], + type: "u128", + }, + { + name: "positionAuthority", + docs: [ + "The account that will own the LP position, usually the same as the", + "liquidity provider", + ], + type: "publicKey", + }, + ], + }, + }, + { + name: "SpotSwapParams", + type: { + kind: "struct", + fields: [ + { + name: "inputAmount", + type: "u64", + }, + { + name: "swapType", + type: { + defined: "SwapType", + }, + }, + { + name: "minOutputAmount", + type: "u64", + }, + ], + }, + }, + { + name: "StakeToProposalParams", + type: { + kind: "struct", + fields: [ + { + name: "amount", + type: "u64", + }, + ], + }, + }, + { + name: "UnstakeFromProposalParams", + type: { + kind: "struct", + fields: [ + { + name: "amount", + type: "u64", + }, + ], + }, + }, + { + name: "UpdateDaoParams", + type: { + kind: "struct", + fields: [ + { + name: "passThresholdBps", + type: { + option: "u16", + }, + }, + { + name: "secondsPerProposal", + type: { + option: "u32", + }, + }, + { + name: "twapInitialObservation", + type: { + option: "u128", + }, + }, + { + name: "twapMaxObservationChangePerUpdate", + type: { + option: "u128", + }, + }, + { + name: "twapStartDelaySeconds", + type: { + option: "u32", + }, + }, + { + name: "minQuoteFutarchicLiquidity", + type: { + option: "u64", + }, + }, + { + name: "minBaseFutarchicLiquidity", + type: { + option: "u64", + }, + }, + { + name: "baseToStake", + type: { + option: "u64", + }, + }, + { + name: "teamSponsoredPassThresholdBps", + type: { + option: "i16", + }, + }, + { + name: "teamAddress", + type: { + option: "publicKey", + }, + }, + { + name: "isOptimisticGovernanceEnabled", + type: { + option: "bool", + }, + }, + ], + }, + }, + { + name: "WithdrawLiquidityParams", + type: { + kind: "struct", + fields: [ + { + name: "liquidityToWithdraw", + docs: ["How much liquidity to withdraw"], + type: "u128", + }, + { + name: "minBaseAmount", + docs: ["Minimum base tokens to receive"], + type: "u64", + }, + { + name: "minQuoteAmount", + docs: ["Minimum quote tokens to receive"], + type: "u64", + }, + ], + }, + }, + { + 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: { + kind: "struct", + fields: [ + { + name: "amountPerMonth", + type: "u64", + }, + { + name: "members", + type: { + vec: "publicKey", + }, + }, + ], + }, + }, + { + name: "FutarchyAmm", + type: { + kind: "struct", + fields: [ + { + name: "state", + type: { + defined: "PoolState", + }, + }, + { + name: "totalLiquidity", + type: "u128", + }, + { + name: "baseMint", + type: "publicKey", + }, + { + name: "quoteMint", + type: "publicKey", + }, + { + name: "ammBaseVault", + type: "publicKey", + }, + { + name: "ammQuoteVault", + type: "publicKey", + }, + ], + }, + }, + { + name: "TwapOracle", + type: { + kind: "struct", + fields: [ + { + name: "aggregator", + docs: [ + "Running sum of seconds_since_last_update * last_observation.", + "", + "Assuming latest observations are as big as possible (u64::MAX * 1e12),", + "we can store 18 million seconds worth of observations, which turns out to", + "be ~213 days.", + "", + "Assuming that latest observations are 100x smaller than they could theoretically", + "be, we can store ~57 years worth of them. Even this is a very", + "very conservative assumption - META/USDC prices should be between 1e9 and", + "1e15, which would overflow after 1e15 years.", + "", + "So in the case of an overflow, the aggregator rolls back to 0. It's the", + "client's responsibility to sanity check the assets or to handle an", + "aggregator at T2 being smaller than an aggregator at T1.", + ], + type: "u128", + }, + { + name: "lastUpdatedTimestamp", + type: "i64", + }, + { + name: "createdAtTimestamp", + type: "i64", + }, + { + name: "lastPrice", + docs: [ + "A price is the number of quote units per base unit multiplied by 1e12.", + "You cannot simply divide by 1e12 to get a price you can display in the UI", + "because the base and quote decimals may be different. Instead, do:", + "ui_price = (price * (10**(base_decimals - quote_decimals))) / 1e12", + ], + type: "u128", + }, + { + name: "lastObservation", + docs: [ + "If we did a raw TWAP over prices, someone could push the TWAP heavily with", + "a few extremely large outliers. So we use observations, which can only move", + "by `max_observation_change_per_update` per update.", + ], + type: "u128", + }, + { + name: "maxObservationChangePerUpdate", + docs: ["The most that an observation can change per update."], + type: "u128", + }, + { + name: "initialObservation", + docs: ["What the initial `latest_observation` is set to."], + type: "u128", + }, + { + name: "startDelaySeconds", + docs: [ + "Number of seconds after amm.created_at_slot to start recording TWAP", + ], + type: "u32", + }, + ], + }, + }, + { + name: "Pool", + type: { + kind: "struct", + fields: [ + { + name: "oracle", + type: { + defined: "TwapOracle", + }, + }, + { + name: "quoteReserves", + type: "u64", + }, + { + name: "baseReserves", + type: "u64", + }, + { + name: "quoteProtocolFeeBalance", + type: "u64", + }, + { + name: "baseProtocolFeeBalance", + type: "u64", + }, + ], + }, + }, + { + name: "PoolState", + type: { + kind: "enum", + variants: [ + { + name: "Spot", + fields: [ + { + name: "spot", + type: { + defined: "Pool", + }, + }, + ], + }, + { + name: "Futarchy", + fields: [ + { + name: "spot", + type: { + defined: "Pool", + }, + }, + { + name: "pass", + type: { + defined: "Pool", + }, + }, + { + name: "fail", + type: { + defined: "Pool", + }, + }, + ], + }, + ], + }, + }, + { + name: "Market", + type: { + kind: "enum", + variants: [ + { + name: "Spot", + }, + { + name: "Pass", + }, + { + name: "Fail", + }, + ], + }, + }, + { + name: "SwapType", + type: { + kind: "enum", + variants: [ + { + name: "Buy", + }, + { + name: "Sell", + }, + ], + }, + }, + { + name: "Token", + type: { + kind: "enum", + variants: [ + { + name: "Base", + }, + { + name: "Quote", + }, + ], + }, + }, + { + name: "ProposalState", + type: { + kind: "enum", + variants: [ + { + name: "Draft", + fields: [ + { + name: "amountStaked", + type: "u64", + }, + ], + }, + { + name: "Pending", + }, + { + name: "Passed", + }, + { + name: "Failed", + }, + { + name: "Removed", + }, + ], + }, + }, + ], + events: [ + { + name: "CollectFeesEvent", + fields: [ + { + name: "common", + type: { + defined: "CommonFields", + }, + index: false, + }, + { + name: "dao", + type: "publicKey", + index: false, + }, + { + name: "baseTokenAccount", + type: "publicKey", + index: false, + }, + { + name: "quoteTokenAccount", + type: "publicKey", + index: false, + }, + { + name: "ammBaseVault", + type: "publicKey", + index: false, + }, + { + name: "ammQuoteVault", + type: "publicKey", + index: false, + }, + { + name: "quoteMint", + type: "publicKey", + index: false, + }, + { + name: "baseMint", + type: "publicKey", + index: false, + }, + { + name: "quoteFeesCollected", + type: "u64", + index: false, + }, + { + name: "baseFeesCollected", + type: "u64", + index: false, + }, + { + name: "postAmmState", + type: { + defined: "FutarchyAmm", + }, + index: false, + }, + ], + }, + { + name: "InitializeDaoEvent", + fields: [ + { + name: "common", + type: { + defined: "CommonFields", + }, + index: false, + }, + { + name: "dao", + type: "publicKey", + index: false, + }, + { + name: "baseMint", + type: "publicKey", + index: false, + }, + { + name: "quoteMint", + type: "publicKey", + index: false, + }, + { + name: "passThresholdBps", + type: "u16", + index: false, + }, + { + name: "secondsPerProposal", + type: "u32", + index: false, + }, + { + name: "twapInitialObservation", + type: "u128", + index: false, + }, + { + name: "twapMaxObservationChangePerUpdate", + type: "u128", + index: false, + }, + { + name: "twapStartDelaySeconds", + type: "u32", + index: false, + }, + { + name: "minQuoteFutarchicLiquidity", + type: "u64", + index: false, + }, + { + name: "minBaseFutarchicLiquidity", + type: "u64", + index: false, + }, + { + name: "baseToStake", + type: "u64", + index: false, + }, + { + name: "initialSpendingLimit", + type: { + option: { + defined: "InitialSpendingLimit", + }, + }, + index: false, + }, + { + name: "squadsMultisig", + type: "publicKey", + index: false, + }, + { + name: "squadsMultisigVault", + type: "publicKey", + index: false, + }, + { + name: "teamSponsoredPassThresholdBps", + type: "i16", + index: false, + }, + { + name: "teamAddress", + type: "publicKey", + index: false, + }, + ], + }, + { + name: "UpdateDaoEvent", + fields: [ + { + name: "common", + type: { + defined: "CommonFields", + }, + index: false, + }, + { + name: "dao", + type: "publicKey", + index: false, + }, + { + name: "passThresholdBps", + type: "u16", + index: false, + }, + { + name: "secondsPerProposal", + type: "u32", + index: false, + }, + { + name: "twapInitialObservation", + type: "u128", + index: false, + }, + { + name: "twapMaxObservationChangePerUpdate", + type: "u128", + index: false, + }, + { + name: "twapStartDelaySeconds", + type: "u32", + index: false, + }, + { + name: "minQuoteFutarchicLiquidity", + type: "u64", + index: false, + }, + { + name: "minBaseFutarchicLiquidity", + type: "u64", + index: false, + }, + { + name: "baseToStake", + type: "u64", + index: false, + }, + { + name: "teamSponsoredPassThresholdBps", + type: "i16", + index: false, + }, + { + name: "teamAddress", + type: "publicKey", + index: false, + }, + { + name: "isOptimisticGovernanceEnabled", + type: "bool", + index: false, + }, + ], + }, + { + name: "InitializeProposalEvent", + fields: [ + { + name: "common", + type: { + defined: "CommonFields", + }, + index: false, + }, + { + name: "proposal", + type: "publicKey", + index: false, + }, + { + name: "dao", + type: "publicKey", + index: false, + }, + { + name: "question", + type: "publicKey", + index: false, + }, + { + name: "quoteVault", + type: "publicKey", + index: false, + }, + { + name: "baseVault", + type: "publicKey", + index: false, + }, + { + name: "proposer", + type: "publicKey", + index: false, + }, + { + name: "number", + type: "u32", + index: false, + }, + { + name: "pdaBump", + type: "u8", + index: false, + }, + { + name: "durationInSeconds", + type: "u32", + index: false, + }, + { + name: "squadsProposal", + type: "publicKey", + index: false, + }, + { + name: "squadsMultisig", + type: "publicKey", + index: false, + }, + { + name: "squadsMultisigVault", + type: "publicKey", + index: false, + }, + ], + }, + { + name: "StakeToProposalEvent", + fields: [ + { + name: "common", + type: { + defined: "CommonFields", + }, + index: false, + }, + { + name: "proposal", + type: "publicKey", + index: false, + }, + { + name: "staker", + type: "publicKey", + index: false, + }, + { + name: "amount", + type: "u64", + index: false, + }, + { + name: "totalStaked", + type: "u64", + index: false, + }, + ], + }, + { + name: "UnstakeFromProposalEvent", + fields: [ + { + name: "common", + type: { + defined: "CommonFields", + }, + index: false, + }, + { + name: "proposal", + type: "publicKey", + index: false, + }, + { + name: "staker", + type: "publicKey", + index: false, + }, + { + name: "amount", + type: "u64", + index: false, + }, + { + name: "totalStaked", + type: "u64", + index: false, + }, + ], + }, + { + name: "LaunchProposalEvent", + fields: [ + { + name: "common", + type: { + defined: "CommonFields", + }, + index: false, + }, + { + name: "proposal", + type: "publicKey", + index: false, + }, + { + name: "dao", + type: "publicKey", + index: false, + }, + { + name: "timestampEnqueued", + type: "i64", + index: false, + }, + { + name: "totalStaked", + type: "u64", + index: false, + }, + { + name: "postAmmState", + type: { + defined: "FutarchyAmm", + }, + index: false, + }, + ], + }, + { + name: "FinalizeProposalEvent", + fields: [ + { + name: "common", + type: { + defined: "CommonFields", + }, + index: false, + }, + { + name: "proposal", + type: "publicKey", + index: false, + }, + { + name: "dao", + type: "publicKey", + index: false, + }, + { + name: "passMarketTwap", + type: "u128", + index: false, + }, + { + name: "failMarketTwap", + type: "u128", + index: false, + }, + { + name: "threshold", + type: "u128", + index: false, + }, + { + name: "state", + type: { + defined: "ProposalState", + }, + index: false, + }, + { + name: "squadsProposal", + type: "publicKey", + index: false, + }, + { + name: "squadsMultisig", + type: "publicKey", + index: false, + }, + { + name: "postAmmState", + type: { + defined: "FutarchyAmm", + }, + index: false, + }, + { + name: "isTeamSponsored", + type: "bool", + index: false, + }, + ], + }, + { + name: "SpotSwapEvent", + fields: [ + { + name: "common", + type: { + defined: "CommonFields", + }, + index: false, + }, + { + name: "dao", + type: "publicKey", + index: false, + }, + { + name: "user", + type: "publicKey", + index: false, + }, + { + name: "swapType", + type: { + defined: "SwapType", + }, + index: false, + }, + { + name: "inputAmount", + type: "u64", + index: false, + }, + { + name: "outputAmount", + type: "u64", + index: false, + }, + { + name: "minOutputAmount", + type: "u64", + index: false, + }, + { + name: "postAmmState", + type: { + defined: "FutarchyAmm", + }, + index: false, + }, + ], + }, + { + name: "ConditionalSwapEvent", + fields: [ + { + name: "common", + type: { + defined: "CommonFields", + }, + index: false, + }, + { + name: "dao", + type: "publicKey", + index: false, + }, + { + name: "proposal", + type: "publicKey", + index: false, + }, + { + name: "trader", + type: "publicKey", + index: false, + }, + { + name: "market", + type: { + defined: "Market", + }, + index: false, + }, + { + name: "swapType", + type: { + defined: "SwapType", + }, + index: false, + }, + { + name: "inputAmount", + type: "u64", + index: false, + }, + { + name: "outputAmount", + type: "u64", + index: false, + }, + { + name: "minOutputAmount", + type: "u64", + index: false, + }, + { + name: "postAmmState", + type: { + defined: "FutarchyAmm", + }, + index: false, + }, + ], + }, + { + name: "ProvideLiquidityEvent", + fields: [ + { + name: "common", + type: { + defined: "CommonFields", + }, + index: false, + }, + { + name: "dao", + type: "publicKey", + index: false, + }, + { + name: "liquidityProvider", + type: "publicKey", + index: false, + }, + { + name: "positionAuthority", + type: "publicKey", + index: false, + }, + { + name: "quoteAmount", + type: "u64", + index: false, + }, + { + name: "baseAmount", + type: "u64", + index: false, + }, + { + name: "liquidityMinted", + type: "u128", + index: false, + }, + { + name: "minLiquidity", + type: "u128", + index: false, + }, + { + name: "postAmmState", + type: { + defined: "FutarchyAmm", + }, + index: false, + }, + ], + }, + { + name: "WithdrawLiquidityEvent", + fields: [ + { + name: "common", + type: { + defined: "CommonFields", + }, + index: false, + }, + { + name: "dao", + type: "publicKey", + index: false, + }, + { + name: "liquidityProvider", + type: "publicKey", + index: false, + }, + { + name: "liquidityWithdrawn", + type: "u128", + index: false, + }, + { + 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: "RemoveProposalEvent", + fields: [ + { + name: "common", + type: { + defined: "CommonFields", + }, + index: false, + }, + { + name: "proposal", + type: "publicKey", + index: false, + }, + { + name: "dao", + type: "publicKey", + index: false, + }, + { + name: "admin", + type: "publicKey", + index: false, + }, + ], + }, + { + name: "AdminCancelProposalEvent", + fields: [ + { + name: "common", + type: { + defined: "CommonFields", + }, + index: false, + }, + { + name: "proposal", + type: "publicKey", + index: false, + }, + { + name: "dao", + type: "publicKey", + index: false, + }, + { + name: "admin", + type: "publicKey", + index: false, + }, + { + name: "postAmmState", + type: { + defined: "FutarchyAmm", + }, + 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: "quoteMint", + type: "publicKey", + index: false, + }, + { + name: "baseMint", + type: "publicKey", + index: false, + }, + { + name: "quoteFeesCollected", + type: "u64", + index: false, + }, + { + name: "baseFeesCollected", + type: "u64", + index: false, + }, + ], + }, + { + name: "AdminFixPositionAuthorityEvent", + fields: [ + { + name: "common", + type: { + defined: "CommonFields", + }, + index: false, + }, + { + name: "dao", + type: "publicKey", + index: false, + }, + { + name: "admin", + type: "publicKey", + index: false, + }, + { + name: "ammPosition", + type: "publicKey", + index: false, + }, + { + name: "oldAuthority", + type: "publicKey", + index: false, + }, + { + name: "newAuthority", + type: "publicKey", + 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: "squadsProposal", + type: "publicKey", + index: false, + }, + ], + }, + ], + errors: [ + { + code: 6000, + name: "AmmTooOld", + msg: "Amms must have been created within 5 minutes (counted in slots) of proposal initialization", + }, + { + code: 6001, + name: "InvalidInitialObservation", + msg: "An amm has an `initial_observation` that doesn't match the `dao`'s config", + }, + { + code: 6002, + name: "InvalidMaxObservationChange", + msg: "An amm has a `max_observation_change_per_update` that doesn't match the `dao`'s config", + }, + { + code: 6003, + name: "InvalidStartDelaySlots", + msg: "An amm has a `start_delay_slots` that doesn't match the `dao`'s config", + }, + { + code: 6004, + name: "InvalidSettlementAuthority", + msg: "One of the vaults has an invalid `settlement_authority`", + }, + { + code: 6005, + name: "ProposalTooYoung", + msg: "Proposal is too young to be executed or rejected", + }, + { + code: 6006, + name: "MarketsTooYoung", + msg: "Markets too young for proposal to be finalized. TWAP might need to be cranked", + }, + { + code: 6007, + name: "ProposalAlreadyFinalized", + msg: "This proposal has already been finalized", + }, + { + code: 6008, + name: "InvalidVaultNonce", + msg: "A conditional vault has an invalid nonce. A nonce should encode the proposal number", + }, + { + code: 6009, + name: "ProposalNotPassed", + msg: "This proposal can't be executed because it isn't in the passed state", + }, + { + code: 6010, + name: "InsufficientLiquidity", + msg: "More liquidity needs to be in the AMM to launch this proposal", + }, + { + code: 6011, + name: "ProposalDurationTooShort", + msg: "Proposal duration must be longer 1 day and longer than 2 times the TWAP start delay", + }, + { + code: 6012, + name: "PassThresholdTooHigh", + msg: "Pass threshold must be less than 10%", + }, + { + code: 6013, + name: "QuestionMustBeBinary", + msg: "Question must have exactly 2 outcomes for binary futarchy", + }, + { + code: 6014, + name: "InvalidSquadsProposalStatus", + msg: "Squads proposal must be in Active status", + }, + { + code: 6015, + name: "CastingOverflow", + msg: "Casting overflow. If you're seeing this, please report this", + }, + { + code: 6016, + name: "InsufficientBalance", + msg: "Insufficient balance", + }, + { + code: 6017, + name: "ZeroLiquidityRemove", + msg: "Cannot remove zero liquidity", + }, + { + code: 6018, + name: "SwapSlippageExceeded", + msg: "Swap slippage exceeded", + }, + { + code: 6019, + name: "AssertFailed", + msg: "Assert failed", + }, + { + code: 6020, + name: "InvalidAdmin", + msg: "Invalid admin", + }, + { + code: 6021, + name: "ProposalNotInDraftState", + msg: "Proposal is not in draft state", + }, + { + code: 6022, + name: "InsufficientTokenBalance", + msg: "Insufficient token balance", + }, + { + code: 6023, + name: "InvalidAmount", + msg: "Invalid amount", + }, + { + code: 6024, + name: "InsufficientStakeToLaunch", + msg: "Insufficient stake to launch proposal", + }, + { + code: 6025, + name: "StakerNotFound", + msg: "Staker not found in proposal", + }, + { + code: 6026, + name: "PoolNotInSpotState", + msg: "Pool must be in spot state", + }, + { + code: 6027, + name: "InvalidDaoCreateLiquidity", + msg: "If you're providing liquidity, you must provide both base and quote token accounts", + }, + { + code: 6028, + name: "InvalidStakeAccount", + msg: "Invalid stake account", + }, + { + code: 6029, + name: "InvariantViolated", + msg: "An invariant was violated. You should get in contact with the MetaDAO team if you see this", + }, + { + code: 6030, + name: "ProposalNotActive", + msg: "Proposal needs to be active to perform a conditional swap", + }, + { + code: 6031, + name: "InvalidTransaction", + msg: "This Squads transaction should only contain calls to update spending limits", + }, + { + code: 6032, + name: "ProposalAlreadySponsored", + msg: "Proposal has already been sponsored", + }, + { + code: 6033, + name: "InvalidTeamSponsoredPassThreshold", + msg: "Team sponsored pass threshold must be between -10% and 10%", + }, + { + code: 6034, + name: "InvalidTargetK", + msg: "Target K must be greater than the current K", + }, + { + code: 6035, + name: "InvalidTransactionMessage", + msg: "Failed to compile transaction message for Squads vault transaction", + }, + { + code: 6036, + name: "InvalidMint", + msg: "Base mint and quote mint must be different", + }, + { + code: 6037, + name: "ProposalNotReadyToUnstake", + msg: "Proposal is not ready to be unstaked", + }, + { + code: 6038, + name: "OptimisticGovernanceDisabled", + msg: "Optimistic governance is disabled", + }, + { + code: 6039, + name: "ActiveOptimisticProposalAlreadyEnqueued", + msg: "An active optimistic proposal is already enqueued", + }, + { + code: 6040, + name: "OptimisticProposalAlreadyPassed", + msg: "Optimistic proposal has already passed", + }, + { + code: 6041, + name: "InvalidSpendingLimitMint", + msg: "Invalid spending limit mint. Must be the same as the DAO's quote mint", + }, + { + code: 6042, + name: "NoActiveOptimisticProposal", + msg: "No active optimistic proposal", + }, + ], +}; diff --git a/tests/futarchy/integration/cooldownRoundTrip.test.ts b/tests/futarchy/integration/cooldownRoundTrip.test.ts new file mode 100644 index 00000000..da274b43 --- /dev/null +++ b/tests/futarchy/integration/cooldownRoundTrip.test.ts @@ -0,0 +1,161 @@ +import { getDaoAddr, PriceMath } from "@metadaoproject/programs"; +import { ComputeBudgetProgram, Keypair } from "@solana/web3.js"; +import BN from "bn.js"; +import { assert } from "chai"; +import { expectError } from "../../utils.js"; + +const THOUSAND_BUCK_PRICE = PriceMath.getAmmPrice(1000, 6, 6); + +// The anti-grief mechanism spans finalize (stamp) and launch (check) across +// two proposals. The takeover cooldown round-trip is unit-covered in +// launchProposal.test.ts; this drives the liquidation kind's stamp and its +// 10-day cooldown through the same seam. +export default function suite() { + it("refuses a relaunch inside the liquidation cooldown and launches once it elapses", async function () { + const META = await this.createMint(this.payer.publicKey, 6); + const USDC = await this.createMint(this.payer.publicKey, 6); + + await this.createTokenAccount(META, this.payer.publicKey); + await this.createTokenAccount(USDC, this.payer.publicKey); + + await this.mintTo( + META, + this.payer.publicKey, + this.payer, + 1_000 * 1_000_000, + ); + await this.mintTo( + USDC, + this.payer.publicKey, + this.payer, + 200_000 * 1_000_000, + ); + + 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: null, + baseToStake: new BN(0), + teamSponsoredPassThresholdBps: 300, + teamAddress: this.payer.publicKey, + }, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), + ]) + .rpc(); + + const [dao] = getDaoAddr({ nonce, daoCreator: this.payer.publicKey }); + + await this.futarchy + .provideLiquidityIx({ + dao, + baseMint: META, + quoteMint: USDC, + quoteAmount: new BN(100_000 * 1_000_000), // 100,000 USDC + maxBaseAmount: new BN(100 * 1_000_000), // 100 META + minLiquidity: new BN(0), + positionAuthority: this.payer.publicKey, + liquidityProvider: this.payer.publicKey, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), + ]) + .rpc(); + + // Fail a first liquidation so the DAO stamps last_failed_liquidation_at + const first = await this.futarchy.initializeHostileLiquidateProposal({ + dao, + liquidator: Keypair.generate().publicKey, + }); + + await this.futarchy + .launchProposalIx({ + proposal: first.proposal, + dao, + baseMint: META, + quoteMint: USDC, + squadsProposal: first.squadsProposal, + }) + .rpc(); + + // One swap after the TWAP start delay records an observation in both + // markets; the equal TWAPs it leaves can't clear the +25% threshold + await this.advanceBySeconds(60 * 60 * 24 + 60); + await this.futarchy + .spotSwapIx({ + dao, + baseMint: META, + quoteMint: USDC, + swapType: "buy", + inputAmount: new BN(1_000), + }) + .rpc(); + + await this.advanceBySeconds(60 * 60 * 24 * 10); + await this.futarchy.finalizeProposal(first.proposal); + + const failedProposal = await this.futarchy.getProposal(first.proposal); + assert.exists(failedProposal.state.failed); + + const clock = await this.banksClient.getClock(); + const storedDao = await this.futarchy.getDao(dao); + assert.equal( + storedDao.lastFailedLiquidationAt.toString(), + clock.unixTimestamp.toString(), + ); + + // An immediate relaunch is refused + const second = await this.futarchy.initializeHostileLiquidateProposal({ + dao, + liquidator: Keypair.generate().publicKey, + }); + + const callbacks = expectError( + "HostileCooldownActive", + "launched a hostile liquidation during its cooldown", + ); + + await this.futarchy + .launchProposalIx({ + proposal: second.proposal, + dao, + baseMint: META, + quoteMint: USDC, + squadsProposal: second.squadsProposal, + }) + .rpc() + .then(callbacks[0], callbacks[1]); + + // The 10-day cooldown gate is inclusive of its final second + await this.advanceBySeconds(60 * 60 * 24 * 10); + + await this.futarchy + .launchProposalIx({ + proposal: second.proposal, + dao, + baseMint: META, + quoteMint: USDC, + squadsProposal: second.squadsProposal, + }) + .postInstructions([ + ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), + ]) + .rpc(); + + const storedProposal = await this.futarchy.getProposal(second.proposal); + assert.exists(storedProposal.state.pending); + }); +} diff --git a/tests/futarchy/integration/largeSpendEndToEnd.test.ts b/tests/futarchy/integration/largeSpendEndToEnd.test.ts new file mode 100644 index 00000000..3d1b6839 --- /dev/null +++ b/tests/futarchy/integration/largeSpendEndToEnd.test.ts @@ -0,0 +1,143 @@ +import { getDaoAddr, PriceMath } from "@metadaoproject/programs"; +import { ComputeBudgetProgram, Keypair } from "@solana/web3.js"; +import BN from "bn.js"; +import { assert } from "chai"; +import { executeVaultTransaction } from "../../utils.js"; + +const THOUSAND_BUCK_PRICE = PriceMath.getAmmPrice(1000, 6, 6); + +// The "behaviorally optimistic" replacement: a sponsored, uncontested +// team payment goes through at -10% with no optimistic machinery. +export default function suite() { + it("pays the team's quote ATA: sponsor, launch, uncontested pass at -10%, execute", async function () { + const META = await this.createMint(this.payer.publicKey, 6); + const USDC = await this.createMint(this.payer.publicKey, 6); + + await this.createTokenAccount(META, this.payer.publicKey); + await this.createTokenAccount(USDC, this.payer.publicKey); + + await this.mintTo( + META, + this.payer.publicKey, + this.payer, + 1_000 * 1_000_000, + ); + await this.mintTo( + USDC, + this.payer.publicKey, + this.payer, + 200_000 * 1_000_000, + ); + + const team = Keypair.generate(); + 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(1_000_000_000), // 1,000 USDC + members: [this.payer.publicKey], + }, + baseToStake: new BN(0), + teamSponsoredPassThresholdBps: 300, + teamAddress: team.publicKey, + }, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), + ]) + .rpc(); + + const [dao] = getDaoAddr({ nonce, daoCreator: this.payer.publicKey }); + + await this.futarchy + .provideLiquidityIx({ + dao, + baseMint: META, + quoteMint: USDC, + quoteAmount: new BN(100_000 * 1_000_000), // 100,000 USDC + maxBaseAmount: new BN(100 * 1_000_000), // 100 META + minLiquidity: new BN(0), + positionAuthority: this.payer.publicKey, + liquidityProvider: this.payer.publicKey, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), + ]) + .rpc(); + + // 3x the monthly limit: the largest spend the kind allows + const amount = new BN(3_000_000_000); // 3,000 USDC + + // The payload's destination, pinned to dao.team_address at create + await this.createTokenAccount(USDC, team.publicKey); + + const { proposal, squadsProposal, squadsTransaction } = + await this.futarchy.initializeLargeSpendProposal({ dao, amount }); + + await this.futarchy + .sponsorProposalIx({ + proposal, + dao, + teamAddress: team.publicKey, + }) + .signers([team]) + .rpc(); + + await this.futarchy + .launchProposalIx({ + proposal, + dao, + baseMint: META, + quoteMint: USDC, + squadsProposal, + }) + .rpc(); + + // Nobody contests the market: one swap after the TWAP start delay records + // an observation in both markets and leaves the TWAPs equal + await this.advanceBySeconds(60 * 60 * 24 + 60); + await this.futarchy + .spotSwapIx({ + dao, + baseMint: META, + quoteMint: USDC, + swapType: "buy", + inputAmount: new BN(1_000), + }) + .rpc(); + + // Past the kind's 1.5-day duration snapshot; equal TWAPs clear the -10% + // snapshot threshold + await this.advanceBySeconds(129_600); + await this.futarchy.finalizeProposal(proposal); + + const storedProposal = await this.futarchy.getProposal(proposal); + assert.exists(storedProposal.state.passed); + + // Fund the treasury ATA the payload pulls from, then execute + const storedDao = await this.futarchy.getDao(dao); + const vault = storedDao.squadsMultisigVault; + await this.createTokenAccount(USDC, vault); + await this.mintTo(USDC, vault, this.payer, amount.toNumber()); + + await executeVaultTransaction(this, dao, squadsTransaction); + + assert.equal( + (await this.getTokenBalance(USDC, team.publicKey)).toString(), + amount.toString(), + ); + assert.equal((await this.getTokenBalance(USDC, vault)).toString(), "0"); + }); +} diff --git a/tests/futarchy/integration/liquidationEndToEnd.test.ts b/tests/futarchy/integration/liquidationEndToEnd.test.ts new file mode 100644 index 00000000..637468f7 --- /dev/null +++ b/tests/futarchy/integration/liquidationEndToEnd.test.ts @@ -0,0 +1,386 @@ +import { + FUTARCHY_V0_6_PROGRAM_ID, + getDaoAddr, + getEventAuthorityAddr, + getProposalAddrsForTransactionIndex, + getSpendingLimitAddr, + PERMISSIONLESS_ACCOUNT, + PriceMath, +} from "@metadaoproject/programs"; +import { + ComputeBudgetProgram, + Keypair, + PublicKey, + SystemProgram, + Transaction, + TransactionMessage, + VersionedTransaction, +} from "@solana/web3.js"; +import { + createTransferInstruction, + getAssociatedTokenAddressSync, + TOKEN_PROGRAM_ID, +} from "@solana/spl-token"; +import BN from "bn.js"; +import { assert } from "chai"; +import * as multisig from "@sqds/multisig"; +import { + createLookupTableForTransaction, + executeVaultTransaction, + pumpPassMarket, +} from "../../utils.js"; + +const THOUSAND_BUCK_PRICE = PriceMath.getAmmPrice(1000, 6, 6); +const SEED_ENQUEUED_APPROVAL = Buffer.from("enqueued_approval"); + +// The no-window path: finalize + execute + sync land as one +// transaction, then the liquidated DAO runs as an estate — liquidator-gated +// enqueue, permissionless approve, ordinary Squads execution — while +// third-party LPs exit on their own schedule. +export default function suite() { + it("liquidates in one transaction, runs the estate cycle, and lets a third-party LP exit", async function () { + const META = await this.createMint(this.payer.publicKey, 6); + const USDC = await this.createMint(this.payer.publicKey, 6); + + await this.createTokenAccount(META, this.payer.publicKey); + await this.createTokenAccount(USDC, this.payer.publicKey); + + await this.mintTo( + META, + this.payer.publicKey, + this.payer, + 1_000 * 1_000_000, + ); + await this.mintTo( + USDC, + this.payer.publicKey, + this.payer, + 500_000 * 1_000_000, + ); + + 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, + // 10% per update: TWAPs converge to actual prices fast enough that + // the pumped pass market clears HostileLiquidate's +25% + twapMaxObservationChangePerUpdate: THOUSAND_BUCK_PRICE.divn(10), + minQuoteFutarchicLiquidity: new BN(10_000), + minBaseFutarchicLiquidity: new BN(10_000), + passThresholdBps: 300, + nonce, + initialSpendingLimit: { + amountPerMonth: new BN(10_000_000_000), // 10,000 USDC + members: [this.payer.publicKey], + }, + baseToStake: new BN(0), + teamSponsoredPassThresholdBps: 300, + teamAddress: this.payer.publicKey, + }, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), + ]) + .rpc(); + + const [dao] = getDaoAddr({ nonce, daoCreator: this.payer.publicKey }); + + const storedDaoBefore = await this.futarchy.getDao(dao); + const vault = storedDaoBefore.squadsMultisigVault; + const multisigPda = storedDaoBefore.squadsMultisig; + + // The baked apply_liquidation payload requires the vault's ATAs to exist + await this.createTokenAccount(META, vault); + await this.createTokenAccount(USDC, vault); + + // The third-party LP position that must stay withdrawable + await this.futarchy + .provideLiquidityIx({ + dao, + baseMint: META, + quoteMint: USDC, + quoteAmount: new BN(100_000 * 1_000_000), // 100,000 USDC + maxBaseAmount: new BN(100 * 1_000_000), // 100 META + minLiquidity: new BN(0), + positionAuthority: this.payer.publicKey, + liquidityProvider: this.payer.publicKey, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), + ]) + .rpc(); + + // The treasury's own LP position, swept at liquidation + await this.futarchy + .provideLiquidityIx({ + dao, + baseMint: META, + quoteMint: USDC, + quoteAmount: new BN(25_000 * 1_000_000), // 25,000 USDC + maxBaseAmount: new BN(25 * 1_000_000), // 25 META + minLiquidity: new BN(1), + positionAuthority: vault, + liquidityProvider: this.payer.publicKey, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), + ]) + .rpc(); + + const liquidator = Keypair.generate(); + + const { proposal, squadsProposal, squadsTransaction } = + await this.futarchy.initializeHostileLiquidateProposal({ + dao, + liquidator: liquidator.publicKey, + }); + + await this.futarchy + .launchProposalIx({ + proposal, + dao, + baseMint: META, + quoteMint: USDC, + squadsProposal, + }) + .rpc(); + + // Runs out the 10-day snapshot with the pass market above +25%, without + // finalizing — finalize rides in the packed transaction below + await pumpPassMarket(this, { + dao, + proposal, + baseMint: META, + quoteMint: USDC, + cranks: 50, + }); + + // finalize + execute + sync packed in ONE transaction: the DAO never + // exists in a passed-but-not-liquidated state + const vaultTransaction = + await multisig.accounts.VaultTransaction.fromAccountAddress( + this.squadsConnection, + squadsTransaction, + ); + const packIxs = [ + await this.futarchy + .finalizeProposalIxV2({ + squadsProposal, + dao, + baseMint: META, + quoteMint: USDC, + }) + .instruction(), + ( + await multisig.instructions.vaultTransactionExecute({ + connection: this.squadsConnection, + multisigPda, + transactionIndex: BigInt(vaultTransaction.index.toString()), + member: PERMISSIONLESS_ACCOUNT.publicKey, + }) + ).instruction, + await this.futarchy.syncSpendingLimitIx({ dao }).instruction(), + ]; + + const lut = await createLookupTableForTransaction( + new Transaction().add(...packIxs), + this, + ); + + const packMessage = new TransactionMessage({ + payerKey: this.payer.publicKey, + recentBlockhash: (await this.banksClient.getLatestBlockhash())[0], + instructions: [ + ComputeBudgetProgram.setComputeUnitLimit({ units: 1_400_000 }), + ...packIxs, + ], + }).compileToV0Message([lut]); + const packTx = new VersionedTransaction(packMessage); + packTx.sign([this.payer, PERMISSIONLESS_ACCOUNT]); + await this.banksClient.processTransaction(packTx); + + // The liquidated end state, all landed by the single transaction + const storedProposal = await this.futarchy.getProposal(proposal); + assert.exists(storedProposal.state.passed); + + const storedDao = await this.futarchy.getDao(dao); + assert.ok(storedDao.liquidator.equals(liquidator.publicKey)); + assert.isNull(storedDao.initialSpendingLimit); + assert.isFalse(storedDao.spendingLimitDirty); + + const [spendingLimitPda] = getSpendingLimitAddr({ dao }); + assert.isNull(await this.banksClient.getAccount(spendingLimitPda)); + + const [treasuryPosition] = PublicKey.findProgramAddressSync( + [Buffer.from("amm_position"), dao.toBuffer(), vault.toBuffer()], + FUTARCHY_V0_6_PROGRAM_ID, + ); + const storedTreasuryPosition = + await this.futarchy.futarchy.account.ammPosition.fetch(treasuryPosition); + assert.equal(storedTreasuryPosition.liquidity.toString(), "0"); + + const sweptBase = await this.getTokenBalance(META, vault); + const sweptQuote = await this.getTokenBalance(USDC, vault); + assert.isTrue(sweptBase > 0n); + assert.isTrue(sweptQuote > 0n); + + // The estate cycle: the liquidator enqueues a distribution from the swept + // treasury, the approval executes permissionlessly, and ordinary Squads + // execution pays out + const fundTx = new Transaction().add( + SystemProgram.transfer({ + fromPubkey: this.payer.publicKey, + toPubkey: liquidator.publicKey, + lamports: 1_000_000_000, + }), + ); + fundTx.recentBlockhash = (await this.banksClient.getLatestBlockhash())[0]; + fundTx.feePayer = this.payer.publicKey; + fundTx.sign(this.payer); + await this.banksClient.processTransaction(fundTx); + + const recipient = Keypair.generate().publicKey; + const recipientAta = await this.createTokenAccount(USDC, recipient); + const vaultUsdcAta = getAssociatedTokenAddressSync(USDC, vault, true); + + // The liquidation payload was transaction 1; the estate starts at 2 + const { tx: estateCreateTx } = this.futarchy.squadsProposalCreateTx({ + dao, + instructions: [ + createTransferInstruction( + vaultUsdcAta, + recipientAta, + vault, + 600 * 1_000_000, + ), + ], + transactionIndex: 2n, + }); + estateCreateTx.recentBlockhash = ( + await this.banksClient.getLatestBlockhash() + )[0]; + estateCreateTx.feePayer = this.payer.publicKey; + estateCreateTx.sign(this.payer, PERMISSIONLESS_ACCOUNT); + await this.banksClient.processTransaction(estateCreateTx); + + const { + squadsProposal: estateSquadsProposal, + squadsTransaction: estateSquadsTransaction, + } = getProposalAddrsForTransactionIndex({ dao, transactionIndex: 2n }); + + const [enqueuedApproval] = PublicKey.findProgramAddressSync( + [ + SEED_ENQUEUED_APPROVAL, + dao.toBuffer(), + new BN(2).toArrayLike(Buffer, "le", 8), + ], + this.futarchy.futarchy.programId, + ); + + await this.futarchy.futarchy.methods + .adminEnqueueMultisigProposalApproval({ transactionIndex: new BN(2) }) + .accounts({ + dao, + admin: liquidator.publicKey, + squadsMultisig: multisigPda, + squadsMultisigProposal: estateSquadsProposal, + enqueuedApproval, + }) + .signers([liquidator]) + .rpc(); + + await this.futarchy.futarchy.methods + .executeMultisigProposalApproval() + .accounts({ + dao, + rentReceiver: this.payer.publicKey, + squadsMultisig: multisigPda, + squadsMultisigProposal: estateSquadsProposal, + enqueuedApproval, + squadsMultisigProgram: multisig.PROGRAM_ID, + }) + .rpc(); + + const storedEstateProposal = + await multisig.accounts.Proposal.fromAccountAddress( + this.squadsConnection, + estateSquadsProposal, + ); + assert.isTrue( + multisig.generated.isProposalStatusApproved(storedEstateProposal.status), + ); + + await executeVaultTransaction(this, dao, estateSquadsTransaction); + + assert.equal( + (await this.getTokenBalance(USDC, recipient)).toString(), + (600 * 1_000_000).toString(), + ); + assert.equal( + (await this.getTokenBalance(USDC, vault)).toString(), + (sweptQuote - BigInt(600 * 1_000_000)).toString(), + ); + + // Liquidation never traps third-party LPs: withdraw_liquidity is exempt + // from the liquidated guards + const [lpPosition] = PublicKey.findProgramAddressSync( + [ + Buffer.from("amm_position"), + dao.toBuffer(), + this.payer.publicKey.toBuffer(), + ], + FUTARCHY_V0_6_PROGRAM_ID, + ); + const storedLpPosition = + await this.futarchy.futarchy.account.ammPosition.fetch(lpPosition); + + const preBase = await this.getTokenBalance(META, this.payer.publicKey); + const preQuote = await this.getTokenBalance(USDC, this.payer.publicKey); + + const [eventAuthority] = getEventAuthorityAddr(FUTARCHY_V0_6_PROGRAM_ID); + await this.futarchy.futarchy.methods + .withdrawLiquidity({ + liquidityToWithdraw: storedLpPosition.liquidity, + minBaseAmount: new BN(0), + minQuoteAmount: new BN(0), + }) + .accounts({ + dao, + positionAuthority: this.payer.publicKey, + liquidityProviderBaseAccount: getAssociatedTokenAddressSync( + META, + this.payer.publicKey, + true, + ), + liquidityProviderQuoteAccount: getAssociatedTokenAddressSync( + USDC, + this.payer.publicKey, + true, + ), + ammBaseVault: getAssociatedTokenAddressSync(META, dao, true), + ammQuoteVault: getAssociatedTokenAddressSync(USDC, dao, true), + ammPosition: lpPosition, + tokenProgram: TOKEN_PROGRAM_ID, + eventAuthority, + program: FUTARCHY_V0_6_PROGRAM_ID, + }) + .rpc(); + + assert.isTrue( + (await this.getTokenBalance(META, this.payer.publicKey)) > preBase, + ); + assert.isTrue( + (await this.getTokenBalance(USDC, this.payer.publicKey)) > preQuote, + ); + + const postLpPosition = + await this.futarchy.futarchy.account.ammPosition.fetch(lpPosition); + assert.equal(postLpPosition.liquidity.toString(), "0"); + }); +} diff --git a/tests/futarchy/integration/takeoverEndToEnd.test.ts b/tests/futarchy/integration/takeoverEndToEnd.test.ts new file mode 100644 index 00000000..5e4b69f3 --- /dev/null +++ b/tests/futarchy/integration/takeoverEndToEnd.test.ts @@ -0,0 +1,271 @@ +import { + getDaoAddr, + getSpendingLimitAddr, + PERMISSIONLESS_ACCOUNT, + PriceMath, +} from "@metadaoproject/programs"; +import { + ComputeBudgetProgram, + Keypair, + PublicKey, + Transaction, + TransactionMessage, + VersionedTransaction, +} from "@solana/web3.js"; +import BN from "bn.js"; +import { assert } from "chai"; +import * as multisig from "@sqds/multisig"; +import { createLookupTableForTransaction, passProposal } from "../../utils.js"; + +const THOUSAND_BUCK_PRICE = PriceMath.getAmmPrice(1000, 6, 6); + +// Declaration semantics through the whole chain: what the market approved is +// byte-for-byte what lands, on both the DAO record and the Squads projection — +// and the regime change cuts off the old members' pull rights. +export default function suite() { + it("rotates the regime: create with Set, stake, launch, pass, packed execute + sync", async function () { + const META = await this.createMint(this.payer.publicKey, 6); + const USDC = await this.createMint(this.payer.publicKey, 6); + + await this.createTokenAccount(META, this.payer.publicKey); + await this.createTokenAccount(USDC, this.payer.publicKey); + + await this.mintTo( + META, + this.payer.publicKey, + this.payer, + 2_000 * 1_000_000, + ); + await this.mintTo( + USDC, + this.payer.publicKey, + this.payer, + 500_000 * 1_000_000, + ); + + const oldMember = Keypair.generate(); + 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_000_000), // 10,000 USDC + members: [oldMember.publicKey], + }, + baseToStake: new BN(1_000 * 1_000_000), // 1,000 META + teamSponsoredPassThresholdBps: 300, + teamAddress: this.payer.publicKey, + }, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), + ]) + .rpc(); + + const [dao] = getDaoAddr({ nonce, daoCreator: this.payer.publicKey }); + + const storedDaoBefore = await this.futarchy.getDao(dao); + const vault = storedDaoBefore.squadsMultisigVault; + const multisigPda = storedDaoBefore.squadsMultisig; + + await this.futarchy + .provideLiquidityIx({ + dao, + baseMint: META, + quoteMint: USDC, + quoteAmount: new BN(100_000 * 1_000_000), // 100,000 USDC + maxBaseAmount: new BN(100 * 1_000_000), // 100 META + minLiquidity: new BN(0), + positionAuthority: this.payer.publicKey, + liquidityProvider: this.payer.publicKey, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), + ]) + .rpc(); + + // The old regime works: the old member pulls from the treasury through + // the Squads spending limit + await this.createTokenAccount(USDC, vault); + await this.mintTo(USDC, vault, this.payer, 1_000 * 1_000_000); + await this.createTokenAccount(USDC, oldMember.publicKey); + + const [spendingLimitPda] = getSpendingLimitAddr({ dao }); + + const pullIx = multisig.instructions.spendingLimitUse({ + multisigPda, + member: oldMember.publicKey, + spendingLimit: spendingLimitPda, + mint: USDC, + vaultIndex: 0, + amount: 100 * 1_000_000, + decimals: 6, + destination: oldMember.publicKey, + }); + const pullTx = new Transaction().add(pullIx); + pullTx.recentBlockhash = (await this.banksClient.getLatestBlockhash())[0]; + pullTx.feePayer = this.payer.publicKey; + pullTx.sign(this.payer, oldMember); + await this.banksClient.processTransaction(pullTx); + + assert.equal( + (await this.getTokenBalance(USDC, oldMember.publicKey)).toString(), + (100 * 1_000_000).toString(), + ); + + // The declared post-takeover regime: new team, new limit, new members + const newTeamAddress = Keypair.generate().publicKey; + const newMember = Keypair.generate(); + const declaredConfig = { + amountPerMonth: new BN(25_000_000_000), // 25,000 USDC + members: [newMember.publicKey], + }; + + const { proposal, squadsProposal, squadsTransaction } = + await this.futarchy.initializeHostileTakeoverProposal({ + dao, + newTeamAddress, + spendingLimitAction: { set: { 0: declaredConfig } }, + }); + + await this.futarchy + .stakeToProposalIx({ + proposal, + dao, + baseMint: META, + amount: new BN(1_000 * 1_000_000), + }) + .rpc(); + + await this.futarchy + .launchProposalIx({ + proposal, + dao, + baseMint: META, + quoteMint: USDC, + squadsProposal, + }) + .rpc(); + + // Runs out the takeover's 20-day snapshot and finalizes to Passed at +10% + await passProposal(this, { + dao, + proposal, + baseMint: META, + quoteMint: USDC, + cranks: 90, + }); + + // Packed execute + sync: the declared regime is live on Squads the moment + // the takeover lands, leaving no window where the outgoing members keep + // their allowance + const vaultTransaction = + await multisig.accounts.VaultTransaction.fromAccountAddress( + this.squadsConnection, + squadsTransaction, + ); + const packIxs = [ + ( + await multisig.instructions.vaultTransactionExecute({ + connection: this.squadsConnection, + multisigPda, + transactionIndex: BigInt(vaultTransaction.index.toString()), + member: PERMISSIONLESS_ACCOUNT.publicKey, + }) + ).instruction, + await this.futarchy.syncSpendingLimitIx({ dao }).instruction(), + ]; + + const lut = await createLookupTableForTransaction( + new Transaction().add(...packIxs), + this, + ); + + const packMessage = new TransactionMessage({ + payerKey: this.payer.publicKey, + recentBlockhash: (await this.banksClient.getLatestBlockhash())[0], + instructions: [ + ComputeBudgetProgram.setComputeUnitLimit({ units: 1_400_000 }), + ...packIxs, + ], + }).compileToV0Message([lut]); + const packTx = new VersionedTransaction(packMessage); + packTx.sign([this.payer, PERMISSIONLESS_ACCOUNT]); + await this.banksClient.processTransaction(packTx); + + // The team pointer moved + const storedDao = await this.futarchy.getDao(dao); + assert.ok(storedDao.teamAddress.equals(newTeamAddress)); + + // The DAO record matches the declaration, and the sync consumed the flag + assert.equal( + storedDao.initialSpendingLimit.amountPerMonth.toString(), + declaredConfig.amountPerMonth.toString(), + ); + assert.deepEqual( + storedDao.initialSpendingLimit.members.map((m) => m.toBase58()), + declaredConfig.members.map((m) => m.toBase58()), + ); + assert.isFalse(storedDao.spendingLimitDirty); + + // The Squads projection matches the declaration + const storedLimit = + await multisig.accounts.SpendingLimit.fromAccountAddress( + this.squadsConnection, + spendingLimitPda, + ); + assert.equal( + storedLimit.amount.toString(), + declaredConfig.amountPerMonth.toString(), + ); + assert.equal( + storedLimit.remainingAmount.toString(), + declaredConfig.amountPerMonth.toString(), + ); + assert.sameMembers( + storedLimit.members.map((m) => m.toBase58()), + declaredConfig.members.map((m) => m.toBase58()), + ); + + // The old member's pull rights are gone — the takeover's economic point + const oldPullIx = multisig.instructions.spendingLimitUse({ + multisigPda, + member: oldMember.publicKey, + spendingLimit: spendingLimitPda, + mint: USDC, + vaultIndex: 0, + amount: 200 * 1_000_000, + decimals: 6, + destination: oldMember.publicKey, + }); + const oldPullTx = new Transaction().add(oldPullIx); + oldPullTx.recentBlockhash = ( + await this.banksClient.getLatestBlockhash() + )[0]; + oldPullTx.feePayer = this.payer.publicKey; + oldPullTx.sign(this.payer, oldMember); + + try { + await this.banksClient.processTransaction(oldPullTx); + assert.fail("Should have failed with Unauthorized"); + } catch (e) { + // Squads' Unauthorized (0x1774 = 6004) + assert( + e.toString().includes("Unauthorized") || + e.toString().includes("0x1774"), + `Expected Unauthorized error, got: ${e}`, + ); + } + }); +} diff --git a/tests/futarchy/main.test.ts b/tests/futarchy/main.test.ts index e800e372..4d87affe 100644 --- a/tests/futarchy/main.test.ts +++ b/tests/futarchy/main.test.ts @@ -1,27 +1,39 @@ import futarchyAmm from "./integration/futarchyAmm.test.js"; +import takeoverEndToEnd from "./integration/takeoverEndToEnd.test.js"; +import liquidationEndToEnd from "./integration/liquidationEndToEnd.test.js"; +import largeSpendEndToEnd from "./integration/largeSpendEndToEnd.test.js"; +import cooldownRoundTrip from "./integration/cooldownRoundTrip.test.js"; import initializeDao from "./unit/initializeDao.test.js"; import initializeProposal from "./unit/initializeProposal.test.js"; +import initializeLargeSpendProposal from "./unit/initializeLargeSpendProposal.test.js"; +import initializeMintTokensProposal from "./unit/initializeMintTokensProposal.test.js"; +import initializeSpendingLimitChangeProposal from "./unit/initializeSpendingLimitChangeProposal.test.js"; +import initializeHostileTakeoverProposal from "./unit/initializeHostileTakeoverProposal.test.js"; +import initializeHostileLiquidateProposal from "./unit/initializeHostileLiquidateProposal.test.js"; import launchProposal from "./unit/launchProposal.test.js"; import finalizeProposal from "./unit/finalizeProposal.test.js"; import updateDao from "./unit/updateDao.test.js"; +import setSpendingLimit from "./unit/setSpendingLimit.test.js"; +import syncSpendingLimit from "./unit/syncSpendingLimit.test.js"; +import applyLiquidation from "./unit/applyLiquidation.test.js"; +import liquidatorPath from "./unit/liquidatorPath.test.js"; +import liquidatedGuards from "./unit/liquidatedGuards.test.js"; import collectFees from "./unit/collectFees.test.js"; import conditionalSwap from "./unit/conditionalSwap.test.js"; import provideLiquidity from "./unit/provideLiquidity.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 adminEnqueueMultisigProposalApproval from "./unit/adminEnqueueMultisigProposalApproval.test.js"; import executeMultisigProposalApproval from "./unit/executeMultisigProposalApproval.test.js"; import adminExecuteMultisigProposal from "./unit/adminExecuteMultisigProposal.test.js"; import adminCancelProposal from "./unit/adminCancelProposal.test.js"; import adminRemoveProposal from "./unit/adminRemoveProposal.test.js"; import unstakeFromProposal from "./unit/unstakeFromProposal.test.js"; +import resizeDao from "./unit/resizeDao.test.js"; +import resizeProposal from "./unit/resizeProposal.test.js"; import { PublicKey } from "@solana/web3.js"; import { @@ -58,22 +70,35 @@ export default function suite() { }); describe("#initialize_dao", initializeDao); describe("#initialize_proposal", initializeProposal); + describe("#initialize_large_spend_proposal", initializeLargeSpendProposal); + describe("#initialize_mint_tokens_proposal", initializeMintTokensProposal); + describe( + "#initialize_spending_limit_change_proposal", + initializeSpendingLimitChangeProposal, + ); + describe( + "#initialize_hostile_takeover_proposal", + initializeHostileTakeoverProposal, + ); + describe( + "#initialize_hostile_liquidate_proposal", + initializeHostileLiquidateProposal, + ); describe("#launch_proposal", launchProposal); describe("#finalize_proposal", finalizeProposal); describe("#update_dao", updateDao); + describe("#set_spending_limit", setSpendingLimit); + describe("#sync_spending_limit", syncSpendingLimit); + describe("#apply_liquidation", applyLiquidation); + describe("liquidator path", liquidatorPath); + describe("liquidated guards", liquidatedGuards); describe("#collect_fees", collectFees); describe("#conditional_swap", conditionalSwap); describe("#provide_liquidity", provideLiquidity); - describe("#execute_spending_limit_change", executeSpendingLimitChange); describe("#collect_meteora_damm_fees", collectMeteoraDammFees); - describe( - "#initiate_vault_spend_optimistic_proposal", - initiateVaultSpendOptimisticProposal, - ); - describe("#finalize_optimistic_proposal", finalizeOptimisticProposal); describe( "#admin_enqueue_multisig_proposal_approval", adminEnqueueMultisigProposalApproval, @@ -86,7 +111,13 @@ export default function suite() { describe("#admin_cancel_proposal", adminCancelProposal); describe("#admin_remove_proposal", adminRemoveProposal); describe("#unstake_from_proposal", unstakeFromProposal); + describe("#resize_dao", resizeDao); + describe("#resize_proposal", resizeProposal); // describe("full proposal", fullProposal); // describe("proposal with a squads batch tx", proposalBatchTx); describe("futarchy amm", futarchyAmm); + describe("integration: takeover end to end", takeoverEndToEnd); + describe("integration: liquidation end to end", liquidationEndToEnd); + describe("integration: large spend end to end", largeSpendEndToEnd); + describe("integration: cooldown round-trip", cooldownRoundTrip); } diff --git a/tests/futarchy/unit/adminCancelProposal.test.ts b/tests/futarchy/unit/adminCancelProposal.test.ts index cea66318..95a6b3f3 100644 --- a/tests/futarchy/unit/adminCancelProposal.test.ts +++ b/tests/futarchy/unit/adminCancelProposal.test.ts @@ -6,6 +6,7 @@ import { } from "@metadaoproject/programs"; import { ComputeBudgetProgram, + Keypair, PublicKey, Transaction, TransactionMessage, @@ -128,9 +129,10 @@ export default function suite() { .rpc(); }); - it("should cancel a pending proposal", async function () { + it("should cancel a blockable proposal mid-market", async function () { let storedProposal = await this.futarchy.getProposal(proposal); assert.exists(storedProposal.state.pending); + assert.isTrue(storedProposal.councilCanBlock); const storedDao = await this.futarchy.getDao(dao); const seqNumBefore = storedDao.seqNum.toNumber(); @@ -332,4 +334,146 @@ export default function suite() { .rpc() .then(callbacks[0], callbacks[1]); }); + + it("should not cancel a live hostile proposal", async function () { + // Fresh DAO — the suite DAO already has a live blockable proposal + const BASE = await this.createMint(this.payer.publicKey, 6); + const QUOTE = await this.createMint(this.payer.publicKey, 6); + + await this.createTokenAccount(BASE, this.payer.publicKey); + await this.createTokenAccount(QUOTE, this.payer.publicKey); + + await this.mintTo(BASE, this.payer.publicKey, this.payer, 100 * 10 ** 9); + await this.mintTo( + QUOTE, + this.payer.publicKey, + this.payer, + 200_000 * 1_000_000, + ); + + const hostileDao = await setupBasicDao({ + context: this, + baseMint: BASE, + quoteMint: QUOTE, + }); + + await this.futarchy + .provideLiquidityIx({ + dao: hostileDao, + baseMint: BASE, + quoteMint: QUOTE, + quoteAmount: new BN(100_000 * 10 ** 6), + maxBaseAmount: new BN(100 * 10 ** 6), + minLiquidity: new BN(0), + positionAuthority: this.payer.publicKey, + liquidityProvider: this.payer.publicKey, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), + ]) + .rpc(); + + const { proposal: hostileProposal, squadsProposal } = + await this.futarchy.initializeHostileTakeoverProposal({ + dao: hostileDao, + newTeamAddress: Keypair.generate().publicKey, + spendingLimitAction: { keep: {} }, + }); + + await this.futarchy + .launchProposalIx({ + proposal: hostileProposal, + dao: hostileDao, + baseMint: BASE, + quoteMint: QUOTE, + squadsProposal, + }) + .rpc(); + + let storedProposal = await this.futarchy.getProposal(hostileProposal); + assert.exists(storedProposal.state.pending); + assert.isFalse(storedProposal.councilCanBlock); + + const { + question, + baseVault, + quoteVault, + passBaseMint, + passQuoteMint, + failBaseMint, + failQuoteMint, + } = this.futarchy.getProposalPdas(hostileProposal, BASE, QUOTE, hostileDao); + + const multisigPda = multisig.getMultisigPda({ createKey: hostileDao })[0]; + const [vaultEventAuthority] = getEventAuthorityAddr( + CONDITIONAL_VAULT_V0_4_PROGRAM_ID, + ); + + const callbacks = expectError( + "InvalidProposalKind", + "cancelled a live hostile proposal", + ); + + await this.futarchy.futarchy.methods + .adminCancelProposal() + .accounts({ + proposal: hostileProposal, + dao: hostileDao, + question, + squadsProposal, + squadsMultisig: multisigPda, + squadsMultisigProgram: SQUADS_PROGRAM_ID, + admin: this.payer.publicKey, + ammPassBaseVault: getAssociatedTokenAddressSync( + passBaseMint, + hostileDao, + true, + ), + ammPassQuoteVault: getAssociatedTokenAddressSync( + passQuoteMint, + hostileDao, + true, + ), + ammFailBaseVault: getAssociatedTokenAddressSync( + failBaseMint, + hostileDao, + true, + ), + ammFailQuoteVault: getAssociatedTokenAddressSync( + failQuoteMint, + hostileDao, + true, + ), + ammBaseVault: getAssociatedTokenAddressSync(BASE, hostileDao, true), + ammQuoteVault: getAssociatedTokenAddressSync(QUOTE, hostileDao, true), + vaultProgram: CONDITIONAL_VAULT_V0_4_PROGRAM_ID, + vaultEventAuthority, + quoteVault, + quoteVaultUnderlyingTokenAccount: getAssociatedTokenAddressSync( + QUOTE, + quoteVault, + true, + ), + passQuoteMint, + failQuoteMint, + passBaseMint, + failBaseMint, + baseVault, + baseVaultUnderlyingTokenAccount: getAssociatedTokenAddressSync( + BASE, + baseVault, + true, + ), + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), + ]) + .signers([this.payer]) + .rpc() + .then(callbacks[0], callbacks[1]); + + // The live hostile market is untouched + storedProposal = await this.futarchy.getProposal(hostileProposal); + assert.exists(storedProposal.state.pending); + }); } diff --git a/tests/futarchy/unit/adminEnqueueMultisigProposalApproval.test.ts b/tests/futarchy/unit/adminEnqueueMultisigProposalApproval.test.ts index 2bcdb77a..e210cea1 100644 --- a/tests/futarchy/unit/adminEnqueueMultisigProposalApproval.test.ts +++ b/tests/futarchy/unit/adminEnqueueMultisigProposalApproval.test.ts @@ -23,12 +23,17 @@ export default function suite() { await this.createTokenAccount(META, this.payer.publicKey); await this.createTokenAccount(USDC, this.payer.publicKey); - await this.mintTo(META, this.payer.publicKey, this.payer, 100 * 10 ** 9); + // 200/200k tokens (not 100/100k): setupBasicDaoWithLiquidity mints the + // same 10^11 atoms to the same ATAs, and identical amounts would make + // these mintTo transactions byte-identical to the helper's, failing with + // "This transaction has already been processed" when they share a + // blockhash tick + await this.mintTo(META, this.payer.publicKey, this.payer, 200 * 10 ** 9); await this.mintTo( USDC, this.payer.publicKey, this.payer, - 100_000 * 1_000_000, + 200_000 * 1_000_000, ); dao = await this.setupBasicDaoWithLiquidity({ diff --git a/tests/futarchy/unit/applyLiquidation.test.ts b/tests/futarchy/unit/applyLiquidation.test.ts new file mode 100644 index 00000000..0a2f993e --- /dev/null +++ b/tests/futarchy/unit/applyLiquidation.test.ts @@ -0,0 +1,644 @@ +import { + ComputeBudgetProgram, + Keypair, + PublicKey, + Transaction, + TransactionMessage, + VersionedTransaction, +} from "@solana/web3.js"; +import { assert } from "chai"; +import * as multisig from "@sqds/multisig"; +import { MEMO_PROGRAM_ID } from "@solana/spl-memo"; +import { getAssociatedTokenAddressSync } from "@solana/spl-token"; +import { + FUTARCHY_V0_6_PROGRAM_ID, + getDaoAddr, + getEventAuthorityAddr, + getProposalAddrsForTransactionIndex, + getSpendingLimitAddr, + PERMISSIONLESS_ACCOUNT, + PriceMath, +} from "@metadaoproject/programs"; +import BN from "bn.js"; +import { + createLookupTableForTransaction, + executeVaultTransaction, + passProposal, +} from "../../utils.js"; +import { TestContext } from "../../main.test.js"; + +const THOUSAND_BUCK_PRICE = PriceMath.getAmmPrice(1000, 6, 6); + +// The treasury's own LP position: the Squads vault is the position authority +async function provideTreasuryLiquidity( + context: TestContext, + { + dao, + vault, + baseMint, + quoteMint, + }: { + dao: PublicKey; + vault: PublicKey; + baseMint: PublicKey; + quoteMint: PublicKey; + }, +) { + await context.futarchy + .provideLiquidityIx({ + dao, + baseMint, + quoteMint, + quoteAmount: new BN(25_000 * 1_000_000), // 25,000 USDC + maxBaseAmount: new BN(25 * 1_000_000), // 25 META + minLiquidity: new BN(1), + positionAuthority: vault, + liquidityProvider: context.payer.publicKey, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), + ]) + .rpc(); +} + +export default function suite() { + let META: PublicKey, + USDC: PublicKey, + dao: PublicKey, + vault: PublicKey, + ammPosition: PublicKey; + + beforeEach(async function () { + META = await this.createMint(this.payer.publicKey, 6); + USDC = await this.createMint(this.payer.publicKey, 6); + + await this.createTokenAccount(META, this.payer.publicKey); + await this.createTokenAccount(USDC, this.payer.publicKey); + + await this.mintTo( + META, + this.payer.publicKey, + this.payer, + 1_000 * 1_000_000, + ); + await this.mintTo( + USDC, + this.payer.publicKey, + this.payer, + 500_000 * 1_000_000, + ); + + 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, + // 10% per update: TWAPs converge to actual prices fast enough that + // a pumped pass market clears +25% even on a repeat run, where the + // fail market starts at an already-appreciated spot price + twapMaxObservationChangePerUpdate: THOUSAND_BUCK_PRICE.divn(10), + minQuoteFutarchicLiquidity: new BN(10_000), + minBaseFutarchicLiquidity: new BN(10_000), + passThresholdBps: 300, + nonce, + initialSpendingLimit: { + amountPerMonth: new BN(10_000_000_000), // 10,000 USDC + members: [this.payer.publicKey], + }, + baseToStake: new BN(0), + teamSponsoredPassThresholdBps: 300, + teamAddress: this.payer.publicKey, + }, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), + ]) + .rpc(); + + [dao] = getDaoAddr({ nonce, daoCreator: this.payer.publicKey }); + + const storedDao = await this.futarchy.getDao(dao); + vault = storedDao.squadsMultisigVault; + + [ammPosition] = PublicKey.findProgramAddressSync( + [Buffer.from("amm_position"), dao.toBuffer(), vault.toBuffer()], + FUTARCHY_V0_6_PROGRAM_ID, + ); + + // The sweep destination: the vault's ATAs + await this.createTokenAccount(META, vault); + await this.createTokenAccount(USDC, vault); + + await this.futarchy + .provideLiquidityIx({ + dao, + baseMint: META, + quoteMint: USDC, + quoteAmount: new BN(100_000 * 1_000_000), // 100,000 USDC + maxBaseAmount: new BN(100 * 1_000_000), // 100 META + minLiquidity: new BN(0), + positionAuthority: this.payer.publicKey, + liquidityProvider: this.payer.publicKey, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), + ]) + .rpc(); + }); + + it("installs the liquidator, zeroes the record, and sweeps the treasury position", async function () { + await provideTreasuryLiquidity(this, { + dao, + vault, + baseMint: META, + quoteMint: USDC, + }); + + const liquidator = Keypair.generate().publicKey; + const { proposal, squadsProposal, squadsTransaction } = + await this.futarchy.initializeHostileLiquidateProposal({ + dao, + liquidator, + }); + + await this.futarchy + .launchProposalIx({ + proposal, + dao, + baseMint: META, + quoteMint: USDC, + squadsProposal, + }) + .rpc(); + + await passProposal(this, { + dao, + proposal, + baseMint: META, + quoteMint: USDC, + cranks: 50, + }); + + // The expected sweep, computed from the live pre-execution reserves + const preDao = await this.futarchy.getDao(dao); + const preSpot = preDao.amm.state.spot.spot; + const prePosition = + await this.futarchy.futarchy.account.ammPosition.fetch(ammPosition); + const expectedBase = prePosition.liquidity + .mul(preSpot.baseReserves) + .div(preDao.amm.totalLiquidity); + const expectedQuote = prePosition.liquidity + .mul(preSpot.quoteReserves) + .div(preDao.amm.totalLiquidity); + const preVaultBase = await this.getTokenBalance(META, vault); + const preVaultQuote = await this.getTokenBalance(USDC, vault); + + // Executing the baked payload is the byte-level proof that the baked + // instruction matches the deployed apply_liquidation + await executeVaultTransaction(this, dao, squadsTransaction); + + const storedDao = await this.futarchy.getDao(dao); + assert.ok(storedDao.liquidator.equals(liquidator)); + assert.isNull(storedDao.initialSpendingLimit); + assert.isTrue(storedDao.spendingLimitDirty); + + const postPosition = + await this.futarchy.futarchy.account.ammPosition.fetch(ammPosition); + assert.equal(postPosition.liquidity.toString(), "0"); + + const postVaultBase = await this.getTokenBalance(META, vault); + const postVaultQuote = await this.getTokenBalance(USDC, vault); + assert.equal( + (postVaultBase - preVaultBase).toString(), + expectedBase.toString(), + ); + assert.equal( + (postVaultQuote - preVaultQuote).toString(), + expectedQuote.toString(), + ); + + const postSpot = storedDao.amm.state.spot.spot; + assert.equal( + postSpot.baseReserves.toString(), + preSpot.baseReserves.sub(expectedBase).toString(), + ); + assert.equal( + postSpot.quoteReserves.toString(), + preSpot.quoteReserves.sub(expectedQuote).toString(), + ); + assert.equal( + storedDao.amm.totalLiquidity.toString(), + preDao.amm.totalLiquidity.sub(prePosition.liquidity).toString(), + ); + }); + + it("refuses an execute_arbitrary proposal whose payload calls apply_liquidation", async function () { + // An arbitrary proposal carrying apply_liquidation would reach + // liquidation at ExecuteArbitrary's terms (10 days, +10%, blockable); + // the kind check is what closes that hole + const { squadsProposal, squadsTransaction, proposal } = + getProposalAddrsForTransactionIndex({ dao, transactionIndex: 1n }); + + const [eventAuthority] = getEventAuthorityAddr(FUTARCHY_V0_6_PROGRAM_ID); + const applyLiquidationIx = await this.futarchy.futarchy.methods + .applyLiquidation() + .accounts({ + proposal, + dao, + squadsMultisigVault: vault, + ammPosition, + ammBaseVault: getAssociatedTokenAddressSync(META, dao, true), + ammQuoteVault: getAssociatedTokenAddressSync(USDC, dao, true), + vaultBaseAccount: getAssociatedTokenAddressSync(META, vault, true), + vaultQuoteAccount: getAssociatedTokenAddressSync(USDC, vault, true), + eventAuthority, + program: FUTARCHY_V0_6_PROGRAM_ID, + }) + .instruction(); + + const { tx: createTx } = this.futarchy.squadsProposalCreateTx({ + dao, + instructions: [applyLiquidationIx], + transactionIndex: 1n, + }); + createTx.recentBlockhash = (await this.banksClient.getLatestBlockhash())[0]; + createTx.feePayer = this.payer.publicKey; + createTx.sign(this.payer, PERMISSIONLESS_ACCOUNT); + await this.banksClient.processTransaction(createTx); + + await this.futarchy.initializeProposal(dao, squadsProposal); + + await this.futarchy + .launchProposalIx({ + proposal, + dao, + baseMint: META, + quoteMint: USDC, + squadsProposal, + }) + .rpc(); + + await passProposal(this, { + dao, + proposal, + baseMint: META, + quoteMint: USDC, + cranks: 50, + }); + + try { + await executeVaultTransaction(this, dao, squadsTransaction); + assert.fail("Should have failed with InvalidProposalKind"); + } catch (e) { + // The error surfaces through the Squads CPI: InvalidProposalKind (0x17a2 = 6050) + assert( + e.toString().includes("InvalidProposalKind") || + e.toString().includes("0x17a2"), + `Expected InvalidProposalKind error, got: ${e}`, + ); + } + + const storedDao = await this.futarchy.getDao(dao); + assert.isNull(storedDao.liquidator); + }); + + it("refuses a second passed liquidation after the first has executed", async function () { + await provideTreasuryLiquidity(this, { + dao, + vault, + baseMint: META, + quoteMint: USDC, + }); + + const liquidatorA = Keypair.generate().publicKey; + const liquidatorB = Keypair.generate().publicKey; + + const a = await this.futarchy.initializeHostileLiquidateProposal({ + dao, + liquidator: liquidatorA, + }); + const b = await this.futarchy.initializeHostileLiquidateProposal({ + dao, + liquidator: liquidatorB, + }); + + // Both markets run to Passed before either payload executes — possible + // because the DAO only becomes liquidated at execution + await this.futarchy + .launchProposalIx({ + proposal: a.proposal, + dao, + baseMint: META, + quoteMint: USDC, + squadsProposal: a.squadsProposal, + }) + .rpc(); + await passProposal(this, { + dao, + proposal: a.proposal, + baseMint: META, + quoteMint: USDC, + cranks: 50, + }); + + await this.futarchy + .launchProposalIx({ + proposal: b.proposal, + dao, + baseMint: META, + quoteMint: USDC, + squadsProposal: b.squadsProposal, + }) + .rpc(); + await passProposal(this, { + dao, + proposal: b.proposal, + baseMint: META, + quoteMint: USDC, + cranks: 50, + }); + + await executeVaultTransaction(this, dao, a.squadsTransaction); + + let storedDao = await this.futarchy.getDao(dao); + assert.ok(storedDao.liquidator.equals(liquidatorA)); + + try { + await executeVaultTransaction(this, dao, b.squadsTransaction); + assert.fail("Should have failed with AlreadyLiquidated"); + } catch (e) { + // The error surfaces through the Squads CPI: AlreadyLiquidated (0x17a3 = 6051) + assert( + e.toString().includes("AlreadyLiquidated") || + e.toString().includes("0x17a3"), + `Expected AlreadyLiquidated error, got: ${e}`, + ); + } + + // The first liquidator is not overwritten + storedDao = await this.futarchy.getDao(dao); + assert.ok(storedDao.liquidator.equals(liquidatorA)); + }); + + it("succeeds when the treasury position doesn't exist", async function () { + const liquidator = Keypair.generate().publicKey; + const { proposal, squadsProposal, squadsTransaction } = + await this.futarchy.initializeHostileLiquidateProposal({ + dao, + liquidator, + }); + + await this.futarchy + .launchProposalIx({ + proposal, + dao, + baseMint: META, + quoteMint: USDC, + squadsProposal, + }) + .rpc(); + + await passProposal(this, { + dao, + proposal, + baseMint: META, + quoteMint: USDC, + cranks: 50, + }); + + const preDao = await this.futarchy.getDao(dao); + const preSpot = preDao.amm.state.spot.spot; + + await executeVaultTransaction(this, dao, squadsTransaction); + + const storedDao = await this.futarchy.getDao(dao); + assert.ok(storedDao.liquidator.equals(liquidator)); + assert.isNull(storedDao.initialSpendingLimit); + assert.isTrue(storedDao.spendingLimitDirty); + + // Nothing to sweep, nothing swept + assert.equal((await this.getTokenBalance(META, vault)).toString(), "0"); + assert.equal((await this.getTokenBalance(USDC, vault)).toString(), "0"); + const postSpot = storedDao.amm.state.spot.spot; + assert.equal( + postSpot.baseReserves.toString(), + preSpot.baseReserves.toString(), + ); + assert.equal( + postSpot.quoteReserves.toString(), + preSpot.quoteReserves.toString(), + ); + assert.equal( + storedDao.amm.totalLiquidity.toString(), + preDao.amm.totalLiquidity.toString(), + ); + }); + + it("succeeds when the treasury position exists with zero liquidity", async function () { + const liquidator = Keypair.generate().publicKey; + const { proposal, squadsProposal, squadsTransaction } = + await this.futarchy.initializeHostileLiquidateProposal({ + dao, + liquidator, + }); + + await this.futarchy + .launchProposalIx({ + proposal, + dao, + baseMint: META, + quoteMint: USDC, + squadsProposal, + }) + .rpc(); + + await passProposal(this, { + dao, + proposal, + baseMint: META, + quoteMint: USDC, + cranks: 50, + }); + + // Manufacture an existing-but-empty position at the treasury's PDA + const positionData = await this.futarchy.futarchy.coder.accounts.encode( + "ammPosition", + { + dao, + positionAuthority: vault, + liquidity: new BN(0), + }, + ); + this.context.setAccount(ammPosition, { + lamports: 10_000_000, + data: positionData, + owner: FUTARCHY_V0_6_PROGRAM_ID, + executable: false, + }); + + await executeVaultTransaction(this, dao, squadsTransaction); + + const storedDao = await this.futarchy.getDao(dao); + assert.ok(storedDao.liquidator.equals(liquidator)); + assert.equal((await this.getTokenBalance(META, vault)).toString(), "0"); + assert.equal((await this.getTokenBalance(USDC, vault)).toString(), "0"); + }); + + it("reverts mid-market and lands with the packed finalize + execute + sync once that market finalizes", async function () { + await provideTreasuryLiquidity(this, { + dao, + vault, + baseMint: META, + quoteMint: USDC, + }); + + const liquidator = Keypair.generate().publicKey; + const { proposal, squadsProposal, squadsTransaction } = + await this.futarchy.initializeHostileLiquidateProposal({ + dao, + liquidator, + }); + + await this.futarchy + .launchProposalIx({ + proposal, + dao, + baseMint: META, + quoteMint: USDC, + squadsProposal, + }) + .rpc(); + + await passProposal(this, { + dao, + proposal, + baseMint: META, + quoteMint: USDC, + cranks: 50, + }); + + // A proposal launched in the finalize→execute gap puts the pool + // mid-market before anyone executes the liquidation payload + const { tx: gapCreateTx, squadsProposal: gapSquadsProposal } = + this.futarchy.squadsProposalCreateTx({ + dao, + instructions: [ + { + programId: MEMO_PROGRAM_ID, + keys: [], + data: Buffer.from("gap proposal"), + }, + ], + transactionIndex: 2n, + }); + gapCreateTx.recentBlockhash = ( + await this.banksClient.getLatestBlockhash() + )[0]; + gapCreateTx.feePayer = this.payer.publicKey; + gapCreateTx.sign(this.payer, PERMISSIONLESS_ACCOUNT); + await this.banksClient.processTransaction(gapCreateTx); + + const gapProposal = await this.futarchy.initializeProposal( + dao, + gapSquadsProposal, + ); + await this.futarchy + .launchProposalIx({ + proposal: gapProposal, + dao, + baseMint: META, + quoteMint: USDC, + squadsProposal: gapSquadsProposal, + }) + .rpc(); + + try { + await executeVaultTransaction(this, dao, squadsTransaction); + assert.fail("Should have failed with PoolNotInSpotState"); + } catch (e) { + // The error surfaces through the Squads CPI: PoolNotInSpotState (0x178a = 6026) + assert( + e.toString().includes("PoolNotInSpotState") || + e.toString().includes("0x178a"), + `Expected PoolNotInSpotState error, got: ${e}`, + ); + } + + // Nothing was lost: the approved Squads transaction stays retryable + let storedDao = await this.futarchy.getDao(dao); + assert.isNull(storedDao.liquidator); + + // Run out the gap market uncontested (one observation after the TWAP + // start delay lets it finalize) + await this.advanceBySeconds(60 * 60 * 24 + 60); + await this.futarchy + .spotSwapIx({ + dao, + baseMint: META, + quoteMint: USDC, + swapType: "buy", + inputAmount: new BN(1_000), + }) + .rpc(); + await this.advanceBySeconds(864_000); + + // The same payload lands as one transaction: the gap market's + // finalize_proposal + vault_transaction_execute + sync_spending_limit. + const packIxs = [ + await this.futarchy + .finalizeProposalIxV2({ + squadsProposal: gapSquadsProposal, + dao, + baseMint: META, + quoteMint: USDC, + }) + .instruction(), + ( + await multisig.instructions.vaultTransactionExecute({ + connection: this.squadsConnection, + multisigPda: multisig.getMultisigPda({ createKey: dao })[0], + transactionIndex: 1n, + member: PERMISSIONLESS_ACCOUNT.publicKey, + }) + ).instruction, + await this.futarchy.syncSpendingLimitIx({ dao }).instruction(), + ]; + + const lut = await createLookupTableForTransaction( + new Transaction().add(...packIxs), + this, + ); + + const packMessage = new TransactionMessage({ + payerKey: this.payer.publicKey, + recentBlockhash: (await this.banksClient.getLatestBlockhash())[0], + instructions: [ + ComputeBudgetProgram.setComputeUnitLimit({ units: 1_400_000 }), + ...packIxs, + ], + }).compileToV0Message([lut]); + const packTx = new VersionedTransaction(packMessage); + packTx.sign([this.payer, PERMISSIONLESS_ACCOUNT]); + await this.banksClient.processTransaction(packTx); + + const storedGap = await this.futarchy.getProposal(gapProposal); + assert.exists(storedGap.state.failed); + + storedDao = await this.futarchy.getDao(dao); + assert.ok(storedDao.liquidator.equals(liquidator)); + assert.isNull(storedDao.initialSpendingLimit); + // The packed sync already projected the removal onto Squads + assert.isFalse(storedDao.spendingLimitDirty); + const [spendingLimit] = getSpendingLimitAddr({ dao }); + assert.isNull(await this.banksClient.getAccount(spendingLimit)); + + const postPosition = + await this.futarchy.futarchy.account.ammPosition.fetch(ammPosition); + assert.equal(postPosition.liquidity.toString(), "0"); + }); +} diff --git a/tests/futarchy/unit/collectFees.test.ts b/tests/futarchy/unit/collectFees.test.ts index c893e6e7..de381c8f 100644 --- a/tests/futarchy/unit/collectFees.test.ts +++ b/tests/futarchy/unit/collectFees.test.ts @@ -197,7 +197,7 @@ export default function suite() { // here we literally swap 1 atom of META, which should result in one atom of META fee because we round up - this.advanceBySeconds(60 * 60 * 24 * 4); + this.advanceBySeconds(60 * 60 * 24 * 11); await this.futarchy .conditionalSwapIx({ diff --git a/tests/futarchy/unit/conditionalSwap.test.ts b/tests/futarchy/unit/conditionalSwap.test.ts index f83f11fb..641093e6 100644 --- a/tests/futarchy/unit/conditionalSwap.test.ts +++ b/tests/futarchy/unit/conditionalSwap.test.ts @@ -244,7 +244,7 @@ export default function suite() { .splitTokensIx(question, baseVault, META, new BN(5 * 10 ** 6), 2) .rpc(); - this.advanceBySeconds(60 * 60 * 24 * 3); + this.advanceBySeconds(60 * 60 * 24 * 11); await this.futarchy .spotSwapIx({ diff --git a/tests/futarchy/unit/executeMultisigProposalApproval.test.ts b/tests/futarchy/unit/executeMultisigProposalApproval.test.ts index 233118d1..407e28e0 100644 --- a/tests/futarchy/unit/executeMultisigProposalApproval.test.ts +++ b/tests/futarchy/unit/executeMultisigProposalApproval.test.ts @@ -23,12 +23,17 @@ export default function suite() { await this.createTokenAccount(META, this.payer.publicKey); await this.createTokenAccount(USDC, this.payer.publicKey); - await this.mintTo(META, this.payer.publicKey, this.payer, 100 * 10 ** 9); + // 200/200k tokens (not 100/100k): setupBasicDaoWithLiquidity mints the + // same 10^11 atoms to the same ATAs, and identical amounts would make + // these mintTo transactions byte-identical to the helper's, failing with + // "This transaction has already been processed" when they share a + // blockhash tick + await this.mintTo(META, this.payer.publicKey, this.payer, 200 * 10 ** 9); await this.mintTo( USDC, this.payer.publicKey, this.payer, - 100_000 * 1_000_000, + 200_000 * 1_000_000, ); dao = await this.setupBasicDaoWithLiquidity({ diff --git a/tests/futarchy/unit/executeSpendingLimitChange.test.ts b/tests/futarchy/unit/executeSpendingLimitChange.test.ts deleted file mode 100644 index da68fc85..00000000 --- a/tests/futarchy/unit/executeSpendingLimitChange.test.ts +++ /dev/null @@ -1,606 +0,0 @@ -import { PERMISSIONLESS_ACCOUNT, PriceMath } from "@metadaoproject/programs"; -import { - ComputeBudgetProgram, - PublicKey, - Transaction, - TransactionMessage, -} from "@solana/web3.js"; -import { - createAssociatedTokenAccountIdempotentInstruction, - getAssociatedTokenAddressSync, -} from "@solana/spl-token"; -import BN from "bn.js"; -import { expectError, setupBasicDao } from "../../utils.js"; -import { assert } from "chai"; -import * as multisig from "@sqds/multisig"; -import { MEMO_PROGRAM_ID } from "@solana/spl-memo"; -const { Permissions, Permission } = multisig.types; - -// TODO: abstract away these tests to make code cleaner - -export default function suite() { - let META: PublicKey, - USDC: PublicKey, - dao: PublicKey, - proposal: PublicKey, - multisigPda: PublicKey, - squadsProposal: PublicKey; - - beforeEach(async function () { - META = await this.createMint(this.payer.publicKey, 6); - USDC = await this.createMint(this.payer.publicKey, 6); - - await this.createTokenAccount(META, this.payer.publicKey); - await this.createTokenAccount(USDC, this.payer.publicKey); - - await this.mintTo(META, this.payer.publicKey, this.payer, 100 * 10 ** 9); - await this.mintTo( - USDC, - this.payer.publicKey, - this.payer, - 200_000 * 1_000_000, - ); - - dao = await this.setupBasicDaoWithLiquidity({ - baseMint: META, - quoteMint: USDC, - }); - - multisigPda = multisig.getMultisigPda({ createKey: dao })[0]; - }); - - it("executes spending limit change proposal", async function () { - const newSpendingLimitPda = multisig.getSpendingLimitPda({ - multisigPda, - createKey: dao, - })[0]; - - const addSpendingLimitIx = multisig.instructions.multisigAddSpendingLimit({ - multisigPda, - spendingLimit: newSpendingLimitPda, - configAuthority: dao, - rentPayer: this.payer.publicKey, - createKey: dao, - vaultIndex: 0, - mint: USDC, - amount: BigInt(50_000 * 10 ** 6), // 50,000 USDC - period: multisig.types.Period.Month, - members: [this.payer.publicKey], // Only the DAO can use this spending limit - destinations: [], // No specific destinations - memo: "", - }); - - const proposalResult = await this.initializeAndLaunchProposal({ - dao, - instructions: [addSpendingLimitIx], - }); - - proposal = proposalResult.proposal; - squadsProposal = proposalResult.squadsProposal; - - const { question, quoteVault, passBaseMint } = - this.futarchy.getProposalPdas(proposal, META, USDC, dao); - - await this.conditionalVault - .splitTokensIx(question, quoteVault, 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: USDC, - proposal, - market: "pass", - swapType: "buy", - inputAmount: new BN(10_000 * 1_000_000), - minOutputAmount: new BN(0), - }) - .preInstructions([ - createAssociatedTokenAccountIdempotentInstruction( - this.payer.publicKey, - getAssociatedTokenAddressSync( - passBaseMint, - this.payer.publicKey, - true, - ), - this.payer.publicKey, - passBaseMint, - ), - ]) - .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: 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] = multisig.getTransactionPda({ - multisigPda: multisigPda, - index: 1n, - }); - - const transactionAccount = - await multisig.accounts.VaultTransaction.fromAccountAddress( - this.squadsConnection, - vaultTransactionPda, - ); - - const [vaultPda] = multisig.getVaultPda({ - multisigPda, - index: transactionAccount.vaultIndex, - programId: multisig.PROGRAM_ID, - }); - - const { accountMetas } = await multisig.utils.accountsForTransactionExecute( - { - connection: this.squadsConnection, - message: transactionAccount.message, - ephemeralSignerBumps: [...transactionAccount.ephemeralSignerBumps], - vaultPda, - transactionPda: vaultTransactionPda, - programId: multisig.PROGRAM_ID, - }, - ); - - await this.futarchy.futarchy.methods - .executeSpendingLimitChange() - .accounts({ - squadsMultisig: multisigPda, - proposal, - dao, - squadsProposal, - squadsMultisigProgram: multisig.PROGRAM_ID, - vaultTransaction: vaultTransactionPda, - }) - .remainingAccounts( - accountMetas.map((meta) => - meta.pubkey.equals(dao) ? { ...meta, isSigner: false } : meta, - ), - ) - .rpc(); - }); - - it("throws if the DAO has an active optimistic proposal", async function () { - const newSpendingLimitPda = multisig.getSpendingLimitPda({ - multisigPda, - createKey: dao, - })[0]; - - const addSpendingLimitIx = multisig.instructions.multisigAddSpendingLimit({ - multisigPda, - spendingLimit: newSpendingLimitPda, - configAuthority: dao, - rentPayer: this.payer.publicKey, - createKey: dao, - vaultIndex: 0, - mint: USDC, - amount: BigInt(50_000 * 10 ** 6), - period: multisig.types.Period.Month, - members: [this.payer.publicKey], - destinations: [], - memo: "", - }); - - const proposalResult = await this.initializeAndLaunchProposal({ - dao, - instructions: [addSpendingLimitIx], - }); - - proposal = proposalResult.proposal; - squadsProposal = proposalResult.squadsProposal; - - const { question, quoteVault, passBaseMint } = - this.futarchy.getProposalPdas(proposal, META, USDC, dao); - - await this.conditionalVault - .splitTokensIx(question, quoteVault, 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: USDC, - proposal, - market: "pass", - swapType: "buy", - inputAmount: new BN(10_000 * 1_000_000), - minOutputAmount: new BN(0), - }) - .preInstructions([ - createAssociatedTokenAccountIdempotentInstruction( - this.payer.publicKey, - getAssociatedTokenAddressSync( - passBaseMint, - this.payer.publicKey, - true, - ), - this.payer.publicKey, - passBaseMint, - ), - ]) - .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: USDC, - proposal, - market: "pass", - swapType: "buy", - inputAmount: new BN(10), - minOutputAmount: new BN(0), - }) - .preInstructions([ - ComputeBudgetProgram.setComputeUnitPrice({ microLamports: i }), - createAssociatedTokenAccountIdempotentInstruction( - this.payer.publicKey, - getAssociatedTokenAddressSync( - passBaseMint, - this.payer.publicKey, - true, - ), - this.payer.publicKey, - passBaseMint, - ), - ]) - .rpc(); - } - - // Finalize the proposal - await this.futarchy.finalizeProposal(proposal); - - const storedProposal = await this.futarchy.getProposal(proposal); - assert.exists(storedProposal.state.passed); - - // Set an active optimistic proposal on the DAO - const daoAccount = await this.futarchy.getDao(dao); - daoAccount.optimisticProposal = { - squadsProposal: PublicKey.default, - enqueuedTimestamp: new BN(0), - }; - const daoAccountBuffer = - await this.futarchy.futarchy.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 [vaultTransactionPda] = multisig.getTransactionPda({ - multisigPda: multisigPda, - index: 1n, - }); - - const transactionAccount = - await multisig.accounts.VaultTransaction.fromAccountAddress( - this.squadsConnection, - vaultTransactionPda, - ); - - const [vaultPda] = multisig.getVaultPda({ - multisigPda, - index: transactionAccount.vaultIndex, - programId: multisig.PROGRAM_ID, - }); - - const { accountMetas } = await multisig.utils.accountsForTransactionExecute( - { - connection: this.squadsConnection, - message: transactionAccount.message, - ephemeralSignerBumps: [...transactionAccount.ephemeralSignerBumps], - vaultPda, - transactionPda: vaultTransactionPda, - programId: multisig.PROGRAM_ID, - }, - ); - - const callbacks = expectError( - "ActiveOptimisticProposalAlreadyEnqueued", - "Should fail because there is an active optimistic proposal", - ); - - await this.futarchy.futarchy.methods - .executeSpendingLimitChange() - .accounts({ - squadsMultisig: multisigPda, - proposal, - dao, - squadsProposal, - squadsMultisigProgram: multisig.PROGRAM_ID, - vaultTransaction: vaultTransactionPda, - }) - .remainingAccounts( - accountMetas.map((meta) => - meta.pubkey.equals(dao) ? { ...meta, isSigner: false } : meta, - ), - ) - .rpc() - .then(callbacks[0], callbacks[1]); - }); - - it("throws if the transaction is to remove the DAO as a member", async function () { - const removeMemberIx = multisig.instructions.multisigRemoveMember({ - multisigPda, - configAuthority: dao, - oldMember: dao, - }); - - const proposalResult = await this.initializeAndLaunchProposal({ - dao, - instructions: [removeMemberIx], - }); - - proposal = proposalResult.proposal; - squadsProposal = proposalResult.squadsProposal; - - const { question, quoteVault, passBaseMint } = - this.futarchy.getProposalPdas(proposal, META, USDC, dao); - - await this.conditionalVault - .splitTokensIx(question, quoteVault, 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: USDC, - proposal, - market: "pass", - swapType: "buy", - inputAmount: new BN(10_000 * 1_000_000), - minOutputAmount: new BN(0), - }) - .preInstructions([ - createAssociatedTokenAccountIdempotentInstruction( - this.payer.publicKey, - getAssociatedTokenAddressSync( - passBaseMint, - this.payer.publicKey, - true, - ), - this.payer.publicKey, - passBaseMint, - ), - ]) - .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: 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] = multisig.getTransactionPda({ - multisigPda: multisigPda, - index: 1n, - }); - - const transactionAccount = - await multisig.accounts.VaultTransaction.fromAccountAddress( - this.squadsConnection, - vaultTransactionPda, - ); - - const [vaultPda] = multisig.getVaultPda({ - multisigPda, - index: transactionAccount.vaultIndex, - programId: multisig.PROGRAM_ID, - }); - - const { accountMetas } = await multisig.utils.accountsForTransactionExecute( - { - connection: this.squadsConnection, - message: transactionAccount.message, - ephemeralSignerBumps: [...transactionAccount.ephemeralSignerBumps], - vaultPda, - transactionPda: vaultTransactionPda, - programId: multisig.PROGRAM_ID, - }, - ); - - const callbacks = expectError( - "InvalidTransaction", - "The transaction should not be executed because it contains a call to remove the DAO as a member", - ); - - await this.futarchy.futarchy.methods - .executeSpendingLimitChange() - .accounts({ - squadsMultisig: multisigPda, - proposal, - dao, - squadsProposal, - squadsMultisigProgram: multisig.PROGRAM_ID, - vaultTransaction: vaultTransactionPda, - }) - .remainingAccounts( - accountMetas.map((meta) => - meta.pubkey.equals(dao) ? { ...meta, isSigner: false } : meta, - ), - ) - .rpc() - .then(callbacks[0], callbacks[1]); - }); - - it("throws if the vault transaction is a memo instruction", async function () { - const proposalResult = await this.initializeAndLaunchProposal({ - dao, - instructions: [ - { - programId: MEMO_PROGRAM_ID, - keys: [], - data: Buffer.from("Hello, world!"), - }, - ], - }); - - proposal = proposalResult.proposal; - squadsProposal = proposalResult.squadsProposal; - - const { question, quoteVault, passBaseMint } = - this.futarchy.getProposalPdas(proposal, META, USDC, dao); - - await this.conditionalVault - .splitTokensIx(question, quoteVault, 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: USDC, - proposal, - market: "pass", - swapType: "buy", - inputAmount: new BN(10_000 * 1_000_000), - minOutputAmount: new BN(0), - }) - .preInstructions([ - createAssociatedTokenAccountIdempotentInstruction( - this.payer.publicKey, - getAssociatedTokenAddressSync( - passBaseMint, - this.payer.publicKey, - true, - ), - this.payer.publicKey, - passBaseMint, - ), - ]) - .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: 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] = multisig.getTransactionPda({ - multisigPda: multisigPda, - index: 1n, - }); - - const transactionAccount = - await multisig.accounts.VaultTransaction.fromAccountAddress( - this.squadsConnection, - vaultTransactionPda, - ); - - const [vaultPda] = multisig.getVaultPda({ - multisigPda, - index: transactionAccount.vaultIndex, - programId: multisig.PROGRAM_ID, - }); - - const { accountMetas } = await multisig.utils.accountsForTransactionExecute( - { - connection: this.squadsConnection, - message: transactionAccount.message, - ephemeralSignerBumps: [...transactionAccount.ephemeralSignerBumps], - vaultPda, - transactionPda: vaultTransactionPda, - programId: multisig.PROGRAM_ID, - }, - ); - - const callbacks = expectError( - "InvalidTransaction", - "The transaction should not be executed because it contains a call to remove the DAO as a member", - ); - - await this.futarchy.futarchy.methods - .executeSpendingLimitChange() - .accounts({ - squadsMultisig: multisigPda, - proposal, - dao, - squadsProposal, - squadsMultisigProgram: multisig.PROGRAM_ID, - vaultTransaction: vaultTransactionPda, - }) - .remainingAccounts( - accountMetas.map((meta) => - meta.pubkey.equals(dao) ? { ...meta, isSigner: false } : meta, - ), - ) - .rpc() - .then(callbacks[0], callbacks[1]); - }); -} diff --git a/tests/futarchy/unit/finalizeOptimisticProposal.test.ts b/tests/futarchy/unit/finalizeOptimisticProposal.test.ts deleted file mode 100644 index b3c50f0a..00000000 --- a/tests/futarchy/unit/finalizeOptimisticProposal.test.ts +++ /dev/null @@ -1,258 +0,0 @@ -import { - PERMISSIONLESS_ACCOUNT, - PriceMath, - getDaoAddr, - MAINNET_USDC, -} from "@metadaoproject/programs"; -import { - ComputeBudgetProgram, - PublicKey, - Transaction, - TransactionMessage, -} from "@solana/web3.js"; -import BN from "bn.js"; -import { expectError, setOptimisticGovernanceEnabled } from "../../utils.js"; -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; - - beforeEach(async function () { - 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(this, 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( - "RequireKeysEqViolated", - "Squads proposal must not match the enqueued optimistic proposal", - ); - - 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(); - - const callbacks = expectError( - "NoActiveOptimisticProposal", - "No active optimistic proposal expected", - ); - - await this.futarchy - .finalizeOptimisticProposalIx({ - dao, - squadsProposal: daoAccount.optimisticProposal.squadsProposal, - }) - .preInstructions([ - // Different compute budget produces a distinct signature from the first finalize call - ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), - ]) - .rpc() - .then(callbacks[0], callbacks[1]); - }); -} diff --git a/tests/futarchy/unit/finalizeProposal.test.ts b/tests/futarchy/unit/finalizeProposal.test.ts index 231c3c9e..d19aaf4c 100644 --- a/tests/futarchy/unit/finalizeProposal.test.ts +++ b/tests/futarchy/unit/finalizeProposal.test.ts @@ -1,6 +1,11 @@ -import { PERMISSIONLESS_ACCOUNT, PriceMath } from "@metadaoproject/programs"; +import { + PERMISSIONLESS_ACCOUNT, + PriceMath, + getDaoAddr, +} from "@metadaoproject/programs"; import { ComputeBudgetProgram, + Keypair, PublicKey, Transaction, TransactionMessage, @@ -282,6 +287,11 @@ export default function suite() { const storedProposal = await this.futarchy.getProposal(proposal); assert.exists(storedProposal.state.failed); + // A failed ExecuteArbitrary stamps no hostile-failure timestamp + const storedDao = await this.futarchy.getDao(dao); + assert.equal(storedDao.lastFailedTakeoverAt.toString(), "0"); + assert.equal(storedDao.lastFailedLiquidationAt.toString(), "0"); + // Verify Squads proposal is rejected const multisigPda = multisig.getMultisigPda({ createKey: dao })[0]; const [squadsProposalPda] = multisig.getProposalPda({ @@ -298,7 +308,7 @@ export default function suite() { }); it("finalizes when last trade is before the deadline (virtual crank covers the gap)", async function () { - // Trade for ~200,000s of the 259,200s proposal duration, then stop + // Trade for ~400,000s of the 864,000s proposal duration, then stop // trading and advance the clock past the deadline before finalizing. // The virtual crank in get_twap() fills in the gap after the last trade. @@ -330,9 +340,11 @@ export default function suite() { }) .rpc(); - // Trade for ~200,000 seconds (10 swaps × 20,000s each) - // This is ~77% of the 259,200s proposal duration - for (let i = 0; i < 10; i++) { + // Trade for ~400,000 seconds (20 swaps × 20,000s each), well short of + // the 864,000s proposal duration. Each swap moves the pass observation + // up by the max change per update, so 20 swaps push the pass TWAP + // comfortably past the +10% snapshot threshold. + for (let i = 0; i < 20; i++) { await this.advanceBySeconds(20_000); await this.futarchy @@ -352,7 +364,7 @@ export default function suite() { .rpc(); } - // At ~200,000s into a 259,200s proposal — finalization should fail + // At ~400,000s into an 864,000s proposal — finalization should fail // because wall-clock time hasn't reached the deadline yet. const earlyCallbacks = expectError( "ProposalTooYoung", @@ -372,9 +384,9 @@ export default function suite() { .rpc() .then(earlyCallbacks[0], earlyCallbacks[1]); - // Stop trading. Advance time past the proposal deadline (259,200s). - // Last trade was at ~200,000s. We need at least 60,000 more seconds. - await this.advanceBySeconds(70_000); + // Stop trading. Advance time past the proposal deadline (864,000s). + // Last trade was at ~400,000s. We need at least 464,000 more seconds. + await this.advanceBySeconds(700_000); // Finalize — should succeed because: // 1. Wall-clock time is past the deadline (validate() passes) @@ -386,8 +398,95 @@ export default function suite() { assert.exists(storedProposal.state.passed); }); - it("passes proposals when the team sponsors them and pass twap is slightly below fail twap", async function () { - // Create a new DAO with -5% team-sponsored threshold + it("fails proposals above the DAO threshold but below the snapshot threshold", async function () { + // The DAO's pass_threshold_bps is +3%, but the proposal snapshotted + // ExecuteArbitrary's +10% at create. Push the pass TWAP ~5% above fail — + // clearing the DAO threshold but not the snapshot — and the proposal + // must still fail, proving finalize judges at the snapshot. + const { baseVault, quoteVault, question } = this.futarchy.getProposalPdas( + proposal, + META, + USDC, + dao, + ); + + await this.conditionalVault + .splitTokensIx(question, baseVault, META, new BN(10 * 10 ** 9), 2) + .rpc(); + await this.conditionalVault + .splitTokensIx(question, quoteVault, USDC, new BN(11_000 * 1_000_000), 2) + .rpc(); + + // Initial swap to pump the pass market price + await this.futarchy + .conditionalSwapIx({ + dao, + baseMint: META, + quoteMint: USDC, + proposal, + market: "pass", + swapType: "buy", + inputAmount: new BN(10_000 * 1_000_000), + minOutputAmount: new BN(0), + }) + .rpc(); + + // 5 swaps × 20,000s: each moves the pass observation up by the max + // change per update (1%), landing the pass TWAP ~5% above fail + for (let i = 0; i < 5; i++) { + await this.advanceBySeconds(20_000); + + await this.futarchy + .conditionalSwapIx({ + dao, + baseMint: META, + quoteMint: USDC, + proposal, + market: "pass", + swapType: "buy", + inputAmount: new BN(10), + minOutputAmount: new BN(0), + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitPrice({ microLamports: i }), + ]) + .rpc(); + } + + // Advance past the 864,000s proposal deadline + await this.advanceBySeconds(800_000); + + // Pin the scenario in the discriminating band: pass TWAP above the + // DAO's +3% threshold, at or below the snapshot's +10% + const now = (await this.banksClient.getClock()).unixTimestamp; + const { pass, fail } = (await this.futarchy.getDao(dao)).amm.state.futarchy; + const twap = (pool: typeof pass) => { + const start = + BigInt(pool.oracle.createdAtTimestamp.toString()) + + BigInt(pool.oracle.startDelaySeconds); + const finalInterval = + now - BigInt(pool.oracle.lastUpdatedTimestamp.toString()); + const total = + BigInt(pool.oracle.aggregator.toString()) + + BigInt(pool.oracle.lastObservation.toString()) * finalInterval; + return total / (now - start); + }; + const passTwap = twap(pass); + const failTwap = twap(fail); + assert.isTrue(passTwap > (failTwap * 10_300n) / 10_000n); + assert.isTrue(passTwap <= (failTwap * 11_000n) / 10_000n); + + await this.futarchy.finalizeProposal(proposal); + + const storedProposal = await this.futarchy.getProposal(proposal); + assert.exists(storedProposal.state.failed); + }); + + it("judges team-sponsored proposals at the snapshot threshold, not the DAO's team threshold", async function () { + // Create a new DAO with -5% team-sponsored threshold. That per-DAO + // threshold is vestigial: finalize judges at the proposal's snapshot + // (+10% for ExecuteArbitrary), so with the pass TWAP slightly below + // fail this sponsored proposal now fails. const META = await this.createMint(this.payer.publicKey, 6); const USDC = await this.createMint(this.payer.publicKey, 6); @@ -550,16 +649,220 @@ export default function suite() { await this.advanceBySeconds(20_000); } - // Finalize the proposal - should pass because it's team-sponsored - // and the pass TWAP is within the -5% threshold + // Fails despite team sponsorship: the pass TWAP is below fail, nowhere + // near the +10% snapshot threshold await this.futarchy.finalizeProposal(teamSponsoredProposal); const storedProposal = await this.futarchy.getProposal( teamSponsoredProposal, ); - assert.exists( - storedProposal.state.passed, - "Team-sponsored proposal should pass when within threshold", + assert.exists(storedProposal.state.failed); + }); + + it("passes an uncontested large_spend at its -10% threshold", async function () { + // Fresh DAO with a spending limit — the suite DAO has none and already + // has a live proposal + const BASE = await this.createMint(this.payer.publicKey, 6); + const QUOTE = await this.createMint(this.payer.publicKey, 6); + + await this.createTokenAccount(BASE, this.payer.publicKey); + await this.createTokenAccount(QUOTE, this.payer.publicKey); + + await this.mintTo(BASE, this.payer.publicKey, this.payer, 100 * 10 ** 9); + await this.mintTo( + QUOTE, + this.payer.publicKey, + this.payer, + 200_000 * 1_000_000, + ); + + const nonce = new BN(Math.floor(Math.random() * 1000000)); + + await this.futarchy + .initializeDaoIx({ + baseMint: BASE, + quoteMint: QUOTE, + 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(1_000_000_000), // 1,000 USDC + members: [this.payer.publicKey], + }, + baseToStake: new BN(0), + teamSponsoredPassThresholdBps: 300, + teamAddress: this.payer.publicKey, + }, + provideLiquidity: true, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), + ]) + .rpc(); + + const [spendDao] = getDaoAddr({ + nonce, + daoCreator: this.payer.publicKey, + }); + + await this.futarchy + .provideLiquidityIx({ + dao: spendDao, + baseMint: BASE, + quoteMint: QUOTE, + quoteAmount: new BN(100_000 * 10 ** 6), + maxBaseAmount: new BN(100 * 10 ** 6), + minLiquidity: new BN(0), + positionAuthority: this.payer.publicKey, + liquidityProvider: this.payer.publicKey, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), + ]) + .rpc(); + + const { proposal: spendProposal, squadsProposal: spendSquadsProposal } = + await this.futarchy.initializeLargeSpendProposal({ + dao: spendDao, + amount: new BN(1_000_000_000), // 1,000 USDC + }); + + await this.futarchy + .sponsorProposalIx({ + proposal: spendProposal, + dao: spendDao, + teamAddress: this.payer.publicKey, + }) + .rpc(); + + await this.futarchy + .launchProposalIx({ + proposal: spendProposal, + dao: spendDao, + baseMint: BASE, + quoteMint: QUOTE, + squadsProposal: spendSquadsProposal, + }) + .rpc(); + + // Nobody contests the market: one swap after the TWAP start delay + // records an observation in both markets and leaves the TWAPs equal + await this.advanceBySeconds(60 * 60 * 24 + 60); + await this.futarchy + .spotSwapIx({ + dao: spendDao, + baseMint: BASE, + quoteMint: QUOTE, + swapType: "buy", + inputAmount: new BN(1_000), + }) + .rpc(); + + // Past the kind's 1.5-day duration snapshot + await this.advanceBySeconds(129_600); + await this.futarchy.finalizeProposal(spendProposal); + + // Equal TWAPs clear the -10% snapshot threshold + const storedProposal = await this.futarchy.getProposal(spendProposal); + assert.exists(storedProposal.state.passed); + + const squadsProposalAccount = + await multisig.accounts.Proposal.fromAccountAddress( + this.squadsConnection, + spendSquadsProposal, + ); + assert.isTrue( + multisig.generated.isProposalStatusApproved(squadsProposalAccount.status), + ); + }); + + it("stamps only its own cooldown timestamp when a hostile proposal fails", async function () { + // Fresh DAO — the suite DAO already has a live proposal + const BASE = await this.createMint(this.payer.publicKey, 6); + const QUOTE = await this.createMint(this.payer.publicKey, 6); + + await this.createTokenAccount(BASE, this.payer.publicKey); + await this.createTokenAccount(QUOTE, this.payer.publicKey); + + await this.mintTo(BASE, this.payer.publicKey, this.payer, 100 * 10 ** 9); + await this.mintTo( + QUOTE, + this.payer.publicKey, + this.payer, + 200_000 * 1_000_000, + ); + + const hostileDao = await setupBasicDao({ + context: this, + baseMint: BASE, + quoteMint: QUOTE, + }); + + await this.futarchy + .provideLiquidityIx({ + dao: hostileDao, + baseMint: BASE, + quoteMint: QUOTE, + quoteAmount: new BN(100_000 * 10 ** 6), + maxBaseAmount: new BN(100 * 10 ** 6), + minLiquidity: new BN(0), + positionAuthority: this.payer.publicKey, + liquidityProvider: this.payer.publicKey, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), + ]) + .rpc(); + + const { proposal: hostileProposal, squadsProposal } = + await this.futarchy.initializeHostileTakeoverProposal({ + dao: hostileDao, + newTeamAddress: Keypair.generate().publicKey, + spendingLimitAction: { keep: {} }, + }); + + await this.futarchy + .launchProposalIx({ + proposal: hostileProposal, + dao: hostileDao, + baseMint: BASE, + quoteMint: QUOTE, + squadsProposal, + }) + .rpc(); + + // One swap after the TWAP start delay records an observation in both + // markets; the equal TWAPs it leaves can't clear the +10% threshold + await this.advanceBySeconds(60 * 60 * 24 + 60); + await this.futarchy + .spotSwapIx({ + dao: hostileDao, + baseMint: BASE, + quoteMint: QUOTE, + swapType: "buy", + inputAmount: new BN(1_000), + }) + .rpc(); + + await this.advanceBySeconds(60 * 60 * 24 * 20); + await this.futarchy.finalizeProposal(hostileProposal); + + const storedProposal = await this.futarchy.getProposal(hostileProposal); + assert.exists(storedProposal.state.failed); + + // The failed takeover stamps its own timestamp and only its own + const clock = await this.banksClient.getClock(); + const storedDao = await this.futarchy.getDao(hostileDao); + assert.equal( + storedDao.lastFailedTakeoverAt.toString(), + clock.unixTimestamp.toString(), ); + assert.equal(storedDao.lastFailedLiquidationAt.toString(), "0"); }); } diff --git a/tests/futarchy/unit/initializeHostileLiquidateProposal.test.ts b/tests/futarchy/unit/initializeHostileLiquidateProposal.test.ts new file mode 100644 index 00000000..3d83291f --- /dev/null +++ b/tests/futarchy/unit/initializeHostileLiquidateProposal.test.ts @@ -0,0 +1,116 @@ +import { + FUTARCHY_V0_6_PROGRAM_ID, + getDaoAddr, + getEventAuthorityAddr, + PriceMath, +} from "@metadaoproject/programs"; +import { ComputeBudgetProgram, Keypair, PublicKey } from "@solana/web3.js"; +import { + getAssociatedTokenAddressSync, + TOKEN_PROGRAM_ID, +} from "@solana/spl-token"; +import BN from "bn.js"; +import { assert } from "chai"; +import { assertVaultTransactionPayload } from "../../utils.js"; + +const ONE_BUCK_PRICE = PriceMath.getAmmPrice(1, 6, 6); + +export default function suite() { + let META: PublicKey, USDC: PublicKey, dao: PublicKey; + + beforeEach(async function () { + META = await this.createMint(this.payer.publicKey, 6); + USDC = await this.createMint(this.payer.publicKey, 6); + + 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: ONE_BUCK_PRICE, + twapMaxObservationChangePerUpdate: ONE_BUCK_PRICE.divn(100), + minQuoteFutarchicLiquidity: new BN(10_000), + minBaseFutarchicLiquidity: new BN(10_000), + passThresholdBps: 300, + nonce, + initialSpendingLimit: { + amountPerMonth: new BN(10_000_000_000), // 10,000 USDC + members: [this.payer.publicKey], + }, + baseToStake: new BN(0), + teamSponsoredPassThresholdBps: 300, + teamAddress: this.payer.publicKey, + }, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), + ]) + .rpc(); + + [dao] = getDaoAddr({ nonce, daoCreator: this.payer.publicKey }); + }); + + it("bakes an apply_liquidation whose accounts are exactly the derived set, including this proposal's own PDA", async function () { + const liquidator = Keypair.generate().publicKey; + + const { proposal, squadsProposal, squadsTransaction } = + await this.futarchy.initializeHostileLiquidateProposal({ + dao, + liquidator, + }); + + const storedDao = await this.futarchy.getDao(dao); + const vault = storedDao.squadsMultisigVault; + + const [eventAuthority] = getEventAuthorityAddr(FUTARCHY_V0_6_PROGRAM_ID); + const [ammPosition] = PublicKey.findProgramAddressSync( + [Buffer.from("amm_position"), dao.toBuffer(), vault.toBuffer()], + FUTARCHY_V0_6_PROGRAM_ID, + ); + + const expectedApplyLiquidationIx = await this.futarchy.futarchy.methods + .applyLiquidation() + .accounts({ + proposal, + dao, + squadsMultisigVault: vault, + ammPosition, + ammBaseVault: storedDao.amm.ammBaseVault, + ammQuoteVault: storedDao.amm.ammQuoteVault, + vaultBaseAccount: getAssociatedTokenAddressSync(META, vault, true), + vaultQuoteAccount: getAssociatedTokenAddressSync(USDC, vault, true), + tokenProgram: TOKEN_PROGRAM_ID, + eventAuthority, + program: FUTARCHY_V0_6_PROGRAM_ID, + }) + .instruction(); + + await assertVaultTransactionPayload(this, dao, squadsTransaction, [ + expectedApplyLiquidationIx, + ]); + + const storedProposal = await this.futarchy.getProposal(proposal); + + assert.equal(storedProposal.number, 1); + assert.ok(storedProposal.dao.equals(dao)); + assert.ok(storedProposal.proposer.equals(this.payer.publicKey)); + assert.ok(storedProposal.squadsProposal.equals(squadsProposal)); + assert.exists(storedProposal.state.draft); + assert.isFalse(storedProposal.isTeamSponsored); + assert.ok( + storedProposal.action.hostileLiquidate.liquidator.equals(liquidator), + ); + + // 10 days, +25%, unblockable + assert.equal(storedProposal.durationInSeconds, 864_000); + assert.equal(storedProposal.passThresholdBps, 2500); + assert.isFalse(storedProposal.councilCanBlock); + + const updatedDao = await this.futarchy.getDao(dao); + assert.equal(updatedDao.proposalCount, 1); + }); +} diff --git a/tests/futarchy/unit/initializeHostileTakeoverProposal.test.ts b/tests/futarchy/unit/initializeHostileTakeoverProposal.test.ts new file mode 100644 index 00000000..5e0b1373 --- /dev/null +++ b/tests/futarchy/unit/initializeHostileTakeoverProposal.test.ts @@ -0,0 +1,260 @@ +import { + getDaoAddr, + getSpendingLimitAddr, + PriceMath, +} from "@metadaoproject/programs"; +import { ComputeBudgetProgram, Keypair, PublicKey } from "@solana/web3.js"; +import BN from "bn.js"; +import { assert } from "chai"; +import * as multisig from "@sqds/multisig"; +import { + assertVaultTransactionPayload, + executeVaultTransaction, + expectError, + forceApproveSquadsProposal, +} from "../../utils.js"; +import { TestContext } from "../../main.test.js"; + +const ONE_BUCK_PRICE = PriceMath.getAmmPrice(1, 6, 6); + +export default function suite() { + let META: PublicKey, USDC: PublicKey, dao: PublicKey; + + beforeEach(async function () { + META = await this.createMint(this.payer.publicKey, 6); + USDC = await this.createMint(this.payer.publicKey, 6); + + 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: ONE_BUCK_PRICE, + twapMaxObservationChangePerUpdate: ONE_BUCK_PRICE.divn(100), + minQuoteFutarchicLiquidity: new BN(10_000), + minBaseFutarchicLiquidity: new BN(10_000), + passThresholdBps: 300, + nonce, + initialSpendingLimit: { + amountPerMonth: new BN(10_000_000_000), // 10,000 USDC + members: [this.payer.publicKey], + }, + baseToStake: new BN(0), + teamSponsoredPassThresholdBps: 300, + teamAddress: this.payer.publicKey, + }, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), + ]) + .rpc(); + + [dao] = getDaoAddr({ nonce, daoCreator: this.payer.publicKey }); + }); + + // update_dao re-pointing the team and changing nothing else + function expectedUpdateDaoIx( + context: TestContext, + newTeamAddress: PublicKey, + ) { + return context.futarchy + .updateDaoIx({ + dao, + params: { + passThresholdBps: null, + secondsPerProposal: null, + twapInitialObservation: null, + twapMaxObservationChangePerUpdate: null, + twapStartDelaySeconds: null, + minQuoteFutarchicLiquidity: null, + minBaseFutarchicLiquidity: null, + baseToStake: null, + teamSponsoredPassThresholdBps: null, + teamAddress: newTeamAddress, + isOptimisticGovernanceEnabled: null, + }, + }) + .instruction(); + } + + it("bakes only a vault-signed update_dao when keeping the limit and snapshots the kind's params", async function () { + const newTeamAddress = Keypair.generate().publicKey; + + const { proposal, squadsProposal, squadsTransaction } = + await this.futarchy.initializeHostileTakeoverProposal({ + dao, + newTeamAddress, + spendingLimitAction: { keep: {} }, + }); + + await assertVaultTransactionPayload(this, dao, squadsTransaction, [ + await expectedUpdateDaoIx(this, newTeamAddress), + ]); + + const storedProposal = await this.futarchy.getProposal(proposal); + + assert.equal(storedProposal.number, 1); + assert.ok(storedProposal.dao.equals(dao)); + assert.ok(storedProposal.proposer.equals(this.payer.publicKey)); + assert.ok(storedProposal.squadsProposal.equals(squadsProposal)); + assert.exists(storedProposal.state.draft); + assert.isFalse(storedProposal.isTeamSponsored); + + assert.ok( + storedProposal.action.hostileTakeover.newTeamAddress.equals( + newTeamAddress, + ), + ); + assert.exists( + storedProposal.action.hostileTakeover.spendingLimitAction.keep, + ); + + // 20 days, +10%, unblockable + assert.equal(storedProposal.durationInSeconds, 1_728_000); + assert.equal(storedProposal.passThresholdBps, 1000); + assert.isFalse(storedProposal.councilCanBlock); + + const storedDao = await this.futarchy.getDao(dao); + assert.equal(storedDao.proposalCount, 1); + }); + + it("appends a set_spending_limit with a None config when removing the limit", async function () { + const newTeamAddress = Keypair.generate().publicKey; + + const { proposal, squadsTransaction } = + await this.futarchy.initializeHostileTakeoverProposal({ + dao, + newTeamAddress, + spendingLimitAction: { remove: {} }, + }); + + await assertVaultTransactionPayload(this, dao, squadsTransaction, [ + await expectedUpdateDaoIx(this, newTeamAddress), + await this.futarchy + .setSpendingLimitIx({ dao, config: null }) + .instruction(), + ]); + + const storedProposal = await this.futarchy.getProposal(proposal); + assert.exists( + storedProposal.action.hostileTakeover.spendingLimitAction.remove, + ); + }); + + it("appends a set_spending_limit carrying the declared config verbatim when setting the limit", async function () { + const newTeamAddress = Keypair.generate().publicKey; + const config = { + amountPerMonth: new BN(25_000_000_000), // 25,000 USDC + members: [Keypair.generate().publicKey, Keypair.generate().publicKey], + }; + + const { proposal, squadsTransaction } = + await this.futarchy.initializeHostileTakeoverProposal({ + dao, + newTeamAddress, + spendingLimitAction: { set: { 0: config } }, + }); + + await assertVaultTransactionPayload(this, dao, squadsTransaction, [ + await expectedUpdateDaoIx(this, newTeamAddress), + await this.futarchy.setSpendingLimitIx({ dao, config }).instruction(), + ]); + + const storedProposal = await this.futarchy.getProposal(proposal); + const storedAction = storedProposal.action.hostileTakeover; + assert.ok(storedAction.newTeamAddress.equals(newTeamAddress)); + assert.equal( + storedAction.spendingLimitAction.set[0].amountPerMonth.toString(), + config.amountPerMonth.toString(), + ); + assert.deepEqual( + storedAction.spendingLimitAction.set[0].members.map((m) => m.toBase58()), + config.members.map((m) => m.toBase58()), + ); + }); + + it("the executed and synced end state matches the declaration", async function () { + const newTeamAddress = Keypair.generate().publicKey; + const config = { + amountPerMonth: new BN(25_000_000_000), // 25,000 USDC + members: [Keypair.generate().publicKey, Keypair.generate().publicKey], + }; + + const { squadsProposal, squadsTransaction } = + await this.futarchy.initializeHostileTakeoverProposal({ + dao, + newTeamAddress, + spendingLimitAction: { set: { 0: config } }, + }); + + await forceApproveSquadsProposal(this, squadsProposal); + await executeVaultTransaction(this, dao, squadsTransaction); + + let storedDao = await this.futarchy.getDao(dao); + assert.ok(storedDao.teamAddress.equals(newTeamAddress)); + assert.equal( + storedDao.initialSpendingLimit.amountPerMonth.toString(), + config.amountPerMonth.toString(), + ); + assert.deepEqual( + storedDao.initialSpendingLimit.members.map((m) => m.toBase58()), + config.members.map((m) => m.toBase58()), + ); + assert.isTrue(storedDao.spendingLimitDirty); + + await this.futarchy.syncSpendingLimitIx({ dao }).rpc(); + + const [spendingLimitPda] = getSpendingLimitAddr({ dao }); + const storedLimit = + await multisig.accounts.SpendingLimit.fromAccountAddress( + this.squadsConnection, + spendingLimitPda, + ); + assert.equal( + storedLimit.amount.toString(), + config.amountPerMonth.toString(), + ); + assert.equal( + storedLimit.remainingAmount.toString(), + config.amountPerMonth.toString(), + ); + // Squads stores members sorted, so compare as sets + assert.sameMembers( + storedLimit.members.map((m) => m.toBase58()), + config.members.map((m) => m.toBase58()), + ); + + storedDao = await this.futarchy.getDao(dao); + assert.isFalse(storedDao.spendingLimitDirty); + }); + + it("throws error when a Set action has more than 10 members", async function () { + const elevenMembers = Array.from( + { length: 11 }, + () => Keypair.generate().publicKey, + ); + + const callbacks = expectError( + "TooManySpendingLimitMembers", + "created a hostile takeover proposal with more than 10 members", + ); + await this.futarchy + .initializeHostileTakeoverProposal({ + dao, + newTeamAddress: Keypair.generate().publicKey, + spendingLimitAction: { + set: { + 0: { + amountPerMonth: new BN(1_000_000_000), // 1,000 USDC + members: elevenMembers, + }, + }, + }, + }) + .then(...callbacks); + }); +} diff --git a/tests/futarchy/unit/initializeLargeSpendProposal.test.ts b/tests/futarchy/unit/initializeLargeSpendProposal.test.ts new file mode 100644 index 00000000..2b2e5ce7 --- /dev/null +++ b/tests/futarchy/unit/initializeLargeSpendProposal.test.ts @@ -0,0 +1,198 @@ +import { getDaoAddr, PriceMath } from "@metadaoproject/programs"; +import { ComputeBudgetProgram, PublicKey } from "@solana/web3.js"; +import { + createTransferInstruction, + getAssociatedTokenAddressSync, +} from "@solana/spl-token"; +import BN from "bn.js"; +import { assert } from "chai"; +import { + assertVaultTransactionPayload, + executeVaultTransaction, + expectError, + forceApproveSquadsProposal, + setupBasicDao, +} from "../../utils.js"; + +const ONE_BUCK_PRICE = PriceMath.getAmmPrice(1, 6, 6); + +// 1,000 USDC per month => 3,000 USDC cap +const AMOUNT_PER_MONTH = new BN(1_000_000_000); + +export default function suite() { + let META: PublicKey, USDC: PublicKey, dao: PublicKey; + + beforeEach(async function () { + META = await this.createMint(this.payer.publicKey, 6); + USDC = await this.createMint(this.payer.publicKey, 6); + + await this.createTokenAccount(META, this.payer.publicKey); + await this.createTokenAccount(USDC, this.payer.publicKey); + + await this.mintTo( + META, + this.payer.publicKey, + this.payer, + 100_000 * 10 ** 6, + ); + await this.mintTo( + USDC, + this.payer.publicKey, + this.payer, + 100_000 * 10 ** 6, + ); + + 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: ONE_BUCK_PRICE, + twapMaxObservationChangePerUpdate: ONE_BUCK_PRICE.divn(100), + minQuoteFutarchicLiquidity: new BN(10_000), + minBaseFutarchicLiquidity: new BN(10_000), + passThresholdBps: 300, + nonce, + initialSpendingLimit: { + amountPerMonth: AMOUNT_PER_MONTH, + 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, + }); + }); + + it("bakes exactly one vault-to-team transfer and snapshots the kind's params", async function () { + // exactly 3x the monthly limit: the cap is inclusive + const amount = AMOUNT_PER_MONTH.muln(3); + + const { proposal, squadsProposal, squadsTransaction } = + await this.futarchy.initializeLargeSpendProposal({ dao, amount }); + + const storedDao = await this.futarchy.getDao(dao); + + // the recipient is the team's quote ATA, pinned at create + await assertVaultTransactionPayload(this, dao, squadsTransaction, [ + createTransferInstruction( + getAssociatedTokenAddressSync( + USDC, + storedDao.squadsMultisigVault, + true, + ), + getAssociatedTokenAddressSync(USDC, this.payer.publicKey, true), + storedDao.squadsMultisigVault, + BigInt(amount.toString()), + ), + ]); + + const storedProposal = await this.futarchy.getProposal(proposal); + + assert.equal(storedProposal.number, 1); + assert.ok(storedProposal.dao.equals(dao)); + assert.ok(storedProposal.proposer.equals(this.payer.publicKey)); + assert.ok(storedProposal.squadsProposal.equals(squadsProposal)); + assert.exists(storedProposal.state.draft); + assert.isFalse(storedProposal.isTeamSponsored); + + assert.equal( + storedProposal.action.largeSpend.amount.toString(), + amount.toString(), + ); + assert.equal(storedProposal.durationInSeconds, 129_600); + assert.equal(storedProposal.passThresholdBps, -1000); + assert.isTrue(storedProposal.councilCanBlock); + + assert.equal(storedDao.proposalCount, 1); + }); + + it("throws error when the DAO has no spending limit", async function () { + const noRecordDao = await setupBasicDao({ + context: this, + baseMint: META, + quoteMint: USDC, + }); + + const callbacks = expectError( + "NoSpendingLimit", + "created a large spend proposal without a spending limit", + ); + await this.futarchy + .initializeLargeSpendProposal({ dao: noRecordDao, amount: new BN(1) }) + .then(...callbacks); + }); + + it("throws error when the amount exceeds 3x the monthly limit", async function () { + const callbacks = expectError( + "SpendCapExceeded", + "created a large spend proposal above the cap", + ); + await this.futarchy + .initializeLargeSpendProposal({ + dao, + amount: AMOUNT_PER_MONTH.muln(3).addn(1), + }) + .then(...callbacks); + }); + + it("the transfer payload executes once the Squads proposal is approved", async function () { + const amount = AMOUNT_PER_MONTH.muln(3); + + const { squadsProposal, squadsTransaction } = + await this.futarchy.initializeLargeSpendProposal({ dao, amount }); + + const storedDao = await this.futarchy.getDao(dao); + + // fund the treasury ATA the payload pulls from + const vaultBalanceBefore = await this.getTokenBalance( + USDC, + storedDao.squadsMultisigVault, + ); + await this.mintTo( + USDC, + storedDao.squadsMultisigVault, + this.payer, + amount.toNumber(), + ); + + // the team is the payer + const teamBalanceBefore = await this.getTokenBalance( + USDC, + this.payer.publicKey, + ); + + await forceApproveSquadsProposal(this, squadsProposal); + await executeVaultTransaction(this, dao, squadsTransaction); + + const teamBalanceAfter = await this.getTokenBalance( + USDC, + this.payer.publicKey, + ); + assert.equal( + (teamBalanceAfter - teamBalanceBefore).toString(), + amount.toString(), + ); + // the vault paid out exactly what we funded + assert.equal( + ( + await this.getTokenBalance(USDC, storedDao.squadsMultisigVault) + ).toString(), + vaultBalanceBefore.toString(), + ); + }); +} diff --git a/tests/futarchy/unit/initializeMintTokensProposal.test.ts b/tests/futarchy/unit/initializeMintTokensProposal.test.ts new file mode 100644 index 00000000..1d3e8c37 --- /dev/null +++ b/tests/futarchy/unit/initializeMintTokensProposal.test.ts @@ -0,0 +1,282 @@ +import { Keypair, PublicKey, Transaction } from "@solana/web3.js"; +import { + AuthorityType, + createMintToInstruction, + createSetAuthorityInstruction, + getAssociatedTokenAddressSync, +} from "@solana/spl-token"; +import BN from "bn.js"; +import { assert } from "chai"; +import { + getMintAuthorityAddr, + MintGovernorClient, +} from "@metadaoproject/programs"; +import { BankrunProvider } from "anchor-bankrun"; +import { initializeMintGovernorWithDefaults } from "../../mintGovernor/utils.js"; +import { + assertVaultTransactionPayload, + executeVaultTransaction, + expectError, + forceApproveSquadsProposal, +} from "../../utils.js"; +import { TestContext } from "../../main.test.js"; + +async function setMintAuthority( + context: TestContext, + mint: PublicKey, + newAuthority: PublicKey | null, +) { + const tx = new Transaction().add( + createSetAuthorityInstruction( + mint, + context.payer.publicKey, + AuthorityType.MintTokens, + newAuthority, + ), + ); + [tx.recentBlockhash] = await context.banksClient.getLatestBlockhash(); + tx.feePayer = context.payer.publicKey; + tx.sign(context.payer); + await context.banksClient.processTransaction(tx); +} + +export default function suite() { + let META: PublicKey, USDC: PublicKey, dao: PublicKey; + let squadsMultisigVault: PublicKey; + let recipient: PublicKey; + let mintGovernorClient: MintGovernorClient; + + before(async function () { + mintGovernorClient = MintGovernorClient.createClient({ + provider: new BankrunProvider(this.context) as any, + }); + }); + + beforeEach(async function () { + META = await this.createMint(this.payer.publicKey, 6); + USDC = await this.createMint(this.payer.publicKey, 6); + + await this.createTokenAccount(META, this.payer.publicKey); + await this.createTokenAccount(USDC, this.payer.publicKey); + + await this.mintTo( + META, + this.payer.publicKey, + this.payer, + 100_000 * 10 ** 6, + ); + await this.mintTo( + USDC, + this.payer.publicKey, + this.payer, + 100_000 * 10 ** 6, + ); + + dao = await this.setupBasicDao({ baseMint: META, quoteMint: USDC }); + ({ squadsMultisigVault } = await this.futarchy.getDao(dao)); + recipient = Keypair.generate().publicKey; + }); + + it("bakes exactly one MintTo when the vault holds the mint authority and snapshots the kind's params", async function () { + await setMintAuthority(this, META, squadsMultisigVault); + + // 1,000 tokens + const amount = new BN(1_000_000_000); + + const { proposal, squadsProposal, squadsTransaction } = + await this.futarchy.initializeMintTokensProposal({ + dao, + amount, + recipient, + }); + + await assertVaultTransactionPayload(this, dao, squadsTransaction, [ + createMintToInstruction( + META, + getAssociatedTokenAddressSync(META, recipient, true), + squadsMultisigVault, + BigInt(amount.toString()), + ), + ]); + + const storedProposal = await this.futarchy.getProposal(proposal); + + assert.ok(storedProposal.dao.equals(dao)); + assert.ok(storedProposal.squadsProposal.equals(squadsProposal)); + assert.exists(storedProposal.state.draft); + + assert.equal( + storedProposal.action.mintTokens.amount.toString(), + amount.toString(), + ); + assert.ok(storedProposal.action.mintTokens.recipient.equals(recipient)); + assert.equal(storedProposal.durationInSeconds, 432_000); + assert.equal(storedProposal.passThresholdBps, 500); + assert.isTrue(storedProposal.councilCanBlock); + }); + + it("bakes exactly one mint_governor::mint_tokens when a governor holds the mint authority", async function () { + const { mintGovernor } = await initializeMintGovernorWithDefaults( + this.banksClient, + mintGovernorClient, + this.payer, + META, + ); + await mintGovernorClient + .transferAuthorityToGovernorIx({ + mintGovernor, + mint: META, + currentAuthority: this.payer.publicKey, + }) + .rpc(); + await mintGovernorClient + .addMintAuthorityIx({ + mintGovernor, + admin: this.payer.publicKey, + authorizedMinter: squadsMultisigVault, + maxTotal: null, + }) + .rpc(); + + // 500 tokens + const amount = new BN(500_000_000); + + const { proposal, squadsTransaction } = + await this.futarchy.initializeMintTokensProposal({ + dao, + amount, + recipient, + }); + + // the baked instruction must be byte-for-byte what a direct client call + // to mint_governor would send + const expectedIx = await mintGovernorClient + .mintTokensIx({ + mintGovernor, + mint: META, + destinationAta: getAssociatedTokenAddressSync(META, recipient, true), + authorizedMinter: squadsMultisigVault, + amount, + }) + .instruction(); + + await assertVaultTransactionPayload(this, dao, squadsTransaction, [ + expectedIx, + ]); + + const storedProposal = await this.futarchy.getProposal(proposal); + + assert.equal( + storedProposal.action.mintTokens.amount.toString(), + amount.toString(), + ); + assert.ok(storedProposal.action.mintTokens.recipient.equals(recipient)); + assert.equal(storedProposal.durationInSeconds, 432_000); + assert.equal(storedProposal.passThresholdBps, 500); + assert.isTrue(storedProposal.councilCanBlock); + }); + + it("throws error when the mint authority is neither the vault nor a governor", async function () { + const callbacks = expectError( + "UnknownMintAuthority", + "created a mint tokens proposal with an unknown mint authority", + ); + await this.futarchy + .initializeMintTokensProposal({ + dao, + amount: new BN(1_000_000), + recipient, + }) + .then(...callbacks); + }); + + it("throws error when the mint authority is burned", async function () { + await setMintAuthority(this, META, null); + + const callbacks = expectError( + "UnknownMintAuthority", + "created a mint tokens proposal for a mint with a burned authority", + ); + await this.futarchy + .initializeMintTokensProposal({ + dao, + amount: new BN(1_000_000), + recipient, + }) + .then(...callbacks); + }); + + it("the MintTo payload executes once the Squads proposal is approved", async function () { + await setMintAuthority(this, META, squadsMultisigVault); + + // 1,000 tokens + const amount = new BN(1_000_000_000); + + const { squadsProposal, squadsTransaction } = + await this.futarchy.initializeMintTokensProposal({ + dao, + amount, + recipient, + }); + + await forceApproveSquadsProposal(this, squadsProposal); + // MintTo does not create the recipient's ATA — it must exist at execution + await this.createTokenAccount(META, recipient); + await executeVaultTransaction(this, dao, squadsTransaction); + + const balance = await this.getTokenBalance(META, recipient); + assert.equal(balance.toString(), amount.toString()); + }); + + it("the mint_governor payload executes once the Squads proposal is approved", async function () { + const { mintGovernor } = await initializeMintGovernorWithDefaults( + this.banksClient, + mintGovernorClient, + this.payer, + META, + ); + await mintGovernorClient + .transferAuthorityToGovernorIx({ + mintGovernor, + mint: META, + currentAuthority: this.payer.publicKey, + }) + .rpc(); + await mintGovernorClient + .addMintAuthorityIx({ + mintGovernor, + admin: this.payer.publicKey, + authorizedMinter: squadsMultisigVault, + maxTotal: null, + }) + .rpc(); + + // 500 tokens + const amount = new BN(500_000_000); + + const { squadsProposal, squadsTransaction } = + await this.futarchy.initializeMintTokensProposal({ + dao, + amount, + recipient, + }); + + await forceApproveSquadsProposal(this, squadsProposal); + await this.createTokenAccount(META, recipient); + await executeVaultTransaction(this, dao, squadsTransaction); + + const balance = await this.getTokenBalance(META, recipient); + assert.equal(balance.toString(), amount.toString()); + + const [mintAuthority] = getMintAuthorityAddr({ + mintGovernor, + authorizedMinter: squadsMultisigVault, + }); + const mintAuthorityAccount = + await mintGovernorClient.fetchMintAuthority(mintAuthority); + assert.equal( + mintAuthorityAccount.totalMinted.toString(), + amount.toString(), + ); + }); +} diff --git a/tests/futarchy/unit/initializeProposal.test.ts b/tests/futarchy/unit/initializeProposal.test.ts index ec3bea39..bf47558f 100644 --- a/tests/futarchy/unit/initializeProposal.test.ts +++ b/tests/futarchy/unit/initializeProposal.test.ts @@ -10,7 +10,6 @@ import { TransactionMessage, } from "@solana/web3.js"; import BN from "bn.js"; -import { expectError, setOptimisticGovernanceEnabled } from "../../utils.js"; import { assert } from "chai"; import * as multisig from "@sqds/multisig"; const { Permissions, Permission } = multisig.types; @@ -71,8 +70,6 @@ export default function suite() { nonce, daoCreator: this.payer.publicKey, }); - - await setOptimisticGovernanceEnabled(this, dao, true); }); it("should initialize a proposal", async function () { @@ -161,38 +158,13 @@ export default function suite() { assert.ok(storedProposal.squadsProposal.equals(squadsProposalPda)); assert.exists(storedProposal.state.draft); + assert.isDefined(storedProposal.action.executeArbitrary); + assert.equal(storedProposal.passThresholdBps, 1000); + assert.isTrue(storedProposal.councilCanBlock); + assert.equal(storedProposal.durationInSeconds, 864_000); + // Verify the DAO proposal count was incremented 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 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/initializeSpendingLimitChangeProposal.test.ts b/tests/futarchy/unit/initializeSpendingLimitChangeProposal.test.ts new file mode 100644 index 00000000..bbafd890 --- /dev/null +++ b/tests/futarchy/unit/initializeSpendingLimitChangeProposal.test.ts @@ -0,0 +1,190 @@ +import { + getDaoAddr, + getSpendingLimitAddr, + PriceMath, +} from "@metadaoproject/programs"; +import { ComputeBudgetProgram, Keypair, PublicKey } from "@solana/web3.js"; +import BN from "bn.js"; +import { assert } from "chai"; +import * as multisig from "@sqds/multisig"; +import { + assertVaultTransactionPayload, + executeVaultTransaction, + expectError, + forceApproveSquadsProposal, +} from "../../utils.js"; + +const ONE_BUCK_PRICE = PriceMath.getAmmPrice(1, 6, 6); + +export default function suite() { + let META: PublicKey, USDC: PublicKey, dao: PublicKey; + + beforeEach(async function () { + META = await this.createMint(this.payer.publicKey, 6); + USDC = await this.createMint(this.payer.publicKey, 6); + + 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: ONE_BUCK_PRICE, + twapMaxObservationChangePerUpdate: ONE_BUCK_PRICE.divn(100), + minQuoteFutarchicLiquidity: new BN(10_000), + minBaseFutarchicLiquidity: new BN(10_000), + passThresholdBps: 300, + nonce, + initialSpendingLimit: { + amountPerMonth: new BN(10_000_000_000), // 10,000 USDC + members: [this.payer.publicKey], + }, + baseToStake: new BN(0), + teamSponsoredPassThresholdBps: 300, + teamAddress: this.payer.publicKey, + }, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), + ]) + .rpc(); + + [dao] = getDaoAddr({ nonce, daoCreator: this.payer.publicKey }); + }); + + it("bakes exactly one vault-signed set_spending_limit and snapshots the kind's params", async function () { + const config = { + amountPerMonth: new BN(25_000_000_000), // 25,000 USDC + members: [Keypair.generate().publicKey, Keypair.generate().publicKey], + }; + + const { proposal, squadsProposal, squadsTransaction } = + await this.futarchy.initializeSpendingLimitChangeProposal({ + dao, + config, + }); + + await assertVaultTransactionPayload(this, dao, squadsTransaction, [ + await this.futarchy.setSpendingLimitIx({ dao, config }).instruction(), + ]); + + const storedProposal = await this.futarchy.getProposal(proposal); + + assert.equal(storedProposal.number, 1); + assert.ok(storedProposal.dao.equals(dao)); + assert.ok(storedProposal.proposer.equals(this.payer.publicKey)); + assert.ok(storedProposal.squadsProposal.equals(squadsProposal)); + assert.exists(storedProposal.state.draft); + assert.isFalse(storedProposal.isTeamSponsored); + + assert.equal( + storedProposal.action.spendingLimitChange.config.amountPerMonth.toString(), + config.amountPerMonth.toString(), + ); + assert.deepEqual( + storedProposal.action.spendingLimitChange.config.members.map((m) => + m.toBase58(), + ), + config.members.map((m) => m.toBase58()), + ); + assert.equal(storedProposal.durationInSeconds, 432_000); + assert.equal(storedProposal.passThresholdBps, 500); + assert.isTrue(storedProposal.councilCanBlock); + + const storedDao = await this.futarchy.getDao(dao); + assert.equal(storedDao.proposalCount, 1); + }); + + it("bakes a None config verbatim when removing the limit", async function () { + const { proposal, squadsTransaction } = + await this.futarchy.initializeSpendingLimitChangeProposal({ + dao, + config: null, + }); + + await assertVaultTransactionPayload(this, dao, squadsTransaction, [ + await this.futarchy + .setSpendingLimitIx({ dao, config: null }) + .instruction(), + ]); + + const storedProposal = await this.futarchy.getProposal(proposal); + assert.isNull(storedProposal.action.spendingLimitChange.config); + }); + + it("throws error when the config has more than 10 members", async function () { + const elevenMembers = Array.from( + { length: 11 }, + () => Keypair.generate().publicKey, + ); + + const callbacks = expectError( + "TooManySpendingLimitMembers", + "created a spending limit change proposal with more than 10 members", + ); + await this.futarchy + .initializeSpendingLimitChangeProposal({ + dao, + config: { + amountPerMonth: new BN(1_000_000_000), // 1,000 USDC + members: elevenMembers, + }, + }) + .then(...callbacks); + }); + + it("the executed and synced end state matches the declaration", async function () { + const config = { + amountPerMonth: new BN(25_000_000_000), // 25,000 USDC + members: [Keypair.generate().publicKey, Keypair.generate().publicKey], + }; + + const { squadsProposal, squadsTransaction } = + await this.futarchy.initializeSpendingLimitChangeProposal({ + dao, + config, + }); + + await forceApproveSquadsProposal(this, squadsProposal); + await executeVaultTransaction(this, dao, squadsTransaction); + + let storedDao = await this.futarchy.getDao(dao); + assert.equal( + storedDao.initialSpendingLimit.amountPerMonth.toString(), + config.amountPerMonth.toString(), + ); + assert.deepEqual( + storedDao.initialSpendingLimit.members.map((m) => m.toBase58()), + config.members.map((m) => m.toBase58()), + ); + assert.isTrue(storedDao.spendingLimitDirty); + + await this.futarchy.syncSpendingLimitIx({ dao }).rpc(); + + const [spendingLimitPda] = getSpendingLimitAddr({ dao }); + const storedLimit = + await multisig.accounts.SpendingLimit.fromAccountAddress( + this.squadsConnection, + spendingLimitPda, + ); + assert.equal( + storedLimit.amount.toString(), + config.amountPerMonth.toString(), + ); + assert.equal( + storedLimit.remainingAmount.toString(), + config.amountPerMonth.toString(), + ); + // Squads stores members sorted, so compare as sets + assert.sameMembers( + storedLimit.members.map((m) => m.toBase58()), + config.members.map((m) => m.toBase58()), + ); + + storedDao = await this.futarchy.getDao(dao); + assert.isFalse(storedDao.spendingLimitDirty); + }); +} diff --git a/tests/futarchy/unit/initiateVaultSpendOptimisticProposal.test.ts b/tests/futarchy/unit/initiateVaultSpendOptimisticProposal.test.ts deleted file mode 100644 index db4dd15a..00000000 --- a/tests/futarchy/unit/initiateVaultSpendOptimisticProposal.test.ts +++ /dev/null @@ -1,1309 +0,0 @@ -import { - PERMISSIONLESS_ACCOUNT, - PriceMath, - SQUADS_PROGRAM_ID, - getDaoAddr, - MAINNET_USDC, -} from "@metadaoproject/programs"; -import { - ComputeBudgetProgram, - Keypair, - PublicKey, - SystemProgram, - Transaction, - TransactionInstruction, - TransactionMessage, -} from "@solana/web3.js"; -import BN from "bn.js"; -import { expectError, setOptimisticGovernanceEnabled } from "../../utils.js"; -import { - createAssociatedTokenAccountIdempotentInstruction, - createTransferInstruction, - getAssociatedTokenAddressSync, - TOKEN_PROGRAM_ID, -} 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; - - beforeEach(async function () { - 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(this, 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, passBaseMint } = - 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), - }) - .preInstructions([ - createAssociatedTokenAccountIdempotentInstruction( - this.payer.publicKey, - getAssociatedTokenAddressSync( - passBaseMint, - this.payer.publicKey, - true, - ), - this.payer.publicKey, - passBaseMint, - ), - ]) - .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 }), - createAssociatedTokenAccountIdempotentInstruction( - this.payer.publicKey, - getAssociatedTokenAddressSync( - passBaseMint, - this.payer.publicKey, - true, - ), - this.payer.publicKey, - passBaseMint, - ), - ]) - .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.futarchy.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(this, 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: 1n, - }) - .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.futarchy.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: 2n, - }) - .postInstructions([ - // 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, - }) - .postInstructions([ - // 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]); - }); - - describe("vault transaction validation", function () { - async function createSquadsVtAndProposal( - ctx: any, - multisigPda: PublicKey, - instructions: TransactionInstruction[], - transactionIndex: bigint, - isDraft: boolean = false, - ) { - const vault = squads.getVaultPda({ multisigPda, index: 0 })[0]; - const transactionMessage = new TransactionMessage({ - payerKey: vault, - recentBlockhash: (await ctx.banksClient.getLatestBlockhash())[0], - instructions, - }); - - const tx = new Transaction().add( - squads.instructions.vaultTransactionCreate({ - multisigPda, - transactionIndex, - creator: PERMISSIONLESS_ACCOUNT.publicKey, - rentPayer: ctx.payer.publicKey, - vaultIndex: 0, - ephemeralSigners: 0, - transactionMessage, - }), - squads.instructions.proposalCreate({ - multisigPda, - transactionIndex, - creator: PERMISSIONLESS_ACCOUNT.publicKey, - rentPayer: ctx.payer.publicKey, - isDraft, - }), - ); - - tx.recentBlockhash = (await ctx.banksClient.getLatestBlockhash())[0]; - tx.feePayer = ctx.payer.publicKey; - tx.sign(ctx.payer, PERMISSIONLESS_ACCOUNT); - - await ctx.banksClient.processTransaction(tx); - - const [squadsProposal] = squads.getProposalPda({ - multisigPda, - transactionIndex, - }); - const [squadsVaultTransaction] = squads.getTransactionPda({ - multisigPda, - index: transactionIndex, - }); - - return { squadsProposal, squadsVaultTransaction }; - } - - async function callInitiateRaw( - ctx: any, - dao: PublicKey, - amount: BN, - recipient: PublicKey, - squadsProposal: PublicKey, - squadsVaultTransaction: PublicKey, - ) { - const multisigPda = squads.getMultisigPda({ createKey: dao })[0]; - const squadsMultisigVault = squads.getVaultPda({ - multisigPda, - index: 0, - })[0]; - const squadsSpendingLimit = squads.getSpendingLimitPda({ - multisigPda, - createKey: dao, - })[0]; - const daoAccount = await ctx.futarchy.getDao(dao); - const daoQuoteVaultAccount = getAssociatedTokenAddressSync( - daoAccount.quoteMint, - squadsMultisigVault, - true, - ); - const recipientQuoteAccount = getAssociatedTokenAddressSync( - daoAccount.quoteMint, - recipient, - true, - ); - - return ctx.futarchy.futarchy.methods - .initiateVaultSpendOptimisticProposal({ amount }) - .accounts({ - squadsMultisig: multisigPda, - squadsMultisigVault, - squadsSpendingLimit, - squadsProposal, - squadsVaultTransaction, - dao, - daoQuoteVaultAccount, - proposer: ctx.payer.publicKey, - recipient, - recipientQuoteAccount, - squadsProgram: SQUADS_PROGRAM_ID, - tokenProgram: TOKEN_PROGRAM_ID, - }) - .preInstructions([ - createAssociatedTokenAccountIdempotentInstruction( - ctx.payer.publicKey, - recipientQuoteAccount, - recipient, - daoAccount.quoteMint, - ), - ]); - } - - it("fails when vault transaction has wrong transfer amount", async function () { - const multisigPda = squads.getMultisigPda({ createKey: dao })[0]; - const vault = squads.getVaultPda({ multisigPda, index: 0 })[0]; - - const wrongAmountIx = createTransferInstruction( - getAssociatedTokenAddressSync(MAINNET_USDC, vault, true), - getAssociatedTokenAddressSync(MAINNET_USDC, this.payer.publicKey), - vault, - 500n, - ); - - const { squadsProposal, squadsVaultTransaction } = - await createSquadsVtAndProposal(this, multisigPda, [wrongAmountIx], 1n); - - const callbacks = expectError( - "InvalidTransaction", - "VT amount doesn't match instruction amount", - ); - - await callInitiateRaw( - this, - dao, - new BN(1000), - this.payer.publicKey, - squadsProposal, - squadsVaultTransaction, - ) - .then((b: any) => b.signers([this.payer]).rpc()) - .then(callbacks[0], callbacks[1]); - }); - - it("fails when vault transaction has wrong recipient", async function () { - const multisigPda = squads.getMultisigPda({ createKey: dao })[0]; - const vault = squads.getVaultPda({ multisigPda, index: 0 })[0]; - const wrongRecipient = Keypair.generate().publicKey; - - // Create the wrong-recipient's ATA so the instruction references it - const wrongRecipientAta = getAssociatedTokenAddressSync( - MAINNET_USDC, - wrongRecipient, - ); - - const wrongDestIx = createTransferInstruction( - getAssociatedTokenAddressSync(MAINNET_USDC, vault, true), - wrongRecipientAta, - vault, - 1000n, - ); - - const { squadsProposal, squadsVaultTransaction } = - await createSquadsVtAndProposal(this, multisigPda, [wrongDestIx], 1n); - - const callbacks = expectError( - "InvalidTransaction", - "VT destination doesn't match recipient", - ); - - await callInitiateRaw( - this, - dao, - new BN(1000), - this.payer.publicKey, - squadsProposal, - squadsVaultTransaction, - ) - .then((b: any) => b.signers([this.payer]).rpc()) - .then(callbacks[0], callbacks[1]); - }); - - it("fails when vault transaction has wrong source", async function () { - const multisigPda = squads.getMultisigPda({ createKey: dao })[0]; - const vault = squads.getVaultPda({ multisigPda, index: 0 })[0]; - const wrongSource = Keypair.generate().publicKey; - const wrongSourceAta = getAssociatedTokenAddressSync( - MAINNET_USDC, - wrongSource, - ); - - const wrongSrcIx = createTransferInstruction( - wrongSourceAta, - getAssociatedTokenAddressSync(MAINNET_USDC, this.payer.publicKey), - vault, - 1000n, - ); - - const { squadsProposal, squadsVaultTransaction } = - await createSquadsVtAndProposal(this, multisigPda, [wrongSrcIx], 1n); - - const callbacks = expectError( - "InvalidTransaction", - "VT source doesn't match dao vault account", - ); - - await callInitiateRaw( - this, - dao, - new BN(1000), - this.payer.publicKey, - squadsProposal, - squadsVaultTransaction, - ) - .then((b: any) => b.signers([this.payer]).rpc()) - .then(callbacks[0], callbacks[1]); - }); - - it("fails when vault transaction uses wrong program", async function () { - const multisigPda = squads.getMultisigPda({ createKey: dao })[0]; - const vault = squads.getVaultPda({ multisigPda, index: 0 })[0]; - - // Build a raw instruction targeting SystemProgram with SPL Transfer-like data - const data = Buffer.alloc(9); - data[0] = 3; // SPL Transfer discriminator - data.writeBigUInt64LE(1000n, 1); - - const wrongProgramIx = new TransactionInstruction({ - programId: SystemProgram.programId, - keys: [ - { - pubkey: getAssociatedTokenAddressSync(MAINNET_USDC, vault, true), - isSigner: false, - isWritable: true, - }, - { - pubkey: getAssociatedTokenAddressSync( - MAINNET_USDC, - this.payer.publicKey, - ), - isSigner: false, - isWritable: true, - }, - { pubkey: vault, isSigner: true, isWritable: false }, - ], - data, - }); - - const { squadsProposal, squadsVaultTransaction } = - await createSquadsVtAndProposal( - this, - multisigPda, - [wrongProgramIx], - 1n, - ); - - const callbacks = expectError( - "InvalidTransaction", - "VT instruction targets wrong program", - ); - - await callInitiateRaw( - this, - dao, - new BN(1000), - this.payer.publicKey, - squadsProposal, - squadsVaultTransaction, - ) - .then((b: any) => b.signers([this.payer]).rpc()) - .then(callbacks[0], callbacks[1]); - }); - - it("fails when vault transaction has multiple instructions", async function () { - const multisigPda = squads.getMultisigPda({ createKey: dao })[0]; - const vault = squads.getVaultPda({ multisigPda, index: 0 })[0]; - - const transferIx = createTransferInstruction( - getAssociatedTokenAddressSync(MAINNET_USDC, vault, true), - getAssociatedTokenAddressSync(MAINNET_USDC, this.payer.publicKey), - vault, - 1000n, - ); - - // Two identical transfer instructions - const { squadsProposal, squadsVaultTransaction } = - await createSquadsVtAndProposal( - this, - multisigPda, - [transferIx, transferIx], - 1n, - ); - - const callbacks = expectError( - "InvalidTransaction", - "VT has multiple instructions", - ); - - await callInitiateRaw( - this, - dao, - new BN(1000), - this.payer.publicKey, - squadsProposal, - squadsVaultTransaction, - ) - .then((b: any) => b.signers([this.payer]).rpc()) - .then(callbacks[0], callbacks[1]); - }); - - it("fails when squads proposal is in Draft status", async function () { - const multisigPda = squads.getMultisigPda({ createKey: dao })[0]; - const vault = squads.getVaultPda({ multisigPda, index: 0 })[0]; - - const transferIx = createTransferInstruction( - getAssociatedTokenAddressSync(MAINNET_USDC, vault, true), - getAssociatedTokenAddressSync(MAINNET_USDC, this.payer.publicKey), - vault, - 1000n, - ); - - const { squadsProposal, squadsVaultTransaction } = - await createSquadsVtAndProposal( - this, - multisigPda, - [transferIx], - 1n, - true, // isDraft = true - ); - - const callbacks = expectError( - "InvalidSquadsProposalStatus", - "Squads proposal is in Draft status", - ); - - await callInitiateRaw( - this, - dao, - new BN(1000), - this.payer.publicKey, - squadsProposal, - squadsVaultTransaction, - ) - .then((b: any) => b.signers([this.payer]).rpc()) - .then(callbacks[0], callbacks[1]); - }); - - it("fails when vault transaction has wrong vault_index", async function () { - const multisigPda = squads.getMultisigPda({ createKey: dao })[0]; - const vault = squads.getVaultPda({ multisigPda, index: 0 })[0]; - - const transferIx = createTransferInstruction( - getAssociatedTokenAddressSync(MAINNET_USDC, vault, true), - getAssociatedTokenAddressSync(MAINNET_USDC, this.payer.publicKey), - vault, - 1000n, - ); - - const { squadsProposal, squadsVaultTransaction } = - await createSquadsVtAndProposal(this, multisigPda, [transferIx], 1n); - - const vtAccount = - await squads.accounts.VaultTransaction.fromAccountAddress( - this.squadsConnection, - squadsVaultTransaction, - ); - - const modifiedVt = squads.accounts.VaultTransaction.fromArgs({ - ...vtAccount, - vaultIndex: 1, - }); - const [serialized] = modifiedVt.serialize(); - - const vtBanksAccount = await this.banksClient.getAccount( - squadsVaultTransaction, - ); - vtBanksAccount.data = serialized; - this.context.setAccount(squadsVaultTransaction, vtBanksAccount); - - const callbacks = expectError( - "InvalidTransaction", - "VT has wrong vault_index", - ); - - await callInitiateRaw( - this, - dao, - new BN(1000), - this.payer.publicKey, - squadsProposal, - squadsVaultTransaction, - ) - .then((b: any) => b.signers([this.payer]).rpc()) - .then(callbacks[0], callbacks[1]); - }); - - it("fails when vault transaction has ephemeral signers", async function () { - const multisigPda = squads.getMultisigPda({ createKey: dao })[0]; - const vault = squads.getVaultPda({ multisigPda, index: 0 })[0]; - - const transferIx = createTransferInstruction( - getAssociatedTokenAddressSync(MAINNET_USDC, vault, true), - getAssociatedTokenAddressSync(MAINNET_USDC, this.payer.publicKey), - vault, - 1000n, - ); - - const { squadsProposal, squadsVaultTransaction } = - await createSquadsVtAndProposal(this, multisigPda, [transferIx], 1n); - - const vtAccount = - await squads.accounts.VaultTransaction.fromAccountAddress( - this.squadsConnection, - squadsVaultTransaction, - ); - - const modifiedVt = squads.accounts.VaultTransaction.fromArgs({ - ...vtAccount, - ephemeralSignerBumps: Uint8Array.from([255]), - }); - const [serialized] = modifiedVt.serialize(); - - const vtBanksAccount = await this.banksClient.getAccount( - squadsVaultTransaction, - ); - vtBanksAccount.data = serialized; - this.context.setAccount(squadsVaultTransaction, vtBanksAccount); - - const callbacks = expectError( - "InvalidTransaction", - "VT has ephemeral signers", - ); - - await callInitiateRaw( - this, - dao, - new BN(1000), - this.payer.publicKey, - squadsProposal, - squadsVaultTransaction, - ) - .then((b: any) => b.signers([this.payer]).rpc()) - .then(callbacks[0], callbacks[1]); - }); - - it("fails when message has wrong num_signers", async function () { - const multisigPda = squads.getMultisigPda({ createKey: dao })[0]; - const vault = squads.getVaultPda({ multisigPda, index: 0 })[0]; - - const transferIx = createTransferInstruction( - getAssociatedTokenAddressSync(MAINNET_USDC, vault, true), - getAssociatedTokenAddressSync(MAINNET_USDC, this.payer.publicKey), - vault, - 1000n, - ); - - const { squadsProposal, squadsVaultTransaction } = - await createSquadsVtAndProposal(this, multisigPda, [transferIx], 1n); - - const vtAccount = - await squads.accounts.VaultTransaction.fromAccountAddress( - this.squadsConnection, - squadsVaultTransaction, - ); - - const modifiedVt = squads.accounts.VaultTransaction.fromArgs({ - ...vtAccount, - message: { - ...vtAccount.message, - numSigners: 2, - }, - }); - const [serialized] = modifiedVt.serialize(); - - const vtBanksAccount = await this.banksClient.getAccount( - squadsVaultTransaction, - ); - vtBanksAccount.data = serialized; - this.context.setAccount(squadsVaultTransaction, vtBanksAccount); - - const callbacks = expectError( - "InvalidTransaction", - "Message has wrong num_signers", - ); - - await callInitiateRaw( - this, - dao, - new BN(1000), - this.payer.publicKey, - squadsProposal, - squadsVaultTransaction, - ) - .then((b: any) => b.signers([this.payer]).rpc()) - .then(callbacks[0], callbacks[1]); - }); - - it("fails when first signer is not the vault", async function () { - const multisigPda = squads.getMultisigPda({ createKey: dao })[0]; - const vault = squads.getVaultPda({ multisigPda, index: 0 })[0]; - - const transferIx = createTransferInstruction( - getAssociatedTokenAddressSync(MAINNET_USDC, vault, true), - getAssociatedTokenAddressSync(MAINNET_USDC, this.payer.publicKey), - vault, - 1000n, - ); - - const { squadsProposal, squadsVaultTransaction } = - await createSquadsVtAndProposal(this, multisigPda, [transferIx], 1n); - - const vtAccount = - await squads.accounts.VaultTransaction.fromAccountAddress( - this.squadsConnection, - squadsVaultTransaction, - ); - - const tamperedKeys = [...vtAccount.message.accountKeys]; - tamperedKeys[0] = Keypair.generate().publicKey; - - const modifiedVt = squads.accounts.VaultTransaction.fromArgs({ - ...vtAccount, - message: { - ...vtAccount.message, - accountKeys: tamperedKeys, - }, - }); - const [serialized] = modifiedVt.serialize(); - - const vtBanksAccount = await this.banksClient.getAccount( - squadsVaultTransaction, - ); - vtBanksAccount.data = serialized; - this.context.setAccount(squadsVaultTransaction, vtBanksAccount); - - const callbacks = expectError( - "InvalidTransaction", - "First signer is not the vault", - ); - - await callInitiateRaw( - this, - dao, - new BN(1000), - this.payer.publicKey, - squadsProposal, - squadsVaultTransaction, - ) - .then((b: any) => b.signers([this.payer]).rpc()) - .then(callbacks[0], callbacks[1]); - }); - - it("fails when message has wrong num_writable_signers", async function () { - const multisigPda = squads.getMultisigPda({ createKey: dao })[0]; - const vault = squads.getVaultPda({ multisigPda, index: 0 })[0]; - - const transferIx = createTransferInstruction( - getAssociatedTokenAddressSync(MAINNET_USDC, vault, true), - getAssociatedTokenAddressSync(MAINNET_USDC, this.payer.publicKey), - vault, - 1000n, - ); - - const { squadsProposal, squadsVaultTransaction } = - await createSquadsVtAndProposal(this, multisigPda, [transferIx], 1n); - - const vtAccount = - await squads.accounts.VaultTransaction.fromAccountAddress( - this.squadsConnection, - squadsVaultTransaction, - ); - - const modifiedVt = squads.accounts.VaultTransaction.fromArgs({ - ...vtAccount, - message: { - ...vtAccount.message, - numWritableSigners: 2, - }, - }); - const [serialized] = modifiedVt.serialize(); - - const vtBanksAccount = await this.banksClient.getAccount( - squadsVaultTransaction, - ); - vtBanksAccount.data = serialized; - this.context.setAccount(squadsVaultTransaction, vtBanksAccount); - - const callbacks = expectError( - "InvalidTransaction", - "Message has too many writable signers", - ); - - await callInitiateRaw( - this, - dao, - new BN(1000), - this.payer.publicKey, - squadsProposal, - squadsVaultTransaction, - ) - .then((b: any) => b.signers([this.payer]).rpc()) - .then(callbacks[0], callbacks[1]); - }); - - it("fails when message has wrong num_writable_non_signers", async function () { - const multisigPda = squads.getMultisigPda({ createKey: dao })[0]; - const vault = squads.getVaultPda({ multisigPda, index: 0 })[0]; - - const transferIx = createTransferInstruction( - getAssociatedTokenAddressSync(MAINNET_USDC, vault, true), - getAssociatedTokenAddressSync(MAINNET_USDC, this.payer.publicKey), - vault, - 1000n, - ); - - const { squadsProposal, squadsVaultTransaction } = - await createSquadsVtAndProposal(this, multisigPda, [transferIx], 1n); - - const vtAccount = - await squads.accounts.VaultTransaction.fromAccountAddress( - this.squadsConnection, - squadsVaultTransaction, - ); - - const modifiedVt = squads.accounts.VaultTransaction.fromArgs({ - ...vtAccount, - message: { - ...vtAccount.message, - numWritableNonSigners: 1, - }, - }); - const [serialized] = modifiedVt.serialize(); - - const vtBanksAccount = await this.banksClient.getAccount( - squadsVaultTransaction, - ); - vtBanksAccount.data = serialized; - this.context.setAccount(squadsVaultTransaction, vtBanksAccount); - - const callbacks = expectError( - "InvalidTransaction", - "Message has wrong numWritableNonSigners", - ); - - await callInitiateRaw( - this, - dao, - new BN(1000), - this.payer.publicKey, - squadsProposal, - squadsVaultTransaction, - ) - .then((b: any) => b.signers([this.payer]).rpc()) - .then(callbacks[0], callbacks[1]); - }); - - it("fails when message has address table lookups", async function () { - const multisigPda = squads.getMultisigPda({ createKey: dao })[0]; - const vault = squads.getVaultPda({ multisigPda, index: 0 })[0]; - - const transferIx = createTransferInstruction( - getAssociatedTokenAddressSync(MAINNET_USDC, vault, true), - getAssociatedTokenAddressSync(MAINNET_USDC, this.payer.publicKey), - vault, - 1000n, - ); - - const { squadsProposal, squadsVaultTransaction } = - await createSquadsVtAndProposal(this, multisigPda, [transferIx], 1n); - - const vtAccount = - await squads.accounts.VaultTransaction.fromAccountAddress( - this.squadsConnection, - squadsVaultTransaction, - ); - - const modifiedVt = squads.accounts.VaultTransaction.fromArgs({ - ...vtAccount, - message: { - ...vtAccount.message, - addressTableLookups: [ - { - accountKey: Keypair.generate().publicKey, - writableIndexes: Uint8Array.from([0]), - readonlyIndexes: Uint8Array.from([1]), - }, - ], - }, - }); - const [serialized] = modifiedVt.serialize(); - - const vtBanksAccount = await this.banksClient.getAccount( - squadsVaultTransaction, - ); - vtBanksAccount.data = serialized; - this.context.setAccount(squadsVaultTransaction, vtBanksAccount); - - const callbacks = expectError( - "InvalidTransaction", - "Message has address table lookups", - ); - - await callInitiateRaw( - this, - dao, - new BN(1000), - this.payer.publicKey, - squadsProposal, - squadsVaultTransaction, - ) - .then((b: any) => b.signers([this.payer]).rpc()) - .then(callbacks[0], callbacks[1]); - }); - - it("fails when squads proposal has approvals", async function () { - const multisigPda = squads.getMultisigPda({ createKey: dao })[0]; - const vault = squads.getVaultPda({ multisigPda, index: 0 })[0]; - - const transferIx = createTransferInstruction( - getAssociatedTokenAddressSync(MAINNET_USDC, vault, true), - getAssociatedTokenAddressSync(MAINNET_USDC, this.payer.publicKey), - vault, - 1000n, - ); - - const { squadsProposal, squadsVaultTransaction } = - await createSquadsVtAndProposal(this, multisigPda, [transferIx], 1n); - - const proposalAccount = await squads.accounts.Proposal.fromAccountAddress( - this.squadsConnection, - squadsProposal, - ); - - const modifiedProposal = squads.accounts.Proposal.fromArgs({ - ...proposalAccount, - approved: [Keypair.generate().publicKey], - }); - const [serialized] = modifiedProposal.serialize(); - - const proposalBanksAccount = - await this.banksClient.getAccount(squadsProposal); - proposalBanksAccount.data = serialized; - this.context.setAccount(squadsProposal, proposalBanksAccount); - - const callbacks = expectError( - "InvalidTransaction", - "Proposal has approvals", - ); - - await callInitiateRaw( - this, - dao, - new BN(1000), - this.payer.publicKey, - squadsProposal, - squadsVaultTransaction, - ) - .then((b: any) => b.signers([this.payer]).rpc()) - .then(callbacks[0], callbacks[1]); - }); - - it("fails when squads proposal has rejections", async function () { - const multisigPda = squads.getMultisigPda({ createKey: dao })[0]; - const vault = squads.getVaultPda({ multisigPda, index: 0 })[0]; - - const transferIx = createTransferInstruction( - getAssociatedTokenAddressSync(MAINNET_USDC, vault, true), - getAssociatedTokenAddressSync(MAINNET_USDC, this.payer.publicKey), - vault, - 1000n, - ); - - const { squadsProposal, squadsVaultTransaction } = - await createSquadsVtAndProposal(this, multisigPda, [transferIx], 1n); - - const proposalAccount = await squads.accounts.Proposal.fromAccountAddress( - this.squadsConnection, - squadsProposal, - ); - - const modifiedProposal = squads.accounts.Proposal.fromArgs({ - ...proposalAccount, - rejected: [Keypair.generate().publicKey], - }); - const [serialized] = modifiedProposal.serialize(); - - const proposalBanksAccount = - await this.banksClient.getAccount(squadsProposal); - proposalBanksAccount.data = serialized; - this.context.setAccount(squadsProposal, proposalBanksAccount); - - const callbacks = expectError( - "InvalidTransaction", - "Proposal has rejections", - ); - - await callInitiateRaw( - this, - dao, - new BN(1000), - this.payer.publicKey, - squadsProposal, - squadsVaultTransaction, - ) - .then((b: any) => b.signers([this.payer]).rpc()) - .then(callbacks[0], callbacks[1]); - }); - - it("fails when squads proposal is stale", async function () { - const multisigPda = squads.getMultisigPda({ createKey: dao })[0]; - const vault = squads.getVaultPda({ multisigPda, index: 0 })[0]; - - const transferIx = createTransferInstruction( - getAssociatedTokenAddressSync(MAINNET_USDC, vault, true), - getAssociatedTokenAddressSync(MAINNET_USDC, this.payer.publicKey), - vault, - 1000n, - ); - - // Create the squads proposal while stale_transaction_index is 0 - const { squadsProposal, squadsVaultTransaction } = - await createSquadsVtAndProposal(this, multisigPda, [transferIx], 1n); - - // Now mutate the multisig to set staleTransactionIndex = 1 - const multisigAccount = await squads.accounts.Multisig.fromAccountAddress( - this.squadsConnection, - multisigPda, - ); - - const modifiedMultisig = squads.accounts.Multisig.fromArgs({ - ...multisigAccount, - staleTransactionIndex: new BN(1), - }); - const [serialized] = modifiedMultisig.serialize(); - - const multisigBanksAccount = - await this.banksClient.getAccount(multisigPda); - multisigBanksAccount.data.set(serialized, 0); - this.context.setAccount(multisigPda, multisigBanksAccount); - - const callbacks = expectError( - "RequireGtViolated", - "Squads proposal is stale", - ); - - await callInitiateRaw( - this, - dao, - new BN(1000), - this.payer.publicKey, - squadsProposal, - squadsVaultTransaction, - ) - .then((b: any) => b.signers([this.payer]).rpc()) - .then(callbacks[0], callbacks[1]); - }); - }); -} diff --git a/tests/futarchy/unit/launchProposal.test.ts b/tests/futarchy/unit/launchProposal.test.ts index a2a06198..0c53f51f 100644 --- a/tests/futarchy/unit/launchProposal.test.ts +++ b/tests/futarchy/unit/launchProposal.test.ts @@ -2,7 +2,6 @@ import { PERMISSIONLESS_ACCOUNT, PriceMath, getDaoAddr, - getProposalAddrV2, } from "@metadaoproject/programs"; import { ComputeBudgetProgram, @@ -12,18 +11,14 @@ import { TransactionMessage, } from "@solana/web3.js"; import BN from "bn.js"; -import { expectError, setOptimisticGovernanceEnabled } from "../../utils.js"; +import { expectError } from "../../utils.js"; import { assert } from "chai"; import * as multisig from "@sqds/multisig"; const THOUSAND_BUCK_PRICE = PriceMath.getAmmPrice(1000, 6, 6); export default function suite() { - let META: PublicKey, - USDC: PublicKey, - dao: PublicKey, - spendingLimit: BN, - transferAmount: bigint; + let META: PublicKey, USDC: PublicKey, dao: PublicKey, spendingLimit: BN; beforeEach(async function () { META = await this.createMint(this.payer.publicKey, 6); @@ -337,10 +332,7 @@ export default function suite() { ); }); - it("sets proposal duration_in_seconds to DAO's current seconds_per_proposal on launch", async function () { - const THREE_DAYS = 60 * 60 * 24 * 3; // 259200 - const FIVE_DAYS = 60 * 60 * 24 * 5; // 432000 - + it("keeps the create-time duration snapshot on launch", async function () { // Create DAO with secondsPerProposal = 3 days const dao = await createDaoWithStakeThreshold( this, @@ -378,21 +370,10 @@ export default function suite() { }) .rpc(); - // Verify proposal has the original duration (3 days) + // Create-time snapshot comes from ExecuteArbitrary's params (10 days), + // not the DAO's 3-day secondsPerProposal const proposalBefore = await this.futarchy.getProposal(proposal); - assert.equal(proposalBefore.durationInSeconds, THREE_DAYS); - - // Directly modify the DAO's secondsPerProposal to 5 days - const daoAccountInfo = await this.banksClient.getAccount(dao); - const coder = this.futarchy.futarchy.coder.accounts; - const daoData = coder.decode("dao", Buffer.from(daoAccountInfo.data)); - daoData.secondsPerProposal = FIVE_DAYS; - const encodedData = await coder.encode("dao", daoData); - // Preserve original account size (may be larger due to InitSpace allocation) - const newData = new Uint8Array(daoAccountInfo.data.length); - newData.set(encodedData, 0); - daoAccountInfo.data = newData; - this.context.setAccount(dao, daoAccountInfo); + assert.equal(proposalBefore.durationInSeconds, 864_000); // Launch the proposal await this.futarchy @@ -405,9 +386,10 @@ export default function suite() { }) .rpc(); - // Verify proposal picked up the new DAO duration + // The snapshot is authoritative — launch must not overwrite it with the + // DAO's seconds_per_proposal const storedProposal = await this.futarchy.getProposal(proposal); - assert.equal(storedProposal.durationInSeconds, FIVE_DAYS); + assert.equal(storedProposal.durationInSeconds, 864_000); }); it("fails for non-team-sponsored with insufficient stake", async function () { @@ -468,17 +450,15 @@ export default function suite() { .then(callbacks[0], callbacks[1]); }); - it("can challenge an optimistic proposal by launching a new futarchy proposal using the same squads proposal", async function () { - const stakeThreshold = new BN(100 * 10 ** 6); // 100 tokens + it("fails to launch an unsponsored large_spend, launches once sponsored", async function () { const dao = await createDaoWithStakeThreshold( this, META, USDC, - stakeThreshold, + new BN(0), this.payer, ); - // Add liquidity so proposal can be launched await this.futarchy .provideLiquidityIx({ dao, @@ -495,50 +475,108 @@ export default function suite() { ]) .rpc(); - // Mint DAO tokens to payer's account - await this.mintTo( - META, - this.payer.publicKey, - this.payer, - 10_000_000 * 10 ** 6, - ); + const { proposal, squadsProposal } = + await this.futarchy.initializeLargeSpendProposal({ + dao, + amount: new BN(10_000), + }); - let daoAccount = await this.futarchy.getDao(dao); + const callbacks = expectError( + "ProposalNotTeamSponsored", + "launched an unsponsored large spend proposal", + ); - await this.createTokenAccount(USDC, daoAccount.squadsMultisigVault); + await this.futarchy + .launchProposalIx({ + proposal, + dao, + baseMint: META, + quoteMint: USDC, + squadsProposal, + }) + .rpc() + .then(callbacks[0], callbacks[1]); - await setOptimisticGovernanceEnabled(this, dao, true); + await this.futarchy + .sponsorProposalIx({ + proposal, + dao, + teamAddress: this.payer.publicKey, + }) + .rpc(); await this.futarchy - .initiateVaultSpendOptimisticProposalIx({ + .launchProposalIx({ + proposal, dao, - amount: new BN(transferAmount), - recipient: this.payer.publicKey, - transactionIndex: 1n, + baseMint: META, quoteMint: USDC, + squadsProposal, }) - .signers([this.payer, PERMISSIONLESS_ACCOUNT]) + .postInstructions([ + ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), + ]) .rpc(); - daoAccount = await this.futarchy.getDao(dao); + const storedProposal = await this.futarchy.getProposal(proposal); + assert.exists(storedProposal.state.pending); + }); - assert.exists(daoAccount.optimisticProposal); + it("fails to launch an unsponsored spending_limit_change, launches once sponsored", async function () { + const dao = await createDaoWithStakeThreshold( + this, + META, + USDC, + new BN(0), + this.payer, + ); - const [squadsProposal] = multisig.getProposalPda({ - multisigPda: daoAccount.squadsMultisig, - transactionIndex: 1n, - }); + await this.futarchy + .provideLiquidityIx({ + dao, + baseMint: META, + quoteMint: USDC, + quoteAmount: new BN(100_000 * 10 ** 6), + maxBaseAmount: new BN(100_000 * 10 ** 6), + minLiquidity: new BN(0), + positionAuthority: this.payer.publicKey, + liquidityProvider: this.payer.publicKey, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), + ]) + .rpc(); - await this.futarchy.initializeProposal(dao, squadsProposal); + const { proposal, squadsProposal } = + await this.futarchy.initializeSpendingLimitChangeProposal({ + dao, + config: { + amountPerMonth: new BN(20_000), + members: [this.payer.publicKey], + }, + }); - const [proposal] = getProposalAddrV2({ squadsProposal }); + const callbacks = expectError( + "ProposalNotTeamSponsored", + "launched an unsponsored spending limit change proposal", + ); await this.futarchy - .stakeToProposalIx({ - amount: new BN(1_000_000 * 10 ** 6), + .launchProposalIx({ proposal, dao, baseMint: META, + quoteMint: USDC, + squadsProposal, + }) + .rpc() + .then(callbacks[0], callbacks[1]); + + await this.futarchy + .sponsorProposalIx({ + proposal, + dao, + teamAddress: this.payer.publicKey, }) .rpc(); @@ -550,31 +588,24 @@ export default function suite() { quoteMint: USDC, squadsProposal, }) + .postInstructions([ + ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), + ]) .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(), - ); + const storedProposal = await this.futarchy.getProposal(proposal); + assert.exists(storedProposal.state.pending); }); - it("can't challenge an optimistic proposal if it has already passed due to age", async function () { - const stakeThreshold = new BN(100 * 10 ** 6); // 100 tokens + it("fails to launch a hostile takeover during its cooldown, launches once it elapses", async function () { const dao = await createDaoWithStakeThreshold( this, META, USDC, - stakeThreshold, + new BN(0), this.payer, ); - // Add liquidity so proposal can be launched await this.futarchy .provideLiquidityIx({ dao, @@ -591,70 +622,81 @@ export default function suite() { ]) .rpc(); - // Mint DAO tokens to payer's account - await this.mintTo( - META, - this.payer.publicKey, - this.payer, - 10_000_000 * 10 ** 6, - ); - - let daoAccount = await this.futarchy.getDao(dao); - - await this.createTokenAccount(USDC, daoAccount.squadsMultisigVault); + // Fail a first takeover so the DAO stamps last_failed_takeover_at + const first = await this.futarchy.initializeHostileTakeoverProposal({ + dao, + newTeamAddress: Keypair.generate().publicKey, + spendingLimitAction: { keep: {} }, + }); - await setOptimisticGovernanceEnabled(this, dao, true); + await this.futarchy + .launchProposalIx({ + proposal: first.proposal, + dao, + baseMint: META, + quoteMint: USDC, + squadsProposal: first.squadsProposal, + }) + .rpc(); + // One swap after the TWAP start delay records an observation in both + // markets; the equal TWAPs it leaves can't clear the +10% threshold + await this.advanceBySeconds(60 * 60 * 24 + 60); await this.futarchy - .initiateVaultSpendOptimisticProposalIx({ + .spotSwapIx({ dao, - amount: new BN(transferAmount), - recipient: this.payer.publicKey, - transactionIndex: 1n, + baseMint: META, quoteMint: USDC, + swapType: "buy", + inputAmount: new BN(1_000), }) - .signers([this.payer, PERMISSIONLESS_ACCOUNT]) .rpc(); - daoAccount = await this.futarchy.getDao(dao); + await this.advanceBySeconds(60 * 60 * 24 * 20); + await this.futarchy.finalizeProposal(first.proposal); - assert.exists(daoAccount.optimisticProposal); + const failedProposal = await this.futarchy.getProposal(first.proposal); + assert.exists(failedProposal.state.failed); - const [squadsProposal] = multisig.getProposalPda({ - multisigPda: daoAccount.squadsMultisig, - transactionIndex: 1n, + const second = await this.futarchy.initializeHostileTakeoverProposal({ + dao, + newTeamAddress: Keypair.generate().publicKey, + spendingLimitAction: { keep: {} }, }); - // 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 }); + const callbacks = expectError( + "HostileCooldownActive", + "launched a hostile takeover during its cooldown", + ); await this.futarchy - .stakeToProposalIx({ - amount: new BN(1_000_000 * 10 ** 6), - proposal, + .launchProposalIx({ + proposal: second.proposal, dao, baseMint: META, + quoteMint: USDC, + squadsProposal: second.squadsProposal, }) - .rpc(); + .rpc() + .then(callbacks[0], callbacks[1]); - const callbacks = expectError( - "OptimisticProposalAlreadyPassed", - "Optimistic proposal has already passed", - ); + // The 20-day cooldown gate is inclusive of its final second + await this.advanceBySeconds(60 * 60 * 24 * 20); await this.futarchy .launchProposalIx({ - proposal, + proposal: second.proposal, dao, baseMint: META, quoteMint: USDC, - squadsProposal, + squadsProposal: second.squadsProposal, }) - .rpc() - .then(callbacks[0], callbacks[1]); + .postInstructions([ + ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), + ]) + .rpc(); + + const storedProposal = await this.futarchy.getProposal(second.proposal); + assert.exists(storedProposal.state.pending); }); } diff --git a/tests/futarchy/unit/liquidatedGuards.test.ts b/tests/futarchy/unit/liquidatedGuards.test.ts new file mode 100644 index 00000000..0ac93fa7 --- /dev/null +++ b/tests/futarchy/unit/liquidatedGuards.test.ts @@ -0,0 +1,614 @@ +import { ComputeBudgetProgram, Keypair, PublicKey } from "@solana/web3.js"; +import { assert } from "chai"; +import * as multisig from "@sqds/multisig"; +import { MEMO_PROGRAM_ID } from "@solana/spl-memo"; +import { + getAssociatedTokenAddressSync, + TOKEN_PROGRAM_ID, +} from "@solana/spl-token"; +import { + FUTARCHY_V0_6_PROGRAM_ID, + METADAO_MULTISIG_VAULT, + getDaoAddr, + getEventAuthorityAddr, + getProposalAddrsForTransactionIndex, + getSpendingLimitAddr, + PERMISSIONLESS_ACCOUNT, +} from "@metadaoproject/programs"; +import BN from "bn.js"; +import { + executeVaultTransaction, + expectError, + forceApproveSquadsProposal, + passProposal, + THOUSAND_BUCK_PRICE, +} from "../../utils.js"; +import { TestContext } from "../../main.test.js"; + +// Every blocked instruction refuses on a liquidated DAO; every allowed one +// still works. Not covered here because a liquidated DAO can't reach them: +// - finalize_proposal: a market can never be live once the DAO is liquidated +// (launch is guarded and apply_liquidation requires a spot pool), so the +// gap-market interleaving it exists for is pinned by the packed +// finalize + execute + sync case in applyLiquidation.test.ts +// - the liquidator path: liquidatorPath.test.ts runs the estate cycle +// - collect_meteora_damm_fees: reads no liquidation state (its own suite +// covers the mechanics; setup needs a full launchpad DAMM pool) +export default function suite() { + let META: PublicKey, + USDC: PublicKey, + dao: PublicKey, + vault: PublicKey, + draftProposal: PublicKey, + draftSquadsProposal: PublicKey, + liquidationProposal: PublicKey; + + // A single liquidated DAO serves every case: blocked instructions are pure + // refusals, and the allowed ones each touch disjoint state (the sync flag, + // the LP position, the stake, the fee balances), so one `before` avoids + // re-running the whole market flow per test. + before(async function () { + META = await this.createMint(this.payer.publicKey, 6); + USDC = await this.createMint(this.payer.publicKey, 6); + + await this.createTokenAccount(META, this.payer.publicKey); + await this.createTokenAccount(USDC, this.payer.publicKey); + + await this.mintTo( + META, + this.payer.publicKey, + this.payer, + 1_000 * 1_000_000, + ); + await this.mintTo( + USDC, + this.payer.publicKey, + this.payer, + 500_000 * 1_000_000, + ); + + 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, + // 10% per update: TWAPs converge fast enough that the pumped pass + // market clears HostileLiquidate's +25% + twapMaxObservationChangePerUpdate: THOUSAND_BUCK_PRICE.divn(10), + minQuoteFutarchicLiquidity: new BN(10_000), + minBaseFutarchicLiquidity: new BN(10_000), + passThresholdBps: 300, + nonce, + initialSpendingLimit: { + amountPerMonth: new BN(10_000_000_000), // 10,000 USDC + members: [this.payer.publicKey], + }, + baseToStake: new BN(0), + teamSponsoredPassThresholdBps: 300, + teamAddress: this.payer.publicKey, + }, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), + ]) + .rpc(); + + [dao] = getDaoAddr({ nonce, daoCreator: this.payer.publicKey }); + + const storedDao = await this.futarchy.getDao(dao); + vault = storedDao.squadsMultisigVault; + + // The baked apply_liquidation payload requires the vault's ATAs to exist + await this.createTokenAccount(META, vault); + await this.createTokenAccount(USDC, vault); + + // Destination ATAs for the post-liquidation collect_fees case + await this.createTokenAccount(META, METADAO_MULTISIG_VAULT); + await this.createTokenAccount(USDC, METADAO_MULTISIG_VAULT); + + // The third-party LP position that must stay withdrawable + await this.futarchy + .provideLiquidityIx({ + dao, + baseMint: META, + quoteMint: USDC, + quoteAmount: new BN(100_000 * 1_000_000), // 100,000 USDC + maxBaseAmount: new BN(100 * 1_000_000), // 100 META + minLiquidity: new BN(0), + positionAuthority: this.payer.publicKey, + liquidityProvider: this.payer.publicKey, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), + ]) + .rpc(); + + // Accrue protocol fees for the post-liquidation collect_fees case + await this.futarchy + .spotSwapIx({ + dao, + baseMint: META, + quoteMint: USDC, + swapType: "buy", + inputAmount: new BN(1_000 * 1_000_000), + }) + .rpc(); + + // A pre-liquidation draft with stake: staking more must refuse afterward, + // unstaking must still work + ({ squadsProposal: draftSquadsProposal } = await createSquadsVaultTx(this, [ + { + programId: MEMO_PROGRAM_ID, + keys: [], + data: Buffer.from("draft proposal"), + }, + ])); + draftProposal = await this.futarchy.initializeProposal( + dao, + draftSquadsProposal, + ); + + await this.futarchy + .stakeToProposalIx({ + proposal: draftProposal, + dao, + baseMint: META, + amount: new BN(100 * 1_000_000), + }) + .rpc(); + + const { proposal, squadsProposal, squadsTransaction } = + await this.futarchy.initializeHostileLiquidateProposal({ + dao, + liquidator: Keypair.generate().publicKey, + }); + liquidationProposal = proposal; + + await this.futarchy + .launchProposalIx({ + proposal, + dao, + baseMint: META, + quoteMint: USDC, + squadsProposal, + }) + .rpc(); + + await passProposal(this, { + dao, + proposal, + baseMint: META, + quoteMint: USDC, + cranks: 50, + }); + + await executeVaultTransaction(this, dao, squadsTransaction); + + const liquidatedDao = await this.futarchy.getDao(dao); + assert.isNotNull(liquidatedDao.liquidator); + }); + + // Creates (but never executes) a Squads vault transaction + proposal at the + // live next transaction index, so each caller gets fresh PDAs. + const createSquadsVaultTx = async function ( + context: TestContext, + instructions: any[], + ) { + const multisigPda = multisig.getMultisigPda({ createKey: dao })[0]; + const multisigAccount = await multisig.accounts.Multisig.fromAccountAddress( + context.squadsConnection, + multisigPda, + ); + const transactionIndex = + BigInt(multisigAccount.transactionIndex.toString()) + 1n; + + const { tx } = context.futarchy.squadsProposalCreateTx({ + dao, + instructions, + transactionIndex, + }); + tx.recentBlockhash = (await context.banksClient.getLatestBlockhash())[0]; + tx.feePayer = context.payer.publicKey; + tx.sign(context.payer, PERMISSIONLESS_ACCOUNT); + await context.banksClient.processTransaction(tx); + + return getProposalAddrsForTransactionIndex({ dao, transactionIndex }); + }; + + it("refuses initialize_proposal", async function () { + const { squadsProposal } = await createSquadsVaultTx(this, [ + { + programId: MEMO_PROGRAM_ID, + keys: [], + data: Buffer.from("post-liquidation proposal"), + }, + ]); + + const callbacks = expectError( + "DaoLiquidated", + "initialize_proposal should refuse on a liquidated DAO", + ); + + await this.futarchy + .initializeProposal(dao, squadsProposal) + .then(callbacks[0], callbacks[1]); + }); + + it("refuses initialize_large_spend_proposal", async function () { + // Each failed typed create leaves its question + vaults behind at the + // unchanged next transaction index, so bump the index first to give every + // attempt fresh PDAs + await createSquadsVaultTx(this, [ + { programId: MEMO_PROGRAM_ID, keys: [], data: Buffer.from("bump") }, + ]); + + const callbacks = expectError( + "DaoLiquidated", + "initialize_large_spend_proposal should refuse on a liquidated DAO", + ); + + await this.futarchy + .initializeLargeSpendProposal({ + dao, + amount: new BN(1_000 * 1_000_000), + }) + .then(callbacks[0], callbacks[1]); + }); + + it("refuses initialize_mint_tokens_proposal", async function () { + await createSquadsVaultTx(this, [ + { programId: MEMO_PROGRAM_ID, keys: [], data: Buffer.from("bump") }, + ]); + + const callbacks = expectError( + "DaoLiquidated", + "initialize_mint_tokens_proposal should refuse on a liquidated DAO", + ); + + await this.futarchy + .initializeMintTokensProposal({ + dao, + amount: new BN(100 * 1_000_000), + recipient: this.payer.publicKey, + }) + .then(callbacks[0], callbacks[1]); + }); + + it("refuses initialize_spending_limit_change_proposal", async function () { + await createSquadsVaultTx(this, [ + { programId: MEMO_PROGRAM_ID, keys: [], data: Buffer.from("bump") }, + ]); + + const callbacks = expectError( + "DaoLiquidated", + "initialize_spending_limit_change_proposal should refuse on a liquidated DAO", + ); + + await this.futarchy + .initializeSpendingLimitChangeProposal({ + dao, + config: null, + }) + .then(callbacks[0], callbacks[1]); + }); + + it("refuses initialize_hostile_takeover_proposal", async function () { + await createSquadsVaultTx(this, [ + { programId: MEMO_PROGRAM_ID, keys: [], data: Buffer.from("bump") }, + ]); + + const callbacks = expectError( + "DaoLiquidated", + "initialize_hostile_takeover_proposal should refuse on a liquidated DAO", + ); + + await this.futarchy + .initializeHostileTakeoverProposal({ + dao, + newTeamAddress: Keypair.generate().publicKey, + spendingLimitAction: { keep: {} }, + }) + .then(callbacks[0], callbacks[1]); + }); + + it("refuses initialize_hostile_liquidate_proposal", async function () { + await createSquadsVaultTx(this, [ + { programId: MEMO_PROGRAM_ID, keys: [], data: Buffer.from("bump") }, + ]); + + const callbacks = expectError( + "DaoLiquidated", + "initialize_hostile_liquidate_proposal should refuse on a liquidated DAO", + ); + + await this.futarchy + .initializeHostileLiquidateProposal({ + dao, + liquidator: Keypair.generate().publicKey, + }) + .then(callbacks[0], callbacks[1]); + }); + + it("refuses stake_to_proposal", async function () { + const callbacks = expectError( + "DaoLiquidated", + "stake_to_proposal should refuse on a liquidated DAO", + ); + + await this.futarchy + .stakeToProposalIx({ + proposal: draftProposal, + dao, + baseMint: META, + amount: new BN(10 * 1_000_000), + }) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("refuses launch_proposal", async function () { + const callbacks = expectError( + "DaoLiquidated", + "launch_proposal should refuse on a liquidated DAO", + ); + + await this.futarchy + .launchProposalIx({ + proposal: draftProposal, + dao, + baseMint: META, + quoteMint: USDC, + squadsProposal: draftSquadsProposal, + }) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("refuses spot_swap", async function () { + const callbacks = expectError( + "DaoLiquidated", + "spot_swap should refuse on a liquidated DAO", + ); + + await this.futarchy + .spotSwapIx({ + dao, + baseMint: META, + quoteMint: USDC, + swapType: "buy", + inputAmount: new BN(1_000_000), + }) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("refuses conditional_swap", async function () { + const callbacks = expectError( + "DaoLiquidated", + "conditional_swap should refuse on a liquidated DAO", + ); + + await this.futarchy + .conditionalSwapIx({ + dao, + baseMint: META, + quoteMint: USDC, + proposal: liquidationProposal, + market: "pass", + swapType: "buy", + inputAmount: new BN(1_000_000), + minOutputAmount: new BN(0), + }) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("refuses provide_liquidity", async function () { + const callbacks = expectError( + "DaoLiquidated", + "provide_liquidity should refuse on a liquidated DAO", + ); + + await this.futarchy + .provideLiquidityIx({ + dao, + baseMint: META, + quoteMint: USDC, + quoteAmount: new BN(1_000 * 1_000_000), + maxBaseAmount: new BN(2 * 1_000_000), + minLiquidity: new BN(1), + positionAuthority: this.payer.publicKey, + liquidityProvider: this.payer.publicKey, + }) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("refuses update_dao", async function () { + const updateDaoIx = await this.futarchy + .updateDaoIx({ + dao, + params: { + passThresholdBps: 500, + secondsPerProposal: null, + twapInitialObservation: null, + twapMaxObservationChangePerUpdate: null, + twapStartDelaySeconds: null, + minQuoteFutarchicLiquidity: null, + minBaseFutarchicLiquidity: null, + baseToStake: null, + teamSponsoredPassThresholdBps: null, + teamAddress: null, + isOptimisticGovernanceEnabled: null, + }, + }) + .instruction(); + + const { squadsProposal, squadsTransaction } = await createSquadsVaultTx( + this, + [updateDaoIx], + ); + await forceApproveSquadsProposal(this, squadsProposal); + + try { + await executeVaultTransaction(this, dao, squadsTransaction); + assert.fail("Should have failed with DaoLiquidated"); + } catch (e) { + // The error surfaces through the Squads CPI: DaoLiquidated (0x179b = 6043) + assert( + e.toString().includes("DaoLiquidated") || + e.toString().includes("0x179b"), + `Expected DaoLiquidated error, got: ${e}`, + ); + } + }); + + it("refuses set_spending_limit", async function () { + const setSpendingLimitIx = await this.futarchy + .setSpendingLimitIx({ + dao, + config: { + amountPerMonth: new BN(1_000_000_000), + members: [this.payer.publicKey], + }, + }) + .instruction(); + + const { squadsProposal, squadsTransaction } = await createSquadsVaultTx( + this, + [setSpendingLimitIx], + ); + await forceApproveSquadsProposal(this, squadsProposal); + + try { + await executeVaultTransaction(this, dao, squadsTransaction); + assert.fail("Should have failed with DaoLiquidated"); + } catch (e) { + // The error surfaces through the Squads CPI: DaoLiquidated (0x179b = 6043) + assert( + e.toString().includes("DaoLiquidated") || + e.toString().includes("0x179b"), + `Expected DaoLiquidated error, got: ${e}`, + ); + } + }); + + it("allows sync_spending_limit, which removes the Squads limit without recreating", async function () { + const [spendingLimitPda] = getSpendingLimitAddr({ dao }); + assert.isNotNull(await this.banksClient.getAccount(spendingLimitPda)); + + await this.futarchy.syncSpendingLimitIx({ dao }).rpc(); + + assert.isNull(await this.banksClient.getAccount(spendingLimitPda)); + + const storedDao = await this.futarchy.getDao(dao); + assert.isNull(storedDao.initialSpendingLimit); + assert.isFalse(storedDao.spendingLimitDirty); + }); + + it("allows withdraw_liquidity", async function () { + const [ammPosition] = PublicKey.findProgramAddressSync( + [ + Buffer.from("amm_position"), + dao.toBuffer(), + this.payer.publicKey.toBuffer(), + ], + FUTARCHY_V0_6_PROGRAM_ID, + ); + const position = + await this.futarchy.futarchy.account.ammPosition.fetch(ammPosition); + + const preBase = await this.getTokenBalance(META, this.payer.publicKey); + const preQuote = await this.getTokenBalance(USDC, this.payer.publicKey); + + const [eventAuthority] = getEventAuthorityAddr(FUTARCHY_V0_6_PROGRAM_ID); + await this.futarchy.futarchy.methods + .withdrawLiquidity({ + liquidityToWithdraw: position.liquidity, + minBaseAmount: new BN(0), + minQuoteAmount: new BN(0), + }) + .accounts({ + dao, + positionAuthority: this.payer.publicKey, + liquidityProviderBaseAccount: getAssociatedTokenAddressSync( + META, + this.payer.publicKey, + true, + ), + liquidityProviderQuoteAccount: getAssociatedTokenAddressSync( + USDC, + this.payer.publicKey, + true, + ), + ammBaseVault: getAssociatedTokenAddressSync(META, dao, true), + ammQuoteVault: getAssociatedTokenAddressSync(USDC, dao, true), + ammPosition, + tokenProgram: TOKEN_PROGRAM_ID, + eventAuthority, + program: FUTARCHY_V0_6_PROGRAM_ID, + }) + .rpc(); + + assert.isTrue( + (await this.getTokenBalance(META, this.payer.publicKey)) > preBase, + ); + assert.isTrue( + (await this.getTokenBalance(USDC, this.payer.publicKey)) > preQuote, + ); + + const postPosition = + await this.futarchy.futarchy.account.ammPosition.fetch(ammPosition); + assert.equal(postPosition.liquidity.toString(), "0"); + }); + + it("allows unstake_from_proposal", async function () { + const preBalance = await this.getTokenBalance(META, this.payer.publicKey); + + await this.futarchy + .unstakeFromProposalIx({ + proposal: draftProposal, + dao, + baseMint: META, + amount: new BN(100 * 1_000_000), + }) + .rpc(); + + const postBalance = await this.getTokenBalance(META, this.payer.publicKey); + assert.equal((postBalance - preBalance).toString(), "100000000"); + }); + + it("allows collect_fees", async function () { + const preDao = await this.futarchy.getDao(dao); + const baseFees = preDao.amm.state.spot.spot.baseProtocolFeeBalance; + const quoteFees = preDao.amm.state.spot.spot.quoteProtocolFeeBalance; + assert.isTrue(quoteFees.gtn(0)); + + const preBase = await this.getTokenBalance(META, METADAO_MULTISIG_VAULT); + const preQuote = await this.getTokenBalance(USDC, METADAO_MULTISIG_VAULT); + + await this.futarchy + .collectFeesIx({ dao, baseMint: META, quoteMint: USDC }) + .rpc(); + + const postBase = await this.getTokenBalance(META, METADAO_MULTISIG_VAULT); + const postQuote = await this.getTokenBalance(USDC, METADAO_MULTISIG_VAULT); + assert.equal((postBase - preBase).toString(), baseFees.toString()); + assert.equal((postQuote - preQuote).toString(), quoteFees.toString()); + + const postDao = await this.futarchy.getDao(dao); + assert.equal( + postDao.amm.state.spot.spot.baseProtocolFeeBalance.toString(), + "0", + ); + assert.equal( + postDao.amm.state.spot.spot.quoteProtocolFeeBalance.toString(), + "0", + ); + }); +} diff --git a/tests/futarchy/unit/liquidatorPath.test.ts b/tests/futarchy/unit/liquidatorPath.test.ts new file mode 100644 index 00000000..c28fe30a --- /dev/null +++ b/tests/futarchy/unit/liquidatorPath.test.ts @@ -0,0 +1,318 @@ +import { + ComputeBudgetProgram, + Keypair, + PublicKey, + SystemProgram, + Transaction, +} from "@solana/web3.js"; +import { assert } from "chai"; +import * as multisig from "@sqds/multisig"; +import { + createTransferInstruction, + getAssociatedTokenAddressSync, +} from "@solana/spl-token"; +import { + getDaoAddr, + getProposalAddrsForTransactionIndex, + PERMISSIONLESS_ACCOUNT, +} from "@metadaoproject/programs"; +import BN from "bn.js"; +import { + executeVaultTransaction, + expectError, + passProposal, + THOUSAND_BUCK_PRICE, +} from "../../utils.js"; + +const SEED_ENQUEUED_APPROVAL = Buffer.from("enqueued_approval"); + +export default function suite() { + let META: PublicKey, + USDC: PublicKey, + dao: PublicKey, + vault: PublicKey, + squadsMultisig: PublicKey, + liquidator: Keypair; + + // The estate cycle starts from a genuinely liquidated DAO: a hostile + // liquidation market passes and its payload executes. + beforeEach(async function () { + META = await this.createMint(this.payer.publicKey, 6); + USDC = await this.createMint(this.payer.publicKey, 6); + + await this.createTokenAccount(META, this.payer.publicKey); + await this.createTokenAccount(USDC, this.payer.publicKey); + + await this.mintTo( + META, + this.payer.publicKey, + this.payer, + 1_000 * 1_000_000, + ); + await this.mintTo( + USDC, + this.payer.publicKey, + this.payer, + 500_000 * 1_000_000, + ); + + 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, + // 10% per update: TWAPs converge to actual prices fast enough that + // a pumped pass market clears HostileLiquidate's +25% + twapMaxObservationChangePerUpdate: THOUSAND_BUCK_PRICE.divn(10), + minQuoteFutarchicLiquidity: new BN(10_000), + minBaseFutarchicLiquidity: new BN(10_000), + passThresholdBps: 300, + nonce, + initialSpendingLimit: { + amountPerMonth: new BN(10_000_000_000), // 10,000 USDC + members: [this.payer.publicKey], + }, + baseToStake: new BN(0), + teamSponsoredPassThresholdBps: 300, + teamAddress: this.payer.publicKey, + }, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), + ]) + .rpc(); + + [dao] = getDaoAddr({ nonce, daoCreator: this.payer.publicKey }); + + const storedDao = await this.futarchy.getDao(dao); + vault = storedDao.squadsMultisigVault; + squadsMultisig = storedDao.squadsMultisig; + + // The baked apply_liquidation payload requires the vault's ATAs to exist + await this.createTokenAccount(META, vault); + await this.createTokenAccount(USDC, vault); + + await this.futarchy + .provideLiquidityIx({ + dao, + baseMint: META, + quoteMint: USDC, + quoteAmount: new BN(100_000 * 1_000_000), // 100,000 USDC + maxBaseAmount: new BN(100 * 1_000_000), // 100 META + minLiquidity: new BN(0), + positionAuthority: this.payer.publicKey, + liquidityProvider: this.payer.publicKey, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), + ]) + .rpc(); + + liquidator = Keypair.generate(); + + const { proposal, squadsProposal, squadsTransaction } = + await this.futarchy.initializeHostileLiquidateProposal({ + dao, + liquidator: liquidator.publicKey, + }); + + await this.futarchy + .launchProposalIx({ + proposal, + dao, + baseMint: META, + quoteMint: USDC, + squadsProposal, + }) + .rpc(); + + await passProposal(this, { + dao, + proposal, + baseMint: META, + quoteMint: USDC, + cranks: 50, + }); + + await executeVaultTransaction(this, dao, squadsTransaction); + + // The liquidator pays rent for the enqueued approval account + const fundTx = new Transaction().add( + SystemProgram.transfer({ + fromPubkey: this.payer.publicKey, + toPubkey: liquidator.publicKey, + lamports: 1_000_000_000, + }), + ); + fundTx.recentBlockhash = (await this.banksClient.getLatestBlockhash())[0]; + fundTx.feePayer = this.payer.publicKey; + fundTx.sign(this.payer); + await this.banksClient.processTransaction(fundTx); + + // The estate: give the vault something to distribute + await this.mintTo(USDC, vault, this.payer, 1_000 * 1_000_000); + }); + + // A Squads vault transaction + proposal carrying one estate instruction: + // pay out USDC from the vault + const createEstateProposal = async function ( + context: any, + transactionIndex: bigint, + instructions: any[], + ) { + const { tx } = context.futarchy.squadsProposalCreateTx({ + dao, + instructions, + transactionIndex, + }); + tx.recentBlockhash = (await context.banksClient.getLatestBlockhash())[0]; + tx.feePayer = context.payer.publicKey; + tx.sign(context.payer, PERMISSIONLESS_ACCOUNT); + await context.banksClient.processTransaction(tx); + + return getProposalAddrsForTransactionIndex({ dao, transactionIndex }); + }; + + const deriveEnqueuedApprovalPda = ( + context: any, + transactionIndex: bigint, + ): PublicKey => { + const [pda] = PublicKey.findProgramAddressSync( + [ + SEED_ENQUEUED_APPROVAL, + dao.toBuffer(), + new BN(transactionIndex.toString()).toArrayLike(Buffer, "le", 8), + ], + context.futarchy.futarchy.programId, + ); + return pda; + }; + + it("refuses a non-liquidator enqueue once the DAO is liquidated", async function () { + const recipient = Keypair.generate().publicKey; + const recipientAta = await this.createTokenAccount(USDC, recipient); + const vaultUsdcAta = getAssociatedTokenAddressSync(USDC, vault, true); + + // The liquidation payload was transaction 1; the estate starts at 2 + const { squadsProposal } = await createEstateProposal(this, 2n, [ + createTransferInstruction( + vaultUsdcAta, + recipientAta, + vault, + 600 * 1_000_000, + ), + ]); + + const callbacks = expectError( + "InvalidLiquidator", + "enqueue by a non-liquidator should fail on a liquidated DAO", + ); + + await this.futarchy.futarchy.methods + .adminEnqueueMultisigProposalApproval({ transactionIndex: new BN(2) }) + .accounts({ + dao, + admin: this.payer.publicKey, + squadsMultisig, + squadsMultisigProposal: squadsProposal, + enqueuedApproval: deriveEnqueuedApprovalPda(this, 2n), + }) + .signers([this.payer]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("runs the estate cycle: liquidator enqueues, the DAO PDA approves permissionlessly, ordinary Squads execution pays out", async function () { + const recipient = Keypair.generate().publicKey; + const recipientAta = await this.createTokenAccount(USDC, recipient); + const vaultUsdcAta = getAssociatedTokenAddressSync(USDC, vault, true); + + const { squadsProposal, squadsTransaction } = await createEstateProposal( + this, + 2n, + [ + createTransferInstruction( + vaultUsdcAta, + recipientAta, + vault, + 600 * 1_000_000, + ), + ], + ); + + const enqueuedApprovalPda = deriveEnqueuedApprovalPda(this, 2n); + + await this.futarchy.futarchy.methods + .adminEnqueueMultisigProposalApproval({ transactionIndex: new BN(2) }) + .accounts({ + dao, + admin: liquidator.publicKey, + squadsMultisig, + squadsMultisigProposal: squadsProposal, + enqueuedApproval: enqueuedApprovalPda, + }) + .signers([liquidator]) + .rpc(); + + const enqueued = + await this.futarchy.futarchy.account.enqueuedMultisigProposalApproval.fetch( + enqueuedApprovalPda, + ); + assert.equal(enqueued.dao.toBase58(), dao.toBase58()); + assert.equal(enqueued.transactionIndex.toString(), "2"); + + // The middle leg stays permissionless: any signer cranks the DAO PDA's + // approve vote, which meets the threshold of 1 on its own + await this.futarchy.futarchy.methods + .executeMultisigProposalApproval() + .accounts({ + dao, + rentReceiver: this.payer.publicKey, + squadsMultisig, + squadsMultisigProposal: squadsProposal, + enqueuedApproval: enqueuedApprovalPda, + squadsMultisigProgram: multisig.PROGRAM_ID, + }) + .signers([this.payer]) + .rpc(); + + let storedSquadsProposal = + await multisig.accounts.Proposal.fromAccountAddress( + this.squadsConnection, + squadsProposal, + ); + assert.isTrue( + multisig.generated.isProposalStatusApproved(storedSquadsProposal.status), + ); + assert.deepEqual( + storedSquadsProposal.approved.map((k) => k.toBase58()), + [dao.toBase58()], + ); + + // Execution is the ordinary top-level Squads execute — no futarchy + // instruction and no liquidator signature involved + await executeVaultTransaction(this, dao, squadsTransaction); + + storedSquadsProposal = await multisig.accounts.Proposal.fromAccountAddress( + this.squadsConnection, + squadsProposal, + ); + assert.isTrue( + multisig.generated.isProposalStatusExecuted(storedSquadsProposal.status), + ); + + assert.equal( + (await this.getTokenBalance(USDC, recipient)).toString(), + "600000000", + ); + assert.equal( + (await this.getTokenBalance(USDC, vault)).toString(), + "400000000", + ); + }); +} diff --git a/tests/futarchy/unit/resizeDao.test.ts b/tests/futarchy/unit/resizeDao.test.ts new file mode 100644 index 00000000..4df5fffa --- /dev/null +++ b/tests/futarchy/unit/resizeDao.test.ts @@ -0,0 +1,214 @@ +import { + ComputeBudgetProgram, + Keypair, + PublicKey, + SystemProgram, + Transaction, +} from "@solana/web3.js"; +import BN from "bn.js"; +import { setupBasicDao } from "../../utils.js"; +import { TestContext } from "../../main.test.js"; +import { assert } from "chai"; + +type OldLayoutOverrides = { + optimisticProposal?: { + squadsProposal: PublicKey; + enqueuedTimestamp: BN; + } | null; + isOptimisticGovernanceEnabled?: boolean; +}; + +// Rewrites a real (new-layout) Dao account to the pre-migration on-chain layout +// by re-encoding its body as the `oldDao` IDL type (dropping the appended +// `liquidator`, failure timestamps, and `spending_limit_dirty`). Truncation does +// NOT work for Dao: its Option slack would leave the fields' bytes in place. +// Optional overrides let a test pin the optimistic fields without driving the +// (now deleted) optimistic instructions. +async function makeOldLayout( + ctx: TestContext, + dao: PublicKey, + overrides: OldLayoutOverrides = {}, + opts: { lamports?: number } = {}, +): Promise<{ AFTER: number; BEFORE: number }> { + const raw = await ctx.banksClient.getAccount(dao); + const AFTER = raw.data.length; + // 50 bytes: liquidator (Option) + last_failed_takeover_at (i64) + // + last_failed_liquidation_at (i64) + spending_limit_dirty (bool) + const BEFORE = AFTER - 50; + + const disc = Buffer.from(raw.data.slice(0, 8)); + const coder = ctx.futarchy.futarchy.account.dao.coder.accounts; + const decoded = coder.decode("dao", Buffer.from(raw.data)); + + if (overrides.optimisticProposal !== undefined) + decoded.optimisticProposal = overrides.optimisticProposal; + if (overrides.isOptimisticGovernanceEnabled !== undefined) + decoded.isOptimisticGovernanceEnabled = + overrides.isOptimisticGovernanceEnabled; + + // Encode as oldDao (mainnet layout, ending at is_optimistic_governance_enabled); + // drop its discriminator and reattach the real Dao discriminator at the + // pre-migration size. + const body = await coder.encode("oldDao", decoded); + const buf = Buffer.alloc(BEFORE); + disc.copy(buf, 0); + body.subarray(8).copy(buf, 8); + + ctx.context.setAccount(dao, { + ...raw, + data: buf, + ...(opts.lamports !== undefined ? { lamports: opts.lamports } : {}), + }); + + return { AFTER, BEFORE }; +} + +export default function suite() { + let META: PublicKey, USDC: PublicKey, dao: PublicKey; + + beforeEach(async function () { + META = await this.createMint(this.payer.publicKey, 6); + USDC = await this.createMint(this.payer.publicKey, 6); + + dao = await setupBasicDao({ + context: this, + baseMint: META, + quoteMint: USDC, + }); + }); + + it("migrates an old DAO with the new fields defaulted, preserving every other field", async function () { + const original = await this.futarchy.getDao(dao); + // The migration defaults must match a freshly-initialized DAO, so the + // whole account can round-trip equal below. + assert.isNull(original.liquidator); + assert.equal(original.lastFailedTakeoverAt.toString(), "0"); + assert.equal(original.lastFailedLiquidationAt.toString(), "0"); + assert.isFalse(original.spendingLimitDirty); + + const { AFTER, BEFORE } = await makeOldLayout(this, dao); + + const short = await this.banksClient.getAccount(dao); + assert.equal(short.data.length, BEFORE); + + await this.futarchy.futarchy.methods + .resizeDao() + .accounts({ dao, payer: this.payer.publicKey }) + .rpc(); + + const resized = await this.banksClient.getAccount(dao); + assert.equal(resized.data.length, AFTER); + + const migrated = await this.futarchy.getDao(dao); + assert.isNull(migrated.liquidator); + assert.equal(migrated.lastFailedTakeoverAt.toString(), "0"); + assert.equal(migrated.lastFailedLiquidationAt.toString(), "0"); + assert.isFalse(migrated.spendingLimitDirty); + + assert.deepEqual( + JSON.parse(JSON.stringify(migrated)), + JSON.parse(JSON.stringify(original)), + ); + + // Idempotent: a second crank is a no-op (compute-budget bump for a unique sig). + await this.futarchy.futarchy.methods + .resizeDao() + .accounts({ dao, payer: this.payer.publicKey }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 200_000 }), + ]) + .rpc(); + + const after2 = await this.banksClient.getAccount(dao); + assert.equal(after2.data.length, AFTER); + }); + + it("clears an in-flight optimistic proposal and carries the governance flag", async function () { + const fakeSquadsProposal = Keypair.generate().publicKey; + await makeOldLayout(this, dao, { + isOptimisticGovernanceEnabled: true, + optimisticProposal: { + squadsProposal: fakeSquadsProposal, + enqueuedTimestamp: new BN(1_700_000_000), + }, + }); + + await this.futarchy.futarchy.methods + .resizeDao() + .accounts({ dao, payer: this.payer.publicKey }) + .rpc(); + + const migrated = await this.futarchy.getDao(dao); + // The optimistic machinery is gone: in-flight spends are cleared, not + // carried into a state nothing can finalize. + assert.isNull(migrated.optimisticProposal); + assert.isTrue(migrated.isOptimisticGovernanceEnabled); + assert.isNull(migrated.liquidator); + assert.equal(migrated.lastFailedTakeoverAt.toString(), "0"); + assert.equal(migrated.lastFailedLiquidationAt.toString(), "0"); + assert.isFalse(migrated.spendingLimitDirty); + }); + + it("is a no-op on an already-new-layout DAO", async function () { + const before = await this.futarchy.getDao(dao); + const beforeRaw = await this.banksClient.getAccount(dao); + + await this.futarchy.futarchy.methods + .resizeDao() + .accounts({ dao, payer: this.payer.publicKey }) + .rpc(); + + const afterRaw = await this.banksClient.getAccount(dao); + assert.equal(afterRaw.data.length, beforeRaw.data.length); + + const after = await this.futarchy.getDao(dao); + assert.deepEqual( + JSON.parse(JSON.stringify(after)), + JSON.parse(JSON.stringify(before)), + ); + }); + + it("tops up rent from the payer when the migrated account is under-funded", async function () { + const rent = await this.banksClient.getRent(); + const raw0 = await this.banksClient.getAccount(dao); + const AFTER = raw0.data.length; + const BEFORE = AFTER - 50; + const rentBefore = rent.minimumBalance(BigInt(BEFORE)); + const rentAfter = rent.minimumBalance(BigInt(AFTER)); + const delta = rentAfter - rentBefore; + + // Shrink to old layout AND drop lamports to the old rent-exempt minimum so + // the realloc forces a top-up transfer. + await makeOldLayout(this, dao, {}, { lamports: Number(rentBefore) }); + + // Dedicated crank payer (not the fee payer) so its balance change isolates + // the top-up transfer from transaction fees. + const crankPayer = Keypair.generate(); + const fundTx = new Transaction().add( + SystemProgram.transfer({ + fromPubkey: this.payer.publicKey, + toPubkey: crankPayer.publicKey, + lamports: 1_000_000_000, + }), + ); + fundTx.recentBlockhash = (await this.banksClient.getLatestBlockhash())[0]; + fundTx.feePayer = this.payer.publicKey; + fundTx.sign(this.payer); + await this.banksClient.processTransaction(fundTx); + + const payerBefore = await this.banksClient.getBalance(crankPayer.publicKey); + + await this.futarchy.futarchy.methods + .resizeDao() + .accounts({ dao, payer: crankPayer.publicKey }) + .signers([crankPayer]) + .rpc(); + + const payerAfter = await this.banksClient.getBalance(crankPayer.publicKey); + const daoLamports = await this.banksClient.getBalance(dao); + + // Account brought exactly to the new rent-exempt minimum, funded by the payer. + assert.equal(daoLamports.toString(), rentAfter.toString()); + assert.equal((payerBefore - payerAfter).toString(), delta.toString()); + }); +} diff --git a/tests/futarchy/unit/resizeProposal.test.ts b/tests/futarchy/unit/resizeProposal.test.ts new file mode 100644 index 00000000..e2894392 --- /dev/null +++ b/tests/futarchy/unit/resizeProposal.test.ts @@ -0,0 +1,220 @@ +import { PERMISSIONLESS_ACCOUNT } from "@metadaoproject/programs"; +import { + ComputeBudgetProgram, + PublicKey, + SystemProgram, + Transaction, + TransactionMessage, +} from "@solana/web3.js"; +import * as multisig from "@sqds/multisig"; +import { expectError, setupBasicDao } from "../../utils.js"; +import { TestContext } from "../../main.test.js"; +import { assert } from "chai"; + +// Rewrites a real (new-layout) Proposal account to the pre-migration on-chain +// layout by re-encoding its body as the `oldProposal` IDL type (dropping the +// appended `pass_threshold_bps`, `council_can_block`, and `action`). The +// optional override lets a test pin `is_team_sponsored` without driving the +// sponsor flow. +async function makeOldLayout( + ctx: TestContext, + proposal: PublicKey, + overrides: { isTeamSponsored?: boolean } = {}, +): Promise<{ AFTER: number; BEFORE: number }> { + const raw = await ctx.banksClient.getAccount(proposal); + const AFTER = raw.data.length; + // 369 bytes: pass_threshold_bps (i16) + council_can_block (bool) + // + action (ProposalAction) + const BEFORE = AFTER - 369; + + const disc = Buffer.from(raw.data.slice(0, 8)); + const coder = ctx.futarchy.futarchy.account.proposal.coder.accounts; + const decoded = coder.decode("proposal", Buffer.from(raw.data)); + + if (overrides.isTeamSponsored !== undefined) + decoded.isTeamSponsored = overrides.isTeamSponsored; + + const body = await coder.encode("oldProposal", decoded); + const buf = Buffer.alloc(BEFORE); + disc.copy(buf, 0); + body.subarray(8).copy(buf, 8); + + ctx.context.setAccount(proposal, { ...raw, data: buf }); + + return { AFTER, BEFORE }; +} + +// Creates the Squads vault transaction + proposal pair (with an arbitrary +// message payload) and the futarchy proposal on top of them. +async function createProposal( + ctx: TestContext, + dao: PublicKey, +): Promise { + const multisigPda = multisig.getMultisigPda({ createKey: dao })[0]; + + const message = new TransactionMessage({ + payerKey: ctx.payer.publicKey, + recentBlockhash: (await ctx.banksClient.getLatestBlockhash())[0], + instructions: [ + SystemProgram.transfer({ + fromPubkey: ctx.payer.publicKey, + toPubkey: ctx.payer.publicKey, + lamports: 1, + }), + ], + }); + + const vaultTxCreate = multisig.instructions.vaultTransactionCreate({ + multisigPda, + transactionIndex: 1n, + creator: PERMISSIONLESS_ACCOUNT.publicKey, + rentPayer: ctx.payer.publicKey, + vaultIndex: 0, + ephemeralSigners: 0, + transactionMessage: message, + }); + + const proposalCreateIx = multisig.instructions.proposalCreate({ + multisigPda, + transactionIndex: 1n, + creator: PERMISSIONLESS_ACCOUNT.publicKey, + rentPayer: ctx.payer.publicKey, + }); + + const [squadsProposal] = multisig.getProposalPda({ + multisigPda, + transactionIndex: 1n, + }); + + const tx = new Transaction().add(vaultTxCreate, proposalCreateIx); + tx.recentBlockhash = (await ctx.banksClient.getLatestBlockhash())[0]; + tx.feePayer = ctx.payer.publicKey; + tx.sign(ctx.payer, PERMISSIONLESS_ACCOUNT); + await ctx.banksClient.processTransaction(tx); + + return ctx.futarchy.initializeProposal(dao, squadsProposal); +} + +export default function suite() { + let META: PublicKey, USDC: PublicKey, dao: PublicKey, proposal: PublicKey; + + beforeEach(async function () { + META = await this.createMint(this.payer.publicKey, 6); + USDC = await this.createMint(this.payer.publicKey, 6); + + // Distinct thresholds (300 vs -100) so the two snapshot branches are + // distinguishable, and both differ from ExecuteArbitrary's constant (1000). + dao = await setupBasicDao({ + context: this, + baseMint: META, + quoteMint: USDC, + teamSponsoredPassThresholdBps: -100, + }); + + proposal = await createProposal(this, dao); + }); + + it("migrates an old proposal with defaults snapshotted from the DAO, preserving every other field", async function () { + const original = await this.futarchy.getProposal(proposal); + assert.isFalse(original.isTeamSponsored); + assert.equal(original.passThresholdBps, 1000); + + const { AFTER, BEFORE } = await makeOldLayout(this, proposal); + + const short = await this.banksClient.getAccount(proposal); + assert.equal(short.data.length, BEFORE); + + await this.futarchy.futarchy.methods + .resizeProposal() + .accounts({ proposal, dao, payer: this.payer.publicKey }) + .rpc(); + + const resized = await this.banksClient.getAccount(proposal); + assert.equal(resized.data.length, AFTER); + + const migrated = await this.futarchy.getProposal(proposal); + assert.isDefined(migrated.action.executeArbitrary); + assert.isTrue(migrated.councilCanBlock); + // The vestigial per-DAO threshold (300), not the kind constant (1000). + assert.equal(migrated.passThresholdBps, 300); + + original.passThresholdBps = 300; + assert.deepEqual( + JSON.parse(JSON.stringify(migrated)), + JSON.parse(JSON.stringify(original)), + ); + + // Idempotent: a second crank is a no-op (compute-budget bump for a unique sig). + await this.futarchy.futarchy.methods + .resizeProposal() + .accounts({ proposal, dao, payer: this.payer.publicKey }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 200_000 }), + ]) + .rpc(); + + const after2 = await this.banksClient.getAccount(proposal); + assert.equal(after2.data.length, AFTER); + + const migrated2 = await this.futarchy.getProposal(proposal); + assert.deepEqual( + JSON.parse(JSON.stringify(migrated2)), + JSON.parse(JSON.stringify(migrated)), + ); + }); + + it("snapshots the team-sponsored threshold for a team-sponsored proposal", async function () { + await makeOldLayout(this, proposal, { isTeamSponsored: true }); + + await this.futarchy.futarchy.methods + .resizeProposal() + .accounts({ proposal, dao, payer: this.payer.publicKey }) + .rpc(); + + const migrated = await this.futarchy.getProposal(proposal); + assert.isTrue(migrated.isTeamSponsored); + assert.equal(migrated.passThresholdBps, -100); + assert.isDefined(migrated.action.executeArbitrary); + assert.isTrue(migrated.councilCanBlock); + }); + + it("is a no-op on an already-new-layout proposal", async function () { + const before = await this.futarchy.getProposal(proposal); + const beforeRaw = await this.banksClient.getAccount(proposal); + + await this.futarchy.futarchy.methods + .resizeProposal() + .accounts({ proposal, dao, payer: this.payer.publicKey }) + .rpc(); + + const afterRaw = await this.banksClient.getAccount(proposal); + assert.equal(afterRaw.data.length, beforeRaw.data.length); + + const after = await this.futarchy.getProposal(proposal); + assert.deepEqual( + JSON.parse(JSON.stringify(after)), + JSON.parse(JSON.stringify(before)), + ); + }); + + it("rejects a DAO that is not the proposal's", async function () { + const otherDao = await setupBasicDao({ + context: this, + baseMint: META, + quoteMint: USDC, + }); + + await makeOldLayout(this, proposal); + + const callbacks = expectError( + "RequireKeysEqViolated", + "resized against a DAO that is not the proposal's", + ); + + await this.futarchy.futarchy.methods + .resizeProposal() + .accounts({ proposal, dao: otherDao, payer: this.payer.publicKey }) + .rpc() + .then(callbacks[0], callbacks[1]); + }); +} diff --git a/tests/futarchy/unit/setSpendingLimit.test.ts b/tests/futarchy/unit/setSpendingLimit.test.ts new file mode 100644 index 00000000..9d05889a --- /dev/null +++ b/tests/futarchy/unit/setSpendingLimit.test.ts @@ -0,0 +1,238 @@ +import { + ComputeBudgetProgram, + Keypair, + PublicKey, + Transaction, +} from "@solana/web3.js"; +import { assert } from "chai"; +import * as multisig from "@sqds/multisig"; +import { + PERMISSIONLESS_ACCOUNT, + getDaoAddr, + PriceMath, +} from "@metadaoproject/programs"; +import BN from "bn.js"; +import { expectError } from "../../utils.js"; +import { TestContext } from "../../main.test.js"; + +const ONE_BUCK_PRICE = PriceMath.getAmmPrice(1, 6, 6); +const SEED_ENQUEUED_APPROVAL = Buffer.from("enqueued_approval"); + +// The vault PDA can only sign via a Squads vault transaction execution, so the +// record is written by creating + approving + executing one containing a +// single set_spending_limit instruction. +async function executeSetSpendingLimitViaVault( + context: TestContext, + dao: PublicKey, + config: { amountPerMonth: BN; members: PublicKey[] } | null, +) { + const daoAccount = await context.futarchy.getDao(dao); + const multisigPda = daoAccount.squadsMultisig; + + const multisigAccount = await multisig.accounts.Multisig.fromAccountAddress( + context.squadsConnection, + multisigPda, + ); + const transactionIndex = + BigInt(multisigAccount.transactionIndex.toString()) + 1n; + + const setSpendingLimitIx = await context.futarchy + .setSpendingLimitIx({ dao, config }) + .instruction(); + + const { tx: createTx } = context.futarchy.squadsProposalCreateTx({ + dao, + instructions: [setSpendingLimitIx], + transactionIndex, + }); + createTx.recentBlockhash = ( + await context.banksClient.getLatestBlockhash() + )[0]; + createTx.feePayer = context.payer.publicKey; + createTx.sign(context.payer, PERMISSIONLESS_ACCOUNT); + await context.banksClient.processTransaction(createTx); + + const [squadsProposal] = multisig.getProposalPda({ + multisigPda, + transactionIndex, + }); + + const [enqueuedApproval] = PublicKey.findProgramAddressSync( + [ + SEED_ENQUEUED_APPROVAL, + dao.toBuffer(), + new BN(transactionIndex.toString()).toArrayLike(Buffer, "le", 8), + ], + context.futarchy.futarchy.programId, + ); + + await context.futarchy.futarchy.methods + .adminEnqueueMultisigProposalApproval({ + transactionIndex: new BN(transactionIndex.toString()), + }) + .accounts({ + dao, + admin: context.payer.publicKey, + squadsMultisig: multisigPda, + squadsMultisigProposal: squadsProposal, + enqueuedApproval, + }) + .rpc(); + + await context.futarchy.futarchy.methods + .executeMultisigProposalApproval() + .accounts({ + dao, + rentReceiver: context.payer.publicKey, + squadsMultisig: multisigPda, + squadsMultisigProposal: squadsProposal, + enqueuedApproval, + squadsMultisigProgram: multisig.PROGRAM_ID, + }) + .rpc(); + + // Execute as a top-level Squads instruction so the vault PDA signs the + // inner set_spending_limit + const executeIx = await multisig.instructions.vaultTransactionExecute({ + connection: context.squadsConnection, + multisigPda, + transactionIndex, + member: PERMISSIONLESS_ACCOUNT.publicKey, + }); + + const executeTx = new Transaction().add(executeIx.instruction); + executeTx.recentBlockhash = ( + await context.banksClient.getLatestBlockhash() + )[0]; + executeTx.feePayer = context.payer.publicKey; + executeTx.sign(context.payer, PERMISSIONLESS_ACCOUNT); + await context.banksClient.processTransaction(executeTx); +} + +export default function suite() { + let META: PublicKey, USDC: PublicKey, dao: PublicKey; + + beforeEach(async function () { + META = await this.createMint(this.payer.publicKey, 6); + USDC = await this.createMint(this.payer.publicKey, 6); + + 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: ONE_BUCK_PRICE, + twapMaxObservationChangePerUpdate: ONE_BUCK_PRICE.divn(100), + minQuoteFutarchicLiquidity: new BN(10_000), + minBaseFutarchicLiquidity: new BN(10_000), + passThresholdBps: 300, + nonce, + initialSpendingLimit: { + amountPerMonth: new BN(10_000_000_000), // 10,000 USDC + members: [this.payer.publicKey], + }, + baseToStake: new BN(0), + teamSponsoredPassThresholdBps: 300, + teamAddress: this.payer.publicKey, + }, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), + ]) + .rpc(); + + [dao] = getDaoAddr({ nonce, daoCreator: this.payer.publicKey }); + }); + + it("replaces the record with the declared config and sets the dirty flag", async function () { + let daoAccount = await this.futarchy.getDao(dao); + assert.isFalse(daoAccount.spendingLimitDirty); + const seqNumBefore = daoAccount.seqNum; + + const newMembers = [ + Keypair.generate().publicKey, + Keypair.generate().publicKey, + ]; + + await executeSetSpendingLimitViaVault(this, dao, { + amountPerMonth: new BN(25_000_000_000), // 25,000 USDC + members: newMembers, + }); + + daoAccount = await this.futarchy.getDao(dao); + assert.equal( + daoAccount.initialSpendingLimit.amountPerMonth.toString(), + "25000000000", + ); + assert.deepEqual( + daoAccount.initialSpendingLimit.members.map((m) => m.toBase58()), + newMembers.map((m) => m.toBase58()), + ); + assert.isTrue(daoAccount.spendingLimitDirty); + assert.equal(daoAccount.seqNum.toString(), seqNumBefore.addn(1).toString()); + }); + + it("deletes the record on a None config and sets the dirty flag", async function () { + let daoAccount = await this.futarchy.getDao(dao); + assert.isNotNull(daoAccount.initialSpendingLimit); + assert.isFalse(daoAccount.spendingLimitDirty); + + await executeSetSpendingLimitViaVault(this, dao, null); + + daoAccount = await this.futarchy.getDao(dao); + assert.isNull(daoAccount.initialSpendingLimit); + assert.isTrue(daoAccount.spendingLimitDirty); + }); + + it("throws when the signer is not the multisig vault", async function () { + const attacker = Keypair.generate(); + + const callbacks = expectError( + "ConstraintHasOne", + "set_spending_limit should require the vault signature", + ); + + await this.futarchy.futarchy.methods + .setSpendingLimit({ config: null }) + .accounts({ + dao, + squadsMultisigVault: attacker.publicKey, + }) + .signers([attacker]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("throws when the config has more than 10 members", async function () { + const elevenMembers = Array.from( + { length: 11 }, + () => Keypair.generate().publicKey, + ); + + try { + await executeSetSpendingLimitViaVault(this, dao, { + amountPerMonth: new BN(1_000_000_000), // 1,000 USDC + members: elevenMembers, + }); + assert.fail("Should have failed with TooManySpendingLimitMembers"); + } catch (e) { + // The error surfaces through the Squads CPI: TooManySpendingLimitMembers (0x17a4 = 6052) + assert( + e.toString().includes("TooManySpendingLimitMembers") || + e.toString().includes("0x17a4"), + `Expected TooManySpendingLimitMembers error, got: ${e}`, + ); + } + + const daoAccount = await this.futarchy.getDao(dao); + assert.equal( + daoAccount.initialSpendingLimit.amountPerMonth.toString(), + "10000000000", + ); + assert.isFalse(daoAccount.spendingLimitDirty); + }); +} diff --git a/tests/futarchy/unit/syncSpendingLimit.test.ts b/tests/futarchy/unit/syncSpendingLimit.test.ts new file mode 100644 index 00000000..6ec105a8 --- /dev/null +++ b/tests/futarchy/unit/syncSpendingLimit.test.ts @@ -0,0 +1,309 @@ +import { + ComputeBudgetProgram, + Keypair, + PublicKey, + Transaction, +} from "@solana/web3.js"; +import { assert } from "chai"; +import * as multisig from "@sqds/multisig"; +import { + PERMISSIONLESS_ACCOUNT, + getDaoAddr, + getSpendingLimitAddr, + PriceMath, +} from "@metadaoproject/programs"; +import BN from "bn.js"; +import { expectError } from "../../utils.js"; +import { TestContext } from "../../main.test.js"; + +const { Period } = multisig.types; + +const ONE_BUCK_PRICE = PriceMath.getAmmPrice(1, 6, 6); +const SEED_ENQUEUED_APPROVAL = Buffer.from("enqueued_approval"); + +async function initializeTestDao( + context: TestContext, + baseMint: PublicKey, + quoteMint: PublicKey, + initialSpendingLimit: { amountPerMonth: BN; members: PublicKey[] } | null, +): Promise { + const nonce = new BN(Math.floor(Math.random() * 1000000)); + + await context.futarchy + .initializeDaoIx({ + baseMint, + quoteMint, + params: { + secondsPerProposal: 60 * 60 * 24 * 3, + twapStartDelaySeconds: 60 * 60 * 24, + twapInitialObservation: ONE_BUCK_PRICE, + twapMaxObservationChangePerUpdate: ONE_BUCK_PRICE.divn(100), + minQuoteFutarchicLiquidity: new BN(10_000), + minBaseFutarchicLiquidity: new BN(10_000), + passThresholdBps: 300, + nonce, + initialSpendingLimit, + baseToStake: new BN(0), + teamSponsoredPassThresholdBps: 300, + teamAddress: context.payer.publicKey, + }, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), + ]) + .rpc(); + + return getDaoAddr({ nonce, daoCreator: context.payer.publicKey })[0]; +} + +// The vault PDA can only sign via a Squads vault transaction execution, so the +// record is written by creating + approving + executing one containing a +// single set_spending_limit instruction. +async function executeSetSpendingLimitViaVault( + context: TestContext, + dao: PublicKey, + config: { amountPerMonth: BN; members: PublicKey[] } | null, +) { + const daoAccount = await context.futarchy.getDao(dao); + const multisigPda = daoAccount.squadsMultisig; + + const multisigAccount = await multisig.accounts.Multisig.fromAccountAddress( + context.squadsConnection, + multisigPda, + ); + const transactionIndex = + BigInt(multisigAccount.transactionIndex.toString()) + 1n; + + const setSpendingLimitIx = await context.futarchy + .setSpendingLimitIx({ dao, config }) + .instruction(); + + const { tx: createTx } = context.futarchy.squadsProposalCreateTx({ + dao, + instructions: [setSpendingLimitIx], + transactionIndex, + }); + createTx.recentBlockhash = ( + await context.banksClient.getLatestBlockhash() + )[0]; + createTx.feePayer = context.payer.publicKey; + createTx.sign(context.payer, PERMISSIONLESS_ACCOUNT); + await context.banksClient.processTransaction(createTx); + + const [squadsProposal] = multisig.getProposalPda({ + multisigPda, + transactionIndex, + }); + + const [enqueuedApproval] = PublicKey.findProgramAddressSync( + [ + SEED_ENQUEUED_APPROVAL, + dao.toBuffer(), + new BN(transactionIndex.toString()).toArrayLike(Buffer, "le", 8), + ], + context.futarchy.futarchy.programId, + ); + + await context.futarchy.futarchy.methods + .adminEnqueueMultisigProposalApproval({ + transactionIndex: new BN(transactionIndex.toString()), + }) + .accounts({ + dao, + admin: context.payer.publicKey, + squadsMultisig: multisigPda, + squadsMultisigProposal: squadsProposal, + enqueuedApproval, + }) + .rpc(); + + await context.futarchy.futarchy.methods + .executeMultisigProposalApproval() + .accounts({ + dao, + rentReceiver: context.payer.publicKey, + squadsMultisig: multisigPda, + squadsMultisigProposal: squadsProposal, + enqueuedApproval, + squadsMultisigProgram: multisig.PROGRAM_ID, + }) + .rpc(); + + // Execute as a top-level Squads instruction so the vault PDA signs the + // inner set_spending_limit + const executeIx = await multisig.instructions.vaultTransactionExecute({ + connection: context.squadsConnection, + multisigPda, + transactionIndex, + member: PERMISSIONLESS_ACCOUNT.publicKey, + }); + + const executeTx = new Transaction().add(executeIx.instruction); + executeTx.recentBlockhash = ( + await context.banksClient.getLatestBlockhash() + )[0]; + executeTx.feePayer = context.payer.publicKey; + executeTx.sign(context.payer, PERMISSIONLESS_ACCOUNT); + await context.banksClient.processTransaction(executeTx); +} + +export default function suite() { + let META: PublicKey, USDC: PublicKey, dao: PublicKey; + + beforeEach(async function () { + META = await this.createMint(this.payer.publicKey, 6); + USDC = await this.createMint(this.payer.publicKey, 6); + + dao = await initializeTestDao(this, META, USDC, { + amountPerMonth: new BN(10_000_000_000), // 10,000 USDC + members: [this.payer.publicKey], + }); + }); + + it("throws SpendingLimitNotDirty when the flag is clear", async function () { + const daoAccount = await this.futarchy.getDao(dao); + assert.isFalse(daoAccount.spendingLimitDirty); + + const callbacks = expectError( + "SpendingLimitNotDirty", + "sync should refuse when the record has not changed", + ); + + await this.futarchy + .syncSpendingLimitIx({ dao }) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("removes and recreates the Squads limit to match the record", async function () { + const newMembers = [ + Keypair.generate().publicKey, + Keypair.generate().publicKey, + ]; + + await executeSetSpendingLimitViaVault(this, dao, { + amountPerMonth: new BN(25_000_000_000), // 25,000 USDC + members: newMembers, + }); + + const [spendingLimitPda] = getSpendingLimitAddr({ dao }); + + // The record is written but not yet projected: Squads still holds the old limit + let storedLimit = await multisig.accounts.SpendingLimit.fromAccountAddress( + this.squadsConnection, + spendingLimitPda, + ); + assert.equal(storedLimit.amount.toString(), "10000000000"); + + await this.futarchy.syncSpendingLimitIx({ dao }).rpc(); + + storedLimit = await multisig.accounts.SpendingLimit.fromAccountAddress( + this.squadsConnection, + spendingLimitPda, + ); + assert.ok(storedLimit.createKey.equals(dao)); + assert.equal(storedLimit.vaultIndex, 0); + assert.ok(storedLimit.mint.equals(USDC)); + assert.equal(storedLimit.amount.toString(), "25000000000"); + assert.equal(storedLimit.remainingAmount.toString(), "25000000000"); + assert.equal(storedLimit.period, Period.Month); + // Squads stores members sorted, so compare as sets + assert.sameMembers( + storedLimit.members.map((m) => m.toBase58()), + newMembers.map((m) => m.toBase58()), + ); + assert.equal(storedLimit.destinations.length, 0); + + const daoAccount = await this.futarchy.getDao(dao); + assert.isFalse(daoAccount.spendingLimitDirty); + + // The flag is consumed: an immediate re-sync (a would-be monthly budget + // refresh) refuses + const callbacks = expectError( + "SpendingLimitNotDirty", + "re-sync should refuse once the flag is cleared", + ); + + await this.futarchy + .syncSpendingLimitIx({ dao }) + .postInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 200_000 }), + ]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("removes the Squads limit without recreating when the record is None", async function () { + await executeSetSpendingLimitViaVault(this, dao, null); + + const [spendingLimitPda] = getSpendingLimitAddr({ dao }); + const limitAccountBefore = + await this.banksClient.getAccount(spendingLimitPda); + const freedRent = limitAccountBefore.lamports; + + const rentPayer = Keypair.generate(); + + await this.futarchy + .syncSpendingLimitIx({ dao, rentPayer: rentPayer.publicKey }) + .signers([rentPayer]) + .rpc(); + + assert.isNull(await this.banksClient.getAccount(spendingLimitPda)); + + const rentPayerAccount = await this.banksClient.getAccount( + rentPayer.publicKey, + ); + assert.equal(rentPayerAccount.lamports.toString(), freedRent.toString()); + + const daoAccount = await this.futarchy.getDao(dao); + assert.isNull(daoAccount.initialSpendingLimit); + assert.isFalse(daoAccount.spendingLimitDirty); + }); + + it("creates the Squads limit from scratch when none exists", async function () { + const noLimitDao = await initializeTestDao(this, META, USDC, null); + + const [spendingLimitPda] = getSpendingLimitAddr({ dao: noLimitDao }); + assert.isNull(await this.banksClient.getAccount(spendingLimitPda)); + + const members = [Keypair.generate().publicKey]; + await executeSetSpendingLimitViaVault(this, noLimitDao, { + amountPerMonth: new BN(1_000_000_000), // 1,000 USDC + members, + }); + + await this.futarchy.syncSpendingLimitIx({ dao: noLimitDao }).rpc(); + + const storedLimit = + await multisig.accounts.SpendingLimit.fromAccountAddress( + this.squadsConnection, + spendingLimitPda, + ); + assert.equal(storedLimit.amount.toString(), "1000000000"); + assert.equal(storedLimit.remainingAmount.toString(), "1000000000"); + assert.deepEqual( + storedLimit.members.map((m) => m.toBase58()), + members.map((m) => m.toBase58()), + ); + + const daoAccount = await this.futarchy.getDao(noLimitDao); + assert.isFalse(daoAccount.spendingLimitDirty); + }); + + it("succeeds when both legs are no-ops", async function () { + const noLimitDao = await initializeTestDao(this, META, USDC, null); + + await executeSetSpendingLimitViaVault(this, noLimitDao, null); + + let daoAccount = await this.futarchy.getDao(noLimitDao); + assert.isTrue(daoAccount.spendingLimitDirty); + + await this.futarchy.syncSpendingLimitIx({ dao: noLimitDao }).rpc(); + + const [spendingLimitPda] = getSpendingLimitAddr({ dao: noLimitDao }); + assert.isNull(await this.banksClient.getAccount(spendingLimitPda)); + + daoAccount = await this.futarchy.getDao(noLimitDao); + assert.isFalse(daoAccount.spendingLimitDirty); + }); +} diff --git a/tests/futarchy/unit/updateDao.test.ts b/tests/futarchy/unit/updateDao.test.ts index 50b8fc00..0eeaea7a 100644 --- a/tests/futarchy/unit/updateDao.test.ts +++ b/tests/futarchy/unit/updateDao.test.ts @@ -17,7 +17,6 @@ import { sha256, } from "@metadaoproject/programs"; import BN from "bn.js"; -import { setOptimisticGovernanceEnabled } from "../../utils.js"; const THOUSAND_BUCK_PRICE = PriceMath.getAmmPrice(1000, 9, 6); @@ -187,7 +186,7 @@ export default function suite() { .splitTokensIx(question, baseVault, META, new BN(1000_000_000), 2) .rpc(); await this.conditionalVault - .splitTokensIx(question, quoteVault, USDC, new BN(1000_000_000), 2) + .splitTokensIx(question, quoteVault, USDC, new BN(6_000_000_000), 2) .rpc(); // Launch proposal A to put DAO in Futarchy state @@ -205,8 +204,9 @@ export default function suite() { let daoState = await this.futarchy.getDao(dao); assert.isDefined(daoState.amm.state.futarchy); - // Step 3: Trade on pass market to make proposal A pass - // Using conditionalSwapIx which handles all the AMM interaction + // Step 3: Trade on pass market to make proposal A pass. 5,000 USDC into + // ~50,000 USDC reserves moves the pass price ~20%, clearing the + // proposal's +10% snapshot threshold. await this.futarchy .conditionalSwapIx({ dao, @@ -215,7 +215,7 @@ export default function suite() { proposal: proposalA, market: "pass", swapType: "buy", - inputAmount: new BN(900_000_000), + inputAmount: new BN(5_000_000_000), minOutputAmount: new BN(0), }) .rpc(); @@ -387,208 +387,4 @@ export default function suite() { ); } }); - - it("should fail updateDao execution when DAO has an active optimistic proposal", async function () { - const daoAccount = await this.futarchy.getDao(dao); - - // Step 1: Create updateDao squads vault transaction (index 1) - const updateDaoIx = await this.futarchy - .updateDaoIx({ - dao, - params: { - passThresholdBps: 500, - secondsPerProposal: null, - twapInitialObservation: null, - twapMaxObservationChangePerUpdate: null, - minQuoteFutarchicLiquidity: null, - minBaseFutarchicLiquidity: null, - baseToStake: null, - teamSponsoredPassThresholdBps: null, - teamAddress: null, - twapStartDelaySeconds: null, - isOptimisticGovernanceEnabled: null, - }, - }) - .instruction(); - - const updateDaoMessage = new TransactionMessage({ - payerKey: daoAccount.squadsMultisigVault, - recentBlockhash: (await this.banksClient.getLatestBlockhash())[0], - instructions: [updateDaoIx], - }); - - const vaultTxCreateIx = multisig.instructions.vaultTransactionCreate({ - multisigPda: daoAccount.squadsMultisig, - transactionIndex: 1n, - creator: PERMISSIONLESS_ACCOUNT.publicKey, - rentPayer: this.payer.publicKey, - vaultIndex: 0, - ephemeralSigners: 0, - transactionMessage: updateDaoMessage, - }); - - const squadsProposalCreateIx = multisig.instructions.proposalCreate({ - multisigPda: daoAccount.squadsMultisig, - transactionIndex: 1n, - creator: PERMISSIONLESS_ACCOUNT.publicKey, - rentPayer: this.payer.publicKey, - }); - - const [squadsProposalPda] = multisig.getProposalPda({ - multisigPda: daoAccount.squadsMultisig, - transactionIndex: 1n, - }); - - const createSquadsTx = new Transaction().add( - vaultTxCreateIx, - squadsProposalCreateIx, - ); - createSquadsTx.recentBlockhash = ( - await this.banksClient.getLatestBlockhash() - )[0]; - createSquadsTx.feePayer = this.payer.publicKey; - createSquadsTx.sign(this.payer, PERMISSIONLESS_ACCOUNT); - - await this.banksClient.processTransaction(createSquadsTx); - - // Step 2: Create futarchy proposal A linked to updateDao squads proposal - let [proposalA] = getProposalAddrV2({ squadsProposal: squadsProposalPda }); - - await this.conditionalVault.initializeQuestion( - sha256(`Will ${proposalA} pass?/FAIL/PASS`), - proposalA, - 2, - ); - - const { question, baseVault, quoteVault } = this.futarchy.getProposalPdas( - proposalA, - META, - USDC, - dao, - ); - - await this.conditionalVault - .initializeVaultIx(question, META, 2) - .postInstructions( - await InstructionUtils.getInstructions( - this.conditionalVault.initializeVaultIx(question, USDC, 2), - ), - ) - .rpc(); - - await this.futarchy - .initializeProposalIx(squadsProposalPda, dao, META, USDC, question) - .preInstructions([ - ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), - ]) - .rpc(); - - // Split tokens before launching proposal - await this.conditionalVault - .splitTokensIx(question, baseVault, META, new BN(1000_000_000), 2) - .rpc(); - await this.conditionalVault - .splitTokensIx(question, quoteVault, USDC, new BN(1000_000_000), 2) - .rpc(); - - // Launch proposal A to put DAO in Futarchy state - await this.futarchy - .launchProposalIx({ - proposal: proposalA, - dao, - baseMint: META, - quoteMint: USDC, - squadsProposal: squadsProposalPda, - }) - .rpc(); - - // Step 3: Trade on pass market to make proposal A pass - await this.futarchy - .conditionalSwapIx({ - dao, - baseMint: META, - quoteMint: USDC, - proposal: proposalA, - market: "pass", - swapType: "buy", - inputAmount: new BN(900_000_000), - minOutputAmount: new BN(0), - }) - .rpc(); - - // Crank TWAP over time by doing small swaps - for (let i = 0; i < 100; i++) { - await this.advanceBySeconds(20_000); - - await this.futarchy - .conditionalSwapIx({ - dao, - baseMint: META, - quoteMint: USDC, - proposal: proposalA, - market: "pass", - swapType: "buy", - inputAmount: new BN(10), - minOutputAmount: new BN(0), - payer: this.payer.publicKey, - }) - .preInstructions([ - ComputeBudgetProgram.setComputeUnitPrice({ microLamports: i }), - ]) - .rpc(); - } - - // Step 4: Finalize proposal A - DAO returns to Spot state - await this.futarchy.finalizeProposal(proposalA); - - // Verify DAO is in Spot state - let daoState = await this.futarchy.getDao(dao); - assert.isDefined(daoState.amm.state.spot); - - // Step 5: Enqueue an optimistic proposal - await setOptimisticGovernanceEnabled(this, dao, true); - - await this.futarchy - .initiateVaultSpendOptimisticProposalIx({ - dao, - quoteMint: USDC, - amount: new BN(1000), - recipient: this.payer.publicKey, - transactionIndex: 2n, - }) - .signers([this.payer, PERMISSIONLESS_ACCOUNT]) - .rpc(); - - // Verify optimistic proposal is set - const updatedDao = await this.futarchy.getDao(dao); - assert.exists(updatedDao.optimisticProposal); - - // Step 6: Try to execute updateDao - should fail with ActiveOptimisticProposalAlreadyEnqueued - const txExecuteIx = await multisig.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); - - try { - await this.banksClient.processTransaction(txExecute); - assert.fail( - "Should have failed with ActiveOptimisticProposalAlreadyEnqueued", - ); - } catch (e) { - assert( - e.toString().includes("ActiveOptimisticProposalAlreadyEnqueued") || - e.toString().includes("0x1797"), - `Expected ActiveOptimisticProposalAlreadyEnqueued error, got: ${e}`, - ); - } - }); } diff --git a/tests/gatedMint/unit/removeWhitelistedUser.test.ts b/tests/gatedMint/unit/removeWhitelistedUser.test.ts index 713743f1..47d8281d 100644 --- a/tests/gatedMint/unit/removeWhitelistedUser.test.ts +++ b/tests/gatedMint/unit/removeWhitelistedUser.test.ts @@ -191,8 +191,9 @@ export default function suite() { .rpc(); assert.isNull(await this.banksClient.getAccount(addr)); - // The PDA was freed, so the same user can be whitelisted again. - await whitelistUser(gatedMintClient, mint, admin, user, this.payer); + // The PDA was freed, so the same user can be whitelisted again. The + // compute price keeps this transaction distinct from the first add. + await whitelistUser(gatedMintClient, mint, admin, user, this.payer, 1); assert.isNotNull(await this.banksClient.getAccount(addr)); }); diff --git a/tests/gatedMint/utils.ts b/tests/gatedMint/utils.ts index 16cf81bd..88fcb171 100644 --- a/tests/gatedMint/utils.ts +++ b/tests/gatedMint/utils.ts @@ -1,4 +1,5 @@ import { + ComputeBudgetProgram, PublicKey, Keypair, Transaction, @@ -89,6 +90,9 @@ export async function whitelistUser( authority: Keypair, user: PublicKey, payer: Keypair, + // Pass when repeating an identical call (e.g. re-adding a removed user) so + // the transaction signature is unique within the blockhash window + computeUnitPrice?: number, ): Promise { const providerKey = gatedMintClient.provider.publicKey; const signers: Keypair[] = []; @@ -109,6 +113,15 @@ export async function whitelistUser( user, payer: payer.publicKey, }) + .postInstructions( + computeUnitPrice + ? [ + ComputeBudgetProgram.setComputeUnitPrice({ + microLamports: computeUnitPrice, + }), + ] + : [], + ) .signers(signers) .rpc(); diff --git a/tests/performancePackageV2/unit/completeUnlock.test.ts b/tests/performancePackageV2/unit/completeUnlock.test.ts index 45d1b40d..dcf23871 100644 --- a/tests/performancePackageV2/unit/completeUnlock.test.ts +++ b/tests/performancePackageV2/unit/completeUnlock.test.ts @@ -543,6 +543,9 @@ export default function suite() { recipient: recipient.publicKey, signer: authority.publicKey, }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), + ]) .signers([authority]) .rpc(); @@ -657,6 +660,9 @@ export default function suite() { signer: recipient.publicKey, dao, }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), + ]) .signers([recipient]) .rpc(); @@ -822,6 +828,9 @@ export default function suite() { recipient: recipient.publicKey, signer: authority.publicKey, }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), + ]) .signers([authority]) .rpc(); diff --git a/tests/utils.ts b/tests/utils.ts index 4792adfe..526fcc2c 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -8,9 +8,15 @@ import { Keypair, PublicKey, Transaction, + TransactionInstruction, } from "@solana/web3.js"; +import * as multisig from "@sqds/multisig"; import { TestContext } from "./main.test.js"; -import { getDaoAddr, PriceMath } from "@metadaoproject/programs"; +import { + getDaoAddr, + PERMISSIONLESS_ACCOUNT, + PriceMath, +} from "@metadaoproject/programs"; export const TEN_SECONDS_IN_SLOTS = 25n; export const ONE_MINUTE_IN_SLOTS = TEN_SECONDS_IN_SLOTS * 6n; @@ -20,7 +26,7 @@ export const DAY_IN_SLOTS = HOUR_IN_SLOTS * 24n; export const toBN = (val: bigint): typeof BN.prototype => new BN(val.toString()); -const THOUSAND_BUCK_PRICE = PriceMath.getAmmPrice(1000, 6, 6); +export const THOUSAND_BUCK_PRICE = PriceMath.getAmmPrice(1000, 6, 6); export async function setupBasicDao({ context, @@ -70,22 +76,183 @@ export async function setupBasicDao({ return dao; } -export async function setOptimisticGovernanceEnabled( +// Pumps the pass market with a one-shot conditional-quote buy, then cranks +// the TWAPs `cranks` times, 20,000s apart. The defaults clear every kind's +// threshold (including HostileLiquidate's +25%) for the standard test market +// (~62,500 USDC / 62.5 META per conditional pool at price 1e15) and outlast +// every kind's duration. Deeper pools need a larger buyAmount; tighter +// clamps or longer durations need more cranks. +export async function pumpPassMarket( + context: TestContext, + { + dao, + proposal, + baseMint, + quoteMint, + buyAmount = new BN(20_000 * 1_000_000), + cranks = 100, + }: { + dao: PublicKey; + proposal: PublicKey; + baseMint: PublicKey; + quoteMint: PublicKey; + buyAmount?: typeof BN.prototype; + cranks?: number; + }, +) { + const { question, baseVault, quoteVault } = context.futarchy.getProposalPdas( + proposal, + baseMint, + quoteMint, + dao, + ); + + // Splitting both sides also creates the trader's conditional token ATAs + await context.conditionalVault + .splitTokensIx(question, baseVault, baseMint, new BN(10 * 1_000_000), 2) + .rpc(); + await context.conditionalVault + .splitTokensIx( + question, + quoteVault, + quoteMint, + buyAmount.addn(cranks * 10 + 10_000), + 2, + ) + .rpc(); + + await context.futarchy + .conditionalSwapIx({ + dao, + baseMint, + quoteMint, + proposal, + market: "pass", + swapType: "buy", + inputAmount: buyAmount, + minOutputAmount: new BN(0), + }) + .rpc(); + + for (let i = 0; i < cranks; i++) { + await context.advanceBySeconds(20_000); + + await context.futarchy + .conditionalSwapIx({ + dao, + baseMint, + quoteMint, + proposal, + market: "pass", + swapType: "buy", + inputAmount: new BN(10), + minOutputAmount: new BN(0), + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitPrice({ microLamports: i }), + ]) + .rpc(); + } +} + +// pumpPassMarket, then finalize to Passed. +export async function passProposal( + context: TestContext, + args: { + dao: PublicKey; + proposal: PublicKey; + baseMint: PublicKey; + quoteMint: PublicKey; + buyAmount?: typeof BN.prototype; + cranks?: number; + }, +) { + await pumpPassMarket(context, args); + + await context.futarchy.finalizeProposal(args.proposal); + + const storedProposal = await context.futarchy.getProposal(args.proposal); + assert.exists(storedProposal.state.passed); +} + +// Squads' vault_transaction_execute gates only on the proposal's status, so +// flipping the borsh enum tag from Active (1) to Approved (3) — same payload +// shape, same size — makes the payload executable without running a market. +export async function forceApproveSquadsProposal( + context: TestContext, + squadsProposal: PublicKey, +) { + const account = await context.banksClient.getAccount(squadsProposal); + // 8 discriminator + 32 multisig + 8 transaction_index, then the status tag + assert.equal(account.data[48], 1); + account.data[48] = 3; + context.context.setAccount(squadsProposal, account); +} + +export async function executeVaultTransaction( context: TestContext, dao: PublicKey, - enabled: boolean, -): Promise { - const daoAccount = await context.futarchy.getDao(dao); - daoAccount.isOptimisticGovernanceEnabled = enabled; - const daoAccountBuffer = - await context.futarchy.futarchy.account.dao.coder.accounts.encode( - "dao", - daoAccount, + squadsTransaction: PublicKey, +) { + const vaultTransaction = + await multisig.accounts.VaultTransaction.fromAccountAddress( + context.squadsConnection, + squadsTransaction, ); - const daoBanksAccount = await context.banksClient.getAccount(dao); - daoBanksAccount.data.set(daoAccountBuffer, 0); - context.context.setAccount(dao, daoBanksAccount); + const { instruction } = await multisig.instructions.vaultTransactionExecute({ + connection: context.squadsConnection, + multisigPda: multisig.getMultisigPda({ createKey: dao })[0], + transactionIndex: BigInt(vaultTransaction.index.toString()), + member: PERMISSIONLESS_ACCOUNT.publicKey, + }); + + const tx = new Transaction().add(instruction); + [tx.recentBlockhash] = await context.banksClient.getLatestBlockhash(); + tx.feePayer = context.payer.publicKey; + tx.sign(context.payer, PERMISSIONLESS_ACCOUNT); + await context.banksClient.processTransaction(tx); +} + +// The payload a typed create baked into its Squads vault transaction must be +// byte-identical to the expected instructions, in order, with the DAO's vault +// as the inner transaction's only signer — nothing re-validates the payload at +// execution, so exactness at create is the security model. +export async function assertVaultTransactionPayload( + context: TestContext, + dao: PublicKey, + squadsTransaction: PublicKey, + expectedIxs: TransactionInstruction[], +) { + const { squadsMultisigVault } = await context.futarchy.getDao(dao); + + const vaultTransaction = + await multisig.accounts.VaultTransaction.fromAccountAddress( + context.squadsConnection, + squadsTransaction, + ); + const message = vaultTransaction.message; + + assert.equal(message.instructions.length, expectedIxs.length); + assert.equal(message.numSigners, 1); + assert.ok(message.accountKeys[0].equals(squadsMultisigVault)); + + expectedIxs.forEach((expectedIx, i) => { + const innerIx = message.instructions[i]; + assert.ok( + message.accountKeys[innerIx.programIdIndex].equals(expectedIx.programId), + ); + assert.deepEqual( + [...innerIx.accountIndexes].map((index) => + message.accountKeys[index].toBase58(), + ), + expectedIx.keys.map((key) => key.pubkey.toBase58()), + ); + assert.equal( + Buffer.from(innerIx.data).toString("hex"), + expectedIx.data.toString("hex"), + ); + }); } /**