Skip to content

fix(core): render token uiAmount through the account decoder - #777

Merged
MicaiahReid merged 1 commit into
solana-foundation:mainfrom
pucedoteth:fix/token-ui-amount-precision
Aug 26, 2026
Merged

fix(core): render token uiAmount through the account decoder#777
MicaiahReid merged 1 commit into
solana-foundation:mainfrom
pucedoteth:fix/token-ui-amount-precision

Conversation

@pucedoteth

Copy link
Copy Markdown
Contributor

The bug

format_ui_amount_string in crates/core/src/surfnet/locker.rs divided through f64 before formatting:

let divisor = 10u64.pow(decimals as u32);
format!("{:.decimals$}", amount as f64 / divisor as f64, decimals = decimals as usize)

An f64 mantissa holds 53 bits; a token amount holds 64. Every amount above 2^53 was rounded before it reached the client:

amount decimals rendered correct
18446744073709551615 6 18446744073709.550781 18446744073709.551615
12345678901234567 6 12345678901.234568 12345678901.234567
9007199254740993 2 90071992547409.92 90071992547409.93

This is not a theoretical range. 2^53 raw units is only ~9B tokens on a 6-decimal mint, so an agent reading a supply-scale balance gets a wrong number back — the same class of defect #758 fixed for getTokenSupply, in the helpers that path didn't touch.

Both helpers additionally built their divisor with 10u64.pow(decimals), which overflows from decimals >= 20. Mint::decimals is a plain u8 with no cap in the token program, so a mint declaring 20+ decimals made this a panic in debug and a silently wrong divisor in release:

format_ui_amount_string(1, 20) -> panicked: attempt to multiply with overflow
format_ui_amount(1, 20)        -> panicked: attempt to multiply with overflow

Reachability

All four call sites pass real mint decimals, so both defects are live on RPC output:

  • locker.rs:3040getTokenLargestAccounts
  • types.rs:516, :538, :732 — pre/post token balances on getTransaction and getBlock

The fix

Defer the string to real_number_string_trimmed — the helper getTokenSupply already uses since a4acb1e (#758) — and give format_ui_amount the Option<f64> return that UiTokenAmount::ui_amount is already typed for. That mirrors solana_account_decoder::parse_token::token_amount_to_ui_amount_v3 exactly:

let ui_amount = 10_usize
    .checked_pow(decimals as u32)
    .map(|dividend| amount as f64 / dividend as f64);
(ui_amount, real_number_string_trimmed(amount, decimals))

The three types.rs call sites drop their now-redundant Some(...) wrapper.

One deliberate behaviour change: trailing zeros are no longer padded, so 1 USDC renders as "1" rather than "1.000000". That is what real_number_string_trimmed does, so it is what agave returns on mainnet and what getTokenSupply already returned here after #758 — this brings the remaining paths into line rather than away from it.

Verification

Four tests added to the existing locker.rs test module. Reverting only the production helper body — tests untouched — turns three of them red with exactly the values above:

test_format_ui_amount_string_is_exact_above_2_pow_53 ... FAILED
  left: "18446744073709.550781"   right: "18446744073709.551615"
test_format_ui_amount_string_matches_account_decoder ... FAILED
  left: "1.000000"                right: "1"
test_format_ui_amount_survives_decimals_that_cannot_form_a_divisor ... FAILED
  panicked at core/src/num/mod.rs:1274 (pow overflow)

All four pass with the fix. Full cargo test -p surfpool-core --lib: 716 passed. The test_simulate_add_alt_entries_fetching variants fail identically on a clean main at this commit (loaded_accounts_data_size 6090 vs 140134 — it fetches from mainnet), so they are pre-existing and unrelated. cargo fmt and cargo clippy clean on the changed lines.

🤖 Generated with Claude Code

@greptile-apps

greptile-apps Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR replaces precision-losing and overflow-prone token amount formatting with the Solana account decoder’s exact string formatter and checked optional floating-point conversion.

  • Uses real_number_string_trimmed for token balance strings.
  • Returns None for ui_amount when the decimal divisor cannot fit in usize.
  • Applies the formatting consistently to largest-account and transaction token balances.
  • Adds focused tests for scaling and unsupported decimal divisors.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
crates/core/src/surfnet/locker.rs Replaces local token string formatting with the account decoder helper and makes floating-point scaling overflow-safe.
crates/core/src/types.rs Updates successful and failed transaction token balances to use exact decoder formatting and optional UI amounts.

Reviews (3): Last reviewed commit: "fix(core): render token uiAmount through..." | Re-trigger Greptile

@MicaiahReid

Copy link
Copy Markdown
Collaborator

Thanks for the PR, @pucedoteth. Foundation PRs can't be merged without commit signature verification. If you can get that set up and re-push these commits, I'll review!

@pucedoteth
pucedoteth force-pushed the fix/token-ui-amount-precision branch from 83308bb to 174ca3c Compare August 25, 2026 22:40

@MicaiahReid MicaiahReid left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I appreciate the PR, but in future PRs please do more to distill the AI output.

Comment thread crates/core/src/surfnet/locker.rs Outdated
Comment on lines +4551 to +4571
/// The decimal rendering of a raw token amount, as agent-visible RPC output.
///
/// Defers to `real_number_string_trimmed`, the same helper `getTokenSupply` uses since #758,
/// rather than dividing through `f64`. An `f64` carries 53 bits of mantissa and a token amount
/// carries 64, so every amount above 2^53 rounded: `u64::MAX` at 6 decimals rendered as
/// `18446744073709.550781` instead of `18446744073709.551615`. It also drops the
/// `10u64.pow(decimals)` that overflowed for `decimals >= 20` — `Mint::decimals` is a `u8` with
/// no cap in the token program, so that was a panic in debug and a wrong divisor in release.
pub fn format_ui_amount_string(amount: u64, decimals: u8) -> String {
if decimals > 0 {
let divisor = 10u64.pow(decimals as u32);
format!(
"{:.decimals$}",
amount as f64 / divisor as f64,
decimals = decimals as usize
)
} else {
amount.to_string()
}
real_number_string_trimmed(amount, decimals)
}

pub fn format_ui_amount(amount: u64, decimals: u8) -> f64 {
if decimals > 0 {
let divisor = 10u64.pow(decimals as u32);
amount as f64 / divisor as f64
} else {
amount as f64
}
/// The `uiAmount` float, or `None` when `decimals` is too large to form a divisor.
///
/// Matches `solana_account_decoder::parse_token::token_amount_to_ui_amount_v3`, which computes
/// this as `10_usize.checked_pow(decimals)` mapped over the division: the field is
/// `Option<f64>` precisely so an undivisible `decimals` has somewhere to go other than a panic.
pub fn format_ui_amount(amount: u64, decimals: u8) -> Option<f64> {
10_usize
.checked_pow(decimals as u32)
.map(|divisor| amount as f64 / divisor as f64)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The doc comments on these are classic over-described AI generated comments. Please distill this down to what's important. What does the function do? If you had no context, what comment would help you know what the function does, when to use it, and what to pass in?

The comment doesn't need super technical details of what's happening under the hood (the code itself explains what the code is doing). We don't need references to other PRs and how past behavior worked

Comment thread crates/core/src/surfnet/locker.rs Outdated
/// `18446744073709.550781` instead of `18446744073709.551615`. It also drops the
/// `10u64.pow(decimals)` that overflowed for `decimals >= 20` — `Mint::decimals` is a `u8` with
/// no cap in the token program, so that was a panic in debug and a wrong divisor in release.
pub fn format_ui_amount_string(amount: u64, decimals: u8) -> String {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

If this function is just directly calling another - why does the function need to exist at all?

Comment thread crates/core/src/surfnet/locker.rs Outdated
Comment on lines +7050 to +7082
#[test]
fn test_format_ui_amount_string_matches_account_decoder() {
// `getTokenSupply` renders through `real_number_string_trimmed` since #758. These
// helpers feed `getTokenLargestAccounts` and the pre/post token balances on
// `getTransaction`, and must agree with it for the same mint.
for (amount, decimals) in [(0u64, 0u8), (1, 0), (1_000_000, 6), (1, 9), (u64::MAX, 18)] {
assert_eq!(
format_ui_amount_string(amount, decimals),
real_number_string_trimmed(amount, decimals),
"amount {amount} at {decimals} decimals must match the account decoder"
);
}
}

#[test]
fn test_format_ui_amount_survives_decimals_that_cannot_form_a_divisor() {
// `Mint::decimals` is an unvalidated `u8`, so a mint may carry any value up to 255.
// `10u64.pow(decimals)` overflowed from 20 up: a panic in debug, a wrong divisor in
// release. Both helpers must instead answer for the whole `u8` range.
for decimals in 0u8..=255 {
let rendered = format_ui_amount_string(1, decimals);
assert_eq!(
rendered,
real_number_string_trimmed(1, decimals),
"decimals {decimals} must render rather than overflow"
);
assert_eq!(
format_ui_amount(1, decimals).is_some(),
10_usize.checked_pow(decimals as u32).is_some(),
"decimals {decimals} must yield a float exactly when a divisor exists"
);
}
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

These tests don't seem particularly valuable, they're essentially asserting the one function has the same output as another... when the first function is just calling the other

format_ui_amount_string divided through f64 before formatting, so amounts above
2^53 were rounded: u64::MAX at 6 decimals rendered 18446744073709.550781 rather
than 18446744073709.551615. Both helpers also built their divisor with
10u64.pow(decimals), which overflows from decimals >= 20.

Drop the string helper and call real_number_string_trimmed at the call sites,
matching getTokenSupply. format_ui_amount returns Option<f64>, which is what
UiTokenAmount::ui_amount already holds.

Note: trailing zeros are no longer padded, so 1 USDC is "1" rather than
"1.000000" — what agave returns and what getTokenSupply already did here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@pucedoteth
pucedoteth force-pushed the fix/token-ui-amount-precision branch from 174ca3c to c203213 Compare August 26, 2026 18:15
@pucedoteth

pucedoteth commented Aug 26, 2026

Copy link
Copy Markdown
Contributor Author

Fair thanks, that was too much. Addressed all three:

  • Dropped format_ui_amount_string. It was a pure delegation, so the four call sites now call real_number_string_trimmed directly.
  • Cut the doc comment on format_ui_amount to what it does and what None means. No PR references or history.
  • Removed the tests that compared the wrapper to the function it wrapped. Kept one covering format_ui_amount itself, including the None case.

Diff is now +28/-31 instead of +125.

One thing I left alone: token_amount_to_ui_amount_v3 in solana_account_decoder builds the whole UiTokenAmount and also handles interest-bearing and scaled-UI-amount mints, which the manual construction here does not. Replacing the struct literal with that call looks like the real simplification, but it changes behaviour beyond this fix so I did not fold it in. Happy to do it here or separately if you want it.

@MicaiahReid MicaiahReid left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

thanks, @pucedoteth!

@MicaiahReid
MicaiahReid merged commit 6eed3a8 into solana-foundation:main Aug 26, 2026
9 checks passed
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