Hybrid book leg has no slippage floor in the no-belief-price path #273
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#273
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?
Severity: Medium
Reachability: Any taker who runs a hybrid swap without setting
belief_price(and, via the router, withoutminimum_receive).Affected:
assert_max_spread/check_max_spreadon the pair, hybrid book leg.Root cause: in the no-
belief_pricebranch the spread metric numerator is the pool spread only; the book leg's execution quality isn't bounded by any per-swap floor, and the pair has nominimum_receiveof its own.Summary
For a hybrid swap, slippage protection runs through
check_max_spread. The belief-price branch is fine — it compares expected vsbook_net + pool_net + pool_commission, so a bad book fill is caught. But the no-belief branch computespool_spread.min(pool_gross) / total_gross_out— the numerator is purely the pool leg's spread. The book leg only shows up in the denominator, where a worse book fill actually makes the ratio smaller and easier to pass.So a taker who omits
belief_pricehas no floor on the book-leg price. The pair has nominimum_receiveeither — the only end-to-end floor is the router'sminimum_receive, which is also optional. A taker who quotes, then gets the good resting orders pulled/consumed before execution, can fill the book leg at materially worse prices and the no-belief check won't reject it.Not a drain — it's an unprotected-by-default footgun on the book leg. Calling it Medium because the taker can self-protect with
belief_priceorminimum_receive, but the contract shouldn't rely on that.Current codebase
smartcontracts/packages/dex-common/src/max_spread.rs— no-belief branch:spread_cmp = pool_spread.min(pool_gross)overtotal_gross_out; book leg never constrains the numerator.smartcontracts/contracts/pair/src/contract.rs—assert_max_spreadis the only slippage guard inexecute_swap; there is no pair-levelminimum_receive.smartcontracts/contracts/router/src/contract.rs— per-hop swaps are built withbelief_price: None, so every router-initiated hybrid hop runs the no-belief path; only the final-hopminimum_receivebounds the whole chain, and it's optional.Why this matters
Hybrid is the headline feature and the path most takers will use. "Default settings, no belief price" should not silently expose the book leg to unbounded slippage. Right now safety depends on the frontend always setting a guard — fine until something calls the contract directly.
Recommended direction
max_spread, or fold a book-leg shortfall term into the numerator.min_returnfor hybrid so there's a hard floor independent ofbelief_price.belief_priceorminimum_receivewhenbook_input > 0.Acceptance criteria
belief_pricewhose book leg fills materially worse than quoted is rejected by the spread/return check.minimum_receiveunset.Test plan (functional)
Test plan (attack / abuse)
All 3 approved
Shipped direction 1 — the no-belief metric now reflects book-leg degradation. This is the actual security fix and it's non-breaking (contract internals only, no
Swapschema change, no frontend impact). Directions 2 & 3 flagged below as the frontend-coordinated follow-up, on purpose.PoC first (you asked): added
limit_order_tests::hybrid_no_belief_book_far_below_pool_rejected— a no-belief hybrid whose book leg fills ~50% below the pool fair rate. It FAILS on current code (the swap is accepted — the gap), and passes after the fix.Fix (net-vs-net): I ran an adversarial design pass on the formula (3 independent approaches, numerically stress-tested). Winner = reference the book leg against the pool's realized NET rate, not gross — gross would phantom-reject a legit book that's only short by its own fee on deep pools. In
max_spread.rsno-belief branch:book_shortfall = max(0, pool_net_return * book_input / pool_input - book_net_return)folded into the numerator (checked_multiply_ratio, overflow-safe; gated to exactly 0 unless both legs present → #197 pool-only metric byte-identical). At the call site I plumbpool_input_amountandoffer_consumed_by_book(NOTh.book_input— the book's unfilled remainder spills into the pool, so that keepspool_input + book_input == offer).Numbers: PoC drain case → metric 0.83 > 1% reject (today: 0.00017 pass). Legit same-price hybrid (book short only by ~10bps fee) → 0.0036 pass. Pool-only → byte-identical to #197. Verified zero false-rejects across 18 legit splits/fees.
Meets AC1/AC2/AC3. Contract suite 415/0 (incl. new max_spread reject + accept unit tests + the PoC).
Flagged follow-up (directions 2 & 3 — frontend-coordinated):
belief_price/min_receivewhenbook_input>0) is the only thing that protects a pure-book hybrid (pool_input==0, no pool reference rate — this metric can't bound it). But making it MANDATORY breaks every book swap in the dapp until the frontend + router are updated to send a floor (router hops currently setbelief_price: None). So it needs to land WITH a frontend/router change, not before.min_returnon theSwaphook) is backward-compatible but inert until the frontend adopts it — belongs with the direction-3 rollout.I scoped this MR to the non-breaking security fix so it can merge without breaking book trading; happy to do 2 & 3 once the frontend change is sequenced. Branch
qa/273-hybrid-noblief-slippage-floor, MR fork→main (no closing keyword). @PlasticDigitsmentioned in merge request !746
mentioned in commit
016972a77bThe direction-1 fix is solid and I'd ship it, but the adversarial pass turned up a reachable corner the metric still doesn't cover, so I don't think this closes yet — flagging it for the direction-2/3 follow-up.
Verified: the no-belief metric now folds the book-leg shortfall against the pool's realized net rate into the numerator (max_spread.rs), gated to zero unless both legs are present so the #197 pool-only path is byte-identical. The PoC
hybrid_no_belief_book_far_below_pool_rejected(book leg ~50% below pool fair) fails on the old code and passes now; contract suite 415/0; I re-derived the three metric cases by hand and they match. For a sensibly-split hybrid this closes the footgun.The corner it doesn't cover: the guard leans on
pool_net_return / pool_inputbeing a meaningful reference, and the split is fully caller-controlled (pool_input + book_input == input_amountis the only check, no minimum pool leg — contract.rs:812). Setpool_input = 1against a deep balanced pool: the AMMceil_divfloorsgross_outputto 0, sopool_net_return = 0,book_shortfall = 0, andspread_cmp = min(pool_spread, pool_gross=0) = 0— the whole numerator is 0 and any book fill, however far below market, passes the no-belief check. The router passes hybrid params through unchanged, so a router hop can hit it too. It's the same class as the pure-book (pool_input == 0) case the fix already leaves to belief/min_receive — the max_spread.rs comment carves out onlypool_input == 0, but the real boundary is "pool leg small enough that the AMM output rounds to 0", which is a bit wider.Same Medium severity (taker omitting belief_price and minimum_receive on a degenerate split) — the fix shrinks the footgun but doesn't eliminate it. I'd fold a material-pool-leg floor (require
pool_net_return > 0, or a minpool_inputwhenbook_input > 0on the no-belief path) into the direction-2/3 work where you're already planning the belief/min_receive requirement. Happy to implement once the frontend/router floor is sequenced. @PlasticDigitsmarked as related to #307
mentioned in issue #307
mentioned in commit
6cf3d7a4ddmentioned in merge request !785
mentioned in commit
9ac09ab1c1mentioned in commit c1f6903039cb226a7550f4397637e7c99ac4e6ac
mentioned in commit
e1ae059653mentioned in commit
845ea37c19mentioned in merge request !801
Implementation verify — GitLab #273
Issue: https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/work_items/273
The contract security fix is already merged to
main:58092d1— no-beliefbook_shortfallvs pool net rate (#273)6cf3d7a— material declared pool leg +pool_net > 0when book leg present (#307)e1ae059— frontendswapMaxSpreadpreflight aligned with execute legsDoc sync MR: https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/merge_requests/83
Acceptance criteria
cargo test -p cl8y-dex-tests hybrid_no_belief_book_far_below_pool_rejectedcargo test -p dex-common max_spread(no_belief_rejects_book_far_below_pool_net_rate,no_belief_accepts_book_at_pool_net_rate)minimum_receivevalidate_declared_hybrid_pool_leg_for_no_beliefon router + pair; dust leghybrid_no_belief_dust_pool_leg_rejectedmake test-contracts(374 integration + unit)npm test -- src/utils/swapMaxSpread.test.ts(9 tests)Out of scope (documented follow-up)
Pure-book hybrid (
declared_pool_input = 0) still needsbelief_priceorminimum_receive— see issue discussion (directions 2 & 3).Removing
agent:implement/agent:verifylabels.mentioned in commit
b9121d8392Verification — GitLab #273
Issue: https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/work_items/273
Independent re-verification on branch
main(no repo changes).Acceptance criteria
cd smartcontracts && cargo test -p cl8y-dex-tests hybrid_no_belief_book_far_below_pool_rejectedcd smartcontracts && cargo test -p dex-common max_spread—no_belief_rejects_book_far_below_pool_net_rate,no_belief_accepts_book_at_pool_net_rate(10/10 unit tests)minimum_receivevalidate_declared_hybrid_pool_leg_for_no_beliefin pair/router;hybrid_no_belief_dust_pool_leg_rejectedintegration testmake test-contracts— 374 integration + 41 pair unit + 19 dex-common unitnpm test -- src/utils/swapMaxSpread.test.ts(9/9)Functional test plan
belief_priceset, normal bookcargo test -p cl8y-dex-tests hybrid_belief_price_max_spread_rejects_shortfall_on_total_output+hybrid_max_spread_exact_tolerance_succeedsno_belief_accepts_book_at_pool_net_rate(dex-common)Attack / abuse test plan
hybrid_no_belief_book_far_below_pool_rejectedminimum_receiveunset, dust/bad pool leghybrid_no_belief_dust_pool_leg_rejected+no_belief_rejects_dust_pool_leg_with_bookImplementation on
main58092d1— no-beliefbook_shortfallvs pool net rate (#273)6cf3d7a— material declared pool leg +pool_net > 0when book leg present (#307)e1ae059— frontendswapMaxSpreadpreflight aligned with execute legsdocs/contracts-security-audit.mdandskills/AGENTS_MAX_SPREAD_HYBRID.mdOut of scope (documented follow-up)
Pure-book hybrid (
declared_pool_input = 0) still requiresbelief_priceorminimum_receive— directions 2 & 3 from issue discussion. Not a blocker for closing #273.Verdict: All acceptance criteria PASS. Closing.
mentioned in issue #334
marked as related to #334
mentioned in merge request !819
mentioned in commit
6e2ebbe1d0mentioned in commit
05ee14db17mentioned in issue #376