diff --git a/AGENTS.md b/AGENTS.md index 5186d4b3..f32e3ca2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -150,6 +150,8 @@ When touching transaction and position flows, validation MUST include all releva 15. **Transaction-history consistency**: dedupe and merge confirmed local/on-chain history consistently (canonical user+chain scope, stable dedup key, bounded TTL) to avoid double counting during indexer lag. 16. **Share-based full-exit withdrawals**: when an existing supplied position is intended to be fully exited (or the target leaves only dust), prefer share-based `morphoWithdraw` over asset-amount withdrawal so residual dust is not stranded by rounding. 17. **UI signal quality**: remove duplicate or low-signal metrics and keep transaction-critical UI focused on decision-relevant data. +18. **External incentive parity**: when external rewards (for example Merkl HOLD opportunities) materially change carry economics, include them in net preview calculations when global reward-inclusion is enabled, and resolve incentives using canonical chainId+address mappings rather than token symbols. +19. **APR/APY unit homogeneity**: in any reward/carry/net-rate calculation, normalize every term (base rate, each reward component, aggregates, and displayed subtotals) to the same selected mode before combining, so displayed formulas remain numerically consistent in both APR and APY modes. ### REQUIRED: Regression Rule Capture After fixing any user-reported bug in a high-impact flow: diff --git a/src/components/shared/help-tooltip-icon.tsx b/src/components/shared/help-tooltip-icon.tsx new file mode 100644 index 00000000..13765646 --- /dev/null +++ b/src/components/shared/help-tooltip-icon.tsx @@ -0,0 +1,43 @@ +import type { ReactNode } from 'react'; +import { IoHelpCircleOutline } from 'react-icons/io5'; +import { Tooltip } from '@/components/ui/tooltip'; +import { cn } from '@/utils/components'; + +type HelpTooltipIconProps = { + content: ReactNode; + ariaLabel?: string; + className?: string; + iconClassName?: string; + tooltipClassName?: string; + size?: number; +}; + +export function HelpTooltipIcon({ + content, + ariaLabel = 'Show help information', + className, + iconClassName, + tooltipClassName, + size = 14, +}: HelpTooltipIconProps) { + return ( + + + + ); +} diff --git a/src/components/shared/tooltip-content.tsx b/src/components/shared/tooltip-content.tsx index d803d9d2..b888ba6b 100644 --- a/src/components/shared/tooltip-content.tsx +++ b/src/components/shared/tooltip-content.tsx @@ -48,7 +48,7 @@ export function TooltipContent({
{icon &&
{icon}
}
- {title &&
{title}
} + {title &&
{title}
} {detail &&
{detail}
} {secondaryDetail &&
{secondaryDetail}
}
diff --git a/src/components/ui/tooltip.tsx b/src/components/ui/tooltip.tsx index e01a5863..22ed191e 100644 --- a/src/components/ui/tooltip.tsx +++ b/src/components/ui/tooltip.tsx @@ -18,7 +18,7 @@ const TooltipContent = React.forwardRef< ref={ref} sideOffset={sideOffset} className={cn( - 'z-50 overflow-hidden rounded-sm bg-surface px-5 py-4 text-sm text-primary shadow-md animate-in fade-in-0 zoom-in-95', + 'z-50 overflow-hidden rounded-sm bg-surface px-5 py-4 text-sm text-primary font-zen shadow-md animate-in fade-in-0 zoom-in-95', 'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95', 'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', className, diff --git a/src/constants/merklHoldIncentives.ts b/src/constants/merklHoldIncentives.ts new file mode 100644 index 00000000..6364d8fc --- /dev/null +++ b/src/constants/merklHoldIncentives.ts @@ -0,0 +1,38 @@ +import type { Address } from 'viem'; +import { SupportedNetworks } from '@/utils/networks'; + +export type HardcodedMerklHoldIncentive = { + chainId: number; + collateralTokenAddress: Address; + opportunityType: string; + opportunityIdentifier: string; + label: string; +}; + +const normalizeAddress = (address: string): string => address.toLowerCase(); + +export const HARDCODED_MERKL_HOLD_INCENTIVES: readonly HardcodedMerklHoldIncentive[] = [ + { + chainId: SupportedNetworks.Base, + collateralTokenAddress: '0x0000000f2eB9f69274678c76222B35eEc7588a65', + opportunityType: 'ERC20LOGPROCESSOR', + opportunityIdentifier: '0x0000000f2eB9f69274678c76222B35eEc7588a65', + label: 'yoUSD', + }, +] as const; + +export const getHardcodedMerklHoldIncentive = ({ + chainId, + collateralTokenAddress, +}: { + chainId: number; + collateralTokenAddress: string; +}): HardcodedMerklHoldIncentive | null => { + const normalizedCollateralAddress = normalizeAddress(collateralTokenAddress); + + const match = HARDCODED_MERKL_HOLD_INCENTIVES.find( + (incentive) => incentive.chainId === chainId && normalizeAddress(incentive.collateralTokenAddress) === normalizedCollateralAddress, + ); + + return match ?? null; +}; diff --git a/src/features/market-detail/components/position-stats.tsx b/src/features/market-detail/components/position-stats.tsx index 43fbd522..b9a935db 100644 --- a/src/features/market-detail/components/position-stats.tsx +++ b/src/features/market-detail/components/position-stats.tsx @@ -6,14 +6,11 @@ import { LuUser } from 'react-icons/lu'; import { HiOutlineGlobeAsiaAustralia } from 'react-icons/hi2'; import { Spinner } from '@/components/ui/spinner'; import { TokenIcon } from '@/components/shared/token-icon'; -import { useMarketCampaigns } from '@/hooks/useMarketCampaigns'; -import { useAppSettings } from '@/stores/useAppSettings'; import { useRateLabel } from '@/hooks/useRateLabel'; import { formatBalance, formatReadable } from '@/utils/balance'; import { getTruncatedAssetName } from '@/utils/oracle'; -import { convertApyToApr } from '@/utils/rateMath'; import type { Market, MarketPosition } from '@/utils/types'; -import { APYBreakdownTooltip } from '@/features/markets/components/apy-breakdown-tooltip'; +import { APYCell } from '@/features/markets/components/apy-breakdown-tooltip'; type PositionStatsProps = { market: Market; @@ -36,15 +33,8 @@ export function PositionStats({ market, userPosition, positionLoading, cardStyle // Default to user view if they have a position, otherwise global const [viewMode, setViewMode] = useState<'global' | 'user'>(userPosition && hasPosition(userPosition) ? 'user' : 'global'); - const { showFullRewardAPY, isAprDisplay } = useAppSettings(); const { label: rateLabel } = useRateLabel({ prefix: 'Supply' }); const { label: borrowRateLabel } = useRateLabel({ prefix: 'Borrow' }); - const { activeCampaigns, hasActiveRewards } = useMarketCampaigns({ - marketId: market.uniqueKey, - loanTokenAddress: market.loanAsset.address, - chainId: market.morphoBlue.chain.id, - whitelisted: market.whitelisted, - }); const toggleView = () => { setViewMode((prev) => (prev === 'global' ? 'user' : 'global')); @@ -118,18 +108,6 @@ export function PositionStats({ market, userPosition, positionLoading, cardStyle ); } - // Global stats - calculate rates - const baseSupplyAPY = market.state.supplyApy * 100; - const baseBorrowAPY = market.state.borrowApy * 100; - - // Convert to APR if display mode is enabled - const baseSupplyRate = isAprDisplay ? convertApyToApr(market.state.supplyApy) * 100 : baseSupplyAPY; - const baseBorrowRate = isAprDisplay ? convertApyToApr(market.state.borrowApy) * 100 : baseBorrowAPY; - - const extraRewards = hasActiveRewards ? activeCampaigns.reduce((sum, campaign) => sum + campaign.apr, 0) : 0; - const fullSupplyRate = baseSupplyRate + extraRewards; - const displaySupplyRate = showFullRewardAPY && hasActiveRewards ? fullSupplyRate : baseSupplyRate; - return (
@@ -167,24 +145,27 @@ export function PositionStats({ market, userPosition, positionLoading, cardStyle
{rateLabel}:
- {hasActiveRewards ? ( - - - {baseSupplyRate.toFixed(2)}% (+{extraRewards.toFixed(2)}%) - - + {market.state.supplyApy != null ? ( + ) : ( - {displaySupplyRate.toFixed(2)}% + )}
{borrowRateLabel}:
- {baseBorrowRate.toFixed(2)}% + {market.state.borrowApy != null ? ( + + ) : ( + + )}
diff --git a/src/features/markets/components/apy-breakdown-tooltip.tsx b/src/features/markets/components/apy-breakdown-tooltip.tsx index 136734b0..de128712 100644 --- a/src/features/markets/components/apy-breakdown-tooltip.tsx +++ b/src/features/markets/components/apy-breakdown-tooltip.tsx @@ -1,64 +1,123 @@ import type React from 'react'; +import { RiSparklingFill } from 'react-icons/ri'; import { Tooltip } from '@/components/ui/tooltip'; import { TokenIcon } from '@/components/shared/token-icon'; import { useMarketCampaigns } from '@/hooks/useMarketCampaigns'; import { useAppSettings } from '@/stores/useAppSettings'; import { useRateLabel } from '@/hooks/useRateLabel'; +import { MONARCH_PRIMARY } from '@/constants/chartColors'; import type { SimplifiedCampaign } from '@/utils/merklTypes'; -import { convertApyToApr } from '@/utils/rateMath'; +import { convertAprToApy, convertApyToApr } from '@/utils/rateMath'; import type { Market } from '@/utils/types'; +type RateMode = 'supply' | 'borrow'; + type APYBreakdownTooltipProps = { baseAPY: number; activeCampaigns: SimplifiedCampaign[]; children: React.ReactNode; + mode?: RateMode; }; type APYCellProps = { market: Market; + mode?: RateMode; +}; + +const modeByType: Record = { + MORPHOSUPPLY: 'supply', + MORPHOSUPPLY_SINGLETOKEN: 'supply', + MORPHOBORROW: 'borrow', +}; + +const getCampaignMode = (campaign: SimplifiedCampaign): RateMode | null => { + const directMode = modeByType[campaign.type]; + if (directMode) return directMode; + + if (campaign.type !== 'MULTILENDBORROW') return null; + + const action = campaign.opportunityAction?.toUpperCase(); + if (action === 'LEND') return 'supply'; + if (action === 'BORROW') return 'borrow'; + + const name = campaign.name?.toLowerCase() ?? ''; + if (name.includes('borrow')) return 'borrow'; + if (name.includes('supply') || name.includes('lend')) return 'supply'; + + return null; +}; + +const filterCampaignsByMode = (campaigns: SimplifiedCampaign[], mode: RateMode): SimplifiedCampaign[] => { + return campaigns.filter((campaign) => getCampaignMode(campaign) === mode); }; -export function APYBreakdownTooltip({ baseAPY, activeCampaigns, children }: APYBreakdownTooltipProps) { +const getFullRate = (baseRate: number, rewardTotal: number, mode: RateMode): number => { + return mode === 'borrow' ? baseRate - rewardTotal : baseRate + rewardTotal; +}; + +const getRewardRatePrefix = (mode: RateMode): string => { + return mode === 'borrow' ? '-' : '+'; +}; + +const getDisplayRewardRate = (rewardAprPercent: number, isAprDisplay: boolean): number => { + if (!Number.isFinite(rewardAprPercent)) return 0; + if (isAprDisplay) return rewardAprPercent; + const rewardApyPercent = convertAprToApy(rewardAprPercent / 100) * 100; + return Number.isFinite(rewardApyPercent) ? rewardApyPercent : 0; +}; + +export function APYBreakdownTooltip({ baseAPY, activeCampaigns, children, mode = 'supply' }: APYBreakdownTooltipProps) { const { isAprDisplay } = useAppSettings(); const { short: rateLabel } = useRateLabel(); + const modeLabel = mode === 'borrow' ? 'Borrow' : 'Supply'; // Convert base rate if APR display is enabled // Note: baseAPY is already a percentage (not decimal), so we need to convert it const baseRateValue = isAprDisplay ? convertApyToApr(baseAPY / 100) * 100 : baseAPY; - // Calculate total: base (converted if needed) + rewards (already APR) - const rewardTotal = activeCampaigns.reduce((sum, campaign) => sum + campaign.apr, 0); - const totalRate = baseRateValue + rewardTotal; + const rewardTotal = activeCampaigns.reduce((sum, campaign) => { + return sum + getDisplayRewardRate(campaign.apr, isAprDisplay); + }, 0); + const totalRate = getFullRate(baseRateValue, rewardTotal, mode); + const rewardPrefix = getRewardRatePrefix(mode); const content = ( -
-
{rateLabel} Breakdown
+
+
+ {modeLabel} {rateLabel} Breakdown +
- Base {rateLabel} + Base {modeLabel} {rateLabel} {baseRateValue.toFixed(2)}%
- {activeCampaigns.map((campaign, index) => ( -
-
- {campaign.rewardToken.symbol} - + {activeCampaigns.map((campaign) => { + const rewardRateValue = getDisplayRewardRate(campaign.apr, isAprDisplay); + return ( +
+
+ {campaign.rewardToken.symbol} + +
+ + {rewardPrefix} + {rewardRateValue.toFixed(2)}% +
- {campaign.apr.toFixed(2)}% -
- ))} + ); + })}
- Total + Net {modeLabel} {rateLabel} {totalRate.toFixed(2)}%
@@ -66,39 +125,73 @@ export function APYBreakdownTooltip({ baseAPY, activeCampaigns, children }: APYB
); - return {children}; + return ( + + {children} + + ); } -export function APYCell({ market }: APYCellProps) { +export function APYCell({ market, mode = 'supply' }: APYCellProps) { const { showFullRewardAPY, isAprDisplay } = useAppSettings(); - const { activeCampaigns, hasActiveRewards } = useMarketCampaigns({ + const { activeCampaigns } = useMarketCampaigns({ marketId: market.uniqueKey, loanTokenAddress: market.loanAsset.address, chainId: market.morphoBlue.chain.id, whitelisted: market.whitelisted, }); - const baseAPY = market.state.supplyApy * 100; - const extraRewards = hasActiveRewards ? activeCampaigns.reduce((sum, campaign) => sum + campaign.apr, 0) : 0; + const baseApyDecimal = mode === 'borrow' ? market.state.borrowApy : market.state.supplyApy; + const baseAPY = baseApyDecimal * 100; + const relevantCampaigns = filterCampaignsByMode(activeCampaigns, mode); + const hasModeRewards = relevantCampaigns.length > 0; + const extraRewards = hasModeRewards + ? relevantCampaigns.reduce((sum, campaign) => { + return sum + getDisplayRewardRate(campaign.apr, isAprDisplay); + }, 0) + : 0; // Convert base rate if APR display is enabled - const baseRate = isAprDisplay ? convertApyToApr(market.state.supplyApy) * 100 : baseAPY; + const baseRate = isAprDisplay ? convertApyToApr(baseApyDecimal) * 100 : baseAPY; - // Full rate includes base (converted if needed) + rewards - const fullRate = baseRate + extraRewards; + // Net rate: suppliers earn rewards, borrowers are offset by rewards. + const fullRate = getFullRate(baseRate, extraRewards, mode); + const showRewardsInline = showFullRewardAPY && hasModeRewards; + const displayRate = showRewardsInline ? fullRate : baseRate; - const displayRate = showFullRewardAPY && hasActiveRewards ? fullRate : baseRate; + const rateDisplay = ( + + {displayRate.toFixed(2)}% + {showRewardsInline && ( + + )} + + ); - if (hasActiveRewards) { + if (hasModeRewards) { + const modeLabel = mode === 'borrow' ? 'borrow' : 'supply'; return ( - {displayRate.toFixed(2)}% + ); } - return {displayRate.toFixed(2)}%; + return rateDisplay; } diff --git a/src/features/markets/components/table/market-table-body.tsx b/src/features/markets/components/table/market-table-body.tsx index d5ba55a6..138f5b7c 100644 --- a/src/features/markets/components/table/market-table-body.tsx +++ b/src/features/markets/components/table/market-table-body.tsx @@ -215,7 +215,7 @@ export function MarketTableBody({ currentEntries, expandedRowId, setExpandedRowI className="z-50 text-center" style={{ minWidth: '85px', paddingLeft: 3, paddingRight: 3 }} > -

{item.state.borrowApy ? : '—'}

+

{item.state.borrowApy != null ? : '—'}

)} {columnVisibility.rateAtTarget && ( diff --git a/src/features/positions/components/borrow-position-actions-dropdown.tsx b/src/features/positions/components/borrow-position-actions-dropdown.tsx index 7f45ab56..3dc308e3 100644 --- a/src/features/positions/components/borrow-position-actions-dropdown.tsx +++ b/src/features/positions/components/borrow-position-actions-dropdown.tsx @@ -3,6 +3,7 @@ import type React from 'react'; import { IoEllipsisVertical } from 'react-icons/io5'; import { BsArrowDownLeftCircle, BsArrowUpRightCircle } from 'react-icons/bs'; +import { TbTrendingDown } from 'react-icons/tb'; import { Button } from '@/components/ui/button'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; @@ -11,6 +12,7 @@ type BorrowPositionActionsDropdownProps = { isActiveDebt: boolean; onBorrowMoreClick: () => void; onRepayClick: () => void; + onDeleverageClick: () => void; }; export function BorrowPositionActionsDropdown({ @@ -18,6 +20,7 @@ export function BorrowPositionActionsDropdown({ isActiveDebt, onBorrowMoreClick, onRepayClick, + onDeleverageClick, }: BorrowPositionActionsDropdownProps) { const handleClick = (event: React.MouseEvent) => { event.stopPropagation(); @@ -61,6 +64,16 @@ export function BorrowPositionActionsDropdown({ > {isActiveDebt ? 'Repay' : 'Manage'} + {isActiveDebt && ( + } + disabled={!isOwner} + className={isOwner ? '' : 'cursor-not-allowed opacity-50'} + > + Deleverage + + )}
diff --git a/src/features/positions/components/borrowed-morpho-blue-table.tsx b/src/features/positions/components/borrowed-morpho-blue-table.tsx index da9ccd8d..d13ab74d 100644 --- a/src/features/positions/components/borrowed-morpho-blue-table.tsx +++ b/src/features/positions/components/borrowed-morpho-blue-table.tsx @@ -199,6 +199,16 @@ export function BorrowedMorphoBlueTable({ account, positions, onRefetch, isRefet }, }) } + onDeleverageClick={() => + open('leverage', { + market: row.market, + defaultMode: 'deleverage', + toggleLeverageDeleverage: false, + refetch: () => { + void onRefetch(); + }, + }) + } />
diff --git a/src/hooks/leverage/math.ts b/src/hooks/leverage/math.ts index 572a5c88..248a342d 100644 --- a/src/hooks/leverage/math.ts +++ b/src/hooks/leverage/math.ts @@ -28,7 +28,7 @@ const getSlippageFloorBps = (slippageBps?: number): bigint => { return floorBps > 0n ? floorBps : 1n; }; -const toScaledRatio = (numerator: bigint, denominator: bigint): number | null => { +export const toScaledRatio = (numerator: bigint, denominator: bigint): number | null => { if (denominator <= 0n) return null; const scaledRatio = (numerator * APY_RATIO_SCALE) / denominator; const ratio = Number(scaledRatio) / Number(APY_RATIO_SCALE); diff --git a/src/hooks/queries/useMerklCampaignsQuery.ts b/src/hooks/queries/useMerklCampaignsQuery.ts index 31f5dc23..636a22d9 100644 --- a/src/hooks/queries/useMerklCampaignsQuery.ts +++ b/src/hooks/queries/useMerklCampaignsQuery.ts @@ -2,7 +2,7 @@ import { useQuery } from '@tanstack/react-query'; import { fetchActiveCampaigns, simplifyMerklCampaign, expandMultiLendBorrowCampaign } from '@/utils/merklApi'; import type { SimplifiedCampaign, MerklCampaignType } from '@/utils/merklTypes'; -const CAMPAIGN_TYPES_TO_FETCH: MerklCampaignType[] = ['MORPHOSUPPLY', 'MORPHOSUPPLY_SINGLETOKEN', 'MULTILENDBORROW']; +const CAMPAIGN_TYPES_TO_FETCH: MerklCampaignType[] = ['MORPHOSUPPLY', 'MORPHOBORROW', 'MORPHOSUPPLY_SINGLETOKEN', 'MULTILENDBORROW']; export const useMerklCampaignsQuery = () => { const query = useQuery({ diff --git a/src/hooks/queries/useMerklHoldIncentivesQuery.ts b/src/hooks/queries/useMerklHoldIncentivesQuery.ts new file mode 100644 index 00000000..8b52671c --- /dev/null +++ b/src/hooks/queries/useMerklHoldIncentivesQuery.ts @@ -0,0 +1,77 @@ +import { useMemo } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { getHardcodedMerklHoldIncentive } from '@/constants/merklHoldIncentives'; +import { fetchMerklOpportunityById, getMerklOpportunityAprDecimal, isLiveHoldOpportunity } from '@/utils/merklApi'; +import type { MerklOpportunity } from '@/utils/merklTypes'; + +type UseMerklHoldIncentivesQueryParams = { + chainId: number; + collateralTokenAddress?: string; + enabled?: boolean; +}; + +type UseMerklHoldIncentivesQueryReturn = { + holdRewardAprDecimal: number | null; + holdRewardAprPercent: number | null; + hasLiveHoldReward: boolean; + incentiveLabel: string | null; + opportunity: MerklOpportunity | null; + loading: boolean; + error: string | null; + refetch: () => void; +}; + +export const useMerklHoldIncentivesQuery = ({ + chainId, + collateralTokenAddress, + enabled = true, +}: UseMerklHoldIncentivesQueryParams): UseMerklHoldIncentivesQueryReturn => { + const hardcodedIncentive = useMemo(() => { + if (!collateralTokenAddress) return null; + return getHardcodedMerklHoldIncentive({ + chainId, + collateralTokenAddress, + }); + }, [chainId, collateralTokenAddress]); + + const query = useQuery({ + queryKey: [ + 'merkl-hold-opportunity', + hardcodedIncentive?.chainId, + hardcodedIncentive?.opportunityType, + hardcodedIncentive?.opportunityIdentifier, + ], + enabled: enabled && hardcodedIncentive != null, + queryFn: async () => { + if (!hardcodedIncentive) return null; + + return fetchMerklOpportunityById({ + chainId: hardcodedIncentive.chainId, + type: hardcodedIncentive.opportunityType, + identifier: hardcodedIncentive.opportunityIdentifier, + campaigns: true, + }); + }, + staleTime: 5 * 60 * 1000, + refetchInterval: 5 * 60 * 1000, + refetchOnWindowFocus: true, + }); + + const opportunity = query.data ?? null; + const hasLiveHoldReward = useMemo(() => isLiveHoldOpportunity(opportunity), [opportunity]); + const holdRewardAprDecimal = useMemo(() => getMerklOpportunityAprDecimal(opportunity), [opportunity]); + const holdRewardAprPercent = holdRewardAprDecimal == null ? null : holdRewardAprDecimal * 100; + + return { + holdRewardAprDecimal, + holdRewardAprPercent, + hasLiveHoldReward, + incentiveLabel: hardcodedIncentive?.label ?? null, + opportunity, + loading: query.isLoading || query.isFetching, + error: query.error instanceof Error ? query.error.message : null, + refetch: () => { + void query.refetch(); + }, + }; +}; diff --git a/src/modals/borrow/borrow-modal.tsx b/src/modals/borrow/borrow-modal.tsx index b1def0b0..eada19b9 100644 --- a/src/modals/borrow/borrow-modal.tsx +++ b/src/modals/borrow/borrow-modal.tsx @@ -1,8 +1,6 @@ import { useCallback, useEffect, useState } from 'react'; -import { TbTrendingDown, TbTrendingUp } from 'react-icons/tb'; import { useConnection, useReadContract, useBalance } from 'wagmi'; import { erc20Abi } from 'viem'; -import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Modal, ModalHeader, ModalBody } from '@/components/common/Modal'; import { ModalIntentSwitcher } from '@/components/common/Modal/ModalIntentSwitcher'; @@ -175,20 +173,12 @@ export function BorrowModal({ actions={ canOpenLeverageModal ? ( ) : undefined } diff --git a/src/modals/leverage/components/add-collateral-and-leverage.tsx b/src/modals/leverage/components/add-collateral-and-leverage.tsx index 156bb0df..8e35cb4b 100644 --- a/src/modals/leverage/components/add-collateral-and-leverage.tsx +++ b/src/modals/leverage/components/add-collateral-and-leverage.tsx @@ -2,7 +2,10 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { erc20Abi, formatUnits } from 'viem'; import { useConnection, useReadContract } from 'wagmi'; import { BorrowPositionRiskCard } from '@/modals/borrow/components/borrow-position-risk-card'; -import { clampEditablePercent, computeLtv, formatEditableLtvPercent } from '@/modals/borrow/components/helpers'; +import { LTV_WAD, clampEditablePercent, computeLtv, formatEditableLtvPercent } from '@/modals/borrow/components/helpers'; +import { HelpTooltipIcon } from '@/components/shared/help-tooltip-icon'; +import { RateFormatted } from '@/components/shared/rate-formatted'; +import { TooltipContent as SharedTooltipContent } from '@/components/shared/tooltip-content'; import Input from '@/components/Input/Input'; import { LTVWarning } from '@/components/shared/ltv-warning'; import { TokenIcon } from '@/components/shared/token-icon'; @@ -13,7 +16,7 @@ import { clampTargetLtvBps, clampMultiplierBps, computeMaxMultiplierBpsForTargetLtv, - computeExpectedNetCarryApy, + convertVaultSharesToUnderlyingAssets, computeLeverageProjectedPosition, formatPercentFromBps, formatMultiplierBps, @@ -23,9 +26,11 @@ import { parsePercentToBps, parseMultiplierToBps, parseUnsignedBigInt, + toScaledRatio, targetLtvBpsFromMultiplier, } from '@/hooks/leverage/math'; import { LEVERAGE_DEFAULT_MULTIPLIER_BPS } from '@/hooks/leverage/types'; +import { useMerklHoldIncentivesQuery } from '@/hooks/queries/useMerklHoldIncentivesQuery'; import { use4626VaultAPR } from '@/hooks/use4626VaultAPR'; import { useLeverageQuote } from '@/hooks/useLeverageQuote'; import { useLeverageTransaction } from '@/hooks/useLeverageTransaction'; @@ -36,7 +41,7 @@ import { formatSwapRatePreview } from '@/features/swap/utils/quote-preview'; import { getLeverageFee } from '@/config/fees'; import { formatBalance } from '@/utils/balance'; import { previewMarketState } from '@/utils/morpho'; -import { convertApyToApr } from '@/utils/rateMath'; +import { convertAprToApy, toApyFromDisplayRate, toDisplayRateFromApy } from '@/utils/rateMath'; import type { LeverageRoute } from '@/hooks/leverage/types'; import type { Market, MarketPosition } from '@/utils/types'; @@ -66,6 +71,7 @@ export function AddCollateralAndLeverage({ const { usePermit2: usePermit2Setting, isAprDisplay, + showFullRewardAPY, leverageUseTargetLtvInput: useTargetLtvInput, setLeverageUseTargetLtvInput, } = useAppSettings(); @@ -179,15 +185,17 @@ export function AddCollateralAndLeverage({ if (netAddedCollateral <= 0n) return 'Net collateral after fee must be positive.'; return null; }, [hasQuoteChanges, collateralAssetPriceUsd, leverageTransferFee, netAddedCollateral]); + const addedCollateralAssets = useMemo(() => (isLeverageFeeReady && netAddedCollateral != null ? netAddedCollateral : 0n), [isLeverageFeeReady, netAddedCollateral]); + const addedBorrowAssets = useMemo(() => (isLeverageFeeReady ? quote.flashLoanAmount : 0n), [isLeverageFeeReady, quote.flashLoanAmount]); const { projectedCollateralAssets, projectedBorrowAssets } = useMemo( () => computeLeverageProjectedPosition({ currentCollateralAssets, currentBorrowAssets, - addedCollateralAssets: isLeverageFeeReady && netAddedCollateral != null ? netAddedCollateral : 0n, - addedBorrowAssets: isLeverageFeeReady ? quote.flashLoanAmount : 0n, + addedCollateralAssets, + addedBorrowAssets, }), - [currentCollateralAssets, currentBorrowAssets, isLeverageFeeReady, netAddedCollateral, quote.flashLoanAmount], + [currentCollateralAssets, currentBorrowAssets, addedCollateralAssets, addedBorrowAssets], ); const marketLiquidity = BigInt(market.state.liquidityAssets); const hasChanges = isLeverageFeeReady; @@ -201,6 +209,10 @@ export function AddCollateralAndLeverage({ enabled: isErc4626Route, lookbackDays: 3, }); + const merklHoldIncentives = useMerklHoldIncentivesQuery({ + chainId: market.morphoBlue.chain.id, + collateralTokenAddress: market.collateralAsset.address, + }); const projectedLTV = useMemo( () => @@ -335,10 +347,6 @@ export function AddCollateralAndLeverage({ () => formatTokenAmountPreview(quote.flashCollateralAmount, market.collateralAsset.decimals), [quote.flashCollateralAmount, market.collateralAsset.decimals], ); - const initialCollateralPreview = useMemo( - () => formatTokenAmountPreview(quote.initialCollateralAmount, market.collateralAsset.decimals), - [quote.initialCollateralAmount, market.collateralAsset.decimals], - ); const collateralPreviewForDisplay = isSwapRoute && !useLoanAssetInput ? swapCollateralOutPreview : totalCollateralAddedPreview; const collateralPreviewLabel = isSwapRoute ? useLoanAssetInput @@ -380,22 +388,24 @@ export function AddCollateralAndLeverage({ market.collateralAsset.symbol, ]); const shouldShowSwapPreviewDetails = isSwapRoute && quote.swapPriceRoute != null && swapRatePreviewText != null; - const shouldShowInputConversionPreview = isErc4626Route && useLoanAssetInput && quote.initialCollateralAmount > 0n; - const renderRateValue = useCallback( - (apy: number | null): JSX.Element => { - if (apy == null || !Number.isFinite(apy)) return -; - const displayRate = isAprDisplay ? convertApyToApr(apy) : apy; - if (!Number.isFinite(displayRate)) return -; - - const isNegative = displayRate < 0; - const absolutePercent = Math.abs(displayRate * 100).toFixed(2); - - return ( - <> - {isNegative && -} - {absolutePercent}% - - ); + const toDisplayRate = useCallback( + (apy: number | null): number | null => { + if (apy == null || !Number.isFinite(apy)) return null; + const displayRate = toDisplayRateFromApy(apy, isAprDisplay); + return Number.isFinite(displayRate) ? displayRate : null; + }, + [isAprDisplay], + ); + const renderRateFromApy = useCallback((apy: number | null): JSX.Element => { + if (apy == null || !Number.isFinite(apy)) return -; + return ; + }, []); + const renderRateFromDisplayMode = useCallback( + (displayRate: number | null): JSX.Element => { + if (displayRate == null || !Number.isFinite(displayRate)) return -; + const apyEquivalent = toApyFromDisplayRate(displayRate, isAprDisplay); + if (!Number.isFinite(apyEquivalent)) return -; + return ; }, [isAprDisplay], ); @@ -410,40 +420,186 @@ export function AddCollateralAndLeverage({ }, [hasChanges, market, quote.flashLoanAmount]); const previewBorrowApy = projectedBorrowApy ?? fallbackBorrowApy; const borrowRatePreviewLabel = projectedBorrowApy != null ? `Borrow ${rateLabel} (Est.)` : `Borrow ${rateLabel}`; - const previewExpectedNetApy = useMemo(() => { - if (!isErc4626Route || vaultRateInsight.sharePriceNow == null || vaultRateInsight.vaultApy3d == null) { - return vaultRateInsight.expectedNetApy; + const vaultTokenApy = isErc4626Route ? vaultRateInsight.vaultApy3d : 0; + const projectedLtvRatio = useMemo(() => { + if (projectedBorrowAssets <= 0n) return 0; + + if (isErc4626Route) { + if (vaultRateInsight.sharePriceNow == null) return null; + + const oneShareUnit = 10n ** BigInt(market.collateralAsset.decimals); + const collateralUnderlyingAssets = convertVaultSharesToUnderlyingAssets({ + shares: projectedCollateralAssets, + sharePriceInUnderlying: vaultRateInsight.sharePriceNow, + oneShareUnit, + }); + if (collateralUnderlyingAssets <= 0n) return null; + + const ratio = toScaledRatio(projectedBorrowAssets, collateralUnderlyingAssets); + if (ratio == null || !Number.isFinite(ratio) || ratio < 0) return null; + return ratio; } - const oneShareUnit = 10n ** BigInt(market.collateralAsset.decimals); - return computeExpectedNetCarryApy({ - collateralShares: projectedCollateralAssets, - borrowAssets: projectedBorrowAssets, - sharePriceInUnderlying: vaultRateInsight.sharePriceNow, - oneShareUnit, - vaultApy: vaultRateInsight.vaultApy3d, - borrowApy: previewBorrowApy, - }); + if (projectedCollateralAssets <= 0n) return null; + + const ratio = toScaledRatio(projectedLTV, LTV_WAD); + if (ratio == null || !Number.isFinite(ratio) || ratio < 0) return null; + return ratio; }, [ isErc4626Route, market.collateralAsset.decimals, - previewBorrowApy, projectedBorrowAssets, projectedCollateralAssets, - vaultRateInsight.expectedNetApy, + projectedLTV, + vaultRateInsight.sharePriceNow, + ]); + const addedDebtToCollateralRatio = useMemo(() => { + if (addedBorrowAssets <= 0n) return 0; + + if (isErc4626Route) { + if (vaultRateInsight.sharePriceNow == null) return null; + + const oneShareUnit = 10n ** BigInt(market.collateralAsset.decimals); + const addedCollateralUnderlyingAssets = convertVaultSharesToUnderlyingAssets({ + shares: addedCollateralAssets, + sharePriceInUnderlying: vaultRateInsight.sharePriceNow, + oneShareUnit, + }); + if (addedCollateralUnderlyingAssets <= 0n) return null; + + const ratio = Number(addedBorrowAssets) / Number(addedCollateralUnderlyingAssets); + if (!Number.isFinite(ratio) || ratio < 0) return null; + return ratio; + } + + if (addedCollateralAssets <= 0n) return null; + + const addedLtv = computeLtv({ + borrowAssets: addedBorrowAssets, + collateralAssets: addedCollateralAssets, + oraclePrice, + }); + const ratio = Number(addedLtv) / Number(LTV_WAD); + if (!Number.isFinite(ratio) || ratio < 0) return null; + return ratio; + }, [ + addedBorrowAssets, + addedCollateralAssets, + isErc4626Route, + market.collateralAsset.decimals, + oraclePrice, vaultRateInsight.sharePriceNow, - vaultRateInsight.vaultApy3d, ]); + const contributedCapitalAssets = useMemo(() => { + if (addedCollateralAssets <= 0n) return null; + + if (isSwapRoute && useLoanAssetInput) { + const totalLoanInput = collateralAmount + addedBorrowAssets; + if (totalLoanInput <= 0n) return null; + + const contributedFromLoanInput = (addedCollateralAssets * collateralAmount) / totalLoanInput; + return contributedFromLoanInput > 0n ? contributedFromLoanInput : null; + } + + if (quote.totalAddedCollateral <= 0n || quote.initialCollateralAmount <= 0n) return null; + + const contributedFromInitialCollateral = (addedCollateralAssets * quote.initialCollateralAmount) / quote.totalAddedCollateral; + return contributedFromInitialCollateral > 0n ? contributedFromInitialCollateral : null; + }, [ + addedBorrowAssets, + addedCollateralAssets, + collateralAmount, + isSwapRoute, + quote.initialCollateralAmount, + quote.totalAddedCollateral, + useLoanAssetInput, + ]); + const holdRewardsApy = useMemo(() => { + const holdRewardAprDecimal = merklHoldIncentives.holdRewardAprDecimal; + if (holdRewardAprDecimal == null || !Number.isFinite(holdRewardAprDecimal)) return null; + const holdRewardApy = convertAprToApy(holdRewardAprDecimal); + return Number.isFinite(holdRewardApy) ? holdRewardApy : null; + }, [merklHoldIncentives.holdRewardAprDecimal]); + const holdRewardsApyForNet = useMemo(() => { + if (!showFullRewardAPY) return 0; + return holdRewardsApy ?? 0; + }, [showFullRewardAPY, holdRewardsApy]); + const borrowRateForCarry = useMemo(() => toDisplayRate(previewBorrowApy), [previewBorrowApy, toDisplayRate]); + const vaultTokenRateForCarry = useMemo(() => toDisplayRate(vaultTokenApy), [vaultTokenApy, toDisplayRate]); + const holdRewardsRateForCarry = useMemo(() => toDisplayRate(holdRewardsApyForNet), [holdRewardsApyForNet, toDisplayRate]); + const collateralYieldRate = useMemo(() => { + if (vaultTokenRateForCarry == null) return null; + return vaultTokenRateForCarry + (holdRewardsRateForCarry ?? 0); + }, [vaultTokenRateForCarry, holdRewardsRateForCarry]); + const hasConfiguredHoldRewards = merklHoldIncentives.incentiveLabel != null; + const shouldShowHoldRewardsRow = hasConfiguredHoldRewards; + const shouldShowNetRate = isErc4626Route || (showFullRewardAPY && shouldShowHoldRewardsRow); + const isNetRateLoading = + (isErc4626Route && (vaultRateInsight.isLoading || vaultRateInsight.vaultApy3d == null || projectedLtvRatio == null)) || + (showFullRewardAPY && shouldShowHoldRewardsRow && merklHoldIncentives.loading); + const holdRewardsLabel = useMemo( + () => `${merklHoldIncentives.incentiveLabel ?? market.collateralAsset.symbol} Hold Reward (Merkl) ${rateLabel}`, + [merklHoldIncentives.incentiveLabel, market.collateralAsset.symbol, rateLabel], + ); + const projectedPositionPreview = useMemo( + () => formatTokenAmountPreview(projectedCollateralAssets, market.collateralAsset.decimals), + [projectedCollateralAssets, market.collateralAsset.decimals], + ); + const addedCapitalPreview = useMemo(() => { + if (contributedCapitalAssets == null) return null; + return formatTokenAmountPreview(contributedCapitalAssets, market.collateralAsset.decimals); + }, [contributedCapitalAssets, market.collateralAsset.decimals]); + const netCarryLabel = `Net Carry ${rateLabel}`; + const leveredCarryLabel = `Levered Carry ${rateLabel}`; + const netCarryDetail = `Expected yearly yield on your full leveraged ${market.collateralAsset.symbol} position.`; + const netCarrySecondaryDetail = `Projected position size: ${projectedPositionPreview.compact} ${market.collateralAsset.symbol}.`; + const leveredCarryDetail = `Expected yearly yield on your own added ${market.collateralAsset.symbol} capital.`; + const leveredCarrySecondaryDetail = `Added capital for this action: ${addedCapitalPreview?.compact ?? '-'} ${market.collateralAsset.symbol}.`; + const previewExpectedNetRate = useMemo(() => { + if (collateralYieldRate == null || borrowRateForCarry == null || projectedLtvRatio == null) return null; + const netRate = collateralYieldRate - projectedLtvRatio * borrowRateForCarry; + return Number.isFinite(netRate) ? netRate : null; + }, [ + collateralYieldRate, + borrowRateForCarry, + projectedLtvRatio, + ]); + const previewLeveredCarryOnCapitalRate = useMemo(() => { + if (collateralYieldRate == null || borrowRateForCarry == null || addedDebtToCollateralRatio == null || contributedCapitalAssets == null || addedCollateralAssets <= 0n) return null; + + const incrementalNetCarryRate = collateralYieldRate - addedDebtToCollateralRatio * borrowRateForCarry; + if (!Number.isFinite(incrementalNetCarryRate)) return null; + + const leverageFactor = Number(addedCollateralAssets) / Number(contributedCapitalAssets); + if (!Number.isFinite(leverageFactor) || leverageFactor <= 0) return null; + + const leveredCarryRate = incrementalNetCarryRate * leverageFactor; + return Number.isFinite(leveredCarryRate) ? leveredCarryRate : null; + }, [ + addedCollateralAssets, + addedDebtToCollateralRatio, + collateralYieldRate, + borrowRateForCarry, + contributedCapitalAssets, + ]); + const isLeveredCarryLoading = + isLeverageFeeReady && + ((isErc4626Route && (vaultRateInsight.isLoading || vaultRateInsight.vaultApy3d == null || vaultRateInsight.sharePriceNow == null)) || + (showFullRewardAPY && shouldShowHoldRewardsRow && merklHoldIncentives.loading)); const expectedNetRateClass = useMemo(() => { - if (previewExpectedNetApy == null) return 'text-secondary'; - return previewExpectedNetApy >= 0 ? 'text-emerald-500' : 'text-red-500'; - }, [previewExpectedNetApy]); + if (previewExpectedNetRate == null) return 'text-secondary'; + return previewExpectedNetRate >= 0 ? 'text-emerald-500' : 'text-red-500'; + }, [previewExpectedNetRate]); + const leveredCarryRateClass = useMemo(() => { + if (previewLeveredCarryOnCapitalRate == null) return 'text-secondary'; + return previewLeveredCarryOnCapitalRate >= 0 ? 'text-emerald-500' : 'text-red-500'; + }, [previewLeveredCarryOnCapitalRate]); return (
{!transaction?.isModalVisible && (
-

Leverage Preview

+

Leverage Preview

-

+

{useLoanAssetInput ? `Start with ${market.loanAsset.symbol}` : `Add Collateral ${market.collateralAsset.symbol}`}

{canUseLoanAssetInput && ( @@ -509,7 +665,7 @@ export function AddCollateralAndLeverage({
-

+

{useTargetLtvInput ? 'Target LTV' : 'Target Multiplier'}

@@ -559,12 +715,12 @@ export function AddCollateralAndLeverage({
-

Transaction Preview

+

Transaction Preview

{isSwapRoute ? 'Flash Borrow Required' : 'Flash Borrow'} - {flashBorrowPreview.full}}> + {flashBorrowPreview.full}}> {flashBorrowPreview.compact} {collateralPreviewLabel} - {collateralPreviewForDisplay.full}}> + {collateralPreviewForDisplay.full}}> {collateralPreviewForDisplay.compact}
- {shouldShowInputConversionPreview && ( -
- Collateral Shares From Input - - {initialCollateralPreview.full}}> - {initialCollateralPreview.compact} - - - -
- )} {leverageFeePreview != null && (
- Leverage Fee (Est.) + Fee - {leverageFeePreview.full}}> + {leverageFeePreview.full}}> {leverageFeePreview.compact} {borrowRatePreviewLabel} - {renderRateValue(previewBorrowApy)} + {renderRateFromApy(previewBorrowApy)}
- {isErc4626Route && ( + {(isErc4626Route || shouldShowHoldRewardsRow || shouldShowNetRate) && ( <>
-
- Vault Token {rateLabel} - - {vaultRateInsight.isLoading ? '...' : renderRateValue(vaultRateInsight.vaultApy3d)} - -
-
- Net {rateLabel} - - {vaultRateInsight.isLoading ? '...' : renderRateValue(previewExpectedNetApy)} - -
+ {isErc4626Route && ( +
+ Vault Token {rateLabel} + + {vaultRateInsight.isLoading ? '...' : renderRateFromApy(vaultRateInsight.vaultApy3d)} + +
+ )} + {shouldShowHoldRewardsRow && ( +
+ {holdRewardsLabel} + + {merklHoldIncentives.loading ? '...' : renderRateFromApy(holdRewardsApy)} + +
+ )} + {shouldShowNetRate && ( + <> +
+
+
+ {netCarryLabel} + + } + ariaLabel={`Explain ${netCarryLabel}`} + className="h-auto w-auto" + /> +
+ + {isNetRateLoading ? '...' : renderRateFromDisplayMode(previewExpectedNetRate)} + +
+
+
+ {leveredCarryLabel} + + } + ariaLabel={`Explain ${leveredCarryLabel}`} + className="h-auto w-auto" + /> +
+ + {isLeveredCarryLoading ? '...' : renderRateFromDisplayMode(previewLeveredCarryOnCapitalRate)} + +
+ + )} )}
@@ -667,6 +853,9 @@ export function AddCollateralAndLeverage({ {isErc4626Route && vaultRateInsight.error && (

Failed to fetch 3-day vault/borrow rates: {vaultRateInsight.error}

)} + {merklHoldIncentives.error && ( +

Failed to fetch Merkl hold rewards: {merklHoldIncentives.error}

+ )} {insufficientLiquidity && (

Flash loan repayment borrow exceeds market liquidity ({formatBalance(marketLiquidity, market.loanAsset.decimals)}{' '} diff --git a/src/stores/useAppSettings.ts b/src/stores/useAppSettings.ts index 71f9f79f..4536b494 100644 --- a/src/stores/useAppSettings.ts +++ b/src/stores/useAppSettings.ts @@ -61,7 +61,7 @@ export const useAppSettings = create()( usePermit2: true, useEth: false, showUnwhitelistedMarkets: false, - showFullRewardAPY: false, + showFullRewardAPY: true, isAprDisplay: false, trustedVaultsWarningDismissed: false, showDeveloperOptions: false, diff --git a/src/utils/merklApi.ts b/src/utils/merklApi.ts index 3c3df17a..0be773fb 100644 --- a/src/utils/merklApi.ts +++ b/src/utils/merklApi.ts @@ -1,11 +1,14 @@ import { MerklApi } from '@merkl/api'; -import type { MerklCampaign, SimplifiedCampaign, MerklApiParams } from './merklTypes'; +import type { MerklCampaign, SimplifiedCampaign, MerklApiParams, MerklOpportunityLookupParams, MerklOpportunity } from './merklTypes'; const MERKL_API_BASE_URL = 'https://api.merkl.xyz'; // Initialize the Merkl SDK singleton export const merklClient = MerklApi(MERKL_API_BASE_URL); +const MERKL_LIVE_STATUS = 'LIVE'; +const MERKL_HOLD_ACTION = 'HOLD'; + // Helper function to fetch campaigns using the SDK with Adapter pattern export async function fetchCampaigns(params: MerklApiParams = {}): Promise { try { @@ -68,6 +71,49 @@ export async function fetchActiveCampaigns(params: Omit): string => + `${params.chainId}-${params.type}-${params.identifier}`; + +export async function fetchMerklOpportunityById(params: MerklOpportunityLookupParams): Promise { + try { + const opportunityId = buildMerklOpportunityId(params); + const { data, error, status } = await merklClient.v4.opportunities({ id: opportunityId }).get({ + query: { + campaigns: params.campaigns ?? false, + }, + }); + + if (status === 404) { + return null; + } + + if (error || status !== 200) { + throw new Error(`Merkl API opportunity error: ${status} ${error}`); + } + + return (data as MerklOpportunity | null) ?? null; + } catch (err) { + console.error('Error fetching Merkl opportunity:', err); + throw err; + } +} + +export const isLiveHoldOpportunity = (opportunity: MerklOpportunity | null | undefined): opportunity is MerklOpportunity => { + if (!opportunity) return false; + const action = opportunity.action?.toUpperCase(); + const status = opportunity.status?.toUpperCase(); + const apr = opportunity.apr; + const hasLiveCampaigns = opportunity.liveCampaigns == null || opportunity.liveCampaigns > 0; + + return action === MERKL_HOLD_ACTION && status === MERKL_LIVE_STATUS && hasLiveCampaigns && typeof apr === 'number' && Number.isFinite(apr) && apr > 0; +}; + +export const getMerklOpportunityAprDecimal = (opportunity: MerklOpportunity | null | undefined): number | null => { + if (!isLiveHoldOpportunity(opportunity)) return null; + const aprPercent = opportunity.apr ?? 0; + return aprPercent / 100; +}; + // Helper to check if a campaign is currently active function isCampaignActive(campaign: MerklCampaign): boolean { const now = Math.floor(Date.now() / 1000); @@ -89,6 +135,7 @@ function getBaseCampaignFields( | 'isActive' | 'name' | 'opportunityIdentifier' + | 'opportunityAction' > { return { chainId: campaign.computeChainId, @@ -105,6 +152,7 @@ function getBaseCampaignFields( isActive: isCampaignActive(campaign), name: campaign.Opportunity?.name, opportunityIdentifier: campaign.Opportunity?.identifier, + opportunityAction: campaign.Opportunity?.action, }; } diff --git a/src/utils/merklTypes.ts b/src/utils/merklTypes.ts index e32f6bd9..58c806a8 100644 --- a/src/utils/merklTypes.ts +++ b/src/utils/merklTypes.ts @@ -85,10 +85,18 @@ export type MerklCampaignParams = { }; export type MerklOpportunity = { + id?: string; identifier: string; name: string; chainId: number; type: string; + status?: string; + action?: string; + apr?: number; + maxApr?: number; + liveCampaigns?: number; + tokens?: MerklToken[]; + campaigns?: MerklCampaign[]; }; export type MerklCampaign = { @@ -130,6 +138,13 @@ export type MerklApiParams = { endTimestamp?: number; }; +export type MerklOpportunityLookupParams = { + chainId: number; + type: string; + identifier: string; + campaigns?: boolean; +}; + export type SimplifiedCampaign = { marketId: string; chainId: number; @@ -156,4 +171,5 @@ export type SimplifiedCampaign = { isActive: boolean; name?: string; opportunityIdentifier?: string; + opportunityAction?: string; }; diff --git a/src/utils/rateMath.ts b/src/utils/rateMath.ts index 6880bdc2..76617618 100644 --- a/src/utils/rateMath.ts +++ b/src/utils/rateMath.ts @@ -28,6 +28,42 @@ export function convertApyToApr(apy: number): number { return Math.log(1 + apy); } +/** + * Converts APR (continuous-compounding style) back to APY. + * Inverse of `convertApyToApr`: APY = e^(APR) - 1 + * + * @param apr - The APR value as a decimal (e.g., 0.04879 for 4.879%) + * @returns The APY value as a decimal + */ +export function convertAprToApy(apr: number): number { + if (!Number.isFinite(apr)) return 0; + return Math.exp(apr) - 1; +} + +/** + * Normalizes an APY decimal to the currently selected display mode. + * + * @param apy - The APY value as a decimal + * @param isAprDisplay - Whether APR mode is enabled + * @returns Rate as decimal in the selected mode + */ +export function toDisplayRateFromApy(apy: number, isAprDisplay: boolean): number { + if (!Number.isFinite(apy)) return Number.NaN; + return isAprDisplay ? convertApyToApr(apy) : apy; +} + +/** + * Converts a display-mode rate decimal back to APY decimal. + * + * @param rate - Rate as decimal in selected display mode + * @param isAprDisplay - Whether APR mode is enabled + * @returns Equivalent APY decimal + */ +export function toApyFromDisplayRate(rate: number, isAprDisplay: boolean): number { + if (!Number.isFinite(rate)) return Number.NaN; + return isAprDisplay ? convertAprToApy(rate) : rate; +} + /** * Formats a rate value as a percentage string *