UNPKG

blockbook-client

Version:

Client for interacting with Trezor's blockbook API

563 lines (509 loc) 20.2 kB
import { AxiosRequestConfig } from 'axios' import { assertType, DelegateLogger, isString, isUndefined, Logger } from '@bitaccess/ts-common' import * as t from 'io-ts' import WebSocket from 'ws' import { BlockHashResponseWs, EstimateFeeResponse, GetBlockOptions, SubscribeAddressesEvent, SubscribeNewBlockEvent, } from './types/common' import { XpubDetailsBasic, XpubDetailsTokens, XpubDetailsTokenBalances, XpubDetailsTxids, XpubDetailsTxs, BlockbookConfig, SystemInfo, BlockHashResponse, GetAddressDetailsOptions, UtxoDetails, UtxoDetailsXpub, GetUtxosOptions, GetXpubDetailsOptions, SendTxSuccess, SendTxError, Resolve, Reject, SystemInfoWs, } from './types' import { jsonRequest, USER_AGENT } from './utils' const xpubDetailsCodecs = { basic: XpubDetailsBasic, tokens: XpubDetailsTokens, tokenBalances: XpubDetailsTokenBalances, txids: XpubDetailsTxids, txs: XpubDetailsTxs, } type PendingWsRequests = { [id: string]: { resolve: Resolve; reject: Reject } } type SubscriptionData = { callback: (value: any) => void | Promise<void>; method: string; params: object } type SubscriptionIdToData = { [id: string]: SubscriptionData } /** * Blockbook client with support for both http and ws with multi-node and type validation support. * * Reference websocket implementation based on: * https://github.com/trezor/blockbook/blob/master/static/test-websocket.html */ export abstract class BaseBlockbook< NormalizedTx, SpecificTx, BlockInfo, AddressDetailsBasic, AddressDetailsTokens, AddressDetailsTokenBalances, AddressDetailsTxids, AddressDetailsTxs > { /** Blockbook URIs */ nodes: string[] /** Set to true to turn off response type validation */ disableTypeValidation: boolean /** Maximum number of milliseconds to wait for both http and ws requests to respond */ requestTimeoutMs: number /** * Base millisecond delay for exponential backoff reconnect logic. * Set to 0 to not reconnect on unexpected websocket closure */ reconnectDelayMs: number /** Websocket currently connected */ ws: WebSocket /** True if connected with websocket */ wsConnected: boolean = false /** Blockbook URI websocket is currently connected to */ wsConnectedNode?: string logger: Logger debug: boolean /** Count all requests. Used to load balance multiple nodes and identify ws messages */ private requestCounter = 0 /** Pending websocket connect promise. Used to prevent connect race case */ private wsPendingConnectPromise?: Promise<void> /** Interval to ping the websocket on to keep alive */ private pingIntervalId: NodeJS.Timeout /** Pending websocket request promise handlers mapped by id */ private pendingWsRequests: PendingWsRequests = {} /** Existing websocket subscriptions mapped by id */ private subscriptionIdToData: SubscriptionIdToData = {} /** Map existing subscription methods to ids */ private subscribtionMethodToId: Record<string, string> = {} /** Websocket closure codes that won't trigger automatic reconnect */ static WS_NORMAL_CLOSURE_CODES = [1000, 1005] constructor( config: BlockbookConfig, private normalizedTxCodec: t.Type<NormalizedTx>, private specificTxCodec: t.Type<SpecificTx>, private blockInfoCodec: t.Type<BlockInfo>, private addressDetailsCodecs: { basic: t.Type<AddressDetailsBasic> tokens: t.Type<AddressDetailsTokens> tokenBalances: t.Type<AddressDetailsTokenBalances> txids: t.Type<AddressDetailsTxids> txs: t.Type<AddressDetailsTxs> }, ) { config = assertType(BlockbookConfig, config) if (config.nodes.length === 0) { throw new Error('Blockbook node list must not be empty') } // trim trailing slash this.nodes = config.nodes.map(node => node.trim().replace(/\/$/, '')) // validate all responses by default this.disableTypeValidation = config.disableTypeValidation || false // fail fast by default this.requestTimeoutMs = config.requestTimeoutMs || 5000 // reconnect to failed ws quickly by default this.reconnectDelayMs = config.reconnectDelayMs || 2000 // prefix all log messages with package name. Default to null -> no logging this.logger = new DelegateLogger(config.logger ?? null, 'blockbook-client') // Turn on debug logging this.debug = process.env.DEBUG?.includes('blockbook-client') ?? false } doAssertType<T>(codec: t.Type<T, any, unknown>, value: unknown, ...rest: any[]): T { if (this.disableTypeValidation) { return value as T } return assertType(codec, value, ...rest) } /** Load balance using round robin. Helps any retry logic fallback to different nodes */ private pickNode() { return this.nodes[this.requestCounter++ % this.nodes.length] } async httpRequest( method: 'GET' | 'POST', path: string, params?: object, body?: AxiosRequestConfig['data'], options?: Partial<AxiosRequestConfig>, ) { 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: string, params?: object, idOption?: string): Promise<any> { const id = idOption ?? (this.requestCounter++).toString() const req = { id, method, params, } return new Promise((resolve, reject) => { setTimeout(() => { if (this.pendingWsRequests[id]?.reject === reject) { // Verify the same request is still pending 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)) }) } /** Subscribe to a websocket method */ async subscribe(method: string, params: object, callback: (result: any) => void | Promise<void>) { const id = (this.requestCounter++).toString() this.subscriptionIdToData[id] = { callback, method, params } let result try { result = await this.wsRequest(method, params, id) } catch (e) { // If request fails, delete our handler delete this.subscriptionIdToData[id] throw e } // Only one of each subscription can exist at once so delete the old one if it exists const oldSubscriptionId = this.subscribtionMethodToId[method] if (oldSubscriptionId) { delete this.subscriptionIdToData[oldSubscriptionId] } this.subscribtionMethodToId[method] = id return result } /** Unsubscribe from a particular websocket method that was previously subscribed */ async unsubscribe(method: string) { 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) } /** * Recursively reconnect to websocket with exponential backoff delay. * Resubscribe existingSubscriptions upon reconnecting. */ private reconnect(baseDelay: number, existingSubscriptions: SubscriptionData[]) { 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() // Resubscribe to subscriptions that existed before disconnection 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) } /** Reject all pending websocket requests with a given reason */ private rejectAllPendingRequests(reason: string) { for (let pendingRequestId of Object.keys(this.pendingWsRequests)) { const { reject } = this.pendingWsRequests[pendingRequestId] delete this.pendingWsRequests[pendingRequestId] reject(new Error(reason)) } } /** Establish a websocket connection to a node and return the node url if successful */ async connect(): Promise<string> { if (this.wsPendingConnectPromise) { await this.wsPendingConnectPromise // If successful, wsConnectedNode should be set at this point } 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' } // Store the promise before awaiting to prevent a race case this.wsPendingConnectPromise = new Promise<void>((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) }) }) // Wait for the connection before resolving 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) }) // Parse all incoming messages and forward them to any pending requests or subscriptions this.ws.on('message', data => { 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: string = '' if (result?.error) { errorMessage = result.error.message ?? 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?.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) }) // Periodically ping the server and disconnect when unresponsive this.pingIntervalId = setInterval(async () => { try { await this.wsRequest('ping', {}) } catch (e) { this.ws.terminate() // force close } }, 25000) return node } /* Close the websocket or do nothing if not connected */ async disconnect(): Promise<void> { 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?: string) { if (!this.wsConnected) { throw new Error(`Websocket must be connected to ${msg ?? ''}`) } } // ws getInfo async getInfo(): Promise<SystemInfoWs> { 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(): Promise<SystemInfo> { const response = await this.httpRequest('GET', '/api/v2') return this.doAssertType(SystemInfo, response) } async getBestBlock(): Promise<{ height: number; hash: string }> { 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: number): Promise<string> { 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: string): Promise<NormalizedTx> { 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: string): Promise<SpecificTx> { 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: string, options: GetAddressDetailsOptions & { details: 'basic' }, ): Promise<AddressDetailsBasic> async getAddressDetails( address: string, options: GetAddressDetailsOptions & { details: 'tokens' }, ): Promise<AddressDetailsTokens> async getAddressDetails( address: string, options: GetAddressDetailsOptions & { details: 'tokenBalances' }, ): Promise<AddressDetailsTokenBalances> async getAddressDetails( address: string, options?: (GetAddressDetailsOptions & { details: 'txids' | undefined }) | Omit<GetAddressDetailsOptions, 'details'>, ): Promise<AddressDetailsTxids> async getAddressDetails( address: string, options: GetAddressDetailsOptions & { details: 'txs' }, ): Promise<AddressDetailsTxs> async getAddressDetails(address: string, options: GetAddressDetailsOptions = {}) { 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: t.Mixed = this.addressDetailsCodecs[detailsLevel] return this.doAssertType(codec, response) } async getXpubDetails(xpub: string, options: GetXpubDetailsOptions & { details: 'basic' }): Promise<XpubDetailsBasic> async getXpubDetails(xpub: string, options: GetXpubDetailsOptions & { details: 'tokens' }): Promise<XpubDetailsTokens> async getXpubDetails( xpub: string, options: GetXpubDetailsOptions & { details: 'tokenBalances' }, ): Promise<XpubDetailsTokenBalances> async getXpubDetails( xpub: string, options?: (GetXpubDetailsOptions & { details: 'txids' | undefined }) | Omit<GetXpubDetailsOptions, 'details'>, ): Promise<XpubDetailsTxids> async getXpubDetails(xpub: string, options: GetXpubDetailsOptions & { details: 'txs' }): Promise<XpubDetailsTxs> async getXpubDetails(xpub: string, options: GetXpubDetailsOptions = {}) { 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: t.Mixed = xpubDetailsCodecs[detailsLevel] return this.doAssertType(codec, response) } async getUtxosForAddress(address: string, options: GetUtxosOptions = {}): Promise<UtxoDetails[]> { 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: string, options: GetUtxosOptions = {}): Promise<UtxoDetailsXpub[]> { 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: string | number, options: GetBlockOptions = {}): Promise<BlockInfo> { // http only const response = await this.httpRequest('GET', `/api/v2/block/${block}`, options) return this.doAssertType(this.blockInfoCodec, response) } async sendTx(txHex: string): Promise<string> { // NOTE: sendtx POST doesn't work without trailing slash, and sendtx GET fails for large txs 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: number): Promise<string> { const response = await this.httpRequest('GET', `/api/v2/estimatefee/${blockTarget}`) const { result: fee } = this.doAssertType(EstimateFeeResponse, response) return fee } /** * ws only - subscribe to new mempool transactions for particular addresses. Subsequent calls overwrite previous * address list subscription. */ async subscribeAddresses( addresses: string[], cb: (e: SubscribeAddressesEvent) => void | Promise<void>, ): Promise<{ subscribed: true }> { this.assertWsConnected('call subscribeAddresses') return this.subscribe('subscribeAddresses', { addresses }, cb) } /** * ws only - remove existing subscribeAddresses subscription */ async unsubscribeAddresses(): Promise<{ subscribed: false }> { this.assertWsConnected('call unsubscribeAddresses') return this.unsubscribe('subscribeAddresses') } /** * ws only - subscribe to new block events */ async subscribeNewBlock(cb: (e: SubscribeNewBlockEvent) => void | Promise<void>): Promise<{ subscribed: true }> { this.assertWsConnected('call subscribeNewBlock') return this.subscribe('subscribeNewBlock', {}, cb) } /** * ws only - remove existing subscribeAddresses subscription */ async unsubscribeNewBlock(): Promise<{ subscribed: false }> { this.assertWsConnected('call unsubscribeNewBlock') return this.unsubscribe('subscribeNewBlock') } }