From ab900ed91a021c7df93410604b44087e9215d4e5 Mon Sep 17 00:00:00 2001 From: memoyil <2213635+memoyil@users.noreply.github.com> Date: Mon, 30 Dec 2024 20:16:59 +0300 Subject: [PATCH] feat: Support v2 pools merkl rewards (#11099) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ## PR-Codex overview This PR focuses on integrating the `Merkl` feature into the application, enhancing the `MerklSection` component, and updating API endpoints for fetching `Merkl` data. It also refines the logic for calculating APR and improves the structure of the `Merkl` configuration. ### Detailed summary - Updated `AprTooltipContent` to simplify the `expired` condition. - Enhanced `MerklSection` with additional props. - Integrated `MerklSection` into `PoolV2Page` and adjusted rendering in `liquidity/[tokenId].tsx`. - Removed unnecessary `expired` logic in `PoolAprButton`. - Modified `merklPools.json` to include new `Merkl` pool addresses. - Updated API endpoint from `angle.money` to `merkl.xyz` for fetching APR data. - Refined logic in `fetcher.ts` for retrieving `Merkl` APR. - Adjusted `useMerkl` hook to support new API structure and handle user rewards. - Enhanced error handling and data parsing in `useMerklInfo`. > ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}` --- .../web/src/components/Merkl/MerklSection.tsx | 3 +- apps/web/src/config/constants/merklPools.json | 25 +-- apps/web/src/hooks/useMerkl.tsx | 142 ++++++++++++------ apps/web/src/pages/liquidity/[tokenId].tsx | 37 ++--- .../web/src/pages/v2/pair/[[...currency]].tsx | 10 ++ .../state/farmsV4/state/poolApr/fetcher.ts | 25 ++- .../PoolAprButton/AprTooltipContent.tsx | 3 +- .../PoolAprButton/PoolAprButton.tsx | 10 +- scripts/updateMerkl/index.ts | 65 +++----- 9 files changed, 193 insertions(+), 127 deletions(-) diff --git a/apps/web/src/components/Merkl/MerklSection.tsx b/apps/web/src/components/Merkl/MerklSection.tsx index 5eabd9f5aa336..588a5c4ed7191 100644 --- a/apps/web/src/components/Merkl/MerklSection.tsx +++ b/apps/web/src/components/Merkl/MerklSection.tsx @@ -61,6 +61,7 @@ export function MerklSection({ }: { poolAddress?: `0x${string}` chainId?: ChainId + tokenId?: bigint notEnoughLiquidity: boolean outRange: boolean disabled: boolean @@ -74,7 +75,7 @@ export function MerklSection({ if (!rewardsPerToken.length || (!hasMerkl && rewardsPerToken.every((r) => r.equalTo('0')))) return null return ( - + {t('Merkl Rewards')} diff --git a/apps/web/src/config/constants/merklPools.json b/apps/web/src/config/constants/merklPools.json index 3ebae5e8b658b..b62305446b440 100644 --- a/apps/web/src/config/constants/merklPools.json +++ b/apps/web/src/config/constants/merklPools.json @@ -1,4 +1,19 @@ [ + { + "chainId": 56, + "address": "0xeD000AB362Ef11E962658Fc04c1A7D667a647213", + "link": "https://merkl.angle.money/bnb%20smart%20chain/pool/1/0xeD000AB362Ef11E962658Fc04c1A7D667a647213" + }, + { + "chainId": 56, + "address": "0xd5a79aB649E0a5F20e995026d034a0bF28B8aACa", + "link": "https://merkl.angle.money/bnb%20smart%20chain/pool/2/0xd5a79aB649E0a5F20e995026d034a0bF28B8aACa" + }, + { + "chainId": 42161, + "address": "0xE4BfcC208f3447cc5D2f5CCB40C52778d4bE2004", + "link": "https://merkl.angle.money/arbitrum/pool/2/0xE4BfcC208f3447cc5D2f5CCB40C52778d4bE2004" + }, { "chainId": 1, "address": "0x7B94A5622035207d3f527d236d47B7714Ee0acBa", @@ -9,19 +24,9 @@ "address": "0x6db0f81Db2C3B2A85a802d511577d8522D0D8C14", "link": "https://merkl.angle.money/ethereum/pool/2/0x6db0f81Db2C3B2A85a802d511577d8522D0D8C14" }, - { - "chainId": 56, - "address": "0xd5a79aB649E0a5F20e995026d034a0bF28B8aACa", - "link": "https://merkl.angle.money/bnb%20smart%20chain/pool/2/0xd5a79aB649E0a5F20e995026d034a0bF28B8aACa" - }, { "chainId": 56, "address": "0x9d84f1d12FdC6c977BF451e70689F45107b79b77", "link": "https://merkl.angle.money/bnb%20smart%20chain/pool/2/0x9d84f1d12FdC6c977BF451e70689F45107b79b77" - }, - { - "chainId": 42161, - "address": "0xE4BfcC208f3447cc5D2f5CCB40C52778d4bE2004", - "link": "https://merkl.angle.money/arbitrum/pool/2/0xE4BfcC208f3447cc5D2f5CCB40C52778d4bE2004" } ] diff --git a/apps/web/src/hooks/useMerkl.tsx b/apps/web/src/hooks/useMerkl.tsx index 48941ce3d6d5b..1d6e6eac846a9 100644 --- a/apps/web/src/hooks/useMerkl.tsx +++ b/apps/web/src/hooks/useMerkl.tsx @@ -10,7 +10,6 @@ import { DISTRIBUTOR_ADDRESSES } from 'config/merkl' import useAccountActiveChain from 'hooks/useAccountActiveChain' import { useCallWithGasPrice } from 'hooks/useCallWithGasPrice' import useCatchTxError from 'hooks/useCatchTxError' -import first from 'lodash/first' import uniq from 'lodash/uniq' import { useCallback, useMemo } from 'react' import { useAllLists } from 'state/lists/hooks' @@ -19,8 +18,9 @@ import { Address } from 'viem' import { useWalletClient } from 'wagmi' import { useMasterchefV3 } from 'hooks/useContract' import { isAddressEqual } from 'utils' +import { useCurrentBlockTimestamp as useBlockTimestamp } from 'state/block/hooks' -export const MERKL_API_V2 = 'https://api.angle.money/v2/merkl' +export const MERKL_API_V4 = 'https://api.merkl.xyz/v4' export function useMerklInfo(poolAddress?: string): { rewardsPerToken: CurrencyAmount[] @@ -36,33 +36,75 @@ export function useMerklInfo(poolAddress?: string): { merklApr?: number } { const { account, chainId } = useAccountActiveChain() + const currentTimestamp = useBlockTimestamp() const masterChefV3Address = useMasterchefV3()?.address as Address const lists = useAllLists() const { data, isPending, refetch } = useQuery({ - queryKey: [`fetchMerkl-${chainId}-${account || 'no-account'}`], + queryKey: [`fetchMerkl-${chainId}`], queryFn: async () => { - const responsev2 = await fetch( - `${MERKL_API_V2}?chainIds[]=${chainId}${account ? `&user=${account}` : ''}&AMMs[]=pancakeswapv3`, + if (!chainId) return undefined + + const responsev4 = await fetch( + `${MERKL_API_V4}/opportunities?chainId=${chainId}&test=false&items=1000&action=POOL,HOLD`, ) - if (!responsev2.ok) { - throw responsev2 + if (!responsev4.ok) { + throw responsev4 } - const merklDataV2 = await responsev2.json() + const merklDataV4 = await responsev4.json() + + const opportunities = merklDataV4?.filter( + (opportunity) => + opportunity?.tokens?.[0]?.symbol?.toLowerCase().startsWith('Cake-LP') || + opportunity?.protocol?.id?.toLowerCase().startsWith('pancakeswap'), + ) - if (!chainId || !merklDataV2[chainId]) return null + if (!opportunities || !opportunities.length) return undefined + + const pools = await Promise.all( + opportunities.map(async (opportunity) => { + const responseCampaignV4 = await fetch(`${MERKL_API_V4}/opportunities/${opportunity.id}/campaigns`) + if (!responseCampaignV4.ok) { + throw responseCampaignV4 + } + const campaignV4 = await responseCampaignV4.json() + return { ...opportunity, campaigns: campaignV4?.campaigns } + }), + ) - return merklDataV2[chainId] + return { pools } }, enabled: Boolean(chainId && poolAddress), staleTime: FAST_INTERVAL, retryDelay: (attemptIndex) => Math.min(2000 * 2 ** attemptIndex, 30000), }) + const { data: userData } = useQuery({ + queryKey: [`fetchMerkl-${chainId}-${account}`], + queryFn: async () => { + if (!chainId) return undefined + + const responsev4 = await fetch(`${MERKL_API_V4}/users/${account}/rewards?chainId=${chainId}`) + + if (!responsev4.ok) { + throw responsev4 + } + + const merklDataV4 = await responsev4.json() + + if (!merklDataV4) return undefined + + return merklDataV4?.[0] || {} + }, + enabled: Boolean(data && chainId && account && poolAddress), + staleTime: FAST_INTERVAL, + retryDelay: (attemptIndex) => Math.min(2000 * 2 ** attemptIndex, 30000), + }) + return useMemo(() => { - if (!data) + if (!data || !currentTimestamp) return { rewardsPerToken: [], rewardTokenAddresses: [], @@ -72,18 +114,19 @@ export function useMerklInfo(poolAddress?: string): { isPending, } - const { pools, transactionData } = data + const { pools } = data - const hasLive = Object.keys(pools) - .filter((poolId) => poolId === poolAddress) - .some((poolId) => { - const pool = pools[poolId] - const hasMeanAPR = pool.meanAPR > 0 + const hasLive = pools.some((pool) => { + const hasMeanAPR = pool.status === 'LIVE' && pool.apr > 0 - if (!hasMeanAPR) return false + if (!hasMeanAPR) return false - const hasLiveDistribution = pool.distributionData.some((distribution) => { - const { isLive, whitelist } = distribution + const hasLiveDistribution = Boolean( + pool.campaigns?.some((campaign) => { + const { startTimestamp, endTimestamp, whitelist, blacklist } = campaign + const startTimestampNumber = Number(startTimestamp) + const endTimestampNumber = Number(endTimestamp) + const isLive = startTimestampNumber <= currentTimestamp && currentTimestamp <= endTimestampNumber if (!isLive) return false const whitelistValid = !whitelist || @@ -91,34 +134,49 @@ export function useMerklInfo(poolAddress?: string): { whitelist.includes(account) || whitelist.includes(masterChefV3Address) - return whitelistValid - }) + const blacklistValid = + !blacklist || + blacklist.length === 0 || + !blacklist.includes(account) || + !blacklist.includes(masterChefV3Address) - return hasLiveDistribution - }) + return whitelistValid && blacklistValid + }), + ) + + return hasLiveDistribution + }) - const merklPoolData = first( - Object.keys(pools) - .filter((poolId) => poolId === poolAddress) - .map((poolId) => pools[poolId]), - ) + const rewardsPerTokenObject = userData?.rewards?.filter((reward) => { + const { amount, claimed } = reward || {} + const unclaimed = BigInt(amount || 0) - BigInt(claimed || 0) + return unclaimed > 0 + }) - const rewardsPerTokenObject = merklPoolData?.rewardsPerToken + const transactionData = rewardsPerTokenObject?.reduce((acc, reward) => { + // eslint-disable-next-line no-param-reassign + acc[reward?.token?.address] = { proof: reward?.proofs, claim: reward?.amount } + return acc + }, {}) const rewardResult = { hasMerkl: Boolean(hasLive), rewardsPerToken: rewardsPerTokenObject - ? Object.keys(rewardsPerTokenObject) - .map((tokenAddress) => { - const tokenInfo = rewardsPerTokenObject[tokenAddress] - - const token = new Token(chainId as number, tokenAddress as Address, tokenInfo.decimals, tokenInfo.symbol) - - return CurrencyAmount.fromRawAmount(token, tokenInfo.unclaimedUnformatted) + ? rewardsPerTokenObject + .map((tokenInfo) => { + const { + amount, + claimed, + token: { address, decimals, symbol }, + } = tokenInfo + + const token = new Token(chainId as number, address as Address, decimals, symbol) + const unclaimed = BigInt(amount || 0) - BigInt(claimed || 0) + return CurrencyAmount.fromRawAmount(token, unclaimed) }) .filter(Boolean) : [], - rewardTokenAddresses: uniq(merklPoolData?.distributionData?.map((d) => d.token)), + rewardTokenAddresses: uniq(rewardsPerTokenObject?.map((tokenInfo) => tokenInfo?.token?.address)), transactionData, isPending, } @@ -143,7 +201,7 @@ export function useMerklInfo(poolAddress?: string): { return CurrencyAmount.fromRawAmount(t, '0') }) - const merklApr = data?.[chainId ?? 0]?.pools?.[poolAddress ?? '']?.aprs?.['Average APR (rewards / pool TVL)'] as + const merklApr = data?.pools?.find((pool) => isAddressEqual(pool.identifier, poolAddress))?.apr as | number | undefined @@ -153,7 +211,7 @@ export function useMerklInfo(poolAddress?: string): { refreshData: refetch, merklApr, } - }, [chainId, data, lists, refetch, isPending, poolAddress, account, masterChefV3Address]) + }, [chainId, data, lists, refetch, isPending, poolAddress, account, masterChefV3Address, currentTimestamp, userData]) } export default function useMerkl(poolAddress?: string) { @@ -192,8 +250,8 @@ export default function useMerkl(poolAddress?: string) { }) .filter(Boolean) as string[] - const claims = tokens.map((txnData) => transactionData[txnData].claim) - const proofs = tokens.map((txnData) => transactionData[txnData].proof) + const claims = tokens.map((token) => transactionData[token].claim) + const proofs = tokens.map((token) => transactionData[token].proof) const receipt = await fetchWithCatchTxError(() => { return callWithGasPrice(distributorContract, 'claim', [ diff --git a/apps/web/src/pages/liquidity/[tokenId].tsx b/apps/web/src/pages/liquidity/[tokenId].tsx index 6107537502086..14dde942071c2 100644 --- a/apps/web/src/pages/liquidity/[tokenId].tsx +++ b/apps/web/src/pages/liquidity/[tokenId].tsx @@ -789,24 +789,25 @@ export default function PoolPage() { tickAtLimit={tickAtLimit} /> - - + + + {positionDetails && currency0 && currency1 && ( + + + { try { - if (!result[chainId] || !result[chainId].pools) return {} - return Object.keys(result[chainId].pools).reduce((acc, poolId) => { - const key = `${chainId}:${safeGetAddress(poolId)}` - if (!result[chainId].pools[poolId].aprs || !Object.keys(result[chainId].pools[poolId].aprs).length) return acc + const opportunities = result?.filter((opportunity) => opportunity?.chainId === chainId) + if (!opportunities || opportunities?.length === 0) return {} + return opportunities.reduce((acc, opportunity) => { + const key = `${chainId}:${safeGetAddress(opportunity.identifier)}` - const apr = result[chainId].pools[poolId].aprs?.['Average APR (rewards / pool TVL)'] ?? '0' // eslint-disable-next-line no-param-reassign - acc[key] = apr / 100 + acc[key] = (opportunity.apr ?? 0) / 100 return acc }, {} as MerklApr) } catch (error) { @@ -217,10 +216,20 @@ export const getMerklApr = async (result: any, chainId: number) => { } export const getAllNetworkMerklApr = async (signal?: AbortSignal) => { - const resp = await fetch(`https://api.angle.money/v2/merkl`, { signal }) + const resp = await fetch( + `https://api.merkl.xyz/v4/opportunities/?chainId=${supportedChainIdV4.join( + ',', + )}&test=false&status=LIVE&items=1000&action=POOL,HOLD`, + { signal }, + ) if (resp.ok) { const result = await resp.json() - const aprs = await Promise.all(supportedChainIdV4.map((chainId) => getMerklApr(result, chainId))) + const pancakeResult = result?.filter( + (opportunity) => + opportunity?.tokens?.[0].name?.toLowerCase().startsWith('pancake') || + opportunity?.protocol?.id?.toLowerCase().startsWith('pancakeswap'), + ) + const aprs = await Promise.all(supportedChainIdV4.map((chainId) => getMerklApr(pancakeResult, chainId))) return aprs.reduce((acc, apr) => Object.assign(acc, apr), {}) } throw resp diff --git a/apps/web/src/views/universalFarms/components/PoolAprButton/AprTooltipContent.tsx b/apps/web/src/views/universalFarms/components/PoolAprButton/AprTooltipContent.tsx index 9d1aa6926f1e2..638223ff38881 100644 --- a/apps/web/src/views/universalFarms/components/PoolAprButton/AprTooltipContent.tsx +++ b/apps/web/src/views/universalFarms/components/PoolAprButton/AprTooltipContent.tsx @@ -49,7 +49,6 @@ export const AprTooltipContent: React.FC { const { t } = useTranslation() @@ -77,7 +76,7 @@ export const AprTooltipContent: React.FC {t('LP Fee APR')}:  {displayApr(lpFeeApr)} - {merklApr && !expired ? ( + {merklApr ? ( {t('Merkl APR')}:  {displayApr(merklApr)} diff --git a/apps/web/src/views/universalFarms/components/PoolAprButton/PoolAprButton.tsx b/apps/web/src/views/universalFarms/components/PoolAprButton/PoolAprButton.tsx index e6a60b554f7c6..4a4a9cf532e59 100644 --- a/apps/web/src/views/universalFarms/components/PoolAprButton/PoolAprButton.tsx +++ b/apps/web/src/views/universalFarms/components/PoolAprButton/PoolAprButton.tsx @@ -20,15 +20,14 @@ type PoolGlobalAprButtonProps = { } export const PoolAprButton: React.FC = ({ pool, lpApr, cakeApr, merklApr, userPosition }) => { - const expired = cakeApr?.poolWeight?.isZero() const baseApr = useMemo(() => { - return sumApr(lpApr, cakeApr?.value, expired ? 0 : merklApr) - }, [lpApr, cakeApr?.value, expired, merklApr]) + return sumApr(lpApr, cakeApr?.value, merklApr) + }, [lpApr, cakeApr?.value, merklApr]) const boostApr = useMemo(() => { return typeof cakeApr?.boost !== 'undefined' && parseFloat(cakeApr.boost) > 0 - ? sumApr(lpApr, cakeApr?.boost, expired ? 0 : merklApr) + ? sumApr(lpApr, cakeApr?.boost, merklApr) : undefined - }, [cakeApr.boost, expired, lpApr, merklApr]) + }, [cakeApr.boost, lpApr, merklApr]) const hasBCake = pool.protocol === 'v2' || pool.protocol === 'stable' const merklLink = useMemo(() => { return getMerklLink({ chainId: pool.chainId, lpAddress: pool.lpAddress }) @@ -43,7 +42,6 @@ export const PoolAprButton: React.FC = ({ pool, lpApr, lpFeeApr={Number(lpApr) ?? 0} merklApr={Number(merklApr) ?? 0} merklLink={merklLink} - expired={cakeApr?.poolWeight?.isZero()} showDesc > {hasBCake ? : null} diff --git a/scripts/updateMerkl/index.ts b/scripts/updateMerkl/index.ts index 694ea30ed025c..71c95c2f229d3 100644 --- a/scripts/updateMerkl/index.ts +++ b/scripts/updateMerkl/index.ts @@ -9,29 +9,6 @@ type MerklConfigPool = { link: string } -type MerklPool = { - ammName: string - chainId: number - pool: `0x${string}` - token0: `0x${string}` - token1: `0x${string}` - symbolToken0: string - symbolToken1: string - aprs: Record -} - -type MerklConfig = { - merkleRoot: string - message: string - pools: { - [address: string]: MerklPool - } -} - -type MerklConfigResponse = { - [chainId: number]: MerklConfig -} - export const chainIdToChainName = { 1: 'ethereum', 56: 'bnb smart chain', @@ -42,35 +19,43 @@ export const chainIdToChainName = { 59144: 'linea', } as const -const fetchAllMerklConfig = async (): Promise => { - const response = await fetch('https://api.angle.money/v2/merkl') +const fetchAllMerklConfig = async (): Promise => { + const response = await fetch( + `https://api.merkl.xyz/v4/opportunities/?chainId=${Object.keys(chainIdToChainName).join( + ',', + )}&test=false&status=LIVE&items=1000&action=POOL,HOLD`, + ) if (!response.ok) { throw new Error('Unable to fetch merkl config') } - return response.json() as Promise + return response.json() as Promise } -const parseMerklConfig = (merklConfigResponse: MerklConfigResponse): MerklConfigPool[] => { - const pools = Object.entries(merklConfigResponse).reduce((acc, [chainId, config]) => { - const _pools = Object.values(config.pools) - .filter((pool) => Object.keys(pool.aprs).length > 1 && pool.ammName.toLowerCase().startsWith('pancakeswap')) - .map((pool) => ({ ...pool, chainId: Number(chainId) })) - return [...acc, ..._pools] - }, [] as MerklPool[]) - - return pools.map((pool) => ({ - chainId: pool.chainId, - address: pool.pool, - link: encodeURI(`https://merkl.angle.money/${chainIdToChainName[pool.chainId]}/pool/2/${pool.pool}`), - })) +const parseMerklConfig = (merklConfigResponse: any[]): MerklConfigPool[] => { + return merklConfigResponse + .filter( + (opportunity) => + (opportunity?.tokens?.[0].name?.toLowerCase().startsWith('pancake') || + opportunity?.protocol?.id?.toLowerCase().startsWith('pancakeswap')) && + opportunity?.apr > 0, + ) + .map((pool) => ({ + chainId: pool.chainId, + address: pool.identifier, + link: encodeURI( + `https://merkl.angle.money/${chainIdToChainName[pool.chainId]}/pool/${pool.type === 'ERC20' ? 1 : 2}/${ + pool.identifier + }`, + ), + })) } const run = async () => { console.info('Fetching merkl config...') const merklConfig = await fetchAllMerklConfig() - console.info('Fetched merkl config!', Object.keys(merklConfig).length) + console.info('Fetched merkl config!', merklConfig.length) console.info('Parsing merkl config...') const merklPools = parseMerklConfig(merklConfig) console.info('Writing merkl config...')