security(pair): in-band min-price asks skip forever and clog match_asks #1225
Labels
No labels
agent:fix_bugfix
agent:fix_conflicts
agent:fix_security
agent:gap_analysis
agent:implement
agent:implement
agent:implement
agent:open_issues
agent:ready
agent:research
agent:security_audit
agent:verify
architecture
backend
blocker:hybrid
blocker:launch
blocker:limit-orders
blocker:v2
block:log_only
block:security
bug
ci
contracts
correctness
deploy
dev
devops
docs
documentation
duplicate
e2e
enhancement
epic
feature
frontend
functional-completion
gas
good first issue
governance
help wanted
high-risk
hooks
hybrid
indexer
infra
infrastructure
integrators
invalid
launch-blocker
limit-orders
localnet
localterra
low priority
missing-implementation
needs-design
ops
performance
priority
high
priority
medium
product
qa
QA
question
ready
ready
research
scripts
security
security-hardening
smartcontracts
tech-debt
testing
ux
UX
v2
verification
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
code/cl8y-dex-terraclassic#1225
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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_asksthen computes fill cost asfloor(fill_t0 × price). At the band floor that product is zero for any fill the matcher will attempt against a typical remaining, so the #470cost.is_zero()guard skips the order forever. Lowest-price asks sit at the ask head. They are not time-expired (expires_atunset) and defaultCleanLimitBookforce-clean thresholds are 0, so the keeper path cannot park them. A head prefix on the order ofMAX_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_hintpast an expired prefix,CleanLimitBookparking 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)
expires_atwhilemin_remaining_token0stays 0. Each skipped node still consumes aMAX_SCAN_STEPSiteration.match_asks/simulate_match_asks+ placementvalidate_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_PRICEisDecimal::raw(1_000_000_000)(human 1e-9 on equal-decimal pairs).checked_mul_flooris zero wheneverfill_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 nodematch_asksvisits.Constraints / guardrails
cost.is_zero()skip so makers are never paid 0 token1 for a positivefill_t0. Fix placement, eviction, and/or skip-vs-park, not by restoring the free-fill.book_start_hintmust not be the only defense.LIMIT_CLEAN_CONFIGas the sole fix. Defaults are 0/0 by design (#263); unfillable in-band rows must be handled with that default.remaining × pricecannot producecost ≥ 1at the accepted human price, reject the place / price-update / ladder rung (or requireexpires_at). Equal-decimal and mismatched-decimal human-scale paths (#529) both need the same economic-fillability check.continueforever. Do not park out-of-band legacy dust that #467 already skips on reciprocal overflow unless that is documented as the same helper.simulate_match_asks/ indexerdb_orderbook_simmust match execute (L8). Quotes must not pretend the head is fillable.match_bidshas the samecost.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.MAX_EXPIRED_PARKS_PER_SWAPaccounting if park is used (do not starve genuine time-expiry parks without a documented cap split, cf. #264 dust vs expiry).ready.Relevant files
smartcontracts/packages/dex-common/src/limit_placement.rsMIN_LIMIT_PRICE;validate_limit_order_price/ ladder accepthuman == MIN_LIMIT_PRICEwith no min-fillable-remaining checksmartcontracts/contracts/pair/src/orderbook.rsmatch_asks/simulate_match_askscost.is_zero()continue; ask FIFO head; scan-step countersmartcontracts/packages/dex-common/src/pair.rsMAX_SCAN_STEPS(500)smartcontracts/contracts/pair/src/limit_book_clean.rsis_force_dustfalse whenmin_remainingis 0; noexpires_at→ never parksindexerdb_orderbook_simcl8y-dex-testsorderbook testsmatch_asks_skips_zero_cost_fill_sub_unity_pricestays green; extend for band-floor remaining that can never yieldcost ≥ 1docs/limit-orders.md/docs/contracts-security-audit.mdRecommended direction
UpdateLimitOrderPrice/ ladder) whenorder.amount(post-fee remaining) cannot satisfychecked_mul_floor(price) ≥ 1at that price. Keeps garbage off the book; cheapest invariant.EXPIRED_LIMIT_CLAIMS(or existing clean-park helper); count the node toward scan steps; do notcontinueas a healthy skip.CleanLimitBooktreats “remaining cannot paycost ≥ 1at 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.MIN_LIMIT_PRICEwith remaining that cannot producecost ≥ 1is rejected or parked on first match, not left atHEAD_ASK; (b) a prefix of such rows of lengthMAX_SCAN_STEPSdoes 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
MIN_LIMIT_PRICEand whose remaining cannot producefloor(fill × price) ≥ 1for anyfill ≤ remainingcannot remain an immortalHEAD_ASK/ DLL node after placement or after one match/clean under default clean config (thresholds 0, noexpires_at).match_asksstill never credits maker 0 token1 forfill_t0 > 0(#470 tests remain PASS).MAX_SCAN_STEPSwalk with zero parks/unlinks under default config. Either placement forbids the prefix, or the walk/clean parks them.scan_steps_cappedmay still fire on expired prefixes (#289); it must not fire solely because of live band-floor skips.simulate_match_asksand indexerdb_orderbook_simmatch execute (no phantom best-ask liquidity).docs/limit-orders.md+ security matrix distinguish #470 skip, #467 out-of-band skip, #289 expired clog, and this unfillable in-band floor.cargo testfor pair orderbook +dex-commonplacement +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:
validate_limit_order_price/ place-limit integration — humanMIN_LIMIT_PRICEwith remaining whosechecked_mul_flooris always 0 is rejected (if that is the chosen gate).HEAD_ASKmoves to a fillable order (or empty), maker token1 unchanged, escrow follows L1 park (no swap-tx CW20 to maker).MAX_SCAN_STEPSsuch 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 returnscan_steps_cappedwith the fillable ask untouched and all prefix rows still linked.match_asks_skips_zero_cost_fill_sub_unity_priceandplace_limit_order_dust_price_rejectedstay green.LIMIT_CLEAN_CONFIGzeros and noexpires_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-commonhuman-scale band),match_asksskip-vs-park, optionalclean_limit_bookeligibility, 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.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:
Related: #467, #470, #529, #263, #264, #289, #1219. Keep the proposed default-greedy change #718 gated until #1225 is resolved.
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.