No price band on limit orders: a 1e-18 dust ask at the book head overflows match math and reverts any crossing swap #467

Closed
opened 2026-07-01 12:57:04 +00:00 by Brouie · 8 comments
Brouie commented 2026-07-01 12:57:04 +00:00 (Migrated from gitlab.com)

Came out of the #381 security sweep, poking at the limit-order path. There's no lower/upper bound on the price a limit order can be placed at, and the match loop divides by that price. A single dust-priced order parked at the best-price head of the book lets anyone brick book matching for every crossing swap.

What it is / where

validate_placement_item in smartcontracts/contracts/pair/src/limit_placement.rs:316-333 is the only gate on a placed order's price, and all it does is:

  • reject amount == 0 (ZeroAmount)
  • reject price == 0 ("limit price must be positive")
  • reject a past expires_at

No minimum, no maximum. grep -rniE "MIN_LIMIT_PRICE|MAX_LIMIT_PRICE|min_price|max_price|price_band" over contracts/pair/src comes back empty — the band doesn't exist anywhere, and expand_limit_ladder doesn't add one either.

So the smallest positive price you can place is Decimal::raw(1) = 1e-18.

Why it blows up (the mechanism)

The match loop inverts the price to size a fill. Ask side, orderbook.rs:1588-1600:

let max_fill_token0_from_budget = if !order.price.is_zero() {
    token1_left
        .checked_mul_floor(Decimal::one().checked_div(order.price).map_err(...)?) // 1/price
        .map_err(|_| ContractError::InvariantViolation { reason: "ask mul_floor".into() })?
} else { Uint128::zero() };

Decimal::one() / Decimal::raw(1) = 1e18. Then token1_left.checked_mul_floor(1e18) internally does token1_left * 1e18 in Uint256/Uint128 space and floors back to Uint128. Uint128::MAX is ~3.4e38, so once token1_left (the taker's remaining token1 budget on this leg) climbs past ~3.4e20 raw, token1_left * 1e18 overflows the Uint128 result and checked_mul_floor returns Err. That maps straight to ContractError::InvariantViolation { reason: "ask mul_floor" } and the entire swap message reverts.

The nasty part is placement order: an ask at price = raw(1) is the lowest possible ask, so it sorts to the head of the ask book — the first maker any crossing swap walks. The loop hits it before it can fill anything real, and dies. Doesn't matter how deep or healthy the rest of the book is.

Bid side is the same shape at orderbook.rs:1429-1433:

let inv = Decimal::one().checked_div(order.price)...?;      // 1/price -> 1e18
let max_fill_from_bid = order.remaining.checked_mul_floor(inv)
    .map_err(|_| ContractError::InvariantViolation { reason: "bid max fill".into() })?;

Here the overflow input is order.remaining (the maker's own escrowed token1) rather than the taker's budget, so it's the weaker mirror — the attacker has to actually escrow a large remaining to trip it, and can recover it by cancelling. The ask side is the real problem because the multiplicand is taker-controlled budget, not attacker-locked funds.

How to actually hit it

  1. Attacker places one ask limit order at price = Decimal::raw(1) (1e-18) with a trivial amount. Passes validate_placement_item fine — amount and price are both non-zero. It lands at the ask-book head.
  2. Any swap that crosses the ask book with a token1 leg above ~3.4e20 raw now reverts on checked_mul_floor before filling. For an 18-decimal token that's ~342 tokens on that leg — not exotic.
  3. If the attacker wants to target a specific fat swap instead of blanket-bricking, front-run it with the dust ask in the same block.

No special privileges, one cheap order, and book matching for that pair is down until the dust order is expired/cleared.

Impact

Griefing DoS on the order-book match path — crossing swaps abort instead of filling. No fund loss or mispricing; it fails closed on the InvariantViolation. But "any crossing swap reverts" on a launch pair is a real availability hit, and it's dirt cheap to sustain.

Fix direction

Enforce a sane [MIN_LIMIT_PRICE, MAX_LIMIT_PRICE] band in validate_placement_item (and mirror it in expand_limit_ladder so ladder rungs get checked too), rejecting anything outside it before the order ever reaches the book. Pick the min so that 1/price can't overflow Uint128 * price_inv against any plausible token1_left/remaining, and cap the max symmetrically for the reciprocal case. That kills both the head-of-book ask vector and the bid mirror. Belt-and-suspenders, the match loop could also treat the reciprocal overflow as "skip this maker" rather than aborting the whole swap, but the band is the clean fix and keeps garbage orders off the book entirely.

Filing under the #381 security-hardening umbrella.

Came out of the #381 security sweep, poking at the limit-order path. There's no lower/upper bound on the price a limit order can be placed at, and the match loop divides by that price. A single dust-priced order parked at the best-price head of the book lets anyone brick book matching for every crossing swap. ## What it is / where `validate_placement_item` in `smartcontracts/contracts/pair/src/limit_placement.rs:316-333` is the only gate on a placed order's price, and all it does is: - reject `amount == 0` (`ZeroAmount`) - reject `price == 0` ("limit price must be positive") - reject a past `expires_at` No minimum, no maximum. `grep -rniE "MIN_LIMIT_PRICE|MAX_LIMIT_PRICE|min_price|max_price|price_band"` over `contracts/pair/src` comes back empty — the band doesn't exist anywhere, and `expand_limit_ladder` doesn't add one either. So the smallest positive price you can place is `Decimal::raw(1)` = 1e-18. ## Why it blows up (the mechanism) The match loop inverts the price to size a fill. Ask side, `orderbook.rs:1588-1600`: ```rust let max_fill_token0_from_budget = if !order.price.is_zero() { token1_left .checked_mul_floor(Decimal::one().checked_div(order.price).map_err(...)?) // 1/price .map_err(|_| ContractError::InvariantViolation { reason: "ask mul_floor".into() })? } else { Uint128::zero() }; ``` `Decimal::one() / Decimal::raw(1)` = 1e18. Then `token1_left.checked_mul_floor(1e18)` internally does `token1_left * 1e18` in `Uint256`/`Uint128` space and floors back to `Uint128`. `Uint128::MAX` is ~3.4e38, so once `token1_left` (the taker's remaining token1 budget on this leg) climbs past ~3.4e20 raw, `token1_left * 1e18` overflows the `Uint128` result and `checked_mul_floor` returns `Err`. That maps straight to `ContractError::InvariantViolation { reason: "ask mul_floor" }` and the entire swap message reverts. The nasty part is placement order: an ask at `price = raw(1)` is the lowest possible ask, so it sorts to the **head** of the ask book — the first maker any crossing swap walks. The loop hits it before it can fill anything real, and dies. Doesn't matter how deep or healthy the rest of the book is. Bid side is the same shape at `orderbook.rs:1429-1433`: ```rust let inv = Decimal::one().checked_div(order.price)...?; // 1/price -> 1e18 let max_fill_from_bid = order.remaining.checked_mul_floor(inv) .map_err(|_| ContractError::InvariantViolation { reason: "bid max fill".into() })?; ``` Here the overflow input is `order.remaining` (the maker's own escrowed token1) rather than the taker's budget, so it's the weaker mirror — the attacker has to actually escrow a large `remaining` to trip it, and can recover it by cancelling. The ask side is the real problem because the multiplicand is taker-controlled budget, not attacker-locked funds. ## How to actually hit it 1. Attacker places one ask limit order at `price = Decimal::raw(1)` (1e-18) with a trivial amount. Passes `validate_placement_item` fine — amount and price are both non-zero. It lands at the ask-book head. 2. Any swap that crosses the ask book with a token1 leg above ~3.4e20 raw now reverts on `checked_mul_floor` before filling. For an 18-decimal token that's ~342 tokens on that leg — not exotic. 3. If the attacker wants to target a specific fat swap instead of blanket-bricking, front-run it with the dust ask in the same block. No special privileges, one cheap order, and book matching for that pair is down until the dust order is expired/cleared. ## Impact Griefing DoS on the order-book match path — crossing swaps abort instead of filling. No fund loss or mispricing; it fails closed on the `InvariantViolation`. But "any crossing swap reverts" on a launch pair is a real availability hit, and it's dirt cheap to sustain. ## Fix direction Enforce a sane `[MIN_LIMIT_PRICE, MAX_LIMIT_PRICE]` band in `validate_placement_item` (and mirror it in `expand_limit_ladder` so ladder rungs get checked too), rejecting anything outside it before the order ever reaches the book. Pick the min so that `1/price` can't overflow `Uint128 * price_inv` against any plausible `token1_left`/`remaining`, and cap the max symmetrically for the reciprocal case. That kills both the head-of-book ask vector and the bid mirror. Belt-and-suspenders, the match loop could also treat the reciprocal overflow as "skip this maker" rather than aborting the whole swap, but the band is the clean fix and keeps garbage orders off the book entirely. Filing under the #381 security-hardening umbrella.
PlasticDigits commented 2026-07-07 02:21:30 +00:00 (Migrated from gitlab.com)

mentioned in commit e5cb6f3f1c

mentioned in commit e5cb6f3f1c89b79808e79056993eafb0b860b5c6
PlasticDigits commented 2026-07-07 02:21:42 +00:00 (Migrated from gitlab.com)

mentioned in merge request !1010

mentioned in merge request !1010
PlasticDigits commented 2026-07-07 02:49:16 +00:00 (Migrated from gitlab.com)

mentioned in commit 86cf616168

mentioned in commit 86cf6161689efb3b8b1557c1bd52d1bfd93080ec
PlasticDigits commented 2026-07-07 02:52:33 +00:00 (Migrated from gitlab.com)

Verification complete — PASS

Verified on main (clean working tree, 2026-07-07).

Acceptance criteria

Item Result How verified
MIN_LIMIT_PRICE / MAX_LIMIT_PRICE gate placement, ladder expansion, and UpdateLimitOrderPrice PASS validate_limit_order_price in dex-common::limit_placement (1e-9 … 1e9); wired in pair limit_placement.rs, contract.rs, orderbook.rs
Dust ask Decimal::raw(1) (1e-18) rejected at placement (bid + ask) PASS cargo test -p cl8y-dex-tests place_limit_order_dust_price_rejected
Crossing hybrid swap still fills valid ask when dust placement is blocked PASS cargo test -p cl8y-dex-tests dust_ask_brick_attack_prevented_valid_ask_still_fills
Legacy out-of-band resting dust ask at book head skipped (no whole-swap revert) PASS cargo test -p cl8y-dex-pair match_asks_skips_legacy_dust_price_without_reverting
expand_limit_ladder rejects out-of-band rungs PASS cargo test -p dex-common expand_ladder_rejects_out_of_band + validate_limit_price unit tests
Invariant L20 documented PASS docs/contracts-security-audit.md, docs/limit-orders.md#limit-price-band-gitlab-467
Agent skill cross-link PASS skills/AGENTS_BOOK_MATCH_HINT_SECURITY.md (L20 / #467)
Aggregated QA script PASS make verify-issue-467 — 7/7 steps, 0 failures

Notes

  • Primary fix: price band at placement prevents the 1e-18 dust-ask head-of-book DoS vector described in the issue.
  • Belt-and-suspenders: match_asks / match_bids skip legacy out-of-band rows on reciprocal checked_mul_floor overflow instead of reverting the swap.
  • No LocalTerra / frontend / indexer run required — issue is contract-level; integration tests cover the attack and mitigation.

Closing as verified on main. No MR opened (no repo changes during verify).

## Verification complete — PASS Verified on `main` (clean working tree, 2026-07-07). ### Acceptance criteria | Item | Result | How verified | |------|--------|--------------| | `MIN_LIMIT_PRICE` / `MAX_LIMIT_PRICE` gate placement, ladder expansion, and `UpdateLimitOrderPrice` | **PASS** | `validate_limit_order_price` in `dex-common::limit_placement` (1e-9 … 1e9); wired in pair `limit_placement.rs`, `contract.rs`, `orderbook.rs` | | Dust ask `Decimal::raw(1)` (1e-18) rejected at placement (bid + ask) | **PASS** | `cargo test -p cl8y-dex-tests place_limit_order_dust_price_rejected` | | Crossing hybrid swap still fills valid ask when dust placement is blocked | **PASS** | `cargo test -p cl8y-dex-tests dust_ask_brick_attack_prevented_valid_ask_still_fills` | | Legacy out-of-band resting dust ask at book head skipped (no whole-swap revert) | **PASS** | `cargo test -p cl8y-dex-pair match_asks_skips_legacy_dust_price_without_reverting` | | `expand_limit_ladder` rejects out-of-band rungs | **PASS** | `cargo test -p dex-common expand_ladder_rejects_out_of_band` + `validate_limit_price` unit tests | | Invariant **L20** documented | **PASS** | `docs/contracts-security-audit.md`, `docs/limit-orders.md#limit-price-band-gitlab-467` | | Agent skill cross-link | **PASS** | `skills/AGENTS_BOOK_MATCH_HINT_SECURITY.md` (L20 / #467) | | Aggregated QA script | **PASS** | `make verify-issue-467` — **7/7 steps, 0 failures** | ### Notes - Primary fix: price band at placement prevents the 1e-18 dust-ask head-of-book DoS vector described in the issue. - Belt-and-suspenders: `match_asks` / `match_bids` skip legacy out-of-band rows on reciprocal `checked_mul_floor` overflow instead of reverting the swap. - No LocalTerra / frontend / indexer run required — issue is contract-level; integration tests cover the attack and mitigation. Closing as verified on `main`. No MR opened (no repo changes during verify).
PlasticDigits (Migrated from gitlab.com) closed this issue 2026-07-07 02:52:34 +00:00
leonardocolucci commented 2026-08-15 21:58:48 +00:00 (Migrated from gitlab.com)

mentioned in issue #529

mentioned in issue #529
PlasticDigits commented 2026-08-16 07:32:49 +00:00 (Migrated from gitlab.com)

mentioned in commit 91d90ddba4

mentioned in commit 91d90ddba401db8de8ebad59f41479c2ba3def6c
PlasticDigits commented 2026-08-16 07:32:52 +00:00 (Migrated from gitlab.com)

mentioned in merge request !1060

mentioned in merge request !1060
PlasticDigits commented 2026-08-16 08:43:17 +00:00 (Migrated from gitlab.com)

mentioned in issue #532

mentioned in issue #532
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
code/cl8y-dex-terraclassic#467
No description provided.