UNPKG

@moonwell-fi/moonwell-sdk

Version:

TypeScript Interface for Moonwell

394 lines (355 loc) 11.5 kB
import { type Address, getContract, parseAbi, zeroAddress } from "viem"; import { Amount } from "../../../common/amount.js"; import { MOONWELL_FETCH_JSON_HEADERS } from "../../../common/fetch-headers.js"; import { type Environment, type TokenConfig, publicEnvironments, } from "../../../environments/index.js"; import { findMarketByAddress, findTokenByAddress, } from "../../../environments/utils/index.js"; import type { MorphoUserReward } from "../../../types/morphoUserReward.js"; import type { MorphoUserStakingReward } from "../../../types/morphoUserStakingReward.js"; /** * Error thrown for any failure communicating with the Merkl API: non-ok HTTP * responses, network rejections (fetch threw), and response-body parse errors. * * - HTTP failures populate `status` and `statusText`. * - Network and parse failures leave `status`/`statusText` undefined and * carry the original error via `cause`. */ export class MerklApiError extends Error { readonly status: number | undefined; readonly statusText: string | undefined; readonly url: string; readonly chainId: number; constructor(params: { message: string; url: string; chainId: number; status?: number | undefined; statusText?: string | undefined; cause?: unknown; }) { super( params.message, params.cause !== undefined ? { cause: params.cause } : undefined, ); this.name = "MerklApiError"; this.url = params.url; this.chainId = params.chainId; this.status = params.status; this.statusText = params.statusText; } } export async function getUserMorphoRewardsData(params: { environment: Environment; account: `0x${string}`; throwOnExternalApiError?: boolean; }): Promise<MorphoUserReward[]> { // The Morpho URD distributions endpoint (rewards.morpho.org) was // deprecated and now 301-redirects to a SPA, so JSON parsing fails. // Surface only Merkl rewards. const merklRewards = await getMerklRewardsData( params.environment, params.account, { throwOnError: params.throwOnExternalApiError ?? false }, ); const isFullDeployment = params.environment.custom.morpho?.minimalDeployment === false; // For full deployments (Base), restrict to Moonwell vault campaigns so the // result excludes staking and other Moonwell campaigns; those are returned // by their own actions (e.g. getUserStakingInfo). On other chains, surface // every Merkl reward we get back. const vaultCampaignIds = isFullDeployment ? new Set<string>( (Object.values(publicEnvironments) as Environment[]).flatMap( (environment) => Object.values(environment.config.vaults ?? {}) .map((vault) => vault.campaignId) .filter((id): id is string => id !== undefined), ), ) : null; const sumBreakdowns = ( breakdowns: { campaignId: string; amount: string; claimed: string; pending: string; }[], field: "amount" | "claimed" | "pending", ): bigint => breakdowns.reduce( (acc, curr) => vaultCampaignIds === null || vaultCampaignIds.has(curr.campaignId) ? acc + BigInt(curr[field]) : acc, 0n, ); const merklResult: MorphoUserReward[] = []; for (const chainData of merklRewards) { for (const reward of chainData.rewards) { const rewardToken: TokenConfig = { address: reward.token.address as Address, decimals: reward.token.decimals, symbol: reward.token.symbol, name: reward.token.symbol, }; const amount = vaultCampaignIds ? sumBreakdowns(reward.breakdowns, "amount") : BigInt(reward.amount); const claimed = vaultCampaignIds ? sumBreakdowns(reward.breakdowns, "claimed") : BigInt(reward.claimed); const pending = vaultCampaignIds ? sumBreakdowns(reward.breakdowns, "pending") : BigInt(reward.pending); const claimableNow = new Amount(amount - claimed, rewardToken.decimals); const claimableNowUsd = claimableNow.value * (reward.token.price ?? 0); const claimableFuture = new Amount(pending, rewardToken.decimals); const claimableFutureUsd = claimableFuture.value * (reward.token.price ?? 0); merklResult.push({ type: "merkl-reward", chainId: chainData.chain.id, account: params.account, rewardToken, claimableNow, claimableNowUsd, claimableFuture, claimableFutureUsd, }); } } return merklResult; } export async function getUserMorphoStakingRewardsData(params: { environment: Environment; account: `0x${string}`; }): Promise<MorphoUserStakingReward[]> { const vaultsWithStaking = Object.values( params.environment.config.vaults, ).filter((vault) => Boolean(vault.multiReward)); if (!vaultsWithStaking.length) { return []; } // Hoist shared contract reads outside the per-vault loop const homeEnvironment = (Object.values(publicEnvironments) as Environment[]).find((e) => e.custom?.governance?.chainIds?.includes(params.environment.chainId), ) || params.environment; const viewsContract = params.environment.contracts.views; const homeViewsContract = homeEnvironment.contracts.views; const [allMarkets, nativeTokenPriceRaw, governanceTokenPriceRaw] = await Promise.all([ viewsContract?.read.getAllMarketsInfo(), homeViewsContract?.read.getNativeTokenPrice(), homeViewsContract?.read.getGovernanceTokenPrice(), ]); const governanceTokenPrice = new Amount(governanceTokenPriceRaw || 0n, 18); const nativeTokenPrice = new Amount(nativeTokenPriceRaw || 0n, 18); let tokenPrices = allMarkets ?.map((marketInfo) => { const marketFound = findMarketByAddress( params.environment, marketInfo.market, ); if (marketFound) { return { token: marketFound.underlyingToken, tokenPrice: new Amount( marketInfo.underlyingPrice, 36 - marketFound.underlyingToken.decimals, ), }; } else { return; } }) .filter((token) => !!token) || []; // Add governance token to token prices if (params.environment.custom?.governance?.token) { tokenPrices = [ ...tokenPrices, { token: params.environment.config.tokens[ params.environment.custom.governance.token ]!, tokenPrice: governanceTokenPrice, }, ]; } // Add native token to token prices tokenPrices = [ ...tokenPrices, { token: findTokenByAddress(params.environment, zeroAddress)!, tokenPrice: nativeTokenPrice, }, ]; const rewards = await Promise.all( vaultsWithStaking.map(async (vault) => { if (!vault.multiReward) return []; const vaultRewards = await getRewardsEarnedData( params.environment, params.account, vault.multiReward, ); return vaultRewards .filter((reward): reward is { amount: Amount; token: TokenConfig } => { return reward !== undefined && reward.amount.value > 0; }) .map((reward) => { const market = tokenPrices.find( (m) => m?.token.address === reward.token.address, ); const priceUsd = market?.tokenPrice.value ?? 0; return { ...reward, chainId: params.environment.chainId, amountUsd: reward.amount.value * priceUsd, }; }); }), ); return rewards.flat(); } const getRewardsEarnedData = async ( environment: Environment, userAddress: Address, multiRewardsAddress: Address, ) => { if (!environment.custom.multiRewarder) { return []; } const multiRewardAbi = parseAbi([ "function earned(address account, address token) view returns (uint256)", ]); const multiRewardContract = getContract({ address: multiRewardsAddress, abi: multiRewardAbi, client: environment.publicClient, }); const rewards = await Promise.all( environment.custom.multiRewarder.map(async (multiRewarder) => { const token = environment.config.tokens[multiRewarder.rewardToken]; if (!token) { return; } try { const earned = await multiRewardContract.read.earned([ userAddress, token.address, ]); return { amount: new Amount(BigInt(earned), token.decimals), token }; } catch { return { amount: new Amount(0n, token.decimals), token }; } }), ); return rewards.filter(Boolean); }; type MerklRewardsResponse = { chain: { id: number; name: string; icon: string; liveCampaigns: number; endOfDisputePeriod: number; Explorer: Array<{ id: string; type: string; url: string; chainId: number; }>; }; rewards: Array<{ root: string; recipient: string; amount: string; claimed: string; pending: string; proofs: string[]; token: { address: string; chainId: number; symbol: string; decimals: number; price: number; }; breakdowns: Array<{ reason: string; amount: string; claimed: string; pending: string; campaignId: string; subCampaignId?: string; }>; }>; }; async function getMerklRewardsData( environment: Environment, account: Address, options: { throwOnError: boolean } = { throwOnError: false }, ): Promise<MerklRewardsResponse[]> { const url = `https://api.merkl.xyz/v4/users/${account}/rewards?chainId=${environment.chainId}&test=false&breakdownPage=0&reloadChainId=${environment.chainId}`; let response: Response; try { // Merkl campaigns always distribute rewards on the same chain as the // opportunity, so environment.chainId is the only chain we need to query. // The previous two-phase approach (fetch opportunities per vault → extract // chain IDs → fetch rewards per chain) made N+1 HTTP calls to discover // a chain ID we already know. response = await fetch(url, { headers: MOONWELL_FETCH_JSON_HEADERS }); } catch (error) { if (options.throwOnError) { throw new MerklApiError({ message: `Merkl API network error for chain ${environment.chainId}`, url, chainId: environment.chainId, cause: error, }); } console.error( `[getMerklRewardsData:network] chain=${environment.chainId} url=${url}`, error, ); return []; } if (!response.ok) { const message = `Merkl API request failed for chain ${environment.chainId}: ${response.status} ${response.statusText}`; if (options.throwOnError) { throw new MerklApiError({ message, url, chainId: environment.chainId, status: response.status, statusText: response.statusText, }); } console.warn(`${message} (url=${url})`); return []; } try { return (await response.json()) as MerklRewardsResponse[]; } catch (error) { if (options.throwOnError) { throw new MerklApiError({ message: `Merkl API response parse error for chain ${environment.chainId}`, url, chainId: environment.chainId, cause: error, }); } console.error( `[getMerklRewardsData:parse] chain=${environment.chainId} url=${url}`, error, ); return []; } }