From f1d2428f17dc60b6e41183917da995cf727d1b8e Mon Sep 17 00:00:00 2001 From: Pileks Date: Tue, 14 Apr 2026 00:44:25 +0200 Subject: [PATCH] add unstake delay after successful proposal launch --- programs/futarchy/src/error.rs | 2 + .../src/instructions/unstake_from_proposal.rs | 10 + programs/futarchy/src/lib.rs | 3 + sdk/src/v0.7/types/futarchy.ts | 10 + tests/futarchy/main.test.ts | 2 + .../futarchy/unit/unstakeFromProposal.test.ts | 290 ++++++++++++++++++ tests/integration/fullLaunch_v7.test.ts | 24 +- 7 files changed, 339 insertions(+), 2 deletions(-) create mode 100644 tests/futarchy/unit/unstakeFromProposal.test.ts diff --git a/programs/futarchy/src/error.rs b/programs/futarchy/src/error.rs index 150e72973..a5ffb8bcb 100644 --- a/programs/futarchy/src/error.rs +++ b/programs/futarchy/src/error.rs @@ -78,4 +78,6 @@ pub enum FutarchyError { InvalidTransactionMessage, #[msg("Base mint and quote mint must be different")] InvalidMint, + #[msg("Proposal is not ready to be unstaked")] + ProposalNotReadyToUnstake, } diff --git a/programs/futarchy/src/instructions/unstake_from_proposal.rs b/programs/futarchy/src/instructions/unstake_from_proposal.rs index 671874656..dc89ac339 100644 --- a/programs/futarchy/src/instructions/unstake_from_proposal.rs +++ b/programs/futarchy/src/instructions/unstake_from_proposal.rs @@ -49,6 +49,16 @@ pub struct UnstakeFromProposal<'info> { impl UnstakeFromProposal<'_> { pub fn validate(&self, params: &UnstakeFromProposalParams) -> Result<()> { + let clock = Clock::get()?; + + // When a proposal is launched, we allow unstaking after a small delay + // Before it is launched, unstaking can happen normally, as the timestamp_enqueued is 0 + require_gte!( + clock.unix_timestamp, + self.proposal.timestamp_enqueued + MIN_PROPOSAL_UNSTAKE_DELAY_SECONDS, + FutarchyError::ProposalNotReadyToUnstake + ); + require_keys_eq!(self.proposal.dao, self.dao.key()); require_gt!(params.amount, 0, FutarchyError::InvalidAmount); diff --git a/programs/futarchy/src/lib.rs b/programs/futarchy/src/lib.rs index d3ca8884b..a87e63f6a 100644 --- a/programs/futarchy/src/lib.rs +++ b/programs/futarchy/src/lib.rs @@ -55,6 +55,9 @@ pub const PASS_INDEX: usize = 1; // TWAP can only move by $5 per slot pub const DEFAULT_MAX_OBSERVATION_CHANGE_PER_UPDATE_LOTS: u64 = 5_000; +// Unstaking from a proposal should only be allowed after a small delay +pub const MIN_PROPOSAL_UNSTAKE_DELAY_SECONDS: i64 = 5; + #[program] pub mod futarchy { use super::*; diff --git a/sdk/src/v0.7/types/futarchy.ts b/sdk/src/v0.7/types/futarchy.ts index ea0ab7c8d..f33c9eb04 100644 --- a/sdk/src/v0.7/types/futarchy.ts +++ b/sdk/src/v0.7/types/futarchy.ts @@ -3279,6 +3279,11 @@ export type Futarchy = { name: "InvalidMint"; msg: "Base mint and quote mint must be different"; }, + { + code: 6037; + name: "ProposalNotReadyToUnstake"; + msg: "Proposal is not ready to be unstaked"; + }, ]; }; @@ -6563,5 +6568,10 @@ export const IDL: Futarchy = { name: "InvalidMint", msg: "Base mint and quote mint must be different", }, + { + code: 6037, + name: "ProposalNotReadyToUnstake", + msg: "Proposal is not ready to be unstaked", + }, ], }; diff --git a/tests/futarchy/main.test.ts b/tests/futarchy/main.test.ts index c05ad6760..49441b435 100644 --- a/tests/futarchy/main.test.ts +++ b/tests/futarchy/main.test.ts @@ -17,6 +17,7 @@ import adminApproveMultisigProposal from "./unit/adminApproveMultisigProposal.te import adminExecuteMultisigProposal from "./unit/adminExecuteMultisigProposal.test.js"; import adminCancelProposal from "./unit/adminCancelProposal.test.js"; import adminRemoveProposal from "./unit/adminRemoveProposal.test.js"; +import unstakeFromProposal from "./unit/unstakeFromProposal.test.js"; import { PublicKey } from "@solana/web3.js"; import { @@ -65,6 +66,7 @@ export default function suite() { describe("#admin_execute_multisig_proposal", adminExecuteMultisigProposal); describe("#admin_cancel_proposal", adminCancelProposal); describe("#admin_remove_proposal", adminRemoveProposal); + describe("#unstake_from_proposal", unstakeFromProposal); // describe("full proposal", fullProposal); // describe("proposal with a squads batch tx", proposalBatchTx); describe("futarchy amm", futarchyAmm); diff --git a/tests/futarchy/unit/unstakeFromProposal.test.ts b/tests/futarchy/unit/unstakeFromProposal.test.ts new file mode 100644 index 000000000..609560a08 --- /dev/null +++ b/tests/futarchy/unit/unstakeFromProposal.test.ts @@ -0,0 +1,290 @@ +import { + InstructionUtils, + PERMISSIONLESS_ACCOUNT, +} from "@metadaoproject/futarchy/v0.6"; +import { + ComputeBudgetProgram, + 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, + squadsProposalPda: PublicKey; + + beforeEach(async function () { + META = await this.createMint(this.payer.publicKey, 6); + USDC = await this.createMint(this.payer.publicKey, 6); + + // Ensure the clock is well above zero so `timestamp_enqueued == 0` + // (the draft-state sentinel) is unambiguously in the past, regardless + // of what earlier test files did to the clock. + await this.advanceBySeconds(3600); + + await this.createTokenAccount(META, this.payer.publicKey); + await this.createTokenAccount(USDC, this.payer.publicKey); + + await this.mintTo(META, this.payer.publicKey, this.payer, 1_000 * 10 ** 6); + await this.mintTo( + USDC, + this.payer.publicKey, + this.payer, + 200_000 * 10 ** 6, + ); + + 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); + }); + + it("allows unstaking from a Draft proposal", async function () { + const stakeAmount = new BN(100 * 10 ** 6); + + await this.futarchy + .stakeToProposalIx({ + proposal, + dao, + baseMint: META, + amount: stakeAmount, + }) + .rpc(); + + const beforeBalance = await this.getTokenBalance( + META, + this.payer.publicKey, + ); + + await this.futarchy + .unstakeFromProposalIx({ + proposal, + dao, + baseMint: META, + amount: stakeAmount, + }) + .rpc(); + + const afterBalance = await this.getTokenBalance(META, this.payer.publicKey); + assert.equal( + (afterBalance - beforeBalance).toString(), + stakeAmount.toString(), + ); + }); + + it("allows unstaking from a launched proposal after the delay", async function () { + const stakeAmount = new BN(100 * 10 ** 6); + + await this.futarchy + .stakeToProposalIx({ + proposal, + dao, + baseMint: META, + amount: stakeAmount, + }) + .rpc(); + + await this.futarchy + .launchProposalIx({ + proposal, + dao, + baseMint: META, + quoteMint: USDC, + squadsProposal: squadsProposalPda, + }) + .rpc(); + + await this.advanceBySeconds(5); + + const beforeBalance = await this.getTokenBalance( + META, + this.payer.publicKey, + ); + + await this.futarchy + .unstakeFromProposalIx({ + proposal, + dao, + baseMint: META, + amount: stakeAmount, + }) + .rpc(); + + const afterBalance = await this.getTokenBalance(META, this.payer.publicKey); + assert.equal( + (afterBalance - beforeBalance).toString(), + stakeAmount.toString(), + ); + }); + + it("fails when unstaking one second before the delay has elapsed", async function () { + const stakeAmount = new BN(100 * 10 ** 6); + + await this.futarchy + .stakeToProposalIx({ + proposal, + dao, + baseMint: META, + amount: stakeAmount, + }) + .rpc(); + + await this.futarchy + .launchProposalIx({ + proposal, + dao, + baseMint: META, + quoteMint: USDC, + squadsProposal: squadsProposalPda, + }) + .rpc(); + + // MIN_PROPOSAL_UNSTAKE_DELAY_SECONDS is 5, so advancing by 4 is the + // largest advance that still leaves the check `clock >= enqueued + 5` false. + await this.advanceBySeconds(4); + + const callbacks = expectError( + "ProposalNotReadyToUnstake", + "Unstaking one second before the delay should fail", + ); + + await this.futarchy + .unstakeFromProposalIx({ + proposal, + dao, + baseMint: META, + amount: stakeAmount, + }) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("fails when unstaking in the same transaction as launch (flash-loan)", async function () { + const stakeAmount = new BN(100 * 10 ** 6); + + // stakeIxs includes the SDK's ATA-creation preInstructions for the + // staker/proposal base accounts, which we need because this is the + // first stake on this proposal. + const stakeIxs = await InstructionUtils.getInstructions( + this.futarchy.stakeToProposalIx({ + proposal, + dao, + baseMint: META, + amount: stakeAmount, + }), + ); + + // Use .instruction() for launch so we don't pull in its own + // ComputeBudget preInstruction — Solana rejects transactions with + // duplicate compute-budget instructions. + const launchIx = await this.futarchy + .launchProposalIx({ + proposal, + dao, + baseMint: META, + quoteMint: USDC, + squadsProposal: squadsProposalPda, + }) + .instruction(); + + const callbacks = expectError( + "ProposalNotReadyToUnstake", + "Unstaking in the same tx as launch should fail the delay check", + ); + + await this.futarchy + .unstakeFromProposalIx({ + proposal, + dao, + baseMint: META, + amount: stakeAmount, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 800_000 }), + ...stakeIxs, + launchIx, + ]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); +} diff --git a/tests/integration/fullLaunch_v7.test.ts b/tests/integration/fullLaunch_v7.test.ts index d917fd652..1693dc296 100644 --- a/tests/integration/fullLaunch_v7.test.ts +++ b/tests/integration/fullLaunch_v7.test.ts @@ -1,4 +1,5 @@ import { + ComputeBudgetProgram, Keypair, PublicKey, Transaction, @@ -15,7 +16,7 @@ import { } from "@metadaoproject/futarchy/v0.7"; import { BN } from "bn.js"; import { initializeMintWithSeeds } from "../launchpad_v7/utils.js"; -import { createLookupTableForTransaction } from "../utils.js"; +import { createLookupTableForTransaction, expectError } from "../utils.js"; import * as token from "@solana/spl-token"; import * as multisig from "@sqds/multisig"; @@ -510,7 +511,23 @@ export default async function suite() { }) .rpc(); - // Unstake from the proposal + // Unstake should fail immediately after launch due to the unstake delay + const unstakeCallbacks = expectError( + "ProposalNotReadyToUnstake", + "Should not allow unstaking before the lockout delay has passed", + ); + await this.futarchy + .unstakeFromProposalIx({ + proposal, + dao, + baseMint: META, + amount: stakeAmount, + }) + .rpc() + .then(unstakeCallbacks[0], unstakeCallbacks[1]); + + // Advance past the unstake delay and retry + await this.advanceBySeconds(5); await this.futarchy .unstakeFromProposalIx({ proposal, @@ -518,6 +535,9 @@ export default async function suite() { baseMint: META, amount: stakeAmount, }) + .postInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 200_001 }), + ]) .rpc(); await this.conditionalVault