Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ EXPO_PUBLIC_FIREBASE_DATABASE_URL=
EXPO_PUBLIC_AMPLITUDE_API_KEY=
EXPO_PUBLIC_AMPLITUDE_PROXY_URL=
EXPO_PUBLIC_MERKL_CAMPAIGN_ID=
# Comma-separated Merkl campaign ids of the WFUSE yield-boost campaigns (soUSD, soETH, soFUSE)
EXPO_PUBLIC_MERKL_YIELD_BOOST_CAMPAIGN_IDS=
EXPO_PUBLIC_FLASH_VAULT_MANAGER_API_BASE_URL=http://localhost:5003
EXPO_PUBLIC_COINGECKO_API_KEY=

Expand Down
1 change: 1 addition & 0 deletions components/Rewards/NewRewards/RewardsScreenNew.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,7 @@ export default function RewardsScreenNew() {
maxCashbackMonthly={rewardsData?.maxCashbackMonthly ?? 0}
allTimeCashback={allTimeCashback}
{...benefitRates}
yieldBoostBalanceCap={rewardsData?.yieldBoostBalanceCap ?? 0}
onGetMoreCashback={() => router.push(path.REWARDS_BENEFITS)}
onReferralsPress={() => setIsReferralModalOpen(true)}
/>
Expand Down
2 changes: 2 additions & 0 deletions components/Rewards/NewRewards/TierBenefitsGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ const TierBenefitsGrid = ({
yieldBoostPercentage,
yieldBoostCap,
yieldBoostEarned,
yieldBoostBalanceCap,
onGetMoreCashback,
onReferralsPress,
...cashbackData
Expand Down Expand Up @@ -121,6 +122,7 @@ const TierBenefitsGrid = ({
yieldBoostPercentage={yieldBoostPercentage}
yieldBoostCap={yieldBoostCap}
yieldBoostEarned={yieldBoostEarned}
yieldBoostBalanceCap={yieldBoostBalanceCap}
/>
),
subscription: (
Expand Down
9 changes: 6 additions & 3 deletions components/Rewards/NewRewards/YieldBoostContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const YieldBoostContent = ({
yieldBoostPercentage,
yieldBoostCap,
yieldBoostEarned,
yieldBoostBalanceCap = 0,
onClose,
animationSession,
isSheet = true,
Expand All @@ -44,9 +45,11 @@ const YieldBoostContent = ({
className="mt-[7px] w-[284px] text-center text-base text-white/70"
style={{ fontFamily: 'MonaSans_400Regular', lineHeight: 18 }}
>
{yieldBoostCap > 0
? `Earn USDC on top of your savings deposits, up to ${formatWholeDollars(yieldBoostCap)}`
: 'Earn USDC on top of your savings deposits'}
{yieldBoostBalanceCap > 0
? `Earn FUSE on top of your savings yield, on your first ${formatWholeDollars(yieldBoostBalanceCap)}`
: yieldBoostCap > 0
? `Earn USDC on top of your savings deposits, up to ${formatWholeDollars(yieldBoostCap)}`
: 'Earn USDC on top of your savings deposits'}
</Text>

<View className="mt-9 h-[117px] w-full overflow-hidden rounded-twice bg-[#2B2B2B]">
Expand Down
6 changes: 6 additions & 0 deletions components/Rewards/NewRewards/YieldBoostSheet.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ export interface YieldBoostData {
yieldBoostCap: number;
/** Boost payouts the user has received so far, in USD. */
yieldBoostEarned: number;
/**
* Savings balance the boost is paid on, in USD (Prime $10K, Ultra $25K).
* When set, the copy describes the FUSE boost and its cap; absent or 0 keeps
* the older payout-cap wording for a backend that doesn't send it yet.
*/
yieldBoostBalanceCap?: number;
}

export interface YieldBoostSheetProps extends YieldBoostData {
Expand Down
2 changes: 2 additions & 0 deletions components/Savings/NewSavings/SavingsScreenNew.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { VaultType } from '@/lib/types';
import SavingsFundedActions from './SavingsFundedActions';
import SavingsHelpModal from './SavingsHelpModal';
import SavingsVaultHero from './SavingsVaultHero';
import SavingsYieldBoostCard from './SavingsYieldBoostCard';
import SimulateSavingsCard from './SimulateSavingsCard';
import StartEarningButton from './StartEarningButton';
import VaultApyHistoryCard from './VaultApyHistoryCard';
Expand Down Expand Up @@ -80,6 +81,7 @@ export default function SavingsScreenNew() {
<>
<SavingsFundedActions vaultType={selectedVaultType} />
<VaultBalanceCard vaultType={selectedVaultType} balanceUsd={selectedBalanceUsd} />
<SavingsYieldBoostCard />
</>
) : (
<StartEarningButton vaultType={selectedVaultType} />
Expand Down
110 changes: 90 additions & 20 deletions components/Savings/NewSavings/SavingsYieldBoostCard.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,37 @@
import { View } from 'react-native';
import { ActivityIndicator, Pressable, View } from 'react-native';
import Toast from 'react-native-toast-message';
import { formatUnits } from 'viem';
import { fuse } from 'viem/chains';

import { resolveTierBenefitRates } from '@/components/Rewards/NewRewards/tierBenefitCards';
import Skeleton from '@/components/ui/skeleton';
import { Text } from '@/components/ui/text';
import { useNativePriceUsd } from '@/hooks/useNativePriceUsd';
import { useRewardsUserData } from '@/hooks/useRewards';
import { useClaimYieldBoost, useYieldBoostRewards } from '@/hooks/useYieldBoostRewards';
import { isDevFeatureEnabled } from '@/lib/config';
import { YIELD_BOOST_REWARD_DECIMALS } from '@/lib/merklYieldBoost';
import { formatBalanceUSD, formatNumber } from '@/lib/utils';

/**
* "Yield boost" — the extra APY the user's rewards tier adds on top of the base
* savings yield, and what it has paid out so far.
* Figma 26134:23293 — "Yield boost": the extra APY the user's rewards tier adds
* on top of the base savings yield, what it has paid so far, and the claim.
*
* Renders nothing for tiers that grant no boost, so Core users don't see an
* empty box for a perk they haven't unlocked. Off production the boost is
* The boost is paid in FUSE through Merkl, across all three vaults, so the card
* is the same whichever vault the screen shows. It renders nothing for a user
* with no boost and nothing left to claim, so Core users don't see an empty box
* for a perk they haven't unlocked — while someone who dropped a tier can still
* collect what they earned at the old one. Off production the boost is
* previewed at stock Prime rates instead, matching the rewards screen so the
* strip is reviewable from a Core test account.
* card is reviewable from a Core test account.
*/
const SavingsYieldBoostCard = () => {
const { data: rewardsData } = useRewardsUserData();
const { data: boostRewards, isLoading: isRewardsLoading } = useYieldBoostRewards();
const { mutateAsync: claim, isPending: isClaiming } = useClaimYieldBoost();
const fusePriceUsd = useNativePriceUsd(fuse.id, 'fusePriceUsd', true);

const { yieldBoostPercentage: boostPercentage, yieldBoostEarned } = resolveTierBenefitRates(
const { yieldBoostPercentage: boostPercentage } = resolveTierBenefitRates(
{
yieldBoostPercentage: rewardsData?.yieldBoostPercentage ?? 0,
yieldBoostCap: rewardsData?.yieldBoostCap ?? 0,
Expand All @@ -28,7 +41,33 @@ const SavingsYieldBoostCard = () => {
isDevFeatureEnabled,
);

if (boostPercentage <= 0) return null;
const claimable = boostRewards?.claimable ?? 0n;
if (boostPercentage <= 0 && claimable === 0n) return null;

const priceUsd = fusePriceUsd > 0 ? fusePriceUsd : (boostRewards?.priceUsd ?? 0);
const earnedUsd =
Number(formatUnits(boostRewards?.earned ?? 0n, YIELD_BOOST_REWARD_DECIMALS)) * priceUsd;
const canClaim = claimable > 0n && !isClaiming;

const handleClaim = async () => {
try {
const result = await claim();
if (!result) return;
Toast.show({
type: 'success',
text1: 'Yield boost claimed',
text2: `${formatNumber(Number(result.amount), 2)} FUSE`,
props: { badgeText: 'Onchain' },
});
} catch (error) {
Toast.show({
type: 'error',
text1: "Couldn't claim yield boost",
text2: error instanceof Error ? error.message : undefined,
props: { badgeText: 'Onchain' },
});
}
};

return (
<View className="mx-4 overflow-hidden rounded-twice bg-card">
Expand All @@ -37,24 +76,55 @@ const SavingsYieldBoostCard = () => {
</View>
<View className="h-px bg-[#2A2A2A]" />

<View className="flex-row px-5 pb-[18px] pt-[19px]">
<View className="flex-row items-center px-5 pb-[18px] pt-[19px]">
<View className="flex-1">
<Text className="text-sm font-medium leading-[14px] text-white/50">Boost amount</Text>
<View className="mt-[7px] h-[23px] items-center justify-center self-start rounded-lg bg-white px-[7px]">
<Text className="text-sm font-semibold text-[#282041]">
+{formatNumber(boostPercentage, 2, 0)}% Boost
</Text>
</View>
{boostPercentage > 0 ? (
<View className="mt-[7px] h-[23px] items-center justify-center self-start rounded-lg bg-white px-[7px]">
<Text className="text-sm font-semibold text-[#282041]">
+{formatNumber(boostPercentage, 2, 0)}% Boost
</Text>
</View>
) : (
<Text className="mt-[7px] text-sm font-semibold leading-[23px] text-white/50">—</Text>
)}
</View>
<View className="flex-1">
<Text className="text-sm font-medium leading-[14px] text-white/50">Total Earned</Text>
<Text
className="mt-[7px] text-[22px] text-white"
style={{ fontFamily: 'MonaSans_600SemiBold', lineHeight: 24 }}
>
{formatBalanceUSD(yieldBoostEarned)}
</Text>
{isRewardsLoading ? (
<Skeleton className="mt-[7px] h-6 w-[88px] rounded-md bg-white/10" />
) : (
<Text
className="mt-[7px] text-[22px] text-white"
style={{ fontFamily: 'MonaSans_600SemiBold', lineHeight: 24 }}
>
{formatBalanceUSD(earnedUsd)}
</Text>
)}
</View>
<Pressable
accessibilityRole="button"
accessibilityLabel="Claim yield boost"
accessibilityState={{ disabled: !canClaim, busy: isClaiming }}
disabled={!canClaim}
onPress={handleClaim}
className={`h-[37px] min-w-[80px] items-center justify-center rounded-full px-[21px] ${
canClaim
? 'bg-[#94F27F] transition-all active:scale-95 active:opacity-80'
: 'bg-white/10'
}`}
>
{isClaiming ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<Text
className={canClaim ? 'text-black' : 'text-white/70'}
style={{ fontFamily: 'MonaSans_600SemiBold', fontSize: 16, lineHeight: 20 }}
>
Claim
</Text>
)}
</Pressable>
</View>
</View>
);
Expand Down
108 changes: 108 additions & 0 deletions components/Savings/NewSavings/__tests__/SavingsYieldBoostCard.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import React from 'react';

import SavingsYieldBoostCard from '@/components/Savings/NewSavings/SavingsYieldBoostCard';
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { act, create } = require('react-test-renderer');

jest.mock('react-native-toast-message', () => ({ __esModule: true, default: { show: jest.fn() } }));
jest.mock('@/components/ui/text', () => ({ Text: 'Text' }));
jest.mock('@/components/ui/skeleton', () => ({ __esModule: true, default: 'Skeleton' }));
jest.mock('@/lib/config', () => ({ isDevFeatureEnabled: false }));
jest.mock('@/lib/merklYieldBoost', () => ({ YIELD_BOOST_REWARD_DECIMALS: 18 }));
// The real module pulls in wagmi, which Jest can't parse; these mirror it.
jest.mock('@/lib/utils', () => {
const formatNumber = (value: number, max = 6, min = 2) =>
new Intl.NumberFormat('en-us', {
maximumFractionDigits: max,
minimumFractionDigits: Math.min(value >= 1 ? min : 0, max),
}).format(value);
return {
formatNumber,
formatBalanceUSD: (value: number) => (value > 0 ? `$${formatNumber(value, 2)}` : '$0.00'),
};
});
jest.mock('@/hooks/useNativePriceUsd', () => ({ useNativePriceUsd: () => 0.02 }));
jest.mock('@/hooks/useRewards', () => ({
useRewardsUserData: () => ({ data: mockRewardsData }),
}));
jest.mock('@/hooks/useYieldBoostRewards', () => ({
useYieldBoostRewards: () => mockBoostQuery,
useClaimYieldBoost: () => ({ mutateAsync: mockClaim, isPending: false }),
}));

let mockRewardsData: Record<string, number> = {};
let mockBoostQuery: { data?: Record<string, unknown>; isLoading: boolean } = { isLoading: false };
const mockClaim = jest.fn();

const ONE_FUSE = 10n ** 18n;

let root: any;
const render = () => {
act(() => {
root = create(<SavingsYieldBoostCard />);
});
return root;
};
const texts = () =>
root.root
.findAllByType('Text')
.map((node: any) => [node.props.children].flat().join(''))
.join('|');
// NativeWind wraps Pressable, so find the claim by what a screen reader sees.
const claimButton = () =>
root.root.findAll(
(node: any) =>
node.props.accessibilityLabel === 'Claim yield boost' &&
typeof node.props.onPress === 'function',
)[0];

beforeEach(() => {
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
jest.clearAllMocks();
mockRewardsData = { yieldBoostPercentage: 2 };
mockBoostQuery = {
isLoading: false,
data: { claimable: 5n * ONE_FUSE, earned: 21_032n * ONE_FUSE, priceUsd: null },
};
});
afterEach(() => act(() => root?.unmount()));

it('shows the tier boost and what the boost has earned in USD', () => {
render();

expect(texts()).toContain('+2% Boost');
// 21,032 FUSE × $0.02
expect(texts()).toContain('$420.64');
});

it('claims through the mutation when there is something to claim', async () => {
mockClaim.mockResolvedValue({ amount: '5' });
render();

expect(claimButton().props.disabled).toBe(false);
await act(async () => claimButton().props.onPress());
expect(mockClaim).toHaveBeenCalledTimes(1);
});

it('disables the claim when nothing is claimable yet', () => {
mockBoostQuery = { isLoading: false, data: { claimable: 0n, earned: 0n, priceUsd: null } };
render();

expect(claimButton().props.disabled).toBe(true);
});

it('renders nothing for a user with no boost and nothing to claim', () => {
mockRewardsData = { yieldBoostPercentage: 0 };
mockBoostQuery = { isLoading: false, data: { claimable: 0n, earned: 0n, priceUsd: null } };
render();

expect(root.toJSON()).toBeNull();
});

it('still lets a user who lost the boost collect what they earned', () => {
mockRewardsData = { yieldBoostPercentage: 0 };
render();

expect(texts()).not.toContain('% Boost');
expect(claimButton().props.disabled).toBe(false);
});
Loading
Loading