aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/api.js33
-rw-r--r--src/app.js49
-rw-r--r--src/wallet.js8
3 files changed, 82 insertions, 8 deletions
diff --git a/src/api.js b/src/api.js
index 6f010cb..d135218 100644
--- a/src/api.js
+++ b/src/api.js
@@ -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) => {
diff --git a/src/app.js b/src/app.js
index 99343f7..1dbd478 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 } 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;