clean_limit_book caps parks but not traversal — unbounded scan / gas DoS #274
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#274
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?
Severity: Medium
Reachability: Permissionless. Anyone calls
CleanLimitBook; the cost scales with the live orders ahead of the first eligible one.Affected:
clean_limit_book(smartcontracts/contracts/pair/src/limit_book_clean.rs).Root cause:
max_orderscaps how many orders get parked, not how many get traversed. There is no scan-step cap.Summary
clean_limit_bookwalks the book from the head (or a hint) and parks expired / dust orders, up tomax_orders. But the loop only breaks oncleaned_count >= cap, andcleaned_countincrements only when an order is actually parked. Zero-remaining orderscontinuewithout counting, and live, healthy, non-expired, non-dust orders fall straight through and advancecur = order.nextwithout counting either.So a long run of healthy orders at the head is traversed in full — one
ORDERS.loadper node — no matter whatmax_ordersis. The matcher has aMAX_SCAN_STEPScap for exactly this reason; clean has no equivalent.Impact: clean's gas is O(orders ahead of the first eligible one), not O(max_orders). If a head-run of healthy orders is long enough to blow the gas budget, clean can never make progress past it, so a clogged head of expired/dust orders sitting behind that run can't be cleared.
Current codebase
limit_book_clean.rsclean_limit_book:while let Some(oid) = cur { if cleaned_count >= cap { break } ... }—cleaned_count += 1only inside theif time_exp || forcepark branch; healthy orders just docur = next_ptr.smartcontracts/contracts/pair/src/orderbook.rsmatcher, which bounds traversal withMAX_SCAN_STEPSindependent of fills.Recommended direction
clean_limit_book(mirrorMAX_SCAN_STEPS): count every node visited, break with a "scan capped" flag when hit, return a resume hint.Acceptance criteria
clean_limit_bookvisits at most a bounded number of nodes per call regardless of book length.Test plan (attack / abuse)
First,
clean_limit_bookshould consume a start index for where in the buy/sell it should start then step away from the market price.Second,
clean_limit_bookshould consume a max steps for how long it will run before exiting (unless it hits the end of the book at farthest price from market price).This allow callers to decide how far they want to clean.
Implementation plan (your "start index + max steps" direction). Small, contract-only.
max_steps: u32toclean_limit_book+ aMAX_CLEAN_SCAN_STEPS=500cap in dex-common/limit_clean.rs (mirrors the matcher's MAX_SCAN_STEPS, same ~19k-gas/iter sizing). Count EVERY visited node at the top of the walk loop — zero-remainingcontinue, healthy fall-through, AND parked — and break when the cap is hit.start_hintstays the "start index"; the existing head→tailnextwalk is already directional (head = nearest market, next = away). Park cap (max_orders) stays independent.scan_capped+resume_cursoronCleanLimitBookResultand emit them as execute attrs, so a keeper resumes by re-submitting withstart_hint = resume_cursoruntilscan_capped=false. Add optionalmax_stepsto theCleanLimitBookExecuteMsg (None → cap; backward-compatible).limit_book_clean.rs(loop + result + sig),dex-common/limit_clean.rs(const + clamp),dex-common/pair.rs(msg field),pair/contract.rs(thread + attrs).The gotcha:
resume_cursormust be the FIRST UNVISITED node (the oid the walk broke before), NOT the last processed — clean doesn't consume the healthy head, so re-passing the samestart_hintwould loop forever on the same prefix. Off-by-one here = no progress or skipped orders. A stale cursor degrades safely (resolve_start validates + falls back to head). No DB/frontend/indexer/router ripple (clean is a keeper action). Precedent:book_walk_step/MAX_SCAN_STEPS +match_bids_scan_steps_cap_bounds_expired_prefix_walk. Tests: traversal-capped, resume-reaches-expired-tail, clamp, park-cap-independent, end-of-book-no-resume. @PlasticDigitsmentioned in issue #289
mentioned in merge request !753
Shipped your "start index + max steps" direction — MR !753.
The hole:
clean_limit_bookcapped parks (max_orders) but not traversal, so the walk visited every node until it parked the cap or reached the end — a book of healthy or zero-remaining orders got walked unboundedly.Fix:
MAX_CLEAN_SCAN_STEPS=500(mirrors the matcher'sMAX_SCAN_STEPS, ~19k gas/iter), counted on EVERY visited node (zero-remaining skip, healthy fall-through, parked alike).CleanLimitBookResultreturnsscan_capped+resume_cursor= the first UNVISITED order id, emitted as execute attrs; a keeper resumes withstart_hint = resume_cursoruntil it clears. The off-by-one you'd worry about — resume must be the node we broke BEFORE, not the last processed, or re-passing the samestart_hintloops on the same prefix; a stale cursor degrades safely (resolve_start falls back to head). New optionalmax_stepson the msg (#[serde(default)], absent or 0 -> full cap, backward-compatible). Park cap stays independent.Tests (suite 419/0): traversal-bounded-when-nothing-parked (the DoS itself — 0 parks but still bounded), scan-cap-resume-reaches-tail, zero-max-steps-is-full-cap, park-cap-independent. Live gas re-confirm rides the next deploy. @PlasticDigits
mentioned in commit
974cabb659Verification — GitLab #274 (clean_limit_book traversal cap)
Verified on
main@9f0babeafter merge of !753.Acceptance criteria
clean_limit_bookvisits at most a bounded number of nodes per call regardless of book lengthMAX_CLEAN_SCAN_STEPS = 500index-common/limit_clean.rs; loop breaks whensteps >= step_capbefore loading the next node (limit_book_clean.rs).clean_limit_book_traversal_bounded_when_nothing_parked: 5 far-future (unparkable) bids,max_steps=2→cleaned_count=0,scan_capped=true,resume_cursor=ids[2](only 2 nodes visited).clean_limit_book_scan_cap_resume_reaches_tail: first pass parks 2 withscan_capped; second pass withstart_hint=resume_cursorparks remaining 3;scan_capped=false, noresume_cursor.Test plan (issue body)
clean_limit_book_traversal_bounded_when_nothing_parked(healthy prefix, cap before tail).clean_limit_book_scan_cap_resume_reaches_tail.Additional checks (MR !753 / comments)
max_stepsoptional onCleanLimitBook(None/0→ full cap)clean_limit_book_zero_max_steps_means_full_cap;pair.rsExecuteMsg::CleanLimitBook.max_steps.clean_limit_book_park_cap_independent_of_scan_cap(cap_hit=true,scan_capped=false).resume_cursor= first unvisited oid (off-by-one)steps >= step_capbeforesteps += 1; tests assertresume_cursor=ids[2]after 2 visits.scan_capped,resume_cursorcontract.rsexecute_clean_limit_book; tests read wasm attrs.clean_limit_book_*regression suitecargo test clean_limit_book_→ 10/10 passed.Implementation reference
feat(pair): bound CleanLimitBook traversal with max_steps + resume cursor (#274))MAX_CLEAN_SCAN_STEPS=MAX_SCAN_STEPS= 500Conclusion: Issue fixed on
main; acceptance criteria and automated abuse tests pass. Closing.mentioned in commit
8e8c1ef41bLive gas re-confirm (LocalTerra) — PASS
Reopened per request; fresh wasm via
QA_FRESH_VOLUMES=1 make reset-qa(stampgit_sha=1ad11dc), thenmake verify-issue-274withVERIFY274_PAIR_INDEX=3andVERIFY274_HEALTHY_COUNT=0(book already had 100 healthy far-future bids from seed; 5 expired bids at tail0.10).Results
max_steps=15)scan_capped=true,cleaned_count=0gas_used=152798(≪ 800k ceiling; ~105 nodes unbounded would be ~2M+ at ~19k/step)resume_cursor+ resume parks tailresume_cursor=16; resume tx parked 5 expired (scan_capped=false)E8CE2030…/7BA60562…How to reproduce
MR adds
scripts/qa/verify-issue-274.sh,make verify-issue-274, and deploy fix for #276 pair-creation fee attachment.mentioned in merge request !768
mentioned in commit
4490ba8d28mentioned in commit
4c4c26846bmentioned in merge request !795
mentioned in issue #597