diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/app.js | 88 | ||||
| -rw-r--r-- | src/features/ark.js | 8 | ||||
| -rw-r--r-- | src/format.js | 10 | ||||
| -rw-r--r-- | src/i18n.js | 5 | ||||
| -rw-r--r-- | src/rates.js | 128 |
5 files changed, 221 insertions, 18 deletions
@@ -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 + } +} |
