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;
+};