From 8edd5465a0ab732820f66a4a62fb27c909f544f4 Mon Sep 17 00:00:00 2001 From: Pileks Date: Thu, 16 Apr 2026 22:52:06 +0200 Subject: [PATCH] refactor(futarchy): split admin_approve_multisig_proposal into enqueue + execute --- ...dmin_enqueue_multisig_proposal_approval.rs | 124 +++++ ... => execute_multisig_proposal_approval.rs} | 57 +-- programs/futarchy/src/instructions/mod.rs | 6 +- programs/futarchy/src/lib.rs | 15 +- .../enqueued_multisig_proposal_approval.rs | 11 + programs/futarchy/src/state/mod.rs | 2 + sdk/src/v0.7/types/futarchy.ts | 146 +++++- tests/futarchy/main.test.ts | 12 +- .../unit/adminApproveMultisigProposal.test.ts | 297 ------------ ...minEnqueueMultisigProposalApproval.test.ts | 422 ++++++++++++++++++ .../unit/adminExecuteMultisigProposal.test.ts | 31 +- .../executeMultisigProposalApproval.test.ts | 418 +++++++++++++++++ 12 files changed, 1195 insertions(+), 346 deletions(-) create mode 100644 programs/futarchy/src/instructions/admin_enqueue_multisig_proposal_approval.rs rename programs/futarchy/src/instructions/{admin_approve_multisig_proposal.rs => execute_multisig_proposal_approval.rs} (65%) create mode 100644 programs/futarchy/src/state/enqueued_multisig_proposal_approval.rs delete mode 100644 tests/futarchy/unit/adminApproveMultisigProposal.test.ts create mode 100644 tests/futarchy/unit/adminEnqueueMultisigProposalApproval.test.ts create mode 100644 tests/futarchy/unit/executeMultisigProposalApproval.test.ts diff --git a/programs/futarchy/src/instructions/admin_enqueue_multisig_proposal_approval.rs b/programs/futarchy/src/instructions/admin_enqueue_multisig_proposal_approval.rs new file mode 100644 index 000000000..ee9de35d1 --- /dev/null +++ b/programs/futarchy/src/instructions/admin_enqueue_multisig_proposal_approval.rs @@ -0,0 +1,124 @@ +use super::*; + +mod admin { + use anchor_lang::prelude::declare_id; + + // TODO - update to new admin key + declare_id!("CWGawadYU8CzRVBecnJymNw97H7E3ndDinV5sMzesgY2"); +} + +#[derive(Debug, Clone, AnchorSerialize, AnchorDeserialize)] +pub struct AdminEnqueueMultisigProposalApprovalArgs { + pub transaction_index: u64, +} + +#[derive(Accounts)] +#[instruction(args: AdminEnqueueMultisigProposalApprovalArgs)] +pub struct AdminEnqueueMultisigProposalApproval<'info> { + #[account(has_one = squads_multisig)] + pub dao: Account<'info, Dao>, + + #[account(mut)] + pub admin: Signer<'info>, + + #[account( + seeds = [ + squads_multisig_program::SEED_PREFIX, + squads_multisig_program::SEED_MULTISIG, + dao.key().as_ref(), + ], + bump, + seeds::program = squads_multisig_program::ID, + )] + pub squads_multisig: Account<'info, squads_multisig_program::Multisig>, + + #[account( + seeds = [ + squads_multisig_program::SEED_PREFIX, + squads_multisig.key().as_ref(), + squads_multisig_program::SEED_TRANSACTION, + args.transaction_index.to_le_bytes().as_ref(), + squads_multisig_program::SEED_PROPOSAL, + ], + bump, + seeds::program = squads_multisig_program::ID, + )] + pub squads_multisig_proposal: Account<'info, squads_multisig_program::Proposal>, + + #[account( + init, + payer = admin, + space = 8 + EnqueuedMultisigProposalApproval::INIT_SPACE, + seeds = [ + SEED_ENQUEUED_MULTISIG_PROPOSAL_APPROVAL, + dao.key().as_ref(), + args.transaction_index.to_le_bytes().as_ref(), + ], + bump, + )] + pub enqueued_approval: Account<'info, EnqueuedMultisigProposalApproval>, + + pub system_program: Program<'info, System>, +} + +impl AdminEnqueueMultisigProposalApproval<'_> { + pub fn validate(&self, _args: &AdminEnqueueMultisigProposalApprovalArgs) -> Result<()> { + #[cfg(feature = "production")] + require_keys_eq!(self.admin.key(), admin::ID, FutarchyError::InvalidAdmin); + + if !matches!(self.dao.amm.state, PoolState::Spot { .. }) { + return Err(FutarchyError::PoolNotInSpotState.into()); + } + + validate_squads_proposal( + &self.squads_multisig_proposal, + &self.squads_multisig, + &self.dao.squads_multisig, + &self.dao.key(), + )?; + + Ok(()) + } + + pub fn handle( + ctx: Context, + args: AdminEnqueueMultisigProposalApprovalArgs, + ) -> Result<()> { + let enqueued = &mut ctx.accounts.enqueued_approval; + + enqueued.dao = ctx.accounts.dao.key(); + enqueued.transaction_index = args.transaction_index; + enqueued.pda_bump = ctx.bumps.enqueued_approval; + + Ok(()) + } +} + +pub fn validate_squads_proposal( + squads_proposal: &squads_multisig_program::Proposal, + squads_multisig: &squads_multisig_program::Multisig, + dao_multisig_key: &Pubkey, + dao_key: &Pubkey, +) -> Result<()> { + require_keys_eq!(squads_proposal.multisig, *dao_multisig_key); + + match squads_proposal.status { + squads_multisig_program::ProposalStatus::Active { timestamp: _ } => {} + _ => { + msg!("squads proposal status: {:?}", squads_proposal.status); + return Err(FutarchyError::InvalidSquadsProposalStatus.into()); + } + } + + require_gt!( + squads_proposal.transaction_index, + squads_multisig.stale_transaction_index + ); + + require!( + !squads_proposal.approved.contains(dao_key), + FutarchyError::InvalidSquadsProposalStatus + ); + + Ok(()) +} diff --git a/programs/futarchy/src/instructions/admin_approve_multisig_proposal.rs b/programs/futarchy/src/instructions/execute_multisig_proposal_approval.rs similarity index 65% rename from programs/futarchy/src/instructions/admin_approve_multisig_proposal.rs rename to programs/futarchy/src/instructions/execute_multisig_proposal_approval.rs index 5b52ca60f..d44e12525 100644 --- a/programs/futarchy/src/instructions/admin_approve_multisig_proposal.rs +++ b/programs/futarchy/src/instructions/execute_multisig_proposal_approval.rs @@ -1,24 +1,12 @@ use super::*; -mod admin { - use anchor_lang::prelude::declare_id; - - // MetaDAO-controlled admin - cannot be a Squads signer because of reentrancy - declare_id!("CWGawadYU8CzRVBecnJymNw97H7E3ndDinV5sMzesgY2"); -} - -#[derive(Debug, Clone, AnchorSerialize, AnchorDeserialize)] -pub struct AdminApproveMultisigProposalArgs { - pub transaction_index: u64, -} - #[derive(Accounts)] -#[instruction(args: AdminApproveMultisigProposalArgs)] -pub struct AdminApproveMultisigProposal<'info> { +pub struct ExecuteMultisigProposalApproval<'info> { #[account(mut, has_one = squads_multisig)] pub dao: Account<'info, Dao>, + #[account(mut)] - pub admin: Signer<'info>, + pub rent_receiver: Signer<'info>, #[account( mut, @@ -28,7 +16,7 @@ pub struct AdminApproveMultisigProposal<'info> { dao.key().as_ref(), ], bump, - seeds::program = squads_multisig_program + seeds::program = squads_multisig_program::ID, )] pub squads_multisig: Account<'info, squads_multisig_program::Multisig>, @@ -38,36 +26,54 @@ pub struct AdminApproveMultisigProposal<'info> { squads_multisig_program::SEED_PREFIX, squads_multisig.key().as_ref(), squads_multisig_program::SEED_TRANSACTION, - args.transaction_index.to_le_bytes().as_ref(), + enqueued_approval.transaction_index.to_le_bytes().as_ref(), squads_multisig_program::SEED_PROPOSAL, ], bump, - seeds::program = squads_multisig_program + seeds::program = squads_multisig_program::ID, )] pub squads_multisig_proposal: Account<'info, squads_multisig_program::Proposal>, + #[account( + mut, + close = rent_receiver, + has_one = dao, + seeds = [ + SEED_ENQUEUED_MULTISIG_PROPOSAL_APPROVAL, + dao.key().as_ref(), + enqueued_approval.transaction_index.to_le_bytes().as_ref(), + ], + bump = enqueued_approval.pda_bump, + )] + pub enqueued_approval: Account<'info, EnqueuedMultisigProposalApproval>, + pub squads_multisig_program: Program<'info, squads_multisig_program::program::SquadsMultisigProgram>, } -impl AdminApproveMultisigProposal<'_> { - pub fn validate(&self, _args: &AdminApproveMultisigProposalArgs) -> Result<()> { - #[cfg(feature = "production")] - require_keys_eq!(self.admin.key(), admin::ID, FutarchyError::InvalidAdmin); - +impl ExecuteMultisigProposalApproval<'_> { + pub fn validate(&self) -> Result<()> { if !matches!(self.dao.amm.state, PoolState::Spot { .. }) { return Err(FutarchyError::PoolNotInSpotState.into()); } + validate_squads_proposal( + &self.squads_multisig_proposal, + &self.squads_multisig, + &self.dao.squads_multisig, + &self.dao.key(), + )?; + Ok(()) } - pub fn handle(ctx: Context, _args: AdminApproveMultisigProposalArgs) -> Result<()> { + pub fn handle(ctx: Context) -> Result<()> { let Self { dao, - admin: _, + rent_receiver: _, squads_multisig, squads_multisig_proposal, + enqueued_approval: _, squads_multisig_program, } = ctx.accounts; @@ -76,7 +82,6 @@ impl AdminApproveMultisigProposal<'_> { let dao_seeds = &[SEED_DAO, dao_creator_key, dao_nonce, &[dao.pda_bump]]; let dao_signer = &[&dao_seeds[..]]; - // Approve the proposal squads_multisig_program::cpi::proposal_approve( CpiContext::new_with_signer( squads_multisig_program.to_account_info(), diff --git a/programs/futarchy/src/instructions/mod.rs b/programs/futarchy/src/instructions/mod.rs index 137792680..cb66c55c1 100644 --- a/programs/futarchy/src/instructions/mod.rs +++ b/programs/futarchy/src/instructions/mod.rs @@ -1,12 +1,13 @@ use super::*; -pub mod admin_approve_multisig_proposal; pub mod admin_cancel_proposal; +pub mod admin_enqueue_multisig_proposal_approval; pub mod admin_execute_multisig_proposal; pub mod admin_remove_proposal; pub mod collect_fees; pub mod collect_meteora_damm_fees; pub mod conditional_swap; +pub mod execute_multisig_proposal_approval; pub mod execute_spending_limit_change; pub mod finalize_proposal; pub mod initialize_dao; @@ -20,13 +21,14 @@ pub mod unstake_from_proposal; pub mod update_dao; pub mod withdraw_liquidity; -pub use admin_approve_multisig_proposal::*; pub use admin_cancel_proposal::*; +pub use admin_enqueue_multisig_proposal_approval::*; pub use admin_execute_multisig_proposal::*; pub use admin_remove_proposal::*; pub use collect_fees::*; pub use collect_meteora_damm_fees::*; pub use conditional_swap::*; +pub use execute_multisig_proposal_approval::*; pub use execute_spending_limit_change::*; pub use finalize_proposal::*; pub use initialize_dao::*; diff --git a/programs/futarchy/src/lib.rs b/programs/futarchy/src/lib.rs index 23c8595f8..2d8ad4f60 100644 --- a/programs/futarchy/src/lib.rs +++ b/programs/futarchy/src/lib.rs @@ -154,11 +154,18 @@ pub mod futarchy { } #[access_control(ctx.accounts.validate(&args))] - pub fn admin_approve_multisig_proposal( - ctx: Context, - args: AdminApproveMultisigProposalArgs, + pub fn admin_enqueue_multisig_proposal_approval( + ctx: Context, + args: AdminEnqueueMultisigProposalApprovalArgs, ) -> Result<()> { - AdminApproveMultisigProposal::handle(ctx, args) + AdminEnqueueMultisigProposalApproval::handle(ctx, args) + } + + #[access_control(ctx.accounts.validate())] + pub fn execute_multisig_proposal_approval( + ctx: Context, + ) -> Result<()> { + ExecuteMultisigProposalApproval::handle(ctx) } #[access_control(ctx.accounts.validate())] diff --git a/programs/futarchy/src/state/enqueued_multisig_proposal_approval.rs b/programs/futarchy/src/state/enqueued_multisig_proposal_approval.rs new file mode 100644 index 000000000..bc7f29561 --- /dev/null +++ b/programs/futarchy/src/state/enqueued_multisig_proposal_approval.rs @@ -0,0 +1,11 @@ +use super::*; + +pub const SEED_ENQUEUED_MULTISIG_PROPOSAL_APPROVAL: &[u8] = b"enqueued_approval"; + +#[account] +#[derive(InitSpace)] +pub struct EnqueuedMultisigProposalApproval { + pub dao: Pubkey, + pub transaction_index: u64, + pub pda_bump: u8, +} diff --git a/programs/futarchy/src/state/mod.rs b/programs/futarchy/src/state/mod.rs index bfd9c1770..b261e1392 100644 --- a/programs/futarchy/src/state/mod.rs +++ b/programs/futarchy/src/state/mod.rs @@ -1,11 +1,13 @@ pub mod amm_position; pub mod dao; +pub mod enqueued_multisig_proposal_approval; pub mod futarchy_amm; pub mod proposal; pub mod stake_account; pub use amm_position::*; pub use dao::*; +pub use enqueued_multisig_proposal_approval::*; pub use futarchy_amm::*; pub use proposal::*; pub use stake_account::*; diff --git a/sdk/src/v0.7/types/futarchy.ts b/sdk/src/v0.7/types/futarchy.ts index 3ec208560..51a9c85f4 100644 --- a/sdk/src/v0.7/types/futarchy.ts +++ b/sdk/src/v0.7/types/futarchy.ts @@ -1181,11 +1181,11 @@ export type Futarchy = { args: []; }, { - name: "adminApproveMultisigProposal"; + name: "adminEnqueueMultisigProposalApproval"; accounts: [ { name: "dao"; - isMut: true; + isMut: false; isSigner: false; }, { @@ -1195,16 +1195,21 @@ export type Futarchy = { }, { name: "squadsMultisig"; - isMut: true; + isMut: false; isSigner: false; }, { name: "squadsMultisigProposal"; + isMut: false; + isSigner: false; + }, + { + name: "enqueuedApproval"; isMut: true; isSigner: false; }, { - name: "squadsMultisigProgram"; + name: "systemProgram"; isMut: false; isSigner: false; }, @@ -1213,11 +1218,47 @@ export type Futarchy = { { name: "args"; type: { - defined: "AdminApproveMultisigProposalArgs"; + defined: "AdminEnqueueMultisigProposalApprovalArgs"; }; }, ]; }, + { + name: "executeMultisigProposalApproval"; + accounts: [ + { + name: "dao"; + isMut: true; + isSigner: false; + }, + { + name: "rentReceiver"; + isMut: true; + isSigner: true; + }, + { + name: "squadsMultisig"; + isMut: true; + isSigner: false; + }, + { + name: "squadsMultisigProposal"; + isMut: true; + isSigner: false; + }, + { + name: "enqueuedApproval"; + isMut: true; + isSigner: false; + }, + { + name: "squadsMultisigProgram"; + isMut: false; + isSigner: false; + }, + ]; + args: []; + }, { name: "adminExecuteMultisigProposal"; accounts: [ @@ -1579,6 +1620,26 @@ export type Futarchy = { ]; }; }, + { + name: "enqueuedMultisigProposalApproval"; + type: { + kind: "struct"; + fields: [ + { + name: "dao"; + type: "publicKey"; + }, + { + name: "transactionIndex"; + type: "u64"; + }, + { + name: "pdaBump"; + type: "u8"; + }, + ]; + }; + }, { name: "proposal"; type: { @@ -1700,7 +1761,7 @@ export type Futarchy = { }; }, { - name: "AdminApproveMultisigProposalArgs"; + name: "AdminEnqueueMultisigProposalApprovalArgs"; type: { kind: "struct"; fields: [ @@ -4484,11 +4545,11 @@ export const IDL: Futarchy = { args: [], }, { - name: "adminApproveMultisigProposal", + name: "adminEnqueueMultisigProposalApproval", accounts: [ { name: "dao", - isMut: true, + isMut: false, isSigner: false, }, { @@ -4498,16 +4559,21 @@ export const IDL: Futarchy = { }, { name: "squadsMultisig", - isMut: true, + isMut: false, isSigner: false, }, { name: "squadsMultisigProposal", + isMut: false, + isSigner: false, + }, + { + name: "enqueuedApproval", isMut: true, isSigner: false, }, { - name: "squadsMultisigProgram", + name: "systemProgram", isMut: false, isSigner: false, }, @@ -4516,11 +4582,47 @@ export const IDL: Futarchy = { { name: "args", type: { - defined: "AdminApproveMultisigProposalArgs", + defined: "AdminEnqueueMultisigProposalApprovalArgs", }, }, ], }, + { + name: "executeMultisigProposalApproval", + accounts: [ + { + name: "dao", + isMut: true, + isSigner: false, + }, + { + name: "rentReceiver", + isMut: true, + isSigner: true, + }, + { + name: "squadsMultisig", + isMut: true, + isSigner: false, + }, + { + name: "squadsMultisigProposal", + isMut: true, + isSigner: false, + }, + { + name: "enqueuedApproval", + isMut: true, + isSigner: false, + }, + { + name: "squadsMultisigProgram", + isMut: false, + isSigner: false, + }, + ], + args: [], + }, { name: "adminExecuteMultisigProposal", accounts: [ @@ -4882,6 +4984,26 @@ export const IDL: Futarchy = { ], }, }, + { + name: "enqueuedMultisigProposalApproval", + type: { + kind: "struct", + fields: [ + { + name: "dao", + type: "publicKey", + }, + { + name: "transactionIndex", + type: "u64", + }, + { + name: "pdaBump", + type: "u8", + }, + ], + }, + }, { name: "proposal", type: { @@ -5003,7 +5125,7 @@ export const IDL: Futarchy = { }, }, { - name: "AdminApproveMultisigProposalArgs", + name: "AdminEnqueueMultisigProposalApprovalArgs", type: { kind: "struct", fields: [ diff --git a/tests/futarchy/main.test.ts b/tests/futarchy/main.test.ts index 49441b435..dca8ff6ad 100644 --- a/tests/futarchy/main.test.ts +++ b/tests/futarchy/main.test.ts @@ -13,7 +13,8 @@ import provideLiquidity from "./unit/provideLiquidity.test.js"; import executeSpendingLimitChange from "./unit/executeSpendingLimitChange.test.js"; import collectMeteoraDammFees from "./unit/collectMeteoraDammFees.test.js"; -import adminApproveMultisigProposal from "./unit/adminApproveMultisigProposal.test.js"; +import adminEnqueueMultisigProposalApproval from "./unit/adminEnqueueMultisigProposalApproval.test.js"; +import executeMultisigProposalApproval from "./unit/executeMultisigProposalApproval.test.js"; import adminExecuteMultisigProposal from "./unit/adminExecuteMultisigProposal.test.js"; import adminCancelProposal from "./unit/adminCancelProposal.test.js"; import adminRemoveProposal from "./unit/adminRemoveProposal.test.js"; @@ -62,7 +63,14 @@ export default function suite() { describe("#collect_meteora_damm_fees", collectMeteoraDammFees); - describe("#admin_approve_multisig_proposal", adminApproveMultisigProposal); + describe( + "#admin_enqueue_multisig_proposal_approval", + adminEnqueueMultisigProposalApproval, + ); + describe( + "#execute_multisig_proposal_approval", + executeMultisigProposalApproval, + ); describe("#admin_execute_multisig_proposal", adminExecuteMultisigProposal); describe("#admin_cancel_proposal", adminCancelProposal); describe("#admin_remove_proposal", adminRemoveProposal); diff --git a/tests/futarchy/unit/adminApproveMultisigProposal.test.ts b/tests/futarchy/unit/adminApproveMultisigProposal.test.ts deleted file mode 100644 index 0565f7ac7..000000000 --- a/tests/futarchy/unit/adminApproveMultisigProposal.test.ts +++ /dev/null @@ -1,297 +0,0 @@ -import { PERMISSIONLESS_ACCOUNT } from "@metadaoproject/futarchy/v0.6"; -import { - ComputeBudgetProgram, - PublicKey, - Transaction, - TransactionMessage, -} from "@solana/web3.js"; -import { expectError, setupBasicDao } from "../../utils.js"; -import { assert } from "chai"; -import * as multisig from "@sqds/multisig"; -import { createMemoInstruction } from "@solana/spl-memo"; -import BN from "bn.js"; - -export default function suite() { - let META: PublicKey, USDC: PublicKey, dao: PublicKey; - - beforeEach(async function () { - META = await this.createMint(this.payer.publicKey, 9); - USDC = await this.createMint(this.payer.publicKey, 6); - - // Create payer's token accounts for both mints - await this.createTokenAccount(META, this.payer.publicKey); - await this.createTokenAccount(USDC, this.payer.publicKey); - - // Mint tokens to payer's accounts - await this.mintTo(META, this.payer.publicKey, this.payer, 100 * 10 ** 9); - await this.mintTo( - USDC, - this.payer.publicKey, - this.payer, - 100_000 * 1_000_000, - ); - - dao = await setupBasicDao({ - context: this, - baseMint: META, - quoteMint: USDC, - }); - }); - - it("should approve a squads proposal", async function () { - const daoAccount = await this.futarchy.getDao(dao); - - const vaultTransactionCreateIx = - multisig.instructions.vaultTransactionCreate({ - multisigPda: daoAccount.squadsMultisig, - transactionIndex: 1n, - creator: PERMISSIONLESS_ACCOUNT.publicKey, - rentPayer: this.payer.publicKey, - vaultIndex: 0, - transactionMessage: new TransactionMessage({ - payerKey: this.payer.publicKey, - recentBlockhash: (await this.banksClient.getLatestBlockhash())[0], - instructions: [createMemoInstruction("hello world")], - }), - ephemeralSigners: 0, - }); - - const proposalCreateIx = multisig.instructions.proposalCreate({ - multisigPda: daoAccount.squadsMultisig, - transactionIndex: 1n, - creator: PERMISSIONLESS_ACCOUNT.publicKey, - rentPayer: this.payer.publicKey, - }); - - const squadsCreateTx = new Transaction().add( - vaultTransactionCreateIx, - proposalCreateIx, - ); - squadsCreateTx.recentBlockhash = ( - await this.banksClient.getLatestBlockhash() - )[0]; - squadsCreateTx.feePayer = this.payer.publicKey; - squadsCreateTx.sign(this.payer, PERMISSIONLESS_ACCOUNT); - - await this.banksClient.processTransaction(squadsCreateTx); - - const [vaultTransactionPda] = multisig.getTransactionPda({ - multisigPda: daoAccount.squadsMultisig, - index: 1n, - }); - - const [squadsProposalPda] = multisig.getProposalPda({ - multisigPda: daoAccount.squadsMultisig, - transactionIndex: 1n, - }); - - let squadsProposal = await multisig.accounts.Proposal.fromAccountAddress( - this.squadsConnection, - squadsProposalPda, - ); - - assert.equal(squadsProposal.transactionIndex, 1); - assert.equal(squadsProposal.approved.length, 0); - assert.isTrue( - multisig.generated.isProposalStatusActive(squadsProposal.status), - ); - - await this.futarchy.autocrat.methods - .adminApproveMultisigProposal({ transactionIndex: new BN(1) }) - .accounts({ - dao: dao, - squadsMultisig: daoAccount.squadsMultisig, - squadsMultisigProposal: squadsProposalPda, - admin: this.payer.publicKey, - squadsMultisigProgram: multisig.PROGRAM_ID, - }) - .signers([this.payer]) - .rpc(); - - squadsProposal = await multisig.accounts.Proposal.fromAccountAddress( - this.squadsConnection, - squadsProposalPda, - ); - - assert.equal(squadsProposal.transactionIndex, 1); - assert.equal(squadsProposal.approved[0].toBase58(), dao.toBase58()); - assert.isTrue( - multisig.generated.isProposalStatusApproved(squadsProposal.status), - ); - }); - - it("should fail to approve an invalidated proposal", async function () { - const daoAccount = await this.futarchy.getDao(dao); - - // Create a vault transaction that will be invalidated by the config transaction - const vaultTransactionToInvalidateCreateIx = - multisig.instructions.vaultTransactionCreate({ - multisigPda: daoAccount.squadsMultisig, - transactionIndex: 1n, - creator: PERMISSIONLESS_ACCOUNT.publicKey, - rentPayer: this.payer.publicKey, - vaultIndex: 0, - transactionMessage: new TransactionMessage({ - payerKey: this.payer.publicKey, - recentBlockhash: (await this.banksClient.getLatestBlockhash())[0], - instructions: [ - createMemoInstruction("I will never see the light of day"), - ], - }), - ephemeralSigners: 0, - }); - - const vaultProposalToInvalidateCreateIx = - multisig.instructions.proposalCreate({ - multisigPda: daoAccount.squadsMultisig, - transactionIndex: 1n, - creator: PERMISSIONLESS_ACCOUNT.publicKey, - rentPayer: this.payer.publicKey, - }); - - const configTransactionIndex = 2n; - - const multisigSetTimeLockIx = multisig.instructions.multisigSetTimeLock({ - multisigPda: daoAccount.squadsMultisig, - timeLock: 100, - configAuthority: dao, - }); - - const setTimeLockMessage = new TransactionMessage({ - payerKey: this.payer.publicKey, - recentBlockhash: (await this.banksClient.getLatestBlockhash())[0], - instructions: [multisigSetTimeLockIx], - }); - - const vaultConfigTransactionCreateIx = - multisig.instructions.vaultTransactionCreate({ - multisigPda: daoAccount.squadsMultisig, - transactionIndex: configTransactionIndex, - creator: PERMISSIONLESS_ACCOUNT.publicKey, - rentPayer: this.payer.publicKey, - vaultIndex: 0, - ephemeralSigners: 0, - transactionMessage: setTimeLockMessage, - }); - - const multisigConfigProposalCreateIx = multisig.instructions.proposalCreate( - { - multisigPda: daoAccount.squadsMultisig, - transactionIndex: configTransactionIndex, - creator: PERMISSIONLESS_ACCOUNT.publicKey, - rentPayer: this.payer.publicKey, - }, - ); - - // Create the squads proposals - const squadsTransactionsCreateTx = new Transaction().add( - vaultTransactionToInvalidateCreateIx, - vaultProposalToInvalidateCreateIx, - vaultConfigTransactionCreateIx, - multisigConfigProposalCreateIx, - ); - squadsTransactionsCreateTx.recentBlockhash = ( - await this.banksClient.getLatestBlockhash() - )[0]; - squadsTransactionsCreateTx.feePayer = this.payer.publicKey; - squadsTransactionsCreateTx.sign(this.payer, PERMISSIONLESS_ACCOUNT); - - await this.banksClient.processTransaction(squadsTransactionsCreateTx); - - const [vaultConfigTransactionPda] = multisig.getTransactionPda({ - multisigPda: daoAccount.squadsMultisig, - index: configTransactionIndex, - }); - - const [squadsConfigProposalPda] = multisig.getProposalPda({ - multisigPda: daoAccount.squadsMultisig, - transactionIndex: configTransactionIndex, - }); - - const configTransactionAccount = - await multisig.accounts.VaultTransaction.fromAccountAddress( - this.squadsConnection, - vaultConfigTransactionPda, - ); - - const { accountMetas: configTransactionAccountMetas } = - await multisig.utils.accountsForTransactionExecute({ - connection: this.squadsConnection, - message: configTransactionAccount.message, - ephemeralSignerBumps: [ - ...configTransactionAccount.ephemeralSignerBumps, - ], - vaultPda: daoAccount.squadsMultisigVault, - transactionPda: vaultConfigTransactionPda, - programId: multisig.PROGRAM_ID, - }); - - // Approve and execute the config transaction using the new split instructions - await this.futarchy.autocrat.methods - .adminApproveMultisigProposal({ - transactionIndex: new BN(configTransactionIndex.toString()), - }) - .accounts({ - dao: dao, - squadsMultisig: daoAccount.squadsMultisig, - squadsMultisigProposal: squadsConfigProposalPda, - admin: this.payer.publicKey, - squadsMultisigProgram: multisig.PROGRAM_ID, - }) - .signers([this.payer]) - .rpc(); - - await this.futarchy.autocrat.methods - .adminExecuteMultisigProposal() - .accounts({ - dao: dao, - squadsMultisig: daoAccount.squadsMultisig, - squadsMultisigProposal: squadsConfigProposalPda, - squadsMultisigVaultTransaction: vaultConfigTransactionPda, - admin: this.payer.publicKey, - squadsMultisigProgram: multisig.PROGRAM_ID, - }) - .remainingAccounts( - configTransactionAccountMetas.map((meta) => - meta.pubkey.equals(dao) ? { ...meta, isSigner: false } : meta, - ), - ) - .preInstructions([ - ComputeBudgetProgram.setComputeUnitLimit({ units: 200_000 }), - ]) - .signers([this.payer]) - .rpc(); - - // Now try to approve the invalidated proposal (index 1) - const [squadsInvalidatedProposalPda] = multisig.getProposalPda({ - multisigPda: daoAccount.squadsMultisig, - transactionIndex: 1n, - }); - - const [vaultInvalidatedTransactionPda] = multisig.getTransactionPda({ - multisigPda: daoAccount.squadsMultisig, - index: 1n, - }); - - const callbacks = expectError( - "StaleProposal", - "The proposal should not be approved because it should have been invalidated", - ); - - await this.futarchy.autocrat.methods - .adminApproveMultisigProposal({ transactionIndex: new BN(1) }) - .accounts({ - dao: dao, - squadsMultisig: daoAccount.squadsMultisig, - squadsMultisigProposal: squadsInvalidatedProposalPda, - admin: this.payer.publicKey, - squadsMultisigProgram: multisig.PROGRAM_ID, - }) - .preInstructions([ - ComputeBudgetProgram.setComputeUnitLimit({ units: 200_001 }), - ]) - .signers([this.payer]) - .rpc() - .then(callbacks[0], callbacks[1]); - }); -} diff --git a/tests/futarchy/unit/adminEnqueueMultisigProposalApproval.test.ts b/tests/futarchy/unit/adminEnqueueMultisigProposalApproval.test.ts new file mode 100644 index 000000000..6ef324da4 --- /dev/null +++ b/tests/futarchy/unit/adminEnqueueMultisigProposalApproval.test.ts @@ -0,0 +1,422 @@ +import { PERMISSIONLESS_ACCOUNT } from "@metadaoproject/futarchy/v0.6"; +import { + ComputeBudgetProgram, + PublicKey, + Transaction, + TransactionMessage, +} from "@solana/web3.js"; +import { expectError } from "../../utils.js"; +import { assert } from "chai"; +import * as multisig from "@sqds/multisig"; +import { createMemoInstruction } from "@solana/spl-memo"; +import BN from "bn.js"; + +const SEED_ENQUEUED_APPROVAL = Buffer.from("enqueued_approval"); + +export default function suite() { + let META: PublicKey, USDC: PublicKey, dao: PublicKey; + + beforeEach(async function () { + META = await this.createMint(this.payer.publicKey, 9); + USDC = await this.createMint(this.payer.publicKey, 6); + + await this.createTokenAccount(META, this.payer.publicKey); + await this.createTokenAccount(USDC, this.payer.publicKey); + + await this.mintTo(META, this.payer.publicKey, this.payer, 100 * 10 ** 9); + await this.mintTo( + USDC, + this.payer.publicKey, + this.payer, + 100_000 * 1_000_000, + ); + + dao = await this.setupBasicDaoWithLiquidity({ + baseMint: META, + quoteMint: USDC, + }); + }); + + const deriveEnqueuedApprovalPda = ( + context: any, + daoKey: PublicKey, + transactionIndex: bigint, + ): PublicKey => { + const [pda] = PublicKey.findProgramAddressSync( + [ + SEED_ENQUEUED_APPROVAL, + daoKey.toBuffer(), + new BN(transactionIndex.toString()).toArrayLike(Buffer, "le", 8), + ], + context.futarchy.autocrat.programId, + ); + return pda; + }; + + const createSquadsVaultTxAndProposal = async function ( + context: any, + squadsMultisig: PublicKey, + transactionIndex: bigint, + memo = "hello world", + ) { + const vaultTransactionCreateIx = + multisig.instructions.vaultTransactionCreate({ + multisigPda: squadsMultisig, + transactionIndex, + creator: PERMISSIONLESS_ACCOUNT.publicKey, + rentPayer: context.payer.publicKey, + vaultIndex: 0, + transactionMessage: new TransactionMessage({ + payerKey: context.payer.publicKey, + recentBlockhash: (await context.banksClient.getLatestBlockhash())[0], + instructions: [createMemoInstruction(memo)], + }), + ephemeralSigners: 0, + }); + + const proposalCreateIx = multisig.instructions.proposalCreate({ + multisigPda: squadsMultisig, + transactionIndex, + creator: PERMISSIONLESS_ACCOUNT.publicKey, + rentPayer: context.payer.publicKey, + }); + + const tx = new Transaction().add( + vaultTransactionCreateIx, + proposalCreateIx, + ); + tx.recentBlockhash = (await context.banksClient.getLatestBlockhash())[0]; + tx.feePayer = context.payer.publicKey; + tx.sign(context.payer, PERMISSIONLESS_ACCOUNT); + + await context.banksClient.processTransaction(tx); + + const [proposalPda] = multisig.getProposalPda({ + multisigPda: squadsMultisig, + transactionIndex, + }); + + return { proposalPda }; + }; + + it("should enqueue a proposal approval", async function () { + const daoAccount = await this.futarchy.getDao(dao); + const { proposalPda } = await createSquadsVaultTxAndProposal( + this, + daoAccount.squadsMultisig, + 1n, + ); + + const enqueuedApprovalPda = deriveEnqueuedApprovalPda(this, dao, 1n); + + await this.futarchy.autocrat.methods + .adminEnqueueMultisigProposalApproval({ transactionIndex: new BN(1) }) + .accounts({ + dao, + admin: this.payer.publicKey, + squadsMultisig: daoAccount.squadsMultisig, + squadsMultisigProposal: proposalPda, + enqueuedApproval: enqueuedApprovalPda, + }) + .signers([this.payer]) + .rpc(); + + const enqueued = + await this.futarchy.autocrat.account.enqueuedMultisigProposalApproval.fetch( + enqueuedApprovalPda, + ); + assert.equal(enqueued.dao.toBase58(), dao.toBase58()); + assert.equal(enqueued.transactionIndex.toString(), "1"); + }); + + it("should fail with PoolNotInSpotState when a futarchy proposal is active", async function () { + const daoAccount = await this.futarchy.getDao(dao); + + // Launching a futarchy proposal creates a Squads proposal at index 1 and + // moves the AMM out of Spot. Use that Squads proposal as our approval + // target — any Active Squads proposal would do here; we just need one + // that exists when the AMM is non-Spot. + const { squadsProposal } = await this.initializeAndLaunchProposal({ + dao, + instructions: [], + }); + + const enqueuedApprovalPda = deriveEnqueuedApprovalPda(this, dao, 1n); + + const callbacks = expectError( + "PoolNotInSpotState", + "enqueue should fail when the AMM is not in Spot state", + ); + + await this.futarchy.autocrat.methods + .adminEnqueueMultisigProposalApproval({ transactionIndex: new BN(1) }) + .accounts({ + dao, + admin: this.payer.publicKey, + squadsMultisig: daoAccount.squadsMultisig, + squadsMultisigProposal: squadsProposal, + enqueuedApproval: enqueuedApprovalPda, + }) + .signers([this.payer]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("should fail when enqueuing twice for the same transaction_index", async function () { + const daoAccount = await this.futarchy.getDao(dao); + const { proposalPda } = await createSquadsVaultTxAndProposal( + this, + daoAccount.squadsMultisig, + 1n, + ); + + const enqueuedApprovalPda = deriveEnqueuedApprovalPda(this, dao, 1n); + + await this.futarchy.autocrat.methods + .adminEnqueueMultisigProposalApproval({ transactionIndex: new BN(1) }) + .accounts({ + dao, + admin: this.payer.publicKey, + squadsMultisig: daoAccount.squadsMultisig, + squadsMultisigProposal: proposalPda, + enqueuedApproval: enqueuedApprovalPda, + }) + .signers([this.payer]) + .rpc(); + + try { + await this.futarchy.autocrat.methods + .adminEnqueueMultisigProposalApproval({ transactionIndex: new BN(1) }) + .accounts({ + dao, + admin: this.payer.publicKey, + squadsMultisig: daoAccount.squadsMultisig, + squadsMultisigProposal: proposalPda, + enqueuedApproval: enqueuedApprovalPda, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 200_001 }), + ]) + .signers([this.payer]) + .rpc(); + assert.fail("Should have thrown error"); + } catch (e) { + // The init constraint fails because the account already exists + // (system program error 0x0). + assert.include(e.message, "custom program error: 0x0"); + } + }); + + it("should fail with InvalidSquadsProposalStatus when the Squads proposal is no longer Active", async function () { + const daoAccount = await this.futarchy.getDao(dao); + const { proposalPda } = await createSquadsVaultTxAndProposal( + this, + daoAccount.squadsMultisig, + 1n, + ); + + const enqueuedApprovalPda = deriveEnqueuedApprovalPda(this, dao, 1n); + + await this.futarchy.autocrat.methods + .adminEnqueueMultisigProposalApproval({ transactionIndex: new BN(1) }) + .accounts({ + dao, + admin: this.payer.publicKey, + squadsMultisig: daoAccount.squadsMultisig, + squadsMultisigProposal: proposalPda, + enqueuedApproval: enqueuedApprovalPda, + }) + .signers([this.payer]) + .rpc(); + + await this.futarchy.autocrat.methods + .executeMultisigProposalApproval() + .accounts({ + dao, + rentReceiver: this.payer.publicKey, + squadsMultisig: daoAccount.squadsMultisig, + squadsMultisigProposal: proposalPda, + enqueuedApproval: enqueuedApprovalPda, + squadsMultisigProgram: multisig.PROGRAM_ID, + }) + .signers([this.payer]) + .rpc(); + + const callbacks = expectError( + "InvalidSquadsProposalStatus", + "second enqueue should fail because proposal is no longer Active", + ); + + await this.futarchy.autocrat.methods + .adminEnqueueMultisigProposalApproval({ transactionIndex: new BN(1) }) + .accounts({ + dao, + admin: this.payer.publicKey, + squadsMultisig: daoAccount.squadsMultisig, + squadsMultisigProposal: proposalPda, + enqueuedApproval: enqueuedApprovalPda, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 200_001 }), + ]) + .signers([this.payer]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("should fail with RequireGtViolated when the Squads proposal is stale", async function () { + const daoAccount = await this.futarchy.getDao(dao); + + const { proposalPda: victimProposalPda } = + await createSquadsVaultTxAndProposal( + this, + daoAccount.squadsMultisig, + 1n, + "will be invalidated", + ); + + const configTransactionIndex = 2n; + const multisigSetTimeLockIx = multisig.instructions.multisigSetTimeLock({ + multisigPda: daoAccount.squadsMultisig, + timeLock: 100, + configAuthority: dao, + }); + + const setTimeLockMessage = new TransactionMessage({ + payerKey: this.payer.publicKey, + recentBlockhash: (await this.banksClient.getLatestBlockhash())[0], + instructions: [multisigSetTimeLockIx], + }); + + const vaultConfigTransactionCreateIx = + multisig.instructions.vaultTransactionCreate({ + multisigPda: daoAccount.squadsMultisig, + transactionIndex: configTransactionIndex, + creator: PERMISSIONLESS_ACCOUNT.publicKey, + rentPayer: this.payer.publicKey, + vaultIndex: 0, + ephemeralSigners: 0, + transactionMessage: setTimeLockMessage, + }); + + const configProposalCreateIx = multisig.instructions.proposalCreate({ + multisigPda: daoAccount.squadsMultisig, + transactionIndex: configTransactionIndex, + creator: PERMISSIONLESS_ACCOUNT.publicKey, + rentPayer: this.payer.publicKey, + }); + + const squadsCreateConfigTx = new Transaction().add( + vaultConfigTransactionCreateIx, + configProposalCreateIx, + ); + squadsCreateConfigTx.recentBlockhash = ( + await this.banksClient.getLatestBlockhash() + )[0]; + squadsCreateConfigTx.feePayer = this.payer.publicKey; + squadsCreateConfigTx.sign(this.payer, PERMISSIONLESS_ACCOUNT); + await this.banksClient.processTransaction(squadsCreateConfigTx); + + const [vaultConfigTransactionPda] = multisig.getTransactionPda({ + multisigPda: daoAccount.squadsMultisig, + index: configTransactionIndex, + }); + const [configProposalPda] = multisig.getProposalPda({ + multisigPda: daoAccount.squadsMultisig, + transactionIndex: configTransactionIndex, + }); + const configEnqueuedApprovalPda = deriveEnqueuedApprovalPda( + this, + dao, + configTransactionIndex, + ); + + await this.futarchy.autocrat.methods + .adminEnqueueMultisigProposalApproval({ + transactionIndex: new BN(configTransactionIndex.toString()), + }) + .accounts({ + dao, + admin: this.payer.publicKey, + squadsMultisig: daoAccount.squadsMultisig, + squadsMultisigProposal: configProposalPda, + enqueuedApproval: configEnqueuedApprovalPda, + }) + .signers([this.payer]) + .rpc(); + + await this.futarchy.autocrat.methods + .executeMultisigProposalApproval() + .accounts({ + dao, + rentReceiver: this.payer.publicKey, + squadsMultisig: daoAccount.squadsMultisig, + squadsMultisigProposal: configProposalPda, + enqueuedApproval: configEnqueuedApprovalPda, + squadsMultisigProgram: multisig.PROGRAM_ID, + }) + .signers([this.payer]) + .rpc(); + + const configTransactionAccount = + await multisig.accounts.VaultTransaction.fromAccountAddress( + this.squadsConnection, + vaultConfigTransactionPda, + ); + const { accountMetas: configTransactionAccountMetas } = + await multisig.utils.accountsForTransactionExecute({ + connection: this.squadsConnection, + message: configTransactionAccount.message, + ephemeralSignerBumps: [ + ...configTransactionAccount.ephemeralSignerBumps, + ], + vaultPda: daoAccount.squadsMultisigVault, + transactionPda: vaultConfigTransactionPda, + programId: multisig.PROGRAM_ID, + }); + + await this.futarchy.autocrat.methods + .adminExecuteMultisigProposal() + .accounts({ + dao, + squadsMultisig: daoAccount.squadsMultisig, + squadsMultisigProposal: configProposalPda, + squadsMultisigVaultTransaction: vaultConfigTransactionPda, + admin: this.payer.publicKey, + squadsMultisigProgram: multisig.PROGRAM_ID, + }) + .remainingAccounts( + configTransactionAccountMetas.map((meta) => + meta.pubkey.equals(dao) ? { ...meta, isSigner: false } : meta, + ), + ) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 200_000 }), + ]) + .signers([this.payer]) + .rpc(); + + const victimEnqueuedApprovalPda = deriveEnqueuedApprovalPda(this, dao, 1n); + + const callbacks = expectError( + "RequireGtViolated", + "enqueue should fail because the proposal was invalidated by a later config tx", + ); + + await this.futarchy.autocrat.methods + .adminEnqueueMultisigProposalApproval({ transactionIndex: new BN(1) }) + .accounts({ + dao, + admin: this.payer.publicKey, + squadsMultisig: daoAccount.squadsMultisig, + squadsMultisigProposal: victimProposalPda, + enqueuedApproval: victimEnqueuedApprovalPda, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 200_001 }), + ]) + .signers([this.payer]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); +} diff --git a/tests/futarchy/unit/adminExecuteMultisigProposal.test.ts b/tests/futarchy/unit/adminExecuteMultisigProposal.test.ts index 97ab5dabc..69378473a 100644 --- a/tests/futarchy/unit/adminExecuteMultisigProposal.test.ts +++ b/tests/futarchy/unit/adminExecuteMultisigProposal.test.ts @@ -11,6 +11,8 @@ import * as multisig from "@sqds/multisig"; import { createMemoInstruction } from "@solana/spl-memo"; import BN from "bn.js"; +const SEED_ENQUEUED_APPROVAL = Buffer.from("enqueued_approval"); + export default function suite() { let META: PublicKey, USDC: PublicKey, dao: PublicKey; @@ -109,14 +111,37 @@ export default function suite() { programId: multisig.PROGRAM_ID, }); - // First approve + const [enqueuedApprovalPda] = PublicKey.findProgramAddressSync( + [ + SEED_ENQUEUED_APPROVAL, + dao.toBuffer(), + new BN(1).toArrayLike(Buffer, "le", 8), + ], + this.futarchy.autocrat.programId, + ); + + // First enqueue an approval (admin-gated) await this.futarchy.autocrat.methods - .adminApproveMultisigProposal({ transactionIndex: new BN(1) }) + .adminEnqueueMultisigProposalApproval({ transactionIndex: new BN(1) }) .accounts({ dao: dao, + admin: this.payer.publicKey, squadsMultisig: daoAccount.squadsMultisig, squadsMultisigProposal: squadsProposalPda, - admin: this.payer.publicKey, + enqueuedApproval: enqueuedApprovalPda, + }) + .signers([this.payer]) + .rpc(); + + // Then execute the approval (permissionless) + await this.futarchy.autocrat.methods + .executeMultisigProposalApproval() + .accounts({ + dao: dao, + rentReceiver: this.payer.publicKey, + squadsMultisig: daoAccount.squadsMultisig, + squadsMultisigProposal: squadsProposalPda, + enqueuedApproval: enqueuedApprovalPda, squadsMultisigProgram: multisig.PROGRAM_ID, }) .signers([this.payer]) diff --git a/tests/futarchy/unit/executeMultisigProposalApproval.test.ts b/tests/futarchy/unit/executeMultisigProposalApproval.test.ts new file mode 100644 index 000000000..4afb987d6 --- /dev/null +++ b/tests/futarchy/unit/executeMultisigProposalApproval.test.ts @@ -0,0 +1,418 @@ +import { PERMISSIONLESS_ACCOUNT } from "@metadaoproject/futarchy/v0.6"; +import { + ComputeBudgetProgram, + PublicKey, + Transaction, + TransactionMessage, +} from "@solana/web3.js"; +import { expectError } from "../../utils.js"; +import { assert } from "chai"; +import * as multisig from "@sqds/multisig"; +import { createMemoInstruction } from "@solana/spl-memo"; +import BN from "bn.js"; + +const SEED_ENQUEUED_APPROVAL = Buffer.from("enqueued_approval"); + +export default function suite() { + let META: PublicKey, USDC: PublicKey, dao: PublicKey; + + beforeEach(async function () { + META = await this.createMint(this.payer.publicKey, 9); + USDC = await this.createMint(this.payer.publicKey, 6); + + await this.createTokenAccount(META, this.payer.publicKey); + await this.createTokenAccount(USDC, this.payer.publicKey); + + await this.mintTo(META, this.payer.publicKey, this.payer, 100 * 10 ** 9); + await this.mintTo( + USDC, + this.payer.publicKey, + this.payer, + 100_000 * 1_000_000, + ); + + dao = await this.setupBasicDaoWithLiquidity({ + baseMint: META, + quoteMint: USDC, + }); + }); + + const deriveEnqueuedApprovalPda = ( + context: any, + daoKey: PublicKey, + transactionIndex: bigint, + ): PublicKey => { + const [pda] = PublicKey.findProgramAddressSync( + [ + SEED_ENQUEUED_APPROVAL, + daoKey.toBuffer(), + new BN(transactionIndex.toString()).toArrayLike(Buffer, "le", 8), + ], + context.futarchy.autocrat.programId, + ); + return pda; + }; + + const createSquadsVaultTxAndProposal = async function ( + context: any, + squadsMultisig: PublicKey, + transactionIndex: bigint, + memo = "hello world", + ) { + const vaultTransactionCreateIx = + multisig.instructions.vaultTransactionCreate({ + multisigPda: squadsMultisig, + transactionIndex, + creator: PERMISSIONLESS_ACCOUNT.publicKey, + rentPayer: context.payer.publicKey, + vaultIndex: 0, + transactionMessage: new TransactionMessage({ + payerKey: context.payer.publicKey, + recentBlockhash: (await context.banksClient.getLatestBlockhash())[0], + instructions: [createMemoInstruction(memo)], + }), + ephemeralSigners: 0, + }); + + const proposalCreateIx = multisig.instructions.proposalCreate({ + multisigPda: squadsMultisig, + transactionIndex, + creator: PERMISSIONLESS_ACCOUNT.publicKey, + rentPayer: context.payer.publicKey, + }); + + const tx = new Transaction().add( + vaultTransactionCreateIx, + proposalCreateIx, + ); + tx.recentBlockhash = (await context.banksClient.getLatestBlockhash())[0]; + tx.feePayer = context.payer.publicKey; + tx.sign(context.payer, PERMISSIONLESS_ACCOUNT); + + await context.banksClient.processTransaction(tx); + + const [proposalPda] = multisig.getProposalPda({ + multisigPda: squadsMultisig, + transactionIndex, + }); + + return { proposalPda }; + }; + + const enqueue = async function ( + context: any, + daoKey: PublicKey, + squadsMultisigKey: PublicKey, + squadsProposalPda: PublicKey, + transactionIndex: bigint, + ) { + const enqueuedApprovalPda = deriveEnqueuedApprovalPda( + context, + daoKey, + transactionIndex, + ); + await context.futarchy.autocrat.methods + .adminEnqueueMultisigProposalApproval({ + transactionIndex: new BN(transactionIndex.toString()), + }) + .accounts({ + dao: daoKey, + admin: context.payer.publicKey, + squadsMultisig: squadsMultisigKey, + squadsMultisigProposal: squadsProposalPda, + enqueuedApproval: enqueuedApprovalPda, + }) + .signers([context.payer]) + .rpc(); + return enqueuedApprovalPda; + }; + + it("should execute an enqueued approval with a permissionless signer", async function () { + const daoAccount = await this.futarchy.getDao(dao); + const { proposalPda } = await createSquadsVaultTxAndProposal( + this, + daoAccount.squadsMultisig, + 1n, + ); + const enqueuedApprovalPda = await enqueue( + this, + dao, + daoAccount.squadsMultisig, + proposalPda, + 1n, + ); + + let squadsProposal = await multisig.accounts.Proposal.fromAccountAddress( + this.squadsConnection, + proposalPda, + ); + assert.isTrue( + multisig.generated.isProposalStatusActive(squadsProposal.status), + ); + + await this.futarchy.autocrat.methods + .executeMultisigProposalApproval() + .accounts({ + dao, + rentReceiver: PERMISSIONLESS_ACCOUNT.publicKey, + squadsMultisig: daoAccount.squadsMultisig, + squadsMultisigProposal: proposalPda, + enqueuedApproval: enqueuedApprovalPda, + squadsMultisigProgram: multisig.PROGRAM_ID, + }) + .signers([PERMISSIONLESS_ACCOUNT]) + .rpc(); + + squadsProposal = await multisig.accounts.Proposal.fromAccountAddress( + this.squadsConnection, + proposalPda, + ); + assert.equal(squadsProposal.approved[0].toBase58(), dao.toBase58()); + assert.isTrue( + multisig.generated.isProposalStatusApproved(squadsProposal.status), + ); + + const enqueuedAccount = + await this.banksClient.getAccount(enqueuedApprovalPda); + assert.isNull(enqueuedAccount); + }); + + it("should fail when no enqueued approval exists", async function () { + const daoAccount = await this.futarchy.getDao(dao); + const { proposalPda } = await createSquadsVaultTxAndProposal( + this, + daoAccount.squadsMultisig, + 1n, + ); + + const enqueuedApprovalPda = deriveEnqueuedApprovalPda(this, dao, 1n); + + const callbacks = expectError( + "AccountNotInitialized", + "execute should fail without an enqueued approval PDA", + ); + + await this.futarchy.autocrat.methods + .executeMultisigProposalApproval() + .accounts({ + dao, + rentReceiver: this.payer.publicKey, + squadsMultisig: daoAccount.squadsMultisig, + squadsMultisigProposal: proposalPda, + enqueuedApproval: enqueuedApprovalPda, + squadsMultisigProgram: multisig.PROGRAM_ID, + }) + .signers([this.payer]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("should fail with PoolNotInSpotState when a futarchy proposal launches between enqueue and execute", async function () { + const daoAccount = await this.futarchy.getDao(dao); + + // Initialize (but don't launch) a futarchy proposal. This creates a + // Squads proposal at index 1 and leaves the AMM in Spot — so we can + // enqueue approval against it. Launching is done separately below. + const { proposal, squadsProposal: proposalPda } = + await this.initializeProposal({ dao, instructions: [] }); + + const enqueuedApprovalPda = await enqueue( + this, + dao, + daoAccount.squadsMultisig, + proposalPda, + 1n, + ); + + // Now launch the futarchy proposal to push the AMM out of Spot. + const storedDao = await this.futarchy.getDao(dao); + await this.futarchy + .launchProposalIx({ + proposal, + dao, + baseMint: storedDao.baseMint, + quoteMint: storedDao.quoteMint, + squadsProposal: proposalPda, + }) + .rpc(); + + const callbacks = expectError( + "PoolNotInSpotState", + "execute should fail once the AMM is no longer in Spot state", + ); + + await this.futarchy.autocrat.methods + .executeMultisigProposalApproval() + .accounts({ + dao, + rentReceiver: this.payer.publicKey, + squadsMultisig: daoAccount.squadsMultisig, + squadsMultisigProposal: proposalPda, + enqueuedApproval: enqueuedApprovalPda, + squadsMultisigProgram: multisig.PROGRAM_ID, + }) + .signers([this.payer]) + .rpc() + .then(callbacks[0], callbacks[1]); + + const enqueuedAccount = + await this.banksClient.getAccount(enqueuedApprovalPda); + assert.isNotNull(enqueuedAccount); + }); + + it("should fail with RequireGtViolated when the Squads proposal is invalidated between enqueue and execute", async function () { + const daoAccount = await this.futarchy.getDao(dao); + + const { proposalPda: victimProposalPda } = + await createSquadsVaultTxAndProposal( + this, + daoAccount.squadsMultisig, + 1n, + "will be invalidated", + ); + + const victimEnqueuedApprovalPda = await enqueue( + this, + dao, + daoAccount.squadsMultisig, + victimProposalPda, + 1n, + ); + + const configTransactionIndex = 2n; + const multisigSetTimeLockIx = multisig.instructions.multisigSetTimeLock({ + multisigPda: daoAccount.squadsMultisig, + timeLock: 100, + configAuthority: dao, + }); + + const setTimeLockMessage = new TransactionMessage({ + payerKey: this.payer.publicKey, + recentBlockhash: (await this.banksClient.getLatestBlockhash())[0], + instructions: [multisigSetTimeLockIx], + }); + + const vaultConfigTransactionCreateIx = + multisig.instructions.vaultTransactionCreate({ + multisigPda: daoAccount.squadsMultisig, + transactionIndex: configTransactionIndex, + creator: PERMISSIONLESS_ACCOUNT.publicKey, + rentPayer: this.payer.publicKey, + vaultIndex: 0, + ephemeralSigners: 0, + transactionMessage: setTimeLockMessage, + }); + + const configProposalCreateIx = multisig.instructions.proposalCreate({ + multisigPda: daoAccount.squadsMultisig, + transactionIndex: configTransactionIndex, + creator: PERMISSIONLESS_ACCOUNT.publicKey, + rentPayer: this.payer.publicKey, + }); + + const squadsCreateConfigTx = new Transaction().add( + vaultConfigTransactionCreateIx, + configProposalCreateIx, + ); + squadsCreateConfigTx.recentBlockhash = ( + await this.banksClient.getLatestBlockhash() + )[0]; + squadsCreateConfigTx.feePayer = this.payer.publicKey; + squadsCreateConfigTx.sign(this.payer, PERMISSIONLESS_ACCOUNT); + await this.banksClient.processTransaction(squadsCreateConfigTx); + + const [vaultConfigTransactionPda] = multisig.getTransactionPda({ + multisigPda: daoAccount.squadsMultisig, + index: configTransactionIndex, + }); + const [configProposalPda] = multisig.getProposalPda({ + multisigPda: daoAccount.squadsMultisig, + transactionIndex: configTransactionIndex, + }); + const configEnqueuedApprovalPda = await enqueue( + this, + dao, + daoAccount.squadsMultisig, + configProposalPda, + configTransactionIndex, + ); + + await this.futarchy.autocrat.methods + .executeMultisigProposalApproval() + .accounts({ + dao, + rentReceiver: this.payer.publicKey, + squadsMultisig: daoAccount.squadsMultisig, + squadsMultisigProposal: configProposalPda, + enqueuedApproval: configEnqueuedApprovalPda, + squadsMultisigProgram: multisig.PROGRAM_ID, + }) + .signers([this.payer]) + .rpc(); + + const configTransactionAccount = + await multisig.accounts.VaultTransaction.fromAccountAddress( + this.squadsConnection, + vaultConfigTransactionPda, + ); + const { accountMetas: configTransactionAccountMetas } = + await multisig.utils.accountsForTransactionExecute({ + connection: this.squadsConnection, + message: configTransactionAccount.message, + ephemeralSignerBumps: [ + ...configTransactionAccount.ephemeralSignerBumps, + ], + vaultPda: daoAccount.squadsMultisigVault, + transactionPda: vaultConfigTransactionPda, + programId: multisig.PROGRAM_ID, + }); + + await this.futarchy.autocrat.methods + .adminExecuteMultisigProposal() + .accounts({ + dao, + squadsMultisig: daoAccount.squadsMultisig, + squadsMultisigProposal: configProposalPda, + squadsMultisigVaultTransaction: vaultConfigTransactionPda, + admin: this.payer.publicKey, + squadsMultisigProgram: multisig.PROGRAM_ID, + }) + .remainingAccounts( + configTransactionAccountMetas.map((meta) => + meta.pubkey.equals(dao) ? { ...meta, isSigner: false } : meta, + ), + ) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 200_000 }), + ]) + .signers([this.payer]) + .rpc(); + + const callbacks = expectError( + "RequireGtViolated", + "execute should fail because the proposal was invalidated by the config tx", + ); + + await this.futarchy.autocrat.methods + .executeMultisigProposalApproval() + .accounts({ + dao, + rentReceiver: this.payer.publicKey, + squadsMultisig: daoAccount.squadsMultisig, + squadsMultisigProposal: victimProposalPda, + enqueuedApproval: victimEnqueuedApprovalPda, + squadsMultisigProgram: multisig.PROGRAM_ID, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 200_001 }), + ]) + .signers([this.payer]) + .rpc() + .then(callbacks[0], callbacks[1]); + + const stillEnqueued = await this.banksClient.getAccount( + victimEnqueuedApprovalPda, + ); + assert.isNotNull(stillEnqueued); + }); +}