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. *