diff options
| author | Adam Soltys <[email protected]> | 2026-06-27 13:53:00 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-06-27 13:53:00 -0700 |
| commit | e27b5ac2666ada59b97bbb52dea76dcc58ace13f (patch) | |
| tree | 799cd97e989c2706cdfaa36a069cb42ec97fda64 | |
| parent | 381ca347bab721965c682a630ed61a7c236ebed5 (diff) | |
| parent | 1dc0c188d38c9d291375601c25a0533924402359 (diff) | |
| download | coinosv3-e27b5ac2666ada59b97bbb52dea76dcc58ace13f.tar.gz coinosv3-e27b5ac2666ada59b97bbb52dea76dcc58ace13f.zip | |
Merge branch 'master' into boltz-swaps
# Conflicts:
# src/app.js
| -rw-r--r-- | src/api.js | 260 | ||||
| -rw-r--r-- | src/app.js | 244 | ||||
| -rw-r--r-- | src/electrum.js | 3 | ||||
| -rw-r--r-- | src/i18n.js | 7 | ||||
| -rw-r--r-- | src/wallet.js | 35 |
5 files changed, 281 insertions, 268 deletions
@@ -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,17 +46,65 @@ 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 ":<net>" suffix. +function nk(base, net) { return net === 'mainnet' ? base : `${base}:${net}`; } // Boltz swap provider — the api-v2 REST endpoint the SwapManager talks to; the // swap-status WebSocket is derived from it (http->ws + /v2/ws) unless given. @@ -97,141 +146,83 @@ export function getBoltzWs() { const p = getBoltzProvider(); return p.ws || deri // 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')]; -} - -// 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'; + 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)]; } -// 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 (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 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 global 'coinos' mode maps to 'electrum'. 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() { +export function getDataMode(net = getNetwork()) { 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 (localStorage.getItem(REALTIME_KEY) === 'off') return 'explorer'; + 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 } catch {} - return 'coinos'; + return netDefaults(net).mode; } -export function setDataMode(m) { - try { localStorage.setItem(DATA_MODE_KEY, m === 'electrum' || m === 'explorer' ? m : 'coinos'); } catch {} +export function setDataMode(m, net = getNetwork()) { + try { localStorage.setItem(nk(DATA_MODE_KEY, net), m === 'explorer' ? 'explorer' : 'electrum'); } 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'; 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. -export const ELECTRUM_PRESETS = [ - { 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: 'blockstream', 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 - if (getDataMode() === 'coinos' && COINOS_ELECTRUM) return [COINOS_ELECTRUM_URL, ...PUBLIC_ELECTRUM()]; - 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'; } @@ -261,8 +252,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'; } @@ -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, getBoltzApi, BOLTZ_PRESETS, getBoltzProviderId, setBoltzProviderId, getBoltzCustom, setBoltzCustom } from './api.js'; +import { getExplorerConfig, setExplorerConfig, explorerPresets, getDataMode, setDataMode, electrumPresets, getElectrumServerConfig, setElectrumServerConfig, getNetwork, setNetwork, NETWORKS, getBoltzApi, BOLTZ_PRESETS, getBoltzProviderId, setBoltzProviderId, getBoltzCustom, setBoltzCustom } from './api.js'; import { SwapManager } from './swap.js'; import { isSilentPaymentAddress } from './silentpay.js'; import { t, LANGS, getLang, setLang, isRTL, loadLocale } from './i18n.js'; @@ -237,6 +237,8 @@ function render() { ? walletScreen() : ui.screen === 'accounts' ? accountsScreen() + : ui.screen === 'accountSettings' + ? accountSettingsScreen() : ui.screen === 'vault' ? vaultScreen() : ui.screen === 'claim' @@ -895,13 +897,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; } @@ -1263,18 +1267,19 @@ 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' }, + // Quick link to the active wallet's own settings (name, seed, pubkey, auto-logout). + activeAccount() + ? 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. wallet.watchOnly ? (ui.loadSeed ? loadSeedCard() @@ -1285,32 +1290,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,7 +1308,6 @@ function settingsTab() { ? null : h('button', { onClick: () => { ui.addrScan = true; ui.addrScanPage = 0; render(); } }, t('rescanAddresses')) ), - autoLockCard(), networkCard(), boltzProviderCard(), explorerCard(), @@ -1336,27 +1315,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. @@ -1543,25 +1501,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) { @@ -1605,56 +1544,31 @@ function boltzProviderCard() { ); } -// 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(); 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', @@ -1662,19 +1576,15 @@ 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')), 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')), @@ -1693,7 +1603,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')), @@ -2062,7 +1972,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' @@ -2092,6 +2002,98 @@ 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.loadSeed = null; + 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), + // Recovery phrase (top) + isWatch + ? (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' }, + 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')) + ), + // 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')), + 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')) + ), + // 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.loadSeed = null; 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/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/i18n.js b/src/i18n.js index 1a85014..376837d 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -281,8 +281,11 @@ 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', + 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.", + 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', @@ -291,7 +294,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', diff --git a/src/wallet.js b/src/wallet.js index bc24393..a92b0ce 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 }, @@ -703,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); }); } @@ -1288,14 +1294,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 @@ -1498,16 +1504,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') { @@ -1535,6 +1543,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(); } @@ -1598,6 +1608,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(); }; @@ -1810,6 +1824,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; |
