diff --git a/programs/futarchy/src/error.rs b/programs/futarchy/src/error.rs index d822ac67..8da0614e 100644 --- a/programs/futarchy/src/error.rs +++ b/programs/futarchy/src/error.rs @@ -90,4 +90,14 @@ pub enum FutarchyError { InvalidSpendingLimitMint, #[msg("No active optimistic proposal")] NoActiveOptimisticProposal, + #[msg("Invalid MetaDAO approver")] + InvalidApprover, + #[msg("Proposal has already been approved by MetaDAO")] + ProposalAlreadyApproved, + #[msg("base_to_supermajority must be 0 (disabled) or >= base_to_stake")] + InvalidSupermajorityThreshold, + #[msg("Proposal lacks enough approval points to launch")] + InsufficientApprovalToLaunch, + #[msg("Proposal validation is not enabled for this DAO")] + ProposalValidationDisabled, } diff --git a/programs/futarchy/src/events.rs b/programs/futarchy/src/events.rs index 0479eddc..102a6120 100644 --- a/programs/futarchy/src/events.rs +++ b/programs/futarchy/src/events.rs @@ -53,6 +53,8 @@ pub struct InitializeDaoEvent { pub squads_multisig_vault: Pubkey, pub team_sponsored_pass_threshold_bps: i16, pub team_address: Pubkey, + pub base_to_supermajority: u64, + pub is_proposal_validation_enabled: bool, } #[event] @@ -70,6 +72,8 @@ pub struct UpdateDaoEvent { pub team_sponsored_pass_threshold_bps: i16, pub team_address: Pubkey, pub is_optimistic_governance_enabled: bool, + pub base_to_supermajority: u64, + pub is_proposal_validation_enabled: bool, } #[event] @@ -192,6 +196,14 @@ pub struct SponsorProposalEvent { pub team_address: Pubkey, } +#[event] +pub struct ApproveProposalEvent { + pub common: CommonFields, + pub proposal: Pubkey, + pub dao: Pubkey, + pub approver: Pubkey, +} + #[event] pub struct RemoveProposalEvent { pub common: CommonFields, diff --git a/programs/futarchy/src/instructions/approve_proposal.rs b/programs/futarchy/src/instructions/approve_proposal.rs new file mode 100644 index 00000000..4408f4ea --- /dev/null +++ b/programs/futarchy/src/instructions/approve_proposal.rs @@ -0,0 +1,70 @@ +use super::*; + +mod metadao_approver { + use anchor_lang::prelude::declare_id; + declare_id!("6awyHMshBGVjJ3ozdSJdyyDE1CTAXUwrpNMaRGMsb4sf"); +} + +#[derive(Accounts)] +#[event_cpi] +pub struct ApproveProposal<'info> { + #[account(mut, has_one = dao)] + pub proposal: Box>, + #[account(mut)] + pub dao: Box>, + pub approver: Signer<'info>, +} + +impl ApproveProposal<'_> { + pub fn validate(&self) -> Result<()> { + require!( + self.dao.is_proposal_validation_enabled, + FutarchyError::ProposalValidationDisabled + ); + + #[cfg(feature = "production")] + require_keys_eq!( + self.approver.key(), + metadao_approver::ID, + FutarchyError::InvalidApprover + ); + + require!( + matches!(self.proposal.state, ProposalState::Draft { .. }), + FutarchyError::ProposalNotInDraftState + ); + + require_neq!( + self.proposal.is_metadao_approved, + true, + FutarchyError::ProposalAlreadyApproved + ); + + Ok(()) + } + + pub fn handle(ctx: Context) -> Result<()> { + let Self { + proposal, + dao, + approver, + event_authority: _, + program: _, + } = ctx.accounts; + + proposal.is_metadao_approved = true; + + dao.seq_num += 1; + + let clock = Clock::get()?; + + emit_cpi!(ApproveProposalEvent { + common: CommonFields::new(&clock, dao.seq_num), + proposal: proposal.key(), + dao: dao.key(), + approver: approver.key(), + }); + + Ok(()) + } +} diff --git a/programs/futarchy/src/instructions/initialize_dao.rs b/programs/futarchy/src/instructions/initialize_dao.rs index 64fe30be..54cab890 100644 --- a/programs/futarchy/src/instructions/initialize_dao.rs +++ b/programs/futarchy/src/instructions/initialize_dao.rs @@ -18,6 +18,8 @@ pub struct InitializeDaoParams { pub initial_spending_limit: Option, pub team_sponsored_pass_threshold_bps: i16, pub team_address: Pubkey, + pub base_to_supermajority: u64, + pub is_proposal_validation_enabled: bool, } #[derive(Accounts)] @@ -93,6 +95,8 @@ impl InitializeDao<'_> { initial_spending_limit, team_sponsored_pass_threshold_bps, team_address, + base_to_supermajority, + is_proposal_validation_enabled, } = params; let dao = &mut ctx.accounts.dao; @@ -223,6 +227,8 @@ impl InitializeDao<'_> { team_address, optimistic_proposal: None, is_optimistic_governance_enabled: false, + base_to_supermajority, + is_proposal_validation_enabled, }); dao.invariant()?; @@ -248,6 +254,8 @@ 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, + base_to_supermajority: dao.base_to_supermajority, + is_proposal_validation_enabled: dao.is_proposal_validation_enabled, }); Ok(()) diff --git a/programs/futarchy/src/instructions/initialize_proposal.rs b/programs/futarchy/src/instructions/initialize_proposal.rs index a59736a5..e634ab8d 100644 --- a/programs/futarchy/src/instructions/initialize_proposal.rs +++ b/programs/futarchy/src/instructions/initialize_proposal.rs @@ -115,6 +115,7 @@ impl InitializeProposal<'_> { pass_quote_mint: quote_vault.conditional_token_mints[1], fail_quote_mint: quote_vault.conditional_token_mints[0], is_team_sponsored: false, + is_metadao_approved: false, }); dao.seq_num += 1; diff --git a/programs/futarchy/src/instructions/launch_proposal.rs b/programs/futarchy/src/instructions/launch_proposal.rs index 7816215c..e89fbe91 100644 --- a/programs/futarchy/src/instructions/launch_proposal.rs +++ b/programs/futarchy/src/instructions/launch_proposal.rs @@ -46,15 +46,44 @@ impl LaunchProposal<'_> { require_keys_eq!(self.proposal.dao, self.dao.key()); - // If the proposal is not team sponsored, check if sufficient stake has been accumulated - if !self.proposal.is_team_sponsored { - if let ProposalState::Draft { amount_staked } = self.proposal.state { - require_gte!( - amount_staked, - self.dao.base_to_stake, - FutarchyError::InsufficientStakeToLaunch - ); - } + let amount_staked = match self.proposal.state { + ProposalState::Draft { amount_staked } => amount_staked, + _ => unreachable!(), // Draft already asserted above + }; + + if self.dao.is_proposal_validation_enabled { + // A proposal that challenges an active optimistic proposal is team-authored by design: + // the team initiated *this exact* squads_proposal optimistically (the match is also enforced + // below in validate), so it carries the team point — mirroring `handle`, which already flips + // `is_team_sponsored = true` for the pass threshold. + let is_optimistic_challenge = matches!( + &self.dao.optimistic_proposal, + Some(op) if op.squads_proposal == self.proposal.squads_proposal + ); + + // Approval points: launch needs >= 2 of 3 {token-holder, team, MetaDAO} + let points = [ + amount_staked >= self.dao.base_to_stake, // token-holder point + self.proposal.is_team_sponsored || is_optimistic_challenge, // team point (auto for optimistic challenges) + self.proposal.is_metadao_approved, // MetaDAO point + ] + .into_iter() + .filter(|&p| p) + .count(); + + // Supermajority: stake alone reaches the per-DAO bar (0 disables this path) + let supermajority_reached = self.dao.base_to_supermajority > 0 + && amount_staked >= self.dao.base_to_supermajority; + + require!( + points >= LAUNCH_APPROVAL_POINTS_REQUIRED || supermajority_reached, + FutarchyError::InsufficientApprovalToLaunch + ); + } else { + require!( + self.proposal.is_team_sponsored || amount_staked >= self.dao.base_to_stake, + FutarchyError::InsufficientStakeToLaunch + ); } // If there is an active optimistic proposal, it must be for the same squads proposal diff --git a/programs/futarchy/src/instructions/mod.rs b/programs/futarchy/src/instructions/mod.rs index 0076cb2a..672c3b20 100644 --- a/programs/futarchy/src/instructions/mod.rs +++ b/programs/futarchy/src/instructions/mod.rs @@ -4,6 +4,7 @@ pub mod admin_cancel_proposal; pub mod admin_enqueue_multisig_proposal_approval; pub mod admin_execute_multisig_proposal; pub mod admin_remove_proposal; +pub mod approve_proposal; pub mod collect_fees; pub mod collect_meteora_damm_fees; pub mod conditional_swap; @@ -17,6 +18,7 @@ pub mod initiate_vault_spend_optimistic_proposal; pub mod launch_proposal; pub mod provide_liquidity; pub mod resize_dao; +pub mod resize_proposal; pub mod sponsor_proposal; pub mod spot_swap; pub mod stake_to_proposal; @@ -28,6 +30,7 @@ pub use admin_cancel_proposal::*; pub use admin_enqueue_multisig_proposal_approval::*; pub use admin_execute_multisig_proposal::*; pub use admin_remove_proposal::*; +pub use approve_proposal::*; pub use collect_fees::*; pub use collect_meteora_damm_fees::*; pub use conditional_swap::*; @@ -41,6 +44,7 @@ pub use initiate_vault_spend_optimistic_proposal::*; pub use launch_proposal::*; pub use provide_liquidity::*; pub use resize_dao::*; +pub use resize_proposal::*; pub use sponsor_proposal::*; pub use spot_swap::*; pub use stake_to_proposal::*; diff --git a/programs/futarchy/src/instructions/resize_dao.rs b/programs/futarchy/src/instructions/resize_dao.rs index 6352ac11..99e6c8df 100644 --- a/programs/futarchy/src/instructions/resize_dao.rs +++ b/programs/futarchy/src/instructions/resize_dao.rs @@ -21,8 +21,8 @@ impl ResizeDao<'_> { require_eq!(is_discriminator_correct, true); const AFTER_REALLOC_SIZE: usize = Dao::INIT_SPACE + 8; - // 42 bytes: 1 (Option discriminant) + 32 (Pubkey) + 8 (i64) + 1 (bool) - const BEFORE_REALLOC_SIZE: usize = AFTER_REALLOC_SIZE - 42; + // 9 bytes: base_to_supermajority (u64) + is_proposal_validation_enabled (bool) + const BEFORE_REALLOC_SIZE: usize = AFTER_REALLOC_SIZE - 9; if dao.data_len() != BEFORE_REALLOC_SIZE { // already realloced @@ -32,6 +32,9 @@ impl ResizeDao<'_> { let old_dao_data = OldDao::deserialize(&mut &dao.try_borrow_data().unwrap()[8..])?; + // Opt-in defaults: existing DAOs migrate in with the validation gate OFF and the + // supermajority path disabled, so their launch behavior is unchanged until they + // explicitly opt in via update_dao. let new_dao_data = Dao { amm: old_dao_data.amm, nonce: old_dao_data.nonce, @@ -55,8 +58,10 @@ impl ResizeDao<'_> { initial_spending_limit: old_dao_data.initial_spending_limit, team_sponsored_pass_threshold_bps: old_dao_data.team_sponsored_pass_threshold_bps, team_address: old_dao_data.team_address, - optimistic_proposal: None, - is_optimistic_governance_enabled: false, + optimistic_proposal: old_dao_data.optimistic_proposal, + is_optimistic_governance_enabled: old_dao_data.is_optimistic_governance_enabled, + base_to_supermajority: 0, + is_proposal_validation_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 new file mode 100644 index 00000000..a3461012 --- /dev/null +++ b/programs/futarchy/src/instructions/resize_proposal.rs @@ -0,0 +1,78 @@ +use anchor_lang::{system_program, Discriminator}; + +use super::*; + +#[derive(Accounts)] +pub struct ResizeProposal<'info> { + /// CHECK: we check the discriminator + #[account(mut)] + pub proposal: UncheckedAccount<'info>, + #[account(mut)] + pub payer: Signer<'info>, + pub system_program: Program<'info, System>, +} + +impl ResizeProposal<'_> { + pub fn handle(ctx: Context) -> Result<()> { + let proposal = &ctx.accounts.proposal; + + require_eq!(proposal.owner, &crate::ID); + let is_discriminator_correct = + proposal.try_borrow_data().unwrap()[..8] == Proposal::discriminator(); + require_eq!(is_discriminator_correct, true); + + const AFTER_REALLOC_SIZE: usize = Proposal::INIT_SPACE + 8; + // 1 byte: is_metadao_approved (bool) + const BEFORE_REALLOC_SIZE: usize = AFTER_REALLOC_SIZE - 1; + + if proposal.data_len() != BEFORE_REALLOC_SIZE { + // already realloced + require_eq!(proposal.data_len(), AFTER_REALLOC_SIZE); + return Ok(()); + } + + let old_proposal_data = + OldProposal::deserialize(&mut &proposal.try_borrow_data().unwrap()[8..])?; + + let new_proposal_data = Proposal { + number: old_proposal_data.number, + proposer: old_proposal_data.proposer, + timestamp_enqueued: old_proposal_data.timestamp_enqueued, + state: old_proposal_data.state, + base_vault: old_proposal_data.base_vault, + quote_vault: old_proposal_data.quote_vault, + dao: old_proposal_data.dao, + pda_bump: old_proposal_data.pda_bump, + question: old_proposal_data.question, + duration_in_seconds: old_proposal_data.duration_in_seconds, + squads_proposal: old_proposal_data.squads_proposal, + pass_base_mint: old_proposal_data.pass_base_mint, + pass_quote_mint: old_proposal_data.pass_quote_mint, + fail_base_mint: old_proposal_data.fail_base_mint, + fail_quote_mint: old_proposal_data.fail_quote_mint, + is_team_sponsored: old_proposal_data.is_team_sponsored, + is_metadao_approved: false, + }; + + proposal.realloc(AFTER_REALLOC_SIZE, true)?; + + let lamports_needed = Rent::get()?.minimum_balance(AFTER_REALLOC_SIZE); + + if lamports_needed > proposal.lamports() { + system_program::transfer( + CpiContext::new( + ctx.accounts.system_program.to_account_info(), + system_program::Transfer { + from: ctx.accounts.payer.to_account_info(), + to: proposal.to_account_info(), + }, + ), + lamports_needed - proposal.lamports(), + )?; + } + + new_proposal_data.serialize(&mut &mut proposal.try_borrow_mut_data().unwrap()[8..])?; + + Ok(()) + } +} diff --git a/programs/futarchy/src/instructions/update_dao.rs b/programs/futarchy/src/instructions/update_dao.rs index d5d5d5d7..23c8e8c1 100644 --- a/programs/futarchy/src/instructions/update_dao.rs +++ b/programs/futarchy/src/instructions/update_dao.rs @@ -13,6 +13,8 @@ pub struct UpdateDaoParams { pub team_sponsored_pass_threshold_bps: Option, pub team_address: Option, pub is_optimistic_governance_enabled: Option, + pub base_to_supermajority: Option, + pub is_proposal_validation_enabled: Option, } #[derive(Accounts)] @@ -83,6 +85,12 @@ impl UpdateDao<'_> { is_optimistic_governance_enabled: dao_params .is_optimistic_governance_enabled .unwrap_or(dao.is_optimistic_governance_enabled), + base_to_supermajority: dao_params + .base_to_supermajority + .unwrap_or(dao.base_to_supermajority), + is_proposal_validation_enabled: dao_params + .is_proposal_validation_enabled + .unwrap_or(dao.is_proposal_validation_enabled), }); dao.seq_num += 1; @@ -104,6 +112,8 @@ 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, + base_to_supermajority: dao.base_to_supermajority, + is_proposal_validation_enabled: dao.is_proposal_validation_enabled, }); Ok(()) diff --git a/programs/futarchy/src/lib.rs b/programs/futarchy/src/lib.rs index 7b5dc5e5..d2ffcf2f 100644 --- a/programs/futarchy/src/lib.rs +++ b/programs/futarchy/src/lib.rs @@ -60,6 +60,13 @@ pub const DEFAULT_MAX_OBSERVATION_CHANGE_PER_UPDATE_LOTS: u64 = 5_000; // Unstaking from a proposal should only be allowed after a small delay pub const MIN_PROPOSAL_UNSTAKE_DELAY_SECONDS: i64 = 5; +// Standard supermajority bar in WHOLE tokens (~25% of the 10M floating supply). Single source +// of truth; scaled to base units by the base mint's decimals at the point of use. +pub const DEFAULT_BASE_TO_SUPERMAJORITY_TOKENS: u64 = 2_500_000; + +// Number of approval points (of 3: stake, team, MetaDAO) a proposal needs to launch. +pub const LAUNCH_APPROVAL_POINTS_REQUIRED: usize = 2; + #[program] pub mod futarchy { use super::*; @@ -109,6 +116,10 @@ pub mod futarchy { ResizeDao::handle(ctx) } + pub fn resize_proposal(ctx: Context) -> Result<()> { + ResizeProposal::handle(ctx) + } + // AMM instructions pub fn spot_swap(ctx: Context, params: SpotSwapParams) -> Result<()> { @@ -154,6 +165,11 @@ pub mod futarchy { SponsorProposal::handle(ctx) } + #[access_control(ctx.accounts.validate())] + pub fn approve_proposal(ctx: Context) -> Result<()> { + ApproveProposal::handle(ctx) + } + #[access_control(ctx.accounts.validate())] pub fn collect_meteora_damm_fees(ctx: Context) -> Result<()> { CollectMeteoraDammFees::handle(ctx) diff --git a/programs/futarchy/src/state/dao.rs b/programs/futarchy/src/state/dao.rs index 62cfd536..afa44291 100644 --- a/programs/futarchy/src/state/dao.rs +++ b/programs/futarchy/src/state/dao.rs @@ -66,6 +66,13 @@ pub struct Dao { pub team_address: Pubkey, pub optimistic_proposal: Option, pub is_optimistic_governance_enabled: bool, + /// Absolute base-token stake at which a proposal launches on supermajority + /// stake alone. `0` disables the supermajority path for this DAO. + pub base_to_supermajority: u64, + /// When enabled, `launch_proposal` enforces the stricter validation gate + /// (>= 2 of 3 approval points, or the supermajority path). When disabled, + /// the DAO uses the legacy gate + pub is_proposal_validation_enabled: bool, } #[derive(AnchorSerialize, AnchorDeserialize, Debug, Clone, PartialEq, Eq, InitSpace)] @@ -133,6 +140,14 @@ impl Dao { FutarchyError::InvalidMaxObservationChange ); + // The supermajority bar (`base_to_supermajority`) substitutes overwhelming + // stake for human review, so it must never be *easier* than the ordinary + // token-holder point. `0` disables it. + require!( + self.base_to_supermajority == 0 || self.base_to_supermajority >= self.base_to_stake, + FutarchyError::InvalidSupermajorityThreshold + ); + Ok(()) } } @@ -191,4 +206,6 @@ pub struct OldDao { /// Can be negative to allow for team-sponsored proposals to pass by default. pub team_sponsored_pass_threshold_bps: i16, pub team_address: Pubkey, + pub optimistic_proposal: Option, + pub is_optimistic_governance_enabled: bool, } diff --git a/programs/futarchy/src/state/proposal.rs b/programs/futarchy/src/state/proposal.rs index 331bf9e2..b65a2a80 100644 --- a/programs/futarchy/src/state/proposal.rs +++ b/programs/futarchy/src/state/proposal.rs @@ -36,4 +36,26 @@ pub struct Proposal { pub fail_base_mint: Pubkey, pub fail_quote_mint: Pubkey, pub is_team_sponsored: bool, + pub is_metadao_approved: bool, +} + +#[account] +#[derive(InitSpace)] +pub struct OldProposal { + pub number: u32, + pub proposer: Pubkey, + pub timestamp_enqueued: i64, + pub state: ProposalState, + pub base_vault: Pubkey, + pub quote_vault: Pubkey, + pub dao: Pubkey, + pub pda_bump: u8, + pub question: Pubkey, + pub duration_in_seconds: u32, + pub squads_proposal: Pubkey, + pub pass_base_mint: Pubkey, + pub pass_quote_mint: Pubkey, + pub fail_base_mint: Pubkey, + pub fail_quote_mint: Pubkey, + pub is_team_sponsored: bool, } diff --git a/programs/v06_launchpad/src/instructions/complete_launch.rs b/programs/v06_launchpad/src/instructions/complete_launch.rs index 9704633f..6803faa8 100644 --- a/programs/v06_launchpad/src/instructions/complete_launch.rs +++ b/programs/v06_launchpad/src/instructions/complete_launch.rs @@ -413,6 +413,8 @@ impl CompleteLaunch<'_> { }), team_sponsored_pass_threshold_bps: -500, team_address: self.launch.team_address, + base_to_supermajority: futarchy::DEFAULT_BASE_TO_SUPERMAJORITY_TOKENS * TOKEN_SCALE, + is_proposal_validation_enabled: true, }, ) } diff --git a/programs/v07_launchpad/src/instructions/complete_launch.rs b/programs/v07_launchpad/src/instructions/complete_launch.rs index 39be1f37..f1215e37 100644 --- a/programs/v07_launchpad/src/instructions/complete_launch.rs +++ b/programs/v07_launchpad/src/instructions/complete_launch.rs @@ -444,6 +444,8 @@ impl CompleteLaunch<'_> { }), team_sponsored_pass_threshold_bps: -300, team_address: self.launch.team_address, + base_to_supermajority: futarchy::DEFAULT_BASE_TO_SUPERMAJORITY_TOKENS * TOKEN_SCALE, + is_proposal_validation_enabled: true, }, ) } diff --git a/programs/v08_launchpad/src/instructions/settle_launch.rs b/programs/v08_launchpad/src/instructions/settle_launch.rs index a80b07f9..6ab319f2 100644 --- a/programs/v08_launchpad/src/instructions/settle_launch.rs +++ b/programs/v08_launchpad/src/instructions/settle_launch.rs @@ -452,6 +452,8 @@ impl SettleLaunch<'_> { }), team_sponsored_pass_threshold_bps: -300, team_address: self.launch.team_address, + base_to_supermajority: futarchy::DEFAULT_BASE_TO_SUPERMAJORITY_TOKENS * TOKEN_SCALE, + is_proposal_validation_enabled: true, }, ) } diff --git a/scripts/v0.6/createDao.ts b/scripts/v0.6/createDao.ts index d474c69d..e3a6ccab 100644 --- a/scripts/v0.6/createDao.ts +++ b/scripts/v0.6/createDao.ts @@ -160,6 +160,8 @@ export const createDao = async () => { secondsPerProposal, teamSponsoredPassThresholdBps: TEAM_SPONSORED_PASS_THRESHOLD_BPS, teamAddress: TEAM_ADDRESS, + baseToSupermajority: new BN(0), + isProposalValidationEnabled: true, }, }) .rpc(); diff --git a/scripts/v0.6/initializeDao.ts b/scripts/v0.6/initializeDao.ts index 71504731..d9eec264 100644 --- a/scripts/v0.6/initializeDao.ts +++ b/scripts/v0.6/initializeDao.ts @@ -91,6 +91,8 @@ export const initializeDao = async () => { secondsPerProposal: 60 * 60 * 24 * 3, teamAddress: PublicKey.default, teamSponsoredPassThresholdBps: 0, + baseToSupermajority: new BN(0), + isProposalValidationEnabled: false, }, }) .rpc(); diff --git a/sdk/src/futarchy/v0.6/FutarchyClient.ts b/sdk/src/futarchy/v0.6/FutarchyClient.ts index 2468db64..5a07f831 100644 --- a/sdk/src/futarchy/v0.6/FutarchyClient.ts +++ b/sdk/src/futarchy/v0.6/FutarchyClient.ts @@ -1005,6 +1005,22 @@ export class FutarchyClient { }); } + approveProposalIx({ + proposal, + dao, + approver = this.provider.publicKey, + }: { + proposal: PublicKey; + dao: PublicKey; + approver?: PublicKey; + }) { + return this.futarchy.methods.approveProposal().accounts({ + proposal, + dao, + approver, + }); + } + collectMeteoraDammFeesIx({ dao, baseMint, diff --git a/sdk/src/futarchy/v0.6/types/futarchy.ts b/sdk/src/futarchy/v0.6/types/futarchy.ts index 9af98c32..26ec4d5e 100644 --- a/sdk/src/futarchy/v0.6/types/futarchy.ts +++ b/sdk/src/futarchy/v0.6/types/futarchy.ts @@ -603,6 +603,27 @@ export type Futarchy = { ]; args: []; }, + { + name: "resizeProposal"; + accounts: [ + { + name: "proposal"; + isMut: true; + isSigner: false; + }, + { + name: "payer"; + isMut: true; + isSigner: true; + }, + { + name: "systemProgram"; + isMut: false; + isSigner: false; + }, + ]; + args: []; + }, { name: "spotSwap"; accounts: [ @@ -1053,6 +1074,37 @@ export type Futarchy = { ]; args: []; }, + { + name: "approveProposal"; + accounts: [ + { + name: "proposal"; + isMut: true; + isSigner: false; + }, + { + name: "dao"; + isMut: true; + isSigner: false; + }, + { + name: "approver"; + isMut: false; + isSigner: true; + }, + { + name: "eventAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "program"; + isMut: false; + isSigner: false; + }, + ]; + args: []; + }, { name: "collectMeteoraDammFees"; accounts: [ @@ -1769,6 +1821,23 @@ export type Futarchy = { name: "isOptimisticGovernanceEnabled"; type: "bool"; }, + { + name: "baseToSupermajority"; + docs: [ + "Absolute base-token stake at which a proposal launches on supermajority", + "stake alone. `0` disables the supermajority path for this DAO.", + ]; + type: "u64"; + }, + { + name: "isProposalValidationEnabled"; + docs: [ + "When enabled, `launch_proposal` enforces the stricter validation gate", + "(>= 2 of 3 approval points, or the supermajority path). When disabled,", + "the DAO uses the legacy gate", + ]; + type: "bool"; + }, ]; }; }, @@ -1905,6 +1974,18 @@ export type Futarchy = { name: "teamAddress"; type: "publicKey"; }, + { + name: "optimisticProposal"; + type: { + option: { + defined: "OptimisticProposal"; + }; + }; + }, + { + name: "isOptimisticGovernanceEnabled"; + type: "bool"; + }, ]; }; }, @@ -1930,6 +2011,84 @@ export type Futarchy = { }, { name: "proposal"; + type: { + kind: "struct"; + fields: [ + { + name: "number"; + type: "u32"; + }, + { + name: "proposer"; + type: "publicKey"; + }, + { + name: "timestampEnqueued"; + type: "i64"; + }, + { + name: "state"; + type: { + defined: "ProposalState"; + }; + }, + { + name: "baseVault"; + type: "publicKey"; + }, + { + name: "quoteVault"; + type: "publicKey"; + }, + { + name: "dao"; + type: "publicKey"; + }, + { + name: "pdaBump"; + type: "u8"; + }, + { + name: "question"; + type: "publicKey"; + }, + { + name: "durationInSeconds"; + type: "u32"; + }, + { + name: "squadsProposal"; + type: "publicKey"; + }, + { + name: "passBaseMint"; + type: "publicKey"; + }, + { + name: "passQuoteMint"; + type: "publicKey"; + }, + { + name: "failBaseMint"; + type: "publicKey"; + }, + { + name: "failQuoteMint"; + type: "publicKey"; + }, + { + name: "isTeamSponsored"; + type: "bool"; + }, + { + name: "isMetadaoApproved"; + type: "bool"; + }, + ]; + }; + }, + { + name: "oldProposal"; type: { kind: "struct"; fields: [ @@ -2145,6 +2304,14 @@ export type Futarchy = { name: "teamAddress"; type: "publicKey"; }, + { + name: "baseToSupermajority"; + type: "u64"; + }, + { + name: "isProposalValidationEnabled"; + type: "bool"; + }, ]; }; }, @@ -2308,6 +2475,18 @@ export type Futarchy = { option: "bool"; }; }, + { + name: "baseToSupermajority"; + type: { + option: "u64"; + }; + }, + { + name: "isProposalValidationEnabled"; + type: { + option: "bool"; + }; + }, ]; }; }, @@ -2786,6 +2965,16 @@ export type Futarchy = { type: "publicKey"; index: false; }, + { + name: "baseToSupermajority"; + type: "u64"; + index: false; + }, + { + name: "isProposalValidationEnabled"; + type: "bool"; + index: false; + }, ]; }, { @@ -2858,6 +3047,16 @@ export type Futarchy = { type: "bool"; index: false; }, + { + name: "baseToSupermajority"; + type: "u64"; + index: false; + }, + { + name: "isProposalValidationEnabled"; + type: "bool"; + index: false; + }, ]; }, { @@ -3350,6 +3549,33 @@ export type Futarchy = { }, ]; }, + { + name: "ApproveProposalEvent"; + fields: [ + { + name: "common"; + type: { + defined: "CommonFields"; + }; + index: false; + }, + { + name: "proposal"; + type: "publicKey"; + index: false; + }, + { + name: "dao"; + type: "publicKey"; + index: false; + }, + { + name: "approver"; + type: "publicKey"; + index: false; + }, + ]; + }, { name: "RemoveProposalEvent"; fields: [ @@ -3801,6 +4027,31 @@ export type Futarchy = { name: "NoActiveOptimisticProposal"; msg: "No active optimistic proposal"; }, + { + code: 6043; + name: "InvalidApprover"; + msg: "Invalid MetaDAO approver"; + }, + { + code: 6044; + name: "ProposalAlreadyApproved"; + msg: "Proposal has already been approved by MetaDAO"; + }, + { + code: 6045; + name: "InvalidSupermajorityThreshold"; + msg: "base_to_supermajority must be 0 (disabled) or >= base_to_stake"; + }, + { + code: 6046; + name: "InsufficientApprovalToLaunch"; + msg: "Proposal lacks enough approval points to launch"; + }, + { + code: 6047; + name: "ProposalValidationDisabled"; + msg: "Proposal validation is not enabled for this DAO"; + }, ]; }; @@ -4409,6 +4660,27 @@ export const IDL: Futarchy = { ], args: [], }, + { + name: "resizeProposal", + accounts: [ + { + name: "proposal", + isMut: true, + isSigner: false, + }, + { + name: "payer", + isMut: true, + isSigner: true, + }, + { + name: "systemProgram", + isMut: false, + isSigner: false, + }, + ], + args: [], + }, { name: "spotSwap", accounts: [ @@ -4859,6 +5131,37 @@ export const IDL: Futarchy = { ], args: [], }, + { + name: "approveProposal", + accounts: [ + { + name: "proposal", + isMut: true, + isSigner: false, + }, + { + name: "dao", + isMut: true, + isSigner: false, + }, + { + name: "approver", + isMut: false, + isSigner: true, + }, + { + name: "eventAuthority", + isMut: false, + isSigner: false, + }, + { + name: "program", + isMut: false, + isSigner: false, + }, + ], + args: [], + }, { name: "collectMeteoraDammFees", accounts: [ @@ -5575,6 +5878,23 @@ export const IDL: Futarchy = { name: "isOptimisticGovernanceEnabled", type: "bool", }, + { + name: "baseToSupermajority", + docs: [ + "Absolute base-token stake at which a proposal launches on supermajority", + "stake alone. `0` disables the supermajority path for this DAO.", + ], + type: "u64", + }, + { + name: "isProposalValidationEnabled", + docs: [ + "When enabled, `launch_proposal` enforces the stricter validation gate", + "(>= 2 of 3 approval points, or the supermajority path). When disabled,", + "the DAO uses the legacy gate", + ], + type: "bool", + }, ], }, }, @@ -5711,6 +6031,18 @@ export const IDL: Futarchy = { name: "teamAddress", type: "publicKey", }, + { + name: "optimisticProposal", + type: { + option: { + defined: "OptimisticProposal", + }, + }, + }, + { + name: "isOptimisticGovernanceEnabled", + type: "bool", + }, ], }, }, @@ -5736,6 +6068,84 @@ export const IDL: Futarchy = { }, { name: "proposal", + type: { + kind: "struct", + fields: [ + { + name: "number", + type: "u32", + }, + { + name: "proposer", + type: "publicKey", + }, + { + name: "timestampEnqueued", + type: "i64", + }, + { + name: "state", + type: { + defined: "ProposalState", + }, + }, + { + name: "baseVault", + type: "publicKey", + }, + { + name: "quoteVault", + type: "publicKey", + }, + { + name: "dao", + type: "publicKey", + }, + { + name: "pdaBump", + type: "u8", + }, + { + name: "question", + type: "publicKey", + }, + { + name: "durationInSeconds", + type: "u32", + }, + { + name: "squadsProposal", + type: "publicKey", + }, + { + name: "passBaseMint", + type: "publicKey", + }, + { + name: "passQuoteMint", + type: "publicKey", + }, + { + name: "failBaseMint", + type: "publicKey", + }, + { + name: "failQuoteMint", + type: "publicKey", + }, + { + name: "isTeamSponsored", + type: "bool", + }, + { + name: "isMetadaoApproved", + type: "bool", + }, + ], + }, + }, + { + name: "oldProposal", type: { kind: "struct", fields: [ @@ -5951,6 +6361,14 @@ export const IDL: Futarchy = { name: "teamAddress", type: "publicKey", }, + { + name: "baseToSupermajority", + type: "u64", + }, + { + name: "isProposalValidationEnabled", + type: "bool", + }, ], }, }, @@ -6114,6 +6532,18 @@ export const IDL: Futarchy = { option: "bool", }, }, + { + name: "baseToSupermajority", + type: { + option: "u64", + }, + }, + { + name: "isProposalValidationEnabled", + type: { + option: "bool", + }, + }, ], }, }, @@ -6592,6 +7022,16 @@ export const IDL: Futarchy = { type: "publicKey", index: false, }, + { + name: "baseToSupermajority", + type: "u64", + index: false, + }, + { + name: "isProposalValidationEnabled", + type: "bool", + index: false, + }, ], }, { @@ -6664,6 +7104,16 @@ export const IDL: Futarchy = { type: "bool", index: false, }, + { + name: "baseToSupermajority", + type: "u64", + index: false, + }, + { + name: "isProposalValidationEnabled", + type: "bool", + index: false, + }, ], }, { @@ -7156,6 +7606,33 @@ export const IDL: Futarchy = { }, ], }, + { + name: "ApproveProposalEvent", + fields: [ + { + name: "common", + type: { + defined: "CommonFields", + }, + index: false, + }, + { + name: "proposal", + type: "publicKey", + index: false, + }, + { + name: "dao", + type: "publicKey", + index: false, + }, + { + name: "approver", + type: "publicKey", + index: false, + }, + ], + }, { name: "RemoveProposalEvent", fields: [ @@ -7607,5 +8084,30 @@ export const IDL: Futarchy = { name: "NoActiveOptimisticProposal", msg: "No active optimistic proposal", }, + { + code: 6043, + name: "InvalidApprover", + msg: "Invalid MetaDAO approver", + }, + { + code: 6044, + name: "ProposalAlreadyApproved", + msg: "Proposal has already been approved by MetaDAO", + }, + { + code: 6045, + name: "InvalidSupermajorityThreshold", + msg: "base_to_supermajority must be 0 (disabled) or >= base_to_stake", + }, + { + code: 6046, + name: "InsufficientApprovalToLaunch", + msg: "Proposal lacks enough approval points to launch", + }, + { + code: 6047, + name: "ProposalValidationDisabled", + msg: "Proposal validation is not enabled for this DAO", + }, ], }; diff --git a/tests/futarchy/integration/futarchyAmm.test.ts b/tests/futarchy/integration/futarchyAmm.test.ts index b74c7ede..d59aa77d 100644 --- a/tests/futarchy/integration/futarchyAmm.test.ts +++ b/tests/futarchy/integration/futarchyAmm.test.ts @@ -82,6 +82,8 @@ export default function suite() { teamSponsoredPassThresholdBps: null, teamAddress: null, isOptimisticGovernanceEnabled: null, + baseToSupermajority: null, + isProposalValidationEnabled: null, }, }) .instruction(); diff --git a/tests/futarchy/main.test.ts b/tests/futarchy/main.test.ts index e800e372..8c283562 100644 --- a/tests/futarchy/main.test.ts +++ b/tests/futarchy/main.test.ts @@ -22,6 +22,9 @@ import adminExecuteMultisigProposal from "./unit/adminExecuteMultisigProposal.te import adminCancelProposal from "./unit/adminCancelProposal.test.js"; import adminRemoveProposal from "./unit/adminRemoveProposal.test.js"; import unstakeFromProposal from "./unit/unstakeFromProposal.test.js"; +import approveProposal from "./unit/approveProposal.test.js"; +import resizeProposal from "./unit/resizeProposal.test.js"; +import resizeDao from "./unit/resizeDao.test.js"; import { PublicKey } from "@solana/web3.js"; import { @@ -86,6 +89,9 @@ export default function suite() { describe("#admin_cancel_proposal", adminCancelProposal); describe("#admin_remove_proposal", adminRemoveProposal); describe("#unstake_from_proposal", unstakeFromProposal); + describe("#approve_proposal", approveProposal); + describe("#resize_proposal", resizeProposal); + describe("#resize_dao", resizeDao); // describe("full proposal", fullProposal); // describe("proposal with a squads batch tx", proposalBatchTx); describe("futarchy amm", futarchyAmm); diff --git a/tests/futarchy/unit/adminCancelProposal.test.ts b/tests/futarchy/unit/adminCancelProposal.test.ts index cea66318..70d21c5a 100644 --- a/tests/futarchy/unit/adminCancelProposal.test.ts +++ b/tests/futarchy/unit/adminCancelProposal.test.ts @@ -75,6 +75,8 @@ export default function suite() { teamSponsoredPassThresholdBps: null, teamAddress: null, isOptimisticGovernanceEnabled: null, + baseToSupermajority: null, + isProposalValidationEnabled: null, }, }) .instruction(); diff --git a/tests/futarchy/unit/adminRemoveProposal.test.ts b/tests/futarchy/unit/adminRemoveProposal.test.ts index 565313bf..6e665505 100644 --- a/tests/futarchy/unit/adminRemoveProposal.test.ts +++ b/tests/futarchy/unit/adminRemoveProposal.test.ts @@ -51,6 +51,8 @@ export default function suite() { teamSponsoredPassThresholdBps: null, teamAddress: null, isOptimisticGovernanceEnabled: null, + baseToSupermajority: null, + isProposalValidationEnabled: null, }, }) .instruction(); diff --git a/tests/futarchy/unit/approveProposal.test.ts b/tests/futarchy/unit/approveProposal.test.ts new file mode 100644 index 00000000..38ddc0a4 --- /dev/null +++ b/tests/futarchy/unit/approveProposal.test.ts @@ -0,0 +1,210 @@ +import { PERMISSIONLESS_ACCOUNT } from "@metadaoproject/programs"; +import { + ComputeBudgetProgram, + PublicKey, + Transaction, + TransactionMessage, +} from "@solana/web3.js"; +import BN from "bn.js"; +import { + expectError, + setupBasicDao, + setProposalValidationEnabled, +} from "../../utils.js"; +import { assert } from "chai"; +import * as multisig from "@sqds/multisig"; + +export default function suite() { + let META: PublicKey, + USDC: PublicKey, + dao: PublicKey, + proposal: PublicKey, + squadsProposalPda: PublicKey; + + beforeEach(async function () { + META = await this.createMint(this.payer.publicKey, 6); + USDC = await this.createMint(this.payer.publicKey, 6); + + await this.createTokenAccount(META, this.payer.publicKey); + await this.createTokenAccount(USDC, this.payer.publicKey); + + await this.mintTo(META, this.payer.publicKey, this.payer, 100 * 10 ** 9); + await this.mintTo( + USDC, + this.payer.publicKey, + this.payer, + 200_000 * 1_000_000, + ); + + // Validation enabled so the MetaDAO approval point is live (approve_proposal + // requires it); setupBasicDao uses baseToStake: 0, so a draft proposal can + // still launch. + dao = await setupBasicDao({ + context: this, + baseMint: META, + quoteMint: USDC, + isProposalValidationEnabled: true, + }); + + await this.futarchy + .provideLiquidityIx({ + dao, + baseMint: META, + quoteMint: USDC, + quoteAmount: new BN(100_000 * 10 ** 6), + maxBaseAmount: new BN(100 * 10 ** 6), + minLiquidity: new BN(0), + positionAuthority: this.payer.publicKey, + liquidityProvider: this.payer.publicKey, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), + ]) + .rpc(); + + // Wrap an arbitrary instruction in a squads vault transaction so we have a + // valid active squads proposal to initialize the futarchy proposal against. + 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, + baseToSupermajority: null, + isProposalValidationEnabled: null, + }, + }) + .instruction(); + + const updateDaoMessage = new TransactionMessage({ + payerKey: this.payer.publicKey, + recentBlockhash: (await this.banksClient.getLatestBlockhash())[0], + instructions: [updateDaoIx], + }); + + const multisigPda = multisig.getMultisigPda({ createKey: dao })[0]; + const vaultTxCreate = multisig.instructions.vaultTransactionCreate({ + multisigPda, + transactionIndex: 1n, + creator: PERMISSIONLESS_ACCOUNT.publicKey, + rentPayer: this.payer.publicKey, + vaultIndex: 0, + ephemeralSigners: 0, + transactionMessage: updateDaoMessage, + }); + + const proposalCreateIx = multisig.instructions.proposalCreate({ + multisigPda, + transactionIndex: 1n, + creator: PERMISSIONLESS_ACCOUNT.publicKey, + rentPayer: this.payer.publicKey, + }); + + [squadsProposalPda] = multisig.getProposalPda({ + multisigPda, + transactionIndex: 1n, + }); + + const tx = new Transaction().add(vaultTxCreate, proposalCreateIx); + tx.recentBlockhash = (await this.banksClient.getLatestBlockhash())[0]; + tx.feePayer = this.payer.publicKey; + tx.sign(this.payer, PERMISSIONLESS_ACCOUNT); + + await this.banksClient.processTransaction(tx); + + proposal = await this.futarchy.initializeProposal(dao, squadsProposalPda); + }); + + it("approves a draft proposal and sets is_metadao_approved", async function () { + const before = await this.futarchy.getProposal(proposal); + assert.isFalse(before.isMetadaoApproved); + + const daoBefore = await this.futarchy.getDao(dao); + + await this.futarchy + .approveProposalIx({ proposal, dao, approver: this.payer.publicKey }) + .rpc(); + + const after = await this.futarchy.getProposal(proposal); + assert.isTrue(after.isMetadaoApproved); + + // ApproveProposalEvent carries common.dao_seq_num, sourced from this bump. + // bankrun's transaction metadata exposes neither inner instructions nor + // emit_cpi! payloads, so the seq_num increment is the observable proof that + // the event-emitting code path ran to completion. + const daoAfter = await this.futarchy.getDao(dao); + assert.equal(daoAfter.seqNum.toNumber(), daoBefore.seqNum.toNumber() + 1); + }); + + it("rejects a second approval with ProposalAlreadyApproved", async function () { + await this.futarchy + .approveProposalIx({ proposal, dao, approver: this.payer.publicKey }) + .rpc(); + + const callbacks = expectError( + "ProposalAlreadyApproved", + "a second approval should fail", + ); + + await this.futarchy + .approveProposalIx({ proposal, dao, approver: this.payer.publicKey }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 200_000 }), + ]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("rejects approval once the proposal has left Draft", async function () { + // Sponsor so the proposal can launch without being MetaDAO-approved. + await this.futarchy.sponsorProposalIx({ proposal, dao }).rpc(); + + await this.futarchy + .launchProposalIx({ + proposal, + dao, + baseMint: META, + quoteMint: USDC, + squadsProposal: squadsProposalPda, + }) + .rpc(); + + const launched = await this.futarchy.getProposal(proposal); + assert.exists(launched.state.pending); + + const callbacks = expectError( + "ProposalNotInDraftState", + "approval should fail once the proposal has launched", + ); + + await this.futarchy + .approveProposalIx({ proposal, dao, approver: this.payer.publicKey }) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("rejects approval when proposal validation is disabled", async function () { + // Flip the DAO onto the legacy gate, where the MetaDAO approval point is inert; + // approving must then be rejected rather than write dead state. + await setProposalValidationEnabled(this, dao, false); + + const callbacks = expectError( + "ProposalValidationDisabled", + "approval on a non-validating DAO must be rejected", + ); + + await this.futarchy + .approveProposalIx({ proposal, dao, approver: this.payer.publicKey }) + .rpc() + .then(callbacks[0], callbacks[1]); + }); +} diff --git a/tests/futarchy/unit/finalizeOptimisticProposal.test.ts b/tests/futarchy/unit/finalizeOptimisticProposal.test.ts index b3c50f0a..e29b7007 100644 --- a/tests/futarchy/unit/finalizeOptimisticProposal.test.ts +++ b/tests/futarchy/unit/finalizeOptimisticProposal.test.ts @@ -11,7 +11,11 @@ import { TransactionMessage, } from "@solana/web3.js"; import BN from "bn.js"; -import { expectError, setOptimisticGovernanceEnabled } from "../../utils.js"; +import { + expectError, + setOptimisticGovernanceEnabled, + nextDaoNonce, +} from "../../utils.js"; import { createTransferInstruction, getAssociatedTokenAddressSync, @@ -37,7 +41,7 @@ export default function suite() { // Mint tokens to payer's accounts await this.mintTo(META, this.payer.publicKey, this.payer, 100 * 10 ** 9); - const nonce = new BN(Math.floor(Math.random() * 1000000)); + const nonce = nextDaoNonce(); await this.futarchy .initializeDaoIx({ @@ -57,6 +61,8 @@ export default function suite() { members: [this.payer.publicKey], }, baseToStake: new BN(0), + baseToSupermajority: new BN(0), + isProposalValidationEnabled: false, teamSponsoredPassThresholdBps: 0, teamAddress: this.payer.publicKey, }, diff --git a/tests/futarchy/unit/finalizeProposal.test.ts b/tests/futarchy/unit/finalizeProposal.test.ts index 231c3c9e..00805f4e 100644 --- a/tests/futarchy/unit/finalizeProposal.test.ts +++ b/tests/futarchy/unit/finalizeProposal.test.ts @@ -75,6 +75,8 @@ export default function suite() { teamSponsoredPassThresholdBps: null, teamAddress: null, isOptimisticGovernanceEnabled: null, + baseToSupermajority: null, + isProposalValidationEnabled: null, }, }) .instruction(); @@ -446,6 +448,8 @@ export default function suite() { teamSponsoredPassThresholdBps: null, teamAddress: null, isOptimisticGovernanceEnabled: null, + baseToSupermajority: null, + isProposalValidationEnabled: null, }, }) .instruction(); diff --git a/tests/futarchy/unit/initializeDao.test.ts b/tests/futarchy/unit/initializeDao.test.ts index 110c3faa..ce8f2191 100644 --- a/tests/futarchy/unit/initializeDao.test.ts +++ b/tests/futarchy/unit/initializeDao.test.ts @@ -5,7 +5,7 @@ import { } from "@metadaoproject/programs"; import { ComputeBudgetProgram, Keypair, PublicKey } from "@solana/web3.js"; import BN from "bn.js"; -import { expectError } from "../../utils.js"; +import { expectError, nextDaoNonce } from "../../utils.js"; import { assert } from "chai"; import * as multisig from "@sqds/multisig"; const { Permissions, Permission, Period } = multisig.types; @@ -21,6 +21,7 @@ export default function suite() { }); it("should initialize a DAO", async function () { + const nonce = nextDaoNonce(); await this.futarchy .initializeDaoIx({ baseMint: META, @@ -33,11 +34,13 @@ export default function suite() { minQuoteFutarchicLiquidity: new BN(1), minBaseFutarchicLiquidity: new BN(1000), baseToStake: new BN(1000), + baseToSupermajority: new BN(5000), passThresholdBps: 300, - nonce: new BN(1337), + nonce, initialSpendingLimit: null, teamAddress: this.payer.publicKey, teamSponsoredPassThresholdBps: 123, + isProposalValidationEnabled: true, }, }) .preInstructions([ @@ -46,7 +49,7 @@ export default function suite() { .rpc(); const [dao, daoBump] = getDaoAddr({ - nonce: new BN(1337), + nonce, daoCreator: this.payer.publicKey, }); @@ -57,7 +60,7 @@ export default function suite() { assert.equal(storedDao.pdaBump, daoBump); assert.equal(storedDao.proposalCount, 0); - assert.equal(storedDao.nonce.toString(), "1337"); + assert.equal(storedDao.nonce.toString(), nonce.toString()); assert.equal(storedDao.secondsPerProposal, 60 * 60 * 24 * 3); assert.equal(storedDao.twapStartDelaySeconds, 60 * 60 * 24); assert.equal( @@ -76,8 +79,11 @@ export default function suite() { assert.isTrue(storedDao.teamAddress.equals(this.payer.publicKey)); assert.equal(storedDao.teamSponsoredPassThresholdBps, 123); + assert.equal(storedDao.baseToSupermajority.toString(), "5000"); + assert.isNull(storedDao.optimisticProposal); assert.isFalse(storedDao.isOptimisticGovernanceEnabled); + assert.isTrue(storedDao.isProposalValidationEnabled); const multisigPda = multisig.getMultisigPda({ createKey: dao })[0]; const squadsMultisigVault = multisig.getVaultPda({ @@ -119,6 +125,7 @@ export default function suite() { it("should initialize a DAO with an initial spending limit", async function () { const spender = Keypair.generate(); + const nonce = nextDaoNonce(); await this.futarchy .initializeDaoIx({ @@ -132,8 +139,10 @@ export default function suite() { minQuoteFutarchicLiquidity: new BN(1), minBaseFutarchicLiquidity: new BN(1000), baseToStake: new BN(1000), + baseToSupermajority: new BN(0), + isProposalValidationEnabled: false, passThresholdBps: 300, - nonce: new BN(420), + nonce, initialSpendingLimit: { // 10k per month burn amountPerMonth: new BN(10_000 * 10 ** 6), @@ -146,7 +155,7 @@ export default function suite() { .rpc(); const [dao] = getDaoAddr({ - nonce: new BN(420), + nonce, daoCreator: this.payer.publicKey, }); @@ -189,6 +198,7 @@ export default function suite() { assert.equal(storedDao.teamSponsoredPassThresholdBps, 123); assert.isNull(storedDao.optimisticProposal); assert.isFalse(storedDao.isOptimisticGovernanceEnabled); + assert.isFalse(storedDao.isProposalValidationEnabled); }); it("doesn't allow DAOs with identical base and quote mints", async function () { @@ -211,9 +221,11 @@ export default function suite() { minQuoteFutarchicLiquidity: new BN(1), minBaseFutarchicLiquidity: new BN(1000), passThresholdBps: 300, - nonce: new BN(9999), + nonce: nextDaoNonce(), initialSpendingLimit: null, baseToStake: new BN(1000), + baseToSupermajority: new BN(0), + isProposalValidationEnabled: false, teamSponsoredPassThresholdBps: 1500, teamAddress: this.payer.publicKey, }, @@ -240,9 +252,11 @@ export default function suite() { minBaseFutarchicLiquidity: new BN(5000), passThresholdBps: 300, secondsPerProposal: 5000, - nonce: new BN(1338), + nonce: nextDaoNonce(), initialSpendingLimit: null, baseToStake: new BN(1000), + baseToSupermajority: new BN(0), + isProposalValidationEnabled: false, teamSponsoredPassThresholdBps: 123, teamAddress: this.payer.publicKey, }, @@ -250,4 +264,35 @@ export default function suite() { .rpc() .then(callbacks[0], callbacks[1]); }); + + it("doesn't allow base_to_supermajority between 0 and base_to_stake", async function () { + const callbacks = expectError( + "InvalidSupermajorityThreshold", + "DAO initialized despite base_to_supermajority being below base_to_stake", + ); + + await this.futarchy + .initializeDaoIx({ + baseMint: META, + quoteMint: USDC, + params: { + secondsPerProposal: 60 * 60 * 24 * 3, + twapStartDelaySeconds: 60 * 60 * 24, + twapInitialObservation: THOUSAND_BUCK_PRICE, + twapMaxObservationChangePerUpdate: THOUSAND_BUCK_PRICE.divn(100), + minQuoteFutarchicLiquidity: new BN(1), + minBaseFutarchicLiquidity: new BN(1000), + baseToStake: new BN(1000), + baseToSupermajority: new BN(999), + isProposalValidationEnabled: false, + passThresholdBps: 300, + nonce: nextDaoNonce(), + initialSpendingLimit: null, + teamAddress: this.payer.publicKey, + teamSponsoredPassThresholdBps: 123, + }, + }) + .rpc() + .then(callbacks[0], callbacks[1]); + }); } diff --git a/tests/futarchy/unit/initializeProposal.test.ts b/tests/futarchy/unit/initializeProposal.test.ts index ec3bea39..7130da42 100644 --- a/tests/futarchy/unit/initializeProposal.test.ts +++ b/tests/futarchy/unit/initializeProposal.test.ts @@ -10,7 +10,11 @@ import { TransactionMessage, } from "@solana/web3.js"; import BN from "bn.js"; -import { expectError, setOptimisticGovernanceEnabled } from "../../utils.js"; +import { + expectError, + setOptimisticGovernanceEnabled, + nextDaoNonce, +} from "../../utils.js"; import { assert } from "chai"; import * as multisig from "@sqds/multisig"; const { Permissions, Permission } = multisig.types; @@ -37,7 +41,7 @@ export default function suite() { 100_000 * 1_000_000, ); - const nonce = new BN(Math.floor(Math.random() * 1000000)); + const nonce = nextDaoNonce(); await this.futarchy .initializeDaoIx({ @@ -57,6 +61,8 @@ export default function suite() { members: [this.payer.publicKey], }, baseToStake: new BN(0), + baseToSupermajority: new BN(0), + isProposalValidationEnabled: false, teamSponsoredPassThresholdBps: 0, teamAddress: this.payer.publicKey, }, @@ -92,6 +98,8 @@ export default function suite() { teamSponsoredPassThresholdBps: null, teamAddress: null, isOptimisticGovernanceEnabled: null, + baseToSupermajority: null, + isProposalValidationEnabled: null, }, }) .instruction(); diff --git a/tests/futarchy/unit/initiateVaultSpendOptimisticProposal.test.ts b/tests/futarchy/unit/initiateVaultSpendOptimisticProposal.test.ts index db4dd15a..c81dc73b 100644 --- a/tests/futarchy/unit/initiateVaultSpendOptimisticProposal.test.ts +++ b/tests/futarchy/unit/initiateVaultSpendOptimisticProposal.test.ts @@ -15,7 +15,11 @@ import { TransactionMessage, } from "@solana/web3.js"; import BN from "bn.js"; -import { expectError, setOptimisticGovernanceEnabled } from "../../utils.js"; +import { + expectError, + setOptimisticGovernanceEnabled, + nextDaoNonce, +} from "../../utils.js"; import { createAssociatedTokenAccountIdempotentInstruction, createTransferInstruction, @@ -39,7 +43,7 @@ export default function suite() { // Mint tokens to payer's accounts await this.mintTo(META, this.payer.publicKey, this.payer, 100 * 10 ** 9); - const nonce = new BN(Math.floor(Math.random() * 1000000)); + const nonce = nextDaoNonce(); await this.futarchy .initializeDaoIx({ @@ -59,6 +63,8 @@ export default function suite() { members: [this.payer.publicKey], }, baseToStake: new BN(0), + baseToSupermajority: new BN(0), + isProposalValidationEnabled: false, teamSponsoredPassThresholdBps: 0, teamAddress: this.payer.publicKey, }, diff --git a/tests/futarchy/unit/launchProposal.test.ts b/tests/futarchy/unit/launchProposal.test.ts index a2a06198..f1ab44c7 100644 --- a/tests/futarchy/unit/launchProposal.test.ts +++ b/tests/futarchy/unit/launchProposal.test.ts @@ -6,24 +6,27 @@ import { } from "@metadaoproject/programs"; import { ComputeBudgetProgram, - Keypair, PublicKey, Transaction, TransactionMessage, } from "@solana/web3.js"; import BN from "bn.js"; -import { expectError, setOptimisticGovernanceEnabled } from "../../utils.js"; +import { + expectError, + setOptimisticGovernanceEnabled, + nextDaoNonce, +} from "../../utils.js"; import { assert } from "chai"; import * as multisig from "@sqds/multisig"; const THOUSAND_BUCK_PRICE = PriceMath.getAmmPrice(1000, 6, 6); +// A non-zero base-to-stake floor, so the stake approval point is actually +// meaningful (setupBasicDao uses 0, which would make the stake point free). +const BASE_TO_STAKE = new BN(1_000 * 10 ** 6); // 1,000 tokens + export default function suite() { - let META: PublicKey, - USDC: PublicKey, - dao: PublicKey, - spendingLimit: BN, - transferAmount: bigint; + let META: PublicKey, USDC: PublicKey, spendingLimit: BN; beforeEach(async function () { META = await this.createMint(this.payer.publicKey, 6); @@ -33,36 +36,44 @@ export default function suite() { await this.createTokenAccount(META, this.payer.publicKey); await this.createTokenAccount(USDC, this.payer.publicKey); + // Mint generously so large stakes (the supermajority cases) are never + // supply-bound. await this.mintTo( META, this.payer.publicKey, this.payer, - 200_000 * 10 ** 6, + 20_000_000 * 10 ** 6, ); await this.mintTo( USDC, this.payer.publicKey, this.payer, - 200_000 * 1_000_000, + 1_000_000 * 10 ** 6, ); }); - /** - * Helper function to create a DAO with a specific baseToStake threshold - */ - async function createDaoWithStakeThreshold( + // Create a DAO with the given launch thresholds. isProposalValidationEnabled + // defaults to true because this suite is predominantly about the validation + // gate; the legacy-gate block opts out explicitly. baseToSupermajority + // defaults to 0 (supermajority disabled), which the invariant always permits. + async function createDao( context: any, - baseMint: PublicKey, - quoteMint: PublicKey, - baseToStake: BN, - payer: Keypair, + { + baseToStake, + baseToSupermajority = new BN(0), + isProposalValidationEnabled = true, + }: { + baseToStake: BN; + baseToSupermajority?: BN; + isProposalValidationEnabled?: boolean; + }, ): Promise { - const nonce = new BN(Math.floor(Math.random() * 1000000)); + const nonce = nextDaoNonce(); await context.futarchy .initializeDaoIx({ - baseMint, - quoteMint, + baseMint: META, + quoteMint: USDC, params: { secondsPerProposal: 60 * 60 * 24 * 3, twapStartDelaySeconds: 60 * 60 * 24, @@ -74,11 +85,13 @@ export default function suite() { nonce, initialSpendingLimit: { amountPerMonth: spendingLimit, - members: [payer.publicKey], + members: [context.payer.publicKey], }, baseToStake, + baseToSupermajority, teamSponsoredPassThresholdBps: 300, - teamAddress: payer.publicKey, + teamAddress: context.payer.publicKey, + isProposalValidationEnabled, }, provideLiquidity: true, }) @@ -87,18 +100,31 @@ export default function suite() { ]) .rpc(); - const [dao] = getDaoAddr({ - nonce, - daoCreator: payer.publicKey, - }); - + const [dao] = getDaoAddr({ nonce, daoCreator: context.payer.publicKey }); return dao; } - /** - * Helper function to initialize a proposal for a DAO - */ - async function initializeProposal( + async function provideLiquidity(context: any, dao: PublicKey): Promise { + await context.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: context.payer.publicKey, + liquidityProvider: context.payer.publicKey, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), + ]) + .rpc(); + } + + // Set up a launchable draft proposal: wrap an arbitrary instruction in an + // active Squads vault transaction, then initialize the futarchy proposal. + async function initDraftProposal( context: any, dao: PublicKey, ): Promise<{ proposal: PublicKey; squadsProposal: PublicKey }> { @@ -116,6 +142,9 @@ export default function suite() { twapStartDelaySeconds: null, teamSponsoredPassThresholdBps: null, teamAddress: null, + isOptimisticGovernanceEnabled: null, + baseToSupermajority: null, + isProposalValidationEnabled: null, }, }) .instruction(); @@ -164,106 +193,62 @@ export default function suite() { return { proposal, squadsProposal }; } - it("succeeds for team-sponsored proposal regardless of stake", async function () { - // Create DAO with non-zero stake threshold - const stakeThreshold = new BN(1000 * 10 ** 6); // 1000 tokens - const dao = await createDaoWithStakeThreshold( - this, - META, - USDC, - stakeThreshold, - this.payer, - ); - - // Add liquidity so launch can proceed - await this.futarchy - .provideLiquidityIx({ - dao, - baseMint: META, - quoteMint: USDC, - quoteAmount: new BN(100_000 * 10 ** 6), - maxBaseAmount: new BN(100_000 * 10 ** 6), - minLiquidity: new BN(0), - positionAuthority: this.payer.publicKey, - liquidityProvider: this.payer.publicKey, - }) - .preInstructions([ - ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), - ]) - .rpc(); - - const { proposal, squadsProposal } = await initializeProposal(this, dao); + // Enqueue an optimistic vault spend and initialize a futarchy proposal that + // challenges it (same squads proposal). Under the validation gate such a + // challenger auto-earns the team approval point; under the legacy gate it has + // no special standing and must clear base_to_stake (or be team-sponsored). + async function initOptimisticChallenge( + context: any, + dao: PublicKey, + ): Promise<{ proposal: PublicKey; squadsProposal: PublicKey }> { + let daoAccount = await context.futarchy.getDao(dao); + await context.createTokenAccount(USDC, daoAccount.squadsMultisigVault); - // Sponsor the proposal (makes is_team_sponsored = true) - await this.futarchy - .sponsorProposalIx({ - proposal, - dao, - teamAddress: this.payer.publicKey, - }) - .rpc(); + await setOptimisticGovernanceEnabled(context, dao, true); - // Launch proposal without staking anything - should succeed because it's team-sponsored - await this.futarchy - .launchProposalIx({ - proposal, + await context.futarchy + .initiateVaultSpendOptimisticProposalIx({ dao, - baseMint: META, + amount: new BN(0), + recipient: context.payer.publicKey, + transactionIndex: 1n, quoteMint: USDC, - squadsProposal, }) + .signers([context.payer, PERMISSIONLESS_ACCOUNT]) .rpc(); - // Verify proposal is now pending - const storedProposal = await this.futarchy.getProposal(proposal); - assert.exists( - storedProposal.state.pending, - "Proposal should be in pending state after launch", - ); - }); + daoAccount = await context.futarchy.getDao(dao); + assert.exists(daoAccount.optimisticProposal); - it("succeeds for non-team-sponsored with sufficient stake", async function () { - const stakeThreshold = new BN(100 * 10 ** 6); // 100 tokens - const dao = await createDaoWithStakeThreshold( - this, - META, - USDC, - stakeThreshold, - this.payer, - ); + const [squadsProposal] = multisig.getProposalPda({ + multisigPda: daoAccount.squadsMultisig, + transactionIndex: 1n, + }); - // Add liquidity - await this.futarchy - .provideLiquidityIx({ - dao, - baseMint: META, - quoteMint: USDC, - quoteAmount: new BN(100_000 * 10 ** 6), - maxBaseAmount: new BN(100_000 * 10 ** 6), - minLiquidity: new BN(0), - positionAuthority: this.payer.publicKey, - liquidityProvider: this.payer.publicKey, - }) - .preInstructions([ - ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), - ]) - .rpc(); + await context.futarchy.initializeProposal(dao, squadsProposal); + const [proposal] = getProposalAddrV2({ squadsProposal }); - const { proposal, squadsProposal } = await initializeProposal(this, dao); + return { proposal, squadsProposal }; + } - // Stake more than threshold - const stakeAmount = new BN(200 * 10 ** 6); // 200 tokens (> 100 threshold) - await this.futarchy - .stakeToProposalIx({ - proposal, - dao, - baseMint: META, - amount: stakeAmount, - }) + function stake( + context: any, + proposal: PublicKey, + dao: PublicKey, + amount: BN, + ): Promise { + return context.futarchy + .stakeToProposalIx({ proposal, dao, baseMint: META, amount }) .rpc(); + } - // Launch should succeed - await this.futarchy + function launch( + context: any, + proposal: PublicKey, + dao: PublicKey, + squadsProposal: PublicKey, + ): Promise { + return context.futarchy .launchProposalIx({ proposal, dao, @@ -272,389 +257,399 @@ export default function suite() { squadsProposal, }) .rpc(); + } - const storedProposal = await this.futarchy.getProposal(proposal); - assert.exists( - storedProposal.state.pending, - "Proposal should be in pending state after launch", - ); - }); + describe("proposal validation enabled", function () { + // ---- Approval points: launch needs >= 2 of 3 {stake, team, metadao} ---- + + const POINT_MATRIX: { + stakePoint: boolean; + team: boolean; + metadao: boolean; + launches: boolean; + }[] = [ + { stakePoint: false, team: false, metadao: false, launches: false }, + { stakePoint: true, team: false, metadao: false, launches: false }, + { stakePoint: false, team: true, metadao: false, launches: false }, + { stakePoint: false, team: false, metadao: true, launches: false }, + { stakePoint: true, team: true, metadao: false, launches: true }, + { stakePoint: true, team: false, metadao: true, launches: true }, + { stakePoint: false, team: true, metadao: true, launches: true }, + { stakePoint: true, team: true, metadao: true, launches: true }, + ]; + + POINT_MATRIX.forEach(({ stakePoint, team, metadao, launches }) => { + const label = `stake=${stakePoint ? "Y" : "N"} team=${ + team ? "Y" : "N" + } metadao=${metadao ? "Y" : "N"}`; + + it(`approval points (${label}) ${launches ? "launches" : "fails"}`, async function () { + const dao = await createDao(this, { baseToStake: BASE_TO_STAKE }); + await provideLiquidity(this, dao); + const { proposal, squadsProposal } = await initDraftProposal(this, dao); + + if (stakePoint) await stake(this, proposal, dao, BASE_TO_STAKE); + if (team) + await this.futarchy.sponsorProposalIx({ proposal, dao }).rpc(); + if (metadao) + await this.futarchy.approveProposalIx({ proposal, dao }).rpc(); + + if (launches) { + await launch(this, proposal, dao, squadsProposal); + const stored = await this.futarchy.getProposal(proposal); + assert.exists(stored.state.pending); + } else { + const callbacks = expectError( + "InsufficientApprovalToLaunch", + "launch should fail with fewer than 2 approval points", + ); + await launch(this, proposal, dao, squadsProposal).then( + callbacks[0], + callbacks[1], + ); + } + }); + }); - it("succeeds at exact stake threshold", async function () { - const stakeThreshold = new BN(100 * 10 ** 6); // 100 tokens - const dao = await createDaoWithStakeThreshold( - this, - META, - USDC, - stakeThreshold, - this.payer, - ); + // ---- Supermajority: stake alone reaches the per-DAO bar (0 disables) ---- + + it("supermajority: stake at the bar launches with no team/metadao point", async function () { + const T = new BN(2_000 * 10 ** 6); // reachable, and >= base_to_stake (invariant) + const dao = await createDao(this, { + baseToStake: BASE_TO_STAKE, + baseToSupermajority: T, + }); + await provideLiquidity(this, dao); + const { proposal, squadsProposal } = await initDraftProposal(this, dao); + + // Stake exactly the supermajority. With no team/metadao point the lone stake + // point (1 of 2) can't satisfy the 2-of-3 approval gate, so launching here + // proves the supermajority path fired. + await stake(this, proposal, dao, T); + + await launch(this, proposal, dao, squadsProposal); + const stored = await this.futarchy.getProposal(proposal); + assert.exists(stored.state.pending); + }); - // Add liquidity - 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(); + it("supermajority: stake one unit below the bar fails", async function () { + const T = new BN(2_000 * 10 ** 6); + const dao = await createDao(this, { + baseToStake: BASE_TO_STAKE, + baseToSupermajority: T, + }); + await provideLiquidity(this, dao); + const { proposal, squadsProposal } = await initDraftProposal(this, dao); + + await stake(this, proposal, dao, T.subn(1)); + + const callbacks = expectError( + "InsufficientApprovalToLaunch", + "stake one below the supermajority must not launch", + ); + await launch(this, proposal, dao, squadsProposal).then( + callbacks[0], + callbacks[1], + ); + }); - const { proposal, squadsProposal } = await initializeProposal(this, dao); + it("supermajority disabled (base_to_supermajority = 0): a huge stake can't launch, but enabling it can", async function () { + const HUGE = new BN(5_000_000 * 10 ** 6); // >> any base_to_stake floor + + // Disabled: with the supermajority off and no team/metadao point, the lone stake point + // (1 of 2) can't launch regardless of how large the stake is. This is the + // load-bearing intent test for the `> 0` guard. + const disabledDao = await createDao(this, { + baseToStake: BASE_TO_STAKE, + baseToSupermajority: new BN(0), + }); + await provideLiquidity(this, disabledDao); + const disabled = await initDraftProposal(this, disabledDao); + await stake(this, disabled.proposal, disabledDao, HUGE); + + const callbacks = expectError( + "InsufficientApprovalToLaunch", + "supermajority disabled: no amount of stake should bypass the 2-of-3 gate", + ); + await launch( + this, + disabled.proposal, + disabledDao, + disabled.squadsProposal, + ).then(callbacks[0], callbacks[1]); + + // Positive control: a sibling DAO identical except base_to_supermajority is + // enabled (= HUGE). The same stake now launches via the supermajority, so the failure + // above is attributable to the `> 0` guard, not an unrelated gate condition. + const enabledDao = await createDao(this, { + baseToStake: BASE_TO_STAKE, + baseToSupermajority: HUGE, + }); + await provideLiquidity(this, enabledDao); + const enabled = await initDraftProposal(this, enabledDao); + await stake(this, enabled.proposal, enabledDao, HUGE); + + await launch(this, enabled.proposal, enabledDao, enabled.squadsProposal); + const stored = await this.futarchy.getProposal(enabled.proposal); + assert.exists(stored.state.pending); + }); - // Stake exactly the threshold amount - await this.futarchy - .stakeToProposalIx({ - proposal, - dao, - baseMint: META, - amount: stakeThreshold, - }) - .rpc(); + // ---- Optimistic-challenge gate: challenging an active optimistic proposal + // auto-earns the team point, so the challenge costs the same as today. ---- - // Launch should succeed at exact threshold - await this.futarchy - .launchProposalIx({ - proposal, + it("optimistic challenge launches with stake >= base_to_stake (stake + auto team point)", async function () { + const dao = await createDao(this, { baseToStake: BASE_TO_STAKE }); + await provideLiquidity(this, dao); + const { proposal, squadsProposal } = await initOptimisticChallenge( + this, dao, - baseMint: META, - quoteMint: USDC, - squadsProposal, - }) - .rpc(); + ); - const storedProposal = await this.futarchy.getProposal(proposal); - assert.exists( - storedProposal.state.pending, - "Proposal should be in pending state after launch", - ); - }); + await stake(this, proposal, dao, BASE_TO_STAKE); - it("sets proposal duration_in_seconds to DAO's current seconds_per_proposal on launch", async function () { - const THREE_DAYS = 60 * 60 * 24 * 3; // 259200 - const FIVE_DAYS = 60 * 60 * 24 * 5; // 432000 + await launch(this, proposal, dao, squadsProposal); + const stored = await this.futarchy.getProposal(proposal); + assert.exists(stored.state.pending); - // Create DAO with secondsPerProposal = 3 days - const dao = await createDaoWithStakeThreshold( - this, - META, - USDC, - new BN(0), - this.payer, - ); + const daoAccount = await this.futarchy.getDao(dao); + assert.notExists(daoAccount.optimisticProposal); + }); - // Add liquidity - await this.futarchy - .provideLiquidityIx({ + it("optimistic challenge fails with stake below base_to_stake (only the auto team point)", async function () { + const dao = await createDao(this, { baseToStake: BASE_TO_STAKE }); + await provideLiquidity(this, dao); + const { proposal, squadsProposal } = await initOptimisticChallenge( + this, dao, - baseMint: META, - quoteMint: USDC, - quoteAmount: new BN(100_000 * 10 ** 6), - maxBaseAmount: new BN(100_000 * 10 ** 6), - minLiquidity: new BN(0), - positionAuthority: this.payer.publicKey, - liquidityProvider: this.payer.publicKey, - }) - .preInstructions([ - ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), - ]) - .rpc(); - - const { proposal, squadsProposal } = await initializeProposal(this, dao); + ); + + await stake(this, proposal, dao, BASE_TO_STAKE.divn(2)); // below the floor + + const callbacks = expectError( + "InsufficientApprovalToLaunch", + "a sub-floor optimistic challenge has only the auto team point", + ); + await launch(this, proposal, dao, squadsProposal).then( + callbacks[0], + callbacks[1], + ); + }); - // Sponsor the proposal - await this.futarchy - .sponsorProposalIx({ - proposal, + it("optimistic challenge launches below base_to_stake when MetaDAO-approved (metadao + auto team point)", async function () { + const dao = await createDao(this, { baseToStake: BASE_TO_STAKE }); + await provideLiquidity(this, dao); + const { proposal, squadsProposal } = await initOptimisticChallenge( + this, dao, - teamAddress: this.payer.publicKey, - }) - .rpc(); + ); - // Verify proposal has the original duration (3 days) - const proposalBefore = await this.futarchy.getProposal(proposal); - assert.equal(proposalBefore.durationInSeconds, THREE_DAYS); - - // Directly modify the DAO's secondsPerProposal to 5 days - const daoAccountInfo = await this.banksClient.getAccount(dao); - const coder = this.futarchy.futarchy.coder.accounts; - const daoData = coder.decode("dao", Buffer.from(daoAccountInfo.data)); - daoData.secondsPerProposal = FIVE_DAYS; - const encodedData = await coder.encode("dao", daoData); - // Preserve original account size (may be larger due to InitSpace allocation) - const newData = new Uint8Array(daoAccountInfo.data.length); - newData.set(encodedData, 0); - daoAccountInfo.data = newData; - this.context.setAccount(dao, daoAccountInfo); - - // Launch the proposal - await this.futarchy - .launchProposalIx({ - proposal, - dao, - baseMint: META, - quoteMint: USDC, - squadsProposal, - }) - .rpc(); + await stake(this, proposal, dao, BASE_TO_STAKE.divn(2)); // below the floor + await this.futarchy.approveProposalIx({ proposal, dao }).rpc(); - // Verify proposal picked up the new DAO duration - const storedProposal = await this.futarchy.getProposal(proposal); - assert.equal(storedProposal.durationInSeconds, FIVE_DAYS); - }); + await launch(this, proposal, dao, squadsProposal); + const stored = await this.futarchy.getProposal(proposal); + assert.exists(stored.state.pending); + }); - it("fails for non-team-sponsored with insufficient stake", async function () { - const stakeThreshold = new BN(100 * 10 ** 6); // 100 tokens - const dao = await createDaoWithStakeThreshold( - this, - META, - USDC, - stakeThreshold, - this.payer, - ); + // ---- Existing optimistic-launch behaviour, re-derived against the new gate. + // The 1M stake is >= base_to_stake, so stake + auto team = 2 points. ---- - // Add liquidity - await this.futarchy - .provideLiquidityIx({ + it("can challenge an optimistic proposal by launching a futarchy proposal on the same squads proposal", async function () { + const dao = await createDao(this, { baseToStake: BASE_TO_STAKE }); + await provideLiquidity(this, dao); + const { proposal, squadsProposal } = await initOptimisticChallenge( + this, dao, - baseMint: META, - quoteMint: USDC, - quoteAmount: new BN(100_000 * 10 ** 6), - maxBaseAmount: new BN(100_000 * 10 ** 6), - minLiquidity: new BN(0), - positionAuthority: this.payer.publicKey, - liquidityProvider: this.payer.publicKey, - }) - .preInstructions([ - ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), - ]) - .rpc(); + ); - const { proposal, squadsProposal } = await initializeProposal(this, dao); + await stake(this, proposal, dao, new BN(1_000_000 * 10 ** 6)); + await launch(this, proposal, dao, squadsProposal); - // Stake less than threshold - const insufficientStake = new BN(50 * 10 ** 6); // 50 tokens (< 100 threshold) - await this.futarchy - .stakeToProposalIx({ - proposal, - dao, - baseMint: META, - amount: insufficientStake, - }) - .rpc(); + const daoAccount = await this.futarchy.getDao(dao); + assert.notExists(daoAccount.optimisticProposal); - // Launch should fail with InsufficientStakeToLaunch - const callbacks = expectError( - "InsufficientStakeToLaunch", - "Launch should fail when stake is below threshold", - ); + const proposalAccount = await this.futarchy.getProposal(proposal); + assert.exists(proposalAccount.state.pending); + assert.equal( + proposalAccount.squadsProposal.toBase58(), + squadsProposal.toBase58(), + ); + }); - await this.futarchy - .launchProposalIx({ - proposal, + it("can't challenge an optimistic proposal once it has passed due to age", async function () { + const dao = await createDao(this, { baseToStake: BASE_TO_STAKE }); + await provideLiquidity(this, dao); + const { proposal, squadsProposal } = await initOptimisticChallenge( + this, dao, - baseMint: META, - quoteMint: USDC, - squadsProposal, - }) - .rpc() - .then(callbacks[0], callbacks[1]); - }); + ); - it("can challenge an optimistic proposal by launching a new futarchy proposal using the same squads proposal", async function () { - const stakeThreshold = new BN(100 * 10 ** 6); // 100 tokens - const dao = await createDaoWithStakeThreshold( - this, - META, - USDC, - stakeThreshold, - this.payer, - ); + const daoAccount = await this.futarchy.getDao(dao); + this.advanceBySeconds(daoAccount.secondsPerProposal); - // Add liquidity so proposal can be launched - await this.futarchy - .provideLiquidityIx({ - dao, - baseMint: META, - quoteMint: USDC, - quoteAmount: new BN(100_000 * 10 ** 6), - maxBaseAmount: new BN(100_000 * 10 ** 6), - minLiquidity: new BN(0), - positionAuthority: this.payer.publicKey, - liquidityProvider: this.payer.publicKey, - }) - .preInstructions([ - ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), - ]) - .rpc(); + await stake(this, proposal, dao, new BN(1_000_000 * 10 ** 6)); - // Mint DAO tokens to payer's account - await this.mintTo( - META, - this.payer.publicKey, - this.payer, - 10_000_000 * 10 ** 6, - ); + const callbacks = expectError( + "OptimisticProposalAlreadyPassed", + "optimistic proposal has already passed", + ); + await launch(this, proposal, dao, squadsProposal).then( + callbacks[0], + callbacks[1], + ); + }); - let daoAccount = await this.futarchy.getDao(dao); + // ---- Orthogonal: launch refreshes duration_in_seconds from the DAO. ---- - await this.createTokenAccount(USDC, daoAccount.squadsMultisigVault); + it("sets proposal duration_in_seconds to the DAO's current seconds_per_proposal on launch", async function () { + const THREE_DAYS = 60 * 60 * 24 * 3; + const FIVE_DAYS = 60 * 60 * 24 * 5; - await setOptimisticGovernanceEnabled(this, dao, true); + const dao = await createDao(this, { baseToStake: new BN(0) }); + await provideLiquidity(this, dao); + const { proposal, squadsProposal } = await initDraftProposal(this, dao); - await this.futarchy - .initiateVaultSpendOptimisticProposalIx({ - dao, - amount: new BN(transferAmount), - recipient: this.payer.publicKey, - transactionIndex: 1n, - quoteMint: USDC, - }) - .signers([this.payer, PERMISSIONLESS_ACCOUNT]) - .rpc(); + await this.futarchy.sponsorProposalIx({ proposal, dao }).rpc(); - daoAccount = await this.futarchy.getDao(dao); + const proposalBefore = await this.futarchy.getProposal(proposal); + assert.equal(proposalBefore.durationInSeconds, THREE_DAYS); - assert.exists(daoAccount.optimisticProposal); + // Bump the DAO's seconds_per_proposal directly (account surgery), then launch. + const daoAccountInfo = await this.banksClient.getAccount(dao); + const coder = this.futarchy.futarchy.coder.accounts; + const daoData = coder.decode("dao", Buffer.from(daoAccountInfo.data)); + daoData.secondsPerProposal = FIVE_DAYS; + const encodedData = await coder.encode("dao", daoData); + // Preserve original account size (may be larger due to InitSpace allocation) + const newData = new Uint8Array(daoAccountInfo.data.length); + newData.set(encodedData, 0); + daoAccountInfo.data = newData; + this.context.setAccount(dao, daoAccountInfo); - const [squadsProposal] = multisig.getProposalPda({ - multisigPda: daoAccount.squadsMultisig, - transactionIndex: 1n, - }); + await launch(this, proposal, dao, squadsProposal); - await this.futarchy.initializeProposal(dao, squadsProposal); + const stored = await this.futarchy.getProposal(proposal); + assert.equal(stored.durationInSeconds, FIVE_DAYS); + }); + }); - const [proposal] = getProposalAddrV2({ squadsProposal }); + describe("legacy gate (validation disabled)", function () { + // A DAO that has not opted into proposal validation uses the pre-MET-425 + // gate: launch on team sponsorship OR stake >= base_to_stake. The MetaDAO + // approval point and the supermajority bar are not consulted. - await this.futarchy - .stakeToProposalIx({ - amount: new BN(1_000_000 * 10 ** 6), - proposal, - dao, - baseMint: META, - }) - .rpc(); + it("succeeds for team-sponsored proposal regardless of stake", async function () { + const dao = await createDao(this, { + baseToStake: BASE_TO_STAKE, + isProposalValidationEnabled: false, + }); + await provideLiquidity(this, dao); + const { proposal, squadsProposal } = await initDraftProposal(this, dao); - await this.futarchy - .launchProposalIx({ - proposal, - dao, - baseMint: META, - quoteMint: USDC, - squadsProposal, - }) - .rpc(); + await this.futarchy.sponsorProposalIx({ proposal, dao }).rpc(); - // Assert that the optimistic proposal has been migrated to the futarchy proposal - daoAccount = await this.futarchy.getDao(dao); - assert.notExists(daoAccount.optimisticProposal); + await launch(this, proposal, dao, squadsProposal); + const stored = await this.futarchy.getProposal(proposal); + assert.exists(stored.state.pending); + }); - const proposalAccount = await this.futarchy.getProposal(proposal); - assert.exists(proposalAccount.state.pending); - assert.equal( - proposalAccount.squadsProposal.toBase58(), - squadsProposal.toBase58(), - ); - }); + it("succeeds for non-team-sponsored with sufficient stake", async function () { + const dao = await createDao(this, { + baseToStake: BASE_TO_STAKE, + isProposalValidationEnabled: false, + }); + await provideLiquidity(this, dao); + const { proposal, squadsProposal } = await initDraftProposal(this, dao); - it("can't challenge an optimistic proposal if it has already passed due to age", async function () { - const stakeThreshold = new BN(100 * 10 ** 6); // 100 tokens - const dao = await createDaoWithStakeThreshold( - this, - META, - USDC, - stakeThreshold, - this.payer, - ); + await stake(this, proposal, dao, BASE_TO_STAKE.muln(2)); - // Add liquidity so proposal can be launched - await this.futarchy - .provideLiquidityIx({ - dao, - baseMint: META, - quoteMint: USDC, - quoteAmount: new BN(100_000 * 10 ** 6), - maxBaseAmount: new BN(100_000 * 10 ** 6), - minLiquidity: new BN(0), - positionAuthority: this.payer.publicKey, - liquidityProvider: this.payer.publicKey, - }) - .preInstructions([ - ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), - ]) - .rpc(); + await launch(this, proposal, dao, squadsProposal); + const stored = await this.futarchy.getProposal(proposal); + assert.exists(stored.state.pending); + }); - // Mint DAO tokens to payer's account - await this.mintTo( - META, - this.payer.publicKey, - this.payer, - 10_000_000 * 10 ** 6, - ); + it("succeeds at exact stake threshold", async function () { + const dao = await createDao(this, { + baseToStake: BASE_TO_STAKE, + isProposalValidationEnabled: false, + }); + await provideLiquidity(this, dao); + const { proposal, squadsProposal } = await initDraftProposal(this, dao); - let daoAccount = await this.futarchy.getDao(dao); + await stake(this, proposal, dao, BASE_TO_STAKE); - await this.createTokenAccount(USDC, daoAccount.squadsMultisigVault); + await launch(this, proposal, dao, squadsProposal); + const stored = await this.futarchy.getProposal(proposal); + assert.exists(stored.state.pending); + }); - await setOptimisticGovernanceEnabled(this, dao, true); + it("fails for non-team-sponsored with insufficient stake", async function () { + const dao = await createDao(this, { + baseToStake: BASE_TO_STAKE, + isProposalValidationEnabled: false, + }); + await provideLiquidity(this, dao); + const { proposal, squadsProposal } = await initDraftProposal(this, dao); + + await stake(this, proposal, dao, BASE_TO_STAKE.subn(1)); + + const callbacks = expectError( + "InsufficientStakeToLaunch", + "a sub-floor non-team proposal must not launch under the legacy gate", + ); + await launch(this, proposal, dao, squadsProposal).then( + callbacks[0], + callbacks[1], + ); + }); - await this.futarchy - .initiateVaultSpendOptimisticProposalIx({ + it("can challenge an optimistic proposal by clearing base_to_stake (no auto team point)", async function () { + const dao = await createDao(this, { + baseToStake: BASE_TO_STAKE, + isProposalValidationEnabled: false, + }); + await provideLiquidity(this, dao); + const { proposal, squadsProposal } = await initOptimisticChallenge( + this, dao, - amount: new BN(transferAmount), - recipient: this.payer.publicKey, - transactionIndex: 1n, - quoteMint: USDC, - }) - .signers([this.payer, PERMISSIONLESS_ACCOUNT]) - .rpc(); + ); - daoAccount = await this.futarchy.getDao(dao); + // The legacy gate grants no auto team point for optimistic challenges, so the + // challenger must clear base_to_stake on its own. + await stake(this, proposal, dao, new BN(1_000_000 * 10 ** 6)); + await launch(this, proposal, dao, squadsProposal); - assert.exists(daoAccount.optimisticProposal); + const daoAccount = await this.futarchy.getDao(dao); + assert.notExists(daoAccount.optimisticProposal); - const [squadsProposal] = multisig.getProposalPda({ - multisigPda: daoAccount.squadsMultisig, - transactionIndex: 1n, + const proposalAccount = await this.futarchy.getProposal(proposal); + assert.exists(proposalAccount.state.pending); }); - // Initialize the futarchy proposal before the optimistic proposal is auto-approved - await this.futarchy.initializeProposal(dao, squadsProposal); - - this.advanceBySeconds(daoAccount.secondsPerProposal); - - const [proposal] = getProposalAddrV2({ squadsProposal }); - - await this.futarchy - .stakeToProposalIx({ - amount: new BN(1_000_000 * 10 ** 6), - proposal, + it("can't challenge an optimistic proposal once it has passed due to age", async function () { + const dao = await createDao(this, { + baseToStake: BASE_TO_STAKE, + isProposalValidationEnabled: false, + }); + await provideLiquidity(this, dao); + const { proposal, squadsProposal } = await initOptimisticChallenge( + this, dao, - baseMint: META, - }) - .rpc(); + ); - const callbacks = expectError( - "OptimisticProposalAlreadyPassed", - "Optimistic proposal has already passed", - ); + const daoAccount = await this.futarchy.getDao(dao); + this.advanceBySeconds(daoAccount.secondsPerProposal); - await this.futarchy - .launchProposalIx({ - proposal, - dao, - baseMint: META, - quoteMint: USDC, - squadsProposal, - }) - .rpc() - .then(callbacks[0], callbacks[1]); + await stake(this, proposal, dao, new BN(1_000_000 * 10 ** 6)); + + const callbacks = expectError( + "OptimisticProposalAlreadyPassed", + "optimistic proposal has already passed", + ); + await launch(this, proposal, dao, squadsProposal).then( + callbacks[0], + callbacks[1], + ); + }); }); } diff --git a/tests/futarchy/unit/resizeDao.test.ts b/tests/futarchy/unit/resizeDao.test.ts new file mode 100644 index 00000000..58faa1d9 --- /dev/null +++ b/tests/futarchy/unit/resizeDao.test.ts @@ -0,0 +1,221 @@ +import { + ComputeBudgetProgram, + Keypair, + PublicKey, + SystemProgram, + Transaction, +} from "@solana/web3.js"; +import BN from "bn.js"; +import { setupBasicDao } from "../../utils.js"; +import { TestContext } from "../../main.test.js"; +import { assert } from "chai"; + +type OldLayoutOverrides = { + baseToStake?: BN; + optimisticProposal?: { + squadsProposal: PublicKey; + enqueuedTimestamp: BN; + } | null; + isOptimisticGovernanceEnabled?: boolean; +}; + +// Rewrites a real (new-layout) Dao account to the pre-migration on-chain layout +// by re-encoding its body as the `oldDao` IDL type (dropping the appended +// `base_to_supermajority` and `is_proposal_validation_enabled`). Truncation does +// NOT work for Dao: its Option slack would leave the fields' bytes in place. +// Optional field overrides let a test pin base_to_stake / optimistic state +// without driving the real instructions. +async function makeOldLayout( + ctx: TestContext, + dao: PublicKey, + overrides: OldLayoutOverrides = {}, + opts: { lamports?: number } = {}, +): Promise<{ AFTER: number; BEFORE: number }> { + const raw = await ctx.banksClient.getAccount(dao); + const AFTER = raw.data.length; + // 9 bytes: base_to_supermajority (u64) + is_proposal_validation_enabled (bool) + const BEFORE = AFTER - 9; + + const disc = Buffer.from(raw.data.slice(0, 8)); + const coder = ctx.futarchy.futarchy.account.dao.coder.accounts; + const decoded = coder.decode("dao", Buffer.from(raw.data)); + + if (overrides.baseToStake !== undefined) + decoded.baseToStake = overrides.baseToStake; + if (overrides.optimisticProposal !== undefined) + decoded.optimisticProposal = overrides.optimisticProposal; + if (overrides.isOptimisticGovernanceEnabled !== undefined) + decoded.isOptimisticGovernanceEnabled = + overrides.isOptimisticGovernanceEnabled; + + // Encode as oldDao (mainnet layout, no supermajority / validation flag); drop + // its discriminator and reattach the real Dao discriminator at the + // pre-migration size. + const body = await coder.encode("oldDao", decoded); + const buf = Buffer.alloc(BEFORE); + disc.copy(buf, 0); + body.subarray(8).copy(buf, 8); + + ctx.context.setAccount(dao, { + ...raw, + data: buf, + ...(opts.lamports !== undefined ? { lamports: opts.lamports } : {}), + }); + + return { AFTER, BEFORE }; +} + +export default function suite() { + let META: PublicKey, USDC: PublicKey, dao: PublicKey; + + beforeEach(async function () { + META = await this.createMint(this.payer.publicKey, 6); + USDC = await this.createMint(this.payer.publicKey, 6); + + dao = await setupBasicDao({ + context: this, + baseMint: META, + quoteMint: USDC, + }); + }); + + it("migrates an old DAO with both new fields defaulted off, preserving every other field", async function () { + const original = await this.futarchy.getDao(dao); + // Opt-in defaults: the migration must leave existing DAOs on the legacy gate. + assert.equal(original.baseToSupermajority.toNumber(), 0); + assert.isFalse(original.isProposalValidationEnabled); + + const { AFTER, BEFORE } = await makeOldLayout(this, dao); + + // A short Dao is NOT frozen (unlike a Proposal): it decodes with both new + // fields read from the Option zero-slack. This is the empirical confirmation + // of the non-freeze conclusion. + const short = await this.banksClient.getAccount(dao); + assert.equal(short.data.length, BEFORE); + const preResize = await this.futarchy.getDao(dao); + assert.equal(preResize.baseToSupermajority.toNumber(), 0); + assert.isFalse(preResize.isProposalValidationEnabled); + + await this.futarchy.futarchy.methods + .resizeDao() + .accounts({ dao, payer: this.payer.publicKey }) + .rpc(); + + const resized = await this.banksClient.getAccount(dao); + assert.equal(resized.data.length, AFTER); + + const migrated = await this.futarchy.getDao(dao); + assert.equal(migrated.baseToSupermajority.toNumber(), 0); + assert.isFalse(migrated.isProposalValidationEnabled); + + // The migration adds the two fields at their off defaults, which already + // match the freshly-initialized DAO — so the whole account round-trips equal. + assert.deepEqual( + JSON.parse(JSON.stringify(migrated)), + JSON.parse(JSON.stringify(original)), + ); + + // Idempotent: a second crank is a no-op (compute-budget bump for a unique sig). + await this.futarchy.futarchy.methods + .resizeDao() + .accounts({ dao, payer: this.payer.publicKey }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 200_000 }), + ]) + .rpc(); + + const after2 = await this.banksClient.getAccount(dao); + assert.equal(after2.data.length, AFTER); + }); + + it("preserves optimistic governance fields through the migration", async function () { + const fakeSquadsProposal = Keypair.generate().publicKey; + await makeOldLayout(this, dao, { + isOptimisticGovernanceEnabled: true, + optimisticProposal: { + squadsProposal: fakeSquadsProposal, + enqueuedTimestamp: new BN(1_700_000_000), + }, + }); + + await this.futarchy.futarchy.methods + .resizeDao() + .accounts({ dao, payer: this.payer.publicKey }) + .rpc(); + + const migrated = await this.futarchy.getDao(dao); + // Must carry over from `old`, NOT reset to None/false. + assert.isTrue(migrated.isOptimisticGovernanceEnabled); + assert.exists(migrated.optimisticProposal); + assert.isTrue( + migrated.optimisticProposal.squadsProposal.equals(fakeSquadsProposal), + ); + assert.equal( + migrated.optimisticProposal.enqueuedTimestamp.toString(), + "1700000000", + ); + }); + + it("is a no-op on an already-new-layout DAO", async function () { + const before = await this.futarchy.getDao(dao); + const beforeRaw = await this.banksClient.getAccount(dao); + + await this.futarchy.futarchy.methods + .resizeDao() + .accounts({ dao, payer: this.payer.publicKey }) + .rpc(); + + const afterRaw = await this.banksClient.getAccount(dao); + assert.equal(afterRaw.data.length, beforeRaw.data.length); + + const after = await this.futarchy.getDao(dao); + assert.deepEqual( + JSON.parse(JSON.stringify(after)), + JSON.parse(JSON.stringify(before)), + ); + }); + + it("tops up rent from the payer when the migrated account is under-funded", async function () { + const rent = await this.banksClient.getRent(); + const raw0 = await this.banksClient.getAccount(dao); + const AFTER = raw0.data.length; + const BEFORE = AFTER - 9; + const rentBefore = rent.minimumBalance(BigInt(BEFORE)); + const rentAfter = rent.minimumBalance(BigInt(AFTER)); + const delta = rentAfter - rentBefore; + + // Shrink to old layout AND drop lamports to the old rent-exempt minimum so + // the realloc forces a top-up transfer. + await makeOldLayout(this, dao, {}, { lamports: Number(rentBefore) }); + + // Dedicated crank payer (not the fee payer) so its balance change isolates + // the top-up transfer from transaction fees. + const crankPayer = Keypair.generate(); + const fundTx = new Transaction().add( + SystemProgram.transfer({ + fromPubkey: this.payer.publicKey, + toPubkey: crankPayer.publicKey, + lamports: 1_000_000_000, + }), + ); + fundTx.recentBlockhash = (await this.banksClient.getLatestBlockhash())[0]; + fundTx.feePayer = this.payer.publicKey; + fundTx.sign(this.payer); + await this.banksClient.processTransaction(fundTx); + + const payerBefore = await this.banksClient.getBalance(crankPayer.publicKey); + + await this.futarchy.futarchy.methods + .resizeDao() + .accounts({ dao, payer: crankPayer.publicKey }) + .signers([crankPayer]) + .rpc(); + + const payerAfter = await this.banksClient.getBalance(crankPayer.publicKey); + const daoLamports = await this.banksClient.getBalance(dao); + + // Account brought exactly to the new rent-exempt minimum, funded by the payer. + assert.equal(daoLamports.toString(), rentAfter.toString()); + assert.equal((payerBefore - payerAfter).toString(), delta.toString()); + }); +} diff --git a/tests/futarchy/unit/resizeProposal.test.ts b/tests/futarchy/unit/resizeProposal.test.ts new file mode 100644 index 00000000..f16749a2 --- /dev/null +++ b/tests/futarchy/unit/resizeProposal.test.ts @@ -0,0 +1,305 @@ +import { PERMISSIONLESS_ACCOUNT } from "@metadaoproject/programs"; +import { + ComputeBudgetProgram, + Keypair, + PublicKey, + SystemProgram, + Transaction, + TransactionMessage, +} from "@solana/web3.js"; +import BN from "bn.js"; +import { setupBasicDao, expectError } from "../../utils.js"; +import { TestContext } from "../../main.test.js"; +import { assert } from "chai"; +import * as multisig from "@sqds/multisig"; + +// Rewrites a real Proposal account to the pre-migration (pre-`is_metadao_approved`) +// on-chain layout by dropping its final byte. Proposal is fixed-size, so the last +// byte is exactly `is_metadao_approved` — truncation yields a faithful old account. +async function truncateToOldLayout( + ctx: TestContext, + proposal: PublicKey, + opts: { lamports?: number } = {}, +) { + const raw = await ctx.banksClient.getAccount(proposal); + ctx.context.setAccount(proposal, { + ...raw, + data: raw.data.slice(0, raw.data.length - 1), + ...(opts.lamports !== undefined ? { lamports: opts.lamports } : {}), + }); +} + +export default function suite() { + let META: PublicKey, + USDC: PublicKey, + dao: PublicKey, + proposal: PublicKey, + squadsProposalPda: PublicKey; + + // The new Proposal layout is exactly 1 byte longer than the old one (the + // appended `is_metadao_approved` bool). AFTER_SIZE is the migrated length; + // BEFORE_SIZE the pre-migration length. + let AFTER_SIZE: number, BEFORE_SIZE: number; + + beforeEach(async function () { + META = await this.createMint(this.payer.publicKey, 6); + USDC = await this.createMint(this.payer.publicKey, 6); + + await this.createTokenAccount(META, this.payer.publicKey); + await this.createTokenAccount(USDC, this.payer.publicKey); + + await this.mintTo(META, this.payer.publicKey, this.payer, 100 * 10 ** 9); + await this.mintTo( + USDC, + this.payer.publicKey, + this.payer, + 200_000 * 1_000_000, + ); + + // Validation enabled so the post-crank approve_proposal (which exercises the + // un-frozen proposal) is accepted. + dao = await setupBasicDao({ + context: this, + baseMint: META, + quoteMint: USDC, + isProposalValidationEnabled: true, + }); + + await this.futarchy + .provideLiquidityIx({ + dao, + baseMint: META, + quoteMint: USDC, + quoteAmount: new BN(100_000 * 10 ** 6), + maxBaseAmount: new BN(100 * 10 ** 6), + minLiquidity: new BN(0), + positionAuthority: this.payer.publicKey, + liquidityProvider: this.payer.publicKey, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), + ]) + .rpc(); + + // Wrap an arbitrary instruction in a squads vault transaction so we have a + // valid active squads proposal to initialize the futarchy proposal against. + 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, + baseToSupermajority: null, + isProposalValidationEnabled: null, + }, + }) + .instruction(); + + const updateDaoMessage = new TransactionMessage({ + payerKey: this.payer.publicKey, + recentBlockhash: (await this.banksClient.getLatestBlockhash())[0], + instructions: [updateDaoIx], + }); + + const multisigPda = multisig.getMultisigPda({ createKey: dao })[0]; + const vaultTxCreate = multisig.instructions.vaultTransactionCreate({ + multisigPda, + transactionIndex: 1n, + creator: PERMISSIONLESS_ACCOUNT.publicKey, + rentPayer: this.payer.publicKey, + vaultIndex: 0, + ephemeralSigners: 0, + transactionMessage: updateDaoMessage, + }); + + const proposalCreateIx = multisig.instructions.proposalCreate({ + multisigPda, + transactionIndex: 1n, + creator: PERMISSIONLESS_ACCOUNT.publicKey, + rentPayer: this.payer.publicKey, + }); + + [squadsProposalPda] = multisig.getProposalPda({ + multisigPda, + transactionIndex: 1n, + }); + + const tx = new Transaction().add(vaultTxCreate, proposalCreateIx); + tx.recentBlockhash = (await this.banksClient.getLatestBlockhash())[0]; + tx.feePayer = this.payer.publicKey; + tx.sign(this.payer, PERMISSIONLESS_ACCOUNT); + + await this.banksClient.processTransaction(tx); + + proposal = await this.futarchy.initializeProposal(dao, squadsProposalPda); + + const raw = await this.banksClient.getAccount(proposal); + AFTER_SIZE = raw.data.length; + BEFORE_SIZE = AFTER_SIZE - 1; + }); + + it("freezes an old-layout proposal on-chain, then the crank migrates and un-freezes it", async function () { + const original = await this.futarchy.getProposal(proposal); + + await truncateToOldLayout(this, proposal); + + // The fixture is genuinely one byte short of the new layout. + const short = await this.banksClient.getAccount(proposal); + assert.equal(short.data.length, BEFORE_SIZE); + + // Frozen on-chain: any instruction taking Account<'info, Proposal> can no + // longer load the short account, so the proposal is inert until cranked. + // approve_proposal is representative — it fails to deserialize before validate. + const frozenCallbacks = expectError( + "AccountDidNotDeserialize", + "a frozen proposal must not deserialize on-chain", + ); + await this.futarchy + .approveProposalIx({ proposal, dao, approver: this.payer.publicKey }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 200_000 }), + ]) + .rpc() + .then(frozenCallbacks[0], frozenCallbacks[1]); + + // Crank to the new layout via the raw program object (no SDK wrapper). + await this.futarchy.futarchy.methods + .resizeProposal() + .accounts({ proposal, payer: this.payer.publicKey }) + .rpc(); + + // Grew by exactly the one appended byte. + const resized = await this.banksClient.getAccount(proposal); + assert.equal(resized.data.length, AFTER_SIZE); + + const migrated = await this.futarchy.getProposal(proposal); + assert.isFalse(migrated.isMetadaoApproved); + + // Every carried-over field is identical to the pre-truncation account. + assert.deepEqual( + JSON.parse(JSON.stringify(migrated)), + JSON.parse(JSON.stringify(original)), + ); + + // Un-frozen: the same instruction that failed pre-crank now succeeds, + // proving the crank makes the proposal interactable again. (Incrementing the + // compute-unit limit keeps this transaction's signature distinct from the + // pre-crank attempt above.) + await this.futarchy + .approveProposalIx({ proposal, dao, approver: this.payer.publicKey }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 200_001 }), + ]) + .rpc(); + + const approved = await this.futarchy.getProposal(proposal); + assert.isTrue(approved.isMetadaoApproved); + }); + + it("is a no-op on an already-new-layout proposal", async function () { + const before = await this.futarchy.getProposal(proposal); + + await this.futarchy.futarchy.methods + .resizeProposal() + .accounts({ proposal, payer: this.payer.publicKey }) + .rpc(); + + const raw = await this.banksClient.getAccount(proposal); + assert.equal(raw.data.length, AFTER_SIZE); + + const after = await this.futarchy.getProposal(proposal); + assert.deepEqual( + JSON.parse(JSON.stringify(after)), + JSON.parse(JSON.stringify(before)), + ); + }); + + it("is idempotent across repeated resizes", async function () { + await truncateToOldLayout(this, proposal); + + // First crank migrates the account... + await this.futarchy.futarchy.methods + .resizeProposal() + .accounts({ proposal, payer: this.payer.publicKey }) + .rpc(); + + // ...the second is a no-op (compute-budget bump for a unique signature). + await this.futarchy.futarchy.methods + .resizeProposal() + .accounts({ proposal, payer: this.payer.publicKey }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 200_000 }), + ]) + .rpc(); + + const resized = await this.banksClient.getAccount(proposal); + assert.equal(resized.data.length, AFTER_SIZE); + const migrated = await this.futarchy.getProposal(proposal); + assert.isFalse(migrated.isMetadaoApproved); + }); + + it("tops up rent from the payer when the account is under-funded", async function () { + const rent = await this.banksClient.getRent(); + const rentBefore = rent.minimumBalance(BigInt(BEFORE_SIZE)); + const rentAfter = rent.minimumBalance(BigInt(AFTER_SIZE)); + const delta = rentAfter - rentBefore; + + // Truncate AND drop lamports to the old rent-exempt minimum so the realloc + // forces a top-up transfer. + await truncateToOldLayout(this, proposal, { lamports: Number(rentBefore) }); + + // Fund a dedicated crank payer (not the fee payer) so its balance change + // isolates the top-up transfer from transaction fees. + const crankPayer = Keypair.generate(); + const fundTx = new Transaction().add( + SystemProgram.transfer({ + fromPubkey: this.payer.publicKey, + toPubkey: crankPayer.publicKey, + lamports: 1_000_000_000, + }), + ); + fundTx.recentBlockhash = (await this.banksClient.getLatestBlockhash())[0]; + fundTx.feePayer = this.payer.publicKey; + fundTx.sign(this.payer); + await this.banksClient.processTransaction(fundTx); + + const payerBefore = await this.banksClient.getBalance(crankPayer.publicKey); + + await this.futarchy.futarchy.methods + .resizeProposal() + .accounts({ proposal, payer: crankPayer.publicKey }) + .signers([crankPayer]) + .rpc(); + + const payerAfter = await this.banksClient.getBalance(crankPayer.publicKey); + const proposalLamports = await this.banksClient.getBalance(proposal); + + // Account brought exactly to the new rent-exempt minimum, funded by the payer. + assert.equal(proposalLamports.toString(), rentAfter.toString()); + assert.equal((payerBefore - payerAfter).toString(), delta.toString()); + }); + + it("fails when given an account that is not a Proposal", async function () { + // The dao is owned by futarchy but carries the Dao discriminator, so the + // crank's discriminator guard rejects it. + let threw = false; + try { + await this.futarchy.futarchy.methods + .resizeProposal() + .accounts({ proposal: dao, payer: this.payer.publicKey }) + .rpc(); + } catch { + threw = true; + } + assert.isTrue(threw); + }); +} diff --git a/tests/futarchy/unit/unstakeFromProposal.test.ts b/tests/futarchy/unit/unstakeFromProposal.test.ts index 96a31a72..aae8f24d 100644 --- a/tests/futarchy/unit/unstakeFromProposal.test.ts +++ b/tests/futarchy/unit/unstakeFromProposal.test.ts @@ -77,6 +77,8 @@ export default function suite() { teamSponsoredPassThresholdBps: null, teamAddress: null, isOptimisticGovernanceEnabled: null, + baseToSupermajority: null, + isProposalValidationEnabled: null, }, }) .instruction(); diff --git a/tests/futarchy/unit/updateDao.test.ts b/tests/futarchy/unit/updateDao.test.ts index 50b8fc00..54b6d62f 100644 --- a/tests/futarchy/unit/updateDao.test.ts +++ b/tests/futarchy/unit/updateDao.test.ts @@ -17,10 +17,140 @@ import { sha256, } from "@metadaoproject/programs"; import BN from "bn.js"; -import { setOptimisticGovernanceEnabled } from "../../utils.js"; +import { setOptimisticGovernanceEnabled, nextDaoNonce } from "../../utils.js"; +import { TestContext } from "../../main.test.js"; const THOUSAND_BUCK_PRICE = PriceMath.getAmmPrice(1000, 9, 6); +// Builds an UpdateDaoParams object with every field defaulting to null +// (no-op), overriding only the fields under test. +function withNulls(overrides: { + baseToStake?: BN | null; + baseToSupermajority?: BN | null; + isProposalValidationEnabled?: boolean | null; +}) { + return { + passThresholdBps: null, + secondsPerProposal: null, + twapInitialObservation: null, + twapMaxObservationChangePerUpdate: null, + twapStartDelaySeconds: null, + minQuoteFutarchicLiquidity: null, + minBaseFutarchicLiquidity: null, + baseToStake: null, + teamSponsoredPassThresholdBps: null, + teamAddress: null, + isOptimisticGovernanceEnabled: null, + baseToSupermajority: null, + isProposalValidationEnabled: null, + ...overrides, + }; +} + +// update_dao must be signed by the DAO's Squads vault, whose only Vote member is +// the DAO PDA. So a standalone update_dao is driven by: create the vault tx + +// proposal, approve it through the futarchy admin path (enqueue -> execute the +// approval, which votes via the DAO PDA), then execute the vault tx as a +// SEPARATE top-level transaction. The execute is kept top-level (not via +// admin_execute_multisig_proposal) because update_dao CPIs back into futarchy, +// and futarchy -> squads -> futarchy would trip the Solana reentrancy guard. +async function executeUpdateDao( + ctx: TestContext, + dao: PublicKey, + overrides: { + baseToStake?: BN | null; + baseToSupermajority?: BN | null; + isProposalValidationEnabled?: boolean | null; + }, + transactionIndex: bigint, +) { + const daoAccount = await ctx.futarchy.getDao(dao); + + const updateIx = await ctx.futarchy + .updateDaoIx({ dao, params: withNulls(overrides) }) + .instruction(); + + const message = new TransactionMessage({ + payerKey: daoAccount.squadsMultisigVault, + recentBlockhash: (await ctx.banksClient.getLatestBlockhash())[0], + instructions: [updateIx], + }); + + const vaultTxCreateIx = multisig.instructions.vaultTransactionCreate({ + multisigPda: daoAccount.squadsMultisig, + transactionIndex, + creator: PERMISSIONLESS_ACCOUNT.publicKey, + rentPayer: ctx.payer.publicKey, + vaultIndex: 0, + ephemeralSigners: 0, + transactionMessage: message, + }); + const proposalCreateIx = multisig.instructions.proposalCreate({ + multisigPda: daoAccount.squadsMultisig, + transactionIndex, + creator: PERMISSIONLESS_ACCOUNT.publicKey, + rentPayer: ctx.payer.publicKey, + }); + + const createTx = new Transaction().add(vaultTxCreateIx, proposalCreateIx); + createTx.recentBlockhash = (await ctx.banksClient.getLatestBlockhash())[0]; + createTx.feePayer = ctx.payer.publicKey; + createTx.sign(ctx.payer, PERMISSIONLESS_ACCOUNT); + await ctx.banksClient.processTransaction(createTx); + + const [squadsProposalPda] = multisig.getProposalPda({ + multisigPda: daoAccount.squadsMultisig, + transactionIndex, + }); + const [enqueuedApprovalPda] = PublicKey.findProgramAddressSync( + [ + Buffer.from("enqueued_approval"), + dao.toBuffer(), + new BN(transactionIndex.toString()).toArrayLike(Buffer, "le", 8), + ], + ctx.futarchy.futarchy.programId, + ); + + await ctx.futarchy.futarchy.methods + .adminEnqueueMultisigProposalApproval({ + transactionIndex: new BN(transactionIndex.toString()), + }) + .accounts({ + dao, + admin: ctx.payer.publicKey, + squadsMultisig: daoAccount.squadsMultisig, + squadsMultisigProposal: squadsProposalPda, + enqueuedApproval: enqueuedApprovalPda, + }) + .signers([ctx.payer]) + .rpc(); + + await ctx.futarchy.futarchy.methods + .executeMultisigProposalApproval() + .accounts({ + dao, + rentReceiver: ctx.payer.publicKey, + squadsMultisig: daoAccount.squadsMultisig, + squadsMultisigProposal: squadsProposalPda, + enqueuedApproval: enqueuedApprovalPda, + squadsMultisigProgram: multisig.PROGRAM_ID, + }) + .signers([ctx.payer]) + .rpc(); + + const execIx = await multisig.instructions.vaultTransactionExecute({ + connection: ctx.squadsConnection, + multisigPda: daoAccount.squadsMultisig, + transactionIndex, + member: PERMISSIONLESS_ACCOUNT.publicKey, + }); + const execTx = new Transaction().add(execIx.instruction); + execTx.recentBlockhash = (await ctx.banksClient.getLatestBlockhash())[0]; + execTx.feePayer = ctx.payer.publicKey; + execTx.sign(ctx.payer, PERMISSIONLESS_ACCOUNT); + await ctx.banksClient.processTransaction(execTx); +} + export default function suite() { let META: PublicKey, USDC: PublicKey, dao: PublicKey; @@ -31,7 +161,7 @@ export default function suite() { await this.createTokenAccount(META, this.payer.publicKey); await this.mintTo(META, this.payer.publicKey, this.payer, 10_000 * 10 ** 9); - const nonce = new BN(Math.floor(Math.random() * 1000000)); + const nonce = nextDaoNonce(); await this.futarchy .initializeDaoIx({ @@ -51,6 +181,8 @@ export default function suite() { members: [this.payer.publicKey], }, baseToStake: new BN(0), + baseToSupermajority: new BN(0), + isProposalValidationEnabled: false, teamSponsoredPassThresholdBps: 0, teamAddress: this.payer.publicKey, }, @@ -106,6 +238,8 @@ export default function suite() { teamAddress: null, twapStartDelaySeconds: null, isOptimisticGovernanceEnabled: null, + baseToSupermajority: null, + isProposalValidationEnabled: null, }, }) .instruction(); @@ -407,6 +541,8 @@ export default function suite() { teamAddress: null, twapStartDelaySeconds: null, isOptimisticGovernanceEnabled: null, + baseToSupermajority: null, + isProposalValidationEnabled: null, }, }) .instruction(); @@ -591,4 +727,116 @@ export default function suite() { ); } }); + + it("sets base_to_supermajority, enabling the supermajority path", async function () { + const before = await this.futarchy.getDao(dao); + assert.equal(before.baseToSupermajority.toNumber(), 0); + + await executeUpdateDao( + this, + dao, + { baseToSupermajority: new BN(1_000_000) }, + 1n, + ); + + const after = await this.futarchy.getDao(dao); + assert.equal(after.baseToSupermajority.toString(), "1000000"); + // seq_num is bumped immediately before emit_cpi!(UpdateDaoEvent), so the + // increment is the observable proof that the event-emitting path ran + // (bankrun exposes neither inner instructions nor emit_cpi! payloads). + assert.equal(after.seqNum.toNumber(), before.seqNum.toNumber() + 1); + }); + + it("rejects base_to_supermajority below base_to_stake", async function () { + // base_to_stake and base_to_supermajority are set in a single update; the + // invariant is checked after set_inner applies all fields, so 999 < 1000 fails. + try { + await executeUpdateDao( + this, + dao, + { baseToStake: new BN(1000), baseToSupermajority: new BN(999) }, + 1n, + ); + assert.fail("update_dao should have been rejected"); + } catch (e) { + assert( + e.toString().includes("InvalidSupermajorityThreshold") || + e.toString().includes("0x179d"), + `Expected InvalidSupermajorityThreshold error, got: ${e}`, + ); + } + + // The rejected update left the DAO untouched. + const after = await this.futarchy.getDao(dao); + assert.equal(after.baseToStake.toNumber(), 0); + assert.equal(after.baseToSupermajority.toNumber(), 0); + }); + + it("accepts base_to_supermajority equal to, above, or disabling the supermajority", async function () { + // == base_to_stake (equality is benign) + await executeUpdateDao( + this, + dao, + { baseToStake: new BN(1000), baseToSupermajority: new BN(1000) }, + 1n, + ); + let after = await this.futarchy.getDao(dao); + assert.equal(after.baseToStake.toNumber(), 1000); + assert.equal(after.baseToSupermajority.toNumber(), 1000); + + // > base_to_stake + await executeUpdateDao( + this, + dao, + { baseToSupermajority: new BN(2000) }, + 2n, + ); + after = await this.futarchy.getDao(dao); + assert.equal(after.baseToSupermajority.toNumber(), 2000); + + // 0 disables the supermajority path regardless of the (positive) base_to_stake floor. + await executeUpdateDao(this, dao, { baseToSupermajority: new BN(0) }, 3n); + after = await this.futarchy.getDao(dao); + assert.equal(after.baseToSupermajority.toNumber(), 0); + assert.equal(after.baseToStake.toNumber(), 1000); + }); + + it("accepts raising base_to_stake and base_to_supermajority together", async function () { + await executeUpdateDao( + this, + dao, + { + baseToStake: new BN(2_000_000), + baseToSupermajority: new BN(2_500_000), + }, + 1n, + ); + + const after = await this.futarchy.getDao(dao); + assert.equal(after.baseToStake.toString(), "2000000"); + assert.equal(after.baseToSupermajority.toString(), "2500000"); + }); + + it("toggles is_proposal_validation_enabled on and off", async function () { + const before = await this.futarchy.getDao(dao); + assert.isFalse(before.isProposalValidationEnabled); + + await executeUpdateDao( + this, + dao, + { isProposalValidationEnabled: true }, + 1n, + ); + const enabled = await this.futarchy.getDao(dao); + assert.isTrue(enabled.isProposalValidationEnabled); + + await executeUpdateDao( + this, + dao, + { isProposalValidationEnabled: false }, + 2n, + ); + const disabled = await this.futarchy.getDao(dao); + assert.isFalse(disabled.isProposalValidationEnabled); + }); } diff --git a/tests/gatedMint/utils.ts b/tests/gatedMint/utils.ts index 16cf81bd..9f24f51f 100644 --- a/tests/gatedMint/utils.ts +++ b/tests/gatedMint/utils.ts @@ -3,6 +3,7 @@ import { Keypair, Transaction, SystemProgram, + ComputeBudgetProgram, } from "@solana/web3.js"; import * as token from "@solana/spl-token"; import { BanksClient, ProgramTestContext } from "solana-bankrun"; @@ -12,6 +13,10 @@ import { getWhitelistedUserAddr, } from "@metadaoproject/programs"; +// Ever-incrementing compute-unit price so each whitelistUser call gets a unique +// transaction signature (avoids bankrun "transaction already processed"). +let whitelistTxNonce = 1; + export async function createMintWithFreezeAuthority( banksClient: BanksClient, payer: Keypair, @@ -109,6 +114,11 @@ export async function whitelistUser( user, payer: payer.publicKey, }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitPrice({ + microLamports: whitelistTxNonce++, + }), + ]) .signers(signers) .rpc(); diff --git a/tests/integration/fullLaunch.test.ts b/tests/integration/fullLaunch.test.ts index b2914587..dd2e0d1c 100644 --- a/tests/integration/fullLaunch.test.ts +++ b/tests/integration/fullLaunch.test.ts @@ -385,6 +385,8 @@ export default async function suite() { teamSponsoredPassThresholdBps: null, teamAddress: null, isOptimisticGovernanceEnabled: false, + baseToSupermajority: null, + isProposalValidationEnabled: null, }, }) .instruction(); @@ -460,6 +462,8 @@ export default async function suite() { }) .rpc(); + await this.futarchy.approveProposalIx({ proposal, dao }).rpc(); + // Launch the proposal first await this.futarchy .launchProposalIx({ diff --git a/tests/integration/fullLaunch_v7.test.ts b/tests/integration/fullLaunch_v7.test.ts index e236370d..7af72ee9 100644 --- a/tests/integration/fullLaunch_v7.test.ts +++ b/tests/integration/fullLaunch_v7.test.ts @@ -430,6 +430,8 @@ export default async function suite() { teamSponsoredPassThresholdBps: null, teamAddress: null, isOptimisticGovernanceEnabled: true, + baseToSupermajority: null, + isProposalValidationEnabled: null, }, }) .instruction(); @@ -505,6 +507,8 @@ export default async function suite() { }) .rpc(); + await this.futarchy.approveProposalIx({ proposal, dao }).rpc(); + // Launch the proposal first await this.futarchy .launchProposalIx({ diff --git a/tests/integration/mintAndSwap.test.ts b/tests/integration/mintAndSwap.test.ts index 486268cc..58e7d15b 100644 --- a/tests/integration/mintAndSwap.test.ts +++ b/tests/integration/mintAndSwap.test.ts @@ -6,6 +6,7 @@ import { import { PublicKey, Transaction } from "@solana/web3.js"; import BN from "bn.js"; import { assert } from "chai"; +import { nextDaoNonce } from "../utils.js"; export default async function test() { const vaultClient: ConditionalVaultClient = this.conditionalVault; @@ -27,7 +28,7 @@ export default async function test() { ); // Initialize DAO - const nonce = new BN(Math.floor(Math.random() * 1000000)); + const nonce = nextDaoNonce(); await futarchyClient .initializeDaoIx({ @@ -44,6 +45,8 @@ export default async function test() { nonce, initialSpendingLimit: null, baseToStake: new BN(0), + baseToSupermajority: new BN(0), + isProposalValidationEnabled: false, teamAddress: PublicKey.default, teamSponsoredPassThresholdBps: 0, }, diff --git a/tests/main.test.ts b/tests/main.test.ts index 7c4d6307..10589e88 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -86,9 +86,15 @@ import fullLaunch_v8 from "./integration/launchpad_v8_full_lifecycle.test.js"; import gatedLaunchpadV8 from "./integration/gatedLaunchpadV8.test.js"; import trancheLifecycle_v8 from "./integration/launchpad_v8_tranche_lifecycle.test.js"; import { BN } from "bn.js"; +import { nextDaoNonce } from "./utils.js"; const ONE_BUCK_PRICE = PriceMath.getAmmPrice(1, 6, 6); +// Every test transaction needs a distinct signature; bankrun rejects a duplicate +// (same message + blockhash) as "transaction already processed", and the blockhash +// doesn't always advance between back-to-back txs. +let mintToTxNonce = 1; + // Export the test context interface for use in other files export interface TestContext { context: ProgramTestContext; @@ -385,6 +391,15 @@ before(async function () { const tx = new Transaction(); + // Unique compute-unit price per call → unique signature, so two otherwise + // identical mints can't collide as "transaction already processed". Price + // (not limit) leaves the compute budget untouched and increments freely. + tx.add( + ComputeBudgetProgram.setComputeUnitPrice({ + microLamports: mintToTxNonce++, + }), + ); + tx.add( token.createAssociatedTokenAccountIdempotentInstruction( this.payer.publicKey, @@ -493,7 +508,7 @@ before(async function () { teamSponsoredPassThresholdBps?: number; teamAddress?: PublicKey; }) => { - const nonce = new BN(Math.floor(Math.random() * 1000000)); + const nonce = nextDaoNonce(); await this.futarchy .initializeDaoIx({ @@ -512,6 +527,8 @@ before(async function () { baseToStake: new BN(0), teamSponsoredPassThresholdBps, teamAddress, + baseToSupermajority: new BN(0), + isProposalValidationEnabled: false, }, provideLiquidity: true, }) @@ -653,6 +670,7 @@ before(async function () { const { proposal, question, baseVault, quoteVault, squadsProposal } = await this.initializeProposal({ dao, instructions }); const storedDao = await this.futarchy.getDao(dao); + await this.futarchy .launchProposalIx({ proposal, diff --git a/tests/performancePackageV2/utils.ts b/tests/performancePackageV2/utils.ts index c9ab0459..3325ced4 100644 --- a/tests/performancePackageV2/utils.ts +++ b/tests/performancePackageV2/utils.ts @@ -8,6 +8,7 @@ import { import * as token from "@solana/spl-token"; import { BanksClient } from "solana-bankrun"; import BN from "bn.js"; +import { nextDaoNonce } from "../utils.js"; import { MintGovernorClient, PerformancePackageV2Client, @@ -338,7 +339,7 @@ export async function setupDaoForTwapTests(context: any): Promise { await context.banksClient.processTransaction(mintBaseTx); // Initialize DAO - const nonce = new BN(Math.floor(Math.random() * 1000000)); + const nonce = nextDaoNonce(); await context.futarchy .initializeDaoIx({ @@ -355,6 +356,8 @@ export async function setupDaoForTwapTests(context: any): Promise { nonce, initialSpendingLimit: null, baseToStake: new BN(0), + baseToSupermajority: new BN(0), + isProposalValidationEnabled: false, teamSponsoredPassThresholdBps: 300, teamAddress: context.payer.publicKey, }, diff --git a/tests/utils.ts b/tests/utils.ts index 4792adfe..f2ed1f6d 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -20,6 +20,15 @@ export const DAY_IN_SLOTS = HOUR_IN_SLOTS * 24n; export const toBN = (val: bigint): typeof BN.prototype => new BN(val.toString()); +// Monotonic DAO-nonce allocator. The DAO PDA is [SEED_DAO, daoCreator, nonce] and +// every test shares the same creator (the provider wallet), so the nonce space is +// global across the whole suite. A single shared counter guarantees uniqueness; +// random nonces collided (birthday bound) and intermittently failed init with the +// System program's "account already in use" (0x0). Mirrors `mintToTxNonce`. +let daoNonceCounter = 1; +export const nextDaoNonce = (): typeof BN.prototype => + new BN(daoNonceCounter++); + const THOUSAND_BUCK_PRICE = PriceMath.getAmmPrice(1000, 6, 6); export async function setupBasicDao({ @@ -28,14 +37,16 @@ export async function setupBasicDao({ quoteMint, teamSponsoredPassThresholdBps = 300, teamAddress, + isProposalValidationEnabled = false, }: { context: TestContext; baseMint: PublicKey; quoteMint: PublicKey; teamSponsoredPassThresholdBps?: number; teamAddress?: PublicKey; + isProposalValidationEnabled?: boolean; }) { - const nonce = new BN(Math.floor(Math.random() * 1000000)); + const nonce = nextDaoNonce(); await context.futarchy .initializeDaoIx({ @@ -54,6 +65,8 @@ export async function setupBasicDao({ baseToStake: new BN(0), teamSponsoredPassThresholdBps, teamAddress: teamAddress || context.payer.publicKey, + baseToSupermajority: new BN(0), + isProposalValidationEnabled, }, provideLiquidity: true, }) @@ -88,6 +101,24 @@ export async function setOptimisticGovernanceEnabled( context.context.setAccount(dao, daoBanksAccount); } +export async function setProposalValidationEnabled( + context: TestContext, + dao: PublicKey, + enabled: boolean, +): Promise { + const daoAccount = await context.futarchy.getDao(dao); + daoAccount.isProposalValidationEnabled = enabled; + const daoAccountBuffer = + await context.futarchy.futarchy.account.dao.coder.accounts.encode( + "dao", + daoAccount, + ); + + const daoBanksAccount = await context.banksClient.getAccount(dao); + daoBanksAccount.data.set(daoAccountBuffer, 0); + context.context.setAccount(dao, daoBanksAccount); +} + /** * Creates a lookup table for all unique accounts in a transaction * @param transaction - The transaction to create a lookup table for