diff --git a/.env.example b/.env.example index 02ff60711..cb397d876 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,7 @@ EXPO_PUBLIC_BASE_URL= # Frontend domain (e.g., https://app.solix.xyz) EXPO_PUBLIC_ALCHEMY_API_KEY= # TODO: Move Pimlico API Key to backend EXPO_PUBLIC_PIMLICO_API_KEY= +EXPO_PUBLIC_PIMLICO_SPONSORSHIP_POLICY_ID=sp_romantic_fallen_one EXPO_PUBLIC_FLASH_API_BASE_URL= EXPO_PUBLIC_FLASH_ANALYTICS_API_BASE_URL= EXPO_PUBLIC_BRIDGE_AUTO_DEPOSIT_ADDRESS= @@ -68,6 +69,12 @@ EXPO_PUBLIC_CARD_SPEND_V2=true EXPO_PUBLIC_CASH_MODULE_V2_ADDRESS= # SolidSpendLens on Fuse — the cohort-aware read serving both module generations. EXPO_PUBLIC_SPEND_LENS_V2_ADDRESS= +# Earlier SolidCashModuleV2 cores the address above replaced, comma-separated. Any of +# these still enabled on a Safe is disabled in the same batch that puts it on the live +# core — the backend no longer reads them, so a Safe left there declines every payment. +# Empty means the 2026-09-24 retiree (0xE2d4FB3d1eeD6Bdc3fD62A93ab35A33FC3c97b2B). +# Append here on the next redeploy, and change BOTH addresses above in the same release. +EXPO_PUBLIC_RETIRED_CASH_MODULE_V2_ADDRESSES= # ---- Base: the EURC spend instance ------------------------------------------- # A SECOND, INDEPENDENT deployment of the spend module, not a setting on the Fuse diff --git a/README.md b/README.md index 6091c96f4..252b6aec1 100644 --- a/README.md +++ b/README.md @@ -193,6 +193,7 @@ Key environment variables (see `.env.example`): - `EXPO_PUBLIC_ALCHEMY_API_KEY`: Alchemy API key for blockchain data - `EXPO_PUBLIC_ETHEREUM_API_KEY`: Ethereum API key - `EXPO_PUBLIC_PIMLICO_API_KEY`: Pimlico paymaster API key +- `EXPO_PUBLIC_PIMLICO_SPONSORSHIP_POLICY_ID`: Pimlico sponsorship policy attached to every user operation - `EXPO_PUBLIC_FLASH_API_BASE_URL`: Solid API configuration - `EXPO_PUBLIC_FLASH_ANALYTICS_API_BASE_URL`: Solid analytics endpoint - `EXPO_PUBLIC_BRIDGE_AUTO_DEPOSIT_ADDRESS`: Bridge auto-deposit address diff --git a/app.config.ts b/app.config.ts index eac8d4173..f80a6e248 100644 --- a/app.config.ts +++ b/app.config.ts @@ -51,7 +51,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ ...config, name: 'Solid', slug: 'flash-frontend', - version: '2.0.1', + version: '2.0.2', orientation: 'portrait', icon: './assets/images/adaptive-icon.png', scheme: 'solid', diff --git a/app/(protected)/(tabs)/index.native.tsx b/app/(protected)/(tabs)/index.native.tsx index b0f6e536f..7a621ca44 100644 --- a/app/(protected)/(tabs)/index.native.tsx +++ b/app/(protected)/(tabs)/index.native.tsx @@ -17,6 +17,7 @@ import { WalletInfo } from '@/components/Wallet'; import LazyWalletTabs from '@/components/Wallet/LazyWalletTabs'; import TokenListSkeleton from '@/components/Wallet/WalletTokenTab/TokenListSkeleton'; import { SPIN_WIN_MODAL } from '@/constants/modals'; +import { useActivityRefresh } from '@/hooks/useActivityRefresh'; import { useUserTransactions } from '@/hooks/useAnalytics'; import { useCardDetails } from '@/hooks/useCardDetails'; import { useCardStatus } from '@/hooks/useCardStatus'; @@ -48,6 +49,7 @@ function LegacyHome() { const { user } = useUser(); const queryClient = useQueryClient(); + const { refetchAll, isRefreshing } = useActivityRefresh(); const { data: balance, isLoading: isBalanceLoading } = useVaultBalance( user?.safeAddress as Address, ); @@ -127,7 +129,7 @@ function LegacyHome() { const showAssets = isLoadingTokens || hasTokens || !!tokenError; return ( - + {isBalanceSectionLoading ? ( diff --git a/app/(protected)/activity/index.tsx b/app/(protected)/activity/index.tsx index 1e4e9724c..1898844a5 100644 --- a/app/(protected)/activity/index.tsx +++ b/app/(protected)/activity/index.tsx @@ -52,6 +52,7 @@ export default function Activity() { items, isLoading, isSyncing, + isRefreshing, isSyncStale, hasNextPage, isFetchingNextPage, @@ -73,7 +74,11 @@ export default function Activity() { const searchTerm = query.trim(); const pageHeader = ( - + Activity @@ -89,30 +94,35 @@ export default function Activity() { ); + const filters = ( + + + + ); + return ( openSupportDrawer()} + scrollable={isWeb} > - {pageHeader} + {isWeb && pageHeader} - - - + {isWeb && filters} + {pageHeader} + {filters} + + ) : undefined + } emptyTitle={searchTerm ? 'No matching activity' : 'No transactions found'} emptyDescription={ searchTerm diff --git a/app/_layout.tsx b/app/_layout.tsx index ce2945179..4cb0e5700 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -45,6 +45,7 @@ import CashbackStoreReviewTrigger from '@/components/StoreReview/CashbackStoreRe import ThirdwebConnectionBridge from '@/components/ThirdwebConnectionBridge'; import { toastProps } from '@/components/Toast'; import { TurnkeyProvider } from '@/components/TurnkeyProvider'; +import { path } from '@/constants/path'; import { getInfoClient } from '@/graphql/clients'; import { useAttributionInitialization } from '@/hooks/useAttributionInitialization'; import { useDimension } from '@/hooks/useDimension'; @@ -57,6 +58,7 @@ import { EXPO_PUBLIC_ENVIRONMENT, isProduction } from '@/lib/config'; import { configureObserve, markAppInteractive, withObserve } from '@/lib/observe'; import { config } from '@/lib/wagmi'; import { useUserStore } from '@/store/useUserStore'; +import { useWhatsNewStore } from '@/store/useWhatsNewStore'; import type { ErrorBoundaryProps } from 'expo-router'; @@ -162,7 +164,9 @@ SplashScreen.setOptions({ // Note: Only rendered when hasSelectedUser is true (see conditional render below) // Uses LazyWhatsNewModal to defer react-native-reanimated-carousel bundle function WhatsNewWrapper() { - const { whatsNew, isVisible, closeWhatsNew } = useWhatsNew(); + const pathname = usePathname(); + const isHomeReady = useWhatsNewStore(state => state.isHomeReady); + const { whatsNew, isVisible, closeWhatsNew } = useWhatsNew(pathname === path.HOME && isHomeReady); if (!whatsNew) return null; diff --git a/app/recovery.tsx b/app/recovery.tsx index b352664af..88c2cc890 100644 --- a/app/recovery.tsx +++ b/app/recovery.tsx @@ -21,11 +21,23 @@ import { useDimension } from '@/hooks/useDimension'; import { initRecoveryOtp, verifyRecoveryOtp } from '@/lib/api'; import { getAsset } from '@/lib/assets'; import { buildRecoveryPasskeyName, isTurnkeySessionError } from '@/lib/utils/passkey'; -import { useUserStore } from '@/store/useUserStore'; +import { selectLastKnownIdentity, useUserStore } from '@/store/useUserStore'; // Validation schemas -const emailSchema = z.object({ - email: z.email({ error: 'Please enter a valid email address' }), +// +// Deliberately loose: this field takes an email *or* a username, and the two +// cannot both be checked strictly here. Anything with an "@" is held to the +// email rules — a typo there is worth catching before a round trip — and +// anything else only has to be long enough to be a handle. Which account it +// names is the server's call either way. +const identifierSchema = z.object({ + identifier: z + .string() + .trim() + .min(1, { error: 'Enter your email or username' }) + .refine(part => !part.includes('@') || z.email().safeParse(part).success, { + error: 'Please enter a valid email address', + }), }); const otpSchema = z.object({ @@ -35,7 +47,7 @@ const otpSchema = z.object({ .regex(/^\d+$/, { error: 'Verification code must only contain numbers' }), }); -type EmailFormData = z.infer; +type IdentifierFormData = z.infer; type OtpFormData = z.infer; const STEPS = { @@ -64,12 +76,18 @@ export default function RecoveryPasskey() { const { isDesktop } = useDimension(); const { createApiKeyPair, addPasskey, storeSession, httpClient, session } = useTurnkey(); const setCredentialIdsForIdentity = useUserStore(state => state.setCredentialIdsForIdentity); + const lastKnownIdentity = useUserStore(selectLastKnownIdentity); const [step, setStep] = useState(STEPS.EMAIL_INPUT); const [apiError, setApiError] = useState(''); const [loading, setLoading] = useState(false); - const [email, setEmail] = useState(''); + // What the user typed: an email, or a username for an account whose address + // they cannot remember — or which has none. + const [identifier, setIdentifier] = useState(''); + // Where the code actually went, masked by the backend. The only thing the + // OTP step can honestly say when the recovery started from a username. + const [emailHint, setEmailHint] = useState(''); const [otpId, setOtpId] = useState(''); const [recoveryData, setRecoveryData] = useState<{ credentialBundle: string; @@ -78,19 +96,26 @@ export default function RecoveryPasskey() { expiresAt?: number; } | null>(null); - // Step 1: Send OTP to user's email via backend - const handleSendOtp = useCallback(async (data: EmailFormData) => { + // Only an address can be cross-checked on verify; a username recovery leaves + // the account to be resolved from the challenge the backend issued. + const verifiedEmail = identifier.includes('@') ? identifier : undefined; + + // Step 1: Send OTP to the account's email via backend + const handleSendOtp = useCallback(async (data: IdentifierFormData) => { setLoading(true); setApiError(''); try { - const response = await initRecoveryOtp(data.email); + const response = await initRecoveryOtp(data.identifier); if (!response.otpId) { throw new Error('Failed to send verification code'); } - setEmail(data.email); + setIdentifier(data.identifier); + // An older backend sends no hint; what they typed is then the best we + // have, and for an email recovery it is the right answer anyway. + setEmailHint(response.emailHint || data.identifier); setOtpId(response.otpId); setStep(STEPS.OTP_VERIFY); } catch (err: any) { @@ -116,7 +141,12 @@ export default function RecoveryPasskey() { } // Verify OTP via backend - backend also calls otpLogin and returns credentialBundle - const verifyResponse = await verifyRecoveryOtp(otpId, data.otpCode, email, publicKey); + const verifyResponse = await verifyRecoveryOtp( + otpId, + data.otpCode, + verifiedEmail, + publicKey, + ); if (!verifyResponse.credentialBundle) { throw new Error('Failed to verify code'); @@ -135,7 +165,7 @@ export default function RecoveryPasskey() { setLoading(false); } }, - [otpId, email, createApiKeyPair, storeSession], + [otpId, verifiedEmail, createApiKeyPair, storeSession], ); // Every credential Turnkey holds for the recovered account, read with the @@ -218,7 +248,12 @@ export default function RecoveryPasskey() { // re-prompts. Replace them with what Turnkey holds now, including the // passkey just added. const credentialIds = await readCredentialIds(recoveryData); - setCredentialIdsForIdentity({ turnkeyUserId: recoveryData.userId, email }, credentialIds); + // `turnkeyUserId` is always present here, so the row is found whether or + // not this recovery ever learned the address. + setCredentialIdsForIdentity( + { turnkeyUserId: recoveryData.userId, email: verifiedEmail }, + credentialIds, + ); setStep(STEPS.SUCCESS); } catch (err: any) { @@ -232,7 +267,7 @@ export default function RecoveryPasskey() { Sentry.captureException(err, { tags: { type: 'recovery_passkey_creation_error', turnkey_error_code: err?.code }, extra: { - email, + identifier, turnkeyUserId: recoveryData.userId, organizationId: recoveryData.organizationId, cause: err?.cause?.message, @@ -254,7 +289,8 @@ export default function RecoveryPasskey() { recoveryData, readCredentialIds, setCredentialIdsForIdentity, - email, + identifier, + verifiedEmail, hasUsableSession, sendBackForNewCode, session, @@ -266,7 +302,7 @@ export default function RecoveryPasskey() { setApiError(''); try { - const response = await initRecoveryOtp(email); + const response = await initRecoveryOtp(identifier); if (!response.otpId) { throw new Error('Failed to resend verification code'); @@ -279,7 +315,7 @@ export default function RecoveryPasskey() { } finally { setLoading(false); } - }, [email]); + }, [identifier]); const handleBack = useCallback(() => { router.replace(path.ONBOARDING); @@ -289,11 +325,16 @@ export default function RecoveryPasskey() { const stepContent = ( {step === STEPS.EMAIL_INPUT && ( - + )} {step === STEPS.OTP_VERIFY && ( Promise; +// Identifier Input Component with react-hook-form +interface IdentifierInputProps { + onSubmit: (data: IdentifierFormData) => Promise; loading: boolean; apiError: string; + /** + * The account this device last knew about. Someone locked out is reaching for + * the account they were signed into here, and they are on a phone with no + * password manager to help — the one who prompted this change opened the + * screen twice and left both times without filling the field in. + */ + initialValue: string; } -function EmailInput({ onSubmit, loading, apiError }: EmailInputProps) { +function IdentifierInput({ onSubmit, loading, apiError, initialValue }: IdentifierInputProps) { const { control, handleSubmit, formState: { errors, isValid }, - } = useForm({ - resolver: zodResolver(emailSchema), + } = useForm({ + resolver: zodResolver(identifierSchema), mode: 'onChange', defaultValues: { - email: '', + identifier: initialValue, }, }); - const fieldError = errors.email?.message; + const fieldError = errors.identifier?.message; const displayError = fieldError || apiError; return ( @@ -392,27 +440,33 @@ function EmailInput({ onSubmit, loading, apiError }: EmailInputProps) { - Email + Email or username ( )} /> + + The code goes to the email on your account. + {displayError ? ( @@ -441,14 +495,19 @@ function EmailInput({ onSubmit, loading, apiError }: EmailInputProps) { // OTP Verification Component with react-hook-form interface OtpVerifyProps { - email: string; + /** + * Where the code went, already masked by the backend. A recovery started + * from a username never learns the full address, so this screen is told what + * to show rather than deriving it. + */ + emailHint: string; onSubmit: (data: OtpFormData) => Promise; onResend: () => void; loading: boolean; apiError: string; } -function OtpVerify({ email, onSubmit, onResend, loading, apiError }: OtpVerifyProps) { +function OtpVerify({ emailHint, onSubmit, onResend, loading, apiError }: OtpVerifyProps) { const { control, handleSubmit, @@ -462,7 +521,11 @@ function OtpVerify({ email, onSubmit, onResend, loading, apiError }: OtpVerifyPr }, }); - const maskedEmail = email.replace(/(.{2})(.*)(@.*)/, '$1***$3'); + // Masked server-side when it came from there. An older backend sends no hint + // and this is whatever the user typed, so mask it here too. + const maskedEmail = emailHint.includes('•') + ? emailHint + : emailHint.replace(/(.{2})(.*)(@.*)/, '$1***$3'); const fieldError = errors.otpCode?.message; const displayError = fieldError || apiError; diff --git a/assets/animations/credit-help-keep-earning-poster.png b/assets/animations/credit-help-keep-earning-poster.png new file mode 100644 index 000000000..ca5034faf Binary files /dev/null and b/assets/animations/credit-help-keep-earning-poster.png differ diff --git a/assets/animations/credit-help-keep-earning.mp4 b/assets/animations/credit-help-keep-earning.mp4 new file mode 100644 index 000000000..c8dde322a Binary files /dev/null and b/assets/animations/credit-help-keep-earning.mp4 differ diff --git a/assets/animations/credit-help-repay-anytime-poster.png b/assets/animations/credit-help-repay-anytime-poster.png new file mode 100644 index 000000000..f587ba2cf Binary files /dev/null and b/assets/animations/credit-help-repay-anytime-poster.png differ diff --git a/assets/animations/credit-help-repay-anytime.mp4 b/assets/animations/credit-help-repay-anytime.mp4 new file mode 100644 index 000000000..38fadd60e Binary files /dev/null and b/assets/animations/credit-help-repay-anytime.mp4 differ diff --git a/assets/animations/credit-help-two-ways-poster.png b/assets/animations/credit-help-two-ways-poster.png new file mode 100644 index 000000000..899b9cc3e Binary files /dev/null and b/assets/animations/credit-help-two-ways-poster.png differ diff --git a/assets/animations/credit-help-two-ways.mp4 b/assets/animations/credit-help-two-ways.mp4 new file mode 100644 index 000000000..57003670a Binary files /dev/null and b/assets/animations/credit-help-two-ways.mp4 differ diff --git a/assets/animations/figma-credit-help-source/figma-credit-help-keep-earning.mp4 b/assets/animations/figma-credit-help-source/figma-credit-help-keep-earning.mp4 new file mode 100644 index 000000000..d922e4039 Binary files /dev/null and b/assets/animations/figma-credit-help-source/figma-credit-help-keep-earning.mp4 differ diff --git a/assets/animations/figma-credit-help-source/figma-credit-help-repay-anytime.mp4 b/assets/animations/figma-credit-help-source/figma-credit-help-repay-anytime.mp4 new file mode 100644 index 000000000..17aeb2566 Binary files /dev/null and b/assets/animations/figma-credit-help-source/figma-credit-help-repay-anytime.mp4 differ diff --git a/assets/animations/figma-credit-help-source/figma-credit-help-two-ways.mp4 b/assets/animations/figma-credit-help-source/figma-credit-help-two-ways.mp4 new file mode 100644 index 000000000..ae5e6d1a5 Binary files /dev/null and b/assets/animations/figma-credit-help-source/figma-credit-help-two-ways.mp4 differ diff --git a/assets/animations/figma-credit-help-source/repay-label.png b/assets/animations/figma-credit-help-source/repay-label.png new file mode 100644 index 000000000..cc91bf55e Binary files /dev/null and b/assets/animations/figma-credit-help-source/repay-label.png differ diff --git a/components/Activity/UnifiedActivityList.tsx b/components/Activity/UnifiedActivityList.tsx index 8d49c5303..ad97db893 100644 --- a/components/Activity/UnifiedActivityList.tsx +++ b/components/Activity/UnifiedActivityList.tsx @@ -5,6 +5,9 @@ import { FlashList } from '@shopify/flash-list'; import UnifiedActivityRow, { useUnifiedActivityPress, } from '@/components/Activity/UnifiedActivityRow'; +import PullToRefreshScrollView, { + REFRESH_SPINNER_COLOR, +} from '@/components/PullToRefreshScrollView'; import Skeleton from '@/components/ui/skeleton'; import { Text } from '@/components/ui/text'; import { useCardProvider } from '@/hooks/useCardProvider'; @@ -21,6 +24,7 @@ type UnifiedActivityListProps = { userHasCard: boolean; isLoading: boolean; isSyncing?: boolean; + isRefreshing?: boolean; isSyncStale?: boolean; hasNextPage?: boolean; isFetchingNextPage?: boolean; @@ -65,6 +69,7 @@ export default function UnifiedActivityList({ userHasCard, isLoading, isSyncing = false, + isRefreshing = false, isSyncStale = false, hasNextPage = false, isFetchingNextPage = false, @@ -193,10 +198,11 @@ export default function UnifiedActivityList({ ); }, [handleLoadMore, hasNextPage, isFetchingNextPage]); - return ( - - {/* Subtle syncing indicator for background syncs (native only) */} - {Platform.OS !== 'web' && isSyncing && ( + const listHeader = ( + <> + {listHeaderComponent} + {/* Keep the background sync status in the content that moves with a pull. */} + {Platform.OS !== 'web' && isSyncing && !isRefreshing && ( @@ -204,7 +210,11 @@ export default function UnifiedActivityList({ )} + + ); + return ( + ) : undefined } + renderScrollComponent={Platform.OS !== 'web' ? PullToRefreshScrollView : undefined} showsVerticalScrollIndicator={false} /> diff --git a/components/Card/NewCardDetails/CardDetailsHeader.tsx b/components/Card/NewCardDetails/CardDetailsHeader.tsx index 7053a8214..5df738510 100644 --- a/components/Card/NewCardDetails/CardDetailsHeader.tsx +++ b/components/Card/NewCardDetails/CardDetailsHeader.tsx @@ -14,9 +14,8 @@ interface CardDetailsHeaderProps { } /** - * Mobile header of the card-details pane: a 50pt circular back button on the left - * and a centred "Solid card" title (Figma 20095:5396 / 20095:5761). Both fade + - * settle in behind the flying card. + * Card-details header: a 50pt circular back button and a centred "Solid card" + * title. Both fade and settle in behind the flying card. */ const CardDetailsHeader = ({ onBack }: CardDetailsHeaderProps) => { return ( diff --git a/components/Card/NewCardDetails/CardDetailsPane.tsx b/components/Card/NewCardDetails/CardDetailsPane.tsx index a64c7d790..4971d5afd 100644 --- a/components/Card/NewCardDetails/CardDetailsPane.tsx +++ b/components/Card/NewCardDetails/CardDetailsPane.tsx @@ -33,6 +33,7 @@ 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 SpendModeHelpModal from '@/components/Card/NewCardDetails/SpendMode/SpendModeHelpModal'; import SpendModeSheet from '@/components/Card/NewCardDetails/SpendMode/SpendModeSheet'; import useSpendModeFigures from '@/components/Card/NewCardDetails/SpendMode/useSpendModeFigures'; import { useCardPaneVisibility } from '@/components/Card/NewCardDetails/useCardPaneVisibility'; @@ -66,6 +67,8 @@ import { import { useCardHeroStore } from '@/store/useCardHeroStore'; import { useCardPaneStore } from '@/store/useCardPaneStore'; import { useCardWelcomePopupStore } from '@/store/useCardWelcomePopupStore'; +import { useSpendModeHelpStore } from '@/store/useSpendModeHelpStore'; +import { useUserStore } from '@/store/useUserStore'; const HEADER_FADE_EXTENT = 32; const HEADER_GRADIENT_FADE_MS = 280; @@ -101,6 +104,11 @@ const CardDetailsPane = () => { const isHeroFlying = useCardHeroStore(state => state.active); const spendModeRequested = useCardPaneStore(state => state.spendModeRequested); const dismissSpendModeRequest = useCardPaneStore(state => state.dismissSpendModeRequest); + const selectedUserId = useUserStore(state => state.users.find(user => user.selected)?.userId); + const hasShownSpendModeHelp = useSpendModeHelpStore( + state => !selectedUserId || Boolean(state.shownByUserId[selectedUserId]), + ); + const markSpendModeHelpShown = useSpendModeHelpStore(state => state.markShown); // Shown here rather than on the old details route: card issuance sets this flag // and sends the user to /card/details, which on mobile now lands on this pane. @@ -177,6 +185,15 @@ const CardDetailsPane = () => { } }, [spendRegistration]); const [isSpendModeOpen, setIsSpendModeOpen] = useState(false); + const [isSpendModeHelpOpen, setIsSpendModeHelpOpen] = useState(false); + // The explainer is an introduction to this sheet, so it opens only when the + // account first reaches Spend Mode. Mark it shown on opening: closing early + // still leaves the question-mark button available for a later revisit. + useEffect(() => { + if (!isOpen || !isSpendModeOpen || !selectedUserId || hasShownSpendModeHelp) return; + markSpendModeHelpShown(selectedUserId); + setIsSpendModeHelpOpen(true); + }, [hasShownSpendModeHelp, isOpen, isSpendModeOpen, markSpendModeHelpShown, selectedUserId]); // The borrow position's own sheet, opened by tapping the card that shows it. const [isBorrowPositionOpen, setIsBorrowPositionOpen] = useState(false); // Add funds from inside the spend-mode sheet. Its own instance of the fund modal, driven @@ -244,6 +261,7 @@ const CardDetailsPane = () => { setSpendSheetSource(null); setIsAddToWalletOpen(false); setIsSpendModeOpen(false); + setIsSpendModeHelpOpen(false); setIsBorrowPositionOpen(false); setIsSpendModeFundOpen(false); }, [isOpen]); @@ -464,6 +482,7 @@ const CardDetailsPane = () => { setIsSpendModeHelpOpen(true)} activeMode={spendModeFigures.mode} // Same gate as the actions row's Add funds, so the two never disagree about whether // funds can move right now. Spend modes are Wirex-only, and a Wirex card is funded @@ -475,6 +494,10 @@ const CardDetailsPane = () => { : undefined } /> + setIsSpendModeHelpOpen(false)} + /> = { + twoWays: require('@/assets/animations/credit-help-two-ways.mp4'), + keepEarning: require('@/assets/animations/credit-help-keep-earning.mp4'), + repayAnytime: require('@/assets/animations/credit-help-repay-anytime.mp4'), +}; + +const SLIDE_POSTERS: Record = { + twoWays: require('@/assets/animations/credit-help-two-ways-poster.png'), + keepEarning: require('@/assets/animations/credit-help-keep-earning-poster.png'), + repayAnytime: require('@/assets/animations/credit-help-repay-anytime-poster.png'), +}; + +interface SpendModeHelpModalProps { + isOpen: boolean; + onClose: () => void; +} + +const SlideDot = ({ active }: { active: boolean }) => { + const progress = useSharedValue(active ? 1 : 0); + + useEffect(() => { + progress.value = withTiming(active ? 1 : 0, { duration: DOT_TRANSITION_MS }); + }, [active, progress]); + + const style = useAnimatedStyle(() => ({ + width: 6 + progress.value * 14, + opacity: 0.3 + progress.value * 0.7, + })); + + return ( + + ); +}; + +const HelpPage = ({ + slide, + isActive, + reduceMotion, + playbackSession, + pageWidth, +}: { + slide: SpendModeHelpSlide; + isActive: boolean; + reduceMotion: boolean; + playbackSession: number; + pageWidth: number; +}) => { + const illustrationHeight = pageWidth * ILLUSTRATION_ASPECT_RATIO; + const verticalFadeSize = pageWidth * 0.18; + const horizontalFadeSize = pageWidth * 0.07; + + return ( + + + {reduceMotion ? ( + + ) : ( + + )} + + + + + + + + + + {slide.title} + + + + + + {slide.description} + + + + + ); +}; + +/** + * Spend Mode help carousel (Figma 27048:3993 / 27217:1250 / 27245:1250). + * Opened from the "?" button in the Select spend mode sheet. + * + * All three slides are mounted side by side in a real pager row — swiping (or + * tapping the CTA) drags/slides between actual pages rather than faking it + * with a fade/slide of a single swapped-out content block. + * + * Each illustration is cropped from its exported Figma timeline. H.264 keeps + * decoding off the JS thread. Only the visible page plays, and selecting it + * restarts the animation. + * + * Uses React Native's native `Modal` (its own OS-level window) rather than the + * shared Dialog/ResponsiveModal, so it reliably covers the card pane and tab bar. + */ +const SpendModeHelpModal = ({ isOpen, onClose }: SpendModeHelpModalProps) => { + const insets = useSafeAreaInsets(); + const reduceMotion = useReducedMotion(); + const { isScreenMedium } = useDimension(); + const { width: windowWidth, height: windowHeight } = useWindowDimensions(); + const isDesktopPopup = Platform.OS === 'web' && isScreenMedium; + const pageWidth = isDesktopPopup ? Math.min(DESKTOP_MODAL_WIDTH, windowWidth - 32) : windowWidth; + const modalHeight = Math.min(DESKTOP_MODAL_HEIGHT, windowHeight - 32); + const pagerRef = useRef(null); + const navigationTargetRef = useRef(null); + const [index, setIndex] = useState(0); + const [playbackSession, setPlaybackSession] = useState(0); + const slide = SPEND_MODE_HELP_SLIDES[index]; + const isLastSlide = index === SPEND_MODE_HELP_SLIDES.length - 1; + + // Reset the pager and restart the first illustration each time the modal opens. + useEffect(() => { + navigationTargetRef.current = null; + if (!isOpen) return; + + setIndex(0); + setPlaybackSession(session => session + 1); + const frame = requestAnimationFrame(() => { + pagerRef.current?.scrollTo({ x: 0, animated: false }); + }); + + return () => cancelAnimationFrame(frame); + }, [isOpen]); + + const goToSlide = useCallback( + (targetIndex: number) => { + navigationTargetRef.current = targetIndex; + setIndex(targetIndex); + pagerRef.current?.scrollTo({ + x: targetIndex * pageWidth, + animated: true, + }); + }, + [pageWidth], + ); + + const handleNext = useCallback(() => { + if (navigationTargetRef.current !== null) return; + + if (isLastSlide) { + onClose(); + return; + } + goToSlide(index + 1); + }, [goToSlide, index, isLastSlide, onClose]); + + // Activate the nearest page while it moves into view. On web, momentum-end + // is not guaranteed to fire after a manual swipe, which can leave the old + // page active and the visible illustration paused on its first frame. + const handleScroll = useCallback( + (event: NativeSyntheticEvent) => { + const offsetX = event.nativeEvent.contentOffset.x; + const navigationTarget = navigationTargetRef.current; + + // Animated scrollTo emits events from the outgoing page first. Keep the + // requested page active until the pager reaches its destination. + if (navigationTarget !== null) { + if (Math.abs(offsetX - navigationTarget * pageWidth) < 1) { + navigationTargetRef.current = null; + } + setIndex(currentIndex => + currentIndex === navigationTarget ? currentIndex : navigationTarget, + ); + return; + } + + const targetIndex = Math.round(offsetX / pageWidth); + const boundedIndex = Math.max(0, Math.min(targetIndex, SPEND_MODE_HELP_SLIDES.length - 1)); + setIndex(currentIndex => (currentIndex === boundedIndex ? currentIndex : boundedIndex)); + }, + [pageWidth], + ); + + return ( + + + + + + {isDesktopPopup ? ( + + ) : ( + + )} + + + + + {SPEND_MODE_HELP_SLIDES.map((item, itemIndex) => ( + + ))} + + + + {SPEND_MODE_HELP_SLIDES.map((item, itemIndex) => ( + + ))} + + + + + + + + + ); +}; + +export default SpendModeHelpModal; diff --git a/components/Card/NewCardDetails/SpendMode/SpendModePanels.tsx b/components/Card/NewCardDetails/SpendMode/SpendModePanels.tsx index 796468b93..3b4a776a1 100644 --- a/components/Card/NewCardDetails/SpendMode/SpendModePanels.tsx +++ b/components/Card/NewCardDetails/SpendMode/SpendModePanels.tsx @@ -34,13 +34,17 @@ interface BalancePanelProps { onAddFunds?: () => void; } -/** "Your USDC balance / $0.0" with the Add funds pill on the right. */ +/** + * "Your USD balance / $0.0" with the Add funds pill on the right. + * + * Deliberately not "USDC": the figure behind it is `useCardSpendableBalanceUSD`, which sums + * every asset in `CARD_SPENDABLE_ASSETS` — USDC, USDT and soUSD on Fuse. Naming one of the + * three told a cardholder holding USDT that the balance shown was somebody else's. + */ export const SpendModeBalancePanel = ({ balance, onAddFunds }: BalancePanelProps) => ( - - Your USDC balance - + Your USD balance {balance} {onAddFunds ? ( diff --git a/components/Card/NewCardDetails/SpendMode/SpendModeSegmentedControl.tsx b/components/Card/NewCardDetails/SpendMode/SpendModeSegmentedControl.tsx index dcd00dfaf..5743f338a 100644 --- a/components/Card/NewCardDetails/SpendMode/SpendModeSegmentedControl.tsx +++ b/components/Card/NewCardDetails/SpendMode/SpendModeSegmentedControl.tsx @@ -28,7 +28,7 @@ interface SegmentLabelsProps { tone: 'light' | 'dark'; } -/** "Cash / Add USDC", stacked and centred — the same block in both tones. */ +/** "Cash / Add funds", stacked and centred — the same block in both tones. */ const SegmentLabels = ({ mode, value, tone }: SegmentLabelsProps) => ( { @@ -57,6 +58,7 @@ const SpendModeSheet = ({ isSwitching={isSwitchingMode} error={error} onDismiss={dismiss} + onHelpPress={onHelpPress} onAddFunds={onAddFunds} topPadding={topPadding} presentation={presentation} diff --git a/components/Card/NewCardDetails/SpendMode/SpendModeSheet.types.ts b/components/Card/NewCardDetails/SpendMode/SpendModeSheet.types.ts index 2b1e2619f..79e8aea68 100644 --- a/components/Card/NewCardDetails/SpendMode/SpendModeSheet.types.ts +++ b/components/Card/NewCardDetails/SpendMode/SpendModeSheet.types.ts @@ -8,6 +8,8 @@ export interface SpendModeSheetProps { * plain dismissal rather than a "Change to …". */ activeMode?: SpendMode; + /** Opens the spend-mode explanation from within this sheet. */ + onHelpPress: () => void; /** 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 index 11c572f01..e7250eef0 100644 --- a/components/Card/NewCardDetails/SpendMode/SpendModeSheetContent.tsx +++ b/components/Card/NewCardDetails/SpendMode/SpendModeSheetContent.tsx @@ -38,6 +38,7 @@ import { type SpendModePanel, } from '@/components/Card/NewCardDetails/SpendMode/spendModes'; import SpendModeSegmentedControl from '@/components/Card/NewCardDetails/SpendMode/SpendModeSegmentedControl'; +import HeaderHelpButton from '@/components/Navbar/HeaderHelpButton'; import { Text } from '@/components/ui/text'; import { cn } from '@/lib/utils'; @@ -126,6 +127,7 @@ interface SpendModeSheetContentProps { /** Why the last attempt failed, or null. */ error?: string | null; onDismiss: () => void; + onHelpPress: () => void; onAddFunds?: () => void; /** Space above the heading; sheets and the desktop modal clear different chrome. */ topPadding?: number; @@ -152,6 +154,7 @@ const SpendModeSheetContent = ({ isSwitching, error, onDismiss, + onHelpPress, onAddFunds, topPadding = SPEND_MODE_SHEET_TOP, presentation = 'sheet', @@ -236,6 +239,16 @@ const SpendModeSheetContent = ({ return ( + + + Select spend mode @@ -372,6 +385,10 @@ const SpendModeSheetContent = ({ }; const styles = StyleSheet.create({ + // Figma 26974:12806 places the 44pt control 21pt below the sheet edge. + // Scale the shared 50pt help icon to the 44pt circle used on this sheet. + helpButton: { position: 'absolute', right: 17, zIndex: 1 }, + modalHelpButton: { right: 40, top: -7, transform: [{ scale: 0.72 }] }, // The close button sits in the heading's row, centred on its 36pt line box, so the modal // spends no height on a row of its own. heading: { justifyContent: 'center' }, diff --git a/components/Card/NewCardDetails/SpendMode/spendModeHelpData.ts b/components/Card/NewCardDetails/SpendMode/spendModeHelpData.ts new file mode 100644 index 000000000..2edb549ca --- /dev/null +++ b/components/Card/NewCardDetails/SpendMode/spendModeHelpData.ts @@ -0,0 +1,30 @@ +export interface SpendModeHelpSlide { + key: 'twoWays' | 'keepEarning' | 'repayAnytime'; + title: string; + description: string; + cta: 'Next' | 'Got it'; +} + +/** Copy from the three Credit help frames in Figma. */ +export const SPEND_MODE_HELP_SLIDES: SpendModeHelpSlide[] = [ + { + key: 'twoWays', + title: 'Two ways to pay', + description: + 'Cash pays from your USD balance. Credit lets you spend without selling your assets.', + cta: 'Next', + }, + { + key: 'keepEarning', + title: 'Keep earning', + description: + 'With Credit, your assets stay invested and keep earning yield. You still earn cashback too.', + cta: 'Next', + }, + { + key: 'repayAnytime', + title: 'Repay anytime', + description: 'No fixed payments or penalties. See what you owe and repay whenever you want.', + cta: 'Got it', + }, +]; diff --git a/components/Card/NewCardDetails/SpendMode/spendModes.ts b/components/Card/NewCardDetails/SpendMode/spendModes.ts index 0848b6978..5444e3c20 100644 --- a/components/Card/NewCardDetails/SpendMode/spendModes.ts +++ b/components/Card/NewCardDetails/SpendMode/spendModes.ts @@ -48,7 +48,9 @@ interface SpendModeCopy { export const SPEND_MODE_COPY: Record = { cash: { label: 'Cash', - caption: 'Spend your USDC asset balance', + // Not a single ticker: Cash draws on every asset in `CARD_SPENDABLE_ASSETS` — USDC, + // USDT and soUSD on Fuse — and the panel below it adds all three up. + caption: 'Spend your USD asset balance', panels: ['balance'], }, credit: { diff --git a/components/Card/NewCardDetails/SpendMode/useSpendModeFigures.ts b/components/Card/NewCardDetails/SpendMode/useSpendModeFigures.ts index cd6efdaae..2d7847f63 100644 --- a/components/Card/NewCardDetails/SpendMode/useSpendModeFigures.ts +++ b/components/Card/NewCardDetails/SpendMode/useSpendModeFigures.ts @@ -173,8 +173,9 @@ export const useSpendModeFigures = (): SpendModeFigures => { 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', + // rather than shown a zero. "Funds", not a ticker — the balance above sums USDC, + // USDT and soUSD, so naming one of them would send the other two to the wrong place. + cash: cashMicro > 0n ? cashLabel : 'Add funds', 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 diff --git a/components/DepositOption/DepositFeeNotice.tsx b/components/DepositOption/DepositFeeNotice.tsx index 052b5ef37..f21e723ed 100644 --- a/components/DepositOption/DepositFeeNotice.tsx +++ b/components/DepositOption/DepositFeeNotice.tsx @@ -1,10 +1,13 @@ import { Text } from '@/components/ui/text'; +import { useDepositFeeQuote } from '@/hooks/useDepositFeeQuote'; import { CardProvider } from '@/lib/types'; import { cn } from '@/lib/utils'; import { DepositFeeProduct, formatDepositFeePercent, - getDepositFeeBps, + getDepositFeeDestinationType, + getDepositFeeRatePpm, + resolveDepositFeeRatePpm, } from '@/lib/utils/depositFee'; type DepositFeeNoticeProps = { @@ -17,7 +20,7 @@ type DepositFeeNoticeProps = { provider: CardProvider | null | undefined; /** Chain the deposit is sent on. */ chainId: number; - /** Currency being sent. */ + /** Currency being sent. The backend prices the deposit by it. */ symbol?: string; /** Share token a savings deposit mints. */ vaultToken?: string; @@ -26,7 +29,9 @@ type DepositFeeNoticeProps = { /** * The fee line under a deposit address. Renders nothing when the deposit is - * free; `getDepositFeeBps` decides when that is. + * free, and nothing until the backend has said at what rate it is not: + * `getDepositFeeRatePpm` decides which deposits can be charged, and the + * backend's quote what they pay. */ const DepositFeeNotice = ({ product, @@ -36,13 +41,23 @@ const DepositFeeNotice = ({ vaultToken, className, }: DepositFeeNoticeProps) => { - const bps = getDepositFeeBps({ provider, product, chainId, symbol, vaultToken }); + const rulePpm = getDepositFeeRatePpm({ provider, product, chainId, symbol, vaultToken }); + const { data: quote, isError } = useDepositFeeQuote({ + destinationType: getDepositFeeDestinationType(product), + chainId, + symbol, + provider, + enabled: rulePpm > 0, + }); + // No currency means nothing to ask the backend about, which is the same as it + // not answering: quote the default. + const ratePpm = resolveDepositFeeRatePpm({ rulePpm, quote, quoteFailed: isError || !symbol }); - if (!bps) return null; + if (!ratePpm) return null; return ( - {`${formatDepositFeePercent(bps)} fee will be charged for deposits on this network`} + {`${formatDepositFeePercent(ratePpm)} fee will be charged for deposits on this network`} ); }; diff --git a/components/DepositOption/WalletDepositAddress/index.tsx b/components/DepositOption/WalletDepositAddress/index.tsx index 129072daf..2c5ac624b 100644 --- a/components/DepositOption/WalletDepositAddress/index.tsx +++ b/components/DepositOption/WalletDepositAddress/index.tsx @@ -72,7 +72,7 @@ const WalletDepositAddress = () => { // the Safe, which is where they land and stay. const { address, isError: hasAddressError, isMinted } = useWalletDepositAddress(chainId, symbol); // Wirex cardholders and people with no card both land here, and only the - // cardholders are charged — see `getDepositFeeBps`. + // cardholders are charged — see `getDepositFeeRatePpm`. const { provider } = useCardProvider(); const tokenIcon = getWalletDepositTokenIcon(chainId, symbol); // The pipeline's own floor when it has answered, the committed estimate until diff --git a/components/DepositOption/__tests__/DepositFeeNotice.test.tsx b/components/DepositOption/__tests__/DepositFeeNotice.test.tsx index 54d92ee33..ab8421b28 100644 --- a/components/DepositOption/__tests__/DepositFeeNotice.test.tsx +++ b/components/DepositOption/__tests__/DepositFeeNotice.test.tsx @@ -12,7 +12,26 @@ jest.mock('@/lib/utils', () => ({ cn: (...classes: unknown[]) => classes.filter(Boolean).join(' '), })); -const FEE_LINE = '0.03% fee will be charged for deposits on this network'; +type QuoteState = { + data?: { applies: boolean; ratePpm: number }; + isError: boolean; +}; + +// The backend's answer. Charges the 0.03% default unless a test says otherwise, +// so the rule tests below show which deposits are never asked about at all. +let mockQuote: QuoteState; +const mockUseDepositFeeQuote = jest.fn((_params: unknown) => mockQuote); +jest.mock('@/hooks/useDepositFeeQuote', () => ({ + useDepositFeeQuote: (params: unknown) => mockUseDepositFeeQuote(params), +})); + +beforeEach(() => { + mockQuote = { data: { applies: true, ratePpm: 300 }, isError: false }; + mockUseDepositFeeQuote.mockClear(); +}); + +const feeLine = (percent: string) => `${percent} fee will be charged for deposits on this network`; +const FEE_LINE = feeLine('0.03%'); const render = (element: React.ReactElement) => { let tree: any; @@ -79,3 +98,98 @@ test("follows the vault's chain on a savings deposit", () => { expect(notice(mainnet.id)).toEqual([]); expect(notice(base.id)).toEqual([FEE_LINE]); }); + +describe('the rate', () => { + test('is the one the backend quotes for the route and chain', () => { + mockQuote = { data: { applies: true, ratePpm: 500 }, isError: false }; + expect(rainCardNotice()).toEqual([feeLine('0.05%')]); + }); + + test('is left off when the backend would not charge the deposit', () => { + mockQuote = { data: { applies: false, ratePpm: 0 }, isError: false }; + expect(rainCardNotice()).toEqual([]); + }); + + test('is left off until the backend answers', () => { + mockQuote = { data: undefined, isError: false }; + expect(rainCardNotice()).toEqual([]); + }); + + test('falls back to the default when the backend cannot be asked', () => { + mockQuote = { data: undefined, isError: true }; + expect(rainCardNotice()).toEqual([FEE_LINE]); + }); +}); + +describe('the quote', () => { + test('is asked of the card address for the card and wallet flows', () => { + rainCardNotice(); + render( + , + ); + + expect(mockUseDepositFeeQuote.mock.calls.map(([params]) => params)).toEqual([ + { + destinationType: 'RAIN_CARD', + chainId: mainnet.id, + symbol: 'USDC', + provider: CardProvider.RAIN, + enabled: true, + }, + { + destinationType: 'RAIN_CARD', + chainId: base.id, + symbol: 'USDT', + provider: CardProvider.WIREX, + enabled: true, + }, + ]); + }); + + test('is asked of the savings address for a savings deposit', () => { + render( + , + ); + + expect(mockUseDepositFeeQuote).toHaveBeenCalledWith( + expect.objectContaining({ destinationType: 'PROTOCOL', symbol: 'USDC', enabled: true }), + ); + }); + + test('is not asked for a deposit that is free by rule', () => { + render( + , + ); + + expect(mockUseDepositFeeQuote).toHaveBeenCalledWith( + expect.objectContaining({ enabled: false }), + ); + }); +}); + +function rainCardNotice() { + return render( + , + ); +} diff --git a/components/Home/NewHome/HomeCashbackCtaBanner.tsx b/components/Home/NewHome/HomeCashbackCtaBanner.tsx index 3cf581d4c..7c5c875d0 100644 --- a/components/Home/NewHome/HomeCashbackCtaBanner.tsx +++ b/components/Home/NewHome/HomeCashbackCtaBanner.tsx @@ -1,8 +1,13 @@ -import { StyleSheet, View } from 'react-native'; +import { Pressable, StyleSheet, View } from 'react-native'; +import { router } from 'expo-router'; import { HOME_BANNER_RADIUS } from '@/components/Home/NewHome/homeBannerStyle'; +import CashbackDetailsSheet from '@/components/Rewards/NewRewards/CashbackDetailsSheet'; import { Text } from '@/components/ui/text'; +import { path } from '@/constants/path'; +import { useCardDetails } from '@/hooks/useCardDetails'; import { useRewardsUserData } from '@/hooks/useRewards'; +import { monthlyCashbackTotal } from '@/lib/cashbackProgress'; import { resolveUserCashbackRate, TIER_CASHBACK_RATES } from '@/lib/tierCashback'; import { RewardsTier } from '@/lib/types'; import { formatBalanceUSD } from '@/lib/utils'; @@ -23,23 +28,42 @@ import { formatBalanceUSD } from '@/lib/utils'; */ const HomeCashbackCtaBanner = ({ className }: { className?: string }) => { const { data: rewardsData } = useRewardsUserData(); + const { data: cardDetails } = useCardDetails(); const rate = resolveUserCashbackRate(rewardsData) || TIER_CASHBACK_RATES[RewardsTier.CORE]; - const earnedThisMonth = rewardsData?.cashbackThisMonth ?? 0; + const cashbackThisMonth = rewardsData?.cashbackThisMonth ?? 0; + const allTimeCashback = Math.max(cardDetails?.cashback?.totalUsdValue ?? 0, cashbackThisMonth); + const earnedThisMonth = monthlyCashbackTotal( + rewardsData?.cashbackThisMonth, + rewardsData?.cashbackPendingThisMonth, + ); return ( - - - - Earning {rate}% - {'\n'}cashback - - - Earned this month - {formatBalanceUSD(earnedThisMonth)} - - - + router.push(path.REWARDS_BENEFITS)} + trigger={ + + + Earning {rate}% + {'\n'}cashback + + + Earned this month + {formatBalanceUSD(earnedThisMonth)} + + + } + /> ); }; diff --git a/components/Home/NewHome/HomeScreenNew.tsx b/components/Home/NewHome/HomeScreenNew.tsx index 9d56463e5..2ccbabdd9 100644 --- a/components/Home/NewHome/HomeScreenNew.tsx +++ b/components/Home/NewHome/HomeScreenNew.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useRef } from 'react'; -import { TouchableOpacity, View } from 'react-native'; +import { Platform, TouchableOpacity, View } from 'react-native'; import { router, useLocalSearchParams } from 'expo-router'; import { useQueryClient } from '@tanstack/react-query'; import { Address } from 'viem'; @@ -28,6 +28,7 @@ import LazyWalletTabs from '@/components/Wallet/LazyWalletTabs'; import TokenListSkeleton from '@/components/Wallet/WalletTokenTab/TokenListSkeleton'; import { resolveDigitalWallet } from '@/constants/digital-wallet'; import { CARD_INFO_SCREEN, CARD_INFO_WALLET_PARAM } from '@/constants/path'; +import { useActivityRefresh } from '@/hooks/useActivityRefresh'; import { useUserTransactions } from '@/hooks/useAnalytics'; import { useCardDetails } from '@/hooks/useCardDetails'; import { useCardProvider } from '@/hooks/useCardProvider'; @@ -43,6 +44,7 @@ import { formatBalanceUSD, hasCard } from '@/lib/utils'; import { cardHoldsBalance } from '@/lib/utils/cardHelpers'; import { useCardPaneStore } from '@/store/useCardPaneStore'; import { useUserStore } from '@/store/useUserStore'; +import { useWhatsNewStore } from '@/store/useWhatsNewStore'; /** * Redesigned home/wallet screen (Apple "glass" style), shown only on qa/preview @@ -62,12 +64,14 @@ export default function HomeScreenNew() { const { user } = useUser(); const queryClient = useQueryClient(); + const { refetchAll, isRefreshing } = useActivityRefresh(); const { data: balance, isLoading: isBalanceLoading, isError: isBalanceError, } = useVaultBalance(user?.safeAddress as Address); const updateUser = useUserStore(state => state.updateUser); + const setHomeReady = useWhatsNewStore(state => state.setHomeReady); const intercom = useIntercom(); const { data: cardStatus, isLoading: isCardStatusLoading } = useCardStatus(); const { data: cardDetails, isLoading: isCardDetailsLoading } = useCardDetails(); @@ -95,6 +99,7 @@ export default function HomeScreenNew() { wallet?: string; }>(); const openCardPane = useCardPaneStore(state => state.open); + const isCardPaneOpen = useCardPaneStore(state => state.isOpen); const openCardPaneWalletGuide = useCardPaneStore(state => state.openWalletGuide); useEffect(() => { @@ -194,6 +199,20 @@ export default function HomeScreenNew() { isTotalSavingsLoading || isCardBalanceLoading || totalSavingsUSD === undefined; + + useEffect(() => { + if (isBalanceSectionLoading) { + setHomeReady(false); + return; + } + + // Let the loaded balance and card commit a frame before starting the delay. + const frame = requestAnimationFrame(() => setHomeReady(true)); + return () => { + cancelAnimationFrame(frame); + setHomeReady(false); + }; + }, [isBalanceSectionLoading, setHomeReady]); const walletBalance = totalUSDExcludingVaultTokens; const savingsBalance = totalSavingsUSD ?? 0; // Headline = everything the user holds. Combined for display only; the breakdown @@ -241,6 +260,9 @@ export default function HomeScreenNew() { mobileTitle={walletTitle} animateCardHeroExit additionalContent={} + scrollEnabled={!isCardPaneOpen} + onRefresh={Platform.OS !== 'web' ? refetchAll : undefined} + refreshing={isRefreshing} > {isBalanceSectionLoading ? ( diff --git a/components/Home/NewHome/WalletActions.tsx b/components/Home/NewHome/WalletActions.tsx index 99bbe7790..48edb79dc 100644 --- a/components/Home/NewHome/WalletActions.tsx +++ b/components/Home/NewHome/WalletActions.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { Platform, Pressable, StyleSheet, useWindowDimensions, View } from 'react-native'; +import { Pressable, StyleSheet, useWindowDimensions, View } from 'react-native'; import HomeSend from '@/assets/images/home-send'; import HomeSwap from '@/assets/images/home-swap'; @@ -97,8 +97,7 @@ interface WalletActionsProps { /** * Home action row. No funds → full-width white "Add Funds". Funded → "Add Funds" - * plus "Swap" and "Send". Reuses the global Deposit/Swap/Send modals. Note - * SwapModal renders null on iOS, so Swap self-hides there (same as the legacy row). + * plus "Swap" and "Send". Reuses the global Deposit/Swap/Send modals. * * "Add Funds" has three destinations, and none of them is a destination picker: * @@ -117,7 +116,7 @@ const WalletActions = ({ hasFunds, hasCard }: WalletActionsProps) => { const fundsGoToCard = Boolean(hasCard) && canDepositToCard(provider); // Only the crowded three-pill row needs to shrink; alone, "Add Funds" always fits. const compact = hasFunds && width > 0 && width < COMPACT_WIDTH; - const showSwap = hasFunds && Platform.OS !== 'ios'; + const showSwap = hasFunds; const addFundsTrigger = ; const [isCardModalOpen, setIsCardModalOpen] = useState(false); diff --git a/components/PageLayout.tsx b/components/PageLayout.tsx index 307bc7087..7cd692670 100644 --- a/components/PageLayout.tsx +++ b/components/PageLayout.tsx @@ -9,6 +9,7 @@ import { import { Edge, SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import { BlurTargetView } from 'expo-blur'; import { usePathname } from 'expo-router'; +import { useScrollToTop } from '@react-navigation/native'; import { useDimension } from '@/hooks/useDimension'; @@ -16,6 +17,7 @@ import Loading from './Loading'; import Navbar from './Navbar'; import NavbarMobile, { MOBILE_NAVBAR_CONTENT_HEIGHT } from './Navbar/NavbarMobile'; import { SIDEBAR_BODY_TOP_GUTTER, SIDEBAR_BODY_WIDTH, useIsSidebarShell } from './Navbar/Sidebar'; +import PullToRefreshScrollView from './PullToRefreshScrollView'; import { useRegisterTabBarBlurTarget } from './tabBar/TabBarBlurContext'; const MOBILE_NAVBAR_DIVIDER_OFFSET = 1; @@ -82,6 +84,8 @@ interface PageLayoutProps { scrollEnabled?: boolean; showsVerticalScrollIndicator?: boolean; sidebarTopGutter?: number; + onRefresh?: () => void; + refreshing?: boolean; edges?: readonly Edge[]; // SafeAreaView edges // Sticky header (sticks to top when scrolling) @@ -175,6 +179,8 @@ export default function PageLayout({ scrollEnabled = true, showsVerticalScrollIndicator = true, sidebarTopGutter = SIDEBAR_BODY_TOP_GUTTER, + onRefresh, + refreshing = false, edges = ['right', 'left', 'bottom', 'top'], stickyHeader, additionalContent, @@ -186,6 +192,8 @@ export default function PageLayout({ const isSidebarShell = useIsSidebarShell(); const pathname = usePathname(); const insets = useSafeAreaInsets(); + const scrollViewRef = useRef(null); + useScrollToTop(scrollViewRef); const mobileBlurTargetRef = useRef(null); const [mobileNavbarOffset, setMobileNavbarOffset] = useState(0); const [isMobileNavbarScrolled, setIsMobileNavbarScrolled] = useState(false); @@ -307,11 +315,15 @@ export default function PageLayout({ // Build the main content if (scrollable) { const scrollView = ( - )} {renderBody(children)} - + ); return ( diff --git a/components/PullToRefreshScrollView.tsx b/components/PullToRefreshScrollView.tsx new file mode 100644 index 000000000..0256e17f1 --- /dev/null +++ b/components/PullToRefreshScrollView.tsx @@ -0,0 +1,295 @@ +import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef } from 'react'; +import { + Platform, + RefreshControl, + ScrollView, + type ScrollViewProps, + StyleSheet, + View, +} from 'react-native'; +import { Gesture, GestureDetector } from 'react-native-gesture-handler'; +import Animated, { + cancelAnimation, + Easing, + useAnimatedProps, + useAnimatedReaction, + useAnimatedRef, + useAnimatedStyle, + useScrollOffset, + useSharedValue, + withRepeat, + withTiming, +} from 'react-native-reanimated'; +import Svg, { Circle } from 'react-native-svg'; +import { scheduleOnRN } from 'react-native-worklets'; + +export const REFRESH_SPINNER_COLOR = '#8E8E93'; +const REFRESH_DISTANCE = 64; +const MAX_PULL_DISTANCE = 120; +const PULL_RESISTANCE = 0.45; +const TOUCH_SLOP = 8; +const SPINNER_SIZE = 20; +const SPINNER_CONTAINER_SIZE = 36; +const SPINNER_RADIUS = 8; +const SPINNER_CIRCUMFERENCE = 2 * Math.PI * SPINNER_RADIUS; +const AnimatedCircle = Animated.createAnimatedComponent(Circle); + +type Props = ScrollViewProps & { + onRefresh?: () => void | Promise; + refreshing?: boolean; + refreshIndicatorOffset?: number; +}; + +/** Android's standard refresh control leaves the content stationary. Move the + * scroll view with the finger instead; iOS keeps its native bounce and spinner. */ +const AndroidRefreshScrollView = forwardRef(function AndroidRefreshScrollView( + { + onRefresh, + refreshing = false, + refreshIndicatorOffset = 0, + refreshControl: _refreshControl, + style, + scrollEnabled = true, + ...props + }, + ref, +) { + const scrollRef = useAnimatedRef(); + const scrollOffset = useScrollOffset(scrollRef); + const pullDistance = useSharedValue(0); + const startX = useSharedValue(0); + const startY = useSharedValue(0); + const isDragging = useSharedValue(false); + const refreshInFlight = useSharedValue(refreshing); + const spinRotation = useSharedValue(0); + const awaitingRefresh = useRef(false); + const refreshingRef = useRef(refreshing); + refreshingRef.current = refreshing; + + // FlashList and scroll-to-top callers need the actual scroll view's methods. + useImperativeHandle(ref, () => scrollRef.current!, [scrollRef]); + + useEffect(() => { + refreshInFlight.value = refreshing || awaitingRefresh.current; + if (refreshing) { + pullDistance.value = withTiming(REFRESH_DISTANCE, { duration: 180 }); + } else if (!awaitingRefresh.current) { + pullDistance.value = withTiming(0, { duration: 220 }); + } + }, [pullDistance, refreshInFlight, refreshing]); + + const requestRefresh = useCallback(async () => { + if (!onRefresh || awaitingRefresh.current || refreshingRef.current) return; + awaitingRefresh.current = true; + try { + await onRefresh(); + } finally { + awaitingRefresh.current = false; + refreshInFlight.value = refreshingRef.current; + if (!refreshingRef.current) { + pullDistance.value = withTiming(0, { duration: 220 }); + } + } + }, [onRefresh, pullDistance, refreshInFlight]); + + const nativeScrollGesture = Gesture.Native().enabled(scrollEnabled); + const pullGesture = Gesture.Pan() + .enabled(scrollEnabled) + .maxPointers(1) + .manualActivation(true) + .simultaneousWithExternalGesture(nativeScrollGesture) + .onTouchesDown((event, manager) => { + if (refreshInFlight.value || scrollOffset.value > 0 || event.numberOfTouches !== 1) { + manager.fail(); + return; + } + const touch = event.allTouches[0]; + startX.value = touch.absoluteX; + startY.value = touch.absoluteY; + }) + .onTouchesMove((event, manager) => { + if (isDragging.value) return; + if (refreshInFlight.value || scrollOffset.value > 0 || event.numberOfTouches !== 1) { + manager.fail(); + return; + } + const touch = event.allTouches[0]; + const dx = touch.absoluteX - startX.value; + const dy = touch.absoluteY - startY.value; + // Give upward scrolling and horizontal card/filter swipes to their owners. + if (dy < -TOUCH_SLOP || (Math.abs(dx) > TOUCH_SLOP && Math.abs(dx) > Math.abs(dy))) { + manager.fail(); + } else if (dy > TOUCH_SLOP) { + manager.activate(); + } + }) + .onStart(() => { + cancelAnimation(pullDistance); + isDragging.value = true; + }) + .onUpdate(event => { + // Absolute coordinates stay stable while the view itself moves. + pullDistance.value = Math.min( + MAX_PULL_DISTANCE, + Math.max(0, (event.absoluteY - startY.value) * PULL_RESISTANCE), + ); + }) + .onEnd((_event, success) => { + if (success && pullDistance.value >= REFRESH_DISTANCE && !refreshInFlight.value) { + refreshInFlight.value = true; + pullDistance.value = withTiming(REFRESH_DISTANCE, { duration: 180 }); + scheduleOnRN(requestRefresh); + } + }) + .onFinalize(() => { + isDragging.value = false; + if (!refreshInFlight.value) { + pullDistance.value = withTiming(0, { duration: 220 }); + } + }); + + const contentStyle = useAnimatedStyle(() => ({ + transform: [{ translateY: pullDistance.value }], + })); + const spinnerStyle = useAnimatedStyle(() => ({ + opacity: Math.min(1, pullDistance.value / 24), + transform: [{ translateY: Math.max(0, (pullDistance.value - SPINNER_CONTAINER_SIZE) / 2) }], + })); + + // Pulling fills and turns the arc with the finger. Only a committed refresh + // runs a repeating animation; holding a short pull leaves the arc stationary. + useAnimatedReaction( + () => refreshInFlight.value, + (inFlight, previous) => { + if (inFlight === previous) return; + cancelAnimation(spinRotation); + spinRotation.value = 0; + if (inFlight) { + spinRotation.value = withRepeat( + withTiming(360, { duration: 900, easing: Easing.linear }), + -1, + false, + ); + } + }, + ); + const progressProps = useAnimatedProps(() => { + const progress = refreshInFlight.value ? 1 : Math.min(1, pullDistance.value / REFRESH_DISTANCE); + return { strokeDashoffset: SPINNER_CIRCUMFERENCE * (1 - progress * 0.75) }; + }); + const rotationStyle = useAnimatedStyle(() => { + const progress = refreshInFlight.value ? 1 : Math.min(1, pullDistance.value / REFRESH_DISTANCE); + return { transform: [{ rotate: `${progress * 180 + spinRotation.value}deg` }] }; + }); + + return ( + + + + + + + + + + + + + + + + + + + ); +}); + +const PullToRefreshScrollView = forwardRef(function PullToRefreshScrollView( + { onRefresh, refreshing, refreshIndicatorOffset, refreshControl, ...props }, + ref, +) { + // FlashList passes refresh callbacks through its RefreshControl element. + const refresh = onRefresh ?? refreshControl?.props.onRefresh; + const isRefreshing = refreshing ?? refreshControl?.props.refreshing ?? false; + const indicatorOffset = refreshIndicatorOffset ?? refreshControl?.props.progressViewOffset ?? 0; + + if (Platform.OS === 'android' && refresh) { + return ( + + ); + } + + return ( + + ) : ( + refreshControl + ) + } + /> + ); +}); + +export default PullToRefreshScrollView; + +const styles = StyleSheet.create({ + container: { flex: 1, overflow: 'hidden' }, + scrollView: { flex: 1 }, + spinner: { position: 'absolute', left: 0, right: 0, alignItems: 'center' }, + spinnerCircle: { + width: SPINNER_CONTAINER_SIZE, + height: SPINNER_CONTAINER_SIZE, + borderRadius: SPINNER_CONTAINER_SIZE / 2, + backgroundColor: '#242424', + borderColor: '#3A3A3A', + borderWidth: 1, + alignItems: 'center', + justifyContent: 'center', + }, +}); diff --git a/components/Rewards/EarnPointsSection.tsx b/components/Rewards/EarnPointsSection.tsx index 8907e0ba4..e18489dc1 100644 --- a/components/Rewards/EarnPointsSection.tsx +++ b/components/Rewards/EarnPointsSection.tsx @@ -1,4 +1,4 @@ -import { ActivityIndicator, Platform, View } from 'react-native'; +import { ActivityIndicator, View } from 'react-native'; import { Text } from '@/components/ui/text'; import { useDimension } from '@/hooks/useDimension'; @@ -111,10 +111,7 @@ const EarnPointsSection = () => { ]; }; - // Swap is not available on iOS, so exclude it from the earning methods there. - const earningMethods = getEarningMethods().filter( - method => Platform.OS !== 'ios' || method.title !== 'Swap', - ); + const earningMethods = getEarningMethods(); return ( diff --git a/components/Rewards/NewRewards/CashbackDetailsContent.tsx b/components/Rewards/NewRewards/CashbackDetailsContent.tsx index 0e0ff1bb3..4af1b447f 100644 --- a/components/Rewards/NewRewards/CashbackDetailsContent.tsx +++ b/components/Rewards/NewRewards/CashbackDetailsContent.tsx @@ -76,6 +76,9 @@ const CashbackDetailsContent = ({ {formatBalanceUSD(monthlyCashbackTotal(cashbackThisMonth, cashbackPendingThisMonth))} + + Total includes pending cashback. + diff --git a/components/Rewards/NewRewards/JoinTierClubCard.tsx b/components/Rewards/NewRewards/JoinTierClubCard.tsx index c82ca9cdc..89ffeb7b6 100644 --- a/components/Rewards/NewRewards/JoinTierClubCard.tsx +++ b/components/Rewards/NewRewards/JoinTierClubCard.tsx @@ -3,20 +3,24 @@ import { LinearGradient } from 'expo-linear-gradient'; import { ChevronRight } from 'lucide-react-native'; import { Text } from '@/components/ui/text'; +import { useTierBenefits } from '@/hooks/useRewards'; import { formatTierCashbackRate } from '@/lib/tierCashback'; import { getTierDisplayName } from '@/lib/tierNames'; +import { formatTierYieldBoost, resolveTierYieldBoostRate } from '@/lib/tierYieldBoost'; import { RewardsTier } from '@/lib/types'; import SubscriptionBrandBadge from './SubscriptionBrandBadge'; import { SUBSCRIPTION_CATEGORIES } from './subscriptionBrands'; import { BOTTOM_RIGHT_WASH } from './tierGradients'; -const TIER_PROMISE: Record< - RewardsTier.PRIME | RewardsTier.ULTRA, - { yieldBoost: string; aiCashback: string } -> = { - [RewardsTier.PRIME]: { yieldBoost: '+2%', aiCashback: '25%' }, - [RewardsTier.ULTRA]: { yieldBoost: '+3%', aiCashback: '50%' }, +/** + * The teaser's AI cashback figure per tier. The yield boost is deliberately not here: + * it comes from the tier-benefits endpoint at render, so this card cannot advertise a + * boost the rewards screen and the tier detail screen are not also quoting. + */ +const TIER_PROMISE: Record = { + [RewardsTier.PRIME]: { aiCashback: '25%' }, + [RewardsTier.ULTRA]: { aiCashback: '50%' }, }; const AI_BRANDS = SUBSCRIPTION_CATEGORIES.find(category => category.key === 'ai')!.brands; @@ -30,6 +34,8 @@ interface JoinTierClubCardProps { const JoinTierClubCard = ({ tier, onPress }: JoinTierClubCardProps) => { const tierName = getTierDisplayName(tier); const promise = TIER_PROMISE[tier]; + const { data: tierBenefits } = useTierBenefits(); + const yieldBoost = formatTierYieldBoost(resolveTierYieldBoostRate(tier, tierBenefits)); return ( { - {promise.yieldBoost} Yield boost + {yieldBoost} Yield boost diff --git a/components/Rewards/NewRewards/RewardsSummaryCard.tsx b/components/Rewards/NewRewards/RewardsSummaryCard.tsx index 1488efcf1..9cdc6974b 100644 --- a/components/Rewards/NewRewards/RewardsSummaryCard.tsx +++ b/components/Rewards/NewRewards/RewardsSummaryCard.tsx @@ -114,13 +114,14 @@ const SummaryStat = ({ label, value, onPress }: SummaryStatProps) => ( > {label} {formatBalanceUSD(value)} @@ -141,7 +142,7 @@ const RewardsSummaryCard = ({ onReferralsPress, }: RewardsSummaryCardProps) => { return ( - + @@ -154,7 +155,7 @@ const RewardsSummaryCard = ({ - + } {...cashbackDetails} diff --git a/components/Rewards/NewRewards/tierBenefitsPresentation.ts b/components/Rewards/NewRewards/tierBenefitsPresentation.ts index 92935526e..ca913712b 100644 --- a/components/Rewards/NewRewards/tierBenefitsPresentation.ts +++ b/components/Rewards/NewRewards/tierBenefitsPresentation.ts @@ -1,4 +1,5 @@ import { formatTierCashbackRate } from '@/lib/tierCashback'; +import { TIER_YIELD_BOOST_RATES } from '@/lib/tierYieldBoost'; import { RewardsTier, type TierBenefits, type TierFees, type TierOffer } from '@/lib/types'; export const TIER_LABELS: Record = { @@ -92,13 +93,11 @@ export function tierPresentationContent( : tier === RewardsTier.ULTRA ? 20 : 0; + // Through the shared table rather than a second set of literals: this screen and the + // rewards tab's teaser card each used to keep their own, and they drifted. const yieldPercentage = isBenefitNumber(live?.yieldBoostPercentage) ? live.yieldBoostPercentage - : tier === RewardsTier.PRIME - ? 2 - : tier === RewardsTier.ULTRA - ? 3 - : 0; + : TIER_YIELD_BOOST_RATES[tier]; const balanceCap = isBenefitNumber(live?.yieldBoostBalanceCap) ? live.yieldBoostBalanceCap : tier === RewardsTier.PRIME diff --git a/components/Rewards/TierFeesTable.tsx b/components/Rewards/TierFeesTable.tsx index 0dd378706..d4f162b26 100644 --- a/components/Rewards/TierFeesTable.tsx +++ b/components/Rewards/TierFeesTable.tsx @@ -1,5 +1,3 @@ -import { Platform } from 'react-native'; - import { resolveTierFees } from '@/components/Rewards/NewRewards/tierFees'; import { RewardsTier, TierBenefit, TierBenefits } from '@/lib/types'; @@ -13,8 +11,7 @@ interface TierFeesTableProps { const FEE_ROWS: { key: string; label: string; subtitle?: string }[] = [ { key: 'virtual_card', label: 'Virtual card', subtitle: 'Issued instantly' }, { key: 'bank_deposit', label: 'Bank deposit' }, - // Swap is not available on iOS, so the swap row is omitted there. - ...(Platform.OS === 'ios' ? [] : [{ key: 'swap', label: 'Swaps' }]), + { key: 'swap', label: 'Swaps' }, { key: 'stocks', label: 'Stocks' }, { key: 'fx', label: 'FX conversion' }, { key: 'offramp', label: 'Bank withdrawal' }, diff --git a/components/Savings/NewSavings/VaultBalanceCard.tsx b/components/Savings/NewSavings/VaultBalanceCard.tsx index 8c406ab3e..32720499f 100644 --- a/components/Savings/NewSavings/VaultBalanceCard.tsx +++ b/components/Savings/NewSavings/VaultBalanceCard.tsx @@ -1,4 +1,4 @@ -import { View } from 'react-native'; +import { Platform, View } from 'react-native'; import { Address } from 'viem'; import { fuse, mainnet } from 'viem/chains'; @@ -16,21 +16,18 @@ import { useVaultBalance } from '@/hooks/useVault'; import { useVaultExchangeRate } from '@/hooks/useVaultExchangeRate'; import { ADDRESSES } from '@/lib/config'; import { SavingMode, VaultType } from '@/lib/types'; -import { formatNumber } from '@/lib/utils'; -export const formatCompactVaultUsd = (value: number) => { - const safeValue = Math.max(Number(value) || 0, 0); - const compact = new Intl.NumberFormat('en-US', { - notation: 'compact', - maximumFractionDigits: 2, - }) - .format(safeValue) - .toUpperCase(); - return `${compact} USD`; -}; +import { formatCompactVaultUsd, formatExactVaultUsd } from './vaultBalanceFormat'; -export const formatExactVaultUsd = (value: number) => - `$${formatNumber(Math.max(Number(value) || 0, 0), 1, 1)}`; +/** + * Native keeps the value on one line and shrinks it to fit the half-width + * column; web has no `adjustsFontSizeToFit`, so there it may wrap and the card + * grows instead. + */ +const singleLineFit = + Platform.OS === 'web' + ? {} + : ({ numberOfLines: 1, adjustsFontSizeToFit: true, minimumFontScale: 0.6 } as const); const VaultStat = ({ label, @@ -58,10 +55,16 @@ const VaultStat = ({ ) : ( <> - + {/* Native clips glyphs to the line box (iOS cuts the top, Android the + bottom) and Mona Sans needs ~1.15em to fit, so both lines get that + and the margins give the difference back. */} + {formatCompactVaultUsd(value)} - + {formatExactVaultUsd(value)} @@ -132,7 +135,7 @@ const VaultBalanceCard = ({ vaultType, balanceUsd }: VaultBalanceCardProps) => { const rewardsUsd = Math.max(rewardsInVaultCurrency * priceUsd, 0); return ( - + ({ + formatNumber: (value: number, max: number, min: number) => + new Intl.NumberFormat('en-us', { + maximumFractionDigits: max, + minimumFractionDigits: value >= 1 ? min : 0, + }).format(value), +})); + +describe('formatCompactVaultUsd', () => { + it('keeps values under a thousand as they are, to two decimals', () => { + expect(formatCompactVaultUsd(0)).toBe('0 USD'); + expect(formatCompactVaultUsd(1.764)).toBe('1.76 USD'); + expect(formatCompactVaultUsd(363.86)).toBe('363.86 USD'); + expect(formatCompactVaultUsd(12.5)).toBe('12.5 USD'); + }); + + it('compacts thousands and up without relying on Intl compact notation', () => { + expect(formatCompactVaultUsd(6759.16)).toBe('6.76K USD'); + expect(formatCompactVaultUsd(1000)).toBe('1K USD'); + expect(formatCompactVaultUsd(2_500_000)).toBe('2.5M USD'); + expect(formatCompactVaultUsd(3_210_000_000)).toBe('3.21B USD'); + expect(formatCompactVaultUsd(4e12)).toBe('4T USD'); + }); + + it('promotes a value that rounds up into the next unit', () => { + expect(formatCompactVaultUsd(999.999)).toBe('1K USD'); + expect(formatCompactVaultUsd(999_999)).toBe('1M USD'); + }); + + it('treats negative and non-numeric input as zero', () => { + expect(formatCompactVaultUsd(-5)).toBe('0 USD'); + expect(formatCompactVaultUsd(Number.NaN)).toBe('0 USD'); + }); +}); + +describe('formatExactVaultUsd', () => { + it('shows one decimal', () => { + expect(formatExactVaultUsd(363.86)).toBe('$363.9'); + expect(formatExactVaultUsd(-1)).toBe('$0'); + }); +}); diff --git a/components/Savings/NewSavings/vaultBalanceFormat.ts b/components/Savings/NewSavings/vaultBalanceFormat.ts new file mode 100644 index 000000000..905869dfb --- /dev/null +++ b/components/Savings/NewSavings/vaultBalanceFormat.ts @@ -0,0 +1,37 @@ +import { formatNumber } from '@/lib/utils'; + +/** Ascending, so a value that rounds up into the next unit is promoted. */ +const COMPACT_UNITS = [ + { value: 1, suffix: '' }, + { value: 1e3, suffix: 'K' }, + { value: 1e6, suffix: 'M' }, + { value: 1e9, suffix: 'B' }, + { value: 1e12, suffix: 'T' }, +] as const; + +const roundToHundredths = (value: number) => Math.round(value * 100) / 100; + +/** + * Compact USD figure for the vault balance card, e.g. `6.76K USD`. + * + * Built by hand rather than with `Intl.NumberFormat({ notation: 'compact' })`: + * Hermes on iOS ignores `notation`, so the card showed the full `6,759.16 USD` + * there, which wrapped and was clipped by the fixed-height card. + */ +export const formatCompactVaultUsd = (value: number) => { + const safeValue = Math.max(Number(value) || 0, 0); + + let unit: (typeof COMPACT_UNITS)[number] = COMPACT_UNITS[0]; + let scaled = roundToHundredths(safeValue); + for (const next of COMPACT_UNITS.slice(1)) { + // Compare the rounded figure so 999,999 reads 1M rather than 1000K. + if (scaled < 1000) break; + unit = next; + scaled = roundToHundredths(safeValue / next.value); + } + + return `${scaled}${unit.suffix} USD`; +}; + +export const formatExactVaultUsd = (value: number) => + `$${formatNumber(Math.max(Number(value) || 0, 0), 1, 1)}`; diff --git a/components/Savings/SavingsFund/SavingsFundDepositAddress.tsx b/components/Savings/SavingsFund/SavingsFundDepositAddress.tsx index c8794bdcd..843427922 100644 --- a/components/Savings/SavingsFund/SavingsFundDepositAddress.tsx +++ b/components/Savings/SavingsFund/SavingsFundDepositAddress.tsx @@ -170,6 +170,7 @@ const SavingsFundDepositAddress = ({ product="savings" provider={provider} chainId={chainId} + symbol={symbol} vaultToken={vaultToken} /> diff --git a/components/Swap/SwapModal.tsx b/components/Swap/SwapModal.tsx index 1694e48d3..ae238668c 100644 --- a/components/Swap/SwapModal.tsx +++ b/components/Swap/SwapModal.tsx @@ -1,5 +1,4 @@ import React, { ReactNode, useEffect } from 'react'; -import { Platform } from 'react-native'; import { SWAP_MODAL } from '@/constants/modals'; import { useSwapState } from '@/store/swapStore'; @@ -34,10 +33,6 @@ const SwapModal = ({ trigger = null, defaultOpen = false }: SwapModalProps) => { } }, [defaultOpen, setModal]); - if (Platform.OS === 'ios') { - return null; - } - // Headless usage - the global SwapModalProvider handles the modal if (trigger === null) { return null; diff --git a/components/Swap/SwapModalProvider.tsx b/components/Swap/SwapModalProvider.tsx index 168a69d19..247f7df20 100644 --- a/components/Swap/SwapModalProvider.tsx +++ b/components/Swap/SwapModalProvider.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useMemo } from 'react'; -import { Platform, View } from 'react-native'; +import { View } from 'react-native'; import { useRouter } from 'expo-router'; import { useShallow } from 'zustand/react/shallow'; @@ -155,12 +155,6 @@ const SwapModalProvider = () => { handleTransactionStatusPress, ]); - // Regular Swap remains unavailable on iOS. Buy FUSE can open through its - // dedicated entry points and still passes the geo and disclaimer gates. - if (Platform.OS === 'ios' && !isBuyFuse) { - return null; - } - return ( { act(() => root.unmount()); }); -it('keeps the regular Swap modal hidden on iOS', () => { +it('renders the regular Swap modal on iOS', () => { mockCurrentModal = SWAP_MODAL.OPEN_FORM; const root = render(); - expect(root.root.findAllByType('ResponsiveModal')).toHaveLength(0); + expect(root.root.findAllByType('ResponsiveModal')).toHaveLength(1); + expect(root.root.findAllByType('SwapPair')).toHaveLength(1); act(() => root.unmount()); }); diff --git a/components/Unstake/RegularWithdrawForm.tsx b/components/Unstake/RegularWithdrawForm.tsx index 5d5cb3168..de95c8d66 100644 --- a/components/Unstake/RegularWithdrawForm.tsx +++ b/components/Unstake/RegularWithdrawForm.tsx @@ -6,7 +6,7 @@ import { Image } from 'expo-image'; import { zodResolver } from '@hookform/resolvers/zod'; import { Address } from 'abitype'; import { Wallet } from 'lucide-react-native'; -import { formatUnits, zeroAddress } from 'viem'; +import { formatUnits, parseUnits, zeroAddress } from 'viem'; import { useBalance } from 'wagmi'; import { z } from 'zod'; import { useShallow } from 'zustand/react/shallow'; @@ -115,10 +115,10 @@ const RegularWithdrawForm = () => { const { data: formattedBalance, isLoading: isLoadingFuseBalance } = useFuseVaultBalance( user?.safeAddress as Address, ); - const { data: soFuseFormattedBalance, isLoading: isLoadingSoFuseBalance } = useSoFuseVaultBalance( + const { data: soFuseBalanceWei, isLoading: isLoadingSoFuseBalance } = useSoFuseVaultBalance( user?.safeAddress as Address, ); - const { data: soEthFormattedBalance, isLoading: isLoadingSoEthBalance } = useSoEthVaultBalance( + const { data: soEthBalanceWei, isLoading: isLoadingSoEthBalance } = useSoEthVaultBalance( user?.safeAddress as Address, ); @@ -151,44 +151,60 @@ const RegularWithdrawForm = () => { const balance = isNative ? balanceNative?.value : balanceERC20?.value; const isLoading = isNative ? isBalanceNativeLoading : isBalanceERC20Loading; - const balanceAmount = useMemo(() => { + // Max and validation work in wei. A JS number can't hold 18 decimals: a + // soETH balance of 0.361164894291325699 becomes 0.3611648942913257, and + // withdrawing that reverts because it is more than the Safe holds. + const { balanceWei, balanceDecimals } = useMemo(() => { if (isSoFuse) { - return soFuseFormattedBalance ? Number(soFuseFormattedBalance) : 0; + return { balanceWei: soFuseBalanceWei ?? 0n, balanceDecimals: 18 }; } if (isSoEth) { - return soEthFormattedBalance ? Number(soEthFormattedBalance) : 0; + return { balanceWei: soEthBalanceWei ?? 0n, balanceDecimals: 18 }; } if (!selectedToken) { - return formattedBalance ? Number(formattedBalance) : 0; + // soUSD has 6 decimals, which a number holds exactly. + return { + balanceWei: formattedBalance ? parseUnits(formattedBalance.toString(), 6) : 0n, + balanceDecimals: 6, + }; } - if (balance) { - return Number(formatUnits(balance, selectedToken.contractDecimals)); - } - return Number( - formatUnits(BigInt(selectedToken.balance || '0'), selectedToken.contractDecimals), - ); + return { + balanceWei: balance || BigInt(selectedToken.balance || '0'), + balanceDecimals: selectedToken.contractDecimals, + }; }, [ isSoFuse, isSoEth, selectedToken, balance, formattedBalance, - soFuseFormattedBalance, - soEthFormattedBalance, + soFuseBalanceWei, + soEthBalanceWei, ]); + const balanceAmount = Number(formatUnits(balanceWei, balanceDecimals)); + const bridgeSchema = useMemo(() => { + const toWei = (val: string) => { + try { + return parseUnits(val, balanceDecimals); + } catch { + return undefined; + } + }; return z.object({ amount: z .string() - .refine(val => val !== '' && !isNaN(Number(val)), { error: 'Please enter a valid amount' }) - .refine(val => Number(val) > 0, { error: 'Amount must be greater than 0' }) - .refine(val => Number(val) <= balanceAmount, { - error: `Available balance is ${formatNumber(balanceAmount)} ${selectedToken?.contractTickerSymbol || 'soUSD'}`, + .trim() + .refine(val => val !== '' && toWei(val) !== undefined, { + error: 'Please enter a valid amount', }) - .transform(val => Number(val)), + .refine(val => (toWei(val) ?? 0n) > 0n, { error: 'Amount must be greater than 0' }) + .refine(val => (toWei(val) ?? 0n) <= balanceWei, { + error: `Available balance is ${formatNumber(balanceAmount)} ${selectedToken?.contractTickerSymbol || 'soUSD'}`, + }), }); - }, [selectedToken, balanceAmount]); + }, [selectedToken, balanceWei, balanceDecimals, balanceAmount]); type WithdrawFormData = { amount: string }; @@ -262,8 +278,8 @@ const RegularWithdrawForm = () => { }; const handleMaxPress = () => { - if (balanceAmount > 0) { - const maxAmount = balanceAmount.toString(); + if (balanceWei > 0n) { + const maxAmount = formatUnits(balanceWei, balanceDecimals); setValue('amount', maxAmount); trigger('amount'); } diff --git a/components/Wallet/WalletCard.tsx b/components/Wallet/WalletCard.tsx index 9721980a8..f5c1c98e7 100644 --- a/components/Wallet/WalletCard.tsx +++ b/components/Wallet/WalletCard.tsx @@ -1,4 +1,4 @@ -import { Platform, Pressable, View } from 'react-native'; +import { Pressable, View } from 'react-native'; import WalletIcon from '@/assets/images/wallet'; import CountUp from '@/components/CountUp'; @@ -74,7 +74,7 @@ const WalletCard = ({ balance, className, tokens, isLoading, decimalPlaces }: Wa content={ Displaying top three tokens by balance. Wallet can contain any ERC-20 and native - token in Ethereum and Fuse for {Platform.OS === 'ios' ? 'Send' : 'Swap and Send'}. + token in Ethereum and Fuse for Swap and Send. } /> diff --git a/components/tabBar/NewCustomTabBar.tsx b/components/tabBar/NewCustomTabBar.tsx index c04312cf6..c60495d7f 100644 --- a/components/tabBar/NewCustomTabBar.tsx +++ b/components/tabBar/NewCustomTabBar.tsx @@ -22,6 +22,8 @@ import { CommonActions, getFocusedRouteNameFromRoute } from '@react-navigation/n import { Text } from '@/components/ui/text'; import { WHITELIST_TAB_LABELS, WHITELIST_TAB_NAMES } from '@/constants/tabs'; +import { useCardHeroStore } from '@/store/useCardHeroStore'; +import { useCardPaneStore } from '@/store/useCardPaneStore'; import { useTabBarBlurTarget } from './TabBarBlurContext'; @@ -291,6 +293,16 @@ export function NewCustomTabBar({ state, descriptors, navigation }: BottomTabBar canPreventDefault: true, }); + if (route.name === 'index' && !event.defaultPrevented) { + const cardPane = useCardPaneStore.getState(); + if (cardPane.isOpen) { + // Wallet scrolls to the top on this tap, so its card's saved + // position is no longer a destination for the return flight. + useCardHeroStore.getState().end(); + cardPane.close(); + } + } + if (state.index !== originalIndex && !event.defaultPrevented) { navigation.dispatch({ ...CommonActions.navigate(route), diff --git a/components/ui/video-illustration.tsx b/components/ui/video-illustration.tsx index 187f12516..17cc174ef 100644 --- a/components/ui/video-illustration.tsx +++ b/components/ui/video-illustration.tsx @@ -1,6 +1,6 @@ import { useEffect } from 'react'; import { type StyleProp, type ViewStyle } from 'react-native'; -import { useVideoPlayer, type VideoContentFit, VideoView } from 'expo-video'; +import { useVideoPlayer, type VideoContentFit, VideoView, type VideoViewProps } from 'expo-video'; interface VideoIllustrationProps { /** A `require()`d mp4. */ @@ -12,6 +12,7 @@ interface VideoIllustrationProps { loop?: boolean; style?: StyleProp; contentFit?: VideoContentFit; + surfaceType?: VideoViewProps['surfaceType']; /** Fires once the first frame is actually on screen. */ onReady?: () => void; } @@ -37,6 +38,7 @@ const VideoIllustration = ({ loop = false, style, contentFit = 'contain', + surfaceType, onReady, }: VideoIllustrationProps) => { const player = useVideoPlayer(source, p => { @@ -63,6 +65,7 @@ const VideoIllustration = ({ player={player} style={style} contentFit={contentFit} + surfaceType={surfaceType} nativeControls={false} allowsVideoFrameAnalysis={false} onFirstFrameRender={onReady} diff --git a/constants/alchemy.ts b/constants/alchemy.ts index 9383b7edf..b0e367b6c 100644 --- a/constants/alchemy.ts +++ b/constants/alchemy.ts @@ -39,9 +39,34 @@ export const ALCHEMY_NETWORKS: Record = { [bsc.id]: 'bnb-mainnet', }; +/** + * Chains the backend's price route (`POST /accounts/v1/prices`) prices by + * address, its own `ALCHEMY_PRICE_NETWORKS`. The route leaves out a chain it + * doesn't know, which the app would take for "no price", so tokens on a chain + * added to `ALCHEMY_NETWORKS` before the backend has it go to Alchemy directly. + */ +export const BACKEND_PRICE_CHAIN_IDS: ReadonlySet = new Set([ + mainnet.id, + base.id, + polygon.id, + arbitrum.id, + bsc.id, +]); + export const ALCHEMY_PRICES_URL = `https://api.g.alchemy.com/prices/v1/${EXPO_PUBLIC_ALCHEMY_API_KEY}/tokens`; -/** Addresses per `tokens/by-address` request — Alchemy rejects more than this. */ +/** + * Symbols per `tokens/by-symbol` request, and addresses per `tokens/by-address` + * request — Alchemy rejects more than this. A request costs the same 40 CU and + * one unit of the hourly token_price quota however many it carries. + */ export const ALCHEMY_PRICE_BATCH_SIZE = 25; +/** + * Distinct networks per `tokens/by-address` request, per Alchemy's docs. The + * endpoint accepts more today, but a request past a documented limit is one + * enforcement change away from failing whole. + */ +export const ALCHEMY_PRICE_MAX_NETWORKS = 3; + export const ALCHEMY_REQUEST_TIMEOUT_MS = 10_000; diff --git a/hooks/__tests__/useActivity.freshness.test.tsx b/hooks/__tests__/useActivity.freshness.test.tsx new file mode 100644 index 000000000..f084b18b6 --- /dev/null +++ b/hooks/__tests__/useActivity.freshness.test.tsx @@ -0,0 +1,90 @@ +import React from 'react'; + +import { useActivity } from '@/hooks/useActivity'; +import { ActivityEvent, TransactionStatus, TransactionType } from '@/lib/types'; +import { useAccountRefreshStore } from '@/store/useAccountRefreshStore'; +import { useActivityStore } from '@/store/useActivityStore'; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { act, create } = require('react-test-renderer'); + +const mockUser = { userId: 'account', safeAddress: '0xABC' }; +let mockWithdraws: { requestTxHash: string; requestStatus: string }[] | undefined; +jest.mock('@/hooks/useUser', () => ({ __esModule: true, default: () => ({ user: mockUser }) })); +jest.mock('@/lib/config', () => ({ USER: 'user' })); +jest.mock('@/lib/mmvkStorage', () => ({ + __esModule: true, + default: () => ({ getItem: () => null, setItem: jest.fn() }), +})); +jest.mock('@/lib/utils', () => ({ withRefreshToken: (fn: () => unknown) => fn() })); +jest.mock('@/lib/api', () => ({ + fetchActivityEvents: jest.fn(async () => ({ docs: [], hasNextPage: false })), +})); +jest.mock('@/hooks/useAnalytics', () => ({ + useUserTransactions: () => ({ data: { withdraws: mockWithdraws } }), +})); +jest.mock('@/hooks/useSyncActivities', () => ({ + useSyncActivities: () => ({ isSyncing: false, isStale: false, canSync: true }), +})); +jest.mock('@/hooks/useActivityRefresh', () => ({ + useActivityRefresh: () => ({ refetchAll: jest.fn(), isRefreshing: false }), +})); +jest.mock('@/hooks/useActivityActions', () => ({ useActivityActions: () => ({}) })); + +const withdrawal: ActivityEvent = { + clientTxId: 'withdrawal', + hash: '0xwithdrawal', + type: TransactionType.WITHDRAW, + status: TransactionStatus.SUCCESS, + amount: '10', + title: 'Withdraw', + timestamp: '123', + symbol: 'USDC', +}; + +describe('Activity withdrawal status freshness', () => { + let renderer: ReturnType; + const results: ReturnType[] = []; + const Probe = () => { + results.push(useActivity()); + return null; + }; + beforeEach(() => { + useActivityStore.setState({ events: { account: [withdrawal] } }); + useAccountRefreshStore.setState({ refreshingByUser: {}, latestPageByUser: {} }); + mockWithdraws = undefined; + }); + afterEach(() => { + act(() => renderer.unmount()); + }); + + it('completes a processing row when server fulfillment arrives, without changing the wallet store or navigating', async () => { + await act(async () => { + renderer = create(); + }); + expect(results.at(-1)?.activities[0].status).toBe(TransactionStatus.PROCESSING); + mockWithdraws = [{ requestTxHash: '0xwithdrawal', requestStatus: 'SOLVED' }]; + await act(async () => { + renderer.update(); + }); + expect(results.at(-1)?.activities[0].status).toBe(TransactionStatus.SUCCESS); + }); + + it('detects a changed older withdrawal beyond the first three rows with an unchanged row count', async () => { + mockWithdraws = ['first', 'second', 'third', 'withdrawal'].map(id => ({ + requestTxHash: `0x${id}`, + requestStatus: 'REQUESTED', + })); + await act(async () => { + renderer = create(); + }); + expect(results.at(-1)?.activities[0].status).toBe(TransactionStatus.PROCESSING); + mockWithdraws = mockWithdraws.map((row, index) => + index === 3 ? { ...row, requestStatus: 'SOLVED' } : row, + ); + await act(async () => { + renderer.update(); + }); + expect(results.at(-1)?.activities[0].status).toBe(TransactionStatus.SUCCESS); + }); +}); diff --git a/hooks/__tests__/useActivityRefresh.test.tsx b/hooks/__tests__/useActivityRefresh.test.tsx new file mode 100644 index 000000000..4b2aeac41 --- /dev/null +++ b/hooks/__tests__/useActivityRefresh.test.tsx @@ -0,0 +1,201 @@ +import React from 'react'; +import Toast from 'react-native-toast-message'; +import { QueryClient, QueryClientProvider, QueryObserver } from '@tanstack/react-query'; + +import { useActivityRefresh } from '@/hooks/useActivityRefresh'; +import { useSyncActivities, useSyncStore } from '@/hooks/useSyncActivities'; +import { fetchActivityEvents, syncActivities } from '@/lib/api'; +import { + ActivityEvent, + ActivityEvents, + TransactionStatus, + TransactionType, + User, +} from '@/lib/types'; +import { useAccountRefreshStore } from '@/store/useAccountRefreshStore'; +import { useActivityStore } from '@/store/useActivityStore'; +import { useUserStore } from '@/store/useUserStore'; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { act, create } = require('react-test-renderer'); + +const mockUser = { userId: 'account', safeAddress: '0xABC', selected: true }; +jest.mock('@/hooks/useUser', () => ({ __esModule: true, default: () => ({ user: mockUser }) })); +jest.mock('@/lib/config', () => ({ USER: 'user' })); +jest.mock('@/lib/mmvkStorage', () => ({ + __esModule: true, + default: () => ({ getItem: () => null, setItem: jest.fn() }), +})); +jest.mock('@/lib/utils', () => ({ withRefreshToken: (fn: () => unknown) => fn() })); +jest.mock('@/lib/api', () => ({ + syncActivities: jest.fn(), + fetchActivityEvents: jest.fn(), + fetchActivityEvent: jest.fn(), +})); +jest.mock('react-native-toast-message', () => ({ __esModule: true, default: { show: jest.fn() } })); + +const deferred = () => { + let resolve!: (value: T) => void; + let reject!: (error: Error) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +}; +const event: ActivityEvent = { + clientTxId: 'deposit', + type: TransactionType.DEPOSIT, + status: TransactionStatus.PENDING, + title: 'Deposit', + timestamp: '123', + amount: '10', + symbol: 'USDC', +}; +const page = { + docs: [{ ...event, status: TransactionStatus.SUCCESS }], + hasNextPage: false, +} as ActivityEvents; +const syncResult = { synced: 1, skipped: 0, errors: 0, message: 'Synced' }; + +describe('manual account refresh', () => { + let client: QueryClient; + let renderer: ReturnType; + const results: ReturnType[] = []; + const background: ReturnType[] = []; + const unsubscribers: (() => void)[] = []; + const Probe = ({ slot }: { slot: number }) => { + const refresh = useActivityRefresh(); + const sync = useSyncActivities({ syncOnMount: false, syncOnAppActive: false }); + React.useEffect(() => { + results[slot] = refresh; + background[slot] = sync; + }); + return null; + }; + const mount = async () => { + await act(async () => { + renderer = create( + + + + , + ); + }); + }; + const observe = (key: string[], queryFn: () => Promise) => { + const observer = new QueryObserver(client, { + queryKey: key, + queryFn, + initialData: 'old', + staleTime: Infinity, + }); + unsubscribers.push(observer.subscribe(() => {})); + }; + beforeEach(() => { + jest.clearAllMocks(); + mockUser.selected = true; + useUserStore.setState({ users: [mockUser as User] }); + useSyncStore.setState({ lastSyncByUser: {}, isSyncingLock: false, syncLockTimestamp: null }); + useAccountRefreshStore.setState({ refreshingByUser: {}, latestPageByUser: {} }); + useActivityStore.setState({ events: { account: [event] } }); + client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: Infinity } } }); + (syncActivities as jest.Mock).mockResolvedValue(syncResult); + (fetchActivityEvents as jest.Mock).mockResolvedValue(page); + }); + afterEach(() => { + if (renderer) act(() => renderer.unmount()); + unsubscribers.splice(0).forEach(unsubscribe => unsubscribe()); + client.clear(); + }); + + it('joins a running backend sync, deduplicates pulls across screens and waits for card history', async () => { + const sync = deferred(); + const card = deferred(); + (syncActivities as jest.Mock).mockReturnValue(sync.promise); + const fetchCard = jest.fn(() => card.promise); + observe(['cardTransactions'], fetchCard); + await mount(); + let backgroundPromise!: ReturnType<(typeof background)[0]['sync']>; + let refreshPromise!: Promise; + await act(async () => { + backgroundPromise = background[0].sync(undefined, true); + refreshPromise = results[0].refetchAll(); + expect(results[1].refetchAll()).toBe(refreshPromise); + }); + expect(syncActivities).toHaveBeenCalledTimes(1); + expect(fetchActivityEvents).not.toHaveBeenCalled(); + expect(results[0].isRefreshing).toBe(true); + await act(async () => { + sync.resolve(syncResult); + await backgroundPromise; + }); + expect(fetchActivityEvents).toHaveBeenCalledTimes(1); + expect(fetchCard).toHaveBeenCalledTimes(1); + expect(useActivityStore.getState().events.account[0].status).toBe(TransactionStatus.SUCCESS); + expect(results[1].isRefreshing).toBe(true); + await act(async () => { + card.resolve('posted'); + await refreshPromise; + }); + expect(client.getQueryData(['cardTransactions'])).toBe('posted'); + expect(results[0].isRefreshing).toBe(false); + expect(Toast.show).not.toHaveBeenCalled(); + }); + + it('forces a new server sync even within the automatic cooldown', async () => { + useSyncStore.setState({ lastSyncByUser: { account: Date.now() } }); + await mount(); + await act(async () => { + await results[0].refetchAll(); + }); + expect(syncActivities).toHaveBeenCalledTimes(1); + expect(fetchActivityEvents).toHaveBeenCalledTimes(1); + }); + + it('reloads server rows after sync failure, waits for remaining queries and allows retry', async () => { + const errorLog = jest.spyOn(console, 'error').mockImplementation(() => {}); + (syncActivities as jest.Mock).mockRejectedValueOnce(new Error('Offline')); + const balances = deferred(); + observe(['tokenBalances', '0xABC'], () => balances.promise); + observe(['cardTransactions'], async () => { + throw new Error('Issuer unavailable'); + }); + await mount(); + let promise!: Promise; + await act(async () => { + promise = results[0].refetchAll(); + }); + expect(fetchActivityEvents).toHaveBeenCalledTimes(1); + expect(results[0].isRefreshing).toBe(true); + await act(async () => { + balances.resolve('fresh'); + await promise; + }); + expect(results[0].isRefreshing).toBe(false); + expect(Toast.show).toHaveBeenCalledTimes(1); + await act(async () => { + await results[0].refetchAll(); + }); + expect(syncActivities).toHaveBeenCalledTimes(2); + expect(results[0].isRefreshing).toBe(false); + errorLog.mockRestore(); + }); + + it('does not fetch shared caches for an account left during sync', async () => { + const sync = deferred(); + (syncActivities as jest.Mock).mockReturnValue(sync.promise); + await mount(); + let promise!: Promise; + await act(async () => { + promise = results[0].refetchAll(); + }); + act(() => useUserStore.setState({ users: [] })); + await act(async () => { + sync.resolve(syncResult); + await promise; + }); + expect(fetchActivityEvents).not.toHaveBeenCalled(); + expect(useAccountRefreshStore.getState().refreshingByUser.account).toBe(false); + }); +}); diff --git a/hooks/__tests__/useWhatsNew.test.tsx b/hooks/__tests__/useWhatsNew.test.tsx new file mode 100644 index 000000000..6a8aff8d2 --- /dev/null +++ b/hooks/__tests__/useWhatsNew.test.tsx @@ -0,0 +1,81 @@ +import React from 'react'; +import { useQuery } from '@tanstack/react-query'; + +import { useWhatsNew } from '@/hooks/useWhatsNew'; +import { WhatsNew } from '@/lib/types'; +import { useWhatsNewStore } from '@/store/useWhatsNewStore'; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { act, create } = require('react-test-renderer'); + +const mockStorage = new Map(); + +jest.mock('@/lib/mmvkStorage', () => ({ + __esModule: true, + default: () => ({ + getItem: (key: string) => mockStorage.get(key) ?? null, + setItem: (key: string, value: string) => mockStorage.set(key, value), + }), +})); + +jest.mock('@/lib/api', () => ({ fetchLatestWhatsNew: jest.fn() })); +jest.mock('@tanstack/react-query', () => ({ useQuery: jest.fn() })); + +const announcement: WhatsNew = { + _id: 'new-announcement', + isActive: true, + showOnLoad: true, + steps: [], + createdAt: '2026-09-27T00:00:00.000Z', +}; + +const Probe = ({ ready }: { ready: boolean }) => { + useWhatsNew(ready); + return null; +}; + +describe('useWhatsNew auto-open', () => { + beforeEach(() => { + jest.useFakeTimers(); + mockStorage.clear(); + useWhatsNewStore.setState({ whatsNew: announcement, isVisible: false }); + (useQuery as jest.Mock).mockReturnValue({ data: undefined, refetch: jest.fn() }); + }); + + afterEach(() => { + jest.clearAllTimers(); + jest.useRealTimers(); + }); + + it('waits three seconds after Home becomes ready', async () => { + let renderer: ReturnType; + await act(async () => { + renderer = create(); + }); + + act(() => jest.advanceTimersByTime(10000)); + expect(useWhatsNewStore.getState().isVisible).toBe(false); + + await act(async () => renderer!.update()); + act(() => jest.advanceTimersByTime(2999)); + expect(useWhatsNewStore.getState().isVisible).toBe(false); + + act(() => jest.advanceTimersByTime(1)); + expect(useWhatsNewStore.getState().isVisible).toBe(true); + act(() => renderer!.unmount()); + }); + + it('cancels the pending open when Home is left', async () => { + let renderer: ReturnType; + await act(async () => { + renderer = create(); + }); + + act(() => jest.advanceTimersByTime(1500)); + await act(async () => renderer!.update()); + act(() => jest.advanceTimersByTime(5000)); + + expect(useWhatsNewStore.getState().isVisible).toBe(false); + act(() => renderer!.unmount()); + }); +}); diff --git a/hooks/useActivity.ts b/hooks/useActivity.ts index 4b8e369d1..d7e76347f 100644 --- a/hooks/useActivity.ts +++ b/hooks/useActivity.ts @@ -3,35 +3,16 @@ import { Hash } from 'viem'; import { useShallow } from 'zustand/react/shallow'; import useUser from '@/hooks/useUser'; +import { constructActivity } from '@/lib/activityEvents'; import { fetchActivityEvents } from '@/lib/api'; import { ActivityEvent, TransactionStatus, TransactionType } from '@/lib/types'; import { withRefreshToken } from '@/lib/utils'; +import { useAccountRefreshStore } from '@/store/useAccountRefreshStore'; import { useActivityStore } from '@/store/useActivityStore'; import { useActivityActions } from './useActivityActions'; +import { useActivityRefresh } from './useActivityRefresh'; import { useUserTransactions } from './useAnalytics'; -import { useSyncActivities } from './useSyncActivities'; - -function constructActivity(tx: ActivityEvent, safeAddress: string): ActivityEvent { - let clientTxId = `${tx.type}-${tx.timestamp}`; - if ('trackingId' in tx && tx.trackingId) { - clientTxId = tx.trackingId as string; - } else if ('clientTxId' in tx && tx.clientTxId) { - clientTxId = tx.clientTxId; - } else if ('hash' in tx && tx.hash) { - clientTxId = `${tx.type}-${tx.hash}`; - } - - return { - ...tx, - clientTxId, - title: tx.title || `${tx.type} Transaction`, - timestamp: tx.timestamp || Math.floor(Date.now() / 1000).toString(), - amount: tx.amount.toString(), - symbol: tx.symbol || 'USDC', - fromAddress: tx.fromAddress || safeAddress, - }; -} export interface CreateActivityParams { type: TransactionType; @@ -91,41 +72,14 @@ export function useActivity() { const hasFetchedInitial = useRef(false); const fetchedForUserId = useRef(null); - // Memoize sync options to ensure stable reference - // (useSyncActivities extracts primitives, but this prevents potential re-render issues) - const syncOptions = useMemo( - () => ({ - syncOnAppActive: true, - syncOnMount: true, - }), - [], - ); - - // Sync all activities from backend (handles smart caching internally) - // Backend now syncs: Blockscout, deposits, bridges, and bank transfers - const { - sync: syncFromBackend, - isSyncing, - isStale: isSyncStale, - canSync, - } = useSyncActivities(syncOptions); + const { refetchAll, isRefreshing, isSyncing, isSyncStale, canSync } = useActivityRefresh({ + syncOnAppActive: true, + syncOnMount: true, + }); const { data: userTransactions } = useUserTransactions(user?.safeAddress); - const transactionsRef = useRef(userTransactions); - const withdrawsKey = useMemo(() => { - const withdraws = userTransactions?.withdraws; - if (!withdraws?.length) return 'empty'; - const hashSample = withdraws - .slice(0, 3) - .map(w => `${w.requestTxHash?.slice(0, 10) ?? ''}-${w.requestStatus ?? ''}`) - .join('|'); - return `${withdraws.length}:${hashSample}`; - }, [userTransactions?.withdraws]); - - useEffect(() => { - transactionsRef.current = userTransactions; - }, [withdrawsKey, userTransactions]); + const withdraws = userTransactions?.withdraws; // Fetch a page of activities directly from the API and push to Zustand const fetchPage = useCallback( @@ -199,22 +153,16 @@ export function useActivity() { } }, [hasNextPage, isFetchingNextPage, currentPage, fetchPage]); - // Refetch all data sources (backend handles all syncing now) - const refetchAll = useCallback( - (force = false) => { - if (isSyncing) return; - // Reset pagination and refetch first page - setCurrentPage(1); - fetchPage(1).catch((error: any) => { - console.error('Failed to refetch first page:', error); - }); - // Trigger backend sync - syncFromBackend(undefined, force).catch((error: any) => { - console.error('Background sync failed:', error); - }); - }, - [isSyncing, syncFromBackend, fetchPage], + const refreshedPage = useAccountRefreshStore(state => + user?.userId ? state.latestPageByUser[user.userId] : undefined, ); + useEffect(() => { + // Keep cached rows visible, but restart server pagination: new rows can + // shift page boundaries, so the old cursor could otherwise skip history. + if (!refreshedPage) return; + setCurrentPage(1); + setHasNextPage(refreshedPage.hasNextPage); + }, [refreshedPage]); // Get user's activities from local storage const activities = useMemo(() => { @@ -240,13 +188,13 @@ export function useActivity() { // // When subgraph data is unavailable we now show PROCESSING (not PENDING) // to reflect that the on-chain tx succeeded and the request is in the queue. - if (transactionsRef.current?.withdraws) { + if (withdraws) { userEvents = userEvents.map(activity => { if ( activity.type === TransactionType.WITHDRAW && activity.status === TransactionStatus.SUCCESS ) { - const matchingWithdraw = transactionsRef.current?.withdraws.find(w => { + const matchingWithdraw = withdraws.find(w => { const activityHash = activity.hash?.toLowerCase(); const activityUserOpHash = activity.userOpHash?.toLowerCase(); const reqHash = w.requestTxHash?.toLowerCase(); @@ -309,13 +257,11 @@ export function useActivity() { return true; }) .sort((a, b) => parseInt(b.timestamp) - parseInt(a.timestamp)); - // eslint-disable-next-line react-hooks/exhaustive-deps -- withdrawsKey is intentional: stable lightweight key replaces unstable object reference - }, [userEventsFromStore, user?.userId, withdrawsKey]); + }, [userEventsFromStore, user?.userId, withdraws]); useEffect(() => { if (!activities?.length) return; setCachedActivities(activities); - // eslint-disable-next-line react-hooks/exhaustive-deps -- setCachedActivities is stable useState setter }, [activities]); // Get pending activities @@ -349,6 +295,7 @@ export function useActivity() { refetchAll, // Sync state for UI indicators isSyncing, + isRefreshing, isSyncStale, canSync, }; diff --git a/hooks/useActivityRefresh.ts b/hooks/useActivityRefresh.ts index b07cfdfe7..5cd4d843f 100644 --- a/hooks/useActivityRefresh.ts +++ b/hooks/useActivityRefresh.ts @@ -1,64 +1,95 @@ -import { useCallback, useMemo } from 'react'; -import { useQueryClient } from '@tanstack/react-query'; +import { useCallback } from 'react'; +import Toast from 'react-native-toast-message'; +import { QueryClient, useQueryClient } from '@tanstack/react-query'; -import { cardTransactionsQueryKey } from '@/hooks/useCardTransactions'; -import { useSyncActivities } from '@/hooks/useSyncActivities'; -import useUser from '@/hooks/useUser'; +import { useSyncActivities, UseSyncActivitiesOptions } from '@/hooks/useSyncActivities'; +import { refreshAccountQueries } from '@/lib/refreshAccountQueries'; +import { refreshWalletActivity } from '@/lib/refreshWalletActivity'; +import { useAccountRefreshStore } from '@/store/useAccountRefreshStore'; import { useActivityStore } from '@/store/useActivityStore'; +import { useUserStore } from '@/store/useUserStore'; -/** - * Lightweight hook for activity refresh functionality. - * Use this instead of useActivity() when you only need refresh capabilities - * without the heavy activity data computations. - * - * This prevents excessive re-renders in components like ActivityScreen - * that don't need the full activity data. - */ -export function useActivityRefresh() { - const { user } = useUser(); - const queryClient = useQueryClient(); - - // Memoize options to ensure stable reference - // (useSyncActivities extracts primitives, but this is good defensive coding) - const syncOptions = useMemo( - () => ({ - syncOnAppActive: false, // Don't auto-sync, this is just for manual refresh - syncOnMount: false, - }), - [], - ); +const refreshPromises = new WeakMap>>(); +const SYNC_OPTIONS = { syncOnAppActive: false, syncOnMount: false }; - const { sync: syncFromBackend, isSyncing, isStale: isSyncStale } = useSyncActivities(syncOptions); - - // Derive isLoading: true when syncing AND no cached events (first load). - // Narrow selector avoids re-rendering on every event change. - const hasEvents = useActivityStore( - state => !!(user?.userId && state.events[user.userId]?.length), +/** One awaited refresh for Home, every Activity filter and the web refresh button. */ +export function useActivityRefresh(options: UseSyncActivitiesOptions = SYNC_OPTIONS) { + const user = useUserStore(state => state.users.find(account => account.selected)); + const userId = user?.userId; + const safeAddress = user?.safeAddress; + const queryClient = useQueryClient(); + const { sync, isSyncing, isStale: isSyncStale, canSync } = useSyncActivities(options); + const isRefreshing = useAccountRefreshStore(state => + userId ? !!state.refreshingByUser[userId] : false, ); - const isLoading = isSyncing && !hasEvents; + const hasEvents = useActivityStore(state => !!(userId && state.events[userId]?.length)); const refetchAll = useCallback( - (force = false) => { - if (!user?.userId || isSyncing) return; + (force = true): Promise => { + if (!userId || !safeAddress) return Promise.resolve(); + let pending = refreshPromises.get(queryClient); + if (!pending) { + pending = new Map(); + refreshPromises.set(queryClient, pending); + } + const existing = pending.get(userId); + if (existing) return existing; - // Card history is a separate query from the wallet activity store, so - // without this the refresh button on the Card tab appears to work and - // refreshes nothing. It matters most for cards whose transactions settle - // out-of-band (Wirex), where a pending purchase only turns into a - // confirmed one on a refetch. - queryClient.invalidateQueries({ queryKey: cardTransactionsQueryKey }); + useAccountRefreshStore.setState(state => ({ + refreshingByUser: { ...state.refreshingByUser, [userId]: true }, + })); + const promise = (async () => { + const failures: unknown[] = []; + try { + const result = await sync(undefined, force); + if (result?.errors) failures.push(new Error('Some transactions could not be synced')); + } catch (error) { + failures.push(error); + } + const isSelected = () => + useUserStore.getState().users.find(account => account.selected)?.userId === userId; + if (!isSelected()) return; - syncFromBackend(undefined, force).catch((error: any) => { - console.error('Background sync failed:', error); + // Read AFTER sync, even when sync fails: the server may still have newer + // webhook data. Await every source before releasing the native spinner. + const results = await Promise.allSettled([ + refreshWalletActivity(userId, safeAddress, isSelected).then(page => { + if (!isSelected()) return; + useAccountRefreshStore.setState(state => ({ + latestPageByUser: { ...state.latestPageByUser, [userId]: page }, + })); + }), + refreshAccountQueries(queryClient, userId, safeAddress, true), + ]); + for (const result of results) { + if (result.status === 'rejected') failures.push(result.reason); + } + if (failures.length && isSelected()) { + console.error('Account refresh failed:', failures[0]); + Toast.show({ + type: 'error', + text1: "Couldn't refresh everything", + text2: 'Some data may be out of date. Pull to try again.', + }); + } + })().finally(() => { + pending.delete(userId); + useAccountRefreshStore.setState(state => ({ + refreshingByUser: { ...state.refreshingByUser, [userId]: false }, + })); }); + pending.set(userId, promise); + return promise; }, - [user?.userId, isSyncing, syncFromBackend, queryClient], + [userId, safeAddress, queryClient, sync], ); return { refetchAll, - isSyncing, + isRefreshing, + isSyncing: isSyncing || isRefreshing, isSyncStale, - isLoading, + canSync, + isLoading: (isSyncing || isRefreshing) && !hasEvents, }; } diff --git a/hooks/useActivitySSE.ts b/hooks/useActivitySSE.ts index 9129e95be..6a3508a30 100644 --- a/hooks/useActivitySSE.ts +++ b/hooks/useActivitySSE.ts @@ -4,6 +4,7 @@ import * as Sentry from '@sentry/react-native'; import { queryClient } from '@/app/_layout'; import { fetchActivityEvents, getActivityStreamUrl, refreshToken } from '@/lib/api'; +import { refreshAccountQueries } from '@/lib/refreshAccountQueries'; import { refreshRewardsAfterSavings } from '@/lib/refreshRewardsAfterSavings'; import { ActivityEvent, @@ -710,12 +711,12 @@ class SSEConnectionManager { this.balanceDebounceTimer = null; if (this.currentUserId !== eventUserId) return; try { - // Invalidate token balance queries to trigger refetch - queryClient.invalidateQueries({ queryKey: ['tokenBalances'] }); - - // Invalidate vault balance queries to trigger refetch - // Using partial match to invalidate all vault balance variants - queryClient.invalidateQueries({ queryKey: ['vault'] }); + const user = useUserStore.getState().users.find(user => user.userId === eventUserId); + if (user?.safeAddress) { + void refreshAccountQueries(queryClient, eventUserId, user.safeAddress).catch(err => { + Sentry.captureException(err, { tags: { type: 'sse_balance_update_error' } }); + }); + } queryClient.invalidateQueries({ queryKey: ['rewards', 'userData', eventUserId] }); // External deposits, withdrawals and share transfers can change tier // eligibility. Reconcile past the vault cache without inferring a tier. @@ -724,7 +725,6 @@ class SSEConnectionManager { data.balance.changeType, ) ) { - const user = useUserStore.getState().users.find(user => user.userId === eventUserId); // The stream does not identify the destination vault. Poll quietly // for external Savings deposits without blocking a new wallet-funded buy. refreshRewardsAfterSavings(queryClient, eventUserId, user?.safeAddress, false); diff --git a/hooks/useAnalytics.ts b/hooks/useAnalytics.ts index c1eff47dd..2db8f834e 100644 --- a/hooks/useAnalytics.ts +++ b/hooks/useAnalytics.ts @@ -157,7 +157,9 @@ export const userTransactionsQueryOptions = ( variables: { address: safeAddress.toLowerCase(), }, - fetchPolicy: 'cache-first', + // React Query owns the freshness window. An Apollo cache hit here would + // defeat manual invalidation and keep deposits/withdrawals stale forever. + fetchPolicy: 'network-only', }); return data; }, diff --git a/hooks/useBalances.ts b/hooks/useBalances.ts index bc3007262..31a29a864 100644 --- a/hooks/useBalances.ts +++ b/hooks/useBalances.ts @@ -8,7 +8,7 @@ import { fetchCoinSimplePrice, fetchTokenList, fetchTokenPricesByAddress, - fetchTokenPriceUsd, + fetchTokenPricesBySymbol, } from '@/lib/api'; import { ADDRESSES } from '@/lib/config'; import { fetchTokenBalancesWithFallback } from '@/lib/data-source'; @@ -108,9 +108,10 @@ const symbols = { /** * 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. + * CoinGecko on failure) rather than a bare Alchemy call, so a chain still gets + * a price while Alchemy has none or is rate-limiting us. The five lookups start + * in the same tick, so they ride one Prices API request — ETH is asked for once, + * not once per chain — and are served from cache for the next minute. */ const NATIVE_PRICE_FETCHERS: Record Promise> = { [mainnet.id]: makeNativePriceFetcher(mainnet.id), @@ -571,7 +572,8 @@ const fetchTokenBalances = async (safeAddress: string) => { } } - // Fallback 3: Alchemy by symbol for tokens still at 0 (no tokenId) + // Fallback 3: Alchemy by symbol for tokens still at 0 (no tokenId). One + // request carries up to 25 symbols; this used to be a request per symbol. const stillZero = allTokens.filter( t => isZeroRate(t.quoteRate) && @@ -580,25 +582,13 @@ const fetchTokenBalances = async (safeAddress: string) => { ); const symbolsToFetch = [...new Set(stillZero.map(t => t.contractTickerSymbol))]; if (symbolsToFetch.length > 0) { - try { - const results = await Promise.allSettled(symbolsToFetch.map(s => fetchTokenPriceUsd(s))); - const symbolToPrice: Record = {}; - symbolsToFetch.forEach((sym, i) => { - const r = results[i]; - if (r.status === 'fulfilled') { - const p = parsePrice(r.value); - if (p != null && p > 0) symbolToPrice[sym] = p; - } - }); - allTokens = allTokens.map(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; - }); - } catch (e) { - console.warn('Alchemy fallback price failed:', e); - } + const symbolToPrice = await fetchTokenPricesBySymbol(symbolsToFetch); + allTokens = allTokens.map(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; + }); } // Helper function to calculate token value @@ -732,22 +722,34 @@ const EMPTY_BALANCE_DATA = { unifiedTokens: [] as UnifiedTokenBalance[], }; +/** Shared by useBalances and the protected layout's prefetch. */ +export const tokenBalancesQueryOptions = (safeAddress: string | undefined) => ({ + queryKey: ['tokenBalances', safeAddress], + queryFn: () => fetchTokenBalances(safeAddress!), + enabled: !!safeAddress, + staleTime: 30 * 1000, + gcTime: 5 * 60 * 1000, +}); + +/** + * SSE handles real-time updates (useActivitySSE invalidates ['tokenBalances'] + * on every balance event); polling is the fallback for missed events or SSE + * failure. The price lookups inside a refresh are cached for a minute, so this + * interval doesn't drive Prices API usage. + */ +const BALANCES_POLL_INTERVAL_MS = 5_000; + export const useBalances = (): BalanceData => { const { user } = useUser(); const { data, isLoading, isRefetching, error, refetch } = useQuery({ - queryKey: ['tokenBalances', user?.safeAddress], - queryFn: () => fetchTokenBalances(user?.safeAddress!), - enabled: !!user?.safeAddress, - // TanStack Query handles all the manual logic: + ...tokenBalancesQueryOptions(user?.safeAddress), staleTime: 5_000, - gcTime: 5 * 60 * 1000, // 5 minutes - data stays in cache for 5 minutes when unused retry: 3, // retry up to 3 times on failure retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000), // Exponential backoff refetchOnWindowFocus: true, // refetch when user returns to tab refetchOnReconnect: true, // refetch when network reconnects - // SSE handles real-time updates; polling is fallback for missed events or SSE failure - refetchInterval: 5_000, + refetchInterval: BALANCES_POLL_INTERVAL_MS, refetchIntervalInBackground: false, // Don't refetch when app is backgrounded (saves battery) }); @@ -761,11 +763,3 @@ export const useBalances = (): BalanceData => { retry: refetch, }; }; - -export const tokenBalancesQueryOptions = (safeAddress: string | undefined) => ({ - queryKey: ['tokenBalances', safeAddress], - queryFn: () => fetchTokenBalances(safeAddress!), - enabled: !!safeAddress, - staleTime: 30 * 1000, - gcTime: 5 * 60 * 1000, -}); diff --git a/hooks/useCardSpendRegistration.ts b/hooks/useCardSpendRegistration.ts index 9767cf077..aeaadcd5b 100644 --- a/hooks/useCardSpendRegistration.ts +++ b/hooks/useCardSpendRegistration.ts @@ -30,6 +30,7 @@ import { confirmWirexCardRegistration } from '@/lib/api'; import { ADDRESSES } from '@/lib/config'; import { executeTransactions, USER_CANCELLED_TRANSACTION } from '@/lib/execute'; import { CardProvider } from '@/lib/types'; +import { buildModuleDisables, includesModule, SENTINEL_MODULES } from '@/lib/utils/safeModules'; import { publicClient } from '@/lib/wagmi'; import { useUserStore } from '@/store/useUserStore'; @@ -40,13 +41,7 @@ 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 - * entry pointing at the one being removed, and for the most recently enabled module - * that pointer is the sentinel itself rather than another module's address. - */ -const SENTINEL_MODULES = '0x0000000000000000000000000000000000000001' as Address; +const RETIRED_MODULES_V2 = ADDRESSES.fuse.retiredCashModulesV2; /** Enough to cover any real Safe's module list in one read. */ const MODULE_PAGE_SIZE = 50n; @@ -301,6 +296,31 @@ const findModulePredecessor = async ( return index === 0 ? SENTINEL_MODULES : modules[index - 1]; }; +/** + * `disableModule` calls for each of `targets` still enabled on the Safe, read at press time. + * + * The list is read fresh for the same reason {@link findModulePredecessor} reads it: enabling + * any module rewrites the pointers, and a stale predecessor reverts the whole batch. + * + * A new cardholder's Safe is often still counterfactual on Fuse — the set-up batch is what + * deploys it. It has no code yet, so `getModulesPaginated` returns `0x` and viem throws; with + * no code there are no modules either, so there is nothing to disable. + */ +const encodeModuleDisables = async (safeAddress: Address, targets: readonly Address[]) => { + const client = publicClient(fuse.id); + const code = await client.getCode({ address: safeAddress }); + if (!code || code === '0x') return { disabled: [] as Address[], transactions: [] }; + + const [modules] = await client.readContract({ + address: safeAddress, + abi: Safe_ABI, + functionName: 'getModulesPaginated', + args: [SENTINEL_MODULES, MODULE_PAGE_SIZE], + }); + + return buildModuleDisables(safeAddress, modules, targets); +}; + /** * The caps a migrating Safe registers on v2 with. * @@ -1127,28 +1147,22 @@ export function useCardSpendRegistration({ enabled }: UseCardSpendRegistrationOp const target = fresh.moduleAddress; // A Safe headed for v2 with v1 still switched on: v2's `registerSafe` reverts - // `LegacyModuleStillEnabled`, so v1 comes off first, in the same batch. - const prevLegacyModule = - fresh.awaitingV2 && fresh.legacyEnabled - ? await findModulePredecessor(safeAddress, MODULE as Address) - : null; - if (fresh.awaitingV2 && fresh.legacyEnabled && !prevLegacyModule) { + // `LegacyModuleStillEnabled`, so v1 comes off first, in the same batch. So does any + // retired v2 core the Safe was left on by a redeploy — it can no longer fund the card, + // and leaving it enabled keeps a module on the Safe that nothing is watching. + const needsLegacyDisable = fresh.awaitingV2 && fresh.legacyEnabled; + const cleanup = isV2Module(target) + ? await encodeModuleDisables(safeAddress, [ + ...(needsLegacyDisable ? [MODULE as Address] : []), + ...RETIRED_MODULES_V2, + ]) + : { disabled: [], transactions: [] }; + if (needsLegacyDisable && !includesModule(cleanup.disabled, MODULE as Address)) { throw new Error('Could not read your Safe. Please try again.'); } const transactions = [ - ...(prevLegacyModule - ? [ - { - to: safeAddress, - data: encodeFunctionData({ - abi: Safe_ABI, - functionName: 'disableModule', - args: [prevLegacyModule, MODULE as Address], - }), - }, - ] - : []), + ...cleanup.transactions, ...(fresh.moduleEnabled ? [] : [ @@ -1427,25 +1441,20 @@ export function useCardSpendRegistration({ enabled }: UseCardSpendRegistrationOp // 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], - }), - }); + // independent sets of spending caps. A retired v2 core comes off in the same place — + // this is the one write a Safe left holding both cores is sure to make again. + const cleanup = await encodeModuleDisables(safeAddress, [ + ...(v2.legacyEnabled ? [MODULE as Address] : []), + ...RETIRED_MODULES_V2, + ]); + if (v2.legacyEnabled && !includesModule(cleanup.disabled, MODULE as Address)) { + throw new Error('Could not read your Safe. Please try again.'); } + const transactions: { to: Address; data: `0x${string}` }[] = [...cleanup.transactions]; + if (!v2.moduleEnabled) { transactions.push({ to: safeAddress, diff --git a/hooks/useCardSteps/__tests__/buildCardSteps.test.ts b/hooks/useCardSteps/__tests__/buildCardSteps.test.ts index 41cee84f8..75f0431bb 100644 --- a/hooks/useCardSteps/__tests__/buildCardSteps.test.ts +++ b/hooks/useCardSteps/__tests__/buildCardSteps.test.ts @@ -1,5 +1,11 @@ +import { EndorsementStatus } from '@/components/BankTransfer/enums'; import { buildCardSteps } from '@/hooks/useCardSteps/stepHelpers'; -import { CardProvider, KycStatus, RainApplicationStatus } from '@/lib/types'; +import { + BridgeCustomerEndorsement, + CardProvider, + KycStatus, + RainApplicationStatus, +} from '@/lib/types'; const noop = () => {}; @@ -274,3 +280,34 @@ describe('buildCardSteps - a blocked activation', () => { expect(activate?.onPress).toBeDefined(); }); }); + +describe('buildCardSteps - a retired Bridge endorsement', () => { + // An old bridge.xyz customer keeps their "cards" endorsement on Bridge's side. + const approvedBridgeEndorsement = { + name: 'cards', + status: EndorsementStatus.APPROVED, + } as unknown as BridgeCustomerEndorsement; + + const kycStepFor = (kycStatus?: KycStatus) => + buildCardSteps(approvedBridgeEndorsement, undefined, false, undefined, noop, noop, noop, { + cardIssuer: CardProvider.WIREX, + kycStatus, + depositRequired: false, + }).find(s => s.key === 'kyc'); + + it('does not complete KYC for a Wirex applicant who has not verified', () => { + // The backend reports a card customer, so its kycStatus is the answer — + // the Bridge approval is for a card that no longer exists. + const kyc = kycStepFor(KycStatus.NOT_STARTED); + + expect(kyc?.completed).toBe(false); + expect(kyc?.buttonText).toBe('Continue verification'); + expect(kyc?.onPress).toBeDefined(); + }); + + it('still honours the endorsement for a Bridge-only user with no card customer', () => { + const kyc = kycStepFor(undefined); + + expect(kyc?.completed).toBe(true); + }); +}); diff --git a/hooks/useCardSteps/stepHelpers.ts b/hooks/useCardSteps/stepHelpers.ts index f6a93ecec..df36ad83b 100644 --- a/hooks/useCardSteps/stepHelpers.ts +++ b/hooks/useCardSteps/stepHelpers.ts @@ -63,6 +63,12 @@ export function buildCardSteps( isSubmittingPendingApplication?: boolean; }, ): Step[] { + // The Bridge endorsement is a fallback for Bridge-only users, who have no card + // customer and so no `kycStatus`. Once `/cards/status` reports one, the + // application is on Rain or Wirex and bridge.xyz has no say in it: an old + // Bridge "cards" approval would otherwise mark a Wirex applicant's KYC step + // done (and offer "Activate card") before they have verified with Sumsub. + const legacyEndorsement = options?.kycStatus != null ? undefined : cardsEndorsement; const stepOptions = options?.cardIssuer != null || options?.kycStatus != null ? { @@ -72,9 +78,9 @@ export function buildCardSteps( kycWarnings: options?.kycWarnings, } : undefined; - const description = getStepDescription(cardsEndorsement, customerRejectionReasons, stepOptions); - const buttonText = getStepButtonText(cardsEndorsement, stepOptions); - const isButtonDisabled = isStepButtonDisabled(cardsEndorsement, stepOptions); + const description = getStepDescription(legacyEndorsement, customerRejectionReasons, stepOptions); + const buttonText = getStepButtonText(legacyEndorsement, stepOptions); + const isButtonDisabled = isStepButtonDisabled(legacyEndorsement, stepOptions); const isRainKycApproved = options?.cardIssuer === CardProvider.RAIN && @@ -94,7 +100,7 @@ export function buildCardSteps( // kycStatus is the canonical backend decision for every non-Rain issuer, // with the Bridge endorsement kept as the legacy fallback. options?.kycStatus === KycStatus.APPROVED || - cardsEndorsement?.status === EndorsementStatus.APPROVED; + legacyEndorsement?.status === EndorsementStatus.APPROVED; // Deliberately does NOT repeat the failure reason. `CardStatusBanner` already // carries it — as a headline, with the detail and a support action — and @@ -163,7 +169,7 @@ export function buildCardSteps( : description, completed: kycStepComplete, status: kycStepComplete ? 'completed' : 'pending', - endorsementStatus: cardsEndorsement?.status, + endorsementStatus: legacyEndorsement?.status, buttonText: showHoldStep ? undefined : buttonText, onPress: showHoldStep || isButtonDisabled ? undefined : kycStepOnPress, }; diff --git a/hooks/useDepositFeeQuote.ts b/hooks/useDepositFeeQuote.ts new file mode 100644 index 000000000..09a29ed85 --- /dev/null +++ b/hooks/useDepositFeeQuote.ts @@ -0,0 +1,40 @@ +import { useQuery } from '@tanstack/react-query'; + +import { getDepositFeeQuote } from '@/lib/api'; +import { CardProvider } from '@/lib/types'; +import { withRefreshToken } from '@/lib/utils'; + +export const DEPOSIT_FEE_QUOTE_QUERY_KEY = 'deposit-fee-quote'; + +/** + * What the backend would charge a deposit to this destination, on this chain, + * in this currency: the rate set for its route and chain, or the 0.03% default. + * + * `provider` is not sent - the backend resolves the issuer itself - but it is + * in the key, so learning of an issuer switch asks again rather than quoting the + * old card's route. + * + * Cached for 30 seconds, the backend's own cache window for the rate grid, so a + * rate an admin changes shows up about as fast as it starts being charged. + */ +export const useDepositFeeQuote = ({ + destinationType, + chainId, + symbol, + provider, + enabled, +}: { + destinationType: 'PROTOCOL' | 'RAIN_CARD'; + chainId: number; + symbol?: string; + provider: CardProvider | null | undefined; + enabled: boolean; +}) => + useQuery({ + queryKey: [DEPOSIT_FEE_QUOTE_QUERY_KEY, destinationType, chainId, symbol, provider ?? null], + queryFn: () => + withRefreshToken(() => getDepositFeeQuote(destinationType, chainId, symbol as string)), + enabled: enabled && !!symbol, + staleTime: 30 * 1000, + retry: 1, + }); diff --git a/hooks/useEstimateGas.ts b/hooks/useEstimateGas.ts index f5adacea8..62734f000 100644 --- a/hooks/useEstimateGas.ts +++ b/hooks/useEstimateGas.ts @@ -20,6 +20,9 @@ async function estimateGasCost( const fastGasPrice = (baseGasPrice * 195n) / 100n; // 85% above base for fast transactions const tokenPriceUsd = await fetchTokenPriceUsd(token); + // The price lookup reports "no price" instead of throwing; fail the query as + // before, so the cost shows as 0 rather than NaN. + if (tokenPriceUsd === undefined) throw new Error(`No USD price for ${token}`); const gasCostInWei = gasEstimate * fastGasPrice; diff --git a/hooks/useNativePriceUsd.ts b/hooks/useNativePriceUsd.ts index d77dcf65d..48379b8e1 100644 --- a/hooks/useNativePriceUsd.ts +++ b/hooks/useNativePriceUsd.ts @@ -3,6 +3,13 @@ import { useQuery } from '@tanstack/react-query'; import { NATIVE_COINGECKO_TOKENS, NATIVE_TOKENS } from '@/constants/tokens'; import { fetchCoinSimplePrice, fetchTokenPriceUsd } from '@/lib/api'; +/** + * How often native-token prices refresh. Polling this often costs no Prices API + * quota of its own: the price lookups are cached for a minute, in the app and + * by the backend's shared cache. + */ +export const NATIVE_PRICE_REFRESH_MS = 5_000; + /** * Native-token USD price fetcher for a chain (Alchemy first, CoinGecko fallback). * Shared by useTotalSavingsUSD (FUSE + ETH terms) and the per-vault savings card. @@ -24,17 +31,21 @@ export const makeNativePriceFetcher = }; /** - * USD price of a chain's native token as a number (0 when disabled/unavailable). - * `queryKey` is shared with useTotalSavingsUSD ('fusePriceUsd' / 'ethPriceUsd') - * so the two hooks reuse one cached request. + * Query for a chain's native-token USD price. `queryKey` is shared by every + * caller for the same chain ('fusePriceUsd' / 'ethPriceUsd'), so they all read + * one cached request. */ -export const useNativePriceUsd = (chainId: number, queryKey: string, enabled: boolean): number => { - const { data } = useQuery({ +export const useNativePriceQuery = (chainId: number, queryKey: string, enabled: boolean) => + useQuery({ queryKey: [queryKey], queryFn: makeNativePriceFetcher(chainId), enabled, - staleTime: 5_000, - refetchInterval: 5_000, + staleTime: NATIVE_PRICE_REFRESH_MS, + refetchInterval: NATIVE_PRICE_REFRESH_MS, }); + +/** USD price of a chain's native token as a number (0 when disabled/unavailable). */ +export const useNativePriceUsd = (chainId: number, queryKey: string, enabled: boolean): number => { + const { data } = useNativePriceQuery(chainId, queryKey, enabled); return enabled ? Number(data) || 0 : 0; }; diff --git a/hooks/useSyncActivities.ts b/hooks/useSyncActivities.ts index ed3b86dbc..e744defa8 100644 --- a/hooks/useSyncActivities.ts +++ b/hooks/useSyncActivities.ts @@ -16,6 +16,9 @@ const SYNC_STALE_MS = 24 * 60 * 60 * 1000; // 24 hours - show prominent loading const SYNC_MIN_INTERVAL_MS = 30 * 1000; // 30 seconds - minimum time between syncs const LOCK_TIMEOUT_MS = 30 * 1000; // 30 seconds - force release lock if held this long +// A manual pull must await an existing background sync, then read its results. +const syncPromises = new Map>(); + // Store to track last sync time per user interface SyncState { lastSyncByUser: Record; @@ -196,6 +199,9 @@ export function useSyncActivities(options: UseSyncActivitiesOptions = {}): UseSy async (syncOptions?: SyncActivitiesOptions, force = false) => { if (!userId) return undefined; + const existing = syncPromises.get(userId); + if (existing) return existing; + // Check if we can sync (respects min interval) if (!force && !canSync(userId)) { return undefined; @@ -207,13 +213,12 @@ export function useSyncActivities(options: UseSyncActivitiesOptions = {}): UseSy return undefined; } - try { - return await doSync(syncOptions); - } finally { - // Always release the lock, regardless of success or failure - // This ensures no memory leaks even if mutation is cancelled/aborted + const promise = doSync(syncOptions).finally(() => { + syncPromises.delete(userId); releaseSyncLock(); - } + }); + syncPromises.set(userId, promise); + return promise; }, [userId, canSync, acquireSyncLock, releaseSyncLock, doSync], ); diff --git a/hooks/useToken.tsx b/hooks/useToken.tsx index 6921da4a1..213735186 100644 --- a/hooks/useToken.tsx +++ b/hooks/useToken.tsx @@ -1,5 +1,5 @@ -import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useEffect, useState } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; import { Address, erc20Abi, formatUnits } from 'viem'; import { mainnet } from 'viem/chains'; import { readContractQueryOptions } from 'wagmi/query'; @@ -21,7 +21,8 @@ type Balance = { export const tokenPriceUsdQueryOptions = (tokenId: string) => { return { queryKey: ['tokenPriceUsd', tokenId], - queryFn: () => fetchTokenPriceUsd(tokenId), + // null, not undefined: TanStack Query rejects undefined as query data. + queryFn: async () => (await fetchTokenPriceUsd(tokenId)) ?? null, enabled: !!tokenId, }; }; diff --git a/hooks/useTotalSavingsUSD.ts b/hooks/useTotalSavingsUSD.ts index 80fe8d515..37692c056 100644 --- a/hooks/useTotalSavingsUSD.ts +++ b/hooks/useTotalSavingsUSD.ts @@ -1,10 +1,9 @@ import { useMemo } from 'react'; -import { useQuery } from '@tanstack/react-query'; import { Address } from 'viem'; import { fuse, mainnet } from 'viem/chains'; import { VAULTS } from '@/constants/vaults'; -import { makeNativePriceFetcher } from '@/hooks/useNativePriceUsd'; +import { useNativePriceQuery } from '@/hooks/useNativePriceUsd'; import useUser from '@/hooks/useUser'; import { useVaultBalance } from '@/hooks/useVault'; import { useVaultExchangeRate } from '@/hooks/useVaultExchangeRate'; @@ -15,9 +14,6 @@ const usdcVault = ACTIVE_VAULTS[0]; const fuseVault = ACTIVE_VAULTS[1]; const ethVault = ACTIVE_VAULTS[2]; -const fetchFusePrice = makeNativePriceFetcher(fuse.id); -const fetchEthPrice = makeNativePriceFetcher(mainnet.id); - /** * Total redeemable savings in USD across ALL vaults: soUSD + soFUSE + soETH. * USDC uses its soUSD→USD rate directly; FUSE and ETH multiply their @@ -59,20 +55,16 @@ export const useTotalSavingsUSD = (): { const hasFuseBalance = !!fuseVault && (balanceFuse ?? 0) > 0; const hasEthBalance = !!ethVault && (balanceEth ?? 0) > 0; - const { data: fusePriceUsd, isLoading: isLoadingFusePrice } = useQuery({ - queryKey: ['fusePriceUsd'], - queryFn: fetchFusePrice, - enabled: hasFuseBalance, - staleTime: 5_000, - refetchInterval: 5_000, - }); - const { data: ethPriceUsd, isLoading: isLoadingEthPrice } = useQuery({ - queryKey: ['ethPriceUsd'], - queryFn: fetchEthPrice, - enabled: hasEthBalance, - staleTime: 5_000, - refetchInterval: 5_000, - }); + const { data: fusePriceUsd, isLoading: isLoadingFusePrice } = useNativePriceQuery( + fuse.id, + 'fusePriceUsd', + hasFuseBalance, + ); + const { data: ethPriceUsd, isLoading: isLoadingEthPrice } = useNativePriceQuery( + mainnet.id, + 'ethPriceUsd', + hasEthBalance, + ); const isLoading = isLoadingBalanceUsdc || diff --git a/hooks/useUnifiedActivity.ts b/hooks/useUnifiedActivity.ts index aadcac138..d88c1d39b 100644 --- a/hooks/useUnifiedActivity.ts +++ b/hooks/useUnifiedActivity.ts @@ -16,12 +16,13 @@ export type UseUnifiedActivityResult = { items: UnifiedActivityItem[]; isLoading: boolean; isSyncing: boolean; + isRefreshing: boolean; isSyncStale: boolean; hasNextPage: boolean; isFetchingNextPage: boolean; /** Advances whichever of the two sources still has pages left. */ loadMore: () => void; - refetchAll: (force?: boolean) => void; + refetchAll: (force?: boolean) => Promise; userHasCard: boolean; }; @@ -45,6 +46,7 @@ export function useUnifiedActivity(): UseUnifiedActivityResult { activities, isLoading, isSyncing, + isRefreshing, isSyncStale, hasNextPage, isFetchingNextPage, @@ -102,6 +104,7 @@ export function useUnifiedActivity(): UseUnifiedActivityResult { items, isLoading: isLoading || (userHasCard && isCardLoading), isSyncing, + isRefreshing, isSyncStale, hasNextPage: hasNextPage || !!hasNextCardPage, isFetchingNextPage: isFetchingNextPage || isFetchingNextCardPage, diff --git a/hooks/useUser.ts b/hooks/useUser.ts index eb7a11624..9286abff6 100644 --- a/hooks/useUser.ts +++ b/hooks/useUser.ts @@ -25,7 +25,11 @@ import { updateUserCredentialId, } from '@/lib/api'; import { getAttributionChannel } from '@/lib/attribution'; -import { EXPO_PUBLIC_TURNKEY_ORGANIZATION_ID, USER } from '@/lib/config'; +import { + EXPO_PUBLIC_PIMLICO_SPONSORSHIP_POLICY_ID, + EXPO_PUBLIC_TURNKEY_ORGANIZATION_ID, + USER, +} from '@/lib/config'; import { useIntercom } from '@/lib/intercom'; import { pimlicoClient } from '@/lib/pimlico'; import { Status, User } from '@/lib/types'; @@ -190,6 +194,9 @@ const useUser = (): UseUserReturn => { account: safeAccount, chain: chain, paymaster: bundlerClient, + paymasterContext: EXPO_PUBLIC_PIMLICO_SPONSORSHIP_POLICY_ID + ? { sponsorshipPolicyId: EXPO_PUBLIC_PIMLICO_SPONSORSHIP_POLICY_ID } + : undefined, userOperation: { estimateFeesPerGas: async () => { try { diff --git a/hooks/useVault.ts b/hooks/useVault.ts index c45623a47..8cedafd0d 100644 --- a/hooks/useVault.ts +++ b/hooks/useVault.ts @@ -17,14 +17,13 @@ const VAULT_REFETCH_INTERVAL = secondsToMilliseconds(3); // Poll every 3 seconds export const VAULT = 'vault'; -export const fetchVaultBalance = async ( +const fetchVaultBalanceWei = ( queryClient: QueryClient, safeAddress: Address, chainId: number, vaultAddress: Address, - decimals = 6, -) => { - const balance = await queryClient.fetchQuery({ +) => + queryClient.fetchQuery({ ...readContractQueryOptions(config, { abi: FuseVault, address: vaultAddress, @@ -32,9 +31,21 @@ export const fetchVaultBalance = async ( args: [safeAddress], chainId: chainId, }), - staleTime: VAULT_STALE_TIME, + // Always read the chain (concurrent reads of one vault still share a + // request): the vault queries are refetched when SSE reports a balance + // change, and a read cached from just before the transaction would answer + // that refetch with the old balance until the next poll. + staleTime: 0, }); +export const fetchVaultBalance = async ( + queryClient: QueryClient, + safeAddress: Address, + chainId: number, + vaultAddress: Address, + decimals = 6, +) => { + const balance = await fetchVaultBalanceWei(queryClient, safeAddress, chainId, vaultAddress); return Number(formatUnits(balance, decimals)) || 0; }; @@ -50,13 +61,15 @@ export const useFuseVaultBalance = (safeAddress: Address) => { }); }; +// soFUSE and soETH have 18 decimals, more than a JS number holds, so these two +// return the raw wei balance and leave formatting to the caller. export const useSoFuseVaultBalance = (safeAddress: Address) => { const queryClient = useQueryClient(); return useQuery({ - queryKey: [VAULT, 'balanceSoFuse', safeAddress], + queryKey: [VAULT, 'balanceSoFuseWei', safeAddress], queryFn: () => - fetchVaultBalance(queryClient, safeAddress, fuse.id, ADDRESSES.fuse.fuseVault, 18), + fetchVaultBalanceWei(queryClient, safeAddress, fuse.id, ADDRESSES.fuse.fuseVault), enabled: !!safeAddress, staleTime: VAULT_STALE_TIME, gcTime: VAULT_GC_TIME, @@ -67,9 +80,9 @@ export const useSoEthVaultBalance = (safeAddress: Address) => { const queryClient = useQueryClient(); return useQuery({ - queryKey: [VAULT, 'balanceSoEth', safeAddress], + queryKey: [VAULT, 'balanceSoEthWei', safeAddress], queryFn: () => - fetchVaultBalance(queryClient, safeAddress, fuse.id, ADDRESSES.fuse.soEthVault, 18), + fetchVaultBalanceWei(queryClient, safeAddress, fuse.id, ADDRESSES.fuse.soEthVault), enabled: !!safeAddress, staleTime: VAULT_STALE_TIME, gcTime: VAULT_GC_TIME, diff --git a/hooks/useWhatsNew.ts b/hooks/useWhatsNew.ts index edb7e377d..4f4346571 100644 --- a/hooks/useWhatsNew.ts +++ b/hooks/useWhatsNew.ts @@ -21,7 +21,7 @@ const getDismissedIds = (): string[] => { return data ? JSON.parse(data) : []; }; -export const useWhatsNew = () => { +export const useWhatsNew = (autoShowOnHome = false) => { // Use useShallow for object selection to prevent unnecessary re-renders const { whatsNew, isVisible, setWhatsNew, setIsVisible } = useWhatsNewStore( useShallow(state => ({ @@ -47,7 +47,7 @@ export const useWhatsNew = () => { refetchOnMount: true, // Ensure WhatsNew is fetched on first load }); - // Handle data updates and auto-show logic + // Keep the latest content available to the modal and settings button. const handleWhatsNewData = useCallback( (latest: Awaited>, forceShow = false) => { if (latest && latest.isActive) { @@ -55,12 +55,6 @@ export const useWhatsNew = () => { if (forceShow) { setIsVisible(true); - return; - } - - const seenIds = getSeenIds(); - if (latest.showOnLoad && !seenIds.includes(latest._id)) { - setIsVisible(true); } } }, @@ -76,6 +70,28 @@ export const useWhatsNew = () => { } }, [data, handleWhatsNewData]); + // Start the auto-open delay only when Home has finished its initial loading. + // The settings button still opens the modal immediately via forceShow. + useEffect(() => { + if ( + !autoShowOnHome || + !whatsNew?.isActive || + !whatsNew.showOnLoad || + isVisible || + getSeenIds().includes(whatsNew._id) + ) { + return; + } + + const timer = setTimeout(() => { + if (!getSeenIds().includes(whatsNew._id)) { + setIsVisible(true); + } + }, 3000); + + return () => clearTimeout(timer); + }, [autoShowOnHome, isVisible, setIsVisible, whatsNew]); + const checkWhatsNew = useCallback( async (forceShow = false) => { try { diff --git a/lib/__tests__/alchemyTokenPrices.test.ts b/lib/__tests__/alchemyTokenPrices.test.ts index 6e7b2be7c..f086e2f89 100644 --- a/lib/__tests__/alchemyTokenPrices.test.ts +++ b/lib/__tests__/alchemyTokenPrices.test.ts @@ -6,11 +6,18 @@ jest.mock('@/constants/rewards', () => ({ MOCK_REWARDS_USER_DATA: {}, MOCK_TIER_BENEFITS: {}, })); -// Reached for the JWT the price lookup deliberately does not send; the store -// itself sits on native MMKV. +// Read for the JWT the backend lookup sends (and the Alchemy one deliberately +// does not); the store itself sits on native MMKV. +const mockUsers: { selected: boolean; tokens?: { accessToken: string } }[] = []; jest.mock('@/store/useUserStore', () => ({ - useUserStore: { getState: () => ({ users: [] }) }, + useUserStore: { getState: () => ({ users: mockUsers }) }, })); +// A chain the app prices by address that the backend doesn't cover yet, as when +// an OTA adds one before the backend ships it. +jest.mock('@/constants/alchemy', () => { + const actual = jest.requireActual('@/constants/alchemy'); + return { ...actual, ALCHEMY_NETWORKS: { ...actual.ALCHEMY_NETWORKS, 10: 'opt-mainnet' } }; +}); jest.mock('@sentry/react-native', () => ({ addBreadcrumb: jest.fn(), captureException: jest.fn(), @@ -31,8 +38,14 @@ jest.mock('axios', () => { }); /* eslint-disable @typescript-eslint/no-require-imports */ -const post = (require('axios') as { default: { post: jest.Mock } }).default.post; -const { fetchTokenPricesByAddress } = require('@/lib/api') as typeof import('@/lib/api'); +const { post, get } = (require('axios') as { default: { post: jest.Mock; get: jest.Mock } }) + .default; +const { + clearAlchemyPriceCache, + fetchTokenPricesByAddress, + fetchTokenPricesBySymbol, + fetchTokenPriceUsd, +} = require('@/lib/api') as typeof import('@/lib/api'); /* eslint-enable @typescript-eslint/no-require-imports */ const USDC_BASE = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913'; @@ -45,7 +58,145 @@ const priced = (network: string, address: string, value: string) => ({ prices: [{ currency: 'usd', value, lastUpdatedAt: '2026-09-09T00:00:00Z' }], }); -beforeEach(() => post.mockReset()); +const bySymbol = (symbol: string, value?: string) => ({ + symbol, + prices: value ? [{ currency: 'usd', value, lastUpdatedAt: '2026-09-09T00:00:00Z' }] : [], + ...(value ? {} : { error: { message: `Price not found for symbol: ${symbol}` } }), +}); + +/** Symbols a by-symbol GET asked for, in order. */ +const requestedSymbols = (url: string) => + new URL(url).searchParams.getAll('symbols').map(s => decodeURIComponent(s)); + +const rateLimited = () => + Object.assign(new Error('Request failed with status code 429'), { + response: { status: 429, headers: {} }, + }); + +/** + * The backend route (`POST /accounts/v1/prices`), which the lookups try first. + * It is unreachable unless a test says otherwise, so the Alchemy tests below + * exercise the fallback every app has when the backend can't answer. + */ +const fetchMock = jest.fn(); +const backendAnswers = (body: unknown, status = 200) => + fetchMock.mockResolvedValue({ + ok: status >= 200 && status < 300, + status, + json: async () => body, + }); + +beforeEach(() => { + post.mockReset(); + get.mockReset(); + fetchMock.mockReset(); + fetchMock.mockRejectedValue(new TypeError('Network request failed')); + global.fetch = fetchMock as unknown as typeof fetch; + mockUsers.length = 0; + // Prices are shared module-wide for a minute; start every test cold. + clearAlchemyPriceCache(); +}); + +describe('fetchTokenPricesBySymbol', () => { + it('asks for every symbol looked up in the same tick in one request', async () => { + get.mockResolvedValue({ + data: { data: [bySymbol('ETH', '2658.09'), bySymbol('BNB', '765.87')] }, + }); + + // The native fetchers in useBalances ask for ETH once per chain. + const [ethereum, bsc, base] = await Promise.all([ + fetchTokenPriceUsd('ETH'), + fetchTokenPriceUsd('BNB'), + fetchTokenPriceUsd('ETH'), + ]); + + expect(get).toHaveBeenCalledTimes(1); + expect(get.mock.calls[0][0]).toContain('/tokens/by-symbol?'); + expect(requestedSymbols(get.mock.calls[0][0])).toEqual(['ETH', 'BNB']); + expect([ethereum, bsc, base]).toEqual(['2658.09', '765.87', '2658.09']); + }); + + it('matches the upper-cased symbols Alchemy echoes back', async () => { + get.mockResolvedValue({ + data: { data: [bySymbol('FUSE-NETWORK-TOKEN', '0.00805'), bySymbol('SOUSD', '1.066')] }, + }); + + await expect(fetchTokenPricesBySymbol(['fuse-network-token', 'soUSD'])).resolves.toEqual({ + 'fuse-network-token': 0.00805, + soUSD: 1.066, + }); + }); + + it('chunks past the 25-symbol cap', async () => { + const symbols = Array.from({ length: ALCHEMY_PRICE_BATCH_SIZE + 2 }, (_, i) => `TOKEN${i}`); + get.mockImplementation((url: string) => + Promise.resolve({ data: { data: requestedSymbols(url).map(s => bySymbol(s, '1')) } }), + ); + + const prices = await fetchTokenPricesBySymbol(symbols); + + expect(get).toHaveBeenCalledTimes(2); + expect(requestedSymbols(get.mock.calls[0][0])).toHaveLength(ALCHEMY_PRICE_BATCH_SIZE); + expect(requestedSymbols(get.mock.calls[1][0])).toHaveLength(2); + expect(Object.keys(prices)).toHaveLength(ALCHEMY_PRICE_BATCH_SIZE + 2); + }); + + it('encodes symbols that are not URL-safe', async () => { + get.mockResolvedValue({ data: { data: [] } }); + + await fetchTokenPricesBySymbol(['G$', 'USDC.E']); + + expect(get.mock.calls[0][0]).toContain('symbols=G%24&symbols=USDC.E'); + }); + + it('reuses a price for later callers instead of asking again', async () => { + get.mockResolvedValue({ data: { data: [bySymbol('ETH', '2658.09')] } }); + + await fetchTokenPriceUsd('ETH'); + await expect(fetchTokenPriceUsd('ETH')).resolves.toBe('2658.09'); + + expect(get).toHaveBeenCalledTimes(1); + }); + + it('remembers symbols Alchemy has no price for', async () => { + get.mockResolvedValue({ data: { data: [bySymbol('NOTAREALTOKEN')] } }); + + await expect(fetchTokenPriceUsd('NOTAREALTOKEN')).resolves.toBeUndefined(); + await expect(fetchTokenPriceUsd('NOTAREALTOKEN')).resolves.toBeUndefined(); + + expect(get).toHaveBeenCalledTimes(1); + }); + + it('stops calling either endpoint once the token_price quota is spent', async () => { + get.mockRejectedValueOnce(rateLimited()); + + await expect(fetchTokenPriceUsd('ETH')).resolves.toBeUndefined(); + await expect(fetchTokenPricesBySymbol(['BNB'])).resolves.toEqual({}); + await expect( + fetchTokenPricesByAddress([{ chainId: 8453, address: USDC_BASE }]), + ).resolves.toEqual({}); + + expect(get).toHaveBeenCalledTimes(1); + expect(post).not.toHaveBeenCalled(); + }); + + it('keeps showing the last price while rate-limited', async () => { + const now = jest.spyOn(Date, 'now'); + try { + now.mockReturnValue(1_000_000); + get.mockResolvedValueOnce({ data: { data: [bySymbol('ETH', '2658.09')] } }); + await fetchTokenPriceUsd('ETH'); + + now.mockReturnValue(1_000_000 + 61_000); // past the one-minute TTL + get.mockRejectedValueOnce(rateLimited()); + + await expect(fetchTokenPriceUsd('ETH')).resolves.toBe('2658.09'); + expect(get).toHaveBeenCalledTimes(2); + } finally { + now.mockRestore(); + } + }); +}); describe('fetchTokenPricesByAddress', () => { it('keys prices by chain id and lowercased address', async () => { @@ -177,4 +328,185 @@ describe('fetchTokenPricesByAddress', () => { await expect(fetchTokenPricesByAddress([])).resolves.toEqual({}); expect(post).not.toHaveBeenCalled(); }); + + it('keeps each request within the three-network cap', async () => { + post.mockResolvedValue({ data: { data: [] } }); + + // One token on each of the five Alchemy-served chains. + await fetchTokenPricesByAddress( + [1, 8453, 137, 42161, 56].map(chainId => ({ chainId, address: USDC_BASE })), + ); + + expect(post).toHaveBeenCalledTimes(2); + const networks = post.mock.calls.map( + ([, body]: [string, { addresses: { network: string }[] }]) => + new Set(body.addresses.map(a => a.network)).size, + ); + expect(networks).toEqual([3, 2]); + }); + + it('reuses prices for later callers instead of asking again', async () => { + post.mockResolvedValue({ + data: { data: [priced('base-mainnet', USDC_BASE.toLowerCase(), '1.0001')] }, + }); + + await fetchTokenPricesByAddress([{ chainId: 8453, address: USDC_BASE }]); + await expect( + fetchTokenPricesByAddress([{ chainId: 8453, address: USDC_BASE }]), + ).resolves.toEqual({ [`8453:${USDC_BASE.toLowerCase()}`]: 1.0001 }); + + expect(post).toHaveBeenCalledTimes(1); + }); +}); + +describe('prices from the backend', () => { + it('asks the backend first and leaves Alchemy alone when it answers', async () => { + backendAnswers({ symbols: { ETH: 2658.09, BNB: 765.87 }, tokens: {} }); + + const [eth, bnb] = await Promise.all([fetchTokenPriceUsd('ETH'), fetchTokenPriceUsd('bnb')]); + + expect([eth, bnb]).toEqual(['2658.09', '765.87']); + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toMatch(/\/accounts\/v1\/prices$/); + expect(init.method).toBe('POST'); + expect(init.credentials).toBe('include'); + expect(JSON.parse(init.body as string)).toEqual({ symbols: ['ETH', 'BNB'] }); + expect(get).not.toHaveBeenCalled(); + }); + + it('sends the signed-in user’s token on native', async () => { + mockUsers.push({ selected: true, tokens: { accessToken: 'jwt-1' } }); + backendAnswers({ symbols: { ETH: 2658.09 }, tokens: {} }); + + await fetchTokenPriceUsd('ETH'); + + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect((init.headers as Record).Authorization).toBe('Bearer jwt-1'); + }); + + it('prices tokens by address through the backend with the same keys', async () => { + backendAnswers({ symbols: {}, tokens: { [`8453:${USDC_BASE.toLowerCase()}`]: 1.0001 } }); + + await expect( + fetchTokenPricesByAddress([{ chainId: 8453, address: USDC_BASE }]), + ).resolves.toEqual({ + [`8453:${USDC_BASE.toLowerCase()}`]: 1.0001, + }); + + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(JSON.parse(init.body as string)).toEqual({ + tokens: [{ chainId: 8453, address: USDC_BASE.toLowerCase() }], + }); + expect(post).not.toHaveBeenCalled(); + }); + + it('asks Alchemy itself about a chain the backend does not cover yet', async () => { + const USDC_OPTIMISM = '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85'; + backendAnswers({ symbols: {}, tokens: { [`8453:${USDC_BASE.toLowerCase()}`]: 1.0001 } }); + post.mockResolvedValue({ + data: { data: [priced('opt-mainnet', USDC_OPTIMISM.toLowerCase(), '0.9998')] }, + }); + + // Were the Optimism token sent to the backend, it would come back unpriced + // and be remembered for minutes as having no price. + await expect( + fetchTokenPricesByAddress([ + { chainId: 8453, address: USDC_BASE }, + { chainId: 10, address: USDC_OPTIMISM }, + ]), + ).resolves.toEqual({ + [`8453:${USDC_BASE.toLowerCase()}`]: 1.0001, + [`10:${USDC_OPTIMISM.toLowerCase()}`]: 0.9998, + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(JSON.parse(init.body as string)).toEqual({ + tokens: [{ chainId: 8453, address: USDC_BASE.toLowerCase() }], + }); + expect(post).toHaveBeenCalledTimes(1); + expect(post.mock.calls[0][1]).toEqual({ + addresses: [{ network: 'opt-mainnet', address: USDC_OPTIMISM.toLowerCase() }], + }); + }); + + it('shares one cached answer instead of asking the backend again', async () => { + backendAnswers({ symbols: { ETH: 2658.09 }, tokens: {} }); + + await fetchTokenPriceUsd('ETH'); + await fetchTokenPriceUsd('ETH'); + + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('takes a symbol the backend has no price for as a miss, without asking Alchemy', async () => { + backendAnswers({ symbols: {}, tokens: {} }); + + await expect(fetchTokenPriceUsd('NOTAREALTOKEN')).resolves.toBeUndefined(); + expect(get).not.toHaveBeenCalled(); + }); + + it.each([ + ['is unreachable', () => fetchMock.mockRejectedValue(new TypeError('Network request failed'))], + [ + 'has no such route yet (older backend)', + () => backendAnswers({ message: 'Cannot POST' }, 404), + ], + ['rejects the session', () => backendAnswers({ message: 'Unauthorized' }, 401)], + // What the route answers when it can't reach Alchemy for a token it has no price for. + ['cannot price right now', () => backendAnswers({ message: 'No price available' }, 503)], + ['rate-limits this user', () => backendAnswers({ message: 'Too Many Requests' }, 429)], + ['answers with something unexpected', () => backendAnswers('proxy error')], + ])( + 'falls back to Alchemy when the backend %s, then skips the backend for a while', + async (_, setup) => { + setup(); + get.mockResolvedValue({ + data: { data: [bySymbol('ETH', '2658.09'), bySymbol('BNB', '765.87')] }, + }); + + await expect(fetchTokenPriceUsd('ETH')).resolves.toBe('2658.09'); + await expect(fetchTokenPriceUsd('BNB')).resolves.toBe('765.87'); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(get).toHaveBeenCalledTimes(2); + }, + ); + + it('asks the backend again once the pause is over', async () => { + const now = jest.spyOn(Date, 'now'); + try { + now.mockReturnValue(1_000_000); + get.mockResolvedValue({ data: { data: [bySymbol('ETH', '2658.09')] } }); + await fetchTokenPriceUsd('ETH'); // backend unreachable: Alchemy answers + + now.mockReturnValue(1_000_000 + 5 * 60_000); + backendAnswers({ symbols: { BNB: 765.87 }, tokens: {} }); + await expect(fetchTokenPriceUsd('BNB')).resolves.toBe('765.87'); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(get).toHaveBeenCalledTimes(1); + } finally { + now.mockRestore(); + } + }); + + it('never sends the backend a symbol or address its validation would reject', async () => { + backendAnswers({ symbols: {}, tokens: {} }); + + await fetchTokenPricesBySymbol(['ETH', 'X'.repeat(65)]); + await fetchTokenPricesByAddress([ + { chainId: 1, address: 'not-an-address' }, + { chainId: 1, address: USDC_ETHEREUM }, + ]); + + const bodies = fetchMock.mock.calls.map( + ([, init]) => JSON.parse((init as RequestInit).body as string) as unknown, + ); + expect(bodies).toEqual([ + { symbols: ['ETH'] }, + { tokens: [{ chainId: 1, address: USDC_ETHEREUM.toLowerCase() }] }, + ]); + }); }); diff --git a/lib/__tests__/batchedLoader.test.ts b/lib/__tests__/batchedLoader.test.ts new file mode 100644 index 000000000..b9f7b89d1 --- /dev/null +++ b/lib/__tests__/batchedLoader.test.ts @@ -0,0 +1,187 @@ +import { Cooldown, createBatchedLoader } from '@/lib/batchedLoader'; + +const TTL = 60_000; +const MISS_TTL = 300_000; +const ERROR_TTL = 15_000; +const MAX_STALE = 600_000; + +let clock = 0; +const rateLimited = Object.assign(new Error('429'), { rateLimited: true }); + +const setup = (options: { cooldown?: Cooldown; chunkSize?: number } = {}) => { + const fetchChunk = jest.fn(async (keys: string[]) => { + const values = new Map(); + for (const key of keys) if (!key.startsWith('missing')) values.set(key, key.length); + return values; + }); + const loader = createBatchedLoader({ + chunk: keys => { + const size = options.chunkSize ?? 25; + const chunks: string[][] = []; + for (let i = 0; i < keys.length; i += size) chunks.push(keys.slice(i, i + size)); + return chunks; + }, + fetchChunk, + ttlMs: TTL, + missTtlMs: MISS_TTL, + errorTtlMs: ERROR_TTL, + maxStaleMs: MAX_STALE, + cooldownMs: error => ((error as { rateLimited?: boolean }).rateLimited ? 60_000 : undefined), + cooldown: options.cooldown, + now: () => clock, + }); + return { loader, fetchChunk }; +}; + +beforeEach(() => { + clock = 1_000_000; +}); + +describe('createBatchedLoader', () => { + it('answers lookups started in the same tick with one request', async () => { + const { loader, fetchChunk } = setup(); + + const [a, b, c] = await Promise.all([ + loader.load(['ETH']), + loader.load(['BNB', 'ETH']), + loader.load(['FUSE']), + ]); + + expect(fetchChunk).toHaveBeenCalledTimes(1); + expect(fetchChunk).toHaveBeenCalledWith(['ETH', 'BNB', 'FUSE']); + expect(a).toEqual(new Map([['ETH', 3]])); + expect(b).toEqual( + new Map([ + ['BNB', 3], + ['ETH', 3], + ]), + ); + expect(c).toEqual(new Map([['FUSE', 4]])); + }); + + it('splits one flush into the chunks the upstream accepts', async () => { + const { loader, fetchChunk } = setup({ chunkSize: 2 }); + + await loader.load(['a', 'b', 'c', 'd', 'e']); + + expect(fetchChunk.mock.calls).toEqual([[['a', 'b']], [['c', 'd']], [['e']]]); + }); + + it('joins a request already in flight instead of sending another', async () => { + const { loader, fetchChunk } = setup(); + let respond: (values: Map) => void = () => {}; + fetchChunk.mockImplementationOnce(() => new Promise(resolve => (respond = resolve))); + + const first = loader.load(['ETH']); + await new Promise(resolve => setTimeout(resolve, 0)); // the first request is now out + expect(fetchChunk).toHaveBeenCalledTimes(1); + const second = loader.load(['ETH']); + respond(new Map([['ETH', 3]])); + + await expect(Promise.all([first, second])).resolves.toEqual([ + new Map([['ETH', 3]]), + new Map([['ETH', 3]]), + ]); + expect(fetchChunk).toHaveBeenCalledTimes(1); + }); + + it('serves a value from cache until its TTL runs out', async () => { + const { loader, fetchChunk } = setup(); + + await loader.load(['ETH']); + clock += TTL - 1; + await loader.load(['ETH']); + expect(fetchChunk).toHaveBeenCalledTimes(1); + + clock += 1; + await loader.load(['ETH']); + expect(fetchChunk).toHaveBeenCalledTimes(2); + }); + + it('remembers a key upstream had no value for', async () => { + const { loader, fetchChunk } = setup(); + + await expect(loader.load(['missing-token'])).resolves.toEqual(new Map()); + clock += MISS_TTL - 1; + await expect(loader.load(['missing-token'])).resolves.toEqual(new Map()); + expect(fetchChunk).toHaveBeenCalledTimes(1); + + clock += 1; + await loader.load(['missing-token']); + expect(fetchChunk).toHaveBeenCalledTimes(2); + }); + + it('keeps serving the last value while a refresh fails, then retries after the error TTL', async () => { + const { loader, fetchChunk } = setup(); + await loader.load(['ETH']); + + clock += TTL; + fetchChunk.mockRejectedValueOnce(new Error('network down')); + await expect(loader.load(['ETH'])).resolves.toEqual(new Map([['ETH', 3]])); + expect(fetchChunk).toHaveBeenCalledTimes(2); + + clock += ERROR_TTL - 1; + await loader.load(['ETH']); + expect(fetchChunk).toHaveBeenCalledTimes(2); + + clock += 1; + await loader.load(['ETH']); + expect(fetchChunk).toHaveBeenCalledTimes(3); + }); + + it('drops a value once it is older than the stale limit', async () => { + const { loader, fetchChunk } = setup(); + await loader.load(['ETH']); + + fetchChunk.mockRejectedValue(new Error('network down')); + clock += MAX_STALE + 1; + + await expect(loader.load(['ETH'])).resolves.toEqual(new Map()); + }); + + it('pauses every loader sharing a cooldown after a rate limit', async () => { + const cooldown = { until: 0 }; + const symbols = setup({ cooldown }); + const addresses = setup({ cooldown }); + symbols.fetchChunk.mockRejectedValueOnce(rateLimited); + + await expect(symbols.loader.load(['ETH'])).resolves.toEqual(new Map()); + await expect(addresses.loader.load(['8453:0xabc'])).resolves.toEqual(new Map()); + await symbols.loader.load(['BNB']); + expect(symbols.fetchChunk).toHaveBeenCalledTimes(1); + expect(addresses.fetchChunk).not.toHaveBeenCalled(); + + clock += 60_000; + await addresses.loader.load(['8453:0xabc']); + expect(addresses.fetchChunk).toHaveBeenCalledTimes(1); + }); + + it('never rejects, even when splitting the keys throws', async () => { + const loader = createBatchedLoader({ + chunk: () => { + throw new Error('bad chunking'); + }, + fetchChunk: jest.fn(), + ttlMs: TTL, + missTtlMs: MISS_TTL, + errorTtlMs: ERROR_TTL, + maxStaleMs: MAX_STALE, + now: () => clock, + }); + + await expect(loader.load(['ETH'])).resolves.toEqual(new Map()); + }); + + it('forgets cached values and lifts the pause on clear', async () => { + const cooldown = { until: 0 }; + const { loader, fetchChunk } = setup({ cooldown }); + await loader.load(['ETH']); + cooldown.until = clock + 60_000; + + loader.clear(); + await loader.load(['ETH']); + + expect(cooldown.until).toBe(0); + expect(fetchChunk).toHaveBeenCalledTimes(2); + }); +}); diff --git a/lib/__tests__/refreshAccountQueries.test.ts b/lib/__tests__/refreshAccountQueries.test.ts new file mode 100644 index 000000000..554f2ca4c --- /dev/null +++ b/lib/__tests__/refreshAccountQueries.test.ts @@ -0,0 +1,41 @@ +import { QueryClient, QueryObserver } from '@tanstack/react-query'; + +import { refreshAccountQueries } from '@/lib/refreshAccountQueries'; + +describe('shared manual and live balance invalidation', () => { + it('refreshes wallet, card, savings and status reads without touching another account or unrelated content', async () => { + const client = new QueryClient({ + defaultOptions: { queries: { gcTime: Infinity, retry: false } }, + }); + const keys = [ + ['tokenBalances', '0xABC'], + ['vault', 'balance', '0xabc', 'USDC'], + ['cardStatus', 'account'], + ['cardDetails', 'account'], + ['cardBalance', 'account'], + ['user-transactions', '0xabc', 'usdc'], + ['vaultExchangeRate', 'USDC'], + ['readContract', { functionName: 'balanceOf', args: ['0xabc'] }], + ['readContract', { functionName: 'getRate', address: '0xaccountant' }], + ['cardTransactions'], + ['layerZeroStatus', 'hash'], + ['tokenBalances', '0xDEF'], + ['cardDetails', 'other-account'], + ['whats-new'], + ]; + const fetches = keys.map(() => jest.fn(async () => 'fresh')); + const unsubscribers = keys.map((queryKey, index) => + new QueryObserver(client, { + queryKey, + queryFn: fetches[index], + initialData: 'cached', + staleTime: Infinity, + }).subscribe(() => {}), + ); + await refreshAccountQueries(client, 'account', '0xABC', true); + fetches.slice(0, 11).forEach(fetch => expect(fetch).toHaveBeenCalledTimes(1)); + fetches.slice(11).forEach(fetch => expect(fetch).not.toHaveBeenCalled()); + unsubscribers.forEach(unsubscribe => unsubscribe()); + client.clear(); + }); +}); diff --git a/lib/__tests__/refreshWalletActivity.test.ts b/lib/__tests__/refreshWalletActivity.test.ts new file mode 100644 index 000000000..6ee2c9754 --- /dev/null +++ b/lib/__tests__/refreshWalletActivity.test.ts @@ -0,0 +1,70 @@ +import { fetchActivityEvent, fetchActivityEvents } from '@/lib/api'; +import { refreshWalletActivity } from '@/lib/refreshWalletActivity'; +import { ActivityEvent, TransactionStatus, TransactionType } from '@/lib/types'; +import { useActivityStore } from '@/store/useActivityStore'; + +jest.mock('@/lib/config', () => ({ USER: 'user' })); +jest.mock('@/lib/mmvkStorage', () => ({ + __esModule: true, + default: () => ({ getItem: () => null, setItem: jest.fn() }), +})); +jest.mock('@/lib/utils', () => ({ withRefreshToken: (fn: () => unknown) => fn() })); +jest.mock('@/lib/api', () => ({ fetchActivityEvents: jest.fn(), fetchActivityEvent: jest.fn() })); + +const row = (id: string, status = TransactionStatus.PENDING): ActivityEvent => ({ + clientTxId: id, + type: TransactionType.DEPOSIT, + status, + title: 'Deposit', + amount: '1', + symbol: 'USDC', + timestamp: '123', +}); + +describe('wallet activity reconciliation without live events', () => { + beforeEach(() => { + jest.clearAllMocks(); + useActivityStore.setState({ events: {} }); + }); + it('adds new rows, updates recent rows and completes older processing rows outside page one', async () => { + useActivityStore.setState({ + events: { + account: [ + row('recent'), + row('older', TransactionStatus.PROCESSING), + row('complete', TransactionStatus.SUCCESS), + ], + }, + }); + (fetchActivityEvents as jest.Mock).mockResolvedValue({ + docs: [row('new', TransactionStatus.SUCCESS), row('recent', TransactionStatus.SUCCESS)], + hasNextPage: true, + }); + (fetchActivityEvent as jest.Mock).mockResolvedValue(row('older', TransactionStatus.SUCCESS)); + await refreshWalletActivity('account', '0xABC'); + const rows = useActivityStore.getState().events.account; + expect(rows).toHaveLength(4); + expect(rows.every(event => event.status === TransactionStatus.SUCCESS)).toBe(true); + expect(fetchActivityEvent).toHaveBeenCalledTimes(1); + expect(fetchActivityEvent).toHaveBeenCalledWith('older'); + }); + it('preserves optimistic rows that the server has not indexed yet', async () => { + useActivityStore.setState({ events: { account: [row('optimistic')] } }); + (fetchActivityEvents as jest.Mock).mockResolvedValue({ docs: [], hasNextPage: false }); + (fetchActivityEvent as jest.Mock).mockRejectedValue({ status: 404 }); + await expect(refreshWalletActivity('account', '0xABC')).resolves.toMatchObject({ docs: [] }); + expect(useActivityStore.getState().events.account).toHaveLength(1); + }); + it('surfaces failures while retaining successful row updates', async () => { + useActivityStore.setState({ events: { account: [row('old')] } }); + (fetchActivityEvents as jest.Mock).mockResolvedValue({ + docs: [row('new')], + hasNextPage: false, + }); + (fetchActivityEvent as jest.Mock).mockRejectedValue(new Error('Network failed')); + await expect(refreshWalletActivity('account', '0xABC')).rejects.toThrow('Network failed'); + expect( + useActivityStore.getState().events.account.some(event => event.clientTxId === 'new'), + ).toBe(true); + }); +}); diff --git a/lib/__tests__/tierYieldBoost.test.ts b/lib/__tests__/tierYieldBoost.test.ts new file mode 100644 index 000000000..77d011b5a --- /dev/null +++ b/lib/__tests__/tierYieldBoost.test.ts @@ -0,0 +1,78 @@ +import { + formatTierYieldBoost, + resolveTierYieldBoostRate, + TIER_YIELD_BOOST_RATES, +} from '@/lib/tierYieldBoost'; +import { RewardsTier, TierBenefit, TierBenefits } from '@/lib/types'; + +const BENEFIT: TierBenefit = { title: 'Base yield' }; + +const benefitsFor = (tier: RewardsTier, yieldBoostPercentage?: number): TierBenefits => ({ + tier, + depositBoost: BENEFIT, + yieldBoostPercentage, + cardCashback: BENEFIT, + subscriptionDiscount: null, + cardCashbackCap: BENEFIT, + subscriptionDiscountCap: null, + cardFees: BENEFIT, + bankDeposit: BENEFIT, + swapFees: BENEFIT, + support: BENEFIT, +}); + +/** + * The tier screens used to print their own yield boost constants, which had drifted + * from the figure the rewards home reads off the API, and from each other. Every + * surface resolves through this now, so the live rate is the one answer and the table + * is only what fills the gap before it lands. + */ +describe('resolveTierYieldBoostRate', () => { + it('prefers the rate the tier-benefits endpoint sent', () => { + const benefits = [benefitsFor(RewardsTier.PRIME, 2), benefitsFor(RewardsTier.ULTRA, 7)]; + + expect(resolveTierYieldBoostRate(RewardsTier.ULTRA, benefits)).toBe(7); + expect(resolveTierYieldBoostRate(RewardsTier.PRIME, benefits)).toBe(2); + }); + + it('takes a live zero as an answer rather than falling back', () => { + // A tier whose boost has been switched off in admin config. Reading 0 as "missing" + // would keep advertising a boost that no longer pays out. + const benefits = [benefitsFor(RewardsTier.ULTRA, 0)]; + + expect(resolveTierYieldBoostRate(RewardsTier.ULTRA, benefits)).toBe(0); + }); + + it('falls back to the table while the request is in flight', () => { + expect(resolveTierYieldBoostRate(RewardsTier.ULTRA)).toBe( + TIER_YIELD_BOOST_RATES[RewardsTier.ULTRA], + ); + expect(resolveTierYieldBoostRate(RewardsTier.CORE, [])).toBe(0); + }); + + it('falls back when the backend omits the structured rate', () => { + // An older backend sends `depositBoost` prose and no `yieldBoostPercentage`. + const benefits = [benefitsFor(RewardsTier.ULTRA)]; + + expect(resolveTierYieldBoostRate(RewardsTier.ULTRA, benefits)).toBe( + TIER_YIELD_BOOST_RATES[RewardsTier.ULTRA], + ); + }); + + it('matches the rates the accounts service pays from', () => { + // `YIELD_BOOST_DEFAULTS` in the accounts service, as APYs: tier1 0, tier2 0.02, + // tier3 0.03 — percentage points here, so Core 0, Prime 2, Ultra 3. + expect(TIER_YIELD_BOOST_RATES).toEqual({ + [RewardsTier.CORE]: 0, + [RewardsTier.PRIME]: 2, + [RewardsTier.ULTRA]: 3, + }); + }); +}); + +describe('formatTierYieldBoost', () => { + it('prints the rate the way the tier screens do', () => { + expect(formatTierYieldBoost(3)).toBe('+3%'); + expect(formatTierYieldBoost(0)).toBe('+0%'); + }); +}); diff --git a/lib/activityEvents.ts b/lib/activityEvents.ts new file mode 100644 index 000000000..eb419e168 --- /dev/null +++ b/lib/activityEvents.ts @@ -0,0 +1,23 @@ +import { ActivityEvent } from '@/lib/types'; + +/** Keep REST pages, manual refreshes and live events in the same store format. */ +export function constructActivity(tx: ActivityEvent, safeAddress: string): ActivityEvent { + let clientTxId = `${tx.type}-${tx.timestamp}`; + if ('trackingId' in tx && tx.trackingId) { + clientTxId = tx.trackingId as string; + } else if (tx.clientTxId) { + clientTxId = tx.clientTxId; + } else if (tx.hash) { + clientTxId = `${tx.type}-${tx.hash}`; + } + + return { + ...tx, + clientTxId, + title: tx.title || `${tx.type} Transaction`, + timestamp: tx.timestamp || Math.floor(Date.now() / 1000).toString(), + amount: tx.amount != null ? tx.amount.toString() : '0', + symbol: tx.symbol || 'USDC', + fromAddress: tx.fromAddress || safeAddress, + }; +} diff --git a/lib/api.ts b/lib/api.ts index a34ae020f..7a1d05497 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -7,9 +7,13 @@ import { fuse } from 'viem/chains'; import { ALCHEMY_NETWORKS, ALCHEMY_PRICE_BATCH_SIZE, + ALCHEMY_PRICE_MAX_NETWORKS, ALCHEMY_PRICES_URL, + ALCHEMY_REQUEST_TIMEOUT_MS, + BACKEND_PRICE_CHAIN_IDS, } from '@/constants/alchemy'; import { MOCK_REWARDS_USER_DATA, MOCK_TIER_BENEFITS } from '@/constants/rewards'; +import { Cooldown, createBatchedLoader } from '@/lib/batchedLoader'; import { fetchTokenTransferWithFallback } from '@/lib/data-source'; import { fetchWithTimeout } from '@/lib/fetchWithTimeout'; import { toTransfiError } from '@/lib/transfiErrors'; @@ -74,6 +78,7 @@ import { CustomerFromBridgeResponse, Deposit, DepositAssetsResponse, + DepositFeeQuote, DepositTransaction, DetectedDirectDepositResponse, DiditSessionResponse, @@ -452,74 +457,281 @@ export const fetchTokenTransfer = async ({ }); }; -export const fetchTokenPriceUsd = async (token: string) => { +/** + * How long a price from Alchemy is shared by every caller before it's asked for + * again. Alchemy refreshes its prices about once a minute. + */ +const ALCHEMY_PRICE_TTL_MS = 60_000; +/** A token Alchemy has no price for rarely gains one within minutes. */ +const ALCHEMY_PRICE_MISS_TTL_MS = 5 * 60_000; +const ALCHEMY_PRICE_ERROR_TTL_MS = 15_000; +/** Past this a price is dropped rather than shown, even when it can't be refreshed. */ +const ALCHEMY_PRICE_MAX_STALE_MS = 10 * 60_000; +/** Pause on a 429 without a usable Retry-After, and the cap on one that has it. */ +const ALCHEMY_PRICE_RATE_LIMIT_PAUSE_MS = 60_000; +const ALCHEMY_PRICE_MAX_RATE_LIMIT_PAUSE_MS = 15 * 60_000; + +/** The USD quote from a Prices API entry, when it's a usable positive number. */ +const usdPrice = (prices: { currency: string; value: string }[] | undefined) => { + const value = Number(prices?.find(price => price.currency?.toLowerCase() === 'usd')?.value); + return Number.isFinite(value) && value > 0 ? value : undefined; +}; + +/** + * Both Prices endpoints draw on one token_price quota ("Your payg app has + * exceeded its limit of 10000 token_price requests per 1 hours"), so a 429 from + * either pauses both. Every request made while the quota is spent is refused + * anyway, and each refusal was also being reported to Sentry. + */ +const alchemyPriceCooldown: Cooldown = { until: 0 }; + +const rateLimitPauseMs = (error: unknown) => { + const response = (error as { response?: { status?: number; headers?: Record } }) + ?.response; + if (response?.status !== 429) return undefined; + const retryAfterMs = Number(response.headers?.['retry-after']) * 1000; + return Number.isFinite(retryAfterMs) && retryAfterMs > 0 + ? Math.min(retryAfterMs, ALCHEMY_PRICE_MAX_RATE_LIMIT_PAUSE_MS) + : ALCHEMY_PRICE_RATE_LIMIT_PAUSE_MS; +}; + +const alchemyPriceCaching = { + ttlMs: ALCHEMY_PRICE_TTL_MS, + missTtlMs: ALCHEMY_PRICE_MISS_TTL_MS, + errorTtlMs: ALCHEMY_PRICE_ERROR_TTL_MS, + maxStaleMs: ALCHEMY_PRICE_MAX_STALE_MS, + cooldownMs: rateLimitPauseMs, + cooldown: alchemyPriceCooldown, +}; + +/** Longer than the backend's own Alchemy timeout (4s), so it gives up first. */ +const BACKEND_PRICES_TIMEOUT_MS = 6_000; +const BACKEND_PRICES_RETRY_MS = 5 * 60_000; +/** The route's per-list limits; anything past them would fail the whole request. */ +const BACKEND_PRICES_MAX_SYMBOL_LENGTH = 64; +const ADDRESS_PATTERN = /^0x[0-9a-fA-F]{40}$/; +let backendPricesPausedUntil = 0; + +type BackendPrices = { symbols: Record; tokens: Record }; + +const positivePrices = (value: unknown): Record | undefined => { + if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; + return Object.fromEntries( + Object.entries(value).filter( + (entry): entry is [string, number] => + typeof entry[1] === 'number' && Number.isFinite(entry[1]) && entry[1] > 0, + ), + ); +}; + +/** + * Prices from the backend, or undefined when it couldn't answer. Never throws. + * + * The backend serves prices from one cache shared by every user + * (POST /accounts/v1/prices), so the app asks it first and every open copy no + * longer spends the app key's Alchemy quota. When it can't answer — a backend + * released before the route existed, an outage, an expired session, a token it + * can't price right now (503) — the app asks Alchemy itself as before, and + * skips the backend for a few minutes so each lookup doesn't wait on it first. + */ +const fetchBackendPrices = async (body: { + symbols?: string[]; + tokens?: { chainId: number; address: string }[]; +}): Promise => { + if (Date.now() < backendPricesPausedUntil) return undefined; + try { + const jwt = getJWTToken(); + const response = await fetchWithTimeout( + `${EXPO_PUBLIC_FLASH_API_BASE_URL}/accounts/v1/prices`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...getPlatformHeaders(), + ...(jwt ? { Authorization: `Bearer ${jwt}` } : {}), + }, + credentials: 'include', + body: JSON.stringify(body), + }, + BACKEND_PRICES_TIMEOUT_MS, + ); + if (!response.ok) throw new Error(`Backend prices responded ${response.status}`); + const data = (await response.json()) as { symbols?: unknown; tokens?: unknown }; + const symbols = positivePrices(data?.symbols); + const tokens = positivePrices(data?.tokens); + // Anything but the expected shape is a failure, not "no prices": a miss is + // remembered for minutes, so a misrouted reply mustn't be taken for one. + if (!symbols || !tokens) throw new Error('Unexpected backend prices response'); + return { symbols, tokens }; + } catch { + backendPricesPausedUntil = Date.now() + BACKEND_PRICES_RETRY_MS; + return undefined; + } +}; + +const fetchAlchemyPricesBySymbol = async (symbols: string[]) => { // externalAxios (not the global axios): Alchemy 401s when the Solid JWT is // attached, which zeroes out every price on native builds. const response = await externalAxios.get( - `${ALCHEMY_PRICES_URL}/by-symbol?symbols=${token}`, + `${ALCHEMY_PRICES_URL}/by-symbol?${symbols.map(s => `symbols=${encodeURIComponent(s)}`).join('&')}`, + { timeout: ALCHEMY_REQUEST_TIMEOUT_MS }, ); - return response?.data?.data[0]?.prices[0]?.value; + const prices = new Map(); + for (const entry of response.data?.data ?? []) { + const price = usdPrice(entry.prices); + if (entry.symbol && price !== undefined) prices.set(entry.symbol.toUpperCase(), price); + } + return prices; +}; + +/** Keyed by upper-cased symbol: Alchemy upper-cases the symbols it echoes back. */ +const alchemyPricesBySymbol = createBatchedLoader({ + ...alchemyPriceCaching, + chunk: symbols => { + const chunks: string[][] = []; + for (let i = 0; i < symbols.length; i += ALCHEMY_PRICE_BATCH_SIZE) { + chunks.push(symbols.slice(i, i + ALCHEMY_PRICE_BATCH_SIZE)); + } + return chunks; + }, + fetchChunk: async symbols => { + // A symbol past the route's limit isn't a real ticker; leaving it out keeps + // it from failing the lookup for the others. + const sendable = symbols.filter(s => s.length <= BACKEND_PRICES_MAX_SYMBOL_LENGTH); + const fromBackend = sendable.length + ? await fetchBackendPrices({ symbols: sendable }) + : undefined; + if (fromBackend) { + return new Map( + Object.entries(fromBackend.symbols).map(([symbol, price]) => [symbol.toUpperCase(), price]), + ); + } + return fetchAlchemyPricesBySymbol(symbols); + }, +}); + +/** Keyed by `${chainId}:${lowercased address}`. */ +const alchemyPricesByAddress = createBatchedLoader({ + ...alchemyPriceCaching, + // First fit, so tokens keep their order and each request stays within both + // the address and the network cap. Tokens on chains the backend doesn't cover + // never share a request with ones it does, so they can go to Alchemy alone. + chunk: keys => { + const chunks: { keys: string[]; networks: Set; viaBackend: boolean }[] = []; + for (const key of keys) { + const chainId = Number(key.split(':')[0]); + const network = ALCHEMY_NETWORKS[chainId]; + const viaBackend = BACKEND_PRICE_CHAIN_IDS.has(chainId); + const chunk = chunks.find( + c => + c.viaBackend === viaBackend && + c.keys.length < ALCHEMY_PRICE_BATCH_SIZE && + (c.networks.has(network) || c.networks.size < ALCHEMY_PRICE_MAX_NETWORKS), + ); + if (chunk) { + chunk.keys.push(key); + chunk.networks.add(network); + } else { + chunks.push({ keys: [key], networks: new Set([network]), viaBackend }); + } + } + return chunks.map(c => c.keys); + }, + fetchChunk: async keys => { + const tokens = keys.map(key => { + const [chainId, address] = key.split(':'); + return { chainId: Number(chainId), network: ALCHEMY_NETWORKS[Number(chainId)], address }; + }); + + const viaBackend = tokens.every(({ chainId }) => BACKEND_PRICE_CHAIN_IDS.has(chainId)); + const sendable = viaBackend + ? tokens + .filter(({ address }) => ADDRESS_PATTERN.test(address)) + .map(({ chainId, address }) => ({ chainId, address })) + : []; + const fromBackend = sendable.length + ? await fetchBackendPrices({ tokens: sendable }) + : undefined; + // Keyed `${chainId}:${lowercased address}` by the backend too. + if (fromBackend) return new Map(Object.entries(fromBackend.tokens)); + + const response = await externalAxios.post( + `${ALCHEMY_PRICES_URL}/by-address`, + { addresses: tokens.map(({ network, address }) => ({ network, address })) }, + { timeout: ALCHEMY_REQUEST_TIMEOUT_MS }, + ); + // Alchemy echoes the network slug back, not the chain id, so map the + // response entries onto the request to recover the chain id. + const chainIdByNetwork = new Map(tokens.map(({ network, chainId }) => [network, chainId])); + const prices = new Map(); + for (const entry of response.data?.data ?? []) { + const chainId = chainIdByNetwork.get(entry.network); + const price = usdPrice(entry.prices); + if (chainId === undefined || !entry.address || price === undefined) continue; + prices.set(`${chainId}:${entry.address.toLowerCase()}`, price); + } + return prices; + }, +}); + +/** Drops every cached price and lifts any pause on the backend or on Alchemy. */ +export const clearAlchemyPriceCache = () => { + alchemyPricesBySymbol.clear(); + alchemyPricesByAddress.clear(); + backendPricesPausedUntil = 0; +}; + +/** + * USD prices by symbol from Alchemy's Prices API, keyed by the symbols as passed. + * + * Lookups started in the same tick share requests of up to 25 symbols, and a + * price is reused by every caller for a minute. Never throws: a symbol without + * a price, or one Alchemy couldn't be asked about (rate-limited, down), is left + * out so the caller's next price source gets its turn. + */ +export const fetchTokenPricesBySymbol = async ( + symbols: string[], +): Promise> => { + const wanted = [...new Set(symbols.filter(Boolean))]; + if (wanted.length === 0) return {}; + const prices = await alchemyPricesBySymbol.load(wanted.map(symbol => symbol.toUpperCase())); + const bySymbol: Record = {}; + for (const symbol of wanted) { + const price = prices.get(symbol.toUpperCase()); + if (price !== undefined) bySymbol[symbol] = price; + } + return bySymbol; +}; + +/** USD price of one symbol, as a string; undefined when Alchemy has none to give. */ +export const fetchTokenPriceUsd = async (token: string): Promise => { + const price = (await fetchTokenPricesBySymbol([token]))[token]; + return price === undefined ? undefined : String(price); }; /** * USD prices for ERC-20s from Alchemy's Prices API, keyed by * `${chainId}:${lowercased address}`. * - * Preferred over {@link fetchTokenPriceUsd} for ERC-20s: a contract address - * identifies a token exactly, where a symbol does not (every chain has its own - * "USDC", and plenty of scam tokens borrow a real ticker), and one POST covers - * a whole batch instead of a request per symbol. Alchemy's own token balances - * carry no price, so without this every Alchemy-sourced ERC-20 arrives at - * quoteRate 0. + * Preferred over {@link fetchTokenPricesBySymbol} for ERC-20s: a contract + * address identifies a token exactly, where a symbol does not (every chain has + * its own "USDC", and plenty of scam tokens borrow a real ticker). Alchemy's + * own token balances carry no price, so without this every Alchemy-sourced + * ERC-20 arrives at quoteRate 0. * - * Never throws: a failed batch resolves to no prices for that batch so the - * remaining price sources still get their turn. + * Batched and cached like the symbol lookup, within the endpoint's limits of + * 25 addresses and 3 networks per request. Never throws: a failed batch + * resolves to no prices for that batch so the remaining price sources still + * get their turn. */ export const fetchTokenPricesByAddress = async ( tokens: { chainId: number; address: string }[], ): Promise> => { - const pairs = [ - ...new Map( - tokens - .filter(({ chainId, address }) => !!ALCHEMY_NETWORKS[chainId] && !!address) - .map(({ chainId, address }) => [ - `${chainId}:${address.toLowerCase()}`, - { chainId, network: ALCHEMY_NETWORKS[chainId], address: address.toLowerCase() }, - ]), - ).values(), - ]; - if (pairs.length === 0) return {}; - - const batches: (typeof pairs)[] = []; - for (let i = 0; i < pairs.length; i += ALCHEMY_PRICE_BATCH_SIZE) { - batches.push(pairs.slice(i, i + ALCHEMY_PRICE_BATCH_SIZE)); - } - - const responses = await Promise.allSettled( - batches.map(batch => - externalAxios.post(`${ALCHEMY_PRICES_URL}/by-address`, { - addresses: batch.map(({ network, address }) => ({ network, address })), - }), - ), - ); - - const prices: Record = {}; - responses.forEach((response, i) => { - if (response.status !== 'fulfilled') return; - // Alchemy echoes the network slug back, not the chain id, so map the - // response entries onto the batch we sent to recover the chain id. - const chainIdByNetwork = new Map(batches[i].map(({ network, chainId }) => [network, chainId])); - for (const entry of response.value.data?.data ?? []) { - const chainId = chainIdByNetwork.get(entry.network); - const value = Number(entry.prices?.find(price => price.currency === 'usd')?.value); - if (chainId === undefined || !entry.address || !Number.isFinite(value) || value <= 0) { - continue; - } - prices[`${chainId}:${entry.address.toLowerCase()}`] = value; - } - }); - - return prices; + const keys = tokens + .filter(({ chainId, address }) => !!ALCHEMY_NETWORKS[chainId] && !!address) + .map(({ chainId, address }) => `${chainId}:${address.toLowerCase()}`); + if (keys.length === 0) return {}; + return Object.fromEntries(await alchemyPricesByAddress.load(keys)); }; export const createKycLink = async ( @@ -3888,6 +4100,41 @@ export const getDetectedDirectDeposit = async ( return response.json(); }; +/** + * The deposit bridge fee a deposit to this destination would pay, without an + * amount: whether it applies on this chain, and at what rate. The rate is the + * one set for the route and chain on the admin Deposit fees page, or the 0.03% + * default where none is set. + */ +export const getDepositFeeQuote = async ( + destinationType: 'PROTOCOL' | 'RAIN_CARD', + chainId: number, + tokenSymbol: string, +): Promise => { + const jwt = getJWTToken(); + + const params = new URLSearchParams({ + destinationType, + chainId: String(chainId), + tokenSymbol, + }); + + const response = await fetch( + `${EXPO_PUBLIC_FLASH_API_BASE_URL}/accounts/v1/deposit/fee-quote?${params}`, + { + headers: { + ...getPlatformHeaders(), + ...(jwt ? { Authorization: `Bearer ${jwt}` } : {}), + }, + credentials: 'include', + }, + ); + + if (!response.ok) throw response; + + return response.json(); +}; + export const getDirectDepositSession = async ( sessionId: string, ): Promise => { @@ -4009,7 +4256,24 @@ export const startPasskeyRecovery = async (username: string, targetPublicKey: st * Step 1: Initiate OTP for passkey recovery (public - no auth required) * Sends OTP to user's registered email */ -export const initRecoveryOtp = async (email: string): Promise<{ otpId: string }> => { +/** + * Step 1: Ask for a recovery code. + * + * `identifier` is an email or a username. `email` is sent alongside it only + * when the identifier is an address, so a backend that predates the username + * path still reads the field it knows. + */ +export const initRecoveryOtp = async ( + identifier: string, +): Promise<{ + otpId: string; + /** + * Masked address the code went to, e.g. `o•••••@gmail.com`. Absent from an + * older backend; the caller falls back to what the user typed. + */ + emailHint?: string; +}> => { + const trimmed = identifier.trim(); const response = await fetch( `${EXPO_PUBLIC_FLASH_API_BASE_URL}/accounts/v1/auths/init-recovery-otp`, { @@ -4018,7 +4282,10 @@ export const initRecoveryOtp = async (email: string): Promise<{ otpId: string }> 'Content-Type': 'application/json', ...getPlatformHeaders(), }, - body: JSON.stringify({ email }), + body: JSON.stringify({ + identifier: trimmed, + ...(trimmed.includes('@') && { email: trimmed }), + }), }, ); const data = await response.json(); @@ -4033,7 +4300,12 @@ export const initRecoveryOtp = async (email: string): Promise<{ otpId: string }> export const verifyRecoveryOtp = async ( otpId: string, otpCode: string, - email: string, + /** + * The address the code went to, when this recovery started from one. Omitted + * for a username recovery, which never learns it — the backend resolves the + * account from the address the challenge was issued against instead. + */ + email: string | undefined, publicKey: string, ): Promise<{ credentialBundle: string; @@ -4054,7 +4326,7 @@ export const verifyRecoveryOtp = async ( 'Content-Type': 'application/json', ...getPlatformHeaders(), }, - body: JSON.stringify({ otpId, otpCode, email, publicKey }), + body: JSON.stringify({ otpId, otpCode, ...(email && { email }), publicKey }), }, ); const data = await response.json(); diff --git a/lib/assets.ts b/lib/assets.ts index 41cad9aaf..ef110c5f3 100644 --- a/lib/assets.ts +++ b/lib/assets.ts @@ -956,6 +956,22 @@ export const ASSETS = { module: require('@/assets/images/rewards-tiers/ultra-tier-sparkle.png'), hash: 'a06bb5c5', }, + 'images/rewards-tiers/upgrade-card-cap.svg': { + module: require('@/assets/images/rewards-tiers/upgrade-card-cap.svg'), + hash: 'e469f639', + }, + 'images/rewards-tiers/upgrade-card-star.svg': { + module: require('@/assets/images/rewards-tiers/upgrade-card-star.svg'), + hash: '741a79ad', + }, + 'images/rewards-tiers/upgrade-card-subscription.svg': { + module: require('@/assets/images/rewards-tiers/upgrade-card-subscription.svg'), + hash: '2cd5b3b6', + }, + 'images/rewards-tiers/upgrade-card-yield.svg': { + module: require('@/assets/images/rewards-tiers/upgrade-card-yield.svg'), + hash: '0bbf9dce', + }, 'images/rewards-tiers/v4/airline.svg': { module: require('@/assets/images/rewards-tiers/v4/airline.svg'), hash: '7670516b', @@ -1199,6 +1215,14 @@ export const ASSETS = { hash: '13207041', }, 'images/sousd-4x.png': { module: require('@/assets/images/sousd-4x.png'), hash: 'bf43c3e5' }, + 'images/spend-mode-credit-notice-badge.svg': { + module: require('@/assets/images/spend-mode-credit-notice-badge.svg'), + hash: '04ab236e', + }, + 'images/spend-mode-credit-notice-bg.png': { + module: require('@/assets/images/spend-mode-credit-notice-bg.png'), + hash: '37d02537', + }, 'images/spend-yellow.png': { module: require('@/assets/images/spend-yellow.png'), hash: '98629c6c', diff --git a/lib/batchedLoader.ts b/lib/batchedLoader.ts new file mode 100644 index 000000000..dabf07701 --- /dev/null +++ b/lib/batchedLoader.ts @@ -0,0 +1,128 @@ +/** + * Merges lookups started in the same tick into as few upstream requests as the + * API allows, and caches what comes back so repeat lookups stay local. + * + * Built for Alchemy's Prices API, whose quota is counted in requests — 10,000 + * an hour per app on Pay As You Go, shared by every client of the app — while a + * single request carries up to 25 tokens at the same 40 CU. Asking for one + * price per request with nothing kept spent that quota several times over on + * every balance refresh, and 429'd everyone once it ran out. + */ + +type Entry = { + /** Last value upstream returned. Kept past its TTL, up to `maxStaleMs`. */ + value?: V; + /** When `value` was fetched. */ + fetchedAt?: number; + /** Until then the key is answered from the cache without asking upstream. */ + freshUntil: number; +}; + +/** A pause on upstream calls, shareable between loaders drawing on one quota. */ +export type Cooldown = { until: number }; + +export type BatchedLoaderOptions = { + /** Splits one flush's keys into groups that each fit in a single request. */ + chunk: (keys: string[]) => string[][]; + /** Fetches one group. Keys absent from the result have no value upstream. */ + fetchChunk: (keys: string[]) => Promise>; + /** How long a fetched value is reused before asking upstream again. */ + ttlMs: number; + /** How long to wait before asking again about a key upstream had no value for. */ + missTtlMs: number; + /** How long to wait before retrying keys whose request failed. */ + errorTtlMs: number; + /** How long a value stays usable past its TTL when refreshing it fails or is skipped. */ + maxStaleMs: number; + /** + * How long a failure should stop every upstream call, if at all. Meant for + * rate limits: once the quota is spent, each further request until it refills + * is refused anyway. + */ + cooldownMs?: (error: unknown) => number | undefined; + /** Pass the same object to loaders that share a quota so one 429 pauses them all. */ + cooldown?: Cooldown; + now?: () => number; +}; + +export type BatchedLoader = { + /** Values for the keys that have one. Never rejects. */ + load: (keys: string[]) => Promise>; + /** Forgets every cached value and lifts any pause. */ + clear: () => void; +}; + +export const createBatchedLoader = (options: BatchedLoaderOptions): BatchedLoader => { + const now = options.now ?? (() => Date.now()); + const cooldown = options.cooldown ?? { until: 0 }; + const cache = new Map>(); + // Keys queued for, or waiting on, a request that hasn't settled yet. + const pending = new Map>(); + let queue: string[] = []; + let scheduled: Promise | undefined; + + const settle = (keys: string[], freshForMs: number, values?: Map) => { + const at = now(); + for (const key of keys) { + const value = values?.get(key); + cache.set( + key, + value === undefined + ? { ...cache.get(key), freshUntil: at + freshForMs } + : { value, fetchedAt: at, freshUntil: at + options.ttlMs }, + ); + } + }; + + const fetchGroup = async (keys: string[]) => { + try { + settle(keys, options.missTtlMs, await options.fetchChunk(keys)); + } catch (error) { + const pauseMs = options.cooldownMs?.(error); + if (pauseMs) cooldown.until = Math.max(cooldown.until, now() + pauseMs); + settle(keys, options.errorTtlMs); + } + }; + + const flush = async () => { + const keys = queue; + queue = []; + scheduled = undefined; + try { + await Promise.all(options.chunk(keys).map(fetchGroup)); + } finally { + for (const key of keys) pending.delete(key); + } + }; + + const load = async (keys: string[]) => { + const unique = [...new Set(keys)]; + const at = now(); + for (const key of unique) { + if (pending.has(key) || at < cooldown.until) continue; + if ((cache.get(key)?.freshUntil ?? 0) > at) continue; + queue.push(key); + // Wait out the current tick so every lookup started in it rides one flush. + scheduled ??= new Promise(resolve => setTimeout(resolve, 0)).then(flush); + pending.set(key, scheduled); + } + await Promise.allSettled(unique.map(key => pending.get(key))); + + const values = new Map(); + const readAt = now(); + for (const key of unique) { + const entry = cache.get(key); + if (entry?.value !== undefined && readAt - (entry.fetchedAt ?? 0) <= options.maxStaleMs) { + values.set(key, entry.value); + } + } + return values; + }; + + const clear = () => { + cache.clear(); + cooldown.until = 0; + }; + + return { load, clear }; +}; diff --git a/lib/config.ts b/lib/config.ts index 800ad2327..7dab7a95d 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -1,6 +1,8 @@ import { Address } from 'viem'; import { mainnet } from 'viem/chains'; +import { parseRetiredCashModulesV2 } from '@/lib/utils/safeModules'; + import type { CardProvider } from '@/lib/types'; export const EXPO_PUBLIC_BASE_URL = process.env.EXPO_PUBLIC_BASE_URL ?? ''; @@ -9,6 +11,9 @@ export const EXPO_PUBLIC_CARD_ISSUER = process.env.EXPO_PUBLIC_CARD_ISSUER as | undefined; export const EXPO_PUBLIC_ALCHEMY_API_KEY = process.env.EXPO_PUBLIC_ALCHEMY_API_KEY ?? ''; export const EXPO_PUBLIC_PIMLICO_API_KEY = process.env.EXPO_PUBLIC_PIMLICO_API_KEY ?? ''; +// Pimlico sponsorship policy attached to every userop. Empty means no policy is sent. +export const EXPO_PUBLIC_PIMLICO_SPONSORSHIP_POLICY_ID = + process.env.EXPO_PUBLIC_PIMLICO_SPONSORSHIP_POLICY_ID ?? ''; export const EXPO_PUBLIC_FLASH_API_BASE_URL = process.env.EXPO_PUBLIC_FLASH_API_BASE_URL ?? ''; export const EXPO_PUBLIC_FLASH_ANALYTICS_API_BASE_URL = process.env.EXPO_PUBLIC_FLASH_ANALYTICS_API_BASE_URL ?? ''; @@ -176,6 +181,15 @@ type Addresses = { * operation, and it only happens when a cardholder first chooses a mode v1 cannot serve. */ cashModuleV2: Address; + /** + * Earlier SolidCashModuleV2 cores that {@link cashModuleV2} replaced. + * + * A redeploy cannot move a Safe: the old core stays enabled on every Safe that consented to + * it, and the backend no longer reads it — so a Safe left there declines every card payment + * with `SAFE_NOT_REGISTERED` while the old lens still reports it as set up. Every write that + * puts a Safe on v2 disables whichever of these it still has enabled, in the same batch. + */ + retiredCashModulesV2: readonly Address[]; /** * SolidSpendLens — the cohort-aware read serving BOTH module generations from one call. * @@ -266,6 +280,10 @@ export const ADDRESSES: Addresses = { // `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, + retiredCashModulesV2: parseRetiredCashModulesV2( + process.env.EXPO_PUBLIC_RETIRED_CASH_MODULE_V2_ADDRESSES, + process.env.EXPO_PUBLIC_CASH_MODULE_V2_ADDRESS, + ), spendLensV2: (process.env.EXPO_PUBLIC_SPEND_LENS_V2_ADDRESS ?? ZERO_ADDRESS) as Address, fastWithdrawManager: '0x0bA17eab7B6B2353eA4731c37A2cBA2a5AA4Ea1b', stargateOftUSDC: '0xAF54BE5B6eEc24d6BFACf1cce4eaF680A8239398', diff --git a/lib/refreshAccountQueries.ts b/lib/refreshAccountQueries.ts new file mode 100644 index 000000000..a30e51e5e --- /dev/null +++ b/lib/refreshAccountQueries.ts @@ -0,0 +1,63 @@ +import { Query, QueryClient } from '@tanstack/react-query'; + +const USER_QUERY_ROOTS = new Set([ + 'tokenBalances', + 'vault', + 'user-transactions', + 'cardStatus', + 'cardDetails', + 'cardBalance', + 'cardSpendRegistration', + 'cardSpendModeAccess', +]); +const SHARED_QUERY_ROOTS = new Set([ + 'vaultExchangeRate', + 'ethPriceUsd', + 'fusePriceUsd', + 'cardTransactions', + 'card-transactions-poller', + 'layerZeroStatus', + 'tx-receipt-poll', +]); + +/** Shared by manual refresh and balance events; leave other accounts' caches alone. */ +export async function refreshAccountQueries( + queryClient: QueryClient, + userId: string, + safeAddress: string, + throwOnError = false, +) { + const address = safeAddress.toLowerCase(); + const predicate = (query: Query) => { + const [root, ...parts] = query.queryKey; + if (SHARED_QUERY_ROOTS.has(String(root))) return true; + if (USER_QUERY_ROOTS.has(String(root))) { + return parts.some( + part => typeof part === 'string' && (part === userId || part.toLowerCase() === address), + ); + } + // Savings rates and balances can be backed by wagmi's inner contract cache. + if (root === 'readContract') { + const options = parts[0] as { functionName?: string; args?: unknown[] } | undefined; + return ( + options?.functionName === 'getRate' || + !!options?.args?.some(arg => typeof arg === 'string' && arg.toLowerCase() === address) + ); + } + return false; + }; + + const queries = queryClient.getQueryCache().findAll({ predicate }); + // Mark inner contract reads stale before outer savings queries read them. + await queryClient.invalidateQueries({ predicate, refetchType: 'none' }); + const results = await Promise.allSettled( + queries.map(query => + queryClient.refetchQueries( + { queryKey: query.queryKey, exact: true, type: 'active' }, + { cancelRefetch: false, throwOnError }, + ), + ), + ); + const failure = results.find(result => result.status === 'rejected'); + if (failure?.status === 'rejected') throw failure.reason; +} diff --git a/lib/refreshWalletActivity.ts b/lib/refreshWalletActivity.ts new file mode 100644 index 000000000..224e6500c --- /dev/null +++ b/lib/refreshWalletActivity.ts @@ -0,0 +1,54 @@ +import { constructActivity } from '@/lib/activityEvents'; +import { fetchActivityEvent, fetchActivityEvents } from '@/lib/api'; +import { TransactionStatus } from '@/lib/types'; +import { withRefreshToken } from '@/lib/utils'; +import { useActivityStore } from '@/store/useActivityStore'; + +/** Reconcile missed live events, including pending rows older than the first page. */ +export async function refreshWalletActivity( + userId: string, + safeAddress: string, + isSelected = () => true, +) { + const page = await withRefreshToken(() => fetchActivityEvents(1)); + if (!page) throw new Error('Activity refresh returned no data'); + if (!isSelected()) return page; + const store = useActivityStore.getState(); + const events = page.docs.filter(Boolean).map(tx => constructActivity(tx, safeAddress)); + store.bulkUpsertEvent(userId, events); + + const latestIds = new Set(events.map(event => event.clientTxId)); + const outstanding = (store.events[userId] ?? []).filter( + event => + event && + !event.deleted && + !latestIds.has(event.clientTxId) && + [ + TransactionStatus.PENDING, + TransactionStatus.DETECTED, + TransactionStatus.PROCESSING, + ].includes(event.status), + ); + const failures: unknown[] = []; + // Bound concurrency for accounts with many historical pending transactions. + for (let offset = 0; offset < outstanding.length; offset += 4) { + const results = await Promise.allSettled( + outstanding.slice(offset, offset + 4).map(async event => { + if (!isSelected()) return; + try { + const fresh = await withRefreshToken(() => fetchActivityEvent(event.clientTxId)); + if (fresh && isSelected()) + store.upsertEvent(userId, constructActivity(fresh, safeAddress)); + } catch (error) { + // A local optimistic row may not have reached the server yet. + if ((error as { status?: number })?.status !== 404) throw error; + } + }), + ); + for (const result of results) { + if (result.status === 'rejected') failures.push(result.reason); + } + } + if (failures.length) throw failures[0]; + return page; +} diff --git a/lib/tierYieldBoost.ts b/lib/tierYieldBoost.ts new file mode 100644 index 000000000..f1f4610aa --- /dev/null +++ b/lib/tierYieldBoost.ts @@ -0,0 +1,46 @@ +import { RewardsTier, TierBenefits } from '@/lib/types'; + +/** + * Extra APY each tier adds on top of the base savings yield, in percentage + * points — the app's fallback copy of what the accounts service pays from: + * `YIELD_BOOST_DEFAULTS` in `app-config.constants.ts`, as the rewards service + * reads it through `yieldBoostForTier` and `grantedBoostPercentage` (an APY of + * 0.03 is the 3 quoted here). + * + * A fallback, not a second source of truth: every surface here prefers the + * figure the tier-benefits endpoint sends and only falls back to this when the + * request has not landed, or when the build is talking to a backend that does + * not send `yieldBoostPercentage` at all. + * + * Kept because the tier comparison screen is reachable before that request + * resolves, and a tier's headline benefit rendering as "+0%" for a beat reads + * as a worse promise than the one being sold. Whenever this does disagree with + * the backend, the backend is right — it is what actually pays out. + */ +export const TIER_YIELD_BOOST_RATES: Record = { + [RewardsTier.CORE]: 0, + [RewardsTier.PRIME]: 2, + [RewardsTier.ULTRA]: 3, +}; + +/** + * The boost a tier advertises, in percentage points. + * + * The live figure wins whenever the endpoint sent a usable one, so a rate + * changed in admin config reaches the screens without a release. A tier that + * genuinely grants no boost (Core) sends 0, which is a real answer rather than + * a missing one — hence the explicit `Number.isFinite` check rather than a + * truthiness test, which would quietly swap Core's 0 for the table's 0 and, + * more importantly, would hide a backend that had switched a tier's boost off. + */ +export const resolveTierYieldBoostRate = ( + tier: RewardsTier, + tierBenefits?: TierBenefits[], +): number => { + const apiRate = tierBenefits?.find(entry => entry.tier === tier)?.yieldBoostPercentage; + if (typeof apiRate === 'number' && Number.isFinite(apiRate)) return apiRate; + return TIER_YIELD_BOOST_RATES[tier] ?? 0; +}; + +/** "+3%" — the boost as the tier screens print it. */ +export const formatTierYieldBoost = (rate: number): string => `+${rate}%`; diff --git a/lib/types.ts b/lib/types.ts index 795639f36..6ec862deb 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -83,6 +83,21 @@ export interface DetectedDirectDepositResponse { detectedAt?: string; } +/** + * What `/deposit/fee-quote` says a deposit would pay, from the same assessment + * the deposit workflows charge with. + */ +export interface DepositFeeQuote { + /** Whether the deposit bridge fee is taken on this deposit. */ + applies: boolean; + /** Why it is or is not (a backend `DepositFeeReason`). */ + reason: string; + /** The rate, in parts per million (0.03% = 300). 0 when free. */ + ratePpm: number; + /** Chain the deposit is delivered on; deposits sent from it are free. */ + deliveryChainId: number; +} + /** One (chain, token) the deposit pipeline credits, as `/deposit/assets` gives it. */ export interface DepositAsset { chainId: number; diff --git a/lib/utils/__tests__/depositFee.test.ts b/lib/utils/__tests__/depositFee.test.ts index 251662199..db32e6191 100644 --- a/lib/utils/__tests__/depositFee.test.ts +++ b/lib/utils/__tests__/depositFee.test.ts @@ -1,7 +1,13 @@ import { arbitrum, base, bsc, fuse, mainnet, polygon } from 'viem/chains'; import { CardProvider } from '@/lib/types'; -import { DEPOSIT_FEE_BPS, formatDepositFeePercent, getDepositFeeBps } from '@/lib/utils/depositFee'; +import { + DEFAULT_DEPOSIT_FEE_RATE_PPM, + formatDepositFeePercent, + getDepositFeeDestinationType, + getDepositFeeRatePpm, + resolveDepositFeeRatePpm, +} from '@/lib/utils/depositFee'; /** Every chain the deposit screens offer. */ const CHAIN_IDS = [mainnet.id, polygon.id, base.id, arbitrum.id, bsc.id, fuse.id]; @@ -13,9 +19,9 @@ const NON_STABLECOINS = ['ETH', 'WETH', 'FUSE', 'WFUSE']; /** The chains a rule is free on, and the fee it charges on every other one. */ const expectFreeOnlyOn = (freeChainIds: number[], feeFor: (chainId: number) => number) => { for (const chainId of CHAIN_IDS) { - expect({ chainId, bps: feeFor(chainId) }).toEqual({ + expect({ chainId, ratePpm: feeFor(chainId) }).toEqual({ chainId, - bps: freeChainIds.includes(chainId) ? 0 : DEPOSIT_FEE_BPS, + ratePpm: freeChainIds.includes(chainId) ? 0 : DEFAULT_DEPOSIT_FEE_RATE_PPM, }); } }; @@ -27,17 +33,17 @@ const NO_CARD: [string, CardProvider | null | undefined][] = [ ['a Bridge card', CardProvider.BRIDGE], ]; -describe('getDepositFeeBps', () => { +describe('getDepositFeeRatePpm', () => { describe('"Fund your card"', () => { it.each(STABLECOINS)('charges a Rain card %s deposit everywhere but Base', symbol => { expectFreeOnlyOn([base.id], chainId => - getDepositFeeBps({ provider: CardProvider.RAIN, product: 'card', chainId, symbol }), + getDepositFeeRatePpm({ provider: CardProvider.RAIN, product: 'card', chainId, symbol }), ); }); it.each(STABLECOINS)('charges a Wirex card %s deposit everywhere but Fuse', symbol => { expectFreeOnlyOn([fuse.id], chainId => - getDepositFeeBps({ provider: CardProvider.WIREX, product: 'card', chainId, symbol }), + getDepositFeeRatePpm({ provider: CardProvider.WIREX, product: 'card', chainId, symbol }), ); }); }); @@ -45,26 +51,26 @@ describe('getDepositFeeBps', () => { describe('wallet deposit', () => { it.each(STABLECOINS)('charges a Wirex cardholder %s everywhere but Fuse', symbol => { expectFreeOnlyOn([fuse.id], chainId => - getDepositFeeBps({ provider: CardProvider.WIREX, product: 'wallet', chainId, symbol }), + getDepositFeeRatePpm({ provider: CardProvider.WIREX, product: 'wallet', chainId, symbol }), ); }); it.each(NON_STABLECOINS)('never charges a Wirex cardholder %s', symbol => { expectFreeOnlyOn(CHAIN_IDS, chainId => - getDepositFeeBps({ provider: CardProvider.WIREX, product: 'wallet', chainId, symbol }), + getDepositFeeRatePpm({ provider: CardProvider.WIREX, product: 'wallet', chainId, symbol }), ); }); it('never charges a Wirex cardholder when the currency is not known', () => { expectFreeOnlyOn(CHAIN_IDS, chainId => - getDepositFeeBps({ provider: CardProvider.WIREX, product: 'wallet', chainId }), + getDepositFeeRatePpm({ provider: CardProvider.WIREX, product: 'wallet', chainId }), ); }); it.each(NO_CARD)('never charges someone with %s', (_label, provider) => { for (const symbol of [...STABLECOINS, ...NON_STABLECOINS]) { expectFreeOnlyOn(CHAIN_IDS, chainId => - getDepositFeeBps({ provider, product: 'wallet', chainId, symbol }), + getDepositFeeRatePpm({ provider, product: 'wallet', chainId, symbol }), ); } }); @@ -74,7 +80,7 @@ describe('getDepositFeeBps', () => { it('never charges a Wirex cardholder, on any chain or vault', () => { for (const vaultToken of ['soUSD', 'soETH', 'soFUSE']) { expectFreeOnlyOn(CHAIN_IDS, chainId => - getDepositFeeBps({ + getDepositFeeRatePpm({ provider: CardProvider.WIREX, product: 'savings', chainId, @@ -93,16 +99,16 @@ describe('getDepositFeeBps', () => { ['soFUSE', fuse.id], ])('charges %s deposits everywhere but its vault chain', (vaultToken, vaultChainId) => { expectFreeOnlyOn([vaultChainId], chainId => - getDepositFeeBps({ provider, product: 'savings', chainId, vaultToken }), + getDepositFeeRatePpm({ provider, product: 'savings', chainId, vaultToken }), ); }); it('quotes no fee for a vault it cannot place', () => { expectFreeOnlyOn(CHAIN_IDS, chainId => - getDepositFeeBps({ provider, product: 'savings', chainId, vaultToken: 'soBTC' }), + getDepositFeeRatePpm({ provider, product: 'savings', chainId, vaultToken: 'soBTC' }), ); expectFreeOnlyOn(CHAIN_IDS, chainId => - getDepositFeeBps({ provider, product: 'savings', chainId }), + getDepositFeeRatePpm({ provider, product: 'savings', chainId }), ); }); }, @@ -110,13 +116,73 @@ describe('getDepositFeeBps', () => { }); }); +describe('getDepositFeeDestinationType', () => { + it('prices savings on the savings address and every other flow on the card one', () => { + expect(getDepositFeeDestinationType('savings')).toBe('PROTOCOL'); + expect(getDepositFeeDestinationType('card')).toBe('RAIN_CARD'); + expect(getDepositFeeDestinationType('wallet')).toBe('RAIN_CARD'); + }); +}); + +describe('resolveDepositFeeRatePpm', () => { + const rulePpm = DEFAULT_DEPOSIT_FEE_RATE_PPM; + + it("quotes the backend's rate for the route and chain", () => { + expect( + resolveDepositFeeRatePpm({ + rulePpm, + quote: { applies: true, ratePpm: 500 }, + quoteFailed: false, + }), + ).toBe(500); + }); + + it('quotes nothing when the backend would not charge', () => { + expect( + resolveDepositFeeRatePpm({ + rulePpm, + quote: { applies: false, ratePpm: 0 }, + quoteFailed: false, + }), + ).toBe(0); + }); + + it('keeps a deposit that is free by rule free, whatever the backend says', () => { + expect( + resolveDepositFeeRatePpm({ + rulePpm: 0, + quote: { applies: true, ratePpm: 500 }, + quoteFailed: false, + }), + ).toBe(0); + expect(resolveDepositFeeRatePpm({ rulePpm: 0, quote: undefined, quoteFailed: false })).toBe(0); + }); + + it('waits for the backend rather than quoting a rate it may not charge', () => { + expect(resolveDepositFeeRatePpm({ rulePpm, quote: undefined, quoteFailed: false })).toBe( + undefined, + ); + }); + + it('falls back to the default rate, never to free, when the backend cannot be asked', () => { + expect(resolveDepositFeeRatePpm({ rulePpm, quote: undefined, quoteFailed: true })).toBe( + DEFAULT_DEPOSIT_FEE_RATE_PPM, + ); + }); +}); + describe('formatDepositFeePercent', () => { - it('quotes the fee as the notice shows it', () => { - expect(formatDepositFeePercent(DEPOSIT_FEE_BPS)).toBe('0.03%'); + it('quotes the default as the notice shows it', () => { + expect(formatDepositFeePercent(DEFAULT_DEPOSIT_FEE_RATE_PPM)).toBe('0.03%'); }); - it('keeps other rates exact', () => { - expect(formatDepositFeePercent(10)).toBe('0.1%'); - expect(formatDepositFeePercent(50)).toBe('0.5%'); + it('keeps other rates exact, fractions of a basis point included', () => { + expect(formatDepositFeePercent(1_000)).toBe('0.1%'); + expect(formatDepositFeePercent(5_000)).toBe('0.5%'); + expect(formatDepositFeePercent(250)).toBe('0.025%'); + expect(formatDepositFeePercent(333)).toBe('0.0333%'); + expect(formatDepositFeePercent(1)).toBe('0.0001%'); + expect(formatDepositFeePercent(10_000)).toBe('1%'); + expect(formatDepositFeePercent(12_500)).toBe('1.25%'); }); }); diff --git a/lib/utils/__tests__/safeModules.test.ts b/lib/utils/__tests__/safeModules.test.ts new file mode 100644 index 000000000..d46b18912 --- /dev/null +++ b/lib/utils/__tests__/safeModules.test.ts @@ -0,0 +1,137 @@ +import { Address, decodeFunctionData, getAddress } from 'viem'; + +import { Safe_ABI } from '@/lib/abis/Safe'; +import { + buildModuleDisables, + includesModule, + parseRetiredCashModulesV2, + RETIRED_FUSE_CASH_MODULE_V2, + SENTINEL_MODULES, +} from '@/lib/utils/safeModules'; + +/** + * The shape of a real stranded Safe (Fuse, 2026-09-25): the migration batch disabled v1 and + * enabled the retired v2 core, which went to the head of the list ahead of the 4337 module. + */ +const SAFE = getAddress('0xebaf1edf7164c9ecd5b8698e6bda9636ae09eabc'); +const V1 = getAddress('0x31F7f64769C6B2D4d3edd053421a0465FB371061'); +const RETIRED_V2 = getAddress('0xE2d4FB3d1eeD6Bdc3fD62A93ab35A33FC3c97b2B'); +const LIVE_V2 = getAddress('0xa98f2D4b79A465B265F68F745f5048BC369DA999'); +const SAFE_4337 = getAddress('0x75cf11467937ce3F2f357CE24ffc3DBF8fD5c226'); + +/** `[prevModule, module]` of each `disableModule` call, in batch order. */ +const disableArgs = (transactions: { to: Address; data: `0x${string}` }[]) => + transactions.map(({ to, data }) => { + expect(to).toBe(SAFE); + const decoded = decodeFunctionData({ abi: Safe_ABI, data }); + expect(decoded.functionName).toBe('disableModule'); + return decoded.args; + }); + +describe('buildModuleDisables', () => { + it('disables the retired core on a stranded Safe and skips v1, which is already off', () => { + const { disabled, transactions } = buildModuleDisables( + SAFE, + [RETIRED_V2, SAFE_4337], + [V1, RETIRED_V2], + ); + + expect(disabled).toEqual([RETIRED_V2]); + expect(disableArgs(transactions)).toEqual([[SENTINEL_MODULES, RETIRED_V2]]); + }); + + it('points at the list as earlier calls leave it when the targets are neighbours', () => { + // sentinel -> RETIRED_V2 -> V1 -> 4337. Removing RETIRED_V2 first re-points the sentinel + // at V1, so V1's predecessor is the sentinel — not RETIRED_V2, which a single snapshot says. + const { transactions } = buildModuleDisables( + SAFE, + [RETIRED_V2, V1, SAFE_4337], + [RETIRED_V2, V1], + ); + + expect(disableArgs(transactions)).toEqual([ + [SENTINEL_MODULES, RETIRED_V2], + [SENTINEL_MODULES, V1], + ]); + }); + + it('uses the neighbour that is still there when the later module goes first', () => { + const { transactions } = buildModuleDisables( + SAFE, + [RETIRED_V2, V1, SAFE_4337], + [V1, RETIRED_V2], + ); + + expect(disableArgs(transactions)).toEqual([ + [RETIRED_V2, V1], + [SENTINEL_MODULES, RETIRED_V2], + ]); + }); + + it('removes a module deeper in the list from its actual predecessor', () => { + const { transactions } = buildModuleDisables( + SAFE, + [LIVE_V2, SAFE_4337, RETIRED_V2], + [RETIRED_V2], + ); + + expect(disableArgs(transactions)).toEqual([[SAFE_4337, RETIRED_V2]]); + }); + + it('matches regardless of checksum case and reports the address as the Safe lists it', () => { + const { disabled, transactions } = buildModuleDisables( + SAFE, + [RETIRED_V2, SAFE_4337], + [RETIRED_V2.toLowerCase() as Address], + ); + + expect(disabled).toEqual([RETIRED_V2]); + expect(disableArgs(transactions)).toEqual([[SENTINEL_MODULES, RETIRED_V2]]); + }); + + it('sends nothing when none of the targets are enabled', () => { + expect(buildModuleDisables(SAFE, [LIVE_V2, SAFE_4337], [V1, RETIRED_V2])).toEqual({ + disabled: [], + transactions: [], + }); + }); + + it('leaves the list it was given untouched', () => { + const modules = [RETIRED_V2, V1, SAFE_4337]; + buildModuleDisables(SAFE, modules, [RETIRED_V2, V1]); + + expect(modules).toEqual([RETIRED_V2, V1, SAFE_4337]); + }); +}); + +describe('includesModule', () => { + it('ignores checksum case', () => { + expect(includesModule([V1], V1.toLowerCase() as Address)).toBe(true); + expect(includesModule([RETIRED_V2], V1)).toBe(false); + }); +}); + +describe('parseRetiredCashModulesV2', () => { + it('defaults to the 2026-09-24 retiree when unset or blank', () => { + expect(parseRetiredCashModulesV2(undefined, LIVE_V2)).toEqual([RETIRED_FUSE_CASH_MODULE_V2]); + // Helm and EAS render an unset key as an empty string, not as absent. + expect(parseRetiredCashModulesV2(' ', LIVE_V2)).toEqual([RETIRED_FUSE_CASH_MODULE_V2]); + }); + + it('reads a comma-separated list and drops entries that are not addresses', () => { + const other = getAddress('0x34c3564C4EBC29f90B4DD200509880C40Fd26E32'); + + expect(parseRetiredCashModulesV2(` ${RETIRED_V2} , nope,,${other}`, LIVE_V2)).toEqual([ + RETIRED_V2, + other, + ]); + }); + + it('never lists the live core, whatever the env says', () => { + expect( + parseRetiredCashModulesV2(`${RETIRED_V2},${LIVE_V2.toLowerCase()}`, ` ${LIVE_V2} `), + ).toEqual([RETIRED_V2]); + // A build still pointed at the retiree must not disable the module it registers on. + expect(parseRetiredCashModulesV2(undefined, RETIRED_V2)).toEqual([]); + }); +}); diff --git a/lib/utils/depositFee.ts b/lib/utils/depositFee.ts index 2e9b1f89d..41a7ad76f 100644 --- a/lib/utils/depositFee.ts +++ b/lib/utils/depositFee.ts @@ -1,13 +1,13 @@ import { base, fuse, mainnet } from 'viem/chains'; import { isStablecoinSymbol } from '@/constants/stablecoins'; -import { CardProvider } from '@/lib/types'; +import { CardProvider, DepositFeeQuote } from '@/lib/types'; /** * Which crypto deposits pay Solid's deposit fee. * - * A deposit pays `DEPOSIT_FEE_BPS` when it is sent from a chain other than the - * one its destination lives on. Where that is depends on the flow that handed + * A deposit can be charged when it is sent from a chain other than the one its + * destination lives on. Where that is depends on the flow that handed * out the address, and on who it was handed to: * * | Flow | Who | Charged on | @@ -24,12 +24,24 @@ import { CardProvider } from '@/lib/types'; * screen warns that the deposit will be charged, so a rule that changes belongs * in this file rather than in the screens. * + * The rate is the backend's. Admins can price each route and chain on their own + * from the Deposit fees page, so a charged deposit asks `/deposit/fee-quote` + * what it pays, and the backend answers 0.03% for anything not set there. The + * table above only decides which deposits are worth asking about: an address + * that is never bridged is never charged, whatever the grid says. + * * A leaf module, like `cardFunding`, so it can be tested under jest-expo without * pulling in `lib/assets`. */ -/** The fee on a deposit that is charged: 3 bps, i.e. 0.03%. */ -export const DEPOSIT_FEE_BPS = 3; +/** + * The rate a charged deposit pays when no other is set for its route and chain: + * 3 bps, i.e. 0.03%. In parts per million, as the backend quotes it, so a rate + * set in fractions of a basis point (0.025%) is still exact. + * + * Only quoted when the backend cannot be asked. + */ +export const DEFAULT_DEPOSIT_FEE_RATE_PPM = 300; /** * The flow a deposit address was handed out by: "Fund your card", the wallet @@ -45,13 +57,14 @@ const VAULT_CHAIN_IDS: Record = { }; /** - * The fee in basis points on this deposit, or 0 when it is free. + * Whether this deposit can be charged: the default rate in parts per million + * when it can, 0 when it is free by rule. * * Only a Wirex card changes the rules. No card, a Rain card and the deprecated * Bridge card (which `useCardProvider` already reports as no card) all follow * the Rain rows. */ -export function getDepositFeeBps({ +export function getDepositFeeRatePpm({ provider, product, chainId, @@ -79,22 +92,65 @@ export function getDepositFeeBps({ const vaultChainId = vaultToken ? VAULT_CHAIN_IDS[vaultToken] : undefined; if (vaultChainId === undefined) return 0; - return chainId === vaultChainId ? 0 : DEPOSIT_FEE_BPS; + return chainId === vaultChainId ? 0 : DEFAULT_DEPOSIT_FEE_RATE_PPM; } // A Wirex card holds no balance of its own, so funding it is funding the Safe // on Fuse, whichever flow the address came from. Only stablecoins are routed // there by the deposit pipeline; ETH and FUSE are sent straight to the Safe. if (isWirex) { - return isStablecoinSymbol(symbol) && chainId !== fuse.id ? DEPOSIT_FEE_BPS : 0; + return isStablecoinSymbol(symbol) && chainId !== fuse.id ? DEFAULT_DEPOSIT_FEE_RATE_PPM : 0; } - if (product === 'card') return chainId === base.id ? 0 : DEPOSIT_FEE_BPS; + if (product === 'card') return chainId === base.id ? 0 : DEFAULT_DEPOSIT_FEE_RATE_PPM; // The wallet flow charges cardholders only. Rain cardholders are sent to "Fund // your card" instead, so whoever is left here has no card. return 0; } -/** Basis points as the percentage the notice quotes: 3 → "0.03%". */ -export const formatDepositFeePercent = (bps: number): string => `${bps / 100}%`; +/** + * The deposit address the backend prices a flow's deposit on. The card and + * wallet flows both mint theirs as `RAIN_CARD`, which the backend delivers to + * whichever card the user holds - the Rain card on Base, or a Wirex card's Safe + * on Fuse - and prices on that card's route. + */ +export const getDepositFeeDestinationType = ( + product: DepositFeeProduct, +): 'PROTOCOL' | 'RAIN_CARD' => (product === 'savings' ? 'PROTOCOL' : 'RAIN_CARD'); + +/** + * The rate the notice quotes, or undefined while it is still being asked for. + * + * Free by rule stays free without asking. Otherwise the backend decides, since + * it is what charges: its rate for the route and chain, or 0 when it would not + * charge this deposit (a route set to 0%, the fee switched off). If it cannot + * be asked, the notice falls back to the default rate rather than to nothing: + * saying a deposit is free when it is charged is the worse mistake. + */ +export function resolveDepositFeeRatePpm({ + rulePpm, + quote, + quoteFailed, +}: { + /** What `getDepositFeeRatePpm` says. */ + rulePpm: number; + quote: Pick | undefined; + quoteFailed: boolean; +}): number | undefined { + if (!rulePpm) return 0; + if (quote) return quote.applies ? quote.ratePpm : 0; + return quoteFailed ? rulePpm : undefined; +} + +/** + * Parts per million as the percentage the notice quotes: 300 → "0.03%", + * 250 → "0.025%". Integer arithmetic, so no rate prints as 0.029999…%. + */ +export function formatDepositFeePercent(ratePpm: number): string { + const whole = Math.floor(ratePpm / 10_000); + const frac = String(ratePpm % 10_000) + .padStart(4, '0') + .replace(/0+$/, ''); + return `${whole}${frac ? `.${frac}` : ''}%`; +} diff --git a/lib/utils/safeModules.ts b/lib/utils/safeModules.ts new file mode 100644 index 000000000..90dfd0cf2 --- /dev/null +++ b/lib/utils/safeModules.ts @@ -0,0 +1,77 @@ +import { Address, encodeFunctionData, isAddress } from 'viem'; + +import { Safe_ABI } from '@/lib/abis/Safe'; + +/** + * Head of a Safe's module linked list. `disableModule(prevModule, module)` needs the + * entry pointing at the one being removed, and for the most recently enabled module + * that pointer is the sentinel itself rather than another module's address. + */ +export const SENTINEL_MODULES = '0x0000000000000000000000000000000000000001' as Address; + +/** + * `disableModule` calls for each of `targets` present in `modules`, in batch order. + * + * `modules` is the Safe's list as `getModulesPaginated` returns it, walking outwards from the + * sentinel. Predecessors are worked out against the list as the calls before them leave it, not + * against that one snapshot: removing a module re-points its predecessor at its successor, so two + * neighbours read off the same snapshot would give the second call a pointer to a module that is + * already gone — GS103, and the whole batch with it. + * + * `disabled` names what the calls remove, so a caller that needs one of them can tell "already + * off" from "could not find it". + */ +export const buildModuleDisables = ( + safeAddress: Address, + modules: readonly Address[], + targets: readonly Address[], +): { disabled: Address[]; transactions: { to: Address; data: `0x${string}` }[] } => { + const remaining = [...modules]; + const disabled: Address[] = []; + const transactions: { to: Address; data: `0x${string}` }[] = []; + + for (const target of targets) { + const index = remaining.findIndex(entry => entry.toLowerCase() === target.toLowerCase()); + if (index === -1) continue; + + transactions.push({ + to: safeAddress, + data: encodeFunctionData({ + abi: Safe_ABI, + functionName: 'disableModule', + args: [index === 0 ? SENTINEL_MODULES : remaining[index - 1], remaining[index]], + }), + }); + disabled.push(remaining[index]); + remaining.splice(index, 1); + } + + return { disabled, transactions }; +}; + +/** Whether `modules` includes `target`, ignoring checksum case. */ +export const includesModule = (modules: readonly Address[], target: Address) => + modules.some(entry => entry.toLowerCase() === target.toLowerCase()); + +/** + * The Fuse v2 core replaced by the 2026-09-24 redeploy. Safes kept being moved onto it by builds + * that still carried its address, so it is the default rather than something every environment has + * to remember to set. + */ +export const RETIRED_FUSE_CASH_MODULE_V2 = '0xE2d4FB3d1eeD6Bdc3fD62A93ab35A33FC3c97b2B' as Address; + +/** + * The retired v2 cores to clean off a Safe: a comma-separated env list, or the known one when unset. + * + * The live core is always dropped from the list. A build whose env names its own module as retired + * would otherwise disable, in the same batch, the module it is about to register on. + */ +export const parseRetiredCashModulesV2 = ( + configured: string | undefined, + current: string | undefined, +): Address[] => + (configured?.trim() ? configured : RETIRED_FUSE_CASH_MODULE_V2) + .split(',') + .map(entry => entry.trim()) + .filter(entry => isAddress(entry, { strict: false })) + .filter(entry => entry.toLowerCase() !== current?.trim().toLowerCase()) as Address[]; diff --git a/lib/wagmi.ts b/lib/wagmi.ts index ba909cfb0..924396c68 100644 --- a/lib/wagmi.ts +++ b/lib/wagmi.ts @@ -2,15 +2,7 @@ import { Platform } from 'react-native'; import { Chain, createPublicClient } from 'viem'; import { createConfig, http } from 'wagmi'; import { getWalletClient } from 'wagmi/actions'; -import { - arbitrum, - base, - baseSepolia, - bsc, - fuse, - mainnet, - polygon, -} from 'wagmi/chains'; +import { arbitrum, base, baseSepolia, bsc, fuse, mainnet, polygon } from 'wagmi/chains'; import { EXPO_PUBLIC_ALCHEMY_API_KEY } from './config'; @@ -42,12 +34,32 @@ const transports: Record> = { [bsc.id]: http(rpcUrls[bsc.id]), }; -export const publicClient = (chainId: number) => +const createChainClient = (chainId: number) => createPublicClient({ chain: chains.find(chain => chain.id === chainId), transport: http(rpcUrls[chainId]), + // Contract reads made in the same tick go out as one Multicall3 eth_call, + // billed by Alchemy as a single 26 CU call however many reads it carries. + // viem only batches plain reads (no account, value or gas) and falls back + // to a normal eth_call on a chain without Multicall3. + batch: { multicall: true }, }); +const publicClients = new Map>(); + +/** + * One client per chain. viem batches per client, so the fresh client every + * call used to build could never share a request with anything. + */ +export const publicClient = (chainId: number) => { + let client = publicClients.get(chainId); + if (!client) { + client = createChainClient(chainId); + publicClients.set(chainId, client); + } + return client; +}; + export const getWallet = (chainId: number) => { return getWalletClient(config, { chainId }); }; diff --git a/patches/react-native-mmkv+4.3.2.patch b/patches/react-native-mmkv+4.3.2.patch new file mode 100644 index 000000000..646cfc9e8 --- /dev/null +++ b/patches/react-native-mmkv+4.3.2.patch @@ -0,0 +1,52 @@ +diff --git a/node_modules/react-native-mmkv/src/addContentChangedListener/addContentChangedListener.ts b/node_modules/react-native-mmkv/src/addContentChangedListener/addContentChangedListener.ts +--- a/node_modules/react-native-mmkv/src/addContentChangedListener/addContentChangedListener.ts ++++ b/node_modules/react-native-mmkv/src/addContentChangedListener/addContentChangedListener.ts +@@ -16,7 +16,10 @@ export function addContentChangedListener(mmkv: MMKV): void { + 'change', + (state: AppStateStatus) => { + if (state === 'active') { +- weakMmkv.deref()?.checkContentChanged() ++ const instance = weakMmkv.deref() ++ if (typeof instance?.checkContentChanged === 'function') { ++ instance.checkContentChanged() ++ } + } + } + ) +@@ -29,7 +32,9 @@ export function addContentChangedListener(mmkv: MMKV): void { + } else { + AppState.addEventListener('change', (state: AppStateStatus) => { + if (state === 'active') { +- mmkv.checkContentChanged() ++ if (typeof mmkv.checkContentChanged === 'function') { ++ mmkv.checkContentChanged() ++ } + } + }) + } +diff --git a/node_modules/react-native-mmkv/lib/addContentChangedListener/addContentChangedListener.js b/node_modules/react-native-mmkv/lib/addContentChangedListener/addContentChangedListener.js +--- a/node_modules/react-native-mmkv/lib/addContentChangedListener/addContentChangedListener.js ++++ b/node_modules/react-native-mmkv/lib/addContentChangedListener/addContentChangedListener.js +@@ -9,7 +9,10 @@ export function addContentChangedListener(mmkv) { + const weakMmkv = new WeakRef(mmkv); + const listener = AppState.addEventListener('change', (state) => { + if (state === 'active') { +- weakMmkv.deref()?.checkContentChanged(); ++ const instance = weakMmkv.deref(); ++ if (typeof instance?.checkContentChanged === 'function') { ++ instance.checkContentChanged(); ++ } + } + }); + const finalization = new FinalizationRegistry((l) => { +@@ -20,7 +23,9 @@ export function addContentChangedListener(mmkv) { + else { + AppState.addEventListener('change', (state) => { + if (state === 'active') { +- mmkv.checkContentChanged(); ++ if (typeof mmkv.checkContentChanged === 'function') { ++ mmkv.checkContentChanged(); ++ } + } + }); + } diff --git a/scripts/generate-credit-help-videos.py b/scripts/generate-credit-help-videos.py new file mode 100644 index 000000000..37a4e7af3 --- /dev/null +++ b/scripts/generate-credit-help-videos.py @@ -0,0 +1,87 @@ +"""Crop the actual Figma timeline exports for the native Spend Mode help pager. + +The source MP4s are exports of Figma frames 27048:3993, 27217:1250, and +27245:1250. They contain the entire mock phone screen. This keeps only the +animated illustration; the app renders its own text, controls, and status bar. +Run from the repository root with ffmpeg installed. +""" + +from pathlib import Path +import subprocess + + +SOURCE = Path("assets/animations/figma-credit-help-source") +DESTINATION = Path("assets/animations") +POSTER_SECONDS = {"two-ways": 3.0, "keep-earning": 3.8, "repay-anytime": 3.7} + + +def repair_channel(channel: str) -> str: + """Interpolate through Figma's split Repay text as the card moves.""" + sample = lambda x: f"{channel}({x},Y)" + button = f"{sample(525)}*(725-X)/190+{sample(735)}*(X-535)/190" + overflow = f"{sample(580)}*(670-X)/80+{sample(680)}*(X-590)/80" + # The card enters from below; its label settles at the exported position. + shift = "if(lt(T,0.4),53*exp(-10*T),0)" + return ( + f"if(between(X,535,725)*between(Y,602+{shift},693+{shift}),{button}," + f"if(between(X,590,670)*between(Y,694+{shift},755+{shift}),{overflow}," + f"{channel}(X,Y)))" + ) + + +for name, poster_second in POSTER_SECONDS.items(): + source = SOURCE / f"figma-credit-help-{name}.mp4" + output = DESTINATION / f"credit-help-{name}.mp4" + dimensions = subprocess.check_output( + [ + "ffprobe", "-v", "error", "-select_streams", "v:0", + "-show_entries", "stream=width,height", "-of", "csv=s=x:p=0", + str(source), + ], + text=True, + ).strip() + width, height = map(int, dimensions.split("x")) + if width < 1000: + raise ValueError(f"{source} needs a 3× Figma timeline export") + + # The source is a complete 419×1132 phone screen exported at 3×. Keep only + # the animated illustration and retain its original pixel density. + scale = height / 1132 + even = lambda value: round(value / 2) * 2 + illustration_top = even(170 * scale) + illustration_height = even(440 * scale) + filter_graph = f"crop=iw:{illustration_height}:0:{illustration_top}" + command = ["ffmpeg", "-loglevel", "error", "-y", "-i", str(source)] + if name == "repay-anytime": + # Figma's video exporter wraps the button label into "Repa" / "y". + # Remove the split source text across the entire button animation, + # then draw the correct label at the button's animated position. + command += ["-loop", "1", "-framerate", "30", "-i", str(SOURCE / "repay-label.png")] + repair = ":".join(f"{channel}='{repair_channel(channel)}'" for channel in "rgb") + command += [ + "-filter_complex", + f"[0:v]{filter_graph},format=gbrp," + f"geq={repair}:enable='lte(t,2.24)',format=yuv420p[base];" + "[1:v]format=rgba," + "fade=t=in:st=0:d=0.08:alpha=1," + "fade=t=out:st=2.10:d=0.09:alpha=1[label];" + "[base][label]overlay=x=0:" + "y='if(lt(t,0.4),53*exp(-10*t),0)':" + "eval=frame:shortest=1:enable='between(t,0.03,2.19)'[out]", + "-map", "[out]", + ] + else: + command += ["-vf", filter_graph] + command += [ + "-an", "-c:v", "libx264", "-preset", "slow", "-crf", "12", + "-pix_fmt", "yuv420p", "-movflags", "+faststart", str(output), + ] + subprocess.run(command, check=True) + subprocess.run( + [ + "ffmpeg", "-loglevel", "error", "-y", "-ss", str(poster_second), + "-i", str(output), "-frames:v", "1", + str(DESTINATION / f"credit-help-{name}-poster.png"), + ], + check=True, + ) diff --git a/store/__tests__/useUserStore.lastKnownIdentity.test.ts b/store/__tests__/useUserStore.lastKnownIdentity.test.ts new file mode 100644 index 000000000..8fbc40413 --- /dev/null +++ b/store/__tests__/useUserStore.lastKnownIdentity.test.ts @@ -0,0 +1,89 @@ +/// + +import { User } from '@/lib/types'; +import { selectLastKnownIdentity, useUserStore } from '@/store/useUserStore'; + +// MMKV is a native module — back the persisted store with plain memory here. +// (jest.mock is hoisted above the import by babel-jest.) +jest.mock('@/lib/mmvkStorage', () => { + const memory = new Map(); + return { + __esModule: true, + default: () => ({ + setItem: (key: string, value: string) => memory.set(key, value), + getItem: (key: string) => memory.get(key) ?? null, + removeItem: (key: string) => memory.delete(key), + }), + }; +}); + +const buildUser = (overrides: Partial = {}): User => + ({ + userId: 'user-1', + username: 'frank.crypto2121', + email: 'frank.crypto2121@gmail.com', + safeAddress: '0x07853745fea7396242C999Bc7bCcD8F64d387875', + signWith: '0xBc82DaA01eBe5360C2eeD6d64C004A4db868DA22', + suborgId: '13a64832-98d3-4e88-ba08-c7b67908866f', + selected: false, + ...overrides, + }) as User; + +const setUsers = (users: User[]) => useUserStore.setState({ users }); +const identity = () => selectLastKnownIdentity(useUserStore.getState()); + +afterEach(() => setUsers([])); + +/** + * What the recovery screen prefills its first field with. + * + * The user this came from opened that screen twice on a phone, sat on the empty + * field both times, and left without typing anything — so the value it starts + * with is the whole feature, not a convenience. + */ +describe('selectLastKnownIdentity', () => { + it('offers the email of the account this device was signed into', () => { + setUsers([buildUser()]); + expect(identity()).toBe('frank.crypto2121@gmail.com'); + }); + + it('survives a logout, which is when it is actually needed', () => { + // Logging out and a session expiring both clear `selected` and the tokens + // but leave the row — only "Forget all users" removes it. + setUsers([buildUser({ selected: false, tokens: undefined })]); + expect(identity()).toBe('frank.crypto2121@gmail.com'); + }); + + it('prefers the account that was selected over the others', () => { + setUsers([ + buildUser({ userId: 'other', email: 'other@example.com' }), + buildUser({ userId: 'user-1', selected: true }), + ]); + expect(identity()).toBe('frank.crypto2121@gmail.com'); + }); + + it('falls back to the username for an account carrying no email', () => { + // Roughly one account in ten. Recovery now takes a username, so this is a + // usable prefill rather than a blank field. + setUsers([buildUser({ email: undefined })]); + expect(identity()).toBe('frank.crypto2121'); + }); + + it('skips a row that has neither, rather than offering an empty string', () => { + setUsers([ + buildUser({ userId: 'blank', email: undefined, username: undefined }), + buildUser({ userId: 'user-1' }), + ]); + expect(identity()).toBe('frank.crypto2121@gmail.com'); + }); + + it('trims what it offers, so a stray space cannot fail validation', () => { + setUsers([buildUser({ email: ' frank.crypto2121@gmail.com ' })]); + expect(identity()).toBe('frank.crypto2121@gmail.com'); + }); + + it('offers nothing on a device that has never signed in', () => { + setUsers([]); + expect(identity()).toBe(''); + }); +}); diff --git a/store/useAccountRefreshStore.ts b/store/useAccountRefreshStore.ts new file mode 100644 index 000000000..9598c4dde --- /dev/null +++ b/store/useAccountRefreshStore.ts @@ -0,0 +1,9 @@ +import { create } from 'zustand'; + +import { ActivityEvents } from '@/lib/types'; + +/** Runtime-only state shared by Home, Activity and their nested feed hooks. */ +export const useAccountRefreshStore = create<{ + refreshingByUser: Record; + latestPageByUser: Record; +}>(() => ({ refreshingByUser: {}, latestPageByUser: {} })); diff --git a/store/useSpendModeHelpStore.ts b/store/useSpendModeHelpStore.ts new file mode 100644 index 000000000..2c32efbfa --- /dev/null +++ b/store/useSpendModeHelpStore.ts @@ -0,0 +1,29 @@ +import { create } from 'zustand'; +import { createJSONStorage, persist } from 'zustand/middleware'; + +import mmkvStorage from '@/lib/mmvkStorage'; + +interface SpendModeHelpState { + /** Accounts that have had the Spend Mode help opened automatically. */ + shownByUserId: Record; + markShown: (userId: string) => void; +} + +const SPEND_MODE_HELP_STORAGE_KEY = 'spend-mode-help-storage'; + +/** Show the Spend Mode explanation once per account on this device. */ +export const useSpendModeHelpStore = create()( + persist( + set => ({ + shownByUserId: {}, + markShown: userId => + set(state => ({ + shownByUserId: { ...state.shownByUserId, [userId]: true }, + })), + }), + { + name: SPEND_MODE_HELP_STORAGE_KEY, + storage: createJSONStorage(() => mmkvStorage(SPEND_MODE_HELP_STORAGE_KEY)), + }, + ), +); diff --git a/store/useUserStore.ts b/store/useUserStore.ts index 71df4a78d..602d9b058 100644 --- a/store/useUserStore.ts +++ b/store/useUserStore.ts @@ -61,6 +61,27 @@ export const selectSelectedCredentialIds = (state: UserState): string[] => { return user?.credentialId ? [user.credentialId] : []; }; +/** + * The account this device last knew about, for prefilling recovery. + * + * Logging out and a session expiring both leave the row in place — only + * "Forget all users" clears it — so on the device someone is locked out of, + * this is almost always the account they are reaching for. Prefers the + * selected user, then the most recently stored one. + * + * Email first because that is what recovery mails; the username is the + * fallback for the roughly one account in ten carrying no address, and for the + * rows written before emails were stored locally. + */ +export const selectLastKnownIdentity = ({ users }: UserState): string => { + const candidates = [users.find(u => u.selected), ...users].filter((user): user is User => !!user); + for (const user of candidates) { + const identity = user.email?.trim() || user.username?.trim(); + if (identity) return identity; + } + return ''; +}; + export const useUserStore = create()( persist( set => ({ diff --git a/store/useWhatsNewStore.ts b/store/useWhatsNewStore.ts index c42a95ef7..decbceeb6 100644 --- a/store/useWhatsNewStore.ts +++ b/store/useWhatsNewStore.ts @@ -5,13 +5,17 @@ import { WhatsNew } from '@/lib/types'; interface WhatsNewState { whatsNew: WhatsNew | null; isVisible: boolean; + isHomeReady: boolean; setWhatsNew: (whatsNew: WhatsNew | null) => void; setIsVisible: (isVisible: boolean) => void; + setHomeReady: (isHomeReady: boolean) => void; } export const useWhatsNewStore = create(set => ({ whatsNew: null, isVisible: false, + isHomeReady: false, setWhatsNew: whatsNew => set({ whatsNew }), setIsVisible: isVisible => set({ isVisible }), + setHomeReady: isHomeReady => set({ isHomeReady }), }));