blockbook-client
Version:
Client for interacting with Trezor's blockbook API
353 lines • 15.5 kB
JavaScript
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