diff --git a/Anchor.toml b/Anchor.toml index 10a9ab039..983c2c47b 100644 --- a/Anchor.toml +++ b/Anchor.toml @@ -59,6 +59,7 @@ v07-approve-points-based = "yarn run tsx scripts/v0.7/pointsBased/approveWithPoi v07-close-launch = "yarn run tsx scripts/v0.7/closeLaunch.ts" v07-initialize-performance-package = "yarn run tsx scripts/v0.7/initializePerformancePackage.ts" v07-claim-launch-additional-tokens = "yarn run tsx scripts/v0.7/claimLaunchAdditionalTokens.ts" +v07-remove-proposal = "yarn run tsx scripts/v0.7/removeProposal.ts" [test] startup_wait = 5000 diff --git a/programs/futarchy/src/events.rs b/programs/futarchy/src/events.rs index 5e96aedf6..5c9d02a33 100644 --- a/programs/futarchy/src/events.rs +++ b/programs/futarchy/src/events.rs @@ -191,6 +191,14 @@ pub struct SponsorProposalEvent { pub team_address: Pubkey, } +#[event] +pub struct RemoveProposalEvent { + pub common: CommonFields, + pub proposal: Pubkey, + pub dao: Pubkey, + pub admin: Pubkey, +} + #[event] pub struct CollectMeteoraDammFeesEvent { pub common: CommonFields, diff --git a/programs/futarchy/src/instructions/admin_remove_proposal.rs b/programs/futarchy/src/instructions/admin_remove_proposal.rs new file mode 100644 index 000000000..8399fd450 --- /dev/null +++ b/programs/futarchy/src/instructions/admin_remove_proposal.rs @@ -0,0 +1,51 @@ +use super::*; + +pub mod admin { + use anchor_lang::prelude::declare_id; + declare_id!("tSTp6B6kE9o6ZaTmHm2ZwnJBBtgd3x112tapxFhmBEQ"); +} + +#[derive(Accounts)] +#[event_cpi] +pub struct AdminRemoveProposal<'info> { + #[account(mut, has_one = dao)] + pub proposal: Box>, + #[account(mut)] + pub dao: Box>, + #[account(mut)] + pub admin: Signer<'info>, +} + +impl AdminRemoveProposal<'_> { + pub fn validate(&self) -> Result<()> { + #[cfg(feature = "production")] + require_keys_eq!(self.admin.key(), admin::ID, FutarchyError::InvalidAdmin); + + // TODO: See how we'd handle cancelling a proposal that has already been launched (in Pending state) + require!( + matches!(self.proposal.state, ProposalState::Draft { .. }), + FutarchyError::ProposalNotInDraftState + ); + + Ok(()) + } + + pub fn handle(ctx: Context) -> Result<()> { + let proposal = &mut ctx.accounts.proposal; + let dao = &mut ctx.accounts.dao; + + proposal.state = ProposalState::Removed; + + dao.seq_num += 1; + let clock = Clock::get()?; + + emit_cpi!(RemoveProposalEvent { + common: CommonFields::new(&clock, dao.seq_num), + proposal: proposal.key(), + dao: dao.key(), + admin: ctx.accounts.admin.key(), + }); + + Ok(()) + } +} diff --git a/programs/futarchy/src/instructions/mod.rs b/programs/futarchy/src/instructions/mod.rs index 40a9efd71..c5113ae4c 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_remove_proposal; pub mod collect_fees; pub mod collect_meteora_damm_fees; pub mod conditional_swap; @@ -18,6 +19,7 @@ pub mod update_dao; pub mod withdraw_liquidity; pub use admin_approve_execute_multisig_proposal::*; +pub use admin_remove_proposal::*; pub use collect_fees::*; pub use collect_meteora_damm_fees::*; pub use conditional_swap::*; diff --git a/programs/futarchy/src/instructions/withdraw_liquidity.rs b/programs/futarchy/src/instructions/withdraw_liquidity.rs index 4ab95f21a..b693a3e54 100644 --- a/programs/futarchy/src/instructions/withdraw_liquidity.rs +++ b/programs/futarchy/src/instructions/withdraw_liquidity.rs @@ -96,10 +96,7 @@ impl WithdrawLiquidity<'_> { // TODO: check that pool is already in right state unreachable!(); }; - spot.get_base_and_quote_withdrawable( - liquidity_to_withdraw as u64, - total_liquidity as u64, - ) + spot.get_base_and_quote_withdrawable(liquidity_to_withdraw, total_liquidity) }; require_gte!( diff --git a/programs/futarchy/src/lib.rs b/programs/futarchy/src/lib.rs index 4f68ecf3b..8c90266df 100644 --- a/programs/futarchy/src/lib.rs +++ b/programs/futarchy/src/lib.rs @@ -154,4 +154,9 @@ pub mod futarchy { ) -> Result<()> { AdminApproveExecuteMultisigProposal::handle(ctx) } + + #[access_control(ctx.accounts.validate())] + pub fn admin_remove_proposal(ctx: Context) -> Result<()> { + AdminRemoveProposal::handle(ctx) + } } diff --git a/programs/futarchy/src/state/futarchy_amm.rs b/programs/futarchy/src/state/futarchy_amm.rs index a71ac0a42..2fcb0cb3e 100644 --- a/programs/futarchy/src/state/futarchy_amm.rs +++ b/programs/futarchy/src/state/futarchy_amm.rs @@ -583,8 +583,8 @@ impl Pool { /// Get the number of base and quote tokens withdrawable from a position pub fn get_base_and_quote_withdrawable( &self, - lp_tokens: u64, - lp_total_supply: u64, + lp_tokens: u128, + lp_total_supply: u128, ) -> (u64, u64) { ( self.get_base_withdrawable(lp_tokens, lp_total_supply), @@ -593,14 +593,14 @@ impl Pool { } /// Get the number of base tokens withdrawable from a position - pub fn get_base_withdrawable(&self, lp_tokens: u64, lp_total_supply: u64) -> u64 { + pub fn get_base_withdrawable(&self, lp_tokens: u128, lp_total_supply: u128) -> u64 { // must fit back into u64 since `lp_tokens` <= `lp_total_supply` - ((lp_tokens as u128 * self.base_reserves as u128) / lp_total_supply as u128) as u64 + ((lp_tokens * self.base_reserves as u128) / lp_total_supply) as u64 } /// Get the number of quote tokens withdrawable from a position - pub fn get_quote_withdrawable(&self, lp_tokens: u64, lp_total_supply: u64) -> u64 { - ((lp_tokens as u128 * self.quote_reserves as u128) / lp_total_supply as u128) as u64 + pub fn get_quote_withdrawable(&self, lp_tokens: u128, lp_total_supply: u128) -> u64 { + ((lp_tokens * self.quote_reserves as u128) / lp_total_supply) as u64 } } diff --git a/programs/futarchy/src/state/proposal.rs b/programs/futarchy/src/state/proposal.rs index b0998246e..ae8ff9e71 100644 --- a/programs/futarchy/src/state/proposal.rs +++ b/programs/futarchy/src/state/proposal.rs @@ -6,6 +6,7 @@ pub enum ProposalState { Pending, Passed, Failed, + Removed, } impl std::fmt::Display for ProposalState { diff --git a/scripts/v0.7/removeProposal.ts b/scripts/v0.7/removeProposal.ts new file mode 100644 index 000000000..1c48e80d1 --- /dev/null +++ b/scripts/v0.7/removeProposal.ts @@ -0,0 +1,44 @@ +import * as anchor from "@coral-xyz/anchor"; +import { + CONDITIONAL_VAULT_PROGRAM_ID, + FUTARCHY_PROGRAM_ID, + FutarchyClient, +} from "@metadaoproject/futarchy/v0.7"; +import { PublicKey } from "@solana/web3.js"; + +// Set the proposal address before running the script +const proposal = new PublicKey(""); + +const provider = anchor.AnchorProvider.env(); + +// Payer MUST be the admin signer - tSTp6B6kE9o6ZaTmHm2ZwnJBBtgd3x112tapxFhmBEQ +const payer = provider.wallet["payer"]; + +const futarchy: FutarchyClient = new FutarchyClient( + provider, + FUTARCHY_PROGRAM_ID, + CONDITIONAL_VAULT_PROGRAM_ID, + [], +); + +export const removeProposal = async () => { + // Fetch the proposal to get the DAO address + const proposalAccount = await futarchy.getProposal(proposal); + + console.log(`Removing proposal at address: ${proposal.toBase58()}`); + console.log(`DAO: ${proposalAccount.dao.toBase58()}`); + console.log(`Current state: ${JSON.stringify(proposalAccount.state)}`); + + const tx = await futarchy.autocrat.methods + .adminRemoveProposal() + .accounts({ + proposal, + dao: proposalAccount.dao, + admin: payer.publicKey, + }) + .rpc(); + + console.log(`Proposal removed successfully. Signature: ${tx}`); +}; + +removeProposal().catch(console.error); diff --git a/sdk/src/v0.7/types/futarchy.ts b/sdk/src/v0.7/types/futarchy.ts index 0e882f610..ee544d9d8 100644 --- a/sdk/src/v0.7/types/futarchy.ts +++ b/sdk/src/v0.7/types/futarchy.ts @@ -1231,6 +1231,37 @@ export type Futarchy = { ]; args: []; }, + { + name: "adminRemoveProposal"; + accounts: [ + { + name: "proposal"; + isMut: true; + isSigner: false; + }, + { + name: "dao"; + isMut: true; + isSigner: false; + }, + { + name: "admin"; + isMut: true; + isSigner: true; + }, + { + name: "eventAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "program"; + isMut: false; + isSigner: false; + }, + ]; + args: []; + }, ]; accounts: [ { @@ -2029,6 +2060,9 @@ export type Futarchy = { { name: "Failed"; }, + { + name: "Removed"; + }, ]; }; }, @@ -2751,6 +2785,33 @@ export type Futarchy = { }, ]; }, + { + name: "RemoveProposalEvent"; + 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: "CollectMeteoraDammFeesEvent"; fields: [ @@ -4221,6 +4282,37 @@ export const IDL: Futarchy = { ], args: [], }, + { + name: "adminRemoveProposal", + accounts: [ + { + name: "proposal", + isMut: true, + isSigner: false, + }, + { + name: "dao", + isMut: true, + isSigner: false, + }, + { + name: "admin", + isMut: true, + isSigner: true, + }, + { + name: "eventAuthority", + isMut: false, + isSigner: false, + }, + { + name: "program", + isMut: false, + isSigner: false, + }, + ], + args: [], + }, ], accounts: [ { @@ -5019,6 +5111,9 @@ export const IDL: Futarchy = { { name: "Failed", }, + { + name: "Removed", + }, ], }, }, @@ -5741,6 +5836,33 @@ export const IDL: Futarchy = { }, ], }, + { + name: "RemoveProposalEvent", + 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: "CollectMeteoraDammFeesEvent", fields: [ diff --git a/tests/futarchy/main.test.ts b/tests/futarchy/main.test.ts index cf03545e3..696260b17 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 adminRemoveProposal from "./unit/adminRemoveProposal.test.js"; import { PublicKey } from "@solana/web3.js"; import { @@ -55,6 +56,7 @@ export default function suite() { describe("#collect_meteora_damm_fees", collectMeteoraDammFees); describe("#admin_approve_proposal", adminApproveProposal); + describe("#admin_remove_proposal", adminRemoveProposal); // describe("full proposal", fullProposal); // describe("proposal with a squads batch tx", proposalBatchTx); describe("futarchy amm", futarchyAmm); diff --git a/tests/futarchy/unit/adminRemoveProposal.test.ts b/tests/futarchy/unit/adminRemoveProposal.test.ts new file mode 100644 index 000000000..aab9ef045 --- /dev/null +++ b/tests/futarchy/unit/adminRemoveProposal.test.ts @@ -0,0 +1,262 @@ +import { + PERMISSIONLESS_ACCOUNT, + PriceMath, +} from "@metadaoproject/futarchy/v0.6"; +import { + ComputeBudgetProgram, + Keypair, + PublicKey, + Transaction, + TransactionMessage, +} from "@solana/web3.js"; +import BN from "bn.js"; +import { expectError, setupBasicDao } from "../../utils.js"; +import { assert } from "chai"; +import * as multisig from "@sqds/multisig"; + +export default function suite() { + let META: PublicKey, USDC: PublicKey, dao: PublicKey, proposal: PublicKey; + + 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 setupBasicDao({ + context: this, + baseMint: META, + quoteMint: USDC, + }); + + // Create a proposal in Draft state + 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, + }); + + const [squadsProposalPda] = multisig.getProposalPda({ + multisigPda, + transactionIndex: 1n, + }); + + const tx = new Transaction().add(vaultTxCreate, proposalCreateIx); + tx.recentBlockhash = (await this.banksClient.getLatestBlockhash())[0]; + tx.feePayer = this.payer.publicKey; + tx.sign(this.payer, PERMISSIONLESS_ACCOUNT); + + await this.banksClient.processTransaction(tx); + + proposal = await this.futarchy.initializeProposal(dao, squadsProposalPda); + }); + + it("should move proposal from Draft to Removed state", async function () { + // Verify initial state is Draft + let storedProposal = await this.futarchy.getProposal(proposal); + assert.exists(storedProposal.state.draft); + + // Call admin_remove_proposal + await this.futarchy.autocrat.methods + .adminRemoveProposal() + .accounts({ + proposal, + dao, + admin: this.payer.publicKey, + }) + .signers([this.payer]) + .rpc(); + + // Verify state is now Removed + storedProposal = await this.futarchy.getProposal(proposal); + assert.exists(storedProposal.state.removed); + }); + + it("should allow unstaking from Removed proposals", async function () { + const stakeAmount = new BN(10 * 10 ** 9); + + // Stake first + await this.futarchy + .stakeToProposalIx({ + proposal, + dao, + baseMint: META, + amount: stakeAmount, + staker: this.payer.publicKey, + payer: this.payer.publicKey, + }) + .rpc(); + + // Remove the proposal + await this.futarchy.autocrat.methods + .adminRemoveProposal() + .accounts({ + proposal, + dao, + admin: this.payer.publicKey, + }) + .signers([this.payer]) + .rpc(); + + // Verify state is Removed + let storedProposal = await this.futarchy.getProposal(proposal); + assert.exists(storedProposal.state.removed); + + // Unstake should still work from Removed state + const initialBalance = await this.getTokenBalance( + META, + this.payer.publicKey, + ); + + await this.futarchy + .unstakeFromProposalIx({ + proposal, + dao, + baseMint: META, + amount: stakeAmount, + staker: this.payer.publicKey, + }) + .rpc(); + + const finalBalance = await this.getTokenBalance(META, this.payer.publicKey); + assert(finalBalance > initialBalance, "Tokens should be returned"); + }); + + it("should not allow staking to Removed proposals", async function () { + // Remove the proposal first + await this.futarchy.autocrat.methods + .adminRemoveProposal() + .accounts({ + proposal, + dao, + admin: this.payer.publicKey, + }) + .signers([this.payer]) + .rpc(); + + // Verify state is Removed + let storedProposal = await this.futarchy.getProposal(proposal); + assert.exists(storedProposal.state.removed); + + // Try to stake - should fail with ProposalNotInDraftState + const callbacks = expectError( + "ProposalNotInDraftState", + "Should not allow staking to Removed proposal", + ); + + await this.futarchy + .stakeToProposalIx({ + proposal, + dao, + baseMint: META, + amount: new BN(10 * 10 ** 9), + staker: this.payer.publicKey, + payer: this.payer.publicKey, + }) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("should not allow removing Pending proposals", async function () { + // Add liquidity to the DAO AMM so launch can succeed + await this.futarchy + .provideLiquidityIx({ + dao, + baseMint: META, + quoteMint: USDC, + quoteAmount: new BN(100_000 * 10 ** 6), + maxBaseAmount: new BN(100 * 10 ** 9), + minLiquidity: new BN(0), + positionAuthority: this.payer.publicKey, + liquidityProvider: this.payer.publicKey, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), + ]) + .rpc(); + + // Get squads proposal PDA + const multisigPda = multisig.getMultisigPda({ createKey: dao })[0]; + const [squadsProposalPda] = multisig.getProposalPda({ + multisigPda, + transactionIndex: 1n, + }); + + // Launch the proposal to move it to Pending state + await this.futarchy + .launchProposalIx({ + proposal, + dao, + baseMint: META, + quoteMint: USDC, + squadsProposal: squadsProposalPda, + }) + .rpc(); + + // Verify state is Pending + let storedProposal = await this.futarchy.getProposal(proposal); + assert.exists(storedProposal.state.pending); + + // Try to remove - should fail + const callbacks = expectError( + "ProposalNotInDraftState", + "Should not allow removing Pending proposal", + ); + + await this.futarchy.autocrat.methods + .adminRemoveProposal() + .accounts({ + proposal, + dao, + admin: this.payer.publicKey, + }) + .signers([this.payer]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); +}