feat: other-DEX v2 LP in route solver / hybrid swap (3% CMM fee, max 1 foreign hop) #690

Open
opened 2026-08-27 11:52:13 +00:00 by PlasticDigits · 5 comments
PlasticDigits commented 2026-08-27 11:52:13 +00:00 (Migrated from gitlab.com)

Summary

Add other-DEX v2 LP (Terraport, TerraSwap, Garuda DeFi, Weso World, LuncSwap) to CL8Y Swap / Trade market routing and the indexer route solver, with a 300 bps (3%) CL8Y surcharge on any path that uses an external hop so our market makers keep the solver edge. Fees go to the CMM treasury. Maximum one external pair per swap. IBC USDC as an intermediate is out of scope here — follow-up issue (linked).

Product goal: more tokens and deeper liquidity for retail, without routing flow away from CL8Y books/pools whenever a CL8Y path is within 3% (plus wrap / foreign AMM costs).


Current codebase

CL8Y routing is factory-local, CW20-only, hybrid-first.

Router (execute)

  • smartcontracts/contracts/router/src/msg.rs — SwapOperation is TerraSwap { offer, ask, hybrid, min_return } or rejected NativeSwap. ExecuteSwapOperations is CW20-hook only (Use CW20 Send to initiate swaps). MAX_HOPS = 4. Optional unwrap_output sends the final CW20 to the wrap-mapper (Unwrap { recipient }), not native in.
  • smartcontracts/contracts/router/src/contract.rs — each hop resolve_operation queries our factory Pair { asset_infos }. Foreign pair addresses cannot be executed. Hop output is balance-delta of the ask CW20 on the router (hop_output_from_balance); zero delta reverts. Blacklist guard (blacklist_guard.rs) only understands CW20 TerraSwap ops.
  • Native LUNC/USTC never enter the router. Wrap is a separate frontend msg to treasury / wrap-mapper so burn tax fires once (NATIVE_TOKEN_WRAPPING.md). Unwrap fee is wrap-mapper fee_unwrap_bps (~51 bps live target) then treasury InstantWithdraw 1.5% burn tax ≈ 2% all-in (#516). Wrap input is fee_wrap_bps (200 live target = 2%).
  • Errors: NativeTokenNotSupported, NativeSwapNotSupported, PairNotFound (error.rs).

Indexer route solver

  • Graph nodes = indexed CW20 assets.contract_address; edges = our factory pairs (docs/route-solver.md, ADR 0002).
  • GET /api/v1/route/solve = global best execution: top-5 paths, ≤ 4 hops, joint hybrid grid, winner = max estimated_amount_out_net (#209, #615). Frozen F6 hops excluded (#585).
  • No foreign factory crawl, no native/IBC nodes, no “second-class” edges.

Frontend

  • Swap + Trade market always use GET /route/solve (#501, #596). Single CL8Y hop is pair-direct send→swap; router only when ops.length ≥ 2 (swapRouting.ts).
  • Native pay/receive = wrap then CW20 route then optional unwrap_output.

Other-DEX knowledge today

  • Terraport factory is pinned for migrate inventory only — never RegisterListedPair (communityTaxMigratePairs.ts, docs/terraport.md). Address: terra1n75fgfc8clsssrm2k0fswgtzsvstdaah7la6sfu96szdu22xta0q57rqqr.
  • Classic TerraSwap factory (docs): terra1jkndu9w5attpz09ut02sgey5dd3e8sq5watzm0. Pair/router schemas are documented in docs/classic-terraswap-upstream-source.md / docs/terraport.md. Terraport/TerraSwap are not Astroport.
  • Garuda (garuda-defi.org), Weso (wesoworld.io / wesoswap_factory candidate terra1veqa6znu8lfdmz9kp9v047chfmn84q5k3pacme75gl8ywmplk92q6xnq2k — unverified in this repo), LuncSwap (luncswap.fun): no in-repo factory pin, pair wasm, or swap message shape.
  • Factory CW20 whitelist (columbus-5): [6036, 8266, 10184] plus listed tax pins (11619 / 11626 / 11630 family). F6 pins live code_id at CreatePair and re-checks on write (#582). Code 3 adopt+list is NO-GO (#627).
  • CMM treasury (F4): terra16j5u6ey7a84g40sr3gd94nzg5w5fm45046k9s2347qhfpwm5fr6sem3lr2 — not governance terra1zlmv2….
  • USD marks: CEX USTC/LUNC oracles (#515) + DEX hub prices (#556). Pair TVL rollup is pair_liquidity_usd for our pairs only (#655).

Why this is needed

Retail can only swap tokens that sit on our factory graph. Many Classic tokens have their deepest v2 LP on Terraport / TerraSwap / other AMMs versus LUNC or USTC. Without foreign hops, those markets are invisible on Swap / Trade market even when a 1-hop other-DEX pool would fill the trade.

We still want CL8Y makers to win whenever our hybrid (pool + book) is close. A mandatory 3% CL8Y surcharge on any route that includes an external pair makes the solver prefer our books unless the foreign path is materially better. That surcharge is protocol revenue to CMM, not a penalty on our LPs.


Constraints / guardrails

Invariants for this issue (XDex-1–XDex-14). Do not ship a venue that cannot satisfy them.

ID Rule
XDex-1 — max one external hop Solver, router execute, and frontend submit allow at most one foreign pair per swap. Two foreign hops → reject. CL8Y hops around that one foreign hop are fine (≤ MAX_HOPS). A swap may be only that one foreign pair (no CL8Y hop).
XDex-2 — 300 bps to CMM Every executed path that includes a foreign hop skims exactly 300 bps of the measured foreign-hop ask delta (integer floor) to CMM terra16j5u6ey7a84g40sr3gd94nzg5w5fm45046k9s2347qhfpwm5fr6sem3lr2 in that ask asset (CW20 transfer or native bank send). Not governance. Not pair FEE_CONFIG.treasury. No CL8Y fee-tier discount on this 300 bps. Quote ranking uses post-skim amount.
XDex-3 — do not prefer foreign LP Winner compare is still max estimated_amount_out_net, but foreign-path out must include: (a) foreign AMM / pair fee, (b) 300 bps XDex-2, (c) wrap and/or unwrap costs when those msgs are actually in the execute envelope. A CL8Y-only hybrid path that loses by < 3% + wrap/foreign fees must win. Do not add a “prefer foreign” boost. Do not rank on pre-fee foreign simulation.
XDex-4 — admission (solver) An external v2 pair is eligible only if all of: (1) both CW20 legs (if any) have live ContractInfo.code_id that is factory IsCodeIdWhitelisted (same set as F6; fail closed on query error); native uluna / uusd have no code_id and are allowed only as the intermediate side; (2) USD TVL ≥ $1,000 using hub/oracle USD of both reserves (humanized decimals); unpriced reserve → ineligible, not $1; (3) at least one leg is LUNC (uluna or wrap cLUNC) or USTC (uusd or wrap cUSTC). TOKEN/TOKEN with no LUNC/USTC leg is excluded. USDC IBC is not an intermediate on this ticket.
XDex-5 — execute does not trust the foreign contract Router (or the msg that talks to the foreign pair) must not treat simulation / return_amount / events as truth. Snapshot pre-hop balances of offer+ask (and native denoms in play), execute, measure delta, revert if ask delta < min_return, if offer spent > declared, if unexpected tokens appear, or if delta is zero. No belief_price trust. Foreign hybrid / book params are illegal (foreign venues are pool-only).
XDex-6 — factory pin, not caller pair addr Caller may pass venue + assets; router re-queries the pinned venue factory Pair { asset_infos } and uses that contract. Mismatch with a supplied pair addr → revert. Unknown venue id → revert. Pair must still be on the allowlisted factory at execute (not only at index time).
XDex-7 — wrap / unwrap honesty (the ~5% stack) Default native USTC/LUNC involvement: ~2% wrap or unwrap all-in + 3% XDex-2 ≈ 5% on top of the foreign AMM fee. Solver and You Receive must show that stack, not 3% only. Skip wrap when user pays native USTC/LUNC and the first hop is that native on the foreign pair (do not WrapDeposit then unwrap back). Skip unwrap when user wants native USTC/LUNC and the last hop’s ask is that native (do not wrap-mapper InstantWithdraw). Skipping must not send native through the CL8Y router in a way that doubles burn tax (see NATIVE_TOKEN_WRAPPING.md — user→router→pair is two tax events). Prefer: native swap directly on the pinned foreign pair with to = user (solo hop) or to = CL8Y router (more hops, CW20 ask only).
XDex-8 — foreign AMM fees in ranking Each venue’s pair fee (TerraSwap-family typically 30 bps; confirm per factory/pair query) is applied in the quote. Stale or missing fee → fail closed for that edge, do not assume 0.
XDex-9 — venue enablement Terraport + Classic TerraSwap may ship once LCD pair/swap/simulate shapes are pinned. Garuda / Weso / LuncSwap stay disabled until factory+pair+swap+simulate are LCD-probed and recorded in-repo. Unknown schema = no graph edge.
XDex-10 — not a CL8Y pair Do not RegisterListedPair foreign pairs (#626 M626-10 / #634). Community-tax extra-debit applies on our listed hops only. Foreign hop stays 1:1 on that venue. Do not add pair/router FoT math (H-01).
XDex-11 — hybrid stays on for CL8Y hops Official dApp does not turn hybrid off (#596). Foreign hop is pool-only; CL8Y hops on the same route still get solver hybrid splits. Single foreign-only hop still goes through the router (or an equivalent guarded execute), not CL8Y pair-direct send→swap.
XDex-12 — blacklist + pause + F6 Factory token/pair blacklist and F6 freeze still fail closed. Foreign hop whose CW20 drifted off whitelist at execute reverts. Do not route gems in production (#562).
XDex-13 — integrator POST POST /route/solve and raw execute_swap_operations cannot smuggle a second foreign hop or skip the 300 bps skim. Contract is source of truth.
XDex-14 — gas New execute shapes need getGasLimitForTx mapping (#475). Wrap+foreign+≥2hop and unwrap combos need measured floors (siblings #587 / #679 / #599). Unmapped send hooks must throw, not silent 600k.

Relevant files

Contracts: smartcontracts/contracts/router/src/{msg,contract,state,error,blacklist_guard}.rs · smartcontracts/packages/dex-common/src/{types,factory,wrap_mapper,blacklist}.rs · smartcontracts/tests/src/lib.rs (router + wrap tests)

Indexer: indexer/src/api/{route_solver,route_paths,route_graph,best_execution,hybrid_route_opt}.rs · indexer/src/indexer/ (new foreign-pair ingest) · indexer/tests/api_route_solve*.rs

Frontend: frontend-dapp/src/utils/cw20RouteSolveQuote.ts · directHybridQuote.ts · services/terraclassic/{router,swapRouting,wrapMapper,terraGas,hybridSwapGas}.ts · Swap + Trade market submit · route row (#158)

Docs / pins: docs/route-solver.md · docs/adr/0002-*.md · docs/terraport.md · NATIVE_TOKEN_WRAPPING.md · docs/runbooks/rotate-fee-treasury.md · frontend-dapp/src/utils/communityTaxMigratePairs.ts (TERRAPORT_FACTORY_ADDRESS)

Related issues: #189 / #209 / #323 (solver) · #501 / #596 (retail GET hybrid) · #516 / #523 / #343 (wrap fees / unwrap) · #582 / #585 (code_id) · #607 / #615 (tax hops) · #626 / #634 (do not register foreign pairs) · #562 (gems) · #475 / #587 / #679 / #599 (gas)


  1. Venue catalog (config, not hardcoded per pair) — governance/env allowlist: { venue_id, factory, pair_code_ids?, swap_msg_family: terraswap_v2 }. Start with Terraport + Classic TerraSwap. Others append after LCD probe.
  2. Indexer ingest — paginate each factory pairs, store external_pairs (venue, pair addr, asset_infos, reserves, fee_bps, usd_tvl, code_ids, last_seen). Refresh on a slower cadence than CL8Y books. Drop edges that fail XDex-4.
  3. Graph — add optional foreign edges. Path enumerator still ≤4 hops / top-5, but reject any path with >1 foreign edge. Foreign hop is never hybrid-gridded.
  4. Ranking — simulate foreign hop via LCD pair simulation only for quoting; subtract venue fee (if not already in sim), 300 bps, wrap/unwrap when the envelope includes them. Compare to CL8Y hybrid estimated_amount_out_net. Cache key includes venue set + fee bps + wrap flags.
  5. Router wasm — new SwapOperation variant (name TBD) carrying venue_id + asset infos (not a free pair addr). Execute: factory pin (XDex-6) → balance snapshot → CW20 send / native swap on that pair with to: router (or user on last hop) → delta + min_return → skim 300 bps to CMM → continue remaining CL8Y hops. Re-use hop reply machine; do not re-enter SWAP_STATE from a foreign callback.
  6. Solo foreign hop — ops.length == 1 foreign still uses the router (or a dedicated guarded execute that still skims 300 bps and checks deltas). Do not teach the dApp to send straight to a Terraport pair without the skim / invariants.
  7. Wrap skip — frontend (and solver flags) choose wrap msgs independently of hops: needs_wrap_in, needs_unwrap_out, foreign_first_native, foreign_last_native. Retail copy stays short (#489): e.g. “Other DEX pool · 3% protocol fee” plus existing wrap fee note when wrap runs.
  8. ADR — short amendment to ADR 0002: foreign edges, 300 bps, max-1, wrap skip. solver_version bump when ranking changes.

Do not: proxy through a foreign router (unbounded hops, extra trust); treat Terraport events as our volume for CG/CMC; list foreign TVL on /pool; turn hybrid off.


Acceptance criteria

  • GET /route/solve can return a path with exactly one external (or equivalent) hop when it beats CL8Y-only after 300 bps + wrap/foreign fees; otherwise CL8Y-only wins.
  • Paths with two foreign hops are never returned; execute of such ops reverts.
  • A foreign-only 1-hop swap quotes and executes (300 bps to CMM, delta checks).
  • External pair excluded unless both CW20 code_ids are factory-whitelisted, USD TVL ≥ $1,000, and one leg is LUNC or USTC (native or wrap).
  • Unpriced USD or LCD code_id failure → pair ineligible / execute revert (fail closed).
  • Execute measures balances; revert on shortfall vs min_return / minimum_receive even if the foreign pair’s sim looked good.
  • 300 bps of measured ask delta lands on CMM; not governance; not skipped for discounted traders.
  • USTC/LUNC output + foreign last hop native ask → no wrap-mapper unwrap; user receives native; quote is not charged unwrap ~2%.
  • USTC/LUNC input + foreign first hop native offer → no wrap deposit; quote is not charged wrap 2%; burn tax is not doubled.
  • When wrap or unwrap does run, You Receive includes that cost (~5% protocol+wrap vs ~3% when skipped).
  • Foreign pair fee is in the quote; missing fee → no edge.
  • Terraport + TerraSwap enabled only with pinned factories; Garuda/Weso/LuncSwap off until probed.
  • Swap + Trade market submit match the solver ops (quote = execute). Route row can show one “other DEX” hop without an architecture essay.
  • Gas mapped; hybrid remains on for CL8Y hops; no pool_only=true on the official dApp.
  • Docs: route-solver + ADR 0002 amendment + venue pin table. Verify target: make verify-issue-<iid>.

Test plan (functional paths)

Contracts (multitest + mocks of a TerraSwap-shaped pair):

  1. CL8Y-only multi-hop — unchanged.
  2. CL8Y hop → one foreign hop → CL8Y hop (if hop cap allows) — deltas, 300 bps, minimum_receive post-skim.
  3. Foreign hop first / last / only.
  4. min_return on foreign hop; minimum_receive on router.
  5. Reject 0, 2+ foreign ops; reject hybrid on foreign op; reject spoofed pair addr vs factory pin.
  6. Native first hop skip-wrap sequence (no native sitting on router across a tax boundary).
  7. Native last hop skip-unwrap; unwrap_output: true + native last hop → revert or ignore unwrap (pick one, test it).
  8. CMM receives 300 bps; 1-unit dust floor (no underflow); fee-tier trader still pays 300 bps.
  9. Blacklisted token/pair/sender; F6-unlisted code_id at execute; wrap-mapper unset when unwrap still requested.

Indexer:

  1. Ingest Terraport-shaped pair; drop TVL < $1000; drop TOKEN/TOKEN; drop unwhitelisted code_id; drop unpriced USD.
  2. Path search: foreign edge used only when post-fee out wins; CL8Y hybrid wins when within the fee stack.
  3. Never enumerate 2 foreign hops; cache isolation vs CL8Y-only.
  4. Tax-aware net ranking (#615) still applies on CL8Y hops; foreign hop not treated as listed-pair tax.
  5. pool_only=true integrator path: still max-1 foreign if allowed at all (or explicitly no foreign — document and test).

Frontend (Vitest + Playwright when LocalTerra can mock a foreign pair):

  1. Swap + Trade market GET quote includes foreign hop; submit ops match.
  2. Wrap skipped / unwrap skipped flags vs wrap notes.
  3. Solo foreign hop uses router envelope, not pair-direct CL8Y swap.
  4. Gas inventory includes new send-hook shapes.
  5. Disconnect / indexer 404 / LCD fail — no silent pool-only foreign execute.

Venues: LCD fixtures for Terraport + TerraSwap factory Pair + pair simulation. Garuda/Weso/LuncSwap: probe issue comment or stay disabled.


Test plan (attack, hack, abuse)

Vector Expect
Malicious / migrated foreign pair returns less than sim, or simulation lies Delta < min_return → revert; user not left mid-hop (SWAP_STATE cleared or never committed).
Caller supplies attacker pair addr that is not factory Pair Revert (XDex-6).
Reentrancy — foreign pair callbacks into router mid-hop SwapInProgress or no writable re-entry; no double-skim / drain.
Unexpected token / donation to router during hop Ignore donations; only delta of declared ask counts; do not credit attacker-chosen denom toward minimum_receive.
FoT / tax CW20 on foreign hop Fail closed unless 1:1 inbound (H-01). Do not add FoT math. Listed tax still extra-debits our hops only.
Fee-on-transfer skim bypass (send 100, pair sees 99) Offer spent vs declared checked; revert if pair pulled more than snapshot.
Two foreign hops in POST body / crafted wasm msg Contract revert; indexer never suggests it.
Skip 300 bps via pair-direct send to Terraport Official dApp never builds that msg. Integrator who bypasses router is out of product scope; do not document it as supported.
Discount-tier / trader spoof to zero the 3% 300 bps not discounted; trader still required on CL8Y hops for tax (#607).
Flash / momentary $1k TVL TVL from last ingest snapshot + execute-time reserve check recommended (stale-low is OK; stale-high is the risk). If execute-time TVL < $1k, revert or still allow if sim/min_return holds — prefer execute-time reserve floor so flash LP cannot advertise then empty.
Oracle / hub USD spoof to pass $1k Use same hub/oracle as #515/#556; unpriced → ineligible. Do not use last print.
Code_id drift after ingest (F6) Execute re-queries ContractInfo + IsCodeIdWhitelisted; revert.
Native double burn tax via router-held uusd Tests prove skip-wrap path does not BankMsg native to router then out again.
Unwrap still on when last hop already paid native User must not pay InstantWithdraw on a zero/wrong CW20.
Sandwich / stale quote max_spread / min_return / deadline remain; solver still advisory.
Gem / unlisted junk via foreign TOKEN/LUNC Production gem hide (#562); whitelist code_id gate.
Blacklist bypass by hopping out to foreign and back Blacklist still sees all CW20s in ops; native denoms in foreign ops included in the check.
CMM address substitution CMM is instantiate/governance pin, not caller-supplied.
Integer overflow / 0-amount skim Floor bps on Uint128; zero ask reverts before skim.
LCD sim grief (foreign factory query gas / 500) Indexer: ContractQueryRejected without treating as infinite liquidity; skip edge.
Volume washing / fake CG volume Do not ingest foreign swaps into swap_events as CL8Y volume.

Verification criteria

Close when:

  1. make verify-issue-<iid> exists and covers contracts + indexer unit/integration (Postgres) + frontend Vitest for wrap-skip flags and op shapes.
  2. LocalTerra (or mocked foreign pair) executes: (a) CL8Y-only still preferred when foreign is < fee stack better, (b) foreign-only hop pays CMM 3%, (c) skip unwrap on native last hop, (d) skip wrap on native first hop, (e) two-foreign execute reverts, (f) lying pair sim reverts on delta.
  3. Columbus-5 probe comment: Terraport + TerraSwap factory pins, sample pair TVL ≥ $1k, whitelist code_ids, CMM balance delta on a dust trade (or dry-run with DRY_RUN docs). Garuda/Weso/LuncSwap listed as enabled or explicitly disabled with probe notes.
  4. No RegisterListedPair on foreign addrs. Hybrid checkbox not reintroduced. ADR 0002 + docs/route-solver.md updated.

Blocked on: venue schema pins for non-TerraSwap forks; wrap-mapper live split fees (#516) for honest 5% math; factory whitelist remaining the listing policy (do not whitelist code 3 / 8654).

## Summary Add **other-DEX v2 LP** (Terraport, TerraSwap, Garuda DeFi, Weso World, LuncSwap) to CL8Y **Swap / Trade market** routing and the indexer **route solver**, with a **300 bps (3%) CL8Y surcharge** on any path that uses an external hop so **our market makers keep the solver edge**. Fees go to the **CMM treasury**. **Maximum one external pair per swap.** IBC **USDC** as an intermediate is **out of scope** here — follow-up issue (linked). Product goal: more tokens and deeper liquidity for retail, without routing flow away from CL8Y books/pools whenever a CL8Y path is within 3% (plus wrap / foreign AMM costs). --- ## Current codebase CL8Y routing is **factory-local, CW20-only, hybrid-first**. ### Router (execute) - [`smartcontracts/contracts/router/src/msg.rs`](smartcontracts/contracts/router/src/msg.rs) — `SwapOperation` is `TerraSwap { offer, ask, hybrid, min_return }` or rejected `NativeSwap`. `ExecuteSwapOperations` is CW20-hook only (`Use CW20 Send to initiate swaps`). `MAX_HOPS = 4`. Optional `unwrap_output` sends the **final CW20** to the wrap-mapper (`Unwrap { recipient }`), not native in. - [`smartcontracts/contracts/router/src/contract.rs`](smartcontracts/contracts/router/src/contract.rs) — each hop **`resolve_operation` queries our factory `Pair { asset_infos }`**. Foreign pair addresses cannot be executed. Hop output is **balance-delta** of the ask CW20 on the router (`hop_output_from_balance`); zero delta reverts. Blacklist guard ([`blacklist_guard.rs`](smartcontracts/contracts/router/src/blacklist_guard.rs)) only understands CW20 TerraSwap ops. - Native LUNC/USTC never enter the router. Wrap is a **separate frontend msg** to treasury / wrap-mapper so burn tax fires once ([`NATIVE_TOKEN_WRAPPING.md`](NATIVE_TOKEN_WRAPPING.md)). Unwrap fee is wrap-mapper `fee_unwrap_bps` (~51 bps live target) then treasury InstantWithdraw **1.5% burn tax** ≈ **2% all-in** ([#516](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/516)). Wrap input is `fee_wrap_bps` (**200** live target = 2%). - Errors: `NativeTokenNotSupported`, `NativeSwapNotSupported`, `PairNotFound` ([`error.rs`](smartcontracts/contracts/router/src/error.rs)). ### Indexer route solver - Graph nodes = indexed **CW20** `assets.contract_address`; edges = **our factory** `pairs` ([`docs/route-solver.md`](docs/route-solver.md), ADR [0002](docs/adr/0002-global-best-execution-route-solver.md)). - `GET /api/v1/route/solve` = global best execution: top-5 paths, ≤ **4 hops**, joint hybrid grid, winner = max **`estimated_amount_out_net`** ([#209](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/209), [#615](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/615)). Frozen F6 hops excluded ([#585](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/585)). - No foreign factory crawl, no native/IBC nodes, no “second-class” edges. ### Frontend - Swap + Trade market always use GET `/route/solve` ([#501](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/501), [#596](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/596)). Single CL8Y hop is **pair-direct** `send→swap`; router only when `ops.length ≥ 2` ([`swapRouting.ts`](frontend-dapp/src/services/terraclassic/swapRouting.ts)). - Native pay/receive = wrap then CW20 route then optional `unwrap_output`. ### Other-DEX knowledge today - Terraport factory is pinned for **migrate inventory only** — never `RegisterListedPair` ([`communityTaxMigratePairs.ts`](frontend-dapp/src/utils/communityTaxMigratePairs.ts), [`docs/terraport.md`](docs/terraport.md)). Address: `terra1n75fgfc8clsssrm2k0fswgtzsvstdaah7la6sfu96szdu22xta0q57rqqr`. - Classic TerraSwap factory (docs): `terra1jkndu9w5attpz09ut02sgey5dd3e8sq5watzm0`. Pair/router schemas are documented in [`docs/classic-terraswap-upstream-source.md`](docs/classic-terraswap-upstream-source.md) / [`docs/terraport.md`](docs/terraport.md). **Terraport/TerraSwap are not Astroport.** - Garuda (`garuda-defi.org`), Weso (`wesoworld.io` / `wesoswap_factory` candidate `terra1veqa6znu8lfdmz9kp9v047chfmn84q5k3pacme75gl8ywmplk92q6xnq2k` — **unverified in this repo**), LuncSwap (`luncswap.fun`): **no in-repo factory pin, pair wasm, or swap message shape.** - Factory CW20 whitelist (columbus-5): **`[6036, 8266, 10184]`** plus listed tax pins (11619 / 11626 / 11630 family). F6 pins live `code_id` at `CreatePair` and re-checks on write ([#582](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/582)). Code **3** adopt+list is **NO-GO** ([#627](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/627)). - CMM treasury (F4): `terra16j5u6ey7a84g40sr3gd94nzg5w5fm45046k9s2347qhfpwm5fr6sem3lr2` — **not** governance `terra1zlmv2…`. - USD marks: CEX USTC/LUNC oracles ([#515](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/515)) + DEX hub prices ([#556](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/556)). Pair TVL rollup is `pair_liquidity_usd` for **our** pairs only ([#655](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/655)). --- ## Why this is needed Retail can only swap tokens that sit on **our** factory graph. Many Classic tokens have their deepest v2 LP on Terraport / TerraSwap / other AMMs versus **LUNC or USTC**. Without foreign hops, those markets are invisible on Swap / Trade market even when a 1-hop other-DEX pool would fill the trade. We still want **CL8Y makers to win** whenever our hybrid (pool + book) is close. A **mandatory 3% CL8Y surcharge** on any route that includes an external pair makes the solver prefer our books unless the foreign path is **materially** better. That surcharge is protocol revenue to **CMM**, not a penalty on our LPs. --- ## Constraints / guardrails Invariants for this issue (**XDex-1–XDex-14**). Do not ship a venue that cannot satisfy them. | ID | Rule | |----|------| | **XDex-1 — max one external hop** | Solver, router execute, and frontend submit allow **at most one** foreign pair per swap. Two foreign hops → reject. CL8Y hops around that one foreign hop are fine (≤ `MAX_HOPS`). A swap **may be only that one foreign pair** (no CL8Y hop). | | **XDex-2 — 300 bps to CMM** | Every executed path that includes a foreign hop skims **exactly 300 bps** of the **measured foreign-hop ask delta** (integer floor) to CMM `terra16j5u6ey7a84g40sr3gd94nzg5w5fm45046k9s2347qhfpwm5fr6sem3lr2` in that ask asset (CW20 transfer or native bank send). Not governance. Not pair `FEE_CONFIG.treasury`. **No CL8Y fee-tier discount** on this 300 bps. Quote ranking uses **post-skim** amount. | | **XDex-3 — do not prefer foreign LP** | Winner compare is still max `estimated_amount_out_net`, but foreign-path **out must include**: (a) foreign AMM / pair fee, (b) 300 bps XDex-2, (c) wrap and/or unwrap costs when those msgs are actually in the execute envelope. A CL8Y-only hybrid path that loses by **&lt; 3% + wrap/foreign fees** must win. Do not add a “prefer foreign” boost. Do not rank on pre-fee foreign simulation. | | **XDex-4 — admission (solver)** | An external v2 pair is eligible **only if all** of: (1) **both CW20 legs** (if any) have live `ContractInfo.code_id` that is factory `IsCodeIdWhitelisted` (same set as F6; fail closed on query error); native `uluna` / `uusd` have no code_id and are allowed **only** as the intermediate side; (2) USD TVL ≥ **$1,000** using hub/oracle USD of **both** reserves (humanized decimals); unpriced reserve → **ineligible**, not `$1`; (3) **at least one** leg is LUNC (`uluna` or wrap cLUNC) or USTC (`uusd` or wrap cUSTC). TOKEN/TOKEN with no LUNC/USTC leg is excluded. **USDC IBC is not an intermediate on this ticket.** | | **XDex-5 — execute does not trust the foreign contract** | Router (or the msg that talks to the foreign pair) **must not** treat `simulation` / `return_amount` / events as truth. Snapshot **pre-hop balances** of offer+ask (and native denoms in play), execute, measure **delta**, revert if ask delta `< min_return`, if offer spent `> declared`, if unexpected tokens appear, or if delta is zero. No `belief_price` trust. Foreign `hybrid` / book params are **illegal** (foreign venues are pool-only). | | **XDex-6 — factory pin, not caller pair addr** | Caller may pass venue + assets; router **re-queries the pinned venue factory** `Pair { asset_infos }` and uses **that** contract. Mismatch with a supplied pair addr → revert. Unknown venue id → revert. Pair must still be on the allowlisted factory at execute (not only at index time). | | **XDex-7 — wrap / unwrap honesty (the ~5% stack)** | Default native USTC/LUNC involvement: **~2% wrap or unwrap all-in + 3% XDex-2 ≈ 5%** on top of the foreign AMM fee. Solver and You Receive **must show that stack**, not 3% only. **Skip wrap** when user **pays native USTC/LUNC** and the **first** hop is that native on the foreign pair (do not `WrapDeposit` then unwrap back). **Skip unwrap** when user **wants native USTC/LUNC** and the **last** hop’s ask is that native (do not wrap-mapper InstantWithdraw). Skipping must not send native through the CL8Y router in a way that **doubles burn tax** (see `NATIVE_TOKEN_WRAPPING.md` — user→router→pair is two tax events). Prefer: native `swap` **directly** on the pinned foreign pair with `to` = user (solo hop) or `to` = CL8Y router (more hops, CW20 ask only). | | **XDex-8 — foreign AMM fees in ranking** | Each venue’s pair fee (TerraSwap-family typically 30 bps; confirm per factory/pair `query`) is applied in the quote. Stale or missing fee → fail closed for that edge, do not assume 0. | | **XDex-9 — venue enablement** | Terraport + Classic TerraSwap may ship once LCD pair/swap/simulate shapes are pinned. Garuda / Weso / LuncSwap **stay disabled** until factory+pair+swap+simulate are LCD-probed and recorded in-repo. Unknown schema = no graph edge. | | **XDex-10 — not a CL8Y pair** | Do **not** `RegisterListedPair` foreign pairs ([#626](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/626) **M626-10** / [#634](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/634)). Community-tax extra-debit applies on **our listed hops only**. Foreign hop stays 1:1 on that venue. Do not add pair/router FoT math (**H-01**). | | **XDex-11 — hybrid stays on for CL8Y hops** | Official dApp does not turn hybrid off ([#596](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/596)). Foreign hop is pool-only; CL8Y hops on the same route still get solver hybrid splits. Single **foreign-only** hop still goes through the **router** (or an equivalent guarded execute), **not** CL8Y pair-direct `send→swap`. | | **XDex-12 — blacklist + pause + F6** | Factory token/pair blacklist and F6 freeze still fail closed. Foreign hop whose CW20 drifted off whitelist at execute reverts. Do not route gems in production ([#562](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/562)). | | **XDex-13 — integrator POST** | `POST /route/solve` and raw `execute_swap_operations` cannot smuggle a second foreign hop or skip the 300 bps skim. Contract is source of truth. | | **XDex-14 — gas** | New execute shapes need `getGasLimitForTx` mapping ([#475](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/475)). Wrap+foreign+≥2hop and unwrap combos need measured floors (siblings [#587](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/587) / [#679](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/679) / [#599](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/599)). Unmapped send hooks must throw, not silent 600k. | --- ## Relevant files **Contracts:** `smartcontracts/contracts/router/src/{msg,contract,state,error,blacklist_guard}.rs` · `smartcontracts/packages/dex-common/src/{types,factory,wrap_mapper,blacklist}.rs` · `smartcontracts/tests/src/lib.rs` (router + wrap tests) **Indexer:** `indexer/src/api/{route_solver,route_paths,route_graph,best_execution,hybrid_route_opt}.rs` · `indexer/src/indexer/` (new foreign-pair ingest) · `indexer/tests/api_route_solve*.rs` **Frontend:** `frontend-dapp/src/utils/cw20RouteSolveQuote.ts` · `directHybridQuote.ts` · `services/terraclassic/{router,swapRouting,wrapMapper,terraGas,hybridSwapGas}.ts` · Swap + Trade market submit · route row ([#158](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/158)) **Docs / pins:** `docs/route-solver.md` · `docs/adr/0002-*.md` · `docs/terraport.md` · `NATIVE_TOKEN_WRAPPING.md` · `docs/runbooks/rotate-fee-treasury.md` · `frontend-dapp/src/utils/communityTaxMigratePairs.ts` (`TERRAPORT_FACTORY_ADDRESS`) **Related issues:** #189 / #209 / #323 (solver) · #501 / #596 (retail GET hybrid) · #516 / #523 / #343 (wrap fees / unwrap) · #582 / #585 (code_id) · #607 / #615 (tax hops) · #626 / #634 (do not register foreign pairs) · #562 (gems) · #475 / #587 / #679 / #599 (gas) --- ## Recommended direction 1. **Venue catalog (config, not hardcoded per pair)** — governance/env allowlist: `{ venue_id, factory, pair_code_ids?, swap_msg_family: terraswap_v2 }`. Start with Terraport + Classic TerraSwap. Others append after LCD probe. 2. **Indexer ingest** — paginate each factory `pairs`, store `external_pairs` (venue, pair addr, asset_infos, reserves, fee_bps, usd_tvl, code_ids, last_seen). Refresh on a slower cadence than CL8Y books. Drop edges that fail XDex-4. 3. **Graph** — add **optional** foreign edges. Path enumerator still ≤4 hops / top-5, but **reject any path with &gt;1 foreign edge**. Foreign hop is never hybrid-gridded. 4. **Ranking** — simulate foreign hop via LCD pair `simulation` **only for quoting**; subtract venue fee (if not already in sim), 300 bps, wrap/unwrap when the envelope includes them. Compare to CL8Y hybrid `estimated_amount_out_net`. Cache key includes venue set + fee bps + wrap flags. 5. **Router wasm** — new `SwapOperation` variant (name TBD) carrying `venue_id` + asset infos (not a free pair addr). Execute: factory pin (XDex-6) → balance snapshot → CW20 `send` / native swap on **that** pair with `to: router` (or user on last hop) → delta + `min_return` → skim 300 bps to CMM → continue remaining CL8Y hops. Re-use hop reply machine; do not re-enter `SWAP_STATE` from a foreign callback. 6. **Solo foreign hop** — `ops.length == 1` foreign still uses the **router** (or a dedicated guarded execute that still skims 300 bps and checks deltas). Do not teach the dApp to `send` straight to a Terraport pair without the skim / invariants. 7. **Wrap skip** — frontend (and solver flags) choose wrap msgs independently of hops: `needs_wrap_in`, `needs_unwrap_out`, `foreign_first_native`, `foreign_last_native`. Retail copy stays short ([#489](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/489)): e.g. “Other DEX pool · 3% protocol fee” plus existing wrap fee note when wrap runs. 8. **ADR** — short amendment to ADR 0002: foreign edges, 300 bps, max-1, wrap skip. `solver_version` bump when ranking changes. Do **not**: proxy through a foreign **router** (unbounded hops, extra trust); treat Terraport events as our volume for CG/CMC; list foreign TVL on `/pool`; turn hybrid off. --- ## Acceptance criteria - [ ] GET `/route/solve` can return a path with **exactly one** `external` (or equivalent) hop when it **beats** CL8Y-only after 300 bps + wrap/foreign fees; otherwise CL8Y-only wins. - [ ] Paths with two foreign hops are never returned; execute of such ops **reverts**. - [ ] A **foreign-only** 1-hop swap quotes and executes (300 bps to CMM, delta checks). - [ ] External pair excluded unless both CW20 `code_id`s are factory-whitelisted, USD TVL ≥ $1,000, and one leg is LUNC or USTC (native or wrap). - [ ] Unpriced USD or LCD code_id failure → pair ineligible / execute revert (fail closed). - [ ] Execute measures balances; revert on shortfall vs `min_return` / `minimum_receive` even if the foreign pair’s sim looked good. - [ ] 300 bps of measured ask delta lands on **CMM**; not governance; not skipped for discounted traders. - [ ] USTC/LUNC **output** + foreign last hop native ask → **no** wrap-mapper unwrap; user receives native; quote is not charged unwrap ~2%. - [ ] USTC/LUNC **input** + foreign first hop native offer → **no** wrap deposit; quote is not charged wrap 2%; burn tax is not doubled. - [ ] When wrap or unwrap **does** run, You Receive includes that cost (**~5%** protocol+wrap vs ~3% when skipped). - [ ] Foreign pair fee is in the quote; missing fee → no edge. - [ ] Terraport + TerraSwap enabled only with pinned factories; Garuda/Weso/LuncSwap off until probed. - [ ] Swap + Trade market submit match the solver ops (quote = execute). Route row can show one “other DEX” hop without an architecture essay. - [ ] Gas mapped; hybrid remains on for CL8Y hops; no `pool_only=true` on the official dApp. - [ ] Docs: route-solver + ADR 0002 amendment + venue pin table. Verify target: `make verify-issue-<iid>`. --- ## Test plan (functional paths) **Contracts (multitest + mocks of a TerraSwap-shaped pair):** 1. CL8Y-only multi-hop — unchanged. 2. CL8Y hop → one foreign hop → CL8Y hop (if hop cap allows) — deltas, 300 bps, `minimum_receive` post-skim. 3. Foreign hop first / last / only. 4. `min_return` on foreign hop; `minimum_receive` on router. 5. Reject 0, 2+ foreign ops; reject `hybrid` on foreign op; reject spoofed pair addr vs factory pin. 6. Native first hop skip-wrap sequence (no native sitting on router across a tax boundary). 7. Native last hop skip-unwrap; `unwrap_output: true` + native last hop → revert or ignore unwrap (pick one, test it). 8. CMM receives 300 bps; 1-unit dust floor (no underflow); fee-tier trader still pays 300 bps. 9. Blacklisted token/pair/sender; F6-unlisted `code_id` at execute; wrap-mapper unset when unwrap still requested. **Indexer:** 10. Ingest Terraport-shaped pair; drop TVL &lt; $1000; drop TOKEN/TOKEN; drop unwhitelisted `code_id`; drop unpriced USD. 11. Path search: foreign edge used only when post-fee out wins; CL8Y hybrid wins when within the fee stack. 12. Never enumerate 2 foreign hops; cache isolation vs CL8Y-only. 13. Tax-aware net ranking (#615) still applies on **CL8Y** hops; foreign hop not treated as listed-pair tax. 14. `pool_only=true` integrator path: still max-1 foreign if allowed at all (or explicitly no foreign — document and test). **Frontend (Vitest + Playwright when LocalTerra can mock a foreign pair):** 15. Swap + Trade market GET quote includes foreign hop; submit ops match. 16. Wrap skipped / unwrap skipped flags vs wrap notes. 17. Solo foreign hop uses router envelope, not pair-direct CL8Y swap. 18. Gas inventory includes new send-hook shapes. 19. Disconnect / indexer 404 / LCD fail — no silent pool-only foreign execute. **Venues:** LCD fixtures for Terraport + TerraSwap factory `Pair` + pair `simulation`. Garuda/Weso/LuncSwap: probe issue comment **or** stay `disabled`. --- ## Test plan (attack, hack, abuse) | Vector | Expect | |--------|--------| | **Malicious / migrated foreign pair** returns less than sim, or `simulation` lies | Delta &lt; `min_return` → revert; user not left mid-hop (`SWAP_STATE` cleared or never committed). | | **Caller supplies attacker pair addr** that is not factory `Pair` | Revert (XDex-6). | | **Reentrancy** — foreign pair callbacks into router mid-hop | `SwapInProgress` or no writable re-entry; no double-skim / drain. | | **Unexpected token / donation** to router during hop | Ignore donations; only **delta** of declared ask counts; do not credit attacker-chosen denom toward `minimum_receive`. | | **FoT / tax CW20** on foreign hop | Fail closed unless 1:1 inbound (H-01). Do not add FoT math. Listed tax still extra-debits **our** hops only. | | **Fee-on-transfer skim bypass** (send 100, pair sees 99) | Offer spent vs declared checked; revert if pair pulled more than snapshot. | | **Two foreign hops** in POST body / crafted wasm msg | Contract revert; indexer never suggests it. | | **Skip 300 bps** via pair-direct send to Terraport | Official dApp never builds that msg. Integrator who bypasses router is out of product scope; do not document it as supported. | | **Discount-tier / `trader` spoof** to zero the 3% | 300 bps not discounted; `trader` still required on CL8Y hops for tax (#607). | | **Flash / momentary $1k TVL** | TVL from last ingest snapshot + execute-time reserve check recommended (stale-low is OK; stale-high is the risk). If execute-time TVL &lt; $1k, revert or still allow if sim/`min_return` holds — **prefer execute-time reserve floor** so flash LP cannot advertise then empty. | | **Oracle / hub USD spoof** to pass $1k | Use same hub/oracle as #515/#556; unpriced → ineligible. Do not use last print. | | **Code_id drift** after ingest (F6) | Execute re-queries `ContractInfo` + `IsCodeIdWhitelisted`; revert. | | **Native double burn tax** via router-held `uusd` | Tests prove skip-wrap path does not `BankMsg` native to router then out again. | | **Unwrap still on** when last hop already paid native | User must not pay InstantWithdraw on a zero/wrong CW20. | | **Sandwich / stale quote** | `max_spread` / `min_return` / deadline remain; solver still advisory. | | **Gem / unlisted junk** via foreign TOKEN/LUNC | Production gem hide (#562); whitelist code_id gate. | | **Blacklist bypass** by hopping out to foreign and back | Blacklist still sees all CW20s in ops; native denoms in foreign ops included in the check. | | **CMM address substitution** | CMM is instantiate/governance pin, not caller-supplied. | | **Integer overflow / 0-amount skim** | Floor bps on `Uint128`; zero ask reverts before skim. | | **LCD sim grief** (foreign factory query gas / 500) | Indexer: `ContractQueryRejected` without treating as infinite liquidity; skip edge. | | **Volume washing / fake CG volume** | Do not ingest foreign swaps into `swap_events` as CL8Y volume. | --- ## Verification criteria Close when: 1. `make verify-issue-<iid>` exists and covers contracts + indexer unit/integration (Postgres) + frontend Vitest for wrap-skip flags and op shapes. 2. LocalTerra (or mocked foreign pair) executes: (a) CL8Y-only still preferred when foreign is &lt; fee stack better, (b) foreign-only hop pays CMM 3%, (c) skip unwrap on native last hop, (d) skip wrap on native first hop, (e) two-foreign execute reverts, (f) lying pair sim reverts on delta. 3. Columbus-5 probe comment: Terraport + TerraSwap factory pins, sample pair TVL ≥ $1k, whitelist `code_id`s, CMM balance delta on a dust trade (or dry-run with `DRY_RUN` docs). Garuda/Weso/LuncSwap listed as enabled **or** explicitly disabled with probe notes. 4. No `RegisterListedPair` on foreign addrs. Hybrid checkbox not reintroduced. ADR 0002 + `docs/route-solver.md` updated. **Blocked on:** venue schema pins for non-TerraSwap forks; wrap-mapper live split fees (#516) for honest 5% math; factory whitelist remaining the listing policy (do not whitelist code 3 / 8654).
PlasticDigits commented 2026-08-27 11:52:44 +00:00 (Migrated from gitlab.com)

mentioned in issue #691

mentioned in issue #691
PlasticDigits commented 2026-08-27 11:52:45 +00:00 (Migrated from gitlab.com)

marked as related to #691

marked as related to #691
PlasticDigits commented 2026-08-27 11:52:54 +00:00 (Migrated from gitlab.com)

Follow-up for IBC USDC as a common intermediate (not in this ticket’s admission set): #691.

USDC stays analysis/design until this issue’s execute path (max-1 foreign hop, 300 bps to CMM, balance-delta) is specified. Do not expand XDex-4 to USDC on the implementation MR for this issue.

Follow-up for IBC USDC as a common intermediate (not in this ticket’s admission set): #691. USDC stays analysis/design until this issue’s execute path (max-1 foreign hop, 300 bps to CMM, balance-delta) is specified. Do not expand XDex-4 to USDC on the implementation MR for this issue.
PlasticDigits commented 2026-08-30 05:24:17 +00:00 (Migrated from gitlab.com)

mentioned in issue #708

mentioned in issue #708

Charge 2.9% instead of 3%, on top of any wrap or 3rd party dex fees (do not cover those with the 2.9%)

Charge 2.9% instead of 3%, on top of any wrap or 3rd party dex fees (do not cover those with the 2.9%)
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#690
No description provided.