fix(frontend): Swap uses token_info decimals for unlisted CW20s #1255
Labels
No labels
agent:fix_bugfix
agent:fix_conflicts
agent:fix_security
agent:gap_analysis
agent:implement
agent:implement
agent:implement
agent:open_issues
agent:ready
agent:research
agent:security_audit
agent:verify
architecture
backend
blocker:hybrid
blocker:launch
blocker:limit-orders
blocker:v2
block:log_only
block:security
bug
ci
contracts
correctness
deploy
dev
devops
docs
documentation
duplicate
e2e
enhancement
epic
feature
frontend
functional-completion
gas
good first issue
governance
help wanted
high-risk
hooks
hybrid
indexer
infra
infrastructure
integrators
invalid
launch-blocker
limit-orders
localnet
localterra
low priority
missing-implementation
needs-design
ops
performance
priority
high
priority
medium
product
qa
QA
question
ready
ready
research
scripts
security
security-hardening
smartcontracts
tech-debt
testing
ux
UX
v2
verification
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
code/cl8y-dex-terraclassic#1255
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Home Swap (
/) scales You Pay / You Receive withgetDecimals(asset) → lookupByAssetInfo ?? 6. That helper only knows the hardcodedtokenRegistrymap (product CW20s + natives). Factory-listed CW20s that are not intokenlist.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-chaintoken_info.decimalsis 18.SwapPagealready LCD-queriestoken_infofor every factory CW20 (fetchCW20TokenInfoin auseEffectovergetAllTokens(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, andsimulateSwap/swapall consume the 6-decimal lie.This is not closed #15 (Swap used to send raw human strings with no
10^decimalsat all; that was fixed for listed 6-dec tokens). This is not closed #564 (Charts display; that ticket already forbids falling back togetDecimalsbecause it defaults to 6). This is not closed #676 (indexerNUMERICoverflow 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 is10^18. Typed “1” therefore simulates/executes 10^−12 of a token.Balance / Max use the same scale:
fromRawAmount(10^18, 6)shows1000000000000. 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
/are wrong by10^(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.fetchCW20TokenInforeturnsdecimals. Limit orders already consume it when the registry misses (#166). Swap throws that field away and hard-codes 6.token_infodecimals in0…18.” Create Pair catalog explicitly must not use tokenlistdecimalsfor amounts (C542-11). Swap is the remaining execute surface that still guesses 6.tokenRegistry.ts.Constraints / guardrails
uluna/uusdstay 6 via registry. Missing / out-of-range (not integer,<0,>18) → unresolved; do not quote or execute.tokenlist.jsondecimalsas the amount source (C542-11). Registry remains a fast path for known product ids only.AGENTS_FRONTEND_SWAP_QUERY_PARAMS/ #715 QS-4 / X1). Picker universe staysgetAllTokens(pairs)after gem hide (#562). This ticket only resolves decimals for tokens already in that factory (+ wrap) set.token_info.symbolas a Swap query key (X1 / #715 QS-4). Decimals fromtoken_infoare for scaling only.tokenRegistryfor known product ids, (2) indexer pair-leg /GET /tokensdecimals when the selected route’s pair row exists andisPairLegDecimals, (3) LCDtoken_info.decimals(already fetched). Indexer JSON stays raw; UI scales.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”.getDecimalsis synchronous and used bySwapPage,swapDisclosure(hybrid book split),pairCatalogRank,limitPlacementLifecycle. Changing the default without a resolved source will keep the bug. Either returnnullfor unknown CW20 and gate Swap, or provide a hook (useAssetDecimals) that is pending until registry / indexer / LCD agrees.refResolutionLoading): disable quote/execute, no dustsimulateSwap. Do not flash a 6-dec quote then snap to 18.token_info: ignore decimals outside0…18; do notNumberoverflow; text nodes only; noeval. A CW20 that reports 255 must not become10**255.?showGems=1.#678/ 5% confirm-again / 30% Expert / 99% / blacklist / pause / freeze / extra-debit stay. Do not skip those because “decimals were wrong.”exactField=output, #713):toRawAmount(amount, receiveDecimals)has the same hole on the ask leg. Fix both legs.decimals. Do not invent a human-amount JSON field.Relevant files
frontend-dapp/src/utils/formatAmount.tsgetDecimals=lookupByAssetInfo ?? 6;toRawAmount/fromRawAmount; comment onisPairLegDecimalsalready warns against this defaultfrontend-dapp/src/utils/__tests__/formatAmount.test.tsfrontend-dapp/src/pages/SwapPage.tsxofferDecimals/receiveDecimals; typed/reverse raw; Max;simulateSwap/ execute; already prefetchesfetchCW20TokenInfowithout using decimalsfrontend-dapp/src/utils/tokenDisplay.tsfetchCW20TokenInfo+ localStorage cache omitsdecimalsfrontend-dapp/src/utils/tokenRegistry.tsfrontend-dapp/src/utils/swapDisclosure.tsgetDecimals(pay)frontend-dapp/src/utils/limitOrderPriceReference.tsfrontend-dapp/src/hooks/useTokenDisplayInfo.tsGET /tokensalready loaded;IndexerToken.decimalsunused for Swap amountsfrontend-dapp/src/utils/maxSpendableAmount.tsdocs/frontend.mdskills/AGENTS_FRONTEND_SWAP_TOKENLIST_SYMBOLS.mdRecommended direction
decimals. ExtendCachedEntry(and bumpCACHE_KEY) to{ symbol, name, decimals }.fetchCW20TokenInfoalready has the field. Reject / omit whendecimalsis not an integer in0…18.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.useAssetDecimals(assetInfo, pairRow?)uses React Query (LCDtoken_info+ existing indexer tokens/pair).SwapPageuses the resolved number only when non-null. While pending or null: notoRawAmountfor quote/execute; Swap CTA disabled; balance/Max—or hold last resolved scale (do not show 6-dec inflated balance).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).getDecimals(unknown) === 6withnull/ hook pending. RTL: factory token absent fromCW20_MAP, mockedtoken_info.decimals = 18, type1→ sim/execute raw10^18. 6-dec unlisted still10^6. Listed USTR/CL8Y unchanged. Do not callsimulateSwapuntil decimals resolved.Acceptance criteria
Given a factory-listed CW20 that is not in
tokenlist.json/tokenRegistry, whose LCDtoken_info.decimalsis 18, and the user is on/with that token as You PayWhen they enter human
1and a quote runsThen
simulateSwap/ executeoffer_amountis10^18, not1000000.Given the same token as You Receive on a direct pair with
exactField=outputand human1When reverse sim runs
Then
reverseSimulateSwapask raw is10^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
1Then 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_infonot yet returned and indexer decimals missingWhen 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.decimalsis255, negative, or non-integerWhen 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)
token_info.decimals = 18, type110^181.515×10^176, type110^61exactField=output110^18simulateSwap; Swap disabled10^181, not1000000000000asset_*.decimals = 18even before LCD/listed-only pair (UST1/cUSTC)Test plan — attack, hack, and abuse
getDecimals(unknown) === 6token_infoonly.terra1into combobox because decimals were neededgetAllTokensids.token_info.decimals255/1e9/"-1"/"18e0"10**nexplosion.{symbol,name}without decimalstoken_info, not ticker.token_info.symbol?showGems=1token_info.name/ symboltoken_infofor CW20 execute amounts when it is valid; do not execute at 6 if LCD says 18. Document the precedence indocs/frontend.md.uluna/uusdstay 6. Unknown bank/IBC denom stays fail-closed (#630), not 18.10^6Verification criteria
Manual: on a factory pair whose CW20 is absent from
tokenlist.jsonand 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
tokenlist.json/tokenRegistryas a substitute for chain decimals (that does not scale).token_infochanges.First-pass model recommendation
Recommendation: grok-high
Rationale: Home Swap execute-path amount scaling. A correct fix is not a one-helper patch:
getDecimalsis shared (SwapPage,swapDisclosurehybrid 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 hostiletoken_infovalues 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, plusmake verify-issue-<iid>.cl8y-agent-control: queued
implementjob164a2e8e-7bf0-41a7-abe7-5c610824a622(not executed; no Hetzner VM)./agent implement
/agent implement
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.cl8y-agent-control: needs_human inbox card POST failed. Job stays parked.