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: 0 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,6 @@ 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
5 changes: 5 additions & 0 deletions components/Earn/EarnScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { View } from 'react-native';
import { Href, router } from 'expo-router';

import { BalanceHeadline, BalancePillRow } from '@/components/BalanceHeadline';
import EarnYieldBoostCard from '@/components/Earn/EarnYieldBoostCard';
import LockedFuseTile from '@/components/Earn/LockedFuseTile';
import HeaderHelpButton from '@/components/Navbar/HeaderHelpButton';
import PageLayout from '@/components/PageLayout';
Expand Down Expand Up @@ -152,6 +153,10 @@ export default function EarnScreen() {
</BalancePillRow>
</View>

{/* Earned on the portfolio as a whole, so it sits with the portfolio
total rather than on any one vault. */}
<EarnYieldBoostCard className="mt-[27px]" />

<Text className="mb-[14px] mt-[27px] text-[16px] leading-5 text-white/50">
Earn interest, Withdraw anytime
</Text>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,64 +1,84 @@
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 { useClaimYieldBoost, useYieldBoostSummary } from '@/hooks/useRewards';
import { isDevFeatureEnabled } from '@/lib/config';
import { YIELD_BOOST_REWARD_DECIMALS } from '@/lib/merklYieldBoost';
import { formatBalanceUSD, formatNumber } from '@/lib/utils';
import { YieldBoostClaim, YieldBoostClaimStatus } from '@/lib/types';
import { cn, formatBalanceUSD, formatNumber } from '@/lib/utils';

/** "1,234.57 soFUSE", from the backend's decimal string. */
const formatSoFuse = (amount: string) => `${formatNumber(Number(amount), 2)} soFUSE`;

/** What to tell the user once the claim request comes back. */
const claimToast = (claim: YieldBoostClaim) => {
switch (claim.status) {
case YieldBoostClaimStatus.PAID:
return {
type: 'success',
text1: 'Yield boost claimed',
text2: formatSoFuse(claim.soFuseAmount),
};
case YieldBoostClaimStatus.FAILED:
return {
type: 'error',
text1: "Couldn't claim yield boost",
text2: 'Nothing was sent. Your boost is still here to claim.',
};
default:
// Sent but not mined within the request; the summary polls until it is.
return {
type: 'success',
text1: 'Yield boost on its way',
text2: `${formatSoFuse(claim.soFuseAmount)} is being sent to your savings`,
};
}
};

interface EarnYieldBoostCardProps {
className?: string;
}

/**
* 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.
* on top of every vault's own yield, what it has earned so far, and the claim.
*
* 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
* card is reviewable from a Core test account.
* It sits on the Earn page rather than on a vault because it is earned on the
* user's savings across all three vaults together, and paid in soFUSE whichever
* vault the money is in. It renders nothing for a user with no boost and
* nothing 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, matching the rewards screen, so the card is reviewable from a
* Core test account.
*/
const SavingsYieldBoostCard = () => {
const { data: rewardsData } = useRewardsUserData();
const { data: boostRewards, isLoading: isRewardsLoading } = useYieldBoostRewards();
const EarnYieldBoostCard = ({ className }: EarnYieldBoostCardProps) => {
const { data: summary, isLoading } = useYieldBoostSummary();
const { mutateAsync: claim, isPending: isClaiming } = useClaimYieldBoost();
const fusePriceUsd = useNativePriceUsd(fuse.id, 'fusePriceUsd', true);

const { yieldBoostPercentage: boostPercentage } = resolveTierBenefitRates(
{
yieldBoostPercentage: rewardsData?.yieldBoostPercentage ?? 0,
yieldBoostCap: rewardsData?.yieldBoostCap ?? 0,
yieldBoostEarned: rewardsData?.yieldBoostEarned ?? 0,
subscriptionDiscountRate: rewardsData?.subscriptionDiscountRate ?? 0,
yieldBoostPercentage: summary?.apyPercentage ?? 0,
yieldBoostCap: 0,
yieldBoostEarned: summary?.totalEarnedUsd ?? 0,
subscriptionDiscountRate: 0,
},
isDevFeatureEnabled,
);

const claimable = boostRewards?.claimable ?? 0n;
if (boostPercentage <= 0 && claimable === 0n) return null;
const hasClaimable = Number(summary?.claimableSoFuse ?? 0) > 0;
const isPaying = isClaiming || Boolean(summary?.pendingClaim);
if (boostPercentage <= 0 && !hasClaimable && !isPaying) 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 isPaused = hasClaimable && summary?.claimsEnabled === false;
const canClaim = hasClaimable && !isPaused && !isPaying;

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' },
});
const { claim: result } = await claim();
Toast.show({ ...claimToast(result), props: { badgeText: 'Onchain' } });
} catch (error) {
Toast.show({
type: 'error',
Expand All @@ -70,7 +90,7 @@ const SavingsYieldBoostCard = () => {
};

return (
<View className="mx-4 overflow-hidden rounded-twice bg-card">
<View className={cn('overflow-hidden rounded-twice bg-card', className)}>
<View className="h-[54px] justify-center px-5">
<Text className="text-base font-semibold leading-4 text-white">Yield boost</Text>
</View>
Expand All @@ -91,21 +111,21 @@ const SavingsYieldBoostCard = () => {
</View>
<View className="flex-1">
<Text className="text-sm font-medium leading-[14px] text-white/50">Total Earned</Text>
{isRewardsLoading ? (
{isLoading ? (
<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)}
{formatBalanceUSD(summary?.totalEarnedUsd ?? 0)}
</Text>
)}
</View>
<Pressable
accessibilityRole="button"
accessibilityLabel="Claim yield boost"
accessibilityState={{ disabled: !canClaim, busy: isClaiming }}
accessibilityState={{ disabled: !canClaim, busy: isPaying }}
disabled={!canClaim}
onPress={handleClaim}
className={`h-[37px] min-w-[80px] items-center justify-center rounded-full px-[21px] ${
Expand All @@ -114,7 +134,7 @@ const SavingsYieldBoostCard = () => {
: 'bg-white/10'
}`}
>
{isClaiming ? (
{isPaying ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<Text
Expand All @@ -126,8 +146,14 @@ const SavingsYieldBoostCard = () => {
)}
</Pressable>
</View>

{isPaused ? (
<Text className="px-5 pb-[18px] text-sm font-medium text-white/50">
Claims are paused for now. Your boost keeps accruing and nothing is lost.
</Text>
) : null}
</View>
);
};

export default SavingsYieldBoostCard;
export default EarnYieldBoostCard;
178 changes: 178 additions & 0 deletions components/Earn/__tests__/EarnYieldBoostCard.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import React from 'react';

import EarnYieldBoostCard from '@/components/Earn/EarnYieldBoostCard';
import { YieldBoostClaimStatus } from '@/lib/types';
// 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 }));
// 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 {
cn: (...classes: unknown[]) => classes.filter(Boolean).join(' '),
formatNumber,
formatBalanceUSD: (value: number) => (value > 0 ? `$${formatNumber(value, 2)}` : '$0.00'),
};
});
jest.mock('@/hooks/useRewards', () => ({
useYieldBoostSummary: () => mockSummaryQuery,
useClaimYieldBoost: () => ({ mutateAsync: mockClaim, isPending: false }),
}));

const Toast = jest.requireMock('react-native-toast-message').default as { show: jest.Mock };

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

const summary = (overrides: Record<string, unknown> = {}) => ({
enabled: true,
claimsEnabled: true,
tier: 'prime',
apyPercentage: 2,
maxDepositUsd: 10_000,
claimableSoFuse: '51.25',
claimableUsd: 1.64,
totalEarnedSoFuse: '420.5',
totalEarnedUsd: 13.46,
totalClaimedSoFuse: '369.25',
lastAccrual: null,
pendingClaim: null,
maxClaimUsd: 250,
...overrides,
});

let root: any;
const render = () => {
act(() => {
root = create(<EarnYieldBoostCard />);
});
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();
mockSummaryQuery = { isLoading: false, data: summary() };
});
afterEach(() => act(() => root?.unmount()));

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

expect(texts()).toContain('+2% Boost');
expect(texts()).toContain('$13.46');
});

it('claims, and says what was paid', async () => {
mockClaim.mockResolvedValue({
claim: { status: YieldBoostClaimStatus.PAID, soFuseAmount: '51.25' },
summary: summary(),
});
render();

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

expect(mockClaim).toHaveBeenCalledTimes(1);
expect(Toast.show).toHaveBeenCalledWith(
expect.objectContaining({
type: 'success',
text1: 'Yield boost claimed',
text2: '51.25 soFUSE',
}),
);
});

it('says a claim that is still being sent is on its way', async () => {
mockClaim.mockResolvedValue({
claim: { status: YieldBoostClaimStatus.SUBMITTED, soFuseAmount: '51.25' },
summary: summary(),
});
render();

await act(async () => claimButton().props.onPress());

expect(Toast.show).toHaveBeenCalledWith(
expect.objectContaining({ text1: 'Yield boost on its way' }),
);
});

it('shows the reason the backend gave when a claim is refused', async () => {
mockClaim.mockRejectedValue(new Error("You've reached today's limit."));
render();

await act(async () => claimButton().props.onPress());

expect(Toast.show).toHaveBeenCalledWith(
expect.objectContaining({
type: 'error',
text2: "You've reached today's limit.",
}),
);
});

it('disables the claim when nothing is claimable yet', () => {
mockSummaryQuery = { isLoading: false, data: summary({ claimableSoFuse: '0.0' }) };
render();

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

it('disables the claim while an earlier one is still being paid', () => {
mockSummaryQuery = {
isLoading: false,
data: summary({ pendingClaim: { id: 'c1', status: YieldBoostClaimStatus.SUBMITTED } }),
};
render();

expect(claimButton().props.disabled).toBe(true);
expect(claimButton().props.accessibilityState).toEqual({ disabled: true, busy: true });
});

it('explains a paused claim instead of offering it', () => {
mockSummaryQuery = { isLoading: false, data: summary({ claimsEnabled: false }) };
render();

expect(claimButton().props.disabled).toBe(true);
expect(texts()).toContain('Claims are paused for now');
});

it('renders nothing for a user with no boost and nothing to claim', () => {
mockSummaryQuery = {
isLoading: false,
data: summary({ apyPercentage: 0, claimableSoFuse: '0.0' }),
};
render();

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

it('still lets a user who lost the boost collect what they earned', () => {
mockSummaryQuery = { isLoading: false, data: summary({ apyPercentage: 0 }) };
render();

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