[Security/Critical] Investigate: expired limit orders in match_* do not refund makers; funds may be lost or swept #120

Closed
opened 2026-05-03 11:58:26 +00:00 by PlasticDigits · 34 comments
PlasticDigits commented 2026-05-03 11:58:26 +00:00 (Migrated from gitlab.com)

Summary

Third-party security report: when an expired limit order is hit during a taker match walk (match_bids / match_asks), the code appears to decrement pending escrow and remove the order without refunding CW20 tokens to the maker. This investigation should confirm behaviour, quantify impact for limit orders with expires_at, and define a fix (refund parity with cancellation path).

Reported severity

Critical

Location

  • File: smartcontracts/contracts/pair/src/orderbook.rs
  • match_bids: reportedly ~563–568
  • match_asks: reportedly ~739–744

Reference (reported behaviour, paraphrased):

// match_bids — when order is expired during walk
if order.expires_at.is_some_and(|e| now >= e) {
    escrow_sub_pending_token1(storage, order.remaining)?;
    unlink_order(storage, oid)?;
    cur = next_ptr;
    continue; // no CW20 Transfer to owner
}

// match_asks — analogous for token0
if order.expires_at.is_some_and(|e| now >= e) {
    escrow_sub_pending_token0(storage, order.remaining)?;
    unlink_order(storage, oid)?;
    cur = next_ptr;
    continue;
}

During the walk:

  1. escrow_sub_pending_token0 / _token1 reduces PENDING_ESCROW by order.remaining
  2. unlink_order removes the order from storage
  3. No CW20 transfer is issued to the maker

Tokens would remain in the pair contract CW20 balance; execute_sweep’s excess (actual_balance - reserve - pending_escrow) could then grow by order.remaining, making funds sweepable (e.g. by governance recipient) unless another recovery path exists.

Contrast: execute_cancel_limit_order (~1169 per report) correctly performs CosmosMsg::Wasm(Cw20ExecuteMsg::Transfer { recipient: removed.owner, amount: removed.remaining, ... }) before reducing escrow.

Reported attack / failure scenario

  1. Maker posts a bid with expires_at = T+1h
  2. Order is not filled; pair not paused
  3. After expiry, any taker runs a hybrid swap that walks that side of the book
  4. Expiry branch runs: escrow decremented, order deleted, no refund
  5. Governance (or ambiguity) regarding sweep; or stranded balance if nobody sweeps and the maker cannot cancel because the order row is gone

Impact (per reporter)

Limit orders that use expires_at and expire without explicit cancel may lose maker escrow when the next walk processes that expiry branch; recovery via CancelLimitOrder fails if ORDERS no longer contains the id.

Investigation checklist

  • Verify current branch matches line numbers / logic above
  • Trace full accounting path: escrow, CW20 custody, sweep math
  • Compare with hybrid swap entry points and non-expiry removals
  • Add tests (unit/integration) covering expired order during match walk vs cancel
  • Propose fix: refund maker on expiry in match paths (mirror cancel), or alternate ledger path that preserves invariants

Raw source snippet (verbatim from reporter)

Expired limit orders encountered during a match walk call escrow_sub_pending_* and unlink_order but do not CW20-transfer to owner; execute_cancel_limit_order does transfer. Reporter claims permanent loss / sweep-by-excess risk for expires_at orders when a subsequent taker triggers the expiry branch.

## Summary Third-party security report: when an expired limit order is hit during a taker match walk (`match_bids` / `match_asks`), the code appears to decrement pending escrow and remove the order **without** refunding CW20 tokens to the maker. This investigation should confirm behaviour, quantify impact for limit orders with `expires_at`, and define a fix (refund parity with cancellation path). ## Reported severity Critical ## Location - File: `smartcontracts/contracts/pair/src/orderbook.rs` - **`match_bids`**: reportedly ~563–568 - **`match_asks`**: reportedly ~739–744 Reference (reported behaviour, paraphrased): ```rust // match_bids — when order is expired during walk if order.expires_at.is_some_and(|e| now >= e) { escrow_sub_pending_token1(storage, order.remaining)?; unlink_order(storage, oid)?; cur = next_ptr; continue; // no CW20 Transfer to owner } // match_asks — analogous for token0 if order.expires_at.is_some_and(|e| now >= e) { escrow_sub_pending_token0(storage, order.remaining)?; unlink_order(storage, oid)?; cur = next_ptr; continue; } ``` During the walk: 1. `escrow_sub_pending_token0` / `_token1` reduces `PENDING_ESCROW` by `order.remaining` 2. `unlink_order` removes the order from storage 3. No CW20 transfer is issued to the maker Tokens would remain in the pair contract CW20 balance; `execute_sweep`’s excess (`actual_balance - reserve - pending_escrow`) could then grow by `order.remaining`, making funds sweepable (e.g. by governance recipient) unless another recovery path exists. Contrast: `execute_cancel_limit_order` (~1169 per report) correctly performs `CosmosMsg::Wasm(Cw20ExecuteMsg::Transfer { recipient: removed.owner, amount: removed.remaining, ... })` before reducing escrow. ## Reported attack / failure scenario 1. Maker posts a bid with `expires_at = T+1h` 2. Order is not filled; pair not paused 3. After expiry, any taker runs a hybrid swap that walks that side of the book 4. Expiry branch runs: escrow decremented, order deleted, no refund 5. Governance (or ambiguity) regarding sweep; or stranded balance if nobody sweeps and the maker cannot cancel because the order row is gone ## Impact (per reporter) Limit orders that use `expires_at` and expire without explicit cancel may lose maker escrow when the next walk processes that expiry branch; recovery via `CancelLimitOrder` fails if `ORDERS` no longer contains the id. ## Investigation checklist - [ ] Verify current branch matches line numbers / logic above - [ ] Trace full accounting path: escrow, CW20 custody, sweep math - [ ] Compare with hybrid swap entry points and non-expiry removals - [ ] Add tests (unit/integration) covering expired order during match walk vs cancel - [ ] Propose fix: refund maker on expiry in match paths (mirror cancel), or alternate ledger path that preserves invariants ## Raw source snippet (verbatim from reporter) Expired limit orders encountered during a match walk call `escrow_sub_pending_*` and `unlink_order` but do not CW20-transfer to `owner`; `execute_cancel_limit_order` does transfer. Reporter claims permanent loss / sweep-by-excess risk for `expires_at` orders when a subsequent taker triggers the expiry branch.
PlasticDigits commented 2026-05-03 13:05:49 +00:00 (Migrated from gitlab.com)

mentioned in commit 5c744eef9c

mentioned in commit 5c744eef9cf85e15a0b6c1e0e023a95d13d945b0
PlasticDigits commented 2026-05-03 13:06:10 +00:00 (Migrated from gitlab.com)

Fix implemented and pushed to main

@brouie Please verify the expired limit-order escrow handling.

What changed

  • Match walk (match_bids / match_asks): When expires_at has passed, the order is unlinked from the book, stored in EXPIRED_LIMIT_CLAIMS, and PENDING_ESCROW_* is not reduced in that taker transaction. A wasm event limit_order_expired_parked is emitted. No CW20 is sent to the maker in the taker tx.
  • ClaimExpiredLimitOrder: Owner-only; subtracts pending escrow and sends CW20 (same token routing as cancel). Clears the claim row.
  • CancelLimitOrder: Still only applies to active rows in ORDERS; after a park there is no row, so cancel errors — no double refund.
  • Query ExpiredLimitRefund { order_id }: Returns the claimable row or null.
  • Pause: ClaimExpiredLimitOrder is not blocked when the pair is paused (unlike cancel), so parked refunds remain recoverable.

Docs / agents

  • docs/contracts-security-audit.md L1, L6; docs/limit-orders.md; smartcontracts/contracts/pair/src/lib.rs; skills/AGENTS_LOCALNET_TRADING_SWARM.md; skills/AGENTS_TERRACLASSIC_GAS.md (crosslinks to #120).

Verification checklist

  • Unit / integration: cargo test -p cl8y-dex-pair -p cl8y-dex-tests (includes expired_bid_parked_on_hybrid_walk_claim_refunds_maker, claim_expired_limit_order_allowed_while_pair_paused, park_expired_bid_unlinks_and_records_claim_without_pending_delta).
  • Accounting: After a taker walk parks an expired bid, pending token1 escrow on the pair is unchanged until the maker claims; pair CW20 balance still covers it; sweep excess must not include that escrow.
  • Double-spend: CancelLimitOrder on a parked id fails; second ClaimExpiredLimitOrder fails with no claim row.
  • Indexer / UX: Subsystems that relied on old behaviour (pending decremented without transfer) should use ExpiredLimitRefund + limit_order_expired_parked for maker recovery.

Leaving issue open for your sign-off.

## Fix implemented and pushed to `main` @brouie Please verify the expired limit-order escrow handling. ### What changed - **Match walk (`match_bids` / `match_asks`)**: When `expires_at` has passed, the order is **unlinked** from the book, stored in **`EXPIRED_LIMIT_CLAIMS`**, and **`PENDING_ESCROW_*` is not reduced** in that taker transaction. A wasm event **`limit_order_expired_parked`** is emitted. **No CW20** is sent to the maker in the taker tx. - **`ClaimExpiredLimitOrder`**: Owner-only; subtracts pending escrow and sends CW20 (same token routing as cancel). Clears the claim row. - **`CancelLimitOrder`**: Still only applies to active rows in `ORDERS`; after a park there is no row, so cancel errors — **no double refund**. - **Query `ExpiredLimitRefund { order_id }`**: Returns the claimable row or null. - **Pause**: `ClaimExpiredLimitOrder` is **not** blocked when the pair is paused (unlike cancel), so parked refunds remain recoverable. ### Docs / agents - `docs/contracts-security-audit.md` **L1**, **L6**; `docs/limit-orders.md`; `smartcontracts/contracts/pair/src/lib.rs`; `skills/AGENTS_LOCALNET_TRADING_SWARM.md`; `skills/AGENTS_TERRACLASSIC_GAS.md` (crosslinks to #120). ### Verification checklist - [ ] **Unit / integration**: `cargo test -p cl8y-dex-pair -p cl8y-dex-tests` (includes `expired_bid_parked_on_hybrid_walk_claim_refunds_maker`, `claim_expired_limit_order_allowed_while_pair_paused`, `park_expired_bid_unlinks_and_records_claim_without_pending_delta`). - [ ] **Accounting**: After a taker walk parks an expired bid, **pending token1 escrow** on the pair is unchanged until the maker **claims**; pair CW20 balance still covers it; **sweep** excess must **not** include that escrow. - [ ] **Double-spend**: `CancelLimitOrder` on a parked id fails; second `ClaimExpiredLimitOrder` fails with no claim row. - [ ] **Indexer / UX**: Subsystems that relied on old behaviour (pending decremented without transfer) should use **`ExpiredLimitRefund`** + **`limit_order_expired_parked`** for maker recovery. Leaving issue **open** for your sign-off.
Brouie commented 2026-05-04 03:41:54 +00:00 (Migrated from gitlab.com)

verified at cargo unit/integration level. all 3 specifically-called-out tests pass:

  • orderbook::tests::park_expired_bid_unlinks_and_records_claim_without_pending_delta — confirms pending escrow stays untouched on park (Item 2 accounting invariant)
  • limit_order_tests::expired_bid_parked_on_hybrid_walk_claim_refunds_maker — refund parity with cancellation path
  • limit_order_tests::claim_expired_limit_order_allowed_while_pair_paused — pause doesn't block recovery

cargo test --workspace overall: 308 tests pass, 0 failures.

double-spend gate (Item 3) covered by the orderbook tests + your design note that CancelLimitOrder only applies to ORDERS rows and a parked claim row is consumed on first claim — both paths fail closed.

Item 4 (indexer / UX migration to ExpiredLimitRefund + limit_order_expired_parked for maker recovery) is downstream of the contract fix and lives in the indexer/UX subsystems, not gated on contract sign-off.

verified at cargo unit/integration level. all 3 specifically-called-out tests pass: - `orderbook::tests::park_expired_bid_unlinks_and_records_claim_without_pending_delta` — confirms pending escrow stays untouched on park (Item 2 accounting invariant) - `limit_order_tests::expired_bid_parked_on_hybrid_walk_claim_refunds_maker` — refund parity with cancellation path - `limit_order_tests::claim_expired_limit_order_allowed_while_pair_paused` — pause doesn't block recovery `cargo test --workspace` overall: 308 tests pass, 0 failures. double-spend gate (Item 3) covered by the orderbook tests + your design note that `CancelLimitOrder` only applies to ORDERS rows and a parked claim row is consumed on first claim — both paths fail closed. Item 4 (indexer / UX migration to `ExpiredLimitRefund` + `limit_order_expired_parked` for maker recovery) is downstream of the contract fix and lives in the indexer/UX subsystems, not gated on contract sign-off.
Brouie commented 2026-05-04 03:42:33 +00:00 (Migrated from gitlab.com)

@PlasticDigits — flagging the verification above for your eyes since this is the security/critical one.

@PlasticDigits — flagging the verification above for your eyes since this is the security/critical one.
PlasticDigits commented 2026-05-04 03:43:26 +00:00 (Migrated from gitlab.com)

@Brouie Please recheck item 4 in indexer and frontend packages, reiew needs to cover these as well as the contracts

@Brouie Please recheck item 4 in indexer and frontend packages, reiew needs to cover these as well as the contracts
Brouie commented 2026-05-05 03:06:16 +00:00 (Migrated from gitlab.com)

@PlasticDigits — re-checked item 4 across indexer + frontend per your ask. read the diff between baseline and HEAD 9c32a51 end-to-end across indexer/src/, indexer/tests/, frontend-dapp/src/services/terraclassic/, frontend-dapp/src/pages/LimitOrdersPage.tsx, frontend-dapp/src/components/trade/TradeOrderTicket.tsx, and the e2e specs. contract side stays PASS. found real gaps on both indexer and frontend — flagging because the user-protective intent of #120 is invisible at the dapp level today.

Indexer — NOT FIXED

grep -rn 'limit_order_expired_parked|claim_expired_limit_order|expired_limit_refund|EXPIRED_LIMIT_CLAIMS' indexer/ returns zero matches.

  • no decoder, no sol!/struct/string match for limit_order_expired_parked anywhere under indexer/src/
  • existing handlers in indexer/src/indexer/parser.rs strict-match action strings limit_order_fill (L427), place_limit_order (L538), cancel_limit_order (L583). new park event silently dropped at ingest.
  • no migration adds expired_parked or refund_claimed table — only limit_order_placements and limit_order_cancellations exist (migrations/20260326120000_swap_events_unique_limit_lifecycle.sql:32-58). persistence target doesn't exist either.

contract side is fine — park_expired_limit_order_for_claim only emits limit_order_expired_parked (verified at smartcontracts/contracts/pair/src/orderbook.rs:617-622 and :794). no false-positive misclassification on the existing parsers. so the indexer doesn't misreport the order as filled or cancelled — it just doesn't report it at all.

Frontend — NOT FIXED

grep -rn 'expired_limit_refund|ExpiredLimitRefund|claim_expired|ClaimExpiredLimitOrder|limit_order_expired_parked|EXPIRED_LIMIT_CLAIMS' frontend-dapp/src/ returns zero matches.

  • frontend-dapp/src/services/terraclassic/pair.ts exposes only placeLimitOrder (L95) and cancelLimitOrder (L125). no claimExpiredLimitOrder helper, no ExpiredLimitRefund query helper.
  • frontend-dapp/src/services/terraclassic/transactions.ts gas-limit branches only for place_limit_order and cancel_limit_order (L75-96).
  • frontend-dapp/src/pages/LimitOrdersPage.tsx and components/trade/TradeOrderTicket.tsx are the only entry points that touch limit orders — both place + cancel only.

maker recovery via the official UI is impossible at HEAD 9c32a51. a maker can recover via hand-crafted contract message in their wallet but that's outside the dapp.

since the surface doesn't exist yet there's no live UX walk to do this round. when the claim flow ships, happy to walk the full thing on localnet — maker places limit, expires, taker walks past, maker sees the parked status surfaced, claims successfully, double-spend gate confirms on second claim attempt, plus the pause-doesn't-block-claim path.

OrderBook display — PARTIAL / misleading

LCD book panel (LimitOrdersPage.tsx:242-288) reads getPairLimitBookPage which walks the on-chain FIFO via limit_book_lcd.rs. parked orders are correctly absent from the LCD book because park_expired_limit_order_for_claim calls unlink_order first. that part isn't misleading.

but the 'Your recent placements (indexer)' panel (L383-400) renders myPlacements straight from limit_order_placements with no status, no terminal-state column, no claimable-refund signal:

<li key={r.id}>
  order #{r.order_id} · {r.side ?? '?'} · {r.price ?? '?'} · {r.block_timestamp.slice(0, 19)}
</li>

after expiry-park, the maker's order silently disappears from the LCD book panel while still listed under 'recent placements' with no annotation. no UI string mentioning expired/parked/claim, no link to a recovery flow.

Net effect on user experience

a maker who triggered the very condition #120 was filed to protect against sees:

  1. order vanish from the order book (looks gone)
  2. same order still listed in 'recent placements' with no terminal status
  3. no surface saying 'your order expired but funds are recoverable'
  4. no button / flow to claim
  5. net perception: 'my order vanished, did I lose funds?'

contract correctly preserves the escrow invariant via EXPIRED_LIMIT_CLAIMS and exposes ClaimExpiredLimitOrder + ExpiredLimitRefund. but the dapp-level experience blunts the protective intent.

Tests in indexer/ and frontend-dapp/ — NOT FIXED

no matches in indexer/tests/ for park/claim/expired-refund (the four expires_at hits in api_limit_book_lcd_mock.rs and api_limit_book_deep.rs are pre-existing fixture fields, not new park-flow tests). no matches in frontend-dapp/e2e/limit-orders.spec.ts, limit-orders-tx.spec.ts, or frontend-dapp/src/test/.

coverage for the new code path lives entirely in smartcontracts/tests/src/limit_order_tests.rs (the 3 contract tests already verified yesterday).

Net-new follow-up findings

  • frontend lacks maker-facing recovery path — High. LimitOrdersPage.tsx + TradeOrderTicket.tsx handle place + cancel only. no helper / hook / mutation / button / status surface for claim_expired_limit_order or expired_limit_refund.
  • indexer can't distinguish parked-expiry from 'still active' — Medium. /api/v1/pairs/{addr}/limit-placements (pairs.rs:582) returns the placement indefinitely with no terminal indicator. clients joining 'indexed placements' against 'current LCD book' to compute order status will be wrong for parked-expired orders — they look like 'placed but missing from book' which is also the failure mode of 'node lost the order due to bug'. LimitPlacementResponse (pairs.rs:550-566) has no status field that could be backfilled without a schema change.
  • preventive note for whoever implements the future claim button: claim must NOT be gated on isPaused. cancel button correctly disables under pause (LimitOrdersPage.tsx:367), and copy-pasting that gate onto a claim button would re-introduce the recovery-while-paused regression. contract intentionally allows ClaimExpiredLimitOrder while paused per docs/limit-orders.md and contract.rs:1183-1247.

contract fix stands. indexer + frontend gaps are separate work — could spawn:

  • child A (indexer): decode limit_order_expired_parked, persist to new limit_order_expirations table, optionally also decode the claim/refund event for terminal-state tracking.
  • child B (frontend): useExpiredLimitRefund(orderId) hook + query helper, claimExpiredLimitOrder mutation in pair.ts, status badge on 'recent placements' panel, claim button on parked rows. don't gate on isPaused.

happy to file these as separate tickets if you want them tracked independently, or you can scope them as #120 follow-ups. either shape, ready to verify when the work lands.

@PlasticDigits — re-checked item 4 across indexer + frontend per your ask. read the diff between baseline and HEAD `9c32a51` end-to-end across `indexer/src/`, `indexer/tests/`, `frontend-dapp/src/services/terraclassic/`, `frontend-dapp/src/pages/LimitOrdersPage.tsx`, `frontend-dapp/src/components/trade/TradeOrderTicket.tsx`, and the e2e specs. contract side stays PASS. found real gaps on both indexer and frontend — flagging because the user-protective intent of #120 is invisible at the dapp level today. ## Indexer — NOT FIXED `grep -rn 'limit_order_expired_parked|claim_expired_limit_order|expired_limit_refund|EXPIRED_LIMIT_CLAIMS' indexer/` returns zero matches. - no decoder, no `sol!`/struct/string match for `limit_order_expired_parked` anywhere under `indexer/src/` - existing handlers in `indexer/src/indexer/parser.rs` strict-match action strings `limit_order_fill` (L427), `place_limit_order` (L538), `cancel_limit_order` (L583). new park event silently dropped at ingest. - no migration adds `expired_parked` or `refund_claimed` table — only `limit_order_placements` and `limit_order_cancellations` exist (`migrations/20260326120000_swap_events_unique_limit_lifecycle.sql:32-58`). persistence target doesn't exist either. contract side is fine — `park_expired_limit_order_for_claim` only emits `limit_order_expired_parked` (verified at `smartcontracts/contracts/pair/src/orderbook.rs:617-622` and `:794`). no false-positive misclassification on the existing parsers. so the indexer doesn't misreport the order as filled or cancelled — it just doesn't report it at all. ## Frontend — NOT FIXED `grep -rn 'expired_limit_refund|ExpiredLimitRefund|claim_expired|ClaimExpiredLimitOrder|limit_order_expired_parked|EXPIRED_LIMIT_CLAIMS' frontend-dapp/src/` returns zero matches. - `frontend-dapp/src/services/terraclassic/pair.ts` exposes only `placeLimitOrder` (L95) and `cancelLimitOrder` (L125). no `claimExpiredLimitOrder` helper, no `ExpiredLimitRefund` query helper. - `frontend-dapp/src/services/terraclassic/transactions.ts` gas-limit branches only for `place_limit_order` and `cancel_limit_order` (L75-96). - `frontend-dapp/src/pages/LimitOrdersPage.tsx` and `components/trade/TradeOrderTicket.tsx` are the only entry points that touch limit orders — both place + cancel only. maker recovery via the official UI is impossible at HEAD `9c32a51`. a maker can recover via hand-crafted contract message in their wallet but that's outside the dapp. since the surface doesn't exist yet there's no live UX walk to do this round. when the claim flow ships, happy to walk the full thing on localnet — maker places limit, expires, taker walks past, maker sees the parked status surfaced, claims successfully, double-spend gate confirms on second claim attempt, plus the pause-doesn't-block-claim path. ## OrderBook display — PARTIAL / misleading LCD book panel (`LimitOrdersPage.tsx:242-288`) reads `getPairLimitBookPage` which walks the on-chain FIFO via `limit_book_lcd.rs`. parked orders are correctly absent from the LCD book because `park_expired_limit_order_for_claim` calls `unlink_order` first. that part isn't misleading. but the 'Your recent placements (indexer)' panel (L383-400) renders `myPlacements` straight from `limit_order_placements` with no status, no terminal-state column, no claimable-refund signal: ```tsx <li key={r.id}> order #{r.order_id} · {r.side ?? '?'} · {r.price ?? '?'} · {r.block_timestamp.slice(0, 19)} </li> ``` after expiry-park, the maker's order silently disappears from the LCD book panel while still listed under 'recent placements' with no annotation. no UI string mentioning expired/parked/claim, no link to a recovery flow. ## Net effect on user experience a maker who triggered the very condition #120 was filed to protect against sees: 1. order vanish from the order book (looks gone) 2. same order still listed in 'recent placements' with no terminal status 3. no surface saying 'your order expired but funds are recoverable' 4. no button / flow to claim 5. net perception: 'my order vanished, did I lose funds?' contract correctly preserves the escrow invariant via `EXPIRED_LIMIT_CLAIMS` and exposes `ClaimExpiredLimitOrder` + `ExpiredLimitRefund`. but the dapp-level experience blunts the protective intent. ## Tests in indexer/ and frontend-dapp/ — NOT FIXED no matches in `indexer/tests/` for park/claim/expired-refund (the four `expires_at` hits in `api_limit_book_lcd_mock.rs` and `api_limit_book_deep.rs` are pre-existing fixture fields, not new park-flow tests). no matches in `frontend-dapp/e2e/limit-orders.spec.ts`, `limit-orders-tx.spec.ts`, or `frontend-dapp/src/test/`. coverage for the new code path lives entirely in `smartcontracts/tests/src/limit_order_tests.rs` (the 3 contract tests already verified yesterday). ## Net-new follow-up findings - **frontend lacks maker-facing recovery path** — High. `LimitOrdersPage.tsx` + `TradeOrderTicket.tsx` handle place + cancel only. no helper / hook / mutation / button / status surface for `claim_expired_limit_order` or `expired_limit_refund`. - **indexer can't distinguish parked-expiry from 'still active'** — Medium. `/api/v1/pairs/{addr}/limit-placements` (`pairs.rs:582`) returns the placement indefinitely with no terminal indicator. clients joining 'indexed placements' against 'current LCD book' to compute order status will be wrong for parked-expired orders — they look like 'placed but missing from book' which is also the failure mode of 'node lost the order due to bug'. `LimitPlacementResponse` (`pairs.rs:550-566`) has no status field that could be backfilled without a schema change. - **preventive note** for whoever implements the future claim button: claim must NOT be gated on `isPaused`. cancel button correctly disables under pause (`LimitOrdersPage.tsx:367`), and copy-pasting that gate onto a claim button would re-introduce the recovery-while-paused regression. contract intentionally allows `ClaimExpiredLimitOrder` while paused per `docs/limit-orders.md` and `contract.rs:1183-1247`. ## Recommended scope split contract fix stands. indexer + frontend gaps are separate work — could spawn: - **child A (indexer)**: decode `limit_order_expired_parked`, persist to new `limit_order_expirations` table, optionally also decode the claim/refund event for terminal-state tracking. - **child B (frontend)**: `useExpiredLimitRefund(orderId)` hook + query helper, `claimExpiredLimitOrder` mutation in `pair.ts`, status badge on 'recent placements' panel, claim button on parked rows. don't gate on `isPaused`. happy to file these as separate tickets if you want them tracked independently, or you can scope them as #120 follow-ups. either shape, ready to verify when the work lands.
Brouie commented 2026-05-05 23:55:34 +00:00 (Migrated from gitlab.com)

mentioned in issue #133

mentioned in issue #133
PlasticDigits commented 2026-05-06 08:21:46 +00:00 (Migrated from gitlab.com)

@Brouie

  • Cancel MUST be pausable, this is a REQUIREMENT as cancel allows WITHDRAWING ASSETS. Pause must TOTALLY PAUSE the entire system but ESPECIALLY any action that involves withdrawing assets. PAUSING CANCEL IS A CRITICAL FEATURE and needs to be documented as an invariant, a business requirement, tested, fuzz tested, and proven to hold throughout the repository.
  • Please open an issue for the frontend maker facing recovery path.
  • Please open an issue for indexer to distinguish parked expirty from active orders.
@Brouie - Cancel MUST be pausable, this is a REQUIREMENT as cancel allows WITHDRAWING ASSETS. Pause must TOTALLY PAUSE the entire system but ESPECIALLY any action that involves withdrawing assets. PAUSING CANCEL IS A CRITICAL FEATURE and needs to be documented as an invariant, a business requirement, tested, fuzz tested, and proven to hold throughout the repository. - Please open an issue for the frontend maker facing recovery path. - Please open an issue for indexer to distinguish parked expirty from active orders.
Brouie commented 2026-05-07 05:16:37 +00:00 (Migrated from gitlab.com)

mentioned in issue #141

mentioned in issue #141
Brouie commented 2026-05-07 05:17:31 +00:00 (Migrated from gitlab.com)

mentioned in issue #142

mentioned in issue #142
Brouie commented 2026-05-07 05:39:35 +00:00 (Migrated from gitlab.com)

@PlasticDigits — re-verification of the pause invariant per your directive ("Cancel MUST be pausable... PAUSING CANCEL IS A CRITICAL FEATURE... documented as an invariant, business requirement, tested, fuzz tested, and proven to hold throughout the repository").

TL;DR

cancel pause is correctly gated and tested. but there is a policy contradiction between your #120 directive and existing invariant L6 documented in the repo. surfacing for your call before any code change.

what is correctly in place

cancel pause holds end-to-end. dispatcher gate at pair/src/contract.rs:547, function-level rustdoc at :1743-1747 ("CancelLimitOrder is also blocked"), test pause_blocks_swap_and_place_cancel_refunds_escrow at tests/src/limit_order_tests.rs:1395-1535, audit catalog entry at docs/contracts-security-audit.md:57 (row L6), long-form description at docs/limit-orders.md:60.

pause-gated user-callable entrypoints on pair (4 total): Receive (:507), ProvideLiquidity (:516), CancelLimitOrder (:547), UpdateLimitOrderPrice (:559). cross-contract: router cannot bypass pair pause (every hop lands on pair Receive which is gated); factory has no direct user-callable asset-withdrawing path; hooks only run as part of pause-gated pair swaps; fee-discount holds no assets.

the contradiction

ExecuteMsg::ClaimExpiredLimitOrder (the new entrypoint added by your #120 fix at commit 5c744ee) is not pause-gated:

pair/src/contract.rs:546-552
        ExecuteMsg::CancelLimitOrder { order_id } => {
            assert_not_paused(deps.storage)?;       // ← gated
            execute_cancel_limit_order(deps, env, info, order_id)
        }
        ExecuteMsg::ClaimExpiredLimitOrder { order_id } => {
            execute_claim_expired_limit_order(deps, env, info, order_id)  // ← not gated
        }

and execute_claim_expired_limit_order body at :1183-1247 does construct an outbound Cw20ExecuteMsg::Transfer to the maker — same shape as cancel. mechanically identical asset-withdrawal path.

but the bypass is intentional and currently codified across three layers:

  1. docs/contracts-security-audit.md:57 row L6: "ClaimExpiredLimitOrder is not blocked: makers can recover escrow for rows parked from expiry during a prior walk. Active resting orders cannot cancel until unpause."
  2. docs/limit-orders.md:46: "Allowed while the pair is paused (unlike cancel)."
  3. docs/limit-orders.md:60-61: "ClaimExpiredLimitOrder remains available while paused so makers can recover escrow for orders that were already moved to EXPIRED_LIMIT_CLAIMS when a prior (pre-pause) match walk handled expiry."
  4. test claim_expired_limit_order_allowed_while_pair_paused at tests/src/limit_order_tests.rs:998-1106 explicitly asserts the bypass succeeds while paused.

so this is not code-vs-docs drift. it is an actively encoded policy choice (parked-expiry escrow rescue exempt from pause) that contradicts your #120 comment ("totally pause the entire system, especially withdrawals").

options

A. enforce strict pause everywhere (your #120 comment as written):

  • add assert_not_paused(deps.storage)?; to the dispatcher arm at :551
  • invert the test at limit_order_tests.rs:998 to assert rejection-while-paused
  • rewrite invariant L6 at docs/contracts-security-audit.md:57 to remove the carve-out
  • update docs/limit-orders.md:46, 60-61 to remove the "available while paused" language
  • tradeoff: makers cannot recover legitimately-parked escrow while pair is paused. governance pause becomes a temporary hostage of maker funds (safer but more aggressive).

B. keep the L6 carve-out (current encoded behavior):

  • amend your #120 comment to read "all asset withdrawals except parked-expiry escrow rescue"
  • close the existing gap on test/fuzz coverage (see "remaining gaps" below)
  • tradeoff: governance pause does not freeze maker recovery, which preserves the recovery-after-expiry property the L6 carve-out was designed for.

remaining gaps regardless of A or B

  • no fuzz/proptest of the pause invariant. you explicitly asked for "fuzz tested" — currently zero property-based coverage. needs a follow-up ticket once policy is settled.
  • UpdateLimitOrderPrice pause gate at :559 exists in code but has no test. follow-up: add update_limit_order_price_blocked_while_paused to limit_order_tests.rs.
  • test_paused_withdraw_still_works at lib.rs:6772-6823 is misleadingly named — body asserts withdraw IS blocked while paused. inline comment at :6816-6818 already flags as design question. cosmetic rename when policy lands.
  • no INV-*-PAUSE invariant marker. catalogue uses L1...L8 letter-numbered rows in contracts-security-audit.md. consistent with the repo style but no upgrade to invariant-marker tooling.

next step

before any code change: pick A or B. happy to file the follow-up tickets (fuzz tests, missing UpdateLimitOrderPrice test, INV-* markers) once policy is settled. if A, also happy to ship the dispatcher gate + invariant L6 rewrite as an MR. if B, just the docs alignment + amend the #120 comment.

cc @PlasticDigits

@PlasticDigits — re-verification of the pause invariant per your directive ("Cancel MUST be pausable... PAUSING CANCEL IS A CRITICAL FEATURE... documented as an invariant, business requirement, tested, fuzz tested, and proven to hold throughout the repository"). ## TL;DR cancel pause is correctly gated and tested. but there is a **policy contradiction** between your #120 directive and existing invariant L6 documented in the repo. surfacing for your call before any code change. ## what is correctly in place cancel pause holds end-to-end. dispatcher gate at `pair/src/contract.rs:547`, function-level rustdoc at `:1743-1747` ("CancelLimitOrder is also blocked"), test `pause_blocks_swap_and_place_cancel_refunds_escrow` at `tests/src/limit_order_tests.rs:1395-1535`, audit catalog entry at `docs/contracts-security-audit.md:57` (row L6), long-form description at `docs/limit-orders.md:60`. pause-gated user-callable entrypoints on pair (4 total): `Receive` (:507), `ProvideLiquidity` (:516), `CancelLimitOrder` (:547), `UpdateLimitOrderPrice` (:559). cross-contract: router cannot bypass pair pause (every hop lands on pair `Receive` which is gated); factory has no direct user-callable asset-withdrawing path; hooks only run as part of pause-gated pair swaps; fee-discount holds no assets. ## the contradiction `ExecuteMsg::ClaimExpiredLimitOrder` (the new entrypoint added by your #120 fix at commit `5c744ee`) is **not** pause-gated: ``` pair/src/contract.rs:546-552 ExecuteMsg::CancelLimitOrder { order_id } => { assert_not_paused(deps.storage)?; // ← gated execute_cancel_limit_order(deps, env, info, order_id) } ExecuteMsg::ClaimExpiredLimitOrder { order_id } => { execute_claim_expired_limit_order(deps, env, info, order_id) // ← not gated } ``` and `execute_claim_expired_limit_order` body at `:1183-1247` does construct an outbound `Cw20ExecuteMsg::Transfer` to the maker — same shape as cancel. mechanically identical asset-withdrawal path. **but** the bypass is intentional and currently codified across three layers: 1. `docs/contracts-security-audit.md:57` row L6: "ClaimExpiredLimitOrder is not blocked: makers can recover escrow for rows parked from expiry during a prior walk. Active resting orders cannot cancel until unpause." 2. `docs/limit-orders.md:46`: "Allowed while the pair is paused (unlike cancel)." 3. `docs/limit-orders.md:60-61`: "ClaimExpiredLimitOrder remains available while paused so makers can recover escrow for orders that were already moved to `EXPIRED_LIMIT_CLAIMS` when a prior (pre-pause) match walk handled expiry." 4. test `claim_expired_limit_order_allowed_while_pair_paused` at `tests/src/limit_order_tests.rs:998-1106` explicitly asserts the bypass succeeds while paused. so this is not code-vs-docs drift. it is an actively encoded policy choice (parked-expiry escrow rescue exempt from pause) that contradicts your #120 comment ("totally pause the entire system, especially withdrawals"). ## options **A. enforce strict pause everywhere (your #120 comment as written):** - add `assert_not_paused(deps.storage)?;` to the dispatcher arm at `:551` - invert the test at `limit_order_tests.rs:998` to assert rejection-while-paused - rewrite invariant L6 at `docs/contracts-security-audit.md:57` to remove the carve-out - update `docs/limit-orders.md:46, 60-61` to remove the "available while paused" language - tradeoff: makers cannot recover legitimately-parked escrow while pair is paused. governance pause becomes a temporary hostage of maker funds (safer but more aggressive). **B. keep the L6 carve-out (current encoded behavior):** - amend your #120 comment to read "all asset withdrawals except parked-expiry escrow rescue" - close the existing gap on test/fuzz coverage (see "remaining gaps" below) - tradeoff: governance pause does not freeze maker recovery, which preserves the recovery-after-expiry property the L6 carve-out was designed for. ## remaining gaps regardless of A or B - no fuzz/proptest of the pause invariant. you explicitly asked for "fuzz tested" — currently zero property-based coverage. needs a follow-up ticket once policy is settled. - `UpdateLimitOrderPrice` pause gate at `:559` exists in code but has no test. follow-up: add `update_limit_order_price_blocked_while_paused` to `limit_order_tests.rs`. - `test_paused_withdraw_still_works` at `lib.rs:6772-6823` is misleadingly named — body asserts withdraw IS blocked while paused. inline comment at `:6816-6818` already flags as design question. cosmetic rename when policy lands. - no `INV-*-PAUSE` invariant marker. catalogue uses `L1...L8` letter-numbered rows in `contracts-security-audit.md`. consistent with the repo style but no upgrade to invariant-marker tooling. ## next step before any code change: pick A or B. happy to file the follow-up tickets (fuzz tests, missing UpdateLimitOrderPrice test, INV-* markers) once policy is settled. if A, also happy to ship the dispatcher gate + invariant L6 rewrite as an MR. if B, just the docs alignment + amend the #120 comment. cc @PlasticDigits
Brouie commented 2026-05-07 06:30:41 +00:00 (Migrated from gitlab.com)

mentioned in issue #134

mentioned in issue #134
PlasticDigits commented 2026-05-07 12:06:55 +00:00 (Migrated from gitlab.com)

We need to rewrite the invariant to emphasize "pause everywhere" functionality, including cancel/parked expirty. This is because a bug could emerge in cancelation/expirty draining the system, which is exactly the type of bugs pause is supposed to mitigate. We cannot risk dex asset drainage without pausing. Option A is approved.

We need to rewrite the invariant to emphasize "pause everywhere" functionality, including cancel/parked expirty. This is because a bug could emerge in cancelation/expirty draining the system, which is exactly the type of bugs pause is supposed to mitigate. We cannot risk dex asset drainage without pausing. Option A is approved.
PlasticDigits commented 2026-05-11 09:09:22 +00:00 (Migrated from gitlab.com)

mentioned in commit b03152d0a4

mentioned in commit b03152d0a4e3fc5dac9e75cf54c86e16a9042d5e
PlasticDigits commented 2026-05-11 09:09:40 +00:00 (Migrated from gitlab.com)

Implemented Option A from the thread (2026-05-07): ClaimExpiredLimitOrder is now assert_not_paused-gated in the pair execute dispatcher, same as CancelLimitOrder, so emergency pause freezes all maker CW20 withdrawals from pair custody (including parked-expiry refunds).

Code

  • smartcontracts/contracts/pair/src/contract.rs — pause check before execute_claim_expired_limit_order.
  • smartcontracts/tests/src/limit_order_tests.rs — renamed/reworked test: claim_expired_limit_order_blocked_while_pair_paused_then_succeeds_after_unpause (expects Paused while paused, refund after unpause).

Docs / invariants

  • docs/contracts-security-audit.md L6 + residual risks; docs/limit-orders.md (pause + dApp bullets); docs/indexer-invariants.md (integrator note); docs/README.md agent crosslink.
  • dex-common ExecuteMsg::ClaimExpiredLimitOrder / IsPaused rustdoc; pair lib.rs module notes.

dApp + agent skills

  • LimitOrderMyPlacementsPanel takes isPairPaused; Claim refund disabled with copy Unavailable (pair paused) when paused.
  • skills/AGENTS_FRONTEND_LIMIT_PARKED_EXPIRED.md, AGENTS_LOCALNET_TRADING_SWARM.md, AGENTS_TERRACLASSIC_GAS.md updated for the new policy.

Merged to main: b03152d


Verification checklist (for @brouie)

  • cargo test -p cl8y-dex-tests claim_expired_limit_order_blocked_while_pair_paused_then_succeeds_after_unpause
  • cargo test -p cl8y-dex-tests expired_bid_parked_on_hybrid_walk_claim_refunds_maker and pause_blocks_swap_and_place_cancel_refunds_escrow
  • cargo test -p cl8y-dex-pair park_expired_bid_unlinks_and_records_claim_without_pending_delta
  • cargo test --workspace under smartcontracts/ (full suite)
  • cd frontend-dapp && npm test -- --run
  • Manual: paused pair → parked-expired row shows disabled claim; unpause → claim succeeds; balances match pre-change expectations

Issue left open for your sign-off on the revised L6 / pause-everywhere policy.

Implemented **Option A** from the thread (2026-05-07): **`ClaimExpiredLimitOrder`** is now **`assert_not_paused`**-gated in the pair `execute` dispatcher, same as **`CancelLimitOrder`**, so emergency pause freezes **all** maker CW20 withdrawals from pair custody (including parked-expiry refunds). **Code** - `smartcontracts/contracts/pair/src/contract.rs` — pause check before `execute_claim_expired_limit_order`. - `smartcontracts/tests/src/limit_order_tests.rs` — renamed/reworked test: `claim_expired_limit_order_blocked_while_pair_paused_then_succeeds_after_unpause` (expects `Paused` while paused, refund after unpause). **Docs / invariants** - `docs/contracts-security-audit.md` **L6** + residual risks; `docs/limit-orders.md` (pause + dApp bullets); `docs/indexer-invariants.md` (integrator note); `docs/README.md` agent crosslink. - `dex-common` `ExecuteMsg::ClaimExpiredLimitOrder` / `IsPaused` rustdoc; pair `lib.rs` module notes. **dApp + agent skills** - `LimitOrderMyPlacementsPanel` takes **`isPairPaused`**; **Claim refund** disabled with copy **Unavailable (pair paused)** when paused. - `skills/AGENTS_FRONTEND_LIMIT_PARKED_EXPIRED.md`, `AGENTS_LOCALNET_TRADING_SWARM.md`, `AGENTS_TERRACLASSIC_GAS.md` updated for the new policy. **Merged to `main`:** `b03152d` --- ### Verification checklist (for @brouie) - [ ] `cargo test -p cl8y-dex-tests claim_expired_limit_order_blocked_while_pair_paused_then_succeeds_after_unpause` - [ ] `cargo test -p cl8y-dex-tests expired_bid_parked_on_hybrid_walk_claim_refunds_maker` and `pause_blocks_swap_and_place_cancel_refunds_escrow` - [ ] `cargo test -p cl8y-dex-pair park_expired_bid_unlinks_and_records_claim_without_pending_delta` - [ ] `cargo test --workspace` under `smartcontracts/` (full suite) - [ ] `cd frontend-dapp && npm test -- --run` - [ ] Manual: paused pair → parked-expired row shows disabled claim; unpause → claim succeeds; balances match pre-change expectations Issue left **open** for your sign-off on the revised L6 / pause-everywhere policy.
Brouie commented 2026-05-13 03:13:33 +00:00 (Migrated from gitlab.com)

walked the pause-gate fix at HEAD 94adb5f.

Source

  • smartcontracts/contracts/pair/src/contract.rs:551 — assert_not_paused(deps.storage)? added at the dispatcher before execute_claim_expired_limit_order
  • smartcontracts/contracts/pair/src/lib.rs + smartcontracts/packages/dex-common/src/pair.rs — doc strings flipped from "Allowed while the pair is paused" to "Blocked while the pair is paused" across ExecuteMsg, QueryMsg, IsPaused query doc
  • inner handler (~contract.rs:1196) retains the owner check — non-maker claims stay blocked even when unpaused, defense in depth

Tests at HEAD 94adb5f

  • cl8y-dex-pair: 12/12 PASS
  • cl8y-dex-tests: 302/302 PASS
  • 314/314 total, no regressions from b03152d → 94adb5f
  • targeted: limit_order_tests::claim_expired_limit_order_blocked_while_pair_paused_then_succeeds_after_unpause — 1/1 PASS (covers place → expire → park → pause → claim-rejects → unpause → claim-succeeds → refund in one harness)

Live walk

  • deferred. fix is contract-side with no UI surface. the integration test above exercises the full pause→reject→unpause→succeed sequence end-to-end on the contract harness.
  • a separate LocalTerra walk would require a pause/unpause governance call which the current QA setup doesn't have a script for — would add no signal beyond what the test already proves.
  • happy to revisit if you want a live walk with a pause harness wired in.

Policy note

  • this fix REVERSES the original #120 fix from 5/03 — claim during pause is now blocked, was previously allowed. doc strings updated coherently. verified the NEW pause-gate policy, not the original-fix policy.

Integration with #141

  • #141 maker-recovery panel pipes isPairPaused into the Claim button disabled state, so the frontend gate matches the contract gate. #141 verification note coming separately.

ready for close on your side.

walked the pause-gate fix at HEAD 94adb5f. **Source** - smartcontracts/contracts/pair/src/contract.rs:551 — `assert_not_paused(deps.storage)?` added at the dispatcher before `execute_claim_expired_limit_order` - smartcontracts/contracts/pair/src/lib.rs + smartcontracts/packages/dex-common/src/pair.rs — doc strings flipped from "Allowed while the pair is paused" to "Blocked while the pair is paused" across ExecuteMsg, QueryMsg, IsPaused query doc - inner handler (~contract.rs:1196) retains the owner check — non-maker claims stay blocked even when unpaused, defense in depth **Tests at HEAD 94adb5f** - cl8y-dex-pair: 12/12 PASS - cl8y-dex-tests: 302/302 PASS - 314/314 total, no regressions from b03152d → 94adb5f - targeted: `limit_order_tests::claim_expired_limit_order_blocked_while_pair_paused_then_succeeds_after_unpause` — 1/1 PASS (covers place → expire → park → pause → claim-rejects → unpause → claim-succeeds → refund in one harness) **Live walk** - deferred. fix is contract-side with no UI surface. the integration test above exercises the full pause→reject→unpause→succeed sequence end-to-end on the contract harness. - a separate LocalTerra walk would require a pause/unpause governance call which the current QA setup doesn't have a script for — would add no signal beyond what the test already proves. - happy to revisit if you want a live walk with a pause harness wired in. **Policy note** - this fix REVERSES the original #120 fix from 5/03 — claim during pause is now blocked, was previously allowed. doc strings updated coherently. verified the NEW pause-gate policy, not the original-fix policy. **Integration with #141** - #141 maker-recovery panel pipes `isPairPaused` into the Claim button disabled state, so the frontend gate matches the contract gate. #141 verification note coming separately. ready for close on your side.
Brouie commented 2026-05-13 05:28:04 +00:00 (Migrated from gitlab.com)

complete checklist walk at HEAD 94adb5f.

Source (re-confirmed)

  • smartcontracts/contracts/pair/src/contract.rs:551 — assert_not_paused(deps.storage)? at dispatcher before execute_claim_expired_limit_order
  • doc strings flipped across pair/lib.rs + dex-common/pair.rs ("Allowed while paused" → "Blocked while paused")
  • inner handler retains owner check, defense in depth

Checklist items

(1) cargo test -p cl8y-dex-tests claim_expired_limit_order_blocked_while_pair_paused_then_succeeds_after_unpause — 1/1 PASS

(2a) cargo test -p cl8y-dex-tests expired_bid_parked_on_hybrid_walk_claim_refunds_maker — 1/1 PASS

(2b) cargo test -p cl8y-dex-tests pause_blocks_swap_and_place_cancel_refunds_escrow — 1/1 PASS

(3) cargo test -p cl8y-dex-pair park_expired_bid_unlinks_and_records_claim_without_pending_delta — 1/1 PASS (orderbook::tests::park_expired_bid_unlinks_and_records_claim_without_pending_delta)

(4) cargo test --workspace under smartcontracts/ — 320/320 PASS across all crates, 0 fail / 0 ignored

(5) cd frontend-dapp && npm test -- --run — 441/441 PASS across 62 test files (+7 from a separate fix on #156)

(6) Manual paused-pair walk on LocalTerra — partial, detail below.

Item 6 — what got done

While walking item 6 I discovered the QA server's deployed contracts were stale: make start-qa reuses the LocalTerra docker volume by default, so even though the wasm artifacts on disk were freshly built at 94adb5f, the chain was still running pre-b03152d contracts (queries like is_paused and expired_limit_refund returned "unknown variant"). Stack was torn down and redeployed against a clean volume (docker volume rm cl8y-dex-terraclassic_localterra-data cl8y-dex-terraclassic_postgres-data + make start-qa). Fresh stack now reports {"data":{"paused":false}} for the is_paused query — pause-gate fix is deployed.

Contract-level end-to-end walk against the fresh stack:

  • placed bid order_id=1, price=1.0, expires_at=now+90s, 10000 CORAL escrow on EMBER/CORAL (terra146y...c9mjav) — tx 32078F76B8A93652BE6945005681677C396E400F157235AACA8E599B251A9FA1
  • waited past expiry
  • triggered hybrid walk by sending 5000 EMBER with hybrid: { pool_input: "0", book_input: "5000", max_maker_fills: 8 } — tx EBA6DECB081245B5F7823A84D404C41388DC05710A74220D78C1E573D504DEBB
  • order moved out of active book: {"limit_order":{"order_id":1}} → "not found"
  • order present in parked-expiry refund map: {"expired_limit_refund":{"order_id":1}} returns {"order_id":1,"owner":"terra1x46rqay4d3cssq8gxxvqz8xt6nwlz4td20k38v","side":"bid","remaining":"9910","expires_at":1778646273} (10000 - 90 fee = 9910)

That confirms the place → expire → park sequence works on-chain at HEAD 94adb5f. The pause-gate fix sits on top of this same path (claim during pause now rejects per the named integration test).

Item 6 — what didn't get done

The dapp UI walk (paused-pair → claim disabled → unpause → claim succeeds, with balance check) hit laptop-side networking flakiness — Vite/SSH tunnel intermittently dropping the LCD endpoint, dapp showed "No pairs on factory" despite the LCD itself responding via curl. That's environment-side, not fix-side. The UI gate piping isPairPaused into Claim disabled state is source-verified at #141 separately.

Integration with #141

#141 frontend wires isPairPaused from the is_paused query (now confirmed working on the deployed contract) into LimitOrderMyPlacementsPanel's Claim button disabled prop with copy "Unavailable (pair paused)". Separate verification note coming.

Op note for dev

Worth scripting a fresh-volumes toggle into make start-qa (or a separate make reset-qa). Today's stale-contract issue meant earlier live walks on this server were against pre-b03152d code without my catching it — could potentially affect any contract-side verification on this stack. Happy to write a PR for it.

ready for close on your side modulo the UI-walk gap, which I'll revisit once the laptop tunnel stabilizes.

/cc @PlasticDigits

complete checklist walk at HEAD 94adb5f. **Source (re-confirmed)** - smartcontracts/contracts/pair/src/contract.rs:551 — `assert_not_paused(deps.storage)?` at dispatcher before `execute_claim_expired_limit_order` - doc strings flipped across pair/lib.rs + dex-common/pair.rs ("Allowed while paused" → "Blocked while paused") - inner handler retains owner check, defense in depth **Checklist items** (1) `cargo test -p cl8y-dex-tests claim_expired_limit_order_blocked_while_pair_paused_then_succeeds_after_unpause` — **1/1 PASS** (2a) `cargo test -p cl8y-dex-tests expired_bid_parked_on_hybrid_walk_claim_refunds_maker` — **1/1 PASS** (2b) `cargo test -p cl8y-dex-tests pause_blocks_swap_and_place_cancel_refunds_escrow` — **1/1 PASS** (3) `cargo test -p cl8y-dex-pair park_expired_bid_unlinks_and_records_claim_without_pending_delta` — **1/1 PASS** (`orderbook::tests::park_expired_bid_unlinks_and_records_claim_without_pending_delta`) (4) `cargo test --workspace` under smartcontracts/ — **320/320 PASS** across all crates, 0 fail / 0 ignored (5) `cd frontend-dapp && npm test -- --run` — **441/441 PASS across 62 test files** (+7 from a separate fix on #156) (6) Manual paused-pair walk on LocalTerra — **partial**, detail below. **Item 6 — what got done** While walking item 6 I discovered the QA server's deployed contracts were stale: `make start-qa` reuses the LocalTerra docker volume by default, so even though the wasm artifacts on disk were freshly built at 94adb5f, the chain was still running pre-b03152d contracts (queries like `is_paused` and `expired_limit_refund` returned "unknown variant"). Stack was torn down and redeployed against a clean volume (`docker volume rm cl8y-dex-terraclassic_localterra-data cl8y-dex-terraclassic_postgres-data` + `make start-qa`). Fresh stack now reports `{"data":{"paused":false}}` for the `is_paused` query — pause-gate fix is deployed. Contract-level end-to-end walk against the fresh stack: - placed bid order_id=1, price=1.0, expires_at=now+90s, 10000 CORAL escrow on EMBER/CORAL (`terra146y...c9mjav`) — tx `32078F76B8A93652BE6945005681677C396E400F157235AACA8E599B251A9FA1` - waited past expiry - triggered hybrid walk by sending 5000 EMBER with `hybrid: { pool_input: "0", book_input: "5000", max_maker_fills: 8 }` — tx `EBA6DECB081245B5F7823A84D404C41388DC05710A74220D78C1E573D504DEBB` - order moved out of active book: `{"limit_order":{"order_id":1}}` → "not found" - order present in parked-expiry refund map: `{"expired_limit_refund":{"order_id":1}}` returns `{"order_id":1,"owner":"terra1x46rqay4d3cssq8gxxvqz8xt6nwlz4td20k38v","side":"bid","remaining":"9910","expires_at":1778646273}` (10000 - 90 fee = 9910) That confirms the place → expire → park sequence works on-chain at HEAD 94adb5f. The pause-gate fix sits on top of this same path (claim during pause now rejects per the named integration test). **Item 6 — what didn't get done** The dapp UI walk (paused-pair → claim disabled → unpause → claim succeeds, with balance check) hit laptop-side networking flakiness — Vite/SSH tunnel intermittently dropping the LCD endpoint, dapp showed "No pairs on factory" despite the LCD itself responding via curl. That's environment-side, not fix-side. The UI gate piping `isPairPaused` into Claim disabled state is source-verified at #141 separately. **Integration with #141** #141 frontend wires `isPairPaused` from the `is_paused` query (now confirmed working on the deployed contract) into LimitOrderMyPlacementsPanel's Claim button disabled prop with copy "Unavailable (pair paused)". Separate verification note coming. **Op note for dev** Worth scripting a fresh-volumes toggle into `make start-qa` (or a separate `make reset-qa`). Today's stale-contract issue meant earlier live walks on this server were against pre-b03152d code without my catching it — could potentially affect any contract-side verification on this stack. Happy to write a PR for it. ready for close on your side modulo the UI-walk gap, which I'll revisit once the laptop tunnel stabilizes. /cc @PlasticDigits
PlasticDigits commented 2026-05-13 06:31:41 +00:00 (Migrated from gitlab.com)

@Brouie Please open a seperate issue for fresh volumes toggle and stale contracts

@Brouie Please open a seperate issue for fresh volumes toggle and stale contracts
Brouie commented 2026-05-13 06:55:18 +00:00 (Migrated from gitlab.com)

mentioned in issue #147

mentioned in issue #147
PlasticDigits commented 2026-05-27 02:38:22 +00:00 (Migrated from gitlab.com)

mentioned in issue #202

mentioned in issue #202
PlasticDigits commented 2026-05-27 02:38:24 +00:00 (Migrated from gitlab.com)

mentioned in issue #203

mentioned in issue #203
PlasticDigits commented 2026-05-27 02:38:28 +00:00 (Migrated from gitlab.com)

Closing #120 — contract fix, indexer/frontend follow-ups (#141+), and Option A pause-everywhere policy are verified at HEAD 94adb5f.

Follow-up QA tooling split per @PlasticDigits request:

  • Fresh volumes toggle: #202 — QA_FRESH_VOLUMES / make reset-qa so start-qa can wipe LocalTerra + Postgres volumes on demand
  • Stale deployed contracts: #203 — detect/warn when reused volumes leave old pair code at deployed addresses despite fresh wasm builds

/cc @PlasticDigits @Brouie

Closing #120 — contract fix, indexer/frontend follow-ups (#141+), and Option A pause-everywhere policy are verified at HEAD `94adb5f`. Follow-up QA tooling split per @PlasticDigits request: - **Fresh volumes toggle:** #202 — `QA_FRESH_VOLUMES` / `make reset-qa` so `start-qa` can wipe LocalTerra + Postgres volumes on demand - **Stale deployed contracts:** #203 — detect/warn when reused volumes leave old pair code at deployed addresses despite fresh wasm builds /cc @PlasticDigits @Brouie
PlasticDigits (Migrated from gitlab.com) closed this issue 2026-05-27 02:38:31 +00:00
PlasticDigits commented 2026-05-31 13:07:56 +00:00 (Migrated from gitlab.com)

mentioned in issue #253

mentioned in issue #253
PlasticDigits commented 2026-05-31 13:52:01 +00:00 (Migrated from gitlab.com)

mentioned in issue #259

mentioned in issue #259
PlasticDigits commented 2026-06-01 02:30:56 +00:00 (Migrated from gitlab.com)

marked as related to #263

marked as related to #263
PlasticDigits commented 2026-06-07 12:14:17 +00:00 (Migrated from gitlab.com)

mentioned in issue #337

mentioned in issue #337
PlasticDigits commented 2026-06-08 08:14:17 +00:00 (Migrated from gitlab.com)

mentioned in issue #339

mentioned in issue #339
PlasticDigits commented 2026-06-25 14:12:57 +00:00 (Migrated from gitlab.com)

mentioned in issue #419

mentioned in issue #419
totdking commented 2026-06-30 17:52:12 +00:00 (Migrated from gitlab.com)

mentioned in issue #457

mentioned in issue #457
PlasticDigits commented 2026-08-05 00:36:34 +00:00 (Migrated from gitlab.com)

mentioned in issue #504

mentioned in issue #504
PlasticDigits commented 2026-08-16 07:14:04 +00:00 (Migrated from gitlab.com)

mentioned in issue #530

mentioned in issue #530
PlasticDigits commented 2026-08-17 03:45:50 +00:00 (Migrated from gitlab.com)

mentioned in issue #542

mentioned in issue #542
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-30 10:20:11 +00:00 (Migrated from gitlab.com)

mentioned in issue #710

mentioned in issue #710
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#120
No description provided.