fix(swap): USTR→USDT quotes size-inconsistent through UST1/cLUNC #1257

Closed
opened 2026-09-12 13:33:21 +00:00 by PlasticDigits · 2 comments

Summary

Home Swap (/) quotes listed USTR → listed USDT with implausible, size-inconsistent You Receive values while showing CL8Y-internal multi-hop routes through UST1 and cLUNC (sometimes inserting cUSTC). Both assets are product CW20s in tokenRegistry (USTR 18, USDT 18). The session is quote-only (wallet disconnected).

Observed on /?from=USTR&to=… (canonical Swap query, #711 / #713):

You Pay (USTR) You Receive (USDT) Disclosed route Notes
1 ~9.091B USTR → UST1 → cUSTC → cLUNC → USDT Network fee ~216 LUNC; Quote only
1 ~8.855B USTR → UST1 → cLUNC → USDT ~165 LUNC fee; same pair, shorter path, still 10^9-scale
100 0.3134 (same Swap surface; green Tether USDT chrome) ~0.00313 USDT per USTR
1000 0.4559 same ~0.00046 per USTR — worse per-unit than 100
10000 80.02 same ~0.00800 per USTR — better per-unit than 100

A constant-product path on fixed token ids cannot improve per-unit output as size grows, and it cannot print billions of USDT for 1 USTR while 10k USTR prints ~80. Something in scale, token identity, or solver scoring is lying. Execution is LIMIT BOOK + POOL (always-on hybrid, #596).

This is not closed #15 (Swap used to send human strings with no 10^decimals). This is not closed #464 (18-dec Uint128 k-overflow; that bricks quotes, it does not print billions). This is not closed #564 / #544 (Charts / pool volume chrome). This is not closed #529 (limit-price cap on UST1/USTR). This is not closed #678 (unfunded-size acquire copy). This is not open #1218 (LUNC→USTR hop-count / native wrap never reaching GET /route/solve). This is not open #1255 (unlisted factory CW20 getDecimals ?? 6; that ticket already carves out listed USTR/USDT). This is not ops #1221 (bridge + CMM seed USDT/cLUNC). This is not #690 (other-DEX v2 hops). Do not fold those tickets into this one.

Venue 24h volume rankings (CL8Y vs Terraport / TerraSwap / Garuda / Weso / …) are context only. They do not make foreign hops the fix. Ship a correct CL8Y-graph quote first.

Current codebase

USTR and USDT are registry product tokens:

  • frontend-dapp/src/utils/tokenRegistry.ts — USTR 18, USDT 18; CW20 map includes terra1vy3kc0swag2rhn7jz6n72jp0l2ns0p6r6ez5grxq5uhj2rvs97fqfsetxv (USTR) and terra1z0xe7t5ymmltg4vju8tghkq0pewy4et548ta23nlu9zxtl950uyqkv8mv4 (USDT).
  • Intermediates on the disclosed routes are 6-dec: UST1, cUSTC, cLUNC (tokenRegistry + wrap mapper). First hop is mixed 18/6 (USTR/UST1). Last hop is mixed 6/18 (cLUNC/USDT) once a USDT/cLUNC factory pair exists (#1221).

CW20 Swap/Trade market quotes go through indexer global best-execution, then a wallet sim of the returned ops:

  • quoteCw20ViaRouteSolve (frontend-dapp/src/utils/cw20RouteSolveQuote.ts) — GET /api/v1/route/solve + simulateMultiHopSwap + hop preflight. You Receive is wallet return_amount (net of catalog buy split, #615), humanized with getDecimals / fromRawAmount.
  • SwapPage.tsx — offerDecimals / receiveDecimals; simRaw = toRawAmount(amount, offerDecimals). Disconnected sessions still quote (#678).
  • Indexer: find_paths_top_k (≤5 simple paths, shortest-first), then max estimated_amount_out_net (docs/route-solver.md, best_execution.rs, route_paths.rs). GET_DEFAULT_MAX_HOPS = 4. AMOUNT_CACHE_BUCKET = 1_000_000 raw offer units.
  • Hop-count bias is documented: paths outside the top-5 shortest are never scored. That is the #1218 family for native wrap I/O. USTR and USDT are CW20, so this pair does hit /route/solve. If both the 3-hop and 4-hop still print billions, ranking among those candidates is not enough — the per-hop sim / humanize is wrong or the USDT identity is not the registry id.

Slippage chrome (docs/frontend.md, swapRouteSlippage.ts, docs/swap-max-spread-ux.md): >5% banner, >30% Expert block, ≥99% extreme alert. Unit-size 9e9 USDT out did not fail-close. Either spot_amount_out / slippage_percent is scaled with the same lie, or the 99% gate is not applied to indexer multihop receive chrome.

Picker chrome: some quotes showed non-registry USDT art, later quotes the tokenlist Tether logo. Identity is contract / denom, never ticker (#715 X1 / QS-4). A second factory CW20 named USDT is a check (then #1255 on the ask leg). It does not replace mixed-decimal hop tests on the registry pair.

Why the new implementation is needed

  1. Retail Swap is showing a live-looking USDT receive that cannot be the economic output of 1 USTR. Quote-only theater is how people screenshot “the price.”
  2. Size 100 / 1000 / 10000 are not a single AMM impact curve. Either the solver is switching paths/identities without a honest scale, or hop math mixes 18-dec offer raw into 6-dec pools (or the reverse on the ask).
  3. Mixed 6/18 is already a known USTR/UST1 surface (#529 limits, #564 charts). Swap execute still goes through those hops for USDT. Charts/tape fixes do not cover GET /route/solve + wallet sim + You Receive.
  4. #1218 ranking-by-output cannot save a path whose LCD/DB sim is 10^12 off. Fix the USTR→USDT CW20 quote/sim (and fail-close implausible hops) here.

Constraints / guardrails

  • Do not treat this as native wrap. Do not route USTR/USDT through findRouteWithNativeSupport / simulateNativeSwap. Keep H596-7 (native paths pool-only) untouched.
  • Do not implement other-DEX edges (#690 XDex-1–14) as the resolution. CL8Y-graph honesty first.
  • Do not default unknown CW20 decimals to 6. If the ask id is not CW20_MAP USDT, that is #1255 — fail closed here until decimals resolve; do not duplicate that helper.
  • Do not use bundled tokenlist.json decimals as the amount source (C542-11). Registry is the fast path for these two product ids; hop legs use indexer pair-leg / LCD token_info in 0…18.
  • Identity by contract_addr / denom. Two CW20s with symbol=USDT must not share a quote. Deep link from=USTR&to=USDT follows #715 unique-symbol CI; colliding USDT → unique-symbol rules, not “first USDT in the picker.”
  • Fail closed when a hop sim is implausible: ask out > that hop’s ask reserve (humanized with that leg’s decimals), worst-hop spread ~100%, or estimated_amount_out that humanizes 10^9× away from hub/oracle cross (USTR hub vs USDT). Do not show it as You Receive. Prefer no quote / Calculating / extreme-slippage block over theater.
  • #678 / 5% confirm-again / 30% Expert / 99% / blacklist / pause / freeze / extra-debit stay. A 10^9-scale receive must hit the 99% path (or a stronger fail-closed). Do not skip those because “routing was weird.”
  • Rank enumerated CW20 paths by simulated output (estimated_amount_out_net), not hop count. MAX_PATH_CANDIDATES = 5 and hop cap 4 stay unless a test proves the honest USTR→USDT path is outside top-5 shortest — then raise K with a LCD-budget note in docs/route-solver.md, do not silently take first BFS.
  • AMOUNT_CACHE_BUCKET must not alias distinct 18-dec sizes or mix 6-dec and 18-dec keys. 1 USTR (10^18) must not share a cache entry with a 6-dec 1000000 offer on another pair.
  • Wallet sim and indexer estimated_amount_out must agree on raw USDT. You Receive uses the same decimals as simulateSwap / router execute. No “display 18, submit 6.”
  • U1: UST1 as a hop is AMM, not /ust1 mint/redeem. Do not add window copy to this ticket.
  • Gems (#562) stay hidden. Do not honor ?showGems=1.
  • No contract migrate, no factory native-IBC, no CMM seed amounts, no Coolify/host notes.

Relevant files

Path Why
frontend-dapp/src/pages/SwapPage.tsx Pay/receive decimals, simRaw, You Receive, route chrome, quote-only
frontend-dapp/src/utils/cw20RouteSolveQuote.ts Indexer solve + wallet sim; return_amount vs spot
frontend-dapp/src/utils/formatAmount.ts getDecimals / toRawAmount / fromRawAmount
frontend-dapp/src/utils/tokenRegistry.ts Listed USTR 18 / USDT 18 map
frontend-dapp/src/utils/swapRouteSlippage.ts 5 / 30 / 99 gates vs spot_amount_out
frontend-dapp/src/utils/swapRouteDisplay.ts Route chip vs intermediate_tokens
frontend-dapp/src/services/terraclassic/swapRoutePreflight.ts Per-hop spread; submit disable
indexer route_paths.rs / best_execution.rs / hybrid_route_opt.rs / route_solver.rs Top-K, hop sim, cache bucket, output ranking
docs/route-solver.md Shortest-first top-5; optimality scope
docs/frontend.md Swap quote / slippage invariants
docs/swap-max-spread-ux.md Extreme quote must not look executable
skills/AGENTS_FRONTEND_SWAP_ROUTE_DISPLAY.md Route chip vs ops
  1. Pin the ask id. RTL/mainnet fixture: picker USDT must be CW20_MAP terra1z0xe7… (or documented unique-symbol winner). If LCD token_info.decimals ≠ 18, fail closed (do not quote). Log/test the id used for token_out and receiveDecimals.
  2. Per-hop decimals. Indexer + wallet hop sim take pair-leg decimals from the indexed pair row / LCD token_info, not a single 6. Vitest/Rust: offer 10^18 USTR into UST1/cLUNC hops must not treat offer as 10^6. Last hop cLUNC → USDT must return USDT raw at 18.
  3. Humanize once. fromRawAmount(walletSim, 18) for registry USDT. A fixture where sim raw is 9.091e15 must display 0.009091, never 9.091B. Inverse: human 1 USTR → simRaw = 10^18.
  4. Fail-close implausible hops. After each hop sim, reject (no winner) if ask out > ask reserve or spread ≥ ~100%. Swap must not paint You Receive for a rejected winner. 99% chrome is backup, not the only gate.
  5. Size curve. Same token ids, amounts 1 / 100 / 1000 / 10000: per-unit out is monotonically non-increasing or the route chip changes and each quote still passes (3)–(4). No 10^9 at size 1 while size 10k is O(10^1–10^2).
  6. Keep ranking-by-output on the CW20 graph (do not regress #209 / #615). If the honest path is 4 hops and a 3-hop dust USDT pool scores higher only because of the scale bug, (2)+(4) make the 3-hop lose. Do not open #1218 native wrap here.

Expected vs actual

  • Expected: 1 listed USTR pays 10^18 raw. You Receive is USDT at 18-dec, in a plausible band vs hub USTR USD and USDT ~$1 (order-of-magnitude, not a peg promise). Larger pay on the same ids cannot print a higher per-unit USDT out. 10^9-scale receive is blocked.
  • Actual: 1 USTR shows ~9B USDT on two different UST1/cLUNC routes; 100 / 1000 / 10000 are non-monotonic per-unit; Quote only still looks like a market.

Acceptance criteria

  • Given picker pay = registry USTR and receive = registry USDT (both LCD decimals 18), user on /
    When they enter human 1 and the CW20 quote settles
    Then simRaw / amount_in is 10^18, You Receive is not 10^9-scale, and it equals fromRawAmount(walletSim, 18) where walletSim matches indexer estimated_amount_out (pre-buy-tax).

  • Given the same token ids and human amounts 100, 1000, 10000
    When three quotes settle
    Then USDT-per-USTR is monotonically non-increasing, or the disclosed route (ops / intermediate_tokens) changed and each quote still passes the reserve/spread fail-closed checks. None of the three display 10^9-scale USDT.

  • Given a candidate path whose last hop (cLUNC→USDT or UST1→…→USDT) simulates ask out greater than that hop’s ask reserve, or worst-hop spread ~100%
    When GET /route/solve ranks paths
    Then that path is not the winner; Swap shows no theater You Receive for it (no quote / next-best honest path / extreme block).

  • Given spot_amount_out and wallet out that differ by ≥99% on this pair
    When the quote paints
    Then the existing ≥99% extreme path is visible and submit stays blocked (Expert does not waive 99%).

  • Given two factory CW20s with symbol=USDT
    When quoting USTR → USDT from /?from=USTR&to=USDT
    Then token_out is the unique-symbol winner (#715), never a silent other USDT; decimals follow that contract.

  • AC: Direct listed 6-dec pairs (UST1/cUSTC) and USTR→UST1 1-hop (if in top-K) do not regress. Native LUNC wrap remains #1218.

  • AC: Docs: Swap USTR/USDT (18↔6 hops) use per-leg decimals; implausible hop sims are dropped. make verify-issue-<iid> green.

Test plan (functional paths)

# Path Expect
T1 Registry USTR→USDT, type 1 Offer raw 10^18; receive human not 10^9-scale
T2 Same, type 100 / 1000 / 10000 Per-unit out non-increasing or route change + fail-closed
T3 Wallet sim raw 9.091e15 USDT 18-dec Display 0.009091, not 9.091B
T4 Indexer hop USTR(18)→UST1(6) with offer 10^18 UST1 out scaled as 6-dec; not treated as 18-dec UST1
T5 Last hop cLUNC(6)→USDT(18) USDT raw 18; humanize 18
T6 3-hop USTR→UST1→cLUNC→USDT with ~100% last-hop spread Not winner; no billions chrome
T7 4-hop inserting cUSTC, honest sim better Can win on output; still 18-dec USDT display
T8 ≥99% wallet vs spot Extreme alert; submit blocked
T9 Unique-symbol USDT vs second symbol=USDT Quote uses mapped contract only
T10 UST1/cUSTC 1 Unchanged 6-dec quote
T11 Cache: 1 USTR then 10000 USTR Second quote not the first’s scaled ghost
T12 Disconnected quote-only Same honesty gates; no extra acquire epic (#678)

Test plan — attack, hack, and abuse

Vector Expected
18-dec raw shown as 6-dec (10^12 You Receive) Forbidden. Fixture T3 must fail the UI if fromRawAmount(..., 6) is used for registry USDT.
6-dec hop offer using 18-dec raw (drains 6-dec pool in sim) Forbidden. Hop sim uses that pair’s offer-leg decimals.
Symbol spoof — second USDT CW20 Identity is contract; unique-symbol CI; no merge of quotes.
Tokenlist decimals as amount source Forbidden (C542-11).
Cache alias AMOUNT_CACHE_BUCKET across 6 vs 18 Distinct keys; 1 USTR ≠ 1 UST1.
Top-K hop-count only — skip scoring 4-hop Winner is max simulated net out among enumerated paths; dust 3-hop cannot win via scale lie.
Foreign DEX hop smuggled to “fix” liquidity Out of scope (#690). Tests must still pass on factory graph.
Gem bridge / ?showGems=1 Still rejected (shouldRejectGemBridgeQuote).
Hostile token_info.decimals 255 Unresolved; no quote; no 10**255.
Quote vs execute skew — display billions, broadcast dust Signing modal / min received / simRaw match the same decimals (#356).
Skip 99% / Expert / extra-debit because “stablecoin route” Forbidden.
XSS in route chip symbols Text nodes / existing sanitizers; no raw query HTML.

Verification criteria

make verify-issue-<iid>
# expected:
# - vitest: registry USDT receive uses 18; 9.091e15 raw ≠ 9.091B chrome
# - vitest/indexer: mixed 18/6 hop offer 10^18 does not score as 6-dec offer
# - implausible hop (out > reserve or ~100% spread) is not GET /route/solve winner
# - size 1 vs 100/1000/10000 per-unit monotonicity or disclosed route change
# - docs/route-solver.md + docs/frontend.md: 18↔6 USTR/USDT hops; fail-closed theater

Manual: on / with USTR→USDT, type 1 then 10000. Unit size must not show billions while 10k shows tens of USDT. Route chip must match wallet ops. 99% / hop preflight must catch leftover theater.

Out of scope

  • Reopening #15 / #464 / #564 / #529 / #678.
  • Commenting on or expanding #1218 (native LUNC wrap → solve).
  • Unlisted factory decimals (#1255) except the dual-USDT identity check.
  • Bridging / CMM seed (#1221).
  • Other-DEX solver (#690) and IBC USDC intermediates (#691).
  • Parallel amount split (#1203).
  • Contract Uint256 work (already on main via #464).

First-pass model recommendation

Recommendation: grok-high

Rationale: Root cause is not a one-helper patch. The failing surface spans indexer top-K + mixed 18/6 hop simulation (route_paths / best_execution / hop sim), Swap humanize (SwapPage, formatAmount, cw20RouteSolveQuote), and 99% / preflight fail-closed. That is more than three production files, crosses subsystems, and is funds-adjacent quote/execute scale (wrong You Receive is a money bug). Comparable DEX work is #1218 / #690 (solver + Swap), not a single RCA doc or test-helper change. Verification needs fixtures at four sizes plus hop-reserve checks, not a single deterministic local edit. Composer’s bar fails on file count, cross-cutting protocol/quote state, and uncertain ranking-vs-scale diagnosis. Grok high for the first pass.

## Summary Home Swap (`/`) quotes **listed USTR → listed USDT** with implausible, **size-inconsistent** You Receive values while showing CL8Y-internal multi-hop routes through **UST1 and cLUNC** (sometimes inserting **cUSTC**). Both assets are product CW20s in `tokenRegistry` (USTR 18, USDT 18). The session is quote-only (wallet disconnected). Observed on `/?from=USTR&to=…` (canonical Swap query, #711 / #713): | You Pay (USTR) | You Receive (USDT) | Disclosed route | Notes | | --- | --- | --- | --- | | 1 | ~9.091B | USTR → UST1 → cUSTC → cLUNC → USDT | Network fee ~216 LUNC; Quote only | | 1 | ~8.855B | USTR → UST1 → cLUNC → USDT | ~165 LUNC fee; same pair, shorter path, still 10^9-scale | | 100 | 0.3134 | (same Swap surface; green Tether USDT chrome) | ~0.00313 USDT per USTR | | 1000 | 0.4559 | same | ~0.00046 per USTR — **worse** per-unit than 100 | | 10000 | 80.02 | same | ~0.00800 per USTR — **better** per-unit than 100 | A constant-product path on fixed token ids cannot improve per-unit output as size grows, and it cannot print **billions of USDT for 1 USTR** while 10k USTR prints ~80. Something in **scale, token identity, or solver scoring** is lying. Execution is LIMIT BOOK + POOL (always-on hybrid, #596). This is not closed [#15](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/15) (Swap used to send human strings with no `10^decimals`). This is not closed [#464](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/464) (18-dec `Uint128` k-overflow; that bricks quotes, it does not print billions). This is not closed [#564](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/564) / [#544](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/544) (Charts / pool volume chrome). This is not closed [#529](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/529) (limit-price cap on UST1/USTR). This is not closed [#678](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/678) (unfunded-size acquire copy). This is not open [#1218](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/1218) (LUNC→USTR hop-count / native wrap never reaching `GET /route/solve`). This is not open [#1255](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/1255) (unlisted factory CW20 `getDecimals ?? 6`; that ticket already carves out listed USTR/USDT). This is not ops [#1221](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/1221) (bridge + CMM seed USDT/cLUNC). This is not [#690](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/690) (other-DEX v2 hops). Do not fold those tickets into this one. Venue 24h volume rankings (CL8Y vs Terraport / TerraSwap / Garuda / Weso / …) are **context only**. They do not make foreign hops the fix. Ship a correct CL8Y-graph quote first. ## Current codebase USTR and USDT are registry product tokens: - `frontend-dapp/src/utils/tokenRegistry.ts` — USTR 18, USDT 18; CW20 map includes `terra1vy3kc0swag2rhn7jz6n72jp0l2ns0p6r6ez5grxq5uhj2rvs97fqfsetxv` (USTR) and `terra1z0xe7t5ymmltg4vju8tghkq0pewy4et548ta23nlu9zxtl950uyqkv8mv4` (USDT). - Intermediates on the disclosed routes are **6-dec**: UST1, cUSTC, cLUNC (`tokenRegistry` + wrap mapper). First hop is mixed 18/6 (USTR/UST1). Last hop is mixed 6/18 (cLUNC/USDT) once a USDT/cLUNC factory pair exists (#1221). CW20 Swap/Trade market quotes go through indexer global best-execution, then a wallet sim of the returned ops: - `quoteCw20ViaRouteSolve` (`frontend-dapp/src/utils/cw20RouteSolveQuote.ts`) — `GET /api/v1/route/solve` + `simulateMultiHopSwap` + hop preflight. You Receive is wallet `return_amount` (net of catalog buy split, #615), humanized with `getDecimals` / `fromRawAmount`. - `SwapPage.tsx` — `offerDecimals` / `receiveDecimals`; `simRaw = toRawAmount(amount, offerDecimals)`. Disconnected sessions still quote (#678). - Indexer: `find_paths_top_k` (≤5 simple paths, **shortest-first**), then max `estimated_amount_out_net` (`docs/route-solver.md`, `best_execution.rs`, `route_paths.rs`). `GET_DEFAULT_MAX_HOPS = 4`. `AMOUNT_CACHE_BUCKET = 1_000_000` **raw** offer units. - Hop-count bias is documented: paths outside the top-5 shortest are never scored. That is the #1218 family for **native** wrap I/O. USTR and USDT are CW20, so this pair **does** hit `/route/solve`. If both the 3-hop and 4-hop still print billions, ranking among those candidates is not enough — the **per-hop sim / humanize** is wrong or the USDT identity is not the registry id. Slippage chrome (`docs/frontend.md`, `swapRouteSlippage.ts`, `docs/swap-max-spread-ux.md`): >5% banner, >30% Expert block, ≥99% extreme alert. Unit-size **9e9 USDT out** did not fail-close. Either `spot_amount_out` / `slippage_percent` is scaled with the same lie, or the 99% gate is not applied to indexer multihop receive chrome. Picker chrome: some quotes showed non-registry USDT art, later quotes the tokenlist Tether logo. Identity is **contract / denom**, never ticker (#715 X1 / QS-4). A second factory CW20 named USDT is a **check** (then #1255 on the ask leg). It does not replace mixed-decimal hop tests on the registry pair. ## Why the new implementation is needed 1. Retail Swap is showing a live-looking USDT receive that cannot be the economic output of 1 USTR. Quote-only theater is how people screenshot “the price.” 2. Size 100 / 1000 / 10000 are not a single AMM impact curve. Either the solver is switching paths/identities without a honest scale, or hop math mixes 18-dec offer raw into 6-dec pools (or the reverse on the ask). 3. Mixed 6/18 is already a known USTR/UST1 surface (#529 limits, #564 charts). Swap execute still goes through those hops for USDT. Charts/tape fixes do not cover `GET /route/solve` + wallet sim + You Receive. 4. #1218 ranking-by-output cannot save a path whose LCD/DB sim is 10^12 off. Fix the USTR→USDT CW20 quote/sim (and fail-close implausible hops) here. ## Constraints / guardrails - **Do not** treat this as native wrap. Do not route USTR/USDT through `findRouteWithNativeSupport` / `simulateNativeSwap`. Keep H596-7 (native paths pool-only) untouched. - **Do not** implement other-DEX edges (#690 XDex-1–14) as the resolution. CL8Y-graph honesty first. - **Do not** default unknown CW20 decimals to 6. If the ask id is **not** `CW20_MAP` USDT, that is #1255 — fail closed here until decimals resolve; do not duplicate that helper. - **Do not** use bundled `tokenlist.json` `decimals` as the amount source (C542-11). Registry is the fast path for these two product ids; hop legs use indexer pair-leg / LCD `token_info` in `0…18`. - Identity by `contract_addr` / denom. Two CW20s with `symbol=USDT` must not share a quote. Deep link `from=USTR&to=USDT` follows #715 unique-symbol CI; colliding USDT → unique-symbol rules, not “first USDT in the picker.” - Fail closed when a hop sim is implausible: ask out > that hop’s ask reserve (humanized with **that** leg’s decimals), worst-hop spread ~100%, or `estimated_amount_out` that humanizes 10^9× away from hub/oracle cross (USTR hub vs USDT). Do not show it as You Receive. Prefer no quote / Calculating / extreme-slippage block over theater. - #678 / 5% confirm-again / 30% Expert / 99% / blacklist / pause / freeze / extra-debit stay. A 10^9-scale receive must hit the 99% path (or a stronger fail-closed). Do not skip those because “routing was weird.” - Rank enumerated CW20 paths by **simulated output** (`estimated_amount_out_net`), not hop count. `MAX_PATH_CANDIDATES = 5` and hop cap 4 stay unless a test proves the honest USTR→USDT path is outside top-5 shortest — then raise K with a LCD-budget note in `docs/route-solver.md`, do not silently take first BFS. - `AMOUNT_CACHE_BUCKET` must not alias distinct 18-dec sizes or mix 6-dec and 18-dec keys. 1 USTR (`10^18`) must not share a cache entry with a 6-dec `1000000` offer on another pair. - Wallet sim and indexer `estimated_amount_out` must agree on **raw** USDT. You Receive uses the same decimals as `simulateSwap` / router execute. No “display 18, submit 6.” - U1: UST1 as a **hop** is AMM, not `/ust1` mint/redeem. Do not add window copy to this ticket. - Gems (#562) stay hidden. Do not honor `?showGems=1`. - No contract migrate, no factory native-IBC, no CMM seed amounts, no Coolify/host notes. ## Relevant files | Path | Why | | --- | --- | | `frontend-dapp/src/pages/SwapPage.tsx` | Pay/receive decimals, `simRaw`, You Receive, route chrome, quote-only | | `frontend-dapp/src/utils/cw20RouteSolveQuote.ts` | Indexer solve + wallet sim; `return_amount` vs spot | | `frontend-dapp/src/utils/formatAmount.ts` | `getDecimals` / `toRawAmount` / `fromRawAmount` | | `frontend-dapp/src/utils/tokenRegistry.ts` | Listed USTR 18 / USDT 18 map | | `frontend-dapp/src/utils/swapRouteSlippage.ts` | 5 / 30 / 99 gates vs `spot_amount_out` | | `frontend-dapp/src/utils/swapRouteDisplay.ts` | Route chip vs `intermediate_tokens` | | `frontend-dapp/src/services/terraclassic/swapRoutePreflight.ts` | Per-hop spread; submit disable | | `indexer` `route_paths.rs` / `best_execution.rs` / `hybrid_route_opt.rs` / `route_solver.rs` | Top-K, hop sim, cache bucket, output ranking | | `docs/route-solver.md` | Shortest-first top-5; optimality scope | | `docs/frontend.md` | Swap quote / slippage invariants | | `docs/swap-max-spread-ux.md` | Extreme quote must not look executable | | `skills/AGENTS_FRONTEND_SWAP_ROUTE_DISPLAY.md` | Route chip vs ops | ## Recommended direction 1. **Pin the ask id.** RTL/mainnet fixture: picker USDT must be `CW20_MAP` `terra1z0xe7…` (or documented unique-symbol winner). If LCD `token_info.decimals` ≠ 18, fail closed (do not quote). Log/test the id used for `token_out` and `receiveDecimals`. 2. **Per-hop decimals.** Indexer + wallet hop sim take **pair-leg** decimals from the indexed pair row / LCD `token_info`, not a single 6. Vitest/Rust: offer `10^18` USTR into UST1/cLUNC hops must not treat offer as `10^6`. Last hop cLUNC → USDT must return USDT raw at 18. 3. **Humanize once.** `fromRawAmount(walletSim, 18)` for registry USDT. A fixture where sim raw is `9.091e15` must display `0.009091`, never `9.091B`. Inverse: human `1` USTR → `simRaw = 10^18`. 4. **Fail-close implausible hops.** After each hop sim, reject (no winner) if ask out > ask reserve or spread ≥ ~100%. Swap must not paint You Receive for a rejected winner. 99% chrome is backup, not the only gate. 5. **Size curve.** Same token ids, amounts 1 / 100 / 1000 / 10000: per-unit out is monotonically non-increasing **or** the route chip changes and each quote still passes (3)–(4). No 10^9 at size 1 while size 10k is O(10^1–10^2). 6. **Keep ranking-by-output** on the CW20 graph (do not regress #209 / #615). If the honest path is 4 hops and a 3-hop dust USDT pool scores higher only because of the scale bug, (2)+(4) make the 3-hop lose. Do not open #1218 native wrap here. ## Expected vs actual - **Expected:** 1 listed USTR pays `10^18` raw. You Receive is USDT at 18-dec, in a plausible band vs hub USTR USD and USDT ~$1 (order-of-magnitude, not a peg promise). Larger pay on the same ids cannot print a **higher** per-unit USDT out. 10^9-scale receive is blocked. - **Actual:** 1 USTR shows ~9B USDT on two different UST1/cLUNC routes; 100 / 1000 / 10000 are non-monotonic per-unit; Quote only still looks like a market. ## Acceptance criteria - **Given** picker pay = registry USTR and receive = registry USDT (both LCD decimals 18), user on `/` **When** they enter human `1` and the CW20 quote settles **Then** `simRaw` / `amount_in` is `10^18`, You Receive is **not** 10^9-scale, and it equals `fromRawAmount(walletSim, 18)` where `walletSim` matches indexer `estimated_amount_out` (pre-buy-tax). - **Given** the same token ids and human amounts `100`, `1000`, `10000` **When** three quotes settle **Then** USDT-per-USTR is monotonically non-increasing, **or** the disclosed route (ops / `intermediate_tokens`) changed and each quote still passes the reserve/spread fail-closed checks. None of the three display 10^9-scale USDT. - **Given** a candidate path whose last hop (cLUNC→USDT or UST1→…→USDT) simulates ask out greater than that hop’s ask reserve, or worst-hop spread ~100% **When** `GET /route/solve` ranks paths **Then** that path is not the winner; Swap shows no theater You Receive for it (no quote / next-best honest path / extreme block). - **Given** `spot_amount_out` and wallet out that differ by ≥99% on this pair **When** the quote paints **Then** the existing ≥99% extreme path is visible and submit stays blocked (Expert does not waive 99%). - **Given** two factory CW20s with `symbol=USDT` **When** quoting USTR → USDT from `/?from=USTR&to=USDT` **Then** `token_out` is the unique-symbol winner (#715), never a silent other USDT; decimals follow **that** contract. - **AC:** Direct listed 6-dec pairs (UST1/cUSTC) and USTR→UST1 1-hop (if in top-K) do not regress. Native LUNC wrap remains #1218. - **AC:** Docs: Swap USTR/USDT (18↔6 hops) use per-leg decimals; implausible hop sims are dropped. `make verify-issue-<iid>` green. ## Test plan (functional paths) | # | Path | Expect | | --- | --- | --- | | T1 | Registry USTR→USDT, type `1` | Offer raw `10^18`; receive human not 10^9-scale | | T2 | Same, type `100` / `1000` / `10000` | Per-unit out non-increasing or route change + fail-closed | | T3 | Wallet sim raw `9.091e15` USDT 18-dec | Display `0.009091`, not `9.091B` | | T4 | Indexer hop USTR(18)→UST1(6) with offer `10^18` | UST1 out scaled as 6-dec; not treated as 18-dec UST1 | | T5 | Last hop cLUNC(6)→USDT(18) | USDT raw 18; humanize 18 | | T6 | 3-hop USTR→UST1→cLUNC→USDT with ~100% last-hop spread | Not winner; no billions chrome | | T7 | 4-hop inserting cUSTC, honest sim better | Can win on output; still 18-dec USDT display | | T8 | ≥99% wallet vs spot | Extreme alert; submit blocked | | T9 | Unique-symbol USDT vs second `symbol=USDT` | Quote uses mapped contract only | | T10 | UST1/cUSTC `1` | Unchanged 6-dec quote | | T11 | Cache: 1 USTR then 10000 USTR | Second quote not the first’s scaled ghost | | T12 | Disconnected quote-only | Same honesty gates; no extra acquire epic (#678) | ## Test plan — attack, hack, and abuse | Vector | Expected | | --- | --- | | 18-dec raw shown as 6-dec (10^12 You Receive) | Forbidden. Fixture T3 must fail the UI if `fromRawAmount(..., 6)` is used for registry USDT. | | 6-dec hop offer using 18-dec raw (drains 6-dec pool in sim) | Forbidden. Hop sim uses that pair’s offer-leg decimals. | | Symbol spoof — second USDT CW20 | Identity is contract; unique-symbol CI; no merge of quotes. | | Tokenlist decimals as amount source | Forbidden (C542-11). | | Cache alias `AMOUNT_CACHE_BUCKET` across 6 vs 18 | Distinct keys; 1 USTR ≠ 1 UST1. | | Top-K hop-count only — skip scoring 4-hop | Winner is max simulated net out among enumerated paths; dust 3-hop cannot win via scale lie. | | Foreign DEX hop smuggled to “fix” liquidity | Out of scope (#690). Tests must still pass on factory graph. | | Gem bridge / `?showGems=1` | Still rejected (`shouldRejectGemBridgeQuote`). | | Hostile `token_info.decimals` 255 | Unresolved; no quote; no `10**255`. | | Quote vs execute skew — display billions, broadcast dust | Signing modal / min received / `simRaw` match the same decimals (#356). | | Skip 99% / Expert / extra-debit because “stablecoin route” | Forbidden. | | XSS in route chip symbols | Text nodes / existing sanitizers; no raw query HTML. | ## Verification criteria ```bash make verify-issue-<iid> # expected: # - vitest: registry USDT receive uses 18; 9.091e15 raw ≠ 9.091B chrome # - vitest/indexer: mixed 18/6 hop offer 10^18 does not score as 6-dec offer # - implausible hop (out > reserve or ~100% spread) is not GET /route/solve winner # - size 1 vs 100/1000/10000 per-unit monotonicity or disclosed route change # - docs/route-solver.md + docs/frontend.md: 18↔6 USTR/USDT hops; fail-closed theater ``` Manual: on `/` with USTR→USDT, type `1` then `10000`. Unit size must not show billions while 10k shows tens of USDT. Route chip must match wallet ops. 99% / hop preflight must catch leftover theater. ## Out of scope - Reopening #15 / #464 / #564 / #529 / #678. - Commenting on or expanding #1218 (native LUNC wrap → solve). - Unlisted factory decimals (#1255) except the dual-USDT identity check. - Bridging / CMM seed (#1221). - Other-DEX solver (#690) and IBC USDC intermediates (#691). - Parallel amount split (#1203). - Contract Uint256 work (already on `main` via #464). ## First-pass model recommendation Recommendation: grok-high Rationale: Root cause is not a one-helper patch. The failing surface spans indexer top-K + mixed 18/6 hop simulation (`route_paths` / `best_execution` / hop sim), Swap humanize (`SwapPage`, `formatAmount`, `cw20RouteSolveQuote`), and 99% / preflight fail-closed. That is more than three production files, crosses subsystems, and is funds-adjacent quote/execute scale (wrong You Receive is a money bug). Comparable DEX work is #1218 / #690 (solver + Swap), not a single RCA doc or test-helper change. Verification needs fixtures at four sizes plus hop-reserve checks, not a single deterministic local edit. Composer’s bar fails on file count, cross-cutting protocol/quote state, and uncertain ranking-vs-scale diagnosis. Grok high for the first pass.
Author
Owner

/agent implement

/agent implement
Author
Owner

cl8y-agent-control: queued implement job 5c405e06-c2b4-4a4c-9db7-37efc763ceac (not executed; no Hetzner VM).

cl8y-agent-control: queued `implement` job `5c405e06-c2b4-4a4c-9db7-37efc763ceac` (not executed; no Hetzner VM).
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#1257
No description provided.