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 +++++++++++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 76 insertions(+), 12 deletions(-) (limited to 'src/app.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 -- cgit v1.2.3