rspace-online/modules/rwallet/lib/defi-positions.ts

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&currency=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;
}
}