From e9a0aa2ca4d3aa659b579295dd983decd3729b6f Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Fri, 11 Sep 2026 22:55:14 -0700 Subject: Every payment remembers what it was worth. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx --- src/app.js | 88 +++++++++++++++++++++++++++++++----- src/features/ark.js | 8 ++-- src/format.js | 10 ++-- src/i18n.js | 5 ++ src/rates.js | 128 ++++++++++++++++++++++++++++++++++++++++++++++++++++ tools/rates-test.js | 69 ++++++++++++++++++++++++++++ 6 files changed, 290 insertions(+), 18 deletions(-) create mode 100644 src/rates.js create mode 100644 tools/rates-test.js diff --git a/src/app.js b/src/app.js index 1c8f2f8..6b481a5 100644 --- a/src/app.js +++ b/src/app.js @@ -15,6 +15,7 @@ import { dataSources, getSource, setSource, getNetwork, setNetwork, NETWORKS } f import { STAGING } from './build-flags.js'; import { buildFeatures, loadDeferredFeatures } from './features/index.js'; import { t, LANGS, getLang, setLang, isRTL, loadLocale } from './i18n.js'; +import { rateNow, rateAt, refreshRates, haveRate, getCurrency, setCurrency, currencies, fmtFiat } from './rates.js'; import { fmtBtc, fmtSats, @@ -790,24 +791,56 @@ function pasteInto(ta, apply) { // logouts. Every unit label on the site is clickable to toggle it. const UNIT_KEY = 'btc-wallet-unit'; let unit = (() => { - // Default to sats for first-time users; only an explicit 'btc' choice sticks. + // Default to sats for first-time users; only an explicit choice sticks. try { - return localStorage.getItem(UNIT_KEY) === 'btc' ? 'btc' : 'sats'; + const v = localStorage.getItem(UNIT_KEY); + return v === 'btc' || v === 'fiat' ? v : 'sats'; } catch { return 'sats'; } })(); +// sats → BTC → your money → sats. Fiat is a display unit like the other two +// rather than a second number beside them: the balance is one figure, and +// which figure is your choice. It's skipped entirely when no price has ever +// been fetched — a third position showing blanks is worse than two that work. function toggleUnit() { - unit = unit === 'btc' ? 'sats' : 'btc'; + const ring = haveRate() ? ['sats', 'btc', 'fiat'] : ['sats', 'btc']; + unit = ring[(ring.indexOf(unit) + 1) % ring.length]; try { localStorage.setItem(UNIT_KEY, unit); } catch {} render(); } -const unitLabel = () => (unit === 'sats' ? 'sats' : 'BTC'); -const fmtAmount = (sats) => (unit === 'sats' ? fmtSats(sats) : fmtBtc(sats)); +const unitLabel = () => (unit === 'sats' ? 'sats' : unit === 'fiat' ? getCurrency() : 'BTC'); + +// "Worth X then · Y now" for a payment's detail page — the one place both +// numbers earn their keep, because that page is where the question is asked. +// Absent when there's no price, and when the two would say the same thing. +function worthLine(sats, at, row = null) { + const then = rateAt(at); + const now = rateNow(); + if (!now || !at) return null; + const line = row || ((k, v) => h('div', { class: 'line' }, h('span', { class: 'k' }, k), h('span', { class: 'v' }, v))); + const thenStr = then.rate && !then.stale ? fmtFiat(sats, then.rate) : null; + const nowStr = fmtFiat(sats, now); + if (!thenStr) return line(t('worthNow'), nowStr); + return line(t('worthThen'), thenStr + (thenStr === nowStr ? '' : ' · ' + t('worthNowInline', { v: nowStr }))); +} +// `at` is when the money moved. In fiat it picks the price recorded at that +// moment, so a payment is shown for what it was worth then — the number the +// rest of the world remembers it by. Without it (a balance, a form) the live +// price is used. A '~' means we had no sample near that time and had to price +// it at today's, which is a different claim and shouldn't look like the same +// one. +const fmtAmount = (sats, at) => { + if (unit === 'sats') return fmtSats(sats); + if (unit === 'btc') return fmtBtc(sats); + const { rate, stale } = rateAt(at); + if (!rate) return fmtSats(sats); + return (stale && at ? '~' : '') + fmtFiat(sats, rate); +}; // A clickable unit label. cls lets callers inherit surrounding sizing. function unitTag(cls = '') { @@ -2296,7 +2329,7 @@ function settingsTab() { switch (ui.settingsPage) { case 'wallet': return page(a ? [walletNameCard(a), pubkeyCard(a), recoveryCard(a)] : []); case 'payments': return page(featureAll('settingsCards').reverse()); - case 'network': return page([networkCard(), explorerCard()]); + case 'network': return page([networkCard(), currencyCard(), explorerCard()]); case 'nostr': return nostrSettingsView(); case 'notifications': return page(featureAll('notifySettingsCards').reverse()); case 'advanced': return advancedSettingsView(); @@ -2455,6 +2488,26 @@ function explorerCard() { } +// Which money the third tap on an amount shows. The list is whatever the +// rates endpoint carries, so it's every currency coinos knows a price for. +// Changing it starts the recorded price history again: the samples behind +// "worth then" are denominated in the currency they were taken in, and +// relabelling them would be a lie. +function currencyCard() { + const codes = currencies(); + return h( + 'div', + { class: 'card col' }, + h('h3', {}, t('currencyLabel')), + h('select', { + value: getCurrency(), + onChange: (e) => { setCurrency(e.target.value); refreshRates({ force: true }).then(render); render(); }, + }, codes.map((c) => h('option', { value: c, selected: c === getCurrency() }, c))), + h('div', { class: 'small faint' }, t('currencyHint')), + rateNow() ? h('div', { class: 'small muted' }, '1 BTC = ' + fmtFiat(SATS, rateNow())) : null + ); +} + // Language selector. Changing it persists the choice, flips text direction for // RTL languages, and re-renders the whole app in the new language. function languagePicker() { @@ -4572,7 +4625,7 @@ function reviewSend() { recipients = s.recipients.map((r, i) => { const addr = r.address.trim(); if (!addr) throw new Error(t('enterAddrForN', { n: i + 1 })); - const sats = parseAmount(r.amount, unit); + const sats = parseAmount(r.amount, unit, rateNow()); if (!sats || sats <= 0) throw new Error(t('enterValidAmtForN', { n: i + 1 })); return { address: addr, amount: sats }; }); @@ -4746,8 +4799,9 @@ function txHistoryItem(tx) { h('div', { class: 'small faint' }, tx.confirmed ? timeAgo(tx.blockTime) : stuck ? t('stuckNote') : t('awaitingConfirmation')) ), h('div', { style: 'text-align:right' }, - h('div', { class: incoming ? 'amount-pos' : 'amount-neg' }, (incoming ? '+' : '') + fmtAmount(tx.net)), - !incoming && tx.fee ? h('div', { class: 'small faint' }, t('feeShort', { x: fmtAmount(tx.fee) })) : null + // priced at the moment it happened, not at today's number + h('div', { class: incoming ? 'amount-pos' : 'amount-neg' }, (incoming ? '+' : '') + fmtAmount(tx.net, tx.blockTime)), + !incoming && tx.fee ? h('div', { class: 'small faint' }, t('feeShort', { x: fmtAmount(tx.fee, tx.blockTime) })) : null ) ); } @@ -4864,14 +4918,17 @@ function txDetailView(tx) { h('span', { class: `tag ${tx.confirmed ? 'conf' : 'pending'}` }, tx.confirmed ? t('confirmedTag') : t('pendingTag')) ), h('div', { class: 'amt', style: 'font-size:30px' }, - h('span', { class: incoming ? 'amount-pos' : 'amount-neg' }, (incoming ? '+' : '') + fmtAmount(tx.net)), + h('span', { class: incoming ? 'amount-pos' : 'amount-neg' }, (incoming ? '+' : '') + fmtAmount(tx.net, tx.blockTime)), ' ', unitTag('unit') ), h('div', { class: 'summary col', style: 'gap:0' }, line(t('status'), tx.confirmed ? t('confirmed') : t('pending')), tx.confirmed ? line(t('block'), String(tx.blockHeight || '—')) : null, tx.confirmed && tx.blockTime ? line(t('date'), new Date(tx.blockTime * 1000).toLocaleString()) : null, - tx.fee ? line(t('networkFee'), fmtAmount(tx.fee) + ' ' + unitLabel()) : null + // what it was worth then, beside what it is now — the one place both + // numbers earn their keep, because this is the page you open to ask + worthLine(tx.net, tx.blockTime), + tx.fee ? line(t('networkFee'), fmtAmount(tx.fee, tx.blockTime) + ' ' + unitLabel()) : null ), ...FEATURES.map((f) => (f.txDetailSection ? f.txDetailSection(tx) : null)), !tx.confirmed && wallet.isStuck(tx) @@ -4944,7 +5001,10 @@ async function importSnapshotFile(e) { // only invoked at runtime (first render happens after loadLocale below). const ctx = { h, ui, render, wallet, toast, copy, copyBtn, pasteBtn, blankSend, goBack, goHome, openExternal, - fmtAmount, unitLabel, unitTag, parseAmount, getUnit: () => unit, toggleUnit, download, + fmtAmount, unitLabel, unitTag, getUnit: () => unit, toggleUnit, download, + // in fiat the typed figure is money, so it needs today's price to become sats + parseAmount: (v, u) => parseAmount(v, u, rateNow()), + worthLine, // "The next balance isn't a change, it's the first real value" — state // arriving late (the ark store opening past boot's patience) must not read // as money landing: without this the balance counts up from 0 in green, @@ -5204,6 +5264,10 @@ loadLocale(getLang()).finally(async () => { // counted itself up in green as if money had just come in. The shell is on // screen either way; waiting shows it a moment longer rather than showing a // wrong balance. The hook caps its own wait. + // The price, early: the unit toggle only offers fiat once we've had one, + // and a wallet opening in fiat should open with a number in it. + refreshRates().then(() => { if (unit === 'fiat') render(); }).catch(() => {}); + setInterval(() => { refreshRates().then(() => { if (unit === 'fiat') render(); }).catch(() => {}); }, 10 * 60_000); const storeReady = featureHook('arkStoreReady'); if (storeReady) await storeReady; _bootDeciding = false; // from here on the screen is a decision, not a guess diff --git a/src/features/ark.js b/src/features/ark.js index 730cccd..e460b4c 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -1224,7 +1224,7 @@ export function arkFeature(ctx) { h('div', {}, t('arkHistoryIncomplete')), h('div', { class: 'small faint' }, t('arkDetailsNotSynced'))), h('div', { style: 'text-align:right' }, - h('div', { class: 'small muted' }, fmtAmount(m.amountSat) + ' ' + unitLabel()))); + h('div', { class: 'small muted' }, fmtAmount(m.amountSat, m.ts / 1000) + ' ' + unitLabel()))); } // a renewal moves nothing anywhere — its history amount is what it COST if (m.type === 'refresh') { @@ -1266,7 +1266,8 @@ export function arkFeature(ctx) { m.status !== 'complete' && !failed ? h('span', { class: 'tag pending' }, m.status) : null), h('div', { class: 'small faint' }, timeAgo(m.ts / 1000))), h('div', { style: 'text-align:right' }, - h('div', { class: failed ? 'muted' : incoming ? 'amount-pos' : 'amount-neg' }, (failed ? '' : incoming ? '+' : '-') + fmtAmount(m.amountSat))) + // in fiat this is what the payment was worth when it was made + h('div', { class: failed ? 'muted' : incoming ? 'amount-pos' : 'amount-neg' }, (failed ? '' : incoming ? '+' : '-') + fmtAmount(m.amountSat, m.ts / 1000))) ); } @@ -1376,8 +1377,9 @@ export function arkFeature(ctx) { ? h('div', { class: m.feeSat > 0 ? 'amount-neg' : 'small faint', style: 'font-size:20px' }, m.feeSat > 0 ? '-' + fmtAmount(m.feeSat) + ' ' + unitLabel() : t('arkDepthFree')) : h('div', { class: failed ? 'muted' : incoming ? 'amount-pos' : 'amount-neg', style: 'font-size:20px' }, - (failed ? '' : incoming ? '+' : '-') + fmtAmount(m.amountSat) + ' ' + unitLabel()), + (failed ? '' : incoming ? '+' : '-') + fmtAmount(m.amountSat, m.ts / 1000) + ' ' + unitLabel()), row(t('dateLabel'), new Date(m.ts).toLocaleString()), + ctx.worthLine ? ctx.worthLine(m.amountSat, m.ts / 1000, row) : null, (() => { const pk = zapNoteFor(m); const chip = pk ? ctx.hook('profileChip', pk) : null; diff --git a/src/format.js b/src/format.js index a7f49c2..febba22 100644 --- a/src/format.js +++ b/src/format.js @@ -39,11 +39,15 @@ export function fmtUsd(sats, price) { return usd.toLocaleString('en-US', { style: 'currency', currency: 'USD' }); } -// Parse a user-entered amount. Accepts BTC (default) or sats. -export function parseAmount(value, unit) { +// Parse a user-entered amount. Accepts BTC (default), sats, or — when the +// display unit is the user's own money and a rate is passed — that: typing 5 +// with the unit on USD means five dollars' worth, priced right now. +export function parseAmount(value, unit, rate = null) { const n = Number(String(value).trim().replace(/,/g, '')); if (!isFinite(n) || n < 0) return null; - return unit === 'sats' ? Math.round(n) : Math.round(n * SATS); + if (unit === 'sats') return Math.round(n); + if (unit === 'fiat') return rate ? Math.round((n / rate) * SATS) : null; + return Math.round(n * SATS); } export function shortAddr(a, head = 10, tail = 8) { diff --git a/src/i18n.js b/src/i18n.js index 9532b2e..df7c5c8 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -193,6 +193,11 @@ const STR = { msgReact: 'React', msgZap: 'Zap', zapTallyTitle: '{n} sats zapped', + worthThen: 'Worth then', + worthNow: 'Worth now', + worthNowInline: '{v} now', + currencyLabel: 'Show amounts in', + currencyHint: 'The third tap on any amount switches to this. Payments are shown at the price when they happened.', feedTitle: 'Feed', feedFollow: 'Follow', feedFollowing: 'Following', 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 + } +} diff --git a/tools/rates-test.js b/tools/rates-test.js new file mode 100644 index 0000000..88119f3 --- /dev/null +++ b/tools/rates-test.js @@ -0,0 +1,69 @@ +// What a payment was worth WHEN IT HAPPENED. The price is sampled as the app +// runs and kept as a series; a payment's fiat value is read off the sample +// nearest its timestamp, and a payment older than anything we sampled is +// priced at today's number and SAID to be — never quietly. +// +// Run: bun tools/rates-test.js +let fails = 0; +const check = (name, ok, detail = '') => { + console.log(` ${ok ? '✓' : '✗'} ${name}${detail ? ' — ' + String(detail).slice(0, 90) : ''}`); + if (!ok) fails++; +}; +globalThis.localStorage = { + _d: {}, getItem(k) { return this._d[k] ?? null; }, setItem(k, v) { this._d[k] = String(v); }, + removeItem(k) { delete this._d[k]; }, key(i) { return Object.keys(this._d)[i] ?? null; }, + get length() { return Object.keys(this._d).length; }, +}; +const HOUR = 3600; +const now = Math.floor(Date.now() / 1000); +// a week of hourly samples, climbing from 50k to 100k +const series = []; +for (let i = 7 * 24; i >= 0; i--) series.push([now - i * HOUR, 50_000 + (7 * 24 - i) * 300]); +localStorage.setItem('btc-wallet-rate-series', JSON.stringify(series)); +localStorage.setItem('btc-wallet-rates', JSON.stringify({ USD: series[series.length - 1][1], EUR: 1234 })); + +const { rateAt, rateNow, fmtFiat, getCurrency, setCurrency, currencies, RATE_TOLERANCE } = await import('../src/rates.js'); +const { parseAmount, SATS } = await import('../src/format.js'); + +check('the live price is the newest sample', rateNow() === series[series.length - 1][1], String(rateNow())); + +// a payment three days ago is priced at the price three days ago +const threeDays = now - 3 * 24 * HOUR; +const then = rateAt(threeDays); +const want = series.find(([ts]) => ts === threeDays)[1]; +check('a payment is priced at the moment it happened', then.rate === want && !then.stale, `${then.rate} vs ${want}`); +check("...which isn't today's price", then.rate !== rateNow(), `${then.rate} vs ${rateNow()}`); + +// a payment between samples takes the nearest one +const between = rateAt(threeDays + 900); +check('between samples, the nearest one wins', between.rate === want && !between.stale, String(between.rate)); + +// older than anything we have: today's price, flagged +const ancient = rateAt(now - 400 * 24 * HOUR); +check('an older payment than we have samples for is flagged', ancient.stale === true && ancient.rate === rateNow(), JSON.stringify(ancient)); +check('...and the tolerance is a day', RATE_TOLERANCE === 24 * HOUR, String(RATE_TOLERANCE)); + +// no timestamp at all (a balance, a form) is simply the live price +check('an amount with no time is the live price', rateAt(null).rate === rateNow() && !rateAt(null).stale); + +// typing money +check('typing 5 with the unit on fiat buys five dollars of bitcoin', + parseAmount('5', 'fiat', 100_000) === Math.round((5 / 100_000) * SATS), String(parseAmount('5', 'fiat', 100_000))); +check('...and without a price it refuses rather than guessing', parseAmount('5', 'fiat', null) === null); +check('sats and BTC are untouched', parseAmount('2100', 'sats') === 2100 && parseAmount('0.001', 'btc') === 100_000); + +// formatting +check('an amount formats as money', /\$1[,.]/.test(fmtFiat(SATS / 100, 100_000)), fmtFiat(SATS / 100, 100_000)); +check('a small amount keeps more decimals', fmtFiat(100, 100_000).includes('0.10'), fmtFiat(100, 100_000)); +check('a currency Intl has never heard of still renders', /XYZ/.test(fmtFiat(SATS, 10, 'XYZ')), fmtFiat(SATS, 10, 'XYZ')); + +// changing currency drops the series rather than mislabelling it +check('the currency list comes from the rates map', currencies().includes('EUR'), currencies().join(',')); +setCurrency('EUR'); +check('changing currency starts the history again', JSON.parse(localStorage.getItem('btc-wallet-rate-series')).length <= 1, + localStorage.getItem('btc-wallet-rate-series').slice(0, 40)); +check('...and the live price follows it', rateNow() === 1234, String(rateNow())); +check('the choice sticks', getCurrency() === 'EUR'); + +console.log(fails ? `\n❌ ${fails} failed` : '\n✅ prices are remembered, not invented'); +process.exit(fails ? 1 : 0); -- cgit v1.2.3