diff --git a/programs/futarchy/src/error.rs b/programs/futarchy/src/error.rs index 508a07e0..8af623e9 100644 --- a/programs/futarchy/src/error.rs +++ b/programs/futarchy/src/error.rs @@ -106,8 +106,6 @@ pub enum FutarchyError { SpendingLimitNotDirty, #[msg("Wrong proposal kind for this instruction")] InvalidProposalKind, - #[msg("This DAO has already been liquidated")] - AlreadyLiquidated, #[msg("A spending limit can have at most 10 members")] TooManySpendingLimitMembers, #[msg("Invalid liquidator")] @@ -118,7 +116,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, @@ -126,10 +124,32 @@ pub enum FutarchyError { InvalidBuybackStartDelay, #[msg("min_price must be no greater than max_price")] InvalidBuybackPriceBand, - #[msg("A treasury account is neither a vault-owned quote account nor the treasury's AMM position")] + #[msg( + "A treasury account is neither a vault-owned quote account nor the treasury's AMM position" + )] InvalidTreasuryAccount, #[msg("Treasury accounts must be in strictly ascending key order")] 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, + #[msg("Invalid team address")] + InvalidTeamAddress, + #[msg("This proposal kind cannot be team-sponsored")] + TeamSponsorshipForbidden, + #[msg("Squads proposal must be in Approved status to be cancelled")] + SquadsProposalNotApproved, } 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/admin_enqueue_multisig_proposal_cancellation.rs b/programs/futarchy/src/instructions/admin_enqueue_multisig_proposal_cancellation.rs new file mode 100644 index 00000000..79b41ff1 --- /dev/null +++ b/programs/futarchy/src/instructions/admin_enqueue_multisig_proposal_cancellation.rs @@ -0,0 +1,125 @@ +use super::*; + +mod admin { + use anchor_lang::prelude::declare_id; + + // MetaDAO ops multisig — the same signer as the approval enqueue + declare_id!("6awyHMshBGVjJ3ozdSJdyyDE1CTAXUwrpNMaRGMsb4sf"); +} + +#[derive(Debug, Clone, AnchorSerialize, AnchorDeserialize)] +pub struct AdminEnqueueMultisigProposalCancellationArgs { + pub transaction_index: u64, +} + +#[derive(Accounts)] +#[instruction(args: AdminEnqueueMultisigProposalCancellationArgs)] +pub struct AdminEnqueueMultisigProposalCancellation<'info> { + #[account(has_one = squads_multisig)] + pub dao: Account<'info, Dao>, + + #[account(mut)] + pub admin: Signer<'info>, + + #[account( + seeds = [ + squads_multisig_program::SEED_PREFIX, + squads_multisig_program::SEED_MULTISIG, + dao.key().as_ref(), + ], + bump, + seeds::program = squads_multisig_program::ID, + )] + pub squads_multisig: Account<'info, squads_multisig_program::Multisig>, + + #[account( + seeds = [ + squads_multisig_program::SEED_PREFIX, + squads_multisig.key().as_ref(), + squads_multisig_program::SEED_TRANSACTION, + args.transaction_index.to_le_bytes().as_ref(), + squads_multisig_program::SEED_PROPOSAL, + ], + bump, + seeds::program = squads_multisig_program::ID, + )] + pub squads_multisig_proposal: Account<'info, squads_multisig_program::Proposal>, + + #[account( + init, + payer = admin, + space = 8 + EnqueuedMultisigProposalCancellation::INIT_SPACE, + seeds = [ + SEED_ENQUEUED_MULTISIG_PROPOSAL_CANCELLATION, + dao.key().as_ref(), + args.transaction_index.to_le_bytes().as_ref(), + ], + bump, + )] + pub enqueued_cancellation: Account<'info, EnqueuedMultisigProposalCancellation>, + + pub system_program: Program<'info, System>, +} + +impl AdminEnqueueMultisigProposalCancellation<'_> { + pub fn validate(&self, _args: &AdminEnqueueMultisigProposalCancellationArgs) -> 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 cancel leg stays permissionless. + match self.dao.liquidator { + Some(liquidator) => { + require_keys_eq!( + self.admin.key(), + liquidator, + FutarchyError::InvalidLiquidator + ); + } + None => { + #[cfg(feature = "production")] + require_keys_eq!(self.admin.key(), admin::ID, FutarchyError::InvalidAdmin); + } + } + + validate_squads_proposal_for_cancellation( + &self.squads_multisig_proposal, + &self.dao.squads_multisig, + )?; + + Ok(()) + } + + pub fn handle( + ctx: Context, + args: AdminEnqueueMultisigProposalCancellationArgs, + ) -> Result<()> { + let enqueued = &mut ctx.accounts.enqueued_cancellation; + + enqueued.dao = ctx.accounts.dao.key(); + enqueued.transaction_index = args.transaction_index; + enqueued.pda_bump = ctx.bumps.enqueued_cancellation; + + Ok(()) + } +} + +/// A cancellation targets an `Approved` proposal. Squads permits cancelling a +/// stale proposal, so there is no stale-index check here. +pub fn validate_squads_proposal_for_cancellation( + squads_proposal: &squads_multisig_program::Proposal, + dao_multisig_key: &Pubkey, +) -> Result<()> { + require_keys_eq!(squads_proposal.multisig, *dao_multisig_key); + + require!( + matches!( + squads_proposal.status, + squads_multisig_program::ProposalStatus::Approved { .. } + ), + FutarchyError::SquadsProposalNotApproved + ); + + Ok(()) +} 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/execute_multisig_proposal_cancellation.rs b/programs/futarchy/src/instructions/execute_multisig_proposal_cancellation.rs new file mode 100644 index 00000000..5934cfbd --- /dev/null +++ b/programs/futarchy/src/instructions/execute_multisig_proposal_cancellation.rs @@ -0,0 +1,95 @@ +use super::*; + +#[derive(Accounts)] +pub struct ExecuteMultisigProposalCancellation<'info> { + #[account(mut, has_one = squads_multisig)] + pub dao: Account<'info, Dao>, + + #[account(mut)] + pub rent_receiver: Signer<'info>, + + #[account( + mut, + seeds = [ + squads_multisig_program::SEED_PREFIX, + squads_multisig_program::SEED_MULTISIG, + dao.key().as_ref(), + ], + bump, + seeds::program = squads_multisig_program::ID, + )] + pub squads_multisig: Account<'info, squads_multisig_program::Multisig>, + + #[account( + mut, + seeds = [ + squads_multisig_program::SEED_PREFIX, + squads_multisig.key().as_ref(), + squads_multisig_program::SEED_TRANSACTION, + enqueued_cancellation.transaction_index.to_le_bytes().as_ref(), + squads_multisig_program::SEED_PROPOSAL, + ], + bump, + seeds::program = squads_multisig_program::ID, + )] + pub squads_multisig_proposal: Account<'info, squads_multisig_program::Proposal>, + + #[account( + mut, + close = rent_receiver, + has_one = dao, + seeds = [ + SEED_ENQUEUED_MULTISIG_PROPOSAL_CANCELLATION, + dao.key().as_ref(), + enqueued_cancellation.transaction_index.to_le_bytes().as_ref(), + ], + bump = enqueued_cancellation.pda_bump, + )] + pub enqueued_cancellation: Account<'info, EnqueuedMultisigProposalCancellation>, + + pub squads_multisig_program: + Program<'info, squads_multisig_program::program::SquadsMultisigProgram>, +} + +impl ExecuteMultisigProposalCancellation<'_> { + pub fn validate(&self) -> Result<()> { + // No Spot-state gate: a live market's Squads proposal is Active, never Approved. + validate_squads_proposal_for_cancellation( + &self.squads_multisig_proposal, + &self.dao.squads_multisig, + )?; + + Ok(()) + } + + pub fn handle(ctx: Context) -> Result<()> { + let Self { + dao, + rent_receiver: _, + squads_multisig, + squads_multisig_proposal, + enqueued_cancellation: _, + squads_multisig_program, + } = ctx.accounts; + + let dao_nonce = &dao.nonce.to_le_bytes(); + let dao_creator_key = &dao.dao_creator.as_ref(); + let dao_seeds = &[SEED_DAO, dao_creator_key, dao_nonce, &[dao.pda_bump]]; + let dao_signer = &[&dao_seeds[..]]; + + squads_multisig_program::cpi::proposal_cancel( + CpiContext::new_with_signer( + squads_multisig_program.to_account_info(), + squads_multisig_program::cpi::accounts::ProposalVote { + proposal: squads_multisig_proposal.to_account_info(), + multisig: squads_multisig.to_account_info(), + member: dao.to_account_info(), + }, + dao_signer, + ), + squads_multisig_program::ProposalVoteArgs { memo: None }, + )?; + + Ok(()) + } +} diff --git a/programs/futarchy/src/instructions/finalize_proposal.rs b/programs/futarchy/src/instructions/finalize_proposal.rs index e3b4ec4b..8c231c72 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,21 @@ impl FinalizeProposal<'_> { } } + // In case of a hostile liquidation, set the liquidator immediately. + // This write can only occur once, as a liquidated DAO can't start another proposal. + 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 @@ -302,7 +321,7 @@ impl FinalizeProposal<'_> { squads_proposal: squads_proposal.key(), squads_multisig: dao.squads_multisig, post_amm_state: dao.amm.clone(), - is_team_sponsored: proposal.is_team_sponsored, + is_team_sponsored: proposal.is_sponsored_by(dao.team_address), }); Ok(()) 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..c04e838d 100644 --- a/programs/futarchy/src/instructions/initialize_hostile_takeover_proposal.rs +++ b/programs/futarchy/src/instructions/initialize_hostile_takeover_proposal.rs @@ -19,12 +19,14 @@ impl InitializeHostileTakeoverProposal<'_> { pub fn validate(&self, args: &InitializeHostileTakeoverProposalArgs) -> Result<()> { self.typed_initialize_accounts.validate()?; + require_keys_neq!( + args.new_team_address, + self.typed_initialize_accounts.dao.team_address, + FutarchyError::InvalidTeamAddress + ); + if let SpendingLimitAction::Set(config) = &args.spending_limit_action { - require_gte!( - MAX_SPENDING_LIMIT_MEMBERS, - config.members.len(), - FutarchyError::TooManySpendingLimitMembers - ); + config.validate()?; } Ok(()) @@ -57,7 +59,6 @@ impl InitializeHostileTakeoverProposal<'_> { base_to_stake: None, team_sponsored_pass_threshold_bps: None, team_address: Some(args.new_team_address), - is_optimistic_governance_enabled: None, }, } .data(), 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_proposal.rs b/programs/futarchy/src/instructions/initialize_proposal.rs index d1a945b0..201e305d 100644 --- a/programs/futarchy/src/instructions/initialize_proposal.rs +++ b/programs/futarchy/src/instructions/initialize_proposal.rs @@ -106,7 +106,7 @@ impl InitializeProposal<'_> { fail_base_mint: base_vault.conditional_token_mints[0], pass_quote_mint: quote_vault.conditional_token_mints[1], fail_quote_mint: quote_vault.conditional_token_mints[0], - is_team_sponsored: false, + sponsored_by: None, pass_threshold_bps: params.pass_threshold_bps, council_can_block: params.council_can_block, action, 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/launch_proposal.rs b/programs/futarchy/src/instructions/launch_proposal.rs index 9f5e7a70..a63ad73e 100644 --- a/programs/futarchy/src/instructions/launch_proposal.rs +++ b/programs/futarchy/src/instructions/launch_proposal.rs @@ -48,8 +48,11 @@ impl<'info> LaunchProposal<'info> { require_keys_eq!(self.proposal.dao, self.dao.key()); + // A sponsorship only counts while the sponsor is still the DAO's team. + let is_team_sponsored = self.proposal.is_sponsored_by(self.dao.team_address); + // If the proposal is not team sponsored, check if sufficient stake has been accumulated - if !self.proposal.is_team_sponsored { + if !is_team_sponsored { if let ProposalState::Draft { amount_staked } = self.proposal.state { require_gte!( amount_staked, @@ -63,11 +66,8 @@ impl<'info> LaunchProposal<'info> { // drafts can't bypass them let params = self.proposal.action.params(); - if params.requires_team_sponsorship { - require!( - self.proposal.is_team_sponsored, - FutarchyError::ProposalNotTeamSponsored - ); + if params.team_sponsorship_policy == TeamSponsorshipPolicy::Required { + require!(is_team_sponsored, FutarchyError::ProposalNotTeamSponsored); } // A market that doesn't outlive its start delay reaches its nominal end diff --git a/programs/futarchy/src/instructions/mod.rs b/programs/futarchy/src/instructions/mod.rs index 89034d7d..0b8d7c8f 100644 --- a/programs/futarchy/src/instructions/mod.rs +++ b/programs/futarchy/src/instructions/mod.rs @@ -2,14 +2,15 @@ use super::*; pub mod admin_cancel_proposal; pub mod admin_enqueue_multisig_proposal_approval; +pub mod admin_enqueue_multisig_proposal_cancellation; 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; pub mod execute_multisig_proposal_approval; +pub mod execute_multisig_proposal_cancellation; pub mod finalize_proposal; pub mod initialize_buyback_token_proposal; pub mod initialize_dao; @@ -35,14 +36,15 @@ pub mod withdraw_liquidity; pub use admin_cancel_proposal::*; pub use admin_enqueue_multisig_proposal_approval::*; +pub use admin_enqueue_multisig_proposal_cancellation::*; 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::*; pub use execute_multisig_proposal_approval::*; +pub use execute_multisig_proposal_cancellation::*; pub use finalize_proposal::*; pub use initialize_buyback_token_proposal::*; pub use initialize_dao::*; diff --git a/programs/futarchy/src/instructions/resize_dao.rs b/programs/futarchy/src/instructions/resize_dao.rs index 5be9c25b..f97fc851 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,13 +79,14 @@ 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 - // optimistic spend is cleared rather than carried over. + // optimistic spend is cleared rather than carried over, and the + // governance flag is explicitly reset to false. optimistic_proposal: None, - is_optimistic_governance_enabled: old_dao_data.is_optimistic_governance_enabled, + is_optimistic_governance_enabled: false, liquidator: None, last_failed_takeover_at: 0, last_failed_liquidation_at: 0, @@ -87,4 +115,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..7e3d7350 100644 --- a/programs/futarchy/src/instructions/resize_proposal.rs +++ b/programs/futarchy/src/instructions/resize_proposal.rs @@ -25,10 +25,11 @@ 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; - // 369 bytes: 2 (i16 pass_threshold_bps) + 1 (bool council_can_block) + const AFTER_REALLOC_SIZE: usize = Proposal::MIGRATED_SIZE; + // 401 bytes: 32 (Option sponsored_by replacing the bool) + // + 2 (i16 pass_threshold_bps) + 1 (bool council_can_block) // + 366 (ProposalAction) - const BEFORE_REALLOC_SIZE: usize = AFTER_REALLOC_SIZE - 369; + const BEFORE_REALLOC_SIZE: usize = AFTER_REALLOC_SIZE - 401; if proposal.data_len() != BEFORE_REALLOC_SIZE { // already realloced @@ -41,12 +42,29 @@ 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 + 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) + }; + + // A legacy sponsorship was signed by the team of its day, which is + // still the DAO's team. Before this migration, we never had a team change. + let sponsored_by = if old_proposal_data.is_team_sponsored { + Some(dao.team_address) } else { - dao.pass_threshold_bps as i16 + None }; let new_proposal_data = Proposal { @@ -59,16 +77,16 @@ 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, fail_base_mint: old_proposal_data.fail_base_mint, fail_quote_mint: old_proposal_data.fail_quote_mint, - is_team_sponsored: old_proposal_data.is_team_sponsored, + sponsored_by, 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/sponsor_proposal.rs b/programs/futarchy/src/instructions/sponsor_proposal.rs index 725aa629..e9ccb9a0 100644 --- a/programs/futarchy/src/instructions/sponsor_proposal.rs +++ b/programs/futarchy/src/instructions/sponsor_proposal.rs @@ -12,14 +12,22 @@ pub struct SponsorProposal<'info> { impl SponsorProposal<'_> { pub fn validate(&self) -> Result<()> { + require!(self.dao.liquidator.is_none(), FutarchyError::DaoLiquidated); + require!( matches!(self.proposal.state, ProposalState::Draft { .. }), FutarchyError::ProposalNotInDraftState ); - require_neq!( - self.proposal.is_team_sponsored, - true, + require!( + self.proposal.action.params().team_sponsorship_policy + != TeamSponsorshipPolicy::Forbidden, + FutarchyError::TeamSponsorshipForbidden + ); + + // A previous team's sponsorship can be replaced, the current team's can't be repeated. + require!( + !self.proposal.is_sponsored_by(self.dao.team_address), FutarchyError::ProposalAlreadySponsored ); @@ -35,7 +43,7 @@ impl SponsorProposal<'_> { program: _, } = ctx.accounts; - proposal.is_team_sponsored = true; + proposal.sponsored_by = Some(team_address.key()); dao.seq_num += 1; 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/instructions/typed_initialize.rs b/programs/futarchy/src/instructions/typed_initialize.rs index cc87aafa..ff9fb74e 100644 --- a/programs/futarchy/src/instructions/typed_initialize.rs +++ b/programs/futarchy/src/instructions/typed_initialize.rs @@ -146,7 +146,7 @@ impl TypedInitializeAccounts<'_> { fail_base_mint: self.base_vault.conditional_token_mints[0], pass_quote_mint: self.quote_vault.conditional_token_mints[1], fail_quote_mint: self.quote_vault.conditional_token_mints[0], - is_team_sponsored: false, + sponsored_by: None, pass_threshold_bps: params.pass_threshold_bps, council_can_block: params.council_can_block, action, diff --git a/programs/futarchy/src/instructions/update_dao.rs b/programs/futarchy/src/instructions/update_dao.rs index 6a140e2c..4acacf08 100644 --- a/programs/futarchy/src/instructions/update_dao.rs +++ b/programs/futarchy/src/instructions/update_dao.rs @@ -12,7 +12,6 @@ pub struct UpdateDaoParams { pub base_to_stake: Option, pub team_sponsored_pass_threshold_bps: Option, pub team_address: Option, - pub is_optimistic_governance_enabled: Option, } #[derive(Accounts)] @@ -77,9 +76,7 @@ impl UpdateDao<'_> { .unwrap_or(dao.team_sponsored_pass_threshold_bps), team_address: dao_params.team_address.unwrap_or(dao.team_address), optimistic_proposal: dao.optimistic_proposal.clone(), - is_optimistic_governance_enabled: dao_params - .is_optimistic_governance_enabled - .unwrap_or(dao.is_optimistic_governance_enabled), + is_optimistic_governance_enabled: dao.is_optimistic_governance_enabled, liquidator: dao.liquidator, last_failed_takeover_at: dao.last_failed_takeover_at, last_failed_liquidation_at: dao.last_failed_liquidation_at, diff --git a/programs/futarchy/src/lib.rs b/programs/futarchy/src/lib.rs index 5a83362a..561fba53 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) } @@ -241,6 +235,21 @@ pub mod futarchy { ExecuteMultisigProposalApproval::handle(ctx) } + #[access_control(ctx.accounts.validate(&args))] + pub fn admin_enqueue_multisig_proposal_cancellation( + ctx: Context, + args: AdminEnqueueMultisigProposalCancellationArgs, + ) -> Result<()> { + AdminEnqueueMultisigProposalCancellation::handle(ctx, args) + } + + #[access_control(ctx.accounts.validate())] + pub fn execute_multisig_proposal_cancellation( + ctx: Context, + ) -> Result<()> { + ExecuteMultisigProposalCancellation::handle(ctx) + } + #[access_control(ctx.accounts.validate())] pub fn admin_execute_multisig_proposal<'c: 'info, 'info>( ctx: Context<'_, '_, 'c, 'info, AdminExecuteMultisigProposal<'info>>, diff --git a/programs/futarchy/src/state/dao.rs b/programs/futarchy/src/state/dao.rs index 41369278..ffe80a89 100644 --- a/programs/futarchy/src/state/dao.rs +++ b/programs/futarchy/src/state/dao.rs @@ -67,10 +67,13 @@ pub struct Dao { /// Can be negative to allow for team-sponsored proposals to pass by default. pub team_sponsored_pass_threshold_bps: i16, pub team_address: Pubkey, + /// Deprecated in favor of typed proposals pub optimistic_proposal: Option, + /// Deprecated in favor of typed proposals 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 +101,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/enqueued_multisig_proposal_cancellation.rs b/programs/futarchy/src/state/enqueued_multisig_proposal_cancellation.rs new file mode 100644 index 00000000..945f046c --- /dev/null +++ b/programs/futarchy/src/state/enqueued_multisig_proposal_cancellation.rs @@ -0,0 +1,11 @@ +use super::*; + +pub const SEED_ENQUEUED_MULTISIG_PROPOSAL_CANCELLATION: &[u8] = b"enqueued_cancellation"; + +#[account] +#[derive(InitSpace)] +pub struct EnqueuedMultisigProposalCancellation { + pub dao: Pubkey, + pub transaction_index: u64, + pub pda_bump: u8, +} diff --git a/programs/futarchy/src/state/mod.rs b/programs/futarchy/src/state/mod.rs index 6726cecf..95c0cb6b 100644 --- a/programs/futarchy/src/state/mod.rs +++ b/programs/futarchy/src/state/mod.rs @@ -1,6 +1,7 @@ pub mod amm_position; pub mod dao; pub mod enqueued_multisig_proposal_approval; +pub mod enqueued_multisig_proposal_cancellation; pub mod futarchy_amm; pub mod proposal; pub mod proposal_action; @@ -9,6 +10,7 @@ pub mod stake_account; pub use amm_position::*; pub use dao::*; pub use enqueued_multisig_proposal_approval::*; +pub use enqueued_multisig_proposal_cancellation::*; pub use futarchy_amm::*; pub use proposal::*; pub use proposal_action::*; diff --git a/programs/futarchy/src/state/proposal.rs b/programs/futarchy/src/state/proposal.rs index 9053d227..9e156220 100644 --- a/programs/futarchy/src/state/proposal.rs +++ b/programs/futarchy/src/state/proposal.rs @@ -39,7 +39,8 @@ pub struct Proposal { pub pass_quote_mint: Pubkey, pub fail_base_mint: Pubkey, pub fail_quote_mint: Pubkey, - pub is_team_sponsored: bool, + /// The team that last sponsored the proposal. `None` = never sponsored. + pub sponsored_by: Option, /// Snapshot of the kind's threshold at create. pub pass_threshold_bps: i16, /// Snapshot of the kind's blockable flag at create. @@ -48,6 +49,26 @@ pub struct Proposal { pub action: ProposalAction, } +impl Proposal { + /// Whether the sponsorship is by the DAO's current team. + pub fn is_sponsored_by(&self, team_address: Pubkey) -> bool { + self.sponsored_by == Some(team_address) + } + + /// 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..06757916 100644 --- a/programs/futarchy/src/state/proposal_action.rs +++ b/programs/futarchy/src/state/proposal_action.rs @@ -2,14 +2,24 @@ use super::*; pub const DAY_SECONDS: u32 = 24 * 60 * 60; +#[derive(AnchorSerialize, AnchorDeserialize, Debug, Clone, Copy, PartialEq, Eq, InitSpace)] +pub enum TeamSponsorshipPolicy { + /// Must be team-sponsored to launch. + Required, + /// May be team-sponsored. Sponsorship waives the stake. + Optional, + /// Cannot be team-sponsored. + Forbidden, +} + #[derive(AnchorSerialize, AnchorDeserialize, Debug, Clone, Copy, PartialEq, Eq, InitSpace)] pub struct InstructionParams { pub duration_seconds: u32, /// Signed: a negative threshold lets a proposal pass even when the pass /// price is below the fail price. pub pass_threshold_bps: i16, - /// Launch condition: the proposal must be team-sponsored to launch. - pub requires_team_sponsorship: bool, + /// Sponsorship policy + pub team_sponsorship_policy: TeamSponsorshipPolicy, pub council_can_block: bool, /// Cooldown checked at launch. 0 = none. pub cooldown_seconds: u32, @@ -32,6 +42,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 +65,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. @@ -73,7 +87,7 @@ impl ProposalAction { ProposalAction::LargeSpend { .. } => InstructionParams { duration_seconds: DAY_SECONDS * 3 / 2, // 1.5 days pass_threshold_bps: -1000, - requires_team_sponsorship: true, + team_sponsorship_policy: TeamSponsorshipPolicy::Required, council_can_block: true, cooldown_seconds: 0, twap_start_delay_seconds: DAY_SECONDS / 2, @@ -81,7 +95,7 @@ impl ProposalAction { ProposalAction::MintTokens { .. } => InstructionParams { duration_seconds: DAY_SECONDS * 5, pass_threshold_bps: 500, - requires_team_sponsorship: false, + team_sponsorship_policy: TeamSponsorshipPolicy::Optional, council_can_block: true, cooldown_seconds: 0, twap_start_delay_seconds: DAY_SECONDS, @@ -89,7 +103,7 @@ impl ProposalAction { ProposalAction::SpendingLimitChange { .. } => InstructionParams { duration_seconds: DAY_SECONDS * 5, pass_threshold_bps: 500, - requires_team_sponsorship: true, + team_sponsorship_policy: TeamSponsorshipPolicy::Required, council_can_block: true, cooldown_seconds: 0, twap_start_delay_seconds: DAY_SECONDS, @@ -97,7 +111,7 @@ impl ProposalAction { ProposalAction::ExecuteArbitrary => InstructionParams { duration_seconds: DAY_SECONDS * 10, pass_threshold_bps: 1000, - requires_team_sponsorship: false, + team_sponsorship_policy: TeamSponsorshipPolicy::Optional, council_can_block: true, cooldown_seconds: 0, twap_start_delay_seconds: DAY_SECONDS, @@ -105,7 +119,7 @@ impl ProposalAction { ProposalAction::HostileTakeover { .. } => InstructionParams { duration_seconds: DAY_SECONDS * 20, pass_threshold_bps: 1000, - requires_team_sponsorship: false, + team_sponsorship_policy: TeamSponsorshipPolicy::Forbidden, council_can_block: true, cooldown_seconds: DAY_SECONDS * 20, twap_start_delay_seconds: DAY_SECONDS, @@ -113,7 +127,7 @@ impl ProposalAction { ProposalAction::HostileLiquidate { .. } => InstructionParams { duration_seconds: DAY_SECONDS * 10, pass_threshold_bps: 2500, - requires_team_sponsorship: false, + team_sponsorship_policy: TeamSponsorshipPolicy::Forbidden, council_can_block: true, cooldown_seconds: DAY_SECONDS * 10, twap_start_delay_seconds: DAY_SECONDS, @@ -121,7 +135,7 @@ impl ProposalAction { ProposalAction::BuybackToken { .. } => InstructionParams { duration_seconds: DAY_SECONDS * 10, pass_threshold_bps: 1000, - requires_team_sponsorship: false, + team_sponsorship_policy: TeamSponsorshipPolicy::Optional, council_can_block: true, cooldown_seconds: DAY_SECONDS * 90, twap_start_delay_seconds: DAY_SECONDS, @@ -140,18 +154,56 @@ 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(), - 0, - FutarchyError::UnexpectedLaunchAccounts - ); + require_eq!(accounts.len(), 0, FutarchyError::UnexpectedLaunchAccounts); Ok(()) } } } } +/// 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 +272,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 0604fca8..f029b34b 100644 --- a/sdk/src/futarchy/v0.6/FutarchyClient.ts +++ b/sdk/src/futarchy/v0.6/FutarchyClient.ts @@ -57,6 +57,8 @@ import { } from "./types/v0.6.1-futarchy.js"; import { getDaoAddr, + getEnqueuedMultisigProposalApprovalAddr, + getEnqueuedMultisigProposalCancellationAddr, getProposalAddr, getProposalAddrV2, getProposalAddrsForTransactionIndex, @@ -1227,15 +1229,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; @@ -1258,7 +1260,7 @@ export class FutarchyClient { dao, baseMint, quoteMint, - liquidator, + liquidator = METADAO_MULTISIG_VAULT, transactionIndex, proposer = this.provider.publicKey, payer = this.provider.publicKey, @@ -1266,7 +1268,7 @@ export class FutarchyClient { dao: PublicKey; baseMint: PublicKey; quoteMint: PublicKey; - liquidator: PublicKey; + liquidator?: PublicKey; transactionIndex: bigint; proposer?: PublicKey; payer?: PublicKey; @@ -1292,7 +1294,7 @@ export class FutarchyClient { async initializeBuybackTokenProposal({ dao, quoteAmount, - quoteAmountPerCycle, + cycleCount, cycleFrequencySeconds, startDelaySeconds, minPrice = null, @@ -1300,7 +1302,7 @@ export class FutarchyClient { }: { dao: PublicKey; quoteAmount: BN; - quoteAmountPerCycle: BN; + cycleCount: number; cycleFrequencySeconds: number; startDelaySeconds: number; minPrice?: BN | null; @@ -1318,7 +1320,7 @@ export class FutarchyClient { baseMint: storedDao.baseMint, quoteMint: storedDao.quoteMint, quoteAmount, - quoteAmountPerCycle, + cycleCount, cycleFrequencySeconds, startDelaySeconds, minPrice, @@ -1333,7 +1335,7 @@ export class FutarchyClient { baseMint, quoteMint, quoteAmount, - quoteAmountPerCycle, + cycleCount, cycleFrequencySeconds, startDelaySeconds, minPrice = null, @@ -1346,7 +1348,7 @@ export class FutarchyClient { baseMint: PublicKey; quoteMint: PublicKey; quoteAmount: BN; - quoteAmountPerCycle: BN; + cycleCount: number; cycleFrequencySeconds: number; startDelaySeconds: number; minPrice?: BN | null; @@ -1358,7 +1360,7 @@ export class FutarchyClient { return this.futarchy.methods .initializeBuybackTokenProposal({ quoteAmount, - quoteAmountPerCycle, + cycleCount, cycleFrequencySeconds, startDelaySeconds, minPrice, @@ -1543,6 +1545,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, @@ -1705,6 +1723,118 @@ export class FutarchyClient { }); } + adminEnqueueMultisigProposalApprovalIx({ + dao, + transactionIndex, + admin = this.provider.publicKey, + }: { + dao: PublicKey; + transactionIndex: bigint; + admin?: PublicKey; + }) { + const { squadsMultisig, squadsProposal } = + getProposalAddrsForTransactionIndex({ dao, transactionIndex }); + const [enqueuedApproval] = getEnqueuedMultisigProposalApprovalAddr({ + dao, + transactionIndex, + }); + + return this.futarchy.methods + .adminEnqueueMultisigProposalApproval({ + transactionIndex: new BN(transactionIndex.toString()), + }) + .accounts({ + dao, + admin, + squadsMultisig, + squadsMultisigProposal: squadsProposal, + enqueuedApproval, + }); + } + + executeMultisigProposalApprovalIx({ + dao, + transactionIndex, + rentReceiver = this.provider.publicKey, + }: { + dao: PublicKey; + transactionIndex: bigint; + rentReceiver?: PublicKey; + }) { + const { squadsMultisig, squadsProposal } = + getProposalAddrsForTransactionIndex({ dao, transactionIndex }); + const [enqueuedApproval] = getEnqueuedMultisigProposalApprovalAddr({ + dao, + transactionIndex, + }); + + return this.futarchy.methods.executeMultisigProposalApproval().accounts({ + dao, + rentReceiver, + squadsMultisig, + squadsMultisigProposal: squadsProposal, + enqueuedApproval, + squadsMultisigProgram: SQUADS_PROGRAM_ID, + }); + } + + adminEnqueueMultisigProposalCancellationIx({ + dao, + transactionIndex, + admin = this.provider.publicKey, + }: { + dao: PublicKey; + transactionIndex: bigint; + admin?: PublicKey; + }) { + const { squadsMultisig, squadsProposal } = + getProposalAddrsForTransactionIndex({ dao, transactionIndex }); + const [enqueuedCancellation] = getEnqueuedMultisigProposalCancellationAddr({ + dao, + transactionIndex, + }); + + return this.futarchy.methods + .adminEnqueueMultisigProposalCancellation({ + transactionIndex: new BN(transactionIndex.toString()), + }) + .accounts({ + dao, + admin, + squadsMultisig, + squadsMultisigProposal: squadsProposal, + enqueuedCancellation, + }); + } + + executeMultisigProposalCancellationIx({ + dao, + transactionIndex, + rentReceiver = this.provider.publicKey, + }: { + dao: PublicKey; + transactionIndex: bigint; + rentReceiver?: PublicKey; + }) { + const { squadsMultisig, squadsProposal } = + getProposalAddrsForTransactionIndex({ dao, transactionIndex }); + const [enqueuedCancellation] = getEnqueuedMultisigProposalCancellationAddr({ + dao, + transactionIndex, + }); + + return this.futarchy.methods + .executeMultisigProposalCancellation() + .accounts({ + dao, + rentReceiver, + squadsMultisig, + squadsMultisigProposal: squadsProposal, + enqueuedCancellation, + squadsMultisigProgram: SQUADS_PROGRAM_ID, + }); + } + collectMeteoraDammFeesIx({ dao, baseMint, diff --git a/sdk/src/futarchy/v0.6/pda.ts b/sdk/src/futarchy/v0.6/pda.ts index b2796fb4..eb28f925 100644 --- a/sdk/src/futarchy/v0.6/pda.ts +++ b/sdk/src/futarchy/v0.6/pda.ts @@ -1,5 +1,6 @@ -import { BN, utils } from "@coral-xyz/anchor"; +import { utils } from "@coral-xyz/anchor"; import { PublicKey } from "@solana/web3.js"; +import BN from "bn.js"; import * as multisig from "@sqds/multisig"; import { FUTARCHY_V0_6_PROGRAM_ID } from "../../constants.js"; @@ -81,6 +82,44 @@ export const getProposalAddrsForTransactionIndex = ({ }; }; +export const getEnqueuedMultisigProposalApprovalAddr = ({ + dao, + transactionIndex, + programId = FUTARCHY_V0_6_PROGRAM_ID, +}: { + dao: PublicKey; + transactionIndex: bigint; + programId?: PublicKey; +}): [PublicKey, number] => { + return PublicKey.findProgramAddressSync( + [ + Buffer.from("enqueued_approval"), + dao.toBuffer(), + new BN(transactionIndex.toString()).toArrayLike(Buffer, "le", 8), + ], + programId, + ); +}; + +export const getEnqueuedMultisigProposalCancellationAddr = ({ + dao, + transactionIndex, + programId = FUTARCHY_V0_6_PROGRAM_ID, +}: { + dao: PublicKey; + transactionIndex: bigint; + programId?: PublicKey; +}): [PublicKey, number] => { + return PublicKey.findProgramAddressSync( + [ + Buffer.from("enqueued_cancellation"), + dao.toBuffer(), + new BN(transactionIndex.toString()).toArrayLike(Buffer, "le", 8), + ], + programId, + ); +}; + // The Squads spending-limit PDA — `create_key` is always the DAO, so the // address is derivable from the DAO alone. export const getSpendingLimitAddr = ({ diff --git a/sdk/src/futarchy/v0.6/types/futarchy.ts b/sdk/src/futarchy/v0.6/types/futarchy.ts index 3ffccf28..668d3109 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; @@ -2077,6 +2013,85 @@ export type Futarchy = { ]; args: []; }, + { + name: "adminEnqueueMultisigProposalCancellation"; + accounts: [ + { + name: "dao"; + isMut: false; + isSigner: false; + }, + { + name: "admin"; + isMut: true; + isSigner: true; + }, + { + name: "squadsMultisig"; + isMut: false; + isSigner: false; + }, + { + name: "squadsMultisigProposal"; + isMut: false; + isSigner: false; + }, + { + name: "enqueuedCancellation"; + isMut: true; + isSigner: false; + }, + { + name: "systemProgram"; + isMut: false; + isSigner: false; + }, + ]; + args: [ + { + name: "args"; + type: { + defined: "AdminEnqueueMultisigProposalCancellationArgs"; + }; + }, + ]; + }, + { + name: "executeMultisigProposalCancellation"; + accounts: [ + { + name: "dao"; + isMut: true; + isSigner: false; + }, + { + name: "rentReceiver"; + isMut: true; + isSigner: true; + }, + { + name: "squadsMultisig"; + isMut: true; + isSigner: false; + }, + { + name: "squadsMultisigProposal"; + isMut: true; + isSigner: false; + }, + { + name: "enqueuedCancellation"; + isMut: true; + isSigner: false; + }, + { + name: "squadsMultisigProgram"; + isMut: false; + isSigner: false; + }, + ]; + args: []; + }, { name: "adminExecuteMultisigProposal"; accounts: [ @@ -2480,6 +2495,7 @@ export type Futarchy = { }, { name: "optimisticProposal"; + docs: ["Deprecated in favor of typed proposals"]; type: { option: { defined: "OptimisticProposal"; @@ -2488,13 +2504,15 @@ export type Futarchy = { }, { name: "isOptimisticGovernanceEnabled"; + docs: ["Deprecated in favor of typed proposals"]; type: "bool"; }, { name: "liquidator"; docs: [ "`Some` means the DAO has been liquidated, and holds who runs the estate.", - "Set once by `apply_liquidation`, never cleared.", + "Set once by `finalize_proposal` the moment a hostile liquidation", + "passes, never cleared.", ]; type: { option: "publicKey"; @@ -2696,6 +2714,26 @@ export type Futarchy = { ]; }; }, + { + name: "enqueuedMultisigProposalCancellation"; + type: { + kind: "struct"; + fields: [ + { + name: "dao"; + type: "publicKey"; + }, + { + name: "transactionIndex"; + type: "u64"; + }, + { + name: "pdaBump"; + type: "u8"; + }, + ]; + }; + }, { name: "proposal"; type: { @@ -2764,8 +2802,13 @@ export type Futarchy = { type: "publicKey"; }, { - name: "isTeamSponsored"; - type: "bool"; + name: "sponsoredBy"; + docs: [ + "The team that last sponsored the proposal. `None` = never sponsored.", + ]; + type: { + option: "publicKey"; + }; }, { name: "passThresholdBps"; @@ -2919,6 +2962,18 @@ export type Futarchy = { ]; }; }, + { + name: "AdminEnqueueMultisigProposalCancellationArgs"; + type: { + kind: "struct"; + fields: [ + { + name: "transactionIndex"; + type: "u64"; + }, + ]; + }; + }, { name: "AdminUpdateProposalParamsArgs"; type: { @@ -2977,8 +3032,8 @@ export type Futarchy = { type: "u64"; }, { - name: "quoteAmountPerCycle"; - type: "u64"; + name: "cycleCount"; + type: "u32"; }, { name: "cycleFrequencySeconds"; @@ -3299,12 +3354,6 @@ export type Futarchy = { option: "publicKey"; }; }, - { - name: "isOptimisticGovernanceEnabled"; - type: { - option: "bool"; - }; - }, ]; }; }, @@ -3525,11 +3574,11 @@ export type Futarchy = { type: "i16"; }, { - name: "requiresTeamSponsorship"; - docs: [ - "Launch condition: the proposal must be team-sponsored to launch.", - ]; - type: "bool"; + name: "teamSponsorshipPolicy"; + docs: ["Sponsorship policy"]; + type: { + defined: "TeamSponsorshipPolicy"; + }; }, { name: "councilCanBlock"; @@ -3635,6 +3684,23 @@ export type Futarchy = { ]; }; }, + { + name: "TeamSponsorshipPolicy"; + type: { + kind: "enum"; + variants: [ + { + name: "Required"; + }, + { + name: "Optional"; + }, + { + name: "Forbidden"; + }, + ]; + }; + }, { name: "SpendingLimitAction"; docs: ["What a hostile takeover declares for the spending limit."]; @@ -3675,6 +3741,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 +3813,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 +4865,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: [ { @@ -5093,69 +5124,114 @@ export type Futarchy = { }, { code: 6051; - name: "AlreadyLiquidated"; - msg: "This DAO has already been liquidated"; - }, - { - code: 6052; name: "TooManySpendingLimitMembers"; msg: "A spending limit can have at most 10 members"; }, { - code: 6053; + code: 6052; name: "InvalidLiquidator"; msg: "Invalid liquidator"; }, { - code: 6054; + code: 6053; name: "InvalidProposalPassThreshold"; msg: "Pass threshold must be between -99.99% and 99.99%"; }, { - code: 6055; + code: 6054; name: "EmptyProposalParamsUpdate"; msg: "A proposal params update must set at least one field"; }, { - code: 6056; + code: 6055; name: "BuybackCapExceeded"; msg: "Buyback amount exceeds 25% of the treasury"; }, { - code: 6057; + code: 6056; 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; + code: 6057; name: "InvalidBuybackCycleFrequency"; msg: "Cycle frequency must be between 60 seconds and 1 year"; }, { - code: 6059; + code: 6058; name: "InvalidBuybackStartDelay"; msg: "Start delay must be at most 30 days"; }, { - code: 6060; + code: 6059; name: "InvalidBuybackPriceBand"; msg: "min_price must be no greater than max_price"; }, { - code: 6061; + code: 6060; name: "InvalidTreasuryAccount"; msg: "A treasury account is neither a vault-owned quote account nor the treasury's AMM position"; }, { - code: 6062; + code: 6061; name: "TreasuryAccountsNotSorted"; msg: "Treasury accounts must be in strictly ascending key order"; }, { - code: 6063; + code: 6062; name: "UnexpectedLaunchAccounts"; msg: "This proposal kind's launch takes no extra accounts"; }, + { + code: 6063; + name: "InvalidSpendingLimitAccount"; + msg: "Spending limit account is not the canonical spending-limit PDA"; + }, + { + code: 6064; + name: "StaleTeamAddress"; + msg: "The DAO's team has changed since this draft was created"; + }, + { + code: 6065; + name: "AccountNotMigrated"; + msg: "Account is not migrated to latest layout"; + }, + { + code: 6066; + name: "InvalidSpendingLimitAmount"; + msg: "A spending limit's monthly amount must be non-zero"; + }, + { + code: 6067; + name: "EmptySpendingLimitMembers"; + msg: "A spending limit must have at least one member"; + }, + { + code: 6068; + name: "DuplicateSpendingLimitMember"; + msg: "A spending limit's members must be unique"; + }, + { + code: 6069; + name: "InvalidBuybackCycleCount"; + msg: "A buyback must run at least two cycles"; + }, + { + code: 6070; + name: "InvalidTeamAddress"; + msg: "Invalid team address"; + }, + { + code: 6071; + name: "TeamSponsorshipForbidden"; + msg: "This proposal kind cannot be team-sponsored"; + }, + { + code: 6072; + name: "SquadsProposalNotApproved"; + msg: "Squads proposal must be in Approved status to be cancelled"; + }, ]; }; @@ -6426,110 +6502,37 @@ export const IDL: Futarchy = { }, { name: "syncSpendingLimit", - accounts: [ - { - name: "dao", - isMut: true, - isSigner: false, - }, - { - name: "squadsMultisig", - isMut: true, - isSigner: false, - }, - { - name: "spendingLimit", - isMut: true, - isSigner: false, - }, - { - name: "rentPayer", - isMut: true, - isSigner: true, - docs: [ - "Pays rent when the limit is recreated and receives freed rent when it is removed.", - ], - }, - { - name: "squadsProgram", - isMut: false, - isSigner: false, - }, - { - name: "systemProgram", - isMut: false, - isSigner: false, - }, - { - name: "eventAuthority", - isMut: false, - isSigner: false, - }, - { - name: "program", - isMut: false, - isSigner: false, - }, - ], - args: [], - }, - { - name: "applyLiquidation", - accounts: [ - { - name: "proposal", - isMut: false, - isSigner: false, - docs: [ - "The linked liquidation proposal, baked into the payload at create.", - ], - }, - { - name: "dao", - isMut: true, - isSigner: false, - }, - { - name: "squadsMultisigVault", - isMut: false, - isSigner: true, - docs: [ - "The vault's signature is only obtainable through a Squads vault", - "transaction execution, so the caller is a passed proposal's payload.", - ], - }, + accounts: [ { - name: "ammPosition", + name: "dao", isMut: true, isSigner: false, - docs: [ - "seeds, but whether the account exists at execution is unknowable at", - "create, so it is parsed manually — a passed liquidation must never", - "brick on treasury shape.", - ], }, { - name: "ammBaseVault", + name: "squadsMultisig", isMut: true, isSigner: false, }, { - name: "ammQuoteVault", + name: "spendingLimit", isMut: true, isSigner: false, }, { - name: "vaultBaseAccount", + name: "rentPayer", isMut: true, - isSigner: false, + isSigner: true, + docs: [ + "Pays rent when the limit is recreated and receives freed rent when it is removed.", + ], }, { - name: "vaultQuoteAccount", - isMut: true, + name: "squadsProgram", + isMut: false, isSigner: false, }, { - name: "tokenProgram", + name: "systemProgram", isMut: false, isSigner: false, }, @@ -6554,6 +6557,15 @@ export const IDL: Futarchy = { isMut: true, isSigner: false, }, + { + name: "spendingLimit", + isMut: false, + isSigner: false, + docs: [ + "spending-limit PDA (`create_key` is always the DAO); read-only and may", + "not exist", + ], + }, { name: "payer", isMut: true, @@ -7238,6 +7250,85 @@ export const IDL: Futarchy = { ], args: [], }, + { + name: "adminEnqueueMultisigProposalCancellation", + accounts: [ + { + name: "dao", + isMut: false, + isSigner: false, + }, + { + name: "admin", + isMut: true, + isSigner: true, + }, + { + name: "squadsMultisig", + isMut: false, + isSigner: false, + }, + { + name: "squadsMultisigProposal", + isMut: false, + isSigner: false, + }, + { + name: "enqueuedCancellation", + isMut: true, + isSigner: false, + }, + { + name: "systemProgram", + isMut: false, + isSigner: false, + }, + ], + args: [ + { + name: "args", + type: { + defined: "AdminEnqueueMultisigProposalCancellationArgs", + }, + }, + ], + }, + { + name: "executeMultisigProposalCancellation", + accounts: [ + { + name: "dao", + isMut: true, + isSigner: false, + }, + { + name: "rentReceiver", + isMut: true, + isSigner: true, + }, + { + name: "squadsMultisig", + isMut: true, + isSigner: false, + }, + { + name: "squadsMultisigProposal", + isMut: true, + isSigner: false, + }, + { + name: "enqueuedCancellation", + isMut: true, + isSigner: false, + }, + { + name: "squadsMultisigProgram", + isMut: false, + isSigner: false, + }, + ], + args: [], + }, { name: "adminExecuteMultisigProposal", accounts: [ @@ -7641,6 +7732,7 @@ export const IDL: Futarchy = { }, { name: "optimisticProposal", + docs: ["Deprecated in favor of typed proposals"], type: { option: { defined: "OptimisticProposal", @@ -7649,13 +7741,15 @@ export const IDL: Futarchy = { }, { name: "isOptimisticGovernanceEnabled", + docs: ["Deprecated in favor of typed proposals"], type: "bool", }, { name: "liquidator", docs: [ "`Some` means the DAO has been liquidated, and holds who runs the estate.", - "Set once by `apply_liquidation`, never cleared.", + "Set once by `finalize_proposal` the moment a hostile liquidation", + "passes, never cleared.", ], type: { option: "publicKey", @@ -7857,6 +7951,26 @@ export const IDL: Futarchy = { ], }, }, + { + name: "enqueuedMultisigProposalCancellation", + type: { + kind: "struct", + fields: [ + { + name: "dao", + type: "publicKey", + }, + { + name: "transactionIndex", + type: "u64", + }, + { + name: "pdaBump", + type: "u8", + }, + ], + }, + }, { name: "proposal", type: { @@ -7925,8 +8039,13 @@ export const IDL: Futarchy = { type: "publicKey", }, { - name: "isTeamSponsored", - type: "bool", + name: "sponsoredBy", + docs: [ + "The team that last sponsored the proposal. `None` = never sponsored.", + ], + type: { + option: "publicKey", + }, }, { name: "passThresholdBps", @@ -8080,6 +8199,18 @@ export const IDL: Futarchy = { ], }, }, + { + name: "AdminEnqueueMultisigProposalCancellationArgs", + type: { + kind: "struct", + fields: [ + { + name: "transactionIndex", + type: "u64", + }, + ], + }, + }, { name: "AdminUpdateProposalParamsArgs", type: { @@ -8138,8 +8269,8 @@ export const IDL: Futarchy = { type: "u64", }, { - name: "quoteAmountPerCycle", - type: "u64", + name: "cycleCount", + type: "u32", }, { name: "cycleFrequencySeconds", @@ -8460,12 +8591,6 @@ export const IDL: Futarchy = { option: "publicKey", }, }, - { - name: "isOptimisticGovernanceEnabled", - type: { - option: "bool", - }, - }, ], }, }, @@ -8686,11 +8811,11 @@ export const IDL: Futarchy = { type: "i16", }, { - name: "requiresTeamSponsorship", - docs: [ - "Launch condition: the proposal must be team-sponsored to launch.", - ], - type: "bool", + name: "teamSponsorshipPolicy", + docs: ["Sponsorship policy"], + type: { + defined: "TeamSponsorshipPolicy", + }, }, { name: "councilCanBlock", @@ -8796,6 +8921,23 @@ export const IDL: Futarchy = { ], }, }, + { + name: "TeamSponsorshipPolicy", + type: { + kind: "enum", + variants: [ + { + name: "Required", + }, + { + name: "Optional", + }, + { + name: "Forbidden", + }, + ], + }, + }, { name: "SpendingLimitAction", docs: ["What a hostile takeover declares for the spending limit."], @@ -8836,6 +8978,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 +9050,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 +10102,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: [ { @@ -10254,68 +10361,113 @@ export const IDL: Futarchy = { }, { code: 6051, - name: "AlreadyLiquidated", - msg: "This DAO has already been liquidated", - }, - { - code: 6052, name: "TooManySpendingLimitMembers", msg: "A spending limit can have at most 10 members", }, { - code: 6053, + code: 6052, name: "InvalidLiquidator", msg: "Invalid liquidator", }, { - code: 6054, + code: 6053, name: "InvalidProposalPassThreshold", msg: "Pass threshold must be between -99.99% and 99.99%", }, { - code: 6055, + code: 6054, name: "EmptyProposalParamsUpdate", msg: "A proposal params update must set at least one field", }, { - code: 6056, + code: 6055, name: "BuybackCapExceeded", msg: "Buyback amount exceeds 25% of the treasury", }, { - code: 6057, + code: 6056, 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, + code: 6057, name: "InvalidBuybackCycleFrequency", msg: "Cycle frequency must be between 60 seconds and 1 year", }, { - code: 6059, + code: 6058, name: "InvalidBuybackStartDelay", msg: "Start delay must be at most 30 days", }, { - code: 6060, + code: 6059, name: "InvalidBuybackPriceBand", msg: "min_price must be no greater than max_price", }, { - code: 6061, + code: 6060, name: "InvalidTreasuryAccount", msg: "A treasury account is neither a vault-owned quote account nor the treasury's AMM position", }, { - code: 6062, + code: 6061, name: "TreasuryAccountsNotSorted", msg: "Treasury accounts must be in strictly ascending key order", }, { - code: 6063, + code: 6062, name: "UnexpectedLaunchAccounts", msg: "This proposal kind's launch takes no extra accounts", }, + { + code: 6063, + name: "InvalidSpendingLimitAccount", + msg: "Spending limit account is not the canonical spending-limit PDA", + }, + { + code: 6064, + name: "StaleTeamAddress", + msg: "The DAO's team has changed since this draft was created", + }, + { + code: 6065, + name: "AccountNotMigrated", + msg: "Account is not migrated to latest layout", + }, + { + code: 6066, + name: "InvalidSpendingLimitAmount", + msg: "A spending limit's monthly amount must be non-zero", + }, + { + code: 6067, + name: "EmptySpendingLimitMembers", + msg: "A spending limit must have at least one member", + }, + { + code: 6068, + name: "DuplicateSpendingLimitMember", + msg: "A spending limit's members must be unique", + }, + { + code: 6069, + name: "InvalidBuybackCycleCount", + msg: "A buyback must run at least two cycles", + }, + { + code: 6070, + name: "InvalidTeamAddress", + msg: "Invalid team address", + }, + { + code: 6071, + name: "TeamSponsorshipForbidden", + msg: "This proposal kind cannot be team-sponsored", + }, + { + code: 6072, + name: "SquadsProposalNotApproved", + msg: "Squads proposal must be in Approved status to be cancelled", + }, ], }; diff --git a/sdk/src/futarchy/v0.6/types/index.ts b/sdk/src/futarchy/v0.6/types/index.ts index 62a6691a..d6153fbb 100644 --- a/sdk/src/futarchy/v0.6/types/index.ts +++ b/sdk/src/futarchy/v0.6/types/index.ts @@ -33,6 +33,8 @@ export type InitializeHostileTakeoverProposalArgs = export type InitializeBuybackTokenProposalArgs = IdlTypes["InitializeBuybackTokenProposalArgs"]; export type InstructionParams = IdlTypes["InstructionParams"]; +export type TeamSponsorshipPolicy = + IdlTypes["TeamSponsorshipPolicy"]; export type SpendingLimitAction = IdlTypes["SpendingLimitAction"]; export type ProposalAction = IdlTypes["ProposalAction"]; diff --git a/tests/futarchy/integration/cancelApprovedPayloadAfterLiquidation.test.ts b/tests/futarchy/integration/cancelApprovedPayloadAfterLiquidation.test.ts new file mode 100644 index 00000000..2358e704 --- /dev/null +++ b/tests/futarchy/integration/cancelApprovedPayloadAfterLiquidation.test.ts @@ -0,0 +1,266 @@ +import { getDaoAddr, PriceMath } from "@metadaoproject/programs"; +import { + ComputeBudgetProgram, + Keypair, + SystemProgram, + Transaction, +} from "@solana/web3.js"; +import BN from "bn.js"; +import { assert } from "chai"; +import * as multisig from "@sqds/multisig"; +import { + executeVaultTransaction, + expectError, + passProposal, +} from "../../utils.js"; + +const THOUSAND_BUCK_PRICE = PriceMath.getAmmPrice(1000, 6, 6); + +// A payload the team had approved before a liquidation stays executable +// afterwards; the cancellation set is how the liquidator kills it. +export default function suite() { + it("lets the liquidator cancel a large spend approved before the liquidation", async function () { + const META = await this.createMint(this.payer.publicKey, 6); + const USDC = await this.createMint(this.payer.publicKey, 6); + + await this.createTokenAccount(META, this.payer.publicKey); + await this.createTokenAccount(USDC, this.payer.publicKey); + + await this.mintTo( + META, + this.payer.publicKey, + this.payer, + 1_000 * 1_000_000, + ); + await this.mintTo( + USDC, + this.payer.publicKey, + this.payer, + 500_000 * 1_000_000, + ); + + const team = Keypair.generate(); + const nonce = new BN(Math.floor(Math.random() * 1000000)); + + await this.futarchy + .initializeDaoIx({ + baseMint: META, + quoteMint: USDC, + params: { + secondsPerProposal: 60 * 60 * 24 * 3, + twapStartDelaySeconds: 60 * 60 * 24, + twapInitialObservation: THOUSAND_BUCK_PRICE, + // 10% per update: TWAPs converge to actual prices fast enough that + // a pumped pass market clears HostileLiquidate's +25% + twapMaxObservationChangePerUpdate: THOUSAND_BUCK_PRICE.divn(10), + minQuoteFutarchicLiquidity: new BN(10_000), + minBaseFutarchicLiquidity: new BN(10_000), + passThresholdBps: 300, + nonce, + initialSpendingLimit: { + amountPerMonth: new BN(1_000_000_000), // 1,000 USDC + members: [this.payer.publicKey], + }, + baseToStake: new BN(0), + teamSponsoredPassThresholdBps: 300, + teamAddress: team.publicKey, + }, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), + ]) + .rpc(); + + const [dao] = getDaoAddr({ nonce, daoCreator: this.payer.publicKey }); + const vault = (await this.futarchy.getDao(dao)).squadsMultisigVault; + + await this.futarchy + .provideLiquidityIx({ + dao, + baseMint: META, + quoteMint: USDC, + quoteAmount: new BN(100_000 * 1_000_000), // 100,000 USDC + maxBaseAmount: new BN(100 * 1_000_000), // 100 META + minLiquidity: new BN(0), + positionAuthority: this.payer.publicKey, + liquidityProvider: this.payer.publicKey, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), + ]) + .rpc(); + + // The team's large spend passes, and nobody executes it + const amount = new BN(3_000_000_000); // 3,000 USDC + await this.createTokenAccount(USDC, team.publicKey); + + const { + proposal: spendProposal, + squadsProposal: spendSquadsProposal, + squadsTransaction: spendSquadsTransaction, + } = await this.futarchy.initializeLargeSpendProposal({ dao, amount }); + + await this.futarchy + .sponsorProposalIx({ + proposal: spendProposal, + dao, + teamAddress: team.publicKey, + }) + .signers([team]) + .rpc(); + + await this.futarchy + .launchProposalIx({ + proposal: spendProposal, + dao, + baseMint: META, + quoteMint: USDC, + squadsProposal: spendSquadsProposal, + }) + .rpc(); + + // Uncontested: one swap after the kind's 12-hour TWAP delay leaves the + // TWAPs equal, which clears the sponsored -10% threshold + await this.advanceBySeconds(60 * 60 * 12 + 60); + await this.futarchy + .spotSwapIx({ + dao, + baseMint: META, + quoteMint: USDC, + swapType: "buy", + inputAmount: new BN(1_000), + }) + .rpc(); + await this.advanceBySeconds(129_600); + await this.futarchy.finalizeProposal(spendProposal); + + let storedSpend = await multisig.accounts.Proposal.fromAccountAddress( + this.squadsConnection, + spendSquadsProposal, + ); + assert.isTrue( + multisig.generated.isProposalStatusApproved(storedSpend.status), + ); + + // Fund the treasury ATA the payload pulls from, so only the cancellation + // stands between the old team and the money + await this.createTokenAccount(USDC, vault); + await this.mintTo(USDC, vault, this.payer, amount.toNumber()); + + // A hostile liquidation passes + const liquidator = Keypair.generate(); + const { + proposal: liquidateProposal, + squadsProposal: liquidateSquadsProposal, + } = await this.futarchy.initializeHostileLiquidateProposal({ + dao, + liquidator: liquidator.publicKey, + }); + + await this.futarchy + .launchProposalIx({ + proposal: liquidateProposal, + dao, + baseMint: META, + quoteMint: USDC, + squadsProposal: liquidateSquadsProposal, + }) + .rpc(); + + await passProposal(this, { + dao, + proposal: liquidateProposal, + baseMint: META, + quoteMint: USDC, + cranks: 50, + }); + + const storedDao = await this.futarchy.getDao(dao); + assert.ok(storedDao.liquidator.equals(liquidator.publicKey)); + + // The spend is still a standing mandate + storedSpend = await multisig.accounts.Proposal.fromAccountAddress( + this.squadsConnection, + spendSquadsProposal, + ); + assert.isTrue( + multisig.generated.isProposalStatusApproved(storedSpend.status), + ); + + // The liquidator pays rent for the enqueued cancellation 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 spend was the DAO's first Squads transaction + const spendTransactionIndex = 1n; + + const callbacks = expectError( + "InvalidLiquidator", + "enqueue by a non-liquidator should fail on a liquidated DAO", + ); + + await this.futarchy + .adminEnqueueMultisigProposalCancellationIx({ + dao, + transactionIndex: spendTransactionIndex, + }) + .rpc() + .then(callbacks[0], callbacks[1]); + + // The liquidator enqueues; anyone runs the cancel + await this.futarchy + .adminEnqueueMultisigProposalCancellationIx({ + dao, + transactionIndex: spendTransactionIndex, + admin: liquidator.publicKey, + }) + .signers([liquidator]) + .rpc(); + + await this.futarchy + .executeMultisigProposalCancellationIx({ + dao, + transactionIndex: spendTransactionIndex, + }) + .rpc(); + + storedSpend = await multisig.accounts.Proposal.fromAccountAddress( + this.squadsConnection, + spendSquadsProposal, + ); + assert.isTrue( + multisig.generated.isProposalStatusCancelled(storedSpend.status), + ); + assert.deepEqual( + storedSpend.cancelled.map((k) => k.toBase58()), + [dao.toBase58()], + ); + + // The payload can no longer move the estate + try { + await executeVaultTransaction(this, dao, spendSquadsTransaction); + assert.fail("Should have thrown error"); + } catch (e) { + // Squads' InvalidProposalStatus (0x1778 = 6008) + assert.isTrue(e.toString().includes("0x1778"), `unexpected error: ${e}`); + } + + assert.equal( + (await this.getTokenBalance(USDC, vault)).toString(), + amount.toString(), + ); + assert.equal( + (await this.getTokenBalance(USDC, team.publicKey)).toString(), + "0", + ); + }); +} 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/futarchyAmm.test.ts b/tests/futarchy/integration/futarchyAmm.test.ts index b74c7ede..6d6062aa 100644 --- a/tests/futarchy/integration/futarchyAmm.test.ts +++ b/tests/futarchy/integration/futarchyAmm.test.ts @@ -81,7 +81,6 @@ export default function suite() { twapStartDelaySeconds: null, teamSponsoredPassThresholdBps: null, teamAddress: null, - isOptimisticGovernanceEnabled: null, }, }) .instruction(); diff --git a/tests/futarchy/integration/gatedLiquidationUnwind.test.ts b/tests/futarchy/integration/gatedLiquidationUnwind.test.ts new file mode 100644 index 00000000..bc01828b --- /dev/null +++ b/tests/futarchy/integration/gatedLiquidationUnwind.test.ts @@ -0,0 +1,501 @@ +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); + +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; + + // 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 { squadsTransaction: estateSquadsTransaction } = + getProposalAddrsForTransactionIndex({ dao, transactionIndex: 2n }); + + await this.futarchy + .adminEnqueueMultisigProposalApprovalIx({ + dao, + transactionIndex: 2n, + admin: liquidator.publicKey, + }) + .signers([liquidator]) + .rpc(); + + await this.futarchy + .executeMultisigProposalApprovalIx({ dao, transactionIndex: 2n }) + .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 { squadsTransaction: estateSquadsTransaction } = + getProposalAddrsForTransactionIndex({ dao, transactionIndex: 2n }); + + await this.futarchy + .adminEnqueueMultisigProposalApprovalIx({ + dao, + transactionIndex: 2n, + admin: liquidator.publicKey, + }) + .signers([liquidator]) + .rpc(); + + await this.futarchy + .executeMultisigProposalApprovalIx({ dao, transactionIndex: 2n }) + .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..0e46e183 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,16 @@ 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); @@ -93,9 +87,8 @@ export default function suite() { const storedDaoBefore = await this.futarchy.getDao(dao); const vault = storedDaoBefore.squadsMultisigVault; - const multisigPda = storedDaoBefore.squadsMultisig; - // The baked apply_liquidation payload requires the vault's ATAs to exist + // The unwind destination: the vault's ATAs await this.createTokenAccount(META, vault); await this.createTokenAccount(USDC, vault); @@ -116,7 +109,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 +144,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 +152,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 +192,100 @@ 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); - // 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, + const { squadsTransaction: estateSquadsTransaction } = + getProposalAddrsForTransactionIndex({ dao, transactionIndex }); + + await this.futarchy + .adminEnqueueMultisigProposalApprovalIx({ + dao, + transactionIndex, + admin: liquidator.publicKey, + }) + .signers([liquidator]) + .rpc(); + + await this.futarchy + .executeMultisigProposalApprovalIx({ dao, transactionIndex }) + .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 +303,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 +341,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..46475324 100644 --- a/tests/futarchy/main.test.ts +++ b/tests/futarchy/main.test.ts @@ -1,6 +1,8 @@ import futarchyAmm from "./integration/futarchyAmm.test.js"; import takeoverEndToEnd from "./integration/takeoverEndToEnd.test.js"; import liquidationEndToEnd from "./integration/liquidationEndToEnd.test.js"; +import cancelApprovedPayloadAfterLiquidation from "./integration/cancelApprovedPayloadAfterLiquidation.test.js"; +import gatedLiquidationUnwind from "./integration/gatedLiquidationUnwind.test.js"; import largeSpendEndToEnd from "./integration/largeSpendEndToEnd.test.js"; import cooldownRoundTrip from "./integration/cooldownRoundTrip.test.js"; @@ -13,11 +15,11 @@ import initializeHostileTakeoverProposal from "./unit/initializeHostileTakeoverP import initializeHostileLiquidateProposal from "./unit/initializeHostileLiquidateProposal.test.js"; import initializeBuybackTokenProposal from "./unit/initializeBuybackTokenProposal.test.js"; import launchProposal from "./unit/launchProposal.test.js"; +import sponsorProposal from "./unit/sponsorProposal.test.js"; import finalizeProposal from "./unit/finalizeProposal.test.js"; import updateDao from "./unit/updateDao.test.js"; import setSpendingLimit from "./unit/setSpendingLimit.test.js"; import syncSpendingLimit from "./unit/syncSpendingLimit.test.js"; -import applyLiquidation from "./unit/applyLiquidation.test.js"; import liquidatorPath from "./unit/liquidatorPath.test.js"; import liquidatedGuards from "./unit/liquidatedGuards.test.js"; @@ -30,6 +32,8 @@ import collectMeteoraDammFees from "./unit/collectMeteoraDammFees.test.js"; import adminEnqueueMultisigProposalApproval from "./unit/adminEnqueueMultisigProposalApproval.test.js"; import executeMultisigProposalApproval from "./unit/executeMultisigProposalApproval.test.js"; +import adminEnqueueMultisigProposalCancellation from "./unit/adminEnqueueMultisigProposalCancellation.test.js"; +import executeMultisigProposalCancellation from "./unit/executeMultisigProposalCancellation.test.js"; import adminExecuteMultisigProposal from "./unit/adminExecuteMultisigProposal.test.js"; import adminCancelProposal from "./unit/adminCancelProposal.test.js"; import adminRemoveProposal from "./unit/adminRemoveProposal.test.js"; @@ -92,11 +96,11 @@ export default function suite() { initializeBuybackTokenProposal, ); describe("#launch_proposal", launchProposal); + describe("#sponsor_proposal", sponsorProposal); describe("#finalize_proposal", finalizeProposal); describe("#update_dao", updateDao); describe("#set_spending_limit", setSpendingLimit); describe("#sync_spending_limit", syncSpendingLimit); - describe("#apply_liquidation", applyLiquidation); describe("liquidator path", liquidatorPath); describe("liquidated guards", liquidatedGuards); @@ -115,6 +119,14 @@ export default function suite() { "#execute_multisig_proposal_approval", executeMultisigProposalApproval, ); + describe( + "#admin_enqueue_multisig_proposal_cancellation", + adminEnqueueMultisigProposalCancellation, + ); + describe( + "#execute_multisig_proposal_cancellation", + executeMultisigProposalCancellation, + ); describe("#admin_execute_multisig_proposal", adminExecuteMultisigProposal); describe("#admin_cancel_proposal", adminCancelProposal); describe("#admin_remove_proposal", adminRemoveProposal); @@ -127,6 +139,11 @@ export default function suite() { describe("futarchy amm", futarchyAmm); describe("integration: takeover end to end", takeoverEndToEnd); describe("integration: liquidation end to end", liquidationEndToEnd); + describe( + "integration: cancel approved payload after liquidation", + cancelApprovedPayloadAfterLiquidation, + ); + 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..e99ac999 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"; @@ -75,7 +75,6 @@ export default function suite() { twapStartDelaySeconds: null, teamSponsoredPassThresholdBps: null, teamAddress: null, - isOptimisticGovernanceEnabled: null, }, }) .instruction(); @@ -335,6 +334,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..fb5ee2f5 100644 --- a/tests/futarchy/unit/adminEnqueueMultisigProposalApproval.test.ts +++ b/tests/futarchy/unit/adminEnqueueMultisigProposalApproval.test.ts @@ -1,17 +1,19 @@ -import { PERMISSIONLESS_ACCOUNT } from "@metadaoproject/programs"; +import { + getEnqueuedMultisigProposalApprovalAddr, + 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"; -import BN from "bn.js"; - -const SEED_ENQUEUED_APPROVAL = Buffer.from("enqueued_approval"); export default function suite() { let META: PublicKey, USDC: PublicKey, dao: PublicKey; @@ -42,22 +44,6 @@ export default function suite() { }); }); - const deriveEnqueuedApprovalPda = ( - context: any, - daoKey: PublicKey, - transactionIndex: bigint, - ): PublicKey => { - const [pda] = PublicKey.findProgramAddressSync( - [ - SEED_ENQUEUED_APPROVAL, - daoKey.toBuffer(), - new BN(transactionIndex.toString()).toArrayLike(Buffer, "le", 8), - ], - context.futarchy.futarchy.programId, - ); - return pda; - }; - const createSquadsVaultTxAndProposal = async function ( context: any, squadsMultisig: PublicKey, @@ -106,24 +92,15 @@ export default function suite() { it("should enqueue a proposal approval", async function () { const daoAccount = await this.futarchy.getDao(dao); - const { proposalPda } = await createSquadsVaultTxAndProposal( - this, - daoAccount.squadsMultisig, - 1n, - ); + await createSquadsVaultTxAndProposal(this, daoAccount.squadsMultisig, 1n); - const enqueuedApprovalPda = deriveEnqueuedApprovalPda(this, dao, 1n); + const enqueuedApprovalPda = getEnqueuedMultisigProposalApprovalAddr({ + dao, + transactionIndex: 1n, + })[0]; - await this.futarchy.futarchy.methods - .adminEnqueueMultisigProposalApproval({ transactionIndex: new BN(1) }) - .accounts({ - dao, - admin: this.payer.publicKey, - squadsMultisig: daoAccount.squadsMultisig, - squadsMultisigProposal: proposalPda, - enqueuedApproval: enqueuedApprovalPda, - }) - .signers([this.payer]) + await this.futarchy + .adminEnqueueMultisigProposalApprovalIx({ dao, transactionIndex: 1n }) .rpc(); const enqueued = @@ -134,71 +111,90 @@ export default function suite() { assert.equal(enqueued.transactionIndex.toString(), "1"); }); - it("should fail with PoolNotInSpotState when a futarchy proposal is active", async function () { + it("rejects a legacy-sized DAO whose residue decodes as a liquidator", async function () { const daoAccount = await this.futarchy.getDao(dao); + 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 + .adminEnqueueMultisigProposalApprovalIx({ + dao, + transactionIndex: 1n, + admin: attacker.publicKey, + }) + .signers([attacker]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("should fail with PoolNotInSpotState when a futarchy proposal is active", async function () { // Launching a futarchy proposal creates a Squads proposal at index 1 and // moves the AMM out of Spot. Use that Squads proposal as our approval // target — any Active Squads proposal would do here; we just need one // that exists when the AMM is non-Spot. - const { squadsProposal } = await this.initializeAndLaunchProposal({ + await this.initializeAndLaunchProposal({ dao, instructions: [], }); - const enqueuedApprovalPda = deriveEnqueuedApprovalPda(this, dao, 1n); - const callbacks = expectError( "PoolNotInSpotState", "enqueue should fail when the AMM is not in Spot state", ); - await this.futarchy.futarchy.methods - .adminEnqueueMultisigProposalApproval({ transactionIndex: new BN(1) }) - .accounts({ - dao, - admin: this.payer.publicKey, - squadsMultisig: daoAccount.squadsMultisig, - squadsMultisigProposal: squadsProposal, - enqueuedApproval: enqueuedApprovalPda, - }) - .signers([this.payer]) + await this.futarchy + .adminEnqueueMultisigProposalApprovalIx({ dao, transactionIndex: 1n }) .rpc() .then(callbacks[0], callbacks[1]); }); it("should fail when enqueuing twice for the same transaction_index", async function () { const daoAccount = await this.futarchy.getDao(dao); - const { proposalPda } = await createSquadsVaultTxAndProposal( - this, - daoAccount.squadsMultisig, - 1n, - ); - - const enqueuedApprovalPda = deriveEnqueuedApprovalPda(this, dao, 1n); + await createSquadsVaultTxAndProposal(this, daoAccount.squadsMultisig, 1n); - await this.futarchy.futarchy.methods - .adminEnqueueMultisigProposalApproval({ transactionIndex: new BN(1) }) - .accounts({ - dao, - admin: this.payer.publicKey, - squadsMultisig: daoAccount.squadsMultisig, - squadsMultisigProposal: proposalPda, - enqueuedApproval: enqueuedApprovalPda, - }) - .signers([this.payer]) + await this.futarchy + .adminEnqueueMultisigProposalApprovalIx({ dao, transactionIndex: 1n }) .rpc(); try { - await this.futarchy.futarchy.methods - .adminEnqueueMultisigProposalApproval({ transactionIndex: new BN(1) }) - .accounts({ - dao, - admin: this.payer.publicKey, - squadsMultisig: daoAccount.squadsMultisig, - squadsMultisigProposal: proposalPda, - enqueuedApproval: enqueuedApprovalPda, - }) + await this.futarchy + .adminEnqueueMultisigProposalApprovalIx({ dao, transactionIndex: 1n }) .preInstructions([ ComputeBudgetProgram.setComputeUnitLimit({ units: 200_001 }), ]) @@ -214,37 +210,14 @@ export default function suite() { it("should fail with InvalidSquadsProposalStatus when the Squads proposal is no longer Active", async function () { const daoAccount = await this.futarchy.getDao(dao); - const { proposalPda } = await createSquadsVaultTxAndProposal( - this, - daoAccount.squadsMultisig, - 1n, - ); + await createSquadsVaultTxAndProposal(this, daoAccount.squadsMultisig, 1n); - const enqueuedApprovalPda = deriveEnqueuedApprovalPda(this, dao, 1n); - - await this.futarchy.futarchy.methods - .adminEnqueueMultisigProposalApproval({ transactionIndex: new BN(1) }) - .accounts({ - dao, - admin: this.payer.publicKey, - squadsMultisig: daoAccount.squadsMultisig, - squadsMultisigProposal: proposalPda, - enqueuedApproval: enqueuedApprovalPda, - }) - .signers([this.payer]) + await this.futarchy + .adminEnqueueMultisigProposalApprovalIx({ dao, transactionIndex: 1n }) .rpc(); - await this.futarchy.futarchy.methods - .executeMultisigProposalApproval() - .accounts({ - dao, - rentReceiver: this.payer.publicKey, - squadsMultisig: daoAccount.squadsMultisig, - squadsMultisigProposal: proposalPda, - enqueuedApproval: enqueuedApprovalPda, - squadsMultisigProgram: multisig.PROGRAM_ID, - }) - .signers([this.payer]) + await this.futarchy + .executeMultisigProposalApprovalIx({ dao, transactionIndex: 1n }) .rpc(); const callbacks = expectError( @@ -252,15 +225,8 @@ export default function suite() { "second enqueue should fail because proposal is no longer Active", ); - await this.futarchy.futarchy.methods - .adminEnqueueMultisigProposalApproval({ transactionIndex: new BN(1) }) - .accounts({ - dao, - admin: this.payer.publicKey, - squadsMultisig: daoAccount.squadsMultisig, - squadsMultisigProposal: proposalPda, - enqueuedApproval: enqueuedApprovalPda, - }) + await this.futarchy + .adminEnqueueMultisigProposalApprovalIx({ dao, transactionIndex: 1n }) .preInstructions([ ComputeBudgetProgram.setComputeUnitLimit({ units: 200_001 }), ]) @@ -272,13 +238,12 @@ export default function suite() { it("should fail with RequireGtViolated when the Squads proposal is stale", async function () { const daoAccount = await this.futarchy.getDao(dao); - const { proposalPda: victimProposalPda } = - await createSquadsVaultTxAndProposal( - this, - daoAccount.squadsMultisig, - 1n, - "will be invalidated", - ); + await createSquadsVaultTxAndProposal( + this, + daoAccount.squadsMultisig, + 1n, + "will be invalidated", + ); const configTransactionIndex = 2n; const multisigSetTimeLockIx = multisig.instructions.multisigSetTimeLock({ @@ -330,37 +295,19 @@ export default function suite() { multisigPda: daoAccount.squadsMultisig, transactionIndex: configTransactionIndex, }); - const configEnqueuedApprovalPda = deriveEnqueuedApprovalPda( - this, - dao, - configTransactionIndex, - ); - await this.futarchy.futarchy.methods - .adminEnqueueMultisigProposalApproval({ - transactionIndex: new BN(configTransactionIndex.toString()), - }) - .accounts({ + await this.futarchy + .adminEnqueueMultisigProposalApprovalIx({ dao, - admin: this.payer.publicKey, - squadsMultisig: daoAccount.squadsMultisig, - squadsMultisigProposal: configProposalPda, - enqueuedApproval: configEnqueuedApprovalPda, + transactionIndex: configTransactionIndex, }) - .signers([this.payer]) .rpc(); - await this.futarchy.futarchy.methods - .executeMultisigProposalApproval() - .accounts({ + await this.futarchy + .executeMultisigProposalApprovalIx({ dao, - rentReceiver: this.payer.publicKey, - squadsMultisig: daoAccount.squadsMultisig, - squadsMultisigProposal: configProposalPda, - enqueuedApproval: configEnqueuedApprovalPda, - squadsMultisigProgram: multisig.PROGRAM_ID, + transactionIndex: configTransactionIndex, }) - .signers([this.payer]) .rpc(); const configTransactionAccount = @@ -401,22 +348,13 @@ export default function suite() { .signers([this.payer]) .rpc(); - const victimEnqueuedApprovalPda = deriveEnqueuedApprovalPda(this, dao, 1n); - const callbacks = expectError( "RequireGtViolated", "enqueue should fail because the proposal was invalidated by a later config tx", ); - await this.futarchy.futarchy.methods - .adminEnqueueMultisigProposalApproval({ transactionIndex: new BN(1) }) - .accounts({ - dao, - admin: this.payer.publicKey, - squadsMultisig: daoAccount.squadsMultisig, - squadsMultisigProposal: victimProposalPda, - enqueuedApproval: victimEnqueuedApprovalPda, - }) + await this.futarchy + .adminEnqueueMultisigProposalApprovalIx({ dao, transactionIndex: 1n }) .preInstructions([ ComputeBudgetProgram.setComputeUnitLimit({ units: 200_001 }), ]) diff --git a/tests/futarchy/unit/adminEnqueueMultisigProposalCancellation.test.ts b/tests/futarchy/unit/adminEnqueueMultisigProposalCancellation.test.ts new file mode 100644 index 00000000..360fef70 --- /dev/null +++ b/tests/futarchy/unit/adminEnqueueMultisigProposalCancellation.test.ts @@ -0,0 +1,228 @@ +import { + getEnqueuedMultisigProposalCancellationAddr, + getProposalAddrsForTransactionIndex, + PERMISSIONLESS_ACCOUNT, +} from "@metadaoproject/programs"; +import { + ComputeBudgetProgram, + Keypair, + PublicKey, + SystemProgram, + Transaction, +} from "@solana/web3.js"; +import { + executeVaultTransaction, + expectError, + forceApproveSquadsProposal, + makeOldDaoLayout, +} from "../../utils.js"; +import { assert } from "chai"; +import { createMemoInstruction } from "@solana/spl-memo"; + +export default function suite() { + let META: PublicKey, USDC: PublicKey, dao: PublicKey; + + beforeEach(async function () { + META = await this.createMint(this.payer.publicKey, 9); + USDC = await this.createMint(this.payer.publicKey, 6); + + await this.createTokenAccount(META, this.payer.publicKey); + await this.createTokenAccount(USDC, this.payer.publicKey); + + // 200/200k tokens (not 100/100k): setupBasicDaoWithLiquidity mints the + // same 10^11 atoms to the same ATAs, and identical amounts would make + // these mintTo transactions byte-identical to the helper's, failing with + // "This transaction has already been processed" when they share a + // blockhash tick + await this.mintTo(META, this.payer.publicKey, this.payer, 200 * 10 ** 9); + await this.mintTo( + USDC, + this.payer.publicKey, + this.payer, + 200_000 * 1_000_000, + ); + + dao = await this.setupBasicDaoWithLiquidity({ + baseMint: META, + quoteMint: USDC, + }); + }); + + // A memo vault transaction + Active proposal at `transactionIndex` + const createPayload = async function ( + context: any, + transactionIndex: bigint, + ) { + const { tx } = context.futarchy.squadsProposalCreateTx({ + dao, + instructions: [createMemoInstruction("hello world")], + transactionIndex, + }); + tx.recentBlockhash = (await context.banksClient.getLatestBlockhash())[0]; + tx.feePayer = context.payer.publicKey; + tx.sign(context.payer, PERMISSIONLESS_ACCOUNT); + await context.banksClient.processTransaction(tx); + + return getProposalAddrsForTransactionIndex({ dao, transactionIndex }); + }; + + // The same payload, force-approved so it is executable without a market + const createApprovedPayload = async function ( + context: any, + transactionIndex: bigint, + ) { + const addrs = await createPayload(context, transactionIndex); + await forceApproveSquadsProposal(context, addrs.squadsProposal); + return addrs; + }; + + it("enqueues a cancellation for an approved Squads proposal", async function () { + await createApprovedPayload(this, 1n); + + await this.futarchy + .adminEnqueueMultisigProposalCancellationIx({ dao, transactionIndex: 1n }) + .rpc(); + + const [enqueuedCancellationPda] = + getEnqueuedMultisigProposalCancellationAddr({ + dao, + transactionIndex: 1n, + }); + const enqueued = + await this.futarchy.futarchy.account.enqueuedMultisigProposalCancellation.fetch( + enqueuedCancellationPda, + ); + assert.equal(enqueued.dao.toBase58(), dao.toBase58()); + assert.equal(enqueued.transactionIndex.toString(), "1"); + }); + + it("fails with SquadsProposalNotApproved when the Squads proposal is still Active", async function () { + await createPayload(this, 1n); + + const callbacks = expectError( + "SquadsProposalNotApproved", + "enqueue should fail while the Squads proposal is Active", + ); + + await this.futarchy + .adminEnqueueMultisigProposalCancellationIx({ dao, transactionIndex: 1n }) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("fails with SquadsProposalNotApproved when the Squads proposal was already executed", async function () { + const { squadsTransaction } = await createApprovedPayload(this, 1n); + await executeVaultTransaction(this, dao, squadsTransaction); + + const callbacks = expectError( + "SquadsProposalNotApproved", + "enqueue should fail once the payload has executed", + ); + + await this.futarchy + .adminEnqueueMultisigProposalCancellationIx({ dao, transactionIndex: 1n }) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("fails when enqueuing twice for the same transaction_index", async function () { + await createApprovedPayload(this, 1n); + + await this.futarchy + .adminEnqueueMultisigProposalCancellationIx({ dao, transactionIndex: 1n }) + .rpc(); + + try { + await this.futarchy + .adminEnqueueMultisigProposalCancellationIx({ + dao, + transactionIndex: 1n, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 200_001 }), + ]) + .rpc(); + assert.fail("Should have thrown error"); + } catch (e) { + // The init constraint fails because the account already exists + // (system program error 0x0). + assert.include(e.message, "custom program error: 0x0"); + } + }); + + it("rejects a legacy-sized DAO whose residue decodes as a liquidator", async function () { + await createApprovedPayload(this, 1n); + + // The attacker pays rent for the enqueued cancellation 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 + .adminEnqueueMultisigProposalCancellationIx({ + dao, + transactionIndex: 1n, + admin: attacker.publicKey, + }) + .signers([attacker]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("enqueues while a futarchy proposal is live", async function () { + // The market's own Squads proposal takes index 1 and stays Active; the + // approved payload is the separate transaction at index 2 + await this.initializeAndLaunchProposal({ dao, instructions: [] }); + await createApprovedPayload(this, 2n); + + const storedDao = await this.futarchy.getDao(dao); + assert.exists(storedDao.amm.state.futarchy); + + await this.futarchy + .adminEnqueueMultisigProposalCancellationIx({ dao, transactionIndex: 2n }) + .rpc(); + + const [enqueuedCancellationPda] = + getEnqueuedMultisigProposalCancellationAddr({ + dao, + transactionIndex: 2n, + }); + const enqueued = + await this.futarchy.futarchy.account.enqueuedMultisigProposalCancellation.fetch( + enqueuedCancellationPda, + ); + assert.equal(enqueued.transactionIndex.toString(), "2"); + }); +} diff --git a/tests/futarchy/unit/adminExecuteMultisigProposal.test.ts b/tests/futarchy/unit/adminExecuteMultisigProposal.test.ts index 5c9d7ef7..e35ab56f 100644 --- a/tests/futarchy/unit/adminExecuteMultisigProposal.test.ts +++ b/tests/futarchy/unit/adminExecuteMultisigProposal.test.ts @@ -1,17 +1,9 @@ import { PERMISSIONLESS_ACCOUNT } from "@metadaoproject/programs"; -import { - ComputeBudgetProgram, - PublicKey, - Transaction, - TransactionMessage, -} from "@solana/web3.js"; +import { PublicKey, Transaction, TransactionMessage } from "@solana/web3.js"; import { expectError, setupBasicDao } from "../../utils.js"; import { assert } from "chai"; import * as multisig from "@sqds/multisig"; import { createMemoInstruction } from "@solana/spl-memo"; -import BN from "bn.js"; - -const SEED_ENQUEUED_APPROVAL = Buffer.from("enqueued_approval"); export default function suite() { let META: PublicKey, USDC: PublicKey, dao: PublicKey; @@ -111,40 +103,14 @@ export default function suite() { programId: multisig.PROGRAM_ID, }); - const [enqueuedApprovalPda] = PublicKey.findProgramAddressSync( - [ - SEED_ENQUEUED_APPROVAL, - dao.toBuffer(), - new BN(1).toArrayLike(Buffer, "le", 8), - ], - this.futarchy.futarchy.programId, - ); - // First enqueue an approval (admin-gated) - await this.futarchy.futarchy.methods - .adminEnqueueMultisigProposalApproval({ transactionIndex: new BN(1) }) - .accounts({ - dao: dao, - admin: this.payer.publicKey, - squadsMultisig: daoAccount.squadsMultisig, - squadsMultisigProposal: squadsProposalPda, - enqueuedApproval: enqueuedApprovalPda, - }) - .signers([this.payer]) + await this.futarchy + .adminEnqueueMultisigProposalApprovalIx({ dao, transactionIndex: 1n }) .rpc(); // Then execute the approval (permissionless) - await this.futarchy.futarchy.methods - .executeMultisigProposalApproval() - .accounts({ - dao: dao, - rentReceiver: this.payer.publicKey, - squadsMultisig: daoAccount.squadsMultisig, - squadsMultisigProposal: squadsProposalPda, - enqueuedApproval: enqueuedApprovalPda, - squadsMultisigProgram: multisig.PROGRAM_ID, - }) - .signers([this.payer]) + await this.futarchy + .executeMultisigProposalApprovalIx({ dao, transactionIndex: 1n }) .rpc(); // Then execute diff --git a/tests/futarchy/unit/adminRemoveProposal.test.ts b/tests/futarchy/unit/adminRemoveProposal.test.ts index 565313bf..88dbd5aa 100644 --- a/tests/futarchy/unit/adminRemoveProposal.test.ts +++ b/tests/futarchy/unit/adminRemoveProposal.test.ts @@ -50,7 +50,6 @@ export default function suite() { twapStartDelaySeconds: null, teamSponsoredPassThresholdBps: null, teamAddress: null, - isOptimisticGovernanceEnabled: null, }, }) .instruction(); diff --git a/tests/futarchy/unit/adminUpdateProposalParams.test.ts b/tests/futarchy/unit/adminUpdateProposalParams.test.ts index 6c0c4062..240c0082 100644 --- a/tests/futarchy/unit/adminUpdateProposalParams.test.ts +++ b/tests/futarchy/unit/adminUpdateProposalParams.test.ts @@ -231,7 +231,10 @@ export default function suite() { .rpc(); const after = await this.futarchy.getProposal(proposal); - assert.isTrue(after.isTeamSponsored); + assert.equal( + after.sponsoredBy?.toBase58(), + this.payer.publicKey.toBase58(), + ); assert.equal(after.durationInSeconds, DAY_SECONDS * 2); assert.equal(after.passThresholdBps, 200); }); @@ -250,7 +253,10 @@ export default function suite() { await this.futarchy.sponsorProposalIx({ proposal, dao }).rpc(); const after = await this.futarchy.getProposal(proposal); - assert.isTrue(after.isTeamSponsored); + assert.equal( + after.sponsoredBy?.toBase58(), + this.payer.publicKey.toBase58(), + ); assert.equal(after.durationInSeconds, DAY_SECONDS * 2); assert.equal(after.passThresholdBps, 200); }); @@ -437,7 +443,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/executeMultisigProposalApproval.test.ts b/tests/futarchy/unit/executeMultisigProposalApproval.test.ts index 407e28e0..9db46ec1 100644 --- a/tests/futarchy/unit/executeMultisigProposalApproval.test.ts +++ b/tests/futarchy/unit/executeMultisigProposalApproval.test.ts @@ -1,4 +1,7 @@ -import { PERMISSIONLESS_ACCOUNT } from "@metadaoproject/programs"; +import { + getEnqueuedMultisigProposalApprovalAddr, + PERMISSIONLESS_ACCOUNT, +} from "@metadaoproject/programs"; import { ComputeBudgetProgram, PublicKey, @@ -9,9 +12,6 @@ import { expectError } from "../../utils.js"; import { assert } from "chai"; import * as multisig from "@sqds/multisig"; import { createMemoInstruction } from "@solana/spl-memo"; -import BN from "bn.js"; - -const SEED_ENQUEUED_APPROVAL = Buffer.from("enqueued_approval"); export default function suite() { let META: PublicKey, USDC: PublicKey, dao: PublicKey; @@ -42,22 +42,6 @@ export default function suite() { }); }); - const deriveEnqueuedApprovalPda = ( - context: any, - daoKey: PublicKey, - transactionIndex: bigint, - ): PublicKey => { - const [pda] = PublicKey.findProgramAddressSync( - [ - SEED_ENQUEUED_APPROVAL, - daoKey.toBuffer(), - new BN(transactionIndex.toString()).toArrayLike(Buffer, "le", 8), - ], - context.futarchy.futarchy.programId, - ); - return pda; - }; - const createSquadsVaultTxAndProposal = async function ( context: any, squadsMultisig: PublicKey, @@ -104,32 +88,14 @@ export default function suite() { return { proposalPda }; }; - const enqueue = async function ( - context: any, - daoKey: PublicKey, - squadsMultisigKey: PublicKey, - squadsProposalPda: PublicKey, - transactionIndex: bigint, - ) { - const enqueuedApprovalPda = deriveEnqueuedApprovalPda( - context, - daoKey, - transactionIndex, - ); - await context.futarchy.futarchy.methods - .adminEnqueueMultisigProposalApproval({ - transactionIndex: new BN(transactionIndex.toString()), - }) - .accounts({ - dao: daoKey, - admin: context.payer.publicKey, - squadsMultisig: squadsMultisigKey, - squadsMultisigProposal: squadsProposalPda, - enqueuedApproval: enqueuedApprovalPda, - }) - .signers([context.payer]) + const enqueue = async function (context: any, transactionIndex: bigint) { + await context.futarchy + .adminEnqueueMultisigProposalApprovalIx({ dao, transactionIndex }) .rpc(); - return enqueuedApprovalPda; + return getEnqueuedMultisigProposalApprovalAddr({ + dao, + transactionIndex, + })[0]; }; it("should execute an enqueued approval with a permissionless signer", async function () { @@ -139,13 +105,7 @@ export default function suite() { daoAccount.squadsMultisig, 1n, ); - const enqueuedApprovalPda = await enqueue( - this, - dao, - daoAccount.squadsMultisig, - proposalPda, - 1n, - ); + const enqueuedApprovalPda = await enqueue(this, 1n); let squadsProposal = await multisig.accounts.Proposal.fromAccountAddress( this.squadsConnection, @@ -155,15 +115,11 @@ export default function suite() { multisig.generated.isProposalStatusActive(squadsProposal.status), ); - await this.futarchy.futarchy.methods - .executeMultisigProposalApproval() - .accounts({ + await this.futarchy + .executeMultisigProposalApprovalIx({ dao, + transactionIndex: 1n, rentReceiver: PERMISSIONLESS_ACCOUNT.publicKey, - squadsMultisig: daoAccount.squadsMultisig, - squadsMultisigProposal: proposalPda, - enqueuedApproval: enqueuedApprovalPda, - squadsMultisigProgram: multisig.PROGRAM_ID, }) .signers([PERMISSIONLESS_ACCOUNT]) .rpc(); @@ -184,50 +140,27 @@ export default function suite() { it("should fail when no enqueued approval exists", async function () { const daoAccount = await this.futarchy.getDao(dao); - const { proposalPda } = await createSquadsVaultTxAndProposal( - this, - daoAccount.squadsMultisig, - 1n, - ); - - const enqueuedApprovalPda = deriveEnqueuedApprovalPda(this, dao, 1n); + await createSquadsVaultTxAndProposal(this, daoAccount.squadsMultisig, 1n); const callbacks = expectError( "AccountNotInitialized", "execute should fail without an enqueued approval PDA", ); - await this.futarchy.futarchy.methods - .executeMultisigProposalApproval() - .accounts({ - dao, - rentReceiver: this.payer.publicKey, - squadsMultisig: daoAccount.squadsMultisig, - squadsMultisigProposal: proposalPda, - enqueuedApproval: enqueuedApprovalPda, - squadsMultisigProgram: multisig.PROGRAM_ID, - }) - .signers([this.payer]) + await this.futarchy + .executeMultisigProposalApprovalIx({ dao, transactionIndex: 1n }) .rpc() .then(callbacks[0], callbacks[1]); }); it("should fail with PoolNotInSpotState when a futarchy proposal launches between enqueue and execute", async function () { - const daoAccount = await this.futarchy.getDao(dao); - // Initialize (but don't launch) a futarchy proposal. This creates a // Squads proposal at index 1 and leaves the AMM in Spot — so we can // enqueue approval against it. Launching is done separately below. const { proposal, squadsProposal: proposalPda } = await this.initializeProposal({ dao, instructions: [] }); - const enqueuedApprovalPda = await enqueue( - this, - dao, - daoAccount.squadsMultisig, - proposalPda, - 1n, - ); + const enqueuedApprovalPda = await enqueue(this, 1n); // Now launch the futarchy proposal to push the AMM out of Spot. const storedDao = await this.futarchy.getDao(dao); @@ -246,17 +179,8 @@ export default function suite() { "execute should fail once the AMM is no longer in Spot state", ); - await this.futarchy.futarchy.methods - .executeMultisigProposalApproval() - .accounts({ - dao, - rentReceiver: this.payer.publicKey, - squadsMultisig: daoAccount.squadsMultisig, - squadsMultisigProposal: proposalPda, - enqueuedApproval: enqueuedApprovalPda, - squadsMultisigProgram: multisig.PROGRAM_ID, - }) - .signers([this.payer]) + await this.futarchy + .executeMultisigProposalApprovalIx({ dao, transactionIndex: 1n }) .rpc() .then(callbacks[0], callbacks[1]); @@ -268,22 +192,15 @@ export default function suite() { it("should fail with RequireGtViolated when the Squads proposal is invalidated between enqueue and execute", async function () { const daoAccount = await this.futarchy.getDao(dao); - const { proposalPda: victimProposalPda } = - await createSquadsVaultTxAndProposal( - this, - daoAccount.squadsMultisig, - 1n, - "will be invalidated", - ); - - const victimEnqueuedApprovalPda = await enqueue( + await createSquadsVaultTxAndProposal( this, - dao, daoAccount.squadsMultisig, - victimProposalPda, 1n, + "will be invalidated", ); + const victimEnqueuedApprovalPda = await enqueue(this, 1n); + const configTransactionIndex = 2n; const multisigSetTimeLockIx = multisig.instructions.multisigSetTimeLock({ multisigPda: daoAccount.squadsMultisig, @@ -336,23 +253,14 @@ export default function suite() { }); const configEnqueuedApprovalPda = await enqueue( this, - dao, - daoAccount.squadsMultisig, - configProposalPda, configTransactionIndex, ); - await this.futarchy.futarchy.methods - .executeMultisigProposalApproval() - .accounts({ + await this.futarchy + .executeMultisigProposalApprovalIx({ dao, - rentReceiver: this.payer.publicKey, - squadsMultisig: daoAccount.squadsMultisig, - squadsMultisigProposal: configProposalPda, - enqueuedApproval: configEnqueuedApprovalPda, - squadsMultisigProgram: multisig.PROGRAM_ID, + transactionIndex: configTransactionIndex, }) - .signers([this.payer]) .rpc(); const configTransactionAccount = @@ -398,16 +306,8 @@ export default function suite() { "execute should fail because the proposal was invalidated by the config tx", ); - await this.futarchy.futarchy.methods - .executeMultisigProposalApproval() - .accounts({ - dao, - rentReceiver: this.payer.publicKey, - squadsMultisig: daoAccount.squadsMultisig, - squadsMultisigProposal: victimProposalPda, - enqueuedApproval: victimEnqueuedApprovalPda, - squadsMultisigProgram: multisig.PROGRAM_ID, - }) + await this.futarchy + .executeMultisigProposalApprovalIx({ dao, transactionIndex: 1n }) .preInstructions([ ComputeBudgetProgram.setComputeUnitLimit({ units: 200_001 }), ]) diff --git a/tests/futarchy/unit/executeMultisigProposalCancellation.test.ts b/tests/futarchy/unit/executeMultisigProposalCancellation.test.ts new file mode 100644 index 00000000..51921d24 --- /dev/null +++ b/tests/futarchy/unit/executeMultisigProposalCancellation.test.ts @@ -0,0 +1,272 @@ +import { + getEnqueuedMultisigProposalCancellationAddr, + getProposalAddrsForTransactionIndex, + PERMISSIONLESS_ACCOUNT, +} from "@metadaoproject/programs"; +import { PublicKey } from "@solana/web3.js"; +import { + executeVaultTransaction, + expectError, + forceApproveSquadsProposal, +} from "../../utils.js"; +import { assert } from "chai"; +import * as multisig from "@sqds/multisig"; +import { createMemoInstruction } from "@solana/spl-memo"; + +export default function suite() { + let META: PublicKey, + USDC: PublicKey, + dao: PublicKey, + squadsMultisig: PublicKey, + squadsMultisigVault: PublicKey; + + beforeEach(async function () { + META = await this.createMint(this.payer.publicKey, 9); + USDC = await this.createMint(this.payer.publicKey, 6); + + await this.createTokenAccount(META, this.payer.publicKey); + await this.createTokenAccount(USDC, this.payer.publicKey); + + // 200/200k tokens (not 100/100k): setupBasicDaoWithLiquidity mints the + // same 10^11 atoms to the same ATAs, and identical amounts would make + // these mintTo transactions byte-identical to the helper's, failing with + // "This transaction has already been processed" when they share a + // blockhash tick + await this.mintTo(META, this.payer.publicKey, this.payer, 200 * 10 ** 9); + await this.mintTo( + USDC, + this.payer.publicKey, + this.payer, + 200_000 * 1_000_000, + ); + + dao = await this.setupBasicDaoWithLiquidity({ + baseMint: META, + quoteMint: USDC, + }); + + const storedDao = await this.futarchy.getDao(dao); + squadsMultisig = storedDao.squadsMultisig; + squadsMultisigVault = storedDao.squadsMultisigVault; + }); + + // A memo vault transaction + proposal at `transactionIndex`, force-approved + // so it is executable without a market + const createApprovedPayload = async function ( + context: any, + transactionIndex: bigint, + ) { + const { tx } = context.futarchy.squadsProposalCreateTx({ + dao, + instructions: [createMemoInstruction("hello world")], + transactionIndex, + }); + tx.recentBlockhash = (await context.banksClient.getLatestBlockhash())[0]; + tx.feePayer = context.payer.publicKey; + tx.sign(context.payer, PERMISSIONLESS_ACCOUNT); + await context.banksClient.processTransaction(tx); + + const addrs = getProposalAddrsForTransactionIndex({ + dao, + transactionIndex, + }); + await forceApproveSquadsProposal(context, addrs.squadsProposal); + return addrs; + }; + + const enqueue = async function (context: any, transactionIndex: bigint) { + await context.futarchy + .adminEnqueueMultisigProposalCancellationIx({ dao, transactionIndex }) + .rpc(); + return getEnqueuedMultisigProposalCancellationAddr({ + dao, + transactionIndex, + })[0]; + }; + + // Runs a multisig_set_time_lock config instruction as vault transaction + // `configTransactionIndex`: approved through the approval set, then executed + // through admin_execute_multisig_proposal so the DAO PDA signs as config + // authority. Squads invalidates every prior transaction on the way. + const advanceStaleIndex = async function ( + context: any, + configTransactionIndex: bigint, + ) { + const setTimeLockIx = multisig.instructions.multisigSetTimeLock({ + multisigPda: squadsMultisig, + timeLock: 100, + configAuthority: dao, + }); + + const { tx } = context.futarchy.squadsProposalCreateTx({ + dao, + instructions: [setTimeLockIx], + transactionIndex: configTransactionIndex, + }); + tx.recentBlockhash = (await context.banksClient.getLatestBlockhash())[0]; + tx.feePayer = context.payer.publicKey; + tx.sign(context.payer, PERMISSIONLESS_ACCOUNT); + await context.banksClient.processTransaction(tx); + + const { squadsProposal: configProposal, squadsTransaction } = + getProposalAddrsForTransactionIndex({ + dao, + transactionIndex: configTransactionIndex, + }); + + await context.futarchy + .adminEnqueueMultisigProposalApprovalIx({ + dao, + transactionIndex: configTransactionIndex, + }) + .rpc(); + + await context.futarchy + .executeMultisigProposalApprovalIx({ + dao, + transactionIndex: configTransactionIndex, + }) + .rpc(); + + const configTransactionAccount = + await multisig.accounts.VaultTransaction.fromAccountAddress( + context.squadsConnection, + squadsTransaction, + ); + const { accountMetas } = await multisig.utils.accountsForTransactionExecute( + { + connection: context.squadsConnection, + message: configTransactionAccount.message, + ephemeralSignerBumps: [ + ...configTransactionAccount.ephemeralSignerBumps, + ], + vaultPda: squadsMultisigVault, + transactionPda: squadsTransaction, + programId: multisig.PROGRAM_ID, + }, + ); + + await context.futarchy.futarchy.methods + .adminExecuteMultisigProposal() + .accounts({ + dao, + squadsMultisig, + squadsMultisigProposal: configProposal, + squadsMultisigVaultTransaction: squadsTransaction, + admin: context.payer.publicKey, + squadsMultisigProgram: multisig.PROGRAM_ID, + }) + .remainingAccounts( + accountMetas.map((meta) => + meta.pubkey.equals(dao) ? { ...meta, isSigner: false } : meta, + ), + ) + .signers([context.payer]) + .rpc(); + }; + + it("cancels the Squads proposal with a permissionless signer and closes the enqueued cancellation", async function () { + const { squadsProposal, squadsTransaction } = await createApprovedPayload( + this, + 1n, + ); + const enqueuedCancellationPda = await enqueue(this, 1n); + + await this.futarchy + .executeMultisigProposalCancellationIx({ + dao, + transactionIndex: 1n, + rentReceiver: PERMISSIONLESS_ACCOUNT.publicKey, + }) + .signers([PERMISSIONLESS_ACCOUNT]) + .rpc(); + + const storedSquadsProposal = + await multisig.accounts.Proposal.fromAccountAddress( + this.squadsConnection, + squadsProposal, + ); + assert.isTrue( + multisig.generated.isProposalStatusCancelled(storedSquadsProposal.status), + ); + assert.deepEqual( + storedSquadsProposal.cancelled.map((k) => k.toBase58()), + [dao.toBase58()], + ); + + assert.isNull(await this.banksClient.getAccount(enqueuedCancellationPda)); + + // The payload is dead: Squads' execute requires Approved + try { + await executeVaultTransaction(this, dao, squadsTransaction); + assert.fail("Should have thrown error"); + } catch (e) { + // Squads' InvalidProposalStatus (0x1778 = 6008) + assert.isTrue(e.toString().includes("0x1778"), `unexpected error: ${e}`); + } + }); + + it("fails when no enqueued cancellation exists", async function () { + await createApprovedPayload(this, 1n); + + const callbacks = expectError( + "AccountNotInitialized", + "execute should fail without an enqueued cancellation PDA", + ); + + await this.futarchy + .executeMultisigProposalCancellationIx({ dao, transactionIndex: 1n }) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("fails with SquadsProposalNotApproved when the payload executes between enqueue and execute", async function () { + const { squadsTransaction } = await createApprovedPayload(this, 1n); + const enqueuedCancellationPda = await enqueue(this, 1n); + + await executeVaultTransaction(this, dao, squadsTransaction); + + const callbacks = expectError( + "SquadsProposalNotApproved", + "execute should fail once the payload has executed", + ); + + await this.futarchy + .executeMultisigProposalCancellationIx({ dao, transactionIndex: 1n }) + .rpc() + .then(callbacks[0], callbacks[1]); + + assert.isNotNull( + await this.banksClient.getAccount(enqueuedCancellationPda), + ); + }); + + it("cancels a stale Approved proposal", async function () { + const { squadsProposal: victimProposal } = await createApprovedPayload( + this, + 1n, + ); + + await advanceStaleIndex(this, 2n); + + const storedMultisig = await multisig.accounts.Multisig.fromAccountAddress( + this.squadsConnection, + squadsMultisig, + ); + assert.equal(storedMultisig.staleTransactionIndex.toString(), "2"); + + await enqueue(this, 1n); + + await this.futarchy + .executeMultisigProposalCancellationIx({ dao, transactionIndex: 1n }) + .rpc(); + + const storedVictim = await multisig.accounts.Proposal.fromAccountAddress( + this.squadsConnection, + victimProposal, + ); + assert.isTrue( + multisig.generated.isProposalStatusCancelled(storedVictim.status), + ); + }); +} diff --git a/tests/futarchy/unit/finalizeProposal.test.ts b/tests/futarchy/unit/finalizeProposal.test.ts index d19aaf4c..ef242fbd 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); @@ -79,7 +83,6 @@ export default function suite() { twapStartDelaySeconds: null, teamSponsoredPassThresholdBps: null, teamAddress: null, - isOptimisticGovernanceEnabled: null, }, }) .instruction(); @@ -108,7 +111,7 @@ export default function suite() { rentPayer: this.payer.publicKey, }); - const [squadsProposalPda] = multisig.getProposalPda({ + [squadsProposalPda] = multisig.getProposalPda({ multisigPda, transactionIndex: 1n, }); @@ -146,6 +149,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( @@ -544,7 +609,6 @@ export default function suite() { twapStartDelaySeconds: null, teamSponsoredPassThresholdBps: null, teamAddress: null, - isOptimisticGovernanceEnabled: null, }, }) .instruction(); diff --git a/tests/futarchy/unit/initializeBuybackTokenProposal.test.ts b/tests/futarchy/unit/initializeBuybackTokenProposal.test.ts index 6e3cd85a..56a980dd 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", ), }); @@ -255,7 +255,7 @@ export default function suite() { assert.ok(storedProposal.dao.equals(dao)); assert.ok(storedProposal.squadsProposal.equals(squadsProposal)); assert.exists(storedProposal.state.draft); - assert.isFalse(storedProposal.isTeamSponsored); + assert.isNull(storedProposal.sponsoredBy); }); it("formats a banded mandate's prices into the memo", async function () { @@ -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..9e55c0a9 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, ]); @@ -115,7 +82,7 @@ export default function suite() { assert.ok(storedProposal.proposer.equals(this.payer.publicKey)); assert.ok(storedProposal.squadsProposal.equals(squadsProposal)); assert.exists(storedProposal.state.draft); - assert.isFalse(storedProposal.isTeamSponsored); + assert.isNull(storedProposal.sponsoredBy); assert.ok( storedProposal.action.hostileLiquidate.liquidator.equals(liquidator), ); diff --git a/tests/futarchy/unit/initializeHostileTakeoverProposal.test.ts b/tests/futarchy/unit/initializeHostileTakeoverProposal.test.ts index 767d4819..e2d33e36 100644 --- a/tests/futarchy/unit/initializeHostileTakeoverProposal.test.ts +++ b/tests/futarchy/unit/initializeHostileTakeoverProposal.test.ts @@ -75,7 +75,6 @@ export default function suite() { baseToStake: null, teamSponsoredPassThresholdBps: null, teamAddress: newTeamAddress, - isOptimisticGovernanceEnabled: null, }, }) .instruction(); @@ -102,7 +101,7 @@ export default function suite() { assert.ok(storedProposal.proposer.equals(this.payer.publicKey)); assert.ok(storedProposal.squadsProposal.equals(squadsProposal)); assert.exists(storedProposal.state.draft); - assert.isFalse(storedProposal.isTeamSponsored); + assert.isNull(storedProposal.sponsoredBy); assert.ok( storedProposal.action.hostileTakeover.newTeamAddress.equals( @@ -257,4 +256,84 @@ 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); + }); + + it("throws error when the new team address is the current team", async function () { + const callbacks = expectError( + "InvalidTeamAddress", + "created a hostile takeover proposal targeting the current team", + ); + await this.futarchy + .initializeHostileTakeoverProposal({ + dao, + newTeamAddress: this.payer.publicKey, + spendingLimitAction: { keep: {} }, + }) + .then(...callbacks); + }); } diff --git a/tests/futarchy/unit/initializeLargeSpendProposal.test.ts b/tests/futarchy/unit/initializeLargeSpendProposal.test.ts index 2b2e5ce7..7154c5e3 100644 --- a/tests/futarchy/unit/initializeLargeSpendProposal.test.ts +++ b/tests/futarchy/unit/initializeLargeSpendProposal.test.ts @@ -108,12 +108,15 @@ export default function suite() { assert.ok(storedProposal.proposer.equals(this.payer.publicKey)); assert.ok(storedProposal.squadsProposal.equals(squadsProposal)); assert.exists(storedProposal.state.draft); - assert.isFalse(storedProposal.isTeamSponsored); + assert.isNull(storedProposal.sponsoredBy); assert.equal( 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/initializeProposal.test.ts b/tests/futarchy/unit/initializeProposal.test.ts index bf47558f..e51427d3 100644 --- a/tests/futarchy/unit/initializeProposal.test.ts +++ b/tests/futarchy/unit/initializeProposal.test.ts @@ -88,7 +88,6 @@ export default function suite() { twapStartDelaySeconds: null, teamSponsoredPassThresholdBps: null, teamAddress: null, - isOptimisticGovernanceEnabled: null, }, }) .instruction(); diff --git a/tests/futarchy/unit/initializeSpendingLimitChangeProposal.test.ts b/tests/futarchy/unit/initializeSpendingLimitChangeProposal.test.ts index bbafd890..c5d89a7b 100644 --- a/tests/futarchy/unit/initializeSpendingLimitChangeProposal.test.ts +++ b/tests/futarchy/unit/initializeSpendingLimitChangeProposal.test.ts @@ -78,7 +78,7 @@ export default function suite() { assert.ok(storedProposal.proposer.equals(this.payer.publicKey)); assert.ok(storedProposal.squadsProposal.equals(squadsProposal)); assert.exists(storedProposal.state.draft); - assert.isFalse(storedProposal.isTeamSponsored); + assert.isNull(storedProposal.sponsoredBy); assert.equal( storedProposal.action.spendingLimitChange.config.amountPerMonth.toString(), @@ -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..6970cabc 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"; @@ -190,7 +194,7 @@ export default function suite() { const { proposal, squadsProposal } = await initializeProposal(this, dao); - // Sponsor the proposal (makes is_team_sponsored = true) + // Sponsor the proposal (sets sponsored_by to the team) await this.futarchy .sponsorProposalIx({ proposal, @@ -633,6 +637,228 @@ export default function suite() { assert.exists(storedProposal.state.pending); }); + it("fails to launch a large_spend paying the previous team even after the new team sponsors it", 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 newTeam = Keypair.generate(); + const takeover = await this.futarchy.initializeHostileTakeoverProposal({ + dao, + newTeamAddress: newTeam.publicKey, + spendingLimitAction: { keep: {} }, + }); + await forceApproveSquadsProposal(this, takeover.squadsProposal); + await executeVaultTransaction(this, dao, takeover.squadsTransaction); + + const staleSponsorCallbacks = expectError( + "ProposalNotTeamSponsored", + "launched a large spend sponsored by the previous team", + ); + + await this.futarchy + .launchProposalIx({ + proposal, + dao, + baseMint: META, + quoteMint: USDC, + squadsProposal, + }) + .rpc() + .then(staleSponsorCallbacks[0], staleSponsorCallbacks[1]); + + await this.futarchy + .sponsorProposalIx({ proposal, dao, teamAddress: newTeam.publicKey }) + .signers([newTeam]) + .rpc(); + + const stalePayeeCallbacks = expectError( + "StaleTeamAddress", + "launched a large spend paying the previous team", + ); + + // The compute unit price makes this transaction's hash differ from the + // first launch attempt, so it isn't rejected as already processed + await this.futarchy + .launchProposalIx({ + proposal, + dao, + baseMint: META, + quoteMint: USDC, + squadsProposal, + }) + .postInstructions([ + ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), + ]) + .rpc() + .then(stalePayeeCallbacks[0], stalePayeeCallbacks[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, @@ -708,6 +934,189 @@ export default function suite() { assert.exists(storedProposal.state.pending); }); + it("fails to launch a spending_limit_change sponsored by the previous team, launches once the new team sponsors it", 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.initializeSpendingLimitChangeProposal({ + dao, + config: { + amountPerMonth: new BN(20_000), + members: [Keypair.generate().publicKey], + }, + }); + + await this.futarchy + .sponsorProposalIx({ + proposal, + dao, + teamAddress: this.payer.publicKey, + }) + .rpc(); + + const newTeam = Keypair.generate(); + const takeover = await this.futarchy.initializeHostileTakeoverProposal({ + dao, + newTeamAddress: newTeam.publicKey, + spendingLimitAction: { keep: {} }, + }); + await forceApproveSquadsProposal(this, takeover.squadsProposal); + await executeVaultTransaction(this, dao, takeover.squadsTransaction); + + const callbacks = expectError( + "ProposalNotTeamSponsored", + "launched a spending limit change sponsored by the previous team", + ); + + await this.futarchy + .launchProposalIx({ + proposal, + dao, + baseMint: META, + quoteMint: USDC, + squadsProposal, + }) + .rpc() + .then(callbacks[0], callbacks[1]); + + await this.futarchy + .sponsorProposalIx({ proposal, dao, teamAddress: newTeam.publicKey }) + .signers([newTeam]) + .rpc(); + + // The compute unit price makes this transaction's hash differ from the + // first launch attempt, so it isn't rejected as already processed + await this.futarchy + .launchProposalIx({ + proposal, + dao, + baseMint: META, + quoteMint: USDC, + squadsProposal, + }) + .postInstructions([ + ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), + ]) + .rpc(); + + const storedProposal = await this.futarchy.getProposal(proposal); + assert.exists(storedProposal.state.pending); + assert.equal( + storedProposal.sponsoredBy?.toBase58(), + newTeam.publicKey.toBase58(), + ); + }); + + it("requires the stake once a sponsorship goes stale", async function () { + const stakeThreshold = new BN(100 * 10 ** 6); + const dao = await createDaoWithStakeThreshold( + this, + META, + USDC, + stakeThreshold, + 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 initializeProposal(this, dao); + + await this.futarchy + .sponsorProposalIx({ + proposal, + dao, + teamAddress: this.payer.publicKey, + }) + .rpc(); + + 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( + "InsufficientStakeToLaunch", + "launched on a stale sponsorship with no stake", + ); + + await this.futarchy + .launchProposalIx({ + proposal, + dao, + baseMint: META, + quoteMint: USDC, + squadsProposal, + }) + .rpc() + .then(callbacks[0], callbacks[1]); + + await this.futarchy + .stakeToProposalIx({ + proposal, + dao, + baseMint: META, + amount: stakeThreshold, + }) + .rpc(); + + // The compute unit price makes this transaction's hash differ from the + // first launch attempt, so it isn't rejected as already processed + await this.futarchy + .launchProposalIx({ + proposal, + dao, + baseMint: META, + quoteMint: USDC, + squadsProposal, + }) + .postInstructions([ + ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), + ]) + .rpc(); + + const storedProposal = await this.futarchy.getProposal(proposal); + assert.exists(storedProposal.state.pending); + }); + it("fails to launch a hostile takeover during its cooldown, launches once it elapses", 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..c159c32a 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,22 +362,19 @@ export default function suite() { .then(callbacks[0], callbacks[1]); }); - it("refuses spot_swap", async function () { + it("refuses sponsor_proposal", async function () { const callbacks = expectError( "DaoLiquidated", - "spot_swap should refuse on a liquidated DAO", + "sponsor_proposal should refuse on a liquidated DAO", ); await this.futarchy - .spotSwapIx({ - dao, - baseMint: META, - quoteMint: USDC, - swapType: "buy", - inputAmount: new BN(1_000_000), - }) + .sponsorProposalIx({ proposal: draftProposal, dao }) .rpc() .then(callbacks[0], callbacks[1]); + + const storedProposal = await this.futarchy.getProposal(draftProposal); + assert.isNull(storedProposal.sponsoredBy); }); it("refuses conditional_swap", async function () { @@ -444,7 +434,6 @@ export default function suite() { baseToStake: null, teamSponsoredPassThresholdBps: null, teamAddress: null, - isOptimisticGovernanceEnabled: null, }, }) .instruction(); @@ -498,17 +487,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 +630,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..3cd2b335 100644 --- a/tests/futarchy/unit/liquidatorPath.test.ts +++ b/tests/futarchy/unit/liquidatorPath.test.ts @@ -13,6 +13,7 @@ import { } from "@solana/spl-token"; import { getDaoAddr, + getEnqueuedMultisigProposalApprovalAddr, getProposalAddrsForTransactionIndex, PERMISSIONLESS_ACCOUNT, } from "@metadaoproject/programs"; @@ -24,14 +25,11 @@ import { THOUSAND_BUCK_PRICE, } from "../../utils.js"; -const SEED_ENQUEUED_APPROVAL = Buffer.from("enqueued_approval"); - export default function suite() { let META: PublicKey, USDC: PublicKey, dao: PublicKey, vault: PublicKey, - squadsMultisig: PublicKey, liquidator: Keypair; // The estate cycle starts from a genuinely liquidated DAO: a hostile @@ -91,10 +89,7 @@ export default function suite() { const storedDao = await this.futarchy.getDao(dao); vault = storedDao.squadsMultisigVault; - squadsMultisig = storedDao.squadsMultisig; - // The baked apply_liquidation payload requires the vault's ATAs to exist - await this.createTokenAccount(META, vault); await this.createTokenAccount(USDC, vault); await this.futarchy @@ -139,6 +134,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 @@ -178,28 +174,13 @@ export default function suite() { return getProposalAddrsForTransactionIndex({ dao, transactionIndex }); }; - const deriveEnqueuedApprovalPda = ( - context: any, - transactionIndex: bigint, - ): PublicKey => { - const [pda] = PublicKey.findProgramAddressSync( - [ - SEED_ENQUEUED_APPROVAL, - dao.toBuffer(), - new BN(transactionIndex.toString()).toArrayLike(Buffer, "le", 8), - ], - context.futarchy.futarchy.programId, - ); - return pda; - }; - it("refuses a non-liquidator enqueue once the DAO is liquidated", async function () { const recipient = Keypair.generate().publicKey; const recipientAta = await this.createTokenAccount(USDC, recipient); const vaultUsdcAta = getAssociatedTokenAddressSync(USDC, vault, true); // The liquidation payload was transaction 1; the estate starts at 2 - const { squadsProposal } = await createEstateProposal(this, 2n, [ + await createEstateProposal(this, 2n, [ createTransferInstruction( vaultUsdcAta, recipientAta, @@ -213,16 +194,8 @@ export default function suite() { "enqueue by a non-liquidator should fail on a liquidated DAO", ); - await this.futarchy.futarchy.methods - .adminEnqueueMultisigProposalApproval({ transactionIndex: new BN(2) }) - .accounts({ - dao, - admin: this.payer.publicKey, - squadsMultisig, - squadsMultisigProposal: squadsProposal, - enqueuedApproval: deriveEnqueuedApprovalPda(this, 2n), - }) - .signers([this.payer]) + await this.futarchy + .adminEnqueueMultisigProposalApprovalIx({ dao, transactionIndex: 2n }) .rpc() .then(callbacks[0], callbacks[1]); }); @@ -245,16 +218,16 @@ export default function suite() { ], ); - const enqueuedApprovalPda = deriveEnqueuedApprovalPda(this, 2n); + const enqueuedApprovalPda = getEnqueuedMultisigProposalApprovalAddr({ + dao, + transactionIndex: 2n, + })[0]; - await this.futarchy.futarchy.methods - .adminEnqueueMultisigProposalApproval({ transactionIndex: new BN(2) }) - .accounts({ + await this.futarchy + .adminEnqueueMultisigProposalApprovalIx({ dao, + transactionIndex: 2n, admin: liquidator.publicKey, - squadsMultisig, - squadsMultisigProposal: squadsProposal, - enqueuedApproval: enqueuedApprovalPda, }) .signers([liquidator]) .rpc(); @@ -268,17 +241,8 @@ export default function suite() { // The middle leg stays permissionless: any signer cranks the DAO PDA's // approve vote, which meets the threshold of 1 on its own - await this.futarchy.futarchy.methods - .executeMultisigProposalApproval() - .accounts({ - dao, - rentReceiver: this.payer.publicKey, - squadsMultisig, - squadsMultisigProposal: squadsProposal, - enqueuedApproval: enqueuedApprovalPda, - squadsMultisigProgram: multisig.PROGRAM_ID, - }) - .signers([this.payer]) + await this.futarchy + .executeMultisigProposalApprovalIx({ dao, transactionIndex: 2n }) .rpc(); let storedSquadsProposal = diff --git a/tests/futarchy/unit/resizeDao.test.ts b/tests/futarchy/unit/resizeDao.test.ts index 44e07a97..7c9c015a 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 }), ]) @@ -126,9 +119,9 @@ export default function suite() { assert.equal(after2.data.length, AFTER); }); - it("clears an in-flight optimistic proposal and carries the governance flag", async function () { + it("clears an in-flight optimistic proposal and disables the governance flag", async function () { const fakeSquadsProposal = Keypair.generate().publicKey; - await makeOldLayout(this, dao, { + await makeOldDaoLayout(this, dao, { isOptimisticGovernanceEnabled: true, optimisticProposal: { squadsProposal: fakeSquadsProposal, @@ -136,16 +129,13 @@ 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 - // carried into a state nothing can finalize. + // carried into a state nothing can finalize, and the flag is reset. assert.isNull(migrated.optimisticProposal); - assert.isTrue(migrated.isOptimisticGovernanceEnabled); + assert.isFalse(migrated.isOptimisticGovernanceEnabled); assert.isNull(migrated.liquidator); assert.equal(migrated.lastFailedTakeoverAt.toString(), "0"); assert.equal(migrated.lastFailedLiquidationAt.toString(), "0"); @@ -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..9aec322a 100644 --- a/tests/futarchy/unit/resizeProposal.test.ts +++ b/tests/futarchy/unit/resizeProposal.test.ts @@ -13,26 +13,33 @@ 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. +// appended `pass_threshold_bps`, `council_can_block`, and `action`, and +// collapsing `sponsored_by` back to the `is_team_sponsored` bit). The +// 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; - // 369 bytes: pass_threshold_bps (i16) + council_can_block (bool) - // + action (ProposalAction) - const BEFORE = AFTER - 369; + // 401 bytes: sponsored_by (Option) in place of is_team_sponsored (bool) + // + pass_threshold_bps (i16) + council_can_block (bool) + action (ProposalAction) + const BEFORE = AFTER - 401; const disc = Buffer.from(raw.data.slice(0, 8)); const coder = ctx.futarchy.futarchy.account.proposal.coder.accounts; const decoded = coder.decode("proposal", Buffer.from(raw.data)); - if (overrides.isTeamSponsored !== undefined) - decoded.isTeamSponsored = overrides.isTeamSponsored; + decoded.isTeamSponsored = overrides.isTeamSponsored ?? false; + 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.isNull(original.sponsoredBy); 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 @@ -172,12 +185,53 @@ export default function suite() { .rpc(); const migrated = await this.futarchy.getProposal(proposal); - assert.isTrue(migrated.isTeamSponsored); - assert.equal(migrated.passThresholdBps, -100); + assert.equal( + migrated.sponsoredBy?.toBase58(), + this.payer.publicKey.toBase58(), + ); + 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.equal( + migrated.sponsoredBy?.toBase58(), + this.payer.publicKey.toBase58(), + ); + 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 +259,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..6918ca2d 100644 --- a/tests/futarchy/unit/setSpendingLimit.test.ts +++ b/tests/futarchy/unit/setSpendingLimit.test.ts @@ -16,7 +16,6 @@ import { expectError } from "../../utils.js"; import { TestContext } from "../../main.test.js"; const ONE_BUCK_PRICE = PriceMath.getAmmPrice(1, 6, 6); -const SEED_ENQUEUED_APPROVAL = Buffer.from("enqueued_approval"); // The vault PDA can only sign via a Squads vault transaction execution, so the // record is written by creating + approving + executing one containing a @@ -52,43 +51,12 @@ async function executeSetSpendingLimitViaVault( createTx.sign(context.payer, PERMISSIONLESS_ACCOUNT); await context.banksClient.processTransaction(createTx); - const [squadsProposal] = multisig.getProposalPda({ - multisigPda, - transactionIndex, - }); - - const [enqueuedApproval] = PublicKey.findProgramAddressSync( - [ - SEED_ENQUEUED_APPROVAL, - dao.toBuffer(), - new BN(transactionIndex.toString()).toArrayLike(Buffer, "le", 8), - ], - context.futarchy.futarchy.programId, - ); - - await context.futarchy.futarchy.methods - .adminEnqueueMultisigProposalApproval({ - transactionIndex: new BN(transactionIndex.toString()), - }) - .accounts({ - dao, - admin: context.payer.publicKey, - squadsMultisig: multisigPda, - squadsMultisigProposal: squadsProposal, - enqueuedApproval, - }) + await context.futarchy + .adminEnqueueMultisigProposalApprovalIx({ dao, transactionIndex }) .rpc(); - await context.futarchy.futarchy.methods - .executeMultisigProposalApproval() - .accounts({ - dao, - rentReceiver: context.payer.publicKey, - squadsMultisig: multisigPda, - squadsMultisigProposal: squadsProposal, - enqueuedApproval, - squadsMultisigProgram: multisig.PROGRAM_ID, - }) + await context.futarchy + .executeMultisigProposalApprovalIx({ dao, transactionIndex }) .rpc(); // Execute as a top-level Squads instruction so the vault PDA signs the @@ -109,6 +77,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 +208,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", + "0x17a3", // 6051 + ); + }); - 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", + "0x17b2", // 6066 + ); + }); + + 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", + "0x17b3", // 6067 + ); + }); + + 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", + "0x17b4", // 6068 ); - assert.isFalse(daoAccount.spendingLimitDirty); }); } diff --git a/tests/futarchy/unit/sponsorProposal.test.ts b/tests/futarchy/unit/sponsorProposal.test.ts new file mode 100644 index 00000000..01844420 --- /dev/null +++ b/tests/futarchy/unit/sponsorProposal.test.ts @@ -0,0 +1,259 @@ +import { + ComputeBudgetProgram, + Keypair, + PublicKey, + Transaction, +} from "@solana/web3.js"; +import { + AuthorityType, + createSetAuthorityInstruction, +} from "@solana/spl-token"; +import BN from "bn.js"; +import { assert } from "chai"; +import { + executeVaultTransaction, + expectError, + forceApproveSquadsProposal, +} from "../../utils.js"; +import { TestContext } from "../../main.test.js"; + +export default function suite() { + let META: PublicKey, USDC: PublicKey, dao: PublicKey; + + beforeEach(async function () { + META = await this.createMint(this.payer.publicKey, 6); + USDC = await this.createMint(this.payer.publicKey, 6); + + await this.createTokenAccount(META, this.payer.publicKey); + await this.createTokenAccount(USDC, this.payer.publicKey); + + await this.mintTo( + META, + this.payer.publicKey, + this.payer, + 100_000 * 10 ** 6, + ); + await this.mintTo( + USDC, + this.payer.publicKey, + this.payer, + 100_000 * 10 ** 6, + ); + + dao = await this.setupBasicDao({ baseMint: META, quoteMint: USDC }); + }); + + async function createMintTokensDraft(ctx: TestContext) { + const { squadsMultisigVault } = await ctx.futarchy.getDao(dao); + + const tx = new Transaction().add( + createSetAuthorityInstruction( + META, + ctx.payer.publicKey, + AuthorityType.MintTokens, + squadsMultisigVault, + ), + ); + [tx.recentBlockhash] = await ctx.banksClient.getLatestBlockhash(); + tx.feePayer = ctx.payer.publicKey; + tx.sign(ctx.payer); + await ctx.banksClient.processTransaction(tx); + + return ctx.futarchy.initializeMintTokensProposal({ + dao, + amount: new BN(1_000_000_000), + recipient: Keypair.generate().publicKey, + }); + } + + it("rejects a hostile takeover draft", async function () { + const { proposal } = await this.futarchy.initializeHostileTakeoverProposal({ + dao, + newTeamAddress: Keypair.generate().publicKey, + spendingLimitAction: { keep: {} }, + }); + + const callbacks = expectError( + "TeamSponsorshipForbidden", + "sponsored a hostile takeover", + ); + + await this.futarchy + .sponsorProposalIx({ proposal, dao }) + .rpc() + .then(callbacks[0], callbacks[1]); + + const storedProposal = await this.futarchy.getProposal(proposal); + assert.isNull(storedProposal.sponsoredBy); + }); + + it("rejects a hostile liquidate draft", async function () { + const { proposal } = await this.futarchy.initializeHostileLiquidateProposal( + { dao }, + ); + + const callbacks = expectError( + "TeamSponsorshipForbidden", + "sponsored a hostile liquidation", + ); + + await this.futarchy + .sponsorProposalIx({ proposal, dao }) + .rpc() + .then(callbacks[0], callbacks[1]); + + const storedProposal = await this.futarchy.getProposal(proposal); + assert.isNull(storedProposal.sponsoredBy); + }); + + it("sponsors a mint tokens draft", async function () { + const { proposal } = await createMintTokensDraft(this); + + await this.futarchy.sponsorProposalIx({ proposal, dao }).rpc(); + + const storedProposal = await this.futarchy.getProposal(proposal); + assert.equal( + storedProposal.sponsoredBy?.toBase58(), + this.payer.publicKey.toBase58(), + ); + }); + + it("rejects a second sponsorship", async function () { + const { proposal } = await createMintTokensDraft(this); + + await this.futarchy.sponsorProposalIx({ proposal, dao }).rpc(); + + const callbacks = expectError( + "ProposalAlreadySponsored", + "sponsored a proposal twice", + ); + + // Compute unit price makes this transaction's hash differ from the first one + await this.futarchy + .sponsorProposalIx({ proposal, dao }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), + ]) + .rpc() + .then(callbacks[0], callbacks[1]); + + const storedProposal = await this.futarchy.getProposal(proposal); + assert.equal( + storedProposal.sponsoredBy?.toBase58(), + this.payer.publicKey.toBase58(), + ); + }); + + it("records the new team after a team change", async function () { + const { proposal } = await createMintTokensDraft(this); + + await this.futarchy.sponsorProposalIx({ proposal, dao }).rpc(); + + const newTeam = Keypair.generate(); + const takeover = await this.futarchy.initializeHostileTakeoverProposal({ + dao, + newTeamAddress: newTeam.publicKey, + spendingLimitAction: { keep: {} }, + }); + await forceApproveSquadsProposal(this, takeover.squadsProposal); + await executeVaultTransaction(this, dao, takeover.squadsTransaction); + + await this.futarchy + .sponsorProposalIx({ proposal, dao, teamAddress: newTeam.publicKey }) + .signers([newTeam]) + .rpc(); + + const storedProposal = await this.futarchy.getProposal(proposal); + assert.equal( + storedProposal.sponsoredBy?.toBase58(), + newTeam.publicKey.toBase58(), + ); + }); + + it("rejects the previous team after a team change", async function () { + const { proposal } = await createMintTokensDraft(this); + + 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( + "ConstraintHasOne", + "sponsored with the previous team", + ); + + await this.futarchy + .sponsorProposalIx({ proposal, dao }) + .rpc() + .then(callbacks[0], callbacks[1]); + + const storedProposal = await this.futarchy.getProposal(proposal); + assert.isNull(storedProposal.sponsoredBy); + }); + + it("rejects a launched proposal", async function () { + const { proposal, squadsProposal } = await createMintTokensDraft(this); + + await this.futarchy + .provideLiquidityIx({ + dao, + baseMint: META, + quoteMint: USDC, + quoteAmount: new BN(10_000 * 10 ** 6), + maxBaseAmount: new BN(20_000 * 10 ** 6), + minLiquidity: new BN(0), + positionAuthority: this.payer.publicKey, + liquidityProvider: this.payer.publicKey, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), + ]) + .rpc(); + + await this.futarchy + .launchProposalIx({ + proposal, + dao, + baseMint: META, + quoteMint: USDC, + squadsProposal, + }) + .rpc(); + + const callbacks = expectError( + "ProposalNotInDraftState", + "sponsored a launched proposal", + ); + + await this.futarchy + .sponsorProposalIx({ proposal, dao }) + .rpc() + .then(callbacks[0], callbacks[1]); + + const storedProposal = await this.futarchy.getProposal(proposal); + assert.isNull(storedProposal.sponsoredBy); + }); + + it("rejects a signer that is not the team", async function () { + const { proposal } = await createMintTokensDraft(this); + const stranger = Keypair.generate(); + + const callbacks = expectError( + "ConstraintHasOne", + "sponsored with a non-team signer", + ); + + await this.futarchy + .sponsorProposalIx({ proposal, dao, teamAddress: stranger.publicKey }) + .signers([stranger]) + .rpc() + .then(callbacks[0], callbacks[1]); + + const storedProposal = await this.futarchy.getProposal(proposal); + assert.isNull(storedProposal.sponsoredBy); + }); +} diff --git a/tests/futarchy/unit/syncSpendingLimit.test.ts b/tests/futarchy/unit/syncSpendingLimit.test.ts index 6ec105a8..4fbc8e62 100644 --- a/tests/futarchy/unit/syncSpendingLimit.test.ts +++ b/tests/futarchy/unit/syncSpendingLimit.test.ts @@ -19,7 +19,6 @@ import { TestContext } from "../../main.test.js"; const { Period } = multisig.types; const ONE_BUCK_PRICE = PriceMath.getAmmPrice(1, 6, 6); -const SEED_ENQUEUED_APPROVAL = Buffer.from("enqueued_approval"); async function initializeTestDao( context: TestContext, @@ -90,43 +89,12 @@ async function executeSetSpendingLimitViaVault( createTx.sign(context.payer, PERMISSIONLESS_ACCOUNT); await context.banksClient.processTransaction(createTx); - const [squadsProposal] = multisig.getProposalPda({ - multisigPda, - transactionIndex, - }); - - const [enqueuedApproval] = PublicKey.findProgramAddressSync( - [ - SEED_ENQUEUED_APPROVAL, - dao.toBuffer(), - new BN(transactionIndex.toString()).toArrayLike(Buffer, "le", 8), - ], - context.futarchy.futarchy.programId, - ); - - await context.futarchy.futarchy.methods - .adminEnqueueMultisigProposalApproval({ - transactionIndex: new BN(transactionIndex.toString()), - }) - .accounts({ - dao, - admin: context.payer.publicKey, - squadsMultisig: multisigPda, - squadsMultisigProposal: squadsProposal, - enqueuedApproval, - }) + await context.futarchy + .adminEnqueueMultisigProposalApprovalIx({ dao, transactionIndex }) .rpc(); - await context.futarchy.futarchy.methods - .executeMultisigProposalApproval() - .accounts({ - dao, - rentReceiver: context.payer.publicKey, - squadsMultisig: multisigPda, - squadsMultisigProposal: squadsProposal, - enqueuedApproval, - squadsMultisigProgram: multisig.PROGRAM_ID, - }) + await context.futarchy + .executeMultisigProposalApprovalIx({ dao, transactionIndex }) .rpc(); // Execute as a top-level Squads instruction so the vault PDA signs the diff --git a/tests/futarchy/unit/unstakeFromProposal.test.ts b/tests/futarchy/unit/unstakeFromProposal.test.ts index 96a31a72..c982187b 100644 --- a/tests/futarchy/unit/unstakeFromProposal.test.ts +++ b/tests/futarchy/unit/unstakeFromProposal.test.ts @@ -76,7 +76,6 @@ export default function suite() { twapStartDelaySeconds: null, teamSponsoredPassThresholdBps: null, teamAddress: null, - isOptimisticGovernanceEnabled: null, }, }) .instruction(); diff --git a/tests/futarchy/unit/updateDao.test.ts b/tests/futarchy/unit/updateDao.test.ts index 0eeaea7a..c5f55607 100644 --- a/tests/futarchy/unit/updateDao.test.ts +++ b/tests/futarchy/unit/updateDao.test.ts @@ -104,7 +104,6 @@ export default function suite() { teamSponsoredPassThresholdBps: null, teamAddress: null, twapStartDelaySeconds: null, - isOptimisticGovernanceEnabled: null, }, }) .instruction(); diff --git a/tests/integration/fullLaunch.test.ts b/tests/integration/fullLaunch.test.ts index b2914587..038ac5f0 100644 --- a/tests/integration/fullLaunch.test.ts +++ b/tests/integration/fullLaunch.test.ts @@ -384,7 +384,6 @@ export default async function suite() { minBaseFutarchicLiquidity: null, teamSponsoredPassThresholdBps: null, teamAddress: null, - isOptimisticGovernanceEnabled: false, }, }) .instruction(); diff --git a/tests/integration/fullLaunch_v7.test.ts b/tests/integration/fullLaunch_v7.test.ts index e236370d..7fdaeba4 100644 --- a/tests/integration/fullLaunch_v7.test.ts +++ b/tests/integration/fullLaunch_v7.test.ts @@ -429,7 +429,6 @@ export default async function suite() { minBaseFutarchicLiquidity: null, teamSponsoredPassThresholdBps: null, teamAddress: null, - isOptimisticGovernanceEnabled: true, }, }) .instruction(); @@ -617,7 +616,7 @@ export default async function suite() { const storedDao2 = await this.futarchy.getDao(dao); assert.equal(storedDao2.passThresholdBps, 500); - assert.isTrue(storedDao2.isOptimisticGovernanceEnabled); + assert.isFalse(storedDao2.isOptimisticGovernanceEnabled); const storedMeta = await this.getMint(META); diff --git a/tests/liquidation/unit/refund.test.ts b/tests/liquidation/unit/refund.test.ts index d5330373..9ef7157b 100644 --- a/tests/liquidation/unit/refund.test.ts +++ b/tests/liquidation/unit/refund.test.ts @@ -4,7 +4,12 @@ import { PublicKey, ComputeBudgetProgram, SystemProgram, + Transaction, } from "@solana/web3.js"; +import { + createMintToInstruction, + getAssociatedTokenAddressSync, +} from "@solana/spl-token"; import { assert } from "chai"; import { expectError } from "../../utils.js"; import { @@ -146,13 +151,21 @@ export default function suite() { assert.equal(record.baseBurned.toString(), "500000000"); assert.equal(record.quoteRefunded.toString(), "250000000"); - // Mint 500 more tokens - await this.mintTo( - baseMint, - recipient.publicKey, - baseMintAuthority, - 500_000_000, + // Mint 500 more tokens. The compute-unit price makes this transaction's + // hash differ from the first mint. + const mintTx = new Transaction().add( + ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), + createMintToInstruction( + baseMint, + getAssociatedTokenAddressSync(baseMint, recipient.publicKey, true), + baseMintAuthority.publicKey, + 500_000_000, + ), ); + [mintTx.recentBlockhash] = await this.banksClient.getLatestBlockhash(); + mintTx.feePayer = this.payer.publicKey; + mintTx.sign(this.payer, baseMintAuthority); + await this.banksClient.processTransaction(mintTx); // Second refund: burns remaining 500, gets 250 more await liquidationClient 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); }