Skip to content

Commit 29bd3a1

Browse files
Edwardmikemaccana-edwardbot
authored andcommitted
fix(clob): enforce has_one on market vaults/mints in SettleFunds and PlaceOrder
Before this fix, SettleFunds and PlaceOrder only constrained the market's fee_vault via has_one; they did not bind base_vault, quote_vault, base_mint, or quote_mint to the addresses recorded on the Market PDA at initialize_market time. Because the fee_vault is a token account on the same mint and with the same authority (market PDA) as the quote_vault, a caller with a positive unsettled_quote balance could call SettleFunds and pass market.fee_vault where quote_vault was expected. transfer_checked only verifies mint and authority on the source account, not its identity, so the CPI would succeed and drain accumulated taker fees to the attacker's own quote ATA. The same gap existed on PlaceOrder for base_vault / quote_vault / base_mint / quote_mint. Fix: add has_one constraints on the market field in both SettleFunds and PlaceOrder so Anchor rejects any mismatched address with ConstraintHasOne (anchor error 2001) before any transfer runs. New error variants (InvalidBaseVault, InvalidQuoteVault, InvalidBaseMint, InvalidQuoteMint) mirror the existing InvalidFeeVault style so the failure is self-describing. Adds a LiteSVM regression test (settle_funds_rejects_fee_vault_substituted_for_quote_vault) that earns a buyer some unsettled_quote, then tries the attack and asserts the transaction fails.
1 parent d686c29 commit 29bd3a1

4 files changed

Lines changed: 113 additions & 1 deletion

File tree

defi/clob/anchor/programs/clob/src/errors.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,18 @@ pub enum ErrorCode {
4141
#[msg("Fee vault does not match the market's fee vault")]
4242
InvalidFeeVault,
4343

44+
#[msg("Base vault does not match the market's base vault")]
45+
InvalidBaseVault,
46+
47+
#[msg("Quote vault does not match the market's quote vault")]
48+
InvalidQuoteVault,
49+
50+
#[msg("Base mint does not match the market's base mint")]
51+
InvalidBaseMint,
52+
53+
#[msg("Quote mint does not match the market's quote mint")]
54+
InvalidQuoteMint,
55+
4456
#[msg("Maker account provided does not correspond to a resting order on the book")]
4557
MakerAccountMismatch,
4658

defi/clob/anchor/programs/clob/src/instructions/place_order.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -422,9 +422,19 @@ pub fn handle_place_order<'info>(
422422
#[derive(Accounts)]
423423
#[instruction(side: OrderSide, price: u64, quantity: u64)]
424424
pub struct PlaceOrder<'info> {
425+
// `has_one` ties every market-owned account on this struct to the
426+
// addresses recorded on the Market PDA. Crucially, without
427+
// has_one on base_vault / quote_vault / base_mint / quote_mint a caller
428+
// could swap fee_vault in for quote_vault (same mint, same authority)
429+
// and steer the per-fill fee transfer to drain real fees instead of
430+
// routing them in.
425431
#[account(
426432
mut,
427433
has_one = fee_vault @ ErrorCode::InvalidFeeVault,
434+
has_one = base_vault @ ErrorCode::InvalidBaseVault,
435+
has_one = quote_vault @ ErrorCode::InvalidQuoteVault,
436+
has_one = base_mint @ ErrorCode::InvalidBaseMint,
437+
has_one = quote_mint @ ErrorCode::InvalidQuoteMint,
428438
)]
429439
pub market: Account<'info, Market>,
430440

defi/clob/anchor/programs/clob/src/instructions/settle_funds.rs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ use anchor_spl::token_interface::{
33
transfer_checked, Mint, TokenAccount, TokenInterface, TransferChecked,
44
};
55

6+
use crate::errors::ErrorCode;
67
use crate::state::{Market, UserAccount, MARKET_SEED, USER_ACCOUNT_SEED};
78

89
pub fn handle_settle_funds(context: Context<SettleFunds>) -> Result<()> {
@@ -64,7 +65,19 @@ pub fn handle_settle_funds(context: Context<SettleFunds>) -> Result<()> {
6465

6566
#[derive(Accounts)]
6667
pub struct SettleFunds<'info> {
67-
#[account(mut)]
68+
// `has_one` constraints bind these vaults/mints to the addresses stored
69+
// on the Market PDA at initialise_market time. Without them a caller
70+
// could substitute the fee_vault (same mint + same authority as
71+
// quote_vault) for `quote_vault` and drain accumulated taker fees,
72+
// since transfer_checked only verifies mint + authority on the source
73+
// account, not its identity.
74+
#[account(
75+
mut,
76+
has_one = base_vault @ ErrorCode::InvalidBaseVault,
77+
has_one = quote_vault @ ErrorCode::InvalidQuoteVault,
78+
has_one = base_mint @ ErrorCode::InvalidBaseMint,
79+
has_one = quote_mint @ ErrorCode::InvalidQuoteMint,
80+
)]
6881
pub market: Account<'info, Market>,
6982

7083
#[account(

defi/clob/anchor/programs/clob/tests/test_clob.rs

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -916,6 +916,83 @@ fn cancel_and_settle_bid_refunds_full_quote() {
916916
);
917917
}
918918

919+
// Regression test for the fee-drain attack on settle_funds. Pre-fix,
920+
// `SettleFunds` did not bind `quote_vault` to `market.quote_vault` via
921+
// `has_one`, so a caller could pass `market.fee_vault` (same mint and
922+
// same authority) where `quote_vault` was expected and drain accumulated
923+
// taker fees while spending their own unsettled_quote credit. The
924+
// has_one constraint now bound on the `market` field must surface this
925+
// as `ConstraintHasOne` (anchor error 2001) before any transfer runs.
926+
#[test]
927+
fn settle_funds_rejects_fee_vault_substituted_for_quote_vault() {
928+
let mut sc = full_setup();
929+
initialize_market_and_users(&mut sc);
930+
931+
// Earn the buyer some unsettled_quote: place a bid (locks quote in
932+
// the real quote_vault) and immediately cancel it (credits the
933+
// buyer's unsettled_quote). settle_funds would normally drain the
934+
// quote_vault to the buyer's ATA.
935+
let bid_order_id = 1u64;
936+
let place_ix = build_place_order_ix(
937+
&sc,
938+
&sc.buyer,
939+
sc.buyer_user_account,
940+
sc.buyer_base_ata,
941+
sc.buyer_quote_ata,
942+
clob::state::OrderSide::Bid,
943+
bid_order_id,
944+
BID_PRICE,
945+
BID_QUANTITY,
946+
);
947+
let cancel_ix = build_cancel_order_ix(
948+
&sc,
949+
&sc.buyer.pubkey(),
950+
sc.buyer_user_account,
951+
bid_order_id,
952+
);
953+
send_transaction_from_instructions(
954+
&mut sc.svm,
955+
vec![place_ix, cancel_ix],
956+
&[&sc.buyer],
957+
&sc.buyer.pubkey(),
958+
)
959+
.unwrap();
960+
961+
// Build a settle_funds ix but swap fee_vault in for quote_vault.
962+
// Everything else (base_vault, mints, user accounts, owner) stays
963+
// correct, so the only thing that should reject this is the new
964+
// has_one constraint on the market PDA.
965+
let attack_ix = Instruction::new_with_bytes(
966+
sc.program_id,
967+
&clob::instruction::SettleFunds {}.data(),
968+
clob::accounts::SettleFunds {
969+
market: sc.market,
970+
user_account: sc.buyer_user_account,
971+
base_vault: sc.base_vault.pubkey(),
972+
// Attack: route the quote-side transfer at the fee_vault.
973+
quote_vault: sc.fee_vault.pubkey(),
974+
user_base_account: sc.buyer_base_ata,
975+
user_quote_account: sc.buyer_quote_ata,
976+
base_mint: sc.base_mint,
977+
quote_mint: sc.quote_mint,
978+
owner: sc.buyer.pubkey(),
979+
token_program: token_program_id(),
980+
}
981+
.to_account_metas(None),
982+
);
983+
984+
let result = send_transaction_from_instructions(
985+
&mut sc.svm,
986+
vec![attack_ix],
987+
&[&sc.buyer],
988+
&sc.buyer.pubkey(),
989+
);
990+
assert!(
991+
result.is_err(),
992+
"settle_funds must reject fee_vault substituted for quote_vault"
993+
);
994+
}
995+
919996
#[test]
920997
fn initialize_market_rejects_zero_tick_size() {
921998
let mut sc = full_setup();

0 commit comments

Comments
 (0)