Skip to content

fix(paywall): block Pay when the balance is known to be too low - #79

Open
pedro-pelicioni wants to merge 3 commits into
stellar:mainfrom
Nearx-Labs:fix/74-balance-guard
Open

fix(paywall): block Pay when the balance is known to be too low#79
pedro-pelicioni wants to merge 3 commits into
stellar:mainfrom
Nearx-Labs:fix/74-balance-guard

Conversation

@pedro-pelicioni

Copy link
Copy Markdown

fix(paywall): block Pay when the balance is known to be too low

Branch: fix/74-balance-guard
Screenshots: harness/shots/before-empty-balance.pngharness/shots/after-empty-balance.png

The bug

handlePayment in packages/paywall/src/browser/StellarPaywall.tsx guards on an
empty string:

if (tokenBalanceFormatted === "") {
  const freshBalance = await refreshBalance();
  if (Number(freshBalance) < amount) { /* reject */ }
}

useStellarBalance fetches the balance in a useEffect as soon as address
becomes non-null, so by the time the Pay button is clickable
tokenBalanceFormatted is already populated and the whole block is skipped. A
connected wallet holding less than the price goes straight to signing and only
fails afterwards, at verify/settle, with an error that does not name the cause.

Reproduce

Connect a wallet with 0 USDC to a route priced at $0.51 and press Pay. The
wallet prompts for a signature; the failure arrives from the facilitator.

Rendering the real component against a stubbed balance of 0n confirms it:

Pay label disabled
before Pay false
after Insufficient USDC true

The change

  • Always compare balance against the requirement before paying; re-read the
    balance first only when it is not known.
  • Compare in raw atomic units via isBalanceInsufficient rather than
    Number(formatted) < amount. Both sides are already atomic units of the same
    asset, so the comparison is exact and independent of the asset's decimals. The
    old float path also lost precision above 2^53 — there is a test for that case.
  • refreshBalance now resolves to bigint | null instead of the formatted
    string, so callers can make that comparison. It is only consumed inside this
    package.
  • Disable Pay while the balance is known to be insufficient, and say why:
    current balance, the Circle faucet link on testnet, and an inline
    "check again" that re-reads the balance.

A null balance — never fetched, or the read failed — leaves Pay enabled.
Unknown is not treated as insufficient, so a transient RPC blip cannot lock
someone out of paying.

Tests

8 new cases in utils.test.ts covering unknown balance, unparseable amount,
below/exact/above the price, a zero-price requirement, and the 2^53 precision
case. pnpm test, pnpm typecheck and pnpm lint all pass.


Part of #74.

pedro-pelicioni and others added 3 commits August 11, 2026 21:19
`isBalanceInsufficient` takes the raw on-chain balance and the atomic
`amount` from the payment requirement. Both are already atomic units of
the same asset, so the comparison needs no decimals lookup and no float
round-trip — the existing `Number(formatted) < amount` path silently
loses precision above 2^53.

Returns `null` when the balance is unknown or the amount is unparseable,
so callers can tell "not enough" apart from "don't know yet".

Co-authored-by: Nearx-Labs <nearxlabs@nearx.com.br>
It returned the formatted display string, which is the wrong shape for
deciding whether a payment can go through. Return `bigint | null`
instead so callers can compare against the requirement exactly.

The value is only consumed inside this package.

Co-authored-by: Nearx-Labs <nearxlabs@nearx.com.br>
The guard in `handlePayment` only ran while `tokenBalanceFormatted` was
still empty. `useStellarBalance` fetches as soon as a wallet connects, so
by the time Pay is clickable that string is populated and the guard is
skipped entirely. A wallet holding less than the price went straight to
signing and only failed later, at settlement, with an error that does not
name the cause.

Always compare before paying, re-reading the balance first only when it
is not known. Disable Pay while the balance is short and say why: the
current balance, the Circle faucet on testnet, and an inline check that
re-reads it.

An unknown balance leaves Pay enabled — a transient RPC failure should
not lock someone out of paying.

Co-authored-by: Nearx-Labs <nearxlabs@nearx.com.br>
Copilot AI balanced review requested due to automatic review settings August 12, 2026 00:19

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

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