From 006ba9416baf06e900d237dc9bdf52992ed8f987 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 13:25:16 +0000 Subject: [PATCH 01/16] fix(home,card): keep Swap/Send for card-funded users, unblock card withdraw MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two symptoms from one support report (Solid Operations 13039057237): the Swap and Send buttons had disappeared from home, and withdrawing from the card did not work. The reporter holds $4.87 on a Rain card and $0 in their wallet — every deposit they have ever made went to the card's own deposit address, which never touches their Safe. Home: the action row asked `depositCompleted`, which only ever knows about wallet funding (a deposit on record, a token balance, a vault balance). A cardholder who funds their card directly satisfies none of it, so the row collapsed to a lone "Add Funds" on a screen that was showing them a balance right above it. It now asks whether the user holds anything in any of the pots the breakdown shows, with `depositCompleted` still in front so an erroring or not-yet-settled balance query cannot hide the buttons either. Swap and Send work off wallet tokens, so a card-only balance opens Send on an empty asset list — which now says why, and points at Add Funds, instead of rendering a blank list under "Select an asset". Card withdraw: the asset picker was a portalled dropdown menu on web. Inside the withdraw sheet on a phone browser it covered the "Withdraw" button and swallowed taps on its own rows — in the recording the user spent nine seconds tapping the asset they wanted and gave up with the sheet still open. Both platforms now render the in-flow list native already had, from one shared file. Two more things the same screen got wrong: - Every token the user's Rain contracts support was offered, so the two assets they could actually withdraw sat behind eight $0 rows, including a duplicate "USDC" and "USDT". Only funded assets are listed now (plus the selected one, and the full list when nothing is funded), and a symbol held on two chains names its chain. - Picking the asset already selected cleared the amount, so "press Max, then confirm the destination" ended with an empty field. Only an actual change of asset clears it now. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FxQs7MKViUX9AL5bzUzj9o --- components/Card/CardWithdrawForm.tsx | 16 +- .../Card/ToDestinationSelector.native.tsx | 96 ++--------- .../Card/ToDestinationSelector.shared.tsx | 128 +++++++++++++++ components/Card/ToDestinationSelector.tsx | 2 +- components/Card/ToDestinationSelector.web.tsx | 91 ++--------- .../__tests__/ToDestinationSelector.test.tsx | 154 ++++++++++++++++++ components/Home/NewHome/HomeScreenNew.tsx | 19 ++- .../OtherBalancesDropdown/balanceTotals.ts | 30 ++++ .../NewHome/OtherBalancesDropdown/index.tsx | 9 +- .../__tests__/homeBalanceTotals.test.ts | 54 ++++++ components/Send/TokenSelector.tsx | 14 ++ .../__tests__/cardCollateralAssets.test.ts | 101 ++++++++++++ lib/utils/cardHelpers.ts | 47 ++++++ 13 files changed, 592 insertions(+), 169 deletions(-) create mode 100644 components/Card/ToDestinationSelector.shared.tsx create mode 100644 components/Card/__tests__/ToDestinationSelector.test.tsx create mode 100644 lib/utils/__tests__/cardCollateralAssets.test.ts diff --git a/components/Card/CardWithdrawForm.tsx b/components/Card/CardWithdrawForm.tsx index 757796f14..25b1dac32 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 c47d3517c..bd4288f84 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 000000000..ec3c7d17a --- /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 baa59ce9b..b625fa630 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 86a169297..330613421 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 000000000..bbd5a6565 --- /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 3d3d6bf2d..e8f3f305a 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 1e50f7b07..a3fb59e50 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 b2c7677d2..fa7261a9b 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 35944f97d..a36d8f186 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 a94881534..f56011c3c 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 000000000..0d6791d68 --- /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 0a7a4f329..8d5bb0b15 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. * From 231b515e90c576ff5f7e9295c5c4e7a0d06a8078 Mon Sep 17 00:00:00 2001 From: "sentry[bot]" <39604003+sentry[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 10:37:42 +0000 Subject: [PATCH 02/16] fix(withdraw): prevent user cancellations from being reported as Sentry errors --- hooks/__tests__/useWithdraw.test.tsx | 134 +++++++++++++++++++++++++++ hooks/useWithdraw.ts | 36 +++---- 2 files changed, 153 insertions(+), 17 deletions(-) create mode 100644 hooks/__tests__/useWithdraw.test.tsx diff --git a/hooks/__tests__/useWithdraw.test.tsx b/hooks/__tests__/useWithdraw.test.tsx new file mode 100644 index 000000000..0914760f5 --- /dev/null +++ b/hooks/__tests__/useWithdraw.test.tsx @@ -0,0 +1,134 @@ +import React, { useEffect } from 'react'; +import * as Sentry from '@sentry/react-native'; + +import useWithdraw from '@/hooks/useWithdraw'; +import { executeTransactions, USER_CANCELLED_TRANSACTION } from '@/lib/execute'; +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { act, create } = require('react-test-renderer'); + +jest.mock('@sentry/react-native', () => ({ + captureException: jest.fn(), +})); +jest.mock('@tanstack/react-query', () => ({ + useQueryClient: () => ({ invalidateQueries: jest.fn() }), +})); +jest.mock('viem', () => ({ + erc20Abi: [], + maxUint256: BigInt('0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff'), + parseUnits: (_val: string, _dec: number) => BigInt(1_000_000), +})); +jest.mock('viem/utils', () => ({ + encodeFunctionData: () => '0xdata', + parseUnits: (_val: string, _dec: number) => BigInt(1_000_000), +})); +jest.mock('viem/chains', () => ({ mainnet: { id: 1 } })); +jest.mock('wagmi', () => ({ + useReadContract: () => ({ + data: BigInt(0), + isLoading: false, + refetch: jest.fn().mockResolvedValue({ data: BigInt(999_999_999) }), + }), +})); +jest.mock('@/lib/execute', () => ({ + executeTransactions: jest.fn(), + USER_CANCELLED_TRANSACTION: Symbol('cancel'), +})); +jest.mock('@/hooks/useActivityActions', () => ({ + useActivityActions: () => ({ + trackTransaction: (_params: unknown, execute: (onHash: () => void) => Promise) => + execute(jest.fn()), + }), +})); +jest.mock('@/hooks/useUser', () => ({ + __esModule: true, + default: () => ({ + user: { + userId: 'user-1', + safeAddress: '0xsafe', + suborgId: 'org-1', + signWith: 'passkey', + }, + safeAA: jest.fn().mockResolvedValue({}), + }), +})); +jest.mock('@/hooks/useVault', () => ({ VAULT: 'vault' })); +jest.mock('@/lib/analytics', () => ({ track: jest.fn() })); +jest.mock('@/lib/config', () => ({ + ADDRESSES: { + ethereum: { + vault: '0xvault', + boringQueue: '0xqueue', + usdc: '0xusdc', + }, + }, +})); +jest.mock('@/lib/abis/BoringQueue', () => []); +jest.mock('@/constants/tracking-events', () => ({ + TRACKING_EVENTS: { + WITHDRAW_TRANSACTION_INITIATED: 'withdraw_initiated', + WITHDRAW_TRANSACTION_COMPLETED: 'withdraw_completed', + WITHDRAW_TRANSACTION_ERROR: 'withdraw_error', + }, +})); +jest.mock('@/constants/withdraw', () => ({ WITHDRAW_SECONDS_TO_DEADLINE: 3600 })); + +const execute = executeTransactions as jest.Mock; +const captureException = Sentry.captureException as jest.Mock; + +const receipt = { transactionHash: '0xhash' }; + +let hook: ReturnType; +let root: ReturnType; + +function Harness() { + const result = useWithdraw(); + useEffect(() => { + hook = result; + }); + return null; +} + +beforeEach(() => { + (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + jest.clearAllMocks(); + execute.mockResolvedValue({ transaction: receipt }); +}); +afterEach(() => act(() => root?.unmount())); + +function mount() { + return act(() => { + root = create(); + }); +} + +it('does not report to Sentry when the user cancels the transaction', async () => { + execute.mockResolvedValueOnce({ transaction: USER_CANCELLED_TRANSACTION }); + mount(); + await act(async () => { + await expect(hook.withdraw('100')).rejects.toThrow('User cancelled transaction'); + }); + expect(captureException).not.toHaveBeenCalled(); +}); + +it('reports to Sentry when a real error occurs', async () => { + execute.mockRejectedValueOnce(new Error('Network failure')); + mount(); + await act(async () => { + await expect(hook.withdraw('100')).rejects.toThrow('Network failure'); + }); + expect(captureException).toHaveBeenCalledTimes(1); + expect(captureException).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Network failure' }), + expect.objectContaining({ tags: expect.objectContaining({ type: 'withdraw_error' }) }), + ); +}); + +it('reports to Sentry when executeTransactions returns USER_CANCELLED_TRANSACTION as the raw result', async () => { + // Direct return (not wrapped in { transaction: ... }) also works + execute.mockResolvedValueOnce(USER_CANCELLED_TRANSACTION); + mount(); + await act(async () => { + await expect(hook.withdraw('100')).rejects.toThrow('User cancelled transaction'); + }); + expect(captureException).not.toHaveBeenCalled(); +}); diff --git a/hooks/useWithdraw.ts b/hooks/useWithdraw.ts index a27d9f524..3696aecae 100644 --- a/hooks/useWithdraw.ts +++ b/hooks/useWithdraw.ts @@ -194,23 +194,25 @@ const useWithdraw = (): WithdrawResult => { source: 'withdraw_hook', }); - Sentry.captureException(error, { - tags: { - type: 'withdraw_error', - userId: user?.userId, - revert_reason: revertReason, - }, - extra: { - amount, - allowance: currentAllowance?.toString(), - needsApproval, - userAddress: user?.safeAddress, - }, - user: { - id: user?.userId, - address: user?.safeAddress, - }, - }); + if (!isUserCancelledError(error)) { + Sentry.captureException(error, { + tags: { + type: 'withdraw_error', + userId: user?.userId, + revert_reason: revertReason, + }, + extra: { + amount, + allowance: currentAllowance?.toString(), + needsApproval, + userAddress: user?.safeAddress, + }, + user: { + id: user?.userId, + address: user?.safeAddress, + }, + }); + } setWithdrawStatus(Status.ERROR); setError(describeWithdrawError(error)); throw error; From 1567010644974021e5ae2dd6727867c26ed2339a Mon Sep 17 00:00:00 2001 From: Mayank Mittal Date: Mon, 21 Sep 2026 19:54:34 +0530 Subject: [PATCH 03/16] fix: retryable issuer decline code --- app/(protected)/(tabs)/card/activate.tsx | 2 + .../Card/ActivateCard/CardStatusBanner.tsx | 44 +++++++++++++--- constants/tracking-events.ts | 4 ++ hooks/useActivateCard.ts | 19 ++++++- hooks/useCardSteps/useCardSteps.ts | 25 ++++++--- lib/utils/cardActivationRetry.ts | 52 +++++++++++++++++++ lib/utils/cardReviewState.ts | 17 +++--- 7 files changed, 142 insertions(+), 21 deletions(-) create mode 100644 lib/utils/cardActivationRetry.ts diff --git a/app/(protected)/(tabs)/card/activate.tsx b/app/(protected)/(tabs)/card/activate.tsx index a36c803e0..cb44c98bc 100644 --- a/app/(protected)/(tabs)/card/activate.tsx +++ b/app/(protected)/(tabs)/card/activate.tsx @@ -21,6 +21,7 @@ export default function ActivateMobile() { isUnderReview, activationBlockedReason, activationFailure, + retryActivation, steps, activeStepId, isStepButtonEnabled, @@ -56,6 +57,7 @@ export default function ActivateMobile() { isBlocked={isCardBlocked} blockedReason={activationBlockedReason} failure={activationFailure} + onRetry={retryActivation} /> void; } /** - * A blocker the user is expected to fix themselves rather than escalate. + * A blocker the user is expected to wait out rather than escalate. * - * Everything else — an unsupported country, an issuer decline, a name the - * issuer cannot read — is ours to resolve, so the support action is the point - * of the card rather than a footnote. + * Only these two suppress the support action outright. Everything else offers + * support, and a retryable failure offers a retry alongside it — an issuer + * decline is ours to chase, but it is also the one the user can simply try + * again once the issuer is back. */ const SELF_SERVICE_CODES = new Set(['ACTIVATION_PENDING', 'TEMPORARY_FAILURE']); @@ -40,7 +50,7 @@ const FALLBACK_DETAIL_BY_CODE: Record = { MISSING_VERIFIED_NAME: 'This has to be corrected on our side — retrying will not fix it.', INVALID_PROFILE_DATA: 'This has to be corrected on our side — retrying will not fix it.', ISSUER_DECLINED: - 'This is a problem at our card issuer, not with your account or your verification. We are already tracking it.', + 'This was a problem at our card issuer, not with your account or your verification. Please try again — if it keeps failing, contact support.', ACTIVATION_PENDING: 'This page updates itself — there is no need to keep retrying.', TEMPORARY_FAILURE: 'Please try again in a few minutes.', }; @@ -50,6 +60,7 @@ export function CardStatusBanner({ isBlocked, blockedReason, failure, + onRetry, }: CardStatusBannerProps) { // A recorded failure outranks the pending state: a card that is "on its way" // and an issuance that just failed are the same screen, and the failure is @@ -57,6 +68,13 @@ export function CardStatusBanner({ if (failure) { const selfService = SELF_SERVICE_CODES.has(failure.code); const detail = failure.detail || FALLBACK_DETAIL_BY_CODE[failure.code]; + // ACTIVATION_PENDING is the one retryable code with nothing to press: the + // card account is mid-provisioning and the page polls itself, so a button + // would only invite the retry loop the copy is talking the user out of. + const canRetry = + Boolean(onRetry) && + failure.code !== 'ACTIVATION_PENDING' && + isActivationFailureRetryable(failure); return ( {detail}} + {canRetry && ( + + )} + + {/* Support stays available next to a retry, not instead of it: an issuer + decline is still ours to chase if trying again does not clear it. */} {!selfService && ( openSupportDrawer()} - className="mt-4 self-start rounded-lg bg-white/10 px-4 py-2" + className={`self-start rounded-lg bg-white/10 px-4 py-2 ${canRetry ? 'mt-3' : 'mt-4'}`} > Contact support diff --git a/constants/tracking-events.ts b/constants/tracking-events.ts index 0f0db9298..7b0c644a4 100644 --- a/constants/tracking-events.ts +++ b/constants/tracking-events.ts @@ -282,6 +282,10 @@ export const TRACKING_EVENTS = { REGION_UNAVAILABLE_CONTINUE_PRESSED: 'region_unavailable_continue_pressed', REGION_UNAVAILABLE_CHANGE_COUNTRY_PRESSED: 'region_unavailable_change_country_pressed', CARD_ACTIVATE_PAGE_VIEWED: 'card_activate_page_viewed', + // Pressed from the failure banner on /card/activate. Carries the failure + // code, so a retry wave after an issuer outage is separable from a user + // pressing again at a failure that will not clear. + CARD_ACTIVATION_RETRIED: 'card_activation_retried', USER_KYC_INFO_PAGE_VIEWED: 'user_kyc_info_page_viewed', USER_KYC_INFO_FORM_STARTED: 'user_kyc_info_form_started', KYC_STEP_STARTED: 'kyc_step_started', diff --git a/hooks/useActivateCard.ts b/hooks/useActivateCard.ts index 28086612d..9a48f3740 100644 --- a/hooks/useActivateCard.ts +++ b/hooks/useActivateCard.ts @@ -1,4 +1,4 @@ -import { useEffect } from 'react'; +import { useCallback, useEffect } from 'react'; import { useLocalSearchParams, useRouter } from 'expo-router'; import { path } from '@/constants/path'; @@ -76,6 +76,7 @@ export function useActivateCard() { canToggleStep, activatingCard, cardsEndorsement, + pushCardReady, } = useCardSteps(_kycStatus as KycStatus | undefined, cardStatusResponse); // Derived: under review state — the screen shows "your card is on its way" @@ -116,6 +117,21 @@ export function useActivateCard() { } }, [userHasCard, cardStatus, router]); + // Retry from the failure banner. Deliberately the SAME destination as the + // "Activate card" step — /card/ready, where the consents are accepted and + // the card is created — rather than a direct create call: a second path to + // issuance is a second path to get out of step with the consents, and the + // failures this retries are upstream faults, not anything about the consents + // already given. + const retryActivation = useCallback(() => { + track(TRACKING_EVENTS.CARD_ACTIVATION_RETRIED, { + failure_code: activationFailure?.code, + failure_terminal: activationFailure?.terminal, + card_status: cardStatus, + }); + pushCardReady(); + }, [activationFailure?.code, activationFailure?.terminal, cardStatus, pushCardReady]); + // Navigation handler for back button const handleGoBack = () => { if (router.canGoBack()) { @@ -137,6 +153,7 @@ export function useActivateCard() { isUnderReview, activationBlockedReason, activationFailure, + retryActivation, // Step management steps, activeStepId, diff --git a/hooks/useCardSteps/useCardSteps.ts b/hooks/useCardSteps/useCardSteps.ts index e3dd94405..767d4ed84 100644 --- a/hooks/useCardSteps/useCardSteps.ts +++ b/hooks/useCardSteps/useCardSteps.ts @@ -18,6 +18,7 @@ import { resolveKycProvider } from '@/lib/kycProviderRouting'; import { redirectToRainVerification } from '@/lib/rainVerification'; import { CardProvider, CardStatusResponse, KycProvider, KycStatus } from '@/lib/types'; import { hasMetSavingsDeposit, requiresCardDeposit, withRefreshToken } from '@/lib/utils'; +import { blocksCardActivation } from '@/lib/utils/cardActivationRetry'; import { useCountryStore } from '@/store/useCountryStore'; import { useDepositStore } from '@/store/useDepositStore'; import { useKycStore } from '@/store/useKycStore'; @@ -390,13 +391,18 @@ export function useCardSteps( cardsEndorsement, customer?.rejection_reasons, cardActivated, - // A terminal issuance failure gates the activate step exactly like the - // sticky block does: pressing "Activate card" on an unsupported country - // or an issuer decline can only reproduce the same failure, and that - // retry loop is what the support tickets are made of. Non-terminal - // failures (an issuer blip, provisioning still running) leave the button - // alone on purpose — there, retrying is the right move. - cardStatusResponse?.activationBlocked || cardStatusResponse?.activationFailure?.terminal, + // A failure nothing on this screen can clear gates the activate step + // exactly like the sticky block does: pressing "Activate card" on an + // unsupported document country or an unreadable name can only reproduce + // the same failure, and that retry loop is what the support tickets are + // made of. Retryable failures — an issuer outage, a blip, provisioning + // still running — leave the button alone on purpose: there, retrying is + // the whole point, and disabling it is how the September 2026 i2C + // outage kept stranding applicants after the issuer had already fixed + // it. See `blocksCardActivation` for why the code is consulted and not + // just the `terminal` flag. + cardStatusResponse?.activationBlocked || + blocksCardActivation(cardStatusResponse?.activationFailure), handleProceedToKyc, pushCardReady, pushCardDetails, @@ -421,7 +427,7 @@ export function useCardSteps( customer?.rejection_reasons, cardActivated, cardStatusResponse?.activationBlocked, - cardStatusResponse?.activationFailure?.terminal, + cardStatusResponse?.activationFailure, cardStatusResponse?.rainApplicationStatus, cardStatusResponse?.kycStatus, cardStatusResponse?.kycWarnings, @@ -452,5 +458,8 @@ export function useCardSteps( canToggleStep, activatingCard, cardsEndorsement, + // Exposed so the failure banner can offer the same action the activate + // step does, rather than owning a second, divergent route to issuance. + pushCardReady, }; } diff --git a/lib/utils/cardActivationRetry.ts b/lib/utils/cardActivationRetry.ts new file mode 100644 index 000000000..5041a93df --- /dev/null +++ b/lib/utils/cardActivationRetry.ts @@ -0,0 +1,52 @@ +import type { CardActivationFailure } from '@/lib/types'; + +/** + * Failure codes the user may always retry, whatever `terminal` says. + * + * These name faults that sit outside the applicant and end on their own — an + * issuer outage, a provisioning window, a blip upstream. The backend now + * classifies them non-terminal and rewrites the flag on read, so in the normal + * case `terminal` already answers this. The set is kept here as well because + * the September 2026 i2C outage showed what the alternative costs: rows written + * while `ISSUER_DECLINED` was terminal disabled the only button on the screen, + * and the flag is cleared solely by an issuance succeeding — which the disabled + * button made impossible. A client that can reach the same conclusion on its + * own cannot be stranded by a stale row or by the two deploys landing out of + * order. + * + * Everything absent from this set defers entirely to `terminal`: an unsupported + * document country or a name the issuer cannot read is not retried away. + */ +export const RETRYABLE_ACTIVATION_FAILURE_CODES: ReadonlySet = new Set([ + 'ISSUER_DECLINED', + 'ACTIVATION_PENDING', + 'TEMPORARY_FAILURE', +]); + +/** + * Whether the card screen should offer this failure a retry. + * + * True for anything non-terminal, and for the codes above even when the stored + * row still calls them terminal. + */ +export function isActivationFailureRetryable( + failure: CardActivationFailure | null | undefined, +): boolean { + if (!failure) return false; + if (RETRYABLE_ACTIVATION_FAILURE_CODES.has(failure.code)) return true; + return !failure.terminal; +} + +/** + * Whether a failure should gate the activate step and hide the "on its way" + * screen — i.e. the inverse of the above, for a failure that exists at all. + * + * Kept as its own function rather than negating at each call site: the two + * call sites (`useCardSteps`, `isCardIssuanceUnderReview`) previously read + * `activationFailure?.terminal` directly, and that is exactly the expression + * that has to stop being duplicated. + */ +export function blocksCardActivation(failure: CardActivationFailure | null | undefined): boolean { + if (!failure) return false; + return !isActivationFailureRetryable(failure); +} diff --git a/lib/utils/cardReviewState.ts b/lib/utils/cardReviewState.ts index f29dcecba..3baf841a8 100644 --- a/lib/utils/cardReviewState.ts +++ b/lib/utils/cardReviewState.ts @@ -1,5 +1,6 @@ import { EndorsementStatus } from '@/components/BankTransfer/enums'; import { BridgeCustomerEndorsement, CardStatusResponse } from '@/lib/types'; +import { blocksCardActivation } from '@/lib/utils/cardActivationRetry'; import { isKycAwaitingDecision } from '@/lib/utils/kyc/verificationProgress'; export interface CardIssuanceReviewInput { @@ -54,12 +55,16 @@ export function isCardIssuanceUnderReview({ if (cardStatus?.activationBlocked) return false; // An issuance attempt that failed for a reason nothing on this screen can - // clear — an unsupported document country, an issuer decline. Same argument - // as the blocked case above, and it needs saying separately because most of - // these never set `activationBlocked`: that flag is sticky and is only ever - // raised by hand, so a terminal failure would otherwise be hidden behind - // "your card is on its way" indefinitely. - if (cardStatus?.activationFailure?.terminal) return false; + // clear — an unsupported document country, a name the issuer cannot read. + // Same argument as the blocked case above, and it needs saying separately + // because most of these never set `activationBlocked`: that flag is sticky + // and is only ever raised by hand, so the failure would otherwise be hidden + // behind "your card is on its way" indefinitely. + // + // A RETRYABLE failure is deliberately not in scope here — the steps list is + // where its retry lives, so hiding the list behind "on its way" would take + // the button away again. + if (blocksCardActivation(cardStatus?.activationFailure)) return false; // Verification passed but the application is parked because the deposit is no // longer held. The "top up and hold" step is the user's move, and it lives on From ead48c5534c771c23ec6a62676264acd93bb628b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 08:06:51 +0000 Subject: [PATCH 04/16] fix(onboarding): stop sending a failed passkey login to "Create account" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A login whose passkey the backend cannot tie to an account was answered by replacing the screen with signup. The user had just passed Face ID, so what they saw was their account being offered back to them as a new one — and signup was a dead end anyway, refusing their own address as already registered. Reported from Italy with a screen recording: three authentications, then Create your account. The 404 does not mean "new user". It means the passkey the authenticator just signed with reaches no account: unknown to Turnkey, or belonging to a sub-organization whose account row a failed signup never wrote. The account itself is usually fine — it is the passkey that isn't on it — so the way out is recovery, which attaches a working passkey to the account they already have. Keep them on onboarding, name what happened, and put the recovery prompt in front of them. A login started from the landing step raises the Welcome sheet, which is where that prompt and an explicit "Create an account" live; the landing hero has room for neither, so until now those attempts failed to a bare hero with no way forward. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HRh59soi7iX4J3UwxVVGRS --- components/Onboarding/LegacyOnboarding.tsx | 29 +-- .../NewOnboarding/OnboardingNew.tsx | 34 ++-- .../__tests__/OnboardingNew.test.tsx | 173 ++++++++++++++++++ constants/__tests__/errors.test.ts | 50 +++++ constants/errors.ts | 47 +++++ hooks/useUser.ts | 25 ++- 6 files changed, 323 insertions(+), 35 deletions(-) create mode 100644 components/Onboarding/NewOnboarding/__tests__/OnboardingNew.test.tsx create mode 100644 constants/__tests__/errors.test.ts diff --git a/components/Onboarding/LegacyOnboarding.tsx b/components/Onboarding/LegacyOnboarding.tsx index 1b27d34b9..516291429 100644 --- a/components/Onboarding/LegacyOnboarding.tsx +++ b/components/Onboarding/LegacyOnboarding.tsx @@ -24,6 +24,7 @@ import { import PasskeyFaqModal from '@/components/PasskeyFaqModal'; import { Button } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; +import { isUnlinkedPasskeyError } from '@/constants/errors'; import { path } from '@/constants/path'; import { useDimension } from '@/hooks/useDimension'; import useUser from '@/hooks/useUser'; @@ -96,20 +97,22 @@ export default function LegacyOnboarding() { try { await handleLogin(); } catch (error: any) { - if (error?.status === 404) { - // User not found — redirect to signup - router.replace(path.SIGNUP_EMAIL); - } else { - // Other errors — show toast, stay on onboarding, and offer account recovery - Toast.show({ - type: 'error', - text1: 'Login failed', - text2: error?.message || 'Something went wrong. Please try again.', - }); - setShowRecoveryLink(true); - } + // A passkey the backend cannot tie to an account is not a new user, and + // replacing the screen with signup said it was. The account is usually + // fine — it is the passkey that isn't on it — and signup refuses that + // user's own address as already registered, so the redirect dead-ended. + // Stay here and offer recovery, which does put a working passkey on the + // account they already have. + Toast.show({ + type: 'error', + text1: isUnlinkedPasskeyError(error) + ? "Passkey isn't linked to an account" + : 'Login failed', + text2: error?.message || 'Something went wrong. Please try again.', + }); + setShowRecoveryLink(true); } - }, [handleLogin, router, setHasSeenOnboarding]); + }, [handleLogin, setHasSeenOnboarding]); const handleCreateAccount = useCallback(() => { setHasSeenOnboarding(true); diff --git a/components/Onboarding/NewOnboarding/OnboardingNew.tsx b/components/Onboarding/NewOnboarding/OnboardingNew.tsx index 528713d0a..2f396b9bf 100644 --- a/components/Onboarding/NewOnboarding/OnboardingNew.tsx +++ b/components/Onboarding/NewOnboarding/OnboardingNew.tsx @@ -7,6 +7,7 @@ import { useShallow } from 'zustand/react/shallow'; import PasskeyFaqModal from '@/components/PasskeyFaqModal'; import { Text } from '@/components/ui/text'; +import { isUnlinkedPasskeyError } from '@/constants/errors'; import { path } from '@/constants/path'; import useUser from '@/hooks/useUser'; import { Status } from '@/lib/types'; @@ -47,20 +48,27 @@ export default function OnboardingNew() { try { await handleLogin(); } catch (error: any) { - if (error?.status === 404) { - // User not found — redirect to signup - router.replace(path.SIGNUP_EMAIL); - } else { - // Other errors — show toast, stay on onboarding, and offer account recovery - Toast.show({ - type: 'error', - text1: 'Login failed', - text2: error?.message || 'Something went wrong. Please try again.', - }); - setShowRecoveryLink(true); - } + // A passkey the backend cannot tie to an account is not a new user, and + // replacing the screen with signup said it was. The account is usually + // fine — it is the passkey that isn't on it, stranded by a signup that + // failed after creating the credential — so signup was also a dead end: + // it refuses the user's own address as already registered. Stay here, + // name what happened, and offer recovery, which is what actually puts a + // working passkey on the account they already have. + Toast.show({ + type: 'error', + text1: isUnlinkedPasskeyError(error) + ? "Passkey isn't linked to an account" + : 'Login failed', + text2: error?.message || 'Something went wrong. Please try again.', + }); + // The landing step has nowhere to show the recovery prompt, so raise the + // Welcome sheet — it carries that prompt, and an explicit "Create an + // account" for anyone who did mean to sign up. + setShowWelcome(true); + setShowRecoveryLink(true); } - }, [handleLogin, router, setHasSeenOnboarding]); + }, [handleLogin, setHasSeenOnboarding]); const handleCreateAccount = useCallback(() => { setHasSeenOnboarding(true); diff --git a/components/Onboarding/NewOnboarding/__tests__/OnboardingNew.test.tsx b/components/Onboarding/NewOnboarding/__tests__/OnboardingNew.test.tsx new file mode 100644 index 000000000..c9f7ad304 --- /dev/null +++ b/components/Onboarding/NewOnboarding/__tests__/OnboardingNew.test.tsx @@ -0,0 +1,173 @@ +import React from 'react'; +import Toast from 'react-native-toast-message'; + +import OnboardingNew from '@/components/Onboarding/NewOnboarding/OnboardingNew'; +import { PASSKEY_ACCOUNT_NOT_FOUND_CODE } from '@/constants/errors'; +import { path } from '@/constants/path'; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { act, create } = require('react-test-renderer'); + +/** + * Props the last render handed each step, so the test can invoke them. Named + * `mock*` because jest.mock factories are hoisted above every other binding and + * may only close over identifiers with that prefix. + */ +const mockRendered: { + landing?: { onLogin: () => Promise; onGetStarted: () => void }; + welcome?: { visible: boolean; recoveryLink: React.ReactNode }; +} = {}; +const mockRouter = { replace: jest.fn(), push: jest.fn() }; +const mockHandleLogin = jest.fn(); + +jest.mock('expo-router', () => ({ useRouter: () => mockRouter })); +jest.mock('react-native-toast-message', () => ({ show: jest.fn() })); +jest.mock('@/hooks/useUser', () => ({ + __esModule: true, + default: () => ({ handleLogin: mockHandleLogin }), +})); +jest.mock('zustand/react/shallow', () => ({ useShallow: (selector: unknown) => selector })); +jest.mock('@/store/useOnboardingStore', () => ({ + useOnboardingStore: (selector: (state: unknown) => unknown) => + selector({ setHasSeenOnboarding: jest.fn() }), +})); +jest.mock('@/store/useUserStore', () => ({ + useUserStore: (selector: (state: unknown) => unknown) => + selector({ loginInfo: { status: 'idle' } }), +})); +jest.mock('@/components/PasskeyFaqModal', () => ({ __esModule: true, default: () => null })); +// Both ship untranspiled JSX in their published bundles, which the project's +// `transformIgnorePatterns` does not cover. They only dress the recovery prompt. +jest.mock('@/components/ui/text', () => ({ + Text: ({ children }: { children?: React.ReactNode }) => + jest.requireActual('react').createElement('Text', null, children), +})); +jest.mock('lucide-react-native', () => ({ ChevronRight: () => null })); + +// The two steps are replaced by props-recorders: this test is about which screen +// a failed login leaves the user on, not about the hero layout or the sheet +// animation — both of which pull in reanimated and the native image loader. +jest.mock('@/components/Onboarding/NewOnboarding/LandingScreen', () => ({ + LandingScreen: (props: Record) => { + Object.assign(mockRendered, { landing: props }); + return null; + }, +})); +jest.mock('@/components/Onboarding/NewOnboarding/WelcomeSheet', () => ({ + WelcomeSheet: (props: Record) => { + Object.assign(mockRendered, { welcome: props }); + return null; + }, +})); +jest.mock('@/components/Onboarding/NewOnboarding/OnboardingHeroBackground', () => ({ + OnboardingHeroBackground: () => null, +})); + +/** + * The bug this pins: a user who taps "Log in", passes Face ID, and whose passkey + * the backend cannot tie to an account was replaced onto "Create your account" — + * where signup then refuses their own email as already registered. Reported as + * "Passkey Login Redirecting Existing User to Create Account". + */ +describe('onboarding, when a passkey login fails', () => { + const renderOnboarding = async () => { + await act(async () => { + create(); + }); + }; + + const pressLogin = async () => { + await act(async () => { + await mockRendered.landing?.onLogin(); + }); + }; + + beforeEach(() => { + mockRouter.replace.mockClear(); + mockRouter.push.mockClear(); + mockHandleLogin.mockReset(); + (Toast.show as jest.Mock).mockClear(); + delete mockRendered.landing; + delete mockRendered.welcome; + }); + + it('keeps an unlinked passkey on onboarding instead of sending it to signup', async () => { + mockHandleLogin.mockRejectedValue( + Object.assign(new Error('This passkey is not linked to a Solid account.'), { + name: 'ApiError', + status: 404, + statusCode: 404, + code: PASSKEY_ACCOUNT_NOT_FOUND_CODE, + }), + ); + + await renderOnboarding(); + await pressLogin(); + + expect(mockRouter.replace).not.toHaveBeenCalledWith(path.SIGNUP_EMAIL); + expect(Toast.show).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'error', + text1: "Passkey isn't linked to an account", + text2: 'This passkey is not linked to a Solid account.', + }), + ); + }); + + it('raises the Welcome sheet so recovery is reachable from the landing step', async () => { + // The landing screen has no slot for the recovery prompt, so a login started + // there used to fail with nothing on screen but the hero. + mockHandleLogin.mockRejectedValue( + Object.assign(new Error('User not found'), { + name: 'ApiError', + status: 404, + statusCode: 404, + }), + ); + + await renderOnboarding(); + expect(mockRendered.welcome?.visible).toBe(false); + + await pressLogin(); + + expect(mockRendered.welcome?.visible).toBe(true); + expect(mockRendered.welcome?.recoveryLink).toBeTruthy(); + }); + + it('still offers recovery for failures that are not a 404', async () => { + mockHandleLogin.mockRejectedValue(new Error('Network request failed')); + + await renderOnboarding(); + await pressLogin(); + + expect(mockRouter.replace).not.toHaveBeenCalled(); + expect(Toast.show).toHaveBeenCalledWith( + expect.objectContaining({ text1: 'Login failed', text2: 'Network request failed' }), + ); + expect(mockRendered.welcome?.recoveryLink).toBeTruthy(); + }); + + it('leaves the onboarding screen alone when the login succeeds', async () => { + // handleLogin navigates to Home itself; onboarding must not second-guess it. + mockHandleLogin.mockResolvedValue(undefined); + + await renderOnboarding(); + await pressLogin(); + + expect(Toast.show).not.toHaveBeenCalled(); + expect(mockRouter.replace).not.toHaveBeenCalled(); + expect(mockRendered.welcome?.visible).toBe(false); + }); + + it('keeps "Get started" as the deliberate way to the Welcome sheet', async () => { + mockHandleLogin.mockResolvedValue(undefined); + + await renderOnboarding(); + await act(async () => { + mockRendered.landing?.onGetStarted(); + }); + + expect(mockRouter.replace).not.toHaveBeenCalled(); + expect(mockRendered.welcome?.visible).toBe(true); + }); +}); diff --git a/constants/__tests__/errors.test.ts b/constants/__tests__/errors.test.ts new file mode 100644 index 000000000..cb2ed1119 --- /dev/null +++ b/constants/__tests__/errors.test.ts @@ -0,0 +1,50 @@ +import { + isUnlinkedPasskeyError, + PASSKEY_ACCOUNT_NOT_FOUND_CODE, + PASSKEY_NOT_REGISTERED_CODE, +} from '@/constants/errors'; + +/** The shape `login()` throws: ApiError sets `status` and `statusCode` alike. */ +const apiError = (status: number, code?: string) => ({ + name: 'ApiError', + status, + statusCode: status, + code, + message: 'boom', +}); + +describe('isUnlinkedPasskeyError', () => { + it('recognises both backend codes for a passkey that reaches no account', () => { + expect(isUnlinkedPasskeyError(apiError(404, PASSKEY_NOT_REGISTERED_CODE))).toBe(true); + expect(isUnlinkedPasskeyError(apiError(404, PASSKEY_ACCOUNT_NOT_FOUND_CODE))).toBe(true); + }); + + it('recognises the untyped 404 an older backend sends', () => { + // Deployed backends predate the codes above and answer both cases with a + // bare "User not found". The app still has to route those to recovery. + expect(isUnlinkedPasskeyError(apiError(404))).toBe(true); + }); + + it('ignores a 404 that did not come from the login call', () => { + // viem's HttpRequestError carries `status` but no `statusCode`, and the + // login sequence calls an RPC host straight after authenticating. Reading + // that as "your passkey has no account" would send the user to recovery + // over a blip on someone else's server. + expect(isUnlinkedPasskeyError({ status: 404, message: 'HTTP request failed.' })).toBe(false); + }); + + it('leaves every other login failure alone', () => { + // A cancelled prompt, a closed account, a rate limit and a network blip all + // need their own answer — none of them is "your passkey has no account". + expect(isUnlinkedPasskeyError({ name: 'NotAllowedError' })).toBe(false); + expect(isUnlinkedPasskeyError(apiError(403))).toBe(false); + expect(isUnlinkedPasskeyError(apiError(429))).toBe(false); + expect(isUnlinkedPasskeyError(new Error('Network request failed'))).toBe(false); + expect(isUnlinkedPasskeyError(null)).toBe(false); + expect(isUnlinkedPasskeyError(undefined)).toBe(false); + }); + + it('does not treat the string "404" as a 404', () => { + expect(isUnlinkedPasskeyError({ status: '404', statusCode: '404' })).toBe(false); + }); +}); diff --git a/constants/errors.ts b/constants/errors.ts index 4573f159c..59eea3bcd 100644 --- a/constants/errors.ts +++ b/constants/errors.ts @@ -11,3 +11,50 @@ export const ERRORS = { * (SIGNATURE_INVALID). Retrying with the same passkey cannot succeed. */ export const PASSKEY_NOT_REGISTERED_CODE = 'PASSKEY_NOT_REGISTERED'; + +/** + * Backend code for a login stamp Turnkey *did* authenticate, against a + * sub-organization no Solid account claims. + * + * Signup creates the passkey before the sub-organization and the sub-organization + * before the account row, so a failure part-way through leaves a credential in + * the keychain that passes Face ID forever and can never sign in. + */ +export const PASSKEY_ACCOUNT_NOT_FOUND_CODE = 'PASSKEY_ACCOUNT_NOT_FOUND'; + +/** + * Our own copy for a 404 that arrives without a `code` — an older backend, which + * answered both cases above with a bare "User not found". Shown instead of that + * text because the account usually is not missing: it is the passkey that isn't + * on it, and "User not found" reads as "your money is gone". + */ +export const PASSKEY_UNLINKED_MESSAGE = + 'This passkey is not linked to a Solid account. If you already have an account, ' + + 'recover it to add this device.'; + +const UNLINKED_PASSKEY_CODES: string[] = [ + PASSKEY_NOT_REGISTERED_CODE, + PASSKEY_ACCOUNT_NOT_FOUND_CODE, +]; + +/** + * Whether a failed login means "the passkey that just signed cannot reach an + * account", as opposed to a cancelled prompt, a network failure or a closed + * account. + * + * Both shapes are a 404 from `/auths/log-in`, and both share the same two facts: + * retrying with this passkey cannot succeed, and the way out is recovery — not + * signup, which refuses the user's own email as already registered. + */ +export const isUnlinkedPasskeyError = (error: unknown): boolean => { + const err = error as { code?: unknown; status?: unknown; statusCode?: unknown } | null; + if (!err) return false; + + if (typeof err.code === 'string' && UNLINKED_PASSKEY_CODES.includes(err.code)) return true; + + // An older backend answers untyped. `statusCode` is set alongside `status` + // only by this app's own ApiError — the shape the login call throws — so + // requiring both keeps a 404 from elsewhere in the login sequence (viem + // reaching an RPC host, say) from being read as "your passkey has no account". + return err.status === 404 && err.statusCode === 404; +}; diff --git a/hooks/useUser.ts b/hooks/useUser.ts index 6106f2e0f..f7183bc2b 100644 --- a/hooks/useUser.ts +++ b/hooks/useUser.ts @@ -12,7 +12,7 @@ import { entryPoint07Address } from 'viem/account-abstraction'; import { mainnet } from 'viem/chains'; import { useShallow } from 'zustand/react/shallow'; -import { PASSKEY_NOT_REGISTERED_CODE } from '@/constants/errors'; +import { isUnlinkedPasskeyError, PASSKEY_UNLINKED_MESSAGE } from '@/constants/errors'; import { path } from '@/constants/path'; import { TRACKING_EVENTS } from '@/constants/tracking-events'; import { getAmplitudeDeviceId, track, trackIdentity } from '@/lib/analytics'; @@ -422,14 +422,17 @@ const useUser = (): UseUserReturn => { } } catch (error: any) { let errorMessage = - // The backend's own copy for an unregistered passkey is more precise than - // the generic 404 fallback, which reads as "no such account" even when the - // account exists and only this passkey is unusable. - error?.code === PASSKEY_NOT_REGISTERED_CODE - ? error.message - : error?.status === 404 - ? 'User not found, please sign up' - : error?.message || 'Network request timed out'; + // A 404 means the passkey that just signed reaches no account. The + // backend names which flavour it is (PASSKEY_NOT_REGISTERED / + // PASSKEY_ACCOUNT_NOT_FOUND) and its copy is the precise one; only an + // older backend answers untyped, and its bare "User not found" reads as + // "no such account" to someone whose account exists and works + // elsewhere — so that text never reaches the user. + isUnlinkedPasskeyError(error) + ? error?.code + ? error.message + : PASSKEY_UNLINKED_MESSAGE + : error?.message || 'Network request timed out'; if (error?.name === 'NotAllowedError') { errorMessage = 'User cancelled login'; @@ -451,6 +454,10 @@ const useUser = (): UseUserReturn => { track(TRACKING_EVENTS.LOGIN_FAILED, { username: user?.username, error: error.message, + // The message alone cannot separate "the prompt was cancelled" from + // "this passkey reaches no account", and the two need very different + // answers. Older backends send no code, hence the explicit fallback. + error_code: error?.code ?? (isUnlinkedPasskeyError(error) ? 'HTTP_404' : undefined), device_id: deviceId, ...attributionData, attribution_channel: getAttributionChannel(attributionData), From 55831adc961cf2b2b181a990c43f7ce3de7f9443 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 09:25:13 +0000 Subject: [PATCH 05/16] fix(onboarding): show the sanitized login error, not the raw API message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit handleLogin re-throws the original error so callers keep its `status` and `code` — which left every caller holding the raw API message alongside the sanitized one, and every toast read the raw one. The sanitized copy went into `loginInfo.message`, which nothing reads. So the screen still said "User not found": the bare 404 of a backend that predates the typed codes, under a heading telling the user their passkey isn't linked to an account. That is the near-term default, not an edge case — the backend half of this fix ships separately. Decide the copy in one exported place and call it from each toast: onboarding, legacy onboarding, and the multi-account welcome screen, which showed the same raw message next to the account row the user had just tapped. Reported by Seer on #2549. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HRh59soi7iX4J3UwxVVGRS --- app/welcome.tsx | 6 +- components/Onboarding/LegacyOnboarding.tsx | 6 +- .../NewOnboarding/OnboardingNew.tsx | 6 +- .../__tests__/OnboardingNew.test.tsx | 25 ++++++- constants/__tests__/errors.test.ts | 68 +++++++++++++++++++ constants/errors.ts | 27 ++++++++ hooks/useUser.ts | 16 +---- 7 files changed, 133 insertions(+), 21 deletions(-) diff --git a/app/welcome.tsx b/app/welcome.tsx index 4967a97cd..c43f72840 100644 --- a/app/welcome.tsx +++ b/app/welcome.tsx @@ -11,7 +11,7 @@ import LoginKeyIcon from '@/assets/images/login_key_icon'; import { DesktopHero } from '@/components/Onboarding'; import { Button } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; -import { PASSKEY_NOT_REGISTERED_CODE } from '@/constants/errors'; +import { loginErrorMessage, PASSKEY_NOT_REGISTERED_CODE } from '@/constants/errors'; import { path } from '@/constants/path'; import { useDimension } from '@/hooks/useDimension'; import useUser from '@/hooks/useUser'; @@ -100,7 +100,9 @@ export default function Welcome() { Toast.show({ type: 'error', text1: 'Authentication failed', - text2: error?.message || 'Please try again', + // Same login call, same raw 404 — this screen showed "User not found" + // for it too, next to the account row the user just tapped. + text2: loginErrorMessage(error), props: { badgeText: '', }, diff --git a/components/Onboarding/LegacyOnboarding.tsx b/components/Onboarding/LegacyOnboarding.tsx index 516291429..3553d10f0 100644 --- a/components/Onboarding/LegacyOnboarding.tsx +++ b/components/Onboarding/LegacyOnboarding.tsx @@ -24,7 +24,7 @@ import { import PasskeyFaqModal from '@/components/PasskeyFaqModal'; import { Button } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; -import { isUnlinkedPasskeyError } from '@/constants/errors'; +import { isUnlinkedPasskeyError, loginErrorMessage } from '@/constants/errors'; import { path } from '@/constants/path'; import { useDimension } from '@/hooks/useDimension'; import useUser from '@/hooks/useUser'; @@ -108,7 +108,9 @@ export default function LegacyOnboarding() { text1: isUnlinkedPasskeyError(error) ? "Passkey isn't linked to an account" : 'Login failed', - text2: error?.message || 'Something went wrong. Please try again.', + // `loginErrorMessage`, not `error.message`: the raw reply is what says + // "User not found" when the backend predates the typed codes. + text2: loginErrorMessage(error), }); setShowRecoveryLink(true); } diff --git a/components/Onboarding/NewOnboarding/OnboardingNew.tsx b/components/Onboarding/NewOnboarding/OnboardingNew.tsx index 2f396b9bf..3fb3d4ab0 100644 --- a/components/Onboarding/NewOnboarding/OnboardingNew.tsx +++ b/components/Onboarding/NewOnboarding/OnboardingNew.tsx @@ -7,7 +7,7 @@ import { useShallow } from 'zustand/react/shallow'; import PasskeyFaqModal from '@/components/PasskeyFaqModal'; import { Text } from '@/components/ui/text'; -import { isUnlinkedPasskeyError } from '@/constants/errors'; +import { isUnlinkedPasskeyError, loginErrorMessage } from '@/constants/errors'; import { path } from '@/constants/path'; import useUser from '@/hooks/useUser'; import { Status } from '@/lib/types'; @@ -60,7 +60,9 @@ export default function OnboardingNew() { text1: isUnlinkedPasskeyError(error) ? "Passkey isn't linked to an account" : 'Login failed', - text2: error?.message || 'Something went wrong. Please try again.', + // `loginErrorMessage`, not `error.message`: the raw reply is what says + // "User not found" when the backend predates the typed codes. + text2: loginErrorMessage(error), }); // The landing step has nowhere to show the recovery prompt, so raise the // Welcome sheet — it carries that prompt, and an explicit "Create an diff --git a/components/Onboarding/NewOnboarding/__tests__/OnboardingNew.test.tsx b/components/Onboarding/NewOnboarding/__tests__/OnboardingNew.test.tsx index c9f7ad304..6c0995a87 100644 --- a/components/Onboarding/NewOnboarding/__tests__/OnboardingNew.test.tsx +++ b/components/Onboarding/NewOnboarding/__tests__/OnboardingNew.test.tsx @@ -2,7 +2,7 @@ import React from 'react'; import Toast from 'react-native-toast-message'; import OnboardingNew from '@/components/Onboarding/NewOnboarding/OnboardingNew'; -import { PASSKEY_ACCOUNT_NOT_FOUND_CODE } from '@/constants/errors'; +import { PASSKEY_ACCOUNT_NOT_FOUND_CODE, PASSKEY_UNLINKED_MESSAGE } from '@/constants/errors'; import { path } from '@/constants/path'; // eslint-disable-next-line @typescript-eslint/no-require-imports @@ -114,6 +114,29 @@ describe('onboarding, when a passkey login fails', () => { ); }); + it('keeps the raw "User not found" of an untyped 404 off the screen', async () => { + // What the deployed backend still answers. The toast is built from the + // thrown error, so reading its `message` put the alarming wording in front + // of a user whose account is fine. + mockHandleLogin.mockRejectedValue( + Object.assign(new Error('User not found'), { + name: 'ApiError', + status: 404, + statusCode: 404, + }), + ); + + await renderOnboarding(); + await pressLogin(); + + expect(Toast.show).toHaveBeenCalledWith( + expect.objectContaining({ + text1: "Passkey isn't linked to an account", + text2: PASSKEY_UNLINKED_MESSAGE, + }), + ); + }); + it('raises the Welcome sheet so recovery is reachable from the landing step', async () => { // The landing screen has no slot for the recovery prompt, so a login started // there used to fail with nothing on screen but the hero. diff --git a/constants/__tests__/errors.test.ts b/constants/__tests__/errors.test.ts index cb2ed1119..3fa260071 100644 --- a/constants/__tests__/errors.test.ts +++ b/constants/__tests__/errors.test.ts @@ -1,7 +1,9 @@ import { isUnlinkedPasskeyError, + loginErrorMessage, PASSKEY_ACCOUNT_NOT_FOUND_CODE, PASSKEY_NOT_REGISTERED_CODE, + PASSKEY_UNLINKED_MESSAGE, } from '@/constants/errors'; /** The shape `login()` throws: ApiError sets `status` and `statusCode` alike. */ @@ -48,3 +50,69 @@ describe('isUnlinkedPasskeyError', () => { expect(isUnlinkedPasskeyError({ status: '404', statusCode: '404' })).toBe(false); }); }); + +describe('loginErrorMessage', () => { + it('never lets a bare "User not found" reach the user', () => { + // The untyped 404 of a backend that predates the codes. Every screen builds + // its toast from the thrown error, so reading `error.message` there put this + // exact string on screen — the one thing the copy exists to avoid. + const raw = Object.assign(new Error('User not found'), { + name: 'ApiError', + status: 404, + statusCode: 404, + }); + + expect(loginErrorMessage(raw)).toBe(PASSKEY_UNLINKED_MESSAGE); + }); + + it('prefers the backend copy when the reply is typed', () => { + // A typed reply says which flavour it is, so its wording beats ours. + expect( + loginErrorMessage( + Object.assign(new Error('This passkey is not registered to a Solid account.'), { + name: 'ApiError', + status: 404, + statusCode: 404, + code: PASSKEY_NOT_REGISTERED_CODE, + }), + ), + ).toBe('This passkey is not registered to a Solid account.'); + }); + + it('falls back to our copy if a typed reply arrives with no message', () => { + expect( + loginErrorMessage({ + name: 'ApiError', + status: 404, + statusCode: 404, + code: PASSKEY_ACCOUNT_NOT_FOUND_CODE, + }), + ).toBe(PASSKEY_UNLINKED_MESSAGE); + }); + + it("describes a dismissed prompt as the user's own action", () => { + expect( + loginErrorMessage( + Object.assign(new Error('The user cancelled the request.'), { name: 'NotAllowedError' }), + ), + ).toBe('User cancelled login'); + }); + + it('passes through every other failure', () => { + expect(loginErrorMessage(new Error('Network request failed'))).toBe('Network request failed'); + expect( + loginErrorMessage( + Object.assign(new Error('This account has been closed'), { + name: 'ApiError', + status: 403, + statusCode: 403, + }), + ), + ).toBe('This account has been closed'); + }); + + it('has something to say for an error carrying no message', () => { + expect(loginErrorMessage({})).toBe('Something went wrong. Please try again.'); + expect(loginErrorMessage(null)).toBe('Something went wrong. Please try again.'); + }); +}); diff --git a/constants/errors.ts b/constants/errors.ts index 59eea3bcd..2065c4dfd 100644 --- a/constants/errors.ts +++ b/constants/errors.ts @@ -58,3 +58,30 @@ export const isUnlinkedPasskeyError = (error: unknown): boolean => { // reaching an RPC host, say) from being read as "your passkey has no account". return err.status === 404 && err.statusCode === 404; }; + +/** + * The copy to show for a failed login, derived from the error `handleLogin` + * re-throws. + * + * Deliberately the only place that decides it. `handleLogin` re-throws the + * original error so callers keep its `status` and `code`, which means every + * caller holds a raw API message alongside the sanitized one — and reading + * `error.message` for the toast is how "User not found", the bare 404 of a + * backend that predates the codes above and the exact phrasing this exists to + * keep off the screen, reached the user anyway. + */ +export const loginErrorMessage = (error: unknown): string => { + const err = error as { name?: unknown; code?: unknown; message?: unknown } | null; + const message = typeof err?.message === 'string' ? err.message : ''; + + // WebAuthn reports a dismissed prompt as a failure; the platform's wording for + // it describes the machinery, not the thing the user just chose to do. + if (err?.name === 'NotAllowedError') return 'User cancelled login'; + + if (isUnlinkedPasskeyError(error)) { + // A typed reply names which flavour it is, and its copy is the precise one. + return typeof err?.code === 'string' && message ? message : PASSKEY_UNLINKED_MESSAGE; + } + + return message || 'Something went wrong. Please try again.'; +}; diff --git a/hooks/useUser.ts b/hooks/useUser.ts index f7183bc2b..c2180088a 100644 --- a/hooks/useUser.ts +++ b/hooks/useUser.ts @@ -12,7 +12,7 @@ import { entryPoint07Address } from 'viem/account-abstraction'; import { mainnet } from 'viem/chains'; import { useShallow } from 'zustand/react/shallow'; -import { isUnlinkedPasskeyError, PASSKEY_UNLINKED_MESSAGE } from '@/constants/errors'; +import { isUnlinkedPasskeyError, loginErrorMessage } from '@/constants/errors'; import { path } from '@/constants/path'; import { TRACKING_EVENTS } from '@/constants/tracking-events'; import { getAmplitudeDeviceId, track, trackIdentity } from '@/lib/analytics'; @@ -421,21 +421,9 @@ const useUser = (): UseUserReturn => { router.replace(path.HOME); } } catch (error: any) { - let errorMessage = - // A 404 means the passkey that just signed reaches no account. The - // backend names which flavour it is (PASSKEY_NOT_REGISTERED / - // PASSKEY_ACCOUNT_NOT_FOUND) and its copy is the precise one; only an - // older backend answers untyped, and its bare "User not found" reads as - // "no such account" to someone whose account exists and works - // elsewhere — so that text never reaches the user. - isUnlinkedPasskeyError(error) - ? error?.code - ? error.message - : PASSKEY_UNLINKED_MESSAGE - : error?.message || 'Network request timed out'; + const errorMessage = loginErrorMessage(error); if (error?.name === 'NotAllowedError') { - errorMessage = 'User cancelled login'; Sentry.captureMessage(errorMessage, { level: 'warning', extra: { From 6e56a0f01e1ac3b0284b6b738366570e4d71aef0 Mon Sep 17 00:00:00 2001 From: Mayank Mittal Date: Tue, 22 Sep 2026 15:55:24 +0530 Subject: [PATCH 06/16] feat: EURC spending on base --- .env.example | 20 ++ .../Card/NewCardDetails/CardDetailsPane.tsx | 17 ++ .../NewCardDetails/EnableEuroSpendCard.tsx | 87 ++++++ constants/spendModuleDeployments.ts | 115 ++++++++ constants/tracking-events.ts | 8 + hooks/useEuroSpendEnablement.ts | 247 ++++++++++++++++++ lib/api.ts | 64 +++++ lib/types.ts | 52 ++++ 8 files changed, 610 insertions(+) create mode 100644 components/Card/NewCardDetails/EnableEuroSpendCard.tsx create mode 100644 constants/spendModuleDeployments.ts create mode 100644 hooks/useEuroSpendEnablement.ts diff --git a/.env.example b/.env.example index 6e2d51142..c8c16a22d 100644 --- a/.env.example +++ b/.env.example @@ -66,6 +66,26 @@ EXPO_PUBLIC_CASH_MODULE_V2_ADDRESS= # SolidSpendLens on Fuse — the cohort-aware read serving both module generations. EXPO_PUBLIC_SPEND_LENS_V2_ADDRESS= +# ---- Base: the EURC spend instance ------------------------------------------- +# A SECOND, INDEPENDENT deployment of the spend module, not a setting on the Fuse +# one. Each instance has its own limits, its own replay markers and its own debt +# book, and none of that is visible across chains — so never fill these in from +# their Fuse counterparts above. +# +# Empty is correct until the Base deployment lands. With either address unset the +# app does not render the enable card, which is the right behaviour: a half-filled +# set is the dangerous state, not an empty one. +# +# Note the instance is DOLLAR-denominated despite being the euro deployment. EURC +# is priced within it as a floating asset at around 1.08; the euro is the asset, +# not the accounting unit. +EXPO_PUBLIC_BASE_CASH_MODULE_ADDRESS= +# SolidSpendLens on Base. Read before building the enable batch, so a partially +# completed enablement resumes instead of reverting on the half already done. +EXPO_PUBLIC_BASE_SPEND_LENS_ADDRESS= +# EURC on Base. Spendable, never collateral at launch. +EXPO_PUBLIC_BASE_EURC_ADDRESS= + # Quote the launch cashback rates baked into the app (Core 3%, Prime 4%, Ultra 5%) # instead of the rate the rewards API reports. On unless set to "false" — flip it # once the admin-configured rates are live. diff --git a/components/Card/NewCardDetails/CardDetailsPane.tsx b/components/Card/NewCardDetails/CardDetailsPane.tsx index 3bc02f45f..60b3e969e 100644 --- a/components/Card/NewCardDetails/CardDetailsPane.tsx +++ b/components/Card/NewCardDetails/CardDetailsPane.tsx @@ -25,6 +25,7 @@ import { } from '@/components/Card/NewCardDetails/cardHeroLayout'; import CardLinksList from '@/components/Card/NewCardDetails/CardLinksList'; import CardRevealSection from '@/components/Card/NewCardDetails/CardRevealSection'; +import EnableEuroSpendCard from '@/components/Card/NewCardDetails/EnableEuroSpendCard'; import { EASE_OUT_QUINT, HERO_ENTER, HeroEnter } from '@/components/Card/NewCardDetails/heroMotion'; import ManageCardSheet from '@/components/Card/NewCardDetails/ManageCardSheet'; import SpendingModeCard from '@/components/Card/NewCardDetails/SpendingModeCard'; @@ -42,6 +43,7 @@ import { useCardProvider } from '@/hooks/useCardProvider'; import { CardSpendRegistrationSource } from '@/hooks/useCardSpendRegistration'; import { useCardStatus } from '@/hooks/useCardStatus'; import { useCustomer } from '@/hooks/useCustomer'; +import useEuroSpendEnablement from '@/hooks/useEuroSpendEnablement'; import { useRewardsUserData } from '@/hooks/useRewards'; import { freezeCard, unfreezeCard } from '@/lib/api'; import { resolveUserCashbackRate } from '@/lib/tierCashback'; @@ -115,6 +117,7 @@ const CardDetailsPane = () => { // Which funds the card draws on — cash, credit or both. UI only for now: the // sheet previews the three modes and never commits one. const spendModeFigures = useSpendModeFigures(); + const euroSpend = useEuroSpendEnablement(); const [isSpendModeOpen, setIsSpendModeOpen] = useState(false); // The borrow position's own sheet, opened by tapping the card that shows it. const [isBorrowPositionOpen, setIsBorrowPositionOpen] = useState(false); @@ -285,6 +288,19 @@ const CardDetailsPane = () => { /> ) : null} + {/* Cohort-only, and gone for good once enabled — the backend stores the enablement per + chain, so it does not come back on another device. Placed directly under the spend + mode row because it is the same kind of thing: a property of how this card spends, + not a promotion. */} + {euroSpend.shouldOffer ? ( + + + + ) : null} {/* Shown to anyone who has a credit line, not only to someone already in debt — see `showsBorrowPosition`. A cardholder on Credit needs to see what they can spend against BEFORE they spend it; gating on the loan meant the first thing @@ -397,6 +413,7 @@ const styles = StyleSheet.create({ // to the spend-mode row, then 20 between each card down the stack. actionsRow: { marginTop: 51 }, spendModeCard: { marginTop: 53 }, + euroSpendCard: { marginTop: 20 }, borrowPositionCard: { marginTop: 20 }, cashbackCard: { marginTop: 20 }, linksList: { marginTop: 20 }, diff --git a/components/Card/NewCardDetails/EnableEuroSpendCard.tsx b/components/Card/NewCardDetails/EnableEuroSpendCard.tsx new file mode 100644 index 000000000..ab1994fae --- /dev/null +++ b/components/Card/NewCardDetails/EnableEuroSpendCard.tsx @@ -0,0 +1,87 @@ +import { ActivityIndicator, Pressable, StyleSheet, View } from 'react-native'; + +import { Text } from '@/components/ui/text'; + +interface EnableEuroSpendCardProps { + isEnabling: boolean; + /** Message from the last failed attempt, shown in place of the subtitle. */ + error?: string; + onEnable: () => void; +} + +/** + * "Enable euro lending" — the card that opens the Base spend instance for a cohort user. + * + * ## Who sees it, and when it goes away + * + * Only members of the `wirexTeamMembers` cohort, only where the backend reports a working Base + * deployment, and only until they enable it. That last part is why enablement is stored per chain + * server-side: the card disappearing for good is the whole point, and a client-side flag would + * bring it back on the next device the user signs in on. + * + * ## A note on the copy + * + * The label says "lending", and at launch nothing is lent. The Base instance is dollar-denominated + * and EURC is configured `spendable: true, collateral: false` — borrowing against a balance whose + * dollar value moves with an exchange rate is a decision being taken separately, with its own risk + * parameters. What this actually enables is spending a euro balance. The wording is the product's + * and is kept, but it should not be read as describing a credit line, and it will be wrong in a + * way worth revisiting if EURC is ever promoted to collateral. + * + * Laid out as the "Spend mode" row is (`SpendingModeCard`): a 23px card, 17px side inset, label + * left and action right — so it reads as another row of the card pane rather than a promo banner. + */ +const EnableEuroSpendCard = ({ isEnabling, error, onEnable }: EnableEuroSpendCardProps) => ( + + + Enable euro lending + + {error ?? 'Spend your EURC balance on Base'} + + + + {isEnabling ? ( + + ) : ( + Enable + )} + + +); + +const styles = StyleSheet.create({ + // Matches `SpendingModeCard`: 17 either side, content centred on the height. Taller than that + // row because this one carries a second line of copy. + row: { + alignItems: 'center', + flexDirection: 'row', + justifyContent: 'space-between', + minHeight: 72, + paddingHorizontal: 17, + paddingVertical: 12, + }, + label: { flex: 1, gap: 2, paddingRight: 12 }, + button: { + alignItems: 'center', + borderRadius: 100, + height: 35, + justifyContent: 'center', + minWidth: 84, + paddingHorizontal: 12, + }, +}); + +export default EnableEuroSpendCard; diff --git a/constants/spendModuleDeployments.ts b/constants/spendModuleDeployments.ts new file mode 100644 index 000000000..f649ddd02 --- /dev/null +++ b/constants/spendModuleDeployments.ts @@ -0,0 +1,115 @@ +import { Address } from 'viem'; +import { base, fuse } from 'viem/chains'; + +/** + * Every spend-module deployment the app can enable, in one place, keyed by chain. + * + * ## Why this is a separate file from `lib/config.ts` + * + * `ADDRESSES` is organised by chain and then by product — a Fuse entry holds the vault, the + * teller, the bridge paymaster and the cash module side by side. That shape answers "what is + * deployed on Fuse", which is the wrong question for this feature. What the card screen needs is + * "which spend-module instances exist, and what does each one need to be enabled", and answering it + * from `ADDRESSES` means the reader assembles a deployment out of four keys and has no way to tell + * they got all four. + * + * So the spend module's contracts live here, together, with the same shape per chain. This mirrors + * `cash/config/spend-module.config.ts` on the backend deliberately: the two agree on which chains + * exist and what each one is called, and drift between them is the failure that would offer a user + * a transaction the backend then refuses to confirm. + * + * ## Addresses come from env, and `undefined` is a normal state + * + * Base was unallocated when this was written — only the Chainlink quote adapter exists there. A + * deployment missing its module or lens is `configured: false`, and the enable card is not + * rendered. Never substitute another chain's address to fill a gap: instances are separate + * accounting universes and are not interchangeable. + * + * Expo inlines `process.env.EXPO_PUBLIC_*` at build time by **static** reference only, so each one + * is written out in full below rather than looked up by a computed key — a computed key silently + * resolves to `undefined` in a release build. + */ + +/** The unit an instance's amounts are denominated in — its accounting unit, not a display choice. */ +export type SettlementCurrency = 'USD' | 'EUR'; + +export interface SpendModuleDeployment { + chainId: number; + /** Viem chain, for building the user operation. */ + chain: typeof fuse | typeof base; + /** Human name for the chain chip. */ + name: string; + /** + * The accounting unit. + * + * `USD` for Base as well as Fuse, which is the thing most worth not getting wrong here. Base is + * the EURC deployment, but the module's unit there is dollars and EURC is priced within it as a + * floating asset at around 1.08 — the euro is the **asset**, not the unit. A limit or a balance + * from the Base instance is a dollar figure. + */ + currency: SettlementCurrency; + /** The asset this deployment exists to let people spend, for copy. */ + assetSymbol: string; + /** `SolidCashModuleV2`. The address the Safe enables and registers against. */ + moduleAddress?: Address; + /** `SolidSpendLens`. The cohort-aware read of what the Safe can spend. */ + spendLensAddress?: Address; + /** The spendable asset on this chain. */ + spendTokenAddress?: Address; +} + +const envAddress = (value: string | undefined): Address | undefined => { + const trimmed = value?.trim(); + + // A zero address is treated as unset rather than as a target. It is what an unfilled template + // leaves behind, and letting it through produces a transaction that fails at the RPC instead of + // at the config check, a long way from what was actually wrong. + if (!trimmed || /^0x0{40}$/i.test(trimmed)) return undefined; + + return trimmed as Address; +}; + +/** Fuse — the dollar book, and the instance every cardholder spends from today. */ +export const FUSE_SPEND_DEPLOYMENT: SpendModuleDeployment = { + chainId: fuse.id, + chain: fuse, + name: 'Fuse', + currency: 'USD', + assetSymbol: 'soUSD', + moduleAddress: envAddress(process.env.EXPO_PUBLIC_CASH_MODULE_V2_ADDRESS), + spendLensAddress: envAddress(process.env.EXPO_PUBLIC_SPEND_LENS_V2_ADDRESS), + spendTokenAddress: '0x75333830E7014e909535389a6E5b0C02aA62ca27', +}; + +/** Base — the EURC instance. Dollar-denominated accounting, euro-denominated asset. */ +export const BASE_SPEND_DEPLOYMENT: SpendModuleDeployment = { + chainId: base.id, + chain: base, + name: 'Base', + currency: 'USD', + assetSymbol: 'EURC', + moduleAddress: envAddress(process.env.EXPO_PUBLIC_BASE_CASH_MODULE_ADDRESS), + spendLensAddress: envAddress(process.env.EXPO_PUBLIC_BASE_SPEND_LENS_ADDRESS), + spendTokenAddress: envAddress(process.env.EXPO_PUBLIC_BASE_EURC_ADDRESS), +}; + +export const SPEND_MODULE_DEPLOYMENTS: readonly SpendModuleDeployment[] = [ + FUSE_SPEND_DEPLOYMENT, + BASE_SPEND_DEPLOYMENT, +]; + +/** + * Whether this build can actually execute an enablement on a deployment. + * + * The module is the call target; without it there is no transaction to build. The lens is what the + * backend reads to verify the result, so a build that has one but not the other would land a + * transaction the confirm endpoint could never prove — which looks to the user like the enablement + * silently failing. + */ +export const isSpendDeploymentConfigured = ( + deployment: SpendModuleDeployment | undefined, +): deployment is SpendModuleDeployment & { moduleAddress: Address } => + Boolean(deployment?.moduleAddress && deployment?.spendLensAddress); + +export const spendDeploymentFor = (chainId: number): SpendModuleDeployment | undefined => + SPEND_MODULE_DEPLOYMENTS.find(deployment => deployment.chainId === chainId); diff --git a/constants/tracking-events.ts b/constants/tracking-events.ts index 7b0c644a4..2a44bf3a1 100644 --- a/constants/tracking-events.ts +++ b/constants/tracking-events.ts @@ -348,6 +348,14 @@ export const TRACKING_EVENTS = { CARD_SPEND_REGISTER_COMPLETED: 'card_spend_register_completed', CARD_SPEND_REGISTER_FAILED: 'card_spend_register_failed', CARD_SPEND_REGISTER_CANCELLED: 'card_spend_register_cancelled', + // Enabling the Base instance so the card can spend a EURC balance. Its own funnel + // rather than properties on the register events above, because it is a SECOND + // enablement on a second chain by users who already finished that one — folding them + // together would make a completion rate that mixes first-time setup with an upsell. + CARD_EURO_SPEND_ENABLE_STARTED: 'card_euro_spend_enable_started', + CARD_EURO_SPEND_ENABLE_COMPLETED: 'card_euro_spend_enable_completed', + CARD_EURO_SPEND_ENABLE_FAILED: 'card_euro_spend_enable_failed', + CARD_EURO_SPEND_ENABLE_CANCELLED: 'card_euro_spend_enable_cancelled', // Turning card spending back off: `Safe.disableModule`. Its own events rather than a // property on the register ones, because this is the funnel leaving — a user who // disables is a different signal from one who never set up. diff --git a/hooks/useEuroSpendEnablement.ts b/hooks/useEuroSpendEnablement.ts new file mode 100644 index 000000000..1a81dd384 --- /dev/null +++ b/hooks/useEuroSpendEnablement.ts @@ -0,0 +1,247 @@ +import { useCallback, useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { Address, encodeFunctionData } from 'viem'; + +import { + BASE_SPEND_DEPLOYMENT, + isSpendDeploymentConfigured, +} from '@/constants/spendModuleDeployments'; +import { TRACKING_EVENTS } from '@/constants/tracking-events'; +import useUser from '@/hooks/useUser'; +import { Safe_ABI } from '@/lib/abis/Safe'; +import { SolidCashModuleV2_ABI } from '@/lib/abis/SolidCashModuleV2'; +import { SolidSpendLens_ABI } from '@/lib/abis/SolidSpendLens'; +import { track } from '@/lib/analytics'; +import { confirmCardSpendDeployment, getCardSpendDeployments } from '@/lib/api'; +import { executeTransactions, USER_CANCELLED_TRANSACTION } from '@/lib/execute'; +import { CardSpendDeployment } from '@/lib/types'; +import { publicClient } from '@/lib/wagmi'; + +export const CARD_SPEND_DEPLOYMENTS_QUERY_KEY = 'cardSpendDeployments'; + +/** How long the deployment list is trusted before it is asked again. */ +const STALE_MS = 5 * 60 * 1000; + +/** + * Register with the deployment's own default caps. + * + * `registerSafe` applies the module's configured defaults when passed zero, so passing zero is + * what asks for them. Deliberately not carried over from the user's Fuse caps: the two instances + * police separate windows, and copying a number across would present one limit to a user who + * actually has two — which is a promise this client is in no position to keep. + */ +const DEFAULT_LIMITS = 0n; + +/** + * Seconds from UTC this device's day boundary sits at, for the rolling spend windows. + * + * Negated because `getTimezoneOffset` reports minutes to ADD to local time to reach UTC, and the + * module wants the offset FROM UTC. Getting the sign wrong moves a user's daily reset by twice + * their offset, which is silent and only shows up as a limit resetting at the wrong hour. + */ +const deviceTimezoneOffsetSeconds = () => -new Date().getTimezoneOffset() * 60; + +interface EuroSpendEnablement { + /** + * Whether to render the enable card. + * + * True only when every one of these holds: the user is in the rollout cohort, the backend has a + * working Base deployment configured, this build has the addresses to execute against, and the + * backend has no record of this user already enabling it. + */ + shouldOffer: boolean; + /** The Base deployment as the backend describes it, once it has answered. */ + deployment: CardSpendDeployment | undefined; + /** Already enabled, per our durable record. */ + isEnabled: boolean; + isLoading: boolean; + isEnabling: boolean; + error: string | undefined; + clearError: () => void; + enable: () => void; +} + +/** + * The "enable euro spending on Base" flow, end to end. + * + * ## What enabling actually is + * + * Two calls in one user operation, on Base: `enableModule` on the user's Safe, which is the + * owner-signed consent that lets the module move funds at all, and `registerSafe` on the module, + * which opens the Safe's spend record and its limit windows. They are batched because a Safe with + * the module enabled but unregistered declines every transaction, and so does the reverse — a + * half-finished enablement is a card that looks set up and is not. + * + * Each call is skipped if the chain says it is already done. Both revert on a repeat + * (`registerSafe` reverts `AlreadyRegistered` and there is no deregister), and a revert fails the + * whole batch — so a user retrying after a partial success would be permanently stuck without this. + * + * ## Why the gate is the backend's record and not a chain read + * + * `shouldOffer` comes from `spend-deployments`, which the backend serves from Mongo. A chain read + * here would mean a slow or failing Base RPC shows the enable card to someone who already enabled + * it, and hands them a transaction that reverts. The chain is still authoritative for what the + * transaction does — it is read below, immediately before the batch is built, and again by the + * backend before it records anything. + * + * ## Note on the name + * + * The Base instance is **dollar-denominated**; EURC is a spendable asset within it and is + * explicitly not collateral at launch, so nothing here lends against euros. The copy this powers + * says "euro", which is what the user experiences — spending a euro balance — and is fine as long + * as nobody reads the code and concludes there is a euro credit line. + */ +export const useEuroSpendEnablement = (): EuroSpendEnablement => { + const { user, safeAA } = useUser(); + const queryClient = useQueryClient(); + const [error, setError] = useState(); + + const { data, isLoading } = useQuery({ + queryKey: [CARD_SPEND_DEPLOYMENTS_QUERY_KEY, user?.userId], + queryFn: getCardSpendDeployments, + enabled: !!user?.userId, + staleTime: STALE_MS, + // A gate that retries forever keeps the card hidden for the same length of time either way; + // one retry covers a transient blip without holding the query pending. + retry: 1, + }); + + const deployment = data?.deployments.find(d => d.chainId === BASE_SPEND_DEPLOYMENT.chainId); + + const enableMutation = useMutation({ + mutationFn: async () => { + if (!user?.suborgId || !user?.signWith || !user?.safeAddress) { + throw new Error('Your wallet is still setting up. Please try again shortly.'); + } + + if (!isSpendDeploymentConfigured(BASE_SPEND_DEPLOYMENT)) { + throw new Error('Euro spending is not available in this version of the app.'); + } + + // The backend's address wins over the build's. The app can be weeks behind a redeploy, and + // enabling a module the backend does not operate produces a Safe that consents to a contract + // nothing will ever debit — a silent failure the user has no way to see. + const moduleAddress = (deployment?.moduleAddress ?? + BASE_SPEND_DEPLOYMENT.moduleAddress) as Address; + const safeAddress = (deployment?.safeAddress ?? user.safeAddress) as Address; + + // Read the chain immediately before building the batch, so a partially completed enablement + // — one call landed, the other reverted or was abandoned — resumes rather than reverting on + // the half that is already done. + const state = await publicClient(BASE_SPEND_DEPLOYMENT.chainId).readContract({ + address: BASE_SPEND_DEPLOYMENT.spendLensAddress as Address, + abi: SolidSpendLens_ABI, + functionName: 'availableToSpendWith', + args: [safeAddress, []], + }); + + const transactions = [ + ...(state.moduleEnabled + ? [] + : [ + { + to: safeAddress, + data: encodeFunctionData({ + abi: Safe_ABI, + functionName: 'enableModule', + args: [moduleAddress], + }), + }, + ]), + ...(state.registered + ? [] + : [ + { + to: moduleAddress, + data: encodeFunctionData({ + abi: SolidCashModuleV2_ABI, + functionName: 'registerSafe', + args: [DEFAULT_LIMITS, DEFAULT_LIMITS, BigInt(deviceTimezoneOffsetSeconds())], + }), + }, + ]), + ]; + + // Both halves already done on-chain while our record says otherwise — the confirm call after + // a previous attempt never landed, most likely. Recording it is exactly the fix, so this + // falls through to `confirmCardSpendDeployment` rather than raising at the user. + if (transactions.length > 0) { + const smartAccountClient = await safeAA( + BASE_SPEND_DEPLOYMENT.chain, + user.suborgId, + user.signWith, + ); + + const result = await executeTransactions( + smartAccountClient, + transactions, + 'Failed to enable euro spending', + BASE_SPEND_DEPLOYMENT.chain, + ); + + if (result === USER_CANCELLED_TRANSACTION) { + track(TRACKING_EVENTS.CARD_EURO_SPEND_ENABLE_CANCELLED, { + chain_id: BASE_SPEND_DEPLOYMENT.chainId, + }); + return null; + } + + await confirmCardSpendDeployment(BASE_SPEND_DEPLOYMENT.chainId, { + transactionHash: result.transactionHash, + moduleAddress, + }); + + return { transactionHash: result.transactionHash }; + } + + await confirmCardSpendDeployment(BASE_SPEND_DEPLOYMENT.chainId, { moduleAddress }); + + return { transactionHash: undefined }; + }, + onSuccess: result => { + // Cancelled by the user. Nothing changed, so nothing is invalidated and no success is + // reported — a refetch here would only re-render the same card they just dismissed. + if (!result) return; + + void queryClient.invalidateQueries({ + queryKey: [CARD_SPEND_DEPLOYMENTS_QUERY_KEY], + }); + + track(TRACKING_EVENTS.CARD_EURO_SPEND_ENABLE_COMPLETED, { + chain_id: BASE_SPEND_DEPLOYMENT.chainId, + transaction_hash: result.transactionHash, + }); + }, + onError: (mutationError: Error) => { + const message = mutationError?.message || 'Failed to enable euro spending'; + setError(message); + track(TRACKING_EVENTS.CARD_EURO_SPEND_ENABLE_FAILED, { error: message }); + }, + }); + + const enable = useCallback(() => { + setError(undefined); + track(TRACKING_EVENTS.CARD_EURO_SPEND_ENABLE_STARTED, { + chain_id: BASE_SPEND_DEPLOYMENT.chainId, + }); + enableMutation.mutate(); + }, [enableMutation]); + + return { + shouldOffer: Boolean( + data?.cohortEnabled && + deployment?.available && + !deployment?.enabled && + isSpendDeploymentConfigured(BASE_SPEND_DEPLOYMENT), + ), + deployment, + isEnabled: deployment?.enabled === true, + isLoading: !!user?.userId && isLoading, + isEnabling: enableMutation.isPending, + error, + clearError: useCallback(() => setError(undefined), []), + enable, + }; +}; + +export default useEuroSpendEnablement; diff --git a/lib/api.ts b/lib/api.ts index 32ac32579..8c47314da 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -60,6 +60,8 @@ import { CardProvider, CardResponse, CardSecretsResponseDto, + CardSpendDeploymentConfirmRequest, + CardSpendDeploymentsResponse, CardSpendModeAccessResponse, CardStatusResponse, CardTransaction, @@ -3417,6 +3419,68 @@ export const getWirexCardRegistration = async ( return response.json(); }; +/** + * Every spend-module deployment and whether this user has already enabled each one. + * + * Its own request rather than a field on the registration read, for the same reason the + * spend-mode gate is: that read is a live Fuse chain call, and whether to offer a card for Base + * must not be unavailable because Fuse is lagging. + * + * The enablement half is served from the backend's durable record, which is what stops the enable + * card reappearing for someone who has already enabled it. + */ +export const getCardSpendDeployments = async (): Promise => { + const jwt = getJWTToken(); + + const response = await fetch( + `${EXPO_PUBLIC_FLASH_API_BASE_URL}/accounts/v1/wirex/spend-deployments`, + { + headers: { + ...getPlatformHeaders(), + ...(jwt ? { Authorization: `Bearer ${jwt}` } : {}), + }, + credentials: 'include', + }, + ); + + if (!response.ok) throw response; + + return response.json(); +}; + +/** + * Records that the user enabled the spend module on one chain. + * + * Grants nothing: the Safe enabled the module with its own owner signature, and the backend reads + * that chain to verify it before writing anything. What it buys is that the enable card is not + * offered again — and the fresh deployment list comes back, so the UI flips on the same + * interaction rather than waiting out a cache. + */ +export const confirmCardSpendDeployment = async ( + chainId: number, + body: CardSpendDeploymentConfirmRequest, +): Promise => { + const jwt = getJWTToken(); + + const response = await fetch( + `${EXPO_PUBLIC_FLASH_API_BASE_URL}/accounts/v1/wirex/spend-deployments/${chainId}/confirm`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...getPlatformHeaders(), + ...(jwt ? { Authorization: `Bearer ${jwt}` } : {}), + }, + credentials: 'include', + body: JSON.stringify(body), + }, + ); + + if (!response.ok) throw response; + + return response.json(); +}; + /** * Whether this user may see the spend-mode picker and the borrow position. * diff --git a/lib/types.ts b/lib/types.ts index 6bc26eada..c07cc6ea0 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -920,6 +920,58 @@ export interface CardSpendModeAccessResponse { reason: 'cohort' | 'open' | 'not-in-cohort' | 'unavailable'; } +/** + * One spend-module deployment, as the card screen needs to see it. + * + * `available` and `enabled` answer different questions and the enable card needs both: the first + * is "does this environment have a working deployment here", the second is "has this user already + * consented to it". The card shows when `available && !enabled`. + */ +export interface CardSpendDeployment { + chainId: number; + /** Human name for the chain chip — "Fuse", "Base". */ + name: string; + /** + * The accounting unit of the instance. + * + * `USD` for Base too: Base is the EURC deployment, but its unit is dollars and EURC is priced + * within it as a floating asset. Rendering this as the currency of a balance is correct; reading + * it as "this is the euro card" is not. + */ + currency: 'USD' | 'EUR'; + /** The address to `enableModule` and `registerSafe` against. Null when unavailable. */ + moduleAddress: string | null; + /** Whether this environment can support enabling here at all. */ + available: boolean; + /** + * Whether the backend has recorded this user enabling the module here. + * + * Served from the durable record rather than from a chain read, deliberately: an enable card + * that reappears after a successful enablement — because an RPC was slow — would offer a + * transaction that reverts with `AlreadyRegistered`. + */ + enabled: boolean; + enabledAt: string | null; + enableTxHash: string | null; + safeAddress: string | null; + /** Assets this instance may draw from, lowercased, in draw order. */ + spendTokens: string[]; +} + +export interface CardSpendDeploymentsResponse { + /** Whether this user is in the rollout cohort (`wirexTeamMembers`) at all. */ + cohortEnabled: boolean; + cohortReason: 'cohort' | 'open' | 'not-in-cohort' | 'unavailable'; + deployments: CardSpendDeployment[]; +} + +/** What the app reports after its enable + register user operation lands on a chain. */ +export interface CardSpendDeploymentConfirmRequest { + transactionHash?: string; + /** Cross-checked server-side against the module that chain is configured with. */ + moduleAddress?: string; +} + export interface WirexCardRegistrationResponse { /** Both halves done: the module is enabled on the Safe *and* the Safe is registered. */ registered: boolean; From eb1c61ae7a6d2e749b38be4f0a8b8b354f8b6f63 Mon Sep 17 00:00:00 2001 From: Mayank Mittal Date: Tue, 22 Sep 2026 17:26:42 +0530 Subject: [PATCH 07/16] fix: eurc spend --- hooks/useEuroSpendEnablement.ts | 38 +++++++++++++++++++++++++++------ 1 file changed, 31 insertions(+), 7 deletions(-) diff --git a/hooks/useEuroSpendEnablement.ts b/hooks/useEuroSpendEnablement.ts index 1a81dd384..13506d0b9 100644 --- a/hooks/useEuroSpendEnablement.ts +++ b/hooks/useEuroSpendEnablement.ts @@ -10,7 +10,6 @@ import { TRACKING_EVENTS } from '@/constants/tracking-events'; import useUser from '@/hooks/useUser'; import { Safe_ABI } from '@/lib/abis/Safe'; import { SolidCashModuleV2_ABI } from '@/lib/abis/SolidCashModuleV2'; -import { SolidSpendLens_ABI } from '@/lib/abis/SolidSpendLens'; import { track } from '@/lib/analytics'; import { confirmCardSpendDeployment, getCardSpendDeployments } from '@/lib/api'; import { executeTransactions, USER_CANCELLED_TRANSACTION } from '@/lib/execute'; @@ -128,12 +127,37 @@ export const useEuroSpendEnablement = (): EuroSpendEnablement => { // Read the chain immediately before building the batch, so a partially completed enablement // — one call landed, the other reverted or was abandoned — resumes rather than reverting on // the half that is already done. - const state = await publicClient(BASE_SPEND_DEPLOYMENT.chainId).readContract({ - address: BASE_SPEND_DEPLOYMENT.spendLensAddress as Address, - abi: SolidSpendLens_ABI, - functionName: 'availableToSpendWith', - args: [safeAddress, []], - }); + // + // ## Read the MODULE, not the lens + // + // `SolidSpendLens.availableToSpendWith` is the obvious call and it does not work here. + // `cohortOf` short-circuits on a Safe already live on v2 and otherwise falls through to + // `v1.isRegistered(safe)` — and on Base the v1 sentinel is the burn address, which has no + // code, so that call reverts. Every Safe that is not already registered therefore reverts, + // which is precisely every Safe arriving at this screen. Verified against the deployed + // contracts: the Base lens reverts for an unregistered Safe where the Fuse lens returns + // clean zeros. + // + // The module answers both facts directly and has code, so it is both correct and cheaper. + // They are separate reads because they are separate facts: registration is permanent + // (`registerSafe` reverts `AlreadyRegistered` and there is no deregister) while module + // consent can be withdrawn at any time, so a Safe can be registered with the module off. + const [moduleEnabled, registered] = await Promise.all([ + publicClient(BASE_SPEND_DEPLOYMENT.chainId).readContract({ + address: moduleAddress, + abi: SolidCashModuleV2_ABI, + functionName: 'isModuleEnabledOn', + args: [safeAddress], + }), + publicClient(BASE_SPEND_DEPLOYMENT.chainId).readContract({ + address: moduleAddress, + abi: SolidCashModuleV2_ABI, + functionName: 'isRegistered', + args: [safeAddress], + }), + ]); + + const state = { moduleEnabled, registered }; const transactions = [ ...(state.moduleEnabled From aba1a853199d27914eef0d1fff49cbba95b4665b Mon Sep 17 00:00:00 2001 From: Mayank Mittal Date: Tue, 22 Sep 2026 21:35:07 +0530 Subject: [PATCH 08/16] fix: eurc spend enable cache --- hooks/useEuroSpendEnablement.ts | 43 +++++++++++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/hooks/useEuroSpendEnablement.ts b/hooks/useEuroSpendEnablement.ts index 13506d0b9..9912a9b6c 100644 --- a/hooks/useEuroSpendEnablement.ts +++ b/hooks/useEuroSpendEnablement.ts @@ -31,6 +31,45 @@ const STALE_MS = 5 * 60 * 1000; */ const DEFAULT_LIMITS = 0n; +/** + * How many times the confirm is retried, and how long between attempts. + * + * The enable transaction and the backend's verifying read go to different nodes, so the read can + * land before the block carrying the enablement has propagated. The backend answers 503 in that + * case and records nothing — and without a retry here that is a user who enabled euro spending + * on-chain, paid the gas, and has no record of it anywhere that routing can see. + * + * Bounded rather than indefinite: if it has not propagated within this window something is + * genuinely wrong, and the user is better told so than left watching a spinner. Re-pressing enable + * recovers from there, since both on-chain halves are already done and the hook goes straight back + * to the confirm. + */ +const CONFIRM_RETRIES = 4; +const CONFIRM_RETRY_DELAY_MS = 2_000; + +const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + +/** + * Confirms the enablement, retrying while the backend reports it cannot yet see it on-chain. + * + * Only 503 is retried. A 400 means the request itself is wrong — an unknown chain, or a module + * address this backend does not operate — and repeating it would just be slower. + */ +const confirmWithRetry = async ( + chainId: number, + body: Parameters[1], +) => { + for (let attempt = 0; ; attempt++) { + try { + return await confirmCardSpendDeployment(chainId, body); + } catch (error) { + const status = (error as Response)?.status; + if (status !== 503 || attempt >= CONFIRM_RETRIES) throw error; + await sleep(CONFIRM_RETRY_DELAY_MS); + } + } +}; + /** * Seconds from UTC this device's day boundary sits at, for the rolling spend windows. * @@ -210,7 +249,7 @@ export const useEuroSpendEnablement = (): EuroSpendEnablement => { return null; } - await confirmCardSpendDeployment(BASE_SPEND_DEPLOYMENT.chainId, { + await confirmWithRetry(BASE_SPEND_DEPLOYMENT.chainId, { transactionHash: result.transactionHash, moduleAddress, }); @@ -218,7 +257,7 @@ export const useEuroSpendEnablement = (): EuroSpendEnablement => { return { transactionHash: result.transactionHash }; } - await confirmCardSpendDeployment(BASE_SPEND_DEPLOYMENT.chainId, { moduleAddress }); + await confirmWithRetry(BASE_SPEND_DEPLOYMENT.chainId, { moduleAddress }); return { transactionHash: undefined }; }, From 7ff3b7e899a577d70e71f7f812b2b5c62644d36c Mon Sep 17 00:00:00 2001 From: Mayank Mittal Date: Thu, 24 Sep 2026 00:02:30 +0530 Subject: [PATCH 09/16] feat: repay card credit from wallet or collateral Adds the repay step to the borrow-position sheet. Repay slides the sheet into an amount + "Repay with" view; a successful repayment slides back to the updated position. - Wallet sources use repayFromSafe (approve + repay + approve(0) when the module is revoked); collateral sources use repayFromCollateral. - A full repayment also withdraws the remaining escrowed collateral in the same user operation, gated on a buffer for interest and price moves so the withdraw can never revert the batch. - Tender is read from repayTender per token. The deployed build predates it, so where the call fails the list falls back to USDC.e + soUSD. - ABI entries generated with forge inspect from the v2 sources. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../SpendMode/BorrowPositionSheet.tsx | 78 ++- .../SpendMode/BorrowPositionSheetContent.tsx | 153 ++++- .../SpendMode/CardBottomSheet.native.tsx | 3 + .../SpendMode/RepaySheetContent.tsx | 574 ++++++++++++++++++ .../SpendMode/SheetTextInput.native.tsx | 10 + .../SpendMode/SheetTextInput.tsx | 11 + constants/cardSpendV2.ts | 13 + constants/tracking-events.ts | 7 + hooks/useCardRepay.ts | 337 ++++++++++ lib/abis/SolidCashModuleV2.ts | 131 ++++ lib/utils/__tests__/cardRepay.test.ts | 329 ++++++++++ lib/utils/cardRepay.ts | 408 +++++++++++++ 12 files changed, 2036 insertions(+), 18 deletions(-) create mode 100644 components/Card/NewCardDetails/SpendMode/RepaySheetContent.tsx create mode 100644 components/Card/NewCardDetails/SpendMode/SheetTextInput.native.tsx create mode 100644 components/Card/NewCardDetails/SpendMode/SheetTextInput.tsx create mode 100644 hooks/useCardRepay.ts create mode 100644 lib/utils/__tests__/cardRepay.test.ts create mode 100644 lib/utils/cardRepay.ts diff --git a/components/Card/NewCardDetails/SpendMode/BorrowPositionSheet.tsx b/components/Card/NewCardDetails/SpendMode/BorrowPositionSheet.tsx index 9536b473e..d5a662855 100644 --- a/components/Card/NewCardDetails/SpendMode/BorrowPositionSheet.tsx +++ b/components/Card/NewCardDetails/SpendMode/BorrowPositionSheet.tsx @@ -1,22 +1,86 @@ -import { useCallback } from 'react'; +import { useCallback, useMemo } from 'react'; +import Toast from 'react-native-toast-message'; +import { fuse } from 'viem/chains'; import BorrowPositionSheetContent, { BORROW_POSITION_SHEET_BOTTOM, BORROW_POSITION_SHEET_TOP, + type BorrowPositionRepay, } from '@/components/Card/NewCardDetails/SpendMode/BorrowPositionSheetContent'; import CardBottomSheet from '@/components/Card/NewCardDetails/SpendMode/CardBottomSheet'; import useSpendModeFigures from '@/components/Card/NewCardDetails/SpendMode/useSpendModeFigures'; +import { formatUsd } from '@/constants/cardSpendModule'; +import { explorerUrls } from '@/constants/explorers'; +import { type CardRepayRequest, useCardRepay } from '@/hooks/useCardRepay'; +import { eclipseAddress } from '@/lib/utils'; +import { formatRepayTokenAmount } from '@/lib/utils/cardRepay'; interface BorrowPositionSheetProps { isOpen: boolean; onOpenChange: (open: boolean) => void; } -/** The borrow position, opened by tapping the card on the card screen. */ +/** + * The borrow position, opened by tapping the card on the card screen, and the repay step + * behind its Repay button. + * + * Owns the repayment: the content is presentation, and this is where a chosen amount becomes + * a signature. The sheet stays open on failure with the reason, because a silently closed + * sheet reads as success, and on success slides back to the position so the new figures are + * what the cardholder sees next. + */ const BorrowPositionSheet = ({ isOpen, onOpenChange }: BorrowPositionSheetProps) => { const figures = useSpendModeFigures(); + // Read only while the sheet is open. The card screen mounts this for everyone it shows the + // position card to, and two multicalls on every visit buy nothing until Repay is in reach. + const { state, isLoading, isError, refetch, repay, isRepaying, error, clearError } = useCardRepay( + { enabled: isOpen }, + ); const dismiss = useCallback(() => onOpenChange(false), [onOpenChange]); + const confirmRepay = useCallback( + async (request: CardRepayRequest) => { + // `repay` resolves null when the signature prompt was dismissed, which is not a failure + // and must not move the sheet — the cardholder is still deciding. A real failure has + // already been recorded as `error`, which the step shows. + const result = await repay(request).catch(() => null); + if (!result) return false; + + const returned = result.returnedCollateral + .map(item => `${formatRepayTokenAmount(item.amount, item.decimals)} ${item.displaySymbol}`) + .join(' and '); + const explorer = explorerUrls[fuse.id]?.blockscout; + + Toast.show({ + type: 'success', + text1: result.isFull ? 'Loan repaid' : `Repaid ${formatUsd(result.repayUsd)}`, + text2: returned ? `${returned} is back in your wallet` : undefined, + props: explorer + ? { + link: `${explorer}/tx/${result.transactionHash}`, + linkText: eclipseAddress(result.transactionHash), + } + : undefined, + }); + return true; + }, + [repay], + ); + + const repayProps = useMemo( + () => ({ + state, + isLoading, + isError, + onRetry: () => void refetch(), + onRepay: confirmRepay, + isRepaying, + error, + onClearError: clearError, + }), + [state, isLoading, isError, refetch, confirmRepay, isRepaying, error, clearError], + ); + return ( - {({ topPadding }) => ( - + {({ session, topPadding }) => ( + )} ); diff --git a/components/Card/NewCardDetails/SpendMode/BorrowPositionSheetContent.tsx b/components/Card/NewCardDetails/SpendMode/BorrowPositionSheetContent.tsx index 3227d490b..209810ce9 100644 --- a/components/Card/NewCardDetails/SpendMode/BorrowPositionSheetContent.tsx +++ b/components/Card/NewCardDetails/SpendMode/BorrowPositionSheetContent.tsx @@ -1,10 +1,19 @@ +import { useCallback, useEffect, useState } from 'react'; import { Pressable, StyleSheet, View } from 'react-native'; +import Animated, { + FadeInLeft, + FadeInRight, + FadeOutLeft, + FadeOutRight, +} from 'react-native-reanimated'; import HelpBadge from '@/components/Card/NewCardDetails/SpendMode/HelpBadge'; +import RepaySheetContent from '@/components/Card/NewCardDetails/SpendMode/RepaySheetContent'; import { BorrowedSummary } from '@/components/Card/NewCardDetails/SpendMode/SpendModePanels'; import { Text } from '@/components/ui/text'; import type { SpendModeFigures } from '@/components/Card/NewCardDetails/SpendMode/useSpendModeFigures'; +import type { CardRepayRequest, CardRepayState } from '@/hooks/useCardRepay'; /** * Vertical rhythm, measured off Figma 26134:23880 (419 × 670 on the artboard). @@ -23,34 +32,149 @@ const RISK_TO_CANCEL = 36; /** Figma puts Repay 29 below the track and 23 above the card's bottom edge. */ const TRACK_TO_REPAY = 29; +/** The same slide the spend-mode sheet swaps its panels on, so the two sheets move alike. */ +const SWAP_DURATION = 240; + +/** Everything the repay step needs, owned by the sheet so this file stays presentation. */ +export interface BorrowPositionRepay { + state: CardRepayState | null; + isLoading: boolean; + isError: boolean; + onRetry: () => void; + /** Resolves true once the repayment is on-chain, false when it did not happen. */ + onRepay: (request: CardRepayRequest) => Promise; + isRepaying: boolean; + error: string | null; + onClearError: () => void; +} + interface BorrowPositionSheetContentProps { figures: SpendModeFigures; onDismiss: () => void; - /** Opens the repay flow. Not wired yet — see the note below. */ - onRepay?: () => void; + repay: BorrowPositionRepay; + /** + * Bumped every time the sheet opens, which sends it back to the position — a sheet + * reopened after backing out of a repayment should not come back halfway through one. + */ + session: number; /** Space above the headline; sheets and the desktop modal clear different chrome. */ topPadding?: number; } +type SheetView = 'position' | 'repay'; + +/** + * The borrow-position sheet: the position itself, and the repay step behind its Repay button. + * + * Both live in one sheet rather than a second one stacked on top, because repaying is a + * continuation of reading the position, not a new task — and a sheet that slides its content + * sideways keeps the cardholder's place, where a second sheet rising over the first hides it. + * It slides like the spend-mode sheet: forward into Repay, back out of it, and back on its + * own once a repayment lands, so the updated figures are the first thing seen after one. + */ +const BorrowPositionSheetContent = ({ + figures, + onDismiss, + repay, + session, + topPadding = BORROW_POSITION_SHEET_TOP, +}: BorrowPositionSheetContentProps) => { + // `hasMoved` keeps the sheet's own entrance free of a sideways slide: only a move between + // the two views animates, never the first paint of a sheet that has just opened. + const [navigation, setNavigation] = useState<{ view: SheetView; hasMoved: boolean }>({ + view: 'position', + hasMoved: false, + }); + + useEffect(() => { + setNavigation({ view: 'position', hasMoved: false }); + }, [session]); + + const { onRepay, onClearError } = repay; + + const openRepay = useCallback(() => { + onClearError(); + setNavigation({ view: 'repay', hasMoved: true }); + }, [onClearError]); + + const backToPosition = useCallback(() => { + setNavigation({ view: 'position', hasMoved: true }); + }, []); + + const confirmRepay = useCallback( + async (request: CardRepayRequest) => { + const repaid = await onRepay(request); + if (repaid) backToPosition(); + return repaid; + }, + [backToPosition, onRepay], + ); + + const { view, hasMoved } = navigation; + + return ( + // Clipped, so the view sliding out does not paint over the sheet's rounded edge. + // + // Each view's directions are fixed rather than read from the last press. The position + // sits to the left of Repay and is only ever left by going forward into it, and Repay is + // only ever left by going back, so every direction is known in advance. It also has to + // be: a removed view animates out with the `exiting` it was last RENDERED with, which a + // direction held in state would still have at its previous value. + + {view === 'position' ? ( + + + + ) : ( + + + + )} + + ); +}; + +interface PositionViewProps { + figures: SpendModeFigures; + onDismiss: () => void; + onRepay: () => void; + topPadding: number; +} + /** * The borrow position (Figma 26134:23880): what is still available to borrow, at * what rate, the loan itself with a Repay button, and the liquidation warning. * - * Every figure is live. **Repay is not** — it still just closes the sheet, which is - * the one thing on this screen that does not do what it says. Repaying needs an - * amount, a token choice and its own confirmation, and is a separate pass; until it - * lands, a cardholder who needs to reduce a position does it from the borrow flow. - * * The risk panel reads the health factor rather than always showing: the module * liquidates below 1.0, and the two bands above that are presentation — warning at * the boundary would tell someone their assets are being sold as it happens. */ -const BorrowPositionSheetContent = ({ - figures, - onDismiss, - onRepay, - topPadding = BORROW_POSITION_SHEET_TOP, -}: BorrowPositionSheetContentProps) => ( +const PositionView = ({ figures, onDismiss, onRepay, topPadding }: PositionViewProps) => ( Available to borrow @@ -79,7 +203,7 @@ const BorrowPositionSheetContent = ({ accessibilityLabel="Repay" accessibilityRole="button" className="bg-brand transition-all active:scale-95 active:opacity-80" - onPress={onRepay ?? onDismiss} + onPress={onRepay} style={styles.repay} > Repay @@ -121,6 +245,7 @@ const BorrowPositionSheetContent = ({ ); const styles = StyleSheet.create({ + stage: { overflow: 'hidden' }, // 17pt inset either side, which is the 385pt content block on the 419pt frame. body: { paddingHorizontal: 17 }, apy: { diff --git a/components/Card/NewCardDetails/SpendMode/CardBottomSheet.native.tsx b/components/Card/NewCardDetails/SpendMode/CardBottomSheet.native.tsx index f50c41d64..9f7c458ea 100644 --- a/components/Card/NewCardDetails/SpendMode/CardBottomSheet.native.tsx +++ b/components/Card/NewCardDetails/SpendMode/CardBottomSheet.native.tsx @@ -61,6 +61,9 @@ const CardBottomSheet = ({ ref={sheetRef} enableDynamicSizing enablePanDownToClose + // The repay step has a field. Without this the sheet stays lifted after the keyboard + // is dismissed, floating over a gap where the keyboard used to be. + keyboardBlurBehavior="restore" onDismiss={dismiss} backdropComponent={renderBackdrop} backgroundStyle={{ diff --git a/components/Card/NewCardDetails/SpendMode/RepaySheetContent.tsx b/components/Card/NewCardDetails/SpendMode/RepaySheetContent.tsx new file mode 100644 index 000000000..1953cf6e4 --- /dev/null +++ b/components/Card/NewCardDetails/SpendMode/RepaySheetContent.tsx @@ -0,0 +1,574 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { ActivityIndicator, Pressable, StyleSheet, TextStyle, View } from 'react-native'; +import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'; +import { Check, ChevronDown, ChevronLeft } from 'lucide-react-native'; + +import SheetTextInput from '@/components/Card/NewCardDetails/SpendMode/SheetTextInput'; +import RenderTokenIcon from '@/components/RenderTokenIcon'; +import { Text } from '@/components/ui/text'; +import { formatUsd } from '@/constants/cardSpendModule'; +import getTokenIcon from '@/lib/getTokenIcon'; +import { + formatRepayTokenAmount, + maxRepayableUsd, + parseUsdAmountText, + quoteRepay, + type RepaySource, + sanitizeUsdAmountText, + usdToAmountText, +} from '@/lib/utils/cardRepay'; + +import type { CardRepayRequest, CardRepayState } from '@/hooks/useCardRepay'; + +/** + * Vertical rhythm. Each value is the gap from the element above it, on the same 17pt inset + * and card language as the borrow position this step slides in from. + */ +const HEADER_TO_SUMMARY = 35; +const CARD_GAP = 12; +const INPUT_TO_NOTE = 16; +/** The note's `leading-[18px]`. */ +const NOTE_LINE_HEIGHT = 18; +const NOTE_TO_ACTION = 28; +const ACTION_GAP = 12; +/** Space held for the body while the first read is in flight, so the sheet does not jump. */ +const LOADING_HEIGHT = 260; +/** How far the Repay button fades back while it cannot be pressed. Matches the mode sheet. */ +const DISABLED_DIM = 0.45; +const PICKER_FADE_IN = 180; +const PICKER_FADE_OUT = 120; + +const RED = '#D96167'; + +interface RepaySheetContentProps { + state: CardRepayState | null; + isLoading: boolean; + isError: boolean; + onRetry: () => void; + /** Resolves true once the repayment is on-chain, false when it did not happen. */ + onRepay: (request: CardRepayRequest) => Promise; + isRepaying: boolean; + /** Why the last attempt failed, or null. */ + error: string | null; + onClearError: () => void; + /** Back to the borrow position. */ + onBack: () => void; + /** Close the sheet. */ + onDismiss: () => void; + topPadding: number; +} + +/** + * The repay step of the borrow-position sheet: how much, paid with what, and one button. + * + * The source list mixes the two ways the module can be paid. Wallet rows are tender tokens the + * Safe holds loose; collateral rows spend the soUSD already escrowed against the loan, which + * is how someone with nothing loose closes a position. The same token can appear twice, so + * every row and the balance line say which of the two it is. + * + * Nothing here decides what a repayment does — `quoteRepay` does, and the hook runs it again + * against a fresh read before signing — so the figure on the button is the one that gets sent. + */ +const RepaySheetContent = ({ + state, + isLoading, + isError, + onRetry, + onRepay, + isRepaying, + error, + onClearError, + onBack, + onDismiss, + topPadding, +}: RepaySheetContentProps) => { + const [sourceId, setSourceId] = useState(null); + const [text, setText] = useState(''); + const [isMax, setIsMax] = useState(false); + const [isPickerOpen, setIsPickerOpen] = useState(false); + + const sources = useMemo(() => state?.sources ?? [], [state]); + const source = sources.find(candidate => candidate.id === sourceId) ?? null; + + // The most useful source until the cardholder picks one, and again if the one they picked + // disappears from a refreshed read. + useEffect(() => { + if (sources.length === 0) return; + if (!sourceId || !sources.some(candidate => candidate.id === sourceId)) { + setSourceId(sources[0].id); + } + }, [sources, sourceId]); + + const debtUsd = state?.debtUsd ?? 0n; + const amountUsd = parseUsdAmountText(text); + + const quote = useMemo( + () => + state && source + ? quoteRepay({ + debtUsd: state.debtUsd, + borrowApyPerSecond: state.borrowApyPerSecond, + source, + collateral: state.collateral, + amountUsd, + isMax, + }) + : null, + [state, source, amountUsd, isMax], + ); + + const handleChangeText = useCallback( + (next: string) => { + setText(sanitizeUsdAmountText(next)); + setIsMax(false); + if (error) onClearError(); + }, + [error, onClearError], + ); + + const fillMax = useCallback( + (target: RepaySource) => { + setText(usdToAmountText(maxRepayableUsd(debtUsd, target))); + setIsMax(true); + if (error) onClearError(); + }, + [debtUsd, error, onClearError], + ); + + const handleSelect = useCallback( + (next: RepaySource) => { + setSourceId(next.id); + setIsPickerOpen(false); + // MAX means "as much as this source can", so it follows the source rather than keeping + // a figure worked out for the previous one. + if (isMax) fillMax(next); + if (error) onClearError(); + }, + [error, fillMax, isMax, onClearError], + ); + + const canSubmit = quote?.ok === true && !isRepaying; + + const handleRepay = useCallback(() => { + if (!canSubmit || !source) return; + void onRepay({ sourceId: source.id, amountUsd: isMax ? null : amountUsd, isMax }); + }, [amountUsd, canSubmit, isMax, onRepay, source]); + + const note = describeQuote(quote, source, error); + + return ( + + + + + + Repay + + + {!state ? ( + + {isLoading ? ( + + ) : ( + <> + + {isError ? 'Could not load your loan.' : 'Nothing to repay.'} + + {isError ? ( + + Try again + + ) : null} + + )} + + ) : ( + <> + + + + + + + + + + Amount to repay + + + + $ + + + + + + + + Repay with + + setIsPickerOpen(open => !open)} + style={styles.selector} + > + {source ? ( + <> + + + {source.displaySymbol} + + + ) : ( + Choose + )} + + + + + + + + {source ? describeBalance(source) : ' '} + + source && fillMax(source)} + style={[styles.max, isMax && styles.maxActive]} + > + + MAX + + + + + + {isPickerOpen ? ( + + {sources.map(option => ( + handleSelect(option)} + /> + ))} + + ) : null} + + {note ? ( + + {note.text} + + ) : null} + + + {isRepaying ? : null} + + {isRepaying ? 'Confirming…' : actionLabel(quote)} + + + + )} + + + Cancel + + + ); +}; + +const SummaryRow = ({ label, value }: { label: string; value: string }) => ( + + {label} + {value} + +); + +interface SourceRowProps { + source: RepaySource; + isSelected: boolean; + onPress: () => void; +} + +/** One row of the picker. A source that cannot be used still shows, and says why. */ +const SourceRow = ({ source, isSelected, onPress }: SourceRowProps) => { + const isUsable = source.unavailableReason === null; + const where = source.kind === 'wallet' ? 'Wallet' : 'Collateral'; + + return ( + + + + + {source.displaySymbol} + + + {source.unavailableReason ?? where} + + + + + {formatUsd(source.valueUsd)} + + + {formatRepayTokenAmount(source.balance, source.decimals)} {source.displaySymbol} + + + {isSelected ? : null} + + ); +}; + +/** "In wallet: 3,676.84 soUSD ($3,974.28)" */ +const describeBalance = (source: RepaySource): string => { + const where = source.kind === 'wallet' ? 'In wallet' : 'Collateral'; + return `${where}: ${formatRepayTokenAmount(source.balance, source.decimals)} ${source.displaySymbol} (${formatUsd(source.valueUsd)})`; +}; + +const actionLabel = (quote: ReturnType | null): string => { + if (!quote?.ok) return 'Repay'; + return quote.isFull ? 'Repay all' : `Repay ${formatUsd(quote.repayUsd)}`; +}; + +/** + * The one line under the input. A failure from the last attempt wins, then a reason the + * figure cannot be repaid, then what a valid repayment will do beyond lowering the debt. + */ +const describeQuote = ( + quote: ReturnType | null, + source: RepaySource | null, + error: string | null, +): { text: string; isError: boolean } | null => { + if (error) return { text: error, isError: true }; + if (!quote) return null; + if (!quote.ok) return quote.reason ? { text: quote.reason, isError: true } : null; + + if (quote.isFull) { + if (quote.returnedCollateral.length === 0) return { text: 'Closes your loan.', isError: false }; + const returned = quote.returnedCollateral + .map(item => `${formatRepayTokenAmount(item.amount, item.decimals)} ${item.displaySymbol}`) + .join(' and '); + return { text: `Closes your loan. ${returned} goes back to your wallet.`, isError: false }; + } + + if (source?.kind === 'collateral') { + return { text: `Paid from the ${source.displaySymbol} securing your loan.`, isError: false }; + } + + return null; +}; + +/** + * The amount field. Styled by hand rather than by class, because on native it is Gorhom's + * input and not a component NativeWind knows how to style. The face is the one + * `font-semibold` resolves to everywhere else. + */ +const amountInputStyle: TextStyle = { + color: '#FFFFFF', + flex: 1, + fontFamily: 'MonaSans_600SemiBold', + fontSize: 40, + includeFontPadding: false, + minWidth: 0, + paddingBottom: 0, + paddingHorizontal: 0, + paddingTop: 0, +}; + +const styles = StyleSheet.create({ + body: { paddingHorizontal: 17 }, + header: { alignItems: 'center', height: 40, justifyContent: 'center' }, + back: { + alignItems: 'center', + backgroundColor: '#2B2B2B', + borderRadius: 20, + height: 40, + justifyContent: 'center', + left: 0, + position: 'absolute', + width: 40, + }, + loading: { + alignItems: 'center', + gap: 16, + height: LOADING_HEIGHT, + justifyContent: 'center', + }, + retry: { + alignItems: 'center', + backgroundColor: '#2B2B2B', + borderRadius: 100, + height: 40, + justifyContent: 'center', + paddingHorizontal: 20, + }, + card: { backgroundColor: '#2B2B2B', borderRadius: 20, overflow: 'hidden' }, + summary: { gap: 14, marginTop: HEADER_TO_SUMMARY, paddingHorizontal: 20, paddingVertical: 20 }, + summaryRow: { alignItems: 'center', flexDirection: 'row', justifyContent: 'space-between' }, + input: { marginTop: CARD_GAP, padding: 20 }, + inputRow: { alignItems: 'flex-start', flexDirection: 'row', gap: 12 }, + amountColumn: { flex: 1, minWidth: 0 }, + amountRow: { alignItems: 'center', flexDirection: 'row', height: 52, marginTop: 8 }, + currency: { marginRight: 2 }, + withColumn: { alignItems: 'flex-end' }, + selector: { + alignItems: 'center', + backgroundColor: '#404040', + borderRadius: 100, + flexDirection: 'row', + gap: 8, + height: 40, + marginTop: 14, + paddingLeft: 8, + paddingRight: 12, + }, + balanceRow: { alignItems: 'center', flexDirection: 'row', gap: 12, marginTop: 16 }, + max: { + alignItems: 'center', + borderColor: 'rgba(255,255,255,0.3)', + borderRadius: 100, + borderWidth: 1, + height: 30, + justifyContent: 'center', + paddingHorizontal: 14, + }, + maxActive: { backgroundColor: '#FFFFFF', borderColor: '#FFFFFF' }, + picker: { marginTop: CARD_GAP, paddingVertical: 6 }, + sourceRow: { + alignItems: 'center', + flexDirection: 'row', + height: 62, + paddingLeft: 20, + paddingRight: 16, + }, + sourceRowDisabled: { opacity: 0.4 }, + sourceText: { flex: 1, gap: 4, marginLeft: 12, minWidth: 0 }, + sourceValue: { alignItems: 'flex-end', gap: 4, marginLeft: 12 }, + check: { alignItems: 'center', marginLeft: 12, width: 18 }, + note: { marginTop: INPUT_TO_NOTE }, + action: { + alignItems: 'center', + borderRadius: 100, + flexDirection: 'row', + gap: 8, + height: 50, + justifyContent: 'center', + marginTop: NOTE_TO_ACTION, + }, + // Where the button sits under a one-line note, so it does not jump as a note comes and goes + // while the cardholder types. + actionWithoutNote: { marginTop: INPUT_TO_NOTE + NOTE_LINE_HEIGHT + NOTE_TO_ACTION }, + cancel: { + alignItems: 'center', + backgroundColor: '#404040', + borderRadius: 100, + height: 50, + justifyContent: 'center', + marginTop: ACTION_GAP, + }, +}); + +export default RepaySheetContent; diff --git a/components/Card/NewCardDetails/SpendMode/SheetTextInput.native.tsx b/components/Card/NewCardDetails/SpendMode/SheetTextInput.native.tsx new file mode 100644 index 000000000..13e801511 --- /dev/null +++ b/components/Card/NewCardDetails/SpendMode/SheetTextInput.native.tsx @@ -0,0 +1,10 @@ +import { BottomSheetTextInput } from '@gorhom/bottom-sheet'; + +/** + * The native half of `SheetTextInput`: Gorhom's own input, which is what tells the sheet a + * keyboard is coming. A plain `TextInput` inside a `BottomSheetModal` leaves the sheet where + * it is and the keyboard covers the field being typed into. + */ +const SheetTextInput = BottomSheetTextInput; + +export default SheetTextInput; diff --git a/components/Card/NewCardDetails/SpendMode/SheetTextInput.tsx b/components/Card/NewCardDetails/SpendMode/SheetTextInput.tsx new file mode 100644 index 000000000..1afa5ca03 --- /dev/null +++ b/components/Card/NewCardDetails/SpendMode/SheetTextInput.tsx @@ -0,0 +1,11 @@ +import { TextInput } from 'react-native'; + +/** + * A text field for a body rendered inside `CardBottomSheet`. + * + * Plain `TextInput` on web, where the sheet is a dialog and the browser keeps a focused + * field in view on its own. Native has its own version in `SheetTextInput.native.tsx`. + */ +const SheetTextInput = TextInput; + +export default SheetTextInput; diff --git a/constants/cardSpendV2.ts b/constants/cardSpendV2.ts index 62eb36dd7..65613ef34 100644 --- a/constants/cardSpendV2.ts +++ b/constants/cardSpendV2.ts @@ -1,3 +1,4 @@ +import { USDC_STARGATE } from '@/constants/addresses'; import { ADDRESSES, EXPO_PUBLIC_CARD_SPEND_V2 } from '@/lib/config'; import type { SpendMode } from '@/components/Card/NewCardDetails/SpendMode/spendModes'; @@ -80,6 +81,18 @@ export enum SpendCohort { Both = 3, } +/** + * Tokens offered for repaying a credit position when the module cannot say itself. + * + * The module's own answer is `repayTender(token)`, and that is always read first. The build + * deployed today predates it — the call reverts — and on that build `repay` accepts every + * allowlisted token, USDT included. The tender list exists because a bare USDT peg is sound + * for valuing one bounded settlement but not for deciding how much debt a payment retires, so + * where the chain cannot answer the app offers the set the list launches with: soUSD and + * USDC.e, the accountant's own base asset. + */ +export const REPAY_TENDER_FALLBACK: readonly Address[] = [USDC_STARGATE, ADDRESSES.fuse.vault]; + /** What `ADDRESSES` holds for a contract this build has no deployment for. */ const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000' as Address; diff --git a/constants/tracking-events.ts b/constants/tracking-events.ts index 2a44bf3a1..2326cf729 100644 --- a/constants/tracking-events.ts +++ b/constants/tracking-events.ts @@ -371,6 +371,13 @@ export const TRACKING_EVENTS = { CARD_SPEND_MODE_CHANGE_COMPLETED: 'card_spend_mode_change_completed', CARD_SPEND_MODE_CHANGE_FAILED: 'card_spend_mode_change_failed', CARD_SPEND_MODE_CHANGE_CANCELLED: 'card_spend_mode_change_cancelled', + // Repaying a v2 credit position, from the wallet or from escrowed collateral. `source` + // and `is_full` on the completed event split the two paths and tell a pay-down from a + // closed loan, which also returns the remaining collateral in the same signature. + CARD_CREDIT_REPAY_PRESSED: 'card_credit_repay_pressed', + CARD_CREDIT_REPAY_COMPLETED: 'card_credit_repay_completed', + CARD_CREDIT_REPAY_FAILED: 'card_credit_repay_failed', + CARD_CREDIT_REPAY_CANCELLED: 'card_credit_repay_cancelled', // Changing the caps on an existing registration. Separate from the register funnel // because it is a returning user tuning a live card, not a new one being set up, and // the two directions are genuinely different products of the contract: a decrease diff --git a/hooks/useCardRepay.ts b/hooks/useCardRepay.ts new file mode 100644 index 000000000..e1e6ec1cd --- /dev/null +++ b/hooks/useCardRepay.ts @@ -0,0 +1,337 @@ +import { useCallback, useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { Address, erc20Abi } from 'viem'; +import { fuse } from 'viem/chains'; + +import { isCardSpendV2Configured, REPAY_TENDER_FALLBACK } from '@/constants/cardSpendV2'; +import { TRACKING_EVENTS } from '@/constants/tracking-events'; +import { CARD_SPEND_REGISTRATION_QUERY_KEY } from '@/hooks/useCardSpendRegistration'; +import useUser from '@/hooks/useUser'; +import { SolidCashModuleV2_ABI } from '@/lib/abis/SolidCashModuleV2'; +import { track } from '@/lib/analytics'; +import { ADDRESSES } from '@/lib/config'; +import { executeTransactions, USER_CANCELLED_TRANSACTION } from '@/lib/execute'; +import { + buildRepayCalls, + type EscrowedCollateral, + quoteRepay, + repayDisplaySymbol, + type RepaySource, + type ReturnedCollateral, + tokenToUsdFloor, +} from '@/lib/utils/cardRepay'; +import { publicClient } from '@/lib/wagmi'; +import { useUserStore } from '@/store/useUserStore'; + +export const CARD_REPAY_QUERY_KEY = 'cardCreditRepay'; + +const MODULE_V2 = ADDRESSES.fuse.cashModuleV2; + +/** Everything the repay screen quotes from, read in two multicalls. */ +export interface CardRepayState { + /** Outstanding debt including accrued interest, 6-decimal USD. */ + debtUsd: bigint; + /** WAD per second. Only used to size the accrual buffer on a full repayment. */ + borrowApyPerSecond: bigint; + /** + * Whether the module is enabled on the Safe. A wallet repayment goes through the module + * when it is and through an allowance when it is not. + */ + moduleEnabled: boolean; + /** Every way to pay, funded and usable ones first. */ + sources: RepaySource[]; + /** What the module holds for the Safe. A full repayment sends it back. */ + collateral: EscrowedCollateral[]; +} + +export interface CardRepayRequest { + sourceId: string; + /** The typed amount, 6-decimal USD, or null when MAX is driving it. */ + amountUsd: bigint | null; + isMax: boolean; +} + +export interface CardRepayResult { + transactionHash: string; + repayUsd: bigint; + isFull: boolean; + source: RepaySource; + returnedCollateral: ReturnedCollateral[]; +} + +/** Calls read per allowlisted token, in the order {@link readCardRepayState} unpacks them. */ +const PER_TOKEN_CALLS = 7; + +const PRICE_UNAVAILABLE = 'Its price is unavailable right now.'; + +/** + * The Safe's position and every token it could repay with. + * + * The allowlist is walked rather than hard-coded, so a token the module adds or drops shows + * up here without a release. Per-token reads may fail individually — `repayTender` does on + * the build deployed today, which predates it — and a token that cannot be read in full is + * simply not offered: quoting a repayment against a balance or price we do not have would put + * a figure on screen the module then contradicts. + */ +const readCardRepayState = async (safe: Address): Promise => { + const client = publicClient(fuse.id); + const module = { address: MODULE_V2, abi: SolidCashModuleV2_ABI } as const; + + const [tokens, debtUsd, moduleEnabled, borrowApyPerSecond] = await client.multicall({ + allowFailure: false, + contracts: [ + { ...module, functionName: 'allowedTokens' }, + { ...module, functionName: 'debtUsd', args: [safe] }, + { ...module, functionName: 'isModuleEnabledOn', args: [safe] }, + { ...module, functionName: 'borrowApyPerSecond' }, + ], + }); + + const results = await client.multicall({ + allowFailure: true, + contracts: tokens.flatMap(token => [ + { address: token, abi: erc20Abi, functionName: 'symbol' }, + { address: token, abi: erc20Abi, functionName: 'decimals' }, + { address: token, abi: erc20Abi, functionName: 'balanceOf', args: [safe] }, + { ...module, functionName: 'collateralOf', args: [safe, token] }, + { ...module, functionName: 'getPriceUsd', args: [token] }, + { ...module, functionName: 'tokenPaused', args: [token] }, + { ...module, functionName: 'repayTender', args: [token] }, + ]) as never[], + }); + + const fallbackTender = new Set(REPAY_TENDER_FALLBACK.map(address => address.toLowerCase())); + const wallet: RepaySource[] = []; + const escrowed: RepaySource[] = []; + const collateral: EscrowedCollateral[] = []; + + tokens.forEach((token, index) => { + const slice = results.slice(index * PER_TOKEN_CALLS, (index + 1) * PER_TOKEN_CALLS); + const [ + symbolRead, + decimalsRead, + balanceRead, + collateralRead, + priceRead, + pausedRead, + tenderRead, + ] = slice as { status: string; result?: unknown }[]; + + // The six reads a quote cannot do without. `repayTender` is the one allowed to fail. + if ( + [symbolRead, decimalsRead, balanceRead, collateralRead, priceRead, pausedRead].some( + read => read?.status !== 'success', + ) + ) { + return; + } + + const symbol = symbolRead.result as string; + const decimals = Number(decimalsRead.result); + const balance = balanceRead.result as bigint; + const escrowedAmount = collateralRead.result as bigint; + const [price, usable, inBand] = priceRead.result as readonly [bigint, boolean, boolean]; + const paused = pausedRead.result as boolean; + const isTender = + tenderRead?.status === 'success' + ? (tenderRead.result as boolean) + : fallbackTender.has(token.toLowerCase()); + + // Every repay path prices with the module's strict accessor, which reverts on an unusable + // price and on one above the band's ceiling. `getPriceUsd` reports both. + const priced = usable && inBand && price > 0n; + const displaySymbol = repayDisplaySymbol(symbol); + const base = { token, symbol, displaySymbol, decimals, priceUsd: priced ? price : 0n }; + + if (isTender) { + wallet.push({ + ...base, + id: `wallet:${token}`, + kind: 'wallet', + balance, + valueUsd: priced ? tokenToUsdFloor(balance, price, decimals) : 0n, + // The guardian's pause refuses tender outright; it does not gate spending collateral. + unavailableReason: paused + ? `${displaySymbol} is paused right now.` + : priced + ? null + : PRICE_UNAVAILABLE, + }); + } + + if (escrowedAmount > 0n) { + collateral.push({ ...base, amount: escrowedAmount }); + escrowed.push({ + ...base, + id: `collateral:${token}`, + kind: 'collateral', + balance: escrowedAmount, + valueUsd: priced ? tokenToUsdFloor(escrowedAmount, price, decimals) : 0n, + unavailableReason: priced ? null : PRICE_UNAVAILABLE, + }); + } + }); + + // Usable before unusable, then by what each is worth, so the default is the source that can + // repay the most. + const byUsefulness = (a: RepaySource, b: RepaySource) => { + const aUsable = a.unavailableReason === null ? 1 : 0; + const bUsable = b.unavailableReason === null ? 1 : 0; + if (aUsable !== bUsable) return bUsable - aUsable; + if (a.valueUsd !== b.valueUsd) return a.valueUsd > b.valueUsd ? -1 : 1; + return 0; + }; + + return { + debtUsd, + borrowApyPerSecond, + moduleEnabled, + sources: [...wallet, ...escrowed].sort(byUsefulness), + collateral, + }; +}; + +const cardRepayQueryOptions = (selectedUserId: string | undefined, safe: Address | undefined) => ({ + queryKey: [CARD_REPAY_QUERY_KEY, selectedUserId, safe], + queryFn: () => readCardRepayState(safe!), + retry: false, + staleTime: 15_000, +}); + +/** + * Repaying the card's credit position on `SolidCashModuleV2`. + * + * Every call is sent by the Safe itself in one user operation, so the cardholder signs once: + * the repayment, and on a full one the withdrawals that return the remaining collateral. The + * batch is also what makes a full close possible at all — `withdrawCollateral` refuses while + * any debt remains, and it is the repayment ahead of it in the same batch that clears it. + * + * The quote is recomputed against a fresh read right before signing, with the same function + * the screen used. A balance, price or debt that moved while the sheet sat open therefore + * costs a message instead of a failed user operation. + */ +export function useCardRepay({ enabled = true }: { enabled?: boolean } = {}) { + const { user, safeAA } = useUser(); + const queryClient = useQueryClient(); + const selectedUserId = useUserStore(state => state.users.find(u => u.selected)?.userId); + const [error, setError] = useState(null); + + const safeAddress = user?.safeAddress as Address | undefined; + const isEnabled = enabled && isCardSpendV2Configured() && Boolean(safeAddress); + + const query = useQuery({ + ...cardRepayQueryOptions(selectedUserId, safeAddress), + enabled: isEnabled, + }); + + const mutation = useMutation({ + mutationFn: async ({ + sourceId, + amountUsd, + isMax, + }: CardRepayRequest): Promise => { + if (!user?.suborgId || !user?.signWith || !safeAddress) { + throw new Error('Your wallet is still setting up. Please try again shortly.'); + } + + const fresh = await queryClient.fetchQuery({ + ...cardRepayQueryOptions(selectedUserId, safeAddress), + staleTime: 0, + }); + + const source = fresh.sources.find(candidate => candidate.id === sourceId); + if (!source) throw new Error('That payment option is no longer available.'); + + const quote = quoteRepay({ + debtUsd: fresh.debtUsd, + borrowApyPerSecond: fresh.borrowApyPerSecond, + source, + collateral: fresh.collateral, + amountUsd, + isMax, + }); + if (!quote.ok) throw new Error(quote.reason ?? 'Enter an amount to repay.'); + + const calls = buildRepayCalls({ + safe: safeAddress, + module: MODULE_V2, + moduleEnabled: fresh.moduleEnabled, + source, + quote, + }); + + const smartAccountClient = await safeAA(fuse, user.suborgId, user.signWith); + const result = await executeTransactions( + smartAccountClient, + calls, + 'Failed to repay your loan', + fuse, + ); + + if (result === USER_CANCELLED_TRANSACTION) { + track(TRACKING_EVENTS.CARD_CREDIT_REPAY_CANCELLED, { source: source.kind }); + return null; + } + + return { + transactionHash: result.transactionHash, + repayUsd: quote.repayUsd, + isFull: quote.isFull, + source, + returnedCollateral: quote.returnedCollateral, + }; + }, + onSuccess: result => { + if (!result) return; + // The position, the credit line and the Safe's balances all moved. + queryClient.invalidateQueries({ queryKey: [CARD_REPAY_QUERY_KEY] }); + queryClient.invalidateQueries({ queryKey: [CARD_SPEND_REGISTRATION_QUERY_KEY] }); + queryClient.invalidateQueries({ queryKey: ['tokenBalances'] }); + queryClient.invalidateQueries({ queryKey: ['cardDetails'] }); + track(TRACKING_EVENTS.CARD_CREDIT_REPAY_COMPLETED, { + source: result.source.kind, + token: result.source.displaySymbol, + repay_usd: Number(result.repayUsd) / 1_000_000, + is_full: result.isFull, + collateral_returned: result.returnedCollateral.length > 0, + transaction_hash: result.transactionHash, + }); + }, + onError: (mutationError: Error) => { + const message = mutationError?.message || 'Failed to repay your loan'; + setError(message); + track(TRACKING_EVENTS.CARD_CREDIT_REPAY_FAILED, { error: message }); + }, + }); + + /** + * Repay from the chosen source. + * + * Resolves with the result once it is on-chain, `null` when the signature prompt was + * dismissed, and rejects on a real failure — the same three outcomes as `switchMode`, for + * the same reason: a dismissed prompt is not an error to show. + */ + const repay = useCallback( + async (request: CardRepayRequest): Promise => { + setError(null); + track(TRACKING_EVENTS.CARD_CREDIT_REPAY_PRESSED, { is_max: request.isMax }); + return mutation.mutateAsync(request); + }, + [mutation], + ); + + const clearError = useCallback(() => setError(null), []); + + return { + state: query.data ?? null, + isLoading: query.isLoading, + isError: query.isError, + refetch: query.refetch, + repay, + isRepaying: mutation.isPending, + error, + clearError, + }; +} + +export default useCardRepay; diff --git a/lib/abis/SolidCashModuleV2.ts b/lib/abis/SolidCashModuleV2.ts index 79590bd4c..5e90ab1be 100644 --- a/lib/abis/SolidCashModuleV2.ts +++ b/lib/abis/SolidCashModuleV2.ts @@ -632,4 +632,135 @@ export const SolidCashModuleV2_ABI = [ outputs: [], stateMutability: 'nonpayable', }, + { + type: 'function', + name: 'repay', + inputs: [ + { + name: 'safe', + type: 'address', + internalType: 'address', + }, + { + name: 'token', + type: 'address', + internalType: 'address', + }, + { + name: 'amount', + type: 'uint256', + internalType: 'uint256', + }, + ], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'repayFromCollateral', + inputs: [ + { + name: 'safe', + type: 'address', + internalType: 'address', + }, + { + name: 'token', + type: 'address', + internalType: 'address', + }, + { + name: 'amountUsd', + type: 'uint256', + internalType: 'uint256', + }, + ], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'withdrawCollateral', + inputs: [ + { + name: 'token', + type: 'address', + internalType: 'address', + }, + { + name: 'amount', + type: 'uint256', + internalType: 'uint256', + }, + ], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'getPriceUsd', + inputs: [ + { + name: 'token', + type: 'address', + internalType: 'address', + }, + ], + outputs: [ + { + name: 'price', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'usable', + type: 'bool', + internalType: 'bool', + }, + { + name: 'inBand', + type: 'bool', + internalType: 'bool', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'tokenPaused', + inputs: [ + { + name: 'token', + type: 'address', + internalType: 'address', + }, + ], + outputs: [ + { + name: '', + type: 'bool', + internalType: 'bool', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'repayTender', + inputs: [ + { + name: 'token', + type: 'address', + internalType: 'address', + }, + ], + outputs: [ + { + name: '', + type: 'bool', + internalType: 'bool', + }, + ], + stateMutability: 'view', + }, ] as const; diff --git a/lib/utils/__tests__/cardRepay.test.ts b/lib/utils/__tests__/cardRepay.test.ts new file mode 100644 index 000000000..24812b11f --- /dev/null +++ b/lib/utils/__tests__/cardRepay.test.ts @@ -0,0 +1,329 @@ +import { decodeFunctionData, erc20Abi, getAddress, maxUint256 } from 'viem'; + +import { SolidCashModuleV2_ABI } from '@/lib/abis/SolidCashModuleV2'; +import { + bufferedDebtUsd, + buildRepayCalls, + type EscrowedCollateral, + parseUsdAmountText, + quoteRepay, + repayDisplaySymbol, + type RepaySource, + sanitizeUsdAmountText, + tokenToUsdFloor, + usdToTokenCeil, +} from '@/lib/utils/cardRepay'; + +/** + * Figures are a real position on the deployed module (Fuse, 2026-09-23): $304.77 of debt + * against 318.87 soUSD escrowed, with 3,676.84 soUSD loose in the Safe. Every call shape here + * was also run against a fork of that state, and the returned-collateral estimate below came + * out within interest-accrual dust of what the fork actually returned. + */ +// Checksummed, because that is how `decodeFunctionData` hands addresses back. +const SAFE = getAddress('0x000acc8042ea362149ce2b2709fc497b6505634e'); +const MODULE = getAddress('0xE2d4FB3d1eeD6Bdc3fD62A93ab35A33FC3c97b2B'); +const SOUSD = getAddress('0x75333830E7014e909535389a6E5b0C02aA62ca27'); +const USDC = getAddress('0xc6Bc407706B7140EE8Eef2f86F9504651b63e7f9'); + +const DEBT = 304_766_745n; +const SOUSD_PRICE = 1_080_875n; +const ESCROWED = 318_873_663n; +const LOOSE = 3_676_844_231n; +const RATE = 1_243_680_656n; + +const source = (overrides: Partial & Pick): RepaySource => { + const balance = overrides.balance ?? (overrides.kind === 'wallet' ? LOOSE : ESCROWED); + const priceUsd = overrides.priceUsd ?? SOUSD_PRICE; + return { + id: `${overrides.kind}:${SOUSD}`, + token: SOUSD, + symbol: 'soUSD', + displaySymbol: 'soUSD', + decimals: 6, + unavailableReason: null, + ...overrides, + balance, + priceUsd, + valueUsd: overrides.valueUsd ?? tokenToUsdFloor(balance, priceUsd, 6), + }; +}; + +const collateral: EscrowedCollateral[] = [ + { + token: SOUSD, + symbol: 'soUSD', + displaySymbol: 'soUSD', + decimals: 6, + priceUsd: SOUSD_PRICE, + amount: ESCROWED, + }, +]; + +const quote = ( + from: RepaySource, + amountUsd: bigint | null, + isMax = false, + debtUsd = DEBT, + held: EscrowedCollateral[] = collateral, +) => + quoteRepay({ + debtUsd, + borrowApyPerSecond: RATE, + source: from, + collateral: held, + amountUsd, + isMax, + }); + +const decodeModule = (data: `0x${string}`) => + decodeFunctionData({ abi: SolidCashModuleV2_ABI, data }); + +describe('rounding', () => { + it('values delivered tokens down and sizes taken tokens up, like the module', () => { + expect(tokenToUsdFloor(ESCROWED, SOUSD_PRICE, 6)).toBe(344_662_570n); + expect(usdToTokenCeil(DEBT, SOUSD_PRICE, 6)).toBe(281_962_989n); + // Ceil then floor never lands under the figure asked for. + const tokens = usdToTokenCeil(50_000_000n, SOUSD_PRICE, 6); + expect(tokenToUsdFloor(tokens, SOUSD_PRICE, 6)).toBeGreaterThanOrEqual(50_000_000n); + }); + + it('pads the debt for accrual and a reprint', () => { + const buffered = bufferedDebtUsd(DEBT, RATE); + expect(buffered).toBeGreaterThan(DEBT); + // Ten basis points plus an hour of interest, not more. + expect(buffered - DEBT).toBeLessThan((DEBT * 12n) / 10_000n); + }); +}); + +describe('quoteRepay', () => { + it('says nothing while the field is empty', () => { + expect(quote(source({ kind: 'wallet' }), null)).toEqual({ ok: false, reason: null }); + expect(quote(source({ kind: 'wallet' }), 0n)).toEqual({ ok: false, reason: null }); + }); + + it('refuses when there is no debt', () => { + expect(quote(source({ kind: 'wallet' }), 1_000_000n, false, 0n)).toEqual({ + ok: false, + reason: 'You have nothing to repay.', + }); + }); + + it('quotes a partial wallet repayment in token units rounded up', () => { + const result = quote(source({ kind: 'wallet' }), 50_000_000n); + expect(result).toMatchObject({ + ok: true, + isFull: false, + useMax: false, + repayUsd: 50_000_000n, + tokenAmount: 46_258_819n, + remainingDebtUsd: DEBT - 50_000_000n, + returnedCollateral: [], + }); + }); + + it('treats the on-screen cent the debt rounds up to as repaying all of it', () => { + const result = quote(source({ kind: 'wallet' }), 304_770_000n); + expect(result).toMatchObject({ ok: true, isFull: true, useMax: true, remainingDebtUsd: 0n }); + }); + + it('refuses a figure beyond what is owed rather than clamping it', () => { + expect(quote(source({ kind: 'wallet' }), 304_780_000n)).toEqual({ + ok: false, + reason: 'You owe $304.77.', + }); + }); + + it('refuses more than the source holds', () => { + const thin = source({ kind: 'wallet', balance: 10_000_000n }); + const result = quote(thin, 50_000_000n); + expect(result.ok).toBe(false); + expect(!result.ok && result.reason).toMatch(/^Not enough soUSD in your wallet/); + }); + + it('refuses a source with nothing in it', () => { + const empty = source({ + kind: 'wallet', + token: USDC, + symbol: 'USDC.e', + displaySymbol: 'USDC', + balance: 0n, + }); + expect(quote(empty, 1_000_000n)).toEqual({ + ok: false, + reason: 'You have no USDC in your wallet.', + }); + }); + + it('surfaces why a source is unavailable', () => { + const paused = source({ kind: 'wallet', unavailableReason: 'soUSD is paused right now.' }); + expect(quote(paused, 1_000_000n)).toEqual({ ok: false, reason: 'soUSD is paused right now.' }); + }); + + it('MAX from a source smaller than the debt repays all of the source and keeps the loan', () => { + const thin = source({ kind: 'wallet', balance: 100_000_000n }); + const result = quote(thin, null, true); + expect(result).toMatchObject({ ok: true, isFull: false, useMax: true, returnedCollateral: [] }); + expect(result.ok && result.remainingDebtUsd).toBe(DEBT - thin.valueUsd); + }); + + it('a full wallet repayment returns every escrowed balance', () => { + const result = quote(source({ kind: 'wallet' }), null, true); + expect(result).toMatchObject({ ok: true, isFull: true, useMax: true }); + expect(result.ok && result.returnedCollateral).toEqual([ + { token: SOUSD, displaySymbol: 'soUSD', decimals: 6, amount: ESCROWED }, + ]); + }); + + it('a full collateral repayment returns what the debt did not need', () => { + const result = quote(source({ kind: 'collateral' }), null, true); + expect(result).toMatchObject({ ok: true, isFull: true, useMax: true, tokenAmount: null }); + // 318.87 soUSD escrowed less the 281.96 the debt takes. The fork returned 36,910,659. + expect(result.ok && result.returnedCollateral).toEqual([ + { token: SOUSD, displaySymbol: 'soUSD', decimals: 6, amount: 36_910_674n }, + ]); + }); + + it('withdraws nothing when the source only just covers the debt', () => { + // Clears the debt as quoted but not the buffer: interest or a reprint could leave dust, + // and `withdrawCollateral` would then revert the whole batch. + const exact = source({ kind: 'wallet', balance: usdToTokenCeil(DEBT, SOUSD_PRICE, 6) }); + const result = quote(exact, null, true); + expect(result).toMatchObject({ ok: true, isFull: true, returnedCollateral: [] }); + }); + + it('never withdraws a balance the repayment itself exhausts', () => { + // Collateral worth the debt plus a hair: covers it, but the buffered spend exceeds it. + const escrowed = usdToTokenCeil(DEBT, SOUSD_PRICE, 6) + 1n; + const held = [{ ...collateral[0], amount: escrowed }]; + const result = quote(source({ kind: 'collateral', balance: escrowed }), null, true, DEBT, held); + expect(result).toMatchObject({ ok: true, isFull: true, returnedCollateral: [] }); + }); +}); + +describe('buildRepayCalls', () => { + it('repays a partial wallet amount through the module, with no allowance', () => { + const from = source({ kind: 'wallet' }); + const calls = buildRepayCalls({ + safe: SAFE, + module: MODULE, + moduleEnabled: true, + source: from, + quote: quote(from, 50_000_000n), + }); + + expect(calls).toHaveLength(1); + expect(calls[0].to).toBe(MODULE); + expect(decodeModule(calls[0].data)).toEqual({ + functionName: 'repayFromSafe', + args: [SAFE, SOUSD, 46_258_819n], + }); + }); + + it('closes from collateral with max and returns the rest in the same batch', () => { + const from = source({ kind: 'collateral' }); + const calls = buildRepayCalls({ + safe: SAFE, + module: MODULE, + moduleEnabled: true, + source: from, + quote: quote(from, null, true), + }); + + expect(calls.map(call => decodeModule(call.data))).toEqual([ + { functionName: 'repayFromCollateral', args: [SAFE, SOUSD, maxUint256] }, + { functionName: 'withdrawCollateral', args: [SOUSD, maxUint256] }, + ]); + }); + + it('sends a partial collateral repayment in USD', () => { + const from = source({ kind: 'collateral' }); + const calls = buildRepayCalls({ + safe: SAFE, + module: MODULE, + moduleEnabled: true, + source: from, + quote: quote(from, 100_000_000n), + }); + + expect(calls.map(call => decodeModule(call.data))).toEqual([ + { functionName: 'repayFromCollateral', args: [SAFE, SOUSD, 100_000_000n] }, + ]); + }); + + it('falls back to an exact allowance and clears it when the module is revoked', () => { + const from = source({ kind: 'wallet' }); + const calls = buildRepayCalls({ + safe: SAFE, + module: MODULE, + moduleEnabled: false, + source: from, + quote: quote(from, null, true), + }); + + expect(calls.map(call => call.to)).toEqual([SOUSD, MODULE, SOUSD, MODULE]); + expect(decodeFunctionData({ abi: erc20Abi, data: calls[0].data })).toEqual({ + functionName: 'approve', + args: [MODULE, LOOSE], + }); + expect(decodeModule(calls[1].data)).toEqual({ + functionName: 'repay', + args: [SAFE, SOUSD, LOOSE], + }); + expect(decodeFunctionData({ abi: erc20Abi, data: calls[2].data })).toEqual({ + functionName: 'approve', + args: [MODULE, 0n], + }); + expect(decodeModule(calls[3].data)).toEqual({ + functionName: 'withdrawCollateral', + args: [SOUSD, maxUint256], + }); + }); + + it('refuses to build a quote that is not ok', () => { + const from = source({ kind: 'wallet' }); + expect(() => + buildRepayCalls({ + safe: SAFE, + module: MODULE, + moduleEnabled: true, + source: from, + quote: quote(from, 304_780_000n), + }), + ).toThrow('You owe $304.77.'); + }); +}); + +describe('amount text', () => { + it('keeps digits and one decimal point, two places at most', () => { + expect(sanitizeUsdAmountText('50.567')).toBe('50.56'); + expect(sanitizeUsdAmountText('1.2.3')).toBe('1.23'); + expect(sanitizeUsdAmountText('abc')).toBe(''); + }); + + it('reads a typed comma as the decimal point', () => { + expect(sanitizeUsdAmountText('12,')).toBe('12.'); + expect(sanitizeUsdAmountText('12,5')).toBe('12.5'); + expect(sanitizeUsdAmountText('12,50')).toBe('12.50'); + }); + + it('drops a pasted comma that is grouping, rather than repaying a thousandth', () => { + expect(sanitizeUsdAmountText('$1,234.56')).toBe('1234.56'); + expect(sanitizeUsdAmountText('1,234')).toBe('1234'); + expect(sanitizeUsdAmountText('1,234,567')).toBe('1234567'); + }); + + it('parses to 6-decimal USD', () => { + expect(parseUsdAmountText('')).toBeNull(); + expect(parseUsdAmountText('.')).toBeNull(); + expect(parseUsdAmountText('50')).toBe(50_000_000n); + expect(parseUsdAmountText('50.5')).toBe(50_500_000n); + expect(parseUsdAmountText('.05')).toBe(50_000n); + }); + + it('shows the bridged USDC under the name the app uses for it', () => { + expect(repayDisplaySymbol('USDC.e')).toBe('USDC'); + expect(repayDisplaySymbol('soUSD')).toBe('soUSD'); + expect(repayDisplaySymbol('USDT')).toBe('USDT'); + }); +}); diff --git a/lib/utils/cardRepay.ts b/lib/utils/cardRepay.ts new file mode 100644 index 000000000..2e1c7d990 --- /dev/null +++ b/lib/utils/cardRepay.ts @@ -0,0 +1,408 @@ +import { Address, encodeFunctionData, erc20Abi, Hex, maxUint256 } from 'viem'; + +import { formatUsd } from '@/constants/cardSpendModule'; +import { SolidCashModuleV2_ABI } from '@/lib/abis/SolidCashModuleV2'; + +/** + * Repaying a card credit position on `SolidCashModuleV2`: what a repayment would do, and + * the calls that do it. + * + * Pure on purpose. The sheet quotes with it on every keystroke and the hook re-quotes with + * it against a fresh read right before signing, so the figure a cardholder agreed to and the + * calls that get signed come from one function rather than two that could drift. + * + * Two sources, matching the module's two Safe-callable repay paths: + * + * - **wallet** — `repayFromSafe`, paying with a tender token the Safe holds loose. The module + * moves the tokens itself, so no allowance is involved while the module is enabled. + * - **collateral** — `repayFromCollateral`, paying with soUSD already escrowed against the + * loan. Priced as collateral rather than accepted as tender, so the tender list does not + * gate it and neither does a guardian's token pause. + * + * A repayment that clears the whole debt also sends every remaining escrowed balance back to + * the wallet with `withdrawCollateral`, in the same signature: that call is only valid at zero + * debt, and the batch is what gets it there. + * + * USD figures are 6-decimal, like everything else the module reports. + */ + +const WAD = 10n ** 18n; +const ONE_CENT = 10_000n; +const MAX_BPS = 10_000n; + +/** + * Headroom a full repayment must clear before the batch also withdraws collateral. + * + * `withdrawCollateral` reverts while any debt remains, and a revert there fails the whole + * batch — repayment included. Two things move between the read this is quoted from and the + * block the operation lands in: interest keeps accruing, and a share price (soUSD) can be + * reprinted. An hour of interest covers the first many times over at any rate the module + * allows; ten basis points covers a reprint. Clearing both means the repayment is certain to + * zero the debt; missing them only means collateral stays escrowed, never a failed batch. + */ +const ACCRUAL_BUFFER_SECONDS = 3_600n; +const PRICE_BUFFER_BPS = 10n; + +export type RepaySourceKind = 'wallet' | 'collateral'; + +/** One way to pay the loan down, as the picker lists it. */ +export interface RepaySource { + /** `wallet:0x…` or `collateral:0x…`. The same token can be both. */ + id: string; + kind: RepaySourceKind; + token: Address; + /** The on-chain ticker, for the icon lookup. */ + symbol: string; + /** What the app calls it everywhere else — see {@link repayDisplaySymbol}. */ + displaySymbol: string; + decimals: number; + /** The module's own price, 6-decimal. Zero when it cannot price the token. */ + priceUsd: bigint; + /** Token units: the Safe's loose balance, or what it has escrowed. */ + balance: bigint; + /** {@link balance} at {@link priceUsd}, rounded down the way the module credits it. */ + valueUsd: bigint; + /** Why this source cannot be used right now, or null when it can. */ + unavailableReason: string | null; +} + +/** A balance the module holds for the Safe, which a full repayment returns. */ +export interface EscrowedCollateral { + token: Address; + symbol: string; + displaySymbol: string; + decimals: number; + priceUsd: bigint; + amount: bigint; +} + +export interface ReturnedCollateral { + token: Address; + displaySymbol: string; + decimals: number; + /** An estimate for display. The call itself withdraws whatever is left, exactly. */ + amount: bigint; +} + +export interface RepayQuoteInput { + debtUsd: bigint; + borrowApyPerSecond: bigint; + source: RepaySource; + collateral: readonly EscrowedCollateral[]; + /** What the cardholder typed, or null when the field is empty. */ + amountUsd: bigint | null; + /** MAX was pressed and nothing has been typed since. */ + isMax: boolean; +} + +export type RepayQuote = + | { + ok: false; + /** Why the repayment cannot go ahead, or null when there is simply nothing entered. */ + reason: string | null; + } + | { + ok: true; + /** Debt this retires, as quoted. The module prices it again at execution. */ + repayUsd: bigint; + /** Clears the whole loan. */ + isFull: boolean; + /** + * Send `type(uint256).max` and let the module size it. + * + * Both repay paths clamp a max to what is actually available and owed, so this is how a + * "repay everything" survives the interest that accrues between quote and inclusion — a + * fixed figure quoted a minute earlier would leave a dust balance behind. + */ + useMax: boolean; + /** Token units for a partial wallet repayment. Null whenever the module sizes it. */ + tokenAmount: bigint | null; + remainingDebtUsd: bigint; + /** Balances the same signature sends back to the wallet. Empty unless {@link isFull}. */ + returnedCollateral: ReturnedCollateral[]; + }; + +/** + * The name the app already uses for a token. + * + * The module allowlists USDC by its bridged contract, whose ticker is `USDC.e`, while the + * card screen and the balance list both call it USDC. Showing the on-chain ticker here would + * make the repay list the one place the same dollars go by a different name. + */ +export const repayDisplaySymbol = (symbol: string): string => + symbol.toUpperCase() === 'USDC.E' ? 'USDC' : symbol; + +/** Token units to USD, rounded down — how the module values what a payer delivers. */ +export const tokenToUsdFloor = (amount: bigint, priceUsd: bigint, decimals: number): bigint => + (amount * priceUsd) / 10n ** BigInt(decimals); + +/** USD to token units, rounded up — how the module sizes what it takes. */ +export const usdToTokenCeil = (usd: bigint, priceUsd: bigint, decimals: number): bigint => { + if (priceUsd === 0n) return 0n; + const numerator = usd * 10n ** BigInt(decimals); + return (numerator + priceUsd - 1n) / priceUsd; +}; + +/** The debt as it could stand by the time the operation lands. See {@link ACCRUAL_BUFFER_SECONDS}. */ +export const bufferedDebtUsd = (debtUsd: bigint, borrowApyPerSecond: bigint): bigint => + debtUsd + + (debtUsd * borrowApyPerSecond * ACCRUAL_BUFFER_SECONDS) / WAD + + (debtUsd * PRICE_BUFFER_BPS) / MAX_BPS + + 1n; + +const roundUpToCent = (usd: bigint): bigint => ((usd + ONE_CENT - 1n) / ONE_CENT) * ONE_CENT; + +/** The most a source can repay: the debt, or everything the source is worth if that is less. */ +export const maxRepayableUsd = (debtUsd: bigint, source: RepaySource): bigint => + source.valueUsd < debtUsd ? source.valueUsd : debtUsd; + +const whereHeld = (source: RepaySource): string => + source.kind === 'wallet' ? 'in your wallet' : 'in collateral'; + +/** + * What repaying `amountUsd` from `source` would do, or why it cannot. + * + * Typing a figure that covers the debt — the debt itself, or the cent it rounds up to on + * screen — is treated as repaying all of it. Anything beyond that cent is refused rather than + * silently clamped: someone who typed $400 against a $304.77 loan has misread something, and + * quietly repaying $304.77 would hide that from them. + */ +export const quoteRepay = ({ + debtUsd, + borrowApyPerSecond, + source, + collateral, + amountUsd, + isMax, +}: RepayQuoteInput): RepayQuote => { + if (debtUsd === 0n) return { ok: false, reason: 'You have nothing to repay.' }; + + const hasEntry = isMax || (amountUsd !== null && amountUsd > 0n); + if (!hasEntry) return { ok: false, reason: null }; + + if (source.unavailableReason) return { ok: false, reason: source.unavailableReason }; + if (source.valueUsd === 0n) { + return { ok: false, reason: `You have no ${source.displaySymbol} ${whereHeld(source)}.` }; + } + + const requested = isMax ? maxRepayableUsd(debtUsd, source) : (amountUsd as bigint); + + if (!isMax && requested > roundUpToCent(debtUsd)) { + return { ok: false, reason: `You owe ${formatUsd(debtUsd)}.` }; + } + + const coversDebt = requested >= debtUsd; + const shortfall = coversDebt ? source.valueUsd < debtUsd : requested > source.valueUsd; + if (shortfall) { + return { + ok: false, + reason: `Not enough ${source.displaySymbol} ${whereHeld(source)}. You have ${formatUsd(source.valueUsd)}.`, + }; + } + + const isFull = coversDebt; + const useMax = isMax || isFull; + const repayUsd = isFull ? debtUsd : requested; + + return { + ok: true, + repayUsd, + isFull, + useMax, + // Rounded up, so the module's rounded-down valuation still retires at least the figure + // shown. Never above the balance: `requested` is at most the balance's rounded-down value. + tokenAmount: + source.kind === 'wallet' && !useMax + ? usdToTokenCeil(requested, source.priceUsd, source.decimals) + : null, + remainingDebtUsd: debtUsd - repayUsd, + returnedCollateral: isFull + ? returnedOnFullRepay(debtUsd, borrowApyPerSecond, source, collateral) + : [], + }; +}; + +/** + * What a full repayment can safely send back, per escrowed token. + * + * Only when the source clears the debt with room to spare — see {@link bufferedDebtUsd}. A + * token the repayment itself exhausts is left out, because withdrawing a zero balance reverts + * (`AmountZero`) and would take the repayment down with it. + */ +const returnedOnFullRepay = ( + debtUsd: bigint, + borrowApyPerSecond: bigint, + source: RepaySource, + collateral: readonly EscrowedCollateral[], +): ReturnedCollateral[] => { + const buffered = bufferedDebtUsd(debtUsd, borrowApyPerSecond); + if (source.valueUsd < buffered) return []; + + return collateral.flatMap(held => { + if (held.amount === 0n) return []; + + const spendsThis = source.kind === 'collateral' && held.token === source.token; + if (!spendsThis) { + return [ + { + token: held.token, + displaySymbol: held.displaySymbol, + decimals: held.decimals, + amount: held.amount, + }, + ]; + } + + const worstCaseSpent = usdToTokenCeil(buffered, source.priceUsd, source.decimals); + if (worstCaseSpent >= held.amount) return []; + + return [ + { + token: held.token, + displaySymbol: held.displaySymbol, + decimals: held.decimals, + amount: held.amount - usdToTokenCeil(debtUsd, source.priceUsd, source.decimals), + }, + ]; + }); +}; + +export interface RepayCall { + to: Address; + data: Hex; +} + +/** + * The calls a quoted repayment becomes, in the order they must run. + * + * A wallet repayment normally goes through `repayFromSafe`, which has the module move the + * tokens out of the Safe itself. That needs the module enabled, and a cardholder who has + * revoked it still owes the debt — so for them it falls back to the permissionless `repay`, + * behind an exact allowance that is cleared again afterwards so nothing is left approved. + * + * @throws when handed a quote that cannot be executed + */ +export const buildRepayCalls = ({ + safe, + module, + moduleEnabled, + source, + quote, +}: { + safe: Address; + module: Address; + moduleEnabled: boolean; + source: RepaySource; + quote: RepayQuote; +}): RepayCall[] => { + if (!quote.ok) throw new Error(quote.reason ?? 'Enter an amount to repay.'); + + const calls: RepayCall[] = []; + + if (source.kind === 'collateral') { + calls.push({ + to: module, + data: encodeFunctionData({ + abi: SolidCashModuleV2_ABI, + functionName: 'repayFromCollateral', + args: [safe, source.token, quote.useMax ? maxUint256 : quote.repayUsd], + }), + }); + } else if (moduleEnabled) { + const amount = quote.useMax ? maxUint256 : quote.tokenAmount; + if (amount === null || amount === 0n) throw new Error('Enter an amount to repay.'); + + calls.push({ + to: module, + data: encodeFunctionData({ + abi: SolidCashModuleV2_ABI, + functionName: 'repayFromSafe', + args: [safe, source.token, amount], + }), + }); + } else { + // `repay` does not clamp to the payer's balance the way `repayFromSafe` does, so a max + // here is the whole balance. The module still pulls only what the debt needs. + const amount = quote.useMax ? source.balance : quote.tokenAmount; + if (amount === null || amount === 0n) throw new Error('Enter an amount to repay.'); + + calls.push( + { + to: source.token, + data: encodeFunctionData({ + abi: erc20Abi, + functionName: 'approve', + args: [module, amount], + }), + }, + { + to: module, + data: encodeFunctionData({ + abi: SolidCashModuleV2_ABI, + functionName: 'repay', + args: [safe, source.token, amount], + }), + }, + { + to: source.token, + data: encodeFunctionData({ abi: erc20Abi, functionName: 'approve', args: [module, 0n] }), + }, + ); + } + + for (const returned of quote.returnedCollateral) { + // Max, because the module clamps it to what is left — the exact remainder depends on the + // price and index at execution, which the quote can only estimate. + calls.push({ + to: module, + data: encodeFunctionData({ + abi: SolidCashModuleV2_ABI, + functionName: 'withdrawCollateral', + args: [returned.token, maxUint256], + }), + }); + } + + return calls; +}; + +/** + * A typed dollar amount, trimmed to what the field accepts: digits and one decimal point, + * at most two places. + * + * A comma is a decimal point when typed — it is what a European keyboard's decimal key + * produces, and it turns into a dot on screen the moment it is entered. In a pasted figure it + * is usually grouping instead, and reading "1,234.56" as $1.23 would repay a thousandth of + * what was meant. So a comma is dropped as grouping when a dot is also present, when there is + * more than one, or when exactly three digits follow it; only a bare "12,5" stays a decimal. + */ +export const sanitizeUsdAmountText = (text: string): string => { + const commaCount = (text.match(/,/g) ?? []).length; + const isGrouping = + text.includes('.') || commaCount > 1 || /^\d+,\d{3}$/.test(text.replace(/[^\d,]/g, '')); + const cleaned = (isGrouping ? text.replace(/,/g, '') : text.replace(/,/g, '.')).replace( + /[^\d.]/g, + '', + ); + const [whole, ...rest] = cleaned.split('.'); + const trimmedWhole = whole.slice(0, 12); + if (rest.length === 0) return trimmedWhole; + return `${trimmedWhole}.${rest.join('').slice(0, 2)}`; +}; + +/** A sanitized dollar amount as 6-decimal USD, or null when there is no number in it. */ +export const parseUsdAmountText = (text: string): bigint | null => { + if (!/\d/.test(text)) return null; + const [whole, fraction = ''] = text.split('.'); + return BigInt(whole || '0') * 1_000_000n + BigInt(`${fraction}00`.slice(0, 2)) * ONE_CENT; +}; + +/** 6-decimal USD as the plain figure the field shows: "304.77", no symbol or separators. */ +export const usdToAmountText = (usd: bigint): string => (Number(usd) / 1_000_000).toFixed(2); + +/** "3,676.84" — a token amount for display, to two places. */ +export const formatRepayTokenAmount = (amount: bigint, decimals: number): string => + (Number(amount) / 10 ** decimals).toLocaleString('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }); From f8e3d47f1b6cd4b5cc6fa82d292fd785c2381674 Mon Sep 17 00:00:00 2001 From: Mayank Mittal Date: Thu, 24 Sep 2026 00:31:37 +0530 Subject: [PATCH 10/16] fix: tighten card sheets on desktop web, show repay token amount MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Desktop web only (the ResponsiveModal branch of CardBottomSheet); native and the phone-width web bottom sheet render as before. - Modal is 480px with a 24px inset all round and the body drops its 17pt inset: ~432px of content instead of ~306px, so the repay amount no longer clips and labels stop wrapping. - The modal's own header row is hidden. Bodies draw a 36px close button in their first row (Repay: back · title · close), cutting ~122px of space above the first element to 24px. - MAX stays an outlined pill instead of inverting to white. - Repay shows the token amount under the dollar figure: exact for a partial wallet repayment, an estimate (≈) where the module sizes it. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../SpendMode/BorrowPositionSheet.tsx | 3 +- .../SpendMode/BorrowPositionSheetContent.tsx | 35 ++++++- .../SpendMode/CardBottomSheet.tsx | 17 +++- .../SpendMode/CardBottomSheet.types.ts | 19 ++++ .../SpendMode/RepaySheetContent.tsx | 97 ++++++++++++------- .../SpendMode/SheetIconButton.tsx | 63 ++++++++++++ .../SpendMode/SpendModeSheet.tsx | 3 +- .../SpendMode/SpendModeSheetContent.tsx | 34 +++++-- lib/utils/__tests__/cardRepay.test.ts | 42 ++++++++ lib/utils/cardRepay.ts | 39 ++++++++ 10 files changed, 304 insertions(+), 48 deletions(-) create mode 100644 components/Card/NewCardDetails/SpendMode/SheetIconButton.tsx diff --git a/components/Card/NewCardDetails/SpendMode/BorrowPositionSheet.tsx b/components/Card/NewCardDetails/SpendMode/BorrowPositionSheet.tsx index d5a662855..4850750df 100644 --- a/components/Card/NewCardDetails/SpendMode/BorrowPositionSheet.tsx +++ b/components/Card/NewCardDetails/SpendMode/BorrowPositionSheet.tsx @@ -89,13 +89,14 @@ const BorrowPositionSheet = ({ isOpen, onOpenChange }: BorrowPositionSheetProps) designTop={BORROW_POSITION_SHEET_TOP} designBottom={BORROW_POSITION_SHEET_BOTTOM} > - {({ session, topPadding }) => ( + {({ session, topPadding, presentation }) => ( )} diff --git a/components/Card/NewCardDetails/SpendMode/BorrowPositionSheetContent.tsx b/components/Card/NewCardDetails/SpendMode/BorrowPositionSheetContent.tsx index 209810ce9..f3ca04980 100644 --- a/components/Card/NewCardDetails/SpendMode/BorrowPositionSheetContent.tsx +++ b/components/Card/NewCardDetails/SpendMode/BorrowPositionSheetContent.tsx @@ -7,8 +7,13 @@ import Animated, { FadeOutRight, } from 'react-native-reanimated'; +import { + type CardSheetPresentation, + sheetBodyInset, +} from '@/components/Card/NewCardDetails/SpendMode/CardBottomSheet.types'; import HelpBadge from '@/components/Card/NewCardDetails/SpendMode/HelpBadge'; import RepaySheetContent from '@/components/Card/NewCardDetails/SpendMode/RepaySheetContent'; +import SheetIconButton from '@/components/Card/NewCardDetails/SpendMode/SheetIconButton'; import { BorrowedSummary } from '@/components/Card/NewCardDetails/SpendMode/SpendModePanels'; import { Text } from '@/components/ui/text'; @@ -59,6 +64,8 @@ interface BorrowPositionSheetContentProps { session: number; /** Space above the headline; sheets and the desktop modal clear different chrome. */ topPadding?: number; + /** The desktop modal draws its close button in the body and drops the side inset. */ + presentation?: CardSheetPresentation; } type SheetView = 'position' | 'repay'; @@ -78,6 +85,7 @@ const BorrowPositionSheetContent = ({ repay, session, topPadding = BORROW_POSITION_SHEET_TOP, + presentation = 'sheet', }: BorrowPositionSheetContentProps) => { // `hasMoved` keeps the sheet's own entrance free of a sideways slide: only a move between // the two views animates, never the first paint of a sheet that has just opened. @@ -132,6 +140,7 @@ const BorrowPositionSheetContent = ({ onDismiss={onDismiss} onRepay={openRepay} topPadding={topPadding} + presentation={presentation} /> ) : ( @@ -152,6 +161,7 @@ const BorrowPositionSheetContent = ({ onBack={backToPosition} onDismiss={onDismiss} topPadding={topPadding} + presentation={presentation} /> )} @@ -164,6 +174,7 @@ interface PositionViewProps { onDismiss: () => void; onRepay: () => void; topPadding: number; + presentation: CardSheetPresentation; } /** @@ -174,8 +185,25 @@ interface PositionViewProps { * liquidates below 1.0, and the two bands above that are presentation — warning at * the boundary would tell someone their assets are being sold as it happens. */ -const PositionView = ({ figures, onDismiss, onRepay, topPadding }: PositionViewProps) => ( - +const PositionView = ({ + figures, + onDismiss, + onRepay, + topPadding, + presentation, +}: PositionViewProps) => ( + + {/* Level with the headline rather than on a row above it: the figures are centred and + narrow, so the corner is free, and a row of its own is the height the modal was + wasting. Drawn first and absolute, so it lays out nothing. */} + {presentation === 'modal' ? ( + + ) : null} Available to borrow @@ -246,8 +274,7 @@ const PositionView = ({ figures, onDismiss, onRepay, topPadding }: PositionViewP const styles = StyleSheet.create({ stage: { overflow: 'hidden' }, - // 17pt inset either side, which is the 385pt content block on the 419pt frame. - body: { paddingHorizontal: 17 }, + close: { position: 'absolute', right: 0, zIndex: 1 }, apy: { alignItems: 'center', alignSelf: 'center', diff --git a/components/Card/NewCardDetails/SpendMode/CardBottomSheet.tsx b/components/Card/NewCardDetails/SpendMode/CardBottomSheet.tsx index 35cee921d..1cc812b78 100644 --- a/components/Card/NewCardDetails/SpendMode/CardBottomSheet.tsx +++ b/components/Card/NewCardDetails/SpendMode/CardBottomSheet.tsx @@ -45,10 +45,21 @@ const CardBottomSheet = ({ trigger={null} contentKey={contentKey} shouldAnimate={false} - contentClassName="md:max-w-[420px]" + // The modal's stock chrome is sized for phones blown up: a 40px inset on every side, + // then a 50px close button on a row of its own and a 32px gap under it, all before + // the body's own 17pt inset. At 420px that left ~306px for a body built for 385, so + // figures wrapped and the amount field clipped. Here the header is hidden — the body + // puts a compact close button in its own first row — the inset is 24px all round, + // and the body drops its 17pt inset, which leaves 432px of content. + hideHeader + // The body scrolls in its own view below, so the bottom inset can match the top one; + // the stock scroll view adds a fixed 40px under the content. + disableScroll + contentClassName="md:max-w-[480px] md:px-6 md:pt-6 md:pb-6" > - {/* The modal brings its own header padding, so the body starts flush. */} - {children({ session, topPadding: 0 })} + + {children({ session, topPadding: 0, presentation: 'modal' })} + ); } diff --git a/components/Card/NewCardDetails/SpendMode/CardBottomSheet.types.ts b/components/Card/NewCardDetails/SpendMode/CardBottomSheet.types.ts index 70e69b57f..0c1835671 100644 --- a/components/Card/NewCardDetails/SpendMode/CardBottomSheet.types.ts +++ b/components/Card/NewCardDetails/SpendMode/CardBottomSheet.types.ts @@ -1,8 +1,27 @@ import type { ReactNode } from 'react'; +/** + * Where the body is being drawn. + * + * `sheet` is the bottom sheet on native and on phone-width web: Figma's 17pt inset, and no + * close button, because the handle and a pan down close it. `modal` is the desktop dialog, + * which brings its own inset and hides its header, so the body draws a compact close button + * in its own first row instead of the dialog spending a whole row on one. + */ +export type CardSheetPresentation = 'sheet' | 'modal'; + +/** Side inset of a sheet body: the 385pt content block on Figma's 419pt frame. */ +export const SHEET_BODY_INSET = 17; + +/** The body's own side inset. The desktop modal already pads its content. */ +export const sheetBodyInset = (presentation: CardSheetPresentation = 'sheet'): number => + presentation === 'modal' ? 0 : SHEET_BODY_INSET; + export interface CardSheetBody { /** Bumped each time the sheet opens, for state that should reset per visit. */ session: number; + /** Omitted by the bottom sheets, which is `sheet`. */ + presentation?: CardSheetPresentation; /** * Padding the body should put above its first element. The drag handle already * eats into Figma's measurement on native, and the desktop modal brings its own diff --git a/components/Card/NewCardDetails/SpendMode/RepaySheetContent.tsx b/components/Card/NewCardDetails/SpendMode/RepaySheetContent.tsx index 1953cf6e4..7f66c391a 100644 --- a/components/Card/NewCardDetails/SpendMode/RepaySheetContent.tsx +++ b/components/Card/NewCardDetails/SpendMode/RepaySheetContent.tsx @@ -1,8 +1,15 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { ActivityIndicator, Pressable, StyleSheet, TextStyle, View } from 'react-native'; import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'; -import { Check, ChevronDown, ChevronLeft } from 'lucide-react-native'; +import { Check, ChevronDown } from 'lucide-react-native'; +import { + type CardSheetPresentation, + sheetBodyInset, +} from '@/components/Card/NewCardDetails/SpendMode/CardBottomSheet.types'; +import SheetIconButton, { + MODAL_CONTROL_SIZE, +} from '@/components/Card/NewCardDetails/SpendMode/SheetIconButton'; import SheetTextInput from '@/components/Card/NewCardDetails/SpendMode/SheetTextInput'; import RenderTokenIcon from '@/components/RenderTokenIcon'; import { Text } from '@/components/ui/text'; @@ -14,6 +21,7 @@ import { parseUsdAmountText, quoteRepay, type RepaySource, + repayTokenEstimate, sanitizeUsdAmountText, usdToAmountText, } from '@/lib/utils/cardRepay'; @@ -25,6 +33,9 @@ import type { CardRepayRequest, CardRepayState } from '@/hooks/useCardRepay'; * and card language as the borrow position this step slides in from. */ const HEADER_TO_SUMMARY = 35; +const HEADER_HEIGHT = 40; +/** The bottom sheet's back button, as it has always been drawn there. */ +const SHEET_BACK_SIZE = 40; const CARD_GAP = 12; const INPUT_TO_NOTE = 16; /** The note's `leading-[18px]`. */ @@ -56,6 +67,11 @@ interface RepaySheetContentProps { /** Close the sheet. */ onDismiss: () => void; topPadding: number; + /** + * The desktop modal puts back, the title and close in one row, with both controls at the + * modal's compact size, and drops the side inset. + */ + presentation?: CardSheetPresentation; } /** @@ -81,6 +97,7 @@ const RepaySheetContent = ({ onBack, onDismiss, topPadding, + presentation = 'sheet', }: RepaySheetContentProps) => { const [sourceId, setSourceId] = useState(null); const [text, setText] = useState(''); @@ -155,22 +172,34 @@ const RepaySheetContent = ({ }, [amountUsd, canSubmit, isMax, onRepay, source]); const note = describeQuote(quote, source, error); + const tokenEstimate = source + ? repayTokenEstimate({ source, quote, debtUsd, amountUsd, isMax }) + : null; + const isModal = presentation === 'modal'; + const controlSize = isModal ? MODAL_CONTROL_SIZE : SHEET_BACK_SIZE; return ( - + - - - + size={controlSize} + style={[styles.headerControl, { left: 0, top: (HEADER_HEIGHT - controlSize) / 2 }]} + /> Repay + {isModal ? ( + + ) : null} {!state ? ( @@ -239,6 +268,23 @@ const RepaySheetContent = ({ value={text} /> + {/* What the dollar figure is in the token paying it. Always drawn, greyed + while empty, so the card does not grow on the first keystroke. */} + + {source + ? tokenEstimate + ? `${tokenEstimate.isExact ? '' : '≈ '}${formatRepayTokenAmount(tokenEstimate.amount, source.decimals)} ${source.displaySymbol}` + : `0.00 ${source.displaySymbol}` + : ' '} + @@ -290,17 +336,12 @@ const RepaySheetContent = ({ className="transition-all active:scale-95 active:opacity-80" disabled={!source || isRepaying} onPress={() => source && fillMax(source)} - style={[styles.max, isMax && styles.maxActive]} + // Always the same outlined pill. It used to invert to white while MAX was + // driving the figure, which read as a stuck control rather than a state — and + // the field already shows what MAX filled in. + style={styles.max} > - - MAX - + MAX @@ -479,18 +520,8 @@ const amountInputStyle: TextStyle = { }; const styles = StyleSheet.create({ - body: { paddingHorizontal: 17 }, - header: { alignItems: 'center', height: 40, justifyContent: 'center' }, - back: { - alignItems: 'center', - backgroundColor: '#2B2B2B', - borderRadius: 20, - height: 40, - justifyContent: 'center', - left: 0, - position: 'absolute', - width: 40, - }, + header: { alignItems: 'center', height: HEADER_HEIGHT, justifyContent: 'center' }, + headerControl: { position: 'absolute', zIndex: 1 }, loading: { alignItems: 'center', gap: 16, @@ -513,6 +544,7 @@ const styles = StyleSheet.create({ amountColumn: { flex: 1, minWidth: 0 }, amountRow: { alignItems: 'center', flexDirection: 'row', height: 52, marginTop: 8 }, currency: { marginRight: 2 }, + tokenEstimate: { marginTop: 4 }, withColumn: { alignItems: 'flex-end' }, selector: { alignItems: 'center', @@ -535,7 +567,6 @@ const styles = StyleSheet.create({ justifyContent: 'center', paddingHorizontal: 14, }, - maxActive: { backgroundColor: '#FFFFFF', borderColor: '#FFFFFF' }, picker: { marginTop: CARD_GAP, paddingVertical: 6 }, sourceRow: { alignItems: 'center', diff --git a/components/Card/NewCardDetails/SpendMode/SheetIconButton.tsx b/components/Card/NewCardDetails/SpendMode/SheetIconButton.tsx new file mode 100644 index 000000000..3e83b03ba --- /dev/null +++ b/components/Card/NewCardDetails/SpendMode/SheetIconButton.tsx @@ -0,0 +1,63 @@ +import { Pressable, StyleProp, ViewStyle } from 'react-native'; +import { ChevronLeft, X } from 'lucide-react-native'; + +/** The desktop modal's back and close buttons: one size, so they read as a pair in one row. */ +export const MODAL_CONTROL_SIZE = 36; + +interface SheetIconButtonProps { + icon: 'back' | 'close'; + onPress: () => void; + accessibilityLabel: string; + disabled?: boolean; + /** Diameter. The bottom sheet's back button is 40; the modal's controls are 36. */ + size?: number; + /** Placement, which is the caller's — usually absolute within a header row. */ + style?: StyleProp; +} + +/** + * The round #2B2B2B icon button the card sheets use for back and close — the same surface as + * their cards, so a control reads as part of the sheet rather than as page chrome. + */ +const SheetIconButton = ({ + icon, + onPress, + accessibilityLabel, + disabled, + size = MODAL_CONTROL_SIZE, + style, +}: SheetIconButtonProps) => { + // 22 in the 40pt back button is what the bottom sheet has always drawn; the rest scale + // with it. The cross is drawn smaller than the chevron because it fills more of its box. + const glyph = Math.round(size * (icon === 'back' ? 0.55 : 0.45)); + + return ( + + {icon === 'back' ? ( + + ) : ( + + )} + + ); +}; + +export default SheetIconButton; diff --git a/components/Card/NewCardDetails/SpendMode/SpendModeSheet.tsx b/components/Card/NewCardDetails/SpendMode/SpendModeSheet.tsx index 9493d62cc..a11b1406b 100644 --- a/components/Card/NewCardDetails/SpendMode/SpendModeSheet.tsx +++ b/components/Card/NewCardDetails/SpendMode/SpendModeSheet.tsx @@ -48,7 +48,7 @@ const SpendModeSheet = ({ designTop={SPEND_MODE_SHEET_TOP} designBottom={SPEND_MODE_SHEET_BOTTOM} > - {({ session, topPadding }) => ( + {({ session, topPadding, presentation }) => ( )} diff --git a/components/Card/NewCardDetails/SpendMode/SpendModeSheetContent.tsx b/components/Card/NewCardDetails/SpendMode/SpendModeSheetContent.tsx index 95eee74b7..10af54c29 100644 --- a/components/Card/NewCardDetails/SpendMode/SpendModeSheetContent.tsx +++ b/components/Card/NewCardDetails/SpendMode/SpendModeSheetContent.tsx @@ -14,7 +14,14 @@ import Animated, { } from 'react-native-reanimated'; import { EASE_OUT_QUINT } from '@/components/Card/NewCardDetails/heroMotion'; +import { + type CardSheetPresentation, + sheetBodyInset, +} from '@/components/Card/NewCardDetails/SpendMode/CardBottomSheet.types'; import HelpBadge from '@/components/Card/NewCardDetails/SpendMode/HelpBadge'; +import SheetIconButton, { + MODAL_CONTROL_SIZE, +} from '@/components/Card/NewCardDetails/SpendMode/SheetIconButton'; import { SpendModeBalancePanel, SpendModeBorrowedPanel, @@ -81,6 +88,8 @@ interface SpendModeSheetContentProps { onAddFunds?: () => void; /** Space above the heading; sheets and the desktop modal clear different chrome. */ topPadding?: number; + /** The desktop modal adds a close button to the heading row and drops the side inset. */ + presentation?: CardSheetPresentation; } /** @@ -104,6 +113,7 @@ const SpendModeSheetContent = ({ onDismiss, onAddFunds, topPadding = SPEND_MODE_SHEET_TOP, + presentation = 'sheet', }: SpendModeSheetContentProps) => { // The direction of travel is kept with the selection rather than derived on // render, so the swap animation always matches the tap that caused it. @@ -171,10 +181,20 @@ const SpendModeSheetContent = ({ }, [isActiveSelected, isSwitching, onConfirm, onDismiss, selected]); return ( - - - Select spend mode - + + + + Select spend mode + + {presentation === 'modal' ? ( + + ) : null} + { }); }); +describe('repayTokenEstimate', () => { + const estimate = (from: RepaySource, amountUsd: bigint | null, isMax = false) => + repayTokenEstimate({ + source: from, + quote: quote(from, amountUsd, isMax), + debtUsd: DEBT, + amountUsd, + isMax, + }); + + it('is the exact amount sent for a partial wallet repayment', () => { + expect(estimate(source({ kind: 'wallet' }), 50_000_000n)).toEqual({ + amount: 46_258_819n, + isExact: true, + }); + }); + + it('estimates what the module will size for a full or collateral repayment', () => { + expect(estimate(source({ kind: 'wallet' }), null, true)).toEqual({ + amount: 281_962_989n, + isExact: false, + }); + expect(estimate(source({ kind: 'collateral' }), 100_000_000n)).toEqual({ + amount: usdToTokenCeil(100_000_000n, SOUSD_PRICE, 6), + isExact: false, + }); + }); + + it('still converts a figure the quote refuses', () => { + const thin = source({ kind: 'wallet', balance: 10_000_000n }); + expect(estimate(thin, 50_000_000n)).toEqual({ amount: 46_258_819n, isExact: false }); + }); + + it('shows nothing without an amount or a price', () => { + expect(estimate(source({ kind: 'wallet' }), null)).toBeNull(); + expect( + estimate(source({ kind: 'wallet', priceUsd: 0n, valueUsd: 0n }), 50_000_000n), + ).toBeNull(); + }); +}); + describe('buildRepayCalls', () => { it('repays a partial wallet amount through the module, with no allowance', () => { const from = source({ kind: 'wallet' }); diff --git a/lib/utils/cardRepay.ts b/lib/utils/cardRepay.ts index 2e1c7d990..045506fea 100644 --- a/lib/utils/cardRepay.ts +++ b/lib/utils/cardRepay.ts @@ -267,6 +267,45 @@ const returnedOnFullRepay = ( }); }; +/** + * How many tokens a dollar figure is, for the line under the amount field. + * + * Exact only for a partial wallet repayment, where the quote carries the very token amount + * that gets sent. Everywhere else the module sizes it at execution — a max, a full repayment, + * anything from collateral — so the figure is the same round-up the module will apply, at + * the price read a moment ago, and is marked as an estimate. A figure the quote refuses (too + * much, say) still converts, because seeing what it is worth in tokens is part of seeing why. + * + * Null when there is nothing entered or no price to convert at. + */ +export const repayTokenEstimate = ({ + source, + quote, + debtUsd, + amountUsd, + isMax, +}: { + source: RepaySource; + quote: RepayQuote | null; + debtUsd: bigint; + amountUsd: bigint | null; + isMax: boolean; +}): { amount: bigint; isExact: boolean } | null => { + if (source.priceUsd === 0n) return null; + + if (quote?.ok) { + if (quote.tokenAmount !== null) return { amount: quote.tokenAmount, isExact: true }; + return { + amount: usdToTokenCeil(quote.repayUsd, source.priceUsd, source.decimals), + isExact: false, + }; + } + + const usd = isMax ? maxRepayableUsd(debtUsd, source) : amountUsd; + if (usd === null || usd === 0n) return null; + return { amount: usdToTokenCeil(usd, source.priceUsd, source.decimals), isExact: false }; +}; + export interface RepayCall { to: Address; data: Hex; From 20bd17219b6496b732320448d37e0305da703ea2 Mon Sep 17 00:00:00 2001 From: Mayank Mittal Date: Thu, 24 Sep 2026 10:34:16 +0530 Subject: [PATCH 11/16] feat: home spend-mode strip, re-enable card spending, drawer fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Home: "Spend mode › [toggle]" strip under the card (Figma 26134:22854) for cardholders who pass canChangeMode (Wirex, spend-mode cohort, v2 reachable). Tapping flies the card to the pane and opens the spend-mode sheet once it lands. Toggle is on for Credit/Smart, off for Cash, and only reports the mode. - Card pane: "Card spending isn't set up" banner when spending was set up and both modules are now off. Enable re-enables the module the Safe is registered on (v2 if migrated, else v1) via register(); hidden when v2 is configured but unreadable so it never guesses v1 for a migrated Safe. - Spend-mode drawer: selected pill is always white; Add funds opens WirexCardFundModal (hidden when funds can't be added); panel area holds Smart's height so the drawer no longer resizes between modes. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Card/NewCardDetails/CardDetailsPane.tsx | 101 ++++++++++- .../NewCardDetails/EnableCardSpendingCard.tsx | 102 +++++++++++ .../SpendMode/SpendModePanels.tsx | 35 ++-- .../SpendMode/SpendModeSegmentedControl.tsx | 49 ++---- .../SpendMode/SpendModeSheetContent.tsx | 60 +++++-- components/Home/NewHome/HomeScreenNew.tsx | 9 + components/Home/NewHome/HomeWalletCard.tsx | 162 +++++++++++++++++- hooks/useCardSpendRegistration.ts | 9 +- store/useCardPaneStore.ts | 15 +- 9 files changed, 468 insertions(+), 74 deletions(-) create mode 100644 components/Card/NewCardDetails/EnableCardSpendingCard.tsx diff --git a/components/Card/NewCardDetails/CardDetailsPane.tsx b/components/Card/NewCardDetails/CardDetailsPane.tsx index 60b3e969e..e84fb0bfd 100644 --- a/components/Card/NewCardDetails/CardDetailsPane.tsx +++ b/components/Card/NewCardDetails/CardDetailsPane.tsx @@ -11,6 +11,7 @@ import { } from 'react-native'; import Animated, { useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import Toast from 'react-native-toast-message'; import { LinearGradient } from 'expo-linear-gradient'; import { router } from 'expo-router'; @@ -25,6 +26,7 @@ import { } from '@/components/Card/NewCardDetails/cardHeroLayout'; import CardLinksList from '@/components/Card/NewCardDetails/CardLinksList'; import CardRevealSection from '@/components/Card/NewCardDetails/CardRevealSection'; +import EnableCardSpendingCard from '@/components/Card/NewCardDetails/EnableCardSpendingCard'; import EnableEuroSpendCard from '@/components/Card/NewCardDetails/EnableEuroSpendCard'; import { EASE_OUT_QUINT, HERO_ENTER, HeroEnter } from '@/components/Card/NewCardDetails/heroMotion'; import ManageCardSheet from '@/components/Card/NewCardDetails/ManageCardSheet'; @@ -34,13 +36,19 @@ import BorrowPositionSheet from '@/components/Card/NewCardDetails/SpendMode/Borr import SpendModeSheet from '@/components/Card/NewCardDetails/SpendMode/SpendModeSheet'; import useSpendModeFigures from '@/components/Card/NewCardDetails/SpendMode/useSpendModeFigures'; import { useCardPaneVisibility } from '@/components/Card/NewCardDetails/useCardPaneVisibility'; +import WirexCardFundModal from '@/components/Card/WirexCardFundModal'; import { usePageLeft } from '@/components/Navbar/Sidebar'; import CashbackDetailsSheet from '@/components/Rewards/NewRewards/CashbackDetailsSheet'; +import { formatUsd, onChainToUsd } from '@/constants/cardSpendModule'; +import { isCardSpendV2Configured } from '@/constants/cardSpendV2'; import { DigitalWalletType } from '@/constants/digital-wallet'; import { path } from '@/constants/path'; import { useCardDetails } from '@/hooks/useCardDetails'; import { useCardProvider } from '@/hooks/useCardProvider'; -import { CardSpendRegistrationSource } from '@/hooks/useCardSpendRegistration'; +import { + CardSpendRegistrationSource, + useCardSpendRegistration, +} from '@/hooks/useCardSpendRegistration'; import { useCardStatus } from '@/hooks/useCardStatus'; import { useCustomer } from '@/hooks/useCustomer'; import useEuroSpendEnablement from '@/hooks/useEuroSpendEnablement'; @@ -50,6 +58,7 @@ import { resolveUserCashbackRate } from '@/lib/tierCashback'; import { CardStatus } from '@/lib/types'; import { canAddFundsToCard, + canDepositToCard, canToggleCardFreeze, canWithdrawFromCard, isCustomerFundsRestricted, @@ -89,6 +98,9 @@ const CardDetailsPane = () => { const walletGuide = useCardPaneStore(state => state.walletGuide); const dismissWalletGuide = useCardPaneStore(state => state.dismissWalletGuide); const startFlight = useCardHeroStore(state => state.start); + const isHeroFlying = useCardHeroStore(state => state.active); + const spendModeRequested = useCardPaneStore(state => state.spendModeRequested); + const dismissSpendModeRequest = useCardPaneStore(state => state.dismissSpendModeRequest); // Shown here rather than on the old details route: card issuance sets this flag // and sends the user to /card/details, which on mobile now lands on this pane. @@ -118,9 +130,70 @@ const CardDetailsPane = () => { // sheet previews the three modes and never commits one. const spendModeFigures = useSpendModeFigures(); const euroSpend = useEuroSpendEnablement(); + // Its own instance, so a failed enable reports here rather than in the Manage sheet. + const spendRegistration = useCardSpendRegistration(); + /** + * Card spending was set up and both modules are now off, so the card declines everything. + * + * Only when the read can say WHICH module the Safe belongs to. A build that is configured + * for v2 but could not read it (`v2Available` false) cannot tell a migrated Safe from a v1 + * one — the registration falls back to reporting v1 — and re-enabling v1 on a migrated Safe + * would leave v2 inert while it still holds the collateral. A build with v2 switched off has + * only ever put Safes on v1, so it has nothing to be unsure about. + */ + const showsEnableSpending = + spendRegistration.isRevoked && + (spendRegistration.registration?.v2Available === true || !isCardSpendV2Configured()); + const enableCardSpending = useCallback(async () => { + const limit = spendRegistration.limit; + if (!limit) return; + try { + // Registered already, so `register` sends only `enableModule` for the module the Safe + // is registered on, and the daily figure is ignored — it is passed for the funnel. + // False means the signature prompt was dismissed: nothing changed, so say nothing. + if ( + !(await spendRegistration.register(onChainToUsd(limit.dailyLimitUsd), 'spending_banner')) + ) { + return; + } + Toast.show({ + type: 'success', + text1: 'Card spending is on', + text2: `Your card can spend up to ${formatUsd(limit.dailyLimitUsd)} a day again.`, + props: { badgeText: '' }, + }); + } catch { + // The hook recorded the reason as `error`, which the row shows in place of its subtitle. + } + }, [spendRegistration]); const [isSpendModeOpen, setIsSpendModeOpen] = useState(false); // The borrow position's own sheet, opened by tapping the card that shows it. const [isBorrowPositionOpen, setIsBorrowPositionOpen] = useState(false); + // Add funds from inside the spend-mode sheet. Its own instance of the fund modal, driven + // from here, because the actions row's instance only opens from its own trigger. + const [isSpendModeFundOpen, setIsSpendModeFundOpen] = useState(false); + // The spend-mode sheet closes first: a funding flow opened over it would leave a sheet + // behind the modal that the cardholder has to dismiss again afterwards. + // The home screen's "Spend mode" strip opens the pane and asks for this sheet. It waits + // for the card to land: a sheet rising while the card is still flying up covers the + // flight, and the two animations fight for the same frames. Guarded on the mode being + // changeable, which is what the strip itself is gated on — a request that arrives for a + // cardholder who has since fallen out of the cohort just opens the card page. + useEffect(() => { + if (!isOpen || !spendModeRequested || isHeroFlying) return; + if (spendModeFigures.canChangeMode) setIsSpendModeOpen(true); + dismissSpendModeRequest(); + }, [ + isOpen, + spendModeRequested, + isHeroFlying, + spendModeFigures.canChangeMode, + dismissSpendModeRequest, + ]); + const openFundsFromSpendMode = useCallback(() => { + setIsSpendModeOpen(false); + setIsSpendModeFundOpen(true); + }, []); // Stable identities: the reveal section folds its opener into the memoised toggle // handler, which would be rebuilt on every render of this pane otherwise. const openSpendSheet = useCallback(() => setSpendSheetSource('spending_sheet'), []); @@ -162,6 +235,7 @@ const CardDetailsPane = () => { setIsAddToWalletOpen(false); setIsSpendModeOpen(false); setIsBorrowPositionOpen(false); + setIsSpendModeFundOpen(false); }, [isOpen]); const isCardFrozen = cardDetails?.status === CardStatus.FROZEN; @@ -275,6 +349,18 @@ const CardDetailsPane = () => { canAddFunds={canAddFundsToCard(fundsAccess)} /> + {/* In the spend-mode row's place, which cannot show at the same time: that row needs + card spending to be on, and this one is only here while it is off. */} + {showsEnableSpending ? ( + + + + ) : null} {/* Hidden outright rather than shown inert while the card has only one way to be funded. Until this build can reach the v2 module there is nothing to change to, and a "Spend mode: Cash [Change]" row that cannot change anything is worse than @@ -369,6 +455,19 @@ const CardDetailsPane = () => { isOpen={isOpen && isSpendModeOpen} onOpenChange={setIsSpendModeOpen} activeMode={spendModeFigures.mode} + // Same gate as the actions row's Add funds, so the two never disagree about whether + // funds can move right now. Spend modes are Wirex-only, and a Wirex card is funded + // through its Safe (`WirexCardFundModal`) rather than by deposit; the provider check + // just keeps this from ever opening the wrong flow. + onAddFunds={ + canAddFundsToCard(fundsAccess) && !canDepositToCard(provider) + ? openFundsFromSpendMode + : undefined + } + /> + void; +} + +/** + * "Card spending isn't set up" — shown when neither spend module is enabled on a Safe that set + * card spending up before. + * + * That state comes from the Safe itself: registration is permanent, but module consent can be + * withdrawn at any time, from "Turn card spending off" or from any Safe client. The card then + * declines everything, and the spend-mode row disappears with it, since there is no mode to + * change while nothing can spend. This row is what is left in its place. + * + * "Enable" puts back the module the Safe is registered on — v2 once it has migrated, v1 + * otherwise — with the limits it already had. Which one is `useCardSpendRegistration`'s + * `moduleAddress`, and its `register` sends only `enableModule` for a Safe already registered; + * this is presentation. + * + * Laid out as `EnableEuroSpendCard` is: a 23px card, 17px side inset, two lines of copy left + * and the action right. + */ +const EnableCardSpendingCard = ({ + isEnabling, + isPaused, + error, + onEnable, +}: EnableCardSpendingCardProps) => { + const isDisabled = isEnabling || isPaused; + const subtitle = + error ?? + (isPaused + ? 'Card spending is paused right now' + : 'Your card will decline payments until you enable it'); + + return ( + + + + {"Card spending isn't set up"} + + + {subtitle} + + + + {isEnabling ? ( + + ) : ( + Enable + )} + + + ); +}; + +const styles = StyleSheet.create({ + // The same box as `EnableEuroSpendCard`. + row: { + alignItems: 'center', + flexDirection: 'row', + justifyContent: 'space-between', + minHeight: 72, + paddingHorizontal: 17, + paddingVertical: 12, + }, + label: { flex: 1, gap: 2, paddingRight: 12 }, + button: { + alignItems: 'center', + borderRadius: 100, + height: 35, + justifyContent: 'center', + minWidth: 84, + paddingHorizontal: 12, + }, +}); + +export default EnableCardSpendingCard; diff --git a/components/Card/NewCardDetails/SpendMode/SpendModePanels.tsx b/components/Card/NewCardDetails/SpendMode/SpendModePanels.tsx index 72ad85ee5..1e07dcdb5 100644 --- a/components/Card/NewCardDetails/SpendMode/SpendModePanels.tsx +++ b/components/Card/NewCardDetails/SpendMode/SpendModePanels.tsx @@ -11,8 +11,17 @@ import type { SpendModeFigures } from '@/components/Card/NewCardDetails/SpendMod * same block shows the same number wherever it is placed. */ +/** Figma 25950:2984. Fixed, so the sheet can reserve room for any stack of these. */ +export const BALANCE_PANEL_HEIGHT = 106; +/** Figma 25961:3504. See {@link BALANCE_PANEL_HEIGHT}. */ +export const BORROWED_PANEL_HEIGHT = 126; + interface BalancePanelProps { balance: string; + /** + * Opens the add-funds flow. Without one the pill is not drawn: it used to render with + * nothing behind it, and a button that ignores the tap reads as broken. + */ onAddFunds?: () => void; } @@ -25,15 +34,17 @@ export const SpendModeBalancePanel = ({ balance, onAddFunds }: BalancePanelProps {balance} - - Add funds - + {onAddFunds ? ( + + Add funds + + ) : null} ); @@ -85,11 +96,11 @@ export const SpendModeBorrowedPanel = (figures: BorrowedSummaryFigures) => ( const styles = StyleSheet.create({ panel: { backgroundColor: '#2B2B2B', borderRadius: 20, overflow: 'hidden' }, - // Figma 25950:2984 — 106pt tall, the text block and the pill both centred in it. + // The text block and the pill both centred in the panel. balancePanel: { alignItems: 'center', flexDirection: 'row', - height: 106, + height: BALANCE_PANEL_HEIGHT, paddingLeft: 23, paddingRight: 24, }, @@ -104,7 +115,7 @@ const styles = StyleSheet.create({ }, // 126pt tall: the title at 23, the figures 35 below it, the track 15 below those. - borrowedPanel: { height: 126, paddingTop: 23 }, + borrowedPanel: { height: BORROWED_PANEL_HEIGHT, paddingTop: 23 }, borrowedRow: { flexDirection: 'row', justifyContent: 'space-between', diff --git a/components/Card/NewCardDetails/SpendMode/SpendModeSegmentedControl.tsx b/components/Card/NewCardDetails/SpendMode/SpendModeSegmentedControl.tsx index 2c203b531..6bf99839e 100644 --- a/components/Card/NewCardDetails/SpendMode/SpendModeSegmentedControl.tsx +++ b/components/Card/NewCardDetails/SpendMode/SpendModeSegmentedControl.tsx @@ -1,11 +1,6 @@ import { useCallback, useEffect, useState } from 'react'; import { LayoutChangeEvent, Pressable, StyleSheet, View } from 'react-native'; -import Animated, { - interpolateColor, - useAnimatedStyle, - useSharedValue, - withTiming, -} from 'react-native-reanimated'; +import Animated, { useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated'; import { EASE_OUT_EXPO } from '@/components/Card/NewCardDetails/heroMotion'; import { @@ -57,14 +52,13 @@ const SegmentLabels = ({ mode, value, tone }: SegmentLabelsProps) => ( ); interface SpendModeSegmentedControlProps { - /** The segment the sheet is currently showing. */ - selected: SpendMode; /** - * The mode actually in force. The pill goes solid white on it and stays - * translucent everywhere else, which is how the sheet says "this is the one - * you are on" without a second label. + * The segment the sheet is currently showing. The white pill sits on it whether or + * not it is the mode in force — the button under the panels already says which one + * that is ("Cancel" on it, "Change to …" anywhere else), and a translucent pill on a + * mode being considered read as a control that had not taken the tap. */ - activeMode: SpendMode; + selected: SpendMode; /** The figure under each segment name. */ segmentValue: Record; onSelect: (mode: SpendMode) => void; @@ -90,7 +84,6 @@ interface SpendModeSegmentedControlProps { */ const SpendModeSegmentedControl = ({ selected, - activeMode, segmentValue, onSelect, disabled = false, @@ -99,10 +92,9 @@ const SpendModeSegmentedControl = ({ const segmentWidth = trackWidth > 0 ? (trackWidth - CONTROL_INSET * 2) / SPEND_MODES.length : 0; const selectedIndex = SPEND_MODES.indexOf(selected); - const activeIndex = SPEND_MODES.indexOf(activeMode); - // 0 → 2 as the pill travels. Its position, its colour and the two label tones - // all read from this one value, so they can never arrive out of step. + // 0 → 2 as the pill travels. Its position and the label tones all read from this + // one value, so the dark copy can never arrive out of step with the pill. const position = useSharedValue(selectedIndex); useEffect(() => { @@ -120,13 +112,6 @@ const SpendModeSegmentedControl = ({ const pillStyle = useAnimatedStyle(() => ({ width: segmentWidth, transform: [{ translateX: position.value * segmentWidth }], - // Solid white only while the pill is over the mode in force; anywhere else - // it is the faint highlight the design uses for a mode being considered. - backgroundColor: interpolateColor( - Math.max(0, 1 - Math.abs(position.value - activeIndex)), - [0, 1], - ['rgba(255,255,255,0.1)', '#FFFFFF'], - ), })); return ( @@ -138,7 +123,6 @@ const SpendModeSegmentedControl = ({ mode={mode} value={segmentValue[mode]} index={index} - activeIndex={activeIndex} position={position} onSelect={onSelect} disabled={disabled} @@ -152,7 +136,6 @@ interface SegmentProps { mode: SpendMode; value: string; index: number; - activeIndex: number; position: ReturnType>; onSelect: (mode: SpendMode) => void; disabled: boolean; @@ -161,19 +144,12 @@ interface SegmentProps { /** * One third of the control. The dark copy is a second, stacked layer rather than * an animated text colour: it fades up exactly as the white pill arrives, so the - * label flips with the background instead of a step behind it. + * label flips with the background instead of a step behind it — including on the + * segments the pill only passes over on its way. */ -const Segment = ({ - mode, - value, - index, - activeIndex, - position, - onSelect, - disabled, -}: SegmentProps) => { +const Segment = ({ mode, value, index, position, onSelect, disabled }: SegmentProps) => { const darkStyle = useAnimatedStyle(() => ({ - opacity: index === activeIndex ? Math.max(0, 1 - Math.abs(position.value - index)) : 0, + opacity: Math.max(0, 1 - Math.abs(position.value - index)), })); return ( @@ -203,6 +179,7 @@ const styles = StyleSheet.create({ padding: CONTROL_INSET, }, pill: { + backgroundColor: '#FFFFFF', borderRadius: 200, bottom: CONTROL_INSET, left: CONTROL_INSET, diff --git a/components/Card/NewCardDetails/SpendMode/SpendModeSheetContent.tsx b/components/Card/NewCardDetails/SpendMode/SpendModeSheetContent.tsx index 10af54c29..e115cb562 100644 --- a/components/Card/NewCardDetails/SpendMode/SpendModeSheetContent.tsx +++ b/components/Card/NewCardDetails/SpendMode/SpendModeSheetContent.tsx @@ -23,6 +23,8 @@ import SheetIconButton, { MODAL_CONTROL_SIZE, } from '@/components/Card/NewCardDetails/SpendMode/SheetIconButton'; import { + BALANCE_PANEL_HEIGHT, + BORROWED_PANEL_HEIGHT, SpendModeBalancePanel, SpendModeBorrowedPanel, } from '@/components/Card/NewCardDetails/SpendMode/SpendModePanels'; @@ -30,6 +32,7 @@ import { SPEND_MODE_COPY, SPEND_MODES, type SpendMode, + type SpendModePanel, } from '@/components/Card/NewCardDetails/SpendMode/spendModes'; import SpendModeSegmentedControl from '@/components/Card/NewCardDetails/SpendMode/SpendModeSegmentedControl'; import { Text } from '@/components/ui/text'; @@ -60,6 +63,26 @@ export const SPEND_MODE_SHEET_TOP = 55; /** The sheet keeps this much below the button, before any safe-area inset. */ export const SPEND_MODE_SHEET_BOTTOM = 57; +const PANEL_HEIGHT: Record = { + balance: BALANCE_PANEL_HEIGHT, + borrowed: BORROWED_PANEL_HEIGHT, +}; + +/** + * Room held for the panels: the tallest mode's stack, which is Smart's two cards. + * + * Without it the sheet changed height on every tap — Cash and Credit show one card and + * Smart two — so the drawer and its button jumped under the thumb that was browsing. Held + * at the tallest, a shorter mode leaves space below its card and nothing else moves. + */ +const PANELS_MIN_HEIGHT = Math.max( + ...SPEND_MODES.map(mode => { + const panels = SPEND_MODE_COPY[mode].panels; + const cards = panels.reduce((sum, panel) => sum + PANEL_HEIGHT[panel], 0); + return cards + PANEL_GAP * Math.max(0, panels.length - 1); + }), +); + const SWAP_DURATION = 240; const ACTION_FADE_DURATION = 260; /** How far the action button fades back while the switch is in flight. */ @@ -199,7 +222,6 @@ const SpendModeSheetContent = ({ - - {SPEND_MODE_COPY[selected].panels.map((panel, index) => ( - 0 ? styles.stackedPanel : undefined}> - {panel === 'balance' ? ( - - ) : ( - - )} - - ))} - + + + {SPEND_MODE_COPY[selected].panels.map((panel, index) => ( + 0 ? styles.stackedPanel : undefined}> + {panel === 'balance' ? ( + + ) : ( + + )} + + ))} + + {error ? ( @@ -318,6 +342,8 @@ const styles = StyleSheet.create({ marginBottom: CAPTION_TO_PANEL, marginTop: CONTROL_TO_CAPTION, }, + // Not keyed, so it holds its height while the keyed panels swap inside it. + panels: { minHeight: PANELS_MIN_HEIGHT }, stackedPanel: { marginTop: PANEL_GAP }, action: { borderRadius: 100, marginTop: PANEL_TO_ACTION, overflow: 'hidden' }, actionPress: { alignItems: 'center', height: 50, justifyContent: 'center' }, diff --git a/components/Home/NewHome/HomeScreenNew.tsx b/components/Home/NewHome/HomeScreenNew.tsx index e8f3f305a..bba42021f 100644 --- a/components/Home/NewHome/HomeScreenNew.tsx +++ b/components/Home/NewHome/HomeScreenNew.tsx @@ -7,6 +7,7 @@ import { Address } from 'viem'; import { BalancePillRow } from '@/components/BalanceHeadline'; import CardDetailsPane from '@/components/Card/NewCardDetails/CardDetailsPane'; import { HERO_EXIT, HeroExit } from '@/components/Card/NewCardDetails/heroMotion'; +import { useSpendModeFigures } from '@/components/Card/NewCardDetails/SpendMode/useSpendModeFigures'; import HomePromoBanners from '@/components/Home/NewHome/HomePromoBanners'; import HomePromptCard from '@/components/Home/NewHome/HomePromptCard'; import HomeRecentActivity from '@/components/Home/NewHome/HomeRecentActivity'; @@ -73,6 +74,13 @@ export default function HomeScreenNew() { const { provider: cardProvider } = useCardProvider(); const userHasCard = hasCard(cardStatus); + // The "Spend mode" strip under the card. `canChangeMode` already carries all three + // gates — a Wirex card (the registration read only runs for Wirex), the spend-mode + // cohort, and a build that can reach v2 — the same condition the card page shows its + // own spend-mode row on, so the shortcut never leads to a sheet that is not there. + const spendModeFigures = useSpendModeFigures(); + const homeSpendMode = + userHasCard && spendModeFigures.canChangeMode ? spendModeFigures.mode : null; // Whether the card balance is a pot of its own or a view onto savings (Wirex). const cardHoldsOwnBalance = cardHoldsBalance(cardProvider); @@ -283,6 +291,7 @@ export default function HomeScreenNew() { last4={cardDetails?.card_details?.last_4} depositCompleted={depositCompleted} hasCtaBanner={isPromptReady} + spendMode={homeSpendMode} /> )} {isPromptReady && promptKey && ( diff --git a/components/Home/NewHome/HomeWalletCard.tsx b/components/Home/NewHome/HomeWalletCard.tsx index 5ede26471..0ef318339 100644 --- a/components/Home/NewHome/HomeWalletCard.tsx +++ b/components/Home/NewHome/HomeWalletCard.tsx @@ -11,7 +11,12 @@ import { CARD_TOP_SHADOW_RATIO, getCardHeroDestination, } from '@/components/Card/NewCardDetails/cardHeroLayout'; +import { HERO_EXIT, HeroExit } from '@/components/Card/NewCardDetails/heroMotion'; import NewCardArt, { NEW_CARD_ASPECT_RATIO } from '@/components/Card/NewCardDetails/NewCardArt'; +import { + SPEND_MODE_COPY, + type SpendMode, +} from '@/components/Card/NewCardDetails/SpendMode/spendModes'; import CardWaitingModal from '@/components/Home/CardWaitingModal'; import { usePageLeft } from '@/components/Navbar/Sidebar'; import { Text } from '@/components/ui/text'; @@ -42,6 +47,15 @@ interface HomeWalletCardProps { * setup prompt is still one tap from here. */ hasCtaBanner?: boolean; + /** + * The cardholder's spend mode, when the "Spend mode" strip should show under the card + * (Figma 26134:22854) — or null to leave the card on its own. + * + * The caller decides, from `useSpendModeFigures().canChangeMode`: a Wirex cardholder in + * the spend-mode cohort on a build that can reach v2. Everyone else has one way to fund + * the card and nothing to change, so the strip would be a control with nothing behind it. + */ + spendMode?: SpendMode | null; } const CARD_BODY_ASPECT_RATIO = @@ -54,6 +68,22 @@ const GET_CARD_PANEL_HEIGHT = 140; const GET_CARD_PANEL_COVER = 98; const GET_CARD_CTA_TOP_GAP = 8; const GET_CARD_LABEL_BOTTOM = 13; +/** + * The "Spend mode" strip (Figma 26134:22854): a 387x138 panel whose top 80pt sits + * behind the card, leaving a 58pt strip below it. Unlike the cardless panel above, + * these are held in points rather than scaled with the width — the strip carries a + * row of 16pt text and a toggle, and scaled up to the desktop column it grew to twice + * the height its contents need. + */ +const SPEND_MODE_PANEL_HEIGHT = 138; +const SPEND_MODE_PANEL_COVER = 80; +const SPEND_MODE_STRIP_HEIGHT = SPEND_MODE_PANEL_HEIGHT - SPEND_MODE_PANEL_COVER; +/** The toggle's bottom edge; both labels end 2pt above it (y 120 against 122). */ +const SPEND_MODE_ROW_BOTTOM = 16; +const SPEND_MODE_TOGGLE_WIDTH = 40; +const SPEND_MODE_TOGGLE_HEIGHT = 24; +const SPEND_MODE_KNOB_SIZE = 20; +const SPEND_MODE_KNOB_INSET = 2; const GET_CARD_PANEL_ASPECT_RATIO = GET_CARD_PANEL_WIDTH / GET_CARD_PANEL_HEIGHT; const CARDLESS_STACK_ASPECT_RATIO = GET_CARD_PANEL_WIDTH / @@ -79,10 +109,12 @@ const HomeWalletCard = ({ last4, depositCompleted, hasCtaBanner, + spendMode = null, }: HomeWalletCardProps) => { const start = useCardHeroStore(state => state.start); const heroActive = useCardHeroStore(state => state.active); const openPane = useCardPaneStore(state => state.open); + const openSpendMode = useCardPaneStore(state => state.openSpendMode); const isPaneOpen = useCardPaneStore(state => state.isOpen); const ref = useRef(null); const { width: windowWidth } = useWindowDimensions(); @@ -180,16 +212,23 @@ const HomeWalletCard = ({ ); } - const handlePress = () => { + /** + * Fly the card up into the pane. With `withSpendMode` the pane also puts the + * spend-mode sheet up once the card lands — the strip under the card is a shortcut to + * that sheet, and it takes the same flight so the card page it opens over is the one + * the cardholder watched arrive. + */ + const flyToPane = (withSpendMode: boolean) => { + const open = withSpendMode ? openSpendMode : openPane; const node = ref.current; if (!node) { - openPane(); + open(); return; } // Both directions must use the overlay's root coordinate system. const openFromRect = (x: number, y: number, width: number, height: number) => { if (!width || !height) { - openPane(); + open(); return; } const from = { x, y, width, height }; @@ -207,7 +246,7 @@ const HomeWalletCard = ({ }), last4 ?? '', ); - openPane(from); + open(from); }; if (Platform.OS === 'android') { @@ -222,13 +261,83 @@ const HomeWalletCard = ({ } }; + const isCardHidden = heroActive || isPaneOpen; + + if (spendMode) { + const isCreditOn = spendMode !== 'cash'; + const modeLabel = SPEND_MODE_COPY[spendMode].label; + + return ( + + + {/* Behind the card, and leaving with the section below it rather than with the + card: only the card is handed to the flight, so the strip fades and lifts + like everything else under it instead of vanishing on the tap. */} + + flyToPane(true)} + style={styles.spendModeRow} + > + + + Spend mode + + + + + + {modeLabel} + + {/* Whether the card can borrow: on for Credit and Smart, off for Cash. It + reports the mode rather than switching it — the strip opens the + spend-mode sheet, which is where a change is made and signed. */} + + + + + + + flyToPane(false)} + style={[styles.cardBodyFrame, isCardHidden && styles.hidden]} + > + + {card} + + + {/* Reserves the strip's height in the layout; taps fall through to it. */} + + + + ); + } + return ( // Hidden for as long as the pane owns the card — while it flies, and while the // pane is open — so no copy is left behind under the (background-less) pane. flyToPane(false)} className="px-4" - style={heroActive || isPaneOpen ? styles.hidden : undefined} + style={isCardHidden ? styles.hidden : undefined} > {/* The measured node is the artwork box, not this gutter — the hero flight's `from` rect has to be the same box getCardHeroDestination predicts. */} @@ -270,6 +379,47 @@ const styles = StyleSheet.create({ getCardLabel: { minHeight: 23, transform: [{ translateY: -2 }] }, getCardText: { fontFamily: 'MonaSans_500Medium', lineHeight: 23 }, getCardChevron: { height: 12, width: 7 }, + // Figma 26134:22854 — see SPEND_MODE_PANEL_HEIGHT. + spendModeStack: { position: 'relative' }, + spendModePanel: { + bottom: 0, + height: SPEND_MODE_PANEL_HEIGHT, + left: 0, + position: 'absolute', + right: 0, + }, + spendModeStripSpace: { height: SPEND_MODE_STRIP_HEIGHT }, + // Bottom-aligned so each item's bottom edge lands where Figma puts it: the toggle + // 16pt above the panel's edge, the two 23pt label boxes 2pt above that. + spendModeRow: { + alignItems: 'flex-end', + flexDirection: 'row', + justifyContent: 'space-between', + paddingBottom: SPEND_MODE_ROW_BOTTOM, + paddingLeft: 17, + paddingRight: 16, + }, + spendModeLabel: { alignItems: 'center', flexDirection: 'row', gap: 10, marginBottom: 2 }, + // The chevron's box sits 1.5pt below the label's centre line (y 105–115 in a 97–120 + // row), which is where the design draws it. + spendModeChevron: { height: 11.5, marginTop: 3, width: 6.81066 }, + spendModeValue: { alignItems: 'flex-end', flexDirection: 'row', gap: 10 }, + spendModeValueText: { marginBottom: 2 }, + spendModeToggle: { + backgroundColor: 'rgba(255,255,255,0.1)', + borderRadius: SPEND_MODE_TOGGLE_HEIGHT / 2, + height: SPEND_MODE_TOGGLE_HEIGHT, + width: SPEND_MODE_TOGGLE_WIDTH, + }, + spendModeKnob: { + backgroundColor: '#FFFFFF', + borderRadius: SPEND_MODE_KNOB_SIZE / 2, + height: SPEND_MODE_KNOB_SIZE, + position: 'absolute', + top: SPEND_MODE_KNOB_INSET, + width: SPEND_MODE_KNOB_SIZE, + }, + stripText: { fontFamily: 'MonaSans_500Medium', lineHeight: 23 }, hidden: { opacity: 0 }, }); diff --git a/hooks/useCardSpendRegistration.ts b/hooks/useCardSpendRegistration.ts index bcabbf5d8..6e8f67303 100644 --- a/hooks/useCardSpendRegistration.ts +++ b/hooks/useCardSpendRegistration.ts @@ -59,8 +59,15 @@ const MODULE_PAGE_SIZE = 50n; * handing over the PAN. Worth telling apart from `spending_sheet` — someone who came * looking for their card number is being asked a question they did not go there to * answer, and how many of them finish it is the thing to watch. + * + * `spending_banner` is the "Card spending isn't set up" row on the card pane, shown to a Safe that + * set up card spending and then had its module turned off. It only ever re-enables. */ -export type CardSpendRegistrationSource = 'spending_sheet' | 'card_activation' | 'card_reveal'; +export type CardSpendRegistrationSource = + | 'spending_sheet' + | 'card_activation' + | 'card_reveal' + | 'spending_banner'; /** The Safe's live limit state, with every matured transition already applied. */ export interface CardSpendLimit { diff --git a/store/useCardPaneStore.ts b/store/useCardPaneStore.ts index a54e8fd80..4ca434b29 100644 --- a/store/useCardPaneStore.ts +++ b/store/useCardPaneStore.ts @@ -25,7 +25,17 @@ interface CardPaneState { * plain redirect and still land the user on the instructions. */ walletGuide: DigitalWalletType | null; + /** + * Whether the pane should put the spend-mode sheet up once it has opened. Set by the + * home screen's "Spend mode" strip under the card, which is a shortcut to that sheet + * rather than to the card page itself. One-shot, like {@link walletGuide}. + */ + spendModeRequested: boolean; open: (originRect?: CardHeroRect | null) => void; + /** Open the pane — flying from `originRect` when given — and then the spend-mode sheet. */ + openSpendMode: (originRect?: CardHeroRect | null) => void; + /** Mark the spend-mode request handled, leaving the pane open. */ + dismissSpendModeRequest: () => void; /** Open the pane with the wallet guide already up on `wallet`'s tab. */ openWalletGuide: (wallet: DigitalWalletType) => void; /** Close the guide, leaving the pane itself open. */ @@ -37,7 +47,10 @@ export const useCardPaneStore = create(set => ({ isOpen: false, originRect: null, walletGuide: null, + spendModeRequested: false, open: (originRect = null) => set({ isOpen: true, originRect }), + openSpendMode: (originRect = null) => set({ isOpen: true, originRect, spendModeRequested: true }), + dismissSpendModeRequest: () => set({ spendModeRequested: false }), openWalletGuide: (wallet: DigitalWalletType) => // No origin rect: the user arrived by link, so there is no card on screen to // fly from — or back to when they close the pane. @@ -46,5 +59,5 @@ export const useCardPaneStore = create(set => ({ // Clear the guide too: it was a one-shot instruction from the link that // opened the pane, and re-opening the pane by tapping the card should not // resurrect it. - close: () => set({ isOpen: false, walletGuide: null }), + close: () => set({ isOpen: false, walletGuide: null, spendModeRequested: false }), })); From 1a73f5b396afd76b6a346dbd141ad0eea8e277a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 06:38:59 +0000 Subject: [PATCH 12/16] fix(referral): show each friend's progress against the bar they cleared The referral target moved from $75 to $150 while rewards that qualified on $75 were still in their 30-day payout delay. Their rows read "$145.63/$150 spent" next to "Qualified", which looked like a mistake and was reported as one. The referral summary now sends each row's own bar (spendTargetUsd, merchantTarget): the one a qualified friend cleared, today's for everyone else. The row uses it for the spent line, the "to go" chip and the progress bar, and falls back to the program-level target when talking to a backend that doesn't send it yet. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01C4DDGA1KnJ6ADwWHFgUX4Q --- components/Referral/ReferralFriendRow.tsx | 10 ++++++++-- lib/types.ts | 9 +++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/components/Referral/ReferralFriendRow.tsx b/components/Referral/ReferralFriendRow.tsx index fc0060ffd..7f7b93383 100644 --- a/components/Referral/ReferralFriendRow.tsx +++ b/components/Referral/ReferralFriendRow.tsx @@ -148,6 +148,7 @@ interface StagePresentation { interface ReferralFriendRowProps { item: ReferralRewardListItem; index: number; + /** Program-level targets, for rows the API sends without their own bar. */ spendTargetUsd: number; merchantTarget: number; /** Called when a countdown reaches zero, so the screen can re-fetch. */ @@ -157,10 +158,15 @@ interface ReferralFriendRowProps { export default function ReferralFriendRow({ item, index, - spendTargetUsd, - merchantTarget, + spendTargetUsd: programSpendTargetUsd, + merchantTarget: programMerchantTarget, onPayoutDue, }: ReferralFriendRowProps) { + // The row's own bar when the API sends one: a friend who qualified before the + // target moved is measured against the bar they cleared, not today's. + const spendTargetUsd = item.spendTargetUsd ?? programSpendTargetUsd; + const merchantTarget = item.merchantTarget ?? programMerchantTarget; + const countdown = useReferralCountdown( item.stage === ReferralFriendStage.REWARD_UNLOCKING ? item.payoutEtaAt : null, ); diff --git a/lib/types.ts b/lib/types.ts index c07cc6ea0..b5528c974 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -3034,6 +3034,15 @@ export interface ReferralRewardListItem { paidAt?: string; spendUsd: number; merchantCount: number; + /** + * The bar this row is measured against. For a friend who has qualified it is + * the bar they cleared, which can be lower than today's — the target moved + * from $75 to $150 while rewards were still in their payout delay, and + * "$80/$150 spent" next to "Qualified" reads as a mistake. Absent on backends + * that predate it, where the program-level target applies. + */ + spendTargetUsd?: number; + merchantTarget?: number; hasActiveCard: boolean; rewardUsd: number; /** From 38bc5f866ffe9657376858f7572391465e70fd10 Mon Sep 17 00:00:00 2001 From: Mayank Mittal Date: Thu, 24 Sep 2026 18:24:09 +0530 Subject: [PATCH 13/16] fix: quote credit headroom, not the daily limit, as available to borrow The Credit segment, Smart's figure and "Available to borrow" all read the lens's availableToBorrowUsd, which is also clamped by the Safe's remaining spending limit. A Safe with a $1,000 daily limit therefore showed $1,000 of credit beside a $2,890 line, while Cash showed the unclamped balance. Quote the line minus debt, still clamped by the per-Safe and global debt caps (totalDebtUsd added to the ABI for the latter). The lens figure stays a floor, which keeps a limits waiver correct. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../SpendMode/useSpendModeFigures.ts | 23 +++++----- hooks/useCardSpendRegistration.ts | 44 +++++++++++++++++++ lib/abis/SolidCashModuleV2.ts | 13 ++++++ 3 files changed, 69 insertions(+), 11 deletions(-) diff --git a/components/Card/NewCardDetails/SpendMode/useSpendModeFigures.ts b/components/Card/NewCardDetails/SpendMode/useSpendModeFigures.ts index fc3ac2b60..23bf67b45 100644 --- a/components/Card/NewCardDetails/SpendMode/useSpendModeFigures.ts +++ b/components/Card/NewCardDetails/SpendMode/useSpendModeFigures.ts @@ -38,11 +38,12 @@ export interface SpendModeFigures { /** "$246.50" — the whole credit line the cardholder's collateral backs, drawn or not. */ creditLimit: string; /** - * "$2,000.00" — what could actually be borrowed right now. + * "$2,000.00" — what is left of the credit line to borrow. * - * Below {@link creditLimit} by the drawn debt, and below that again whenever a cap binds: - * the lens clamps it by the per-Safe and global debt ceilings and the Safe's remaining - * spending limit, so this is the figure that can be promised without a tap contradicting it. + * {@link creditLimit} less the drawn debt, and less again when the per-Safe or global debt + * ceiling binds. Deliberately not cut by the Safe's rolling spending limit: that caps the + * card in every mode, and this figure is about the line — cut by it, the sheet read the + * daily limit ("$1,000") as what could be borrowed. */ availableToBorrow: string; /** "5.57%" — the borrow rate, compounded to an annual figure. */ @@ -144,11 +145,11 @@ export const useSpendModeFigures = (): SpendModeFigures => { // debt, so it already covers what has been drawn. const creditLine = (position?.borrowingPowerUsd ?? 0n) + (position?.prospectivePowerUsd ?? 0n); - // What could actually be drawn right now. Straight from the lens, which has already - // clamped it by the per-Safe debt cap, the global debt cap and the Safe's remaining - // spending limit — the same clamps the authorize path applies. Deriving it here as - // `creditLine - debt` would quote headroom a card tap then declines. - const available = position?.availableToBorrowUsd ?? 0n; + // What is left of the line: the lens's borrowable figure without the rolling spending + // limit, which caps the card in every mode — the cash figure is not cut by it either. + // Cut by it here alone, Credit read as the daily limit ("$1,000") beside a far larger + // line. Still clamped by both debt caps, so it never quotes debt the module would refuse. + const creditHeadroom = position?.creditHeadroomUsd ?? 0n; const apy = borrowApyPercent(borrowApyPerSecond); // Cash draws on the balance and nothing else, so there is no line to speak of. Both of // the other modes can end a transaction in debt — Smart only sometimes, but "sometimes" @@ -156,7 +157,7 @@ export const useSpendModeFigures = (): SpendModeFigures => { const canBorrow = mode === 'credit' || mode === 'smart'; const cashLabel = formatUsd(cashMicro); - const availableLabel = formatUsd(available); + const availableLabel = formatUsd(creditHeadroom); return { mode, @@ -189,7 +190,7 @@ export const useSpendModeFigures = (): SpendModeFigures => { // takes exactly one path, so the most Smart can fund is the larger of the two; the // two figures also overlap heavily, both deriving from the same balance, so adding // them would quote money the Safe does not have. - smart: formatUsd(cashMicro > available ? cashMicro : available), + smart: formatUsd(cashMicro > creditHeadroom ? cashMicro : creditHeadroom), }, hasPosition: debt > 0n, diff --git a/hooks/useCardSpendRegistration.ts b/hooks/useCardSpendRegistration.ts index 6e8f67303..7b6083d4d 100644 --- a/hooks/useCardSpendRegistration.ts +++ b/hooks/useCardSpendRegistration.ts @@ -375,6 +375,16 @@ export interface CardBorrowPosition { * number a card tap then declines. */ availableToBorrowUsd: bigint; + /** + * What the credit line leaves to draw: {@link availableToBorrowUsd} without the Safe's + * rolling spending limit. + * + * Still clamped by the per-Safe and global debt caps, which bound borrowing itself. The + * spending limit bounds the card instead, so it binds every mode alike — the cash figure + * beside this one is not reduced by it either, and a credit figure that was would quote the + * daily limit as if it were the line. + */ + creditHeadroomUsd: bigint; /** WAD. The module reports `type(uint256).max` when there is no debt. */ healthFactorWad: bigint; /** @@ -484,6 +494,31 @@ const lensCredit = ( }; }; +/** + * The lens's borrowable figure with the rolling spending limit taken back out. + * + * Re-applies the lens's other clamps rather than trying to undo one: collateral headroom, + * then the per-Safe debt cap, then the global cap's remaining room. The lens figure is a floor + * because it can only be lower, except under a limits waiver — which lifts the per-Safe cap + * here too, and which only the lens knows about. + */ +const creditHeadroom = ( + credit: NonNullable>, + caps: { maxDebtPerSafeUsd: bigint; maxGlobalDebtUsd: bigint }, + totalDebtUsd: bigint, +): bigint => { + const room = (cap: bigint, used: bigint) => (cap > used ? cap - used : 0n); + const min = (a: bigint, b: bigint) => (a < b ? a : b); + + const line = credit.borrowingPowerUsd + credit.prospectiveCollateralUsd; + const headroom = min( + min(room(line, credit.debtUsd), room(caps.maxDebtPerSafeUsd, credit.debtUsd)), + room(caps.maxGlobalDebtUsd, totalDebtUsd), + ); + + return headroom > credit.availableToBorrowUsd ? headroom : credit.availableToBorrowUsd; +}; + /** * v2's answer for this Safe, or null when there is nothing to ask. * @@ -521,6 +556,8 @@ const readV2State = async (safeAddress: Address): Promise => { { ...module, functionName: 'positionValue', args: [safeAddress] }, { ...module, functionName: 'maxCanSpendUsd', args: [safeAddress] }, { ...module, functionName: 'borrowApyPerSecond' }, + // For the global debt cap's remaining room, which bounds the credit headroom below. + { ...module, functionName: 'totalDebtUsd' }, // The lens, for the credit figures the module cannot answer on its own — above all // `prospectiveCollateralUsd`, the power the Safe's LOOSE balance would give it. // Without this the card offered Credit at $0 to a cardholder holding soUSD, because @@ -560,6 +597,7 @@ const readV2State = async (safeAddress: Address): Promise => { positionValue, , borrowApyPerSecond, + totalDebtUsd, ] = results.map(result => result.result) as never[]; const p = params as unknown as { @@ -570,6 +608,8 @@ const readV2State = async (safeAddress: Address): Promise => { defaultMonthlyLimitUsd: bigint; limitRaiseDelay: bigint; modeDelay: bigint; + maxDebtPerSafeUsd: bigint; + maxGlobalDebtUsd: bigint; }; const [powerUsd, capacityUsd, fullyPriced] = positionValue as unknown as [ bigint, @@ -611,6 +651,10 @@ const readV2State = async (safeAddress: Address): Promise => { // in both directions. Zero says "we do not know" and the UI shows a line of zero // rather than one it cannot stand behind. availableToBorrowUsd: credit?.availableToBorrowUsd ?? 0n, + // Zero without the lens for the same reason: the line it is cut from is the lens's. + creditHeadroomUsd: credit + ? creditHeadroom(credit, p, totalDebtUsd as unknown as bigint) + : 0n, healthFactorWad: healthFactorWad as unknown as bigint, fullyPriced: credit?.fullyPriced ?? fullyPriced, }, diff --git a/lib/abis/SolidCashModuleV2.ts b/lib/abis/SolidCashModuleV2.ts index 5e90ab1be..2541e7d0e 100644 --- a/lib/abis/SolidCashModuleV2.ts +++ b/lib/abis/SolidCashModuleV2.ts @@ -69,6 +69,19 @@ export const SolidCashModuleV2_ABI = [ ], stateMutability: 'view', }, + { + type: 'function', + name: 'totalDebtUsd', + inputs: [], + outputs: [ + { + name: '', + type: 'uint256', + internalType: 'uint256', + }, + ], + stateMutability: 'view', + }, { type: 'function', name: 'debtUsd', From 427a25a2b6d470360d7e1dbb98c469e97009259d Mon Sep 17 00:00:00 2001 From: Mayank Mittal Date: Thu, 24 Sep 2026 18:25:33 +0530 Subject: [PATCH 14/16] feat: set up spend-mode cohort cards on v2 when no module is enabled After the v2 redeploy every cohort Safe has all modules off and is unknown to the new core. The registration read then reported it as v1, so the "Enable" row re-enabled v1 (or registered a fresh Safe on v1), putting a cohort cardholder back on cash with Credit and Smart out of reach. For a spend-mode cohort member with no working module and no v2 registration, report the Safe as awaiting v2: the enable row shows, and the batch disables v1 if it is on, enables v2 and registers with the v1 caps clamped to v2's ceilings (v2 defaults when there were none). The read waits for the cohort answer so it cannot report v1 first, and the backend confirm is now told which module was registered. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Card/NewCardDetails/CardDetailsPane.tsx | 20 +++- hooks/useCardSpendRegistration.ts | 103 +++++++++++++++--- 2 files changed, 103 insertions(+), 20 deletions(-) diff --git a/components/Card/NewCardDetails/CardDetailsPane.tsx b/components/Card/NewCardDetails/CardDetailsPane.tsx index e84fb0bfd..a212813ce 100644 --- a/components/Card/NewCardDetails/CardDetailsPane.tsx +++ b/components/Card/NewCardDetails/CardDetailsPane.tsx @@ -142,17 +142,27 @@ const CardDetailsPane = () => { * only ever put Safes on v1, so it has nothing to be unsure about. */ const showsEnableSpending = - spendRegistration.isRevoked && - (spendRegistration.registration?.v2Available === true || !isCardSpendV2Configured()); + // A cohort card with nothing working is waiting on v2, which only a v2 read can report, + // so it needs no guard of its own. + spendRegistration.isAwaitingV2 || + (spendRegistration.isRevoked && + (spendRegistration.registration?.v2Available === true || !isCardSpendV2Configured())); const enableCardSpending = useCallback(async () => { - const limit = spendRegistration.limit; + const carried = spendRegistration.carriedLimit; + const limit = carried ?? spendRegistration.limit; if (!limit) return; try { // Registered already, so `register` sends only `enableModule` for the module the Safe // is registered on, and the daily figure is ignored — it is passed for the funnel. - // False means the signature prompt was dismissed: nothing changed, so say nothing. + // A Safe awaiting v2 is not registered there, so both carried caps are sent and + // written. False means the signature prompt was dismissed: nothing changed, so say + // nothing. if ( - !(await spendRegistration.register(onChainToUsd(limit.dailyLimitUsd), 'spending_banner')) + !(await spendRegistration.register( + onChainToUsd(limit.dailyLimitUsd), + 'spending_banner', + carried ? onChainToUsd(carried.monthlyLimitUsd) : undefined, + )) ) { return; } diff --git a/hooks/useCardSpendRegistration.ts b/hooks/useCardSpendRegistration.ts index 6e8f67303..d4aa6341d 100644 --- a/hooks/useCardSpendRegistration.ts +++ b/hooks/useCardSpendRegistration.ts @@ -20,6 +20,7 @@ import { } from '@/constants/cardSpendV2'; import { TRACKING_EVENTS } from '@/constants/tracking-events'; import { useCardProvider } from '@/hooks/useCardProvider'; +import { useCardSpendModeAccess } from '@/hooks/useCardSpendModeAccess'; import useUser from '@/hooks/useUser'; import { Safe_ABI } from '@/lib/abis/Safe'; import { SolidCashModule_ABI } from '@/lib/abis/SolidCashModule'; @@ -196,6 +197,22 @@ export interface CardSpendRegistration { * modes, so it must not present them as choices. */ v2Available: boolean; + /** + * A spend-mode cohort member with no working module, about to be set up on v2. + * + * The state every cohort Safe is in after a v2 redeploy: whatever it was registered on + * before is off, and the new core has never seen it. It is reported as the v2 Safe it is + * about to become, so every write here targets v2 — not v1, which is where a Safe with no + * v2 registration would otherwise be sent. + */ + awaitingV2: boolean; + /** v1 is enabled on the Safe. v2's `registerSafe` refuses until it is disabled. */ + legacyEnabled: boolean; + /** + * The caps an {@link awaitingV2} Safe registers with: its v1 caps where it had them, + * clamped to v2's ceilings, otherwise v2's defaults. Null in every other state. + */ + carriedLimit: Pick | null; } /** @@ -674,7 +691,11 @@ const foldMaturedRaise = (limit: RawSpendingLimit) => { * or an increase), and reading that from a render closure means signing against whatever * was true when the sheet last rendered. */ -const readCardSpendRegistration = async (safeAddress: Address): Promise => { +const readCardSpendRegistration = async ( + safeAddress: Address, + /** The cardholder is in the spend-mode cohort, so a Safe with no working module goes to v2. */ + preferV2 = false, +): Promise => { const client = publicClient(fuse.id); const module = { address: MODULE as Address, abi: SolidCashModule_ABI } as const; @@ -715,7 +736,14 @@ const readCardSpendRegistration = async (safeAddress: Address): Promise ({ - queryKey: [CARD_SPEND_REGISTRATION_QUERY_KEY, selectedUserId, safeAddress], - queryFn: () => readCardSpendRegistration(safeAddress!), + queryKey: [CARD_SPEND_REGISTRATION_QUERY_KEY, selectedUserId, safeAddress, preferV2], + queryFn: () => readCardSpendRegistration(safeAddress!, preferV2), retry: false, staleTime: 15_000, }); @@ -861,9 +896,10 @@ const readFresh = ( queryClient: QueryClient, selectedUserId: string | undefined, safeAddress: Address, + preferV2: boolean, ) => queryClient.fetchQuery({ - ...cardSpendRegistrationQueryOptions(selectedUserId, safeAddress), + ...cardSpendRegistrationQueryOptions(selectedUserId, safeAddress, preferV2), staleTime: 0, }); @@ -936,9 +972,15 @@ export function useCardSpendRegistration({ enabled }: UseCardSpendRegistrationOp // would answer the question does not exist yet. const isEnabled = (provider === CardProvider.WIREX || enabled === true) && Boolean(safeAddress); + // Which module a Safe with nothing working is set up on: v2 for the spend-mode cohort, v1 + // for everyone else. The read waits for the answer, because reading first would report a + // cohort Safe as v1 — and an enable pressed in that window would re-enable v1. + const spendModeAccess = useCardSpendModeAccess(); + const preferV2 = spendModeAccess.isEnabled && isCardSpendV2Configured(); + const query = useQuery({ - ...cardSpendRegistrationQueryOptions(selectedUserId, safeAddress), - enabled: isEnabled, + ...cardSpendRegistrationQueryOptions(selectedUserId, safeAddress, preferV2), + enabled: isEnabled && !spendModeAccess.isLoading, }); const registration = query.data ?? null; @@ -993,7 +1035,7 @@ export function useCardSpendRegistration({ enabled }: UseCardSpendRegistrationOp } if (dailyLimitUsd === undefined) throw new Error('Pick a daily limit first.'); - const fresh = await readFresh(queryClient, selectedUserId, safeAddress); + const fresh = await readFresh(queryClient, selectedUserId, safeAddress, preferV2); if (fresh.registered) throw new Error('Card spending is already set up.'); const daily = usdToOnChain(dailyLimitUsd); @@ -1036,12 +1078,35 @@ export function useCardSpendRegistration({ enabled }: UseCardSpendRegistrationOp // Building the batch from what is actually missing makes this one action cover // first-time setup and re-enabling, instead of stranding the user in either state. // Whichever generation this Safe belongs to. For a new cardholder that is v1, which - // is where everyone starts; for a migrated cardholder who revoked the module it is - // v2, and re-enabling v1 instead would silently return them to cash spending while - // v2 still held their collateral. + // is where everyone starts, unless they are in the spend-mode cohort (`awaitingV2`); + // for a migrated cardholder who revoked the module it is v2, and re-enabling v1 + // instead would silently return them to cash spending while v2 still held their + // collateral. const target = fresh.moduleAddress; + // A cohort Safe headed for v2 with v1 still switched on: v2's `registerSafe` reverts + // `LegacyModuleStillEnabled`, so v1 comes off first, in the same batch. + const prevLegacyModule = + fresh.awaitingV2 && fresh.legacyEnabled + ? await findModulePredecessor(safeAddress, MODULE as Address) + : null; + if (fresh.awaitingV2 && fresh.legacyEnabled && !prevLegacyModule) { + throw new Error('Could not read your Safe. Please try again.'); + } + const transactions = [ + ...(prevLegacyModule + ? [ + { + to: safeAddress, + data: encodeFunctionData({ + abi: Safe_ABI, + functionName: 'disableModule', + args: [prevLegacyModule, MODULE as Address], + }), + }, + ] + : []), ...(fresh.moduleEnabled ? [] : [ @@ -1085,6 +1150,7 @@ export function useCardSpendRegistration({ enabled }: UseCardSpendRegistrationOp dailyLimitUsd: fresh.registeredOnChain ? fresh.limit.dailyLimitUsd : daily, monthlyLimitUsd: fresh.registeredOnChain ? fresh.limit.monthlyLimitUsd : monthly, timezoneOffset: fresh.registeredOnChain ? fresh.limit.timezoneOffset : timezoneOffset, + moduleAddress: target, }); return { transactionHash: result.transactionHash, dailyLimitUsd, timezoneOffset }; @@ -1124,7 +1190,7 @@ export function useCardSpendRegistration({ enabled }: UseCardSpendRegistrationOp throw new Error('Your wallet is still setting up. Please try again shortly.'); } - const fresh = await readFresh(queryClient, selectedUserId, safeAddress); + const fresh = await readFresh(queryClient, selectedUserId, safeAddress, preferV2); if (!fresh.registeredOnChain) throw new Error('Set up card spending first.'); // A cap the caller did not name is left exactly where it was. The two are stored @@ -1222,7 +1288,7 @@ export function useCardSpendRegistration({ enabled }: UseCardSpendRegistrationOp throw new Error('Your wallet is still setting up. Please try again shortly.'); } - const fresh = await readFresh(queryClient, selectedUserId, safeAddress); + const fresh = await readFresh(queryClient, selectedUserId, safeAddress, preferV2); // Either it matured while the sheet sat open, or another client cancelled it. // Sending the call anyway would succeed and change nothing, which is a signature // spent to tell the user something the re-read already told them. @@ -1298,7 +1364,7 @@ export function useCardSpendRegistration({ enabled }: UseCardSpendRegistrationOp throw new Error('Your wallet is still setting up. Please try again shortly.'); } - const fresh = await readFresh(queryClient, selectedUserId, safeAddress); + const fresh = await readFresh(queryClient, selectedUserId, safeAddress, preferV2); // Read directly rather than off `fresh`, which reports whichever module operates the // Safe today — for a cardholder still on v1 that is v1, and the migration needs v2's @@ -1444,7 +1510,7 @@ export function useCardSpendRegistration({ enabled }: UseCardSpendRegistrationOp throw new Error('Your wallet is still setting up. Please try again shortly.'); } - const fresh = await readFresh(queryClient, selectedUserId, safeAddress); + const fresh = await readFresh(queryClient, selectedUserId, safeAddress, preferV2); if (!fresh.moduleEnabled) throw new Error('Card spending is already off.'); const prevModule = await findModulePredecessor(safeAddress, fresh.moduleAddress); @@ -1634,6 +1700,13 @@ export function useCardSpendRegistration({ enabled }: UseCardSpendRegistrationOp * changing mode does. */ isLegacyConflict: registration?.cohort === SpendCohort.Both, + /** + * A cohort cardholder whose card has no working module and will be set up on v2. Shown + * the same "enable" prompt as a revoked Safe, with {@link carriedLimit} as the caps. + */ + isAwaitingV2: registration?.awaitingV2 === true, + /** The caps an {@link isAwaitingV2} Safe registers on v2 with, or null. */ + carriedLimit: registration?.carriedLimit ?? null, isLoading: query.isLoading, isSwitchingMode: switchModeMutation.isPending, From 4a861740f9330127174ccf6f9e49c0a3cfc41d4e Mon Sep 17 00:00:00 2001 From: Mayank Mittal Date: Thu, 24 Sep 2026 19:58:32 +0530 Subject: [PATCH 15/16] feat: open spend modes to every cardholder and register new Safes on v2 Drop the spend-mode cohort gate: the home "Spend mode" strip, the card page's spend-mode row and picker, and the borrow position card now show for any Wirex card with working spending on a build that reaches v2. The backend only used the cohort to answer spend-mode/access; ExtAuth never checks it. A Safe with no working module is now set up on v2 for every cardholder, not only the cohort: new cards, a disabled v1 module, and Safes from a replaced v2 core. v2's registerSafe starts every Safe in Debit, so no setMode is needed. A working v1 Safe stays on v1 and migrates through the picker; a build without v2 still falls back to v1. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Card/NewCardDetails/CardDetailsPane.tsx | 4 +- .../SpendMode/useSpendModeFigures.ts | 26 +++-------- components/Home/NewHome/HomeScreenNew.tsx | 8 ++-- components/Home/NewHome/HomeWalletCard.tsx | 4 +- hooks/useCardSpendRegistration.ts | 46 +++++++++---------- 5 files changed, 36 insertions(+), 52 deletions(-) diff --git a/components/Card/NewCardDetails/CardDetailsPane.tsx b/components/Card/NewCardDetails/CardDetailsPane.tsx index a212813ce..a64c7d790 100644 --- a/components/Card/NewCardDetails/CardDetailsPane.tsx +++ b/components/Card/NewCardDetails/CardDetailsPane.tsx @@ -142,7 +142,7 @@ const CardDetailsPane = () => { * only ever put Safes on v1, so it has nothing to be unsure about. */ const showsEnableSpending = - // A cohort card with nothing working is waiting on v2, which only a v2 read can report, + // A card with nothing working is waiting on v2, which only a v2 read can report, // so it needs no guard of its own. spendRegistration.isAwaitingV2 || (spendRegistration.isRevoked && @@ -188,7 +188,7 @@ const CardDetailsPane = () => { // for the card to land: a sheet rising while the card is still flying up covers the // flight, and the two animations fight for the same frames. Guarded on the mode being // changeable, which is what the strip itself is gated on — a request that arrives for a - // cardholder who has since fallen out of the cohort just opens the card page. + // card that can no longer change mode (spending since turned off) just opens the card page. useEffect(() => { if (!isOpen || !spendModeRequested || isHeroFlying) return; if (spendModeFigures.canChangeMode) setIsSpendModeOpen(true); diff --git a/components/Card/NewCardDetails/SpendMode/useSpendModeFigures.ts b/components/Card/NewCardDetails/SpendMode/useSpendModeFigures.ts index 23bf67b45..cd6efdaae 100644 --- a/components/Card/NewCardDetails/SpendMode/useSpendModeFigures.ts +++ b/components/Card/NewCardDetails/SpendMode/useSpendModeFigures.ts @@ -10,7 +10,6 @@ import { healthFactorToNumber, } from '@/constants/cardSpendV2'; import useCardSpendableBalanceUSD from '@/hooks/useCardSpendableBalance'; -import useCardSpendModeAccess from '@/hooks/useCardSpendModeAccess'; import { useCardSpendRegistration } from '@/hooks/useCardSpendRegistration'; import type { SpendMode } from '@/components/Card/NewCardDetails/SpendMode/spendModes'; @@ -22,10 +21,8 @@ export interface SpendModeFigures { /** * Whether Credit and Smart can be offered at all. * - * Three things have to hold: the build reaches v2, the cardholder has a working card to - * migrate, and they are inside the staged rollout. The last is server-decided — see - * `useCardSpendModeAccess` — so this is false for everyone outside the Wirex cohort even - * on a Safe that could technically be switched today. + * Two things have to hold: the build reaches v2, and the cardholder has a working card to + * migrate. Open to every cardholder — the staged-rollout cohort no longer gates it. */ canChangeMode: boolean; /** A switch that is armed but not in force, or null. Null at the launch `modeDelay` of 0. */ @@ -123,9 +120,6 @@ export const useSpendModeFigures = (): SpendModeFigures => { } = useCardSpendRegistration(); const { data: cashBalanceUsd, isLoading: isBalanceLoading } = useCardSpendableBalanceUSD(); - // The staged-rollout gate. Every credit surface hangs off this, so there is exactly one - // place the cohort is consulted and no way for the two cards to disagree about it. - const { isEnabled: hasSpendModeAccess, isLoading: isAccessLoading } = useCardSpendModeAccess(); return useMemo(() => { const cashMicro = usdToMicro(cashBalanceUsd); @@ -161,11 +155,7 @@ export const useSpendModeFigures = (): SpendModeFigures => { return { mode, - // The rollout gate is applied here rather than inside `useCardSpendRegistration`, - // because that hook describes the CHAIN — what the Safe is and what it could do — and - // folding a cohort list into it would make an on-chain fact read as false for a - // cardholder whose Safe is perfectly capable of the switch. - canChangeMode: canChangeMode && hasSpendModeAccess, + canChangeMode, pendingMode, cashBalance: cashLabel, @@ -195,15 +185,13 @@ export const useSpendModeFigures = (): SpendModeFigures => { hasPosition: debt > 0n, canBorrow, - // Gated too, and including the debt case: a cardholder outside the rollout should not - // be shown a borrow position at all, and one cannot exist for them anyway — they have - // never been offered the mode that creates it. - showsBorrowPosition: hasSpendModeAccess && (debt > 0n || (canBorrow && creditLine > 0n)), + // Including the debt case: a position that exists is shown whatever the mode is now. + showsBorrowPosition: debt > 0n || (canBorrow && creditLine > 0n), risk: position ? borrowRisk(position.healthFactorWad, debt) : ('none' as BorrowRisk), healthFactor: position ? healthFactorToNumber(position.healthFactorWad) : null, fullyPriced: position?.fullyPriced ?? true, - isLoading: isRegistrationLoading || isBalanceLoading || isAccessLoading, + isLoading: isRegistrationLoading || isBalanceLoading, }; }, [ mode, @@ -212,10 +200,8 @@ export const useSpendModeFigures = (): SpendModeFigures => { position, borrowApyPerSecond, cashBalanceUsd, - hasSpendModeAccess, isRegistrationLoading, isBalanceLoading, - isAccessLoading, ]); }; diff --git a/components/Home/NewHome/HomeScreenNew.tsx b/components/Home/NewHome/HomeScreenNew.tsx index bba42021f..9d56463e5 100644 --- a/components/Home/NewHome/HomeScreenNew.tsx +++ b/components/Home/NewHome/HomeScreenNew.tsx @@ -74,10 +74,10 @@ export default function HomeScreenNew() { const { provider: cardProvider } = useCardProvider(); const userHasCard = hasCard(cardStatus); - // The "Spend mode" strip under the card. `canChangeMode` already carries all three - // gates — a Wirex card (the registration read only runs for Wirex), the spend-mode - // cohort, and a build that can reach v2 — the same condition the card page shows its - // own spend-mode row on, so the shortcut never leads to a sheet that is not there. + // The "Spend mode" strip under the card. `canChangeMode` already carries both gates — a + // Wirex card (the registration read only runs for Wirex) and a build that can reach v2 — + // the same condition the card page shows its own spend-mode row on, so the shortcut never + // leads to a sheet that is not there. const spendModeFigures = useSpendModeFigures(); const homeSpendMode = userHasCard && spendModeFigures.canChangeMode ? spendModeFigures.mode : null; diff --git a/components/Home/NewHome/HomeWalletCard.tsx b/components/Home/NewHome/HomeWalletCard.tsx index 0ef318339..788674f88 100644 --- a/components/Home/NewHome/HomeWalletCard.tsx +++ b/components/Home/NewHome/HomeWalletCard.tsx @@ -51,8 +51,8 @@ interface HomeWalletCardProps { * The cardholder's spend mode, when the "Spend mode" strip should show under the card * (Figma 26134:22854) — or null to leave the card on its own. * - * The caller decides, from `useSpendModeFigures().canChangeMode`: a Wirex cardholder in - * the spend-mode cohort on a build that can reach v2. Everyone else has one way to fund + * The caller decides, from `useSpendModeFigures().canChangeMode`: a Wirex cardholder with + * working card spending on a build that can reach v2. Everyone else has one way to fund * the card and nothing to change, so the strip would be a control with nothing behind it. */ spendMode?: SpendMode | null; diff --git a/hooks/useCardSpendRegistration.ts b/hooks/useCardSpendRegistration.ts index 9a68984d3..9767cf077 100644 --- a/hooks/useCardSpendRegistration.ts +++ b/hooks/useCardSpendRegistration.ts @@ -20,7 +20,6 @@ import { } from '@/constants/cardSpendV2'; import { TRACKING_EVENTS } from '@/constants/tracking-events'; import { useCardProvider } from '@/hooks/useCardProvider'; -import { useCardSpendModeAccess } from '@/hooks/useCardSpendModeAccess'; import useUser from '@/hooks/useUser'; import { Safe_ABI } from '@/lib/abis/Safe'; import { SolidCashModule_ABI } from '@/lib/abis/SolidCashModule'; @@ -198,12 +197,13 @@ export interface CardSpendRegistration { */ v2Available: boolean; /** - * A spend-mode cohort member with no working module, about to be set up on v2. + * A Safe with no working module, about to be set up on v2. * - * The state every cohort Safe is in after a v2 redeploy: whatever it was registered on - * before is off, and the new core has never seen it. It is reported as the v2 Safe it is - * about to become, so every write here targets v2 — not v1, which is where a Safe with no - * v2 registration would otherwise be sent. + * v2 is where every new registration goes: a cardholder setting up for the first time, one + * whose v1 module is off, and one whose earlier v2 core was replaced by a redeploy. It is + * reported as the v2 Safe it is about to become, so every write here targets v2 — not v1, + * which is where a Safe with no v2 registration would otherwise be sent. It registers in + * Debit mode, which is where v2's `registerSafe` starts every Safe. */ awaitingV2: boolean; /** v1 is enabled on the Safe. v2's `registerSafe` refuses until it is disabled. */ @@ -737,7 +737,7 @@ const foldMaturedRaise = (limit: RawSpendingLimit) => { */ const readCardSpendRegistration = async ( safeAddress: Address, - /** The cardholder is in the spend-mode cohort, so a Safe with no working module goes to v2. */ + /** This build can reach v2, so a Safe with no working module is set up there. */ preferV2 = false, ): Promise => { const client = publicClient(fuse.id); @@ -780,10 +780,10 @@ const readCardSpendRegistration = async ( // card, so that Safe is reported as the v1 cardholder it is behaving like. const isV2Active = v2 !== null && v2.registeredOnChain && !v2.legacyEnabled; - // The cohort's case: nothing operates the Safe and v2 has never registered it. Reporting v1 - // here would send the enable action to v1 — `enableModule(v1)` for a Safe that was once on - // v1, a fresh v1 registration for one that never was — and put a cohort cardholder back on - // cash with the credit modes out of reach. + // Nothing operates the Safe and v2 has never registered it. Reporting v1 here would send the + // enable action to v1 — `enableModule(v1)` for a Safe that was once on v1, a fresh v1 + // registration for one that never was — and leave the credit modes a migration away. A Safe + // whose v1 still works is left on v1; the mode picker migrates it. const awaitingV2 = preferV2 && v2 !== null && cohort === SpendCohort.None && !v2.registeredOnChain; @@ -1016,15 +1016,13 @@ export function useCardSpendRegistration({ enabled }: UseCardSpendRegistrationOp // would answer the question does not exist yet. const isEnabled = (provider === CardProvider.WIREX || enabled === true) && Boolean(safeAddress); - // Which module a Safe with nothing working is set up on: v2 for the spend-mode cohort, v1 - // for everyone else. The read waits for the answer, because reading first would report a - // cohort Safe as v1 — and an enable pressed in that window would re-enable v1. - const spendModeAccess = useCardSpendModeAccess(); - const preferV2 = spendModeAccess.isEnabled && isCardSpendV2Configured(); + // Which module a Safe with nothing working is set up on: v2 whenever this build can reach + // it, for every cardholder. v1 only for a build without v2. + const preferV2 = isCardSpendV2Configured(); const query = useQuery({ ...cardSpendRegistrationQueryOptions(selectedUserId, safeAddress, preferV2), - enabled: isEnabled && !spendModeAccess.isLoading, + enabled: isEnabled, }); const registration = query.data ?? null; @@ -1121,14 +1119,14 @@ export function useCardSpendRegistration({ enabled }: UseCardSpendRegistrationOp // // Building the batch from what is actually missing makes this one action cover // first-time setup and re-enabling, instead of stranding the user in either state. - // Whichever generation this Safe belongs to. For a new cardholder that is v1, which - // is where everyone starts, unless they are in the spend-mode cohort (`awaitingV2`); - // for a migrated cardholder who revoked the module it is v2, and re-enabling v1 - // instead would silently return them to cash spending while v2 still held their - // collateral. + // Whichever generation this Safe belongs to. For a new cardholder, or one with no + // working module, that is v2 (`awaitingV2`) whenever this build can reach it; v1 only + // when it cannot. For a migrated cardholder who revoked the module it is v2 too, and + // re-enabling v1 instead would silently return them to cash spending while v2 still + // held their collateral. const target = fresh.moduleAddress; - // A cohort Safe headed for v2 with v1 still switched on: v2's `registerSafe` reverts + // A Safe headed for v2 with v1 still switched on: v2's `registerSafe` reverts // `LegacyModuleStillEnabled`, so v1 comes off first, in the same batch. const prevLegacyModule = fresh.awaitingV2 && fresh.legacyEnabled @@ -1745,7 +1743,7 @@ export function useCardSpendRegistration({ enabled }: UseCardSpendRegistrationOp */ isLegacyConflict: registration?.cohort === SpendCohort.Both, /** - * A cohort cardholder whose card has no working module and will be set up on v2. Shown + * A cardholder whose card has no working module and will be set up on v2. Shown * the same "enable" prompt as a revoked Safe, with {@link carriedLimit} as the caps. */ isAwaitingV2: registration?.awaitingV2 === true, From 7fc96f203b3c0de7dc49f1e1c17030a5b1603ec6 Mon Sep 17 00:00:00 2001 From: Mayank Mittal Date: Thu, 24 Sep 2026 20:39:24 +0530 Subject: [PATCH 16/16] feat: hide Smart in the spend-mode picker and add the credit yield notice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements Figma 26974:12806. The picker offers Cash and Credit only: Smart is listed in HIDDEN_SPEND_MODES rather than removed, and a Safe already on Smart still sees its own segment. Segment widths, the pill's position and the reserved panel height all follow the offered modes, so the hidden one leaves no gap. Credit now shows a yellow notice between the caption and the Borrowed card ("Currently credit mode works only with your USD yield balance"), using the design's background and badge assets. Gaps follow the frame: 17 caption→notice, 42 notice→card, 34 card→button. The notice's rendered height feeds the reserved height, so a three-line wrap on a narrow sheet does not move the button between tabs. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../images/spend-mode-credit-notice-badge.svg | 3 + assets/images/spend-mode-credit-notice-bg.png | Bin 0 -> 14145 bytes .../SpendMode/SpendModePanels.tsx | 55 ++++++++++- .../SpendMode/SpendModeSegmentedControl.tsx | 22 +++-- .../SpendMode/SpendModeSheetContent.tsx | 91 +++++++++++++----- .../NewCardDetails/SpendMode/spendModes.ts | 21 ++++ 6 files changed, 158 insertions(+), 34 deletions(-) create mode 100644 assets/images/spend-mode-credit-notice-badge.svg create mode 100644 assets/images/spend-mode-credit-notice-bg.png diff --git a/assets/images/spend-mode-credit-notice-badge.svg b/assets/images/spend-mode-credit-notice-badge.svg new file mode 100644 index 000000000..69266a1ec --- /dev/null +++ b/assets/images/spend-mode-credit-notice-badge.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/images/spend-mode-credit-notice-bg.png b/assets/images/spend-mode-credit-notice-bg.png new file mode 100644 index 0000000000000000000000000000000000000000..a32663e5104ed24095e7ad6675beba2c96fb2218 GIT binary patch literal 14145 zcmV-HH@?V;P)%;Rr#I8>XT6? zBBNHv|NO81Njm#(l@6beNkq<3Bwh0{FS_PDO=H1!ts{(yiahctaz8=nB568RVTFx)ZecauT^)FwK z$32Zv%4!;(>$O)$)A`)0@0CTtdWXkhZWpWBG2erZ^LoE1ABEfJbnBY)UJuW~r%&a4 z4S%OsL%R>hdb}TwugmrAL%Gkd%6%BF<$V7@_qU{Jxl_wxovW+y{O)de1~%h3@Lb1Q z=Um$7yu-ut+1{4^FNeHcFji}9SdP#2TIBM6I9?BJJVV}nY4`OipO@n*1AiWT0Je3a z^yBhCo9lW%dif zy;i=qrJycj*$u#`*P2%6<&2*fDG>zlOK4-)7`M0hV4mDlIm%ci?K=6Q;EqB-&uJU? zM{E6{Bdr>LfP8)mFtG4=EUh^L;}&38ZCZ1&`mE^#h@TBNRnSI2y#oH1;W{1Dt5;*4 zK7SImtfAIZ-GC8yynUA0or+0%0DW2rwPa(p-(-3-X1EbzkXduGafe6eYjZ5 z{BXo8cR|Fo)>~FfVNeji7_J`yAO*;G2{iQkW7ggeqQxfy?^1@r*8=uP<3@nq8_%m% zhk>0nc08W}@a1ZuN#E!5qYL0xJ17kP-rIm2zyDr9?~v|?XIakg%fgfMhxD`WSNS+7 z#pw!wuWIXQuF*<4PZLimEz&Vf+p)BEY*PUJ{&{@M5N#a+UPUzcL;1l7kRzze7!}0v z`~aHzJ}nm|1p8!w{MuOTu7|M+u*-O)v;A;f55KAX`@;y*e=5q#@LcCU0mM_e#^db1 z70|cJ4sRbzjlW0Zw#s|p9lfe^xk#+OQc5QQe(yXMb0Fhh65*aF)gObq2ul zHu&Ofrn%>KhI3(Plzx$O)W#9iJ@EE4cot6s)^RF`M|2COof+*Rcwd6nuJnr@y@jb> zm*>QylvG>a6}m;hT>w0T_6F|5-B=#AaTL_nF^{jgjcrL2+Gl6fD`i)efI5N_suy&4FLV162!5HQlOfN%S^>*B+R( zq|z*yrVl>wAqeQ6R_*~fiU<7b>$5x-q+l+ftYtjKu^kNF-qX-?9Y-T?Eq*-TET?Jvg)>g!?L`vEi$Ya3jxYfmF* z@i=zxn%mVeE8QYo|47HsSV2=4xepATdXCb=zjIq$SH^h%JW)d`7N}d=Is^OeRp0O5 zTX4KFGstV%tf}k$DRj&ZH1*oHLA^-Y`Ma~l--XNaLV$T69Nr$=y1t%3UCyJo0qS=( z^Xln&HODkd+dq76Xf)rgy6c<$f_9ESPPrC}MF0os7mF)bu>fDwwJhcdnD4ivc8VIh zXts*Uk3_d@hC0vN!~7mNz@FP%_i5+#etZ{DRL+}PbdN>QFlx~v>Ioxxa=$$<7%gi_ zgNGc+rdPVhj&VuAoEKEc3+yuP0FlKoR$h`XV$^~%L z$_31y-X9iaP#ByahdLKS9ewM_^CGT3lI!cKt!R8}qfH)*rVZz#<&J^F)5<-{MJ3|a zEm@Qm#95~b>N9{U#?th1G+usAa}8RJr8({SI9is9SlYYvtsriB#fYYUk!tR5oR|MR0}IkyW`{qGyMIxj|9B(cX*t)`;YupxHl)YfC#(FZ|N^6u?ZZVij zzt;r}Wtt^>qHSc)Te6p$1YlmPTGK5iJwBI-=1ukJ+JKJW)zR1YH~P-dOi1 zT0kS)1WTl?3y@w7`F94rff{Gw*3v%PBTyZ5>ytB0c=KXE=>CZp^m~ zsh*}Uz#|4yJUwPnwwd27wlhxM1nbD<(rVl4(-Z4$K#x0g z=WJ01n8*1QwA48Ip#Ugo@R}}?DVU;>AItgix%jbb-g*S*oc27Q6oWm2e%90r{gO8k zr8)hQ#{mGpgLB<!QHeOo}yT)0Ka=(Xjp=($0@8jhb5fqY3*Fx?`+3bmK~hdiYedbM!5 zo=r;JMs;IU`sM4fx-+V%SIn)dcnEsXU!Hr4HS$Q4=X0%-#|WE{uqT{xUwH+=;d)DB-x(UZwJ2pxeN%v3kE*4Y^AYq4o;)up7}eN4I~o)M6sMX03T`Zs;P^(Zzyn`+iloA zJOgW_`CA)~SOd2J9?Pai8!dVz()_u6)V2bWf{+II-j*s(lZ4r{Y+&> zjvu{XvVVFiU|$TRUp9pj`7&-ss+7yCK5(}Tt5$ghap_An_8EASzq;k}n0KVU#_0|7 zdG;Sv;nXcn9k0i7%eNd)!{_pvmoeL-KGM>toANHxLsb2^bpLPrM*=Jn@Id*n$l(a3_5-geqZ43gA zS-USj>lrLF6;qxS$5j!<(s=P20dV23UVtzgOUw9Yz*~;nG%c1o&MMghPtwOc^^V~& z@z^bGJ@V3N-HZBGQM7cK)5V~v>jp{z?Lku?G>-|$8PM@MS`1Qdb$Ey}&=>c{1iMV- zP6{x;90nkC%d1zbMfd}=3R!E(_8prHaTN?N91un=g+72x6Bf4xqrXSZm}FzQh&dLAtl{K}C5USt68 zr;mq8Q!cd2!HCUb0Wt%6PZ`T45{<5Am;iNa{JNwq1gx*tLa`X!c$y52ktS=oJ~9>K z86anV>I(2zllDH!(m2XHOhvM>l#Yq!gJYB9))+6(4FO#wZ$e7bj?+upRazIuw3;+| z0oJ<-6cMm3kgWX&2Fgq!<(mlFHr5lM-j3s|v6eQ|!h;*9CWGn{fUQLd$KAUCSTb9@ zz&26ke7N7cxdtz9$@?$mo)C~Tplcqcv!*ZSaJ@SIccOYc4c-gvg?ShYMg-j%tzD%G z>=-VgXJ{92|q-)aJw<)_T3WBVKr)~s1!EaGwLQ^#HLO8Iz2Nn1{=wL+b}a5Z z(&BXQI3ma8@Ou#)IR*>{_R?#Vh))*~ ze|#Q|r=Htq?WY5*t$Z!x8K}983iIpgqb?vSUOTF(dwjTFM_}$5M!CHx&sso4co(VV zfnr_P`(beHEl~FAsI(RijRnJ-kQ5Ma3$VQ=n!@A3St3uE9%HI*LdyLXU}TS&7O;D~ zy2gab0GuYUXC6AT1@PQ0FBtJzzYI9%T>iE1WfF1K+>VU>tzi(wey|RVf%1*3?_O zOjQ)rRf$I5^=)G;-Wv+a!bn;=5@6>|NFI>qb{vgX%d%LYyK5LOu}!5`(6!MU7p7ym z*0_1>(Rd8E#=0;fxGS(*##4(|_q6Pm@ztsyf&0|}+5+AR+fm6z(EI7lBLY+g^@u|8+TbxpZyM2JVfBF& zj9}v3aR1a`>Va@eTGULB*3M-addxE9d$wlBbI8qrsDVF<`#Mte|Q}E^6mEKMRmmEMShjURu1nx;NfY+!XXxuk(Ag3gg79 zAPs}G%r^N{5|1gRcwtk)I53&&d0uL^dR^~>Lsdk8srjhcZc}Q$5ukQ1z>c0S5wC9@ zt?_exYs^@W+eI1)`SovbYjibjkFNQawk)|W-aBVd|05RoSV6jiw_bA^=eNoy0sg=L zw||ycBHlbYavLbTV0;;jzE2dxMt78=uR4mSVfTW)2j19*<=egh_W{J$Kd!33dInB$ ziEa3+vv|CCbdF_p4v{}R_OGD=kXL7*R8SXfop99!Xb;#u%{Out#5W53`ca*WLAO{M zJO}u)K9s@I1Li$c%q4nm7*QKpS{iPPz7W}_)TK37Zu<3ViUrU2A>hrUBe!AAQPW=x zM&GaR4~sJT)=}Hp_?mKL2yIa)o`%kghjq>6RYWsqUR~t91^ypKFcytnDVTf>;BB3& zNSe-p%Q;#;g8wE2Xit0Be5|9_s-c&# zm+NL9at42PZR9k}TllJDe-Z5#)%{f4 zEoB8rwJmM;8h1=(t^F_qVExDkiuWeLFuEL|F?hf8xz=31i&%wi+%|SMYMA@l&W~NNXsLp z$585N?H)Cw@~V`d#eh*7#sF85$h!s8SX!Pi*tX{xlkaUYnC5Z=DDg)#n7W8|nCAOr z7(rX&dBtcR_hWt9&I=1*ed11pW5BT1&TkF!cmDd}uoyFsU&pH2u4um13r01}ZEDgk z5%6O^)}Mi3n)B-GhG7=N@Nyg&kG8S;ZqYK9v^0F3NA-jeumsn}+(w6iwAWA?dAE49 z4T{#Yv~=aw)le#QjM6Xy@W`#_G(*eAbUcdiIhR%O=o~Hg!`oDWT_l6M-is@EcJJo* z^Y&^r0>8#m$4}cKs3HY}{_DBXPO#=QE8PQeuRO~u$hSiAiu8E1phOyb%jMB3$LHwv zlG6Yr7jx135Ks^vh_?VcGORMEuIE-LUeD`=^A8V4b7$$!S(GCCfoCsJgKpko`ln-3 zr1cDh5rFG;3n(#xK}Ma~ zH$&d@v;eG*|K+dW<+HEnXEXox+8_bC1?HH(7-Yb;V>_PV)Y8}$2<59tuZ*t?odV!J z!0sKnoYhyf328G>AI~2S)jwT%bLI zv-l?7}VAos9C;+ zKEiq}T8H~kEj_1qYR{e~@6k1Q?tylXVbnIU`t9UdF z=3#qSM!j_wb1E9Sp69s_*W#KfKrWg&f;NIRYU>*Ud`q|;()Q=T-_rE+Kpi@Uuf4Z| zEVKd1;jI{v?wN&NRBaIfNN1OH9(sLE$)ayNXX#1&K zE{!VaHfL$;y_Ac$XkGHgBM28@ff`;%ARbX4$gN|WR(a*rudT3>MtzIQu{3`Vq^ zd!T8!nd(yMm)kUt1EYNgE{H8!#cCtEj@}9o)J2d_S8e=&b|j9T`W;`smL z54DX=ilmo*5nL1x7fI8NL?Y&9pvzULQDa$5Z-KhU-CM?0GgJWF3Q7C4S>-Q0<L5R6QFycc@g|oTi4)Tl`46d zV#CbhsKY$AG+!PsE(jyHKGw^Cq`>+0I5(O;&;RCm5lF|-P^#B_L@$`c3xynEP8zLI z+tlj`Tu0w<3FdWuy)rdw?HjK>khkja_;HSldSW2GJT;%c{m-+nhLl71sJWu$O@6!+w>y^|Ox6M-Whx!&G5Mg_qnY<`>$?v6 z2J4`Yx~0`)dh~{2)oJnQUONlpFHr+6FO{A$k*_(|v(j-a*sks*6+y!&3VOe&WKBNc z%X67v6O!lRs1j-W!l$d2F0@c&G_CV49Qq9$1+>LzdfTCGTUvRGqRDRr!x(piedY78 z$Oy2jrY@3!-gzVN#=!k*`CFxWLSHFsfbjU`&d5Y#nK{nc}s6Zz^0+_4Se(~(Kp zIhr;0Bh62dd8(z0!Bl{rx%50oBMrgb0@9Y@)S_b?PQB)_0R3XNg>4u{ z+O!_-#g{)E7V{C&^QThpd^%rh{36jtBa1WV^iwo?t4*X}YMY?l-a8b7h_@LhI>+nC zFew|5g!17KymcNAv4NE5qv_}cqnf)Htr5r564um3vU}shaTfmtwf5P(18q9u(>b4c z^_G@y#eea%aIbGni{31-d=Dp&b3(6|Q?~ef+;`n*H2l53{-1-{CbwZ3S6NU~7qPVU znFq{I1&oVJV?CzWxQS`K8ok#hdcTN>rjABYi(Ak40lZ*D*aK!`oa40z@FL&+Zq&xD zHbh$&4P7;NtH0SC8)LE-37nJ4`{auP{rQqMS^|HIe~&gAMP-y%7qLEZcUz33BIpUD zVtKZRL}1QkA__*+5XdpemwLiP!l2I|p4L9_^LCNpx92S*w^ueFv1_A(D2oMUVJle&YCyyP=%mQZJ;pgpA zm$q%C^SRDVKbTPsrK*MB&)+^gK0XS=Yl)|MW4paHja3iRbx~43b&a#mJi7(9wQU3N zxt?|1Qx_pgzZ&l6$DiSANOyD4_5ilF z5u`C~5y<(#A^cY~hvFroo<*?8Tm@Ajgb*)8(`@}Z6F4Hf!29Q4)ZA-e`&&PqEM}YrcVwx4uX7ILv-b*vU zem1qLt=FJI8jx(QfH;EV>$OlXfq^i!wQx%;@V0f)FPSfSqjdI|)F zA9&7EzRw`v|GdoU>Q1ldjiF!|0r<-+G=4h31v}Nk(+bN8L)}bj@Qr z#-nrQ#?j-9J6CWQKtJq6V^7tsG5fK!3%otg7tQl{@X1hGc=#{J>dwgJ z(QVS}4?$nGcHz{;i?)J!IaaQnG<9nM5E^E`Jq(P;S$g|oxTi1ELOT~>(C55pQLrc; zXk(Lo)#W497%rfKmHgM>&oPh7U|R@~9iZXdwfqp(FOM^%Mb);cl zfJY2|ka|sX^E`dj3+58S?`FVxzXZE6ielP^AS#G@c!NsV0Aj zqKtFBQLT8=>U-WIc#L|U|A}Fe!tG)mZnrM7YAn4S%Y4NTX57ffsE0Oh$~e<;whJ}4 zrG`;D*1kLZ(K+3F3y^<5{KWvuCH!oS3zO#5Oa}d87|olM#Ay2a0c?i*{p(MlVSh|H ztr}H}v|J$Z*sI}Z`lST-Qea=7r-@HLAE}s|vdCLTQn~f)uYNnZ3fBG*R4vf;=n)mv z(tr6Ccy?Kc&Qp`m_oTWt${Wld=FjS_MX#VXZ%{HK8Nd~QWuMc#xz?V+{ly%-MZ34e z(b59%MJSL0oecz4YA0M?6D6NTaBa+dIyka|3KQc9s8zIwhWSFOgl;rkRGe=%sKcd0?V!g$EF|=&X^V;ziFmK!viWfgboznw@W{Yy^t&eR; znt0C0BEoHR`|J&)ZjL=6czb=;Sz3#L=OLpTkVI~n#xi(yJy*HCid_{q0q>6a5zwM5 zql)tD76)&Q!|In8np>kjdbfyJ=gg&l_`0h3Vk|{rAXo9yB3|91akveueXCtA>$UCm zQH{F{{0flx%~BEFFxA?G#AzbxmyW@_ICA4`aE7g*PNbo|Ut`RgyEOlT&-1DYgcgWn zIgj!{ZQUz}g077CdAkU}TUvK=#%qbUSn)jY({{CwhS77@PlwS|wSCRUKBB%*_+uGX zJeYi*(sEa+0Q==gwbXRSV6@*b9td*L_A}tN(8nMsCyi|r@i;|%hF-6P2PA78G&Cl zb_^Y0ea=oL+LXlp>#V6q{^}7GgA`2W)eU~z^MZ*$u!nO;+Cn+($f$|e%$s}TiZp9) zG2$^sixM9(#=7MNlj}Qvk-vIG#jwE? zIdwgL`tGdq=AJtvO9akV8Tp%j`QL|=C>hT?r$r5U#$9h7twjlrtKPed{&^55*a@1xDt9;wGEKV)Wyw(Y{+dKyY9If^>A31gI3;R@wG;}Ou z2`?mGnx?DAOV!d#xByFQ&vk+gLcMt{?cX!1;!%JbIdzQM_^!||*SqO6cPc%eE+{Q6 zoWLbgUytaWdM_FjkcfD<{1(!(7YrCe z*(jP)azWqPgyd<}5y*Q~j0LPcygiSlXF*wCH-7)Yq7yA7oQ`dI<4vOR3-GJ(2Q;?U zK*$zg0Xdelw0T`P_Jv=^4`)~gM~|Lq@!QqyFHNB6Y0-5~q^&RJA5cLact@fio%ZI< z{is{xEabvXayK0HYUGi&&Eol{M~epfsk8sO#(@z{{e2@`z_+?GMw&a9*K+CwgPOXx zn^c4irD!mXJXvbr0mxf*SW6etumSR>!HdXjo_Dt4729P&5Et=)Jf$VrGqCFhB-PRx zd{rWPrp0etWjrmK%X<=0L9M!$y!?x^`UcTjtT5dgQ}0-l0RMj+fWKw9#XReLwgHJa z^;{;_C#JR97( zN5@zp0~pLzQx}#E;+Z|oP88-r)}=#SJ?5%UX7J2*P*lm=IG6ix#(0Hz1M!xn;cU)w+AM^d2vmXz@_tz+Pt5M*0+9iy!yExdrxE#yZEoMKE-L z^#KhL%b=)WiNU#Aymp=EUA*02gadntd!uOTazyVK&)8~R^SE$6Dp7a{|B*+tGTKIGViM7L92$x;EmbqjFy5 zc?r%LjDr~LEr8T|)o5z)^O`3cpD@_i$g3ag2Bcj6%QRg6^j$BvF6Gt%*U$6ddFFfa zx@E z*Oqx>l9pjDUh~-9aWtGB((_~4m{!x48=x%8<`+0uo#K6&PZakBC40Sm9%&bmy0Pd* zs=G=BIBwE$0aQ|U|9L!DaA^Ug$iBhPM*tQ{r1e^Y?M7U`)%Kpsa$i<^dt)9}J6g`G z=Yjs-n6r_-I{w{1dt>s;iyPDdgesB0dXGk3cb9f9Alwd)HyUe14K3ym#!>NAzjUY= zj}EEhYxt~lUdz4FTS(ZQQAKGOVGdMN&)hottQXhD9uF7J@AYHnG8}?;wzCvGUM&7x z#naexzlPS_qC~WwRSr3IrD2eQ={3~8{PVLMPo3LG>P63WZjl!ZgS~3%nkFExn)}|- zP+L^P-T^|dz6DM!9!T~qAlwd)HyUg7Het#71PZ684R7q2*P@uBg~aNov@sfYdpu%P zl(N`00M-(YvCe%&fX?Njexos1?Nvjmel)|_UD{(j#X4qxftQS4TlKMhjIFNVE_BOT ziT5I+UN9ohg=^P5{$NJr0mEWx-`-{>kAC5urPgL6Pi{MC70+PFWn1SeO2Htf-s6*Q zfxbn{Fp!@+z%J4C- zuC4m5x1Zj(3FvdPpe|Hq#!}LZvq7~OaRoFr7R;s6FV`vb(D7&$Pvf@UKs-8z1%rFl zo1q41iMDi;#-LsTjQY*eWNXm^6Nvx^gBZ(yP!fR^0Vp)7H5SqkNn+ouglQQURzkFTqtR4#y9e=23+v$++>@rdBB-Y;27 zpsy@1+L|=ySpeh#PNeB_Ti9L%tUVe&kHhQ38UvT}1nXP(sAn#m9xbxb7^4L^0(b9T zSY=Ay=+$FUA40e6%B#1egB6o z448ce@Dd`x*;$%_{7ssHy0_WL)4HR)$O8t`lyc#4yl5O{*G3TqaP(J4PP$hs#lKMWQu9$Fs)X~czCF#-Ytz$e zJ#F8rXU+3l%6enQJnP(&bq96Sy4O7Z9{R=`o2A)X*GAsz_+S6>Cgi#!4Se6>1;gdJ zjadXw@>S15v`emI_@`S2Q15y+&H%VsB3g%C8Z}MjP|9f--b6b~G02w}uO)I%#BzEb zmEi>h-5Oae4P8V(LapF#Y3iO2c_bd_Vqh;U<7wp4!hvJaYZ-suI7Izw$ldb>En^u| zl}rDYA9&9XIchrsK~H;(%Be)4ie-8LAD=}JKyh4{ziv<+QUBgJy?cWFa2nUewz*tu zJY3J3pI1Iwyr|6hF3te$Q7d&IK-XYiPJ0ly!8@}7r%h?L&^WyF?ed2T0Pegy;!b}&+vG`G6>?E^YNZ8>7Jt1 z7g`2+63>vTlHW86K>s$)cbaN5PmqR!8aji$##R@)MLc6fBqVPt9x&qZ5`k@`8CpMd z24$~))Gm+58k1Fr$H3)#HC#@M7Oftz<2D$Kxqa*HfbT=J2zzNfhe$j3#=-OV+GmNz zW8A~l@$dfih*r#(_jt~5J{o=n+e+YVQu|G zTbuT-TZU1uEJtkA`q$^aCe5BOH*l=6)gA8>6pwDP%4&IQ6Oz?W1l-t%=yD0U%m1zE#Uljd^-?HT@L8wC3k60Nj3*=LwhPbE|($ z^ZvfA`FPjXn0w~ey2sw!q3#6yIdcGN2_b;g%ff#@O|jMa({?Sd6-_e-W|V6!3|QCF z_A!>afH=0rZjB1+l}|6B0wP~?@4P2;0E)M8s0R$XHnO05BT_K84rsSF6hg! zpYlix=kl0d2NZbEa66dBp?F1*SI>EAuBo@A2e=~|rj;JWfU#o1wn0Ryw!eBR#;~J4P$LN1Kn@Y}JeEz`QmU z*o9Z$Z$Q(&u|YdGM8J)^Nx6Ir#CzNy(VbC5H1%<3=_(sdW%sB8Z1svM#?tJvm{zku zUoU@qR{QVK);T}&fLUtnypwcvbKKvh@_D;rELCG*O7X|2)BLk{o@a~)s#sRbps#w~ zEOfqHL~hQLDcIlQo{_e$tl!KVrW|MBes?s(@P4`)Hy%|mS1$c?F`~Yh{|MxGBd*1t zr!in&9j6g$SA>695zunE2xwZMZ}rXPxPFgzZUBHeH><4(T=m)-a}N+&<6_}4wH90K zuk}{LW4ELQ`aKIJ_80yAy}7i;g?(A$g#1C{|99ihvB#Sw@`{OEB(3@yhF9GhMYig) zSay%*aw*L=ALV?nK1?xn-x(IGY;@zcsp6}6c{WECf%&SMPhIu>zYAqIQQnbDO%I4gnyghe0!Ry(ygY83+i8`v z=445vZMOP}wDD-ZUjHo7J#CE}`?cD|{=GVD=k0;(@_2ju^PU?XPxPF43&W}7;XbW$ z-t$OhC*aQo901pA{-j+*^;lm(;u!1i4GJdGx~($3z^-}R4(2JSBcIN`>iJ#}r!F%4 z81>yT{BTC@@5-MeYw#Q-i~-!DVP^9V77Ol|1?ObBHHvt#V64yGMGva?gYxpR9Te5* zT7co@M*tIneAEwz-)$`b5x8)_+*fq}yv2q4u=>+Ea9^!{ddpe;_KXw715(Z7b~w*l zAgwadn7sLVV~N^oJv-ift+|!!)$wot`4}DF^L6+9)>ZJkrQSD7EwKO|HEq1swC5m_ zBd2+NT3mZy=S)fZ_!#*jIqo${wqggL`|J3n3={7 zo-iUU%0<&Xe!bTdMkM-HsTFHIhY7el&SI(O_Bv=6k5{+6RwDO95&pbg?_y!Ktw-Eh zs)pc}tD~j0>j$az!x_khM}V$kfj8!@uiM&aOWW$zxLa*U0MHtPSDxGM`LSDVwaQv; zvP5HVNlTNr%446H=8Y%1r#%ZM_h+^3fpIiWE8lwFyk{`_tyuZJi-lLWwZIfv9sk#V zm_DRveTW*xGlaIn1+cV$-aF6nUvv^gyD&9_zUm?#uioO)XXCp&hQ9407^zV~Jqqx} z?RtG+KerB8vrD5&^pzy^jA=c{t#gzJ2)%Mxw&#a>iUxM-xLZGBu4DC`Qoq*9eS2lR z@px(8T)0hc+sgOuL(ihgbLm+;qx%vqjJ?VLwquoHX)ScEadG-7&n5N~-FKcJmoG9= zJtC)Q*jB!^=_slrqTzTs1aBUwp{b*5qX7Aja9+m}Xy}nv&hOEqZhn;`f2^(y}DK#)-|T{xI9pa#+Ooj zPa~kR#@Paf-UUL_t@<8VW1Cif1a>X)o|Oo|qqe*@T7MU7+|is`&ytsB*`cC&M)${> zr!{|QYXp8T`l1)1QN-JHr0MxfLIu$C00`9Uz(`XUVeZ+|)+25`QCYm!I-IXm%PqX+ zv^FBO_nmM)IvcmR4D7%spV!^W#6|hhxnX+vqJ9 zfwAUWV`$w=EMw(GeR*;Uzx(&ZlyF3;|1=q+Ku#|=n& zol;6YJ5=?0JiRbA_1Yi+JMvDlyi+x24K~Ej49+va&WAr!RG*6;u2K#0&p;rCY-w5a3y8!h5 z&a7Z^+^0A8RzIga1OJbH|C=x#jC#S0nW+Hye9{W|YAns#I)eCEHSedoBARBin+Ny8 zjYXDNlnH-?db~c?SUOq}_7$bK0JK$~$Am|3%vKpME$SQF?EPVKKi0K%UwhiNfRV@R0Wn_p+y`r% zEwG9f5=;1*wDM5|Un~Ih>U)1n@7k*4{RX1N!n+sN^NIJ4|IS`t(ew2G>G!`n{?EU@ z8Z!(Y-HNfU!dkkDXCSpg3$(meSTm0{BB59dgf$+ngLI4);7g^&wOe08ihxDM0*eTs zT42WyqcxUwplNYi5g2mY7O1rVGXjhjFk5rB@*?oE@>^-q; ); +interface NoticeProps { + message: string; + /** Reports the rendered height, which grows when the message wraps further. */ + onLayout?: (event: LayoutChangeEvent) => void; +} + +/** + * "! Currently credit mode works only with your USD yield balance" — a caveat about the + * highlighted mode, in yellow on a faint yellow wash (Figma 26974:14829). + */ +export const SpendModeNotice = ({ message, onLayout }: NoticeProps) => ( + + + + + ! + + {message} + +); + /** The borrowed block as the spend-mode sheet shows it (Figma 25961:3504). */ export const SpendModeBorrowedPanel = (figures: BorrowedSummaryFigures) => ( @@ -133,4 +168,22 @@ const styles = StyleSheet.create({ overflow: 'hidden', }, fill: { backgroundColor: '#94F27F', borderRadius: 5, height: 10 }, + + // Figma places the badge at 15, the message at 50.5 and 12 from the top, and leaves 15 + // under it and 17.5 after it — all from the outer edge. Padding here starts inside the + // 1pt border, so each is one less; the badge then centres on the message block. + notice: { + alignItems: 'center', + borderColor: 'rgba(255, 209, 81, 0.15)', + borderRadius: 20, + borderWidth: 1, + flexDirection: 'row', + gap: 13.5, + overflow: 'hidden', + paddingBottom: 14, + paddingLeft: 14, + paddingRight: 16.5, + paddingTop: 11, + }, + noticeBadge: { alignItems: 'center', height: 22, justifyContent: 'center', width: 22 }, }); diff --git a/components/Card/NewCardDetails/SpendMode/SpendModeSegmentedControl.tsx b/components/Card/NewCardDetails/SpendMode/SpendModeSegmentedControl.tsx index 6bf99839e..dcd00dfaf 100644 --- a/components/Card/NewCardDetails/SpendMode/SpendModeSegmentedControl.tsx +++ b/components/Card/NewCardDetails/SpendMode/SpendModeSegmentedControl.tsx @@ -5,7 +5,6 @@ import Animated, { useAnimatedStyle, useSharedValue, withTiming } from 'react-na import { EASE_OUT_EXPO } from '@/components/Card/NewCardDetails/heroMotion'; import { SPEND_MODE_COPY, - SPEND_MODES, type SpendMode, } from '@/components/Card/NewCardDetails/SpendMode/spendModes'; import { Text } from '@/components/ui/text'; @@ -52,6 +51,11 @@ const SegmentLabels = ({ mode, value, tone }: SegmentLabelsProps) => ( ); interface SpendModeSegmentedControlProps { + /** + * The segments to show, left to right. Every width and the pill's position are derived + * from this list, so a hidden mode leaves no gap. + */ + modes: readonly SpendMode[]; /** * The segment the sheet is currently showing. The white pill sits on it whether or * not it is the mode in force — the button under the panels already says which one @@ -74,26 +78,28 @@ interface SpendModeSegmentedControlProps { } /** - * The three-way spend-mode switch (Figma 25961:3490). + * The spend-mode switch (Figma 25961:3490). * * Figma draws the pill 140pt wide under "Cash" and 122pt under the other two, * because each one was sized to its own caption on a 385pt artboard. Here the - * segments are even thirds of whatever width the sheet gets: the control has to + * segments are even shares of whatever width the sheet gets: the control has to * hold its proportions from a narrow phone to the desktop modal, and hand-fitting * the pill to the text would either overflow or leave a gap at every other size. */ const SpendModeSegmentedControl = ({ + modes, selected, segmentValue, onSelect, disabled = false, }: SpendModeSegmentedControlProps) => { const [trackWidth, setTrackWidth] = useState(0); - const segmentWidth = trackWidth > 0 ? (trackWidth - CONTROL_INSET * 2) / SPEND_MODES.length : 0; + const segmentWidth = trackWidth > 0 ? (trackWidth - CONTROL_INSET * 2) / modes.length : 0; - const selectedIndex = SPEND_MODES.indexOf(selected); + // Never -1: the caller always includes the selected mode (see `offeredSpendModes`). + const selectedIndex = Math.max(0, modes.indexOf(selected)); - // 0 → 2 as the pill travels. Its position and the label tones all read from this + // 0 → last index as the pill travels. Its position and the label tones all read from this // one value, so the dark copy can never arrive out of step with the pill. const position = useSharedValue(selectedIndex); @@ -117,7 +123,7 @@ const SpendModeSegmentedControl = ({ return ( - {SPEND_MODES.map((mode, index) => ( + {modes.map((mode, index) => ( = { borrowed: BORROWED_PANEL_HEIGHT, }; +/** Space from the caption to the first card, through the notice when the mode has one. */ +const leadHeight = (mode: SpendMode, noticeHeight: number) => + SPEND_MODE_COPY[mode].notice + ? CAPTION_TO_NOTICE + noticeHeight + NOTICE_TO_PANEL + : CAPTION_TO_PANEL; + /** - * Room held for the panels: the tallest mode's stack, which is Smart's two cards. + * Room held below the caption: the tallest stack among the modes the picker offers — + * Credit's notice and card while Smart is hidden, Smart's two cards when it is shown. * - * Without it the sheet changed height on every tap — Cash and Credit show one card and - * Smart two — so the drawer and its button jumped under the thumb that was browsing. Held - * at the tallest, a shorter mode leaves space below its card and nothing else moves. + * Without it the sheet changed height on every tap — the modes stack different cards — so + * the drawer and its button jumped under the thumb that was browsing. Held at the tallest + * offered, a shorter mode leaves space below its card and nothing else moves, and a hidden + * mode reserves no space at all. */ -const PANELS_MIN_HEIGHT = Math.max( - ...SPEND_MODES.map(mode => { - const panels = SPEND_MODE_COPY[mode].panels; - const cards = panels.reduce((sum, panel) => sum + PANEL_HEIGHT[panel], 0); - return cards + PANEL_GAP * Math.max(0, panels.length - 1); - }), -); +const panelsMinHeight = (modes: readonly SpendMode[], noticeHeight: number) => + Math.max( + ...modes.map(mode => { + const panels = SPEND_MODE_COPY[mode].panels; + const cards = panels.reduce((sum, panel) => sum + PANEL_HEIGHT[panel], 0); + return leadHeight(mode, noticeHeight) + cards + PANEL_GAP * Math.max(0, panels.length - 1); + }), + ); const SWAP_DURATION = 240; const ACTION_FADE_DURATION = 260; @@ -142,6 +160,19 @@ const SpendModeSheetContent = ({ // render, so the swap animation always matches the tap that caused it. const [selection, setSelection] = useState({ mode: activeMode, isForward: true }); const { mode: selected, isForward } = selection; + const modes = useMemo(() => offeredSpendModes(activeMode), [activeMode]); + // The notice's real height, which a narrow sheet raises by wrapping it to a third line. + // Measured rather than assumed, so the room held for Credit is the room it takes. + const [noticeHeight, setNoticeHeight] = useState(SPEND_MODE_NOTICE_HEIGHT); + const handleNoticeLayout = useCallback( + (event: LayoutChangeEvent) => setNoticeHeight(event.nativeEvent.layout.height), + [], + ); + const panelsStyle = useMemo( + () => ({ minHeight: panelsMinHeight(modes, noticeHeight) }), + [modes, noticeHeight], + ); + const notice = SPEND_MODE_COPY[selected].notice; useEffect(() => { setSelection({ mode: activeMode, isForward: true }); @@ -221,6 +252,7 @@ const SpendModeSheetContent = ({ - - + {/* Not keyed, so it holds its height while the keyed panels swap inside it. */} + + + {notice ? ( + + + + ) : null} {SPEND_MODE_COPY[selected].panels.map((panel, index) => ( 0 ? styles.stackedPanel : undefined}> {panel === 'balance' ? ( @@ -339,11 +382,9 @@ const styles = StyleSheet.create({ flexDirection: 'row', gap: 7, justifyContent: 'center', - marginBottom: CAPTION_TO_PANEL, marginTop: CONTROL_TO_CAPTION, }, - // Not keyed, so it holds its height while the keyed panels swap inside it. - panels: { minHeight: PANELS_MIN_HEIGHT }, + notice: { marginBottom: NOTICE_TO_PANEL }, stackedPanel: { marginTop: PANEL_GAP }, action: { borderRadius: 100, marginTop: PANEL_TO_ACTION, overflow: 'hidden' }, actionPress: { alignItems: 'center', height: 50, justifyContent: 'center' }, diff --git a/components/Card/NewCardDetails/SpendMode/spendModes.ts b/components/Card/NewCardDetails/SpendMode/spendModes.ts index b5e8858b3..0848b6978 100644 --- a/components/Card/NewCardDetails/SpendMode/spendModes.ts +++ b/components/Card/NewCardDetails/SpendMode/spendModes.ts @@ -16,6 +16,22 @@ export type SpendMode = 'cash' | 'credit' | 'smart'; /** Left-to-right order of the sheet's segmented control. */ export const SPEND_MODES = ['cash', 'credit', 'smart'] as const satisfies readonly SpendMode[]; +/** + * Modes the picker does not offer yet. Hidden, not removed: everything behind Smart still + * works, and taking it out of this list is the whole of turning it back on. + */ +export const HIDDEN_SPEND_MODES: readonly SpendMode[] = ['smart']; + +/** + * The segments the picker shows, in {@link SPEND_MODES} order. + * + * The mode in force is always one of them, hidden or not: a Safe already on Smart has to + * see its own mode highlighted, and a control whose selection is off the track has nowhere + * to put the pill. + */ +export const offeredSpendModes = (activeMode: SpendMode): readonly SpendMode[] => + SPEND_MODES.filter(mode => mode === activeMode || !HIDDEN_SPEND_MODES.includes(mode)); + /** The cards the sheet stacks under the caption. */ export type SpendModePanel = 'balance' | 'borrowed'; @@ -24,6 +40,8 @@ interface SpendModeCopy { label: string; /** One line under the control saying what this mode actually spends. */ caption: string; + /** A caveat shown between the caption and the cards, in the yellow notice. */ + notice?: string; panels: readonly SpendModePanel[]; } @@ -36,6 +54,9 @@ export const SPEND_MODE_COPY: Record = { credit: { label: 'Credit', caption: 'Spend without selling your assets', + // soUSD is the only collateral the module accepts, so a credit line comes from the USD + // yield balance and nothing else the cardholder holds. + notice: 'Currently credit mode works only with your USD yield balance', panels: ['borrowed'], }, smart: {