diff --git a/app/(protected)/(tabs)/savings.tsx b/app/(protected)/(tabs)/savings.tsx index cb77c8d02..4b6bcae7e 100644 --- a/app/(protected)/(tabs)/savings.tsx +++ b/app/(protected)/(tabs)/savings.tsx @@ -15,6 +15,7 @@ import SavingsEmptyState from '@/components/Savings/EmptyState'; import SavingsAnalytics from '@/components/Savings/SavingsAnalytics'; import SavingsHeaderButtonsMobile from '@/components/Savings/SavingsHeaderButtonsMobile'; import SavingVault from '@/components/Savings/SavingVault'; +import YieldBoostBanner from '@/components/Savings/YieldBoostBanner'; import TooltipPopover from '@/components/Tooltip'; import Skeleton from '@/components/ui/skeleton'; import { Text } from '@/components/ui/text'; @@ -585,6 +586,7 @@ export default function Savings() { )} + {!isScreenMedium && } diff --git a/components/Savings/YieldBoostBanner.tsx b/components/Savings/YieldBoostBanner.tsx new file mode 100644 index 000000000..2d7341710 --- /dev/null +++ b/components/Savings/YieldBoostBanner.tsx @@ -0,0 +1,134 @@ +import { ActivityIndicator, View } from 'react-native'; +import Toast from 'react-native-toast-message'; +import { LinearGradient } from 'expo-linear-gradient'; +import { Link } from 'expo-router'; +import * as Sentry from '@sentry/react-native'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { HandCoins } from 'lucide-react-native'; +import { fuse } from 'viem/chains'; + +import { Button } from '@/components/ui/button'; +import { Text } from '@/components/ui/text'; +import { path } from '@/constants/path'; +import { useActivity } from '@/hooks/useActivity'; +import { useRewardsUserData } from '@/hooks/useRewards'; +import useUser from '@/hooks/useUser'; +import { calculateUnclaimedMerklRewards, claimMerklRewards, getMerklRewards } from '@/lib/merkl'; +import { RewardsTier } from '@/lib/types'; +import { compactNumberFormat } from '@/lib/utils'; + +const YELLOW = '#ffd151'; + +const TIER_BOOST: Partial> = { + [RewardsTier.PRIME]: { boost: 2, name: 'Prime' }, + [RewardsTier.ULTRA]: { boost: 5, name: 'Ultra' }, +}; + +const YieldBoostBanner = () => { + const { data: rewardsData } = useRewardsUserData(); + const tierConfig = rewardsData?.currentTier ? TIER_BOOST[rewardsData.currentTier] : undefined; + + const { user, safeAA } = useUser(); + const queryClient = useQueryClient(); + const { trackTransaction } = useActivity(); + + const { data: merklRewards, isLoading: isMerklLoading } = useQuery({ + queryKey: ['merkl', user?.safeAddress], + queryFn: () => getMerklRewards(user?.safeAddress as string, fuse.id), + enabled: !!user?.safeAddress, + }); + + const totalUnclaimed = merklRewards + ? Number(calculateUnclaimedMerklRewards(merklRewards).formatted) + : 0; + + const { mutate: handleClaim, isPending: isClaiming } = useMutation({ + mutationFn: async () => { + if (!user?.suborgId || !user?.signWith) { + throw new Error('User suborgId or signWith not found'); + } + const smartAccountClient = await safeAA(fuse, user.suborgId, user.signWith); + await claimMerklRewards(smartAccountClient, fuse, trackTransaction); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['merkl', user?.safeAddress] }); + }, + onError: error => { + const errorMessage = 'Error claiming boosted yield'; + console.error(errorMessage, error); + Sentry.captureException(error, { + tags: { type: 'merkl_claim_banner', userId: user?.userId }, + }); + Toast.show({ + type: 'error', + text1: errorMessage, + text2: 'Please try again.', + props: { badgeText: '' }, + }); + }, + }); + + const isLoading = isMerklLoading || isClaiming; + const isDisabled = isLoading || totalUnclaimed === 0; + + const getClaimText = () => { + if (isClaiming) return 'Claiming…'; + if (isMerklLoading) return 'Checking…'; + if (totalUnclaimed > 0) return `Claim $${compactNumberFormat(totalUnclaimed)}`; + return 'Claim boosted yield'; + }; + + if (!tierConfig) return null; + + return ( + + + + + + + + + {'You are receiving '} + + {`${0}%`} + + {' yield boost'} + + + + {`Your ${'Core'} tier adds ${0}% on top of your base savings rate.`} + + + + Read more › + + + + + + + + + ); +}; + +export default YieldBoostBanner; diff --git a/lib/merkl.ts b/lib/merkl.ts index 83fa3612b..2b3fdd0b2 100644 --- a/lib/merkl.ts +++ b/lib/merkl.ts @@ -20,66 +20,66 @@ export const calculateUnclaimedMerklRewards = (rewards: MerklRewards) => { return { value, formatted, - } -} + }; +}; export const getMerklRewards = async ( address: string, chainId: number, - campaignId: string = EXPO_PUBLIC_MERKL_CAMPAIGN_ID + campaignId: string = EXPO_PUBLIC_MERKL_CAMPAIGN_ID, ): Promise => { const { status, data } = await MerklApi('https://api.merkl.xyz') .v4.users({ address }) - .rewards.get({ query: { chainId: [chainId.toString()], breakdownPage: 0 } }) + .rewards.get({ query: { chainId: [chainId.toString()], breakdownPage: 0 } }); - if (status !== 200) throw 'Failed to fetch Merkl rewards' + if (status !== 200) throw 'Failed to fetch Merkl rewards'; - if (!data) throw 'No data received from Merkl API' + if (!data) throw 'No data received from Merkl API'; let rewardsData: MerklRewards = []; for (const d of data) { - if (d.chain.id !== chainId) continue + if (d.chain.id !== chainId) continue; for (const reward of d.rewards) { for (const breakdown of reward.breakdowns) { if (breakdown.campaignId === campaignId) { - rewardsData.push(reward as unknown as MerklReward) + rewardsData.push(reward as unknown as MerklReward); } } } } - return rewardsData -} + return rewardsData; +}; export const claimMerklRewards = async ( smartAccountClient: SmartAccountClient, chain: Chain, trackTransaction: TrackTransaction, - campaignId: string = EXPO_PUBLIC_MERKL_CAMPAIGN_ID + campaignId: string = EXPO_PUBLIC_MERKL_CAMPAIGN_ID, ) => { - const safeAddress = smartAccountClient.account?.address - if (!safeAddress) throw 'Safe address not found' + const safeAddress = smartAccountClient.account?.address; + if (!safeAddress) throw 'Safe address not found'; - const rewards = await getMerklRewards(safeAddress, chain.id, campaignId) + const rewards = await getMerklRewards(safeAddress, chain.id, campaignId); - const users: `0x${string}`[] = [] - const tokens: `0x${string}`[] = [] - const amounts: bigint[] = [] - const proofs: `0x${string}`[][] = [] + const users: `0x${string}`[] = []; + const tokens: `0x${string}`[] = []; + const amounts: bigint[] = []; + const proofs: `0x${string}`[][] = []; for (const reward of rewards) { - const tokenAddress = reward.token.address as `0x${string}` - users.push(safeAddress) - tokens.push(tokenAddress) - amounts.push(BigInt(reward.amount)) - proofs.push(reward.proofs as `0x${string}`[]) + const tokenAddress = reward.token.address as `0x${string}`; + users.push(safeAddress); + tokens.push(tokenAddress); + amounts.push(BigInt(reward.amount)); + proofs.push(reward.proofs as `0x${string}`[]); } - if (tokens.length === 0) throw 'No tokens to claim Merkl rewards' + if (tokens.length === 0) throw 'No tokens to claim Merkl rewards'; - const merklDistributorAddress = ADDRESSES.fuse.merklDistributor + const merklDistributorAddress = ADDRESSES.fuse.merklDistributor; const transactions = [ { @@ -90,11 +90,11 @@ export const claimMerklRewards = async ( args: [users, tokens, amounts, proofs], }), }, - ] + ]; - const { formatted: formattedAmount } = calculateUnclaimedMerklRewards(rewards) + const { formatted: formattedAmount } = calculateUnclaimedMerklRewards(rewards); // Track only successful claim rewards - const status = TransactionStatus.SUCCESS + const status = TransactionStatus.SUCCESS; const result = await trackTransaction( { @@ -111,15 +111,11 @@ export const claimMerklRewards = async ( tokenAddress: ADDRESSES.fuse.vault, }, }, - () => executeTransactions( - smartAccountClient, - transactions, - 'Failed to claim Merkl rewards', - chain, - ) + () => + executeTransactions(smartAccountClient, transactions, 'Failed to claim Merkl rewards', chain), ); - const transaction = getTransaction(result) + const transaction = getTransaction(result); if (transaction === USER_CANCELLED_TRANSACTION) { const error = new Error('User cancelled transaction'); @@ -138,5 +134,5 @@ export const claimMerklRewards = async ( throw error; } - return transaction -} + return transaction; +};