@moonwell-fi/moonwell-sdk
Version:
TypeScript Interface for Moonwell
350 lines • 16.1 kB
JavaScript
import { applyGranularity, calculateTimeRange, getEnvironmentFromArgs, isStartOfDay, toApiGranularity, } from "../../../common/index.js";
import { buildMarketId } from "../../../common/lunar-indexer-helpers.js";
import { postWithRetry } from "../../axiosWithRetry.js";
import { DEFAULT_LUNAR_TIMEOUT_MS, createLunarIndexerClient, } from "../../lunar-indexer-client.js";
import { transformMarketSnapshots } from "../../lunar-indexer-transformers.js";
import { fetchMarketSnapshotsFromIndexer, transformIsolatedMarketSnapshotFromIndexer, } from "../../morpho/markets/lunarIndexerTransform.js";
/**
* Remove snapshots from before the market's first recorded activity.
* Exported for testing.
* When a requested time range predates the market's deployment, the indexer
* returns zero-value records for those early dates. This trims everything
* before the earliest snapshot that has any supply or borrow activity.
*/
export function trimLeadingEmptySnapshots(snapshots) {
let firstActiveTimestamp = Number.POSITIVE_INFINITY;
for (const s of snapshots) {
if ((s.totalSupply > 0 || s.totalBorrows > 0) &&
s.timestamp < firstActiveTimestamp) {
firstActiveTimestamp = s.timestamp;
}
}
if (firstActiveTimestamp === Number.POSITIVE_INFINITY)
return [];
return snapshots.filter((s) => s.timestamp >= firstActiveTimestamp);
}
export async function getMarketSnapshots(client, args) {
const environment = getEnvironmentFromArgs(client, args);
if (!environment) {
return [];
}
if (args?.type === "core") {
const snapshots = await fetchCoreMarketSnapshots(args.marketId, environment, args.period, args.startTime, args.endTime);
return trimLeadingEmptySnapshots(snapshots);
}
const snapshots = await fetchIsolatedMarketSnapshots(args.marketId, environment, args.period, args.startTime, args.endTime);
// Apply a final sanity check to ensure totalSupply is always in loan token units.
//
// Some markets (e.g. USDC/ETH where collateral=USDC, loan=WETH) normalize
// totalSupply to collateral units — skip the check for those because
// loanTokenPrice (~$2000) vs implied price (~$1) would incorrectly trigger.
const marketConfig = Object.values(environment.config.morphoMarkets).find((m) => m.id.toLowerCase() === args.marketId.toLowerCase());
const loanSymbol = marketConfig
? environment.config.tokens[marketConfig.loanToken]?.symbol
: undefined;
const collateralSymbol = marketConfig
? environment.config.tokens[marketConfig.collateralToken]?.symbol
: undefined;
// Markets where totalSupply is already in collateral units after the inner
// conversion — skip the outer USDC-unit sanity check for these.
// • USDC/ETH: normalizeToCollateral flips supply to USDC units (collateral)
// • stkWELL/USDC: isStkWellMarket block converts USDC supply → stkWELL units
const isNormalizedMarket = (loanSymbol === "ETH" && collateralSymbol === "USDC") ||
collateralSymbol === "stkWELL";
const result = isNormalizedMarket
? snapshots
: snapshots.map((snapshot) => {
if (snapshot.totalSupply === 0 || snapshot.loanTokenPrice === 0)
return snapshot;
const impliedLoanPrice = snapshot.totalSupplyUsd / snapshot.totalSupply;
if (impliedLoanPrice < snapshot.loanTokenPrice * 0.1) {
return {
...snapshot,
totalSupply: snapshot.totalSupplyUsd / snapshot.loanTokenPrice,
totalLiquidity: snapshot.totalLiquidityUsd / snapshot.loanTokenPrice,
};
}
return snapshot;
});
return trimLeadingEmptySnapshots(result);
}
async function fetchCoreMarketSnapshots(marketAddress, environment, period, startTime, endTime) {
if (!environment.lunarIndexerUrl) {
if (!environment.indexerUrl)
return [];
try {
return await fetchCoreMarketSnapshotsFromPonder(marketAddress, environment);
}
catch (error) {
console.warn(`[getMarketSnapshots] Ponder failed for chain ${environment.chainId}:`, error);
environment.onError?.(error, {
source: "market-snapshots-ponder",
chainId: environment.chainId,
});
return [];
}
}
try {
return await fetchCoreMarketSnapshotsFromLunar(marketAddress, environment, period, startTime, endTime);
}
catch (error) {
console.warn(`[getMarketSnapshots] Lunar Indexer failed for chain ${environment.chainId}:`, error);
environment.onError?.(error, {
source: "market-snapshots",
chainId: environment.chainId,
});
return [];
}
}
async function fetchCoreMarketSnapshotsFromPonder(marketAddress, environment) {
if (!environment.indexerUrl)
return [];
const dailyData = [];
let hasNextPage = true;
let endCursor;
while (hasNextPage) {
const result = await postWithRetry(environment.indexerUrl, {
query: `
query {
marketDailySnapshots (
limit: 1000,
orderBy: "timestamp"
orderDirection: "desc"
where: {marketAddress: "${marketAddress.toLowerCase()}", chainId: ${environment.chainId}}
${endCursor ? `after: "${endCursor}"` : ""}
) {
items {
totalBorrows
totalBorrowsUSD
totalSupplies
totalSuppliesUSD
totalLiquidity
totalLiquidityUSD
baseSupplyApy
baseBorrowApy
timestamp
}
pageInfo {
hasNextPage
endCursor
}
}
}
`,
});
dailyData.push(...result.data.data.marketDailySnapshots.items.filter((f) => isStartOfDay(f.timestamp)));
hasNextPage = result.data.data.marketDailySnapshots.pageInfo.hasNextPage;
endCursor = result.data.data.marketDailySnapshots.pageInfo.endCursor;
}
if (dailyData.length === 0)
return [];
return dailyData.map((point) => {
const supplied = Number(point.totalSupplies);
const borrow = Number(point.totalBorrows);
const borrowUsd = Number(point.totalBorrowsUSD);
const suppliedUsd = Number(point.totalSuppliesUSD);
const liquidity = Math.max(point.totalLiquidity, 0);
const liquidityUsd = Math.max(point.totalLiquidityUSD, 0);
const price = suppliedUsd / supplied;
return {
marketId: marketAddress.toLowerCase(),
chainId: environment.chainId,
timestamp: point.timestamp * 1000,
totalSupply: supplied,
totalSupplyUsd: suppliedUsd,
totalBorrows: borrow,
totalBorrowsUsd: borrowUsd,
totalLiquidity: liquidity,
totalLiquidityUsd: liquidityUsd,
totalReallocatableLiquidity: 0,
totalReallocatableLiquidityUsd: 0,
baseSupplyApy: point.baseSupplyApy,
baseBorrowApy: point.baseBorrowApy,
collateralTokenPrice: price,
loanTokenPrice: price,
};
});
}
async function fetchCoreMarketSnapshotsFromLunar(marketAddress, environment, period, customStartTime, customEndTime) {
if (!environment.lunarIndexerUrl) {
throw new Error("Lunar Indexer URL not configured");
}
const client = createLunarIndexerClient({
baseUrl: environment.lunarIndexerUrl,
timeout: DEFAULT_LUNAR_TIMEOUT_MS,
});
const marketId = buildMarketId(environment.chainId, marketAddress);
const { startTime, endTime, granularity } = calculateTimeRange(period, customStartTime, customEndTime);
const allSnapshots = [];
let cursor = null;
const MAX_PAGES = 100;
let page = 0;
do {
const response = await client.getMarketSnapshots(marketId, {
limit: 1000,
...(cursor && { cursor }),
granularity: toApiGranularity(granularity),
startTime,
endTime,
});
const transformed = transformMarketSnapshots(response.results, environment.chainId);
allSnapshots.push(...transformed);
cursor = response.nextCursor;
page++;
} while (cursor !== null && page < MAX_PAGES);
allSnapshots.sort((a, b) => a.timestamp - b.timestamp);
return applyGranularity(allSnapshots, granularity).map((snapshot) => {
const supplied = snapshot.totalSupply;
const suppliedUsd = snapshot.totalSupplyUsd;
const price = supplied > 0 ? suppliedUsd / supplied : 0;
return {
...snapshot,
collateralTokenPrice: price,
loanTokenPrice: price,
};
});
}
// Fetches a timestamp-ms → WELL price map from the WELL market in the indexer.
// Used to correct stkWELL snapshots where the indexer stored collateralTokenPrice = 0.
async function fetchWellPricesByTimestamp(lunarIndexerUrl, chainId, wellMarketConfig, startTime) {
const wellIsCollateral = wellMarketConfig.collateralToken === "WELL";
const priceMap = new Map();
let cursor;
const MAX_PAGES = 100;
let page = 0;
do {
const response = await fetchMarketSnapshotsFromIndexer(lunarIndexerUrl, chainId, wellMarketConfig.id, {
startTime,
granularity: "1d",
limit: 1000,
...(cursor && { cursor }),
});
for (const snapshot of response.results) {
const price = Number.parseFloat(wellIsCollateral
? snapshot.collateralTokenPrice
: snapshot.loanTokenPrice);
if (price > 0) {
priceMap.set(snapshot.timestamp * 1000, price);
}
}
cursor = response.nextCursor ?? undefined;
page++;
} while (cursor !== undefined && page < MAX_PAGES);
return priceMap;
}
export async function fetchIsolatedMarketSnapshots(marketAddress, environment, period, customStartTime, customEndTime) {
const lunarIndexerUrl = environment.lunarIndexerUrl;
if (!lunarIndexerUrl) {
return [];
}
try {
return await fetchIsolatedMarketSnapshotsFromLunar(marketAddress, environment, lunarIndexerUrl, period, customStartTime, customEndTime);
}
catch (error) {
console.warn(`[getMarketSnapshots] Lunar Indexer failed for chain ${environment.chainId}:`, error);
environment.onError?.(error, {
source: "market-snapshots",
chainId: environment.chainId,
});
return [];
}
}
async function fetchIsolatedMarketSnapshotsFromLunar(marketAddress, environment, lunarIndexerUrl, period, customStartTime, customEndTime) {
const { startTime } = calculateTimeRange(period, customStartTime, customEndTime);
// The USDC/ETH market (collateral = USDC, loan = WETH) needs normalization:
// the indexer returns totalSupplyAssets in WETH units but the chart needs
// USDC-equivalent units (totalSupplyAssetsUsd / collateralTokenPrice).
const marketConfig = Object.values(environment.config.morphoMarkets).find((m) => m.id.toLowerCase() === marketAddress.toLowerCase());
const loanSymbol = marketConfig
? environment.config.tokens[marketConfig.loanToken]?.symbol
: undefined;
const collateralSymbol = marketConfig
? environment.config.tokens[marketConfig.collateralToken]?.symbol
: undefined;
const normalizeToCollateral = loanSymbol === "ETH" && collateralSymbol === "USDC";
// stkWELL is not priced by the indexer (same oracle as WELL but unrecognized).
// Fetch the WELL market snapshots concurrently and use their prices to correct
// any stkWELL snapshots where collateralTokenPrice is 0.
const isStkWellMarket = collateralSymbol === "stkWELL";
const wellMarketConfig = isStkWellMarket
? Object.values(environment.config.morphoMarkets).find((m) => m.collateralToken === "WELL" || m.loanToken === "WELL")
: undefined;
const wellPricesPromise = wellMarketConfig
? fetchWellPricesByTimestamp(lunarIndexerUrl, environment.chainId, wellMarketConfig, startTime)
: Promise.resolve(new Map());
const allSnapshots = [];
let cursor;
const MAX_PAGES = 100;
let page = 0;
do {
const response = await fetchMarketSnapshotsFromIndexer(lunarIndexerUrl, environment.chainId, marketAddress, {
startTime,
granularity: "1d",
limit: 1000,
...(cursor && { cursor }),
});
allSnapshots.push(...response.results
.filter((s) => isStartOfDay(s.timestamp))
.map((s) => transformIsolatedMarketSnapshotFromIndexer(s, {
normalizeToCollateral,
})));
cursor = response.nextCursor ?? undefined;
page++;
} while (cursor !== undefined && page < MAX_PAGES);
// Sanity check for non-normalized markets: detect when totalSupplyAssets was
// stored in collateral units instead of loan token units (an indexer bug seen on
// the stkWELL/USDC market). When this happens, totalSupplyUsd / totalSupply is
// close to collateralTokenPrice rather than loanTokenPrice. Recover the correct
// loan-token amount from the USD value instead of discarding the data point.
// We skip normalized markets (e.g. USDC/ETH where totalSupply is already in
// collateral units after normalization) to avoid false corrections.
const sanitizedSnapshots = normalizeToCollateral
? allSnapshots
: allSnapshots.map((snapshot) => {
if (snapshot.totalSupply === 0 || snapshot.loanTokenPrice === 0)
return snapshot;
const impliedLoanPrice = snapshot.totalSupplyUsd / snapshot.totalSupply;
if (impliedLoanPrice < snapshot.loanTokenPrice * 0.1) {
return {
...snapshot,
totalSupply: snapshot.totalSupplyUsd / snapshot.loanTokenPrice,
totalLiquidity: snapshot.totalLiquidityUsd / snapshot.loanTokenPrice,
};
}
return snapshot;
});
if (isStkWellMarket) {
const wellPriceByTimestampMs = await wellPricesPromise;
return sanitizedSnapshots.map((snapshot) => {
// Resolve the collateral (stkWELL) price: use indexed price when available,
// fall back to the WELL market price fetched concurrently.
let collateralTokenPrice = snapshot.collateralTokenPrice;
if (collateralTokenPrice === 0) {
const wellPrice = wellPriceByTimestampMs.get(snapshot.timestamp);
if (wellPrice)
collateralTokenPrice = wellPrice;
}
// The lunar indexer stores totalSupplyAssets in loan-token (USDC) units.
// Convert to collateral (stkWELL) units for a consistent chart axis:
// totalSupply = totalSupplyUsd / collateralTokenPrice
const totalSupply = collateralTokenPrice > 0
? snapshot.totalSupplyUsd / collateralTokenPrice
: snapshot.totalSupply;
const totalLiquidity = collateralTokenPrice > 0
? snapshot.totalLiquidityUsd / collateralTokenPrice
: snapshot.totalLiquidity;
const totalReallocatableLiquidity = collateralTokenPrice > 0
? snapshot.totalReallocatableLiquidityUsd / collateralTokenPrice
: snapshot.totalReallocatableLiquidity;
return {
...snapshot,
collateralTokenPrice,
totalSupply,
totalLiquidity,
totalReallocatableLiquidity,
};
});
}
return sanitizedSnapshots;
}
//# sourceMappingURL=getMarketSnapshots.js.map