// Fiat prices for every Aegis-supported coin. Opt-in via Settings so a // privacy-conscious user isn't quietly telling ANY oracle when Aegis is // open. Source is user-selectable — different oracles trade off privacy, // coverage, and freshness: // // - coingecko : one HTTP request covers all 7 coins, best coverage, // default. Sees the browser IP + User-Agent every poll. // - kraken : per-pair spot from Kraken's public /Ticker; fewer // pairs (BCH/BTC/ETH/SOL/TRX; no SC/DGB). Sees IP but // no user id. // - coinbase : Coinbase's public spot endpoint; similar coverage to // Kraken, similar IP-only exposure. // // New sources plug in by adding an entry to SOURCES. Each provider takes a // list of chain keys and returns { : usd } for the ones it knows // about; unknown chains just stay absent from the snapshot. The poller is // generic. // // Cache is in-memory (returned by fullState() → panel). Poll interval is // per-source since some rate-limit tighter than others. Off by default. const CHAINS = ["bch", "btc", "trx", "eth", "sol", "sc", "dgb"]; const SOURCES = { coingecko: { id: "coingecko", label: "CoinGecko", origin: "api.coingecko.com", pollMs: 5 * 60 * 1000, coversAll: true, fetch: async () => { const ids = { bch: "bitcoin-cash", btc: "bitcoin", trx: "tron", eth: "ethereum", sol: "solana", sc: "siacoin", dgb: "digibyte", }; const url = `https://api.coingecko.com/api/v3/simple/price?ids=${encodeURIComponent(Object.values(ids).join(","))}&vs_currencies=usd`; const r = await fetch(url); if (!r.ok) throw new Error(`CoinGecko HTTP ${r.status}`); const body = await r.json(); const out = {}; for (const [chain, cgId] of Object.entries(ids)) { const usd = body?.[cgId]?.usd; if (typeof usd === "number") out[chain] = usd; } return out; }, }, kraken: { id: "kraken", label: "Kraken", origin: "api.kraken.com", pollMs: 60 * 1000, coversAll: false, fetch: async () => { // Kraken uses non-standard pair names (XBT, ZUSD…). Only cover the // coins Kraken lists with USD spot. SC + DGB are not on Kraken. const pairs = { bch: "BCHUSD", btc: "XBTUSD", eth: "ETHUSD", sol: "SOLUSD", trx: "TRXUSD" }; const url = `https://api.kraken.com/0/public/Ticker?pair=${Object.values(pairs).join(",")}`; const r = await fetch(url); if (!r.ok) throw new Error(`Kraken HTTP ${r.status}`); const body = await r.json(); if (body?.error?.length) throw new Error("Kraken: " + body.error.join(";")); // Kraken returns keys like "XBCHZUSD" — match by suffix. const out = {}; const result = body?.result || {}; const entries = Object.entries(result); for (const [chain, pair] of Object.entries(pairs)) { const hit = entries.find(([k]) => k === pair || k.endsWith(pair) || k.endsWith(pair.replace("XBT", "BT"))); const last = hit && parseFloat(hit[1]?.c?.[0]); if (Number.isFinite(last)) out[chain] = last; } return out; }, }, coinbase: { id: "coinbase", label: "Coinbase", origin: "api.coinbase.com", pollMs: 60 * 1000, coversAll: false, fetch: async () => { // Coinbase publishes one spot per pair via /v2/prices//spot. // Runs the requests in parallel — 5 calls, each ~150 B response. const map = { bch: "BCH-USD", btc: "BTC-USD", eth: "ETH-USD", sol: "SOL-USD" }; const out = {}; await Promise.all(Object.entries(map).map(async ([chain, pair]) => { try { const r = await fetch(`https://api.coinbase.com/v2/prices/${pair}/spot`); if (!r.ok) return; const body = await r.json(); const usd = parseFloat(body?.data?.amount); if (Number.isFinite(usd)) out[chain] = usd; } catch { /* one pair failing shouldn't kill the others */ } })); return out; }, }, }; const DEFAULT_SOURCE = "coingecko"; module.exports = function makePriceFeed({ log = () => {}, onChange = () => {} } = {}) { const state = { enabled: false, source: DEFAULT_SOURCE, prices: {}, // { : usd (number) } fetchedAt: null, error: null, loading: false, }; let timer = null; function currentProvider() { return SOURCES[state.source] || SOURCES[DEFAULT_SOURCE]; } async function fetchOnce() { if (!state.enabled) return; state.loading = true; state.error = null; onChange(); try { const src = currentProvider(); const next = await src.fetch(); state.prices = next || {}; state.fetchedAt = Date.now(); state.error = null; } catch (e) { state.error = e?.message || String(e); log(`price fetch (${state.source}) failed:`, state.error); } finally { state.loading = false; onChange(); } } function schedule() { clearTimeout(timer); if (!state.enabled) return; timer = setTimeout(async () => { await fetchOnce(); schedule(); }, currentProvider().pollMs); } return { snapshot() { return { enabled: state.enabled, source: state.source, prices: state.prices, fetchedAt: state.fetchedAt, error: state.error, loading: state.loading, sources: Object.values(SOURCES).map((s) => ({ id: s.id, label: s.label, origin: s.origin, coversAll: s.coversAll, })), }; }, // Turn the feed on/off. Enabling triggers an immediate fetch so the // panel doesn't wait a full poll interval for the first price. async setEnabled(on) { const changed = !!on !== state.enabled; state.enabled = !!on; if (!state.enabled) { state.prices = {}; state.fetchedAt = null; state.error = null; clearTimeout(timer); if (changed) onChange(); return; } onChange(); await fetchOnce(); schedule(); }, // Switch source. Clears the current cache, kicks a fresh fetch if the // feed is enabled. No-op when the source is already current. async setSource(id) { if (!SOURCES[id] || id === state.source) return; state.source = id; state.prices = {}; state.fetchedAt = null; onChange(); if (state.enabled) { await fetchOnce(); schedule(); } }, refresh() { return fetchOnce(); }, dispose() { clearTimeout(timer); state.enabled = false; }, }; };