diff options
| author | Adam Soltys <[email protected]> | 2026-06-27 08:40:41 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-06-27 08:40:41 -0700 |
| commit | a9a903e7a1f80ed4a6f4163419b25d4e6de5db01 (patch) | |
| tree | 8598b89bfd414b7fb4323fee506bcdb98dfb1143 /src | |
| parent | 291e03eb15a97fdaaa18537cc4a811b6e6004a8f (diff) | |
| download | coinosv3-a9a903e7a1f80ed4a6f4163419b25d4e6de5db01.tar.gz coinosv3-a9a903e7a1f80ed4a6f4163419b25d4e6de5db01.zip | |
Add regtest network support
New `regtest` network (bech32 `bcrt`, testnet version bytes) selectable in
Settings, pointed at a configurable local Esplora (default http://localhost:3000).
Adds getNetwork/setNetwork + getRegtestEsplora persistence, a regtest branch in
resolveHosts, mainnet-only gating for the realtime watcher + Electrum backend,
and a Network selector card. Account loading reads the active network.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Diffstat (limited to 'src')
| -rw-r--r-- | src/api.js | 33 | ||||
| -rw-r--r-- | src/app.js | 49 | ||||
| -rw-r--r-- | src/wallet.js | 8 |
3 files changed, 82 insertions, 8 deletions
@@ -32,8 +32,37 @@ export function setExplorerConfig({ server, url }) { } catch {} } +// Active Bitcoin network (mainnet | testnet | regtest), persisted globally. +const NETWORK_KEY = 'btc-wallet-network'; +export function getNetwork() { + try { + const n = localStorage.getItem(NETWORK_KEY); + if (n === 'mainnet' || n === 'testnet' || n === 'regtest') return n; + } catch {} + return 'mainnet'; +} +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 {} +} + // 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 }); @@ -138,7 +167,7 @@ export function resolveElectrumUrl() { } export function wsUrl(net) { - if (net === 'testnet') return null; + if (net !== 'mainnet') return null; // coinos watcher is mainnet-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; @@ -182,6 +211,8 @@ export class Api { return web + (this.net === 'testnet' ? '/testnet/tx/' : '/tx/') + txid; } + get isRegtest() { return this.net === 'regtest'; } + // ---- rate-aware global scheduler -------------------------------------- #schedule(task) { return new Promise((resolve, reject) => { @@ -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 } from './api.js'; +import { getExplorerConfig, setExplorerConfig, EXPLORER_PRESETS, getDataMode, setDataMode, ELECTRUM_PRESETS, getElectrumServerConfig, setElectrumServerConfig, getNetwork, setNetwork, getRegtestEsplora, setRegtestEsplora } from './api.js'; import { isSilentPaymentAddress } from './silentpay.js'; import { t, LANGS, getLang, setLang, isRTL, loadLocale } from './i18n.js'; import { @@ -629,9 +629,10 @@ async function activateAccount(acc, opts = {}) { // until the user commits (claims, or chooses to keep it / enters the wallet), // so bailing from an already-claimed gift doesn't leave an empty account. if (opts.gift) acc.provisional = true; - if (acc.type === 'watch') wallet.load({ xpub: acc.xpub, netName: 'mainnet', offline: false }); - else if (acc.xprv) wallet.load({ xprv: acc.xprv, netName: 'mainnet', offline: false }); - else wallet.load({ mnemonic: acc.mnemonic, passphrase: acc.passphrase || '', netName: 'mainnet', offline: false }); + const netName = getNetwork(); + if (acc.type === 'watch') wallet.load({ xpub: acc.xpub, netName, offline: false }); + else if (acc.xprv) wallet.load({ xprv: acc.xprv, netName, offline: false }); + else wallet.load({ mnemonic: acc.mnemonic, passphrase: acc.passphrase || '', netName, offline: false }); // Record the account-level xpub so this wallet survives a session wipe as a // watch-only entry (see the durable account directory) — you keep seeing your // balance/history and re-enter the seed to spend again. @@ -1140,7 +1141,7 @@ function lock() { wallet.stopRealtime(); clearAccounts(); vaultPassword = null; - wallet.load({ mnemonic: '', passphrase: '', netName: 'mainnet', offline: false }); + wallet.load({ mnemonic: '', passphrase: '', netName: getNetwork(), offline: false }); wallet.mnemonic = ''; ui.screen = hasVault() ? 'vault' : 'unlock'; ui.unlockTab = 'create'; @@ -1320,6 +1321,7 @@ function settingsTab() { : h('button', { onClick: () => { ui.addrScan = true; ui.addrScanPage = 0; render(); } }, t('rescanAddresses')) ), autoLockCard(), + networkCard(), explorerCard(), wallet.watchOnly || !wallet.mnemonic ? null : syncCard() ); @@ -1551,6 +1553,43 @@ function autoLockCard() { ); } +// 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) { + if (net === getNetwork()) return; + setNetwork(net); + const acc = accounts.find((a) => a.id === activeId); + 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). +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('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('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).') + ) + : null + ); +} + function explorerCard() { const mode = getDataMode(); const cfg = getExplorerConfig(); diff --git a/src/wallet.js b/src/wallet.js index 33a1f3f..a721c00 100644 --- a/src/wallet.js +++ b/src/wallet.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 }, + // 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 }, }; export function newMnemonic(strengthBits = 128) { @@ -192,7 +195,7 @@ export class Wallet { this.api = new ElectrumApi({ call: (m, p) => this._rpcCall(m, p), network: this.netCfg.net, - testnet: this.netName === 'testnet', + testnet: this.netName !== 'mainnet', explorerWeb: explorerWeb(), }); } else { @@ -1266,7 +1269,8 @@ export class Wallet { // and balances update with no polling. wsUrl() { // Electrum backend: pick the current failover candidate (rotated on failure). - if (getBackend() === 'electrum' && this.netName !== 'testnet') { + // Electrum is mainnet-only here; testnet/regtest use esplora + polling. + if (getBackend() === 'electrum' && this.netName === 'mainnet') { if (!this._wsCandidates || !this._wsCandidates.length) { this._wsCandidates = electrumCandidates(); this._wsCandIdx = 0; } const list = this._wsCandidates; return list.length ? list[(this._wsCandIdx || 0) % list.length] : null; |
