fix(router): declared hybrid split must equal hop offer on execute #1280

Closed
opened 2026-09-18 09:01:14 +00:00 by PlasticDigits · 2 comments

Summary

Retail Swap/Trade hybrid execute can revert in the router with a CosmWasm generic error whose payload is hybrid pool_input + book_input must equal hop offer amount. Wallets wrap that as execute wasm contract failed. The pair has the same invariant (HybridSplitMismatch: pool_input + book_input must equal the CW20 hook amount).

This is a construction / hop-amount drift bug, not a request to drop the invariant and not greedy-default work.

Declared Pattern C splits are frozen in SwapOperation::TerraSwap.hybrid at quote/submit time. Router execute then requires that sum to equal:

  1. Hop 0: the CW20 Send amount into execute_swap_operations.
  2. Hop 1+: the actual previous-hop output (hop_output from the router balance delta in reply_swap_hop).

If indexer/dApp splits were sized for a simulated intermediate (or a different pay raw), hop 1+ fails even when hop 0 was exact.

Related, not duplicates (keyword overlap on hybrid / pool_input / book_input is not enough):

  • #708 / #709 (closed) — opt-in greedy so callers omit a split. Documents the invariant; does not fix mismatched declared splits.
  • #718 (open) — greedy as omitted-params default. Pattern C hybrid: Some(_) stays unchanged.
  • #1264 (open) — wrap+2hop gas. Native wrap path is pool-only and must not copy book_input.
  • #1265 (open) — quote/solve census. Not this execute assert.
  • #1203 (open) — split across paths. Per-hop pool/book is already shipped.
  • #501 / #209 (closed) — optimizer that emits splits.
  • #356 / #360 (closed) — pay/book debounce on submit. Different layer; do not reopen unless this ticket proves the same stale-UI path.

Current codebase

Router (error the wallet shows)

smartcontracts/contracts/router/src/contract.rs:

  • validate_hybrid_declared_split_for_no_belief(hop_offer, hybrid) — if hybrid is Some, pool_input.checked_add(book_input) must equal hop_offer or StdError::generic_err("hybrid pool_input + book_input must equal hop offer amount").
  • execute_swap_operations prechecks hop 0 against the incoming amount (~L213).
  • reply_swap_hop prechecks the next hop against this hop’s hop_output (~L457) before Send { amount: hop_output, msg: Swap { hybrid } }.
  • Simulate uses a sibling string: must equal simulated offer amount for this hop (~L674–676) as current_amount walks simulated returns.

Router belief_price is always None on these hops, so the no-belief pool-leg / min_return floors from #307 / #334 still apply after the sum check.

Pair

smartcontracts/contracts/pair/src/contract.rs execute_swap / HybridSimulation: SwapHybridMode::Declared requires pool_input + book_input == input_amount else HybridSplitMismatch (must equal offer amount). Direct 1-hop send → pair.swap hits this string instead of the router hop wording.

smartcontracts/packages/dex-common/src/pair.rs — HybridSwapParams { pool_input, book_input, max_maker_fills, book_start_hint }; comment: sum must equal the CW20 hook amount. pool_only_hybrid_params(offer) is book_input = 0.

Unfilled book still rolls remaining book budget into the pool after a matching split (pool_input_amount = pool_leg + (book_leg − offer_consumed_by_book)). The revert is before that roll: the declared integers must already sum.

Indexer

indexer/src/api/hybrid_route_opt.rs — per-hop grid; pool_input = offer_amount.saturating_sub(best_book) so a single hop’s strings should sum to that hop’s running offer. Joint optimize walks running from simulated hop outs (#209). GET /api/v1/route/solve returns router_operations with those hybrid objects.

Gaps the sum check will catch:

  • Later hops sized for quote-time running, while execute uses realized hop_output (book fill, fee, tax, dust).
  • Any path that copies hop-0 split onto hop 1, or rebuilds pool_input / book_input independently without remainder-to-pool.
  • Pay raw / tax extra-debit / wrap-fee net that does not match hop-0 hybrid sum (#1267 is extra-debit balance, not this assert).

Frontend

Why the new implementation is needed

  1. The invariant is correct (do not execute a split that is not a partition of the hop offer). The product failure is silent construction drift plus a generic LCD string.
  2. Multi-hop hybrid is the default retail path (GET /route/solve). Hop 1+ will keep reverting whenever realized intermediate ≠ quote-time intermediate and the next hop still carries a frozen hybrid.
  3. Direct 1-hop pair execute can still fail hop-0 if pay raw, tax debit, or a stale book leg disagrees with the quoted split.
  4. Integrators copying indexer ops without rescaling interior hops will hit the same revert; the official dApp should not.

Constraints / guardrails

  • Keep the sum invariant on declared hybrid: Some(_). Do not accept pool_input + book_input != hop offer. Do not overflow-wrap.
  • Do not change G1 (hybrid: None pool-only vs #718 greedy default). Do not overload pool_input=0, book_input=offer as “rescale for me.”
  • Do not skip min_return / max_spread / material pool-leg / greedy mutex (G8 / G11 / #307 / #334).
  • Interior-hop repair, if any, must preserve the hop offer (rescale ratio then remainder to pool, or drop that hop to pool-only). Never invent extra offer. Never take more book than the actual hop offer.
  • Indexer emitted splits must use integer remainder (pool = offer - book) so strings always sum; no independent rounding of both legs.
  • Wrap-enter / native BFS stays pool-only (#1264). Do not attach leftover hybrid from a CW20 solve onto a wrap hop.
  • No columbus-5 migrate unless a router/pair wasm change is required; prefer client/indexer rescale + tests if execute can stay as-is.
  • Do not log wallets, tx hashes, or LCD URLs in the issue follow-up.

Relevant files

Path Why
smartcontracts/contracts/router/src/contract.rs Hop-0 and reply_swap_hop sum checks; sim walk
smartcontracts/contracts/pair/src/contract.rs + error.rs Direct-pair HybridSplitMismatch
smartcontracts/packages/dex-common/src/pair.rs HybridSwapParams schema
smartcontracts/tests/src/limit_order_tests.rs Existing hybrid hop tests (#78 / #192)
indexer/src/api/hybrid_route_opt.rs Per-hop pool = offer - book; running offer
indexer/src/api/route_solver.rs router_operations hybrid attach
frontend-dapp/src/utils/cw20RouteSolveQuote.ts Submit ops vs wallet sim
frontend-dapp/src/services/terraclassic/router.ts Msg copy; wrap hops
frontend-dapp/src/services/terraclassic/swapRouting.ts 1-hop vs router
frontend-dapp/src/utils/humanizeTerraTxError.ts Optional clearer copy after the revert is rare
docs/route-solver.md / docs/integrators.md Integrator: splits are hop-offer partitions; interior hops must match realized offer
  1. Classify the surface from the error string (router hop-offer vs pair offer vs sim-only). Reproduce with cw-multi-test: 2-hop declared hybrid where hop 1 output is 1–N units off the quote-time running used to size hop 2.
  2. Preflight in the dApp (Vitest-first): before broadcast, for each op with hybrid, Uint128(pool)+Uint128(book) === hopOffer. Hop 0 offer = submit pay raw (post-tax/wrap-net). Later hop offers = wallet simulate_swap_operations running amounts, not stale indexer strings. Fail closed with a specific UI string; do not broadcast.
  3. Interior hop policy (pick one, test it):
    • A (preferred if no wasm): indexer + client emit hop 1+ as hybrid: null (pool-only) unless the simulated running amount is wired into execute (it cannot be — execute uses realized output). Retail then only declares hybrid on hop 0, or on 1-hop pair-direct.
    • B (wasm): in reply_swap_hop, if declared sum ≠ hop_output, rescale book_input to min(book, hop_output) (or proportional) and pool_input = hop_output - book, with dust-to-pool; reject if rescaling would violate material pool-leg / min_return.
    • C: keep hard fail but replace generic_err with a typed error that includes expected vs actual sums (still no wallet addresses).
  4. Indexer: assert in unit tests that every returned hop pool_input + book_input == hop_offer (decimal integer strings, no float). Joint optimizer must rebuild hop i+1 splits from that hop’s simulated offer, never reuse hop 0’s pair of integers.
  5. Do not implement #718 or #1203 here.

Repro (Given / When / Then)

Hop 0 mismatch (pair or router)

  • Given a declared hybrid whose pool_input + book_input is not equal to the CW20 amount sent into the hop (off by 1 unit, or a stale book leg vs current pay raw).
  • When the wallet broadcasts Swap / Trade market execute.
  • Then CosmWasm rejects; the user sees a generic execute failure derived from the router hop-offer string or pair HybridSplitMismatch. No funds should remain stuck in the router (SWAP_STATE cleared on error).

Hop 1+ quote/execute drift (router)

  • Given a ≥2 hop indexer route with hybrid: Some(_) on hop 2 sized to simulated hop-1 output S.
  • When hop 1 executes and realized hop_output ≠ S (book fill or fee), and the router forwards hop 2 with the frozen split.
  • Then reply_swap_hop hits must equal hop offer amount before hop 2 Send.

Expected vs actual

  • Expected: every submitted hop is a partition of that hop’s offer at execute time; quote and preflight catch mismatch; interior hops either pool-only or rescaled. User-facing error names the invariant if it still hits.
  • Actual: LCD generic error: hybrid pool_input + book_input must equal hop offer amount (paraphrased in wallets as hop amount) + execute wasm contract failed. Simulate may have passed on quote-time current_amount.

Acceptance criteria

  • AC1. cw-multi-test: declared split sum ≠ hop offer reverts; exact sum executes (1-hop pair + 2-hop router hop 0).
  • AC2. cw-multi-test: 2-hop with hybrid on hop 2 and realized hop-1 output ≠ quote-time split does not leave a generic hop-offer revert as the only outcome — either hop 2 is pool-only, rescaled to hop_output, or preflight refuses broadcast. Pick the policy in Recommended direction and lock it in the test name.
  • AC3. Indexer unit test: every hybrid on router_operations satisfies pool + book == hop offer for that hop’s running amount (including 0 book / all pool).
  • AC4. Frontend: Vitest helper rejects ops that fail AC3 before sign; wrap/native hops still have hybrid omitted (#1264).
  • AC5. Greedy mutex and hybrid: None behavior unchanged. Pattern C interior splits that do sum still work.
  • AC6. Docs: one integrator sentence — declared hybrid is a partition of the hop offer; do not reuse hop-0 integers on later hops.
  • AC7. No ready / implement labels invented outside intake type bug. No secrets in fixtures.

Test plan (functional paths)

# Path Expect
T1 1-hop pair, pool+book == offer Execute ok
T2 1-hop pair, sum offer-1 HybridSplitMismatch
T3 Router 1-op, sum offer-1 hop-offer generic_err at execute entry
T4 Router 2-op, hop 0 exact, hop 1 hybrid sized to sim out, execute out differs Policy A/B/C as chosen; no stuck SWAP_STATE
T5 Router 2-op, hop 1 hybrid: null Pool-only hop 1; hop 0 hybrid ok
T6 Indexer fixture 3-hop joint opt Each hop strings sum to that hop offer
T7 dApp wrap-enter ops No hybrid copied onto wrap hop
T8 Simulate vs execute same ops, exact book Sim and exec both accept
T9 book_input = 0, pool_input = offer Pool-only Pattern C still ok
T10 Existing #78 / #192 hybrid hop tests Stay green

Test plan (attack, hack, and abuse)

# Vector Expect
A1 pool_input + book_input > hop offer Reject; no extra debit of the hop token
A2 Sum < hop offer Reject; remainder must not be silently dropped or swept
A3 Overflowing checked_add Std overflow / reject, not wrap
A4 Hop 2 hybrid integers from hop 0 (copy-paste) Reject or rescale; never spend wrong book budget
A5 book_input = hop_output + 1 after rescale Clamp/reject; matcher must not see oversize book budget
A6 Hostile min_return omitted with book leg Still #334 floor after a valid sum
A7 Both hybrid and greedy G11 reject unchanged
A8 Blacklisted trader Blacklist still blocks before hop chain

Verification criteria

  • cargo test router/pair/limit-order hybrid split cases (locked workspace as the repo already runs).
  • Indexer unit test for hop-offer partitions.
  • Frontend Vitest for preflight; Playwright hybrid multi-hop only if LocalTerra path already exists (multihop-hybrid-tx.spec.ts) — do not add live columbus-5.
  • Manual: one 2-hop hybrid quote on LocalTerra; confirm simulate and execute either both succeed or preflight blocks with the invariant named.
  • Confirm wrap-only routes never send hybrid (#1264).

First-pass model recommendation

Recommendation: grok-high

Rationale: Founder-required wasm / router execute state (SWAP_STATE, hop reply) plus indexer split emission and dApp preflight. More than three production files and a protocol-level hop-amount invariant; a wrong rescale could oversize book_input or skip slippage floors. Verification is cw-multi-test + indexer unit + Vitest, not a single local helper. Composer criteria fail on wasm, cross-subsystem scope, and uncertain hop-0 vs hop-1+ root cause until T4 exists.

## Summary Retail Swap/Trade hybrid execute can revert in the **router** with a CosmWasm generic error whose payload is **`hybrid pool_input + book_input must equal hop offer amount`**. Wallets wrap that as **execute wasm contract failed**. The pair has the same invariant (`HybridSplitMismatch`: `pool_input + book_input` must equal the CW20 hook `amount`). This is a **construction / hop-amount drift** bug, not a request to drop the invariant and not greedy-default work. Declared Pattern C splits are frozen in `SwapOperation::TerraSwap.hybrid` at quote/submit time. Router execute then requires that sum to equal: 1. **Hop 0:** the CW20 `Send` amount into `execute_swap_operations`. 2. **Hop 1+:** the **actual** previous-hop output (`hop_output` from the router balance delta in `reply_swap_hop`). If indexer/dApp splits were sized for a simulated intermediate (or a different pay raw), hop 1+ fails even when hop 0 was exact. Related, **not duplicates** (keyword overlap on hybrid / `pool_input` / `book_input` is not enough): - [#708](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/708) / [#709](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/709) (closed) — opt-in greedy so callers omit a split. Documents the invariant; does not fix mismatched declared splits. - [#718](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/718) (open) — greedy as omitted-params default. Pattern C `hybrid: Some(_)` stays unchanged. - [#1264](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/1264) (open) — wrap+2hop **gas**. Native wrap path is pool-only and must not copy `book_input`. - [#1265](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/1265) (open) — quote/solve census. Not this execute assert. - [#1203](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/1203) (open) — split **across paths**. Per-hop pool/book is already shipped. - [#501](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/501) / [#209](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/209) (closed) — optimizer that **emits** splits. - [#356](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/356) / [#360](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/360) (closed) — pay/book debounce on submit. Different layer; do not reopen unless this ticket proves the same stale-UI path. ## Current codebase ### Router (error the wallet shows) [`smartcontracts/contracts/router/src/contract.rs`](smartcontracts/contracts/router/src/contract.rs): - `validate_hybrid_declared_split_for_no_belief(hop_offer, hybrid)` — if `hybrid` is `Some`, `pool_input.checked_add(book_input)` must equal `hop_offer` or `StdError::generic_err("hybrid pool_input + book_input must equal hop offer amount")`. - `execute_swap_operations` prechecks hop 0 against the incoming `amount` (~L213). - `reply_swap_hop` prechecks the **next** hop against **this** hop’s `hop_output` (~L457) before `Send { amount: hop_output, msg: Swap { hybrid } }`. - Simulate uses a sibling string: `must equal simulated offer amount for this hop` (~L674–676) as `current_amount` walks simulated returns. Router `belief_price` is always `None` on these hops, so the no-belief pool-leg / `min_return` floors from [#307](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/307) / [#334](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/334) still apply **after** the sum check. ### Pair [`smartcontracts/contracts/pair/src/contract.rs`](smartcontracts/contracts/pair/src/contract.rs) `execute_swap` / `HybridSimulation`: `SwapHybridMode::Declared` requires `pool_input + book_input == input_amount` else [`HybridSplitMismatch`](smartcontracts/contracts/pair/src/error.rs) (`must equal offer amount`). Direct 1-hop `send → pair.swap` hits this string instead of the router hop wording. [`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 }`; comment: sum must equal the CW20 hook amount. `pool_only_hybrid_params(offer)` is `book_input = 0`. Unfilled book still **rolls remaining book budget into the pool** after a matching split (`pool_input_amount = pool_leg + (book_leg − offer_consumed_by_book)`). The revert is **before** that roll: the declared integers must already sum. ### Indexer [`indexer/src/api/hybrid_route_opt.rs`](indexer/src/api/hybrid_route_opt.rs) — per-hop grid; `pool_input = offer_amount.saturating_sub(best_book)` so a **single** hop’s strings should sum to that hop’s `running` offer. Joint optimize walks `running` from simulated hop outs ([#209](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/209)). `GET /api/v1/route/solve` returns `router_operations` with those `hybrid` objects. Gaps the sum check will catch: - Later hops sized for **quote-time** `running`, while execute uses **realized** `hop_output` (book fill, fee, tax, dust). - Any path that copies hop-0 split onto hop 1, or rebuilds `pool_input` / `book_input` independently without remainder-to-pool. - Pay raw / tax extra-debit / wrap-fee net that does not match hop-0 hybrid sum ([#1267](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/1267) is extra-debit **balance**, not this assert). ### Frontend - [`frontend-dapp/src/utils/cw20RouteSolveQuote.ts`](frontend-dapp/src/utils/cw20RouteSolveQuote.ts) — wallet sim + submit must use indexer `router_operations` (including per-hop `hybrid`). - [`frontend-dapp/src/services/terraclassic/router.ts`](frontend-dapp/src/services/terraclassic/router.ts) — copies `pool_input` / `book_input` into execute msgs; wrap/native BFS hops stay pool-only (#587 / #599 / #1264). - [`frontend-dapp/src/services/terraclassic/swapRouting.ts`](frontend-dapp/src/services/terraclassic/swapRouting.ts) — 1-hop uses `hybridFromSingleHopIndexerOps`; ≥2 hops go router. - [`frontend-dapp/src/utils/humanizeTerraTxError.ts`](frontend-dapp/src/utils/humanizeTerraTxError.ts) / [`humanizeUserFacingError.ts`](frontend-dapp/src/utils/humanizeUserFacingError.ts) — LCD `generic error: … : execute wasm contract failed`. No preflight that the hop sums match the offer about to be sent. ## Why the new implementation is needed 1. The invariant is correct (do not execute a split that is not a partition of the hop offer). The product failure is **silent construction drift** plus a generic LCD string. 2. Multi-hop hybrid is the default retail path (`GET /route/solve`). Hop 1+ will keep reverting whenever realized intermediate ≠ quote-time intermediate and the next hop still carries a frozen `hybrid`. 3. Direct 1-hop pair execute can still fail hop-0 if pay raw, tax debit, or a stale book leg disagrees with the quoted split. 4. Integrators copying indexer ops without rescaling interior hops will hit the same revert; the official dApp should not. ## Constraints / guardrails - **Keep the sum invariant** on declared `hybrid: Some(_)`. Do not accept `pool_input + book_input != hop offer`. Do not overflow-wrap. - **Do not change G1** (`hybrid: None` pool-only vs [#718](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/718) greedy default). Do not overload `pool_input=0, book_input=offer` as “rescale for me.” - **Do not skip** `min_return` / `max_spread` / material pool-leg / greedy mutex (G8 / G11 / [#307](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/307) / [#334](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/334)). - Interior-hop repair, if any, must **preserve the hop offer** (rescale ratio then remainder to pool, or drop that hop to pool-only). Never invent extra offer. Never take more book than the actual hop offer. - Indexer emitted splits must use integer remainder (`pool = offer - book`) so strings always sum; no independent rounding of both legs. - Wrap-enter / native BFS stays pool-only (#1264). Do not attach leftover `hybrid` from a CW20 solve onto a wrap hop. - No columbus-5 migrate unless a router/pair wasm change is required; prefer client/indexer rescale + tests if execute can stay as-is. - Do not log wallets, tx hashes, or LCD URLs in the issue follow-up. ## Relevant files | Path | Why | | --- | --- | | `smartcontracts/contracts/router/src/contract.rs` | Hop-0 and `reply_swap_hop` sum checks; sim walk | | `smartcontracts/contracts/pair/src/contract.rs` + `error.rs` | Direct-pair `HybridSplitMismatch` | | `smartcontracts/packages/dex-common/src/pair.rs` | `HybridSwapParams` schema | | `smartcontracts/tests/src/limit_order_tests.rs` | Existing hybrid hop tests (#78 / #192) | | `indexer/src/api/hybrid_route_opt.rs` | Per-hop `pool = offer - book`; running offer | | `indexer/src/api/route_solver.rs` | `router_operations` hybrid attach | | `frontend-dapp/src/utils/cw20RouteSolveQuote.ts` | Submit ops vs wallet sim | | `frontend-dapp/src/services/terraclassic/router.ts` | Msg copy; wrap hops | | `frontend-dapp/src/services/terraclassic/swapRouting.ts` | 1-hop vs router | | `frontend-dapp/src/utils/humanizeTerraTxError.ts` | Optional clearer copy **after** the revert is rare | | `docs/route-solver.md` / `docs/integrators.md` | Integrator: splits are hop-offer partitions; interior hops must match realized offer | ## Recommended direction 1. **Classify the surface from the error string** (router hop-offer vs pair offer vs sim-only). Reproduce with cw-multi-test: 2-hop declared hybrid where hop 1 output is 1–N units off the quote-time `running` used to size hop 2. 2. **Preflight in the dApp** (Vitest-first): before broadcast, for each op with `hybrid`, `Uint128(pool)+Uint128(book) === hopOffer`. Hop 0 offer = submit pay raw (post-tax/wrap-net). Later hop offers = **wallet** `simulate_swap_operations` running amounts, not stale indexer strings. Fail closed with a specific UI string; do not broadcast. 3. **Interior hop policy (pick one, test it):** - **A (preferred if no wasm):** indexer + client emit hop 1+ as `hybrid: null` (pool-only) unless the simulated running amount is wired into execute (it cannot be — execute uses realized output). Retail then only declares hybrid on hop 0, or on 1-hop pair-direct. - **B (wasm):** in `reply_swap_hop`, if declared sum ≠ `hop_output`, rescale `book_input` to `min(book, hop_output)` (or proportional) and `pool_input = hop_output - book`, with dust-to-pool; reject if rescaling would violate material pool-leg / `min_return`. - **C:** keep hard fail but replace `generic_err` with a typed error that includes expected vs actual sums (still no wallet addresses). 4. **Indexer:** assert in unit tests that every returned hop `pool_input + book_input == hop_offer` (decimal integer strings, no float). Joint optimizer must rebuild hop *i+1* splits from **that hop’s simulated offer**, never reuse hop 0’s pair of integers. 5. Do not implement [#718](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/718) or [#1203](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/1203) here. ## Repro (Given / When / Then) ### Hop 0 mismatch (pair or router) - **Given** a declared `hybrid` whose `pool_input + book_input` is not equal to the CW20 amount sent into the hop (off by 1 unit, or a stale book leg vs current pay raw). - **When** the wallet broadcasts Swap / Trade market execute. - **Then** CosmWasm rejects; the user sees a generic execute failure derived from the router hop-offer string or pair `HybridSplitMismatch`. No funds should remain stuck in the router (`SWAP_STATE` cleared on error). ### Hop 1+ quote/execute drift (router) - **Given** a ≥2 hop indexer route with `hybrid: Some(_)` on hop 2 sized to simulated hop-1 output `S`. - **When** hop 1 executes and realized `hop_output ≠ S` (book fill or fee), and the router forwards hop 2 with the frozen split. - **Then** `reply_swap_hop` hits `must equal hop offer amount` before hop 2 `Send`. ### Expected vs actual - **Expected:** every submitted hop is a partition of that hop’s offer at execute time; quote and preflight catch mismatch; interior hops either pool-only or rescaled. User-facing error names the invariant if it still hits. - **Actual:** LCD `generic error: hybrid pool_input + book_input must equal hop offer amount` (paraphrased in wallets as hop amount) + `execute wasm contract failed`. Simulate may have passed on quote-time `current_amount`. ## Acceptance criteria - AC1. cw-multi-test: declared split sum ≠ hop offer reverts; exact sum executes (1-hop pair + 2-hop router hop 0). - AC2. cw-multi-test: 2-hop with hybrid on hop 2 and realized hop-1 output ≠ quote-time split **does not** leave a generic hop-offer revert as the only outcome — either hop 2 is pool-only, rescaled to `hop_output`, or preflight refuses broadcast. Pick the policy in Recommended direction and lock it in the test name. - AC3. Indexer unit test: every `hybrid` on `router_operations` satisfies `pool + book == hop offer` for that hop’s running amount (including 0 book / all pool). - AC4. Frontend: Vitest helper rejects ops that fail AC3 before `sign`; wrap/native hops still have `hybrid` omitted (#1264). - AC5. Greedy mutex and `hybrid: None` behavior unchanged. Pattern C interior splits that **do** sum still work. - AC6. Docs: one integrator sentence — declared hybrid is a partition of the hop offer; do not reuse hop-0 integers on later hops. - AC7. No `ready` / implement labels invented outside intake type `bug`. No secrets in fixtures. ## Test plan (functional paths) | # | Path | Expect | | --- | --- | --- | | T1 | 1-hop pair, `pool+book == offer` | Execute ok | | T2 | 1-hop pair, sum `offer-1` | `HybridSplitMismatch` | | T3 | Router 1-op, sum `offer-1` | hop-offer generic_err at execute entry | | T4 | Router 2-op, hop 0 exact, hop 1 `hybrid` sized to sim out, execute out differs | Policy A/B/C as chosen; no stuck `SWAP_STATE` | | T5 | Router 2-op, hop 1 `hybrid: null` | Pool-only hop 1; hop 0 hybrid ok | | T6 | Indexer fixture 3-hop joint opt | Each hop strings sum to that hop offer | | T7 | dApp wrap-enter ops | No `hybrid` copied onto wrap hop | | T8 | Simulate vs execute same ops, exact book | Sim and exec both accept | | T9 | `book_input = 0`, `pool_input = offer` | Pool-only Pattern C still ok | | T10 | Existing #78 / #192 hybrid hop tests | Stay green | ## Test plan (attack, hack, and abuse) | # | Vector | Expect | | --- | --- | --- | | A1 | `pool_input + book_input > hop offer` | Reject; no extra debit of the hop token | | A2 | Sum `< hop offer` | Reject; remainder must not be silently dropped or swept | | A3 | Overflowing `checked_add` | Std overflow / reject, not wrap | | A4 | Hop 2 hybrid integers from hop 0 (copy-paste) | Reject or rescale; never spend wrong book budget | | A5 | `book_input = hop_output + 1` after rescale | Clamp/reject; matcher must not see oversize book budget | | A6 | Hostile `min_return` omitted with book leg | Still [#334](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/334) floor after a valid sum | | A7 | Both `hybrid` and `greedy` | G11 reject unchanged | | A8 | Blacklisted trader | Blacklist still blocks before hop chain | ## Verification criteria - `cargo test` router/pair/limit-order hybrid split cases (locked workspace as the repo already runs). - Indexer unit test for hop-offer partitions. - Frontend Vitest for preflight; Playwright hybrid multi-hop only if LocalTerra path already exists (`multihop-hybrid-tx.spec.ts`) — do not add live columbus-5. - Manual: one 2-hop hybrid quote on LocalTerra; confirm simulate and execute either both succeed or preflight blocks with the invariant named. - Confirm wrap-only routes never send `hybrid` (#1264). ## First-pass model recommendation Recommendation: grok-high Rationale: Founder-required **wasm / router execute state** (`SWAP_STATE`, hop reply) plus indexer split emission and dApp preflight. More than three production files and a protocol-level hop-amount invariant; a wrong rescale could oversize `book_input` or skip slippage floors. Verification is cw-multi-test + indexer unit + Vitest, not a single local helper. Composer criteria fail on wasm, cross-subsystem scope, and uncertain hop-0 vs hop-1+ root cause until T4 exists.
Author
Owner

Merge complete: PR #1281 landed on main (94ea0c91).

Met: Policy A retail GET hop-0-only hybrid; wasm still fail-closes pool_input + book_input == hop offer; POST interiors remain; wrap hops stay pool-only (H596-7). DB fidelity now recomputes grid_out on the stripped plan so hop-0-only quotes are not marked indexer_hybrid_db_degraded. make verify-issue-1280 passed on the fidelity SHA; Woodpecker on the PR was green. Push CI on main tip 94ea0c91 is success (ci/woodpecker/push/woodpecker).

Leftover: VERIFY_ISSUE_1280_CHAIN=1 LocalTerra Playwright was not run in this merge. Columbus-5 wrap+2hop measurement stays on #1264 (envelope not raised).

Merge complete: PR #1281 landed on main (`94ea0c91`). **Met:** Policy A retail GET hop-0-only hybrid; wasm still fail-closes `pool_input + book_input == hop offer`; POST interiors remain; wrap hops stay pool-only (H596-7). DB fidelity now recomputes `grid_out` on the stripped plan so hop-0-only quotes are not marked `indexer_hybrid_db_degraded`. `make verify-issue-1280` passed on the fidelity SHA; Woodpecker on the PR was green. Push CI on main tip `94ea0c91` is success (`ci/woodpecker/push/woodpecker`). **Leftover:** `VERIFY_ISSUE_1280_CHAIN=1` LocalTerra Playwright was not run in this merge. Columbus-5 wrap+2hop measurement stays on #1264 (envelope not raised).
Author
Owner

Follow-up for columbus-5 wrap+2hop measurement remains #1264. Git hook leftover from this land: #1286.

Follow-up for columbus-5 wrap+2hop measurement remains #1264. Git hook leftover from this land: #1286.
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#1280
No description provided.