bitcore-node
Version:
A blockchain indexing node with extended capabilities using bitcore
680 lines (632 loc) • 23.6 kB
text/typescript
import { GetBlockBeforeTimeParams, StreamTransactionParams, WalletBalanceType } from '../../../types/namespaces/ChainStateProvider';
import { StreamBlocksParams } from '../../../types/namespaces/ChainStateProvider';
import { Validation } from 'crypto-wallet-core';
import { ObjectId } from 'mongodb';
import { Transform } from 'stream';
import { LoggifyClass } from '../../../decorators/Loggify';
import { MongoBound } from '../../../models/base';
import { BitcoinBlockStorage, IBtcBlock } from '../../../models/block';
import { CacheStorage } from '../../../models/cache';
import { CoinStorage, ICoin } from '../../../models/coin';
import { StateStorage } from '../../../models/state';
import { ITransaction, TransactionStorage } from '../../../models/transaction';
import { IWallet, WalletStorage } from '../../../models/wallet';
import { IWalletAddress, WalletAddressStorage } from '../../../models/walletAddress';
import { RPC } from '../../../rpc';
import { Config } from '../../../services/config';
import { Storage } from '../../../services/storage';
import { IBlock } from '../../../types/Block';
import { CoinJSON, SpentHeightIndicators } from '../../../types/Coin';
import { IUtxoNetworkConfig } from '../../../types/Config';
import {
BroadcastTransactionParams,
CreateWalletParams,
DailyTransactionsParams,
GetBalanceForAddressParams,
GetBlockParams,
GetEstimateSmartFeeParams,
GetWalletBalanceAtTimeParams,
GetWalletBalanceParams,
GetWalletParams,
IChainStateService,
StreamAddressUtxosParams,
StreamTransactionsParams,
StreamWalletAddressesParams,
StreamWalletMissingAddressesParams,
StreamWalletTransactionsParams,
StreamWalletUtxosParams,
UpdateWalletParams,
WalletCheckParams
} from '../../../types/namespaces/ChainStateProvider';
import { TransactionJSON } from '../../../types/Transaction';
import { StringifyJsonStream } from '../../../utils/jsonStream';
import { ListTransactionsStream } from './transforms';
export class InternalStateProvider implements IChainStateService {
chain: string;
constructor(chain: string, private WalletStreamTransform = ListTransactionsStream) {
this.chain = chain;
this.chain = this.chain.toUpperCase();
}
getRPC(chain: string, network: string) {
const RPC_PEER = (Config.chainConfig({ chain, network }) as IUtxoNetworkConfig).rpc;
if (!RPC_PEER) {
throw new Error(`RPC not configured for ${chain} ${network}`);
}
const { username, password, host, port } = RPC_PEER;
return new RPC(username, password, host, port);
}
private getAddressQuery(params: StreamAddressUtxosParams) {
const { chain, network, address, args } = params;
if (typeof address !== 'string' || !chain || !network) {
throw new Error('Missing required param');
}
const query = { chain, network: network.toLowerCase(), address } as any;
if (args.unspent) {
query.spentHeight = { $lt: SpentHeightIndicators.minimum };
}
if (args.excludeConflicting) {
query.mintHeight = { $gt: SpentHeightIndicators.conflicting };
}
return query;
}
streamAddressUtxos(params: StreamAddressUtxosParams) {
const { req, res, args } = params;
const { limit, since } = args;
const query = this.getAddressQuery(params);
Storage.apiStreamingFind(CoinStorage, query, { limit, since, paging: '_id' }, req!, res!);
}
async streamAddressTransactions(params: StreamAddressUtxosParams) {
const { req, res, args } = params;
const { limit, since } = args;
const query = this.getAddressQuery(params);
Storage.apiStreamingFind(CoinStorage, query, { limit, since, paging: '_id' }, req!, res!);
}
async getBalanceForAddress(params: GetBalanceForAddressParams): Promise<WalletBalanceType> {
const { chain, network, address } = params;
const query = {
chain,
network,
address,
spentHeight: { $lt: SpentHeightIndicators.minimum },
mintHeight: { $gt: SpentHeightIndicators.conflicting }
};
let balance = await CoinStorage.getBalance({ query });
return balance;
}
streamBlocks(params: StreamBlocksParams) {
const { req, res } = params;
const { query, options } = this.getBlocksQuery(params);
Storage.apiStreamingFind(BitcoinBlockStorage, query, options, req, res);
}
async getBlocks(params: GetBlockParams): Promise<Array<IBlock>> {
const { query, options } = this.getBlocksQuery(params);
let cursor = BitcoinBlockStorage.collection.find(query, options).addCursorFlag('noCursorTimeout', true);
if (options.sort) {
cursor = cursor.sort(options.sort);
}
let blocks = await cursor.toArray();
const tip = await this.getLocalTip(params);
const tipHeight = tip ? tip.height : 0;
const blockTransform = (b: IBtcBlock) => {
let confirmations = 0;
if (b.height > -1) {
confirmations = tipHeight - b.height + 1;
}
const convertedBlock = BitcoinBlockStorage._apiTransform(b, { object: true }) as IBtcBlock;
return { ...convertedBlock, confirmations };
};
return blocks.map(blockTransform);
}
protected getBlocksQuery(params: GetBlockParams | StreamBlocksParams) {
const { chain, network, sinceBlock, blockId, args = {} } = params;
let { startDate, endDate, date, since, direction, paging } = args;
let { limit = 10, sort = { height: -1 } } = args;
let options = { limit, sort, since, direction, paging };
if (!chain || !network) {
throw new Error('Missing required param');
}
let query: any = {
chain,
network: network.toLowerCase(),
processed: true
};
if (blockId) {
if (blockId.length >= 64) {
query.hash = blockId;
} else {
let height = parseInt(blockId, 10);
if (Number.isNaN(height) || height.toString(10) !== blockId) {
throw new Error('invalid block id provided');
}
query.height = height;
}
}
if (sinceBlock) {
let height = Number(sinceBlock);
if (Number.isNaN(height) || height.toString(10) !== sinceBlock) {
throw new Error('invalid block id provided');
}
query.height = { $gt: height };
}
if (startDate) {
query.time = { $gt: new Date(startDate) };
}
if (endDate) {
query.time = Object.assign({}, query.time, { $lt: new Date(endDate) });
}
if (date) {
let firstDate = new Date(date);
let nextDate = new Date(date);
nextDate.setDate(nextDate.getDate() + 1);
query.time = { $gt: firstDate, $lt: nextDate };
}
return { query, options };
}
async getBlock(params: GetBlockParams) {
const blocks = await this.getBlocks(params);
return blocks[0];
}
async getBlockBeforeTime(params: GetBlockBeforeTimeParams): Promise<IBlock|null> {
const { chain, network, time } = params;
const date = new Date(time || Date.now());
const [block] = await BitcoinBlockStorage.collection
.find({
chain,
network,
timeNormalized: { $lte: date }
})
.limit(1)
.sort({ timeNormalized: -1 })
.toArray();
return block;
}
async streamTransactions(params: StreamTransactionsParams) {
const { chain, network, req, res, args } = params;
let { blockHash, blockHeight } = args;
if (!chain || !network) {
throw new Error('Missing chain or network');
}
let query: any = {
chain,
network: network.toLowerCase()
};
if (blockHeight !== undefined) {
query.blockHeight = Number(blockHeight);
}
if (blockHash !== undefined) {
query.blockHash = blockHash;
}
const tip = await this.getLocalTip(params);
const tipHeight = tip ? tip.height : 0;
return Storage.apiStreamingFind(TransactionStorage, query, args, req, res, t => {
let confirmations = 0;
if (t.blockHeight !== undefined && t.blockHeight >= 0) {
confirmations = tipHeight - t.blockHeight + 1;
}
const convertedTx = TransactionStorage._apiTransform(t, { object: true }) as Partial<ITransaction>;
return JSON.stringify({ ...convertedTx, confirmations });
});
}
async getTransaction(params: StreamTransactionParams) {
let { chain, network, txId } = params;
if (typeof txId !== 'string' || !chain || !network) {
throw new Error('Missing required param');
}
network = network.toLowerCase();
let query = { chain, network, txid: txId };
const tip = await this.getLocalTip(params);
const tipHeight = tip ? tip.height : 0;
const found = await TransactionStorage.collection.findOne(query);
if (found) {
let confirmations = 0;
if (found.blockHeight != null && found.blockHeight >= 0) {
confirmations = tipHeight - found.blockHeight + 1;
}
const convertedTx = TransactionStorage._apiTransform(found, { object: true }) as TransactionJSON;
return { ...convertedTx, confirmations } as any;
} else {
return undefined;
}
}
async getAuthhead(params: StreamTransactionParams) {
let { chain, network, txId } = params;
if (typeof txId !== 'string') {
throw new Error('Missing required param');
}
const found = (await CoinStorage.resolveAuthhead(txId, chain, network))[0];
if (found) {
const transformedCoins = found.identityOutputs.map<CoinJSON>(output =>
CoinStorage._apiTransform(output, { object: true })
);
return {
chain: found.chain,
network: found.network,
authbase: found.authbase,
identityOutputs: transformedCoins
};
} else {
return undefined;
}
}
async createWallet(params: CreateWalletParams) {
const { chain, network, name, pubKey, path, singleAddress } = params;
if (typeof name !== 'string' || !network) {
throw new Error('Missing required param');
}
const state = await StateStorage.collection.findOne({});
const initialSyncComplete =
state && state.initialSyncComplete && state.initialSyncComplete.includes(`${chain}:${network}`);
const walletConfig = Config.for('api').wallets;
const canCreate = walletConfig && walletConfig.allowCreationBeforeCompleteSync;
const isP2P = this.isP2p({ chain, network });
if (isP2P && !initialSyncComplete && !canCreate) {
throw new Error('Wallet creation not permitted before intitial sync is complete');
}
const wallet: IWallet = {
chain,
network,
name,
pubKey,
path,
singleAddress
};
await WalletStorage.collection.insertOne(wallet);
return wallet;
}
async getWallet(params: GetWalletParams) {
const { chain, pubKey } = params;
return WalletStorage.collection.findOne({ chain, pubKey });
}
streamWalletAddresses(params: StreamWalletAddressesParams) {
let { walletId, req, res } = params;
let query = { wallet: walletId };
Storage.apiStreamingFind(WalletAddressStorage, query, {}, req, res);
}
async walletCheck(params: WalletCheckParams) {
let { chain, network, wallet } = params;
return new Promise(resolve => {
const addressStream = WalletAddressStorage.collection.find({ chain, network, wallet }).project({ address: 1 });
let sum = 0;
let lastAddress;
addressStream.on('data', (walletAddress: IWalletAddress) => {
if (walletAddress.address) {
lastAddress = walletAddress.address;
const addressSum = Buffer.from(walletAddress.address).reduce(
(tot, cur) => (tot + cur) % Number.MAX_SAFE_INTEGER
);
sum = (sum + addressSum) % Number.MAX_SAFE_INTEGER;
}
});
addressStream.on('end', () => {
resolve({ lastAddress, sum });
});
});
}
isP2p({ chain, network }) {
return Config.chainConfig({ chain, network })?.chainSource !== 'p2p';
}
async streamMissingWalletAddresses(params: StreamWalletMissingAddressesParams) {
const { chain, network, pubKey, res } = params;
const wallet = await WalletStorage.collection.findOne({ pubKey });
const walletId = wallet!._id!;
const query = { chain, network, wallets: walletId, spentHeight: { $gte: SpentHeightIndicators.minimum } };
const cursor = CoinStorage.collection.find(query).addCursorFlag('noCursorTimeout', true);
const seen = {};
const stringifyWallets = (wallets: Array<ObjectId>) => wallets.map(w => w.toHexString());
const allMissingAddresses = new Array<string>();
let totalMissingValue = 0;
const missingStream = cursor.pipe(
new Transform({
objectMode: true,
async transform(spentCoin: MongoBound<ICoin>, _, next) {
if (!seen[spentCoin.spentTxid]) {
seen[spentCoin.spentTxid] = true;
// find coins that were spent with my coins
const spends = await CoinStorage.collection
.find({ chain, network, spentTxid: spentCoin.spentTxid })
.addCursorFlag('noCursorTimeout', true)
.toArray();
const missing = spends
.filter(coin => !stringifyWallets(coin.wallets).includes(walletId.toHexString()))
.map(coin => {
const { _id, wallets, address, value } = coin;
totalMissingValue += value;
allMissingAddresses.push(address);
return { _id, wallets, address, value, expected: walletId.toHexString() };
});
if (missing.length > 0) {
return next(undefined, { txid: spentCoin.spentTxid, missing });
}
}
return next();
},
flush(done) {
done(null, { allMissingAddresses, totalMissingValue });
}
})
);
missingStream.pipe(new StringifyJsonStream()).pipe(res);
}
async updateWallet(params: UpdateWalletParams) {
const { wallet, addresses, reprocess = false } = params;
await WalletAddressStorage.updateCoins({ wallet, addresses, opts: { reprocess } });
}
async streamWalletTransactions(params: StreamWalletTransactionsParams) {
const { chain, network, wallet, res, args } = params;
const query: any = {
chain,
network,
wallets: wallet._id,
'wallets.0': { $exists: true }
};
if (wallet.chain === 'BTC' && ['testnet3', 'testnet4'].includes(wallet.network)) {
query['network'] = wallet.network;
}
if (args) {
if (args.startBlock || args.endBlock) {
query.$or = [];
if (args.includeMempool) {
query.$or.push({ blockHeight: SpentHeightIndicators.pending });
}
let blockRangeQuery = {} as any;
if (args.startBlock) {
blockRangeQuery.$gte = Number(args.startBlock);
}
if (args.endBlock) {
blockRangeQuery.$lte = Number(args.endBlock);
}
query.$or.push({ blockHeight: blockRangeQuery });
} else {
if (args.startDate) {
const startDate = new Date(args.startDate);
if (startDate.getTime()) {
query.blockTimeNormalized = { $gte: new Date(args.startDate) };
}
}
if (args.endDate) {
const endDate = new Date(args.endDate);
if (endDate.getTime()) {
query.blockTimeNormalized = query.blockTimeNormalized || {};
query.blockTimeNormalized.$lt = new Date(args.endDate);
}
}
}
}
const transactionStream = TransactionStorage.collection
.find(query)
.sort({ blockTimeNormalized: 1 })
.addCursorFlag('noCursorTimeout', true);
const listTransactionsStream = new this.WalletStreamTransform(wallet);
transactionStream.pipe(listTransactionsStream).pipe(res);
}
async getWalletBalance(params: GetWalletBalanceParams): Promise<WalletBalanceType> {
const query = {
wallets: params.wallet._id,
'wallets.0': { $exists: true },
spentHeight: { $lt: SpentHeightIndicators.minimum },
mintHeight: { $gt: SpentHeightIndicators.conflicting }
};
if (params.wallet.chain === 'BTC' && ['testnet3', 'testnet4'].includes(params.wallet.network)) {
query['network'] = params.wallet.network;
}
return CoinStorage.getBalance({ query });
}
async getWalletBalanceAtTime(params: GetWalletBalanceAtTimeParams): Promise<WalletBalanceType> {
const { chain, network, time } = params;
let query = { wallets: params.wallet._id, 'wallets.0': { $exists: true } };
if (params.wallet.chain === 'BTC' && ['testnet3', 'testnet4'].includes(params.wallet.network)) {
query['network'] = params.wallet.network;
}
return CoinStorage.getBalanceAtTime({ query, time, chain, network });
}
async streamWalletUtxos(params: StreamWalletUtxosParams) {
const { wallet, limit, args = {}, req, res } = params;
let query: any = {
wallets: wallet._id,
'wallets.0': { $exists: true },
mintHeight: { $gt: SpentHeightIndicators.conflicting }
};
if (wallet.chain === 'BTC' && ['testnet3', 'testnet4'].includes(wallet.network)) {
query['network'] = wallet.network;
}
if (args.includeSpent !== 'true') {
if (args.includePending === 'true') {
query.spentHeight = { $lte: SpentHeightIndicators.pending };
} else {
query.spentHeight = { $lt: SpentHeightIndicators.pending };
}
}
const tip = await this.getLocalTip(params);
const tipHeight = tip ? tip.height : 0;
const utxoTransform = (c: Partial<ICoin>): string => {
let confirmations = 0;
if (c.mintHeight && c.mintHeight >= 0) {
confirmations = tipHeight - c.mintHeight + 1;
}
c.confirmations = confirmations;
return CoinStorage._apiTransform(c) as string;
};
Storage.apiStreamingFind(CoinStorage, query, { limit }, req, res, utxoTransform);
}
async getFee(params: GetEstimateSmartFeeParams) {
const { chain, network, target, mode } = params;
const cacheKey = `getFee-${chain}-${network}-${target}${mode ? '-' + mode.toLowerCase() : ''}`;
return CacheStorage.getGlobalOrRefresh(
cacheKey,
async () => {
return this.getRPC(chain, network).getEstimateSmartFee(Number(target), mode);
},
5 * CacheStorage.Times.Minute
);
}
async broadcastTransaction(params: BroadcastTransactionParams) {
const { chain, network, rawTx } = params;
const txids = new Array<string>();
const rawTxs = typeof rawTx === 'string' ? [rawTx] : rawTx;
for (const tx of rawTxs) {
const txid = await this.getRPC(chain, network).sendTransaction(tx);
txids.push(txid);
}
return txids.length === 1 ? txids[0] : txids;
}
async getCoinsForTx({ chain, network, txid }: { chain: string; network: string; txid: string }) {
const tx = await TransactionStorage.collection.findOne({ txid });
if (!tx) {
throw new Error(`No such transaction ${txid}`);
}
const tip = await this.getLocalTip({ chain, network });
const confirmations = (tip && tx.blockHeight! > -1) ? tip.height - tx.blockHeight! + 1 : 0;
const inputs = await CoinStorage.collection
.find({
chain,
network,
spentTxid: txid
})
.addCursorFlag('noCursorTimeout', true)
.toArray();
const outputs = await CoinStorage.collection
.find({
chain,
network,
mintTxid: txid
})
.addCursorFlag('noCursorTimeout', true)
.toArray();
return {
inputs: inputs.map(input => CoinStorage._apiTransform(input, { object: true, confirmations })),
outputs: outputs.map(output => CoinStorage._apiTransform(output, { object: true, confirmations }))
};
}
async getDailyTransactions(params: DailyTransactionsParams) {
const { chain, network, startDate, endDate } = params;
const formatDate = (d: Date) => new Date(d.toISOString().split('T')[0]);
const todayTruncatedUTC = formatDate(new Date());
let oneMonth = new Date(todayTruncatedUTC);
oneMonth.setDate(todayTruncatedUTC.getDate() - 30);
oneMonth = formatDate(oneMonth);
const isValidDate = (d: string) => {
return new Date(d).toString() !== 'Invalid Date';
};
const start = startDate && isValidDate(startDate) ? new Date(startDate) : oneMonth;
const end = endDate && isValidDate(endDate) ? formatDate(new Date(endDate)) : todayTruncatedUTC;
const results = await BitcoinBlockStorage.collection
.aggregate<{
date: string;
transactionCount: number;
}>([
{
$match: {
chain,
network,
timeNormalized: {
$gte: start,
$lt: end
}
}
},
{
$group: {
_id: {
$dateToString: {
format: '%Y-%m-%d',
date: '$timeNormalized'
}
},
transactionCount: {
$sum: '$transactionCount'
}
}
},
{
$project: {
_id: 0,
date: '$_id',
transactionCount: '$transactionCount'
}
},
{
$sort: {
date: 1
}
}
])
.toArray();
return {
chain,
network,
results
};
}
async getLocalTip({ chain, network }) {
return BitcoinBlockStorage.getLocalTip({ chain, network });
}
/**
* Get a series of hashes that come before a given height, or the 30 most recent hashes
*
* @returns {Promise<Array<string>>}
*/
async getLocatorHashes(params): Promise<Array<string>> {
const { chain, network, startHeight, endHeight } = params;
const query =
startHeight && endHeight
? {
processed: true,
chain,
network,
height: { $gt: startHeight, $lt: endHeight }
}
: {
processed: true,
chain,
network
};
const locatorBlocks = await BitcoinBlockStorage.collection
.find(query).sort({ height: -1 }).limit(30)
.addCursorFlag('noCursorTimeout', true)
.toArray();
if (locatorBlocks.length < 2) {
return [Array(65).join('0')];
}
return locatorBlocks.map(block => block.hash);
}
public isValid(params) {
const { input } = params;
if (this.isValidBlockOrTx(input)) {
return { isValid: true, type: 'blockOrTx' };
} else if (this.isValidAddress(params)) {
return { isValid: true, type: 'addr' };
} else if (this.isValidBlockIndex(input)) {
return { isValid: true, type: 'blockOrTx' };
} else {
return { isValid: false, type: 'invalid' };
}
}
private isValidBlockOrTx(inputValue: string): boolean {
const regexp = /^[0-9a-fA-F]{64}$/;
if (regexp.test(inputValue)) {
return true;
} else {
return false;
}
}
private isValidAddress(params): boolean {
const { chain, network, input } = params;
const addr = this.extractAddress(input);
return !!Validation.validateAddress(chain, network, addr);
}
private isValidBlockIndex(inputValue): boolean {
return isFinite(inputValue);
}
private extractAddress(address: string): string {
const extractedAddress = address.replace(/^(bitcoincash:|bchtest:|bitcoin:)/i, '').replace(/\?.*/, '');
return extractedAddress || address;
}
async getWalletAddresses(walletId: ObjectId) {
let query = { chain: this.chain, wallet: walletId };
return WalletAddressStorage.collection
.find(query)
.addCursorFlag('noCursorTimeout', true)
.toArray();
}
}