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 type { Utxo } from '../class/wallets/types'; 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'; import { errorMessage } from '../helpers/errorMessage'; const ElectrumClient = require('electrum-client'); const net = require('net'); const tls = require('tls'); export type ElectrumTransaction = { txid: string; hash: string; version: number; size: number; vsize: number; weight: number; locktime: number; fee?: 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(); 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 | 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 = {}; 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 => { 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 { 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 { 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 { 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((_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 { if (!mainClient) return false; const client = mainClient; try { await Promise.race([ client.server_ping(), new Promise((_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 { const { showAlertOnFailure = false } = opts; if (await isDisabled()) { setConnectionState('disabled'); return false; } if (ensureInFlight) { if (showAlertOnFailure) ensureInFlightShowAlert = true; return ensureInFlight; } ensureInFlightShowAlert = showAlertOnFailure; ensureInFlight = (async (): Promise => { 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) { // Wait until the async disabled-preference check has completed and the alert has actually // been presented. Coalesced refresh callers may need to register dismissal work before // this ensureConnected() promise resolves. // eslint-disable-next-line @typescript-eslint/no-use-before-define -- defined later in file await presentNetworkErrorAlert(lastPeer); } return false; } finally { ensureInFlight = null; ensureInFlightShowAlert = false; } })(); return ensureInFlight; } export async function presentResetToDefaultsAlert(): Promise { 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; } await 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: async () => { const result = await presentResetToDefaultsAlert(); 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 { 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 { 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 { 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 { 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 { 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 => { 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) { if (errorMessage(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(errorMessage(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) { if (errorMessage(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(errorMessage(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; }; export const multiGetBalanceByAddress = async (addresses: string[], batchsize: number = 200): Promise => { if (!mainClient) throw new Error('Electrum client is not connected'); const ret = { balance: 0, unconfirmed_balance: 0, addresses: {} as Record, }; const chunks = splitIntoChunks(addresses, batchsize); for (const chunk of chunks) { const scripthashes = []; const scripthash2addr: Record = {}; 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 = {}; 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; }; /** raw row of blockchain.scripthash.listunspent */ type ElectrumUnspent = { tx_hash: string; tx_pos: number; height: number; value: number; }; export const multiGetUtxoByAddress = async function (addresses: string[], batchsize: number = 100): Promise> { if (!mainClient) throw new Error('Electrum client is not connected'); const ret: Record = {}; const chunks = splitIntoChunks(addresses, batchsize); for (const chunk of chunks) { const scripthashes = []; const scripthash2addr: Record = {}; 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: { param: string; result: ElectrumUnspent[] }[] = []; 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) { const address = scripthash2addr[utxos.param]; ret[address] = utxos.result.map(u => ({ address, txid: u.tx_hash, vout: u.tx_pos, height: u.height, value: u.value, })); } } return ret; }; export type ElectrumHistory = { tx_hash: string; height: number; address: string; }; export const multiGetHistoryByAddress = async function ( addresses: string[], batchsize: number = 100, ): Promise> { if (!mainClient) throw new Error('Electrum client is not connected'); const ret: Record = {}; const chunks = splitIntoChunks(addresses, batchsize); for (const chunk of chunks) { const scripthashes = []; const scripthash2addr: Record = {}; 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 = {}; 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 : Record type MultiGetTransactionByTxidResult = T extends true ? Record : Record; // TODO: this function returns different results based on the value of `verboseParam`, consider splitting it into two export async function multiGetTransactionByTxid( txids: string[], verbose: T, batchsize: number = 45, ): Promise> { 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 = {}; 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 = {}; 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) { if (errorMessage(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 (if caller asked for it): if (verbose) 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; } /** * Removes cached raw txhexes (non-verbose) of these txids, so next time they are fetched from the server. Used when a * cached copy fails verification (e.g. it was saved while connected to a malicious server) */ export async function forgetCachedTransactions(txids: string[]) { try { const realm = await _getRealm(); realm.write(() => { for (const txid of txids) { const cached = realm.objectForPrimaryKey('Cache', txid + '_non_verbose'); if (cached) realm.delete(cached); } }); } catch (writeError) { console.error('[electrum] Failed to forget transaction cache:', writeError); } } // 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} Satoshis per byte */ export const estimateFee = async function (numberOfBlocks: number): Promise { 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.ceil(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 { if (!mainClient) throw new Error('Electrum client is not connected'); try { return await mainClient.blockchainTransaction_broadcast(hex); } catch (error) { // electrum-client rejects JSON-RPC errors as `{ code, message }`, not Error throw new Error(errorMessage(error) || String(error)); } }; 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} Whether provided host:port is a valid electrum server */ export const testConnection = async function (host: string, tcpPort?: number, sslPort?: number): Promise { 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 { return mainClient.request('server.banner', []); } /** Seconds before `getCurrentBlockTip()` re-requests the header from the server. */ const TIP_CACHE_TTL_SEC = 60; /** Max blocks a cached tx height may lead the tip before we treat the cache entry as poisoned. */ const MAX_CACHE_AHEAD_OF_TIP = 6; async function fetchBlockTipFromServer(): Promise { if (!mainClient) { return latestBlock.height ?? null; } const now = Math.floor(+new Date() / 1000); try { const header = await mainClient.blockchainHeaders_subscribe(); if (header && header.height) { latestBlock = { height: header.height, time: now }; return header.height; } } catch (e) { console.warn('getCurrentBlockTip: subscribe failed', e); } return latestBlock.height ?? null; } export async function getCurrentBlockTip(): Promise { const now = Math.floor(+new Date() / 1000); if (latestBlock.height && now - latestBlock.time < TIP_CACHE_TTL_SEC) { return latestBlock.height; } if (!mainClient) { if (latestBlock.height) return latestBlock.height; throw new Error('Electrum client is not connected'); } const refreshed = await fetchBlockTipFromServer(); if (refreshed) return refreshed; return estimateCurrentBlockheight(); } export type ConfirmedBlockInfo = { height: number; tip: number }; /** * Returns the confirmed block height and current tip for a given txHash. * Both values share the same tip source so callers never mix an estimated tip with a real height. * 1. Tries the in-memory txhashHeightCache (populated from address history). * 2. Falls back to a DIRECT server call (bypassing Realm cache) to get fresh confirmations. * Uses standard Bitcoin convention: height = tip - confirmations + 1. */ export async function getConfirmedBlockHeight(txHash: string): Promise { let tip = await getCurrentBlockTip(); const cached = txhashHeightCache[txHash]; if (cached && cached > 0) { if (cached <= tip) { return { height: cached, tip }; } // Cached height ahead of tip: refresh tip (TTL may be stale), then re-validate. const refreshedTip = await fetchBlockTipFromServer(); if (refreshedTip != null) { tip = refreshedTip; } if (cached <= tip) { return { height: cached, tip }; } const aheadBy = cached - tip; if (aheadBy <= MAX_CACHE_AHEAD_OF_TIP) { // Address-history height is trustworthy; our tip is just lagging. return { height: cached, tip }; } delete txhashHeightCache[txHash]; } if (!mainClient) return null; try { const verboseTx = await mainClient.blockchainTransaction_get(txHash, true); if (typeof verboseTx === 'string') { // Server didn't support verbose — decode locally. // Without txhashHeightCache entry we can't determine height. return null; } const confirmations = Number(verboseTx?.confirmations); if (!confirmations || confirmations <= 0) return null; const height = tip - confirmations + 1; if (height <= 0 || height > tip) { return null; } txhashHeightCache[txHash] = height; return { height, tip }; } catch (e) { console.warn('getConfirmedBlockHeight: failed', e); return null; } } /** * Fetches actual block header timestamps from the Electrum server for the given heights. * Parses the 80-byte hex-encoded header to extract the 4-byte LE timestamp at byte offset 68. */ export async function getBlockTimestamps(heights: number[]): Promise> { if (!mainClient) throw new Error('Electrum client is not connected'); const result: Record = {}; const promises = heights.map(async height => { try { const headerHex: string = await mainClient.blockchainBlock_header(height); // timestamp is at bytes 68–71 of the 80-byte header (hex chars 136–143), little-endian uint32 const tsHex = headerHex.slice(136, 144); /* eslint-disable no-bitwise */ const timestamp = parseInt(tsHex.slice(0, 2), 16) | (parseInt(tsHex.slice(2, 4), 16) << 8) | (parseInt(tsHex.slice(4, 6), 16) << 16) | ((parseInt(tsHex.slice(6, 8), 16) << 24) >>> 0); /* eslint-enable no-bitwise */ result[height] = timestamp; } catch (e) { console.warn(`Failed to fetch block header for height ${height}:`, e); } }); await Promise.all(promises); return result; } const splitIntoChunks = function (arr: T[], chunkSize: number): T[][] { const groups: T[][] = []; 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; };