diff --git a/src/gateway/public-gateway.mjs b/src/gateway/public-gateway.mjs index 1bd4d2a..f11192b 100644 --- a/src/gateway/public-gateway.mjs +++ b/src/gateway/public-gateway.mjs @@ -161,6 +161,35 @@ async function findTokenUtxo(lockHex, category, commitmentHex) { } // Listings whose certificate is still where the seller left it. Cached 30 s. let _marketCheck = { at: 0, list: null }; +// ---- BCH/USD price: median of public exchange tickers ---- +const PRICE_SOURCES = [ + { name: "coinbase", url: "https://api.coinbase.com/v2/prices/BCH-USD/spot", pick: (j) => Number(j?.data?.amount) }, + { name: "kraken", url: "https://api.kraken.com/0/public/Ticker?pair=BCHUSD", pick: (j) => Number(Object.values(j?.result || {})[0]?.c?.[0]) }, + { name: "bitstamp", url: "https://www.bitstamp.net/api/v2/ticker/bchusd/", pick: (j) => Number(j?.last) }, + { name: "binance", url: "https://api.binance.com/api/v3/ticker/price?symbol=BCHUSDT", pick: (j) => Number(j?.price) }, + { name: "coingecko", url: "https://api.coingecko.com/api/v3/simple/price?ids=bitcoin-cash&vs_currencies=usd", pick: (j) => Number(j?.["bitcoin-cash"]?.usd) }, +]; +let priceCache = { at: 0, body: null }; +async function getPrice() { + if (priceCache.body && Date.now() - priceCache.at < 60_000) return priceCache.body; + const results = await Promise.allSettled(PRICE_SOURCES.map(async (src) => { + const r = await fetch(src.url, { signal: AbortSignal.timeout(5000), headers: { "user-agent": "bns-gateway/1", accept: "application/json" } }); + if (!r.ok) throw new Error(String(r.status)); + const usd = src.pick(await r.json()); + if (!Number.isFinite(usd) || usd <= 0) throw new Error("no price"); + return { name: src.name, usd: Math.round(usd * 100) / 100 }; + })); + const ok = results.filter((r) => r.status === "fulfilled").map((r) => r.value).sort((a, b) => a.usd - b.usd); + if (ok.length) { + const mid = Math.floor(ok.length / 2); + const usd = ok.length % 2 ? ok[mid].usd : Math.round((ok[mid - 1].usd + ok[mid].usd) * 50) / 100; + priceCache = { at: Date.now(), body: { usd, sats_per_usd: Math.round(1e8 / usd), sources: ok, median_of: ok.length, at: new Date().toISOString(), stale: false } }; + return priceCache.body; + } + if (priceCache.body) return { ...priceCache.body, stale: true }; + return { usd: null, sats_per_usd: null, sources: [], median_of: 0, at: new Date().toISOString(), stale: true, error: results.map((r) => r.reason?.message).join("; ") }; +} + async function activeListings() { if (_marketCheck.list && Date.now() - _marketCheck.at < 30000) return _marketCheck.list; const stored = readMarket(); @@ -1138,6 +1167,22 @@ const server = http.createServer(async (req, res) => { } // --- TLD certificate UTXO (for co-signed registrations) --- + // GET /api/price -> { usd, sats_per_usd, sources: [{name, usd}], median_of, at, stale } + // BCH/USD from several exchanges' public endpoints (no keys), median of + // whoever answers, cached 60 s; the last good answer is served (marked + // stale) if every source fails. Clients also query the same sources + // themselves when this route is unreachable. + if (req.url === "/api/price" || req.url.startsWith("/api/price?")) { + res.setHeader("access-control-allow-origin", "*"); + res.setHeader("content-type", "application/json"); + res.setHeader("cache-control", "public, max-age=30"); + try { + const q = await getPrice(); + res.writeHead(q.usd ? 200 : 503); res.end(JSON.stringify(q, null, 2)); + } catch (e) { res.writeHead(500); res.end(JSON.stringify({ error: e.message })); } + return; + } + // GET /api/tld-cert/ -> { tld, owner, utxo: {txid, vout, satoshis, lockingBytecode, token} } if (req.url.startsWith("/api/tld-cert/")) { res.setHeader("access-control-allow-origin", "*");