aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-11 22:55:14 -0700
committerAdam Soltys <[email protected]>2026-09-11 22:55:14 -0700
commite9a0aa2ca4d3aa659b579295dd983decd3729b6f (patch)
treeb237a05408a451aedd2d86be0fdda04040a37aaf /src
parentefaa9a6d0525e5fab18bb1c97da5685107506eff (diff)
downloadcoinosv3-e9a0aa2ca4d3aa659b579295dd983decd3729b6f.tar.gz
coinosv3-e9a0aa2ca4d3aa659b579295dd983decd3729b6f.zip
Every payment remembers what it was worth.
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) <[email protected]> Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx
Diffstat (limited to 'src')
-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
5 files changed, 221 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
+ }
+}