diff --git a/.env.example b/.env.example index 77bbe0cf1..2a79463e0 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/components/Rewards/NewRewards/RewardsScreenNew.tsx b/components/Rewards/NewRewards/RewardsScreenNew.tsx index 93a98425c..6904db02f 100644 --- a/components/Rewards/NewRewards/RewardsScreenNew.tsx +++ b/components/Rewards/NewRewards/RewardsScreenNew.tsx @@ -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)} /> diff --git a/components/Rewards/NewRewards/TierBenefitsGrid.tsx b/components/Rewards/NewRewards/TierBenefitsGrid.tsx index b6b47fdf5..a30621edf 100644 --- a/components/Rewards/NewRewards/TierBenefitsGrid.tsx +++ b/components/Rewards/NewRewards/TierBenefitsGrid.tsx @@ -77,6 +77,7 @@ const TierBenefitsGrid = ({ yieldBoostPercentage, yieldBoostCap, yieldBoostEarned, + yieldBoostBalanceCap, onGetMoreCashback, onReferralsPress, ...cashbackData @@ -121,6 +122,7 @@ const TierBenefitsGrid = ({ yieldBoostPercentage={yieldBoostPercentage} yieldBoostCap={yieldBoostCap} yieldBoostEarned={yieldBoostEarned} + yieldBoostBalanceCap={yieldBoostBalanceCap} /> ), subscription: ( diff --git a/components/Rewards/NewRewards/YieldBoostContent.tsx b/components/Rewards/NewRewards/YieldBoostContent.tsx index 7d439824f..7cb8eca23 100644 --- a/components/Rewards/NewRewards/YieldBoostContent.tsx +++ b/components/Rewards/NewRewards/YieldBoostContent.tsx @@ -24,6 +24,7 @@ const YieldBoostContent = ({ yieldBoostPercentage, yieldBoostCap, yieldBoostEarned, + yieldBoostBalanceCap = 0, onClose, animationSession, isSheet = true, @@ -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'} diff --git a/components/Rewards/NewRewards/YieldBoostSheet.types.ts b/components/Rewards/NewRewards/YieldBoostSheet.types.ts index 49b49ab11..f64aa8c8d 100644 --- a/components/Rewards/NewRewards/YieldBoostSheet.types.ts +++ b/components/Rewards/NewRewards/YieldBoostSheet.types.ts @@ -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 { diff --git a/components/Savings/NewSavings/SavingsScreenNew.tsx b/components/Savings/NewSavings/SavingsScreenNew.tsx index 7298e2c69..8cb4a8b0e 100644 --- a/components/Savings/NewSavings/SavingsScreenNew.tsx +++ b/components/Savings/NewSavings/SavingsScreenNew.tsx @@ -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'; @@ -80,6 +81,7 @@ export default function SavingsScreenNew() { <> + ) : ( diff --git a/components/Savings/NewSavings/SavingsYieldBoostCard.tsx b/components/Savings/NewSavings/SavingsYieldBoostCard.tsx index a8bfcdd7d..92cdaf70f 100644 --- a/components/Savings/NewSavings/SavingsYieldBoostCard.tsx +++ b/components/Savings/NewSavings/SavingsYieldBoostCard.tsx @@ -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, @@ -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 ( @@ -37,24 +76,55 @@ const SavingsYieldBoostCard = () => { - + Boost amount - - - +{formatNumber(boostPercentage, 2, 0)}% Boost - - + {boostPercentage > 0 ? ( + + + +{formatNumber(boostPercentage, 2, 0)}% Boost + + + ) : ( + — + )} Total Earned - - {formatBalanceUSD(yieldBoostEarned)} - + {isRewardsLoading ? ( + + ) : ( + + {formatBalanceUSD(earnedUsd)} + + )} + + {isClaiming ? ( + + ) : ( + + Claim + + )} + ); diff --git a/components/Savings/NewSavings/__tests__/SavingsYieldBoostCard.test.tsx b/components/Savings/NewSavings/__tests__/SavingsYieldBoostCard.test.tsx new file mode 100644 index 000000000..689724d97 --- /dev/null +++ b/components/Savings/NewSavings/__tests__/SavingsYieldBoostCard.test.tsx @@ -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 = {}; +let mockBoostQuery: { data?: Record; isLoading: boolean } = { isLoading: false }; +const mockClaim = jest.fn(); + +const ONE_FUSE = 10n ** 18n; + +let root: any; +const render = () => { + act(() => { + root = create(); + }); + 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); +}); diff --git a/hooks/useYieldBoostRewards.ts b/hooks/useYieldBoostRewards.ts new file mode 100644 index 000000000..067244d97 --- /dev/null +++ b/hooks/useYieldBoostRewards.ts @@ -0,0 +1,144 @@ +import * as Sentry from '@sentry/react-native'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { Address, formatUnits } from 'viem'; +import { fuse } from 'viem/chains'; + +import { useActivityActions } from '@/hooks/useActivityActions'; +import useUser from '@/hooks/useUser'; +import MerklDistributorABI from '@/lib/abis/MerklDistributor'; +import { ADDRESSES } from '@/lib/config'; +import { executeTransactions, getTransaction, USER_CANCELLED_TRANSACTION } from '@/lib/execute'; +import { + buildYieldBoostClaimTransactions, + fetchMerklYieldBoostRewards, + YIELD_BOOST_REWARD_DECIMALS, + YIELD_BOOST_REWARD_TOKEN, + yieldBoostClaimPayout, +} from '@/lib/merklYieldBoost'; +import { TransactionType } from '@/lib/types'; +import { publicClient } from '@/lib/wagmi'; + +export const yieldBoostRewardsQueryKey = (safeAddress?: string) => + ['merkl', 'yieldBoost', safeAddress?.toLowerCase()] as const; + +/** + * The Safe's Merkl yield-boost rewards (WFUSE on Fuse). + * + * Merkl publishes a new root every few hours and `pending` moves roughly every + * two, so a minute of staleness is invisible and keeps this off Merkl's rate + * limit however often the savings screen is opened. + */ +export const useYieldBoostRewards = () => { + const { user } = useUser(); + const safeAddress = user?.safeAddress; + + return useQuery({ + queryKey: yieldBoostRewardsQueryKey(safeAddress), + queryFn: () => fetchMerklYieldBoostRewards(safeAddress as string), + enabled: Boolean(safeAddress), + staleTime: 60_000, + }); +}; + +/** + * Claim every yield-boost reward the Safe has and unwrap it to FUSE, in one + * user operation. + * + * Reads fresh (cache-bypassing) proofs and the Distributor's own claimed record + * right before signing, so a root Merkl pushed while the screen sat open, or a + * claim from another device, can't turn into a reverted batch. Resolves to + * `null` when the user cancels the passkey prompt. + */ +export const useClaimYieldBoost = () => { + const { user, safeAA } = useUser(); + const { trackTransaction } = useActivityActions(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async () => { + if (!user?.safeAddress || !user?.suborgId || !user?.signWith) { + throw new Error('Your wallet is still setting up. Please try again shortly.'); + } + + const safeAddress = user.safeAddress as Address; + const distributor = ADDRESSES.fuse.merklDistributor; + + const [rewards, [claimedOnchain]] = await Promise.all([ + fetchMerklYieldBoostRewards(safeAddress, { reload: true }), + publicClient(fuse.id).readContract({ + address: distributor, + abi: MerklDistributorABI, + functionName: 'claimed', + args: [safeAddress, YIELD_BOOST_REWARD_TOKEN], + }), + ]); + + const transactions = buildYieldBoostClaimTransactions({ + safeAddress, + rewards, + claimedOnchain, + }); + if (transactions.length === 0) { + throw new Error('There is nothing to claim yet.'); + } + + const amount = formatUnits( + yieldBoostClaimPayout(rewards.amount, claimedOnchain), + YIELD_BOOST_REWARD_DECIMALS, + ); + + const smartAccountClient = await safeAA(fuse, user.suborgId, user.signWith); + + const result = await trackTransaction( + { + type: TransactionType.MERKL_CLAIM, + title: 'Claim yield boost', + shortTitle: 'Yield boost', + amount, + symbol: 'FUSE', + chainId: fuse.id, + fromAddress: distributor, + toAddress: safeAddress, + metadata: { + description: `Claim ${amount} FUSE yield boost from Merkl`, + tokenAddress: ADDRESSES.fuse.nativeFeeToken, + }, + }, + onUserOpHash => + executeTransactions( + smartAccountClient, + transactions, + 'Failed to claim yield boost', + fuse, + onUserOpHash, + ), + ); + + if (getTransaction(result) === USER_CANCELLED_TRANSACTION) return null; + + return { amount }; + }, + onSuccess: async result => { + if (!result || !user?.safeAddress) return; + + // Merkl takes a few minutes to index the claim, so write the fresh, + // cache-bypassing read into the query rather than waiting for it. + try { + queryClient.setQueryData( + yieldBoostRewardsQueryKey(user.safeAddress), + await fetchMerklYieldBoostRewards(user.safeAddress, { reload: true }), + ); + } catch { + await queryClient.invalidateQueries({ + queryKey: yieldBoostRewardsQueryKey(user.safeAddress), + }); + } + }, + onError: error => { + Sentry.captureException(error, { + tags: { operation: 'yield_boost_claim' }, + extra: { userAddress: user?.safeAddress }, + }); + }, + }); +}; diff --git a/lib/__tests__/merklYieldBoost.test.ts b/lib/__tests__/merklYieldBoost.test.ts new file mode 100644 index 000000000..5236cfbae --- /dev/null +++ b/lib/__tests__/merklYieldBoost.test.ts @@ -0,0 +1,146 @@ +import { decodeFunctionData } from 'viem'; +import { fuse, mainnet } from 'viem/chains'; + +import MerklDistributorABI from '@/lib/abis/MerklDistributor'; +import { wNativeABI } from '@/lib/abis/tokens'; +import { ADDRESSES } from '@/lib/config'; +import { + buildYieldBoostClaimTransactions, + EMPTY_YIELD_BOOST_REWARDS, + parseMerklYieldBoostRewards, + YIELD_BOOST_REWARD_TOKEN, + yieldBoostClaimPayout, +} from '@/lib/merklYieldBoost'; + +const SAFE = '0x1111111111111111111111111111111111111111'; +const BOOST_USD = '0xaaaa'; +const BOOST_ETH = '0xbbbb'; +const OTHER = '0xcccc'; +const PROOF = `0x${'ab'.repeat(32)}` as const; + +const wfuseReward = (overrides: Record = {}) => ({ + amount: '5000', + claimed: '1000', + pending: '300', + proofs: [PROOF], + token: { address: YIELD_BOOST_REWARD_TOKEN.toLowerCase(), price: 0.02 }, + breakdowns: [ + { campaignId: BOOST_USD, amount: '3000', pending: '200' }, + { campaignId: BOOST_ETH.toUpperCase().replace('0X', '0x'), amount: '1500', pending: '100' }, + { campaignId: OTHER, amount: '500', pending: '0' }, + ], + ...overrides, +}); + +const response = (rewards: unknown[], chainId: number = fuse.id) => [ + { chain: { id: chainId }, rewards }, +]; + +describe('parseMerklYieldBoostRewards', () => { + it('reads the WFUSE leaf on Fuse', () => { + const parsed = parseMerklYieldBoostRewards(response([wfuseReward()]), []); + + expect(parsed).toEqual({ + amount: 5000n, + claimed: 1000n, + claimable: 4000n, + earned: 5300n, + proofs: [PROOF], + priceUsd: 0.02, + }); + }); + + it('scopes earned to the boost campaigns, whatever their case', () => { + const parsed = parseMerklYieldBoostRewards(response([wfuseReward()]), [BOOST_USD, BOOST_ETH]); + + // 3000 + 200 + 1500 + 100 — the unrelated campaign's 500 is left out. + expect(parsed.earned).toBe(4800n); + // Claims are per token, so what can be claimed is never scoped. + expect(parsed.claimable).toBe(4000n); + }); + + it('ignores other tokens and other chains', () => { + const otherToken = wfuseReward({ + token: { address: '0x28C3d1cD466Ba22f6cae51b1a4692a831696391A' }, + }); + + expect(parseMerklYieldBoostRewards(response([otherToken]), [])).toBe(EMPTY_YIELD_BOOST_REWARDS); + expect(parseMerklYieldBoostRewards(response([wfuseReward()], mainnet.id), [])).toBe( + EMPTY_YIELD_BOOST_REWARDS, + ); + }); + + it('never reports a negative claimable', () => { + const parsed = parseMerklYieldBoostRewards( + response([wfuseReward({ amount: '1000', claimed: '1000' })]), + [], + ); + + expect(parsed.claimable).toBe(0n); + }); + + it('survives malformed payloads', () => { + expect(parseMerklYieldBoostRewards(null, [])).toBe(EMPTY_YIELD_BOOST_REWARDS); + expect(parseMerklYieldBoostRewards({ error: 'x' }, [])).toBe(EMPTY_YIELD_BOOST_REWARDS); + + const parsed = parseMerklYieldBoostRewards( + response([ + wfuseReward({ + amount: 'NaN', + claimed: undefined, + token: { address: YIELD_BOOST_REWARD_TOKEN }, + }), + ]), + [], + ); + expect(parsed.amount).toBe(0n); + expect(parsed.priceUsd).toBeNull(); + }); +}); + +describe('buildYieldBoostClaimTransactions', () => { + const rewards = { amount: 5000n, proofs: [PROOF] }; + + it('claims the cumulative amount and unwraps exactly what it pays', () => { + const [claim, unwrap] = buildYieldBoostClaimTransactions({ + safeAddress: SAFE, + rewards, + claimedOnchain: 1200n, + }); + + expect(claim.to).toBe(ADDRESSES.fuse.merklDistributor); + expect(decodeFunctionData({ abi: MerklDistributorABI, data: claim.data })).toEqual({ + functionName: 'claim', + args: [[SAFE], [YIELD_BOOST_REWARD_TOKEN], [5000n], [[PROOF]]], + }); + + expect(unwrap.to).toBe(YIELD_BOOST_REWARD_TOKEN); + expect(decodeFunctionData({ abi: wNativeABI, data: unwrap.data })).toEqual({ + functionName: 'withdraw', + args: [3800n], + }); + }); + + it('builds nothing when everything is already claimed', () => { + expect( + buildYieldBoostClaimTransactions({ safeAddress: SAFE, rewards, claimedOnchain: 5000n }), + ).toEqual([]); + }); + + it('builds nothing without proofs', () => { + expect( + buildYieldBoostClaimTransactions({ + safeAddress: SAFE, + rewards: { amount: 5000n, proofs: [] }, + claimedOnchain: 0n, + }), + ).toEqual([]); + }); +}); + +describe('yieldBoostClaimPayout', () => { + it('is the unclaimed remainder, floored at zero', () => { + expect(yieldBoostClaimPayout(5000n, 1000n)).toBe(4000n); + expect(yieldBoostClaimPayout(1000n, 5000n)).toBe(0n); + }); +}); diff --git a/lib/config.ts b/lib/config.ts index a8bb14988..ecbcb29a7 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -69,6 +69,18 @@ export const EXPO_PUBLIC_FIREBASE_DATABASE_URL = export const EXPO_PUBLIC_AMPLITUDE_API_KEY = process.env.EXPO_PUBLIC_AMPLITUDE_API_KEY ?? ''; export const EXPO_PUBLIC_AMPLITUDE_PROXY_URL = process.env.EXPO_PUBLIC_AMPLITUDE_PROXY_URL ?? ''; export const EXPO_PUBLIC_MERKL_CAMPAIGN_ID = process.env.EXPO_PUBLIC_MERKL_CAMPAIGN_ID ?? ''; +/** + * Merkl campaign ids (0x… hashes) of the tiered yield-boost campaigns — one per + * vault (soUSD, soETH, soFUSE), all paying WFUSE on Fuse. Comma-separated. + * They scope "Total Earned" on the savings yield-boost card to the boost; + * claiming always takes the whole WFUSE balance, since Merkl claims per token. + */ +export const EXPO_PUBLIC_MERKL_YIELD_BOOST_CAMPAIGN_IDS: readonly string[] = ( + process.env.EXPO_PUBLIC_MERKL_YIELD_BOOST_CAMPAIGN_IDS ?? '' +) + .split(',') + .map((id: string) => id.trim().toLowerCase()) + .filter(Boolean); export const EXPO_PUBLIC_FLASH_VAULT_MANAGER_API_BASE_URL = process.env.EXPO_PUBLIC_FLASH_VAULT_MANAGER_API_BASE_URL ?? ''; export const EXPO_PUBLIC_BRIDGE_CARD_API_BASE_URL = diff --git a/lib/merklYieldBoost.ts b/lib/merklYieldBoost.ts new file mode 100644 index 000000000..78f909a08 --- /dev/null +++ b/lib/merklYieldBoost.ts @@ -0,0 +1,205 @@ +import { Address, encodeFunctionData, Hex } from 'viem'; +import { fuse } from 'viem/chains'; + +import { WRAPPED_FUSE } from '@/constants/addresses'; +import MerklDistributorABI from '@/lib/abis/MerklDistributor'; +import { wNativeABI } from '@/lib/abis/tokens'; +import { ADDRESSES, EXPO_PUBLIC_MERKL_YIELD_BOOST_CAMPAIGN_IDS } from '@/lib/config'; + +/** + * The tiered yield boost, paid through Merkl. + * + * One campaign per savings vault (soUSD, soETH, soFUSE), all on Fuse and all + * paying WFUSE; the backend's API Boost endpoint sets each Safe's share from its + * tier (Prime +2% on the first $10K, Ultra +3% on the first $25K). Merkl claims + * are per token and cumulative, so the three campaigns collapse into one WFUSE + * leaf and one claim. + * + * Deliberately free of `@merkl/api`: the endpoints used here are plain REST + * (https://developers.merkl.xyz/integrate-merkl/user-rewards), and the typed + * client drags an Elysia runtime into every screen that imports it. + */ + +export const MERKL_API_URL = 'https://api.merkl.xyz'; + +/** Reward token of every yield-boost campaign. */ +export const YIELD_BOOST_REWARD_TOKEN: Address = WRAPPED_FUSE; +export const YIELD_BOOST_REWARD_DECIMALS = 18; + +export interface MerklYieldBoostRewards { + /** Cumulative WFUSE in the live Merkle root, claimed or not (wei). */ + amount: bigint; + /** Cumulative WFUSE already pulled onchain (wei), as Merkl last indexed it. */ + claimed: bigint; + /** `amount - claimed`: what a claim transfers right now (wei). */ + claimable: bigint; + /** + * Everything the boost campaigns have credited, claimed or not, including + * rewards not yet in a root (wei). Drives "Total Earned". + */ + earned: bigint; + /** Proofs for `amount` against the live root. Passed to the Distributor as-is. */ + proofs: Hex[]; + /** Merkl's USD quote for the token, when it sent one. */ + priceUsd: number | null; +} + +export const EMPTY_YIELD_BOOST_REWARDS: MerklYieldBoostRewards = { + amount: 0n, + claimed: 0n, + claimable: 0n, + earned: 0n, + proofs: [], + priceUsd: null, +}; + +const toBigInt = (value: unknown): bigint => { + if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'bigint') { + return 0n; + } + try { + const parsed = BigInt(value); + return parsed > 0n ? parsed : 0n; + } catch { + return 0n; + } +}; + +const sameAddress = (a: unknown, b: string) => + typeof a === 'string' && a.toLowerCase() === b.toLowerCase(); + +interface MerklRewardBreakdownJson { + campaignId?: string; + amount?: string; + pending?: string; +} + +interface MerklRewardJson { + amount?: string; + claimed?: string; + pending?: string; + proofs?: string[]; + token?: { address?: string; price?: number | null }; + breakdowns?: MerklRewardBreakdownJson[]; +} + +interface MerklChainRewardsJson { + chain?: { id?: number }; + rewards?: MerklRewardJson[]; +} + +/** + * The WFUSE-on-Fuse leaf out of a `/v4/users/{address}/rewards` response. + * + * `campaignIds` scopes `earned` to the boost campaigns, so an unrelated WFUSE + * campaign the Safe also earns from doesn't inflate "Total Earned". Empty means + * unscoped: every WFUSE reward on Fuse counts. `claimable` is never scoped — + * the Distributor only claims whole tokens. + */ +export const parseMerklYieldBoostRewards = ( + data: unknown, + campaignIds: readonly string[] = EXPO_PUBLIC_MERKL_YIELD_BOOST_CAMPAIGN_IDS, +): MerklYieldBoostRewards => { + if (!Array.isArray(data)) return EMPTY_YIELD_BOOST_REWARDS; + + const reward = (data as MerklChainRewardsJson[]) + .filter(entry => entry?.chain?.id === fuse.id) + .flatMap(entry => entry.rewards ?? []) + .find(item => sameAddress(item?.token?.address, YIELD_BOOST_REWARD_TOKEN)); + + if (!reward) return EMPTY_YIELD_BOOST_REWARDS; + + const amount = toBigInt(reward.amount); + const claimed = toBigInt(reward.claimed); + const scope = new Set(campaignIds.map(id => id.toLowerCase())); + + const earned = + scope.size === 0 + ? amount + toBigInt(reward.pending) + : (reward.breakdowns ?? []) + .filter(breakdown => scope.has(String(breakdown?.campaignId ?? '').toLowerCase())) + .reduce( + (total, breakdown) => total + toBigInt(breakdown.amount) + toBigInt(breakdown.pending), + 0n, + ); + + const price = reward.token?.price; + + return { + amount, + claimed, + claimable: amount > claimed ? amount - claimed : 0n, + earned, + proofs: (reward.proofs ?? []).filter((proof): proof is Hex => typeof proof === 'string'), + priceUsd: typeof price === 'number' && Number.isFinite(price) && price > 0 ? price : null, + }; +}; + +/** + * The Safe's yield-boost rewards from Merkl. + * + * `reload` bypasses Merkl's cache for Fuse, which otherwise keeps reporting the + * old `claimed` for up to ~5 minutes after a claim. It is more expensive for + * Merkl to serve, so it is only for the read right before and right after a + * claim. + */ +export const fetchMerklYieldBoostRewards = async ( + address: string, + { reload = false }: { reload?: boolean } = {}, +): Promise => { + const params = new URLSearchParams({ chainId: String(fuse.id) }); + if (reload) params.set('reloadChainId', String(fuse.id)); + + const response = await fetch(`${MERKL_API_URL}/v4/users/${address}/rewards?${params}`); + if (!response.ok) { + throw new Error(`Failed to fetch yield boost rewards (${response.status})`); + } + + return parseMerklYieldBoostRewards(await response.json()); +}; + +/** What a claim of `amount` transfers, given the Distributor's own record. */ +export const yieldBoostClaimPayout = (amount: bigint, claimedOnchain: bigint) => + amount > claimedOnchain ? amount - claimedOnchain : 0n; + +/** + * Claim the WFUSE leaf and unwrap what it pays into native FUSE, in one batch. + * + * The unwrap amount is measured against the Distributor's own `claimed` record + * rather than Merkl's indexed one: the claim transfers exactly + * `amount - claimedOnchain`, and unwrapping a stale larger figure would revert + * the whole batch. Returns no calls when there is nothing to claim. + */ +export const buildYieldBoostClaimTransactions = ({ + safeAddress, + rewards, + claimedOnchain, +}: { + safeAddress: Address; + rewards: Pick; + claimedOnchain: bigint; +}): { to: Address; data: Hex; value: bigint }[] => { + const payout = yieldBoostClaimPayout(rewards.amount, claimedOnchain); + if (payout === 0n || rewards.proofs.length === 0) return []; + + return [ + { + to: ADDRESSES.fuse.merklDistributor, + data: encodeFunctionData({ + abi: MerklDistributorABI, + functionName: 'claim', + args: [[safeAddress], [YIELD_BOOST_REWARD_TOKEN], [rewards.amount], [rewards.proofs]], + }), + value: 0n, + }, + { + to: YIELD_BOOST_REWARD_TOKEN, + data: encodeFunctionData({ + abi: wNativeABI, + functionName: 'withdraw', + args: [payout], + }), + value: 0n, + }, + ]; +}; diff --git a/lib/types.ts b/lib/types.ts index 3b33ede3d..2abe14333 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -1803,6 +1803,11 @@ export interface RewardsUserData { yieldBoostCap?: number; /** Yield boost payouts the user has already received, in USD. */ yieldBoostEarned?: number; + /** + * Savings balance the current tier's boost is paid on, in USD — Prime's + * first $10K, Ultra's first $25K. 0 (or absent) when unboosted. + */ + yieldBoostBalanceCap?: number; /** * Cashback % the current tier earns back on eligible subscriptions. 0 (or * absent) means the tier grants none, which hides the subscription card. @@ -1946,6 +1951,8 @@ export interface TierBenefits { yieldBoostPercentage?: number; /** Ceiling on yield boost payouts for this tier, in USD. */ yieldBoostCap?: number; + /** Savings balance this tier's boost is paid on, in USD (0 when unboosted). */ + yieldBoostBalanceCap?: number; cardCashback: TierBenefit; /** * `subscriptionDiscount` as a bare percentage (0 when the tier grants none),