UNPKG

bitcore-node

Version:

A blockchain indexing node with extended capabilities using bitcore

564 lines (503 loc) 20.1 kB
import os from 'os'; import request = require('request'); import Web3 from 'web3'; import config from '../../../config'; import logger from '../../../logger'; import { MongoBound } from '../../../models/base'; import { CacheStorage } from '../../../models/cache'; import { CoinEvent } from '../../../models/events'; import { WalletAddressStorage } from '../../../models/walletAddress'; import { BaseEVMStateProvider, BuildWalletTxsStreamParams } from '../../../providers/chain-state/evm/api/csp'; import { EVMBlockStorage } from '../../../providers/chain-state/evm/models/block'; import { EVMTransactionStorage } from '../../../providers/chain-state/evm/models/transaction'; import { EVMTransactionJSON, GethTraceCall, IEVMBlock, IEVMTransactionTransformed, Transaction } from '../../../providers/chain-state/evm/types'; import { ExternalApiStream } from '../../../providers/chain-state/external/streams/apiStream'; import { IBlock } from '../../../types/Block'; import { ChainId, ChainNetwork } from '../../../types/ChainNetwork'; import { IAddressSubscription } from '../../../types/ExternalProvider'; import { GetBlockBeforeTimeParams, GetBlockParams, StreamAddressUtxosParams, StreamBlocksParams, StreamTransactionParams, StreamWalletTransactionsParams } from '../../../types/namespaces/ChainStateProvider'; import { isDateValid } from '../../../utils'; import { ReadableWithEventPipe } from '../../../utils/streamWithEventPipe'; export interface MoralisAddressSubscription { id?: string; message?: string; status?: string; } export class MoralisStateProvider extends BaseEVMStateProvider { baseUrl = 'https://deep-index.moralis.io/api/v2.2'; baseStreamUrl = 'https://api.moralis-streams.com/streams/evm'; apiKey = config.externalProviders?.moralis?.apiKey; baseWebhookurl = config.externalProviders?.moralis?.webhookBaseUrl; headers = { 'Content-Type': 'application/json', 'X-API-Key': this.apiKey, }; constructor(chain: string) { super(chain); } // @override async getBlockBeforeTime(params: GetBlockBeforeTimeParams): Promise<IBlock|null> { const { chain, network, time } = params; const date = new Date(time || Date.now()); const chainId = await this.getChainId({ network }); const blockNum = await this._getBlockNumberByDate({ chainId, date }); if (!blockNum) { return null; } const blockId = blockNum.toString(); const blocks = await this._getBlocks({ chain, network, blockId, args: { limit: 1 } }); return blocks.blocks[0] || null; } // @override async getFee(params) { let { network, target = 4, txType } = params; const chain = this.chain; if (network === 'livenet') { network = 'mainnet'; } let cacheKey = `getFee-${chain}-${network}-${target}`; if (txType) { cacheKey += `-type${txType}`; } return CacheStorage.getGlobalOrRefresh( cacheKey, async () => { let feerate; const { rpc } = await this.getWeb3(network, { type: 'historical' }); feerate = await rpc.estimateFee({ nBlocks: target, txType }); return { feerate, blocks: target }; }, CacheStorage.Times.Minute ); } // @override async getLocalTip({ chain, network }): Promise<IBlock> { const { web3 } = await this.getWeb3(network); const block = await web3.eth.getBlock('latest'); return EVMBlockStorage.convertRawBlock(chain, network, block); } // @override async streamBlocks(params: StreamBlocksParams) { const { chain, network, req, res } = params; const { web3 } = await this.getWeb3(network); const chainId = await this.getChainId({ network }); const blockRange = await this.getBlocksRange({ ...params, chainId }); const tipHeight = await web3.eth.getBlockNumber(); let isReading = false; const stream = new ReadableWithEventPipe({ objectMode: true, async read() { if (isReading) { return; } isReading = true; let block; let nextBlock; try { for (const blockNum of blockRange) { // stage next block in new var so `nextBlock` doesn't get overwritten if needed for `block` const thisNextBlock = parseInt(block?.number) === blockNum + 1 ? block : await web3.eth.getBlock(blockNum + 1); block = parseInt(nextBlock?.number) === blockNum ? nextBlock : await web3.eth.getBlock(blockNum); if (!block) { continue; } nextBlock = thisNextBlock; const convertedBlock = EVMBlockStorage.convertRawBlock(chain, network, block); convertedBlock.nextBlockHash = nextBlock?.hash; convertedBlock.confirmations = tipHeight - block.number + 1; this.push(convertedBlock); } } catch (e) { logger.error('Error streaming blocks: %o', e); } this.push(null); } }); return ExternalApiStream.onStream(stream, req!, res!); } // @override async _getTransaction(params: StreamTransactionParams) { let { chain, network, txId } = params; network = network.toLowerCase(); const { web3 } = await this.getWeb3(network, { type: 'historical' }); const tipHeight = await web3.eth.getBlockNumber(); const chainId = await this.getChainId({ network }); const found = await this._getTransactionFromMoralis({ chain, network, chainId, txId }); return { tipHeight, found }; } // @override async _buildAddressTransactionsStream(params: StreamAddressUtxosParams) { const { req, res, args, network, address } = params; const chainId = await this.getChainId({ network }); const txStream = await this._streamAddressTransactionsFromMoralis({ chainId, chain: this.chain, network, address, args: { limit: 10, // default limit when querying by address ...args } }); // TODO unify `ExternalApiStream.onStream` and `Storage.apiStream` which are effectively doing the same thing const result = await ExternalApiStream.onStream(txStream, req!, res!); if (!result?.success) { logger.error('Error mid-stream (streamAddressTransactions): %o', result.error?.log || result.error); } } // @override async _buildWalletTransactionsStream(params: StreamWalletTransactionsParams, streamParams: BuildWalletTxsStreamParams) { const { network, args } = params; let { transactionStream, walletAddresses } = streamParams; const chainId = await this.getChainId({ network }); for (const address of walletAddresses) { const txStream = await this._streamAddressTransactionsFromMoralis({ chainId, chain: this.chain, network, address, args: { limit: args.limit, // no default limit when querying by wallet. Note: BWS caches txs order: 'ASC', ...args } }); transactionStream = txStream.eventPipe(transactionStream); // Do not await these promises. They are not critical to the stream. WalletAddressStorage.updateLastQueryTime({ chain: this.chain, network, address }) .catch(e => logger.warn(`Failed to update ${this.chain}:${network} address lastQueryTime: %o`, e)), this._addAddressToSubscription({ chainId, address }) .catch(e => logger.warn(`Failed to add address to ${this.chain}:${network} Moralis subscription: %o`, e)) } return transactionStream; } // @override async _getBlocks(params: GetBlockParams) { const { chain, network } = params; const blocks: MongoBound<IEVMBlock>[] = []; const { web3 } = await this.getWeb3(network); const chainId = await this.getChainId({ network }); const blockRange = await this.getBlocksRange({ ...params, chainId }); for (const blockNum of blockRange) { const block = await web3.eth.getBlock(blockNum); const nextBlock = await web3.eth.getBlock(block.number + 1); const convertedBlock = EVMBlockStorage.convertRawBlock(chain, network, block); convertedBlock.nextBlockHash = nextBlock?.hash; blocks.push(convertedBlock); } const tipHeight = await web3.eth.getBlockNumber(); return { tipHeight, blocks }; } // @override async _getBlockNumberByDate({ chainId, date }) { if (!date || !isDateValid(date)) { throw new Error('Invalid date'); } if (!chainId) { throw new Error('Invalid chainId'); } const query = this._transformQueryParams({ chainId, args: { date } }); const queryStr = this._buildQueryString(query); return new Promise<number>((resolve, reject) => { request({ method: 'GET', url: `${this.baseUrl}/dateToBlock${queryStr}`, headers: this.headers, json: true }, (err, _data: any, body) => { if (err) { return reject(err); } return resolve(body.block as number); }) }); } /** MORALIS METHODS */ async _getTransactionFromMoralis(params: StreamTransactionParams & ChainId) { const { chain, network, chainId, txId } = params; const query = this._buildQueryString({ chain: chainId, include: 'internal_transactions' }); return new Promise<IEVMTransactionTransformed>((resolve, reject) => { request({ method: 'GET', url: `${this.baseUrl}/transaction/${txId}${query}`, headers: this.headers, json: true }, (err, data) => { if (err) { return reject(err); } if (typeof data === 'string') { return reject(new Error(data)); } const tx = data.body; return resolve(this._transformTransaction({ chain, network, ...tx })); }); }); } _streamAddressTransactionsFromMoralis(params: StreamAddressUtxosParams & ChainId) { const { chainId, chain, network, address, args } = params; if (args.tokenAddress) { return this._streamERC20TransactionsByAddress({ chainId, chain, network, address, tokenAddress: args.tokenAddress, args }); } if (!address) { throw new Error('Missing address'); } if (!chainId) { throw new Error('Invalid chainId'); } const query = this._transformQueryParams({ chainId, args }); // throws if no chain or network const queryStr = this._buildQueryString({ ...query, order: args.order || 'DESC', // default to descending order limit: args.pageSize || 10, // limit per request/page. total limit (args.limit) is checked in apiStream._read() include: 'internal_transactions' }); args.transform = (tx) => { const _tx: any = this._transformTransaction({ chain, network, ...tx }); const confirmations = this._calculateConfirmations(tx, args.tipHeight); return EVMTransactionStorage._apiTransform({ ..._tx, confirmations }, { object: true }) as EVMTransactionJSON; } return new ExternalApiStream( `${this.baseUrl}/${address}${queryStr}`, this.headers, args ) } private _streamERC20TransactionsByAddress({ chainId, chain, network, address, tokenAddress, args }): any { if (!address) { throw new Error('Missing address'); } if (!tokenAddress) { throw new Error('Missing token address'); } if (!chainId) { throw new Error('Invalid chainId'); } const queryTransform = this._transformQueryParams({ chainId, args }); // throws if no chain or network const queryStr = this._buildQueryString({ ...queryTransform, order: args.order || 'DESC', // default to descending order limit: args.pageSize || 10, // limit per request/page. total limit (args.limit) is checked in apiStream._read() contract_addresses: [tokenAddress], }); args.transform = (tx) => { const _tx: any = this._transformTokenTransfer({ chain, network, ...tx }); const confirmations = this._calculateConfirmations(tx, args.tipHeight); return EVMTransactionStorage._apiTransform({ ..._tx, confirmations }, { object: true }) as EVMTransactionJSON; } return new ExternalApiStream( `${this.baseUrl}/${address}/erc20/transfers${queryStr}`, this.headers, args ) } private _transformTransaction(tx) { const transformed = { chain: tx.chain, network: tx.network, txid: tx.hash || tx.transaction_hash, // erc20 transfer txs have transaction_hash blockHeight: Number(tx.block_number ?? tx.blockNumber), blockHash: tx.block_hash ?? tx.blockHash, blockTime: new Date(tx.block_timestamp ?? tx.blockTimestamp), blockTimeNormalized: new Date(tx.block_timestamp ?? tx.blockTimestamp), value: tx.value, gasLimit: tx.gas ?? 0, gasPrice: tx.gas_price ?? tx.gasPrice ?? 0, fee: Number(tx.receipt_gas_used ?? tx.receiptGasUsed ?? 0) * Number(tx.gas_price ?? tx.gasPrice ?? 0), nonce: tx.nonce, to: Web3.utils.toChecksumAddress(tx.to_address ?? tx.toAddress), from: Web3.utils.toChecksumAddress(tx.from_address ?? tx.fromAddress), data: tx.input, internal: [], calls: tx?.internal_transactions?.map(t => this._transformInternalTransaction(t)) || [], effects: [], category: tx.category, wallets: [], transactionIndex: tx.transaction_index ?? tx.transactionIndex } as IEVMTransactionTransformed; EVMTransactionStorage.addEffectsToTxs([transformed]); return transformed; } private _transformInternalTransaction(tx) { return { from: Web3.utils.toChecksumAddress(tx.from), to: Web3.utils.toChecksumAddress(tx.to), gas: tx.gas, gasUsed: tx.gas_used, input: tx.input, output: tx.output, type: tx.type, value: tx.value, abiType: EVMTransactionStorage.abiDecode(tx.input) } as GethTraceCall; } private _transformTokenTransfer(transfer) { let _transfer = this._transformTransaction(transfer); return { ..._transfer, transactionHash: transfer.transaction_hash, transactionIndex: transfer.transaction_index, contractAddress: transfer.contract_address ?? transfer.address, name: transfer.token_name } as Partial<Transaction> | any; } private _transformQueryParams(params) { const { chainId, args } = params; let query = { chain: this._formatChainId(chainId), } as any; if (args) { if (args.startBlock || args.endBlock) { if (args.startBlock) { query.from_block = Number(args.startBlock); } if (args.endBlock) { query.to_block = Number(args.endBlock); } } else { if (args.startDate) { query.from_date = args.startDate } if (args.endDate) { query.to_date = args.endDate; } } if (args.direction) { query.order = Number(args.direction) > 0 ? 'ASC' : 'DESC'; } if (args.date) { query.date = new Date(args.date).getTime(); } } return query; } private _calculateConfirmations(tx, tip) { let confirmations = 0; if (tx.blockHeight && tx.blockHeight >= 0) { confirmations = tip - tx.blockHeight + 1; } return confirmations; } private _buildQueryString(params: Record<string, any>): string { const query: string[] = []; if (params.chain) { params.chain = this._formatChainId(params.chain); } for (const [key, value] of Object.entries(params)) { if (Array.isArray(value)) { for (let i = 0; i < value.length; i++) { // add array values in the form of key[i]=value if (value[i] != null) query.push(`${key}%5B${i}%5D=${value[i]}`); } } else if (value != null) { query.push(`${key}=${value}`); } } return query.length ? `?${query.join('&')}` : ''; } private _formatChainId(chainId) { return '0x' + parseInt(chainId).toString(16); } /** * Request wrapper for moralis Streams (subscriptions) * @param method * @param url * @param body * @returns */ _subsRequest(method: string, url: string, body?: any) { return new Promise((resolve, reject) => { request({ method, url, headers: this.headers, json: true, body }, (err, data) => { if (err) { logger.error(`Error with Moralis subscription call ${method}:${url}: ${err.stack || err.message || err}`); return reject(err); } if (typeof data === 'string') { logger.warn(`Moralis subscription ${method}:${url} returned a string: ${data}`); return reject(new Error(data)); } return resolve(data.body); }); }); } async createAddressSubscription(params: ChainNetwork & ChainId) { const { chain, network, chainId } = params; const _chainId = this._formatChainId(chainId); const result: any = await this._subsRequest('PUT', this.baseStreamUrl, { description: `Bitcore ${_chainId} - ${os.hostname()} - addresses`, // tag: '', chainIds: [_chainId], webhookUrl: `${this.baseWebhookurl}/${chain}/${network}/moralis`, includeNativeTxs: true, includeInternalTxs: true } ); if (!result.id) { throw new Error('Failed to create subscription: ' + JSON.stringify(result)); } return result; } async getAddressSubscriptions() { const subs: any = await this._subsRequest('GET', this.baseStreamUrl + '?limit=100'); return subs.result as any[]; } deleteAddressSubscription(params: { sub: IAddressSubscription }) { const { sub } = params; return this._subsRequest('DELETE', `${this.baseStreamUrl}/${sub.id}`) as Promise<MoralisAddressSubscription>; } async updateAddressSubscription(params: { sub: IAddressSubscription, addressesToAdd?: string[], addressesToRemove?: string[], status?: string }) { const { sub, addressesToAdd, addressesToRemove, status } = params; let moralisSub: MoralisAddressSubscription | null = null; if (addressesToAdd && addressesToAdd.length > 0) { moralisSub = await this._subsRequest('POST', `${this.baseStreamUrl}/${sub.id}/address`, { address: addressesToAdd }) as MoralisAddressSubscription; } else if (addressesToRemove && addressesToRemove.length > 0) { moralisSub = await this._subsRequest('DELETE', `${this.baseStreamUrl}/${sub.id}/address`, { address: addressesToRemove }) as MoralisAddressSubscription; } else if (status) { moralisSub = await this._subsRequest('POST', `${this.baseStreamUrl}/${sub.id}/status`, { status }) as MoralisAddressSubscription; } if (moralisSub?.message) { throw new Error(moralisSub.message); } return moralisSub?.id ? moralisSub : sub; // fallback to sub in case there's nothing to update (e.g. addressesToAdd is an empty array) } webhookToCoinEvents(params: { webhook: any } & ChainNetwork) { const { chain, network, webhook } = params; if (webhook.body.confirmed) { // Moralis broadcasts both confirmed and unconfirmed. // Filtering out confirmed de-duplicates events. return []; } const coinEvents: CoinEvent[] = webhook.body.txs.flatMap(tx => this._transformWebhookTransaction({ chain, network, tx, webhook: webhook.body })); return coinEvents; } private _transformWebhookTransaction(params: { webhook, tx } & ChainNetwork): CoinEvent[] { const { chain, network, tx } = params; const events: CoinEvent[] = []; for (const address of tx.triggered_by) { events.push({ address, coin: { chain, network, value: tx.value, address: tx.toAddress, mintTxid: tx.hash } }); } return events; } private async _addAddressToSubscription({ chainId, address }) { const subs = await this.getAddressSubscriptions(); const sub = subs?.find(sub => sub.chainIds.includes('0x' + chainId.toString(16))); if (sub) { await this.updateAddressSubscription({ sub, addressesToAdd: [address] }); } } }