security(pair): in-band min-price asks skip forever and clog match_asks #1225

Closed
opened 2026-09-11 07:30:01 +00:00 by PlasticDigits · 2 comments

Summary

Follow-up to closed #470 (zero-cost fill skip) and the in-band price gate from #467 / #529. Placement accepts asks whose human-scale price equals MIN_LIMIT_PRICE. match_asks then computes fill cost as floor(fill_t0 × price). At the band floor that product is zero for any fill the matcher will attempt against a typical remaining, so the #470 cost.is_zero() guard skips the order forever. Lowest-price asks sit at the ask head. They are not time-expired (expires_at unset) and default CleanLimitBook force-clean thresholds are 0, so the keeper path cannot park them. A head prefix on the order of MAX_SCAN_STEPS (500) spends the entire taker walk with no fills.

This is not a reopen of #470: that ticket’s AC was “do not credit the maker 0 token1.” Those tests still pass. The skip is honest for a single sub-unity fill; it becomes a liveness / griefing defect when the skipped row is in-band, un-evictable, and stacked at the FIFO head.

Not a duplicate of #289 (expired-prefix clog). #289 mitigations (book_start_hint past an expired prefix, CleanLimitBook parking expired / force-dust) do not apply: these rows are live, in-band, and ineligible for clean when thresholds are 0. Not #264 (post-fill remainder < 10): these orders never fill, and remaining can be far above the dust-flush constant.

Impact (Immunefi-style)

Question Answer
Funds at risk today? No steal-from-pool / cross-escrow drain. Maker escrow in the clog rows stays locked until the maker cancels. Takers’ book leg under-fills (pool spillover or slippage revert).
Auth / admin required? No. Any address that can place an in-band ask can rest a permanently skipped row at the ask head.
Theft vs lock? Book-match DoS / under-fill, not a value-extraction drain. Recovery of the clog is maker cancel or a governance raise of clean thresholds / wasm change — keepers cannot clear the default config.
Sticky once armed? Yes, for rows with no expires_at while min_remaining_token0 stays 0. Each skipped node still consumes a MAX_SCAN_STEPS iteration.
Scope Pair match_asks / simulate_match_asks + placement validate_limit_order_price + clean_limit_book. Indexer/router inherit the empty book leg when they walk from head.

Invariant that is broken: an in-band resting ask must be fillable at its limit price or evictable from the active DLL. A skip that protects the maker from a 0-token1 credit must not leave an immortal head node that burns the taker scan budget.

Arithmetic bound (for implementers, not a public attack recipe): MIN_LIMIT_PRICE is Decimal::raw(1_000_000_000) (human 1e-9 on equal-decimal pairs). checked_mul_floor is zero whenever fill_t0 × price < 1. For equal-decimal 6-dec assets that means fills below 1e9 raw (~1000 whole tokens) never pay the maker; remaining below that bound is economically unfillable at the accepted floor. Asks sort ascending, so the floor price is the first node match_asks visits.

Constraints / guardrails

  • Do not reopen or retarget #470 ACs. Keep cost.is_zero() skip so makers are never paid 0 token1 for a positive fill_t0. Fix placement, eviction, and/or skip-vs-park, not by restoring the free-fill.
  • Do not treat this as #289 “pass a hint.” A hint past the clog hides the rows from one taker; it does not unlink them, and head-only / stale-hint paths still pay the scan tax. Indexer book_start_hint must not be the only defense.
  • Do not require operators to set non-zero LIMIT_CLEAN_CONFIG as the sole fix. Defaults are 0/0 by design (#263); unfillable in-band rows must be handled with that default.
  • Placement: if remaining × price cannot produce cost ≥ 1 at the accepted human price, reject the place / price-update / ladder rung (or require expires_at). Equal-decimal and mismatched-decimal human-scale paths (#529) both need the same economic-fillability check.
  • Match path: a zero-cost in-band live ask is a candidate to park (same L1 park / claim queue as expiry — no CW20 in the match tx) rather than continue forever. Do not park out-of-band legacy dust that #467 already skips on reciprocal overflow unless that is documented as the same helper.
  • Simulation parity: simulate_match_asks / indexer db_orderbook_sim must match execute (L8). Quotes must not pretend the head is fillable.
  • Bid-side audit: match_bids has the same cost.is_zero() skip. If a MAX-band bid can be permanently skipped at the bid head, cover it in this ticket — do not split a sibling.
  • Preserve L5 scan cap, L1 escrow (no extra CW20 on skip/park), pause/blacklist, and MAX_EXPIRED_PARKS_PER_SWAP accounting if park is used (do not starve genuine time-expiry parks without a documented cap split, cf. #264 dust vs expiry).
  • No public on-chain attack transaction. Verification is in-tree unit/integration tests.
  • Founder-required: CosmWasm pair matching. No community autoland. Do not add ready.

Relevant files

Path Why
smartcontracts/packages/dex-common/src/limit_placement.rs MIN_LIMIT_PRICE; validate_limit_order_price / ladder accept human == MIN_LIMIT_PRICE with no min-fillable-remaining check
smartcontracts/contracts/pair/src/orderbook.rs match_asks / simulate_match_asks cost.is_zero() continue; ask FIFO head; scan-step counter
smartcontracts/packages/dex-common/src/pair.rs MAX_SCAN_STEPS (500)
smartcontracts/contracts/pair/src/limit_book_clean.rs is_force_dust false when min_remaining is 0; no expires_at → never parks
indexer db_orderbook_sim Must skip/park with the same rule so quotes do not advertise an unfillable best ask as liquidity
Pair / cl8y-dex-tests orderbook tests Existing match_asks_skips_zero_cost_fill_sub_unity_price stays green; extend for band-floor remaining that can never yield cost ≥ 1
docs/limit-orders.md / docs/contracts-security-audit.md Document unfillable-floor vs #470 skip vs #289 expired clog
  1. Reject at placement (and UpdateLimitOrderPrice / ladder) when order.amount (post-fee remaining) cannot satisfy checked_mul_floor(price) ≥ 1 at that price. Keeps garbage off the book; cheapest invariant.
  2. Match-time park for live in-band rows that still lose the zero-cost check (legacy rows placed before the gate, or remainder that became unfillable). Unlink + EXPIRED_LIMIT_CLAIMS (or existing clean-park helper); count the node toward scan steps; do not continue as a healthy skip.
  3. Optional belt: CleanLimitBook treats “remaining cannot pay cost ≥ 1 at resting price” as force-eligible even when governance thresholds are 0 — only if (1)+(2) cannot cover legacy state. Prefer matcher/placement so keepers are not load-bearing.
  4. Tests: (a) equal-decimal ask at MIN_LIMIT_PRICE with remaining that cannot produce cost ≥ 1 is rejected or parked on first match, not left at HEAD_ASK; (b) a prefix of such rows of length MAX_SCAN_STEPS does not leave later fillable asks unreachable after the fix (either they never enter, or they park within the walk / a clean call with default thresholds); (c) #470 sub-unity skip with a fillable remaining still skips the dust fill without giving token0 away; (d) simulation parity.

Acceptance criteria

  • AC1. An ask whose human price is MIN_LIMIT_PRICE and whose remaining cannot produce floor(fill × price) ≥ 1 for any fill ≤ remaining cannot remain an immortal HEAD_ASK / DLL node after placement or after one match/clean under default clean config (thresholds 0, no expires_at).
  • AC2. match_asks still never credits maker 0 token1 for fill_t0 > 0 (#470 tests remain PASS).
  • AC3. A book whose ask head is a long prefix of such unfillable in-band rows does not spend a full MAX_SCAN_STEPS walk with zero parks/unlinks under default config. Either placement forbids the prefix, or the walk/clean parks them. scan_steps_capped may still fire on expired prefixes (#289); it must not fire solely because of live band-floor skips.
  • AC4. simulate_match_asks and indexer db_orderbook_sim match execute (no phantom best-ask liquidity).
  • AC5. Bid-side zero-cost skip cannot form the same immortal head clog, or is covered by the same gate/park.
  • AC6. docs/limit-orders.md + security matrix distinguish #470 skip, #467 out-of-band skip, #289 expired clog, and this unfillable in-band floor.
  • AC7. Focused cargo test for pair orderbook + dex-common placement + make test-contracts (or the repo’s documented contract suite) pass.

Verification (non-exploitative)

Do not publish a step-by-step mainnet placement sequence. Verify in unit tests:

  1. Placement: validate_limit_order_price / place-limit integration — human MIN_LIMIT_PRICE with remaining whose checked_mul_floor is always 0 is rejected (if that is the chosen gate).
  2. Match: seed one in-band floor ask that would have been skipped; assert it is parked or rejected, HEAD_ASK moves to a fillable order (or empty), maker token1 unchanged, escrow follows L1 park (no swap-tx CW20 to maker).
  3. Scan: seed more than MAX_SCAN_STEPS such rows only in the test harness, then one fillable ask behind them. After the fix, a hybrid take from head fills the live ask or parks the prefix; it must not return scan_steps_capped with the fillable ask untouched and all prefix rows still linked.
  4. Regression: match_asks_skips_zero_cost_fill_sub_unity_price and place_limit_order_dust_price_rejected stay green.
  5. Clean: with LIMIT_CLEAN_CONFIG zeros and no expires_at, either clean is not required (placement/match already unlinked) or clean now parks the unfillable-floor case.

Existing expired-prefix tests for #289 stay the expired story; do not overload them.

First-pass model recommendation

Recommendation: grok-high

Rationale: Matching-engine / CosmWasm pair work (founder-required: contracts, wasm). The change crosses placement (dex-common human-scale band), match_asks skip-vs-park, optional clean_limit_book eligibility, simulation/indexer parity, and scan-budget invariants. Security / griefing-DoS, not a local helper. Composer is disallowed for security and for contract/wasm even if the edit were small. Verify with the focused orderbook + placement tests above plus the existing #470 / #467 suites — not a live-chain clog.

## Summary Follow-up to closed [#470](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/470) (zero-cost fill skip) and the in-band price gate from [#467](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/467) / [#529](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/529). Placement **accepts** asks whose **human-scale** price equals `MIN_LIMIT_PRICE`. `match_asks` then computes fill cost as `floor(fill_t0 × price)`. At the band floor that product is **zero** for any fill the matcher will attempt against a typical remaining, so the [#470](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/470) `cost.is_zero()` guard **skips the order forever**. Lowest-price asks sit at the ask **head**. They are not time-expired (`expires_at` unset) and default `CleanLimitBook` force-clean thresholds are **0**, so the keeper path cannot park them. A head prefix on the order of `MAX_SCAN_STEPS` (500) spends the entire taker walk with **no fills**. This is **not** a reopen of [#470](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/470): that ticket’s AC was “do not credit the maker 0 token1.” Those tests still pass. The skip is honest for a *single* sub-unity fill; it becomes a **liveness / griefing** defect when the skipped row is in-band, un-evictable, and stacked at the FIFO head. Not a duplicate of [#289](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/289) (expired-prefix clog). [#289](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/289) mitigations (`book_start_hint` past an expired prefix, `CleanLimitBook` parking expired / force-dust) **do not apply**: these rows are live, in-band, and ineligible for clean when thresholds are 0. Not [#264](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/264) (post-fill remainder < 10): these orders **never fill**, and remaining can be far above the dust-flush constant. ## Impact (Immunefi-style) | Question | Answer | | --- | --- | | Funds at risk today? | **No steal-from-pool / cross-escrow drain.** Maker escrow in the clog rows stays locked until the maker cancels. Takers’ book leg under-fills (pool spillover or slippage revert). | | Auth / admin required? | **No.** Any address that can place an in-band ask can rest a permanently skipped row at the ask head. | | Theft vs lock? | **Book-match DoS / under-fill**, not a value-extraction drain. Recovery of the clog is maker cancel or a **governance** raise of clean thresholds / wasm change — keepers cannot clear the default config. | | Sticky once armed? | **Yes**, for rows with no `expires_at` while `min_remaining_token0` stays 0. Each skipped node still consumes a `MAX_SCAN_STEPS` iteration. | | Scope | Pair `match_asks` / `simulate_match_asks` + placement `validate_limit_order_price` + `clean_limit_book`. Indexer/router inherit the empty book leg when they walk from head. | Invariant that is broken: **an in-band resting ask must be fillable at its limit price or evictable from the active DLL.** A skip that protects the maker from a 0-token1 credit must not leave an immortal head node that burns the taker scan budget. Arithmetic bound (for implementers, not a public attack recipe): `MIN_LIMIT_PRICE` is `Decimal::raw(1_000_000_000)` (human 1e-9 on equal-decimal pairs). `checked_mul_floor` is zero whenever `fill_t0 × price < 1`. For equal-decimal 6-dec assets that means fills below **1e9 raw** (~1000 whole tokens) never pay the maker; remaining below that bound is **economically unfillable** at the accepted floor. Asks sort ascending, so the floor price is the first node `match_asks` visits. ## Constraints / guardrails - Do **not** reopen or retarget [#470](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/470) ACs. Keep `cost.is_zero()` skip so makers are never paid 0 token1 for a positive `fill_t0`. Fix **placement, eviction, and/or skip-vs-park**, not by restoring the free-fill. - Do **not** treat this as [#289](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/289) “pass a hint.” A hint past the clog hides the rows from one taker; it does not unlink them, and head-only / stale-hint paths still pay the scan tax. Indexer `book_start_hint` must not be the only defense. - Do **not** require operators to set non-zero `LIMIT_CLEAN_CONFIG` as the sole fix. Defaults are 0/0 by design ([#263](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/263)); unfillable in-band rows must be handled with that default. - Placement: if `remaining × price` cannot produce `cost ≥ 1` at the accepted human price, **reject** the place / price-update / ladder rung (or require `expires_at`). Equal-decimal and mismatched-decimal human-scale paths ([#529](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/529)) both need the same economic-fillability check. - Match path: a zero-cost **in-band** live ask is a candidate to **park** (same L1 park / claim queue as expiry — no CW20 in the match tx) rather than `continue` forever. Do not park out-of-band legacy dust that [#467](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/467) already skips on reciprocal overflow unless that is documented as the same helper. - Simulation parity: `simulate_match_asks` / indexer `db_orderbook_sim` must match execute (L8). Quotes must not pretend the head is fillable. - Bid-side audit: `match_bids` has the same `cost.is_zero()` skip. If a MAX-band bid can be permanently skipped at the bid head, cover it in this ticket — do not split a sibling. - Preserve L5 scan cap, L1 escrow (no extra CW20 on skip/park), pause/blacklist, and `MAX_EXPIRED_PARKS_PER_SWAP` accounting if park is used (do not starve genuine time-expiry parks without a documented cap split, cf. [#264](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/264) dust vs expiry). - No public on-chain attack transaction. Verification is in-tree unit/integration tests. - Founder-required: CosmWasm pair matching. No community autoland. Do not add `ready`. ## Relevant files | Path | Why | | --- | --- | | `smartcontracts/packages/dex-common/src/limit_placement.rs` | `MIN_LIMIT_PRICE`; `validate_limit_order_price` / ladder accept `human == MIN_LIMIT_PRICE` with no min-fillable-remaining check | | `smartcontracts/contracts/pair/src/orderbook.rs` | `match_asks` / `simulate_match_asks` `cost.is_zero()` continue; ask FIFO head; scan-step counter | | `smartcontracts/packages/dex-common/src/pair.rs` | `MAX_SCAN_STEPS` (500) | | `smartcontracts/contracts/pair/src/limit_book_clean.rs` | `is_force_dust` false when `min_remaining` is 0; no `expires_at` → never parks | | `indexer` `db_orderbook_sim` | Must skip/park with the same rule so quotes do not advertise an unfillable best ask as liquidity | | Pair / `cl8y-dex-tests` orderbook tests | Existing `match_asks_skips_zero_cost_fill_sub_unity_price` stays green; extend for band-floor remaining that can never yield `cost ≥ 1` | | `docs/limit-orders.md` / `docs/contracts-security-audit.md` | Document unfillable-floor vs [#470](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/470) skip vs [#289](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/289) expired clog | ## Recommended direction 1. **Reject at placement** (and `UpdateLimitOrderPrice` / ladder) when `order.amount` (post-fee remaining) cannot satisfy `checked_mul_floor(price) ≥ 1` at that price. Keeps garbage off the book; cheapest invariant. 2. **Match-time park** for live in-band rows that still lose the zero-cost check (legacy rows placed before the gate, or remainder that became unfillable). Unlink + `EXPIRED_LIMIT_CLAIMS` (or existing clean-park helper); count the node toward scan steps; do not `continue` as a healthy skip. 3. Optional belt: `CleanLimitBook` treats “remaining cannot pay `cost ≥ 1` at resting price” as force-eligible even when governance thresholds are 0 — only if (1)+(2) cannot cover legacy state. Prefer matcher/placement so keepers are not load-bearing. 4. Tests: (a) equal-decimal ask at `MIN_LIMIT_PRICE` with remaining that cannot produce `cost ≥ 1` is rejected **or** parked on first match, not left at `HEAD_ASK`; (b) a prefix of such rows of length `MAX_SCAN_STEPS` does not leave later fillable asks unreachable **after** the fix (either they never enter, or they park within the walk / a clean call with default thresholds); (c) [#470](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/470) sub-unity skip with a **fillable** remaining still skips the *dust fill* without giving token0 away; (d) simulation parity. ## Acceptance criteria - AC1. An ask whose human price is `MIN_LIMIT_PRICE` and whose remaining cannot produce `floor(fill × price) ≥ 1` for any `fill ≤ remaining` cannot remain an immortal `HEAD_ASK` / DLL node after placement **or** after one match/clean under **default** clean config (thresholds 0, no `expires_at`). - AC2. `match_asks` still never credits maker 0 token1 for `fill_t0 > 0` ([#470](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/470) tests remain PASS). - AC3. A book whose ask head is a long prefix of such unfillable in-band rows does not spend a full `MAX_SCAN_STEPS` walk with zero parks/unlinks under default config. Either placement forbids the prefix, or the walk/clean parks them. `scan_steps_capped` may still fire on *expired* prefixes ([#289](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/289)); it must not fire solely because of live band-floor skips. - AC4. `simulate_match_asks` and indexer `db_orderbook_sim` match execute (no phantom best-ask liquidity). - AC5. Bid-side zero-cost skip cannot form the same immortal head clog, or is covered by the same gate/park. - AC6. `docs/limit-orders.md` + security matrix distinguish [#470](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/470) skip, [#467](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/467) out-of-band skip, [#289](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/289) expired clog, and this unfillable in-band floor. - AC7. Focused `cargo test` for pair orderbook + `dex-common` placement + `make test-contracts` (or the repo’s documented contract suite) pass. ## Verification (non-exploitative) Do not publish a step-by-step mainnet placement sequence. Verify in unit tests: 1. Placement: `validate_limit_order_price` / place-limit integration — human `MIN_LIMIT_PRICE` with remaining whose `checked_mul_floor` is always 0 is rejected (if that is the chosen gate). 2. Match: seed one in-band floor ask that would have been skipped; assert it is parked or rejected, `HEAD_ASK` moves to a fillable order (or empty), maker token1 unchanged, escrow follows L1 park (no swap-tx CW20 to maker). 3. Scan: seed more than `MAX_SCAN_STEPS` such rows **only in the test harness**, then one fillable ask behind them. After the fix, a hybrid take from head fills the live ask or parks the prefix; it must not return `scan_steps_capped` with the fillable ask untouched **and** all prefix rows still linked. 4. Regression: `match_asks_skips_zero_cost_fill_sub_unity_price` and `place_limit_order_dust_price_rejected` stay green. 5. Clean: with `LIMIT_CLEAN_CONFIG` zeros and no `expires_at`, either clean is not required (placement/match already unlinked) or clean now parks the unfillable-floor case. Existing expired-prefix tests for [#289](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/289) stay the expired story; do not overload them. ## First-pass model recommendation Recommendation: grok-high Rationale: Matching-engine / CosmWasm pair work (founder-required: contracts, wasm). The change crosses placement (`dex-common` human-scale band), `match_asks` skip-vs-park, optional `clean_limit_book` eligibility, simulation/indexer parity, and scan-budget invariants. Security / griefing-DoS, not a local helper. Composer is disallowed for security and for contract/wasm even if the edit were small. Verify with the focused orderbook + placement tests above plus the existing [#470](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/470) / [#467](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/467) suites — not a live-chain clog.
Author
Owner

Verification on 2026-09-24 against current origin/main (54c4868e): keep #1225 open. Exact MIN_LIMIT_PRICE is still accepted; pair matching/simulation and indexer simulation skip zero-cost rows without unlinking them, and default cleaner thresholds are 0/0. Existing tests confirm the #470 no-zero-payout guard, not the #1225 liveness fix. Search found no newer successor issue.

Remaining before close:

  1. Reject in-band orders that cannot produce cost ≥ 1 across place, reprice, and ladder, or park legacy rows under default clean settings.
  2. Prove a long unfillable head prefix cannot exhaust MAX_SCAN_STEPS; audit and cover the bid side.
  3. Add pair execute/simulation/indexer parity tests while preserving escrow, pause, and park-cap invariants.

Related: #467, #470, #529, #263, #264, #289, #1219. Keep the proposed default-greedy change #718 gated until #1225 is resolved.

Verification on 2026-09-24 against current origin/main (54c4868e): keep #1225 open. Exact MIN_LIMIT_PRICE is still accepted; pair matching/simulation and indexer simulation skip zero-cost rows without unlinking them, and default cleaner thresholds are 0/0. Existing tests confirm the #470 no-zero-payout guard, not the #1225 liveness fix. Search found no newer successor issue. Remaining before close: 1. Reject in-band orders that cannot produce cost ≥ 1 across place, reprice, and ladder, or park legacy rows under default clean settings. 2. Prove a long unfillable head prefix cannot exhaust MAX_SCAN_STEPS; audit and cover the bid side. 3. Add pair execute/simulation/indexer parity tests while preserving escrow, pause, and park-cap invariants. Related: #467, #470, #529, #263, #264, #289, #1219. Keep the proposed default-greedy change #718 gated until #1225 is resolved.
Author
Owner

Post-merge status check 2026-09-24: #1225 is marked Closed, but the latest verification comment says to keep it open and lists outstanding liveness checks. PR #1338 merged documentation only; it did not implement the in-band unfillable-order fix. Please reconcile the issue state with its acceptance criteria. No duplicate issue was created.

Post-merge status check 2026-09-24: #1225 is marked Closed, but the latest verification comment says to keep it open and lists outstanding liveness checks. PR #1338 merged documentation only; it did not implement the in-band unfillable-order fix. Please reconcile the issue state with its acceptance criteria. No duplicate issue was created.
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#1225
No description provided.