Skip to content

Add missing validation on four admin-only config setters - #673

Merged
abayomicornelius merged 4 commits into
SO4-Markets:mainfrom
chonilius:fix/config-validation-632-633-634-635
Aug 24, 2026
Merged

Add missing validation on four admin-only config setters#673
abayomicornelius merged 4 commits into
SO4-Markets:mainfrom
chonilius:fix/config-validation-632-633-634-635

Conversation

@chonilius

Copy link
Copy Markdown
Contributor

Summary

Four small, independent config-input validation gaps across four contracts, each flagged in its own issue. All are admin/controller-gated setters that accepted values with no sanity checking, so a fat-fingered value (rather than any actual privilege-escalation bug) could cause real damage. Each fix is a minimal, targeted guard plus a dedicated error variant — no unrelated refactors.

order_handler::set_keeper_heartbeat_timeout — reject timeout_ledgers == 0

check_keeper_heartbeat computes is_stale = ledgers_since_last_activity > timeout. A timeout_ledgers = 0 misconfiguration made every keeper on that role look permanently stale after a single ledger, which could lead an admin to revoke a perfectly healthy keeper's role based on a false signal. Now rejected with Error::InvalidHeartbeatTimeout.

data_store::set_liquidation_execution_fee — cap at a sane ceiling

order_handler::liquidate_position caps the transferred keeper fee at the position's entire collateral, not some smaller fraction of it. Any configured fee at or above a position's collateral value resulted in the whole remaining collateral being paid out to the liquidating keeper. Added MAX_LIQUIDATION_EXECUTION_FEE (1,000 tokens at Stellar's 7-decimal precision — well above any realistic keeper reimbursement) and reject fees above it with Error::LiquidationExecutionFeeTooHigh.

exchange_router::update_withdrawal_handler — reject self/other-handler addresses

Accepted any address with zero validation. A copy-paste mistake (e.g. pointing it at order_handler's or fee_handler's address) would silently misroute every withdrawal call to a contract with a completely different interface, surfacing only as a confusing cross-contract-call error on the first user's withdrawal. Now rejects new_handler equal to the router's own address or any of its other registered handler addresses (deposit_handler, order_handler, fee_handler) with Error::InvalidWithdrawalHandler.

market_token::initialize — require admin auth

Every other contract's initialize in this workspace (fee_handler, oracle, liquidation_handler, adl_handler, order_handler, deposit_handler, withdrawal_handler, market_factory, referral_storage, role_store) calls admin.require_auth() before setting up state. market_token::initialize was the sole outlier — it stored the passed-in admin address without ever requiring that (or any) address to authorize the call. Not exploitable via the current sole call path (market_factory::create_market deploys and initializes atomically in one invocation), but it's a real gap in this contract's own defenses against any future/alternate deployment path that separates deploy from init. Added admin.require_auth() as the first line, matching every sibling contract.

Why these are safe, minimal fixes

All four are validation-only additions on admin/controller-gated entry points — no changes to any existing authorized behavior, no new state, no changes to any read path. cargo build -p order-handler -p data-store -p exchange-router -p market-token passes clean.

Closes

Closes #632
Closes #633
Closes #634
Closes #635

Test plan

  • cargo build across all four touched crates (order_handler, data_store, exchange_router, market_token) — passes clean
  • CI test suite

…beat_timeout

timeout_ledgers = 0 made check_keeper_heartbeat treat a keeper as stale the
instant a single ledger elapsed since its last recorded activity, since
is_stale = ledgers_since_last_activity > timeout. That false signal could
lead an admin to revoke a perfectly healthy keeper's role based on a
misconfigured (fat-fingered, or seconds/ledgers unit-mixed-up) value rather
than real inactivity.

Reject timeout_ledgers == 0 with a new InvalidHeartbeatTimeout error before
it's written to data_store.

Closes SO4-Markets#634
order_handler::liquidate_position caps the keeper fee transfer at the
position's full collateral, not at some smaller fraction of it, so any
configured liquidation_execution_fee at or above a position's collateral
value resulted in the entire remaining collateral being paid to the
liquidating keeper, zeroing out the position owner's remaining collateral.
require_controller already gates the setter, but nothing on-chain caught a
fat-fingered or decimal-scale-mixed-up value before it took effect.

Add MAX_LIQUIDATION_EXECUTION_FEE (1,000 tokens at 7-decimal precision) and
reject fees above it with a new LiquidationExecutionFeeTooHigh error.

Closes SO4-Markets#633
…dress

update_withdrawal_handler accepted any address with no validation, so a
copy-paste mistake (e.g. pointing it at order_handler's or fee_handler's
address instead of the real withdrawal handler) would silently misroute
every create_withdrawal/execute_withdrawal call to a contract that doesn't
implement the expected interface, surfacing only as a confusing
cross-contract-call failure on the first user withdrawal.

Reject new_handler == the router's own address or any of its other
registered handler addresses (deposit_handler, order_handler, fee_handler)
with a new InvalidWithdrawalHandler error.

Closes SO4-Markets#635
Every other contract's initialize in this workspace (fee_handler, oracle,
liquidation_handler, adl_handler, order_handler, deposit_handler,
withdrawal_handler, market_factory, referral_storage, role_store) calls
admin.require_auth() before setting up state. market_token::initialize was
the sole outlier: it stored the passed-in admin address without ever
requiring that (or any) address to authorize the call. Not exploitable via
the current sole call path (market_factory::create_market deploys and
initializes atomically), but a real gap in this contract's own defenses
against any future/alternate deployment path that separates deploy from init.

Add admin.require_auth() as the first line, matching every sibling contract.

Closes SO4-Markets#632
@drips-wave

drips-wave Bot commented Aug 24, 2026

Copy link
Copy Markdown

@chonilius 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

@abayomicornelius
abayomicornelius merged commit 9ca386f into SO4-Markets:main Aug 24, 2026
1 of 3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment