156 lines
4.3 KiB
TypeScript
156 lines
4.3 KiB
TypeScript
/**
|
|
* Yield position tracking — queries on-chain aToken and vault share balances
|
|
* for a given address across Aave V3 and Morpho Blue vaults.
|
|
*/
|
|
|
|
import type { YieldPosition, SupportedYieldChain, StablecoinSymbol } from "./yield-protocols";
|
|
import {
|
|
AAVE_ATOKENS, MORPHO_VAULTS,
|
|
STABLECOIN_ADDRESSES, STABLECOIN_DECIMALS,
|
|
encodeBalanceOf, encodeConvertToAssets,
|
|
YIELD_CHAIN_NAMES,
|
|
} from "./yield-protocols";
|
|
import { getRpcUrl } from "../mod";
|
|
import { getYieldRates } from "./yield-rates";
|
|
|
|
async function rpcCall(rpcUrl: string, to: string, data: string): Promise<string | null> {
|
|
try {
|
|
const res = await fetch(rpcUrl, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
jsonrpc: "2.0", id: 1,
|
|
method: "eth_call",
|
|
params: [{ to, data }, "latest"],
|
|
}),
|
|
signal: AbortSignal.timeout(5000),
|
|
});
|
|
const result = (await res.json()).result;
|
|
if (!result || result === "0x" || result === "0x0") return null;
|
|
return result;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
interface PositionQuery {
|
|
protocol: "aave-v3" | "morpho-blue";
|
|
chainId: SupportedYieldChain;
|
|
asset: StablecoinSymbol;
|
|
assetAddress: string;
|
|
vaultAddress: string;
|
|
vaultName?: string;
|
|
}
|
|
|
|
function buildPositionQueries(): PositionQuery[] {
|
|
const queries: PositionQuery[] = [];
|
|
const chains: SupportedYieldChain[] = ["1", "8453"];
|
|
|
|
for (const chainId of chains) {
|
|
// Aave aTokens
|
|
const aTokens = AAVE_ATOKENS[chainId];
|
|
if (aTokens) {
|
|
for (const [asset, aToken] of Object.entries(aTokens)) {
|
|
const assetAddr = STABLECOIN_ADDRESSES[chainId]?.[asset as StablecoinSymbol];
|
|
if (assetAddr && assetAddr !== "0x0000000000000000000000000000000000000000") {
|
|
queries.push({
|
|
protocol: "aave-v3",
|
|
chainId,
|
|
asset: asset as StablecoinSymbol,
|
|
assetAddress: assetAddr,
|
|
vaultAddress: aToken,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Morpho vaults
|
|
for (const vault of MORPHO_VAULTS[chainId] || []) {
|
|
const assetAddr = STABLECOIN_ADDRESSES[chainId]?.[vault.asset];
|
|
if (assetAddr && assetAddr !== "0x0000000000000000000000000000000000000000") {
|
|
queries.push({
|
|
protocol: "morpho-blue",
|
|
chainId,
|
|
asset: vault.asset,
|
|
assetAddress: assetAddr,
|
|
vaultAddress: vault.address,
|
|
vaultName: vault.name,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return queries;
|
|
}
|
|
|
|
export async function getYieldPositions(address: string): Promise<YieldPosition[]> {
|
|
const queries = buildPositionQueries();
|
|
const positions: YieldPosition[] = [];
|
|
|
|
// Get current rates for APY enrichment
|
|
const rates = await getYieldRates();
|
|
const rateMap = new Map(rates.map((r) => [`${r.protocol}:${r.chainId}:${r.vaultAddress.toLowerCase()}`, r]));
|
|
|
|
const results = await Promise.allSettled(
|
|
queries.map(async (q) => {
|
|
const rpcUrl = getRpcUrl(q.chainId);
|
|
if (!rpcUrl) return null;
|
|
|
|
const balanceHex = await rpcCall(rpcUrl, q.vaultAddress, encodeBalanceOf(address));
|
|
if (!balanceHex) return null;
|
|
|
|
const shares = BigInt(balanceHex);
|
|
if (shares === 0n) return null;
|
|
|
|
const decimals = STABLECOIN_DECIMALS[q.asset];
|
|
let underlying = shares; // For Aave, aToken balance = underlying (rebasing)
|
|
|
|
if (q.protocol === "morpho-blue") {
|
|
// Convert shares to underlying via convertToAssets
|
|
const assetsHex = await rpcCall(rpcUrl, q.vaultAddress, encodeConvertToAssets(shares));
|
|
if (assetsHex) {
|
|
underlying = BigInt(assetsHex);
|
|
}
|
|
}
|
|
|
|
// Look up APY
|
|
const rateKey = `${q.protocol}:${q.chainId}:${q.vaultAddress.toLowerCase()}`;
|
|
const rate = rateMap.get(rateKey);
|
|
const apy = rate?.apy || 0;
|
|
|
|
const underlyingUSD = Number(underlying) / 10 ** decimals; // stablecoins ≈ $1
|
|
const annualEarnings = underlyingUSD * (apy / 100);
|
|
const dailyEarnings = annualEarnings / 365;
|
|
|
|
return {
|
|
protocol: q.protocol,
|
|
chainId: q.chainId,
|
|
asset: q.asset,
|
|
assetAddress: q.assetAddress,
|
|
vaultAddress: q.vaultAddress,
|
|
shares: shares.toString(),
|
|
underlying: underlying.toString(),
|
|
decimals,
|
|
apy,
|
|
dailyEarnings,
|
|
annualEarnings,
|
|
} satisfies YieldPosition;
|
|
}),
|
|
);
|
|
|
|
for (const r of results) {
|
|
if (r.status === "fulfilled" && r.value) {
|
|
positions.push(r.value);
|
|
}
|
|
}
|
|
|
|
// Sort by underlying value descending
|
|
positions.sort((a, b) => {
|
|
const aVal = Number(BigInt(b.underlying)) / 10 ** b.decimals;
|
|
const bVal = Number(BigInt(a.underlying)) / 10 ** a.decimals;
|
|
return aVal - bVal;
|
|
});
|
|
|
|
return positions;
|
|
}
|