166 lines
4.3 KiB
TypeScript
166 lines
4.3 KiB
TypeScript
/**
|
|
* Zerion DeFi positions — fetches protocol positions (Aave, Uniswap, etc.)
|
|
* with 5-minute in-memory cache. Requires ZERION_API_KEY env var.
|
|
*/
|
|
|
|
export interface DefiPosition {
|
|
protocol: string;
|
|
type: string; // "deposit", "loan", "staked", "locked", "reward"
|
|
chain: string; // chain name
|
|
chainId: string;
|
|
tokens: Array<{ symbol: string; amount: number; valueUSD: number }>;
|
|
totalValueUSD: number;
|
|
}
|
|
|
|
interface CacheEntry {
|
|
positions: DefiPosition[];
|
|
ts: number;
|
|
}
|
|
|
|
const TTL = 5 * 60 * 1000;
|
|
const cache = new Map<string, CacheEntry>();
|
|
const inFlight = new Map<string, Promise<DefiPosition[]>>();
|
|
|
|
// Zerion chain ID → our chain ID mapping
|
|
const ZERION_CHAIN_MAP: Record<string, string> = {
|
|
ethereum: "1",
|
|
optimism: "10",
|
|
"gnosis-chain": "100",
|
|
"xdai": "100",
|
|
polygon: "137",
|
|
base: "8453",
|
|
arbitrum: "42161",
|
|
"binance-smart-chain": "56",
|
|
avalanche: "43114",
|
|
celo: "42220",
|
|
"zksync-era": "324",
|
|
};
|
|
|
|
function getApiKey(): string | null {
|
|
return process.env.ZERION_API_KEY || null;
|
|
}
|
|
|
|
/**
|
|
* Fetch DeFi protocol positions for an address via Zerion API.
|
|
* Returns empty array if ZERION_API_KEY is not set.
|
|
*/
|
|
export async function getDefiPositions(address: string): Promise<DefiPosition[]> {
|
|
const apiKey = getApiKey();
|
|
if (!apiKey) return [];
|
|
|
|
const lower = address.toLowerCase();
|
|
|
|
// Check cache
|
|
const cached = cache.get(lower);
|
|
if (cached && Date.now() - cached.ts < TTL) return cached.positions;
|
|
|
|
// Deduplicate concurrent requests
|
|
const pending = inFlight.get(lower);
|
|
if (pending) return pending;
|
|
|
|
const promise = (async (): Promise<DefiPosition[]> => {
|
|
try {
|
|
const auth = btoa(`${apiKey}:`);
|
|
const url = `https://api.zerion.io/v1/wallets/${lower}/positions/?filter[positions]=only_complex¤cy=usd&filter[trash]=only_non_trash`;
|
|
const res = await fetch(url, {
|
|
headers: {
|
|
accept: "application/json",
|
|
authorization: `Basic ${auth}`,
|
|
},
|
|
signal: AbortSignal.timeout(15000),
|
|
});
|
|
|
|
if (res.status === 429) {
|
|
console.warn("[defi-positions] Zerion rate limited");
|
|
return [];
|
|
}
|
|
if (!res.ok) {
|
|
console.warn(`[defi-positions] Zerion API error: ${res.status}`);
|
|
return [];
|
|
}
|
|
|
|
const data = await res.json() as { data?: any[] };
|
|
const positions = (data.data || []).map(normalizePosition).filter(Boolean) as DefiPosition[];
|
|
|
|
cache.set(lower, { positions, ts: Date.now() });
|
|
return positions;
|
|
} catch (e) {
|
|
console.warn("[defi-positions] Failed to fetch:", e);
|
|
return [];
|
|
} finally {
|
|
inFlight.delete(lower);
|
|
}
|
|
})();
|
|
|
|
inFlight.set(lower, promise);
|
|
return promise;
|
|
}
|
|
|
|
function normalizePosition(item: any): DefiPosition | null {
|
|
try {
|
|
const attrs = item.attributes;
|
|
if (!attrs) return null;
|
|
|
|
const protocol = attrs.protocol_id || attrs.protocol || "Unknown";
|
|
const posType = attrs.position_type || "deposit";
|
|
const chainRaw = attrs.chain || item.relationships?.chain?.data?.id || "";
|
|
const chainId = ZERION_CHAIN_MAP[chainRaw] || "";
|
|
|
|
const tokens: DefiPosition["tokens"] = [];
|
|
let totalValueUSD = 0;
|
|
|
|
// Fungible positions — may have a single fungible_info or multiple
|
|
if (attrs.fungible_info) {
|
|
const fi = attrs.fungible_info;
|
|
const amount = attrs.quantity?.float ?? 0;
|
|
const value = attrs.value ?? 0;
|
|
tokens.push({
|
|
symbol: fi.symbol || "???",
|
|
amount,
|
|
valueUSD: value,
|
|
});
|
|
totalValueUSD += value;
|
|
}
|
|
|
|
// Interpretations — complex positions with sub-tokens
|
|
if (attrs.interpretations) {
|
|
for (const interp of attrs.interpretations) {
|
|
if (interp.tokens) {
|
|
for (const t of interp.tokens) {
|
|
const tVal = t.value ?? 0;
|
|
tokens.push({
|
|
symbol: t.fungible_info?.symbol || t.symbol || "???",
|
|
amount: t.quantity?.float ?? 0,
|
|
valueUSD: tVal,
|
|
});
|
|
totalValueUSD += tVal;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fall back to top-level value if tokens didn't capture it
|
|
if (totalValueUSD === 0 && attrs.value) {
|
|
totalValueUSD = attrs.value;
|
|
}
|
|
|
|
if (totalValueUSD < 0.01 && tokens.length === 0) return null;
|
|
|
|
// Prettify protocol name
|
|
const prettyProtocol = protocol
|
|
.replace(/-/g, " ")
|
|
.replace(/\b\w/g, (c: string) => c.toUpperCase());
|
|
|
|
return {
|
|
protocol: prettyProtocol,
|
|
type: posType,
|
|
chain: chainRaw.replace(/-/g, " ").replace(/\b\w/g, (c: string) => c.toUpperCase()),
|
|
chainId,
|
|
tokens,
|
|
totalValueUSD,
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|