UNPKG

@moonwell-fi/moonwell-sdk

Version:

TypeScript Interface for Moonwell

502 lines (448 loc) 16.1 kB
/** * Lunar Indexer Response Transformation Utilities for Morpho Markets * * This module provides transformation functions to convert Lunar Indexer API responses * into SDK MorphoMarket types. The indexer returns string values that need to be converted * to proper numeric types and Amount objects. * * @module morpho/markets/lunarIndexerTransform */ import type { Address } from "viem"; import { parseUnits } from "viem"; import { Amount } from "../../../common/amount.js"; import type { Environment } from "../../../environments/index.js"; import type { MarketSnapshot } from "../../../types/market.js"; import type { MorphoMarket, MorphoMarketParamsType, PublicAllocatorSharedLiquidityType, } from "../../../types/morphoMarket.js"; import type { MorphoReward } from "../../../types/morphoReward.js"; import { getWithRetry } from "../../axiosWithRetry.js"; /** * Lunar Indexer API Response Types * These match the structure returned by the Lunar Indexer endpoints */ export type LunarIndexerToken = { address: string; name: string; symbol: string; decimals: number; }; export type LunarIndexerMarketReward = { token: string; tokenSymbol: string; tokenDecimals: number; tokenName: string; supplyApr: string; borrowApr: string; }; export type LunarIndexerMarket = { marketId: string; chainId: number; totalSupplyAssets: string; totalBorrowAssets: string; totalLiquidity: string; totalSupplyAssetsUsd: string; totalBorrowAssetsUsd: string; totalLiquidityUsd: string; loanTokenPrice: string; collateralTokenPrice: string; supplyApy: string; borrowApy: string; lltv: string; fee: string; oracle: string; irm: string; totalCollateralAssets?: string; totalCollateralAssetsUsd?: string; loanToken: LunarIndexerToken; collateralToken: LunarIndexerToken; rewards?: LunarIndexerMarketReward[]; }; export type LunarIndexerMarketsResponse = { results: LunarIndexerMarket[]; }; export type LunarIndexerMarketSnapshot = { id: string; chainId: number; marketId: string; timestamp: number; blockNumber: string; totalSupplyAssets: string; totalBorrowAssets: string; totalLiquidity: string; totalSupplyAssetsUsd: string; totalBorrowAssetsUsd: string; totalLiquidityUsd: string; totalReallocatableLiquidity?: string; totalReallocatableLiquidityUsd?: string; loanTokenPrice: string; collateralTokenPrice: string; supplyApy: string; borrowApy: string; lltv: string; fee: string; timeInterval: number; }; export type LunarIndexerMarketSnapshotsResponse = { results: LunarIndexerMarketSnapshot[]; nextCursor?: string; }; /** * Transform a Lunar Indexer isolated market snapshot to SDK MarketSnapshot format * * @param options.normalizeToCollateral - Set true for markets where totalSupplyAssets * is denominated in the loan token but the chart needs collateral-equivalent units * (e.g. the USDC/ETH market where loan = WETH and collateral = USDC: the indexer * returns supply in WETH units, but we need USDC units for the chart). */ export function transformIsolatedMarketSnapshotFromIndexer( snapshot: LunarIndexerMarketSnapshot, options?: { normalizeToCollateral?: boolean }, ): MarketSnapshot { const collateralTokenPrice = Number.parseFloat(snapshot.collateralTokenPrice); const loanTokenPrice = Number.parseFloat(snapshot.loanTokenPrice); const totalSupplyAssetsUsd = Number.parseFloat(snapshot.totalSupplyAssetsUsd); const totalLiquidityUsd = Number.parseFloat(snapshot.totalLiquidityUsd); const needsUsdNormalization = options?.normalizeToCollateral === true; const totalSupply = needsUsdNormalization && collateralTokenPrice > 0 ? totalSupplyAssetsUsd / collateralTokenPrice : Number.parseFloat(snapshot.totalSupplyAssets); const totalLiquidity = needsUsdNormalization && collateralTokenPrice > 0 ? totalLiquidityUsd / collateralTokenPrice : Number.parseFloat(snapshot.totalLiquidity); const totalReallocatableLiquidity = Number.parseFloat( snapshot.totalReallocatableLiquidity ?? "0", ); const totalReallocatableLiquidityUsd = Number.parseFloat( snapshot.totalReallocatableLiquidityUsd ?? "0", ); return { chainId: snapshot.chainId, marketId: snapshot.marketId.toLowerCase(), timestamp: snapshot.timestamp * 1000, totalSupply, totalSupplyUsd: totalSupplyAssetsUsd, totalBorrows: Number.parseFloat(snapshot.totalBorrowAssets), totalBorrowsUsd: Number.parseFloat(snapshot.totalBorrowAssetsUsd), totalLiquidity, totalLiquidityUsd, totalReallocatableLiquidity, totalReallocatableLiquidityUsd, baseSupplyApy: Number.parseFloat(snapshot.supplyApy), baseBorrowApy: Number.parseFloat(snapshot.borrowApy), loanTokenPrice, collateralTokenPrice, }; } export type LunarIndexerMarketPosition = { chainId: number; marketId: string; supplyShares: string; borrowShares: string; collateral: string; }; export type LunarIndexerAccountPortfolioPosition = { timestamp: number; markets: LunarIndexerMarketPosition[]; }; export type LunarIndexerAccountPortfolioResponse = { account: string; positions: LunarIndexerAccountPortfolioPosition[]; }; /** * Fetch markets from Lunar Indexer */ export async function fetchMarketsFromIndexer( lunarIndexerUrl: string, chainId: number, options?: { includeRewards?: boolean }, ): Promise<LunarIndexerMarketsResponse> { const params = new URLSearchParams(); if (options?.includeRewards) { params.set("includeRewards", "true"); } const queryString = params.toString(); const url = `${lunarIndexerUrl}/api/v1/isolated/markets/${chainId}${queryString ? `?${queryString}` : ""}`; const response = await getWithRetry<LunarIndexerMarketsResponse>(url); return response.data; } /** * Fetch a single market from Lunar Indexer */ export async function fetchMarketFromIndexer( lunarIndexerUrl: string, chainId: number, marketId: string, ): Promise<LunarIndexerMarket> { const url = `${lunarIndexerUrl}/api/v1/isolated/market/${chainId}/${marketId.toLowerCase()}`; const response = await getWithRetry<LunarIndexerMarket>(url); return response.data; } /** * Fetch market snapshots from Lunar Indexer */ export async function fetchMarketSnapshotsFromIndexer( lunarIndexerUrl: string, chainId: number, marketId: string, options?: { startTime?: number; endTime?: number; limit?: number; cursor?: string; granularity?: "1h" | "6h" | "1d"; }, ): Promise<LunarIndexerMarketSnapshotsResponse> { const params = new URLSearchParams(); if (options?.startTime !== undefined) { params.set("startTime", options.startTime.toString()); } if (options?.endTime !== undefined) { params.set("endTime", options.endTime.toString()); } if (options?.limit !== undefined) { params.set("limit", options.limit.toString()); } if (options?.cursor !== undefined) { params.set("cursor", options.cursor); } if (options?.granularity !== undefined) { params.set("granularity", options.granularity); } const queryString = params.toString(); const url = `${lunarIndexerUrl}/api/v1/isolated/market/${chainId}/${marketId.toLowerCase()}/snapshots${ queryString ? `?${queryString}` : "" }`; const response = await getWithRetry<LunarIndexerMarketSnapshotsResponse>(url); return response.data; } /** * Fetch account market portfolio from Lunar Indexer */ export async function fetchAccountMarketPortfolioFromIndexer( lunarIndexerUrl: string, accountAddress: string, options?: { chainId?: number; marketId?: string; startTime?: number; endTime?: number; granularity?: "1h" | "6h" | "1d"; }, ): Promise<LunarIndexerAccountPortfolioResponse> { const params = new URLSearchParams(); if (options?.chainId !== undefined) { params.set("chainId", options.chainId.toString()); } if (options?.marketId !== undefined) { params.set("marketId", options.marketId.toLowerCase()); } if (options?.startTime !== undefined) { params.set("startTime", options.startTime.toString()); } if (options?.endTime !== undefined) { params.set("endTime", options.endTime.toString()); } if (options?.granularity !== undefined) { params.set("granularity", options.granularity); } const queryString = params.toString(); const url = `${lunarIndexerUrl}/api/v1/isolated/account/${accountAddress.toLowerCase()}/portfolio${ queryString ? `?${queryString}` : "" }`; const response = await getWithRetry<LunarIndexerAccountPortfolioResponse>(url); return response.data; } /** * Helper type for rewards data keyed by market */ export type GetMorphoMarketsRewardsReturnType = { chainId: number; marketId: string; collateralAssets: Amount | null; collateralAssetsUsd: number | null; rewardsSupplyApy: number; rewardsBorrowApy: number; rewards: Required<MorphoReward>[]; }; /** * Transform a single market from Lunar Indexer format to SDK MorphoMarket type */ export function transformMarketFromIndexer( indexerMarket: LunarIndexerMarket, environment: Environment, rewardsData?: GetMorphoMarketsRewardsReturnType, sharedLiquidityData?: PublicAllocatorSharedLiquidityType[], collateralPriceOverride?: number, ): MorphoMarket | null { // Find the market configuration in the environment const marketKey = Object.keys(environment.config.morphoMarkets).find( (key) => environment.config.morphoMarkets[key].id.toLowerCase() === indexerMarket.marketId.toLowerCase(), ); if (!marketKey) { return null; } const marketConfig = environment.config.morphoMarkets[marketKey]; // Get token configs from environment const loanToken = environment.config.tokens[marketConfig.loanToken]; const collateralToken = environment.config.tokens[marketConfig.collateralToken]; // Parse prices from indexer const loanTokenPrice = Number.parseFloat(indexerMarket.loanTokenPrice); const collateralTokenPrice = collateralPriceOverride ?? Number.parseFloat(indexerMarket.collateralTokenPrice); // Calculate oracle price const lltv = Number.parseFloat(indexerMarket.lltv); const lltvBigInt = BigInt(Math.floor(lltv * 10 ** 16)); // Convert percentage to 18 decimal bigint // Use parseUnits (viem) to convert decimal strings to BigInt — avoids float64 // precision loss for large USDC amounts (e.g. "1026834782.838286" * 10^6). // parseUnits can throw on malformed strings (e.g. scientific notation), so we // wrap each call and fall back to 0n to avoid crashing the entire market list. const safeParseUnits = (value: string, decimals: number): bigint => { try { return parseUnits(value, decimals); } catch { return 0n; } }; const totalSupplyInLoanToken = new Amount( safeParseUnits(indexerMarket.totalSupplyAssets, loanToken.decimals), loanToken.decimals, ); const totalBorrows = new Amount( safeParseUnits(indexerMarket.totalBorrowAssets, loanToken.decimals), loanToken.decimals, ); const availableLiquidity = new Amount( safeParseUnits(indexerMarket.totalLiquidity, loanToken.decimals), loanToken.decimals, ); // Calculate oracle price for collateral conversion // Oracle price = collateralTokenPrice / loanTokenPrice with proper decimal adjustment const oraclePrice = loanTokenPrice > 0 ? collateralTokenPrice / loanTokenPrice : 0; // Calculate total supply in collateral token terms. // When oraclePrice = 0 (missing collateral price), return 0 rather than falling // back to oraclePrice=1 which would produce a meaningless loan-token-sized number. const totalSupply = new Amount( oraclePrice > 0 ? BigInt( Math.floor( (totalSupplyInLoanToken.value / oraclePrice) * 10 ** collateralToken.decimals, ), ) : 0n, collateralToken.decimals, ); // Parse APYs const baseSupplyApy = Number.parseFloat(indexerMarket.supplyApy); const baseBorrowApy = Number.parseFloat(indexerMarket.borrowApy); // Calculate USD values const totalSupplyUsd = Number.parseFloat(indexerMarket.totalSupplyAssetsUsd); const totalBorrowsUsd = Number.parseFloat(indexerMarket.totalBorrowAssetsUsd); const availableLiquidityUsd = Number.parseFloat( indexerMarket.totalLiquidityUsd, ); // Parse market params const marketParams: MorphoMarketParamsType = { loanToken: indexerMarket.loanToken.address as Address, collateralToken: indexerMarket.collateralToken.address as Address, oracle: indexerMarket.oracle as Address, irm: indexerMarket.irm as Address, lltv: lltvBigInt, }; // Find shared liquidity for this market const publicAllocatorSharedLiquidity = sharedLiquidityData || []; // Build the MorphoMarket object const market: MorphoMarket = { chainId: environment.chainId, marketId: indexerMarket.marketId, marketKey, deprecated: marketConfig.deprecated === true, loanToValue: lltv / 100, // Convert from percentage to decimal (94.5 -> 0.945) performanceFee: Number.parseFloat(indexerMarket.fee), loanToken, loanTokenPrice, collateralToken, collateralTokenPrice, collateralAssets: rewardsData?.collateralAssets ?? null, collateralAssetsUsd: rewardsData?.collateralAssetsUsd ?? null, totalSupply, totalSupplyUsd, totalSupplyInLoanToken, totalBorrows, totalBorrowsUsd, availableLiquidity, availableLiquidityUsd, marketParams, baseSupplyApy, baseBorrowApy, rewardsSupplyApy: rewardsData?.rewardsSupplyApy ?? 0, rewardsBorrowApy: rewardsData?.rewardsBorrowApy ?? 0, totalSupplyApr: baseSupplyApy + (rewardsData?.rewardsSupplyApy ?? 0), totalBorrowApr: baseBorrowApy + (rewardsData?.rewardsBorrowApy ?? 0), rewards: rewardsData?.rewards ?? [], publicAllocatorSharedLiquidity, }; return market; } /** * Transform multiple markets from Lunar Indexer format to SDK MorphoMarket types */ // stkWELL is not priced correctly by the indexer: its oracle is the same as WELL's oracle // and the two tokens trade at the same price. Override with the WELL price to avoid // displaying zero or incorrect collateral prices for stkWELL markets. export const PRICE_ALIAS: Record<string, string> = { stkWELL: "WELL", }; export function transformMarketsFromIndexer( indexerMarkets: LunarIndexerMarket[], environment: Environment, rewardsDataMap?: Map<string, GetMorphoMarketsRewardsReturnType>, sharedLiquidityMap?: Map<string, PublicAllocatorSharedLiquidityType[]>, ): MorphoMarket[] { // Build symbol → price map from markets that have a known price. const tokenPriceBySymbol = new Map<string, number>(); const setPriceWithCheck = (symbol: string, price: number) => { tokenPriceBySymbol.set(symbol, price); }; for (const m of indexerMarkets) { const loanPrice = Number.parseFloat(m.loanTokenPrice); const collateralPrice = Number.parseFloat(m.collateralTokenPrice); if (loanPrice > 0) setPriceWithCheck(m.loanToken.symbol, loanPrice); if (collateralPrice > 0) setPriceWithCheck(m.collateralToken.symbol, collateralPrice); } return indexerMarkets.flatMap((indexerMarket) => { const rewardKey = `${environment.chainId}-${indexerMarket.marketId.toLowerCase()}`; const rewardsData = rewardsDataMap?.get(rewardKey); const sharedLiquidityData = sharedLiquidityMap?.get(rewardKey); // Resolve price override for tokens that alias another token's price const collateralSymbol = indexerMarket.collateralToken.symbol; const collateralPrice = Number.parseFloat( indexerMarket.collateralTokenPrice, ); const aliasSymbol = PRICE_ALIAS[collateralSymbol]; const collateralPriceOverride = collateralPrice === 0 && aliasSymbol ? tokenPriceBySymbol.get(aliasSymbol) : undefined; const market = transformMarketFromIndexer( indexerMarket, environment, rewardsData, sharedLiquidityData, collateralPriceOverride, ); return market ? [market] : []; }); }