aboutsummaryrefslogtreecommitdiffstats
path: root/src/rates.js
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-11 22:55:14 -0700
committerAdam Soltys <[email protected]>2026-09-11 22:55:14 -0700
commite9a0aa2ca4d3aa659b579295dd983decd3729b6f (patch)
treeb237a05408a451aedd2d86be0fdda04040a37aaf /src/rates.js
parentefaa9a6d0525e5fab18bb1c97da5685107506eff (diff)
downloadcoinosv3-e9a0aa2ca4d3aa659b579295dd983decd3729b6f.tar.gz
coinosv3-e9a0aa2ca4d3aa659b579295dd983decd3729b6f.zip
Every payment remembers what it was worth.
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 the app fetches 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 own timestamp. The history rows and both detail pages (on-chain and Spending) pass the time the money moved; balances and forms use the live price, as they should. A payment older than any sample we hold is priced at today's number and says so with a '~', because that is a different claim and shouldn't look like the same one. The detail page spells it out: "Worth then $X · $Y now". Fiat is a third position on the unit toggle rather than a second number beside the first: sats → BTC → your money → sats. One figure, and which figure is your choice. The ring skips fiat entirely until a price has been fetched — a third position showing blanks is worse than two that work. Typing an amount with the unit on USD means dollars, priced now. The rates are coinos' own endpoint, a map of every currency it knows, so there's no third party watching what the wallet asks about; the currency picker under Settings → Network lists all of them. Changing it starts the recorded history again rather than relabelling samples taken in another currency. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx
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
+ }
+}