// Fiat prices for every Aegis-supported coin. Poll every enabled source in // parallel and reconcile per chain: if two or more sources agree within a // small band (±3% of the median), take their median as the truth; if none // agree, fall back to the median of every reported value. This kills any // single oracle's ability to make Aegis show a wrong number — a spoofed // or wildly stale feed on one origin is outvoted by the others. // // The user-facing model in 0.6.36+ is just "on / off": no source picker, // no per-source config. Adding a new oracle here fans out to everyone // with no UI churn. // // Sources currently wired: // coingecko — 1 request covers all 7 coins, best overall coverage // kraken — public /Ticker; BCH/BTC/ETH/SOL/TRX pairs // coinbase — public /spot; BCH/BTC/ETH/SOL pairs // coinspectrum — coin-spectrum.com free /assets/.json (~10 min TTL) // // Sources deferred (need their own protocol work first): // oracles.cash / General Protocols — the /oracleMetadata endpoint returns // hex-encoded signed attestations. Extracting a usable USD number // requires decoding the message format (pair || timestamp || price_int // || decimals) and verifying the signature against a known oracle // pubkey per pair. Left as a TODO stub below so the plumbing is // ready; enable once the message parser is done. const CHAINS = ["bch", "btc", "trx", "eth", "sol", "sc", "dgb"]; // Sources return { : usd_number } for every chain they know about. // Absence just means "this source doesn't cover that chain"; reconciliation // ignores it. Errors thrown here bubble to the poller which stores them // per-source in the snapshot so the panel can show which oracle is down. const SOURCES = { coingecko: { id: "coingecko", label: "CoinGecko", origin: "api.coingecko.com", 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", coversAll: false, fetch: async () => { 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(";")); 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", coversAll: false, fetch: async () => { 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; }, }, coinspectrum: { id: "coinspectrum", label: "Coin-Spectrum", origin: "coin-spectrum.com", coversAll: true, fetch: async () => { const slugs = { bch: "bitcoin-cash", btc: "bitcoin", trx: "tron", eth: "ethereum", sol: "solana", sc: "siacoin", dgb: "digibyte", }; const out = {}; await Promise.all(Object.entries(slugs).map(async ([chain, slug]) => { try { const r = await fetch(`https://coin-spectrum.com/api/v1/assets/${slug}.json`, { cache: "no-store" }); if (!r.ok) return; const body = await r.json(); // coin-spectrum wraps everything under body.asset: // { generated_at, asset: { slug, symbol, price_usd, … } } // Older builds read body.price_usd, which is undefined, so every // chain silently NaN'd and the UI showed "✓ 0 coins". const usd = Number(body?.asset?.price_usd ?? body?.price_usd); if (Number.isFinite(usd) && usd > 0) out[chain] = usd; } catch { /* one slug failing shouldn't kill the others */ } })); return out; }, }, // oracles.cash (General Protocols) is deferred until we decode their // signed-attestation message format. Enable by moving this entry into // SOURCES above once fetch() returns real USD numbers. // _oraclescash: { // id: "oraclescash", // label: "oracles.cash", // origin: "oracles.generalprotocols.com", // coversAll: false, // fetch: async () => { // // TODO: pick per-pair oracle pubkey, fetch /api/v1/oracleMessages, // // decode `message` = pair_ascii(2 bytes) || timestamp(u32) || // // price_int(u32-or-u64) || decimals; verify signature. See // // https://oracles.generalprotocols.com/api/v1/oracleMetadata for // // the list of active oracles. // return {}; // }, // }, }; const POLL_MS = 5 * 60 * 1000; // 5 min: gentle on free tiers, still fresh enough const AGREEMENT_BAND = 0.03; // ±3% around the median counts as "agreeing" const median = (nums) => { const s = nums.slice().sort((a, b) => a - b); const m = s.length; if (!m) return null; return m % 2 ? s[(m - 1) / 2] : (s[m / 2 - 1] + s[m / 2]) / 2; }; // Reconcile a per-source map for ONE chain into a single trusted USD number. // perSource: { : usd_number } // Returns { usd, method, samples: [{sourceId, usd, agrees}] }. function reconcileOne(perSource) { const samples = Object.entries(perSource) .filter(([, v]) => Number.isFinite(v) && v > 0) .map(([sourceId, usd]) => ({ sourceId, usd, agrees: false })); if (!samples.length) return { usd: null, method: "none", samples }; if (samples.length === 1) { samples[0].agrees = true; return { usd: samples[0].usd, method: "single", samples }; } // Pin agreement around the overall median so no single outlier can shift // the anchor. Any two samples within ±3% of that median form a "cluster"; // if ≥2 exist we take their median as the truth. const mid = median(samples.map((s) => s.usd)); const lo = mid * (1 - AGREEMENT_BAND); const hi = mid * (1 + AGREEMENT_BAND); const agreeing = samples.filter((s) => s.usd >= lo && s.usd <= hi); if (agreeing.length >= 2) { for (const a of agreeing) a.agrees = true; return { usd: median(agreeing.map((s) => s.usd)), method: `majority-${agreeing.length}of${samples.length}`, samples }; } // Nobody agrees within the band — every source disagrees. Fall back to // the median of everything reported so we still show A price (biased // toward the middle) rather than nothing. Panel can show a "spread" // warning if callers care. return { usd: mid, method: `median-${samples.length}`, samples }; } // Fan out to every SOURCES.fetch() in parallel. Returns // { perChain: { : {usd, method, samples} }, sourceStatus: { : {ok, error, prices, at} } }. async function pollAll(log) { const sourceStatus = {}; const perSourcePrices = {}; // chain -> {sourceId: usd} await Promise.all(Object.values(SOURCES).map(async (s) => { const start = Date.now(); try { const got = await s.fetch(); const prices = got && typeof got === "object" ? got : {}; sourceStatus[s.id] = { ok: true, error: null, prices, at: Date.now(), took: Date.now() - start }; for (const [chain, usd] of Object.entries(prices)) { if (!Number.isFinite(usd) || usd <= 0) continue; if (!perSourcePrices[chain]) perSourcePrices[chain] = {}; perSourcePrices[chain][s.id] = usd; } } catch (e) { const msg = e?.message || String(e); sourceStatus[s.id] = { ok: false, error: msg, prices: {}, at: Date.now(), took: Date.now() - start }; log(`price fetch (${s.id}) failed:`, msg); } })); const perChain = {}; for (const chain of CHAINS) { const rec = reconcileOne(perSourcePrices[chain] || {}); if (rec.usd != null) perChain[chain] = rec; } return { perChain, sourceStatus }; } module.exports = function makePriceFeed({ log = () => {}, onChange = () => {} } = {}) { const state = { enabled: false, prices: {}, // { : usd (number) } — backward-compat reconciled: {}, // { : {usd, method, samples: [...]} } sourceStatus: {}, // { : {ok, error, prices, at, took} } fetchedAt: null, error: null, loading: false, }; let timer = null; async function fetchOnce() { if (!state.enabled) return; state.loading = true; state.error = null; onChange(); try { const { perChain, sourceStatus } = await pollAll(log); const flat = {}; for (const [chain, rec] of Object.entries(perChain)) flat[chain] = rec.usd; state.prices = flat; state.reconciled = perChain; state.sourceStatus = sourceStatus; state.fetchedAt = Date.now(); // Only escalate to a top-level error if EVERY source failed. A single // oracle being unreachable is normal and doesn't need a red banner. const allDown = Object.values(sourceStatus).every((s) => !s.ok); state.error = allDown ? "All price sources unreachable" : null; } catch (e) { state.error = e?.message || String(e); log("price poll failed:", state.error); } finally { state.loading = false; onChange(); } } function schedule() { clearTimeout(timer); if (!state.enabled) return; timer = setTimeout(async () => { await fetchOnce(); schedule(); }, POLL_MS); } return { snapshot() { return { enabled: state.enabled, prices: state.prices, reconciled: state.reconciled, sourceStatus: state.sourceStatus, fetchedAt: state.fetchedAt, error: state.error, loading: state.loading, // Retained so existing settings UI paths that expect a `sources` // list keep rendering. `coversAll` is informational only now that // the picker's gone. sources: Object.values(SOURCES).map((s) => ({ id: s.id, label: s.label, origin: s.origin, coversAll: s.coversAll })), }; }, async setEnabled(on) { const changed = !!on !== state.enabled; state.enabled = !!on; if (!state.enabled) { state.prices = {}; state.reconciled = {}; state.sourceStatus = {}; state.fetchedAt = null; state.error = null; clearTimeout(timer); if (changed) onChange(); return; } onChange(); await fetchOnce(); schedule(); }, // No-op kept for API compatibility — there is no source picker anymore. // Existing callers that persisted a chosen source can still call this // and get a benign refresh. async setSource(_id) { if (state.enabled) { await fetchOnce(); schedule(); } }, refresh() { return fetchOnce(); }, dispose() { clearTimeout(timer); state.enabled = false; }, }; };