From 2ab85e1335989f96b0beafe615d64c3a29fc0446 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sat, 27 Jun 2026 11:17:30 -0700 Subject: Cut 'coinos' data source over to our own Fulcrum (Electrum) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flip COINOS_ELECTRUM on: the default 'coinos' mode is now a full Electrum backend served same-origin at wss://halwallet.app/electrum (nginx -> Fulcrum on cs, synced + using bitcoind hashblock ZMQ), with the public Electrum servers as automatic failover. Replaces the explorer-REST-data + coinos-watcher-push setup — one connection to our own node now serves both data and instant notifications. Verified the endpoint end-to-end through Cloudflare (server.version + get_balance). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/api.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) (limited to 'src') diff --git a/src/api.js b/src/api.js index bcc5f85..f364e92 100644 --- a/src/api.js +++ b/src/api.js @@ -111,12 +111,12 @@ export function getDataMode() { export function setDataMode(m) { try { localStorage.setItem(DATA_MODE_KEY, m === 'electrum' || m === 'explorer' ? m : 'coinos'); } catch {} } -// Cutover switch: flip to true once coinos runs a real Electrum server (Fulcrum) -// behind the /electrum endpoint. Until then 'coinos' mode = explorer REST data + -// coinos watcher push (the shim can't serve data). When true, 'coinos' becomes a -// full Electrum backend pointed at coinos, with public servers as failover. -export const COINOS_ELECTRUM = false; -const COINOS_ELECTRUM_URL = 'wss://coinos.io/electrum'; +// 'coinos' data-source mode is now a full Electrum backend pointed at our own +// Fulcrum (served same-origin at /electrum, proxied to the Fulcrum on cs), with +// the public Electrum servers as automatic failover. Set false to revert to the +// old explorer-REST-data + coinos-watcher-push behaviour. +export const COINOS_ELECTRUM = true; +const COINOS_ELECTRUM_URL = 'wss://halwallet.app/electrum'; export function getBackend() { // Regtest runs Fulcrum (Electrum over WS) for both data and realtime push — -- cgit v1.2.3 From 80f254f106eed65e24e1ff496283846e32b680e7 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sat, 27 Jun 2026 11:18:42 -0700 Subject: Update coinos data-source copy now that it's our own Fulcrum node Co-Authored-By: Claude Opus 4.8 (1M context) --- src/i18n.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/i18n.js b/src/i18n.js index 1a85014..893ba95 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -291,7 +291,7 @@ const STR = { dataSource: 'Data source', dataSourceDesc: 'Where Hal reads the chain and how it learns about incoming payments.', modeCoinos: 'coinos (instant)', - modeCoinosDesc: 'The default. Instant payment alerts via coinos, with chain data from a public block explorer.', + modeCoinosDesc: "The default. Chain data and instant payments come from coinos's own Bitcoin node, with public servers as automatic backup.", modeExplorer: 'Block explorer', modeExplorerDesc: 'Reads from your chosen block explorer and polls for payments — nothing is sent to coinos. A bit slower.', modeElectrum: 'Electrum server', -- cgit v1.2.3 From d4700eed2fe48178e85d264e7c4d615cf36e77c9 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sat, 27 Jun 2026 11:33:55 -0700 Subject: Make coinos the default Electrum server instead of a separate mode The data-source picker is now just Electrum vs Block explorer; coinos is the first/default entry in the Electrum server list (our own Fulcrum), with public servers as failover. Drops the standalone 'coinos' mode and the retired watcher shim. Old 'coinos' selections migrate to electrum. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/api.js | 50 +++++++++++++++++++++----------------------------- src/app.js | 18 +++++------------- 2 files changed, 26 insertions(+), 42 deletions(-) (limited to 'src') diff --git a/src/api.js b/src/api.js index f364e92..0adda52 100644 --- a/src/api.js +++ b/src/api.js @@ -89,51 +89,44 @@ export function getElectrumWsUrl() { return 'wss://coinos.io/electrum'; } -// One "data source" setting drives both where chain data comes from and whether -// coinos sees your addresses for instant push: -// coinos — block-explorer data + coinos instant-payment notifications (default) -// explorer — block-explorer data only; polls for payments, nothing sent to coinos -// electrum — a single Electrum-over-WS server for both data and push (your node) -// getBackend()/getRealtimeEnabled() derive from it, so the rest of the code (and -// the wsUrl logic) is unchanged. Existing per-feature settings migrate in. +// The "data source" setting picks where chain data + payment notifications come +// from: +// electrum — an Electrum-over-WS server for both data and instant push. The +// default server is coinos's own Fulcrum node (see ELECTRUM_PRESETS), +// with public servers as failover; or point it at your own node. +// explorer — block-explorer REST data only; polls for payments, no node. +// getBackend() derives from it. Old 'coinos' mode maps to 'electrum' — coinos is +// now simply the default Electrum server rather than a separate mode. const DATA_MODE_KEY = 'btc-wallet-mode'; -const BACKEND_KEY = 'btc-wallet-backend'; // legacy (migration only) const REALTIME_KEY = 'btc-wallet-realtime'; // legacy (migration only) export function getDataMode() { try { const m = localStorage.getItem(DATA_MODE_KEY); - if (m === 'coinos' || m === 'explorer' || m === 'electrum') return m; - if (localStorage.getItem(BACKEND_KEY) === 'electrum') return 'electrum'; + if (m === 'electrum' || m === 'explorer') return m; + if (m === 'coinos') return 'electrum'; // coinos is now the default Electrum server if (localStorage.getItem(REALTIME_KEY) === 'off') return 'explorer'; } catch {} - return 'coinos'; + return 'electrum'; } export function setDataMode(m) { - try { localStorage.setItem(DATA_MODE_KEY, m === 'electrum' || m === 'explorer' ? m : 'coinos'); } catch {} + try { localStorage.setItem(DATA_MODE_KEY, m === 'explorer' ? 'explorer' : 'electrum'); } catch {} } -// 'coinos' data-source mode is now a full Electrum backend pointed at our own -// Fulcrum (served same-origin at /electrum, proxied to the Fulcrum on cs), with -// the public Electrum servers as automatic failover. Set false to revert to the -// old explorer-REST-data + coinos-watcher-push behaviour. -export const COINOS_ELECTRUM = true; -const COINOS_ELECTRUM_URL = 'wss://halwallet.app/electrum'; export function getBackend() { // Regtest runs Fulcrum (Electrum over WS) for both data and realtime push — // matches prod and drops the esplora/electrs dependency. if (getNetwork() === 'regtest') return 'electrum'; - const m = getDataMode(); - if (m === 'electrum') return 'electrum'; - if (m === 'coinos' && COINOS_ELECTRUM) return 'electrum'; - return 'esplora'; + return getDataMode() === 'electrum' ? 'electrum' : 'esplora'; } -// The coinos watcher is only used in 'coinos' mode AND only while coinos serves -// notifications via the shim (pre-Fulcrum); 'explorer' mode polls instead. -export function getRealtimeEnabled() { return getDataMode() === 'coinos' && !COINOS_ELECTRUM; } +// The legacy coinos watcher shim is retired (coinos is a full Electrum server +// now); 'explorer' mode polls for payments instead of subscribing. +export function getRealtimeEnabled() { return false; } -// Electrum servers that accept WebSocket connections. blockstream.info is -// electrs (validated); the point, though, is pointing at your own Fulcrum/electrs. +// Electrum servers that accept WebSocket connections. coinos is our own Fulcrum +// (served same-origin at /electrum) and the default; the public servers are +// failover and alternatives; or point it at your own Fulcrum/electrs. export const ELECTRUM_PRESETS = [ + { id: 'coinos', label: 'coinos', url: 'wss://halwallet.app/electrum' }, { id: 'blockstream', label: 'blockstream.info', url: 'wss://blockstream.info/electrum-websocket/' }, { id: 'electroncash', label: 'btc.electroncash.dk', url: 'wss://btc.electroncash.dk:60004' }, { id: 'blackie', label: 'blackie.c3-soft.com', url: 'wss://blackie.c3-soft.com:57004' }, @@ -147,7 +140,7 @@ export function getElectrumServerConfig() { const c = JSON.parse(localStorage.getItem(ELECTRUM_SERVER_KEY) || 'null'); if (c && c.server) return { server: c.server, url: c.url || '' }; } catch {} - return { server: 'blockstream', url: '' }; + return { server: 'coinos', url: '' }; } export function setElectrumServerConfig({ server, url }) { try { localStorage.setItem(ELECTRUM_SERVER_KEY, JSON.stringify({ server, url: url || '' })); } catch {} @@ -160,7 +153,6 @@ export function setElectrumServerConfig({ server, url }) { const PUBLIC_ELECTRUM = () => ELECTRUM_PRESETS.filter((x) => x.url).map((x) => x.url); export function electrumCandidates() { if (getNetwork() === 'regtest') return [getRegtestElectrumWs()]; // local Fulcrum - if (getDataMode() === 'coinos' && COINOS_ELECTRUM) return [COINOS_ELECTRUM_URL, ...PUBLIC_ELECTRUM()]; const cfg = getElectrumServerConfig(); if (cfg.server === 'custom') return cfg.url.trim() ? [cfg.url.trim()] : []; const primary = (ELECTRUM_PRESETS.find((x) => x.id === cfg.server) || ELECTRUM_PRESETS[0]).url; diff --git a/src/app.js b/src/app.js index 81f952c..9cf974e 100644 --- a/src/app.js +++ b/src/app.js @@ -1603,15 +1603,11 @@ function explorerCard() { const cfg = getExplorerConfig(); const ecfg = getElectrumServerConfig(); const setMode = (m) => { - const prev = getDataMode(); - if (m === prev) return; + if (m === getDataMode()) return; setDataMode(m); render(); - if (wallet.offline) return; - // coinos⇄explorer only flips the watcher (same REST data); to/from electrum - // swaps the whole backend, so rebuild + rescan there. - if ((m === 'electrum') !== (prev === 'electrum')) wallet.reloadExplorer(); - else wallet.startRealtime(); + // electrum⇄explorer swaps the whole backend, so rebuild + rescan. + if (!wallet.offline) wallet.reloadExplorer(); }; return h( 'div', @@ -1619,13 +1615,9 @@ function explorerCard() { h('h3', {}, t('dataSource')), h('p', { class: 'small muted', style: 'margin:0' }, t('dataSourceDesc')), h('select', { onChange: (e) => setMode(e.target.value) }, - h('option', { value: 'coinos', selected: mode === 'coinos' }, t('modeCoinos')), - h('option', { value: 'explorer', selected: mode === 'explorer' }, t('modeExplorer')), - h('option', { value: 'electrum', selected: mode === 'electrum' }, t('modeElectrum')) + h('option', { value: 'electrum', selected: mode === 'electrum' }, t('modeElectrum')), + h('option', { value: 'explorer', selected: mode === 'explorer' }, t('modeExplorer')) ), - mode === 'coinos' - ? h('div', { class: 'small faint' }, t('modeCoinosDesc')) - : null, mode === 'electrum' ? h('div', { class: 'col', style: 'gap:8px' }, h('div', { class: 'small faint' }, t('backendElectrumDesc')), -- cgit v1.2.3 From 3c79da0976b8b1c5c5ce7d5ea1b90221b1a96b48 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sat, 27 Jun 2026 11:45:49 -0700 Subject: Per-network data source; drop duplicate regtest fields; add mutinynet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The network card is now just the network picker. Each network's endpoints (Electrum server + block explorer) live solely in the Data source card, whose options change per network and are stored per network (so switching networks remembers each one's choice). Removes the duplicate regtest Esplora/WS fields. Adds mutinynet (signet — testnet address format, explorer at mutinynet.com). Per-network defaults: mainnet → coinos Electrum, testnet/mutinynet → explorer, regtest → local Fulcrum. Fixes tx-explorer links to use each network's web base (no double /testnet). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/api.js | 236 ++++++++++++++++++++++++++++---------------------------- src/app.js | 37 ++------- src/electrum.js | 3 +- src/wallet.js | 15 ++-- 4 files changed, 137 insertions(+), 154 deletions(-) (limited to 'src') diff --git a/src/api.js b/src/api.js index 0adda52..2095d51 100644 --- a/src/api.js +++ b/src/api.js @@ -6,38 +6,39 @@ // - a 429 from ANY request immediately backs off ALL subsequent requests // (global exponential pause), easing back as requests succeed. -// Block explorer selection (global, persisted in localStorage). Defaults to -// mempool.space, with blockstream.info as a silent failover for reliability. -// Users can pick blockstream.info only, or a custom Esplora/electrs REST URL -// (e.g. their own node) in Settings. +// Block explorer selection, stored per network (see the per-network presets + +// nk() helper below). Each network defaults to its first esplora preset; users +// can pick another or a custom Esplora/electrs REST URL (their own node). const EXPLORER_KEY = 'btc-wallet-explorer'; -export const EXPLORER_PRESETS = [ - { id: 'mempool', label: 'mempool.space' }, - { id: 'blockstream', label: 'blockstream.info' }, - { id: 'custom', label: 'Custom' }, -]; - -export function getExplorerConfig() { +export function getExplorerConfig(net = getNetwork()) { try { - const c = JSON.parse(localStorage.getItem(EXPLORER_KEY) || 'null'); + const c = JSON.parse(localStorage.getItem(nk(EXPLORER_KEY, net)) || 'null'); if (c && c.server) return { server: c.server, url: c.url || '' }; } catch {} - return { server: 'mempool', url: '' }; + return { server: netDefaults(net).explorer, url: '' }; } -export function setExplorerConfig({ server, url }) { +export function setExplorerConfig({ server, url }, net = getNetwork()) { try { - localStorage.setItem(EXPLORER_KEY, JSON.stringify({ server, url: url || '' })); + localStorage.setItem(nk(EXPLORER_KEY, net), JSON.stringify({ server, url: url || '' })); } catch {} } -// Active Bitcoin network (mainnet | testnet | regtest), persisted globally. +// Active Bitcoin network, persisted globally. mutinynet is a public signet (30s +// blocks) for testing — same address format as testnet, its own explorer at +// mutinynet.com. Data-source choices are stored per network (see nk()). const NETWORK_KEY = 'btc-wallet-network'; +export const NETWORKS = [ + { id: 'mainnet', label: 'Mainnet' }, + { id: 'testnet', label: 'Testnet' }, + { id: 'mutinynet', label: 'Mutinynet' }, + { id: 'regtest', label: 'Regtest' }, +]; export function getNetwork() { try { const n = localStorage.getItem(NETWORK_KEY); - if (n === 'mainnet' || n === 'testnet' || n === 'regtest') return n; + if (NETWORKS.some((x) => x.id === n)) return n; } catch {} return 'mainnet'; } @@ -45,147 +46,145 @@ export function setNetwork(net) { try { localStorage.setItem(NETWORK_KEY, net); } catch {} } -// Regtest points at a local Esplora-compatible server (coinos's chopsticks `cs` -// at :3000 by default). Treated like a self-hosted custom explorer: the URL is -// used as the esplora base directly (no `/api` prefix, no `/testnet`). -const REGTEST_ESPLORA_KEY = 'btc-wallet-regtest-esplora'; -export function getRegtestEsplora() { - try { return (localStorage.getItem(REGTEST_ESPLORA_KEY) || '').trim() || 'http://localhost:3000'; } - catch { return 'http://localhost:3000'; } -} -export function setRegtestEsplora(url) { - try { localStorage.setItem(REGTEST_ESPLORA_KEY, (url || '').trim()); } catch {} -} +// Per-network data-source presets — the single place network-specific endpoints +// live (so there are no duplicate URL fields elsewhere). Each network has its own +// Electrum servers and block explorers; the selection is stored per network, so +// switching networks remembers each one's choice. 'custom' points at your node. +const ELECTRUM_PRESETS_BY_NET = { + mainnet: [ + { id: 'coinos', label: 'coinos', url: 'wss://halwallet.app/electrum' }, + { id: 'blockstream', label: 'blockstream.info', url: 'wss://blockstream.info/electrum-websocket/' }, + { id: 'electroncash', label: 'btc.electroncash.dk', url: 'wss://btc.electroncash.dk:60004' }, + { id: 'blackie', label: 'blackie.c3-soft.com', url: 'wss://blackie.c3-soft.com:57004' }, + { id: 'jochen', label: 'electrum.jochen-hoenicke.de', url: 'wss://electrum.jochen-hoenicke.de:50010' }, + { id: 'mempoolguide', label: 'mempool.guide', url: 'wss://mempool.guide/electrum-websocket/' }, + { id: 'custom', label: 'Custom (your node)', url: '' }, + ], + testnet: [ + { id: 'blockstream', label: 'blockstream.info', url: 'wss://blockstream.info/testnet/electrum-websocket/' }, + { id: 'custom', label: 'Custom (your node)', url: '' }, + ], + mutinynet: [ + { id: 'custom', label: 'Custom (your node)', url: '' }, + ], + regtest: [ + { id: 'local', label: 'Local Fulcrum', url: 'ws://localhost:50003' }, + { id: 'custom', label: 'Custom', url: '' }, + ], +}; +const EXPLORER_PRESETS_BY_NET = { + mainnet: [ + { id: 'mempool', label: 'mempool.space', base: 'https://mempool.space/api', web: 'https://mempool.space', kind: 'mempool' }, + { id: 'blockstream', label: 'blockstream.info', base: 'https://blockstream.info/api', web: 'https://blockstream.info', kind: 'esplora' }, + { id: 'custom', label: 'Custom', base: '', web: '', kind: 'esplora' }, + ], + testnet: [ + { id: 'mempool', label: 'mempool.space', base: 'https://mempool.space/testnet/api', web: 'https://mempool.space/testnet', kind: 'mempool' }, + { id: 'blockstream', label: 'blockstream.info', base: 'https://blockstream.info/testnet/api', web: 'https://blockstream.info/testnet', kind: 'esplora' }, + { id: 'custom', label: 'Custom', base: '', web: '', kind: 'esplora' }, + ], + mutinynet: [ + { id: 'mutinynet', label: 'mutinynet.com', base: 'https://mutinynet.com/api', web: 'https://mutinynet.com', kind: 'esplora' }, + { id: 'custom', label: 'Custom', base: '', web: '', kind: 'esplora' }, + ], + regtest: [ + { id: 'local', label: 'Local (localhost:3000)', base: 'http://localhost:3000', web: 'http://localhost:3000', kind: 'esplora' }, + { id: 'custom', label: 'Custom', base: '', web: '', kind: 'esplora' }, + ], +}; +// Per-network defaults: the mode + server a network starts on before any choice. +const NET_DEFAULTS = { + mainnet: { mode: 'electrum', electrum: 'coinos', explorer: 'mempool' }, + testnet: { mode: 'explorer', electrum: 'blockstream', explorer: 'mempool' }, + mutinynet: { mode: 'explorer', electrum: 'custom', explorer: 'mutinynet' }, + regtest: { mode: 'electrum', electrum: 'local', explorer: 'local' }, +}; +export function electrumPresets(net = getNetwork()) { return ELECTRUM_PRESETS_BY_NET[net] || ELECTRUM_PRESETS_BY_NET.mainnet; } +export function explorerPresets(net = getNetwork()) { return EXPLORER_PRESETS_BY_NET[net] || EXPLORER_PRESETS_BY_NET.mainnet; } +function netDefaults(net) { return NET_DEFAULTS[net] || NET_DEFAULTS.mainnet; } +// Per-network storage keys: mainnet keeps the bare key (backward compat), others +// get a ":" suffix. +function nk(base, net) { return net === 'mainnet' ? base : `${base}:${net}`; } // Resolve the configured explorer to the host list the Api tries in order. function resolveHosts(net) { - if (net === 'regtest') { - const base = getRegtestEsplora().replace(/\/+$/, ''); - return [{ base, kind: 'esplora', web: base, cooldownUntil: 0 }]; - } - const cfg = getExplorerConfig(); - const apiPath = net === 'testnet' ? '/testnet/api' : '/api'; - const host = (web, kind) => ({ base: web + apiPath, kind, web, cooldownUntil: 0 }); + const cfg = getExplorerConfig(net); + const presets = explorerPresets(net); + const mk = (p) => ({ base: p.base, kind: p.kind, web: p.web, cooldownUntil: 0 }); if (cfg.server === 'custom' && cfg.url) { const base = cfg.url.trim().replace(/\/+$/, ''); return [{ base, kind: 'esplora', web: base.replace(/\/api$/, ''), cooldownUntil: 0 }]; } - if (cfg.server === 'blockstream') return [host('https://blockstream.info', 'esplora')]; - return [host('https://mempool.space', 'mempool'), host('https://blockstream.info', 'esplora')]; + const p = presets.find((x) => x.id === cfg.server && x.base) || presets.find((x) => x.base) || presets[0]; + // Mainnet silently fails mempool.space over to blockstream.info. + if (net === 'mainnet' && p.id === 'mempool') return [mk(p), mk(presets.find((x) => x.id === 'blockstream'))]; + return [mk(p)]; } -// Realtime notifications come from coinos's WebSocket watcher: it pushes the -// instant a tx touches a watched address (the app subscribes its frontier + -// pending scripthashes). Independent of the REST explorer choice. Mainnet only; -// testnet falls back to polling (null). Overridable for staging/testing. -const ELECTRUM_KEY = 'btc-wallet-electrum'; -export function getElectrumWsUrl() { - try { - const u = localStorage.getItem(ELECTRUM_KEY); - if (u) return u; - } catch {} - // Served as a path under coinos.io (a dedicated subdomain hit a Cloudflare - // WebSocket quirk; the main hostname proxies WS cleanly). - return 'wss://coinos.io/electrum'; -} - -// The "data source" setting picks where chain data + payment notifications come -// from: +// The "data source" setting (stored per network) picks where chain data + +// payment notifications come from: // electrum — an Electrum-over-WS server for both data and instant push. The -// default server is coinos's own Fulcrum node (see ELECTRUM_PRESETS), -// with public servers as failover; or point it at your own node. +// default server is coinos's own Fulcrum on mainnet (see the +// per-network presets), or point it at your own node. // explorer — block-explorer REST data only; polls for payments, no node. -// getBackend() derives from it. Old 'coinos' mode maps to 'electrum' — coinos is -// now simply the default Electrum server rather than a separate mode. +// getBackend() derives from it. Old global 'coinos' mode maps to 'electrum'. const DATA_MODE_KEY = 'btc-wallet-mode'; -const REALTIME_KEY = 'btc-wallet-realtime'; // legacy (migration only) -export function getDataMode() { +export function getDataMode(net = getNetwork()) { try { - const m = localStorage.getItem(DATA_MODE_KEY); + const m = localStorage.getItem(nk(DATA_MODE_KEY, net)); if (m === 'electrum' || m === 'explorer') return m; if (m === 'coinos') return 'electrum'; // coinos is now the default Electrum server - if (localStorage.getItem(REALTIME_KEY) === 'off') return 'explorer'; } catch {} - return 'electrum'; + return netDefaults(net).mode; } -export function setDataMode(m) { - try { localStorage.setItem(DATA_MODE_KEY, m === 'explorer' ? 'explorer' : 'electrum'); } catch {} +export function setDataMode(m, net = getNetwork()) { + try { localStorage.setItem(nk(DATA_MODE_KEY, net), m === 'explorer' ? 'explorer' : 'electrum'); } catch {} } export function getBackend() { - // Regtest runs Fulcrum (Electrum over WS) for both data and realtime push — - // matches prod and drops the esplora/electrs dependency. - if (getNetwork() === 'regtest') return 'electrum'; return getDataMode() === 'electrum' ? 'electrum' : 'esplora'; } // The legacy coinos watcher shim is retired (coinos is a full Electrum server // now); 'explorer' mode polls for payments instead of subscribing. export function getRealtimeEnabled() { return false; } -// Electrum servers that accept WebSocket connections. coinos is our own Fulcrum -// (served same-origin at /electrum) and the default; the public servers are -// failover and alternatives; or point it at your own Fulcrum/electrs. -export const ELECTRUM_PRESETS = [ - { id: 'coinos', label: 'coinos', url: 'wss://halwallet.app/electrum' }, - { id: 'blockstream', label: 'blockstream.info', url: 'wss://blockstream.info/electrum-websocket/' }, - { id: 'electroncash', label: 'btc.electroncash.dk', url: 'wss://btc.electroncash.dk:60004' }, - { id: 'blackie', label: 'blackie.c3-soft.com', url: 'wss://blackie.c3-soft.com:57004' }, - { id: 'jochen', label: 'electrum.jochen-hoenicke.de', url: 'wss://electrum.jochen-hoenicke.de:50010' }, - { id: 'mempoolguide', label: 'mempool.guide', url: 'wss://mempool.guide/electrum-websocket/' }, - { id: 'custom', label: 'Custom (your node)', url: '' }, -]; const ELECTRUM_SERVER_KEY = 'btc-wallet-electrum-server'; -export function getElectrumServerConfig() { +export function getElectrumServerConfig(net = getNetwork()) { try { - const c = JSON.parse(localStorage.getItem(ELECTRUM_SERVER_KEY) || 'null'); + const c = JSON.parse(localStorage.getItem(nk(ELECTRUM_SERVER_KEY, net)) || 'null'); if (c && c.server) return { server: c.server, url: c.url || '' }; } catch {} - return { server: 'coinos', url: '' }; + return { server: netDefaults(net).electrum, url: '' }; } -export function setElectrumServerConfig({ server, url }) { - try { localStorage.setItem(ELECTRUM_SERVER_KEY, JSON.stringify({ server, url: url || '' })); } catch {} +export function setElectrumServerConfig({ server, url }, net = getNetwork()) { + try { localStorage.setItem(nk(ELECTRUM_SERVER_KEY, net), JSON.stringify({ server, url: url || '' })); } catch {} } // Ordered Electrum WS candidates for the current selection — the wallet tries -// them in turn, advancing when one fails to connect. A named public preset (and -// coinos, post-Fulcrum) falls over to the other public servers for resilience; -// a custom your-node URL does NOT fall over — we won't silently leak your -// addresses to a public server you didn't pick. -const PUBLIC_ELECTRUM = () => ELECTRUM_PRESETS.filter((x) => x.url).map((x) => x.url); -export function electrumCandidates() { - if (getNetwork() === 'regtest') return [getRegtestElectrumWs()]; // local Fulcrum - const cfg = getElectrumServerConfig(); +// them in turn, advancing when one fails to connect. A named public preset falls +// over to the network's other public servers for resilience; a custom your-node +// URL does NOT fall over — we won't silently leak your addresses to a server you +// didn't pick. +const PUBLIC_ELECTRUM = (net) => electrumPresets(net).filter((x) => x.url).map((x) => x.url); +export function electrumCandidates(net = getNetwork()) { + const cfg = getElectrumServerConfig(net); + const presets = electrumPresets(net); if (cfg.server === 'custom') return cfg.url.trim() ? [cfg.url.trim()] : []; - const primary = (ELECTRUM_PRESETS.find((x) => x.id === cfg.server) || ELECTRUM_PRESETS[0]).url; - return [primary, ...PUBLIC_ELECTRUM().filter((u) => u !== primary)]; + const primary = (presets.find((x) => x.id === cfg.server) || presets[0]).url; + if (!primary) return []; + return [primary, ...PUBLIC_ELECTRUM(net).filter((u) => u !== primary)]; } export function resolveElectrumUrl() { return electrumCandidates()[0] || null; } -// Regtest realtime push: a local electrs reached over a WS<->TCP bridge (electrs -// is TCP-only). Same Electrum protocol the watcher already speaks, so the wallet -// subscribes scripthashes and reconciles via esplora on each notification. -const REGTEST_ELECTRUM_KEY = 'btc-wallet-regtest-electrum'; -export function getRegtestElectrumWs() { - try { return (localStorage.getItem(REGTEST_ELECTRUM_KEY) || '').trim() || 'ws://localhost:50003'; } - catch { return 'ws://localhost:50003'; } -} -export function setRegtestElectrumWs(url) { try { localStorage.setItem(REGTEST_ELECTRUM_KEY, (url || '').trim()); } catch {} } - -export function wsUrl(net) { - // Regtest gets realtime via the local electrs WS bridge (data still via esplora). - if (net === 'regtest') return getRealtimeEnabled() ? getRegtestElectrumWs() : null; - if (net !== 'mainnet') return null; // testnet: poll-only - // Electrum backend selects/rotates candidates in the wallet; here we only - // resolve the coinos watcher (esplora 'coinos' mode) or null (esplora 'explorer'). - if (getBackend() === 'electrum') return null; - if (!getRealtimeEnabled()) return null; - return getElectrumWsUrl(); -} +// The Electrum backend (data + realtime push) selects/rotates candidates in the +// wallet via electrumCandidates(); the esplora backend has no push and polls. So +// there's no separate watcher URL anymore — wsUrl is always null. +export function wsUrl() { return null; } // Web base for tx links (Electrum has no web UI — reuse the chosen explorer host). -export function explorerWeb() { - const hosts = resolveHosts('mainnet'); +export function explorerWeb(net = getNetwork()) { + const hosts = resolveHosts(net); return (hosts[0] && hosts[0].web) || 'https://mempool.space'; } @@ -215,8 +214,9 @@ export class Api { } explorerTx(txid) { + // web already carries any network path prefix (e.g. .../testnet, mutinynet.com). const web = (this._hosts[0] && this._hosts[0].web) || 'https://mempool.space'; - return web + (this.net === 'testnet' ? '/testnet/tx/' : '/tx/') + txid; + return web + '/tx/' + txid; } get isRegtest() { return this.net === 'regtest'; } diff --git a/src/app.js b/src/app.js index 9cf974e..028f236 100644 --- a/src/app.js +++ b/src/app.js @@ -8,7 +8,7 @@ import { Wallet, newMnemonic, isValidMnemonic, accountXpubFor, cacheKeyFor, utxo import { qrSvg } from './qr.js'; import { scanQr } from './scan.js'; import { getSyncConfig, setSyncConfig } from './nostr.js'; -import { getExplorerConfig, setExplorerConfig, EXPLORER_PRESETS, getDataMode, setDataMode, ELECTRUM_PRESETS, getElectrumServerConfig, setElectrumServerConfig, getNetwork, setNetwork, getRegtestEsplora, setRegtestEsplora, getRegtestElectrumWs, setRegtestElectrumWs } from './api.js'; +import { getExplorerConfig, setExplorerConfig, explorerPresets, getDataMode, setDataMode, electrumPresets, getElectrumServerConfig, setElectrumServerConfig, getNetwork, setNetwork, NETWORKS } from './api.js'; import { isSilentPaymentAddress } from './silentpay.js'; import { t, LANGS, getLang, setLang, isRTL, loadLocale } from './i18n.js'; import { @@ -1562,43 +1562,22 @@ function changeNetwork(net) { if (acc) activateAccount(acc); else render(); } -// Network selector (mainnet / testnet / regtest). Regtest also exposes the -// Esplora base URL (default coinos chopsticks at http://localhost:3000). +// Network selector. Per-network endpoints (Electrum server / block explorer) are +// configured in the Data source card below — no duplicate URL fields here. function networkCard() { const net = getNetwork(); return h( 'div', { class: 'card col' }, h('h3', {}, 'Network'), - h('p', { class: 'small muted', style: 'margin:0' }, 'Bitcoin network this wallet operates on.'), + h('p', { class: 'small muted', style: 'margin:0' }, 'Bitcoin network this wallet operates on. Pick its servers under Data source.'), h('select', { onChange: (e) => changeNetwork(e.target.value) }, - h('option', { value: 'mainnet', selected: net === 'mainnet' }, 'Mainnet'), - h('option', { value: 'testnet', selected: net === 'testnet' }, 'Testnet'), - h('option', { value: 'regtest', selected: net === 'regtest' }, 'Regtest') - ), - net === 'regtest' - ? h('div', { class: 'col', style: 'gap:10px' }, - h('label', { class: 'field' }, - h('span', { class: 'lab' }, 'Regtest Esplora URL'), - h('input', { - type: 'text', class: 'mono-input', placeholder: 'http://localhost:3000', - autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', value: getRegtestEsplora(), - onChange: (e) => { setRegtestEsplora(e.target.value.trim()); wallet.reloadExplorer(); }, - }), - h('div', { class: 'small faint' }, 'Esplora-compatible REST endpoint (coinos chopsticks/electrs).')), - h('label', { class: 'field' }, - h('span', { class: 'lab' }, 'Regtest realtime (Electrum WS)'), - h('input', { - type: 'text', class: 'mono-input', placeholder: 'ws://localhost:50003', - autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', value: getRegtestElectrumWs(), - onChange: (e) => { setRegtestElectrumWs(e.target.value.trim()); wallet.reloadExplorer(); }, - }), - h('div', { class: 'small faint' }, 'Electrum-over-WebSocket bridge (ews) for instant updates; falls back to polling if unreachable.'))) - : null + NETWORKS.map((n) => h('option', { value: n.id, selected: net === n.id }, n.label))) ); } function explorerCard() { + const net = getNetwork(); const mode = getDataMode(); const cfg = getExplorerConfig(); const ecfg = getElectrumServerConfig(); @@ -1623,7 +1602,7 @@ function explorerCard() { h('div', { class: 'small faint' }, t('backendElectrumDesc')), h('select', { onChange: (e) => { setElectrumServerConfig({ server: e.target.value, url: ecfg.url }); render(); if (e.target.value !== 'custom' || ecfg.url) wallet.reloadExplorer(); }, - }, ELECTRUM_PRESETS.map((o) => h('option', { value: o.id, selected: o.id === ecfg.server }, o.label))), + }, electrumPresets(net).map((o) => h('option', { value: o.id, selected: o.id === ecfg.server }, o.label))), ecfg.server === 'custom' ? h('label', { class: 'field' }, h('span', { class: 'lab' }, t('electrumUrl')), @@ -1642,7 +1621,7 @@ function explorerCard() { h('div', { class: 'small faint' }, t('modeExplorerDesc')), h('select', { onChange: (e) => { setExplorerConfig({ server: e.target.value, url: cfg.url }); render(); if (e.target.value !== 'custom' || cfg.url) wallet.reloadExplorer(); }, - }, EXPLORER_PRESETS.map((o) => h('option', { value: o.id, selected: o.id === cfg.server }, o.label))), + }, explorerPresets(net).map((o) => h('option', { value: o.id, selected: o.id === cfg.server }, o.label))), cfg.server === 'custom' ? h('label', { class: 'field' }, h('span', { class: 'lab' }, t('explorerUrl')), diff --git a/src/electrum.js b/src/electrum.js index 57f8c34..b8ca164 100644 --- a/src/electrum.js +++ b/src/electrum.js @@ -52,7 +52,8 @@ export class ElectrumApi { } explorerTx(txid) { - return this._explorerWeb + (this.testnet ? '/testnet/tx/' : '/tx/') + txid; + // explorerWeb already carries any network path prefix (e.g. .../testnet). + return this._explorerWeb + '/tx/' + txid; } _sh(address) { diff --git a/src/wallet.js b/src/wallet.js index 3c88b77..5882ee2 100644 --- a/src/wallet.js +++ b/src/wallet.js @@ -21,7 +21,7 @@ import * as btc from '@scure/btc-signer'; import { p2wpkh } from '@scure/btc-signer/payment'; import { concatBytes } from '@scure/btc-signer/utils.js'; -import { Api, pool, wsUrl, getBackend, explorerWeb, electrumCandidates } from './api.js'; +import { Api, pool, getBackend, explorerWeb, electrumCandidates } from './api.js'; import { ElectrumApi } from './electrum.js'; import { NostrSync, getSyncConfig } from './nostr.js'; import { isSilentPaymentAddress, decodeSilentPaymentAddress, silentPaymentScripts, silentPaymentPlaceholder } from './silentpay.js'; @@ -41,6 +41,9 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const NETS = { mainnet: { net: btc.NETWORK, coin: 0 }, testnet: { net: btc.TEST_NETWORK, coin: 1 }, + // mutinynet is a signet — same address format / version bytes / coin type as + // testnet (`tb` HRP); only the chain + servers differ. + mutinynet: { net: btc.TEST_NETWORK, coin: 1 }, // Regtest shares testnet's version bytes / coin type; only the bech32 HRP // differs (`bcrt`). @scure/btc-signer derives addresses + validates from this. regtest: { net: { ...btc.TEST_NETWORK, bech32: 'bcrt' }, coin: 1 }, @@ -1268,14 +1271,14 @@ export class Wallet { // Pushes us new mempool/confirmed transactions for our addresses so history // and balances update with no polling. wsUrl() { - // Electrum backend: pick the current failover candidate (rotated on failure). - // Mainnet (when cut over to Fulcrum) and regtest (local Fulcrum) use Electrum. - if (getBackend() === 'electrum' && (this.netName === 'mainnet' || this.netName === 'regtest')) { - if (!this._wsCandidates || !this._wsCandidates.length) { this._wsCandidates = electrumCandidates(); this._wsCandIdx = 0; } + // Electrum backend (any network): pick the current failover candidate for the + // active network (rotated on failure). Esplora backend has no push → poll. + if (getBackend() === 'electrum') { + if (!this._wsCandidates || !this._wsCandidates.length) { this._wsCandidates = electrumCandidates(this.netName); this._wsCandIdx = 0; } const list = this._wsCandidates; return list.length ? list[(this._wsCandIdx || 0) % list.length] : null; } - return wsUrl(this.netName); // coinos watcher, or null + return null; } // What realtime watches: the fresh frontier (next receive + next change), plus -- cgit v1.2.3 From 76eacb57aeaddc08b122005b8c142021d0a41ed5 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sat, 27 Jun 2026 11:52:58 -0700 Subject: Add a per-wallet settings page (cog from the accounts list) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The accounts list now shows a settings cog (instead of a rename pencil) that opens a per-wallet settings page: edit name, set auto-logout (moved here from the global settings tab), reveal the recovery phrase, and show the public key (zpub) — all read from the account itself, so it works for any wallet, not just the active one. Room for more per-wallet settings later. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app.js | 88 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-- src/i18n.js | 2 ++ 2 files changed, 88 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/src/app.js b/src/app.js index 028f236..4b5ed56 100644 --- a/src/app.js +++ b/src/app.js @@ -231,6 +231,8 @@ function render() { ? walletScreen() : ui.screen === 'accounts' ? accountsScreen() + : ui.screen === 'accountSettings' + ? accountSettingsScreen() : ui.screen === 'vault' ? vaultScreen() : ui.screen === 'claim' @@ -1320,7 +1322,6 @@ function settingsTab() { ? null : h('button', { onClick: () => { ui.addrScan = true; ui.addrScanPage = 0; render(); } }, t('rescanAddresses')) ), - autoLockCard(), networkCard(), explorerCard(), wallet.watchOnly || !wallet.mnemonic ? null : syncCard() @@ -1990,7 +1991,7 @@ function accountsScreen() { class: 'linklike', style: 'text-align:left;flex:1;font-size:15px;' + (isActive ? 'font-weight:600' : ''), onClick: () => { if (isActive) { ui.screen = 'wallet'; render(); } else switchAccount(a.id); }, }, (isActive ? '● ' : '○ ') + a.label + tag), - h('button', { class: 'btn-sm', title: t('rename'), onClick: () => { ui.editId = a.id; ui.editLabel = a.label; render(); } }, '✎'), + h('button', { class: 'btn-sm', title: t('walletSettings'), onClick: () => openAccountSettings(a.id) }, '⚙'), h('button', { class: 'btn-sm', title: t('remove'), onClick: () => { ui.confirmRemove = a.id; render(); } }, '✕') ), a.type === 'full' @@ -2020,6 +2021,89 @@ function renameAccount(id) { render(); } +// Open the per-wallet settings page for an account. +function openAccountSettings(id) { + ui.settingsId = id; + ui.editLabel = null; + ui.revealShown = false; + ui.pubkeyShown = false; + ui.screen = 'accountSettings'; + render(); +} + +// Per-wallet settings: name, auto-logout, recovery phrase, and public key — for +// any account (not just the active one; full accounts keep their seed in memory). +function accountSettingsScreen() { + const a = accounts.find((x) => x.id === ui.settingsId) || activeAccount(); + if (!a) { ui.screen = 'accounts'; return accountsScreen(); } + const isWatch = a.type === 'watch' || (!a.mnemonic && !a.xprv); + const shown = ui.revealShown; + const words = a.mnemonic ? a.mnemonic.split(' ') : []; + let zpub = ''; + try { if (a.xpub) zpub = xpubToZpub(a.xpub); } catch {} + const saveName = () => { + const v = (ui.editLabel || '').trim(); + if (v && v !== a.label) { a.label = v; persistAccounts(); if (a.persisted) writeVault(); } + ui.editLabel = null; + render(); + }; + return h( + 'div', + { class: 'col', style: 'gap:16px' }, + brandHeader(false), + // Name + h('div', { class: 'card col' }, + h('h3', {}, t('walletName')), + h('div', { class: 'row gap6' }, + h('input', { type: 'text', style: 'flex:1', value: ui.editLabel != null ? ui.editLabel : a.label, + onInput: (e) => { ui.editLabel = e.target.value; }, + onKeyDown: (e) => { if (e.key === 'Enter') saveName(); } }), + h('button', { class: 'btn-sm', onClick: saveName }, t('save')) + ) + ), + // Auto-logout (per wallet) + h('div', { class: 'card col' }, + h('h3', {}, t('autolockTitle')), + h('p', { class: 'small muted', style: 'margin:0' }, t('autolockDesc')), + h('select', { onChange: (e) => { a.autoLock = Number(e.target.value) || 0; persistAccounts(); if (a.persisted) writeVault(); render(); } }, + AUTOLOCK_OPTIONS.map((o) => h('option', { value: String(o.ms), selected: o.ms === accAutoLock(a) }, t(o.label)))) + ), + // Recovery phrase + isWatch + ? h('div', { class: 'card col' }, h('h3', {}, t('recoveryPhrase')), h('p', { class: 'small muted', style: 'margin:0' }, t('watchOnlyNote'))) + : !a.mnemonic + ? h('div', { class: 'card col' }, h('h3', {}, t('importedKey')), h('p', { class: 'small muted', style: 'margin:0' }, t('importedKeyNote'))) + : h('div', { class: 'card col' }, + h('h3', {}, t('recoveryPhrase')), + h('div', { class: 'warn-box' }, t('recoveryWarn')), + h('div', { class: 'words' }, words.map((w, i) => + h('div', { class: 'w' + (shown ? '' : ' masked') }, h('span', { class: 'n' }, i + 1), h('span', { class: 't' }, shown ? w : '••••••')))), + shown && a.passphrase + ? h('div', { class: 'col gap6' }, h('span', { class: 'lab' }, t('bip39Passphrase')), h('div', { class: 'addr-box' }, a.passphrase)) + : null, + shown + ? h('div', { class: 'row gap6 wrap' }, + copyBtn(a.mnemonic, t('copyPhrase')), + a.passphrase ? copyBtn(a.passphrase, t('copyPassphrase')) : null, + h('button', { class: 'btn-sm grow', onClick: () => { ui.revealShown = false; render(); } }, t('hide'))) + : h('button', { class: 'btn-primary btn-block', onClick: () => { ui.revealShown = true; render(); } }, t('revealRecovery')) + ), + // Public key (zpub) + h('div', { class: 'card col' }, + h('h3', {}, t('publicKey')), + h('p', { class: 'small muted', style: 'margin:0' }, t('publicKeyDesc')), + ui.pubkeyShown + ? h('div', { class: 'col gap6' }, + h('div', { class: 'addr-box break', style: 'font-size:12px' }, zpub), + h('div', { class: 'row gap6 wrap' }, + copyBtn(zpub, t('copyKey')), + h('button', { class: 'btn-sm grow', onClick: () => { ui.pubkeyShown = false; render(); } }, t('hide')))) + : h('button', { class: 'btn-block', onClick: () => { ui.pubkeyShown = true; render(); } }, t('showPublicKey')) + ), + h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.editLabel = null; ui.revealShown = false; ui.pubkeyShown = false; ui.screen = 'accounts'; render(); } }, t('back')) + ); +} + // Wipe every wallet from this device: session accounts, the encrypted vault, // and saved watch-only accounts. Unbacked-up seeds are unrecoverable after this. function clearAll() { diff --git a/src/i18n.js b/src/i18n.js index 893ba95..149afdc 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -281,6 +281,8 @@ const STR = { explorerDesc: 'The server this wallet queries for balances and history. Point it at your own node for maximum privacy.', explorerUrl: 'Server URL', explorerUrlHint: 'Esplora / electrs REST base, e.g. https://mempool.space/api', + walletName: 'Wallet name', + walletSettings: 'Wallet settings', autolockTitle: 'Log out automatically', autolockDesc: "After the app has been in the background this long, log THIS wallet out — it's removed from this device (including from Save to device), so keep its recovery phrase. Other wallets aren't affected. The timer never runs while the app is open and focused.", autolockNever: 'Never', -- cgit v1.2.3 From 3f96cf4c5ee6e8304f111ce1675f64726629478e Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sat, 27 Jun 2026 11:59:58 -0700 Subject: De-dup wallet settings; reorder per-wallet page; lowercase 'this' MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The global Settings tab no longer shows the recovery phrase / public key — those live only on each wallet's settings page now (the watch-only 'load seed' upgrade stays in the global tab since it targets the active wallet). On the per-wallet page, recovery phrase moves to the top and auto-logout to the bottom. Removes the now-dead pubkeyCard/autoLockCard. Lowercases 'THIS' in the auto-logout copy. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app.js | 114 +++++++++++------------------------------------------------- src/i18n.js | 2 +- 2 files changed, 22 insertions(+), 94 deletions(-) (limited to 'src') diff --git a/src/app.js b/src/app.js index 4b5ed56..075471f 100644 --- a/src/app.js +++ b/src/app.js @@ -1257,18 +1257,11 @@ function addressScanView() { function settingsTab() { if (ui.addrScan && !wallet.offline) return addressScanView(); - const shown = ui.revealShown; - const words = wallet.mnemonic.split(' '); - const cells = words.map((w, i) => - h('div', { class: 'w' + (shown ? '' : ' masked') }, - h('span', { class: 'n' }, i + 1), - h('span', { class: 't' }, shown ? w : '••••••') - ) - ); - return h( 'div', { class: 'col', style: 'gap:16px' }, + // Recovery phrase + public key live on each wallet's own settings page now + // (Accounts → ⚙). Watch-only wallets can still add their seed here. wallet.watchOnly ? (ui.loadSeed ? loadSeedCard() @@ -1279,32 +1272,7 @@ function settingsTab() { ? h('button', { class: 'btn-primary btn-block', style: 'margin-top:4px', onClick: () => startLoadSeed() }, t('loadSeedBtn')) : null )) - : !wallet.mnemonic - ? h('div', { class: 'card col' }, - h('h3', {}, t('importedKey')), - h('p', { class: 'small muted', style: 'margin:0' }, t('importedKeyNote')) - ) - : h( - 'div', - { class: 'card col' }, - h('h3', {}, t('recoveryPhrase')), - h('div', { class: 'warn-box' }, t('recoveryWarn')), - h('div', { class: 'words' }, cells), - shown && wallet.passphrase - ? h('div', { class: 'col gap6' }, - h('span', { class: 'lab' }, t('bip39Passphrase')), - h('div', { class: 'addr-box' }, wallet.passphrase) - ) - : null, - shown - ? h('div', { class: 'row gap6 wrap' }, - copyBtn(wallet.mnemonic, t('copyPhrase')), - wallet.passphrase ? copyBtn(wallet.passphrase, t('copyPassphrase')) : null, - h('button', { class: 'btn-sm grow', onClick: () => { ui.revealShown = false; render(); } }, t('hide')) - ) - : h('button', { class: 'btn-primary btn-block', onClick: () => { ui.revealShown = true; render(); } }, t('revealRecovery')) - ), - pubkeyCard(), + : null, wallet.watchOnly ? null : h( @@ -1328,27 +1296,6 @@ function settingsTab() { ); } -// Public key export: the account zpub, for watching this wallet elsewhere -// (read-only). Gated behind a reveal since it exposes all your addresses. -function pubkeyCard() { - let zpub = ''; - try { zpub = xpubToZpub(wallet.accountXpub()); } catch {} - return h( - 'div', - { class: 'card col' }, - h('h3', {}, t('publicKey')), - h('p', { class: 'small muted', style: 'margin:0' }, t('publicKeyDesc')), - ui.pubkeyShown - ? h('div', { class: 'col gap6' }, - h('div', { class: 'addr-box break', style: 'font-size:12px' }, zpub), - h('div', { class: 'row gap6 wrap' }, - copyBtn(zpub, t('copyKey')), - h('button', { class: 'btn-sm grow', onClick: () => { ui.pubkeyShown = false; render(); } }, t('hide')) - ) - ) - : h('button', { class: 'btn-block', onClick: () => { ui.pubkeyShown = true; render(); } }, t('showPublicKey')) - ); -} // Gift link: presign a chosen amount as a #gift= PSBT that whoever opens claims // into a fresh wallet only they control. The coin is reserved until claimed. @@ -1535,25 +1482,6 @@ async function doRevoke(id) { render(); } -// Block explorer / server selection: a preset (mempool.space, blockstream.info) -// or a custom Esplora/electrs REST URL (e.g. your own node). -// Auto log-out after the app has been in the background for the chosen time — -// drops to watch-only (re-enter the seed to spend). Never counts while focused. -function autoLockCard() { - const acc = activeAccount(); - if (!acc) return null; - const cur = accAutoLock(acc); - return h( - 'div', - { class: 'card col' }, - h('h3', {}, t('autolockTitle')), - h('p', { class: 'small muted', style: 'margin:0' }, t('autolockDesc')), - h('select', { onChange: (e) => { acc.autoLock = Number(e.target.value) || 0; persistAccounts(); render(); } }, - AUTOLOCK_OPTIONS.map((o) => h('option', { value: String(o.ms), selected: o.ms === cur }, t(o.label))) - ) - ); -} - // Switch the active Bitcoin network. Changes addresses/balances entirely, so we // persist the choice and reload the active account (or empty wallet) under it. function changeNetwork(net) { @@ -2051,24 +1979,7 @@ function accountSettingsScreen() { 'div', { class: 'col', style: 'gap:16px' }, brandHeader(false), - // Name - h('div', { class: 'card col' }, - h('h3', {}, t('walletName')), - h('div', { class: 'row gap6' }, - h('input', { type: 'text', style: 'flex:1', value: ui.editLabel != null ? ui.editLabel : a.label, - onInput: (e) => { ui.editLabel = e.target.value; }, - onKeyDown: (e) => { if (e.key === 'Enter') saveName(); } }), - h('button', { class: 'btn-sm', onClick: saveName }, t('save')) - ) - ), - // Auto-logout (per wallet) - h('div', { class: 'card col' }, - h('h3', {}, t('autolockTitle')), - h('p', { class: 'small muted', style: 'margin:0' }, t('autolockDesc')), - h('select', { onChange: (e) => { a.autoLock = Number(e.target.value) || 0; persistAccounts(); if (a.persisted) writeVault(); render(); } }, - AUTOLOCK_OPTIONS.map((o) => h('option', { value: String(o.ms), selected: o.ms === accAutoLock(a) }, t(o.label)))) - ), - // Recovery phrase + // Recovery phrase (top) isWatch ? h('div', { class: 'card col' }, h('h3', {}, t('recoveryPhrase')), h('p', { class: 'small muted', style: 'margin:0' }, t('watchOnlyNote'))) : !a.mnemonic @@ -2088,6 +1999,16 @@ function accountSettingsScreen() { h('button', { class: 'btn-sm grow', onClick: () => { ui.revealShown = false; render(); } }, t('hide'))) : h('button', { class: 'btn-primary btn-block', onClick: () => { ui.revealShown = true; render(); } }, t('revealRecovery')) ), + // Name + h('div', { class: 'card col' }, + h('h3', {}, t('walletName')), + h('div', { class: 'row gap6' }, + h('input', { type: 'text', style: 'flex:1', value: ui.editLabel != null ? ui.editLabel : a.label, + onInput: (e) => { ui.editLabel = e.target.value; }, + onKeyDown: (e) => { if (e.key === 'Enter') saveName(); } }), + h('button', { class: 'btn-sm', onClick: saveName }, t('save')) + ) + ), // Public key (zpub) h('div', { class: 'card col' }, h('h3', {}, t('publicKey')), @@ -2100,6 +2021,13 @@ function accountSettingsScreen() { h('button', { class: 'btn-sm grow', onClick: () => { ui.pubkeyShown = false; render(); } }, t('hide')))) : h('button', { class: 'btn-block', onClick: () => { ui.pubkeyShown = true; render(); } }, t('showPublicKey')) ), + // Auto-logout (bottom) + h('div', { class: 'card col' }, + h('h3', {}, t('autolockTitle')), + h('p', { class: 'small muted', style: 'margin:0' }, t('autolockDesc')), + h('select', { onChange: (e) => { a.autoLock = Number(e.target.value) || 0; persistAccounts(); if (a.persisted) writeVault(); render(); } }, + AUTOLOCK_OPTIONS.map((o) => h('option', { value: String(o.ms), selected: o.ms === accAutoLock(a) }, t(o.label)))) + ), h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.editLabel = null; ui.revealShown = false; ui.pubkeyShown = false; ui.screen = 'accounts'; render(); } }, t('back')) ); } diff --git a/src/i18n.js b/src/i18n.js index 149afdc..2e2e4e7 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -284,7 +284,7 @@ const STR = { walletName: 'Wallet name', walletSettings: 'Wallet settings', autolockTitle: 'Log out automatically', - autolockDesc: "After the app has been in the background this long, log THIS wallet out — it's removed from this device (including from Save to device), so keep its recovery phrase. Other wallets aren't affected. The timer never runs while the app is open and focused.", + autolockDesc: "After the app has been in the background this long, log this wallet out — it's removed from this device (including from Save to device), so keep its recovery phrase. Other wallets aren't affected. The timer never runs while the app is open and focused.", autolockNever: 'Never', autolock1m: 'After 1 minute', autolock5m: 'After 5 minutes', -- cgit v1.2.3 From 6bb2de48cdc15a0bc41fbcf4b0e97d6ab0d948b4 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sat, 27 Jun 2026 12:02:06 -0700 Subject: Link to the current wallet's settings from the top of global Settings Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app.js | 4 ++++ 1 file changed, 4 insertions(+) (limited to 'src') diff --git a/src/app.js b/src/app.js index 075471f..da91797 100644 --- a/src/app.js +++ b/src/app.js @@ -1260,6 +1260,10 @@ function settingsTab() { return h( 'div', { class: 'col', style: 'gap:16px' }, + // Quick link to the active wallet's own settings (name, seed, pubkey, auto-logout). + activeAccount() + ? h('button', { class: 'btn-block', onClick: () => openAccountSettings(activeId) }, '⚙ ' + t('walletSettings')) + : null, // Recovery phrase + public key live on each wallet's own settings page now // (Accounts → ⚙). Watch-only wallets can still add their seed here. wallet.watchOnly -- cgit v1.2.3 From dec8bb600852f1df2a0fff3fd28fcfcb568e3013 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sat, 27 Jun 2026 12:04:00 -0700 Subject: Make the wallet-settings link a card with a clear button, not a bare heading Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app.js | 6 +++++- src/i18n.js | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) (limited to 'src') diff --git a/src/app.js b/src/app.js index da91797..69fde4d 100644 --- a/src/app.js +++ b/src/app.js @@ -1262,7 +1262,11 @@ function settingsTab() { { class: 'col', style: 'gap:16px' }, // Quick link to the active wallet's own settings (name, seed, pubkey, auto-logout). activeAccount() - ? h('button', { class: 'btn-block', onClick: () => openAccountSettings(activeId) }, '⚙ ' + t('walletSettings')) + ? h('div', { class: 'card col' }, + h('h3', {}, activeAccount().label), + h('p', { class: 'small muted', style: 'margin:0' }, t('walletSettingsDesc')), + h('button', { class: 'btn-primary btn-block', onClick: () => openAccountSettings(activeId) }, '⚙ ' + t('walletSettings')) + ) : null, // Recovery phrase + public key live on each wallet's own settings page now // (Accounts → ⚙). Watch-only wallets can still add their seed here. diff --git a/src/i18n.js b/src/i18n.js index 2e2e4e7..376837d 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -283,6 +283,7 @@ const STR = { explorerUrlHint: 'Esplora / electrs REST base, e.g. https://mempool.space/api', walletName: 'Wallet name', walletSettings: 'Wallet settings', + walletSettingsDesc: 'Name, recovery phrase, public key, and auto-logout for this wallet.', autolockTitle: 'Log out automatically', autolockDesc: "After the app has been in the background this long, log this wallet out — it's removed from this device (including from Save to device), so keep its recovery phrase. Other wallets aren't affected. The timer never runs while the app is open and focused.", autolockNever: 'Never', -- cgit v1.2.3 From 90fe7a2ca754d39bc363417180b0b8b267972adb Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sat, 27 Jun 2026 12:08:31 -0700 Subject: Allow importing a seed from a watch-only wallet's settings page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The recovery-phrase section of the per-wallet settings page now shows an Import seed action for watch-only wallets, with the load-seed card inline. The load-seed flow takes an account id (defaulting to the active wallet), so it can upgrade a specific watch-only wallet — validating the entered seed against that wallet's xpub before making it spendable. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app.js | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) (limited to 'src') diff --git a/src/app.js b/src/app.js index 69fde4d..e3664b1 100644 --- a/src/app.js +++ b/src/app.js @@ -889,13 +889,15 @@ function startSave(id) { } // --- load a seed into a watch-only account (make it spendable) -------------- function startLoadSeed(opts = {}) { - ui.loadSeed = { value: '', passphrase: '', save: !!opts.save, error: '' }; + // accId lets the per-wallet settings page import a seed for a specific + // (possibly non-active) watch-only wallet; defaults to the active one. + ui.loadSeed = { value: '', passphrase: '', save: !!opts.save, error: '', accId: opts.accId || activeId }; render(); } function cancelLoadSeed() { ui.loadSeed = null; render(); } async function doLoadSeed() { const ls = ui.loadSeed; - const acc = activeAccount(); + const acc = (ls && accounts.find((a) => a.id === ls.accId)) || activeAccount(); if (!ls || !acc || !acc.xpub) return; const raw = (ls.value || '').trim().replace(/\s+/g, ' '); if (!raw) { ls.error = t('enterSeedToSpend'); render(); return; } @@ -1963,6 +1965,7 @@ function openAccountSettings(id) { ui.editLabel = null; ui.revealShown = false; ui.pubkeyShown = false; + ui.loadSeed = null; ui.screen = 'accountSettings'; render(); } @@ -1989,7 +1992,15 @@ function accountSettingsScreen() { brandHeader(false), // Recovery phrase (top) isWatch - ? h('div', { class: 'card col' }, h('h3', {}, t('recoveryPhrase')), h('p', { class: 'small muted', style: 'margin:0' }, t('watchOnlyNote'))) + ? (ui.loadSeed && ui.loadSeed.accId === a.id + ? loadSeedCard() + : h('div', { class: 'card col' }, + h('h3', {}, t('recoveryPhrase')), + h('p', { class: 'small muted', style: 'margin:0' }, t('watchOnlyNote')), + a.xpub + ? h('button', { class: 'btn-primary btn-block', style: 'margin-top:4px', onClick: () => startLoadSeed({ accId: a.id }) }, t('loadSeedBtn')) + : null + )) : !a.mnemonic ? h('div', { class: 'card col' }, h('h3', {}, t('importedKey')), h('p', { class: 'small muted', style: 'margin:0' }, t('importedKeyNote'))) : h('div', { class: 'card col' }, @@ -2036,7 +2047,7 @@ function accountSettingsScreen() { h('select', { onChange: (e) => { a.autoLock = Number(e.target.value) || 0; persistAccounts(); if (a.persisted) writeVault(); render(); } }, AUTOLOCK_OPTIONS.map((o) => h('option', { value: String(o.ms), selected: o.ms === accAutoLock(a) }, t(o.label)))) ), - h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.editLabel = null; ui.revealShown = false; ui.pubkeyShown = false; ui.screen = 'accounts'; render(); } }, t('back')) + h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.editLabel = null; ui.revealShown = false; ui.pubkeyShown = false; ui.loadSeed = null; ui.screen = 'accounts'; render(); } }, t('back')) ); } -- cgit v1.2.3 From 5de1b09a26745f3ba1ba54e3e666266c2620352c Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sat, 27 Jun 2026 13:43:39 -0700 Subject: Harden Electrum WS recovery: fail in-flight calls on drop, faster half-open detect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After a socket drop the in-flight _rpcCall requests were never rejected (only stopRealtime did that), so dead calls held every _rpcPump slot until they timed out — stalling the post-reconnect refresh by ~10s even though the new socket and its pushes were already live. Now reject pending calls in ws.onclose and in _reconnectNow (which nulls onclose), so the pump frees instantly and the on-open refreshLive runs right away. Also tighten the half-open watchdog: ping every 10s and recycle the socket after 21s of silence (two unanswered pings) instead of 45s, so a silently-dead connection — no onclose, no online/visibility event — resumes pushing in ~20s instead of ~45s. Verified via CDP: force-closing the live socket reconnects in ~0.6s and the next deposit pushes in ~0.4s (UI ~1.2s). Co-Authored-By: Claude Opus 4.8 --- src/wallet.js | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) (limited to 'src') diff --git a/src/wallet.js b/src/wallet.js index 5882ee2..7236a9f 100644 --- a/src/wallet.js +++ b/src/wallet.js @@ -1481,16 +1481,18 @@ export class Wallet { if (typeof WebSocket === 'undefined' || !this.wsUrl()) return; this._wsWant = true; - // Heartbeat: ping every 15s; if no message (incl. pong) for 45s the socket - // is half-open (died without firing onclose) — force a reconnect. + // Heartbeat: ping every 10s; if nothing comes back (incl. our own pong) for + // 21s the socket is half-open (died without firing onclose) — recycle it. + // The trigger is two unanswered pings, so one slow round-trip won't churn, + // but a silently-dead socket now recovers in ~20s instead of ~45s. this._hbTimer = setInterval(() => { if (this.offline || !this._ws || this._ws.readyState !== 1) return; - if (Date.now() - this._lastMsg > 45000) { + if (Date.now() - this._lastMsg > 21000) { this._reconnectNow(); } else { this._rpcSend('server.ping', []); } - }, 15000); + }, 10000); // Reconnect + refresh when the tab refocuses or the network returns. if (!this._wakeHooked && typeof document !== 'undefined') { @@ -1518,6 +1520,8 @@ export class Wallet { } catch {} this._ws = null; } + // onclose was nulled above so it won't fire — fail in-flight calls here too. + this._rejectPending('socket recycled'); this.live = false; this._connectWs(); } @@ -1581,6 +1585,10 @@ export class Wallet { ws.onclose = () => { this.live = false; this._ws = null; + // Fail any in-flight data calls so the _rpcCall pump frees its slots + // immediately; otherwise dead calls hold the pump until they time out and + // the post-reconnect refresh stalls behind them (~10s of stale UI). + this._rejectPending('socket closed'); this.emit(); this._scheduleReconnect(); }; -- cgit v1.2.3 From 1dc0c188d38c9d291375601c25a0533924402359 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sat, 27 Jun 2026 13:49:48 -0700 Subject: Sort pending txs newest-first in History (by firstSeen, not blockTime) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All pending txs share blockTime 0, so _sortTxs's blockTime comparator returned 0 for every pending pair and they kept array (scan/arrival-append) order — so the most recently received tx sat at the bottom of the pending group instead of the top. Order pending txs by firstSeen descending (the timestamp we already record when a tx is first seen unconfirmed); confirmed txs still sort by block time. Also sort on cache restore so the order is right immediately, not only after the first refresh. Co-Authored-By: Claude Opus 4.8 --- src/wallet.js | 4 ++++ 1 file changed, 4 insertions(+) (limited to 'src') diff --git a/src/wallet.js b/src/wallet.js index 7236a9f..46b9f5c 100644 --- a/src/wallet.js +++ b/src/wallet.js @@ -706,6 +706,9 @@ export class Wallet { _sortTxs() { this.txs.sort((a, b) => { if (a.confirmed !== b.confirmed) return a.confirmed ? 1 : -1; // pending first + // Pending txs share blockTime 0, so order them by when we first saw them + // (newest on top); confirmed txs go by block time (newest on top). + if (!a.confirmed) return (b.firstSeen || 0) - (a.firstSeen || 0); return (b.blockTime || 0) - (a.blockTime || 0); }); } @@ -1801,6 +1804,7 @@ export class Wallet { this.change = d.change || []; this.utxos = d.utxos || []; this.txs = d.txs || []; + this._sortTxs(); // a cache saved before the firstSeen-ordering fix may be stale this.nextReceiveIndex = d.nextReceiveIndex || 0; this.nextChangeIndex = d.nextChangeIndex || 0; this.feeRates = d.feeRates || this.feeRates; -- cgit v1.2.3