[Security/Critical] Investigate: expired limit orders in match_* do not refund makers; funds may be lost or swept #120
Labels
No labels
agent:fix_bugfix
agent:fix_conflicts
agent:fix_security
agent:gap_analysis
agent:implement
agent:implement
agent:implement
agent:open_issues
agent:ready
agent:research
agent:security_audit
agent:verify
architecture
backend
blocker:hybrid
blocker:launch
blocker:limit-orders
blocker:v2
block:log_only
block:security
bug
ci
contracts
correctness
deploy
dev
devops
docs
documentation
duplicate
e2e
enhancement
epic
feature
frontend
functional-completion
gas
good first issue
governance
help wanted
high-risk
hooks
hybrid
indexer
infra
infrastructure
integrators
invalid
launch-blocker
limit-orders
localnet
localterra
low priority
missing-implementation
needs-design
ops
performance
priority
high
priority
medium
product
qa
QA
question
ready
ready
research
scripts
security
security-hardening
smartcontracts
tech-debt
testing
ux
UX
v2
verification
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
code/cl8y-dex-terraclassic#120
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
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 withexpires_at, and define a fix (refund parity with cancellation path).Reported severity
Critical
Location
smartcontracts/contracts/pair/src/orderbook.rsmatch_bids: reportedly ~563–568match_asks: reportedly ~739–744Reference (reported behaviour, paraphrased):
During the walk:
escrow_sub_pending_token0/_token1reducesPENDING_ESCROWbyorder.remainingunlink_orderremoves the order from storageTokens would remain in the pair contract CW20 balance;
execute_sweep’s excess (actual_balance - reserve - pending_escrow) could then grow byorder.remaining, making funds sweepable (e.g. by governance recipient) unless another recovery path exists.Contrast:
execute_cancel_limit_order(~1169 per report) correctly performsCosmosMsg::Wasm(Cw20ExecuteMsg::Transfer { recipient: removed.owner, amount: removed.remaining, ... })before reducing escrow.Reported attack / failure scenario
expires_at = T+1hImpact (per reporter)
Limit orders that use
expires_atand expire without explicit cancel may lose maker escrow when the next walk processes that expiry branch; recovery viaCancelLimitOrderfails ifORDERSno longer contains the id.Investigation checklist
Raw source snippet (verbatim from reporter)
Expired limit orders encountered during a match walk call
escrow_sub_pending_*andunlink_orderbut do not CW20-transfer toowner;execute_cancel_limit_orderdoes transfer. Reporter claims permanent loss / sweep-by-excess risk forexpires_atorders when a subsequent taker triggers the expiry branch.mentioned in commit
5c744eef9cFix implemented and pushed to
main@brouie Please verify the expired limit-order escrow handling.
What changed
match_bids/match_asks): Whenexpires_athas passed, the order is unlinked from the book, stored inEXPIRED_LIMIT_CLAIMS, andPENDING_ESCROW_*is not reduced in that taker transaction. A wasm eventlimit_order_expired_parkedis 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 inORDERS; after a park there is no row, so cancel errors — no double refund.ExpiredLimitRefund { order_id }: Returns the claimable row or null.ClaimExpiredLimitOrderis not blocked when the pair is paused (unlike cancel), so parked refunds remain recoverable.Docs / agents
docs/contracts-security-audit.mdL1, 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
cargo test -p cl8y-dex-pair -p cl8y-dex-tests(includesexpired_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).CancelLimitOrderon a parked id fails; secondClaimExpiredLimitOrderfails with no claim row.ExpiredLimitRefund+limit_order_expired_parkedfor maker recovery.Leaving issue open for your 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 pathlimit_order_tests::claim_expired_limit_order_allowed_while_pair_paused— pause doesn't block recoverycargo test --workspaceoverall: 308 tests pass, 0 failures.double-spend gate (Item 3) covered by the orderbook tests + your design note that
CancelLimitOrderonly 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_parkedfor maker recovery) is downstream of the contract fix and lives in the indexer/UX subsystems, not gated on contract sign-off.@PlasticDigits — flagging the verification above for your eyes since this is the security/critical one.
@Brouie Please recheck item 4 in indexer and frontend packages, reiew needs to cover these as well as the contracts
@PlasticDigits — re-checked item 4 across indexer + frontend per your ask. read the diff between baseline and HEAD
9c32a51end-to-end acrossindexer/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.sol!/struct/string match forlimit_order_expired_parkedanywhere underindexer/src/indexer/src/indexer/parser.rsstrict-match action stringslimit_order_fill(L427),place_limit_order(L538),cancel_limit_order(L583). new park event silently dropped at ingest.expired_parkedorrefund_claimedtable — onlylimit_order_placementsandlimit_order_cancellationsexist (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_claimonly emitslimit_order_expired_parked(verified atsmartcontracts/contracts/pair/src/orderbook.rs:617-622and: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.tsexposes onlyplaceLimitOrder(L95) andcancelLimitOrder(L125). noclaimExpiredLimitOrderhelper, noExpiredLimitRefundquery helper.frontend-dapp/src/services/terraclassic/transactions.tsgas-limit branches only forplace_limit_orderandcancel_limit_order(L75-96).frontend-dapp/src/pages/LimitOrdersPage.tsxandcomponents/trade/TradeOrderTicket.tsxare 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) readsgetPairLimitBookPagewhich walks the on-chain FIFO vialimit_book_lcd.rs. parked orders are correctly absent from the LCD book becausepark_expired_limit_order_for_claimcallsunlink_orderfirst. that part isn't misleading.but the 'Your recent placements (indexer)' panel (L383-400) renders
myPlacementsstraight fromlimit_order_placementswith no status, no terminal-state column, no claimable-refund signal: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:
contract correctly preserves the escrow invariant via
EXPIRED_LIMIT_CLAIMSand exposesClaimExpiredLimitOrder+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 fourexpires_athits inapi_limit_book_lcd_mock.rsandapi_limit_book_deep.rsare pre-existing fixture fields, not new park-flow tests). no matches infrontend-dapp/e2e/limit-orders.spec.ts,limit-orders-tx.spec.ts, orfrontend-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
LimitOrdersPage.tsx+TradeOrderTicket.tsxhandle place + cancel only. no helper / hook / mutation / button / status surface forclaim_expired_limit_orderorexpired_limit_refund./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.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 allowsClaimExpiredLimitOrderwhile paused perdocs/limit-orders.mdandcontract.rs:1183-1247.Recommended scope split
contract fix stands. indexer + frontend gaps are separate work — could spawn:
limit_order_expired_parked, persist to newlimit_order_expirationstable, optionally also decode the claim/refund event for terminal-state tracking.useExpiredLimitRefund(orderId)hook + query helper,claimExpiredLimitOrdermutation inpair.ts, status badge on 'recent placements' panel, claim button on parked rows. don't gate onisPaused.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.
mentioned in issue #133
@Brouie
mentioned in issue #141
mentioned in issue #142
@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"), testpause_blocks_swap_and_place_cancel_refunds_escrowattests/src/limit_order_tests.rs:1395-1535, audit catalog entry atdocs/contracts-security-audit.md:57(row L6), long-form description atdocs/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 pairReceivewhich 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 commit5c744ee) is not pause-gated:and
execute_claim_expired_limit_orderbody at:1183-1247does construct an outboundCw20ExecuteMsg::Transferto the maker — same shape as cancel. mechanically identical asset-withdrawal path.but the bypass is intentional and currently codified across three layers:
docs/contracts-security-audit.md:57row 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."docs/limit-orders.md:46: "Allowed while the pair is paused (unlike cancel)."docs/limit-orders.md:60-61: "ClaimExpiredLimitOrder remains available while paused so makers can recover escrow for orders that were already moved toEXPIRED_LIMIT_CLAIMSwhen a prior (pre-pause) match walk handled expiry."claim_expired_limit_order_allowed_while_pair_pausedattests/src/limit_order_tests.rs:998-1106explicitly 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):
assert_not_paused(deps.storage)?;to the dispatcher arm at:551limit_order_tests.rs:998to assert rejection-while-pauseddocs/contracts-security-audit.md:57to remove the carve-outdocs/limit-orders.md:46, 60-61to remove the "available while paused" languageB. keep the L6 carve-out (current encoded behavior):
remaining gaps regardless of A or B
UpdateLimitOrderPricepause gate at:559exists in code but has no test. follow-up: addupdate_limit_order_price_blocked_while_pausedtolimit_order_tests.rs.test_paused_withdraw_still_worksatlib.rs:6772-6823is misleadingly named — body asserts withdraw IS blocked while paused. inline comment at:6816-6818already flags as design question. cosmetic rename when policy lands.INV-*-PAUSEinvariant marker. catalogue usesL1...L8letter-numbered rows incontracts-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
mentioned in issue #134
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.
mentioned in commit
b03152d0a4Implemented Option A from the thread (2026-05-07):
ClaimExpiredLimitOrderis nowassert_not_paused-gated in the pairexecutedispatcher, same asCancelLimitOrder, so emergency pause freezes all maker CW20 withdrawals from pair custody (including parked-expiry refunds).Code
smartcontracts/contracts/pair/src/contract.rs— pause check beforeexecute_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(expectsPausedwhile paused, refund after unpause).Docs / invariants
docs/contracts-security-audit.mdL6 + residual risks;docs/limit-orders.md(pause + dApp bullets);docs/indexer-invariants.md(integrator note);docs/README.mdagent crosslink.dex-commonExecuteMsg::ClaimExpiredLimitOrder/IsPausedrustdoc; pairlib.rsmodule notes.dApp + agent skills
LimitOrderMyPlacementsPaneltakesisPairPaused; 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.mdupdated for the new policy.Merged to
main:b03152dVerification checklist (for @brouie)
cargo test -p cl8y-dex-tests claim_expired_limit_order_blocked_while_pair_paused_then_succeeds_after_unpausecargo test -p cl8y-dex-tests expired_bid_parked_on_hybrid_walk_claim_refunds_makerandpause_blocks_swap_and_place_cancel_refunds_escrowcargo test -p cl8y-dex-pair park_expired_bid_unlinks_and_records_claim_without_pending_deltacargo test --workspaceundersmartcontracts/(full suite)cd frontend-dapp && npm test -- --runIssue left open for your sign-off on the revised L6 / pause-everywhere policy.
walked the pause-gate fix at HEAD
94adb5f.Source
assert_not_paused(deps.storage)?added at the dispatcher beforeexecute_claim_expired_limit_orderTests at HEAD
94adb5fb03152d→94adb5flimit_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
Policy note
Integration with #141
isPairPausedinto the Claim button disabled state, so the frontend gate matches the contract gate. #141 verification note coming separately.ready for close on your side.
complete checklist walk at HEAD
94adb5f.Source (re-confirmed)
assert_not_paused(deps.storage)?at dispatcher beforeexecute_claim_expired_limit_orderChecklist 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 --workspaceunder 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-qareuses the LocalTerra docker volume by default, so even though the wasm artifacts on disk were freshly built at94adb5f, the chain was still running pre-b03152d contracts (queries likeis_pausedandexpired_limit_refundreturned "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 theis_pausedquery — pause-gate fix is deployed.Contract-level end-to-end walk against the fresh stack:
terra146y...c9mjav) — tx32078F76B8A93652BE6945005681677C396E400F157235AACA8E599B251A9FA1hybrid: { pool_input: "0", book_input: "5000", max_maker_fills: 8 }— txEBA6DECB081245B5F7823A84D404C41388DC05710A74220D78C1E573D504DEBB{"limit_order":{"order_id":1}}→ "not found"{"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
isPairPausedinto Claim disabled state is source-verified at #141 separately.Integration with #141
#141 frontend wires
isPairPausedfrom theis_pausedquery (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 separatemake 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
@Brouie Please open a seperate issue for fresh volumes toggle and stale contracts
mentioned in issue #147
mentioned in issue #202
mentioned in issue #203
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:
QA_FRESH_VOLUMES/make reset-qasostart-qacan wipe LocalTerra + Postgres volumes on demand/cc @PlasticDigits @Brouie
mentioned in issue #253
mentioned in issue #259
marked as related to #263
mentioned in issue #337
mentioned in issue #339
mentioned in issue #419
mentioned in issue #457
mentioned in issue #504
mentioned in issue #530
mentioned in issue #542
mentioned in issue #589
mentioned in issue #710