fix(pair): named min-size for dust limit rungs (Overflow Cannot Sub on tiny batch/ladder) #1219

Closed
opened 2026-09-10 13:41:01 +00:00 by LeonardoLUNC · 3 comments

Summary

PlaceLimitOrderBatch / PlaceLimitOrderLadder (ladder expands into the same batch execute) can fail a whole CW20 send with CosmWasm

Overflow: Cannot Sub with 1 and 3: execute wasm contract failed

when a rung is at single-digit raw units, or when a descending ladder subtracts end - start on Decimal atomics 1 and 3. Retail expected a named minimum size (or a successful place). One dust rung currently reverts healthy rungs.

This issue bundles (do not split):

  1. Named min size at placement — reject a rung whose post–maker-fee remaining is below the existing dust floor (LIMIT_ORDER_DUST_FLUSH_THRESHOLD = 10 raw escrow units). Same gate for batch, ladder, and retail n=1.
  2. Descending ladder math — ladder_prices must step from start toward end when end < start without checked_sub overflow. Boundary-rung hints already assume this (ladder_boundary_rung_index).
  3. dApp preflight — /trade ticket + /limits ladder must not sign a dust rung; show the same minimum in retail copy.

Not implemented on current main. Not a duplicate of closed #467 (price band), #264 (match-time park), or open #1225 (in-band MIN_LIMIT_PRICE asks that skip forever). Leave #1225 open for match/eviction of legacy unfillable heads.

Given / When / Then

  • Given a factory CW20/CW20 pair at default fee_bps (30 → maker 15 bps)

  • When PlaceLimitOrderBatch includes any rung with gross amount in 1…9 (or post-fee remaining < 10)

  • Then the tx reverts with a named ContractError (not Overflow: Cannot Sub …); no book row; CW20 send atomically undone; other rungs in that tx are not partially inserted

  • Given PlaceLimitOrderLadder with start_price > end_price and both prices in the human band (including 18-vs-6 raw atomics 1 and 3)

  • When count ≥ 2 and total_amount is large enough for every rung after the min-size gate

  • Then rungs expand, place, and sum to total_amount — no Overflow

  • Given the official dApp ladder / single place

  • When the user types a total that would assign < 10 raw to any rung

  • Then Place is disabled with a named minimum; no increase_allowance


Current codebase

Retail place is always CW20 Send → pair Receive → Cw20HookMsg::PlaceLimitOrderBatch (one item) or PlaceLimitOrderLadder. Ladder calls expand_limit_ladder then the same execute_place_limit_orders_batch (smartcontracts/contracts/pair/src/limit_placement.rs). Failed txs still look like “the batch” to the wallet.

Amount gate is zero-only

validate_placement_item rejects amount == 0 (ZeroAmount) and the L20 human price band. It does not require a minimum residual. Maker fee is floor(amount × maker_bps / 10_000) with maker_bps = effective_fee_bps / 2 (15 bps at default 30). For amount < 667, fee is 0. remaining_for_book = amount.checked_sub(maker_fee) therefore succeeds for amount = 1.

LimitOrderMakerFeeExceedsAmount (“Limit order amount too small after maker fee”) only fires when maker_fee >= amount (impossible at ≤100% bps for amount > 0 once fee is the floor product). The display string is misleading. There is no test that places amount ∈ {1,9,10}.

Dust exists only at match time

LIMIT_ORDER_DUST_FLUSH_THRESHOLD = 10 (L16 / #264) parks 0 < remaining < 10 after a fill. Placement can rest remaining = 1…9 forever. Default CleanLimitBook thresholds are 0 (#263), so keepers do not evict them. Tier 9 limit_discount_bps = 10000 makes placement free (#514 / I13); internal audit already flagged amount = 1 spam.

Why Cannot Sub with 1 and 3

CosmWasm prints Overflow: Cannot Sub with {a} and {b} for Uint128 a.checked_sub(b) (Decimal uses the atomics). Two in-tree paths match the report:

  1. Descending ladder — ladder_prices does end.checked_sub(start) and maps the error to "ladder price range overflow". On wasm without that map (or if a caller hits a raw ? on Decimal atomics), 18-vs-6 prices Decimal::raw(1) and Decimal::raw(3) pass the human band (1e-6 / 3e-6 > MIN_LIMIT_PRICE) and overflow as Cannot Sub with 1 and 3. ladder_boundary_rung_index already treats start > end as valid. Unit tests only expand ascending prices.
  2. Dust amount vs a larger operand — the fee checked_sub is guarded today; do not assume it stays that way if fee math changes to ceil / min-fee. Fail closed with a named min before any sub.

Validation is all-or-nothing (docs/limit-orders.md). A single dust / overflow rung reverts the entire send (reporter’s “other rungs die with it”). Book-walk skip (LimitInsertStepsExceeded) is unrelated.

dApp

LimitOrderLadderPanel + expandLimitLadder split total / count with remainder on the last rung. total=1, count=3 → [0,0,1] → on-chain ZeroAmount, not Overflow. JS handles descending prices; on-chain ladder does not. Escrow gates compare total human vs wallet (limitOrderEscrowBalanceGate.ts); they do not require per-rung raw ≥ 10. TradeOrderTicket toRawAmount can submit 1 on an 18-dec token. Swarm uses MIN_SWAP_OR_ESCROW_AMOUNT = 500_000 only as a bot heuristic.

Issue Why not a duplicate
#467 closed Price band; amount=1 dust price Decimal::raw(1) is already rejected on equal-dec
#529 Human-scale band; does not min size
#264 Post-fill park; does not reject place
#1225 open Unfillable MIN_PRICE asks at match; keep match/park there
#233 String-concat Uint128 on the client; already fixed
#10 Non-numeric amount field
#342 / #559 Native wrap / zap Cannot Sub — different contracts
#1230 Swap belief_price dust, not limits
#1228 Community-tax Swap extra-debit. Limit Send to a listed pair is inbound 1:1 (T592-1)

Why the new implementation is needed

  1. Wallet error is unusable. Overflow 1 and 3 does not tell the maker the minimum size. Integrators and the dApp cannot branch on it.
  2. Atomic batch grief. One dust rung (ladder remainder, MAX leftover, 18-dec “0.000…001”) burns gas for every other rung and refunds nothing useful until the tx fails.
  3. Descending ladders are specified but broken on-chain whenever end < start (equal-dec human 3 → 1 overflows 1e18-scale; 18-vs-6 raw 3 → 1 is the literal 1 and 3 string).
  4. Book quality. Sub-10 rests are unfillable noise (L16 would park them only after a fill they may never get). T9 can spam amount=1 at gas-only cost.

Pair wasm + a thin dApp gate. No indexer schema. No factory migrate of existing rows (legacy dust stays until cancel / #1225 / clean).


Constraints / guardrails

  • Named error, not skip. Do not treat undersize like LimitInsertStepsExceeded (partial place + refund). Invalid size is all-or-nothing, same as price band / empty batch.
  • Reuse 10. Minimum post-fee remaining = LIMIT_ORDER_DUST_FLUSH_THRESHOLD. Do not invent a second constant unless docs say why. Gross amount must be > maker_fee and remaining ≥ 10.
  • Fee math stays floor. Do not switch maker fee to ceil as a way to “force” the old overflow. Keep maker_fee >= amount → existing variant; add a new variant (or fix the existing one’s semantics) for remaining < 10.
  • Ladder descending. ladder_prices must use an absolute step (start ± i × |end−start|/(count−1)), last rung exactly end. Do not require the UI to sort start < end. Keep L20 on every rung. Do not weaken MIN_LIMIT_PRICE.
  • #1225 stays open. Optional: if remaining.checked_mul_floor(price) < 1, reject here too or document that #1225 owns it. Do not implement match-time park / CleanLimitBook changes in this MR.
  • #467 / #529 / L20 / L23 / L14 / L6 / L1 unchanged. UpdateLimitOrderPrice does not change remaining — out of scope for size. Pause still blocks place. Blacklist / F6 code-id gates stay.
  • Community tax. Do not extra-debit limit Send. Do not mix #1228.
  • dApp. No new shell-panel* / lecture banner (C653 / #489). Disable Place + one short line (e.g. “Minimum per order is 10 units”). Do not broadcast then toast Overflow.
  • No saturating sub on escrow/fee that would place a 0-remaining row or skim the wrong treasury amount.
  • Founder-required CosmWasm pair. No ready until tests exist.

Relevant files

Path Why
smartcontracts/contracts/pair/src/limit_placement.rs validate_placement_item; maker fee; batch loop
smartcontracts/contracts/pair/src/error.rs Named min-size error (fix or replace misleading LimitOrderMakerFeeExceedsAmount copy)
smartcontracts/packages/dex-common/src/limit_placement.rs ladder_prices descending; optional shared min-size helper; tests
smartcontracts/packages/dex-common/src/pair.rs LIMIT_ORDER_DUST_FLUSH_THRESHOLD — document as place + flush floor
smartcontracts/tests/src/limit_order_tests.rs Dust batch, mixed one-bad-rung, descending ladder, 18-vs-6 raw 1/3, T9
frontend-dapp/src/utils/limitOrderLadder.ts Reject zero/dust rungs in preview
frontend-dapp/src/components/trade/LimitOrderLadderPanel.tsx Gate Place
frontend-dapp/src/components/trade/TradeOrderTicket.tsx Single-rung min raw
frontend-dapp/src/utils/limitOrderEscrowBalanceGate.ts Optional shared min-raw helper
docs/limit-orders.md / docs/contracts-security-audit.md Place min vs L16 vs #1225
skills/AGENTS_LIMIT_ORDER_BATCH_LADDER.md Invariant + tests
scripts/qa/verify-issue-1219.sh New make verify-issue-1219

  1. min_limit_place_remaining() in dex-common = LIMIT_ORDER_DUST_FLUSH_THRESHOLD. After computing maker_fee, if amount ≤ maker_fee keep/rename fee-exceeds; else if amount - fee < min return LimitOrderAmountTooSmall { min, actual } (include both in the string). Call from validate_placement_item or immediately after fee (fee depends on bps). Ladder expand_limit_ladder: if any equal-split rung would be < min (including zeros from total < count), StdError with the same min before batch execute.
  2. ladder_prices: span = |end − start|; step = span / (count−1); rung i = start toward end; last = end exactly. Table-test equal-dec 3 → 1, 1 → 3, and 18-vs-6 Decimal::raw(3) → Decimal::raw(1) (must not Overflow).
  3. dApp: expandLimitLadder throws if any amountRaw < 10n; ticket submit if escrow raw < 10. Copy: Minimum size is 10 units (not “Overflow”).
  4. Docs: one paragraph in limit-orders.md place section; L16 note “placement also rejects remaining < 10”. Playbook invariant. make verify-issue-1219 greps the named error + descending test + frontend min.

Acceptance criteria

  • AC1. Batch or retail n=1 with amount ∈ {1,9} (valid in-band price) reverts named min-size; message includes the minimum 10; no Overflow: Cannot Sub.
  • AC2. amount such that post-fee remaining ≥ 10 still places; remaining == amount - floor(amount × maker_bps / 10_000).
  • AC3. Batch of N healthy rungs + one dust rung reverts entirely; zero new ORDERS / pending escrow.
  • AC4. Ladder total_amount that would assign < 10 to any equal-split rung (incl. total < count) reverts named / ladder-invalid before inserts.
  • AC5. Descending ladder start > end with in-band prices (equal-dec and 18-vs-6 raw 3 → 1) places count rungs; prices monotonic toward end; amounts sum to total_amount.
  • AC6. T9 (limit_discount_bps = 10000, fee 0) cannot place amount < 10.
  • AC7. dApp: dust total / dust rung → Place disabled; no allowance tx. Healthy 5-rung ladder unchanged.
  • AC8. make verify-issue-467 / #529 / #264 / #1227 behavior unchanged. #1225 not closed by this MR.
  • AC9. Docs + AGENTS_LIMIT_ORDER_BATCH_LADDER.md + make verify-issue-1219.

Test plan (functional paths)

# Path Expect
T1 Batch ask amount=1, price 1.0, 6/6 Named min-size; no order
T2 Batch amount=9 Same as T1
T3 Batch amount=10, fee 0 or 15 bps (remaining ≥ 10) Places; remaining = amount − fee
T4 Batch amount=667 @ 15 bps (fee 1) Places if remaining ≥ 10
T5 Three rungs 1000,1,1000 Whole tx fails; no ids
T6 Ladder count=3, total=1 Expand/place error; no Overflow
T7 Ladder count=3, total=30 equal-dec 0.95→1.05 Places three rungs of 10
T8 Ladder start Decimal::one()*3, end Decimal::one(), count 3 Places; no Overflow
T9 18-vs-6 ladder Decimal::raw(3) → Decimal::raw(1) No Cannot Sub with 1 and 3; L20 still enforced per rung
T10 Retail dApp human that encodes to raw 1 Button disabled
T11 Existing batch/ladder/hint/partial-skip tests Green
T12 Pause / blacklist place Unchanged reject
T13 Playwright limit-orders-tx 5-rung (LocalTerra) Still places

Contracts: cl8y-dex-tests limit_order_tests + dex-common limit_placement unit. Frontend: limitOrderLadder + ticket min-raw Vitest. 5 Playwright workers when chain E2E is in the MR.


Test plan (attack, hack, and abuse)

# Vector Expect
A1 T9 spam amount=1 × max_batch_rungs Every rung named-reject; book head unchanged
A2 Hide dust as last ladder remainder (total=29, count=3 → 9,9,11 or 9,9,11 depending on split) If any rung < 10, reject all
A3 Integrator omits min and catches Overflow Must match new error string, not Cannot Sub
A4 Descending raw 3→1 on 18-vs-6 to overflow match math Place only if L20 human band holds; still no Overflow sub
A5 Dust ask at band floor to clog match_asks Size gate reduces junk; #1225 still owns zero-cost skip/park
A6 Partial-skip confusion: undersize as LimitInsertStepsExceeded Must not skip+refund; full revert
A7 Saturating sub to force remaining=0 Forbidden; would break escrow vs CW20
A8 Hostile CW20 FoT on place Unchanged F6 / listed-pair 1:1; this issue does not add tax math
A9 Frontend bypass (raw script send amount=1) Chain still named-rejects
A10 UpdateLimitOrderPrice on a legacy remaining=1 row Unchanged (size not re-checked); cancel/claim still work

No public mainnet attack tx. In-tree multitest only.


Verification criteria

  • make verify-issue-1219 (named error + descending ladder unit + frontend min-raw + docs greps).
  • cd smartcontracts && cargo test -p cl8y-dex-tests --test limit_order_tests -- --test-threads=1 paths T1–T9, A1–A6.
  • cd frontend-dapp && Vitest limitOrderLadder + ticket/ladder Place disabled on dust.
  • Manual: /limits ladder total that splits under 10 → no Keplr popup; raise total → place. Descending start/end on UST1/USTR (6/18) and a 18/6 pair if present.
  • Coolify: not required to close; pair wasm migrate is a later ops ticket if columbus-5 still runs pre-fix code.

Out of scope

  • Match-time park / CleanLimitBook defaults (#1225, #264).
  • Changing maker/taker bps split or #514 tier table.
  • Indexer placement schema / new wasm action.
  • Community-tax extra-debit on limit Send.
  • Raising max_batch_rungs or gas envelopes.

Original report

Reporter (LeonardoLUNC). Preserved during issue repair.

Placing a limit-order batch on a cw20/cw20 pair fails on-chain with code 5:

Overflow: Cannot Sub with 1 and 3: execute wasm contract failed

when one or more rungs in the batch carry an amount at the very bottom of the token's precision (single-digit base units). Larger batches on the same pair and the same code path succeed normally.

Expected: either the rung is accepted, or the contract returns a clear validation error naming a minimum order size.

Actual: an Overflow panic that fails the whole batch, so one dust rung rejects the other rungs alongside it.

Suggestion: validate a minimum order amount per rung up front and return a descriptive error, and/or use checked/saturating subtraction on that path.

## Summary `PlaceLimitOrderBatch` / `PlaceLimitOrderLadder` (ladder expands into the same batch execute) can fail a **whole** CW20 `send` with CosmWasm `Overflow: Cannot Sub with 1 and 3: execute wasm contract failed` when a rung is at **single-digit raw units**, or when a **descending** ladder subtracts `end - start` on Decimal atomics `1` and `3`. Retail expected a **named minimum size** (or a successful place). One dust rung currently reverts healthy rungs. This issue **bundles** (do not split): 1. **Named min size at placement** — reject a rung whose **post–maker-fee remaining** is below the existing dust floor (`LIMIT_ORDER_DUST_FLUSH_THRESHOLD` = **10** raw escrow units). Same gate for batch, ladder, and retail `n=1`. 2. **Descending ladder math** — `ladder_prices` must step from `start` toward `end` when `end < start` without `checked_sub` overflow. Boundary-rung hints already assume this (`ladder_boundary_rung_index`). 3. **dApp preflight** — `/trade` ticket + `/limits` ladder must not sign a dust rung; show the same minimum in retail copy. Not implemented on current `main`. Not a duplicate of closed [#467](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/467) (price band), [#264](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/264) (match-time park), or open [#1225](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/1225) (in-band `MIN_LIMIT_PRICE` asks that skip forever). Leave #1225 open for match/eviction of legacy unfillable heads. ### Given / When / Then - **Given** a factory CW20/CW20 pair at default `fee_bps` (30 → maker 15 bps) - **When** `PlaceLimitOrderBatch` includes any rung with gross `amount` in `1…9` (or post-fee remaining `< 10`) - **Then** the tx reverts with a **named** `ContractError` (not `Overflow: Cannot Sub …`); no book row; CW20 send atomically undone; other rungs in that tx are not partially inserted - **Given** `PlaceLimitOrderLadder` with `start_price > end_price` and both prices in the human band (including 18-vs-6 raw atomics `1` and `3`) - **When** `count ≥ 2` and `total_amount` is large enough for every rung after the min-size gate - **Then** rungs expand, place, and sum to `total_amount` — no Overflow - **Given** the official dApp ladder / single place - **When** the user types a total that would assign `< 10` raw to any rung - **Then** Place is disabled with a named minimum; no `increase_allowance` --- ## Current codebase Retail place is always CW20 `Send` → pair `Receive` → **`Cw20HookMsg::PlaceLimitOrderBatch`** (one item) or **`PlaceLimitOrderLadder`**. Ladder calls `expand_limit_ladder` then the **same** `execute_place_limit_orders_batch` ([`smartcontracts/contracts/pair/src/limit_placement.rs`](smartcontracts/contracts/pair/src/limit_placement.rs)). Failed txs still look like “the batch” to the wallet. ### Amount gate is zero-only `validate_placement_item` rejects `amount == 0` (`ZeroAmount`) and the **L20** human price band. It does **not** require a minimum residual. Maker fee is `floor(amount × maker_bps / 10_000)` with `maker_bps = effective_fee_bps / 2` (15 bps at default 30). For `amount < 667`, fee is **0**. `remaining_for_book = amount.checked_sub(maker_fee)` therefore succeeds for `amount = 1`. `LimitOrderMakerFeeExceedsAmount` (“Limit order amount too small after maker fee”) only fires when `maker_fee >= amount` (impossible at ≤100% bps for `amount > 0` once fee is the floor product). The display string is misleading. There is **no** test that places `amount ∈ {1,9,10}`. ### Dust exists only at match time [`LIMIT_ORDER_DUST_FLUSH_THRESHOLD = 10`](smartcontracts/packages/dex-common/src/pair.rs) (**L16** / [#264](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/264)) parks `0 < remaining < 10` **after a fill**. Placement can rest `remaining = 1…9` forever. Default `CleanLimitBook` thresholds are **0** ([#263](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/263)), so keepers do not evict them. Tier 9 `limit_discount_bps = 10000` makes placement **free** ([#514](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/514) / **I13**); internal audit already flagged `amount = 1` spam. ### Why `Cannot Sub with 1 and 3` CosmWasm prints `Overflow: Cannot Sub with {a} and {b}` for `Uint128` `a.checked_sub(b)` (Decimal uses the **atomics**). Two in-tree paths match the report: 1. **Descending ladder** — [`ladder_prices`](smartcontracts/packages/dex-common/src/limit_placement.rs) does `end.checked_sub(start)` and maps the error to `"ladder price range overflow"`. On wasm without that map (or if a caller hits a raw `?` on Decimal atomics), **18-vs-6** prices `Decimal::raw(1)` and `Decimal::raw(3)` **pass** the human band (`1e-6` / `3e-6` > `MIN_LIMIT_PRICE`) and overflow as **`Cannot Sub with 1 and 3`**. `ladder_boundary_rung_index` already treats `start > end` as valid. Unit tests only expand **ascending** prices. 2. **Dust amount vs a larger operand** — the fee `checked_sub` is guarded today; do not assume it stays that way if fee math changes to ceil / min-fee. Fail closed with a named min before any sub. Validation is **all-or-nothing** (docs/`limit-orders.md`). A single dust / overflow rung reverts the entire `send` (reporter’s “other rungs die with it”). Book-walk skip (`LimitInsertStepsExceeded`) is unrelated. ### dApp [`LimitOrderLadderPanel`](frontend-dapp/src/components/trade/LimitOrderLadderPanel.tsx) + [`expandLimitLadder`](frontend-dapp/src/utils/limitOrderLadder.ts) split `total / count` with remainder on the last rung. `total=1`, `count=3` → `[0,0,1]` → on-chain `ZeroAmount`, not Overflow. JS handles descending prices; **on-chain ladder does not**. Escrow gates compare **total** human vs wallet ([`limitOrderEscrowBalanceGate.ts`](frontend-dapp/src/utils/limitOrderEscrowBalanceGate.ts)); they do **not** require per-rung raw ≥ 10. [`TradeOrderTicket`](frontend-dapp/src/components/trade/TradeOrderTicket.tsx) `toRawAmount` can submit `1` on an 18-dec token. Swarm uses `MIN_SWAP_OR_ESCROW_AMOUNT = 500_000` only as a bot heuristic. ### Related tickets (not this work) | Issue | Why not a duplicate | | --- | --- | | [#467](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/467) closed | **Price** band; `amount=1` dust *price* `Decimal::raw(1)` is already rejected on equal-dec | | [#529](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/529) | Human-scale band; does not min **size** | | [#264](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/264) | Post-**fill** park; does not reject place | | [#1225](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/1225) open | Unfillable **MIN_PRICE** asks at match; keep match/park there | | [#233](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/233) | String-concat Uint128 on the client; already fixed | | [#10](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/10) | Non-numeric amount field | | [#342](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/342) / [#559](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/559) | Native wrap / zap `Cannot Sub` — different contracts | | [#1230](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/1230) | Swap `belief_price` dust, not limits | | [#1228](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/1228) | Community-tax **Swap** extra-debit. Limit `Send` to a listed pair is inbound 1:1 (**T592-1**) | --- ## Why the new implementation is needed 1. **Wallet error is unusable.** Overflow `1` and `3` does not tell the maker the minimum size. Integrators and the dApp cannot branch on it. 2. **Atomic batch grief.** One dust rung (ladder remainder, MAX leftover, 18-dec “0.000…001”) burns gas for every other rung and refunds nothing useful until the tx fails. 3. **Descending ladders are specified but broken** on-chain whenever `end < start` (equal-dec human `3 → 1` overflows 1e18-scale; 18-vs-6 raw `3 → 1` is the literal `1` and `3` string). 4. **Book quality.** Sub-10 rests are unfillable noise (**L16** would park them only after a fill they may never get). T9 can spam `amount=1` at gas-only cost. Pair wasm + a thin dApp gate. No indexer schema. No factory migrate of existing rows (legacy dust stays until cancel / #1225 / clean). --- ## Constraints / guardrails - **Named error, not skip.** Do **not** treat undersize like `LimitInsertStepsExceeded` (partial place + refund). Invalid size is all-or-nothing, same as price band / empty batch. - **Reuse 10.** Minimum **post-fee remaining** = `LIMIT_ORDER_DUST_FLUSH_THRESHOLD`. Do not invent a second constant unless docs say why. Gross `amount` must be `> maker_fee` and remaining `≥ 10`. - **Fee math stays floor.** Do not switch maker fee to ceil as a way to “force” the old overflow. Keep `maker_fee >= amount` → existing variant; add a **new** variant (or fix the existing one’s semantics) for remaining `< 10`. - **Ladder descending.** `ladder_prices` must use an absolute step (`start ± i × |end−start|/(count−1)`), last rung exactly `end`. Do not require the UI to sort start < end. Keep **L20** on every rung. Do not weaken `MIN_LIMIT_PRICE`. - **#1225 stays open.** Optional: if `remaining.checked_mul_floor(price) < 1`, reject here too **or** document that #1225 owns it. Do not implement match-time park / `CleanLimitBook` changes in this MR. - **#467 / #529 / L20 / L23 / L14 / L6 / L1** unchanged. `UpdateLimitOrderPrice` does not change `remaining` — out of scope for size. Pause still blocks place. Blacklist / F6 code-id gates stay. - **Community tax.** Do not extra-debit limit `Send`. Do not mix #1228. - **dApp.** No new `shell-panel*` / lecture banner (**C653** / #489). Disable Place + one short line (e.g. “Minimum per order is 10 units”). Do not broadcast then toast Overflow. - **No saturating sub on escrow/fee** that would place a 0-remaining row or skim the wrong treasury amount. - Founder-required CosmWasm pair. No `ready` until tests exist. --- ## Relevant files | Path | Why | | --- | --- | | `smartcontracts/contracts/pair/src/limit_placement.rs` | `validate_placement_item`; maker fee; batch loop | | `smartcontracts/contracts/pair/src/error.rs` | Named min-size error (fix or replace misleading `LimitOrderMakerFeeExceedsAmount` copy) | | `smartcontracts/packages/dex-common/src/limit_placement.rs` | `ladder_prices` descending; optional shared min-size helper; tests | | `smartcontracts/packages/dex-common/src/pair.rs` | `LIMIT_ORDER_DUST_FLUSH_THRESHOLD` — document as place + flush floor | | `smartcontracts/tests/src/limit_order_tests.rs` | Dust batch, mixed one-bad-rung, descending ladder, 18-vs-6 raw 1/3, T9 | | `frontend-dapp/src/utils/limitOrderLadder.ts` | Reject zero/dust rungs in preview | | `frontend-dapp/src/components/trade/LimitOrderLadderPanel.tsx` | Gate Place | | `frontend-dapp/src/components/trade/TradeOrderTicket.tsx` | Single-rung min raw | | `frontend-dapp/src/utils/limitOrderEscrowBalanceGate.ts` | Optional shared min-raw helper | | `docs/limit-orders.md` / `docs/contracts-security-audit.md` | Place min vs L16 vs #1225 | | `skills/AGENTS_LIMIT_ORDER_BATCH_LADDER.md` | Invariant + tests | | `scripts/qa/verify-issue-1219.sh` | New `make verify-issue-1219` | --- ## Recommended direction 1. **`min_limit_place_remaining()`** in `dex-common` = `LIMIT_ORDER_DUST_FLUSH_THRESHOLD`. After computing `maker_fee`, if `amount ≤ maker_fee` keep/rename fee-exceeds; else if `amount - fee < min` return **`LimitOrderAmountTooSmall { min, actual }`** (include both in the string). Call from `validate_placement_item` **or** immediately after fee (fee depends on bps). Ladder `expand_limit_ladder`: if any equal-split rung would be `< min` (including zeros from `total < count`), `StdError` with the same min **before** batch execute. 2. **`ladder_prices`:** `span = |end − start|`; `step = span / (count−1)`; rung `i` = `start` toward `end`; last = `end` exactly. Table-test equal-dec `3 → 1`, `1 → 3`, and 18-vs-6 `Decimal::raw(3) → Decimal::raw(1)` (must not Overflow). 3. **dApp:** `expandLimitLadder` throws if any `amountRaw < 10n`; ticket submit if escrow raw `< 10`. Copy: **Minimum size is 10 units** (not “Overflow”). 4. **Docs:** one paragraph in `limit-orders.md` place section; L16 note “placement also rejects remaining `< 10`”. Playbook invariant. `make verify-issue-1219` greps the named error + descending test + frontend min. --- ## Acceptance criteria - [ ] **AC1.** Batch or retail `n=1` with `amount ∈ {1,9}` (valid in-band price) reverts **named** min-size; message includes the minimum `10`; **no** `Overflow: Cannot Sub`. - [ ] **AC2.** `amount` such that post-fee remaining `≥ 10` still places; `remaining == amount - floor(amount × maker_bps / 10_000)`. - [ ] **AC3.** Batch of N healthy rungs + **one** dust rung reverts entirely; **zero** new `ORDERS` / pending escrow. - [ ] **AC4.** Ladder `total_amount` that would assign `< 10` to any equal-split rung (incl. `total < count`) reverts named / ladder-invalid **before** inserts. - [ ] **AC5.** Descending ladder `start > end` with in-band prices (equal-dec and 18-vs-6 raw `3 → 1`) places `count` rungs; prices monotonic toward `end`; amounts sum to `total_amount`. - [ ] **AC6.** T9 (`limit_discount_bps = 10000`, fee 0) cannot place `amount < 10`. - [ ] **AC7.** dApp: dust total / dust rung → Place disabled; no allowance tx. Healthy 5-rung ladder unchanged. - [ ] **AC8.** `make verify-issue-467` / `#529` / `#264` / `#1227` behavior unchanged. `#1225` not closed by this MR. - [ ] **AC9.** Docs + `AGENTS_LIMIT_ORDER_BATCH_LADDER.md` + `make verify-issue-1219`. --- ## Test plan (functional paths) | # | Path | Expect | |---|------|--------| | T1 | Batch ask `amount=1`, price 1.0, 6/6 | Named min-size; no order | | T2 | Batch `amount=9` | Same as T1 | | T3 | Batch `amount=10`, fee 0 or 15 bps (remaining ≥ 10) | Places; remaining = amount − fee | | T4 | Batch `amount=667` @ 15 bps (fee 1) | Places if remaining ≥ 10 | | T5 | Three rungs `1000,1,1000` | Whole tx fails; no ids | | T6 | Ladder `count=3`, `total=1` | Expand/place error; no Overflow | | T7 | Ladder `count=3`, `total=30` equal-dec 0.95→1.05 | Places three rungs of 10 | | T8 | Ladder start `Decimal::one()*3`, end `Decimal::one()`, count 3 | Places; no Overflow | | T9 | 18-vs-6 ladder `Decimal::raw(3)` → `Decimal::raw(1)` | No `Cannot Sub with 1 and 3`; L20 still enforced per rung | | T10 | Retail dApp human that encodes to raw 1 | Button disabled | | T11 | Existing batch/ladder/hint/partial-skip tests | Green | | T12 | Pause / blacklist place | Unchanged reject | | T13 | Playwright `limit-orders-tx` 5-rung (LocalTerra) | Still places | Contracts: `cl8y-dex-tests` `limit_order_tests` + `dex-common` `limit_placement` unit. Frontend: `limitOrderLadder` + ticket min-raw Vitest. 5 Playwright workers when chain E2E is in the MR. --- ## Test plan (attack, hack, and abuse) | # | Vector | Expect | |---|--------|--------| | A1 | T9 spam `amount=1` × `max_batch_rungs` | Every rung named-reject; book head unchanged | | A2 | Hide dust as last ladder remainder (`total=29`, `count=3` → 9,9,11 or 9,9,11 depending on split) | If any rung `< 10`, reject all | | A3 | Integrator omits min and catches Overflow | Must match new error string, not `Cannot Sub` | | A4 | Descending raw 3→1 on 18-vs-6 to overflow match math | Place only if **L20** human band holds; still no Overflow sub | | A5 | Dust ask at band floor to clog `match_asks` | Size gate reduces junk; **#1225** still owns zero-cost skip/park | | A6 | Partial-skip confusion: undersize as `LimitInsertStepsExceeded` | Must **not** skip+refund; full revert | | A7 | Saturating sub to force `remaining=0` | Forbidden; would break escrow vs CW20 | | A8 | Hostile CW20 FoT on place | Unchanged F6 / listed-pair 1:1; this issue does not add tax math | | A9 | Frontend bypass (raw script `send` amount=1) | Chain still named-rejects | | A10 | `UpdateLimitOrderPrice` on a legacy `remaining=1` row | Unchanged (size not re-checked); cancel/claim still work | No public mainnet attack tx. In-tree multitest only. --- ## Verification criteria - `make verify-issue-1219` (named error + descending ladder unit + frontend min-raw + docs greps). - `cd smartcontracts && cargo test -p cl8y-dex-tests --test limit_order_tests -- --test-threads=1` paths T1–T9, A1–A6. - `cd frontend-dapp &&` Vitest `limitOrderLadder` + ticket/ladder Place disabled on dust. - Manual: `/limits` ladder total that splits under 10 → no Keplr popup; raise total → place. Descending start/end on UST1/USTR (6/18) and a 18/6 pair if present. - Coolify: not required to close; pair wasm migrate is a later ops ticket if columbus-5 still runs pre-fix code. ## Out of scope - Match-time park / `CleanLimitBook` defaults ([#1225](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/1225), [#264](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/264)). - Changing maker/taker bps split or #514 tier table. - Indexer placement schema / new wasm action. - Community-tax extra-debit on limit Send. - Raising `max_batch_rungs` or gas envelopes. ## Original report Reporter (LeonardoLUNC). Preserved during issue repair. Placing a limit-order batch on a cw20/cw20 pair fails on-chain with code 5: `Overflow: Cannot Sub with 1 and 3: execute wasm contract failed` when one or more rungs in the batch carry an amount at the very bottom of the token's precision (single-digit base units). Larger batches on the same pair and the same code path succeed normally. Expected: either the rung is accepted, or the contract returns a clear validation error naming a minimum order size. Actual: an Overflow panic that fails the whole batch, so one dust rung rejects the other rungs alongside it. Suggestion: validate a minimum order amount per rung up front and return a descriptive error, and/or use checked/saturating subtraction on that path.

Approved for issue repair -> implement

Approved for issue repair -> implement
PlasticDigits changed title from place_limit_order_batch fails with "Overflow: Cannot Sub with 1 and 3" on very small order amounts to fix(pair): named min-size for dust limit rungs (Overflow Cannot Sub on tiny batch/ladder) 2026-09-21 04:23:05 +00:00

Issue repair — Definition of Ready written from current main (pair limit_placement + dex-common ladder expand + dApp ladder/ticket).

Not implemented. Placement still allows amount=1…9. Descending ladder_prices still does end.checked_sub(start).

Not a duplicate. Do not close #467, #264, or #1225. Bundle stays: named min remaining (=10), descending ladder math, dApp preflight. Original reporter text is appended on the issue body.

Summary

PlaceLimitOrderBatch / PlaceLimitOrderLadder (ladder expands into the same batch execute) can fail a whole CW20 send with CosmWasm

Overflow: Cannot Sub with 1 and 3: execute wasm contract failed

when a rung is at single-digit raw units, or when a descending ladder subtracts end - start on Decimal atomics 1 and 3. Retail expected a named minimum size (or a successful place). One dust rung currently reverts healthy rungs.

This issue bundles (do not split):

  1. Named min size at placement — reject a rung whose post–maker-fee remaining is below the existing dust floor (LIMIT_ORDER_DUST_FLUSH_THRESHOLD = 10 raw escrow units). Same gate for batch, ladder, and retail n=1.
  2. Descending ladder math — ladder_prices must step from start toward end when end < start without checked_sub overflow. Boundary-rung hints already assume this (ladder_boundary_rung_index).
  3. dApp preflight — /trade ticket + /limits ladder must not sign a dust rung; show the same minimum in retail copy.

Not implemented on current main. Not a duplicate of closed #467 (price band), #264 (match-time park), or open #1225 (in-band MIN_LIMIT_PRICE asks that skip forever). Leave #1225 open for match/eviction of legacy unfillable heads.

Given / When / Then

  • Given a factory CW20/CW20 pair at default fee_bps (30 → maker 15 bps)

  • When PlaceLimitOrderBatch includes any rung with gross amount in 1…9 (or post-fee remaining < 10)

  • Then the tx reverts with a named ContractError (not Overflow: Cannot Sub …); no book row; CW20 send atomically undone; other rungs in that tx are not partially inserted

  • Given PlaceLimitOrderLadder with start_price > end_price and both prices in the human band (including 18-vs-6 raw atomics 1 and 3)

  • When count ≥ 2 and total_amount is large enough for every rung after the min-size gate

  • Then rungs expand, place, and sum to total_amount — no Overflow

  • Given the official dApp ladder / single place

  • When the user types a total that would assign < 10 raw to any rung

  • Then Place is disabled with a named minimum; no increase_allowance


Current codebase

Retail place is always CW20 Send → pair Receive → Cw20HookMsg::PlaceLimitOrderBatch (one item) or PlaceLimitOrderLadder. Ladder calls expand_limit_ladder then the same execute_place_limit_orders_batch (smartcontracts/contracts/pair/src/limit_placement.rs). Failed txs still look like “the batch” to the wallet.

Amount gate is zero-only

validate_placement_item rejects amount == 0 (ZeroAmount) and the L20 human price band. It does not require a minimum residual. Maker fee is floor(amount × maker_bps / 10_000) with maker_bps = effective_fee_bps / 2 (15 bps at default 30). For amount < 667, fee is 0. remaining_for_book = amount.checked_sub(maker_fee) therefore succeeds for amount = 1.

LimitOrderMakerFeeExceedsAmount (“Limit order amount too small after maker fee”) only fires when maker_fee >= amount (impossible at ≤100% bps for amount > 0 once fee is the floor product). The display string is misleading. There is no test that places amount ∈ {1,9,10}.

Dust exists only at match time

LIMIT_ORDER_DUST_FLUSH_THRESHOLD = 10 (L16 / #264) parks 0 < remaining < 10 after a fill. Placement can rest remaining = 1…9 forever. Default CleanLimitBook thresholds are 0 (#263), so keepers do not evict them. Tier 9 limit_discount_bps = 10000 makes placement free (#514 / I13); internal audit already flagged amount = 1 spam.

Why Cannot Sub with 1 and 3

CosmWasm prints Overflow: Cannot Sub with {a} and {b} for Uint128 a.checked_sub(b) (Decimal uses the atomics). Two in-tree paths match the report:

  1. Descending ladder — ladder_prices does end.checked_sub(start) and maps the error to "ladder price range overflow". On wasm without that map (or if a caller hits a raw ? on Decimal atomics), 18-vs-6 prices Decimal::raw(1) and Decimal::raw(3) pass the human band (1e-6 / 3e-6 > MIN_LIMIT_PRICE) and overflow as Cannot Sub with 1 and 3. ladder_boundary_rung_index already treats start > end as valid. Unit tests only expand ascending prices.
  2. Dust amount vs a larger operand — the fee checked_sub is guarded today; do not assume it stays that way if fee math changes to ceil / min-fee. Fail closed with a named min before any sub.

Validation is all-or-nothing (docs/limit-orders.md). A single dust / overflow rung reverts the entire send (reporter’s “other rungs die with it”). Book-walk skip (LimitInsertStepsExceeded) is unrelated.

dApp

LimitOrderLadderPanel + expandLimitLadder split total / count with remainder on the last rung. total=1, count=3 → [0,0,1] → on-chain ZeroAmount, not Overflow. JS handles descending prices; on-chain ladder does not. Escrow gates compare total human vs wallet (limitOrderEscrowBalanceGate.ts); they do not require per-rung raw ≥ 10. TradeOrderTicket toRawAmount can submit 1 on an 18-dec token. Swarm uses MIN_SWAP_OR_ESCROW_AMOUNT = 500_000 only as a bot heuristic.

Issue Why not a duplicate
#467 closed Price band; amount=1 dust price Decimal::raw(1) is already rejected on equal-dec
#529 Human-scale band; does not min size
#264 Post-fill park; does not reject place
#1225 open Unfillable MIN_PRICE asks at match; keep match/park there
#233 String-concat Uint128 on the client; already fixed
#10 Non-numeric amount field
#342 / #559 Native wrap / zap Cannot Sub — different contracts
#1230 Swap belief_price dust, not limits
#1228 Community-tax Swap extra-debit. Limit Send to a listed pair is inbound 1:1 (T592-1)

Why the new implementation is needed

  1. Wallet error is unusable. Overflow 1 and 3 does not tell the maker the minimum size. Integrators and the dApp cannot branch on it.
  2. Atomic batch grief. One dust rung (ladder remainder, MAX leftover, 18-dec “0.000…001”) burns gas for every other rung and refunds nothing useful until the tx fails.
  3. Descending ladders are specified but broken on-chain whenever end < start (equal-dec human 3 → 1 overflows 1e18-scale; 18-vs-6 raw 3 → 1 is the literal 1 and 3 string).
  4. Book quality. Sub-10 rests are unfillable noise (L16 would park them only after a fill they may never get). T9 can spam amount=1 at gas-only cost.

Pair wasm + a thin dApp gate. No indexer schema. No factory migrate of existing rows (legacy dust stays until cancel / #1225 / clean).


Constraints / guardrails

  • Named error, not skip. Do not treat undersize like LimitInsertStepsExceeded (partial place + refund). Invalid size is all-or-nothing, same as price band / empty batch.
  • Reuse 10. Minimum post-fee remaining = LIMIT_ORDER_DUST_FLUSH_THRESHOLD. Do not invent a second constant unless docs say why. Gross amount must be > maker_fee and remaining ≥ 10.
  • Fee math stays floor. Do not switch maker fee to ceil as a way to “force” the old overflow. Keep maker_fee >= amount → existing variant; add a new variant (or fix the existing one’s semantics) for remaining < 10.
  • Ladder descending. ladder_prices must use an absolute step (start ± i × |end−start|/(count−1)), last rung exactly end. Do not require the UI to sort start < end. Keep L20 on every rung. Do not weaken MIN_LIMIT_PRICE.
  • #1225 stays open. Optional: if remaining.checked_mul_floor(price) < 1, reject here too or document that #1225 owns it. Do not implement match-time park / CleanLimitBook changes in this MR.
  • #467 / #529 / L20 / L23 / L14 / L6 / L1 unchanged. UpdateLimitOrderPrice does not change remaining — out of scope for size. Pause still blocks place. Blacklist / F6 code-id gates stay.
  • Community tax. Do not extra-debit limit Send. Do not mix #1228.
  • dApp. No new shell-panel* / lecture banner (C653 / #489). Disable Place + one short line (e.g. “Minimum per order is 10 units”). Do not broadcast then toast Overflow.
  • No saturating sub on escrow/fee that would place a 0-remaining row or skim the wrong treasury amount.
  • Founder-required CosmWasm pair. No ready until tests exist.

Relevant files

Path Why
smartcontracts/contracts/pair/src/limit_placement.rs validate_placement_item; maker fee; batch loop
smartcontracts/contracts/pair/src/error.rs Named min-size error (fix or replace misleading LimitOrderMakerFeeExceedsAmount copy)
smartcontracts/packages/dex-common/src/limit_placement.rs ladder_prices descending; optional shared min-size helper; tests
smartcontracts/packages/dex-common/src/pair.rs LIMIT_ORDER_DUST_FLUSH_THRESHOLD — document as place + flush floor
smartcontracts/tests/src/limit_order_tests.rs Dust batch, mixed one-bad-rung, descending ladder, 18-vs-6 raw 1/3, T9
frontend-dapp/src/utils/limitOrderLadder.ts Reject zero/dust rungs in preview
frontend-dapp/src/components/trade/LimitOrderLadderPanel.tsx Gate Place
frontend-dapp/src/components/trade/TradeOrderTicket.tsx Single-rung min raw
frontend-dapp/src/utils/limitOrderEscrowBalanceGate.ts Optional shared min-raw helper
docs/limit-orders.md / docs/contracts-security-audit.md Place min vs L16 vs #1225
skills/AGENTS_LIMIT_ORDER_BATCH_LADDER.md Invariant + tests
scripts/qa/verify-issue-1219.sh New make verify-issue-1219

  1. min_limit_place_remaining() in dex-common = LIMIT_ORDER_DUST_FLUSH_THRESHOLD. After computing maker_fee, if amount ≤ maker_fee keep/rename fee-exceeds; else if amount - fee < min return LimitOrderAmountTooSmall { min, actual } (include both in the string). Call from validate_placement_item or immediately after fee (fee depends on bps). Ladder expand_limit_ladder: if any equal-split rung would be < min (including zeros from total < count), StdError with the same min before batch execute.
  2. ladder_prices: span = |end − start|; step = span / (count−1); rung i = start toward end; last = end exactly. Table-test equal-dec 3 → 1, 1 → 3, and 18-vs-6 Decimal::raw(3) → Decimal::raw(1) (must not Overflow).
  3. dApp: expandLimitLadder throws if any amountRaw < 10n; ticket submit if escrow raw < 10. Copy: Minimum size is 10 units (not “Overflow”).
  4. Docs: one paragraph in limit-orders.md place section; L16 note “placement also rejects remaining < 10”. Playbook invariant. make verify-issue-1219 greps the named error + descending test + frontend min.

Acceptance criteria

  • AC1. Batch or retail n=1 with amount ∈ {1,9} (valid in-band price) reverts named min-size; message includes the minimum 10; no Overflow: Cannot Sub.
  • AC2. amount such that post-fee remaining ≥ 10 still places; remaining == amount - floor(amount × maker_bps / 10_000).
  • AC3. Batch of N healthy rungs + one dust rung reverts entirely; zero new ORDERS / pending escrow.
  • AC4. Ladder total_amount that would assign < 10 to any equal-split rung (incl. total < count) reverts named / ladder-invalid before inserts.
  • AC5. Descending ladder start > end with in-band prices (equal-dec and 18-vs-6 raw 3 → 1) places count rungs; prices monotonic toward end; amounts sum to total_amount.
  • AC6. T9 (limit_discount_bps = 10000, fee 0) cannot place amount < 10.
  • AC7. dApp: dust total / dust rung → Place disabled; no allowance tx. Healthy 5-rung ladder unchanged.
  • AC8. make verify-issue-467 / #529 / #264 / #1227 behavior unchanged. #1225 not closed by this MR.
  • AC9. Docs + AGENTS_LIMIT_ORDER_BATCH_LADDER.md + make verify-issue-1219.

Test plan (functional paths)

# Path Expect
T1 Batch ask amount=1, price 1.0, 6/6 Named min-size; no order
T2 Batch amount=9 Same as T1
T3 Batch amount=10, fee 0 or 15 bps (remaining ≥ 10) Places; remaining = amount − fee
T4 Batch amount=667 @ 15 bps (fee 1) Places if remaining ≥ 10
T5 Three rungs 1000,1,1000 Whole tx fails; no ids
T6 Ladder count=3, total=1 Expand/place error; no Overflow
T7 Ladder count=3, total=30 equal-dec 0.95→1.05 Places three rungs of 10
T8 Ladder start Decimal::one()*3, end Decimal::one(), count 3 Places; no Overflow
T9 18-vs-6 ladder Decimal::raw(3) → Decimal::raw(1) No Cannot Sub with 1 and 3; L20 still enforced per rung
T10 Retail dApp human that encodes to raw 1 Button disabled
T11 Existing batch/ladder/hint/partial-skip tests Green
T12 Pause / blacklist place Unchanged reject
T13 Playwright limit-orders-tx 5-rung (LocalTerra) Still places

Contracts: cl8y-dex-tests limit_order_tests + dex-common limit_placement unit. Frontend: limitOrderLadder + ticket min-raw Vitest. 5 Playwright workers when chain E2E is in the MR.


Test plan (attack, hack, and abuse)

# Vector Expect
A1 T9 spam amount=1 × max_batch_rungs Every rung named-reject; book head unchanged
A2 Hide dust as last ladder remainder (total=29, count=3 → 9,9,11 or 9,9,11 depending on split) If any rung < 10, reject all
A3 Integrator omits min and catches Overflow Must match new error string, not Cannot Sub
A4 Descending raw 3→1 on 18-vs-6 to overflow match math Place only if L20 human band holds; still no Overflow sub
A5 Dust ask at band floor to clog match_asks Size gate reduces junk; #1225 still owns zero-cost skip/park
A6 Partial-skip confusion: undersize as LimitInsertStepsExceeded Must not skip+refund; full revert
A7 Saturating sub to force remaining=0 Forbidden; would break escrow vs CW20
A8 Hostile CW20 FoT on place Unchanged F6 / listed-pair 1:1; this issue does not add tax math
A9 Frontend bypass (raw script send amount=1) Chain still named-rejects
A10 UpdateLimitOrderPrice on a legacy remaining=1 row Unchanged (size not re-checked); cancel/claim still work

No public mainnet attack tx. In-tree multitest only.


Verification criteria

  • make verify-issue-1219 (named error + descending ladder unit + frontend min-raw + docs greps).
  • cd smartcontracts && cargo test -p cl8y-dex-tests --test limit_order_tests -- --test-threads=1 paths T1–T9, A1–A6.
  • cd frontend-dapp && Vitest limitOrderLadder + ticket/ladder Place disabled on dust.
  • Manual: /limits ladder total that splits under 10 → no Keplr popup; raise total → place. Descending start/end on UST1/USTR (6/18) and a 18/6 pair if present.
  • Coolify: not required to close; pair wasm migrate is a later ops ticket if columbus-5 still runs pre-fix code.

Out of scope

  • Match-time park / CleanLimitBook defaults (#1225, #264).
  • Changing maker/taker bps split or #514 tier table.
  • Indexer placement schema / new wasm action.
  • Community-tax extra-debit on limit Send.
  • Raising max_batch_rungs or gas envelopes.
Issue repair — Definition of Ready written from current `main` (pair `limit_placement` + `dex-common` ladder expand + dApp ladder/ticket). **Not implemented.** Placement still allows `amount=1…9`. Descending `ladder_prices` still does `end.checked_sub(start)`. **Not a duplicate.** Do not close [#467](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/467), [#264](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/264), or [#1225](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/1225). Bundle stays: named min remaining (=10), descending ladder math, dApp preflight. Original reporter text is appended on the issue body. ## Summary `PlaceLimitOrderBatch` / `PlaceLimitOrderLadder` (ladder expands into the same batch execute) can fail a **whole** CW20 `send` with CosmWasm `Overflow: Cannot Sub with 1 and 3: execute wasm contract failed` when a rung is at **single-digit raw units**, or when a **descending** ladder subtracts `end - start` on Decimal atomics `1` and `3`. Retail expected a **named minimum size** (or a successful place). One dust rung currently reverts healthy rungs. This issue **bundles** (do not split): 1. **Named min size at placement** — reject a rung whose **post–maker-fee remaining** is below the existing dust floor (`LIMIT_ORDER_DUST_FLUSH_THRESHOLD` = **10** raw escrow units). Same gate for batch, ladder, and retail `n=1`. 2. **Descending ladder math** — `ladder_prices` must step from `start` toward `end` when `end < start` without `checked_sub` overflow. Boundary-rung hints already assume this (`ladder_boundary_rung_index`). 3. **dApp preflight** — `/trade` ticket + `/limits` ladder must not sign a dust rung; show the same minimum in retail copy. Not implemented on current `main`. Not a duplicate of closed [#467](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/467) (price band), [#264](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/264) (match-time park), or open [#1225](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/1225) (in-band `MIN_LIMIT_PRICE` asks that skip forever). Leave #1225 open for match/eviction of legacy unfillable heads. ### Given / When / Then - **Given** a factory CW20/CW20 pair at default `fee_bps` (30 → maker 15 bps) - **When** `PlaceLimitOrderBatch` includes any rung with gross `amount` in `1…9` (or post-fee remaining `< 10`) - **Then** the tx reverts with a **named** `ContractError` (not `Overflow: Cannot Sub …`); no book row; CW20 send atomically undone; other rungs in that tx are not partially inserted - **Given** `PlaceLimitOrderLadder` with `start_price > end_price` and both prices in the human band (including 18-vs-6 raw atomics `1` and `3`) - **When** `count ≥ 2` and `total_amount` is large enough for every rung after the min-size gate - **Then** rungs expand, place, and sum to `total_amount` — no Overflow - **Given** the official dApp ladder / single place - **When** the user types a total that would assign `< 10` raw to any rung - **Then** Place is disabled with a named minimum; no `increase_allowance` --- ## Current codebase Retail place is always CW20 `Send` → pair `Receive` → **`Cw20HookMsg::PlaceLimitOrderBatch`** (one item) or **`PlaceLimitOrderLadder`**. Ladder calls `expand_limit_ladder` then the **same** `execute_place_limit_orders_batch` ([`smartcontracts/contracts/pair/src/limit_placement.rs`](smartcontracts/contracts/pair/src/limit_placement.rs)). Failed txs still look like “the batch” to the wallet. ### Amount gate is zero-only `validate_placement_item` rejects `amount == 0` (`ZeroAmount`) and the **L20** human price band. It does **not** require a minimum residual. Maker fee is `floor(amount × maker_bps / 10_000)` with `maker_bps = effective_fee_bps / 2` (15 bps at default 30). For `amount < 667`, fee is **0**. `remaining_for_book = amount.checked_sub(maker_fee)` therefore succeeds for `amount = 1`. `LimitOrderMakerFeeExceedsAmount` (“Limit order amount too small after maker fee”) only fires when `maker_fee >= amount` (impossible at ≤100% bps for `amount > 0` once fee is the floor product). The display string is misleading. There is **no** test that places `amount ∈ {1,9,10}`. ### Dust exists only at match time [`LIMIT_ORDER_DUST_FLUSH_THRESHOLD = 10`](smartcontracts/packages/dex-common/src/pair.rs) (**L16** / [#264](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/264)) parks `0 < remaining < 10` **after a fill**. Placement can rest `remaining = 1…9` forever. Default `CleanLimitBook` thresholds are **0** ([#263](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/263)), so keepers do not evict them. Tier 9 `limit_discount_bps = 10000` makes placement **free** ([#514](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/514) / **I13**); internal audit already flagged `amount = 1` spam. ### Why `Cannot Sub with 1 and 3` CosmWasm prints `Overflow: Cannot Sub with {a} and {b}` for `Uint128` `a.checked_sub(b)` (Decimal uses the **atomics**). Two in-tree paths match the report: 1. **Descending ladder** — [`ladder_prices`](smartcontracts/packages/dex-common/src/limit_placement.rs) does `end.checked_sub(start)` and maps the error to `"ladder price range overflow"`. On wasm without that map (or if a caller hits a raw `?` on Decimal atomics), **18-vs-6** prices `Decimal::raw(1)` and `Decimal::raw(3)` **pass** the human band (`1e-6` / `3e-6` > `MIN_LIMIT_PRICE`) and overflow as **`Cannot Sub with 1 and 3`**. `ladder_boundary_rung_index` already treats `start > end` as valid. Unit tests only expand **ascending** prices. 2. **Dust amount vs a larger operand** — the fee `checked_sub` is guarded today; do not assume it stays that way if fee math changes to ceil / min-fee. Fail closed with a named min before any sub. Validation is **all-or-nothing** (docs/`limit-orders.md`). A single dust / overflow rung reverts the entire `send` (reporter’s “other rungs die with it”). Book-walk skip (`LimitInsertStepsExceeded`) is unrelated. ### dApp [`LimitOrderLadderPanel`](frontend-dapp/src/components/trade/LimitOrderLadderPanel.tsx) + [`expandLimitLadder`](frontend-dapp/src/utils/limitOrderLadder.ts) split `total / count` with remainder on the last rung. `total=1`, `count=3` → `[0,0,1]` → on-chain `ZeroAmount`, not Overflow. JS handles descending prices; **on-chain ladder does not**. Escrow gates compare **total** human vs wallet ([`limitOrderEscrowBalanceGate.ts`](frontend-dapp/src/utils/limitOrderEscrowBalanceGate.ts)); they do **not** require per-rung raw ≥ 10. [`TradeOrderTicket`](frontend-dapp/src/components/trade/TradeOrderTicket.tsx) `toRawAmount` can submit `1` on an 18-dec token. Swarm uses `MIN_SWAP_OR_ESCROW_AMOUNT = 500_000` only as a bot heuristic. ### Related tickets (not this work) | Issue | Why not a duplicate | | --- | --- | | [#467](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/467) closed | **Price** band; `amount=1` dust *price* `Decimal::raw(1)` is already rejected on equal-dec | | [#529](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/529) | Human-scale band; does not min **size** | | [#264](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/264) | Post-**fill** park; does not reject place | | [#1225](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/1225) open | Unfillable **MIN_PRICE** asks at match; keep match/park there | | [#233](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/233) | String-concat Uint128 on the client; already fixed | | [#10](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/10) | Non-numeric amount field | | [#342](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/342) / [#559](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/559) | Native wrap / zap `Cannot Sub` — different contracts | | [#1230](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/1230) | Swap `belief_price` dust, not limits | | [#1228](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/1228) | Community-tax **Swap** extra-debit. Limit `Send` to a listed pair is inbound 1:1 (**T592-1**) | --- ## Why the new implementation is needed 1. **Wallet error is unusable.** Overflow `1` and `3` does not tell the maker the minimum size. Integrators and the dApp cannot branch on it. 2. **Atomic batch grief.** One dust rung (ladder remainder, MAX leftover, 18-dec “0.000…001”) burns gas for every other rung and refunds nothing useful until the tx fails. 3. **Descending ladders are specified but broken** on-chain whenever `end < start` (equal-dec human `3 → 1` overflows 1e18-scale; 18-vs-6 raw `3 → 1` is the literal `1` and `3` string). 4. **Book quality.** Sub-10 rests are unfillable noise (**L16** would park them only after a fill they may never get). T9 can spam `amount=1` at gas-only cost. Pair wasm + a thin dApp gate. No indexer schema. No factory migrate of existing rows (legacy dust stays until cancel / #1225 / clean). --- ## Constraints / guardrails - **Named error, not skip.** Do **not** treat undersize like `LimitInsertStepsExceeded` (partial place + refund). Invalid size is all-or-nothing, same as price band / empty batch. - **Reuse 10.** Minimum **post-fee remaining** = `LIMIT_ORDER_DUST_FLUSH_THRESHOLD`. Do not invent a second constant unless docs say why. Gross `amount` must be `> maker_fee` and remaining `≥ 10`. - **Fee math stays floor.** Do not switch maker fee to ceil as a way to “force” the old overflow. Keep `maker_fee >= amount` → existing variant; add a **new** variant (or fix the existing one’s semantics) for remaining `< 10`. - **Ladder descending.** `ladder_prices` must use an absolute step (`start ± i × |end−start|/(count−1)`), last rung exactly `end`. Do not require the UI to sort start < end. Keep **L20** on every rung. Do not weaken `MIN_LIMIT_PRICE`. - **#1225 stays open.** Optional: if `remaining.checked_mul_floor(price) < 1`, reject here too **or** document that #1225 owns it. Do not implement match-time park / `CleanLimitBook` changes in this MR. - **#467 / #529 / L20 / L23 / L14 / L6 / L1** unchanged. `UpdateLimitOrderPrice` does not change `remaining` — out of scope for size. Pause still blocks place. Blacklist / F6 code-id gates stay. - **Community tax.** Do not extra-debit limit `Send`. Do not mix #1228. - **dApp.** No new `shell-panel*` / lecture banner (**C653** / #489). Disable Place + one short line (e.g. “Minimum per order is 10 units”). Do not broadcast then toast Overflow. - **No saturating sub on escrow/fee** that would place a 0-remaining row or skim the wrong treasury amount. - Founder-required CosmWasm pair. No `ready` until tests exist. --- ## Relevant files | Path | Why | | --- | --- | | `smartcontracts/contracts/pair/src/limit_placement.rs` | `validate_placement_item`; maker fee; batch loop | | `smartcontracts/contracts/pair/src/error.rs` | Named min-size error (fix or replace misleading `LimitOrderMakerFeeExceedsAmount` copy) | | `smartcontracts/packages/dex-common/src/limit_placement.rs` | `ladder_prices` descending; optional shared min-size helper; tests | | `smartcontracts/packages/dex-common/src/pair.rs` | `LIMIT_ORDER_DUST_FLUSH_THRESHOLD` — document as place + flush floor | | `smartcontracts/tests/src/limit_order_tests.rs` | Dust batch, mixed one-bad-rung, descending ladder, 18-vs-6 raw 1/3, T9 | | `frontend-dapp/src/utils/limitOrderLadder.ts` | Reject zero/dust rungs in preview | | `frontend-dapp/src/components/trade/LimitOrderLadderPanel.tsx` | Gate Place | | `frontend-dapp/src/components/trade/TradeOrderTicket.tsx` | Single-rung min raw | | `frontend-dapp/src/utils/limitOrderEscrowBalanceGate.ts` | Optional shared min-raw helper | | `docs/limit-orders.md` / `docs/contracts-security-audit.md` | Place min vs L16 vs #1225 | | `skills/AGENTS_LIMIT_ORDER_BATCH_LADDER.md` | Invariant + tests | | `scripts/qa/verify-issue-1219.sh` | New `make verify-issue-1219` | --- ## Recommended direction 1. **`min_limit_place_remaining()`** in `dex-common` = `LIMIT_ORDER_DUST_FLUSH_THRESHOLD`. After computing `maker_fee`, if `amount ≤ maker_fee` keep/rename fee-exceeds; else if `amount - fee < min` return **`LimitOrderAmountTooSmall { min, actual }`** (include both in the string). Call from `validate_placement_item` **or** immediately after fee (fee depends on bps). Ladder `expand_limit_ladder`: if any equal-split rung would be `< min` (including zeros from `total < count`), `StdError` with the same min **before** batch execute. 2. **`ladder_prices`:** `span = |end − start|`; `step = span / (count−1)`; rung `i` = `start` toward `end`; last = `end` exactly. Table-test equal-dec `3 → 1`, `1 → 3`, and 18-vs-6 `Decimal::raw(3) → Decimal::raw(1)` (must not Overflow). 3. **dApp:** `expandLimitLadder` throws if any `amountRaw < 10n`; ticket submit if escrow raw `< 10`. Copy: **Minimum size is 10 units** (not “Overflow”). 4. **Docs:** one paragraph in `limit-orders.md` place section; L16 note “placement also rejects remaining `< 10`”. Playbook invariant. `make verify-issue-1219` greps the named error + descending test + frontend min. --- ## Acceptance criteria - [ ] **AC1.** Batch or retail `n=1` with `amount ∈ {1,9}` (valid in-band price) reverts **named** min-size; message includes the minimum `10`; **no** `Overflow: Cannot Sub`. - [ ] **AC2.** `amount` such that post-fee remaining `≥ 10` still places; `remaining == amount - floor(amount × maker_bps / 10_000)`. - [ ] **AC3.** Batch of N healthy rungs + **one** dust rung reverts entirely; **zero** new `ORDERS` / pending escrow. - [ ] **AC4.** Ladder `total_amount` that would assign `< 10` to any equal-split rung (incl. `total < count`) reverts named / ladder-invalid **before** inserts. - [ ] **AC5.** Descending ladder `start > end` with in-band prices (equal-dec and 18-vs-6 raw `3 → 1`) places `count` rungs; prices monotonic toward `end`; amounts sum to `total_amount`. - [ ] **AC6.** T9 (`limit_discount_bps = 10000`, fee 0) cannot place `amount < 10`. - [ ] **AC7.** dApp: dust total / dust rung → Place disabled; no allowance tx. Healthy 5-rung ladder unchanged. - [ ] **AC8.** `make verify-issue-467` / `#529` / `#264` / `#1227` behavior unchanged. `#1225` not closed by this MR. - [ ] **AC9.** Docs + `AGENTS_LIMIT_ORDER_BATCH_LADDER.md` + `make verify-issue-1219`. --- ## Test plan (functional paths) | # | Path | Expect | |---|------|--------| | T1 | Batch ask `amount=1`, price 1.0, 6/6 | Named min-size; no order | | T2 | Batch `amount=9` | Same as T1 | | T3 | Batch `amount=10`, fee 0 or 15 bps (remaining ≥ 10) | Places; remaining = amount − fee | | T4 | Batch `amount=667` @ 15 bps (fee 1) | Places if remaining ≥ 10 | | T5 | Three rungs `1000,1,1000` | Whole tx fails; no ids | | T6 | Ladder `count=3`, `total=1` | Expand/place error; no Overflow | | T7 | Ladder `count=3`, `total=30` equal-dec 0.95→1.05 | Places three rungs of 10 | | T8 | Ladder start `Decimal::one()*3`, end `Decimal::one()`, count 3 | Places; no Overflow | | T9 | 18-vs-6 ladder `Decimal::raw(3)` → `Decimal::raw(1)` | No `Cannot Sub with 1 and 3`; L20 still enforced per rung | | T10 | Retail dApp human that encodes to raw 1 | Button disabled | | T11 | Existing batch/ladder/hint/partial-skip tests | Green | | T12 | Pause / blacklist place | Unchanged reject | | T13 | Playwright `limit-orders-tx` 5-rung (LocalTerra) | Still places | Contracts: `cl8y-dex-tests` `limit_order_tests` + `dex-common` `limit_placement` unit. Frontend: `limitOrderLadder` + ticket min-raw Vitest. 5 Playwright workers when chain E2E is in the MR. --- ## Test plan (attack, hack, and abuse) | # | Vector | Expect | |---|--------|--------| | A1 | T9 spam `amount=1` × `max_batch_rungs` | Every rung named-reject; book head unchanged | | A2 | Hide dust as last ladder remainder (`total=29`, `count=3` → 9,9,11 or 9,9,11 depending on split) | If any rung `< 10`, reject all | | A3 | Integrator omits min and catches Overflow | Must match new error string, not `Cannot Sub` | | A4 | Descending raw 3→1 on 18-vs-6 to overflow match math | Place only if **L20** human band holds; still no Overflow sub | | A5 | Dust ask at band floor to clog `match_asks` | Size gate reduces junk; **#1225** still owns zero-cost skip/park | | A6 | Partial-skip confusion: undersize as `LimitInsertStepsExceeded` | Must **not** skip+refund; full revert | | A7 | Saturating sub to force `remaining=0` | Forbidden; would break escrow vs CW20 | | A8 | Hostile CW20 FoT on place | Unchanged F6 / listed-pair 1:1; this issue does not add tax math | | A9 | Frontend bypass (raw script `send` amount=1) | Chain still named-rejects | | A10 | `UpdateLimitOrderPrice` on a legacy `remaining=1` row | Unchanged (size not re-checked); cancel/claim still work | No public mainnet attack tx. In-tree multitest only. --- ## Verification criteria - `make verify-issue-1219` (named error + descending ladder unit + frontend min-raw + docs greps). - `cd smartcontracts && cargo test -p cl8y-dex-tests --test limit_order_tests -- --test-threads=1` paths T1–T9, A1–A6. - `cd frontend-dapp &&` Vitest `limitOrderLadder` + ticket/ladder Place disabled on dust. - Manual: `/limits` ladder total that splits under 10 → no Keplr popup; raise total → place. Descending start/end on UST1/USTR (6/18) and a 18/6 pair if present. - Coolify: not required to close; pair wasm migrate is a later ops ticket if columbus-5 still runs pre-fix code. ## Out of scope - Match-time park / `CleanLimitBook` defaults ([#1225](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/1225), [#264](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/264)). - Changing maker/taker bps split or #514 tier table. - Indexer placement schema / new wasm action. - Community-tax extra-debit on limit Send. - Raising `max_batch_rungs` or gas envelopes.

Merged as PR #1296. Named LimitOrderAmountTooSmall plus descending absolute-span ladders. L23 F6 reprice gate from #1234 kept.

Leftover: columbus-5 pair wasm migrate; Playwright T13 5-rung LocalTerra. Tracked on #1300. #1225 stays open (unfillable in-band asks).

Merged as PR #1296. Named `LimitOrderAmountTooSmall` plus descending absolute-span ladders. L23 F6 reprice gate from #1234 kept. Leftover: columbus-5 pair wasm migrate; Playwright T13 5-rung LocalTerra. Tracked on #1300. #1225 stays open (unfillable in-band asks).
Sign in to join this conversation.
No milestone
No project
No assignees
2 participants
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#1219
No description provided.