match_asks credits maker zero token1 when fill_t0 * price floors to 0 (ask priced below 1) #470
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#470
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?
Came out of the security sweep on the pair contract's orderbook matching (fits under the #381 hardening umbrella — value-flow / invariant correctness). This is low severity but it's a real limit-price violation: an ask maker can hand over token0 and get zero token1 back.
What / where
match_asksinsmartcontracts/contracts/pair/src/orderbook.rs. The cost of a fill is computed at line 1608:costis what the maker gets paid (token1) and what the taker's budget gets debited. There's nocost > 0guard anywhere before it's used, so a fill wherecost == 0sails straight through.Why it happens
checked_mul_floorrounds toward zero. When an ask is priced below 1 (order.price= token1-per-token0 < 1, which is realistic when the two tokens have mismatched decimals — both denoms are allowed up to 18), a smallfill_t0makesfill_t0 * priceland in the open interval (0,1) and floor to 0.The one adjustment loop right after only handles the too-expensive direction:
It shrinks
fill_t0when cost exceeds the taker budget. It never touches the case where cost floored to 0, andfill_t0itself is still non-zero, so the earlierfill_t0.is_zero()continues (1603, 1624) don't catch it either.With
cost == 0the loop then does, per matched maker:cost= 0 token1 (line 1639)fill_t0token0 (line 1641, batched intotoken0_escrow_sub_total)order.remainingreduced byfill_t0(line 1654)net_to_taker≈fill_t0token0 (line 1656)token1_left -= cost→token1_leftunchanged (line 1655), so the taker's token1 budget isn't even consumedSo the maker gives away up to
fill_t0token0 and receives nothing, and the taker gets that token0 essentially for free.How to hit it
max_fill_token0_from_budget(1588-1600) resolves to afill_t0wherefill_t0 * 0.4 < 1— i.e.fill_t0= 1 or 2 base units.fill_t0is non-zero,cost = floor(fill_t0 * 0.4) = 0. The fill executes: maker escrow drops byfill_t0, maker gets 0 token1, taker walks with the token0.It's dust per fill, but it's repeatable and it's a straight-up broken price guarantee — the maker's limit price says "I want 0.4 token1 per token0" and the book honors it at 0.
Fix direction
Guard
cost > 0before committing the fill. Cleanest options:cost(and after the too-expensive loop), ifcost.is_zero()thencontinueto the next order instead of filling — the taker's remaining budget just can't afford a price-honoring fill against this maker.fill_t0such thatfill_t0 * pricecan't floor to zero (round the fill size up to the smallest quantity that yieldscost >= 1, capped byorder.remainingand budget).Rounding the cost up instead of the fill would over-charge the taker vs their stated budget, so I'd lean toward the skip-when-zero path — it keeps both the maker's price and the taker's budget honest. Same pattern should be checked on the bid side of the match (
match_bids) for the symmetric floor-to-zero case.Investigate the Fix with cost.is_zero continue solution and make sure it doesnt create any unexpected behavior or liquidity drains. Also check bid side
mentioned in commit
93a8b20096mentioned in merge request !1004
mentioned in commit
ff18265e77Verification complete — PASS
Verified GitLab #470 on
main(no repo changes; no MR).Acceptance criteria
floor(fill_t0 × price) = 0whilefill_t0 > 0match_asksguardscost.is_zero()after the too-expensive shrink loop (orderbook.rs~1638). Integration testmatch_asks_skips_zero_cost_fill_sub_unity_price: price=0.4, swap_in=1 → orderremainingunchanged, maker token1 balance unchanged,HybridSimulationreturn=0.match_bidssame guard (~1467). Integration testmatch_bids_skips_zero_cost_fill_sub_unity_price: price=0.4, swap_in=1 → bidremainingunchanged, maker token0 balance unchanged.simulate_match_*)simulate_match_bids/simulate_match_asksbothcontinueoncost.is_zero()(~1783, ~1892). Ask integration test assertssim.return_amount.is_zero().db_orderbook_sim)simulate_match_bids/simulate_match_asksskip whencost == 0(~316, ~381).cargo test --lib db_orderbook_sim— 8/8 pass.cur = order.nextwithout debiting escrow or crediting zero payout; taker budget (token1_left/token0_left) unchanged on skip — matches issue's recommendedcontinuefix (no cost round-up).docs/contracts-security-audit.md; product note indocs/limit-orders.md§ Zero-cost fill skip; agent playbookskills/AGENTS_BOOK_MATCH_HINT_SECURITY.md§ L18 with test commands.Commands run
Follow-ups
mentioned in merge request !1010