Skip to content

Fix card withdrawal showing $0 when balance cannot be read - #2515

Merged
MusabShakeel576 merged 2 commits into
masterfrom
claude/monday-task-review-92c6hd
Sep 13, 2026
Merged

MusabShakeel576 merged 2 commits into
masterfrom
claude/monday-task-review-92c6hd

Conversation

@MusabShakeel576

Copy link
Copy Markdown
Contributor

Summary

Fixed a bug where the card withdrawal screen displayed "$0" with a disabled Max button when the collateral balance could not be read from the blockchain, misleading users into thinking their card had no funds available. The issue occurred because three distinct states (query failed, issuer holds no collateral, balance unreadable) were all collapsed into availableUsd ?? 0, and React Query v5 reports isLoading: false for all three, preventing skeleton loaders from indicating uncertainty.

Key Changes

  • CardWithdrawForm.tsx:

    • Added logic to distinguish between "balance is zero" and "balance is unknown" by tracking isCollateralResolving and isCollateralUnknown states separately
    • Display "—" instead of "$0" when the balance cannot be determined
    • Disable the Max button only when the balance is truly unknown, not when it's zero
    • Skip amount validation cap when balance is unknown (matching backend behavior)
    • Added collateralUnavailableNotice to explain why the balance couldn't be read with context-specific messaging
    • Added "Try again" button for Rain cards to retry the collateral query
    • Improved error handling in onSubmit to catch missing fundingTokenAddress
  • CardWithdrawForm.test.tsx (new):

    • Comprehensive test suite covering four distinct scenarios:
      • Successfully read balance shows correct amount with enabled Max
      • Zero balance shows "$0" with disabled Max
      • Failed query shows "—" with explanation and disabled Max
      • Unreadable balance shows "—" with specific error message
      • Wirex cards (no collateral) show appropriate message
      • Unresolved issuer shows skeleton while resolving
  • cardHelpers.ts:

    • Updated canWithdrawFromCard to check card provider and hide withdraw action for Wirex cards (which hold no balance and have no collateral proxy)
    • Added provider field to CardFundsAccess interface
  • cardFundsAccess.test.ts:

    • Added tests verifying Wirex cards cannot withdraw
    • Added tests verifying Rain cards and unresolved issuers can withdraw
  • lib/types.ts:

    • Added unavailableReason field to CardCollateralAvailableDto to indicate when a balance read failed
  • CardDetailsPane.tsx:

    • Pass provider to fundsAccess object for withdrawal eligibility checks

Implementation Details

The fix introduces three key state variables:

  • isCollateralResolving: True while waiting for data (loading or issuer still resolving)
  • isCollateralUnknown: True when we have no trustworthy figure (error, missing data, or unreadable balance)
  • isAvailableUnknown: True when the displayed figure is unknown

The validation schema now allows amounts above the unknown cap (matching backend behavior), since Rain's on-chain validation will reject over-withdrawals anyway, and a flaky RPC should not block valid withdrawals.

The issuer resolution state is carefully handled: a null provider means "not known yet" and must not be treated as "not Rain" to avoid flashing wrong copy at Rain cardholders on screen open.

https://claude.ai/code/session_01NqcWY7ujadfEEEpiVV7576

"Withdraw from card" showed `$0` with Max greyed out and no explanation to
cardholders whose card reported a balance, so they could not withdraw at
all. Four different states arrived at that screen as `availableUsd ?? 0`,
and only one of them is an answer of zero:

- the collateral query failed (a 401 past the refresh, a Rain outage — the
  endpoint throws whenever `getContracts` does);
- the backend read the response but could not read the asset's on-chain
  balance, and now says so with `unavailableReason`;
- the query never ran, because it is Rain-only;
- the proxy really is empty.

React Query v5 reports `isLoading: false` for a disabled or errored query,
so the first three did not even get a skeleton — the screen settled on a
confident "$0". It now shows an em dash and a line saying which of the
three happened, with a Try again for the ones that can be retried.

An unknown cap also no longer blocks the withdrawal. The backend's own
pre-check already takes that line — it logs a failed balance read and
defers to Rain, because Rain rejects an over-withdrawal anyway and a flaky
RPC must not block a valid one — so the screen was stricter than the API it
calls. It still blocks when there is no response at all, since the
signature needs a token address that only that response carries.

Withdraw is no longer offered on a Wirex card. Those hold no balance of
their own (see `canDepositToCard`): there is no collateral proxy behind
them, no backend endpoint that would serve one, and the withdraw screen's
Rain-only query never runs — so the action was a guaranteed dead end on
"$0". Their money is in savings and comes out through the savings
withdrawal.

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

vercel Bot commented Sep 13, 2026

Copy link
Copy Markdown

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

2 Skipped Deployments
Project Deployment Actions Updated
solid-app Ignored Ignored Preview Sep 13, 2026 1:47pm UTC
solid-app-staging Ignored Ignored Preview Sep 13, 2026 1:47pm UTC

Request Review

@claude

claude Bot commented Sep 13, 2026

Copy link
Copy Markdown

Code Review

1 issue found:

Bug in components/Card/CardWithdrawForm.tsx (line 364)

Missing isIssuerResolving check causes wrong error message.

When provider === null (issuer still resolving) AND the collateral query errors, isRainCard evaluates to false (since null !== CardProvider.RAIN). This causes a Rain cardholder to see the Wirex-specific message instead of the correct error.

Scenario:

  1. User opens withdrawal screen
  2. Provider query has not resolved yet (provider === null)
  3. Collateral query fails fast (isCollateralError === true)
  4. The condition evaluates to true
  5. Wrong message shown: Withdrawing to your wallet is only available on cards that hold their own balance.

The code comment at lines 82-84 warns about this exact issue.

Suggested fix at CardWithdrawForm.tsx L363-L366:

Change line 364 from:
if (!isRainCard && !collateral) {
to:
if (!isRainCard && !isIssuerResolving && !collateral) {

See:

if (!isAvailableUnknown) return null;
if (!isRainCard && !collateral) {
return 'Withdrawing to your wallet is only available on cards that hold their own balance.';
}

`isRainCard` is false for two unrelated reasons — the card is not on Rain,
and we do not know the issuer yet — and the unavailable-collateral notice
read it as the first. So a Rain cardholder whose collateral query had
errored while the issuer was momentarily unresolved was told "Withdrawing
to your wallet is only available on cards that hold their own balance",
which is both wrong and alarming on a card that holds a balance.

Reachable because `isError` outlives a provider flip back to null: the
issuer queries are keyed on the selected userId, so switching user re-keys
them and `resolveCardIssuer` answers null again, while the collateral key
(`[key, tokenAddress]`) carries no userId and keeps its error.

The distinction was already drawn for `isCollateralResolving` a few lines
up; it is now a named `holdsNoCollateral` that both can share, so the two
questions stop being asked with one boolean.

The Try again affordance deliberately stays on `isRainCard`: the notice
only has to name what went wrong, but a retry has to do something, and the
query it refetches runs for Rain alone.

Reported by Claude Code Review on #2515.

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

Copy link
Copy Markdown
Contributor Author

Confirmed and fixed in 3f9702e.

The trace holds: with provider === null and isCollateralError === true, isCollateralResolving is false || (true && true && false)false, so isCollateralUnknown is true and !isRainCard && !collateral reaches the Wirex branch. And it's reachable rather than theoretical — isError outlives a provider flip back to null, because the issuer queries are keyed on the selected userId (switching user re-keys them and resolveCardIssuer answers null again) while the collateral key is [key, tokenAddress] with no userId in it, so it holds onto its error.

Implemented as a named predicate rather than an inline !isIssuerResolving, since the same distinction was already drawn a few lines up for isCollateralResolving and the point is to stop asking two questions with one boolean:

const holdsNoCollateral = !isRainCard && !isIssuerResolving;

One deliberate departure: the Try again affordance stays gated on isRainCard, not !holdsNoCollateral. The notice only has to name what went wrong, but a retry has to actually do something, and the query it refetches runs for Rain alone — while the issuer is still resolving there is nothing to retry, and the query starts itself (then polls every 5s) the moment the issuer lands. Commented at the call site so it doesn't read as the same oversight.

Added a regression test for the exact scenario; it fails against the previous commit and passes now. Full run: 599 tests / 38 suites pass, eslint clean, no new tsc errors.


Generated by Claude Code

@MusabShakeel576
MusabShakeel576 merged commit a3fc931 into master Sep 13, 2026
5 checks passed
@MusabShakeel576
MusabShakeel576 deleted the claude/monday-task-review-92c6hd branch September 13, 2026 13:48
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