Skip to content

Commit f1d2428

Browse files
committed
add unstake delay after successful proposal launch
1 parent 33c1461 commit f1d2428

7 files changed

Lines changed: 339 additions & 2 deletions

File tree

programs/futarchy/src/error.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,4 +78,6 @@ pub enum FutarchyError {
7878
InvalidTransactionMessage,
7979
#[msg("Base mint and quote mint must be different")]
8080
InvalidMint,
81+
#[msg("Proposal is not ready to be unstaked")]
82+
ProposalNotReadyToUnstake,
8183
}

programs/futarchy/src/instructions/unstake_from_proposal.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,16 @@ pub struct UnstakeFromProposal<'info> {
4949

5050
impl UnstakeFromProposal<'_> {
5151
pub fn validate(&self, params: &UnstakeFromProposalParams) -> Result<()> {
52+
let clock = Clock::get()?;
53+
54+
// When a proposal is launched, we allow unstaking after a small delay
55+
// Before it is launched, unstaking can happen normally, as the timestamp_enqueued is 0
56+
require_gte!(
57+
clock.unix_timestamp,
58+
self.proposal.timestamp_enqueued + MIN_PROPOSAL_UNSTAKE_DELAY_SECONDS,
59+
FutarchyError::ProposalNotReadyToUnstake
60+
);
61+
5262
require_keys_eq!(self.proposal.dao, self.dao.key());
5363

5464
require_gt!(params.amount, 0, FutarchyError::InvalidAmount);

programs/futarchy/src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,9 @@ pub const PASS_INDEX: usize = 1;
5555
// TWAP can only move by $5 per slot
5656
pub const DEFAULT_MAX_OBSERVATION_CHANGE_PER_UPDATE_LOTS: u64 = 5_000;
5757

58+
// Unstaking from a proposal should only be allowed after a small delay
59+
pub const MIN_PROPOSAL_UNSTAKE_DELAY_SECONDS: i64 = 5;
60+
5861
#[program]
5962
pub mod futarchy {
6063
use super::*;

sdk/src/v0.7/types/futarchy.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3279,6 +3279,11 @@ export type Futarchy = {
32793279
name: "InvalidMint";
32803280
msg: "Base mint and quote mint must be different";
32813281
},
3282+
{
3283+
code: 6037;
3284+
name: "ProposalNotReadyToUnstake";
3285+
msg: "Proposal is not ready to be unstaked";
3286+
},
32823287
];
32833288
};
32843289

@@ -6563,5 +6568,10 @@ export const IDL: Futarchy = {
65636568
name: "InvalidMint",
65646569
msg: "Base mint and quote mint must be different",
65656570
},
6571+
{
6572+
code: 6037,
6573+
name: "ProposalNotReadyToUnstake",
6574+
msg: "Proposal is not ready to be unstaked",
6575+
},
65666576
],
65676577
};

tests/futarchy/main.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import adminApproveMultisigProposal from "./unit/adminApproveMultisigProposal.te
1717
import adminExecuteMultisigProposal from "./unit/adminExecuteMultisigProposal.test.js";
1818
import adminCancelProposal from "./unit/adminCancelProposal.test.js";
1919
import adminRemoveProposal from "./unit/adminRemoveProposal.test.js";
20+
import unstakeFromProposal from "./unit/unstakeFromProposal.test.js";
2021

2122
import { PublicKey } from "@solana/web3.js";
2223
import {
@@ -65,6 +66,7 @@ export default function suite() {
6566
describe("#admin_execute_multisig_proposal", adminExecuteMultisigProposal);
6667
describe("#admin_cancel_proposal", adminCancelProposal);
6768
describe("#admin_remove_proposal", adminRemoveProposal);
69+
describe("#unstake_from_proposal", unstakeFromProposal);
6870
// describe("full proposal", fullProposal);
6971
// describe("proposal with a squads batch tx", proposalBatchTx);
7072
describe("futarchy amm", futarchyAmm);
Lines changed: 290 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,290 @@
1+
import {
2+
InstructionUtils,
3+
PERMISSIONLESS_ACCOUNT,
4+
} from "@metadaoproject/futarchy/v0.6";
5+
import {
6+
ComputeBudgetProgram,
7+
PublicKey,
8+
Transaction,
9+
TransactionMessage,
10+
} from "@solana/web3.js";
11+
import BN from "bn.js";
12+
import { expectError, setupBasicDao } from "../../utils.js";
13+
import { assert } from "chai";
14+
import * as multisig from "@sqds/multisig";
15+
16+
export default function suite() {
17+
let META: PublicKey,
18+
USDC: PublicKey,
19+
dao: PublicKey,
20+
proposal: PublicKey,
21+
squadsProposalPda: PublicKey;
22+
23+
beforeEach(async function () {
24+
META = await this.createMint(this.payer.publicKey, 6);
25+
USDC = await this.createMint(this.payer.publicKey, 6);
26+
27+
// Ensure the clock is well above zero so `timestamp_enqueued == 0`
28+
// (the draft-state sentinel) is unambiguously in the past, regardless
29+
// of what earlier test files did to the clock.
30+
await this.advanceBySeconds(3600);
31+
32+
await this.createTokenAccount(META, this.payer.publicKey);
33+
await this.createTokenAccount(USDC, this.payer.publicKey);
34+
35+
await this.mintTo(META, this.payer.publicKey, this.payer, 1_000 * 10 ** 6);
36+
await this.mintTo(
37+
USDC,
38+
this.payer.publicKey,
39+
this.payer,
40+
200_000 * 10 ** 6,
41+
);
42+
43+
dao = await setupBasicDao({
44+
context: this,
45+
baseMint: META,
46+
quoteMint: USDC,
47+
});
48+
49+
await this.futarchy
50+
.provideLiquidityIx({
51+
dao,
52+
baseMint: META,
53+
quoteMint: USDC,
54+
quoteAmount: new BN(100_000 * 10 ** 6),
55+
maxBaseAmount: new BN(100 * 10 ** 6),
56+
minLiquidity: new BN(0),
57+
positionAuthority: this.payer.publicKey,
58+
liquidityProvider: this.payer.publicKey,
59+
})
60+
.preInstructions([
61+
ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }),
62+
])
63+
.rpc();
64+
65+
const updateDaoIx = await this.futarchy
66+
.updateDaoIx({
67+
dao,
68+
params: {
69+
passThresholdBps: 500,
70+
secondsPerProposal: null,
71+
baseToStake: null,
72+
twapInitialObservation: null,
73+
twapMaxObservationChangePerUpdate: null,
74+
minQuoteFutarchicLiquidity: null,
75+
minBaseFutarchicLiquidity: null,
76+
twapStartDelaySeconds: null,
77+
teamSponsoredPassThresholdBps: null,
78+
teamAddress: null,
79+
},
80+
})
81+
.instruction();
82+
83+
const updateDaoMessage = new TransactionMessage({
84+
payerKey: this.payer.publicKey,
85+
recentBlockhash: (await this.banksClient.getLatestBlockhash())[0],
86+
instructions: [updateDaoIx],
87+
});
88+
89+
const multisigPda = multisig.getMultisigPda({ createKey: dao })[0];
90+
const vaultTxCreate = multisig.instructions.vaultTransactionCreate({
91+
multisigPda,
92+
transactionIndex: 1n,
93+
creator: PERMISSIONLESS_ACCOUNT.publicKey,
94+
rentPayer: this.payer.publicKey,
95+
vaultIndex: 0,
96+
ephemeralSigners: 0,
97+
transactionMessage: updateDaoMessage,
98+
});
99+
100+
const proposalCreateIx = multisig.instructions.proposalCreate({
101+
multisigPda,
102+
transactionIndex: 1n,
103+
creator: PERMISSIONLESS_ACCOUNT.publicKey,
104+
rentPayer: this.payer.publicKey,
105+
});
106+
107+
[squadsProposalPda] = multisig.getProposalPda({
108+
multisigPda,
109+
transactionIndex: 1n,
110+
});
111+
112+
const tx = new Transaction().add(vaultTxCreate, proposalCreateIx);
113+
tx.recentBlockhash = (await this.banksClient.getLatestBlockhash())[0];
114+
tx.feePayer = this.payer.publicKey;
115+
tx.sign(this.payer, PERMISSIONLESS_ACCOUNT);
116+
117+
await this.banksClient.processTransaction(tx);
118+
119+
proposal = await this.futarchy.initializeProposal(dao, squadsProposalPda);
120+
});
121+
122+
it("allows unstaking from a Draft proposal", async function () {
123+
const stakeAmount = new BN(100 * 10 ** 6);
124+
125+
await this.futarchy
126+
.stakeToProposalIx({
127+
proposal,
128+
dao,
129+
baseMint: META,
130+
amount: stakeAmount,
131+
})
132+
.rpc();
133+
134+
const beforeBalance = await this.getTokenBalance(
135+
META,
136+
this.payer.publicKey,
137+
);
138+
139+
await this.futarchy
140+
.unstakeFromProposalIx({
141+
proposal,
142+
dao,
143+
baseMint: META,
144+
amount: stakeAmount,
145+
})
146+
.rpc();
147+
148+
const afterBalance = await this.getTokenBalance(META, this.payer.publicKey);
149+
assert.equal(
150+
(afterBalance - beforeBalance).toString(),
151+
stakeAmount.toString(),
152+
);
153+
});
154+
155+
it("allows unstaking from a launched proposal after the delay", async function () {
156+
const stakeAmount = new BN(100 * 10 ** 6);
157+
158+
await this.futarchy
159+
.stakeToProposalIx({
160+
proposal,
161+
dao,
162+
baseMint: META,
163+
amount: stakeAmount,
164+
})
165+
.rpc();
166+
167+
await this.futarchy
168+
.launchProposalIx({
169+
proposal,
170+
dao,
171+
baseMint: META,
172+
quoteMint: USDC,
173+
squadsProposal: squadsProposalPda,
174+
})
175+
.rpc();
176+
177+
await this.advanceBySeconds(5);
178+
179+
const beforeBalance = await this.getTokenBalance(
180+
META,
181+
this.payer.publicKey,
182+
);
183+
184+
await this.futarchy
185+
.unstakeFromProposalIx({
186+
proposal,
187+
dao,
188+
baseMint: META,
189+
amount: stakeAmount,
190+
})
191+
.rpc();
192+
193+
const afterBalance = await this.getTokenBalance(META, this.payer.publicKey);
194+
assert.equal(
195+
(afterBalance - beforeBalance).toString(),
196+
stakeAmount.toString(),
197+
);
198+
});
199+
200+
it("fails when unstaking one second before the delay has elapsed", async function () {
201+
const stakeAmount = new BN(100 * 10 ** 6);
202+
203+
await this.futarchy
204+
.stakeToProposalIx({
205+
proposal,
206+
dao,
207+
baseMint: META,
208+
amount: stakeAmount,
209+
})
210+
.rpc();
211+
212+
await this.futarchy
213+
.launchProposalIx({
214+
proposal,
215+
dao,
216+
baseMint: META,
217+
quoteMint: USDC,
218+
squadsProposal: squadsProposalPda,
219+
})
220+
.rpc();
221+
222+
// MIN_PROPOSAL_UNSTAKE_DELAY_SECONDS is 5, so advancing by 4 is the
223+
// largest advance that still leaves the check `clock >= enqueued + 5` false.
224+
await this.advanceBySeconds(4);
225+
226+
const callbacks = expectError(
227+
"ProposalNotReadyToUnstake",
228+
"Unstaking one second before the delay should fail",
229+
);
230+
231+
await this.futarchy
232+
.unstakeFromProposalIx({
233+
proposal,
234+
dao,
235+
baseMint: META,
236+
amount: stakeAmount,
237+
})
238+
.rpc()
239+
.then(callbacks[0], callbacks[1]);
240+
});
241+
242+
it("fails when unstaking in the same transaction as launch (flash-loan)", async function () {
243+
const stakeAmount = new BN(100 * 10 ** 6);
244+
245+
// stakeIxs includes the SDK's ATA-creation preInstructions for the
246+
// staker/proposal base accounts, which we need because this is the
247+
// first stake on this proposal.
248+
const stakeIxs = await InstructionUtils.getInstructions(
249+
this.futarchy.stakeToProposalIx({
250+
proposal,
251+
dao,
252+
baseMint: META,
253+
amount: stakeAmount,
254+
}),
255+
);
256+
257+
// Use .instruction() for launch so we don't pull in its own
258+
// ComputeBudget preInstruction — Solana rejects transactions with
259+
// duplicate compute-budget instructions.
260+
const launchIx = await this.futarchy
261+
.launchProposalIx({
262+
proposal,
263+
dao,
264+
baseMint: META,
265+
quoteMint: USDC,
266+
squadsProposal: squadsProposalPda,
267+
})
268+
.instruction();
269+
270+
const callbacks = expectError(
271+
"ProposalNotReadyToUnstake",
272+
"Unstaking in the same tx as launch should fail the delay check",
273+
);
274+
275+
await this.futarchy
276+
.unstakeFromProposalIx({
277+
proposal,
278+
dao,
279+
baseMint: META,
280+
amount: stakeAmount,
281+
})
282+
.preInstructions([
283+
ComputeBudgetProgram.setComputeUnitLimit({ units: 800_000 }),
284+
...stakeIxs,
285+
launchIx,
286+
])
287+
.rpc()
288+
.then(callbacks[0], callbacks[1]);
289+
});
290+
}

0 commit comments

Comments
 (0)