aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-06-26 09:20:53 -0700
committerAdam Soltys <[email protected]>2026-06-26 09:20:53 -0700
commitf090a29e9a208e67716cd61e43443ec7e095002f (patch)
tree813365515804be8693cbb3462e888543406f714e
parenteef3193729ccea2c3d712cda9381c24325fac260 (diff)
downloadcoinosv3-f090a29e9a208e67716cd61e43443ec7e095002f.tar.gz
coinosv3-f090a29e9a208e67716cd61e43443ec7e095002f.zip
Stage Electrum failover + coinos-primary cutover switch
Failover (live now): the Electrum backend now tries an ordered list of servers, advancing to the next when one fails to connect or dies within ~5s. Named public presets fall over to the other public servers; a custom your-node URL does NOT fall over (won't silently leak your addresses to a server you didn't pick). Verified: dead primary → auto-advances to a working server. Cutover switch (dormant): COINOS_ELECTRUM (false) flips 'coinos' mode from explorer-REST + watcher push to a full Electrum backend pointed at wss://coinos.io/electrum, with the public servers as failover, and disables the watcher for coinos mode. Flip it true once Fulcrum serves data behind that endpoint — no new logic needed at cutover. Flag off = current behavior exactly (default coinos → esplora + watcher), so this ships safely while Fulcrum syncs. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
-rw-r--r--src/api.js43
-rw-r--r--src/wallet.js19
2 files changed, 50 insertions, 12 deletions
diff --git a/src/api.js b/src/api.js
index 03db6b9..1ab8c0a 100644
--- a/src/api.js
+++ b/src/api.js
@@ -82,9 +82,22 @@ export function getDataMode() {
export function setDataMode(m) {
try { localStorage.setItem(DATA_MODE_KEY, m === 'electrum' || m === 'explorer' ? m : 'coinos'); } catch {}
}
-export function getBackend() { return getDataMode() === 'electrum' ? 'electrum' : 'esplora'; }
-// The coinos watcher is used only in 'coinos' mode; 'explorer' polls instead.
-export function getRealtimeEnabled() { return getDataMode() === 'coinos'; }
+// 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() {
+ const m = getDataMode();
+ if (m === 'electrum') return 'electrum';
+ if (m === 'coinos' && COINOS_ELECTRUM) return 'electrum';
+ return '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; }
// Electrum servers that accept WebSocket connections. blockstream.info is
// electrs (validated); the point, though, is pointing at your own Fulcrum/electrs.
@@ -107,18 +120,28 @@ export function getElectrumServerConfig() {
export function setElectrumServerConfig({ server, url }) {
try { localStorage.setItem(ELECTRUM_SERVER_KEY, JSON.stringify({ server, url: url || '' })); } catch {}
}
-export function resolveElectrumUrl() {
+// 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 (getDataMode() === 'coinos' && COINOS_ELECTRUM) return [COINOS_ELECTRUM_URL, ...PUBLIC_ELECTRUM()];
const cfg = getElectrumServerConfig();
- if (cfg.server === 'custom') return cfg.url.trim() || null;
- const p = ELECTRUM_PRESETS.find((x) => x.id === cfg.server);
- return (p && p.url) || ELECTRUM_PRESETS[0].url;
+ 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)];
+}
+export function resolveElectrumUrl() {
+ return electrumCandidates()[0] || null;
}
export function wsUrl(net) {
if (net === 'testnet') return null;
- // Electrum backend: the socket IS the data source, so it's always wanted
- // (independent of the coinos-notification opt-out, which only governs Esplora).
- if (getBackend() === 'electrum') return resolveElectrumUrl();
+ // 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();
}
diff --git a/src/wallet.js b/src/wallet.js
index a6dd7e3..e146b34 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 } from './api.js';
+import { Api, pool, wsUrl, 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';
@@ -1235,7 +1235,13 @@ export class Wallet {
// Pushes us new mempool/confirmed transactions for our addresses so history
// and balances update with no polling.
wsUrl() {
- return wsUrl(this.netName);
+ // Electrum backend: pick the current failover candidate (rotated on failure).
+ if (getBackend() === 'electrum' && this.netName !== 'testnet') {
+ 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;
+ }
+ return wsUrl(this.netName); // coinos watcher, or null
}
// What realtime watches: the fresh frontier (next receive + next change), plus
@@ -1353,6 +1359,8 @@ export class Wallet {
startRealtime() {
if (this.offline) return;
this.stopRealtime();
+ this._wsCandidates = null; // rebuild the failover list fresh (picks up config), prefer primary
+ this._wsCandIdx = 0;
// Frontier poll — now purely a safety net. The watcher pushes incoming
// payments AND confirmations instantly with full data (credited via the WS
// with no REST), so when the socket is live we poll rarely (~30s) just in
@@ -1437,9 +1445,11 @@ export class Wallet {
this._ws = ws;
this._wsBuf = ''; // partial-line accumulator (newline-delimited JSON-RPC)
this._subscribed = new Set(); // scripthashes subscribed on this connection
+ this._wsOpenedAt = 0; // set on open; used to judge a healthy connection for failover
if (this._rpcId == null) this._rpcId = 0; // monotonic across reconnects (don't alias pending ids)
ws.onopen = () => {
this.live = true;
+ this._wsOpenedAt = Date.now();
this._wsBackoff = 0; // healthy again — reset the reconnect backoff
this._lastMsg = Date.now();
// Electrum handshake, then subscribe our watched scripthashes.
@@ -1566,6 +1576,11 @@ export class Wallet {
_scheduleReconnect() {
if (!this._wsWant) return;
+ // Electrum failover: if this connection never became healthy (failed to open,
+ // or died within a few seconds), advance to the next candidate server.
+ if (getBackend() === 'electrum' && this._wsCandidates && this._wsCandidates.length > 1) {
+ if (!this._wsOpenedAt || Date.now() - this._wsOpenedAt < 5000) this._wsCandIdx = (this._wsCandIdx || 0) + 1;
+ }
clearTimeout(this._wsRetry);
// Exponential backoff starting small, so a first attempt that's transiently
// rejected (e.g. a stale rate-limit) retries in ~0.6s instead of a flat 4s —