expired_limit_refund cannot distinguish a dust-flushed fill from an unfilled expiry (follow-up to #264) #504
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#504
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
Since #264, a fill that leaves a sub-
LIMIT_ORDER_DUST_FLUSH_THRESHOLDremainder is parked off-book:A genuinely time-expired order swept during a match walk is parked into the same map by
park_expired_limit_order_for_claim. SoEXPIRED_LIMIT_CLAIMSholds two very different outcomes, and theExpiredLimitRefund { order_id }query returns the same shape for both:Everything an integrator touches on that path is named for the second one: the map, the
ExpiredLimitRefundstruct, theexpired_limit_refundquery, theClaimExpiredLimitOrderexecute. Reading a row and concluding "this did not fill" is the natural mistake, and it is the wrong one.Note up front: no funds are at risk. The claim path returns everything correctly and promptly. This is purely about what an integrator can learn from the query.
Why it matters in practice
I run a market maker on EMBER/CORAL. It reconciled resting orders against the book each poll and treated a park as "expired, nothing traded". Two real fills:
book_return_amountBoth times the router consumed my tightest rung down to dust -- parked, claimed a few minutes later (claim txs
6799B83D74D7C0C5AE5CC98D9ED1506EF723A0BC372BA354DDA87C7FA347680BandAD6BAC768F94E0177B129E3F9EA4F4CCBA1C28C24B59D5D34CBF40E66FA1A1B2) -- and never booked as a fill. Only the partially-consumed second rung, still resting and visible as aremainingdecrease, was recorded.On 07-30 the wallet gained 18.656 EMBER; the bot booked 8.70. So ~53% of executed maker volume was invisible, and because the missing portion is always the tightest rung, the average fill price was wrong in the flattering direction too -- my recorded average edge read 1.03% when the blended reality was ~0.75%. Any maker sizing, inventory skew or PnL built on that is running on half the trades at the wrong price.
Because prices are rarely round, the dust flush is not an edge case -- it is how nearly every completed rung leaves the book. Both of the fills this bot has ever taken went down this path.
What does distinguish them today
expires_aton the refund row isSome(..)only for a genuine expiry park; the dust flush passesNone. The emitted event also carriesforce_expired. So the information is technically there, but:Nonealso covers the blacklist park (#468), so it means "not a TTL expiry" rather than "filled";Suggested change
Either of these would close it:
ExpiredLimitRefundResponse-- areason: Filled | Expired | Blacklisted, and/orfilled_amount.finalize_order_after_fillalready knows which branch it took, so this looks cheap.expires_at == nullmeans it was parked after a fill (or a blacklist park), and consider a name for the map/query that is not exclusively "expired".For anyone else hitting this, the workaround is to treat a park as
filled = last_on_book_remaining - refund.remainingand only<= 0as a genuine expiry. That is what my bot does now, and it reconciles to the wallet exactly.Triage: accepted
Thanks for the clear report and mainnet evidence — this is a real integrator footgun, not a funds bug. We verified the claims against the pair contract: dust flush, TTL expiry, blacklist park, and governance force-clean all land in
EXPIRED_LIMIT_CLAIMS, while naming/docs push integrators toward “expired = unfilled.” No funds at risk (claim path is correct); the defect is observability / semantics.Accepted. Prefer an additive
reasondiscriminator on the refund row/response (option 1), not a rename/migration of the map/execute (option 2 alone is insufficient becauseexpires_at == nullstays multi-way ambiguous).Correction to the suggested enum
expires_at: Nonecovers three non-TTL parks, not two:force_expiredexpires_aton rowfinalize_order_after_filltrueNonepark_expired_limit_order_for_claimfalseSome(..)trueNonelimit_book_cleantrueNoneSuggested on-chain
reason(names bikesheddable):Expired | DustFilled | ForceCleaned | Blacklisted.Also note: wasm attr
force_expired=truemeans “parked though not a TTL expiry” — inverted vs naive reading; docs should say so explicitly.Relevant files
Contracts / shared types
smartcontracts/contracts/pair/src/orderbook.rs—finalize_order_after_fill,park_limit_order_for_clean,park_expired_limit_order_for_claim, blacklist park,limit_order_expired_parked_eventsmartcontracts/contracts/pair/src/limit_book_clean.rs— permissionless clean parkssmartcontracts/contracts/pair/src/state.rs—ExpiredLimitRefund,EXPIRED_LIMIT_CLAIMSsmartcontracts/packages/dex-common/src/pair.rs—ExpiredLimitRefundResponse, query/execute docs forExpiredLimitRefund/ClaimExpiredLimitOrder*smartcontracts/contracts/pair/src/contract.rs/msg.rs— query wiringsmartcontracts/tests/src/limit_order_tests.rs,blacklist_tests.rs— existing park/claim coverageDocs / integrator surface
docs/integrators.md(§ Match-time dust flush #264, § Limit book clean #263)docs/limit-orders.md(§ Expiry, clean, dust flush)docs/contracts-security-audit.md/docs/security-model.md(L1 escrow, park invariants as needed)skills/AGENTS_FRONTEND_LIMIT_PARKED_EXPIRED.mdDownstream (follow-up OK, not required to close on-chain semantics)
limit_order_expired_parked→ singleparked_expiredtoday; optional split oncereasonis on eventsLimitOrderMyPlacementsPanel, lifecycle helpers) — “Claim dust” vs filled leftoverRecommended direction
reasontoExpiredLimitRefund/ExpiredLimitRefundResponsewith#[serde(default)](or equivalent) so old rows remain decodeable; set reason explicitly at each of the four park sites (do not re-derive from(force_expired, expires_at)alone — those cannot distinguish dust vs blacklist vs force-clean).reason(and keepforce_expiredfor back-compat) onlimit_order_expired_parkedso indexers can classify without LCD round-trips.filled_amounton the pair for this issue —LimitOrderhas onlyremaining; that would bloat every resting order. Serve filled volume from indexer placement/fill history if needed.ClaimExpiredLimitOrder/ map in this change (breaking + migration cost for naming only). Docs +reasonclose the footgun.force_expiredandexpires_atoverload; update integrator workaround note to preferreason.Gas/storage impact should stay negligible (extra enum bytes on a transient claim row deleted on claim). Match-walk hot path should not add extra storage loads for reason.
Acceptance criteria
ExpiredLimitRefund/ExpiredLimitRefundResponseexpose a stablereasoncovering all four park paths above.limit_order_expired_parkedincludesreason(or equivalent attr) for indexer consumers.integrators.md,limit-orders.md, dex-common comments) state: park ≠ unfilled; document reason meanings andforce_expiredsemantics.remainingonly; no new privilege escalation on claim/clean.Test plan — all park / claim paths
0 < remaining < LIMIT_ORDER_DUST_FLUSH_THRESHOLD→ assert unlinked, claim rowreason=DustFilled,expires_at=None, event attrs; claim restores escrow; book has no dust stub.remaining=0→ unlink, noEXPIRED_LIMIT_CLAIMSrow (still Unlinked path).expires_atin the past relative to match walk → parkreason=Expired,expires_at=Some(..),force_expiredabsent/false; claim works.CleanLimitBookdust threshold path →reason=ForceCleaned,expires_at=None.reason=Blacklisted(not DustFilled); taker still progresses; claim after unblacklist per #468 rules.remaining >= 10→ stays on book; no claim row.ClaimExpiredLimitOrders→ all-or-nothing economics unchanged; reasons only on query/events.HybridSimulationdust/expiry/blacklist skip behavior still matches execute for the same snapshot (no new park in sim).Test plan — attack / abuse / hack vectors
reasonexpires_at/force_expiredassert_not_paused)InvariantViolationon duplicate order idCleanLimitBookspammax_orders, scan steps) unchanged; reason must not widen eligibilityreasonparsedOptionstrategy tested; no panic on pre-reason rowsVerification criteria
make test-contracts(or targeted pair/orderbook +limit_order_tests/blacklist_tests) green with new cases for all four reasons.expired_limit_refundreturnsreasonconsistent with DustFilled (not Expired).reasonbooks fill without relying on last on-book poll race.remaining.reason+ docs land first.Workaround for integrators until ship: treat park as
filled ≈ last_on_book_remaining - refund.remainingonly when you accept poll races; prefer waiting forreasononce deployed.mentioned in commit
3b93a9a743mentioned in commit
46377d3612mentioned in merge request !1043
Implementation landed — !1043
MR: https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/merge_requests/325
Acceptance criteria
ExpiredLimitRefund/ExpiredLimitRefundResponseexpose stablereasoncovering Expired | DustFilled | ForceCleaned | Blacklistedreasonoptional; legacy omit →null); clients ignoring new fields still decodelimit_order_expired_parkedincludes wasmreason=(keepsforce_expiredfor back-compat)integrators.md,limit-orders.md, dex-common comments) + invariant L21 + skillAGENTS_EXPIRED_LIMIT_PARK_REASON.mdremainingonlyTest / verification
make test-contractsgreen (incl. all four reason paths + pause/non-owner/batch regressions)reason→None(no panic / no invented DustFilled)Intentionally not in this MR (follow-ups)
reason(still singleparked_expired)remaining < 10“Claim dust” heuristic withreasonfilled_amounton pair; no rename of map /ClaimExpiredLimitOrdermentioned in commit
ab5da35547mentioned in commit
31784ef504mentioned in commit
ac62c2bf51Verification complete — closed
On-chain
#504(ExpiredLimitParkReason) was already landed via !1043. This pass re-verified acceptance criteria, fixed doc/test gaps found during LocalTerra LCD QA, and added an automated gate.Pushed to
main:31784ef/ mergeac62c2bWhat we verified
reasonon refund row/response for Expired / DustFilled / ForceCleaned / Blacklistedexpires_at/force_expired)reasonoptional; legacy omit → null)limit_order_expired_parkedemitsreason=(+ keepsforce_expired)AGENTS_EXPIRED_LIMIT_PARK_REASON.mdremainingonly; owner-only; pause-gated)expired_limit_refund.reason == dust_filled(notexpired)Wire-format correction (found in LCD QA)
Docs previously said query JSON was PascalCase (
DustFilled). Live LCD +#[cw_serde]emit snake_case (dust_filled), matching wasm attrs andside: "bid". Docs/skills/L22 + a unit wire assert were corrected; no on-chain funds-path change.Tooling added
Checklist for re-verify / QA agents
make verify-issue-504green (no chain)make deploy-localwith current pair wasm:VERIFY504_LCD=1 make verify-issue-504{ "expired_limit_refund": { "order_id": N } }after dust flush shows"reason":"dust_filled","expires_at":null, smallremainingreasonfirstremainingonly (pause + owner gates unchanged)reason; dApp replaceremaining < 10“Claim dust” heuristicIntentionally not required to close
parked_expiredreasonClaimExpiredLimitOrder/ map keyfilled_amounton-chainmentioned in issue #546
Thanks for accepting this and landing the reason discriminator (!1043) — the correction that expires_at=None covers three park paths, not two, was a good catch. Integrators (us included) will read reason first from now on.
mentioned in issue #589
mentioned in issue #597