From 28190866e05976b1b448f1bde6a237b63c7a1e3b Mon Sep 17 00:00:00 2001 From: Pileks Date: Fri, 6 Feb 2026 16:36:13 -0800 Subject: [PATCH] cancel proposal ix --- programs/futarchy/src/events.rs | 9 + .../src/instructions/admin_cancel_proposal.rs | 230 ++++++++++++ programs/futarchy/src/instructions/mod.rs | 2 + programs/futarchy/src/lib.rs | 5 + sdk/src/v0.7/types/futarchy.ts | 340 ++++++++++++++++++ tests/futarchy/main.test.ts | 2 + .../futarchy/unit/adminCancelProposal.test.ts | 334 +++++++++++++++++ 7 files changed, 922 insertions(+) create mode 100644 programs/futarchy/src/instructions/admin_cancel_proposal.rs create mode 100644 tests/futarchy/unit/adminCancelProposal.test.ts diff --git a/programs/futarchy/src/events.rs b/programs/futarchy/src/events.rs index 5c9d02a33..fa4b302c3 100644 --- a/programs/futarchy/src/events.rs +++ b/programs/futarchy/src/events.rs @@ -199,6 +199,15 @@ pub struct RemoveProposalEvent { pub admin: Pubkey, } +#[event] +pub struct AdminCancelProposalEvent { + pub common: CommonFields, + pub proposal: Pubkey, + pub dao: Pubkey, + pub admin: Pubkey, + pub post_amm_state: FutarchyAmm, +} + #[event] pub struct CollectMeteoraDammFeesEvent { pub common: CommonFields, diff --git a/programs/futarchy/src/instructions/admin_cancel_proposal.rs b/programs/futarchy/src/instructions/admin_cancel_proposal.rs new file mode 100644 index 000000000..6a49dcdc5 --- /dev/null +++ b/programs/futarchy/src/instructions/admin_cancel_proposal.rs @@ -0,0 +1,230 @@ +use super::*; + +use conditional_vault::{cpi::accounts::ResolveQuestion, ResolveQuestionArgs}; +use squads_multisig_program::program::SquadsMultisigProgram; + +pub mod admin { + use anchor_lang::prelude::declare_id; + declare_id!("CWGawadYU8CzRVBecnJymNw97H7E3ndDinV5sMzesgY2"); +} + +#[derive(Accounts)] +#[event_cpi] +pub struct AdminCancelProposal<'info> { + #[account( + mut, has_one = question, has_one = dao, has_one = squads_proposal, + has_one = base_vault, has_one = quote_vault, + has_one = pass_base_mint, has_one = pass_quote_mint, + has_one = fail_base_mint, has_one = fail_quote_mint + )] + pub proposal: Box>, + #[account(mut, has_one = squads_multisig)] + pub dao: Box>, + #[account(mut)] + pub question: Box>, + /// CHECK: checked by squads multisig program + #[account(mut)] + pub squads_proposal: UncheckedAccount<'info>, + /// CHECK: checked by squads multisig program + pub squads_multisig: UncheckedAccount<'info>, + pub squads_multisig_program: Program<'info, SquadsMultisigProgram>, + + #[account(mut, associated_token::mint = proposal.pass_base_mint, associated_token::authority = dao)] + pub amm_pass_base_vault: Box>, + #[account(mut, associated_token::mint = proposal.pass_quote_mint, associated_token::authority = dao)] + pub amm_pass_quote_vault: Box>, + #[account(mut, associated_token::mint = proposal.fail_base_mint, associated_token::authority = dao)] + pub amm_fail_base_vault: Box>, + #[account(mut, associated_token::mint = proposal.fail_quote_mint, associated_token::authority = dao)] + pub amm_fail_quote_vault: Box>, + + #[account(mut, associated_token::mint = dao.base_mint, associated_token::authority = dao)] + pub amm_base_vault: Account<'info, TokenAccount>, + #[account(mut, associated_token::mint = dao.quote_mint, associated_token::authority = dao)] + pub amm_quote_vault: Account<'info, TokenAccount>, + + pub vault_program: Program<'info, ConditionalVaultProgram>, + /// CHECK: checked by vault program + pub vault_event_authority: UncheckedAccount<'info>, + pub token_program: Program<'info, Token>, + #[account(mut)] + pub quote_vault: Box>, + #[account(mut, address = quote_vault.underlying_token_account)] + pub quote_vault_underlying_token_account: Box>, + #[account(mut)] + pub pass_quote_mint: Box>, + #[account(mut)] + pub fail_quote_mint: Box>, + #[account(mut)] + pub pass_base_mint: Box>, + #[account(mut)] + pub fail_base_mint: Box>, + #[account(mut)] + pub base_vault: Box>, + #[account(mut, address = base_vault.underlying_token_account)] + pub base_vault_underlying_token_account: Box>, + + #[account(mut)] + pub admin: Signer<'info>, +} + +impl AdminCancelProposal<'_> { + pub fn validate(&self) -> Result<()> { + #[cfg(feature = "production")] + require_keys_eq!(self.admin.key(), admin::ID, FutarchyError::InvalidAdmin); + + require!( + self.proposal.state == ProposalState::Pending, + FutarchyError::ProposalNotActive + ); + + Ok(()) + } + + pub fn handle(ctx: Context) -> Result<()> { + let Self { + proposal, + dao, + question, + squads_proposal, + squads_multisig, + squads_multisig_program, + vault_program, + quote_vault, + token_program, + event_authority: _, + vault_event_authority, + program: _, + quote_vault_underlying_token_account, + pass_quote_mint, + fail_quote_mint, + amm_pass_quote_vault, + amm_fail_quote_vault, + pass_base_mint, + fail_base_mint, + amm_quote_vault, + amm_pass_base_vault, + amm_fail_base_vault, + amm_base_vault, + base_vault, + base_vault_underlying_token_account, + admin: _, + } = ctx.accounts; + + let squads_proposal_key = squads_proposal.key(); + let proposal_seeds = &[ + b"proposal", + squads_proposal_key.as_ref(), + &[proposal.pda_bump], + ]; + let proposal_signer = &[&proposal_seeds[..]]; + + let PoolState::Futarchy { fail, mut spot, .. } = dao.amm.state.to_owned() else { + unreachable!(); + }; + + proposal.state = ProposalState::Failed; + + let cpi_accounts = ResolveQuestion { + question: question.to_account_info(), + oracle: proposal.to_account_info(), + event_authority: vault_event_authority.to_account_info(), + program: vault_program.to_account_info(), + }; + let cpi_ctx = CpiContext::new(vault_program.to_account_info(), cpi_accounts) + .with_signer(proposal_signer); + conditional_vault::cpi::resolve_question( + cpi_ctx, + ResolveQuestionArgs { + payout_numerators: vec![1, 0], + }, + )?; + + let dao_nonce = &dao.nonce.to_le_bytes(); + let dao_creator_key = &dao.dao_creator.as_ref(); + let dao_seeds = &[b"dao".as_ref(), dao_creator_key, dao_nonce, &[dao.pda_bump]]; + let dao_signer = &[&dao_seeds[..]]; + + squads_multisig_program::cpi::proposal_reject( + CpiContext::new_with_signer( + squads_multisig_program.to_account_info(), + squads_multisig_program::cpi::accounts::ProposalVote { + proposal: squads_proposal.to_account_info(), + multisig: squads_multisig.to_account_info(), + member: dao.to_account_info(), + }, + dao_signer, + ), + squads_multisig_program::ProposalVoteArgs { memo: None }, + )?; + + spot.base_reserves += fail.base_reserves; + spot.quote_reserves += fail.quote_reserves; + spot.base_protocol_fee_balance += fail.base_protocol_fee_balance; + spot.quote_protocol_fee_balance += fail.quote_protocol_fee_balance; + + let quote_cpi_context = CpiContext::new_with_signer( + vault_program.to_account_info(), + conditional_vault::cpi::accounts::InteractWithVault { + question: question.to_account_info(), + vault: quote_vault.to_account_info(), + vault_underlying_token_account: quote_vault_underlying_token_account + .to_account_info(), + authority: dao.to_account_info(), + user_underlying_token_account: amm_quote_vault.to_account_info(), + event_authority: vault_event_authority.to_account_info(), + program: vault_program.to_account_info(), + token_program: token_program.to_account_info(), + }, + dao_signer, + ) + .with_remaining_accounts(vec![ + fail_quote_mint.to_account_info(), + pass_quote_mint.to_account_info(), + amm_fail_quote_vault.to_account_info(), + amm_pass_quote_vault.to_account_info(), + ]); + + conditional_vault::cpi::redeem_tokens(quote_cpi_context)?; + + let base_cpi_context = CpiContext::new_with_signer( + vault_program.to_account_info(), + conditional_vault::cpi::accounts::InteractWithVault { + question: question.to_account_info(), + vault: base_vault.to_account_info(), + vault_underlying_token_account: base_vault_underlying_token_account + .to_account_info(), + authority: dao.to_account_info(), + user_underlying_token_account: amm_base_vault.to_account_info(), + event_authority: vault_event_authority.to_account_info(), + program: vault_program.to_account_info(), + token_program: token_program.to_account_info(), + }, + dao_signer, + ) + .with_remaining_accounts(vec![ + fail_base_mint.to_account_info(), + pass_base_mint.to_account_info(), + amm_fail_base_vault.to_account_info(), + amm_pass_base_vault.to_account_info(), + ]); + + conditional_vault::cpi::redeem_tokens(base_cpi_context)?; + + dao.amm.state = PoolState::Spot { spot }; + + dao.seq_num += 1; + + let clock = Clock::get()?; + + emit_cpi!(AdminCancelProposalEvent { + common: CommonFields::new(&clock, dao.seq_num), + proposal: proposal.key(), + dao: dao.key(), + admin: ctx.accounts.admin.key(), + post_amm_state: dao.amm.clone(), + }); + + Ok(()) + } +} diff --git a/programs/futarchy/src/instructions/mod.rs b/programs/futarchy/src/instructions/mod.rs index c5113ae4c..3c32078ba 100644 --- a/programs/futarchy/src/instructions/mod.rs +++ b/programs/futarchy/src/instructions/mod.rs @@ -1,6 +1,7 @@ use super::*; pub mod admin_approve_execute_multisig_proposal; +pub mod admin_cancel_proposal; pub mod admin_remove_proposal; pub mod collect_fees; pub mod collect_meteora_damm_fees; @@ -19,6 +20,7 @@ pub mod update_dao; pub mod withdraw_liquidity; pub use admin_approve_execute_multisig_proposal::*; +pub use admin_cancel_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 8c90266df..7d19f0c20 100644 --- a/programs/futarchy/src/lib.rs +++ b/programs/futarchy/src/lib.rs @@ -155,6 +155,11 @@ pub mod futarchy { AdminApproveExecuteMultisigProposal::handle(ctx) } + #[access_control(ctx.accounts.validate())] + pub fn admin_cancel_proposal(ctx: Context) -> Result<()> { + AdminCancelProposal::handle(ctx) + } + #[access_control(ctx.accounts.validate())] pub fn admin_remove_proposal(ctx: Context) -> Result<()> { AdminRemoveProposal::handle(ctx) diff --git a/sdk/src/v0.7/types/futarchy.ts b/sdk/src/v0.7/types/futarchy.ts index ee544d9d8..7a75ea0d7 100644 --- a/sdk/src/v0.7/types/futarchy.ts +++ b/sdk/src/v0.7/types/futarchy.ts @@ -1231,6 +1231,142 @@ export type Futarchy = { ]; args: []; }, + { + name: "adminCancelProposal"; + accounts: [ + { + name: "proposal"; + isMut: true; + isSigner: false; + }, + { + name: "dao"; + isMut: true; + isSigner: false; + }, + { + name: "question"; + isMut: true; + isSigner: false; + }, + { + name: "squadsProposal"; + isMut: true; + isSigner: false; + }, + { + name: "squadsMultisig"; + isMut: false; + isSigner: false; + }, + { + name: "squadsMultisigProgram"; + isMut: false; + isSigner: false; + }, + { + name: "ammPassBaseVault"; + isMut: true; + isSigner: false; + }, + { + name: "ammPassQuoteVault"; + isMut: true; + isSigner: false; + }, + { + name: "ammFailBaseVault"; + isMut: true; + isSigner: false; + }, + { + name: "ammFailQuoteVault"; + isMut: true; + isSigner: false; + }, + { + name: "ammBaseVault"; + isMut: true; + isSigner: false; + }, + { + name: "ammQuoteVault"; + isMut: true; + isSigner: false; + }, + { + name: "vaultProgram"; + isMut: false; + isSigner: false; + }, + { + name: "vaultEventAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "tokenProgram"; + isMut: false; + isSigner: false; + }, + { + name: "quoteVault"; + isMut: true; + isSigner: false; + }, + { + name: "quoteVaultUnderlyingTokenAccount"; + isMut: true; + isSigner: false; + }, + { + name: "passQuoteMint"; + isMut: true; + isSigner: false; + }, + { + name: "failQuoteMint"; + isMut: true; + isSigner: false; + }, + { + name: "passBaseMint"; + isMut: true; + isSigner: false; + }, + { + name: "failBaseMint"; + isMut: true; + isSigner: false; + }, + { + name: "baseVault"; + isMut: true; + isSigner: false; + }, + { + name: "baseVaultUnderlyingTokenAccount"; + isMut: true; + isSigner: false; + }, + { + name: "admin"; + isMut: true; + isSigner: true; + }, + { + name: "eventAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "program"; + isMut: false; + isSigner: false; + }, + ]; + args: []; + }, { name: "adminRemoveProposal"; accounts: [ @@ -2812,6 +2948,40 @@ export type Futarchy = { }, ]; }, + { + name: "AdminCancelProposalEvent"; + fields: [ + { + name: "common"; + type: { + defined: "CommonFields"; + }; + index: false; + }, + { + name: "proposal"; + type: "publicKey"; + index: false; + }, + { + name: "dao"; + type: "publicKey"; + index: false; + }, + { + name: "admin"; + type: "publicKey"; + index: false; + }, + { + name: "postAmmState"; + type: { + defined: "FutarchyAmm"; + }; + index: false; + }, + ]; + }, { name: "CollectMeteoraDammFeesEvent"; fields: [ @@ -4282,6 +4452,142 @@ export const IDL: Futarchy = { ], args: [], }, + { + name: "adminCancelProposal", + accounts: [ + { + name: "proposal", + isMut: true, + isSigner: false, + }, + { + name: "dao", + isMut: true, + isSigner: false, + }, + { + name: "question", + isMut: true, + isSigner: false, + }, + { + name: "squadsProposal", + isMut: true, + isSigner: false, + }, + { + name: "squadsMultisig", + isMut: false, + isSigner: false, + }, + { + name: "squadsMultisigProgram", + isMut: false, + isSigner: false, + }, + { + name: "ammPassBaseVault", + isMut: true, + isSigner: false, + }, + { + name: "ammPassQuoteVault", + isMut: true, + isSigner: false, + }, + { + name: "ammFailBaseVault", + isMut: true, + isSigner: false, + }, + { + name: "ammFailQuoteVault", + isMut: true, + isSigner: false, + }, + { + name: "ammBaseVault", + isMut: true, + isSigner: false, + }, + { + name: "ammQuoteVault", + isMut: true, + isSigner: false, + }, + { + name: "vaultProgram", + isMut: false, + isSigner: false, + }, + { + name: "vaultEventAuthority", + isMut: false, + isSigner: false, + }, + { + name: "tokenProgram", + isMut: false, + isSigner: false, + }, + { + name: "quoteVault", + isMut: true, + isSigner: false, + }, + { + name: "quoteVaultUnderlyingTokenAccount", + isMut: true, + isSigner: false, + }, + { + name: "passQuoteMint", + isMut: true, + isSigner: false, + }, + { + name: "failQuoteMint", + isMut: true, + isSigner: false, + }, + { + name: "passBaseMint", + isMut: true, + isSigner: false, + }, + { + name: "failBaseMint", + isMut: true, + isSigner: false, + }, + { + name: "baseVault", + isMut: true, + isSigner: false, + }, + { + name: "baseVaultUnderlyingTokenAccount", + isMut: true, + isSigner: false, + }, + { + name: "admin", + isMut: true, + isSigner: true, + }, + { + name: "eventAuthority", + isMut: false, + isSigner: false, + }, + { + name: "program", + isMut: false, + isSigner: false, + }, + ], + args: [], + }, { name: "adminRemoveProposal", accounts: [ @@ -5863,6 +6169,40 @@ export const IDL: Futarchy = { }, ], }, + { + name: "AdminCancelProposalEvent", + fields: [ + { + name: "common", + type: { + defined: "CommonFields", + }, + index: false, + }, + { + name: "proposal", + type: "publicKey", + index: false, + }, + { + name: "dao", + type: "publicKey", + index: false, + }, + { + name: "admin", + type: "publicKey", + index: false, + }, + { + name: "postAmmState", + type: { + defined: "FutarchyAmm", + }, + index: false, + }, + ], + }, { name: "CollectMeteoraDammFeesEvent", fields: [ diff --git a/tests/futarchy/main.test.ts b/tests/futarchy/main.test.ts index 696260b17..31e0fd7eb 100644 --- a/tests/futarchy/main.test.ts +++ b/tests/futarchy/main.test.ts @@ -12,6 +12,7 @@ import executeSpendingLimitChange from "./unit/executeSpendingLimitChange.test.j import collectMeteoraDammFees from "./unit/collectMeteoraDammFees.test.js"; import adminApproveProposal from "./unit/adminApproveExecuteMultisigProposal.test.js"; +import adminCancelProposal from "./unit/adminCancelProposal.test.js"; import adminRemoveProposal from "./unit/adminRemoveProposal.test.js"; import { PublicKey } from "@solana/web3.js"; @@ -56,6 +57,7 @@ export default function suite() { describe("#collect_meteora_damm_fees", collectMeteoraDammFees); describe("#admin_approve_proposal", adminApproveProposal); + describe("#admin_cancel_proposal", adminCancelProposal); describe("#admin_remove_proposal", adminRemoveProposal); // describe("full proposal", fullProposal); // describe("proposal with a squads batch tx", proposalBatchTx); diff --git a/tests/futarchy/unit/adminCancelProposal.test.ts b/tests/futarchy/unit/adminCancelProposal.test.ts new file mode 100644 index 000000000..847556595 --- /dev/null +++ b/tests/futarchy/unit/adminCancelProposal.test.ts @@ -0,0 +1,334 @@ +import { PERMISSIONLESS_ACCOUNT } from "@metadaoproject/futarchy/v0.6"; +import { + CONDITIONAL_VAULT_PROGRAM_ID, + SQUADS_PROGRAM_ID, + getEventAuthorityAddr, +} from "@metadaoproject/futarchy/v0.7"; +import { + ComputeBudgetProgram, + PublicKey, + Transaction, + TransactionMessage, +} from "@solana/web3.js"; +import { getAssociatedTokenAddressSync } from "@solana/spl-token"; +import BN from "bn.js"; +import { expectError, setupBasicDao } from "../../utils.js"; +import { assert } from "chai"; +import * as multisig from "@sqds/multisig"; + +export default function suite() { + let META: PublicKey, + USDC: PublicKey, + dao: PublicKey, + proposal: PublicKey, + squadsProposalPda: PublicKey; + + beforeEach(async function () { + META = await this.createMint(this.payer.publicKey, 6); + USDC = await this.createMint(this.payer.publicKey, 6); + + await this.createTokenAccount(META, this.payer.publicKey); + await this.createTokenAccount(USDC, this.payer.publicKey); + + await this.mintTo(META, this.payer.publicKey, this.payer, 100 * 10 ** 9); + await this.mintTo( + USDC, + this.payer.publicKey, + this.payer, + 200_000 * 1_000_000, + ); + + dao = await setupBasicDao({ + context: this, + baseMint: META, + quoteMint: USDC, + }); + + await this.futarchy + .provideLiquidityIx({ + dao, + baseMint: META, + quoteMint: USDC, + quoteAmount: new BN(100_000 * 10 ** 6), + maxBaseAmount: new BN(100 * 10 ** 6), + minLiquidity: new BN(0), + positionAuthority: this.payer.publicKey, + liquidityProvider: this.payer.publicKey, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), + ]) + .rpc(); + + const updateDaoIx = await this.futarchy + .updateDaoIx({ + dao, + params: { + passThresholdBps: 500, + secondsPerProposal: null, + baseToStake: null, + twapInitialObservation: null, + twapMaxObservationChangePerUpdate: null, + minQuoteFutarchicLiquidity: null, + minBaseFutarchicLiquidity: null, + twapStartDelaySeconds: null, + teamSponsoredPassThresholdBps: null, + teamAddress: null, + }, + }) + .instruction(); + + const updateDaoMessage = new TransactionMessage({ + payerKey: this.payer.publicKey, + recentBlockhash: (await this.banksClient.getLatestBlockhash())[0], + instructions: [updateDaoIx], + }); + + const multisigPda = multisig.getMultisigPda({ createKey: dao })[0]; + const vaultTxCreate = multisig.instructions.vaultTransactionCreate({ + multisigPda, + transactionIndex: 1n, + creator: PERMISSIONLESS_ACCOUNT.publicKey, + rentPayer: this.payer.publicKey, + vaultIndex: 0, + ephemeralSigners: 0, + transactionMessage: updateDaoMessage, + }); + + const proposalCreateIx = multisig.instructions.proposalCreate({ + multisigPda, + transactionIndex: 1n, + creator: PERMISSIONLESS_ACCOUNT.publicKey, + rentPayer: this.payer.publicKey, + }); + + [squadsProposalPda] = multisig.getProposalPda({ + multisigPda, + transactionIndex: 1n, + }); + + const tx = new Transaction().add(vaultTxCreate, proposalCreateIx); + tx.recentBlockhash = (await this.banksClient.getLatestBlockhash())[0]; + tx.feePayer = this.payer.publicKey; + tx.sign(this.payer, PERMISSIONLESS_ACCOUNT); + + await this.banksClient.processTransaction(tx); + + proposal = await this.futarchy.initializeProposal(dao, squadsProposalPda); + + await this.futarchy + .launchProposalIx({ + proposal, + dao, + baseMint: META, + quoteMint: USDC, + squadsProposal: squadsProposalPda, + }) + .rpc(); + }); + + it.only("should cancel a pending proposal", async function () { + let storedProposal = await this.futarchy.getProposal(proposal); + assert.exists(storedProposal.state.pending); + + const storedDao = await this.futarchy.getDao(dao); + const seqNumBefore = storedDao.seqNum.toNumber(); + + const { + question, + baseVault, + quoteVault, + passBaseMint, + passQuoteMint, + failBaseMint, + failQuoteMint, + } = this.futarchy.getProposalPdas( + proposal, + storedDao.baseMint, + storedDao.quoteMint, + dao, + ); + + const multisigPda = multisig.getMultisigPda({ createKey: dao })[0]; + const [vaultEventAuthority] = getEventAuthorityAddr( + CONDITIONAL_VAULT_PROGRAM_ID, + ); + + await this.futarchy.autocrat.methods + .adminCancelProposal() + .accounts({ + proposal, + dao, + question, + squadsProposal: storedProposal.squadsProposal, + squadsMultisig: multisigPda, + squadsMultisigProgram: SQUADS_PROGRAM_ID, + admin: this.payer.publicKey, + ammPassBaseVault: getAssociatedTokenAddressSync( + passBaseMint, + dao, + true, + ), + ammPassQuoteVault: getAssociatedTokenAddressSync( + passQuoteMint, + dao, + true, + ), + ammFailBaseVault: getAssociatedTokenAddressSync( + failBaseMint, + dao, + true, + ), + ammFailQuoteVault: getAssociatedTokenAddressSync( + failQuoteMint, + dao, + true, + ), + ammBaseVault: getAssociatedTokenAddressSync( + storedDao.baseMint, + dao, + true, + ), + ammQuoteVault: getAssociatedTokenAddressSync( + storedDao.quoteMint, + dao, + true, + ), + vaultProgram: CONDITIONAL_VAULT_PROGRAM_ID, + vaultEventAuthority, + quoteVault, + quoteVaultUnderlyingTokenAccount: getAssociatedTokenAddressSync( + storedDao.quoteMint, + quoteVault, + true, + ), + passQuoteMint, + failQuoteMint, + passBaseMint, + failBaseMint, + baseVault, + baseVaultUnderlyingTokenAccount: getAssociatedTokenAddressSync( + storedDao.baseMint, + baseVault, + true, + ), + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), + ]) + .signers([this.payer]) + .rpc(); + + storedProposal = await this.futarchy.getProposal(proposal); + assert.exists(storedProposal.state.failed); + + const storedDaoAfter = await this.futarchy.getDao(dao); + assert.exists(storedDaoAfter.amm.state.spot); + assert.notExists(storedDaoAfter.amm.state.futarchy); + assert.equal(storedDaoAfter.seqNum.toNumber(), seqNumBefore + 1); + }); + + it("should not cancel an already cancelled proposal", async function () { + const storedProposal = await this.futarchy.getProposal(proposal); + const storedDao = await this.futarchy.getDao(dao); + + const { + question, + baseVault, + quoteVault, + passBaseMint, + passQuoteMint, + failBaseMint, + failQuoteMint, + } = this.futarchy.getProposalPdas( + proposal, + storedDao.baseMint, + storedDao.quoteMint, + dao, + ); + + const multisigPda = multisig.getMultisigPda({ createKey: dao })[0]; + const [vaultEventAuthority] = getEventAuthorityAddr( + CONDITIONAL_VAULT_PROGRAM_ID, + ); + + const accounts = { + proposal, + dao, + question, + squadsProposal: storedProposal.squadsProposal, + squadsMultisig: multisigPda, + squadsMultisigProgram: SQUADS_PROGRAM_ID, + admin: this.payer.publicKey, + ammPassBaseVault: getAssociatedTokenAddressSync(passBaseMint, dao, true), + ammPassQuoteVault: getAssociatedTokenAddressSync( + passQuoteMint, + dao, + true, + ), + ammFailBaseVault: getAssociatedTokenAddressSync(failBaseMint, dao, true), + ammFailQuoteVault: getAssociatedTokenAddressSync( + failQuoteMint, + dao, + true, + ), + ammBaseVault: getAssociatedTokenAddressSync( + storedDao.baseMint, + dao, + true, + ), + ammQuoteVault: getAssociatedTokenAddressSync( + storedDao.quoteMint, + dao, + true, + ), + vaultProgram: CONDITIONAL_VAULT_PROGRAM_ID, + vaultEventAuthority, + quoteVault, + quoteVaultUnderlyingTokenAccount: getAssociatedTokenAddressSync( + storedDao.quoteMint, + quoteVault, + true, + ), + passQuoteMint, + failQuoteMint, + passBaseMint, + failBaseMint, + baseVault, + baseVaultUnderlyingTokenAccount: getAssociatedTokenAddressSync( + storedDao.baseMint, + baseVault, + true, + ), + }; + + // Cancel the proposal first + await this.futarchy.autocrat.methods + .adminCancelProposal() + .accounts(accounts) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), + ]) + .signers([this.payer]) + .rpc(); + + const cancelledProposal = await this.futarchy.getProposal(proposal); + assert.exists(cancelledProposal.state.failed); + + // Try to cancel again — should fail with ProposalNotActive + const callbacks = expectError( + "ProposalNotActive", + "should not cancel an already cancelled proposal", + ); + + await this.futarchy.autocrat.methods + .adminCancelProposal() + .accounts(accounts) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 300_001 }), + ]) + .signers([this.payer]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); +}