Fix card withdrawal showing $0 when balance cannot be read - #2515
Conversation
"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
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
Code Review1 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:
The code comment at lines 82-84 warns about this exact issue. Suggested fix at CardWithdrawForm.tsx L363-L366: Change line 364 from: See: solid-ui/components/Card/CardWithdrawForm.tsx Lines 363 to 366 in 5b369a9 |
`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
|
Confirmed and fixed in The trace holds: with Implemented as a named predicate rather than an inline const holdsNoCollateral = !isRainCard && !isIssuerResolving;One deliberate departure: the Try again affordance stays gated on 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 |
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 reportsisLoading: falsefor all three, preventing skeleton loaders from indicating uncertainty.Key Changes
CardWithdrawForm.tsx:
isCollateralResolvingandisCollateralUnknownstates separatelycollateralUnavailableNoticeto explain why the balance couldn't be read with context-specific messagingonSubmitto catch missingfundingTokenAddressCardWithdrawForm.test.tsx (new):
cardHelpers.ts:
canWithdrawFromCardto check card provider and hide withdraw action for Wirex cards (which hold no balance and have no collateral proxy)providerfield toCardFundsAccessinterfacecardFundsAccess.test.ts:
lib/types.ts:
unavailableReasonfield toCardCollateralAvailableDtoto indicate when a balance read failedCardDetailsPane.tsx:
providertofundsAccessobject for withdrawal eligibility checksImplementation 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 unknownThe 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
nullprovider 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