diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/api.js | 86 | ||||
| -rw-r--r-- | src/app.js | 56 | ||||
| -rw-r--r-- | src/i18n.js | 14 | ||||
| -rw-r--r-- | src/style.css | 5 | ||||
| -rw-r--r-- | src/wallet.js | 17 |
5 files changed, 113 insertions, 65 deletions
@@ -6,46 +6,50 @@ // - a 429 from ANY request immediately backs off ALL subsequent requests // (global exponential pause), easing back as requests succeed. -// Explorer endpoint. Defaults to the public explorers (correct for a hosted, -// multi-user deploy — each visitor uses their own IP). A specific machine whose -// IP is rate-limited can opt into a proxy at runtime: -// localStorage.setItem('explorer-proxy', 'http://51.79.52.200:8088') -// (http proxy only works when the page itself is served over http/localhost). -const PROXY = (() => { +// 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. +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() { try { - return localStorage.getItem('explorer-proxy') || ''; - } catch { - return ''; - } -})(); + const c = JSON.parse(localStorage.getItem(EXPLORER_KEY) || 'null'); + if (c && c.server) return { server: c.server, url: c.url || '' }; + } catch {} + return { server: 'mempool', url: '' }; +} -const BACKENDS = PROXY - ? { - mainnet: [ - { base: `${PROXY}/mp/api`, kind: 'mempool' }, - { base: `${PROXY}/bs/api`, kind: 'esplora' }, - ], - testnet: [ - { base: `${PROXY}/mp/testnet/api`, kind: 'mempool' }, - { base: `${PROXY}/bs/testnet/api`, kind: 'esplora' }, - ], - } - : { - mainnet: [ - { base: 'https://mempool.space/api', kind: 'mempool' }, - { base: 'https://blockstream.info/api', kind: 'esplora' }, - ], - testnet: [ - { base: 'https://mempool.space/testnet/api', kind: 'mempool' }, - { base: 'https://blockstream.info/testnet/api', kind: 'esplora' }, - ], - }; +export function setExplorerConfig({ server, url }) { + try { + localStorage.setItem(EXPLORER_KEY, JSON.stringify({ server, url: url || '' })); + } catch {} +} + +// Resolve the configured explorer to the host list the Api tries in order. +function resolveHosts(net) { + const cfg = getExplorerConfig(); + const apiPath = net === 'testnet' ? '/testnet/api' : '/api'; + const host = (web, kind) => ({ base: web + apiPath, kind, 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')]; +} -// WebSocket URL (mempool) — through the proxy when configured. +// WebSocket URL for live updates — only mempool.space exposes one, so other +// explorers fall back to polling (returns null → no socket). export function wsUrl(net) { - const path = net === 'testnet' ? '/testnet/api/v1/ws' : '/api/v1/ws'; - if (PROXY) return PROXY.replace(/^http/, 'ws') + '/mp' + path; - return 'wss://mempool.space' + path; + if (getExplorerConfig().server !== 'mempool') return null; + return 'wss://mempool.space' + (net === 'testnet' ? '/testnet/api/v1/ws' : '/api/v1/ws'); } const REQUEST_TIMEOUT_MS = 10000; @@ -55,9 +59,9 @@ export class Api { this.net = net; this.offline = false; - // Hosts are tried in order (mempool first); a host that 429s is parked on - // cooldown so we stop hammering it (Blockstream rate-limits aggressively). - this._hosts = (BACKENDS[net] || BACKENDS.mainnet).map((h) => ({ ...h, cooldownUntil: 0 })); + // Hosts are tried in order; a host that 429s is parked on cooldown so we + // stop hammering it (Blockstream rate-limits aggressively). + this._hosts = resolveHosts(net); this._timeoutMs = REQUEST_TIMEOUT_MS; // Serialized scheduler: one request at a time, min gap between starts. @@ -74,8 +78,8 @@ export class Api { } explorerTx(txid) { - const path = this.net === 'testnet' ? 'testnet/tx' : 'tx'; - return `https://mempool.space/${path}/${txid}`; + const web = (this._hosts[0] && this._hosts[0].web) || 'https://mempool.space'; + return web + (this.net === 'testnet' ? '/testnet/tx/' : '/tx/') + txid; } // ---- rate-aware global scheduler -------------------------------------- @@ -8,6 +8,7 @@ import { Wallet, newMnemonic, isValidMnemonic, utxoId } from './wallet.js'; import { qrSvg } from './qr.js'; import { scanQr } from './scan.js'; import { getSyncConfig, setSyncConfig } from './nostr.js'; +import { getExplorerConfig, setExplorerConfig, EXPLORER_PRESETS } from './api.js'; import { t, LANGS, getLang, setLang, isRTL } from './i18n.js'; import { fmtBtc, @@ -268,19 +269,9 @@ function howItWorksScreen() { h('h3', {}, t('hiwBasicsTitle')), para('hiwBasics1'), para('hiwBasics2'), + para('hiwBasics3'), para('hiwBasics4'), - h( - 'details', - { class: 'hiw-tech' }, - h('summary', {}, t('hiwTechTitle')), - h( - 'div', - { class: 'col', style: 'gap:12px;margin-top:12px' }, - para('hiwTech1'), - para('hiwTech2'), - para('hiwTech4') - ) - ) + para('hiwBasics5') ), h('button', { class: 'btn-block', onClick: back }, t('back')) ); @@ -677,6 +668,7 @@ function settingsTab() { : h('button', { disabled: wallet.scanning, onClick: () => wallet.scan() }, wallet.scanning ? t('scanning') : t('rescanWallet')) ), + explorerCard(), syncCard(), h( 'div', @@ -687,6 +679,46 @@ function settingsTab() { ); } +// Block explorer / server selection: a preset (mempool.space, blockstream.info) +// or a custom Esplora/electrs REST URL (e.g. your own node). +function explorerCard() { + const cfg = getExplorerConfig(); + return h( + 'div', + { class: 'card col' }, + h('h3', {}, t('explorer')), + h('p', { class: 'small muted', style: 'margin:0' }, t('explorerDesc')), + h( + 'select', + { + onChange: (e) => { + const server = e.target.value; + setExplorerConfig({ server, url: cfg.url }); + render(); + if (server !== 'custom' || cfg.url) wallet.reloadExplorer(); + }, + }, + EXPLORER_PRESETS.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')), + h('input', { + type: 'text', class: 'mono-input', placeholder: 'https://mempool.space/api', + autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', + value: cfg.url, + // Apply on blur/Enter, not every keystroke (each change rescans). + onChange: (e) => { + setExplorerConfig({ server: 'custom', url: e.target.value.trim() }); + wallet.reloadExplorer(); + }, + }), + h('div', { class: 'small faint' }, t('explorerUrlHint')) + ) + : null + ); +} + // Cross-device sync settings: toggle + editable relay list (default coinos). function syncCard() { const cfg = getSyncConfig(); diff --git a/src/i18n.js b/src/i18n.js index 5289974..67eb29b 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -63,12 +63,10 @@ const STR = { installApp: 'Install app', hiwBasicsTitle: 'The basics', hiwBasics1: 'When you make a wallet, the page generates twelve random words. Those words are the key to your wallet. Anyone who has them can spend the coins, so write them down on paper and keep them somewhere only you can get to.', - hiwBasics2: 'Everything happens on your device. There’s no server keeping your balance and nobody holding your coins for you. The words stay in your browser, and the page still works when you’re offline.', - hiwBasics4: 'If you lose the twelve words and don’t have a backup, the coins are gone for good. There’s no reset link and no one to call, so back them up before you put money in.', - hiwTechTitle: 'For the technically curious', - hiwTech1: 'There’s no backend. This page is one static HTML file, so nothing on a server ever touches your keys. It reads balances and history from a public block explorer (mempool.space).', - hiwTech2: 'It sticks to the usual Bitcoin standards: a 12-word BIP39 recovery phrase, with BIP84 native SegWit (P2WPKH) keys derived at m/84\'/0\'/0\'. If this wallet ever vanishes, drop the same phrase into Electrum, Sparrow, or any other standard wallet and your coins will be right there.', - hiwTech4: 'You can save this page and open it straight off your hard drive with no connection at all.', + hiwBasics2: 'If you lose the twelve words and don’t have a backup, the coins are gone for good. There’s no reset link and no one to call, so back them up before you put money in.', + hiwBasics3: 'There’s no server and no account. The whole app is a single page that runs in your browser, so nobody holds your coins and nothing on a server ever sees your keys. To show your balance and history it reads from a public block explorer (mempool.space by default, or your own node if you set one in Settings).', + hiwBasics4: 'You can even save this page and open it straight from your computer with no internet at all. It keeps working offline.', + hiwBasics5: 'It follows the usual Bitcoin standards (a 12-word BIP39 phrase and BIP84 native SegWit keys), so if this site ever disappears you can restore the same phrase in Sparrow, Electrum, or any other standard wallet and your coins will be right there.', balance: 'Balance', pending: 'Pending', tabReceive: 'Receive', @@ -153,6 +151,10 @@ const STR = { rescanDesc: 'Your current address updates automatically. Rescan only if a payment was sent to an old (reused) address and isn’t showing.', rescanWallet: 'Rescan wallet', scanning: 'Scanning…', + explorer: 'Block explorer', + explorerDesc: 'The server this wallet queries for balances and history. Use blockstream.info, or point it at your own node for maximum privacy.', + explorerUrl: 'Server URL', + explorerUrlHint: 'Esplora / electrs REST base, e.g. https://mempool.space/api', deviceSync: 'Device sync', deviceSyncDesc: 'Keep this wallet in sync across your devices by storing an encrypted copy on a Nostr relay — only a device with your seed can read it. Turn it off to keep everything on this device.', syncAcross: 'Sync across devices', diff --git a/src/style.css b/src/style.css index 136473a..a74c7e5 100644 --- a/src/style.css +++ b/src/style.css @@ -248,11 +248,6 @@ hr.divider { margin: 16px 0; } .linklike:hover { text-decoration: underline; background: none; } /* ---- how it works ---- */ -.hiw-tech {border-top: 1px solid var(--line); padding-top: 14px; } -.hiw-tech > summary { font-weight: 650; cursor: pointer; list-style: none; color: var(--accent-ink); display: flex; align-items: center; gap: 7px; } -.hiw-tech > summary::-webkit-details-marker { display: none; } -.hiw-tech > summary::before { content: '▸'; font-size: 12px; transition: transform .15s; } -.hiw-tech[open] > summary::before { transform: rotate(90deg); } @media (max-width: 420px) { .words { grid-template-columns: repeat(2, 1fr); } diff --git a/src/wallet.js b/src/wallet.js index 111fb92..0f71454 100644 --- a/src/wallet.js +++ b/src/wallet.js @@ -148,6 +148,19 @@ export class Wallet { this.emit(); } + // Rebuild the Api against the currently-configured explorer, then rescan and + // reconnect realtime (called when the user switches explorer in Settings). + async reloadExplorer() { + this.stopRealtime(); + this.api = new Api(this.netName); + this.api.offline = this.offline; + if (this.offline) return; + try { + await this.scan(); + } catch {} + this.startRealtime(); + } + get netCfg() { return NETS[this.netName]; } @@ -683,7 +696,9 @@ export class Wallet { // Periodic full scan: reconciles everything refreshLive can't see on its own // — pending→confirmed history, spends, and stale cache/relay state. this._deepTimer = setInterval(() => this.scan({ silent: true }).catch(() => {}), 120000); - if (typeof WebSocket === 'undefined') return; + // Only mempool.space has a live socket; other explorers rely on the poll + // and deep scan above (wsUrl returns null → no socket). + if (typeof WebSocket === 'undefined' || !this.wsUrl()) return; this._wsWant = true; // Heartbeat: ping every 15s; if no message (incl. pong) for 45s the socket |
