Skip to content

fix(ui-react): resolve jetton decimals from toncenter v3 metadata - #3

Open
sepivip wants to merge 1 commit into
RSquad:masterfrom
sepivip:fix/jetton-balance-decimals
Open

fix(ui-react): resolve jetton decimals from toncenter v3 metadata#3
sepivip wants to merge 1 commit into
RSquad:masterfrom
sepivip:fix/jetton-balance-decimals

Conversation

@sepivip

@sepivip sepivip commented Apr 19, 2026

Copy link
Copy Markdown

Follow-up PR for #2.

The bug

fetchJettonBalance in packages/ui-react/src/hooks/useWalletBalance.ts reads:

const decimals = wallet.jetton?.decimals ?? 9;

But toncenter v3's /api/v3/jetton/wallets returns wallet.jetton as the master address string — so .decimals is always undefined and the fallback to 9 always wins. Any jetton whose decimals ≠ 9 renders at the wrong magnitude. USDT (6 decimals) shows 1000× smaller than reality: a 20 USDT balance displays as 0.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 handlePay entirely — so merchant backends never see a reference, pay.ton.org never indexes the transfer, and the order stays pending forever despite real funds landing on chain.

The fix

Read decimals from where v3 actually puts them:

data.metadata[masterAddr].token_info[0].extra.decimals

(returned as a string; Number()-cast it). Fallback chain:

  1. metadata.token_info.extra.decimals (v3 current shape)
  2. wallet.jetton.decimals (legacy object shape — future-proofing)
  3. 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.

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.
Copilot AI review requested due to automatic review settings April 19, 2026 21:59

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.decimals shape, then default to 9.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +66 to +69
if (fromMetadata != null) {
const parsed = Number(fromMetadata);
if (Number.isFinite(parsed)) decimals = parsed;
} else if (typeof wallet.jetton?.decimals === 'number') {

Copilot AI Apr 19, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +69 to +70
} else if (typeof wallet.jetton?.decimals === 'number') {
decimals = wallet.jetton.decimals;

Copilot AI Apr 19, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment on lines +57 to +59
// 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.

Copilot AI Apr 19, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// 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.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants