You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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:
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:
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.
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
decrease_position's own pnl/fee/collateral bookkeeping in isolation, which is internally consistent — the bug is specifically the interaction between the two withdrawals.
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).
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 anydata_storeaccounting key for that withdrawal: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 ofpool_amount/collateral_sumand touches nodata_storekey. Immediately afterward,decrease_positionis called (libs/decrease_position_utils/src/lib.rs,decrease_position) and re-reads the position fresh from persistent storage, whosecollateral_amountwas never reduced byfee_to_transfer(the localpositionvariable read earlier inliquidate_positionwas never written back). Sodecrease_positioncomputescollateral_deltafrom the full, un-reducedcollateral_amount(line ~256-265), debitscollateral_sumby that fullcollateral_delta(line ~312-318), and paysoutput_amount— built from that same fullcollateral_delta(line ~267) — in full to the trader via its own separatewithdraw_from_poolcall (line ~350-355).Why it matters
Every liquidation with a nonzero
liquidation_execution_feeconfigured causes the market pool's real SEP-41 token balance to shrink byfee_to_transfer + output_amount, while the tracked ledger (pool_amount+collateral_sumindata_store) is only debited foroutput_amount's worth of collateral (viacollateral_sum) —fee_to_transferleaves with no corresponding ledger entry anywhere. Two consequences, both silent:output_amountis computed from the full, unreducedcollateral_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.market_utils::get_pool_value(libs/market_utils/src/lib.rs, ~line 501) computes GM token NAV directly from thepool_amountledger keys read viaget_u128_batch, not from the market contract's live token balance. Sincepool_amountis never decremented forfee_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 infee_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_transferbecomes an unrecoverable, silently-growing gap betweenpool_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 subsequentdecrease_positioncall.Out of scope
decrease_position's own pnl/fee/collateral bookkeeping in isolation, which is internally consistent — the bug is specifically the interaction between the two withdrawals.market_tokeninstead oforder_vault), which correctly solved the "wrong contract balance" problem this issue does not dispute.Suggested fix
Before calling
decrease_position, persist the fee deduction against the position sodecrease_position's owncollateral_delta/output_amountcomputation reflects it — e.g. writeposition.collateral_amount -= fee_to_transferback to storage prior to the call — and debitcollateral_sum(orpool_amount, whichever bucket conceptually backs the keeper-fee withdrawal) byfee_to_transferat the same pointwithdraw_from_poolis called, mirroring how every other pool-affecting transfer in this codebase pairs its token movement with adata_storeledger update.Verification
cargo test -p order-handlerAdd a test that liquidates a position with a nonzero
liquidation_execution_fee, then readspool_amount/collateral_sumfromdata_storeafterward 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 inliquidation_handleralready check).