We hit this in Perps and fixed it on our side. The same pattern shows up in Predict and in a couple of shared confirmations hooks, so flagging it rather than sitting on it.
What we saw
User picks ETH as the payment method for a trade. The token picker lists it at $9.37. The trade screen then tells them they can't afford a $10 order at 2x leverage — where the margin required is $5.01.
Probing both balance sources on device at the same moment:
snapshot balanceUsd: "0" <- what the trade screen read
live balanceUsd: "9.3670768275998875…" <- what the picker showed
symbol: ETH · chainId: 0xa4b1
Why
updatePaymentToken → getPaymentToken → getTokenBalance computes balanceUsd once, when the token is selected, and stores it on transactionData.paymentToken. It's never recomputed. If the balance hasn't been polled yet — or the balance call fails — it stays frozen at 0 for the life of that selection.
usePayTokenAccountBalance already exists for exactly this, and its comment says so:
payToken.balanceUsd is a one-time snapshot that can be stale (race with AccountTracker polling). Read from the same reactive asset source the token selector uses so the balance stays in sync.
The picker's preferred-token row uses it. Most other call sites don't.
Other places reading the snapshot
We have not reproduced any of these — it's from reading the code after finding our own. They may well be fine in your flows. Worth a look:
| Where |
What it feeds |
Possible impact |
useTransactionCustomAmount.ts:574 (useTokenBalance) |
Max / percentage in the custom-amount keypad |
Max could produce $0 |
usePredictBuyAvailableBalance.ts:16 |
"Available: $X" on Predict buy |
Could show $0.00 while funded — looked display-only to us, not gating the order |
useDepositPrefillAmount.ts:128 |
Deposit prefill (!balanceUsd short-circuits) |
Prefill may silently not happen |
usePayWithSelectedToken.ts:47 |
Selected-token row in the pay-with sheet |
Selected row and preferred row could disagree in the same sheet |
That last one is what made us think this is systemic rather than a one-off: someone already hit the race, wrote the hook to fix the preferred row, and the other call sites were never migrated.
Repro (Perps, but the shape should transfer)
- Wallet with a small non-zero token balance on a chain that isn't actively polled yet.
- Open a flow with "Pay with", switch from the default to that token.
- Read
payToken.balanceUsd vs usePayTokenAccountBalance() in the same render.
The window is timing-dependent — it reproduces reliably right after switching payment method, before the balance lands. Anything that caches or fails the balance read makes it stick.
What we did in Perps
Swapped payToken.balanceUsd for usePayTokenAccountBalance(), and collapsed the screen down to a single balance source so the affordability banner couldn't contradict the slider and validation. Fix is going out with the Perps 8.8.0 pay-with-token work.
Suggestion
The per-consumer swap works but doesn't scale — every new call site has to independently know not to trust a field that looks authoritative. The durable fix is probably in @metamask/transaction-pay-controller: either make balanceUsd reactive, or drop it from the payload so consumers can't read a stale number at all.
Happy to help with the mobile-side migration if useful.
cc @MetaMask/confirmations @MetaMask/predict
We hit this in Perps and fixed it on our side. The same pattern shows up in Predict and in a couple of shared confirmations hooks, so flagging it rather than sitting on it.
What we saw
User picks ETH as the payment method for a trade. The token picker lists it at $9.37. The trade screen then tells them they can't afford a $10 order at 2x leverage — where the margin required is $5.01.
Probing both balance sources on device at the same moment:
Why
updatePaymentToken→getPaymentToken→getTokenBalancecomputesbalanceUsdonce, when the token is selected, and stores it ontransactionData.paymentToken. It's never recomputed. If the balance hasn't been polled yet — or the balance call fails — it stays frozen at0for the life of that selection.usePayTokenAccountBalancealready exists for exactly this, and its comment says so:The picker's preferred-token row uses it. Most other call sites don't.
Other places reading the snapshot
We have not reproduced any of these — it's from reading the code after finding our own. They may well be fine in your flows. Worth a look:
useTransactionCustomAmount.ts:574(useTokenBalance)usePredictBuyAvailableBalance.ts:16useDepositPrefillAmount.ts:128!balanceUsdshort-circuits)usePayWithSelectedToken.ts:47That last one is what made us think this is systemic rather than a one-off: someone already hit the race, wrote the hook to fix the preferred row, and the other call sites were never migrated.
Repro (Perps, but the shape should transfer)
payToken.balanceUsdvsusePayTokenAccountBalance()in the same render.The window is timing-dependent — it reproduces reliably right after switching payment method, before the balance lands. Anything that caches or fails the balance read makes it stick.
What we did in Perps
Swapped
payToken.balanceUsdforusePayTokenAccountBalance(), and collapsed the screen down to a single balance source so the affordability banner couldn't contradict the slider and validation. Fix is going out with the Perps 8.8.0 pay-with-token work.Suggestion
The per-consumer swap works but doesn't scale — every new call site has to independently know not to trust a field that looks authoritative. The durable fix is probably in
@metamask/transaction-pay-controller: either makebalanceUsdreactive, or drop it from the payload so consumers can't read a stale number at all.Happy to help with the mobile-side migration if useful.
cc @MetaMask/confirmations @MetaMask/predict