Flipper logo

Flipper Core Protocol Security Review Report

July 2026

Overview

This report covers the security review for Flipper Core protocol, a Solana perpetual DEX aggregator. It integrates with various DeFi protocols on Solana to unify liquidity and orchestrate atomic multi-DEX order execution. Our security assessment was a full review of the program code, spanning a total of 2 weeks. During our review, we identified 8 Critical and 15 High severity vulnerabilities. Most of these vulnerabilities could have resulted in direct loss of assets. We also identified a large amount of minor severity vulnerabilities and code optimizations. We can say that the overall security and code quality have increased af ter completion of our audit, however due to the high number of critical issues we do not feel confident in signing off on the security of the code. We highly recommend Flipper to perform an internal audit and then another audit with an external party.

Scope

The analyzed resources are located on:

https://github.com/Flipper-Ecosystem/perps-ag-programcore/tree/a6c5c86cafb3966d983c5ba9debc9f06670935e0/programs/flipper-core

The issues described in this report were fixed in the following commit:

https://github.com/Flipper-Ecosystem/perps-ag-program-core/tree/7168a0d00c406a810a1827ddf10b8f30a9167ef6/programs/flipper-core

Summary

Total number of findings
44

Weaknesses

This section contains the list of discovered weaknesses.

FLIP2-2 | KEEPER-SUPPLIED FILLED_SIZE OVERWRITES ON-CHAIN POSITION SIZE WITHOUT VERIFICATION

Severity:

Critical

Status:

Fixed

Path:

programs/flipper-core/src/instructions/confirm_async_fill.rs#L183-L230

Description:

In confirm_async_fill, the PendingFillOpen branch unconditionally overwrites the sub-position's tracked size with a keeper-supplied parameter:

sp_mut.size_usd = params.filled_size;

The only validation checks are that filled_size > 0 and filled_size <= sp.size_usd. As a result, a malicious or compromised keeper can confirm a legitimate DEX fill using an arbitrarily small filled_size (e.g., 1), reducing the on-chain size_usd to a negligible value while the actual DEX position retains its full notional size.

As the impact, when the user later calls execute_close_with_collateral, close_size is derived from the reduced sp.size_usd:

let close_size = (pre_remaining_size as u128)
    .checked_mul(alloc.close_percent as u128)
    .and_then(|v| v.checked_div(100))
    ... as u64;

Because sp.size_usd has been reduced to 1 and close_percent = 100, then close_size = 1. The CPI to DEX closes only a dust-sized portion of the position, but Flipper evaluates closed_size_usd (1) >= size_usd (1) and transitions the sub-position to Closed. The aggregate position is then marked as terminal, active_positions_count is decremented, and the user is able to withdraw collateral - even though the actual DEX position, still holding its original notional size, remains open with no remaining protocol-controlled path to close it (which make the fund on the external DEX lost).

Similarly, during position closure, the protocol should not blindly trust the keeper-supplied params.filled_size. A malicious keeper can report params.filled_size = sp_mut.size_usd, causing the sub-position to be marked as fully closed even though only a partial close was executed.

For example, suppose a user has a GMTrade sub-position with a size of $1,000 and intends to close only 10% of it ($100). Instead of reporting the actual filled size of $100, a malicious keeper reports params.filled_size = $1,000. As a result, Flipper treats the sub-position as fully closed because the reported closed size equals the tracked position size. However, on GMTrade, only $100 was actually closed, leaving a $900 position still open. This creates a state mismatch where Flipper considers the position closed while a substantial position remains active on GMTrade.

Remediation:

After execution, read the actual position size directly from the DEX's position account on-chain rather than accepting filled_size as a caller-provided parameter. Or whitelisting the keeper who can call the function.

FLIP2-10 | (GMTRADE) INCORRECT POSITION ACCOUNT RECORDED WHEN CREATING A MARKET ORDER

Severity:

Critical

Status:

Fixed

Path:

programs/flipper-core/src/adapters/gmtrade_adapter.rs#L168

Description:

In gmtrade_adapter::open_position(), the returned CpiOpenResult sets position_account to remaining_accounts[5]:

Ok(CpiOpenResult {
    position_account: remaining_accounts[5].key(),
    accounts_consumed: GMTRADE_CREATE_ORDER_ACCOUNTS,
})

However, according to the GMTrade CreateOrderV2 account layout, remaining_accounts[5] corresponds to the order account, not the position account.

Reference:

https://github.com/gmsol-labs/gmx-solana/blob/3f10e9cd14bd23765567503c2a20e758a3fcbf39/programs/store/src/instructions/exchange/order.rs#L184-L314

The relevant portion of the account definition is:

pub struct CreateOrderV2<'info> {
    pub owner: Signer<'info>,
    pub receiver: UncheckedAccount<'info>,
    pub store: AccountLoader<'info, Store>,
    pub market: AccountLoader<'info, Market>,
    pub user: AccountLoader<'info, UserHeader>,
 
    /// The order to be created.
    pub order: AccountLoader<'info, Order>,
 
    /// The related position.
    pub position: Option<AccountLoader<'info, Position>>,
    ...
}

The corresponding account indices are:

IndexAccount
0owner
1receiver
2store
3market
4user
5order
6position

As a result, the adapter stores the order account as the sub-position's dex_position_account instead of the actual GMTrade position account.

Subsequent position-management operations rely on dex_position_account to retrieve and decode the underlying GMTrade position. Because an order account is stored instead of a position account, any logic that expects position data may operate on invalid account data.

In particular, when closing a GMTrade sub-position, the protocol may decode incorrect information from dex_position_account, leading to inaccurate close-size accounting. This can prevent the protocol from properly closing the underlying GMTrade position and may leave funds stranded on GMTrade.

Remediation:

Store the actual position account instead of the order account when constructing CpiOpenResult:

Ok(CpiOpenResult {
    position_account: remaining_accounts[6].key(),
    accounts_consumed: GMTRADE_CREATE_ORDER_ACCOUNTS,
})

FLIP2-18 | FUNDS CAN BECOME STUCK AFTER POSITION LIQUIDATION OR CLOSURE

Severity:

Critical

Status:

Fixed

Path:

programs/flipper-core/src/adapters/custody.rs#L154-L157

Description:

The function custody::decode_leg_custody_with_decimals() is used to decode position data from external DEXs.

pub fn decode_leg_custody_with_decimals(
    dex: DexType,
    leg_accounts: &[AccountInfo],
    expected_position: &Pubkey,
    gmsol_index_decimals: Option<u8>,
    phoenix_base_lot_decimals: Option<i8>,
) -> Result<DexCustody> {
    let owner = expected_owner(dex);
 
    // Locate the position account by key (authority is the stored PDA).
    let position_acc = leg_accounts
        .iter()
        .find(|a| a.key() == *expected_position)
        .ok_or(FlipperError::DexPositionAccountMismatch)?;
 
    require!(
        position_acc.owner == &owner,
        FlipperError::DexCustodyOwnerMismatch
    );
 
    let data = position_acc.try_borrow_data()?;
 
    match dex {
        DexType::Adrena => decode_anchor_adrena_like(&data),
        DexType::GMTrade => decode_anchor_gmtrade(&data, gmsol_index_decimals),
        DexType::Sour => decode_sour(&data, leg_accounts, &owner),
        DexType::Phoenix => decode_anchor_phoenix(&data, phoenix_base_lot_decimals),
    }
}

The function verifies that the supplied position account is owned by the expected DEX program. This check is intended to prevent callers from supplying malformed or unrelated accounts.

However, this assumption may not hold if the underlying position account has already been closed.

Some integrated protocols close position accounts after liquidation. One example is GMTrade, where fully liquidated positions are removed and their accounts are closed.

Consider the following GMTrade liquidation transaction:

F5K9twnGoxNSY376uGQ6VoAbsxcf6jVebg6vF34nDzv9

has its balance reduced from 0.02809824 SOL to 0 SOL, indicating that the account was closed.

Examining the Instruction Details tab shows that this same account corresponds to the liquidated position in the GMTrade: Liquidate instruction.

This suggests that GMTrade closes position accounts upon liquidation. If Flipper later attempts to read such a position through decode_leg_custody_with_decimals(), the account will no longer be owned by the expected program and may no longer contain valid position data.

As a result, the function will revert when attempting to validate or decode the closed account.

This issue affects several critical code paths that rely on custody::decode_leg_custody_with_decimals() to determine the status of external positions.

In particular, withdraw_collateral::handle_withdraw_collateral() uses this function when verifying that all sub-positions have been closed before allowing collateral withdrawal.

If any underlying GMTrade position has been liquidated and its account has subsequently been closed, the custody decoding step will fail and cause the withdrawal transaction to revert.

Consequently, positions that reference closed external accounts may become permanently unmanageable, preventing users from withdrawing the remaining collateral stored in Flipper's collateral vault.

Remediation:

In the case that the position is closed, consider skipping the decoding the position.

FLIP2-13 | (GMTRADE) MISSING RECEIVER VALIDATION WHEN CREATING AND CLOSING ORDERS

Severity:

Critical

Status:

Fixed

Path:

programs/flipper-core/src/adapters/gmtrade_adapter.rs#L174

Description:

In gmtrade_adapter::close_position(), the only validation performed on the remaining_accounts array is a call to validate_owner_pda(), which verifies that remaining_accounts[0] matches the PDA derived from owner_seeds:

pub fn close_position(...) -> Result<CpiCloseResult> {
    ...
 
    let owner_pda = super::validate_owner_pda(remaining_accounts, owner_seeds)?;
 
    ...
}

No validation is performed on remaining_accounts[1].

According to GMTrade's account layout, remaining_accounts[1] corresponds to the receiver account in CreateOrderV2:

gmx-solana/programs/store/src/instructions/exchange/order.rs at 3f10e9cd14bd23765567503c2a20e758a3fcbf39 · gmsol-labs/gmx-solana

pub struct CreateOrderV2<'info> {
    /// The owner of the order to be created.
    #[account(mut)]
    pub owner: Signer<'info>,
 
    /// The receiver of the output funds.
    /// CHECK: only the address is used.
    pub receiver: UncheckedAccount<'info>,
 
    ...
}

The receiver account determines where the proceeds of the position are sent when the position is closed.

This can be seen in GMTrade's CloseOrderV2 instruction:

gmx-solana/programs/store/src/instructions/exchange/order.rs at 3f10e9cd14bd23765567503c2a20e758a3fcbf39 · gmsol-labs/gmx-solana

pub struct CloseOrderV2<'info> {
    ...
 
    /// The ATA for final output token of the receiver.
    #[account(
        mut,
        constraint = is_associated_token_account_or_owner(
            final_output_token_ata.key,
            receiver.key,
            &final_output_token.as_ref().map(|a| a.key()).expect("must provide")
        ) @ CoreError::NotAnATA,
    )]
    pub final_output_token_ata: Option<UncheckedAccount<'info>>,
 
    /// The ATA for long token of the receiver.
    #[account(
        mut,
        constraint = is_associated_token_account_or_owner(
            long_token_ata.key,
            receiver.key,
            &long_token.as_ref().map(|a| a.key()).expect("must provide")
        ) @ CoreError::NotAnATA,
    )]
    pub long_token_ata: Option<UncheckedAccount<'info>>,
 
    /// The ATA for initial collateral token of the receiver.
    #[account(
        mut,
        constraint = is_associated_token_account_or_owner(
            short_token_ata.key,
            receiver.key,
            &short_token.as_ref().map(|a| a.key()).expect("must provide")
        ) @ CoreError::NotAnATA,
    )]
    pub short_token_ata: Option<UncheckedAccount<'info>>,
 
    ...
}

The token accounts receiving the collateral and realized PnL are all required to be associated with the specified receiver. Therefore, the receiver ultimately controls the destination of the funds released during position closure.

Because Flipper does not validate remaining_accounts[1], an attacker can supply an arbitrary receiver address under their control. When a position is subsequently closed by execute_stop_out() the proceeds of the GMTrade position may be transferred directly to the attacker's accounts.

Remediation:

Validate remaining_accounts[1] when opening and closing positions on GMTrade.

FLIP2-15 | AN ATTACKER CAN EXPLOIT THE SWEEP DESTINATION IN `EXECUTE_STOP_OUT` AND `EXECUTE_TRIGGER_ORDER` OF THE SOUR ADAPTER

Severity:

Critical

Status:

Fixed

Path:

programs/flipper-core/src/adapters/sour_adapter.rs#L579-L587, programs/flipper-core/src/adapters/sour_adapter.rs#L183-L200

Description:

The Sour close is a 3-CPI envelope: close_position → withdraw_collateral (Sour USDC vault → per-position conduit position_vault_usdc, which is pinned by seeds) → a final sweep moving the proceeds from the conduit to remaining_accounts[IDX_VAULT_USDC_ATA] (index 1):

// programs/flipper-core/src/adapters/sour_adapter.rs#L579-L587
let sweep_amount = read_token_amount(&remaining_accounts[IDX_POSITION_VAULT_USDC]);
if sweep_amount > 0 {
   let sweep_ix = spl_transfer_ix(
      remaining_accounts[IDX_CLOSE_TOKEN_PROGRAM].key(),
      remaining_accounts[IDX_POSITION_VAULT_USDC].key(),
      remaining_accounts[IDX_VAULT_USDC_ATA].key(),     // dest = idx 1 (UNPINNED)
      owner_pda,
      sweep_amount,
   );
   invoke_signed(&sweep_ix,, &[owner_seeds])?;
}

In Sour adaptor, validate_flipper_owned_slots explicitly does not validate index 1 (IDX_VAULT_USDC_ATA). The comment in validate_flipper_owned_slots() claims:

//We add no extra check here to
// keep the CU envelope low; the canonical Flipper close-path balance
// delta check in `execute_compound_close` re-asserts the value movement
// landed in the right ATA via balance-pre/post.

However, in the current version, execute_close_with_collateral, execute_stop_out, and execute_trigger_order perform no comparable balance delta assertion on col_vault. This is because the execute_compound_close instruction was removed previously. They only read collateral_vault.amount to cap a fee, without validation of destination.

When closing a position via execute_close_with_collateral, the proceeds belong to the user. However, execute_stop_out and execute_trigger_order are permissionless (i.e., any cranker can invoke them once the user has set stop_out_approved or configured a TP/SL that has been crossed), and they pass the cranker-supplied remaining_accounts directly into dispatch_close_positionsour_adapter::close_position. As a result, a cranker can set index 1 to an attacker-controlled USDC account and redirect a victim's Sour close proceeds.

Remediation:

Pin remaining_accounts[IDX_VAULT_USDC_ATA] to the canonical col_vault USDC ATA (re-derive the ATA from the vault PDA + mint and require! equality) inside validate_flipper_owned_slots, or reinstate a pre/post balance delta assertion of user collateral vault in every close/stop-out/trigger handler.

FLIP2-14 | (GMTRADE) COLLATERAL VAULT CANNOT RECEIVE FUNDS WHEN CLOSING A POSITION

Severity:

Critical

Status:

Fixed

Path:

programs/flipper-core/src/adapters/gmtrade_adapter.rs#L174

Description:

The GMTrade CloseOrderV2 instruction distributes collateral and realized PnL to token accounts associated with the specified receiver.

Reference:

gmx-solana/programs/store/src/instructions/exchange/order.rs at 3f10e9cd14bd23765567503c2a20e758a3fcbf39 · gmsol-labs/gmx-solana

pub struct CloseOrderV2<'info> {
    ...
 
    /// The ATA for final output token of the receiver.
    #[account(
        mut,
        constraint = is_associated_token_account_or_owner(
            final_output_token_ata.key,
            receiver.key,
            &final_output_token.as_ref().map(|a| a.key()).expect("must provide")
        ) @ CoreError::NotAnATA,
    )]
    pub final_output_token_ata: Option<UncheckedAccount<'info>>,
 
    /// The ATA for long token of the receiver.
    #[account(
        mut,
        constraint = is_associated_token_account_or_owner(
            long_token_ata.key,
            receiver.key,
            &long_token.as_ref().map(|a| a.key()).expect("must provide")
        ) @ CoreError::NotAnATA,
    )]
    pub long_token_ata: Option<UncheckedAccount<'info>>,
 
    /// The ATA for initial collateral token of the receiver.
    #[account(
        mut,
        constraint = is_associated_token_account_or_owner(
            short_token_ata.key,
            receiver.key,
            &short_token.as_ref().map(|a| a.key()).expect("must provide")
        ) @ CoreError::NotAnATA,
    )]
    pub short_token_ata: Option<UncheckedAccount<'info>>,
 
    ...
}

The accounts final_output_token_ata, long_token_ata, and short_token_ata are the destinations that receive the released collateral and realized PnL. Each of these accounts is required to be the receiver's ATA.

However, Flipper's accounting model expects funds to return to the user's collateral vault, which is a program-derived token account owned by Flipper rather than an ATA managed through the Associated Token Program.

As a result, GMTrade cannot transfer the proceeds directly back to the collateral vault. Instead, the collateral and realized PnL are deposited into receiver-controlled token accounts that satisfy GMTrade's ownership constraints.

The issue is compounded by the fact that Flipper does not provide any mechanism to subsequently sweep or claim funds from these receiver-owned associated token accounts back into the protocol's collateral vault. Consequently, the proceeds of a closed GMTrade position remain stranded outside of Flipper's accounting system.

Remediation:

Introduce a mechanism that allows the PDA derived from position_vault_seeds to reclaim funds from the receiver associated token accounts after a GMTrade position is closed.

FLIP2-19 | NEWLY CREATED ASYNC SUB-POSITIONS CAN BE INCORRECTLY MARKED AS LIQUIDATED

Severity:

Critical

Status:

Fixed

Path:

programs/flipper-core/src/instructions/handle_liquidation.rs#L109-L111

Description:

The function handle_liquidation::handle_handle_liquidation() is used to mark a sub-position as liquidated after the underlying DEX has liquidated the position externally.

To determine whether liquidation has occurred, the function decodes the corresponding position account on the external DEX and considers the position liquidated if the decoded size_usd equals zero:

// Cannot liquidate already-liquidated or closed sub-positions
require!(
    sub.status != SubPositionStatus::Liquidated,
    FlipperError::SubPositionAlreadyLiquidated
);
require!(
    sub.status != SubPositionStatus::Closed,
    FlipperError::PositionNotActive
);
 
...
 
let decoded =
    custody::decode_leg_custody(sub.dex, ctx.remaining_accounts, &sub.dex_position_account)?;
 
require!(decoded.size_usd == 0, FlipperError::PositionNotActive);

However, this logic is unsafe for asynchronous position flows.

Currently, Flipper only supports asynchronous position creation through GMTrade. When a new GMTrade order is submitted, the corresponding position account is not updated immediately. Instead, the order must first be executed by GMTrade before the position state reflects the newly opened position.

As a result, during the period between order creation and order execution, the associated GMTrade position reports a size of 0, even though the position has not been liquidated and is merely awaiting execution.

An attacker can exploit this behavior by calling handle_liquidation::handle_handle_liquidation() immediately after a GMTrade sub-position is created but before it is filled. Since the decoded position size is still 0, the liquidation check passes and the sub-position is incorrectly transitioned to the Liquidated state.

A malicious actor can mark a newly created GMTrade sub-position as liquidated before the corresponding order has been executed.

Once the sub-position enters the Liquidated state, the protocol treats it as permanently terminated even though the underlying GMTrade order may still be pending or may subsequently open a real position. This creates a state mismatch between Flipper and GMTrade and can leave collateral or positions stranded on the external DEX with no valid recovery path through the protocol.

Remediation:

Prevent liquidation of sub-positions that have not yet completed the fill process.

In particular, handle_liquidation::handle_handle_liquidation() should reject sub-positions whose status is PendingFill.

FLIP2-30 | PENDING GMTRADE CLOSE SETTLEMENT CAN BE LIQUIDATED BEFORE FUNDS ARE SWEPT

Severity:

Critical

Status:

Fixed

Path:

flipper-jun-26/programs/flipper-core/src/instructions/handle_liquidation.rs:handle_handle_liquidation#L37-L160, called by flipper-jun-26/programs/flipper-core/src/lib.rs:handle_liquidation#L96-L101

Description:

The handle_liquidation instruction is permissionless liquidation bookkeeping. A cranker calls it after a DEX has already liquidated a sub-position, and the handler checks that the stored DEX position is flat before marking the Flipper sub-position as Liquidated. It applies to any user position passed into the instruction, as long as the aggregate and sub-position status checks pass.

For GMTrade async closes, the normal flow is different from a real liquidation. A close first leaves the leg in PendingClose, then a keeper confirms or times it out, and the leg moves to SettlementPending. Only after that can finalize_gmtrade_leg sweep the GMTrade conduit into the user's collateral vault. That sweep is the important part: until it happens, the close proceeds or cancelled-open refund are not back in the user's collateral vault yet.

The bug is that handle_liquidation accepts aggregate positions in PendingClose and PendingFill, and it only rejects sub-positions in PendingFill. It does not reject PendingClose or SettlementPending legs:

PositionStatus::PendingFill | PositionStatus::PendingClose
...
sub.status != SubPositionStatus::PendingFill
...
sub_mut.status = SubPositionStatus::Liquidated;

So a GMTrade leg that is merely waiting to be swept can be marked as Liquidated as soon as the stored GMTrade position account looks flat. That contradicts the expected GMTrade settlement flow, because finalize_gmtrade_leg only accepts SettlementPending legs and will no longer run once the leg has been flipped to Liquidated.

An attacker can use this as a griefing freeze:

  1. A victim closes a GMTrade-backed position, leaving the leg in PendingClose.
  2. GMTrade executes the close, so the stored GMTrade position is flat, but the proceeds still need to be swept by finalize_gmtrade_leg.
  3. Before the keeper finalizes the leg, anyone calls handle_liquidation for that sub-position.
  4. The flat-position check passes, the leg is marked Liquidated, and the sweep path is bypassed.
  5. If all legs are now terminal, the aggregate position can also be closed while non-empty position-vault token accounts are skipped, leaving the funds with no remaining Flipper recovery path. The same issue can hit a timed-out GMTrade async open refund after the timeout path moves the leg to SettlementPending.

Remediation:

Reject GMTrade async settlement states in handle_liquidation, especially PendingClose and SettlementPending. Flat GMTrade legs in those states should be forced through confirm_async_fill or timeout_async_fill, then finalize_gmtrade_leg, so the conduit sweep cannot be skipped.

FLIP2-5 | (ADRENA) INCORRECT COLLATERAL VALUE PASSED TO OPENPOSITIONWITHSWAPPARAMS

Severity:

High

Status:

Fixed

Path:

programs/flipper-core/src/adapters/adrena_adapter.rs#L104-L106, programs/flipper-core/src/adapters/adrena_adapter.rs#L113

Description:

In adrena_adapter.rs, the collateral value is calculated as:

let collateral = size_usd
    .checked_div(leverage as u64)
    .ok_or(FlipperError::DivisionByZero)?;

This computes the required collateral as a USD-denominated value (size_usd / leverage). The resulting value is then passed directly to the collateral field of OpenPositionWithSwapParams when constructing the CPI request to Adrena:

let params = OpenPositionWithSwapParams {
    price: super::compute_acceptable_open_price(
        oracle_price,
        max_slippage_bps,
        side,
    ),
    collateral,
    leverage: leverage_bps,
    oracle_prices: None,
    multi_oracle_prices: None,
};

However, the collateral field in OpenPositionWithSwapParams is expected to be the collateral amount denominated in the deposit token's native units, not a USD value.

This can be verified by examining a successful Adrena position-opening transaction:

{
 "collateral": {
   "type": "u64",
   "data": "299880824"
 }
}

Reviewing the transaction actions shows a transfer of 0.299880824 JitoSOL between token accounts. Since JitoSOL uses 9 decimals, the transferred amount corresponds exactly to 299880824 native units, confirming that Adrena expects the collateral parameter to be specified in token units rather than USD value.

As a result, the current implementation passes a value with the wrong denomination into the Adrena CPI, causing the collateral amount interpreted by Adrena to differ from the amount intended by Flipper.

Remediation:

Convert the required USD collateral value into the deposit token's native units before populating OpenPositionWithSwapParams.collateral.

FLIP2-1 | STRANDED PER-MINT INSURANCE AND TREASURY VAULTS

Severity:

High

Status:

Fixed

Path:

programs/flipper-core/src/instructions/withdraw_insurance_fund.rs#L39-L44, programs/flipper-core/src/instructions/withdraw_treasury.rs#L48-L53

Description:

All fee-collecting instructions (execute_trade_with_collateral, execute_close_with_collateral, execute_stop_out, execute_limit_order_intent, confirm_stop_out_close) route insurance and treasury fees to per-mint vaults seeded with [INSURANCE_VAULT_SEED, mint] and [TREASURY_VAULT_SEED, mint], created by register_collateral_mint.

However, both withdrawal instructions hardcode the legacy singleton seed with no mint suffix:

  • withdraw_insurance_fund — seeds = [INSURANCE_VAULT_SEED]
  • withdraw_treasury — seeds = [TREASURY_VAULT_SEED] There is no instruction in the program that can withdraw from ["insurance_vault", mint] or ["treasury_vault", mint]. All fees collected in non-USDC collateral (SOL, USDT, WBTC, WETH) are permanently locked with no admin recovery path.

This is compounded by a bookkeeping mismatch: InsuranceFund.net_balance() aggregates total_fee_accumulated across all mints, while the withdrawal can only drain the legacy USDC vault. The guard require!(amount <= fund.net_balance()) therefore passes with an inflated cap, potentially masking the underlying unavailability of funds.

As the consequence all per-mint insurance and treasury fees are irrecoverable by the admin. Depending on protocol activity with non-USDC collateral, this may represent a significant portion of accumulated revenue.

#[account(
    mut,
    seeds = [INSURANCE_VAULT_SEED],
    bump,
)]
pub insurance_vault: Account<'info, TokenAccount>,
 
 
#[account(
    mut,
    seeds = [TREASURY_VAULT_SEED],
    bump,
)]
pub treasury_token_account: Account<'info, TokenAccount>,

Remediation:

Add a mint parameter to both withdraw_insurance_fund and withdraw_treasury and derive the correct per-mint vault:

seeds = [INSURANCE_VAULT_SEED, mint.key().as_ref()],
seeds = [TREASURY_VAULT_SEED, mint.key().as_ref()],

The net_balance() guard should also be scoped per-mint (tracked separately) rather than aggregated across all mints, to prevent the over-permission issue described above.

FLIP2-8 | (ADRENA + GMTRADE) DUPLICATE POSITION_VAULT_SEEDS WHEN A POSITION CONTAINS MULTIPLE SUB-POSITIONS FOR THE SAME MARKET

Severity:

High

Status:

Fixed

Path:

programs/flipper-core/src/instructions/execute_trade_with_collateral.rs#L384-L389 docs/spec/dex/adrena.md#ownership--isolation

Description:

For Adrena integrations, the position_vault_seeds are currently derived as follows:

let position_vault_seeds: &[&[u8]] = &[
    POSITION_VAULT_SEED,
    authority_key.as_ref(),
    position_id_le.as_ref(),
    &[position_vault_bump],
];

These seeds are subsequently passed as owner_seeds to adrena_adapter::open_position(). Adrena derives its position account using a PDA of the form:

PDA([b"position", owner, custody, side])

As a result, the owner account directly influences the derived Adrena position address. Since the owner account is derived from position_vault_seeds, identical position_vault_seeds will produce the same Adrena position account for a given market (custody) and side.

Consider a Flipper position that contains two sub-positions routed to Adrena (same market, same side). Because both sub-positions belong to the same parent position, they share the same:

  • authority_key
  • position_id_le Therefore, they also share the same position_vault_seeds.

If both sub-positions target the same Adrena market and side, they will resolve to the same Adrena position account rather than creating two independent positions. In effect, multiple Flipper sub-positions become mapped to a single underlying Adrena position.

This can lead to incorrect accounting in downstream logic. For example, custody::decode_anchor_adrena_like() reads collateral and position data from the Adrena position account. When multiple Flipper sub-positions reference the same underlying Adrena position, the same collateral value may be attributed to each sub-position independently, resulting in double-counting during collateral and exposure calculations.

Multiple Flipper sub-positions may unintentionally share a single Adrena position account when they use the same market and side.

This can cause:

  • Incorrect collateral accounting.
  • Double-counting of position value and collateral.
  • Inaccurate risk calculations.
  • Incorrect position state tracking between Flipper and Adrena. Note: The issue happens to the GMTrade DEX as well.

Remediation:

Ensure that a Flipper position cannot contain multiple sub-positions that target the same DEX, market, and side when routed to Adrena.

Alternatively, incorporate a sub-position-specific identifier into the position_vault_seeds derivation so that each sub-position maps to a unique owner PDA and, consequently, a unique Adrena position account. This guarantees a one-to-one relationship between Flipper sub-positions and Adrena positions and prevents accounting inconsistencies caused by shared position addresses.

FLIP2-9 | (ADRENA + GMTRADE) MISSING MARKET-TO-CUSTODY VERIFICATION DURING POSITION OPENING

Severity:

High

Status:

Fixed

Path:

programs/flipper-core/src/adapters/adrena_adapter.rs#L23-L26

Description:

In adrena_adapter::open_position(), the only market validation performed is a call to validate_market():

fn validate_market(market: &MarketType) -> Result<()> {
    get_adrena_custody_index(market)
        .map_err(|_| error!(FlipperError::UnsupportedMarket))?;
    Ok(())
}
/// Custody index for Adrena. Adrena supports SOL, BTC, ETH, and BONK.
pub fn get_adrena_custody_index(
    market: &crate::state::order_intent::MarketType,
) -> std::result::Result<u8, crate::errors::FlipperError> {
    use crate::state::order_intent::MarketType::*;
 
    match market {
        SolPerp => Ok(0),
        BtcPerp => Ok(1),
        EthPerp => Ok(2),
        BonkPerp => Ok(3),
        _ => Err(crate::errors::FlipperError::UnsupportedMarket),
    }
}

This validation only ensures that the supplied market is one of the markets supported by Adrena. It does not verify that the provided Adrena accounts correspond to that market.

In particular, there is no validation that remaining_accounts[7] (the principalCustody account) matches the specified market.

As a result, a user can supply a custody account that belongs to a different market than the one recorded by Flipper. For example, a user could request a position on SolPerp while providing a principalCustody account associated with BonkPerp.

In such a scenario:

  • Flipper records the position as a SolPerp position.
  • Adrena opens the position against the custody corresponding to BonkPerp.
  • Subsequent protocol operations continue to treat the position as SolPerp. This creates a market mismatch between Flipper's internal state and the actual position maintained by Adrena.

As a consequence, Flipper may use the wrong oracle and market configuration when performing downstream operations such as:

  • Stop-out execution.
  • Trigger-order execution.
  • Funding updates.
  • PnL calculations.
  • Risk and liquidation checks. This can lead to incorrect position management and significant inconsistencies between Flipper's accounting and the underlying Adrena position.

Note: The similar issue happen when dealing with GmTrade as well.

Remediation:

When opening a position on Adrena, verify that the supplied principalCustody account corresponds to the specified market.

The adapter should maintain a canonical mapping between supported markets and their expected custody accounts and reject any CPI invocation where the provided custody account does not match the market selected by the user. This ensures that the market recorded by Flipper is always consistent with the market on which the position is actually opened.

FLIP2-28 | GMTRADE NON-USDC COLLATERAL IS MISPRICED IN HEALTH CHECKS

Severity:

High

Status:

Fixed

Path:

programs/flipper-core/src/adapters/custody.rs:decode_anchor_gmtrade#L449-L489

Description:

The function decode_anchor_gmtrade decodes a GMTrade position account into Flipper's normalized DexCustody struct. It is called from custody::decode_leg_custody_with_decimals, then consumed by execute_stop_out and execute_trigger_order when they compute effective collateral, unrealized PnL, and whether the aggregate position is healthy. The function applies to any open GMTrade leg, so also including positions using Flipper-supported non-USDC GMTrade collateral such as SOL, WBTC, and WETH.

It should normally be the case that any value returned in DexCustody.collateral_usd is denominated in USD (6 decimals), because both health callers add that field directly into effective_collateral. Stable 6-decimal collateral can pass through as raw token units, but non-stable collateral must be converted before being used as USD collateral.

The open path already treats GMTrade collateral as native token units. adapters/mod.rs converts the USD-e6 margin into collateral native units for GMTrade, and gmtrade_adapter.rs transfers exactly that native amount into the GMTrade conduit before passing it as initial_collateral_delta_amount (L616-679). A non-buggy read path must apply the inverse conversion when decoding GMTrade custody for margin checks.

However, decode_anchor_gmtrade reads GMTrade's native state.collateral_amount and returns it as collateral_usd without using the collateral type, decimals, or oracle price:

let collateral_amount = read_u128_le(data, COLLATERAL_AMOUNT_OFF)?;
let size_usd = (size_in_usd / MARKET_UNIT_TO_6).min(u64::MAX as u128) as u64;
let collateral_usd = collateral_amount.min(u64::MAX as u128) as u64;

This contradicts the DexCustody contract that collateral_usd is 6 decimals. For WBTC, a $10,000 margin deposit at $100,000/BTC is 0.1 WBTC, or 10_000_000 satoshis. The decoder returns 10_000_000 as USD-e6, so the health check treats the collateral as $10 instead of $10,000. For SOL, the direction can reverse: a $10,000 deposit at $150/SOL is about 66_666_666_666 lamports, which is treated as $66,666.66 instead of $10,000.

Both health gates consume the mispriced value directly. execute_stop_out adds custody.collateral_usd into effective_collateral before requiring the position to be unhealthy. execute_trigger_order does the same before enforcing stop-out precedence with StopOutRequired.

SOL overcounting can make an unhealthy GMTrade position appear healthy, preventing the intended stop-out path and allowing trigger-order close logic to proceed without the stop-out precedence check binding.

Remediation:

Convert GMTrade collateral_amount from native token units to USD-e6 before returning it in DexCustody, and require callers that use GMTrade custody for health checks to pass the position collateral type and validated collateral oracle price. Add regression coverage for GMTrade SOL, WBTC, and WETH collateral in both stop-out and trigger-order health checks.

FLIP2-29 | GMTRADE PENDING-OPEN TIMEOUT CAN ORPHAN THE DEX ORDER AND COLLATERAL

Severity:

High

Status:

Fixed

Path:

programs/flipper-core/src/instructions/timeout_async_fill.rs:handle_timeout_async_fill#L51-L171

Description:

The function handle_timeout_async_fill is the keeper recovery path for async DEX legs that do not settle before Flipper's pending_fill_deadline. It is reached after a user opens a GMTrade leg through prepare_gmtrade_leg, or after a limit order is filled through execute_limit_order_intent and routed to GMTrade. The function only applies to authorized Flipper keepers, but the affected positions are normal user positions.

For a healthy timeout flow, Flipper should only mark a GMTrade PendingFill leg as failed after the external GMTrade order is no longer able to execute and the posted collateral is back under Flipper's normal custody. In other words, the timeout should be a DEX reconciliation step first and a bookkeeping update second. If the GMTrade order is still pending, Flipper needs to cancel it, prove it was cancelled or otherwise cannot fill, and account for any refund before closing the local leg.

This matters because a GMTrade open is not just a local request. prepare_gmtrade_leg creates a per-position conduit owned by the position_vault, and the GMTrade adapter then moves the leg collateral from Flipper's pooled collateral vault into that conduit. create_order_v2 pulls the same collateral into GMTrade order escrow:

// Move exactly the leg's collateral (native units) col_vault -> conduit,
// signed by the col_vault PDA. create_order then debits the same amount.
let fund_ix = spl_transfer_ix(
    remaining_accounts[GMTRADE_IDX_TOKEN_PROGRAM].key(),
    remaining_accounts[GMTRADE_IDX_COL_VAULT_ATA].key(),
    remaining_accounts[GMTRADE_IDX_COLLATERAL_SOURCE].key(),
    col_vault,
    collateral_native,
);

After that CPI, direct opens and limit-order fills both store the GMTrade sub-position as PendingFill and set the aggregate deadline. At that point, the user's margin is already outside the pooled Flipper vault and tied to the GMTrade order lifecycle.

However, handle_timeout_async_fill does not cancel the GMTrade order or require the GMTrade order and escrow accounts on the open-timeout path. It only proves DEX flatness for PendingClose:

if was_pending_close {
   let decoded =
     custody::decode_leg_custody(dex_type, ctx.remaining_accounts, &dex_position_account)?;
   require!(decoded.size_usd == 0, FlipperError::StopOutCloseNotSettled);
}

For a PendingFill, it skips that kind of DEX-side check and immediately terminalizes the local sub-position:

let sp_mut = &mut position.sub_positions[idx];
sp_mut.status = SubPositionStatus::FillFailed;
sp_mut.closed_size_usd = sp_mut.size_usd;

If every pending leg is now resolved, the function can also set the aggregate to Closed, clear pending_dex, and decrement active_positions_count. That contradicts the expected behavior above: Flipper records the GMTrade leg as done while the GMTrade order may still be pending with collateral in escrow.

The codebase even has a GMTrade cancel_order_if_no_position helper in gmtrade_adapter.rs, but it is not wired into any instruction-level recovery path. The user-facing cancel_order instruction only cancels a Flipper OrderIntent; it does not touch GMTrade. So once timeout writes FillFailed, there is no normal Flipper instruction that cancels the external order, refunds the escrowed collateral, or binds a later GMTrade fill back to the aggregate position.

The cleanup path makes this worse. AggregatedPosition::has_inflight_sub_position no longer treats FillFailed as in-flight, so close_aggregated_position can close the terminal PDA after the timeout. In the single-leg case, that can delete the only Flipper state that still records the GMTrade position account. If the GMTrade order later fills, the live DEX position is owned by Flipper's position_vault PDA, not by the user, so the user cannot sign to recover or close it directly.

Remediation:

Do not mark a GMTrade PendingFill leg as FillFailed until the external order has been cancelled, proven unable to execute, and any collateral refund has been returned to the canonical Flipper vault. The timeout path should require the GMTrade order/position/escrow accounts needed for that reconciliation, or it should refuse to terminalize GMTrade pending opens and leave the state in a recoverable pending status.

FLIP2-31 | UNPROTECTED PROTOCOL CONFIG INITIALIZATION LETS FIRST CALLER TAKE ADMIN

Severity:

High

Status:

Fixed

Path:

flipper-jun-26/programs/flipper-core/src/instructions/initialize_protocol_config.rs:handle_initialize_protocol_config#L44-L116

Description:

The function handle_initialize_protocol_config initializes the protocol-wide config PDA. This is the root setup step for the protocol: the mainnet setup script calls initializeProtocolConfig, the instruction creates the singleton protocol_config account, and the handler stores the admin authority used by later setup and admin instructions. The function applies to whoever signs as authority during the first initialization.

The expected behavior is that only the intended deployer or initial admin can create this root config. Once the config exists, later setup should use the stored admin to initialize treasury, market, collateral, keeper, circuit-breaker, settlement, and fee-withdraw settings. In other words, the first config account should anchor the official deployment, not just whichever wallet got a transaction in first.

However, the initializer only requires authority to be a signer. It does not check that the signer is the deployer, the upgrade authority, or any preconfigured genesis admin:

pub authority: Signer<'info>,
...
config.authority = ctx.accounts.authority.key();

The config PDA is also a singleton at the fixed protocol_config seed. If an attacker calls this instruction before the real setup transaction, their key is stored as ProtocolConfig.authority and the legitimate operator cannot initialize the account again. This also blocks normal recovery through set_admin, because that path requires the current stored admin to sign.

One more practical issue makes the launch flow easier to trip over: scripts/mainnet/setup.ts skips initializeProtocolConfig when the PDA already exists, but it does not verify that the live authority is the expected operator. So a captured config can turn into a confusing failed setup, or worse, a deployment that looks like it exists at the official program id but is controlled by the attacker.

Exploit path:

  1. The operator deploys the program at the fixed program id, but has not run the mainnet setup transaction yet.
  2. An attacker calls initialize_protocol_config first and passes their own wallet as authority.
  3. The singleton protocol_config PDA is now initialized with the attacker as admin.
  4. The legitimate setup can no longer create the config, and all later admin-gated initializers require the attacker's key.
  5. The attacker can either block deployment or finish setup with their own admin-controlled registries and protocol settings.

Remediation:

Bind initialize_protocol_config to a deploy-time expected authority, such as the program upgrade authority or a hard-coded/build-time genesis admin. Also make the setup script fail closed if protocol_config already exists but its stored authority is not the expected operator.

FLIP2-32 | GMTRADE PARTIAL-CLOSE PROCEEDS CAN BE PERMANENTLY STRANDED

Severity:

High

Status:

Fixed

Path:

flipper-jun-26/programs/flipper-core/src/instructions/confirm_async_fill.rs:confirm_async_fill#L269-L345, flipper-jun-26/programs/flipper-core/src/instructions/finalize_gmtrade_leg.rs:finalize_gmtrade_leg#L153-L188

Description:

confirm_async_fill is used by the keeper after GMTrade completes an async open or close. For closes, it checks the real on-chain GMTrade size delta and updates the Flipper sub-position. GMTrade close proceeds do not go straight to the user's collateral vault; they first land in the per-position conduit, ATA(position_vault, mint), and finalize_gmtrade_leg later sweeps that balance into col_vault.

For a normal full GMTrade close, the flow is simple: the close order fills, confirm_async_fill marks the leg as SettlementPending, and finalize_gmtrade_leg sweeps the conduit, charges the deferred close fee, and closes the leg. For a partial close, the residual GMTrade leg stays live, but the already-realized proceeds from the closed slice still need a reachable sweep path.

The issue is that a genuine partial close is marked as PartialClosed, not SettlementPending:

sp_mut.status = if new_closed >= sp_mut.size_usd {
   SubPositionStatus::SettlementPending
} else {
   SubPositionStatus::PartialClosed
};

At the same time, the only sweep path requires SettlementPending:

require!(
    position.sub_positions[idx].status == SubPositionStatus::SettlementPending,
    FlipperError::LegNotSettlementPending
);

So after a partial close, the funds are sitting in the position-vault conduit, but the normal finalizer is not callable. This is only a delay if the remaining leg later goes through a normal full close. If the residual leg instead becomes flat and is terminalized through another path, like handle_liquidation or settle_position, those paths mark the leg closed or liquidated without sweeping the conduit. At that point there is no SettlementPending leg left to unlock the funds.

Exploit scenario:

  1. A user partially closes a GMTrade leg, for example 99% of the size. GMTrade pays the returned margin and PnL for that slice into ATA(position_vault, mint).
  2. The keeper calls confirm_async_fill. Because 1% remains open, the sub-position becomes PartialClosed and the aggregate position goes back to Open.
  3. The residual leg later becomes flat through external liquidation or global settlement.
  4. handle_liquidation or settle_position terminalizes the leg without calling finalize_gmtrade_leg.
  5. The 99% close proceeds stay stuck in the conduit with no callable sweep path.

Remediation:

Do not let realized GMTrade close proceeds leave the pending settlement flow before they are swept. Either route partial closes through a sweepable settlement state first, or add a dedicated sweep for PartialClosed GMTrade legs, and make liquidation/settlement reject or sweep any non-empty conduit before terminalizing a leg.

FLIP2-33 | LIQUIDATION DURING A PENDING FILL CAN ORPHAN GMTRADE COLLATERAL

Severity:

High

Status:

Fixed

Path:

flipper-jun-26/programs/flipper-core/src/instructions/handle_liquidation.rs:handle_handle_liquidation#L68-L164

Description:

handle_handle_liquidation is the permissionless bookkeeping path for DEX-side liquidations. A cranker calls it after a user's sub-position is already flat on its DEX; the handler verifies the DEX account, marks that sub-position as Liquidated, and recomputes the aggregate position status. The public call chain is the handle_liquidation instruction into this handler, and it applies to any user's aggregate position.

Mixed opens can normally contain both sync and async legs. For example, execute_trade_with_collateral can open an Adrena leg immediately as Open, while a GMTrade leg stays PendingFill. In that state, the aggregate position should stay in the pending lifecycle until the GMTrade keeper either confirms the fill or times it out, because both confirm_async_fill and timeout_async_fill only accept PendingFill or PendingClose.

The liquidation handler breaks that expectation. It accepts an aggregate that is still PendingFill, and it only blocks liquidation of the sub-position that is itself PendingFill. That means a different already-open leg can still be liquidated while GMTrade is waiting in the background. After marking the flat leg as Liquidated, the handler recomputes the aggregate like this:

} else {
   // Some sub-positions still open
   position.status = PositionStatus::PartiallyOpen;
}
 
position.total_size_usd = position.total_remaining_size();

This overwrites the aggregate status with PartiallyOpen even though an async GMTrade leg is still pending. It also uses total_remaining_size(), which only counts open legs and ignores PendingFill. The result is a position with a GMTrade fill that is still live or refundable, but the aggregate status is no longer one the async handlers will touch. Normal close paths only operate on open legs, and close_aggregated_position also refuses to clean up while an in-flight sub-position still exists.

Exploit scenario:

  1. A user opens a split position, for example Adrena 50% and GMTrade 50%. Adrena becomes Open, GMTrade is PendingFill, and the aggregate is PendingFill.
  2. Before GMTrade is confirmed or timed out, the Adrena leg is liquidated externally and its DEX account is flat.
  3. Any cranker calls handle_liquidation for the Adrena sub-position.
  4. The handler marks Adrena as Liquidated, rewrites the aggregate to PartiallyOpen, and drops the pending GMTrade size.
  5. The keeper can no longer confirm or time out GMTrade, and close paths do not resolve the PendingFill leg. The user's GMTrade collateral or live filled position can be stuck.

Remediation:

When any sub-position is still PendingFill, PendingClose, or SettlementPending, handle_liquidation should preserve the aggregate pending status and pending bookkeeping. Alternatively, reject liquidation while any async leg is in flight, and use total_size_with_pending() whenever pending async size remains.

FLIP2-43 | GMTRADE LIQUIDATION BEFORE FILL CONFIRMATION PERMANENTLY FREEZES THE POSITION AND PROCEEDS

Severity:

High

Status:

Fixed

Path:

programs/flipper-core/src/instructions/execute_trade_with_collateral.rs#L744-L803, L925-L961, programs/flipper-core/src/instructions/confirm_async_fill.rs#L211-L280, L355-L364, programs/flipper-core/src/instructions/timeout_async_fill.rs#L310-L405

Description:

A GMTrade open is asynchronous. Flipper first creates an increase order and marks the user's leg as PendingFill. A GMTrade keeper then fills and closes that order, after which a Flipper keeper calls confirm_async_fill to mark the leg as Open.

Normally, confirmation should reconcile the live GMTrade position with Flipper's bookkeeping. If the order was never filled, timeout_async_fill should cancel it after the deadline and return the position to a recoverable state. If the filled position is liquidated before confirmation, Flipper should still be able to recognize that sequence, settle any remaining proceeds, and close its bookkeeping position.

However, a position that is filled and then liquidated before confirm_async_fill becomes permanently stuck. After liquidation, the current GMTrade size is zero. Confirmation rejects a reported size of zero, while any nonzero size fails the check against the current GMTrade position:

require!(params.filled_size > 0, FlipperError::InvalidFillData);
require!(
    params.filled_size == decoded.size_usd,
    FlipperError::InvalidFillData
);

The timeout path cannot recover the position either. It always calls GMTrade's close_order_v2, but the normal GMTrade keeper already closed the filled increase order. That CPI fails and rolls back the whole timeout. The liquidation, settlement, emergency close, and finalization handlers also reject a leg that is still PendingFill.

This leaves the leg and aggregate position stuck forever. Any liquidation remainder held by the position_vault conduit cannot be swept to the user's withdrawable collateral vault, and one of the user's active-position slots stays locked.

An example failure flow is:

  1. A user opens a GMTrade position, and Flipper records the leg as PendingFill.
  2. A GMTrade keeper fills the increase and closes the completed order.
  3. Before the Flipper keeper confirms the fill, normal market movement causes GMTrade to liquidate or ADL the position.
  4. confirm_async_fill rejects the now-zero position size.
  5. After the deadline, timeout_async_fill tries to close the already-closed order and reverts.
  6. All other recovery paths reject PendingFill, permanently freezing the position and any remaining proceeds.

Remediation:

Track an executed order separately from a confirmed fill, and make the timeout path tolerate an order that has already been closed. Add an authenticated recovery path that can recognize a filled-then-liquidated leg, settle its proceeds, and move it to a terminal state.

FLIP2-38 | GMTRADE LIQUIDATIONS CAN PERMANENTLY LOCK USER PAYOUTS

Severity:

High

Status:

Fixed

Path:

perps-ag-program-core/programs/flipper-core/src/instructions/handle_liquidation.rs:handle_handle_liquidation#L44-L185

Description:

The permissionless handle_handle_liquidation function updates Flipper's bookkeeping after GMTrade has liquidated a user's position. It checks that the GMTrade position is flat, marks the matching leg as Liquidated, and may also mark the full aggregated position as terminal.

For a normal asynchronous GMTrade close, flattening the position is not the end of settlement. GMTrade first sends the order output to the canonical token account owned by Flipper's position_vault PDA. Flipper keeps the leg in SettlementPending until finalize_gmtrade_leg signs for that PDA and sweeps the payout into the user's pooled collateral vault. Only then should the leg become terminal.

However, handle_handle_liquidation skips this settlement step. It moves no funds and changes the leg directly to Liquidated as soon as GMTrade reports a zero position:

require!(decoded.size_usd == 0, FlipperError::PositionNotActive);
 
sub_mut.status = SubPositionStatus::Liquidated;

GMTrade can still hold the liquidation or ADL output in an order escrow at this point. Closing that order later pays ATA(position_vault, mint), and GMTrade may also create owner-claimable credits delegated to position_vault. These funds are not recoverable after the status change: finalize_gmtrade_leg only accepts SettlementPending legs, the user cannot sign for the PDA directly, and Flipper has no instruction for claiming the delegated credits.

An example failure flow is:

  1. A user opens a GMTrade leg, with position_vault set as its owner and payout receiver.
  2. GMTrade liquidates or fully auto-deleverages the leg and flattens the position.
  3. Any caller invokes handle_liquidation, which marks the leg Liquidated without settling its outputs.
  4. GMTrade closes the position-cut order and sends the remaining collateral and PnL to the PDA-owned token account.
  5. finalize_gmtrade_leg rejects the terminal leg, so the payout and any owner-claimable credits remain locked.

Remediation:

Keep GMTrade liquidations in a settlement-pending state until all order outputs and owner-claimable credits have been moved into the user's pooled collateral vault. Only mark the leg Liquidated and allow cleanup after that reconciliation is complete.

FLIP2-37 | ONE TOKEN UNIT CAN PERMANENTLY FREEZE POSITION-VAULT SOL AND PHOENIX SLOTS

Severity:

High

Status:

Fixed

Path:

programs/flipper-core/src/instructions/close_aggregated_position.rs:handle_close_aggregated_position#L76-L255

Description:

handle_close_aggregated_position is the permissionless cleanup path for a terminal position. It closes the position's canonical collateral account, returns the remaining SOL and rent from position_vault, releases finalized Phoenix slot reservations, and finally closes the position account.

Once every leg is settled, this cleanup should always be reachable. In particular, tokens left in the canonical collateral account should be swept to the user's pooled collateral vault before the account is closed, and unrelated token-account state should not prevent a finalized Phoenix reservation from being released.

However, the function requires the canonical collateral account to have a zero balance. Anyone can send tokens to this public, deterministic ATA without approval from the victim or its position_vault authority. An attacker can therefore send one raw collateral unit to the account and make the check at lines 132-142 fail before any SOL is returned or Phoenix reservation is closed.

The victim cannot remove this dust directly because only the position_vault PDA can sign for the account. For GMTrade, the only instruction that sweeps this account requires a SettlementPending leg, but the last successful finalization has already made the leg terminal. Phoenix does not use this ATA at all, yet its slot release is still placed behind the same zero-balance check.

  1. A user closes a GMTrade position, and finalize_gmtrade_leg sweeps the conduit and marks the last leg terminal.
  2. Before close_aggregated_position runs, an attacker transfers one raw collateral unit to the canonical position-vault ATA.
  3. close_aggregated_position rejects the nonzero balance before returning the position vault's SOL or closing any accounts.
  4. The sweep cannot be called again for the terminal leg, so roughly 0.19021712 SOL of open-budget remainder per GMTrade leg, plus rent and other refunds, can remain frozen permanently.
  5. For a Phoenix position, the same attack also prevents the finalized child reservation from being closed. Repeating it as the user consumes new indices can lock all 255 Phoenix slots for that wallet.

Remediation:

Sweep the canonical account's full balance into the user's seed- and mint-validated pooled collateral vault before closing it. Phoenix reservation release should also be independently retryable so unrelated token-account dust cannot keep a finalized slot locked.

FLIP2-39 | GMTRADE CLOSE PROCEEDS CAN BE STRANDED BY PREMATURE FINALIZATION

Severity:

High

Status:

Fixed

Path:

programs/flipper-core/src/instructions/finalize_gmtrade_leg.rs:handle_finalize_gmtrade_leg#L160-L195, L445-L479

Description:

finalize_gmtrade_leg settles a GMTrade leg after a close or stop-out has been confirmed. A registered Flipper keeper calls it once the leg is SettlementPending. The function sweeps the close proceeds from the position's conduit into col_vault, charges any fee, and marks the leg as closed.

GMTrade closes a position in two separate transactions. execute_decrease_order_v2 first reduces the position and sends the returned margin and PnL to order-owned escrows. close_order_v2 then releases those funds to the conduit. Flipper should only finalize the leg after the second transaction has completed, so all proceeds are available to sweep.

However, Flipper only checks that the GMTrade position size was reduced or became zero. It does not prove that the order was closed or that its escrows paid the conduit. The finalizer also accepts a zero conduit balance:

let sweep_amount = ctx.accounts.conduit.amount;
if sweep_amount > 0 {
   token::transfer(/* conduit -> col_vault */, sweep_amount)?;
}

It then unconditionally changes the leg to Closed, PartialClosed, or StoppedOut and clears pending_settlement_size_usd. If close_order_v2 was delayed, the finalizer therefore sweeps nothing and consumes the only state that allows a later sweep. The user's full returned margin and PnL can remain in the order escrows or arrive later in an account owned by the position_vault PDA, where the user cannot recover them.

One possible scenario is:

  1. A GMTrade keeper executes a victim's decrease order, making the position flat and placing the proceeds in the order escrows.
  2. The keeper's separate close_order_v2 transaction is delayed or fails.
  3. Flipper observes the flat position and marks the leg SettlementPending, either through normal confirmation or the permissionless stop-out timeout.
  4. A registered Flipper keeper calls finalize_gmtrade_leg. The empty conduit is accepted, and the leg becomes terminal.
  5. If close_order_v2 later delivers the proceeds, another finalization attempt fails because the leg is no longer SettlementPending. Permissionless cleanup may also delete the aggregate and close the empty conduit.
  6. The victim's returned margin and PnL are permanently stranded.

Remediation:

Require proof that the exact GMTrade order is closed and its output escrows are empty before finalizing the leg, preferably by closing the order in the confirmation flow. Keep a retryable sweep path and block aggregate cleanup until delivery has been proven.

FLIP2-40 | SOUR LIQUIDATIONS CAN PERMANENTLY LOCK A USER'S REMAINING EQUITY

Severity:

High

Status:

Fixed

Path:

perps-ag-program-core/programs/flipper-core/src/instructions/handle_liquidation.rs:handle_handle_liquidation#L44-L185

Description:

handle_handle_liquidation reconciles a Flipper position after it has been liquidated on an external DEX. For Sour legs, any caller can use this bookkeeping flow to mark the leg as Liquidated once its decoded position size is zero. The function does not move any funds.

Sour handles liquidation and collateral withdrawal as separate steps. Its clear_batch flow can liquidate a position while it still has positive equity, sets qmax to zero, and leaves the remaining collateral in the owner's TraderAccount. Flipper should withdraw that balance before treating the leg as fully settled.

However, Flipper's Sour decoder allows the TraderAccount to be omitted when qmax is zero and reports the collateral as zero in that case. handle_handle_liquidation then makes the leg terminal without recovering the actual balance. The only existing Sour withdrawal path first calls close_position, which Sour rejects after the position has already been zeroed. The user cannot withdraw directly either, because the TraderAccount is owned by Flipper's position_vault PDA.

A typical failure path is:

  1. A user's Sour position falls below maintenance margin while it still has some positive equity.
  2. Sour liquidates the position, sets qmax to zero, and leaves the remaining equity in TraderAccount.usdc_collateral.
  3. Any caller invokes handle_liquidation without providing the TraderAccount, so Flipper records the leg as Liquidated and moves no funds.
  4. The user's normal close can no longer reach withdraw_collateral, and the user cannot sign as the position_vault PDA.
  5. Permissionless cleanup can delete the Flipper position while the residual equity remains permanently locked in Sour.

Remediation:

Require the canonical Sour TraderAccount during liquidation reconciliation and keep the leg in a pending state while it has collateral. Add a finalizer that calls withdraw_collateral directly as the position_vault PDA and only marks the leg as Liquidated after the balance has been swept to the user's collateral vault.

FLIP2-41 | PERMISSIONLESS EMERGENCY CLOSES CAN ORPHAN LIVE POOL-DEX POSITIONS

Severity:

High

Status:

Fixed

Path:

perps-ag-program-core/programs/flipper-core/src/instructions/emergency_close_sub_position.rs:handle_emergency_close_sub_position#L86-L215

Description:

The permissionless emergency_close_sub_position function marks one sub-position as closed when either its DEX circuit breaker is active or the whole protocol is halted. It is a bookkeeping-only escape hatch and does not send a close instruction to the underlying DEX.

This behavior is reasonable when the selected DEX is compromised and its circuit breaker is active, since calling that DEX may be unsafe. A general protocol halt is different. During a global settlement, live DEX positions should be closed through settlement_close_sub_position, and a leg should only become terminal after its exposure has been reduced and its collateral can be reconciled.

However, the global halt also enables the bookkeeping-only path:

require!(
    breaker_active || config.is_halted(),
    FlipperError::EmergencyCloseNotAllowed
);

This lets any signer mark an Adrena, GMTrade, or Sour leg as Closed while the real DEX position remains open. These venues use the Flipper position_vault PDA as the position owner, so the user cannot manage the position directly. The normal close, settlement, stop-out, trigger, and liquidation flows also reject the leg once it is marked Closed.

If all legs are marked closed, close_aggregated_position can then remove the aggregate account and reclaim the empty position-vault accounts. This deletes the lifecycle state needed by the current owner-signed recovery paths. The live DEX position remains exposed to price changes, funding, and liquidation, putting up to its full margin at risk. This cleanup path does not apply in the same way to Phoenix legs because their reservation must be finalized first.

An attack can happen as follows:

  1. An admin starts global settlement or otherwise enables the protocol halt.
  2. An attacker calls emergency_close_sub_position for a live Adrena, GMTrade, or Sour leg, even though that DEX's circuit breaker is inactive.
  3. The function sets the leg to Closed without a DEX CPI, and the aggregate becomes Closed once no open legs remain.
  4. Any caller invokes close_aggregated_position, removing the aggregate and empty position-vault accounts.
  5. The DEX position stays live under the position_vault PDA, but the user cannot sign for it and the current Flipper close paths can no longer reach it.

Remediation:

Only allow bookkeeping-only emergency closes when the affected DEX's circuit breaker is active. During a general protocol halt, use the normal settlement close flow, or restrict any fallback path to authorized keepers and keep enough state to recover the live DEX position.

FLIP2-3 | INCORRECT ORACLE STALENESS AND CONFIDENCE-BAND PARAMETERS USED FOR COLLATERAL PRICE VALIDATION

Severity:

Medium

Status:

Fixed

Path:

programs/flipper-core/src/instructions/deposit_collateral.rs#L154-L159, programs/flipper-core/src/instructions/execute_trade_with_collateral.rs#L342-L346, programs/flipper-core/src/instructions/execute_close_with_collateral.rs#L224-L229, programs/flipper-core/src/instructions/execute_stop_out.rs#L192-L197, programs/flipper-core/src/instructions/execute_trigger_order.rs#L346-L351, programs/flipper-core/src/instructions/execute_limit_order_intent.rs#L418-L423, programs/flipper-core/src/instructions/withdraw_collateral.rs#L322-L327

Description:

The protocol introduces a CollateralRegistry account that stores configuration for each supported collateral asset, including whether the collateral is enabled, its associated Pyth feed, and per-collateral oracle validation parameters such as maximum staleness and confidence-band thresholds.

#[account]
pub struct CollateralRegistry {
   /// Admin authority (must match `ProtocolConfig.authority`).
   pub authority: Pubkey,
   /// PDA bump.
   pub bump: u8,
   /// One entry per CollateralType. Indexed by `CollateralType as u8`.
   pub entries: [CollateralEntry; 5],
   /// Timestamps.
   pub created_at: i64,
   pub updated_at: i64,
   /// Reserved for future fields (extra entries, per-asset risk weights, ...).
   pub _reserved: [u8; 64],
}
 
#[derive(AnchorSerialize, AnchorDeserialize, Clone, Copy, Debug)]
pub struct CollateralEntry {
   pub collateral_type: u8,
   pub mint: Pubkey,
   pub feed_id: [u8; 32],
   pub decimals: u8,
   pub stablecoin_short_circuit: bool,
   pub enabled: bool,
   pub max_oracle_age_secs: u16,
   pub max_oracle_conf_bps: u16,
   pub _reserved: [u8; 1],
}

This registry is intended to be consulted by every collateral-related instruction during the Pyth Pull v2 validation flow (e.g., withdraw_collateral, execute_stop_out, update_position_funding, etc.) to derive the correct expected_feed_id, max_oracle_age_secs, and max_oracle_conf_bps parameters passed to read_pyth_price.

However, the current implementation does not use the CollateralRegistry when validating collateral prices. Instead, it retrieves the staleness and confidence-band limits from the associated market registry:

let registry = &ctx.accounts.market_registry;
 
...
 
let collateral_feed_id = params.collateral_type.pyth_feed_id();
let collateral_data = read_pyth_price(
   &collateral_price_update,
   &collateral_feed_id,
   registry.effective_max_age_secs(config),
   registry.effective_max_conf_bps(config),
)?;
Some(normalize_pyth_price(
   collateral_data.price,
   collateral_data.exponent,
)?)

The values returned by registry.effective_max_age_secs() and registry.effective_max_conf_bps() are market-specific settings and are unrelated to the collateral being priced. As a result, any per-collateral overrides configured in CollateralRegistry are ignored and collateral prices are validated using the wrong staleness and confidence-band thresholds. Consequently, oracle updates that should be rejected according to the collateral's configured risk parameters may be accepted, while valid updates may be rejected if the market configuration is more restrictive.

This breaks the intended risk isolation between markets and collateral assets and renders the per-collateral oracle controls in CollateralRegistry ineffective.

Remediation:

When validating collateral prices, derive the oracle validation parameters from the corresponding CollateralEntry in CollateralRegistry rather than from MarketRegistry.

FLIP2-6 | (ADRENA) INCORRECT PRICE VALUE PASSED TO OPENPOSITIONWITHSWAPPARAMS

Severity:

Medium

Status:

Fixed

Path:

programs/flipper-core/src/adapters/adrena_adapter.rs#L112, programs/flipper-core/src/adapters/adrena_adapter.rs#L220

Description:

In adrena_adapter::open_position(), the input size_usd represents the intended position size in USD and is denominated with 6 decimals:

pub struct TradeAllocation {
   pub dex: DexType,
   /// Size in USDC for this DEX leg (6 decimals).
   pub size_usd: u64,
   /// Allocation percentage (0-100).
   pub allocation_percent: u8,
}

When constructing the CPI request to Adrena, the price field of OpenPositionWithSwapParams is populated using compute_acceptable_open_price():

let params = OpenPositionWithSwapParams {
   price: super::compute_acceptable_open_price(
      oracle_price,
      max_slippage_bps,
      side,
   ),
   ...
};

compute_acceptable_open_price() only applies the configured slippage adjustment to the oracle price and does not perform any decimal conversion. Consequently, the value passed to OpenPositionWithSwapParams.price retains the original 6-decimal USD precision used internally by Flipper.

However, this appears to be incompatible with the format expected by Adrena.

Consider the following Adrena transaction:

{
 "price": {
   "type": "u64",
   "data": "642276039925000"
 }
}

This transaction corresponds to the WBTC market. If Adrena interpreted prices using Flipper's 6-decimal format, the above value would represent:

642276039925000 / 1e6 = 642,276,039.925

which implies a BTC price of more than $642 million and is clearly unrealistic.

Instead, the observed value is consistent with a higher-precision format. For example, scaling by 1e10 yields:

642276039925000 / 1e10 = 64,227.6039925

which is a reasonable BTC price.

This suggests that Adrena expects the price parameter to use a different decimal scale than the 6-decimal price currently supplied by Flipper. As a result, the CPI may be passing an incorrectly formatted price value when opening positions.

As the impact, if the decimal precision expected by Adrena differs from the precision used by Flipper, the acceptable execution price submitted to Adrena may be significantly distorted. Depending on how Adrena validates this parameter, this could lead to:

  • Failed position openings.
  • Incorrect slippage protection.
  • Orders executing under unintended price constraints.
  • Inconsistent behavior across different markets. The similar issue happens in the function close_position as well.

Remediation:

Confirm with the Adrena team the exact decimal format required for OpenPositionWithSwapParams.price and convert the value accordingly before constructing the CPI request.

The transaction above suggests that the expected scale may be 1e10, but additional verification is required to determine whether the precision is fixed across all markets or varies by asset. The integration should use Adrena's canonical price format rather than forwarding Flipper's internal 6-decimal representation directly.

FLIP2-7 | (ADRENA + GMTRADE) INAPPROPRIATE OWNER_SEEDS USED FOR ADRENA POSITION OPENING

Severity:

Medium

Status:

Fixed

Path:

programs/flipper-core/src/adapters/adrena_adapter.rs#L92-L102, programs/flipper-core/src/adapters/adrena_adapter.rs#L210-L216

Description:

In adrena_adapter::open_position(), the owner_seeds parameter is used to validate remaining_accounts[0], while vault_seeds is used to validate remaining_accounts[2] and remaining_accounts[3].

These checks ensure that:

  • remaining_accounts[0] is the PDA derived from owner_seeds.
  • remaining_accounts[2] and remaining_accounts[3] are PDAs derived from vault_seeds.
pub fn open_position(
   ...,
   vault_seeds: &[&[u8]],
   owner_seeds: &[&[u8]],
   ...
) -> Result<CpiOpenResult> {
   ...
 
   super::validate_owner_pda(remaining_accounts, owner_seeds)?;
 
   let vault_pda = Pubkey::create_program_address(vault_seeds, &crate::ID)
      .map_err(|_| error!(FlipperError::InvalidRemainingAccounts))?;
 
   require!(
      remaining_accounts[2].key() == vault_pda,
      FlipperError::InvalidRemainingAccounts
   );
 
   require!(
      remaining_accounts[3].key() == vault_pda,
      FlipperError::InvalidRemainingAccounts
   );
 
   ...
}
 
pub fn validate_signer_vault_pda(
   remaining_accounts: &[AccountInfo],
   vault_seeds: &[&[u8]],
) -> Result<Pubkey> {
   require!(
      !remaining_accounts.is_empty(),
      FlipperError::InvalidRemainingAccounts
   );
 
   let derived = Pubkey::create_program_address(vault_seeds, &crate::ID)
      .map_err(|_| error!(FlipperError::InvalidRemainingAccounts))?;
 
   require!(
      remaining_accounts[0].key() == derived,
      FlipperError::InvalidRemainingAccounts
   );
 
   Ok(derived)
}

For Adrena positions, dex_uses_position_vault_owner() returns true, causing the position vault PDA to be used as owner_seeds, while the collateral vault PDA is used as vault_seeds:

let vault_seeds: &[&[u8]] = &[
   COLLATERAL_VAULT_SEED,
   authority_key.as_ref(),
   mint_bytes,
   &[vault_bump],
];
 
let position_vault_seeds: &[&[u8]] = &[
   POSITION_VAULT_SEED,
   authority_key.as_ref(),
   position_id_le.as_ref(),
   &[position_vault_bump],
];
 
let owner_seeds: &[&[u8]] = if adapters::dex_uses_position_vault_owner(alloc.dex) {
   position_vault_seeds
} else {
   vault_seeds
};
 
pub fn dex_uses_position_vault_owner(dex: DexType) -> bool {
   !matches!(dex, DexType::Phoenix)
}

The issue arises from Adrena's account constraints. According to the IDL for open_or_increase_position_with_swap_long, the owner account has a relationship constraint with both funding_account and collateral_account:

{
 "name": "owner",
 "signer": true,
 "relations": [
  "funding_account",
  "collateral_account"
 ]
}

This relation requires the funding_account and collateral_account token accounts to be owned by the owner account supplied to the instruction.

However, Flipper currently passes the collateral vault as both funding_account and collateral_account. These token accounts are self-owned, as shown below:

#[account(
   init,
   payer = authority,
   token::mint = collateral_mint,
   token::authority = collateral_vault,
   seeds = [
      COLLATERAL_VAULT_SEED,
      authority.key().as_ref(),
      collateral_mint.key().as_ref()
   ],
   bump,
)]
pub collateral_vault: Account<'info, TokenAccount>,

As a result:

  • owner resolves to the position vault PDA.
  • funding_account.owner resolves to the collateral vault PDA.
  • collateral_account.owner resolves to the collateral vault PDA. Since the token account owner does not match the supplied owner account, Adrena's relation constraints are violated.

As the impact attempting to open positions on Adrena will fail during CPI account validation. The instruction will revert before execution, preventing Flipper from successfully opening positions through the Adrena integration.

Note that the similar issue happens to other GMTrade as well.

Remediation:

Consider creating a dedicated token account for each Adrena sub-position and setting its authority to the position vault PDA.

The position-opening flow could then proceed as follows:

  1. Create a position-specific token account owned by the position vault PDA.
  2. Transfer the required collateral from the global collateral vault into this position-specific token account.
  3. Pass the position-specific token account as funding_account and collateral_account during the Adrena CPI. This approach would satisfy Adrena's ownership constraints while preserving Flipper's existing position-vault authorization model.

FLIP2-11 | `EXECUTE_CLOSE_WITH_COLLATERAL` OVERCHARGES THE CLOSING FEE FOR UNSETTLED GMTRADE POSITIONS

Severity:

Medium

Status:

Fixed

Path:

programs/flipper-core/src/instructions/execute_close_with_collateral.rs#L356-L362, programs/flipper-core/src/instructions/execute_close_with_collateral.rs#L464

Description:

When a user closes a position, execute_close_with_collateral iterates through each allocation, calls adapters::dispatch_close_position, and accumulates the closed notional in total_closed_size. For synchronous DEXes, it accumulates the realized closed size (i.e., the amount actually filled by the DEX). For the asynchronous DEX (GMTrade), however, it accumulates the requested close_size, even though the position remains unsettled at that point:

// execute_close_with_collateral.rs L356-L370
match result {
   Ok(cpi_result) => {
      let realized_close = if is_async_dex {
         let sp = &mut position.sub_positions[sp_idx];
         sp.status = SubPositionStatus::PendingClose;
         has_async_close = true;
         close_size
      } else {
         // ...
         actual_closed
      };
      total_closed_size = total_closed_size
         .checked_add(realized_close)
         .ok_or(FlipperError::MathOverflow)?;
      remaining_idx += cpi_result.accounts_consumed;
   }

After that, the closing fee is calculated based on total_closed_size and charged to the user.

// execute_close_with_collateral.rs L461-L470
'close_fee: {
   if close_fee_dbps > 0 && total_closed_size > 0 && !close_only_on_profit {
      ctx.accounts.collateral_vault.reload()?;
      // ...
      let fee_usd = calculate_fee(total_closed_size, close_fee_dbps, close_min_fee)?;
   }
}

However, GMTrade positions remain unsettled and effectively still open. After this transaction, the close request is processed by the GMTrade DEX, but the actual closed margin may be smaller than the requested close_size due to partial rejection or expiration. In such cases, the closing fee has already been charged based on the requested close_size, resulting in users being overcharged and incurring a loss.

Additionally, fee handling is implemented correctly for asynchronous (GMTrade) positions in execute_stop_out. execute_stop_out charges fees only on the notional closed synchronously and defers fees for async positions to confirm_stop_out_close. Then the closing fee for async positions will be calculated using the actual closed size via charge_async_leg_fee().

The similar issue also happens when the user open the position as well.

Remediation:

The fee for async (GMTrade) positions should be calculated separately after the position is settled in confirm_async_fill().

FLIP2-12 | (GMTRADE) USER AND POSITION ACCOUNTS MUST BE INITIALIZED BEFORE CREATING AN ORDER

Severity:

Medium

Status:

Fixed

Path:

programs/flipper-core/src/adapters/gmtrade_adapter.rs#L163-L164

Description:

The GMTrade CreateOrderV2 instruction requires both the user and position accounts to already exist before an order can be created.

Reference: https://github.com/gmsol-labs/gmx-solana/blob/main/programs/store/src/instructions/exchange/order.rs#L187-L236

The relevant account constraints are:

pub struct CreateOrderV2<'info> {
   /// The owner of the order to be created.
   #[account(mut)]
   pub owner: Signer<'info>,
 
   ...
 
   /// User Account.
   #[account(
      mut,
      constraint = user.load()?.is_initialized() @ CoreError::InvalidUserAccount,
      has_one = owner,
      has_one = store,
      seeds = [UserHeader::SEED, store.key().as_ref(), owner.key().as_ref()],
      bump = user.load()?.bump,
   )]
   pub user: AccountLoader<'info, UserHeader>,
 
   ...
 
   #[account(
      mut,
      has_one = store,
      has_one = owner,
      constraint = position.load()?.market_token == market.load()?.meta().market_token_mint @ CoreError::MarketTokenMintMismatched,
      constraint = position.load()?.collateral_token == *params.collateral_token(&*market.load()?) @ CoreError::InvalidPosition,
      constraint = position.load()?.kind()? == params.to_position_kind()? @ CoreError::InvalidPosition,
      seeds = [
         Position::SEED,
         store.key().as_ref(),
         owner.key().as_ref(),
         market.load()?.meta().market_token_mint.as_ref(),
         params.collateral_token(market.load()?.meta()).as_ref(),
         &[params.to_position_kind()? as u8],
      ],
      bump = position.load()?.bump,
   )]
   pub position: Option<AccountLoader<'info, Position>>,
}

Notably, neither account uses an init or init_if_needed constraint. Therefore, both accounts must be created before CreateOrderV2 is invoked.

The user account is initialized through a separate instruction:

https://github.com/gmsol-labs/gmx-solana/blob/3f10e9cd14bd23765567503c2a20e758a3fcbf39/programs/store/src/instructions/user.rs#L14-L30

pub struct PrepareUser<'info> {
   /// Owner.
   #[account(mut)]
   pub owner: Signer<'info>,
 
   /// Store.
   pub store: AccountLoader<'info, Store>,
 
   /// User Account.
   #[account(
      init_if_needed,
      payer = owner,
      space = 8 + UserHeader::space(0),
      seeds = [UserHeader::SEED, store.key().as_ref(), owner.key().as_ref()],
      bump,
   )]
   pub user: AccountLoader<'info, UserHeader>,
 
   pub system_program: Program<'info, System>,
}

Importantly, the owner account must act as the signer when creating the user account.

In Flipper's GMTrade integration, the owner account supplied to CreateOrderV2 is derived from position_vault_seeds:

let position_vault_seeds: &[&[u8]] = &[
   POSITION_VAULT_SEED,
   authority_key.as_ref(),
   position_id_le.as_ref(),
   &[position_vault_bump],
];

This behavior is enforced by validate_owner_pda(), which requires remaining_accounts[0] to equal the PDA derived from owner_seeds (which, for GMTrade positions, resolves to position_vault_seeds):

pub fn validate_owner_pda(
   remaining_accounts: &[AccountInfo],
   owner_seeds: &[&[u8]],
) -> Result<Pubkey> {
   validate_signer_vault_pda(remaining_accounts, owner_seeds)
}
 
pub fn validate_signer_vault_pda(
   remaining_accounts: &[AccountInfo],
   vault_seeds: &[&[u8]],
) -> Result<Pubkey> {
   require!(
      !remaining_accounts.is_empty(),
      FlipperError::InvalidRemainingAccounts
   );
 
   let derived = Pubkey::create_program_address(vault_seeds, &crate::ID)
      .map_err(|_| error!(FlipperError::InvalidRemainingAccounts))?;
 
   require!(
      remaining_accounts[0].key() == derived,
      FlipperError::InvalidRemainingAccounts
   );
 
   Ok(derived)
}

The issue is that Flipper never initializes the GMTrade user or position accounts for this owner PDA before attempting to invoke CreateOrderV2.

Since the owner PDA derived from position_vault_seeds has no mechanism in the current integration to execute GMTrade's account-preparation flow, the required user and position accounts are never created. Consequently, the CreateOrderV2 account constraints cannot be satisfied.

As a result, the protocol is unable to successfully create GMTrade positions, causing all affected position-opening transactions to revert.

Remediation:

Before invoking CreateOrderV2, ensure that the required GMTrade accounts are initialized for the owner PDA used by Flipper.

In particular, gmtrade_adapter::open_position() should perform the necessary preparation steps to create the corresponding user and position accounts before submitting the market order.

FLIP2-16 | (PHOENIX) INCORRECT TRADER SUBACCOUNT PDA DERIVATION DURING TRADER REGISTRATION

Severity:

Medium

Status:

Fixed

Path:

programs/flipper-core/src/adapters/cpi_interfaces/phoenix.rs#L304-L309

Description:

In prepare_phoenix_leg::handle_prepare_phoenix_leg(), the expected_sub account is derived using derive_trader_subaccount_pda():

/// Derive an isolated trader-subaccount PDA `[b"trader", authority, [index]]`.
pub fn derive_trader_subaccount_pda(
   authority: &Pubkey,
   subaccount_index: u8,
) -> (Pubkey, u8) {
   Pubkey::find_program_address(
      &[PHOENIX_TRADER_SEED, authority.as_ref(), &[subaccount_index]],
      &PHOENIX_PROGRAM_ID,
   )
}

The resulting PDA is then passed as the trader_account when constructing the Phoenix register_trader instruction:

let reg = build_register_trader(
   authority_key,
   ctx.accounts.phoenix_global_config.key(),
   authority_key,
   col_vault_key,
   expected_sub, // trader_account
   ctx.accounts.system_program.key(),
   margin::ISOLATED_MAX_POSITIONS,
   subaccount_index,
);

However, according to the Phoenix IDL, the traderAccount PDA is derived using four seeds:

{
 "name": "traderAccount",
 "writable": true,
 "docs": [
   "Trader state PDA to be created; derived from [\"trader\", trader_wallet, [trader_pda_index, trader_subaccount_index]]."
 ],
 "pda": {
  "seeds": [
   {
     "kind": "const",
     "value": [116,114,97,100,101,114]
   },
   {
     "kind": "account",
     "path": "traderWallet"
   },
   {
     "kind": "arg",
     "path": "params.traderPdaIndex"
   },
   {
     "kind": "arg",
     "path": "params.traderSubaccountIndex"
   }
  ]
 }
}

Notably, the PDA derivation includes both:

  • params.traderPdaIndex
  • params.traderSubaccountIndex The Flipper implementation only includes subaccount_index and omits traderPdaIndex entirely. Consequently, the PDA derived by derive_trader_subaccount_pda() does not match the PDA expected by Phoenix.

As a result, the expected_sub account passed to register_trader is incorrect, causing the instruction to fail PDA validation.

Remediation:

Update derive_trader_subaccount_pda() to derive the trader account using the same seed layout defined by Phoenix:

[b"trader", trader_wallet, trader_pda_index, trader_subaccount_index]

FLIP2-17 | (PHOENIX) UNABLE TO DEPOSIT FUNDS TO PHOENIX DUE TO MISSING PERMISSIONS

Severity:

Medium

Status:

Fixed

Path:

programs/flipper-core/src/instructions/prepare_phoenix_leg.rs#L108-L157

Description:

The function prepare_phoenix_leg::handle_prepare_phoenix_leg() performs the onboarding flow before opening a position on Phoenix. It executes two instructions:

  • register_trader(): Registers a new trader account on Phoenix for the collateral vault.
  • deposit_funds(): Deposits funds from Flipper's collateral vault into Phoenix.
let reg = build_register_trader(
   authority_key,
   ctx.accounts.phoenix_global_config.key(),
   authority_key,
   col_vault_key,
   expected_sub, // ["trader", trader_wallet, [trader_pda_index, trader_subaccount_index]]
   ctx.accounts.system_program.key(),
   margin::ISOLATED_MAX_POSITIONS, /*1*/
   subaccount_index,
);
 
...
 
let dep = build_deposit_funds(
   authority_key,
   ctx.accounts.phoenix_global_config.key(),
   col_vault_key,
   col_vault_key,
   expected_sub,
   ctx.accounts.phoenix_global_vault.key(),
   ctx.accounts.token_program.key(),
   amount,
);

However, Phoenix does not allow newly registered traders to deposit funds by default. According to the Phoenix delegated onboarding documentation:

https://docs.phoenix.trade/sdk/delegated-onboarding

a trader must be granted specific capabilities before it can perform certain actions.

In particular, a trader account must have the TRADER_CAPABILITY_CAN_DEPOSIT = 1 << 4 flag set in its capabilities field before it is allowed to deposit funds into Phoenix. The register_trader() instruction does not automatically grant this capability.

As a result, the trader account created by register_trader() lacks permission to perform deposit_funds(). Consequently, the subsequent deposit_funds() instruction fails, causing handle_prepare_phoenix_leg() to revert and preventing users from opening positions on Phoenix.

Remediation:

Consider integrating with Phoenix's trader-onboarder program:

https://docs.phoenix.trade/sdk/delegated-onboarding#program-pda-onboarding

This onboarding path aligns closely with Flipper's architecture because it allows permissions to be delegated to a program-derived authority rather than requiring a backend-held signer.

As described in the Phoenix documentation:

[b"trader", trader_wallet, trader_pda_index, trader_subaccount_index]

Using the trader-onboarder flow would allow Flipper to provision trader accounts with the required capabilities and complete the onboarding process entirely on-chain.

FLIP2-20 | INCORRECT TOTAL_SIZE_USD WHEN CREATING A NEW POSITION

Severity:

Medium

Status:

Fixed

Path:

programs/flipper-core/src/instructions/execute_trade_with_collateral.rs#L629-L643, programs/flipper-core/src/instructions/execute_limit_order_intent.rs#L637-L650

Description:

Currently, there are two paths to create a new position: the ExecuteTradeWithCollateral and ExecuteLimitOrderIntent instructions. Both of them assign the new position's total_size_usd field to the same value, effective_size.

The effective_size parameter is calculated as follows:

let total_size: u64 = params
   .allocations
   .iter()
   .map(|a| a.size_usd)
   .try_fold(0u64, |acc, s| acc.checked_add(s))
   .ok_or(FlipperError::MathOverflow)?;
 
...
 
let margin = total_size
   .checked_div(params.leverage as u64)
   .ok_or(FlipperError::DivisionByZero)?;
 
require!(
   margin > total_fee,
   FlipperError::InsufficientCollateralForFee
);
let effective_collateral = margin
   .checked_sub(total_fee)
   .ok_or(FlipperError::MathOverflow)?;
let effective_size = effective_collateral
   .checked_mul(params.leverage as u64)
   .ok_or(FlipperError::MathOverflow)?;
 
...
 
position.total_size_usd = effective_size;

The position.total_size_usd (or effective_size) field is crucial, as it is used to determine whether the position is healthy when executing a stop-out or withdrawing collateral.

This value can be seen as the position size after deducting the Flipper fee. However, including the Flipper fee in the position size does not make sense because the total_fee is charged directly from the user's collateral vault instead of being deducted from the position size. As a result, effective_size does not reflect the true size of the position opened on the external DEX. In particular, effective_size will be smaller than the actual total size opened on the external DEX.

As a result, when executing a stop-out, the position's health may appear larger than expected, causing the position to become stoppable later than intended and reducing the effectiveness of the protocol's protection. Similarly, the user may be able to withdraw more collateral than expected.

One additional detail is that positions containing an async sub-position are not affected by this issue. When the ConfirmAsyncFill instruction is triggered, the position's total_size_usd is recalculated by summing the sizes of all sub-positions without considering the fee.

position.total_size_usd = position.total_size_with_pending();

Remediation:

Consider setting the position's total_size_usd equal to total_size when creating a new position.

- position.total_size_usd = effective_size;
+ position.total_size_usd = total_size;

FLIP2-22 | REDUNDANT FILL-PRICE CHECK IN CONFIRM_ASYNC_FILL COULD PERMANENTLY STRAND USER FUNDS

Severity:

Medium

Status:

Fixed

Path:

programs/flipper-core/src/instructions/confirm_async_fill.rs#L170-L181

Description:

When a keeper confirms an async GMTrade fill, the program verifies that the reported fill_price is within max_slippage_bps of the current oracle price:

// Verify fill_price is within max_slippage_bps of oracle price
if oracle_price > 0 {
   let max_slippage = config.max_slippage_bps as u128;
   let fill_128 = params.fill_price as u128;
   let oracle_128 = oracle_price as u128;
   let deviation = if fill_128 > oracle_128 {
      (fill_128 - oracle_128) * 10_000 / oracle_128
   } else {
      (oracle_128 - fill_128) * 10_000 / oracle_128
   };
   require!(deviation <= max_slippage, FlipperError::SlippageExceeded);
}

The issue is that the comparison uses the wrong reference price.

oracle_price is obtained from read_pyth_price(), which returns the current Pyth price at the time the keeper calls confirm_async_fill. In contrast, fill_price is the actual execution price on GMTrade, which was determined when the order was filled, potentially seconds or minutes earlier.

These two prices are unrelated. The user's slippage tolerance applies when the order is submitted, not when it is later confirmed. As a result, this check effectively requires the market price to remain within max_slippage_bps between execution and confirmation. If the market moves beyond that threshold, an honest keeper reporting the correct fill_price will be rejected.

This is a realistic scenario rather than a theoretical one. With the default max_slippage_bps of 500 (5%) and an async confirmation window of ASYNC_FILL_TIMEOUT_SECS = 120, a price movement of more than 5% within two minutes is sufficient to cause confirm_async_fill to revert.

For example,

  1. A user opens a position on GMTrade with an acceptable execution price range of $0.90–$1.10.
  2. The order is successfully filled at $1.00.
  3. Before the keeper submits confirm_async_fill, the market price increases to $10.00.
  4. The keeper correctly reports fill_price = $1.00. The reported fill price now differs from the current oracle price by approximately 90%, causing the slippage check to fail and confirm_async_fill to revert, even though the order executed exactly as intended.

Once confirmation is blocked, the sub-position remains in the PendingFill state until the timeout expires. After ASYNC_FILL_TIMEOUT_SECS elapses, anyone can call timeout_async_fill(). For PendingFill sub-positions, this instruction immediately marks the sub-position as FillFailed without verifying whether the GMTrade order was actually executed.

As a result, since no instruction can operate on a FillFailed sub-position, the user permanently loses the ability to manage or close the GMTrade position through Flipper, leaving the collateral stranded.

Remediation:

Consider validating fill_price against the price when the position is created instead. Alternatively, consider removing the params.fill_price parameter entirely, since confirm_async_fill does not use it for anything other than emitting the AsyncFillConfirmed event.

FLIP2-23 | TIMEOUTASYNCFILL SHOULD NOT BE PERMISSIONLESS

Severity:

Medium

Status:

Fixed

Path:

programs/flipper-core/src/instructions/timeout_async_fill.rs#L72-L76

Description:

The TimeoutAsyncFill instruction marks an async sub-position as FillFailed, indicating that opening the async sub-position has failed. Currently, the instruction is permissionless and can be called by anyone once the sub-position is considered "expired."

A sub-position is considered expired when the current timestamp exceeds its pending_fill_deadline:

// Verify deadline has passed
require!(
   clock.unix_timestamp > position.pending_fill_deadline,
   FlipperError::FillDeadlineNotReached
);

The pending_fill_deadline is simply computed by adding two minutes to the timestamp when the open/close request is submitted:

position.pending_fill_deadline = clock.unix_timestamp + ASYNC_FILL_TIMEOUT_SECS as i64;

However, this two-minute timeout is defined by Flipper and is not enforced by GMTrade. In practice, a GMTrade order may remain pending for longer than two minutes depending on its acceptable execution price.

If this occurs, anyone can call TimeoutAsyncFill before the GMTrade order is actually executed. The sub-position will be marked as FillFailed, even though the order may still be filled later on GMTrade. Since Flipper provides no way to manage or close a FillFailed sub-position, the user permanently loses control of the corresponding GMTrade position, potentially resulting in a complete loss of the funds allocated to that sub-position.

Remediation:

Consider restricting TimeoutAsyncFill to a trusted or whitelisted caller instead of allowing anyone to invoke it. This prevents premature timeouts from being triggered before the actual state of the order on GMTrade has been verified.

FLIP2-26 | EFFECTIVE_COLLATERAL_USD_E6 IS CALCULATED INCORRECTLY WHEN WITHDRAWING COLLATERAL

Severity:

Medium

Status:

Fixed

Path:

programs/flipper-core/src/instructions/withdraw_collateral.rs#L364

Description:

In the WithdrawCollateral instruction, effective_collateral_usd_e6 represents the user's total effective collateral. Currently, it is calculated by summing post_withdrawal_usd_e6 and dex_collateral_usd, where:

  • post_withdrawal_usd_e6 is the balance of the collateral vault after the withdrawal.
  • dex_collateral_usd is the collateral currently held by positions on the external DEXes.
while i + 1 < remaining.len() {
   ...
 
   aggregate_size_usd = aggregate_size_usd.saturating_add(pos.total_size_usd as u128);
 
   ...
}
 
let post_withdrawal_usd_e6: u64 = if is_stable {
   post_withdrawal_balance
} else ...
 
...
 
let effective_collateral_usd_e6 = post_withdrawal_usd_e6.saturating_add(dex_collateral_usd);

However, summing only these two components is insufficient. post_withdrawal_usd_e6 only accounts for the balance of the collateral vault corresponding to the mint being withdrawn. It does not include the balances of the user's other collateral vaults, even though those vaults also contribute to the user's total collateral.

As a result, effective_collateral_usd_e6 may underestimate the user's actual collateral, causing healthy withdrawals to be rejected.

Remediation:

Include the balances of all collateral vaults when calculating effective_collateral_usd_e6, rather than only the vault corresponding to the withdrawal mint.

FLIP2-27 | WITHDRAWAL MARGIN CHECK ASSUMES THE COLLATERAL_VAULT BACKS OPEN POSITIONS, BUT DEX COLLATERAL IS ISOLATED

Severity:

Medium

Status:

Fixed

Path:

programs/flipper-core/src/instructions/withdraw_collateral.rs#L366-L374

Description:

The withdraw_collateral instruction prevents a withdrawal unless the user's remaining collateral satisfies the initial margin requirement:

let effective_collateral_usd_e6 = post_withdrawal_usd_e6.saturating_add(dex_collateral_usd);
 
require!(
   margin::meets_initial_margin(
      effective_collateral_usd_e6,
      aggregate_size_u64,
      worst_margin_bps,
      config.initial_margin_buffer_bps,
   ),
   FlipperError::WithdrawalExceedsMargin
);

Here, effective_collateral_usd_e6 is calculated as the sum of:

  • post_withdrawal_usd_e6: the remaining balance in the user's collateral vault after the withdrawal.
  • dex_collateral_usd: the collateral currently locked in positions on external DEXes. This calculation assumes that the remaining balance in the collateral vault continues to back the user's open positions. However, this assumption does not hold under the current architecture.

Once a position is opened, its collateral is transferred to the external DEX and remains there for the lifetime of the position. There is no mechanism to replenish position collateral from the collateral vault, nor can the DEX access the vault during liquidation or while funding fees accrue. The collateral vault is controlled exclusively by Flipper's PDA and can only be debited through Flipper-signed CPIs.

As a result, any idle balance remaining in the collateral vault is independent of the risk of the user's open positions. Whether those positions gain, lose, or are liquidated has no effect on the vault balance, and the vault balance cannot be used to support those positions.

Consequently, requiring:

post_withdrawal_usd_e6 + dex_collateral_usd >= required_margin

effectively enforces a cross-margin model, even though the protocol operates using isolated margin. This can unnecessarily reject withdrawals that are otherwise safe because the funds remaining in the collateral vault are not actually backing any open position.

Remediation:

Reconsider whether the withdrawal margin check is necessary under the current isolated-margin design. Since position solvency is enforced when the position is opened and maintained by the external DEX's liquidation engine, the remaining collateral vault balance does not contribute to position safety after the collateral has been transferred to the DEX.

If the protocol is intended to remain isolated-margin, consider removing this check. A vault-based margin check would only be appropriate if future designs allow the collateral vault to participate in covering position losses or otherwise back open positions.

FLIP2-34 | GLOBAL SETTLEMENT CAN DEADLOCK ON ORDINARY LIVE DEX POSITIONS

Severity:

Medium

Status:

Fixed

Path:

flipper-jun-26/programs/flipper-core/src/instructions/settle_position.rs:handle_settle_position#L46-L121

Description:

settle_position is the permissionless crank used after the authority triggers global settlement. The intended flow is: trigger_global_settlement stores snapshot prices and halts the protocol, then anyone can call settle_position to wind down active user positions at those prices. This matters most when users are offline or not cooperating, since global settlement is meant to be the last-resort shutdown path.

In the healthy case, a live position should not be able to block the shutdown. If the position still has exposure on Adrena, Sour, Phoenix, GMTrade, or another supported DEX, the settlement flow should either close that leg using the Flipper-owned signer, or have another permissionless path that does it before the position is marked settled.

Instead, settle_position only accepts legs that are already flat. For every live sub-position it decodes the external DEX account and requires the DEX size to already be zero:

let decoded =
   custody::decode_leg_custody(dex, ctx.remaining_accounts, &dex_position_account)?;
require!(decoded.size_usd == 0, FlipperError::StopOutCloseNotSettled);

So a normal healthy open leg makes settlement revert with StopOutCloseNotSettled. The handler never sends a DEX close CPI. The usual close path, execute_close_with_collateral, does close DEX exposure, but it is tied to position.user == authority.key() and requires the position owner to sign. That is not enough for an emergency settlement path.

Exploit scenario:

  1. An attacker opens a small healthy position with a live DEX leg.
  2. The protocol authority triggers global settlement, which halts the protocol.
  3. The attacker refuses to sign the normal user close.
  4. A cranker calls settle_position, but the DEX custody check sees size_usd > 0 and the call reverts.
  5. After the grace period, cancel_global_settlement is no longer available through the normal lifecycle, leaving the emergency wind-down stuck unless the admin uses an out-of-band workaround.

Remediation:

Add a permissionless settlement path that can close live DEX legs with the correct Flipper signer before marking the position settled. If settlement is meant to require pre-flattened DEX legs, add a separate non-user-signed way to flatten healthy live positions before the grace period can expire.

FLIP2-35 | GLOBAL HALT CAN ORPHAN LIVE DEX POSITIONS THROUGH EMERGENCY CLOSE

Severity:

Medium

Status:

Fixed

Path:

flipper-jun-26/programs/flipper-core/src/instructions/trigger_global_settlement.rs:handle_trigger_global_settlement#L45-L76

Description:

handle_trigger_global_settlement starts the protocol's emergency wind-down. The authority creates the settlement state, snapshots prices, and then halts the protocol by setting protocol_config.emergency_halt = true. This is an admin-only path, but once it is triggered it affects every user position that still needs to be settled.

For GMTrade opens, the normal flow is asynchronous. Flipper first records the GMTrade leg as PendingFill; after GMTrade actually fills the order, an authorized keeper calls confirm_async_fill to bind the fill report to the decoded GMTrade position size and move the leg to Open. From there, the position can go through the normal close or settlement flow.

During global settlement, already-started async work still needs a safe way to finish. A GMTrade leg that was filled before the halt should either be confirmed into Flipper state and then settled, or closed through a path that proves there is no live GMTrade exposure left.

Instead, global settlement halts the protocol immediately:

config.emergency_halt = true;

confirm_async_fill then rejects before it can resolve the pending fill:

require!(!config.is_halted(), FlipperError::ProtocolHalted);

This leaves a filled GMTrade open stuck in PendingFill. settle_position cannot process it either, because its account constraint only accepts Open, PartiallyFilled, and PartiallyOpen aggregates. The timeout fallback is not safe for this case: timeout_async_fill only proves DEX flatness for PendingClose, and finalize_gmtrade_leg can later mark a PendingFill timeout as FillFailed even though the GMTrade position may already be live under Flipper's position_vault.

Exploit scenario:

  1. A user opens a GMTrade-backed position, and Flipper records the aggregate as PositionStatus::PendingFill.
  2. GMTrade fills the order and creates live DEX exposure, but the keeper has not called confirm_async_fill yet.
  3. The protocol authority triggers global settlement through handle_trigger_global_settlement.
  4. confirm_async_fill now reverts with ProtocolHalted, while settle_position rejects the still-PendingFill aggregate.
  5. The position is stuck. If the timeout/finalize route is used instead, Flipper can mark the fill as failed without first proving the live GMTrade exposure was closed.

Remediation:

Allow confirm_async_fill during active settlement for pending fills that were created before the halt, then settle the resulting open position through the normal DEX-flat checks. Also require the pending-fill timeout path to prove that the GMTrade order or position is absent or flat before finalizing it as FillFailed.

FLIP2-36 | GLOBAL SETTLEMENT DROPS PRICES FOR MARKETS ABOVE INDEX 63

Severity:

Medium

Status:

Fixed

Path:

flipper-jun-26/programs/flipper-core/src/instructions/trigger_global_settlement.rs:handle_trigger_global_settlement#L45-L77

Description:

handle_trigger_global_settlementis the admin entrypoint for the protocol's emergency wind-down flow. It creates the singletonSettlementState, stores one snapshot price per market, and sets ProtocolConfig.emergency_halt = true. After that, anyone should be able to call settle_position` to settle open positions at their recorded snapshot price.

The expected behavior is that every supported market can be included in the snapshot. The protocol currently defines 85 contiguous MarketType values from 0..84, so a settlement price for XpdUsdPerp at index 84 should be stored the same way as a price for SolPerp at index 0. If a submitted market index cannot be stored, the trigger should reject the transaction instead of pretending the snapshot is complete.

That does not happen. SettlementState only has 64 price slots:

pub settlement_prices: [i64; 64],

trigger_global_settlement also silently skips entries outside that array:

for entry in params.settlement_prices.iter() {
   if (entry.market_idx as usize) < state.settlement_prices.len() {
      state.settlement_prices[entry.market_idx as usize] = entry.price;
   }
}

So prices for markets 64..84 are accepted by the API input but never saved. Later, settle_position calls get_settlement_price(position.market as u8) and receives None for those markets, which makes the call revert with InvalidPrice. Once the grace period has expired, the admin can no longer use the normal cancel path to close the singleton settlement PDA, so the settlement flow is stuck for any affected market.

Example flow:

  1. A user has an active position in XpdUsdPerp (market_idx = 84).
  2. The admin triggers global settlement and submits a snapshot price for market 84.
  3. The trigger succeeds, but the price is skipped because the array only has indexes 0..63.
  4. After the grace period, a cranker calls settle_position for the XPD position.
  5. get_settlement_price(84) returns None, settle_position reverts, and the active settlement PDA cannot be repaired through the normal lifecycle. This breaks the emergency settlement mechanism for all enabled markets above index 63. It can also keep the protocol in a halted state unless the admin uses a separate manual config update, which still does not fix the active settlement account.

Remediation:

Make the settlement price storage cover the full supported market set, and derive its size from the market count instead of a stale literal. Also reject any out-of-range settlement price entry in trigger_global_settlement so missing snapshots cannot be silently dropped.

FLIP2-42 | INCOMPLETE TERMINAL STATUS CHECK TEMPORARILY LOCKS LIQUIDATED POSITIONS

Severity:

Medium

Status:

Fixed

Path:

perps-ag-program-core/programs/flipper-core/src/instructions/handle_liquidation.rs:handle_handle_liquidation#L44-L186

Description:

The permissionless handle_handle_liquidation function records a sub-position that was liquidated by an external DEX. After confirming that the DEX position is flat, it marks the leg as Liquidated and updates the status of the aggregated position.

Once every leg is terminal, the aggregate should also move to a terminal status and the user's active_positions_count should decrease. This lets the position PDA close, releases any SOL left in its vault, and frees an active-position slot.

However, the terminal check only accepts Closed and Liquidated legs:

matches!(
   sp.status,
   SubPositionStatus::Closed | SubPositionStatus::Liquidated
)

StoppedOut and FillFailed are also terminal, but are missing from this check. If the final live leg is liquidated while a sibling is in either state, the aggregate is incorrectly set to PartiallyOpen. There is no exposure left, yet the position cannot be closed and its active-position slot and residual vault SOL stay locked. The owner can recover by setting an already-satisfied TP/SL and asking a registered keeper to execute the trigger flow, but this extra step should not be needed.

An example flow is:

  1. A user opens a two-leg position.
  2. One leg fails to fill and reaches FillFailed.
  3. The external DEX liquidates the remaining live leg.
  4. A cranker calls handle_handle_liquidation, which marks that leg Liquidated.
  5. Because FillFailed is not recognized as terminal, the aggregate becomes PartiallyOpen and immediate cleanup is blocked.

Remediation:

Treat every non-live sub-position as terminal, for example by checking that no leg returns is_open(). Keep the separate all_liquidated check so fully liquidated aggregates still receive the correct status.

FLIP2-44 | SOUR'S ISOLATED MARGIN IS OMITTED FROM STOP-OUT HEALTH CHECKS

Severity:

Medium

Status:

Fixed

Path:

programs/flipper-core/src/adapters/custody.rs:decode_sour#L705-L752

Description:

The decode_sour function reads a live Sour position and returns the collateral, size, entry price, and direction used by Flipper's risk checks. Both keeper stop-outs and triggered TP/SL orders rely on this data, so the issue affects users with an open Sour position.

For an isolated position, the health check should use the position's full equity. Sour splits this collateral between the free USDC balance in TraderAccount.usdc_collateral and the margin reserved in Position.collateral_locked, so both values need to be counted.

However, decode_sour only reads the free balance from the trader account:

let collateral_usd =
   sour_bound_collateral(data, leg_accounts, owner, size_usd)?;

It never reads collateral_locked from the position. This understates the user's equity and can make a healthy high-leverage position look unsafe. For example, a $10,000 position at 14x can have about $711 of real equity against a $600 stop-out threshold, while Flipper reports only about $561 after Sour reserves part of the margin.

An affected flow is:

  1. A user opens a high-leverage Sour position in the default isolated margin mode.
  2. Sour moves part of the deposited collateral into Position.collateral_locked, but Flipper checks only the remaining free balance.
  3. Flipper incorrectly marks the position as unsafe. A registered keeper can then close it early through execute_stop_out if stop-out was approved.
  4. If stop-out was not approved, a reached TP/SL instead reverts with StopOutRequired, leaving the position open until the user closes it manually or Sour liquidates it. The first case causes unnecessary fees, execution loss, and loss of the intended exposure. The second can let losses continue past the user's configured stop price.

Remediation:

Make the Sour decoder aware of the margin mode and include Position.collateral_locked in isolated-position equity using checked arithmetic. Update the Sour mock and add high-leverage stop-out and TP/SL tests against the production account layout.

FLIP2-25 | LACK OF VERIFICATION OF ALLOCATION_PERCENT WHEN OPENING A NEW POSITION VIA EXECUTETRADEWITHCOLLATERAL

Severity:

Informational

Status:

Fixed

Path:

programs/flipper-core/src/instructions/execute_trade_with_collateral.rs#L578

Description:

Unlike the ExecuteLimitOrderIntent instruction, which calculates each sub-position's size from the allocation percentage (intent_allocs[i].percent),

for i in 0..intent_alloc_len {
   let pct = intent_allocs[i].percent as u64;
   let leg_size = (intent_size_usd as u128)
      .checked_mul(pct as u128)
      .ok_or(FlipperError::MathOverflow)?
      .checked_div(100)
      .ok_or(FlipperError::MathOverflow)?;

the ExecuteTradeWithCollateral instruction allows the caller to specify each sub-position's size directly via params.allocations[i].size_usd.

sub_positions[sub_positions_len as usize] = SubPosition {
   dex: alloc.dex,
   dex_position_account: cpi_result.position_account,
   allocation_percent: alloc.allocation_percent,
   size_usd: alloc.size_usd,

However, the instruction does not validate that alloc.size_usd is consistent with alloc.allocation_percent. In particular, there is no check that:

alloc.size_usd == total_size * alloc.allocation_percent / 100

For example, suppose a user wants to open a $1,000 long position entirely on GMTrade. The following allocation is currently accepted:

TradeAllocation {
   dex: GMTrade,
   size_usd: 1000,
   allocation_percent: 67,
}

In this example, allocation_percent should be 100%, not 67%. As a result, the recorded allocation_percent no longer reflects the actual proportion of the sub-position within the overall position.

Remediation:

Validate that each allocation is consistent with its declared percentage. In particular, enforce that:

alloc.size_usd == total_size * alloc.allocation_percent / 100

before creating the position.

FLIP2-21 | STOP-OUT APPROVAL CANNOT BE REVOKED

Severity:

Informational

Status:

Fixed

Path:

programs/flipper-core/src/instructions/approve_stop_out.rs#L41

Description:

Function apply_stop_out::apply_stop_out_approval only ever sets stop_out_approved = true:

fn apply_stop_out_approval(user_account: &mut UserAccount, now: i64) {
   user_account.stop_out_approved = true;
   user_account.stop_out_approved_at = now;
   user_account.last_activity = now;
}

This is the only place in the program that writes to stop_out_approved. The flag is never reset to false: there is no revoke_stop_out instruction, and execute_stop_out does not clear it after use.

As a result, once a user approves stop-outs, the approval becomes permanent. The user can never revoke their consent, permanently authorizing permissionless keepers to execute stop-outs on all current and future positions associated with the UserAccount.

Remediation:

One approach is to add an approved: bool parameter to the approval instruction so that users can both grant and revoke permission:

user_account.stop_out_approved = approved;
user_account.stop_out_approved_at = now;
user_account.last_activity = now;

This enables users to withdraw their consent at any time instead of permanently authorizing permissionless keeper stop-outs.

FLIP2-24 | INITIALIZETREASURY SHOULD VERIFY THE AUTHORITY ACCOUNT

Severity:

Informational

Status:

Fixed

Path:

programs/flipper-core/src/instructions/initialize_treasury.rs#L33

Description:

Unlike InitializeInsuranceVault, which requires the signer authority to match protocol_config.authority, InitializeTreasury does not perform any authorization check on the authority account.

#[derive(Accounts)]
pub struct InitializeTreasury<'info> {
   #[account(
      init,
      payer = authority,
      space = Treasury::LEN,
      seeds = [TREASURY_SEED],
      bump,
   )]
   pub treasury: Account<'info, Treasury>,
 
   /// The USDC mint
   pub usdc_mint: Account<'info, Mint>,
 
   /// Treasury token account for collecting protocol fees (SPL)
   #[account(
      init,
      payer = authority,
      token::mint = usdc_mint,
      token::authority = treasury,
      seeds = [TREASURY_VAULT_SEED],
      bump,
   )]
   pub treasury_token_account: Account<'info, TokenAccount>,
 
   // [$audit-low] need to check if we should require authority is from protocol_config (like insurance_fund)
   #[account(mut)]
   pub authority: Signer<'info>,
 
   pub token_program: Program<'info, Token>,
   pub system_program: Program<'info, System>,
   pub rent: Sysvar<'info, Rent>,
}

As a result, if the protocol has not been initialized properly, an attacker can front-run the InitializeTreasury instruction and provide an arbitrary usdc_mint account, causing the treasury vault to be initialized with an incorrect mint.

Remediation:

Require the signer authority to match protocol_config.authority, consistent with the authorization check performed by InitializeInsuranceVault.

FLIP2-4 | LEGACY SINGLETON VAULTS ARE UNUSED AND REDUNDANT

Severity:

Informational

Status:

Fixed

Path:

programs/flipper-core/src/instructions/initialize_insurance_vault.rs#L26-L34, programs/flipper-core/src/instructions/initialize_treasury.rs#L21-L30

Description:

The protocol currently maintains two generations of fee vaults. The legacy singleton vaults are initialized during deployment but are no longer used by the active fee collection flow:

AccountSeedsCreated by
Legacy treasury vault[TREASURY_VAULT_SEED]initialize_treasury
Legacy insurance vault[INSURANCE_VAULT_SEED]initialize_insurance_vault

In the current implementation, all fee-collecting instructions route treasury and insurance fees to per-mint vaults derived using the collateral mint address. No instruction deposits funds into the legacy singleton vaults derived from [TREASURY_VAULT_SEED] or [INSURANCE_VAULT_SEED].

As a result, these legacy vaults remain unused while still requiring initialization and maintenance, increasing code complexity and creating ambiguity around the protocol's fee storage model.

/// Treasury token account for collecting protocol fees (SPL)
#[account(
   init,
   payer = authority,
   token::mint = usdc_mint,
   token::authority = treasury,
   seeds = [TREASURY_VAULT_SEED],
   bump,
#[account(
   init,
   payer = authority,
   token::mint = usdc_mint,
   token::authority = insurance_fund,
   seeds = [INSURANCE_VAULT_SEED],
   bump,
)]
pub insurance_vault: Account<'info, TokenAccount>,

Remediation:

Remove initialize_insurance_vault and the legacy vault initialization logic from initialize_treasury. The singleton vaults derived from [TREASURY_VAULT_SEED] and [INSURANCE_VAULT_SEED] should be fully deprecated in favor of the per-mint vault architecture.

Table of contents