expired_limit_refund cannot distinguish a dust-flushed fill from an unfilled expiry (follow-up to #264) #504

Closed
opened 2026-07-31 17:34:20 +00:00 by leonardocolucci · 13 comments
leonardocolucci commented 2026-07-31 17:34:20 +00:00 (Migrated from gitlab.com)

Summary

Since #264, a fill that leaves a sub-LIMIT_ORDER_DUST_FLUSH_THRESHOLD remainder is parked off-book:

// orderbook.rs, finalize_order_after_fill
if order.remaining.is_zero() { unlink_order(..); return Unlinked }
if should_flush_dust(order.remaining) {
    park_limit_order_for_clean(storage, oid, pair_contract, /*force_expired*/ true, /*refund_expires_at*/ None)?;
    ...
}

A genuinely time-expired order swept during a match walk is parked into the same map by park_expired_limit_order_for_claim. So EXPIRED_LIMIT_CLAIMS holds two very different outcomes, and the ExpiredLimitRefund { order_id } query returns the same shape for both:

  • your order traded to (near) completion, and
  • your order expired without trading at all.

Everything an integrator touches on that path is named for the second one: the map, the ExpiredLimitRefund struct, the expired_limit_refund query, the ClaimExpiredLimitOrder execute. 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:

date on-chain book_return_amount what my bot recorded
2026-07-29 17.858056 CORAL 8.7038 EMBER (~8.34 CORAL)
2026-07-30 17.805559 CORAL 8.7038 EMBER (~8.32 CORAL)

Both times the router consumed my tightest rung down to dust -- parked, claimed a few minutes later (claim txs 6799B83D74D7C0C5AE5CC98D9ED1506EF723A0BC372BA354DDA87C7FA347680B and AD6BAC768F94E0177B129E3F9EA4F4CCBA1C28C24B59D5D34CBF40E66FA1A1B2) -- and never booked as a fill. Only the partially-consumed second rung, still resting and visible as a remaining decrease, 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_at on the refund row is Some(..) only for a genuine expiry park; the dust flush passes None. The emitted event also carries force_expired. So the information is technically there, but:

  • it is undocumented, and inverted from what the naming implies;
  • None also covers the blacklist park (#468), so it means "not a TTL expiry" rather than "filled";
  • it still does not say how much filled -- the integrator has to diff against its own last on-book sighting, which only works if it happened to see the order at the right moment.

Suggested change

Either of these would close it:

  1. Add a discriminator to ExpiredLimitRefundResponse -- a reason: Filled | Expired | Blacklisted, and/or filled_amount. finalize_order_after_fill already knows which branch it took, so this looks cheap.
  2. If the response shape is fixed, document in the integrator docs that a row does not imply the order went unfilled, that expires_at == null means 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.remaining and only <= 0 as a genuine expiry. That is what my bot does now, and it reconciles to the wallet exactly.

## Summary Since #264, a fill that leaves a sub-`LIMIT_ORDER_DUST_FLUSH_THRESHOLD` remainder is parked off-book: ```rust // orderbook.rs, finalize_order_after_fill if order.remaining.is_zero() { unlink_order(..); return Unlinked } if should_flush_dust(order.remaining) { park_limit_order_for_clean(storage, oid, pair_contract, /*force_expired*/ true, /*refund_expires_at*/ None)?; ... } ``` A genuinely time-expired order swept during a match walk is parked into the *same* map by `park_expired_limit_order_for_claim`. So `EXPIRED_LIMIT_CLAIMS` holds two very different outcomes, and the `ExpiredLimitRefund { order_id }` query returns the same shape for both: - **your order traded to (near) completion**, and - **your order expired without trading at all**. Everything an integrator touches on that path is named for the second one: the map, the `ExpiredLimitRefund` struct, the `expired_limit_refund` query, the `ClaimExpiredLimitOrder` execute. 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: | date | on-chain `book_return_amount` | what my bot recorded | |---|---|---| | 2026-07-29 | 17.858056 CORAL | 8.7038 EMBER (~8.34 CORAL) | | 2026-07-30 | 17.805559 CORAL | 8.7038 EMBER (~8.32 CORAL) | Both times the router consumed my tightest rung down to dust -- parked, claimed a few minutes later (claim txs `6799B83D74D7C0C5AE5CC98D9ED1506EF723A0BC372BA354DDA87C7FA347680B` and `AD6BAC768F94E0177B129E3F9EA4F4CCBA1C28C24B59D5D34CBF40E66FA1A1B2`) -- and never booked as a fill. Only the partially-consumed second rung, still resting and visible as a `remaining` decrease, 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_at` on the refund row is `Some(..)` only for a genuine expiry park; the dust flush passes `None`. The emitted event also carries `force_expired`. So the information is technically there, but: - it is undocumented, and inverted from what the naming implies; - `None` also covers the blacklist park (#468), so it means "not a TTL expiry" rather than "filled"; - it still does not say *how much* filled -- the integrator has to diff against its own last on-book sighting, which only works if it happened to see the order at the right moment. ## Suggested change Either of these would close it: 1. **Add a discriminator to `ExpiredLimitRefundResponse`** -- a `reason: Filled | Expired | Blacklisted`, and/or `filled_amount`. `finalize_order_after_fill` already knows which branch it took, so this looks cheap. 2. **If the response shape is fixed**, document in the integrator docs that a row does not imply the order went unfilled, that `expires_at == null` means 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.remaining` and only `<= 0` as a genuine expiry. That is what my bot does now, and it reconciles to the wallet exactly.
PlasticDigits commented 2026-08-05 00:36:33 +00:00 (Migrated from gitlab.com)

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 reason discriminator on the refund row/response (option 1), not a rename/migration of the map/execute (option 2 alone is insufficient because expires_at == null stays multi-way ambiguous).

Correction to the suggested enum

expires_at: None covers three non-TTL parks, not two:

Park path Call site force_expired expires_at on row
Match-time dust flush (#264) finalize_order_after_fill true None
TTL expiry during match park_expired_limit_order_for_claim false Some(..)
Blacklisted maker (#468) match walk skip/park true None
Governance force-clean (#263) limit_book_clean true None

Suggested on-chain reason (names bikesheddable): Expired | DustFilled | ForceCleaned | Blacklisted.

Also note: wasm attr force_expired=true means “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_event
  • smartcontracts/contracts/pair/src/limit_book_clean.rs — permissionless clean parks
  • smartcontracts/contracts/pair/src/state.rs — ExpiredLimitRefund, EXPIRED_LIMIT_CLAIMS
  • smartcontracts/packages/dex-common/src/pair.rs — ExpiredLimitRefundResponse, query/execute docs for ExpiredLimitRefund / ClaimExpiredLimitOrder*
  • smartcontracts/contracts/pair/src/contract.rs / msg.rs — query wiring
  • smartcontracts/tests/src/limit_order_tests.rs, blacklist_tests.rs — existing park/claim coverage

Docs / 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.md

Downstream (follow-up OK, not required to close on-chain semantics)

  • Indexer: limit_order_expired_parked → single parked_expired today; optional split once reason is on events
  • dApp claim copy (LimitOrderMyPlacementsPanel, lifecycle helpers) — “Claim dust” vs filled leftover
  1. On-chain (primary): Add reason to ExpiredLimitRefund / ExpiredLimitRefundResponse with #[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).
  2. Events: Emit reason (and keep force_expired for back-compat) on limit_order_expired_parked so indexers can classify without LCD round-trips.
  3. Do not store lifetime filled_amount on the pair for this issue — LimitOrder has only remaining; that would bloat every resting order. Serve filled volume from indexer placement/fill history if needed.
  4. Do not rename storage key / ClaimExpiredLimitOrder / map in this change (breaking + migration cost for naming only). Docs + reason close the footgun.
  5. Docs: Document that a refund row ≠ unfilled; table of reasons; clarify force_expired and expires_at overload; update integrator workaround note to prefer reason.

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 / ExpiredLimitRefundResponse expose a stable reason covering all four park paths above.
  • Each park call site sets the correct reason; claim execute unchanged economically (owner-only, pause gate, escrow L1).
  • Query shape remains non-breaking for clients that ignore unknown/new fields; historical rows either default safely or are documented.
  • limit_order_expired_parked includes reason (or equivalent attr) for indexer consumers.
  • Integrator docs (integrators.md, limit-orders.md, dex-common comments) state: park ≠ unfilled; document reason meanings and force_expired semantics.
  • No funds-path change: claim still refunds remaining only; no new privilege escalation on claim/clean.

Test plan — all park / claim paths

  1. DustFilled: Place limit → partial fill leaving 0 < remaining < LIMIT_ORDER_DUST_FLUSH_THRESHOLD → assert unlinked, claim row reason=DustFilled, expires_at=None, event attrs; claim restores escrow; book has no dust stub.
  2. Exact fill (control): Fill to remaining=0 → unlink, no EXPIRED_LIMIT_CLAIMS row (still Unlinked path).
  3. Expired: Place with expires_at in the past relative to match walk → park reason=Expired, expires_at=Some(..), force_expired absent/false; claim works.
  4. ForceCleaned: Governance/CleanLimitBook dust threshold path → reason=ForceCleaned, expires_at=None.
  5. Blacklisted: Resting maker blacklisted during match → park reason=Blacklisted (not DustFilled); taker still progresses; claim after unblacklist per #468 rules.
  6. Partial non-dust: Fill leaving remaining >= 10 → stays on book; no claim row.
  7. Batch claim: Mix of reasons in one ClaimExpiredLimitOrders → all-or-nothing economics unchanged; reasons only on query/events.
  8. Simulation alignment: HybridSimulation dust/expiry/blacklist skip behavior still matches execute for the same snapshot (no new park in sim).
  9. Regression: Existing #264 / #263 / #468 / #120 pause-blocks-claim tests still pass.

Test plan — attack / abuse / hack vectors

Vector Concern Expected
Spoofed / wrong reason Misleading PnL if a path sets DustFilled for blacklist/clean Unit/integration: each site asserts enum; forbid deriving reason only from expires_at/force_expired
Claim as non-owner Theft of parked escrow Still owner-only; unauthorized claim reverts
Claim while paused Bypass L6 freeze Still blocked (assert_not_paused)
Double-claim / replay Double refund Row removed after claim; second claim fails
Park when claim row already exists Invariant break / overwrite Existing InvariantViolation on duplicate order id
Permissionless CleanLimitBook spam Gas grief / reason spam Caps (max_orders, scan steps) unchanged; reason must not widen eligibility
Blacklist park budget exhaustion Skip vs park confusion Cap/skip attrs unchanged; skipped rows not falsely claimed as DustFilled
Malicious integrator treating any park as fill Wrong inventory Docs + reason; DustFilled only when post-fill dust path ran
Event/indexer injection Fake fill signals off-chain On-chain query is source of truth; indexer tests if reason parsed
Storage migration / decode of old rows DoS query or wrong default Default/Option strategy tested; no panic on pre-reason rows
Matching gas grief via extra writes Swap DoS Reason write only on existing park path; measure/assert no extra loads in fill hot path beyond park

Verification criteria

  • make test-contracts (or targeted pair/orderbook + limit_order_tests / blacklist_tests) green with new cases for all four reasons.
  • LCD/query fixture: for a dust-flushed order_id, expired_limit_refund returns reason consistent with DustFilled (not Expired).
  • Docs drift: integrator section explicitly warns against “park ⇒ unfilled”; table matches code.
  • Manual or LocalTerra: place tight rung → consume to dust → claim; bot-style reconcile using reason books fill without relying on last on-book poll race.
  • No change to claim transfer amounts vs pre-change for the same parked remaining.
  • Optional follow-up ticket OK: indexer lifecycle split + dApp copy (“filled leftover” vs “expired”) — not blockers if on-chain reason + docs land first.

Workaround for integrators until ship: treat park as filled ≈ last_on_book_remaining - refund.remaining only when you accept poll races; prefer waiting for reason once deployed.

## 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 `reason` discriminator on the refund row/response (option 1), not a rename/migration of the map/execute (option 2 alone is insufficient because `expires_at == null` stays multi-way ambiguous). ### Correction to the suggested enum `expires_at: None` covers **three** non-TTL parks, not two: | Park path | Call site | `force_expired` | `expires_at` on row | |-----------|-----------|-----------------|---------------------| | Match-time dust flush (#264) | `finalize_order_after_fill` | `true` | `None` | | TTL expiry during match | `park_expired_limit_order_for_claim` | `false` | `Some(..)` | | Blacklisted maker (#468) | match walk skip/park | `true` | `None` | | Governance force-clean (#263) | `limit_book_clean` | `true` | `None` | Suggested on-chain `reason` (names bikesheddable): `Expired | DustFilled | ForceCleaned | Blacklisted`. Also note: wasm attr `force_expired=true` means “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_event` - `smartcontracts/contracts/pair/src/limit_book_clean.rs` — permissionless clean parks - `smartcontracts/contracts/pair/src/state.rs` — `ExpiredLimitRefund`, `EXPIRED_LIMIT_CLAIMS` - `smartcontracts/packages/dex-common/src/pair.rs` — `ExpiredLimitRefundResponse`, query/execute docs for `ExpiredLimitRefund` / `ClaimExpiredLimitOrder*` - `smartcontracts/contracts/pair/src/contract.rs` / `msg.rs` — query wiring - `smartcontracts/tests/src/limit_order_tests.rs`, `blacklist_tests.rs` — existing park/claim coverage **Docs / 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.md` **Downstream (follow-up OK, not required to close on-chain semantics)** - Indexer: `limit_order_expired_parked` → single `parked_expired` today; optional split once `reason` is on events - dApp claim copy (`LimitOrderMyPlacementsPanel`, lifecycle helpers) — “Claim dust” vs filled leftover ### Recommended direction 1. **On-chain (primary):** Add `reason` to `ExpiredLimitRefund` / `ExpiredLimitRefundResponse` with `#[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). 2. **Events:** Emit `reason` (and keep `force_expired` for back-compat) on `limit_order_expired_parked` so indexers can classify without LCD round-trips. 3. **Do not** store lifetime `filled_amount` on the pair for this issue — `LimitOrder` has only `remaining`; that would bloat every resting order. Serve filled volume from indexer placement/fill history if needed. 4. **Do not** rename storage key / `ClaimExpiredLimitOrder` / map in this change (breaking + migration cost for naming only). Docs + `reason` close the footgun. 5. **Docs:** Document that a refund row ≠ unfilled; table of reasons; clarify `force_expired` and `expires_at` overload; update integrator workaround note to prefer `reason`. 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` / `ExpiredLimitRefundResponse` expose a stable `reason` covering all four park paths above. - [ ] Each park call site sets the correct reason; claim execute unchanged economically (owner-only, pause gate, escrow L1). - [ ] Query shape remains non-breaking for clients that ignore unknown/new fields; historical rows either default safely or are documented. - [ ] `limit_order_expired_parked` includes `reason` (or equivalent attr) for indexer consumers. - [ ] Integrator docs (`integrators.md`, `limit-orders.md`, dex-common comments) state: park ≠ unfilled; document reason meanings and `force_expired` semantics. - [ ] No funds-path change: claim still refunds `remaining` only; no new privilege escalation on claim/clean. ### Test plan — all park / claim paths 1. **DustFilled:** Place limit → partial fill leaving `0 < remaining < LIMIT_ORDER_DUST_FLUSH_THRESHOLD` → assert unlinked, claim row `reason=DustFilled`, `expires_at=None`, event attrs; claim restores escrow; book has no dust stub. 2. **Exact fill (control):** Fill to `remaining=0` → unlink, **no** `EXPIRED_LIMIT_CLAIMS` row (still Unlinked path). 3. **Expired:** Place with `expires_at` in the past relative to match walk → park `reason=Expired`, `expires_at=Some(..)`, `force_expired` absent/false; claim works. 4. **ForceCleaned:** Governance/`CleanLimitBook` dust threshold path → `reason=ForceCleaned`, `expires_at=None`. 5. **Blacklisted:** Resting maker blacklisted during match → park `reason=Blacklisted` (not DustFilled); taker still progresses; claim after unblacklist per #468 rules. 6. **Partial non-dust:** Fill leaving `remaining >= 10` → stays on book; no claim row. 7. **Batch claim:** Mix of reasons in one `ClaimExpiredLimitOrders` → all-or-nothing economics unchanged; reasons only on query/events. 8. **Simulation alignment:** `HybridSimulation` dust/expiry/blacklist skip behavior still matches execute for the same snapshot (no new park in sim). 9. **Regression:** Existing #264 / #263 / #468 / #120 pause-blocks-claim tests still pass. ### Test plan — attack / abuse / hack vectors | Vector | Concern | Expected | |--------|---------|----------| | Spoofed / wrong `reason` | Misleading PnL if a path sets DustFilled for blacklist/clean | Unit/integration: each site asserts enum; forbid deriving reason only from `expires_at`/`force_expired` | | Claim as non-owner | Theft of parked escrow | Still owner-only; unauthorized claim reverts | | Claim while paused | Bypass L6 freeze | Still blocked (`assert_not_paused`) | | Double-claim / replay | Double refund | Row removed after claim; second claim fails | | Park when claim row already exists | Invariant break / overwrite | Existing `InvariantViolation` on duplicate order id | | Permissionless `CleanLimitBook` spam | Gas grief / reason spam | Caps (`max_orders`, scan steps) unchanged; reason must not widen eligibility | | Blacklist park budget exhaustion | Skip vs park confusion | Cap/skip attrs unchanged; skipped rows not falsely claimed as DustFilled | | Malicious integrator treating any park as fill | Wrong inventory | Docs + reason; DustFilled only when post-fill dust path ran | | Event/indexer injection | Fake fill signals off-chain | On-chain query is source of truth; indexer tests if `reason` parsed | | Storage migration / decode of old rows | DoS query or wrong default | Default/`Option` strategy tested; no panic on pre-reason rows | | Matching gas grief via extra writes | Swap DoS | Reason write only on existing park path; measure/assert no extra loads in fill hot path beyond park | ### Verification criteria - [ ] `make test-contracts` (or targeted pair/orderbook + `limit_order_tests` / `blacklist_tests`) green with new cases for all four reasons. - [ ] LCD/query fixture: for a dust-flushed order_id, `expired_limit_refund` returns `reason` consistent with DustFilled (not Expired). - [ ] Docs drift: integrator section explicitly warns against “park ⇒ unfilled”; table matches code. - [ ] Manual or LocalTerra: place tight rung → consume to dust → claim; bot-style reconcile using `reason` books fill without relying on last on-book poll race. - [ ] No change to claim transfer amounts vs pre-change for the same parked `remaining`. - [ ] Optional follow-up ticket OK: indexer lifecycle split + dApp copy (“filled leftover” vs “expired”) — not blockers if on-chain `reason` + docs land first. Workaround for integrators until ship: treat park as `filled ≈ last_on_book_remaining - refund.remaining` only when you accept poll races; prefer waiting for `reason` once deployed.
PlasticDigits commented 2026-08-05 02:09:42 +00:00 (Migrated from gitlab.com)

mentioned in commit 3b93a9a743

mentioned in commit 3b93a9a743dd62e98e89c750245cbe9040440300
PlasticDigits commented 2026-08-05 02:09:42 +00:00 (Migrated from gitlab.com)

mentioned in commit 46377d3612

mentioned in commit 46377d36126e301417c0d06fb5094ba6ceb70262
PlasticDigits commented 2026-08-05 02:09:52 +00:00 (Migrated from gitlab.com)

mentioned in merge request !1043

mentioned in merge request !1043
PlasticDigits commented 2026-08-05 02:10:01 +00:00 (Migrated from gitlab.com)

Implementation landed — !1043

MR: https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/merge_requests/325

Acceptance criteria

  • ExpiredLimitRefund / ExpiredLimitRefundResponse expose stable reason covering Expired | DustFilled | ForceCleaned | Blacklisted
  • Each park call site sets the correct reason; claim execute unchanged economically
  • Query shape non-breaking (reason optional; legacy omit → null); clients ignoring new fields still decode
  • limit_order_expired_parked includes wasm reason= (keeps force_expired for back-compat)
  • Integrator docs (integrators.md, limit-orders.md, dex-common comments) + invariant L21 + skill AGENTS_EXPIRED_LIMIT_PARK_REASON.md
  • No funds-path change: claim still refunds remaining only

Test / verification

  • make test-contracts green (incl. all four reason paths + pause/non-owner/batch regressions)
  • Legacy JSON fixture: omitted reason → None (no panic / no invented DustFilled)
  • Manual LocalTerra bot-style reconcile (optional; multi-test covers LCD query shape)

Intentionally not in this MR (follow-ups)

  • Indexer lifecycle split / parse of reason (still single parked_expired)
  • dApp copy: replace remaining < 10 “Claim dust” heuristic with reason
  • No filled_amount on pair; no rename of map / ClaimExpiredLimitOrder
## Implementation landed — !1043 MR: https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/merge_requests/325 ### Acceptance criteria - [x] `ExpiredLimitRefund` / `ExpiredLimitRefundResponse` expose stable `reason` covering Expired | DustFilled | ForceCleaned | Blacklisted - [x] Each park call site sets the correct reason; claim execute unchanged economically - [x] Query shape non-breaking (`reason` optional; legacy omit → `null`); clients ignoring new fields still decode - [x] `limit_order_expired_parked` includes wasm `reason=` (keeps `force_expired` for back-compat) - [x] Integrator docs (`integrators.md`, `limit-orders.md`, dex-common comments) + invariant **L21** + skill `AGENTS_EXPIRED_LIMIT_PARK_REASON.md` - [x] No funds-path change: claim still refunds `remaining` only ### Test / verification - [x] `make test-contracts` green (incl. all four reason paths + pause/non-owner/batch regressions) - [x] Legacy JSON fixture: omitted `reason` → `None` (no panic / no invented DustFilled) - [ ] Manual LocalTerra bot-style reconcile (optional; multi-test covers LCD query shape) ### Intentionally not in this MR (follow-ups) - Indexer lifecycle split / parse of `reason` (still single `parked_expired`) - dApp copy: replace `remaining < 10` “Claim dust” heuristic with `reason` - No `filled_amount` on pair; no rename of map / `ClaimExpiredLimitOrder`
PlasticDigits commented 2026-08-05 02:17:45 +00:00 (Migrated from gitlab.com)

mentioned in commit ab5da35547

mentioned in commit ab5da355477bc9932ba2d270f9170404350dc1b8
PlasticDigits commented 2026-08-08 10:38:41 +00:00 (Migrated from gitlab.com)

mentioned in commit 31784ef504

mentioned in commit 31784ef504c42c20ef3bb9cf5f9ab2dcd6ac214d
PlasticDigits commented 2026-08-08 10:38:41 +00:00 (Migrated from gitlab.com)

mentioned in commit ac62c2bf51

mentioned in commit ac62c2bf51f47009e2f1c3f8e4c10098fb4054c2
PlasticDigits commented 2026-08-08 10:38:49 +00:00 (Migrated from gitlab.com)

Verification 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 / merge ac62c2b

What we verified

Criterion Result
Stable reason on refund row/response for Expired / DustFilled / ForceCleaned / Blacklisted Pass
Each park call site sets reason explicitly (not derived from expires_at/force_expired) Pass
Query non-breaking (reason optional; legacy omit → null) Pass
limit_order_expired_parked emits reason= (+ keeps force_expired) Pass
Integrator docs + invariant L22 + skill AGENTS_EXPIRED_LIMIT_PARK_REASON.md Pass
Claim economics unchanged (refund remaining only; owner-only; pause-gated) Pass
LocalTerra LCD: dust flush → expired_limit_refund.reason == dust_filled (not expired) Pass

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 and side: "bid". Docs/skills/L22 + a unit wire assert were corrected; no on-chain funds-path change.

Tooling added

make verify-issue-504                 # multi-test + docs gate
VERIFY504_LCD=1 make verify-issue-504 # + LocalTerra dust-flush LCD smoke

Checklist for re-verify / QA agents

  • make verify-issue-504 green (no chain)
  • After make deploy-local with current pair wasm: VERIFY504_LCD=1 make verify-issue-504
  • LCD { "expired_limit_refund": { "order_id": N } } after dust flush shows "reason":"dust_filled", "expires_at":null, small remaining
  • Do not treat any parked row as unfilled expiry — read reason first
  • Confirm claim still refunds remaining only (pause + owner gates unchanged)
  • Follow-ups still open (not blockers): indexer parse of reason; dApp replace remaining < 10 “Claim dust” heuristic

Intentionally not required to close

  • Indexer lifecycle split beyond parked_expired
  • Frontend copy wired to LCD/indexer reason
  • Pair rename of ClaimExpiredLimitOrder / map key
  • Lifetime filled_amount on-chain
## Verification 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` / merge `ac62c2b` ### What we verified | Criterion | Result | |-----------|--------| | Stable `reason` on refund row/response for Expired / DustFilled / ForceCleaned / Blacklisted | Pass | | Each park call site sets reason explicitly (not derived from `expires_at`/`force_expired`) | Pass | | Query non-breaking (`reason` optional; legacy omit → null) | Pass | | `limit_order_expired_parked` emits `reason=` (+ keeps `force_expired`) | Pass | | Integrator docs + invariant **L22** + skill `AGENTS_EXPIRED_LIMIT_PARK_REASON.md` | Pass | | Claim economics unchanged (refund `remaining` only; owner-only; pause-gated) | Pass | | LocalTerra LCD: dust flush → `expired_limit_refund.reason == dust_filled` (not `expired`) | Pass | ### 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 and `side: "bid"`. Docs/skills/L22 + a unit wire assert were corrected; no on-chain funds-path change. ### Tooling added ```bash make verify-issue-504 # multi-test + docs gate VERIFY504_LCD=1 make verify-issue-504 # + LocalTerra dust-flush LCD smoke ``` ### Checklist for re-verify / QA agents - [ ] `make verify-issue-504` green (no chain) - [ ] After `make deploy-local` with current pair wasm: `VERIFY504_LCD=1 make verify-issue-504` - [ ] LCD `{ "expired_limit_refund": { "order_id": N } }` after dust flush shows `"reason":"dust_filled"`, `"expires_at":null`, small `remaining` - [ ] Do **not** treat any parked row as unfilled expiry — read `reason` first - [ ] Confirm claim still refunds `remaining` only (pause + owner gates unchanged) - [ ] Follow-ups still open (not blockers): indexer parse of `reason`; dApp replace `remaining < 10` “Claim dust” heuristic ### Intentionally not required to close - Indexer lifecycle split beyond `parked_expired` - Frontend copy wired to LCD/indexer `reason` - Pair rename of `ClaimExpiredLimitOrder` / map key - Lifetime `filled_amount` on-chain
PlasticDigits (Migrated from gitlab.com) closed this issue 2026-08-08 10:38:50 +00:00
PlasticDigits commented 2026-08-17 10:26:08 +00:00 (Migrated from gitlab.com)

mentioned in issue #546

mentioned in issue #546
leonardocolucci commented 2026-08-17 21:14:17 +00:00 (Migrated from gitlab.com)

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.

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.
PlasticDigits commented 2026-08-22 03:10:04 +00:00 (Migrated from gitlab.com)

mentioned in issue #589

mentioned in issue #589
PlasticDigits commented 2026-08-22 12:26:36 +00:00 (Migrated from gitlab.com)

mentioned in issue #597

mentioned in issue #597
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
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#504
No description provided.