diff --git a/programs/futarchy/src/error.rs b/programs/futarchy/src/error.rs index 8af623e9..88caafe4 100644 --- a/programs/futarchy/src/error.rs +++ b/programs/futarchy/src/error.rs @@ -152,4 +152,14 @@ pub enum FutarchyError { TeamSponsorshipForbidden, #[msg("Squads proposal must be in Approved status to be cancelled")] SquadsProposalNotApproved, + #[msg("This DAO has not opted into typed proposals")] + TypedProposalsDisabled, + #[msg("Typed proposals cannot be disabled")] + TypedProposalsCannotBeDisabled, + #[msg("Address lookup tables referenced by the vault transaction must be frozen")] + UnfrozenAddressLookupTable, + #[msg("Lookup table accounts must match the vault transaction's address table lookups")] + InvalidAddressLookupTable, + #[msg("Expected the proposal's Squads vault transaction as the first launch account")] + InvalidSquadsVaultTransaction, } diff --git a/programs/futarchy/src/events.rs b/programs/futarchy/src/events.rs index 22eb8e04..39531788 100644 --- a/programs/futarchy/src/events.rs +++ b/programs/futarchy/src/events.rs @@ -53,6 +53,7 @@ pub struct InitializeDaoEvent { pub squads_multisig_vault: Pubkey, pub team_sponsored_pass_threshold_bps: i16, pub team_address: Pubkey, + pub typed_proposals_enabled: bool, } #[event] @@ -70,6 +71,7 @@ pub struct UpdateDaoEvent { pub team_sponsored_pass_threshold_bps: i16, pub team_address: Pubkey, pub is_optimistic_governance_enabled: bool, + pub typed_proposals_enabled: bool, } #[event] @@ -116,6 +118,10 @@ pub struct LaunchProposalEvent { pub timestamp_enqueued: i64, pub total_staked: u64, pub post_amm_state: FutarchyAmm, + /// The terms the market opened with, as written by launch. + pub duration_in_seconds: u32, + pub pass_threshold_bps: i16, + pub is_team_sponsored: bool, } #[event] diff --git a/programs/futarchy/src/instructions/admin_update_proposal_params.rs b/programs/futarchy/src/instructions/admin_update_proposal_params.rs index a7dd449c..d3974d7a 100644 --- a/programs/futarchy/src/instructions/admin_update_proposal_params.rs +++ b/programs/futarchy/src/instructions/admin_update_proposal_params.rs @@ -55,7 +55,10 @@ impl AdminUpdateProposalParams<'_> { // The same comparison `launch_proposal` makes require_gt!( duration_in_seconds, - self.proposal.action.params().twap_start_delay_seconds, + self.proposal + .action + .params_for(&self.dao, false) + .twap_start_delay_seconds, FutarchyError::ProposalDurationTooShort ); } @@ -91,6 +94,11 @@ impl AdminUpdateProposalParams<'_> { proposal.pass_threshold_bps = pass_threshold_bps; } + // `launch_proposal` keeps these values instead of writing its own. + // It is assumed that a change to the proposal params applies to both, + // even when only one is changed. + proposal.params_overridden = true; + dao.seq_num += 1; let clock = Clock::get()?; diff --git a/programs/futarchy/src/instructions/finalize_proposal.rs b/programs/futarchy/src/instructions/finalize_proposal.rs index 8c231c72..65e84b03 100644 --- a/programs/futarchy/src/instructions/finalize_proposal.rs +++ b/programs/futarchy/src/instructions/finalize_proposal.rs @@ -121,6 +121,9 @@ impl FinalizeProposal<'_> { let clock = Clock::get()?; + // Read before a passed takeover moves the team below. + let is_team_sponsored = proposal.is_sponsored_by(dao.team_address); + let calculate_twap = |amm: &Pool| -> Result { let twap_start_timestamp = amm.oracle.created_at_timestamp + amm.oracle.start_delay_seconds as i64; @@ -165,7 +168,6 @@ impl FinalizeProposal<'_> { proposal.state = new_proposal_state; // We want to prevent certain proposals from being retried immediately after failure. - // This is to prevent abuse of the system, given that the council can't veto the proposal. if new_proposal_state == ProposalState::Failed { match proposal.action { ProposalAction::HostileTakeover { .. } => { @@ -178,26 +180,37 @@ 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. + // Hostile actions that change the DAO itself land said changes immediately. if new_proposal_state == ProposalState::Passed { - if let ProposalAction::HostileLiquidate { liquidator } = &proposal.action { - dao.liquidator = Some(*liquidator); - - // The spending limit must be zeroed so that the estate can be swept. - // Otherwise a still-live limit member could drain the estate. - if dao.initial_spending_limit.is_some() { - dao.initial_spending_limit = None; - dao.spending_limit_dirty = true; + match &proposal.action { + // This write can only occur once, as a liquidated DAO can't start another proposal. + ProposalAction::HostileLiquidate { liquidator } => { + dao.liquidator = Some(*liquidator); + + // The spending limit must be zeroed so that the estate can be swept. + dao.set_spending_limit(None); } + // The team moves at once, so the outgoing team's sponsorships stop counting. + ProposalAction::HostileTakeover { + new_team_address, + spending_limit_action, + } => { + dao.team_address = *new_team_address; + + match spending_limit_action { + SpendingLimitAction::Keep => {} + SpendingLimitAction::Remove => dao.set_spending_limit(None), + SpendingLimitAction::Set(config) => { + dao.set_spending_limit(Some(config.clone())) + } + } + } + _ => {} } } - // 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 - // retries of a rejected proposal. `admin_cancel_proposal` writes no - // timestamp, so a council block can't lock buybacks out for a quarter. + // The buyback cooldown stamps on either outcome, so back-to-back + // buybacks can't drain the treasury. if matches!(proposal.action, ProposalAction::BuybackToken { .. }) { dao.last_buyback_finalized_at = clock.unix_timestamp; } @@ -321,7 +334,7 @@ impl FinalizeProposal<'_> { squads_proposal: squads_proposal.key(), squads_multisig: dao.squads_multisig, post_amm_state: dao.amm.clone(), - is_team_sponsored: proposal.is_sponsored_by(dao.team_address), + is_team_sponsored, }); Ok(()) diff --git a/programs/futarchy/src/instructions/initialize_dao.rs b/programs/futarchy/src/instructions/initialize_dao.rs index 9c8161a1..df921581 100644 --- a/programs/futarchy/src/instructions/initialize_dao.rs +++ b/programs/futarchy/src/instructions/initialize_dao.rs @@ -225,6 +225,7 @@ impl InitializeDao<'_> { last_failed_liquidation_at: 0, spending_limit_dirty: false, last_buyback_finalized_at: 0, + typed_proposals_enabled: true, }); dao.invariant()?; @@ -250,6 +251,7 @@ impl InitializeDao<'_> { squads_multisig_vault: dao.squads_multisig_vault, team_sponsored_pass_threshold_bps: dao.team_sponsored_pass_threshold_bps, team_address: dao.team_address, + typed_proposals_enabled: dao.typed_proposals_enabled, }); Ok(()) diff --git a/programs/futarchy/src/instructions/initialize_hostile_takeover_proposal.rs b/programs/futarchy/src/instructions/initialize_hostile_takeover_proposal.rs index c04e838d..ed7d3996 100644 --- a/programs/futarchy/src/instructions/initialize_hostile_takeover_proposal.rs +++ b/programs/futarchy/src/instructions/initialize_hostile_takeover_proposal.rs @@ -1,6 +1,3 @@ -use anchor_lang::solana_program::instruction::Instruction; -use anchor_lang::InstructionData; - use super::*; #[derive(AnchorSerialize, AnchorDeserialize, Clone)] @@ -17,6 +14,11 @@ pub struct InitializeHostileTakeoverProposal<'info> { impl InitializeHostileTakeoverProposal<'_> { pub fn validate(&self, args: &InitializeHostileTakeoverProposalArgs) -> Result<()> { + // Hostile takeovers are switched off in production for now. + if cfg!(feature = "production") { + return err!(FutarchyError::InvalidProposalKind); + } + self.typed_initialize_accounts.validate()?; require_keys_neq!( @@ -35,66 +37,22 @@ impl InitializeHostileTakeoverProposal<'_> { pub fn handle(ctx: Context, args: InitializeHostileTakeoverProposalArgs) -> Result<()> { let typed_initialize_accounts = &mut ctx.accounts.typed_initialize_accounts; - let (event_authority, _) = - Pubkey::find_program_address(&[b"__event_authority"], &crate::ID); - - let update_dao_ix = Instruction { - program_id: crate::ID, - accounts: crate::accounts::UpdateDao { - dao: typed_initialize_accounts.dao.key(), - squads_multisig_vault: typed_initialize_accounts.dao.squads_multisig_vault, - event_authority, - program: crate::ID, - } - .to_account_metas(None), - data: crate::instruction::UpdateDao { - dao_params: UpdateDaoParams { - pass_threshold_bps: None, - seconds_per_proposal: None, - twap_initial_observation: None, - twap_max_observation_change_per_update: None, - twap_start_delay_seconds: None, - min_quote_futarchic_liquidity: None, - min_base_futarchic_liquidity: None, - base_to_stake: None, - team_sponsored_pass_threshold_bps: None, - team_address: Some(args.new_team_address), - }, - } - .data(), + // The payload is only a memo: finalize_proposal applies a passed takeover itself. + let spending_limit = match &args.spending_limit_action { + SpendingLimitAction::Keep => "keep".to_string(), + SpendingLimitAction::Remove => "remove".to_string(), + SpendingLimitAction::Set(limit) => format!("set:{}", limit.amount_per_month), }; - - let mut instructions = vec![update_dao_ix]; - - // `Keep` bakes no second instruction; `Remove`/`Set` append a - // vault-signed set_spending_limit carrying the declared end state. - let spending_limit_config = match &args.spending_limit_action { - SpendingLimitAction::Keep => None, - SpendingLimitAction::Remove => Some(SetSpendingLimitArgs { config: None }), - SpendingLimitAction::Set(limit) => Some(SetSpendingLimitArgs { - config: Some(limit.clone()), - }), - }; - - if let Some(set_spending_limit_args) = spending_limit_config { - instructions.push(Instruction { - program_id: crate::ID, - accounts: crate::accounts::SetSpendingLimit { - dao: typed_initialize_accounts.dao.key(), - squads_multisig_vault: typed_initialize_accounts.dao.squads_multisig_vault, - event_authority, - program: crate::ID, - } - .to_account_metas(None), - data: crate::instruction::SetSpendingLimit { - args: set_spending_limit_args, - } - .data(), - }); - } + let memo = format!( + "metadao-takeover/1 proposal={} new_team={} spending_limit={}", + typed_initialize_accounts.proposal.key(), + args.new_team_address, + spending_limit, + ); + let memo_ix = spl_memo::build_memo(memo.as_bytes(), &[]); let event = typed_initialize_accounts.initialize_proposal( - &instructions, + &[memo_ix], ProposalAction::HostileTakeover { new_team_address: args.new_team_address, spending_limit_action: args.spending_limit_action, diff --git a/programs/futarchy/src/instructions/initialize_proposal.rs b/programs/futarchy/src/instructions/initialize_proposal.rs index 201e305d..ad6359fc 100644 --- a/programs/futarchy/src/instructions/initialize_proposal.rs +++ b/programs/futarchy/src/instructions/initialize_proposal.rs @@ -13,6 +13,17 @@ pub struct InitializeProposal<'info> { pub proposal: Box>, pub squads_proposal: Box>, pub squads_multisig: Box>, + #[account( + seeds = [ + squads_multisig_program::SEED_PREFIX, + squads_multisig.key().as_ref(), + squads_multisig_program::SEED_TRANSACTION, + squads_proposal.transaction_index.to_le_bytes().as_ref(), + ], + bump, + seeds::program = squads_multisig_program::ID, + )] + pub squads_vault_transaction: Box>, #[account(mut, has_one = squads_multisig)] pub dao: Box>, #[account( @@ -35,8 +46,8 @@ pub struct InitializeProposal<'info> { pub system_program: Program<'info, System>, } -impl InitializeProposal<'_> { - pub fn validate(&self) -> Result<()> { +impl<'info> InitializeProposal<'info> { + pub fn validate(&self, remaining_accounts: &'info [AccountInfo<'info>]) -> Result<()> { require!(self.dao.liquidator.is_none(), FutarchyError::DaoLiquidated); require_eq!( @@ -61,6 +72,10 @@ impl InitializeProposal<'_> { self.squads_multisig.stale_transaction_index ); + // Every lookup table the payload resolves through must be frozen, so + // the accounts the market prices are the ones that execute. + validate_address_lookup_tables(&self.squads_vault_transaction.message, remaining_accounts)?; + // Should never be the case because the oracle is the proposal account, and you can't re-initialize a proposal assert!(!self.question.is_resolved()); @@ -75,6 +90,7 @@ impl InitializeProposal<'_> { proposal, squads_proposal, squads_multisig: _, + squads_vault_transaction: _, dao, proposer, payer: _, @@ -88,7 +104,8 @@ impl InitializeProposal<'_> { dao.proposal_count += 1; let action = ProposalAction::ExecuteArbitrary; - let params = action.params(); + // A preview: launch writes the terms from the configuration in force later. + let params = action.params_for(dao, false); proposal.set_inner(Proposal { number: dao.proposal_count, @@ -110,6 +127,7 @@ impl InitializeProposal<'_> { pass_threshold_bps: params.pass_threshold_bps, council_can_block: params.council_can_block, action, + params_overridden: false, }); dao.seq_num += 1; diff --git a/programs/futarchy/src/instructions/launch_proposal.rs b/programs/futarchy/src/instructions/launch_proposal.rs index a63ad73e..43da59bf 100644 --- a/programs/futarchy/src/instructions/launch_proposal.rs +++ b/programs/futarchy/src/instructions/launch_proposal.rs @@ -64,7 +64,16 @@ impl<'info> LaunchProposal<'info> { // Kind gates, checked at launch rather than create so that pre-created // drafts can't bypass them - let params = self.proposal.action.params(); + + // Typed kinds need the DAO typed proposals enabled. + if !matches!(self.proposal.action, ProposalAction::ExecuteArbitrary) { + require!( + self.dao.typed_proposals_enabled, + FutarchyError::TypedProposalsDisabled + ); + } + + let params = self.proposal.launch_params(&self.dao); if params.team_sponsorship_policy == TeamSponsorshipPolicy::Required { require!(is_team_sponsored, FutarchyError::ProposalNotTeamSponsored); @@ -74,7 +83,7 @@ impl<'info> LaunchProposal<'info> { // with an empty aggregator, and `MarketsTooYoung` blocks finalize. // Strict, because finalize needs the last update past that boundary. require_gt!( - self.proposal.duration_in_seconds, + params.duration_seconds, params.twap_start_delay_seconds, FutarchyError::ProposalDurationTooShort ); @@ -109,9 +118,11 @@ impl<'info> LaunchProposal<'info> { self.squads_multisig.stale_transaction_index ); - self.proposal - .action - .verify_launch_accounts(&self.dao, remaining_accounts)?; + self.proposal.action.verify_launch_accounts( + &self.dao, + self.squads_proposal.transaction_index, + remaining_accounts, + )?; Ok(()) } @@ -176,8 +187,10 @@ impl<'info> LaunchProposal<'info> { let clock = Clock::get()?; - // Per-kind, not per-DAO: `dao.twap_start_delay_seconds` is vestigial. - let twap_start_delay_seconds = proposal.action.params().twap_start_delay_seconds; + // Write the terms in force now; the draft only carried a preview. + let params = proposal.launch_params(dao); + proposal.duration_in_seconds = params.duration_seconds; + proposal.pass_threshold_bps = params.pass_threshold_bps; dao.amm.state = PoolState::Futarchy { spot, @@ -190,7 +203,7 @@ impl<'info> LaunchProposal<'info> { clock.unix_timestamp, dao.twap_initial_observation, dao.twap_max_observation_change_per_update, - twap_start_delay_seconds, + params.twap_start_delay_seconds, ), }, fail: Pool { @@ -202,7 +215,7 @@ impl<'info> LaunchProposal<'info> { clock.unix_timestamp, dao.twap_initial_observation, dao.twap_max_observation_change_per_update, - twap_start_delay_seconds, + params.twap_start_delay_seconds, ), }, }; @@ -220,6 +233,9 @@ impl<'info> LaunchProposal<'info> { timestamp_enqueued: proposal.timestamp_enqueued, total_staked, post_amm_state: dao.amm.clone(), + duration_in_seconds: proposal.duration_in_seconds, + pass_threshold_bps: proposal.pass_threshold_bps, + is_team_sponsored: proposal.is_sponsored_by(dao.team_address), }); Ok(()) diff --git a/programs/futarchy/src/instructions/resize_dao.rs b/programs/futarchy/src/instructions/resize_dao.rs index f97fc851..8ec4ba4b 100644 --- a/programs/futarchy/src/instructions/resize_dao.rs +++ b/programs/futarchy/src/instructions/resize_dao.rs @@ -26,8 +26,9 @@ impl ResizeDao<'_> { require_eq!(is_discriminator_correct, true); 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; + // 59 bytes: 33 (Option liquidator) + 8 (i64) + 8 (i64) + 1 (bool) + // + 8 (i64) + 1 (bool typed_proposals_enabled) + const BEFORE_REALLOC_SIZE: usize = AFTER_REALLOC_SIZE - 59; if dao.data_len() != BEFORE_REALLOC_SIZE { // already realloced @@ -75,8 +76,9 @@ impl ResizeDao<'_> { twap_max_observation_change_per_update: old_dao_data .twap_max_observation_change_per_update, twap_start_delay_seconds: old_dao_data.twap_start_delay_seconds, - min_quote_futarchic_liquidity: old_dao_data.min_quote_futarchic_liquidity, - min_base_futarchic_liquidity: old_dao_data.min_base_futarchic_liquidity, + // A zero minimum fails `Dao::invariant`; 1 is the launchpads' value. + min_quote_futarchic_liquidity: old_dao_data.min_quote_futarchic_liquidity.max(1), + min_base_futarchic_liquidity: old_dao_data.min_base_futarchic_liquidity.max(1), base_to_stake: old_dao_data.base_to_stake, seq_num: old_dao_data.seq_num, initial_spending_limit: live_spending_limit, @@ -92,6 +94,7 @@ impl ResizeDao<'_> { last_failed_liquidation_at: 0, spending_limit_dirty: false, last_buyback_finalized_at: 0, + typed_proposals_enabled: false, }; dao.realloc(AFTER_REALLOC_SIZE, true)?; diff --git a/programs/futarchy/src/instructions/resize_proposal.rs b/programs/futarchy/src/instructions/resize_proposal.rs index 7e3d7350..9b9eef67 100644 --- a/programs/futarchy/src/instructions/resize_proposal.rs +++ b/programs/futarchy/src/instructions/resize_proposal.rs @@ -8,7 +8,7 @@ pub struct ResizeProposal<'info> { #[account(mut)] pub proposal: UncheckedAccount<'info>, /// The proposal's DAO, checked against the deserialized proposal in the - /// handler. Must already be migrated to the new layout (crank DAOs first). + /// handler. pub dao: Account<'info, Dao>, #[account(mut)] pub payer: Signer<'info>, @@ -26,10 +26,10 @@ impl ResizeProposal<'_> { require_eq!(is_discriminator_correct, true); const AFTER_REALLOC_SIZE: usize = Proposal::MIGRATED_SIZE; - // 401 bytes: 32 (Option sponsored_by replacing the bool) + // 402 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 - 401; + // + 366 (ProposalAction) + 1 (bool params_overridden) + const BEFORE_REALLOC_SIZE: usize = AFTER_REALLOC_SIZE - 402; if proposal.data_len() != BEFORE_REALLOC_SIZE { // already realloced @@ -44,11 +44,10 @@ impl ResizeProposal<'_> { 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. + // Drafts preview what launch would write today; launched proposals keep their rules. let (pass_threshold_bps, duration_in_seconds) = if matches!(old_proposal_data.state, ProposalState::Draft { .. }) { - let params = action.params(); + let params = action.params_for(dao, old_proposal_data.is_team_sponsored); (params.pass_threshold_bps, params.duration_seconds) } else { let pass_threshold_bps = if old_proposal_data.is_team_sponsored { @@ -87,6 +86,7 @@ impl ResizeProposal<'_> { pass_threshold_bps, council_can_block: true, action, + params_overridden: false, }; 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 cc5144e2..9b9e600f 100644 --- a/programs/futarchy/src/instructions/set_spending_limit.rs +++ b/programs/futarchy/src/instructions/set_spending_limit.rs @@ -16,6 +16,9 @@ pub struct SetSpendingLimit<'info> { impl SetSpendingLimit<'_> { pub fn validate(&self, args: &SetSpendingLimitArgs) -> Result<()> { + // Ensure the DAO is migrated. + Dao::assert_migrated(&self.dao.to_account_info())?; + // Prevent config changes during active futarchy markets if !matches!(self.dao.amm.state, PoolState::Spot { .. }) { return Err(FutarchyError::PoolNotInSpotState.into()); @@ -33,8 +36,7 @@ impl SetSpendingLimit<'_> { pub fn handle(ctx: Context, args: SetSpendingLimitArgs) -> Result<()> { let dao = &mut ctx.accounts.dao; - dao.initial_spending_limit = args.config; - dao.spending_limit_dirty = true; + dao.set_spending_limit(args.config); dao.seq_num += 1; let clock = Clock::get()?; diff --git a/programs/futarchy/src/instructions/typed_initialize.rs b/programs/futarchy/src/instructions/typed_initialize.rs index ff9fb74e..8d618389 100644 --- a/programs/futarchy/src/instructions/typed_initialize.rs +++ b/programs/futarchy/src/instructions/typed_initialize.rs @@ -59,6 +59,12 @@ impl TypedInitializeAccounts<'_> { pub fn validate(&self) -> Result<()> { require!(self.dao.liquidator.is_none(), FutarchyError::DaoLiquidated); + // The catalog is opt-in per DAO. + require!( + self.dao.typed_proposals_enabled, + FutarchyError::TypedProposalsDisabled + ); + require_eq!( self.question.num_outcomes(), 2, @@ -150,6 +156,7 @@ impl TypedInitializeAccounts<'_> { pass_threshold_bps: params.pass_threshold_bps, council_can_block: params.council_can_block, action, + params_overridden: false, }; self.proposal.set_inner(proposal); diff --git a/programs/futarchy/src/instructions/update_dao.rs b/programs/futarchy/src/instructions/update_dao.rs index 4acacf08..164ebfc7 100644 --- a/programs/futarchy/src/instructions/update_dao.rs +++ b/programs/futarchy/src/instructions/update_dao.rs @@ -12,6 +12,12 @@ pub struct UpdateDaoParams { pub base_to_stake: Option, pub team_sponsored_pass_threshold_bps: Option, pub team_address: Option, + /// Ignored. The retired optimistic-governance flag, kept in place so + /// payloads encoded before the upgrade are refused, not reinterpreted. + pub _deprecated_is_optimistic_governance_enabled: Option, + /// `Some(true)` turns typed proposals on for this DAO. `None` leaves them + /// as they are. `Some(false)` is refused: there is no way to turn them off. + pub typed_proposals_enabled: Option, } #[derive(Accounts)] @@ -23,7 +29,10 @@ pub struct UpdateDao<'info> { } impl UpdateDao<'_> { - pub fn validate(&self) -> Result<()> { + pub fn validate(&self, dao_params: &UpdateDaoParams) -> Result<()> { + // Ensure the DAO is migrated. + Dao::assert_migrated(&self.dao.to_account_info())?; + require!(self.dao.liquidator.is_none(), FutarchyError::DaoLiquidated); // Prevent parameter updates during active futarchy markets @@ -31,6 +40,12 @@ impl UpdateDao<'_> { return Err(FutarchyError::PoolNotInSpotState.into()); } + // Typed proposals only turn on. + require!( + dao_params.typed_proposals_enabled != Some(false), + FutarchyError::TypedProposalsCannotBeDisabled + ); + Ok(()) } @@ -82,6 +97,9 @@ impl UpdateDao<'_> { last_failed_liquidation_at: dao.last_failed_liquidation_at, spending_limit_dirty: dao.spending_limit_dirty, last_buyback_finalized_at: dao.last_buyback_finalized_at, + typed_proposals_enabled: dao_params + .typed_proposals_enabled + .unwrap_or(dao.typed_proposals_enabled), }); dao.seq_num += 1; @@ -103,6 +121,7 @@ impl UpdateDao<'_> { team_sponsored_pass_threshold_bps: dao.team_sponsored_pass_threshold_bps, team_address: dao.team_address, is_optimistic_governance_enabled: dao.is_optimistic_governance_enabled, + typed_proposals_enabled: dao.typed_proposals_enabled, }); Ok(()) diff --git a/programs/futarchy/src/lib.rs b/programs/futarchy/src/lib.rs index 561fba53..1ab15ee3 100644 --- a/programs/futarchy/src/lib.rs +++ b/programs/futarchy/src/lib.rs @@ -25,7 +25,7 @@ security_txt! { project_url: "https://metadao.fi", contacts: "telegram:metaproph3t,telegram:kollan_house", source_code: "https://github.com/metaDAOproject/programs", - source_release: "v0.6.1", + source_release: "v0.6.2", policy: "The market will decide whether we pay a bug bounty.", acknowledgements: "DCF = (CF1 / (1 + r)^1) + (CF2 / (1 + r)^2) + ... (CFn / (1 + r)^n)" } @@ -69,8 +69,10 @@ pub mod futarchy { InitializeDao::handle(ctx, params) } - #[access_control(ctx.accounts.validate())] - pub fn initialize_proposal(ctx: Context) -> Result<()> { + #[access_control(ctx.accounts.validate(ctx.remaining_accounts))] + pub fn initialize_proposal<'info>( + ctx: Context<'_, '_, 'info, 'info, InitializeProposal<'info>>, + ) -> Result<()> { InitializeProposal::handle(ctx) } @@ -150,7 +152,7 @@ pub mod futarchy { FinalizeProposal::handle(ctx) } - #[access_control(ctx.accounts.validate())] + #[access_control(ctx.accounts.validate(&dao_params))] pub fn update_dao(ctx: Context, dao_params: UpdateDaoParams) -> Result<()> { UpdateDao::handle(ctx, dao_params) } diff --git a/programs/futarchy/src/squads.rs b/programs/futarchy/src/squads.rs index 9a56b9a2..96a009e4 100644 --- a/programs/futarchy/src/squads.rs +++ b/programs/futarchy/src/squads.rs @@ -1,9 +1,70 @@ use anchor_lang::prelude::*; +use anchor_lang::solana_program::address_lookup_table::{self, state::AddressLookupTable}; use std::collections::BTreeMap; use crate::FutarchyError; +/// Validates that every address lookup table a vault transaction message +/// references is frozen (`authority` permanently `None`, so its contents can +/// never change) and already holds every index the message references. +/// `lookup_table_accounts` must hold exactly one account per +/// `message.address_table_lookups` entry, in the same order, which is the +/// convention Squads' own `vault_transaction_execute` uses. +pub fn validate_address_lookup_tables<'info>( + message: &squads_multisig_program::VaultTransactionMessage, + lookup_table_accounts: &[AccountInfo<'info>], +) -> Result<()> { + require_eq!( + lookup_table_accounts.len(), + message.address_table_lookups.len(), + FutarchyError::InvalidAddressLookupTable + ); + + for (lookup, lookup_table_account) in message + .address_table_lookups + .iter() + .zip(lookup_table_accounts.iter()) + { + require_keys_eq!( + *lookup_table_account.key, + lookup.account_key, + FutarchyError::InvalidAddressLookupTable + ); + require_keys_eq!( + *lookup_table_account.owner, + address_lookup_table::program::ID, + FutarchyError::InvalidAddressLookupTable + ); + + let lookup_table_data = lookup_table_account.try_borrow_data()?; + let lookup_table = AddressLookupTable::deserialize(&lookup_table_data) + .map_err(|_| FutarchyError::InvalidAddressLookupTable)?; + + require!( + lookup_table.meta.authority.is_none(), + FutarchyError::UnfrozenAddressLookupTable + ); + + // A frozen table's length is final, so an index past it could never + // be filled and the proposal could pass its market yet never execute. + if let Some(max_index) = lookup + .writable_indexes + .iter() + .chain(lookup.readonly_indexes.iter()) + .max() + { + require_gt!( + lookup_table.addresses.len(), + usize::from(*max_index), + FutarchyError::InvalidAddressLookupTable + ); + } + } + + Ok(()) +} + /// Compiles a Solana instruction into a Squads TransactionMessage format. /// This is necessary because Solana's Message::serialize() uses a different header format /// (num_readonly_signed_accounts, num_readonly_unsigned_accounts) than Squads expects diff --git a/programs/futarchy/src/state/dao.rs b/programs/futarchy/src/state/dao.rs index ffe80a89..7d7be264 100644 --- a/programs/futarchy/src/state/dao.rs +++ b/programs/futarchy/src/state/dao.rs @@ -84,6 +84,8 @@ pub struct Dao { pub spending_limit_dirty: bool, /// Unix time of the last buyback finalization. 0 = never. pub last_buyback_finalized_at: i64, + /// Whether the DAO runs on the mini-instructions catalog. Never turns off. + pub typed_proposals_enabled: bool, } #[derive(AnchorSerialize, AnchorDeserialize, Debug, Clone, PartialEq, Eq, InitSpace)] @@ -145,6 +147,12 @@ impl Dao { Ok(()) } + /// Replaces the spending-limit record and flags it for the next sync. + pub fn set_spending_limit(&mut self, config: Option) { + self.initial_spending_limit = config; + self.spending_limit_dirty = true; + } + pub fn invariant(&self) -> Result<()> { require_gte!( self.seconds_per_proposal, diff --git a/programs/futarchy/src/state/proposal.rs b/programs/futarchy/src/state/proposal.rs index 9e156220..671eabb8 100644 --- a/programs/futarchy/src/state/proposal.rs +++ b/programs/futarchy/src/state/proposal.rs @@ -41,12 +41,14 @@ pub struct Proposal { pub fail_quote_mint: Pubkey, /// 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. pub council_can_block: bool, /// The typed action parameters. pub action: ProposalAction, + /// Set by `admin_update_proposal_params`. `launch_proposal` then leaves the + /// duration and threshold alone. + pub params_overridden: bool, } impl Proposal { @@ -55,6 +57,23 @@ impl Proposal { self.sponsored_by == Some(team_address) } + /// The parameters this proposal launches under. + pub fn launch_params(&self, dao: &Dao) -> InstructionParams { + let params = self + .action + .params_for(dao, self.is_sponsored_by(dao.team_address)); + + if !self.params_overridden { + return params; + } + + InstructionParams { + duration_seconds: self.duration_in_seconds, + pass_threshold_bps: self.pass_threshold_bps, + ..params + } + } + /// A migrated `Proposal` account is exactly this long. pub const MIGRATED_SIZE: usize = Proposal::INIT_SPACE + 8; diff --git a/programs/futarchy/src/state/proposal_action.rs b/programs/futarchy/src/state/proposal_action.rs index 06757916..2750b85b 100644 --- a/programs/futarchy/src/state/proposal_action.rs +++ b/programs/futarchy/src/state/proposal_action.rs @@ -56,6 +56,8 @@ pub enum ProposalAction { }, ExecuteArbitrary, HostileTakeover { + /// The team to install. Launch requires it to still differ from the + /// DAO's team. new_team_address: Pubkey, spending_limit_action: SpendingLimitAction, }, @@ -143,14 +145,42 @@ impl ProposalAction { } } + /// The parameters a proposal of this kind runs under for `dao`. Plain + /// proposals of a DAO that has not opted in run under the DAO's own + /// configuration, where sponsorship by the current team selects the + /// team-sponsored threshold; everything else is the catalog. + pub fn params_for(&self, dao: &Dao, is_team_sponsored: bool) -> InstructionParams { + let follows_dao_config = + matches!(self, ProposalAction::ExecuteArbitrary) && !dao.typed_proposals_enabled; + + if !follows_dao_config { + return self.params(); + } + + InstructionParams { + duration_seconds: dao.seconds_per_proposal, + pass_threshold_bps: if is_team_sponsored { + dao.team_sponsored_pass_threshold_bps + } else { + dao.pass_threshold_bps as i16 + }, + twap_start_delay_seconds: dao.twap_start_delay_seconds, + ..self.params() + } + } + /// Per-kind launch gates over caller-supplied accounts, hooked in by /// `launch_proposal`. Kinds with no account gate require an empty list. pub fn verify_launch_accounts<'info>( &self, dao: &Account<'info, Dao>, + squads_transaction_index: u64, accounts: &'info [AccountInfo<'info>], ) -> Result<()> { match self { + ProposalAction::ExecuteArbitrary => { + verify_execute_arbitrary_launch(dao, squads_transaction_index, accounts) + } ProposalAction::BuybackToken { quote_amount, .. } => { verify_buyback_treasury_cap(*quote_amount, dao, accounts) } @@ -158,6 +188,9 @@ impl ProposalAction { amount, team_address, } => verify_large_spend_launch(*amount, *team_address, dao, accounts), + ProposalAction::HostileTakeover { + new_team_address, .. + } => verify_hostile_takeover_launch(*new_team_address, dao, accounts), _ => { require_eq!(accounts.len(), 0, FutarchyError::UnexpectedLaunchAccounts); Ok(()) @@ -166,6 +199,39 @@ impl ProposalAction { } } +/// The execute-arbitrary launch gate: the proposal's Squads vault transaction +/// followed by every lookup table its message references, which must be +/// frozen. Only this kind carries a caller-built payload; typed kinds compile +/// theirs on-chain. Repeated at launch for drafts created before this check. +fn verify_execute_arbitrary_launch<'info>( + dao: &Dao, + squads_transaction_index: u64, + accounts: &'info [AccountInfo<'info>], +) -> Result<()> { + let Some((vault_transaction_account, lookup_table_accounts)) = accounts.split_first() else { + return err!(FutarchyError::InvalidSquadsVaultTransaction); + }; + + let (vault_transaction_pda, _) = Pubkey::find_program_address( + &[ + squads_multisig_program::SEED_PREFIX, + dao.squads_multisig.as_ref(), + squads_multisig_program::SEED_TRANSACTION, + &squads_transaction_index.to_le_bytes(), + ], + &squads_multisig_program::ID, + ); + require_keys_eq!( + vault_transaction_account.key(), + vault_transaction_pda, + FutarchyError::InvalidSquadsVaultTransaction + ); + let vault_transaction = + Account::::try_from(vault_transaction_account)?; + + validate_address_lookup_tables(&vault_transaction.message, lookup_table_accounts) +} + /// The large-spend launch gate: no extra accounts, and the create-time checks /// re-run against current state. fn verify_large_spend_launch( @@ -187,6 +253,24 @@ fn verify_large_spend_launch( Ok(()) } +/// The hostile-takeover launch gate: no extra accounts, and the create-time +/// team check re-runs against current state. +fn verify_hostile_takeover_launch( + new_team_address: Pubkey, + dao: &Dao, + accounts: &[AccountInfo], +) -> Result<()> { + require_eq!(accounts.len(), 0, FutarchyError::UnexpectedLaunchAccounts); + + require_keys_neq!( + new_team_address, + dao.team_address, + FutarchyError::InvalidTeamAddress + ); + + 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<()> { diff --git a/scripts/utils/daoActions.ts b/scripts/utils/daoActions.ts index 193bcf4a..183f0f19 100644 --- a/scripts/utils/daoActions.ts +++ b/scripts/utils/daoActions.ts @@ -85,7 +85,7 @@ const EMPTY_UPDATE_DAO_PARAMS: UpdateDaoParams = { baseToStake: null, teamSponsoredPassThresholdBps: null, teamAddress: null, - isOptimisticGovernanceEnabled: null, + typedProposalsEnabled: null, }; // Updates the given DAO config fields, leaving the omitted ones unchanged diff --git a/scripts/utils/futarchyProposal.ts b/scripts/utils/futarchyProposal.ts index 974a93ec..97097da0 100644 --- a/scripts/utils/futarchyProposal.ts +++ b/scripts/utils/futarchyProposal.ts @@ -92,6 +92,8 @@ export const initializeFutarchyProposal = async ({ dao, ); const vaultClient = futarchy.vaultClient; + const { squadsTransaction, lookupTables } = + await futarchy.getSquadsVaultTransactionAccounts(squadsProposal); console.log("Squads proposal:", squadsProposal.toBase58()); console.log("Proposal:", proposal.toBase58()); @@ -149,6 +151,8 @@ export const initializeFutarchyProposal = async ({ daoAccount.baseMint, daoAccount.quoteMint, question, + squadsTransaction, + lookupTables, payer.publicKey, ) .preInstructions([ diff --git a/scripts/v0.6/migrateDaosProposals.ts b/scripts/v0.6/migrateDaosProposals.ts index 301f7858..39b4deb0 100644 --- a/scripts/v0.6/migrateDaosProposals.ts +++ b/scripts/v0.6/migrateDaosProposals.ts @@ -7,7 +7,10 @@ import { TransactionMessage, } from "@solana/web3.js"; import * as anchor from "@coral-xyz/anchor"; -import { FutarchyClient } from "@metadaoproject/programs/futarchy/v0.6"; +import { + FutarchyClient, + getSpendingLimitAddr, +} from "@metadaoproject/programs/futarchy/v0.6"; import dotenv from "dotenv"; import bs58 from "bs58"; @@ -39,7 +42,9 @@ async function main() { const daoDiscriminator = getDiscriminator("Dao"); const proposalDiscriminator = getDiscriminator("Proposal"); - const daoBatchSize = 15; + // resize_dao now also references the DAO's spending limit, two unique + // account keys per instruction, so the same cap as proposals applies. + const daoBatchSize = 10; // Each resize_proposal also references the proposal's dao, so the worst // case is two unique account keys per instruction; 10 keeps the batch under // the transaction size limit. @@ -87,6 +92,7 @@ async function main() { .resizeDao() .accounts({ dao: pubkey, + spendingLimit: getSpendingLimitAddr({ dao: pubkey })[0], payer: payer.publicKey, }) .instruction(); diff --git a/sdk/package.json b/sdk/package.json index b4c2fe3c..0db3e667 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@metadaoproject/programs", - "version": "0.1.1-alpha.2", + "version": "0.1.2-alpha.2", "type": "module", "main": "dist/index.js", "module": "dist/index.js", diff --git a/sdk/src/futarchy/v0.6/FutarchyClient.ts b/sdk/src/futarchy/v0.6/FutarchyClient.ts index 3a48692d..53cae981 100644 --- a/sdk/src/futarchy/v0.6/FutarchyClient.ts +++ b/sdk/src/futarchy/v0.6/FutarchyClient.ts @@ -340,12 +340,19 @@ export class FutarchyClient { return existing.sort((a, b) => a.toBuffer().compare(b.toBuffer())); } + /** + * `squadsTransaction` and `lookupTables` are required for a generic + * (execute-arbitrary) proposal and must be omitted for typed kinds; see + * `getSquadsVaultTransactionAccounts`. `treasuryAccounts` is for buybacks. + */ launchProposalIx({ proposal, dao, baseMint, quoteMint, squadsProposal, + squadsTransaction, + lookupTables = [], treasuryAccounts = [], }: { proposal: PublicKey; @@ -353,6 +360,8 @@ export class FutarchyClient { baseMint: PublicKey; quoteMint: PublicKey; squadsProposal: PublicKey; + squadsTransaction?: PublicKey; + lookupTables?: PublicKey[]; treasuryAccounts?: PublicKey[]; }) { const { @@ -402,7 +411,10 @@ export class FutarchyClient { payer: this.provider.publicKey, }) .remainingAccounts( - treasuryAccounts.map((pubkey) => ({ + [ + ...(squadsTransaction ? [squadsTransaction, ...lookupTables] : []), + ...treasuryAccounts, + ].map((pubkey) => ({ pubkey, isSigner: false, isWritable: false, @@ -664,7 +676,11 @@ export class FutarchyClient { instructions: TransactionInstruction[]; transactionIndex: bigint; payer?: PublicKey; - }): { tx: Transaction; squadsProposal: PublicKey } { + }): { + tx: Transaction; + squadsProposal: PublicKey; + squadsTransaction: PublicKey; + } { const multisigPda = multisig.getMultisigPda({ createKey: dao })[0]; const squadsMultisigVault = multisig.getVaultPda({ multisigPda, @@ -699,10 +715,48 @@ export class FutarchyClient { multisigPda, transactionIndex: transactionIndex, }); + const [squadsTransaction] = multisig.getTransactionPda({ + multisigPda, + index: transactionIndex, + }); const tx = new Transaction().add(vaultTxCreate, proposalCreate); - return { tx, squadsProposal }; + return { tx, squadsProposal, squadsTransaction }; + } + + /** + * Resolves the vault transaction behind a Squads proposal and the lookup + * tables its message references. Both go to `initializeProposalIx` and, for + * a generic proposal, `launchProposalIx`, which check that every table is + * frozen. + */ + async getSquadsVaultTransactionAccounts(squadsProposal: PublicKey): Promise<{ + squadsTransaction: PublicKey; + lookupTables: PublicKey[]; + }> { + const squadsProposalAccount = + await multisig.accounts.Proposal.fromAccountAddress( + this.provider.connection, + squadsProposal, + ); + + const [squadsTransaction] = multisig.getTransactionPda({ + multisigPda: squadsProposalAccount.multisig, + index: BigInt(squadsProposalAccount.transactionIndex.toString()), + }); + + const vaultTransaction = + await multisig.accounts.VaultTransaction.fromAccountAddress( + this.provider.connection, + squadsTransaction, + ); + + const lookupTables = vaultTransaction.message.addressTableLookups.map( + (lookup) => lookup.accountKey, + ); + + return { squadsTransaction, lookupTables }; } async initializeProposal( @@ -736,12 +790,17 @@ export class FutarchyClient { ) .rpc(); + const { squadsTransaction, lookupTables } = + await this.getSquadsVaultTransactionAccounts(squadsProposal); + await this.initializeProposalIx( squadsProposal, dao, storedDao.baseMint, storedDao.quoteMint, question, + squadsTransaction, + lookupTables, ) .preInstructions([ ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), @@ -757,6 +816,8 @@ export class FutarchyClient { baseMint: PublicKey, quoteMint: PublicKey, question: PublicKey, + squadsTransaction: PublicKey, + lookupTables: PublicKey[] = [], proposer: PublicKey = this.provider.publicKey, ) { let [proposal] = getProposalAddr(this.futarchy.programId, squadsProposal); @@ -782,12 +843,20 @@ export class FutarchyClient { question, proposal, squadsProposal, + squadsVaultTransaction: squadsTransaction, dao, baseVault, quoteVault, proposer, squadsMultisig, }) + .remainingAccounts( + lookupTables.map((pubkey) => ({ + pubkey, + isSigner: false, + isWritable: false, + })), + ) .preInstructions([ createAssociatedTokenAccountIdempotentInstruction( this.provider.publicKey, @@ -1163,9 +1232,9 @@ export class FutarchyClient { .signers([PERMISSIONLESS_ACCOUNT]); } - // The payload declares the complete post-takeover regime: update_dao - // re-points the team, and unless the action is `keep`, set_spending_limit - // carries the declared limit end state. + // The proposal's action declares the complete post-takeover regime. A passed + // takeover is applied by finalize_proposal itself; the baked payload is only + // a memo. async initializeHostileTakeoverProposal({ dao, newTeamAddress, @@ -1502,10 +1571,12 @@ export class FutarchyClient { index: 0, })[0]; - return this.futarchy.methods.updateDao(params).accounts({ - dao, - squadsMultisigVault, - }); + return this.futarchy.methods + .updateDao({ ...params, deprecatedIsOptimisticGovernanceEnabled: null }) + .accounts({ + dao, + squadsMultisigVault, + }); } setSpendingLimitIx({ diff --git a/sdk/src/futarchy/v0.6/types/futarchy.ts b/sdk/src/futarchy/v0.6/types/futarchy.ts index 668d3109..b689da89 100644 --- a/sdk/src/futarchy/v0.6/types/futarchy.ts +++ b/sdk/src/futarchy/v0.6/types/futarchy.ts @@ -123,6 +123,11 @@ export type Futarchy = { isMut: false; isSigner: false; }, + { + name: "squadsVaultTransaction"; + isMut: false; + isSigner: false; + }, { name: "dao"; isMut: true; @@ -1356,7 +1361,7 @@ export type Futarchy = { isSigner: false; docs: [ "The proposal's DAO, checked against the deserialized proposal in the", - "handler. Must already be migrated to the new layout (crank DAOs first).", + "handler.", ]; }, { @@ -2543,6 +2548,13 @@ export type Futarchy = { docs: ["Unix time of the last buyback finalization. 0 = never."]; type: "i64"; }, + { + name: "typedProposalsEnabled"; + docs: [ + "Whether the DAO runs on the mini-instructions catalog. Never turns off.", + ]; + type: "bool"; + }, ]; }; }, @@ -2812,7 +2824,6 @@ export type Futarchy = { }, { name: "passThresholdBps"; - docs: ["Snapshot of the kind's threshold at create."]; type: "i16"; }, { @@ -2827,6 +2838,14 @@ export type Futarchy = { defined: "ProposalAction"; }; }, + { + name: "paramsOverridden"; + docs: [ + "Set by `admin_update_proposal_params`. `launch_proposal` then leaves the", + "duration and threshold alone.", + ]; + type: "bool"; + }, ]; }; }, @@ -3354,6 +3373,26 @@ export type Futarchy = { option: "publicKey"; }; }, + { + name: "deprecatedIsOptimisticGovernanceEnabled"; + docs: [ + "Ignored. The retired optimistic-governance flag, kept in place so", + "payloads encoded before the upgrade are refused, not reinterpreted.", + ]; + type: { + option: "bool"; + }; + }, + { + name: "typedProposalsEnabled"; + docs: [ + "`Some(true)` turns typed proposals on for this DAO. `None` leaves them", + "as they are. `Some(false)` is refused: there is no way to turn them off.", + ]; + type: { + option: "bool"; + }; + }, ]; }; }, @@ -3785,6 +3824,10 @@ export type Futarchy = { fields: [ { name: "newTeamAddress"; + docs: [ + "The team to install. Launch requires it to still differ from the", + "DAO's team.", + ]; type: "publicKey"; }, { @@ -4040,6 +4083,11 @@ export type Futarchy = { type: "publicKey"; index: false; }, + { + name: "typedProposalsEnabled"; + type: "bool"; + index: false; + }, ]; }, { @@ -4112,6 +4160,11 @@ export type Futarchy = { type: "bool"; index: false; }, + { + name: "typedProposalsEnabled"; + type: "bool"; + index: false; + }, ]; }, { @@ -4294,6 +4347,21 @@ export type Futarchy = { }; index: false; }, + { + name: "durationInSeconds"; + type: "u32"; + index: false; + }, + { + name: "passThresholdBps"; + type: "i16"; + index: false; + }, + { + name: "isTeamSponsored"; + type: "bool"; + index: false; + }, ]; }, { @@ -5232,6 +5300,31 @@ export type Futarchy = { name: "SquadsProposalNotApproved"; msg: "Squads proposal must be in Approved status to be cancelled"; }, + { + code: 6073; + name: "TypedProposalsDisabled"; + msg: "This DAO has not opted into typed proposals"; + }, + { + code: 6074; + name: "TypedProposalsCannotBeDisabled"; + msg: "Typed proposals cannot be disabled"; + }, + { + code: 6075; + name: "UnfrozenAddressLookupTable"; + msg: "Address lookup tables referenced by the vault transaction must be frozen"; + }, + { + code: 6076; + name: "InvalidAddressLookupTable"; + msg: "Lookup table accounts must match the vault transaction's address table lookups"; + }, + { + code: 6077; + name: "InvalidSquadsVaultTransaction"; + msg: "Expected the proposal's Squads vault transaction as the first launch account"; + }, ]; }; @@ -5360,6 +5453,11 @@ export const IDL: Futarchy = { isMut: false, isSigner: false, }, + { + name: "squadsVaultTransaction", + isMut: false, + isSigner: false, + }, { name: "dao", isMut: true, @@ -6593,7 +6691,7 @@ export const IDL: Futarchy = { isSigner: false, docs: [ "The proposal's DAO, checked against the deserialized proposal in the", - "handler. Must already be migrated to the new layout (crank DAOs first).", + "handler.", ], }, { @@ -7780,6 +7878,13 @@ export const IDL: Futarchy = { docs: ["Unix time of the last buyback finalization. 0 = never."], type: "i64", }, + { + name: "typedProposalsEnabled", + docs: [ + "Whether the DAO runs on the mini-instructions catalog. Never turns off.", + ], + type: "bool", + }, ], }, }, @@ -8049,7 +8154,6 @@ export const IDL: Futarchy = { }, { name: "passThresholdBps", - docs: ["Snapshot of the kind's threshold at create."], type: "i16", }, { @@ -8064,6 +8168,14 @@ export const IDL: Futarchy = { defined: "ProposalAction", }, }, + { + name: "paramsOverridden", + docs: [ + "Set by `admin_update_proposal_params`. `launch_proposal` then leaves the", + "duration and threshold alone.", + ], + type: "bool", + }, ], }, }, @@ -8591,6 +8703,26 @@ export const IDL: Futarchy = { option: "publicKey", }, }, + { + name: "deprecatedIsOptimisticGovernanceEnabled", + docs: [ + "Ignored. The retired optimistic-governance flag, kept in place so", + "payloads encoded before the upgrade are refused, not reinterpreted.", + ], + type: { + option: "bool", + }, + }, + { + name: "typedProposalsEnabled", + docs: [ + "`Some(true)` turns typed proposals on for this DAO. `None` leaves them", + "as they are. `Some(false)` is refused: there is no way to turn them off.", + ], + type: { + option: "bool", + }, + }, ], }, }, @@ -9022,6 +9154,10 @@ export const IDL: Futarchy = { fields: [ { name: "newTeamAddress", + docs: [ + "The team to install. Launch requires it to still differ from the", + "DAO's team.", + ], type: "publicKey", }, { @@ -9277,6 +9413,11 @@ export const IDL: Futarchy = { type: "publicKey", index: false, }, + { + name: "typedProposalsEnabled", + type: "bool", + index: false, + }, ], }, { @@ -9349,6 +9490,11 @@ export const IDL: Futarchy = { type: "bool", index: false, }, + { + name: "typedProposalsEnabled", + type: "bool", + index: false, + }, ], }, { @@ -9531,6 +9677,21 @@ export const IDL: Futarchy = { }, index: false, }, + { + name: "durationInSeconds", + type: "u32", + index: false, + }, + { + name: "passThresholdBps", + type: "i16", + index: false, + }, + { + name: "isTeamSponsored", + type: "bool", + index: false, + }, ], }, { @@ -10469,5 +10630,30 @@ export const IDL: Futarchy = { name: "SquadsProposalNotApproved", msg: "Squads proposal must be in Approved status to be cancelled", }, + { + code: 6073, + name: "TypedProposalsDisabled", + msg: "This DAO has not opted into typed proposals", + }, + { + code: 6074, + name: "TypedProposalsCannotBeDisabled", + msg: "Typed proposals cannot be disabled", + }, + { + code: 6075, + name: "UnfrozenAddressLookupTable", + msg: "Address lookup tables referenced by the vault transaction must be frozen", + }, + { + code: 6076, + name: "InvalidAddressLookupTable", + msg: "Lookup table accounts must match the vault transaction's address table lookups", + }, + { + code: 6077, + name: "InvalidSquadsVaultTransaction", + msg: "Expected the proposal's Squads vault transaction as the first launch account", + }, ], }; diff --git a/sdk/src/futarchy/v0.6/types/index.ts b/sdk/src/futarchy/v0.6/types/index.ts index d6153fbb..0cdc8a80 100644 --- a/sdk/src/futarchy/v0.6/types/index.ts +++ b/sdk/src/futarchy/v0.6/types/index.ts @@ -19,7 +19,12 @@ import type { IdlAccounts, IdlTypes, IdlEvents } from "@coral-xyz/anchor"; export type InitializeDaoParams = IdlTypes["InitializeDaoParams"]; -export type UpdateDaoParams = IdlTypes["UpdateDaoParams"]; +// The retired optimistic-governance flag only keeps its position in the +// encoding; `updateDaoIx` fills it in, so callers never set it. +export type UpdateDaoParams = Omit< + IdlTypes["UpdateDaoParams"], + "deprecatedIsOptimisticGovernanceEnabled" +>; export type SetSpendingLimitArgs = IdlTypes["SetSpendingLimitArgs"]; export type InitializeLargeSpendProposalArgs = @@ -65,6 +70,20 @@ export type WithdrawLiquidityEvent = IdlEvents["WithdrawLiquidityEvent"]; export type SponsorProposalEvent = IdlEvents["SponsorProposalEvent"]; +export type RemoveProposalEvent = + IdlEvents["RemoveProposalEvent"]; +export type AdminUpdateProposalParamsEvent = + IdlEvents["AdminUpdateProposalParamsEvent"]; +export type AdminCancelProposalEvent = + IdlEvents["AdminCancelProposalEvent"]; +export type CollectMeteoraDammFeesEvent = + IdlEvents["CollectMeteoraDammFeesEvent"]; +export type AdminFixPositionAuthorityEvent = + IdlEvents["AdminFixPositionAuthorityEvent"]; +export type SetSpendingLimitEvent = + IdlEvents["SetSpendingLimitEvent"]; +export type SyncSpendingLimitEvent = + IdlEvents["SyncSpendingLimitEvent"]; export type FutarchyEvent = | CollectFeesEvent | InitializeDaoEvent @@ -78,7 +97,14 @@ export type FutarchyEvent = | ConditionalSwapEvent | ProvideLiquidityEvent | WithdrawLiquidityEvent - | SponsorProposalEvent; + | SponsorProposalEvent + | RemoveProposalEvent + | AdminUpdateProposalParamsEvent + | AdminCancelProposalEvent + | CollectMeteoraDammFeesEvent + | AdminFixPositionAuthorityEvent + | SetSpendingLimitEvent + | SyncSpendingLimitEvent; export type v0_6_0_CollectFeesEvent = IdlEvents["CollectFeesEvent"]; diff --git a/tests/futarchy/integration/fullProposal.test.ts b/tests/futarchy/integration/fullProposal.test.ts index 9c065a80..54c581df 100644 --- a/tests/futarchy/integration/fullProposal.test.ts +++ b/tests/futarchy/integration/fullProposal.test.ts @@ -86,6 +86,7 @@ export default function suite() { twapMaxObservationChangePerUpdate: null, minQuoteFutarchicLiquidity: null, minBaseFutarchicLiquidity: null, + typedProposalsEnabled: null, }, }) .instruction(); diff --git a/tests/futarchy/integration/futarchyAmm.test.ts b/tests/futarchy/integration/futarchyAmm.test.ts index 6d6062aa..c1c955e4 100644 --- a/tests/futarchy/integration/futarchyAmm.test.ts +++ b/tests/futarchy/integration/futarchyAmm.test.ts @@ -81,6 +81,7 @@ export default function suite() { twapStartDelaySeconds: null, teamSponsoredPassThresholdBps: null, teamAddress: null, + typedProposalsEnabled: null, }, }) .instruction(); @@ -212,6 +213,10 @@ export default function suite() { ); const proposalAccount = await this.futarchy.getProposal(proposal); + const { squadsTransaction } = + await this.futarchy.getSquadsVaultTransactionAccounts( + proposalAccount.squadsProposal, + ); await this.futarchy .launchProposalIx({ @@ -220,6 +225,7 @@ export default function suite() { baseMint: META, quoteMint: USDC, squadsProposal: proposalAccount.squadsProposal, + squadsTransaction, }) .rpc(); diff --git a/tests/futarchy/integration/gatedProposalSettlement.test.ts b/tests/futarchy/integration/gatedProposalSettlement.test.ts index ecd002b5..6e232a2a 100644 --- a/tests/futarchy/integration/gatedProposalSettlement.test.ts +++ b/tests/futarchy/integration/gatedProposalSettlement.test.ts @@ -29,6 +29,7 @@ import { whitelistUser, } from "../../gatedMint/utils.js"; import { TestContext } from "../../main.test.js"; +import { EMPTY_UPDATE_DAO_PARAMS } from "../utils.js"; const ata = (mint: PublicKey, owner: PublicKey) => getAssociatedTokenAddressSync(mint, owner, true); @@ -179,27 +180,18 @@ export default function suite() { const updateDaoIx = await this.futarchy .updateDaoIx({ dao, - params: { - passThresholdBps: 500, - secondsPerProposal: null, - baseToStake: null, - twapInitialObservation: null, - twapMaxObservationChangePerUpdate: null, - minQuoteFutarchicLiquidity: null, - minBaseFutarchicLiquidity: null, - twapStartDelaySeconds: null, - teamSponsoredPassThresholdBps: null, - teamAddress: null, - isOptimisticGovernanceEnabled: null, - }, + params: { ...EMPTY_UPDATE_DAO_PARAMS, passThresholdBps: 500 }, }) .instruction(); - const { tx: squadsCreateTx, squadsProposal: createdSquadsProposal } = - this.futarchy.squadsProposalCreateTx({ - dao, - instructions: [updateDaoIx], - transactionIndex: 1n, - }); + const { + tx: squadsCreateTx, + squadsProposal: createdSquadsProposal, + squadsTransaction, + } = this.futarchy.squadsProposalCreateTx({ + dao, + instructions: [updateDaoIx], + transactionIndex: 1n, + }); squadsCreateTx.recentBlockhash = ( await this.banksClient.getLatestBlockhash() )[0]; @@ -216,6 +208,7 @@ export default function suite() { baseMint: GATED, quoteMint: USDC, squadsProposal, + squadsTransaction, }) .rpc(); diff --git a/tests/futarchy/integration/proposalBatchTx.test.ts b/tests/futarchy/integration/proposalBatchTx.test.ts index ffae0450..339238cd 100644 --- a/tests/futarchy/integration/proposalBatchTx.test.ts +++ b/tests/futarchy/integration/proposalBatchTx.test.ts @@ -86,6 +86,7 @@ export default function suite() { twapMaxObservationChangePerUpdate: null, minQuoteFutarchicLiquidity: null, minBaseFutarchicLiquidity: null, + typedProposalsEnabled: null, }, }) .instruction(); @@ -100,6 +101,7 @@ export default function suite() { twapMaxObservationChangePerUpdate: null, minQuoteFutarchicLiquidity: null, minBaseFutarchicLiquidity: null, + typedProposalsEnabled: null, }, }) .instruction(); diff --git a/tests/futarchy/integration/takeoverEndToEnd.test.ts b/tests/futarchy/integration/takeoverEndToEnd.test.ts index 5e4b69f3..4107cfb2 100644 --- a/tests/futarchy/integration/takeoverEndToEnd.test.ts +++ b/tests/futarchy/integration/takeoverEndToEnd.test.ts @@ -1,7 +1,6 @@ import { getDaoAddr, getSpendingLimitAddr, - PERMISSIONLESS_ACCOUNT, PriceMath, } from "@metadaoproject/programs"; import { @@ -9,44 +8,50 @@ import { Keypair, PublicKey, Transaction, - TransactionMessage, - VersionedTransaction, } from "@solana/web3.js"; import BN from "bn.js"; import { assert } from "chai"; import * as multisig from "@sqds/multisig"; -import { createLookupTableForTransaction, passProposal } from "../../utils.js"; +import { expectError, passProposal } from "../../utils.js"; +import { TestContext } from "../../main.test.js"; const THOUSAND_BUCK_PRICE = PriceMath.getAmmPrice(1000, 6, 6); -// Declaration semantics through the whole chain: what the market approved is -// byte-for-byte what lands, on both the DAO record and the Squads projection — -// and the regime change cuts off the old members' pull rights. +const OLD_LIMIT_PER_MONTH = new BN(10_000 * 1_000_000); // 10,000 USDC + +type Regime = { + META: PublicKey; + USDC: PublicKey; + dao: PublicKey; + multisigPda: PublicKey; + oldMember: Keypair; + spendingLimitPda: PublicKey; +}; + +type SpendingLimitAction = Parameters< + TestContext["futarchy"]["initializeHostileTakeoverProposal"] +>[0]["spendingLimitAction"]; + +// Declaration semantics through the whole chain: what the market approved +// lands on the DAO record the moment the takeover finalizes, and on the Squads +// projection at sync — and a regime change cuts off the old members' pull +// rights. export default function suite() { - it("rotates the regime: create with Set, stake, launch, pass, packed execute + sync", async function () { - const META = await this.createMint(this.payer.publicKey, 6); - const USDC = await this.createMint(this.payer.publicKey, 6); + // A DAO whose live spending limit lets the old member pull, proven by one pull + async function setupRegime(ctx: TestContext): Promise { + const META = await ctx.createMint(ctx.payer.publicKey, 6); + const USDC = await ctx.createMint(ctx.payer.publicKey, 6); - await this.createTokenAccount(META, this.payer.publicKey); - await this.createTokenAccount(USDC, this.payer.publicKey); + await ctx.createTokenAccount(META, ctx.payer.publicKey); + await ctx.createTokenAccount(USDC, ctx.payer.publicKey); - await this.mintTo( - META, - this.payer.publicKey, - this.payer, - 2_000 * 1_000_000, - ); - await this.mintTo( - USDC, - this.payer.publicKey, - this.payer, - 500_000 * 1_000_000, - ); + await ctx.mintTo(META, ctx.payer.publicKey, ctx.payer, 2_000 * 1_000_000); + await ctx.mintTo(USDC, ctx.payer.publicKey, ctx.payer, 500_000 * 1_000_000); const oldMember = Keypair.generate(); const nonce = new BN(Math.floor(Math.random() * 1000000)); - await this.futarchy + await ctx.futarchy .initializeDaoIx({ baseMint: META, quoteMint: USDC, @@ -60,12 +65,12 @@ export default function suite() { passThresholdBps: 300, nonce, initialSpendingLimit: { - amountPerMonth: new BN(10_000_000_000), // 10,000 USDC + amountPerMonth: OLD_LIMIT_PER_MONTH, members: [oldMember.publicKey], }, baseToStake: new BN(1_000 * 1_000_000), // 1,000 META teamSponsoredPassThresholdBps: 300, - teamAddress: this.payer.publicKey, + teamAddress: ctx.payer.publicKey, }, }) .preInstructions([ @@ -73,13 +78,13 @@ export default function suite() { ]) .rpc(); - const [dao] = getDaoAddr({ nonce, daoCreator: this.payer.publicKey }); + const [dao] = getDaoAddr({ nonce, daoCreator: ctx.payer.publicKey }); - const storedDaoBefore = await this.futarchy.getDao(dao); - const vault = storedDaoBefore.squadsMultisigVault; - const multisigPda = storedDaoBefore.squadsMultisig; + const storedDao = await ctx.futarchy.getDao(dao); + const vault = storedDao.squadsMultisigVault; + const multisigPda = storedDao.squadsMultisig; - await this.futarchy + await ctx.futarchy .provideLiquidityIx({ dao, baseMint: META, @@ -87,59 +92,84 @@ export default function suite() { 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, + positionAuthority: ctx.payer.publicKey, + liquidityProvider: ctx.payer.publicKey, }) .preInstructions([ ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), ]) .rpc(); - // The old regime works: the old member pulls from the treasury through - // the Squads spending limit - await this.createTokenAccount(USDC, vault); - await this.mintTo(USDC, vault, this.payer, 1_000 * 1_000_000); - await this.createTokenAccount(USDC, oldMember.publicKey); + await ctx.createTokenAccount(USDC, vault); + await ctx.mintTo(USDC, vault, ctx.payer, 1_000 * 1_000_000); + await ctx.createTokenAccount(USDC, oldMember.publicKey); const [spendingLimitPda] = getSpendingLimitAddr({ dao }); + const regime = { + META, + USDC, + dao, + multisigPda, + oldMember, + spendingLimitPda, + }; + await pullAsOldMember(ctx, regime, 100 * 1_000_000, 0); + assert.equal( + (await ctx.getTokenBalance(USDC, oldMember.publicKey)).toString(), + (100 * 1_000_000).toString(), + ); + + return regime; + } + + // The old member draws from the treasury through the live Squads limit. The + // compute unit price makes each pull's transaction hash unique, so a repeat + // pull isn't rejected as already processed. + async function pullAsOldMember( + ctx: TestContext, + { USDC, multisigPda, oldMember, spendingLimitPda }: Regime, + amount: number, + computeUnitPrice: number, + ) { const pullIx = multisig.instructions.spendingLimitUse({ multisigPda, member: oldMember.publicKey, spendingLimit: spendingLimitPda, mint: USDC, vaultIndex: 0, - amount: 100 * 1_000_000, + amount, decimals: 6, destination: oldMember.publicKey, }); - const pullTx = new Transaction().add(pullIx); - pullTx.recentBlockhash = (await this.banksClient.getLatestBlockhash())[0]; - pullTx.feePayer = this.payer.publicKey; - pullTx.sign(this.payer, oldMember); - await this.banksClient.processTransaction(pullTx); - - assert.equal( - (await this.getTokenBalance(USDC, oldMember.publicKey)).toString(), - (100 * 1_000_000).toString(), + const pullTx = new Transaction().add( + ComputeBudgetProgram.setComputeUnitPrice({ + microLamports: computeUnitPrice, + }), + pullIx, ); + pullTx.recentBlockhash = (await ctx.banksClient.getLatestBlockhash())[0]; + pullTx.feePayer = ctx.payer.publicKey; + pullTx.sign(ctx.payer, oldMember); + await ctx.banksClient.processTransaction(pullTx); + } - // The declared post-takeover regime: new team, new limit, new members - const newTeamAddress = Keypair.generate().publicKey; - const newMember = Keypair.generate(); - const declaredConfig = { - amountPerMonth: new BN(25_000_000_000), // 25,000 USDC - members: [newMember.publicKey], - }; - - const { proposal, squadsProposal, squadsTransaction } = - await this.futarchy.initializeHostileTakeoverProposal({ + // Creates, stakes, launches and passes the takeover; the market runs out the + // kind's 20-day snapshot and finalizes to Passed at +10% + async function passTakeover( + ctx: TestContext, + { META, USDC, dao }: Regime, + newTeamAddress: PublicKey, + spendingLimitAction: SpendingLimitAction, + ) { + const { proposal, squadsProposal } = + await ctx.futarchy.initializeHostileTakeoverProposal({ dao, newTeamAddress, - spendingLimitAction: { set: { 0: declaredConfig } }, + spendingLimitAction, }); - await this.futarchy + await ctx.futarchy .stakeToProposalIx({ proposal, dao, @@ -148,7 +178,7 @@ export default function suite() { }) .rpc(); - await this.futarchy + await ctx.futarchy .launchProposalIx({ proposal, dao, @@ -158,57 +188,33 @@ export default function suite() { }) .rpc(); - // Runs out the takeover's 20-day snapshot and finalizes to Passed at +10% - await passProposal(this, { + await passProposal(ctx, { dao, proposal, baseMint: META, quoteMint: USDC, cranks: 90, }); + } - // Packed execute + sync: the declared regime is live on Squads the moment - // the takeover lands, leaving no window where the outgoing members keep - // their allowance - const vaultTransaction = - await multisig.accounts.VaultTransaction.fromAccountAddress( - this.squadsConnection, - squadsTransaction, - ); - const packIxs = [ - ( - await multisig.instructions.vaultTransactionExecute({ - connection: this.squadsConnection, - multisigPda, - transactionIndex: BigInt(vaultTransaction.index.toString()), - member: PERMISSIONLESS_ACCOUNT.publicKey, - }) - ).instruction, - await this.futarchy.syncSpendingLimitIx({ dao }).instruction(), - ]; - - const lut = await createLookupTableForTransaction( - new Transaction().add(...packIxs), - this, - ); + it("Set: the declared regime lands on the record at finalize and on Squads at sync", async function () { + const regime = await setupRegime(this); + const { dao, spendingLimitPda } = regime; - const packMessage = new TransactionMessage({ - payerKey: this.payer.publicKey, - recentBlockhash: (await this.banksClient.getLatestBlockhash())[0], - instructions: [ - ComputeBudgetProgram.setComputeUnitLimit({ units: 1_400_000 }), - ...packIxs, - ], - }).compileToV0Message([lut]); - const packTx = new VersionedTransaction(packMessage); - packTx.sign([this.payer, PERMISSIONLESS_ACCOUNT]); - await this.banksClient.processTransaction(packTx); - - // The team pointer moved - const storedDao = await this.futarchy.getDao(dao); - assert.ok(storedDao.teamAddress.equals(newTeamAddress)); + const newTeamAddress = Keypair.generate().publicKey; + const declaredConfig = { + amountPerMonth: new BN(25_000_000_000), // 25,000 USDC + members: [Keypair.generate().publicKey], + }; - // The DAO record matches the declaration, and the sync consumed the flag + await passTakeover(this, regime, newTeamAddress, { + set: { 0: declaredConfig }, + }); + + // Finalize alone moved the team and wrote the declaration; Squads still + // holds the old limit until the sync + let storedDao = await this.futarchy.getDao(dao); + assert.ok(storedDao.teamAddress.equals(newTeamAddress)); assert.equal( storedDao.initialSpendingLimit.amountPerMonth.toString(), declaredConfig.amountPerMonth.toString(), @@ -217,14 +223,23 @@ export default function suite() { storedDao.initialSpendingLimit.members.map((m) => m.toBase58()), declaredConfig.members.map((m) => m.toBase58()), ); + assert.isTrue(storedDao.spendingLimitDirty); + + let storedLimit = await multisig.accounts.SpendingLimit.fromAccountAddress( + this.squadsConnection, + spendingLimitPda, + ); + assert.equal(storedLimit.amount.toString(), OLD_LIMIT_PER_MONTH.toString()); + + await this.futarchy.syncSpendingLimitIx({ dao }).rpc(); + + storedDao = await this.futarchy.getDao(dao); assert.isFalse(storedDao.spendingLimitDirty); - // The Squads projection matches the declaration - const storedLimit = - await multisig.accounts.SpendingLimit.fromAccountAddress( - this.squadsConnection, - spendingLimitPda, - ); + storedLimit = await multisig.accounts.SpendingLimit.fromAccountAddress( + this.squadsConnection, + spendingLimitPda, + ); assert.equal( storedLimit.amount.toString(), declaredConfig.amountPerMonth.toString(), @@ -239,25 +254,8 @@ export default function suite() { ); // The old member's pull rights are gone — the takeover's economic point - const oldPullIx = multisig.instructions.spendingLimitUse({ - multisigPda, - member: oldMember.publicKey, - spendingLimit: spendingLimitPda, - mint: USDC, - vaultIndex: 0, - amount: 200 * 1_000_000, - decimals: 6, - destination: oldMember.publicKey, - }); - const oldPullTx = new Transaction().add(oldPullIx); - oldPullTx.recentBlockhash = ( - await this.banksClient.getLatestBlockhash() - )[0]; - oldPullTx.feePayer = this.payer.publicKey; - oldPullTx.sign(this.payer, oldMember); - try { - await this.banksClient.processTransaction(oldPullTx); + await pullAsOldMember(this, regime, 200 * 1_000_000, 1); assert.fail("Should have failed with Unauthorized"); } catch (e) { // Squads' Unauthorized (0x1774 = 6004) @@ -268,4 +266,59 @@ export default function suite() { ); } }); + + it("Remove: the record is cleared at finalize and the sync closes the live limit", async function () { + const regime = await setupRegime(this); + const { dao, spendingLimitPda } = regime; + + const newTeamAddress = Keypair.generate().publicKey; + await passTakeover(this, regime, newTeamAddress, { remove: {} }); + + let storedDao = await this.futarchy.getDao(dao); + assert.ok(storedDao.teamAddress.equals(newTeamAddress)); + assert.isNull(storedDao.initialSpendingLimit); + assert.isTrue(storedDao.spendingLimitDirty); + + await this.futarchy.syncSpendingLimitIx({ dao }).rpc(); + + storedDao = await this.futarchy.getDao(dao); + assert.isFalse(storedDao.spendingLimitDirty); + assert.isNull(await this.banksClient.getAccount(spendingLimitPda)); + }); + + it("Keep: the record, the flag and the live limit are untouched", async function () { + const regime = await setupRegime(this); + const { dao, USDC, oldMember } = regime; + + const newTeamAddress = Keypair.generate().publicKey; + await passTakeover(this, regime, newTeamAddress, { keep: {} }); + + const storedDao = await this.futarchy.getDao(dao); + assert.ok(storedDao.teamAddress.equals(newTeamAddress)); + assert.equal( + storedDao.initialSpendingLimit.amountPerMonth.toString(), + OLD_LIMIT_PER_MONTH.toString(), + ); + assert.deepEqual( + storedDao.initialSpendingLimit.members.map((m) => m.toBase58()), + [oldMember.publicKey.toBase58()], + ); + assert.isFalse(storedDao.spendingLimitDirty); + + const callbacks = expectError( + "SpendingLimitNotDirty", + "synced a limit the takeover kept", + ); + await this.futarchy + .syncSpendingLimitIx({ dao }) + .rpc() + .then(callbacks[0], callbacks[1]); + + // The old member keeps pulling under the new team + await pullAsOldMember(this, regime, 100 * 1_000_000, 1); + assert.equal( + (await this.getTokenBalance(USDC, oldMember.publicKey)).toString(), + (200 * 1_000_000).toString(), + ); + }); } diff --git a/tests/futarchy/integration/typedProposalsOptInEndToEnd.test.ts b/tests/futarchy/integration/typedProposalsOptInEndToEnd.test.ts new file mode 100644 index 00000000..b1d23263 --- /dev/null +++ b/tests/futarchy/integration/typedProposalsOptInEndToEnd.test.ts @@ -0,0 +1,358 @@ +import { PERMISSIONLESS_ACCOUNT } from "@metadaoproject/programs"; +import { + ComputeBudgetProgram, + Keypair, + PublicKey, + TransactionInstruction, +} from "@solana/web3.js"; +import BN from "bn.js"; +import { assert } from "chai"; +import * as multisig from "@sqds/multisig"; +import { + executeVaultTransaction, + expectError, + makeOldDaoLayout, + setupBasicDao, +} from "../../utils.js"; +import { + EMPTY_UPDATE_DAO_PARAMS, + TYPED_PROPOSALS_OFF_DAO_TERMS, +} from "../utils.js"; +import { TestContext } from "../../main.test.js"; + +const MEMO_PROGRAM_ID = new PublicKey( + "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr", +); + +const CATALOG_DURATION_SECONDS = 60 * 60 * 24 * 10; +const CATALOG_PASS_THRESHOLD_BPS = 1000; +const CATALOG_TWAP_START_DELAY_SECONDS = 60 * 60 * 24; + +const PRE_MIGRATION_DAO_SIZE = 1205; +const MIGRATED_DAO_SIZE = 1264; + +const memoIx = new TransactionInstruction({ + programId: MEMO_PROGRAM_ID, + keys: [], + data: Buffer.from("arbitrary", "utf8"), +}); + +// A plain proposal at the multisig's next index whose vault transaction holds +// `instructions`. +async function filePlainProposal( + ctx: TestContext, + dao: PublicKey, + instructions: TransactionInstruction[], +) { + const { transactionIndex, squadsProposal, squadsTransaction } = + await ctx.futarchy.getNextProposalAddrs(dao); + + const { tx } = ctx.futarchy.squadsProposalCreateTx({ + dao, + instructions, + transactionIndex, + }); + [tx.recentBlockhash] = await ctx.banksClient.getLatestBlockhash(); + tx.feePayer = ctx.payer.publicKey; + tx.sign(ctx.payer, PERMISSIONLESS_ACCOUNT); + await ctx.banksClient.processTransaction(tx); + + const proposal = await ctx.futarchy.initializeProposal(dao, squadsProposal); + + return { proposal, squadsProposal, squadsTransaction }; +} + +// One spot swap after the warm-up records an observation in both conditional +// pools and leaves their TWAPs equal, then the market runs out. +async function runFlatMarketToEnd( + ctx: TestContext, + { + dao, + baseMint, + quoteMint, + twapStartDelaySeconds, + durationInSeconds, + computeUnitPrice, + }: { + dao: PublicKey; + baseMint: PublicKey; + quoteMint: PublicKey; + twapStartDelaySeconds: number; + durationInSeconds: number; + computeUnitPrice: number; + }, +) { + await ctx.advanceBySeconds(twapStartDelaySeconds + 60); + await ctx.futarchy + .spotSwapIx({ + dao, + baseMint, + quoteMint, + swapType: "buy", + inputAmount: new BN(1_000), + }) + .preInstructions([ + // The compute-unit price makes each market's swap hash unique, so a + // later flat market isn't rejected as already processed. + ComputeBudgetProgram.setComputeUnitPrice({ + microLamports: computeUnitPrice, + }), + ]) + .rpc(); + await ctx.advanceBySeconds(durationInSeconds); +} + +// A DAO that exists today: migrated with typed proposals off, it passes the +// opt-in under its own 2 days and -3%, and governs on the catalog from the +// moment the vault executes it. +export default function suite() { + it("migrates an existing DAO, passes the typed proposals opt-in under its own rules, and lands on the catalog", 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, + 200_000 * 1_000_000, + ); + await this.mintTo( + USDC, + this.payer.publicKey, + this.payer, + 200_000 * 1_000_000, + ); + + const dao = await setupBasicDao({ + context: this, + baseMint: META, + quoteMint: USDC, + ...TYPED_PROPOSALS_OFF_DAO_TERMS, + }); + await this.futarchy + .provideLiquidityIx({ + dao, + baseMint: META, + quoteMint: USDC, + quoteAmount: new BN(100_000 * 1_000_000), + maxBaseAmount: new BN(100_000 * 1_000_000), + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), + ]) + .rpc(); + + // Migrate + await makeOldDaoLayout(this, dao); + assert.equal( + (await this.banksClient.getAccount(dao)).data.length, + PRE_MIGRATION_DAO_SIZE, + ); + + await this.futarchy.resizeDaoIx({ dao }).rpc(); + + assert.equal( + (await this.banksClient.getAccount(dao)).data.length, + MIGRATED_DAO_SIZE, + ); + assert.isFalse((await this.futarchy.getDao(dao)).typedProposalsEnabled); + + // File the opt-in, and a second plain draft that stays open until the + // catalog applies + const optInIx = await this.futarchy + .updateDaoIx({ + dao, + params: { ...EMPTY_UPDATE_DAO_PARAMS, typedProposalsEnabled: true }, + }) + .instruction(); + const optIn = await filePlainProposal(this, dao, [optInIx]); + const leftover = await filePlainProposal(this, dao, [memoIx]); + + const optInDraft = await this.futarchy.getProposal(optIn.proposal); + assert.equal( + optInDraft.durationInSeconds, + TYPED_PROPOSALS_OFF_DAO_TERMS.secondsPerProposal, + ); + assert.equal( + optInDraft.passThresholdBps, + TYPED_PROPOSALS_OFF_DAO_TERMS.passThresholdBps, + ); + + // An outsider's takeover is refused while typed proposals are off + const takeover = { + dao, + newTeamAddress: Keypair.generate().publicKey, + spendingLimitAction: { keep: {} }, + }; + await this.futarchy + .initializeHostileTakeoverProposal(takeover) + .then( + ...expectError( + "TypedProposalsDisabled", + "created a hostile takeover before the DAO opted in", + ), + ); + + // Sponsor and launch under the DAO's own rules + await this.futarchy + .sponsorProposalIx({ proposal: optIn.proposal, dao }) + .rpc(); + await this.futarchy + .launchProposalIx({ + proposal: optIn.proposal, + dao, + baseMint: META, + quoteMint: USDC, + squadsProposal: optIn.squadsProposal, + squadsTransaction: optIn.squadsTransaction, + }) + .rpc(); + + const optInLaunched = await this.futarchy.getProposal(optIn.proposal); + assert.exists(optInLaunched.state.pending); + assert.equal( + optInLaunched.durationInSeconds, + TYPED_PROPOSALS_OFF_DAO_TERMS.secondsPerProposal, + ); + assert.equal( + optInLaunched.passThresholdBps, + TYPED_PROPOSALS_OFF_DAO_TERMS.teamSponsoredPassThresholdBps, + ); + + const optInMarket = (await this.futarchy.getDao(dao)).amm.state.futarchy; + assert.equal( + optInMarket.pass.oracle.startDelaySeconds, + TYPED_PROPOSALS_OFF_DAO_TERMS.twapStartDelaySeconds, + ); + assert.equal( + optInMarket.fail.oracle.startDelaySeconds, + TYPED_PROPOSALS_OFF_DAO_TERMS.twapStartDelaySeconds, + ); + + // Nobody trades against it; equal TWAPs clear the -3% threshold + await runFlatMarketToEnd(this, { + dao, + baseMint: META, + quoteMint: USDC, + twapStartDelaySeconds: + TYPED_PROPOSALS_OFF_DAO_TERMS.twapStartDelaySeconds, + durationInSeconds: TYPED_PROPOSALS_OFF_DAO_TERMS.secondsPerProposal, + computeUnitPrice: 1, + }); + await this.futarchy.finalizeProposal(optIn.proposal); + + assert.exists( + (await this.futarchy.getProposal(optIn.proposal)).state.passed, + ); + const optInSquadsProposal = + await multisig.accounts.Proposal.fromAccountAddress( + this.squadsConnection, + optIn.squadsProposal, + ); + assert.isTrue( + multisig.generated.isProposalStatusApproved(optInSquadsProposal.status), + ); + + // Execute: the vault signs update_dao + await executeVaultTransaction(this, dao, optIn.squadsTransaction); + assert.isTrue((await this.futarchy.getDao(dao)).typedProposalsEnabled); + + // Retry the takeover. The SDK helper can't be used again here: the refused + // attempt already created the question and conditional vaults for this + // transaction index, so only the initialize instruction itself is sent. + const { transactionIndex, proposal: takeoverProposal } = + await this.futarchy.getNextProposalAddrs(dao); + await this.futarchy + .initializeHostileTakeoverProposalIx({ + ...takeover, + baseMint: META, + quoteMint: USDC, + transactionIndex, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 400_000 }), + // Without this the transaction would be byte-identical to the refused + // attempt and rejected as already processed. + ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), + ]) + .rpc(); + assert.exists( + (await this.futarchy.getProposal(takeoverProposal)).action + .hostileTakeover, + ); + + // A fresh plain draft previews the catalog + const fresh = await filePlainProposal(this, dao, [memoIx]); + const freshDraft = await this.futarchy.getProposal(fresh.proposal); + assert.equal(freshDraft.durationInSeconds, CATALOG_DURATION_SECONDS); + assert.equal(freshDraft.passThresholdBps, CATALOG_PASS_THRESHOLD_BPS); + + // The draft left open before the opt-in launches under the catalog + await this.futarchy + .sponsorProposalIx({ proposal: leftover.proposal, dao }) + .rpc(); + await this.futarchy + .launchProposalIx({ + proposal: leftover.proposal, + dao, + baseMint: META, + quoteMint: USDC, + squadsProposal: leftover.squadsProposal, + squadsTransaction: leftover.squadsTransaction, + }) + .rpc(); + + const leftoverLaunched = await this.futarchy.getProposal(leftover.proposal); + assert.exists(leftoverLaunched.state.pending); + assert.equal(leftoverLaunched.durationInSeconds, CATALOG_DURATION_SECONDS); + assert.equal(leftoverLaunched.passThresholdBps, CATALOG_PASS_THRESHOLD_BPS); + + const leftoverMarket = (await this.futarchy.getDao(dao)).amm.state.futarchy; + assert.equal( + leftoverMarket.pass.oracle.startDelaySeconds, + CATALOG_TWAP_START_DELAY_SECONDS, + ); + assert.equal( + leftoverMarket.fail.oracle.startDelaySeconds, + CATALOG_TWAP_START_DELAY_SECONDS, + ); + + // Equal TWAPs don't clear the catalog's +10%. Finalizing frees the pool + // for the next launch. + await runFlatMarketToEnd(this, { + dao, + baseMint: META, + quoteMint: USDC, + twapStartDelaySeconds: CATALOG_TWAP_START_DELAY_SECONDS, + durationInSeconds: CATALOG_DURATION_SECONDS, + computeUnitPrice: 2, + }); + await this.futarchy.finalizeProposal(leftover.proposal); + assert.exists( + (await this.futarchy.getProposal(leftover.proposal)).state.failed, + ); + + // So does the fresh one + await this.futarchy + .sponsorProposalIx({ proposal: fresh.proposal, dao }) + .rpc(); + await this.futarchy + .launchProposalIx({ + proposal: fresh.proposal, + dao, + baseMint: META, + quoteMint: USDC, + squadsProposal: fresh.squadsProposal, + squadsTransaction: fresh.squadsTransaction, + }) + .rpc(); + + const freshLaunched = await this.futarchy.getProposal(fresh.proposal); + assert.exists(freshLaunched.state.pending); + assert.equal(freshLaunched.durationInSeconds, CATALOG_DURATION_SECONDS); + assert.equal(freshLaunched.passThresholdBps, CATALOG_PASS_THRESHOLD_BPS); + }); +} diff --git a/tests/futarchy/main.test.ts b/tests/futarchy/main.test.ts index 67d43ac0..6913e8d4 100644 --- a/tests/futarchy/main.test.ts +++ b/tests/futarchy/main.test.ts @@ -5,6 +5,7 @@ import cancelApprovedPayloadAfterLiquidation from "./integration/cancelApprovedP import gatedLiquidationUnwind from "./integration/gatedLiquidationUnwind.test.js"; import largeSpendEndToEnd from "./integration/largeSpendEndToEnd.test.js"; import cooldownRoundTrip from "./integration/cooldownRoundTrip.test.js"; +import typedProposalsOptInEndToEnd from "./integration/typedProposalsOptInEndToEnd.test.js"; import gatedProposalSettlement from "./integration/gatedProposalSettlement.test.js"; import initializeDao from "./unit/initializeDao.test.js"; @@ -16,6 +17,7 @@ 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 typedProposalsOptIn from "./unit/typedProposalsOptIn.test.js"; import sponsorProposal from "./unit/sponsorProposal.test.js"; import finalizeProposal from "./unit/finalizeProposal.test.js"; import updateDao from "./unit/updateDao.test.js"; @@ -97,6 +99,7 @@ export default function suite() { initializeBuybackTokenProposal, ); describe("#launch_proposal", launchProposal); + describe("typed proposals opt-in", typedProposalsOptIn); describe("#sponsor_proposal", sponsorProposal); describe("#finalize_proposal", finalizeProposal); describe("#update_dao", updateDao); @@ -147,5 +150,9 @@ export default function suite() { describe("integration: gated liquidation unwind", gatedLiquidationUnwind); describe("integration: large spend end to end", largeSpendEndToEnd); describe("integration: cooldown round-trip", cooldownRoundTrip); + describe( + "integration: typed proposals opt-in end to end", + typedProposalsOptInEndToEnd, + ); describe("integration: gated proposal settlement", gatedProposalSettlement); } diff --git a/tests/futarchy/unit/adminCancelProposal.test.ts b/tests/futarchy/unit/adminCancelProposal.test.ts index e99ac999..e24bd9b9 100644 --- a/tests/futarchy/unit/adminCancelProposal.test.ts +++ b/tests/futarchy/unit/adminCancelProposal.test.ts @@ -22,7 +22,8 @@ export default function suite() { USDC: PublicKey, dao: PublicKey, proposal: PublicKey, - squadsProposalPda: PublicKey; + squadsProposalPda: PublicKey, + squadsTransactionPda: PublicKey; beforeEach(async function () { META = await this.createMint(this.payer.publicKey, 6); @@ -75,6 +76,7 @@ export default function suite() { twapStartDelaySeconds: null, teamSponsoredPassThresholdBps: null, teamAddress: null, + typedProposalsEnabled: null, }, }) .instruction(); @@ -107,6 +109,10 @@ export default function suite() { multisigPda, transactionIndex: 1n, }); + [squadsTransactionPda] = multisig.getTransactionPda({ + multisigPda, + index: 1n, + }); const tx = new Transaction().add(vaultTxCreate, proposalCreateIx); tx.recentBlockhash = (await this.banksClient.getLatestBlockhash())[0]; @@ -124,6 +130,7 @@ export default function suite() { baseMint: META, quoteMint: USDC, squadsProposal: squadsProposalPda, + squadsTransaction: squadsTransactionPda, }) .rpc(); }); @@ -344,7 +351,7 @@ export default function suite() { 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]), + Buffer.from([0xb1, 0xf3, 0x00, 0x03, 0x00, 0x37, 0xa2, 0x00]), ]); assert.equal(legacy.length, 355); this.context.setAccount(proposal, { ...raw, data: legacy }); diff --git a/tests/futarchy/unit/adminRemoveProposal.test.ts b/tests/futarchy/unit/adminRemoveProposal.test.ts index 88dbd5aa..63ecd9bc 100644 --- a/tests/futarchy/unit/adminRemoveProposal.test.ts +++ b/tests/futarchy/unit/adminRemoveProposal.test.ts @@ -50,6 +50,7 @@ export default function suite() { twapStartDelaySeconds: null, teamSponsoredPassThresholdBps: null, teamAddress: null, + typedProposalsEnabled: null, }, }) .instruction(); @@ -223,6 +224,10 @@ export default function suite() { multisigPda, transactionIndex: 1n, }); + const [squadsTransactionPda] = multisig.getTransactionPda({ + multisigPda, + index: 1n, + }); // Launch the proposal to move it to Pending state await this.futarchy @@ -232,6 +237,7 @@ export default function suite() { baseMint: META, quoteMint: USDC, squadsProposal: squadsProposalPda, + squadsTransaction: squadsTransactionPda, }) .rpc(); diff --git a/tests/futarchy/unit/adminUpdateProposalParams.test.ts b/tests/futarchy/unit/adminUpdateProposalParams.test.ts index 240c0082..53bfe45c 100644 --- a/tests/futarchy/unit/adminUpdateProposalParams.test.ts +++ b/tests/futarchy/unit/adminUpdateProposalParams.test.ts @@ -11,6 +11,7 @@ import BN from "bn.js"; import * as multisig from "@sqds/multisig"; import { assert } from "chai"; import { expectError, passProposal, setupBasicDao } from "../../utils.js"; +import { rewriteAccount } from "../utils.js"; import { TestContext } from "../../main.test.js"; // ExecuteArbitrary's catalog parameters, and the start delay the duration @@ -24,7 +25,11 @@ const ARBITRARY_PASS_THRESHOLD_BPS = 1000; async function createArbitraryProposal( ctx: TestContext, dao: PublicKey, -): Promise<{ proposal: PublicKey; squadsProposal: PublicKey }> { +): Promise<{ + proposal: PublicKey; + squadsProposal: PublicKey; + squadsTransaction: PublicKey; +}> { const multisigPda = multisig.getMultisigPda({ createKey: dao })[0]; const message = new TransactionMessage({ @@ -60,6 +65,10 @@ async function createArbitraryProposal( multisigPda, transactionIndex: 1n, }); + const [squadsTransaction] = multisig.getTransactionPda({ + multisigPda, + index: 1n, + }); const tx = new Transaction().add(vaultTxCreate, proposalCreateIx); tx.recentBlockhash = (await ctx.banksClient.getLatestBlockhash())[0]; @@ -70,35 +79,17 @@ async function createArbitraryProposal( return { proposal: await ctx.futarchy.initializeProposal(dao, squadsProposal), squadsProposal, + squadsTransaction, }; } -// Re-encodes an account in place, padded back to its allocated length. The two -// callers below both need state no instruction on the branch can produce. -async function rewriteAccount( - ctx: TestContext, - address: PublicKey, - name: "proposal" | "dao", - mutate: (decoded: any) => void, -) { - const raw = await ctx.banksClient.getAccount(address); - const coder = ctx.futarchy.futarchy.account[name].coder.accounts; - const decoded = coder.decode(name, Buffer.from(raw.data)); - - mutate(decoded); - - const buf = Buffer.alloc(raw.data.length); - (await coder.encode(name, decoded)).copy(buf, 0); - - ctx.context.setAccount(address, { ...raw, data: buf }); -} - export default function suite() { let META: PublicKey, USDC: PublicKey, dao: PublicKey, proposal: PublicKey, - squadsProposal: PublicKey; + squadsProposal: PublicKey, + squadsTransaction: PublicKey; beforeEach(async function () { META = await this.createMint(this.payer.publicKey, 6); @@ -125,7 +116,8 @@ export default function suite() { }, }); - ({ proposal, squadsProposal } = await createArbitraryProposal(this, dao)); + ({ proposal, squadsProposal, squadsTransaction } = + await createArbitraryProposal(this, dao)); }); // The market a launch needs; only the launching cases pay for it. @@ -151,6 +143,7 @@ export default function suite() { const before = await this.futarchy.getProposal(proposal); assert.equal(before.durationInSeconds, ARBITRARY_DURATION_SECONDS); assert.equal(before.passThresholdBps, ARBITRARY_PASS_THRESHOLD_BPS); + assert.isFalse(before.paramsOverridden); await this.futarchy .adminUpdateProposalParamsIx({ @@ -164,6 +157,7 @@ export default function suite() { const after = await this.futarchy.getProposal(proposal); assert.equal(after.durationInSeconds, DAY_SECONDS * 2); assert.equal(after.passThresholdBps, 200); + assert.isTrue(after.paramsOverridden); }); it("leaves the threshold alone when only the duration is set", async function () { @@ -283,9 +277,18 @@ export default function suite() { .then(callbacks[0], callbacks[1]); }); - it("rejects a launched proposal", async function () { + it("keeps the tuned values through launch and rejects a launched proposal", async function () { await provideLiquidity(this); + await this.futarchy + .adminUpdateProposalParamsIx({ + proposal, + dao, + durationInSeconds: DAY_SECONDS * 2, + passThresholdBps: 200, + }) + .rpc(); + await this.futarchy .launchProposalIx({ proposal, @@ -293,9 +296,15 @@ export default function suite() { baseMint: META, quoteMint: USDC, squadsProposal, + squadsTransaction, }) .rpc(); + const launched = await this.futarchy.getProposal(proposal); + assert.exists(launched.state.pending); + assert.equal(launched.durationInSeconds, DAY_SECONDS * 2); + assert.equal(launched.passThresholdBps, 200); + const callbacks = expectError( "ProposalNotInDraftState", "should not retune a live market", @@ -305,7 +314,7 @@ export default function suite() { .adminUpdateProposalParamsIx({ proposal, dao, - durationInSeconds: DAY_SECONDS * 2, + durationInSeconds: DAY_SECONDS * 3, }) .rpc() .then(callbacks[0], callbacks[1]); diff --git a/tests/futarchy/unit/executeMultisigProposalApproval.test.ts b/tests/futarchy/unit/executeMultisigProposalApproval.test.ts index 9db46ec1..ffe5b75a 100644 --- a/tests/futarchy/unit/executeMultisigProposalApproval.test.ts +++ b/tests/futarchy/unit/executeMultisigProposalApproval.test.ts @@ -157,8 +157,11 @@ export default function suite() { // 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 { + proposal, + squadsProposal: proposalPda, + squadsTransaction, + } = await this.initializeProposal({ dao, instructions: [] }); const enqueuedApprovalPda = await enqueue(this, 1n); @@ -171,6 +174,7 @@ export default function suite() { baseMint: storedDao.baseMint, quoteMint: storedDao.quoteMint, squadsProposal: proposalPda, + squadsTransaction, }) .rpc(); diff --git a/tests/futarchy/unit/finalizeProposal.test.ts b/tests/futarchy/unit/finalizeProposal.test.ts index ef242fbd..fa19cefd 100644 --- a/tests/futarchy/unit/finalizeProposal.test.ts +++ b/tests/futarchy/unit/finalizeProposal.test.ts @@ -2,20 +2,34 @@ import { PERMISSIONLESS_ACCOUNT, PriceMath, getDaoAddr, + getSpendingLimitAddr, } from "@metadaoproject/programs"; import { ComputeBudgetProgram, Keypair, PublicKey, Transaction, + TransactionInstruction, TransactionMessage, + VersionedTransaction, } from "@solana/web3.js"; import { createAssociatedTokenAccountIdempotentInstruction, getAssociatedTokenAddressSync, } from "@solana/spl-token"; +import { MEMO_PROGRAM_ID } from "@solana/spl-memo"; import BN from "bn.js"; -import { expectError, makeOldDaoLayout, setupBasicDao } from "../../utils.js"; +import { + createLookupTableForTransaction, + executeVaultTransaction, + expectError, + makeOldDaoLayout, + passProposal, + pumpPassMarket, + setupBasicDao, +} from "../../utils.js"; +import { updateDaoViaVault } from "../utils.js"; +import { TestContext } from "../../main.test.js"; import { assert } from "chai"; import * as multisig from "@sqds/multisig"; const { Permissions, Permission } = multisig.types; @@ -27,7 +41,8 @@ export default function suite() { USDC: PublicKey, dao: PublicKey, proposal: PublicKey, - squadsProposalPda: PublicKey; + squadsProposalPda: PublicKey, + squadsTransactionPda: PublicKey; beforeEach(async function () { META = await this.createMint(this.payer.publicKey, 6); @@ -83,6 +98,7 @@ export default function suite() { twapStartDelaySeconds: null, teamSponsoredPassThresholdBps: null, teamAddress: null, + typedProposalsEnabled: null, }, }) .instruction(); @@ -115,6 +131,10 @@ export default function suite() { multisigPda, transactionIndex: 1n, }); + [squadsTransactionPda] = multisig.getTransactionPda({ + multisigPda, + index: 1n, + }); // Create the squads proposal first const tx = new Transaction().add(vaultTxCreate, proposalCreateIx); @@ -134,10 +154,98 @@ export default function suite() { baseMint: META, quoteMint: USDC, squadsProposal: squadsProposalPda, + squadsTransaction: squadsTransactionPda, }) .rpc(); }); + const STAKE = new BN(100 * 1_000_000); // 100 tokens + + // A fresh DAO with a stake requirement, apart from the suite DAO whose + // market is live + async function setupStakedDao( + ctx: TestContext, + initialSpendingLimit: { + amountPerMonth: typeof BN.prototype; + members: PublicKey[]; + } | null, + ) { + const base = await ctx.createMint(ctx.payer.publicKey, 6); + const quote = await ctx.createMint(ctx.payer.publicKey, 6); + + await ctx.createTokenAccount(base, ctx.payer.publicKey); + await ctx.createTokenAccount(quote, ctx.payer.publicKey); + + await ctx.mintTo(base, ctx.payer.publicKey, ctx.payer, 10_000 * 1_000_000); + await ctx.mintTo( + quote, + ctx.payer.publicKey, + ctx.payer, + 500_000 * 1_000_000, + ); + + const stakedDao = await setupBasicDao({ + context: ctx, + baseMint: base, + quoteMint: quote, + baseToStake: STAKE, + initialSpendingLimit, + }); + + await ctx.futarchy + .provideLiquidityIx({ + dao: stakedDao, + 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: ctx.payer.publicKey, + liquidityProvider: ctx.payer.publicKey, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), + ]) + .rpc(); + + return { base, quote, dao: stakedDao }; + } + + // A plain draft whose payload is a memo, at the multisig's next index + async function createMemoDraft(ctx: TestContext, targetDao: PublicKey) { + const multisigPda = multisig.getMultisigPda({ createKey: targetDao })[0]; + const multisigAccount = await multisig.accounts.Multisig.fromAccountAddress( + ctx.squadsConnection, + multisigPda, + ); + const transactionIndex = + BigInt(multisigAccount.transactionIndex.toString()) + 1n; + + const { tx, squadsProposal, squadsTransaction } = + ctx.futarchy.squadsProposalCreateTx({ + dao: targetDao, + instructions: [ + new TransactionInstruction({ + programId: MEMO_PROGRAM_ID, + keys: [], + data: Buffer.from("draft"), + }), + ], + transactionIndex, + }); + [tx.recentBlockhash] = await ctx.banksClient.getLatestBlockhash(); + tx.feePayer = ctx.payer.publicKey; + tx.sign(ctx.payer, PERMISSIONLESS_ACCOUNT); + await ctx.banksClient.processTransaction(tx); + + const proposal = await ctx.futarchy.initializeProposal( + targetDao, + squadsProposal, + ); + + return { proposal, squadsProposal, squadsTransaction }; + } + it("doesn't finalize proposals that are too young", async function () { const callbacks = expectError( "ProposalTooYoung", @@ -154,12 +262,13 @@ export default function suite() { // 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. + // false, action = ExecuteArbitrary, params_overridden = false — 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]), + Buffer.from([0xb1, 0xf3, 0x00, 0x03, 0x00, 0x37, 0xa2, 0x00]), ]); assert.equal(legacy.length, 355); this.context.setAccount(proposal, { ...raw, data: legacy }); @@ -169,6 +278,7 @@ export default function suite() { assert.equal(crafted.passThresholdBps, -3151); assert.isFalse(crafted.councilCanBlock); assert.isDefined(crafted.action.executeArbitrary); + assert.isFalse(crafted.paramsOverridden); const callbacks = expectError( "AccountNotMigrated", @@ -609,6 +719,7 @@ export default function suite() { twapStartDelaySeconds: null, teamSponsoredPassThresholdBps: null, teamAddress: null, + typedProposalsEnabled: null, }, }) .instruction(); @@ -643,6 +754,10 @@ export default function suite() { multisigPda, transactionIndex: 1n, }); + const [squadsTransactionPda] = multisig.getTransactionPda({ + multisigPda, + index: 1n, + }); // Create the squads proposal first const tx = new Transaction().add(vaultTxCreate, proposalCreateIx); @@ -674,6 +789,7 @@ export default function suite() { baseMint: META, quoteMint: USDC, squadsProposal: squadsProposalPda, + squadsTransaction: squadsTransactionPda, }) .rpc(); @@ -928,5 +1044,277 @@ export default function suite() { clock.unixTimestamp.toString(), ); assert.equal(storedDao.lastFailedLiquidationAt.toString(), "0"); + + // Nothing of the declared regime lands on a failure + assert.ok(storedDao.teamAddress.equals(this.payer.publicKey)); + assert.isNull(storedDao.initialSpendingLimit); + assert.isFalse(storedDao.spendingLimitDirty); + }); + + it("applies a passed takeover even when a new market launches in the same transaction", async function () { + const { base, quote, dao: hostileDao } = await setupStakedDao(this, null); + + const newTeamAddress = Keypair.generate().publicKey; + const takeover = await this.futarchy.initializeHostileTakeoverProposal({ + dao: hostileDao, + newTeamAddress, + spendingLimitAction: { keep: {} }, + }); + const blocker = await createMemoDraft(this, hostileDao); + + for (const proposal of [takeover.proposal, blocker.proposal]) { + await this.futarchy + .stakeToProposalIx({ + proposal, + dao: hostileDao, + baseMint: base, + amount: STAKE, + }) + .rpc(); + } + + await this.futarchy + .launchProposalIx({ + proposal: takeover.proposal, + dao: hostileDao, + baseMint: base, + quoteMint: quote, + squadsProposal: takeover.squadsProposal, + }) + .rpc(); + + await pumpPassMarket(this, { + dao: hostileDao, + proposal: takeover.proposal, + baseMint: base, + quoteMint: quote, + cranks: 90, + }); + + // Finalize and the blocker's launch in one transaction: the takeover is + // written by finalize itself, so the new market cannot hold it off + const packIxs = [ + await this.futarchy + .finalizeProposalIxV2({ + squadsProposal: takeover.squadsProposal, + dao: hostileDao, + baseMint: base, + quoteMint: quote, + }) + .instruction(), + await this.futarchy + .launchProposalIx({ + proposal: blocker.proposal, + dao: hostileDao, + baseMint: base, + quoteMint: quote, + squadsProposal: blocker.squadsProposal, + squadsTransaction: blocker.squadsTransaction, + }) + .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]); + await this.banksClient.processTransaction(packTx); + + const storedDao = await this.futarchy.getDao(hostileDao); + assert.ok(storedDao.teamAddress.equals(newTeamAddress)); + assert.exists(storedDao.amm.state.futarchy); + + const storedBlocker = await this.futarchy.getProposal(blocker.proposal); + assert.exists(storedBlocker.state.pending); + }); + + // A takeover's effect lives on the DAO account, so finalize writes it itself + // rather than leaving it to a payload that a new market could hold off + describe("takeover applied by finalize", function () { + let base: PublicKey, + quote: PublicKey, + takeoverDao: PublicKey, + newTeamAddress: PublicKey, + declaredConfig: { + amountPerMonth: typeof BN.prototype; + members: PublicKey[]; + }, + takeoverSquadsTransaction: PublicKey, + sponsoredDraft: { + proposal: PublicKey; + squadsProposal: PublicKey; + squadsTransaction: PublicKey; + }, + stakedDraft: { + proposal: PublicKey; + squadsProposal: PublicKey; + squadsTransaction: PublicKey; + }; + + before(async function () { + ({ + base, + quote, + dao: takeoverDao, + } = await setupStakedDao(this, { + amountPerMonth: new BN(10_000 * 1_000_000), // 10,000 USDC + members: [this.payer.publicKey], + })); + + newTeamAddress = Keypair.generate().publicKey; + declaredConfig = { + amountPerMonth: new BN(25_000 * 1_000_000), // 25,000 USDC + members: [Keypair.generate().publicKey], + }; + + const takeover = await this.futarchy.initializeHostileTakeoverProposal({ + dao: takeoverDao, + newTeamAddress, + spendingLimitAction: { set: { 0: declaredConfig } }, + }); + takeoverSquadsTransaction = takeover.squadsTransaction; + + // Both drafts are staged while the current team is still in place: one + // rides on its sponsorship, the other on a stake + sponsoredDraft = await createMemoDraft(this, takeoverDao); + await this.futarchy + .sponsorProposalIx({ + proposal: sponsoredDraft.proposal, + dao: takeoverDao, + }) + .rpc(); + + stakedDraft = await createMemoDraft(this, takeoverDao); + await this.futarchy + .stakeToProposalIx({ + proposal: stakedDraft.proposal, + dao: takeoverDao, + baseMint: base, + amount: STAKE, + }) + .rpc(); + + await this.futarchy + .stakeToProposalIx({ + proposal: takeover.proposal, + dao: takeoverDao, + baseMint: base, + amount: STAKE, + }) + .rpc(); + + await this.futarchy + .launchProposalIx({ + proposal: takeover.proposal, + dao: takeoverDao, + baseMint: base, + quoteMint: quote, + squadsProposal: takeover.squadsProposal, + }) + .rpc(); + + await passProposal(this, { + dao: takeoverDao, + proposal: takeover.proposal, + baseMint: base, + quoteMint: quote, + cranks: 90, + }); + }); + + it("writes the new team at finalize, before the memo executes", async function () { + const storedDao = await this.futarchy.getDao(takeoverDao); + assert.ok(storedDao.teamAddress.equals(newTeamAddress)); + }); + + it("writes the declared record and raises the flag, and the sync projects it", async function () { + let storedDao = await this.futarchy.getDao(takeoverDao); + assert.equal( + storedDao.initialSpendingLimit.amountPerMonth.toString(), + declaredConfig.amountPerMonth.toString(), + ); + assert.deepEqual( + storedDao.initialSpendingLimit.members.map((m) => m.toBase58()), + declaredConfig.members.map((m) => m.toBase58()), + ); + assert.isTrue(storedDao.spendingLimitDirty); + + await this.futarchy.syncSpendingLimitIx({ dao: takeoverDao }).rpc(); + + storedDao = await this.futarchy.getDao(takeoverDao); + assert.isFalse(storedDao.spendingLimitDirty); + + const [spendingLimitPda] = getSpendingLimitAddr({ dao: takeoverDao }); + const storedLimit = + await multisig.accounts.SpendingLimit.fromAccountAddress( + this.squadsConnection, + spendingLimitPda, + ); + assert.equal( + storedLimit.amount.toString(), + declaredConfig.amountPerMonth.toString(), + ); + }); + + it("the outgoing team's sponsored draft needs the stake once the takeover lands", async function () { + const callbacks = expectError( + "InsufficientStakeToLaunch", + "launched on the outgoing team's sponsorship", + ); + + await this.futarchy + .launchProposalIx({ + proposal: sponsoredDraft.proposal, + dao: takeoverDao, + baseMint: base, + quoteMint: quote, + squadsProposal: sponsoredDraft.squadsProposal, + squadsTransaction: sponsoredDraft.squadsTransaction, + }) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("executing the memo after a later team change leaves that team in place", async function () { + const laterTeam = Keypair.generate().publicKey; + await updateDaoViaVault(this, takeoverDao, { teamAddress: laterTeam }); + + await executeVaultTransaction( + this, + takeoverDao, + takeoverSquadsTransaction, + ); + + const storedDao = await this.futarchy.getDao(takeoverDao); + assert.ok(storedDao.teamAddress.equals(laterTeam)); + }); + + it("a staked draft launches afterwards", async function () { + await this.futarchy + .launchProposalIx({ + proposal: stakedDraft.proposal, + dao: takeoverDao, + baseMint: base, + quoteMint: quote, + squadsProposal: stakedDraft.squadsProposal, + squadsTransaction: stakedDraft.squadsTransaction, + }) + .rpc(); + + const storedProposal = await this.futarchy.getProposal( + stakedDraft.proposal, + ); + assert.exists(storedProposal.state.pending); + }); }); } diff --git a/tests/futarchy/unit/initializeBuybackTokenProposal.test.ts b/tests/futarchy/unit/initializeBuybackTokenProposal.test.ts index 56a980dd..e8ea9717 100644 --- a/tests/futarchy/unit/initializeBuybackTokenProposal.test.ts +++ b/tests/futarchy/unit/initializeBuybackTokenProposal.test.ts @@ -21,6 +21,7 @@ import { passProposal, setupBasicDao, } from "../../utils.js"; +import { setTypedProposalsEnabled } from "../utils.js"; import { TestContext } from "../../main.test.js"; const MEMO_PROGRAM_ID = new PublicKey( @@ -785,14 +786,15 @@ export default function suite() { keys: [], data: Buffer.from("arbitrary", "utf8"), }); - const { proposal, squadsProposal } = await this.initializeProposal({ - dao, - instructions: [memoIx], - }); + const { proposal, squadsProposal, squadsTransaction } = + await this.initializeProposal({ + dao, + instructions: [memoIx], + }); const callbacks = expectError( - "UnexpectedLaunchAccounts", - "launched a non-buyback proposal with a treasury list", + "InvalidAddressLookupTable", + "launched a generic proposal with a treasury list", ); await this.futarchy @@ -802,6 +804,7 @@ export default function suite() { baseMint: META, quoteMint: USDC, squadsProposal, + squadsTransaction, treasuryAccounts: [vaultQuoteAccount], }) .rpc() @@ -1091,6 +1094,24 @@ export default function suite() { assert.equal(storedSquadsProposal.status.__kind, "Executed"); }); + it("throws error when the DAO has typed proposals off", async function () { + await setTypedProposalsEnabled(this, dao, false); + + const callbacks = expectError( + "TypedProposalsDisabled", + "created a buyback proposal on a DAO with typed proposals off", + ); + await this.futarchy + .initializeBuybackTokenProposal({ + dao, + quoteAmount: new BN(400_000_000_000), + cycleCount: 80, + cycleFrequencySeconds: 86_400, + startDelaySeconds: 0, + }) + .then(callbacks[0], callbacks[1]); + }); + it("rejects a zero total", async function () { const callbacks = expectError( "InvalidBuybackAmount", diff --git a/tests/futarchy/unit/initializeDao.test.ts b/tests/futarchy/unit/initializeDao.test.ts index d1658d25..5ce95375 100644 --- a/tests/futarchy/unit/initializeDao.test.ts +++ b/tests/futarchy/unit/initializeDao.test.ts @@ -78,6 +78,7 @@ export default function suite() { assert.isNull(storedDao.optimisticProposal); assert.isFalse(storedDao.isOptimisticGovernanceEnabled); + assert.isTrue(storedDao.typedProposalsEnabled); const multisigPda = multisig.getMultisigPda({ createKey: dao })[0]; const squadsMultisigVault = multisig.getVaultPda({ @@ -189,6 +190,7 @@ export default function suite() { assert.equal(storedDao.teamSponsoredPassThresholdBps, 123); assert.isNull(storedDao.optimisticProposal); assert.isFalse(storedDao.isOptimisticGovernanceEnabled); + assert.isTrue(storedDao.typedProposalsEnabled); }); it("doesn't allow an initial spending limit with a zero monthly amount", async function () { diff --git a/tests/futarchy/unit/initializeHostileLiquidateProposal.test.ts b/tests/futarchy/unit/initializeHostileLiquidateProposal.test.ts index 9e55c0a9..fe120982 100644 --- a/tests/futarchy/unit/initializeHostileLiquidateProposal.test.ts +++ b/tests/futarchy/unit/initializeHostileLiquidateProposal.test.ts @@ -7,7 +7,8 @@ import { } from "@solana/web3.js"; import BN from "bn.js"; import { assert } from "chai"; -import { assertVaultTransactionPayload } from "../../utils.js"; +import { assertVaultTransactionPayload, expectError } from "../../utils.js"; +import { setTypedProposalsEnabled } from "../utils.js"; const ONE_BUCK_PRICE = PriceMath.getAmmPrice(1, 6, 6); @@ -95,4 +96,19 @@ export default function suite() { const updatedDao = await this.futarchy.getDao(dao); assert.equal(updatedDao.proposalCount, 1); }); + + it("throws error when the DAO has typed proposals off", async function () { + await setTypedProposalsEnabled(this, dao, false); + + const callbacks = expectError( + "TypedProposalsDisabled", + "created a hostile liquidate proposal on a DAO with typed proposals off", + ); + await this.futarchy + .initializeHostileLiquidateProposal({ + dao, + liquidator: Keypair.generate().publicKey, + }) + .then(...callbacks); + }); } diff --git a/tests/futarchy/unit/initializeHostileTakeoverProposal.test.ts b/tests/futarchy/unit/initializeHostileTakeoverProposal.test.ts index e2d33e36..c67a237e 100644 --- a/tests/futarchy/unit/initializeHostileTakeoverProposal.test.ts +++ b/tests/futarchy/unit/initializeHostileTakeoverProposal.test.ts @@ -1,19 +1,15 @@ +import { getDaoAddr, PriceMath } from "@metadaoproject/programs"; import { - getDaoAddr, - getSpendingLimitAddr, - PriceMath, -} from "@metadaoproject/programs"; -import { ComputeBudgetProgram, Keypair, PublicKey } from "@solana/web3.js"; + ComputeBudgetProgram, + Keypair, + PublicKey, + TransactionInstruction, +} from "@solana/web3.js"; +import { MEMO_PROGRAM_ID } from "@solana/spl-memo"; import BN from "bn.js"; import { assert } from "chai"; -import * as multisig from "@sqds/multisig"; -import { - assertVaultTransactionPayload, - executeVaultTransaction, - expectError, - forceApproveSquadsProposal, -} from "../../utils.js"; -import { TestContext } from "../../main.test.js"; +import { assertVaultTransactionPayload, expectError } from "../../utils.js"; +import { setTypedProposalsEnabled } from "../utils.js"; const ONE_BUCK_PRICE = PriceMath.getAmmPrice(1, 6, 6); @@ -56,31 +52,24 @@ export default function suite() { [dao] = getDaoAddr({ nonce, daoCreator: this.payer.publicKey }); }); - // update_dao re-pointing the team and changing nothing else - function expectedUpdateDaoIx( - context: TestContext, + // The whole payload: one memo naming the proposal and the declared regime. + // A passed takeover is applied by finalize, so nothing in it can execute. + function expectedMemoIx( + proposal: PublicKey, newTeamAddress: PublicKey, + spendingLimit: string, ) { - return context.futarchy - .updateDaoIx({ - dao, - params: { - passThresholdBps: null, - secondsPerProposal: null, - twapInitialObservation: null, - twapMaxObservationChangePerUpdate: null, - twapStartDelaySeconds: null, - minQuoteFutarchicLiquidity: null, - minBaseFutarchicLiquidity: null, - baseToStake: null, - teamSponsoredPassThresholdBps: null, - teamAddress: newTeamAddress, - }, - }) - .instruction(); + return new TransactionInstruction({ + programId: MEMO_PROGRAM_ID, + keys: [], + data: Buffer.from( + `metadao-takeover/1 proposal=${proposal.toBase58()} new_team=${newTeamAddress.toBase58()} spending_limit=${spendingLimit}`, + "utf8", + ), + }); } - it("bakes only a vault-signed update_dao when keeping the limit and snapshots the kind's params", async function () { + it("bakes exactly one program-built memo as the whole payload and snapshots the kind's params", async function () { const newTeamAddress = Keypair.generate().publicKey; const { proposal, squadsProposal, squadsTransaction } = @@ -91,7 +80,7 @@ export default function suite() { }); await assertVaultTransactionPayload(this, dao, squadsTransaction, [ - await expectedUpdateDaoIx(this, newTeamAddress), + expectedMemoIx(proposal, newTeamAddress, "keep"), ]); const storedProposal = await this.futarchy.getProposal(proposal); @@ -121,7 +110,7 @@ export default function suite() { assert.equal(storedDao.proposalCount, 1); }); - it("appends a set_spending_limit with a None config when removing the limit", async function () { + it("formats a Remove action into the memo", async function () { const newTeamAddress = Keypair.generate().publicKey; const { proposal, squadsTransaction } = @@ -132,10 +121,7 @@ export default function suite() { }); await assertVaultTransactionPayload(this, dao, squadsTransaction, [ - await expectedUpdateDaoIx(this, newTeamAddress), - await this.futarchy - .setSpendingLimitIx({ dao, config: null }) - .instruction(), + expectedMemoIx(proposal, newTeamAddress, "remove"), ]); const storedProposal = await this.futarchy.getProposal(proposal); @@ -144,7 +130,7 @@ export default function suite() { ); }); - it("appends a set_spending_limit carrying the declared config verbatim when setting the limit", async function () { + it("formats a Set action's monthly amount into the memo and stores the config verbatim", async function () { const newTeamAddress = Keypair.generate().publicKey; const config = { amountPerMonth: new BN(25_000_000_000), // 25,000 USDC @@ -159,8 +145,7 @@ export default function suite() { }); await assertVaultTransactionPayload(this, dao, squadsTransaction, [ - await expectedUpdateDaoIx(this, newTeamAddress), - await this.futarchy.setSpendingLimitIx({ dao, config }).instruction(), + expectedMemoIx(proposal, newTeamAddress, "set:25000000000"), ]); const storedProposal = await this.futarchy.getProposal(proposal); @@ -176,61 +161,6 @@ export default function suite() { ); }); - it("the executed and synced end state matches the declaration", async function () { - const newTeamAddress = Keypair.generate().publicKey; - const config = { - amountPerMonth: new BN(25_000_000_000), // 25,000 USDC - members: [Keypair.generate().publicKey, Keypair.generate().publicKey], - }; - - const { squadsProposal, squadsTransaction } = - await this.futarchy.initializeHostileTakeoverProposal({ - dao, - newTeamAddress, - spendingLimitAction: { set: { 0: config } }, - }); - - await forceApproveSquadsProposal(this, squadsProposal); - await executeVaultTransaction(this, dao, squadsTransaction); - - let storedDao = await this.futarchy.getDao(dao); - assert.ok(storedDao.teamAddress.equals(newTeamAddress)); - assert.equal( - storedDao.initialSpendingLimit.amountPerMonth.toString(), - config.amountPerMonth.toString(), - ); - assert.deepEqual( - storedDao.initialSpendingLimit.members.map((m) => m.toBase58()), - config.members.map((m) => m.toBase58()), - ); - assert.isTrue(storedDao.spendingLimitDirty); - - await this.futarchy.syncSpendingLimitIx({ dao }).rpc(); - - const [spendingLimitPda] = getSpendingLimitAddr({ dao }); - const storedLimit = - await multisig.accounts.SpendingLimit.fromAccountAddress( - this.squadsConnection, - spendingLimitPda, - ); - assert.equal( - storedLimit.amount.toString(), - config.amountPerMonth.toString(), - ); - assert.equal( - storedLimit.remainingAmount.toString(), - config.amountPerMonth.toString(), - ); - // Squads stores members sorted, so compare as sets - assert.sameMembers( - storedLimit.members.map((m) => m.toBase58()), - config.members.map((m) => m.toBase58()), - ); - - storedDao = await this.futarchy.getDao(dao); - assert.isFalse(storedDao.spendingLimitDirty); - }); - it("throws error when a Set action has more than 10 members", async function () { const elevenMembers = Array.from( { length: 11 }, @@ -336,4 +266,20 @@ export default function suite() { }) .then(...callbacks); }); + + it("throws error when the DAO has typed proposals off", async function () { + await setTypedProposalsEnabled(this, dao, false); + + const callbacks = expectError( + "TypedProposalsDisabled", + "created a hostile takeover proposal on a DAO with typed proposals off", + ); + await this.futarchy + .initializeHostileTakeoverProposal({ + dao, + newTeamAddress: Keypair.generate().publicKey, + spendingLimitAction: { keep: {} }, + }) + .then(...callbacks); + }); } diff --git a/tests/futarchy/unit/initializeLargeSpendProposal.test.ts b/tests/futarchy/unit/initializeLargeSpendProposal.test.ts index 7154c5e3..858ca722 100644 --- a/tests/futarchy/unit/initializeLargeSpendProposal.test.ts +++ b/tests/futarchy/unit/initializeLargeSpendProposal.test.ts @@ -13,6 +13,7 @@ import { forceApproveSquadsProposal, setupBasicDao, } from "../../utils.js"; +import { setTypedProposalsEnabled } from "../utils.js"; const ONE_BUCK_PRICE = PriceMath.getAmmPrice(1, 6, 6); @@ -153,6 +154,18 @@ export default function suite() { .then(...callbacks); }); + it("throws error when the DAO has typed proposals off", async function () { + await setTypedProposalsEnabled(this, dao, false); + + const callbacks = expectError( + "TypedProposalsDisabled", + "created a large spend proposal on a DAO with typed proposals off", + ); + await this.futarchy + .initializeLargeSpendProposal({ dao, amount: AMOUNT_PER_MONTH }) + .then(...callbacks); + }); + it("the transfer payload executes once the Squads proposal is approved", async function () { const amount = AMOUNT_PER_MONTH.muln(3); diff --git a/tests/futarchy/unit/initializeMintTokensProposal.test.ts b/tests/futarchy/unit/initializeMintTokensProposal.test.ts index 1d3e8c37..3ee81d47 100644 --- a/tests/futarchy/unit/initializeMintTokensProposal.test.ts +++ b/tests/futarchy/unit/initializeMintTokensProposal.test.ts @@ -19,6 +19,7 @@ import { expectError, forceApproveSquadsProposal, } from "../../utils.js"; +import { setTypedProposalsEnabled } from "../utils.js"; import { TestContext } from "../../main.test.js"; async function setMintAuthority( @@ -206,6 +207,23 @@ export default function suite() { .then(...callbacks); }); + it("throws error when the DAO has typed proposals off", async function () { + await setMintAuthority(this, META, squadsMultisigVault); + await setTypedProposalsEnabled(this, dao, false); + + const callbacks = expectError( + "TypedProposalsDisabled", + "created a mint tokens proposal on a DAO with typed proposals off", + ); + await this.futarchy + .initializeMintTokensProposal({ + dao, + amount: new BN(1_000_000_000), + recipient, + }) + .then(...callbacks); + }); + it("the MintTo payload executes once the Squads proposal is approved", async function () { await setMintAuthority(this, META, squadsMultisigVault); diff --git a/tests/futarchy/unit/initializeProposal.test.ts b/tests/futarchy/unit/initializeProposal.test.ts index e51427d3..9b4a6525 100644 --- a/tests/futarchy/unit/initializeProposal.test.ts +++ b/tests/futarchy/unit/initializeProposal.test.ts @@ -5,11 +5,18 @@ import { } from "@metadaoproject/programs"; import { ComputeBudgetProgram, + Keypair, PublicKey, + SystemProgram, Transaction, TransactionMessage, } from "@solana/web3.js"; import BN from "bn.js"; +import { + addLookupsToVaultTransaction, + expectError, + setLookupTableAccount, +} from "../../utils.js"; import { assert } from "chai"; import * as multisig from "@sqds/multisig"; const { Permissions, Permission } = multisig.types; @@ -72,6 +79,60 @@ export default function suite() { }); }); + async function createSquadsProposal( + context: any, + daoKey: PublicKey, + ): Promise<{ squadsProposal: PublicKey; squadsTransaction: PublicKey }> { + const multisigPda = multisig.getMultisigPda({ createKey: daoKey })[0]; + + const transactionMessage = new TransactionMessage({ + payerKey: context.payer.publicKey, + recentBlockhash: (await context.banksClient.getLatestBlockhash())[0], + instructions: [ + SystemProgram.transfer({ + fromPubkey: context.payer.publicKey, + toPubkey: context.payer.publicKey, + lamports: 1, + }), + ], + }); + + const tx = new Transaction().add( + multisig.instructions.vaultTransactionCreate({ + multisigPda, + transactionIndex: 1n, + creator: PERMISSIONLESS_ACCOUNT.publicKey, + rentPayer: context.payer.publicKey, + vaultIndex: 0, + ephemeralSigners: 0, + transactionMessage, + }), + multisig.instructions.proposalCreate({ + multisigPda, + transactionIndex: 1n, + creator: PERMISSIONLESS_ACCOUNT.publicKey, + rentPayer: context.payer.publicKey, + }), + ); + + 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] = multisig.getProposalPda({ + multisigPda, + transactionIndex: 1n, + }); + const [squadsTransaction] = multisig.getTransactionPda({ + multisigPda, + index: 1n, + }); + + return { squadsProposal, squadsTransaction }; + } + it("should initialize a proposal", async function () { // Create a simple instruction for the proposal const updateDaoIx = await this.futarchy @@ -88,6 +149,7 @@ export default function suite() { twapStartDelaySeconds: null, teamSponsoredPassThresholdBps: null, teamAddress: null, + typedProposalsEnabled: null, }, }) .instruction(); @@ -166,4 +228,52 @@ export default function suite() { const storedDao = await this.futarchy.getDao(dao); assert.equal(storedDao.proposalCount, 1); }); + + it("rejects a vault transaction referencing an unfrozen lookup table", async function () { + const { squadsProposal, squadsTransaction } = await createSquadsProposal( + this, + dao, + ); + + const lookupTable = Keypair.generate().publicKey; + setLookupTableAccount(this, lookupTable, this.payer.publicKey, [ + Keypair.generate().publicKey, + ]); + await addLookupsToVaultTransaction(this, squadsTransaction, [ + { accountKey: lookupTable, writableIndexes: [0], readonlyIndexes: [] }, + ]); + + const callbacks = expectError( + "UnfrozenAddressLookupTable", + "initialized a proposal whose payload resolves through an unfrozen lookup table", + ); + + await this.futarchy + .initializeProposal(dao, squadsProposal) + .then(callbacks[0], callbacks[1]); + }); + + it("rejects a frozen lookup table when the message references an index it doesn't hold", async function () { + const { squadsProposal, squadsTransaction } = await createSquadsProposal( + this, + dao, + ); + + const lookupTable = Keypair.generate().publicKey; + setLookupTableAccount(this, lookupTable, null, [ + Keypair.generate().publicKey, + ]); + await addLookupsToVaultTransaction(this, squadsTransaction, [ + { accountKey: lookupTable, writableIndexes: [0], readonlyIndexes: [5] }, + ]); + + const callbacks = expectError( + "InvalidAddressLookupTable", + "initialized a proposal with a lookup index past the end of its table", + ); + + await this.futarchy + .initializeProposal(dao, squadsProposal) + .then(callbacks[0], callbacks[1]); + }); } diff --git a/tests/futarchy/unit/initializeSpendingLimitChangeProposal.test.ts b/tests/futarchy/unit/initializeSpendingLimitChangeProposal.test.ts index c5d89a7b..e395cd67 100644 --- a/tests/futarchy/unit/initializeSpendingLimitChangeProposal.test.ts +++ b/tests/futarchy/unit/initializeSpendingLimitChangeProposal.test.ts @@ -13,6 +13,7 @@ import { expectError, forceApproveSquadsProposal, } from "../../utils.js"; +import { setTypedProposalsEnabled } from "../utils.js"; const ONE_BUCK_PRICE = PriceMath.getAmmPrice(1, 6, 6); @@ -187,6 +188,24 @@ export default function suite() { .then(...callbacks); }); + it("throws error when the DAO has typed proposals off", async function () { + await setTypedProposalsEnabled(this, dao, false); + + const callbacks = expectError( + "TypedProposalsDisabled", + "created a spending limit change proposal on a DAO with typed proposals off", + ); + await this.futarchy + .initializeSpendingLimitChangeProposal({ + dao, + config: { + amountPerMonth: new BN(1_000_000_000), // 1,000 USDC + members: [Keypair.generate().publicKey], + }, + }) + .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 6970cabc..7f00d8c2 100644 --- a/tests/futarchy/unit/launchProposal.test.ts +++ b/tests/futarchy/unit/launchProposal.test.ts @@ -12,15 +12,36 @@ import { } from "@solana/web3.js"; import BN from "bn.js"; import { + addLookupsToVaultTransaction, executeVaultTransaction, expectError, forceApproveSquadsProposal, + setLookupTableAccount, } from "../../utils.js"; +import { + TYPED_PROPOSALS_OFF_DAO_TERMS, + rewriteAccount, + setTypedProposalsEnabled, + setupTypedProposalsOffDao, + updateDaoViaVault, +} from "../utils.js"; +import { TestContext } from "../../main.test.js"; import { assert } from "chai"; import * as multisig from "@sqds/multisig"; const THOUSAND_BUCK_PRICE = PriceMath.getAmmPrice(1000, 6, 6); +const CATALOG_DURATION_SECONDS = 60 * 60 * 24 * 10; +const CATALOG_PASS_THRESHOLD_BPS = 1000; +const CATALOG_TWAP_START_DELAY_SECONDS = 60 * 60 * 24; +const CATALOG_SPENDING_LIMIT_CHANGE_DURATION_SECONDS = 60 * 60 * 24 * 5; +const CATALOG_SPENDING_LIMIT_CHANGE_PASS_THRESHOLD_BPS = 500; + +// Admin-tuned terms that match neither the DAO's nor the catalog's, above +// both warm-ups. +const TUNED_DURATION_SECONDS = 60 * 60 * 24 * 3; +const TUNED_PASS_THRESHOLD_BPS = 700; + export default function suite() { let META: PublicKey, USDC: PublicKey, dao: PublicKey, spendingLimit: BN; @@ -96,12 +117,12 @@ export default function suite() { } /** - * Helper function to initialize a proposal for a DAO + * Helper function to create a Squads proposal, with its vault transaction, for a DAO */ - async function initializeProposal( + async function createSquadsProposal( context: any, dao: PublicKey, - ): Promise<{ proposal: PublicKey; squadsProposal: PublicKey }> { + ): Promise<{ squadsProposal: PublicKey; squadsTransaction: PublicKey }> { const updateDaoIx = await context.futarchy .updateDaoIx({ dao, @@ -116,6 +137,7 @@ export default function suite() { twapStartDelaySeconds: null, teamSponsoredPassThresholdBps: null, teamAddress: null, + typedProposalsEnabled: null, }, }) .instruction(); @@ -148,6 +170,10 @@ export default function suite() { multisigPda, transactionIndex: 1n, }); + const [squadsTransaction] = multisig.getTransactionPda({ + multisigPda, + index: 1n, + }); const tx = new Transaction().add(vaultTxCreate, proposalCreateIx); tx.recentBlockhash = (await context.banksClient.getLatestBlockhash())[0]; @@ -156,12 +182,31 @@ export default function suite() { await context.banksClient.processTransaction(tx); + return { squadsProposal, squadsTransaction }; + } + + /** + * Helper function to initialize a proposal for a DAO + */ + async function initializeProposal( + context: any, + dao: PublicKey, + ): Promise<{ + proposal: PublicKey; + squadsProposal: PublicKey; + squadsTransaction: PublicKey; + }> { + const { squadsProposal, squadsTransaction } = await createSquadsProposal( + context, + dao, + ); + const proposal = await context.futarchy.initializeProposal( dao, squadsProposal, ); - return { proposal, squadsProposal }; + return { proposal, squadsProposal, squadsTransaction }; } it("succeeds for team-sponsored proposal regardless of stake", async function () { @@ -192,7 +237,8 @@ export default function suite() { ]) .rpc(); - const { proposal, squadsProposal } = await initializeProposal(this, dao); + const { proposal, squadsProposal, squadsTransaction } = + await initializeProposal(this, dao); // Sponsor the proposal (sets sponsored_by to the team) await this.futarchy @@ -211,6 +257,7 @@ export default function suite() { baseMint: META, quoteMint: USDC, squadsProposal, + squadsTransaction, }) .rpc(); @@ -249,7 +296,8 @@ export default function suite() { ]) .rpc(); - const { proposal, squadsProposal } = await initializeProposal(this, dao); + const { proposal, squadsProposal, squadsTransaction } = + await initializeProposal(this, dao); // Stake more than threshold const stakeAmount = new BN(200 * 10 ** 6); // 200 tokens (> 100 threshold) @@ -270,6 +318,7 @@ export default function suite() { baseMint: META, quoteMint: USDC, squadsProposal, + squadsTransaction, }) .rpc(); @@ -307,7 +356,8 @@ export default function suite() { ]) .rpc(); - const { proposal, squadsProposal } = await initializeProposal(this, dao); + const { proposal, squadsProposal, squadsTransaction } = + await initializeProposal(this, dao); // Stake exactly the threshold amount await this.futarchy @@ -327,6 +377,7 @@ export default function suite() { baseMint: META, quoteMint: USDC, squadsProposal, + squadsTransaction, }) .rpc(); @@ -364,7 +415,8 @@ export default function suite() { ]) .rpc(); - const { proposal, squadsProposal } = await initializeProposal(this, dao); + const { proposal, squadsProposal, squadsTransaction } = + await initializeProposal(this, dao); // Sponsor the proposal await this.futarchy @@ -388,6 +440,7 @@ export default function suite() { baseMint: META, quoteMint: USDC, squadsProposal, + squadsTransaction, }) .rpc(); @@ -427,7 +480,8 @@ export default function suite() { const storedDaoBefore = await this.futarchy.getDao(dao); assert.equal(storedDaoBefore.twapStartDelaySeconds, 108_000); - const { proposal, squadsProposal } = await initializeProposal(this, dao); + const { proposal, squadsProposal, squadsTransaction } = + await initializeProposal(this, dao); await this.futarchy .sponsorProposalIx({ @@ -444,6 +498,7 @@ export default function suite() { baseMint: META, quoteMint: USDC, squadsProposal, + squadsTransaction, }) .rpc(); @@ -534,7 +589,8 @@ export default function suite() { ]) .rpc(); - const { proposal, squadsProposal } = await initializeProposal(this, dao); + const { proposal, squadsProposal, squadsTransaction } = + await initializeProposal(this, dao); // Stake less than threshold const insufficientStake = new BN(50 * 10 ** 6); // 50 tokens (< 100 threshold) @@ -560,6 +616,7 @@ export default function suite() { baseMint: META, quoteMint: USDC, squadsProposal, + squadsTransaction, }) .rpc() .then(callbacks[0], callbacks[1]); @@ -678,13 +735,7 @@ export default function suite() { // 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); + await updateDaoViaVault(this, dao, { teamAddress: newTeam.publicKey }); const staleSponsorCallbacks = expectError( "ProposalNotTeamSponsored", @@ -977,13 +1028,7 @@ export default function suite() { .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 updateDaoViaVault(this, dao, { teamAddress: newTeam.publicKey }); const callbacks = expectError( "ProposalNotTeamSponsored", @@ -1055,7 +1100,8 @@ export default function suite() { ]) .rpc(); - const { proposal, squadsProposal } = await initializeProposal(this, dao); + const { proposal, squadsProposal, squadsTransaction } = + await initializeProposal(this, dao); await this.futarchy .sponsorProposalIx({ @@ -1065,13 +1111,9 @@ export default function suite() { }) .rpc(); - const takeover = await this.futarchy.initializeHostileTakeoverProposal({ - dao, - newTeamAddress: Keypair.generate().publicKey, - spendingLimitAction: { keep: {} }, + await updateDaoViaVault(this, dao, { + teamAddress: Keypair.generate().publicKey, }); - await forceApproveSquadsProposal(this, takeover.squadsProposal); - await executeVaultTransaction(this, dao, takeover.squadsTransaction); const callbacks = expectError( "InsufficientStakeToLaunch", @@ -1085,6 +1127,7 @@ export default function suite() { baseMint: META, quoteMint: USDC, squadsProposal, + squadsTransaction, }) .rpc() .then(callbacks[0], callbacks[1]); @@ -1107,6 +1150,7 @@ export default function suite() { baseMint: META, quoteMint: USDC, squadsProposal, + squadsTransaction, }) .postInstructions([ ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), @@ -1219,4 +1263,530 @@ export default function suite() { const storedProposal = await this.futarchy.getProposal(second.proposal); assert.exists(storedProposal.state.pending); }); + + it("fails to launch a hostile takeover whose target is already the team", 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 newTeam = Keypair.generate(); + const stale = await this.futarchy.initializeHostileTakeoverProposal({ + dao, + newTeamAddress: newTeam.publicKey, + spendingLimitAction: { keep: {} }, + }); + + // Install the same team while the draft is still unlaunched + await updateDaoViaVault(this, dao, { teamAddress: newTeam.publicKey }); + + const storedDao = await this.futarchy.getDao(dao); + assert.equal( + storedDao.teamAddress.toBase58(), + newTeam.publicKey.toBase58(), + ); + + const callbacks = expectError( + "InvalidTeamAddress", + "launched a hostile takeover whose target is already the team", + ); + + await this.futarchy + .launchProposalIx({ + proposal: stale.proposal, + dao, + baseMint: META, + quoteMint: USDC, + squadsProposal: stale.squadsProposal, + }) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("rejects launching a generic proposal without its vault transaction", async function () { + const dao = await createDaoWithStakeThreshold( + this, + META, + USDC, + new BN(0), + this.payer, + ); + const { proposal, squadsProposal } = await initializeProposal(this, dao); + + const callbacks = expectError( + "InvalidSquadsVaultTransaction", + "launched a generic proposal without its vault transaction", + ); + + await this.futarchy + .launchProposalIx({ + proposal, + dao, + baseMint: META, + quoteMint: USDC, + squadsProposal, + }) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("rejects extra launch accounts on a typed proposal", async function () { + const dao = await createDaoWithStakeThreshold( + this, + META, + USDC, + new BN(0), + this.payer, + ); + const takeover = await this.futarchy.initializeHostileTakeoverProposal({ + dao, + newTeamAddress: Keypair.generate().publicKey, + spendingLimitAction: { keep: {} }, + }); + + const callbacks = expectError( + "UnexpectedLaunchAccounts", + "launched a typed proposal with its vault transaction as an extra account", + ); + + await this.futarchy + .launchProposalIx({ + proposal: takeover.proposal, + dao, + baseMint: META, + quoteMint: USDC, + squadsProposal: takeover.squadsProposal, + squadsTransaction: takeover.squadsTransaction, + }) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("rejects an unfrozen lookup table at launch on an already-initialized proposal", 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 { squadsProposal, squadsTransaction } = await createSquadsProposal( + this, + dao, + ); + const proposal = await this.futarchy.initializeProposal( + dao, + squadsProposal, + ); + + // A draft that predates this check: the lookup appears in the stored + // message only after initialize_proposal ran without seeing it + const lookupTable = Keypair.generate().publicKey; + setLookupTableAccount(this, lookupTable, this.payer.publicKey, [ + Keypair.generate().publicKey, + ]); + await addLookupsToVaultTransaction(this, squadsTransaction, [ + { accountKey: lookupTable, writableIndexes: [0], readonlyIndexes: [] }, + ]); + + const callbacks = expectError( + "UnfrozenAddressLookupTable", + "launched a proposal whose payload resolves through an unfrozen lookup table", + ); + + await this.futarchy + .launchProposalIx({ + proposal, + dao, + baseMint: META, + quoteMint: USDC, + squadsProposal, + squadsTransaction, + lookupTables: [lookupTable], + }) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("launches a proposal whose payload resolves through a frozen, in-bounds lookup table", 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 { squadsProposal, squadsTransaction } = await createSquadsProposal( + this, + dao, + ); + + const lookupTable = Keypair.generate().publicKey; + setLookupTableAccount(this, lookupTable, null, [ + Keypair.generate().publicKey, + Keypair.generate().publicKey, + ]); + await addLookupsToVaultTransaction(this, squadsTransaction, [ + { accountKey: lookupTable, writableIndexes: [0], readonlyIndexes: [1] }, + ]); + + const proposal = await this.futarchy.initializeProposal( + dao, + squadsProposal, + ); + + await this.futarchy + .launchProposalIx({ + proposal, + dao, + baseMint: META, + quoteMint: USDC, + squadsProposal, + squadsTransaction, + lookupTables: [lookupTable], + }) + .rpc(); + + const storedProposal = await this.futarchy.getProposal(proposal); + assert.exists(storedProposal.state.pending); + }); + + // Launch writes a proposal's duration and threshold from whatever applies at + // that moment: the DAO's own terms for a plain proposal while typed + // proposals are off, the catalog otherwise. + describe("terms at launch", function () { + let proposal: PublicKey, + squadsProposal: PublicKey, + squadsTransaction: PublicKey; + + beforeEach(async function () { + dao = await setupTypedProposalsOffDao(this, META, USDC); + ({ proposal, squadsProposal, squadsTransaction } = + await initializeProposal(this, dao)); + }); + + const launch = (ctx: TestContext) => + ctx.futarchy.launchProposalIx({ + proposal, + dao, + baseMint: META, + quoteMint: USDC, + squadsProposal, + squadsTransaction, + }); + + const stake = async (ctx: TestContext) => { + await ctx.futarchy + .stakeToProposalIx({ + proposal, + dao, + baseMint: META, + amount: TYPED_PROPOSALS_OFF_DAO_TERMS.baseToStake, + }) + .rpc(); + }; + + // One swap after the warm-up records an observation in both conditional + // pools and leaves their TWAPs equal, then the market runs out. + const runFlatMarketToEnd = async (ctx: TestContext) => { + await ctx.advanceBySeconds( + TYPED_PROPOSALS_OFF_DAO_TERMS.twapStartDelaySeconds + 60, + ); + await ctx.futarchy + .spotSwapIx({ + dao, + baseMint: META, + quoteMint: USDC, + swapType: "buy", + inputAmount: new BN(1_000), + }) + .rpc(); + await ctx.advanceBySeconds( + TYPED_PROPOSALS_OFF_DAO_TERMS.secondsPerProposal, + ); + }; + + it("writes the DAO's settings as of launch, not as of create", async function () { + const secondsPerProposal = + TYPED_PROPOSALS_OFF_DAO_TERMS.secondsPerProposal * 2; + const passThresholdBps = + TYPED_PROPOSALS_OFF_DAO_TERMS.passThresholdBps + 200; + await updateDaoViaVault(this, dao, { + secondsPerProposal, + passThresholdBps, + }); + + const draft = await this.futarchy.getProposal(proposal); + assert.equal( + draft.durationInSeconds, + TYPED_PROPOSALS_OFF_DAO_TERMS.secondsPerProposal, + ); + assert.equal( + draft.passThresholdBps, + TYPED_PROPOSALS_OFF_DAO_TERMS.passThresholdBps, + ); + + await stake(this); + await launch(this).rpc(); + + const launched = await this.futarchy.getProposal(proposal); + assert.exists(launched.state.pending); + assert.equal(launched.durationInSeconds, secondsPerProposal); + assert.equal(launched.passThresholdBps, passThresholdBps); + }); + + it("applies the team-sponsored threshold when the sponsorship stands at launch", async function () { + await this.futarchy.sponsorProposalIx({ proposal, dao }).rpc(); + await launch(this).rpc(); + + const launched = await this.futarchy.getProposal(proposal); + assert.exists(launched.state.pending); + assert.equal( + launched.durationInSeconds, + TYPED_PROPOSALS_OFF_DAO_TERMS.secondsPerProposal, + ); + assert.equal( + launched.passThresholdBps, + TYPED_PROPOSALS_OFF_DAO_TERMS.teamSponsoredPassThresholdBps, + ); + }); + + it("a stale sponsorship gets the plain threshold and owes the stake", async function () { + await this.futarchy.sponsorProposalIx({ proposal, dao }).rpc(); + await updateDaoViaVault(this, dao, { + teamAddress: Keypair.generate().publicKey, + }); + + const callbacks = expectError( + "InsufficientStakeToLaunch", + "launched on a stale sponsorship with no stake", + ); + await launch(this) + .rpc() + .then(...callbacks); + + await stake(this); + + // The compute-unit price makes this transaction's hash differ from the + // failed launch attempt, so it isn't rejected as already processed. + await launch(this) + .postInstructions([ + ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), + ]) + .rpc(); + + const launched = await this.futarchy.getProposal(proposal); + assert.exists(launched.state.pending); + assert.equal( + launched.sponsoredBy?.toBase58(), + this.payer.publicKey.toBase58(), + ); + assert.equal( + launched.passThresholdBps, + TYPED_PROPOSALS_OFF_DAO_TERMS.passThresholdBps, + ); + }); + + it("starts the conditional oracles after the DAO's warm-up while typed proposals are off", async function () { + await this.futarchy.sponsorProposalIx({ proposal, dao }).rpc(); + await launch(this).rpc(); + + const { pass, fail } = (await this.futarchy.getDao(dao)).amm.state + .futarchy; + assert.equal( + pass.oracle.startDelaySeconds, + TYPED_PROPOSALS_OFF_DAO_TERMS.twapStartDelaySeconds, + ); + assert.equal( + fail.oracle.startDelaySeconds, + TYPED_PROPOSALS_OFF_DAO_TERMS.twapStartDelaySeconds, + ); + }); + + it("a draft created while typed proposals are off launches under the catalog once the DAO opts in", async function () { + await this.futarchy.sponsorProposalIx({ proposal, dao }).rpc(); + await updateDaoViaVault(this, dao, { typedProposalsEnabled: true }); + + await launch(this).rpc(); + + const launched = await this.futarchy.getProposal(proposal); + assert.equal(launched.durationInSeconds, CATALOG_DURATION_SECONDS); + assert.equal(launched.passThresholdBps, CATALOG_PASS_THRESHOLD_BPS); + + const { pass, fail } = (await this.futarchy.getDao(dao)).amm.state + .futarchy; + assert.equal( + pass.oracle.startDelaySeconds, + CATALOG_TWAP_START_DELAY_SECONDS, + ); + assert.equal( + fail.oracle.startDelaySeconds, + CATALOG_TWAP_START_DELAY_SECONDS, + ); + }); + + it("a sponsored proposal passes at the DAO's negative threshold on a flat market", async function () { + await this.futarchy.sponsorProposalIx({ proposal, dao }).rpc(); + await launch(this).rpc(); + + await runFlatMarketToEnd(this); + await this.futarchy.finalizeProposal(proposal); + + const finalized = await this.futarchy.getProposal(proposal); + assert.exists(finalized.state.passed); + }); + + it("an unsponsored proposal fails at the DAO's positive threshold on a flat market", async function () { + await stake(this); + await launch(this).rpc(); + + await runFlatMarketToEnd(this); + await this.futarchy.finalizeProposal(proposal); + + const finalized = await this.futarchy.getProposal(proposal); + assert.exists(finalized.state.failed); + }); + + it("an admin override survives launch while typed proposals are off", async function () { + await this.futarchy + .adminUpdateProposalParamsIx({ + proposal, + dao, + durationInSeconds: TUNED_DURATION_SECONDS, + passThresholdBps: TUNED_PASS_THRESHOLD_BPS, + }) + .rpc(); + await this.futarchy.sponsorProposalIx({ proposal, dao }).rpc(); + + await launch(this).rpc(); + + const launched = await this.futarchy.getProposal(proposal); + assert.equal(launched.durationInSeconds, TUNED_DURATION_SECONDS); + assert.equal(launched.passThresholdBps, TUNED_PASS_THRESHOLD_BPS); + + const { pass } = (await this.futarchy.getDao(dao)).amm.state.futarchy; + assert.equal( + pass.oracle.startDelaySeconds, + TYPED_PROPOSALS_OFF_DAO_TERMS.twapStartDelaySeconds, + ); + }); + + it("an admin override survives launch while typed proposals are on", async function () { + await setTypedProposalsEnabled(this, dao, true); + await this.futarchy + .adminUpdateProposalParamsIx({ + proposal, + dao, + durationInSeconds: TUNED_DURATION_SECONDS, + passThresholdBps: TUNED_PASS_THRESHOLD_BPS, + }) + .rpc(); + await this.futarchy.sponsorProposalIx({ proposal, dao }).rpc(); + + await launch(this).rpc(); + + const launched = await this.futarchy.getProposal(proposal); + assert.equal(launched.durationInSeconds, TUNED_DURATION_SECONDS); + assert.equal(launched.passThresholdBps, TUNED_PASS_THRESHOLD_BPS); + + const { pass } = (await this.futarchy.getDao(dao)).amm.state.futarchy; + assert.equal( + pass.oracle.startDelaySeconds, + CATALOG_TWAP_START_DELAY_SECONDS, + ); + }); + + it("a typed draft launches under the catalog even if its snapshot was altered by hand", async function () { + await setTypedProposalsEnabled(this, dao, true); + + const typed = await this.futarchy.initializeSpendingLimitChangeProposal({ + dao, + config: { + amountPerMonth: new BN(1_000_000_000), // 1,000 USDC + members: [Keypair.generate().publicKey], + }, + }); + await rewriteAccount(this, typed.proposal, "proposal", (decoded) => { + decoded.durationInSeconds = TUNED_DURATION_SECONDS; + decoded.passThresholdBps = TUNED_PASS_THRESHOLD_BPS; + }); + await this.futarchy + .sponsorProposalIx({ proposal: typed.proposal, dao }) + .rpc(); + + await this.futarchy + .launchProposalIx({ + proposal: typed.proposal, + dao, + baseMint: META, + quoteMint: USDC, + squadsProposal: typed.squadsProposal, + }) + .rpc(); + + const launched = await this.futarchy.getProposal(typed.proposal); + assert.exists(launched.state.pending); + assert.equal( + launched.durationInSeconds, + CATALOG_SPENDING_LIMIT_CHANGE_DURATION_SECONDS, + ); + assert.equal( + launched.passThresholdBps, + CATALOG_SPENDING_LIMIT_CHANGE_PASS_THRESHOLD_BPS, + ); + }); + }); } diff --git a/tests/futarchy/unit/liquidatedGuards.test.ts b/tests/futarchy/unit/liquidatedGuards.test.ts index c159c32a..5b3da327 100644 --- a/tests/futarchy/unit/liquidatedGuards.test.ts +++ b/tests/futarchy/unit/liquidatedGuards.test.ts @@ -24,6 +24,7 @@ import { THOUSAND_BUCK_PRICE, } from "../../utils.js"; import { TestContext } from "../../main.test.js"; +import { rewriteAccount } from "../utils.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: @@ -40,6 +41,7 @@ export default function suite() { dao: PublicKey, draftProposal: PublicKey, draftSquadsProposal: PublicKey, + draftSquadsTransaction: PublicKey, liquidationProposal: PublicKey; before(async function () { @@ -129,7 +131,10 @@ export default function suite() { // A pre-liquidation draft with stake: staking more must refuse afterward, // unstaking must still work - ({ squadsProposal: draftSquadsProposal } = await createSquadsVaultTx(this, [ + ({ + squadsProposal: draftSquadsProposal, + squadsTransaction: draftSquadsTransaction, + } = await createSquadsVaultTx(this, [ { programId: MEMO_PROGRAM_ID, keys: [], @@ -357,6 +362,7 @@ export default function suite() { baseMint: META, quoteMint: USDC, squadsProposal: draftSquadsProposal, + squadsTransaction: draftSquadsTransaction, }) .rpc() .then(callbacks[0], callbacks[1]); @@ -434,6 +440,7 @@ export default function suite() { baseToStake: null, teamSponsoredPassThresholdBps: null, teamAddress: null, + typedProposalsEnabled: null, }, }) .instruction(); @@ -640,7 +647,11 @@ export default function suite() { reservedDao: PublicKey, liquidatorA: PublicKey, rivalLiquidation: { proposal: PublicKey; squadsProposal: PublicKey }, - stagedDraft: { proposal: PublicKey; squadsProposal: PublicKey }; + stagedDraft: { + proposal: PublicKey; + squadsProposal: PublicKey; + squadsTransaction: PublicKey; + }; before(async function () { base = await this.createMint(this.payer.publicKey, 6); @@ -721,24 +732,27 @@ export default function suite() { }, ); - const { squadsProposal: stagedSquadsProposal } = - await createSquadsVaultTx( - this, - [ - { - programId: MEMO_PROGRAM_ID, - keys: [], - data: Buffer.from("gap proposal"), - }, - ], - reservedDao, - ); + const { + squadsProposal: stagedSquadsProposal, + squadsTransaction: stagedSquadsTransaction, + } = await createSquadsVaultTx( + this, + [ + { + programId: MEMO_PROGRAM_ID, + keys: [], + data: Buffer.from("gap proposal"), + }, + ], + reservedDao, + ); stagedDraft = { proposal: await this.futarchy.initializeProposal( reservedDao, stagedSquadsProposal, ), squadsProposal: stagedSquadsProposal, + squadsTransaction: stagedSquadsTransaction, }; await this.futarchy @@ -765,6 +779,19 @@ export default function suite() { assert.ok(storedDao.liquidator.equals(liquidatorA)); }); + it("raises the spending-limit flag without a record, and the sync clears it", async function () { + let storedDao = await this.futarchy.getDao(reservedDao); + assert.isNull(storedDao.initialSpendingLimit); + assert.isTrue(storedDao.spendingLimitDirty); + + await this.futarchy.syncSpendingLimitIx({ dao: reservedDao }).rpc(); + + storedDao = await this.futarchy.getDao(reservedDao); + assert.isFalse(storedDao.spendingLimitDirty); + const [spendingLimitPda] = getSpendingLimitAddr({ dao: reservedDao }); + assert.isNull(await this.banksClient.getAccount(spendingLimitPda)); + }); + it("refuses to launch a second liquidation once the first has passed", async function () { const callbacks = expectError( "DaoLiquidated", @@ -800,9 +827,147 @@ export default function suite() { baseMint: base, quoteMint: quote, squadsProposal: stagedDraft.squadsProposal, + squadsTransaction: stagedDraft.squadsTransaction, }) .rpc() .then(callbacks[0], callbacks[1]); }); }); + + // A limit the migration mapped to "no record" while the Squads account stayed + // live: the flag must still be raised at liquidation so the sync removes it. + describe("a live limit the migration did not recognise", function () { + let base: PublicKey, quote: PublicKey, orphanDao: PublicKey; + + // Appends a destination allowlist to the live Squads limit, a shape + // `resize_dao` maps to no record. + async function addDestination(ctx: TestContext, dao: PublicKey) { + const [spendingLimit] = getSpendingLimitAddr({ dao }); + const raw = await ctx.banksClient.getAccount(spendingLimit); + const data = Buffer.from(raw.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 membersLen = data.readUInt32LE(131); + const destinationsOffset = 131 + 4 + 32 * membersLen; + const len = Buffer.alloc(4); + len.writeUInt32LE(1, 0); + ctx.context.setAccount(spendingLimit, { + ...raw, + data: Buffer.concat([ + data.subarray(0, destinationsOffset), + len, + Keypair.generate().publicKey.toBuffer(), + ]), + }); + } + + 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: { + amountPerMonth: new BN(10_000_000), // 10 USDC + members: [this.payer.publicKey], + }, + baseToStake: new BN(0), + teamSponsoredPassThresholdBps: 300, + teamAddress: this.payer.publicKey, + }, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), + ]) + .rpc(); + [orphanDao] = getDaoAddr({ nonce, daoCreator: this.payer.publicKey }); + + await this.futarchy + .provideLiquidityIx({ + dao: orphanDao, + 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(); + + // The post-migration state: the live limit is non-canonical and the + // DAO holds no record and a clean flag + await addDestination(this, orphanDao); + await rewriteAccount(this, orphanDao, "dao", (decoded) => { + decoded.initialSpendingLimit = null; + decoded.spendingLimitDirty = false; + }); + + const liquidation = + await this.futarchy.initializeHostileLiquidateProposal({ + dao: orphanDao, + liquidator: Keypair.generate().publicKey, + }); + await this.futarchy + .launchProposalIx({ + proposal: liquidation.proposal, + dao: orphanDao, + baseMint: base, + quoteMint: quote, + squadsProposal: liquidation.squadsProposal, + }) + .rpc(); + await passProposal(this, { + dao: orphanDao, + proposal: liquidation.proposal, + baseMint: base, + quoteMint: quote, + cranks: 50, + }); + }); + + it("raises the flag at liquidation and the sync closes the live limit", async function () { + const [spendingLimitPda] = getSpendingLimitAddr({ dao: orphanDao }); + assert.isNotNull(await this.banksClient.getAccount(spendingLimitPda)); + + let storedDao = await this.futarchy.getDao(orphanDao); + assert.isNull(storedDao.initialSpendingLimit); + assert.isTrue(storedDao.spendingLimitDirty); + + await this.futarchy.syncSpendingLimitIx({ dao: orphanDao }).rpc(); + + storedDao = await this.futarchy.getDao(orphanDao); + assert.isFalse(storedDao.spendingLimitDirty); + assert.isNull(await this.banksClient.getAccount(spendingLimitPda)); + }); + }); } diff --git a/tests/futarchy/unit/resizeDao.test.ts b/tests/futarchy/unit/resizeDao.test.ts index 7c9c015a..c8a5a3c3 100644 --- a/tests/futarchy/unit/resizeDao.test.ts +++ b/tests/futarchy/unit/resizeDao.test.ts @@ -77,13 +77,15 @@ export default function suite() { it("migrates an old DAO with the new fields defaulted, preserving every other field", async function () { const original = await this.futarchy.getDao(dao); - // The migration defaults must match a freshly-initialized DAO, so the - // whole account can round-trip equal below. + // The migration defaults match a freshly-initialized DAO except for + // `typedProposalsEnabled`, so everything else can round-trip equal below. assert.isNull(original.liquidator); assert.equal(original.lastFailedTakeoverAt.toString(), "0"); assert.equal(original.lastFailedLiquidationAt.toString(), "0"); assert.isFalse(original.spendingLimitDirty); assert.equal(original.lastBuybackFinalizedAt.toString(), "0"); + // True only because the DAO was freshly initialized. + assert.isTrue(original.typedProposalsEnabled); const { AFTER, BEFORE } = await makeOldDaoLayout(this, dao); @@ -101,9 +103,10 @@ export default function suite() { assert.equal(migrated.lastFailedLiquidationAt.toString(), "0"); assert.isFalse(migrated.spendingLimitDirty); assert.equal(migrated.lastBuybackFinalizedAt.toString(), "0"); + assert.isFalse(migrated.typedProposalsEnabled); assert.deepEqual( - JSON.parse(JSON.stringify(migrated)), + JSON.parse(JSON.stringify({ ...migrated, typedProposalsEnabled: true })), JSON.parse(JSON.stringify(original)), ); @@ -141,6 +144,20 @@ export default function suite() { assert.equal(migrated.lastFailedLiquidationAt.toString(), "0"); assert.isFalse(migrated.spendingLimitDirty); assert.equal(migrated.lastBuybackFinalizedAt.toString(), "0"); + assert.isFalse(migrated.typedProposalsEnabled); + }); + + it("lifts a zero minimum liquidity to 1", async function () { + await makeOldDaoLayout(this, dao, { + minQuoteFutarchicLiquidity: new BN(0), + minBaseFutarchicLiquidity: new BN(0), + }); + + await this.futarchy.resizeDaoIx({ dao }).rpc(); + + const migrated = await this.futarchy.getDao(dao); + assert.equal(migrated.minQuoteFutarchicLiquidity.toString(), "1"); + assert.equal(migrated.minBaseFutarchicLiquidity.toString(), "1"); }); it("is a no-op on an already-new-layout DAO", async function () { @@ -163,7 +180,7 @@ export default function suite() { const rent = await this.banksClient.getRent(); const raw0 = await this.banksClient.getAccount(dao); const AFTER = raw0.data.length; - const BEFORE = AFTER - 58; + const BEFORE = AFTER - 59; const rentBefore = rent.minimumBalance(BigInt(BEFORE)); const rentAfter = rent.minimumBalance(BigInt(AFTER)); const delta = rentAfter - rentBefore; diff --git a/tests/futarchy/unit/resizeProposal.test.ts b/tests/futarchy/unit/resizeProposal.test.ts index 9aec322a..612beb28 100644 --- a/tests/futarchy/unit/resizeProposal.test.ts +++ b/tests/futarchy/unit/resizeProposal.test.ts @@ -8,15 +8,17 @@ import { } from "@solana/web3.js"; import * as multisig from "@sqds/multisig"; import { expectError, setupBasicDao } from "../../utils.js"; +import { setTypedProposalsEnabled } from "../utils.js"; import { TestContext } from "../../main.test.js"; import { assert } from "chai"; // Rewrites a real (new-layout) Proposal account to the pre-migration on-chain // layout by re-encoding its body as the `oldProposal` IDL type (dropping the -// appended `pass_threshold_bps`, `council_can_block`, and `action`, 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. +// appended `pass_threshold_bps`, `council_can_block`, `action` and +// `params_overridden`, 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, @@ -28,9 +30,10 @@ async function makeOldLayout( ): Promise<{ AFTER: number; BEFORE: number }> { const raw = await ctx.banksClient.getAccount(proposal); const AFTER = raw.data.length; - // 401 bytes: sponsored_by (Option) in place of is_team_sponsored (bool) + // 402 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; + // + params_overridden (bool) + const BEFORE = AFTER - 402; const disc = Buffer.from(raw.data.slice(0, 8)); const coder = ctx.futarchy.futarchy.account.proposal.coder.accounts; @@ -146,6 +149,7 @@ export default function suite() { const migrated = await this.futarchy.getProposal(proposal); assert.isDefined(migrated.action.executeArbitrary); assert.isTrue(migrated.councilCanBlock); + assert.isFalse(migrated.paramsOverridden); // 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. @@ -190,6 +194,50 @@ export default function suite() { this.payer.publicKey.toBase58(), ); assert.equal(migrated.passThresholdBps, 1000); + assert.isFalse(migrated.paramsOverridden); + }); + + describe("with typed proposals off", function () { + beforeEach(async function () { + await setTypedProposalsEnabled(this, dao, false); + }); + + it("migrates a draft to a preview of the DAO's own duration and threshold", async function () { + await makeOldLayout(this, proposal, { 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.draft); + assert.isNull(migrated.sponsoredBy); + assert.equal(migrated.durationInSeconds, 60 * 60 * 24 * 3); + assert.equal(migrated.passThresholdBps, 300); + assert.isFalse(migrated.paramsOverridden); + }); + + it("migrates a team-sponsored draft to a preview of the team-sponsored threshold", async function () { + await makeOldLayout(this, proposal, { + isTeamSponsored: true, + durationInSeconds: 3600, + }); + + 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.durationInSeconds, 60 * 60 * 24 * 3); + assert.equal(migrated.passThresholdBps, -100); + assert.isFalse(migrated.paramsOverridden); + }); }); it("snapshots the DAO threshold and preserves the duration for a launched proposal", async function () { @@ -211,6 +259,7 @@ export default function suite() { assert.equal(migrated.durationInSeconds, 3600); assert.isDefined(migrated.action.executeArbitrary); assert.isTrue(migrated.councilCanBlock); + assert.isFalse(migrated.paramsOverridden); }); it("snapshots the team-sponsored threshold for a launched team-sponsored proposal", async function () { @@ -230,6 +279,7 @@ export default function suite() { this.payer.publicKey.toBase58(), ); assert.equal(migrated.passThresholdBps, -100); + assert.isFalse(migrated.paramsOverridden); }); it("is a no-op on an already-new-layout proposal", async function () { @@ -259,6 +309,9 @@ export default function suite() { .accounts({ proposal, dao, payer: this.payer.publicKey }) .rpc(); + const migrated = await this.futarchy.getProposal(proposal); + assert.isFalse(migrated.paramsOverridden); + // 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 @@ -273,6 +326,7 @@ export default function suite() { const retuned = await this.futarchy.getProposal(proposal); assert.equal(retuned.durationInSeconds, 60 * 60 * 24 * 2); assert.equal(retuned.passThresholdBps, 500); + assert.isTrue(retuned.paramsOverridden); }); 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 6918ca2d..d01975d4 100644 --- a/tests/futarchy/unit/setSpendingLimit.test.ts +++ b/tests/futarchy/unit/setSpendingLimit.test.ts @@ -12,7 +12,7 @@ import { PriceMath, } from "@metadaoproject/programs"; import BN from "bn.js"; -import { expectError } from "../../utils.js"; +import { expectError, makeOldDaoLayout } from "../../utils.js"; import { TestContext } from "../../main.test.js"; const ONE_BUCK_PRICE = PriceMath.getAmmPrice(1, 6, 6); @@ -261,4 +261,23 @@ export default function suite() { "0x17b4", // 6068 ); }); + + it("fails on a DAO that has not been migrated", async function () { + await makeOldDaoLayout(this, dao); + const before = await this.banksClient.getAccount(dao); + + await executeSetSpendingLimitViaVault(this, dao, null).then( + () => + assert.fail("set_spending_limit should have thrown AccountNotMigrated"), + (e) => + assert( + e.toString().includes("AccountNotMigrated") || + e.toString().includes("0x17b1"), + `Expected AccountNotMigrated error, got: ${e}`, + ), + ); + + const after = await this.banksClient.getAccount(dao); + assert.deepEqual(after.data, before.data); + }); } diff --git a/tests/futarchy/unit/sponsorProposal.test.ts b/tests/futarchy/unit/sponsorProposal.test.ts index 01844420..aac45e68 100644 --- a/tests/futarchy/unit/sponsorProposal.test.ts +++ b/tests/futarchy/unit/sponsorProposal.test.ts @@ -10,11 +10,8 @@ import { } from "@solana/spl-token"; import BN from "bn.js"; import { assert } from "chai"; -import { - executeVaultTransaction, - expectError, - forceApproveSquadsProposal, -} from "../../utils.js"; +import { expectError } from "../../utils.js"; +import { updateDaoViaVault } from "../utils.js"; import { TestContext } from "../../main.test.js"; export default function suite() { @@ -150,13 +147,7 @@ export default function suite() { 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 updateDaoViaVault(this, dao, { teamAddress: newTeam.publicKey }); await this.futarchy .sponsorProposalIx({ proposal, dao, teamAddress: newTeam.publicKey }) @@ -173,13 +164,9 @@ export default function suite() { 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 updateDaoViaVault(this, dao, { + teamAddress: Keypair.generate().publicKey, }); - await forceApproveSquadsProposal(this, takeover.squadsProposal); - await executeVaultTransaction(this, dao, takeover.squadsTransaction); const callbacks = expectError( "ConstraintHasOne", diff --git a/tests/futarchy/unit/typedProposalsOptIn.test.ts b/tests/futarchy/unit/typedProposalsOptIn.test.ts new file mode 100644 index 00000000..27a69156 --- /dev/null +++ b/tests/futarchy/unit/typedProposalsOptIn.test.ts @@ -0,0 +1,176 @@ +import { Keypair, PublicKey, TransactionInstruction } from "@solana/web3.js"; +import BN from "bn.js"; +import { assert } from "chai"; +import { expectError } from "../../utils.js"; +import { + TYPED_PROPOSALS_OFF_DAO_TERMS, + setTypedProposalsEnabled, + setupTypedProposalsOffDao, +} from "../utils.js"; + +const MEMO_PROGRAM_ID = new PublicKey( + "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr", +); + +const CATALOG_DURATION_SECONDS = 60 * 60 * 24 * 10; +const CATALOG_PASS_THRESHOLD_BPS = 1000; +const CATALOG_TWAP_START_DELAY_SECONDS = 60 * 60 * 24; + +const memoIx = new TransactionInstruction({ + programId: MEMO_PROGRAM_ID, + keys: [], + data: Buffer.from("arbitrary", "utf8"), +}); + +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, + 200_000 * 1_000_000, + ); + await this.mintTo( + USDC, + this.payer.publicKey, + this.payer, + 200_000 * 1_000_000, + ); + + dao = await setupTypedProposalsOffDao(this, META, USDC); + }); + + it("still creates a plain proposal while typed proposals are off", async function () { + const { proposal } = await this.initializeProposal({ + dao, + instructions: [memoIx], + }); + + const storedProposal = await this.futarchy.getProposal(proposal); + assert.exists(storedProposal.state.draft); + assert.exists(storedProposal.action.executeArbitrary); + }); + + describe("preview", function () { + it("a plain draft previews the DAO's own duration and threshold while typed proposals are off", async function () { + const { proposal } = await this.initializeProposal({ + dao, + instructions: [memoIx], + }); + + const storedProposal = await this.futarchy.getProposal(proposal); + assert.equal( + storedProposal.durationInSeconds, + TYPED_PROPOSALS_OFF_DAO_TERMS.secondsPerProposal, + ); + assert.equal( + storedProposal.passThresholdBps, + TYPED_PROPOSALS_OFF_DAO_TERMS.passThresholdBps, + ); + }); + + it("a plain draft previews the catalog's duration and threshold while typed proposals are on", async function () { + await setTypedProposalsEnabled(this, dao, true); + + const { proposal } = await this.initializeProposal({ + dao, + instructions: [memoIx], + }); + + const storedProposal = await this.futarchy.getProposal(proposal); + assert.equal(storedProposal.durationInSeconds, CATALOG_DURATION_SECONDS); + assert.equal(storedProposal.passThresholdBps, CATALOG_PASS_THRESHOLD_BPS); + }); + }); + + describe("admin tuning", function () { + it("accepts a duration above the DAO's warm-up but below the catalog's while typed proposals are off", async function () { + const { proposal } = await this.initializeProposal({ + dao, + instructions: [memoIx], + }); + + const durationInSeconds = + (TYPED_PROPOSALS_OFF_DAO_TERMS.twapStartDelaySeconds + + CATALOG_TWAP_START_DELAY_SECONDS) / + 2; + await this.futarchy + .adminUpdateProposalParamsIx({ proposal, dao, durationInSeconds }) + .rpc(); + + const storedProposal = await this.futarchy.getProposal(proposal); + assert.equal(storedProposal.durationInSeconds, durationInSeconds); + assert.isTrue(storedProposal.paramsOverridden); + }); + + it("refuses a duration equal to the DAO's warm-up while typed proposals are off", async function () { + const { proposal } = await this.initializeProposal({ + dao, + instructions: [memoIx], + }); + + const callbacks = expectError( + "ProposalDurationTooShort", + "tuned a duration equal to the DAO's warm-up", + ); + await this.futarchy + .adminUpdateProposalParamsIx({ + proposal, + dao, + durationInSeconds: + TYPED_PROPOSALS_OFF_DAO_TERMS.twapStartDelaySeconds, + }) + .rpc() + .then(...callbacks); + + const storedProposal = await this.futarchy.getProposal(proposal); + assert.equal( + storedProposal.durationInSeconds, + TYPED_PROPOSALS_OFF_DAO_TERMS.secondsPerProposal, + ); + assert.isFalse(storedProposal.paramsOverridden); + }); + }); + + it("refuses to launch a typed draft after typed proposals are turned off underneath it", async function () { + await setTypedProposalsEnabled(this, dao, true); + + const { proposal, squadsProposal } = + await this.futarchy.initializeSpendingLimitChangeProposal({ + dao, + config: { + amountPerMonth: new BN(1_000_000_000), // 1,000 USDC + members: [Keypair.generate().publicKey], + }, + }); + await this.futarchy.sponsorProposalIx({ proposal, dao }).rpc(); + + await setTypedProposalsEnabled(this, dao, false); + + const callbacks = expectError( + "TypedProposalsDisabled", + "launched a typed draft on a DAO with typed proposals off", + ); + await this.futarchy + .launchProposalIx({ + proposal, + dao, + baseMint: META, + quoteMint: USDC, + squadsProposal, + }) + .rpc() + .then(...callbacks); + + const storedProposal = await this.futarchy.getProposal(proposal); + assert.exists(storedProposal.state.draft); + }); +} diff --git a/tests/futarchy/unit/unstakeFromProposal.test.ts b/tests/futarchy/unit/unstakeFromProposal.test.ts index c982187b..decc1608 100644 --- a/tests/futarchy/unit/unstakeFromProposal.test.ts +++ b/tests/futarchy/unit/unstakeFromProposal.test.ts @@ -18,7 +18,8 @@ export default function suite() { USDC: PublicKey, dao: PublicKey, proposal: PublicKey, - squadsProposalPda: PublicKey; + squadsProposalPda: PublicKey, + squadsTransactionPda: PublicKey; beforeEach(async function () { META = await this.createMint(this.payer.publicKey, 6); @@ -76,6 +77,7 @@ export default function suite() { twapStartDelaySeconds: null, teamSponsoredPassThresholdBps: null, teamAddress: null, + typedProposalsEnabled: null, }, }) .instruction(); @@ -108,6 +110,10 @@ export default function suite() { multisigPda, transactionIndex: 1n, }); + [squadsTransactionPda] = multisig.getTransactionPda({ + multisigPda, + index: 1n, + }); const tx = new Transaction().add(vaultTxCreate, proposalCreateIx); tx.recentBlockhash = (await this.banksClient.getLatestBlockhash())[0]; @@ -171,6 +177,7 @@ export default function suite() { baseMint: META, quoteMint: USDC, squadsProposal: squadsProposalPda, + squadsTransaction: squadsTransactionPda, }) .rpc(); @@ -216,6 +223,7 @@ export default function suite() { baseMint: META, quoteMint: USDC, squadsProposal: squadsProposalPda, + squadsTransaction: squadsTransactionPda, }) .rpc(); @@ -264,6 +272,7 @@ export default function suite() { baseMint: META, quoteMint: USDC, squadsProposal: squadsProposalPda, + squadsTransaction: squadsTransactionPda, }) .instruction(); diff --git a/tests/futarchy/unit/updateDao.test.ts b/tests/futarchy/unit/updateDao.test.ts index c5f55607..a65ca69a 100644 --- a/tests/futarchy/unit/updateDao.test.ts +++ b/tests/futarchy/unit/updateDao.test.ts @@ -17,6 +17,15 @@ import { sha256, } from "@metadaoproject/programs"; import BN from "bn.js"; +import { + EMPTY_UPDATE_DAO_PARAMS, + executeViaVault, + expectVaultExecutionError, + rewriteAccount, + setTypedProposalsEnabled, + updateDaoViaVault, +} from "../utils.js"; +import { makeOldDaoLayout } from "../../utils.js"; const THOUSAND_BUCK_PRICE = PriceMath.getAmmPrice(1000, 9, 6); @@ -104,6 +113,7 @@ export default function suite() { teamSponsoredPassThresholdBps: null, teamAddress: null, twapStartDelaySeconds: null, + typedProposalsEnabled: null, }, }) .instruction(); @@ -135,6 +145,10 @@ export default function suite() { multisigPda: daoAccount.squadsMultisig, transactionIndex: 1n, }); + const [squadsTransactionPda] = multisig.getTransactionPda({ + multisigPda: daoAccount.squadsMultisig, + index: 1n, + }); const createSquadsTx = new Transaction().add( vaultTxCreateIx, @@ -174,7 +188,14 @@ export default function suite() { .rpc(); await this.futarchy - .initializeProposalIx(squadsProposalPda, dao, META, USDC, question) + .initializeProposalIx( + squadsProposalPda, + dao, + META, + USDC, + question, + squadsTransactionPda, + ) .preInstructions([ ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), ]) @@ -196,6 +217,7 @@ export default function suite() { baseMint: META, quoteMint: USDC, squadsProposal: squadsProposalPda, + squadsTransaction: squadsTransactionPda, }) .rpc(); @@ -288,6 +310,10 @@ export default function suite() { multisigPda: daoAccount.squadsMultisig, transactionIndex: 2n, }); + const [squadsTransactionPda2] = multisig.getTransactionPda({ + multisigPda: daoAccount.squadsMultisig, + index: 2n, + }); const createSquadsTx2 = new Transaction().add( vaultTxCreateIx2, @@ -337,6 +363,7 @@ export default function suite() { META, USDC, proposalBPdas.question, + squadsTransactionPda2, ) .preInstructions([ ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), @@ -351,6 +378,7 @@ export default function suite() { baseMint: META, quoteMint: USDC, squadsProposal: squadsProposalPda2, + squadsTransaction: squadsTransactionPda2, }) .rpc(); @@ -386,4 +414,174 @@ export default function suite() { ); } }); + + it("turns typed proposals on for a DAO that has them off", async function () { + await setTypedProposalsEnabled(this, dao, false); + const before = await this.futarchy.getDao(dao); + assert.isFalse(before.typedProposalsEnabled); + + await updateDaoViaVault(this, dao, { typedProposalsEnabled: true }); + + const after = await this.futarchy.getDao(dao); + assert.isTrue(after.typedProposalsEnabled); + assert.equal(after.seqNum.toString(), before.seqNum.addn(1).toString()); + assert.deepEqual( + JSON.parse( + JSON.stringify({ + ...after, + typedProposalsEnabled: false, + seqNum: before.seqNum, + }), + ), + JSON.parse(JSON.stringify(before)), + ); + }); + + it("keeps typed proposals on when asked to turn them on again", async function () { + const before = await this.futarchy.getDao(dao); + assert.isTrue(before.typedProposalsEnabled); + + await updateDaoViaVault(this, dao, { typedProposalsEnabled: true }); + + const after = await this.futarchy.getDao(dao); + assert.isTrue(after.typedProposalsEnabled); + assert.equal(after.seqNum.toString(), before.seqNum.addn(1).toString()); + }); + + it("refuses to turn typed proposals off on a DAO that has them off", async function () { + await setTypedProposalsEnabled(this, dao, false); + const before = await this.banksClient.getAccount(dao); + + await expectVaultExecutionError( + this, + updateDaoViaVault(this, dao, { + typedProposalsEnabled: false, + passThresholdBps: 500, + }), + "TypedProposalsCannotBeDisabled", + ); + + const after = await this.banksClient.getAccount(dao); + assert.deepEqual(after.data, before.data); + }); + + it("refuses to turn typed proposals off on a DAO that has them on", async function () { + const before = await this.banksClient.getAccount(dao); + + await expectVaultExecutionError( + this, + updateDaoViaVault(this, dao, { + typedProposalsEnabled: false, + passThresholdBps: 500, + }), + "TypedProposalsCannotBeDisabled", + ); + + const after = await this.banksClient.getAccount(dao); + assert.deepEqual(after.data, before.data); + }); + + it("cannot opt in on a configuration that fails the invariant without fixing it in the same call", async function () { + await rewriteAccount(this, dao, "dao", (decoded) => { + decoded.typedProposalsEnabled = false; + decoded.minQuoteFutarchicLiquidity = new BN(0); + }); + + await expectVaultExecutionError( + this, + updateDaoViaVault(this, dao, { typedProposalsEnabled: true }), + "InsufficientLiquidity", + ); + + let daoAccount = await this.futarchy.getDao(dao); + assert.isFalse(daoAccount.typedProposalsEnabled); + + await updateDaoViaVault(this, dao, { + typedProposalsEnabled: true, + minQuoteFutarchicLiquidity: new BN(1), + }); + + daoAccount = await this.futarchy.getDao(dao); + assert.isTrue(daoAccount.typedProposalsEnabled); + assert.equal(daoAccount.minQuoteFutarchicLiquidity.toString(), "1"); + }); + + it("ignores the retired optimistic-governance flag", async function () { + await setTypedProposalsEnabled(this, dao, false); + const before = await this.futarchy.getDao(dao); + + // Twelve fields: ten `None`, the retired flag set, the switch `None`. + await executeViaVault(this, dao, [ + await updateDaoIxWithArgs(this, dao, [...Array(10).fill(0), 1, 1, 0]), + ]); + + const after = await this.futarchy.getDao(dao); + assert.isFalse(after.typedProposalsEnabled); + assert.equal(after.seqNum.toString(), before.seqNum.addn(1).toString()); + assert.deepEqual( + JSON.parse(JSON.stringify({ ...after, seqNum: before.seqNum })), + JSON.parse(JSON.stringify(before)), + ); + }); + + it("refuses a payload encoded before the upgrade with the old flag set", async function () { + await setTypedProposalsEnabled(this, dao, false); + const before = await this.banksClient.getAccount(dao); + + await expectInstructionDidNotDeserialize( + executeViaVault(this, dao, [ + await updateDaoIxWithArgs(this, dao, [...Array(10).fill(0), 1, 1]), + ]), + ); + + const after = await this.banksClient.getAccount(dao); + assert.deepEqual(after.data, before.data); + }); + + it("refuses a payload encoded before the upgrade with the old flag unset", async function () { + const before = await this.banksClient.getAccount(dao); + + await expectInstructionDidNotDeserialize( + executeViaVault(this, dao, [ + await updateDaoIxWithArgs(this, dao, Array(11).fill(0)), + ]), + ); + + const after = await this.banksClient.getAccount(dao); + assert.deepEqual(after.data, before.data); + }); + + it("fails on a DAO that has not been migrated", async function () { + await makeOldDaoLayout(this, dao); + const before = await this.banksClient.getAccount(dao); + + await expectVaultExecutionError( + this, + updateDaoViaVault(this, dao, { passThresholdBps: 500 }), + "AccountNotMigrated", + ); + + const after = await this.banksClient.getAccount(dao); + assert.deepEqual(after.data, before.data); + }); +} + +// The SDK's update_dao instruction with hand-encoded arguments, for payload +// layouts the client cannot produce. Eleven `None` bytes is what a client of +// the deployed program sends when it leaves every field alone. +async function updateDaoIxWithArgs(ctx: any, dao: PublicKey, args: number[]) { + const ix = await ctx.futarchy + .updateDaoIx({ dao, params: EMPTY_UPDATE_DAO_PARAMS }) + .instruction(); + ix.data = Buffer.concat([ix.data.subarray(0, 8), Buffer.from(args)]); + return ix; +} + +// Anchor's own InstructionDidNotDeserialize (102) is not in the IDL, so it is +// matched by code. +async function expectInstructionDidNotDeserialize(execution: Promise) { + await execution.then( + () => assert.fail("should have failed with InstructionDidNotDeserialize"), + (e) => assert.include(e.toString(), "custom program error: 0x66"), + ); } diff --git a/tests/futarchy/utils.ts b/tests/futarchy/utils.ts new file mode 100644 index 00000000..9e9c247b --- /dev/null +++ b/tests/futarchy/utils.ts @@ -0,0 +1,180 @@ +import { assert } from "chai"; +import { + ComputeBudgetProgram, + PublicKey, + TransactionInstruction, +} from "@solana/web3.js"; +import BN from "bn.js"; +import * as multisig from "@sqds/multisig"; +import { + PERMISSIONLESS_ACCOUNT, + UpdateDaoParams, +} from "@metadaoproject/programs"; +import { TestContext } from "../main.test.js"; +import { + executeVaultTransaction, + forceApproveSquadsProposal, + setupBasicDao, +} from "../utils.js"; + +// Re-encodes an account in place, padded back to its allocated length, for +// states no instruction on the branch can produce. +export async function rewriteAccount( + ctx: TestContext, + address: PublicKey, + name: "proposal" | "dao", + mutate: (decoded: any) => void, +) { + const raw = await ctx.banksClient.getAccount(address); + const coder = ctx.futarchy.futarchy.account[name].coder.accounts; + const decoded = coder.decode(name, Buffer.from(raw.data)); + + mutate(decoded); + + const buf = Buffer.alloc(raw.data.length); + (await coder.encode(name, decoded)).copy(buf, 0); + + ctx.context.setAccount(address, { ...raw, data: buf }); +} + +// Sets the DAO's `typed_proposals_enabled`. No instruction turns typed +// proposals off, so tests that need them off rewrite the account. +export async function setTypedProposalsEnabled( + ctx: TestContext, + dao: PublicKey, + enabled: boolean, +) { + await rewriteAccount(ctx, dao, "dao", (decoded) => { + decoded.typedProposalsEnabled = enabled; + }); +} + +// Terms for a DAO with typed proposals off; every one differs from the +// catalog's 10 days, +10% and 1-day warm-up. +export const TYPED_PROPOSALS_OFF_DAO_TERMS = { + secondsPerProposal: 60 * 60 * 24 * 2, + twapStartDelaySeconds: 60 * 60 * 12, + passThresholdBps: 300, + teamSponsoredPassThresholdBps: -300, + baseToStake: new BN(100_000_000), // 100 tokens at 6 decimals +}; + +// A DAO on `TYPED_PROPOSALS_OFF_DAO_TERMS` with a 100,000-quote spot market and typed +// proposals switched off. +export async function setupTypedProposalsOffDao( + ctx: TestContext, + baseMint: PublicKey, + quoteMint: PublicKey, +): Promise { + const dao = await setupBasicDao({ + context: ctx, + baseMint, + quoteMint, + ...TYPED_PROPOSALS_OFF_DAO_TERMS, + }); + + await ctx.futarchy + .provideLiquidityIx({ + dao, + baseMint, + quoteMint, + quoteAmount: new BN(100_000 * 1_000_000), + maxBaseAmount: new BN(100_000 * 1_000_000), + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), + ]) + .rpc(); + + await setTypedProposalsEnabled(ctx, dao, false); + + return dao; +} + +export const EMPTY_UPDATE_DAO_PARAMS: UpdateDaoParams = { + passThresholdBps: null, + secondsPerProposal: null, + twapInitialObservation: null, + twapMaxObservationChangePerUpdate: null, + twapStartDelaySeconds: null, + minQuoteFutarchicLiquidity: null, + minBaseFutarchicLiquidity: null, + baseToStake: null, + teamSponsoredPassThresholdBps: null, + teamAddress: null, + typedProposalsEnabled: null, +}; + +// Runs instructions through the DAO's Squads vault without a market: a vault +// transaction and proposal at the multisig's next index, force-approved and +// executed. +export async function executeViaVault( + ctx: TestContext, + dao: PublicKey, + instructions: TransactionInstruction[], +) { + const multisigPda = multisig.getMultisigPda({ createKey: dao })[0]; + const multisigAccount = await multisig.accounts.Multisig.fromAccountAddress( + ctx.squadsConnection, + multisigPda, + ); + const transactionIndex = + BigInt(multisigAccount.transactionIndex.toString()) + 1n; + + const { tx, squadsProposal } = ctx.futarchy.squadsProposalCreateTx({ + dao, + instructions, + transactionIndex, + }); + [tx.recentBlockhash] = await ctx.banksClient.getLatestBlockhash(); + tx.feePayer = ctx.payer.publicKey; + tx.sign(ctx.payer, PERMISSIONLESS_ACCOUNT); + await ctx.banksClient.processTransaction(tx); + + await forceApproveSquadsProposal(ctx, squadsProposal); + + const [squadsTransaction] = multisig.getTransactionPda({ + multisigPda, + index: transactionIndex, + }); + await executeVaultTransaction(ctx, dao, squadsTransaction); +} + +// Runs a vault-signed update_dao without a market. Omitted params are left +// unchanged. +export async function updateDaoViaVault( + ctx: TestContext, + dao: PublicKey, + params: Partial, +) { + const updateDaoIx = await ctx.futarchy + .updateDaoIx({ dao, params: { ...EMPTY_UPDATE_DAO_PARAMS, ...params } }) + .instruction(); + + await executeViaVault(ctx, dao, [updateDaoIx]); +} + +// An error inside a vault-executed instruction surfaces through Squads' +// execute as a bare transaction error carrying only the custom error code, +// so the name is resolved through the IDL and matched as hex. +export async function expectVaultExecutionError( + ctx: TestContext, + execution: Promise, + errorName: string, +) { + const error = ctx.futarchy.futarchy.idl.errors.find( + (e) => e.name === errorName, + ); + assert.exists(error, `unknown futarchy error ${errorName}`); + const expected = `custom program error: 0x${error.code.toString(16)}`; + + await execution.then( + () => assert.fail(`should have failed with ${errorName}`), + (e) => + assert.include( + e.toString(), + expected, + `Expected ${errorName}, got: ${e}`, + ), + ); +} diff --git a/tests/integration/fullLaunch.test.ts b/tests/integration/fullLaunch.test.ts index 038ac5f0..4dfaf751 100644 --- a/tests/integration/fullLaunch.test.ts +++ b/tests/integration/fullLaunch.test.ts @@ -384,6 +384,7 @@ export default async function suite() { minBaseFutarchicLiquidity: null, teamSponsoredPassThresholdBps: null, teamAddress: null, + typedProposalsEnabled: null, }, }) .instruction(); @@ -423,6 +424,10 @@ export default async function suite() { multisigPda, transactionIndex: 1n, }); + const [squadsTransactionPda] = multisig.getTransactionPda({ + multisigPda, + index: 1n, + }); // Create the squads proposal first const squadsTx = new Transaction().add(vaultTxCreate, proposalCreateIx); @@ -467,6 +472,7 @@ export default async function suite() { baseMint: META, quoteMint: MAINNET_USDC, squadsProposal: squadsProposalPda, + squadsTransaction: squadsTransactionPda, }) .rpc(); diff --git a/tests/integration/fullLaunch_v7.test.ts b/tests/integration/fullLaunch_v7.test.ts index 7fdaeba4..38ead26e 100644 --- a/tests/integration/fullLaunch_v7.test.ts +++ b/tests/integration/fullLaunch_v7.test.ts @@ -429,6 +429,7 @@ export default async function suite() { minBaseFutarchicLiquidity: null, teamSponsoredPassThresholdBps: null, teamAddress: null, + typedProposalsEnabled: null, }, }) .instruction(); @@ -468,6 +469,10 @@ export default async function suite() { multisigPda, transactionIndex: 1n, }); + const [squadsTransactionPda] = multisig.getTransactionPda({ + multisigPda, + index: 1n, + }); // Create the squads proposal first const squadsTx = new Transaction().add(vaultTxCreate, proposalCreateIx); @@ -512,6 +517,7 @@ export default async function suite() { baseMint: META, quoteMint: MAINNET_USDC, squadsProposal: squadsProposalPda, + squadsTransaction: squadsTransactionPda, }) .rpc(); diff --git a/tests/main.test.ts b/tests/main.test.ts index 7c4d6307..b215b10d 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -159,6 +159,7 @@ export interface TestContext { baseVault: PublicKey; quoteVault: PublicKey; squadsProposal: PublicKey; + squadsTransaction: PublicKey; }>; initializeAndLaunchProposal: ({ dao, @@ -172,6 +173,7 @@ export interface TestContext { baseVault: PublicKey; quoteVault: PublicKey; squadsProposal: PublicKey; + squadsTransaction: PublicKey; }>; advanceBySlots: (slots: bigint) => Promise; advanceBySeconds: (seconds: number) => Promise; @@ -575,15 +577,19 @@ before(async function () { baseVault: PublicKey; quoteVault: PublicKey; squadsProposal: PublicKey; + squadsTransaction: PublicKey; }> => { const storedDao = await this.futarchy.getDao(dao); - const { tx: squadsProposalCreateTx, squadsProposal } = - this.futarchy.squadsProposalCreateTx({ - dao, - instructions, - transactionIndex: 1n, - }); + const { + tx: squadsProposalCreateTx, + squadsProposal, + squadsTransaction, + } = this.futarchy.squadsProposalCreateTx({ + dao, + instructions, + transactionIndex: 1n, + }); squadsProposalCreateTx.recentBlockhash = ( await this.banksClient.getLatestBlockhash() @@ -591,7 +597,7 @@ before(async function () { squadsProposalCreateTx.feePayer = this.payer.publicKey; squadsProposalCreateTx.sign(this.payer, PERMISSIONLESS_ACCOUNT); - this.banksClient.processTransaction(squadsProposalCreateTx); + await this.banksClient.processTransaction(squadsProposalCreateTx); let [proposal] = getProposalAddrV2({ squadsProposal }); @@ -628,13 +634,21 @@ before(async function () { storedDao.baseMint, storedDao.quoteMint, question, + squadsTransaction, ) .preInstructions([ ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), ]) .rpc(); - return { proposal, question, baseVault, quoteVault, squadsProposal }; + return { + proposal, + question, + baseVault, + quoteVault, + squadsProposal, + squadsTransaction, + }; }; this.initializeAndLaunchProposal = async ({ @@ -649,9 +663,16 @@ before(async function () { baseVault: PublicKey; quoteVault: PublicKey; squadsProposal: PublicKey; + squadsTransaction: PublicKey; }> => { - const { proposal, question, baseVault, quoteVault, squadsProposal } = - await this.initializeProposal({ dao, instructions }); + const { + proposal, + question, + baseVault, + quoteVault, + squadsProposal, + squadsTransaction, + } = await this.initializeProposal({ dao, instructions }); const storedDao = await this.futarchy.getDao(dao); await this.futarchy .launchProposalIx({ @@ -660,10 +681,18 @@ before(async function () { baseMint: storedDao.baseMint, quoteMint: storedDao.quoteMint, squadsProposal, + squadsTransaction, }) .rpc(); - return { proposal, question, baseVault, quoteVault, squadsProposal }; + return { + proposal, + question, + baseVault, + quoteVault, + squadsProposal, + squadsTransaction, + }; }; this.setupBasicPerformancePackage = async ({ diff --git a/tests/utils.ts b/tests/utils.ts index 35763ec4..2b903281 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -32,14 +32,22 @@ export async function setupBasicDao({ context, baseMint, quoteMint, + secondsPerProposal = 60 * 60 * 24 * 3, + twapStartDelaySeconds = 60 * 60 * 24, + passThresholdBps = 300, teamSponsoredPassThresholdBps = 300, + baseToStake = new BN(0), teamAddress, initialSpendingLimit = null, }: { context: TestContext; baseMint: PublicKey; quoteMint: PublicKey; + secondsPerProposal?: number; + twapStartDelaySeconds?: number; + passThresholdBps?: number; teamSponsoredPassThresholdBps?: number; + baseToStake?: typeof BN.prototype; teamAddress?: PublicKey; initialSpendingLimit?: { amountPerMonth: typeof BN.prototype; @@ -53,16 +61,16 @@ export async function setupBasicDao({ baseMint, quoteMint, params: { - secondsPerProposal: 60 * 60 * 24 * 3, - twapStartDelaySeconds: 60 * 60 * 24, + secondsPerProposal, + twapStartDelaySeconds, twapInitialObservation: THOUSAND_BUCK_PRICE, twapMaxObservationChangePerUpdate: THOUSAND_BUCK_PRICE.divn(100), minQuoteFutarchicLiquidity: new BN(10_000), minBaseFutarchicLiquidity: new BN(10_000), - passThresholdBps: 300, + passThresholdBps, nonce, initialSpendingLimit, - baseToStake: new BN(0), + baseToStake, teamSponsoredPassThresholdBps, teamAddress: teamAddress || context.payer.publicKey, }, @@ -91,6 +99,8 @@ export type OldDaoLayoutOverrides = { amountPerMonth: typeof BN.prototype; members: PublicKey[]; } | null; + minQuoteFutarchicLiquidity?: typeof BN.prototype; + minBaseFutarchicLiquidity?: typeof BN.prototype; }; // Rewrites a real (new-layout) Dao account to the pre-migration on-chain layout. @@ -102,10 +112,10 @@ export async function makeOldDaoLayout( ): 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) + // 59 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; + // + last_buyback_finalized_at (i64) + typed_proposals_enabled (bool) + const BEFORE = AFTER - 59; const disc = Buffer.from(raw.data.slice(0, 8)); const coder = ctx.futarchy.futarchy.account.dao.coder.accounts; @@ -118,6 +128,10 @@ export async function makeOldDaoLayout( overrides.isOptimisticGovernanceEnabled; if (overrides.initialSpendingLimit !== undefined) decoded.initialSpendingLimit = overrides.initialSpendingLimit; + if (overrides.minQuoteFutarchicLiquidity !== undefined) + decoded.minQuoteFutarchicLiquidity = overrides.minQuoteFutarchicLiquidity; + if (overrides.minBaseFutarchicLiquidity !== undefined) + decoded.minBaseFutarchicLiquidity = overrides.minBaseFutarchicLiquidity; // Encode as oldDao and truncate to the pre-migration size. const body = await coder.encode("oldDao", decoded); @@ -486,3 +500,66 @@ export const advanceBySlots = async ( ), ); }; + +// Writes an address lookup table account directly: the bincode-serialized +// ProgramState::LookupTable(LookupTableMeta) header padded to 56 bytes, then the +// raw addresses. A null authority makes the table frozen. +export function setLookupTableAccount( + context: TestContext, + address: PublicKey, + authority: PublicKey | null, + addresses: PublicKey[], +) { + const meta = Buffer.alloc(56); + meta.writeUInt32LE(1, 0); + meta.writeBigUInt64LE(0xffffffffffffffffn, 4); + meta.writeBigUInt64LE(0n, 12); + meta.writeUInt8(0, 20); + if (authority !== null) { + meta.writeUInt8(1, 21); + authority.toBuffer().copy(meta, 22); + } + + context.context.setAccount(address, { + lamports: 1_000_000_000, + data: Buffer.concat([meta, ...addresses.map((a) => a.toBuffer())]), + owner: AddressLookupTableProgram.programId, + executable: false, + }); +} + +// The Squads SDK only compiles lookups from real on-chain tables, so tests +// rewrite a stored vault transaction message to reference arbitrary tables +// and indexes. +export async function addLookupsToVaultTransaction( + context: TestContext, + squadsTransaction: PublicKey, + lookups: { + accountKey: PublicKey; + writableIndexes: number[]; + readonlyIndexes: number[]; + }[], +) { + const vaultTransaction = + await multisig.accounts.VaultTransaction.fromAccountAddress( + context.squadsConnection, + squadsTransaction, + ); + + const modified = multisig.accounts.VaultTransaction.fromArgs({ + ...vaultTransaction, + message: { + ...vaultTransaction.message, + addressTableLookups: lookups.map((lookup) => ({ + accountKey: lookup.accountKey, + writableIndexes: Uint8Array.from(lookup.writableIndexes), + readonlyIndexes: Uint8Array.from(lookup.readonlyIndexes), + })), + }, + }); + const [serialized] = modified.serialize(); + + const stored = await context.banksClient.getAccount(squadsTransaction); + stored.data = serialized; + context.context.setAccount(squadsTransaction, stored); +}