BlueWallet/blue_modules/BlueElectrum.ts
2026-05-28 13:04:49 +01:00

1516 lines
53 KiB
TypeScript

import BigNumber from 'bignumber.js';
import * as bitcoin from 'bitcoinjs-lib';
import DefaultPreference from 'react-native-default-preference';
import RNFS from 'react-native-fs';
import Realm from 'realm';
import { sha256 as _sha256 } from '@noble/hashes/sha256';
import type { LegacyWallet as LegacyWalletT } from '../class/wallets/legacy-wallet';
import type { SegwitBech32Wallet as SegwitBech32WalletT } from '../class/wallets/segwit-bech32-wallet';
import type { SegwitP2SHWallet as SegwitP2SHWalletT } from '../class/wallets/segwit-p2sh-wallet';
import type { TaprootWallet as TaprootWalletT } from '../class/wallets/taproot-wallet';
import presentAlert from '../components/Alert';
import loc from '../loc';
import { GROUP_IO_BLUEWALLET } from './currency';
import { ElectrumServerItem } from '../screen/settings/ElectrumSettings';
import { triggerWarningHapticFeedback } from './hapticFeedback';
import { AlertButton } from 'react-native';
import { uint8ArrayToHex, stringToUint8Array, hexToUint8Array } from './uint8array-extras/index';
const ElectrumClient = require('electrum-client');
const net = require('net');
const tls = require('tls');
type Utxo = {
height: number;
value: number;
address: string;
txid: string;
vout: number;
wif?: string;
};
export type ElectrumTransaction = {
txid: string;
hash: string;
version: number;
size: number;
vsize: number;
weight: number;
locktime: number;
vin: {
txid: string;
vout: number;
scriptSig: { asm: string; hex: string };
txinwitness: string[];
sequence: number;
addresses?: string[];
value?: number;
}[];
vout: {
value: number;
n: number;
scriptPubKey: {
asm: string;
hex: string;
reqSigs: number;
type: string;
addresses: string[];
};
}[];
// Confirmation-only fields: absent on mempool (unconfirmed) responses.
blockhash?: string;
confirmations?: number;
time?: number;
blocktime?: number;
};
export type ElectrumTransactionWithHex = ElectrumTransaction & {
hex: string;
};
type MempoolTransaction = {
height: 0;
tx_hash: string;
fee: number;
};
type Peer = {
host: string;
ssl?: number;
tcp?: number;
};
export const ELECTRUM_HOST = 'electrum_host';
export const ELECTRUM_TCP_PORT = 'electrum_tcp_port';
export const ELECTRUM_SSL_PORT = 'electrum_ssl_port';
export const ELECTRUM_SERVER_HISTORY = 'electrum_server_history';
const ELECTRUM_CONNECTION_DISABLED = 'electrum_disabled';
const storageKey = 'ELECTRUM_PEERS';
const defaultPeer = { host: 'electrum1.bluewallet.io', ssl: 443 };
export const hardcodedPeers: Peer[] = [
{ host: 'mainnet.foundationdevices.com', ssl: 50002 },
{ host: 'bitcoin.lu.ke', ssl: 50002 },
// { host: 'electrum.jochen-hoenicke.de', ssl: '50006' },
{ host: 'electrum1.bluewallet.io', ssl: 443 },
{ host: 'electrum.acinq.co', ssl: 50002 },
];
export const suggestedServers: Peer[] = hardcodedPeers.map(peer => ({
...peer,
}));
let mainClient: typeof ElectrumClient | undefined;
let serverName: string | false = false;
let disableBatching: boolean = false;
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 };
// --- 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.
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('[electrum] 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;
function bitcoinjs_crypto_sha256(buffer: Uint8Array): Uint8Array {
return _sha256(buffer);
}
async function _getRealm() {
if (_realm) return _realm;
const cacheFolderPath = RNFS.CachesDirectoryPath; // Path to cache folder
const password = uint8ArrayToHex(bitcoinjs_crypto_sha256(stringToUint8Array('fyegjitkyf[eqjnc.lf')));
const buf = hexToUint8Array(password + password);
const encryptionKey = Int8Array.from(buf);
const path = `${cacheFolderPath}/electrumcache.realm`; // Use cache folder path
const schema = [
{
name: 'Cache',
primaryKey: 'cache_key',
properties: {
cache_key: { type: 'string', indexed: true },
cache_value: 'string', // stringified json
},
},
];
// @ts-ignore schema doesn't match Realm's schema type
_realm = await Realm.open({
schema,
path,
encryptionKey,
excludeFromIcloudBackup: true,
});
return _realm;
}
export const getPreferredServer = async (): Promise<ElectrumServerItem | undefined> => {
try {
await DefaultPreference.setName(GROUP_IO_BLUEWALLET);
const host = (await DefaultPreference.get(ELECTRUM_HOST)) as string;
const tcpPort = await DefaultPreference.get(ELECTRUM_TCP_PORT);
const sslPort = await DefaultPreference.get(ELECTRUM_SSL_PORT);
console.log('[electrum] Getting preferred server:', { host, tcpPort, sslPort });
if (!host) {
console.warn('[electrum] Preferred server host is undefined');
return;
}
return {
host,
tcp: tcpPort ? Number(tcpPort) : undefined,
ssl: sslPort ? Number(sslPort) : undefined,
};
} catch (error) {
console.error('[electrum] Error in getPreferredServer:', error);
return undefined;
}
};
export const removePreferredServer = async () => {
try {
await DefaultPreference.setName(GROUP_IO_BLUEWALLET);
console.log('[electrum] Removing preferred server');
await DefaultPreference.clear(ELECTRUM_HOST);
await DefaultPreference.clear(ELECTRUM_TCP_PORT);
await DefaultPreference.clear(ELECTRUM_SSL_PORT);
} catch (error) {
console.error('[electrum] Error in removePreferredServer:', error);
}
};
export async function isDisabled(): Promise<boolean> {
let result;
try {
await DefaultPreference.setName(GROUP_IO_BLUEWALLET);
const savedValue = await DefaultPreference.get(ELECTRUM_CONNECTION_DISABLED);
console.log('[electrum] Getting Electrum connection disabled state:', savedValue);
if (savedValue === null) {
result = false;
} else {
result = savedValue;
}
} catch (error) {
console.error('[electrum] Error getting Electrum connection disabled state:', error);
result = false;
}
return !!result;
}
export async function setDisabled(disabled = true) {
await DefaultPreference.setName(GROUP_IO_BLUEWALLET);
console.log('[electrum] Setting Electrum connection disabled state to:', disabled);
const result = await DefaultPreference.set(ELECTRUM_CONNECTION_DISABLED, disabled ? '1' : '');
// Disabling must abort any in-flight ensureConnected() and tear down the live
// socket so callers don't have to remember to pair this with forceDisconnect().
// Without bumping the generation, an in-flight connect could race back to
// 'connected' after the user toggled Electrum off.
if (disabled) {
disconnectGeneration += 1;
if (mainClient) {
try {
mainClient.close();
} catch {}
mainClient = undefined;
}
setConnectionState('disabled');
}
return result;
}
function getCurrentPeer() {
return hardcodedPeers[currentPeerIndex];
}
/**
* Returns NEXT hardcoded electrum server (increments index after use)
*/
function getNextPeer() {
const peer = getCurrentPeer();
currentPeerIndex++;
if (currentPeerIndex >= hardcodedPeers.length) currentPeerIndex = 0;
return peer;
}
async function getSavedPeer(): Promise<Peer | null> {
try {
await DefaultPreference.setName(GROUP_IO_BLUEWALLET);
const host = (await DefaultPreference.get(ELECTRUM_HOST)) as string;
const tcpPort = await DefaultPreference.get(ELECTRUM_TCP_PORT);
const sslPort = await DefaultPreference.get(ELECTRUM_SSL_PORT);
console.log('[electrum] Getting saved peer:', { host, tcpPort, sslPort });
if (!host) {
return null;
}
if (sslPort) {
return { host, ssl: Number(sslPort) };
}
if (tcpPort) {
return { host, tcp: Number(tcpPort) };
}
return null;
} catch (error) {
console.error('[electrum] Error in getSavedPeer:', error);
return null;
}
}
/** 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(`[electrum] 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('[electrum] 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('[electrum] begin connection:', JSON.stringify(usingPeer));
const client = new ElectrumClient(net, tls, usingPeer.ssl || usingPeer.tcp, usingPeer.host, usingPeer.ssl ? 'tls' : 'tcp');
mainClient = client;
// 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] electrum mainClient.onError():', e.message);
scheduleReconnectFromClient(client, usingPeer, 'socket error');
};
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('[electrum] connected to ', ver);
serverName = ver[0];
if (ver[0].startsWith('ElectrumPersonalServer') || ver[0].startsWith('electrs') || ver[0].startsWith('Fulcrum')) {
disableBatching = true;
const [electrumImplementation, electrumVersion] = ver[0].split(' ');
switch (electrumImplementation) {
case 'electrs':
if (semVerToInt(electrumVersion) >= semVerToInt('0.9.0')) {
disableBatching = false;
}
break;
case 'electrs-esplora':
break;
case 'Fulcrum':
if (semVerToInt(electrumVersion) >= semVerToInt('1.9.0')) {
disableBatching = false;
}
break;
}
}
const header = await client.blockchainHeaders_subscribe();
if (header && header.height) {
latestBlock = {
height: header.height,
time: Math.floor(+new Date() / 1000),
};
}
return { ok: true, peer: usingPeer };
}
return { ok: false, peer: usingPeer };
} catch (e) {
console.log('[electrum] bad connection:', JSON.stringify(usingPeer), e);
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 (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(`[electrum] 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 a disconnect/disable raced us, the bumper already set the right
// state ('disconnected' or 'disabled'); don't clobber it from here.
if (aborted('post-ping')) 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;
}
// Generation-bumper (`forceDisconnect` or `setDisabled(true)`) already
// set the appropriate terminal state; we must not clobber 'disabled'
// back to 'disconnected' here.
if (aborted(`attempt ${i} start`)) return false;
const { ok, peer } = await attemptConnectOnce();
lastPeer = peer;
if (aborted(`attempt ${i} end`)) {
if (mainClient) {
try {
mainClient.close();
} catch {}
mainClient = undefined;
}
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> {
const hasPreferredServer = await getPreferredServer();
const serverHistoryStr = await DefaultPreference.get(ELECTRUM_SERVER_HISTORY);
const serverHistory = typeof serverHistoryStr === 'string' ? JSON.parse(serverHistoryStr) : [];
return new Promise(resolve => {
triggerWarningHapticFeedback();
const buttons: AlertButton[] = [];
if (hasPreferredServer?.host && (hasPreferredServer.tcp || hasPreferredServer.ssl)) {
buttons.push({
text: loc.settings.electrum_reset,
onPress: async () => {
try {
await DefaultPreference.setName(GROUP_IO_BLUEWALLET);
await DefaultPreference.clear(ELECTRUM_HOST);
await DefaultPreference.clear(ELECTRUM_SSL_PORT);
await DefaultPreference.clear(ELECTRUM_TCP_PORT);
} catch (e) {
console.log('[electrum]', e); // Must be running on Android
}
resolve(true);
},
style: 'default',
});
}
if (serverHistory.length > 0) {
buttons.push({
text: loc.settings.electrum_reset_to_default_and_clear_history,
onPress: async () => {
try {
await DefaultPreference.setName(GROUP_IO_BLUEWALLET);
await DefaultPreference.clear(ELECTRUM_SERVER_HISTORY);
await DefaultPreference.clear(ELECTRUM_HOST);
await DefaultPreference.clear(ELECTRUM_SSL_PORT);
await DefaultPreference.clear(ELECTRUM_TCP_PORT);
} catch (e) {
console.log('[electrum]', e); // Must be running on Android
}
resolve(true);
},
style: 'destructive',
});
}
buttons.push({
text: loc._.cancel,
onPress: () => resolve(false),
style: 'cancel',
});
presentAlert({
title: loc.settings.electrum_reset,
message: loc.settings.electrum_reset_to_default,
buttons,
options: { cancelable: true },
});
});
}
async function presentNetworkErrorAlert(usingPeer?: Peer, allowRepeat = false) {
if (await isDisabled()) {
console.log(
'[electrum] Electrum connection disabled by user. Perhaps we are attempting to show this network error alert after the user disabled connections.',
);
return;
}
presentAlert({
allowRepeat,
title: loc.errors.network,
message: loc.formatString(
usingPeer ? loc.settings.electrum_unable_to_connect : loc.settings.electrum_error_connect,
usingPeer ? { server: `${usingPeer.host}:${usingPeer.ssl ?? usingPeer.tcp}` } : {},
),
buttons: [
{
text: loc.wallets.list_tryagain,
onPress: () => {
forceDisconnect();
setTimeout(() => {
ensureConnected({ showAlertOnFailure: true }).catch(() => {});
}, 500);
},
style: 'default',
},
{
text: loc.settings.electrum_reset,
onPress: () => {
presentResetToDefaultsAlert().then(result => {
if (result) {
forceDisconnect();
setTimeout(() => {
ensureConnected({ showAlertOnFailure: true }).catch(() => {});
}, 500);
}
});
},
style: 'destructive',
},
{
text: loc._.cancel,
onPress: () => {
forceDisconnect();
},
style: 'cancel',
},
],
options: { cancelable: false },
});
}
/**
* Wallets list header when Electrum looks disconnected: same actions as the internal timeout alert, with allowRepeat so the user can open it again after dismiss.
*/
export async function presentElectrumDisconnectedHelpAlert(): Promise<void> {
await presentNetworkErrorAlert(undefined, true);
}
/**
* Returns random electrum server out of list of servers
* previous electrum server told us. Nearly half of them is
* usually offline.
* Not used for now.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async function getRandomDynamicPeer(): Promise<Peer> {
try {
let peers = JSON.parse((await DefaultPreference.get(storageKey)) as string);
peers = peers.sort(() => Math.random() - 0.5); // shuffle
for (const peer of peers) {
const ret: Peer = { host: peer[0], ssl: peer[1] };
ret.host = peer[1];
if (peer[1] === 's') {
ret.ssl = peer[2];
} else {
ret.tcp = peer[2];
}
for (const item of peer[2]) {
if (item.startsWith('t')) {
ret.tcp = item.replace('t', '');
}
}
if (ret.host && ret.tcp) return ret;
}
return defaultPeer; // failed to find random client, using default
} catch (_) {
return defaultPeer; // smth went wrong, using default
}
}
export const getBalanceByAddress = async function (address: string): Promise<{ confirmed: number; unconfirmed: number }> {
try {
if (!mainClient) throw new Error('Electrum client is not connected');
const script = bitcoin.address.toOutputScript(address);
const hash = bitcoinjs_crypto_sha256(script);
const reversedHash = new Uint8Array(hash).reverse();
const balance = await mainClient.blockchainScripthash_getBalance(uint8ArrayToHex(reversedHash));
balance.addr = address;
return balance;
} catch (error) {
console.error('[electrum] Error in getBalanceByAddress:', error);
throw error;
}
};
export const getConfig = async function () {
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,
// 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,
};
};
export const getSecondsSinceLastRequest = function () {
return mainClient && mainClient.timeLastCall ? (+new Date() - mainClient.timeLastCall) / 1000 : -1;
};
export const getTransactionsByAddress = async function (address: string): Promise<ElectrumHistory[]> {
if (!mainClient) throw new Error('Electrum client is not connected');
const script = bitcoin.address.toOutputScript(address);
const hash = bitcoinjs_crypto_sha256(script);
const reversedHash = new Uint8Array(hash).reverse();
const history = await mainClient.blockchainScripthash_getHistory(uint8ArrayToHex(reversedHash));
for (const h of history || []) {
if (h.tx_hash) txhashHeightCache[h.tx_hash] = h.height; // cache tx height
}
return history;
};
export const getMempoolTransactionsByAddress = async function (address: string): Promise<MempoolTransaction[]> {
if (!mainClient) throw new Error('Electrum client is not connected');
const script = bitcoin.address.toOutputScript(address);
const hash = bitcoinjs_crypto_sha256(script);
const reversedHash = new Uint8Array(hash).reverse();
return mainClient.blockchainScripthash_getMempool(uint8ArrayToHex(reversedHash));
};
/**
* 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
export function txhexToElectrumTransaction(txhex: string): ElectrumTransactionWithHex {
const tx = bitcoin.Transaction.fromHex(txhex);
const ret: ElectrumTransactionWithHex = {
txid: tx.getId(),
hash: tx.getId(),
version: tx.version,
size: Math.ceil(txhex.length / 2),
vsize: tx.virtualSize(),
weight: tx.weight(),
locktime: tx.locktime,
vin: [],
vout: [],
hex: txhex,
blockhash: '',
confirmations: 0,
time: 0,
blocktime: 0,
};
if (txhashHeightCache[ret.txid]) {
// got blockheight where this tx was confirmed
ret.confirmations = estimateCurrentBlockheight() - txhashHeightCache[ret.txid];
if (ret.confirmations < 0) {
// ugly fix for when estimator lags behind
ret.confirmations = 1;
}
ret.time = calculateBlockTime(txhashHeightCache[ret.txid]);
ret.blocktime = calculateBlockTime(txhashHeightCache[ret.txid]);
}
for (const inn of tx.ins) {
const txinwitness = [];
if (inn.witness[0]) txinwitness.push(uint8ArrayToHex(inn.witness[0]));
if (inn.witness[1]) txinwitness.push(uint8ArrayToHex(inn.witness[1]));
ret.vin.push({
txid: uint8ArrayToHex(new Uint8Array(inn.hash).reverse()),
vout: inn.index,
scriptSig: { hex: uint8ArrayToHex(inn.script), asm: '' },
txinwitness,
sequence: inn.sequence,
});
}
let n = 0;
for (const out of tx.outs) {
const value = new BigNumber(out.value).dividedBy(100000000).toNumber();
let address: false | string = false;
let type: false | string = false;
// Lazy require to avoid the module-scope cycle described above. These
// modules are fully loaded by the time this function is actually invoked.
const { SegwitBech32Wallet } = require('../class/wallets/segwit-bech32-wallet') as {
SegwitBech32Wallet: typeof SegwitBech32WalletT;
};
const { SegwitP2SHWallet } = require('../class/wallets/segwit-p2sh-wallet') as {
SegwitP2SHWallet: typeof SegwitP2SHWalletT;
};
const { LegacyWallet } = require('../class/wallets/legacy-wallet') as {
LegacyWallet: typeof LegacyWalletT;
};
const { TaprootWallet } = require('../class/wallets/taproot-wallet') as {
TaprootWallet: typeof TaprootWalletT;
};
if (SegwitBech32Wallet.scriptPubKeyToAddress(uint8ArrayToHex(out.script))) {
address = SegwitBech32Wallet.scriptPubKeyToAddress(uint8ArrayToHex(out.script));
type = 'witness_v0_keyhash';
} else if (SegwitP2SHWallet.scriptPubKeyToAddress(uint8ArrayToHex(out.script))) {
address = SegwitP2SHWallet.scriptPubKeyToAddress(uint8ArrayToHex(out.script));
type = '???'; // TODO
} else if (LegacyWallet.scriptPubKeyToAddress(uint8ArrayToHex(out.script))) {
address = LegacyWallet.scriptPubKeyToAddress(uint8ArrayToHex(out.script));
type = '???'; // TODO
} else {
address = TaprootWallet.scriptPubKeyToAddress(uint8ArrayToHex(out.script));
type = 'witness_v1_taproot';
}
if (!address) {
throw new Error('Internal error: unable to decode address from output script');
}
ret.vout.push({
value,
n,
scriptPubKey: {
asm: '',
hex: uint8ArrayToHex(out.script),
reqSigs: 1, // todo
type,
addresses: [address],
},
});
n++;
}
return ret;
}
export const getTransactionsFullByAddress = async (address: string): Promise<ElectrumTransaction[]> => {
const txs = await getTransactionsByAddress(address);
const ret: ElectrumTransaction[] = [];
for (const tx of txs) {
let full;
try {
full = await mainClient.blockchainTransaction_get(tx.tx_hash, true);
} catch (error: any) {
if (String(error?.message ?? error).startsWith('verbose transactions are currently unsupported')) {
// apparently, stupid esplora instead of returning txhex when it cant return verbose tx started
// throwing a proper exception. lets fetch txhex manually and decode on our end
const txhex = await mainClient.blockchainTransaction_get(tx.tx_hash, false);
full = txhexToElectrumTransaction(txhex);
} else {
// nope, its something else
throw new Error(String(error?.message ?? error));
}
}
full.address = address;
for (const input of full.vin) {
// now we need to fetch previous TX where this VIN became an output, so we can see its amount
let prevTxForVin;
try {
prevTxForVin = await mainClient.blockchainTransaction_get(input.txid, true);
} catch (error: any) {
if (String(error?.message ?? error).startsWith('verbose transactions are currently unsupported')) {
// apparently, stupid esplora instead of returning txhex when it cant return verbose tx started
// throwing a proper exception. lets fetch txhex manually and decode on our end
const txhex = await mainClient.blockchainTransaction_get(input.txid, false);
prevTxForVin = txhexToElectrumTransaction(txhex);
} else {
// nope, its something else
throw new Error(String(error?.message ?? error));
}
}
if (prevTxForVin && prevTxForVin.vout && prevTxForVin.vout[input.vout]) {
input.value = prevTxForVin.vout[input.vout].value;
// also, we extract destination address from prev output:
if (prevTxForVin.vout[input.vout].scriptPubKey && prevTxForVin.vout[input.vout].scriptPubKey.addresses) {
input.addresses = prevTxForVin.vout[input.vout].scriptPubKey.addresses;
}
// in bitcoin core 22.0.0+ they removed `.addresses` and replaced it with plain `.address`:
if (prevTxForVin.vout[input.vout]?.scriptPubKey?.address) {
input.addresses = [prevTxForVin.vout[input.vout].scriptPubKey.address];
}
}
}
for (const output of full.vout) {
if (output?.scriptPubKey && output.scriptPubKey.addresses) output.addresses = output.scriptPubKey.addresses;
// in bitcoin core 22.0.0+ they removed `.addresses` and replaced it with plain `.address`:
if (output?.scriptPubKey?.address) output.addresses = [output.scriptPubKey.address];
}
full.inputs = full.vin;
full.outputs = full.vout;
delete full.vin;
delete full.vout;
delete full.hex; // compact
delete full.hash; // compact
ret.push(full);
}
return ret;
};
type MultiGetBalanceResponse = {
balance: number;
unconfirmed_balance: number;
addresses: Record<string, { confirmed: number; unconfirmed: number }>;
};
export const multiGetBalanceByAddress = async (addresses: string[], batchsize: number = 200): Promise<MultiGetBalanceResponse> => {
if (!mainClient) throw new Error('Electrum client is not connected');
const ret = {
balance: 0,
unconfirmed_balance: 0,
addresses: {} as Record<string, { confirmed: number; unconfirmed: number }>,
};
const chunks = splitIntoChunks(addresses, batchsize);
for (const chunk of chunks) {
const scripthashes = [];
const scripthash2addr: Record<string, string> = {};
for (const addr of chunk) {
const script = bitcoin.address.toOutputScript(addr);
const hash = bitcoinjs_crypto_sha256(script);
const reversedHash = uint8ArrayToHex(new Uint8Array(hash).reverse());
scripthashes.push(reversedHash);
scripthash2addr[reversedHash] = addr;
}
let balances = [];
if (disableBatching) {
const promises = [];
const index2scripthash: Record<number, string> = {};
for (let promiseIndex = 0; promiseIndex < scripthashes.length; promiseIndex++) {
promises.push(mainClient.blockchainScripthash_getBalance(scripthashes[promiseIndex]));
index2scripthash[promiseIndex] = scripthashes[promiseIndex];
}
const promiseResults = await Promise.all(promises);
for (let resultIndex = 0; resultIndex < promiseResults.length; resultIndex++) {
balances.push({ result: promiseResults[resultIndex], param: index2scripthash[resultIndex] });
}
} else {
balances = await mainClient.blockchainScripthash_getBalanceBatch(scripthashes);
}
for (const bal of balances) {
if (bal.error) console.warn('[electrum] multiGetBalanceByAddress():', bal.error);
ret.balance += +bal.result.confirmed;
ret.unconfirmed_balance += +bal.result.unconfirmed;
ret.addresses[scripthash2addr[bal.param]] = bal.result;
}
}
return ret;
};
export const multiGetUtxoByAddress = async function (addresses: string[], batchsize: number = 100): Promise<Record<string, Utxo[]>> {
if (!mainClient) throw new Error('Electrum client is not connected');
const ret: Record<string, any> = {};
const chunks = splitIntoChunks(addresses, batchsize);
for (const chunk of chunks) {
const scripthashes = [];
const scripthash2addr: Record<string, string> = {};
for (const addr of chunk) {
const script = bitcoin.address.toOutputScript(addr);
const hash = bitcoinjs_crypto_sha256(script);
const reversedHash = uint8ArrayToHex(new Uint8Array(hash).reverse());
scripthashes.push(reversedHash);
scripthash2addr[reversedHash] = addr;
}
let results = [];
if (disableBatching) {
// ElectrumPersonalServer doesnt support `blockchain.scripthash.listunspent`
// electrs OTOH supports it, but we dont know it we are currently connected to it or to EPS
// so it is pretty safe to do nothing, as caller can derive UTXO from stored transactions
} else {
results = await mainClient.blockchainScripthash_listunspentBatch(scripthashes);
}
for (const utxos of results) {
ret[scripthash2addr[utxos.param]] = utxos.result;
for (const utxo of ret[scripthash2addr[utxos.param]]) {
utxo.address = scripthash2addr[utxos.param];
utxo.txid = utxo.tx_hash;
utxo.vout = utxo.tx_pos;
delete utxo.tx_pos;
delete utxo.tx_hash;
}
}
}
return ret;
};
export type ElectrumHistory = {
tx_hash: string;
height: number;
address: string;
};
export const multiGetHistoryByAddress = async function (
addresses: string[],
batchsize: number = 100,
): Promise<Record<string, ElectrumHistory[]>> {
if (!mainClient) throw new Error('Electrum client is not connected');
const ret: Record<string, ElectrumHistory[]> = {};
const chunks = splitIntoChunks(addresses, batchsize);
for (const chunk of chunks) {
const scripthashes = [];
const scripthash2addr: Record<string, string> = {};
for (const addr of chunk) {
const script = bitcoin.address.toOutputScript(addr);
const hash = bitcoinjs_crypto_sha256(script);
const reversedHash = uint8ArrayToHex(new Uint8Array(hash).reverse());
scripthashes.push(reversedHash);
scripthash2addr[reversedHash] = addr;
}
let results = [];
if (disableBatching) {
const promises = [];
const index2scripthash: Record<number, string> = {};
for (let promiseIndex = 0; promiseIndex < scripthashes.length; promiseIndex++) {
index2scripthash[promiseIndex] = scripthashes[promiseIndex];
promises.push(mainClient.blockchainScripthash_getHistory(scripthashes[promiseIndex]));
}
const histories = await Promise.all(promises);
for (let historyIndex = 0; historyIndex < histories.length; historyIndex++) {
results.push({ result: histories[historyIndex], param: index2scripthash[historyIndex] });
}
} else {
results = await mainClient.blockchainScripthash_getHistoryBatch(scripthashes);
}
for (const history of results) {
if (history.error) console.warn('[electrum] multiGetHistoryByAddress():', history.error);
ret[scripthash2addr[history.param]] = history.result || [];
for (const result of history.result || []) {
if (result.tx_hash) txhashHeightCache[result.tx_hash] = result.height; // cache tx height
}
for (const hist of ret[scripthash2addr[history.param]]) {
hist.address = scripthash2addr[history.param];
}
}
}
return ret;
};
// if verbose === true ? Record<string, ElectrumTransaction> : Record<string, string>
type MultiGetTransactionByTxidResult<T extends boolean> = T extends true ? Record<string, ElectrumTransaction> : Record<string, string>;
// TODO: this function returns different results based on the value of `verboseParam`, consider splitting it into two
export async function multiGetTransactionByTxid<T extends boolean>(
txids: string[],
verbose: T,
batchsize: number = 45,
): Promise<MultiGetTransactionByTxidResult<T>> {
txids = txids.filter(txid => !!txid); // failsafe: removing 'undefined' or other falsy stuff from txids array
// this value is fine-tuned so althrough wallets in test suite will occasionally
// throw 'response too large (over 1,000,000 bytes', test suite will pass
if (!mainClient) throw new Error('Electrum client is not connected');
const ret: MultiGetTransactionByTxidResult<T> = {};
txids = [...new Set(txids)]; // deduplicate just for any case
// lets try cache first:
const realm = await _getRealm();
const cacheKeySuffix = verbose ? '_verbose' : '_non_verbose';
const keysCacheMiss = [];
for (const txid of txids) {
const jsonString = realm.objectForPrimaryKey('Cache', txid + cacheKeySuffix); // search for a realm object with a primary key
if (jsonString && jsonString.cache_value) {
try {
ret[txid] = JSON.parse(jsonString.cache_value as string);
} catch (error) {
console.log('[electrum]', error, 'cache failed to parse', jsonString.cache_value);
}
}
if (!ret[txid]) keysCacheMiss.push(txid);
}
if (keysCacheMiss.length === 0) {
return ret;
}
txids = keysCacheMiss;
// end cache
const chunks = splitIntoChunks(txids, batchsize);
for (const chunk of chunks) {
let results = [];
if (disableBatching) {
try {
// in case of ElectrumPersonalServer it might not track some transactions (like source transactions for our transactions)
// so we wrap it in try-catch. note, when `Promise.all` fails we will get _zero_ results, but we have a fallback for that
const promises = [];
const index2txid: Record<number, string> = {};
for (let promiseIndex = 0; promiseIndex < chunk.length; promiseIndex++) {
const txid = chunk[promiseIndex];
index2txid[promiseIndex] = txid;
promises.push(mainClient.blockchainTransaction_get(txid, verbose));
}
const transactionResults = await Promise.all(promises);
for (let resultIndex = 0; resultIndex < transactionResults.length; resultIndex++) {
let tx = transactionResults[resultIndex];
if (typeof tx === 'string' && verbose) {
// apparently electrum server (EPS?) didnt recognize VERBOSE parameter, and sent us plain txhex instead of decoded tx.
// lets decode it manually on our end then:
tx = txhexToElectrumTransaction(tx);
}
const txid = index2txid[resultIndex];
results.push({ result: tx, param: txid });
}
} catch (error: any) {
if (String(error?.message ?? error).startsWith('verbose transactions are currently unsupported')) {
// electrs-esplora. cant use verbose, so fetching txs one by one and decoding locally
for (const txid of chunk) {
try {
let tx = await mainClient.blockchainTransaction_get(txid, false);
tx = txhexToElectrumTransaction(tx);
results.push({ result: tx, param: txid });
} catch (err) {
console.log('[electrum]', err);
}
}
} else {
// fallback. pretty sure we are connected to EPS. we try getting transactions one-by-one. this way we wont
// fail and only non-tracked by EPS transactions will be omitted
for (const txid of chunk) {
try {
let tx = await mainClient.blockchainTransaction_get(txid, verbose);
if (typeof tx === 'string' && verbose) {
// apparently electrum server (EPS?) didnt recognize VERBOSE parameter, and sent us plain txhex instead of decoded tx.
// lets decode it manually on our end then:
tx = txhexToElectrumTransaction(tx);
}
results.push({ result: tx, param: txid });
} catch (err) {
console.log('[electrum]', err);
}
}
}
}
} else {
results = await mainClient.blockchainTransaction_getBatch(chunk, verbose);
}
for (const txdata of results) {
if (txdata.error && txdata.error.code === -32600) {
// response too large
// lets do single call, that should go through okay:
txdata.result = await mainClient.blockchainTransaction_get(txdata.param, false);
// since we used VERBOSE=false, server sent us plain txhex which we must decode on our end:
txdata.result = txhexToElectrumTransaction(txdata.result);
}
ret[txdata.param] = txdata.result;
// @ts-ignore: hex property
if (ret[txdata.param]) delete ret[txdata.param].hex; // compact
}
}
// in bitcoin core 22.0.0+ they removed `.addresses` and replaced it with plain `.address`:
for (const txid of Object.keys(ret)) {
const tx = ret[txid];
if (typeof tx === 'string') continue;
for (const vout of tx?.vout ?? []) {
// @ts-ignore: address is not in type definition
if (vout?.scriptPubKey?.address) vout.scriptPubKey.addresses = [vout.scriptPubKey.address];
}
}
// saving cache:
try {
realm.write(() => {
for (const txid of Object.keys(ret)) {
const tx = ret[txid];
// dont cache immature txs, but only for 'verbose', since its fully decoded tx jsons. non-verbose are just plain
// strings txhex
if (verbose && typeof tx !== 'string' && (!tx?.confirmations || tx.confirmations < 7)) {
continue;
}
realm.create(
'Cache',
{
cache_key: txid + cacheKeySuffix,
cache_value: JSON.stringify(ret[txid]),
},
Realm.UpdateMode.Modified,
);
}
});
} catch (writeError) {
console.error('[electrum] Failed to write transaction cache:', writeError);
}
return ret;
}
// Returns the value at a given percentile in a sorted numeric array.
// "Linear interpolation between closest ranks" method
function percentile(arr: number[], p: number) {
if (arr.length === 0) return 0;
if (typeof p !== 'number') throw new TypeError('p must be a number');
if (p <= 0) return arr[0];
if (p >= 1) return arr[arr.length - 1];
const index = (arr.length - 1) * p;
const lower = Math.floor(index);
const upper = lower + 1;
const weight = index % 1;
if (upper >= arr.length) return arr[lower];
return arr[lower] * (1 - weight) + arr[upper] * weight;
}
/**
* The histogram is an array of [fee, vsize] pairs, where vsizen is the cumulative virtual size of mempool transactions
* with a fee rate in the interval [feen-1, feen], and feen-1 > feen.
*/
export const calcEstimateFeeFromFeeHistorgam = function (numberOfBlocks: number, feeHistorgram: number[][]) {
// first, transforming histogram:
let totalVsize = 0;
const histogramToUse = [];
for (const h of feeHistorgram) {
let [fee, vsize] = h;
let timeToStop = false;
if (totalVsize + vsize >= 1000000 * numberOfBlocks) {
vsize = 1000000 * numberOfBlocks - totalVsize; // only the difference between current summarized sige to tip of the block
timeToStop = true;
}
histogramToUse.push({ fee, vsize });
totalVsize += vsize;
if (timeToStop) break;
}
// now we have histogram of precisely size for numberOfBlocks.
// lets spread it into flat array so its easier to calculate percentile:
let histogramFlat: number[] = [];
for (const hh of histogramToUse) {
histogramFlat = histogramFlat.concat(Array(Math.round(hh.vsize / 25000)).fill(hh.fee));
// division is needed so resulting flat array is not too huge
}
histogramFlat = histogramFlat.sort(function (a, b) {
return a - b;
});
return Math.round(percentile(histogramFlat, 0.5) || 1);
};
export const estimateFees = async function (): Promise<{ fast: number; medium: number; slow: number }> {
let histogram;
let timeoutId;
try {
histogram = await Promise.race([
mainClient.mempool_getFeeHistogram(),
new Promise(resolve => (timeoutId = setTimeout(resolve, 15000))),
]);
} finally {
clearTimeout(timeoutId);
}
// fetching what electrum (which uses bitcoin core) thinks about fees:
const _fast = await estimateFee(1);
const _medium = await estimateFee(18);
const _slow = await estimateFee(144);
/**
* sanity check, see
* @see https://github.com/cculianu/Fulcrum/issues/197
* (fallback to bitcoin core estimates)
*/
if (!histogram || histogram?.[0]?.[0] > 1000) return { fast: _fast, medium: _medium, slow: _slow };
// calculating fast fees from mempool:
const fast = Math.max(2, calcEstimateFeeFromFeeHistorgam(1, histogram));
// recalculating medium and slow fees using bitcoincore estimations only like relative weights:
// (minimum 1 sat, just for any case)
const medium = Math.max(1, Math.round((fast * _medium) / _fast));
const slow = Math.max(1, Math.round((fast * _slow) / _fast));
return { fast, medium, slow };
};
/**
* Returns the estimated transaction fee to be confirmed within a certain number of blocks
*
* @param numberOfBlocks {number} The number of blocks to target for confirmation
* @returns {Promise<number>} Satoshis per byte
*/
export const estimateFee = async function (numberOfBlocks: number): Promise<number> {
if (!mainClient) throw new Error('Electrum client is not connected');
numberOfBlocks = numberOfBlocks || 1;
const coinUnitsPerKilobyte = await mainClient.blockchainEstimatefee(numberOfBlocks);
if (coinUnitsPerKilobyte === -1) return 1;
return Math.round(new BigNumber(coinUnitsPerKilobyte).dividedBy(1024).multipliedBy(100000000).toNumber());
};
export const serverFeatures = async function () {
if (!mainClient) throw new Error('Electrum client is not connected');
return mainClient.server_features();
};
export const broadcast = async function (hex: string) {
if (!mainClient) throw new Error('Electrum client is not connected');
try {
const res = await mainClient.blockchainTransaction_broadcast(hex);
return res;
} catch (error) {
return error;
}
};
export const broadcastV2 = async function (hex: string): Promise<string> {
if (!mainClient) throw new Error('Electrum client is not connected');
return mainClient.blockchainTransaction_broadcast(hex);
};
export const estimateCurrentBlockheight = function (): number {
if (latestBlock.height) {
const timeDiff = Math.floor(+new Date() / 1000) - latestBlock.time;
const extraBlocks = Math.floor(timeDiff / (9.93 * 60));
return latestBlock.height + extraBlocks;
}
const baseTs = 1587570465609; // uS
const baseHeight = 627179;
return Math.floor(baseHeight + (+new Date() - baseTs) / 1000 / 60 / 9.93);
};
export const calculateBlockTime = function (height: number): number {
if (latestBlock.height) {
return Math.floor(latestBlock.time + (height - latestBlock.height) * 9.93 * 60);
}
const baseTs = 1585837504; // sec
const baseHeight = 624083;
return Math.floor(baseTs + (height - baseHeight) * 9.93 * 60);
};
/**
* @returns {Promise<boolean>} Whether provided host:port is a valid electrum server
*/
export const testConnection = async function (host: string, tcpPort?: number, sslPort?: number): Promise<boolean> {
const client = new ElectrumClient(net, tls, sslPort || tcpPort, host, sslPort ? 'tls' : 'tcp');
client.onError = () => {}; // mute
let timeoutId: NodeJS.Timeout | undefined;
const timeoutMs = host.endsWith('.onion') ? 21_000 : 5_000;
try {
const rez = await Promise.race([
new Promise(resolve => {
timeoutId = setTimeout(() => resolve('timeout'), timeoutMs);
}),
client.connect(),
]);
if (rez === 'timeout') return false;
await client.server_version('2.7.11', '1.4');
await client.server_ping();
return true;
} catch (_) {
} finally {
if (timeoutId) clearTimeout(timeoutId);
client.close();
}
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 => {
disconnectGeneration += 1;
if (mainClient) {
try {
mainClient.close();
} catch {}
mainClient = undefined;
}
setConnectionState('disconnected');
};
export const setBatchingDisabled = () => {
disableBatching = true;
};
export const setBatchingEnabled = () => {
disableBatching = false;
};
export function getServerBanner(): Promise<string> {
return mainClient.request('server.banner', []);
}
const splitIntoChunks = function (arr: any[], chunkSize: number) {
const groups = [];
let i;
for (i = 0; i < arr.length; i += chunkSize) {
groups.push(arr.slice(i, i + chunkSize));
}
return groups;
};
const semVerToInt = function (semver: string): number {
if (!semver) return 0;
if (semver.split('.').length !== 3) return 0;
const ret = Number(semver.split('.')[0]) * 1000000 + Number(semver.split('.')[1]) * 1000 + Number(semver.split('.')[2]) * 1;
if (isNaN(ret)) return 0;
return ret;
};