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 (
+
+ );
+};
+
+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