From a60c97d342d8d4928ac89aeb0faca29ac6a30d86 Mon Sep 17 00:00:00 2001 From: Pileks Date: Wed, 17 Jun 2026 21:08:08 +0200 Subject: [PATCH 1/9] metadao proposal approval ix/sdk/tests --- programs/futarchy/src/error.rs | 4 + programs/futarchy/src/events.rs | 8 + .../src/instructions/approve_proposal.rs | 66 +++++++ .../src/instructions/initialize_proposal.rs | 1 + programs/futarchy/src/instructions/mod.rs | 2 + programs/futarchy/src/lib.rs | 5 + programs/futarchy/src/state/proposal.rs | 1 + sdk/src/futarchy/v0.6/FutarchyClient.ts | 16 ++ sdk/src/futarchy/v0.6/types/futarchy.ts | 144 ++++++++++++++ tests/futarchy/main.test.ts | 2 + tests/futarchy/unit/approveProposal.test.ts | 184 ++++++++++++++++++ 11 files changed, 433 insertions(+) create mode 100644 programs/futarchy/src/instructions/approve_proposal.rs create mode 100644 tests/futarchy/unit/approveProposal.test.ts diff --git a/programs/futarchy/src/error.rs b/programs/futarchy/src/error.rs index d822ac676..dc0dc9e46 100644 --- a/programs/futarchy/src/error.rs +++ b/programs/futarchy/src/error.rs @@ -90,4 +90,8 @@ pub enum FutarchyError { InvalidSpendingLimitMint, #[msg("No active optimistic proposal")] NoActiveOptimisticProposal, + #[msg("Invalid MetaDAO approver")] + InvalidApprover, + #[msg("Proposal has already been approved by MetaDAO")] + ProposalAlreadyApproved, } diff --git a/programs/futarchy/src/events.rs b/programs/futarchy/src/events.rs index 0479eddcc..4c2983dd8 100644 --- a/programs/futarchy/src/events.rs +++ b/programs/futarchy/src/events.rs @@ -192,6 +192,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 000000000..fe1c8cfac --- /dev/null +++ b/programs/futarchy/src/instructions/approve_proposal.rs @@ -0,0 +1,66 @@ +use super::*; + +mod metadao_approver { + use anchor_lang::prelude::declare_id; + // METADAO_MULTISIG_VAULT (sdk/src/constants.ts) + 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<()> { + #[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_proposal.rs b/programs/futarchy/src/instructions/initialize_proposal.rs index a59736a5a..e634ab8dd 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/mod.rs b/programs/futarchy/src/instructions/mod.rs index 0076cb2af..150d88df5 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; @@ -28,6 +29,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::*; diff --git a/programs/futarchy/src/lib.rs b/programs/futarchy/src/lib.rs index 7b5dc5e50..189301435 100644 --- a/programs/futarchy/src/lib.rs +++ b/programs/futarchy/src/lib.rs @@ -154,6 +154,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/proposal.rs b/programs/futarchy/src/state/proposal.rs index 331bf9e21..7b10b0aea 100644 --- a/programs/futarchy/src/state/proposal.rs +++ b/programs/futarchy/src/state/proposal.rs @@ -36,4 +36,5 @@ pub struct Proposal { pub fail_base_mint: Pubkey, pub fail_quote_mint: Pubkey, pub is_team_sponsored: bool, + pub is_metadao_approved: bool, } diff --git a/sdk/src/futarchy/v0.6/FutarchyClient.ts b/sdk/src/futarchy/v0.6/FutarchyClient.ts index 2468db649..5a07f8319 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 9af98c323..47f208832 100644 --- a/sdk/src/futarchy/v0.6/types/futarchy.ts +++ b/sdk/src/futarchy/v0.6/types/futarchy.ts @@ -1053,6 +1053,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: [ @@ -1999,6 +2030,10 @@ export type Futarchy = { name: "isTeamSponsored"; type: "bool"; }, + { + name: "isMetadaoApproved"; + type: "bool"; + }, ]; }; }, @@ -3350,6 +3385,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 +3863,16 @@ 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"; + }, ]; }; @@ -4859,6 +4931,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: [ @@ -5805,6 +5908,10 @@ export const IDL: Futarchy = { name: "isTeamSponsored", type: "bool", }, + { + name: "isMetadaoApproved", + type: "bool", + }, ], }, }, @@ -7156,6 +7263,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 +7741,15 @@ 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", + }, ], }; diff --git a/tests/futarchy/main.test.ts b/tests/futarchy/main.test.ts index e800e372e..e554eb5bc 100644 --- a/tests/futarchy/main.test.ts +++ b/tests/futarchy/main.test.ts @@ -22,6 +22,7 @@ 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 { PublicKey } from "@solana/web3.js"; import { @@ -86,6 +87,7 @@ export default function suite() { describe("#admin_cancel_proposal", adminCancelProposal); describe("#admin_remove_proposal", adminRemoveProposal); describe("#unstake_from_proposal", unstakeFromProposal); + describe("#approve_proposal", approveProposal); // describe("full proposal", fullProposal); // describe("proposal with a squads batch tx", proposalBatchTx); describe("futarchy amm", futarchyAmm); diff --git a/tests/futarchy/unit/approveProposal.test.ts b/tests/futarchy/unit/approveProposal.test.ts new file mode 100644 index 000000000..f07451f9a --- /dev/null +++ b/tests/futarchy/unit/approveProposal.test.ts @@ -0,0 +1,184 @@ +import { PERMISSIONLESS_ACCOUNT } from "@metadaoproject/programs"; +import { + ComputeBudgetProgram, + PublicKey, + Transaction, + TransactionMessage, +} from "@solana/web3.js"; +import BN from "bn.js"; +import { expectError, setupBasicDao } 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, + ); + + // setupBasicDao uses baseToStake: 0, so a draft proposal can launch with no + // stake (the Task-1 launch gate is unchanged). + dao = await setupBasicDao({ + context: this, + baseMint: META, + quoteMint: USDC, + }); + + 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, + }, + }) + .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 () { + // baseToStake is 0, so the proposal launches with no stake and no sponsor. + 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]); + }); +} From 9d58fa796166f3ebe0365bc293827e5ef2594de2 Mon Sep 17 00:00:00 2001 From: Pileks Date: Wed, 17 Jun 2026 23:51:40 +0200 Subject: [PATCH 2/9] resize proposal ix --- programs/futarchy/src/instructions/mod.rs | 2 + .../src/instructions/resize_proposal.rs | 78 +++++ programs/futarchy/src/lib.rs | 4 + programs/futarchy/src/state/proposal.rs | 21 ++ sdk/src/futarchy/v0.6/types/futarchy.ts | 202 ++++++++++++ tests/futarchy/main.test.ts | 2 + tests/futarchy/unit/resizeProposal.test.ts | 300 ++++++++++++++++++ 7 files changed, 609 insertions(+) create mode 100644 programs/futarchy/src/instructions/resize_proposal.rs create mode 100644 tests/futarchy/unit/resizeProposal.test.ts diff --git a/programs/futarchy/src/instructions/mod.rs b/programs/futarchy/src/instructions/mod.rs index 150d88df5..672c3b205 100644 --- a/programs/futarchy/src/instructions/mod.rs +++ b/programs/futarchy/src/instructions/mod.rs @@ -18,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; @@ -43,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_proposal.rs b/programs/futarchy/src/instructions/resize_proposal.rs new file mode 100644 index 000000000..a34610127 --- /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/lib.rs b/programs/futarchy/src/lib.rs index 189301435..09c31bc27 100644 --- a/programs/futarchy/src/lib.rs +++ b/programs/futarchy/src/lib.rs @@ -109,6 +109,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<()> { diff --git a/programs/futarchy/src/state/proposal.rs b/programs/futarchy/src/state/proposal.rs index 7b10b0aea..b65a2a804 100644 --- a/programs/futarchy/src/state/proposal.rs +++ b/programs/futarchy/src/state/proposal.rs @@ -38,3 +38,24 @@ pub struct Proposal { 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/sdk/src/futarchy/v0.6/types/futarchy.ts b/sdk/src/futarchy/v0.6/types/futarchy.ts index 47f208832..6bb413f6f 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: [ @@ -2037,6 +2058,86 @@ export type Futarchy = { ]; }; }, + { + name: "oldProposal"; + docs: [ + "Today's `Proposal` layout **without** `is_metadao_approved`. Read by the", + "`resize_proposal` crank to migrate existing accounts to the new layout.", + "Mirrors `OldDao` (same `#[account] #[derive(InitSpace)]` derives): `#[account]`", + "lists it in the IDL so the resize tests can build old-layout fixtures.", + ]; + type: { + kind: "struct"; + fields: [ + { + name: "number"; + type: "u32"; + }, + { + name: "proposer"; + type: "publicKey"; + }, + { + name: "timestampEnqueued"; + type: "i64"; + }, + { + name: "state"; + type: { + defined: "ProposalState"; + }; + }, + { + name: "baseVault"; + type: "publicKey"; + }, + { + name: "quoteVault"; + type: "publicKey"; + }, + { + name: "dao"; + type: "publicKey"; + }, + { + name: "pdaBump"; + type: "u8"; + }, + { + name: "question"; + type: "publicKey"; + }, + { + name: "durationInSeconds"; + type: "u32"; + }, + { + name: "squadsProposal"; + type: "publicKey"; + }, + { + name: "passBaseMint"; + type: "publicKey"; + }, + { + name: "passQuoteMint"; + type: "publicKey"; + }, + { + name: "failBaseMint"; + type: "publicKey"; + }, + { + name: "failQuoteMint"; + type: "publicKey"; + }, + { + name: "isTeamSponsored"; + type: "bool"; + }, + ]; + }; + }, { name: "stakeAccount"; type: { @@ -4481,6 +4582,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: [ @@ -5915,6 +6037,86 @@ export const IDL: Futarchy = { ], }, }, + { + name: "oldProposal", + docs: [ + "Today's `Proposal` layout **without** `is_metadao_approved`. Read by the", + "`resize_proposal` crank to migrate existing accounts to the new layout.", + "Mirrors `OldDao` (same `#[account] #[derive(InitSpace)]` derives): `#[account]`", + "lists it in the IDL so the resize tests can build old-layout fixtures.", + ], + type: { + kind: "struct", + fields: [ + { + name: "number", + type: "u32", + }, + { + name: "proposer", + type: "publicKey", + }, + { + name: "timestampEnqueued", + type: "i64", + }, + { + name: "state", + type: { + defined: "ProposalState", + }, + }, + { + name: "baseVault", + type: "publicKey", + }, + { + name: "quoteVault", + type: "publicKey", + }, + { + name: "dao", + type: "publicKey", + }, + { + name: "pdaBump", + type: "u8", + }, + { + name: "question", + type: "publicKey", + }, + { + name: "durationInSeconds", + type: "u32", + }, + { + name: "squadsProposal", + type: "publicKey", + }, + { + name: "passBaseMint", + type: "publicKey", + }, + { + name: "passQuoteMint", + type: "publicKey", + }, + { + name: "failBaseMint", + type: "publicKey", + }, + { + name: "failQuoteMint", + type: "publicKey", + }, + { + name: "isTeamSponsored", + type: "bool", + }, + ], + }, + }, { name: "stakeAccount", type: { diff --git a/tests/futarchy/main.test.ts b/tests/futarchy/main.test.ts index e554eb5bc..c3dcce821 100644 --- a/tests/futarchy/main.test.ts +++ b/tests/futarchy/main.test.ts @@ -23,6 +23,7 @@ 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 { PublicKey } from "@solana/web3.js"; import { @@ -88,6 +89,7 @@ export default function suite() { describe("#admin_remove_proposal", adminRemoveProposal); describe("#unstake_from_proposal", unstakeFromProposal); describe("#approve_proposal", approveProposal); + describe("#resize_proposal", resizeProposal); // describe("full proposal", fullProposal); // describe("proposal with a squads batch tx", proposalBatchTx); describe("futarchy amm", futarchyAmm); diff --git a/tests/futarchy/unit/resizeProposal.test.ts b/tests/futarchy/unit/resizeProposal.test.ts new file mode 100644 index 000000000..ec5ccf5ee --- /dev/null +++ b/tests/futarchy/unit/resizeProposal.test.ts @@ -0,0 +1,300 @@ +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, + ); + + dao = await setupBasicDao({ + context: this, + baseMint: META, + quoteMint: USDC, + }); + + 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, + }, + }) + .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); + }); +} From 0c6e4d3aa6cd058997b685096552556f027bc71b Mon Sep 17 00:00:00 2001 From: Pileks Date: Thu, 18 Jun 2026 00:16:10 +0200 Subject: [PATCH 3/9] fix flaky tests --- tests/gatedMint/utils.ts | 10 ++++++++++ tests/main.test.ts | 14 ++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/tests/gatedMint/utils.ts b/tests/gatedMint/utils.ts index 16cf81bde..9f24f51f6 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/main.test.ts b/tests/main.test.ts index 7c4d63071..731d881f6 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -89,6 +89,11 @@ import { BN } from "bn.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 +390,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, From 88c736d3936134f67d91b258d02dc0f6a795e23a Mon Sep 17 00:00:00 2001 From: Pileks Date: Thu, 18 Jun 2026 01:38:20 +0200 Subject: [PATCH 4/9] add supermajority vote structure --- programs/futarchy/src/error.rs | 2 + programs/futarchy/src/events.rs | 2 + .../src/instructions/initialize_dao.rs | 4 + .../futarchy/src/instructions/resize_dao.rs | 33 +- .../futarchy/src/instructions/update_dao.rs | 5 + programs/futarchy/src/lib.rs | 4 + programs/futarchy/src/state/dao.rs | 13 + .../src/instructions/complete_launch.rs | 1 + .../src/instructions/complete_launch.rs | 1 + .../src/instructions/settle_launch.rs | 1 + sdk/src/futarchy/v0.6/types/futarchy.ts | 122 +++++++- .../futarchy/integration/futarchyAmm.test.ts | 1 + tests/futarchy/main.test.ts | 2 + .../futarchy/unit/adminCancelProposal.test.ts | 1 + .../futarchy/unit/adminRemoveProposal.test.ts | 1 + tests/futarchy/unit/approveProposal.test.ts | 1 + .../unit/finalizeOptimisticProposal.test.ts | 1 + tests/futarchy/unit/finalizeProposal.test.ts | 2 + tests/futarchy/unit/initializeDao.test.ts | 36 +++ .../futarchy/unit/initializeProposal.test.ts | 2 + ...itiateVaultSpendOptimisticProposal.test.ts | 1 + tests/futarchy/unit/launchProposal.test.ts | 1 + tests/futarchy/unit/resizeDao.test.ts | 287 ++++++++++++++++++ tests/futarchy/unit/resizeProposal.test.ts | 1 + .../futarchy/unit/unstakeFromProposal.test.ts | 1 + tests/futarchy/unit/updateDao.test.ts | 216 +++++++++++++ tests/integration/fullLaunch.test.ts | 1 + tests/integration/fullLaunch_v7.test.ts | 1 + tests/integration/mintAndSwap.test.ts | 1 + tests/main.test.ts | 1 + tests/performancePackageV2/utils.ts | 1 + tests/utils.ts | 1 + 32 files changed, 732 insertions(+), 16 deletions(-) create mode 100644 tests/futarchy/unit/resizeDao.test.ts diff --git a/programs/futarchy/src/error.rs b/programs/futarchy/src/error.rs index dc0dc9e46..d723d0cb8 100644 --- a/programs/futarchy/src/error.rs +++ b/programs/futarchy/src/error.rs @@ -94,4 +94,6 @@ pub enum FutarchyError { InvalidApprover, #[msg("Proposal has already been approved by MetaDAO")] ProposalAlreadyApproved, + #[msg("base_to_supermajority must be 0 (disabled) or >= base_to_stake")] + InvalidSupermajorityThreshold, } diff --git a/programs/futarchy/src/events.rs b/programs/futarchy/src/events.rs index 4c2983dd8..98ee6d327 100644 --- a/programs/futarchy/src/events.rs +++ b/programs/futarchy/src/events.rs @@ -53,6 +53,7 @@ pub struct InitializeDaoEvent { pub squads_multisig_vault: Pubkey, pub team_sponsored_pass_threshold_bps: i16, pub team_address: Pubkey, + pub base_to_supermajority: u64, } #[event] @@ -70,6 +71,7 @@ pub struct UpdateDaoEvent { pub team_sponsored_pass_threshold_bps: i16, pub team_address: Pubkey, pub is_optimistic_governance_enabled: bool, + pub base_to_supermajority: u64, } #[event] diff --git a/programs/futarchy/src/instructions/initialize_dao.rs b/programs/futarchy/src/instructions/initialize_dao.rs index 64fe30be7..fd6fdd98e 100644 --- a/programs/futarchy/src/instructions/initialize_dao.rs +++ b/programs/futarchy/src/instructions/initialize_dao.rs @@ -18,6 +18,7 @@ pub struct InitializeDaoParams { pub initial_spending_limit: Option, pub team_sponsored_pass_threshold_bps: i16, pub team_address: Pubkey, + pub base_to_supermajority: u64, } #[derive(Accounts)] @@ -93,6 +94,7 @@ impl InitializeDao<'_> { initial_spending_limit, team_sponsored_pass_threshold_bps, team_address, + base_to_supermajority, } = params; let dao = &mut ctx.accounts.dao; @@ -223,6 +225,7 @@ impl InitializeDao<'_> { team_address, optimistic_proposal: None, is_optimistic_governance_enabled: false, + base_to_supermajority, }); dao.invariant()?; @@ -248,6 +251,7 @@ impl InitializeDao<'_> { squads_multisig_vault: dao.squads_multisig_vault, team_sponsored_pass_threshold_bps: dao.team_sponsored_pass_threshold_bps, team_address: dao.team_address, + base_to_supermajority: dao.base_to_supermajority, }); Ok(()) diff --git a/programs/futarchy/src/instructions/resize_dao.rs b/programs/futarchy/src/instructions/resize_dao.rs index 6352ac116..ff7e72d0c 100644 --- a/programs/futarchy/src/instructions/resize_dao.rs +++ b/programs/futarchy/src/instructions/resize_dao.rs @@ -7,6 +7,10 @@ pub struct ResizeDao<'info> { /// CHECK: we check the discriminator #[account(mut)] pub dao: UncheckedAccount<'info>, + /// The DAO's base mint, bound to `old.base_mint` below. Because this crank is + /// permissionless, the mint must be bound so a caller can't pass a fabricated + /// low-decimal mint to set a near-zero supermajority bar. + pub base_mint: Account<'info, Mint>, #[account(mut)] pub payer: Signer<'info>, pub system_program: Program<'info, System>, @@ -21,8 +25,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; + // 8 bytes: base_to_supermajority (u64) + const BEFORE_REALLOC_SIZE: usize = AFTER_REALLOC_SIZE - 8; if dao.data_len() != BEFORE_REALLOC_SIZE { // already realloced @@ -32,6 +36,26 @@ impl ResizeDao<'_> { let old_dao_data = OldDao::deserialize(&mut &dao.try_borrow_data().unwrap()[8..])?; + // Bind the passed mint to the DAO before trusting its decimals. + require_keys_eq!( + ctx.accounts.base_mint.key(), + old_dao_data.base_mint, + FutarchyError::InvalidMint + ); + + // 2.5M WHOLE tokens scaled to base units by the base mint's on-chain decimals; + // checked so a pathological high-decimal mint errors rather than silently wrapping. + let scaled = DEFAULT_BASE_TO_SUPERMAJORITY_TOKENS + .checked_mul( + 10u64 + .checked_pow(ctx.accounts.base_mint.decimals as u32) + .ok_or(FutarchyError::CastingOverflow)?, + ) + .ok_or(FutarchyError::CastingOverflow)?; + // Never below the DAO's own base_to_stake floor, so the supermajority bar can't become the *easier* path. + // Satisfies the `base_to_supermajority >= base_to_stake` invariant by construction. + let base_to_supermajority = scaled.max(old_dao_data.base_to_stake); + let new_dao_data = Dao { amm: old_dao_data.amm, nonce: old_dao_data.nonce, @@ -55,8 +79,9 @@ 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, }; dao.realloc(AFTER_REALLOC_SIZE, true)?; diff --git a/programs/futarchy/src/instructions/update_dao.rs b/programs/futarchy/src/instructions/update_dao.rs index d5d5d5d74..c95373d9c 100644 --- a/programs/futarchy/src/instructions/update_dao.rs +++ b/programs/futarchy/src/instructions/update_dao.rs @@ -13,6 +13,7 @@ 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, } #[derive(Accounts)] @@ -83,6 +84,9 @@ 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), }); dao.seq_num += 1; @@ -104,6 +108,7 @@ impl UpdateDao<'_> { team_sponsored_pass_threshold_bps: dao.team_sponsored_pass_threshold_bps, team_address: dao.team_address, is_optimistic_governance_enabled: dao.is_optimistic_governance_enabled, + base_to_supermajority: dao.base_to_supermajority, }); Ok(()) diff --git a/programs/futarchy/src/lib.rs b/programs/futarchy/src/lib.rs index 09c31bc27..959c82dc2 100644 --- a/programs/futarchy/src/lib.rs +++ b/programs/futarchy/src/lib.rs @@ -60,6 +60,10 @@ 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; + #[program] pub mod futarchy { use super::*; diff --git a/programs/futarchy/src/state/dao.rs b/programs/futarchy/src/state/dao.rs index 62cfd5362..4e1b09cac 100644 --- a/programs/futarchy/src/state/dao.rs +++ b/programs/futarchy/src/state/dao.rs @@ -66,6 +66,9 @@ 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, } #[derive(AnchorSerialize, AnchorDeserialize, Debug, Clone, PartialEq, Eq, InitSpace)] @@ -133,6 +136,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. Equality is benign (use `>=`, not `>`). + require!( + self.base_to_supermajority == 0 || self.base_to_supermajority >= self.base_to_stake, + FutarchyError::InvalidSupermajorityThreshold + ); + Ok(()) } } @@ -191,4 +202,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/v06_launchpad/src/instructions/complete_launch.rs b/programs/v06_launchpad/src/instructions/complete_launch.rs index 9704633f1..0ce8610ee 100644 --- a/programs/v06_launchpad/src/instructions/complete_launch.rs +++ b/programs/v06_launchpad/src/instructions/complete_launch.rs @@ -413,6 +413,7 @@ 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, }, ) } diff --git a/programs/v07_launchpad/src/instructions/complete_launch.rs b/programs/v07_launchpad/src/instructions/complete_launch.rs index 39be1f37a..4aa50b434 100644 --- a/programs/v07_launchpad/src/instructions/complete_launch.rs +++ b/programs/v07_launchpad/src/instructions/complete_launch.rs @@ -444,6 +444,7 @@ 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, }, ) } diff --git a/programs/v08_launchpad/src/instructions/settle_launch.rs b/programs/v08_launchpad/src/instructions/settle_launch.rs index a80b07f99..4d196b226 100644 --- a/programs/v08_launchpad/src/instructions/settle_launch.rs +++ b/programs/v08_launchpad/src/instructions/settle_launch.rs @@ -452,6 +452,7 @@ 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, }, ) } diff --git a/sdk/src/futarchy/v0.6/types/futarchy.ts b/sdk/src/futarchy/v0.6/types/futarchy.ts index 6bb413f6f..a025c8290 100644 --- a/sdk/src/futarchy/v0.6/types/futarchy.ts +++ b/sdk/src/futarchy/v0.6/types/futarchy.ts @@ -590,6 +590,16 @@ export type Futarchy = { isMut: true; isSigner: false; }, + { + name: "baseMint"; + isMut: false; + isSigner: false; + docs: [ + "The DAO's base mint, bound to `old.base_mint` below. Because this crank is", + "permissionless, the mint must be bound so a caller can't pass a fabricated", + "low-decimal mint to set a near-zero supermajority bar.", + ]; + }, { name: "payer"; isMut: true; @@ -1821,6 +1831,14 @@ 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"; + }, ]; }; }, @@ -1957,6 +1975,18 @@ export type Futarchy = { name: "teamAddress"; type: "publicKey"; }, + { + name: "optimisticProposal"; + type: { + option: { + defined: "OptimisticProposal"; + }; + }; + }, + { + name: "isOptimisticGovernanceEnabled"; + type: "bool"; + }, ]; }; }, @@ -2060,12 +2090,6 @@ export type Futarchy = { }, { name: "oldProposal"; - docs: [ - "Today's `Proposal` layout **without** `is_metadao_approved`. Read by the", - "`resize_proposal` crank to migrate existing accounts to the new layout.", - "Mirrors `OldDao` (same `#[account] #[derive(InitSpace)]` derives): `#[account]`", - "lists it in the IDL so the resize tests can build old-layout fixtures.", - ]; type: { kind: "struct"; fields: [ @@ -2281,6 +2305,10 @@ export type Futarchy = { name: "teamAddress"; type: "publicKey"; }, + { + name: "baseToSupermajority"; + type: "u64"; + }, ]; }; }, @@ -2444,6 +2472,12 @@ export type Futarchy = { option: "bool"; }; }, + { + name: "baseToSupermajority"; + type: { + option: "u64"; + }; + }, ]; }; }, @@ -2922,6 +2956,11 @@ export type Futarchy = { type: "publicKey"; index: false; }, + { + name: "baseToSupermajority"; + type: "u64"; + index: false; + }, ]; }, { @@ -2994,6 +3033,11 @@ export type Futarchy = { type: "bool"; index: false; }, + { + name: "baseToSupermajority"; + type: "u64"; + index: false; + }, ]; }, { @@ -3974,6 +4018,11 @@ export type Futarchy = { 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"; + }, ]; }; @@ -4569,6 +4618,16 @@ export const IDL: Futarchy = { isMut: true, isSigner: false, }, + { + name: "baseMint", + isMut: false, + isSigner: false, + docs: [ + "The DAO's base mint, bound to `old.base_mint` below. Because this crank is", + "permissionless, the mint must be bound so a caller can't pass a fabricated", + "low-decimal mint to set a near-zero supermajority bar.", + ], + }, { name: "payer", isMut: true, @@ -5800,6 +5859,14 @@ 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", + }, ], }, }, @@ -5936,6 +6003,18 @@ export const IDL: Futarchy = { name: "teamAddress", type: "publicKey", }, + { + name: "optimisticProposal", + type: { + option: { + defined: "OptimisticProposal", + }, + }, + }, + { + name: "isOptimisticGovernanceEnabled", + type: "bool", + }, ], }, }, @@ -6039,12 +6118,6 @@ export const IDL: Futarchy = { }, { name: "oldProposal", - docs: [ - "Today's `Proposal` layout **without** `is_metadao_approved`. Read by the", - "`resize_proposal` crank to migrate existing accounts to the new layout.", - "Mirrors `OldDao` (same `#[account] #[derive(InitSpace)]` derives): `#[account]`", - "lists it in the IDL so the resize tests can build old-layout fixtures.", - ], type: { kind: "struct", fields: [ @@ -6260,6 +6333,10 @@ export const IDL: Futarchy = { name: "teamAddress", type: "publicKey", }, + { + name: "baseToSupermajority", + type: "u64", + }, ], }, }, @@ -6423,6 +6500,12 @@ export const IDL: Futarchy = { option: "bool", }, }, + { + name: "baseToSupermajority", + type: { + option: "u64", + }, + }, ], }, }, @@ -6901,6 +6984,11 @@ export const IDL: Futarchy = { type: "publicKey", index: false, }, + { + name: "baseToSupermajority", + type: "u64", + index: false, + }, ], }, { @@ -6973,6 +7061,11 @@ export const IDL: Futarchy = { type: "bool", index: false, }, + { + name: "baseToSupermajority", + type: "u64", + index: false, + }, ], }, { @@ -7953,5 +8046,10 @@ export const IDL: Futarchy = { 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", + }, ], }; diff --git a/tests/futarchy/integration/futarchyAmm.test.ts b/tests/futarchy/integration/futarchyAmm.test.ts index b74c7ede8..496c7aa50 100644 --- a/tests/futarchy/integration/futarchyAmm.test.ts +++ b/tests/futarchy/integration/futarchyAmm.test.ts @@ -82,6 +82,7 @@ export default function suite() { teamSponsoredPassThresholdBps: null, teamAddress: null, isOptimisticGovernanceEnabled: null, + baseToSupermajority: null, }, }) .instruction(); diff --git a/tests/futarchy/main.test.ts b/tests/futarchy/main.test.ts index c3dcce821..8c2835626 100644 --- a/tests/futarchy/main.test.ts +++ b/tests/futarchy/main.test.ts @@ -24,6 +24,7 @@ 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 { @@ -90,6 +91,7 @@ export default function suite() { 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 cea663183..7e6b41777 100644 --- a/tests/futarchy/unit/adminCancelProposal.test.ts +++ b/tests/futarchy/unit/adminCancelProposal.test.ts @@ -75,6 +75,7 @@ export default function suite() { teamSponsoredPassThresholdBps: null, teamAddress: null, isOptimisticGovernanceEnabled: null, + baseToSupermajority: null, }, }) .instruction(); diff --git a/tests/futarchy/unit/adminRemoveProposal.test.ts b/tests/futarchy/unit/adminRemoveProposal.test.ts index 565313bf9..ab9860e22 100644 --- a/tests/futarchy/unit/adminRemoveProposal.test.ts +++ b/tests/futarchy/unit/adminRemoveProposal.test.ts @@ -51,6 +51,7 @@ export default function suite() { teamSponsoredPassThresholdBps: null, teamAddress: null, isOptimisticGovernanceEnabled: null, + baseToSupermajority: null, }, }) .instruction(); diff --git a/tests/futarchy/unit/approveProposal.test.ts b/tests/futarchy/unit/approveProposal.test.ts index f07451f9a..1d97bb888 100644 --- a/tests/futarchy/unit/approveProposal.test.ts +++ b/tests/futarchy/unit/approveProposal.test.ts @@ -73,6 +73,7 @@ export default function suite() { teamSponsoredPassThresholdBps: null, teamAddress: null, isOptimisticGovernanceEnabled: null, + baseToSupermajority: null, }, }) .instruction(); diff --git a/tests/futarchy/unit/finalizeOptimisticProposal.test.ts b/tests/futarchy/unit/finalizeOptimisticProposal.test.ts index b3c50f0a0..14c4f24f7 100644 --- a/tests/futarchy/unit/finalizeOptimisticProposal.test.ts +++ b/tests/futarchy/unit/finalizeOptimisticProposal.test.ts @@ -57,6 +57,7 @@ export default function suite() { members: [this.payer.publicKey], }, baseToStake: new BN(0), + baseToSupermajority: new BN(0), teamSponsoredPassThresholdBps: 0, teamAddress: this.payer.publicKey, }, diff --git a/tests/futarchy/unit/finalizeProposal.test.ts b/tests/futarchy/unit/finalizeProposal.test.ts index 231c3c9e6..f1a39a774 100644 --- a/tests/futarchy/unit/finalizeProposal.test.ts +++ b/tests/futarchy/unit/finalizeProposal.test.ts @@ -75,6 +75,7 @@ export default function suite() { teamSponsoredPassThresholdBps: null, teamAddress: null, isOptimisticGovernanceEnabled: null, + baseToSupermajority: null, }, }) .instruction(); @@ -446,6 +447,7 @@ export default function suite() { teamSponsoredPassThresholdBps: null, teamAddress: null, isOptimisticGovernanceEnabled: null, + baseToSupermajority: null, }, }) .instruction(); diff --git a/tests/futarchy/unit/initializeDao.test.ts b/tests/futarchy/unit/initializeDao.test.ts index 110c3faae..a93cd0d56 100644 --- a/tests/futarchy/unit/initializeDao.test.ts +++ b/tests/futarchy/unit/initializeDao.test.ts @@ -33,6 +33,7 @@ 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), initialSpendingLimit: null, @@ -76,6 +77,8 @@ 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); @@ -132,6 +135,7 @@ export default function suite() { minQuoteFutarchicLiquidity: new BN(1), minBaseFutarchicLiquidity: new BN(1000), baseToStake: new BN(1000), + baseToSupermajority: new BN(0), passThresholdBps: 300, nonce: new BN(420), initialSpendingLimit: { @@ -214,6 +218,7 @@ export default function suite() { nonce: new BN(9999), initialSpendingLimit: null, baseToStake: new BN(1000), + baseToSupermajority: new BN(0), teamSponsoredPassThresholdBps: 1500, teamAddress: this.payer.publicKey, }, @@ -243,6 +248,7 @@ export default function suite() { nonce: new BN(1338), initialSpendingLimit: null, baseToStake: new BN(1000), + baseToSupermajority: new BN(0), teamSponsoredPassThresholdBps: 123, teamAddress: this.payer.publicKey, }, @@ -250,4 +256,34 @@ 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), + passThresholdBps: 300, + nonce: new BN(7777), + 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 ec3bea394..c2dcc8c22 100644 --- a/tests/futarchy/unit/initializeProposal.test.ts +++ b/tests/futarchy/unit/initializeProposal.test.ts @@ -57,6 +57,7 @@ export default function suite() { members: [this.payer.publicKey], }, baseToStake: new BN(0), + baseToSupermajority: new BN(0), teamSponsoredPassThresholdBps: 0, teamAddress: this.payer.publicKey, }, @@ -92,6 +93,7 @@ export default function suite() { teamSponsoredPassThresholdBps: null, teamAddress: null, isOptimisticGovernanceEnabled: null, + baseToSupermajority: null, }, }) .instruction(); diff --git a/tests/futarchy/unit/initiateVaultSpendOptimisticProposal.test.ts b/tests/futarchy/unit/initiateVaultSpendOptimisticProposal.test.ts index db4dd15a0..1cff68bcb 100644 --- a/tests/futarchy/unit/initiateVaultSpendOptimisticProposal.test.ts +++ b/tests/futarchy/unit/initiateVaultSpendOptimisticProposal.test.ts @@ -59,6 +59,7 @@ export default function suite() { members: [this.payer.publicKey], }, baseToStake: new BN(0), + baseToSupermajority: new BN(0), teamSponsoredPassThresholdBps: 0, teamAddress: this.payer.publicKey, }, diff --git a/tests/futarchy/unit/launchProposal.test.ts b/tests/futarchy/unit/launchProposal.test.ts index a2a06198f..9b7cf7526 100644 --- a/tests/futarchy/unit/launchProposal.test.ts +++ b/tests/futarchy/unit/launchProposal.test.ts @@ -77,6 +77,7 @@ export default function suite() { members: [payer.publicKey], }, baseToStake, + baseToSupermajority: new BN(0), teamSponsoredPassThresholdBps: 300, teamAddress: payer.publicKey, }, diff --git a/tests/futarchy/unit/resizeDao.test.ts b/tests/futarchy/unit/resizeDao.test.ts new file mode 100644 index 000000000..73adee6d5 --- /dev/null +++ b/tests/futarchy/unit/resizeDao.test.ts @@ -0,0 +1,287 @@ +import { + ComputeBudgetProgram, + Keypair, + PublicKey, + SystemProgram, + Transaction, +} 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"; + +// 2.5M whole tokens — the DEFAULT_BASE_TO_SUPERMAJORITY_TOKENS the migration applies, +// scaled to base units by the base mint's on-chain decimals. +const SUPERMAJORITY_WHOLE = new BN(2_500_000); +const scaledSupermajority = (decimals: number) => + SUPERMAJORITY_WHOLE.mul(new BN(10).pow(new BN(decimals))); + +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`). Truncation does NOT work for Dao: its Option slack +// would leave the field's 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; + const BEFORE = AFTER - 8; // base_to_supermajority is a u64 + + 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 (current layout, no supermajority); 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 a 6-decimal DAO to the scaled 2.5M bar, preserving every other field", async function () { + const original = await this.futarchy.getDao(dao); + assert.equal(original.baseToSupermajority.toNumber(), 0); + + const { AFTER, BEFORE } = await makeOldLayout(this, dao); + + // A short Dao is NOT frozen (unlike a Proposal): it decodes with + // base_to_supermajority === 0, 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); + + await this.futarchy.futarchy.methods + .resizeDao() + .accounts({ dao, baseMint: META, 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.toString(), + scaledSupermajority(6).toString(), + ); + + // base_to_supermajority is the ONLY field that changed (0 -> scaled 2.5M); + // everything else round-trips untouched. + const a = JSON.parse(JSON.stringify(migrated)); + const b = JSON.parse(JSON.stringify(original)); + delete a.baseToSupermajority; + delete b.baseToSupermajority; + assert.deepEqual(a, b); + + // Idempotent: a second crank is a no-op (compute-budget bump for a unique sig). + await this.futarchy.futarchy.methods + .resizeDao() + .accounts({ dao, baseMint: META, payer: this.payer.publicKey }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 200_000 }), + ]) + .rpc(); + + const after2 = await this.banksClient.getAccount(dao); + assert.equal(after2.data.length, AFTER); + const migrated2 = await this.futarchy.getDao(dao); + assert.equal( + migrated2.baseToSupermajority.toString(), + scaledSupermajority(6).toString(), + ); + }); + + it("scales the supermajority bar by the base mint's on-chain decimals (9-decimal)", async function () { + const META9 = await this.createMint(this.payer.publicKey, 9); + const dao9 = await setupBasicDao({ + context: this, + baseMint: META9, + quoteMint: USDC, + }); + + await makeOldLayout(this, dao9); + + await this.futarchy.futarchy.methods + .resizeDao() + .accounts({ dao: dao9, baseMint: META9, payer: this.payer.publicKey }) + .rpc(); + + const migrated = await this.futarchy.getDao(dao9); + // 2.5M * 10^9, NOT the 6-decimal value — guards the on-chain decimals derivation. + assert.equal( + migrated.baseToSupermajority.toString(), + scaledSupermajority(9).toString(), + ); + }); + + it("rejects a base_mint that does not match the DAO", async function () { + await makeOldLayout(this, dao); + + // A fabricated mint (here also 6-decimal, but any mint) must be rejected: + // the permissionless crank binds base_mint to dao.base_mint. + const wrongMint = await this.createMint(this.payer.publicKey, 6); + + const callbacks = expectError( + "InvalidMint", + "resize_dao must reject a base_mint that isn't the DAO's", + ); + + await this.futarchy.futarchy.methods + .resizeDao() + .accounts({ dao, baseMint: wrongMint, payer: this.payer.publicKey }) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + 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, baseMint: META, 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("never lets the supermajority bar migrate in below base_to_stake (high floor)", async function () { + // base_to_stake above 2.5M whole tokens (at 6 decimals). + const highFloor = new BN(3_000_000).mul(new BN(10 ** 6)); + await makeOldLayout(this, dao, { baseToStake: highFloor }); + + await this.futarchy.futarchy.methods + .resizeDao() + .accounts({ dao, baseMint: META, payer: this.payer.publicKey }) + .rpc(); + + const migrated = await this.futarchy.getDao(dao); + // max(2.5M scaled, base_to_stake) == base_to_stake, not the flat default. + assert.equal(migrated.baseToStake.toString(), highFloor.toString()); + assert.equal(migrated.baseToSupermajority.toString(), highFloor.toString()); + }); + + 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, baseMint: META, 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 - 8; + 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, baseMint: META, 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 index ec5ccf5ee..d805e9d21 100644 --- a/tests/futarchy/unit/resizeProposal.test.ts +++ b/tests/futarchy/unit/resizeProposal.test.ts @@ -95,6 +95,7 @@ export default function suite() { teamSponsoredPassThresholdBps: null, teamAddress: null, isOptimisticGovernanceEnabled: null, + baseToSupermajority: null, }, }) .instruction(); diff --git a/tests/futarchy/unit/unstakeFromProposal.test.ts b/tests/futarchy/unit/unstakeFromProposal.test.ts index 96a31a728..abdf60e47 100644 --- a/tests/futarchy/unit/unstakeFromProposal.test.ts +++ b/tests/futarchy/unit/unstakeFromProposal.test.ts @@ -77,6 +77,7 @@ export default function suite() { teamSponsoredPassThresholdBps: null, teamAddress: null, isOptimisticGovernanceEnabled: null, + baseToSupermajority: null, }, }) .instruction(); diff --git a/tests/futarchy/unit/updateDao.test.ts b/tests/futarchy/unit/updateDao.test.ts index 50b8fc00e..f1237da74 100644 --- a/tests/futarchy/unit/updateDao.test.ts +++ b/tests/futarchy/unit/updateDao.test.ts @@ -18,9 +18,133 @@ import { } from "@metadaoproject/programs"; import BN from "bn.js"; import { setOptimisticGovernanceEnabled } 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; +}) { + 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, + ...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 }, + 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; @@ -51,6 +175,7 @@ export default function suite() { members: [this.payer.publicKey], }, baseToStake: new BN(0), + baseToSupermajority: new BN(0), teamSponsoredPassThresholdBps: 0, teamAddress: this.payer.publicKey, }, @@ -106,6 +231,7 @@ export default function suite() { teamAddress: null, twapStartDelaySeconds: null, isOptimisticGovernanceEnabled: null, + baseToSupermajority: null, }, }) .instruction(); @@ -407,6 +533,7 @@ export default function suite() { teamAddress: null, twapStartDelaySeconds: null, isOptimisticGovernanceEnabled: null, + baseToSupermajority: null, }, }) .instruction(); @@ -591,4 +718,93 @@ 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"); + }); } diff --git a/tests/integration/fullLaunch.test.ts b/tests/integration/fullLaunch.test.ts index b2914587c..8bce94f19 100644 --- a/tests/integration/fullLaunch.test.ts +++ b/tests/integration/fullLaunch.test.ts @@ -385,6 +385,7 @@ export default async function suite() { teamSponsoredPassThresholdBps: null, teamAddress: null, isOptimisticGovernanceEnabled: false, + baseToSupermajority: null, }, }) .instruction(); diff --git a/tests/integration/fullLaunch_v7.test.ts b/tests/integration/fullLaunch_v7.test.ts index e236370d0..7516f93f0 100644 --- a/tests/integration/fullLaunch_v7.test.ts +++ b/tests/integration/fullLaunch_v7.test.ts @@ -430,6 +430,7 @@ export default async function suite() { teamSponsoredPassThresholdBps: null, teamAddress: null, isOptimisticGovernanceEnabled: true, + baseToSupermajority: null, }, }) .instruction(); diff --git a/tests/integration/mintAndSwap.test.ts b/tests/integration/mintAndSwap.test.ts index 486268cc4..46ffb176d 100644 --- a/tests/integration/mintAndSwap.test.ts +++ b/tests/integration/mintAndSwap.test.ts @@ -44,6 +44,7 @@ export default async function test() { nonce, initialSpendingLimit: null, baseToStake: new BN(0), + baseToSupermajority: new BN(0), teamAddress: PublicKey.default, teamSponsoredPassThresholdBps: 0, }, diff --git a/tests/main.test.ts b/tests/main.test.ts index 731d881f6..dcd944803 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -526,6 +526,7 @@ before(async function () { baseToStake: new BN(0), teamSponsoredPassThresholdBps, teamAddress, + baseToSupermajority: new BN(0), }, provideLiquidity: true, }) diff --git a/tests/performancePackageV2/utils.ts b/tests/performancePackageV2/utils.ts index c9ab04597..4ba4365c7 100644 --- a/tests/performancePackageV2/utils.ts +++ b/tests/performancePackageV2/utils.ts @@ -355,6 +355,7 @@ export async function setupDaoForTwapTests(context: any): Promise { nonce, initialSpendingLimit: null, baseToStake: new BN(0), + baseToSupermajority: new BN(0), teamSponsoredPassThresholdBps: 300, teamAddress: context.payer.publicKey, }, diff --git a/tests/utils.ts b/tests/utils.ts index 4792adfeb..fbc35cdac 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -54,6 +54,7 @@ export async function setupBasicDao({ baseToStake: new BN(0), teamSponsoredPassThresholdBps, teamAddress: teamAddress || context.payer.publicKey, + baseToSupermajority: new BN(0), }, provideLiquidity: true, }) From e0bcf481eed1dbac767e3749278345d4ae4be762 Mon Sep 17 00:00:00 2001 From: Pileks Date: Thu, 18 Jun 2026 20:09:32 +0200 Subject: [PATCH 5/9] additional flaky test fixes --- .../unit/finalizeOptimisticProposal.test.ts | 8 ++++++-- tests/futarchy/unit/initializeDao.test.ts | 20 ++++++++++--------- .../futarchy/unit/initializeProposal.test.ts | 8 ++++++-- ...itiateVaultSpendOptimisticProposal.test.ts | 8 ++++++-- tests/futarchy/unit/launchProposal.test.ts | 8 ++++++-- tests/futarchy/unit/updateDao.test.ts | 4 ++-- tests/integration/mintAndSwap.test.ts | 3 ++- tests/main.test.ts | 3 ++- tests/performancePackageV2/utils.ts | 3 ++- tests/utils.ts | 11 +++++++++- 10 files changed, 53 insertions(+), 23 deletions(-) diff --git a/tests/futarchy/unit/finalizeOptimisticProposal.test.ts b/tests/futarchy/unit/finalizeOptimisticProposal.test.ts index 14c4f24f7..974564abc 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({ diff --git a/tests/futarchy/unit/initializeDao.test.ts b/tests/futarchy/unit/initializeDao.test.ts index a93cd0d56..2aa370adb 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, @@ -35,7 +36,7 @@ export default function suite() { baseToStake: new BN(1000), baseToSupermajority: new BN(5000), passThresholdBps: 300, - nonce: new BN(1337), + nonce, initialSpendingLimit: null, teamAddress: this.payer.publicKey, teamSponsoredPassThresholdBps: 123, @@ -47,7 +48,7 @@ export default function suite() { .rpc(); const [dao, daoBump] = getDaoAddr({ - nonce: new BN(1337), + nonce, daoCreator: this.payer.publicKey, }); @@ -58,7 +59,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( @@ -122,6 +123,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({ @@ -137,7 +139,7 @@ export default function suite() { baseToStake: new BN(1000), baseToSupermajority: new BN(0), passThresholdBps: 300, - nonce: new BN(420), + nonce, initialSpendingLimit: { // 10k per month burn amountPerMonth: new BN(10_000 * 10 ** 6), @@ -150,7 +152,7 @@ export default function suite() { .rpc(); const [dao] = getDaoAddr({ - nonce: new BN(420), + nonce, daoCreator: this.payer.publicKey, }); @@ -215,7 +217,7 @@ 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), @@ -245,7 +247,7 @@ 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), @@ -277,7 +279,7 @@ export default function suite() { baseToStake: new BN(1000), baseToSupermajority: new BN(999), passThresholdBps: 300, - nonce: new BN(7777), + nonce: nextDaoNonce(), initialSpendingLimit: null, teamAddress: this.payer.publicKey, teamSponsoredPassThresholdBps: 123, diff --git a/tests/futarchy/unit/initializeProposal.test.ts b/tests/futarchy/unit/initializeProposal.test.ts index c2dcc8c22..2f989310c 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({ diff --git a/tests/futarchy/unit/initiateVaultSpendOptimisticProposal.test.ts b/tests/futarchy/unit/initiateVaultSpendOptimisticProposal.test.ts index 1cff68bcb..e3992990c 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({ diff --git a/tests/futarchy/unit/launchProposal.test.ts b/tests/futarchy/unit/launchProposal.test.ts index 9b7cf7526..ecd92e268 100644 --- a/tests/futarchy/unit/launchProposal.test.ts +++ b/tests/futarchy/unit/launchProposal.test.ts @@ -12,7 +12,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"; @@ -57,7 +61,7 @@ export default function suite() { baseToStake: BN, payer: Keypair, ): Promise { - const nonce = new BN(Math.floor(Math.random() * 1000000)); + const nonce = nextDaoNonce(); await context.futarchy .initializeDaoIx({ diff --git a/tests/futarchy/unit/updateDao.test.ts b/tests/futarchy/unit/updateDao.test.ts index f1237da74..162860453 100644 --- a/tests/futarchy/unit/updateDao.test.ts +++ b/tests/futarchy/unit/updateDao.test.ts @@ -17,7 +17,7 @@ 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); @@ -155,7 +155,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({ diff --git a/tests/integration/mintAndSwap.test.ts b/tests/integration/mintAndSwap.test.ts index 46ffb176d..a029bc665 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({ diff --git a/tests/main.test.ts b/tests/main.test.ts index dcd944803..b6ce12311 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -86,6 +86,7 @@ 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); @@ -507,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({ diff --git a/tests/performancePackageV2/utils.ts b/tests/performancePackageV2/utils.ts index 4ba4365c7..2524c422f 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({ diff --git a/tests/utils.ts b/tests/utils.ts index fbc35cdac..34e5da978 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({ @@ -35,7 +44,7 @@ export async function setupBasicDao({ teamSponsoredPassThresholdBps?: number; teamAddress?: PublicKey; }) { - const nonce = new BN(Math.floor(Math.random() * 1000000)); + const nonce = nextDaoNonce(); await context.futarchy .initializeDaoIx({ From 2d8ebb7ff028c6fbc5449e4c0589828e1e4f3a18 Mon Sep 17 00:00:00 2001 From: Pileks Date: Thu, 18 Jun 2026 23:00:02 +0200 Subject: [PATCH 6/9] two-path launch gate: 2-of-3 approval points or supermajority --- programs/futarchy/src/error.rs | 2 + .../src/instructions/launch_proposal.rs | 42 +- programs/futarchy/src/lib.rs | 3 + sdk/src/futarchy/v0.6/types/futarchy.ts | 10 + .../futarchy/integration/futarchyAmm.test.ts | 2 + .../futarchy/unit/adminCancelProposal.test.ts | 2 + .../futarchy/unit/adminRemoveProposal.test.ts | 2 + tests/futarchy/unit/approveProposal.test.ts | 4 +- .../executeMultisigProposalApproval.test.ts | 2 + tests/futarchy/unit/finalizeProposal.test.ts | 2 + tests/futarchy/unit/launchProposal.test.ts | 739 +++++++----------- .../futarchy/unit/unstakeFromProposal.test.ts | 7 + tests/futarchy/unit/updateDao.test.ts | 6 + tests/integration/fullLaunch.test.ts | 2 + tests/integration/fullLaunch_v7.test.ts | 2 + tests/main.test.ts | 3 + 16 files changed, 378 insertions(+), 452 deletions(-) diff --git a/programs/futarchy/src/error.rs b/programs/futarchy/src/error.rs index d723d0cb8..55e45dd84 100644 --- a/programs/futarchy/src/error.rs +++ b/programs/futarchy/src/error.rs @@ -96,4 +96,6 @@ pub enum FutarchyError { ProposalAlreadyApproved, #[msg("base_to_supermajority must be 0 (disabled) or >= base_to_stake")] InvalidSupermajorityThreshold, + #[msg("Proposal lacks enough approval points to launch")] + InsufficientApprovalToLaunch, } diff --git a/programs/futarchy/src/instructions/launch_proposal.rs b/programs/futarchy/src/instructions/launch_proposal.rs index 7816215c8..d773081cf 100644 --- a/programs/futarchy/src/instructions/launch_proposal.rs +++ b/programs/futarchy/src/instructions/launch_proposal.rs @@ -46,16 +46,38 @@ 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 + }; + + // 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 + ); // If there is an active optimistic proposal, it must be for the same squads proposal // as the futarchy proposal we're launching, thus challenging the optimistic proposal. diff --git a/programs/futarchy/src/lib.rs b/programs/futarchy/src/lib.rs index 959c82dc2..d2ffcf2fe 100644 --- a/programs/futarchy/src/lib.rs +++ b/programs/futarchy/src/lib.rs @@ -64,6 +64,9 @@ pub const MIN_PROPOSAL_UNSTAKE_DELAY_SECONDS: i64 = 5; // 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::*; diff --git a/sdk/src/futarchy/v0.6/types/futarchy.ts b/sdk/src/futarchy/v0.6/types/futarchy.ts index a025c8290..8da018253 100644 --- a/sdk/src/futarchy/v0.6/types/futarchy.ts +++ b/sdk/src/futarchy/v0.6/types/futarchy.ts @@ -4023,6 +4023,11 @@ export type Futarchy = { 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"; + }, ]; }; @@ -8051,5 +8056,10 @@ export const IDL: Futarchy = { 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", + }, ], }; diff --git a/tests/futarchy/integration/futarchyAmm.test.ts b/tests/futarchy/integration/futarchyAmm.test.ts index 496c7aa50..c7e05fbd6 100644 --- a/tests/futarchy/integration/futarchyAmm.test.ts +++ b/tests/futarchy/integration/futarchyAmm.test.ts @@ -215,6 +215,8 @@ export default function suite() { const proposalAccount = await this.futarchy.getProposal(proposal); + await this.futarchy.approveProposalIx({ proposal, dao }).rpc(); + await this.futarchy .launchProposalIx({ proposal, diff --git a/tests/futarchy/unit/adminCancelProposal.test.ts b/tests/futarchy/unit/adminCancelProposal.test.ts index 7e6b41777..d492cf103 100644 --- a/tests/futarchy/unit/adminCancelProposal.test.ts +++ b/tests/futarchy/unit/adminCancelProposal.test.ts @@ -118,6 +118,8 @@ export default function suite() { proposal = await this.futarchy.initializeProposal(dao, squadsProposalPda); + await this.futarchy.approveProposalIx({ proposal, dao }).rpc(); + await this.futarchy .launchProposalIx({ proposal, diff --git a/tests/futarchy/unit/adminRemoveProposal.test.ts b/tests/futarchy/unit/adminRemoveProposal.test.ts index ab9860e22..b51ad8399 100644 --- a/tests/futarchy/unit/adminRemoveProposal.test.ts +++ b/tests/futarchy/unit/adminRemoveProposal.test.ts @@ -226,6 +226,8 @@ export default function suite() { transactionIndex: 1n, }); + await this.futarchy.approveProposalIx({ proposal, dao }).rpc(); + // Launch the proposal to move it to Pending state await this.futarchy .launchProposalIx({ diff --git a/tests/futarchy/unit/approveProposal.test.ts b/tests/futarchy/unit/approveProposal.test.ts index 1d97bb888..a508a993f 100644 --- a/tests/futarchy/unit/approveProposal.test.ts +++ b/tests/futarchy/unit/approveProposal.test.ts @@ -158,7 +158,9 @@ export default function suite() { }); it("rejects approval once the proposal has left Draft", async function () { - // baseToStake is 0, so the proposal launches with no stake and no sponsor. + // Sponsor so the proposal can launch without being MetaDAO-approved. + await this.futarchy.sponsorProposalIx({ proposal, dao }).rpc(); + await this.futarchy .launchProposalIx({ proposal, diff --git a/tests/futarchy/unit/executeMultisigProposalApproval.test.ts b/tests/futarchy/unit/executeMultisigProposalApproval.test.ts index 233118d18..ce9309214 100644 --- a/tests/futarchy/unit/executeMultisigProposalApproval.test.ts +++ b/tests/futarchy/unit/executeMultisigProposalApproval.test.ts @@ -225,6 +225,8 @@ export default function suite() { ); // Now launch the futarchy proposal to push the AMM out of Spot. + await this.futarchy.approveProposalIx({ proposal, dao }).rpc(); + const storedDao = await this.futarchy.getDao(dao); await this.futarchy .launchProposalIx({ diff --git a/tests/futarchy/unit/finalizeProposal.test.ts b/tests/futarchy/unit/finalizeProposal.test.ts index f1a39a774..dc2f39ab2 100644 --- a/tests/futarchy/unit/finalizeProposal.test.ts +++ b/tests/futarchy/unit/finalizeProposal.test.ts @@ -120,6 +120,8 @@ export default function suite() { // Now initialize the futarchy proposal proposal = await this.futarchy.initializeProposal(dao, squadsProposalPda); + await this.futarchy.approveProposalIx({ proposal, dao }).rpc(); + await this.futarchy .launchProposalIx({ proposal, diff --git a/tests/futarchy/unit/launchProposal.test.ts b/tests/futarchy/unit/launchProposal.test.ts index ecd92e268..8862ce951 100644 --- a/tests/futarchy/unit/launchProposal.test.ts +++ b/tests/futarchy/unit/launchProposal.test.ts @@ -6,7 +6,6 @@ import { } from "@metadaoproject/programs"; import { ComputeBudgetProgram, - Keypair, PublicKey, Transaction, TransactionMessage, @@ -22,12 +21,12 @@ 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); @@ -37,36 +36,37 @@ 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. 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), + }: { baseToStake: BN; baseToSupermajority?: BN }, ): Promise { const nonce = nextDaoNonce(); await context.futarchy .initializeDaoIx({ - baseMint, - quoteMint, + baseMint: META, + quoteMint: USDC, params: { secondsPerProposal: 60 * 60 * 24 * 3, twapStartDelaySeconds: 60 * 60 * 24, @@ -78,12 +78,12 @@ export default function suite() { nonce, initialSpendingLimit: { amountPerMonth: spendingLimit, - members: [payer.publicKey], + members: [context.payer.publicKey], }, baseToStake, - baseToSupermajority: new BN(0), + baseToSupermajority, teamSponsoredPassThresholdBps: 300, - teamAddress: payer.publicKey, + teamAddress: context.payer.publicKey, }, provideLiquidity: true, }) @@ -92,18 +92,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 }> { @@ -121,6 +134,8 @@ export default function suite() { twapStartDelaySeconds: null, teamSponsoredPassThresholdBps: null, teamAddress: null, + isOptimisticGovernanceEnabled: null, + baseToSupermajority: null, }, }) .instruction(); @@ -169,106 +184,61 @@ 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). Such a challenger auto-earns the team + // approval point in the launch gate. + 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, @@ -277,288 +247,213 @@ 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", - ); + // ---- 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) ---- - // 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 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); - const { proposal, squadsProposal } = await initializeProposal(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); - // Stake exactly the threshold amount - await this.futarchy - .stakeToProposalIx({ - proposal, - dao, - baseMint: META, - amount: stakeThreshold, - }) - .rpc(); + await launch(this, proposal, dao, squadsProposal); + const stored = await this.futarchy.getProposal(proposal); + assert.exists(stored.state.pending); + }); - // Launch should succeed at exact threshold - await this.futarchy - .launchProposalIx({ - proposal, - dao, - baseMint: META, - quoteMint: USDC, - squadsProposal, - }) - .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 storedProposal = await this.futarchy.getProposal(proposal); - assert.exists( - storedProposal.state.pending, - "Proposal should be in pending state after launch", + const callbacks = expectError( + "InsufficientApprovalToLaunch", + "stake one below the supermajority must not launch", + ); + await launch(this, proposal, dao, squadsProposal).then( + callbacks[0], + callbacks[1], ); }); - it("sets proposal duration_in_seconds to DAO's current seconds_per_proposal on launch", async function () { - const THREE_DAYS = 60 * 60 * 24 * 3; // 259200 - const FIVE_DAYS = 60 * 60 * 24 * 5; // 432000 + it("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 - // Create DAO with secondsPerProposal = 3 days - const dao = await createDaoWithStakeThreshold( - this, - META, - USDC, - new BN(0), - this.payer, + // 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); - // 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 launch(this, enabled.proposal, enabledDao, enabled.squadsProposal); + const stored = await this.futarchy.getProposal(enabled.proposal); + assert.exists(stored.state.pending); + }); - const { proposal, squadsProposal } = await initializeProposal(this, dao); + // ---- Optimistic-challenge gate: challenging an active optimistic proposal + // auto-earns the team point, so the challenge costs the same as today. ---- - // Sponsor the proposal - await this.futarchy - .sponsorProposalIx({ - proposal, - dao, - teamAddress: this.payer.publicKey, - }) - .rpc(); + 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, + ); - // Verify proposal has the original duration (3 days) - const proposalBefore = await this.futarchy.getProposal(proposal); - assert.equal(proposalBefore.durationInSeconds, THREE_DAYS); + await stake(this, proposal, dao, BASE_TO_STAKE); - // 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); + await launch(this, proposal, dao, squadsProposal); + const stored = await this.futarchy.getProposal(proposal); + assert.exists(stored.state.pending); - // Launch the proposal - await this.futarchy - .launchProposalIx({ - proposal, - dao, - baseMint: META, - quoteMint: USDC, - squadsProposal, - }) - .rpc(); - - // Verify proposal picked up the new DAO duration - const storedProposal = await this.futarchy.getProposal(proposal); - assert.equal(storedProposal.durationInSeconds, FIVE_DAYS); + const daoAccount = await this.futarchy.getDao(dao); + assert.notExists(daoAccount.optimisticProposal); }); - it("fails for non-team-sponsored with insufficient stake", async function () { - const stakeThreshold = new BN(100 * 10 ** 6); // 100 tokens - const dao = await createDaoWithStakeThreshold( + 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, - META, - USDC, - stakeThreshold, - this.payer, + dao, ); - // 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 stake(this, proposal, dao, BASE_TO_STAKE.divn(2)); // below the floor - const { proposal, squadsProposal } = await initializeProposal(this, dao); - - // 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(); - - // Launch should fail with InsufficientStakeToLaunch const callbacks = expectError( - "InsufficientStakeToLaunch", - "Launch should fail when stake is below threshold", + "InsufficientApprovalToLaunch", + "a sub-floor optimistic challenge has only the auto team point", + ); + await launch(this, proposal, dao, squadsProposal).then( + callbacks[0], + callbacks[1], ); - - await this.futarchy - .launchProposalIx({ - proposal, - 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( + 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, - META, - USDC, - stakeThreshold, - this.payer, - ); - - // 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(); - - // Mint DAO tokens to payer's account - await this.mintTo( - META, - this.payer.publicKey, - this.payer, - 10_000_000 * 10 ** 6, + dao, ); - let daoAccount = await this.futarchy.getDao(dao); + await stake(this, proposal, dao, BASE_TO_STAKE.divn(2)); // below the floor + await this.futarchy.approveProposalIx({ proposal, dao }).rpc(); - await this.createTokenAccount(USDC, daoAccount.squadsMultisigVault); - - await setOptimisticGovernanceEnabled(this, dao, true); - - await this.futarchy - .initiateVaultSpendOptimisticProposalIx({ - 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); - - assert.exists(daoAccount.optimisticProposal); - - const [squadsProposal] = multisig.getProposalPda({ - multisigPda: daoAccount.squadsMultisig, - transactionIndex: 1n, - }); - - await this.futarchy.initializeProposal(dao, squadsProposal); + await launch(this, proposal, dao, squadsProposal); + const stored = await this.futarchy.getProposal(proposal); + assert.exists(stored.state.pending); + }); - const [proposal] = getProposalAddrV2({ squadsProposal }); + // ---- Existing optimistic-launch behaviour, re-derived against the new gate. + // The 1M stake is >= base_to_stake, so stake + auto team = 2 points. ---- - await this.futarchy - .stakeToProposalIx({ - amount: new BN(1_000_000 * 10 ** 6), - proposal, - dao, - baseMint: META, - }) - .rpc(); + 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, + ); - await this.futarchy - .launchProposalIx({ - proposal, - dao, - baseMint: META, - quoteMint: USDC, - squadsProposal, - }) - .rpc(); + await stake(this, proposal, dao, new BN(1_000_000 * 10 ** 6)); + await launch(this, proposal, dao, squadsProposal); - // Assert that the optimistic proposal has been migrated to the futarchy proposal - daoAccount = await this.futarchy.getDao(dao); + const daoAccount = await this.futarchy.getDao(dao); assert.notExists(daoAccount.optimisticProposal); const proposalAccount = await this.futarchy.getProposal(proposal); @@ -569,97 +464,59 @@ export default function suite() { ); }); - 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( + 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, - META, - USDC, - stakeThreshold, - this.payer, - ); - - // 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(); - - // Mint DAO tokens to payer's account - await this.mintTo( - META, - this.payer.publicKey, - this.payer, - 10_000_000 * 10 ** 6, + dao, ); - let daoAccount = await this.futarchy.getDao(dao); - - await this.createTokenAccount(USDC, daoAccount.squadsMultisigVault); + const daoAccount = await this.futarchy.getDao(dao); + this.advanceBySeconds(daoAccount.secondsPerProposal); - await setOptimisticGovernanceEnabled(this, dao, true); + await stake(this, proposal, dao, new BN(1_000_000 * 10 ** 6)); - await this.futarchy - .initiateVaultSpendOptimisticProposalIx({ - dao, - amount: new BN(transferAmount), - recipient: this.payer.publicKey, - transactionIndex: 1n, - quoteMint: USDC, - }) - .signers([this.payer, PERMISSIONLESS_ACCOUNT]) - .rpc(); + const callbacks = expectError( + "OptimisticProposalAlreadyPassed", + "optimistic proposal has already passed", + ); + await launch(this, proposal, dao, squadsProposal).then( + callbacks[0], + callbacks[1], + ); + }); - daoAccount = await this.futarchy.getDao(dao); + // ---- Orthogonal: launch refreshes duration_in_seconds from the DAO. ---- - assert.exists(daoAccount.optimisticProposal); + 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; - const [squadsProposal] = multisig.getProposalPda({ - multisigPda: daoAccount.squadsMultisig, - transactionIndex: 1n, - }); - - // Initialize the futarchy proposal before the optimistic proposal is auto-approved - await this.futarchy.initializeProposal(dao, squadsProposal); + const dao = await createDao(this, { baseToStake: new BN(0) }); + await provideLiquidity(this, dao); + const { proposal, squadsProposal } = await initDraftProposal(this, dao); - this.advanceBySeconds(daoAccount.secondsPerProposal); + await this.futarchy.sponsorProposalIx({ proposal, dao }).rpc(); - const [proposal] = getProposalAddrV2({ squadsProposal }); + const proposalBefore = await this.futarchy.getProposal(proposal); + assert.equal(proposalBefore.durationInSeconds, THREE_DAYS); - await this.futarchy - .stakeToProposalIx({ - amount: new BN(1_000_000 * 10 ** 6), - proposal, - dao, - baseMint: META, - }) - .rpc(); + // 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 callbacks = expectError( - "OptimisticProposalAlreadyPassed", - "Optimistic proposal has already passed", - ); + await launch(this, proposal, dao, squadsProposal); - await this.futarchy - .launchProposalIx({ - proposal, - dao, - baseMint: META, - quoteMint: USDC, - squadsProposal, - }) - .rpc() - .then(callbacks[0], callbacks[1]); + const stored = await this.futarchy.getProposal(proposal); + assert.equal(stored.durationInSeconds, FIVE_DAYS); }); } diff --git a/tests/futarchy/unit/unstakeFromProposal.test.ts b/tests/futarchy/unit/unstakeFromProposal.test.ts index abdf60e47..2d339ae69 100644 --- a/tests/futarchy/unit/unstakeFromProposal.test.ts +++ b/tests/futarchy/unit/unstakeFromProposal.test.ts @@ -166,6 +166,8 @@ export default function suite() { }) .rpc(); + await this.futarchy.approveProposalIx({ proposal, dao }).rpc(); + await this.futarchy .launchProposalIx({ proposal, @@ -211,6 +213,8 @@ export default function suite() { }) .rpc(); + await this.futarchy.approveProposalIx({ proposal, dao }).rpc(); + await this.futarchy .launchProposalIx({ proposal, @@ -244,6 +248,9 @@ export default function suite() { it("fails when unstaking in the same transaction as launch (flash-loan)", async function () { const stakeAmount = new BN(100 * 10 ** 6); + // Approve up front (separate tx) so the bundled launch has its second point. + await this.futarchy.approveProposalIx({ proposal, dao }).rpc(); + // stakeIxs includes the SDK's ATA-creation preInstructions for the // staker/proposal base accounts, which we need because this is the // first stake on this proposal. diff --git a/tests/futarchy/unit/updateDao.test.ts b/tests/futarchy/unit/updateDao.test.ts index 162860453..68c725c56 100644 --- a/tests/futarchy/unit/updateDao.test.ts +++ b/tests/futarchy/unit/updateDao.test.ts @@ -316,6 +316,8 @@ export default function suite() { .splitTokensIx(question, quoteVault, USDC, new BN(1000_000_000), 2) .rpc(); + await this.futarchy.approveProposalIx({ proposal: proposalA, dao }).rpc(); + // Launch proposal A to put DAO in Futarchy state await this.futarchy .launchProposalIx({ @@ -470,6 +472,8 @@ export default function suite() { ]) .rpc(); + await this.futarchy.approveProposalIx({ proposal: proposalB, dao }).rpc(); + // Launch proposal B to put DAO in Futarchy state await this.futarchy .launchProposalIx({ @@ -618,6 +622,8 @@ export default function suite() { .splitTokensIx(question, quoteVault, USDC, new BN(1000_000_000), 2) .rpc(); + await this.futarchy.approveProposalIx({ proposal: proposalA, dao }).rpc(); + // Launch proposal A to put DAO in Futarchy state await this.futarchy .launchProposalIx({ diff --git a/tests/integration/fullLaunch.test.ts b/tests/integration/fullLaunch.test.ts index 8bce94f19..edda96c01 100644 --- a/tests/integration/fullLaunch.test.ts +++ b/tests/integration/fullLaunch.test.ts @@ -461,6 +461,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 7516f93f0..34f07cd78 100644 --- a/tests/integration/fullLaunch_v7.test.ts +++ b/tests/integration/fullLaunch_v7.test.ts @@ -506,6 +506,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/main.test.ts b/tests/main.test.ts index b6ce12311..7e75b73d4 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -669,6 +669,9 @@ before(async function () { const { proposal, question, baseVault, quoteVault, squadsProposal } = await this.initializeProposal({ dao, instructions }); const storedDao = await this.futarchy.getDao(dao); + + await this.futarchy.approveProposalIx({ proposal, dao }).rpc(); + await this.futarchy .launchProposalIx({ proposal, From d0302526ddd38fefd16991dabe150f26f6440efc Mon Sep 17 00:00:00 2001 From: Pileks Date: Thu, 18 Jun 2026 23:05:26 +0200 Subject: [PATCH 7/9] modify comments --- programs/futarchy/src/instructions/approve_proposal.rs | 1 - programs/futarchy/src/instructions/resize_dao.rs | 4 +--- programs/futarchy/src/state/dao.rs | 2 +- 3 files changed, 2 insertions(+), 5 deletions(-) diff --git a/programs/futarchy/src/instructions/approve_proposal.rs b/programs/futarchy/src/instructions/approve_proposal.rs index fe1c8cfac..099ff85b8 100644 --- a/programs/futarchy/src/instructions/approve_proposal.rs +++ b/programs/futarchy/src/instructions/approve_proposal.rs @@ -2,7 +2,6 @@ use super::*; mod metadao_approver { use anchor_lang::prelude::declare_id; - // METADAO_MULTISIG_VAULT (sdk/src/constants.ts) declare_id!("6awyHMshBGVjJ3ozdSJdyyDE1CTAXUwrpNMaRGMsb4sf"); } diff --git a/programs/futarchy/src/instructions/resize_dao.rs b/programs/futarchy/src/instructions/resize_dao.rs index ff7e72d0c..3ce2ae630 100644 --- a/programs/futarchy/src/instructions/resize_dao.rs +++ b/programs/futarchy/src/instructions/resize_dao.rs @@ -43,8 +43,7 @@ impl ResizeDao<'_> { FutarchyError::InvalidMint ); - // 2.5M WHOLE tokens scaled to base units by the base mint's on-chain decimals; - // checked so a pathological high-decimal mint errors rather than silently wrapping. + // 2.5M WHOLE tokens scaled to base units by the base mint's on-chain decimals let scaled = DEFAULT_BASE_TO_SUPERMAJORITY_TOKENS .checked_mul( 10u64 @@ -53,7 +52,6 @@ impl ResizeDao<'_> { ) .ok_or(FutarchyError::CastingOverflow)?; // Never below the DAO's own base_to_stake floor, so the supermajority bar can't become the *easier* path. - // Satisfies the `base_to_supermajority >= base_to_stake` invariant by construction. let base_to_supermajority = scaled.max(old_dao_data.base_to_stake); let new_dao_data = Dao { diff --git a/programs/futarchy/src/state/dao.rs b/programs/futarchy/src/state/dao.rs index 4e1b09cac..55f22ffce 100644 --- a/programs/futarchy/src/state/dao.rs +++ b/programs/futarchy/src/state/dao.rs @@ -138,7 +138,7 @@ impl Dao { // 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. Equality is benign (use `>=`, not `>`). + // token-holder point. `0` disables it. require!( self.base_to_supermajority == 0 || self.base_to_supermajority >= self.base_to_stake, FutarchyError::InvalidSupermajorityThreshold From edb475a8d69bfe5485868f558612160a474cf53d Mon Sep 17 00:00:00 2001 From: Pileks Date: Tue, 23 Jun 2026 23:22:48 +0200 Subject: [PATCH 8/9] allow both proposal validation and legacy paths for proposal launch --- programs/futarchy/src/events.rs | 2 + .../src/instructions/initialize_dao.rs | 4 + .../src/instructions/launch_proposal.rs | 59 +- .../futarchy/src/instructions/resize_dao.rs | 32 +- .../futarchy/src/instructions/update_dao.rs | 5 + programs/futarchy/src/state/dao.rs | 4 + .../src/instructions/complete_launch.rs | 1 + .../src/instructions/complete_launch.rs | 1 + .../src/instructions/settle_launch.rs | 1 + scripts/v0.6/createDao.ts | 2 + scripts/v0.6/initializeDao.ts | 2 + sdk/src/futarchy/v0.6/types/futarchy.ts | 78 ++- .../futarchy/integration/futarchyAmm.test.ts | 1 + .../futarchy/unit/adminCancelProposal.test.ts | 3 +- .../futarchy/unit/adminRemoveProposal.test.ts | 3 +- tests/futarchy/unit/approveProposal.test.ts | 1 + .../unit/finalizeOptimisticProposal.test.ts | 1 + tests/futarchy/unit/finalizeProposal.test.ts | 4 +- tests/futarchy/unit/initializeDao.test.ts | 7 + .../futarchy/unit/initializeProposal.test.ts | 2 + ...itiateVaultSpendOptimisticProposal.test.ts | 1 + tests/futarchy/unit/launchProposal.test.ts | 609 +++++++++++------- tests/futarchy/unit/resizeDao.test.ts | 128 +--- tests/futarchy/unit/resizeProposal.test.ts | 1 + .../futarchy/unit/unstakeFromProposal.test.ts | 8 +- tests/futarchy/unit/updateDao.test.ts | 34 +- tests/integration/fullLaunch.test.ts | 1 + tests/integration/fullLaunch_v7.test.ts | 1 + tests/integration/mintAndSwap.test.ts | 1 + tests/main.test.ts | 1 + tests/performancePackageV2/utils.ts | 1 + tests/utils.ts | 3 + 32 files changed, 594 insertions(+), 408 deletions(-) diff --git a/programs/futarchy/src/events.rs b/programs/futarchy/src/events.rs index 98ee6d327..102a6120f 100644 --- a/programs/futarchy/src/events.rs +++ b/programs/futarchy/src/events.rs @@ -54,6 +54,7 @@ pub struct InitializeDaoEvent { pub team_sponsored_pass_threshold_bps: i16, pub team_address: Pubkey, pub base_to_supermajority: u64, + pub is_proposal_validation_enabled: bool, } #[event] @@ -72,6 +73,7 @@ pub struct UpdateDaoEvent { pub team_address: Pubkey, pub is_optimistic_governance_enabled: bool, pub base_to_supermajority: u64, + pub is_proposal_validation_enabled: bool, } #[event] diff --git a/programs/futarchy/src/instructions/initialize_dao.rs b/programs/futarchy/src/instructions/initialize_dao.rs index fd6fdd98e..54cab890e 100644 --- a/programs/futarchy/src/instructions/initialize_dao.rs +++ b/programs/futarchy/src/instructions/initialize_dao.rs @@ -19,6 +19,7 @@ pub struct InitializeDaoParams { pub team_sponsored_pass_threshold_bps: i16, pub team_address: Pubkey, pub base_to_supermajority: u64, + pub is_proposal_validation_enabled: bool, } #[derive(Accounts)] @@ -95,6 +96,7 @@ impl InitializeDao<'_> { team_sponsored_pass_threshold_bps, team_address, base_to_supermajority, + is_proposal_validation_enabled, } = params; let dao = &mut ctx.accounts.dao; @@ -226,6 +228,7 @@ impl InitializeDao<'_> { optimistic_proposal: None, is_optimistic_governance_enabled: false, base_to_supermajority, + is_proposal_validation_enabled, }); dao.invariant()?; @@ -252,6 +255,7 @@ impl InitializeDao<'_> { 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/launch_proposal.rs b/programs/futarchy/src/instructions/launch_proposal.rs index d773081cf..e89fbe911 100644 --- a/programs/futarchy/src/instructions/launch_proposal.rs +++ b/programs/futarchy/src/instructions/launch_proposal.rs @@ -51,33 +51,40 @@ impl LaunchProposal<'_> { _ => unreachable!(), // Draft already asserted above }; - // 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; + 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 + ); - require!( - points >= LAUNCH_APPROVAL_POINTS_REQUIRED || supermajority_reached, - FutarchyError::InsufficientApprovalToLaunch - ); + // 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 // as the futarchy proposal we're launching, thus challenging the optimistic proposal. diff --git a/programs/futarchy/src/instructions/resize_dao.rs b/programs/futarchy/src/instructions/resize_dao.rs index 3ce2ae630..99e6c8df2 100644 --- a/programs/futarchy/src/instructions/resize_dao.rs +++ b/programs/futarchy/src/instructions/resize_dao.rs @@ -7,10 +7,6 @@ pub struct ResizeDao<'info> { /// CHECK: we check the discriminator #[account(mut)] pub dao: UncheckedAccount<'info>, - /// The DAO's base mint, bound to `old.base_mint` below. Because this crank is - /// permissionless, the mint must be bound so a caller can't pass a fabricated - /// low-decimal mint to set a near-zero supermajority bar. - pub base_mint: Account<'info, Mint>, #[account(mut)] pub payer: Signer<'info>, pub system_program: Program<'info, System>, @@ -25,8 +21,8 @@ impl ResizeDao<'_> { require_eq!(is_discriminator_correct, true); const AFTER_REALLOC_SIZE: usize = Dao::INIT_SPACE + 8; - // 8 bytes: base_to_supermajority (u64) - const BEFORE_REALLOC_SIZE: usize = AFTER_REALLOC_SIZE - 8; + // 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 @@ -36,24 +32,9 @@ impl ResizeDao<'_> { let old_dao_data = OldDao::deserialize(&mut &dao.try_borrow_data().unwrap()[8..])?; - // Bind the passed mint to the DAO before trusting its decimals. - require_keys_eq!( - ctx.accounts.base_mint.key(), - old_dao_data.base_mint, - FutarchyError::InvalidMint - ); - - // 2.5M WHOLE tokens scaled to base units by the base mint's on-chain decimals - let scaled = DEFAULT_BASE_TO_SUPERMAJORITY_TOKENS - .checked_mul( - 10u64 - .checked_pow(ctx.accounts.base_mint.decimals as u32) - .ok_or(FutarchyError::CastingOverflow)?, - ) - .ok_or(FutarchyError::CastingOverflow)?; - // Never below the DAO's own base_to_stake floor, so the supermajority bar can't become the *easier* path. - let base_to_supermajority = scaled.max(old_dao_data.base_to_stake); - + // 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, @@ -79,7 +60,8 @@ impl ResizeDao<'_> { team_address: old_dao_data.team_address, optimistic_proposal: old_dao_data.optimistic_proposal, is_optimistic_governance_enabled: old_dao_data.is_optimistic_governance_enabled, - base_to_supermajority, + base_to_supermajority: 0, + is_proposal_validation_enabled: false, }; dao.realloc(AFTER_REALLOC_SIZE, true)?; diff --git a/programs/futarchy/src/instructions/update_dao.rs b/programs/futarchy/src/instructions/update_dao.rs index c95373d9c..23c8e8c1b 100644 --- a/programs/futarchy/src/instructions/update_dao.rs +++ b/programs/futarchy/src/instructions/update_dao.rs @@ -14,6 +14,7 @@ pub struct UpdateDaoParams { pub team_address: Option, pub is_optimistic_governance_enabled: Option, pub base_to_supermajority: Option, + pub is_proposal_validation_enabled: Option, } #[derive(Accounts)] @@ -87,6 +88,9 @@ impl UpdateDao<'_> { 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; @@ -109,6 +113,7 @@ impl UpdateDao<'_> { 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/state/dao.rs b/programs/futarchy/src/state/dao.rs index 55f22ffce..afa44291a 100644 --- a/programs/futarchy/src/state/dao.rs +++ b/programs/futarchy/src/state/dao.rs @@ -69,6 +69,10 @@ pub struct Dao { /// 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)] diff --git a/programs/v06_launchpad/src/instructions/complete_launch.rs b/programs/v06_launchpad/src/instructions/complete_launch.rs index 0ce8610ee..6803faa85 100644 --- a/programs/v06_launchpad/src/instructions/complete_launch.rs +++ b/programs/v06_launchpad/src/instructions/complete_launch.rs @@ -414,6 +414,7 @@ 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 4aa50b434..f1215e373 100644 --- a/programs/v07_launchpad/src/instructions/complete_launch.rs +++ b/programs/v07_launchpad/src/instructions/complete_launch.rs @@ -445,6 +445,7 @@ 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 4d196b226..6ab319f2a 100644 --- a/programs/v08_launchpad/src/instructions/settle_launch.rs +++ b/programs/v08_launchpad/src/instructions/settle_launch.rs @@ -453,6 +453,7 @@ 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 d474c69dd..e3a6ccaba 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 715047315..d9eec264e 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/types/futarchy.ts b/sdk/src/futarchy/v0.6/types/futarchy.ts index 8da018253..2853d615f 100644 --- a/sdk/src/futarchy/v0.6/types/futarchy.ts +++ b/sdk/src/futarchy/v0.6/types/futarchy.ts @@ -590,16 +590,6 @@ export type Futarchy = { isMut: true; isSigner: false; }, - { - name: "baseMint"; - isMut: false; - isSigner: false; - docs: [ - "The DAO's base mint, bound to `old.base_mint` below. Because this crank is", - "permissionless, the mint must be bound so a caller can't pass a fabricated", - "low-decimal mint to set a near-zero supermajority bar.", - ]; - }, { name: "payer"; isMut: true; @@ -1839,6 +1829,15 @@ export type Futarchy = { ]; 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"; + }, ]; }; }, @@ -2309,6 +2308,10 @@ export type Futarchy = { name: "baseToSupermajority"; type: "u64"; }, + { + name: "isProposalValidationEnabled"; + type: "bool"; + }, ]; }; }, @@ -2478,6 +2481,12 @@ export type Futarchy = { option: "u64"; }; }, + { + name: "isProposalValidationEnabled"; + type: { + option: "bool"; + }; + }, ]; }; }, @@ -2961,6 +2970,11 @@ export type Futarchy = { type: "u64"; index: false; }, + { + name: "isProposalValidationEnabled"; + type: "bool"; + index: false; + }, ]; }, { @@ -3038,6 +3052,11 @@ export type Futarchy = { type: "u64"; index: false; }, + { + name: "isProposalValidationEnabled"; + type: "bool"; + index: false; + }, ]; }, { @@ -4623,16 +4642,6 @@ export const IDL: Futarchy = { isMut: true, isSigner: false, }, - { - name: "baseMint", - isMut: false, - isSigner: false, - docs: [ - "The DAO's base mint, bound to `old.base_mint` below. Because this crank is", - "permissionless, the mint must be bound so a caller can't pass a fabricated", - "low-decimal mint to set a near-zero supermajority bar.", - ], - }, { name: "payer", isMut: true, @@ -5872,6 +5881,15 @@ export const IDL: Futarchy = { ], 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", + }, ], }, }, @@ -6342,6 +6360,10 @@ export const IDL: Futarchy = { name: "baseToSupermajority", type: "u64", }, + { + name: "isProposalValidationEnabled", + type: "bool", + }, ], }, }, @@ -6511,6 +6533,12 @@ export const IDL: Futarchy = { option: "u64", }, }, + { + name: "isProposalValidationEnabled", + type: { + option: "bool", + }, + }, ], }, }, @@ -6994,6 +7022,11 @@ export const IDL: Futarchy = { type: "u64", index: false, }, + { + name: "isProposalValidationEnabled", + type: "bool", + index: false, + }, ], }, { @@ -7071,6 +7104,11 @@ export const IDL: Futarchy = { type: "u64", index: false, }, + { + name: "isProposalValidationEnabled", + type: "bool", + index: false, + }, ], }, { diff --git a/tests/futarchy/integration/futarchyAmm.test.ts b/tests/futarchy/integration/futarchyAmm.test.ts index c7e05fbd6..9dd6a01c6 100644 --- a/tests/futarchy/integration/futarchyAmm.test.ts +++ b/tests/futarchy/integration/futarchyAmm.test.ts @@ -83,6 +83,7 @@ export default function suite() { teamAddress: null, isOptimisticGovernanceEnabled: null, baseToSupermajority: null, + isProposalValidationEnabled: null, }, }) .instruction(); diff --git a/tests/futarchy/unit/adminCancelProposal.test.ts b/tests/futarchy/unit/adminCancelProposal.test.ts index d492cf103..70d21c5a1 100644 --- a/tests/futarchy/unit/adminCancelProposal.test.ts +++ b/tests/futarchy/unit/adminCancelProposal.test.ts @@ -76,6 +76,7 @@ export default function suite() { teamAddress: null, isOptimisticGovernanceEnabled: null, baseToSupermajority: null, + isProposalValidationEnabled: null, }, }) .instruction(); @@ -118,8 +119,6 @@ export default function suite() { proposal = await this.futarchy.initializeProposal(dao, squadsProposalPda); - await this.futarchy.approveProposalIx({ proposal, dao }).rpc(); - await this.futarchy .launchProposalIx({ proposal, diff --git a/tests/futarchy/unit/adminRemoveProposal.test.ts b/tests/futarchy/unit/adminRemoveProposal.test.ts index b51ad8399..6e665505a 100644 --- a/tests/futarchy/unit/adminRemoveProposal.test.ts +++ b/tests/futarchy/unit/adminRemoveProposal.test.ts @@ -52,6 +52,7 @@ export default function suite() { teamAddress: null, isOptimisticGovernanceEnabled: null, baseToSupermajority: null, + isProposalValidationEnabled: null, }, }) .instruction(); @@ -226,8 +227,6 @@ export default function suite() { transactionIndex: 1n, }); - await this.futarchy.approveProposalIx({ proposal, dao }).rpc(); - // Launch the proposal to move it to Pending state await this.futarchy .launchProposalIx({ diff --git a/tests/futarchy/unit/approveProposal.test.ts b/tests/futarchy/unit/approveProposal.test.ts index a508a993f..99f289da3 100644 --- a/tests/futarchy/unit/approveProposal.test.ts +++ b/tests/futarchy/unit/approveProposal.test.ts @@ -74,6 +74,7 @@ export default function suite() { teamAddress: null, isOptimisticGovernanceEnabled: null, baseToSupermajority: null, + isProposalValidationEnabled: null, }, }) .instruction(); diff --git a/tests/futarchy/unit/finalizeOptimisticProposal.test.ts b/tests/futarchy/unit/finalizeOptimisticProposal.test.ts index 974564abc..e29b7007d 100644 --- a/tests/futarchy/unit/finalizeOptimisticProposal.test.ts +++ b/tests/futarchy/unit/finalizeOptimisticProposal.test.ts @@ -62,6 +62,7 @@ export default function suite() { }, 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 dc2f39ab2..00805f4e4 100644 --- a/tests/futarchy/unit/finalizeProposal.test.ts +++ b/tests/futarchy/unit/finalizeProposal.test.ts @@ -76,6 +76,7 @@ export default function suite() { teamAddress: null, isOptimisticGovernanceEnabled: null, baseToSupermajority: null, + isProposalValidationEnabled: null, }, }) .instruction(); @@ -120,8 +121,6 @@ export default function suite() { // Now initialize the futarchy proposal proposal = await this.futarchy.initializeProposal(dao, squadsProposalPda); - await this.futarchy.approveProposalIx({ proposal, dao }).rpc(); - await this.futarchy .launchProposalIx({ proposal, @@ -450,6 +449,7 @@ export default function suite() { 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 2aa370adb..ce8f21917 100644 --- a/tests/futarchy/unit/initializeDao.test.ts +++ b/tests/futarchy/unit/initializeDao.test.ts @@ -40,6 +40,7 @@ export default function suite() { initialSpendingLimit: null, teamAddress: this.payer.publicKey, teamSponsoredPassThresholdBps: 123, + isProposalValidationEnabled: true, }, }) .preInstructions([ @@ -82,6 +83,7 @@ export default function suite() { assert.isNull(storedDao.optimisticProposal); assert.isFalse(storedDao.isOptimisticGovernanceEnabled); + assert.isTrue(storedDao.isProposalValidationEnabled); const multisigPda = multisig.getMultisigPda({ createKey: dao })[0]; const squadsMultisigVault = multisig.getVaultPda({ @@ -138,6 +140,7 @@ export default function suite() { minBaseFutarchicLiquidity: new BN(1000), baseToStake: new BN(1000), baseToSupermajority: new BN(0), + isProposalValidationEnabled: false, passThresholdBps: 300, nonce, initialSpendingLimit: { @@ -195,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 () { @@ -221,6 +225,7 @@ export default function suite() { initialSpendingLimit: null, baseToStake: new BN(1000), baseToSupermajority: new BN(0), + isProposalValidationEnabled: false, teamSponsoredPassThresholdBps: 1500, teamAddress: this.payer.publicKey, }, @@ -251,6 +256,7 @@ export default function suite() { initialSpendingLimit: null, baseToStake: new BN(1000), baseToSupermajority: new BN(0), + isProposalValidationEnabled: false, teamSponsoredPassThresholdBps: 123, teamAddress: this.payer.publicKey, }, @@ -278,6 +284,7 @@ export default function suite() { minBaseFutarchicLiquidity: new BN(1000), baseToStake: new BN(1000), baseToSupermajority: new BN(999), + isProposalValidationEnabled: false, passThresholdBps: 300, nonce: nextDaoNonce(), initialSpendingLimit: null, diff --git a/tests/futarchy/unit/initializeProposal.test.ts b/tests/futarchy/unit/initializeProposal.test.ts index 2f989310c..7130da427 100644 --- a/tests/futarchy/unit/initializeProposal.test.ts +++ b/tests/futarchy/unit/initializeProposal.test.ts @@ -62,6 +62,7 @@ export default function suite() { }, baseToStake: new BN(0), baseToSupermajority: new BN(0), + isProposalValidationEnabled: false, teamSponsoredPassThresholdBps: 0, teamAddress: this.payer.publicKey, }, @@ -98,6 +99,7 @@ export default function suite() { 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 e3992990c..c81dc73be 100644 --- a/tests/futarchy/unit/initiateVaultSpendOptimisticProposal.test.ts +++ b/tests/futarchy/unit/initiateVaultSpendOptimisticProposal.test.ts @@ -64,6 +64,7 @@ export default function suite() { }, 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 8862ce951..98e839476 100644 --- a/tests/futarchy/unit/launchProposal.test.ts +++ b/tests/futarchy/unit/launchProposal.test.ts @@ -52,14 +52,21 @@ export default function suite() { ); }); - // Create a DAO with the given launch thresholds. baseToSupermajority defaults - // to 0 (supermajority disabled), which the invariant always permits. + // 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, { baseToStake, baseToSupermajority = new BN(0), - }: { baseToStake: BN; baseToSupermajority?: BN }, + isProposalValidationEnabled = true, + }: { + baseToStake: BN; + baseToSupermajority?: BN; + isProposalValidationEnabled?: boolean; + }, ): Promise { const nonce = nextDaoNonce(); @@ -84,6 +91,7 @@ export default function suite() { baseToSupermajority, teamSponsoredPassThresholdBps: 300, teamAddress: context.payer.publicKey, + isProposalValidationEnabled, }, provideLiquidity: true, }) @@ -136,6 +144,7 @@ export default function suite() { teamAddress: null, isOptimisticGovernanceEnabled: null, baseToSupermajority: null, + isProposalValidationEnabled: null, }, }) .instruction(); @@ -185,8 +194,9 @@ export default function suite() { } // Enqueue an optimistic vault spend and initialize a futarchy proposal that - // challenges it (same squads proposal). Such a challenger auto-earns the team - // approval point in the launch gate. + // 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, @@ -249,274 +259,421 @@ export default function suite() { .rpc(); } - // ---- 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 }); + 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], + ); + } + }); + }); + + // ---- 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); - 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], - ); - } + // 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); }); - }); - // ---- Supermajority: stake alone reaches the per-DAO bar (0 disables) ---- + 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)); - 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, + const callbacks = expectError( + "InsufficientApprovalToLaunch", + "stake one below the supermajority must not launch", + ); + await launch(this, proposal, dao, squadsProposal).then( + callbacks[0], + callbacks[1], + ); }); - 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); + 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); + }); - await launch(this, proposal, dao, squadsProposal); - const stored = await this.futarchy.getProposal(proposal); - assert.exists(stored.state.pending); - }); + // ---- Optimistic-challenge gate: challenging an active optimistic proposal + // auto-earns the team point, so the challenge costs the same as today. ---- - 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, + 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, + ); + + await stake(this, proposal, dao, BASE_TO_STAKE); + + await launch(this, proposal, dao, squadsProposal); + const stored = await this.futarchy.getProposal(proposal); + assert.exists(stored.state.pending); + + const daoAccount = await this.futarchy.getDao(dao); + assert.notExists(daoAccount.optimisticProposal); }); - await provideLiquidity(this, dao); - const { proposal, squadsProposal } = await initDraftProposal(this, dao); - await stake(this, proposal, dao, T.subn(1)); + 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, + ); + + 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], + ); + }); - const callbacks = expectError( - "InsufficientApprovalToLaunch", - "stake one below the supermajority must not launch", - ); - await launch(this, proposal, dao, squadsProposal).then( - callbacks[0], - callbacks[1], - ); - }); + 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, + ); - 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 + await stake(this, proposal, dao, BASE_TO_STAKE.divn(2)); // below the floor + await this.futarchy.approveProposalIx({ proposal, dao }).rpc(); - // 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 launch(this, proposal, dao, squadsProposal); + const stored = await this.futarchy.getProposal(proposal); + assert.exists(stored.state.pending); }); - 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, + // ---- Existing optimistic-launch behaviour, re-derived against the new gate. + // The 1M stake is >= base_to_stake, so stake + auto team = 2 points. ---- + + 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, + ); + + await stake(this, proposal, dao, new BN(1_000_000 * 10 ** 6)); + await launch(this, proposal, dao, squadsProposal); + + const daoAccount = await this.futarchy.getDao(dao); + assert.notExists(daoAccount.optimisticProposal); + + const proposalAccount = await this.futarchy.getProposal(proposal); + assert.exists(proposalAccount.state.pending); + assert.equal( + proposalAccount.squadsProposal.toBase58(), + squadsProposal.toBase58(), + ); }); - 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); - }); + 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, + ); - // ---- Optimistic-challenge gate: challenging an active optimistic proposal - // auto-earns the team point, so the challenge costs the same as today. ---- + const daoAccount = await this.futarchy.getDao(dao); + this.advanceBySeconds(daoAccount.secondsPerProposal); - 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, - ); + await stake(this, proposal, dao, new BN(1_000_000 * 10 ** 6)); - await stake(this, proposal, dao, BASE_TO_STAKE); + const callbacks = expectError( + "OptimisticProposalAlreadyPassed", + "optimistic proposal has already passed", + ); + await launch(this, proposal, dao, squadsProposal).then( + callbacks[0], + callbacks[1], + ); + }); - await launch(this, proposal, dao, squadsProposal); - const stored = await this.futarchy.getProposal(proposal); - assert.exists(stored.state.pending); + // ---- Orthogonal: launch refreshes duration_in_seconds from the DAO. ---- - const daoAccount = await this.futarchy.getDao(dao); - assert.notExists(daoAccount.optimisticProposal); - }); + 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; - 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, - ); + const dao = await createDao(this, { baseToStake: new BN(0) }); + await provideLiquidity(this, dao); + const { proposal, squadsProposal } = await initDraftProposal(this, dao); - await stake(this, proposal, dao, BASE_TO_STAKE.divn(2)); // below the floor + await this.futarchy.sponsorProposalIx({ proposal, dao }).rpc(); - 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], - ); - }); + const proposalBefore = await this.futarchy.getProposal(proposal); + assert.equal(proposalBefore.durationInSeconds, THREE_DAYS); - 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, - ); + // 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); - await stake(this, proposal, dao, BASE_TO_STAKE.divn(2)); // below the floor - await this.futarchy.approveProposalIx({ proposal, dao }).rpc(); + await launch(this, proposal, dao, squadsProposal); - await launch(this, proposal, dao, squadsProposal); - const stored = await this.futarchy.getProposal(proposal); - assert.exists(stored.state.pending); + const stored = await this.futarchy.getProposal(proposal); + assert.equal(stored.durationInSeconds, FIVE_DAYS); + }); }); - // ---- Existing optimistic-launch behaviour, re-derived against the new gate. - // The 1M stake is >= base_to_stake, so stake + auto team = 2 points. ---- + 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. - 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, - ); + 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 stake(this, proposal, dao, new BN(1_000_000 * 10 ** 6)); - await launch(this, proposal, dao, squadsProposal); + await this.futarchy.sponsorProposalIx({ proposal, dao }).rpc(); - const 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 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, - ); + await stake(this, proposal, dao, BASE_TO_STAKE.muln(2)); + + await launch(this, proposal, dao, squadsProposal); + const stored = await this.futarchy.getProposal(proposal); + assert.exists(stored.state.pending); + }); + + 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); - const daoAccount = await this.futarchy.getDao(dao); - this.advanceBySeconds(daoAccount.secondsPerProposal); + await stake(this, proposal, dao, BASE_TO_STAKE); - await stake(this, proposal, dao, new BN(1_000_000 * 10 ** 6)); + await launch(this, proposal, dao, squadsProposal); + const stored = await this.futarchy.getProposal(proposal); + assert.exists(stored.state.pending); + }); - const callbacks = expectError( - "OptimisticProposalAlreadyPassed", - "optimistic proposal has already passed", - ); - await launch(this, proposal, dao, squadsProposal).then( - callbacks[0], - callbacks[1], - ); - }); + 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], + ); + }); - // ---- Orthogonal: launch refreshes duration_in_seconds from the DAO. ---- + it("ignores the MetaDAO approval point: an approved, non-team, sub-floor proposal still fails", async function () { + const dao = await createDao(this, { + baseToStake: BASE_TO_STAKE, + baseToSupermajority: new BN(0), + isProposalValidationEnabled: false, + }); + await provideLiquidity(this, dao); + const { proposal, squadsProposal } = await initDraftProposal(this, dao); - 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; + // MetaDAO approval would be a launch point under the validation gate, but the + // legacy gate doesn't consult it — so this still fails on stake alone. + await this.futarchy.approveProposalIx({ proposal, dao }).rpc(); + await stake(this, proposal, dao, BASE_TO_STAKE.subn(1)); + + const callbacks = expectError( + "InsufficientStakeToLaunch", + "the legacy gate must ignore MetaDAO approval", + ); + await launch(this, proposal, dao, squadsProposal).then( + callbacks[0], + callbacks[1], + ); + }); - const dao = await createDao(this, { baseToStake: new BN(0) }); - await provideLiquidity(this, dao); - const { proposal, squadsProposal } = await initDraftProposal(this, dao); + 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, + ); - await this.futarchy.sponsorProposalIx({ proposal, dao }).rpc(); + // 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); - const proposalBefore = await this.futarchy.getProposal(proposal); - assert.equal(proposalBefore.durationInSeconds, THREE_DAYS); + const daoAccount = await this.futarchy.getDao(dao); + assert.notExists(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 proposalAccount = await this.futarchy.getProposal(proposal); + assert.exists(proposalAccount.state.pending); + }); - await launch(this, proposal, dao, squadsProposal); + 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, + ); + + const daoAccount = await this.futarchy.getDao(dao); + this.advanceBySeconds(daoAccount.secondsPerProposal); - const stored = await this.futarchy.getProposal(proposal); - assert.equal(stored.durationInSeconds, FIVE_DAYS); + 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 index 73adee6d5..58faa1d9f 100644 --- a/tests/futarchy/unit/resizeDao.test.ts +++ b/tests/futarchy/unit/resizeDao.test.ts @@ -6,16 +6,10 @@ import { Transaction, } from "@solana/web3.js"; import BN from "bn.js"; -import { setupBasicDao, expectError } from "../../utils.js"; +import { setupBasicDao } from "../../utils.js"; import { TestContext } from "../../main.test.js"; import { assert } from "chai"; -// 2.5M whole tokens — the DEFAULT_BASE_TO_SUPERMAJORITY_TOKENS the migration applies, -// scaled to base units by the base mint's on-chain decimals. -const SUPERMAJORITY_WHOLE = new BN(2_500_000); -const scaledSupermajority = (decimals: number) => - SUPERMAJORITY_WHOLE.mul(new BN(10).pow(new BN(decimals))); - type OldLayoutOverrides = { baseToStake?: BN; optimisticProposal?: { @@ -27,9 +21,10 @@ type OldLayoutOverrides = { // 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`). Truncation does NOT work for Dao: its Option slack -// would leave the field's bytes in place. Optional field overrides let a test -// pin base_to_stake / optimistic state without driving the real instructions. +// `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, @@ -38,7 +33,8 @@ async function makeOldLayout( ): Promise<{ AFTER: number; BEFORE: number }> { const raw = await ctx.banksClient.getAccount(dao); const AFTER = raw.data.length; - const BEFORE = AFTER - 8; // base_to_supermajority is a u64 + // 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; @@ -52,8 +48,9 @@ async function makeOldLayout( decoded.isOptimisticGovernanceEnabled = overrides.isOptimisticGovernanceEnabled; - // Encode as oldDao (current layout, no supermajority); drop its discriminator - // and reattach the real Dao discriminator at the pre-migration size. + // 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); @@ -82,46 +79,46 @@ export default function suite() { }); }); - it("migrates a 6-decimal DAO to the scaled 2.5M bar, preserving every other field", async function () { + 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 - // base_to_supermajority === 0, read from the Option zero-slack. This is the - // empirical confirmation of the non-freeze conclusion. + // 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, baseMint: META, payer: this.payer.publicKey }) + .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.toString(), - scaledSupermajority(6).toString(), - ); + assert.equal(migrated.baseToSupermajority.toNumber(), 0); + assert.isFalse(migrated.isProposalValidationEnabled); - // base_to_supermajority is the ONLY field that changed (0 -> scaled 2.5M); - // everything else round-trips untouched. - const a = JSON.parse(JSON.stringify(migrated)); - const b = JSON.parse(JSON.stringify(original)); - delete a.baseToSupermajority; - delete b.baseToSupermajority; - assert.deepEqual(a, b); + // 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, baseMint: META, payer: this.payer.publicKey }) + .accounts({ dao, payer: this.payer.publicKey }) .preInstructions([ ComputeBudgetProgram.setComputeUnitLimit({ units: 200_000 }), ]) @@ -129,53 +126,6 @@ export default function suite() { const after2 = await this.banksClient.getAccount(dao); assert.equal(after2.data.length, AFTER); - const migrated2 = await this.futarchy.getDao(dao); - assert.equal( - migrated2.baseToSupermajority.toString(), - scaledSupermajority(6).toString(), - ); - }); - - it("scales the supermajority bar by the base mint's on-chain decimals (9-decimal)", async function () { - const META9 = await this.createMint(this.payer.publicKey, 9); - const dao9 = await setupBasicDao({ - context: this, - baseMint: META9, - quoteMint: USDC, - }); - - await makeOldLayout(this, dao9); - - await this.futarchy.futarchy.methods - .resizeDao() - .accounts({ dao: dao9, baseMint: META9, payer: this.payer.publicKey }) - .rpc(); - - const migrated = await this.futarchy.getDao(dao9); - // 2.5M * 10^9, NOT the 6-decimal value — guards the on-chain decimals derivation. - assert.equal( - migrated.baseToSupermajority.toString(), - scaledSupermajority(9).toString(), - ); - }); - - it("rejects a base_mint that does not match the DAO", async function () { - await makeOldLayout(this, dao); - - // A fabricated mint (here also 6-decimal, but any mint) must be rejected: - // the permissionless crank binds base_mint to dao.base_mint. - const wrongMint = await this.createMint(this.payer.publicKey, 6); - - const callbacks = expectError( - "InvalidMint", - "resize_dao must reject a base_mint that isn't the DAO's", - ); - - await this.futarchy.futarchy.methods - .resizeDao() - .accounts({ dao, baseMint: wrongMint, payer: this.payer.publicKey }) - .rpc() - .then(callbacks[0], callbacks[1]); }); it("preserves optimistic governance fields through the migration", async function () { @@ -190,7 +140,7 @@ export default function suite() { await this.futarchy.futarchy.methods .resizeDao() - .accounts({ dao, baseMint: META, payer: this.payer.publicKey }) + .accounts({ dao, payer: this.payer.publicKey }) .rpc(); const migrated = await this.futarchy.getDao(dao); @@ -206,29 +156,13 @@ export default function suite() { ); }); - it("never lets the supermajority bar migrate in below base_to_stake (high floor)", async function () { - // base_to_stake above 2.5M whole tokens (at 6 decimals). - const highFloor = new BN(3_000_000).mul(new BN(10 ** 6)); - await makeOldLayout(this, dao, { baseToStake: highFloor }); - - await this.futarchy.futarchy.methods - .resizeDao() - .accounts({ dao, baseMint: META, payer: this.payer.publicKey }) - .rpc(); - - const migrated = await this.futarchy.getDao(dao); - // max(2.5M scaled, base_to_stake) == base_to_stake, not the flat default. - assert.equal(migrated.baseToStake.toString(), highFloor.toString()); - assert.equal(migrated.baseToSupermajority.toString(), highFloor.toString()); - }); - 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, baseMint: META, payer: this.payer.publicKey }) + .accounts({ dao, payer: this.payer.publicKey }) .rpc(); const afterRaw = await this.banksClient.getAccount(dao); @@ -245,7 +179,7 @@ export default function suite() { const rent = await this.banksClient.getRent(); const raw0 = await this.banksClient.getAccount(dao); const AFTER = raw0.data.length; - const BEFORE = AFTER - 8; + const BEFORE = AFTER - 9; const rentBefore = rent.minimumBalance(BigInt(BEFORE)); const rentAfter = rent.minimumBalance(BigInt(AFTER)); const delta = rentAfter - rentBefore; @@ -273,7 +207,7 @@ export default function suite() { await this.futarchy.futarchy.methods .resizeDao() - .accounts({ dao, baseMint: META, payer: crankPayer.publicKey }) + .accounts({ dao, payer: crankPayer.publicKey }) .signers([crankPayer]) .rpc(); diff --git a/tests/futarchy/unit/resizeProposal.test.ts b/tests/futarchy/unit/resizeProposal.test.ts index d805e9d21..ba529b563 100644 --- a/tests/futarchy/unit/resizeProposal.test.ts +++ b/tests/futarchy/unit/resizeProposal.test.ts @@ -96,6 +96,7 @@ export default function suite() { teamAddress: null, isOptimisticGovernanceEnabled: null, baseToSupermajority: null, + isProposalValidationEnabled: null, }, }) .instruction(); diff --git a/tests/futarchy/unit/unstakeFromProposal.test.ts b/tests/futarchy/unit/unstakeFromProposal.test.ts index 2d339ae69..aae8f24d6 100644 --- a/tests/futarchy/unit/unstakeFromProposal.test.ts +++ b/tests/futarchy/unit/unstakeFromProposal.test.ts @@ -78,6 +78,7 @@ export default function suite() { teamAddress: null, isOptimisticGovernanceEnabled: null, baseToSupermajority: null, + isProposalValidationEnabled: null, }, }) .instruction(); @@ -166,8 +167,6 @@ export default function suite() { }) .rpc(); - await this.futarchy.approveProposalIx({ proposal, dao }).rpc(); - await this.futarchy .launchProposalIx({ proposal, @@ -213,8 +212,6 @@ export default function suite() { }) .rpc(); - await this.futarchy.approveProposalIx({ proposal, dao }).rpc(); - await this.futarchy .launchProposalIx({ proposal, @@ -248,9 +245,6 @@ export default function suite() { it("fails when unstaking in the same transaction as launch (flash-loan)", async function () { const stakeAmount = new BN(100 * 10 ** 6); - // Approve up front (separate tx) so the bundled launch has its second point. - await this.futarchy.approveProposalIx({ proposal, dao }).rpc(); - // stakeIxs includes the SDK's ATA-creation preInstructions for the // staker/proposal base accounts, which we need because this is the // first stake on this proposal. diff --git a/tests/futarchy/unit/updateDao.test.ts b/tests/futarchy/unit/updateDao.test.ts index 68c725c56..fa75979aa 100644 --- a/tests/futarchy/unit/updateDao.test.ts +++ b/tests/futarchy/unit/updateDao.test.ts @@ -27,6 +27,7 @@ const THOUSAND_BUCK_PRICE = PriceMath.getAmmPrice(1000, 9, 6); function withNulls(overrides: { baseToStake?: BN | null; baseToSupermajority?: BN | null; + isProposalValidationEnabled?: boolean | null; }) { return { passThresholdBps: null, @@ -41,6 +42,7 @@ function withNulls(overrides: { teamAddress: null, isOptimisticGovernanceEnabled: null, baseToSupermajority: null, + isProposalValidationEnabled: null, ...overrides, }; } @@ -55,7 +57,11 @@ function withNulls(overrides: { async function executeUpdateDao( ctx: TestContext, dao: PublicKey, - overrides: { baseToStake?: BN | null; baseToSupermajority?: BN | null }, + overrides: { + baseToStake?: BN | null; + baseToSupermajority?: BN | null; + isProposalValidationEnabled?: boolean | null; + }, transactionIndex: bigint, ) { const daoAccount = await ctx.futarchy.getDao(dao); @@ -176,6 +182,7 @@ export default function suite() { }, baseToStake: new BN(0), baseToSupermajority: new BN(0), + isProposalValidationEnabled: false, teamSponsoredPassThresholdBps: 0, teamAddress: this.payer.publicKey, }, @@ -232,6 +239,7 @@ export default function suite() { twapStartDelaySeconds: null, isOptimisticGovernanceEnabled: null, baseToSupermajority: null, + isProposalValidationEnabled: null, }, }) .instruction(); @@ -538,6 +546,7 @@ export default function suite() { twapStartDelaySeconds: null, isOptimisticGovernanceEnabled: null, baseToSupermajority: null, + isProposalValidationEnabled: null, }, }) .instruction(); @@ -813,4 +822,27 @@ export default function suite() { 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/integration/fullLaunch.test.ts b/tests/integration/fullLaunch.test.ts index edda96c01..dd2e0d1c0 100644 --- a/tests/integration/fullLaunch.test.ts +++ b/tests/integration/fullLaunch.test.ts @@ -386,6 +386,7 @@ export default async function suite() { teamAddress: null, isOptimisticGovernanceEnabled: false, baseToSupermajority: null, + isProposalValidationEnabled: null, }, }) .instruction(); diff --git a/tests/integration/fullLaunch_v7.test.ts b/tests/integration/fullLaunch_v7.test.ts index 34f07cd78..7af72ee9a 100644 --- a/tests/integration/fullLaunch_v7.test.ts +++ b/tests/integration/fullLaunch_v7.test.ts @@ -431,6 +431,7 @@ export default async function suite() { teamAddress: null, isOptimisticGovernanceEnabled: true, baseToSupermajority: null, + isProposalValidationEnabled: null, }, }) .instruction(); diff --git a/tests/integration/mintAndSwap.test.ts b/tests/integration/mintAndSwap.test.ts index a029bc665..58e7d15b0 100644 --- a/tests/integration/mintAndSwap.test.ts +++ b/tests/integration/mintAndSwap.test.ts @@ -46,6 +46,7 @@ export default async function test() { 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 7e75b73d4..01ae64f3c 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -528,6 +528,7 @@ before(async function () { teamSponsoredPassThresholdBps, teamAddress, baseToSupermajority: new BN(0), + isProposalValidationEnabled: false, }, provideLiquidity: true, }) diff --git a/tests/performancePackageV2/utils.ts b/tests/performancePackageV2/utils.ts index 2524c422f..3325ced4b 100644 --- a/tests/performancePackageV2/utils.ts +++ b/tests/performancePackageV2/utils.ts @@ -357,6 +357,7 @@ export async function setupDaoForTwapTests(context: any): Promise { 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 34e5da978..07ea6bde7 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -37,12 +37,14 @@ export async function setupBasicDao({ quoteMint, teamSponsoredPassThresholdBps = 300, teamAddress, + isProposalValidationEnabled = false, }: { context: TestContext; baseMint: PublicKey; quoteMint: PublicKey; teamSponsoredPassThresholdBps?: number; teamAddress?: PublicKey; + isProposalValidationEnabled?: boolean; }) { const nonce = nextDaoNonce(); @@ -64,6 +66,7 @@ export async function setupBasicDao({ teamSponsoredPassThresholdBps, teamAddress: teamAddress || context.payer.publicKey, baseToSupermajority: new BN(0), + isProposalValidationEnabled, }, provideLiquidity: true, }) From 749ead6083fd6052690723ae1ab4aeb7b675fae1 Mon Sep 17 00:00:00 2001 From: Pileks Date: Wed, 24 Jun 2026 00:58:43 +0200 Subject: [PATCH 9/9] error on proposal approval when proposal validation is disabled --- programs/futarchy/src/error.rs | 2 ++ .../src/instructions/approve_proposal.rs | 5 ++++ sdk/src/futarchy/v0.6/types/futarchy.ts | 10 +++++++ .../futarchy/integration/futarchyAmm.test.ts | 2 -- tests/futarchy/unit/approveProposal.test.ts | 28 +++++++++++++++++-- .../executeMultisigProposalApproval.test.ts | 2 -- tests/futarchy/unit/launchProposal.test.ts | 24 ---------------- tests/futarchy/unit/resizeProposal.test.ts | 3 ++ tests/futarchy/unit/updateDao.test.ts | 6 ---- tests/main.test.ts | 2 -- tests/utils.ts | 18 ++++++++++++ 11 files changed, 63 insertions(+), 39 deletions(-) diff --git a/programs/futarchy/src/error.rs b/programs/futarchy/src/error.rs index 55e45dd84..8da0614e4 100644 --- a/programs/futarchy/src/error.rs +++ b/programs/futarchy/src/error.rs @@ -98,4 +98,6 @@ pub enum FutarchyError { 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/instructions/approve_proposal.rs b/programs/futarchy/src/instructions/approve_proposal.rs index 099ff85b8..4408f4eab 100644 --- a/programs/futarchy/src/instructions/approve_proposal.rs +++ b/programs/futarchy/src/instructions/approve_proposal.rs @@ -17,6 +17,11 @@ pub struct ApproveProposal<'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(), diff --git a/sdk/src/futarchy/v0.6/types/futarchy.ts b/sdk/src/futarchy/v0.6/types/futarchy.ts index 2853d615f..26ec4d5ea 100644 --- a/sdk/src/futarchy/v0.6/types/futarchy.ts +++ b/sdk/src/futarchy/v0.6/types/futarchy.ts @@ -4047,6 +4047,11 @@ export type Futarchy = { name: "InsufficientApprovalToLaunch"; msg: "Proposal lacks enough approval points to launch"; }, + { + code: 6047; + name: "ProposalValidationDisabled"; + msg: "Proposal validation is not enabled for this DAO"; + }, ]; }; @@ -8099,5 +8104,10 @@ export const IDL: Futarchy = { 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 9dd6a01c6..d59aa77d7 100644 --- a/tests/futarchy/integration/futarchyAmm.test.ts +++ b/tests/futarchy/integration/futarchyAmm.test.ts @@ -216,8 +216,6 @@ export default function suite() { const proposalAccount = await this.futarchy.getProposal(proposal); - await this.futarchy.approveProposalIx({ proposal, dao }).rpc(); - await this.futarchy .launchProposalIx({ proposal, diff --git a/tests/futarchy/unit/approveProposal.test.ts b/tests/futarchy/unit/approveProposal.test.ts index 99f289da3..38ddc0a41 100644 --- a/tests/futarchy/unit/approveProposal.test.ts +++ b/tests/futarchy/unit/approveProposal.test.ts @@ -6,7 +6,11 @@ import { TransactionMessage, } from "@solana/web3.js"; import BN from "bn.js"; -import { expectError, setupBasicDao } from "../../utils.js"; +import { + expectError, + setupBasicDao, + setProposalValidationEnabled, +} from "../../utils.js"; import { assert } from "chai"; import * as multisig from "@sqds/multisig"; @@ -32,12 +36,14 @@ export default function suite() { 200_000 * 1_000_000, ); - // setupBasicDao uses baseToStake: 0, so a draft proposal can launch with no - // stake (the Task-1 launch gate is unchanged). + // 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 @@ -185,4 +191,20 @@ export default function suite() { .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/executeMultisigProposalApproval.test.ts b/tests/futarchy/unit/executeMultisigProposalApproval.test.ts index ce9309214..233118d18 100644 --- a/tests/futarchy/unit/executeMultisigProposalApproval.test.ts +++ b/tests/futarchy/unit/executeMultisigProposalApproval.test.ts @@ -225,8 +225,6 @@ export default function suite() { ); // Now launch the futarchy proposal to push the AMM out of Spot. - await this.futarchy.approveProposalIx({ proposal, dao }).rpc(); - const storedDao = await this.futarchy.getDao(dao); await this.futarchy .launchProposalIx({ diff --git a/tests/futarchy/unit/launchProposal.test.ts b/tests/futarchy/unit/launchProposal.test.ts index 98e839476..f1ab44c76 100644 --- a/tests/futarchy/unit/launchProposal.test.ts +++ b/tests/futarchy/unit/launchProposal.test.ts @@ -603,30 +603,6 @@ export default function suite() { ); }); - it("ignores the MetaDAO approval point: an approved, non-team, sub-floor proposal still fails", async function () { - const dao = await createDao(this, { - baseToStake: BASE_TO_STAKE, - baseToSupermajority: new BN(0), - isProposalValidationEnabled: false, - }); - await provideLiquidity(this, dao); - const { proposal, squadsProposal } = await initDraftProposal(this, dao); - - // MetaDAO approval would be a launch point under the validation gate, but the - // legacy gate doesn't consult it — so this still fails on stake alone. - await this.futarchy.approveProposalIx({ proposal, dao }).rpc(); - await stake(this, proposal, dao, BASE_TO_STAKE.subn(1)); - - const callbacks = expectError( - "InsufficientStakeToLaunch", - "the legacy gate must ignore MetaDAO approval", - ); - await launch(this, proposal, dao, squadsProposal).then( - callbacks[0], - callbacks[1], - ); - }); - 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, diff --git a/tests/futarchy/unit/resizeProposal.test.ts b/tests/futarchy/unit/resizeProposal.test.ts index ba529b563..f16749a2b 100644 --- a/tests/futarchy/unit/resizeProposal.test.ts +++ b/tests/futarchy/unit/resizeProposal.test.ts @@ -56,10 +56,13 @@ export default function suite() { 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 diff --git a/tests/futarchy/unit/updateDao.test.ts b/tests/futarchy/unit/updateDao.test.ts index fa75979aa..54b6d62f8 100644 --- a/tests/futarchy/unit/updateDao.test.ts +++ b/tests/futarchy/unit/updateDao.test.ts @@ -324,8 +324,6 @@ export default function suite() { .splitTokensIx(question, quoteVault, USDC, new BN(1000_000_000), 2) .rpc(); - await this.futarchy.approveProposalIx({ proposal: proposalA, dao }).rpc(); - // Launch proposal A to put DAO in Futarchy state await this.futarchy .launchProposalIx({ @@ -480,8 +478,6 @@ export default function suite() { ]) .rpc(); - await this.futarchy.approveProposalIx({ proposal: proposalB, dao }).rpc(); - // Launch proposal B to put DAO in Futarchy state await this.futarchy .launchProposalIx({ @@ -631,8 +627,6 @@ export default function suite() { .splitTokensIx(question, quoteVault, USDC, new BN(1000_000_000), 2) .rpc(); - await this.futarchy.approveProposalIx({ proposal: proposalA, dao }).rpc(); - // Launch proposal A to put DAO in Futarchy state await this.futarchy .launchProposalIx({ diff --git a/tests/main.test.ts b/tests/main.test.ts index 01ae64f3c..10589e88d 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -671,8 +671,6 @@ before(async function () { await this.initializeProposal({ dao, instructions }); const storedDao = await this.futarchy.getDao(dao); - await this.futarchy.approveProposalIx({ proposal, dao }).rpc(); - await this.futarchy .launchProposalIx({ proposal, diff --git a/tests/utils.ts b/tests/utils.ts index 07ea6bde7..f2ed1f6d9 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -101,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