diff --git a/.env.example b/.env.example index c626ac77..6e2d5114 100644 --- a/.env.example +++ b/.env.example @@ -50,6 +50,22 @@ EXPO_PUBLIC_RAIN_CARD_DEPOSIT_TOKEN_SYMBOL=rUSD EXPO_PUBLIC_PERSONA_RAIN_TEMPLATE_ID= EXPO_PUBLIC_PERSONA_SANDBOX_ENVIRONMENT_ID= +# --- Card spend v2: the Credit and Smart funding modes --- +# Off means the card behaves exactly as it does today: every cardholder is on the v1 +# module spending cash, and the two modes they cannot use are not offered at all — +# the spend-mode row is not rendered rather than shown inert. +# +# Gated on this flag rather than on whether the addresses are set, so a QA build can +# point at a testnet deployment before mainnet has one. BOTH the flag and the +# addresses are required: a flag with no address is a misconfigured build, an address +# with no flag is a deliberate dark launch, and they fail differently. +EXPO_PUBLIC_CARD_SPEND_V2=true +# SolidCashModuleV2 on Fuse. One address for both halves — the core delegatecalls +# anything it does not implement into the setters, so reads use it too. +EXPO_PUBLIC_CASH_MODULE_V2_ADDRESS= +# SolidSpendLens on Fuse — the cohort-aware read serving both module generations. +EXPO_PUBLIC_SPEND_LENS_V2_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/app/(protected)/activity/[clientTxId].tsx b/app/(protected)/activity/[clientTxId].tsx index 1c577e35..60dc66a6 100644 --- a/app/(protected)/activity/[clientTxId].tsx +++ b/app/(protected)/activity/[clientTxId].tsx @@ -53,6 +53,7 @@ import { import { cn, eclipseAddress, formatNumber, toTitleCase, withRefreshToken } from '@/lib/utils'; import { cardDeclineReason } from '@/lib/utils/cardDeclineReason'; import { + cardRefundExplorerUrl, cardSweepExplorerUrl, cardTransactionExplorerUrl, formatCardAmount, @@ -309,6 +310,9 @@ const CardTransactionDetail = memo(function CardTransactionDetail({ const spend = transaction.spend_details; const sweepHash = spend?.sweep_tx_hash; const sweepUrl = cardSweepExplorerUrl(spend); + // Money coming back, and what was taken out of it on the way. See `CardRefundDetails`. + const refund = spend?.refund; + const refundUrl = cardRefundExplorerUrl(refund); const isApproved = transaction.status === 'approved'; const isDeclined = transaction.status === 'declined'; const isReversed = transaction.status === 'reversed'; @@ -338,9 +342,16 @@ const CardTransactionDetail = memo(function CardTransactionDetail({ // are different findings that our cardholder copy deliberately softens. spend.decline_reason && `Decline code: ${spend.decline_reason}`, sweepHash && `Sweep: ${sweepHash}`, + // The refund leg, because "why is my refund short" is the question this + // screen's refund rows exist to pre-empt — and the one people still write + // in about. Support needs the gross and the deduction, not just the net. + refund && `Refund: ${refund.status} ${refund.paid_usd} of ${refund.gross_usd} USD`, + refund?.cashback_deducted_usd && + `Cashback withheld from refund: ${refund.cashback_deducted_usd} USD`, + refund?.tx_hash && `Refund tx: ${refund.tx_hash}`, ].filter(Boolean); return lines.length ? `\n${lines.join('\n')}` : ''; - }, [spend, sweepHash, transaction.usd_amount]); + }, [spend, sweepHash, refund, transaction.usd_amount]); const transactionContext = useMemo( () => @@ -365,6 +376,10 @@ const CardTransactionDetail = memo(function CardTransactionDetail({ if (sweepUrl) Linking.openURL(sweepUrl); }, [sweepUrl]); + const handleRefundPress = useCallback(() => { + if (refundUrl) Linking.openURL(refundUrl); + }, [refundUrl]); + const handleLocationPress = useCallback(() => { if (!merchantPlace) return; Linking.openURL(getCardMerchantMapsUrl(merchantPlace, transaction.merchant_name)); @@ -546,6 +561,70 @@ const CardTransactionDetail = memo(function CardTransactionDetail({ ), }, + // The three figures that make a short refund explainable. + // + // Only shown when something was actually withheld. A refund that paid in + // full needs no arithmetic — the amount above already is the amount — and + // printing "Cashback returned: $0.00" on it would invent a concern the + // cardholder did not have. + refund?.cashback_deducted_usd + ? { + key: 'refund-cashback', + // Not green. Every other cashback figure on this screen is money + // coming to the user; this one is going back, and colouring it like + // a gain would misread at a glance — which on a figure that makes a + // refund smaller is the one thing worth getting right. + label: ( + + + Cashback returned + + ), + value: ( + + -{formatCardAmount(String(refund.cashback_deducted_usd), cardProvider, 'USD')} + + ), + caption: ( + + {refund.note ?? 'The cashback this purchase earned was returned with the refund'} + + ), + } + : null, + refund?.cashback_deducted_usd + ? { + key: 'refund-paid', + label: ( + + ), + value: ( + + {formatCardAmount(String(refund.paid_usd), cardProvider, 'USD')} + + ), + } + : null, + // The transfer that paid it. Its own row rather than folded into "Sweep": + // that hash is money we took to cover the purchase, this is money we sent + // back, and one label over both is how a cardholder ends up reading a + // refund as another charge. + refundUrl && refund?.tx_hash + ? { + key: 'refund-tx', + label: , + value: ( + + + + {eclipseAddress(refund.tx_hash)} + + + + + ), + } + : null, // What the merchant actually charged, in their own currency — the figure // the user will recognise from the till, against the dollars they were // billed at the top of this screen. @@ -623,6 +702,9 @@ const CardTransactionDetail = memo(function CardTransactionDetail({ transaction.currency, declineReason, transaction.refunded_amount, + refund, + refundUrl, + handleRefundPress, ]); return ( diff --git a/components/Card/NewCardDetails/CardDetailsPane.tsx b/components/Card/NewCardDetails/CardDetailsPane.tsx index 49b62492..3bc02f45 100644 --- a/components/Card/NewCardDetails/CardDetailsPane.tsx +++ b/components/Card/NewCardDetails/CardDetailsPane.tsx @@ -27,6 +27,11 @@ import CardLinksList from '@/components/Card/NewCardDetails/CardLinksList'; import CardRevealSection from '@/components/Card/NewCardDetails/CardRevealSection'; 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'; +import BorrowPositionCard from '@/components/Card/NewCardDetails/SpendMode/BorrowPositionCard'; +import BorrowPositionSheet from '@/components/Card/NewCardDetails/SpendMode/BorrowPositionSheet'; +import SpendModeSheet from '@/components/Card/NewCardDetails/SpendMode/SpendModeSheet'; +import useSpendModeFigures from '@/components/Card/NewCardDetails/SpendMode/useSpendModeFigures'; import { useCardPaneVisibility } from '@/components/Card/NewCardDetails/useCardPaneVisibility'; import { usePageLeft } from '@/components/Navbar/Sidebar'; import CashbackDetailsSheet from '@/components/Rewards/NewRewards/CashbackDetailsSheet'; @@ -107,6 +112,12 @@ const CardDetailsPane = () => { const [spendSheetSource, setSpendSheetSource] = useState( null, ); + // 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 [isSpendModeOpen, setIsSpendModeOpen] = useState(false); + // The borrow position's own sheet, opened by tapping the card that shows it. + const [isBorrowPositionOpen, setIsBorrowPositionOpen] = useState(false); // 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'), []); @@ -146,6 +157,8 @@ const CardDetailsPane = () => { // rather than merely hiding it also stops the sheet reappearing on the next visit. setSpendSheetSource(null); setIsAddToWalletOpen(false); + setIsSpendModeOpen(false); + setIsBorrowPositionOpen(false); }, [isOpen]); const isCardFrozen = cardDetails?.status === CardStatus.FROZEN; @@ -259,6 +272,34 @@ const CardDetailsPane = () => { canAddFunds={canAddFundsToCard(fundsAccess)} /> + {/* 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 + no row — it is the one surface that would give away a migration the cardholder + is deliberately never asked about. */} + {spendModeFigures.canChangeMode ? ( + + setIsSpendModeOpen(true)} + /> + + ) : 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 + they learned about their own line was a decline. */} + {spendModeFigures.showsBorrowPosition ? ( + + setIsBorrowPositionOpen(true)} + /> + + ) : null} } @@ -308,6 +349,15 @@ const CardDetailsPane = () => { }} canWithdraw={canWithdrawFromCard(fundsAccess)} /> + + void; +} + +/** + * The borrow position on the card screen (Figma 26134:23800): the same borrowed + * block the spend-mode sheet shows, on the page's own card background and + * pressable — it opens the position sheet, where the loan can be repaid. + * + * Rendering it at all is the caller's decision: the card screen gates on + * `showsBorrowPosition`, which is a credit line worth naming rather than a loan + * already drawn — an undrawn line still answers "what can I spend on this card", + * which is the question someone on Credit opens the screen with. + */ +const BorrowPositionCard = ({ onPress, ...figures }: BorrowPositionCardProps) => ( + + + +); + +const styles = StyleSheet.create({ + // Figma 385 × 123 — the block starts 3pt lower here than it does inside a sheet. + card: { height: 123, paddingTop: 26 }, +}); + +export default BorrowPositionCard; diff --git a/components/Card/NewCardDetails/SpendMode/BorrowPositionSheet.tsx b/components/Card/NewCardDetails/SpendMode/BorrowPositionSheet.tsx new file mode 100644 index 00000000..9536b473 --- /dev/null +++ b/components/Card/NewCardDetails/SpendMode/BorrowPositionSheet.tsx @@ -0,0 +1,35 @@ +import { useCallback } from 'react'; + +import BorrowPositionSheetContent, { + BORROW_POSITION_SHEET_BOTTOM, + BORROW_POSITION_SHEET_TOP, +} from '@/components/Card/NewCardDetails/SpendMode/BorrowPositionSheetContent'; +import CardBottomSheet from '@/components/Card/NewCardDetails/SpendMode/CardBottomSheet'; +import useSpendModeFigures from '@/components/Card/NewCardDetails/SpendMode/useSpendModeFigures'; + +interface BorrowPositionSheetProps { + isOpen: boolean; + onOpenChange: (open: boolean) => void; +} + +/** The borrow position, opened by tapping the card on the card screen. */ +const BorrowPositionSheet = ({ isOpen, onOpenChange }: BorrowPositionSheetProps) => { + const figures = useSpendModeFigures(); + const dismiss = useCallback(() => onOpenChange(false), [onOpenChange]); + + return ( + + {({ topPadding }) => ( + + )} + + ); +}; + +export default BorrowPositionSheet; diff --git a/components/Card/NewCardDetails/SpendMode/BorrowPositionSheetContent.tsx b/components/Card/NewCardDetails/SpendMode/BorrowPositionSheetContent.tsx new file mode 100644 index 00000000..3227d490 --- /dev/null +++ b/components/Card/NewCardDetails/SpendMode/BorrowPositionSheetContent.tsx @@ -0,0 +1,185 @@ +import { Pressable, StyleSheet, View } from 'react-native'; + +import HelpBadge from '@/components/Card/NewCardDetails/SpendMode/HelpBadge'; +import { BorrowedSummary } from '@/components/Card/NewCardDetails/SpendMode/SpendModePanels'; +import { Text } from '@/components/ui/text'; + +import type { SpendModeFigures } from '@/components/Card/NewCardDetails/SpendMode/useSpendModeFigures'; + +/** + * Vertical rhythm, measured off Figma 26134:23880 (419 × 670 on the artboard). + * Each value is the gap from the element above it. + * + * headline top 50 apy pill 139 + * borrowed card 209 at risk 433 + * cancel 567 sheet ends 670 + */ +export const BORROW_POSITION_SHEET_TOP = 50; +export const BORROW_POSITION_SHEET_BOTTOM = 53; +const AMOUNT_TO_APY = 3; +const APY_TO_CARD = 35; +const CARD_TO_RISK = 25; +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; + +interface BorrowPositionSheetContentProps { + figures: SpendModeFigures; + onDismiss: () => void; + /** Opens the repay flow. Not wired yet — see the note below. */ + onRepay?: () => void; + /** Space above the headline; sheets and the desktop modal clear different chrome. */ + 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) => ( + + + Available to borrow + + + {figures.availableToBorrow} + + + + {figures.borrowApy} APY + + + + + + {/* Nothing borrowed, nothing to repay. The sheet is now reachable before the first + draw — it is where a cardholder goes to see their line — and a Repay button on a + position of zero is an offer to do something that cannot be done. */} + {figures.hasPosition ? ( + + Repay + + ) : null} + + + {/* Figma 26134:23892 — the only red on this screen, so the tint, the border + and the glyph all key off the one colour. Shown only when the position is + actually close to liquidation; a permanent warning is one nobody reads. */} + {figures.risk === 'none' ? null : ( + + + ! + + + + {figures.risk === 'at-risk' ? 'At risk' : 'Getting close'} + + + {figures.risk === 'at-risk' + ? 'Repay now to avoid your assets being sold to cover the loan' + : 'Repay some of your loan to keep your assets safe'} + + + + )} + + + Cancel + + +); + +const styles = StyleSheet.create({ + // 17pt inset either side, which is the 385pt content block on the 419pt frame. + body: { paddingHorizontal: 17 }, + apy: { + alignItems: 'center', + alignSelf: 'center', + backgroundColor: '#2B2B2B', + borderRadius: 100, + flexDirection: 'row', + gap: 8, + height: 35, + marginTop: AMOUNT_TO_APY, + paddingLeft: 15, + paddingRight: 13, + }, + positionCard: { + backgroundColor: '#2B2B2B', + borderRadius: 20, + marginTop: APY_TO_CARD, + overflow: 'hidden', + paddingBottom: 23, + paddingTop: 23, + }, + repay: { + alignItems: 'center', + borderRadius: 30, + height: 48, + justifyContent: 'center', + marginHorizontal: 20, + marginTop: TRACK_TO_REPAY, + }, + risk: { + backgroundColor: 'rgba(195,66,72,0.15)', + borderColor: 'rgba(195,66,72,0.6)', + borderRadius: 20, + borderWidth: 1, + flexDirection: 'row', + height: 98, + marginTop: CARD_TO_RISK, + overflow: 'hidden', + paddingLeft: 19, + }, + riskIcon: { + alignItems: 'center', + backgroundColor: 'rgba(195,66,72,0.2)', + borderRadius: 100, + height: 49, + justifyContent: 'center', + marginTop: 25, + width: 50, + }, + riskText: { flex: 1, marginLeft: 20, marginTop: 20, paddingRight: 24 }, + cancel: { + alignItems: 'center', + backgroundColor: '#404040', + borderRadius: 100, + height: 50, + justifyContent: 'center', + marginTop: RISK_TO_CANCEL, + }, +}); + +export default BorrowPositionSheetContent; diff --git a/components/Card/NewCardDetails/SpendMode/CardBottomSheet.native.tsx b/components/Card/NewCardDetails/SpendMode/CardBottomSheet.native.tsx new file mode 100644 index 00000000..f50c41d6 --- /dev/null +++ b/components/Card/NewCardDetails/SpendMode/CardBottomSheet.native.tsx @@ -0,0 +1,87 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { BottomSheetBackdrop, BottomSheetModal, BottomSheetView } from '@gorhom/bottom-sheet'; + +import { + SHEET_HANDLE_HEIGHT, + SHEET_HANDLE_PADDING, + SHEET_HANDLE_SPACE, +} from '@/components/Card/NewCardDetails/SpendMode/CardBottomSheet.types'; + +import type { CardBottomSheetProps } from './CardBottomSheet.types'; + +/** + * The presentation the card screen's spend surfaces share on native: a Gorhom + * sheet rising from the bottom edge, 40pt top corners, the 73 × 5 handle. + * + * It sizes itself to its content rather than snapping to a height, because these + * sheets change height as you move through them — Smart shows two cards where + * Cash shows one. Gorhom animates that, so the sheet grows on the same curve the + * content swaps on instead of leaving a gap under the shorter states. + */ +const CardBottomSheet = ({ + isOpen, + onOpenChange, + designTop, + designBottom, + children, +}: CardBottomSheetProps) => { + const insets = useSafeAreaInsets(); + const sheetRef = useRef(null); + // Reopening has to reset whatever the body was last showing. The body keys off + // this rather than being remounted, so the sheet's own entry is untouched. + const [session, setSession] = useState(0); + + useEffect(() => { + if (isOpen) { + setSession(current => current + 1); + sheetRef.current?.present(); + } else { + sheetRef.current?.dismiss(); + } + }, [isOpen]); + + const dismiss = useCallback(() => onOpenChange(false), [onOpenChange]); + + const renderBackdrop = useCallback( + (props: React.ComponentProps) => ( + + ), + [], + ); + + return ( + + + {/* The handle is laid out above the body here, so it has already spent + part of the distance Figma measures from the sheet's top edge. */} + {children({ session, topPadding: Math.max(designTop - SHEET_HANDLE_SPACE, 0) })} + + + ); +}; + +export default CardBottomSheet; diff --git a/components/Card/NewCardDetails/SpendMode/CardBottomSheet.tsx b/components/Card/NewCardDetails/SpendMode/CardBottomSheet.tsx new file mode 100644 index 00000000..35cee921 --- /dev/null +++ b/components/Card/NewCardDetails/SpendMode/CardBottomSheet.tsx @@ -0,0 +1,80 @@ +import { useEffect, useState } from 'react'; +import { ScrollView, View } from 'react-native'; + +import { + SHEET_HANDLE_HEIGHT, + SHEET_HANDLE_PADDING, +} from '@/components/Card/NewCardDetails/SpendMode/CardBottomSheet.types'; +import ResponsiveModal, { ModalState } from '@/components/ResponsiveModal'; +import { Dialog, DialogContent } from '@/components/ui/dialog'; +import { useDimension } from '@/hooks/useDimension'; + +import type { CardBottomSheetProps } from './CardBottomSheet.types'; + +const CLOSE_STATE: ModalState = { name: 'close', number: 0 }; + +/** + * The web half of the card screen's shared sheet presentation: a sheet rising + * from the bottom edge on phones, and the standard `ResponsiveModal` from `md` + * up — the same split the cashback sheet on this screen uses, so the drag handle + * stays with the presentation it belongs to. Native has its own Gorhom version + * in `CardBottomSheet.native.tsx`. + */ +const CardBottomSheet = ({ + isOpen, + onOpenChange, + contentKey, + designTop, + designBottom, + children, +}: CardBottomSheetProps) => { + const { isScreenMedium } = useDimension(); + const [session, setSession] = useState(0); + + useEffect(() => { + if (isOpen) setSession(current => current + 1); + }, [isOpen]); + + if (isScreenMedium) { + return ( + + {/* The modal brings its own header padding, so the body starts flush. */} + {children({ session, topPadding: 0 })} + + ); + } + + return ( + + + {/* Drawn over the body rather than above it, so `designTop` is the real + distance from the sheet's edge. */} + + + {children({ session, topPadding: designTop })} + + + + ); +}; + +export default CardBottomSheet; diff --git a/components/Card/NewCardDetails/SpendMode/CardBottomSheet.types.ts b/components/Card/NewCardDetails/SpendMode/CardBottomSheet.types.ts new file mode 100644 index 00000000..70e69b57 --- /dev/null +++ b/components/Card/NewCardDetails/SpendMode/CardBottomSheet.types.ts @@ -0,0 +1,29 @@ +import type { ReactNode } from 'react'; + +export interface CardSheetBody { + /** Bumped each time the sheet opens, for state that should reset per visit. */ + session: number; + /** + * 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 + * header padding, so the presentation works this out rather than the body. + */ + topPadding: number; +} + +export interface CardBottomSheetProps { + isOpen: boolean; + onOpenChange: (open: boolean) => void; + /** Distinguishes this sheet in `ResponsiveModal`'s animation bookkeeping. */ + contentKey: string; + /** Where Figma puts the body's first element, measured from the sheet's top edge. */ + designTop: number; + /** Space Figma leaves below the body's last element, before any safe-area inset. */ + designBottom: number; + children: (body: CardSheetBody) => ReactNode; +} + +/** Drag handle: 15pt of padding over a 5pt indicator, on both platforms. */ +export const SHEET_HANDLE_PADDING = 15; +export const SHEET_HANDLE_HEIGHT = 5; +export const SHEET_HANDLE_SPACE = SHEET_HANDLE_PADDING + SHEET_HANDLE_HEIGHT; diff --git a/components/Card/NewCardDetails/SpendMode/HelpBadge.tsx b/components/Card/NewCardDetails/SpendMode/HelpBadge.tsx new file mode 100644 index 00000000..ec6116a6 --- /dev/null +++ b/components/Card/NewCardDetails/SpendMode/HelpBadge.tsx @@ -0,0 +1,27 @@ +import { StyleSheet, View } from 'react-native'; + +import { Text } from '@/components/ui/text'; + +/** + * The 20pt "?" the spend-mode surfaces put beside a figure or a caption (Figma + * exports it as a circle at 20% white with a plain type glyph). It explains + * nothing yet — the copy behind it is a later pass. + */ +const HelpBadge = () => ( + + ? + +); + +const styles = StyleSheet.create({ + badge: { + alignItems: 'center', + backgroundColor: 'rgba(255,255,255,0.2)', + borderRadius: 10, + height: 20, + justifyContent: 'center', + width: 20, + }, +}); + +export default HelpBadge; diff --git a/components/Card/NewCardDetails/SpendMode/SpendModePanels.tsx b/components/Card/NewCardDetails/SpendMode/SpendModePanels.tsx new file mode 100644 index 00000000..72ad85ee --- /dev/null +++ b/components/Card/NewCardDetails/SpendMode/SpendModePanels.tsx @@ -0,0 +1,125 @@ +import { Pressable, StyleSheet, View } from 'react-native'; + +import { Text } from '@/components/ui/text'; + +import type { SpendModeFigures } from '@/components/Card/NewCardDetails/SpendMode/useSpendModeFigures'; + +/** + * The cards the spend-mode surfaces are built from. + * + * Presentation only: every figure arrives as a prop from `useSpendModeFigures`, so the + * same block shows the same number wherever it is placed. + */ + +interface BalancePanelProps { + balance: string; + onAddFunds?: () => void; +} + +/** "Your USDC balance / $0.0" with the Add funds pill on the right. */ +export const SpendModeBalancePanel = ({ balance, onAddFunds }: BalancePanelProps) => ( + + + + Your USDC balance + + {balance} + + + Add funds + + +); + +/** + * "Borrowed / $0.80 / $246.5 … at 5.57% APY" over the drawn-down track — the + * block three surfaces share: the spend-mode sheet's Credit and Smart panels, + * the borrow-position card on the card screen, and that card's own sheet. + * + * It draws no box of its own. Each of those three sits it on a different + * background at a different inset, and the only thing that moves between them is + * where the block starts, so the container owns the padding and this owns the + * 35 / 15 rhythm inside it. + */ +export type BorrowedSummaryFigures = Pick< + SpendModeFigures, + 'borrowed' | 'creditLimit' | 'borrowApy' | 'borrowedProgress' +>; + +export const BorrowedSummary = ({ + borrowed, + creditLimit, + borrowApy, + borrowedProgress, +}: BorrowedSummaryFigures) => ( + <> + Borrowed + + + {borrowed} / {creditLimit} + + + at {borrowApy} APY + + + + {/* Clamped upstream, so this can only ever be 0–100%. */} + + + +); + +/** The borrowed block as the spend-mode sheet shows it (Figma 25961:3504). */ +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. + balancePanel: { + alignItems: 'center', + flexDirection: 'row', + height: 106, + paddingLeft: 23, + paddingRight: 24, + }, + balanceText: { flex: 1, gap: 10 }, + addFunds: { + alignItems: 'center', + backgroundColor: '#FFFFFF', + borderRadius: 100, + height: 35, + justifyContent: 'center', + paddingHorizontal: 13, + }, + + // 126pt tall: the title at 23, the figures 35 below it, the track 15 below those. + borrowedPanel: { height: 126, paddingTop: 23 }, + borrowedRow: { + flexDirection: 'row', + justifyContent: 'space-between', + marginTop: 17, + paddingLeft: 20, + paddingRight: 25, + }, + // A 10pt round-capped stroke in Figma, so a 10pt bar with a 5pt radius here. + track: { + backgroundColor: '#464646', + borderRadius: 5, + height: 10, + marginHorizontal: 23, + marginTop: 15, + overflow: 'hidden', + }, + fill: { backgroundColor: '#94F27F', borderRadius: 5, height: 10 }, +}); diff --git a/components/Card/NewCardDetails/SpendMode/SpendModeSegmentedControl.tsx b/components/Card/NewCardDetails/SpendMode/SpendModeSegmentedControl.tsx new file mode 100644 index 00000000..2c203b53 --- /dev/null +++ b/components/Card/NewCardDetails/SpendMode/SpendModeSegmentedControl.tsx @@ -0,0 +1,218 @@ +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 { 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'; +import { cn } from '@/lib/utils'; + +/** + * Panel geometry, measured off the Figma control (25961:3490, 385 × 73 on the + * 419pt artboard). The pill sits 3pt inside the track on every edge, which is + * what gives it its 67pt height. + */ +const CONTROL_HEIGHT = 73; +const CONTROL_INSET = 3; +/** How long the pill takes to reach the segment that was tapped. */ +const SLIDE_DURATION = 340; + +interface SegmentLabelsProps { + mode: SpendMode; + /** The figure under the name, read from the chain rather than from copy. */ + value: string; + /** Dark is the copy that shows through the white pill. */ + tone: 'light' | 'dark'; +} + +/** "Cash / Add USDC", stacked and centred — the same block in both tones. */ +const SegmentLabels = ({ mode, value, tone }: SegmentLabelsProps) => ( + + + {SPEND_MODE_COPY[mode].label} + + + {value} + + +); + +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. + */ + activeMode: SpendMode; + /** The figure under each segment name. */ + segmentValue: Record; + onSelect: (mode: SpendMode) => void; + /** + * Ignores taps. Set while a mode change is being signed: the commit captured the + * mode as it was at the press, so letting the selection move underneath it would + * leave the sheet describing one mode while another is on its way on-chain. + * + * No dimming of its own — the pill still shows what is being confirmed, and greying + * the control would hide the one thing worth looking at while waiting. + */ + disabled?: boolean; +} + +/** + * The three-way 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 + * 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 = ({ + selected, + activeMode, + segmentValue, + onSelect, + disabled = false, +}: SpendModeSegmentedControlProps) => { + const [trackWidth, setTrackWidth] = useState(0); + 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. + const position = useSharedValue(selectedIndex); + + useEffect(() => { + position.value = withTiming(selectedIndex, { + duration: SLIDE_DURATION, + easing: EASE_OUT_EXPO, + }); + }, [position, selectedIndex]); + + const handleLayout = useCallback( + (event: LayoutChangeEvent) => setTrackWidth(event.nativeEvent.layout.width), + [], + ); + + 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 ( + + + {SPEND_MODES.map((mode, index) => ( + + ))} + + ); +}; + +interface SegmentProps { + mode: SpendMode; + value: string; + index: number; + activeIndex: number; + position: ReturnType>; + onSelect: (mode: SpendMode) => void; + disabled: boolean; +} + +/** + * 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. + */ +const Segment = ({ + mode, + value, + index, + activeIndex, + position, + onSelect, + disabled, +}: SegmentProps) => { + const darkStyle = useAnimatedStyle(() => ({ + opacity: index === activeIndex ? Math.max(0, 1 - Math.abs(position.value - index)) : 0, + })); + + return ( + onSelect(mode)} + style={styles.segment} + > + + + + + + ); +}; + +const styles = StyleSheet.create({ + track: { + backgroundColor: '#2B2B2B', + borderRadius: 200, + flexDirection: 'row', + height: CONTROL_HEIGHT, + overflow: 'hidden', + padding: CONTROL_INSET, + }, + pill: { + borderRadius: 200, + bottom: CONTROL_INSET, + left: CONTROL_INSET, + position: 'absolute', + top: CONTROL_INSET, + }, + segment: { flex: 1, justifyContent: 'center' }, + // 25pt name over a 14pt figure with 5 between, which is Figma's 13 / 43 pair + // once the block is centred in the 67pt pill. + labels: { alignItems: 'center', flex: 1, gap: 5, justifyContent: 'center' }, +}); + +export default SpendModeSegmentedControl; diff --git a/components/Card/NewCardDetails/SpendMode/SpendModeSheet.tsx b/components/Card/NewCardDetails/SpendMode/SpendModeSheet.tsx new file mode 100644 index 00000000..9493d62c --- /dev/null +++ b/components/Card/NewCardDetails/SpendMode/SpendModeSheet.tsx @@ -0,0 +1,68 @@ +import { useCallback } from 'react'; + +import CardBottomSheet from '@/components/Card/NewCardDetails/SpendMode/CardBottomSheet'; +import SpendModeSheetContent, { + SPEND_MODE_SHEET_BOTTOM, + SPEND_MODE_SHEET_TOP, +} from '@/components/Card/NewCardDetails/SpendMode/SpendModeSheetContent'; +import useSpendModeFigures from '@/components/Card/NewCardDetails/SpendMode/useSpendModeFigures'; +import { useCardSpendRegistration } from '@/hooks/useCardSpendRegistration'; + +import type { SpendModeSheetProps } from './SpendModeSheet.types'; +import type { SpendMode } from '@/components/Card/NewCardDetails/SpendMode/spendModes'; + +/** + * The spend-mode picker (Figma 25847:3581, 25961:3413, 25961:3538). + * + * Owns the commit: the content below is presentation, and this is where a chosen mode + * becomes a signature. The sheet closes only once the change is on-chain, so a cardholder + * never watches it dismiss and then finds the mode unchanged — and stays open on failure + * with the reason, because a silently closed sheet reads as success. + */ +const SpendModeSheet = ({ + isOpen, + onOpenChange, + activeMode = 'cash', + onAddFunds, +}: SpendModeSheetProps) => { + const figures = useSpendModeFigures(); + const { switchMode, isSwitchingMode, error } = useCardSpendRegistration(); + + const dismiss = useCallback(() => onOpenChange(false), [onOpenChange]); + + const confirm = useCallback( + async (mode: SpendMode) => { + // `switchMode` resolves false when the signature prompt was dismissed, which is not + // a failure and must not close the sheet — the cardholder is still deciding. + const changed = await switchMode(mode).catch(() => false); + if (changed) onOpenChange(false); + }, + [switchMode, onOpenChange], + ); + + return ( + + {({ session, topPadding }) => ( + + )} + + ); +}; + +export default SpendModeSheet; diff --git a/components/Card/NewCardDetails/SpendMode/SpendModeSheet.types.ts b/components/Card/NewCardDetails/SpendMode/SpendModeSheet.types.ts new file mode 100644 index 00000000..2b1e2619 --- /dev/null +++ b/components/Card/NewCardDetails/SpendMode/SpendModeSheet.types.ts @@ -0,0 +1,13 @@ +import type { SpendMode } from '@/components/Card/NewCardDetails/SpendMode/spendModes'; + +export interface SpendModeSheetProps { + isOpen: boolean; + onOpenChange: (open: boolean) => void; + /** + * The mode currently in force. Drives the white pill and turns the button into a + * plain dismissal rather than a "Change to …". + */ + activeMode?: SpendMode; + /** Opens the add-funds flow from the balance panel. */ + onAddFunds?: () => void; +} diff --git a/components/Card/NewCardDetails/SpendMode/SpendModeSheetContent.tsx b/components/Card/NewCardDetails/SpendMode/SpendModeSheetContent.tsx new file mode 100644 index 00000000..95eee74b --- /dev/null +++ b/components/Card/NewCardDetails/SpendMode/SpendModeSheetContent.tsx @@ -0,0 +1,305 @@ +import { useCallback, useEffect, useState } from 'react'; +import { ActivityIndicator, Pressable, StyleSheet, View } from 'react-native'; +import Animated, { + FadeIn, + FadeInLeft, + FadeInRight, + FadeOut, + FadeOutLeft, + FadeOutRight, + interpolateColor, + useAnimatedStyle, + useSharedValue, + withTiming, +} from 'react-native-reanimated'; + +import { EASE_OUT_QUINT } from '@/components/Card/NewCardDetails/heroMotion'; +import HelpBadge from '@/components/Card/NewCardDetails/SpendMode/HelpBadge'; +import { + SpendModeBalancePanel, + SpendModeBorrowedPanel, +} from '@/components/Card/NewCardDetails/SpendMode/SpendModePanels'; +import { + SPEND_MODE_COPY, + SPEND_MODES, + type SpendMode, +} from '@/components/Card/NewCardDetails/SpendMode/spendModes'; +import SpendModeSegmentedControl from '@/components/Card/NewCardDetails/SpendMode/SpendModeSegmentedControl'; +import { Text } from '@/components/ui/text'; +import { cn } from '@/lib/utils'; + +import type { SpendModeFigures } from '@/components/Card/NewCardDetails/SpendMode/useSpendModeFigures'; + +/** + * Vertical rhythm, measured off the Figma sheets (419pt artboard, 568pt tall for + * the Credit frame). Everything below is the gap from the element above it, so + * Smart's second card can push the button down without the rest drifting. + * + * drag handle ends 20 heading top 55 + * control 126 caption 218 + * first panel 287 action button 461 + * + * The heading is given a 36pt line box rather than Figma's 24, which would clip + * a 30pt face on Android; starting it at 55 puts that taller box back on the + * centre line Figma's 61–85 box sits on. + */ +const HEADING_TO_CONTROL = 35; +const CONTROL_TO_CAPTION = 19; +const CAPTION_TO_PANEL = 49; +const PANEL_GAP = 12; +const PANEL_TO_ACTION = 48; +/** Where the heading starts, measured from the sheet's top edge. */ +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 SWAP_DURATION = 240; +const ACTION_FADE_DURATION = 260; +/** How far the action button fades back while the switch is in flight. */ +const BUSY_DIM = 0.45; + +interface SpendModeSheetContentProps { + /** + * The mode in force. It decides which segment goes solid white and turns the + * button into a plain dismissal rather than a "Change to …". + */ + activeMode: SpendMode; + /** Every figure the panels and the control show. */ + figures: SpendModeFigures; + /** + * Bumped every time the sheet opens, which resets the selection so a sheet + * reopened after browsing Credit doesn't come back still showing it. + */ + session: number; + /** Commit the previewed mode. Resolves once the change is on-chain. */ + onConfirm: (mode: SpendMode) => Promise; + /** True while the switch is signing or confirming. */ + isSwitching: boolean; + /** Why the last attempt failed, or null. */ + error?: string | null; + onDismiss: () => void; + onAddFunds?: () => void; + /** Space above the heading; sheets and the desktop modal clear different chrome. */ + topPadding?: number; +} + +/** + * The body of the spend-mode sheet (Figma 25847:3581, 25961:3413, 25961:3538): + * a heading, the three-way switch, a line explaining the highlighted mode, its + * cards, and the button at the foot. + * + * Tapping a segment only *previews* that mode — the pill slides, the caption and + * cards swap in from the side it came from — and nothing is committed until the + * button at the foot is pressed. That separation is deliberate: the first switch + * away from Cash also migrates the Safe between spend modules, which is a + * signature, and browsing must never cost one. + */ +const SpendModeSheetContent = ({ + activeMode, + figures, + session, + onConfirm, + isSwitching, + error, + onDismiss, + onAddFunds, + topPadding = SPEND_MODE_SHEET_TOP, +}: 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. + const [selection, setSelection] = useState({ mode: activeMode, isForward: true }); + const { mode: selected, isForward } = selection; + + useEffect(() => { + setSelection({ mode: activeMode, isForward: true }); + }, [activeMode, session]); + + const handleSelect = useCallback((next: SpendMode) => { + setSelection(current => + current.mode === next + ? current + : { + mode: next, + isForward: SPEND_MODES.indexOf(next) > SPEND_MODES.indexOf(current.mode), + }, + ); + }, []); + + // On the mode already in force there is nothing to change to, so the button is + // the grey dismissal from the Cash frame instead of the green one. + const isActiveSelected = selected === activeMode; + const actionProgress = useSharedValue(isActiveSelected ? 0 : 1); + + useEffect(() => { + actionProgress.value = withTiming(isActiveSelected ? 0 : 1, { + duration: ACTION_FADE_DURATION, + easing: EASE_OUT_QUINT, + }); + }, [actionProgress, isActiveSelected]); + + // Dims the button while the switch is in flight. `disabled` below already blocks the + // tap, but nothing said so: a fully lit button that ignores presses reads as broken + // rather than busy, and this one can sit there for a while — the first switch away + // from Cash is a migration, which is four contract calls behind one signature. + const busyProgress = useSharedValue(isSwitching ? 1 : 0); + + useEffect(() => { + busyProgress.value = withTiming(isSwitching ? 1 : 0, { + duration: ACTION_FADE_DURATION, + easing: EASE_OUT_QUINT, + }); + }, [busyProgress, isSwitching]); + + const actionStyle = useAnimatedStyle(() => ({ + backgroundColor: interpolateColor(actionProgress.value, [0, 1], ['#404040', '#94F27F']), + opacity: 1 - busyProgress.value * BUSY_DIM, + })); + + const entering = (isForward ? FadeInRight : FadeInLeft).duration(SWAP_DURATION); + const exiting = (isForward ? FadeOutLeft : FadeOutRight).duration(SWAP_DURATION); + + // On the mode already in force the button dismisses; anywhere else it commits. Guarded + // against a double press because the first one costs a signature and, on a migration, + // four contract calls. + const handleAction = useCallback(() => { + if (isSwitching) return; + if (isActiveSelected) { + onDismiss(); + return; + } + void onConfirm(selected); + }, [isActiveSelected, isSwitching, onConfirm, onDismiss, selected]); + + return ( + + + Select spend mode + + + + + + + {/* Keyed on the mode so the caption and the cards leave together, towards + the segment the user came from. */} + + + {SPEND_MODE_COPY[selected].caption} + + + + + + {SPEND_MODE_COPY[selected].panels.map((panel, index) => ( + 0 ? styles.stackedPanel : undefined}> + {panel === 'balance' ? ( + + ) : ( + + )} + + ))} + + + {error ? ( + + {error} + + ) : null} + + + + {/* Keyed on what is shown, so the white "Cancel" cross-fades into the + black "Change to …" rather than switching colour mid-tint — and so + the spinner fades in with its label instead of appearing beside a + caption that has already changed underneath it. */} + + {/* Beside the label rather than in place of it. "Confirming…" is worth + keeping: this is a signature followed by on-chain confirmation, and a + bare spinner on a sheet that has not moved invites a second tap. */} + {isSwitching ? ( + + ) : null} + + {isSwitching + ? 'Confirming…' + : isActiveSelected + ? 'Cancel' + : `Change to ${SPEND_MODE_COPY[selected].label}`} + + + + + + ); +}; + +const styles = StyleSheet.create({ + // 17pt inset either side, which is the 385pt content block on the 419pt frame. + body: { paddingHorizontal: 17 }, + control: { marginTop: HEADING_TO_CONTROL }, + caption: { + alignItems: 'center', + flexDirection: 'row', + gap: 7, + justifyContent: 'center', + marginBottom: CAPTION_TO_PANEL, + marginTop: CONTROL_TO_CAPTION, + }, + stackedPanel: { marginTop: PANEL_GAP }, + action: { borderRadius: 100, marginTop: PANEL_TO_ACTION, overflow: 'hidden' }, + actionPress: { alignItems: 'center', height: 50, justifyContent: 'center' }, + actionContent: { alignItems: 'center', flexDirection: 'row', gap: 8, justifyContent: 'center' }, +}); + +export default SpendModeSheetContent; diff --git a/components/Card/NewCardDetails/SpendMode/spendModes.ts b/components/Card/NewCardDetails/SpendMode/spendModes.ts new file mode 100644 index 00000000..b5e8858b --- /dev/null +++ b/components/Card/NewCardDetails/SpendMode/spendModes.ts @@ -0,0 +1,51 @@ +/** + * The three ways the card can be told to draw on the user's funds (Figma + * 25847:3581, 25961:3413, 25961:3538). + * + * Copy only. Every figure these surfaces show comes from `useSpendModeFigures`, + * which reads them from whichever spend module operates the Safe — the words + * live here and the numbers never do, so there is no second place for a stale + * figure to hide. + * + * `cash` is what the card does on the v1 module, which is where every cardholder + * starts and the only thing v1 can do. The other two need v2, and a Safe is moved + * there the first time its owner picks one. + */ +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[]; + +/** The cards the sheet stacks under the caption. */ +export type SpendModePanel = 'balance' | 'borrowed'; + +interface SpendModeCopy { + /** Segment name, and the word the "Change to …" button ends on. */ + label: string; + /** One line under the control saying what this mode actually spends. */ + caption: string; + panels: readonly SpendModePanel[]; +} + +export const SPEND_MODE_COPY: Record = { + cash: { + label: 'Cash', + caption: 'Spend your USDC asset balance', + panels: ['balance'], + }, + credit: { + label: 'Credit', + caption: 'Spend without selling your assets', + panels: ['borrowed'], + }, + smart: { + label: 'Smart', + caption: 'Use credit to cover cash purchases', + // Smart spends cash first and covers the rest with credit, so it is the one + // mode that answers both questions and shows both cards. + panels: ['balance', 'borrowed'], + }, +}; + +/** Which modes the v1 module can serve. Everything else needs a Safe on v2. */ +export const V1_MODES: readonly SpendMode[] = ['cash']; diff --git a/components/Card/NewCardDetails/SpendMode/useSpendModeFigures.ts b/components/Card/NewCardDetails/SpendMode/useSpendModeFigures.ts new file mode 100644 index 00000000..fc3ac2b6 --- /dev/null +++ b/components/Card/NewCardDetails/SpendMode/useSpendModeFigures.ts @@ -0,0 +1,221 @@ +import { useMemo } from 'react'; + +import { formatUsd, ONE_USD } from '@/constants/cardSpendModule'; +import { + borrowApyPercent, + borrowedProgress, + type BorrowRisk, + borrowRisk, + formatApy, + 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'; + +/** Everything the spend-mode surfaces render, already formatted. */ +export interface SpendModeFigures { + /** The mode the card is funded by today. `cash` for every cardholder on v1. */ + mode: SpendMode; + /** + * 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. + */ + canChangeMode: boolean; + /** A switch that is armed but not in force, or null. Null at the launch `modeDelay` of 0. */ + pendingMode: SpendMode | null; + + /** "$1,240" — the user's own holdings a card can draw on. */ + cashBalance: string; + /** "$0.80" — outstanding debt, including accrued interest. */ + borrowed: string; + /** "$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. + * + * 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. + */ + availableToBorrow: string; + /** "5.57%" — the borrow rate, compounded to an annual figure. */ + borrowApy: string; + /** 0–1, for the drawn-down track. */ + borrowedProgress: number; + + /** The figure under each segment name in the control. */ + segmentValue: Record; + + /** Whether there is a loan at all. What gates the Repay button. */ + hasPosition: boolean; + /** Whether the Safe's mode can draw on credit at all. False for every v1 cardholder. */ + canBorrow: boolean; + /** + * Whether the card screen shows the borrow-position card. + * + * It used to be {@link hasPosition}, which meant the card only ever appeared to someone + * who had already borrowed — so a cardholder on Credit had no way to see the line they + * were about to spend against, and the first thing they learned about their own credit + * was a declined tap. A line worth naming is worth showing before it is drawn. + * + * Still hidden in two cases, because both would be noise rather than information: a Cash + * cardholder, who has no credit line and is not being sold one here, and a Safe on Credit + * with nothing to lend against, where the card would read "$0 / $0". Debt always shows it + * regardless of mode — a Safe switched back to Cash while still carrying a loan must not + * hide that loan. + */ + showsBorrowPosition: boolean; + /** How close the position is to liquidation. `none` when there is nothing borrowed. */ + risk: BorrowRisk; + /** The health factor as a number, or null when there is no debt to measure against. */ + healthFactor: number | null; + /** + * False when some escrowed collateral cannot be priced. + * + * Worth surfacing rather than hiding: borrowing power is understated in that state AND + * the module refuses to liquidate, so the cardholder sees their line shrink for no + * visible reason. + */ + fullyPriced: boolean; + + isLoading: boolean; +} + +/** Whole dollars to the 6-decimal scale every on-chain USD figure here uses. */ +const usdToMicro = (dollars: number): bigint => + BigInt(Math.max(0, Math.round(dollars * Number(ONE_USD)))); + +/** + * The spend-mode surfaces' single source of figures. + * + * Composed here rather than in each sheet so the card screen, the mode picker and the + * borrow-position sheet cannot show three different answers to the same question — they + * render the same numbers on three different backgrounds, which is exactly the case where + * independently derived values drift. + * + * Every figure is on-chain, read through `useCardSpendRegistration`, rather than recomputed + * from parts. A quote the app derives itself is a quote that can disagree with what the + * spend would actually do. + * + * The credit figures come from `SolidSpendLens` rather than from `SolidCashModuleV2` + * directly, and that distinction is the whole reason the Credit sheet shows anything at all: + * the module can only value collateral it has already ESCROWED, and it escrows nothing until + * the first credit spend. The lens adds `prospectiveCollateralUsd` — the Safe's loose balance + * at the ratio a lock would use — which is what a cardholder considering Credit for the first + * time actually has. + */ +export const useSpendModeFigures = (): SpendModeFigures => { + const { + mode, + canChangeMode, + pendingMode, + position, + borrowApyPerSecond, + isLoading: isRegistrationLoading, + } = 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); + const debt = position?.debtUsd ?? 0n; + + // The whole line, and the reason the card no longer reads "$0 / $0" for a cardholder + // holding soUSD. + // + // Two halves, because collateral reaches the module in two states. `borrowingPowerUsd` + // weights what is already ESCROWED — empty until the first credit spend, since that is + // when `spendCredit` locks anything — and `prospectivePowerUsd` weights what is still + // LOOSE in the Safe, at the same buffered ratio the lock would use. Showing only the + // first quoted a line of zero to everyone who had not borrowed yet, which is precisely + // the population being asked to consider Credit. + // + // Gross, and deliberately not `debt + something`: the module checks power against total + // 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; + 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" + // is still a position the cardholder owns and should be able to look at. + const canBorrow = mode === 'credit' || mode === 'smart'; + + const cashLabel = formatUsd(cashMicro); + const availableLabel = formatUsd(available); + + 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, + pendingMode, + + cashBalance: cashLabel, + borrowed: formatUsd(debt), + // What the cardholder's collateral backs, drawn or not — which is what the + // "borrowed / limit" pair means. Deliberately the collateral-derived figure rather + // than `debt + available`: the latter is clamped by the rolling spend limits, so the + // advertised credit line would shrink every time the cardholder bought lunch on + // debit, which is not what a limit means to anyone reading it. + creditLimit: formatUsd(creditLine), + availableToBorrow: availableLabel, + borrowApy: formatApy(apy), + // Against the same line rendered beside it, so the bar and the figures agree. + borrowedProgress: borrowedProgress(debt, creditLine), + + segmentValue: { + // The design's empty state: a cardholder with nothing to spend is told what to do + // rather than shown a zero. + cash: cashMicro > 0n ? cashLabel : 'Add USDC', + credit: availableLabel, + // `max`, not a sum — and the same rule the authorize path applies. One transaction + // 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), + }, + + 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)), + risk: position ? borrowRisk(position.healthFactorWad, debt) : ('none' as BorrowRisk), + healthFactor: position ? healthFactorToNumber(position.healthFactorWad) : null, + fullyPriced: position?.fullyPriced ?? true, + + isLoading: isRegistrationLoading || isBalanceLoading || isAccessLoading, + }; + }, [ + mode, + canChangeMode, + pendingMode, + position, + borrowApyPerSecond, + cashBalanceUsd, + hasSpendModeAccess, + isRegistrationLoading, + isBalanceLoading, + isAccessLoading, + ]); +}; + +export default useSpendModeFigures; diff --git a/components/Card/NewCardDetails/SpendingModeCard.tsx b/components/Card/NewCardDetails/SpendingModeCard.tsx new file mode 100644 index 00000000..7607d24b --- /dev/null +++ b/components/Card/NewCardDetails/SpendingModeCard.tsx @@ -0,0 +1,71 @@ +import { Pressable, StyleSheet, View } from 'react-native'; + +import { InlineChevronIcon } from '@/components/Card/NewCardDetails/icons'; +import { + SPEND_MODE_COPY, + type SpendMode, +} from '@/components/Card/NewCardDetails/SpendMode/spendModes'; +import { Text } from '@/components/ui/text'; + +interface SpendingModeCardProps { + /** The mode the card is spending in today. */ + mode?: SpendMode; + onChangeMode: () => void; +} + +/** + * "Spend mode › … Credit [Change]" (Figma 26134:24137), the row between the + * action icons and the borrow position. + * + * The chevron beside the label is drawn but inert: the design puts an + * explanation behind it that hasn't been written yet. + * + * The mode it names is read from whichever spend module operates the Safe, and is + * `cash` for every cardholder still on v1 — which is all of them at launch. The + * card screen only renders this row once the other modes are actually reachable, + * so it never offers a change it cannot make. + */ +const SpendingModeCard = ({ mode = 'cash', onChangeMode }: SpendingModeCardProps) => ( + + + Spend mode + + + + + {SPEND_MODE_COPY[mode].label} + + + Change + + + +); + +const styles = StyleSheet.create({ + // Figma 385 × 61, inset 17 either side, everything centred on the height. + row: { + alignItems: 'center', + flexDirection: 'row', + height: 61, + justifyContent: 'space-between', + paddingHorizontal: 17, + }, + label: { alignItems: 'center', flexDirection: 'row', gap: 9 }, + value: { alignItems: 'center', flexDirection: 'row', gap: 15 }, + button: { + alignItems: 'center', + borderRadius: 100, + height: 35, + justifyContent: 'center', + paddingHorizontal: 12, + }, +}); + +export default SpendingModeCard; diff --git a/components/Card/NewCardDetails/heroMotion.tsx b/components/Card/NewCardDetails/heroMotion.tsx index 023600d9..951241a5 100644 --- a/components/Card/NewCardDetails/heroMotion.tsx +++ b/components/Card/NewCardDetails/heroMotion.tsx @@ -82,6 +82,14 @@ export const HERO_ENTER = { title: { delay: 180, fade: 280, transform: 300, translateY: 10 }, /** Figma 21903:903 — the Add funds / Freeze / Settings row. */ actions: { delay: 300, fade: 320, transform: 360, translateY: 24 }, + /** + * The spend-mode row (Figma 26134:24137). The motion frame predates both it and + * the borrow card below, so the two take the steps between the action row and + * the cashback card rather than timings of their own. + */ + spendMode: { delay: 350, fade: 320, transform: 360, translateY: 26 }, + /** The borrow position card (Figma 26134:23800). */ + borrowPosition: { delay: 375, fade: 320, transform: 360, translateY: 27 }, /** Figma 21903:936 — the cashback card. */ cashback: { delay: 400, fade: 320, transform: 360, translateY: 28 }, /** Figma 21903:950 — the transactions / rewards / support list. */ diff --git a/components/Card/NewCardDetails/icons.tsx b/components/Card/NewCardDetails/icons.tsx index da07780d..9e21ab3d 100644 --- a/components/Card/NewCardDetails/icons.tsx +++ b/components/Card/NewCardDetails/icons.tsx @@ -305,6 +305,19 @@ export const RowChevronIcon = () => ( ); +/** Figma 26134:24140 — the smaller chevron beside an inline label, e.g. "Spend mode". */ +export const InlineChevronIcon = () => ( + + + +); + /** * Confirmation shown in the copy button's place for a moment after a value is copied. * The design doesn't specify one, so it's drawn to match CopyIcon exactly — same box, diff --git a/components/ui/dialog.tsx b/components/ui/dialog.tsx index 468cecff..18b02bb3 100644 --- a/components/ui/dialog.tsx +++ b/components/ui/dialog.tsx @@ -24,6 +24,18 @@ import { cn } from '@/lib/utils'; */ export const MOBILE_SHEET_TOP_RATIO = 0.05; +/** + * How long a web bottom sheet takes to slide away, and how long its tree is kept + * alive so it can. Shared by the exit animation and the unmount that follows it. + */ +const WEB_SHEET_EXIT_MS = 180; +/** + * Slack between the animation ending and the unmount, so a frame lost to a busy main + * thread cannot cut the slide off at the very end — which looks exactly like the + * abrupt close this whole mechanism exists to remove. + */ +const WEB_SHEET_UNMOUNT_BUFFER_MS = 60; + const Dialog = DialogPrimitive.Root; const DialogTrigger = DialogPrimitive.Trigger; @@ -33,7 +45,9 @@ const DialogPortal = DialogPrimitive.Portal; const DialogClose = DialogPrimitive.Close; const DialogOverlayWeb = React.forwardRef( - ({ className, ...props }, ref) => { + ({ className, closeOnPress, ...props }, ref) => { + const { onOpenChange } = DialogPrimitive.useRootContext(); + const handlePointerDown = (event: any) => { // Check if the clicked element is a toast const target = event.target as HTMLElement; @@ -41,6 +55,23 @@ const DialogOverlayWeb = React.forwardRef { + if (!isWebBottomSheet) return; + + if (open) { + setIsSheetPresent(true); + return; + } + + const timer = setTimeout( + () => setIsSheetPresent(false), + WEB_SHEET_EXIT_MS + WEB_SHEET_UNMOUNT_BUFFER_MS, + ); + + return () => clearTimeout(timer); + }, [isWebBottomSheet, open]); + const webBounceStyle = useAnimatedStyle(() => { if (!isWebBounce) return {}; return { @@ -243,16 +306,41 @@ const DialogContent = React.forwardRef< } if (isWebBottomSheet) { + // Closed and the slide has finished. Same result as letting Radix unmount, which + // is what happened here before presence moved into this component. + if (!isSheetPresent) return null; + return ( - - - - {content} - + + {/* Dismisses on a backdrop click, the same as the native bottom-sheet + branch above. Tapping beside a sheet to close it is how every sheet + on both platforms behaves, and this was the one that did not. + + The backdrop fades with a plain CSS transition rather than a Reanimated + one: it is a DOM element we style directly, so there is nothing to work + around, and it keeps the two halves of the exit independent — the sheet + slides, the dimming lifts, neither waits on the other. Pointer events go + first, so a click during the fade cannot reopen anything underneath. */} + + {/* Removed on close rather than left mounted: an exit animation is + triggered by the node going away, and with `forceMount` above holding + Radix's tree open this is the only thing that still does. */} + {open ? ( + + {content} + + ) : null} diff --git a/constants/cardSpendV2.ts b/constants/cardSpendV2.ts new file mode 100644 index 00000000..62eb36dd --- /dev/null +++ b/constants/cardSpendV2.ts @@ -0,0 +1,182 @@ +import { ADDRESSES, EXPO_PUBLIC_CARD_SPEND_V2 } from '@/lib/config'; + +import type { SpendMode } from '@/components/Card/NewCardDetails/SpendMode/spendModes'; +import type { Address } from 'viem'; + +/** + * Client-side constants for card spend v2 — the module that adds the Credit and Smart + * funding modes on top of v1's cash-only spending. + * + * USD figures are 6-decimal integers exactly as in v1 (`CASH_USD_DECIMALS`). The health + * factor, LTV and the interest index are WAD (1e18), which is a different scale on the + * same wire, so the two are never mixed in one helper here. + */ + +/** Health factor, LTV and the interest index are all 1e18 on-chain. */ +export const WAD = 10n ** 18n; + +/** + * `Mode` as the contract numbers it. **Append-only, and the order is load-bearing** — + * the module's own `_modeRank` is a cast of this enum, and the mode-switch delay rule is + * "delayed iff the rank rises". A member inserted rather than appended would silently + * re-rank every mode after it. + */ +export enum ContractSpendMode { + /** The asset is SOLD. Tokens move Safe -> settlement treasury. */ + Debit = 0, + /** The asset is LOCKED. Collateral moves Safe -> module escrow and USD debt is booked. */ + Credit = 1, + /** Both paths permitted; the backend chooses per transaction. */ + Smart = 2, +} + +/** + * The app's name for a mode, given the contract's. + * + * "Cash" rather than "Debit" throughout the UI: the module's word describes what happens + * to the asset, and the cardholder's question is what they are spending. + */ +const MODE_FROM_CONTRACT: Record = { + [ContractSpendMode.Debit]: 'cash', + [ContractSpendMode.Credit]: 'credit', + [ContractSpendMode.Smart]: 'smart', +}; + +const MODE_TO_CONTRACT: Record = { + cash: ContractSpendMode.Debit, + credit: ContractSpendMode.Credit, + smart: ContractSpendMode.Smart, +}; + +/** + * Narrows a raw on-chain mode to the app's union. + * + * Anything unrecognised falls back to `cash`, which is the conservative answer in the one + * direction that matters: a mode this build does not know about must not be rendered as + * borrowing. A newer contract with a fourth mode would read as cash here rather than as a + * credit line the app cannot actually operate. + */ +export const toSpendMode = (raw: number | bigint): SpendMode => + MODE_FROM_CONTRACT[Number(raw) as ContractSpendMode] ?? 'cash'; + +export const toContractMode = (mode: SpendMode): ContractSpendMode => MODE_TO_CONTRACT[mode]; + +/** Whether a mode needs the v2 module at all. Cash is what v1 already does. */ +export const requiresV2 = (mode: SpendMode): boolean => mode !== 'cash'; + +/** Which module generation is operating a Safe, as `SolidSpendLens.cohortOf` reports it. */ +export enum SpendCohort { + /** Registered on neither module. Nothing can be spent. */ + None = 0, + V1 = 1, + V2 = 2, + /** + * Both modules enabled — the anomaly a user creates by re-enabling v1 after migrating. + * + * v2 refuses to act in this state, so the Safe is operated by v1 and the app must show + * it as cash-only. It is recoverable by disabling v1, which is exactly what the + * migration batch does. + */ + Both = 3, +} + +/** What `ADDRESSES` holds for a contract this build has no deployment for. */ +const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000' as Address; + +/** + * Whether this build can actually operate v2. + * + * Both halves are required and they fail differently: the flag off is a deliberate dark + * launch, while a flag on with no address is a misconfigured build. Treating either as + * "v2 available" would offer a cardholder a mode whose transaction cannot be built. + */ +export const isCardSpendV2Configured = (): boolean => + EXPO_PUBLIC_CARD_SPEND_V2 && + ADDRESSES.fuse.cashModuleV2 !== ZERO_ADDRESS && + ADDRESSES.fuse.spendLensV2 !== ZERO_ADDRESS; + +/** + * Whether a mode switch armed for `startTime` is still waiting. + * + * **Not `startTime !== 0`.** The module writes the activation instant on every up-rank + * including a zero-delay one, so with `modeDelay` at 0 — which is how this launches — an + * already-effective switch still leaves a non-zero timestamp behind. Reading that as + * "pending" would show a countdown for a mode the cardholder is already in. + */ +export const isModeSwitchPending = (startTimeSeconds: bigint | number): boolean => { + const startTime = Number(startTimeSeconds); + return startTime > 0 && startTime > Math.floor(Date.now() / 1000); +}; + +/** + * A WAD health factor as the number a person reads, or null when there is no debt. + * + * The module reports `type(uint256).max` for a position carrying no debt, which is not a + * ratio and must not be rendered as one — there is nothing at risk, so there is no figure + * to show. + */ +export const healthFactorToNumber = (wad: bigint): number | null => { + // Anything at or above this is the module's infinity sentinel rather than a real ratio. + // Compared rather than equality-checked because interest accrual can leave the reported + // value a hair under the sentinel on a position whose debt has just been cleared. + if (wad >= 2n ** 255n) return null; + return Number((wad * 10_000n) / WAD) / 10_000; +}; + +/** + * How close a position is to liquidation, for the "At risk" panel. + * + * The thresholds are presentation, not protocol: the contract liquidates below 1.0 and + * knows nothing about "caution". Warning early is the point — a cardholder who only learns + * at 1.0 learns when it is already happening. + */ +export type BorrowRisk = 'none' | 'caution' | 'at-risk'; + +export const borrowRisk = (healthFactorWad: bigint, debtUsd: bigint): BorrowRisk => { + if (debtUsd === 0n) return 'none'; + + const hf = healthFactorToNumber(healthFactorWad); + if (hf === null) return 'none'; + if (hf < 1.05) return 'at-risk'; + if (hf < 1.25) return 'caution'; + return 'none'; +}; + +/** + * A per-second WAD borrow rate as an annual percentage. + * + * Compounded, not multiplied: `_accrue` multiplies the *current* index by `1 + r*dt`, so + * successive accruals compound and the realised annual figure is `e^(r * 365 days) - 1`. + * Quoting `r * 365 days` would understate a high rate materially — at the module's own + * ceiling it reads 100% where the cardholder actually pays ~171%. + */ +export const borrowApyPercent = (ratePerSecondWad: bigint): number => { + if (ratePerSecondWad <= 0n) return 0; + + const perSecond = Number(ratePerSecondWad) / Number(WAD); + const yearly = Math.expm1(perSecond * 365 * 24 * 60 * 60); + return Number.isFinite(yearly) ? yearly * 100 : 0; +}; + +/** "5.57%" — two decimals, matching how the rate is quoted in the sheets. */ +export const formatApy = (percent: number): string => `${percent.toFixed(2)}%`; + +/** + * How much of the credit line is drawn, as 0–1, for the progress track. + * + * The denominator is the whole line — the same figure rendered beside the bar — so the + * track and the "$X / $Y" above it can never tell different stories. It used to be + * `debt + availableToBorrow` on the belief that borrowing power is what remains after debt + * is deducted; it is not. `SolidCashModuleV2` compares power against TOTAL debt + * (`if (_debtOf($) > power) revert ExceedsBorrowingPower()`), so power already covers what + * has been drawn, and adding debt back to it counted the drawn part twice. + * + * Clamped because a position that has moved past its own line (interest accrued, or + * collateral repriced down) must render as full rather than overflow the track. + */ +export const borrowedProgress = (debtUsd: bigint, creditLineUsd: bigint): number => { + if (creditLineUsd <= 0n) return 0; + + const ratio = Number((debtUsd * 10_000n) / creditLineUsd) / 10_000; + return Math.min(1, Math.max(0, ratio)); +}; diff --git a/constants/tracking-events.ts b/constants/tracking-events.ts index 2cc6fdd9..0f0db929 100644 --- a/constants/tracking-events.ts +++ b/constants/tracking-events.ts @@ -351,6 +351,14 @@ export const TRACKING_EVENTS = { CARD_SPEND_DISABLE_COMPLETED: 'card_spend_disable_completed', CARD_SPEND_DISABLE_FAILED: 'card_spend_disable_failed', CARD_SPEND_DISABLE_CANCELLED: 'card_spend_disable_cancelled', + // Changing how the card is funded — cash, credit, or smart. The first change off cash + // also migrates the Safe from the v1 module to v2, which `migrated` on the completed + // event distinguishes: a first switch costs a four-call batch and every later one is a + // single `setMode`, and they are worth telling apart in the funnel. + CARD_SPEND_MODE_CHANGE_PRESSED: 'card_spend_mode_change_pressed', + 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', // 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/useBalances.ts b/hooks/useBalances.ts index 1e41d07a..bc300726 100644 --- a/hooks/useBalances.ts +++ b/hooks/useBalances.ts @@ -3,7 +3,7 @@ import { formatUnits, parseUnits, zeroAddress } from 'viem'; import { getBalance, readContract } from 'viem/actions'; import { arbitrum, base, bsc, fuse, mainnet } from 'viem/chains'; -import { NATIVE_COINGECKO_TOKENS, NATIVE_TOKENS } from '@/constants/tokens'; +import { NATIVE_COINGECKO_TOKENS } from '@/constants/tokens'; import { fetchCoinSimplePrice, fetchTokenList, @@ -13,9 +13,10 @@ import { import { ADDRESSES } from '@/lib/config'; import { fetchTokenBalancesWithFallback } from '@/lib/data-source'; import { PromiseStatus, SwapTokenResponse, TokenBalance, TokenType } from '@/lib/types'; -import { isSoFUSEToken, isSoUSDToken, isWalletCardExcludedToken } from '@/lib/utils'; +import { isSoETHToken, isSoFUSEToken, isSoUSDToken, isWalletCardExcludedToken } from '@/lib/utils'; import { publicClient } from '@/lib/wagmi'; +import { makeNativePriceFetcher } from './useNativePriceUsd'; import useUser from './useUser'; // Blockscout response structure for both Ethereum and Fuse @@ -105,6 +106,44 @@ const symbols = { 'USDC.E': 'USDC', }; +/** + * Native-token USD price per chain. Uses the shared fetcher (Alchemy by symbol, + * CoinGecko on failure) rather than a bare Alchemy call: FUSE's "symbol" here is + * a CoinGecko coin id that Alchemy's by-symbol endpoint never resolves, so on + * its own it yields no price at all. + */ +const NATIVE_PRICE_FETCHERS: Record Promise> = { + [mainnet.id]: makeNativePriceFetcher(mainnet.id), + [fuse.id]: makeNativePriceFetcher(fuse.id), + [base.id]: makeNativePriceFetcher(base.id), + [arbitrum.id]: makeNativePriceFetcher(arbitrum.id), + [bsc.id]: makeNativePriceFetcher(bsc.id), +}; + +const isZeroRate = (r: number | null | undefined) => + r == null || r === 0 || (typeof r === 'number' && Number.isNaN(r)); + +const parsePrice = (v: unknown): number | undefined => { + if (typeof v === 'number' && !Number.isNaN(v)) return v; + if (typeof v === 'string') { + const n = parseFloat(v); + return !Number.isNaN(n) ? n : undefined; + } + return undefined; +}; + +/** + * A vault share is worth its exchange rate in the underlying asset, never one + * for one: soFUSE and soETH are listed under the underlying's own price id + * (`fuse-network-token` / `weth`), so letting them take a price from the generic + * fallbacks below values a share as a bare FUSE or ETH and under-reports the + * holding by the whole accrued yield. Their rate comes from the accountant or + * not at all. soUSD is excluded from this rule: its price id names the share + * itself, so a fallback price for it is the right number. + */ +const isUnderlyingPricedShare = (contractAddress: string): boolean => + isSoFUSEToken(contractAddress) || isSoETHToken(contractAddress); + // Fetch function for token balances const fetchTokenBalances = async (safeAddress: string) => { const [ @@ -116,6 +155,7 @@ const fetchTokenBalances = async (safeAddress: string) => { bscResponse, soUSDRate, soFUSERate, + soETHRate, ethBalance, fuseBalance, baseBalance, @@ -148,6 +188,13 @@ const fetchTokenBalances = async (safeAddress: string) => { abi: ACCOUNTANT_ABI, functionName: 'getRate', }), + // One soETH rate for every chain the share sits on, read from the Ethereum + // accountant — the same source the savings screen uses. + readContract(publicClient(mainnet.id), { + address: ADDRESSES.ethereum.soEthAccountant, + abi: ACCOUNTANT_ABI, + functionName: 'getRate', + }), getBalance(publicClient(mainnet.id), { address: safeAddress as `0x${string}`, }), @@ -163,11 +210,11 @@ const fetchTokenBalances = async (safeAddress: string) => { getBalance(publicClient(bsc.id), { address: safeAddress as `0x${string}`, }), - fetchTokenPriceUsd(NATIVE_TOKENS[mainnet.id]), - fetchTokenPriceUsd(NATIVE_TOKENS[fuse.id]), - fetchTokenPriceUsd(NATIVE_TOKENS[base.id]), - fetchTokenPriceUsd(NATIVE_TOKENS[arbitrum.id]), - fetchTokenPriceUsd(NATIVE_TOKENS[bsc.id]), + NATIVE_PRICE_FETCHERS[mainnet.id](), + NATIVE_PRICE_FETCHERS[fuse.id](), + NATIVE_PRICE_FETCHERS[base.id](), + NATIVE_PRICE_FETCHERS[arbitrum.id](), + NATIVE_PRICE_FETCHERS[bsc.id](), fetchTokenList({ isActive: true, }), @@ -181,6 +228,7 @@ const fetchTokenBalances = async (safeAddress: string) => { let bscTokens: TokenBalance[] = []; let soUSDRateNum = 0; let soFUSEQuoteRateUSD = 0; + let soETHQuoteRateUSD = 0; // Process soUSD rate (soUSD → USD, 6 decimals) if (soUSDRate.status === PromiseStatus.FULFILLED) { @@ -190,15 +238,28 @@ const fetchTokenBalances = async (safeAddress: string) => { } // Process soFUSE rate: soFUSE→FUSE (18 decimals) × FUSE price = USD quote rate (align with savings) - if ( - soFUSERate.status === PromiseStatus.FULFILLED && - fusePrice.status === PromiseStatus.FULFILLED - ) { + const fusePriceNum = + fusePrice.status === PromiseStatus.FULFILLED ? parsePrice(fusePrice.value) : undefined; + const ethPriceNum = + ethPrice.status === PromiseStatus.FULFILLED ? parsePrice(ethPrice.value) : undefined; + + if (soFUSERate.status === PromiseStatus.FULFILLED && fusePriceNum) { const soFUSEToFuse = Number(soFUSERate.value) / Math.pow(10, 18); - const fusePriceNum = Number(fusePrice.value); soFUSEQuoteRateUSD = soFUSEToFuse * fusePriceNum; } else if (soFUSERate.status === PromiseStatus.REJECTED) { console.warn('Failed to fetch soFUSE rate:', soFUSERate.reason); + } else if (!fusePriceNum) { + console.warn('No FUSE price — soFUSE left unpriced rather than valued as bare FUSE'); + } + + // Same for soETH: soETH→ETH (18 decimals) × ETH price. + if (soETHRate.status === PromiseStatus.FULFILLED && ethPriceNum) { + const soETHToEth = Number(soETHRate.value) / Math.pow(10, 18); + soETHQuoteRateUSD = soETHToEth * ethPriceNum; + } else if (soETHRate.status === PromiseStatus.REJECTED) { + console.warn('Failed to fetch soETH rate:', soETHRate.reason); + } else if (!ethPriceNum) { + console.warn('No ETH price — soETH left unpriced rather than valued as bare ETH'); } const getAddress = (item: BlockscoutTokenBalance) => { @@ -216,13 +277,18 @@ const fetchTokenBalances = async (safeAddress: string) => { ); const isSoUSD = isSoUSDToken(address); const isSoFUSE = isSoFUSEToken(address); + const isSoETH = isSoETHToken(address); + // Vault shares are priced off their accountant rate, not off a market quote + // for the share (Blockscout has none) or for the underlying asset. const quoteRate = isSoUSD ? soUSDRateNum : isSoFUSE ? soFUSEQuoteRateUSD - : item.token.exchange_rate - ? parseFloat(item.token.exchange_rate) - : 0; + : isSoETH + ? soETHQuoteRateUSD + : item.token.exchange_rate + ? parseFloat(item.token.exchange_rate) + : 0; return { contractTickerSymbol: String( symbols[item.token.symbol as keyof typeof symbols] ?? item.token.symbol, @@ -445,18 +511,6 @@ const fetchTokenBalances = async (safeAddress: string) => { ...bscTokens, ]; - const isZeroRate = (r: number | null | undefined) => - r == null || r === 0 || (typeof r === 'number' && Number.isNaN(r)); - - const parsePrice = (v: unknown): number | undefined => { - if (typeof v === 'number' && !Number.isNaN(v)) return v; - if (typeof v === 'string') { - const n = parseFloat(v); - return !Number.isNaN(n) ? n : undefined; - } - return undefined; - }; - // Fallback 1: Alchemy Prices by contract address. Alchemy's token balances // carry no exchange rate (only Blockscout's do), so every ERC-20 on an // Alchemy-served chain lands here at 0. Runs ahead of the coin-id and symbol @@ -464,7 +518,11 @@ const fetchTokenBalances = async (safeAddress: string) => { // swaptokens entry to resolve — a token missing from the curated list still // gets a price. const addressPriceTokens = allTokens.filter( - t => isZeroRate(t.quoteRate) && t.type !== TokenType.NATIVE && t.contractAddress, + t => + isZeroRate(t.quoteRate) && + t.type !== TokenType.NATIVE && + t.contractAddress && + !isUnderlyingPricedShare(t.contractAddress), ); if (addressPriceTokens.length > 0) { try { @@ -472,7 +530,12 @@ const fetchTokenBalances = async (safeAddress: string) => { addressPriceTokens.map(t => ({ chainId: t.chainId, address: t.contractAddress })), ); allTokens = allTokens.map(t => { - if (!isZeroRate(t.quoteRate) || t.type === TokenType.NATIVE) return t; + if ( + !isZeroRate(t.quoteRate) || + t.type === TokenType.NATIVE || + isUnderlyingPricedShare(t.contractAddress) + ) + return t; const usd = priceByAddress[`${t.chainId}:${t.contractAddress?.toLowerCase()}`]; if (usd != null && usd > 0) return { ...t, quoteRate: usd }; return t; @@ -483,7 +546,9 @@ const fetchTokenBalances = async (safeAddress: string) => { } // Fallback 2: CoinGecko by coin id (tokenId for ERC20, NATIVE_COINGECKO_TOKENS for native) - const zeroRateTokens = allTokens.filter(t => isZeroRate(t.quoteRate)); + const zeroRateTokens = allTokens.filter( + t => isZeroRate(t.quoteRate) && !isUnderlyingPricedShare(t.contractAddress), + ); const coinIds = [ ...new Set( zeroRateTokens @@ -495,7 +560,7 @@ const fetchTokenBalances = async (safeAddress: string) => { try { const priceMap = await fetchCoinSimplePrice(coinIds); allTokens = allTokens.map(t => { - if (!isZeroRate(t.quoteRate)) return t; + if (!isZeroRate(t.quoteRate) || isUnderlyingPricedShare(t.contractAddress)) return t; const id = t.type === TokenType.NATIVE ? NATIVE_COINGECKO_TOKENS[t.chainId] : t.tokenId; const usd = id ? parsePrice(priceMap[id]?.usd) : undefined; if (usd != null && usd > 0) return { ...t, quoteRate: usd }; @@ -507,7 +572,12 @@ const fetchTokenBalances = async (safeAddress: string) => { } // Fallback 3: Alchemy by symbol for tokens still at 0 (no tokenId) - const stillZero = allTokens.filter(t => isZeroRate(t.quoteRate) && t.contractTickerSymbol); + const stillZero = allTokens.filter( + t => + isZeroRate(t.quoteRate) && + t.contractTickerSymbol && + !isUnderlyingPricedShare(t.contractAddress), + ); const symbolsToFetch = [...new Set(stillZero.map(t => t.contractTickerSymbol))]; if (symbolsToFetch.length > 0) { try { @@ -521,7 +591,7 @@ const fetchTokenBalances = async (safeAddress: string) => { } }); allTokens = allTokens.map(t => { - if (!isZeroRate(t.quoteRate)) return t; + if (!isZeroRate(t.quoteRate) || isUnderlyingPricedShare(t.contractAddress)) return t; const p = t.contractTickerSymbol && symbolToPrice[t.contractTickerSymbol]; if (typeof p === 'number') return { ...t, quoteRate: p }; return t; diff --git a/hooks/useCardSpendModeAccess.ts b/hooks/useCardSpendModeAccess.ts new file mode 100644 index 00000000..02017d6a --- /dev/null +++ b/hooks/useCardSpendModeAccess.ts @@ -0,0 +1,52 @@ +import { useQuery } from '@tanstack/react-query'; + +import useUser from '@/hooks/useUser'; +import { getCardSpendModeAccess } from '@/lib/api'; + +export const CARD_SPEND_MODE_ACCESS_QUERY_KEY = 'cardSpendModeAccess'; + +/** How long the gate is trusted before it is asked again. */ +const STALE_MS = 5 * 60 * 1000; + +/** + * Whether this cardholder may see the spend-mode picker and the borrow position. + * + * ## Why the server owns this + * + * Credit and Smart go to the Wirex internal cohort first, and that cohort is a Mongo + * collection so the next tester can be added without a release. Neither a build-time flag + * nor a client-side list could express that — the second would have to ship every member's + * user id to every device. + * + * ## Fails closed, and stays closed while it does not know + * + * `false` until the request answers, and `false` if it fails. The two mistakes here are not + * symmetric: a cohort member waiting a beat for the card to appear is an annoyance they can + * report, whereas flashing Credit to the whole user base on a failed request is a rollout + * that cannot be taken back. It also means the surfaces do not appear and then vanish, which + * is what an optimistic default would produce on every cold start. + * + * Cached for five minutes. Cohort membership changes by hand, at human pace, and a per-render + * refetch of a boolean that almost never moves is a request for nothing. + */ +export const useCardSpendModeAccess = (): { isEnabled: boolean; isLoading: boolean } => { + const { user } = useUser(); + + const { data, isLoading } = useQuery({ + queryKey: [CARD_SPEND_MODE_ACCESS_QUERY_KEY, user?.userId], + queryFn: getCardSpendModeAccess, + // Nothing to ask about without a signed-in user, and the request would 401. + enabled: !!user?.userId, + staleTime: STALE_MS, + // A gate that retries forever keeps the surfaces hidden for the same length of time + // either way; one retry covers a transient blip without holding the query pending. + retry: 1, + }); + + return { + isEnabled: data?.enabled === true, + isLoading: !!user?.userId && isLoading, + }; +}; + +export default useCardSpendModeAccess; diff --git a/hooks/useCardSpendRegistration.ts b/hooks/useCardSpendRegistration.ts index 9ebf6056..bcabbf5d 100644 --- a/hooks/useCardSpendRegistration.ts +++ b/hooks/useCardSpendRegistration.ts @@ -11,11 +11,20 @@ import { spendLimitRejection, usdToOnChain, } from '@/constants/cardSpendModule'; +import { + isCardSpendV2Configured, + isModeSwitchPending, + SpendCohort, + toContractMode, + toSpendMode, +} from '@/constants/cardSpendV2'; import { TRACKING_EVENTS } from '@/constants/tracking-events'; import { useCardProvider } from '@/hooks/useCardProvider'; import useUser from '@/hooks/useUser'; import { Safe_ABI } from '@/lib/abis/Safe'; import { SolidCashModule_ABI } from '@/lib/abis/SolidCashModule'; +import { SolidCashModuleV2_ABI } from '@/lib/abis/SolidCashModuleV2'; +import { SolidSpendLens_ABI } from '@/lib/abis/SolidSpendLens'; import { track } from '@/lib/analytics'; import { confirmWirexCardRegistration } from '@/lib/api'; import { ADDRESSES } from '@/lib/config'; @@ -24,9 +33,13 @@ import { CardProvider } from '@/lib/types'; import { publicClient } from '@/lib/wagmi'; import { useUserStore } from '@/store/useUserStore'; +import type { SpendMode } from '@/components/Card/NewCardDetails/SpendMode/spendModes'; + export const CARD_SPEND_REGISTRATION_QUERY_KEY = 'cardSpendRegistration'; const MODULE = ADDRESSES.fuse.cashModule; +const MODULE_V2 = ADDRESSES.fuse.cashModuleV2; +const SPEND_LENS_V2 = ADDRESSES.fuse.spendLensV2; /** * Head of a Safe's module linked list. `disableModule(prevModule, module)` needs the @@ -129,8 +142,522 @@ export interface CardSpendRegistration { pendingIncrease: PendingLimitIncrease | null; /** How long a requested increase waits before it takes effect, in seconds. */ limitRaiseDelaySeconds: number; + + // ---- Which module generation, and what it adds ------------------------------------- + // + // Everything above is read from whichever module actually operates this Safe, so the + // limit flows work unchanged across a migration. Everything below says which one that + // was and what it can additionally do. + + /** Which module generation operates this Safe right now. */ + cohort: SpendCohort; + /** + * The module every write here must target. + * + * Carried rather than derived at each call site: a Safe's generation is chain state that + * can change under a sheet left open, and a write sent to the module the user is no + * longer on reverts. One field, read fresh before every signature. + */ + moduleAddress: Address; + /** + * How the card is funded today. + * + * Always `cash` for a v1 cardholder, which is the whole of what v1 does — and the + * migration is deliberately invisible to them until they ask for a mode v1 cannot serve. + */ + mode: SpendMode; + /** + * A mode switch that is armed but has not taken effect, or null. + * + * Only ever set for a switch to a *wider* mode: narrowing applies immediately. Null + * whenever the activation instant has already passed, which at the launch configuration + * of `modeDelay = 0` is immediately. + */ + pendingMode: SpendMode | null; + /** Unix seconds {@link pendingMode} takes effect. Zero when nothing is armed. */ + modeActivatesAt: number; + /** How long a switch to a wider mode waits, in seconds. Zero at launch. */ + modeDelaySeconds: number; + /** The Safe's credit position, or null for a cardholder on v1. */ + position: CardBorrowPosition | null; + /** WAD borrow rate per second. Zero when v2 is not readable. */ + borrowApyPerSecond: bigint; + /** + * Whether v2 could be reached at all — configured, deployed and responding. + * + * What gates offering Credit and Smart. False means this build cannot execute those + * modes, so it must not present them as choices. + */ + v2Available: boolean; } +/** + * Encoders that pick the module generation's ABI from the address being written to. + * + * The two generations share these function names and signatures exactly, which is what + * lets one limit flow serve both — but they are different ABI objects, and `viem` needs + * the right one. Branching here rather than at each call site means a new write path + * cannot forget: there is no way to build one of these calls without saying which module + * it is for. + */ +const isV2Module = (moduleAddress: Address): boolean => + moduleAddress.toLowerCase() === MODULE_V2.toLowerCase(); + +const encodeRegisterSafe = ( + moduleAddress: Address, + daily: bigint, + monthly: bigint, + timezoneOffset: bigint, +) => + isV2Module(moduleAddress) + ? encodeFunctionData({ + abi: SolidCashModuleV2_ABI, + functionName: 'registerSafe', + args: [daily, monthly, timezoneOffset], + }) + : encodeFunctionData({ + abi: SolidCashModule_ABI, + functionName: 'registerSafe', + args: [daily, monthly, timezoneOffset], + }); + +const encodeLimitChange = ( + moduleAddress: Address, + isIncrease: boolean, + daily: bigint, + monthly: bigint, +) => { + const functionName = isIncrease ? 'requestSpendingLimitIncrease' : 'decreaseSpendingLimit'; + return isV2Module(moduleAddress) + ? encodeFunctionData({ abi: SolidCashModuleV2_ABI, functionName, args: [daily, monthly] }) + : encodeFunctionData({ abi: SolidCashModule_ABI, functionName, args: [daily, monthly] }); +}; + +const encodeCancelPendingIncrease = (moduleAddress: Address) => + isV2Module(moduleAddress) + ? encodeFunctionData({ + abi: SolidCashModuleV2_ABI, + functionName: 'cancelPendingSpendingLimitIncrease', + }) + : encodeFunctionData({ + abi: SolidCashModule_ABI, + functionName: 'cancelPendingSpendingLimitIncrease', + }); + +/** + * The module-list entry pointing at `target`, which `Safe.disableModule` requires. + * + * Cannot be derived — it is a linked list and the predecessor depends on enable order — and + * passing the wrong one reverts GS103. Read at press time rather than cached with the rest + * of the registration, because enabling any other module rewrites these pointers. + * + * Null when the module is not on the list at all, which means the chain disagrees with what + * was read a moment ago and there is nothing to disable. + */ +const findModulePredecessor = async ( + safeAddress: Address, + target: Address, +): Promise
=> { + const client = publicClient(fuse.id); + const [modules] = await client.readContract({ + address: safeAddress, + abi: Safe_ABI, + functionName: 'getModulesPaginated', + args: [SENTINEL_MODULES, MODULE_PAGE_SIZE], + }); + + const index = modules.findIndex( + entry => entry.toLowerCase() === (target as string).toLowerCase(), + ); + if (index === -1) return null; + + // `getModulesPaginated` walks from the sentinel outwards, so the entry before the target + // in this array is exactly the one pointing at it — and for the first entry that is the + // sentinel itself. + return index === 0 ? SENTINEL_MODULES : modules[index - 1]; +}; + +/** + * The caps a migrating Safe registers on v2 with. + * + * Carried from what the cardholder already chose on v1 rather than reset to v2's defaults: + * they picked those numbers, and a migration they were never shown must not silently move + * them. Clamped *down* where v2's org ceilings are tighter, because `registerSafe` reverts + * on a cap above them and a revert here fails the whole batch. + * + * The monthly is clamped first and the daily then clamped to it, because `SpendingLimitLib` + * rejects a daily above the monthly outright — clamping the two independently against + * different ceilings can produce exactly that pair. + * + * A zero cap falls back to v2's default. It should not happen for a registered Safe, but + * registering at zero would produce a card that declines every payment, which is a worse + * outcome than a default nobody picked. + */ +const carriedLimits = ( + current: CardSpendLimit, + v2: Pick< + V2State, + 'maxDailyLimitUsd' | 'maxMonthlyLimitUsd' | 'defaultDailyLimitUsd' | 'defaultMonthlyLimitUsd' + >, +) => { + const min = (a: bigint, b: bigint) => (a < b ? a : b); + + const monthly = min( + current.monthlyLimitUsd > 0n ? current.monthlyLimitUsd : v2.defaultMonthlyLimitUsd, + v2.maxMonthlyLimitUsd, + ); + const daily = min( + min( + current.dailyLimitUsd > 0n ? current.dailyLimitUsd : v2.defaultDailyLimitUsd, + v2.maxDailyLimitUsd, + ), + monthly, + ); + + return { + dailyLimitUsd: daily, + monthlyLimitUsd: monthly, + // Written once at registration with no setter, so carrying it keeps the cardholder's + // rolling windows resetting on the day they actually experience. + timezoneOffset: current.timezoneOffset, + }; +}; + +/** + * A Safe's credit position, as v2 values it. Null for a cardholder still on v1, which has + * no notion of collateral or debt at all. + */ +export interface CardBorrowPosition { + /** Escrowed collateral at the module's own bounded price. */ + collateralUsd: bigint; + /** + * Borrowing power from collateral **already escrowed**, weighted by each token's LTV. + * + * Zero for every cardholder who has not borrowed yet, and that is correct rather than a + * gap: the module only escrows at `spendCredit` time, so before the first credit spend + * `collateralOf` is empty for every token and `positionValue` has nothing to weight. It + * is NOT the line a cardholder can draw on — see {@link prospectivePowerUsd}. + * + * Gross, not net. The module compares it against TOTAL debt + * (`if (_debtOf($) > power) revert ExceedsBorrowingPower()`), so it already covers what + * has been drawn; subtracting debt from it a second time would understate the line. + */ + borrowingPowerUsd: bigint; + /** + * Borrowing power the Safe's **loose** balance would add if it were locked, from + * `SolidSpendLens.prospectiveCollateralUsd`. + * + * This is the half that makes the figure mean anything before the first borrow. It is + * the one number the lens computes that the module has no equivalent for, and it is + * quoted at `effectiveTargetLtv` — `ltv * targetLtvBps / MAX_BPS`, the same buffered + * ratio `spendCredit` locks at — so a quote can never advertise more power than + * execution would actually back. Expect it to read below `balance * maxLTV`. + */ + prospectivePowerUsd: bigint; + /** Collateral weighted by each liquidation threshold: what could be seized. */ + liquidationCapacityUsd: bigint; + /** Outstanding debt including accrued interest. */ + debtUsd: bigint; + /** + * What could be borrowed right now, from `SolidSpendLens.availableToBorrowUsd`. + * + * Not `power - debt`: the lens has already clamped it by the per-Safe debt cap, the + * global debt cap and the Safe's remaining spending limit. Those are the same clamps + * the authorize path applies, so this is the only figure that can honestly be shown as + * "you can spend this on credit" — anything derived from collateral alone would quote a + * number a card tap then declines. + */ + availableToBorrowUsd: bigint; + /** WAD. The module reports `type(uint256).max` when there is no debt. */ + healthFactorWad: bigint; + /** + * False when any escrowed collateral cannot be strictly priced. + * + * Worth surfacing rather than hiding: in that state borrowing power is understated AND + * the module refuses to liquidate the position, so the user sees an unexplained shrink + * in their line while nothing looks broken. + */ + fullyPriced: boolean; +} + +/** Everything v2 answers about a Safe. Null when v2 is not configured or cannot be read. */ +interface V2State { + registeredOnChain: boolean; + moduleEnabled: boolean; + /** v1 still enabled on this Safe, which makes v2 inert — `COHORT_BOTH`. */ + legacyEnabled: boolean; + modulePaused: boolean; + safePaused: boolean; + maxPerTxUsd: bigint; + maxDailyLimitUsd: bigint; + maxMonthlyLimitUsd: bigint; + defaultDailyLimitUsd: bigint; + defaultMonthlyLimitUsd: bigint; + limitRaiseDelaySeconds: number; + /** How long a switch to a wider mode waits. Zero at launch, but never assumed to be. */ + modeDelaySeconds: number; + rawLimit: RawSpendingLimit; + mode: SpendMode; + incomingMode: SpendMode; + incomingModeStartTime: number; + position: CardBorrowPosition; + borrowApyPerSecond: bigint; +} + +/** The `SpendingLimit` tuple, identical in both module generations. */ +type RawSpendingLimit = { + dailyLimit: bigint; + monthlyLimit: bigint; + spentToday: bigint; + spentThisMonth: bigint; + pendingDailyLimit: bigint; + pendingMonthlyLimit: bigint; + dailyRenewalTimestamp: bigint; + monthlyRenewalTimestamp: bigint; + dailyLimitActivationTime: bigint; + monthlyLimitActivationTime: bigint; + timezoneOffset: bigint; +}; + +/** + * The `credit` half of `SolidSpendLens.availableToSpend`, or undefined when the lens could + * not be read. + * + * Narrowed by hand rather than typed off the ABI, because the surrounding multicall is + * already `as never[]`-shaped and only part of the struct is wanted here. Every access is + * guarded: a lens at the wrong address can answer a shape that decodes but means something + * else, and that has to degrade to "no lens" rather than let a confident `undefined` reach a + * figure the cardholder reads as their credit line. + */ +const lensCredit = ( + result: { status: string; result?: unknown } | undefined, +): + | { + collateralUsd: bigint; + borrowingPowerUsd: bigint; + liquidationCapacityUsd: bigint; + debtUsd: bigint; + availableToBorrowUsd: bigint; + prospectiveCollateralUsd: bigint; + fullyPriced: boolean; + } + | undefined => { + if (result?.status !== 'success') return undefined; + + const credit = (result.result as { credit?: Record } | undefined)?.credit; + if (!credit) return undefined; + + const amount = (key: string): bigint | undefined => + typeof credit[key] === 'bigint' ? (credit[key] as bigint) : undefined; + + const borrowingPowerUsd = amount('borrowingPowerUsd'); + const prospectiveCollateralUsd = amount('prospectiveCollateralUsd'); + const availableToBorrowUsd = amount('availableToBorrowUsd'); + const debtUsd = amount('debtUsd'); + + // The four that the line is actually made of. If any is missing the struct is not the one + // this code was written against, and half a credit line is worse than none. + if ( + borrowingPowerUsd === undefined || + prospectiveCollateralUsd === undefined || + availableToBorrowUsd === undefined || + debtUsd === undefined + ) { + return undefined; + } + + return { + collateralUsd: amount('collateralUsd') ?? 0n, + borrowingPowerUsd, + liquidationCapacityUsd: amount('liquidationCapacityUsd') ?? 0n, + debtUsd, + availableToBorrowUsd, + prospectiveCollateralUsd, + fullyPriced: credit.fullyPriced !== false, + }; +}; + +/** + * v2's answer for this Safe, or null when there is nothing to ask. + * + * `allowFailure` is on and any failed call collapses the whole thing to null, which is + * deliberately blunt: a partially-read v2 cannot be reasoned about — half of these decide + * whether the Safe is even operated by v2 — and falling back to v1 is always safe because + * v1 is where every cardholder already is. An address that is not a contract yet, a + * mis-set env override and a node hiccup all land here and all degrade the same way. + */ +const readV2State = async (safeAddress: Address): Promise => { + if (!isCardSpendV2Configured()) return null; + + const client = publicClient(fuse.id); + const module = { address: MODULE_V2, abi: SolidCashModuleV2_ABI } as const; + + try { + const results = await client.multicall({ + allowFailure: true, + contracts: [ + { ...module, functionName: 'isRegistered', args: [safeAddress] }, + { ...module, functionName: 'isModuleEnabledOn', args: [safeAddress] }, + // v2 goes inert while v1 is still enabled, so this is what tells a half-migrated + // Safe apart from a migrated one. + { ...module, functionName: 'isLegacyEnabledOn', args: [safeAddress] }, + { ...module, functionName: 'isPaused' }, + { ...module, functionName: 'safePaused', args: [safeAddress] }, + // Both of these live in the setters half and resolve through the core's fallback. + { ...module, functionName: 'getParams' }, + { ...module, functionName: 'applicableSpendingLimit', args: [safeAddress] }, + { ...module, functionName: 'getMode', args: [safeAddress] }, + { ...module, functionName: 'getIncomingMode', args: [safeAddress] }, + { ...module, functionName: 'incomingModeStartTime', args: [safeAddress] }, + { ...module, functionName: 'debtUsd', args: [safeAddress] }, + { ...module, functionName: 'healthFactor', args: [safeAddress] }, + { ...module, functionName: 'positionValue', args: [safeAddress] }, + { ...module, functionName: 'maxCanSpendUsd', args: [safeAddress] }, + { ...module, functionName: 'borrowApyPerSecond' }, + // 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 + // `positionValue` only weights what has already been escrowed and nothing is escrowed + // until the first credit spend. Last in the list so the fixed indices above are + // untouched, and read separately below so a lens that cannot be reached costs only the + // credit figures rather than collapsing the whole read. + { + address: SPEND_LENS_V2, + abi: SolidSpendLens_ABI, + functionName: 'availableToSpend', + args: [safeAddress], + }, + ], + }); + + // The lens is the one call allowed to fail. Everything before it decides whether the Safe + // is operated by v2 at all, so a gap there is unreasonable-about; a missing lens only + // costs the loose-balance half of the credit line, which is worth degrading rather than + // falling the whole Safe back to v1 for. + const lensResult = results[results.length - 1]; + if (results.slice(0, -1).some(result => result.status !== 'success')) return null; + + const [ + registeredOnChain, + moduleEnabled, + legacyEnabled, + modulePaused, + safePaused, + params, + rawLimit, + mode, + incomingMode, + incomingModeStartTime, + debtUsd, + healthFactorWad, + positionValue, + , + borrowApyPerSecond, + ] = results.map(result => result.result) as never[]; + + const p = params as unknown as { + maxPerTxUsd: bigint; + maxDailyLimitUsd: bigint; + maxMonthlyLimitUsd: bigint; + defaultDailyLimitUsd: bigint; + defaultMonthlyLimitUsd: bigint; + limitRaiseDelay: bigint; + modeDelay: bigint; + }; + const [powerUsd, capacityUsd, fullyPriced] = positionValue as unknown as [ + bigint, + bigint, + boolean, + ]; + + const debt = debtUsd as unknown as bigint; + const credit = lensCredit(lensResult); + + return { + registeredOnChain: registeredOnChain as unknown as boolean, + moduleEnabled: moduleEnabled as unknown as boolean, + legacyEnabled: legacyEnabled as unknown as boolean, + modulePaused: modulePaused as unknown as boolean, + safePaused: safePaused as unknown as boolean, + maxPerTxUsd: p.maxPerTxUsd, + maxDailyLimitUsd: p.maxDailyLimitUsd, + maxMonthlyLimitUsd: p.maxMonthlyLimitUsd, + defaultDailyLimitUsd: p.defaultDailyLimitUsd, + defaultMonthlyLimitUsd: p.defaultMonthlyLimitUsd, + limitRaiseDelaySeconds: Number(p.limitRaiseDelay), + modeDelaySeconds: Number(p.modeDelay), + rawLimit: rawLimit as unknown as RawSpendingLimit, + mode: toSpendMode(mode as unknown as number), + incomingMode: toSpendMode(incomingMode as unknown as number), + incomingModeStartTime: Number(incomingModeStartTime as unknown as bigint), + position: { + // The lens prices escrowed collateral the same way the module does, so its figure is + // preferred only because it comes with the rest of the struct; the module's is the + // fallback when the lens could not be read. + collateralUsd: credit?.collateralUsd ?? 0n, + borrowingPowerUsd: credit?.borrowingPowerUsd ?? powerUsd, + prospectivePowerUsd: credit?.prospectiveCollateralUsd ?? 0n, + liquidationCapacityUsd: credit?.liquidationCapacityUsd ?? capacityUsd, + debtUsd: credit?.debtUsd ?? debt, + // Without the lens there is no honest borrowable figure: the module's power covers + // only escrowed collateral and none of the caps, so a derived number would be wrong + // 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, + healthFactorWad: healthFactorWad as unknown as bigint, + fullyPriced: credit?.fullyPriced ?? fullyPriced, + }, + borrowApyPerSecond: borrowApyPerSecond as unknown as bigint, + }; + } catch { + // Same reasoning as the failed-call branch above: v1 is the safe fallback. + return null; + } +}; + +/** + * Folds a matured raise into the caps, and reports one that is still waiting. + * + * Shared by both generations because `applicableSpendingLimit` is the same reader with the + * same tuple in each, and because the one subtlety here must not be implemented twice: the + * module only matures a pending increase once a block's timestamp has gone *past* the + * activation time, so a raise signed while the delay is zero still reads as pending until + * the chain ticks. Read back straight after the write — exactly when this runs — that would + * report the cap the user has just replaced, for a whole block time, with no refetch due. + * + * So a raise whose activation instant has already passed in wall-clock terms is folded in + * here: the only reason the reader still calls it pending is that no block has been mined + * since, the chain agrees within one, and nothing can be spent in between that the new cap + * would not have allowed anyway. + */ +const foldMaturedRaise = (limit: RawSpendingLimit) => { + const nowSeconds = BigInt(Math.floor(Date.now() / 1000)); + const activatesAt = limit.dailyLimitActivationTime; + const isRaiseEffective = activatesAt > 0n && activatesAt <= nowSeconds; + + return { + limit: { + dailyLimitUsd: isRaiseEffective ? limit.pendingDailyLimit : limit.dailyLimit, + monthlyLimitUsd: isRaiseEffective ? limit.pendingMonthlyLimit : limit.monthlyLimit, + spentTodayUsd: limit.spentToday, + spentThisMonthUsd: limit.spentThisMonth, + dailyRenewalTimestamp: limit.dailyRenewalTimestamp, + monthlyRenewalTimestamp: limit.monthlyRenewalTimestamp, + timezoneOffset: Number(limit.timezoneOffset), + } satisfies CardSpendLimit, + // The daily and monthly halves of a raise are armed together with one activation + // time, so the daily one answers for both. + pendingIncrease: + activatesAt > 0n && !isRaiseEffective + ? { + dailyLimitUsd: limit.pendingDailyLimit, + monthlyLimitUsd: limit.pendingMonthlyLimit, + activatesAt, + } + : null, + }; +}; + /** * One multicall for everything the spending sheet decides on. * @@ -144,6 +671,112 @@ const readCardSpendRegistration = async (safeAddress: Address): Promise, + module: { address: Address; abi: typeof SolidCashModule_ABI }, + safeAddress: Address, +) => { // One multicall rather than eleven round trips: this runs on mount of the card // screen and the whole point of the module's lens design is that a spending // decision is one read. @@ -181,26 +814,11 @@ const readCardSpendRegistration = async (safeAddress: Address): Promise 0n && activatesAt <= nowSeconds; - return { - // Deliberately an AND. Registered-but-revoked is a real state (the user turned - // the module off in a Safe client) and it must read as not set up, because the - // card genuinely will not work. - registered: registered && moduleEnabled, - registeredOnChain: registered, + // Deliberately kept apart rather than pre-ANDed. Registered-but-revoked is a real + // state (the user turned the module off in a Safe client) and the caller has to be + // able to tell it from never-registered — the fix is different for each. + registered, moduleEnabled, maxDailyLimitUsd, maxMonthlyLimitUsd, @@ -209,25 +827,7 @@ const readCardSpendRegistration = async (safeAddress: Address): Promise 0n && !isRaiseEffective - ? { - dailyLimitUsd: limit.pendingDailyLimit, - monthlyLimitUsd: limit.pendingMonthlyLimit, - activatesAt, - } - : null, + rawLimit: limit as unknown as RawSpendingLimit, limitRaiseDelaySeconds: Number(limitRaiseDelay), }; }; @@ -355,6 +955,14 @@ export function useCardSpendRegistration({ enabled }: UseCardSpendRegistrationOp dailyLimitUsd: bigint; monthlyLimitUsd: bigint; timezoneOffset: number; + /** + * Which module the Safe is now registered on. + * + * Sent so the backend records the generation rather than inferring it from its own + * config, which would be wrong for exactly as long as the two cohorts coexist. + * Omitted means v1, which is what every existing caller means. + */ + moduleAddress?: Address; }) => { try { await confirmWirexCardRegistration({ @@ -362,6 +970,7 @@ export function useCardSpendRegistration({ enabled }: UseCardSpendRegistrationOp dailyLimitUsd: (Number(body.dailyLimitUsd) / 10 ** CASH_USD_DECIMALS).toString(), monthlyLimitUsd: (Number(body.monthlyLimitUsd) / 10 ** CASH_USD_DECIMALS).toString(), timezoneOffset: body.timezoneOffset, + moduleAddress: body.moduleAddress, }); } catch { // Swallowed on purpose — see above. @@ -419,6 +1028,12 @@ 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. + const target = fresh.moduleAddress; + const transactions = [ ...(fresh.moduleEnabled ? [] @@ -428,7 +1043,7 @@ export function useCardSpendRegistration({ enabled }: UseCardSpendRegistrationOp data: encodeFunctionData({ abi: Safe_ABI, functionName: 'enableModule', - args: [MODULE as Address], + args: [target], }), }, ]), @@ -436,12 +1051,8 @@ export function useCardSpendRegistration({ enabled }: UseCardSpendRegistrationOp ? [] : [ { - to: MODULE as Address, - data: encodeFunctionData({ - abi: SolidCashModule_ABI, - functionName: 'registerSafe', - args: [daily, monthly, BigInt(timezoneOffset)], - }), + to: target, + data: encodeRegisterSafe(target, daily, monthly, BigInt(timezoneOffset)), }, ]), ]; @@ -542,12 +1153,8 @@ export function useCardSpendRegistration({ enabled }: UseCardSpendRegistrationOp smartAccountClient, [ { - to: MODULE as Address, - data: encodeFunctionData({ - abi: SolidCashModule_ABI, - functionName: isIncrease ? 'requestSpendingLimitIncrease' : 'decreaseSpendingLimit', - args: [nextDaily, nextMonthly], - }), + to: fresh.moduleAddress, + data: encodeLimitChange(fresh.moduleAddress, isIncrease, nextDaily, nextMonthly), }, ], isIncrease ? 'Failed to request a higher limit' : 'Failed to lower your limit', @@ -619,11 +1226,8 @@ export function useCardSpendRegistration({ enabled }: UseCardSpendRegistrationOp smartAccountClient, [ { - to: MODULE as Address, - data: encodeFunctionData({ - abi: SolidCashModule_ABI, - functionName: 'cancelPendingSpendingLimitIncrease', - }), + to: fresh.moduleAddress, + data: encodeCancelPendingIncrease(fresh.moduleAddress), }, ], 'Failed to cancel the limit change', @@ -648,6 +1252,174 @@ export function useCardSpendRegistration({ enabled }: UseCardSpendRegistrationOp }, }); + /** + * Move the card onto a different funding mode, migrating it to v2 if that is what the + * mode needs. + * + * ## Why the migration hides inside this action + * + * Every cardholder is on v1 when this ships, and v1 has exactly one way to fund a card. + * A cardholder does not have a module generation, they have a card — so nothing asks them + * to "upgrade", and nothing tells them a module is being replaced. The first time they + * choose a mode v1 cannot serve, the move happens underneath the answer they actually + * gave, in the signature they were already going to give. + * + * ## Why it is one batch + * + * Four calls, all needing `msg.sender` to be the Safe, and every intermediate state is + * broken: + * + * 1. `disableModule(v1)` — v2 refuses to act while v1 is enabled (`LegacyModuleStillEnabled`) + * and `registerSafe` asserts it outright, so this has to come first. Between here and + * step 3 the card cannot be debited at all. + * 2. `enableModule(v2)` + * 3. `registerSafe` on v2, carrying the caps and timezone the cardholder already chose + * 4. `setMode` to what they asked for + * + * Batched into one user operation they either all land or none do, so the card is never + * left with no module able to debit it. Split across signatures, a cardholder who + * abandoned after step 1 would have a dead card and no obvious way to notice. + * + * ## Afterwards + * + * Nothing here is repeated. The Safe is a v2 Safe from then on, `moduleAddress` follows + * it, and every later mode change is a bare `setMode`. + */ + const switchModeMutation = useMutation({ + mutationFn: async (target: SpendMode) => { + if (!user?.suborgId || !user?.signWith || !safeAddress) { + throw new Error('Your wallet is still setting up. Please try again shortly.'); + } + + const fresh = await readFresh(queryClient, selectedUserId, safeAddress); + + // 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 + // own ceilings and enablement state to build the batch. + const v2 = await readV2State(safeAddress); + if (!v2) throw new Error('This spend mode is not available yet.'); + + if (!fresh.registered) throw new Error('Set up card spending first.'); + if (fresh.mode === target) throw new Error('That is already your spend mode.'); + if (fresh.pendingMode === target) { + throw new Error('That change is already on its way.'); + } + + const needsRegistration = !v2.registeredOnChain; + // `registerSafe` always starts a Safe in cash, so a fresh migration lands there + // whatever the cardholder asked for. An already-registered Safe keeps the mode v2 + // has stored for it — which is not necessarily what `fresh` reports, since a Safe + // with v1 re-enabled is reported as the v1 cardholder it is behaving like. + const modeAfterBatch: SpendMode = needsRegistration ? 'cash' : v2.mode; + + const transactions: { to: Address; data: `0x${string}` }[] = []; + + // v1 has to go first and has to go entirely: while it is enabled v2 is inert by + // design, and `registerSafe` refuses rather than letting one Safe hold two + // independent sets of spending caps. + if (v2.legacyEnabled) { + const prevModule = await findModulePredecessor(safeAddress, MODULE as Address); + if (!prevModule) throw new Error('Could not read your Safe. Please try again.'); + + transactions.push({ + to: safeAddress, + data: encodeFunctionData({ + abi: Safe_ABI, + functionName: 'disableModule', + args: [prevModule, MODULE as Address], + }), + }); + } + + if (!v2.moduleEnabled) { + transactions.push({ + to: safeAddress, + data: encodeFunctionData({ + abi: Safe_ABI, + functionName: 'enableModule', + args: [MODULE_V2], + }), + }); + } + + if (needsRegistration) { + const carried = carriedLimits(fresh.limit, v2); + transactions.push({ + to: MODULE_V2, + data: encodeRegisterSafe( + MODULE_V2, + carried.dailyLimitUsd, + carried.monthlyLimitUsd, + BigInt(carried.timezoneOffset), + ), + }); + } + + if (modeAfterBatch !== target) { + transactions.push({ + to: MODULE_V2, + data: encodeFunctionData({ + abi: SolidCashModuleV2_ABI, + functionName: 'setMode', + args: [toContractMode(target)], + }), + }); + } + + if (transactions.length === 0) throw new Error('That is already your spend mode.'); + + const smartAccountClient = await safeAA(fuse, user.suborgId, user.signWith); + const result = await executeTransactions( + smartAccountClient, + transactions, + 'Failed to change your spend mode', + fuse, + ); + + if (result === USER_CANCELLED_TRANSACTION) { + track(TRACKING_EVENTS.CARD_SPEND_MODE_CHANGE_CANCELLED, { mode: target }); + return null; + } + + // Only worth reporting when the caps actually moved module — an ordinary mode change + // leaves them exactly where the backend already has them. + if (needsRegistration) { + const carried = carriedLimits(fresh.limit, v2); + await confirmWithBackend({ + transactionHash: result.transactionHash, + dailyLimitUsd: carried.dailyLimitUsd, + monthlyLimitUsd: carried.monthlyLimitUsd, + timezoneOffset: carried.timezoneOffset, + moduleAddress: MODULE_V2, + }); + } + + return { + transactionHash: result.transactionHash, + mode: target, + migrated: needsRegistration, + // `modeDelay` is zero at launch, so the switch is in force in the same block. Read + // rather than assumed: a non-zero value would leave the cardholder in their old + // mode for a while, and the sheet has to be able to say so. + activatesInSeconds: v2.modeDelaySeconds, + }; + }, + onSuccess: result => { + if (!result) return; + invalidateAfterWrite(); + track(TRACKING_EVENTS.CARD_SPEND_MODE_CHANGE_COMPLETED, { + mode: result.mode, + migrated: result.migrated, + transaction_hash: result.transactionHash, + }); + }, + onError: (mutationError: Error) => { + const message = mutationError?.message || 'Failed to change your spend mode'; + setError(message); + track(TRACKING_EVENTS.CARD_SPEND_MODE_CHANGE_FAILED, { error: message }); + }, + }); + /** * Withdraw module consent: `Safe.disableModule`, leaving the Safe registered but unable * to be debited. @@ -668,30 +1440,10 @@ export function useCardSpendRegistration({ enabled }: UseCardSpendRegistrationOp const fresh = await readFresh(queryClient, selectedUserId, safeAddress); if (!fresh.moduleEnabled) throw new Error('Card spending is already off.'); - // `disableModule` takes the list entry that points at the module, so the list has to - // be read first — it cannot be derived, and passing the wrong predecessor reverts - // GS103. Read at press time rather than cached with the rest of the registration: - // enabling any other module rewrites these pointers, and a stale predecessor is a - // failed user operation. - const client = publicClient(fuse.id); - const [modules] = await client.readContract({ - address: safeAddress, - abi: Safe_ABI, - functionName: 'getModulesPaginated', - args: [SENTINEL_MODULES, MODULE_PAGE_SIZE], - }); - - const index = modules.findIndex( - module => module.toLowerCase() === (MODULE as string).toLowerCase(), - ); + const prevModule = await findModulePredecessor(safeAddress, fresh.moduleAddress); // Not on the list at all: the chain disagrees with what we read a moment ago (another // client disabled it). Nothing to do, and sending the transaction would only revert. - if (index === -1) throw new Error('Card spending is already off.'); - - // `getModulesPaginated` walks from the sentinel outwards, so the entry before the - // module in this array is exactly the one pointing at it — and for the first entry - // that is the sentinel. - const prevModule = index === 0 ? SENTINEL_MODULES : modules[index - 1]; + if (!prevModule) throw new Error('Card spending is already off.'); const smartAccountClient = await safeAA(fuse, user.suborgId, user.signWith); const result = await executeTransactions( @@ -702,7 +1454,7 @@ export function useCardSpendRegistration({ enabled }: UseCardSpendRegistrationOp data: encodeFunctionData({ abi: Safe_ABI, functionName: 'disableModule', - args: [prevModule, MODULE as Address], + args: [prevModule, fresh.moduleAddress], }), }, ], @@ -806,6 +1558,24 @@ export function useCardSpendRegistration({ enabled }: UseCardSpendRegistrationOp return result !== null; }, [disableMutation]); + /** + * Change how the card is funded. + * + * Resolves `true` once the change is on-chain, `false` when the signature prompt was + * dismissed, and rejects on a real failure — the same three outcomes as {@link register}, + * and for the same reason: a cancelled signature is not an error to show, but telling + * someone their card now borrows when it does not would be worse. + */ + const switchMode = useCallback( + async (mode: SpendMode): Promise => { + setError(null); + track(TRACKING_EVENTS.CARD_SPEND_MODE_CHANGE_PRESSED, { mode }); + const result = await switchModeMutation.mutateAsync(mode); + return result !== null; + }, + [switchModeMutation], + ); + return { registration, /** Whether to offer the control at all. */ @@ -829,13 +1599,44 @@ export function useCardSpendRegistration({ enabled }: UseCardSpendRegistrationOp limit: registration?.limit ?? null, /** A raise that has been asked for and has not taken effect yet. */ pendingIncrease: registration?.pendingIncrease ?? null, + + // ---- Spend mode ------------------------------------------------------------------- + + /** How the card is funded today. Always `cash` for a cardholder still on v1. */ + mode: registration?.mode ?? 'cash', + /** + * Whether Credit and Smart can be offered at all. + * + * Needs the build to be configured for v2 AND the cardholder to have a working card to + * migrate — there is nothing to move to credit if the card is not set up yet. + */ + canChangeMode: registration?.v2Available === true && registration.registered, + /** A switch that is armed but not yet in force, or null. Null at `modeDelay = 0`. */ + pendingMode: registration?.pendingMode ?? null, + /** Unix seconds {@link pendingMode} takes effect. Zero when nothing is armed. */ + modeActivatesAt: registration?.modeActivatesAt ?? 0, + /** The Safe's credit position, or null for a cardholder on v1. */ + position: registration?.position ?? null, + /** WAD borrow rate per second, for quoting an APY. */ + borrowApyPerSecond: registration?.borrowApyPerSecond ?? 0n, + /** + * True only while the Safe is operated by v1 *and* has already registered on v2. + * + * The recoverable anomaly: v2 goes inert while v1 is enabled, so the card falls back to + * cash and the credit line is unreachable until v1 is disabled — which is exactly what + * changing mode does. + */ + isLegacyConflict: registration?.cohort === SpendCohort.Both, + isLoading: query.isLoading, + isSwitchingMode: switchModeMutation.isPending, isRegistering: mutation.isPending, isUpdatingLimit: updateMutation.isPending, isCancellingIncrease: cancelIncreaseMutation.isPending, isDisabling: disableMutation.isPending, error, register, + switchMode, updateLimit, cancelPendingIncrease, disable, diff --git a/lib/abis/SolidCashModuleV2.ts b/lib/abis/SolidCashModuleV2.ts new file mode 100644 index 00000000..79590bd4 --- /dev/null +++ b/lib/abis/SolidCashModuleV2.ts @@ -0,0 +1,635 @@ +/** + * `SolidCashModuleV2` — the card spend module's v2 surface, as the app uses it. + * + * **Generated from the compiled artifact, not hand-written.** Regenerate rather than edit: + * the credit half of this module is arithmetic the app quotes to users, and a tuple + * transcribed one field out of order decodes silently into wrong money. + * + * Core and setters are two contracts under EIP-170 but ONE address to callers — the core + * `delegatecall`s anything it does not implement into the setters half, and reads resolve + * through that fallback under `staticcall` exactly as writes do. So `applicableSpendingLimit`, + * `getParams` and `getIncomingMode` live in the setters and are still called on the core's + * address, which is what `ADDRESSES.fuse.cashModuleV2` holds. + */ +export const SolidCashModuleV2_ABI = [ + { + type: 'function', + name: 'allowedTokens', + inputs: [], + outputs: [ + { + name: '', + type: 'address[]', + internalType: 'address[]', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'borrowApyPerSecond', + inputs: [], + outputs: [ + { + name: '', + type: 'uint64', + internalType: 'uint64', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'cancelPendingSpendingLimitIncrease', + inputs: [], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'collateralOf', + inputs: [ + { + name: 'safe', + type: 'address', + internalType: 'address', + }, + { + name: 'token', + type: 'address', + internalType: 'address', + }, + ], + outputs: [ + { + name: '', + type: 'uint256', + internalType: 'uint256', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'debtUsd', + inputs: [ + { + name: 'safe', + type: 'address', + internalType: 'address', + }, + ], + outputs: [ + { + name: '', + type: 'uint256', + internalType: 'uint256', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'decreaseSpendingLimit', + inputs: [ + { + name: 'dailyLimitUsd', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'monthlyLimitUsd', + type: 'uint256', + internalType: 'uint256', + }, + ], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'deregisterSafe', + inputs: [], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'getMode', + inputs: [ + { + name: 'safe', + type: 'address', + internalType: 'address', + }, + ], + outputs: [ + { + name: '', + type: 'uint8', + internalType: 'enum Mode', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'healthFactor', + inputs: [ + { + name: 'safe', + type: 'address', + internalType: 'address', + }, + ], + outputs: [ + { + name: '', + type: 'uint256', + internalType: 'uint256', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'incomingModeStartTime', + inputs: [ + { + name: 'safe', + type: 'address', + internalType: 'address', + }, + ], + outputs: [ + { + name: '', + type: 'uint64', + internalType: 'uint64', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'isLegacyEnabledOn', + inputs: [ + { + name: 'safe', + type: 'address', + internalType: 'address', + }, + ], + outputs: [ + { + name: '', + type: 'bool', + internalType: 'bool', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'isModuleEnabledOn', + inputs: [ + { + name: 'safe', + type: 'address', + internalType: 'address', + }, + ], + outputs: [ + { + name: '', + type: 'bool', + internalType: 'bool', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'isPaused', + inputs: [], + outputs: [ + { + name: '', + type: 'bool', + internalType: 'bool', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'isRegistered', + inputs: [ + { + name: 'safe', + type: 'address', + internalType: 'address', + }, + ], + outputs: [ + { + name: '', + type: 'bool', + internalType: 'bool', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'limitsWaived', + inputs: [ + { + name: 'safe', + type: 'address', + internalType: 'address', + }, + ], + outputs: [ + { + name: '', + type: 'bool', + internalType: 'bool', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'maxCanSpendUsd', + inputs: [ + { + name: 'safe', + type: 'address', + internalType: 'address', + }, + ], + outputs: [ + { + name: '', + type: 'uint256', + internalType: 'uint256', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'positionValue', + inputs: [ + { + name: 'safe', + type: 'address', + internalType: 'address', + }, + ], + outputs: [ + { + name: 'powerUsd', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'capacityUsd', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'fullyPriced', + type: 'bool', + internalType: 'bool', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'registerSafe', + inputs: [ + { + name: 'dailyLimitUsd', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'monthlyLimitUsd', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'timezoneOffset', + type: 'int256', + internalType: 'int256', + }, + ], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'requestSpendingLimitIncrease', + inputs: [ + { + name: 'dailyLimitUsd', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'monthlyLimitUsd', + type: 'uint256', + internalType: 'uint256', + }, + ], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'safePaused', + inputs: [ + { + name: 'safe', + type: 'address', + internalType: 'address', + }, + ], + outputs: [ + { + name: '', + type: 'bool', + internalType: 'bool', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'setMode', + inputs: [ + { + name: 'mode', + type: 'uint8', + internalType: 'enum Mode', + }, + ], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'totalCollateral', + inputs: [ + { + name: 'token', + type: 'address', + internalType: 'address', + }, + ], + outputs: [ + { + name: '', + type: 'uint256', + internalType: 'uint256', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'applicableSpendingLimit', + inputs: [ + { + name: 'safe', + type: 'address', + internalType: 'address', + }, + ], + outputs: [ + { + name: '', + type: 'tuple', + internalType: 'struct SpendingLimit', + components: [ + { + name: 'dailyLimit', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'monthlyLimit', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'spentToday', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'spentThisMonth', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'pendingDailyLimit', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'pendingMonthlyLimit', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'dailyRenewalTimestamp', + type: 'uint64', + internalType: 'uint64', + }, + { + name: 'monthlyRenewalTimestamp', + type: 'uint64', + internalType: 'uint64', + }, + { + name: 'dailyLimitActivationTime', + type: 'uint64', + internalType: 'uint64', + }, + { + name: 'monthlyLimitActivationTime', + type: 'uint64', + internalType: 'uint64', + }, + { + name: 'timezoneOffset', + type: 'int256', + internalType: 'int256', + }, + ], + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'getIncomingMode', + inputs: [ + { + name: 'safe', + type: 'address', + internalType: 'address', + }, + ], + outputs: [ + { + name: '', + type: 'uint8', + internalType: 'enum Mode', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'getParams', + inputs: [], + outputs: [ + { + name: '', + type: 'tuple', + internalType: 'struct Params', + components: [ + { + name: 'maxPerTxUsd', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'maxDailyLimitUsd', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'maxMonthlyLimitUsd', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'defaultDailyLimitUsd', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'defaultMonthlyLimitUsd', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'maxDebtPerSafeUsd', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'maxGlobalDebtUsd', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'maxForcedSpendUsd', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'dustFloorUsd', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'minPositionUsd', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'targetLtvBps', + type: 'uint16', + internalType: 'uint16', + }, + { + name: 'closeFactorBps', + type: 'uint16', + internalType: 'uint16', + }, + { + name: 'maxAdjustmentBps', + type: 'uint16', + internalType: 'uint16', + }, + { + name: 'modeDelay', + type: 'uint64', + internalType: 'uint64', + }, + { + name: 'limitRaiseDelay', + type: 'uint64', + internalType: 'uint64', + }, + { + name: 'collateralWithdrawDelay', + type: 'uint64', + internalType: 'uint64', + }, + { + name: 'liquidationGracePeriod', + type: 'uint64', + internalType: 'uint64', + }, + { + name: 'graceFloorHf', + type: 'uint64', + internalType: 'uint64', + }, + { + name: 'paramChangeDelay', + type: 'uint64', + internalType: 'uint64', + }, + { + name: 'limitWaiveDelay', + type: 'uint64', + internalType: 'uint64', + }, + ], + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'repayFromSafe', + inputs: [ + { + name: 'safe', + type: 'address', + internalType: 'address', + }, + { + name: 'token', + type: 'address', + internalType: 'address', + }, + { + name: 'amount', + type: 'uint256', + internalType: 'uint256', + }, + ], + outputs: [], + stateMutability: 'nonpayable', + }, +] as const; diff --git a/lib/abis/SolidSpendLens.ts b/lib/abis/SolidSpendLens.ts new file mode 100644 index 00000000..a079c74a --- /dev/null +++ b/lib/abis/SolidSpendLens.ts @@ -0,0 +1,660 @@ +/** + * `SolidSpendLens` — the cohort-aware, one-`eth_call` read of a Safe's whole spend state. + * + * **Generated from the compiled artifact, not hand-written.** See the note in + * `SolidCashModuleV2.ts`. + * + * Serves v1 and v2 Safes from one call and reports which module owns the Safe, so the app + * asks a single question — "what can this Safe spend, and how?" — instead of branching on + * the module before it knows which one is live. `cohort` is what that branch reads. + */ +export const SolidSpendLens_ABI = [ + { + type: 'function', + name: 'COHORT_BOTH', + inputs: [], + outputs: [ + { + name: '', + type: 'uint8', + internalType: 'uint8', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'COHORT_NONE', + inputs: [], + outputs: [ + { + name: '', + type: 'uint8', + internalType: 'uint8', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'COHORT_V1', + inputs: [], + outputs: [ + { + name: '', + type: 'uint8', + internalType: 'uint8', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'COHORT_V2', + inputs: [], + outputs: [ + { + name: '', + type: 'uint8', + internalType: 'uint8', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'availableToSpend', + inputs: [ + { + name: 'safe', + type: 'address', + internalType: 'address', + }, + ], + outputs: [ + { + name: '', + type: 'tuple', + internalType: 'struct UnifiedAvailability', + components: [ + { + name: 'cohort', + type: 'uint8', + internalType: 'uint8', + }, + { + name: 'moduleEnabled', + type: 'bool', + internalType: 'bool', + }, + { + name: 'registered', + type: 'bool', + internalType: 'bool', + }, + { + name: 'modulePaused', + type: 'bool', + internalType: 'bool', + }, + { + name: 'safePausedFlag', + type: 'bool', + internalType: 'bool', + }, + { + name: 'limitsWaived', + type: 'bool', + internalType: 'bool', + }, + { + name: 'activeMode', + type: 'uint8', + internalType: 'enum Mode', + }, + { + name: 'incomingMode', + type: 'uint8', + internalType: 'enum Mode', + }, + { + name: 'incomingModeStartTime', + type: 'uint64', + internalType: 'uint64', + }, + { + name: 'debit', + type: 'tuple', + internalType: 'struct DebitAvailability', + components: [ + { + name: 'spendableUsd', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'limitRemainingUsd', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'maxPerTxUsd', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'anyPriceUnusable', + type: 'bool', + internalType: 'bool', + }, + { + name: 'perToken', + type: 'tuple[]', + internalType: 'struct TokenAvailability[]', + components: [ + { + name: 'token', + type: 'address', + internalType: 'address', + }, + { + name: 'amount', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'priceUsd', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'priceUsable', + type: 'bool', + internalType: 'bool', + }, + { + name: 'valueUsd', + type: 'uint256', + internalType: 'uint256', + }, + ], + }, + ], + }, + { + name: 'credit', + type: 'tuple', + internalType: 'struct CreditAvailability', + components: [ + { + name: 'collateralUsd', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'borrowingPowerUsd', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'liquidationCapacityUsd', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'debtUsd', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'healthFactorWad', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'availableToBorrowUsd', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'prospectiveCollateralUsd', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'fullyPriced', + type: 'bool', + internalType: 'bool', + }, + { + name: 'liquidatable', + type: 'bool', + internalType: 'bool', + }, + { + name: 'unhealthySince', + type: 'uint64', + internalType: 'uint64', + }, + { + name: 'perCollateral', + type: 'tuple[]', + internalType: 'struct TokenAvailability[]', + components: [ + { + name: 'token', + type: 'address', + internalType: 'address', + }, + { + name: 'amount', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'priceUsd', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'priceUsable', + type: 'bool', + internalType: 'bool', + }, + { + name: 'valueUsd', + type: 'uint256', + internalType: 'uint256', + }, + ], + }, + ], + }, + { + name: 'limit', + type: 'tuple', + internalType: 'struct SpendingLimit', + components: [ + { + name: 'dailyLimit', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'monthlyLimit', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'spentToday', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'spentThisMonth', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'pendingDailyLimit', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'pendingMonthlyLimit', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'dailyRenewalTimestamp', + type: 'uint64', + internalType: 'uint64', + }, + { + name: 'monthlyRenewalTimestamp', + type: 'uint64', + internalType: 'uint64', + }, + { + name: 'dailyLimitActivationTime', + type: 'uint64', + internalType: 'uint64', + }, + { + name: 'monthlyLimitActivationTime', + type: 'uint64', + internalType: 'uint64', + }, + { + name: 'timezoneOffset', + type: 'int256', + internalType: 'int256', + }, + ], + }, + { + name: 'blockNumber', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'blockTimestamp', + type: 'uint256', + internalType: 'uint256', + }, + ], + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'availableToSpendWith', + inputs: [ + { + name: 'safe', + type: 'address', + internalType: 'address', + }, + { + name: 'tokens', + type: 'address[]', + internalType: 'address[]', + }, + ], + outputs: [ + { + name: '', + type: 'tuple', + internalType: 'struct UnifiedAvailability', + components: [ + { + name: 'cohort', + type: 'uint8', + internalType: 'uint8', + }, + { + name: 'moduleEnabled', + type: 'bool', + internalType: 'bool', + }, + { + name: 'registered', + type: 'bool', + internalType: 'bool', + }, + { + name: 'modulePaused', + type: 'bool', + internalType: 'bool', + }, + { + name: 'safePausedFlag', + type: 'bool', + internalType: 'bool', + }, + { + name: 'limitsWaived', + type: 'bool', + internalType: 'bool', + }, + { + name: 'activeMode', + type: 'uint8', + internalType: 'enum Mode', + }, + { + name: 'incomingMode', + type: 'uint8', + internalType: 'enum Mode', + }, + { + name: 'incomingModeStartTime', + type: 'uint64', + internalType: 'uint64', + }, + { + name: 'debit', + type: 'tuple', + internalType: 'struct DebitAvailability', + components: [ + { + name: 'spendableUsd', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'limitRemainingUsd', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'maxPerTxUsd', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'anyPriceUnusable', + type: 'bool', + internalType: 'bool', + }, + { + name: 'perToken', + type: 'tuple[]', + internalType: 'struct TokenAvailability[]', + components: [ + { + name: 'token', + type: 'address', + internalType: 'address', + }, + { + name: 'amount', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'priceUsd', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'priceUsable', + type: 'bool', + internalType: 'bool', + }, + { + name: 'valueUsd', + type: 'uint256', + internalType: 'uint256', + }, + ], + }, + ], + }, + { + name: 'credit', + type: 'tuple', + internalType: 'struct CreditAvailability', + components: [ + { + name: 'collateralUsd', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'borrowingPowerUsd', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'liquidationCapacityUsd', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'debtUsd', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'healthFactorWad', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'availableToBorrowUsd', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'prospectiveCollateralUsd', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'fullyPriced', + type: 'bool', + internalType: 'bool', + }, + { + name: 'liquidatable', + type: 'bool', + internalType: 'bool', + }, + { + name: 'unhealthySince', + type: 'uint64', + internalType: 'uint64', + }, + { + name: 'perCollateral', + type: 'tuple[]', + internalType: 'struct TokenAvailability[]', + components: [ + { + name: 'token', + type: 'address', + internalType: 'address', + }, + { + name: 'amount', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'priceUsd', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'priceUsable', + type: 'bool', + internalType: 'bool', + }, + { + name: 'valueUsd', + type: 'uint256', + internalType: 'uint256', + }, + ], + }, + ], + }, + { + name: 'limit', + type: 'tuple', + internalType: 'struct SpendingLimit', + components: [ + { + name: 'dailyLimit', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'monthlyLimit', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'spentToday', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'spentThisMonth', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'pendingDailyLimit', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'pendingMonthlyLimit', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'dailyRenewalTimestamp', + type: 'uint64', + internalType: 'uint64', + }, + { + name: 'monthlyRenewalTimestamp', + type: 'uint64', + internalType: 'uint64', + }, + { + name: 'dailyLimitActivationTime', + type: 'uint64', + internalType: 'uint64', + }, + { + name: 'monthlyLimitActivationTime', + type: 'uint64', + internalType: 'uint64', + }, + { + name: 'timezoneOffset', + type: 'int256', + internalType: 'int256', + }, + ], + }, + { + name: 'blockNumber', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'blockTimestamp', + type: 'uint256', + internalType: 'uint256', + }, + ], + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'cohortOf', + inputs: [ + { + name: 'safe', + type: 'address', + internalType: 'address', + }, + ], + outputs: [ + { + name: '', + type: 'uint8', + internalType: 'uint8', + }, + ], + stateMutability: 'view', + }, +] as const; diff --git a/lib/api.ts b/lib/api.ts index 616d5983..32ac3257 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -60,6 +60,7 @@ import { CardProvider, CardResponse, CardSecretsResponseDto, + CardSpendModeAccessResponse, CardStatusResponse, CardTransaction, CardTransactionsResponse, @@ -3416,6 +3417,32 @@ export const getWirexCardRegistration = async ( return response.json(); }; +/** + * Whether this user may see the spend-mode picker and the borrow position. + * + * Its own request rather than a field on the registration read, and deliberately so: that + * read is a live chain call, and a rollout gate has no business being unavailable because + * Fuse is lagging. This is one indexed Mongo lookup. + */ +export const getCardSpendModeAccess = async (): Promise => { + const jwt = getJWTToken(); + + const response = await fetch( + `${EXPO_PUBLIC_FLASH_API_BASE_URL}/accounts/v1/wirex/spend-mode/access`, + { + headers: { + ...getPlatformHeaders(), + ...(jwt ? { Authorization: `Bearer ${jwt}` } : {}), + }, + credentials: 'include', + }, + ); + + if (!response.ok) throw response; + + return response.json(); +}; + /** * Record a completed registration and get the re-read status back. * diff --git a/lib/config.ts b/lib/config.ts index a50a18b1..addb0cc8 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -16,6 +16,19 @@ export const EXPO_PUBLIC_ENVIRONMENT = process.env.EXPO_PUBLIC_ENVIRONMENT ?? '' // Sandbox: skip the TransFi buy-crypto KYC gate on the client and go straight to // the amount/quote screen. Pair with backend TRANSFI_SKIP_KYC. Never set in prod. export const EXPO_PUBLIC_TRANSFI_SKIP_KYC = process.env.EXPO_PUBLIC_TRANSFI_SKIP_KYC === 'true'; +/** + * Card spend v2: the Credit and Smart funding modes, and the borrow position behind them. + * + * Gated on a flag rather than on whether {@link ADDRESSES}.fuse.cashModuleV2 is still the zero + * address, so a QA build can point at a testnet deployment before mainnet has one. Off means the + * card behaves exactly as it does today — every cardholder is on v1, spending cash, and the two + * modes they cannot use are not offered. + * + * The flag alone is not sufficient: the addresses have to be real too, which + * `isCardSpendV2Configured` checks. Both are required because they fail differently — a flag with + * no address is a misconfigured build, and an address with no flag is a deliberate dark launch. + */ +export const EXPO_PUBLIC_CARD_SPEND_V2 = process.env.EXPO_PUBLIC_CARD_SPEND_V2 === 'true'; // Onramper buy-crypto (iOS only — the SDK has no Android/web implementation). // `apiKey` is Onramper's publishable partner key: EXPO_PUBLIC_* values are inlined // into the JS bundle, so only ever put a publishable key here, never a secret. @@ -138,6 +151,26 @@ type Addresses = { cashModule: Address; /** SolidCashLens — one-call read of a Safe's spending power across its allowlisted assets. */ cashLens: Address; + /** + * SolidCashModuleV2 — the card spend module that adds the Credit and Smart funding modes. + * + * **One address for both halves.** The module is split into a core and a setters contract to + * fit EIP-170, and the core `delegatecall`s anything it does not implement into the setters — + * so callers only ever use this address, for reads as well as writes. + * + * A Safe is operated by v1 or by v2, never both: v2 goes inert while v1 is still enabled, and + * `registerSafe` refuses unless v1 is disabled. Migration is therefore one batched user + * operation, and it only happens when a cardholder first chooses a mode v1 cannot serve. + */ + cashModuleV2: Address; + /** + * SolidSpendLens — the cohort-aware read serving BOTH module generations from one call. + * + * Distinct from {@link cashLens}, which only knows v1. This one reports which module owns a + * given Safe, which is what lets the app ask "what can this Safe spend?" without first knowing + * which generation the cardholder is on. + */ + spendLensV2: Address; fastWithdrawManager: Address; stargateOftUSDC: Address; aaveV3Pool: Address; @@ -154,6 +187,9 @@ type Addresses = { }; }; +/** Stand-in for a contract this build has no address for. Never a valid call target. */ +const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000' as const; + export const ADDRESSES: Addresses = { ethereum: { teller: isProduction @@ -213,6 +249,11 @@ export const ADDRESSES: Addresses = { // this address therefore means every registered Safe must re-consent. cashModule: '0x31F7f64769C6B2D4d3edd053421a0465FB371061', cashLens: '0x2036512E45BF7c61814050fF9B1b5403353854a6', + // NOT DEPLOYED YET. Both are the zero address until the v2 deployment lands, and + // `isCardSpendV2Configured` is what stops the app offering a mode it cannot execute. The env + // overrides exist so a QA build can point at a testnet deployment without waiting for mainnet. + cashModuleV2: (process.env.EXPO_PUBLIC_CASH_MODULE_V2_ADDRESS ?? ZERO_ADDRESS) as Address, + spendLensV2: (process.env.EXPO_PUBLIC_SPEND_LENS_V2_ADDRESS ?? ZERO_ADDRESS) as Address, fastWithdrawManager: '0x0bA17eab7B6B2353eA4731c37A2cBA2a5AA4Ea1b', stargateOftUSDC: '0xAF54BE5B6eEc24d6BFACf1cce4eaF680A8239398', aaveV3Pool: '0xe3eda4b12ae4ACC031E4CF9Eae08ACe6250CED3E', diff --git a/lib/types.ts b/lib/types.ts index 8b62ab69..6bc26ead 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -900,6 +900,26 @@ export interface WirexThreeDsDecisionResponse { * Limits are decimal USD strings rather than numbers: they are 6-decimal on-chain * values and float rounding on a spending cap is not worth the convenience. */ +/** + * Whether the app may offer the spend-mode picker and the borrow position to this user. + * + * Server-decided, because the first cohort is a Mongo collection (`wirexTeamMembers`) that + * has to be editable without a release — adding the next tester must not mean shipping a + * build. It is a visibility gate and nothing more: the mode is changed by a `setMode` + * UserOperation the Safe signs for itself, so this decides what the app offers, never what + * the chain allows. + */ +export interface CardSpendModeAccessResponse { + /** Whether to render the spend-mode card and the borrow position at all. */ + enabled: boolean; + /** + * `cohort` — on the list. `open` — the gate is lifted for everyone. + * `not-in-cohort` — off, working as intended. `unavailable` — the lookup failed and we + * defaulted to closed, which is a bug rather than the feature. + */ + reason: 'cohort' | 'open' | 'not-in-cohort' | 'unavailable'; +} + export interface WirexCardRegistrationResponse { /** Both halves done: the module is enabled on the Safe *and* the Safe is registered. */ registered: boolean; @@ -982,6 +1002,14 @@ export interface WirexCardRegistrationConfirmRequest { dailyLimitUsd: string; monthlyLimitUsd: string; timezoneOffset: number; + /** + * The spend module the Safe is now registered on. + * + * Sent so the backend records which generation this cardholder is on rather than + * inferring it from its own config, which is wrong for exactly as long as the two + * cohorts coexist. Omitted means v1, which is what every pre-v2 caller means. + */ + moduleAddress?: string; } // --- Rain contracts (funding) --- @@ -2199,6 +2227,41 @@ export interface CardSpendDetails { state?: string; settled_at?: string; decline_reason?: string; + /** + * The refund leg, when this transaction has one. + * + * Separate from the fields above because those describe the *purchase* — what + * left the Safe and how the claim stands. A refund is its own movement in the + * opposite direction, with its own hash and its own arithmetic. + */ + refund?: CardRefundDetails; +} + +/** + * What came back on a refunded transaction, and why it is not the round number + * the merchant quoted. + * + * The cashback the purchase earned is withheld from the refund — a refunded + * purchase was never a purchase, so the reward comes back with the money. That + * is correct and also invisible: the shop says it refunded $40 and $38.80 + * arrives. These three figures are what makes the shortfall explainable on the + * receipt instead of in a support ticket. + */ +export interface CardRefundDetails { + /** `pending` | `paid` | `failed` — where the refund payout stands. */ + status: string; + /** What the refund is worth before anything is withheld, in USD. */ + gross_usd: number; + /** Cashback withheld from it, in USD. Absent when nothing was withheld. */ + cashback_deducted_usd?: number; + /** What actually reached the Safe, or will. `gross_usd` minus the deduction. */ + paid_usd: number; + /** The USDC.e transfer that paid it, on Fuse. Absent until it lands. */ + tx_hash?: string; + /** Chain the refund was paid on. Fuse (122) — NOT the issuer's chain. */ + chain_id?: number; + /** One sentence explaining the deduction, when there is one to explain. */ + note?: string; } export interface CardTransaction { diff --git a/lib/utils/cardHelpers.ts b/lib/utils/cardHelpers.ts index 1796457c..0a7a4f32 100644 --- a/lib/utils/cardHelpers.ts +++ b/lib/utils/cardHelpers.ts @@ -4,6 +4,7 @@ import { CardFeeStatus, CardFeeWaiveReason, CardProvider, + CardRefundDetails, CardResponse, CardSpendDetails, CardStatus, @@ -395,6 +396,25 @@ export const cardSweepExplorerUrl = (details: CardSpendDetails | undefined): str return `https://explorer.fuse.io/tx/${hash}`; }; +/** + * Explorer link for the USDC.e transfer that paid a refund, on Fuse. + * + * Its own helper rather than a second call to {@link cardSweepExplorerUrl} with + * a different field: the two hashes point in opposite directions — one is money + * we took, one is money we sent back — and a single helper reading whichever + * field happened to be set is how a refund ends up rendered under a "Sweep" + * label. Same chain guard, for the same reason. + */ +export const cardRefundExplorerUrl = ( + refund: CardRefundDetails | undefined, +): string | undefined => { + const hash = refund?.tx_hash; + if (!hash) return undefined; + const chainId = refund?.chain_id ?? FUSE_CHAIN_ID; + if (chainId !== FUSE_CHAIN_ID) return undefined; + return `https://explorer.fuse.io/tx/${hash}`; +}; + /** * Currencies that read better as a leading symbol. Deliberately short: anything * missing falls back to a trailing ISO code, which is how CHF and PLN are