design: research parallel split routing (amount split across paths) #1203

Open
opened 2026-09-04 06:54:14 +00:00 by PlasticDigits · 5 comments

Summary

Investigate aggregator-style split routing: splitting one offer amount across two or more independent paths in a single settlement (for example 70% A→B and 30% A→C→B), versus today’s single-winner path.

This is a research + architecture spike. Do not ship a new CosmWasm message, indexer solver_version, retail Swap UI, or columbus-5 migrate in this work item. The deliverable is a written recommendation: go / no-go / later, with search complexity, execute architecture, gas, tax / wrap / greedy interactions, and a threat model.

Not this ticket (related, do not reopen or expand):

Topic Why it is not a duplicate
Per-hop pool / book split (pool_input + book_input) Already shipped: #209, #501, ADR 0002. That is a split inside one pair, not across paths.
Greedy book-first #708 shipped; leftover default-on is #718. Still one hop, one remainder-to-pool.
Pair-direct pool-only / integrator docs #704, #707.
Other-DEX v2 hops + IBC USDC intermediate #690, #691. Extra edges, still one winner path.
Community-tax SKU split_router (retail Split treasury) Tax sink split (treasury / burn / AutoLP / wallet). Unrelated to Swap routing.
Frontend lazy route code-splitting #179 — Vite chunks, not DEX paths.

Docs already name the gap without a ticket: route-solver.md Future work and Non-goals (top-5 paths, no convex split search across paths). #310 mentioned “convex split search” as later algorithm work.

Industry references for the research note (explanatory, not a mandate to copy): 1inch / 0x / Uniswap smart-order routing (amount split across pools), Jupiter split routes. CL8Y also has a hybrid AMM + FIFO book per hop, community-tax net ranking (#615), and Terra Classic gas that already bites multi-hop (#681, #587). Those constraints are first-class in the write-up.


Current codebase

Solver picks one path

GET /api/v1/route/solve with amount_in runs global best execution (ADR 0002, route-solver.md):

  1. Enumerate up to 5 simple paths, hop-count first — route_paths::find_paths_top_k, MAX_PATH_CANDIDATES = 5.
  2. For each path, jointly optimize per-hop book_input on a 17-point grid + 2 coordinate-descent passes (hybrid_route_opt.rs).
  3. Keep the path with max estimated_amount_out_net (#615); estimated_amount_out stays pre-tax raw_out.
  4. Return one hops[] / router_operations[] / intermediate_tokens[].

OPTIMALITY_SCOPE (best_execution.rs):

optimal within top-5 simple paths by hop count and per-hop hybrid split grid (17 book fractions), with 2-pass coordinate refinement across hops

Losing paths are discarded. There is no amount allocation across two winners. Alternate paths are compared only as whole-offer candidates.

Regression that encodes winner-takes-all: route_solve_global_picks_best_path_not_shortest in indexer/tests/api_route_solve.rs.

Discovery GET (no amount_in) and POST /route/solve still use first BFS path only. POST hybrid_by_hop overrides the pool/book split on that one path.

Router execute is one sequential chain

smartcontracts/contracts/router/src/msg.rs ExecuteSwapOperations { operations, max_spread, minimum_receive, … } is TerraSwap-shaped: one CW20 Send hook, ≤ MAX_HOPS (4) sequential hops. Each hop’s offer is the balance-delta of the previous ask (router/src/contract.rs, hop accounting skill). There is no “fan-out 40% / 60% then join” message.

Direct 1-hop retail still goes pair.swap, not the router (swapRouting.ts, #249).

SimulateSwapOperations prices one operation list. A parallel split would need N sims plus a join, or a new query.

Frontend assumes one route

  • Quote: cw20RouteSolveQuote.ts maps one router_operations array.
  • Display vs submit: swapRouteDisplay.ts reconciles intermediate_tokens with that single ops path (#450 / SEC-I02 H09). A split quote would be a new spoofing surface if any displayed path disagreed with any submitted leg.
  • Gas: hop-linear (SWAP_GAS_PER_HOP, ROUTER_SWAP_OPS_MIN_GAS_PER_HOP, hybrid maker/scan — constants.ts, hybridSwapGas.ts). Auto-gas already overshoots on hybrid multi-hop (#681). Two full routes in one tx (or two msgs) is a new gas model.
  • Multi-msg exists (executeTerraContractMulti) for wrap+router, pay-invoice, LP rollback — not for splitting one CW20 offer across two router hooks. CW20 Send delivers the full balance to one contract per message.

Search cost today (before any path-split)

Worst-case pair sims: LCD_HYBRID_SIM_BUDGET = 5 × 4 × 85 = 1700 (best_execution.rs). /route/solve is LCD-heavy (10 RPS/IP). Distant solves were a hang class (#484, #485). Naive “split fractions × path pairs × hybrid grid” multiplies that budget.


Why the work is needed

Constant-product impact is convex: two half-size fills on disjoint liquidity can beat one full-size fill on the single best path. That is the whole point of aggregator split routing. CL8Y already enumerates up to five paths and then throws away all but one, so the topology work is paid and the convex split is never tried.

When two factory pairs share legs (or a direct pair plus a 2-hop via a deep hub), large retail size can walk one AMM + book while a second venue stays unused. Makers on the unused pair see no flow; takers eat extra impact. That is the same incentive story as #501 / #704, one layer up (across paths, not pool vs book).

Without a researched no-go, the next implementer is likely to:

  • Multiply the hybrid grid by split fractions and blow the 45s quote / 10 RPS budget.
  • Paste two ExecuteSwapOperations and strand CW20 in the router on partial fail.
  • Show one hop row while signing two paths (replay of #450).
  • Rank on estimated_amount_out_net incorrectly when buy-tax applies to only one leg (#615).

This spike exists so the next ticket is either a bounded algorithm + execute design, or an explicit do not ship with the convex-search line in route-solver.md updated.


Constraints / guardrails

  • Research only. No pair/router migrate, no new solver_version, no Swap UI, no indexer API additive fields on columbus-5. Simulations stay in /tmp or LocalTerra; do not change production quoting.
  • Do not confuse terms. In the write-up, use path split (this ticket), hybrid split (pool/book on one hop), greedy (book-first remainder-to-pool), tax Split treasury (SKU). Never call tax sinks “split routing.”
  • Solver stays advisory. Execute-time max_spread / minimum_receive / per-hop min_return remain authoritative (ADR 0001). A split quote is still a snapshot.
  • Hop cap. On-chain MAX_HOPS = 4 is per operation list, not a global “8 hops if you run two routers.” Any execute design must state hop accounting.
  • Factory graph only unless explicitly analyzing #690 as a future interaction. Do not assume foreign v2 pairs are executable today.
  • Tax. #615: rank net; min_return uses raw; Option-2 wasm skips middle-hop sells of catalogued tax tokens. Unmigrated 11611 Honest. A path split that sells a tax token on one branch and not the other must not hide extra debit.
  • Gems / freeze / blacklist / pause. Production hide (#562), F6 freeze 404, blacklist, pause — every candidate leg, not only the displayed winner.
  • Wrap. Native LUNC/USTC never enter the router; wrap is a separate msg. Split + wrap must not double-burn or skip mapper fees (#516).
  • Do not treat executeTerraContractMulti as a free splitter: two CW20 Sends need two balances (or an on-chain splitter). Partial fill (path A succeeds, path B max_spread) is a user-funds design choice — all-or-nothing vs best-effort must be explicit.
  • Do not recommend unbounded K, continuous split search, or LCD fan-out that violates LCD_HYBRID_SIM_BUDGET / #485 p95 without a version bump plan.
  • Do not copy confidential third-party bot reviews into this note.
  • Do not choose hosts, images, SKUs, or models. No operational inventory in the issue comment.

Relevant files

Path Why
indexer/src/api/best_execution.rs Winner-takes-all; MAX_PATH_CANDIDATES, OPTIMALITY_SCOPE, sim budget
indexer/src/api/route_paths.rs Top-K simple paths; #286 reachability gate
indexer/src/api/hybrid_route_opt.rs Per-hop hybrid grid (not a path split)
indexer/src/api/route_solver.rs GET/POST matrix; cache; pool_only
docs/route-solver.md Non-goals, theory, future work (convex split named, not ticketed)
docs/adr/0002-global-best-execution-route-solver.md Decision: enumerate paths, pick max out
docs/adr/0001-hybrid-quoting-and-routing.md Hybrid vs greedy vs pool-only
skills/AGENTS_INDEXER_HYBRID_BEST_EXECUTION.md Integrator contract; picks_best_path_not_shortest
skills/AGENTS_INDEXER_TAX_AWARE_ROUTING.md Net ranking / middle-hop skip
smartcontracts/contracts/router/src/{msg,contract}.rs Sequential ops; MAX_HOPS; no fan-out
skills/AGENTS_ROUTER_HOP_ACCOUNTING.md Balance-delta hops
frontend-dapp/src/utils/cw20RouteSolveQuote.ts Single ops → quote/execute
frontend-dapp/src/utils/swapRouteDisplay.ts Display/submit path alignment
frontend-dapp/src/services/terraclassic/swapRouting.ts Direct pair vs router
frontend-dapp/src/services/terraclassic/transactions.ts Multi-msg (not a splitter)
frontend-dapp/src/utils/constants.ts + hybridSwapGas.ts Gas model that would have to sum legs
indexer/tests/api_route_solve.rs Multi-path winner tests to extend if a later feat ships
smartcontracts/contracts/community-tax-token/src/tax.rs split_tax — do not mix into this design

  1. Define the product question in one sentence: “For a given (token_in, token_out, amount_in) snapshot, does splitting the offer across ≤K factory paths raise estimated_amount_out_net enough to justify extra gas, quote latency, and execute complexity?” Answer with numbers, not slogans.

  2. Measure first (LocalTerra or recorded mainnet mirrors, no prod change).

    • Pick markets where top-2 paths both have depth (direct + 2-hop hub, or two disjoint 2-hops).
    • For sizes across cache buckets (AMOUNT_CACHE_BUCKET = 1e6 raw), compare: (a) current winner 100%; (b) discrete splits {0, 25, 50, 75, 100}% on path1 vs path2, each path still running the existing hybrid grid; (c) greedy-only vs Pattern C on each leg.
    • Report bps improvement vs extra hop-gas (use SWAP_GAS_PER_HOP / hybrid maker gas, not a guessed fee).
    • Repeat with a community-tax token_out (net vs raw) and with an empty book on one path (#493 short-circuit).
  3. Execute architectures to rank (go / no-go per option):

    • A. Off-chain only (no wasm): two msgs in one Cosmos tx. Requires a splitter or two CW20 balances. Atomicity: Cosmos tx rolls back all msgs on fail — good for all-or-nothing; still need both legs to simulate. Gas = sum. Likely no-go without a splitter contract because one Send cannot fork.
    • B. Router ExecuteSplitOperations (new wasm): one hook, N sequential sub-chains with explicit offer_portion, join on token_out, one minimum_receive on the sum. Needs migrate, hop-cap policy, dust/remainder rules, blacklist per hop. Highest product fidelity; highest review cost.
    • C. Quote-only / integrator POST: indexer returns legs[] with portions; clients who already split off-chain can POST. Official dApp stays single-path until B exists. Documents the math without retail risk.
    • D. No-go: keep winner-takes-all; document that “convex split search” remains out of scope because gas + sequential router + tax net do not pay back on observed sizes. Update route-solver.md Future work with this ticket’s conclusion.
  4. If any option is go, bound the search before writing code. Suggested default to disprove or confirm, not to ship: at most 2 paths, split grid 5 fractions, each path reuses current hybrid optimizer (no nested 17×17). New budget must fit #485 and LCD-heavy RPS. Bump solver_version only in a follow-up feat.

  5. UI / security (only if B or C is recommended for a later feat): one Share/quote surface must list all legs; H09-style alignment on every ops list; never display path A and sign path B+C leftovers. Silent fail-closed on freeze/gem/hostile ids (#489 lecture ban still applies).

  6. Write the note on this issue (research gate): findings, recommendation, further study, tradeoffs, alternatives, then raw evidence (links, tables, /tmp sim commands). Do not implement product code in that gate.


Acceptance criteria

  • AC1. Written research note on this issue covering: current single-winner solver, convex split hypothesis, measurement table (size × path split × hybrid vs winner-only), gas vs bps, tax/wrap/greedy interactions, execute options A–D, go / no-go / later.
  • AC2. Explicit not-duplicates vs hybrid split, greedy, #690, tax Split treasury.
  • AC3. Search-complexity bound: what a 2-path × F-fraction solver would do to LCD_HYBRID_SIM_BUDGET and quote p95; whether empty-book short-circuit (#493) still holds.
  • AC4. Execute atomicity: what happens if leg 2 hits max_spread / pause / blacklist after leg 1 would have filled; recommended all-or-nothing vs best-effort.
  • AC5. If no-go: patch recommendation for docs/route-solver.md Future work / Non-goals (follow-up docs ticket OK; do not silently leave “convex split search” as implied upcoming work).
  • AC6. If go: a separate follow-up feat issue is enough to start implementation; this ticket stays research (no wasm in this MR).
  • AC7. No production indexer/API/UI/contract change in the research MR. Simulations reproducible from the note.

Test plan (research + later-feat paths)

Research gate (this ticket):

# Path Expect
R1 Winner-only vs 50/50 on a two-path LocalTerra graph with convex impact Table of raw_out / net_out; document if split wins
R2 Split 0/100 and 100/0 Matches current single-path optimizer (sanity)
R3 Empty book on path 2 Path 2 prices pool-only once (#493); no 17× blow-up
R4 Community-tax token_out Rank uses net; min_return discussion uses raw
R5 Middle-hop tax sell on one candidate (Option-2) That path skipped, not used as a split leg
R6 Frozen / gem / unlisted id on one leg Leg ineligible; do not recommend routing through it
R7 Wrap-in then split Mapper fee once; no native in router
R8 Gas model: 1-hop winner vs 1-hop+2-hop split Hop-sum vs SWAP_GAS_PER_HOP / hybrid maker; call out #681-class overshoot
R9 Quote latency: current GET vs hypothetical 2×5 extra path evals Compare to #485 <15s distant target
R10 POST hybrid_by_hop unchanged Research does not require POST to grow legs[] unless option C

If a later feat ships (not this ticket), functional paths to require then:

# Path Expect
T1 Direct pair still 1-hop pair.swap when split not beneficial No router for a 100% direct winner
T2 Split quote JSON lists every leg + portions summing to amount_in No silent remainder
T3 Display tokens aligned with all signed ops H09 for each leg
T4 minimum_receive on sum of legs (architecture B) Cannot drain via one cheap leg
T5 pool_only=true Still single BFS path; no split
T6 Existing make verify-issue-209 / 501 / 615 / 485 stay green Winner-only regressions remain

Test plan (attack, hack, and abuse)

# Vector Expect
A1 Display one path, sign two (indexer spoof) Reject unless every displayed leg ⊆ signed ops (extend #450)
A2 Split portions not summing to offer (dust siphon / inflation) Reject at quote parse and on-chain
A3 Hostile token_in / javascript: / overlong bech32 in a second leg Ignore; factory ids only
A4 Gem / showGems on a hidden split leg (production) Not quoted, not signed
A5 Path explosion DoS (K paths × F splits × 17 grid) Hard caps; 429 on LCD-heavy; no unbounded nested grid
A6 Cache key omits split shape Distinct keys per portion schedule; no cross-tier poison (#283)
A7 Rank on raw while one leg is buy-taxed Net ranking; raw for floors
A8 First msg succeeds, second fails in a non-atomic client split Forbidden unless architecture explicitly chooses best-effort and user copy says so; default all-or-nothing
A9 Router leftover CW20 from a failed join Balance-delta / sweep rules; no silent router custody
A10 max_maker_fills=2^32-1 on each split leg Clamp 100 per hop (#379) per leg
A11 Sandwich / MEV: split makes two observable hops Advisory quote; min_receive on sum; no “MEV-aware” claim
A12 Blacklist/pause between quote and execute on one leg Whole split fails closed (or documented best-effort)
A13 Open-redirect / WC URI stuffed into a new legs query URLSearchParams only; never share raw search
A14 Treating tax Split treasury SKU as a routing feature Docs/tests must not conflate
A15 Using split routing to bypass Expert / 5% / 30% / 99% / #678 size gates Gates apply to full offer, not per-leg dust

Verification criteria

  • Research note posted on this issue (findings + recommendation + raw evidence).
  • Measurement table can be re-run from documented commands (LocalTerra swarm or indexer unit graph + /tmp sim). No prod config change.
  • Terminology audit: the note never calls hybrid pool/book or tax sinks “split routing.”
  • If no-go: linked docs follow-up or in-note exact route-solver.md edits for Future work.
  • If go: child feat issue with wasm/API/UI scope; this issue stays closed as research once the note lands.
  • Existing route-solver drift guard remains the bar for any later constant change: python3 scripts/check_route_solver_docs.py.

Out of scope

  • Implementing split execute or a new solver_version.
  • Expanding #718 greedy default, #690 foreign hops, or indexer exact-out /route/solve.
  • Changing community-tax Sku::SplitRouter.
  • MEV protection, UniswapX-style auctions, or intent solvers.
  • Yen/Eppstein full K-shortest as a deliverable (may be cited as literature only).
  • Frontend lazy-route code splitting.
## Summary Investigate **aggregator-style split routing**: splitting one offer amount across **two or more independent paths** in a single settlement (for example 70% A→B and 30% A→C→B), versus today’s **single-winner** path. This is a **research + architecture spike**. Do **not** ship a new CosmWasm message, indexer `solver_version`, retail Swap UI, or columbus-5 migrate in this work item. The deliverable is a written recommendation: **go / no-go / later**, with **search complexity**, **execute architecture**, **gas**, **tax / wrap / greedy interactions**, and a **threat model**. **Not this ticket (related, do not reopen or expand):** | Topic | Why it is not a duplicate | |-------|---------------------------| | Per-hop **pool / book** split (`pool_input` + `book_input`) | Already shipped: [#209](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/209), [#501](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/501), ADR 0002. That is a split **inside one pair**, not across paths. | | **Greedy book-first** | [#708](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/708) shipped; leftover default-on is [#718](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/718). Still one hop, one remainder-to-pool. | | Pair-direct pool-only / integrator docs | [#704](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/704), [#707](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/707). | | Other-DEX v2 hops + IBC USDC intermediate | [#690](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/690), [#691](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/691). Extra edges, still one winner path. | | Community-tax SKU `split_router` (retail **Split treasury**) | Tax **sink** split (treasury / burn / AutoLP / wallet). Unrelated to Swap routing. | | Frontend **lazy route** code-splitting | [#179](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/179) — Vite chunks, not DEX paths. | Docs already name the gap without a ticket: [route-solver.md](docs/route-solver.md) **Future work** and **Non-goals** (top-5 paths, no convex split search across paths). [#310](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/310) mentioned “convex split search” as later algorithm work. Industry references for the research note (explanatory, not a mandate to copy): 1inch / 0x / Uniswap smart-order routing (amount split across pools), Jupiter split routes. CL8Y also has a **hybrid AMM + FIFO book** per hop, community-tax net ranking ([#615](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/615)), and Terra Classic **gas** that already bites multi-hop ([#681](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/681), [#587](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/587)). Those constraints are first-class in the write-up. --- ## Current codebase ### Solver picks one path [`GET /api/v1/route/solve`](indexer/src/api/route_solver.rs) with `amount_in` runs **global best execution** ([ADR 0002](docs/adr/0002-global-best-execution-route-solver.md), [route-solver.md](docs/route-solver.md)): 1. Enumerate up to **5** simple paths, hop-count first — [`route_paths::find_paths_top_k`](indexer/src/api/route_paths.rs), `MAX_PATH_CANDIDATES = 5`. 2. For **each** path, jointly optimize **per-hop** `book_input` on a 17-point grid + 2 coordinate-descent passes ([`hybrid_route_opt.rs`](indexer/src/api/hybrid_route_opt.rs)). 3. Keep the path with max **`estimated_amount_out_net`** ([#615](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/615)); `estimated_amount_out` stays pre-tax `raw_out`. 4. Return **one** `hops[]` / `router_operations[]` / `intermediate_tokens[]`. `OPTIMALITY_SCOPE` ([`best_execution.rs`](indexer/src/api/best_execution.rs)): > optimal within top-5 simple paths by hop count and per-hop hybrid split grid (17 book fractions), with 2-pass coordinate refinement across hops Losing paths are discarded. There is **no** amount allocation across two winners. Alternate paths are compared only as **whole-offer** candidates. Regression that encodes winner-takes-all: `route_solve_global_picks_best_path_not_shortest` in [`indexer/tests/api_route_solve.rs`](indexer/tests/api_route_solve.rs). Discovery GET (no `amount_in`) and **POST** `/route/solve` still use **first BFS path** only. POST `hybrid_by_hop` overrides the **pool/book** split on that one path. ### Router execute is one sequential chain [`smartcontracts/contracts/router/src/msg.rs`](smartcontracts/contracts/router/src/msg.rs) `ExecuteSwapOperations { operations, max_spread, minimum_receive, … }` is TerraSwap-shaped: **one** CW20 `Send` hook, **≤ `MAX_HOPS` (4)** sequential hops. Each hop’s offer is the **balance-delta** of the previous ask ([`router/src/contract.rs`](smartcontracts/contracts/router/src/contract.rs), hop accounting skill). There is **no** “fan-out 40% / 60% then join” message. Direct 1-hop retail still goes **pair.swap**, not the router ([`swapRouting.ts`](frontend-dapp/src/services/terraclassic/swapRouting.ts), [#249](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/249)). `SimulateSwapOperations` prices **one** operation list. A parallel split would need **N** sims plus a join, or a new query. ### Frontend assumes one route - Quote: [`cw20RouteSolveQuote.ts`](frontend-dapp/src/utils/cw20RouteSolveQuote.ts) maps **one** `router_operations` array. - Display vs submit: [`swapRouteDisplay.ts`](frontend-dapp/src/utils/swapRouteDisplay.ts) reconciles `intermediate_tokens` with that **single** ops path ([#450](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/450) / SEC-I02 H09). A split quote would be a new spoofing surface if any displayed path disagreed with any submitted leg. - Gas: hop-linear (`SWAP_GAS_PER_HOP`, `ROUTER_SWAP_OPS_MIN_GAS_PER_HOP`, hybrid maker/scan — [`constants.ts`](frontend-dapp/src/utils/constants.ts), [`hybridSwapGas.ts`](frontend-dapp/src/services/terraclassic/hybridSwapGas.ts)). Auto-gas already overshoots on hybrid multi-hop ([#681](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/681)). Two full routes in one tx (or two msgs) is a new gas model. - Multi-msg exists ([`executeTerraContractMulti`](frontend-dapp/src/services/terraclassic/transactions.ts)) for wrap+router, pay-invoice, LP rollback — **not** for splitting one CW20 offer across two router hooks. CW20 `Send` delivers the **full** balance to **one** contract per message. ### Search cost today (before any path-split) Worst-case pair sims: `LCD_HYBRID_SIM_BUDGET = 5 × 4 × 85 = 1700` ([`best_execution.rs`](indexer/src/api/best_execution.rs)). `/route/solve` is **LCD-heavy** (10 RPS/IP). Distant solves were a hang class ([#484](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/484), [#485](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/485)). Naive “split fractions × path pairs × hybrid grid” multiplies that budget. --- ## Why the work is needed Constant-product impact is **convex**: two half-size fills on **disjoint** liquidity can beat one full-size fill on the single best path. That is the whole point of aggregator split routing. CL8Y already **enumerates** up to five paths and then **throws away** all but one, so the topology work is paid and the convex split is never tried. When two factory pairs share legs (or a direct pair plus a 2-hop via a deep hub), large retail size can walk one AMM + book while a second venue stays unused. Makers on the unused pair see no flow; takers eat extra impact. That is the same **incentive** story as [#501](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/501) / [#704](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/704), one layer up (across paths, not pool vs book). Without a researched **no-go**, the next implementer is likely to: - Multiply the hybrid grid by split fractions and blow the 45s quote / 10 RPS budget. - Paste two `ExecuteSwapOperations` and strand CW20 in the router on partial fail. - Show one hop row while signing two paths (replay of [#450](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/450)). - Rank on `estimated_amount_out_net` incorrectly when buy-tax applies to only one leg ([#615](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/615)). This spike exists so the next ticket is either a bounded algorithm + execute design, or an explicit **do not ship** with the convex-search line in route-solver.md updated. --- ## Constraints / guardrails - **Research only.** No pair/router migrate, no new `solver_version`, no Swap UI, no indexer API additive fields on columbus-5. Simulations stay in `/tmp` or LocalTerra; do not change production quoting. - **Do not confuse terms.** In the write-up, use **path split** (this ticket), **hybrid split** (pool/book on one hop), **greedy** (book-first remainder-to-pool), **tax Split treasury** (SKU). Never call tax sinks “split routing.” - **Solver stays advisory.** Execute-time `max_spread` / `minimum_receive` / per-hop `min_return` remain authoritative ([ADR 0001](docs/adr/0001-hybrid-quoting-and-routing.md)). A split quote is still a snapshot. - **Hop cap.** On-chain `MAX_HOPS = 4` is per **operation list**, not a global “8 hops if you run two routers.” Any execute design must state hop accounting. - **Factory graph only** unless explicitly analyzing [#690](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/690) as a **future** interaction. Do not assume foreign v2 pairs are executable today. - **Tax.** [#615](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/615): rank net; `min_return` uses raw; Option-2 wasm skips **middle-hop sells** of catalogued tax tokens. Unmigrated **11611** Honest. A path split that sells a tax token on one branch and not the other must not hide extra debit. - **Gems / freeze / blacklist / pause.** Production hide ([#562](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/562)), F6 freeze 404, blacklist, pause — every candidate leg, not only the displayed winner. - **Wrap.** Native LUNC/USTC never enter the router; wrap is a separate msg. Split + wrap must not double-burn or skip mapper fees ([#516](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/516)). - **Do not** treat `executeTerraContractMulti` as a free splitter: two CW20 `Send`s need two balances (or an on-chain splitter). Partial fill (path A succeeds, path B `max_spread`) is a user-funds design choice — all-or-nothing vs best-effort must be explicit. - **Do not** recommend unbounded K, continuous split search, or LCD fan-out that violates `LCD_HYBRID_SIM_BUDGET` / `#485` p95 without a version bump plan. - **Do not** copy confidential third-party bot reviews into this note. - **Do not** choose hosts, images, SKUs, or models. No operational inventory in the issue comment. --- ## Relevant files | Path | Why | |------|-----| | `indexer/src/api/best_execution.rs` | Winner-takes-all; `MAX_PATH_CANDIDATES`, `OPTIMALITY_SCOPE`, sim budget | | `indexer/src/api/route_paths.rs` | Top-K simple paths; #286 reachability gate | | `indexer/src/api/hybrid_route_opt.rs` | Per-hop hybrid grid (not a path split) | | `indexer/src/api/route_solver.rs` | GET/POST matrix; cache; `pool_only` | | `docs/route-solver.md` | Non-goals, theory, future work (convex split named, not ticketed) | | `docs/adr/0002-global-best-execution-route-solver.md` | Decision: enumerate paths, pick max out | | `docs/adr/0001-hybrid-quoting-and-routing.md` | Hybrid vs greedy vs pool-only | | `skills/AGENTS_INDEXER_HYBRID_BEST_EXECUTION.md` | Integrator contract; `picks_best_path_not_shortest` | | `skills/AGENTS_INDEXER_TAX_AWARE_ROUTING.md` | Net ranking / middle-hop skip | | `smartcontracts/contracts/router/src/{msg,contract}.rs` | Sequential ops; `MAX_HOPS`; no fan-out | | `skills/AGENTS_ROUTER_HOP_ACCOUNTING.md` | Balance-delta hops | | `frontend-dapp/src/utils/cw20RouteSolveQuote.ts` | Single ops → quote/execute | | `frontend-dapp/src/utils/swapRouteDisplay.ts` | Display/submit path alignment | | `frontend-dapp/src/services/terraclassic/swapRouting.ts` | Direct pair vs router | | `frontend-dapp/src/services/terraclassic/transactions.ts` | Multi-msg (not a splitter) | | `frontend-dapp/src/utils/constants.ts` + `hybridSwapGas.ts` | Gas model that would have to sum legs | | `indexer/tests/api_route_solve.rs` | Multi-path winner tests to extend **if** a later feat ships | | `smartcontracts/contracts/community-tax-token/src/tax.rs` | `split_tax` — **do not** mix into this design | --- ## Recommended direction 1. **Define the product question** in one sentence: “For a given `(token_in, token_out, amount_in)` snapshot, does splitting the offer across ≤K factory paths raise `estimated_amount_out_net` enough to justify extra gas, quote latency, and execute complexity?” Answer with numbers, not slogans. 2. **Measure first (LocalTerra or recorded mainnet mirrors, no prod change).** - Pick markets where top-2 paths both have depth (direct + 2-hop hub, or two disjoint 2-hops). - For sizes across cache buckets (`AMOUNT_CACHE_BUCKET` = 1e6 raw), compare: (a) current winner 100%; (b) discrete splits `{0, 25, 50, 75, 100}%` on path1 vs path2, each path still running the **existing** hybrid grid; (c) greedy-only vs Pattern C on each leg. - Report **bps improvement vs extra hop-gas** (use `SWAP_GAS_PER_HOP` / hybrid maker gas, not a guessed fee). - Repeat with a community-tax `token_out` (net vs raw) and with an empty book on one path (#493 short-circuit). 3. **Execute architectures to rank (go / no-go per option):** - **A. Off-chain only (no wasm):** two msgs in one Cosmos tx. Requires a **splitter** or two CW20 balances. Atomicity: Cosmos tx rolls back all msgs on fail — good for all-or-nothing; still need both legs to simulate. Gas = sum. **Likely no-go** without a splitter contract because one `Send` cannot fork. - **B. Router `ExecuteSplitOperations` (new wasm):** one hook, N sequential sub-chains with explicit `offer_portion`, join on `token_out`, one `minimum_receive` on the sum. Needs migrate, hop-cap policy, dust/remainder rules, blacklist per hop. Highest product fidelity; highest review cost. - **C. Quote-only / integrator POST:** indexer returns `legs[]` with portions; clients who already split off-chain can POST. Official dApp stays single-path until B exists. Documents the math without retail risk. - **D. No-go:** keep winner-takes-all; document that “convex split search” remains out of scope because gas + sequential router + tax net do not pay back on observed sizes. Update route-solver.md Future work with this ticket’s conclusion. 4. **If any option is go, bound the search before writing code.** Suggested default to **disprove or confirm**, not to ship: at most **2 paths**, split grid **5 fractions**, each path reuses current hybrid optimizer (no nested 17×17). New budget must fit `#485` and LCD-heavy RPS. Bump `solver_version` only in a **follow-up feat**. 5. **UI / security (only if B or C is recommended for a later feat):** one Share/quote surface must list **all** legs; H09-style alignment on **every** ops list; never display path A and sign path B+C leftovers. Silent fail-closed on freeze/gem/hostile ids ([#489](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/489) lecture ban still applies). 6. **Write the note on this issue** (research gate): findings, recommendation, further study, tradeoffs, alternatives, then raw evidence (links, tables, `/tmp` sim commands). Do not implement product code in that gate. --- ## Acceptance criteria - [ ] **AC1.** Written research note on **this** issue covering: current single-winner solver, convex split hypothesis, measurement table (size × path split × hybrid vs winner-only), gas vs bps, tax/wrap/greedy interactions, execute options A–D, **go / no-go / later**. - [ ] **AC2.** Explicit **not-duplicates** vs hybrid split, greedy, [#690](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/690), tax Split treasury. - [ ] **AC3.** Search-complexity bound: what a 2-path × F-fraction solver would do to `LCD_HYBRID_SIM_BUDGET` and quote p95; whether empty-book short-circuit (#493) still holds. - [ ] **AC4.** Execute atomicity: what happens if leg 2 hits `max_spread` / pause / blacklist after leg 1 would have filled; recommended all-or-nothing vs best-effort. - [ ] **AC5.** If **no-go**: patch recommendation for `docs/route-solver.md` Future work / Non-goals (follow-up docs ticket OK; do not silently leave “convex split search” as implied upcoming work). - [ ] **AC6.** If **go**: a **separate** follow-up feat issue is enough to start implementation; this ticket stays research (no wasm in this MR). - [ ] **AC7.** No production indexer/API/UI/contract change in the research MR. Simulations reproducible from the note. --- ## Test plan (research + later-feat paths) Research gate (this ticket): | # | Path | Expect | |---|------|--------| | R1 | Winner-only vs 50/50 on a two-path LocalTerra graph with convex impact | Table of `raw_out` / `net_out`; document if split wins | | R2 | Split 0/100 and 100/0 | Matches current single-path optimizer (sanity) | | R3 | Empty book on path 2 | Path 2 prices pool-only once (#493); no 17× blow-up | | R4 | Community-tax `token_out` | Rank uses **net**; `min_return` discussion uses **raw** | | R5 | Middle-hop tax sell on one candidate (Option-2) | That path skipped, not used as a split leg | | R6 | Frozen / gem / unlisted id on one leg | Leg ineligible; do not recommend routing through it | | R7 | Wrap-in then split | Mapper fee once; no native in router | | R8 | Gas model: 1-hop winner vs 1-hop+2-hop split | Hop-sum vs `SWAP_GAS_PER_HOP` / hybrid maker; call out #681-class overshoot | | R9 | Quote latency: current GET vs hypothetical 2×5 extra path evals | Compare to #485 &lt;15s distant target | | R10 | POST `hybrid_by_hop` unchanged | Research does not require POST to grow `legs[]` unless option C | If a later feat ships (not this ticket), functional paths to require then: | # | Path | Expect | |---|------|--------| | T1 | Direct pair still 1-hop pair.swap when split not beneficial | No router for a 100% direct winner | | T2 | Split quote JSON lists every leg + portions summing to `amount_in` | No silent remainder | | T3 | Display tokens aligned with **all** signed ops | H09 for each leg | | T4 | `minimum_receive` on **sum** of legs (architecture B) | Cannot drain via one cheap leg | | T5 | `pool_only=true` | Still single BFS path; no split | | T6 | Existing `make verify-issue-209` / 501 / 615 / 485 stay green | Winner-only regressions remain | --- ## Test plan (attack, hack, and abuse) | # | Vector | Expect | |---|--------|--------| | A1 | Display one path, sign two (indexer spoof) | Reject unless every displayed leg ⊆ signed ops (extend #450) | | A2 | Split portions not summing to offer (dust siphon / inflation) | Reject at quote parse and on-chain | | A3 | Hostile `token_in` / `javascript:` / overlong bech32 in a second leg | Ignore; factory ids only | | A4 | Gem / `showGems` on a hidden split leg (production) | Not quoted, not signed | | A5 | Path explosion DoS (K paths × F splits × 17 grid) | Hard caps; 429 on LCD-heavy; no unbounded nested grid | | A6 | Cache key omits split shape | Distinct keys per portion schedule; no cross-tier poison (#283) | | A7 | Rank on raw while one leg is buy-taxed | Net ranking; raw for floors | | A8 | First msg succeeds, second fails in a non-atomic client split | Forbidden unless architecture explicitly chooses best-effort **and** user copy says so; default all-or-nothing | | A9 | Router leftover CW20 from a failed join | Balance-delta / sweep rules; no silent router custody | | A10 | `max_maker_fills=2^32-1` on each split leg | Clamp 100 per hop (#379) **per leg** | | A11 | Sandwich / MEV: split makes two observable hops | Advisory quote; `min_receive` on sum; no “MEV-aware” claim | | A12 | Blacklist/pause between quote and execute on **one** leg | Whole split fails closed (or documented best-effort) | | A13 | Open-redirect / WC URI stuffed into a new `legs` query | URLSearchParams only; never share raw search | | A14 | Treating tax Split treasury SKU as a routing feature | Docs/tests must not conflate | | A15 | Using split routing to bypass Expert / 5% / 30% / 99% / #678 size gates | Gates apply to **full** offer, not per-leg dust | --- ## Verification criteria - Research note posted on this issue (findings + recommendation + raw evidence). - Measurement table can be re-run from documented commands (LocalTerra swarm or indexer unit graph + `/tmp` sim). No prod config change. - Terminology audit: the note never calls hybrid pool/book or tax sinks “split routing.” - If no-go: linked docs follow-up or in-note exact `route-solver.md` edits for Future work. - If go: child feat issue with wasm/API/UI scope; this issue stays closed as research once the note lands. - Existing route-solver drift guard remains the bar for any later constant change: `python3 scripts/check_route_solver_docs.py`. ## Out of scope - Implementing split execute or a new `solver_version`. - Expanding [#718](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/718) greedy default, [#690](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/690) foreign hops, or indexer exact-out `/route/solve`. - Changing community-tax `Sku::SplitRouter`. - MEV protection, UniswapX-style auctions, or intent solvers. - Yen/Eppstein full K-shortest as a deliverable (may be cited as literature only). - Frontend lazy-route code splitting.
Author
Owner

cl8y-agent-control: queued implement job 4e916c11-c7da-4c64-90f8-f206f4074dcf (not executed; no Hetzner VM).

cl8y-agent-control: queued `implement` job `4e916c11-c7da-4c64-90f8-f206f4074dcf` (not executed; no Hetzner VM).
Member

/agent implement

/agent implement
Author
Owner

cl8y-agent-control: queued implement job eed2ef36-124e-414b-9a9e-eba25cde1d9b (not executed; no Hetzner VM).

cl8y-agent-control: queued `implement` job `eed2ef36-124e-414b-9a9e-eba25cde1d9b` (not executed; no Hetzner VM).
Author
Owner

cl8y-agent-control: queued design_author job 36033125-b538-4c78-930e-72922104e336 (not executed; no Hetzner VM).

cl8y-agent-control: queued `design_author` job `36033125-b538-4c78-930e-72922104e336` (not executed; no Hetzner VM).
Author
Owner

cl8y-agent-control: needs_human inbox card POST failed. Job stays parked.

cl8y-agent-control: needs_human inbox card POST failed. Job stays parked.
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#1203
No description provided.