@moonwell-fi/moonwell-sdk
Version:
TypeScript Interface for Moonwell
784 lines (700 loc) • 24.3 kB
text/typescript
import { zeroAddress } from "viem";
import {
Amount,
DAYS_PER_YEAR,
calculateApy,
perDay,
} from "../../../common/index.js";
import { MOONWELL_FETCH_JSON_HEADERS } from "../../../common/fetch-headers.js";
import {
type Environment,
publicEnvironments,
} from "../../../environments/index.js";
import {
findMarketByAddress,
findTokenByAddress,
} from "../../../environments/utils/index.js";
import type { Market } from "../../../types/market.js";
export const getMarketsData = async (environment: Environment) => {
// Moonriver (chainId 1285) should always use on-chain data
const isMoonriver = environment.chainId === 1285;
if (environment.lunarIndexerUrl && !isMoonriver) {
try {
return await fetchMarketsFromLunar(environment);
} catch (error) {
console.warn(
`[getMarketsData] Lunar Indexer failed for chain ${environment.chainId}, falling back to on-chain:`,
error,
);
environment.onError?.(error, {
source: "markets",
chainId: environment.chainId,
});
}
}
const homeEnvironment =
(Object.values(publicEnvironments) as Environment[]).find((e) =>
e.custom?.governance?.chainIds?.includes(environment.chainId),
) || environment;
const viewsContract = environment.contracts.views;
const homeViewsContract = homeEnvironment.contracts.views;
const [
protocolInfoResult,
allMarketsInfoResult,
nativePriceResult,
govPriceResult,
] = await Promise.allSettled([
viewsContract?.read.getProtocolInfo(),
viewsContract?.read.getAllMarketsInfo(),
homeViewsContract?.read.getNativeTokenPrice(),
homeViewsContract?.read.getGovernanceTokenPrice(),
]);
// If getAllMarketsInfo failed (e.g. broken on-chain oracle), fall back to
// per-mToken RPC calls. This handles deprecated chains like Moonriver where
// the price oracle is non-functional but individual mToken data is readable.
if (allMarketsInfoResult.status === "rejected") {
console.debug(
"[mToken fallback] getAllMarketsInfo failed, using per-mToken fallback:",
allMarketsInfoResult.reason,
);
const seizePaused =
protocolInfoResult.status === "fulfilled" &&
protocolInfoResult.value !== undefined
? protocolInfoResult.value.seizePaused
: false;
const transferPaused =
protocolInfoResult.status === "fulfilled" &&
protocolInfoResult.value !== undefined
? protocolInfoResult.value.transferPaused
: false;
return await getMarketsFromMTokenFallback(
environment,
seizePaused,
transferPaused,
);
}
const { seizePaused, transferPaused } =
protocolInfoResult.status === "fulfilled" &&
protocolInfoResult.value !== undefined
? protocolInfoResult.value
: { seizePaused: false, transferPaused: false };
const allMarketsInfo = allMarketsInfoResult.value;
if (!allMarketsInfo) {
environment.onError?.(new Error("getAllMarketsInfo returned undefined"), {
source: "markets-onchain-missing-views",
chainId: environment.chainId,
});
return [];
}
const nativeTokenPriceRaw =
nativePriceResult.status === "fulfilled" &&
nativePriceResult.value !== undefined
? nativePriceResult.value
: 0n;
const governanceTokenPriceRaw =
govPriceResult.status === "fulfilled" && govPriceResult.value !== undefined
? govPriceResult.value
: 0n;
const governanceTokenPrice = new Amount(governanceTokenPriceRaw, 18);
const nativeTokenPrice = new Amount(nativeTokenPriceRaw, 18);
const markets: Market[] = [];
const tokenPrices = allMarketsInfo
.map((marketInfo) => {
const marketFound = findMarketByAddress(environment, marketInfo.market);
if (marketFound) {
return {
token: marketFound.underlyingToken,
tokenPrice: new Amount(
marketInfo.underlyingPrice,
36 - marketFound.underlyingToken.decimals,
),
};
} else {
return;
}
})
.filter((token) => !!token);
for (const marketInfo of allMarketsInfo) {
const marketFound = findMarketByAddress(environment, marketInfo.market);
if (marketFound) {
const { marketConfig, marketToken, underlyingToken, marketKey } =
marketFound;
let badDebt = new Amount(0n, underlyingToken.decimals);
if (marketConfig.badDebt === true) {
try {
const badDebtResult =
await environment.markets[marketKey]?.read.badDebt();
badDebt = new Amount(badDebtResult, underlyingToken.decimals);
} catch (error) {
// ignore
}
}
const supplyCaps = new Amount(
marketInfo.supplyCap,
underlyingToken.decimals,
);
const borrowCaps = new Amount(
marketInfo.borrowCap,
underlyingToken.decimals,
);
const collateralFactor = new Amount(marketInfo.collateralFactor, 18)
.value;
const underlyingPrice = new Amount(
marketInfo.underlyingPrice,
36 - underlyingToken.decimals,
).value;
const marketTotalSupply = new Amount(
marketInfo.totalSupply,
marketToken.decimals,
);
const totalBorrows = new Amount(
marketInfo.totalBorrows,
underlyingToken.decimals,
);
const totalReserves = new Amount(
marketInfo.totalReserves,
underlyingToken.decimals,
);
const cash = new Amount(marketInfo.cash, underlyingToken.decimals);
const exchangeRate = new Amount(
marketInfo.exchangeRate,
10 + underlyingToken.decimals,
).value;
const reserveFactor = new Amount(marketInfo.reserveFactor, 18).value;
const borrowRate = new Amount(marketInfo.borrowRate, 18);
const supplyRate = new Amount(marketInfo.supplyRate, 18);
const totalSupply = new Amount(
marketTotalSupply.value * exchangeRate,
underlyingToken.decimals,
);
const badDebtUsd = badDebt.value * underlyingPrice;
const totalSupplyUsd = totalSupply.value * underlyingPrice;
const totalBorrowsUsd = totalBorrows.value * underlyingPrice;
const totalReservesUsd = totalReserves.value * underlyingPrice;
const supplyCapsUsd = supplyCaps.value * underlyingPrice;
const borrowCapsUsd = borrowCaps.value * underlyingPrice;
const baseSupplyApy = calculateApy(supplyRate.value);
const baseBorrowApy = calculateApy(borrowRate.value);
const market: Market = {
marketKey,
chainId: environment.chainId,
seizePaused,
transferPaused,
mintPaused: marketInfo.mintPaused,
borrowPaused: marketInfo.borrowPaused,
deprecated: marketConfig.deprecated === true,
borrowCaps,
borrowCapsUsd,
cash,
collateralFactor,
exchangeRate,
marketToken,
reserveFactor,
supplyCaps,
supplyCapsUsd,
badDebt,
badDebtUsd,
totalBorrows,
totalBorrowsUsd,
totalReserves,
totalReservesUsd,
totalSupply,
totalSupplyUsd,
underlyingPrice,
underlyingToken,
baseBorrowApy,
baseSupplyApy,
totalBorrowApr: 0,
totalSupplyApr: 0,
rewards: [],
};
for (const incentive of marketInfo.incentives) {
let {
borrowIncentivesPerSec,
supplyIncentivesPerSec,
token: tokenAddress,
} = incentive;
const token = findTokenByAddress(environment, tokenAddress);
if (token) {
const isGovernanceToken =
token.symbol === environment.custom?.governance?.token;
const isNativeToken = token.address === zeroAddress;
const tokenPrice = tokenPrices.find(
(r) => r?.token.address === incentive.token,
)?.tokenPrice.value;
const price = isNativeToken
? nativeTokenPrice.value
: isGovernanceToken
? governanceTokenPrice.value
: tokenPrice;
if (price) {
// On-chain contracts use borrowIncentivesPerSec=1 (1 wei) as a
// placeholder when there are no active borrow incentives, because
// setting it to 0 triggers a known smart contract bug. Treat as zero.
if (borrowIncentivesPerSec === 1n) {
borrowIncentivesPerSec = 0n;
}
const supplyRewardsPerDayUsd =
perDay(new Amount(supplyIncentivesPerSec, token.decimals).value) *
price;
const borrowRewardsPerDayUsd =
perDay(new Amount(borrowIncentivesPerSec, token.decimals).value) *
price;
const supplyApr =
totalSupplyUsd === 0
? 0
: (supplyRewardsPerDayUsd / totalSupplyUsd) *
DAYS_PER_YEAR *
100;
// Negative: borrow reward APR reduces the effective borrowing cost
const borrowApr =
totalBorrowsUsd === 0
? 0
: (borrowRewardsPerDayUsd / totalBorrowsUsd) *
DAYS_PER_YEAR *
100 *
-1;
market.rewards.push({
liquidStakingApr: 0,
borrowApr,
supplyApr,
token,
});
}
}
}
market.totalSupplyApr = market.rewards.reduce(
(prev, curr) => prev + curr.supplyApr,
market.baseSupplyApy,
);
market.totalBorrowApr = market.rewards.reduce(
(prev, curr) => prev + curr.borrowApr,
market.baseBorrowApy,
);
markets.push(market);
}
}
return markets;
};
/**
* Fallback for chains whose on-chain price oracle is non-functional (e.g.
* deprecated Moonriver). Reads raw mToken contract data individually via
* Promise.allSettled so a single failed call does not abort the entire chain.
* All USD/price values are set to 0 since oracle prices are unavailable.
*/
async function getMarketsFromMTokenFallback(
environment: Environment,
seizePaused: boolean,
transferPaused: boolean,
): Promise<Market[]> {
const markets: Market[] = [];
for (const marketKey of Object.keys(environment.config.markets)) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const envAny = environment as any;
const marketConfig = envAny.config.markets[marketKey] as
| { underlyingToken: string; marketToken: string; deprecated?: boolean }
| undefined;
if (!marketConfig) continue;
const underlyingToken = envAny.config.tokens[
marketConfig.underlyingToken
] as
| {
address: `0x${string}`;
decimals: number;
symbol: string;
name: string;
}
| undefined;
const marketToken = envAny.config.tokens[marketConfig.marketToken] as
| {
address: `0x${string}`;
decimals: number;
symbol: string;
name: string;
}
| undefined;
if (!underlyingToken || !marketToken) continue;
const mTokenContract = envAny.markets[marketKey] as
| { read: Record<string, (...args: unknown[]) => Promise<bigint>> }
| undefined;
if (!mTokenContract) continue;
const [
totalSupplyResult,
totalBorrowsResult,
totalReservesResult,
cashResult,
exchangeRateResult,
supplyRateResult,
borrowRateResult,
reserveFactorResult,
] = await Promise.allSettled([
mTokenContract.read.totalSupply(),
mTokenContract.read.totalBorrows(),
mTokenContract.read.totalReserves(),
mTokenContract.read.getCash(),
mTokenContract.read.exchangeRateStored(),
mTokenContract.read.supplyRatePerTimestamp(),
mTokenContract.read.borrowRatePerTimestamp(),
mTokenContract.read.reserveFactorMantissa(),
]);
const totalSupplyRaw =
totalSupplyResult.status === "fulfilled" ? totalSupplyResult.value : 0n;
const totalBorrowsRaw =
totalBorrowsResult.status === "fulfilled" ? totalBorrowsResult.value : 0n;
const totalReservesRaw =
totalReservesResult.status === "fulfilled"
? totalReservesResult.value
: 0n;
const cashRaw = cashResult.status === "fulfilled" ? cashResult.value : 0n;
// Default exchange rate of 1.0: 10^(10 + underlyingDecimals) in raw form
const exchangeRateRaw =
exchangeRateResult.status === "fulfilled"
? exchangeRateResult.value
: 10n ** BigInt(10 + underlyingToken.decimals);
const supplyRateRaw =
supplyRateResult.status === "fulfilled" ? supplyRateResult.value : 0n;
const borrowRateRaw =
borrowRateResult.status === "fulfilled" ? borrowRateResult.value : 0n;
const reserveFactorRaw =
reserveFactorResult.status === "fulfilled"
? reserveFactorResult.value
: 0n;
const exchangeRate = new Amount(
exchangeRateRaw,
10 + underlyingToken.decimals,
).value;
const marketTotalSupply = new Amount(totalSupplyRaw, marketToken.decimals);
const totalSupply = new Amount(
marketTotalSupply.value * exchangeRate,
underlyingToken.decimals,
);
const totalBorrows = new Amount(totalBorrowsRaw, underlyingToken.decimals);
const totalReserves = new Amount(
totalReservesRaw,
underlyingToken.decimals,
);
const cash = new Amount(cashRaw, underlyingToken.decimals);
const supplyRate = new Amount(supplyRateRaw, 18);
const borrowRate = new Amount(borrowRateRaw, 18);
const reserveFactor = new Amount(reserveFactorRaw, 18).value;
const baseSupplyApy = calculateApy(supplyRate.value);
const baseBorrowApy = calculateApy(borrowRate.value);
const market: Market = {
marketKey,
chainId: environment.chainId,
seizePaused,
transferPaused,
// Oracle is non-functional so supply/borrow would fail on-chain; mark paused
mintPaused: true,
borrowPaused: true,
deprecated: marketConfig.deprecated === true,
borrowCaps: new Amount(0n, underlyingToken.decimals),
borrowCapsUsd: 0,
cash,
collateralFactor: 0,
exchangeRate,
marketToken,
reserveFactor,
supplyCaps: new Amount(0n, underlyingToken.decimals),
supplyCapsUsd: 0,
badDebt: new Amount(0n, underlyingToken.decimals),
badDebtUsd: 0,
totalBorrows,
totalBorrowsUsd: 0,
totalReserves,
totalReservesUsd: 0,
totalSupply,
totalSupplyUsd: 0,
underlyingPrice: 0,
underlyingToken,
baseBorrowApy,
baseSupplyApy,
totalBorrowApr: baseBorrowApy,
totalSupplyApr: baseSupplyApy,
rewards: [],
};
markets.push(market);
}
return markets;
}
/**
* Fetch markets data from Lunar Indexer (hybrid approach)
*
* Uses Lunar for core market data and conditionally:
* - If Lunar provides priceUsd/supplyApr/borrowApr in incentives: use those (NO RPC calls)
* - If Lunar fields are null: fetch governance/native token prices for reward APR calculations (RPC calls)
* - Always fetch liquid staking APRs from external APIs
*/
async function fetchMarketsFromLunar(
environment: Environment,
): Promise<Market[]> {
if (!environment.lunarIndexerUrl) {
throw new Error("Lunar Indexer URL not configured");
}
// Import client dynamically to avoid circular dependencies
const { createLunarIndexerClient, DEFAULT_LUNAR_TIMEOUT_MS } = await import(
"../../lunar-indexer-client.js"
);
const client = createLunarIndexerClient({
baseUrl: environment.lunarIndexerUrl,
timeout: DEFAULT_LUNAR_TIMEOUT_MS,
});
const lunarMarketsResponse = await client.listMarkets(environment.chainId);
const lunarMarkets = lunarMarketsResponse.results;
const needsRpcPrices = lunarMarkets.some((market) =>
market.incentives.some(
(incentive) =>
incentive.priceUsd === null ||
incentive.supplyApr === null ||
incentive.borrowApr === null,
),
);
let governanceTokenPrice: Amount | undefined;
let nativeTokenPrice: Amount | undefined;
if (needsRpcPrices) {
const homeEnvironment =
(Object.values(publicEnvironments) as Environment[]).find((e) =>
e.custom?.governance?.chainIds?.includes(environment.chainId),
) || environment;
const [nativeTokenPriceRaw, governanceTokenPriceRaw] = await Promise.all([
homeEnvironment.contracts.views?.read.getNativeTokenPrice(),
homeEnvironment.contracts.views?.read.getGovernanceTokenPrice(),
]);
if (!nativeTokenPriceRaw || !governanceTokenPriceRaw) {
throw new Error(
"Failed to fetch native or governance token prices from home chain",
);
}
governanceTokenPrice = new Amount(governanceTokenPriceRaw, 18);
nativeTokenPrice = new Amount(nativeTokenPriceRaw, 18);
}
const markets: Market[] = [];
for (const lunarMarket of lunarMarkets) {
const marketFound = findMarketByAddress(
environment,
lunarMarket.address as `0x${string}`,
);
if (!marketFound) {
continue;
}
const { marketConfig, marketToken, underlyingToken, marketKey } =
marketFound;
// Transform Lunar decimal numbers to SDK Amount types
// Note: Number() wrapping is defensive — the Lunar API may return numeric
// fields as strings, which would break BigInt conversion via Math.floor.
const totalSupply = new Amount(
BigInt(
Math.floor(
Number(lunarMarket.totalSupply) * 10 ** underlyingToken.decimals,
),
),
underlyingToken.decimals,
);
const totalBorrows = new Amount(
BigInt(
Math.floor(
Number(lunarMarket.totalBorrows) * 10 ** underlyingToken.decimals,
),
),
underlyingToken.decimals,
);
const totalReserves = new Amount(
BigInt(
Math.floor(
Number(lunarMarket.totalReserves) * 10 ** underlyingToken.decimals,
),
),
underlyingToken.decimals,
);
const cash = new Amount(
BigInt(
Math.floor(Number(lunarMarket.cash) * 10 ** underlyingToken.decimals),
),
underlyingToken.decimals,
);
const badDebt = new Amount(
BigInt(
Math.floor(
Number(lunarMarket.badDebt) * 10 ** underlyingToken.decimals,
),
),
underlyingToken.decimals,
);
const supplyCaps = new Amount(
BigInt(
Math.floor(
Number(lunarMarket.supplyCap) * 10 ** underlyingToken.decimals,
),
),
underlyingToken.decimals,
);
const borrowCaps = new Amount(
BigInt(
Math.floor(
Number(lunarMarket.borrowCap) * 10 ** underlyingToken.decimals,
),
),
underlyingToken.decimals,
);
// Lunar provides reserveFactor as wei string, convert to decimal
const reserveFactor = new Amount(BigInt(lunarMarket.reserveFactor), 18)
.value;
const market: Market = {
marketKey,
chainId: environment.chainId,
seizePaused: lunarMarket.seizePaused,
transferPaused: lunarMarket.transferPaused,
mintPaused: lunarMarket.mintPaused,
borrowPaused: lunarMarket.borrowPaused,
deprecated: marketConfig.deprecated === true,
borrowCaps,
borrowCapsUsd:
Number(lunarMarket.borrowCap) * Number(lunarMarket.priceUsd),
cash,
collateralFactor: Number(lunarMarket.collateralFactor),
exchangeRate: Number(lunarMarket.exchangeRate),
marketToken,
reserveFactor,
supplyCaps,
supplyCapsUsd:
Number(lunarMarket.supplyCap) * Number(lunarMarket.priceUsd),
badDebt,
badDebtUsd: Number(lunarMarket.badDebtUsd),
totalBorrows,
totalBorrowsUsd: Number(lunarMarket.totalBorrowsUsd),
totalReserves,
totalReservesUsd: Number(lunarMarket.totalReservesUsd),
totalSupply,
totalSupplyUsd: Number(lunarMarket.totalSupplyUsd),
underlyingPrice: Number(lunarMarket.priceUsd),
underlyingToken,
baseBorrowApy: Number(lunarMarket.baseBorrowApy),
baseSupplyApy: Number(lunarMarket.baseSupplyApy),
totalBorrowApr: 0,
totalSupplyApr: 0,
rewards: [],
};
for (const incentive of lunarMarket.incentives) {
const token = findTokenByAddress(
environment,
incentive.token as `0x${string}`,
);
if (!token) {
continue;
}
let supplyApr: number;
let borrowApr: number;
// On-chain contracts use borrowIncentivesPerSec=1 (1 wei) as a
// placeholder when there are no active borrow incentives, because
// setting it to 0 triggers a known smart contract bug. Treat as zero.
const isBorrowPlaceholder =
BigInt(incentive.borrowIncentivesPerSec) === 1n;
if (
incentive.priceUsd !== null &&
incentive.supplyApr !== null &&
incentive.borrowApr !== null
) {
supplyApr = Number(incentive.supplyApr);
borrowApr = isBorrowPlaceholder ? 0 : -Number(incentive.borrowApr);
} else {
const isGovernanceToken =
token.symbol === environment.custom?.governance?.token;
const isNativeToken = token.address === zeroAddress;
const price = isNativeToken
? nativeTokenPrice?.value
: isGovernanceToken
? governanceTokenPrice?.value
: undefined;
if (!price) {
continue;
}
const borrowIncentivesPerSec = isBorrowPlaceholder
? 0n
: BigInt(incentive.borrowIncentivesPerSec);
const supplyIncentivesPerSec = BigInt(incentive.supplyIncentivesPerSec);
const supplyRewardsPerDayUsd =
perDay(new Amount(supplyIncentivesPerSec, token.decimals).value) *
price;
const borrowRewardsPerDayUsd =
perDay(new Amount(borrowIncentivesPerSec, token.decimals).value) *
price;
supplyApr =
Number(lunarMarket.totalSupplyUsd) === 0
? 0
: (supplyRewardsPerDayUsd / Number(lunarMarket.totalSupplyUsd)) *
DAYS_PER_YEAR *
100;
// Negative: borrow reward APR reduces the effective borrowing cost
borrowApr =
Number(lunarMarket.totalBorrowsUsd) === 0
? 0
: (borrowRewardsPerDayUsd / Number(lunarMarket.totalBorrowsUsd)) *
DAYS_PER_YEAR *
100 *
-1;
}
market.rewards.push({
liquidStakingApr: 0,
borrowApr,
supplyApr,
token,
});
}
market.totalSupplyApr = market.rewards.reduce(
(prev, curr) => prev + curr.supplyApr,
market.baseSupplyApy,
);
market.totalBorrowApr = market.rewards.reduce(
(prev, curr) => prev + curr.borrowApr,
market.baseBorrowApy,
);
markets.push(market);
}
return markets;
}
const fetchFromGenericCacheApi = async <T>(uri: string): Promise<T> => {
const response = await fetch(
"https://generic-api-cache.moonwell.workers.dev/",
{
method: "POST",
body: `{"uri":"${uri}","cacheDuration":"300"}`,
headers: {
...MOONWELL_FETCH_JSON_HEADERS,
"Content-Type": "text/plain",
},
},
);
return response.json() as T;
};
export const fetchLiquidStakingRewards = async () => {
const result = {
cbETH: 0,
rETH: 0,
wstETH: 0,
};
try {
const cbETH = await fetchFromGenericCacheApi<{ apy: string }>(
"https://api.exchange.coinbase.com/wrapped-assets/CBETH",
);
result.cbETH = Number(cbETH.apy) * 100;
} catch (error) {
result.cbETH = 0;
}
try {
const rETH = await fetchFromGenericCacheApi<{ rethAPR: string }>(
"https://rocketpool.net/api/mainnet/payload",
);
result.rETH = Number(rETH.rethAPR);
} catch (error) {
result.rETH = 0;
}
try {
const stETH = await fetchFromGenericCacheApi<{ data: { apr: number } }>(
"https://eth-api.lido.fi/v1/protocol/steth/apr/last",
);
result.wstETH = stETH.data.apr;
} catch (error) {
result.wstETH = 0;
}
return result;
};