Skip to content

fix(wallets): serve USDT balances as fractional cents — whole-cent rounding overstated spendable balance - #480

Merged
islandbitcoin merged 4 commits into
mainfrom
fix/fractional-usdt-balance
Aug 14, 2026
Merged

fix(wallets): serve USDT balances as fractional cents — whole-cent rounding overstated spendable balance#480
islandbitcoin merged 4 commits into
mainfrom
fix/fractional-usdt-balance

Conversation

@islandbitcoin

Copy link
Copy Markdown
Contributor

Problem

The wallet balance GraphQL field is typed FractionalCentAmount, but the USDT branch — which every post-cutover cash wallet hits — rounded micros to whole cents (half-to-even) before serving. The API reported up to half a cent more than the wallet holds, so any client that sent its reported balance died at IBEX:

insufficient balance. Current Balance: 1.099346. Estimated Fee: 0.000000. invoice amount: 1.100000

Found on-device while testing flash-mobile's MAX button (#512 / flash-mobile#689): a wallet holding 1,099,346 micros ($1.099346) was served balance: 110, the app filled $1.10, and the send failed. The legacy USD branch already preserves fractions (asCents(8), #230) — the USDT branch regressed the field's contract.

Fix

  • usdtMicrosToUsdCents moves to cash-wallet-cutover/amount-conversion.ts (pure bigint, alongside usdtMicrosToUsdCentsCeil) and now preserves the fraction (1 cent = 10,000 micros, ≤4 dp). Its old home in the graphql module booted app infra on import, which also hung unit tests on Redis retries.
  • Both UsdWallet and UsdtWallet balance resolvers route through it.
  • USDTAmount.asUsdCents() whole-cent behavior is untouched — payment paths are unaffected. No schema change (field was already FractionalCentAmount).

Clients floor for spendable-amount decisions — flash-mobile#689's MAX already does (it floors to whole minor units), so once this deploys the device repro passes. flash-mobile#690 tracks display-side flooring.

Tests

New usdtMicrosToUsdCents suite in amount-conversion.spec.ts: the exact device-repro pin (1,099,346 → 109.9346), the half-to-even cent boundary (109.5 stays 109.5), whole cents, full 4-dp precision, bigint/number inputs, .00 tolerance, fractional-micro and malformed-input rejection. Suites: cutover + wallets dirs 18/18, 129 tests green; tsc-check and eslint clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_015fAxSGEsL2LsMx1uCjAzHH

bobodread876 and others added 4 commits August 14, 2026 10:41
…unding overstated spendable balance

The wallet balance field is typed FractionalCentAmount, but the USDT
branch (every post-cutover cash wallet) rounded micros to whole cents
half-to-even before serving — reporting up to half a cent MORE than the
wallet holds. Any client that sent its reported balance failed at IBEX:
'insufficient balance. Current Balance: 1.099346 ... invoice amount:
1.100000' (found on-device while testing the mobile MAX button:
1,099,346 micros served as 110 cents).

usdtMicrosToUsdCents moves to cash-wallet-cutover/amount-conversion
(pure bigint, alongside its Ceil sibling; the graphql module home
booted app infra on import, hanging unit tests on redis retries) and
now preserves the fraction (≤4 dp). Both UsdWallet and UsdtWallet
balance resolvers route through it. asUsdCents()'s whole-cent default
is untouched — payment paths are unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015fAxSGEsL2LsMx1uCjAzHH
…, resolver-seam pin

- usdtMicrosToUsdCents now handles signed input: FractionalCentAmount is
  documented signed and IBEX can report small negative balances (fee
  reconciliation); the previous hard-error would have broken the wallet's
  balance field on every query. A leading '-' negates the result; -0
  normalizes to 0.
- The function returns number | InvalidCashWalletCutoverAmountError like
  its siblings instead of throwing bare/raw errors; both UsdWallet and
  UsdtWallet balance resolvers throw mapError(err) like their other error
  paths, so bad input surfaces as a mapped INVALID_INPUT, not an unmapped
  internal error.
- Pin the twice-regressed resolver seam (#230 USD; this PR USDT) with a
  real GraphQL query over the real UsdtWallet type (@app mocked, real
  amount-conversion): 1,099,346 micros serves 109.9346 on the wire,
  whole cents serve the integer, negatives serve signed, malformed
  balance maps through mapError.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015fAxSGEsL2LsMx1uCjAzHH
The UsdtWallet seam pin cannot see a UsdWallet-only regression, and the
legacy-wallet query is the path every post-cutover client still hits:
resolveCashWalletPresentationForAccount redirects the legacy USD wallet
id to the USDT settlement wallet inside UsdWallet's balance resolver.
This exact field has regressed twice (#230, this PR's bug).

Real GraphQL query over the real UsdWallet type, presentation mocked to
redirect, conversion real: 1,099,346 micros on the settlement wallet
must serve 109.9346 on the wire, and the balance lookup must land on
the settlement wallet id. Verified by mutation: reverting the resolver
branch to Number(balance.asUsdCents()) fails the test with 110.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015fAxSGEsL2LsMx1uCjAzHH
…cksum verification

carvel-dev/setup-action@v1 verifies downloads against a
'<sha256>  ./<file>' line in the release notes; ytt v0.55.2's notes
use a single space, so every Quickstart and UAT smoke run fails in
seconds at setup. Pin to v0.55.1 (correct two-space format) until an
upstream release restores it, then drop the pin.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015fAxSGEsL2LsMx1uCjAzHH
@islandbitcoin
islandbitcoin merged commit 06f1eca into main Aug 14, 2026
15 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