From d7fc89a7dcfb66c05106d0bcb07959c66c8e78d6 Mon Sep 17 00:00:00 2001 From: Pileks Date: Tue, 20 Jan 2026 16:37:21 -0800 Subject: [PATCH 01/21] close_bid_wall should return funds to authority always --- programs/bid_wall/src/instructions/close_bid_wall.rs | 4 ++-- sdk/src/v0.7/types/bid_wall.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/programs/bid_wall/src/instructions/close_bid_wall.rs b/programs/bid_wall/src/instructions/close_bid_wall.rs index 7ff7236f5..03ec34f51 100644 --- a/programs/bid_wall/src/instructions/close_bid_wall.rs +++ b/programs/bid_wall/src/instructions/close_bid_wall.rs @@ -13,7 +13,7 @@ use crate::{ pub struct CloseBidWall<'info> { #[account( mut, - close=payer, + close=authority, has_one = authority )] pub bid_wall: Account<'info, BidWall>, @@ -22,7 +22,7 @@ pub struct CloseBidWall<'info> { pub payer: Signer<'info>, /// CHECK: used for constraints - #[account(address = bid_wall.authority)] + #[account(mut, address = bid_wall.authority)] pub authority: UncheckedAccount<'info>, /// CHECK: used for constraints diff --git a/sdk/src/v0.7/types/bid_wall.ts b/sdk/src/v0.7/types/bid_wall.ts index 9f1f49892..cb4cdc31e 100644 --- a/sdk/src/v0.7/types/bid_wall.ts +++ b/sdk/src/v0.7/types/bid_wall.ts @@ -105,7 +105,7 @@ export type BidWall = { }, { name: "authority"; - isMut: false; + isMut: true; isSigner: false; }, { @@ -829,7 +829,7 @@ export const IDL: BidWall = { }, { name: "authority", - isMut: false, + isMut: true, isSigner: false, }, { From 9d2ff64731a7914b1ce5d5b66a943bafd40670f9 Mon Sep 17 00:00:00 2001 From: Pileks Date: Tue, 20 Jan 2026 16:58:27 -0800 Subject: [PATCH 02/21] set bid wall fee recipient to metadao multisig vault everywhere --- .../src/instructions/cancel_bid_wall.rs | 5 +-- .../src/instructions/close_bid_wall.rs | 5 +-- .../bid_wall/src/instructions/collect_fees.rs | 8 +---- .../src/instructions/initialize_bid_wall.rs | 6 ++-- programs/bid_wall/src/lib.rs | 7 ++++ sdk/src/v0.7/BidWallClient.ts | 8 ++--- tests/bidWall/unit/cancelBidWall.test.ts | 33 +++++++++++++++++-- tests/bidWall/unit/closeBidWall.test.ts | 30 +++++++++++++++-- tests/bidWall/unit/initializeBidWall.test.ts | 5 +-- tests/bidWall/unit/sellTokens.test.ts | 5 --- 10 files changed, 80 insertions(+), 32 deletions(-) diff --git a/programs/bid_wall/src/instructions/cancel_bid_wall.rs b/programs/bid_wall/src/instructions/cancel_bid_wall.rs index 6cada6b03..5d0e45c20 100644 --- a/programs/bid_wall/src/instructions/cancel_bid_wall.rs +++ b/programs/bid_wall/src/instructions/cancel_bid_wall.rs @@ -3,6 +3,7 @@ use anchor_spl::token::{self, Mint, Token, TokenAccount, Transfer}; use crate::{ events::{BidWallCanceledEvent, CommonFields}, + metadao_multisig_vault, state::BidWall, usdc_mint, }; @@ -24,8 +25,8 @@ pub struct CancelBidWall<'info> { #[account(address = bid_wall.authority)] pub authority: Signer<'info>, - /// CHECK: used for constraints - #[account(address = bid_wall.fee_recipient)] + /// CHECK: the fee recipient is always the metadao multisig vault + #[account(address = metadao_multisig_vault::ID)] pub fee_recipient: UncheckedAccount<'info>, #[account(mut, associated_token::mint = quote_mint, associated_token::authority = bid_wall)] diff --git a/programs/bid_wall/src/instructions/close_bid_wall.rs b/programs/bid_wall/src/instructions/close_bid_wall.rs index 03ec34f51..2fac01379 100644 --- a/programs/bid_wall/src/instructions/close_bid_wall.rs +++ b/programs/bid_wall/src/instructions/close_bid_wall.rs @@ -4,6 +4,7 @@ use anchor_spl::token::{self, Mint, Token, TokenAccount, Transfer}; use crate::{ error::BidWallError, events::{BidWallClosedEvent, CommonFields}, + metadao_multisig_vault, state::BidWall, usdc_mint, }; @@ -25,8 +26,8 @@ pub struct CloseBidWall<'info> { #[account(mut, address = bid_wall.authority)] pub authority: UncheckedAccount<'info>, - /// CHECK: used for constraints - #[account(address = bid_wall.fee_recipient)] + /// CHECK: the fee recipient is always the metadao multisig vault + #[account(address = metadao_multisig_vault::ID)] pub fee_recipient: UncheckedAccount<'info>, #[account(mut, associated_token::mint = quote_mint, associated_token::authority = bid_wall)] diff --git a/programs/bid_wall/src/instructions/collect_fees.rs b/programs/bid_wall/src/instructions/collect_fees.rs index e049be42d..957f9fe6f 100644 --- a/programs/bid_wall/src/instructions/collect_fees.rs +++ b/programs/bid_wall/src/instructions/collect_fees.rs @@ -5,17 +5,11 @@ use anchor_spl::token::{self, Mint, Token, TokenAccount, Transfer}; use crate::error::BidWallError; use crate::{ events::{BidWallFeesCollectedEvent, CommonFields}, + metadao_multisig_vault, state::BidWall, usdc_mint, }; -pub mod metadao_multisig_vault { - use anchor_lang::prelude::declare_id; - - // MetaDAO operations multisig vault - hardcoded fee destination - declare_id!("6awyHMshBGVjJ3ozdSJdyyDE1CTAXUwrpNMaRGMsb4sf"); -} - pub mod metadao_cranker { use anchor_lang::prelude::declare_id; diff --git a/programs/bid_wall/src/instructions/initialize_bid_wall.rs b/programs/bid_wall/src/instructions/initialize_bid_wall.rs index 9f49b01e3..a503259ea 100644 --- a/programs/bid_wall/src/instructions/initialize_bid_wall.rs +++ b/programs/bid_wall/src/instructions/initialize_bid_wall.rs @@ -6,6 +6,7 @@ use anchor_spl::{ use crate::{ events::{BidWallInitializedEvent, CommonFields}, + metadao_multisig_vault, state::BidWall, usdc_mint, }; @@ -34,8 +35,9 @@ pub struct InitializeBidWall<'info> { #[account(mut)] pub payer: Signer<'info>, - /// CHECK: This is the recipient of the fees collected by the bid wall, no need to validate - pub fee_recipient: AccountInfo<'info>, + /// CHECK: The fee recipient is always the metadao multisig vault + #[account(address = metadao_multisig_vault::ID)] + pub fee_recipient: UncheckedAccount<'info>, // Creator must sign to prevent unauthorized bid wall initialization on their behalf pub creator: Signer<'info>, diff --git a/programs/bid_wall/src/lib.rs b/programs/bid_wall/src/lib.rs index 29a6a8743..05ee0eafc 100644 --- a/programs/bid_wall/src/lib.rs +++ b/programs/bid_wall/src/lib.rs @@ -34,6 +34,13 @@ pub mod usdc_mint { declare_id!("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"); } +pub mod metadao_multisig_vault { + use anchor_lang::prelude::declare_id; + + // MetaDAO operations multisig vault - hardcoded fee destination + declare_id!("6awyHMshBGVjJ3ozdSJdyyDE1CTAXUwrpNMaRGMsb4sf"); +} + pub const FEE_BPS: u16 = 100; pub const TOKEN_SCALE: u64 = 1_000_000; diff --git a/sdk/src/v0.7/BidWallClient.ts b/sdk/src/v0.7/BidWallClient.ts index 347fd2182..a64babc21 100644 --- a/sdk/src/v0.7/BidWallClient.ts +++ b/sdk/src/v0.7/BidWallClient.ts @@ -62,7 +62,7 @@ export class BidWallClient { baseMint, creator = this.provider.publicKey, nonce = new BN(0), - feeRecipient, + feeRecipient = METADAO_MULTISIG_VAULT, quoteMint = MAINNET_USDC, payer = this.provider.publicKey, }: { @@ -74,7 +74,7 @@ export class BidWallClient { nonce?: BN; authority?: PublicKey; baseMint: PublicKey; - feeRecipient: PublicKey; + feeRecipient?: PublicKey; quoteMint?: PublicKey; payer?: PublicKey; }) { @@ -208,7 +208,7 @@ export class BidWallClient { bidWall, authority, baseMint, - feeRecipient = PublicKey.default, + feeRecipient = METADAO_MULTISIG_VAULT, quoteMint = MAINNET_USDC, payer = this.provider.publicKey, }: { @@ -254,7 +254,7 @@ export class BidWallClient { bidWall, authority, baseMint, - feeRecipient = PublicKey.default, + feeRecipient = METADAO_MULTISIG_VAULT, quoteMint = MAINNET_USDC, payer = this.provider.publicKey, }: { diff --git a/tests/bidWall/unit/cancelBidWall.test.ts b/tests/bidWall/unit/cancelBidWall.test.ts index 49e12b4de..034604e52 100644 --- a/tests/bidWall/unit/cancelBidWall.test.ts +++ b/tests/bidWall/unit/cancelBidWall.test.ts @@ -1,6 +1,7 @@ import { Keypair, PublicKey, + Transaction, TransactionMessage, VersionedTransaction, } from "@solana/web3.js"; @@ -11,9 +12,13 @@ import { BidWallClient, MAINNET_USDC, getBidWallAddr, + METADAO_MULTISIG_VAULT, } from "@metadaoproject/futarchy/v0.7"; import { BN } from "bn.js"; -import { getAssociatedTokenAddressSync } from "@solana/spl-token"; +import { + createAssociatedTokenAccountIdempotentInstruction, + getAssociatedTokenAddressSync, +} from "@solana/spl-token"; import { initializeMintWithSeeds } from "../utils.js"; import { createLookupTableForTransaction } from "../../utils.js"; @@ -137,8 +142,30 @@ export default function suite() { await this.getTokenBalance(MAINNET_USDC, dao), ); - feeRecipient = Keypair.generate().publicKey; - await this.createTokenAccount(MAINNET_USDC, feeRecipient); + feeRecipient = METADAO_MULTISIG_VAULT; + + const feeRecipientQuoteTokenAccount = getAssociatedTokenAddressSync( + MAINNET_USDC, + feeRecipient, + true, + ); + + const createAtaTx = new Transaction().add( + createAssociatedTokenAccountIdempotentInstruction( + this.payer.publicKey, + feeRecipientQuoteTokenAccount, + feeRecipient, + MAINNET_USDC, + ), + ); + + createAtaTx.recentBlockhash = ( + await this.banksClient.getLatestBlockhash() + )[0]; + createAtaTx.feePayer = this.payer.publicKey; + createAtaTx.sign(this.payer); + + await this.banksClient.processTransaction(createAtaTx); // Claim tokens for the payer await launchpadClient.claimIx(launch, META).rpc(); diff --git a/tests/bidWall/unit/closeBidWall.test.ts b/tests/bidWall/unit/closeBidWall.test.ts index 3507fffe0..9aa56ad97 100644 --- a/tests/bidWall/unit/closeBidWall.test.ts +++ b/tests/bidWall/unit/closeBidWall.test.ts @@ -2,6 +2,7 @@ import { ComputeBudgetProgram, Keypair, PublicKey, + Transaction, TransactionMessage, VersionedTransaction, } from "@solana/web3.js"; @@ -12,11 +13,13 @@ import { BidWallClient, MAINNET_USDC, getBidWallAddr, + METADAO_MULTISIG_VAULT, } from "@metadaoproject/futarchy/v0.7"; import { BN } from "bn.js"; import { getAssociatedTokenAddressSync } from "@solana/spl-token"; import { initializeMintWithSeeds } from "../utils.js"; import { createLookupTableForTransaction } from "../../utils.js"; +import { createAssociatedTokenAccountIdempotentInstruction } from "@solana/spl-token"; export default function suite() { let futarchyClient: FutarchyClient; @@ -138,8 +141,30 @@ export default function suite() { await this.getTokenBalance(MAINNET_USDC, dao), ); - feeRecipient = Keypair.generate().publicKey; - await this.createTokenAccount(MAINNET_USDC, feeRecipient); + feeRecipient = METADAO_MULTISIG_VAULT; + + const feeRecipientQuoteTokenAccount = getAssociatedTokenAddressSync( + MAINNET_USDC, + feeRecipient, + true, + ); + + const createAtaTx = new Transaction().add( + createAssociatedTokenAccountIdempotentInstruction( + this.payer.publicKey, + feeRecipientQuoteTokenAccount, + feeRecipient, + MAINNET_USDC, + ), + ); + + createAtaTx.recentBlockhash = ( + await this.banksClient.getLatestBlockhash() + )[0]; + createAtaTx.feePayer = this.payer.publicKey; + createAtaTx.sign(this.payer); + + await this.banksClient.processTransaction(createAtaTx); // Claim tokens for the payer await launchpadClient.claimIx(launch, META).rpc(); @@ -156,7 +181,6 @@ export default function suite() { nonce: new BN(0), daoTreasury: daoTreasury, baseMint: META, - feeRecipient, quoteMint: MAINNET_USDC, payer: this.payer.publicKey, }) diff --git a/tests/bidWall/unit/initializeBidWall.test.ts b/tests/bidWall/unit/initializeBidWall.test.ts index 56b1547ec..336e58399 100644 --- a/tests/bidWall/unit/initializeBidWall.test.ts +++ b/tests/bidWall/unit/initializeBidWall.test.ts @@ -11,6 +11,7 @@ import { BidWallClient, MAINNET_USDC, getBidWallAddr, + METADAO_MULTISIG_VAULT, } from "@metadaoproject/futarchy/v0.7"; import BN from "bn.js"; import { getAssociatedTokenAddressSync } from "@solana/spl-token"; @@ -144,9 +145,6 @@ export default function suite() { it("successfully initializes a bid wall", async function () { let durationSeconds = 100; - const feeRecipient = Keypair.generate().publicKey; - await this.createTokenAccount(MAINNET_USDC, feeRecipient); - let launchAccount = await this.launchpad_v7.fetchLaunch(launch); await bidWallClient @@ -159,7 +157,6 @@ export default function suite() { nonce: new BN(0), daoTreasury: launchAccount.daoVault, baseMint: META, - feeRecipient, quoteMint: MAINNET_USDC, payer: this.payer.publicKey, }) diff --git a/tests/bidWall/unit/sellTokens.test.ts b/tests/bidWall/unit/sellTokens.test.ts index 3c568e69e..a8beabf21 100644 --- a/tests/bidWall/unit/sellTokens.test.ts +++ b/tests/bidWall/unit/sellTokens.test.ts @@ -31,7 +31,6 @@ export default function suite() { let funderUsdcAccount: PublicKey; let secondFunder: Keypair; let bidWall: PublicKey; - let feeRecipient: PublicKey; let durationSeconds: number; before(async function () { @@ -138,9 +137,6 @@ export default function suite() { await this.getTokenBalance(MAINNET_USDC, dao), ); - feeRecipient = Keypair.generate().publicKey; - await this.createTokenAccount(MAINNET_USDC, feeRecipient); - // Claim tokens for the payer await launchpadClient.claimIx(launch, META).rpc(); @@ -156,7 +152,6 @@ export default function suite() { nonce: new BN(0), daoTreasury: daoTreasury, baseMint: META, - feeRecipient, quoteMint: MAINNET_USDC, payer: this.payer.publicKey, }) From e9ce2e2cefa39d1b016e5e1c5cede3f2015f4cde Mon Sep 17 00:00:00 2001 From: Pileks Date: Tue, 20 Jan 2026 17:38:50 -0800 Subject: [PATCH 03/21] add min_amount_out to bid wall sell instruction --- programs/bid_wall/src/error.rs | 2 ++ .../bid_wall/src/instructions/sell_tokens.rs | 12 +++++++++- sdk/src/v0.7/BidWallClient.ts | 7 +++++- sdk/src/v0.7/types/bid_wall.ts | 18 +++++++++++++++ tests/bidWall/unit/sellTokens.test.ts | 23 ++++++++++++++++++- 5 files changed, 59 insertions(+), 3 deletions(-) diff --git a/programs/bid_wall/src/error.rs b/programs/bid_wall/src/error.rs index 0e19747c6..0dd043fbe 100644 --- a/programs/bid_wall/src/error.rs +++ b/programs/bid_wall/src/error.rs @@ -16,4 +16,6 @@ pub enum BidWallError { InvalidInputAmount, #[msg("Invalid crank address")] InvalidCrankAddress, + #[msg("Insufficient output amount")] + InsufficientOutputAmount, } diff --git a/programs/bid_wall/src/instructions/sell_tokens.rs b/programs/bid_wall/src/instructions/sell_tokens.rs index 1081297c1..20e22d0a6 100644 --- a/programs/bid_wall/src/instructions/sell_tokens.rs +++ b/programs/bid_wall/src/instructions/sell_tokens.rs @@ -11,6 +11,7 @@ use anchor_spl::token::{self, Burn, Mint, Token, TokenAccount, Transfer}; #[derive(AnchorSerialize, AnchorDeserialize, Clone)] pub struct SellTokensArgs { pub amount_in: u64, + pub min_amount_out: u64, } #[event_cpi] @@ -85,7 +86,10 @@ impl SellTokens<'_> { } pub fn handle(ctx: Context, args: SellTokensArgs) -> Result<()> { - let SellTokensArgs { amount_in } = args; + let SellTokensArgs { + amount_in, + min_amount_out, + } = args; // We calculate the total NAV as as sum of: // - The initial quote reserves of the Futarchy AMM @@ -147,6 +151,12 @@ impl SellTokens<'_> { amount_out_after_fee, )?; + require_gte!( + amount_out_after_fee, + min_amount_out, + BidWallError::InsufficientOutputAmount + ); + // Fees can't be used for future token buys, so we subtract the quote amount before fees. ctx.accounts.bid_wall.quote_amount -= amount_out_before_fee; // Track fees collected for fee distribution. diff --git a/sdk/src/v0.7/BidWallClient.ts b/sdk/src/v0.7/BidWallClient.ts index a64babc21..862708753 100644 --- a/sdk/src/v0.7/BidWallClient.ts +++ b/sdk/src/v0.7/BidWallClient.ts @@ -118,6 +118,7 @@ export class BidWallClient { sellTokensIx({ amount, + minAmountOut = 0, bidWall, baseMint, daoTreasury, @@ -125,6 +126,7 @@ export class BidWallClient { user = this.provider.publicKey, }: { amount: number; + minAmountOut?: number; bidWall: PublicKey; baseMint: PublicKey; daoTreasury: PublicKey; @@ -156,7 +158,10 @@ export class BidWallClient { ); return this.bidWallProgram.methods - .sellTokens({ amountIn: new BN(amount) }) + .sellTokens({ + amountIn: new BN(amount), + minAmountOut: new BN(minAmountOut), + }) .accounts({ bidWall, user, diff --git a/sdk/src/v0.7/types/bid_wall.ts b/sdk/src/v0.7/types/bid_wall.ts index cb4cdc31e..438bea796 100644 --- a/sdk/src/v0.7/types/bid_wall.ts +++ b/sdk/src/v0.7/types/bid_wall.ts @@ -502,6 +502,10 @@ export type BidWall = { name: "amountIn"; type: "u64"; }, + { + name: "minAmountOut"; + type: "u64"; + }, ]; }; }, @@ -719,6 +723,11 @@ export type BidWall = { name: "InvalidCrankAddress"; msg: "Invalid crank address"; }, + { + code: 6007; + name: "InsufficientOutputAmount"; + msg: "Insufficient output amount"; + }, ]; }; @@ -1226,6 +1235,10 @@ export const IDL: BidWall = { name: "amountIn", type: "u64", }, + { + name: "minAmountOut", + type: "u64", + }, ], }, }, @@ -1443,5 +1456,10 @@ export const IDL: BidWall = { name: "InvalidCrankAddress", msg: "Invalid crank address", }, + { + code: 6007, + name: "InsufficientOutputAmount", + msg: "Insufficient output amount", + }, ], }; diff --git a/tests/bidWall/unit/sellTokens.test.ts b/tests/bidWall/unit/sellTokens.test.ts index a8beabf21..48b49fedd 100644 --- a/tests/bidWall/unit/sellTokens.test.ts +++ b/tests/bidWall/unit/sellTokens.test.ts @@ -16,7 +16,7 @@ import { import BN from "bn.js"; import { getAssociatedTokenAddressSync } from "@solana/spl-token"; import { initializeMintWithSeeds } from "../utils.js"; -import { createLookupTableForTransaction } from "../../utils.js"; +import { createLookupTableForTransaction, expectError } from "../../utils.js"; export default function suite() { let futarchyClient: FutarchyClient; @@ -193,6 +193,7 @@ export default function suite() { await bidWallClient .sellTokensIx({ amount: 5_000_000_000000, + minAmountOut: 99_000_000000, // We should receive exactly 99K USDC bidWall, baseMint: META, daoTreasury: daoTreasury, @@ -420,6 +421,26 @@ export default function suite() { assert.equal(bidWallUsdcBalanceAfterThirdSell, 10_900_000000n); }); + it("fails to sell tokens into a bid wall when the output amount is less than the minimum output amount", async function () { + const callbacks = expectError( + "InsufficientOutputAmount", + "bid wall should fail to sell tokens when the output amount is less than the minimum output amount", + ); + + await bidWallClient + .sellTokensIx({ + amount: 5_000_000_000000, + minAmountOut: 100_000_000000, + bidWall, + baseMint: META, + daoTreasury: daoTreasury, + quoteMint: MAINNET_USDC, + user: this.payer.publicKey, + }) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + it("sending quote tokens to a bid wall beyond what was originally allocated doesn't change the NAV per token", async function () { // Send 1M USDC to the bid wall await this.transfer(MAINNET_USDC, this.payer, bidWall, 1_000_000_000000); From 6800e457fa7df22b1f978b7c1ddb85a8aa2740be Mon Sep 17 00:00:00 2001 From: Pileks Date: Tue, 20 Jan 2026 17:43:33 -0800 Subject: [PATCH 04/21] disallow zero output amount after fees --- .../bid_wall/src/instructions/sell_tokens.rs | 6 ++++++ tests/bidWall/unit/sellTokens.test.ts | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/programs/bid_wall/src/instructions/sell_tokens.rs b/programs/bid_wall/src/instructions/sell_tokens.rs index 20e22d0a6..2d3239755 100644 --- a/programs/bid_wall/src/instructions/sell_tokens.rs +++ b/programs/bid_wall/src/instructions/sell_tokens.rs @@ -157,6 +157,12 @@ impl SellTokens<'_> { BidWallError::InsufficientOutputAmount ); + require_gt!( + amount_out_after_fee, + 0, + BidWallError::InsufficientOutputAmount + ); + // Fees can't be used for future token buys, so we subtract the quote amount before fees. ctx.accounts.bid_wall.quote_amount -= amount_out_before_fee; // Track fees collected for fee distribution. diff --git a/tests/bidWall/unit/sellTokens.test.ts b/tests/bidWall/unit/sellTokens.test.ts index 48b49fedd..e25417335 100644 --- a/tests/bidWall/unit/sellTokens.test.ts +++ b/tests/bidWall/unit/sellTokens.test.ts @@ -558,4 +558,23 @@ export default function suite() { assert.include(e.message, "InvalidInputAmount"); } }); + + it("fails to sell tokens into a bid wall when the input amount would result in a zero output amount", async function () { + const callbacks = expectError( + "InsufficientOutputAmount", + "bid wall should fail to sell tokens when the input amount would result in a zero output amount", + ); + + await bidWallClient + .sellTokensIx({ + amount: 1, + bidWall, + baseMint: META, + daoTreasury: daoTreasury, + quoteMint: MAINNET_USDC, + user: this.payer.publicKey, + }) + .rpc() + .then(callbacks[0], callbacks[1]); + }); } From 8748a384f9ce92ef3fafcbb9f0aac0a64863c5e8 Mon Sep 17 00:00:00 2001 From: Pileks Date: Fri, 23 Jan 2026 15:18:25 -0800 Subject: [PATCH 05/21] prevent LP position hijack/freeze --- .../src/instructions/provide_liquidity.rs | 22 +- tests/futarchy/main.test.ts | 2 + tests/futarchy/unit/provideLiquidity.test.ts | 216 ++++++++++++++++++ 3 files changed, 233 insertions(+), 7 deletions(-) create mode 100644 tests/futarchy/unit/provideLiquidity.test.ts diff --git a/programs/futarchy/src/instructions/provide_liquidity.rs b/programs/futarchy/src/instructions/provide_liquidity.rs index 5557af291..87fbf31d3 100644 --- a/programs/futarchy/src/instructions/provide_liquidity.rs +++ b/programs/futarchy/src/instructions/provide_liquidity.rs @@ -79,7 +79,7 @@ impl ProvideLiquidity<'_> { quote_amount, max_base_amount, min_liquidity, - position_authority: _, + position_authority, } = params; let total_liquidity = dao.amm.total_liquidity; @@ -128,11 +128,19 @@ impl ProvideLiquidity<'_> { spot.base_reserves += base_amount; spot.quote_reserves += quote_amount; - amm_position.set_inner(AmmPosition { - dao: dao.key(), - position_authority: liquidity_provider.key(), - liquidity: amm_position.liquidity + liquidity_to_mint, - }); + if amm_position.position_authority == Pubkey::default() { + // New account - initialize all fields + // Use position_authority to ensure consistency with PDA derivation + amm_position.set_inner(AmmPosition { + dao: dao.key(), + position_authority, + liquidity: liquidity_to_mint, + }); + } else { + // Existing account - only update liquidity + // The position_authority is immutable once set + amm_position.liquidity += liquidity_to_mint; + } dao.amm.total_liquidity += liquidity_to_mint; @@ -168,7 +176,7 @@ impl ProvideLiquidity<'_> { common: CommonFields::new(&clock, dao.seq_num), dao: dao.key(), liquidity_provider: liquidity_provider.key(), - position_authority: params.position_authority, + position_authority, quote_amount, base_amount, liquidity_minted: liquidity_to_mint, diff --git a/tests/futarchy/main.test.ts b/tests/futarchy/main.test.ts index 1b654bcb6..aa42dad40 100644 --- a/tests/futarchy/main.test.ts +++ b/tests/futarchy/main.test.ts @@ -6,6 +6,7 @@ import finalizeProposal from "./unit/finalizeProposal.test.js"; import collectFees from "./unit/collectFees.test.js"; import conditionalSwap from "./unit/conditionalSwap.test.js"; +import provideLiquidity from "./unit/provideLiquidity.test.js"; import executeSpendingLimitChange from "./unit/executeSpendingLimitChange.test.js"; @@ -48,6 +49,7 @@ export default function suite() { describe("#collect_fees", collectFees); describe("#conditional_swap", conditionalSwap); + describe("#provide_liquidity", provideLiquidity); describe("#execute_spending_limit_change", executeSpendingLimitChange); describe("#collect_meteora_damm_fees", collectMeteoraDammFees); diff --git a/tests/futarchy/unit/provideLiquidity.test.ts b/tests/futarchy/unit/provideLiquidity.test.ts new file mode 100644 index 000000000..3464a10c2 --- /dev/null +++ b/tests/futarchy/unit/provideLiquidity.test.ts @@ -0,0 +1,216 @@ +import { Keypair, PublicKey } from "@solana/web3.js"; +import BN from "bn.js"; +import { assert } from "chai"; +import { FUTARCHY_PROGRAM_ID } from "@metadaoproject/futarchy/v0.7"; +import { + getAssociatedTokenAddressSync, + TOKEN_PROGRAM_ID, +} from "@solana/spl-token"; + +export default function suite() { + let META: PublicKey, USDC: PublicKey, dao: PublicKey; + + beforeEach(async function () { + META = await this.createMint(this.payer.publicKey, 6); + USDC = await this.createMint(this.payer.publicKey, 6); + + // Mint extra tokens for test (on top of what setupBasicDaoWithLiquidity mints) + await this.mintTo(USDC, this.payer.publicKey, this.payer, 1000 * 10 ** 6); + await this.mintTo(META, this.payer.publicKey, this.payer, 1000 * 10 ** 6); + + dao = await this.setupBasicDaoWithLiquidity({ + baseMint: META, + quoteMint: USDC, + }); + }); + + it("allows providing additional liquidity to existing position", async function () { + // Derive ammPosition PDA + const [ammPositionPda] = PublicKey.findProgramAddressSync( + [ + Buffer.from("amm_position"), + dao.toBuffer(), + this.payer.publicKey.toBuffer(), + ], + FUTARCHY_PROGRAM_ID, + ); + + // Fetch position before + const positionBefore = + await this.futarchy.autocrat.account.ammPosition.fetch(ammPositionPda); + + // Call provideLiquidityIx to add more liquidity + // maxBaseAmount needs buffer for rounding (add 1% or more) + await this.futarchy + .provideLiquidityIx({ + dao, + baseMint: META, + quoteMint: USDC, + quoteAmount: new BN(10 * 10 ** 6), + maxBaseAmount: new BN(11 * 10 ** 6), + minLiquidity: new BN(1), + positionAuthority: this.payer.publicKey, + liquidityProvider: this.payer.publicKey, + }) + .rpc(); + + // Fetch position after + const positionAfter = + await this.futarchy.autocrat.account.ammPosition.fetch(ammPositionPda); + + // Assert liquidity increased + assert.isTrue(positionAfter.liquidity.gt(positionBefore.liquidity)); + + // Assert position_authority unchanged + assert.isTrue( + positionAfter.positionAuthority.equals(positionBefore.positionAuthority), + ); + assert.isTrue(positionAfter.positionAuthority.equals(this.payer.publicKey)); + }); + + it("prevents attacker from overwriting victim's position authority", async function () { + // Create attacker keypair + const attacker = Keypair.generate(); + + // Fund attacker with META and USDC + await this.mintTo(META, attacker.publicKey, this.payer, 100 * 10 ** 6); + await this.mintTo(USDC, attacker.publicKey, this.payer, 100 * 10 ** 6); + + // Derive ammPosition PDA using victim's pubkey + const [ammPositionPda] = PublicKey.findProgramAddressSync( + [ + Buffer.from("amm_position"), + dao.toBuffer(), + this.payer.publicKey.toBuffer(), + ], + FUTARCHY_PROGRAM_ID, + ); + + // Fetch position before attack + const positionBefore = + await this.futarchy.autocrat.account.ammPosition.fetch(ammPositionPda); + + // Attacker calls provideLiquidityIx with positionAuthority=victim, liquidityProvider=attacker + await this.futarchy + .provideLiquidityIx({ + dao, + baseMint: META, + quoteMint: USDC, + quoteAmount: new BN(10 * 10 ** 6), + maxBaseAmount: new BN(11 * 10 ** 6), + minLiquidity: new BN(1), + positionAuthority: this.payer.publicKey, // victim + liquidityProvider: attacker.publicKey, // attacker + }) + .signers([attacker]) + .rpc(); + + // Fetch position after attack + const positionAfter = + await this.futarchy.autocrat.account.ammPosition.fetch(ammPositionPda); + + // Assert position_authority is still victim (not overwritten to attacker) + assert.isTrue(positionAfter.positionAuthority.equals(this.payer.publicKey)); + assert.isFalse(positionAfter.positionAuthority.equals(attacker.publicKey)); + + // Assert liquidity increased (attacker's liquidity was added) + assert.isTrue(positionAfter.liquidity.gt(positionBefore.liquidity)); + }); + + it("victim can still withdraw after attacker's hijack attempt", async function () { + // Create attacker keypair + const attacker = Keypair.generate(); + + // Fund attacker with META and USDC + await this.mintTo(META, attacker.publicKey, this.payer, 100 * 10 ** 6); + await this.mintTo(USDC, attacker.publicKey, this.payer, 100 * 10 ** 6); + + // Derive ammPosition PDA using victim's pubkey (payer is the victim) + const [ammPositionPda] = PublicKey.findProgramAddressSync( + [ + Buffer.from("amm_position"), + dao.toBuffer(), + this.payer.publicKey.toBuffer(), + ], + FUTARCHY_PROGRAM_ID, + ); + + // Attacker calls provideLiquidityIx attempting hijack + await this.futarchy + .provideLiquidityIx({ + dao, + baseMint: META, + quoteMint: USDC, + quoteAmount: new BN(10 * 10 ** 6), + maxBaseAmount: new BN(11 * 10 ** 6), + minLiquidity: new BN(1), + positionAuthority: this.payer.publicKey, // victim + liquidityProvider: attacker.publicKey, // attacker + }) + .signers([attacker]) + .rpc(); + + // Get victim's token balances before withdrawal + const victimBaseBalanceBefore = await this.getTokenBalance( + META, + this.payer.publicKey, + ); + const victimQuoteBalanceBefore = await this.getTokenBalance( + USDC, + this.payer.publicKey, + ); + + // Fetch position to get current liquidity + const position = + await this.futarchy.autocrat.account.ammPosition.fetch(ammPositionPda); + + // Derive event authority + const [eventAuthority] = PublicKey.findProgramAddressSync( + [Buffer.from("__event_authority")], + FUTARCHY_PROGRAM_ID, + ); + + // Victim withdraws all liquidity + await this.futarchy.autocrat.methods + .withdrawLiquidity({ + liquidityToWithdraw: position.liquidity, + minBaseAmount: new BN(0), + minQuoteAmount: new BN(0), + }) + .accounts({ + dao, + positionAuthority: this.payer.publicKey, + liquidityProviderBaseAccount: getAssociatedTokenAddressSync( + META, + this.payer.publicKey, + true, + ), + liquidityProviderQuoteAccount: getAssociatedTokenAddressSync( + USDC, + this.payer.publicKey, + true, + ), + ammBaseVault: getAssociatedTokenAddressSync(META, dao, true), + ammQuoteVault: getAssociatedTokenAddressSync(USDC, dao, true), + ammPosition: ammPositionPda, + tokenProgram: TOKEN_PROGRAM_ID, + eventAuthority, + program: FUTARCHY_PROGRAM_ID, + }) + .rpc(); + + // Get victim's token balances after withdrawal + const victimBaseBalanceAfter = await this.getTokenBalance( + META, + this.payer.publicKey, + ); + const victimQuoteBalanceAfter = await this.getTokenBalance( + USDC, + this.payer.publicKey, + ); + + // Assert victim received tokens back + assert.isTrue(victimBaseBalanceAfter > victimBaseBalanceBefore); + assert.isTrue(victimQuoteBalanceAfter > victimQuoteBalanceBefore); + }); +} From d35656183aaf1d7b14c4545f653c8da2e104163b Mon Sep 17 00:00:00 2001 From: Pileks Date: Fri, 23 Jan 2026 15:37:57 -0800 Subject: [PATCH 06/21] prevent supplying wrong multisig when initializing proposal --- programs/futarchy/src/instructions/initialize_proposal.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/programs/futarchy/src/instructions/initialize_proposal.rs b/programs/futarchy/src/instructions/initialize_proposal.rs index daf612870..13265e222 100644 --- a/programs/futarchy/src/instructions/initialize_proposal.rs +++ b/programs/futarchy/src/instructions/initialize_proposal.rs @@ -13,7 +13,7 @@ pub struct InitializeProposal<'info> { pub proposal: Box>, pub squads_proposal: Box>, pub squads_multisig: Box>, - #[account(mut)] + #[account(mut, has_one = squads_multisig)] pub dao: Box>, #[account( constraint = question.oracle == proposal.key() From 3e4b4e77383b29325daef07ba37758b0124c4c98 Mon Sep 17 00:00:00 2001 From: Pileks Date: Fri, 23 Jan 2026 16:03:42 -0800 Subject: [PATCH 07/21] prevent overcharging by 1 atom in provide_liquidity --- programs/futarchy/src/instructions/provide_liquidity.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/programs/futarchy/src/instructions/provide_liquidity.rs b/programs/futarchy/src/instructions/provide_liquidity.rs index 87fbf31d3..37f6421cf 100644 --- a/programs/futarchy/src/instructions/provide_liquidity.rs +++ b/programs/futarchy/src/instructions/provide_liquidity.rs @@ -95,8 +95,11 @@ impl ProvideLiquidity<'_> { let quote_reserves = spot.quote_reserves as u128; let base_reserves = spot.base_reserves as u128; - // this should only panic in an extreme scenario: when (quote_amount * base_reserve) / quote_reserve > u64::MAX - let base_amount: u64 = (((quote_amount as u128 * base_reserves) / quote_reserves) + 1) + // Use ceiling division to ensure the depositor provides at least their fair + // share of base tokens, protecting existing LPs from rounding-based value extraction. + // Formula: ceil(a / b) = (a + b - 1) / b + let numerator = quote_amount as u128 * base_reserves; + let base_amount: u64 = ((numerator + quote_reserves - 1) / quote_reserves) .try_into() .map_err(|_| FutarchyError::CastingOverflow)?; From f01a1bab1f8c27f88b03b439d9011da2e1dcd8b2 Mon Sep 17 00:00:00 2001 From: Pileks Date: Fri, 23 Jan 2026 16:08:42 -0800 Subject: [PATCH 08/21] apply min_liquidity slippage parameter to both first and subsequent lp provisioning --- programs/futarchy/src/instructions/provide_liquidity.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/programs/futarchy/src/instructions/provide_liquidity.rs b/programs/futarchy/src/instructions/provide_liquidity.rs index 37f6421cf..919a1a8a5 100644 --- a/programs/futarchy/src/instructions/provide_liquidity.rs +++ b/programs/futarchy/src/instructions/provide_liquidity.rs @@ -125,6 +125,12 @@ impl ProvideLiquidity<'_> { let initial_liquidity = quote_amount as u128 * 1_000_000_000; + require_gte!( + initial_liquidity, + min_liquidity, + // AmmError::AddLiquiditySlippageExceeded + ); + (initial_liquidity, base_amount) }; From c9db7a945f9578abded5ee1143e884e97a912225 Mon Sep 17 00:00:00 2001 From: Pileks Date: Fri, 23 Jan 2026 16:39:33 -0800 Subject: [PATCH 09/21] replace unreachable with proper errors --- programs/futarchy/src/instructions/provide_liquidity.rs | 3 +-- programs/futarchy/src/instructions/withdraw_liquidity.rs | 5 ++--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/programs/futarchy/src/instructions/provide_liquidity.rs b/programs/futarchy/src/instructions/provide_liquidity.rs index 919a1a8a5..74077a4a4 100644 --- a/programs/futarchy/src/instructions/provide_liquidity.rs +++ b/programs/futarchy/src/instructions/provide_liquidity.rs @@ -84,8 +84,7 @@ impl ProvideLiquidity<'_> { let total_liquidity = dao.amm.total_liquidity; let PoolState::Spot { ref mut spot } = dao.amm.state else { - // TODO: check that pool is already in right state - unreachable!(); + return err!(FutarchyError::PoolNotInSpotState); }; let (liquidity_to_mint, base_amount) = if total_liquidity > 0 { diff --git a/programs/futarchy/src/instructions/withdraw_liquidity.rs b/programs/futarchy/src/instructions/withdraw_liquidity.rs index 4ab95f21a..939e7bf0c 100644 --- a/programs/futarchy/src/instructions/withdraw_liquidity.rs +++ b/programs/futarchy/src/instructions/withdraw_liquidity.rs @@ -93,8 +93,7 @@ impl WithdrawLiquidity<'_> { let (base_to_withdraw, quote_to_withdraw) = { let PoolState::Spot { ref spot } = dao.amm.state else { - // TODO: check that pool is already in right state - unreachable!(); + return err!(FutarchyError::PoolNotInSpotState); }; spot.get_base_and_quote_withdrawable( liquidity_to_withdraw as u64, @@ -120,7 +119,7 @@ impl WithdrawLiquidity<'_> { dao.amm.total_liquidity -= liquidity_to_withdraw; { let PoolState::Spot { ref mut spot } = dao.amm.state else { - unreachable!(); + return err!(FutarchyError::PoolNotInSpotState); }; spot.base_reserves -= base_to_withdraw; spot.quote_reserves -= quote_to_withdraw; From f72bdba4b4364a2d1570627f8ab844eb3df9a602 Mon Sep 17 00:00:00 2001 From: Pileks Date: Mon, 26 Jan 2026 11:54:39 -0800 Subject: [PATCH 10/21] change comment to point to correct implementation reference --- programs/v07_launchpad/src/instructions/complete_launch.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/programs/v07_launchpad/src/instructions/complete_launch.rs b/programs/v07_launchpad/src/instructions/complete_launch.rs index a9b72d8db..797160b22 100644 --- a/programs/v07_launchpad/src/instructions/complete_launch.rs +++ b/programs/v07_launchpad/src/instructions/complete_launch.rs @@ -578,7 +578,7 @@ impl CompleteLaunch<'_> { LaunchpadError::InvariantViolated ); - // ref: https://github.com/MeteoraAg/damm-v2-sdk/blob/3d740ea8434af20a024d5d6fd08d60792dca9ca4/src/helpers/utils.ts#L121-L133 + // ref: https://github.com/MeteoraAg/damm-v2-sdk/blob/3d740ea8434af20a024d5d6fd08d60792dca9ca4/src/helpers/utils.ts#L135-L152 let float_price = final_raise_amount as f64 / TOKENS_TO_PARTICIPANTS as f64; let sqrt_price = (float_price.sqrt() * 2_f64.powf(64.0)) as u128; From 11408ba0fd368831572078fde43fcdae6e3e567b Mon Sep 17 00:00:00 2001 From: Pileks Date: Wed, 28 Jan 2026 13:11:54 -0800 Subject: [PATCH 11/21] prevent launch front-running by merging instructions into single transaction --- scripts/v0.7/launchTemplate.ts | 55 ++++++++++++++++++++-------------- 1 file changed, 32 insertions(+), 23 deletions(-) diff --git a/scripts/v0.7/launchTemplate.ts b/scripts/v0.7/launchTemplate.ts index 9ade1d758..e72ca5358 100644 --- a/scripts/v0.7/launchTemplate.ts +++ b/scripts/v0.7/launchTemplate.ts @@ -57,28 +57,24 @@ export const launch = async () => { const [launch] = getLaunchAddr(undefined, TOKEN); const [launchSigner] = getLaunchSignerAddr(undefined, launch); - const tx = new Transaction().add( - SystemProgram.createAccountWithSeed({ - fromPubkey: payer.publicKey, - newAccountPubkey: TOKEN, - basePubkey: payer.publicKey, - seed: TOKEN_SEED, - lamports: lamports, - space: token.MINT_SIZE, - programId: token.TOKEN_PROGRAM_ID, - }), - token.createInitializeMint2Instruction(TOKEN, 6, launchSigner, null), + const createAccountIx = SystemProgram.createAccountWithSeed({ + fromPubkey: payer.publicKey, + newAccountPubkey: TOKEN, + basePubkey: payer.publicKey, + seed: TOKEN_SEED, + lamports: lamports, + space: token.MINT_SIZE, + programId: token.TOKEN_PROGRAM_ID, + }); + + const createMintIx = token.createInitializeMint2Instruction( + TOKEN, + 6, + launchSigner, + null, ); - tx.recentBlockhash = ( - await provider.connection.getLatestBlockhash() - ).blockhash; - tx.feePayer = payer.publicKey; - tx.sign(payer); - const txHash = await provider.connection.sendRawTransaction(tx.serialize()); - await provider.connection.confirmTransaction(txHash, "confirmed"); - - const initializeLaunchTxSignature = await launchpad + const initializeLaunchIx = await launchpad .initializeLaunchIx({ tokenName: TOKEN_NAME, tokenSymbol: TOKEN_SYMBOL, @@ -100,12 +96,25 @@ export const launch = async () => { additionalTokensRecipient: ADDITIONAL_CARVEOUT_RECIPIENT, launchAuthority: LAUNCH_AUTHORITY, }) - .rpc(); + .instruction(); + + const tx = new Transaction().add( + createAccountIx, + createMintIx, + initializeLaunchIx, + ); + tx.recentBlockhash = ( + await provider.connection.getLatestBlockhash() + ).blockhash; + tx.feePayer = payer.publicKey; + tx.sign(payer); + + const txHash = await provider.connection.sendRawTransaction(tx.serialize()); + await provider.connection.confirmTransaction(txHash, "confirmed"); - console.log("Launch initialized", initializeLaunchTxSignature); + console.log("Launch initialized", txHash); console.log("Launch address:", launch.toBase58()); - // await launchpad.startLaunchIx({ launch }).rpc(); }; launch().catch(console.error); From efaca01f5b08838aeea8738f59269f9cf8213cfa Mon Sep 17 00:00:00 2001 From: Pileks Date: Wed, 28 Jan 2026 14:09:33 -0800 Subject: [PATCH 12/21] remove MAX_PREMINE from launchpad v7 --- .../v07_launchpad/src/instructions/initialize_launch.rs | 7 ------- programs/v07_launchpad/src/lib.rs | 3 --- 2 files changed, 10 deletions(-) diff --git a/programs/v07_launchpad/src/instructions/initialize_launch.rs b/programs/v07_launchpad/src/instructions/initialize_launch.rs index 06fbed155..e648ed0d3 100644 --- a/programs/v07_launchpad/src/instructions/initialize_launch.rs +++ b/programs/v07_launchpad/src/instructions/initialize_launch.rs @@ -5,7 +5,6 @@ use anchor_spl::token::{self, Mint, MintTo, Token, TokenAccount}; use crate::error::LaunchpadError; use crate::events::{CommonFields, LaunchInitializedEvent}; use crate::state::{Launch, LaunchState}; -use crate::MAX_PREMINE; use crate::{ usdc_mint, TOKENS_TO_DAMM_V2_LIQUIDITY, TOKENS_TO_FUTARCHY_LIQUIDITY, TOKENS_TO_PARTICIPANTS, }; @@ -155,12 +154,6 @@ impl InitializeLaunch<'_> { LaunchpadError::InvalidMonthlySpendingLimitMembers ); - require_gte!( - MAX_PREMINE, - args.performance_package_token_amount, - LaunchpadError::InvalidPriceBasedPremineAmount - ); - require_gte!( args.months_until_insiders_can_unlock, 18, diff --git a/programs/v07_launchpad/src/lib.rs b/programs/v07_launchpad/src/lib.rs index 8fd64ad9a..f481153b3 100644 --- a/programs/v07_launchpad/src/lib.rs +++ b/programs/v07_launchpad/src/lib.rs @@ -38,9 +38,6 @@ pub const TOKENS_TO_DAMM_V2_LIQUIDITY: u64 = TOKENS_TO_DAMM_V2_LIQUIDITY_UNSCALE /// we need this to prevent overflow pub const TOKENS_TO_DAMM_V2_LIQUIDITY_UNSCALED: u64 = 900_000; -/// Max 50% premine -pub const MAX_PREMINE: u64 = 15_000_000 * TOKEN_SCALE; - pub mod usdc_mint { use anchor_lang::prelude::declare_id; From 30307620cdb5fb3ef23a5a0a0bcf1043c2b9c460 Mon Sep 17 00:00:00 2001 From: Pileks Date: Wed, 28 Jan 2026 14:46:25 -0800 Subject: [PATCH 13/21] Disallow oracle changes while in Unlocking state to prevent TWAP corruption. --- .../src/instructions/execute_change.rs | 14 +++++++++++++- .../src/instructions/propose_change.rs | 14 ++++++++++++-- .../price_based_performance_package/src/lib.rs | 2 +- 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/programs/price_based_performance_package/src/instructions/execute_change.rs b/programs/price_based_performance_package/src/instructions/execute_change.rs index c808592a8..4b860c028 100644 --- a/programs/price_based_performance_package/src/instructions/execute_change.rs +++ b/programs/price_based_performance_package/src/instructions/execute_change.rs @@ -1,6 +1,6 @@ use crate::{ ChangeExecuted, ChangeRequest, ChangeType, CommonFields, PerformancePackage, - PriceBasedPerformancePackageError, ProposerType, + PerformancePackageState, PriceBasedPerformancePackageError, ProposerType, }; use anchor_lang::prelude::*; @@ -49,6 +49,18 @@ impl<'info> ExecuteChange<'info> { let performance_package = &mut ctx.accounts.performance_package; let change_request = &ctx.accounts.change_request; + // Disallow oracle changes while in Unlocking state to prevent TWAP corruption. + // This is checked here (in addition to propose_change) because a change request + // could have been proposed before start_unlock was called. + if matches!(change_request.change_type, ChangeType::Oracle { .. }) + && matches!( + performance_package.state, + PerformancePackageState::Unlocking { .. } + ) + { + return Err(PriceBasedPerformancePackageError::InvalidPerformancePackageState.into()); + } + // Apply the change based on type match &change_request.change_type { ChangeType::Oracle { new_oracle_config } => { diff --git a/programs/price_based_performance_package/src/instructions/propose_change.rs b/programs/price_based_performance_package/src/instructions/propose_change.rs index 920dcacd9..e873e576a 100644 --- a/programs/price_based_performance_package/src/instructions/propose_change.rs +++ b/programs/price_based_performance_package/src/instructions/propose_change.rs @@ -1,5 +1,5 @@ use crate::{ - ChangeProposed, ChangeRequest, ChangeType, PerformancePackage, + ChangeProposed, ChangeRequest, ChangeType, PerformancePackage, PerformancePackageState, PriceBasedPerformancePackageError, ProposerType, }; use anchor_lang::prelude::*; @@ -36,7 +36,7 @@ pub struct ProposeChange<'info> { } impl<'info> ProposeChange<'info> { - pub fn validate(&self) -> Result<()> { + pub fn validate(&self, params: &ProposeChangeParams) -> Result<()> { if self.proposer.key() != self.performance_package.recipient && self.proposer.key() != self.performance_package.performance_package_authority { @@ -44,6 +44,16 @@ impl<'info> ProposeChange<'info> { return Err(PriceBasedPerformancePackageError::UnauthorizedChangeRequest.into()); } + // Disallow oracle changes while in Unlocking state to prevent TWAP corruption + if matches!(params.change_type, ChangeType::Oracle { .. }) + && matches!( + self.performance_package.state, + PerformancePackageState::Unlocking { .. } + ) + { + return Err(PriceBasedPerformancePackageError::InvalidPerformancePackageState.into()); + } + Ok(()) } diff --git a/programs/price_based_performance_package/src/lib.rs b/programs/price_based_performance_package/src/lib.rs index 2ca5c838a..d161b4c1c 100644 --- a/programs/price_based_performance_package/src/lib.rs +++ b/programs/price_based_performance_package/src/lib.rs @@ -56,7 +56,7 @@ pub mod price_based_performance_package { CompleteUnlock::handle(ctx) } - #[access_control(ctx.accounts.validate())] + #[access_control(ctx.accounts.validate(¶ms))] pub fn propose_change(ctx: Context, params: ProposeChangeParams) -> Result<()> { ProposeChange::handle(ctx, params) } From 46d9613ea33f855c2e05760a92e868dc713e3366 Mon Sep 17 00:00:00 2001 From: Pileks Date: Tue, 3 Feb 2026 11:42:46 -0800 Subject: [PATCH 14/21] adjust liquidity provision logic when position is a new account --- programs/futarchy/src/instructions/provide_liquidity.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/programs/futarchy/src/instructions/provide_liquidity.rs b/programs/futarchy/src/instructions/provide_liquidity.rs index 74077a4a4..d009725ca 100644 --- a/programs/futarchy/src/instructions/provide_liquidity.rs +++ b/programs/futarchy/src/instructions/provide_liquidity.rs @@ -136,7 +136,11 @@ impl ProvideLiquidity<'_> { spot.base_reserves += base_amount; spot.quote_reserves += quote_amount; - if amm_position.position_authority == Pubkey::default() { + // Check `dao` instead of `position_authority` to detect new accounts. + // A valid DAO is always a PDA, never Pubkey::default(). Using `position_authority` + // would fail for donations where position_authority = Pubkey::default(), causing + // subsequent donations to overwrite liquidity instead of accumulating it. + if amm_position.dao == Pubkey::default() { // New account - initialize all fields // Use position_authority to ensure consistency with PDA derivation amm_position.set_inner(AmmPosition { From e8c014ef40c364eb085745b11d328d98551caf78 Mon Sep 17 00:00:00 2001 From: Pileks Date: Tue, 3 Feb 2026 15:03:45 -0800 Subject: [PATCH 15/21] When a Proposal is Rejected by the Market The Squads Proposal Should Be Closed --- .../futarchy/src/instructions/finalize_proposal.rs | 13 +++++++++++++ tests/futarchy/unit/finalizeProposal.test.ts | 14 ++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/programs/futarchy/src/instructions/finalize_proposal.rs b/programs/futarchy/src/instructions/finalize_proposal.rs index 22c5929d7..1abf2eb8a 100644 --- a/programs/futarchy/src/instructions/finalize_proposal.rs +++ b/programs/futarchy/src/instructions/finalize_proposal.rs @@ -197,6 +197,19 @@ impl FinalizeProposal<'_> { spot.base_protocol_fee_balance += pass.base_protocol_fee_balance; spot.quote_protocol_fee_balance += pass.quote_protocol_fee_balance; } else { + 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; diff --git a/tests/futarchy/unit/finalizeProposal.test.ts b/tests/futarchy/unit/finalizeProposal.test.ts index e0686dbb5..56e3c47e9 100644 --- a/tests/futarchy/unit/finalizeProposal.test.ts +++ b/tests/futarchy/unit/finalizeProposal.test.ts @@ -293,6 +293,20 @@ export default function suite() { const storedProposal = await this.futarchy.getProposal(proposal); assert.exists(storedProposal.state.failed); + + // Verify Squads proposal is rejected + const multisigPda = multisig.getMultisigPda({ createKey: dao })[0]; + const [squadsProposalPda] = multisig.getProposalPda({ + multisigPda, + transactionIndex: 1n, + }); + const squadsProposal = await multisig.accounts.Proposal.fromAccountAddress( + this.squadsConnection, + squadsProposalPda, + ); + assert.isTrue( + multisig.generated.isProposalStatusRejected(squadsProposal.status), + ); }); it("passes proposals when the team sponsors them and pass twap is slightly below fail twap", async function () { From 66e4a0226c3d42437200e5a2365337d6bc7c1999 Mon Sep 17 00:00:00 2001 From: Pileks Date: Tue, 3 Feb 2026 16:24:40 -0800 Subject: [PATCH 16/21] prevent dao parameters being upadted during active futarchy markets --- .../futarchy/src/instructions/update_dao.rs | 8 + programs/futarchy/src/lib.rs | 1 + tests/futarchy/main.test.ts | 2 + tests/futarchy/unit/updateDao.test.ts | 343 ++++++++++++++++++ 4 files changed, 354 insertions(+) create mode 100644 tests/futarchy/unit/updateDao.test.ts diff --git a/programs/futarchy/src/instructions/update_dao.rs b/programs/futarchy/src/instructions/update_dao.rs index 3d3babb39..672fa26f6 100644 --- a/programs/futarchy/src/instructions/update_dao.rs +++ b/programs/futarchy/src/instructions/update_dao.rs @@ -23,6 +23,14 @@ pub struct UpdateDao<'info> { } impl UpdateDao<'_> { + pub fn validate(&self) -> Result<()> { + // Prevent parameter updates during active futarchy markets + if !matches!(self.dao.amm.state, PoolState::Spot { .. }) { + return Err(FutarchyError::PoolNotInSpotState.into()); + } + Ok(()) + } + pub fn handle(ctx: Context, dao_params: UpdateDaoParams) -> Result<()> { let dao = &mut ctx.accounts.dao; diff --git a/programs/futarchy/src/lib.rs b/programs/futarchy/src/lib.rs index 4f68ecf3b..ea022df16 100644 --- a/programs/futarchy/src/lib.rs +++ b/programs/futarchy/src/lib.rs @@ -94,6 +94,7 @@ pub mod futarchy { FinalizeProposal::handle(ctx) } + #[access_control(ctx.accounts.validate())] pub fn update_dao(ctx: Context, dao_params: UpdateDaoParams) -> Result<()> { UpdateDao::handle(ctx, dao_params) } diff --git a/tests/futarchy/main.test.ts b/tests/futarchy/main.test.ts index aa42dad40..1cd8671c1 100644 --- a/tests/futarchy/main.test.ts +++ b/tests/futarchy/main.test.ts @@ -3,6 +3,7 @@ import futarchyAmm from "./integration/futarchyAmm.test.js"; import initializeDao from "./unit/initializeDao.test.js"; import initializeProposal from "./unit/initializeProposal.test.js"; import finalizeProposal from "./unit/finalizeProposal.test.js"; +import updateDao from "./unit/updateDao.test.js"; import collectFees from "./unit/collectFees.test.js"; import conditionalSwap from "./unit/conditionalSwap.test.js"; @@ -46,6 +47,7 @@ export default function suite() { describe("#initialize_dao", initializeDao); describe("#initialize_proposal", initializeProposal); describe("#finalize_proposal", finalizeProposal); + describe("#update_dao", updateDao); describe("#collect_fees", collectFees); describe("#conditional_swap", conditionalSwap); diff --git a/tests/futarchy/unit/updateDao.test.ts b/tests/futarchy/unit/updateDao.test.ts new file mode 100644 index 000000000..73c647991 --- /dev/null +++ b/tests/futarchy/unit/updateDao.test.ts @@ -0,0 +1,343 @@ +import { + ComputeBudgetProgram, + PublicKey, + Transaction, + TransactionMessage, +} from "@solana/web3.js"; +import { assert } from "chai"; +import * as multisig from "@sqds/multisig"; +import { MEMO_PROGRAM_ID } from "@solana/spl-memo"; +import { + PERMISSIONLESS_ACCOUNT, + getProposalAddrV2, + InstructionUtils, +} from "@metadaoproject/futarchy/v0.7"; +import { sha256 } from "@metadaoproject/futarchy"; +import BN from "bn.js"; + +export default function suite() { + let META: PublicKey, USDC: PublicKey, dao: PublicKey; + + beforeEach(async function () { + META = await this.createMint(this.payer.publicKey, 6); + USDC = await this.createMint(this.payer.publicKey, 6); + + await this.mintTo( + USDC, + this.payer.publicKey, + this.payer, + 10_000_000_000_000, + ); + await this.mintTo( + META, + this.payer.publicKey, + this.payer, + 10_000_000_000_000, + ); + + dao = await this.setupBasicDaoWithLiquidity({ + baseMint: META, + quoteMint: USDC, + }); + }); + + it("should fail updateDao execution when DAO is in Futarchy state", async function () { + const daoAccount = await this.futarchy.getDao(dao); + + // Step 1: Create updateDao squads vault transaction (index 1) + const updateDaoIx = await this.futarchy + .updateDaoIx({ + dao, + params: { + passThresholdBps: 500, + secondsPerProposal: null, + twapInitialObservation: null, + twapMaxObservationChangePerUpdate: null, + minQuoteFutarchicLiquidity: null, + minBaseFutarchicLiquidity: null, + baseToStake: null, + teamSponsoredPassThresholdBps: null, + teamAddress: null, + twapStartDelaySeconds: null, + }, + }) + .instruction(); + + const updateDaoMessage = new TransactionMessage({ + payerKey: daoAccount.squadsMultisigVault, + recentBlockhash: (await this.banksClient.getLatestBlockhash())[0], + instructions: [updateDaoIx], + }); + + const vaultTxCreateIx = multisig.instructions.vaultTransactionCreate({ + multisigPda: daoAccount.squadsMultisig, + transactionIndex: 1n, + creator: PERMISSIONLESS_ACCOUNT.publicKey, + rentPayer: this.payer.publicKey, + vaultIndex: 0, + ephemeralSigners: 0, + transactionMessage: updateDaoMessage, + }); + + const squadsProposalCreateIx = multisig.instructions.proposalCreate({ + multisigPda: daoAccount.squadsMultisig, + transactionIndex: 1n, + creator: PERMISSIONLESS_ACCOUNT.publicKey, + rentPayer: this.payer.publicKey, + }); + + const [squadsProposalPda] = multisig.getProposalPda({ + multisigPda: daoAccount.squadsMultisig, + transactionIndex: 1n, + }); + + const createSquadsTx = new Transaction().add( + vaultTxCreateIx, + squadsProposalCreateIx, + ); + createSquadsTx.recentBlockhash = ( + await this.banksClient.getLatestBlockhash() + )[0]; + createSquadsTx.feePayer = this.payer.publicKey; + createSquadsTx.sign(this.payer, PERMISSIONLESS_ACCOUNT); + + await this.banksClient.processTransaction(createSquadsTx); + + // Step 2: Create futarchy proposal A linked to updateDao squads proposal + let [proposalA] = getProposalAddrV2({ squadsProposal: squadsProposalPda }); + + await this.conditionalVault.initializeQuestion( + sha256(`Will ${proposalA} pass?/FAIL/PASS`), + proposalA, + 2, + ); + + const { question, baseVault, quoteVault } = this.futarchy.getProposalPdas( + proposalA, + META, + USDC, + dao, + ); + + await this.conditionalVault + .initializeVaultIx(question, META, 2) + .postInstructions( + await InstructionUtils.getInstructions( + this.conditionalVault.initializeVaultIx(question, USDC, 2), + ), + ) + .rpc(); + + await this.futarchy + .initializeProposalIx(squadsProposalPda, dao, META, USDC, question) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), + ]) + .rpc(); + + // Split tokens before launching proposal + await this.conditionalVault + .splitTokensIx(question, baseVault, META, new BN(1000_000_000), 2) + .rpc(); + await this.conditionalVault + .splitTokensIx(question, quoteVault, USDC, new BN(1000_000_000), 2) + .rpc(); + + // Launch proposal A to put DAO in Futarchy state + await this.futarchy + .launchProposalIx({ + proposal: proposalA, + dao, + baseMint: META, + quoteMint: USDC, + squadsProposal: squadsProposalPda, + }) + .rpc(); + + // Verify DAO is in Futarchy state + let daoState = await this.futarchy.getDao(dao); + assert.isDefined(daoState.amm.state.futarchy); + + // Step 3: Trade on pass market to make proposal A pass + // Using conditionalSwapIx which handles all the AMM interaction + await this.futarchy + .conditionalSwapIx({ + dao, + baseMint: META, + quoteMint: USDC, + proposal: proposalA, + market: "pass", + swapType: "buy", + inputAmount: new BN(900_000_000), + minOutputAmount: new BN(0), + }) + .rpc(); + + // Crank TWAP over time by doing small swaps (this updates the TWAP) + for (let i = 0; i < 100; i++) { + await this.advanceBySeconds(20_000); + + await this.futarchy + .conditionalSwapIx({ + dao, + baseMint: META, + quoteMint: USDC, + proposal: proposalA, + market: "pass", + swapType: "buy", + inputAmount: new BN(10), + minOutputAmount: new BN(0), + payer: this.payer.publicKey, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitPrice({ microLamports: i }), + ]) + .rpc(); + } + + // Step 4: Finalize proposal A - DAO returns to Spot state + await this.futarchy.finalizeProposal(proposalA); + + // Verify proposal A is passed + const proposalAAccount = await this.futarchy.getProposal(proposalA); + console.log("proposalAAccount", proposalAAccount); + assert.isDefined(proposalAAccount.state.passed); + + // Verify DAO is back in Spot state + daoState = await this.futarchy.getDao(dao); + assert.isDefined(daoState.amm.state.spot); + + // Step 5: Launch proposal B to put DAO back into Futarchy state + // We need to manually create this with transaction index 2 since index 1 is used by updateDao + const memoInstruction = { + programId: MEMO_PROGRAM_ID, + keys: [], + data: Buffer.from("test proposal B"), + }; + + const memoMessage = new TransactionMessage({ + payerKey: daoAccount.squadsMultisigVault, + recentBlockhash: (await this.banksClient.getLatestBlockhash())[0], + instructions: [memoInstruction], + }); + + const vaultTxCreateIx2 = multisig.instructions.vaultTransactionCreate({ + multisigPda: daoAccount.squadsMultisig, + transactionIndex: 2n, + creator: PERMISSIONLESS_ACCOUNT.publicKey, + rentPayer: this.payer.publicKey, + vaultIndex: 0, + ephemeralSigners: 0, + transactionMessage: memoMessage, + }); + + const squadsProposalCreateIx2 = multisig.instructions.proposalCreate({ + multisigPda: daoAccount.squadsMultisig, + transactionIndex: 2n, + creator: PERMISSIONLESS_ACCOUNT.publicKey, + rentPayer: this.payer.publicKey, + }); + + const [squadsProposalPda2] = multisig.getProposalPda({ + multisigPda: daoAccount.squadsMultisig, + transactionIndex: 2n, + }); + + const createSquadsTx2 = new Transaction().add( + vaultTxCreateIx2, + squadsProposalCreateIx2, + ); + createSquadsTx2.recentBlockhash = ( + await this.banksClient.getLatestBlockhash() + )[0]; + createSquadsTx2.feePayer = this.payer.publicKey; + createSquadsTx2.sign(this.payer, PERMISSIONLESS_ACCOUNT); + + await this.banksClient.processTransaction(createSquadsTx2); + + // Create futarchy proposal B linked to squads proposal 2 + let [proposalB] = getProposalAddrV2({ squadsProposal: squadsProposalPda2 }); + + await this.conditionalVault.initializeQuestion( + sha256(`Will ${proposalB} pass?/FAIL/PASS`), + proposalB, + 2, + ); + + const proposalBPdas = this.futarchy.getProposalPdas( + proposalB, + META, + USDC, + dao, + ); + + await this.conditionalVault + .initializeVaultIx(proposalBPdas.question, META, 2) + .postInstructions( + await InstructionUtils.getInstructions( + this.conditionalVault.initializeVaultIx( + proposalBPdas.question, + USDC, + 2, + ), + ), + ) + .rpc(); + + await this.futarchy + .initializeProposalIx( + squadsProposalPda2, + dao, + META, + USDC, + proposalBPdas.question, + ) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), + ]) + .rpc(); + + // Launch proposal B to put DAO in Futarchy state + await this.futarchy + .launchProposalIx({ + proposal: proposalB, + dao, + baseMint: META, + quoteMint: USDC, + squadsProposal: squadsProposalPda2, + }) + .rpc(); + + // Verify DAO is in Futarchy state again + daoState = await this.futarchy.getDao(dao); + assert.isDefined(daoState.amm.state.futarchy); + + // Step 6: Try to execute updateDao squads transaction + // This should fail because DAO is in Futarchy state + const txExecuteIx = await multisig.instructions.vaultTransactionExecute({ + connection: this.squadsConnection, + multisigPda: daoAccount.squadsMultisig, + transactionIndex: 1n, + member: PERMISSIONLESS_ACCOUNT.publicKey, + }); + + const txExecute = new Transaction().add(txExecuteIx.instruction); + txExecute.recentBlockhash = ( + await this.banksClient.getLatestBlockhash() + )[0]; + txExecute.feePayer = this.payer.publicKey; + txExecute.sign(this.payer, PERMISSIONLESS_ACCOUNT); + + try { + await this.banksClient.processTransaction(txExecute); + assert.fail("Should have failed with PoolNotInSpotState"); + } catch (e) { + // The error comes from the CPI call failing, check for PoolNotInSpotState (0x178a = 6026) + assert( + e.toString().includes("PoolNotInSpotState") || + e.toString().includes("0x178a"), + `Expected PoolNotInSpotState error, got: ${e}`, + ); + } + }); +} From 3865e80491c3a2005e3515b4d3bd58551e6b91a3 Mon Sep 17 00:00:00 2001 From: Pileks Date: Tue, 3 Feb 2026 16:39:43 -0800 Subject: [PATCH 17/21] split_tokens and merge_tokens should verify question is unresolved --- .../src/instructions/merge_tokens.rs | 8 +++++ .../src/instructions/split_tokens.rs | 8 +++++ programs/conditional_vault/src/lib.rs | 2 ++ .../conditionalVault/unit/mergeTokens.test.ts | 30 +++++++++++++++- .../conditionalVault/unit/splitTokens.test.ts | 34 ++++++++++++++++++- 5 files changed, 80 insertions(+), 2 deletions(-) diff --git a/programs/conditional_vault/src/instructions/merge_tokens.rs b/programs/conditional_vault/src/instructions/merge_tokens.rs index 6a9ff848d..c40fb1c50 100644 --- a/programs/conditional_vault/src/instructions/merge_tokens.rs +++ b/programs/conditional_vault/src/instructions/merge_tokens.rs @@ -1,6 +1,14 @@ use super::*; impl<'info, 'c: 'info> InteractWithVault<'info> { + pub fn validate_merge_tokens(&self) -> Result<()> { + require!( + !self.question.is_resolved(), + VaultError::QuestionAlreadyResolved + ); + Ok(()) + } + pub fn handle_merge_tokens(ctx: Context<'_, '_, 'c, 'info, Self>, amount: u64) -> Result<()> { let accs = &ctx.accounts; diff --git a/programs/conditional_vault/src/instructions/split_tokens.rs b/programs/conditional_vault/src/instructions/split_tokens.rs index 4213d88fa..592838102 100644 --- a/programs/conditional_vault/src/instructions/split_tokens.rs +++ b/programs/conditional_vault/src/instructions/split_tokens.rs @@ -1,6 +1,14 @@ use super::*; impl<'info, 'c: 'info> InteractWithVault<'info> { + pub fn validate_split_tokens(&self) -> Result<()> { + require!( + !self.question.is_resolved(), + VaultError::QuestionAlreadyResolved + ); + Ok(()) + } + pub fn handle_split_tokens(ctx: Context<'_, '_, 'c, 'info, Self>, amount: u64) -> Result<()> { let accs = &ctx.accounts; diff --git a/programs/conditional_vault/src/lib.rs b/programs/conditional_vault/src/lib.rs index 4568f3e70..9dfa1400f 100644 --- a/programs/conditional_vault/src/lib.rs +++ b/programs/conditional_vault/src/lib.rs @@ -59,6 +59,7 @@ pub mod conditional_vault { InitializeConditionalVault::handle(ctx) } + #[access_control(ctx.accounts.validate_split_tokens())] pub fn split_tokens<'c: 'info, 'info>( ctx: Context<'_, '_, 'c, 'info, InteractWithVault<'info>>, amount: u64, @@ -66,6 +67,7 @@ pub mod conditional_vault { InteractWithVault::handle_split_tokens(ctx, amount) } + #[access_control(ctx.accounts.validate_merge_tokens())] pub fn merge_tokens<'c: 'info, 'info>( ctx: Context<'_, '_, 'c, 'info, InteractWithVault<'info>>, amount: u64, diff --git a/tests/conditionalVault/unit/mergeTokens.test.ts b/tests/conditionalVault/unit/mergeTokens.test.ts index 8ef54640c..9e609b763 100644 --- a/tests/conditionalVault/unit/mergeTokens.test.ts +++ b/tests/conditionalVault/unit/mergeTokens.test.ts @@ -18,13 +18,15 @@ export default function suite() { let vault: PublicKey; let underlyingTokenMint: PublicKey; let userUnderlyingTokenAccount: PublicKey; + let oracle: Keypair; + before(function () { vaultClient = this.conditionalVault; }); beforeEach(async function () { const questionId = sha256(new Uint8Array([9, 2, 1])); - const oracle = Keypair.generate(); + oracle = Keypair.generate(); question = await vaultClient.initializeQuestion( questionId, @@ -151,4 +153,30 @@ export default function suite() { updatedVault = await vaultClient.fetchVault(vault); assert.equal(updatedVault.seqNum.toString(), "3"); }); + + it("throws error when trying to merge tokens after question is resolved", async function () { + // Resolve the question + await vaultClient.vaultProgram.methods + .resolveQuestion({ payoutNumerators: [1, 0] }) + .accounts({ + question, + oracle: oracle.publicKey, + }) + .signers([oracle]) + .rpc(); + + // Attempt to merge tokens after resolution should fail + const callbacks = expectError( + "QuestionAlreadyResolved", + "merge succeeded despite question being resolved", + ); + + await vaultClient + .mergeTokensIx(question, vault, underlyingTokenMint, new BN(500), 2) + .postInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 200_000 }), + ]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); } diff --git a/tests/conditionalVault/unit/splitTokens.test.ts b/tests/conditionalVault/unit/splitTokens.test.ts index f295fc8dd..65ab5e9f3 100644 --- a/tests/conditionalVault/unit/splitTokens.test.ts +++ b/tests/conditionalVault/unit/splitTokens.test.ts @@ -12,6 +12,7 @@ export default function suite() { let question: PublicKey; let vault: PublicKey; let underlyingTokenMint: PublicKey; + let oracle: Keypair; before(function () { vaultClient = this.conditionalVault; @@ -19,7 +20,7 @@ export default function suite() { beforeEach(async function () { const questionId = sha256(new Uint8Array([5, 2, 1])); - const oracle = Keypair.generate(); + oracle = Keypair.generate(); question = await vaultClient.initializeQuestion( questionId, @@ -238,4 +239,35 @@ export default function suite() { await this.assertBalance(mint, this.payer.publicKey, 2000); } }); + + it("throws error when trying to split tokens after question is resolved", async function () { + // First, split some tokens while the question is unresolved + await vaultClient + .splitTokensIx(question, vault, underlyingTokenMint, new BN(1000), 2) + .rpc(); + + // Resolve the question + await vaultClient.vaultProgram.methods + .resolveQuestion({ payoutNumerators: [1, 0] }) + .accounts({ + question, + oracle: oracle.publicKey, + }) + .signers([oracle]) + .rpc(); + + // Attempt to split tokens after resolution should fail + const callbacks = expectError( + "QuestionAlreadyResolved", + "split succeeded despite question being resolved", + ); + + await vaultClient + .splitTokensIx(question, vault, underlyingTokenMint, new BN(1000), 2) + .postInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 200_000 }), + ]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); } From 9d35ae8418bdd4803aa8367e33cfa98d1a3c390a Mon Sep 17 00:00:00 2001 From: Pileks Date: Thu, 5 Feb 2026 12:51:31 -0800 Subject: [PATCH 18/21] bid wall quote amount debits rounding --- programs/bid_wall/src/instructions/sell_tokens.rs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/programs/bid_wall/src/instructions/sell_tokens.rs b/programs/bid_wall/src/instructions/sell_tokens.rs index 2d3239755..f5d4662e9 100644 --- a/programs/bid_wall/src/instructions/sell_tokens.rs +++ b/programs/bid_wall/src/instructions/sell_tokens.rs @@ -107,16 +107,22 @@ impl SellTokens<'_> { let amount_out_before_fee = (amount_in as u128 * total_nav as u128 / remaining_base as u128) as u64; + // Ceiling division: ensures rounding dust is debited from quote_amount + // rather than accumulating and inflating total_nav on subsequent sells. + let quote_amount_debit = ((amount_in as u128 * total_nav as u128 + remaining_base as u128 + - 1) + / remaining_base as u128) as u64; + require_gte!( ctx.accounts.bid_wall.quote_amount, - amount_out_before_fee, + quote_amount_debit, BidWallError::InsufficientQuoteReserves ); let amount_out_after_fee = ((10_000_u128 - FEE_BPS as u128) * amount_out_before_fee as u128 / 10_000_u128) as u64; - let fee = amount_out_before_fee - amount_out_after_fee; + let fee = quote_amount_debit - amount_out_after_fee; // Burn base tokens token::burn( @@ -163,8 +169,9 @@ impl SellTokens<'_> { BidWallError::InsufficientOutputAmount ); - // Fees can't be used for future token buys, so we subtract the quote amount before fees. - ctx.accounts.bid_wall.quote_amount -= amount_out_before_fee; + // Fees can't be used for future token buys, so we subtract + // the quote amount debit (total amount of quote debited from the bid wall). + ctx.accounts.bid_wall.quote_amount -= quote_amount_debit; // Track fees collected for fee distribution. ctx.accounts.bid_wall.fees_collected += fee; // Track base tokens bought up by the bid wall for NAV calculation. From 5ae14194d697647d08e484f46f23dc0484963c1f Mon Sep 17 00:00:00 2001 From: Pileks Date: Thu, 5 Feb 2026 13:39:45 -0800 Subject: [PATCH 19/21] minor rename --- jup-sdk/src/futarchy_amm.rs | 10 +++++----- programs/futarchy/src/state/futarchy_amm.rs | 6 +++--- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/jup-sdk/src/futarchy_amm.rs b/jup-sdk/src/futarchy_amm.rs index cd6274cd8..5361ea5fd 100644 --- a/jup-sdk/src/futarchy_amm.rs +++ b/jup-sdk/src/futarchy_amm.rs @@ -1,7 +1,7 @@ use anchor_lang::prelude::{ - AccountMeta, AnchorDeserialize, AnchorSerialize, InitSpace, Pubkey, borsh, + borsh, AccountMeta, AnchorDeserialize, AnchorSerialize, InitSpace, Pubkey, }; -use anyhow::{Result, anyhow, bail}; +use anyhow::{anyhow, bail, Result}; use crate::FutarchyAmmError; @@ -206,18 +206,18 @@ impl Pool { bail!(FutarchyAmmError::InvalidReserves); } - let input_amount_with_lp_fee = (input_amount_after_protocol_fee as u128) + let input_amount_after_lp_fee = (input_amount_after_protocol_fee as u128) .checked_mul((MAX_BPS - LP_TAKER_FEE_BPS) as u128) .ok_or_else(|| anyhow!(FutarchyAmmError::MathOverflow))?; - let numerator = input_amount_with_lp_fee + let numerator = input_amount_after_lp_fee .checked_mul(output_reserve as u128) .ok_or_else(|| anyhow!(FutarchyAmmError::MathOverflow))?; let denominator = (input_reserve as u128) .checked_mul(MAX_BPS as u128) .ok_or_else(|| anyhow!(FutarchyAmmError::MathOverflow))? - .checked_add(input_amount_with_lp_fee as u128) + .checked_add(input_amount_after_lp_fee as u128) .ok_or_else(|| anyhow!(FutarchyAmmError::MathOverflow))?; let output_amount = (numerator diff --git a/programs/futarchy/src/state/futarchy_amm.rs b/programs/futarchy/src/state/futarchy_amm.rs index 2fcb0cb3e..d61e53911 100644 --- a/programs/futarchy/src/state/futarchy_amm.rs +++ b/programs/futarchy/src/state/futarchy_amm.rs @@ -509,13 +509,13 @@ impl Pool { require_neq!(input_reserve, 0); require_neq!(output_reserve, 0); - let input_amount_with_lp_fee = + let input_amount_after_lp_fee = input_amount_after_protocol_fee as u128 * (MAX_BPS - LP_TAKER_FEE_BPS) as u128; - let numerator = input_amount_with_lp_fee * output_reserve as u128; + let numerator = input_amount_after_lp_fee * output_reserve as u128; let denominator = - (input_reserve as u128 * MAX_BPS as u128) + input_amount_with_lp_fee as u128; + (input_reserve as u128 * MAX_BPS as u128) + input_amount_after_lp_fee as u128; let output_amount = (numerator / denominator) as u64; From a1beb2fc87946a799716cf63d6026f8f32164069 Mon Sep 17 00:00:00 2001 From: Pileks Date: Thu, 5 Feb 2026 14:05:16 -0800 Subject: [PATCH 20/21] slight rename for internal consistency --- .../admin_approve_execute_multisig_proposal.rs | 2 +- .../instructions/collect_meteora_damm_fees.rs | 2 +- .../src/instructions/complete_launch.rs | 18 +++++++++--------- .../initialize_performance_package.rs | 2 +- sdk/src/v0.7/LaunchpadClient.ts | 4 ++-- sdk/src/v0.7/types/launchpad_v7.ts | 4 ++-- 6 files changed, 16 insertions(+), 16 deletions(-) diff --git a/programs/futarchy/src/instructions/admin_approve_execute_multisig_proposal.rs b/programs/futarchy/src/instructions/admin_approve_execute_multisig_proposal.rs index 673af6bd3..924dad343 100644 --- a/programs/futarchy/src/instructions/admin_approve_execute_multisig_proposal.rs +++ b/programs/futarchy/src/instructions/admin_approve_execute_multisig_proposal.rs @@ -15,7 +15,7 @@ pub struct AdminApproveExecuteMultisigProposal<'info> { #[account(mut)] pub admin: Signer<'info>, - /// CHECK: checked by autocrat program + /// 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)] pub squads_multisig: Account<'info, squads_multisig_program::Multisig>, /// CHECK: squads proposal, initialized by squads multisig program, checked by squads multisig program diff --git a/programs/futarchy/src/instructions/collect_meteora_damm_fees.rs b/programs/futarchy/src/instructions/collect_meteora_damm_fees.rs index d068f5e2d..93ce666c2 100644 --- a/programs/futarchy/src/instructions/collect_meteora_damm_fees.rs +++ b/programs/futarchy/src/instructions/collect_meteora_damm_fees.rs @@ -38,7 +38,7 @@ pub struct CollectMeteoraDammFees<'info> { #[account(mut)] pub admin: Signer<'info>, - /// CHECK: checked by autocrat program + /// 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_program)] pub squads_multisig: Account<'info, squads_multisig_program::Multisig>, /// CHECK: signer for the squads transaction, checked by squads program diff --git a/programs/v07_launchpad/src/instructions/complete_launch.rs b/programs/v07_launchpad/src/instructions/complete_launch.rs index 4880e0b50..cf3c98af6 100644 --- a/programs/v07_launchpad/src/instructions/complete_launch.rs +++ b/programs/v07_launchpad/src/instructions/complete_launch.rs @@ -35,8 +35,8 @@ use damm_v2_cpi::program::DammV2Cpi; pub struct StaticCompleteLaunchAccounts<'info> { pub futarchy_program: Program<'info, Futarchy>, pub token_metadata_program: Program<'info, Metadata>, - /// CHECK: checked by autocrat program - pub autocrat_event_authority: UncheckedAccount<'info>, + /// CHECK: checked by futarchy program + pub futarchy_event_authority: UncheckedAccount<'info>, pub squads_program: Program<'info, squads_multisig_program::program::SquadsMultisigProgram>, /// CHECK: checked by squads multisig program #[account(seeds = [squads_multisig_program::SEED_PREFIX, squads_multisig_program::SEED_PROGRAM_CONFIG], bump, seeds::program = squads_program)] @@ -178,23 +178,23 @@ pub struct CompleteLaunch<'info> { #[account(address = meteora_accounts.quote_mint.key())] pub quote_mint: Box>, - /// CHECK: init by autocrat + /// CHECK: init by futarchy program #[account(mut, seeds = [b"amm_position", dao.key().as_ref(), squads_multisig_vault.key().as_ref()], bump, seeds::program = static_accounts.futarchy_program)] pub dao_owned_lp_position: UncheckedAccount<'info>, - /// CHECK: checked by autocrat + /// CHECK: checked by futarchy program #[account(mut)] pub futarchy_amm_base_vault: UncheckedAccount<'info>, - /// CHECK: checked by autocrat + /// CHECK: checked by futarchy program #[account(mut)] pub futarchy_amm_quote_vault: UncheckedAccount<'info>, - /// CHECK: this is the DAO account, init by autocrat + /// CHECK: this is the DAO account, init by futarchy program #[account(mut)] pub dao: UncheckedAccount<'info>, - /// CHECK: checked by autocrat program + /// 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 = static_accounts.squads_program)] pub squads_multisig: UncheckedAccount<'info>, /// CHECK: just a signer @@ -397,7 +397,7 @@ impl CompleteLaunch<'_> { quote_mint: self.quote_mint.to_account_info(), event_authority: self .static_accounts - .autocrat_event_authority + .futarchy_event_authority .to_account_info(), program: self.static_accounts.futarchy_program.to_account_info(), squads_multisig: self.squads_multisig.to_account_info(), @@ -506,7 +506,7 @@ impl CompleteLaunch<'_> { program: self.static_accounts.futarchy_program.to_account_info(), event_authority: self .static_accounts - .autocrat_event_authority + .futarchy_event_authority .to_account_info(), }, launch_signer, diff --git a/programs/v07_launchpad/src/instructions/initialize_performance_package.rs b/programs/v07_launchpad/src/instructions/initialize_performance_package.rs index 1ed0d9809..5fe29aa9a 100644 --- a/programs/v07_launchpad/src/instructions/initialize_performance_package.rs +++ b/programs/v07_launchpad/src/instructions/initialize_performance_package.rs @@ -39,7 +39,7 @@ pub struct InitializePerformancePackage<'info> { #[account(mut, address = launch.base_mint.key())] pub base_mint: Box>, - /// CHECK: this is the DAO account, init by autocrat + /// CHECK: this is the DAO account, init by futarchy program #[account(address = launch.dao.as_ref().unwrap().key())] pub dao: UncheckedAccount<'info>, diff --git a/sdk/src/v0.7/LaunchpadClient.ts b/sdk/src/v0.7/LaunchpadClient.ts index 2a752b44e..c0c6cef6b 100644 --- a/sdk/src/v0.7/LaunchpadClient.ts +++ b/sdk/src/v0.7/LaunchpadClient.ts @@ -322,7 +322,7 @@ export class LaunchpadClient { daoCreator: launchSigner, }); - const [autocratEventAuthority] = getEventAuthorityAddr( + const [futarchyEventAuthority] = getEventAuthorityAddr( this.autocratClient.getProgramId(), ); @@ -464,7 +464,7 @@ export class LaunchpadClient { staticAccounts: { futarchyProgram: this.autocratClient.getProgramId(), tokenMetadataProgram: MPL_TOKEN_METADATA_PROGRAM_ID, - autocratEventAuthority, + futarchyEventAuthority, squadsProgram: SQUADS_PROGRAM_ID, squadsProgramConfig: SQUADS_PROGRAM_CONFIG, squadsProgramConfigTreasury: isDevnet diff --git a/sdk/src/v0.7/types/launchpad_v7.ts b/sdk/src/v0.7/types/launchpad_v7.ts index ca9d53543..98cf391a3 100644 --- a/sdk/src/v0.7/types/launchpad_v7.ts +++ b/sdk/src/v0.7/types/launchpad_v7.ts @@ -362,7 +362,7 @@ export type LaunchpadV7 = { isSigner: false; }, { - name: "autocratEventAuthority"; + name: "futarchyEventAuthority"; isMut: false; isSigner: false; }, @@ -2063,7 +2063,7 @@ export const IDL: LaunchpadV7 = { isSigner: false, }, { - name: "autocratEventAuthority", + name: "futarchyEventAuthority", isMut: false, isSigner: false, }, From 6e8c5aa32e5a3ef39384ea72fc84e0014ae3f92d Mon Sep 17 00:00:00 2001 From: Pileks Date: Fri, 6 Feb 2026 11:35:11 -0800 Subject: [PATCH 21/21] disallow empty spending limit members and duplicate spending limit members --- .../src/instructions/initialize_launch.rs | 13 ++++ .../unit/initializeLaunch.test.ts | 67 +++++++++++++++++++ 2 files changed, 80 insertions(+) diff --git a/programs/v07_launchpad/src/instructions/initialize_launch.rs b/programs/v07_launchpad/src/instructions/initialize_launch.rs index e648ed0d3..262c2aec6 100644 --- a/programs/v07_launchpad/src/instructions/initialize_launch.rs +++ b/programs/v07_launchpad/src/instructions/initialize_launch.rs @@ -154,6 +154,19 @@ impl InitializeLaunch<'_> { LaunchpadError::InvalidMonthlySpendingLimitMembers ); + require!( + !args.monthly_spending_limit_members.is_empty(), + LaunchpadError::InvalidMonthlySpendingLimitMembers + ); + + let mut sorted_members = args.monthly_spending_limit_members.clone(); + sorted_members.sort(); + let has_duplicates = sorted_members.windows(2).any(|win| win[0] == win[1]); + require!( + !has_duplicates, + LaunchpadError::InvalidMonthlySpendingLimitMembers + ); + require_gte!( args.months_until_insiders_can_unlock, 18, diff --git a/tests/launchpad_v7/unit/initializeLaunch.test.ts b/tests/launchpad_v7/unit/initializeLaunch.test.ts index d025dc575..6a4615fa2 100644 --- a/tests/launchpad_v7/unit/initializeLaunch.test.ts +++ b/tests/launchpad_v7/unit/initializeLaunch.test.ts @@ -108,6 +108,73 @@ export default function suite() { assert.isNull(storedLaunch.dao); }); + it("fails when monthly spending limit members contains duplicates", async function () { + const minRaise = new BN(1000_000000); + const secondsForLaunch = 60 * 60 * 24 * 7; + const monthlySpend = new BN(100_000000); + const recipientAddress = Keypair.generate().publicKey; + const premineAmount = new BN(500_000_000); + + try { + await launchpadClient + .initializeLaunchIx({ + tokenName: "META", + tokenSymbol: "META", + tokenUri: "https://example.com", + minimumRaiseAmount: minRaise, + secondsForLaunch: secondsForLaunch, + baseMint: META, + quoteMint: MAINNET_USDC, + monthlySpendingLimitAmount: monthlySpend, + monthlySpendingLimitMembers: [ + this.payer.publicKey, + this.payer.publicKey, + ], + performancePackageGrantee: recipientAddress, + performancePackageTokenAmount: premineAmount, + monthsUntilInsidersCanUnlock: 18, + teamAddress: PublicKey.default, + launchAuthority: launchAuthority.publicKey, + }) + .rpc(); + assert.fail("Should have thrown error"); + } catch (e) { + assert.include(e.message, "InvalidMonthlySpendingLimitMembers"); + } + }); + + it("fails when monthly spending limit members is empty", async function () { + const minRaise = new BN(1000_000000); + const secondsForLaunch = 60 * 60 * 24 * 7; + const monthlySpend = new BN(100_000000); + const recipientAddress = Keypair.generate().publicKey; + const premineAmount = new BN(500_000_000); + + try { + await launchpadClient + .initializeLaunchIx({ + tokenName: "META", + tokenSymbol: "META", + tokenUri: "https://example.com", + minimumRaiseAmount: minRaise, + secondsForLaunch: secondsForLaunch, + baseMint: META, + quoteMint: MAINNET_USDC, + monthlySpendingLimitAmount: monthlySpend, + monthlySpendingLimitMembers: [], + performancePackageGrantee: recipientAddress, + performancePackageTokenAmount: premineAmount, + monthsUntilInsidersCanUnlock: 18, + teamAddress: PublicKey.default, + launchAuthority: launchAuthority.publicKey, + }) + .rpc(); + assert.fail("Should have thrown error"); + } catch (e) { + assert.include(e.message, "InvalidMonthlySpendingLimitMembers"); + } + }); + it("fails when launch signer is faked", async function () { const minRaise = new BN(1000_000000); // 1000 USDC const secondsForLaunch = 60 * 60 * 24 * 7; // 1 week