fix(frontend): Swap uses token_info decimals for unlisted CW20s #1255

Closed
opened 2026-09-12 13:20:30 +00:00 by PlasticDigits · 5 comments

Summary

Home Swap (/) scales You Pay / You Receive with getDecimals(asset) → lookupByAssetInfo ?? 6. That helper only knows the hardcoded tokenRegistry map (product CW20s + natives). Factory-listed CW20s that are not in tokenlist.json / CW20_MAP — community-tax tokens from Create Token (#604 allows decimals 6–18), gems when exposed, any other factory asset — are treated as 6 decimals even when on-chain token_info.decimals is 18.

SwapPage already LCD-queries token_info for every factory CW20 (fetchCW20TokenInfo in a useEffect over getAllTokens(pairs)), but the cache stores only { symbol, name }. Decimals from that response are discarded. Quote, Max, balance display, reverse-quote, hybrid book split, min received chrome, and simulateSwap / swap all consume the 6-decimal lie.

This is not closed #15 (Swap used to send raw human strings with no 10^decimals at all; that was fixed for listed 6-dec tokens). This is not closed #564 (Charts display; that ticket already forbids falling back to getDecimals because it defaults to 6). This is not closed #676 (indexer NUMERIC overflow on positions). Limit orders already fail-closed on unknown CW20 decimals (#166 / resolvePairDecimalsForLimitPriceRefFromChain). Swap did not get that treatment.

Create Token (#604) can mint 18-decimal CW20s. Those tokens get factory pairs and appear in the Swap picker via getAllTokens (#715 QS-3: unlisted factory CW20 stays bech32 in the URL, but it is still selectable). Home Swap then quotes and executes them as 6-dec.

Magnitude

toRawAmount("1", 6) → 1000000. On-chain 1 token at 18 decimals is 10^18. Typed “1” therefore simulates/executes 10^−12 of a token.

Balance / Max use the same scale: fromRawAmount(10^18, 6) shows 1000000000000. The wallet looks 10^12 too rich. Receive chrome and min-received labels inflate the same way when the ask token is the unlisted 18-dec leg.

Listed 18-dec product tokens (CL8Y, USTR, USDT) are fine — they are in tokenRegistry. The hole is unlisted (not in tokenlist / registry) factory CW20s.

Why the new implementation is needed

  1. Settlement amounts on / are wrong by 10^(on_chain − 6) for any factory CW20 missing from the registry. Dust quotes, bogus Max, and misleading Receive are user-facing money bugs, not chrome.
  2. The LCD read already happens. fetchCW20TokenInfo returns decimals. Limit orders already consume it when the registry misses (#166). Swap throws that field away and hard-codes 6.
  3. Charts (#564) and tape (#557) already document “never assume 6; use pair-leg / token_info decimals in 0…18.” Create Pair catalog explicitly must not use tokenlist decimals for amounts (C542-11). Swap is the remaining execute surface that still guesses 6.
  4. Community-tax / Create Token tokens are the growth path for unlisted 18-dec assets. Leaving the default in place means every new 18-dec listing looks broken on home Swap until someone hand-adds it to tokenRegistry.ts.

Constraints / guardrails

  • Do not default unknown CW20 decimals to 6 (or 18). Native uluna / uusd stay 6 via registry. Missing / out-of-range (not integer, <0, >18) → unresolved; do not quote or execute.
  • Do not use bundled tokenlist.json decimals as the amount source (C542-11). Registry remains a fast path for known product ids only.
  • Do not LCD-fetch unlisted CW20s into the picker (AGENTS_FRONTEND_SWAP_QUERY_PARAMS / #715 QS-4 / X1). Picker universe stays getAllTokens(pairs) after gem hide (#562). This ticket only resolves decimals for tokens already in that factory (+ wrap) set.
  • Do not use LCD token_info.symbol as a Swap query key (X1 / #715 QS-4). Decimals from token_info are for scaling only.
  • Prefer, in order: (1) tokenRegistry for known product ids, (2) indexer pair-leg / GET /tokens decimals when the selected route’s pair row exists and isPairLegDecimals, (3) LCD token_info.decimals (already fetched). Indexer JSON stays raw; UI scales.
  • Reuse fetchCW20TokenInfo + decimalsForAssetInfoFromChain (limit-order helper). Persist decimals in the token-info cache; version the cache key so old {symbol,name} entries cannot be read as “decimals unknown → 6”.
  • getDecimals is synchronous and used by SwapPage, swapDisclosure (hybrid book split), pairCatalogRank, limitPlacementLifecycle. Changing the default without a resolved source will keep the bug. Either return null for unknown CW20 and gate Swap, or provide a hook (useAssetDecimals) that is pending until registry / indexer / LCD agrees.
  • Fail closed while decimals are in flight (same class as #166 refResolutionLoading): disable quote/execute, no dust simulateSwap. Do not flash a 6-dec quote then snap to 18.
  • Hostile token_info: ignore decimals outside 0…18; do not Number overflow; text nodes only; no eval. A CW20 that reports 255 must not become 10**255.
  • Gems (#562): production still cannot apply or write gem ids. Decimal resolve must not become a back door to honor ?showGems=1.
  • #678 / 5% confirm-again / 30% Expert / 99% / blacklist / pause / freeze / extra-debit stay. Do not skip those because “decimals were wrong.”
  • Reverse quote (exactField=output, #713): toRawAmount(amount, receiveDecimals) has the same hole on the ask leg. Fix both legs.
  • No contract migrate, no indexer schema change required if pair/token rows already carry decimals. Do not invent a human-amount JSON field.
  • Do not reopen #15. Do not “fix” Charts/tape again (#564 / #557).

Relevant files

Path Why
frontend-dapp/src/utils/formatAmount.ts getDecimals = lookupByAssetInfo ?? 6; toRawAmount / fromRawAmount; comment on isPairLegDecimals already warns against this default
frontend-dapp/src/utils/__tests__/formatAmount.test.ts Asserts unknown CW20 → 6; that contract is the bug
frontend-dapp/src/pages/SwapPage.tsx offerDecimals / receiveDecimals; typed/reverse raw; Max; simulateSwap / execute; already prefetches fetchCW20TokenInfo without using decimals
frontend-dapp/src/utils/tokenDisplay.ts fetchCW20TokenInfo + localStorage cache omits decimals
frontend-dapp/src/utils/tokenRegistry.ts Fast path for listed product tokens only
frontend-dapp/src/utils/swapDisclosure.ts Hybrid book split uses getDecimals(pay)
frontend-dapp/src/utils/limitOrderPriceReference.ts Existing LCD fallback for unknown CW20s — copy the pattern, do not fork a third
frontend-dapp/src/hooks/useTokenDisplayInfo.ts Indexer GET /tokens already loaded; IndexerToken.decimals unused for Swap amounts
frontend-dapp/src/utils/maxSpendableAmount.ts Max humanizes with whatever decimals Swap passes
docs/frontend.md Swap amount-scaling invariant (new); cross-link #166 / #564 / C542-11
skills/AGENTS_FRONTEND_SWAP_TOKENLIST_SYMBOLS.md QS-3 unlisted factory CW20 is still a picker id — add “decimals from chain/indexer, not 6”
  1. Cache decimals. Extend CachedEntry (and bump CACHE_KEY) to { symbol, name, decimals }. fetchCW20TokenInfo already has the field. Reject / omit when decimals is not an integer in 0…18.
  2. Resolver, not a silent 6. Pure helper e.g. resolveAssetDecimals({ registry, indexer, cachedOnChain }) → number | null. null = unknown. Vitest-first: registry CL8Y → 18; uluna → 6; unknown CW20 with cached 18 → 18; unknown CW20 with no cache → null (not 6); indexer 18 beats missing registry; out-of-range indexer → null.
  3. Swap hook. useAssetDecimals(assetInfo, pairRow?) uses React Query (LCD token_info + existing indexer tokens/pair). SwapPage uses the resolved number only when non-null. While pending or null: no toRawAmount for quote/execute; Swap CTA disabled; balance/Max — or hold last resolved scale (do not show 6-dec inflated balance).
  4. Wire both legs (pay, receive, reverse ask, hybrid book split via swapDisclosure, min-received / commission / pool reserve chrome on Swap). Prefer the selected route’s indexer pair-leg decimals when both legs match that pair row (identity by contract/denom, not symbol).
  5. Tests + verify script. Replace getDecimals(unknown) === 6 with null / hook pending. RTL: factory token absent from CW20_MAP, mocked token_info.decimals = 18, type 1 → sim/execute raw 10^18. 6-dec unlisted still 10^6. Listed USTR/CL8Y unchanged. Do not call simulateSwap until decimals resolved.

Acceptance criteria

  • Given a factory-listed CW20 that is not in tokenlist.json / tokenRegistry, whose LCD token_info.decimals is 18, and the user is on / with that token as You Pay
    When they enter human 1 and a quote runs
    Then simulateSwap / execute offer_amount is 10^18, not 1000000.

  • Given the same token as You Receive on a direct pair with exactField=output and human 1
    When reverse sim runs
    Then reverseSimulateSwap ask raw is 10^18, and You Pay is reverse-sim offer scaled with the pay token’s real decimals.

  • Given an unlisted factory CW20 with on-chain decimals 6
    When the user types 1
    Then raw is 10^6 (no 18-dec overshoot).

  • Given listed product tokens (CL8Y / USTR 18, UST1 / cUSTC / LUNC 6)
    When quoting on /
    Then amounts match today’s registry scaling (no regression of #15).

  • Given LCD token_info not yet returned and indexer decimals missing
    When the user types an amount
    Then Swap does not simulate or broadcast using 6; CTA stays disabled until decimals resolve in 0…18.

  • Given token_info.decimals is 255, negative, or non-integer
    When resolving
    Then treat as unresolved; no quote; no crash; no NaN.

  • AC: Max / balance / insufficient-balance compare use the same resolved decimals as execute (no 10^12 displayed stack for a 1-token 18-dec wallet).

  • AC: Hybrid book-leg override (swapDisclosure.getDirectHybridBookSplit) uses the pay token’s resolved decimals.

  • AC: Picker / query still do not LCD-fetch unknown addresses into the universe; gems stay hidden on production.

  • AC: Docs + skill: Swap amounts for factory CW20s missing the registry use indexer pair-leg or token_info, never ?? 6. make verify-issue-<iid> green.

Test plan (functional paths)

# Path Expect
T1 Unlisted factory CW20, token_info.decimals = 18, type 1 Sim/execute offer raw 10^18
T2 Same, type 1.5 Raw 15 × 10^17
T3 Unlisted factory CW20, decimals 6, type 1 Raw 10^6
T4 Listed CL8Y (18) / UST1 (6) Unchanged vs registry
T5 18-dec unlisted as receive; pay listed 6-dec 1 Displayed receive / min received humanize with 18, not 6
T6 Reverse quote, 18-dec unlisted ask, exactField=output 1 Ask raw 10^18
T7 Decimals in flight No simulateSwap; Swap disabled
T8 Max on 18-dec unlisted with raw balance 10^18 Human Max 1, not 1000000000000
T9 Flip 6-dec ↔ 18-dec unlisted Each leg keeps its own decimals; no snap to 6
T10 Indexer pair row asset_*.decimals = 18 even before LCD Quote uses 18; LCD later agrees
T11 / listed-only pair (UST1/cUSTC) No extra LCD dependency for amounts
T12 Production gem hide Unlisted gem still not pickable; no decimals fetch into picker

Test plan — attack, hack, and abuse

Vector Expected
Default-6 lie — leave getDecimals(unknown) === 6 Forbidden. Tests must fail if unknown CW20 still returns 6.
Tokenlist decimals as amount source Forbidden (C542-11). Registry / indexer / token_info only.
LCD picker injection — fetch random terra1 into combobox because decimals were needed Forbidden (#715 QS-4). Resolve only for getAllTokens ids.
Hostile token_info.decimals 255 / 1e9 / "-1" / "18e0" Unresolved; no quote; no BigInt 10**n explosion.
Cache poison — old localStorage {symbol,name} without decimals Cache key version; do not interpret missing field as 6.
Symbol spoof — two CW20s named UST1 Identity is contract/denom; decimals from that asset’s row / token_info, not ticker.
Query key from token_info.symbol Forbidden (X1).
?showGems=1 Still inert; decimal path must not reveal gems on mainnet builds.
XSS in token_info.name / symbol Display stays existing sanitizers; decimals are numeric only.
Race — 6-dec quote then 18-dec execute Forbidden. No simulate until resolved; in-flight quote cancelled on decimals change.
Stale pair — indexer says 6, LCD says 18 Prefer LCD token_info for CW20 execute amounts when it is valid; do not execute at 6 if LCD says 18. Document the precedence in docs/frontend.md.
Native denom guessed 18 uluna / uusd stay 6. Unknown bank/IBC denom stays fail-closed (#630), not 18.
Slippage / Expert / extra-debit skip because “weird token” Forbidden. Same #678 / tax / blacklist gates.
Misleading settlement chrome — show human 1 while broadcasting 10^6 Amount in the signing modal / pre-submit must match the raw that will be sent (existing Swap signing confirmation skill).

Verification criteria

make verify-issue-<iid>
# expected:
# - vitest: formatAmount / decimal resolver — unknown CW20 is not 6;
#   18 vs 6 unlisted; listed registry unchanged; out-of-range → null
# - vitest/RTL SwapPage (or focused helper): type 1 on mocked 18-dec unlisted
#   factory token → simulate/execute offer_amount 10^18; Max human 1 for raw 10^18
# - grep: SwapPage does not use `getDecimals(...) : 6` as the execute scale
# - docs/frontend.md Swap amount-scaling contract; skill QS-3 cross-link

Manual: on a factory pair whose CW20 is absent from tokenlist.json and has 18 on-chain decimals, / Swap “1” must not quote as 1 micro-unit of a 6-dec token. Listed UST1/cUSTC and CL8Y/USTR stay correct.

Out of scope

  • Re-litigating #15 (human→raw exists; the default is wrong).
  • Charts / tape / portfolio indexer (#564, #557, #676).
  • Adding every community token to tokenlist.json / tokenRegistry as a substitute for chain decimals (that does not scale).
  • Create Pair catalog decimals (Create Pair sends no amounts).
  • Contract-side token_info changes.

First-pass model recommendation

Recommendation: grok-high

Rationale: Home Swap execute-path amount scaling. A correct fix is not a one-helper patch: getDecimals is shared (SwapPage, swapDisclosure hybrid book split, Max, reverse quote), the token-info localStorage cache must version and start storing decimals, Swap must fail-closed while LCD/indexer decimals are unresolved (same class as #166), and hostile token_info values are in-bounds abuse tests. That exceeds Composer’s “≤3 production files, no cross-cutting state, deterministic local edit” bar. Funds move if 18-dec is treated as 6. Verify with Vitest on the resolver + Swap quote/execute raw, plus make verify-issue-<iid>.

## Summary Home Swap (`/`) scales You Pay / You Receive with `getDecimals(asset) → lookupByAssetInfo ?? 6`. That helper only knows the hardcoded `tokenRegistry` map (product CW20s + natives). Factory-listed CW20s that are **not** in `tokenlist.json` / `CW20_MAP` — community-tax tokens from Create Token (#604 allows decimals 6–18), gems when exposed, any other factory asset — are treated as **6 decimals even when on-chain `token_info.decimals` is 18**. `SwapPage` already LCD-queries `token_info` for every factory CW20 (`fetchCW20TokenInfo` in a `useEffect` over `getAllTokens(pairs)`), but the cache stores only `{ symbol, name }`. Decimals from that response are discarded. Quote, Max, balance display, reverse-quote, hybrid book split, min received chrome, and `simulateSwap` / `swap` all consume the 6-decimal lie. This is not closed [#15](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/15) (Swap used to send raw human strings with **no** `10^decimals` at all; that was fixed for listed 6-dec tokens). This is not closed [#564](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/564) (Charts display; that ticket already forbids falling back to `getDecimals` because it defaults to 6). This is not closed [#676](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/676) (indexer `NUMERIC` overflow on positions). Limit orders already fail-closed on unknown CW20 decimals ([#166](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/166) / `resolvePairDecimalsForLimitPriceRefFromChain`). Swap did not get that treatment. Create Token (#604) can mint 18-decimal CW20s. Those tokens get factory pairs and appear in the Swap picker via `getAllTokens` (#715 **QS-3**: unlisted factory CW20 stays bech32 in the URL, but it is still selectable). Home Swap then quotes and executes them as 6-dec. ### Magnitude `toRawAmount("1", 6)` → `1000000`. On-chain 1 token at 18 decimals is `10^18`. Typed “1” therefore simulates/executes **10^−12** of a token. Balance / Max use the same scale: `fromRawAmount(10^18, 6)` shows `1000000000000`. The wallet looks 10^12 too rich. Receive chrome and min-received labels inflate the same way when the ask token is the unlisted 18-dec leg. Listed 18-dec product tokens (CL8Y, USTR, USDT) are fine — they are in `tokenRegistry`. The hole is **unlisted** (not in tokenlist / registry) factory CW20s. ## Why the new implementation is needed 1. Settlement amounts on `/` are wrong by `10^(on_chain − 6)` for any factory CW20 missing from the registry. Dust quotes, bogus Max, and misleading Receive are user-facing money bugs, not chrome. 2. The LCD read already happens. `fetchCW20TokenInfo` returns `decimals`. Limit orders already consume it when the registry misses (#166). Swap throws that field away and hard-codes 6. 3. Charts (#564) and tape (#557) already document “never assume 6; use pair-leg / `token_info` decimals in `0…18`.” Create Pair catalog explicitly must **not** use tokenlist `decimals` for amounts (**C542-11**). Swap is the remaining execute surface that still guesses 6. 4. Community-tax / Create Token tokens are the growth path for unlisted 18-dec assets. Leaving the default in place means every new 18-dec listing looks broken on home Swap until someone hand-adds it to `tokenRegistry.ts`. ## Constraints / guardrails - **Do not** default unknown CW20 decimals to 6 (or 18). Native `uluna` / `uusd` stay 6 via registry. Missing / out-of-range (`not integer`, `<0`, `>18`) → unresolved; **do not quote or execute**. - **Do not** use bundled `tokenlist.json` `decimals` as the amount source (**C542-11**). Registry remains a fast path for known product ids only. - **Do not** LCD-fetch unlisted CW20s **into the picker** (`AGENTS_FRONTEND_SWAP_QUERY_PARAMS` / #715 **QS-4** / **X1**). Picker universe stays `getAllTokens(pairs)` after gem hide (#562). This ticket only resolves decimals for tokens **already** in that factory (+ wrap) set. - **Do not** use LCD `token_info.symbol` as a Swap query key (**X1** / #715 **QS-4**). Decimals from `token_info` are for scaling only. - Prefer, in order: (1) `tokenRegistry` for known product ids, (2) indexer pair-leg / `GET /tokens` decimals when the selected route’s pair row exists and `isPairLegDecimals`, (3) LCD `token_info.decimals` (already fetched). Indexer JSON stays raw; UI scales. - Reuse `fetchCW20TokenInfo` + `decimalsForAssetInfoFromChain` (limit-order helper). Persist decimals in the token-info cache; **version the cache key** so old `{symbol,name}` entries cannot be read as “decimals unknown → 6”. - `getDecimals` is synchronous and used by `SwapPage`, `swapDisclosure` (hybrid book split), `pairCatalogRank`, `limitPlacementLifecycle`. Changing the default without a resolved source will keep the bug. Either return `null` for unknown CW20 and gate Swap, or provide a hook (`useAssetDecimals`) that is pending until registry / indexer / LCD agrees. - Fail closed while decimals are in flight (same class as #166 `refResolutionLoading`): disable quote/execute, no dust `simulateSwap`. Do not flash a 6-dec quote then snap to 18. - Hostile `token_info`: ignore decimals outside `0…18`; do not `Number` overflow; text nodes only; no `eval`. A CW20 that reports 255 must not become `10**255`. - Gems (#562): production still cannot apply or write gem ids. Decimal resolve must not become a back door to honor `?showGems=1`. - `#678` / 5% confirm-again / 30% Expert / 99% / blacklist / pause / freeze / extra-debit stay. Do not skip those because “decimals were wrong.” - Reverse quote (`exactField=output`, #713): `toRawAmount(amount, receiveDecimals)` has the same hole on the ask leg. Fix both legs. - No contract migrate, no indexer schema change required if pair/token rows already carry `decimals`. Do not invent a human-amount JSON field. - Do not reopen #15. Do not “fix” Charts/tape again (#564 / #557). ## Relevant files | Path | Why | | --- | --- | | `frontend-dapp/src/utils/formatAmount.ts` | `getDecimals` = `lookupByAssetInfo ?? 6`; `toRawAmount` / `fromRawAmount`; comment on `isPairLegDecimals` already warns against this default | | `frontend-dapp/src/utils/__tests__/formatAmount.test.ts` | Asserts unknown CW20 → 6; that contract is the bug | | `frontend-dapp/src/pages/SwapPage.tsx` | `offerDecimals` / `receiveDecimals`; typed/reverse raw; Max; `simulateSwap` / execute; already prefetches `fetchCW20TokenInfo` without using decimals | | `frontend-dapp/src/utils/tokenDisplay.ts` | `fetchCW20TokenInfo` + localStorage cache omits `decimals` | | `frontend-dapp/src/utils/tokenRegistry.ts` | Fast path for listed product tokens only | | `frontend-dapp/src/utils/swapDisclosure.ts` | Hybrid book split uses `getDecimals(pay)` | | `frontend-dapp/src/utils/limitOrderPriceReference.ts` | Existing LCD fallback for unknown CW20s — copy the pattern, do not fork a third | | `frontend-dapp/src/hooks/useTokenDisplayInfo.ts` | Indexer `GET /tokens` already loaded; `IndexerToken.decimals` unused for Swap amounts | | `frontend-dapp/src/utils/maxSpendableAmount.ts` | Max humanizes with whatever decimals Swap passes | | `docs/frontend.md` | Swap amount-scaling invariant (new); cross-link #166 / #564 / C542-11 | | `skills/AGENTS_FRONTEND_SWAP_TOKENLIST_SYMBOLS.md` | QS-3 unlisted factory CW20 is still a picker id — add “decimals from chain/indexer, not 6” | ## Recommended direction 1. **Cache `decimals`.** Extend `CachedEntry` (and bump `CACHE_KEY`) to `{ symbol, name, decimals }`. `fetchCW20TokenInfo` already has the field. Reject / omit when `decimals` is not an integer in `0…18`. 2. **Resolver, not a silent 6.** Pure helper e.g. `resolveAssetDecimals({ registry, indexer, cachedOnChain }) → number | null`. `null` = unknown. Vitest-first: registry CL8Y → 18; `uluna` → 6; unknown CW20 with cached 18 → 18; unknown CW20 with no cache → `null` (not 6); indexer 18 beats missing registry; out-of-range indexer → `null`. 3. **Swap hook.** `useAssetDecimals(assetInfo, pairRow?)` uses React Query (LCD `token_info` + existing indexer tokens/pair). `SwapPage` uses the resolved number only when non-null. While pending or null: no `toRawAmount` for quote/execute; Swap CTA disabled; balance/Max `—` or hold last **resolved** scale (do not show 6-dec inflated balance). 4. **Wire both legs** (pay, receive, reverse ask, hybrid book split via `swapDisclosure`, min-received / commission / pool reserve chrome on Swap). Prefer the selected route’s indexer pair-leg decimals when both legs match that pair row (identity by contract/denom, not symbol). 5. **Tests + verify script.** Replace `getDecimals(unknown) === 6` with `null` / hook pending. RTL: factory token absent from `CW20_MAP`, mocked `token_info.decimals = 18`, type `1` → sim/execute raw `10^18`. 6-dec unlisted still `10^6`. Listed USTR/CL8Y unchanged. Do not call `simulateSwap` until decimals resolved. ## Acceptance criteria - **Given** a factory-listed CW20 that is **not** in `tokenlist.json` / `tokenRegistry`, whose LCD `token_info.decimals` is **18**, **and** the user is on `/` with that token as You Pay **When** they enter human `1` and a quote runs **Then** `simulateSwap` / execute `offer_amount` is `10^18`, not `1000000`. - **Given** the same token as You Receive on a direct pair with `exactField=output` and human `1` **When** reverse sim runs **Then** `reverseSimulateSwap` ask raw is `10^18`, and You Pay is reverse-sim offer scaled with the **pay** token’s real decimals. - **Given** an unlisted factory CW20 with on-chain decimals **6** **When** the user types `1` **Then** raw is `10^6` (no 18-dec overshoot). - **Given** listed product tokens (CL8Y / USTR 18, UST1 / cUSTC / LUNC 6) **When** quoting on `/` **Then** amounts match today’s registry scaling (no regression of #15). - **Given** LCD `token_info` not yet returned and indexer decimals missing **When** the user types an amount **Then** Swap does not simulate or broadcast using 6; CTA stays disabled until decimals resolve in `0…18`. - **Given** `token_info.decimals` is `255`, negative, or non-integer **When** resolving **Then** treat as unresolved; no quote; no crash; no `NaN`. - AC: Max / balance / insufficient-balance compare use the same resolved decimals as execute (no 10^12 displayed stack for a 1-token 18-dec wallet). - AC: Hybrid book-leg override (`swapDisclosure.getDirectHybridBookSplit`) uses the pay token’s resolved decimals. - AC: Picker / query still do not LCD-fetch unknown addresses into the universe; gems stay hidden on production. - AC: Docs + skill: Swap amounts for factory CW20s missing the registry use indexer pair-leg or `token_info`, never `?? 6`. `make verify-issue-<iid>` green. ## Test plan (functional paths) | # | Path | Expect | | --- | --- | --- | | T1 | Unlisted factory CW20, `token_info.decimals = 18`, type `1` | Sim/execute offer raw `10^18` | | T2 | Same, type `1.5` | Raw `15` × `10^17` | | T3 | Unlisted factory CW20, decimals `6`, type `1` | Raw `10^6` | | T4 | Listed CL8Y (18) / UST1 (6) | Unchanged vs registry | | T5 | 18-dec unlisted as **receive**; pay listed 6-dec `1` | Displayed receive / min received humanize with 18, not 6 | | T6 | Reverse quote, 18-dec unlisted ask, `exactField=output` `1` | Ask raw `10^18` | | T7 | Decimals in flight | No `simulateSwap`; Swap disabled | | T8 | Max on 18-dec unlisted with raw balance `10^18` | Human Max `1`, not `1000000000000` | | T9 | Flip 6-dec ↔ 18-dec unlisted | Each leg keeps its own decimals; no snap to 6 | | T10 | Indexer pair row `asset_*.decimals = 18` even before LCD | Quote uses 18; LCD later agrees | | T11 | `/` listed-only pair (UST1/cUSTC) | No extra LCD dependency for amounts | | T12 | Production gem hide | Unlisted gem still not pickable; no decimals fetch into picker | ## Test plan — attack, hack, and abuse | Vector | Expected | | --- | --- | | Default-6 lie — leave `getDecimals(unknown) === 6` | Forbidden. Tests must fail if unknown CW20 still returns 6. | | Tokenlist decimals as amount source | Forbidden (**C542-11**). Registry / indexer / `token_info` only. | | LCD picker injection — fetch random `terra1` into combobox because decimals were needed | Forbidden (#715 **QS-4**). Resolve only for `getAllTokens` ids. | | Hostile `token_info.decimals` `255` / `1e9` / `"-1"` / `"18e0"` | Unresolved; no quote; no BigInt `10**n` explosion. | | Cache poison — old localStorage `{symbol,name}` without decimals | Cache key version; do not interpret missing field as 6. | | Symbol spoof — two CW20s named UST1 | Identity is contract/denom; decimals from that asset’s row / `token_info`, not ticker. | | Query key from `token_info.symbol` | Forbidden (**X1**). | | `?showGems=1` | Still inert; decimal path must not reveal gems on mainnet builds. | | XSS in `token_info.name` / symbol | Display stays existing sanitizers; decimals are numeric only. | | Race — 6-dec quote then 18-dec execute | Forbidden. No simulate until resolved; in-flight quote cancelled on decimals change. | | Stale pair — indexer says 6, LCD says 18 | Prefer LCD `token_info` for CW20 execute amounts when it is valid; do not execute at 6 if LCD says 18. Document the precedence in `docs/frontend.md`. | | Native denom guessed 18 | `uluna` / `uusd` stay 6. Unknown bank/IBC denom stays fail-closed (#630), not 18. | | Slippage / Expert / extra-debit skip because “weird token” | Forbidden. Same #678 / tax / blacklist gates. | | Misleading settlement chrome — show human 1 while broadcasting `10^6` | Amount in the signing modal / pre-submit must match the raw that will be sent (existing Swap signing confirmation skill). | ## Verification criteria ```bash make verify-issue-<iid> # expected: # - vitest: formatAmount / decimal resolver — unknown CW20 is not 6; # 18 vs 6 unlisted; listed registry unchanged; out-of-range → null # - vitest/RTL SwapPage (or focused helper): type 1 on mocked 18-dec unlisted # factory token → simulate/execute offer_amount 10^18; Max human 1 for raw 10^18 # - grep: SwapPage does not use `getDecimals(...) : 6` as the execute scale # - docs/frontend.md Swap amount-scaling contract; skill QS-3 cross-link ``` Manual: on a factory pair whose CW20 is absent from `tokenlist.json` and has 18 on-chain decimals, `/` Swap “1” must not quote as 1 micro-unit of a 6-dec token. Listed UST1/cUSTC and CL8Y/USTR stay correct. ## Out of scope - Re-litigating #15 (human→raw exists; the default is wrong). - Charts / tape / portfolio indexer (#564, #557, #676). - Adding every community token to `tokenlist.json` / `tokenRegistry` as a substitute for chain decimals (that does not scale). - Create Pair catalog decimals (Create Pair sends no amounts). - Contract-side `token_info` changes. ## First-pass model recommendation Recommendation: grok-high Rationale: Home Swap execute-path amount scaling. A correct fix is not a one-helper patch: `getDecimals` is shared (`SwapPage`, `swapDisclosure` hybrid book split, Max, reverse quote), the token-info localStorage cache must version and start storing decimals, Swap must fail-closed while LCD/indexer decimals are unresolved (same class as #166), and hostile `token_info` values are in-bounds abuse tests. That exceeds Composer’s “≤3 production files, no cross-cutting state, deterministic local edit” bar. Funds move if 18-dec is treated as 6. Verify with Vitest on the resolver + Swap quote/execute raw, plus `make verify-issue-<iid>`.
Author
Owner

cl8y-agent-control: queued implement job 164a2e8e-7bf0-41a7-abe7-5c610824a622 (not executed; no Hetzner VM).

cl8y-agent-control: queued `implement` job `164a2e8e-7bf0-41a7-abe7-5c610824a622` (not executed; no Hetzner VM).
Author
Owner

/agent implement

/agent implement
Author
Owner

/agent implement

/agent implement
Author
Owner

Merged as PR #1295. Unlisted CW20 execute scale is registry → LCD token_info → indexer; hostile LCD stays unresolved. Swap gas-gate waits for resolved decimals and still uses bank LUNC when pay is not uluna.

Leftover: manual LocalTerra 18-dec factory token on / Swap. Tracked on #1300.

Merged as PR #1295. Unlisted CW20 execute scale is registry → LCD token_info → indexer; hostile LCD stays unresolved. Swap gas-gate waits for resolved decimals and still uses bank LUNC when pay is not uluna. Leftover: manual LocalTerra 18-dec factory token on `/` Swap. Tracked on #1300.
Author
Owner

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

cl8y-agent-control: needs_human inbox card POST failed. Job stays parked.
Sign in to join this conversation.
No milestone
No project
No assignees
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#1255
No description provided.