diff --git a/.gitignore b/.gitignore index 1d7c8b4031..971fba2ca5 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,4 @@ package-lock.json # Sentry Config File .env.sentry-build-plugin +tsconfig.tsbuildinfo diff --git a/src/components/incentives/IncentivesCard.tsx b/src/components/incentives/IncentivesCard.tsx index 4d914c3641..7b9750159f 100644 --- a/src/components/incentives/IncentivesCard.tsx +++ b/src/components/incentives/IncentivesCard.tsx @@ -23,6 +23,7 @@ interface IncentivesCardProps { symbol: string; value: string | number; incentives?: ReserveIncentiveResponse[]; + /** aToken / vToken address (legacy; hook resolves underlying internally). */ address?: string; variant?: 'main14' | 'main16' | 'secondary14'; symbolsVariant?: 'secondary14' | 'secondary16'; diff --git a/src/components/incentives/IncentivesTooltipContent.tsx b/src/components/incentives/IncentivesTooltipContent.tsx index d056520c59..6ab0650618 100644 --- a/src/components/incentives/IncentivesTooltipContent.tsx +++ b/src/components/incentives/IncentivesTooltipContent.tsx @@ -133,6 +133,11 @@ const IncentivesSymbolMap: { symbol: 'aUSDm', aToken: true, }, + aCelUSDT: { + tokenIconSymbol: 'USDT', + symbol: 'aUSDT', + aToken: true, + }, aGnoEURe: { tokenIconSymbol: 'EURe', symbol: 'aEURe', @@ -233,6 +238,11 @@ const IncentivesSymbolMap: { symbol: 'aGHO', aToken: true, }, + aInkWlUSDe: { + tokenIconSymbol: 'USDe', + symbol: 'aUSDe', + aToken: true, + }, }; interface IncentivesTooltipContentProps { diff --git a/src/components/incentives/MeritIncentivesTooltipContent.tsx b/src/components/incentives/MeritIncentivesTooltipContent.tsx index cf0c44724e..eccb29ef1c 100644 --- a/src/components/incentives/MeritIncentivesTooltipContent.tsx +++ b/src/components/incentives/MeritIncentivesTooltipContent.tsx @@ -27,17 +27,19 @@ interface CampaignConfig { } const isCeloAction = (action: MeritAction): boolean => { - return [ - MeritAction.CELO_SUPPLY_CELO, - MeritAction.CELO_SUPPLY_USDT, - MeritAction.CELO_SUPPLY_USDC, - MeritAction.CELO_SUPPLY_WETH, - MeritAction.CELO_SUPPLY_MULTIPLE_BORROW_USDT, - MeritAction.CELO_BORROW_CELO, - MeritAction.CELO_BORROW_USDT, - MeritAction.CELO_BORROW_USDC, - MeritAction.CELO_BORROW_WETH, - ].includes(action); + return ( + [ + MeritAction.CELO_SUPPLY_CELO, + MeritAction.CELO_SUPPLY_USDT, + MeritAction.CELO_SUPPLY_USDC, + MeritAction.CELO_SUPPLY_WETH, + MeritAction.CELO_SUPPLY_MULTIPLE_BORROW_USDT, + MeritAction.CELO_BORROW_CELO, + MeritAction.CELO_BORROW_USDT, + MeritAction.CELO_BORROW_USDC, + MeritAction.CELO_BORROW_WETH, + ] as string[] + ).includes(action); }; const selfCampaignConfig: Map = new Map([ @@ -122,12 +124,15 @@ export const MeritIncentivesTooltipContent = ({ }; const meritIncentivesFormatted = getSymbolMap(meritIncentives); const isCombinedMeritIncentives: boolean = meritIncentives.activeActions.length > 1; - const campaignConfig = getCampaignConfig(meritIncentives.action); - const selfConfig = selfCampaignConfig.get(meritIncentives.action); + // `action` is now optional (backend-driven). Fall back to an empty string + // so the switch/lookup helpers match their STANDARD branch. + const primaryAction = meritIncentives.action ?? ''; + const campaignConfig = getCampaignConfig(primaryAction); + const selfConfig = selfCampaignConfig.get(primaryAction); const remainingCustomMessage = getRemainingMessagesWhenCombined( meritIncentives.activeActions, - meritIncentives.action, + primaryAction, isCombinedMeritIncentives, meritIncentives.actionMessages ); diff --git a/src/components/incentives/MerklIncentivesTooltipContent.tsx b/src/components/incentives/MerklIncentivesTooltipContent.tsx index 3d43ee348d..6d5e60101b 100644 --- a/src/components/incentives/MerklIncentivesTooltipContent.tsx +++ b/src/components/incentives/MerklIncentivesTooltipContent.tsx @@ -185,55 +185,11 @@ export const MerklIncentivesTooltipContent = ({ - ) : merklIncentives.rewardsTokensMappedApys && - merklIncentives.rewardsTokensMappedApys.length > 1 ? ( - <> - {merklIncentives.rewardsTokensMappedApys.map((reward, index) => { - const { tokenIconSymbol, symbol, aToken } = getSymbolMap({ - rewardTokenSymbol: reward.token.symbol, - rewardTokenAddress: reward.token.address, - incentiveAPR: reward.apy.toString(), - }); - return ( - - - {symbol} - - {merklIncentives.breakdown.isBorrow ? '(-)' : '(+)'} - - - } - width="100%" - > - - - - APY - - - - ); - })} - ) : ( + // Note: legacy multi-reward-token rendering (`rewardsTokensMappedApys`) + // is gone. The V3 backend returns one `MerklSupply/Borrow` + // variant per reserve per direction with a single `payoutToken`, + // so the single-row render below covers all live campaigns. { const { data: meritIncentives } = useMeritIncentives({ symbol: 'GHO', market: currentMarketData.market, + protocolAction: ProtocolAction.stake, }); const [_amount, setAmount] = useState(''); const amountRef = useRef(); diff --git a/src/hooks/app-data-provider/useAppDataProvider.tsx b/src/hooks/app-data-provider/useAppDataProvider.tsx index 4591e2c312..0f3187f6dc 100644 --- a/src/hooks/app-data-provider/useAppDataProvider.tsx +++ b/src/hooks/app-data-provider/useAppDataProvider.tsx @@ -81,7 +81,12 @@ export const AppDataProvider: React.FC = ({ children }) => { const marketAddress = currentMarketData.addresses.LENDING_POOL.toLowerCase(); - const sdkMarket = data?.find((item) => item.address.toLowerCase() === marketAddress); + // react-query's structural sharing can replace our Market[] with a + // structurally-similar plain object on refetch when it encounters + // non-POJO values (e.g. bigint-ish strings wrapped by the SDK). Guard + // before calling Array.prototype methods. + const marketsList = Array.isArray(data) ? data : []; + const sdkMarket = marketsList.find((item) => item.address.toLowerCase() === marketAddress); const totalBorrows = sdkMarket?.borrowReserves.reduce((acc, reserve) => { const value = reserve.borrowInfo?.total?.usd ?? 0; diff --git a/src/hooks/pool/usePoolsMerits.ts b/src/hooks/pool/usePoolsMerits.ts new file mode 100644 index 0000000000..a306994493 --- /dev/null +++ b/src/hooks/pool/usePoolsMerits.ts @@ -0,0 +1,116 @@ +/** + * Per-market Merit APR lookup for the net-APY calculation in + * `useUserYield`. + * + * Reads directly from the SDK's `markets()` query (same react-query cache + * as `useAppDataProvider`'s `useMarketsData`), extracts each reserve's + * active `MeritSupply/Borrow/Conditional` incentive, and keys by + * underlying address. The backend already evaluates `userEligible` when + * the user address is passed, so we only credit APR for reserves the user + * is actually eligible for — same behaviour as the legacy aavechan + * per-user fetch. + * + * No new GraphQL query: the shared cache means calling this hook + * alongside the main AppDataProvider fetch is a cache hit. + */ +import { chainId as sdkChainId, evmAddress, OrderDirection } from '@aave/client'; +import { markets } from '@aave/client/actions'; +import { useQueries } from '@tanstack/react-query'; +import { client } from 'pages/_app.page'; +import { MarketDataType } from 'src/ui-config/marketsConfig'; +import { queryKeysFactory } from 'src/ui-config/queries'; + +/** + * Map of `lowercase(underlyingAddress) -> {supplyApr, borrowApr}`. Backed + * by a plain Record because react-query's default `structuralSharing` + * deep-merges fetched data against the previous value, and `Map` instances + * don't round-trip through that merge — they come back as plain objects on + * refetch and `.get()` blows up at the consumer. + */ +export type MeritAprByUnderlying = Record; + +const EMPTY_MAP: MeritAprByUnderlying = Object.freeze({}); + +type Incentive = { + __typename?: string; + userEligible?: boolean | null; + extraSupplyApr?: { formatted: string } | null; + borrowAprDiscount?: { formatted: string } | null; + extraApr?: { formatted: string } | null; +}; + +const parseApr = (value?: { formatted: string } | null): number => { + if (!value) return 0; + const n = parseFloat(value.formatted); + return Number.isFinite(n) && n > 0 ? n : 0; +}; + +/** + * Per-market query that resolves the SDK's `markets()` response and builds + * a `Map` of eligible Merit + * APRs for the user. Entries are only present when the user passes the + * backend's eligibility criteria for that reserve; missing keys mean "no + * Merit contribution for this position". + */ +export const usePoolsMerits = (marketsData: MarketDataType[], userAddress?: string | null) => { + const userAddr = userAddress ? evmAddress(userAddress) : undefined; + + return useQueries({ + queries: marketsData.map((marketData) => ({ + queryKey: [ + ...queryKeysFactory.market(marketData), + ...queryKeysFactory.user(userAddr ?? 'anonymous'), + ], + enabled: !!client, + queryFn: async (): Promise => { + const response = await markets(client, { + chainIds: [sdkChainId(marketData.chainId)], + user: userAddr, + suppliesOrderBy: { tokenName: OrderDirection.Asc }, + borrowsOrderBy: { tokenName: OrderDirection.Asc }, + }); + if (response.isErr()) throw response.error; + + // `markets()` returns every market on the chain (Core, Lido, + // EtherFi, Horizon, …). Keep only the one this query is keyed on, + // otherwise identical underlyings across pools would get merged + // and `useUserYield` would credit incentives from the wrong pool. + const targetPool = marketData.addresses.LENDING_POOL?.toLowerCase(); + const scopedMarkets = targetPool + ? response.value.filter((m) => m.address?.toLowerCase() === targetPool) + : response.value; + + const result: MeritAprByUnderlying = {}; + for (const sdkMarket of scopedMarkets) { + const allReserves = [ + ...(sdkMarket.supplyReserves ?? []), + ...(sdkMarket.borrowReserves ?? []), + ]; + for (const r of allReserves) { + const underlying = r.underlyingToken.address.toLowerCase(); + const existing = result[underlying] ?? { supplyApr: 0, borrowApr: 0 }; + const incentives: Incentive[] = (r.incentives ?? []) as Incentive[]; + for (const inc of incentives) { + if (!inc.userEligible) continue; + if (inc.__typename === 'MeritSupplyIncentive') { + existing.supplyApr += parseApr(inc.extraSupplyApr); + } else if (inc.__typename === 'MeritBorrowIncentive') { + existing.borrowApr += parseApr(inc.borrowAprDiscount); + } else if (inc.__typename === 'MeritBorrowAndSupplyIncentiveCondition') { + // Conditional reward: paid to both sides when the user + // holds the specified collateral + debt simultaneously. + const apr = parseApr(inc.extraApr); + existing.supplyApr += apr; + existing.borrowApr += apr; + } + } + result[underlying] = existing; + } + } + return result; + }, + })), + }); +}; + +export const emptyMeritMap = (): MeritAprByUnderlying => EMPTY_MAP; diff --git a/src/hooks/pool/useUserYield.ts b/src/hooks/pool/useUserYield.ts index 2fddc2c552..d7de5f150d 100644 --- a/src/hooks/pool/useUserYield.ts +++ b/src/hooks/pool/useUserYield.ts @@ -1,24 +1,16 @@ -import { ProtocolAction } from '@aave/contract-helpers'; import { FormatUserSummaryAndIncentivesResponse } from '@aave/math-utils'; import { BigNumber } from 'bignumber.js'; import memoize from 'micro-memoize'; import { MarketDataType } from 'src/ui-config/marketsConfig'; -import { getMeritData } from '../useMeritIncentives'; -import { useUserMeritIncentives } from '../useUserMeritIncentives'; import { FormattedReservesAndIncentives, usePoolsFormattedReserves, } from './usePoolFormattedReserves'; +import { emptyMeritMap, MeritAprByUnderlying, usePoolsMerits } from './usePoolsMerits'; import { useUserSummariesAndIncentives } from './useUserSummaryAndIncentives'; import { combineQueries, SimplifiedUseQueryResult } from './utils'; -type UserMeritIncentivesData = { - currentAPR: { - actionsAPY: Record; - }; -} | null; - export interface UserYield { earnedAPY: number; debtAPY: number; @@ -29,8 +21,7 @@ const formatUserYield = memoize( ( formattedPoolReserves: FormattedReservesAndIncentives[], user: FormatUserSummaryAndIncentivesResponse, - userMeritIncentives?: UserMeritIncentivesData, - marketTitle?: string + meritByUnderlying: MeritAprByUnderlying ) => { const proportions = user.userReservesData.reduce( (acc, value) => { @@ -39,6 +30,7 @@ const formatUserYield = memoize( ); if (reserve) { + const meritEntry = meritByUnderlying[reserve.underlyingAsset.toLowerCase()]; if (value.underlyingBalanceUSD !== '0') { acc.positiveProportion = acc.positiveProportion.plus( new BigNumber(reserve.supplyAPY).multipliedBy(value.underlyingBalanceUSD) @@ -50,22 +42,13 @@ const formatUserYield = memoize( ); }); } - - // Add merit incentives for supply positions - if (userMeritIncentives?.currentAPR?.actionsAPY) { - const meritData = getMeritData(marketTitle || '', reserve.symbol); - if (meritData) { - meritData.forEach((merit) => { - if (merit.protocolAction === ProtocolAction.supply) { - const meritAPY = userMeritIncentives.currentAPR.actionsAPY[merit.action]; - if (meritAPY) { - acc.positiveProportion = acc.positiveProportion.plus( - new BigNumber(meritAPY / 100).multipliedBy(value.underlyingBalanceUSD) - ); - } - } - }); - } + // Merit supply-side APR — backend already filtered by user + // eligibility (only credits when the user passes the criteria + // rules for the program). + if (meritEntry && meritEntry.supplyApr > 0) { + acc.positiveProportion = acc.positiveProportion.plus( + new BigNumber(meritEntry.supplyApr / 100).multipliedBy(value.underlyingBalanceUSD) + ); } } if (value.variableBorrowsUSD !== '0') { @@ -79,23 +62,12 @@ const formatUserYield = memoize( ); }); } - - // Add merit incentives for borrow positions (reduces borrowing cost) - if (userMeritIncentives?.currentAPR?.actionsAPY) { - const meritData = getMeritData(marketTitle || '', reserve.symbol); - if (meritData) { - meritData.forEach((merit) => { - if (merit.protocolAction === ProtocolAction.borrow) { - const meritAPY = userMeritIncentives.currentAPR.actionsAPY[merit.action]; - if (meritAPY) { - // For borrow positions, merit incentives reduce the effective borrow cost - acc.positiveProportion = acc.positiveProportion.plus( - new BigNumber(meritAPY / 100).multipliedBy(value.variableBorrowsUSD) - ); - } - } - }); - } + // Merit borrow-side APR (negative on the debt cost, hence + // added to the positive proportion to offset borrow interest). + if (meritEntry && meritEntry.borrowApr > 0) { + acc.positiveProportion = acc.positiveProportion.plus( + new BigNumber(meritEntry.borrowApr / 100).multipliedBy(value.variableBorrowsUSD) + ); } } } else { @@ -132,21 +104,15 @@ export const useUserYields = ( ): SimplifiedUseQueryResult[] => { const poolsFormattedReservesQuery = usePoolsFormattedReserves(marketsData); const userSummaryQuery = useUserSummariesAndIncentives(marketsData); - const userMeritIncentivesQuery = useUserMeritIncentives(userAddress); + const poolsMeritsQueries = usePoolsMerits(marketsData, userAddress); return poolsFormattedReservesQuery.map((elem, index) => { + const meritMap = poolsMeritsQueries[index]?.data ?? emptyMeritMap(); const selector = ( formattedPoolReserves: FormattedReservesAndIncentives[], user: FormatUserSummaryAndIncentivesResponse ) => { - // Get merit incentives data separately - const meritIncentives = userMeritIncentivesQuery.data; - return formatUserYield( - formattedPoolReserves, - user, - meritIncentives, - marketsData[index].market - ); + return formatUserYield(formattedPoolReserves, user, meritMap); }; return combineQueries([elem, userSummaryQuery[index]] as const, selector); diff --git a/src/hooks/useEthenaIncentives.ts b/src/hooks/useEthenaIncentives.ts index 4fa071d3e8..e53db4881b 100644 --- a/src/hooks/useEthenaIncentives.ts +++ b/src/hooks/useEthenaIncentives.ts @@ -1,35 +1,49 @@ -import { - AaveV3Ethereum, - AaveV3EthereumLido, - AaveV3Mantle, - AaveV3Plasma, -} from '@aave-dao/aave-address-book'; +/** + * Ethena partner incentive adapter over the V3 backend. + * + * Legacy signature: `useEthenaIncentives(rewardedAsset)` where + * `rewardedAsset` is the aToken address. The hook now resolves the aToken + * to its underlying via `useAppDataContext` and reads the + * `SupplyPointsIncentive` variant whose `program.name === "Ethena Rewards"` + * from `useReserveIncentives`. Callsites stay unchanged; the hardcoded + * `ETHENA_DATA_MAP` is gone. Ethena pays in airdrop / sats multipliers, + * not in APR — see `EthenaAirdropTooltipContent` for the rendering. + */ +import { useAppDataContext } from 'src/hooks/app-data-provider/useAppDataProvider'; +import { useRootStore } from 'src/store/root'; -const getEthenaData = (assetAddress: string): number | undefined => - ETHENA_DATA_MAP.get(assetAddress); +import { useReserveIncentives } from './useReserveIncentives'; -const ETHENA_DATA_MAP: Map = new Map([ - [AaveV3Ethereum.ASSETS.USDe.A_TOKEN, 5], - [AaveV3Ethereum.ASSETS.sUSDe.A_TOKEN, 5], - [AaveV3Plasma.ASSETS.sUSDe.A_TOKEN, 5], - [AaveV3Plasma.ASSETS.USDe.A_TOKEN, 5], - [AaveV3EthereumLido.ASSETS.sUSDe.A_TOKEN, 5], - [AaveV3Mantle.ASSETS.sUSDe.A_TOKEN, 5], - [AaveV3Mantle.ASSETS.USDe.A_TOKEN, 5], - ['0x24C1FaC3447C45137E5f1c2C54Fe9ed3F1EdeA61', 5], // sUSDe INK +/** + * Returns the Ethena Rewards multiplier (e.g. `5` for 5x) or `undefined` + * if no Ethena partner incentive is active for the aToken's underlying + * reserve. + */ +export const useEthenaIncentives = (rewardedAsset?: string): number | undefined => { + const chainId = useRootStore((s) => s.currentChainId); + const currentMarket = useRootStore((s) => s.currentMarket); + const { supplyReserves } = useAppDataContext(); - [AaveV3Ethereum.ASSETS.PT_eUSDE_29MAY2025.A_TOKEN, 2], - [AaveV3Ethereum.ASSETS.PT_eUSDE_14AUG2025.A_TOKEN, 2], - [AaveV3Ethereum.ASSETS.PT_USDe_31JUL2025.A_TOKEN, 2], - [AaveV3Ethereum.ASSETS.PT_sUSDE_31JUL2025.A_TOKEN, 1], - [AaveV3Ethereum.ASSETS.PT_sUSDE_31JUL2025.A_TOKEN, 1], - [AaveV3Ethereum.ASSETS.PT_sUSDE_25SEP2025.A_TOKEN, 1], -]); + // Resolve aToken → underlying via the reserves snapshot. + const reserve = rewardedAsset + ? supplyReserves.find((r) => r.aToken.address.toLowerCase() === rewardedAsset.toLowerCase()) + : undefined; + const underlying = reserve?.underlyingToken.address; + const market = reserve?.market.address ?? currentMarket; -export const useEthenaIncentives = (rewardedAsset?: string) => { - if (!rewardedAsset) { - return undefined; - } + const { data } = useReserveIncentives({ + market: market ?? '', + underlying: underlying ?? '', + chainId, + enabled: Boolean(market && underlying && chainId), + }); - return getEthenaData(rewardedAsset); + if (!data) return undefined; + + const ethena = data.find( + (i) => i.__typename === 'SupplyPointsIncentive' && i.program.name === 'Ethena Rewards' + ); + if (!ethena || ethena.__typename !== 'SupplyPointsIncentive') return undefined; + + return Number.isFinite(ethena.multiplier) ? ethena.multiplier : undefined; }; diff --git a/src/hooks/useEtherfiIncentives.ts b/src/hooks/useEtherfiIncentives.ts index fcba85fec5..6bf1606132 100644 --- a/src/hooks/useEtherfiIncentives.ts +++ b/src/hooks/useEtherfiIncentives.ts @@ -1,34 +1,52 @@ +/** + * EtherFi partner incentive adapter over the V3 backend. + * + * Legacy signature: `useEtherfiIncentives(market, symbol, protocolAction)`. + * Resolves `(market, symbol)` to the underlying asset via + * `useAppDataContext`, then reads the `SupplyPointsIncentive` whose + * `program.name === "Ether.fi Loyalty"` from `useReserveIncentives`. EtherFi + * is a supply-only loyalty multiplier — borrow contexts get `undefined` + * so `IncentivesCard` doesn't render the badge on borrow rows. Ether.fi + * pays in loyalty points, not APR — see `EtherFiAirdropTooltipContent`. + */ import { ProtocolAction } from '@aave/contract-helpers'; +import { useAppDataContext } from 'src/hooks/app-data-provider/useAppDataProvider'; +import { useRootStore } from 'src/store/root'; -import { CustomMarket } from '../ui-config/marketsConfig'; - -const getetherfiData = ( - market: string, - protocolAction: ProtocolAction, - symbol: string -): number | undefined => ETHERFI_DATA_MAP.get(`${market}-${protocolAction}-${symbol}`); - -const ETHERFI_DATA_MAP: Map = new Map([ - [`${CustomMarket.proto_mainnet_v3}-${ProtocolAction.supply}-weETH`, 3], - [`${CustomMarket.proto_mainnet_v3}-${ProtocolAction.supply}-eBTC`, 3], - [`${CustomMarket.proto_etherfi_v3}-${ProtocolAction.supply}-weETH`, 3], - [`${CustomMarket.proto_lido_v3}-${ProtocolAction.supply}-weETH`, 3], - [`${CustomMarket.proto_arbitrum_v3}-${ProtocolAction.supply}-weETH`, 3], - [`${CustomMarket.proto_base_v3}-${ProtocolAction.supply}-weETH`, 3], - [`${CustomMarket.proto_scroll_v3}-${ProtocolAction.supply}-weETH`, 3], - [`${CustomMarket.proto_zksync_v3}-${ProtocolAction.supply}-weETH`, 3], - [`${CustomMarket.proto_linea_v3}-${ProtocolAction.supply}-weETH`, 3], - [`${CustomMarket.proto_plasma_v3}-${ProtocolAction.supply}-weETH`, 3], -]); +import { useReserveIncentives } from './useReserveIncentives'; export const useEtherfiIncentives = ( - market: string, - symbol: string, + market?: string, + symbol?: string, protocolAction?: ProtocolAction -) => { - if (!market || !protocolAction || !symbol) { - return undefined; - } +): number | undefined => { + const chainId = useRootStore((s) => s.currentChainId); + const { supplyReserves } = useAppDataContext(); - return getetherfiData(market, protocolAction, symbol); + // IncentivesCard calls this hook for both supply and borrow rows. EtherFi + // only has a supply incentive, so gate the query on non-borrow to avoid + // rendering the badge on borrow positions of eligible assets. + const isSupplyContext = protocolAction !== ProtocolAction.borrow; + + // Resolve (market, symbol) → underlying via the reserves snapshot. + const reserve = symbol + ? supplyReserves.find((r) => r.underlyingToken.symbol.toLowerCase() === symbol.toLowerCase()) + : undefined; + const underlying = reserve?.underlyingToken.address; + + const { data } = useReserveIncentives({ + market: market ?? '', + underlying: underlying ?? '', + chainId, + enabled: Boolean(isSupplyContext && market && underlying && chainId), + }); + + if (!isSupplyContext || !data) return undefined; + + const etherfi = data.find( + (i) => i.__typename === 'SupplyPointsIncentive' && i.program.name === 'Ether.fi Loyalty' + ); + if (!etherfi || etherfi.__typename !== 'SupplyPointsIncentive') return undefined; + + return Number.isFinite(etherfi.multiplier) ? etherfi.multiplier : undefined; }; diff --git a/src/hooks/useMeritIncentives.ts b/src/hooks/useMeritIncentives.ts index 0297ae267d..e5631430ac 100644 --- a/src/hooks/useMeritIncentives.ts +++ b/src/hooks/useMeritIncentives.ts @@ -1,851 +1,239 @@ +/** + * Merit incentive adapter over the V3 backend. + * + * Legacy signature preserved: caller passes `{ market, symbol, protocolAction, + * protocolAPY, protocolIncentives }`. Resolves `(market, symbol)` to the + * underlying reserve via `useAppDataContext`, then reads + * `MeritSupply/Borrow/Conditional` incentives from `useReserveIncentives`. + * + * All per-campaign display data (reward token, custom message, forum link, + * self APR) comes from the backend — the legacy `MERIT_DATA_MAP` hardcoded + * in this file is deleted. + */ import { ProtocolAction } from '@aave/contract-helpers'; -import { ReserveIncentiveResponse } from '@aave/math-utils/dist/esm/formatters/incentive/calculate-reserve-incentives'; -import { - AaveSafetyModule, - AaveV3Arbitrum, - AaveV3Avalanche, - AaveV3Base, - AaveV3Celo, - AaveV3Ethereum, - AaveV3EthereumLido, - AaveV3Gnosis, - AaveV3Sonic, -} from '@aave-dao/aave-address-book'; -import { useQuery } from '@tanstack/react-query'; -import { CustomMarket } from 'src/ui-config/marketsConfig'; +import type { ReserveIncentiveResponse } from '@aave/math-utils/dist/esm/formatters/incentive/calculate-reserve-incentives'; +import { useAppDataContext } from 'src/hooks/app-data-provider/useAppDataProvider'; +import { useRootStore } from 'src/store/root'; import { convertAprToApy } from 'src/utils/utils'; -// Enable or disable Self incentives campaign -export const ENABLE_SELF_CAMPAIGN = true; -// export const ENABLE_SELF_CAMPAIGN = false; - -export enum MeritAction { - ETHEREUM_SGHO = 'ethereum-sgho', - ETHEREUM_SUPPLY_PYUSD = 'ethereum-supply-pyusd', - ETHEREUM_SUPPLY_ETHX = 'ethereum-supply-ethx', - ETHEREUM_SUPPLY_RLUSD = 'ethereum-supply-rlusd', - ETHEREUM_PRIME_SUPPLY_ETH = 'ethereum-prime-supply-weth', - ETHEREUM_PRIME_SUPPLY_EZETH = 'ethereum-prime-supply-ezeth', - SUPPLY_CBBTC_BORROW_USDC = 'ethereum-supply-cbbtc-borrow-usdc', - SUPPLY_WBTC_BORROW_USDT = 'ethereum-supply-wbtc-borrow-usdt', - SUPPLY_WEETH_BORROW_USDC = 'ethereum-supply-weeth-borrow-usdc', - ETHEREUM_BORROW_EURC = 'ethereum-borrow-eurc', - ARBITRUM_SUPPLY_ETH = 'arbitrum-supply-weth', - ARBITRUM_SUPPLY_WSTETH = 'arbitrum-supply-wsteth', - ARBITRUM_SUPPLY_EZETH = 'arbitrum-supply-ezeth', - BASE_SUPPLY_CBBTC = 'base-supply-cbbtc', - BASE_SUPPLY_USDC = 'base-supply-usdc', - BASE_SUPPLY_WSTETH = 'base-supply-wsteth', - BASE_SUPPLY_WEETH = 'base-supply-weeth', - BASE_SUPPLY_EZETH = 'base-supply-ezeth', - BASE_SUPPLY_EURC = 'base-supply-eurc', - BASE_SUPPLY_GHO = 'base-supply-gho', - BASE_SUPPLY_LBTC_BORROW_CBBTC = 'base-supply-lbtc-borrow-cbbtc', - BASE_SUPPLY_CBBTC_BORROW_MULTIPLE = 'base-supply-cbbtc-borrow-multiple', - BASE_SUPPLY_WSTETH_BORROW_MULTIPLE = 'base-supply-wsteth-borrow-multiple', - BASE_SUPPLY_WETH_BORROW_MULTIPLE = 'base-supply-eth-borrow-multiple', - BASE_BORROW_EURC = 'base-borrow-eurc', - BASE_BORROW_USDC = 'base-borrow-usdc', - BASE_BORROW_WSTETH = 'base-borrow-wsteth', - BASE_BORROW_GHO = 'base-borrow-gho', - AVALANCHE_SUPPLY_BTCB = 'avalanche-supply-btcb', - AVALANCHE_SUPPLY_USDC = 'avalanche-supply-usdc', - AVALANCHE_SUPPLY_USDT = 'avalanche-supply-usdt', - AVALANCHE_SUPPLY_SAVAX = 'avalanche-supply-savax', - AVALANCHE_SUPPLY_AUSD = 'avalanche-supply-ausd', - AVALANCHE_SUPPLY_GHO = 'avalanche-supply-gho', - AVALANCHE_BORROW_USDC = 'avalanche-borrow-usdc', - AVALANCHE_BORROW_EURC = 'avalanche-borrow-eurc', - SONIC_SUPPLY_USDCE = 'sonic-supply-usdce', - SONIC_SUPPLY_STS_BORROW_WS = 'sonic-supply-sts-borrow-ws', - GNOSIS_BORROW_EURE = 'gnosis-borrow-eure', - CELO_SUPPLY_CELO = 'celo-supply-celo', - CELO_SUPPLY_USDT = 'celo-supply-usdt', - CELO_SUPPLY_USDC = 'celo-supply-usdc', - CELO_SUPPLY_WETH = 'celo-supply-weth', - CELO_SUPPLY_MULTIPLE_BORROW_USDT = 'celo-supply-multiple-borrow-usdt', - CELO_BORROW_CELO = 'celo-borrow-celo', - CELO_BORROW_USDT = 'celo-borrow-usdt', - CELO_BORROW_USDC = 'celo-borrow-usdc', - CELO_BORROW_WETH = 'celo-borrow-weth', -} +import { useReserveIncentives } from './useReserveIncentives'; + +// The backend returns `action_key` as an opaque slug (e.g. `"ethereum-sgho"`). +// `MeritAction` here is a const object + string type so callsites that +// switch on specific actions (`MeritAction.CELO_SUPPLY_USDT`, etc) keep +// working. Only slugs referenced by the interface need to appear here — +// new campaigns come from the backend as raw strings. +export const MeritAction = { + ETHEREUM_SGHO: 'ethereum-sgho', + CELO_SUPPLY_CELO: 'celo-supply-celo', + CELO_SUPPLY_USDT: 'celo-supply-usdt', + CELO_SUPPLY_USDC: 'celo-supply-usdc', + CELO_SUPPLY_WETH: 'celo-supply-weth', + CELO_SUPPLY_MULTIPLE_BORROW_USDT: 'celo-supply-multiple-borrow-usdt', + CELO_BORROW_CELO: 'celo-borrow-celo', + CELO_BORROW_USDT: 'celo-borrow-usdt', + CELO_BORROW_USDC: 'celo-borrow-usdc', + CELO_BORROW_WETH: 'celo-borrow-weth', +} as const; +export type MeritAction = string; -type MeritIncentives = { - totalAPR: number; - actionsAPR: { - [key in MeritAction]: number | null | undefined; - }; -}; +export const ENABLE_SELF_CAMPAIGN = true; -export type ExtendedReserveIncentiveResponse = ReserveIncentiveResponse & { - action: MeritAction; - customMessage: string; - customForumLink: string; +// Merit action keys that should only appear in specific protocol contexts. +const MERIT_ACTION_PROTOCOL_ALLOWLIST: Record> = { + 'ethereum-sgho': new Set([ProtocolAction.stake]), + 'ethereum-stkgho': new Set([ProtocolAction.umbrellaStake]), }; export type MeritIncentivesBreakdown = { protocolAPY: number; protocolIncentivesAPR: number; - meritIncentivesAPR: number; // Now represents APY (converted from APR) + meritIncentivesAPR: number; totalAPY: number; isBorrow: boolean; breakdown: { protocol: number; protocolIncentives: number; - meritIncentives: number; // Now represents APY (converted from APR) + meritIncentives: number; }; }; -const url = 'https://apps.aavechan.com/api/merit/aprs'; - -export type MeritReserveIncentiveData = Omit & { - action: MeritAction; +export type ExtendedReserveIncentiveResponse = ReserveIncentiveResponse & { + action?: MeritAction; customMessage?: string; customForumLink?: string; - protocolAction?: ProtocolAction; + activeActions: MeritAction[]; + actionMessages: Record; + variants: { selfAPY: number | null }; + breakdown: MeritIncentivesBreakdown; }; -export const getMeritData = ( - market: string, - symbol: string -): MeritReserveIncentiveData[] | undefined => MERIT_DATA_MAP[market]?.[symbol]; - -const antiLoopMessage = - 'Borrowing of some assets or holding of some token may impact the amount of rewards you are eligible for. Please check the forum post for the full eligibility criteria.'; - -const antiLoopBorrowMessage = - 'Supplying of some assets or holding of some token may impact the amount of rewards you are eligible for. Please check the forum post for the full eligibility criteria.'; -const masivBorrowUsdcMessage = - 'Only new debt created since the campaign start will be rewarded. Supplying of some assets or holding of some token may impact the amount of rewards you are eligible for.'; -const lbtcCbbtcCampaignMessage = - 'You must supply LBTC and borrow cbBTC, while maintaining a health factor of 1.5 or below, in order to receive merit rewards. Please check the forum post for the full eligibility criteria.'; - -const StSLoopIncentiveProgramMessage = - 'You must supply stS and borrow wS in order to receive merit rewards. stS/wS e-mode can be used to maximize stS/wS loop. Please check the forum post for the full eligibility criteria.'; - -const weethUsdcCampaignMessage = - 'You must supply weETH and borrow new USDC, while maintaining a health factor of 2 or below, in order to receive merit rewards. Eligibility criteria for this campaign are different from usual, please refer to the forum post for full details.'; - -const baseIncentivesUSDCCampaignsMessage = - 'Users must have Moonwell and Gauntlet Morpho Vault positions on Base and must migrate all their positions to Aave on Base to receive rewards. Holding some assets or positions on other protocols may impact the amount of rewards you are eligible for. Please check the forum post for the full eligibility criteria.'; - -const baseIncentivesGHOCampaignsMessage = - 'Users must have Moonwell and Gauntlet Morpho Vault positions on Base and must migrate all their positions to Aave on Base to receive rewards. Holding some assets or positions on other protocols may impact the amount of rewards you are eligible for. Please check the forum post for the full eligibility criteria.'; -const baseIncentivesCbbtcCampaignsMessage = - 'You must supply cbBTC and borrow USDC, GHO, EURC or wETH to receive Merit rewards. Users must have Morpho positions on Base and/or Ethereum and must migrate all their positions to Aave on Base to receive rewards. Please check the forum post for the full eligibility criteria.'; - -const baseIncentivesWstETHCampaignsMessage = - 'You must supply wstETH and borrow USDC, GHO, EURC or wETH to receive Merit rewards. Users must have Morpho positions on Base and must migrate all their positions to Aave on Base to receive rewards. Holding some assets or positions on other protocols may impact the amount of rewards you are eligible for. Please check the forum post for the full eligibility criteria.'; - -const baseIncentivesETHCampaignsMessage = - 'Supplying ETH alone earns 1.25%, supplying ETH and borrowing USDC or EURC earns 1.50%, supplying ETH and borrowing GHO earns 1.75%. Users must have Moonwell and Gauntlet Morpho Vault positions on Base and must migrate all their positions to Aave on Base to receive rewards. Holding some assets or positions on other protocols may impact the amount of rewards you are eligible for. Please check the forum post for the full eligibility criteria.'; - -const celoSupplyMultipleBorrowUsdtMessage = - 'You must supply (CELO or ETH) and borrow USDT, in order to receive merit rewards. Please check the forum post for the full eligibility criteria.'; - -const joinedEthCorrelatedIncentiveForumLink = - 'https://governance.aave.com/t/arfc-set-aci-as-emission-manager-for-liquidity-mining-programs/17898/56'; - -// const joinedEthCorrelatedIncentivePhase2ForumLink = -// 'https://governance.aave.com/t/arfc-set-aci-as-emission-manager-for-liquidity-mining-programs/17898/70'; - -// const eurcForumLink = -// 'https://governance.aave.com/t/arfc-set-aci-as-emission-manager-for-liquidity-mining-programs/17898/77'; - -const AusdRenewalForumLink = - 'https://governance.aave.com/t/arfc-set-aci-as-emission-manager-for-liquidity-mining-programs/17898/88'; -const AvalancheRenewalForumLink = - 'https://governance.aave.com/t/arfc-set-aci-as-emission-manager-for-liquidity-mining-programs/17898/89'; - -// const lbtcCbbtcForumLink = -// 'https://governance.aave.com/t/arfc-set-aci-as-emission-manager-for-liquidity-mining-programs/17898/91'; - -const weethUsdcForumLink = - 'https://governance.aave.com/t/arfc-set-aci-as-emission-manager-for-liquidity-mining-programs/17898/120'; - -const StSLoopIncentiveProgramForumLink = - 'https://governance.aave.com/t/arfc-sts-loop-incentive-program/22368'; - -const baseIncentivesForumLink = - 'https://governance.aave.com/t/arfc-base-incentive-campaign-funding/21983'; - -export const MERIT_DATA_MAP: Record> = { - [CustomMarket.proto_mainnet_v3]: { - GHO: [ - { - action: MeritAction.ETHEREUM_SGHO, - rewardTokenAddress: AaveSafetyModule.STK_GHO, - rewardTokenSymbol: 'sGHO', - customForumLink: - 'https://governance.aave.com/t/arfc-merit-a-new-aave-alignment-user-reward-system/16646', - }, - ], - // cbBTC: [ - // { - // action: MeritAction.SUPPLY_CBBTC_BORROW_USDC, - // rewardTokenAddress: AaveV3Ethereum.ASSETS.USDC.A_TOKEN, - // rewardTokenSymbol: 'aEthUSDC', - // protocolAction: ProtocolAction.supply, - // customMessage: 'You must supply cbBTC and borrow USDC in order to receive merit rewards.', - // }, - // ], - USDC: [ - // { - // action: MeritAction.SUPPLY_CBBTC_BORROW_USDC, - // rewardTokenAddress: AaveV3Ethereum.ASSETS.USDC.A_TOKEN, - // rewardTokenSymbol: 'aEthUSDC', - // protocolAction: ProtocolAction.borrow, - // customMessage: 'You must supply cbBTC and borrow USDC in order to receive merit rewards.', - // }, - { - action: MeritAction.SUPPLY_WEETH_BORROW_USDC, - rewardTokenAddress: AaveV3Ethereum.ASSETS.USDC.A_TOKEN, - rewardTokenSymbol: 'ETHFI', - protocolAction: ProtocolAction.borrow, - customMessage: weethUsdcCampaignMessage, - customForumLink: weethUsdcForumLink, - }, - ], - WBTC: [ - { - action: MeritAction.SUPPLY_WBTC_BORROW_USDT, - rewardTokenAddress: AaveV3Ethereum.ASSETS.USDT.A_TOKEN, - rewardTokenSymbol: 'aEthUSDT', - protocolAction: ProtocolAction.supply, - customMessage: 'You must supply wBTC and borrow USDT in order to receive merit rewards.', - }, - ], - USDT: [ - { - action: MeritAction.SUPPLY_WBTC_BORROW_USDT, - rewardTokenAddress: AaveV3Ethereum.ASSETS.USDT.A_TOKEN, - rewardTokenSymbol: 'aEthUSDT', - protocolAction: ProtocolAction.borrow, - customMessage: 'You must supply wBTC and borrow USDT in order to receive merit rewards.', - }, - ], - PYUSD: [ - { - action: MeritAction.ETHEREUM_SUPPLY_PYUSD, - rewardTokenAddress: AaveV3Ethereum.ASSETS.PYUSD.A_TOKEN, - rewardTokenSymbol: 'aEthPYUSD', - protocolAction: ProtocolAction.supply, - customForumLink: - 'https://governance.aave.com/t/arfc-pyusd-reserve-configuration-update-incentive-campaign/19573', - customMessage: antiLoopMessage, - }, - ], - ETHx: [ - { - action: MeritAction.ETHEREUM_SUPPLY_ETHX, - rewardTokenAddress: '0x30D20208d987713f46DFD34EF128Bb16C404D10f', // Stader (SD) - rewardTokenSymbol: 'SD', - protocolAction: ProtocolAction.supply, - }, - ], - RLUSD: [ - { - action: MeritAction.ETHEREUM_SUPPLY_RLUSD, - rewardTokenAddress: AaveV3Ethereum.ASSETS.RLUSD.A_TOKEN, - rewardTokenSymbol: 'aEthRLUSD', - protocolAction: ProtocolAction.supply, - customMessage: antiLoopMessage, - customForumLink: - 'https://governance.aave.com/t/arfc-set-aci-as-emission-manager-for-liquidity-mining-programs/17898/85', - }, - ], - weETH: [ - { - action: MeritAction.SUPPLY_WEETH_BORROW_USDC, - rewardTokenAddress: AaveV3Ethereum.ASSETS.weETH.A_TOKEN, - rewardTokenSymbol: 'ETHFI', - protocolAction: ProtocolAction.supply, - customMessage: weethUsdcCampaignMessage, - customForumLink: weethUsdcForumLink, - }, - ], - EURC: [ - { - action: MeritAction.ETHEREUM_BORROW_EURC, - rewardTokenAddress: AaveV3Ethereum.ASSETS.EURC.A_TOKEN, - rewardTokenSymbol: 'aEthEURC', - protocolAction: ProtocolAction.borrow, - customMessage: antiLoopBorrowMessage, - }, - ], - }, - [CustomMarket.proto_lido_v3]: { - ETH: [ - { - action: MeritAction.ETHEREUM_PRIME_SUPPLY_ETH, - rewardTokenAddress: AaveV3EthereumLido.ASSETS.WETH.A_TOKEN, - rewardTokenSymbol: 'aEthLidoWETH', - protocolAction: ProtocolAction.supply, - customMessage: antiLoopMessage, - customForumLink: joinedEthCorrelatedIncentiveForumLink, - }, - ], - WETH: [ - { - action: MeritAction.ETHEREUM_PRIME_SUPPLY_ETH, - rewardTokenAddress: AaveV3EthereumLido.ASSETS.WETH.A_TOKEN, - rewardTokenSymbol: 'aEthLidoWETH', - protocolAction: ProtocolAction.supply, - customMessage: antiLoopMessage, - customForumLink: joinedEthCorrelatedIncentiveForumLink, - }, - ], - ezETH: [ - { - action: MeritAction.ETHEREUM_PRIME_SUPPLY_EZETH, - rewardTokenAddress: '0x3B50805453023a91a8bf641e279401a0b23FA6F9', // Renzo (REZ) - rewardTokenSymbol: 'REZ', - protocolAction: ProtocolAction.supply, - customMessage: antiLoopMessage, - customForumLink: joinedEthCorrelatedIncentiveForumLink, - }, - ], - }, - [CustomMarket.proto_arbitrum_v3]: { - ETH: [ - { - action: MeritAction.ARBITRUM_SUPPLY_ETH, - rewardTokenAddress: AaveV3Arbitrum.ASSETS.WETH.A_TOKEN, - rewardTokenSymbol: 'aArbWETH', - protocolAction: ProtocolAction.supply, - customMessage: antiLoopMessage, - customForumLink: joinedEthCorrelatedIncentiveForumLink, - }, - ], - WETH: [ - { - action: MeritAction.ARBITRUM_SUPPLY_ETH, - rewardTokenAddress: AaveV3Arbitrum.ASSETS.WETH.A_TOKEN, - rewardTokenSymbol: 'aArbWETH', - protocolAction: ProtocolAction.supply, - customMessage: antiLoopMessage, - customForumLink: joinedEthCorrelatedIncentiveForumLink, - }, - ], - wstETH: [ - { - action: MeritAction.ARBITRUM_SUPPLY_WSTETH, - rewardTokenAddress: AaveV3Ethereum.ASSETS.wstETH.UNDERLYING, - rewardTokenSymbol: 'aArbwstETH', - protocolAction: ProtocolAction.supply, - customMessage: antiLoopMessage, - customForumLink: joinedEthCorrelatedIncentiveForumLink, - }, - ], - ezETH: [ - { - action: MeritAction.ARBITRUM_SUPPLY_EZETH, - rewardTokenAddress: '0x3B50805453023a91a8bf641e279401a0b23FA6F9', // Renzo (REZ) - rewardTokenSymbol: 'REZ', - protocolAction: ProtocolAction.supply, - customMessage: antiLoopMessage, - customForumLink: joinedEthCorrelatedIncentiveForumLink, - }, - ], - }, - [CustomMarket.proto_base_v3]: { - cbBTC: [ - // { - // action: MeritAction.BASE_SUPPLY_CBBTC, - // rewardTokenAddress: AaveV3Base.ASSETS.USDC.A_TOKEN, - // rewardTokenSymbol: 'aBasUSDC', - // protocolAction: ProtocolAction.supply, - // }, - { - action: MeritAction.BASE_SUPPLY_CBBTC_BORROW_MULTIPLE, - rewardTokenAddress: AaveV3Base.ASSETS.cbBTC.A_TOKEN, - rewardTokenSymbol: 'aBasCBBTC', - protocolAction: ProtocolAction.supply, - customMessage: baseIncentivesCbbtcCampaignsMessage, - customForumLink: baseIncentivesForumLink, - }, - { - action: MeritAction.BASE_SUPPLY_LBTC_BORROW_CBBTC, - rewardTokenAddress: AaveV3Base.ASSETS.USDC.A_TOKEN, - rewardTokenSymbol: 'aBasUSDC', - protocolAction: ProtocolAction.borrow, - customMessage: lbtcCbbtcCampaignMessage, - customForumLink: baseIncentivesForumLink, - }, - ], - USDC: [ - { - action: MeritAction.BASE_SUPPLY_USDC, - rewardTokenAddress: AaveV3Base.ASSETS.USDC.A_TOKEN, - rewardTokenSymbol: 'aBasUSDC', - protocolAction: ProtocolAction.supply, - customMessage: baseIncentivesUSDCCampaignsMessage, - customForumLink: baseIncentivesForumLink, - }, - { - action: MeritAction.BASE_BORROW_USDC, - rewardTokenAddress: AaveV3Base.ASSETS.USDC.A_TOKEN, - rewardTokenSymbol: 'aBasUSDC', - protocolAction: ProtocolAction.borrow, - customMessage: antiLoopBorrowMessage, - customForumLink: baseIncentivesForumLink, - }, - ], - wstETH: [ - // { - // action: MeritAction.BASE_SUPPLY_WSTETH, - // rewardTokenAddress: AaveV3Base.ASSETS.wstETH.A_TOKEN, - // rewardTokenSymbol: 'aBaswstETH', - // protocolAction: ProtocolAction.supply, - // customMessage: antiLoopMessage, - // customForumLink: joinedEthCorrelatedIncentiveForumLink, - // }, - { - action: MeritAction.BASE_SUPPLY_WSTETH_BORROW_MULTIPLE, - rewardTokenAddress: AaveV3Base.ASSETS.wstETH.A_TOKEN, - rewardTokenSymbol: 'aBaswstETH', - protocolAction: ProtocolAction.supply, - customMessage: baseIncentivesWstETHCampaignsMessage, - customForumLink: baseIncentivesForumLink, - }, - { - action: MeritAction.BASE_BORROW_WSTETH, - rewardTokenAddress: AaveV3Base.ASSETS.wstETH.A_TOKEN, - rewardTokenSymbol: 'aBaswstETH', - protocolAction: ProtocolAction.borrow, - customMessage: antiLoopBorrowMessage, - customForumLink: baseIncentivesForumLink, - }, - ], - ezETH: [ - { - action: MeritAction.BASE_SUPPLY_EZETH, - rewardTokenAddress: '0x3B50805453023a91a8bf641e279401a0b23FA6F9', // Renzo (REZ) - rewardTokenSymbol: 'REZ', - protocolAction: ProtocolAction.supply, - customMessage: antiLoopMessage, - customForumLink: baseIncentivesForumLink, - }, - ], - weETH: [ - { - action: MeritAction.BASE_SUPPLY_WEETH, - rewardTokenAddress: AaveV3Base.ASSETS.weETH.A_TOKEN, - rewardTokenSymbol: 'aBasweETH', - protocolAction: ProtocolAction.supply, - customMessage: antiLoopMessage, - customForumLink: baseIncentivesForumLink, - }, - ], - EURC: [ - { - action: MeritAction.BASE_SUPPLY_EURC, - rewardTokenAddress: AaveV3Base.ASSETS.EURC.A_TOKEN, - rewardTokenSymbol: 'aBasEURC', - protocolAction: ProtocolAction.supply, - customMessage: antiLoopMessage, - customForumLink: baseIncentivesForumLink, - }, - { - action: MeritAction.BASE_BORROW_EURC, - rewardTokenAddress: AaveV3Base.ASSETS.EURC.A_TOKEN, - rewardTokenSymbol: 'aBasEURC', - protocolAction: ProtocolAction.borrow, - customMessage: antiLoopBorrowMessage, - customForumLink: baseIncentivesForumLink, - }, - ], - LBTC: [ - { - action: MeritAction.BASE_SUPPLY_LBTC_BORROW_CBBTC, - rewardTokenAddress: AaveV3Base.ASSETS.USDC.A_TOKEN, - rewardTokenSymbol: 'aBasUSDC', - protocolAction: ProtocolAction.supply, - customMessage: lbtcCbbtcCampaignMessage, - customForumLink: baseIncentivesForumLink, - }, - ], - GHO: [ - { - action: MeritAction.BASE_SUPPLY_GHO, - rewardTokenAddress: AaveV3Base.ASSETS.GHO.A_TOKEN, - rewardTokenSymbol: 'aBasGHO', - protocolAction: ProtocolAction.supply, - customMessage: baseIncentivesGHOCampaignsMessage, - customForumLink: baseIncentivesForumLink, - }, - { - action: MeritAction.BASE_BORROW_GHO, - rewardTokenAddress: AaveV3Base.ASSETS.GHO.A_TOKEN, - rewardTokenSymbol: 'aBasGHO', - protocolAction: ProtocolAction.borrow, - customMessage: antiLoopBorrowMessage, - customForumLink: baseIncentivesForumLink, - }, - ], - WETH: [ - { - action: MeritAction.BASE_SUPPLY_WETH_BORROW_MULTIPLE, - rewardTokenAddress: AaveV3Base.ASSETS.WETH.A_TOKEN, - rewardTokenSymbol: 'aBasWETH', - protocolAction: ProtocolAction.supply, - customMessage: baseIncentivesETHCampaignsMessage, - customForumLink: baseIncentivesForumLink, - }, - ], - ETH: [ - { - action: MeritAction.BASE_SUPPLY_WETH_BORROW_MULTIPLE, - rewardTokenAddress: AaveV3Base.ASSETS.WETH.A_TOKEN, - rewardTokenSymbol: 'aBasWETH', - protocolAction: ProtocolAction.supply, - customMessage: baseIncentivesETHCampaignsMessage, - customForumLink: baseIncentivesForumLink, - }, - ], - }, - [CustomMarket.proto_avalanche_v3]: { - ['BTC.b']: [ - { - action: MeritAction.AVALANCHE_SUPPLY_BTCB, - rewardTokenAddress: AaveV3Avalanche.ASSETS.sAVAX.A_TOKEN, - rewardTokenSymbol: 'aAvaSAVAX', - protocolAction: ProtocolAction.supply, - customMessage: antiLoopMessage, - customForumLink: AvalancheRenewalForumLink, - }, - ], - USDC: [ - { - action: MeritAction.AVALANCHE_SUPPLY_USDC, - rewardTokenAddress: AaveV3Avalanche.ASSETS.sAVAX.A_TOKEN, - rewardTokenSymbol: 'aAvaSAVAX', - protocolAction: ProtocolAction.supply, - customMessage: antiLoopMessage, - customForumLink: AvalancheRenewalForumLink, - }, - { - action: MeritAction.AVALANCHE_BORROW_USDC, - rewardTokenAddress: AaveV3Avalanche.ASSETS.sAVAX.A_TOKEN, - rewardTokenSymbol: 'aAvaSAVAX', - protocolAction: ProtocolAction.borrow, - customMessage: masivBorrowUsdcMessage, - customForumLink: AvalancheRenewalForumLink, - }, - ], - USDt: [ - { - action: MeritAction.AVALANCHE_SUPPLY_USDT, - rewardTokenAddress: AaveV3Avalanche.ASSETS.sAVAX.A_TOKEN, - rewardTokenSymbol: 'aAvaSAVAX', - protocolAction: ProtocolAction.supply, - customMessage: antiLoopMessage, - customForumLink: AvalancheRenewalForumLink, - }, - ], - sAVAX: [ - { - action: MeritAction.AVALANCHE_SUPPLY_SAVAX, - rewardTokenAddress: AaveV3Avalanche.ASSETS.sAVAX.A_TOKEN, - rewardTokenSymbol: 'aAvaSAVAX', - protocolAction: ProtocolAction.supply, - customMessage: antiLoopMessage, - customForumLink: AvalancheRenewalForumLink, - }, - ], - AUSD: [ - { - action: MeritAction.AVALANCHE_SUPPLY_AUSD, - rewardTokenAddress: AaveV3Avalanche.ASSETS.sAVAX.A_TOKEN, - rewardTokenSymbol: 'aAvaSAVAX', - protocolAction: ProtocolAction.supply, - customMessage: antiLoopMessage, - customForumLink: AusdRenewalForumLink, - }, - ], - GHO: [ - { - action: MeritAction.AVALANCHE_SUPPLY_GHO, - rewardTokenAddress: AaveV3Avalanche.ASSETS.sAVAX.A_TOKEN, - rewardTokenSymbol: 'aAvaSAVAX', - protocolAction: ProtocolAction.supply, - customMessage: antiLoopMessage, - }, - ], - EURC: [ - { - action: MeritAction.AVALANCHE_BORROW_EURC, - rewardTokenAddress: AaveV3Avalanche.ASSETS.sAVAX.A_TOKEN, - rewardTokenSymbol: 'aAvaSAVAX', - protocolAction: ProtocolAction.borrow, - customMessage: antiLoopBorrowMessage, - }, - ], - }, - [CustomMarket.proto_sonic_v3]: { - ['USDC']: [ - { - action: MeritAction.SONIC_SUPPLY_USDCE, - rewardTokenAddress: AaveV3Sonic.ASSETS.USDC.A_TOKEN, - rewardTokenSymbol: 'aSonwS', - protocolAction: ProtocolAction.supply, - customMessage: antiLoopMessage, - customForumLink: - 'https://governance.aave.com/t/arfc-set-aci-as-emission-manager-for-liquidity-mining-programs/17898/61', - }, - ], - ['stS']: [ - { - action: MeritAction.SONIC_SUPPLY_STS_BORROW_WS, - rewardTokenAddress: AaveV3Sonic.ASSETS.stS.A_TOKEN, - rewardTokenSymbol: 'aSonstS', - protocolAction: ProtocolAction.supply, - customMessage: StSLoopIncentiveProgramMessage, - customForumLink: StSLoopIncentiveProgramForumLink, - }, - ], - ['S']: [ - { - action: MeritAction.SONIC_SUPPLY_STS_BORROW_WS, - rewardTokenAddress: AaveV3Sonic.ASSETS.stS.A_TOKEN, - rewardTokenSymbol: 'aSonstS', - protocolAction: ProtocolAction.borrow, - customMessage: StSLoopIncentiveProgramMessage, - customForumLink: StSLoopIncentiveProgramForumLink, - }, - ], - }, - [CustomMarket.proto_gnosis_v3]: { - ['EURe']: [ - { - action: MeritAction.GNOSIS_BORROW_EURE, - rewardTokenAddress: AaveV3Gnosis.ASSETS.EURe.V_TOKEN, - rewardTokenSymbol: 'aGnoEURe', - protocolAction: ProtocolAction.borrow, - customMessage: antiLoopMessage, - customForumLink: - 'https://governance.aave.com/t/arfc-set-aci-as-emission-manager-for-liquidity-mining-programs/17898/83', - }, - ], - }, - [CustomMarket.proto_celo_v3]: { - CELO: [ - { - action: MeritAction.CELO_SUPPLY_CELO, - rewardTokenAddress: AaveV3Celo.ASSETS.USDT.A_TOKEN, - rewardTokenSymbol: 'aUSD₮', - protocolAction: ProtocolAction.supply, - customMessage: antiLoopMessage, - }, - { - action: MeritAction.CELO_SUPPLY_MULTIPLE_BORROW_USDT, - rewardTokenAddress: AaveV3Celo.ASSETS.USDT.A_TOKEN, - rewardTokenSymbol: 'aUSD₮', - protocolAction: ProtocolAction.supply, - customMessage: celoSupplyMultipleBorrowUsdtMessage, - }, - { - action: MeritAction.CELO_BORROW_CELO, - rewardTokenAddress: AaveV3Celo.ASSETS.USDT.A_TOKEN, - rewardTokenSymbol: 'aUSD₮', - protocolAction: ProtocolAction.borrow, - customMessage: antiLoopBorrowMessage, - }, - ], - ['USD₮']: [ - { - action: MeritAction.CELO_SUPPLY_USDT, - rewardTokenAddress: AaveV3Celo.ASSETS.USDT.A_TOKEN, - rewardTokenSymbol: 'aUSD₮', - protocolAction: ProtocolAction.supply, - customMessage: antiLoopMessage, - }, - { - action: MeritAction.CELO_BORROW_USDT, - rewardTokenAddress: AaveV3Celo.ASSETS.USDT.A_TOKEN, - rewardTokenSymbol: 'aUSD₮', - protocolAction: ProtocolAction.borrow, - customMessage: antiLoopBorrowMessage, - }, - { - action: MeritAction.CELO_SUPPLY_MULTIPLE_BORROW_USDT, - rewardTokenAddress: AaveV3Celo.ASSETS.USDT.A_TOKEN, - rewardTokenSymbol: 'aUSD₮', - protocolAction: ProtocolAction.borrow, - customMessage: celoSupplyMultipleBorrowUsdtMessage, - }, - ], - USDC: [ - { - action: MeritAction.CELO_SUPPLY_USDC, - rewardTokenAddress: AaveV3Celo.ASSETS.USDT.A_TOKEN, - rewardTokenSymbol: 'aUSD₮', - protocolAction: ProtocolAction.supply, - customMessage: antiLoopMessage, - }, - { - action: MeritAction.CELO_BORROW_USDC, - rewardTokenAddress: AaveV3Celo.ASSETS.USDT.A_TOKEN, - rewardTokenSymbol: 'aUSD₮', - protocolAction: ProtocolAction.borrow, - customMessage: antiLoopBorrowMessage, - }, - ], - WETH: [ - { - action: MeritAction.CELO_SUPPLY_WETH, - rewardTokenAddress: AaveV3Celo.ASSETS.USDT.A_TOKEN, - rewardTokenSymbol: 'aUSD₮', - protocolAction: ProtocolAction.supply, - customMessage: antiLoopMessage, - }, - { - action: MeritAction.CELO_SUPPLY_MULTIPLE_BORROW_USDT, - rewardTokenAddress: AaveV3Celo.ASSETS.USDT.A_TOKEN, - rewardTokenSymbol: 'aUSD₮', - protocolAction: ProtocolAction.supply, - customMessage: celoSupplyMultipleBorrowUsdtMessage, - }, - { - action: MeritAction.CELO_BORROW_WETH, - rewardTokenAddress: AaveV3Celo.ASSETS.USDT.A_TOKEN, - rewardTokenSymbol: 'aUSD₮', - protocolAction: ProtocolAction.borrow, - customMessage: antiLoopBorrowMessage, - }, - ], - }, -}; -const getAprVariants = (action: MeritAction, actionsAPR: MeritIncentives['actionsAPR']) => { - const map = actionsAPR as Record; - const selfAPR = map[`self-${action}`] ?? null; - return { selfAPR }; +type UseMeritIncentivesArgs = { + market: string; + symbol: string; + protocolAction?: ProtocolAction; + protocolAPY?: number; + protocolIncentives?: ReserveIncentiveResponse[]; }; export const useMeritIncentives = ({ - symbol, market, + symbol, protocolAction, protocolAPY = 0, protocolIncentives = [], -}: { - symbol: string; - market: string; - protocolAction?: ProtocolAction; - protocolAPY?: number; - protocolIncentives?: ReserveIncentiveResponse[]; -}) => { - return useQuery({ - queryFn: async () => { - const response = await fetch(url); - const data = await response.json(); - - const meritIncentives = data.currentAPR as MeritIncentives; - - return meritIncentives; - }, - queryKey: ['meritIncentives'], - staleTime: 1000 * 60 * 5, - select: (data) => { - const meritReserveIncentiveData = getMeritData(market, symbol); +}: UseMeritIncentivesArgs) => { + const chainId = useRootStore((s) => s.currentChainId); + const { supplyReserves } = useAppDataContext(); + + // Resolve symbol → underlying via the reserves snapshot. + const reserve = symbol + ? supplyReserves.find((r) => r.underlyingToken.symbol.toLowerCase() === symbol.toLowerCase()) + : undefined; + const underlying = reserve?.underlyingToken.address; + + const query = useReserveIncentives({ + market, + underlying: underlying ?? '', + chainId, + enabled: Boolean(market && underlying && chainId && protocolAction), + }); - if (!meritReserveIncentiveData) { - return null; - } + if (!query.data) { + return { ...query, data: null }; + } - const incentives = meritReserveIncentiveData.filter( - (item) => item.protocolAction === protocolAction + const isBorrow = protocolAction === ProtocolAction.borrow; + const merits = query.data.filter((i) => { + if (isBorrow) { + return ( + i.__typename === 'MeritBorrowIncentive' || + i.__typename === 'MeritBorrowAndSupplyIncentiveCondition' ); + } + return ( + i.__typename === 'MeritSupplyIncentive' || + i.__typename === 'MeritBorrowAndSupplyIncentiveCondition' + ); + }); - if (incentives.length === 0) { - return null; - } - - let totalMeritAPR: number | null = null; - let totalSelfAPR: number | null = null; - const totalAmountIncentivesCampaigns: MeritAction[] = []; - - for (const incentive of incentives) { - const standardAPR = data.actionsAPR[incentive.action]; - - if (standardAPR !== null && standardAPR !== undefined && standardAPR > 0) { - totalAmountIncentivesCampaigns.push(incentive.action); - } - if (standardAPR == null) continue; - - if (totalMeritAPR === null) totalMeritAPR = 0; - totalMeritAPR += standardAPR; - - const variants = getAprVariants(incentive.action, data.actionsAPR); - const selfAPR = ENABLE_SELF_CAMPAIGN ? variants.selfAPR : null; - - if (selfAPR != null) { - if (totalSelfAPR === null) totalSelfAPR = 0; - totalSelfAPR += selfAPR; - } - } - - if (totalMeritAPR === null) { - return null; - } - - const meritIncentivesAPY = convertAprToApy(totalMeritAPR / 100); - - const selfIncentivesAPY = totalSelfAPR != null ? convertAprToApy(totalSelfAPR / 100) : null; - - const protocolIncentivesAPR = protocolIncentives.reduce((sum, inc) => { - return sum + (inc.incentiveAPR === 'Infinity' ? 0 : +inc.incentiveAPR); - }, 0); - - const isBorrow = protocolAction === ProtocolAction.borrow; - - const totalAPY = isBorrow - ? protocolAPY - protocolIncentivesAPR - meritIncentivesAPY - (selfIncentivesAPY ?? 0) - : protocolAPY + protocolIncentivesAPR + meritIncentivesAPY + (selfIncentivesAPY ?? 0); + if (merits.length === 0) { + return { ...query, data: null }; + } + + let totalMeritAPR = 0; + let totalSelfAPR = 0; + let hasSelf = false; + + const activeActions: string[] = []; + const actionMessages: Record = {}; + let firstRewardTokenAddress = ''; + let firstRewardTokenSymbol = ''; + let firstAction: string | undefined; + + // Runtime fields added to the GraphQL union variants but not yet + // reflected in the SDK's generated TypeScript types (needs gql.tada + // regen). We access them via a loose shape until the SDK ships. + type EnrichedMerit = { + __typename?: string; + actionKey?: string | null; + rewardTokenAddress?: string | null; + rewardTokenSymbol?: string | null; + customMessage?: string | null; + customForumLink?: string | null; + selfApr?: { formatted: string; value: string } | null; + }; - let finalAction: MeritAction | undefined = undefined; - if (totalAmountIncentivesCampaigns.length >= 1) { - finalAction = totalAmountIncentivesCampaigns[0]; + for (const m of merits) { + let apr = 0; + const enriched = m as unknown as EnrichedMerit; + const rewardTokenAddress = enriched.rewardTokenAddress ?? ''; + const rewardTokenSymbol = enriched.rewardTokenSymbol ?? ''; + + if (enriched.actionKey) { + const allowedProtocolActions = MERIT_ACTION_PROTOCOL_ALLOWLIST[enriched.actionKey]; + if ( + allowedProtocolActions && + (!protocolAction || !allowedProtocolActions.has(protocolAction)) + ) { + continue; } - - const actionMessages = incentives.reduce((acc, incentive) => { - acc[incentive.action] = { - customMessage: incentive.customMessage, - customForumLink: incentive.customForumLink, - }; - return acc; - }, {} as Record); - - return { - incentiveAPR: meritIncentivesAPY.toString(), - rewardTokenAddress: incentives[0].rewardTokenAddress, - rewardTokenSymbol: incentives[0].rewardTokenSymbol, - activeActions: totalAmountIncentivesCampaigns, - actionMessages: actionMessages, - action: finalAction, - customMessage: finalAction ? actionMessages[finalAction]?.customMessage : undefined, - customForumLink: finalAction ? actionMessages[finalAction]?.customForumLink : undefined, - variants: { selfAPY: selfIncentivesAPY }, - - breakdown: { - protocolAPY, - protocolIncentivesAPR, - meritIncentivesAPR: meritIncentivesAPY, - totalAPY, - isBorrow, - breakdown: { - protocol: protocolAPY, - protocolIncentives: protocolIncentivesAPR, - meritIncentives: meritIncentivesAPY, - }, - } as MeritIncentivesBreakdown, - } as ExtendedReserveIncentiveResponse & { - breakdown: MeritIncentivesBreakdown; - - activeActions: MeritAction[]; - actionMessages: Record; - variants: { selfAPY: number | null }; + } + + if (m.__typename === 'MeritSupplyIncentive') { + apr = parseFloat(m.extraSupplyApr.formatted); + } else if (m.__typename === 'MeritBorrowIncentive') { + apr = parseFloat(m.borrowAprDiscount.formatted); + } else if (m.__typename === 'MeritBorrowAndSupplyIncentiveCondition') { + apr = parseFloat(m.extraApr.formatted); + } + + if (!Number.isFinite(apr) || apr <= 0) continue; + + totalMeritAPR += apr; + const actionKey = enriched.actionKey ?? ''; + if (actionKey) { + activeActions.push(actionKey); + actionMessages[actionKey] = { + customMessage: enriched.customMessage ?? undefined, + customForumLink: enriched.customForumLink ?? undefined, }; + if (!firstAction) firstAction = actionKey; + } + if (!firstRewardTokenAddress && rewardTokenAddress) { + firstRewardTokenAddress = rewardTokenAddress; + } + if (!firstRewardTokenSymbol && rewardTokenSymbol) { + firstRewardTokenSymbol = rewardTokenSymbol; + } + + if (ENABLE_SELF_CAMPAIGN && enriched.selfApr) { + const self = parseFloat(enriched.selfApr.formatted); + if (Number.isFinite(self) && self > 0) { + totalSelfAPR += self; + hasSelf = true; + } + } + } + + if (totalMeritAPR === 0 && !hasSelf) { + return { ...query, data: null }; + } + + const meritIncentivesAPY = convertAprToApy(totalMeritAPR / 100); + const selfIncentivesAPY = hasSelf ? convertAprToApy(totalSelfAPR / 100) : null; + + const protocolIncentivesAPR = protocolIncentives.reduce((sum, inc) => { + return sum + (inc.incentiveAPR === 'Infinity' ? 0 : +inc.incentiveAPR); + }, 0); + + const totalAPY = isBorrow + ? protocolAPY - protocolIncentivesAPR - meritIncentivesAPY - (selfIncentivesAPY ?? 0) + : protocolAPY + protocolIncentivesAPR + meritIncentivesAPY + (selfIncentivesAPY ?? 0); + + const extended: ExtendedReserveIncentiveResponse = { + incentiveAPR: meritIncentivesAPY.toString(), + rewardTokenAddress: firstRewardTokenAddress, + rewardTokenSymbol: firstRewardTokenSymbol, + activeActions, + actionMessages, + action: firstAction, + customMessage: firstAction ? actionMessages[firstAction]?.customMessage : undefined, + customForumLink: firstAction ? actionMessages[firstAction]?.customForumLink : undefined, + variants: { selfAPY: selfIncentivesAPY }, + breakdown: { + protocolAPY, + protocolIncentivesAPR, + meritIncentivesAPR: meritIncentivesAPY, + totalAPY, + isBorrow, + breakdown: { + protocol: protocolAPY, + protocolIncentives: protocolIncentivesAPR, + meritIncentives: meritIncentivesAPY, + }, }, - }); + }; + + return { ...query, data: extended }; }; diff --git a/src/hooks/useMerklIncentives.ts b/src/hooks/useMerklIncentives.ts index edb4173610..e7e07f9bcc 100644 --- a/src/hooks/useMerklIncentives.ts +++ b/src/hooks/useMerklIncentives.ts @@ -1,211 +1,61 @@ +/** + * Merkl incentive adapter over the V3 backend. + * + * Legacy signature preserved: caller passes `market` + `rewardedAsset` + * (aToken address) + `protocolAction` + optional `protocolAPY` + + * `protocolIncentives`. The hook resolves the aToken to its underlying + * reserve via `useAppDataContext`, then reads the matching `MerklSupply` or + * `MerklBorrow` incentive from `useReserveIncentives`. Live APR, partner + * copy (description, customMessage, customForumLink, customClaimMessage), + * and payout token come from the backend. + * + * The return shape matches the legacy `ExtendedReserveIncentiveResponse` + * so existing renderers (`MerklIncentivesTooltipContent`, `IncentivesCard`, + * `useUserYield`) keep working unchanged. + */ import { ProtocolAction } from '@aave/contract-helpers'; import type { ReserveIncentiveResponse } from '@aave/math-utils/dist/esm/formatters/incentive/calculate-reserve-incentives'; -import { useQuery } from '@tanstack/react-query'; +import { useAppDataContext } from 'src/hooks/app-data-provider/useAppDataProvider'; import { useRootStore } from 'src/store/root'; import { convertAprToApy } from 'src/utils/utils'; -import { type Address, checksumAddress } from 'viem'; -enum OpportunityAction { - LEND = 'LEND', - BORROW = 'BORROW', -} - -enum OpportunityStatus { - LIVE = 'LIVE', - PAST = 'PAST', - UPCOMING = 'UPCOMING', -} - -export type MerklOpportunity = { - chainId: number; - type: string; - description?: string; - identifier: Address; - name: string; - depositUrl?: string; - status: OpportunityStatus; - action: OpportunityAction; - tvl: number; - apr: number; - dailyRewards: number; - tags: []; - id: string; - explorerAddress?: Address; - tokens: { - id: string; - name: string; - chainId: number; - address: Address; - decimals: number; - icon: string; - verified: boolean; - isTest: boolean; - price: number; - symbol: string; - }[]; - aprRecord: { - cumulated: number; - timestamp: string; - breakdowns: { - distributionType: string; - identifier: string; - type: string; - value: number; - timestamp: string; - }[]; - }; - rewardsRecord: { - id: string; - total: number; - timestamp: string; - breakdowns: { - token: { - id: string; - name: string; - chainId: number; - address: string; - decimals: number; - symbol: string; - displaySymbol: string; - icon: string; - verified: boolean; - isTest: boolean; - type: string; - isNative: boolean; - price: number; - }; - amount: string; - value: number; - distributionType: string; - id: string; - campaignId: string; - dailyRewardsRecordId: string; - onChainCampaignId: string; - }[]; - }; -}; - -type ReserveIncentiveAdditionalData = { - customClaimMessage?: string; - customMessage?: string; - customForumLink?: string; -}; - -export type ExtendedReserveIncentiveResponse = ReserveIncentiveResponse & - ReserveIncentiveAdditionalData & { - breakdown: MerklIncentivesBreakdown; - description?: string; - rewardsTokensMappedApys?: { - token: { - id: string; - name: string; - chainId: number; - address: string; - decimals: number; - symbol: string; - displaySymbol: string; - icon: string; - verified: boolean; - isTest: boolean; - type: string; - isNative: boolean; - price: number; - }; - amount: string; - value: number; - distributionType: string; - id: string; - campaignId: string; - dailyRewardsRecordId: string; - onChainCampaignId: string; - apy: number; - }[]; - }; +import { useReserveIncentives } from './useReserveIncentives'; export type MerklIncentivesBreakdown = { protocolAPY: number; protocolIncentivesAPR: number; - merklIncentivesAPR: number; // Now represents APY (converted from APR) + merklIncentivesAPR: number; totalAPY: number; isBorrow: boolean; breakdown: { protocol: number; protocolIncentives: number; - merklIncentives: number; // Now represents APY (converted from APR) + merklIncentives: number; }; points?: { dailyPoints: number; pointsPerThousandUsd: number; }; }; -type WhitelistApiResponse = { - whitelistedRewardTokens: string[]; - additionalIncentiveInfo: Record; + +type ReserveIncentiveAdditionalData = { + customClaimMessage?: string; + customMessage?: string; + customForumLink?: string; }; -const MERKL_ENDPOINT = - 'https://api.merkl.xyz/v4/opportunities?mainProtocolId=aave&items=100&status=LIVE'; // Merkl API -const WHITELIST_ENDPOINT = 'https://apps.aavechan.com/api/aave/merkl/whitelist-token-list'; // Endpoint to fetch whitelisted tokens -const EXTRA_WHITELIST_TOKENS = ['0xE3190143Eb552456F88464662f0c0C4aC67A77eB'.toLowerCase()]; -const AAVE_NET_APR_DISTRIBUTION_TYPE = 'AAVE_NET_APR'; -const convertApyToApr = (apy: number) => 12 * ((1 + apy) ** (1 / 12) - 1); +export type ExtendedReserveIncentiveResponse = ReserveIncentiveResponse & + ReserveIncentiveAdditionalData & { + breakdown: MerklIncentivesBreakdown; + description?: string; + }; -const getCampaignIncentiveApr = ({ - targetAprPercent, - distributionType, - protocolAction, - baseProtocolApy, -}: { - targetAprPercent: number; - distributionType?: string; +type UseMerklIncentivesArgs = { + market: string; + rewardedAsset?: string; protocolAction?: ProtocolAction; - baseProtocolApy: number; -}) => { - const campaignApr = targetAprPercent / 100; - - if (distributionType !== AAVE_NET_APR_DISTRIBUTION_TYPE) { - return campaignApr; - } - - // For net APR campaigns, derive incentive delta in APY so: - // supply => base APY + reward APY = target APY - // borrow => base APY - reward APY = target APY - // Then convert delta APY back to APR to keep the existing global APR pipeline unchanged. - const targetApy = convertAprToApy(campaignApr); - const targetMinusBase = targetApy - baseProtocolApy; - - if (protocolAction === ProtocolAction.borrow) { - return convertApyToApr(Math.max(-targetMinusBase, 0)); - } - - return convertApyToApr(Math.max(targetMinusBase, 0)); -}; - -const checkOpportunityAction = ( - opportunityAction: OpportunityAction, - protocolAction: ProtocolAction -) => { - switch (opportunityAction) { - case OpportunityAction.LEND: - return protocolAction === ProtocolAction.supply; - case OpportunityAction.BORROW: - return protocolAction === ProtocolAction.borrow; - default: - return false; - } -}; -const useWhitelistedTokens = () => { - return useQuery({ - queryFn: async (): Promise => { - const response = await fetch(WHITELIST_ENDPOINT); - if (!response.ok) { - throw new Error('Failed to fetch whitelisted tokens'); - } - return await response.json(); - }, - queryKey: ['whitelistedTokens'], - staleTime: 1000 * 60 * 5, // 5 minutes - }); + protocolAPY?: number; + protocolIncentives?: ReserveIncentiveResponse[]; }; export const useMerklIncentives = ({ @@ -214,149 +64,101 @@ export const useMerklIncentives = ({ protocolAction, protocolAPY = 0, protocolIncentives = [], -}: { - market: string; - rewardedAsset?: string; - protocolAction?: ProtocolAction; - protocolAPY?: number; - protocolIncentives?: ReserveIncentiveResponse[]; -}) => { - const currentChainId = useRootStore((state) => state.currentChainId); - const { data: whitelistData } = useWhitelistedTokens(); - - return useQuery({ - queryFn: async () => { - const response = await fetch(`${MERKL_ENDPOINT}`); - const merklOpportunities: MerklOpportunity[] = await response.json(); - - return merklOpportunities; - }, - queryKey: ['merklIncentives', market], - staleTime: 1000 * 60 * 5, - select: (merklOpportunities) => { - const opportunities = merklOpportunities.filter( - (opportunitiy) => - rewardedAsset && - opportunitiy.explorerAddress && - opportunitiy.explorerAddress.toLowerCase() === rewardedAsset.toLowerCase() && - protocolAction && - checkOpportunityAction(opportunitiy.action, protocolAction) && - opportunitiy.chainId === currentChainId - ); - - if (opportunities.length === 0) { - return null; - } - - const validOpportunities = opportunities.filter( - (opp) => opp.status === OpportunityStatus.LIVE && opp.apr > 0 - ); - - if (!whitelistData?.whitelistedRewardTokens) { - return null; - } - - const whitelistedTokensSet = new Set( - [ - ...whitelistData.whitelistedRewardTokens.map((token) => token.toLowerCase()), - ...EXTRA_WHITELIST_TOKENS, - ].filter(Boolean) - ); - - const whitelistedOpportunities = validOpportunities.filter((opp) => - opp.rewardsRecord.breakdowns.some((breakdown) => { - const rewardToken = breakdown.token; - return rewardToken && whitelistedTokensSet.has(rewardToken.address.toLowerCase()); - }) - ); - - if (whitelistedOpportunities.length === 0) { - return null; - } - - const totalMerklAPR = whitelistedOpportunities.reduce((sum, opp) => { - const oppApr = opp.aprRecord.breakdowns.reduce((breakdownSum, breakdown) => { - return ( - breakdownSum + - getCampaignIncentiveApr({ - targetAprPercent: breakdown.value, - distributionType: breakdown.distributionType, - protocolAction, - baseProtocolApy: protocolAPY, - }) - ); - }, 0); - - return sum + oppApr; - }, 0); - - const merklIncentivesAPY = convertAprToApy(totalMerklAPR); - console.log('Total Merkl APR:', totalMerklAPR, '=> APY:', merklIncentivesAPY); - const aprsBreakdowns = whitelistedOpportunities.flatMap((opp) => opp.aprRecord.breakdowns); - const breakdownTokens = whitelistedOpportunities.flatMap((opp) => { - return opp.rewardsRecord.breakdowns; - }); - - const rewardsTokensMappedApys = aprsBreakdowns - .map((aprBreakdown) => { - const matchingReward = breakdownTokens.find((reward) => { - const isWhitelisted = whitelistedTokensSet.has(reward.token.address.toLowerCase()); - return isWhitelisted && reward.onChainCampaignId === aprBreakdown.identifier; - }); - if (matchingReward) { - return { - ...matchingReward, - apy: convertAprToApy( - getCampaignIncentiveApr({ - targetAprPercent: aprBreakdown.value, - distributionType: aprBreakdown.distributionType, - protocolAction, - baseProtocolApy: protocolAPY, - }) - ), - }; - } - return null; - }) - .filter((item): item is NonNullable => item !== null); - - const primaryOpportunity = whitelistedOpportunities[0]; - const rewardToken = primaryOpportunity.rewardsRecord.breakdowns[0].token; - const description = primaryOpportunity.description; - const protocolIncentivesAPR = protocolIncentives.reduce((sum, inc) => { - return sum + (inc.incentiveAPR === 'Infinity' ? 0 : +inc.incentiveAPR); - }, 0); +}: UseMerklIncentivesArgs) => { + const chainId = useRootStore((s) => s.currentChainId); + const { supplyReserves, borrowReserves } = useAppDataContext(); + + // Resolve rewardedAsset (aToken or vToken) → underlying via the reserves + // snapshot. Supply side uses `aToken.address`; borrow side uses + // `vToken.address`. + const reserves = protocolAction === ProtocolAction.borrow ? borrowReserves : supplyReserves; + const reserve = rewardedAsset + ? reserves.find( + (r) => + (protocolAction === ProtocolAction.borrow + ? r.vToken?.address + : r.aToken?.address + )?.toLowerCase() === rewardedAsset.toLowerCase() + ) + : undefined; + const underlying = reserve?.underlyingToken.address; + + const query = useReserveIncentives({ + market, + underlying: underlying ?? '', + chainId, + enabled: Boolean(market && underlying && chainId && protocolAction), + }); - const protocolIncentivesAPY = convertAprToApy(protocolIncentivesAPR); + const isBorrow = protocolAction === ProtocolAction.borrow; + const targetTypename = isBorrow ? 'MerklBorrowIncentive' : 'MerklSupplyIncentive'; - const isBorrow = protocolAction === ProtocolAction.borrow; - const totalAPY = isBorrow - ? protocolAPY - protocolIncentivesAPY - merklIncentivesAPY - : protocolAPY + protocolIncentivesAPY + merklIncentivesAPY; + const incentive = query.data?.find((i) => i.__typename === targetTypename); - const incentiveKey = `${currentChainId}-${checksumAddress(rewardedAsset as Address)}`; - const incentiveAdditionalData = whitelistData?.additionalIncentiveInfo?.[incentiveKey]; + if (!incentive) { + return { ...query, data: null }; + } - return { - incentiveAPR: merklIncentivesAPY.toString(), - rewardTokenAddress: rewardToken.address, - rewardTokenSymbol: rewardToken.symbol, - description: description, - ...incentiveAdditionalData, - rewardsTokensMappedApys, - breakdown: { - protocolAPY, - protocolIncentivesAPR: protocolIncentivesAPY, - merklIncentivesAPR: merklIncentivesAPY, - totalAPY, - isBorrow, - breakdown: { - protocol: protocolAPY, - protocolIncentives: protocolIncentivesAPY, - merklIncentives: merklIncentivesAPY, - }, - } as MerklIncentivesBreakdown, - } as ExtendedReserveIncentiveResponse; + const aprPct = + incentive.__typename === 'MerklSupplyIncentive' + ? parseFloat(incentive.extraSupplyApr.formatted) + : incentive.__typename === 'MerklBorrowIncentive' + ? parseFloat(incentive.borrowAprDiscount.formatted) + : 0; + + const merklIncentivesAPR = Number.isFinite(aprPct) ? aprPct / 100 : 0; + const merklIncentivesAPY = convertAprToApy(merklIncentivesAPR); + + const protocolIncentivesAPR = protocolIncentives.reduce((sum, inc) => { + return sum + (inc.incentiveAPR === 'Infinity' ? 0 : +inc.incentiveAPR); + }, 0); + const protocolIncentivesAPY = convertAprToApy(protocolIncentivesAPR); + + const totalAPY = isBorrow + ? protocolAPY - protocolIncentivesAPY - merklIncentivesAPY + : protocolAPY + protocolIncentivesAPY + merklIncentivesAPY; + + // Fields added to the Merkl*Incentive GraphQL variants but not yet in + // the SDK's generated types. Pull them through a loose shape until the + // SDK schema regen ships. + const enriched = incentive as unknown as { + description?: string | null; + customMessage?: string | null; + customForumLink?: string | null; + customClaimMessage?: string | null; + }; + const description = enriched.description ?? undefined; + const customMessage = enriched.customMessage ?? undefined; + const customForumLink = enriched.customForumLink ?? undefined; + const customClaimMessage = enriched.customClaimMessage ?? undefined; + + const payoutToken = + incentive.__typename === 'MerklSupplyIncentive' || + incentive.__typename === 'MerklBorrowIncentive' + ? incentive.payoutToken + : null; + + const extended: ExtendedReserveIncentiveResponse = { + incentiveAPR: merklIncentivesAPY.toString(), + rewardTokenAddress: payoutToken?.address ?? '', + rewardTokenSymbol: payoutToken?.symbol ?? '', + description, + customMessage, + customForumLink, + customClaimMessage, + breakdown: { + protocolAPY, + protocolIncentivesAPR: protocolIncentivesAPY, + merklIncentivesAPR: merklIncentivesAPY, + totalAPY, + isBorrow, + breakdown: { + protocol: protocolAPY, + protocolIncentives: protocolIncentivesAPY, + merklIncentives: merklIncentivesAPY, + }, }, - }); + }; + + return { ...query, data: extended }; }; diff --git a/src/hooks/useMerklPointsIncentives.ts b/src/hooks/useMerklPointsIncentives.ts index fb00ff2e49..5e11ad2387 100644 --- a/src/hooks/useMerklPointsIncentives.ts +++ b/src/hooks/useMerklPointsIncentives.ts @@ -1,50 +1,28 @@ +/** + * Merkl points incentive adapter over the V3 backend. + * + * Legacy signature preserved: caller passes `market` + `rewardedAsset` + * (aToken/vToken) + `protocolAction` + optional `protocolAPY` + + * `protocolIncentives` + `enabled`. Resolves rewardedAsset → underlying + * via `useAppDataContext` and reads `SupplyPointsIncentive` / + * `BorrowPointsIncentive` from `useReserveIncentives`. + */ import { ProtocolAction } from '@aave/contract-helpers'; -import { ReserveIncentiveResponse } from '@aave/math-utils/dist/esm/formatters/incentive/calculate-reserve-incentives'; -import { useQuery } from '@tanstack/react-query'; +import type { ReserveIncentiveResponse } from '@aave/math-utils/dist/esm/formatters/incentive/calculate-reserve-incentives'; +import { useAppDataContext } from 'src/hooks/app-data-provider/useAppDataProvider'; import { useRootStore } from 'src/store/root'; import { convertAprToApy } from 'src/utils/utils'; -import { - ExtendedReserveIncentiveResponse, - MerklIncentivesBreakdown, - MerklOpportunity, -} from './useMerklIncentives'; +import { ExtendedReserveIncentiveResponse, MerklIncentivesBreakdown } from './useMerklIncentives'; +import { useReserveIncentives } from './useReserveIncentives'; -enum OpportunityAction { - LEND = 'LEND', - BORROW = 'BORROW', -} - -enum OpportunityStatus { - LIVE = 'LIVE', - PAST = 'PAST', - UPCOMING = 'UPCOMING', -} - -type ReserveIncentiveAdditionalData = { - customClaimMessage?: string; - customMessage?: string; - customForumLink?: string; -}; - -// Hardcoded Merkl endpoint for INK/tydro incentives -// Normalize to lowercase for case-insensitive comparison -const INK_POINT_TOKEN_ADDRESSES = ['0x40aBd730Cc9dA34a8EE9823fEaBDBa35E50c4ac7'.toLowerCase()]; -const MERKL_TYDRO_ENDPOINT = - 'https://api.merkl.xyz/v4/opportunities?mainProtocolId=tydro&chainName=ink&items=100&status=LIVE'; // Merkl API - -const checkOpportunityAction = ( - opportunityAction: OpportunityAction, - protocolAction: ProtocolAction -) => { - switch (opportunityAction) { - case OpportunityAction.LEND: - return protocolAction === ProtocolAction.supply; - case OpportunityAction.BORROW: - return protocolAction === ProtocolAction.borrow; - default: - return false; - } +type UseMerklPointsIncentivesArgs = { + market: string; + rewardedAsset?: string; + protocolAction?: ProtocolAction; + protocolAPY?: number; + protocolIncentives?: ReserveIncentiveResponse[]; + enabled?: boolean; }; export const useMerklPointsIncentives = ({ @@ -54,124 +32,93 @@ export const useMerklPointsIncentives = ({ protocolAPY = 0, protocolIncentives = [], enabled = true, -}: { - market: string; - rewardedAsset?: string; - protocolAction?: ProtocolAction; - protocolAPY?: number; - protocolIncentives?: ReserveIncentiveResponse[]; - enabled?: boolean; -}) => { - const currentChainId = useRootStore((state) => state.currentChainId); - - return useQuery({ - queryFn: async () => { - const response = await fetch(`${MERKL_TYDRO_ENDPOINT}`); - const merklOpportunities: MerklOpportunity[] = await response.json(); - return merklOpportunities; - }, - queryKey: ['merklPointsIncentives', market], - staleTime: 1000 * 60 * 5, - enabled: enabled && !!rewardedAsset && !!protocolAction, - select: (merklOpportunities) => { - const opportunities = merklOpportunities.filter((opportunity) => { - if (!rewardedAsset || !protocolAction) { - return false; - } - - // Prefer explorerAddress (aToken/vToken) but fall back to token list to handle - // API inconsistencies where explorerAddress isn't aligned with token[0]. - const matchesExplorer = - opportunity.explorerAddress?.toLowerCase() === rewardedAsset.toLowerCase(); - const matchesToken = opportunity.tokens.some( - (token) => token.address.toLowerCase() === rewardedAsset.toLowerCase() - ); - - return ( - (matchesExplorer || matchesToken) && - checkOpportunityAction(opportunity.action, protocolAction) && - opportunity.chainId === currentChainId - ); - }); - - if (opportunities.length === 0) { - return null; - } - - const pointsOpportunities = opportunities.filter((opp) => - opp.rewardsRecord.breakdowns.some((breakdown) => - INK_POINT_TOKEN_ADDRESSES.includes(breakdown.token.address.toLowerCase()) - ) - ); - - const opportunity = pointsOpportunities.find((opp) => opp.status === OpportunityStatus.LIVE); - - if (!opportunity) { - return null; - } - - const rewardsBreakdown = opportunity.rewardsRecord.breakdowns.find((breakdown) => - INK_POINT_TOKEN_ADDRESSES.includes(breakdown.token.address.toLowerCase()) - ); - - if (!rewardsBreakdown) { - return null; - } - - // APR for this kind of campaign is always 0, as it's point-based - const merklIncentivesAPY = 0; - - const rewardToken = rewardsBreakdown.token; - - if (!INK_POINT_TOKEN_ADDRESSES.includes(rewardToken.address.toLowerCase())) { - return null; - } - - const protocolIncentivesAPR = protocolIncentives.reduce((sum, inc) => { - return sum + (inc.incentiveAPR === 'Infinity' ? 0 : +inc.incentiveAPR); - }, 0); - - const protocolIncentivesAPY = convertAprToApy(protocolIncentivesAPR); - - const isBorrow = protocolAction === ProtocolAction.borrow; - const totalAPY = isBorrow - ? protocolAPY - protocolIncentivesAPY - merklIncentivesAPY - : protocolAPY + protocolIncentivesAPY + merklIncentivesAPY; +}: UseMerklPointsIncentivesArgs) => { + const chainId = useRootStore((s) => s.currentChainId); + const { supplyReserves, borrowReserves } = useAppDataContext(); + + const reserves = protocolAction === ProtocolAction.borrow ? borrowReserves : supplyReserves; + const reserve = rewardedAsset + ? reserves.find( + (r) => + (protocolAction === ProtocolAction.borrow + ? r.vToken?.address + : r.aToken?.address + )?.toLowerCase() === rewardedAsset.toLowerCase() + ) + : undefined; + const underlying = reserve?.underlyingToken.address; + + const query = useReserveIncentives({ + market, + underlying: underlying ?? '', + chainId, + enabled: enabled && Boolean(market && underlying && chainId && protocolAction), + }); - const incentiveAdditionalData: ReserveIncentiveAdditionalData = { - customMessage: opportunity.description, - customForumLink: opportunity.depositUrl, - }; + const isBorrow = protocolAction === ProtocolAction.borrow; + const targetTypename = isBorrow ? 'BorrowPointsIncentive' : 'SupplyPointsIncentive'; - const dailyPoints = Number(rewardsBreakdown.value); - const tvl = Number(opportunity.tvl) || 0; - const pointsPerThousandUsd = tvl > 0 ? (dailyPoints / tvl) * 1000 : 0; + const incentive = query.data?.find((i) => i.__typename === targetTypename); - const breakdown: MerklIncentivesBreakdown = { - protocolAPY, - protocolIncentivesAPR: protocolIncentivesAPY, - merklIncentivesAPR: merklIncentivesAPY, - totalAPY, - isBorrow, - breakdown: { - protocol: protocolAPY, - protocolIncentives: protocolIncentivesAPY, - merklIncentives: merklIncentivesAPY, - }, - points: { - dailyPoints, - pointsPerThousandUsd, - }, - }; + if ( + !incentive || + (incentive.__typename !== 'SupplyPointsIncentive' && + incentive.__typename !== 'BorrowPointsIncentive') + ) { + return { ...query, data: null }; + } - return { - incentiveAPR: merklIncentivesAPY.toString(), - rewardTokenAddress: rewardToken.address, - rewardTokenSymbol: rewardToken.symbol, - ...incentiveAdditionalData, - breakdown: breakdown, - points: { dailyPoints, pointsPerThousandUsd }, - } as ExtendedReserveIncentiveResponse; + const merklIncentivesAPY = 0; + + const protocolIncentivesAPR = protocolIncentives.reduce((sum, inc) => { + return sum + (inc.incentiveAPR === 'Infinity' ? 0 : +inc.incentiveAPR); + }, 0); + const protocolIncentivesAPY = convertAprToApy(protocolIncentivesAPR); + + const totalAPY = isBorrow + ? protocolAPY - protocolIncentivesAPY - merklIncentivesAPY + : protocolAPY + protocolIncentivesAPY + merklIncentivesAPY; + + // dailyPoints / pointsPerThousandUsd / custom copy fields ship in the + // GraphQL Points variants but aren't in the SDK's TypeScript types yet. + const enriched = incentive as unknown as { + dailyPoints?: number | null; + pointsPerThousandUsd?: number | null; + program?: { name?: string | null } | null; + customMessage?: string | null; + customForumLink?: string | null; + }; + const dailyPoints = enriched.dailyPoints ?? 0; + const pointsPerThousandUsd = enriched.pointsPerThousandUsd ?? 0; + const programName = enriched.program?.name; + const customMessage = enriched.customMessage; + const customForumLink = enriched.customForumLink; + + const breakdown: MerklIncentivesBreakdown = { + protocolAPY, + protocolIncentivesAPR: protocolIncentivesAPY, + merklIncentivesAPR: merklIncentivesAPY, + totalAPY, + isBorrow, + breakdown: { + protocol: protocolAPY, + protocolIncentives: protocolIncentivesAPY, + merklIncentives: merklIncentivesAPY, }, - }); + points: { dailyPoints, pointsPerThousandUsd }, + }; + + const extended: ExtendedReserveIncentiveResponse & { + points: { dailyPoints: number; pointsPerThousandUsd: number }; + } = { + incentiveAPR: '0', + rewardTokenAddress: '', + rewardTokenSymbol: programName ?? 'points', + customMessage: customMessage ?? undefined, + customForumLink: customForumLink ?? undefined, + breakdown, + points: { dailyPoints, pointsPerThousandUsd }, + }; + + return { ...query, data: extended }; }; diff --git a/src/hooks/useReserveIncentives.ts b/src/hooks/useReserveIncentives.ts new file mode 100644 index 0000000000..516cdacb83 --- /dev/null +++ b/src/hooks/useReserveIncentives.ts @@ -0,0 +1,370 @@ +/** + * Reserve-level incentives read through the V3 backend GraphQL API. + * + * Background: historically the interface pegged to Merkl, aavechan, and a + * handful of hardcoded partner maps to render incentives on each reserve. + * `aave-v3-backend` now centralizes those sources — Merit (legacy ACI), + * governance-native Aave incentives, Aave-owned Merkl campaigns, and + * points / loyalty programs (Aave Points, Tydro Ink, Ethena Rewards, + * Ether.fi Loyalty) — behind `Reserve.incentives`. This hook reads + * that union so downstream UI can render any variant. + * + * The 7 legacy hooks (`useMerklIncentives`, `useMerklPointsIncentives`, + * `useMeritIncentives`, `useUserMeritIncentives`, `useEthenaIncentives`, + * `useEtherfiIncentives`, `useSonicIncentives`) continue to work; they will + * be migrated to derive from this hook in a follow-up PR. + */ +import { useQuery } from '@tanstack/react-query'; +import { CustomMarket, marketsData } from 'src/ui-config/marketsConfig'; + +/** + * Consumer code historically passes `currentMarket` — an internal slug like + * `"proto_mainnet_v3"` — as `market`. The backend's `ReserveRequest.market` + * field expects a V3 Pool address. Resolve the slug → address here so every + * existing callsite keeps working while new callers can pass the address + * directly. + */ +const resolveMarketAddress = (market: string): string => { + if (market.startsWith('0x')) return market; + const cfg = marketsData[market as CustomMarket]; + return cfg?.addresses?.LENDING_POOL ?? market; +}; + +const DEFAULT_ENDPOINT = 'https://api.v3.staging.aave.com/graphql'; +const GRAPHQL_ENDPOINT = process.env.NEXT_PUBLIC_AAVE_V3_API_URL ?? DEFAULT_ENDPOINT; + +/** Identifier for a reward program row in `aave-v3-backend`. UUID string. */ +export type RewardId = string; + +export type IncentiveCriteria = { + __typename: 'IncentiveCriteria'; + id: string; + text: string; + userPassed: boolean; +}; + +export type PointsProgram = { + __typename: 'PointsProgram'; + id: RewardId; + name: string; + externalUrl: string | null; + iconUrl: string | null; +}; + +type PercentValue = { + formatted: string; + value: string; +}; + +type Currency = { + address: string; + chainId: number; + symbol?: string; +}; + +// ----- Legacy variants (on-chain Merit + governance-native) ------------------ + +export type MeritSupplyIncentive = { + __typename: 'MeritSupplyIncentive'; + extraSupplyApr: PercentValue; + claimLink: string; + actionKey?: string; + rewardTokenAddress?: string; + rewardTokenSymbol?: string; + customMessage?: string | null; + customForumLink?: string | null; + selfApr?: PercentValue | null; +}; + +export type MeritBorrowIncentive = { + __typename: 'MeritBorrowIncentive'; + borrowAprDiscount: PercentValue; + claimLink: string; + actionKey?: string; + rewardTokenAddress?: string; + rewardTokenSymbol?: string; + customMessage?: string | null; + customForumLink?: string | null; + selfApr?: PercentValue | null; +}; + +export type MeritBorrowAndSupplyIncentiveCondition = { + __typename: 'MeritBorrowAndSupplyIncentiveCondition'; + extraApr: PercentValue; + supplyToken: Currency; + borrowToken: Currency; + claimLink: string; + actionKey?: string; + rewardTokenAddress?: string; + rewardTokenSymbol?: string; + customMessage?: string | null; + customForumLink?: string | null; + selfApr?: PercentValue | null; +}; + +export type AaveSupplyIncentive = { + __typename: 'AaveSupplyIncentive'; + extraSupplyApr: PercentValue; + rewardTokenAddress: string; + rewardTokenSymbol: string; +}; + +export type AaveBorrowIncentive = { + __typename: 'AaveBorrowIncentive'; + borrowAprDiscount: PercentValue; + rewardTokenAddress: string; + rewardTokenSymbol: string; +}; + +// ----- New variants (Aave-owned Merkl + points / partner loyalty programs) -- + +export type MerklSupplyIncentive = { + __typename: 'MerklSupplyIncentive'; + id: RewardId; + startDate: string; + endDate: string; + extraSupplyApr: PercentValue; + payoutToken: Currency; + criteria: IncentiveCriteria[]; + userEligible: boolean; + description?: string | null; + customMessage?: string | null; + customForumLink?: string | null; + customClaimMessage?: string | null; +}; + +export type MerklBorrowIncentive = { + __typename: 'MerklBorrowIncentive'; + id: RewardId; + startDate: string; + endDate: string; + borrowAprDiscount: PercentValue; + payoutToken: Currency; + criteria: IncentiveCriteria[]; + userEligible: boolean; + description?: string | null; + customMessage?: string | null; + customForumLink?: string | null; + customClaimMessage?: string | null; +}; + +export type SupplyPointsIncentive = { + __typename: 'SupplyPointsIncentive'; + id: RewardId; + program: PointsProgram; + name: string; + startDate: string; + endDate: string | null; + multiplier: number; + criteria: IncentiveCriteria[] | null; + userEligible: boolean; + dailyPoints?: number | null; + pointsPerThousandUsd?: number | null; + description?: string | null; + customMessage?: string | null; + customForumLink?: string | null; +}; + +export type BorrowPointsIncentive = { + __typename: 'BorrowPointsIncentive'; + id: RewardId; + program: PointsProgram; + name: string; + startDate: string; + endDate: string | null; + multiplier: number; + criteria: IncentiveCriteria[] | null; + userEligible: boolean; + dailyPoints?: number | null; + pointsPerThousandUsd?: number | null; + description?: string | null; + customMessage?: string | null; + customForumLink?: string | null; +}; + +export type ReserveIncentive = + | MeritSupplyIncentive + | MeritBorrowIncentive + | MeritBorrowAndSupplyIncentiveCondition + | AaveSupplyIncentive + | AaveBorrowIncentive + | MerklSupplyIncentive + | MerklBorrowIncentive + | SupplyPointsIncentive + | BorrowPointsIncentive; + +const RESERVE_INCENTIVES_QUERY = ` + query ReserveIncentives($request: ReserveRequest!) { + reserve(request: $request) { + incentives { + __typename + ... on MeritSupplyIncentive { + extraSupplyApr { formatted value } + claimLink + actionKey + rewardTokenAddress + rewardTokenSymbol + customMessage + customForumLink + selfApr { formatted value } + } + ... on MeritBorrowIncentive { + borrowAprDiscount { formatted value } + claimLink + actionKey + rewardTokenAddress + rewardTokenSymbol + customMessage + customForumLink + selfApr { formatted value } + } + ... on MeritBorrowAndSupplyIncentiveCondition { + extraApr { formatted value } + supplyToken { address chainId } + borrowToken { address chainId } + claimLink + actionKey + rewardTokenAddress + rewardTokenSymbol + customMessage + customForumLink + selfApr { formatted value } + } + ... on AaveSupplyIncentive { + extraSupplyApr { formatted value } + rewardTokenAddress + rewardTokenSymbol + } + ... on AaveBorrowIncentive { + borrowAprDiscount { formatted value } + rewardTokenAddress + rewardTokenSymbol + } + ... on MerklSupplyIncentive { + id + startDate + endDate + extraSupplyApr { formatted value } + payoutToken { address chainId symbol } + criteria { id text userPassed } + userEligible + description + customMessage + customForumLink + customClaimMessage + } + ... on MerklBorrowIncentive { + id + startDate + endDate + borrowAprDiscount { formatted value } + payoutToken { address chainId symbol } + criteria { id text userPassed } + userEligible + description + customMessage + customForumLink + customClaimMessage + } + ... on SupplyPointsIncentive { + id + program { id name externalUrl iconUrl } + name + startDate + endDate + multiplier + criteria { id text userPassed } + userEligible + dailyPoints + pointsPerThousandUsd + description + customMessage + customForumLink + } + ... on BorrowPointsIncentive { + id + program { id name externalUrl iconUrl } + name + startDate + endDate + multiplier + criteria { id text userPassed } + userEligible + dailyPoints + pointsPerThousandUsd + description + customMessage + customForumLink + } + } + } + } +`; + +type ReserveIncentivesResponse = { + data?: { + reserve: { incentives: ReserveIncentive[] } | null; + }; + errors?: Array<{ message: string }>; +}; + +export type UseReserveIncentivesArgs = { + /** V3 Pool address for the market (e.g. `0x87870bca...` for Aave V3 Ethereum Core). */ + market: string; + /** Underlying asset address. */ + underlying: string; + chainId: number; + /** Optional user address. When set, `userEligible` on each variant reflects + * the user's actual V3 positions. */ + user?: string; + enabled?: boolean; +}; + +/** + * Fetches the `ReserveIncentive` union for a specific reserve from the V3 + * backend. Returns an empty array if the reserve is not found or the backend + * fails — upstream display code should tolerate missing variants gracefully. + */ +export const useReserveIncentives = ({ + market, + underlying, + chainId, + user, + enabled = true, +}: UseReserveIncentivesArgs) => { + const marketAddress = resolveMarketAddress(market); + return useQuery({ + queryKey: ['reserveIncentives', chainId, marketAddress, underlying, user ?? null], + staleTime: 1000 * 60 * 5, + enabled: + enabled && Boolean(marketAddress && marketAddress.startsWith('0x') && underlying && chainId), + queryFn: async () => { + const response = await fetch(GRAPHQL_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: RESERVE_INCENTIVES_QUERY, + variables: { + request: { market: marketAddress, underlyingToken: underlying, chainId, user }, + }, + }), + }); + + if (!response.ok) { + throw new Error(`Reserve incentives query failed: ${response.status}`); + } + + const body = (await response.json()) as ReserveIncentivesResponse; + + if (body.errors?.length) { + throw new Error( + `Reserve incentives query returned errors: ${body.errors + .map((e) => e.message) + .join(', ')}` + ); + } + + return body.data?.reserve?.incentives ?? []; + }, + }); +}; diff --git a/src/hooks/useSonicIncentives.tsx b/src/hooks/useSonicIncentives.tsx index d5552c8b54..3f117d1752 100644 --- a/src/hooks/useSonicIncentives.tsx +++ b/src/hooks/useSonicIncentives.tsx @@ -1,13 +1,43 @@ -const getSonicData = (assetAddress: string): number | undefined => SONIC_DATA_MAP.get(assetAddress); +/** + * Sonic partner incentive adapter over the V3 backend. + * + * Same shape as `useEthenaIncentives`: legacy signature + * `useSonicIncentives(rewardedAsset)` where `rewardedAsset` is the aToken + * address. Resolves aToken → underlying internally via `useAppDataContext` + * and reads the `SupplyPointsIncentive` whose `program.name === "Sonic"`. + * Currently the V3 backend ships no Sonic POINTS program (the legacy + * `SONIC_DATA_MAP` was empty), so this hook returns `undefined` until BD + * adds one through the Slack admin path. + */ +import { useAppDataContext } from 'src/hooks/app-data-provider/useAppDataProvider'; +import { useRootStore } from 'src/store/root'; -const SONIC_DATA_MAP: Map = new Map([ - // No incentives at the moment -]); +import { useReserveIncentives } from './useReserveIncentives'; -export const useSonicIncentives = (rewardedAsset?: string) => { - if (!rewardedAsset) { - return undefined; - } +export const useSonicIncentives = (rewardedAsset?: string): number | undefined => { + const chainId = useRootStore((s) => s.currentChainId); + const currentMarket = useRootStore((s) => s.currentMarket); + const { supplyReserves } = useAppDataContext(); - return getSonicData(rewardedAsset); + const reserve = rewardedAsset + ? supplyReserves.find((r) => r.aToken.address.toLowerCase() === rewardedAsset.toLowerCase()) + : undefined; + const underlying = reserve?.underlyingToken.address; + const market = reserve?.market.address ?? currentMarket; + + const { data } = useReserveIncentives({ + market: market ?? '', + underlying: underlying ?? '', + chainId, + enabled: Boolean(market && underlying && chainId), + }); + + if (!data) return undefined; + + const sonic = data.find( + (i) => i.__typename === 'SupplyPointsIncentive' && i.program.name === 'Sonic' + ); + if (!sonic || sonic.__typename !== 'SupplyPointsIncentive') return undefined; + + return Number.isFinite(sonic.multiplier) ? sonic.multiplier : undefined; }; diff --git a/src/hooks/useStakeTokenAPR.ts b/src/hooks/useStakeTokenAPR.ts index f167a8f4be..2ba1a6df09 100644 --- a/src/hooks/useStakeTokenAPR.ts +++ b/src/hooks/useStakeTokenAPR.ts @@ -1,39 +1,64 @@ -import { useQuery } from '@tanstack/react-query'; +/** + * sGHO staking APR adapter over the V3 backend. + * + * The sGHO display APR is the same number Merit pays users who supply GHO on + * Aave V3 Ethereum (action `ethereum-sgho`). Previously this hook fetched + * `apps.aavechan.com/api/merit/aprs` directly; now it reads the MERIT + * incentive off `useReserveIncentives` for the GHO reserve on mainnet. + * + * Hardcoded addresses (mainnet GHO reserve) are intentional: this hook + * specifically drives the sGHO staking UI, not a generic reserve. + */ +import { AaveV3Ethereum } from '@aave-dao/aave-address-book'; -import { MeritAction } from './useMeritIncentives'; +import { useReserveIncentives } from './useReserveIncentives'; -type MeritIncentives = { - totalAPR: number; - actionsAPR: { - [key in MeritAction]: number | null | undefined; - }; -}; - -const url = 'https://apps.aavechan.com/api/merit/aprs'; +// ReserveRequest.market expects Pool address, not Address Provider +const MAINNET_V3_MARKET = AaveV3Ethereum.POOL; +const GHO_UNDERLYING = AaveV3Ethereum.ASSETS.GHO.UNDERLYING; +const MAINNET_CHAIN_ID = 1; +const ETHEREUM_SGHO_ACTION = 'ethereum-sgho'; export const useStakeTokenAPR = () => { - return useQuery({ - queryFn: async () => { - const response = await fetch(url); - const data = await response.json(); - const meritIncentives = data.currentAPR as MeritIncentives; + const query = useReserveIncentives({ + market: MAINNET_V3_MARKET, + underlying: GHO_UNDERLYING, + chainId: MAINNET_CHAIN_ID, + }); - return meritIncentives; - }, - queryKey: ['stakeTokenAPR'], - staleTime: 1000 * 60 * 5, // 5 minutes - select: (data) => { - // Get the sGHO staking APR - const stakeAPR = data.actionsAPR[MeritAction.ETHEREUM_SGHO]; - - if (!stakeAPR) { - return null; - } - - return { - apr: (stakeAPR / 100).toString(), // Convert percentage to decimal string - aprPercentage: stakeAPR, // Keep as percentage number - }; + if (!query.data) { + return { ...query, data: null as { apr: string; aprPercentage: number } | null }; + } + + // Prefer the MeritSupplyIncentive tagged with `ethereum-sgho` — matches + // the legacy `actionsAPR[ETHEREUM_SGHO]` read. Fall back to any GHO Merit + // supply incentive so this hook keeps returning a non-null APR on backend + // deployments that don't yet expose `actionKey` (there's only ever one + // Merit supply campaign on GHO mainnet). The field is read via cast until + // the backend-side query schema ships the enrichment. + const supplyMerits = query.data.filter( + (i): i is Extract => + i.__typename === 'MeritSupplyIncentive' + ); + const merit = + supplyMerits.find( + (i) => (i as unknown as { actionKey?: string | null }).actionKey === ETHEREUM_SGHO_ACTION + ) ?? supplyMerits[0]; + + if (!merit) { + return { ...query, data: null }; + } + + const stakeAPR = parseFloat(merit.extraSupplyApr.formatted); + if (!Number.isFinite(stakeAPR) || stakeAPR <= 0) { + return { ...query, data: null }; + } + + return { + ...query, + data: { + apr: (stakeAPR / 100).toString(), + aprPercentage: stakeAPR, }, - }); + }; }; diff --git a/src/hooks/useUserMeritIncentives.ts b/src/hooks/useUserMeritIncentives.ts deleted file mode 100644 index 0760a0fc43..0000000000 --- a/src/hooks/useUserMeritIncentives.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; -import { convertAprToApy } from 'src/utils/utils'; - -import { MeritAction } from './useMeritIncentives'; - -type UserMeritIncentives = { - totalAPR: number; - actionsAPR: { - [key in MeritAction]: number | null | undefined; - }; -}; - -type UserMeritIncentivesResponse = { - previousAPR: UserMeritIncentives | null; - currentAPR: UserMeritIncentives; -}; - -const url = 'https://apps.aavechan.com/api/merit/aprs'; - -export const useUserMeritIncentives = (userAddress?: string) => { - return useQuery({ - queryFn: async () => { - if (!userAddress) { - return null; - } - - const response = await fetch(`${url}?user=${userAddress}`); - const data: UserMeritIncentivesResponse = await response.json(); - - return data; - }, - queryKey: ['userMeritIncentives', userAddress], - staleTime: 1000 * 60 * 60, // 1 hour - enabled: !!userAddress, - select: (data) => { - if (!data) { - return null; - } - - // Convert all APR values to APY using monthly compounding - const convertedActionsAPY: Record = {}; - - Object.entries(data.currentAPR.actionsAPR).forEach(([action, apr]) => { - if (apr !== null && apr !== undefined) { - const aprDecimal = apr / 100; // Convert to decimal - const apy = convertAprToApy(aprDecimal); - convertedActionsAPY[action] = apy * 100; // Convert back to percentage - } - }); - - return { - ...data, - currentAPR: { - ...data.currentAPR, - actionsAPY: convertedActionsAPY, // Add converted APY values - }, - }; - }, - }); -}; diff --git a/src/hooks/useUserRewards.ts b/src/hooks/useUserRewards.ts new file mode 100644 index 0000000000..f8ebb7ed6d --- /dev/null +++ b/src/hooks/useUserRewards.ts @@ -0,0 +1,119 @@ +/** + * User-level Merkl claim data via the V3 backend's `userRewards` query. + * + * Canonical replacement for `useUserMeritIncentives` (which calls + * `apps.aavechan.com/api/merit/aprs` — that endpoint dies when ACI leaves). + * The backend consolidates Merkl-claimable amounts across legacy Merit, + * Aave-owned campaigns, and third-party partner programs, then builds the + * claim transaction against the Merkl distributor. + * + * The `rewardIds` filter on `UserRewardsFilter` scopes the claim to specific + * Aave-owned programs — identical in effect to V4's `claimRewards(ids)`. + */ +import { useQuery } from '@tanstack/react-query'; + +import type { RewardId } from './useReserveIncentives'; + +const DEFAULT_ENDPOINT = 'https://api.v3.staging.aave.com/graphql'; +const GRAPHQL_ENDPOINT = process.env.NEXT_PUBLIC_AAVE_V3_API_URL ?? DEFAULT_ENDPOINT; + +type TokenAmount = { + amount: { formatted: string; value: string }; + currency: { address: string; chainId: number }; + usd: string; +}; + +type Currency = { address: string; chainId: number }; + +type TransactionRequest = { + to: string; + from: string; + data: string; + value: string; + chainId: number; +}; + +export type ClaimableReward = { + __typename: 'ClaimableReward'; + currency: Currency; + amount: TokenAmount; +}; + +export type UserRewards = { + __typename: 'UserRewards'; + chain: number; + claimable: ClaimableReward[]; + transaction: TransactionRequest; +}; + +export type UserRewardsFilter = { + tokens?: string[]; + rewardIds?: RewardId[]; +}; + +const USER_REWARDS_QUERY = ` + query UserRewards($request: UserRewardsRequest!) { + userRewards(request: $request) { + chain + claimable { + currency { address chainId } + amount { + amount { formatted value } + currency { address chainId } + usd + } + } + transaction { + to + from + data + value + chainId + } + } + } +`; + +type UserRewardsResponse = { + data?: { userRewards: UserRewards | null }; + errors?: Array<{ message: string }>; +}; + +export type UseUserRewardsArgs = { + user: string; + chainId: number; + filter?: UserRewardsFilter; + enabled?: boolean; +}; + +export const useUserRewards = ({ user, chainId, filter, enabled = true }: UseUserRewardsArgs) => { + return useQuery({ + queryKey: ['userRewards', chainId, user, filter ?? null], + staleTime: 1000 * 30, + enabled: enabled && Boolean(user && chainId), + queryFn: async () => { + const response = await fetch(GRAPHQL_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: USER_REWARDS_QUERY, + variables: { request: { user, chainId, filter } }, + }), + }); + + if (!response.ok) { + throw new Error(`userRewards query failed: ${response.status}`); + } + + const body = (await response.json()) as UserRewardsResponse; + + if (body.errors?.length) { + throw new Error( + `userRewards query returned errors: ${body.errors.map((e) => e.message).join(', ')}` + ); + } + + return body.data?.userRewards ?? null; + }, + }); +}; diff --git a/src/locales/en/messages.po b/src/locales/en/messages.po index aed4bb8227..e5d051366c 100644 --- a/src/locales/en/messages.po +++ b/src/locales/en/messages.po @@ -3653,7 +3653,6 @@ msgstr "Selected supply assets" #: src/components/incentives/MerklIncentivesTooltipContent.tsx #: src/components/incentives/MerklIncentivesTooltipContent.tsx #: src/components/incentives/MerklIncentivesTooltipContent.tsx -#: src/components/incentives/MerklIncentivesTooltipContent.tsx #: src/modules/dashboard/lists/BorrowedPositionsList/BorrowedPositionsList.tsx #: src/modules/dashboard/lists/BorrowedPositionsList/BorrowedPositionsList.tsx #: src/modules/dashboard/lists/BorrowedPositionsList/BorrowedPositionsListItem.tsx diff --git a/src/modules/markets/Gho/GhoBanner.tsx b/src/modules/markets/Gho/GhoBanner.tsx index dc02720a90..281dc411ac 100644 --- a/src/modules/markets/Gho/GhoBanner.tsx +++ b/src/modules/markets/Gho/GhoBanner.tsx @@ -1,4 +1,4 @@ -import { Stake } from '@aave/contract-helpers'; +import { ProtocolAction, Stake } from '@aave/contract-helpers'; import { Trans } from '@lingui/macro'; import { Box, Button, Skeleton, Stack, Typography, useMediaQuery, useTheme } from '@mui/material'; import { FormattedNumber } from 'src/components/primitives/FormattedNumber'; @@ -22,6 +22,7 @@ export const SavingsGhoBanner = () => { const { data: meritIncentives, isLoading: meritIncentivesLoading } = useMeritIncentives({ symbol: GHO_SYMBOL, market: currentMarketData.market, + protocolAction: ProtocolAction.stake, }); const { data: stakeGeneralResult, isLoading: stakeDataLoading } = useGeneralStakeUiData( currentMarketData, @@ -178,6 +179,7 @@ const GhoSavingsBannerMobile = () => { const { data: meritIncentives, isLoading: meritIncentivesLoading } = useMeritIncentives({ symbol: GHO_SYMBOL, market: currentMarketData.market, + protocolAction: ProtocolAction.stake, }); const { data: stakeGeneralResult, isLoading: stakeDataLoading } = useGeneralStakeUiData( currentMarketData, diff --git a/src/modules/reserve-overview/Gho/SavingsGho.tsx b/src/modules/reserve-overview/Gho/SavingsGho.tsx index 22adb4ba23..4b40df53f9 100644 --- a/src/modules/reserve-overview/Gho/SavingsGho.tsx +++ b/src/modules/reserve-overview/Gho/SavingsGho.tsx @@ -1,4 +1,4 @@ -import { Stake } from '@aave/contract-helpers'; +import { ProtocolAction, Stake } from '@aave/contract-helpers'; import { Trans } from '@lingui/macro'; import { Box, Button, Divider, Skeleton, Stack, Typography } from '@mui/material'; import { BigNumber } from 'ethers'; @@ -30,8 +30,8 @@ export const SavingsGho = () => { const { data: meritIncentives } = useMeritIncentives({ symbol: 'GHO', market: currentMarketData.market, + protocolAction: ProtocolAction.stake, }); - const apr = meritIncentives?.incentiveAPR || '0'; const aprFormatted = (+apr * 100).toFixed(2); @@ -111,7 +111,11 @@ export const SavingsGho = () => { } > - + diff --git a/src/modules/sGho/SGhoDepositPanel.tsx b/src/modules/sGho/SGhoDepositPanel.tsx index 22b2616444..605d31743f 100644 --- a/src/modules/sGho/SGhoDepositPanel.tsx +++ b/src/modules/sGho/SGhoDepositPanel.tsx @@ -1,4 +1,4 @@ -import { ChainId, Stake } from '@aave/contract-helpers'; +import { ChainId, ProtocolAction, Stake } from '@aave/contract-helpers'; import { GetUserStakeUIDataHumanized } from '@aave/contract-helpers/dist/esm/V3-uiStakeDataProvider-contract/types'; import { TimeWindow } from '@aave/react'; import { RefreshIcon } from '@heroicons/react/outline'; @@ -243,7 +243,11 @@ export const SGHODepositPanel: React.FC = ({ Current APY - + {!xsm && +availableToStake > 0 && (