summaryrefslogtreecommitdiffstats
path: root/src/rates.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/rates.js')
-rw-r--r--src/rates.js128
1 files changed, 128 insertions, 0 deletions
diff --git a/src/rates.js b/src/rates.js
new file mode 100644
index 0000000..bc32bd7
--- /dev/null
+++ b/src/rates.js
@@ -0,0 +1,128 @@
+// What a bitcoin was worth, now and at the time.
+//
+// Two jobs. The first is the live price, for balances and for typing an
+// amount in dollars. The second is history: a payment is worth what it was
+// worth WHEN IT HAPPENED, and that number is gone by the time you look at it
+// unless someone wrote it down. So every price we fetch is kept as a sample —
+// one an hour, a few months back — and a payment's fiat value is read off the
+// sample nearest its timestamp.
+//
+// The rates come from coinos' own endpoint (a map of every currency to the
+// bitcoin price in it), so there's no third party watching what the wallet
+// asks about.
+
+const RATES_URL = 'https://coinos.io/api/rates';
+const CUR_KEY = 'btc-wallet-currency';
+const SERIES_KEY = 'btc-wallet-rate-series'; // [[unixSeconds, rate], ...] oldest first
+const LIVE_KEY = 'btc-wallet-rates'; // the last full map, so a cold start has a price
+const SAMPLE_MS = 3600_000; // one sample an hour is plenty to price a payment
+const SERIES_MAX = 2200; // ~3 months
+const REFRESH_MS = 10 * 60_000;
+// How far a sample may be from a payment before it stops being "the price at
+// the time". Beyond this we say so rather than quietly pricing last week's
+// payment at today's number.
+export const RATE_TOLERANCE = 24 * 3600;
+
+let map = null; // { USD: 77239.99, ... }
+let fetchedAt = 0;
+let inflight = null;
+let series = null;
+
+const read = (k, fb) => { try { const v = JSON.parse(localStorage.getItem(k) || 'null'); return v == null ? fb : v; } catch { return fb; } };
+const write = (k, v) => { try { localStorage.setItem(k, JSON.stringify(v)); } catch {} };
+
+export function getCurrency() {
+ try { return localStorage.getItem(CUR_KEY) || 'USD'; } catch { return 'USD'; }
+}
+export function setCurrency(code) {
+ try { localStorage.setItem(CUR_KEY, String(code || 'USD').toUpperCase()); } catch {}
+ // the series is denominated in whatever was chosen when it was recorded, so
+ // a currency change starts a new one rather than mislabelling the old
+ series = [];
+ write(SERIES_KEY, series);
+ if (map) sample(map[getCurrency()]);
+}
+export function currencies() {
+ const m = map || read(LIVE_KEY, null);
+ return m ? Object.keys(m).sort() : ['USD'];
+}
+
+function loadSeries() {
+ if (!series) series = read(SERIES_KEY, []) || [];
+ return series;
+}
+// Keep one sample an hour: a price that moves 2% in a minute is still the
+// same answer to "what was this payment worth".
+function sample(rate) {
+ if (!rate || !isFinite(rate)) return;
+ const s = loadSeries();
+ const now = Math.floor(Date.now() / 1000);
+ const last = s[s.length - 1];
+ if (last && now - last[0] < SAMPLE_MS / 1000) { last[1] = rate; } // same hour: keep it current
+ else s.push([now, rate]);
+ if (s.length > SERIES_MAX) s.splice(0, s.length - SERIES_MAX);
+ write(SERIES_KEY, s);
+}
+
+// The live price in the chosen currency, or null before we've ever had one.
+export function rateNow() {
+ if (!map) map = read(LIVE_KEY, null);
+ const r = map && map[getCurrency()];
+ return r && isFinite(r) ? r : null;
+}
+
+// The price at a moment: the nearest sample, and how far off it was. A caller
+// that gets `stale` back is looking at today's price on an older payment and
+// should say so.
+export function rateAt(unixSeconds) {
+ const now = rateNow();
+ if (!unixSeconds) return { rate: now, stale: false };
+ const s = loadSeries();
+ if (!s.length) return { rate: now, stale: !!now };
+ // binary search for the closest sample
+ let lo = 0, hi = s.length - 1;
+ while (lo < hi) {
+ const mid = (lo + hi) >> 1;
+ if (s[mid][0] < unixSeconds) lo = mid + 1; else hi = mid;
+ }
+ const near = [s[lo], s[lo - 1]].filter(Boolean)
+ .sort((a, b) => Math.abs(a[0] - unixSeconds) - Math.abs(b[0] - unixSeconds))[0];
+ const gap = Math.abs(near[0] - unixSeconds);
+ if (gap <= RATE_TOLERANCE) return { rate: near[1], stale: false };
+ return { rate: now, stale: !!now };
+}
+
+// Fetch the map, remember it, and record this moment's price. Cheap to call
+// often — it refreshes at most every ten minutes.
+export async function refreshRates({ force = false } = {}) {
+ if (!force && Date.now() - fetchedAt < REFRESH_MS) return map;
+ if (inflight) return inflight;
+ inflight = (async () => {
+ try {
+ const r = await fetch(RATES_URL, { signal: AbortSignal.timeout(8000) });
+ const j = await r.json();
+ if (!j || typeof j !== 'object' || !j.USD) throw new Error('bad rates');
+ map = j;
+ fetchedAt = Date.now();
+ write(LIVE_KEY, j);
+ sample(j[getCurrency()]);
+ return map;
+ } catch { return map; } finally { inflight = null; }
+ })();
+ return inflight;
+}
+
+// Does this build know a price at all? The unit toggle only offers fiat when
+// it does — a third position that shows blanks is worse than two that work.
+export const haveRate = () => rateNow() != null;
+
+export function fmtFiat(sats, rate, code = getCurrency()) {
+ if (!rate) return '';
+ const v = (Number(sats) / 100_000_000) * rate;
+ const digits = Math.abs(v) < 1 && v !== 0 ? 4 : 2;
+ try {
+ return v.toLocaleString(undefined, { style: 'currency', currency: code, maximumFractionDigits: digits, minimumFractionDigits: 2 });
+ } catch {
+ return v.toFixed(digits) + ' ' + code; // a code Intl doesn't know
+ }
+}