design: greedy book-first swap variant (hint + max_maker_fills, no caller split) #708

Closed
opened 2026-08-30 05:24:15 +00:00 by PlasticDigits · 18 comments
PlasticDigits commented 2026-08-30 05:24:15 +00:00 (Migrated from gitlab.com)

Summary

Add an opt-in pair swap variant that fills greedy book-first-then-pool in-contract: the caller supplies only book_start_hint + max_maker_fills (no pool_input / book_input). The pair walks the top of book while each live level beats the pool's current marginal price, capped at max_maker_fills (and existing scan/park caps), then routes the remainder to the AMM.

This is the gas-efficient on-chain hybrid approved from #704. It is not a change to the default hybrid: None path (that stays pool-only). Full split optimization stays off-chain (#501 / indexer GET /route/solve).

Sibling (docs now, no wasm): integrator guidance that direct pair.swap is pool-only — see the linked docs issue from #704.


Current codebase

Pattern C is caller-declared. The pair never chooses a pool/book split.

Pair execute

smartcontracts/contracts/pair/src/contract.rs execute_swap (~L968):

hybrid: None  →  pool_leg = offer, book_leg = 0, max_makers = 0   // book skipped
hybrid: Some  →  pool_input + book_input must equal offer

When book_leg > 0, the pair calls orderbook::match_bids or match_asks with the declared book_input, then rolls unfilled book remainder into the pool:

pool_input_amount = pool_leg + (book_leg − offer_consumed_by_book) (~L1131).

That roll-forward is not “stop when the next maker is worse than the pool.” A caller who sets pool_input = 0, book_input = offer will take worse-than-pool resting orders until the budget, max_maker_fills, or MAX_SCAN_STEPS is exhausted. #307 also requires a material pool leg (or belief_price / min_return) for declared hybrid.

Types

smartcontracts/packages/dex-common/src/pair.rs — HybridSwapParams { pool_input, book_input, max_maker_fills, book_start_hint }. Caps: MAX_MAKER_FILLS_HARD_CAP = 100, MAX_SCAN_STEPS = 500. Cw20HookMsg::Swap.hybrid: Option<HybridSwapParams>. Quotes: HybridSimulation / HybridReverseSimulation only (legacy Simulation removed, L8 / #190).

Book walk

smartcontracts/contracts/pair/src/orderbook.rs — match_bids / match_asks / simulate_match_*. book_start_hint is permissionless; wrong-side / stale / missing → fall back to head (L17 / #272). Do not auto-derive the hint on-chain (contrast insert-thread #266). Indexer global_v2 already emits the first live same-side id when the mirror is fresh (#332).

Router

smartcontracts/contracts/router/src/contract.rs — SwapOperation::TerraSwap { hybrid: Option<HybridSwapParams> }. hybrid: None maps to pool_only_hybrid_params for quotes; execute still pool-only on that hop.

Off-chain best execution (already exists)

  • Indexer GET /api/v1/route/solve + amount_in = global path + joint hybrid grid (#209, ADR 0002). Interior splits can beat naive book-first (~+0.07% / ~+0.4% on observed EMBER sizes in #704 discussion).
  • Official dApp always submits solver hybrid (#501, #596). Single-hop is still pair-direct send→swap with those params (swapRouting.ts).

Observed gap

Bots that omit hybrid (columbus-5 CL8Y-cb/cLUNC ~85 swaps/day in #704) fill 100% against the pool even when the best ask is strictly better. Arb may correct later; that taker still overpays and the maker never sees the flow.


Why this is needed

Not every integrator can (or will) run the indexer solver. Asking them to invent pool_input / book_input is the hard part. A bounded greedy walk:

  • shrinks the caller job to “point at top-of-book + cap N makers” (one LCD / order-book-head / indexer hint);
  • keeps cheap pool-only as the default (hybrid: None unchanged);
  • reuses the existing matcher instead of a new on-chain optimizer;
  • is opt-in, so bots that want v2-only (and to pay arbs) keep today’s path.

This is not global best execution. Greedy book-first can lose to an interior split. The indexer remains the price-optimal path. This variant is a better-than-pool-only floor for simple bots.


Constraints / guardrails

Invariants G1–G14. Do not ship a variant that cannot satisfy them.

ID Rule
G1 — default stays pool-only hybrid: None / omitted on Cw20HookMsg::Swap and router TerraSwap must remain pool-only (book_leg = 0). No silent book walk on TerraSwap-compatible callers.
G2 — no caller split The new entry point must not take pool_input / book_input. Caller fields: offer (CW20 amount), max_maker_fills, optional book_start_hint, plus existing slippage (belief_price / max_spread / min_return), to, deadline, trader.
G3 — greedy stop Walk live same-side orders from the resolved hint (else head). Fill a level only while that maker’s executable price beats the pool’s current residual marginal (after pair effective_fee_bps). Next worse-or-equal level → stop book, remainder → pool. Empty book / all worse → 100% pool. Design must pin “beats” (gross vs net of taker commission; 1-unit marginal vs this order’s fill).
G4 — not the solver Do not search interior splits, grids, or multi-hop on-chain. Do not claim indexer optimality. Docs must say greedy ≤ solver, greedy ≥ pool-only when the book is strictly better (modulo fees/dust).
G5 — caps unchanged max_maker_fills clamped with MAX_MAKER_FILLS_HARD_CAP (100). Walk still bounded by MAX_SCAN_STEPS (500) and MAX_EXPIRED_PARKS_PER_SWAP (15). max_maker_fills == 0 → reject (or treat as pool-only — pick one, test it). Oversize fills clamp, do not panic.
G6 — hint stays caller-supplied Do not auto-walk/derive book_start_hint in the pair (no #266 insert-thread on swap). Missing/stale/wrong-side → head fallback (L17). Integrators should pass first live same-side id (#289 expired prefix).
G7 — quote = execute (L8) A matching query (extend HybridSimulation or a sibling) must use the same greedy rule as execute. Queries remain read-only (no parks). Execute may still park expired/dust; document sim vs exec drift for expired heads.
G8 — slippage still gates the book Book contact requires belief_price or min_return (same family as #307 / #334). Do not invent a path that walks the book with neither. max_spread L9 applies to the realized pool+book total.
G9 — remainder is the only pool leg After the greedy stop, all leftover offer goes to the constant-product pool (including book shortfall). k-invariant, fee, TWAP-before-reserves, treasury commission unchanged.
G10 — reuse matcher Fill, FIFO, maker payouts, taker commission, dust flush (L16), blacklist, pause (L6), fee-tier trader (router-stamped only), community-tax extra-debit (T592-13) stay on the existing match_bids / match_asks path. Do not fork a second matcher.
G11 — opt-in surface Prefer a new serde shape (enum variant or dedicated params struct) so old HybridSwapParams JSON cannot be misread as greedy. Do not overload pool_input=0, book_input=offer (that already means “take book even if worse”).
G12 — router optional, explicit Router may forward the new shape per hop. hybrid: null stays pool-only. Do not auto-upgrade router hops to greedy. Official dApp does not have to switch off the solver.
G13 — gas Worst-case envelope stays within HYBRID_SWAP_GAS_LIMIT (15M) and existing book-walk overhead. Map getGasLimitForTx / swarm gas if a new send-hook string appears (#475). Unmapped hooks must throw, not silent 600k.
G14 — migrate, don’t break TerraSwap Pair wasm migrate (factory pair_code_id). No instantiate-msg break. Existing Pattern C execute/sim tests must stay green. Metric: share of pair-direct txs with book_leg_volume > 0 after adoption (indexer columns already exist).

Out of scope: changing hybrid: None; forcing greedy on the official UI; on-chain split optimizer; auto hint from storage; other-DEX hops (#690).


Relevant files

Contracts: smartcontracts/packages/dex-common/src/pair.rs · smartcontracts/contracts/pair/src/{contract,orderbook,error,lib}.rs · smartcontracts/packages/dex-common/src/max_spread.rs · smartcontracts/contracts/router/src/{msg,contract}.rs · smartcontracts/tests/src/limit_order_tests.rs (+ hybrid / hint tests)

Indexer (optional follow-through, same issue if small): indexer/src/api/{route_solver,hybrid_route_opt}.rs — do not replace GET best-execution with greedy; optional document/flag only.

Frontend: no retail requirement. If a new hook string exists, gas maps in frontend-dapp/src/services/terraclassic/{terraGas,hybridSwapGas}.ts.

Docs / skills: docs/limit-orders.md · docs/integrators.md · docs/contracts-security-audit.md (new G* / L* row) · docs/adr/0001-hybrid-quoting-and-routing.md · skills/AGENTS_HYBRID_QUOTING.md · skills/AGENTS_BOOK_MATCH_HINT_SECURITY.md · skills/AGENTS_TERRACLASSIC_GAS.md

Related: #704 (parent) · #501 / #596 (solver UI) · #262 / #254 (caps) · #272 (hint side) · #307 (pool floor) · #190 (L8)


  1. Design spike (must land in the MR description / ADR note before wasm):
    • Message shape: Cw20HookMsg sibling vs enum Hybrid { Declared(HybridSwapParams), Greedy { max_maker_fills, book_start_hint } }.
    • Exact “beats pool” inequality (price units = token1 per token0; include taker half-fee or not; compare next maker vs residual offer’s 1-unit pool out).
    • When the last taken maker is only partially better (level size vs residual).
    • Sim query name and fields (makers_used, offer_consumed_by_book, stop reason).
  2. Implement greedy as a pre-pass that computes book_input_effective / stop, then calls existing match_* with that budget — avoid duplicating fill accounting.
  3. Stop reasons as wasm attrs (e.g. greedy_stop=worse_than_pool|max_makers|scan_cap|empty|filled) for indexer/debug.
  4. Router: optional greedy on TerraSwap; null remains pool-only.
  5. Do not switch Swap/Trade off GET /route/solve.
  6. Migrate pair code id via existing factory upgrade scripts; LocalTerra + one columbus-5 canary pair after tests.
  7. Docs in this ticket: contract semantics + L8. Integrator “use the solver” banner is the sibling docs issue (can merge first).

Acceptance criteria

  • hybrid: None still never reads the book (regression tests).
  • New variant rejects pool_input / book_input (not in schema, or explicit error if a confused payload is sent).
  • Resting ask (or bid) strictly better than pool → greedy consumes that liquidity up to residual / max_maker_fills; remainder goes to pool; taker return_amount ≥ pool-only on the same offer (after fees; equality only if book not used).
  • Resting orders worse than pool → zero book fills; 100% pool (same as hybrid: None economically).
  • Mixed book: takes prefix of better levels, stops before the first worse level (does not drain a worse tail).
  • max_maker_fills / hard cap / MAX_SCAN_STEPS / park cap bound the walk; OOG not used as the bound.
  • Wrong-side / missing hint → head fallback, no revert (L17).
  • Query greedy sim matches execute on a live book with no expired prefix (L8). Expired-head drift documented.
  • Slippage: greedy with book contact and neither belief_price nor min_return reverts.
  • Pause / blacklist / fee discount / tax extra-debit / hooks L7 behave as Pattern C hybrid.
  • Pattern C declared splits still work (interior pool_input/book_input unchanged).
  • Gas mapped for the new hook string; swarm/dApp unmapped send throws.
  • ADR / limit-orders / security-audit row; make verify-issue-<iid>.

Test plan (functional paths)

Pair multitest (cl8y-dex-pair / cl8y-dex-tests):

  1. hybrid: None + live better ask → still pool-only (G1).
  2. Greedy + empty book → pool-only, no fill events.
  3. Greedy + one better ask, residual after fill → book then pool; k holds; treasury fees = pool commission + book taker half.
  4. Greedy + only worse asks → zero book, pool-only amounts.
  5. Greedy + better prefix then worse tail → fills prefix only.
  6. Greedy vs declared book_input = offer on a mixed book: declared may take worse tail; greedy must not.
  7. Both sides: token0 in → match_bids; token1 in → match_asks.
  8. max_maker_fills = 1 with three better makers → one fill, rest pool.
  9. max_maker_fills = 0 and > 100 (clamp vs reject — as designed).
  10. book_start_hint live same-side; wrong-side; stale; None.
  11. Expired head prefix + hint past clog vs hint None hitting scan cap (#289).
  12. Partial fill of a better level; dust flush; FIFO same price.
  13. Slippage: min_return / belief_price / max_spread pass and fail.
  14. Pause (L6); blacklisted maker skip; trader only when CW20 sender is trusted router.
  15. HybridSimulation (greedy) == execute on non-expired book; query does not park.
  16. Hooks AfterSwap return_asset = book net + pool net (L7).
  17. Router hop: greedy op vs hybrid: null hop unchanged.
  18. Community-tax listed pair: extra-debit still on original trader for greedy sell.

Indexer / dApp (only if hook string or attrs change): parser still maps pool_leg_volume / book_leg_volume; gas map unit tests.


Test plan (attack, hack, abuse)

Vector Expect
A1 Wrong-side hint Attacker points at opposite book to drain or skip. L17: ignore, start at correct head. No cross-side debit.
A2 Stale hint into unlinked / cancelled id Head fallback; no revert that griefs the taker into retry loops unless slippage fails independently.
A3 Expired-prefix grief 500 expired heads without hint → scan cap, little/no fill, remainder pool (not OOG). Hint to first live order still fills.
A4 Worse-than-pool makers Malicious tight-looking then worse tail must not be taken by greedy (unlike book_input = offer).
A5 Sandwich / pool move mid-tx Single CosmWasm tx; comparison uses this tx’s reserves before pool leg. Mempool sandwich still max_spread / min_return. No extra MEV toggle.
A6 Zero-cost / dust price maker L18: skip cost == 0 && fill > 0; do not brick the walk.
A7 Overflow / absurd price legacy row Skip maker (continue), do not revert whole swap (L20).
A8 max_maker_fills grief (1 vs 10_000) Clamp 100; gas wanted from cap not from caller fantasy.
A9 Spoof trader Pair ignores trader unless sender is trusted router (existing). Greedy must not loosen this.
A10 Schema confusion Old JSON {pool_input, book_input, …} must not deserialize as greedy and silently ignore the split.
A11 Query vs execute drift Bot quotes greedy sim, executes Pattern C or pool-only → worse fill. Docs + distinct query/execute msgs. Tests: mismatched execute still user-signed (not a contract bug) but sim endpoint cannot be Simulation pool-only.
A12 Pause / blacklist during walk Same as hybrid: pause rejects swap; blacklisted makers skipped not paid.
A13 Re-entrancy / hooks AfterSwap still after settlement; hook cannot re-enter swap and mint extra book fills.
A14 Fee-tier grief Discount applies to both legs from authenticated trader; cannot self-set trader on pair-direct to steal a tier.

Verification criteria

  • make test-contracts (or scoped cargo test -p cl8y-dex-pair + cl8y-dex-tests greedy / hint / hybrid suites) green.
  • Explicit G1 regression: pool-only execute with a better resting order → book_return_amount absent / zero.
  • LocalTerra (or multitest equivalent): greedy swap emits limit_order_fill + positive book_return_amount when the book is better; pool-only control does not.
  • make verify-issue-<iid> covers message-shape grep, G1 comment, ADR/skill links, and the contract test names above.
  • No change to official dApp default (still GET /route/solve) unless a later ticket says so.
## Summary Add an **opt-in pair swap variant** that fills **greedy book-first-then-pool in-contract**: the caller supplies only `book_start_hint` + `max_maker_fills` (no `pool_input` / `book_input`). The pair walks the top of book while each live level **beats the pool's current marginal price**, capped at `max_maker_fills` (and existing scan/park caps), then routes the remainder to the AMM. This is the gas-efficient on-chain hybrid approved from [#704](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/704). It is **not** a change to the default `hybrid: None` path (that stays pool-only). Full split optimization stays off-chain ([#501](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/501) / indexer `GET /route/solve`). Sibling (docs now, no wasm): integrator guidance that direct `pair.swap` is pool-only — see the linked docs issue from [#704](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/704). --- ## Current codebase Pattern C is **caller-declared**. The pair never chooses a pool/book split. ### Pair execute [`smartcontracts/contracts/pair/src/contract.rs`](smartcontracts/contracts/pair/src/contract.rs) `execute_swap` (~L968): ```text hybrid: None → pool_leg = offer, book_leg = 0, max_makers = 0 // book skipped hybrid: Some → pool_input + book_input must equal offer ``` When `book_leg > 0`, the pair calls `orderbook::match_bids` or `match_asks` with the **declared** `book_input`, then rolls unfilled book remainder into the pool: `pool_input_amount = pool_leg + (book_leg − offer_consumed_by_book)` (~L1131). That roll-forward is **not** “stop when the next maker is worse than the pool.” A caller who sets `pool_input = 0`, `book_input = offer` will take **worse-than-pool** resting orders until the budget, `max_maker_fills`, or `MAX_SCAN_STEPS` is exhausted. [#307](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/307) also requires a material pool leg (or `belief_price` / `min_return`) for declared hybrid. ### Types [`smartcontracts/packages/dex-common/src/pair.rs`](smartcontracts/packages/dex-common/src/pair.rs) — `HybridSwapParams { pool_input, book_input, max_maker_fills, book_start_hint }`. Caps: `MAX_MAKER_FILLS_HARD_CAP = 100`, `MAX_SCAN_STEPS = 500`. `Cw20HookMsg::Swap.hybrid: Option<HybridSwapParams>`. Quotes: `HybridSimulation` / `HybridReverseSimulation` only (legacy `Simulation` removed, **L8** / [#190](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/190)). ### Book walk [`smartcontracts/contracts/pair/src/orderbook.rs`](smartcontracts/contracts/pair/src/orderbook.rs) — `match_bids` / `match_asks` / `simulate_match_*`. `book_start_hint` is permissionless; wrong-side / stale / missing → fall back to head (**L17** / [#272](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/272)). Do **not** auto-derive the hint on-chain (contrast insert-thread [#266](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/266)). Indexer `global_v2` already emits the first live same-side id when the mirror is fresh ([#332](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/work_items/332)). ### Router [`smartcontracts/contracts/router/src/contract.rs`](smartcontracts/contracts/router/src/contract.rs) — `SwapOperation::TerraSwap { hybrid: Option<HybridSwapParams> }`. `hybrid: None` maps to `pool_only_hybrid_params` for **quotes**; execute still pool-only on that hop. ### Off-chain best execution (already exists) - Indexer `GET /api/v1/route/solve` + `amount_in` = global path + joint hybrid grid ([#209](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/209), ADR 0002). Interior splits can beat naive book-first (~+0.07% / ~+0.4% on observed EMBER sizes in [#704](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/704) discussion). - Official dApp always submits solver hybrid ([#501](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/501), [#596](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/596)). Single-hop is still **pair-direct** `send→swap` with those params ([`swapRouting.ts`](frontend-dapp/src/services/terraclassic/swapRouting.ts)). ### Observed gap Bots that omit `hybrid` (columbus-5 CL8Y-cb/cLUNC ~85 swaps/day in [#704](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/704)) fill 100% against the pool even when the best ask is strictly better. Arb may correct **later**; that taker still overpays and the maker never sees the flow. --- ## Why this is needed Not every integrator can (or will) run the indexer solver. Asking them to invent `pool_input` / `book_input` is the hard part. A bounded greedy walk: - shrinks the caller job to “point at top-of-book + cap N makers” (one LCD / `order-book-head` / indexer hint); - keeps **cheap pool-only** as the default (`hybrid: None` unchanged); - reuses the existing matcher instead of a new on-chain optimizer; - is **opt-in**, so bots that want v2-only (and to pay arbs) keep today’s path. This is **not** global best execution. Greedy book-first can lose to an interior split. The indexer remains the price-optimal path. This variant is a **better-than-pool-only** floor for simple bots. --- ## Constraints / guardrails Invariants **G1–G14**. Do not ship a variant that cannot satisfy them. | ID | Rule | |----|------| | **G1 — default stays pool-only** | `hybrid: None` / omitted on `Cw20HookMsg::Swap` and router `TerraSwap` **must** remain pool-only (`book_leg = 0`). No silent book walk on TerraSwap-compatible callers. | | **G2 — no caller split** | The new entry point **must not** take `pool_input` / `book_input`. Caller fields: offer (CW20 `amount`), `max_maker_fills`, optional `book_start_hint`, plus existing slippage (`belief_price` / `max_spread` / `min_return`), `to`, `deadline`, `trader`. | | **G3 — greedy stop** | Walk live same-side orders from the resolved hint (else head). Fill a level only while that maker’s **executable** price **beats** the pool’s **current residual** marginal (after pair `effective_fee_bps`). Next worse-or-equal level → stop book, remainder → pool. Empty book / all worse → 100% pool. Design must pin “beats” (gross vs net of taker commission; 1-unit marginal vs this order’s fill). | | **G4 — not the solver** | Do **not** search interior splits, grids, or multi-hop on-chain. Do **not** claim indexer optimality. Docs must say greedy ≤ solver, greedy ≥ pool-only when the book is strictly better (modulo fees/dust). | | **G5 — caps unchanged** | `max_maker_fills` clamped with `MAX_MAKER_FILLS_HARD_CAP` (100). Walk still bounded by `MAX_SCAN_STEPS` (500) and `MAX_EXPIRED_PARKS_PER_SWAP` (15). `max_maker_fills == 0` → reject (or treat as pool-only — pick one, test it). Oversize fills clamp, do not panic. | | **G6 — hint stays caller-supplied** | Do **not** auto-walk/derive `book_start_hint` in the pair (no [#266](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/266) insert-thread on swap). Missing/stale/wrong-side → head fallback (**L17**). Integrators should pass first **live** same-side id ([#289](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/work_items/289) expired prefix). | | **G7 — quote = execute (L8)** | A matching **query** (extend `HybridSimulation` or a sibling) must use the **same** greedy rule as execute. Queries remain read-only (no parks). Execute may still park expired/dust; document sim vs exec drift for expired heads. | | **G8 — slippage still gates the book** | Book contact requires `belief_price` **or** `min_return` (same family as [#307](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/307) / [#334](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/work_items/334)). Do not invent a path that walks the book with neither. `max_spread` **L9** applies to the **realized** pool+book total. | | **G9 — remainder is the only pool leg** | After the greedy stop, **all** leftover offer goes to the constant-product pool (including book shortfall). k-invariant, fee, TWAP-before-reserves, treasury commission unchanged. | | **G10 — reuse matcher** | Fill, FIFO, maker payouts, taker commission, dust flush (**L16**), blacklist, pause (**L6**), fee-tier `trader` (router-stamped only), community-tax extra-debit (**T592-13**) stay on the existing `match_bids` / `match_asks` path. Do not fork a second matcher. | | **G11 — opt-in surface** | Prefer a **new serde shape** (enum variant or dedicated params struct) so old `HybridSwapParams` JSON cannot be misread as greedy. Do **not** overload `pool_input=0, book_input=offer` (that already means “take book even if worse”). | | **G12 — router optional, explicit** | Router may forward the new shape per hop. `hybrid: null` stays pool-only. Do not auto-upgrade router hops to greedy. Official dApp does **not** have to switch off the solver. | | **G13 — gas** | Worst-case envelope stays within `HYBRID_SWAP_GAS_LIMIT` (15M) and existing book-walk overhead. Map `getGasLimitForTx` / swarm gas if a new send-hook string appears ([#475](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/475)). Unmapped hooks must **throw**, not silent 600k. | | **G14 — migrate, don’t break TerraSwap** | Pair wasm migrate (factory `pair_code_id`). No instantiate-msg break. Existing Pattern C execute/sim tests must stay green. Metric: share of pair-direct txs with `book_leg_volume > 0` after adoption (indexer columns already exist). | **Out of scope:** changing `hybrid: None`; forcing greedy on the official UI; on-chain split optimizer; auto hint from storage; other-DEX hops ([#690](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/690)). --- ## Relevant files **Contracts:** `smartcontracts/packages/dex-common/src/pair.rs` · `smartcontracts/contracts/pair/src/{contract,orderbook,error,lib}.rs` · `smartcontracts/packages/dex-common/src/max_spread.rs` · `smartcontracts/contracts/router/src/{msg,contract}.rs` · `smartcontracts/tests/src/limit_order_tests.rs` (+ hybrid / hint tests) **Indexer (optional follow-through, same issue if small):** `indexer/src/api/{route_solver,hybrid_route_opt}.rs` — do **not** replace GET best-execution with greedy; optional document/flag only. **Frontend:** no retail requirement. If a new hook string exists, gas maps in `frontend-dapp/src/services/terraclassic/{terraGas,hybridSwapGas}.ts`. **Docs / skills:** `docs/limit-orders.md` · `docs/integrators.md` · `docs/contracts-security-audit.md` (new G* / L* row) · `docs/adr/0001-hybrid-quoting-and-routing.md` · `skills/AGENTS_HYBRID_QUOTING.md` · `skills/AGENTS_BOOK_MATCH_HINT_SECURITY.md` · `skills/AGENTS_TERRACLASSIC_GAS.md` **Related:** [#704](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/704) (parent) · [#501](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/501) / [#596](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/596) (solver UI) · [#262](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/262) / [#254](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/254) (caps) · [#272](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/272) (hint side) · [#307](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/307) (pool floor) · [#190](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/190) (L8) --- ## Recommended direction 1. **Design spike (must land in the MR description / ADR note before wasm):** - Message shape: `Cw20HookMsg` sibling vs `enum Hybrid { Declared(HybridSwapParams), Greedy { max_maker_fills, book_start_hint } }`. - Exact “beats pool” inequality (price units = token1 per token0; include taker half-fee or not; compare next maker vs **residual** offer’s 1-unit pool out). - When the last taken maker is only **partially** better (level size vs residual). - Sim query name and fields (`makers_used`, `offer_consumed_by_book`, stop reason). 2. **Implement greedy as a pre-pass** that computes `book_input_effective` / stop, then calls existing `match_*` with that budget — avoid duplicating fill accounting. 3. **Stop reasons** as wasm attrs (e.g. `greedy_stop=worse_than_pool|max_makers|scan_cap|empty|filled`) for indexer/debug. 4. **Router:** optional `greedy` on `TerraSwap`; null remains pool-only. 5. **Do not** switch Swap/Trade off GET `/route/solve`. 6. **Migrate** pair code id via existing factory upgrade scripts; LocalTerra + one columbus-5 canary pair after tests. 7. **Docs** in this ticket: contract semantics + L8. Integrator “use the solver” banner is the sibling docs issue (can merge first). --- ## Acceptance criteria - [ ] `hybrid: None` still never reads the book (regression tests). - [ ] New variant rejects `pool_input` / `book_input` (not in schema, or explicit error if a confused payload is sent). - [ ] Resting ask (or bid) **strictly better** than pool → greedy consumes that liquidity up to residual / `max_maker_fills`; remainder goes to pool; taker `return_amount` **≥** pool-only on the same offer (after fees; equality only if book not used). - [ ] Resting orders **worse** than pool → **zero** book fills; 100% pool (same as `hybrid: None` economically). - [ ] Mixed book: takes prefix of better levels, **stops** before the first worse level (does not drain a worse tail). - [ ] `max_maker_fills` / hard cap / `MAX_SCAN_STEPS` / park cap bound the walk; OOG not used as the bound. - [ ] Wrong-side / missing hint → head fallback, no revert (**L17**). - [ ] Query greedy sim matches execute on a live book with **no** expired prefix (L8). Expired-head drift documented. - [ ] Slippage: greedy with book contact and neither `belief_price` nor `min_return` reverts. - [ ] Pause / blacklist / fee discount / tax extra-debit / hooks **L7** behave as Pattern C hybrid. - [ ] Pattern C declared splits still work (interior `pool_input`/`book_input` unchanged). - [ ] Gas mapped for the new hook string; swarm/dApp unmapped send throws. - [ ] ADR / limit-orders / security-audit row; `make verify-issue-<iid>`. --- ## Test plan (functional paths) **Pair multitest (`cl8y-dex-pair` / `cl8y-dex-tests`):** 1. `hybrid: None` + live better ask → still pool-only (G1). 2. Greedy + empty book → pool-only, no fill events. 3. Greedy + one better ask, residual after fill → book then pool; k holds; treasury fees = pool commission + book taker half. 4. Greedy + only worse asks → zero book, pool-only amounts. 5. Greedy + better prefix then worse tail → fills prefix only. 6. Greedy vs declared `book_input = offer` on a mixed book: declared may take worse tail; greedy must not. 7. Both sides: token0 in → `match_bids`; token1 in → `match_asks`. 8. `max_maker_fills = 1` with three better makers → one fill, rest pool. 9. `max_maker_fills = 0` and `> 100` (clamp vs reject — as designed). 10. `book_start_hint` live same-side; wrong-side; stale; `None`. 11. Expired head prefix + hint past clog vs hint `None` hitting scan cap ([#289](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/work_items/289)). 12. Partial fill of a better level; dust flush; FIFO same price. 13. Slippage: `min_return` / `belief_price` / `max_spread` pass and fail. 14. Pause (L6); blacklisted maker skip; `trader` only when CW20 sender is trusted router. 15. HybridSimulation (greedy) == execute on non-expired book; query does not park. 16. Hooks AfterSwap `return_asset` = book net + pool net (L7). 17. Router hop: greedy op vs `hybrid: null` hop unchanged. 18. Community-tax listed pair: extra-debit still on original trader for greedy sell. **Indexer / dApp (only if hook string or attrs change):** parser still maps `pool_leg_volume` / `book_leg_volume`; gas map unit tests. --- ## Test plan (attack, hack, abuse) | Vector | Expect | |--------|--------| | **A1 Wrong-side hint** | Attacker points at opposite book to drain or skip. **L17:** ignore, start at correct head. No cross-side debit. | | **A2 Stale hint into unlinked / cancelled id** | Head fallback; no revert that griefs the taker into retry loops unless slippage fails independently. | | **A3 Expired-prefix grief** | 500 expired heads without hint → scan cap, little/no fill, remainder pool (not OOG). Hint to first live order still fills. | | **A4 Worse-than-pool makers** | Malicious tight-looking then worse tail must **not** be taken by greedy (unlike `book_input = offer`). | | **A5 Sandwich / pool move mid-tx** | Single CosmWasm tx; comparison uses **this tx’s** reserves before pool leg. Mempool sandwich still `max_spread` / `min_return`. No extra MEV toggle. | | **A6 Zero-cost / dust price maker** | **L18:** skip `cost == 0 && fill > 0`; do not brick the walk. | | **A7 Overflow / absurd price legacy row** | Skip maker (`continue`), do not revert whole swap (**L20**). | | **A8 `max_maker_fills` grief (1 vs 10_000)** | Clamp 100; gas wanted from cap not from caller fantasy. | | **A9 Spoof `trader`** | Pair ignores `trader` unless sender is trusted router (existing). Greedy must not loosen this. | | **A10 Schema confusion** | Old JSON `{pool_input, book_input, …}` must not deserialize as greedy and silently ignore the split. | | **A11 Query vs execute drift** | Bot quotes greedy sim, executes Pattern C or pool-only → worse fill. Docs + distinct query/execute msgs. Tests: mismatched execute still user-signed (not a contract bug) but sim endpoint cannot be `Simulation` pool-only. | | **A12 Pause / blacklist during walk** | Same as hybrid: pause rejects swap; blacklisted makers skipped not paid. | | **A13 Re-entrancy / hooks** | AfterSwap still after settlement; hook cannot re-enter swap and mint extra book fills. | | **A14 Fee-tier grief** | Discount applies to both legs from authenticated trader; cannot self-set `trader` on pair-direct to steal a tier. | --- ## Verification criteria - [ ] `make test-contracts` (or scoped `cargo test -p cl8y-dex-pair` + `cl8y-dex-tests` greedy / hint / hybrid suites) green. - [ ] Explicit G1 regression: pool-only execute with a better resting order → `book_return_amount` absent / zero. - [ ] LocalTerra (or multitest equivalent): greedy swap emits `limit_order_fill` + positive `book_return_amount` when the book is better; pool-only control does not. - [ ] `make verify-issue-<iid>` covers message-shape grep, G1 comment, ADR/skill links, and the contract test names above. - [ ] No change to official dApp default (still GET `/route/solve`) unless a later ticket says so.
PlasticDigits commented 2026-08-30 05:24:16 +00:00 (Migrated from gitlab.com)

marked as related to #704

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

marked as related to #707

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

mentioned in issue #704

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

mentioned in issue #707

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

Sibling docs/awareness (can merge first, no wasm): #707. Parent: #704.

Sibling docs/awareness (can merge first, no wasm): #707. Parent: #704.
PlasticDigits commented 2026-08-30 05:30:30 +00:00 (Migrated from gitlab.com)

Should be opt-out instead of opt-in, so by default bots that are doing normal swaps use this unless they have a reason not to, as its a sensible default.

Should be opt-out instead of opt-in, so by default bots that are doing normal swaps use this unless they have a reason not to, as its a sensible default.
PlasticDigits commented 2026-08-30 06:38:56 +00:00 (Migrated from gitlab.com)

mentioned in commit e3ab31400a

mentioned in commit e3ab31400a824f7e02feea8beb2266ebfb8eb792
PlasticDigits commented 2026-08-30 06:40:17 +00:00 (Migrated from gitlab.com)

mentioned in merge request !1198

mentioned in merge request !1198
PlasticDigits commented 2026-08-30 10:20:08 +00:00 (Migrated from gitlab.com)

mentioned in issue #709

mentioned in issue #709
PlasticDigits commented 2026-08-30 10:20:09 +00:00 (Migrated from gitlab.com)

marked as related to #709

marked as related to #709
PlasticDigits commented 2026-08-30 10:20:11 +00:00 (Migrated from gitlab.com)

mentioned in issue #710

mentioned in issue #710
PlasticDigits commented 2026-08-30 10:20:12 +00:00 (Migrated from gitlab.com)

marked as related to #710

marked as related to #710
PlasticDigits commented 2026-08-31 05:07:33 +00:00 (Migrated from gitlab.com)

mentioned in commit 56e4c4f513

mentioned in commit 56e4c4f51339b9bc20422a7783bf94af748d7044
PlasticDigits commented 2026-08-31 05:07:56 +00:00 (Migrated from gitlab.com)

mentioned in commit f54de4fd4b

mentioned in commit f54de4fd4b2373b8f482791c620ff34e6d47d6fa
PlasticDigits (Migrated from gitlab.com) closed this issue 2026-08-31 05:07:57 +00:00
PlasticDigits commented 2026-08-31 05:09:17 +00:00 (Migrated from gitlab.com)

mentioned in issue #712

mentioned in issue #712
PlasticDigits commented 2026-08-31 05:09:18 +00:00 (Migrated from gitlab.com)

marked as related to #712

marked as related to #712
PlasticDigits commented 2026-08-31 05:09:36 +00:00 (Migrated from gitlab.com)

!1198 merged to main (f54de4fd; conflict resolution 56e4c4f5).

Merge-time AC check vs this issue:

Criterion Verdict
G1 hybrid: None never reads the book PASS (g1_pool_only_skips_better_bid)
No pool_input / book_input on greedy PASS
Better book → fill + remainder; return_amount ≥ pool-only PARTIAL — fill + remainder_to_pool tested; no named ≥ pool-only / k / treasury = pool + book half assert
Worse book → 100% pool PASS
Mixed prefix, stop before worse tail PASS
Caps; OOG not the bound PARTIAL — fills=1 / 0 / clamp; no greedy-specific MAX_SCAN_STEPS / park-cap emit test
Wrong-side / missing hint → head PASS (wrong-side + None; live same-side stale hint still should-fix)
Quote = execute; expired-head drift documented PASS (greedy_simulation_matches_execute; ADR L8 note). Query mutex was the #709 fix.
G8 neither floor reverts PASS
Pause / blacklist / fee-tier / tax extra-debit / AfterSwap L7 PASS via #710 tests
Pattern C still works PASS
Gas mapped PARTIAL — dApp vitest + swarm map; make verify-issue-708 gas vitest needs frontend-dapp/node_modules
ADR / skill / make verify-issue-708 PASS (wiring)
LocalTerra / columbus-5 pair migrate (G14) MISSING — ops follow-up

Do not reopen this issue for ops. Tracking leftover: #712.

!1198 merged to `main` (`f54de4fd`; conflict resolution `56e4c4f5`). Merge-time AC check vs this issue: | Criterion | Verdict | |-----------|---------| | G1 `hybrid: None` never reads the book | PASS (`g1_pool_only_skips_better_bid`) | | No `pool_input` / `book_input` on greedy | PASS | | Better book → fill + remainder; `return_amount` ≥ pool-only | PARTIAL — fill + `remainder_to_pool` tested; no named ≥ pool-only / k / treasury = pool + book half assert | | Worse book → 100% pool | PASS | | Mixed prefix, stop before worse tail | PASS | | Caps; OOG not the bound | PARTIAL — fills=1 / 0 / clamp; no greedy-specific `MAX_SCAN_STEPS` / park-cap emit test | | Wrong-side / missing hint → head | PASS (wrong-side + `None`; live same-side stale hint still should-fix) | | Quote = execute; expired-head drift documented | PASS (`greedy_simulation_matches_execute`; ADR L8 note). Query mutex was the #709 fix. | | G8 neither floor reverts | PASS | | Pause / blacklist / fee-tier / tax extra-debit / AfterSwap L7 | PASS via #710 tests | | Pattern C still works | PASS | | Gas mapped | PARTIAL — dApp vitest + swarm map; `make verify-issue-708` gas vitest needs `frontend-dapp/node_modules` | | ADR / skill / `make verify-issue-708` | PASS (wiring) | | LocalTerra / columbus-5 pair migrate (**G14**) | MISSING — ops follow-up | Do **not** reopen this issue for ops. Tracking leftover: #712.
PlasticDigits commented 2026-09-01 08:15:55 +00:00 (Migrated from gitlab.com)

mentioned in issue #718

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