diff --git a/components/Card/CardWithdrawForm.tsx b/components/Card/CardWithdrawForm.tsx
index 757796f1..25b1dac3 100644
--- a/components/Card/CardWithdrawForm.tsx
+++ b/components/Card/CardWithdrawForm.tsx
@@ -21,7 +21,11 @@ import { withdrawFromCard, withdrawFromCardToSavings } from '@/lib/api';
import { EXPO_PUBLIC_CARD_FUNDING_CHAIN_ID } from '@/lib/config';
import { CardProvider } from '@/lib/types';
import { cn, formatNumber, getCardDepositTokenSymbol } from '@/lib/utils';
-import { assetLabel, toAmountInputValue } from '@/lib/utils/cardHelpers';
+import {
+ assetLabel,
+ isDifferentCollateralAsset,
+ toAmountInputValue,
+} from '@/lib/utils/cardHelpers';
import { CardDepositSource } from '@/store/useCardDepositStore';
import { useCardWithdrawStore } from '@/store/useCardWithdrawStore';
@@ -457,6 +461,16 @@ export default function CardWithdrawForm() {
assets={collateral?.tokens}
selectedTokenAddress={collateral?.tokenAddress}
onSelectAsset={asset => {
+ // Re-picking the asset already selected must leave the amount
+ // alone: the row the user taps is the one the trigger names, the
+ // cap has not moved, and wiping it there turned "press Max, then
+ // confirm the destination" into an empty field and a withdrawal
+ // that could not be submitted.
+ if (
+ !isDifferentCollateralAsset(asset, selectedTokenAddress ?? fundingTokenAddress)
+ ) {
+ return;
+ }
setSelectedTokenAddress(asset.tokenAddress);
// The cap belongs to the old asset; clear it rather than
// validate the typed amount against a balance it never had.
diff --git a/components/Card/ToDestinationSelector.native.tsx b/components/Card/ToDestinationSelector.native.tsx
index c47d3517..bd4288f8 100644
--- a/components/Card/ToDestinationSelector.native.tsx
+++ b/components/Card/ToDestinationSelector.native.tsx
@@ -1,92 +1,18 @@
-import { useState } from 'react';
-import { Pressable, View } from 'react-native';
-import { ChevronDown, Wallet as WalletIcon } from 'lucide-react-native';
-
-import { Text } from '@/components/ui/text';
-import { formatNumber } from '@/lib/utils';
-import { assetLabel } from '@/lib/utils/cardHelpers';
-import { CardDepositSource } from '@/store/useCardDepositStore';
-
import type { ToDestinationProps } from './ToDestinationSelector.types';
export type { ToDestinationProps };
/**
- * Re-exported so `import { assetLabel } from '.../ToDestinationSelector'`
- * resolves on native too. Metro picks this `.native` file over the `.web` one,
- * so anything the web module exports has to be exported here as well or it
- * silently becomes `undefined` on device.
+ * Native and web share one picker now (`ToDestinationSelector.shared.tsx`): the
+ * in-flow list this file used to hold, which is the one that works inside the
+ * withdraw sheet on both platforms.
*/
-export { assetLabel };
-
-export default function ToDestinationSelector({
- onChange,
- tokenSymbol = 'USDC',
- assets,
- selectedTokenAddress,
- onSelectAsset,
-}: ToDestinationProps) {
- const [isOpen, setIsOpen] = useState(false);
+export { default } from './ToDestinationSelector.shared';
- const withdrawable = assets?.filter(asset => !asset.unavailableReason) ?? [];
- const selected = withdrawable.find(
- asset => asset.tokenAddress.toLowerCase() === selectedTokenAddress?.toLowerCase(),
- );
- const triggerSymbol = selected ? assetLabel(selected) : tokenSymbol;
-
- return (
-
- setIsOpen(!isOpen)}
- >
-
-
- Wallet
-
-
- {triggerSymbol}
-
-
-
- {isOpen && (
-
- {withdrawable.length ? (
- withdrawable.map(asset => (
- {
- onChange(CardDepositSource.COLLATERAL);
- onSelectAsset?.(asset);
- setIsOpen(false);
- }}
- >
-
-
- Wallet
- {assetLabel(asset)}
-
-
- ${formatNumber(asset.balanceUsd, 2, 2)}
-
-
- ))
- ) : (
- {
- onChange(CardDepositSource.COLLATERAL);
- setIsOpen(false);
- }}
- >
-
- Wallet
- {tokenSymbol}
-
- )}
-
- )}
-
- );
-}
+/**
+ * Re-exported so `import { assetLabel } from '.../ToDestinationSelector'`
+ * resolves on native too. Metro picks this `.native` file over the `.web` one, so
+ * anything the web module exports has to be exported here as well or it silently
+ * becomes `undefined` on device.
+ */
+export { assetLabel } from '@/lib/utils/cardHelpers';
diff --git a/components/Card/ToDestinationSelector.shared.tsx b/components/Card/ToDestinationSelector.shared.tsx
new file mode 100644
index 00000000..ec3c7d17
--- /dev/null
+++ b/components/Card/ToDestinationSelector.shared.tsx
@@ -0,0 +1,128 @@
+import { useState } from 'react';
+import { Pressable, View } from 'react-native';
+import { ChevronDown, Wallet as WalletIcon } from 'lucide-react-native';
+
+import { Text } from '@/components/ui/text';
+import { CHAIN_NAMES } from '@/constants/chains';
+import { CardCollateralTokenBalanceDto } from '@/lib/types';
+import { formatNumber } from '@/lib/utils';
+import { assetLabel, withdrawableAssetOptions } from '@/lib/utils/cardHelpers';
+import { CardDepositSource } from '@/store/useCardDepositStore';
+
+import type { ToDestinationProps } from './ToDestinationSelector.types';
+
+/**
+ * How an asset is named in the picker: its symbol, plus the chain when the same
+ * symbol appears more than once.
+ *
+ * A card funded on two chains holds two assets called "USDC", and two identical
+ * rows with different balances is a choice nobody can make. The chain is only
+ * added where it disambiguates — on the common single-chain card it would be
+ * noise on every row.
+ */
+const optionLabel = (
+ asset: CardCollateralTokenBalanceDto,
+ options: CardCollateralTokenBalanceDto[],
+): string => {
+ const label = assetLabel(asset);
+ const isAmbiguous = options.some(other => other !== asset && assetLabel(other) === label);
+ if (!isAmbiguous) return label;
+ return `${label} · ${CHAIN_NAMES[asset.chainId] ?? `Chain ${asset.chainId}`}`;
+};
+
+/**
+ * "To" on the withdraw-from-card screen: the wallet, and which collateral asset
+ * the withdrawal draws from.
+ *
+ * ## Why this is one plain list rather than a dropdown menu
+ *
+ * The web build used to render this as a portalled dropdown menu over the sheet.
+ * Inside the withdraw modal on a phone browser that combination did not work: the
+ * menu covered the "Withdraw" button it was asking the user to press next, and
+ * taps on its rows mostly went nowhere — a portalled menu layered over a modal is
+ * two dismiss layers arguing over the same touch. A cardholder in the support
+ * recording spent nine seconds tapping the asset they wanted, got no response,
+ * and gave up with the screen still open. That is the whole of "withdraw is not
+ * working".
+ *
+ * An in-flow list has neither problem, and it is what native has always rendered.
+ * Both platforms now share this file, so a fix to the picker can no longer land on
+ * one of them only.
+ */
+export default function ToDestinationSelector({
+ onChange,
+ tokenSymbol = 'USDC',
+ assets,
+ selectedTokenAddress,
+ onSelectAsset,
+}: ToDestinationProps) {
+ const [isOpen, setIsOpen] = useState(false);
+
+ const options = withdrawableAssetOptions(assets, selectedTokenAddress);
+ const selected = options.find(
+ asset => asset.tokenAddress.toLowerCase() === selectedTokenAddress?.toLowerCase(),
+ );
+ const triggerSymbol = selected ? optionLabel(selected, options) : tokenSymbol;
+
+ return (
+
+ setIsOpen(!isOpen)}
+ >
+
+
+ Wallet
+
+
+ {triggerSymbol}
+
+
+
+ {isOpen && (
+
+ {options.length ? (
+ options.map(asset => (
+ {
+ onChange(CardDepositSource.COLLATERAL);
+ onSelectAsset?.(asset);
+ setIsOpen(false);
+ }}
+ >
+
+
+ Wallet
+
+ {optionLabel(asset, options)}
+
+
+
+ ${formatNumber(asset.balanceUsd, 2, 2)}
+
+
+ ))
+ ) : (
+ {
+ onChange(CardDepositSource.COLLATERAL);
+ setIsOpen(false);
+ }}
+ >
+
+ Wallet
+ {tokenSymbol}
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/components/Card/ToDestinationSelector.tsx b/components/Card/ToDestinationSelector.tsx
index baa59ce9..b625fa63 100644
--- a/components/Card/ToDestinationSelector.tsx
+++ b/components/Card/ToDestinationSelector.tsx
@@ -1,3 +1,3 @@
+export { default } from './ToDestinationSelector.shared';
export type { ToDestinationProps } from './ToDestinationSelector.types';
-export { default } from './ToDestinationSelector.web';
export { assetLabel } from '@/lib/utils/cardHelpers';
diff --git a/components/Card/ToDestinationSelector.web.tsx b/components/Card/ToDestinationSelector.web.tsx
index 86a16929..33061342 100644
--- a/components/Card/ToDestinationSelector.web.tsx
+++ b/components/Card/ToDestinationSelector.web.tsx
@@ -1,86 +1,19 @@
-import { Pressable, View } from 'react-native';
-import { ChevronDown, Wallet as WalletIcon } from 'lucide-react-native';
-
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuTrigger,
-} from '@/components/ui/dropdown-menu';
-import { Text } from '@/components/ui/text';
-import { formatNumber } from '@/lib/utils';
-import { assetLabel } from '@/lib/utils/cardHelpers';
-import { CardDepositSource } from '@/store/useCardDepositStore';
-
import type { ToDestinationProps } from './ToDestinationSelector.types';
export type { ToDestinationProps };
/**
- * Re-exported so callers already importing it from the selector keep working.
- * The implementation is platform-neutral on purpose — see `assetLabel`.
+ * Web renders the same in-flow picker as native — see
+ * `ToDestinationSelector.shared.tsx` for why the portalled dropdown menu that
+ * used to live here had to go.
*/
-export { assetLabel };
-
-export default function ToDestinationSelector({
- onChange,
- tokenSymbol = 'USDC',
- assets,
- selectedTokenAddress,
- onSelectAsset,
-}: ToDestinationProps) {
- const withdrawable = assets?.filter(asset => !asset.unavailableReason) ?? [];
- const selected = withdrawable.find(
- asset => asset.tokenAddress.toLowerCase() === selectedTokenAddress?.toLowerCase(),
- );
- const triggerSymbol = selected ? assetLabel(selected) : tokenSymbol;
+export { default } from './ToDestinationSelector.shared';
- return (
-
-
-
-
-
- Wallet
-
-
- {triggerSymbol}
-
-
-
-
-
- {withdrawable.length ? (
- withdrawable.map(asset => (
- {
- onChange(CardDepositSource.COLLATERAL);
- onSelectAsset?.(asset);
- }}
- className="flex-row items-center justify-between px-4 py-3 web:cursor-pointer"
- >
-
-
- Wallet
- {assetLabel(asset)}
-
-
- ${formatNumber(asset.balanceUsd, 2, 2)}
-
-
- ))
- ) : (
- onChange(CardDepositSource.COLLATERAL)}
- className="flex-row items-center gap-2 px-4 py-3 web:cursor-pointer"
- >
-
- Wallet
- {tokenSymbol}
-
- )}
-
-
- );
-}
+/**
+ * Re-exported so `import { assetLabel } from '.../ToDestinationSelector'` keeps
+ * resolving on web. The helper itself is platform-neutral and lives in
+ * `cardHelpers`; both platform variants must export the same names or the one
+ * that does not silently hands callers `undefined` (see
+ * `__tests__/toDestinationSelectorExports.test.ts`).
+ */
+export { assetLabel } from '@/lib/utils/cardHelpers';
diff --git a/components/Card/__tests__/ToDestinationSelector.test.tsx b/components/Card/__tests__/ToDestinationSelector.test.tsx
new file mode 100644
index 00000000..bbd5a656
--- /dev/null
+++ b/components/Card/__tests__/ToDestinationSelector.test.tsx
@@ -0,0 +1,154 @@
+import React from 'react';
+
+import ToDestinationSelector from '@/components/Card/ToDestinationSelector.shared';
+import { CardCollateralTokenBalanceDto } from '@/lib/types';
+
+// eslint-disable-next-line @typescript-eslint/no-require-imports
+const { act, create } = require('react-test-renderer');
+
+/**
+ * The asset picker on "Withdraw from card".
+ *
+ * On web this used to be a portalled dropdown menu rendered over the withdraw
+ * sheet: it covered the "Withdraw" button and swallowed taps on its own rows, so
+ * a cardholder could neither pick an asset nor reach the button — the "withdraw
+ * is not working" report. Both platforms now render the in-flow list this file
+ * exercises, and a press on a row has to do three things: name the destination,
+ * report the asset, and close the list.
+ */
+
+// `@/lib/utils` re-exports wagmi's ESM build, which does not load under
+// jest-expo, and only two helpers are used here.
+jest.mock('@/lib/utils', () => ({
+ cn: (...inputs: unknown[]) => inputs.filter(Boolean).join(' '),
+ formatNumber: (value: number) => String(value),
+}));
+jest.mock('@/constants/chains', () => ({
+ CHAIN_NAMES: { 8453: 'Base', 42161: 'Arbitrum' },
+}));
+// The store is imported for this enum alone, and reaches MMKV on the way in.
+jest.mock('@/store/useCardDepositStore', () => ({
+ CardDepositSource: { COLLATERAL: 'collateral' },
+}));
+jest.mock('@/components/ui/text', () => ({ Text: 'Text' }));
+jest.mock('lucide-react-native', () => ({ ChevronDown: 'ChevronDown', Wallet: 'Wallet' }));
+
+const asset = (
+ overrides: Partial,
+): CardCollateralTokenBalanceDto => ({
+ rainCollateralContractId: 'c1',
+ chainId: 8453,
+ collateralProxy: '0xproxy',
+ tokenAddress: '0xtoken',
+ symbol: 'USDC',
+ decimals: 6,
+ rawBalance: '0',
+ balanceUsd: 0,
+ ...overrides,
+});
+
+/** What the cardholder in the support recording actually held. */
+const USDC = asset({ symbol: 'USDC', tokenAddress: '0xusdc', balanceUsd: 30.04 });
+const USDT = asset({ symbol: 'USDT', tokenAddress: '0xusdt', balanceUsd: 5.01 });
+const EMPTY_USDC = asset({ symbol: 'USDC', tokenAddress: '0xusdc2', chainId: 42161 });
+const EMPTY_DAI = asset({ symbol: 'DAI', tokenAddress: '0xdai' });
+
+type Handlers = {
+ onChange?: jest.Mock;
+ onSelectAsset?: jest.Mock;
+};
+
+const render = (
+ assets: CardCollateralTokenBalanceDto[],
+ { onChange = jest.fn(), onSelectAsset = jest.fn() }: Handlers = {},
+ selectedTokenAddress: string | undefined = assets[0]?.tokenAddress,
+) => {
+ let tree: any;
+ act(() => {
+ tree = create(
+ ,
+ );
+ });
+
+ // Matched on props rather than on `Pressable` itself: the host view a Pressable
+ // renders carries the same accessibility role but no `onPress`, so this picks
+ // out exactly the elements a tap is delivered to, in render order.
+ const pressables = () =>
+ tree.root.findAll(
+ (node: any) =>
+ typeof node.props?.onPress === 'function' && node.props?.accessibilityRole === 'button',
+ );
+
+ return {
+ tree,
+ onChange,
+ onSelectAsset,
+ /** The list is closed until the trigger — the first pressable — is pressed. */
+ open: () => act(() => pressables()[0].props.onPress()),
+ rows: () => pressables().slice(1),
+ labels: () =>
+ tree.root
+ .findAll((node: any) => node.type === 'Text')
+ .flatMap((node: any) => [node.props.children].flat(Infinity))
+ .filter((child: any) => typeof child === 'string')
+ .join(' | '),
+ };
+};
+
+test('offers the assets the card holds, not every token its contracts support', () => {
+ // Ten rows, eight of them $0, is what buried the two assets this cardholder
+ // could actually withdraw.
+ const picker = render([USDC, USDT, EMPTY_USDC, EMPTY_DAI]);
+ picker.open();
+
+ expect(picker.rows()).toHaveLength(2);
+ expect(picker.labels()).toContain('USDT');
+ expect(picker.labels()).not.toContain('DAI');
+
+ act(() => picker.tree.unmount());
+});
+
+test('picking an asset reports it and closes the list', () => {
+ const picker = render([USDC, USDT]);
+ picker.open();
+
+ act(() => picker.rows()[1].props.onPress());
+
+ expect(picker.onSelectAsset).toHaveBeenCalledWith(USDT);
+ expect(picker.onChange).toHaveBeenCalledWith('collateral');
+ // Closed again, so the button underneath it is reachable.
+ expect(picker.rows()).toHaveLength(0);
+
+ act(() => picker.tree.unmount());
+});
+
+test('names the chain when one symbol would otherwise appear twice', () => {
+ const usdcOnArbitrum = asset({
+ symbol: 'USDC',
+ tokenAddress: '0xusdc2',
+ chainId: 42161,
+ balanceUsd: 12,
+ });
+ const picker = render([USDC, usdcOnArbitrum]);
+ picker.open();
+
+ expect(picker.labels()).toContain('USDC · Base');
+ expect(picker.labels()).toContain('USDC · Arbitrum');
+
+ act(() => picker.tree.unmount());
+});
+
+test('falls back to the default symbol when the card reports no collateral', () => {
+ const picker = render([], {}, undefined);
+ picker.open();
+
+ expect(picker.rows()).toHaveLength(1);
+ expect(picker.labels()).toContain('USDC');
+
+ act(() => picker.tree.unmount());
+});
diff --git a/components/Home/NewHome/HomeScreenNew.tsx b/components/Home/NewHome/HomeScreenNew.tsx
index 3d3d6bf2..e8f3f305 100644
--- a/components/Home/NewHome/HomeScreenNew.tsx
+++ b/components/Home/NewHome/HomeScreenNew.tsx
@@ -11,7 +11,10 @@ import HomePromoBanners from '@/components/Home/NewHome/HomePromoBanners';
import HomePromptCard from '@/components/Home/NewHome/HomePromptCard';
import HomeRecentActivity from '@/components/Home/NewHome/HomeRecentActivity';
import HomeWalletCard from '@/components/Home/NewHome/HomeWalletCard';
-import { getTotalBalance } from '@/components/Home/NewHome/OtherBalancesDropdown';
+import {
+ getTotalBalance,
+ holdsFundsAnywhere,
+} from '@/components/Home/NewHome/OtherBalancesDropdown';
import OtherBalancesDropdown from '@/components/Home/NewHome/OtherBalancesDropdown/OtherBalancesDropdown';
import WalletActions from '@/components/Home/NewHome/WalletActions';
import WalletBalanceHeadline from '@/components/Home/NewHome/WalletBalanceHeadline';
@@ -195,6 +198,18 @@ export default function HomeScreenNew() {
userHasCard,
cardHoldsOwnBalance,
});
+ // Whether the action row offers Swap and Send at all. Deliberately NOT
+ // `depositCompleted` on its own: that only knows about wallet funding, and a
+ // cardholder who funds their card directly has none of it — see
+ // `holdsFundsAnywhere`.
+ const hasFunds = holdsFundsAnywhere({
+ depositCompleted,
+ walletBalance,
+ cardBalance,
+ savingsBalance,
+ userHasCard,
+ cardHoldsOwnBalance,
+ });
const walletTitle = isBalanceSectionLoading ? null : formatBalanceUSD(totalBalance);
const showAssets = isLoadingTokens || hasTokens || !!tokenError;
// Which rung of the card funnel belongs under the card, if any — null once the
@@ -253,7 +268,7 @@ export default function HomeScreenNew() {
-
+
)}
diff --git a/components/Home/NewHome/OtherBalancesDropdown/balanceTotals.ts b/components/Home/NewHome/OtherBalancesDropdown/balanceTotals.ts
index 1e50f7b0..a3fb59e5 100644
--- a/components/Home/NewHome/OtherBalancesDropdown/balanceTotals.ts
+++ b/components/Home/NewHome/OtherBalancesDropdown/balanceTotals.ts
@@ -108,3 +108,33 @@ export const getTotalBalance = ({
(walletBalance || 0) +
(shouldShowCard({ cardBalance, userHasCard, cardHoldsOwnBalance }) ? cardBalance || 0 : 0) +
(savingsBalance || 0);
+
+/**
+ * Whether the home action row shows Swap and Send beside "Add Funds".
+ *
+ * It used to ask only whether the *wallet* had ever been funded — a deposit on
+ * record, a token balance, or a vault balance. A cardholder who funds their card
+ * directly (the card's own deposit address, which never touches their Safe)
+ * satisfies none of those, so a user holding money on their card opened the app
+ * to a single "Add Funds" button and two features that had silently disappeared.
+ * That is the support report this predicate exists to answer.
+ *
+ * So the question is "does this person hold anything with us", across every pot
+ * the breakdown shows. `depositCompleted` stays in front of it because it answers
+ * the same question from history rather than from balances, and survives a
+ * balance query that is erroring or briefly empty.
+ *
+ * Swap and Send work off wallet tokens, so a card-only balance opens them on an
+ * empty asset list — which is why that list says so and points at Add Funds.
+ * A named action doing little is still a better answer than a home screen that
+ * quietly drops it: the user in the report could see their balance and had no way
+ * to tell what had happened to the buttons.
+ */
+export const holdsFundsAnywhere = ({
+ depositCompleted,
+ ...balances
+}: Omit &
+ Partial> & {
+ /** Wallet funding proven by history: a deposit, a token, a vault balance. */
+ depositCompleted: boolean;
+ }): boolean => depositCompleted || getTotalBalance(balances) > 0;
diff --git a/components/Home/NewHome/OtherBalancesDropdown/index.tsx b/components/Home/NewHome/OtherBalancesDropdown/index.tsx
index b2c7677d..fa7261a9 100644
--- a/components/Home/NewHome/OtherBalancesDropdown/index.tsx
+++ b/components/Home/NewHome/OtherBalancesDropdown/index.tsx
@@ -20,13 +20,20 @@ import {
bankBalancesToShow,
type CardBalanceDisplay,
getTotalBalance,
+ holdsFundsAnywhere,
type OtherBalances,
shouldShowCard,
shouldShowSpendable,
} from './balanceTotals';
import OtherBalancesPie from './OtherBalancesPie';
-export { bankBalancesToShow, getTotalBalance, shouldShowCard, shouldShowSpendable };
+export {
+ bankBalancesToShow,
+ getTotalBalance,
+ holdsFundsAnywhere,
+ shouldShowCard,
+ shouldShowSpendable,
+};
export type { BankBalance, CardBalanceDisplay, OtherBalances };
const WALLET_COLOR = '#FFFFFF';
diff --git a/components/Home/NewHome/__tests__/homeBalanceTotals.test.ts b/components/Home/NewHome/__tests__/homeBalanceTotals.test.ts
index 35944f97..a36d8f18 100644
--- a/components/Home/NewHome/__tests__/homeBalanceTotals.test.ts
+++ b/components/Home/NewHome/__tests__/homeBalanceTotals.test.ts
@@ -1,6 +1,7 @@
import {
bankBalancesToShow,
getTotalBalance,
+ holdsFundsAnywhere,
shouldShowCard,
shouldShowSpendable,
} from '@/components/Home/NewHome/OtherBalancesDropdown/balanceTotals';
@@ -51,6 +52,59 @@ describe('getTotalBalance', () => {
});
});
+/**
+ * Reported by a cardholder: "Swap and Send are no longer showing". They held
+ * $4.87 on a Rain card and nothing in their wallet, because every deposit they
+ * had ever made went to the card's own deposit address — which never touches
+ * their Safe. The action row asked whether the wallet had been funded, got "no",
+ * and collapsed to a lone "Add Funds" button on a screen that was, right above it,
+ * showing them a balance.
+ */
+describe('holdsFundsAnywhere', () => {
+ const empty = {
+ walletBalance: 0,
+ cardBalance: 0,
+ savingsBalance: 0,
+ userHasCard: false,
+ depositCompleted: false,
+ };
+
+ it('offers Swap and Send to a cardholder whose only money is on the card', () => {
+ expect(holdsFundsAnywhere({ ...empty, cardBalance: 4.87, userHasCard: true })).toBe(true);
+ });
+
+ it('offers them on a savings balance alone', () => {
+ expect(holdsFundsAnywhere({ ...empty, savingsBalance: 12.5 })).toBe(true);
+ });
+
+ it('offers them on a wallet balance alone', () => {
+ expect(holdsFundsAnywhere({ ...empty, walletBalance: 2.03 })).toBe(true);
+ });
+
+ it('keeps offering them to a wallet that was funded and then emptied', () => {
+ // `depositCompleted` is the historical answer, and it also covers a balance
+ // query that is erroring or has not landed yet.
+ expect(holdsFundsAnywhere({ ...empty, depositCompleted: true })).toBe(true);
+ });
+
+ it('hides them only when the user holds nothing at all', () => {
+ expect(holdsFundsAnywhere(empty)).toBe(false);
+ });
+
+ it('is not fooled by a Wirex card reporting spending power as its balance', () => {
+ // That figure is the wallet and savings the card can reach, seen from the
+ // card's side. With both at zero there is nothing to swap or send.
+ expect(
+ holdsFundsAnywhere({
+ ...empty,
+ cardBalance: 9.24,
+ userHasCard: true,
+ cardHoldsOwnBalance: false,
+ }),
+ ).toBe(false);
+ });
+});
+
/**
* Card and Spendable stand in the same slot and answer the same question for two
* kinds of card. Both at once would show a $0 Card row beside a funded Spendable
diff --git a/components/Send/TokenSelector.tsx b/components/Send/TokenSelector.tsx
index a9488153..f56011c3 100644
--- a/components/Send/TokenSelector.tsx
+++ b/components/Send/TokenSelector.tsx
@@ -32,6 +32,7 @@ const TokenSelector: React.FC = () => {
baseTokens,
arbitrumTokens,
bscTokens,
+ isLoading,
} = useWalletTokens();
// Combine and sort tokens by USD value (descending)
@@ -70,6 +71,19 @@ const TokenSelector: React.FC = () => {
Select an asset
+ {/* An empty wallet is a normal state here — a cardholder who funds their
+ card directly holds a balance with us and nothing in their wallet —
+ and an unexplained blank list under "Select an asset" reads as a
+ screen that failed to load. */}
+ {!isLoading && allTokens.length === 0 ? (
+
+ No assets in your wallet yet
+
+ Money held on your card or in savings has to reach your wallet before it can be
+ sent. Use Add Funds to top your wallet up.
+
+
+ ) : null}
{allTokens.map(token => {
const balance = Number(
diff --git a/lib/utils/__tests__/cardCollateralAssets.test.ts b/lib/utils/__tests__/cardCollateralAssets.test.ts
new file mode 100644
index 00000000..0d6791d6
--- /dev/null
+++ b/lib/utils/__tests__/cardCollateralAssets.test.ts
@@ -0,0 +1,101 @@
+import { CardCollateralTokenBalanceDto } from '@/lib/types';
+import { isDifferentCollateralAsset, withdrawableAssetOptions } from '@/lib/utils/cardHelpers';
+
+/**
+ * The asset picker on "Withdraw from card", as a support recording found it.
+ *
+ * The cardholder's Rain contracts hold $30.04 of USDC and $5.01 of USDT, and the
+ * backend lists every token those contracts support — so the picker offered ten
+ * rows, eight of them $0, including a second "USDC" and a second "USDT" at $0
+ * beside the funded pair. Picking a $0 row can only produce "No USDC is available
+ * to withdraw right now", and picking the funded one wiped the amount the user had
+ * just set with Max.
+ */
+
+const asset = (
+ overrides: Partial & Pick,
+): CardCollateralTokenBalanceDto => ({
+ rainCollateralContractId: 'c1',
+ chainId: 8453,
+ collateralProxy: '0xproxy',
+ tokenAddress: `0x${overrides.symbol}${overrides.chainId ?? 8453}`,
+ decimals: 6,
+ rawBalance: '0',
+ balanceUsd: 0,
+ ...overrides,
+});
+
+const USDC_FUNDED = asset({ symbol: 'USDC', tokenAddress: '0xusdc', balanceUsd: 30.04 });
+const USDT_FUNDED = asset({ symbol: 'USDT', tokenAddress: '0xusdt', balanceUsd: 5.01 });
+const EMPTY_ASSETS = [
+ asset({ symbol: 'USDC', tokenAddress: '0xusdc2', chainId: 42161 }),
+ asset({ symbol: 'USDT', tokenAddress: '0xusdt2', chainId: 42161 }),
+ asset({ symbol: 'DAI', tokenAddress: '0xdai' }),
+ asset({ symbol: 'rUSD', tokenAddress: '0xrusd' }),
+];
+
+describe('withdrawableAssetOptions', () => {
+ it('offers only the assets the card actually holds', () => {
+ const options = withdrawableAssetOptions([USDC_FUNDED, USDT_FUNDED, ...EMPTY_ASSETS]);
+
+ expect(options).toEqual([USDC_FUNDED, USDT_FUNDED]);
+ });
+
+ it('keeps the backend order, so the richest asset stays first', () => {
+ const options = withdrawableAssetOptions([USDT_FUNDED, USDC_FUNDED]);
+
+ expect(options.map(option => option.symbol)).toEqual(['USDT', 'USDC']);
+ });
+
+ it('drops an asset whose balance could not be read', () => {
+ // `balanceUsd` is 0 for those and does not mean empty, so quoting it as a
+ // withdrawable figure would be a number we do not have.
+ const unreadable = asset({
+ symbol: 'USDC',
+ tokenAddress: '0xunreadable',
+ unavailableReason: 'RPC error',
+ });
+
+ expect(withdrawableAssetOptions([USDC_FUNDED, unreadable])).toEqual([USDC_FUNDED]);
+ });
+
+ it('keeps the selected asset even once it is empty', () => {
+ // Otherwise the list cannot show what the trigger says is selected.
+ const drained = asset({ symbol: 'USDT', tokenAddress: '0xusdt' });
+
+ expect(withdrawableAssetOptions([USDC_FUNDED, drained], '0xUSDT')).toEqual([
+ USDC_FUNDED,
+ drained,
+ ]);
+ });
+
+ it('falls back to every readable asset when the card holds nothing', () => {
+ // An empty picker offers no way out of itself, and the user still has to see
+ // what the card supports.
+ expect(withdrawableAssetOptions(EMPTY_ASSETS)).toEqual(EMPTY_ASSETS);
+ });
+
+ it('answers with an empty list for a card with no collateral response', () => {
+ expect(withdrawableAssetOptions(undefined)).toEqual([]);
+ });
+});
+
+describe('isDifferentCollateralAsset', () => {
+ it('is false when the user re-picks the asset already selected', () => {
+ // The row they tap is the one the trigger names. Treating that as a change
+ // cleared the amount they had just set with Max.
+ expect(isDifferentCollateralAsset(USDC_FUNDED, '0xusdc')).toBe(false);
+ });
+
+ it('ignores address casing, which differs between our sources', () => {
+ expect(isDifferentCollateralAsset(USDC_FUNDED, '0xUSDC')).toBe(false);
+ });
+
+ it('is true when the pick moves to another asset', () => {
+ expect(isDifferentCollateralAsset(USDT_FUNDED, '0xusdc')).toBe(true);
+ });
+
+ it('is true when nothing is selected yet', () => {
+ expect(isDifferentCollateralAsset(USDC_FUNDED, undefined)).toBe(true);
+ });
+});
diff --git a/lib/utils/cardHelpers.ts b/lib/utils/cardHelpers.ts
index 0a7a4f32..8d5bb0b1 100644
--- a/lib/utils/cardHelpers.ts
+++ b/lib/utils/cardHelpers.ts
@@ -348,6 +348,53 @@ export const toAmountInputValue = (amount: number): string => {
export const assetLabel = (asset: CardCollateralTokenBalanceDto): string =>
asset.symbol || `${asset.tokenAddress.slice(0, 6)}…${asset.tokenAddress.slice(-4)}`;
+/**
+ * The collateral assets the withdraw screen offers, richest first (the backend
+ * already sorts them that way).
+ *
+ * Two things are dropped, for two different reasons:
+ *
+ * - An asset whose balance could not be read. `availableUsd` is 0 for those and
+ * it does not mean "empty", so offering one can only quote a figure we do not
+ * have.
+ * - An asset that holds nothing. A withdrawal moves one named token, so picking
+ * an empty one has exactly one outcome: "No X is available to withdraw right
+ * now". The backend lists every token every Rain contract supports — one
+ * cardholder saw ten rows, eight of them $0 and two of them duplicate symbols
+ * of the funded pair — and burying the assets they could actually withdraw in
+ * that list is what made the screen look broken.
+ *
+ * The selected asset is always kept, even at $0: the list has to be able to show
+ * what the trigger says is selected. And when nothing is funded at all, the whole
+ * readable list comes back rather than an empty one — the user still needs to see
+ * what the card holds, and an empty picker offers no way out of it.
+ */
+export const withdrawableAssetOptions = (
+ assets: CardCollateralTokenBalanceDto[] | undefined,
+ selectedTokenAddress?: string,
+): CardCollateralTokenBalanceDto[] => {
+ const readable = (assets ?? []).filter(asset => !asset.unavailableReason);
+ const selected = selectedTokenAddress?.toLowerCase();
+ const funded = readable.filter(
+ asset => asset.balanceUsd > 0 || asset.tokenAddress.toLowerCase() === selected,
+ );
+ return funded.length ? funded : readable;
+};
+
+/**
+ * Whether picking `asset` replaces the asset a withdrawal would currently draw
+ * from — i.e. whether the amount already typed still belongs to the same cap.
+ *
+ * Re-picking the asset that is already selected is a no-op the user can perform
+ * at any time (the row they tap is the one the trigger names), so treating every
+ * pick as a change is how "$4.87, Max" became an empty amount field the moment
+ * the picker closed.
+ */
+export const isDifferentCollateralAsset = (
+ asset: CardCollateralTokenBalanceDto,
+ selectedTokenAddress?: string,
+): boolean => asset.tokenAddress.toLowerCase() !== selectedTokenAddress?.toLowerCase();
+
/**
* Block explorer for the chain a card transaction settled on.
*