aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/app.js88
-rw-r--r--src/features/ark.js8
-rw-r--r--src/format.js10
-rw-r--r--src/i18n.js5
-rw-r--r--src/rates.js128
-rw-r--r--tools/rates-test.js69
6 files changed, 290 insertions, 18 deletions
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);