Skip to content

order_handler: liquidation keeper fee leaves the pool with no data_store ledger debit, silently inflating pool_amount vs real balance #540

Description

@abayomicornelius

Problem

order_handler::liquidate_position (contracts/order_handler/src/lib.rs, ~lines 1575-1601) pays the liquidation keeper's execution fee by withdrawing real tokens directly from the market pool, but never debits any data_store accounting key for that withdrawal:

let mut fee_to_transfer: i128 = 0;
if keeper_execution_fee > 0 {
    let keeper_fee_i128 = keeper_execution_fee as i128;
    fee_to_transfer = if keeper_fee_i128 <= position.collateral_amount {
        keeper_fee_i128
    } else {
        position.collateral_amount
    };

    if fee_to_transfer > 0 {
        MarketTokenClient::new(&env, &market_props.market_token).withdraw_from_pool(
            &handler, &collateral_token, &keeper, &fee_to_transfer,
        );
    }
}

withdraw_from_pool (contracts/market_token/src/lib.rs) is a pure SEP-41 token transfer out of the market contract's real balance — it has no concept of pool_amount/collateral_sum and touches no data_store key. Immediately afterward, decrease_position is called (libs/decrease_position_utils/src/lib.rs, decrease_position) and re-reads the position fresh from persistent storage, whose collateral_amount was never reduced by fee_to_transfer (the local position variable read earlier in liquidate_position was never written back). So decrease_position computes collateral_delta from the full, un-reduced collateral_amount (line ~256-265), debits collateral_sum by that full collateral_delta (line ~312-318), and pays output_amount — built from that same full collateral_delta (line ~267) — in full to the trader via its own separate withdraw_from_pool call (line ~350-355).

Why it matters

Every liquidation with a nonzero liquidation_execution_fee configured causes the market pool's real SEP-41 token balance to shrink by fee_to_transfer + output_amount, while the tracked ledger (pool_amount + collateral_sum in data_store) is only debited for output_amount's worth of collateral (via collateral_sum) — fee_to_transfer leaves with no corresponding ledger entry anywhere. Two consequences, both silent:

  1. The trader isn't charged for the fee at all. output_amount is computed from the full, unreduced collateral_amount, so the position holder receives exactly what they would have received if the liquidation fee were zero — the pool (not the trader) absorbs the entire cost.
  2. The pool's tracked accounting permanently overstates its real backing. market_utils::get_pool_value (libs/market_utils/src/lib.rs, ~line 501) computes GM token NAV directly from the pool_amount ledger keys read via get_u128_batch, not from the market contract's live token balance. Since pool_amount is never decremented for fee_to_transfer, every liquidation fee paid this way inflates recorded pool value relative to what the contract actually holds — the exact class of drift issue fee_handler: FEE_KEEPER can claim more fees than are actually available #254's balance-before-transfer guard exists to catch in fee_handler, except here it corrupts the primary ledger key itself rather than a single claimable balance, and nothing catches it.

Over the life of a market, the cumulative sum of every liquidation's fee_to_transfer becomes an unrecoverable, silently-growing gap between pool_amount-derived NAV and real backing.

Scope

In scope

  • contracts/order_handler/src/lib.rs::liquidate_position — the keeper-fee withdrawal and its interaction with the subsequent decrease_position call.

Out of scope

Suggested fix

Before calling decrease_position, persist the fee deduction against the position so decrease_position's own collateral_delta/output_amount computation reflects it — e.g. write position.collateral_amount -= fee_to_transfer back to storage prior to the call — and debit collateral_sum (or pool_amount, whichever bucket conceptually backs the keeper-fee withdrawal) by fee_to_transfer at the same point withdraw_from_pool is called, mirroring how every other pool-affecting transfer in this codebase pairs its token movement with a data_store ledger update.

Verification

cargo test -p order-handler

Add a test that liquidates a position with a nonzero liquidation_execution_fee, then reads pool_amount/collateral_sum from data_store afterward and asserts they equal the market contract's real live token balance for that token (not merely that the keeper received the fee, which the existing tests in liquidation_handler already check).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions