Pair-direct swaps bypass the limit book entirely — makers are invisible to any integrator that doesn't pass HybridSwapParams #704

Closed
opened 2026-08-29 13:25:15 +00:00 by leonardocolucci · 13 comments
leonardocolucci commented 2026-08-29 13:25:15 +00:00 (Migrated from gitlab.com)

Summary

#596 made the frontend's swaps always consider the book, but the pair contract itself still only consults the limit book when the caller supplies HybridSwapParams:

// contracts/pair/src/contract.rs:949
let (pool_leg, book_leg, …) = match &hybrid {
    None => (input_amount, Uint128::zero(), 0u32, None),   // book skipped

Any wallet, bot, or integrator that calls the pair contract directly gets pool-only execution — even when a resting order is strictly better. Observed on columbus-5: an automated trader running ~85 swaps/day on CL8Y-cb/cLUNC crossed the best resting ask by 1.7% and still filled 100% against the pool (it never passes HybridSwapParams, so the book cannot participate).

Consequences:

  • Takers who integrate directly systematically get worse prices than the venue can offer.
  • Makers can't reach a growing share of flow no matter how tight they quote, which discourages posting book liquidity — the same incentive problem #596 fixed for the UI, one layer down.

Expected

The pair's plain swap path computes the price-optimal pool/book split itself (or defaults to book-first) when no HybridSwapParams is provided — so best execution is a property of the contract, not of which client the taker happens to use. Callers who explicitly want pool-only could pass a flag for it.

Actual

hybrid: None → book_leg = 0 unconditionally; the default path never reads the book.

Notes

  • If a consensus-breaking pair migration is too heavy, an intermediate step would help: have the indexer/docs steer integrators to the router, and/or expose a simple "best execution" entry point on the pair that fills the split in-contract.
  • The per-trade pool_leg_volume / book_leg_volume fields make the fix measurable: share of pair-direct trades with a non-zero book leg.
## Summary `#596` made the frontend's swaps always consider the book, but the pair contract itself still only consults the limit book when the caller supplies `HybridSwapParams`: ```rust // contracts/pair/src/contract.rs:949 let (pool_leg, book_leg, …) = match &hybrid { None => (input_amount, Uint128::zero(), 0u32, None), // book skipped ``` Any wallet, bot, or integrator that calls the pair contract directly gets pool-only execution — even when a resting order is strictly better. Observed on columbus-5: an automated trader running ~85 swaps/day on CL8Y-cb/cLUNC crossed the best resting ask by 1.7% and still filled 100% against the pool (it never passes `HybridSwapParams`, so the book cannot participate). Consequences: - Takers who integrate directly systematically get worse prices than the venue can offer. - Makers can't reach a growing share of flow no matter how tight they quote, which discourages posting book liquidity — the same incentive problem #596 fixed for the UI, one layer down. ## Expected The pair's plain `swap` path computes the price-optimal pool/book split itself (or defaults to book-first) when no `HybridSwapParams` is provided — so best execution is a property of the contract, not of which client the taker happens to use. Callers who explicitly want pool-only could pass a flag for it. ## Actual `hybrid: None` → `book_leg = 0` unconditionally; the default path never reads the book. ## Notes - If a consensus-breaking pair migration is too heavy, an intermediate step would help: have the indexer/docs steer integrators to the router, and/or expose a simple "best execution" entry point on the pair that fills the split in-contract. - The per-trade `pool_leg_volume` / `book_leg_volume` fields make the fix measurable: share of pair-direct trades with a non-zero book leg.
PlasticDigits commented 2026-08-29 14:27:07 +00:00 (Migrated from gitlab.com)

This is expected behavior, because hybrid orders require complex offchain routing. Onchain behavior must allow bots that dont have access to offchain routing to do direct v2 swaps, then allow other bots to arbitrage between the pair and the limit orders because not all bots can do the complex price routing, so in effect the bot is paying arbitraguers to do price routing for it. Open to additional feedback and ideas - if there is a gas efficient and affordable way to do onchain hybrid orders, an upgrade is fine. Or if this is an issue where bot operators arent aware of the need for offchain routing, perhaps changes to documentation or onchain behavior could help guide bot creators and operators towards better routing algorithms, such as the ones in the indexer in this repo. @leonardocolucci

This is expected behavior, because hybrid orders require complex offchain routing. Onchain behavior must allow bots that dont have access to offchain routing to do direct v2 swaps, then allow other bots to arbitrage between the pair and the limit orders because not all bots can do the complex price routing, so in effect the bot is paying arbitraguers to do price routing for it. Open to additional feedback and ideas - if there is a gas efficient and affordable way to do onchain hybrid orders, an upgrade is fine. Or if this is an issue where bot operators arent aware of the need for offchain routing, perhaps changes to documentation or onchain behavior could help guide bot creators and operators towards better routing algorithms, such as the ones in the indexer in this repo. @leonardocolucci
PlasticDigits commented 2026-08-29 14:27:45 +00:00 (Migrated from gitlab.com)

(leaving open for a few days for ideas/feedback, then will close as expected unless other actions are approved)

(leaving open for a few days for ideas/feedback, then will close as expected unless other actions are approved)
leonardocolucci commented 2026-08-29 19:23:56 +00:00 (Migrated from gitlab.com)

Thank you!

...

From: gitlab@mg.gitlab.com gitlab@mg.gitlab.com on behalf of Plastic Digits (@PlasticDigits) gitlab@mg.gitlab.com
Sent: 29 August 2026 15:27
To: leonardo_colucci@hotmail.com leonardo_colucci@hotmail.com
Subject: Re: cl8y-dex-terraclassic | Pair-direct swaps bypass the limit book entirely — makers are invisible to any integrator that doesn't pass HybridSwapParams (#704)

Plastic Digitshttps://gitlab.com/PlasticDigits commentedhttps://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/work_items/704#note_3756599362:

(leaving open for a few days for ideas/feedback, then will close as expected unless other actions are approved)

—
Reply to this email directly or view it on GitLabhttps://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/work_items/704#note_3756599362.
You're receiving this email because of your account on gitlab.comhttps://gitlab.com/. Unsubscribehttps://gitlab.com/-/sent_notifications/REDACTED/unsubscribe from this thread · Manage all notificationshttps://gitlab.com/-/profile/notifications · Helphttps://gitlab.com/help Notification message regarding https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/work_items/704#note_3756599362 at 1788013667

Thank you! <details><summary>...</summary> ________________________________ From: gitlab@mg.gitlab.com <gitlab@mg.gitlab.com> on behalf of Plastic Digits (@PlasticDigits) <gitlab@mg.gitlab.com> Sent: 29 August 2026 15:27 To: leonardo_colucci@hotmail.com <leonardo_colucci@hotmail.com> Subject: Re: cl8y-dex-terraclassic | Pair-direct swaps bypass the limit book entirely — makers are invisible to any integrator that doesn't pass HybridSwapParams (#704) Plastic Digits<https://gitlab.com/PlasticDigits> commented<https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/work_items/704#note_3756599362>: (leaving open for a few days for ideas/feedback, then will close as expected unless other actions are approved) — Reply to this email directly or view it on GitLab<https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/work_items/704#note_3756599362>. You're receiving this email because of your account on gitlab.com<https://gitlab.com/>. Unsubscribe<https://gitlab.com/-/sent_notifications/REDACTED/unsubscribe> from this thread · Manage all notifications<https://gitlab.com/-/profile/notifications> · Help<https://gitlab.com/help> Notification message regarding https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/work_items/704#note_3756599362 at 1788013667 </details>
leonardocolucci commented 2026-08-29 19:31:58 +00:00 (Migrated from gitlab.com)

Thanks — that's a fair framing, and I agree with the core of it: full multi-hop split optimization does belong off-chain (the indexer solver, per #501), and cheap pool-only direct swaps must stay possible. I'm not arguing the default should force a book walk onto every caller.

One gap in the arb-layer model, though: arbitrage corrects the price over time, but it doesn't give the direct taker best execution on their fill — the taker still pays the worse pool price, and the surplus goes to arbitrageurs, not to that taker or to the maker who was quoting better. On a venue where arb flow is still thin, that correction is slow and partial, so a class of integrators systematically overpays and the maker never sees that flow. That's the piece I don't think "the arb layer handles it" fully answers.

On your open door for a gas-efficient on-chain hybrid — I think there's a bounded middle path that sidesteps the "not all bots can do complex routing" problem:

A swap variant that takes only book_start_hint + max_maker_fills (no caller-supplied pool_input/book_input), and does a greedy book-first-then-pool fill in-contract: walk the top of book while each level beats the pool's marginal price, capped at max_maker_fills, then route the remainder to the pool.

The key point: the hard offchain part is the split optimization, and greedy book-first doesn't need it. The caller's job shrinks from "run the solver" to "point at top-of-book" — one LCD/indexer query any bot can do. Gas stays bounded and opt-in (only walks when the flag is set, only for levels that beat the pool, capped at N); pure pool-only stays the cheap default. And it reuses the book-walk that already runs when HybridSwapParams is passed.

The one thing for you to weigh: whether the contract can cheaply start the walk from a caller-supplied hint (vs the #266 anchor threading) — keeping book_start_hint as a cheap caller input rather than auto-deriving it is probably the pragmatic form.

Fully agree the docs/awareness path is worth doing now regardless — clear integrator guidance that direct pair.swap is pool-only and best execution goes through the router/indexer would help immediately. I'd just suggest keeping this open as a tracked enhancement (the book_start_hint+max_maker_fills variant) rather than closing as expected, and using the per-trade pool_leg_volume / book_leg_volume fields to measure the share of direct trades leaking past a better book — so the call stays data-driven. Happy to help spec the variant if useful.

Thanks — that's a fair framing, and I agree with the core of it: full multi-hop split optimization does belong off-chain (the indexer solver, per #501), and cheap pool-only direct swaps must stay possible. I'm not arguing the default should force a book walk onto every caller. One gap in the arb-layer model, though: arbitrage corrects the **price over time**, but it doesn't give the direct taker best execution on **their** fill — the taker still pays the worse pool price, and the surplus goes to **arbitrageurs**, not to that taker or to the maker who was quoting better. On a venue where arb flow is still thin, that correction is slow and partial, so a class of integrators systematically overpays and the maker never sees that flow. That's the piece I don't think "the arb layer handles it" fully answers. On your open door for a gas-efficient on-chain hybrid — I think there's a bounded middle path that sidesteps the "not all bots can do complex routing" problem: **A swap variant that takes only `book_start_hint` + `max_maker_fills` (no caller-supplied `pool_input`/`book_input`), and does a greedy book-first-then-pool fill in-contract:** walk the top of book while each level beats the pool's marginal price, capped at `max_maker_fills`, then route the remainder to the pool. The key point: the *hard* offchain part is the **split optimization**, and greedy book-first doesn't need it. The caller's job shrinks from "run the solver" to "point at top-of-book" — one LCD/indexer query any bot can do. Gas stays bounded and opt-in (only walks when the flag is set, only for levels that beat the pool, capped at N); pure pool-only stays the cheap default. And it reuses the book-walk that already runs when `HybridSwapParams` is passed. The one thing for you to weigh: whether the contract can cheaply start the walk from a caller-supplied hint (vs the #266 anchor threading) — keeping `book_start_hint` as a cheap caller input rather than auto-deriving it is probably the pragmatic form. Fully agree the **docs/awareness path is worth doing now** regardless — clear integrator guidance that direct `pair.swap` is pool-only and best execution goes through the router/indexer would help immediately. I'd just suggest keeping this open as a tracked enhancement (the `book_start_hint`+`max_maker_fills` variant) rather than closing as expected, and using the per-trade `pool_leg_volume` / `book_leg_volume` fields to measure the share of direct trades leaking past a better book — so the call stays data-driven. Happy to help spec the variant if useful.
PlasticDigits commented 2026-08-30 05:18:12 +00:00 (Migrated from gitlab.com)

"A swap variant that takes only book_start_hint + max_maker_fills (no caller-supplied pool_input/book_input), and does a greedy book-first-then-pool fill in-contract: walk the top of book while each level beats the pool's marginal price, capped at max_maker_fills, then route the remainder to the pool."

Interesting idea, Ill open an issue to investigate the design potential. Plus the docs update.

"**A swap variant that takes only `book_start_hint` + `max_maker_fills` (no caller-supplied `pool_input`/`book_input`), and does a greedy book-first-then-pool fill in-contract:** walk the top of book while each level beats the pool's marginal price, capped at `max_maker_fills`, then route the remainder to the pool." Interesting idea, Ill open an issue to investigate the design potential. Plus the docs update.
PlasticDigits commented 2026-08-30 05:24:14 +00:00 (Migrated from gitlab.com)

mentioned in issue #707

mentioned in issue #707
PlasticDigits commented 2026-08-30 05:24:14 +00:00 (Migrated from gitlab.com)

marked as related to #707

marked as related to #707
PlasticDigits commented 2026-08-30 05:24:16 +00:00 (Migrated from gitlab.com)

mentioned in issue #708

mentioned in issue #708
PlasticDigits commented 2026-08-30 05:24:16 +00:00 (Migrated from gitlab.com)

marked as related to #708

marked as related to #708
PlasticDigits commented 2026-08-30 05:24:29 +00:00 (Migrated from gitlab.com)

Follow-ups from the approved paths on this thread:

  • Docs/awareness (ship independently): #707 — integrator guidance that direct pair.swap without hybrid is pool-only; best execution is indexer GET /route/solve / declared Pattern C.
  • Design (pair wasm, opt-in, hybrid: None stays pool-only): #708 — greedy book-first-then-pool variant taking only book_start_hint + max_maker_fills (no caller pool_input/book_input).

Default TerraSwap Swap {} is unchanged. Closing this issue as expected can wait until those two are triaged.

Follow-ups from the approved paths on this thread: - Docs/awareness (ship independently): #707 — integrator guidance that direct `pair.swap` without hybrid is pool-only; best execution is indexer `GET /route/solve` / declared Pattern C. - Design (pair wasm, opt-in, `hybrid: None` stays pool-only): #708 — greedy book-first-then-pool variant taking only `book_start_hint` + `max_maker_fills` (no caller `pool_input`/`book_input`). Default TerraSwap `Swap {}` is unchanged. Closing this issue as expected can wait until those two are triaged.
PlasticDigits commented 2026-09-01 08:48:56 +00:00 (Migrated from gitlab.com)

@leonardocolucci follow-up on the columbus-5 pair-direct flow you reported (CL8Y-cb/cLUNC bot ~85 swaps/day, never passing HybridSwapParams, crossing a better ask and filling 100% pool).

The greedy variant you sketched is live as opt-in, not as the default Swap {} path.

What migrated (#712, closed): pair 11639 / cw2 1.16.0 on all 20 factory pairs (config.pair_code_id 11639). Router 11640 as well (same cw2 1.1.0; greedy hops now decode). Official dApp still uses GET /route/solve.

Still true after that migrate (G1): omitted hybrid + omitted greedy is pool-only. LCD smoke on EMBER/CORAL: omitted and Pattern C book_input=0 both return 929574; greedy with an empty/worse book is worse_than_pool and the same amount. So a TerraSwap-shaped pair-direct bot that does not send greedy: { max_maker_fills, book_start_hint } will still skip the book — same class of miss as your CL8Y-cb/cLUNC observation.

When greedy is passed, the book does participate. CL8Y/cUSTC query canary (offer 1e15 CL8Y): pool-only 98538 vs greedy 99440 (book_return_amount=99440, greedy_stop=filled).

#718 is the next wasm: omitted params default to greedy (opt-out; pool-only only if the caller sends Pattern C book_input=0). That would cover misconfigured / TerraSwap-clone bots without a client change. It needs pair 1.17.0 + a new router bump; do not treat 11639/11640 as that.

Question for you: given the bot you watched, is 712 (opt-in greedy — they must start sending greedy) enough, or do we still need 718 (default greedy on plain Swap {}) so that flow hits the book with no integrator change? Happy to go either way off what you are still seeing on-chain (pool_leg_volume / book_leg_volume on those pair-direct fills).

@leonardocolucci follow-up on the columbus-5 pair-direct flow you reported (CL8Y-cb/cLUNC bot ~85 swaps/day, never passing `HybridSwapParams`, crossing a better ask and filling 100% pool). The greedy variant you sketched is live as **opt-in**, not as the default `Swap {}` path. **What migrated ([#712](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/712), closed):** pair **11639 / cw2 1.16.0** on all 20 factory pairs (`config.pair_code_id` 11639). Router **11640** as well (same cw2 1.1.0; greedy hops now decode). Official dApp still uses `GET /route/solve`. **Still true after that migrate (G1):** omitted `hybrid` + omitted `greedy` is **pool-only**. LCD smoke on EMBER/CORAL: omitted and Pattern C `book_input=0` both return `929574`; greedy with an empty/worse book is `worse_than_pool` and the same amount. So a TerraSwap-shaped pair-direct bot that does not send `greedy: { max_maker_fills, book_start_hint }` will **still skip the book** — same class of miss as your CL8Y-cb/cLUNC observation. **When greedy is passed, the book does participate.** CL8Y/cUSTC query canary (offer `1e15` CL8Y): pool-only `98538` vs greedy `99440` (`book_return_amount=99440`, `greedy_stop=filled`). **[#718](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/718)** is the next wasm: omitted params default to greedy (opt-out; pool-only only if the caller sends Pattern C `book_input=0`). That would cover misconfigured / TerraSwap-clone bots **without a client change**. It needs pair **1.17.0** + a new router bump; do not treat 11639/11640 as that. **Question for you:** given the bot you watched, is **712 (opt-in greedy — they must start sending `greedy`)** enough, or do we still need **718 (default greedy on plain `Swap {}`)** so that flow hits the book with no integrator change? Happy to go either way off what you are still seeing on-chain (`pool_leg_volume` / `book_leg_volume` on those pair-direct fills).
leonardocolucci commented 2026-09-01 11:36:21 +00:00 (Migrated from gitlab.com)

Thanks for turning this around so fast — the opt-in greedy variant is exactly the shape I had in mind, and your CL8Y/cUSTC canary (99440 vs 98538 pool, greedy_stop=filled) confirms the book participates when greedy is passed. 🙌

On your question — is #712 enough, or do we need #718 — I think the on-chain answer is #718, and it comes straight from the flow this issue was opened about:

The CL8Y-cb/cLUNC bot (terra18xu97…, a third party) is still running today, after the 11639 migrate: ~85 swaps/day, both directions, and its recent fills are 100% pool-only (book_leg_volume = 0). It's a TerraSwap-shaped integrator, so it will never send CL8Y's greedy param — which means opt-in (#712) can't capture it. That's the crux: opt-in still requires the integrator to pass a special param, so makers stay invisible to any bot that doesn't adopt it — the exact problem here, one param-name over.

Opt-in is the right tool for cooperating integrators, and the dApp already best-executes via GET /route/solve. But closing the leak this issue is about — non-cooperating, TerraSwap-clone pair-direct flow — needs omitted-params default greedy (#718), so plain Swap {} fills a strictly-better book with no client change. That also lines up with the best-execution-by-default direction #596 set for the UI, one layer down. Callers who truly want pool-only keep the explicit Pattern C book_input=0 escape hatch, and gas stays bounded/opt-out.

Fully acknowledge the trade-offs are yours to weigh (default-greedy adds a bounded book scan to the common swap path + needs pair 1.17.0 / router bump). Happy to keep watching pool_leg_volume / book_leg_volume on that pair to quantify the captured flow once a maker is quoting against it.

Thanks for turning this around so fast — the opt-in greedy variant is exactly the shape I had in mind, and your CL8Y/cUSTC canary (99440 vs 98538 pool, `greedy_stop=filled`) confirms the book participates when `greedy` is passed. 🙌 On your question — is #712 enough, or do we need #718 — I think the on-chain answer is #718, and it comes straight from the flow this issue was opened about: The CL8Y-cb/cLUNC bot (`terra18xu97…`, a third party) is **still running today, after the 11639 migrate**: ~85 swaps/day, both directions, and its recent fills are **100% pool-only** (`book_leg_volume = 0`). It's a TerraSwap-shaped integrator, so it will never send CL8Y's `greedy` param — which means opt-in (#712) can't capture it. That's the crux: opt-in still requires the integrator to pass a special param, so makers stay invisible to any bot that doesn't adopt it — the exact problem here, one param-name over. Opt-in is the right tool for cooperating integrators, and the dApp already best-executes via `GET /route/solve`. But closing the leak this issue is about — non-cooperating, TerraSwap-clone pair-direct flow — needs **omitted-params default greedy (#718)**, so plain `Swap {}` fills a strictly-better book with no client change. That also lines up with the best-execution-by-default direction #596 set for the UI, one layer down. Callers who truly want pool-only keep the explicit Pattern C `book_input=0` escape hatch, and gas stays bounded/opt-out. Fully acknowledge the trade-offs are yours to weigh (default-greedy adds a bounded book scan to the common swap path + needs pair 1.17.0 / router bump). Happy to keep watching `pool_leg_volume` / `book_leg_volume` on that pair to quantify the captured flow once a maker is quoting against it.
Member

Backlog sweep note (2026-09-06) — comment only

Thread suggests an opt-in greedy / HybridSwapParams path may already have landed (founder thank-you on canary). Not closing overnight.

Founder: if ACs are on main, close; else leave unlabeled (needs product call on default-on vs opt-in — not auto ready/agent:implement).

## Backlog sweep note (2026-09-06) — comment only Thread suggests an opt-in greedy / HybridSwapParams path may already have landed (founder thank-you on canary). **Not closing overnight.** Founder: if ACs are on `main`, close; else leave unlabeled (needs product call on default-on vs opt-in — not auto `ready`/`agent:implement`).
Sign in to join this conversation.
No milestone
No project
No assignees
2 participants
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#704
No description provided.