Skip to content
Open
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 app/(protected)/(tabs)/savings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -585,6 +586,7 @@ export default function Savings() {
</View>
</>
)}
<YieldBoostBanner />
{!isScreenMedium && <SavingsHeaderButtonsMobile hideSend hideSwap />}

<SavingsAnalytics />
Expand Down
134 changes: 134 additions & 0 deletions components/Savings/YieldBoostBanner.tsx
Original file line number Diff line number Diff line change
@@ -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<Record<RewardsTier, { boost: number; name: string }>> = {
[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 (
<LinearGradient
colors={['rgba(255,209,81,0.15)', 'rgba(255,209,81,0.06)']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={{ borderRadius: 16 }}
>
<View className="flex-row items-center gap-4 px-4 py-4 md:px-6 md:py-5">
<View
className="h-12 w-12 shrink-0 items-center justify-center rounded-full"
style={{ backgroundColor: 'rgba(255,209,81,0.15)' }}
>
<HandCoins color={YELLOW} size={22} />
</View>

<View className="flex-1 gap-0.5">
<Text>
<Text className="text-xl font-semibold text-foreground">{'You are receiving '}</Text>
<Text style={{ color: YELLOW }} className="text-xl font-semibold text-foreground">
{`${0}%`}
</Text>
<Text className="text-xl font-semibold text-foreground">{' yield boost'}</Text>
</Text>
<View className="flex-row flex-wrap items-center gap-x-1">
<Text className="text-base text-primary/70">
{`Your ${'Core'} tier adds ${0}% on top of your base savings rate.`}
</Text>
<Link href={path.REWARDS_BENEFITS} className="hover:opacity-70">
<Text className="text-sm font-semibold text-primary/70 web:underline">
Read more ›
</Text>
</Link>
</View>
</View>

<Button
variant="rewards"
className="h-11 shrink-0 rounded-xl"
disabled={isDisabled}
onPress={() => handleClaim()}
>
<View className="flex-row items-center gap-2">
<Text className="text-sm font-semibold text-white">{getClaimText()}</Text>
{isLoading && <ActivityIndicator color={YELLOW} size="small" />}
</View>
</Button>
</View>
</LinearGradient>
);
};

export default YieldBoostBanner;
70 changes: 33 additions & 37 deletions lib/merkl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<MerklRewards> => {
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 = [
{
Expand All @@ -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(
{
Expand All @@ -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');
Expand All @@ -138,5 +134,5 @@ export const claimMerklRewards = async (
throw error;
}

return transaction
}
return transaction;
};
Loading