Skip to content

feat(buy): tell a player they are short of NIM before they open the w… - #17

Merged
Samuel1-ona merged 1 commit into
mainfrom
samuel1-ona/nim-payment-fails-without-warning
Sep 8, 2026
Merged

feat(buy): tell a player they are short of NIM before they open the w…#17
Samuel1-ona merged 1 commit into
mainfrom
samuel1-ona/nim-payment-fails-without-warning

Conversation

@Samuel1-ona

Copy link
Copy Markdown
Contributor

…allet

A player without enough NIM found out from the wallet, after committing to a payment dialog — and sendNimWithData reports whatever comes back as "The payment was declined", which is the wrong sentence: they did not decline, they could not pay. The panel now says so first, with the amount.

Where the check hangs, and why it matters

The mini-app SDK has no balance method — the Nimiq provider exposes accounts, signing, consensus and payments, and nothing that reports a balance (verified against the installed @nimiq/mini-app-sdk@0.1.0 type definitions, not from memory). So the balance comes from a node, via getAccountByAddress, through the same RPC client settlement already trusts. NIMIQ_RPC_URL is server-side, hence the new route.

Reading a balance needs an address, and listAccounts() raises a native confirmation. Nimiq's own guidance is that confirmation-requiring calls must be separated by clear user intent rather than queued, so the address prompt rides the GET NIM PRICE tap and the payment stays on the PAY tap — two dialogs, two taps, the price on screen in between. Doing both inside the pay tap would have queued two dialogs, which is the documented anti-pattern.

Best-effort by construction

The check can only ever add a warning. A declined prompt, an unreachable node, a browser with no provider to ask — all leave the shortfall unknown, which renders as nothing and leaves the button exactly as it was. Only a shortfall that was actually measured disables paying, because the wallet, not this endpoint, is the authority on what a player can afford. /api/nim/balance answers 503 rather than failing the quote.

Balance is returned as a bigint so it is compared against the quoted Luna without either side passing through a float, matching every other amount here.

Verification

lint clean, tsc --noEmit clean, 1035 tests pass (17 new).

Mutation-tested: accepting a negative balance from the node turns 1 red; dropping the address shape check turns 6 red. The address validation is checked before any request leaves the server, so the endpoint cannot be used to forward arbitrary strings to a third-party RPC.

Not verified automatically: the listAccounts() prompt and its declined path only exist inside Nimiq Pay, so the two-dialog sequence needs a look on a real device before this is trusted to behave well.

Claude-Session: https://claude.ai/code/session_01Pa7dcWrqWwkwZE2mZWSG1P

The hole, and the fix

What this does NOT do / residual risk

Judgement calls

Issues

Closes #
Refs #

Stacking / conflicts

Verification evidence

Remaining ops steps

  • none

Checklist

  • Title is the commit message I want on main
  • Ships the test that fails on pre-fix code; fixtures are real captured data; fakes honour their arguments
  • Covers the seam, not only pure functions; any stated guarantee tested on its failure path
  • Error responses audited for what they leak; reads on write paths bounded at the query
  • Judgement calls / bundled product changes flagged above (or "none")
  • lint / typecheck / test pass locally on the current head
  • Re-read acceptance criteria of every Closes issue — all met
  • Development sidebar links match Closes/Refs above
  • README / runbook / .env.example / examples / error strings updated for the world this creates
  • Lockfile touched → rebased on current main, lockfile regenerated (never hand-resolved)
  • Wallet/provider tree touched → loaded in a normal browser, not only Nimiq Pay
  • Money/security path → money-path-checklist.md run; payout logic compared against the other side
  • No secrets in the diff
  • Questions for the maintainer marked clearly at the end (or "none")

…allet

A player without enough NIM found out from the wallet, after committing to a
payment dialog — and `sendNimWithData` reports whatever comes back as "The
payment was declined", which is the wrong sentence: they did not decline, they
could not pay. The panel now says so first, with the amount.

## Where the check hangs, and why it matters

The mini-app SDK has no balance method — the Nimiq provider exposes accounts,
signing, consensus and payments, and nothing that reports a balance (verified
against the installed @nimiq/mini-app-sdk@0.1.0 type definitions, not from
memory). So the balance comes from a node, via `getAccountByAddress`, through
the same RPC client settlement already trusts. `NIMIQ_RPC_URL` is server-side,
hence the new route.

Reading a balance needs an address, and `listAccounts()` raises a native
confirmation. Nimiq's own guidance is that confirmation-requiring calls must be
separated by clear user intent rather than queued, so the address prompt rides
the GET NIM PRICE tap and the payment stays on the PAY tap — two dialogs, two
taps, the price on screen in between. Doing both inside the pay tap would have
queued two dialogs, which is the documented anti-pattern.

## Best-effort by construction

The check can only ever add a warning. A declined prompt, an unreachable node,
a browser with no provider to ask — all leave the shortfall unknown, which
renders as nothing and leaves the button exactly as it was. Only a shortfall
that was actually measured disables paying, because the wallet, not this
endpoint, is the authority on what a player can afford. `/api/nim/balance`
answers 503 rather than failing the quote.

Balance is returned as a bigint so it is compared against the quoted Luna
without either side passing through a float, matching every other amount here.

## Verification

lint clean, `tsc --noEmit` clean, 1035 tests pass (17 new).

Mutation-tested: accepting a negative balance from the node turns 1 red;
dropping the address shape check turns 6 red. The address validation is
checked before any request leaves the server, so the endpoint cannot be used to
forward arbitrary strings to a third-party RPC.

Not verified automatically: the `listAccounts()` prompt and its declined path
only exist inside Nimiq Pay, so the two-dialog sequence needs a look on a real
device before this is trusted to behave well.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pa7dcWrqWwkwZE2mZWSG1P
@vercel

vercel Bot commented Sep 8, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
terreno-web Ready Ready Preview Sep 8, 2026 6:14pm UTC

Request Review

@Samuel1-ona
Samuel1-ona merged commit 470cfee into main Sep 8, 2026
3 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.

1 participant