Limit book match: auto-flush sub-10-unit dust remainders after fill #264
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#264
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
During hybrid book matching, integer rounding can leave resting limit orders with 1–9 smallest-unit remainders instead of fully unlinking them. Each dust row keeps an
ORDERSmap entry and DLL links alive, so high fill volume slowly bloats chain state. Auto-flush orders whose post-fillremainingis > 0 and < 10 in the same match invocation: release escrow, remove the row from the active book, and hand the dust refund to the maker via the existingEXPIRED_LIMIT_CLAIMSclaim path (no extra CW20 transfer in the swap tx).Origin: discovered during live QA of GitLab #255 (batched pending-escrow subtract) — five bid fills each left
remaining = 1token1 fromfloor(fill × price)rounding while escrow accounting stayed exact (L1, L13).Relationship to #263: #263 adds permissionless
CleanLimitBookwith governance-configurable per-side thresholds and a separate keeper tx. This issue is proactive flush at match time with a protocol constant threshold (10 raw units) so takers do not depend on an external sweeper to reclaimORDERSstorage after every near-complete fill.Current codebase
match_bids/match_asks(smartcontracts/contracts/pair/src/orderbook.rs) unlink an order only whenorder.remaining.is_zero()after subtracting fill cost; otherwise theyORDERS.savethe partial row.remainingis token1 (escrow currency). Fill size is token0;cost = fill.checked_mul_floor(price)can leave0 < remaining < costwhen the bid is economically exhausted (QA:floor(94_380_952 × 1.05)left 1 token1).remainingis token0; fills subtractfill_t0directly — dust is rarer but still possible on partial fills near budget caps.escrow_sub_pending_token{0,1}subtracts consumed escrow per side (L13, #255); dust left on-book keeps matching escrow inPENDING_ESCROW_*.CleanLimitBook+ governancemin_remaining_token{0,1}parks sub-threshold live orders intoEXPIRED_LIMIT_CLAIMSviapark_limit_order_for_clean— requires a separate permissionless tx and non-zero governance config; default thresholds are 0 / 0 (time-expired only).remaining; park during match/expiry createsEXPIRED_LIMIT_CLAIMSrow with no CW20 in the park tx (L1).simulate_match_bids/simulate_match_asksmirror fill math but do not mutate storage — they must stay aligned with execute semantics for L8 hybrid quotes.Why this is needed
remaining ∈ [1, 9]) are unfillable at FIFO prices (next taker walk skips zero-size fills) but remain inORDERS+ DLL indefinitely unless a maker cancels or a keeper runsCleanLimitBook.limit-booknoise; at scale this is pure chain/storage overhead with no trading utility.Constraints and guardrails
LIMIT_ORDER_DUST_FLUSH_THRESHOLD = 10(smallest units of the side's escrow token: token1 for bids, token0 for asks) index-common. Not governance-configurable in v1 — keep diff minimal; #263 remains the knob for larger notionals.match_bids/match_asks, if0 < order.remaining < 10, flush; ifremaining ≥ 10, current partial-save behavior unchanged.remainingto the batched escrow subtract for that side (release fromPENDING_ESCROW_*).park_limit_order_for_cleanwithforce_expired = true(reuse #263 helper) — unlinkORDERSrow; store dust inEXPIRED_LIMIT_CLAIMSfor makerClaimExpiredLimitOrder.limit_order_expired_parkedwithforce_expired=true(and/or extend fill event attrs — document choice).execute_swap(preserve L10 transfer aggregation); claim path only.MAX_EXPIRED_PARKS_PER_SWAPif reusing park budget, or define a separate counter — document and test interaction with time-expiry parks (recommend: dust flush does not consume the 15 time-expiry park cap; it is a fill consequence, not a scan-only park).simulate_match_*to treat sub-threshold remainders as gone from the book for subsequent walk steps (in-memory only); hybrid simulation quotes must reflect execute flush.force_expired=trueparks already indexed asparked_expired(#263); confirm parser needs no change.Relevant files
smartcontracts/contracts/pair/src/orderbook.rs(match_bids,match_asks,park_limit_order_for_clean)smartcontracts/contracts/pair/src/orderbook.rs(simulate_match_bids,simulate_match_asks)smartcontracts/contracts/pair/src/orderbook.rs(unlink_order,park_limit_order_for_clean)smartcontracts/packages/dex-common/src/pair.rs(or newlimit_dust.rs)smartcontracts/contracts/pair/src/state.rs(ORDERS,EXPIRED_LIMIT_CLAIMS,PENDING_ESCROW_*)smartcontracts/contracts/pair/src/contract.rs(execute_swap)smartcontracts/contracts/pair/src/limit_book_clean.rssmartcontracts/contracts/pair/src/orderbook.rs(aggregation_tests,proptest_limits),smartcontracts/tests/src/limit_order_tests.rsdocs/limit-orders.md,docs/contracts-security-audit.md(new L16 row),docs/integrators.mdindexer/src/indexer/parser.rs(confirmforce_expiredattrs)Recommended direction
pub const LIMIT_ORDER_DUST_FLUSH_THRESHOLD: Uint128 = Uint128::new(10)index-common.fn should_flush_dust(remaining: Uint128) -> bool, andfn flush_dust_after_fill(...)that:remaining;park_limit_order_for_clean(..., force_expired: true, refund_expires_at: None);match_bids/match_asks, replace theif remaining.is_zero() { unlink } else { save }branch with:zero→ unlink (unchanged);< 10→ flush helper (noORDERS.save);simulate_match_*(zero out in-memoryremainingand do not treat row as resting for subsequent steps).remaining = 1→ after match,ORDERSkeys gone,EXPIRED_LIMIT_CLAIMShold dust,PENDING_ESCROW_TOKEN1reduced by sum(costs) + sum(dust), maker can claim.Acceptance criteria
0 < remaining < 10on bids or asks never leaves a row inORDERS/ DLL aftermatch_bids/match_asks.PENDING_ESCROW_*in the same batched subtract as fill costs (L13 + L1 hold).ClaimExpiredLimitOrder; claim amount equals pre-flush dustremaining.remaining ≥ 10partial fills behave exactly as today.simulate_match_*/HybridSimulationquotes match execute for scenarios with sub-10 remainders (L8).parked_expiredwithforce_expired=true.docs/contracts-security-audit.md.Test plan (functional paths)
remaining = 1remaining = 5remaining = 0remaining = 10ORDERSrowsClaimExpiredLimitOrdersHybridSimulationvs executeTest plan (attack / abuse / hack vectors)
9vs10)remaining = 9flushes,10persistschecked_subon pending escrow; flush + fill cannot exceed pre-fill escroworder_idremainingin forged stateVerification criteria
cargo test -p cl8y-dex-pair orderbook::andcargo test -p cl8y-dex-tests limit_ordergreen.OrderBookHeadwalk shows no orders withremaining < 10; parked claims queryable.limit_order_expired_parked+force_expired=true→parked_expiredlifecycle (existing #263 tests as template).marked as related to #255
mentioned in issue #255
mentioned in commit
a743fa173bImplementation summary (#264)
Implemented match-time dust flush for hybrid limit book fills.
What changed
LIMIT_ORDER_DUST_FLUSH_THRESHOLD = 10index-common::pair.match_bids/match_asks, when0 < remaining < 10(token1 for bids, token0 for asks):park_limit_order_for_clean(..., force_expired=true)— removed fromORDERS/DLL, row stored inEXPIRED_LIMIT_CLAIMS.limit_order_expired_parkedwasm event withforce_expired=true(indexer → existingparked_expiredlifecycle).MAX_EXPIRED_PARKS_PER_SWAP(15) — only time-expired head parks count toward that cap.remaining ≥ 10partial fills unchanged;remaining = 0unlink-only unchanged.simulate_match_*zeroes in-memory sub-threshold remainders so HybridSimulation stays aligned with execute (L8).remainingstays inPENDING_ESCROW_*until makerClaimExpiredLimitOrder(same economics as time-expiry /CleanLimitBookparks).Docs / invariants
docs/contracts-security-audit.mddocs/limit-orders.md§ Match-time dust flushdocs/integrators.md§ match-time dust flushskills/AGENTS_FRONTEND_LIMIT_PARKED_EXPIRED.md,skills/AGENTS_LOCALNET_TRADING_SWARM.mdTests run (green)
cargo test -p cl8y-dex-pair orderbook::cargo test -p cl8y-dex-tests limit_order(63 tests)Merged to
main@a743fa1.Verification checklist
remaining = 1token1; hybrid swap →LimitOrderquery fails,ExpiredLimitRefundshows 1, eventforce_expired=true.remainingin 1…9 → same park path on token0 side.remaining = 9flushes;remaining = 10stays on book.limit-bookwalk; N claim rows (or batched claim).ClaimExpiredLimitOrder/ batch → CW20 refund = dust; pending escrow decrements; claim row removed.HybridSimulationvs execute on dust-flush scenario — samereturn_amount/ fill count.limit_order_expired_parked+force_expired=true→parked_expiredin limit-placements feed (no parser change expected).QA agent team
Please run the checklist above on LocalTerra (or staging) against wasm built from
main@a743fa1, with emphasis on the #255 multi-fill bid ladder scenario and HybridSimulation parity.Issue left open pending QA sign-off.
mentioned in merge request !733
@PlasticDigits heads up —
make test-contractsis red on main (d6701c4): the pair-libproptest
prop_match_bids_maker_cap/prop_match_asks_maker_capfail, so the wholeintegration suite never runs.
Root cause is in #264's wave, and it's test-only — NOT an escrow leak. The helper
assert_escrow_matches_lists(orderbook.rs) still asserts on-book remaining == PENDING_ESCROW,but #264 now parks sub-10 dust off-book into EXPIRED_LIMIT_CLAIMS while escrow keeps backing it
until claim (L1). So escrow = on-book + parked dust. The helper under-counted and tripped on any
random budget that left <10 dust (deltas were all 1/3/6/9).
Conservation itself is correct — the dedicated #264 tests pass and prove it
(match_bid_dust_remainder_one_flushes_to_expired_claim: escrow drops by cost only, dust stays
pending == claimable). I fixed the helper to add the parked-dust total per side:
walk_bid_sum + parked_dust_token1 == PENDING_ESCROW_TOKEN1 (and ask/token0).
Branch qa/fix-264-proptest-escrow-parked-dust, commit
de07725(orderbook.rs only, +19/-2).After the fix: make test-contracts = 402 passed, 0 failed. Needs your verification + merge —
this is gating verification of the whole contract wave.
mentioned in issue #262
Verified #264 on
d6701c4(with the !733 proptest fix applied for a green suite).Tests (all green in make test-contracts = 402/0):
match_ask_dust_remainder_five_flushes_to_expired_claim, should_flush_dust_boundary_nine_yes_ten_no
(9 flushes / 10 stays), match_bids_multi_maker_dust_flush_sim_matches_execute (L8 sim==execute).
-> ORDERS gone -> claim row=1 -> claim refunds exactly 1; L1).
Live repro on LocalTerra (fresh genesis), the explicit checklist scenario:
remaining = 1 -> match-time dust flush.
Indexer: apply_parked_expired keys on the limit_order_expired_parked action (force_expired is an attr) ->
active→parked_expired→refunded; no parser change needed (will confirm live ingestion under #267/#269).
Good to close from my side once !733 merges. @PlasticDigits
mentioned in issue #263
mentioned in commit
52a865bfb7mentioned in issue #271
marked as related to #271
mentioned in merge request !734
mentioned in issue #289
mentioned in issue #309
mentioned in issue #504