1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
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
}
}
|