Skip to content

fix: fault-isolate fee batch sweeper, gate manual refunds, fix cleanup incentive, implement real partial liquidation - #698

Merged
IbrahimIjai merged 6 commits into
SO4-Markets:mainfrom
dev-susa:fix/533-536-537-539-fault-tolerance-and-auth
Aug 31, 2026
Merged

fix: fault-isolate fee batch sweeper, gate manual refunds, fix cleanup incentive, implement real partial liquidation#698
IbrahimIjai merged 6 commits into
SO4-Markets:mainfrom
dev-susa:fix/533-536-537-539-fault-tolerance-and-auth

Conversation

@dev-susa

Copy link
Copy Markdown
Contributor

Summary

Fixes 4 assigned bugs, each a separate commit:

Incidental fixes

While implementing/testing the above, hit several pre-existing compile/test breaks on main unrelated to these 4 issues, needed to get the workspace and test suite building/passing at all:

  • adl_handler: missing is_market_paused_key import.
  • liquidation_handler: same missing import, plus Error::MarketPaused and Error::InvalidMarket both assigned discriminant 6 (duplicate discriminant on a #[contracterror] enum).
  • fee_batch_sweeper's own pre-existing tests used .collect() into a soroban_sdk::Vec, which doesn't implement FromIterator — the crate's tests never compiled.
  • tests/order_cleanup.rs: ContractEvents has no .contains() in this soroban-sdk version (replaced with filter_by_contract + exact-equality); two existing tests minted collateral to the user's wallet instead of transferring it into order_vault, so create_order always reverted with ZeroCollateral.

These are called out explicitly in the relevant commit messages rather than bundled silently.

Test plan

Co-Authored-By: Claude Sonnet 5 noreply@anthropic.com

https://claude.ai/code/session_017FJeNNQgXHQf7omFK8fzax

@drips-wave

drips-wave Bot commented Aug 31, 2026

Copy link
Copy Markdown

@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! 🚀

Learn more about application limits

@dev-susa

Copy link
Copy Markdown
Contributor Author

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: liquidate_position (pre-existing) and the new partial_liquidate_position both withdraw the configured keeper execution fee from the pool before calling decrease_position, which independently re-reads the position's collateral fresh from storage — unaware the fee was already paid out. On a full close this double-pays the fee out of the pool (fee + full original collateral, instead of fee + remaining collateral). On a genuinely partial close it's worse: the stored collateral_amount 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.

Fixed: the fee-reduced collateral is now written back to the position's storage entry before decrease_position runs, so it always operates on the corrected value. Added PartialLiquidatePositionResult-adjacent regression tests: liquidate_position_with_keeper_fee_does_not_double_pay_account (forces liquidatability via min_collateral_factor instead of a price crash so the effect is directly observable against ~0 realised PnL) and an extended assertion on execute_partial_liquidation_pays_configured_keeper_fee confirming stored collateral drops by exactly the fee paid.

Also tightened the PartialLiquidationExecuted.liquidation_fee field's doc comment — it now carries the real, paid keeper_execution_fee rather than the synthetic value the pre-fix stub computed but never transferred to anyone, which is worth flagging explicitly since the field name is unchanged.

All directly affected unit and integration test suites pass; cargo check --workspace is clean.

dev-susa and others added 6 commits August 31, 2026 12:57
…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.
@dev-susa
dev-susa force-pushed the fix/533-536-537-539-fault-tolerance-and-auth branch from ef72f45 to 3ff40fc Compare August 31, 2026 12:23
@dev-susa

Copy link
Copy Markdown
Contributor Author

Rebased onto upstream/main (which had advanced with an unrelated basis-points-divisor-centralization refactor touching liquidation_handler, order_cleanup, order_handler, and decrease_position_utils) to clear a merge conflict that had appeared on this PR.

Conflict resolution: in liquidation_handler::execute_partial_liquidation, kept this PR's #533 fix (delegating the real close to order_handler instead of the old local-computation stub) and adopted upstream's new gmx_math::BPS_DIVISOR constant in place of the stub's hardcoded 10_000.

Also fixed while re-verifying post-rebase (pre-existing, unrelated to #533/#536/#537/#539 — confirmed identical on a clean upstream/main worktree checkout): two compile breaks in order_cleanup's own unit test module — Address::generate called outside the scope of its local testutils::Address import, and a contract Error compared directly against a try_* client result that actually decodes to soroban_sdk::Error.

Re-verified after rebase: cargo check --workspace clean; liquidation-handler 16/16, order-cleanup unit 3/3 + integration 7/7, liquidation integration 6/6, adl-handler, fee-batch-sweeper, and gmx-decrease-position-utils all pass. order-handler is 64/67 — the same 3 pre-existing heartbeat-timing failures noted earlier in this PR, reconfirmed present on clean upstream/main.

PR now shows mergeable: MERGEABLE.

@IbrahimIjai

Copy link
Copy Markdown
Contributor

veeery cool

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