fix(ui-react): resolve jetton decimals from toncenter v3 metadata - #3
fix(ui-react): resolve jetton decimals from toncenter v3 metadata#3sepivip wants to merge 1 commit into
Conversation
fetchJettonBalance read `wallet.jetton?.decimals` from the v3 `/api/v3/jetton/wallets` response, but v3 returns `wallet.jetton` as the jetton master address STRING — so `.decimals` is always undefined and the fallback-to-9 branch always wins. Any jetton with decimals != 9 therefore renders at the wrong magnitude (USDT @ 6 decimals displays 1000x smaller than reality — a 20 USDT balance shows as 0.02 USDT). Decimals actually live in `data.metadata[masterAddr].token_info[0].extra.decimals` (string). Read from there first, fall back to the legacy `wallet.jetton. decimals` object shape (future-proofing), and finally to 9. Fixes the "Insufficient Funds" modal showing wrong balance for USDT (and any other non-9-decimal jetton). Downstream impact: users incorrectly routed to the top-up-crypto QR view end up paying outside the handlePay handshake, so merchant backends never see a reference and orders stuck pending forever.
There was a problem hiding this comment.
Pull request overview
This PR fixes incorrect jetton balance rendering in ui-react by resolving jetton decimals from the toncenter v3 /api/v3/jetton/wallets response metadata (where decimals are actually provided), instead of incorrectly assuming wallet.jetton is an object with a .decimals field.
Changes:
- Resolve jetton decimals from
data.metadata[masterAddr].token_info[0].extra.decimals(string → number) when using toncenter v3. - Keep a fallback path for a legacy
wallet.jetton.decimalsshape, then default to9.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if (fromMetadata != null) { | ||
| const parsed = Number(fromMetadata); | ||
| if (Number.isFinite(parsed)) decimals = parsed; | ||
| } else if (typeof wallet.jetton?.decimals === 'number') { |
There was a problem hiding this comment.
fromMetadata is cast with Number() and accepted if Number.isFinite(parsed), which also allows values like '' (becomes 0), floats, scientific notation, negatives, or very large numbers. Any of those would produce an incorrect divisor (Math.pow(10, decimals)), potentially reintroducing incorrect balance / insufficient-funds behavior. Consider validating that the parsed value is a non-negative integer within a reasonable range (and ignore empty/whitespace strings), otherwise fall back to the legacy shape or 9.
| } else if (typeof wallet.jetton?.decimals === 'number') { | ||
| decimals = wallet.jetton.decimals; |
There was a problem hiding this comment.
The legacy fallback only accepts wallet.jetton.decimals when it is a number. If the legacy API ever provided decimals as a numeric string (common for JSON payloads), this will now skip that fallback and use 9. To preserve backwards compatibility, consider accepting a string here too (with the same integer/range validation as metadata).
| // Decimals actually live in `data.metadata[masterAddr].token_info[0].extra | ||
| // .decimals` as a string. We try that first, fall back to the legacy object | ||
| // shape (future-proofing in case the response evolves), and finally to 9. |
There was a problem hiding this comment.
The path in this comment is split across two lines as ...token_info[0].extra and then .decimals, which reads like extra and .decimals are separate. Consider reflowing to keep extra.decimals together so the documented source matches the code and is unambiguous.
| // Decimals actually live in `data.metadata[masterAddr].token_info[0].extra | |
| // .decimals` as a string. We try that first, fall back to the legacy object | |
| // shape (future-proofing in case the response evolves), and finally to 9. | |
| // Decimals actually live in | |
| // `data.metadata[masterAddr].token_info[0].extra.decimals` as a string. We | |
| // try that first, fall back to the legacy object shape (future-proofing in | |
| // case the response evolves), and finally to 9. |
Follow-up PR for #2.
The bug
fetchJettonBalanceinpackages/ui-react/src/hooks/useWalletBalance.tsreads:But toncenter v3's
/api/v3/jetton/walletsreturnswallet.jettonas the master address string — so.decimalsis alwaysundefinedand the fallback to9always wins. Any jetton whose decimals ≠ 9 renders at the wrong magnitude. USDT (6 decimals) shows 1000× smaller than reality: a 20 USDT balance displays as0.02 USDT.Downstream impact
The "Insufficient Funds" modal fires for users who have plenty of balance, routing them to the top-up-crypto QR view. Users who pay via that direct-deposit path bypass
handlePayentirely — so merchant backends never see areference, pay.ton.org never indexes the transfer, and the order stayspendingforever despite real funds landing on chain.The fix
Read decimals from where v3 actually puts them:
(returned as a string;
Number()-cast it). Fallback chain:metadata.token_info.extra.decimals(v3 current shape)wallet.jetton.decimals(legacy object shape — future-proofing)9(last-resort default, unchanged)Verified against live response
Example v3 payload for the USDT master
EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs:{ "jetton_wallets": [{ "balance": "20000000", "jetton": "0:B113A994B5024A16719F69139328EB759596C38A25F59028B146FECDC3621DFE" }], "metadata": { "0:B113...": { "token_info": [{ "extra": { "decimals": "6" } }] } } }Before:
20000000 / 10^9 = 0.02. After:20000000 / 10^6 = 20. ✓Scope
Single file, ~20 LOC added. No API changes, no new dependencies. Works for TON, USDT, and any other jetton in the ecosystem.