@moonwell-fi/moonwell-sdk
Version:
TypeScript Interface for Moonwell
568 lines (517 loc) • 17.7 kB
text/typescript
import type { MoonwellClient } from "../../../client/createMoonwellClient.js";
import {
type SnapshotPeriod,
applyGranularity,
calculateTimeRange,
getEnvironmentFromArgs,
isStartOfDay,
toApiGranularity,
} from "../../../common/index.js";
import { buildMarketId } from "../../../common/lunar-indexer-helpers.js";
import type { NetworkParameterType } from "../../../common/types.js";
import type { Chain, Environment } from "../../../environments/index.js";
import type { MarketSnapshot } from "../../../types/market.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";
export type GetMarketSnapshotsParameters<
environments,
network extends Chain | undefined,
> = NetworkParameterType<environments, network> & {
type: "core" | "isolated";
marketId: `0x${string}`;
/** Predefined time period for snapshots */
period?: SnapshotPeriod;
startTime?: number;
endTime?: number;
};
export type GetMarketSnapshotsReturnType = Promise<MarketSnapshot[]>;
/**
* 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: MarketSnapshot[],
): MarketSnapshot[] {
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<
environments,
Network extends Chain | undefined,
>(
client: MoonwellClient,
args: GetMarketSnapshotsParameters<environments, Network>,
): GetMarketSnapshotsReturnType {
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: string,
environment: Environment,
period?: "1M" | "3M" | "1Y" | "ALL",
startTime?: number,
endTime?: number,
): Promise<MarketSnapshot[]> {
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: string,
environment: Environment,
): Promise<MarketSnapshot[]> {
if (!environment.indexerUrl) return [];
interface MarketDailyData {
totalBorrows: number;
totalBorrowsUSD: number;
totalSupplies: number;
totalSuppliesUSD: number;
totalLiquidity: number;
totalLiquidityUSD: number;
baseSupplyApy: number;
baseBorrowApy: number;
timestamp: number;
}
const dailyData: MarketDailyData[] = [];
let hasNextPage = true;
let endCursor: string | undefined;
while (hasNextPage) {
const result = await postWithRetry<{
data: {
marketDailySnapshots: {
items: MarketDailyData[];
pageInfo: { hasNextPage: boolean; endCursor: string };
};
};
}>(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: { timestamp: number }) => 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: string,
environment: Environment,
period?: "1M" | "3M" | "1Y" | "ALL",
customStartTime?: number,
customEndTime?: number,
): Promise<MarketSnapshot[]> {
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: MarketSnapshot[] = [];
let cursor: string | null = 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: string,
chainId: number,
wellMarketConfig: { id: string; collateralToken: string; loanToken: string },
startTime: number,
): Promise<Map<number, number>> {
const wellIsCollateral = wellMarketConfig.collateralToken === "WELL";
const priceMap = new Map<number, number>();
let cursor: string | undefined;
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: string,
environment: Environment,
period?: "1M" | "3M" | "1Y" | "ALL",
customStartTime?: number,
customEndTime?: number,
): Promise<MarketSnapshot[]> {
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: string,
environment: Environment,
lunarIndexerUrl: string,
period?: "1M" | "3M" | "1Y" | "ALL",
customStartTime?: number,
customEndTime?: number,
): Promise<MarketSnapshot[]> {
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<number, number>());
const allSnapshots: MarketSnapshot[] = [];
let cursor: string | undefined;
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;
}