fix: fault-isolate fee batch sweeper, gate manual refunds, fix cleanup incentive, implement real partial liquidation - #698
Conversation
|
@dev-susa Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits. You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀 |
|
Ran a self-review pass (`/code-review high`) on this PR before it gets external review, and it caught a real fund-drain bug in the #533 fix that I've now addressed in a follow-up commit: Found: Fixed: the fee-reduced collateral is now written back to the position's storage entry before Also tightened the All directly affected unit and integration test suites pass; |
…sed check The market-pause guard in check_liquidatable's ADL counterpart referenced gmx_keys::is_market_paused_key without importing it, leaving the whole crate (and everything depending on it, including the integration test suite) unable to compile. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017FJeNNQgXHQf7omFK8fzax
…fees Issue SO4-Markets#446's fix — per-item fault isolation so one bad market/token pair doesn't roll back fees already claimed for every other pair in the same batch — was never actually implemented despite SO4-Markets#446 being closed. claim_all_fees called fee_handler_client.claim_fees(...) directly in a plain loop; since Soroban invocations are atomic, a single panicking pair (e.g. the InsufficientPoolBalance guard from SO4-Markets#254) reverted the entire transaction, undoing the whole batch. Switch to try_claim_fees so a panic on any one pair is caught and recorded rather than propagated, and extend BatchClaimResult with a `failed: Vec<(Address, Address)>` field so callers can see which pairs did not succeed, matching SO4-Markets#446's suggested fix. Also fixes two pre-existing bugs uncovered while adding the new test: the existing too_many_markets_panics/too_many_tokens_panics/ product_exceeds_limit_panics tests called `.collect()` into a soroban_sdk::Vec, which doesn't implement FromIterator, so this crate's tests never actually compiled. Closes SO4-Markets#539 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017FJeNNQgXHQf7omFK8fzax
…nd events Two bugs in order_cleanup, fixed together since both are small and live in the same file: - cancel_expired_order (SO4-Markets#536): forwarded this contract's own address (`helper`) instead of the real external `caller` to order_handler::cleanup_expired_order. order_handler pays the 10% cleanup incentive to whichever address it's told is the caller, so every incentive landed in order_cleanup's own balance — which has no withdrawal path — permanently stranding it instead of paying the keeper who did the work. - record_manual_refund (SO4-Markets#537): only checked that the caller-supplied `admin` address signed the call, which is satisfiable by any caller passing their own address. The emitted "man_ref" event is meant to be a trusted audit record of a real admin action, so anyone could publish spoofed refund events with fabricated amounts. Added an initialize(admin, role_store) entrypoint (matching the pattern used by every other handler contract) and require the CONTROLLER role before publishing the event. Also fixes two pre-existing bugs in the integration test file, needed to get the suite compiling/passing again: `.contains()` doesn't exist on this soroban-sdk version's ContractEvents (replaced with filter_by_contract + exact-equality comparison), and two tests minted collateral to the user's own wallet without transferring it into order_vault, so create_order always reverted with ZeroCollateral. Closes SO4-Markets#536, Closes SO4-Markets#537 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017FJeNNQgXHQf7omFK8fzax
execute_partial_liquidation validated liquidatability, computed a partial-close size/fee/remaining-size, emitted PartialLiquidationExecuted, and returned liquidated_size — but never called order_handler, never touched storage, and never moved a token. Callers and indexers trusting the event believed the position was de-risked while it stayed fully open and fully exposed. Add order_handler::partial_liquidate_position, mirroring the delegation pattern liquidate_position and execute_adl already use (positions live in order_handler's storage, so the real mutation has to happen there, re-validating liquidatability itself rather than trusting the wrapper). liquidation_handler now delegates to it and reports the real, applied post-close state in its event instead of a value it derived itself and never confirmed took effect. A plain delegation to decrease_position isn't sufficient on its own: it releases collateral proportionally to the size closed, which leaves the position's collateral/size ratio — and therefore validate_position's health check — exactly unchanged, so a proportional partial close can never cure an already-unhealthy position (only a 100% close, which skips the check entirely, could ever pass). Add a `retain_collateral` flag to DecreasePositionParams: when set and the close is genuinely partial (not a full close), no collateral is released — it stays with the smaller, now-less-leveraged position, which is what actually lets a partial liquidation restore health. Every existing call site keeps `retain_collateral: false` (unchanged behaviour); only the new partial liquidation path opts in. Also fixes two more pre-existing compile breaks in liquidation_handler found while working in this file: a missing is_market_paused_key import, and Error::MarketPaused/Error::InvalidMarket both assigned discriminant 6 (a #[contracterror] enum can't have a duplicate discriminant). Closes SO4-Markets#533 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017FJeNNQgXHQf7omFK8fzax
…f the pool A self-review caught a real fund-drain bug in the partial-liquidation fix: liquidate_position (pre-existing) and the new partial_liquidate_position both withdraw the configured keeper execution fee from the pool first, then call decrease_position — which independently re-reads the position's *pre-fee* collateral from storage. For a full close this pays the fee and the full original collateral to the account, double-spending the fee out of the pool. For a genuinely partial close (retain_collateral: true) it's worse: the stored collateral is never reduced by the fee at all, so the position's on-chain collateral stays permanently overstated and the same fee can be harvested again on a later partial liquidation of the same position. Fix: write the fee-reduced collateral back to the position's storage entry before calling decrease_position, so it reads the corrected value regardless of whether the close ends up full or partial. Requires PositionProps to implement Clone (previously missing). Also clarify the PartialLiquidationExecuted.liquidation_fee field's doc comment: it now carries the real, paid keeper_execution_fee instead of the synthetic 50-bps-of-size value the pre-fix stub computed but never actually transferred to anyone. New tests: liquidate_position_with_keeper_fee_does_not_double_pay_account (order_handler) forces liquidatability via min_collateral_factor instead of a price crash so realised PnL stays ~0, making the fee's effect on the account's payout directly observable; execute_partial_liquidation_pays_ configured_keeper_fee (liquidation_handler) now also asserts the position's stored collateral drops by exactly the fee paid. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017FJeNNQgXHQf7omFK8fzax
Rebased fix/533-536-537-539-fault-tolerance-and-auth onto upstream/main (which had advanced with an unrelated basis-points-constant refactor touching liquidation_handler, order_cleanup, order_handler, and decrease_position_utils) to clear the PR's merge conflict. - liquidation_handler: resolve the one real conflict by keeping the SO4-Markets#533 delegation-to-order_handler fix and adopting upstream's new gmx_math::BPS_DIVISOR constant in place of the pre-fix stub's hardcoded 10_000, fixing the resulting u32/u128 type mismatch. - order_cleanup: fix two pre-existing (unrelated to SO4-Markets#536/SO4-Markets#537) unit test compile breaks in the same file uncovered while re-verifying after the rebase: `Address::generate` used outside the scope of its local `use soroban_sdk::testutils::Address as _` import, and a contract `Error` compared directly against a `try_*` client result that decodes to `soroban_sdk::Error`. All directly affected suites pass: liquidation-handler (16/16), order-cleanup unit (3/3) and integration (7/7), order-handler (64/67 — the 3 failures are the pre-existing heartbeat-timing breaks, verified still present on clean upstream/main), adl-handler, fee-batch-sweeper, and gmx-decrease-position-utils. `cargo check --workspace` is clean.
ef72f45 to
3ff40fc
Compare
|
Rebased onto Conflict resolution: in Also fixed while re-verifying post-rebase (pre-existing, unrelated to #533/#536/#537/#539 — confirmed identical on a clean Re-verified after rebase: PR now shows |
|
veeery cool |
Summary
Fixes 4 assigned bugs, each a separate commit:
fee_batch_sweeper::claim_all_feesnever implemented the per-item fault isolation issue fee_batch_sweeper: one failing market/token pair reverts the entire batch, blocking fee collection for all other markets in that call #446 was supposedly fixed for: one bad(market, token)pair still reverted the whole batch. Switched totry_claim_feesand added afailed: Vec<(Address, Address)>field toBatchClaimResultso callers can see which pairs failed instead of having the whole call revert.order_cleanup::cancel_expired_orderforwarded its own contract address instead of the real caller toorder_handler::cleanup_expired_order, so the 10% cleanup incentive was always paid toorder_cleanupitself (which has no withdrawal path), never to the keeper who did the work.order_cleanup::record_manual_refundonly checked that the caller-suppliedadminaddress signed the call — satisfiable by any caller passing their own address — letting anyone publish spoofed "admin refund" audit events. Added aninitialize(admin, role_store)entrypoint and aCONTROLLERrole check before publishing the event.liquidation_handler::execute_partial_liquidationwas a pure stub: it validated liquidatability, computed what a partial close would look like, and emitted an event — but never touchedorder_handlerstorage or moved a token. Added a realorder_handler::partial_liquidate_positionentrypoint (mirroringliquidate_position/execute_adl's delegation pattern) thatliquidation_handlernow calls. This also required adding aretain_collateraloption todecrease_position: a plain proportional collateral release (the existing behavior, used by voluntary decreases and ADL) leaves a position's collateral/size ratio unchanged, so it can never cure an already-unhealthy position — only keeping collateral fixed while size shrinks can restore health. Every existing call site keeps the old behavior (retain_collateral: false); only the new partial-liquidation path opts in.Incidental fixes
While implementing/testing the above, hit several pre-existing compile/test breaks on
mainunrelated to these 4 issues, needed to get the workspace and test suite building/passing at all:adl_handler: missingis_market_paused_keyimport.liquidation_handler: same missing import, plusError::MarketPausedandError::InvalidMarketboth assigned discriminant6(duplicate discriminant on a#[contracterror]enum).fee_batch_sweeper's own pre-existing tests used.collect()into asoroban_sdk::Vec, which doesn't implementFromIterator— the crate's tests never compiled.tests/order_cleanup.rs:ContractEventshas no.contains()in this soroban-sdk version (replaced withfilter_by_contract+ exact-equality); two existing tests minted collateral to the user's wallet instead of transferring it intoorder_vault, socreate_orderalways reverted withZeroCollateral.These are called out explicitly in the relevant commit messages rather than bundled silently.
Test plan
cargo check --workspace— cleancargo test -p fee-batch-sweeper -p order-cleanup -p order-handler -p liquidation-handler --lib— all green except 3 pre-existing, unrelatedorder_handlerheartbeat-timing test failures, confirmed present on a cleanupstream/maincheckout before any of these changes (custom_heartbeat_timeout_is_respected,execute_order_records_keeper_heartbeat,keeper_goes_stale_after_timeout_and_role_is_revocable)cargo test -p gmx-integration-tests --test order_cleanup— 7/7 pass, including new tests for order_cleanup: cancel_expired_order pays the cleanup incentive to its own contract address, not the caller #536/order_cleanup: record_manual_refund has no admin/role check — any caller can emit spoofed refund audit events #537fee_batch_sweeperreproducing fee_batch_sweeper: closed issue #446's per-item fault isolation was never implemented — one bad pair still reverts the whole batch #539: one panicking pair alongside one healthy pair in the same batch — healthy pair's fees are still claimed, failing pair is reported infailedliquidation_handlerreproducing liquidation_handler: execute_partial_liquidation never actually reduces the position — pure stub #533: partial close actually reducessize_in_usdinorder_handlerstorage, retains collateral, pays the configured keeper fee, a 100% factor behaves like a full close, and a healthy position still revertscargo test --workspacelocally — this sandbox's disk is independently at ~100% capacity (unrelated to this change) and a full workspace build/link exhausts it; all specifically affected packages were verified individually as aboveCo-Authored-By: Claude Sonnet 5 noreply@anthropic.com
https://claude.ai/code/session_017FJeNNQgXHQf7omFK8fzax