diff --git a/programs/futarchy/src/error.rs b/programs/futarchy/src/error.rs index 508a07e0..2596d0a4 100644 --- a/programs/futarchy/src/error.rs +++ b/programs/futarchy/src/error.rs @@ -118,7 +118,7 @@ pub enum FutarchyError { EmptyProposalParamsUpdate, #[msg("Buyback amount exceeds 25% of the treasury")] BuybackCapExceeded, - #[msg("The total must be an exact multiple of the non-zero per-cycle amount, at least twice over")] + #[msg("Buyback total must be non-zero")] InvalidBuybackAmount, #[msg("Cycle frequency must be between 60 seconds and 1 year")] InvalidBuybackCycleFrequency, @@ -132,4 +132,18 @@ pub enum FutarchyError { TreasuryAccountsNotSorted, #[msg("This proposal kind's launch takes no extra accounts")] UnexpectedLaunchAccounts, + #[msg("Spending limit account is not the canonical spending-limit PDA")] + InvalidSpendingLimitAccount, + #[msg("The DAO's team has changed since this draft was created")] + StaleTeamAddress, + #[msg("Account is not migrated to latest layout")] + AccountNotMigrated, + #[msg("A spending limit's monthly amount must be non-zero")] + InvalidSpendingLimitAmount, + #[msg("A spending limit must have at least one member")] + EmptySpendingLimitMembers, + #[msg("A spending limit's members must be unique")] + DuplicateSpendingLimitMember, + #[msg("A buyback must run at least two cycles")] + InvalidBuybackCycleCount, } diff --git a/programs/futarchy/src/events.rs b/programs/futarchy/src/events.rs index 06498b16..22eb8e04 100644 --- a/programs/futarchy/src/events.rs +++ b/programs/futarchy/src/events.rs @@ -262,14 +262,3 @@ pub struct SyncSpendingLimitEvent { /// `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 ce38d2e9..b220fa56 100644 --- a/programs/futarchy/src/instructions/admin_cancel_proposal.rs +++ b/programs/futarchy/src/instructions/admin_cancel_proposal.rs @@ -69,6 +69,10 @@ pub struct AdminCancelProposal<'info> { impl AdminCancelProposal<'_> { pub fn validate(&self) -> Result<()> { + // Ensure the proposal and DAO are migrated. + Proposal::assert_migrated(&self.proposal.to_account_info())?; + Dao::assert_migrated(&self.dao.to_account_info())?; + // 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. 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 9d6ecaff..2ccc1ff0 100644 --- a/programs/futarchy/src/instructions/admin_enqueue_multisig_proposal_approval.rs +++ b/programs/futarchy/src/instructions/admin_enqueue_multisig_proposal_approval.rs @@ -62,6 +62,9 @@ pub struct AdminEnqueueMultisigProposalApproval<'info> { impl AdminEnqueueMultisigProposalApproval<'_> { pub fn validate(&self, _args: &AdminEnqueueMultisigProposalApprovalArgs) -> Result<()> { + // Ensure the DAO is migrated before reading `liquidator`. + Dao::assert_migrated(&self.dao.to_account_info())?; + // 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 diff --git a/programs/futarchy/src/instructions/apply_liquidation.rs b/programs/futarchy/src/instructions/apply_liquidation.rs deleted file mode 100644 index d3abd847..00000000 --- a/programs/futarchy/src/instructions/apply_liquidation.rs +++ /dev/null @@ -1,163 +0,0 @@ -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; - (base_swept, quote_swept) = withdraw_from_position( - dao, - &mut position, - liquidity_to_sweep, - amm_base_vault, - amm_quote_vault, - vault_base_account, - vault_quote_account, - token_program, - )?; - - // The position sits behind an UncheckedAccount, so Anchor - // won't write it back on exit — persist it manually. - { - let mut data = amm_position.try_borrow_mut_data()?; - let mut writer: &mut [u8] = &mut data; - position.try_serialize(&mut writer)?; - } - } - } - - 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/finalize_proposal.rs b/programs/futarchy/src/instructions/finalize_proposal.rs index e3b4ec4b..100cea8a 100644 --- a/programs/futarchy/src/instructions/finalize_proposal.rs +++ b/programs/futarchy/src/instructions/finalize_proposal.rs @@ -62,6 +62,10 @@ pub struct FinalizeProposal<'info> { impl FinalizeProposal<'_> { pub fn validate(&self) -> Result<()> { + // Ensure the proposal and DAO are migrated. + Proposal::assert_migrated(&self.proposal.to_account_info())?; + Dao::assert_migrated(&self.dao.to_account_info())?; + let clock = Clock::get()?; require_gte!( @@ -174,6 +178,20 @@ impl FinalizeProposal<'_> { } } + // In case of a hostile liquidation, set the liquidator immediately. + if new_proposal_state == ProposalState::Passed { + if let ProposalAction::HostileLiquidate { liquidator } = &proposal.action { + dao.liquidator = Some(*liquidator); + + // The spending limit must be zeroed so that the estate can be swept. + // Otherwise a still-live limit member could drain the estate. + if dao.initial_spending_limit.is_some() { + dao.initial_spending_limit = None; + dao.spending_limit_dirty = true; + } + } + } + // The buyback cooldown stamps on either outcome: it rate-limits an // action the DAO consented to — draining the treasury through a // sequence of individually reasonable votes — rather than deterring diff --git a/programs/futarchy/src/instructions/initialize_buyback_token_proposal.rs b/programs/futarchy/src/instructions/initialize_buyback_token_proposal.rs index 4318c59d..fb5e8d11 100644 --- a/programs/futarchy/src/instructions/initialize_buyback_token_proposal.rs +++ b/programs/futarchy/src/instructions/initialize_buyback_token_proposal.rs @@ -1,8 +1,11 @@ use super::*; -/// The venue's DCA interface bounds (Jupiter's Trigger API suite): an -/// integral order count of at least 2, an interval between a minute and a -/// year, and a start at most 30 days out. +/// The venue's DCA interface bounds (Jupiter's Trigger API suite): a total +/// split across an integral order count of at least 2, with any remainder +/// landing in the last order; an interval between a minute and a year; and a +/// start at most 30 days out. Its per-order value floor is a USD figure set by +/// venue policy, so it is deliberately not mirrored here. +pub const MIN_BUYBACK_CYCLE_COUNT: u32 = 2; pub const MIN_BUYBACK_CYCLE_SECONDS: u32 = 60; pub const MAX_BUYBACK_CYCLE_SECONDS: u32 = 365 * DAY_SECONDS; pub const MAX_BUYBACK_START_DELAY_SECONDS: u32 = 30 * DAY_SECONDS; @@ -10,7 +13,7 @@ pub const MAX_BUYBACK_START_DELAY_SECONDS: u32 = 30 * DAY_SECONDS; #[derive(AnchorSerialize, AnchorDeserialize, Clone)] pub struct InitializeBuybackTokenProposalArgs { pub quote_amount: u64, - pub quote_amount_per_cycle: u64, + pub cycle_count: u32, pub cycle_frequency_seconds: u32, pub start_delay_seconds: u32, pub min_price: Option, @@ -27,23 +30,12 @@ impl InitializeBuybackTokenProposal<'_> { pub fn validate(&self, args: &InitializeBuybackTokenProposalArgs) -> Result<()> { self.typed_initialize_accounts.validate()?; - // The venue takes an integral order count with a two-order minimum, - // so the total must be an exact multiple of the per-cycle amount, at - // least twice over. A zero total falls out of the two-order check. - require_gt!( - args.quote_amount_per_cycle, - 0, - FutarchyError::InvalidBuybackAmount - ); - require_eq!( - args.quote_amount % args.quote_amount_per_cycle, - 0, - FutarchyError::InvalidBuybackAmount - ); + // A zero total is a mandate to buy nothing. + require_gt!(args.quote_amount, 0, FutarchyError::InvalidBuybackAmount); require_gte!( - args.quote_amount / args.quote_amount_per_cycle, - 2, - FutarchyError::InvalidBuybackAmount + args.cycle_count, + MIN_BUYBACK_CYCLE_COUNT, + FutarchyError::InvalidBuybackCycleCount ); require_gte!( @@ -78,10 +70,10 @@ impl InitializeBuybackTokenProposal<'_> { None => "none".to_string(), }; let memo = format!( - "metadao-buyback/1 proposal={} spend={} per_cycle={} cycle_seconds={} start_delay={} min_price={} max_price={}", + "metadao-buyback/1 proposal={} spend={} cycles={} cycle_seconds={} start_delay={} min_price={} max_price={}", typed_initialize_accounts.proposal.key(), args.quote_amount, - args.quote_amount_per_cycle, + args.cycle_count, args.cycle_frequency_seconds, args.start_delay_seconds, format_price(args.min_price), @@ -94,7 +86,7 @@ impl InitializeBuybackTokenProposal<'_> { &[memo_ix], ProposalAction::BuybackToken { quote_amount: args.quote_amount, - quote_amount_per_cycle: args.quote_amount_per_cycle, + cycle_count: args.cycle_count, cycle_frequency_seconds: args.cycle_frequency_seconds, start_delay_seconds: args.start_delay_seconds, min_price: args.min_price, diff --git a/programs/futarchy/src/instructions/initialize_dao.rs b/programs/futarchy/src/instructions/initialize_dao.rs index 4b9291ad..9c8161a1 100644 --- a/programs/futarchy/src/instructions/initialize_dao.rs +++ b/programs/futarchy/src/instructions/initialize_dao.rs @@ -147,10 +147,7 @@ impl InitializeDao<'_> { )?; if let Some(initial_spending_limit) = initial_spending_limit.clone() { - require_gte!( - MAX_SPENDING_LIMIT_MEMBERS, - initial_spending_limit.members.len() - ); + initial_spending_limit.validate()?; squads_multisig_program::cpi::multisig_add_spending_limit( CpiContext::new_with_signer( diff --git a/programs/futarchy/src/instructions/initialize_hostile_liquidate_proposal.rs b/programs/futarchy/src/instructions/initialize_hostile_liquidate_proposal.rs index 62ce649d..d9e7654b 100644 --- a/programs/futarchy/src/instructions/initialize_hostile_liquidate_proposal.rs +++ b/programs/futarchy/src/instructions/initialize_hostile_liquidate_proposal.rs @@ -1,8 +1,12 @@ -use anchor_lang::solana_program::instruction::Instruction; -use anchor_lang::InstructionData; - use super::*; +pub mod metadao_multisig_vault { + use anchor_lang::prelude::declare_id; + + // MetaDAO operations multisig vault + declare_id!("6awyHMshBGVjJ3ozdSJdyyDE1CTAXUwrpNMaRGMsb4sf"); +} + #[derive(AnchorSerialize, AnchorDeserialize, Clone)] pub struct InitializeHostileLiquidateProposalArgs { pub liquidator: Pubkey, @@ -15,56 +19,22 @@ pub struct InitializeHostileLiquidateProposal<'info> { } impl InitializeHostileLiquidateProposal<'_> { - pub fn validate(&self) -> Result<()> { + pub fn validate(&self, args: &InitializeHostileLiquidateProposalArgs) -> Result<()> { + // Only the MetaDAO operations multisig vault can be named liquidator in production. + #[cfg(feature = "production")] + require_keys_eq!( + args.liquidator, + metadao_multisig_vault::ID, + FutarchyError::InvalidLiquidator + ); + #[cfg(not(feature = "production"))] + let _ = args; + self.typed_initialize_accounts.validate() } pub fn handle(ctx: Context, args: InitializeHostileLiquidateProposalArgs) -> Result<()> { let typed_initialize_accounts = &mut ctx.accounts.typed_initialize_accounts; - let dao = &typed_initialize_accounts.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: typed_initialize_accounts.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(), - }; // The IP transfer is a legal-layer fact. // The memo records it in the executed transaction. @@ -73,8 +43,10 @@ impl InitializeHostileLiquidateProposal<'_> { &[], ); + // The on-chain actions of liquidation are handled by the liquidator. + let event = typed_initialize_accounts.initialize_proposal( - &[apply_liquidation_ix, memo_ix], + &[memo_ix], ProposalAction::HostileLiquidate { liquidator: args.liquidator, }, diff --git a/programs/futarchy/src/instructions/initialize_hostile_takeover_proposal.rs b/programs/futarchy/src/instructions/initialize_hostile_takeover_proposal.rs index 87621ea7..77af7fd2 100644 --- a/programs/futarchy/src/instructions/initialize_hostile_takeover_proposal.rs +++ b/programs/futarchy/src/instructions/initialize_hostile_takeover_proposal.rs @@ -20,11 +20,7 @@ impl InitializeHostileTakeoverProposal<'_> { self.typed_initialize_accounts.validate()?; if let SpendingLimitAction::Set(config) = &args.spending_limit_action { - require_gte!( - MAX_SPENDING_LIMIT_MEMBERS, - config.members.len(), - FutarchyError::TooManySpendingLimitMembers - ); + config.validate()?; } Ok(()) diff --git a/programs/futarchy/src/instructions/initialize_large_spend_proposal.rs b/programs/futarchy/src/instructions/initialize_large_spend_proposal.rs index d3ce5c50..6f67f133 100644 --- a/programs/futarchy/src/instructions/initialize_large_spend_proposal.rs +++ b/programs/futarchy/src/instructions/initialize_large_spend_proposal.rs @@ -15,18 +15,7 @@ impl InitializeLargeSpendProposal<'_> { pub fn validate(&self, args: &InitializeLargeSpendProposalArgs) -> Result<()> { self.typed_initialize_accounts.validate()?; - let record = self - .typed_initialize_accounts - .dao - .initial_spending_limit - .as_ref() - .ok_or(FutarchyError::NoSpendingLimit)?; - - require_gte!( - record.amount_per_month.saturating_mul(3), - args.amount, - FutarchyError::SpendCapExceeded - ); + verify_large_spend_cap(args.amount, &self.typed_initialize_accounts.dao)?; Ok(()) } @@ -35,8 +24,9 @@ impl InitializeLargeSpendProposal<'_> { let typed_initialize_accounts = &mut ctx.accounts.typed_initialize_accounts; let dao = &typed_initialize_accounts.dao; - // The recipient is pinned to the DAO's team address at create; a later - // team change does not re-point it. + // The recipient is pinned to the DAO's team address at create. The + // action snapshots the same team so launch can reject the draft if the + // team has changed since. let transfer_ix = token::spl_token::instruction::transfer( &token::ID, &anchor_spl::associated_token::get_associated_token_address( @@ -56,6 +46,7 @@ impl InitializeLargeSpendProposal<'_> { &[transfer_ix], ProposalAction::LargeSpend { amount: args.amount, + team_address: dao.team_address, }, ctx.bumps.typed_initialize_accounts.proposal, )?; diff --git a/programs/futarchy/src/instructions/initialize_spending_limit_change_proposal.rs b/programs/futarchy/src/instructions/initialize_spending_limit_change_proposal.rs index f58ac583..a3b2fc17 100644 --- a/programs/futarchy/src/instructions/initialize_spending_limit_change_proposal.rs +++ b/programs/futarchy/src/instructions/initialize_spending_limit_change_proposal.rs @@ -20,11 +20,7 @@ impl InitializeSpendingLimitChangeProposal<'_> { self.typed_initialize_accounts.validate()?; if let Some(config) = &args.config { - require_gte!( - MAX_SPENDING_LIMIT_MEMBERS, - config.members.len(), - FutarchyError::TooManySpendingLimitMembers - ); + config.validate()?; } Ok(()) diff --git a/programs/futarchy/src/instructions/mod.rs b/programs/futarchy/src/instructions/mod.rs index 89034d7d..cb7d3bc7 100644 --- a/programs/futarchy/src/instructions/mod.rs +++ b/programs/futarchy/src/instructions/mod.rs @@ -5,7 +5,6 @@ pub mod admin_enqueue_multisig_proposal_approval; pub mod admin_execute_multisig_proposal; pub mod admin_remove_proposal; pub mod admin_update_proposal_params; -pub mod apply_liquidation; pub mod collect_fees; pub mod collect_meteora_damm_fees; pub mod conditional_swap; @@ -38,7 +37,6 @@ pub use admin_enqueue_multisig_proposal_approval::*; pub use admin_execute_multisig_proposal::*; pub use admin_remove_proposal::*; pub use admin_update_proposal_params::*; -pub use apply_liquidation::*; pub use collect_fees::*; pub use collect_meteora_damm_fees::*; pub use conditional_swap::*; diff --git a/programs/futarchy/src/instructions/resize_dao.rs b/programs/futarchy/src/instructions/resize_dao.rs index 5be9c25b..bfb8aa5c 100644 --- a/programs/futarchy/src/instructions/resize_dao.rs +++ b/programs/futarchy/src/instructions/resize_dao.rs @@ -1,4 +1,5 @@ use anchor_lang::{system_program, Discriminator}; +use squads_multisig_program::{Period, SpendingLimit}; use super::*; @@ -7,6 +8,10 @@ pub struct ResizeDao<'info> { /// CHECK: we check the discriminator #[account(mut)] pub dao: UncheckedAccount<'info>, + /// CHECK: verified in the handler against the canonical Squads + /// spending-limit PDA (`create_key` is always the DAO); read-only and may + /// not exist + pub spending_limit: UncheckedAccount<'info>, #[account(mut)] pub payer: Signer<'info>, pub system_program: Program<'info, System>, @@ -20,7 +25,7 @@ impl ResizeDao<'_> { let is_discriminator_correct = dao.try_borrow_data().unwrap()[..8] == Dao::discriminator(); require_eq!(is_discriminator_correct, true); - const AFTER_REALLOC_SIZE: usize = Dao::INIT_SPACE + 8; + const AFTER_REALLOC_SIZE: usize = Dao::MIGRATED_SIZE; // 58 bytes: 33 (Option liquidator) + 8 (i64) + 8 (i64) + 1 (bool) + 8 (i64) const BEFORE_REALLOC_SIZE: usize = AFTER_REALLOC_SIZE - 58; @@ -32,6 +37,28 @@ impl ResizeDao<'_> { let old_dao_data = OldDao::deserialize(&mut &dao.try_borrow_data().unwrap()[8..])?; + let (canonical_spending_limit, _) = Pubkey::find_program_address( + &[ + squads_multisig_program::SEED_PREFIX, + old_dao_data.squads_multisig.as_ref(), + squads_multisig_program::SEED_SPENDING_LIMIT, + dao.key().as_ref(), + ], + &squads_multisig_program::ID, + ); + require_keys_eq!( + ctx.accounts.spending_limit.key(), + canonical_spending_limit, + FutarchyError::InvalidSpendingLimitAccount + ); + + // The record must reflect the live Squads account because of the + // LargeSpend authorization cap. + let live_spending_limit = Self::live_canonical_spending_limit( + &ctx.accounts.spending_limit, + &old_dao_data.quote_mint, + ); + let new_dao_data = Dao { amm: old_dao_data.amm, nonce: old_dao_data.nonce, @@ -52,7 +79,7 @@ impl ResizeDao<'_> { min_base_futarchic_liquidity: old_dao_data.min_base_futarchic_liquidity, base_to_stake: old_dao_data.base_to_stake, seq_num: old_dao_data.seq_num, - initial_spending_limit: old_dao_data.initial_spending_limit, + initial_spending_limit: live_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 @@ -87,4 +114,30 @@ impl ResizeDao<'_> { Ok(()) } + + /// Reads the live canonical Squads spending limit into a record, or `None` + /// if the account doesn't exist or holds any shape `initialize_dao` could + /// not have created. + fn live_canonical_spending_limit( + spending_limit: &UncheckedAccount, + quote_mint: &Pubkey, + ) -> Option { + if spending_limit.owner != &squads_multisig_program::ID { + return None; + } + + let data = spending_limit.try_borrow_data().ok()?; + let live = SpendingLimit::try_deserialize(&mut &**data).ok()?; + + let is_canonical_shape = live.vault_index == 0 + && live.mint == *quote_mint + && matches!(live.period, Period::Month) + && live.members.len() <= MAX_SPENDING_LIMIT_MEMBERS + && live.destinations.is_empty(); + + is_canonical_shape.then(|| InitialSpendingLimit { + amount_per_month: live.amount, + members: live.members.clone(), + }) + } } diff --git a/programs/futarchy/src/instructions/resize_proposal.rs b/programs/futarchy/src/instructions/resize_proposal.rs index d8ff70b6..2cba0ba7 100644 --- a/programs/futarchy/src/instructions/resize_proposal.rs +++ b/programs/futarchy/src/instructions/resize_proposal.rs @@ -25,7 +25,7 @@ impl ResizeProposal<'_> { proposal.try_borrow_data().unwrap()[..8] == Proposal::discriminator(); require_eq!(is_discriminator_correct, true); - const AFTER_REALLOC_SIZE: usize = Proposal::INIT_SPACE + 8; + const AFTER_REALLOC_SIZE: usize = Proposal::MIGRATED_SIZE; // 369 bytes: 2 (i16 pass_threshold_bps) + 1 (bool council_can_block) // + 366 (ProposalAction) const BEFORE_REALLOC_SIZE: usize = AFTER_REALLOC_SIZE - 369; @@ -41,13 +41,22 @@ impl ResizeProposal<'_> { 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 action = ProposalAction::ExecuteArbitrary; + + // Draft proposals take the kind's catalog params like any new proposal. + // Launched proposals keep the rules they were launched under. + let (pass_threshold_bps, duration_in_seconds) = + if matches!(old_proposal_data.state, ProposalState::Draft { .. }) { + let params = action.params(); + (params.pass_threshold_bps, params.duration_seconds) + } else { + let pass_threshold_bps = if old_proposal_data.is_team_sponsored { + dao.team_sponsored_pass_threshold_bps + } else { + dao.pass_threshold_bps as i16 + }; + (pass_threshold_bps, old_proposal_data.duration_in_seconds) + }; let new_proposal_data = Proposal { number: old_proposal_data.number, @@ -59,7 +68,7 @@ impl ResizeProposal<'_> { 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, + 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, @@ -68,7 +77,7 @@ impl ResizeProposal<'_> { is_team_sponsored: old_proposal_data.is_team_sponsored, pass_threshold_bps, council_can_block: true, - action: ProposalAction::ExecuteArbitrary, + action, }; proposal.realloc(AFTER_REALLOC_SIZE, true)?; diff --git a/programs/futarchy/src/instructions/set_spending_limit.rs b/programs/futarchy/src/instructions/set_spending_limit.rs index b2c37bdb..cc5144e2 100644 --- a/programs/futarchy/src/instructions/set_spending_limit.rs +++ b/programs/futarchy/src/instructions/set_spending_limit.rs @@ -24,11 +24,7 @@ impl SetSpendingLimit<'_> { 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 - ); + config.validate()?; } Ok(()) diff --git a/programs/futarchy/src/instructions/spot_swap.rs b/programs/futarchy/src/instructions/spot_swap.rs index 9f45b508..09aa2f80 100644 --- a/programs/futarchy/src/instructions/spot_swap.rs +++ b/programs/futarchy/src/instructions/spot_swap.rs @@ -43,12 +43,6 @@ 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/lib.rs b/programs/futarchy/src/lib.rs index 5a83362a..3fb1b548 100644 --- a/programs/futarchy/src/lib.rs +++ b/programs/futarchy/src/lib.rs @@ -106,7 +106,7 @@ pub mod futarchy { InitializeHostileTakeoverProposal::handle(ctx, args) } - #[access_control(ctx.accounts.validate())] + #[access_control(ctx.accounts.validate(&args))] pub fn initialize_hostile_liquidate_proposal( ctx: Context, args: InitializeHostileLiquidateProposalArgs, @@ -168,11 +168,6 @@ pub mod futarchy { 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) } @@ -183,7 +178,6 @@ pub mod futarchy { // AMM instructions - #[access_control(ctx.accounts.validate())] pub fn spot_swap(ctx: Context, params: SpotSwapParams) -> Result<()> { SpotSwap::handle(ctx, params) } diff --git a/programs/futarchy/src/state/dao.rs b/programs/futarchy/src/state/dao.rs index 41369278..8397c987 100644 --- a/programs/futarchy/src/state/dao.rs +++ b/programs/futarchy/src/state/dao.rs @@ -70,7 +70,8 @@ pub struct Dao { 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. + /// Set once by `finalize_proposal` the moment a hostile liquidation + /// passes, never cleared. pub liquidator: Option, /// Unix time of the last failed hostile takeover. 0 = never. pub last_failed_takeover_at: i64, @@ -98,7 +99,50 @@ pub struct InitialSpendingLimit { pub members: Vec, } +impl InitialSpendingLimit { + /// Rejects any record that the Squads spending-limit invariant would refuse + /// to create, so every stored record can be projected by `sync_spending_limit`. + pub fn validate(&self) -> Result<()> { + require_neq!( + self.amount_per_month, + 0, + FutarchyError::InvalidSpendingLimitAmount + ); + + require!( + !self.members.is_empty(), + FutarchyError::EmptySpendingLimitMembers + ); + + require_gte!( + MAX_SPENDING_LIMIT_MEMBERS, + self.members.len(), + FutarchyError::TooManySpendingLimitMembers + ); + + let mut sorted_members = self.members.clone(); + sorted_members.sort(); + let has_duplicates = sorted_members.windows(2).any(|win| win[0] == win[1]); + require!(!has_duplicates, FutarchyError::DuplicateSpendingLimitMember); + + Ok(()) + } +} + impl Dao { + /// A migrated `Dao` account is exactly this long. + pub const MIGRATED_SIZE: usize = Dao::INIT_SPACE + 8; + + /// Errors unless `resize_dao` has migrated the account. + pub fn assert_migrated(account: &AccountInfo) -> Result<()> { + require_eq!( + account.data_len(), + Dao::MIGRATED_SIZE, + FutarchyError::AccountNotMigrated + ); + Ok(()) + } + pub fn invariant(&self) -> Result<()> { require_gte!( self.seconds_per_proposal, diff --git a/programs/futarchy/src/state/proposal.rs b/programs/futarchy/src/state/proposal.rs index 9053d227..25594e72 100644 --- a/programs/futarchy/src/state/proposal.rs +++ b/programs/futarchy/src/state/proposal.rs @@ -48,6 +48,21 @@ pub struct Proposal { pub action: ProposalAction, } +impl Proposal { + /// A migrated `Proposal` account is exactly this long. + pub const MIGRATED_SIZE: usize = Proposal::INIT_SPACE + 8; + + /// Errors unless `resize_proposal` has migrated the account. + pub fn assert_migrated(account: &AccountInfo) -> Result<()> { + require_eq!( + account.data_len(), + Proposal::MIGRATED_SIZE, + FutarchyError::AccountNotMigrated + ); + Ok(()) + } +} + #[account] #[derive(InitSpace)] pub struct OldProposal { diff --git a/programs/futarchy/src/state/proposal_action.rs b/programs/futarchy/src/state/proposal_action.rs index 193ebb7c..f7bfb0b9 100644 --- a/programs/futarchy/src/state/proposal_action.rs +++ b/programs/futarchy/src/state/proposal_action.rs @@ -32,6 +32,9 @@ pub enum SpendingLimitAction { pub enum ProposalAction { LargeSpend { amount: u64, + /// The team the baked transfer pays, snapshotted at create. Launch + /// requires it to still be the DAO's team. + team_address: Pubkey, }, MintTokens { amount: u64, @@ -52,7 +55,8 @@ pub enum ProposalAction { BuybackToken { /// Total quote to deploy. Capped at 25% of the treasury. quote_amount: u64, - quote_amount_per_cycle: u64, + /// Orders the total is split across. At least 2. + cycle_count: u32, /// Seconds between orders. cycle_frequency_seconds: u32, /// Seconds after execution before the first order. 0 = immediately. @@ -140,6 +144,10 @@ impl ProposalAction { ProposalAction::BuybackToken { quote_amount, .. } => { verify_buyback_treasury_cap(*quote_amount, dao, accounts) } + ProposalAction::LargeSpend { + amount, + team_address, + } => verify_large_spend_launch(*amount, *team_address, dao, accounts), _ => { require_eq!( accounts.len(), @@ -152,6 +160,44 @@ impl ProposalAction { } } +/// The large-spend launch gate: no extra accounts, and the create-time checks +/// re-run against current state. +fn verify_large_spend_launch( + amount: u64, + team_address: Pubkey, + dao: &Dao, + accounts: &[AccountInfo], +) -> Result<()> { + require_eq!(accounts.len(), 0, FutarchyError::UnexpectedLaunchAccounts); + + verify_large_spend_cap(amount, dao)?; + + require_keys_eq!( + team_address, + dao.team_address, + FutarchyError::StaleTeamAddress + ); + + Ok(()) +} + +/// The three-month spending cap, checked against the DAO's current record. +/// Run at both create and launch. +pub fn verify_large_spend_cap(amount: u64, dao: &Dao) -> Result<()> { + let record = dao + .initial_spending_limit + .as_ref() + .ok_or(FutarchyError::NoSpendingLimit)?; + + require_gte!( + record.amount_per_month.saturating_mul(3), + amount, + FutarchyError::SpendCapExceeded + ); + + Ok(()) +} + /// The 25% treasury cap, measured from the supplied account list. Launch is /// permissionless, so the list is considered adversarial input. fn verify_buyback_treasury_cap<'info>( @@ -220,11 +266,14 @@ fn verify_buyback_treasury_cap<'info>( dao.squads_multisig_vault, FutarchyError::InvalidTreasuryAccount ); - // The quote a withdrawal would deliver right now. + // The quote a withdrawal would deliver, with the pool valued at + // its rate-limited observation: `min(quote, base × observation)` if dao.amm.total_liquidity > 0 { - treasury_quote += spot - .get_quote_withdrawable(position.liquidity, dao.amm.total_liquidity) - as u128; + let quote_at_observation = (spot.base_reserves as u128) + .saturating_mul(spot.oracle.last_observation) + / PRICE_SCALE; + let quote_reserves = (spot.quote_reserves as u128).min(quote_at_observation); + treasury_quote += position.liquidity * quote_reserves / dao.amm.total_liquidity; } } else { return err!(FutarchyError::InvalidTreasuryAccount); diff --git a/scripts/v0.7/resizeDaos.ts b/scripts/v0.7/resizeDaos.ts index d11ceb4f..b4443525 100644 --- a/scripts/v0.7/resizeDaos.ts +++ b/scripts/v0.7/resizeDaos.ts @@ -27,7 +27,9 @@ async function main() { const daoDiscriminator = getDiscriminator("Dao"); - const batchSize = 20; + // Each resize now references two per-DAO accounts (dao + spending limit); + // 10 keeps the transaction under the 1232-byte packet limit. + const batchSize = 10; console.log(`Dao discriminator (hex): ${daoDiscriminator.toString("hex")}`); console.log(`Program ID: ${futarchyClient.getProgramId().toBase58()}\n`); @@ -58,12 +60,8 @@ async function main() { const ixs = await Promise.all( batch.map(async ({ pubkey }) => { - return await autocrat.methods - .resizeDao() - .accounts({ - dao: pubkey, - payer: payer.publicKey, - }) + return await futarchyClient + .resizeDaoIx({ dao: pubkey, payer: payer.publicKey }) .instruction(); }), ); @@ -85,6 +83,13 @@ async function main() { console.log( ` Optimistic governance enabled: ${dao.isOptimisticGovernanceEnabled}`, ); + console.log( + ` Spending limit: ${ + dao.initialSpendingLimit + ? `${dao.initialSpendingLimit.amountPerMonth.toString()}/mo, ${dao.initialSpendingLimit.members.length} member(s)` + : "none" + } (dirty: ${dao.spendingLimitDirty})`, + ); } } diff --git a/sdk/src/futarchy/v0.6/FutarchyClient.ts b/sdk/src/futarchy/v0.6/FutarchyClient.ts index fdf4830b..538ab4b3 100644 --- a/sdk/src/futarchy/v0.6/FutarchyClient.ts +++ b/sdk/src/futarchy/v0.6/FutarchyClient.ts @@ -1222,15 +1222,15 @@ export class FutarchyClient { .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`. + // The payload is the IP-transfer memo alone — finalize_proposal performs the + // state flip, and the liquidator (stored in `action`) unwinds the treasury + // position afterward through the estate cycle. async initializeHostileLiquidateProposal({ dao, - liquidator, + liquidator = METADAO_MULTISIG_VAULT, }: { dao: PublicKey; - liquidator: PublicKey; + liquidator?: PublicKey; }): Promise<{ proposal: PublicKey; squadsProposal: PublicKey; @@ -1253,7 +1253,7 @@ export class FutarchyClient { dao, baseMint, quoteMint, - liquidator, + liquidator = METADAO_MULTISIG_VAULT, transactionIndex, proposer = this.provider.publicKey, payer = this.provider.publicKey, @@ -1261,7 +1261,7 @@ export class FutarchyClient { dao: PublicKey; baseMint: PublicKey; quoteMint: PublicKey; - liquidator: PublicKey; + liquidator?: PublicKey; transactionIndex: bigint; proposer?: PublicKey; payer?: PublicKey; @@ -1287,7 +1287,7 @@ export class FutarchyClient { async initializeBuybackTokenProposal({ dao, quoteAmount, - quoteAmountPerCycle, + cycleCount, cycleFrequencySeconds, startDelaySeconds, minPrice = null, @@ -1295,7 +1295,7 @@ export class FutarchyClient { }: { dao: PublicKey; quoteAmount: BN; - quoteAmountPerCycle: BN; + cycleCount: number; cycleFrequencySeconds: number; startDelaySeconds: number; minPrice?: BN | null; @@ -1313,7 +1313,7 @@ export class FutarchyClient { baseMint: storedDao.baseMint, quoteMint: storedDao.quoteMint, quoteAmount, - quoteAmountPerCycle, + cycleCount, cycleFrequencySeconds, startDelaySeconds, minPrice, @@ -1328,7 +1328,7 @@ export class FutarchyClient { baseMint, quoteMint, quoteAmount, - quoteAmountPerCycle, + cycleCount, cycleFrequencySeconds, startDelaySeconds, minPrice = null, @@ -1341,7 +1341,7 @@ export class FutarchyClient { baseMint: PublicKey; quoteMint: PublicKey; quoteAmount: BN; - quoteAmountPerCycle: BN; + cycleCount: number; cycleFrequencySeconds: number; startDelaySeconds: number; minPrice?: BN | null; @@ -1353,7 +1353,7 @@ export class FutarchyClient { return this.futarchy.methods .initializeBuybackTokenProposal({ quoteAmount, - quoteAmountPerCycle, + cycleCount, cycleFrequencySeconds, startDelaySeconds, minPrice, @@ -1538,6 +1538,22 @@ export class FutarchyClient { }); } + resizeDaoIx({ + dao, + payer = this.provider.publicKey, + }: { + dao: PublicKey; + payer?: PublicKey; + }) { + const [spendingLimit] = getSpendingLimitAddr({ dao }); + + return this.futarchy.methods.resizeDao().accounts({ + dao, + spendingLimit, + payer, + }); + } + stakeToProposalIx({ proposal, dao, diff --git a/sdk/src/futarchy/v0.6/types/futarchy.ts b/sdk/src/futarchy/v0.6/types/futarchy.ts index 3ffccf28..76a5ee74 100644 --- a/sdk/src/futarchy/v0.6/types/futarchy.ts +++ b/sdk/src/futarchy/v0.6/types/futarchy.ts @@ -1313,86 +1313,22 @@ export type Futarchy = { args: []; }, { - name: "applyLiquidation"; + name: "resizeDao"; 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"; + name: "spendingLimit"; 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.", + "spending-limit PDA (`create_key` is always the DAO); read-only and may", + "not exist", ]; }, - { - 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: [ - { - name: "dao"; - isMut: true; - isSigner: false; - }, { name: "payer"; isMut: true; @@ -2494,7 +2430,8 @@ export type Futarchy = { name: "liquidator"; docs: [ "`Some` means the DAO has been liquidated, and holds who runs the estate.", - "Set once by `apply_liquidation`, never cleared.", + "Set once by `finalize_proposal` the moment a hostile liquidation", + "passes, never cleared.", ]; type: { option: "publicKey"; @@ -2977,8 +2914,8 @@ export type Futarchy = { type: "u64"; }, { - name: "quoteAmountPerCycle"; - type: "u64"; + name: "cycleCount"; + type: "u32"; }, { name: "cycleFrequencySeconds"; @@ -3675,6 +3612,14 @@ export type Futarchy = { name: "amount"; type: "u64"; }, + { + name: "teamAddress"; + docs: [ + "The team the baked transfer pays, snapshotted at create. Launch", + "requires it to still be the DAO's team.", + ]; + type: "publicKey"; + }, ]; }, { @@ -3739,8 +3684,9 @@ export type Futarchy = { type: "u64"; }, { - name: "quoteAmountPerCycle"; - type: "u64"; + name: "cycleCount"; + docs: ["Orders the total is split across. At least 2."]; + type: "u32"; }, { name: "cycleFrequencySeconds"; @@ -4790,50 +4736,6 @@ export type Futarchy = { }, ]; }, - { - name: "ApplyLiquidationEvent"; - fields: [ - { - name: "common"; - type: { - defined: "CommonFields"; - }; - index: false; - }, - { - name: "dao"; - type: "publicKey"; - index: false; - }, - { - 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; - }, - ]; - }, ]; errors: [ { @@ -5124,7 +5026,7 @@ export type Futarchy = { { code: 6057; name: "InvalidBuybackAmount"; - msg: "The total must be an exact multiple of the non-zero per-cycle amount, at least twice over"; + msg: "Buyback total must be non-zero"; }, { code: 6058; @@ -5156,6 +5058,41 @@ export type Futarchy = { name: "UnexpectedLaunchAccounts"; msg: "This proposal kind's launch takes no extra accounts"; }, + { + code: 6064; + name: "InvalidSpendingLimitAccount"; + msg: "Spending limit account is not the canonical spending-limit PDA"; + }, + { + code: 6065; + name: "StaleTeamAddress"; + msg: "The DAO's team has changed since this draft was created"; + }, + { + code: 6066; + name: "AccountNotMigrated"; + msg: "Account is not migrated to latest layout"; + }, + { + code: 6067; + name: "InvalidSpendingLimitAmount"; + msg: "A spending limit's monthly amount must be non-zero"; + }, + { + code: 6068; + name: "EmptySpendingLimitMembers"; + msg: "A spending limit must have at least one member"; + }, + { + code: 6069; + name: "DuplicateSpendingLimitMember"; + msg: "A spending limit's members must be unique"; + }, + { + code: 6070; + name: "InvalidBuybackCycleCount"; + msg: "A buyback must run at least two cycles"; + }, ]; }; @@ -6474,86 +6411,22 @@ export const IDL: Futarchy = { args: [], }, { - name: "applyLiquidation", + name: "resizeDao", 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", + name: "spendingLimit", 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.", + "spending-limit PDA (`create_key` is always the DAO); read-only and may", + "not exist", ], }, - { - 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: [ - { - name: "dao", - isMut: true, - isSigner: false, - }, { name: "payer", isMut: true, @@ -7655,7 +7528,8 @@ export const IDL: Futarchy = { name: "liquidator", docs: [ "`Some` means the DAO has been liquidated, and holds who runs the estate.", - "Set once by `apply_liquidation`, never cleared.", + "Set once by `finalize_proposal` the moment a hostile liquidation", + "passes, never cleared.", ], type: { option: "publicKey", @@ -8138,8 +8012,8 @@ export const IDL: Futarchy = { type: "u64", }, { - name: "quoteAmountPerCycle", - type: "u64", + name: "cycleCount", + type: "u32", }, { name: "cycleFrequencySeconds", @@ -8836,6 +8710,14 @@ export const IDL: Futarchy = { name: "amount", type: "u64", }, + { + name: "teamAddress", + docs: [ + "The team the baked transfer pays, snapshotted at create. Launch", + "requires it to still be the DAO's team.", + ], + type: "publicKey", + }, ], }, { @@ -8900,8 +8782,9 @@ export const IDL: Futarchy = { type: "u64", }, { - name: "quoteAmountPerCycle", - type: "u64", + name: "cycleCount", + docs: ["Orders the total is split across. At least 2."], + type: "u32", }, { name: "cycleFrequencySeconds", @@ -9951,50 +9834,6 @@ export const IDL: Futarchy = { }, ], }, - { - name: "ApplyLiquidationEvent", - fields: [ - { - name: "common", - type: { - defined: "CommonFields", - }, - index: false, - }, - { - name: "dao", - type: "publicKey", - index: false, - }, - { - 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, - }, - ], - }, ], errors: [ { @@ -10285,7 +10124,7 @@ export const IDL: Futarchy = { { code: 6057, name: "InvalidBuybackAmount", - msg: "The total must be an exact multiple of the non-zero per-cycle amount, at least twice over", + msg: "Buyback total must be non-zero", }, { code: 6058, @@ -10317,5 +10156,40 @@ export const IDL: Futarchy = { name: "UnexpectedLaunchAccounts", msg: "This proposal kind's launch takes no extra accounts", }, + { + code: 6064, + name: "InvalidSpendingLimitAccount", + msg: "Spending limit account is not the canonical spending-limit PDA", + }, + { + code: 6065, + name: "StaleTeamAddress", + msg: "The DAO's team has changed since this draft was created", + }, + { + code: 6066, + name: "AccountNotMigrated", + msg: "Account is not migrated to latest layout", + }, + { + code: 6067, + name: "InvalidSpendingLimitAmount", + msg: "A spending limit's monthly amount must be non-zero", + }, + { + code: 6068, + name: "EmptySpendingLimitMembers", + msg: "A spending limit must have at least one member", + }, + { + code: 6069, + name: "DuplicateSpendingLimitMember", + msg: "A spending limit's members must be unique", + }, + { + code: 6070, + name: "InvalidBuybackCycleCount", + msg: "A buyback must run at least two cycles", + }, ], }; diff --git a/tests/futarchy/integration/cooldownRoundTrip.test.ts b/tests/futarchy/integration/cooldownRoundTrip.test.ts index f078aa70..3289fba5 100644 --- a/tests/futarchy/integration/cooldownRoundTrip.test.ts +++ b/tests/futarchy/integration/cooldownRoundTrip.test.ts @@ -116,6 +116,8 @@ export default function suite() { storedDao.lastFailedLiquidationAt.toString(), clock.unixTimestamp.toString(), ); + // Only a PASSED liquidation reserves the DAO at finalize + assert.isNull(storedDao.liquidator); // An immediate relaunch is refused const second = await this.futarchy.initializeHostileLiquidateProposal({ diff --git a/tests/futarchy/integration/gatedLiquidationUnwind.test.ts b/tests/futarchy/integration/gatedLiquidationUnwind.test.ts new file mode 100644 index 00000000..ed263e8a --- /dev/null +++ b/tests/futarchy/integration/gatedLiquidationUnwind.test.ts @@ -0,0 +1,547 @@ +import { + FUTARCHY_V0_6_PROGRAM_ID, + GatedMintClient, + getDaoAddr, + getEventAuthorityAddr, + getProposalAddrsForTransactionIndex, + PERMISSIONLESS_ACCOUNT, + PriceMath, +} from "@metadaoproject/programs"; +import { + ComputeBudgetProgram, + Keypair, + PublicKey, + SystemProgram, + Transaction, +} from "@solana/web3.js"; +import { + getAssociatedTokenAddressSync, + TOKEN_PROGRAM_ID, +} from "@solana/spl-token"; +import BN from "bn.js"; +import { assert } from "chai"; +import * as multisig from "@sqds/multisig"; +import { executeVaultTransaction, passProposal } from "../../utils.js"; +import { + setupGatedMint, + whitelistUser, + freezeTokenAccount, + getTokenAccountState, + TOKEN_STATE_FROZEN, + TOKEN_STATE_INITIALIZED, +} from "../../gatedMint/utils.js"; + +const THOUSAND_BUCK_PRICE = PriceMath.getAmmPrice(1000, 6, 6); +const SEED_ENQUEUED_APPROVAL = Buffer.from("enqueued_approval"); + +export default function suite() { + it("liquidates a gated DAO with a frozen AMM base vault and unwinds via gated_invoke", async function () { + const gatedMintClient = GatedMintClient.createClient({ + provider: this.provider as any, + }); + + const gatedAdmin = Keypair.generate(); + const { mint: GATED } = await setupGatedMint( + this.banksClient, + gatedMintClient, + this.payer, + gatedAdmin.publicKey, + ); + const USDC = await this.createMint(this.payer.publicKey, 6); + + await this.createTokenAccount(GATED, this.payer.publicKey); + await this.createTokenAccount(USDC, this.payer.publicKey); + + await this.mintTo( + GATED, + 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: GATED, + 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: 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 }); + + const storedDaoBefore = await this.futarchy.getDao(dao); + const vault = storedDaoBefore.squadsMultisigVault; + const multisigPda = storedDaoBefore.squadsMultisig; + + // The unwind destination: the vault's ATAs + const vaultBaseAta = await this.createTokenAccount(GATED, vault); + const vaultQuoteAta = await this.createTokenAccount(USDC, vault); + + await this.futarchy + .provideLiquidityIx({ + dao, + baseMint: GATED, + quoteMint: USDC, + quoteAmount: new BN(100_000 * 1_000_000), // 100,000 USDC + maxBaseAmount: new BN(100 * 1_000_000), // 100 GATED + 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, unwound after liquidation + await this.futarchy + .provideLiquidityIx({ + dao, + baseMint: GATED, + quoteMint: USDC, + quoteAmount: new BN(25_000 * 1_000_000), // 25,000 USDC + maxBaseAmount: new BN(25 * 1_000_000), // 25 GATED + minLiquidity: new BN(1), + positionAuthority: vault, + liquidityProvider: this.payer.publicKey, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), + ]) + .rpc(); + + // The liquidator's cooperative whitelisted caller for the gated leg + const unwinder = Keypair.generate(); + await whitelistUser( + gatedMintClient, + GATED, + gatedAdmin, + unwinder.publicKey, + this.payer, + ); + + const liquidator = Keypair.generate(); + + const { proposal, squadsProposal, squadsTransaction } = + await this.futarchy.initializeHostileLiquidateProposal({ + dao, + liquidator: liquidator.publicKey, + }); + + await this.futarchy + .launchProposalIx({ + proposal, + dao, + baseMint: GATED, + quoteMint: USDC, + squadsProposal, + }) + .rpc(); + + await passProposal(this, { + dao, + proposal, + baseMint: GATED, + quoteMint: USDC, + cranks: 50, + }); + + const ammBaseVault = getAssociatedTokenAddressSync(GATED, dao, true); + const ammQuoteVault = getAssociatedTokenAddressSync(USDC, dao, true); + + // The gated ratchet has left the AMM base vault frozen on a liquidated + // DAO + await freezeTokenAccount(this.context, this.banksClient, ammBaseVault); + + // The memo payload touches no accounts, so the immutable transaction + // executes even against the frozen vault — where the old token-moving + // payload rolled back forever + await executeVaultTransaction(this, dao, squadsTransaction); + const storedSquadsProposal = + await multisig.accounts.Proposal.fromAccountAddress( + this.squadsConnection, + squadsProposal, + ); + assert.isTrue( + multisig.generated.isProposalStatusExecuted(storedSquadsProposal.status), + ); + + // 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 unwind payload, authored at unwind time when "is this mint gated?" + // is a known fact: gated_invoke thaws the frozen vault, invokes + // withdraw_liquidity with the Squads-minted vault signature, and refreezes + const preUnwindDao = await this.futarchy.getDao(dao); + const preUnwindSpot = preUnwindDao.amm.state.spot.spot; + + 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); + const expectedBase = storedTreasuryPosition.liquidity + .mul(preUnwindSpot.baseReserves) + .div(preUnwindDao.amm.totalLiquidity); + const expectedQuote = storedTreasuryPosition.liquidity + .mul(preUnwindSpot.quoteReserves) + .div(preUnwindDao.amm.totalLiquidity); + + const [eventAuthority] = getEventAuthorityAddr(FUTARCHY_V0_6_PROGRAM_ID); + const withdrawIx = await this.futarchy.futarchy.methods + .withdrawLiquidity({ + liquidityToWithdraw: storedTreasuryPosition.liquidity, + minBaseAmount: new BN(0), + minQuoteAmount: new BN(0), + }) + .accounts({ + dao, + positionAuthority: vault, + liquidityProviderBaseAccount: vaultBaseAta, + liquidityProviderQuoteAccount: vaultQuoteAta, + ammBaseVault, + ammQuoteVault, + ammPosition: treasuryPosition, + tokenProgram: TOKEN_PROGRAM_ID, + eventAuthority, + program: FUTARCHY_V0_6_PROGRAM_ID, + }) + .instruction(); + + const gatedWithdrawIx = await gatedMintClient + .gatedInvokeIx({ + caller: unwinder.publicKey, + mint: GATED, + instruction: withdrawIx, + }) + .instruction(); + + // The memo payload was transaction 1; the estate starts at 2 + const { tx: estateCreateTx } = this.futarchy.squadsProposalCreateTx({ + dao, + instructions: [gatedWithdrawIx], + 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(); + + // The whitelisted caller co-signs the execution alongside the Squads + // member; the vault's signature comes from Squads itself + await executeVaultTransaction( + this, + dao, + estateSquadsTransaction, + [ComputeBudgetProgram.setComputeUnitLimit({ units: 1_400_000 })], + [unwinder], + ); + + // The sweep landed + const postUnwindPosition = + await this.futarchy.futarchy.account.ammPosition.fetch(treasuryPosition); + assert.equal(postUnwindPosition.liquidity.toString(), "0"); + + assert.equal( + (await this.getTokenBalance(GATED, vault)).toString(), + expectedBase.toString(), + ); + assert.equal( + (await this.getTokenBalance(USDC, vault)).toString(), + expectedQuote.toString(), + ); + + // The ratchet: every gated-mint account in the invoke ends frozen, the + // AMM vault again and the swept base alongside it + assert.equal( + await getTokenAccountState(this.banksClient, ammBaseVault), + TOKEN_STATE_FROZEN, + ); + assert.equal( + await getTokenAccountState(this.banksClient, vaultBaseAta), + TOKEN_STATE_FROZEN, + ); + + // The quote leg is not gated, so the swept quote stays spendable + assert.equal( + await getTokenAccountState(this.banksClient, vaultQuoteAta), + TOKEN_STATE_INITIALIZED, + ); + }); + + it("a liquidated DAO holding gating authority drops the gate through its liquidator", async function () { + const gatedMintClient = GatedMintClient.createClient({ + provider: this.provider as any, + }); + + // The DAO treasury itself is the gating admin. The vault address is + // derivable before the DAO exists, so the mint is configured up front + const nonce = new BN(Math.floor(Math.random() * 1000000)); + const [dao] = getDaoAddr({ nonce, daoCreator: this.payer.publicKey }); + const [multisigPda] = multisig.getMultisigPda({ createKey: dao }); + const [vault] = multisig.getVaultPda({ multisigPda, index: 0 }); + + const { mint: GATED, gatedMintConfig } = await setupGatedMint( + this.banksClient, + gatedMintClient, + this.payer, + vault, + ); + const USDC = await this.createMint(this.payer.publicKey, 6); + + await this.createTokenAccount(GATED, this.payer.publicKey); + await this.createTokenAccount(USDC, this.payer.publicKey); + + await this.mintTo( + GATED, + this.payer.publicKey, + this.payer, + 1_000 * 1_000_000, + ); + await this.mintTo( + USDC, + this.payer.publicKey, + this.payer, + 500_000 * 1_000_000, + ); + + await this.futarchy + .initializeDaoIx({ + baseMint: GATED, + 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: null, + baseToStake: new BN(0), + teamSponsoredPassThresholdBps: 300, + teamAddress: this.payer.publicKey, + }, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), + ]) + .rpc(); + + const storedDao = await this.futarchy.getDao(dao); + assert.ok(storedDao.squadsMultisigVault.equals(vault)); + + await this.futarchy + .provideLiquidityIx({ + dao, + baseMint: GATED, + quoteMint: USDC, + quoteAmount: new BN(100_000 * 1_000_000), // 100,000 USDC + maxBaseAmount: new BN(100 * 1_000_000), // 100 GATED + minLiquidity: new BN(0), + positionAuthority: this.payer.publicKey, + liquidityProvider: this.payer.publicKey, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), + ]) + .rpc(); + + const liquidator = Keypair.generate(); + + const { proposal, squadsProposal } = + await this.futarchy.initializeHostileLiquidateProposal({ + dao, + liquidator: liquidator.publicKey, + }); + + await this.futarchy + .launchProposalIx({ + proposal, + dao, + baseMint: GATED, + quoteMint: USDC, + squadsProposal, + }) + .rpc(); + + await passProposal(this, { + dao, + proposal, + baseMint: GATED, + quoteMint: USDC, + cranks: 50, + }); + + // The ratcheted estate the gate drop must free + const ammBaseVault = getAssociatedTokenAddressSync(GATED, dao, true); + await freezeTokenAccount(this.context, this.banksClient, ammBaseVault); + + // 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 cycle carries disable_gating: the config's admin is the + // vault, whose signature Squads mints at execution + const disableGatingIx = await gatedMintClient + .disableGatingIx({ mint: GATED, admin: vault }) + .instruction(); + + // The memo payload was transaction 1; the estate starts at 2 + const { tx: estateCreateTx } = this.futarchy.squadsProposalCreateTx({ + dao, + instructions: [disableGatingIx], + 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(); + + await executeVaultTransaction(this, dao, estateSquadsTransaction); + + const storedConfig = + await gatedMintClient.program.account.gatedMintConfig.fetch( + gatedMintConfig, + ); + assert.isTrue(storedConfig.gatingDisabled); + + // With the gate down, the frozen estate thaws permissionlessly + await gatedMintClient + .thawAccountIx({ mint: GATED, tokenAccount: ammBaseVault }) + .rpc(); + assert.equal( + await getTokenAccountState(this.banksClient, ammBaseVault), + TOKEN_STATE_INITIALIZED, + ); + }); +} diff --git a/tests/futarchy/integration/liquidationEndToEnd.test.ts b/tests/futarchy/integration/liquidationEndToEnd.test.ts index 637468f7..3ac45ca2 100644 --- a/tests/futarchy/integration/liquidationEndToEnd.test.ts +++ b/tests/futarchy/integration/liquidationEndToEnd.test.ts @@ -13,8 +13,7 @@ import { PublicKey, SystemProgram, Transaction, - TransactionMessage, - VersionedTransaction, + TransactionInstruction, } from "@solana/web3.js"; import { createTransferInstruction, @@ -24,21 +23,17 @@ import { import BN from "bn.js"; import { assert } from "chai"; import * as multisig from "@sqds/multisig"; -import { - createLookupTableForTransaction, - executeVaultTransaction, - pumpPassMarket, -} from "../../utils.js"; +import { executeVaultTransaction, passProposal } 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 +// The lazy-unwind path: finalize bricks the DAO (liquidator written, limit +// zeroed), the payload is ceremony (memo only), and the treasury position +// exits afterward through a liquidator-authored estate cycle, 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 () { + it("liquidates at finalize, unwinds the treasury through 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); @@ -95,7 +90,7 @@ export default function suite() { const vault = storedDaoBefore.squadsMultisigVault; const multisigPda = storedDaoBefore.squadsMultisig; - // The baked apply_liquidation payload requires the vault's ATAs to exist + // The unwind destination: the vault's ATAs await this.createTokenAccount(META, vault); await this.createTokenAccount(USDC, vault); @@ -116,7 +111,7 @@ export default function suite() { ]) .rpc(); - // The treasury's own LP position, swept at liquidation + // The treasury's own LP position, unwound after liquidation await this.futarchy .provideLiquidityIx({ dao, @@ -151,9 +146,7 @@ export default function suite() { }) .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, { + await passProposal(this, { dao, proposal, baseMint: META, @@ -161,78 +154,34 @@ export default function suite() { 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); + // Finalize marks the DAO as liquidated: the liquidator is installed and the + // spending-limit record zeroed before any payload runs + let storedDao = await this.futarchy.getDao(dao); assert.ok(storedDao.liquidator.equals(liquidator.publicKey)); assert.isNull(storedDao.initialSpendingLimit); - assert.isFalse(storedDao.spendingLimitDirty); + assert.isTrue(storedDao.spendingLimitDirty); + + // The permissionless sync removes the Squads-side limit, so the outgoing + // team's pull rights die before any funds reach the vault + await this.futarchy.syncSpendingLimitIx({ dao }).rpc(); + storedDao = await this.futarchy.getDao(dao); + 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, + // The ceremonial payload + await executeVaultTransaction(this, dao, squadsTransaction); + const storedSquadsProposal = + await multisig.accounts.Proposal.fromAccountAddress( + this.squadsConnection, + squadsProposal, + ); + assert.isTrue( + multisig.generated.isProposalStatusExecuted(storedSquadsProposal.status), ); - 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 + // The liquidator pays rent for the enqueued approval accounts const fundTx = new Transaction().add( SystemProgram.transfer({ fromPubkey: this.payer.publicKey, @@ -245,87 +194,124 @@ export default function suite() { 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); + // One estate cycle: liquidator-authored vault transaction, liquidator + // enqueue, permissionless approve, ordinary Squads execution + const runEstateCycle = async ( + transactionIndex: bigint, + instructions: TransactionInstruction[], + ) => { + const { tx: createTx } = this.futarchy.squadsProposalCreateTx({ + dao, + instructions, + transactionIndex, + }); + createTx.recentBlockhash = ( + await this.banksClient.getLatestBlockhash() + )[0]; + createTx.feePayer = this.payer.publicKey; + createTx.sign(this.payer, PERMISSIONLESS_ACCOUNT); + await this.banksClient.processTransaction(createTx); + + const { + squadsProposal: estateSquadsProposal, + squadsTransaction: estateSquadsTransaction, + } = getProposalAddrsForTransactionIndex({ dao, transactionIndex }); + + const [enqueuedApproval] = PublicKey.findProgramAddressSync( + [ + SEED_ENQUEUED_APPROVAL, + dao.toBuffer(), + new BN(transactionIndex.toString()).toArrayLike(Buffer, "le", 8), + ], + this.futarchy.futarchy.programId, + ); - // 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(transactionIndex.toString()), + }) + .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(); + + await executeVaultTransaction(this, dao, estateSquadsTransaction); + }; + + // Estate cycle #1 unwinds the treasury position into the vault's ATAs. + const preUnwindDao = await this.futarchy.getDao(dao); + const preUnwindSpot = preUnwindDao.amm.state.spot.spot; + + 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); + const expectedBase = storedTreasuryPosition.liquidity + .mul(preUnwindSpot.baseReserves) + .div(preUnwindDao.amm.totalLiquidity); + const expectedQuote = storedTreasuryPosition.liquidity + .mul(preUnwindSpot.quoteReserves) + .div(preUnwindDao.amm.totalLiquidity); - await this.futarchy.futarchy.methods - .adminEnqueueMultisigProposalApproval({ transactionIndex: new BN(2) }) - .accounts({ - dao, - admin: liquidator.publicKey, - squadsMultisig: multisigPda, - squadsMultisigProposal: estateSquadsProposal, - enqueuedApproval, + const [eventAuthority] = getEventAuthorityAddr(FUTARCHY_V0_6_PROGRAM_ID); + const withdrawIx = await this.futarchy.futarchy.methods + .withdrawLiquidity({ + liquidityToWithdraw: storedTreasuryPosition.liquidity, + minBaseAmount: new BN(0), + minQuoteAmount: new BN(0), }) - .signers([liquidator]) - .rpc(); - - await this.futarchy.futarchy.methods - .executeMultisigProposalApproval() .accounts({ dao, - rentReceiver: this.payer.publicKey, - squadsMultisig: multisigPda, - squadsMultisigProposal: estateSquadsProposal, - enqueuedApproval, - squadsMultisigProgram: multisig.PROGRAM_ID, + positionAuthority: vault, + liquidityProviderBaseAccount: getAssociatedTokenAddressSync( + META, + vault, + true, + ), + liquidityProviderQuoteAccount: getAssociatedTokenAddressSync( + USDC, + vault, + true, + ), + ammBaseVault: getAssociatedTokenAddressSync(META, dao, true), + ammQuoteVault: getAssociatedTokenAddressSync(USDC, dao, true), + ammPosition: treasuryPosition, + tokenProgram: TOKEN_PROGRAM_ID, + eventAuthority, + program: FUTARCHY_V0_6_PROGRAM_ID, }) - .rpc(); + .instruction(); - const storedEstateProposal = - await multisig.accounts.Proposal.fromAccountAddress( - this.squadsConnection, - estateSquadsProposal, - ); - assert.isTrue( - multisig.generated.isProposalStatusApproved(storedEstateProposal.status), - ); + // The memo payload was transaction 1; the estate starts at 2 + await runEstateCycle(2n, [withdrawIx]); - await executeVaultTransaction(this, dao, estateSquadsTransaction); + const postUnwindPosition = + await this.futarchy.futarchy.account.ammPosition.fetch(treasuryPosition); + assert.equal(postUnwindPosition.liquidity.toString(), "0"); - 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(), - ); + const sweptBase = await this.getTokenBalance(META, vault); + const sweptQuote = await this.getTokenBalance(USDC, vault); + assert.equal(sweptBase.toString(), expectedBase.toString()); + assert.equal(sweptQuote.toString(), expectedQuote.toString()); // Liquidation never traps third-party LPs: withdraw_liquidity is exempt // from the liquidated guards @@ -343,7 +329,6 @@ export default function suite() { 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, @@ -382,5 +367,29 @@ export default function suite() { const postLpPosition = await this.futarchy.futarchy.account.ammPosition.fetch(lpPosition); assert.equal(postLpPosition.liquidity.toString(), "0"); + + // Estate cycle #2 distributes from the swept treasury + // Simply proof that the liquidator can move funds out of the DAO + const recipient = Keypair.generate().publicKey; + const recipientAta = await this.createTokenAccount(USDC, recipient); + const vaultUsdcAta = getAssociatedTokenAddressSync(USDC, vault, true); + + await runEstateCycle(3n, [ + createTransferInstruction( + vaultUsdcAta, + recipientAta, + vault, + 600 * 1_000_000, + ), + ]); + + 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(), + ); }); } diff --git a/tests/futarchy/main.test.ts b/tests/futarchy/main.test.ts index 300f8f45..b9fc3e59 100644 --- a/tests/futarchy/main.test.ts +++ b/tests/futarchy/main.test.ts @@ -1,6 +1,7 @@ import futarchyAmm from "./integration/futarchyAmm.test.js"; import takeoverEndToEnd from "./integration/takeoverEndToEnd.test.js"; import liquidationEndToEnd from "./integration/liquidationEndToEnd.test.js"; +import gatedLiquidationUnwind from "./integration/gatedLiquidationUnwind.test.js"; import largeSpendEndToEnd from "./integration/largeSpendEndToEnd.test.js"; import cooldownRoundTrip from "./integration/cooldownRoundTrip.test.js"; @@ -17,7 +18,6 @@ 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"; @@ -96,7 +96,6 @@ export default function suite() { 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); @@ -127,6 +126,7 @@ export default function suite() { describe("futarchy amm", futarchyAmm); describe("integration: takeover end to end", takeoverEndToEnd); describe("integration: liquidation end to end", liquidationEndToEnd); + describe("integration: gated liquidation unwind", gatedLiquidationUnwind); 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 71da7216..33907eab 100644 --- a/tests/futarchy/unit/adminCancelProposal.test.ts +++ b/tests/futarchy/unit/adminCancelProposal.test.ts @@ -13,7 +13,7 @@ import { } from "@solana/web3.js"; import { getAssociatedTokenAddressSync } from "@solana/spl-token"; import BN from "bn.js"; -import { expectError, setupBasicDao } from "../../utils.js"; +import { expectError, makeOldDaoLayout, setupBasicDao } from "../../utils.js"; import { assert } from "chai"; import * as multisig from "@sqds/multisig"; @@ -335,6 +335,219 @@ export default function suite() { .then(callbacks[0], callbacks[1]); }); + it("rejects a legacy-sized proposal that has not been migrated", async function () { + // Shrink the live proposal to the pre-migration allocation: the 8-byte + // discriminator plus the 339-byte Pending body, then 8 bytes standing in + // for the residue a legacy account carries past its Pending body. The + // residue decodes as council_can_block = false — exactly the value that + // would otherwise shield the proposal from cancellation — so the size + // guard must reject it before that flag is ever consulted. + const raw = await this.banksClient.getAccount(proposal); + const legacy = Buffer.concat([ + Buffer.from(raw.data.subarray(0, 347)), + Buffer.from([0xb1, 0xf3, 0x00, 0x03, 0xf0, 0x37, 0xa2, 0x00]), + ]); + assert.equal(legacy.length, 355); + this.context.setAccount(proposal, { ...raw, data: legacy }); + + const crafted = await this.futarchy.getProposal(proposal); + assert.exists(crafted.state.pending); + assert.isFalse(crafted.councilCanBlock); + + const storedDao = await this.futarchy.getDao(dao); + const { + question, + baseVault, + quoteVault, + passBaseMint, + passQuoteMint, + failBaseMint, + failQuoteMint, + } = this.futarchy.getProposalPdas( + proposal, + storedDao.baseMint, + storedDao.quoteMint, + dao, + ); + + const multisigPda = multisig.getMultisigPda({ createKey: dao })[0]; + const [vaultEventAuthority] = getEventAuthorityAddr( + CONDITIONAL_VAULT_V0_4_PROGRAM_ID, + ); + + const callbacks = expectError( + "AccountNotMigrated", + "cancelled an un-migrated legacy proposal", + ); + + await this.futarchy.futarchy.methods + .adminCancelProposal() + .accounts({ + proposal, + dao, + question, + squadsProposal: squadsProposalPda, + squadsMultisig: multisigPda, + squadsMultisigProgram: SQUADS_PROGRAM_ID, + admin: this.payer.publicKey, + ammPassBaseVault: getAssociatedTokenAddressSync( + passBaseMint, + dao, + true, + ), + ammPassQuoteVault: getAssociatedTokenAddressSync( + passQuoteMint, + dao, + true, + ), + ammFailBaseVault: getAssociatedTokenAddressSync( + failBaseMint, + dao, + true, + ), + ammFailQuoteVault: getAssociatedTokenAddressSync( + failQuoteMint, + dao, + true, + ), + ammBaseVault: getAssociatedTokenAddressSync( + storedDao.baseMint, + dao, + true, + ), + ammQuoteVault: getAssociatedTokenAddressSync( + storedDao.quoteMint, + dao, + true, + ), + vaultProgram: CONDITIONAL_VAULT_V0_4_PROGRAM_ID, + vaultEventAuthority, + quoteVault, + quoteVaultUnderlyingTokenAccount: getAssociatedTokenAddressSync( + storedDao.quoteMint, + quoteVault, + true, + ), + passQuoteMint, + failQuoteMint, + passBaseMint, + failBaseMint, + baseVault, + baseVaultUnderlyingTokenAccount: getAssociatedTokenAddressSync( + storedDao.baseMint, + baseVault, + true, + ), + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), + ]) + .signers([this.payer]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("rejects a legacy-sized DAO that has not been migrated", async function () { + // Shrink only the DAO to the pre-migration allocation; the proposal keeps + // its migrated size so its own guard passes. + await makeOldDaoLayout(this, dao); + + const storedDao = await this.futarchy.getDao(dao); + assert.exists(storedDao.amm.state.futarchy); + assert.isNull(storedDao.liquidator); + + const { + question, + baseVault, + quoteVault, + passBaseMint, + passQuoteMint, + failBaseMint, + failQuoteMint, + } = this.futarchy.getProposalPdas( + proposal, + storedDao.baseMint, + storedDao.quoteMint, + dao, + ); + + const multisigPda = multisig.getMultisigPda({ createKey: dao })[0]; + const [vaultEventAuthority] = getEventAuthorityAddr( + CONDITIONAL_VAULT_V0_4_PROGRAM_ID, + ); + + const callbacks = expectError( + "AccountNotMigrated", + "cancelled a proposal on an un-migrated legacy DAO", + ); + + await this.futarchy.futarchy.methods + .adminCancelProposal() + .accounts({ + proposal, + dao, + question, + squadsProposal: squadsProposalPda, + squadsMultisig: multisigPda, + squadsMultisigProgram: SQUADS_PROGRAM_ID, + admin: this.payer.publicKey, + ammPassBaseVault: getAssociatedTokenAddressSync( + passBaseMint, + dao, + true, + ), + ammPassQuoteVault: getAssociatedTokenAddressSync( + passQuoteMint, + dao, + true, + ), + ammFailBaseVault: getAssociatedTokenAddressSync( + failBaseMint, + dao, + true, + ), + ammFailQuoteVault: getAssociatedTokenAddressSync( + failQuoteMint, + dao, + true, + ), + ammBaseVault: getAssociatedTokenAddressSync( + storedDao.baseMint, + dao, + true, + ), + ammQuoteVault: getAssociatedTokenAddressSync( + storedDao.quoteMint, + dao, + true, + ), + vaultProgram: CONDITIONAL_VAULT_V0_4_PROGRAM_ID, + vaultEventAuthority, + quoteVault, + quoteVaultUnderlyingTokenAccount: getAssociatedTokenAddressSync( + storedDao.quoteMint, + quoteVault, + true, + ), + passQuoteMint, + failQuoteMint, + passBaseMint, + failBaseMint, + baseVault, + baseVaultUnderlyingTokenAccount: getAssociatedTokenAddressSync( + storedDao.baseMint, + baseVault, + true, + ), + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), + ]) + .signers([this.payer]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + // This will be blockable in the future it("should cancel a live hostile proposal", async function () { // Fresh DAO — the suite DAO already has a live blockable proposal diff --git a/tests/futarchy/unit/adminEnqueueMultisigProposalApproval.test.ts b/tests/futarchy/unit/adminEnqueueMultisigProposalApproval.test.ts index e210cea1..b2c3a5fe 100644 --- a/tests/futarchy/unit/adminEnqueueMultisigProposalApproval.test.ts +++ b/tests/futarchy/unit/adminEnqueueMultisigProposalApproval.test.ts @@ -1,11 +1,13 @@ import { PERMISSIONLESS_ACCOUNT } from "@metadaoproject/programs"; import { ComputeBudgetProgram, + Keypair, PublicKey, + SystemProgram, Transaction, TransactionMessage, } from "@solana/web3.js"; -import { expectError } from "../../utils.js"; +import { expectError, makeOldDaoLayout } from "../../utils.js"; import { assert } from "chai"; import * as multisig from "@sqds/multisig"; import { createMemoInstruction } from "@solana/spl-memo"; @@ -134,6 +136,65 @@ export default function suite() { assert.equal(enqueued.transactionIndex.toString(), "1"); }); + it("rejects a legacy-sized DAO whose residue decodes as a liquidator", async function () { + const daoAccount = await this.futarchy.getDao(dao); + const { proposalPda } = await createSquadsVaultTxAndProposal( + this, + daoAccount.squadsMultisig, + 1n, + ); + + // The attacker pays rent for the enqueued approval account + const attacker = Keypair.generate(); + const fundTx = new Transaction().add( + SystemProgram.transfer({ + fromPubkey: this.payer.publicKey, + toPubkey: attacker.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); + + // Shrink the DAO to the pre-migration allocation and plant, immediately + // after its Spot-layout body, the bytes a legacy DAO carries there once a + // finalized proposal has collapsed its AMM from Futarchy back to Spot: + // `Some(attacker)` where the new layout reads `liquidator`, then zeros for + // the two timestamps, the dirty flag, and the buyback timestamp. + const residue = Buffer.concat([ + Buffer.from([1]), + attacker.publicKey.toBuffer(), + Buffer.alloc(25), + ]); + await makeOldDaoLayout(this, dao, {}, { residue }); + + // The account still decodes — with the attacker as the liquidator + // authority — so only the size guard stands between them and enqueueing. + const crafted = await this.futarchy.getDao(dao); + assert.equal(crafted.liquidator.toBase58(), attacker.publicKey.toBase58()); + assert.exists(crafted.amm.state.spot); + + const callbacks = expectError( + "AccountNotMigrated", + "enqueued on an un-migrated legacy DAO", + ); + + await this.futarchy.futarchy.methods + .adminEnqueueMultisigProposalApproval({ transactionIndex: new BN(1) }) + .accounts({ + dao, + admin: attacker.publicKey, + squadsMultisig: daoAccount.squadsMultisig, + squadsMultisigProposal: proposalPda, + enqueuedApproval: deriveEnqueuedApprovalPda(this, dao, 1n), + }) + .signers([attacker]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + it("should fail with PoolNotInSpotState when a futarchy proposal is active", async function () { const daoAccount = await this.futarchy.getDao(dao); diff --git a/tests/futarchy/unit/adminUpdateProposalParams.test.ts b/tests/futarchy/unit/adminUpdateProposalParams.test.ts index 6c0c4062..0986333d 100644 --- a/tests/futarchy/unit/adminUpdateProposalParams.test.ts +++ b/tests/futarchy/unit/adminUpdateProposalParams.test.ts @@ -437,7 +437,7 @@ export default function suite() { }); it("refuses on a liquidated DAO", async function () { - // `apply_liquidation` is the only writer of `dao.liquidator`, and reaching + // `finalize_proposal` is the only writer of `dao.liquidator`, and reaching // it takes a full hostile-liquidate market. await rewriteAccount(this, dao, "dao", (decoded) => { decoded.liquidator = Keypair.generate().publicKey; diff --git a/tests/futarchy/unit/applyLiquidation.test.ts b/tests/futarchy/unit/applyLiquidation.test.ts deleted file mode 100644 index 0a2f993e..00000000 --- a/tests/futarchy/unit/applyLiquidation.test.ts +++ /dev/null @@ -1,644 +0,0 @@ -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/finalizeProposal.test.ts b/tests/futarchy/unit/finalizeProposal.test.ts index d19aaf4c..37607970 100644 --- a/tests/futarchy/unit/finalizeProposal.test.ts +++ b/tests/futarchy/unit/finalizeProposal.test.ts @@ -15,7 +15,7 @@ import { getAssociatedTokenAddressSync, } from "@solana/spl-token"; import BN from "bn.js"; -import { expectError, setupBasicDao } from "../../utils.js"; +import { expectError, makeOldDaoLayout, setupBasicDao } from "../../utils.js"; import { assert } from "chai"; import * as multisig from "@sqds/multisig"; const { Permissions, Permission } = multisig.types; @@ -23,7 +23,11 @@ const { Permissions, Permission } = multisig.types; const THOUSAND_BUCK_PRICE = PriceMath.getAmmPrice(1000, 6, 6); export default function suite() { - let META: PublicKey, USDC: PublicKey, dao: PublicKey, proposal: PublicKey; + let META: PublicKey, + USDC: PublicKey, + dao: PublicKey, + proposal: PublicKey, + squadsProposalPda: PublicKey; beforeEach(async function () { META = await this.createMint(this.payer.publicKey, 6); @@ -108,7 +112,7 @@ export default function suite() { rentPayer: this.payer.publicKey, }); - const [squadsProposalPda] = multisig.getProposalPda({ + [squadsProposalPda] = multisig.getProposalPda({ multisigPda, transactionIndex: 1n, }); @@ -146,6 +150,68 @@ export default function suite() { .then(callbacks[0], callbacks[1]); }); + it("rejects a legacy-sized proposal that has not been migrated", async function () { + // Shrink the live proposal to the pre-migration allocation: the 8-byte + // discriminator plus the 339-byte Pending body, then 8 bytes standing in + // for the residue a legacy account carries past its Pending body. The + // residue decodes as pass_threshold_bps = -3151, council_can_block = + // false, action = ExecuteArbitrary — a well-formed new-layout read, so + // only the size guard stands between it and finalization. + const raw = await this.banksClient.getAccount(proposal); + const legacy = Buffer.concat([ + Buffer.from(raw.data.subarray(0, 347)), + Buffer.from([0xb1, 0xf3, 0x00, 0x03, 0xf0, 0x37, 0xa2, 0x00]), + ]); + assert.equal(legacy.length, 355); + this.context.setAccount(proposal, { ...raw, data: legacy }); + + const crafted = await this.futarchy.getProposal(proposal); + assert.exists(crafted.state.pending); + assert.equal(crafted.passThresholdBps, -3151); + assert.isFalse(crafted.councilCanBlock); + assert.isDefined(crafted.action.executeArbitrary); + + const callbacks = expectError( + "AccountNotMigrated", + "finalized an un-migrated legacy proposal", + ); + + await this.futarchy + .finalizeProposalIxV2({ + squadsProposal: squadsProposalPda, + dao, + baseMint: META, + quoteMint: USDC, + }) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("rejects a legacy-sized DAO that has not been migrated", async function () { + // Shrink only the DAO to the pre-migration allocation; the proposal keeps + // its migrated size so its own guard passes. + await makeOldDaoLayout(this, dao); + + const crafted = await this.futarchy.getDao(dao); + assert.exists(crafted.amm.state.futarchy); + assert.isNull(crafted.liquidator); + + const callbacks = expectError( + "AccountNotMigrated", + "finalized a proposal on an un-migrated legacy DAO", + ); + + await this.futarchy + .finalizeProposalIxV2({ + squadsProposal: squadsProposalPda, + dao, + baseMint: META, + quoteMint: USDC, + }) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + it("passes proposals when Pass TWAP > Fail TWAP", async function () { // Split tokens into the vaults const { baseVault, quoteVault, question } = this.futarchy.getProposalPdas( diff --git a/tests/futarchy/unit/initializeBuybackTokenProposal.test.ts b/tests/futarchy/unit/initializeBuybackTokenProposal.test.ts index 6e3cd85a..9bb9616a 100644 --- a/tests/futarchy/unit/initializeBuybackTokenProposal.test.ts +++ b/tests/futarchy/unit/initializeBuybackTokenProposal.test.ts @@ -232,7 +232,7 @@ export default function suite() { await this.futarchy.initializeBuybackTokenProposal({ dao, quoteAmount: new BN(400_000_000_000), - quoteAmountPerCycle: new BN(5_000_000_000), + cycleCount: 80, cycleFrequencySeconds: 86_400, startDelaySeconds: 0, }); @@ -241,7 +241,7 @@ export default function suite() { programId: MEMO_PROGRAM_ID, keys: [], data: Buffer.from( - `metadao-buyback/1 proposal=${proposal.toBase58()} spend=400000000000 per_cycle=5000000000 cycle_seconds=86400 start_delay=0 min_price=none max_price=none`, + `metadao-buyback/1 proposal=${proposal.toBase58()} spend=400000000000 cycles=80 cycle_seconds=86400 start_delay=0 min_price=none max_price=none`, "utf8", ), }); @@ -263,7 +263,7 @@ export default function suite() { await this.futarchy.initializeBuybackTokenProposal({ dao, quoteAmount: new BN(400_000_000), - quoteAmountPerCycle: new BN(5_000_000), + cycleCount: 80, cycleFrequencySeconds: 3_600, startDelaySeconds: 60, minPrice: new BN(1_600_000), @@ -274,7 +274,7 @@ export default function suite() { programId: MEMO_PROGRAM_ID, keys: [], data: Buffer.from( - `metadao-buyback/1 proposal=${proposal.toBase58()} spend=400000000 per_cycle=5000000 cycle_seconds=3600 start_delay=60 min_price=1600000 max_price=2000000`, + `metadao-buyback/1 proposal=${proposal.toBase58()} spend=400000000 cycles=80 cycle_seconds=3600 start_delay=60 min_price=1600000 max_price=2000000`, "utf8", ), }); @@ -288,7 +288,7 @@ export default function suite() { const { proposal } = await this.futarchy.initializeBuybackTokenProposal({ dao, quoteAmount: new BN(400_000_000), - quoteAmountPerCycle: new BN(5_000_000), + cycleCount: 80, cycleFrequencySeconds: 86_400, startDelaySeconds: 3_600, minPrice: new BN(1_600_000), @@ -304,7 +304,7 @@ export default function suite() { const action = storedProposal.action.buybackToken; assert.equal(action.quoteAmount.toString(), "400000000"); - assert.equal(action.quoteAmountPerCycle.toString(), "5000000"); + assert.equal(action.cycleCount, 80); assert.equal(action.cycleFrequencySeconds, 86_400); assert.equal(action.startDelaySeconds, 3_600); assert.equal(action.minPrice.toString(), "1600000"); @@ -321,7 +321,7 @@ export default function suite() { await this.futarchy.initializeBuybackTokenProposal({ dao, quoteAmount: new BN(400_000_000), // 400 tokens = 25% of 1,600 - quoteAmountPerCycle: new BN(5_000_000), + cycleCount: 80, cycleFrequencySeconds: 86_400, startDelaySeconds: 0, }); @@ -350,7 +350,7 @@ export default function suite() { await this.futarchy.initializeBuybackTokenProposal({ dao, quoteAmount: new BN(400_000_001), - quoteAmountPerCycle: new BN(1), + cycleCount: 80, cycleFrequencySeconds: 86_400, startDelaySeconds: 0, }); @@ -401,7 +401,7 @@ export default function suite() { await this.futarchy.initializeBuybackTokenProposal({ dao, quoteAmount: new BN(300_000_000), - quoteAmountPerCycle: new BN(5_000_000), + cycleCount: 60, cycleFrequencySeconds: 86_400, startDelaySeconds: 0, }); @@ -439,6 +439,149 @@ export default function suite() { assert.exists(storedProposal.state.pending); }); + it("values the position at the pool's observation, so pumping the quote reserves can't lift the cap", async function () { + await this.mintTo(USDC, vault, this.payer, 1_000 * 1_000_000); + + 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: vault, + liquidityProvider: this.payer.publicKey, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), + ]) + .rpc(); + + // 600 * 4 = 2,400 against a 2,000 treasury + const { proposal, squadsProposal } = + await this.futarchy.initializeBuybackTokenProposal({ + dao, + quoteAmount: new BN(600_000_000), + cycleCount: 120, + cycleFrequencySeconds: 86_400, + startDelaySeconds: 0, + }); + + await this.futarchy + .spotSwapIx({ + dao, + baseMint: META, + quoteMint: USDC, + swapType: "buy", + inputAmount: new BN(50_000 * 1_000_000), + }) + .rpc(); + + // The pump lifts the position's reserve-based quote share past the cap; + // only valuing it at the observation keeps the launch out. + const storedDao = await this.futarchy.getDao(dao); + const spot = storedDao.amm.state.spot.spot; + const position = + await this.futarchy.futarchy.account.ammPosition.fetch(vaultPosition); + const reserveBasedTreasury = position.liquidity + .mul(spot.quoteReserves) + .div(storedDao.amm.totalLiquidity) + .add(new BN(1_000 * 1_000_000)); + assert.isTrue(reserveBasedTreasury.gte(new BN(2_400_000_000))); + + const callbacks = expectError( + "BuybackCapExceeded", + "launched a buyback against pumped quote reserves", + ); + + await this.futarchy + .launchProposalIx({ + proposal, + dao, + baseMint: META, + quoteMint: USDC, + squadsProposal, + treasuryAccounts: await this.futarchy.assembleBuybackTreasuryAccounts({ + dao, + }), + }) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("takes the lower of the reserve and observation figures, so dumping into the pool can't lift it either", async function () { + await this.mintTo(USDC, vault, this.payer, 1_000 * 1_000_000); + + 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: vault, + liquidityProvider: this.payer.publicKey, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), + ]) + .rpc(); + + const { proposal, squadsProposal } = + await this.futarchy.initializeBuybackTokenProposal({ + dao, + quoteAmount: new BN(600_000_000), + cycleCount: 120, + cycleFrequencySeconds: 86_400, + startDelaySeconds: 0, + }); + + await this.futarchy + .spotSwapIx({ + dao, + baseMint: META, + quoteMint: USDC, + swapType: "sell", + inputAmount: new BN(45 * 1_000_000), + }) + .rpc(); + + // The dump lifts the position's observation-priced base share past the + // cap while its quote share falls; the lower figure is the one that counts. + const storedDao = await this.futarchy.getDao(dao); + const spot = storedDao.amm.state.spot.spot; + const position = + await this.futarchy.futarchy.account.ammPosition.fetch(vaultPosition); + const observationBasedTreasury = position.liquidity + .mul(spot.baseReserves) + .mul(spot.oracle.lastObservation) + .div(new BN(10).pow(new BN(12))) + .div(storedDao.amm.totalLiquidity) + .add(new BN(1_000 * 1_000_000)); + assert.isTrue(observationBasedTreasury.gte(new BN(2_400_000_000))); + + const callbacks = expectError( + "BuybackCapExceeded", + "launched a buyback against dumped base reserves", + ); + + await this.futarchy + .launchProposalIx({ + proposal, + dao, + baseMint: META, + quoteMint: USDC, + squadsProposal, + treasuryAccounts: await this.futarchy.assembleBuybackTreasuryAccounts({ + dao, + }), + }) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + it("binds the cap to the launch-time balance, not create's", async function () { await this.mintTo(USDC, vault, this.payer, 1_600 * 1_000_000); @@ -446,7 +589,7 @@ export default function suite() { await this.futarchy.initializeBuybackTokenProposal({ dao, quoteAmount: new BN(400_000_000), - quoteAmountPerCycle: new BN(5_000_000), + cycleCount: 80, cycleFrequencySeconds: 86_400, startDelaySeconds: 0, }); @@ -566,7 +709,7 @@ export default function suite() { await this.futarchy.initializeBuybackTokenProposal({ dao, quoteAmount: new BN(10_000_000), // comfortably under the cap - quoteAmountPerCycle: new BN(5_000_000), + cycleCount: 2, cycleFrequencySeconds: 86_400, startDelaySeconds: 0, }); @@ -675,7 +818,7 @@ export default function suite() { const first = await this.futarchy.initializeBuybackTokenProposal({ dao, quoteAmount: new BN(400_000_000), - quoteAmountPerCycle: new BN(5_000_000), + cycleCount: 80, cycleFrequencySeconds: 86_400, startDelaySeconds: 0, }); @@ -707,7 +850,7 @@ export default function suite() { const second = await this.futarchy.initializeBuybackTokenProposal({ dao, quoteAmount: new BN(400_000_000), - quoteAmountPerCycle: new BN(5_000_000), + cycleCount: 80, cycleFrequencySeconds: 86_400, startDelaySeconds: 0, }); @@ -735,7 +878,7 @@ export default function suite() { const first = await this.futarchy.initializeBuybackTokenProposal({ dao, quoteAmount: new BN(400_000_000), - quoteAmountPerCycle: new BN(5_000_000), + cycleCount: 80, cycleFrequencySeconds: 86_400, startDelaySeconds: 0, }); @@ -771,7 +914,7 @@ export default function suite() { const second = await this.futarchy.initializeBuybackTokenProposal({ dao, quoteAmount: new BN(400_000_000), - quoteAmountPerCycle: new BN(5_000_000), + cycleCount: 80, cycleFrequencySeconds: 86_400, startDelaySeconds: 0, }); @@ -823,7 +966,7 @@ export default function suite() { const first = await this.futarchy.initializeBuybackTokenProposal({ dao, quoteAmount: new BN(400_000_000), - quoteAmountPerCycle: new BN(5_000_000), + cycleCount: 80, cycleFrequencySeconds: 86_400, startDelaySeconds: 0, }); @@ -858,7 +1001,7 @@ export default function suite() { const second = await this.futarchy.initializeBuybackTokenProposal({ dao, quoteAmount: new BN(400_000_000), - quoteAmountPerCycle: new BN(5_000_000), + cycleCount: 80, cycleFrequencySeconds: 86_400, startDelaySeconds: 0, }); @@ -910,7 +1053,7 @@ export default function suite() { await this.futarchy.initializeBuybackTokenProposal({ dao, quoteAmount: new BN(400_000_000), - quoteAmountPerCycle: new BN(5_000_000), + cycleCount: 80, cycleFrequencySeconds: 86_400, startDelaySeconds: 0, }); @@ -948,23 +1091,6 @@ export default function suite() { assert.equal(storedSquadsProposal.status.__kind, "Executed"); }); - it("rejects a zero per-cycle amount", async function () { - const callbacks = expectError( - "InvalidBuybackAmount", - "created a buyback with a zero per-cycle amount", - ); - - await this.futarchy - .initializeBuybackTokenProposal({ - dao, - quoteAmount: new BN(400_000_000), - quoteAmountPerCycle: new BN(0), - cycleFrequencySeconds: 86_400, - startDelaySeconds: 0, - }) - .then(callbacks[0], callbacks[1]); - }); - it("rejects a zero total", async function () { const callbacks = expectError( "InvalidBuybackAmount", @@ -975,41 +1101,41 @@ export default function suite() { .initializeBuybackTokenProposal({ dao, quoteAmount: new BN(0), - quoteAmountPerCycle: new BN(5_000_000), + cycleCount: 80, cycleFrequencySeconds: 86_400, startDelaySeconds: 0, }) .then(callbacks[0], callbacks[1]); }); - it("rejects a per-cycle that doesn't divide the total", async function () { - const callbacks = expectError( - "InvalidBuybackAmount", - "created a buyback whose per-cycle doesn't divide the total", - ); + it("accepts a total that doesn't split evenly across the cycles", async function () { + // 100 USDC over 3 cycles: the venue puts the remainder in the last order, + // so the mandate records the total and the count as given + const { proposal } = await this.futarchy.initializeBuybackTokenProposal({ + dao, + quoteAmount: new BN(100_000_000), + cycleCount: 3, + cycleFrequencySeconds: 86_400, + startDelaySeconds: 0, + }); - await this.futarchy - .initializeBuybackTokenProposal({ - dao, - quoteAmount: new BN(100_000_000), - quoteAmountPerCycle: new BN(30_000_000), - cycleFrequencySeconds: 86_400, - startDelaySeconds: 0, - }) - .then(callbacks[0], callbacks[1]); + const action = (await this.futarchy.getProposal(proposal)).action + .buybackToken; + assert.equal(action.quoteAmount.toString(), "100000000"); + assert.equal(action.cycleCount, 3); }); - it("rejects a single-order programme", async function () { + it("rejects a single-cycle programme", async function () { const callbacks = expectError( - "InvalidBuybackAmount", - "created a single-order buyback", + "InvalidBuybackCycleCount", + "created a single-cycle buyback", ); await this.futarchy .initializeBuybackTokenProposal({ dao, quoteAmount: new BN(100_000_000), - quoteAmountPerCycle: new BN(100_000_000), + cycleCount: 1, cycleFrequencySeconds: 86_400, startDelaySeconds: 0, }) @@ -1026,7 +1152,7 @@ export default function suite() { .initializeBuybackTokenProposal({ dao, quoteAmount: new BN(400_000_000), - quoteAmountPerCycle: new BN(5_000_000), + cycleCount: 80, cycleFrequencySeconds: 86_400, startDelaySeconds: 0, minPrice: new BN(2_000_000), @@ -1045,7 +1171,7 @@ export default function suite() { .initializeBuybackTokenProposal({ dao, quoteAmount: new BN(400_000_000), - quoteAmountPerCycle: new BN(5_000_000), + cycleCount: 80, cycleFrequencySeconds: 59, startDelaySeconds: 0, }) @@ -1062,7 +1188,7 @@ export default function suite() { .initializeBuybackTokenProposal({ dao, quoteAmount: new BN(400_000_000), - quoteAmountPerCycle: new BN(5_000_000), + cycleCount: 80, cycleFrequencySeconds: 365 * 24 * 60 * 60 + 1, startDelaySeconds: 0, }) @@ -1079,7 +1205,7 @@ export default function suite() { .initializeBuybackTokenProposal({ dao, quoteAmount: new BN(400_000_000), - quoteAmountPerCycle: new BN(5_000_000), + cycleCount: 80, cycleFrequencySeconds: 86_400, startDelaySeconds: 30 * 24 * 60 * 60 + 1, }) @@ -1090,7 +1216,7 @@ export default function suite() { const { proposal } = await this.futarchy.initializeBuybackTokenProposal({ dao, quoteAmount: new BN(400_000_000), - quoteAmountPerCycle: new BN(5_000_000), + cycleCount: 80, cycleFrequencySeconds: 86_400, startDelaySeconds: 0, }); diff --git a/tests/futarchy/unit/initializeDao.test.ts b/tests/futarchy/unit/initializeDao.test.ts index 110c3faa..d1658d25 100644 --- a/tests/futarchy/unit/initializeDao.test.ts +++ b/tests/futarchy/unit/initializeDao.test.ts @@ -191,6 +191,38 @@ export default function suite() { assert.isFalse(storedDao.isOptimisticGovernanceEnabled); }); + it("doesn't allow an initial spending limit with a zero monthly amount", async function () { + const callbacks = expectError( + "InvalidSpendingLimitAmount", + "DAO initialized despite a zero monthly spending limit", + ); + + 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(1), + minBaseFutarchicLiquidity: new BN(1000), + baseToStake: new BN(1000), + passThresholdBps: 300, + nonce: new BN(421), + initialSpendingLimit: { + amountPerMonth: new BN(0), + members: [Keypair.generate().publicKey], + }, + teamSponsoredPassThresholdBps: 123, + teamAddress: this.payer.publicKey, + }, + }) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + it("doesn't allow DAOs with identical base and quote mints", async function () { const SAME_MINT = await this.createMint(this.payer.publicKey, 6); diff --git a/tests/futarchy/unit/initializeHostileLiquidateProposal.test.ts b/tests/futarchy/unit/initializeHostileLiquidateProposal.test.ts index 47f9c037..a9b9a156 100644 --- a/tests/futarchy/unit/initializeHostileLiquidateProposal.test.ts +++ b/tests/futarchy/unit/initializeHostileLiquidateProposal.test.ts @@ -1,19 +1,10 @@ -import { - FUTARCHY_V0_6_PROGRAM_ID, - getDaoAddr, - getEventAuthorityAddr, - PriceMath, -} from "@metadaoproject/programs"; +import { getDaoAddr, PriceMath } from "@metadaoproject/programs"; import { ComputeBudgetProgram, Keypair, PublicKey, TransactionInstruction, } 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"; @@ -59,7 +50,7 @@ export default function suite() { [dao] = getDaoAddr({ nonce, daoCreator: this.payer.publicKey }); }); - it("bakes an apply_liquidation whose accounts are exactly the derived set, plus an IP-transfer memo", async function () { + it("bakes the IP-transfer memo into the Squads payload", async function () { const liquidator = Keypair.generate().publicKey; const { proposal, squadsProposal, squadsTransaction } = @@ -68,32 +59,9 @@ export default function suite() { 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(); - + // The payload is ceremony: a memo touches no accounts, so the immutable + // Squads transaction cannot fail on any DAO configuration. The state flip + // happens at finalize; the liquidator unwinds through the estate cycle. const expectedMemoIx = new TransactionInstruction({ programId: new PublicKey("MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr"), keys: [], @@ -104,7 +72,6 @@ export default function suite() { }); await assertVaultTransactionPayload(this, dao, squadsTransaction, [ - expectedApplyLiquidationIx, expectedMemoIx, ]); diff --git a/tests/futarchy/unit/initializeHostileTakeoverProposal.test.ts b/tests/futarchy/unit/initializeHostileTakeoverProposal.test.ts index 767d4819..5c5b4dd0 100644 --- a/tests/futarchy/unit/initializeHostileTakeoverProposal.test.ts +++ b/tests/futarchy/unit/initializeHostileTakeoverProposal.test.ts @@ -257,4 +257,70 @@ export default function suite() { }) .then(...callbacks); }); + + it("throws error when a Set action's monthly amount is zero", async function () { + const callbacks = expectError( + "InvalidSpendingLimitAmount", + "created a hostile takeover proposal with a zero monthly amount", + ); + await this.futarchy + .initializeHostileTakeoverProposal({ + dao, + newTeamAddress: Keypair.generate().publicKey, + spendingLimitAction: { + set: { + 0: { + amountPerMonth: new BN(0), + members: [Keypair.generate().publicKey], + }, + }, + }, + }) + .then(...callbacks); + }); + + it("throws error when a Set action has no members", async function () { + const callbacks = expectError( + "EmptySpendingLimitMembers", + "created a hostile takeover proposal with no members", + ); + await this.futarchy + .initializeHostileTakeoverProposal({ + dao, + newTeamAddress: Keypair.generate().publicKey, + spendingLimitAction: { + set: { + 0: { + amountPerMonth: new BN(1_000_000_000), // 1,000 USDC + members: [], + }, + }, + }, + }) + .then(...callbacks); + }); + + it("throws error when a Set action has duplicate members", async function () { + const member = Keypair.generate().publicKey; + + const callbacks = expectError( + "DuplicateSpendingLimitMember", + "created a hostile takeover proposal with duplicate members", + ); + await this.futarchy + .initializeHostileTakeoverProposal({ + dao, + newTeamAddress: Keypair.generate().publicKey, + spendingLimitAction: { + set: { + 0: { + amountPerMonth: new BN(1_000_000_000), // 1,000 USDC + // Non-adjacent so the check must sort before comparing neighbours + members: [member, Keypair.generate().publicKey, member], + }, + }, + }, + }) + .then(...callbacks); + }); } diff --git a/tests/futarchy/unit/initializeLargeSpendProposal.test.ts b/tests/futarchy/unit/initializeLargeSpendProposal.test.ts index 2b2e5ce7..a774a69f 100644 --- a/tests/futarchy/unit/initializeLargeSpendProposal.test.ts +++ b/tests/futarchy/unit/initializeLargeSpendProposal.test.ts @@ -114,6 +114,9 @@ export default function suite() { storedProposal.action.largeSpend.amount.toString(), amount.toString(), ); + assert.ok( + storedProposal.action.largeSpend.teamAddress.equals(this.payer.publicKey), + ); assert.equal(storedProposal.durationInSeconds, 129_600); assert.equal(storedProposal.passThresholdBps, -1000); assert.isTrue(storedProposal.councilCanBlock); diff --git a/tests/futarchy/unit/initializeSpendingLimitChangeProposal.test.ts b/tests/futarchy/unit/initializeSpendingLimitChangeProposal.test.ts index bbafd890..bd0476e9 100644 --- a/tests/futarchy/unit/initializeSpendingLimitChangeProposal.test.ts +++ b/tests/futarchy/unit/initializeSpendingLimitChangeProposal.test.ts @@ -136,6 +136,57 @@ export default function suite() { .then(...callbacks); }); + it("throws error when the config's monthly amount is zero", async function () { + const callbacks = expectError( + "InvalidSpendingLimitAmount", + "created a spending limit change proposal with a zero monthly amount", + ); + await this.futarchy + .initializeSpendingLimitChangeProposal({ + dao, + config: { + amountPerMonth: new BN(0), + members: [Keypair.generate().publicKey], + }, + }) + .then(...callbacks); + }); + + it("throws error when the config has no members", async function () { + const callbacks = expectError( + "EmptySpendingLimitMembers", + "created a spending limit change proposal with no members", + ); + await this.futarchy + .initializeSpendingLimitChangeProposal({ + dao, + config: { + amountPerMonth: new BN(1_000_000_000), // 1,000 USDC + members: [], + }, + }) + .then(...callbacks); + }); + + it("throws error when the config has duplicate members", async function () { + const member = Keypair.generate().publicKey; + + const callbacks = expectError( + "DuplicateSpendingLimitMember", + "created a spending limit change proposal with duplicate members", + ); + await this.futarchy + .initializeSpendingLimitChangeProposal({ + dao, + config: { + amountPerMonth: new BN(1_000_000_000), // 1,000 USDC + // Non-adjacent so the check must sort before comparing neighbours + members: [member, Keypair.generate().publicKey, member], + }, + }) + .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 diff --git a/tests/futarchy/unit/launchProposal.test.ts b/tests/futarchy/unit/launchProposal.test.ts index fe10a898..52681ad3 100644 --- a/tests/futarchy/unit/launchProposal.test.ts +++ b/tests/futarchy/unit/launchProposal.test.ts @@ -11,7 +11,11 @@ import { TransactionMessage, } from "@solana/web3.js"; import BN from "bn.js"; -import { expectError } from "../../utils.js"; +import { + executeVaultTransaction, + expectError, + forceApproveSquadsProposal, +} from "../../utils.js"; import { assert } from "chai"; import * as multisig from "@sqds/multisig"; @@ -633,6 +637,201 @@ export default function suite() { assert.exists(storedProposal.state.pending); }); + it("fails to launch a sponsored large_spend after a team change", async function () { + const dao = await createDaoWithStakeThreshold( + this, + META, + USDC, + new BN(0), + this.payer, + ); + + 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(); + + const { proposal, squadsProposal } = + await this.futarchy.initializeLargeSpendProposal({ + dao, + amount: new BN(10_000), + }); + + await this.futarchy + .sponsorProposalIx({ + proposal, + dao, + teamAddress: this.payer.publicKey, + }) + .rpc(); + + // Replace the team while the sponsored draft is still unlaunched + const takeover = await this.futarchy.initializeHostileTakeoverProposal({ + dao, + newTeamAddress: Keypair.generate().publicKey, + spendingLimitAction: { keep: {} }, + }); + await forceApproveSquadsProposal(this, takeover.squadsProposal); + await executeVaultTransaction(this, dao, takeover.squadsTransaction); + + const callbacks = expectError( + "StaleTeamAddress", + "launched a large spend paying the previous team", + ); + + await this.futarchy + .launchProposalIx({ + proposal, + dao, + baseMint: META, + quoteMint: USDC, + squadsProposal, + }) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("fails to launch a sponsored large_spend after the limit drops below its amount", async function () { + const dao = await createDaoWithStakeThreshold( + this, + META, + USDC, + new BN(0), + this.payer, + ); + + 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(); + + // Exactly the three-month cap, so any reduction puts it over + const { proposal, squadsProposal } = + await this.futarchy.initializeLargeSpendProposal({ + dao, + amount: spendingLimit.muln(3), + }); + + await this.futarchy + .sponsorProposalIx({ + proposal, + dao, + teamAddress: this.payer.publicKey, + }) + .rpc(); + + const change = await this.futarchy.initializeSpendingLimitChangeProposal({ + dao, + config: { + amountPerMonth: spendingLimit.divn(10), + members: [this.payer.publicKey], + }, + }); + await forceApproveSquadsProposal(this, change.squadsProposal); + await executeVaultTransaction(this, dao, change.squadsTransaction); + + const callbacks = expectError( + "SpendCapExceeded", + "launched a large spend above the reduced cap", + ); + + await this.futarchy + .launchProposalIx({ + proposal, + dao, + baseMint: META, + quoteMint: USDC, + squadsProposal, + }) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("fails to launch a sponsored large_spend after the limit is removed", async function () { + const dao = await createDaoWithStakeThreshold( + this, + META, + USDC, + new BN(0), + this.payer, + ); + + 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(); + + const { proposal, squadsProposal } = + await this.futarchy.initializeLargeSpendProposal({ + dao, + amount: new BN(10_000), + }); + + await this.futarchy + .sponsorProposalIx({ + proposal, + dao, + teamAddress: this.payer.publicKey, + }) + .rpc(); + + const removal = await this.futarchy.initializeSpendingLimitChangeProposal({ + dao, + config: null, + }); + await forceApproveSquadsProposal(this, removal.squadsProposal); + await executeVaultTransaction(this, dao, removal.squadsTransaction); + + const callbacks = expectError( + "NoSpendingLimit", + "launched a large spend with no spending limit", + ); + + await this.futarchy + .launchProposalIx({ + proposal, + dao, + baseMint: META, + quoteMint: USDC, + squadsProposal, + }) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + it("fails to launch an unsponsored spending_limit_change, launches once sponsored", async function () { const dao = await createDaoWithStakeThreshold( this, diff --git a/tests/futarchy/unit/liquidatedGuards.test.ts b/tests/futarchy/unit/liquidatedGuards.test.ts index 0ac93fa7..43298b13 100644 --- a/tests/futarchy/unit/liquidatedGuards.test.ts +++ b/tests/futarchy/unit/liquidatedGuards.test.ts @@ -27,10 +27,10 @@ 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 +// - finalize_proposal: the liquidator is written by finalize itself, while no +// other market can be live, and launch refuses from then on — so a +// liquidated DAO never has a market left to finalize (the +// finalize → sync → unwind flow is pinned by liquidationEndToEnd.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) @@ -38,15 +38,10 @@ 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); @@ -100,13 +95,6 @@ export default function suite() { [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); @@ -187,6 +175,7 @@ export default function suite() { cranks: 50, }); + await this.futarchy.syncSpendingLimitIx({ dao }).rpc(); await executeVaultTransaction(this, dao, squadsTransaction); const liquidatedDao = await this.futarchy.getDao(dao); @@ -198,8 +187,9 @@ export default function suite() { const createSquadsVaultTx = async function ( context: TestContext, instructions: any[], + targetDao: PublicKey = dao, ) { - const multisigPda = multisig.getMultisigPda({ createKey: dao })[0]; + const multisigPda = multisig.getMultisigPda({ createKey: targetDao })[0]; const multisigAccount = await multisig.accounts.Multisig.fromAccountAddress( context.squadsConnection, multisigPda, @@ -208,7 +198,7 @@ export default function suite() { BigInt(multisigAccount.transactionIndex.toString()) + 1n; const { tx } = context.futarchy.squadsProposalCreateTx({ - dao, + dao: targetDao, instructions, transactionIndex, }); @@ -217,7 +207,10 @@ export default function suite() { tx.sign(context.payer, PERMISSIONLESS_ACCOUNT); await context.banksClient.processTransaction(tx); - return getProposalAddrsForTransactionIndex({ dao, transactionIndex }); + return getProposalAddrsForTransactionIndex({ + dao: targetDao, + transactionIndex, + }); }; it("refuses initialize_proposal", async function () { @@ -369,24 +362,6 @@ export default function suite() { .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", @@ -498,17 +473,47 @@ export default function suite() { } }); - it("allows sync_spending_limit, which removes the Squads limit without recreating", async function () { + it("holds no live spending limit: the pre-sweep sync removed it, and a re-sync refuses", 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); + + const callbacks = expectError( + "SpendingLimitNotDirty", + "re-sync should refuse once the flag is consumed", + ); + + await this.futarchy + .syncSpendingLimitIx({ dao }) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + // Runs before the LP exit below: a swap needs the pool still funded. + it("allows spot_swap", async function () { + const preSpot = (await this.futarchy.getDao(dao)).amm.state.spot.spot; + const preBase = await this.getTokenBalance(META, this.payer.publicKey); + + await this.futarchy + .spotSwapIx({ + dao, + baseMint: META, + quoteMint: USDC, + swapType: "buy", + inputAmount: new BN(500 * 1_000_000), + }) + .rpc(); + + assert.isTrue( + (await this.getTokenBalance(META, this.payer.publicKey)) > preBase, + ); + + const postSpot = (await this.futarchy.getDao(dao)).amm.state.spot.spot; + assert.isTrue(postSpot.quoteReserves.gt(preSpot.quoteReserves)); + assert.isTrue(postSpot.baseReserves.lt(preSpot.baseReserves)); }); it("allows withdraw_liquidity", async function () { @@ -611,4 +616,179 @@ export default function suite() { "0", ); }); + + // dao.liquidator is written by finalize itself, so the DAO is bricked + // the moment the market resolves. The ceremonial payload is never executed + // here — none of these guards depend on it. + describe("liquidation marker set by finalize", function () { + let base: PublicKey, + quote: PublicKey, + reservedDao: PublicKey, + liquidatorA: PublicKey, + rivalLiquidation: { proposal: PublicKey; squadsProposal: PublicKey }, + stagedDraft: { proposal: PublicKey; squadsProposal: PublicKey }; + + before(async function () { + base = await this.createMint(this.payer.publicKey, 6); + 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, + 1_000 * 1_000_000, + ); + await this.mintTo( + quote, + this.payer.publicKey, + this.payer, + 500_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(10), + 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(); + + [reservedDao] = getDaoAddr({ nonce, daoCreator: this.payer.publicKey }); + + await this.futarchy + .provideLiquidityIx({ + dao: reservedDao, + baseMint: base, + quoteMint: quote, + 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(); + + liquidatorA = Keypair.generate().publicKey; + + // Everything is staged while the DAO is still healthy: the winning + // liquidation, a rival liquidation, and an ordinary draft + const winner = await this.futarchy.initializeHostileLiquidateProposal({ + dao: reservedDao, + liquidator: liquidatorA, + }); + rivalLiquidation = await this.futarchy.initializeHostileLiquidateProposal( + { + dao: reservedDao, + liquidator: Keypair.generate().publicKey, + }, + ); + + const { squadsProposal: stagedSquadsProposal } = + await createSquadsVaultTx( + this, + [ + { + programId: MEMO_PROGRAM_ID, + keys: [], + data: Buffer.from("gap proposal"), + }, + ], + reservedDao, + ); + stagedDraft = { + proposal: await this.futarchy.initializeProposal( + reservedDao, + stagedSquadsProposal, + ), + squadsProposal: stagedSquadsProposal, + }; + + await this.futarchy + .launchProposalIx({ + proposal: winner.proposal, + dao: reservedDao, + baseMint: base, + quoteMint: quote, + squadsProposal: winner.squadsProposal, + }) + .rpc(); + + await passProposal(this, { + dao: reservedDao, + proposal: winner.proposal, + baseMint: base, + quoteMint: quote, + cranks: 50, + }); + }); + + it("writes the liquidator at finalize, before the payload ever executes", async function () { + const storedDao = await this.futarchy.getDao(reservedDao); + assert.ok(storedDao.liquidator.equals(liquidatorA)); + }); + + it("refuses to launch a second liquidation once the first has passed", async function () { + const callbacks = expectError( + "DaoLiquidated", + "launched a liquidation after another had already passed", + ); + + await this.futarchy + .launchProposalIx({ + proposal: rivalLiquidation.proposal, + dao: reservedDao, + baseMint: base, + quoteMint: quote, + squadsProposal: rivalLiquidation.squadsProposal, + }) + .rpc() + .then(callbacks[0], callbacks[1]); + + // First writer wins: only one liquidation record ever holds the DAO + const storedDao = await this.futarchy.getDao(reservedDao); + assert.ok(storedDao.liquidator.equals(liquidatorA)); + }); + + it("refuses to launch a pre-staged draft in the finalize→execute gap", async function () { + const callbacks = expectError( + "DaoLiquidated", + "launched a blocker in the finalize→execute gap", + ); + + await this.futarchy + .launchProposalIx({ + proposal: stagedDraft.proposal, + dao: reservedDao, + baseMint: base, + quoteMint: quote, + squadsProposal: stagedDraft.squadsProposal, + }) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + }); } diff --git a/tests/futarchy/unit/liquidatorPath.test.ts b/tests/futarchy/unit/liquidatorPath.test.ts index c28fe30a..763ebe37 100644 --- a/tests/futarchy/unit/liquidatorPath.test.ts +++ b/tests/futarchy/unit/liquidatorPath.test.ts @@ -93,8 +93,6 @@ export default function suite() { 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 @@ -139,6 +137,7 @@ export default function suite() { cranks: 50, }); + await this.futarchy.syncSpendingLimitIx({ dao }).rpc(); await executeVaultTransaction(this, dao, squadsTransaction); // The liquidator pays rent for the enqueued approval account diff --git a/tests/futarchy/unit/resizeDao.test.ts b/tests/futarchy/unit/resizeDao.test.ts index 44e07a97..dbdb1101 100644 --- a/tests/futarchy/unit/resizeDao.test.ts +++ b/tests/futarchy/unit/resizeDao.test.ts @@ -1,3 +1,4 @@ +import { getSpendingLimitAddr } from "@metadaoproject/programs"; import { ComputeBudgetProgram, Keypair, @@ -6,62 +7,58 @@ import { Transaction, } from "@solana/web3.js"; import BN from "bn.js"; -import { setupBasicDao } from "../../utils.js"; +import { expectError, makeOldDaoLayout, 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( +// Byte offsets into a Squads SpendingLimit account's data: +// disc(8) multisig(32) create_key(32) vault_index(1) mint(32) amount(8) +// period(1) remaining_amount(8) last_reset(8) bump(1) members(vec) destinations(vec) +const SL_VAULT_INDEX_OFFSET = 72; +const SL_MINT_OFFSET = 73; +const SL_PERIOD_OFFSET = 113; +const SL_MEMBERS_LEN_OFFSET = 131; +const PERIOD_DAY = 1; + +// Overwrites the live Squads spending-limit account with mutated bytes — +// shapes the old program's governance path could have created but the new +// program never writes. Returns the patched data so tests can assert the +// migration left the account untouched. +async function patchLiveSpendingLimit( 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; - // 58 bytes: liquidator (Option) + last_failed_takeover_at (i64) - // + last_failed_liquidation_at (i64) + spending_limit_dirty (bool) - // + last_buyback_finalized_at (i64) - const BEFORE = AFTER - 58; - - 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 } : {}), - }); + mutate: (data: Buffer) => Buffer, +): Promise { + const [spendingLimit] = getSpendingLimitAddr({ dao }); + const raw = await ctx.banksClient.getAccount(spendingLimit); + const patched = mutate(Buffer.from(raw.data)); + ctx.context.setAccount(spendingLimit, { ...raw, data: patched }); + return patched; +} - return { AFTER, BEFORE }; +function withMembers(data: Buffer, members: PublicKey[]): Buffer { + const oldLen = data.readUInt32LE(SL_MEMBERS_LEN_OFFSET); + const destinationsOffset = SL_MEMBERS_LEN_OFFSET + 4 + 32 * oldLen; + const len = Buffer.alloc(4); + len.writeUInt32LE(members.length, 0); + return Buffer.concat([ + data.subarray(0, SL_MEMBERS_LEN_OFFSET), + len, + ...members.map((m) => Buffer.from(m.toBytes())), + data.subarray(destinationsOffset), + ]); +} + +function withDestinations(data: Buffer, destinations: PublicKey[]): Buffer { + const membersLen = data.readUInt32LE(SL_MEMBERS_LEN_OFFSET); + const destinationsOffset = SL_MEMBERS_LEN_OFFSET + 4 + 32 * membersLen; + const len = Buffer.alloc(4); + len.writeUInt32LE(destinations.length, 0); + return Buffer.concat([ + data.subarray(0, destinationsOffset), + len, + ...destinations.map((d) => Buffer.from(d.toBytes())), + ]); } export default function suite() { @@ -88,15 +85,12 @@ export default function suite() { assert.isFalse(original.spendingLimitDirty); assert.equal(original.lastBuybackFinalizedAt.toString(), "0"); - const { AFTER, BEFORE } = await makeOldLayout(this, dao); + const { AFTER, BEFORE } = await makeOldDaoLayout(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(); + await this.futarchy.resizeDaoIx({ dao }).rpc(); const resized = await this.banksClient.getAccount(dao); assert.equal(resized.data.length, AFTER); @@ -114,9 +108,8 @@ export default function suite() { ); // 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 }) + await this.futarchy + .resizeDaoIx({ dao }) .preInstructions([ ComputeBudgetProgram.setComputeUnitLimit({ units: 200_000 }), ]) @@ -128,7 +121,7 @@ export default function suite() { it("clears an in-flight optimistic proposal and carries the governance flag", async function () { const fakeSquadsProposal = Keypair.generate().publicKey; - await makeOldLayout(this, dao, { + await makeOldDaoLayout(this, dao, { isOptimisticGovernanceEnabled: true, optimisticProposal: { squadsProposal: fakeSquadsProposal, @@ -136,10 +129,7 @@ export default function suite() { }, }); - await this.futarchy.futarchy.methods - .resizeDao() - .accounts({ dao, payer: this.payer.publicKey }) - .rpc(); + await this.futarchy.resizeDaoIx({ dao }).rpc(); const migrated = await this.futarchy.getDao(dao); // The optimistic machinery is gone: in-flight spends are cleared, not @@ -157,10 +147,7 @@ export default function suite() { 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(); + await this.futarchy.resizeDaoIx({ dao }).rpc(); const afterRaw = await this.banksClient.getAccount(dao); assert.equal(afterRaw.data.length, beforeRaw.data.length); @@ -183,7 +170,7 @@ export default function suite() { // 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) }); + await makeOldDaoLayout(this, dao, {}, { lamports: Number(rentBefore) }); // Dedicated crank payer (not the fee payer) so its balance change isolates // the top-up transfer from transaction fees. @@ -202,9 +189,8 @@ export default function suite() { const payerBefore = await this.banksClient.getBalance(crankPayer.publicKey); - await this.futarchy.futarchy.methods - .resizeDao() - .accounts({ dao, payer: crankPayer.publicKey }) + await this.futarchy + .resizeDaoIx({ dao, payer: crankPayer.publicKey }) .signers([crankPayer]) .rpc(); @@ -215,4 +201,140 @@ export default function suite() { assert.equal(daoLamports.toString(), rentAfter.toString()); assert.equal((payerBefore - payerAfter).toString(), delta.toString()); }); + + it("migrates the live Squads limit, not the stale legacy field", async function () { + const limitDao = await setupBasicDao({ + context: this, + baseMint: META, + quoteMint: USDC, + initialSpendingLimit: { + amountPerMonth: new BN(1_000_000_000), + members: [this.payer.publicKey], + }, + }); + + // The legacy field claims 5,000/month even though the live limit is + // 1,000 — the divergence pre-upgrade governance could have created. + await makeOldDaoLayout(this, limitDao, { + initialSpendingLimit: { + amountPerMonth: new BN(5_000_000_000), + members: [Keypair.generate().publicKey], + }, + }); + + await this.futarchy.resizeDaoIx({ dao: limitDao }).rpc(); + + const migrated = await this.futarchy.getDao(limitDao); + assert.equal( + migrated.initialSpendingLimit.amountPerMonth.toString(), + "1000000000", + ); + assert.deepEqual( + migrated.initialSpendingLimit.members.map((m: PublicKey) => m.toBase58()), + [this.payer.publicKey.toBase58()], + ); + assert.isFalse(migrated.spendingLimitDirty); + }); + + it("migrates a stale legacy value as none when no live limit exists", async function () { + // The beforeEach DAO never created a Squads limit, but the legacy field + // claims one exists. + await makeOldDaoLayout(this, dao, { + initialSpendingLimit: { + amountPerMonth: new BN(1_000_000_000), + members: [this.payer.publicKey], + }, + }); + + await this.futarchy.resizeDaoIx({ dao }).rpc(); + + const migrated = await this.futarchy.getDao(dao); + assert.isNull(migrated.initialSpendingLimit); + assert.isFalse(migrated.spendingLimitDirty); + }); + + async function assertShapeMigratesAsNone( + ctx: TestContext, + mutate: (data: Buffer) => Buffer, + ) { + const limitDao = await setupBasicDao({ + context: ctx, + baseMint: META, + quoteMint: USDC, + initialSpendingLimit: { + amountPerMonth: new BN(1_000_000_000), + members: [ctx.payer.publicKey], + }, + }); + + const patched = await patchLiveSpendingLimit(ctx, limitDao, mutate); + await makeOldDaoLayout(ctx, limitDao); + + await ctx.futarchy.resizeDaoIx({ dao: limitDao }).rpc(); + + const migrated = await ctx.futarchy.getDao(limitDao); + assert.isNull(migrated.initialSpendingLimit); + + // The live Squads account is read, never written. + const [spendingLimit] = getSpendingLimitAddr({ dao: limitDao }); + const after = await ctx.banksClient.getAccount(spendingLimit); + assert.isTrue(Buffer.from(after.data).equals(patched)); + } + + it("migrates a non-Month live limit as none, leaving Squads untouched", async function () { + await assertShapeMigratesAsNone(this, (data) => { + data[SL_PERIOD_OFFSET] = PERIOD_DAY; + return data; + }); + }); + + it("migrates a foreign-mint live limit as none, leaving Squads untouched", async function () { + await assertShapeMigratesAsNone(this, (data) => { + Buffer.from(Keypair.generate().publicKey.toBytes()).copy( + data, + SL_MINT_OFFSET, + ); + return data; + }); + }); + + it("migrates a non-zero-vault live limit as none, leaving Squads untouched", async function () { + await assertShapeMigratesAsNone(this, (data) => { + data[SL_VAULT_INDEX_OFFSET] = 1; + return data; + }); + }); + + it("migrates a live limit with too many members as none, leaving Squads untouched", async function () { + await assertShapeMigratesAsNone(this, (data) => + withMembers( + data, + Array.from({ length: 11 }, () => Keypair.generate().publicKey), + ), + ); + }); + + it("migrates a destination-restricted live limit as none, leaving Squads untouched", async function () { + await assertShapeMigratesAsNone(this, (data) => + withDestinations(data, [Keypair.generate().publicKey]), + ); + }); + + it("throws when passed a non-canonical spending-limit account", async function () { + await makeOldDaoLayout(this, dao); + + const callbacks = expectError( + "InvalidSpendingLimitAccount", + "resize succeeded despite a wrong spending-limit account", + ); + await this.futarchy.futarchy.methods + .resizeDao() + .accounts({ + dao, + spendingLimit: Keypair.generate().publicKey, + payer: this.payer.publicKey, + }) + .rpc() + .then(...callbacks); + }); } diff --git a/tests/futarchy/unit/resizeProposal.test.ts b/tests/futarchy/unit/resizeProposal.test.ts index ba8b005a..86da2ebb 100644 --- a/tests/futarchy/unit/resizeProposal.test.ts +++ b/tests/futarchy/unit/resizeProposal.test.ts @@ -14,12 +14,16 @@ 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. +// optional overrides let a test pin `is_team_sponsored`, the state, or the +// duration without driving the sponsor/launch flows. async function makeOldLayout( ctx: TestContext, proposal: PublicKey, - overrides: { isTeamSponsored?: boolean } = {}, + overrides: { + isTeamSponsored?: boolean; + state?: object; + durationInSeconds?: number; + } = {}, ): Promise<{ AFTER: number; BEFORE: number }> { const raw = await ctx.banksClient.getAccount(proposal); const AFTER = raw.data.length; @@ -33,6 +37,9 @@ async function makeOldLayout( if (overrides.isTeamSponsored !== undefined) decoded.isTeamSponsored = overrides.isTeamSponsored; + if (overrides.state !== undefined) decoded.state = overrides.state; + if (overrides.durationInSeconds !== undefined) + decoded.durationInSeconds = overrides.durationInSeconds; const body = await coder.encode("oldProposal", decoded); const buf = Buffer.alloc(BEFORE); @@ -114,12 +121,16 @@ export default function suite() { proposal = await createProposal(this, dao); }); - it("migrates an old proposal with defaults snapshotted from the DAO, preserving every other field", async function () { + it("migrates an old draft to the kind's catalog params, preserving every other field", async function () { const original = await this.futarchy.getProposal(proposal); assert.isFalse(original.isTeamSponsored); assert.equal(original.passThresholdBps, 1000); + assert.equal(original.durationInSeconds, 60 * 60 * 24 * 10); - const { AFTER, BEFORE } = await makeOldLayout(this, proposal); + // A distinctive legacy duration proves normalization to the catalog value. + const { AFTER, BEFORE } = await makeOldLayout(this, proposal, { + durationInSeconds: 3600, + }); const short = await this.banksClient.getAccount(proposal); assert.equal(short.data.length, BEFORE); @@ -135,10 +146,12 @@ export default function suite() { 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); + // The kind constants, not the vestigial per-DAO threshold (300) or the + // legacy duration: a draft has no live market, so the permissionless + // crank's timing must not decide the rules it finalizes under. + assert.equal(migrated.passThresholdBps, 1000); + assert.equal(migrated.durationInSeconds, 60 * 60 * 24 * 10); - original.passThresholdBps = 300; assert.deepEqual( JSON.parse(JSON.stringify(migrated)), JSON.parse(JSON.stringify(original)), @@ -163,7 +176,7 @@ export default function suite() { ); }); - it("snapshots the team-sponsored threshold for a team-sponsored proposal", async function () { + it("migrates a team-sponsored draft to the catalog params too", async function () { await makeOldLayout(this, proposal, { isTeamSponsored: true }); await this.futarchy.futarchy.methods @@ -173,11 +186,46 @@ export default function suite() { const migrated = await this.futarchy.getProposal(proposal); assert.isTrue(migrated.isTeamSponsored); - assert.equal(migrated.passThresholdBps, -100); + assert.equal(migrated.passThresholdBps, 1000); + }); + + it("snapshots the DAO threshold and preserves the duration for a launched proposal", async function () { + await makeOldLayout(this, proposal, { + state: { pending: {} }, + durationInSeconds: 3600, + }); + + await this.futarchy.futarchy.methods + .resizeProposal() + .accounts({ proposal, dao, payer: this.payer.publicKey }) + .rpc(); + + const migrated = await this.futarchy.getProposal(proposal); + assert.isDefined(migrated.state.pending); + // A live market keeps the rules it was staked and traded under: the + // vestigial per-DAO threshold (300), not the kind constant (1000). + assert.equal(migrated.passThresholdBps, 300); + assert.equal(migrated.durationInSeconds, 3600); assert.isDefined(migrated.action.executeArbitrary); assert.isTrue(migrated.councilCanBlock); }); + it("snapshots the team-sponsored threshold for a launched team-sponsored proposal", async function () { + await makeOldLayout(this, proposal, { + state: { pending: {} }, + 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); + }); + 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); @@ -205,21 +253,20 @@ export default function suite() { .accounts({ proposal, dao, payer: this.payer.publicKey }) .rpc(); - // Migration stamps every proposal `ExecuteArbitrary` with a threshold - // copied from the retired per-DAO field, so retuning is the only way to - // bring one in line with the catalog without starting over. + // Migrated drafts land on the catalog params, and stay `ExecuteArbitrary` + // drafts — so the per-proposal admin lever must still apply to them. await this.futarchy .adminUpdateProposalParamsIx({ proposal, dao, durationInSeconds: 60 * 60 * 24 * 2, - passThresholdBps: 1000, + passThresholdBps: 500, }) .rpc(); const retuned = await this.futarchy.getProposal(proposal); assert.equal(retuned.durationInSeconds, 60 * 60 * 24 * 2); - assert.equal(retuned.passThresholdBps, 1000); + assert.equal(retuned.passThresholdBps, 500); }); it("rejects a DAO that is not the proposal's", async function () { diff --git a/tests/futarchy/unit/setSpendingLimit.test.ts b/tests/futarchy/unit/setSpendingLimit.test.ts index 9d05889a..45c57e14 100644 --- a/tests/futarchy/unit/setSpendingLimit.test.ts +++ b/tests/futarchy/unit/setSpendingLimit.test.ts @@ -109,6 +109,33 @@ async function executeSetSpendingLimitViaVault( await context.banksClient.processTransaction(executeTx); } +// A rejected set_spending_limit surfaces through the Squads execute CPI as a +// raw transaction error, so match on the error name or its hex code and then +// confirm the record and dirty flag were left untouched. +async function assertSetSpendingLimitRejected( + context: TestContext, + dao: PublicKey, + config: { amountPerMonth: BN; members: PublicKey[] }, + errorName: string, + errorHex: string, +) { + await executeSetSpendingLimitViaVault(context, dao, config).then( + () => assert.fail(`set_spending_limit should have thrown ${errorName}`), + (e) => + assert( + e.toString().includes(errorName) || e.toString().includes(errorHex), + `Expected ${errorName} error, got: ${e}`, + ), + ); + + const daoAccount = await context.futarchy.getDao(dao); + assert.equal( + daoAccount.initialSpendingLimit.amountPerMonth.toString(), + "10000000000", + ); + assert.isFalse(daoAccount.spendingLimitDirty); +} + export default function suite() { let META: PublicKey, USDC: PublicKey, dao: PublicKey; @@ -213,26 +240,57 @@ export default function suite() { () => Keypair.generate().publicKey, ); - try { - await executeSetSpendingLimitViaVault(this, dao, { + await assertSetSpendingLimitRejected( + 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}`, - ); - } + }, + "TooManySpendingLimitMembers", + "0x17a4", // 6052 + ); + }); - const daoAccount = await this.futarchy.getDao(dao); - assert.equal( - daoAccount.initialSpendingLimit.amountPerMonth.toString(), - "10000000000", + it("throws when the config's monthly amount is zero", async function () { + await assertSetSpendingLimitRejected( + this, + dao, + { + amountPerMonth: new BN(0), + members: [Keypair.generate().publicKey], + }, + "InvalidSpendingLimitAmount", + "0x17b3", // 6067 + ); + }); + + it("throws when the config has no members", async function () { + await assertSetSpendingLimitRejected( + this, + dao, + { + amountPerMonth: new BN(1_000_000_000), // 1,000 USDC + members: [], + }, + "EmptySpendingLimitMembers", + "0x17b4", // 6068 + ); + }); + + it("throws when the config has duplicate members", async function () { + const member = Keypair.generate().publicKey; + + await assertSetSpendingLimitRejected( + this, + dao, + { + amountPerMonth: new BN(1_000_000_000), // 1,000 USDC + // Non-adjacent so the check must sort before comparing neighbours + members: [member, Keypair.generate().publicKey, member], + }, + "DuplicateSpendingLimitMember", + "0x17b5", // 6069 ); - assert.isFalse(daoAccount.spendingLimitDirty); }); } diff --git a/tests/utils.ts b/tests/utils.ts index 6290deae..35763ec4 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -81,6 +81,63 @@ export async function setupBasicDao({ return dao; } +export type OldDaoLayoutOverrides = { + optimisticProposal?: { + squadsProposal: PublicKey; + enqueuedTimestamp: typeof BN.prototype; + } | null; + isOptimisticGovernanceEnabled?: boolean; + initialSpendingLimit?: { + amountPerMonth: typeof BN.prototype; + members: PublicKey[]; + } | null; +}; + +// Rewrites a real (new-layout) Dao account to the pre-migration on-chain layout. +export async function makeOldDaoLayout( + ctx: TestContext, + dao: PublicKey, + overrides: OldDaoLayoutOverrides = {}, + opts: { lamports?: number; residue?: Buffer } = {}, +): Promise<{ AFTER: number; BEFORE: number }> { + const raw = await ctx.banksClient.getAccount(dao); + const AFTER = raw.data.length; + // 58 bytes: liquidator (Option) + last_failed_takeover_at (i64) + // + last_failed_liquidation_at (i64) + spending_limit_dirty (bool) + // + last_buyback_finalized_at (i64) + const BEFORE = AFTER - 58; + + 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; + if (overrides.initialSpendingLimit !== undefined) + decoded.initialSpendingLimit = overrides.initialSpendingLimit; + + // Encode as oldDao and truncate to 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); + if (opts.residue !== undefined) { + assert.isAtMost(body.length + opts.residue.length, BEFORE); + opts.residue.copy(buf, body.length); + } + + ctx.context.setAccount(dao, { + ...raw, + data: buf, + ...(opts.lamports !== undefined ? { lamports: opts.lamports } : {}), + }); + + return { AFTER, BEFORE }; +} + // 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 @@ -198,6 +255,10 @@ export async function executeVaultTransaction( context: TestContext, dao: PublicKey, squadsTransaction: PublicKey, + preInstructions: TransactionInstruction[] = [], + // For payloads whose inner message names signers beyond the vault PDA + // (e.g. a gated_invoke caller) — Squads requires them on the execute + extraSigners: Keypair[] = [], ) { const vaultTransaction = await multisig.accounts.VaultTransaction.fromAccountAddress( @@ -212,10 +273,10 @@ export async function executeVaultTransaction( member: PERMISSIONLESS_ACCOUNT.publicKey, }); - const tx = new Transaction().add(instruction); + const tx = new Transaction().add(...preInstructions, instruction); [tx.recentBlockhash] = await context.banksClient.getLatestBlockhash(); tx.feePayer = context.payer.publicKey; - tx.sign(context.payer, PERMISSIONLESS_ACCOUNT); + tx.sign(context.payer, PERMISSIONLESS_ACCOUNT, ...extraSigners); await context.banksClient.processTransaction(tx); }