From ed0d1deca9b7c9e848d2a66098bcc9ef8c318df0 Mon Sep 17 00:00:00 2001 From: Pileks Date: Fri, 11 Sep 2026 21:34:05 +0200 Subject: [PATCH 01/16] typed proposals opt-in scaffolding --- programs/futarchy/src/events.rs | 1 + .../src/instructions/initialize_dao.rs | 2 ++ .../futarchy/src/instructions/resize_dao.rs | 6 +++-- .../futarchy/src/instructions/update_dao.rs | 1 + programs/futarchy/src/state/dao.rs | 2 ++ sdk/src/futarchy/v0.6/types/futarchy.ts | 24 +++++++++++++++++++ tests/futarchy/unit/initializeDao.test.ts | 2 ++ tests/futarchy/unit/resizeDao.test.ts | 12 ++++++---- tests/utils.ts | 6 ++--- 9 files changed, 47 insertions(+), 9 deletions(-) diff --git a/programs/futarchy/src/events.rs b/programs/futarchy/src/events.rs index 22eb8e04..4ff4f54f 100644 --- a/programs/futarchy/src/events.rs +++ b/programs/futarchy/src/events.rs @@ -53,6 +53,7 @@ pub struct InitializeDaoEvent { pub squads_multisig_vault: Pubkey, pub team_sponsored_pass_threshold_bps: i16, pub team_address: Pubkey, + pub typed_proposals_enabled: bool, } #[event] diff --git a/programs/futarchy/src/instructions/initialize_dao.rs b/programs/futarchy/src/instructions/initialize_dao.rs index 9c8161a1..df921581 100644 --- a/programs/futarchy/src/instructions/initialize_dao.rs +++ b/programs/futarchy/src/instructions/initialize_dao.rs @@ -225,6 +225,7 @@ impl InitializeDao<'_> { last_failed_liquidation_at: 0, spending_limit_dirty: false, last_buyback_finalized_at: 0, + typed_proposals_enabled: true, }); dao.invariant()?; @@ -250,6 +251,7 @@ impl InitializeDao<'_> { squads_multisig_vault: dao.squads_multisig_vault, team_sponsored_pass_threshold_bps: dao.team_sponsored_pass_threshold_bps, team_address: dao.team_address, + typed_proposals_enabled: dao.typed_proposals_enabled, }); Ok(()) diff --git a/programs/futarchy/src/instructions/resize_dao.rs b/programs/futarchy/src/instructions/resize_dao.rs index f97fc851..f7c38ae8 100644 --- a/programs/futarchy/src/instructions/resize_dao.rs +++ b/programs/futarchy/src/instructions/resize_dao.rs @@ -26,8 +26,9 @@ impl ResizeDao<'_> { require_eq!(is_discriminator_correct, true); const AFTER_REALLOC_SIZE: usize = Dao::MIGRATED_SIZE; - // 58 bytes: 33 (Option liquidator) + 8 (i64) + 8 (i64) + 1 (bool) + 8 (i64) - const BEFORE_REALLOC_SIZE: usize = AFTER_REALLOC_SIZE - 58; + // 59 bytes: 33 (Option liquidator) + 8 (i64) + 8 (i64) + 1 (bool) + // + 8 (i64) + 1 (bool typed_proposals_enabled) + const BEFORE_REALLOC_SIZE: usize = AFTER_REALLOC_SIZE - 59; if dao.data_len() != BEFORE_REALLOC_SIZE { // already realloced @@ -92,6 +93,7 @@ impl ResizeDao<'_> { last_failed_liquidation_at: 0, spending_limit_dirty: false, last_buyback_finalized_at: 0, + typed_proposals_enabled: false, }; dao.realloc(AFTER_REALLOC_SIZE, true)?; diff --git a/programs/futarchy/src/instructions/update_dao.rs b/programs/futarchy/src/instructions/update_dao.rs index 4acacf08..568d5587 100644 --- a/programs/futarchy/src/instructions/update_dao.rs +++ b/programs/futarchy/src/instructions/update_dao.rs @@ -82,6 +82,7 @@ impl UpdateDao<'_> { last_failed_liquidation_at: dao.last_failed_liquidation_at, spending_limit_dirty: dao.spending_limit_dirty, last_buyback_finalized_at: dao.last_buyback_finalized_at, + typed_proposals_enabled: dao.typed_proposals_enabled, }); dao.seq_num += 1; diff --git a/programs/futarchy/src/state/dao.rs b/programs/futarchy/src/state/dao.rs index ffe80a89..b71e91f8 100644 --- a/programs/futarchy/src/state/dao.rs +++ b/programs/futarchy/src/state/dao.rs @@ -84,6 +84,8 @@ pub struct Dao { pub spending_limit_dirty: bool, /// Unix time of the last buyback finalization. 0 = never. pub last_buyback_finalized_at: i64, + /// Whether the DAO runs on the mini-instructions catalog. Never turns off. + pub typed_proposals_enabled: bool, } #[derive(AnchorSerialize, AnchorDeserialize, Debug, Clone, PartialEq, Eq, InitSpace)] diff --git a/sdk/src/futarchy/v0.6/types/futarchy.ts b/sdk/src/futarchy/v0.6/types/futarchy.ts index 668d3109..08628710 100644 --- a/sdk/src/futarchy/v0.6/types/futarchy.ts +++ b/sdk/src/futarchy/v0.6/types/futarchy.ts @@ -2543,6 +2543,13 @@ export type Futarchy = { docs: ["Unix time of the last buyback finalization. 0 = never."]; type: "i64"; }, + { + name: "typedProposalsEnabled"; + docs: [ + "Whether the DAO runs on the mini-instructions catalog. Never turns off.", + ]; + type: "bool"; + }, ]; }; }, @@ -4040,6 +4047,11 @@ export type Futarchy = { type: "publicKey"; index: false; }, + { + name: "typedProposalsEnabled"; + type: "bool"; + index: false; + }, ]; }, { @@ -7780,6 +7792,13 @@ export const IDL: Futarchy = { docs: ["Unix time of the last buyback finalization. 0 = never."], type: "i64", }, + { + name: "typedProposalsEnabled", + docs: [ + "Whether the DAO runs on the mini-instructions catalog. Never turns off.", + ], + type: "bool", + }, ], }, }, @@ -9277,6 +9296,11 @@ export const IDL: Futarchy = { type: "publicKey", index: false, }, + { + name: "typedProposalsEnabled", + type: "bool", + index: false, + }, ], }, { diff --git a/tests/futarchy/unit/initializeDao.test.ts b/tests/futarchy/unit/initializeDao.test.ts index d1658d25..5ce95375 100644 --- a/tests/futarchy/unit/initializeDao.test.ts +++ b/tests/futarchy/unit/initializeDao.test.ts @@ -78,6 +78,7 @@ export default function suite() { assert.isNull(storedDao.optimisticProposal); assert.isFalse(storedDao.isOptimisticGovernanceEnabled); + assert.isTrue(storedDao.typedProposalsEnabled); const multisigPda = multisig.getMultisigPda({ createKey: dao })[0]; const squadsMultisigVault = multisig.getVaultPda({ @@ -189,6 +190,7 @@ export default function suite() { assert.equal(storedDao.teamSponsoredPassThresholdBps, 123); assert.isNull(storedDao.optimisticProposal); assert.isFalse(storedDao.isOptimisticGovernanceEnabled); + assert.isTrue(storedDao.typedProposalsEnabled); }); it("doesn't allow an initial spending limit with a zero monthly amount", async function () { diff --git a/tests/futarchy/unit/resizeDao.test.ts b/tests/futarchy/unit/resizeDao.test.ts index 7c9c015a..0d0b73ed 100644 --- a/tests/futarchy/unit/resizeDao.test.ts +++ b/tests/futarchy/unit/resizeDao.test.ts @@ -77,13 +77,15 @@ export default function suite() { it("migrates an old DAO with the new fields defaulted, preserving every other field", async function () { const original = await this.futarchy.getDao(dao); - // The migration defaults must match a freshly-initialized DAO, so the - // whole account can round-trip equal below. + // The migration defaults match a freshly-initialized DAO except for the + // switch, so everything else can round-trip equal below. assert.isNull(original.liquidator); assert.equal(original.lastFailedTakeoverAt.toString(), "0"); assert.equal(original.lastFailedLiquidationAt.toString(), "0"); assert.isFalse(original.spendingLimitDirty); assert.equal(original.lastBuybackFinalizedAt.toString(), "0"); + // True only because the DAO was freshly initialized. + assert.isTrue(original.typedProposalsEnabled); const { AFTER, BEFORE } = await makeOldDaoLayout(this, dao); @@ -101,9 +103,10 @@ export default function suite() { assert.equal(migrated.lastFailedLiquidationAt.toString(), "0"); assert.isFalse(migrated.spendingLimitDirty); assert.equal(migrated.lastBuybackFinalizedAt.toString(), "0"); + assert.isFalse(migrated.typedProposalsEnabled); assert.deepEqual( - JSON.parse(JSON.stringify(migrated)), + JSON.parse(JSON.stringify({ ...migrated, typedProposalsEnabled: true })), JSON.parse(JSON.stringify(original)), ); @@ -141,6 +144,7 @@ export default function suite() { assert.equal(migrated.lastFailedLiquidationAt.toString(), "0"); assert.isFalse(migrated.spendingLimitDirty); assert.equal(migrated.lastBuybackFinalizedAt.toString(), "0"); + assert.isFalse(migrated.typedProposalsEnabled); }); it("is a no-op on an already-new-layout DAO", async function () { @@ -163,7 +167,7 @@ export default function suite() { const rent = await this.banksClient.getRent(); const raw0 = await this.banksClient.getAccount(dao); const AFTER = raw0.data.length; - const BEFORE = AFTER - 58; + const BEFORE = AFTER - 59; const rentBefore = rent.minimumBalance(BigInt(BEFORE)); const rentAfter = rent.minimumBalance(BigInt(AFTER)); const delta = rentAfter - rentBefore; diff --git a/tests/utils.ts b/tests/utils.ts index 35763ec4..fef0a096 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -102,10 +102,10 @@ export async function makeOldDaoLayout( ): Promise<{ AFTER: number; BEFORE: number }> { const raw = await ctx.banksClient.getAccount(dao); const AFTER = raw.data.length; - // 58 bytes: liquidator (Option) + last_failed_takeover_at (i64) + // 59 bytes: liquidator (Option) + last_failed_takeover_at (i64) // + last_failed_liquidation_at (i64) + spending_limit_dirty (bool) - // + last_buyback_finalized_at (i64) - const BEFORE = AFTER - 58; + // + last_buyback_finalized_at (i64) + typed_proposals_enabled (bool) + const BEFORE = AFTER - 59; const disc = Buffer.from(raw.data.slice(0, 8)); const coder = ctx.futarchy.futarchy.account.dao.coder.accounts; From 7b31c96d1049ba6218eea29c11e0b6fff23e0573 Mon Sep 17 00:00:00 2001 From: Pileks Date: Fri, 11 Sep 2026 21:46:18 +0200 Subject: [PATCH 02/16] resize_dao - lift zero min liquidity to 1 --- programs/futarchy/src/instructions/resize_dao.rs | 5 +++-- tests/futarchy/unit/resizeDao.test.ts | 13 +++++++++++++ tests/utils.ts | 6 ++++++ 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/programs/futarchy/src/instructions/resize_dao.rs b/programs/futarchy/src/instructions/resize_dao.rs index f7c38ae8..8ec4ba4b 100644 --- a/programs/futarchy/src/instructions/resize_dao.rs +++ b/programs/futarchy/src/instructions/resize_dao.rs @@ -76,8 +76,9 @@ impl ResizeDao<'_> { twap_max_observation_change_per_update: old_dao_data .twap_max_observation_change_per_update, twap_start_delay_seconds: old_dao_data.twap_start_delay_seconds, - min_quote_futarchic_liquidity: old_dao_data.min_quote_futarchic_liquidity, - min_base_futarchic_liquidity: old_dao_data.min_base_futarchic_liquidity, + // A zero minimum fails `Dao::invariant`; 1 is the launchpads' value. + min_quote_futarchic_liquidity: old_dao_data.min_quote_futarchic_liquidity.max(1), + min_base_futarchic_liquidity: old_dao_data.min_base_futarchic_liquidity.max(1), base_to_stake: old_dao_data.base_to_stake, seq_num: old_dao_data.seq_num, initial_spending_limit: live_spending_limit, diff --git a/tests/futarchy/unit/resizeDao.test.ts b/tests/futarchy/unit/resizeDao.test.ts index 0d0b73ed..c209ca6c 100644 --- a/tests/futarchy/unit/resizeDao.test.ts +++ b/tests/futarchy/unit/resizeDao.test.ts @@ -147,6 +147,19 @@ export default function suite() { assert.isFalse(migrated.typedProposalsEnabled); }); + it("lifts a zero minimum liquidity to 1", async function () { + await makeOldDaoLayout(this, dao, { + minQuoteFutarchicLiquidity: new BN(0), + minBaseFutarchicLiquidity: new BN(0), + }); + + await this.futarchy.resizeDaoIx({ dao }).rpc(); + + const migrated = await this.futarchy.getDao(dao); + assert.equal(migrated.minQuoteFutarchicLiquidity.toString(), "1"); + assert.equal(migrated.minBaseFutarchicLiquidity.toString(), "1"); + }); + it("is a no-op on an already-new-layout DAO", async function () { const before = await this.futarchy.getDao(dao); const beforeRaw = await this.banksClient.getAccount(dao); diff --git a/tests/utils.ts b/tests/utils.ts index fef0a096..69af5c5c 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -91,6 +91,8 @@ export type OldDaoLayoutOverrides = { amountPerMonth: typeof BN.prototype; members: PublicKey[]; } | null; + minQuoteFutarchicLiquidity?: typeof BN.prototype; + minBaseFutarchicLiquidity?: typeof BN.prototype; }; // Rewrites a real (new-layout) Dao account to the pre-migration on-chain layout. @@ -118,6 +120,10 @@ export async function makeOldDaoLayout( overrides.isOptimisticGovernanceEnabled; if (overrides.initialSpendingLimit !== undefined) decoded.initialSpendingLimit = overrides.initialSpendingLimit; + if (overrides.minQuoteFutarchicLiquidity !== undefined) + decoded.minQuoteFutarchicLiquidity = overrides.minQuoteFutarchicLiquidity; + if (overrides.minBaseFutarchicLiquidity !== undefined) + decoded.minBaseFutarchicLiquidity = overrides.minBaseFutarchicLiquidity; // Encode as oldDao and truncate to the pre-migration size. const body = await coder.encode("oldDao", decoded); From 0caf24d733c147f75149d4505f214f407885fc61 Mon Sep 17 00:00:00 2001 From: Pileks Date: Fri, 11 Sep 2026 22:12:19 +0200 Subject: [PATCH 03/16] typed proposals opt-in - update_dao mechanics --- programs/futarchy/src/error.rs | 4 + programs/futarchy/src/events.rs | 1 + .../initialize_hostile_takeover_proposal.rs | 1 + .../futarchy/src/instructions/update_dao.rs | 16 ++- programs/futarchy/src/lib.rs | 2 +- scripts/utils/daoActions.ts | 2 +- sdk/src/futarchy/v0.6/types/futarchy.ts | 50 +++++++ .../futarchy/integration/fullProposal.test.ts | 1 + .../futarchy/integration/futarchyAmm.test.ts | 1 + .../integration/proposalBatchTx.test.ts | 2 + .../futarchy/unit/adminCancelProposal.test.ts | 1 + .../futarchy/unit/adminRemoveProposal.test.ts | 1 + .../unit/adminUpdateProposalParams.test.ts | 21 +-- tests/futarchy/unit/finalizeProposal.test.ts | 2 + .../initializeHostileTakeoverProposal.test.ts | 1 + .../futarchy/unit/initializeProposal.test.ts | 1 + tests/futarchy/unit/launchProposal.test.ts | 1 + tests/futarchy/unit/liquidatedGuards.test.ts | 1 + .../futarchy/unit/unstakeFromProposal.test.ts | 1 + tests/futarchy/unit/updateDao.test.ts | 98 ++++++++++++++ tests/futarchy/utils.ts | 122 ++++++++++++++++++ tests/integration/fullLaunch.test.ts | 1 + tests/integration/fullLaunch_v7.test.ts | 1 + 23 files changed, 308 insertions(+), 24 deletions(-) create mode 100644 tests/futarchy/utils.ts diff --git a/programs/futarchy/src/error.rs b/programs/futarchy/src/error.rs index 8af623e9..fbd9f714 100644 --- a/programs/futarchy/src/error.rs +++ b/programs/futarchy/src/error.rs @@ -152,4 +152,8 @@ pub enum FutarchyError { TeamSponsorshipForbidden, #[msg("Squads proposal must be in Approved status to be cancelled")] SquadsProposalNotApproved, + #[msg("This DAO has not opted into typed proposals")] + TypedProposalsDisabled, + #[msg("Typed proposals cannot be disabled")] + TypedProposalsCannotBeDisabled, } diff --git a/programs/futarchy/src/events.rs b/programs/futarchy/src/events.rs index 4ff4f54f..03ecd4ac 100644 --- a/programs/futarchy/src/events.rs +++ b/programs/futarchy/src/events.rs @@ -71,6 +71,7 @@ pub struct UpdateDaoEvent { pub team_sponsored_pass_threshold_bps: i16, pub team_address: Pubkey, pub is_optimistic_governance_enabled: bool, + pub typed_proposals_enabled: bool, } #[event] diff --git a/programs/futarchy/src/instructions/initialize_hostile_takeover_proposal.rs b/programs/futarchy/src/instructions/initialize_hostile_takeover_proposal.rs index c04e838d..bb63cfd8 100644 --- a/programs/futarchy/src/instructions/initialize_hostile_takeover_proposal.rs +++ b/programs/futarchy/src/instructions/initialize_hostile_takeover_proposal.rs @@ -59,6 +59,7 @@ impl InitializeHostileTakeoverProposal<'_> { base_to_stake: None, team_sponsored_pass_threshold_bps: None, team_address: Some(args.new_team_address), + typed_proposals_enabled: None, }, } .data(), diff --git a/programs/futarchy/src/instructions/update_dao.rs b/programs/futarchy/src/instructions/update_dao.rs index 568d5587..2e1a7582 100644 --- a/programs/futarchy/src/instructions/update_dao.rs +++ b/programs/futarchy/src/instructions/update_dao.rs @@ -12,6 +12,9 @@ pub struct UpdateDaoParams { pub base_to_stake: Option, pub team_sponsored_pass_threshold_bps: Option, pub team_address: Option, + /// `Some(true)` turns the catalog on for this DAO. `None` leaves the + /// switch as it is. `Some(false)` is refused: there is no way to turn it off. + pub typed_proposals_enabled: Option, } #[derive(Accounts)] @@ -23,7 +26,7 @@ pub struct UpdateDao<'info> { } impl UpdateDao<'_> { - pub fn validate(&self) -> Result<()> { + pub fn validate(&self, dao_params: &UpdateDaoParams) -> Result<()> { require!(self.dao.liquidator.is_none(), FutarchyError::DaoLiquidated); // Prevent parameter updates during active futarchy markets @@ -31,6 +34,12 @@ impl UpdateDao<'_> { return Err(FutarchyError::PoolNotInSpotState.into()); } + // The switch only turns on. + require!( + dao_params.typed_proposals_enabled != Some(false), + FutarchyError::TypedProposalsCannotBeDisabled + ); + Ok(()) } @@ -82,7 +91,9 @@ impl UpdateDao<'_> { last_failed_liquidation_at: dao.last_failed_liquidation_at, spending_limit_dirty: dao.spending_limit_dirty, last_buyback_finalized_at: dao.last_buyback_finalized_at, - typed_proposals_enabled: dao.typed_proposals_enabled, + typed_proposals_enabled: dao_params + .typed_proposals_enabled + .unwrap_or(dao.typed_proposals_enabled), }); dao.seq_num += 1; @@ -104,6 +115,7 @@ impl UpdateDao<'_> { team_sponsored_pass_threshold_bps: dao.team_sponsored_pass_threshold_bps, team_address: dao.team_address, is_optimistic_governance_enabled: dao.is_optimistic_governance_enabled, + typed_proposals_enabled: dao.typed_proposals_enabled, }); Ok(()) diff --git a/programs/futarchy/src/lib.rs b/programs/futarchy/src/lib.rs index 561fba53..fcca41f5 100644 --- a/programs/futarchy/src/lib.rs +++ b/programs/futarchy/src/lib.rs @@ -150,7 +150,7 @@ pub mod futarchy { FinalizeProposal::handle(ctx) } - #[access_control(ctx.accounts.validate())] + #[access_control(ctx.accounts.validate(&dao_params))] pub fn update_dao(ctx: Context, dao_params: UpdateDaoParams) -> Result<()> { UpdateDao::handle(ctx, dao_params) } diff --git a/scripts/utils/daoActions.ts b/scripts/utils/daoActions.ts index 17216db9..20565d2f 100644 --- a/scripts/utils/daoActions.ts +++ b/scripts/utils/daoActions.ts @@ -63,7 +63,7 @@ const EMPTY_UPDATE_DAO_PARAMS: UpdateDaoParams = { baseToStake: null, teamSponsoredPassThresholdBps: null, teamAddress: null, - isOptimisticGovernanceEnabled: null, + typedProposalsEnabled: null, }; // Updates the given DAO config fields, leaving the omitted ones unchanged diff --git a/sdk/src/futarchy/v0.6/types/futarchy.ts b/sdk/src/futarchy/v0.6/types/futarchy.ts index 08628710..e9f9d392 100644 --- a/sdk/src/futarchy/v0.6/types/futarchy.ts +++ b/sdk/src/futarchy/v0.6/types/futarchy.ts @@ -3361,6 +3361,16 @@ export type Futarchy = { option: "publicKey"; }; }, + { + name: "typedProposalsEnabled"; + docs: [ + "`Some(true)` turns the catalog on for this DAO. `None` leaves the", + "switch as it is. `Some(false)` is refused: there is no way to turn it off.", + ]; + type: { + option: "bool"; + }; + }, ]; }; }, @@ -4124,6 +4134,11 @@ export type Futarchy = { type: "bool"; index: false; }, + { + name: "typedProposalsEnabled"; + type: "bool"; + index: false; + }, ]; }, { @@ -5244,6 +5259,16 @@ export type Futarchy = { name: "SquadsProposalNotApproved"; msg: "Squads proposal must be in Approved status to be cancelled"; }, + { + code: 6073; + name: "TypedProposalsDisabled"; + msg: "This DAO has not opted into typed proposals"; + }, + { + code: 6074; + name: "TypedProposalsCannotBeDisabled"; + msg: "Typed proposals cannot be disabled"; + }, ]; }; @@ -8610,6 +8635,16 @@ export const IDL: Futarchy = { option: "publicKey", }, }, + { + name: "typedProposalsEnabled", + docs: [ + "`Some(true)` turns the catalog on for this DAO. `None` leaves the", + "switch as it is. `Some(false)` is refused: there is no way to turn it off.", + ], + type: { + option: "bool", + }, + }, ], }, }, @@ -9373,6 +9408,11 @@ export const IDL: Futarchy = { type: "bool", index: false, }, + { + name: "typedProposalsEnabled", + type: "bool", + index: false, + }, ], }, { @@ -10493,5 +10533,15 @@ export const IDL: Futarchy = { name: "SquadsProposalNotApproved", msg: "Squads proposal must be in Approved status to be cancelled", }, + { + code: 6073, + name: "TypedProposalsDisabled", + msg: "This DAO has not opted into typed proposals", + }, + { + code: 6074, + name: "TypedProposalsCannotBeDisabled", + msg: "Typed proposals cannot be disabled", + }, ], }; diff --git a/tests/futarchy/integration/fullProposal.test.ts b/tests/futarchy/integration/fullProposal.test.ts index 9c065a80..54c581df 100644 --- a/tests/futarchy/integration/fullProposal.test.ts +++ b/tests/futarchy/integration/fullProposal.test.ts @@ -86,6 +86,7 @@ export default function suite() { twapMaxObservationChangePerUpdate: null, minQuoteFutarchicLiquidity: null, minBaseFutarchicLiquidity: null, + typedProposalsEnabled: null, }, }) .instruction(); diff --git a/tests/futarchy/integration/futarchyAmm.test.ts b/tests/futarchy/integration/futarchyAmm.test.ts index 6d6062aa..fb0a7a12 100644 --- a/tests/futarchy/integration/futarchyAmm.test.ts +++ b/tests/futarchy/integration/futarchyAmm.test.ts @@ -81,6 +81,7 @@ export default function suite() { twapStartDelaySeconds: null, teamSponsoredPassThresholdBps: null, teamAddress: null, + typedProposalsEnabled: null, }, }) .instruction(); diff --git a/tests/futarchy/integration/proposalBatchTx.test.ts b/tests/futarchy/integration/proposalBatchTx.test.ts index ffae0450..339238cd 100644 --- a/tests/futarchy/integration/proposalBatchTx.test.ts +++ b/tests/futarchy/integration/proposalBatchTx.test.ts @@ -86,6 +86,7 @@ export default function suite() { twapMaxObservationChangePerUpdate: null, minQuoteFutarchicLiquidity: null, minBaseFutarchicLiquidity: null, + typedProposalsEnabled: null, }, }) .instruction(); @@ -100,6 +101,7 @@ export default function suite() { twapMaxObservationChangePerUpdate: null, minQuoteFutarchicLiquidity: null, minBaseFutarchicLiquidity: null, + typedProposalsEnabled: null, }, }) .instruction(); diff --git a/tests/futarchy/unit/adminCancelProposal.test.ts b/tests/futarchy/unit/adminCancelProposal.test.ts index e99ac999..3588adea 100644 --- a/tests/futarchy/unit/adminCancelProposal.test.ts +++ b/tests/futarchy/unit/adminCancelProposal.test.ts @@ -75,6 +75,7 @@ export default function suite() { twapStartDelaySeconds: null, teamSponsoredPassThresholdBps: null, teamAddress: null, + typedProposalsEnabled: null, }, }) .instruction(); diff --git a/tests/futarchy/unit/adminRemoveProposal.test.ts b/tests/futarchy/unit/adminRemoveProposal.test.ts index 88dbd5aa..091c5021 100644 --- a/tests/futarchy/unit/adminRemoveProposal.test.ts +++ b/tests/futarchy/unit/adminRemoveProposal.test.ts @@ -50,6 +50,7 @@ export default function suite() { twapStartDelaySeconds: null, teamSponsoredPassThresholdBps: null, teamAddress: null, + typedProposalsEnabled: null, }, }) .instruction(); diff --git a/tests/futarchy/unit/adminUpdateProposalParams.test.ts b/tests/futarchy/unit/adminUpdateProposalParams.test.ts index 240c0082..3c3ddbfc 100644 --- a/tests/futarchy/unit/adminUpdateProposalParams.test.ts +++ b/tests/futarchy/unit/adminUpdateProposalParams.test.ts @@ -11,6 +11,7 @@ import BN from "bn.js"; import * as multisig from "@sqds/multisig"; import { assert } from "chai"; import { expectError, passProposal, setupBasicDao } from "../../utils.js"; +import { rewriteAccount } from "../utils.js"; import { TestContext } from "../../main.test.js"; // ExecuteArbitrary's catalog parameters, and the start delay the duration @@ -73,26 +74,6 @@ async function createArbitraryProposal( }; } -// Re-encodes an account in place, padded back to its allocated length. The two -// callers below both need state no instruction on the branch can produce. -async function rewriteAccount( - ctx: TestContext, - address: PublicKey, - name: "proposal" | "dao", - mutate: (decoded: any) => void, -) { - const raw = await ctx.banksClient.getAccount(address); - const coder = ctx.futarchy.futarchy.account[name].coder.accounts; - const decoded = coder.decode(name, Buffer.from(raw.data)); - - mutate(decoded); - - const buf = Buffer.alloc(raw.data.length); - (await coder.encode(name, decoded)).copy(buf, 0); - - ctx.context.setAccount(address, { ...raw, data: buf }); -} - export default function suite() { let META: PublicKey, USDC: PublicKey, diff --git a/tests/futarchy/unit/finalizeProposal.test.ts b/tests/futarchy/unit/finalizeProposal.test.ts index ef242fbd..e0ed421f 100644 --- a/tests/futarchy/unit/finalizeProposal.test.ts +++ b/tests/futarchy/unit/finalizeProposal.test.ts @@ -83,6 +83,7 @@ export default function suite() { twapStartDelaySeconds: null, teamSponsoredPassThresholdBps: null, teamAddress: null, + typedProposalsEnabled: null, }, }) .instruction(); @@ -609,6 +610,7 @@ export default function suite() { twapStartDelaySeconds: null, teamSponsoredPassThresholdBps: null, teamAddress: null, + typedProposalsEnabled: null, }, }) .instruction(); diff --git a/tests/futarchy/unit/initializeHostileTakeoverProposal.test.ts b/tests/futarchy/unit/initializeHostileTakeoverProposal.test.ts index e2d33e36..63b5e226 100644 --- a/tests/futarchy/unit/initializeHostileTakeoverProposal.test.ts +++ b/tests/futarchy/unit/initializeHostileTakeoverProposal.test.ts @@ -75,6 +75,7 @@ export default function suite() { baseToStake: null, teamSponsoredPassThresholdBps: null, teamAddress: newTeamAddress, + typedProposalsEnabled: null, }, }) .instruction(); diff --git a/tests/futarchy/unit/initializeProposal.test.ts b/tests/futarchy/unit/initializeProposal.test.ts index e51427d3..4346c93c 100644 --- a/tests/futarchy/unit/initializeProposal.test.ts +++ b/tests/futarchy/unit/initializeProposal.test.ts @@ -88,6 +88,7 @@ export default function suite() { twapStartDelaySeconds: null, teamSponsoredPassThresholdBps: null, teamAddress: null, + typedProposalsEnabled: null, }, }) .instruction(); diff --git a/tests/futarchy/unit/launchProposal.test.ts b/tests/futarchy/unit/launchProposal.test.ts index 6970cabc..5ce43616 100644 --- a/tests/futarchy/unit/launchProposal.test.ts +++ b/tests/futarchy/unit/launchProposal.test.ts @@ -116,6 +116,7 @@ export default function suite() { twapStartDelaySeconds: null, teamSponsoredPassThresholdBps: null, teamAddress: null, + typedProposalsEnabled: null, }, }) .instruction(); diff --git a/tests/futarchy/unit/liquidatedGuards.test.ts b/tests/futarchy/unit/liquidatedGuards.test.ts index c159c32a..b5d10f6f 100644 --- a/tests/futarchy/unit/liquidatedGuards.test.ts +++ b/tests/futarchy/unit/liquidatedGuards.test.ts @@ -434,6 +434,7 @@ export default function suite() { baseToStake: null, teamSponsoredPassThresholdBps: null, teamAddress: null, + typedProposalsEnabled: null, }, }) .instruction(); diff --git a/tests/futarchy/unit/unstakeFromProposal.test.ts b/tests/futarchy/unit/unstakeFromProposal.test.ts index c982187b..2f3f5674 100644 --- a/tests/futarchy/unit/unstakeFromProposal.test.ts +++ b/tests/futarchy/unit/unstakeFromProposal.test.ts @@ -76,6 +76,7 @@ export default function suite() { twapStartDelaySeconds: null, teamSponsoredPassThresholdBps: null, teamAddress: null, + typedProposalsEnabled: null, }, }) .instruction(); diff --git a/tests/futarchy/unit/updateDao.test.ts b/tests/futarchy/unit/updateDao.test.ts index c5f55607..0b63b2e0 100644 --- a/tests/futarchy/unit/updateDao.test.ts +++ b/tests/futarchy/unit/updateDao.test.ts @@ -17,6 +17,12 @@ import { sha256, } from "@metadaoproject/programs"; import BN from "bn.js"; +import { + expectVaultExecutionError, + rewriteAccount, + setTypedProposalsEnabled, + updateDaoViaVault, +} from "../utils.js"; const THOUSAND_BUCK_PRICE = PriceMath.getAmmPrice(1000, 9, 6); @@ -104,6 +110,7 @@ export default function suite() { teamSponsoredPassThresholdBps: null, teamAddress: null, twapStartDelaySeconds: null, + typedProposalsEnabled: null, }, }) .instruction(); @@ -386,4 +393,95 @@ export default function suite() { ); } }); + + it("turns typed proposals on for a DAO that has them off", async function () { + await setTypedProposalsEnabled(this, dao, false); + const before = await this.futarchy.getDao(dao); + assert.isFalse(before.typedProposalsEnabled); + + await updateDaoViaVault(this, dao, { typedProposalsEnabled: true }); + + const after = await this.futarchy.getDao(dao); + assert.isTrue(after.typedProposalsEnabled); + assert.equal(after.seqNum.toString(), before.seqNum.addn(1).toString()); + assert.deepEqual( + JSON.parse( + JSON.stringify({ + ...after, + typedProposalsEnabled: false, + seqNum: before.seqNum, + }), + ), + JSON.parse(JSON.stringify(before)), + ); + }); + + it("keeps typed proposals on when asked to turn them on again", async function () { + const before = await this.futarchy.getDao(dao); + assert.isTrue(before.typedProposalsEnabled); + + await updateDaoViaVault(this, dao, { typedProposalsEnabled: true }); + + const after = await this.futarchy.getDao(dao); + assert.isTrue(after.typedProposalsEnabled); + assert.equal(after.seqNum.toString(), before.seqNum.addn(1).toString()); + }); + + it("refuses to turn typed proposals off on a DAO that has them off", async function () { + await setTypedProposalsEnabled(this, dao, false); + const before = await this.banksClient.getAccount(dao); + + await expectVaultExecutionError( + this, + updateDaoViaVault(this, dao, { + typedProposalsEnabled: false, + passThresholdBps: 500, + }), + "TypedProposalsCannotBeDisabled", + ); + + const after = await this.banksClient.getAccount(dao); + assert.deepEqual(after.data, before.data); + }); + + it("refuses to turn typed proposals off on a DAO that has them on", async function () { + const before = await this.banksClient.getAccount(dao); + + await expectVaultExecutionError( + this, + updateDaoViaVault(this, dao, { + typedProposalsEnabled: false, + passThresholdBps: 500, + }), + "TypedProposalsCannotBeDisabled", + ); + + const after = await this.banksClient.getAccount(dao); + assert.deepEqual(after.data, before.data); + }); + + it("cannot opt in on a configuration that fails the invariant without fixing it in the same call", async function () { + await rewriteAccount(this, dao, "dao", (decoded) => { + decoded.typedProposalsEnabled = false; + decoded.minQuoteFutarchicLiquidity = new BN(0); + }); + + await expectVaultExecutionError( + this, + updateDaoViaVault(this, dao, { typedProposalsEnabled: true }), + "InsufficientLiquidity", + ); + + let daoAccount = await this.futarchy.getDao(dao); + assert.isFalse(daoAccount.typedProposalsEnabled); + + await updateDaoViaVault(this, dao, { + typedProposalsEnabled: true, + minQuoteFutarchicLiquidity: new BN(1), + }); + + daoAccount = await this.futarchy.getDao(dao); + assert.isTrue(daoAccount.typedProposalsEnabled); + assert.equal(daoAccount.minQuoteFutarchicLiquidity.toString(), "1"); + }); } diff --git a/tests/futarchy/utils.ts b/tests/futarchy/utils.ts new file mode 100644 index 00000000..085e9e38 --- /dev/null +++ b/tests/futarchy/utils.ts @@ -0,0 +1,122 @@ +import { assert } from "chai"; +import { PublicKey } from "@solana/web3.js"; +import * as multisig from "@sqds/multisig"; +import { + PERMISSIONLESS_ACCOUNT, + UpdateDaoParams, +} from "@metadaoproject/programs"; +import { TestContext } from "../main.test.js"; +import { + executeVaultTransaction, + forceApproveSquadsProposal, +} from "../utils.js"; + +// Re-encodes an account in place, padded back to its allocated length, for +// states no instruction on the branch can produce. +export async function rewriteAccount( + ctx: TestContext, + address: PublicKey, + name: "proposal" | "dao", + mutate: (decoded: any) => void, +) { + const raw = await ctx.banksClient.getAccount(address); + const coder = ctx.futarchy.futarchy.account[name].coder.accounts; + const decoded = coder.decode(name, Buffer.from(raw.data)); + + mutate(decoded); + + const buf = Buffer.alloc(raw.data.length); + (await coder.encode(name, decoded)).copy(buf, 0); + + ctx.context.setAccount(address, { ...raw, data: buf }); +} + +// Puts the DAO's typed-proposals switch in the given state. No instruction +// turns it off, so tests that need an off DAO rewrite the account. +export async function setTypedProposalsEnabled( + ctx: TestContext, + dao: PublicKey, + enabled: boolean, +) { + await rewriteAccount(ctx, dao, "dao", (decoded) => { + decoded.typedProposalsEnabled = enabled; + }); +} + +const EMPTY_UPDATE_DAO_PARAMS: UpdateDaoParams = { + passThresholdBps: null, + secondsPerProposal: null, + twapInitialObservation: null, + twapMaxObservationChangePerUpdate: null, + twapStartDelaySeconds: null, + minQuoteFutarchicLiquidity: null, + minBaseFutarchicLiquidity: null, + baseToStake: null, + teamSponsoredPassThresholdBps: null, + teamAddress: null, + typedProposalsEnabled: null, +}; + +// Runs a vault-signed update_dao without a market: a Squads vault transaction +// and proposal at the multisig's next index, force-approved and executed. +// Omitted params are left unchanged. +export async function updateDaoViaVault( + ctx: TestContext, + dao: PublicKey, + params: Partial, +) { + const multisigPda = multisig.getMultisigPda({ createKey: dao })[0]; + const multisigAccount = await multisig.accounts.Multisig.fromAccountAddress( + ctx.squadsConnection, + multisigPda, + ); + const transactionIndex = + BigInt(multisigAccount.transactionIndex.toString()) + 1n; + + const updateDaoIx = await ctx.futarchy + .updateDaoIx({ dao, params: { ...EMPTY_UPDATE_DAO_PARAMS, ...params } }) + .instruction(); + + const { tx, squadsProposal } = ctx.futarchy.squadsProposalCreateTx({ + dao, + instructions: [updateDaoIx], + transactionIndex, + }); + [tx.recentBlockhash] = await ctx.banksClient.getLatestBlockhash(); + tx.feePayer = ctx.payer.publicKey; + tx.sign(ctx.payer, PERMISSIONLESS_ACCOUNT); + await ctx.banksClient.processTransaction(tx); + + await forceApproveSquadsProposal(ctx, squadsProposal); + + const [squadsTransaction] = multisig.getTransactionPda({ + multisigPda, + index: transactionIndex, + }); + await executeVaultTransaction(ctx, dao, squadsTransaction); +} + +// An error inside a vault-executed instruction surfaces through Squads' +// execute as a bare transaction error carrying only the custom error code, +// so the name is resolved through the IDL and matched as hex. +export async function expectVaultExecutionError( + ctx: TestContext, + execution: Promise, + errorName: string, +) { + const error = ctx.futarchy.futarchy.idl.errors.find( + (e) => e.name === errorName, + ); + assert.exists(error, `unknown futarchy error ${errorName}`); + const expected = `custom program error: 0x${error.code.toString(16)}`; + + await execution.then( + () => assert.fail(`should have failed with ${errorName}`), + (e) => + assert.include( + e.toString(), + expected, + `Expected ${errorName}, got: ${e}`, + ), + ); +} diff --git a/tests/integration/fullLaunch.test.ts b/tests/integration/fullLaunch.test.ts index 038ac5f0..49f6cdbc 100644 --- a/tests/integration/fullLaunch.test.ts +++ b/tests/integration/fullLaunch.test.ts @@ -384,6 +384,7 @@ export default async function suite() { minBaseFutarchicLiquidity: null, teamSponsoredPassThresholdBps: null, teamAddress: null, + typedProposalsEnabled: null, }, }) .instruction(); diff --git a/tests/integration/fullLaunch_v7.test.ts b/tests/integration/fullLaunch_v7.test.ts index 7fdaeba4..34cee04e 100644 --- a/tests/integration/fullLaunch_v7.test.ts +++ b/tests/integration/fullLaunch_v7.test.ts @@ -429,6 +429,7 @@ export default async function suite() { minBaseFutarchicLiquidity: null, teamSponsoredPassThresholdBps: null, teamAddress: null, + typedProposalsEnabled: null, }, }) .instruction(); From ec0110a5d4c3b8212cc968f09797b4ad4a1167c4 Mon Sep 17 00:00:00 2001 From: Pileks Date: Fri, 11 Sep 2026 22:44:55 +0200 Subject: [PATCH 04/16] typed proposal gates --- .../src/instructions/typed_initialize.rs | 6 ++++++ .../initializeBuybackTokenProposal.test.ts | 19 +++++++++++++++++++ ...initializeHostileLiquidateProposal.test.ts | 18 +++++++++++++++++- .../initializeHostileTakeoverProposal.test.ts | 17 +++++++++++++++++ .../unit/initializeLargeSpendProposal.test.ts | 13 +++++++++++++ .../unit/initializeMintTokensProposal.test.ts | 18 ++++++++++++++++++ ...tializeSpendingLimitChangeProposal.test.ts | 19 +++++++++++++++++++ 7 files changed, 109 insertions(+), 1 deletion(-) diff --git a/programs/futarchy/src/instructions/typed_initialize.rs b/programs/futarchy/src/instructions/typed_initialize.rs index ff9fb74e..946487ee 100644 --- a/programs/futarchy/src/instructions/typed_initialize.rs +++ b/programs/futarchy/src/instructions/typed_initialize.rs @@ -59,6 +59,12 @@ impl TypedInitializeAccounts<'_> { pub fn validate(&self) -> Result<()> { require!(self.dao.liquidator.is_none(), FutarchyError::DaoLiquidated); + // The catalog is opt-in per DAO. + require!( + self.dao.typed_proposals_enabled, + FutarchyError::TypedProposalsDisabled + ); + require_eq!( self.question.num_outcomes(), 2, diff --git a/tests/futarchy/unit/initializeBuybackTokenProposal.test.ts b/tests/futarchy/unit/initializeBuybackTokenProposal.test.ts index 56a980dd..783cc2af 100644 --- a/tests/futarchy/unit/initializeBuybackTokenProposal.test.ts +++ b/tests/futarchy/unit/initializeBuybackTokenProposal.test.ts @@ -21,6 +21,7 @@ import { passProposal, setupBasicDao, } from "../../utils.js"; +import { setTypedProposalsEnabled } from "../utils.js"; import { TestContext } from "../../main.test.js"; const MEMO_PROGRAM_ID = new PublicKey( @@ -1091,6 +1092,24 @@ export default function suite() { assert.equal(storedSquadsProposal.status.__kind, "Executed"); }); + it("throws error when the DAO has typed proposals off", async function () { + await setTypedProposalsEnabled(this, dao, false); + + const callbacks = expectError( + "TypedProposalsDisabled", + "created a buyback proposal on a DAO with typed proposals off", + ); + await this.futarchy + .initializeBuybackTokenProposal({ + dao, + quoteAmount: new BN(400_000_000_000), + cycleCount: 80, + cycleFrequencySeconds: 86_400, + startDelaySeconds: 0, + }) + .then(callbacks[0], callbacks[1]); + }); + it("rejects a zero total", async function () { const callbacks = expectError( "InvalidBuybackAmount", diff --git a/tests/futarchy/unit/initializeHostileLiquidateProposal.test.ts b/tests/futarchy/unit/initializeHostileLiquidateProposal.test.ts index 9e55c0a9..fe120982 100644 --- a/tests/futarchy/unit/initializeHostileLiquidateProposal.test.ts +++ b/tests/futarchy/unit/initializeHostileLiquidateProposal.test.ts @@ -7,7 +7,8 @@ import { } from "@solana/web3.js"; import BN from "bn.js"; import { assert } from "chai"; -import { assertVaultTransactionPayload } from "../../utils.js"; +import { assertVaultTransactionPayload, expectError } from "../../utils.js"; +import { setTypedProposalsEnabled } from "../utils.js"; const ONE_BUCK_PRICE = PriceMath.getAmmPrice(1, 6, 6); @@ -95,4 +96,19 @@ export default function suite() { const updatedDao = await this.futarchy.getDao(dao); assert.equal(updatedDao.proposalCount, 1); }); + + it("throws error when the DAO has typed proposals off", async function () { + await setTypedProposalsEnabled(this, dao, false); + + const callbacks = expectError( + "TypedProposalsDisabled", + "created a hostile liquidate proposal on a DAO with typed proposals off", + ); + await this.futarchy + .initializeHostileLiquidateProposal({ + dao, + liquidator: Keypair.generate().publicKey, + }) + .then(...callbacks); + }); } diff --git a/tests/futarchy/unit/initializeHostileTakeoverProposal.test.ts b/tests/futarchy/unit/initializeHostileTakeoverProposal.test.ts index 63b5e226..07224e90 100644 --- a/tests/futarchy/unit/initializeHostileTakeoverProposal.test.ts +++ b/tests/futarchy/unit/initializeHostileTakeoverProposal.test.ts @@ -13,6 +13,7 @@ import { expectError, forceApproveSquadsProposal, } from "../../utils.js"; +import { setTypedProposalsEnabled } from "../utils.js"; import { TestContext } from "../../main.test.js"; const ONE_BUCK_PRICE = PriceMath.getAmmPrice(1, 6, 6); @@ -337,4 +338,20 @@ export default function suite() { }) .then(...callbacks); }); + + it("throws error when the DAO has typed proposals off", async function () { + await setTypedProposalsEnabled(this, dao, false); + + const callbacks = expectError( + "TypedProposalsDisabled", + "created a hostile takeover proposal on a DAO with typed proposals off", + ); + await this.futarchy + .initializeHostileTakeoverProposal({ + dao, + newTeamAddress: Keypair.generate().publicKey, + spendingLimitAction: { keep: {} }, + }) + .then(...callbacks); + }); } diff --git a/tests/futarchy/unit/initializeLargeSpendProposal.test.ts b/tests/futarchy/unit/initializeLargeSpendProposal.test.ts index 7154c5e3..858ca722 100644 --- a/tests/futarchy/unit/initializeLargeSpendProposal.test.ts +++ b/tests/futarchy/unit/initializeLargeSpendProposal.test.ts @@ -13,6 +13,7 @@ import { forceApproveSquadsProposal, setupBasicDao, } from "../../utils.js"; +import { setTypedProposalsEnabled } from "../utils.js"; const ONE_BUCK_PRICE = PriceMath.getAmmPrice(1, 6, 6); @@ -153,6 +154,18 @@ export default function suite() { .then(...callbacks); }); + it("throws error when the DAO has typed proposals off", async function () { + await setTypedProposalsEnabled(this, dao, false); + + const callbacks = expectError( + "TypedProposalsDisabled", + "created a large spend proposal on a DAO with typed proposals off", + ); + await this.futarchy + .initializeLargeSpendProposal({ dao, amount: AMOUNT_PER_MONTH }) + .then(...callbacks); + }); + it("the transfer payload executes once the Squads proposal is approved", async function () { const amount = AMOUNT_PER_MONTH.muln(3); diff --git a/tests/futarchy/unit/initializeMintTokensProposal.test.ts b/tests/futarchy/unit/initializeMintTokensProposal.test.ts index 1d3e8c37..3ee81d47 100644 --- a/tests/futarchy/unit/initializeMintTokensProposal.test.ts +++ b/tests/futarchy/unit/initializeMintTokensProposal.test.ts @@ -19,6 +19,7 @@ import { expectError, forceApproveSquadsProposal, } from "../../utils.js"; +import { setTypedProposalsEnabled } from "../utils.js"; import { TestContext } from "../../main.test.js"; async function setMintAuthority( @@ -206,6 +207,23 @@ export default function suite() { .then(...callbacks); }); + it("throws error when the DAO has typed proposals off", async function () { + await setMintAuthority(this, META, squadsMultisigVault); + await setTypedProposalsEnabled(this, dao, false); + + const callbacks = expectError( + "TypedProposalsDisabled", + "created a mint tokens proposal on a DAO with typed proposals off", + ); + await this.futarchy + .initializeMintTokensProposal({ + dao, + amount: new BN(1_000_000_000), + recipient, + }) + .then(...callbacks); + }); + it("the MintTo payload executes once the Squads proposal is approved", async function () { await setMintAuthority(this, META, squadsMultisigVault); diff --git a/tests/futarchy/unit/initializeSpendingLimitChangeProposal.test.ts b/tests/futarchy/unit/initializeSpendingLimitChangeProposal.test.ts index c5d89a7b..e395cd67 100644 --- a/tests/futarchy/unit/initializeSpendingLimitChangeProposal.test.ts +++ b/tests/futarchy/unit/initializeSpendingLimitChangeProposal.test.ts @@ -13,6 +13,7 @@ import { expectError, forceApproveSquadsProposal, } from "../../utils.js"; +import { setTypedProposalsEnabled } from "../utils.js"; const ONE_BUCK_PRICE = PriceMath.getAmmPrice(1, 6, 6); @@ -187,6 +188,24 @@ export default function suite() { .then(...callbacks); }); + it("throws error when the DAO has typed proposals off", async function () { + await setTypedProposalsEnabled(this, dao, false); + + const callbacks = expectError( + "TypedProposalsDisabled", + "created a spending limit change proposal on a DAO with typed proposals off", + ); + await this.futarchy + .initializeSpendingLimitChangeProposal({ + dao, + config: { + amountPerMonth: new BN(1_000_000_000), // 1,000 USDC + members: [Keypair.generate().publicKey], + }, + }) + .then(...callbacks); + }); + it("the executed and synced end state matches the declaration", async function () { const config = { amountPerMonth: new BN(25_000_000_000), // 25,000 USDC From 34af1067b1c0efc3cb6ae7741d45e89b8a3277e1 Mon Sep 17 00:00:00 2001 From: Pileks Date: Fri, 11 Sep 2026 22:57:16 +0200 Subject: [PATCH 05/16] gate proposal launch based on typed proposal initialization state --- .../src/instructions/launch_proposal.rs | 9 ++ tests/futarchy/main.test.ts | 2 + .../futarchy/unit/typedProposalsOptIn.test.ts | 123 ++++++++++++++++++ tests/utils.ts | 16 ++- 4 files changed, 146 insertions(+), 4 deletions(-) create mode 100644 tests/futarchy/unit/typedProposalsOptIn.test.ts diff --git a/programs/futarchy/src/instructions/launch_proposal.rs b/programs/futarchy/src/instructions/launch_proposal.rs index a63ad73e..6876ca11 100644 --- a/programs/futarchy/src/instructions/launch_proposal.rs +++ b/programs/futarchy/src/instructions/launch_proposal.rs @@ -64,6 +64,15 @@ impl<'info> LaunchProposal<'info> { // Kind gates, checked at launch rather than create so that pre-created // drafts can't bypass them + + // Typed kinds need the DAO typed proposals enabled. + if !matches!(self.proposal.action, ProposalAction::ExecuteArbitrary) { + require!( + self.dao.typed_proposals_enabled, + FutarchyError::TypedProposalsDisabled + ); + } + let params = self.proposal.action.params(); if params.team_sponsorship_policy == TeamSponsorshipPolicy::Required { diff --git a/tests/futarchy/main.test.ts b/tests/futarchy/main.test.ts index 46475324..07af418a 100644 --- a/tests/futarchy/main.test.ts +++ b/tests/futarchy/main.test.ts @@ -15,6 +15,7 @@ import initializeHostileTakeoverProposal from "./unit/initializeHostileTakeoverP import initializeHostileLiquidateProposal from "./unit/initializeHostileLiquidateProposal.test.js"; import initializeBuybackTokenProposal from "./unit/initializeBuybackTokenProposal.test.js"; import launchProposal from "./unit/launchProposal.test.js"; +import typedProposalsOptIn from "./unit/typedProposalsOptIn.test.js"; import sponsorProposal from "./unit/sponsorProposal.test.js"; import finalizeProposal from "./unit/finalizeProposal.test.js"; import updateDao from "./unit/updateDao.test.js"; @@ -96,6 +97,7 @@ export default function suite() { initializeBuybackTokenProposal, ); describe("#launch_proposal", launchProposal); + describe("typed proposals opt-in", typedProposalsOptIn); describe("#sponsor_proposal", sponsorProposal); describe("#finalize_proposal", finalizeProposal); describe("#update_dao", updateDao); diff --git a/tests/futarchy/unit/typedProposalsOptIn.test.ts b/tests/futarchy/unit/typedProposalsOptIn.test.ts new file mode 100644 index 00000000..64302e15 --- /dev/null +++ b/tests/futarchy/unit/typedProposalsOptIn.test.ts @@ -0,0 +1,123 @@ +import { + ComputeBudgetProgram, + Keypair, + PublicKey, + TransactionInstruction, +} from "@solana/web3.js"; +import BN from "bn.js"; +import { assert } from "chai"; +import { expectError, setupBasicDao } from "../../utils.js"; +import { setTypedProposalsEnabled } from "../utils.js"; + +const MEMO_PROGRAM_ID = new PublicKey( + "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr", +); + +// Every term differs from the catalog's 10 days, +10% and 1-day warm-up. +const SECONDS_PER_PROPOSAL = 60 * 60 * 24 * 2; +const TWAP_START_DELAY_SECONDS = 60 * 60 * 12; +const PASS_THRESHOLD_BPS = 300; +const TEAM_SPONSORED_PASS_THRESHOLD_BPS = -300; +const BASE_TO_STAKE = new BN(100_000_000); // 100 tokens + +export default function suite() { + let META: PublicKey, USDC: PublicKey, dao: PublicKey; + + beforeEach(async function () { + META = await this.createMint(this.payer.publicKey, 6); + USDC = await this.createMint(this.payer.publicKey, 6); + + await this.createTokenAccount(META, this.payer.publicKey); + await this.createTokenAccount(USDC, this.payer.publicKey); + + await this.mintTo( + META, + this.payer.publicKey, + this.payer, + 200_000 * 1_000_000, + ); + await this.mintTo( + USDC, + this.payer.publicKey, + this.payer, + 200_000 * 1_000_000, + ); + + dao = await setupBasicDao({ + context: this, + baseMint: META, + quoteMint: USDC, + secondsPerProposal: SECONDS_PER_PROPOSAL, + twapStartDelaySeconds: TWAP_START_DELAY_SECONDS, + passThresholdBps: PASS_THRESHOLD_BPS, + teamSponsoredPassThresholdBps: TEAM_SPONSORED_PASS_THRESHOLD_BPS, + baseToStake: BASE_TO_STAKE, + }); + + await this.futarchy + .provideLiquidityIx({ + dao, + baseMint: META, + quoteMint: USDC, + quoteAmount: new BN(100_000 * 1_000_000), // 100,000 USDC + maxBaseAmount: new BN(100_000 * 1_000_000), + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), + ]) + .rpc(); + + await setTypedProposalsEnabled(this, dao, false); + }); + + it("still creates a plain proposal while off", async function () { + const memoIx = new TransactionInstruction({ + programId: MEMO_PROGRAM_ID, + keys: [], + data: Buffer.from("arbitrary", "utf8"), + }); + + const { proposal } = await this.initializeProposal({ + dao, + instructions: [memoIx], + }); + + const storedProposal = await this.futarchy.getProposal(proposal); + assert.exists(storedProposal.state.draft); + assert.exists(storedProposal.action.executeArbitrary); + }); + + it("refuses to launch a typed draft after the switch is flipped off underneath it", async function () { + await setTypedProposalsEnabled(this, dao, true); + + const { proposal, squadsProposal } = + await this.futarchy.initializeSpendingLimitChangeProposal({ + dao, + config: { + amountPerMonth: new BN(1_000_000_000), // 1,000 USDC + members: [Keypair.generate().publicKey], + }, + }); + await this.futarchy.sponsorProposalIx({ proposal, dao }).rpc(); + + await setTypedProposalsEnabled(this, dao, false); + + const callbacks = expectError( + "TypedProposalsDisabled", + "launched a typed draft on a DAO with typed proposals off", + ); + await this.futarchy + .launchProposalIx({ + proposal, + dao, + baseMint: META, + quoteMint: USDC, + squadsProposal, + }) + .rpc() + .then(...callbacks); + + const storedProposal = await this.futarchy.getProposal(proposal); + assert.exists(storedProposal.state.draft); + }); +} diff --git a/tests/utils.ts b/tests/utils.ts index 69af5c5c..bc8cd5f1 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -32,14 +32,22 @@ export async function setupBasicDao({ context, baseMint, quoteMint, + secondsPerProposal = 60 * 60 * 24 * 3, + twapStartDelaySeconds = 60 * 60 * 24, + passThresholdBps = 300, teamSponsoredPassThresholdBps = 300, + baseToStake = new BN(0), teamAddress, initialSpendingLimit = null, }: { context: TestContext; baseMint: PublicKey; quoteMint: PublicKey; + secondsPerProposal?: number; + twapStartDelaySeconds?: number; + passThresholdBps?: number; teamSponsoredPassThresholdBps?: number; + baseToStake?: typeof BN.prototype; teamAddress?: PublicKey; initialSpendingLimit?: { amountPerMonth: typeof BN.prototype; @@ -53,16 +61,16 @@ export async function setupBasicDao({ baseMint, quoteMint, params: { - secondsPerProposal: 60 * 60 * 24 * 3, - twapStartDelaySeconds: 60 * 60 * 24, + secondsPerProposal, + twapStartDelaySeconds, twapInitialObservation: THOUSAND_BUCK_PRICE, twapMaxObservationChangePerUpdate: THOUSAND_BUCK_PRICE.divn(100), minQuoteFutarchicLiquidity: new BN(10_000), minBaseFutarchicLiquidity: new BN(10_000), - passThresholdBps: 300, + passThresholdBps, nonce, initialSpendingLimit, - baseToStake: new BN(0), + baseToStake, teamSponsoredPassThresholdBps, teamAddress: teamAddress || context.payer.publicKey, }, From bc690221d1f124285597c314ed9652f5fc3fba24 Mon Sep 17 00:00:00 2001 From: Pileks Date: Fri, 11 Sep 2026 23:11:18 +0200 Subject: [PATCH 06/16] proposals should initialize with parameters according to their dao's typed proposal opt-in status --- .../src/instructions/initialize_proposal.rs | 3 +- .../futarchy/src/state/proposal_action.rs | 24 +++++++++++ .../futarchy/unit/typedProposalsOptIn.test.ts | 41 ++++++++++++++++--- 3 files changed, 61 insertions(+), 7 deletions(-) diff --git a/programs/futarchy/src/instructions/initialize_proposal.rs b/programs/futarchy/src/instructions/initialize_proposal.rs index 201e305d..6155beeb 100644 --- a/programs/futarchy/src/instructions/initialize_proposal.rs +++ b/programs/futarchy/src/instructions/initialize_proposal.rs @@ -88,7 +88,8 @@ impl InitializeProposal<'_> { dao.proposal_count += 1; let action = ProposalAction::ExecuteArbitrary; - let params = action.params(); + // A preview: launch writes the terms from the configuration in force later. + let params = action.params_for(dao, false); proposal.set_inner(Proposal { number: dao.proposal_count, diff --git a/programs/futarchy/src/state/proposal_action.rs b/programs/futarchy/src/state/proposal_action.rs index 06757916..d55781cd 100644 --- a/programs/futarchy/src/state/proposal_action.rs +++ b/programs/futarchy/src/state/proposal_action.rs @@ -143,6 +143,30 @@ impl ProposalAction { } } + /// The parameters a proposal of this kind runs under for `dao`. Plain + /// proposals of a DAO that has not opted in run under the DAO's own + /// configuration, where sponsorship by the current team selects the + /// team-sponsored threshold; everything else is the catalog. + pub fn params_for(&self, dao: &Dao, is_team_sponsored: bool) -> InstructionParams { + let follows_dao_config = + matches!(self, ProposalAction::ExecuteArbitrary) && !dao.typed_proposals_enabled; + + if !follows_dao_config { + return self.params(); + } + + InstructionParams { + duration_seconds: dao.seconds_per_proposal, + pass_threshold_bps: if is_team_sponsored { + dao.team_sponsored_pass_threshold_bps + } else { + dao.pass_threshold_bps as i16 + }, + twap_start_delay_seconds: dao.twap_start_delay_seconds, + ..self.params() + } + } + /// Per-kind launch gates over caller-supplied accounts, hooked in by /// `launch_proposal`. Kinds with no account gate require an empty list. pub fn verify_launch_accounts<'info>( diff --git a/tests/futarchy/unit/typedProposalsOptIn.test.ts b/tests/futarchy/unit/typedProposalsOptIn.test.ts index 64302e15..d8782fad 100644 --- a/tests/futarchy/unit/typedProposalsOptIn.test.ts +++ b/tests/futarchy/unit/typedProposalsOptIn.test.ts @@ -20,6 +20,15 @@ const PASS_THRESHOLD_BPS = 300; const TEAM_SPONSORED_PASS_THRESHOLD_BPS = -300; const BASE_TO_STAKE = new BN(100_000_000); // 100 tokens +const CATALOG_DURATION_SECONDS = 60 * 60 * 24 * 10; +const CATALOG_PASS_THRESHOLD_BPS = 1000; + +const memoIx = new TransactionInstruction({ + programId: MEMO_PROGRAM_ID, + keys: [], + data: Buffer.from("arbitrary", "utf8"), +}); + export default function suite() { let META: PublicKey, USDC: PublicKey, dao: PublicKey; @@ -71,12 +80,6 @@ export default function suite() { }); it("still creates a plain proposal while off", async function () { - const memoIx = new TransactionInstruction({ - programId: MEMO_PROGRAM_ID, - keys: [], - data: Buffer.from("arbitrary", "utf8"), - }); - const { proposal } = await this.initializeProposal({ dao, instructions: [memoIx], @@ -87,6 +90,32 @@ export default function suite() { assert.exists(storedProposal.action.executeArbitrary); }); + describe("preview", function () { + it("a plain draft previews the DAO's own duration and threshold while off", async function () { + const { proposal } = await this.initializeProposal({ + dao, + instructions: [memoIx], + }); + + const storedProposal = await this.futarchy.getProposal(proposal); + assert.equal(storedProposal.durationInSeconds, SECONDS_PER_PROPOSAL); + assert.equal(storedProposal.passThresholdBps, PASS_THRESHOLD_BPS); + }); + + it("a plain draft previews the catalog's duration and threshold while on", async function () { + await setTypedProposalsEnabled(this, dao, true); + + const { proposal } = await this.initializeProposal({ + dao, + instructions: [memoIx], + }); + + const storedProposal = await this.futarchy.getProposal(proposal); + assert.equal(storedProposal.durationInSeconds, CATALOG_DURATION_SECONDS); + assert.equal(storedProposal.passThresholdBps, CATALOG_PASS_THRESHOLD_BPS); + }); + }); + it("refuses to launch a typed draft after the switch is flipped off underneath it", async function () { await setTypedProposalsEnabled(this, dao, true); From e5927a43feb1f05407775e2bb28697e10b1b3ab7 Mon Sep 17 00:00:00 2001 From: Pileks Date: Sat, 12 Sep 2026 00:43:50 +0200 Subject: [PATCH 07/16] admin override for new opt-in typed proposal scheme --- .../admin_update_proposal_params.rs | 8 +++- .../src/instructions/initialize_proposal.rs | 1 + .../src/instructions/resize_proposal.rs | 7 +-- .../src/instructions/typed_initialize.rs | 1 + programs/futarchy/src/state/proposal.rs | 4 +- sdk/src/futarchy/v0.6/types/futarchy.ts | 34 +++++++++++++- .../futarchy/unit/adminCancelProposal.test.ts | 2 +- .../unit/adminUpdateProposalParams.test.ts | 2 + tests/futarchy/unit/finalizeProposal.test.ts | 8 ++-- tests/futarchy/unit/resizeProposal.test.ts | 22 +++++++--- .../futarchy/unit/typedProposalsOptIn.test.ts | 44 +++++++++++++++++++ 11 files changed, 116 insertions(+), 17 deletions(-) diff --git a/programs/futarchy/src/instructions/admin_update_proposal_params.rs b/programs/futarchy/src/instructions/admin_update_proposal_params.rs index a7dd449c..26004c08 100644 --- a/programs/futarchy/src/instructions/admin_update_proposal_params.rs +++ b/programs/futarchy/src/instructions/admin_update_proposal_params.rs @@ -55,7 +55,10 @@ impl AdminUpdateProposalParams<'_> { // The same comparison `launch_proposal` makes require_gt!( duration_in_seconds, - self.proposal.action.params().twap_start_delay_seconds, + self.proposal + .action + .params_for(&self.dao, false) + .twap_start_delay_seconds, FutarchyError::ProposalDurationTooShort ); } @@ -91,6 +94,9 @@ impl AdminUpdateProposalParams<'_> { proposal.pass_threshold_bps = pass_threshold_bps; } + // `launch_proposal` keeps these values instead of writing its own. + proposal.params_overridden = true; + dao.seq_num += 1; let clock = Clock::get()?; diff --git a/programs/futarchy/src/instructions/initialize_proposal.rs b/programs/futarchy/src/instructions/initialize_proposal.rs index 6155beeb..5b01ecd3 100644 --- a/programs/futarchy/src/instructions/initialize_proposal.rs +++ b/programs/futarchy/src/instructions/initialize_proposal.rs @@ -111,6 +111,7 @@ impl InitializeProposal<'_> { pass_threshold_bps: params.pass_threshold_bps, council_can_block: params.council_can_block, action, + params_overridden: false, }); dao.seq_num += 1; diff --git a/programs/futarchy/src/instructions/resize_proposal.rs b/programs/futarchy/src/instructions/resize_proposal.rs index 7e3d7350..7c1d6249 100644 --- a/programs/futarchy/src/instructions/resize_proposal.rs +++ b/programs/futarchy/src/instructions/resize_proposal.rs @@ -26,10 +26,10 @@ impl ResizeProposal<'_> { require_eq!(is_discriminator_correct, true); const AFTER_REALLOC_SIZE: usize = Proposal::MIGRATED_SIZE; - // 401 bytes: 32 (Option sponsored_by replacing the bool) + // 402 bytes: 32 (Option sponsored_by replacing the bool) // + 2 (i16 pass_threshold_bps) + 1 (bool council_can_block) - // + 366 (ProposalAction) - const BEFORE_REALLOC_SIZE: usize = AFTER_REALLOC_SIZE - 401; + // + 366 (ProposalAction) + 1 (bool params_overridden) + const BEFORE_REALLOC_SIZE: usize = AFTER_REALLOC_SIZE - 402; if proposal.data_len() != BEFORE_REALLOC_SIZE { // already realloced @@ -87,6 +87,7 @@ impl ResizeProposal<'_> { pass_threshold_bps, council_can_block: true, action, + params_overridden: false, }; proposal.realloc(AFTER_REALLOC_SIZE, true)?; diff --git a/programs/futarchy/src/instructions/typed_initialize.rs b/programs/futarchy/src/instructions/typed_initialize.rs index 946487ee..8d618389 100644 --- a/programs/futarchy/src/instructions/typed_initialize.rs +++ b/programs/futarchy/src/instructions/typed_initialize.rs @@ -156,6 +156,7 @@ impl TypedInitializeAccounts<'_> { pass_threshold_bps: params.pass_threshold_bps, council_can_block: params.council_can_block, action, + params_overridden: false, }; self.proposal.set_inner(proposal); diff --git a/programs/futarchy/src/state/proposal.rs b/programs/futarchy/src/state/proposal.rs index 9e156220..ec1e039f 100644 --- a/programs/futarchy/src/state/proposal.rs +++ b/programs/futarchy/src/state/proposal.rs @@ -41,12 +41,14 @@ pub struct Proposal { pub fail_quote_mint: Pubkey, /// The team that last sponsored the proposal. `None` = never sponsored. pub sponsored_by: Option, - /// Snapshot of the kind's threshold at create. pub pass_threshold_bps: i16, /// Snapshot of the kind's blockable flag at create. pub council_can_block: bool, /// The typed action parameters. pub action: ProposalAction, + /// Set by `admin_update_proposal_params`. `launch_proposal` then leaves the + /// duration and threshold alone. + pub params_overridden: bool, } impl Proposal { diff --git a/sdk/src/futarchy/v0.6/types/futarchy.ts b/sdk/src/futarchy/v0.6/types/futarchy.ts index e9f9d392..9d13cf86 100644 --- a/sdk/src/futarchy/v0.6/types/futarchy.ts +++ b/sdk/src/futarchy/v0.6/types/futarchy.ts @@ -2786,6 +2786,10 @@ export type Futarchy = { }, { name: "durationInSeconds"; + docs: [ + "The duration finalize reads. A preview at create; written at launch from", + "the configuration in force unless `params_overridden`.", + ]; type: "u32"; }, { @@ -2819,7 +2823,10 @@ export type Futarchy = { }, { name: "passThresholdBps"; - docs: ["Snapshot of the kind's threshold at create."]; + docs: [ + "The threshold finalize reads. A preview at create; written at launch from", + "the configuration in force unless `params_overridden`.", + ]; type: "i16"; }, { @@ -2834,6 +2841,14 @@ export type Futarchy = { defined: "ProposalAction"; }; }, + { + name: "paramsOverridden"; + docs: [ + "Set by `admin_update_proposal_params`. Launch then leaves the duration", + "and threshold alone.", + ]; + type: "bool"; + }, ]; }; }, @@ -8060,6 +8075,10 @@ export const IDL: Futarchy = { }, { name: "durationInSeconds", + docs: [ + "The duration finalize reads. A preview at create; written at launch from", + "the configuration in force unless `params_overridden`.", + ], type: "u32", }, { @@ -8093,7 +8112,10 @@ export const IDL: Futarchy = { }, { name: "passThresholdBps", - docs: ["Snapshot of the kind's threshold at create."], + docs: [ + "The threshold finalize reads. A preview at create; written at launch from", + "the configuration in force unless `params_overridden`.", + ], type: "i16", }, { @@ -8108,6 +8130,14 @@ export const IDL: Futarchy = { defined: "ProposalAction", }, }, + { + name: "paramsOverridden", + docs: [ + "Set by `admin_update_proposal_params`. Launch then leaves the duration", + "and threshold alone.", + ], + type: "bool", + }, ], }, }, diff --git a/tests/futarchy/unit/adminCancelProposal.test.ts b/tests/futarchy/unit/adminCancelProposal.test.ts index 3588adea..f82a5d15 100644 --- a/tests/futarchy/unit/adminCancelProposal.test.ts +++ b/tests/futarchy/unit/adminCancelProposal.test.ts @@ -345,7 +345,7 @@ export default function suite() { const raw = await this.banksClient.getAccount(proposal); const legacy = Buffer.concat([ Buffer.from(raw.data.subarray(0, 347)), - Buffer.from([0xb1, 0xf3, 0x00, 0x03, 0xf0, 0x37, 0xa2, 0x00]), + Buffer.from([0xb1, 0xf3, 0x00, 0x03, 0x00, 0x37, 0xa2, 0x00]), ]); assert.equal(legacy.length, 355); this.context.setAccount(proposal, { ...raw, data: legacy }); diff --git a/tests/futarchy/unit/adminUpdateProposalParams.test.ts b/tests/futarchy/unit/adminUpdateProposalParams.test.ts index 3c3ddbfc..62efadc4 100644 --- a/tests/futarchy/unit/adminUpdateProposalParams.test.ts +++ b/tests/futarchy/unit/adminUpdateProposalParams.test.ts @@ -132,6 +132,7 @@ export default function suite() { const before = await this.futarchy.getProposal(proposal); assert.equal(before.durationInSeconds, ARBITRARY_DURATION_SECONDS); assert.equal(before.passThresholdBps, ARBITRARY_PASS_THRESHOLD_BPS); + assert.isFalse(before.paramsOverridden); await this.futarchy .adminUpdateProposalParamsIx({ @@ -145,6 +146,7 @@ export default function suite() { const after = await this.futarchy.getProposal(proposal); assert.equal(after.durationInSeconds, DAY_SECONDS * 2); assert.equal(after.passThresholdBps, 200); + assert.isTrue(after.paramsOverridden); }); it("leaves the threshold alone when only the duration is set", async function () { diff --git a/tests/futarchy/unit/finalizeProposal.test.ts b/tests/futarchy/unit/finalizeProposal.test.ts index e0ed421f..84ae6862 100644 --- a/tests/futarchy/unit/finalizeProposal.test.ts +++ b/tests/futarchy/unit/finalizeProposal.test.ts @@ -155,12 +155,13 @@ export default function suite() { // discriminator plus the 339-byte Pending body, then 8 bytes standing in // for the residue a legacy account carries past its Pending body. The // residue decodes as pass_threshold_bps = -3151, council_can_block = - // false, action = ExecuteArbitrary — a well-formed new-layout read, so - // only the size guard stands between it and finalization. + // false, action = ExecuteArbitrary, params_overridden = false — a + // well-formed new-layout read, so only the size guard stands between it + // and finalization. const raw = await this.banksClient.getAccount(proposal); const legacy = Buffer.concat([ Buffer.from(raw.data.subarray(0, 347)), - Buffer.from([0xb1, 0xf3, 0x00, 0x03, 0xf0, 0x37, 0xa2, 0x00]), + Buffer.from([0xb1, 0xf3, 0x00, 0x03, 0x00, 0x37, 0xa2, 0x00]), ]); assert.equal(legacy.length, 355); this.context.setAccount(proposal, { ...raw, data: legacy }); @@ -170,6 +171,7 @@ export default function suite() { assert.equal(crafted.passThresholdBps, -3151); assert.isFalse(crafted.councilCanBlock); assert.isDefined(crafted.action.executeArbitrary); + assert.isFalse(crafted.paramsOverridden); const callbacks = expectError( "AccountNotMigrated", diff --git a/tests/futarchy/unit/resizeProposal.test.ts b/tests/futarchy/unit/resizeProposal.test.ts index 9aec322a..66524257 100644 --- a/tests/futarchy/unit/resizeProposal.test.ts +++ b/tests/futarchy/unit/resizeProposal.test.ts @@ -13,10 +13,11 @@ import { assert } from "chai"; // Rewrites a real (new-layout) Proposal account to the pre-migration on-chain // layout by re-encoding its body as the `oldProposal` IDL type (dropping the -// appended `pass_threshold_bps`, `council_can_block`, and `action`, and -// collapsing `sponsored_by` back to the `is_team_sponsored` bit). The -// optional overrides let a test pin `is_team_sponsored`, the state, or the -// duration without driving the sponsor/launch flows. +// appended `pass_threshold_bps`, `council_can_block`, `action` and +// `params_overridden`, and collapsing `sponsored_by` back to the +// `is_team_sponsored` bit). The optional overrides let a test pin +// `is_team_sponsored`, the state, or the duration without driving the +// sponsor/launch flows. async function makeOldLayout( ctx: TestContext, proposal: PublicKey, @@ -28,9 +29,10 @@ async function makeOldLayout( ): Promise<{ AFTER: number; BEFORE: number }> { const raw = await ctx.banksClient.getAccount(proposal); const AFTER = raw.data.length; - // 401 bytes: sponsored_by (Option) in place of is_team_sponsored (bool) + // 402 bytes: sponsored_by (Option) in place of is_team_sponsored (bool) // + pass_threshold_bps (i16) + council_can_block (bool) + action (ProposalAction) - const BEFORE = AFTER - 401; + // + params_overridden (bool) + const BEFORE = AFTER - 402; const disc = Buffer.from(raw.data.slice(0, 8)); const coder = ctx.futarchy.futarchy.account.proposal.coder.accounts; @@ -146,6 +148,7 @@ export default function suite() { const migrated = await this.futarchy.getProposal(proposal); assert.isDefined(migrated.action.executeArbitrary); assert.isTrue(migrated.councilCanBlock); + assert.isFalse(migrated.paramsOverridden); // The kind constants, not the vestigial per-DAO threshold (300) or the // legacy duration: a draft has no live market, so the permissionless // crank's timing must not decide the rules it finalizes under. @@ -190,6 +193,7 @@ export default function suite() { this.payer.publicKey.toBase58(), ); assert.equal(migrated.passThresholdBps, 1000); + assert.isFalse(migrated.paramsOverridden); }); it("snapshots the DAO threshold and preserves the duration for a launched proposal", async function () { @@ -211,6 +215,7 @@ export default function suite() { assert.equal(migrated.durationInSeconds, 3600); assert.isDefined(migrated.action.executeArbitrary); assert.isTrue(migrated.councilCanBlock); + assert.isFalse(migrated.paramsOverridden); }); it("snapshots the team-sponsored threshold for a launched team-sponsored proposal", async function () { @@ -230,6 +235,7 @@ export default function suite() { this.payer.publicKey.toBase58(), ); assert.equal(migrated.passThresholdBps, -100); + assert.isFalse(migrated.paramsOverridden); }); it("is a no-op on an already-new-layout proposal", async function () { @@ -259,6 +265,9 @@ export default function suite() { .accounts({ proposal, dao, payer: this.payer.publicKey }) .rpc(); + const migrated = await this.futarchy.getProposal(proposal); + assert.isFalse(migrated.paramsOverridden); + // Migrated drafts land on the catalog params, and stay `ExecuteArbitrary` // drafts — so the per-proposal admin lever must still apply to them. await this.futarchy @@ -273,6 +282,7 @@ export default function suite() { const retuned = await this.futarchy.getProposal(proposal); assert.equal(retuned.durationInSeconds, 60 * 60 * 24 * 2); assert.equal(retuned.passThresholdBps, 500); + assert.isTrue(retuned.paramsOverridden); }); it("rejects a DAO that is not the proposal's", async function () { diff --git a/tests/futarchy/unit/typedProposalsOptIn.test.ts b/tests/futarchy/unit/typedProposalsOptIn.test.ts index d8782fad..d5679f3d 100644 --- a/tests/futarchy/unit/typedProposalsOptIn.test.ts +++ b/tests/futarchy/unit/typedProposalsOptIn.test.ts @@ -22,6 +22,7 @@ const BASE_TO_STAKE = new BN(100_000_000); // 100 tokens const CATALOG_DURATION_SECONDS = 60 * 60 * 24 * 10; const CATALOG_PASS_THRESHOLD_BPS = 1000; +const CATALOG_TWAP_START_DELAY_SECONDS = 60 * 60 * 24; const memoIx = new TransactionInstruction({ programId: MEMO_PROGRAM_ID, @@ -116,6 +117,49 @@ export default function suite() { }); }); + describe("admin tuning", function () { + it("accepts a duration above the DAO's warm-up but below the catalog's while off", async function () { + const { proposal } = await this.initializeProposal({ + dao, + instructions: [memoIx], + }); + + const durationInSeconds = + (TWAP_START_DELAY_SECONDS + CATALOG_TWAP_START_DELAY_SECONDS) / 2; + await this.futarchy + .adminUpdateProposalParamsIx({ proposal, dao, durationInSeconds }) + .rpc(); + + const storedProposal = await this.futarchy.getProposal(proposal); + assert.equal(storedProposal.durationInSeconds, durationInSeconds); + assert.isTrue(storedProposal.paramsOverridden); + }); + + it("refuses a duration equal to the DAO's warm-up while off", async function () { + const { proposal } = await this.initializeProposal({ + dao, + instructions: [memoIx], + }); + + const callbacks = expectError( + "ProposalDurationTooShort", + "tuned a duration equal to the DAO's warm-up", + ); + await this.futarchy + .adminUpdateProposalParamsIx({ + proposal, + dao, + durationInSeconds: TWAP_START_DELAY_SECONDS, + }) + .rpc() + .then(...callbacks); + + const storedProposal = await this.futarchy.getProposal(proposal); + assert.equal(storedProposal.durationInSeconds, SECONDS_PER_PROPOSAL); + assert.isFalse(storedProposal.paramsOverridden); + }); + }); + it("refuses to launch a typed draft after the switch is flipped off underneath it", async function () { await setTypedProposalsEnabled(this, dao, true); From c0b40538554e7b17747a918668246dc448752b3a Mon Sep 17 00:00:00 2001 From: Pileks Date: Sat, 12 Sep 2026 17:48:55 +0200 Subject: [PATCH 08/16] proper proposal parameter application on launch --- programs/futarchy/src/events.rs | 3 + .../src/instructions/launch_proposal.rs | 16 +- .../futarchy/src/instructions/update_dao.rs | 6 +- programs/futarchy/src/state/proposal.rs | 17 + sdk/src/futarchy/v0.6/types/futarchy.ts | 52 +-- .../unit/adminUpdateProposalParams.test.ts | 18 +- tests/futarchy/unit/launchProposal.test.ts | 301 ++++++++++++++++++ tests/futarchy/unit/resizeDao.test.ts | 4 +- .../futarchy/unit/typedProposalsOptIn.test.ts | 82 ++--- tests/futarchy/utils.ts | 50 ++- 10 files changed, 458 insertions(+), 91 deletions(-) diff --git a/programs/futarchy/src/events.rs b/programs/futarchy/src/events.rs index 03ecd4ac..171bc108 100644 --- a/programs/futarchy/src/events.rs +++ b/programs/futarchy/src/events.rs @@ -118,6 +118,9 @@ pub struct LaunchProposalEvent { pub timestamp_enqueued: i64, pub total_staked: u64, pub post_amm_state: FutarchyAmm, + /// The terms the market opened with, as written by launch. + pub duration_in_seconds: u32, + pub pass_threshold_bps: i16, } #[event] diff --git a/programs/futarchy/src/instructions/launch_proposal.rs b/programs/futarchy/src/instructions/launch_proposal.rs index 6876ca11..db7a059f 100644 --- a/programs/futarchy/src/instructions/launch_proposal.rs +++ b/programs/futarchy/src/instructions/launch_proposal.rs @@ -73,7 +73,7 @@ impl<'info> LaunchProposal<'info> { ); } - let params = self.proposal.action.params(); + let params = self.proposal.launch_params(&self.dao); if params.team_sponsorship_policy == TeamSponsorshipPolicy::Required { require!(is_team_sponsored, FutarchyError::ProposalNotTeamSponsored); @@ -83,7 +83,7 @@ impl<'info> LaunchProposal<'info> { // with an empty aggregator, and `MarketsTooYoung` blocks finalize. // Strict, because finalize needs the last update past that boundary. require_gt!( - self.proposal.duration_in_seconds, + params.duration_seconds, params.twap_start_delay_seconds, FutarchyError::ProposalDurationTooShort ); @@ -185,8 +185,10 @@ impl<'info> LaunchProposal<'info> { let clock = Clock::get()?; - // Per-kind, not per-DAO: `dao.twap_start_delay_seconds` is vestigial. - let twap_start_delay_seconds = proposal.action.params().twap_start_delay_seconds; + // Write the terms in force now; the draft only carried a preview. + let params = proposal.launch_params(dao); + proposal.duration_in_seconds = params.duration_seconds; + proposal.pass_threshold_bps = params.pass_threshold_bps; dao.amm.state = PoolState::Futarchy { spot, @@ -199,7 +201,7 @@ impl<'info> LaunchProposal<'info> { clock.unix_timestamp, dao.twap_initial_observation, dao.twap_max_observation_change_per_update, - twap_start_delay_seconds, + params.twap_start_delay_seconds, ), }, fail: Pool { @@ -211,7 +213,7 @@ impl<'info> LaunchProposal<'info> { clock.unix_timestamp, dao.twap_initial_observation, dao.twap_max_observation_change_per_update, - twap_start_delay_seconds, + params.twap_start_delay_seconds, ), }, }; @@ -229,6 +231,8 @@ impl<'info> LaunchProposal<'info> { timestamp_enqueued: proposal.timestamp_enqueued, total_staked, post_amm_state: dao.amm.clone(), + duration_in_seconds: proposal.duration_in_seconds, + pass_threshold_bps: proposal.pass_threshold_bps, }); Ok(()) diff --git a/programs/futarchy/src/instructions/update_dao.rs b/programs/futarchy/src/instructions/update_dao.rs index 2e1a7582..ff2cc138 100644 --- a/programs/futarchy/src/instructions/update_dao.rs +++ b/programs/futarchy/src/instructions/update_dao.rs @@ -12,8 +12,8 @@ pub struct UpdateDaoParams { pub base_to_stake: Option, pub team_sponsored_pass_threshold_bps: Option, pub team_address: Option, - /// `Some(true)` turns the catalog on for this DAO. `None` leaves the - /// switch as it is. `Some(false)` is refused: there is no way to turn it off. + /// `Some(true)` turns typed proposals on for this DAO. `None` leaves them + /// as they are. `Some(false)` is refused: there is no way to turn them off. pub typed_proposals_enabled: Option, } @@ -34,7 +34,7 @@ impl UpdateDao<'_> { return Err(FutarchyError::PoolNotInSpotState.into()); } - // The switch only turns on. + // Typed proposals only turn on. require!( dao_params.typed_proposals_enabled != Some(false), FutarchyError::TypedProposalsCannotBeDisabled diff --git a/programs/futarchy/src/state/proposal.rs b/programs/futarchy/src/state/proposal.rs index ec1e039f..e9e10e48 100644 --- a/programs/futarchy/src/state/proposal.rs +++ b/programs/futarchy/src/state/proposal.rs @@ -57,6 +57,23 @@ impl Proposal { self.sponsored_by == Some(team_address) } + /// The parameters this proposal launches under. + pub fn launch_params(&self, dao: &Dao) -> InstructionParams { + let params = self + .action + .params_for(dao, self.is_sponsored_by(dao.team_address)); + + if !self.params_overridden { + return params; + } + + InstructionParams { + duration_seconds: self.duration_in_seconds, + pass_threshold_bps: self.pass_threshold_bps, + ..params + } + } + /// A migrated `Proposal` account is exactly this long. pub const MIGRATED_SIZE: usize = Proposal::INIT_SPACE + 8; diff --git a/sdk/src/futarchy/v0.6/types/futarchy.ts b/sdk/src/futarchy/v0.6/types/futarchy.ts index 9d13cf86..b2196237 100644 --- a/sdk/src/futarchy/v0.6/types/futarchy.ts +++ b/sdk/src/futarchy/v0.6/types/futarchy.ts @@ -2786,10 +2786,6 @@ export type Futarchy = { }, { name: "durationInSeconds"; - docs: [ - "The duration finalize reads. A preview at create; written at launch from", - "the configuration in force unless `params_overridden`.", - ]; type: "u32"; }, { @@ -2823,10 +2819,6 @@ export type Futarchy = { }, { name: "passThresholdBps"; - docs: [ - "The threshold finalize reads. A preview at create; written at launch from", - "the configuration in force unless `params_overridden`.", - ]; type: "i16"; }, { @@ -2844,8 +2836,8 @@ export type Futarchy = { { name: "paramsOverridden"; docs: [ - "Set by `admin_update_proposal_params`. Launch then leaves the duration", - "and threshold alone.", + "Set by `admin_update_proposal_params`. `launch_proposal` then leaves the", + "duration and threshold alone.", ]; type: "bool"; }, @@ -3379,8 +3371,8 @@ export type Futarchy = { { name: "typedProposalsEnabled"; docs: [ - "`Some(true)` turns the catalog on for this DAO. `None` leaves the", - "switch as it is. `Some(false)` is refused: there is no way to turn it off.", + "`Some(true)` turns typed proposals on for this DAO. `None` leaves them", + "as they are. `Some(false)` is refused: there is no way to turn them off.", ]; type: { option: "bool"; @@ -4336,6 +4328,16 @@ export type Futarchy = { }; index: false; }, + { + name: "durationInSeconds"; + type: "u32"; + index: false; + }, + { + name: "passThresholdBps"; + type: "i16"; + index: false; + }, ]; }, { @@ -8075,10 +8077,6 @@ export const IDL: Futarchy = { }, { name: "durationInSeconds", - docs: [ - "The duration finalize reads. A preview at create; written at launch from", - "the configuration in force unless `params_overridden`.", - ], type: "u32", }, { @@ -8112,10 +8110,6 @@ export const IDL: Futarchy = { }, { name: "passThresholdBps", - docs: [ - "The threshold finalize reads. A preview at create; written at launch from", - "the configuration in force unless `params_overridden`.", - ], type: "i16", }, { @@ -8133,8 +8127,8 @@ export const IDL: Futarchy = { { name: "paramsOverridden", docs: [ - "Set by `admin_update_proposal_params`. Launch then leaves the duration", - "and threshold alone.", + "Set by `admin_update_proposal_params`. `launch_proposal` then leaves the", + "duration and threshold alone.", ], type: "bool", }, @@ -8668,8 +8662,8 @@ export const IDL: Futarchy = { { name: "typedProposalsEnabled", docs: [ - "`Some(true)` turns the catalog on for this DAO. `None` leaves the", - "switch as it is. `Some(false)` is refused: there is no way to turn it off.", + "`Some(true)` turns typed proposals on for this DAO. `None` leaves them", + "as they are. `Some(false)` is refused: there is no way to turn them off.", ], type: { option: "bool", @@ -9625,6 +9619,16 @@ export const IDL: Futarchy = { }, index: false, }, + { + name: "durationInSeconds", + type: "u32", + index: false, + }, + { + name: "passThresholdBps", + type: "i16", + index: false, + }, ], }, { diff --git a/tests/futarchy/unit/adminUpdateProposalParams.test.ts b/tests/futarchy/unit/adminUpdateProposalParams.test.ts index 62efadc4..c1c6232d 100644 --- a/tests/futarchy/unit/adminUpdateProposalParams.test.ts +++ b/tests/futarchy/unit/adminUpdateProposalParams.test.ts @@ -266,9 +266,18 @@ export default function suite() { .then(callbacks[0], callbacks[1]); }); - it("rejects a launched proposal", async function () { + it("keeps the tuned values through launch and rejects a launched proposal", async function () { await provideLiquidity(this); + await this.futarchy + .adminUpdateProposalParamsIx({ + proposal, + dao, + durationInSeconds: DAY_SECONDS * 2, + passThresholdBps: 200, + }) + .rpc(); + await this.futarchy .launchProposalIx({ proposal, @@ -279,6 +288,11 @@ export default function suite() { }) .rpc(); + const launched = await this.futarchy.getProposal(proposal); + assert.exists(launched.state.pending); + assert.equal(launched.durationInSeconds, DAY_SECONDS * 2); + assert.equal(launched.passThresholdBps, 200); + const callbacks = expectError( "ProposalNotInDraftState", "should not retune a live market", @@ -288,7 +302,7 @@ export default function suite() { .adminUpdateProposalParamsIx({ proposal, dao, - durationInSeconds: DAY_SECONDS * 2, + durationInSeconds: DAY_SECONDS * 3, }) .rpc() .then(callbacks[0], callbacks[1]); diff --git a/tests/futarchy/unit/launchProposal.test.ts b/tests/futarchy/unit/launchProposal.test.ts index 5ce43616..4be7b59b 100644 --- a/tests/futarchy/unit/launchProposal.test.ts +++ b/tests/futarchy/unit/launchProposal.test.ts @@ -16,11 +16,30 @@ import { expectError, forceApproveSquadsProposal, } from "../../utils.js"; +import { + TYPED_PROPOSALS_OFF_DAO_TERMS, + rewriteAccount, + setTypedProposalsEnabled, + setupTypedProposalsOffDao, + updateDaoViaVault, +} from "../utils.js"; +import { TestContext } from "../../main.test.js"; import { assert } from "chai"; import * as multisig from "@sqds/multisig"; const THOUSAND_BUCK_PRICE = PriceMath.getAmmPrice(1000, 6, 6); +const CATALOG_DURATION_SECONDS = 60 * 60 * 24 * 10; +const CATALOG_PASS_THRESHOLD_BPS = 1000; +const CATALOG_TWAP_START_DELAY_SECONDS = 60 * 60 * 24; +const CATALOG_SPENDING_LIMIT_CHANGE_DURATION_SECONDS = 60 * 60 * 24 * 5; +const CATALOG_SPENDING_LIMIT_CHANGE_PASS_THRESHOLD_BPS = 500; + +// Admin-tuned terms that match neither the DAO's nor the catalog's, above +// both warm-ups. +const TUNED_DURATION_SECONDS = 60 * 60 * 24 * 3; +const TUNED_PASS_THRESHOLD_BPS = 700; + export default function suite() { let META: PublicKey, USDC: PublicKey, dao: PublicKey, spendingLimit: BN; @@ -1220,4 +1239,286 @@ export default function suite() { const storedProposal = await this.futarchy.getProposal(second.proposal); assert.exists(storedProposal.state.pending); }); + + // Launch writes a proposal's duration and threshold from whatever applies at + // that moment: the DAO's own terms for a plain proposal while typed + // proposals are off, the catalog otherwise. + describe("terms at launch", function () { + let proposal: PublicKey, squadsProposal: PublicKey; + + beforeEach(async function () { + dao = await setupTypedProposalsOffDao(this, META, USDC); + ({ proposal, squadsProposal } = await initializeProposal(this, dao)); + }); + + const launch = (ctx: TestContext) => + ctx.futarchy.launchProposalIx({ + proposal, + dao, + baseMint: META, + quoteMint: USDC, + squadsProposal, + }); + + const stake = async (ctx: TestContext) => { + await ctx.futarchy + .stakeToProposalIx({ + proposal, + dao, + baseMint: META, + amount: TYPED_PROPOSALS_OFF_DAO_TERMS.baseToStake, + }) + .rpc(); + }; + + // One swap after the warm-up records an observation in both conditional + // pools and leaves their TWAPs equal, then the market runs out. + const runFlatMarketToEnd = async (ctx: TestContext) => { + await ctx.advanceBySeconds( + TYPED_PROPOSALS_OFF_DAO_TERMS.twapStartDelaySeconds + 60, + ); + await ctx.futarchy + .spotSwapIx({ + dao, + baseMint: META, + quoteMint: USDC, + swapType: "buy", + inputAmount: new BN(1_000), + }) + .rpc(); + await ctx.advanceBySeconds( + TYPED_PROPOSALS_OFF_DAO_TERMS.secondsPerProposal, + ); + }; + + it("writes the DAO's settings as of launch, not as of create", async function () { + const secondsPerProposal = + TYPED_PROPOSALS_OFF_DAO_TERMS.secondsPerProposal * 2; + const passThresholdBps = + TYPED_PROPOSALS_OFF_DAO_TERMS.passThresholdBps + 200; + await updateDaoViaVault(this, dao, { + secondsPerProposal, + passThresholdBps, + }); + + const draft = await this.futarchy.getProposal(proposal); + assert.equal( + draft.durationInSeconds, + TYPED_PROPOSALS_OFF_DAO_TERMS.secondsPerProposal, + ); + assert.equal( + draft.passThresholdBps, + TYPED_PROPOSALS_OFF_DAO_TERMS.passThresholdBps, + ); + + await stake(this); + await launch(this).rpc(); + + const launched = await this.futarchy.getProposal(proposal); + assert.exists(launched.state.pending); + assert.equal(launched.durationInSeconds, secondsPerProposal); + assert.equal(launched.passThresholdBps, passThresholdBps); + }); + + it("applies the team-sponsored threshold when the sponsorship stands at launch", async function () { + await this.futarchy.sponsorProposalIx({ proposal, dao }).rpc(); + await launch(this).rpc(); + + const launched = await this.futarchy.getProposal(proposal); + assert.exists(launched.state.pending); + assert.equal( + launched.durationInSeconds, + TYPED_PROPOSALS_OFF_DAO_TERMS.secondsPerProposal, + ); + assert.equal( + launched.passThresholdBps, + TYPED_PROPOSALS_OFF_DAO_TERMS.teamSponsoredPassThresholdBps, + ); + }); + + it("a stale sponsorship gets the plain threshold and owes the stake", async function () { + await this.futarchy.sponsorProposalIx({ proposal, dao }).rpc(); + await updateDaoViaVault(this, dao, { + teamAddress: Keypair.generate().publicKey, + }); + + const callbacks = expectError( + "InsufficientStakeToLaunch", + "launched on a stale sponsorship with no stake", + ); + await launch(this) + .rpc() + .then(...callbacks); + + await stake(this); + + // The compute-unit price makes this transaction's hash differ from the + // failed launch attempt, so it isn't rejected as already processed. + await launch(this) + .postInstructions([ + ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), + ]) + .rpc(); + + const launched = await this.futarchy.getProposal(proposal); + assert.exists(launched.state.pending); + assert.equal( + launched.sponsoredBy?.toBase58(), + this.payer.publicKey.toBase58(), + ); + assert.equal( + launched.passThresholdBps, + TYPED_PROPOSALS_OFF_DAO_TERMS.passThresholdBps, + ); + }); + + it("starts the conditional oracles after the DAO's warm-up while typed proposals are off", async function () { + await this.futarchy.sponsorProposalIx({ proposal, dao }).rpc(); + await launch(this).rpc(); + + const { pass, fail } = (await this.futarchy.getDao(dao)).amm.state + .futarchy; + assert.equal( + pass.oracle.startDelaySeconds, + TYPED_PROPOSALS_OFF_DAO_TERMS.twapStartDelaySeconds, + ); + assert.equal( + fail.oracle.startDelaySeconds, + TYPED_PROPOSALS_OFF_DAO_TERMS.twapStartDelaySeconds, + ); + }); + + it("a draft created while typed proposals are off launches under the catalog once the DAO opts in", async function () { + await this.futarchy.sponsorProposalIx({ proposal, dao }).rpc(); + await updateDaoViaVault(this, dao, { typedProposalsEnabled: true }); + + await launch(this).rpc(); + + const launched = await this.futarchy.getProposal(proposal); + assert.equal(launched.durationInSeconds, CATALOG_DURATION_SECONDS); + assert.equal(launched.passThresholdBps, CATALOG_PASS_THRESHOLD_BPS); + + const { pass, fail } = (await this.futarchy.getDao(dao)).amm.state + .futarchy; + assert.equal( + pass.oracle.startDelaySeconds, + CATALOG_TWAP_START_DELAY_SECONDS, + ); + assert.equal( + fail.oracle.startDelaySeconds, + CATALOG_TWAP_START_DELAY_SECONDS, + ); + }); + + it("a sponsored proposal passes at the DAO's negative threshold on a flat market", async function () { + await this.futarchy.sponsorProposalIx({ proposal, dao }).rpc(); + await launch(this).rpc(); + + await runFlatMarketToEnd(this); + await this.futarchy.finalizeProposal(proposal); + + const finalized = await this.futarchy.getProposal(proposal); + assert.exists(finalized.state.passed); + }); + + it("an unsponsored proposal fails at the DAO's positive threshold on a flat market", async function () { + await stake(this); + await launch(this).rpc(); + + await runFlatMarketToEnd(this); + await this.futarchy.finalizeProposal(proposal); + + const finalized = await this.futarchy.getProposal(proposal); + assert.exists(finalized.state.failed); + }); + + it("an admin override survives launch while typed proposals are off", async function () { + await this.futarchy + .adminUpdateProposalParamsIx({ + proposal, + dao, + durationInSeconds: TUNED_DURATION_SECONDS, + passThresholdBps: TUNED_PASS_THRESHOLD_BPS, + }) + .rpc(); + await this.futarchy.sponsorProposalIx({ proposal, dao }).rpc(); + + await launch(this).rpc(); + + const launched = await this.futarchy.getProposal(proposal); + assert.equal(launched.durationInSeconds, TUNED_DURATION_SECONDS); + assert.equal(launched.passThresholdBps, TUNED_PASS_THRESHOLD_BPS); + + const { pass } = (await this.futarchy.getDao(dao)).amm.state.futarchy; + assert.equal( + pass.oracle.startDelaySeconds, + TYPED_PROPOSALS_OFF_DAO_TERMS.twapStartDelaySeconds, + ); + }); + + it("an admin override survives launch while typed proposals are on", async function () { + await setTypedProposalsEnabled(this, dao, true); + await this.futarchy + .adminUpdateProposalParamsIx({ + proposal, + dao, + durationInSeconds: TUNED_DURATION_SECONDS, + passThresholdBps: TUNED_PASS_THRESHOLD_BPS, + }) + .rpc(); + await this.futarchy.sponsorProposalIx({ proposal, dao }).rpc(); + + await launch(this).rpc(); + + const launched = await this.futarchy.getProposal(proposal); + assert.equal(launched.durationInSeconds, TUNED_DURATION_SECONDS); + assert.equal(launched.passThresholdBps, TUNED_PASS_THRESHOLD_BPS); + + const { pass } = (await this.futarchy.getDao(dao)).amm.state.futarchy; + assert.equal( + pass.oracle.startDelaySeconds, + CATALOG_TWAP_START_DELAY_SECONDS, + ); + }); + + it("a typed draft launches under the catalog even if its snapshot was altered by hand", async function () { + await setTypedProposalsEnabled(this, dao, true); + + const typed = await this.futarchy.initializeSpendingLimitChangeProposal({ + dao, + config: { + amountPerMonth: new BN(1_000_000_000), // 1,000 USDC + members: [Keypair.generate().publicKey], + }, + }); + await rewriteAccount(this, typed.proposal, "proposal", (decoded) => { + decoded.durationInSeconds = TUNED_DURATION_SECONDS; + decoded.passThresholdBps = TUNED_PASS_THRESHOLD_BPS; + }); + await this.futarchy + .sponsorProposalIx({ proposal: typed.proposal, dao }) + .rpc(); + + await this.futarchy + .launchProposalIx({ + proposal: typed.proposal, + dao, + baseMint: META, + quoteMint: USDC, + squadsProposal: typed.squadsProposal, + }) + .rpc(); + + const launched = await this.futarchy.getProposal(typed.proposal); + assert.exists(launched.state.pending); + assert.equal( + launched.durationInSeconds, + CATALOG_SPENDING_LIMIT_CHANGE_DURATION_SECONDS, + ); + assert.equal( + launched.passThresholdBps, + CATALOG_SPENDING_LIMIT_CHANGE_PASS_THRESHOLD_BPS, + ); + }); + }); } diff --git a/tests/futarchy/unit/resizeDao.test.ts b/tests/futarchy/unit/resizeDao.test.ts index c209ca6c..c8a5a3c3 100644 --- a/tests/futarchy/unit/resizeDao.test.ts +++ b/tests/futarchy/unit/resizeDao.test.ts @@ -77,8 +77,8 @@ export default function suite() { it("migrates an old DAO with the new fields defaulted, preserving every other field", async function () { const original = await this.futarchy.getDao(dao); - // The migration defaults match a freshly-initialized DAO except for the - // switch, so everything else can round-trip equal below. + // The migration defaults match a freshly-initialized DAO except for + // `typedProposalsEnabled`, so everything else can round-trip equal below. assert.isNull(original.liquidator); assert.equal(original.lastFailedTakeoverAt.toString(), "0"); assert.equal(original.lastFailedLiquidationAt.toString(), "0"); diff --git a/tests/futarchy/unit/typedProposalsOptIn.test.ts b/tests/futarchy/unit/typedProposalsOptIn.test.ts index d5679f3d..27a69156 100644 --- a/tests/futarchy/unit/typedProposalsOptIn.test.ts +++ b/tests/futarchy/unit/typedProposalsOptIn.test.ts @@ -1,25 +1,17 @@ -import { - ComputeBudgetProgram, - Keypair, - PublicKey, - TransactionInstruction, -} from "@solana/web3.js"; +import { Keypair, PublicKey, TransactionInstruction } from "@solana/web3.js"; import BN from "bn.js"; import { assert } from "chai"; -import { expectError, setupBasicDao } from "../../utils.js"; -import { setTypedProposalsEnabled } from "../utils.js"; +import { expectError } from "../../utils.js"; +import { + TYPED_PROPOSALS_OFF_DAO_TERMS, + setTypedProposalsEnabled, + setupTypedProposalsOffDao, +} from "../utils.js"; const MEMO_PROGRAM_ID = new PublicKey( "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr", ); -// Every term differs from the catalog's 10 days, +10% and 1-day warm-up. -const SECONDS_PER_PROPOSAL = 60 * 60 * 24 * 2; -const TWAP_START_DELAY_SECONDS = 60 * 60 * 12; -const PASS_THRESHOLD_BPS = 300; -const TEAM_SPONSORED_PASS_THRESHOLD_BPS = -300; -const BASE_TO_STAKE = new BN(100_000_000); // 100 tokens - const CATALOG_DURATION_SECONDS = 60 * 60 * 24 * 10; const CATALOG_PASS_THRESHOLD_BPS = 1000; const CATALOG_TWAP_START_DELAY_SECONDS = 60 * 60 * 24; @@ -53,34 +45,10 @@ export default function suite() { 200_000 * 1_000_000, ); - dao = await setupBasicDao({ - context: this, - baseMint: META, - quoteMint: USDC, - secondsPerProposal: SECONDS_PER_PROPOSAL, - twapStartDelaySeconds: TWAP_START_DELAY_SECONDS, - passThresholdBps: PASS_THRESHOLD_BPS, - teamSponsoredPassThresholdBps: TEAM_SPONSORED_PASS_THRESHOLD_BPS, - baseToStake: BASE_TO_STAKE, - }); - - await this.futarchy - .provideLiquidityIx({ - dao, - baseMint: META, - quoteMint: USDC, - quoteAmount: new BN(100_000 * 1_000_000), // 100,000 USDC - maxBaseAmount: new BN(100_000 * 1_000_000), - }) - .preInstructions([ - ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), - ]) - .rpc(); - - await setTypedProposalsEnabled(this, dao, false); + dao = await setupTypedProposalsOffDao(this, META, USDC); }); - it("still creates a plain proposal while off", async function () { + it("still creates a plain proposal while typed proposals are off", async function () { const { proposal } = await this.initializeProposal({ dao, instructions: [memoIx], @@ -92,18 +60,24 @@ export default function suite() { }); describe("preview", function () { - it("a plain draft previews the DAO's own duration and threshold while off", async function () { + it("a plain draft previews the DAO's own duration and threshold while typed proposals are off", async function () { const { proposal } = await this.initializeProposal({ dao, instructions: [memoIx], }); const storedProposal = await this.futarchy.getProposal(proposal); - assert.equal(storedProposal.durationInSeconds, SECONDS_PER_PROPOSAL); - assert.equal(storedProposal.passThresholdBps, PASS_THRESHOLD_BPS); + assert.equal( + storedProposal.durationInSeconds, + TYPED_PROPOSALS_OFF_DAO_TERMS.secondsPerProposal, + ); + assert.equal( + storedProposal.passThresholdBps, + TYPED_PROPOSALS_OFF_DAO_TERMS.passThresholdBps, + ); }); - it("a plain draft previews the catalog's duration and threshold while on", async function () { + it("a plain draft previews the catalog's duration and threshold while typed proposals are on", async function () { await setTypedProposalsEnabled(this, dao, true); const { proposal } = await this.initializeProposal({ @@ -118,14 +92,16 @@ export default function suite() { }); describe("admin tuning", function () { - it("accepts a duration above the DAO's warm-up but below the catalog's while off", async function () { + it("accepts a duration above the DAO's warm-up but below the catalog's while typed proposals are off", async function () { const { proposal } = await this.initializeProposal({ dao, instructions: [memoIx], }); const durationInSeconds = - (TWAP_START_DELAY_SECONDS + CATALOG_TWAP_START_DELAY_SECONDS) / 2; + (TYPED_PROPOSALS_OFF_DAO_TERMS.twapStartDelaySeconds + + CATALOG_TWAP_START_DELAY_SECONDS) / + 2; await this.futarchy .adminUpdateProposalParamsIx({ proposal, dao, durationInSeconds }) .rpc(); @@ -135,7 +111,7 @@ export default function suite() { assert.isTrue(storedProposal.paramsOverridden); }); - it("refuses a duration equal to the DAO's warm-up while off", async function () { + it("refuses a duration equal to the DAO's warm-up while typed proposals are off", async function () { const { proposal } = await this.initializeProposal({ dao, instructions: [memoIx], @@ -149,18 +125,22 @@ export default function suite() { .adminUpdateProposalParamsIx({ proposal, dao, - durationInSeconds: TWAP_START_DELAY_SECONDS, + durationInSeconds: + TYPED_PROPOSALS_OFF_DAO_TERMS.twapStartDelaySeconds, }) .rpc() .then(...callbacks); const storedProposal = await this.futarchy.getProposal(proposal); - assert.equal(storedProposal.durationInSeconds, SECONDS_PER_PROPOSAL); + assert.equal( + storedProposal.durationInSeconds, + TYPED_PROPOSALS_OFF_DAO_TERMS.secondsPerProposal, + ); assert.isFalse(storedProposal.paramsOverridden); }); }); - it("refuses to launch a typed draft after the switch is flipped off underneath it", async function () { + it("refuses to launch a typed draft after typed proposals are turned off underneath it", async function () { await setTypedProposalsEnabled(this, dao, true); const { proposal, squadsProposal } = diff --git a/tests/futarchy/utils.ts b/tests/futarchy/utils.ts index 085e9e38..0399dc20 100644 --- a/tests/futarchy/utils.ts +++ b/tests/futarchy/utils.ts @@ -1,5 +1,6 @@ import { assert } from "chai"; -import { PublicKey } from "@solana/web3.js"; +import { ComputeBudgetProgram, PublicKey } from "@solana/web3.js"; +import BN from "bn.js"; import * as multisig from "@sqds/multisig"; import { PERMISSIONLESS_ACCOUNT, @@ -9,6 +10,7 @@ import { TestContext } from "../main.test.js"; import { executeVaultTransaction, forceApproveSquadsProposal, + setupBasicDao, } from "../utils.js"; // Re-encodes an account in place, padded back to its allocated length, for @@ -31,8 +33,8 @@ export async function rewriteAccount( ctx.context.setAccount(address, { ...raw, data: buf }); } -// Puts the DAO's typed-proposals switch in the given state. No instruction -// turns it off, so tests that need an off DAO rewrite the account. +// Sets the DAO's `typed_proposals_enabled`. No instruction turns typed +// proposals off, so tests that need them off rewrite the account. export async function setTypedProposalsEnabled( ctx: TestContext, dao: PublicKey, @@ -43,6 +45,48 @@ export async function setTypedProposalsEnabled( }); } +// Terms for a DAO with typed proposals off; every one differs from the +// catalog's 10 days, +10% and 1-day warm-up. +export const TYPED_PROPOSALS_OFF_DAO_TERMS = { + secondsPerProposal: 60 * 60 * 24 * 2, + twapStartDelaySeconds: 60 * 60 * 12, + passThresholdBps: 300, + teamSponsoredPassThresholdBps: -300, + baseToStake: new BN(100_000_000), // 100 tokens at 6 decimals +}; + +// A DAO on `TYPED_PROPOSALS_OFF_DAO_TERMS` with a 100,000-quote spot market and typed +// proposals switched off. +export async function setupTypedProposalsOffDao( + ctx: TestContext, + baseMint: PublicKey, + quoteMint: PublicKey, +): Promise { + const dao = await setupBasicDao({ + context: ctx, + baseMint, + quoteMint, + ...TYPED_PROPOSALS_OFF_DAO_TERMS, + }); + + await ctx.futarchy + .provideLiquidityIx({ + dao, + baseMint, + quoteMint, + quoteAmount: new BN(100_000 * 1_000_000), + maxBaseAmount: new BN(100_000 * 1_000_000), + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), + ]) + .rpc(); + + await setTypedProposalsEnabled(ctx, dao, false); + + return dao; +} + const EMPTY_UPDATE_DAO_PARAMS: UpdateDaoParams = { passThresholdBps: null, secondsPerProposal: null, From b593a0f0faa038000553e016fd06e7e585a07f9f Mon Sep 17 00:00:00 2001 From: Pileks Date: Sun, 13 Sep 2026 13:06:16 +0200 Subject: [PATCH 09/16] resize proposal - use dao-specific params based on dao's configuration for unlaunched proposals --- .../src/instructions/resize_proposal.rs | 5 +-- tests/futarchy/unit/resizeProposal.test.ts | 44 +++++++++++++++++++ 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/programs/futarchy/src/instructions/resize_proposal.rs b/programs/futarchy/src/instructions/resize_proposal.rs index 7c1d6249..45045fcd 100644 --- a/programs/futarchy/src/instructions/resize_proposal.rs +++ b/programs/futarchy/src/instructions/resize_proposal.rs @@ -44,11 +44,10 @@ impl ResizeProposal<'_> { let action = ProposalAction::ExecuteArbitrary; - // Draft proposals take the kind's catalog params like any new proposal. - // Launched proposals keep the rules they were launched under. + // Drafts preview what launch would write today; launched proposals keep their rules. let (pass_threshold_bps, duration_in_seconds) = if matches!(old_proposal_data.state, ProposalState::Draft { .. }) { - let params = action.params(); + let params = action.params_for(dao, old_proposal_data.is_team_sponsored); (params.pass_threshold_bps, params.duration_seconds) } else { let pass_threshold_bps = if old_proposal_data.is_team_sponsored { diff --git a/tests/futarchy/unit/resizeProposal.test.ts b/tests/futarchy/unit/resizeProposal.test.ts index 66524257..612beb28 100644 --- a/tests/futarchy/unit/resizeProposal.test.ts +++ b/tests/futarchy/unit/resizeProposal.test.ts @@ -8,6 +8,7 @@ import { } from "@solana/web3.js"; import * as multisig from "@sqds/multisig"; import { expectError, setupBasicDao } from "../../utils.js"; +import { setTypedProposalsEnabled } from "../utils.js"; import { TestContext } from "../../main.test.js"; import { assert } from "chai"; @@ -196,6 +197,49 @@ export default function suite() { assert.isFalse(migrated.paramsOverridden); }); + describe("with typed proposals off", function () { + beforeEach(async function () { + await setTypedProposalsEnabled(this, dao, false); + }); + + it("migrates a draft to a preview of the DAO's own duration and threshold", async function () { + await makeOldLayout(this, proposal, { durationInSeconds: 3600 }); + + await this.futarchy.futarchy.methods + .resizeProposal() + .accounts({ proposal, dao, payer: this.payer.publicKey }) + .rpc(); + + const migrated = await this.futarchy.getProposal(proposal); + assert.isDefined(migrated.state.draft); + assert.isNull(migrated.sponsoredBy); + assert.equal(migrated.durationInSeconds, 60 * 60 * 24 * 3); + assert.equal(migrated.passThresholdBps, 300); + assert.isFalse(migrated.paramsOverridden); + }); + + it("migrates a team-sponsored draft to a preview of the team-sponsored threshold", async function () { + await makeOldLayout(this, proposal, { + isTeamSponsored: true, + durationInSeconds: 3600, + }); + + await this.futarchy.futarchy.methods + .resizeProposal() + .accounts({ proposal, dao, payer: this.payer.publicKey }) + .rpc(); + + const migrated = await this.futarchy.getProposal(proposal); + assert.equal( + migrated.sponsoredBy?.toBase58(), + this.payer.publicKey.toBase58(), + ); + assert.equal(migrated.durationInSeconds, 60 * 60 * 24 * 3); + assert.equal(migrated.passThresholdBps, -100); + assert.isFalse(migrated.paramsOverridden); + }); + }); + it("snapshots the DAO threshold and preserves the duration for a launched proposal", async function () { await makeOldLayout(this, proposal, { state: { pending: {} }, From 367a25c866548d5a05230502df81a595a49bc207 Mon Sep 17 00:00:00 2001 From: Pileks Date: Sun, 13 Sep 2026 14:01:42 +0200 Subject: [PATCH 10/16] typed proposal opt-in end-to-end test --- .../typedProposalsOptInEndToEnd.test.ts | 355 ++++++++++++++++++ tests/futarchy/main.test.ts | 5 + tests/futarchy/utils.ts | 2 +- 3 files changed, 361 insertions(+), 1 deletion(-) create mode 100644 tests/futarchy/integration/typedProposalsOptInEndToEnd.test.ts diff --git a/tests/futarchy/integration/typedProposalsOptInEndToEnd.test.ts b/tests/futarchy/integration/typedProposalsOptInEndToEnd.test.ts new file mode 100644 index 00000000..19166e3a --- /dev/null +++ b/tests/futarchy/integration/typedProposalsOptInEndToEnd.test.ts @@ -0,0 +1,355 @@ +import { PERMISSIONLESS_ACCOUNT } from "@metadaoproject/programs"; +import { + ComputeBudgetProgram, + Keypair, + PublicKey, + TransactionInstruction, +} from "@solana/web3.js"; +import BN from "bn.js"; +import { assert } from "chai"; +import * as multisig from "@sqds/multisig"; +import { + executeVaultTransaction, + expectError, + makeOldDaoLayout, + setupBasicDao, +} from "../../utils.js"; +import { + EMPTY_UPDATE_DAO_PARAMS, + TYPED_PROPOSALS_OFF_DAO_TERMS, +} from "../utils.js"; +import { TestContext } from "../../main.test.js"; + +const MEMO_PROGRAM_ID = new PublicKey( + "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr", +); + +const CATALOG_DURATION_SECONDS = 60 * 60 * 24 * 10; +const CATALOG_PASS_THRESHOLD_BPS = 1000; +const CATALOG_TWAP_START_DELAY_SECONDS = 60 * 60 * 24; + +const PRE_MIGRATION_DAO_SIZE = 1205; +const MIGRATED_DAO_SIZE = 1264; + +const memoIx = new TransactionInstruction({ + programId: MEMO_PROGRAM_ID, + keys: [], + data: Buffer.from("arbitrary", "utf8"), +}); + +// A plain proposal at the multisig's next index whose vault transaction holds +// `instructions`. +async function filePlainProposal( + ctx: TestContext, + dao: PublicKey, + instructions: TransactionInstruction[], +) { + const { transactionIndex, squadsProposal, squadsTransaction } = + await ctx.futarchy.getNextProposalAddrs(dao); + + const { tx } = ctx.futarchy.squadsProposalCreateTx({ + dao, + instructions, + transactionIndex, + }); + [tx.recentBlockhash] = await ctx.banksClient.getLatestBlockhash(); + tx.feePayer = ctx.payer.publicKey; + tx.sign(ctx.payer, PERMISSIONLESS_ACCOUNT); + await ctx.banksClient.processTransaction(tx); + + const proposal = await ctx.futarchy.initializeProposal(dao, squadsProposal); + + return { proposal, squadsProposal, squadsTransaction }; +} + +// One spot swap after the warm-up records an observation in both conditional +// pools and leaves their TWAPs equal, then the market runs out. +async function runFlatMarketToEnd( + ctx: TestContext, + { + dao, + baseMint, + quoteMint, + twapStartDelaySeconds, + durationInSeconds, + computeUnitPrice, + }: { + dao: PublicKey; + baseMint: PublicKey; + quoteMint: PublicKey; + twapStartDelaySeconds: number; + durationInSeconds: number; + computeUnitPrice: number; + }, +) { + await ctx.advanceBySeconds(twapStartDelaySeconds + 60); + await ctx.futarchy + .spotSwapIx({ + dao, + baseMint, + quoteMint, + swapType: "buy", + inputAmount: new BN(1_000), + }) + .preInstructions([ + // The compute-unit price makes each market's swap hash unique, so a + // later flat market isn't rejected as already processed. + ComputeBudgetProgram.setComputeUnitPrice({ + microLamports: computeUnitPrice, + }), + ]) + .rpc(); + await ctx.advanceBySeconds(durationInSeconds); +} + +// A DAO that exists today: migrated with typed proposals off, it passes the +// opt-in under its own 2 days and -3%, and governs on the catalog from the +// moment the vault executes it. +export default function suite() { + it("migrates an existing DAO, passes the typed proposals opt-in under its own rules, and lands on the catalog", async function () { + const META = await this.createMint(this.payer.publicKey, 6); + const USDC = await this.createMint(this.payer.publicKey, 6); + + await this.createTokenAccount(META, this.payer.publicKey); + await this.createTokenAccount(USDC, this.payer.publicKey); + + await this.mintTo( + META, + this.payer.publicKey, + this.payer, + 200_000 * 1_000_000, + ); + await this.mintTo( + USDC, + this.payer.publicKey, + this.payer, + 200_000 * 1_000_000, + ); + + const dao = await setupBasicDao({ + context: this, + baseMint: META, + quoteMint: USDC, + ...TYPED_PROPOSALS_OFF_DAO_TERMS, + }); + await this.futarchy + .provideLiquidityIx({ + dao, + baseMint: META, + quoteMint: USDC, + quoteAmount: new BN(100_000 * 1_000_000), + maxBaseAmount: new BN(100_000 * 1_000_000), + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), + ]) + .rpc(); + + // Migrate + await makeOldDaoLayout(this, dao); + assert.equal( + (await this.banksClient.getAccount(dao)).data.length, + PRE_MIGRATION_DAO_SIZE, + ); + + await this.futarchy.resizeDaoIx({ dao }).rpc(); + + assert.equal( + (await this.banksClient.getAccount(dao)).data.length, + MIGRATED_DAO_SIZE, + ); + assert.isFalse((await this.futarchy.getDao(dao)).typedProposalsEnabled); + + // File the opt-in, and a second plain draft that stays open until the + // catalog applies + const optInIx = await this.futarchy + .updateDaoIx({ + dao, + params: { ...EMPTY_UPDATE_DAO_PARAMS, typedProposalsEnabled: true }, + }) + .instruction(); + const optIn = await filePlainProposal(this, dao, [optInIx]); + const leftover = await filePlainProposal(this, dao, [memoIx]); + + const optInDraft = await this.futarchy.getProposal(optIn.proposal); + assert.equal( + optInDraft.durationInSeconds, + TYPED_PROPOSALS_OFF_DAO_TERMS.secondsPerProposal, + ); + assert.equal( + optInDraft.passThresholdBps, + TYPED_PROPOSALS_OFF_DAO_TERMS.passThresholdBps, + ); + + // An outsider's takeover is refused while typed proposals are off + const takeover = { + dao, + newTeamAddress: Keypair.generate().publicKey, + spendingLimitAction: { keep: {} }, + }; + await this.futarchy + .initializeHostileTakeoverProposal(takeover) + .then( + ...expectError( + "TypedProposalsDisabled", + "created a hostile takeover before the DAO opted in", + ), + ); + + // Sponsor and launch under the DAO's own rules + await this.futarchy + .sponsorProposalIx({ proposal: optIn.proposal, dao }) + .rpc(); + await this.futarchy + .launchProposalIx({ + proposal: optIn.proposal, + dao, + baseMint: META, + quoteMint: USDC, + squadsProposal: optIn.squadsProposal, + }) + .rpc(); + + const optInLaunched = await this.futarchy.getProposal(optIn.proposal); + assert.exists(optInLaunched.state.pending); + assert.equal( + optInLaunched.durationInSeconds, + TYPED_PROPOSALS_OFF_DAO_TERMS.secondsPerProposal, + ); + assert.equal( + optInLaunched.passThresholdBps, + TYPED_PROPOSALS_OFF_DAO_TERMS.teamSponsoredPassThresholdBps, + ); + + const optInMarket = (await this.futarchy.getDao(dao)).amm.state.futarchy; + assert.equal( + optInMarket.pass.oracle.startDelaySeconds, + TYPED_PROPOSALS_OFF_DAO_TERMS.twapStartDelaySeconds, + ); + assert.equal( + optInMarket.fail.oracle.startDelaySeconds, + TYPED_PROPOSALS_OFF_DAO_TERMS.twapStartDelaySeconds, + ); + + // Nobody trades against it; equal TWAPs clear the -3% threshold + await runFlatMarketToEnd(this, { + dao, + baseMint: META, + quoteMint: USDC, + twapStartDelaySeconds: + TYPED_PROPOSALS_OFF_DAO_TERMS.twapStartDelaySeconds, + durationInSeconds: TYPED_PROPOSALS_OFF_DAO_TERMS.secondsPerProposal, + computeUnitPrice: 1, + }); + await this.futarchy.finalizeProposal(optIn.proposal); + + assert.exists( + (await this.futarchy.getProposal(optIn.proposal)).state.passed, + ); + const optInSquadsProposal = + await multisig.accounts.Proposal.fromAccountAddress( + this.squadsConnection, + optIn.squadsProposal, + ); + assert.isTrue( + multisig.generated.isProposalStatusApproved(optInSquadsProposal.status), + ); + + // Execute: the vault signs update_dao + await executeVaultTransaction(this, dao, optIn.squadsTransaction); + assert.isTrue((await this.futarchy.getDao(dao)).typedProposalsEnabled); + + // Retry the takeover. The SDK helper can't be used again here: the refused + // attempt already created the question and conditional vaults for this + // transaction index, so only the initialize instruction itself is sent. + const { transactionIndex, proposal: takeoverProposal } = + await this.futarchy.getNextProposalAddrs(dao); + await this.futarchy + .initializeHostileTakeoverProposalIx({ + ...takeover, + baseMint: META, + quoteMint: USDC, + transactionIndex, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 400_000 }), + // Without this the transaction would be byte-identical to the refused + // attempt and rejected as already processed. + ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), + ]) + .rpc(); + assert.exists( + (await this.futarchy.getProposal(takeoverProposal)).action + .hostileTakeover, + ); + + // A fresh plain draft previews the catalog + const fresh = await filePlainProposal(this, dao, [memoIx]); + const freshDraft = await this.futarchy.getProposal(fresh.proposal); + assert.equal(freshDraft.durationInSeconds, CATALOG_DURATION_SECONDS); + assert.equal(freshDraft.passThresholdBps, CATALOG_PASS_THRESHOLD_BPS); + + // The draft left open before the opt-in launches under the catalog + await this.futarchy + .sponsorProposalIx({ proposal: leftover.proposal, dao }) + .rpc(); + await this.futarchy + .launchProposalIx({ + proposal: leftover.proposal, + dao, + baseMint: META, + quoteMint: USDC, + squadsProposal: leftover.squadsProposal, + }) + .rpc(); + + const leftoverLaunched = await this.futarchy.getProposal(leftover.proposal); + assert.exists(leftoverLaunched.state.pending); + assert.equal(leftoverLaunched.durationInSeconds, CATALOG_DURATION_SECONDS); + assert.equal(leftoverLaunched.passThresholdBps, CATALOG_PASS_THRESHOLD_BPS); + + const leftoverMarket = (await this.futarchy.getDao(dao)).amm.state.futarchy; + assert.equal( + leftoverMarket.pass.oracle.startDelaySeconds, + CATALOG_TWAP_START_DELAY_SECONDS, + ); + assert.equal( + leftoverMarket.fail.oracle.startDelaySeconds, + CATALOG_TWAP_START_DELAY_SECONDS, + ); + + // Equal TWAPs don't clear the catalog's +10%. Finalizing frees the pool + // for the next launch. + await runFlatMarketToEnd(this, { + dao, + baseMint: META, + quoteMint: USDC, + twapStartDelaySeconds: CATALOG_TWAP_START_DELAY_SECONDS, + durationInSeconds: CATALOG_DURATION_SECONDS, + computeUnitPrice: 2, + }); + await this.futarchy.finalizeProposal(leftover.proposal); + assert.exists( + (await this.futarchy.getProposal(leftover.proposal)).state.failed, + ); + + // So does the fresh one + await this.futarchy + .sponsorProposalIx({ proposal: fresh.proposal, dao }) + .rpc(); + await this.futarchy + .launchProposalIx({ + proposal: fresh.proposal, + dao, + baseMint: META, + quoteMint: USDC, + squadsProposal: fresh.squadsProposal, + }) + .rpc(); + + const freshLaunched = await this.futarchy.getProposal(fresh.proposal); + assert.exists(freshLaunched.state.pending); + assert.equal(freshLaunched.durationInSeconds, CATALOG_DURATION_SECONDS); + assert.equal(freshLaunched.passThresholdBps, CATALOG_PASS_THRESHOLD_BPS); + }); +} diff --git a/tests/futarchy/main.test.ts b/tests/futarchy/main.test.ts index 07af418a..a024b87d 100644 --- a/tests/futarchy/main.test.ts +++ b/tests/futarchy/main.test.ts @@ -5,6 +5,7 @@ import cancelApprovedPayloadAfterLiquidation from "./integration/cancelApprovedP import gatedLiquidationUnwind from "./integration/gatedLiquidationUnwind.test.js"; import largeSpendEndToEnd from "./integration/largeSpendEndToEnd.test.js"; import cooldownRoundTrip from "./integration/cooldownRoundTrip.test.js"; +import typedProposalsOptInEndToEnd from "./integration/typedProposalsOptInEndToEnd.test.js"; import initializeDao from "./unit/initializeDao.test.js"; import initializeProposal from "./unit/initializeProposal.test.js"; @@ -148,4 +149,8 @@ export default function suite() { describe("integration: gated liquidation unwind", gatedLiquidationUnwind); describe("integration: large spend end to end", largeSpendEndToEnd); describe("integration: cooldown round-trip", cooldownRoundTrip); + describe( + "integration: typed proposals opt-in end to end", + typedProposalsOptInEndToEnd, + ); } diff --git a/tests/futarchy/utils.ts b/tests/futarchy/utils.ts index 0399dc20..916fa470 100644 --- a/tests/futarchy/utils.ts +++ b/tests/futarchy/utils.ts @@ -87,7 +87,7 @@ export async function setupTypedProposalsOffDao( return dao; } -const EMPTY_UPDATE_DAO_PARAMS: UpdateDaoParams = { +export const EMPTY_UPDATE_DAO_PARAMS: UpdateDaoParams = { passThresholdBps: null, secondsPerProposal: null, twapInitialObservation: null, From 0d4dd3e63f645f83a8a31c24fe28ff329acc59da Mon Sep 17 00:00:00 2001 From: Pileks Date: Wed, 16 Sep 2026 14:13:01 +0200 Subject: [PATCH 11/16] add clarifying comment --- .../futarchy/src/instructions/admin_update_proposal_params.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/programs/futarchy/src/instructions/admin_update_proposal_params.rs b/programs/futarchy/src/instructions/admin_update_proposal_params.rs index 26004c08..d3974d7a 100644 --- a/programs/futarchy/src/instructions/admin_update_proposal_params.rs +++ b/programs/futarchy/src/instructions/admin_update_proposal_params.rs @@ -95,6 +95,8 @@ impl AdminUpdateProposalParams<'_> { } // `launch_proposal` keeps these values instead of writing its own. + // It is assumed that a change to the proposal params applies to both, + // even when only one is changed. proposal.params_overridden = true; dao.seq_num += 1; From 2a3f26058ea72befdb65fb863c5fc9edd9ea69e6 Mon Sep 17 00:00:00 2001 From: Pileks Date: Sat, 19 Sep 2026 01:32:02 +0200 Subject: [PATCH 12/16] fix(scripts): update dao migration to include canonical spending limit --- scripts/v0.6/migrateDaosProposals.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/scripts/v0.6/migrateDaosProposals.ts b/scripts/v0.6/migrateDaosProposals.ts index 301f7858..39b4deb0 100644 --- a/scripts/v0.6/migrateDaosProposals.ts +++ b/scripts/v0.6/migrateDaosProposals.ts @@ -7,7 +7,10 @@ import { TransactionMessage, } from "@solana/web3.js"; import * as anchor from "@coral-xyz/anchor"; -import { FutarchyClient } from "@metadaoproject/programs/futarchy/v0.6"; +import { + FutarchyClient, + getSpendingLimitAddr, +} from "@metadaoproject/programs/futarchy/v0.6"; import dotenv from "dotenv"; import bs58 from "bs58"; @@ -39,7 +42,9 @@ async function main() { const daoDiscriminator = getDiscriminator("Dao"); const proposalDiscriminator = getDiscriminator("Proposal"); - const daoBatchSize = 15; + // resize_dao now also references the DAO's spending limit, two unique + // account keys per instruction, so the same cap as proposals applies. + const daoBatchSize = 10; // Each resize_proposal also references the proposal's dao, so the worst // case is two unique account keys per instruction; 10 keeps the batch under // the transaction size limit. @@ -87,6 +92,7 @@ async function main() { .resizeDao() .accounts({ dao: pubkey, + spendingLimit: getSpendingLimitAddr({ dao: pubkey })[0], payer: payer.publicKey, }) .instruction(); From 639098d7f8874764d83fa50024e7cb91c4c05c84 Mon Sep 17 00:00:00 2001 From: Pileks Date: Fri, 25 Sep 2026 21:51:49 +0200 Subject: [PATCH 13/16] fix(futarchy): re-check hostile takeover target team at launch --- programs/futarchy/src/state/proposal.rs | 2 +- .../futarchy/src/state/proposal_action.rs | 25 ++++++- sdk/src/futarchy/v0.6/types/futarchy.ts | 8 +++ tests/futarchy/unit/launchProposal.test.ts | 65 +++++++++++++++++++ 4 files changed, 98 insertions(+), 2 deletions(-) diff --git a/programs/futarchy/src/state/proposal.rs b/programs/futarchy/src/state/proposal.rs index e9e10e48..671eabb8 100644 --- a/programs/futarchy/src/state/proposal.rs +++ b/programs/futarchy/src/state/proposal.rs @@ -46,7 +46,7 @@ pub struct Proposal { pub council_can_block: bool, /// The typed action parameters. pub action: ProposalAction, - /// Set by `admin_update_proposal_params`. `launch_proposal` then leaves the + /// Set by `admin_update_proposal_params`. `launch_proposal` then leaves the /// duration and threshold alone. pub params_overridden: bool, } diff --git a/programs/futarchy/src/state/proposal_action.rs b/programs/futarchy/src/state/proposal_action.rs index d55781cd..a0328a68 100644 --- a/programs/futarchy/src/state/proposal_action.rs +++ b/programs/futarchy/src/state/proposal_action.rs @@ -56,6 +56,8 @@ pub enum ProposalAction { }, ExecuteArbitrary, HostileTakeover { + /// The team to install. Launch requires it to still differ from the + /// DAO's team. new_team_address: Pubkey, spending_limit_action: SpendingLimitAction, }, @@ -150,7 +152,7 @@ impl ProposalAction { pub fn params_for(&self, dao: &Dao, is_team_sponsored: bool) -> InstructionParams { let follows_dao_config = matches!(self, ProposalAction::ExecuteArbitrary) && !dao.typed_proposals_enabled; - + if !follows_dao_config { return self.params(); } @@ -182,6 +184,9 @@ impl ProposalAction { amount, team_address, } => verify_large_spend_launch(*amount, *team_address, dao, accounts), + ProposalAction::HostileTakeover { + new_team_address, .. + } => verify_hostile_takeover_launch(*new_team_address, dao, accounts), _ => { require_eq!(accounts.len(), 0, FutarchyError::UnexpectedLaunchAccounts); Ok(()) @@ -211,6 +216,24 @@ fn verify_large_spend_launch( Ok(()) } +/// The hostile-takeover launch gate: no extra accounts, and the create-time +/// team check re-runs against current state. +fn verify_hostile_takeover_launch( + new_team_address: Pubkey, + dao: &Dao, + accounts: &[AccountInfo], +) -> Result<()> { + require_eq!(accounts.len(), 0, FutarchyError::UnexpectedLaunchAccounts); + + require_keys_neq!( + new_team_address, + dao.team_address, + FutarchyError::InvalidTeamAddress + ); + + Ok(()) +} + /// The three-month spending cap, checked against the DAO's current record. /// Run at both create and launch. pub fn verify_large_spend_cap(amount: u64, dao: &Dao) -> Result<()> { diff --git a/sdk/src/futarchy/v0.6/types/futarchy.ts b/sdk/src/futarchy/v0.6/types/futarchy.ts index b2196237..772db7e7 100644 --- a/sdk/src/futarchy/v0.6/types/futarchy.ts +++ b/sdk/src/futarchy/v0.6/types/futarchy.ts @@ -3809,6 +3809,10 @@ export type Futarchy = { fields: [ { name: "newTeamAddress"; + docs: [ + "The team to install. Launch requires it to still differ from the", + "DAO's team.", + ]; type: "publicKey"; }, { @@ -9100,6 +9104,10 @@ export const IDL: Futarchy = { fields: [ { name: "newTeamAddress", + docs: [ + "The team to install. Launch requires it to still differ from the", + "DAO's team.", + ], type: "publicKey", }, { diff --git a/tests/futarchy/unit/launchProposal.test.ts b/tests/futarchy/unit/launchProposal.test.ts index 4be7b59b..12de69ed 100644 --- a/tests/futarchy/unit/launchProposal.test.ts +++ b/tests/futarchy/unit/launchProposal.test.ts @@ -1240,6 +1240,71 @@ export default function suite() { assert.exists(storedProposal.state.pending); }); + it("fails to launch a hostile takeover whose target is already the team", async function () { + const dao = await createDaoWithStakeThreshold( + this, + META, + USDC, + new BN(0), + this.payer, + ); + + await this.futarchy + .provideLiquidityIx({ + dao, + baseMint: META, + quoteMint: USDC, + quoteAmount: new BN(100_000 * 10 ** 6), + maxBaseAmount: new BN(100_000 * 10 ** 6), + minLiquidity: new BN(0), + positionAuthority: this.payer.publicKey, + liquidityProvider: this.payer.publicKey, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), + ]) + .rpc(); + + const newTeam = Keypair.generate(); + const stale = await this.futarchy.initializeHostileTakeoverProposal({ + dao, + newTeamAddress: newTeam.publicKey, + spendingLimitAction: { keep: {} }, + }); + + // Install the same team through another takeover while the draft is + // still unlaunched + const installed = await this.futarchy.initializeHostileTakeoverProposal({ + dao, + newTeamAddress: newTeam.publicKey, + spendingLimitAction: { keep: {} }, + }); + await forceApproveSquadsProposal(this, installed.squadsProposal); + await executeVaultTransaction(this, dao, installed.squadsTransaction); + + const storedDao = await this.futarchy.getDao(dao); + assert.equal( + storedDao.teamAddress.toBase58(), + newTeam.publicKey.toBase58(), + ); + + const callbacks = expectError( + "InvalidTeamAddress", + "launched a hostile takeover whose target is already the team", + ); + + await this.futarchy + .launchProposalIx({ + proposal: stale.proposal, + dao, + baseMint: META, + quoteMint: USDC, + squadsProposal: stale.squadsProposal, + }) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + // Launch writes a proposal's duration and threshold from whatever applies at // that moment: the DAO's own terms for a plain proposal while typed // proposals are off, the catalog otherwise. From 9de58747a5108b8d46c93951672863164f384f2c Mon Sep 17 00:00:00 2001 From: Pileks Date: Fri, 25 Sep 2026 23:54:56 +0200 Subject: [PATCH 14/16] feat(futarchy): require frozen lookup tables for generic proposals --- programs/futarchy/src/error.rs | 6 + .../src/instructions/initialize_proposal.rs | 20 +- .../src/instructions/launch_proposal.rs | 8 +- programs/futarchy/src/lib.rs | 6 +- programs/futarchy/src/squads.rs | 61 +++++ .../futarchy/src/state/proposal_action.rs | 37 +++ scripts/utils/futarchyProposal.ts | 4 + sdk/src/futarchy/v0.6/FutarchyClient.ts | 75 +++++- sdk/src/futarchy/v0.6/types/futarchy.ts | 40 +++ .../futarchy/integration/futarchyAmm.test.ts | 5 + .../typedProposalsOptInEndToEnd.test.ts | 3 + .../futarchy/unit/adminCancelProposal.test.ts | 8 +- .../futarchy/unit/adminRemoveProposal.test.ts | 5 + .../unit/adminUpdateProposalParams.test.ts | 18 +- .../executeMultisigProposalApproval.test.ts | 8 +- tests/futarchy/unit/finalizeProposal.test.ts | 13 +- .../initializeBuybackTokenProposal.test.ts | 14 +- .../futarchy/unit/initializeProposal.test.ts | 109 ++++++++ tests/futarchy/unit/launchProposal.test.ts | 252 +++++++++++++++++- tests/futarchy/unit/liquidatedGuards.test.ts | 41 ++- .../futarchy/unit/unstakeFromProposal.test.ts | 10 +- tests/futarchy/unit/updateDao.test.ts | 20 +- tests/integration/fullLaunch.test.ts | 5 + tests/integration/fullLaunch_v7.test.ts | 5 + tests/main.test.ts | 51 +++- tests/utils.ts | 63 +++++ 26 files changed, 824 insertions(+), 63 deletions(-) diff --git a/programs/futarchy/src/error.rs b/programs/futarchy/src/error.rs index fbd9f714..88caafe4 100644 --- a/programs/futarchy/src/error.rs +++ b/programs/futarchy/src/error.rs @@ -156,4 +156,10 @@ pub enum FutarchyError { TypedProposalsDisabled, #[msg("Typed proposals cannot be disabled")] TypedProposalsCannotBeDisabled, + #[msg("Address lookup tables referenced by the vault transaction must be frozen")] + UnfrozenAddressLookupTable, + #[msg("Lookup table accounts must match the vault transaction's address table lookups")] + InvalidAddressLookupTable, + #[msg("Expected the proposal's Squads vault transaction as the first launch account")] + InvalidSquadsVaultTransaction, } diff --git a/programs/futarchy/src/instructions/initialize_proposal.rs b/programs/futarchy/src/instructions/initialize_proposal.rs index 5b01ecd3..ad6359fc 100644 --- a/programs/futarchy/src/instructions/initialize_proposal.rs +++ b/programs/futarchy/src/instructions/initialize_proposal.rs @@ -13,6 +13,17 @@ pub struct InitializeProposal<'info> { pub proposal: Box>, pub squads_proposal: Box>, pub squads_multisig: Box>, + #[account( + seeds = [ + squads_multisig_program::SEED_PREFIX, + squads_multisig.key().as_ref(), + squads_multisig_program::SEED_TRANSACTION, + squads_proposal.transaction_index.to_le_bytes().as_ref(), + ], + bump, + seeds::program = squads_multisig_program::ID, + )] + pub squads_vault_transaction: Box>, #[account(mut, has_one = squads_multisig)] pub dao: Box>, #[account( @@ -35,8 +46,8 @@ pub struct InitializeProposal<'info> { pub system_program: Program<'info, System>, } -impl InitializeProposal<'_> { - pub fn validate(&self) -> Result<()> { +impl<'info> InitializeProposal<'info> { + pub fn validate(&self, remaining_accounts: &'info [AccountInfo<'info>]) -> Result<()> { require!(self.dao.liquidator.is_none(), FutarchyError::DaoLiquidated); require_eq!( @@ -61,6 +72,10 @@ impl InitializeProposal<'_> { self.squads_multisig.stale_transaction_index ); + // Every lookup table the payload resolves through must be frozen, so + // the accounts the market prices are the ones that execute. + validate_address_lookup_tables(&self.squads_vault_transaction.message, remaining_accounts)?; + // Should never be the case because the oracle is the proposal account, and you can't re-initialize a proposal assert!(!self.question.is_resolved()); @@ -75,6 +90,7 @@ impl InitializeProposal<'_> { proposal, squads_proposal, squads_multisig: _, + squads_vault_transaction: _, dao, proposer, payer: _, diff --git a/programs/futarchy/src/instructions/launch_proposal.rs b/programs/futarchy/src/instructions/launch_proposal.rs index db7a059f..9387c4b8 100644 --- a/programs/futarchy/src/instructions/launch_proposal.rs +++ b/programs/futarchy/src/instructions/launch_proposal.rs @@ -118,9 +118,11 @@ impl<'info> LaunchProposal<'info> { self.squads_multisig.stale_transaction_index ); - self.proposal - .action - .verify_launch_accounts(&self.dao, remaining_accounts)?; + self.proposal.action.verify_launch_accounts( + &self.dao, + self.squads_proposal.transaction_index, + remaining_accounts, + )?; Ok(()) } diff --git a/programs/futarchy/src/lib.rs b/programs/futarchy/src/lib.rs index fcca41f5..ddcc8733 100644 --- a/programs/futarchy/src/lib.rs +++ b/programs/futarchy/src/lib.rs @@ -69,8 +69,10 @@ pub mod futarchy { InitializeDao::handle(ctx, params) } - #[access_control(ctx.accounts.validate())] - pub fn initialize_proposal(ctx: Context) -> Result<()> { + #[access_control(ctx.accounts.validate(ctx.remaining_accounts))] + pub fn initialize_proposal<'info>( + ctx: Context<'_, '_, 'info, 'info, InitializeProposal<'info>>, + ) -> Result<()> { InitializeProposal::handle(ctx) } diff --git a/programs/futarchy/src/squads.rs b/programs/futarchy/src/squads.rs index 9a56b9a2..96a009e4 100644 --- a/programs/futarchy/src/squads.rs +++ b/programs/futarchy/src/squads.rs @@ -1,9 +1,70 @@ use anchor_lang::prelude::*; +use anchor_lang::solana_program::address_lookup_table::{self, state::AddressLookupTable}; use std::collections::BTreeMap; use crate::FutarchyError; +/// Validates that every address lookup table a vault transaction message +/// references is frozen (`authority` permanently `None`, so its contents can +/// never change) and already holds every index the message references. +/// `lookup_table_accounts` must hold exactly one account per +/// `message.address_table_lookups` entry, in the same order, which is the +/// convention Squads' own `vault_transaction_execute` uses. +pub fn validate_address_lookup_tables<'info>( + message: &squads_multisig_program::VaultTransactionMessage, + lookup_table_accounts: &[AccountInfo<'info>], +) -> Result<()> { + require_eq!( + lookup_table_accounts.len(), + message.address_table_lookups.len(), + FutarchyError::InvalidAddressLookupTable + ); + + for (lookup, lookup_table_account) in message + .address_table_lookups + .iter() + .zip(lookup_table_accounts.iter()) + { + require_keys_eq!( + *lookup_table_account.key, + lookup.account_key, + FutarchyError::InvalidAddressLookupTable + ); + require_keys_eq!( + *lookup_table_account.owner, + address_lookup_table::program::ID, + FutarchyError::InvalidAddressLookupTable + ); + + let lookup_table_data = lookup_table_account.try_borrow_data()?; + let lookup_table = AddressLookupTable::deserialize(&lookup_table_data) + .map_err(|_| FutarchyError::InvalidAddressLookupTable)?; + + require!( + lookup_table.meta.authority.is_none(), + FutarchyError::UnfrozenAddressLookupTable + ); + + // A frozen table's length is final, so an index past it could never + // be filled and the proposal could pass its market yet never execute. + if let Some(max_index) = lookup + .writable_indexes + .iter() + .chain(lookup.readonly_indexes.iter()) + .max() + { + require_gt!( + lookup_table.addresses.len(), + usize::from(*max_index), + FutarchyError::InvalidAddressLookupTable + ); + } + } + + Ok(()) +} + /// Compiles a Solana instruction into a Squads TransactionMessage format. /// This is necessary because Solana's Message::serialize() uses a different header format /// (num_readonly_signed_accounts, num_readonly_unsigned_accounts) than Squads expects diff --git a/programs/futarchy/src/state/proposal_action.rs b/programs/futarchy/src/state/proposal_action.rs index a0328a68..2750b85b 100644 --- a/programs/futarchy/src/state/proposal_action.rs +++ b/programs/futarchy/src/state/proposal_action.rs @@ -174,9 +174,13 @@ impl ProposalAction { pub fn verify_launch_accounts<'info>( &self, dao: &Account<'info, Dao>, + squads_transaction_index: u64, accounts: &'info [AccountInfo<'info>], ) -> Result<()> { match self { + ProposalAction::ExecuteArbitrary => { + verify_execute_arbitrary_launch(dao, squads_transaction_index, accounts) + } ProposalAction::BuybackToken { quote_amount, .. } => { verify_buyback_treasury_cap(*quote_amount, dao, accounts) } @@ -195,6 +199,39 @@ impl ProposalAction { } } +/// The execute-arbitrary launch gate: the proposal's Squads vault transaction +/// followed by every lookup table its message references, which must be +/// frozen. Only this kind carries a caller-built payload; typed kinds compile +/// theirs on-chain. Repeated at launch for drafts created before this check. +fn verify_execute_arbitrary_launch<'info>( + dao: &Dao, + squads_transaction_index: u64, + accounts: &'info [AccountInfo<'info>], +) -> Result<()> { + let Some((vault_transaction_account, lookup_table_accounts)) = accounts.split_first() else { + return err!(FutarchyError::InvalidSquadsVaultTransaction); + }; + + let (vault_transaction_pda, _) = Pubkey::find_program_address( + &[ + squads_multisig_program::SEED_PREFIX, + dao.squads_multisig.as_ref(), + squads_multisig_program::SEED_TRANSACTION, + &squads_transaction_index.to_le_bytes(), + ], + &squads_multisig_program::ID, + ); + require_keys_eq!( + vault_transaction_account.key(), + vault_transaction_pda, + FutarchyError::InvalidSquadsVaultTransaction + ); + let vault_transaction = + Account::::try_from(vault_transaction_account)?; + + validate_address_lookup_tables(&vault_transaction.message, lookup_table_accounts) +} + /// The large-spend launch gate: no extra accounts, and the create-time checks /// re-run against current state. fn verify_large_spend_launch( diff --git a/scripts/utils/futarchyProposal.ts b/scripts/utils/futarchyProposal.ts index 974a93ec..97097da0 100644 --- a/scripts/utils/futarchyProposal.ts +++ b/scripts/utils/futarchyProposal.ts @@ -92,6 +92,8 @@ export const initializeFutarchyProposal = async ({ dao, ); const vaultClient = futarchy.vaultClient; + const { squadsTransaction, lookupTables } = + await futarchy.getSquadsVaultTransactionAccounts(squadsProposal); console.log("Squads proposal:", squadsProposal.toBase58()); console.log("Proposal:", proposal.toBase58()); @@ -149,6 +151,8 @@ export const initializeFutarchyProposal = async ({ daoAccount.baseMint, daoAccount.quoteMint, question, + squadsTransaction, + lookupTables, payer.publicKey, ) .preInstructions([ diff --git a/sdk/src/futarchy/v0.6/FutarchyClient.ts b/sdk/src/futarchy/v0.6/FutarchyClient.ts index f029b34b..a611f123 100644 --- a/sdk/src/futarchy/v0.6/FutarchyClient.ts +++ b/sdk/src/futarchy/v0.6/FutarchyClient.ts @@ -340,12 +340,19 @@ export class FutarchyClient { return existing.sort((a, b) => a.toBuffer().compare(b.toBuffer())); } + /** + * `squadsTransaction` and `lookupTables` are required for a generic + * (execute-arbitrary) proposal and must be omitted for typed kinds; see + * `getSquadsVaultTransactionAccounts`. `treasuryAccounts` is for buybacks. + */ launchProposalIx({ proposal, dao, baseMint, quoteMint, squadsProposal, + squadsTransaction, + lookupTables = [], treasuryAccounts = [], }: { proposal: PublicKey; @@ -353,6 +360,8 @@ export class FutarchyClient { baseMint: PublicKey; quoteMint: PublicKey; squadsProposal: PublicKey; + squadsTransaction?: PublicKey; + lookupTables?: PublicKey[]; treasuryAccounts?: PublicKey[]; }) { const { @@ -402,7 +411,10 @@ export class FutarchyClient { payer: this.provider.publicKey, }) .remainingAccounts( - treasuryAccounts.map((pubkey) => ({ + [ + ...(squadsTransaction ? [squadsTransaction, ...lookupTables] : []), + ...treasuryAccounts, + ].map((pubkey) => ({ pubkey, isSigner: false, isWritable: false, @@ -663,7 +675,11 @@ export class FutarchyClient { instructions: TransactionInstruction[]; transactionIndex: bigint; payer?: PublicKey; - }): { tx: Transaction; squadsProposal: PublicKey } { + }): { + tx: Transaction; + squadsProposal: PublicKey; + squadsTransaction: PublicKey; + } { const multisigPda = multisig.getMultisigPda({ createKey: dao })[0]; const squadsMultisigVault = multisig.getVaultPda({ multisigPda, @@ -698,10 +714,48 @@ export class FutarchyClient { multisigPda, transactionIndex: transactionIndex, }); + const [squadsTransaction] = multisig.getTransactionPda({ + multisigPda, + index: transactionIndex, + }); const tx = new Transaction().add(vaultTxCreate, proposalCreate); - return { tx, squadsProposal }; + return { tx, squadsProposal, squadsTransaction }; + } + + /** + * Resolves the vault transaction behind a Squads proposal and the lookup + * tables its message references. Both go to `initializeProposalIx` and, for + * a generic proposal, `launchProposalIx`, which check that every table is + * frozen. + */ + async getSquadsVaultTransactionAccounts(squadsProposal: PublicKey): Promise<{ + squadsTransaction: PublicKey; + lookupTables: PublicKey[]; + }> { + const squadsProposalAccount = + await multisig.accounts.Proposal.fromAccountAddress( + this.provider.connection, + squadsProposal, + ); + + const [squadsTransaction] = multisig.getTransactionPda({ + multisigPda: squadsProposalAccount.multisig, + index: BigInt(squadsProposalAccount.transactionIndex.toString()), + }); + + const vaultTransaction = + await multisig.accounts.VaultTransaction.fromAccountAddress( + this.provider.connection, + squadsTransaction, + ); + + const lookupTables = vaultTransaction.message.addressTableLookups.map( + (lookup) => lookup.accountKey, + ); + + return { squadsTransaction, lookupTables }; } async initializeProposal( @@ -735,12 +789,17 @@ export class FutarchyClient { ) .rpc(); + const { squadsTransaction, lookupTables } = + await this.getSquadsVaultTransactionAccounts(squadsProposal); + await this.initializeProposalIx( squadsProposal, dao, storedDao.baseMint, storedDao.quoteMint, question, + squadsTransaction, + lookupTables, ) .preInstructions([ ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), @@ -756,6 +815,8 @@ export class FutarchyClient { baseMint: PublicKey, quoteMint: PublicKey, question: PublicKey, + squadsTransaction: PublicKey, + lookupTables: PublicKey[] = [], proposer: PublicKey = this.provider.publicKey, ) { let [proposal] = getProposalAddr(this.futarchy.programId, squadsProposal); @@ -781,12 +842,20 @@ export class FutarchyClient { question, proposal, squadsProposal, + squadsVaultTransaction: squadsTransaction, dao, baseVault, quoteVault, proposer, squadsMultisig, }) + .remainingAccounts( + lookupTables.map((pubkey) => ({ + pubkey, + isSigner: false, + isWritable: false, + })), + ) .preInstructions([ createAssociatedTokenAccountIdempotentInstruction( this.provider.publicKey, diff --git a/sdk/src/futarchy/v0.6/types/futarchy.ts b/sdk/src/futarchy/v0.6/types/futarchy.ts index 772db7e7..69b5889c 100644 --- a/sdk/src/futarchy/v0.6/types/futarchy.ts +++ b/sdk/src/futarchy/v0.6/types/futarchy.ts @@ -123,6 +123,11 @@ export type Futarchy = { isMut: false; isSigner: false; }, + { + name: "squadsVaultTransaction"; + isMut: false; + isSigner: false; + }, { name: "dao"; isMut: true; @@ -5290,6 +5295,21 @@ export type Futarchy = { name: "TypedProposalsCannotBeDisabled"; msg: "Typed proposals cannot be disabled"; }, + { + code: 6075; + name: "UnfrozenAddressLookupTable"; + msg: "Address lookup tables referenced by the vault transaction must be frozen"; + }, + { + code: 6076; + name: "InvalidAddressLookupTable"; + msg: "Lookup table accounts must match the vault transaction's address table lookups"; + }, + { + code: 6077; + name: "InvalidSquadsVaultTransaction"; + msg: "Expected the proposal's Squads vault transaction as the first launch account"; + }, ]; }; @@ -5418,6 +5438,11 @@ export const IDL: Futarchy = { isMut: false, isSigner: false, }, + { + name: "squadsVaultTransaction", + isMut: false, + isSigner: false, + }, { name: "dao", isMut: true, @@ -10585,5 +10610,20 @@ export const IDL: Futarchy = { name: "TypedProposalsCannotBeDisabled", msg: "Typed proposals cannot be disabled", }, + { + code: 6075, + name: "UnfrozenAddressLookupTable", + msg: "Address lookup tables referenced by the vault transaction must be frozen", + }, + { + code: 6076, + name: "InvalidAddressLookupTable", + msg: "Lookup table accounts must match the vault transaction's address table lookups", + }, + { + code: 6077, + name: "InvalidSquadsVaultTransaction", + msg: "Expected the proposal's Squads vault transaction as the first launch account", + }, ], }; diff --git a/tests/futarchy/integration/futarchyAmm.test.ts b/tests/futarchy/integration/futarchyAmm.test.ts index fb0a7a12..c1c955e4 100644 --- a/tests/futarchy/integration/futarchyAmm.test.ts +++ b/tests/futarchy/integration/futarchyAmm.test.ts @@ -213,6 +213,10 @@ export default function suite() { ); const proposalAccount = await this.futarchy.getProposal(proposal); + const { squadsTransaction } = + await this.futarchy.getSquadsVaultTransactionAccounts( + proposalAccount.squadsProposal, + ); await this.futarchy .launchProposalIx({ @@ -221,6 +225,7 @@ export default function suite() { baseMint: META, quoteMint: USDC, squadsProposal: proposalAccount.squadsProposal, + squadsTransaction, }) .rpc(); diff --git a/tests/futarchy/integration/typedProposalsOptInEndToEnd.test.ts b/tests/futarchy/integration/typedProposalsOptInEndToEnd.test.ts index 19166e3a..b1d23263 100644 --- a/tests/futarchy/integration/typedProposalsOptInEndToEnd.test.ts +++ b/tests/futarchy/integration/typedProposalsOptInEndToEnd.test.ts @@ -207,6 +207,7 @@ export default function suite() { baseMint: META, quoteMint: USDC, squadsProposal: optIn.squadsProposal, + squadsTransaction: optIn.squadsTransaction, }) .rpc(); @@ -300,6 +301,7 @@ export default function suite() { baseMint: META, quoteMint: USDC, squadsProposal: leftover.squadsProposal, + squadsTransaction: leftover.squadsTransaction, }) .rpc(); @@ -344,6 +346,7 @@ export default function suite() { baseMint: META, quoteMint: USDC, squadsProposal: fresh.squadsProposal, + squadsTransaction: fresh.squadsTransaction, }) .rpc(); diff --git a/tests/futarchy/unit/adminCancelProposal.test.ts b/tests/futarchy/unit/adminCancelProposal.test.ts index f82a5d15..e24bd9b9 100644 --- a/tests/futarchy/unit/adminCancelProposal.test.ts +++ b/tests/futarchy/unit/adminCancelProposal.test.ts @@ -22,7 +22,8 @@ export default function suite() { USDC: PublicKey, dao: PublicKey, proposal: PublicKey, - squadsProposalPda: PublicKey; + squadsProposalPda: PublicKey, + squadsTransactionPda: PublicKey; beforeEach(async function () { META = await this.createMint(this.payer.publicKey, 6); @@ -108,6 +109,10 @@ export default function suite() { multisigPda, transactionIndex: 1n, }); + [squadsTransactionPda] = multisig.getTransactionPda({ + multisigPda, + index: 1n, + }); const tx = new Transaction().add(vaultTxCreate, proposalCreateIx); tx.recentBlockhash = (await this.banksClient.getLatestBlockhash())[0]; @@ -125,6 +130,7 @@ export default function suite() { baseMint: META, quoteMint: USDC, squadsProposal: squadsProposalPda, + squadsTransaction: squadsTransactionPda, }) .rpc(); }); diff --git a/tests/futarchy/unit/adminRemoveProposal.test.ts b/tests/futarchy/unit/adminRemoveProposal.test.ts index 091c5021..63ecd9bc 100644 --- a/tests/futarchy/unit/adminRemoveProposal.test.ts +++ b/tests/futarchy/unit/adminRemoveProposal.test.ts @@ -224,6 +224,10 @@ export default function suite() { multisigPda, transactionIndex: 1n, }); + const [squadsTransactionPda] = multisig.getTransactionPda({ + multisigPda, + index: 1n, + }); // Launch the proposal to move it to Pending state await this.futarchy @@ -233,6 +237,7 @@ export default function suite() { baseMint: META, quoteMint: USDC, squadsProposal: squadsProposalPda, + squadsTransaction: squadsTransactionPda, }) .rpc(); diff --git a/tests/futarchy/unit/adminUpdateProposalParams.test.ts b/tests/futarchy/unit/adminUpdateProposalParams.test.ts index c1c6232d..53bfe45c 100644 --- a/tests/futarchy/unit/adminUpdateProposalParams.test.ts +++ b/tests/futarchy/unit/adminUpdateProposalParams.test.ts @@ -25,7 +25,11 @@ const ARBITRARY_PASS_THRESHOLD_BPS = 1000; async function createArbitraryProposal( ctx: TestContext, dao: PublicKey, -): Promise<{ proposal: PublicKey; squadsProposal: PublicKey }> { +): Promise<{ + proposal: PublicKey; + squadsProposal: PublicKey; + squadsTransaction: PublicKey; +}> { const multisigPda = multisig.getMultisigPda({ createKey: dao })[0]; const message = new TransactionMessage({ @@ -61,6 +65,10 @@ async function createArbitraryProposal( multisigPda, transactionIndex: 1n, }); + const [squadsTransaction] = multisig.getTransactionPda({ + multisigPda, + index: 1n, + }); const tx = new Transaction().add(vaultTxCreate, proposalCreateIx); tx.recentBlockhash = (await ctx.banksClient.getLatestBlockhash())[0]; @@ -71,6 +79,7 @@ async function createArbitraryProposal( return { proposal: await ctx.futarchy.initializeProposal(dao, squadsProposal), squadsProposal, + squadsTransaction, }; } @@ -79,7 +88,8 @@ export default function suite() { USDC: PublicKey, dao: PublicKey, proposal: PublicKey, - squadsProposal: PublicKey; + squadsProposal: PublicKey, + squadsTransaction: PublicKey; beforeEach(async function () { META = await this.createMint(this.payer.publicKey, 6); @@ -106,7 +116,8 @@ export default function suite() { }, }); - ({ proposal, squadsProposal } = await createArbitraryProposal(this, dao)); + ({ proposal, squadsProposal, squadsTransaction } = + await createArbitraryProposal(this, dao)); }); // The market a launch needs; only the launching cases pay for it. @@ -285,6 +296,7 @@ export default function suite() { baseMint: META, quoteMint: USDC, squadsProposal, + squadsTransaction, }) .rpc(); diff --git a/tests/futarchy/unit/executeMultisigProposalApproval.test.ts b/tests/futarchy/unit/executeMultisigProposalApproval.test.ts index 9db46ec1..ffe5b75a 100644 --- a/tests/futarchy/unit/executeMultisigProposalApproval.test.ts +++ b/tests/futarchy/unit/executeMultisigProposalApproval.test.ts @@ -157,8 +157,11 @@ export default function suite() { // Initialize (but don't launch) a futarchy proposal. This creates a // Squads proposal at index 1 and leaves the AMM in Spot — so we can // enqueue approval against it. Launching is done separately below. - const { proposal, squadsProposal: proposalPda } = - await this.initializeProposal({ dao, instructions: [] }); + const { + proposal, + squadsProposal: proposalPda, + squadsTransaction, + } = await this.initializeProposal({ dao, instructions: [] }); const enqueuedApprovalPda = await enqueue(this, 1n); @@ -171,6 +174,7 @@ export default function suite() { baseMint: storedDao.baseMint, quoteMint: storedDao.quoteMint, squadsProposal: proposalPda, + squadsTransaction, }) .rpc(); diff --git a/tests/futarchy/unit/finalizeProposal.test.ts b/tests/futarchy/unit/finalizeProposal.test.ts index 84ae6862..f0e8adbf 100644 --- a/tests/futarchy/unit/finalizeProposal.test.ts +++ b/tests/futarchy/unit/finalizeProposal.test.ts @@ -27,7 +27,8 @@ export default function suite() { USDC: PublicKey, dao: PublicKey, proposal: PublicKey, - squadsProposalPda: PublicKey; + squadsProposalPda: PublicKey, + squadsTransactionPda: PublicKey; beforeEach(async function () { META = await this.createMint(this.payer.publicKey, 6); @@ -116,6 +117,10 @@ export default function suite() { multisigPda, transactionIndex: 1n, }); + [squadsTransactionPda] = multisig.getTransactionPda({ + multisigPda, + index: 1n, + }); // Create the squads proposal first const tx = new Transaction().add(vaultTxCreate, proposalCreateIx); @@ -135,6 +140,7 @@ export default function suite() { baseMint: META, quoteMint: USDC, squadsProposal: squadsProposalPda, + squadsTransaction: squadsTransactionPda, }) .rpc(); }); @@ -647,6 +653,10 @@ export default function suite() { multisigPda, transactionIndex: 1n, }); + const [squadsTransactionPda] = multisig.getTransactionPda({ + multisigPda, + index: 1n, + }); // Create the squads proposal first const tx = new Transaction().add(vaultTxCreate, proposalCreateIx); @@ -678,6 +688,7 @@ export default function suite() { baseMint: META, quoteMint: USDC, squadsProposal: squadsProposalPda, + squadsTransaction: squadsTransactionPda, }) .rpc(); diff --git a/tests/futarchy/unit/initializeBuybackTokenProposal.test.ts b/tests/futarchy/unit/initializeBuybackTokenProposal.test.ts index 783cc2af..e8ea9717 100644 --- a/tests/futarchy/unit/initializeBuybackTokenProposal.test.ts +++ b/tests/futarchy/unit/initializeBuybackTokenProposal.test.ts @@ -786,14 +786,15 @@ export default function suite() { keys: [], data: Buffer.from("arbitrary", "utf8"), }); - const { proposal, squadsProposal } = await this.initializeProposal({ - dao, - instructions: [memoIx], - }); + const { proposal, squadsProposal, squadsTransaction } = + await this.initializeProposal({ + dao, + instructions: [memoIx], + }); const callbacks = expectError( - "UnexpectedLaunchAccounts", - "launched a non-buyback proposal with a treasury list", + "InvalidAddressLookupTable", + "launched a generic proposal with a treasury list", ); await this.futarchy @@ -803,6 +804,7 @@ export default function suite() { baseMint: META, quoteMint: USDC, squadsProposal, + squadsTransaction, treasuryAccounts: [vaultQuoteAccount], }) .rpc() diff --git a/tests/futarchy/unit/initializeProposal.test.ts b/tests/futarchy/unit/initializeProposal.test.ts index 4346c93c..9b4a6525 100644 --- a/tests/futarchy/unit/initializeProposal.test.ts +++ b/tests/futarchy/unit/initializeProposal.test.ts @@ -5,11 +5,18 @@ import { } from "@metadaoproject/programs"; import { ComputeBudgetProgram, + Keypair, PublicKey, + SystemProgram, Transaction, TransactionMessage, } from "@solana/web3.js"; import BN from "bn.js"; +import { + addLookupsToVaultTransaction, + expectError, + setLookupTableAccount, +} from "../../utils.js"; import { assert } from "chai"; import * as multisig from "@sqds/multisig"; const { Permissions, Permission } = multisig.types; @@ -72,6 +79,60 @@ export default function suite() { }); }); + async function createSquadsProposal( + context: any, + daoKey: PublicKey, + ): Promise<{ squadsProposal: PublicKey; squadsTransaction: PublicKey }> { + const multisigPda = multisig.getMultisigPda({ createKey: daoKey })[0]; + + const transactionMessage = new TransactionMessage({ + payerKey: context.payer.publicKey, + recentBlockhash: (await context.banksClient.getLatestBlockhash())[0], + instructions: [ + SystemProgram.transfer({ + fromPubkey: context.payer.publicKey, + toPubkey: context.payer.publicKey, + lamports: 1, + }), + ], + }); + + const tx = new Transaction().add( + multisig.instructions.vaultTransactionCreate({ + multisigPda, + transactionIndex: 1n, + creator: PERMISSIONLESS_ACCOUNT.publicKey, + rentPayer: context.payer.publicKey, + vaultIndex: 0, + ephemeralSigners: 0, + transactionMessage, + }), + multisig.instructions.proposalCreate({ + multisigPda, + transactionIndex: 1n, + creator: PERMISSIONLESS_ACCOUNT.publicKey, + rentPayer: context.payer.publicKey, + }), + ); + + tx.recentBlockhash = (await context.banksClient.getLatestBlockhash())[0]; + tx.feePayer = context.payer.publicKey; + tx.sign(context.payer, PERMISSIONLESS_ACCOUNT); + + await context.banksClient.processTransaction(tx); + + const [squadsProposal] = multisig.getProposalPda({ + multisigPda, + transactionIndex: 1n, + }); + const [squadsTransaction] = multisig.getTransactionPda({ + multisigPda, + index: 1n, + }); + + return { squadsProposal, squadsTransaction }; + } + it("should initialize a proposal", async function () { // Create a simple instruction for the proposal const updateDaoIx = await this.futarchy @@ -167,4 +228,52 @@ export default function suite() { const storedDao = await this.futarchy.getDao(dao); assert.equal(storedDao.proposalCount, 1); }); + + it("rejects a vault transaction referencing an unfrozen lookup table", async function () { + const { squadsProposal, squadsTransaction } = await createSquadsProposal( + this, + dao, + ); + + const lookupTable = Keypair.generate().publicKey; + setLookupTableAccount(this, lookupTable, this.payer.publicKey, [ + Keypair.generate().publicKey, + ]); + await addLookupsToVaultTransaction(this, squadsTransaction, [ + { accountKey: lookupTable, writableIndexes: [0], readonlyIndexes: [] }, + ]); + + const callbacks = expectError( + "UnfrozenAddressLookupTable", + "initialized a proposal whose payload resolves through an unfrozen lookup table", + ); + + await this.futarchy + .initializeProposal(dao, squadsProposal) + .then(callbacks[0], callbacks[1]); + }); + + it("rejects a frozen lookup table when the message references an index it doesn't hold", async function () { + const { squadsProposal, squadsTransaction } = await createSquadsProposal( + this, + dao, + ); + + const lookupTable = Keypair.generate().publicKey; + setLookupTableAccount(this, lookupTable, null, [ + Keypair.generate().publicKey, + ]); + await addLookupsToVaultTransaction(this, squadsTransaction, [ + { accountKey: lookupTable, writableIndexes: [0], readonlyIndexes: [5] }, + ]); + + const callbacks = expectError( + "InvalidAddressLookupTable", + "initialized a proposal with a lookup index past the end of its table", + ); + + await this.futarchy + .initializeProposal(dao, squadsProposal) + .then(callbacks[0], callbacks[1]); + }); } diff --git a/tests/futarchy/unit/launchProposal.test.ts b/tests/futarchy/unit/launchProposal.test.ts index 12de69ed..ac531c0e 100644 --- a/tests/futarchy/unit/launchProposal.test.ts +++ b/tests/futarchy/unit/launchProposal.test.ts @@ -12,9 +12,11 @@ import { } from "@solana/web3.js"; import BN from "bn.js"; import { + addLookupsToVaultTransaction, executeVaultTransaction, expectError, forceApproveSquadsProposal, + setLookupTableAccount, } from "../../utils.js"; import { TYPED_PROPOSALS_OFF_DAO_TERMS, @@ -115,12 +117,12 @@ export default function suite() { } /** - * Helper function to initialize a proposal for a DAO + * Helper function to create a Squads proposal, with its vault transaction, for a DAO */ - async function initializeProposal( + async function createSquadsProposal( context: any, dao: PublicKey, - ): Promise<{ proposal: PublicKey; squadsProposal: PublicKey }> { + ): Promise<{ squadsProposal: PublicKey; squadsTransaction: PublicKey }> { const updateDaoIx = await context.futarchy .updateDaoIx({ dao, @@ -168,6 +170,10 @@ export default function suite() { multisigPda, transactionIndex: 1n, }); + const [squadsTransaction] = multisig.getTransactionPda({ + multisigPda, + index: 1n, + }); const tx = new Transaction().add(vaultTxCreate, proposalCreateIx); tx.recentBlockhash = (await context.banksClient.getLatestBlockhash())[0]; @@ -176,12 +182,31 @@ export default function suite() { await context.banksClient.processTransaction(tx); + return { squadsProposal, squadsTransaction }; + } + + /** + * Helper function to initialize a proposal for a DAO + */ + async function initializeProposal( + context: any, + dao: PublicKey, + ): Promise<{ + proposal: PublicKey; + squadsProposal: PublicKey; + squadsTransaction: PublicKey; + }> { + const { squadsProposal, squadsTransaction } = await createSquadsProposal( + context, + dao, + ); + const proposal = await context.futarchy.initializeProposal( dao, squadsProposal, ); - return { proposal, squadsProposal }; + return { proposal, squadsProposal, squadsTransaction }; } it("succeeds for team-sponsored proposal regardless of stake", async function () { @@ -212,7 +237,8 @@ export default function suite() { ]) .rpc(); - const { proposal, squadsProposal } = await initializeProposal(this, dao); + const { proposal, squadsProposal, squadsTransaction } = + await initializeProposal(this, dao); // Sponsor the proposal (sets sponsored_by to the team) await this.futarchy @@ -231,6 +257,7 @@ export default function suite() { baseMint: META, quoteMint: USDC, squadsProposal, + squadsTransaction, }) .rpc(); @@ -269,7 +296,8 @@ export default function suite() { ]) .rpc(); - const { proposal, squadsProposal } = await initializeProposal(this, dao); + const { proposal, squadsProposal, squadsTransaction } = + await initializeProposal(this, dao); // Stake more than threshold const stakeAmount = new BN(200 * 10 ** 6); // 200 tokens (> 100 threshold) @@ -290,6 +318,7 @@ export default function suite() { baseMint: META, quoteMint: USDC, squadsProposal, + squadsTransaction, }) .rpc(); @@ -327,7 +356,8 @@ export default function suite() { ]) .rpc(); - const { proposal, squadsProposal } = await initializeProposal(this, dao); + const { proposal, squadsProposal, squadsTransaction } = + await initializeProposal(this, dao); // Stake exactly the threshold amount await this.futarchy @@ -347,6 +377,7 @@ export default function suite() { baseMint: META, quoteMint: USDC, squadsProposal, + squadsTransaction, }) .rpc(); @@ -384,7 +415,8 @@ export default function suite() { ]) .rpc(); - const { proposal, squadsProposal } = await initializeProposal(this, dao); + const { proposal, squadsProposal, squadsTransaction } = + await initializeProposal(this, dao); // Sponsor the proposal await this.futarchy @@ -408,6 +440,7 @@ export default function suite() { baseMint: META, quoteMint: USDC, squadsProposal, + squadsTransaction, }) .rpc(); @@ -447,7 +480,8 @@ export default function suite() { const storedDaoBefore = await this.futarchy.getDao(dao); assert.equal(storedDaoBefore.twapStartDelaySeconds, 108_000); - const { proposal, squadsProposal } = await initializeProposal(this, dao); + const { proposal, squadsProposal, squadsTransaction } = + await initializeProposal(this, dao); await this.futarchy .sponsorProposalIx({ @@ -464,6 +498,7 @@ export default function suite() { baseMint: META, quoteMint: USDC, squadsProposal, + squadsTransaction, }) .rpc(); @@ -554,7 +589,8 @@ export default function suite() { ]) .rpc(); - const { proposal, squadsProposal } = await initializeProposal(this, dao); + const { proposal, squadsProposal, squadsTransaction } = + await initializeProposal(this, dao); // Stake less than threshold const insufficientStake = new BN(50 * 10 ** 6); // 50 tokens (< 100 threshold) @@ -580,6 +616,7 @@ export default function suite() { baseMint: META, quoteMint: USDC, squadsProposal, + squadsTransaction, }) .rpc() .then(callbacks[0], callbacks[1]); @@ -1075,7 +1112,8 @@ export default function suite() { ]) .rpc(); - const { proposal, squadsProposal } = await initializeProposal(this, dao); + const { proposal, squadsProposal, squadsTransaction } = + await initializeProposal(this, dao); await this.futarchy .sponsorProposalIx({ @@ -1105,6 +1143,7 @@ export default function suite() { baseMint: META, quoteMint: USDC, squadsProposal, + squadsTransaction, }) .rpc() .then(callbacks[0], callbacks[1]); @@ -1127,6 +1166,7 @@ export default function suite() { baseMint: META, quoteMint: USDC, squadsProposal, + squadsTransaction, }) .postInstructions([ ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), @@ -1305,15 +1345,200 @@ export default function suite() { .then(callbacks[0], callbacks[1]); }); + it("rejects launching a generic proposal without its vault transaction", async function () { + const dao = await createDaoWithStakeThreshold( + this, + META, + USDC, + new BN(0), + this.payer, + ); + const { proposal, squadsProposal } = await initializeProposal(this, dao); + + const callbacks = expectError( + "InvalidSquadsVaultTransaction", + "launched a generic proposal without its vault transaction", + ); + + await this.futarchy + .launchProposalIx({ + proposal, + dao, + baseMint: META, + quoteMint: USDC, + squadsProposal, + }) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("rejects extra launch accounts on a typed proposal", async function () { + const dao = await createDaoWithStakeThreshold( + this, + META, + USDC, + new BN(0), + this.payer, + ); + const takeover = await this.futarchy.initializeHostileTakeoverProposal({ + dao, + newTeamAddress: Keypair.generate().publicKey, + spendingLimitAction: { keep: {} }, + }); + + const callbacks = expectError( + "UnexpectedLaunchAccounts", + "launched a typed proposal with its vault transaction as an extra account", + ); + + await this.futarchy + .launchProposalIx({ + proposal: takeover.proposal, + dao, + baseMint: META, + quoteMint: USDC, + squadsProposal: takeover.squadsProposal, + squadsTransaction: takeover.squadsTransaction, + }) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("rejects an unfrozen lookup table at launch on an already-initialized proposal", async function () { + const dao = await createDaoWithStakeThreshold( + this, + META, + USDC, + new BN(0), + this.payer, + ); + + await this.futarchy + .provideLiquidityIx({ + dao, + baseMint: META, + quoteMint: USDC, + quoteAmount: new BN(100_000 * 10 ** 6), + maxBaseAmount: new BN(100_000 * 10 ** 6), + minLiquidity: new BN(0), + positionAuthority: this.payer.publicKey, + liquidityProvider: this.payer.publicKey, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), + ]) + .rpc(); + + const { squadsProposal, squadsTransaction } = await createSquadsProposal( + this, + dao, + ); + const proposal = await this.futarchy.initializeProposal( + dao, + squadsProposal, + ); + + // A draft that predates this check: the lookup appears in the stored + // message only after initialize_proposal ran without seeing it + const lookupTable = Keypair.generate().publicKey; + setLookupTableAccount(this, lookupTable, this.payer.publicKey, [ + Keypair.generate().publicKey, + ]); + await addLookupsToVaultTransaction(this, squadsTransaction, [ + { accountKey: lookupTable, writableIndexes: [0], readonlyIndexes: [] }, + ]); + + const callbacks = expectError( + "UnfrozenAddressLookupTable", + "launched a proposal whose payload resolves through an unfrozen lookup table", + ); + + await this.futarchy + .launchProposalIx({ + proposal, + dao, + baseMint: META, + quoteMint: USDC, + squadsProposal, + squadsTransaction, + lookupTables: [lookupTable], + }) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("launches a proposal whose payload resolves through a frozen, in-bounds lookup table", async function () { + const dao = await createDaoWithStakeThreshold( + this, + META, + USDC, + new BN(0), + this.payer, + ); + + await this.futarchy + .provideLiquidityIx({ + dao, + baseMint: META, + quoteMint: USDC, + quoteAmount: new BN(100_000 * 10 ** 6), + maxBaseAmount: new BN(100_000 * 10 ** 6), + minLiquidity: new BN(0), + positionAuthority: this.payer.publicKey, + liquidityProvider: this.payer.publicKey, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), + ]) + .rpc(); + + const { squadsProposal, squadsTransaction } = await createSquadsProposal( + this, + dao, + ); + + const lookupTable = Keypair.generate().publicKey; + setLookupTableAccount(this, lookupTable, null, [ + Keypair.generate().publicKey, + Keypair.generate().publicKey, + ]); + await addLookupsToVaultTransaction(this, squadsTransaction, [ + { accountKey: lookupTable, writableIndexes: [0], readonlyIndexes: [1] }, + ]); + + const proposal = await this.futarchy.initializeProposal( + dao, + squadsProposal, + ); + + await this.futarchy + .launchProposalIx({ + proposal, + dao, + baseMint: META, + quoteMint: USDC, + squadsProposal, + squadsTransaction, + lookupTables: [lookupTable], + }) + .rpc(); + + const storedProposal = await this.futarchy.getProposal(proposal); + assert.exists(storedProposal.state.pending); + }); + // Launch writes a proposal's duration and threshold from whatever applies at // that moment: the DAO's own terms for a plain proposal while typed // proposals are off, the catalog otherwise. describe("terms at launch", function () { - let proposal: PublicKey, squadsProposal: PublicKey; + let proposal: PublicKey, + squadsProposal: PublicKey, + squadsTransaction: PublicKey; beforeEach(async function () { dao = await setupTypedProposalsOffDao(this, META, USDC); - ({ proposal, squadsProposal } = await initializeProposal(this, dao)); + ({ proposal, squadsProposal, squadsTransaction } = + await initializeProposal(this, dao)); }); const launch = (ctx: TestContext) => @@ -1323,6 +1548,7 @@ export default function suite() { baseMint: META, quoteMint: USDC, squadsProposal, + squadsTransaction, }); const stake = async (ctx: TestContext) => { diff --git a/tests/futarchy/unit/liquidatedGuards.test.ts b/tests/futarchy/unit/liquidatedGuards.test.ts index b5d10f6f..1c703f63 100644 --- a/tests/futarchy/unit/liquidatedGuards.test.ts +++ b/tests/futarchy/unit/liquidatedGuards.test.ts @@ -40,6 +40,7 @@ export default function suite() { dao: PublicKey, draftProposal: PublicKey, draftSquadsProposal: PublicKey, + draftSquadsTransaction: PublicKey, liquidationProposal: PublicKey; before(async function () { @@ -129,7 +130,10 @@ export default function suite() { // A pre-liquidation draft with stake: staking more must refuse afterward, // unstaking must still work - ({ squadsProposal: draftSquadsProposal } = await createSquadsVaultTx(this, [ + ({ + squadsProposal: draftSquadsProposal, + squadsTransaction: draftSquadsTransaction, + } = await createSquadsVaultTx(this, [ { programId: MEMO_PROGRAM_ID, keys: [], @@ -357,6 +361,7 @@ export default function suite() { baseMint: META, quoteMint: USDC, squadsProposal: draftSquadsProposal, + squadsTransaction: draftSquadsTransaction, }) .rpc() .then(callbacks[0], callbacks[1]); @@ -641,7 +646,11 @@ export default function suite() { reservedDao: PublicKey, liquidatorA: PublicKey, rivalLiquidation: { proposal: PublicKey; squadsProposal: PublicKey }, - stagedDraft: { proposal: PublicKey; squadsProposal: PublicKey }; + stagedDraft: { + proposal: PublicKey; + squadsProposal: PublicKey; + squadsTransaction: PublicKey; + }; before(async function () { base = await this.createMint(this.payer.publicKey, 6); @@ -722,24 +731,27 @@ export default function suite() { }, ); - const { squadsProposal: stagedSquadsProposal } = - await createSquadsVaultTx( - this, - [ - { - programId: MEMO_PROGRAM_ID, - keys: [], - data: Buffer.from("gap proposal"), - }, - ], - reservedDao, - ); + const { + squadsProposal: stagedSquadsProposal, + squadsTransaction: stagedSquadsTransaction, + } = await createSquadsVaultTx( + this, + [ + { + programId: MEMO_PROGRAM_ID, + keys: [], + data: Buffer.from("gap proposal"), + }, + ], + reservedDao, + ); stagedDraft = { proposal: await this.futarchy.initializeProposal( reservedDao, stagedSquadsProposal, ), squadsProposal: stagedSquadsProposal, + squadsTransaction: stagedSquadsTransaction, }; await this.futarchy @@ -801,6 +813,7 @@ export default function suite() { baseMint: base, quoteMint: quote, squadsProposal: stagedDraft.squadsProposal, + squadsTransaction: stagedDraft.squadsTransaction, }) .rpc() .then(callbacks[0], callbacks[1]); diff --git a/tests/futarchy/unit/unstakeFromProposal.test.ts b/tests/futarchy/unit/unstakeFromProposal.test.ts index 2f3f5674..decc1608 100644 --- a/tests/futarchy/unit/unstakeFromProposal.test.ts +++ b/tests/futarchy/unit/unstakeFromProposal.test.ts @@ -18,7 +18,8 @@ export default function suite() { USDC: PublicKey, dao: PublicKey, proposal: PublicKey, - squadsProposalPda: PublicKey; + squadsProposalPda: PublicKey, + squadsTransactionPda: PublicKey; beforeEach(async function () { META = await this.createMint(this.payer.publicKey, 6); @@ -109,6 +110,10 @@ export default function suite() { multisigPda, transactionIndex: 1n, }); + [squadsTransactionPda] = multisig.getTransactionPda({ + multisigPda, + index: 1n, + }); const tx = new Transaction().add(vaultTxCreate, proposalCreateIx); tx.recentBlockhash = (await this.banksClient.getLatestBlockhash())[0]; @@ -172,6 +177,7 @@ export default function suite() { baseMint: META, quoteMint: USDC, squadsProposal: squadsProposalPda, + squadsTransaction: squadsTransactionPda, }) .rpc(); @@ -217,6 +223,7 @@ export default function suite() { baseMint: META, quoteMint: USDC, squadsProposal: squadsProposalPda, + squadsTransaction: squadsTransactionPda, }) .rpc(); @@ -265,6 +272,7 @@ export default function suite() { baseMint: META, quoteMint: USDC, squadsProposal: squadsProposalPda, + squadsTransaction: squadsTransactionPda, }) .instruction(); diff --git a/tests/futarchy/unit/updateDao.test.ts b/tests/futarchy/unit/updateDao.test.ts index 0b63b2e0..2d7c947a 100644 --- a/tests/futarchy/unit/updateDao.test.ts +++ b/tests/futarchy/unit/updateDao.test.ts @@ -142,6 +142,10 @@ export default function suite() { multisigPda: daoAccount.squadsMultisig, transactionIndex: 1n, }); + const [squadsTransactionPda] = multisig.getTransactionPda({ + multisigPda: daoAccount.squadsMultisig, + index: 1n, + }); const createSquadsTx = new Transaction().add( vaultTxCreateIx, @@ -181,7 +185,14 @@ export default function suite() { .rpc(); await this.futarchy - .initializeProposalIx(squadsProposalPda, dao, META, USDC, question) + .initializeProposalIx( + squadsProposalPda, + dao, + META, + USDC, + question, + squadsTransactionPda, + ) .preInstructions([ ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), ]) @@ -203,6 +214,7 @@ export default function suite() { baseMint: META, quoteMint: USDC, squadsProposal: squadsProposalPda, + squadsTransaction: squadsTransactionPda, }) .rpc(); @@ -295,6 +307,10 @@ export default function suite() { multisigPda: daoAccount.squadsMultisig, transactionIndex: 2n, }); + const [squadsTransactionPda2] = multisig.getTransactionPda({ + multisigPda: daoAccount.squadsMultisig, + index: 2n, + }); const createSquadsTx2 = new Transaction().add( vaultTxCreateIx2, @@ -344,6 +360,7 @@ export default function suite() { META, USDC, proposalBPdas.question, + squadsTransactionPda2, ) .preInstructions([ ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), @@ -358,6 +375,7 @@ export default function suite() { baseMint: META, quoteMint: USDC, squadsProposal: squadsProposalPda2, + squadsTransaction: squadsTransactionPda2, }) .rpc(); diff --git a/tests/integration/fullLaunch.test.ts b/tests/integration/fullLaunch.test.ts index 49f6cdbc..4dfaf751 100644 --- a/tests/integration/fullLaunch.test.ts +++ b/tests/integration/fullLaunch.test.ts @@ -424,6 +424,10 @@ export default async function suite() { multisigPda, transactionIndex: 1n, }); + const [squadsTransactionPda] = multisig.getTransactionPda({ + multisigPda, + index: 1n, + }); // Create the squads proposal first const squadsTx = new Transaction().add(vaultTxCreate, proposalCreateIx); @@ -468,6 +472,7 @@ export default async function suite() { baseMint: META, quoteMint: MAINNET_USDC, squadsProposal: squadsProposalPda, + squadsTransaction: squadsTransactionPda, }) .rpc(); diff --git a/tests/integration/fullLaunch_v7.test.ts b/tests/integration/fullLaunch_v7.test.ts index 34cee04e..38ead26e 100644 --- a/tests/integration/fullLaunch_v7.test.ts +++ b/tests/integration/fullLaunch_v7.test.ts @@ -469,6 +469,10 @@ export default async function suite() { multisigPda, transactionIndex: 1n, }); + const [squadsTransactionPda] = multisig.getTransactionPda({ + multisigPda, + index: 1n, + }); // Create the squads proposal first const squadsTx = new Transaction().add(vaultTxCreate, proposalCreateIx); @@ -513,6 +517,7 @@ export default async function suite() { baseMint: META, quoteMint: MAINNET_USDC, squadsProposal: squadsProposalPda, + squadsTransaction: squadsTransactionPda, }) .rpc(); diff --git a/tests/main.test.ts b/tests/main.test.ts index 7c4d6307..b215b10d 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -159,6 +159,7 @@ export interface TestContext { baseVault: PublicKey; quoteVault: PublicKey; squadsProposal: PublicKey; + squadsTransaction: PublicKey; }>; initializeAndLaunchProposal: ({ dao, @@ -172,6 +173,7 @@ export interface TestContext { baseVault: PublicKey; quoteVault: PublicKey; squadsProposal: PublicKey; + squadsTransaction: PublicKey; }>; advanceBySlots: (slots: bigint) => Promise; advanceBySeconds: (seconds: number) => Promise; @@ -575,15 +577,19 @@ before(async function () { baseVault: PublicKey; quoteVault: PublicKey; squadsProposal: PublicKey; + squadsTransaction: PublicKey; }> => { const storedDao = await this.futarchy.getDao(dao); - const { tx: squadsProposalCreateTx, squadsProposal } = - this.futarchy.squadsProposalCreateTx({ - dao, - instructions, - transactionIndex: 1n, - }); + const { + tx: squadsProposalCreateTx, + squadsProposal, + squadsTransaction, + } = this.futarchy.squadsProposalCreateTx({ + dao, + instructions, + transactionIndex: 1n, + }); squadsProposalCreateTx.recentBlockhash = ( await this.banksClient.getLatestBlockhash() @@ -591,7 +597,7 @@ before(async function () { squadsProposalCreateTx.feePayer = this.payer.publicKey; squadsProposalCreateTx.sign(this.payer, PERMISSIONLESS_ACCOUNT); - this.banksClient.processTransaction(squadsProposalCreateTx); + await this.banksClient.processTransaction(squadsProposalCreateTx); let [proposal] = getProposalAddrV2({ squadsProposal }); @@ -628,13 +634,21 @@ before(async function () { storedDao.baseMint, storedDao.quoteMint, question, + squadsTransaction, ) .preInstructions([ ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), ]) .rpc(); - return { proposal, question, baseVault, quoteVault, squadsProposal }; + return { + proposal, + question, + baseVault, + quoteVault, + squadsProposal, + squadsTransaction, + }; }; this.initializeAndLaunchProposal = async ({ @@ -649,9 +663,16 @@ before(async function () { baseVault: PublicKey; quoteVault: PublicKey; squadsProposal: PublicKey; + squadsTransaction: PublicKey; }> => { - const { proposal, question, baseVault, quoteVault, squadsProposal } = - await this.initializeProposal({ dao, instructions }); + const { + proposal, + question, + baseVault, + quoteVault, + squadsProposal, + squadsTransaction, + } = await this.initializeProposal({ dao, instructions }); const storedDao = await this.futarchy.getDao(dao); await this.futarchy .launchProposalIx({ @@ -660,10 +681,18 @@ before(async function () { baseMint: storedDao.baseMint, quoteMint: storedDao.quoteMint, squadsProposal, + squadsTransaction, }) .rpc(); - return { proposal, question, baseVault, quoteVault, squadsProposal }; + return { + proposal, + question, + baseVault, + quoteVault, + squadsProposal, + squadsTransaction, + }; }; this.setupBasicPerformancePackage = async ({ diff --git a/tests/utils.ts b/tests/utils.ts index bc8cd5f1..2b903281 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -500,3 +500,66 @@ export const advanceBySlots = async ( ), ); }; + +// Writes an address lookup table account directly: the bincode-serialized +// ProgramState::LookupTable(LookupTableMeta) header padded to 56 bytes, then the +// raw addresses. A null authority makes the table frozen. +export function setLookupTableAccount( + context: TestContext, + address: PublicKey, + authority: PublicKey | null, + addresses: PublicKey[], +) { + const meta = Buffer.alloc(56); + meta.writeUInt32LE(1, 0); + meta.writeBigUInt64LE(0xffffffffffffffffn, 4); + meta.writeBigUInt64LE(0n, 12); + meta.writeUInt8(0, 20); + if (authority !== null) { + meta.writeUInt8(1, 21); + authority.toBuffer().copy(meta, 22); + } + + context.context.setAccount(address, { + lamports: 1_000_000_000, + data: Buffer.concat([meta, ...addresses.map((a) => a.toBuffer())]), + owner: AddressLookupTableProgram.programId, + executable: false, + }); +} + +// The Squads SDK only compiles lookups from real on-chain tables, so tests +// rewrite a stored vault transaction message to reference arbitrary tables +// and indexes. +export async function addLookupsToVaultTransaction( + context: TestContext, + squadsTransaction: PublicKey, + lookups: { + accountKey: PublicKey; + writableIndexes: number[]; + readonlyIndexes: number[]; + }[], +) { + const vaultTransaction = + await multisig.accounts.VaultTransaction.fromAccountAddress( + context.squadsConnection, + squadsTransaction, + ); + + const modified = multisig.accounts.VaultTransaction.fromArgs({ + ...vaultTransaction, + message: { + ...vaultTransaction.message, + addressTableLookups: lookups.map((lookup) => ({ + accountKey: lookup.accountKey, + writableIndexes: Uint8Array.from(lookup.writableIndexes), + readonlyIndexes: Uint8Array.from(lookup.readonlyIndexes), + })), + }, + }); + const [serialized] = modified.serialize(); + + const stored = await context.banksClient.getAccount(squadsTransaction); + stored.data = serialized; + context.context.setAccount(squadsTransaction, stored); +} From c09d43d2710bfeb9a3d4ee052d78aa07b23e088e Mon Sep 17 00:00:00 2001 From: Pileks Date: Sat, 26 Sep 2026 00:40:29 +0200 Subject: [PATCH 15/16] feat(futarchy): switch hostile takeovers off in production --- .../src/instructions/initialize_hostile_takeover_proposal.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/programs/futarchy/src/instructions/initialize_hostile_takeover_proposal.rs b/programs/futarchy/src/instructions/initialize_hostile_takeover_proposal.rs index bb63cfd8..8cfb2417 100644 --- a/programs/futarchy/src/instructions/initialize_hostile_takeover_proposal.rs +++ b/programs/futarchy/src/instructions/initialize_hostile_takeover_proposal.rs @@ -17,6 +17,11 @@ pub struct InitializeHostileTakeoverProposal<'info> { impl InitializeHostileTakeoverProposal<'_> { pub fn validate(&self, args: &InitializeHostileTakeoverProposalArgs) -> Result<()> { + // Hostile takeovers are switched off in production for now. + if cfg!(feature = "production") { + return err!(FutarchyError::InvalidProposalKind); + } + self.typed_initialize_accounts.validate()?; require_keys_neq!( From cead9b6cc4424891a16399cb085c97c1c14bf565 Mon Sep 17 00:00:00 2001 From: Pileks Date: Sat, 26 Sep 2026 01:30:21 +0200 Subject: [PATCH 16/16] fix(futarchy): remove any live spending limit on liquidation --- .../src/instructions/finalize_proposal.rs | 7 +- tests/futarchy/unit/liquidatedGuards.test.ts | 151 ++++++++++++++++++ 2 files changed, 153 insertions(+), 5 deletions(-) diff --git a/programs/futarchy/src/instructions/finalize_proposal.rs b/programs/futarchy/src/instructions/finalize_proposal.rs index 8c231c72..4b3dff1b 100644 --- a/programs/futarchy/src/instructions/finalize_proposal.rs +++ b/programs/futarchy/src/instructions/finalize_proposal.rs @@ -185,11 +185,8 @@ impl FinalizeProposal<'_> { dao.liquidator = Some(*liquidator); // The spending limit must be zeroed so that the estate can be swept. - // Otherwise a still-live limit member could drain the estate. - if dao.initial_spending_limit.is_some() { - dao.initial_spending_limit = None; - dao.spending_limit_dirty = true; - } + dao.initial_spending_limit = None; + dao.spending_limit_dirty = true; } } diff --git a/tests/futarchy/unit/liquidatedGuards.test.ts b/tests/futarchy/unit/liquidatedGuards.test.ts index 1c703f63..5b3da327 100644 --- a/tests/futarchy/unit/liquidatedGuards.test.ts +++ b/tests/futarchy/unit/liquidatedGuards.test.ts @@ -24,6 +24,7 @@ import { THOUSAND_BUCK_PRICE, } from "../../utils.js"; import { TestContext } from "../../main.test.js"; +import { rewriteAccount } from "../utils.js"; // Every blocked instruction refuses on a liquidated DAO; every allowed one // still works. Not covered here because a liquidated DAO can't reach them: @@ -778,6 +779,19 @@ export default function suite() { assert.ok(storedDao.liquidator.equals(liquidatorA)); }); + it("raises the spending-limit flag without a record, and the sync clears it", async function () { + let storedDao = await this.futarchy.getDao(reservedDao); + assert.isNull(storedDao.initialSpendingLimit); + assert.isTrue(storedDao.spendingLimitDirty); + + await this.futarchy.syncSpendingLimitIx({ dao: reservedDao }).rpc(); + + storedDao = await this.futarchy.getDao(reservedDao); + assert.isFalse(storedDao.spendingLimitDirty); + const [spendingLimitPda] = getSpendingLimitAddr({ dao: reservedDao }); + assert.isNull(await this.banksClient.getAccount(spendingLimitPda)); + }); + it("refuses to launch a second liquidation once the first has passed", async function () { const callbacks = expectError( "DaoLiquidated", @@ -819,4 +833,141 @@ export default function suite() { .then(callbacks[0], callbacks[1]); }); }); + + // A limit the migration mapped to "no record" while the Squads account stayed + // live: the flag must still be raised at liquidation so the sync removes it. + describe("a live limit the migration did not recognise", function () { + let base: PublicKey, quote: PublicKey, orphanDao: PublicKey; + + // Appends a destination allowlist to the live Squads limit, a shape + // `resize_dao` maps to no record. + async function addDestination(ctx: TestContext, dao: PublicKey) { + const [spendingLimit] = getSpendingLimitAddr({ dao }); + const raw = await ctx.banksClient.getAccount(spendingLimit); + const data = Buffer.from(raw.data); + // disc(8) multisig(32) create_key(32) vault_index(1) mint(32) amount(8) + // period(1) remaining_amount(8) last_reset(8) bump(1) members(vec) destinations(vec) + const membersLen = data.readUInt32LE(131); + const destinationsOffset = 131 + 4 + 32 * membersLen; + const len = Buffer.alloc(4); + len.writeUInt32LE(1, 0); + ctx.context.setAccount(spendingLimit, { + ...raw, + data: Buffer.concat([ + data.subarray(0, destinationsOffset), + len, + Keypair.generate().publicKey.toBuffer(), + ]), + }); + } + + before(async function () { + base = await this.createMint(this.payer.publicKey, 6); + quote = await this.createMint(this.payer.publicKey, 6); + await this.createTokenAccount(base, this.payer.publicKey); + await this.createTokenAccount(quote, this.payer.publicKey); + await this.mintTo( + base, + this.payer.publicKey, + this.payer, + 1_000 * 1_000_000, + ); + await this.mintTo( + quote, + this.payer.publicKey, + this.payer, + 500_000 * 1_000_000, + ); + + const nonce = new BN(Math.floor(Math.random() * 1000000)); + await this.futarchy + .initializeDaoIx({ + baseMint: base, + quoteMint: quote, + params: { + secondsPerProposal: 60 * 60 * 24 * 3, + twapStartDelaySeconds: 60 * 60 * 24, + twapInitialObservation: THOUSAND_BUCK_PRICE, + twapMaxObservationChangePerUpdate: THOUSAND_BUCK_PRICE.divn(10), + minQuoteFutarchicLiquidity: new BN(10_000), + minBaseFutarchicLiquidity: new BN(10_000), + passThresholdBps: 300, + nonce, + initialSpendingLimit: { + amountPerMonth: new BN(10_000_000), // 10 USDC + members: [this.payer.publicKey], + }, + baseToStake: new BN(0), + teamSponsoredPassThresholdBps: 300, + teamAddress: this.payer.publicKey, + }, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), + ]) + .rpc(); + [orphanDao] = getDaoAddr({ nonce, daoCreator: this.payer.publicKey }); + + await this.futarchy + .provideLiquidityIx({ + dao: orphanDao, + baseMint: base, + quoteMint: quote, + quoteAmount: new BN(100_000 * 1_000_000), // 100,000 USDC + maxBaseAmount: new BN(100 * 1_000_000), // 100 META + minLiquidity: new BN(0), + positionAuthority: this.payer.publicKey, + liquidityProvider: this.payer.publicKey, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), + ]) + .rpc(); + + // The post-migration state: the live limit is non-canonical and the + // DAO holds no record and a clean flag + await addDestination(this, orphanDao); + await rewriteAccount(this, orphanDao, "dao", (decoded) => { + decoded.initialSpendingLimit = null; + decoded.spendingLimitDirty = false; + }); + + const liquidation = + await this.futarchy.initializeHostileLiquidateProposal({ + dao: orphanDao, + liquidator: Keypair.generate().publicKey, + }); + await this.futarchy + .launchProposalIx({ + proposal: liquidation.proposal, + dao: orphanDao, + baseMint: base, + quoteMint: quote, + squadsProposal: liquidation.squadsProposal, + }) + .rpc(); + await passProposal(this, { + dao: orphanDao, + proposal: liquidation.proposal, + baseMint: base, + quoteMint: quote, + cranks: 50, + }); + }); + + it("raises the flag at liquidation and the sync closes the live limit", async function () { + const [spendingLimitPda] = getSpendingLimitAddr({ dao: orphanDao }); + assert.isNotNull(await this.banksClient.getAccount(spendingLimitPda)); + + let storedDao = await this.futarchy.getDao(orphanDao); + assert.isNull(storedDao.initialSpendingLimit); + assert.isTrue(storedDao.spendingLimitDirty); + + await this.futarchy.syncSpendingLimitIx({ dao: orphanDao }).rpc(); + + storedDao = await this.futarchy.getDao(orphanDao); + assert.isFalse(storedDao.spendingLimitDirty); + assert.isNull(await this.banksClient.getAccount(spendingLimitPda)); + }); + }); }