Compare commits
4 Commits
master
...
refactor-b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d5ae31f27 | ||
|
|
e920e4d72b | ||
|
|
0ceef5fa0a | ||
|
|
aa406ae181 |
@ -100,24 +100,84 @@ export const suggestedServers: Peer[] = hardcodedPeers.map(peer => ({
|
||||
}));
|
||||
|
||||
let mainClient: typeof ElectrumClient | undefined;
|
||||
let mainConnected: boolean = false;
|
||||
let wasConnectedAtLeastOnce: boolean = false;
|
||||
let serverName: string | false = false;
|
||||
let disableBatching: boolean = false;
|
||||
let connectionAttempt: number = 0;
|
||||
let currentPeerIndex = hardcodedPeers.findIndex(peer => peer.host === defaultPeer.host && peer.ssl === defaultPeer.ssl);
|
||||
if (currentPeerIndex < 0) currentPeerIndex = 0;
|
||||
let latestBlock: { height: number; time: number } | { height: undefined; time: undefined } = { height: undefined, time: undefined };
|
||||
|
||||
const WAIT_TILL_CONNECTED_TICK_MS = 100;
|
||||
/** After at least one successful Electrum session: wall ~30s before timeout (slow reconnect). */
|
||||
const WAIT_TILL_CONNECTED_MAX_TICKS_AFTER_FIRST_CONNECT = 300;
|
||||
/** First-ever connect: wall ~60s before timeout (cold start / slow TLS / flaky network). */
|
||||
const WAIT_TILL_CONNECTED_MAX_TICKS_NEVER_CONNECTED = 600;
|
||||
// --- Single source of truth for connection liveness -----------------------------
|
||||
// We previously tracked `mainConnected` (boolean) separately from the client's own
|
||||
// `mainClient.status`. They drifted on iOS suspend/resume: a transient `ping()`
|
||||
// failure cleared the flag while the socket was still alive, then `waitTillConnected`
|
||||
// blocked for ~30s on the stale flag and surfaced a false network-error alert. The
|
||||
// state machine + `ensureConnected()` below is the only place that mutates the
|
||||
// connection lifecycle, and UI is driven by subscribing to state changes.
|
||||
|
||||
/** Max wall time for one `waitTillConnected` wait (ms); derived from ticks above for callers (e.g. refresh fetch race). */
|
||||
export const WAIT_TILL_CONNECTED_MAX_WALL_MS_AFTER_FIRST = WAIT_TILL_CONNECTED_MAX_TICKS_AFTER_FIRST_CONNECT * WAIT_TILL_CONNECTED_TICK_MS;
|
||||
export const WAIT_TILL_CONNECTED_MAX_WALL_MS_NEVER = WAIT_TILL_CONNECTED_MAX_TICKS_NEVER_CONNECTED * WAIT_TILL_CONNECTED_TICK_MS;
|
||||
export type ConnectionState = 'disabled' | 'disconnected' | 'connecting' | 'connected';
|
||||
let connState: ConnectionState = 'disconnected';
|
||||
type ConnectionListener = (state: ConnectionState) => void;
|
||||
const connectionListeners = new Set<ConnectionListener>();
|
||||
|
||||
function setConnectionState(next: ConnectionState): void {
|
||||
if (connState === next) return;
|
||||
connState = next;
|
||||
for (const l of connectionListeners) {
|
||||
try {
|
||||
l(next);
|
||||
} catch (e) {
|
||||
console.warn('connection listener threw:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Current connection state for UI. */
|
||||
export function getConnectionState(): ConnectionState {
|
||||
return connState;
|
||||
}
|
||||
|
||||
/** Subscribe to state changes. Returns an unsubscribe function. */
|
||||
export function subscribeConnectionState(listener: ConnectionListener): () => void {
|
||||
connectionListeners.add(listener);
|
||||
return () => {
|
||||
connectionListeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
/** Convenience: `true` iff a usable Electrum connection is currently believed to exist. */
|
||||
export function isConnected(): boolean {
|
||||
return connState === 'connected';
|
||||
}
|
||||
|
||||
// --- Connection lifecycle internals ---------------------------------------------
|
||||
/** One liveness check (`server_ping`) wall-time before giving up and marking the socket dead. */
|
||||
const PING_TIMEOUT_MS = 5_000;
|
||||
/** One full connect attempt (TLS + `server_version` handshake) wall-time before retrying. */
|
||||
const CONNECT_ATTEMPT_TIMEOUT_MS = 10_000;
|
||||
/** Reconnect attempts inside a single `ensureConnected()` call before declaring failure. */
|
||||
const CONNECT_MAX_ATTEMPTS = 5;
|
||||
/** Backoff between attempts to avoid hammering a flaky server. */
|
||||
const CONNECT_BACKOFF_MS = 500;
|
||||
/** Delay before the auto-reconnect triggered by a live-socket `onError`. Onions are slower. */
|
||||
const RECONNECT_ONION_DELAY_MS = 4_000;
|
||||
const RECONNECT_TCP_DELAY_MS = 500;
|
||||
|
||||
/** Max wall time one `ensureConnected()` call may take when no live socket exists. */
|
||||
export const ENSURE_CONNECTED_MAX_WALL_MS =
|
||||
CONNECT_MAX_ATTEMPTS * CONNECT_ATTEMPT_TIMEOUT_MS + (CONNECT_MAX_ATTEMPTS - 1) * CONNECT_BACKOFF_MS;
|
||||
|
||||
/** Coalesces concurrent `ensureConnected()` callers — at most one connect attempt at a time. */
|
||||
let ensureInFlight: Promise<boolean> | null = null;
|
||||
/** If any coalesced caller asked for the failure alert, honour it once the in-flight attempt finishes. */
|
||||
let ensureInFlightShowAlert = false;
|
||||
|
||||
/**
|
||||
* Bumps every time the caller asks us to abandon the current connection
|
||||
* (`forceDisconnect()` or user disabling Electrum). In-flight `ensureConnected()`
|
||||
* checks this between attempts so it can bail out promptly instead of racing back
|
||||
* to `connected` after a disconnect was requested.
|
||||
*/
|
||||
let disconnectGeneration = 0;
|
||||
const txhashHeightCache: Record<string, number> = {};
|
||||
let _realm: Realm | undefined;
|
||||
|
||||
@ -259,48 +319,93 @@ async function getSavedPeer(): Promise<Peer | null> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function connectMain(): Promise<void> {
|
||||
if (await isDisabled()) {
|
||||
console.log('Electrum connection disabled by user. Skipping connectMain call');
|
||||
return;
|
||||
}
|
||||
/** Resolve to the peer this attempt should target (preferred saved peer, or rotate hardcoded list). */
|
||||
async function pickPeer(): Promise<Peer> {
|
||||
let usingPeer = getNextPeer();
|
||||
const savedPeer = await getSavedPeer();
|
||||
if (savedPeer && savedPeer.host && (savedPeer.tcp || savedPeer.ssl)) {
|
||||
usingPeer = savedPeer;
|
||||
}
|
||||
return usingPeer;
|
||||
}
|
||||
|
||||
function scheduleReconnectFromClient(client: typeof ElectrumClient, usingPeer: Peer, reason: string): void {
|
||||
if (connState !== 'connected' || mainClient !== client) return;
|
||||
|
||||
console.log(`scheduling Electrum reconnect after ${reason}`);
|
||||
try {
|
||||
// Also neutralises electrum-client's own timers/reconnect hooks for this instance.
|
||||
client.close();
|
||||
} catch {}
|
||||
if (mainClient === client) mainClient = undefined;
|
||||
setConnectionState('disconnected');
|
||||
|
||||
const delay = usingPeer.host.endsWith('.onion') ? RECONNECT_ONION_DELAY_MS : RECONNECT_TCP_DELAY_MS;
|
||||
const generationAtSchedule = disconnectGeneration;
|
||||
setTimeout(() => {
|
||||
if (generationAtSchedule !== disconnectGeneration) return;
|
||||
// eslint-disable-next-line @typescript-eslint/no-use-before-define -- defined later in file
|
||||
ensureConnected().catch(() => {
|
||||
/* ensureConnected never throws, but be defensive */
|
||||
});
|
||||
}, delay);
|
||||
}
|
||||
|
||||
/**
|
||||
* One connect attempt: build a fresh `ElectrumClient`, run the version handshake,
|
||||
* subscribe to headers. No retries, no UI side effects. Returns the peer used
|
||||
* (for caller-side telemetry/alerts) and whether the attempt succeeded.
|
||||
*/
|
||||
async function attemptConnectOnce(): Promise<{ ok: boolean; peer: Peer }> {
|
||||
const usingPeer = await pickPeer();
|
||||
console.log('Using peer:', JSON.stringify(usingPeer));
|
||||
|
||||
// Drop any prior client before allocating a new one. Closing also neutralises
|
||||
// electrum-client's internal `reconnect()` loop on the old instance.
|
||||
if (mainClient) {
|
||||
try {
|
||||
mainClient.close();
|
||||
} catch {}
|
||||
mainClient = undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('begin connection:', JSON.stringify(usingPeer));
|
||||
mainClient = new ElectrumClient(net, tls, usingPeer.ssl || usingPeer.tcp, usingPeer.host, usingPeer.ssl ? 'tls' : 'tcp');
|
||||
const client = new ElectrumClient(net, tls, usingPeer.ssl || usingPeer.tcp, usingPeer.host, usingPeer.ssl ? 'tls' : 'tcp');
|
||||
mainClient = client;
|
||||
|
||||
mainClient.onError = function (e: { message: string }) {
|
||||
// Live-socket errors after a successful handshake: schedule a single
|
||||
// `ensureConnected()` (deduped). Errors during this attempt's own handshake
|
||||
// are caught below — we must not double-handle them here.
|
||||
client.onError = function (e: { message: string }) {
|
||||
console.log('electrum mainClient.onError():', e.message);
|
||||
if (mainConnected) {
|
||||
// most likely got a timeout from electrum ping. lets reconnect
|
||||
// but only if we were previously connected (mainConnected), otherwise theres other
|
||||
// code which does connection retries
|
||||
mainClient?.close();
|
||||
mainClient = undefined;
|
||||
mainConnected = false;
|
||||
// dropping `mainConnected` flag ensures there wont be reconnection race condition if several
|
||||
// errors triggered
|
||||
console.log('reconnecting after socket error');
|
||||
setTimeout(connectMain, usingPeer.host.endsWith('.onion') ? 4000 : 500);
|
||||
}
|
||||
scheduleReconnectFromClient(client, usingPeer, 'socket error');
|
||||
};
|
||||
const ver = await mainClient.initElectrum({ client: 'bluewallet', version: '1.4' });
|
||||
|
||||
const ver = await Promise.race([
|
||||
client.initElectrum(
|
||||
{ client: 'bluewallet', version: '1.4' },
|
||||
{
|
||||
maxRetry: 0,
|
||||
callback: () => scheduleReconnectFromClient(client, usingPeer, 'socket close'),
|
||||
},
|
||||
),
|
||||
new Promise<never>((_resolve, reject) => setTimeout(() => reject(new Error('connect timeout')), CONNECT_ATTEMPT_TIMEOUT_MS)),
|
||||
]);
|
||||
|
||||
if (mainClient !== client) {
|
||||
// Caller raced `forceDisconnect()` while we were awaiting. Bail.
|
||||
try {
|
||||
client.close();
|
||||
} catch {}
|
||||
return { ok: false, peer: usingPeer };
|
||||
}
|
||||
|
||||
if (ver && ver[0]) {
|
||||
console.log('connected to ', ver);
|
||||
serverName = ver[0];
|
||||
mainConnected = true;
|
||||
wasConnectedAtLeastOnce = true;
|
||||
if (ver[0].startsWith('ElectrumPersonalServer') || ver[0].startsWith('electrs') || ver[0].startsWith('Fulcrum')) {
|
||||
disableBatching = true;
|
||||
|
||||
// exeptions for versions:
|
||||
const [electrumImplementation, electrumVersion] = ver[0].split(' ');
|
||||
switch (electrumImplementation) {
|
||||
case 'electrs':
|
||||
@ -309,8 +414,6 @@ export async function connectMain(): Promise<void> {
|
||||
}
|
||||
break;
|
||||
case 'electrs-esplora':
|
||||
// its a different one, and it does NOT support batching
|
||||
// nop
|
||||
break;
|
||||
case 'Fulcrum':
|
||||
if (semVerToInt(electrumVersion) >= semVerToInt('1.9.0')) {
|
||||
@ -319,36 +422,156 @@ export async function connectMain(): Promise<void> {
|
||||
break;
|
||||
}
|
||||
}
|
||||
const header = await mainClient.blockchainHeaders_subscribe();
|
||||
const header = await client.blockchainHeaders_subscribe();
|
||||
if (header && header.height) {
|
||||
latestBlock = {
|
||||
height: header.height,
|
||||
time: Math.floor(+new Date() / 1000),
|
||||
};
|
||||
}
|
||||
// AsyncStorage.setItem(storageKey, JSON.stringify(peers)); TODO: refactor
|
||||
return { ok: true, peer: usingPeer };
|
||||
}
|
||||
return { ok: false, peer: usingPeer };
|
||||
} catch (e) {
|
||||
mainConnected = false;
|
||||
console.log('bad connection:', JSON.stringify(usingPeer), e);
|
||||
mainClient?.close();
|
||||
mainClient = undefined;
|
||||
if (mainClient) {
|
||||
try {
|
||||
mainClient.close();
|
||||
} catch {}
|
||||
mainClient = undefined;
|
||||
}
|
||||
return { ok: false, peer: usingPeer };
|
||||
}
|
||||
}
|
||||
|
||||
/** Single liveness check on the current `mainClient`, bounded by `PING_TIMEOUT_MS`. */
|
||||
async function pingWithTimeout(timeoutMs: number = PING_TIMEOUT_MS): Promise<boolean> {
|
||||
if (!mainClient) return false;
|
||||
const client = mainClient;
|
||||
try {
|
||||
await Promise.race([
|
||||
client.server_ping(),
|
||||
new Promise<never>((_resolve, reject) => setTimeout(() => reject(new Error('ping timeout')), timeoutMs)),
|
||||
]);
|
||||
return mainClient === client; // server replied AND client wasn't swapped while we waited
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export type EnsureConnectedOptions = {
|
||||
/**
|
||||
* Show the legacy "couldn't connect" alert (Try again / Reset / Cancel) on failure.
|
||||
* Used by initial bootstrap (`SettingsProvider` re-enabling Electrum) and the manual
|
||||
* help alert. Off-hot-path callers (refresh, broadcast, etc.) should leave this false
|
||||
* and surface their own UI.
|
||||
*/
|
||||
showAlertOnFailure?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Make sure a usable Electrum connection exists, healing if needed.
|
||||
*
|
||||
* - If we already think we're connected, run one fast `ping` to verify. If the ping
|
||||
* succeeds, we're done. If it fails the client is torn down and we fall through
|
||||
* to a reconnect.
|
||||
* - Otherwise run up to `CONNECT_MAX_ATTEMPTS` connect attempts (each with its own
|
||||
* timeout + backoff).
|
||||
*
|
||||
* Concurrent callers share the same in-flight promise — there is at most one connect
|
||||
* attempt at a time per process. This replaces the old `mainConnected`-flag-polling
|
||||
* `waitTillConnected()`, which could block ~30s on a stale flag while the socket was
|
||||
* still alive.
|
||||
*/
|
||||
export async function ensureConnected(opts: EnsureConnectedOptions = {}): Promise<boolean> {
|
||||
const { showAlertOnFailure = false } = opts;
|
||||
|
||||
if (await isDisabled()) {
|
||||
setConnectionState('disabled');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!mainConnected) {
|
||||
console.log('retry');
|
||||
connectionAttempt = connectionAttempt + 1;
|
||||
mainClient?.close();
|
||||
mainClient = undefined;
|
||||
if (connectionAttempt >= 5) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-use-before-define -- `presentNetworkErrorAlert` is defined below after `connectMain`
|
||||
presentNetworkErrorAlert(usingPeer);
|
||||
} else {
|
||||
console.log('reconnection attempt #', connectionAttempt);
|
||||
await new Promise(resolve => setTimeout(resolve, 500)); // sleep
|
||||
return connectMain();
|
||||
}
|
||||
if (ensureInFlight) {
|
||||
if (showAlertOnFailure) ensureInFlightShowAlert = true;
|
||||
return ensureInFlight;
|
||||
}
|
||||
|
||||
ensureInFlightShowAlert = showAlertOnFailure;
|
||||
ensureInFlight = (async (): Promise<boolean> => {
|
||||
const myGeneration = disconnectGeneration;
|
||||
/** True iff the current generation no longer matches ours (i.e. `forceDisconnect()` ran). */
|
||||
const aborted = (where: string): boolean => {
|
||||
if (myGeneration === disconnectGeneration) return false;
|
||||
console.log(`[BlueElectrum] ensureConnected aborted by forceDisconnect at ${where} (gen ${myGeneration} → ${disconnectGeneration})`);
|
||||
return true;
|
||||
};
|
||||
let lastPeer: Peer | undefined;
|
||||
try {
|
||||
// Fast path: live ping on the existing client.
|
||||
if (mainClient && connState === 'connected') {
|
||||
if (await pingWithTimeout()) {
|
||||
if (aborted('post-ping')) {
|
||||
setConnectionState('disconnected');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// Stale socket. Tear it down so the attempt loop starts fresh.
|
||||
try {
|
||||
mainClient.close();
|
||||
} catch {}
|
||||
mainClient = undefined;
|
||||
setConnectionState('disconnected');
|
||||
}
|
||||
|
||||
if (aborted('pre-loop')) return false;
|
||||
setConnectionState('connecting');
|
||||
|
||||
for (let i = 0; i < CONNECT_MAX_ATTEMPTS; i++) {
|
||||
if (await isDisabled()) {
|
||||
setConnectionState('disabled');
|
||||
return false;
|
||||
}
|
||||
if (aborted(`attempt ${i} start`)) {
|
||||
setConnectionState('disconnected');
|
||||
return false;
|
||||
}
|
||||
|
||||
const { ok, peer } = await attemptConnectOnce();
|
||||
lastPeer = peer;
|
||||
|
||||
if (aborted(`attempt ${i} end`)) {
|
||||
if (mainClient) {
|
||||
try {
|
||||
mainClient.close();
|
||||
} catch {}
|
||||
mainClient = undefined;
|
||||
}
|
||||
setConnectionState('disconnected');
|
||||
return false;
|
||||
}
|
||||
if (ok) {
|
||||
setConnectionState('connected');
|
||||
return true;
|
||||
}
|
||||
if (i < CONNECT_MAX_ATTEMPTS - 1) {
|
||||
await new Promise(resolve => setTimeout(resolve, CONNECT_BACKOFF_MS));
|
||||
}
|
||||
}
|
||||
|
||||
setConnectionState('disconnected');
|
||||
if (ensureInFlightShowAlert) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-use-before-define -- defined later in file
|
||||
presentNetworkErrorAlert(lastPeer);
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
ensureInFlight = null;
|
||||
ensureInFlightShowAlert = false;
|
||||
}
|
||||
})();
|
||||
|
||||
return ensureInFlight;
|
||||
}
|
||||
|
||||
export async function presentResetToDefaultsAlert(): Promise<boolean> {
|
||||
@ -431,10 +654,10 @@ async function presentNetworkErrorAlert(usingPeer?: Peer, allowRepeat = false) {
|
||||
{
|
||||
text: loc.wallets.list_tryagain,
|
||||
onPress: () => {
|
||||
connectionAttempt = 0;
|
||||
mainClient?.close();
|
||||
mainClient = undefined;
|
||||
setTimeout(connectMain, 500);
|
||||
forceDisconnect();
|
||||
setTimeout(() => {
|
||||
ensureConnected({ showAlertOnFailure: true }).catch(() => {});
|
||||
}, 500);
|
||||
},
|
||||
style: 'default',
|
||||
},
|
||||
@ -443,10 +666,10 @@ async function presentNetworkErrorAlert(usingPeer?: Peer, allowRepeat = false) {
|
||||
onPress: () => {
|
||||
presentResetToDefaultsAlert().then(result => {
|
||||
if (result) {
|
||||
connectionAttempt = 0;
|
||||
mainClient?.close();
|
||||
mainClient = undefined;
|
||||
setTimeout(connectMain, 500);
|
||||
forceDisconnect();
|
||||
setTimeout(() => {
|
||||
ensureConnected({ showAlertOnFailure: true }).catch(() => {});
|
||||
}, 500);
|
||||
}
|
||||
});
|
||||
},
|
||||
@ -455,9 +678,7 @@ async function presentNetworkErrorAlert(usingPeer?: Peer, allowRepeat = false) {
|
||||
{
|
||||
text: loc._.cancel,
|
||||
onPress: () => {
|
||||
connectionAttempt = 0;
|
||||
mainClient?.close();
|
||||
mainClient = undefined;
|
||||
forceDisconnect();
|
||||
},
|
||||
style: 'cancel',
|
||||
},
|
||||
@ -524,12 +745,21 @@ export const getBalanceByAddress = async function (address: string): Promise<{ c
|
||||
};
|
||||
|
||||
export const getConfig = async function () {
|
||||
if (!mainClient) throw new Error('Electrum client is not connected');
|
||||
if (!mainClient) {
|
||||
return {
|
||||
host: undefined,
|
||||
port: undefined,
|
||||
serverName: false as typeof serverName,
|
||||
connected: connState === 'connected' ? 1 : 0,
|
||||
};
|
||||
}
|
||||
return {
|
||||
host: mainClient.host,
|
||||
port: mainClient.port,
|
||||
serverName,
|
||||
connected: mainClient.timeLastCall !== 0 && mainClient.status,
|
||||
// Drive UI "connected" indicator from the single state machine so the settings
|
||||
// screen agrees with the wallets-list header pill and with `ensureConnected()`.
|
||||
connected: connState === 'connected' ? 1 : 0,
|
||||
};
|
||||
};
|
||||
|
||||
@ -558,14 +788,24 @@ export const getMempoolTransactionsByAddress = async function (address: string):
|
||||
return mainClient.blockchainScripthash_getMempool(uint8ArrayToHex(reversedHash));
|
||||
};
|
||||
|
||||
export const ping = async function () {
|
||||
try {
|
||||
await mainClient.server_ping();
|
||||
return true;
|
||||
} catch (_) {}
|
||||
|
||||
mainConnected = false;
|
||||
return false;
|
||||
/**
|
||||
* Read-only liveness probe. Does NOT trigger reconnects (use `ensureConnected()`
|
||||
* for that). Updates the connection state machine to reflect the probe result so
|
||||
* subscribers (UI pill, settings screen) stay in sync.
|
||||
*
|
||||
* - `true`: server replied within `PING_TIMEOUT_MS`.
|
||||
* - `false`: client missing, timed out, or server errored.
|
||||
*/
|
||||
export const ping = async function (): Promise<boolean> {
|
||||
if (await isDisabled()) return false;
|
||||
const ok = await pingWithTimeout();
|
||||
if (ok) {
|
||||
// Heal stale `disconnected` state from a transient ping failure earlier.
|
||||
if (connState !== 'connected') setConnectionState('connected');
|
||||
} else if (connState === 'connected') {
|
||||
setConnectionState('disconnected');
|
||||
}
|
||||
return ok;
|
||||
};
|
||||
|
||||
// exported only to be used in unit tests
|
||||
@ -1040,34 +1280,6 @@ export async function multiGetTransactionByTxid<T extends boolean>(
|
||||
return ret;
|
||||
}
|
||||
|
||||
export const waitTillConnected = async function (): Promise<boolean> {
|
||||
let waitTillConnectedInterval: NodeJS.Timeout | undefined;
|
||||
let retriesCounter = 0;
|
||||
if (await isDisabled()) {
|
||||
console.warn('Electrum connections disabled by user. waitTillConnected skipping...');
|
||||
return false;
|
||||
}
|
||||
return new Promise(function (resolve, reject) {
|
||||
waitTillConnectedInterval = setInterval(() => {
|
||||
if (mainConnected) {
|
||||
clearInterval(waitTillConnectedInterval);
|
||||
return resolve(true);
|
||||
}
|
||||
|
||||
retriesCounter += 1;
|
||||
const maxTicks = wasConnectedAtLeastOnce
|
||||
? WAIT_TILL_CONNECTED_MAX_TICKS_AFTER_FIRST_CONNECT
|
||||
: WAIT_TILL_CONNECTED_MAX_TICKS_NEVER_CONNECTED;
|
||||
|
||||
if (retriesCounter >= maxTicks) {
|
||||
clearInterval(waitTillConnectedInterval);
|
||||
presentNetworkErrorAlert();
|
||||
reject(new Error('Waiting for Electrum connection timeout'));
|
||||
}
|
||||
}, WAIT_TILL_CONNECTED_TICK_MS);
|
||||
});
|
||||
};
|
||||
|
||||
// Returns the value at a given percentile in a sorted numeric array.
|
||||
// "Linear interpolation between closest ranks" method
|
||||
function percentile(arr: number[], p: number) {
|
||||
@ -1240,8 +1452,19 @@ export const testConnection = async function (host: string, tcpPort?: number, ss
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Drop the current connection and tell any in-flight `ensureConnected()` to abort
|
||||
* (so it doesn't race the disconnect by setting state back to `connected`).
|
||||
*/
|
||||
export const forceDisconnect = (): void => {
|
||||
mainClient?.close();
|
||||
disconnectGeneration += 1;
|
||||
if (mainClient) {
|
||||
try {
|
||||
mainClient.close();
|
||||
} catch {}
|
||||
mainClient = undefined;
|
||||
}
|
||||
setConnectionState('disconnected');
|
||||
};
|
||||
|
||||
export const setBatchingDisabled = () => {
|
||||
|
||||
@ -220,7 +220,11 @@ export const SettingsProvider: React.FC<{ children: React.ReactNode }> = React.m
|
||||
|
||||
useEffect(() => {
|
||||
if (walletsInitialized) {
|
||||
isElectrumDisabled ? BlueElectrum.forceDisconnect() : BlueElectrum.connectMain();
|
||||
if (isElectrumDisabled) {
|
||||
BlueElectrum.forceDisconnect();
|
||||
} else {
|
||||
BlueElectrum.ensureConnected({ showAlertOnFailure: true });
|
||||
}
|
||||
}
|
||||
}, [isElectrumDisabled, walletsInitialized]);
|
||||
|
||||
|
||||
@ -331,24 +331,21 @@ export const StorageProvider = ({ children }: { children: React.ReactNode }) =>
|
||||
setWalletTransactionUpdateStatus(WalletTransactionsStatus.ALL);
|
||||
}
|
||||
console.debug('[refreshAllWalletTransactions] Waiting for connectivity...');
|
||||
await BlueElectrum.waitTillConnected();
|
||||
if (!(await BlueElectrum.ping())) {
|
||||
// above `waitTillConnected` is not reliable, as app might have returned from long sleep, so it thinks its
|
||||
// connected but actually socket is closed. thus, we ping, and if it fails - we wait again (reconnection code
|
||||
// should pick up)
|
||||
console.log('[refreshAllWalletTransactions] ping failed, waiting for connection...');
|
||||
await BlueElectrum.waitTillConnected();
|
||||
// `ensureConnected()` ping-checks the existing socket and, only if needed,
|
||||
// tears it down and reconnects. Replaces the old wait+ping+wait pattern
|
||||
// which surfaced false "network error" alerts after iOS suspend/resume.
|
||||
const connected = await BlueElectrum.ensureConnected();
|
||||
if (!connected) {
|
||||
console.log('[refreshAllWalletTransactions] could not establish Electrum connection, aborting refresh');
|
||||
return;
|
||||
}
|
||||
|
||||
console.debug('[refreshAllWalletTransactions] Connected to Electrum');
|
||||
|
||||
// Race only the post-connect work. `waitTillConnected` can take up to
|
||||
// WAIT_TILL_CONNECTED_MAX_WALL_MS_NEVER (+ a second wait); starting the timer earlier caused refresh to abort
|
||||
// while Electrum was still legitimately connecting.
|
||||
const REFRESH_FETCH_PHASE_TIMEOUT_MS = Math.max(
|
||||
120_000,
|
||||
BlueElectrum.WAIT_TILL_CONNECTED_MAX_WALL_MS_NEVER + BlueElectrum.WAIT_TILL_CONNECTED_MAX_WALL_MS_AFTER_FIRST,
|
||||
);
|
||||
// Race only the post-connect work. We budget ample time so that a slow
|
||||
// initial Electrum connection (cold start, slow TLS, flaky network) doesn't
|
||||
// cause the fetch race to abort prematurely.
|
||||
const REFRESH_FETCH_PHASE_TIMEOUT_MS = Math.max(120_000, BlueElectrum.ENSURE_CONNECTED_MAX_WALL_MS * 2);
|
||||
const timeoutPromise = new Promise<never>(
|
||||
(_resolve, reject) =>
|
||||
(refreshTimeout = setTimeout(() => {
|
||||
@ -418,7 +415,11 @@ export const StorageProvider = ({ children }: { children: React.ReactNode }) =>
|
||||
}
|
||||
_lastTimeTriedToRefetchWallet[walletID] = Date.now();
|
||||
|
||||
await BlueElectrum.waitTillConnected();
|
||||
const connected = await BlueElectrum.ensureConnected();
|
||||
if (!connected) {
|
||||
console.log('[fetchAndSaveWalletTransactions] could not establish Electrum connection, aborting');
|
||||
return;
|
||||
}
|
||||
setWalletTransactionUpdateStatus(walletID);
|
||||
|
||||
const balanceStart = Date.now();
|
||||
|
||||
@ -98,38 +98,51 @@ const DetailViewStackScreensStack = () => {
|
||||
const { sizeClass } = useSizeClass();
|
||||
const [electrumConnected, setElectrumConnected] = useState<boolean | null>(null);
|
||||
|
||||
// Probe connection health from the UI (e.g. WalletsList focus / 30s timer).
|
||||
// BlueElectrum.ping() reflects the result into the shared connection state, which
|
||||
// we observe via the subscription below — no need to set local state here.
|
||||
const pollConnection = useCallback(async () => {
|
||||
if (isElectrumDisabled) return;
|
||||
const ok = await BlueElectrum.ping();
|
||||
setElectrumConnected(ok);
|
||||
await BlueElectrum.ping();
|
||||
}, [isElectrumDisabled]);
|
||||
|
||||
// Mirror BlueElectrum's connection state into local UI state.
|
||||
useEffect(() => {
|
||||
if (isElectrumDisabled) {
|
||||
setElectrumConnected(null);
|
||||
return;
|
||||
}
|
||||
pollConnection();
|
||||
}, [isElectrumDisabled, pollConnection]);
|
||||
const sync = () => setElectrumConnected(BlueElectrum.isConnected());
|
||||
sync();
|
||||
const unsubscribe = BlueElectrum.subscribeConnectionState(sync);
|
||||
// Kick off an initial probe so the header pill reflects reality after mount.
|
||||
BlueElectrum.ping().catch(() => {});
|
||||
return unsubscribe;
|
||||
}, [isElectrumDisabled]);
|
||||
|
||||
// On foreground transition, proactively heal: ensureConnected() takes the fast
|
||||
// ping path when the socket is alive (no-op) and rebuilds the connection only
|
||||
// when needed. This replaces the old "ping → maybe show network alert" path that
|
||||
// could surface a false alert after iOS suspend/resume.
|
||||
useEffect(() => {
|
||||
if (isElectrumDisabled) return;
|
||||
const subscription = AppState.addEventListener('change', nextState => {
|
||||
if (nextState === 'active') {
|
||||
pollConnection();
|
||||
BlueElectrum.ensureConnected().catch(() => {});
|
||||
}
|
||||
});
|
||||
return () => subscription.remove();
|
||||
}, [isElectrumDisabled, pollConnection]);
|
||||
// When starting up in an unknown state, we optimistically rely on ping()
|
||||
// and the fast retry loop while disconnected. Slow health checks while connected
|
||||
// run only from WalletsList when that screen is focused (saves idle battery).
|
||||
}, [isElectrumDisabled]);
|
||||
|
||||
// While we believe we're disconnected, ask BlueElectrum to keep trying to
|
||||
// reconnect (silently — the red "Not connected" pill is the only UI signal).
|
||||
useEffect(() => {
|
||||
if (isElectrumDisabled || electrumConnected !== false) return;
|
||||
const interval = setInterval(pollConnection, 3000);
|
||||
const interval = setInterval(() => {
|
||||
BlueElectrum.ensureConnected().catch(() => {});
|
||||
}, 3000);
|
||||
return () => clearInterval(interval);
|
||||
}, [isElectrumDisabled, electrumConnected, pollConnection]);
|
||||
}, [isElectrumDisabled, electrumConnected]);
|
||||
|
||||
const connectionPollContextValue = useMemo(() => ({ pollConnection }), [pollConnection]);
|
||||
|
||||
|
||||
@ -98,8 +98,9 @@ const Broadcast: React.FC = () => {
|
||||
Keyboard.dismiss();
|
||||
setBroadcastResult(BROADCAST_RESULT.pending);
|
||||
try {
|
||||
await BlueElectrum.ping();
|
||||
await BlueElectrum.waitTillConnected();
|
||||
if (!(await BlueElectrum.ensureConnected())) {
|
||||
throw new Error(loc.errors.network);
|
||||
}
|
||||
const walletObj = new HDSegwitBech32Wallet();
|
||||
if (txHex) {
|
||||
const result = await walletObj.broadcastTx(txHex);
|
||||
|
||||
@ -247,8 +247,9 @@ const Confirm: React.FC = () => {
|
||||
};
|
||||
|
||||
const broadcastTransaction = async (transaction: string) => {
|
||||
await BlueElectrum.ping();
|
||||
await BlueElectrum.waitTillConnected();
|
||||
if (!(await BlueElectrum.ensureConnected())) {
|
||||
throw new Error(loc.errors.network);
|
||||
}
|
||||
|
||||
const result = await wallet.broadcastTx(transaction);
|
||||
if (!result) {
|
||||
|
||||
@ -147,8 +147,9 @@ const PsbtWithHardwareWallet = () => {
|
||||
}
|
||||
}
|
||||
try {
|
||||
await BlueElectrum.ping();
|
||||
await BlueElectrum.waitTillConnected();
|
||||
if (!(await BlueElectrum.ensureConnected())) {
|
||||
throw new Error(loc.errors.network);
|
||||
}
|
||||
|
||||
if (!txHex) {
|
||||
setIsLoading(false);
|
||||
|
||||
@ -127,8 +127,7 @@ export default class SelfTest extends Component {
|
||||
//
|
||||
|
||||
if (typeof navigator !== 'undefined' && navigator.product === 'ReactNative') {
|
||||
await BlueElectrum.ping();
|
||||
await BlueElectrum.waitTillConnected();
|
||||
if (!(await BlueElectrum.ensureConnected())) throw new Error('Could not connect to Electrum');
|
||||
const addr4elect = '3GCvDBAktgQQtsbN6x5DYiQCMmgZ9Yk8BK';
|
||||
const electrumBalance = await BlueElectrum.getBalanceByAddress(addr4elect);
|
||||
if (electrumBalance.confirmed !== 51432)
|
||||
|
||||
@ -78,8 +78,9 @@ export default class CPFP extends Component {
|
||||
broadcast = () => {
|
||||
this.setState({ isLoading: true }, async () => {
|
||||
try {
|
||||
await BlueElectrum.ping();
|
||||
await BlueElectrum.waitTillConnected();
|
||||
if (!(await BlueElectrum.ensureConnected())) {
|
||||
throw new Error(loc.errors.network);
|
||||
}
|
||||
const result = await this.state.wallet.broadcastTx(this.state.txhex);
|
||||
if (result) {
|
||||
this.onSuccessBroadcast();
|
||||
|
||||
@ -215,7 +215,9 @@ const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }: { rout
|
||||
|
||||
let smthChanged = false;
|
||||
try {
|
||||
await BlueElectrum.waitTillConnected();
|
||||
if (!(await BlueElectrum.ensureConnected())) {
|
||||
throw new Error(loc.errors.network);
|
||||
}
|
||||
if (wallet.allowBIP47() && wallet.isBIP47Enabled() && 'fetchBIP47SenderPaymentCodes' in wallet) {
|
||||
await wallet.fetchBIP47SenderPaymentCodes();
|
||||
}
|
||||
|
||||
@ -10,7 +10,7 @@ import { BlueDefaultTheme } from '../../components/themes';
|
||||
|
||||
jest.mock('../../blue_modules/BlueElectrum', () => {
|
||||
return {
|
||||
connectMain: jest.fn(),
|
||||
ensureConnected: jest.fn().mockResolvedValue(true),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@ -12,11 +12,8 @@ afterAll(() => {
|
||||
beforeAll(async () => {
|
||||
// awaiting for Electrum to be connected. For RN Electrum would naturally connect
|
||||
// while app starts up, but for tests we need to wait for it
|
||||
try {
|
||||
await BlueElectrum.connectMain();
|
||||
} catch (err) {
|
||||
console.log('failed to connect to Electrum:', err);
|
||||
process.exit(1);
|
||||
if (!(await BlueElectrum.ensureConnected())) {
|
||||
throw new Error('failed to connect to Electrum');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@ -21,7 +21,9 @@ afterAll(async () => {
|
||||
beforeAll(async () => {
|
||||
// awaiting for Electrum to be connected. For RN Electrum would naturally connect
|
||||
// while app starts up, but for tests we need to wait for it
|
||||
await BlueElectrum.connectMain();
|
||||
if (!(await BlueElectrum.ensureConnected())) {
|
||||
throw new Error('failed to connect to Electrum');
|
||||
}
|
||||
});
|
||||
|
||||
describe('Bech32 Segwit HD (BIP84) with BIP47', () => {
|
||||
|
||||
@ -17,7 +17,9 @@ afterAll(async () => {
|
||||
beforeAll(async () => {
|
||||
// awaiting for Electrum to be connected. For RN Electrum would naturally connect
|
||||
// while app starts up, but for tests we need to wait for it
|
||||
await BlueElectrum.connectMain();
|
||||
if (!(await BlueElectrum.ensureConnected())) {
|
||||
throw new Error('failed to connect to Electrum');
|
||||
}
|
||||
});
|
||||
|
||||
it('Legacy HD Breadwallet can fetch utxo, balance, and create transaction', async () => {
|
||||
|
||||
@ -18,7 +18,9 @@ afterAll(async () => {
|
||||
beforeAll(async () => {
|
||||
// awaiting for Electrum to be connected. For RN Electrum would naturally connect
|
||||
// while app starts up, but for tests we need to wait for it
|
||||
await BlueElectrum.connectMain();
|
||||
if (!(await BlueElectrum.ensureConnected())) {
|
||||
throw new Error('failed to connect to Electrum');
|
||||
}
|
||||
});
|
||||
|
||||
let _cachedHdWallet = false;
|
||||
|
||||
@ -13,7 +13,9 @@ afterAll(async () => {
|
||||
beforeAll(async () => {
|
||||
// awaiting for Electrum to be connected. For RN Electrum would naturally connect
|
||||
// while app starts up, but for tests we need to wait for it
|
||||
await BlueElectrum.connectMain();
|
||||
if (!(await BlueElectrum.ensureConnected())) {
|
||||
throw new Error('failed to connect to Electrum');
|
||||
}
|
||||
});
|
||||
|
||||
describe('Bech32 Segwit HD (BIP84)', () => {
|
||||
|
||||
@ -14,11 +14,8 @@ afterAll(() => {
|
||||
beforeAll(async () => {
|
||||
// awaiting for Electrum to be connected. For RN Electrum would naturally connect
|
||||
// while app starts up, but for tests we need to wait for it
|
||||
try {
|
||||
await BlueElectrum.connectMain();
|
||||
} catch (Err) {
|
||||
console.log('failed to connect to Electrum:', Err);
|
||||
process.exit(2);
|
||||
if (!(await BlueElectrum.ensureConnected())) {
|
||||
throw new Error('failed to connect to Electrum');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@ -30,7 +30,9 @@ afterAll(async () => {
|
||||
beforeAll(async () => {
|
||||
// awaiting for Electrum to be connected. For RN Electrum would naturally connect
|
||||
// while app starts up, but for tests we need to wait for it
|
||||
await BlueElectrum.connectMain();
|
||||
if (!(await BlueElectrum.ensureConnected())) {
|
||||
throw new Error('failed to connect to Electrum');
|
||||
}
|
||||
});
|
||||
|
||||
type THistoryItem = { action: 'progress'; data: string } | { action: 'wallet'; data: TWallet } | { action: 'password'; data: string };
|
||||
|
||||
@ -15,7 +15,9 @@ afterAll(async () => {
|
||||
beforeAll(async () => {
|
||||
// awaiting for Electrum to be connected. For RN Electrum would naturally connect
|
||||
// while app starts up, but for tests we need to wait for it
|
||||
await BlueElectrum.connectMain();
|
||||
if (!(await BlueElectrum.ensureConnected())) {
|
||||
throw new Error('failed to connect to Electrum');
|
||||
}
|
||||
});
|
||||
|
||||
describe('LegacyWallet', function () {
|
||||
|
||||
@ -13,11 +13,8 @@ afterAll(() => {
|
||||
beforeAll(async () => {
|
||||
// awaiting for Electrum to be connected. For RN Electrum would naturally connect
|
||||
// while app starts up, but for tests we need to wait for it
|
||||
try {
|
||||
await BlueElectrum.connectMain();
|
||||
} catch (Err) {
|
||||
console.log('failed to connect to Electrum:', Err);
|
||||
process.exit(2);
|
||||
if (!(await BlueElectrum.ensureConnected())) {
|
||||
throw new Error('failed to connect to Electrum');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@ -13,7 +13,9 @@ afterAll(async () => {
|
||||
beforeAll(async () => {
|
||||
// awaiting for Electrum to be connected. For RN Electrum would naturally connect
|
||||
// while app starts up, but for tests we need to wait for it
|
||||
await BlueElectrum.connectMain();
|
||||
if (!(await BlueElectrum.ensureConnected())) {
|
||||
throw new Error('failed to connect to Electrum');
|
||||
}
|
||||
});
|
||||
|
||||
describe('Watch only wallet', () => {
|
||||
|
||||
@ -9,7 +9,7 @@ jest.mock('../../blue_modules/currency', () => {
|
||||
|
||||
jest.mock('../../blue_modules/BlueElectrum', () => {
|
||||
return {
|
||||
connectMain: jest.fn(),
|
||||
ensureConnected: jest.fn().mockResolvedValue(true),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@ -6,7 +6,7 @@ import { LightningCustodianWallet } from '../../class/wallets/lightning-custodia
|
||||
|
||||
jest.mock('../../blue_modules/BlueElectrum', () => {
|
||||
return {
|
||||
connectMain: jest.fn(),
|
||||
ensureConnected: jest.fn().mockResolvedValue(true),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@ -8,7 +8,7 @@ import { WatchOnlyWallet } from '../../class/wallets/watch-only-wallet';
|
||||
|
||||
jest.mock('../../blue_modules/BlueElectrum', () => {
|
||||
return {
|
||||
connectMain: jest.fn(),
|
||||
ensureConnected: jest.fn().mockResolvedValue(true),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user