UNPKG

blockbook-client

Version:

Client for interacting with Trezor's blockbook API

353 lines 15.5 kB
import { assertType, DelegateLogger, isString, isUndefined } from '@bitaccess/ts-common'; import * as t from 'io-ts'; import WebSocket from 'ws'; import { BlockHashResponseWs, EstimateFeeResponse, } from './types/common'; import { XpubDetailsBasic, XpubDetailsTokens, XpubDetailsTokenBalances, XpubDetailsTxids, XpubDetailsTxs, BlockbookConfig, SystemInfo, BlockHashResponse, UtxoDetails, UtxoDetailsXpub, SendTxSuccess, SystemInfoWs, } from './types'; import { jsonRequest, USER_AGENT } from './utils'; const xpubDetailsCodecs = { basic: XpubDetailsBasic, tokens: XpubDetailsTokens, tokenBalances: XpubDetailsTokenBalances, txids: XpubDetailsTxids, txs: XpubDetailsTxs, }; export class BaseBlockbook { constructor(config, normalizedTxCodec, specificTxCodec, blockInfoCodec, addressDetailsCodecs) { var _a, _b, _c; this.normalizedTxCodec = normalizedTxCodec; this.specificTxCodec = specificTxCodec; this.blockInfoCodec = blockInfoCodec; this.addressDetailsCodecs = addressDetailsCodecs; this.wsConnected = false; this.requestCounter = 0; this.pendingWsRequests = {}; this.subscriptionIdToData = {}; this.subscribtionMethodToId = {}; config = assertType(BlockbookConfig, config); if (config.nodes.length === 0) { throw new Error('Blockbook node list must not be empty'); } this.nodes = config.nodes.map(node => node.trim().replace(/\/$/, '')); this.disableTypeValidation = config.disableTypeValidation || false; this.requestTimeoutMs = config.requestTimeoutMs || 5000; this.reconnectDelayMs = config.reconnectDelayMs || 2000; this.logger = new DelegateLogger((_a = config.logger) !== null && _a !== void 0 ? _a : null, 'blockbook-client'); this.debug = (_c = (_b = process.env.DEBUG) === null || _b === void 0 ? void 0 : _b.includes('blockbook-client')) !== null && _c !== void 0 ? _c : false; } doAssertType(codec, value, ...rest) { if (this.disableTypeValidation) { return value; } return assertType(codec, value, ...rest); } pickNode() { return this.nodes[this.requestCounter++ % this.nodes.length]; } async httpRequest(method, path, params, body, options) { const response = jsonRequest(this.pickNode(), method, path, params, body, { timeout: this.requestTimeoutMs, ...options, }); if (this.debug) { this.logger.debug(`http result ${method} ${path}`, response); } return response; } wsRequest(method, params, idOption) { const id = idOption !== null && idOption !== void 0 ? idOption : (this.requestCounter++).toString(); const req = { id, method, params, }; return new Promise((resolve, reject) => { setTimeout(() => { var _a; if (((_a = this.pendingWsRequests[id]) === null || _a === void 0 ? void 0 : _a.reject) === reject) { delete this.pendingWsRequests[id]; reject(new Error(`Timeout waiting for websocket ${method} response (id: ${id})`)); } }, this.requestTimeoutMs); this.pendingWsRequests[id] = { resolve, reject }; this.ws.send(JSON.stringify(req)); }); } async subscribe(method, params, callback) { const id = (this.requestCounter++).toString(); this.subscriptionIdToData[id] = { callback, method, params }; let result; try { result = await this.wsRequest(method, params, id); } catch (e) { delete this.subscriptionIdToData[id]; throw e; } const oldSubscriptionId = this.subscribtionMethodToId[method]; if (oldSubscriptionId) { delete this.subscriptionIdToData[oldSubscriptionId]; } this.subscribtionMethodToId[method] = id; return result; } async unsubscribe(method) { const subscriptionId = this.subscribtionMethodToId[method]; if (isUndefined(subscriptionId)) { return { subscribed: false }; } delete this.subscribtionMethodToId[method]; delete this.subscriptionIdToData[subscriptionId]; return this.wsRequest(`un${method}`, {}, subscriptionId); } reconnect(baseDelay, existingSubscriptions) { const reconnectMs = Math.round(baseDelay * (1 + Math.random())); this.logger.log(`socket reconnecting in ${reconnectMs / 1000}s to one of`, this.nodes); setTimeout(async () => { try { await this.connect(); for (let subscription of existingSubscriptions) { await this.subscribe(subscription.method, subscription.params, subscription.callback); } } catch (e) { this.reconnect(Math.max(60 * 1000, baseDelay * 2), existingSubscriptions); } }, reconnectMs); } rejectAllPendingRequests(reason) { for (let pendingRequestId of Object.keys(this.pendingWsRequests)) { const { reject } = this.pendingWsRequests[pendingRequestId]; delete this.pendingWsRequests[pendingRequestId]; reject(new Error(reason)); } } async connect() { if (this.wsPendingConnectPromise) { await this.wsPendingConnectPromise; } if (this.wsConnectedNode) { return this.wsConnectedNode; } this.pendingWsRequests = {}; this.subscriptionIdToData = {}; this.subscribtionMethodToId = {}; let node = this.pickNode(); if (node.startsWith('http')) { node = node.replace('http', 'ws'); } if (!node.startsWith('ws')) { node = `wss://${node}`; } if (!node.endsWith('/websocket')) { node += '/websocket'; } this.wsPendingConnectPromise = new Promise((resolve, reject) => { this.ws = new WebSocket(node, { headers: { 'user-agent': USER_AGENT } }); this.ws.once('open', () => { this.logger.log(`socket connected to ${node}`); this.wsConnected = true; this.wsConnectedNode = node; resolve(); }); this.ws.once('error', e => { this.logger.warn(`socket error connecting to ${node}`, e); this.ws.terminate(); reject(e); }); }); try { await this.wsPendingConnectPromise; } finally { delete this.wsPendingConnectPromise; } this.ws.on('close', code => { this.logger.warn(`socket connection to ${node} closed with code: ${code}`); this.wsConnected = false; this.wsConnectedNode = undefined; clearInterval(this.pingIntervalId); this.rejectAllPendingRequests('socket closed while waiting for response'); if (!BaseBlockbook.WS_NORMAL_CLOSURE_CODES.includes(code) && this.reconnectDelayMs > 0) { this.reconnect(this.reconnectDelayMs, Object.values(this.subscriptionIdToData)); } }); this.ws.on('error', e => { this.logger.warn(`socket error for ${node}`, e); }); this.ws.on('message', data => { var _a; if (this.debug) { this.logger.debug(`socket message from ${node}`, data); } if (!isString(data)) { this.logger.error(`Unrecognized websocket data type ${typeof data} received from ${node}`); return; } let response; try { response = JSON.parse(data); } catch (e) { this.logger.error(`Failed to parse websocket data received from ${node}`, e.toString()); return; } const id = response.id; if (!isString(id)) { this.logger.error(`Received websocket data without a valid ID from ${node}`, response); } const result = response.data; let errorMessage = ''; if (result === null || result === void 0 ? void 0 : result.error) { errorMessage = (_a = result.error.message) !== null && _a !== void 0 ? _a : data; } const pendingRequest = this.pendingWsRequests[id]; if (pendingRequest) { delete this.pendingWsRequests[id]; if (errorMessage) { return pendingRequest.reject(new Error(errorMessage)); } return pendingRequest.resolve(result); } const activeSubscription = this.subscriptionIdToData[id]; if (activeSubscription) { if (errorMessage) { this.logger.error(`Received error response for ${activeSubscription.method} subscription from ${node}`, errorMessage); } const maybePromise = activeSubscription.callback(result); if (maybePromise) { maybePromise === null || maybePromise === void 0 ? void 0 : maybePromise.catch(e => this.logger.error(`Error handling ${activeSubscription.method} subscription data (id: ${id})`, result, e)); } return; } this.logger.warn(`Unrecognized websocket data (id: ${id}) received from ${node}`, result); }); this.pingIntervalId = setInterval(async () => { try { await this.wsRequest('ping', {}); } catch (e) { this.ws.terminate(); } }, 25000); return node; } async disconnect() { if (!this.wsConnected) { return; } return new Promise((resolve, reject) => { this.ws.once('close', () => resolve()); this.ws.once('error', e => reject(e)); this.ws.close(); }); } assertWsConnected(msg) { if (!this.wsConnected) { throw new Error(`Websocket must be connected to ${msg !== null && msg !== void 0 ? msg : ''}`); } } async getInfo() { if (!this.wsConnected) { throw new Error('Websocket must be connected to call getInfo'); } const response = await this.wsRequest('getInfo'); return this.doAssertType(SystemInfoWs, response); } async getStatus() { const response = await this.httpRequest('GET', '/api/v2'); return this.doAssertType(SystemInfo, response); } async getBestBlock() { if (this.wsConnected) { const info = await this.getInfo(); return { height: info.bestHeight, hash: info.bestHash }; } const status = await this.getStatus(); return { height: status.blockbook.bestHeight, hash: status.backend.bestBlockHash }; } async getBlockHash(blockNumber) { if (this.wsConnected) { const response = await this.wsRequest('getBlockHash', { height: blockNumber }); const { hash } = this.doAssertType(BlockHashResponseWs, response); return hash; } const response = await this.httpRequest('GET', `/api/v2/block-index/${blockNumber}`); const { blockHash } = this.doAssertType(BlockHashResponse, response); return blockHash; } async getTx(txid) { const response = this.wsConnected ? await this.wsRequest('getTransaction', { txid }) : await this.httpRequest('GET', `/api/v2/tx/${txid}`); return this.doAssertType(this.normalizedTxCodec, response); } async getTxSpecific(txid) { const response = this.wsConnected ? await this.wsRequest('getTransactionSpecific', { txid }) : await this.httpRequest('GET', `/api/v2/tx-specific/${txid}`); return this.doAssertType(this.specificTxCodec, response); } async getAddressDetails(address, options = {}) { const detailsLevel = options.details || 'txids'; const response = this.wsConnected ? await this.wsRequest('getAccountInfo', { descriptor: address, ...options, details: detailsLevel }) : await this.httpRequest('GET', `/api/v2/address/${address}`, { ...options, details: detailsLevel }); const codec = this.addressDetailsCodecs[detailsLevel]; return this.doAssertType(codec, response); } async getXpubDetails(xpub, options = {}) { const tokens = options.tokens || 'derived'; const detailsLevel = options.details || 'txids'; const response = this.wsConnected ? await this.wsRequest('getAccountInfo', { descriptor: xpub, details: detailsLevel, tokens, ...options }) : await this.httpRequest('GET', `/api/v2/xpub/${xpub}`, { details: detailsLevel, tokens, ...options }); const codec = xpubDetailsCodecs[detailsLevel]; return this.doAssertType(codec, response); } async getUtxosForAddress(address, options = {}) { const response = this.wsConnected ? await this.wsRequest('getAccountUtxo', { descriptor: address, ...options }) : await this.httpRequest('GET', `/api/v2/utxo/${address}`, options); return this.doAssertType(t.array(UtxoDetails), response); } async getUtxosForXpub(xpub, options = {}) { const response = this.wsConnected ? await this.wsRequest('getAccountUtxo', { descriptor: xpub, ...options }) : await this.httpRequest('GET', `/api/v2/utxo/${xpub}`, options); return this.doAssertType(t.array(UtxoDetailsXpub), response); } async getBlock(block, options = {}) { const response = await this.httpRequest('GET', `/api/v2/block/${block}`, options); return this.doAssertType(this.blockInfoCodec, response); } async sendTx(txHex) { const response = this.wsConnected ? await this.wsRequest('sendTransaction', { hex: txHex }) : await this.httpRequest('POST', '/api/v2/sendtx/', undefined, txHex); const { result: txHash } = this.doAssertType(SendTxSuccess, response); return txHash; } async estimateFee(blockTarget) { const response = await this.httpRequest('GET', `/api/v2/estimatefee/${blockTarget}`); const { result: fee } = this.doAssertType(EstimateFeeResponse, response); return fee; } async subscribeAddresses(addresses, cb) { this.assertWsConnected('call subscribeAddresses'); return this.subscribe('subscribeAddresses', { addresses }, cb); } async unsubscribeAddresses() { this.assertWsConnected('call unsubscribeAddresses'); return this.unsubscribe('subscribeAddresses'); } async subscribeNewBlock(cb) { this.assertWsConnected('call subscribeNewBlock'); return this.subscribe('subscribeNewBlock', {}, cb); } async unsubscribeNewBlock() { this.assertWsConnected('call unsubscribeNewBlock'); return this.unsubscribe('subscribeNewBlock'); } } BaseBlockbook.WS_NORMAL_CLOSURE_CODES = [1000, 1005]; //# sourceMappingURL=BaseBlockbook.js.map