From e7cdedbb3d9552317c1ace04adb25362c99a4285 Mon Sep 17 00:00:00 2001 From: Pileks Date: Sat, 28 Feb 2026 12:08:23 +0100 Subject: [PATCH] Splits adminApproveExecuteMultisigProposal into two instructions --- .../admin_approve_multisig_proposal.rs | 103 ++++++++ ....rs => admin_execute_multisig_proposal.rs} | 39 ++- programs/futarchy/src/instructions/mod.rs | 6 +- programs/futarchy/src/lib.rs | 13 +- sdk/src/v0.7/types/futarchy.ts | 72 ++++- tests/futarchy/main.test.ts | 6 +- ...s => adminApproveMultisigProposal.test.ts} | 176 ++++++++----- .../unit/adminExecuteMultisigProposal.test.ts | 249 ++++++++++++++++++ 8 files changed, 558 insertions(+), 106 deletions(-) create mode 100644 programs/futarchy/src/instructions/admin_approve_multisig_proposal.rs rename programs/futarchy/src/instructions/{admin_approve_execute_multisig_proposal.rs => admin_execute_multisig_proposal.rs} (71%) rename tests/futarchy/unit/{adminApproveExecuteMultisigProposal.test.ts => adminApproveMultisigProposal.test.ts} (63%) create mode 100644 tests/futarchy/unit/adminExecuteMultisigProposal.test.ts diff --git a/programs/futarchy/src/instructions/admin_approve_multisig_proposal.rs b/programs/futarchy/src/instructions/admin_approve_multisig_proposal.rs new file mode 100644 index 000000000..4a89277cc --- /dev/null +++ b/programs/futarchy/src/instructions/admin_approve_multisig_proposal.rs @@ -0,0 +1,103 @@ +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(Accounts)] +pub struct AdminApproveMultisigProposal<'info> { + #[account(mut, has_one = squads_multisig)] + pub dao: Account<'info, Dao>, + #[account(mut)] + pub admin: Signer<'info>, + + #[account( + mut, + seeds = [ + squads_multisig_program::SEED_PREFIX, + squads_multisig_program::SEED_MULTISIG, + dao.key().as_ref(), + ], + bump, + seeds::program = squads_multisig_program + )] + pub squads_multisig: Account<'info, squads_multisig_program::Multisig>, + + #[account( + mut, + seeds = [ + squads_multisig_program::SEED_PREFIX, + squads_multisig.key().as_ref(), + squads_multisig_program::SEED_TRANSACTION, + squads_multisig_vault_transaction.index.to_le_bytes().as_ref(), + squads_multisig_program::SEED_PROPOSAL, + ], + bump, + seeds::program = squads_multisig_program + )] + pub squads_multisig_proposal: Account<'info, squads_multisig_program::Proposal>, + + #[account( + seeds = [ + squads_multisig_program::SEED_PREFIX, + squads_multisig.key().as_ref(), + squads_multisig_program::SEED_TRANSACTION, + squads_multisig_vault_transaction.index.to_le_bytes().as_ref(), + ], + bump, + seeds::program = squads_multisig_program + )] + pub squads_multisig_vault_transaction: + Account<'info, squads_multisig_program::VaultTransaction>, + + pub squads_multisig_program: + Program<'info, squads_multisig_program::program::SquadsMultisigProgram>, +} + +impl AdminApproveMultisigProposal<'_> { + pub fn validate(&self) -> 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()); + } + + Ok(()) + } + + pub fn handle(ctx: Context) -> Result<()> { + let Self { + dao, + admin: _, + squads_multisig, + squads_multisig_proposal, + squads_multisig_vault_transaction: _, + squads_multisig_program, + } = ctx.accounts; + + let dao_nonce = &dao.nonce.to_le_bytes(); + let dao_creator_key = &dao.dao_creator.as_ref(); + 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(), + squads_multisig_program::cpi::accounts::ProposalVote { + proposal: squads_multisig_proposal.to_account_info(), + multisig: squads_multisig.to_account_info(), + member: dao.to_account_info(), + }, + dao_signer, + ), + squads_multisig_program::ProposalVoteArgs { memo: None }, + )?; + + Ok(()) + } +} diff --git a/programs/futarchy/src/instructions/admin_approve_execute_multisig_proposal.rs b/programs/futarchy/src/instructions/admin_execute_multisig_proposal.rs similarity index 71% rename from programs/futarchy/src/instructions/admin_approve_execute_multisig_proposal.rs rename to programs/futarchy/src/instructions/admin_execute_multisig_proposal.rs index 824c637ef..0a93e81c7 100644 --- a/programs/futarchy/src/instructions/admin_approve_execute_multisig_proposal.rs +++ b/programs/futarchy/src/instructions/admin_execute_multisig_proposal.rs @@ -1,6 +1,6 @@ use super::*; -pub mod admin { +mod admin { use anchor_lang::prelude::declare_id; // MetaDAO-controlled admin - cannot be a Squads signer because of reentrancy @@ -8,17 +8,24 @@ pub mod admin { } #[derive(Accounts)] -#[event_cpi] -pub struct AdminApproveExecuteMultisigProposal<'info> { +pub struct AdminExecuteMultisigProposal<'info> { #[account(mut, has_one = squads_multisig)] pub dao: Account<'info, Dao>, #[account(mut)] pub admin: Signer<'info>, - /// CHECK: checked by futarchy program - #[account(mut, seeds = [squads_multisig_program::SEED_PREFIX, squads_multisig_program::SEED_MULTISIG, dao.key().as_ref()], bump, seeds::program = squads_multisig_program)] + #[account( + mut, + seeds = [ + squads_multisig_program::SEED_PREFIX, + squads_multisig_program::SEED_MULTISIG, + dao.key().as_ref(), + ], + bump, + seeds::program = squads_multisig_program + )] pub squads_multisig: Account<'info, squads_multisig_program::Multisig>, - /// CHECK: squads proposal, initialized by squads multisig program, checked by squads multisig program + #[account( mut, seeds = [ @@ -32,7 +39,7 @@ pub struct AdminApproveExecuteMultisigProposal<'info> { seeds::program = squads_multisig_program )] pub squads_multisig_proposal: Account<'info, squads_multisig_program::Proposal>, - /// CHECK: squads vault transaction, initialized by squads multisig program, checked by squads multisig program + #[account( mut, seeds = [ @@ -51,7 +58,7 @@ pub struct AdminApproveExecuteMultisigProposal<'info> { Program<'info, squads_multisig_program::program::SquadsMultisigProgram>, } -impl<'info, 'c: 'info> AdminApproveExecuteMultisigProposal<'info> { +impl<'info, 'c: 'info> AdminExecuteMultisigProposal<'info> { pub fn validate(&self) -> Result<()> { #[cfg(feature = "production")] require_keys_eq!(self.admin.key(), admin::ID, FutarchyError::InvalidAdmin); @@ -71,8 +78,6 @@ impl<'info, 'c: 'info> AdminApproveExecuteMultisigProposal<'info> { squads_multisig_proposal, squads_multisig_vault_transaction, squads_multisig_program, - event_authority: _, - program: _, } = ctx.accounts; let dao_nonce = &dao.nonce.to_le_bytes(); @@ -80,20 +85,6 @@ impl<'info, 'c: 'info> AdminApproveExecuteMultisigProposal<'info> { 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(), - squads_multisig_program::cpi::accounts::ProposalVote { - proposal: squads_multisig_proposal.to_account_info(), - multisig: squads_multisig.to_account_info(), - member: dao.to_account_info(), - }, - dao_signer, - ), - squads_multisig_program::ProposalVoteArgs { memo: None }, - )?; - // Execute the vault transaction squads_multisig_program::cpi::vault_transaction_execute( CpiContext::new_with_signer( diff --git a/programs/futarchy/src/instructions/mod.rs b/programs/futarchy/src/instructions/mod.rs index 3c32078ba..137792680 100644 --- a/programs/futarchy/src/instructions/mod.rs +++ b/programs/futarchy/src/instructions/mod.rs @@ -1,7 +1,8 @@ use super::*; -pub mod admin_approve_execute_multisig_proposal; +pub mod admin_approve_multisig_proposal; pub mod admin_cancel_proposal; +pub mod admin_execute_multisig_proposal; pub mod admin_remove_proposal; pub mod collect_fees; pub mod collect_meteora_damm_fees; @@ -19,8 +20,9 @@ pub mod unstake_from_proposal; pub mod update_dao; pub mod withdraw_liquidity; -pub use admin_approve_execute_multisig_proposal::*; +pub use admin_approve_multisig_proposal::*; pub use admin_cancel_proposal::*; +pub use admin_execute_multisig_proposal::*; pub use admin_remove_proposal::*; pub use collect_fees::*; pub use collect_meteora_damm_fees::*; diff --git a/programs/futarchy/src/lib.rs b/programs/futarchy/src/lib.rs index 642e9a7de..d3ca8884b 100644 --- a/programs/futarchy/src/lib.rs +++ b/programs/futarchy/src/lib.rs @@ -151,10 +151,17 @@ pub mod futarchy { } #[access_control(ctx.accounts.validate())] - pub fn admin_approve_execute_multisig_proposal<'c: 'info, 'info>( - ctx: Context<'_, '_, 'c, 'info, AdminApproveExecuteMultisigProposal<'info>>, + pub fn admin_approve_multisig_proposal( + ctx: Context, ) -> Result<()> { - AdminApproveExecuteMultisigProposal::handle(ctx) + AdminApproveMultisigProposal::handle(ctx) + } + + #[access_control(ctx.accounts.validate())] + pub fn admin_execute_multisig_proposal<'c: 'info, 'info>( + ctx: Context<'_, '_, 'c, 'info, AdminExecuteMultisigProposal<'info>>, + ) -> Result<()> { + AdminExecuteMultisigProposal::handle(ctx) } #[access_control(ctx.accounts.validate())] diff --git a/sdk/src/v0.7/types/futarchy.ts b/sdk/src/v0.7/types/futarchy.ts index 383868227..17fc84cf9 100644 --- a/sdk/src/v0.7/types/futarchy.ts +++ b/sdk/src/v0.7/types/futarchy.ts @@ -1186,7 +1186,7 @@ export type Futarchy = { args: []; }, { - name: "adminApproveExecuteMultisigProposal"; + name: "adminApproveMultisigProposal"; accounts: [ { name: "dao"; @@ -1210,7 +1210,7 @@ export type Futarchy = { }, { name: "squadsMultisigVaultTransaction"; - isMut: true; + isMut: false; isSigner: false; }, { @@ -1218,13 +1218,39 @@ export type Futarchy = { isMut: false; isSigner: false; }, + ]; + args: []; + }, + { + name: "adminExecuteMultisigProposal"; + accounts: [ { - name: "eventAuthority"; - isMut: false; + name: "dao"; + isMut: true; isSigner: false; }, { - name: "program"; + name: "admin"; + isMut: true; + isSigner: true; + }, + { + name: "squadsMultisig"; + isMut: true; + isSigner: false; + }, + { + name: "squadsMultisigProposal"; + isMut: true; + isSigner: false; + }, + { + name: "squadsMultisigVaultTransaction"; + isMut: true; + isSigner: false; + }, + { + name: "squadsMultisigProgram"; isMut: false; isSigner: false; }, @@ -4449,7 +4475,7 @@ export const IDL: Futarchy = { args: [], }, { - name: "adminApproveExecuteMultisigProposal", + name: "adminApproveMultisigProposal", accounts: [ { name: "dao", @@ -4473,7 +4499,7 @@ export const IDL: Futarchy = { }, { name: "squadsMultisigVaultTransaction", - isMut: true, + isMut: false, isSigner: false, }, { @@ -4481,13 +4507,39 @@ export const IDL: Futarchy = { isMut: false, isSigner: false, }, + ], + args: [], + }, + { + name: "adminExecuteMultisigProposal", + accounts: [ { - name: "eventAuthority", - isMut: false, + name: "dao", + isMut: true, isSigner: false, }, { - name: "program", + name: "admin", + isMut: true, + isSigner: true, + }, + { + name: "squadsMultisig", + isMut: true, + isSigner: false, + }, + { + name: "squadsMultisigProposal", + isMut: true, + isSigner: false, + }, + { + name: "squadsMultisigVaultTransaction", + isMut: true, + isSigner: false, + }, + { + name: "squadsMultisigProgram", isMut: false, isSigner: false, }, diff --git a/tests/futarchy/main.test.ts b/tests/futarchy/main.test.ts index 1cbf4671c..c05ad6760 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 adminApproveProposal from "./unit/adminApproveExecuteMultisigProposal.test.js"; +import adminApproveMultisigProposal from "./unit/adminApproveMultisigProposal.test.js"; +import adminExecuteMultisigProposal from "./unit/adminExecuteMultisigProposal.test.js"; import adminCancelProposal from "./unit/adminCancelProposal.test.js"; import adminRemoveProposal from "./unit/adminRemoveProposal.test.js"; @@ -60,7 +61,8 @@ export default function suite() { describe("#collect_meteora_damm_fees", collectMeteoraDammFees); - describe("#admin_approve_proposal", adminApproveProposal); + describe("#admin_approve_multisig_proposal", adminApproveMultisigProposal); + describe("#admin_execute_multisig_proposal", adminExecuteMultisigProposal); describe("#admin_cancel_proposal", adminCancelProposal); describe("#admin_remove_proposal", adminRemoveProposal); // describe("full proposal", fullProposal); diff --git a/tests/futarchy/unit/adminApproveExecuteMultisigProposal.test.ts b/tests/futarchy/unit/adminApproveMultisigProposal.test.ts similarity index 63% rename from tests/futarchy/unit/adminApproveExecuteMultisigProposal.test.ts rename to tests/futarchy/unit/adminApproveMultisigProposal.test.ts index a619add4e..eb9831db8 100644 --- a/tests/futarchy/unit/adminApproveExecuteMultisigProposal.test.ts +++ b/tests/futarchy/unit/adminApproveMultisigProposal.test.ts @@ -37,7 +37,90 @@ export default function suite() { }); }); - it("should approve a squads proposal with a config transaction that belongs to the DAO's multisig", async function () { + 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() + .accounts({ + dao: dao, + squadsMultisig: daoAccount.squadsMultisig, + squadsMultisigProposal: squadsProposalPda, + squadsMultisigVaultTransaction: vaultTransactionPda, + 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 @@ -100,7 +183,7 @@ export default function suite() { }, ); - // Create the squads proposal first + // Create the squads proposals const squadsTransactionsCreateTx = new Transaction().add( vaultTransactionToInvalidateCreateIx, vaultProposalToInvalidateCreateIx, @@ -143,20 +226,22 @@ export default function suite() { programId: multisig.PROGRAM_ID, }); - let squadsConfigProposal = - await multisig.accounts.Proposal.fromAccountAddress( - this.squadsConnection, - squadsConfigProposalPda, - ); - - assert.equal(squadsConfigProposal.transactionIndex, 2); // We're looking at the correct proposal - assert.equal(squadsConfigProposal.approved.length, 0); // Should have zero approvals - assert.isTrue( - multisig.generated.isProposalStatusActive(squadsConfigProposal.status), - ); + // Approve and execute the config transaction using the new split instructions + await this.futarchy.autocrat.methods + .adminApproveMultisigProposal() + .accounts({ + dao: dao, + squadsMultisig: daoAccount.squadsMultisig, + squadsMultisigProposal: squadsConfigProposalPda, + squadsMultisigVaultTransaction: vaultConfigTransactionPda, + admin: this.payer.publicKey, + squadsMultisigProgram: multisig.PROGRAM_ID, + }) + .signers([this.payer]) + .rpc(); await this.futarchy.autocrat.methods - .adminApproveExecuteMultisigProposal() + .adminExecuteMultisigProposal() .accounts({ dao: dao, squadsMultisig: daoAccount.squadsMultisig, @@ -170,64 +255,30 @@ export default function suite() { meta.pubkey.equals(dao) ? { ...meta, isSigner: false } : meta, ), ) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 200_000 }), + ]) .signers([this.payer]) .rpc(); - squadsConfigProposal = await multisig.accounts.Proposal.fromAccountAddress( - this.squadsConnection, - squadsConfigProposalPda, - ); - - assert.equal(squadsConfigProposal.transactionIndex, 2); // We're looking at the correct proposal - assert.equal(squadsConfigProposal.approved[0].toBase58(), dao.toBase58()); // Should have DAO approval - assert.isTrue( - multisig.generated.isProposalStatusExecuted(squadsConfigProposal.status), - ); - - // Confirm that vault transactions before the config transaction are invalidated - const squadsMultisig = await multisig.accounts.Multisig.fromAccountAddress( - this.squadsConnection, - daoAccount.squadsMultisig, - ); - assert.equal(squadsMultisig.staleTransactionIndex, 2); - - // Attempt to execute the invalidated vault transaction - // We could run a regular futarchy market as well here, but we can also just shortcut it using the admin function - const [vaultInvalidatedTransactionPda] = multisig.getTransactionPda({ + // Now try to approve the invalidated proposal (index 1) + const [squadsInvalidatedProposalPda] = multisig.getProposalPda({ multisigPda: daoAccount.squadsMultisig, - index: configTransactionIndex, + transactionIndex: 1n, }); - const [squadsInvalidatedProposalPda] = multisig.getProposalPda({ + const [vaultInvalidatedTransactionPda] = multisig.getTransactionPda({ multisigPda: daoAccount.squadsMultisig, - transactionIndex: configTransactionIndex, + index: 1n, }); - const invalidatedTransactionAccount = - await multisig.accounts.VaultTransaction.fromAccountAddress( - this.squadsConnection, - vaultInvalidatedTransactionPda, - ); - - const { accountMetas: invalidatedTransactionAccountMetas } = - await multisig.utils.accountsForTransactionExecute({ - connection: this.squadsConnection, - message: configTransactionAccount.message, - ephemeralSignerBumps: [ - ...configTransactionAccount.ephemeralSignerBumps, - ], - vaultPda: daoAccount.squadsMultisigVault, - transactionPda: vaultInvalidatedTransactionPda, - programId: multisig.PROGRAM_ID, - }); - const callbacks = expectError( - "InvalidProposalStatus", - "The proposal should not be executed because it should have been invalidated", + "StaleProposal", + "The proposal should not be approved because it should have been invalidated", ); await this.futarchy.autocrat.methods - .adminApproveExecuteMultisigProposal() + .adminApproveMultisigProposal() .accounts({ dao: dao, squadsMultisig: daoAccount.squadsMultisig, @@ -236,13 +287,8 @@ export default function suite() { admin: this.payer.publicKey, squadsMultisigProgram: multisig.PROGRAM_ID, }) - .remainingAccounts( - invalidatedTransactionAccountMetas.map((meta) => - meta.pubkey.equals(dao) ? { ...meta, isSigner: false } : meta, - ), - ) .preInstructions([ - ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), + ComputeBudgetProgram.setComputeUnitLimit({ units: 200_001 }), ]) .signers([this.payer]) .rpc() diff --git a/tests/futarchy/unit/adminExecuteMultisigProposal.test.ts b/tests/futarchy/unit/adminExecuteMultisigProposal.test.ts new file mode 100644 index 000000000..3c0fae540 --- /dev/null +++ b/tests/futarchy/unit/adminExecuteMultisigProposal.test.ts @@ -0,0 +1,249 @@ +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"; + +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 execute an approved squads proposal", async function () { + const daoAccount = await this.futarchy.getDao(dao); + + 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 vaultTransactionCreateIx = + multisig.instructions.vaultTransactionCreate({ + multisigPda: daoAccount.squadsMultisig, + transactionIndex: 1n, + creator: PERMISSIONLESS_ACCOUNT.publicKey, + rentPayer: this.payer.publicKey, + vaultIndex: 0, + ephemeralSigners: 0, + transactionMessage: setTimeLockMessage, + }); + + 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, + }); + + const transactionAccount = + await multisig.accounts.VaultTransaction.fromAccountAddress( + this.squadsConnection, + vaultTransactionPda, + ); + + const { accountMetas: transactionAccountMetas } = + await multisig.utils.accountsForTransactionExecute({ + connection: this.squadsConnection, + message: transactionAccount.message, + ephemeralSignerBumps: [...transactionAccount.ephemeralSignerBumps], + vaultPda: daoAccount.squadsMultisigVault, + transactionPda: vaultTransactionPda, + programId: multisig.PROGRAM_ID, + }); + + // First approve + await this.futarchy.autocrat.methods + .adminApproveMultisigProposal() + .accounts({ + dao: dao, + squadsMultisig: daoAccount.squadsMultisig, + squadsMultisigProposal: squadsProposalPda, + squadsMultisigVaultTransaction: vaultTransactionPda, + admin: this.payer.publicKey, + squadsMultisigProgram: multisig.PROGRAM_ID, + }) + .signers([this.payer]) + .rpc(); + + // Then execute + await this.futarchy.autocrat.methods + .adminExecuteMultisigProposal() + .accounts({ + dao: dao, + squadsMultisig: daoAccount.squadsMultisig, + squadsMultisigProposal: squadsProposalPda, + squadsMultisigVaultTransaction: vaultTransactionPda, + admin: this.payer.publicKey, + squadsMultisigProgram: multisig.PROGRAM_ID, + }) + .remainingAccounts( + transactionAccountMetas.map((meta) => + meta.pubkey.equals(dao) ? { ...meta, isSigner: false } : meta, + ), + ) + .signers([this.payer]) + .rpc(); + + const 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.isProposalStatusExecuted(squadsProposal.status), + ); + + const squadsMultisig = await multisig.accounts.Multisig.fromAccountAddress( + this.squadsConnection, + daoAccount.squadsMultisig, + ); + assert.equal(squadsMultisig.staleTransactionIndex, 1); + }); + + it("should fail to execute a non-approved 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, + }); + + const transactionAccount = + await multisig.accounts.VaultTransaction.fromAccountAddress( + this.squadsConnection, + vaultTransactionPda, + ); + + const { accountMetas: transactionAccountMetas } = + await multisig.utils.accountsForTransactionExecute({ + connection: this.squadsConnection, + message: transactionAccount.message, + ephemeralSignerBumps: [...transactionAccount.ephemeralSignerBumps], + vaultPda: daoAccount.squadsMultisigVault, + transactionPda: vaultTransactionPda, + programId: multisig.PROGRAM_ID, + }); + + const callbacks = expectError( + "InvalidProposalStatus", + "The proposal should not be executed because it has not been approved", + ); + + await this.futarchy.autocrat.methods + .adminExecuteMultisigProposal() + .accounts({ + dao: dao, + squadsMultisig: daoAccount.squadsMultisig, + squadsMultisigProposal: squadsProposalPda, + squadsMultisigVaultTransaction: vaultTransactionPda, + admin: this.payer.publicKey, + squadsMultisigProgram: multisig.PROGRAM_ID, + }) + .remainingAccounts( + transactionAccountMetas.map((meta) => + meta.pubkey.equals(dao) ? { ...meta, isSigner: false } : meta, + ), + ) + .signers([this.payer]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); +}