@bitaccess/coinlib-bitcoin
Version:
Library to assist in processing bitcoin payments, such as deriving addresses and sweeping funds
1,090 lines (1,084 loc) • 134 kB
JavaScript
import * as bitcoin from 'bitcoinjs-lib-bigint';
import { networks } from 'bitcoinjs-lib-bigint';
import { AutoFeeLevels, NetworkTypeT, UtxoInfo, BaseUnsignedTransaction, BaseSignedTransaction, BaseTransactionInfo, BaseBroadcastResult, NetworkType, FeeRateType, BigNumber, FeeLevel, createUnitConverters, Payport, TransactionStatus, DerivablePayport, FeeOptionCustom, PaymentsError, PaymentsErrorCode, PayportOutput, DEFAULT_MAX_FEE_PERCENT, ecpair, BaseMultisigData, bip32, bip32MagicNumberToPrefix, BaseConfig, FeeRate, KeyPairsConfigParam, deriveHDNode, validateHdKey, isValidXprv, isValidXpub, determineHdNode, PaymentsFactory, StandardConnectionManager } from '@bitaccess/coinlib-common';
import * as t from 'io-ts';
import { enumCodec, requiredOptionalCodec, nullable, Logger, instanceofCodec, extendCodec, isString, isMatchingError, toBigNumber, assertType, DelegateLogger, isNil, isUndefined, isType, isNumber, Numeric } from '@bitaccess/ts-common';
import { NormalizedTxBitcoin, BlockbookBitcoin, BlockInfoBitcoin, NormalizedTxBitcoinVin, NormalizedTxBitcoinVout } from 'blockbook-client';
import fetch from 'node-fetch';
import promiseRetry from 'promise-retry';
import crypto from 'crypto';
import { EventEmitter } from 'events';
import { omit, cloneDeep } from 'lodash';
var BitcoinishAddressType;
(function (BitcoinishAddressType) {
BitcoinishAddressType["Legacy"] = "p2pkh";
BitcoinishAddressType["SegwitP2SH"] = "p2sh-p2wpkh";
BitcoinishAddressType["SegwitNative"] = "p2wpkh";
BitcoinishAddressType["MultisigLegacy"] = "p2sh-p2ms";
BitcoinishAddressType["MultisigSegwitP2SH"] = "p2sh-p2wsh-p2ms";
BitcoinishAddressType["MultisigSegwitNative"] = "p2wsh-p2ms";
})(BitcoinishAddressType || (BitcoinishAddressType = {}));
const AddressType = BitcoinishAddressType;
const AddressTypeT = enumCodec(AddressType, 'AddressType');
const SinglesigAddressTypeT = t.keyof({
[AddressType.Legacy]: null,
[AddressType.SegwitP2SH]: null,
[AddressType.SegwitNative]: null,
}, 'SinglesigAddressType');
const SinglesigAddressType = SinglesigAddressTypeT;
const MultisigAddressTypeT = t.keyof({
[AddressType.MultisigLegacy]: null,
[AddressType.MultisigSegwitP2SH]: null,
[AddressType.MultisigSegwitNative]: null,
}, 'MultisigAddressType');
const MultisigAddressType = MultisigAddressTypeT;
const FeeLevelBlockTargets = t.record(AutoFeeLevels, t.number, 'FeeLevelBlockTargets');
class BlockbookServerAPI extends BlockbookBitcoin {
}
const BlockbookConfigServer = t.union([t.string, t.array(t.string), t.null], 'BlockbookConfigServer');
const BlockbookConnectedConfig = requiredOptionalCodec({
network: NetworkTypeT,
packageName: t.string,
server: BlockbookConfigServer,
}, {
logger: nullable(Logger),
api: instanceofCodec(BlockbookServerAPI),
requestTimeoutMs: t.number,
}, 'BlockbookConnectedConfig');
const BitcoinjsNetwork = t.type({
messagePrefix: t.string,
bech32: t.string,
bip32: t.type({
public: t.number,
private: t.number,
}),
pubKeyHash: t.number,
scriptHash: t.number,
wif: t.number,
}, 'BitcoinjsNetwork');
const BitcoinishPaymentsUtilsConfig = extendCodec(BlockbookConnectedConfig, {
coinSymbol: t.string,
coinName: t.string,
coinDecimals: t.number,
bitcoinjsNetwork: BitcoinjsNetwork,
networkMinRelayFee: t.number,
dustThreshold: t.number,
}, {
blockcypherToken: t.string,
feeLevelBlockTargets: FeeLevelBlockTargets,
}, 'BitcoinishPaymentsUtilsConfig');
const BitcoinishTxOutput = t.type({
address: t.string,
value: t.string,
}, 'BitcoinishTxOutput');
const BitcoinishTxOutputSatoshis = t.type({
address: t.string,
satoshis: t.number,
}, 'BitcoinishTxOutputSatoshis');
const BitcoinishWeightedChangeOutput = t.type({
address: t.string,
weight: t.number,
}, 'BitcoinishWeightedChangeOutput');
const BitcoinishPaymentTx = requiredOptionalCodec({
inputs: t.array(UtxoInfo),
outputs: t.array(BitcoinishTxOutput),
fee: t.string,
change: t.string,
changeAddress: nullable(t.string),
}, {
inputTotal: t.string,
externalOutputs: t.array(BitcoinishTxOutput),
externalOutputTotal: t.string,
changeOutputs: t.array(BitcoinishTxOutput),
rawHex: t.string,
rawHash: t.string,
weight: t.number,
}, 'BitcoinishPaymentTx');
const BitcoinishUnsignedTransaction = extendCodec(BaseUnsignedTransaction, {
amount: t.string,
fee: t.string,
data: BitcoinishPaymentTx,
}, 'BitcoinishUnsignedTransaction');
const BitcoinishSignedTransactionData = requiredOptionalCodec({
hex: t.string,
}, {
partial: t.boolean,
unsignedTxHash: t.string,
changeOutputs: t.array(BitcoinishTxOutput),
}, 'BitcoinishSignedTransactionData');
const BitcoinishSignedTransaction = extendCodec(BaseSignedTransaction, {
data: BitcoinishSignedTransactionData,
}, {}, 'BitcoinishSignedTransaction');
const BitcoinishTransactionInfo = extendCodec(BaseTransactionInfo, {
data: NormalizedTxBitcoin,
}, {}, 'BitcoinishTransactionInfo');
const BitcoinishBroadcastResult = extendCodec(BaseBroadcastResult, {}, {}, 'BitcoinishBroadcastResult');
const BitcoinishBlock = BlockInfoBitcoin;
function resolveServer(config, logger) {
const { server } = config;
if (config.api) {
return {
api: config.api,
server: config.api.nodes,
};
}
else if (isString(server)) {
return {
api: new BlockbookServerAPI({
nodes: [server],
logger,
requestTimeoutMs: config.requestTimeoutMs,
}),
server: [server],
};
}
else if (server instanceof BlockbookBitcoin || server instanceof BlockbookServerAPI) {
return {
api: server,
server: server.nodes,
};
}
else if (Array.isArray(server)) {
return {
api: new BlockbookServerAPI({
nodes: server,
logger,
requestTimeoutMs: config.requestTimeoutMs,
}),
server,
};
}
else {
return {
api: new BlockbookServerAPI({
nodes: [''],
logger,
requestTimeoutMs: config.requestTimeoutMs,
}),
server: null,
};
}
}
const RETRYABLE_ERRORS = [
'timeout',
'disconnected',
'time-out',
'StatusCodeError: 522',
'StatusCodeError: 504',
'ENOTFOUND',
'ESOCKETTIMEDOUT',
'ETIMEDOUT',
];
const MAX_RETRIES = 2;
function retryIfDisconnected(fn, api, logger, additionalRetryableErrors = []) {
return promiseRetry((retry, attempt) => {
return fn().catch(async (e) => {
if (isMatchingError(e, [...RETRYABLE_ERRORS, ...additionalRetryableErrors])) {
logger.log(`Retryable error during blockbook server call, retrying ${MAX_RETRIES - attempt} more times`, e.toString());
retry(e);
}
throw e;
});
}, {
retries: MAX_RETRIES,
});
}
function sumField(items, field) {
return items.reduce((total, item) => total.plus(item[field]), toBigNumber(0));
}
function sumUtxoValue(utxos, includeUnconfirmed) {
const filtered = includeUnconfirmed ? utxos : utxos.filter(isConfirmedUtxo);
return sumField(filtered, 'value');
}
function countOccurences(a) {
return a.reduce((result, element) => {
var _a;
result[element] = ((_a = result[element]) !== null && _a !== void 0 ? _a : 0) + 1;
return result;
}, {});
}
function shuffleUtxos(utxoList) {
const result = [...utxoList];
for (let i = result.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * i);
const temp = result[i];
result[i] = result[j];
result[j] = temp;
}
return result;
}
function isConfirmedUtxo(utxo) {
return Boolean((utxo.confirmations && utxo.confirmations > 0) || (utxo.height && Number.parseInt(utxo.height) > 0));
}
function sha256FromHex(hex) {
return hex ? crypto.createHash('sha256').update(Buffer.from(hex, 'hex')).digest('hex') : '';
}
async function getBlockcypherFeeRecommendation(feeLevel, coinSymbol, networkType, blockcypherToken, logger) {
let feeRate;
try {
logger.log('Attempting to use blockcypher for fee rate recommendation');
const networkParam = networkType === NetworkType.Mainnet ? 'main' : 'test3';
const tokenQs = blockcypherToken ? `?token=${blockcypherToken}` : '';
const url = `https://api.blockcypher.com/v1/${coinSymbol.toLowerCase()}/${networkParam}${tokenQs}`;
const response = await fetch(url);
const data = await response.json();
const feePerKbField = `${feeLevel}_fee_per_kb`;
const feePerKb = data[feePerKbField];
if (!feePerKb) {
throw new Error(`Response is missing expected field ${feePerKbField}`);
}
const satPerByte = feePerKb / 1000;
feeRate = String(satPerByte);
logger.log(`Retrieved ${coinSymbol} ${networkType} fee rate of ${satPerByte} sat/vbyte from blockcypher for ${feeLevel} level`);
}
catch (e) {
throw new Error(`Failed to retrieve ${coinSymbol} ${networkType} fee rate from blockcypher - ${e.toString()}`);
}
return {
feeRate,
feeRateType: FeeRateType.BasePerWeight,
};
}
async function getBlockbookFeeRecommendation(blockTarget, coinSymbol, networkType, blockbookClient, logger) {
let feeRate;
try {
logger.log('Attempting to use blockbook for fee rate recommendation');
const btcPerKbString = await blockbookClient.estimateFee(blockTarget);
const fee = new BigNumber(btcPerKbString);
if (fee.isNaN() || fee.lt(0)) {
throw new Error(`Blockbook estimatefee result is not a positive number: ${btcPerKbString}`);
}
const satPerByte = fee.times(100000);
feeRate = satPerByte.toFixed();
logger.log(`Retrieved ${coinSymbol} ${networkType} fee rate of ${satPerByte} sat/vbyte from blockbook, using ${feeRate} for ${blockTarget} block target`);
}
catch (e) {
throw new Error(`Failed to retrieve ${coinSymbol} ${networkType} fee rate from blockbook - ${e.name} - ${e.message}`);
}
return {
feeRate,
feeRateType: FeeRateType.BasePerWeight,
};
}
const ADDRESS_INPUT_WEIGHTS = {
[AddressType.Legacy]: 148 * 4,
[AddressType.SegwitP2SH]: 108 + 64 * 4,
[AddressType.SegwitNative]: 108 + 41 * 4,
[AddressType.MultisigLegacy]: 49 * 4,
[AddressType.MultisigSegwitP2SH]: 6 + 76 * 4,
[AddressType.MultisigSegwitNative]: 6 + 41 * 4,
};
const ADDRESS_OUTPUT_WEIGHTS = {
[AddressType.Legacy]: 34 * 4,
[AddressType.SegwitP2SH]: 32 * 4,
[AddressType.SegwitNative]: 31 * 4,
[AddressType.MultisigLegacy]: 34 * 4,
[AddressType.MultisigSegwitP2SH]: 34 * 4,
[AddressType.MultisigSegwitNative]: 43 * 4,
};
function checkUInt53(n) {
if (n < 0 || n > Number.MAX_SAFE_INTEGER || n % 1 !== 0)
throw new RangeError('value out of range');
}
function varIntLength(n) {
checkUInt53(n);
return n < 0xfd ? 1 : n <= 0xffff ? 3 : n <= 0xffffffff ? 5 : 9;
}
function estimateTxSize(inputCounts, outputCounts, toOutputScript) {
let totalWeight = 0;
let hasWitness = false;
let totalInputs = 0;
let totalOutputs = 0;
Object.keys(inputCounts).forEach((key) => {
const count = inputCounts[key];
checkUInt53(count);
if (key.includes(':')) {
const keyParts = key.split(':');
if (keyParts.length !== 2)
throw new Error('invalid inputCounts key: ' + key);
const addressType = assertType(MultisigAddressType, keyParts[0], 'inputCounts key');
const [m, n] = keyParts[1].split('-').map((x) => parseInt(x));
totalWeight += ADDRESS_INPUT_WEIGHTS[addressType] * count;
const multiplyer = addressType === AddressType.MultisigLegacy ? 4 : 1;
totalWeight += (73 * m + 34 * n) * multiplyer * count;
}
else {
const addressType = assertType(SinglesigAddressType, key, 'inputCounts key');
totalWeight += ADDRESS_INPUT_WEIGHTS[addressType] * count;
}
totalInputs += count;
if (key.indexOf('W') >= 0)
hasWitness = true;
});
Object.keys(outputCounts).forEach(function (key) {
const count = outputCounts[key];
checkUInt53(count);
if (AddressTypeT.is(key)) {
totalWeight += ADDRESS_OUTPUT_WEIGHTS[key] * count;
}
else {
try {
const outputScript = toOutputScript(key);
totalWeight += (outputScript.length + 9) * 4 * count;
}
catch (e) {
throw new Error('invalid outputCounts key: ' + key);
}
}
totalOutputs += count;
});
if (hasWitness)
totalWeight += 2;
totalWeight += 8 * 4;
totalWeight += varIntLength(totalInputs) * 4;
totalWeight += varIntLength(totalOutputs) * 4;
return Math.ceil(totalWeight / 4);
}
const MIN_P2PKH_SWEEP_BYTES = 191;
const DEFAULT_FEE_LEVEL_BLOCK_TARGETS = {
[FeeLevel.High]: 1,
[FeeLevel.Medium]: 24,
[FeeLevel.Low]: 144,
};
const BITCOINISH_ADDRESS_PURPOSE = {
[AddressType.Legacy]: '44',
[AddressType.SegwitP2SH]: '49',
[AddressType.SegwitNative]: '84',
[AddressType.MultisigLegacy]: '87',
[AddressType.MultisigSegwitNative]: '87',
[AddressType.MultisigSegwitP2SH]: '87',
};
class BlockbookConnected {
constructor(config) {
assertType(BlockbookConnectedConfig, config);
this.networkType = config.network;
this.logger = new DelegateLogger(config.logger, config.packageName);
const { api, server } = resolveServer(config, this.logger);
this.api = api;
this.server = server;
}
getApi() {
if (this.server === null) {
throw new Error('Cannot access blockbook network when configured with null server');
}
return this.api;
}
async init() {
await this.api.connect();
}
async destroy() {
await this.api.disconnect();
}
async _retryDced(fn, additionalRetryableErrors) {
return retryIfDisconnected(fn, this.getApi(), this.logger, additionalRetryableErrors);
}
}
class BitcoinishPaymentsUtils extends BlockbookConnected {
constructor(config) {
var _a;
super(config);
this.determinePathForIndexFn = null;
this.deriveUniPubKeyForPathFn = null;
this.coinSymbol = config.coinSymbol;
this.coinName = config.coinName;
this.coinDecimals = config.coinDecimals;
this.bitcoinjsNetwork = config.bitcoinjsNetwork;
this.networkMinRelayFee = config.networkMinRelayFee;
this.dustThreshold = config.dustThreshold;
this.feeLevelBlockTargets = (_a = config.feeLevelBlockTargets) !== null && _a !== void 0 ? _a : DEFAULT_FEE_LEVEL_BLOCK_TARGETS;
this.blockcypherToken = config.blockcypherToken;
const unitConverters = createUnitConverters(this.coinDecimals);
this.toMainDenominationString = unitConverters.toMainDenominationString;
this.toMainDenominationNumber = unitConverters.toMainDenominationNumber;
this.toMainDenominationBigNumber = unitConverters.toMainDenominationBigNumber;
this.toBaseDenominationString = unitConverters.toBaseDenominationString;
this.toBaseDenominationNumber = unitConverters.toBaseDenominationNumber;
this.toBaseDenominationBigNumber = unitConverters.toBaseDenominationBigNumber;
}
isValidExtraId(extraId) {
return false;
}
async getBlockcypherFeeRecommendation(feeLevel) {
return getBlockcypherFeeRecommendation(feeLevel, this.coinSymbol, this.networkType, this.blockcypherToken, this.logger);
}
async getBlockbookFeeRecommendation(feeLevel) {
return getBlockbookFeeRecommendation(this.feeLevelBlockTargets[feeLevel], this.coinSymbol, this.networkType, this.api, this.logger);
}
async getFeeRateRecommendation(feeLevel, options = {}) {
if (options.source === 'blockcypher') {
return this.getBlockcypherFeeRecommendation(feeLevel);
}
if (options.source === 'blockbook') {
return this.getBlockbookFeeRecommendation(feeLevel);
}
if (options.source) {
throw new Error(`Unsupported fee recommendation source: ${options.source}`);
}
try {
return this.getBlockbookFeeRecommendation(feeLevel);
}
catch (e) {
this.logger.warn(e.toString());
return this.getBlockcypherFeeRecommendation(feeLevel);
}
}
_getPayportValidationMessage(payport) {
const { address, extraId } = payport;
if (!this.isValidAddress(address)) {
return 'Invalid payport address';
}
if (!isNil(extraId)) {
return 'Invalid payport extraId';
}
}
getPayportValidationMessage(payport) {
try {
payport = assertType(Payport, payport, 'payport');
}
catch (e) {
return e.message;
}
return this._getPayportValidationMessage(payport);
}
validatePayport(payport) {
payport = assertType(Payport, payport, 'payport');
const message = this._getPayportValidationMessage(payport);
if (message) {
throw new Error(message);
}
}
validateAddress(address) {
if (!this.isValidAddress(address)) {
throw new Error(`Invalid ${this.coinName} address: ${address}`);
}
}
isValidPayport(payport) {
return Payport.is(payport) && !this._getPayportValidationMessage(payport);
}
toMainDenomination(amount) {
return this.toMainDenominationString(amount);
}
toBaseDenomination(amount) {
return this.toBaseDenominationString(amount);
}
async getBlock(id, options = {}) {
if (isUndefined(id)) {
id = await this.getCurrentBlockHash();
}
const { includeTxs, ...getBlockOptions } = options;
const raw = await this._retryDced(() => this.getApi().getBlock(id, getBlockOptions), ['not found']);
if (!raw.time) {
throw new Error(`${this.coinSymbol} block ${id !== null && id !== void 0 ? id : 'latest'} missing timestamp`);
}
return {
id: raw.hash,
height: raw.height,
previousId: raw.previousBlockHash,
time: new Date(raw.time * 1000),
raw: {
...raw,
txs: includeTxs ? raw.txs : undefined,
},
};
}
async getCurrentBlockHash() {
return this._retryDced(async () => (await this.getApi().getBestBlock()).hash);
}
async getCurrentBlockNumber() {
return this._retryDced(async () => (await this.getApi().getBestBlock()).height);
}
isAddressBalanceSweepable(balance) {
return (this.toBaseDenominationNumber(balance) >=
this.dustThreshold + (this.networkMinRelayFee * MIN_P2PKH_SWEEP_BYTES) / 1000);
}
async getAddressBalance(address) {
const result = await this._retryDced(() => this.getApi().getAddressDetails(address, { details: 'basic' }));
const confirmedBalance = this.toMainDenominationBigNumber(result.balance);
const unconfirmedBalance = this.toMainDenominationBigNumber(result.unconfirmedBalance);
const spendableBalance = confirmedBalance.plus(unconfirmedBalance);
this.logger.debug('getBalance', address, confirmedBalance, unconfirmedBalance);
return {
confirmedBalance: confirmedBalance.toString(),
unconfirmedBalance: unconfirmedBalance.toString(),
spendableBalance: spendableBalance.toString(),
sweepable: this.isAddressBalanceSweepable(spendableBalance),
requiresActivation: false,
};
}
async getAddressUtxos(address) {
const utxosRaw = await this._retryDced(() => this.getApi().getUtxosForAddress(address));
const txsById = {};
const utxos = await Promise.all(utxosRaw.map(async (data) => {
var _a, _b;
const { value, height, lockTime, coinbase } = data;
const tx = (_a = txsById[data.txid]) !== null && _a !== void 0 ? _a : (await this._retryDced(() => this.getApi().getTx(data.txid)));
txsById[data.txid] = tx;
const output = tx.vout[data.vout];
const res = {
...data,
satoshis: Number.parseInt(value),
value: this.toMainDenominationString(value),
height: isUndefined(height) || height <= 0 ? undefined : String(height),
lockTime: isUndefined(lockTime) ? undefined : String(lockTime),
coinbase: Boolean(coinbase),
txHex: tx.hex,
scriptPubKeyHex: output === null || output === void 0 ? void 0 : output.hex,
address: (_b = output === null || output === void 0 ? void 0 : output.addresses) === null || _b === void 0 ? void 0 : _b[0],
spent: false,
};
return res;
}));
return utxos;
}
async getAddressNextSequenceNumber() {
return null;
}
txVoutToUtxoInfo(tx, output) {
var _a, _b, _c;
return {
txid: tx.txid,
vout: output.n,
satoshis: new BigNumber(output.value).toNumber(),
value: this.toMainDenominationString(output.value),
confirmations: tx.confirmations,
height: tx.blockHeight > 0 ? String(tx.blockHeight) : undefined,
coinbase: tx.valueIn === '0' && tx.value !== '0',
lockTime: tx.lockTime ? String(tx.lockTime) : undefined,
txHex: tx.hex,
scriptPubKeyHex: output.hex,
address: (_c = this.standardizeAddress((_b = (_a = output.addresses) === null || _a === void 0 ? void 0 : _a[0]) !== null && _b !== void 0 ? _b : '')) !== null && _c !== void 0 ? _c : '',
spent: Boolean(output.spent),
};
}
async getTransactionInfo(txId, options) {
const tx = await this._retryDced(() => this.getApi().getTx(txId));
const txSpecific = await this._retryDced(() => this.getApi().getTxSpecific(txId));
const weight = txSpecific.vsize || txSpecific.size;
const fee = this.toMainDenominationString(tx.fees);
const currentBlockNumber = await this.getCurrentBlockNumber();
const confirmationId = tx.blockHash || null;
const confirmationNumber = tx.blockHeight ? String(tx.blockHeight) : undefined;
const confirmationTimestamp = tx.blockTime ? new Date(tx.blockTime * 1000) : null;
if (tx.confirmations >= 0x7fffffff) {
this.logger.log(`Blockbook returned confirmations count for tx ${txId} that's way too big to be real (${tx.confirmations}), assuming 0`);
tx.confirmations = 0;
}
const isConfirmed = Boolean(tx.confirmations && tx.confirmations > 0);
const status = isConfirmed ? TransactionStatus.Confirmed : TransactionStatus.Pending;
const inputUtxos = tx.vin.map((utxo) => {
var _a, _b, _c;
return ({
txid: utxo.txid || '',
vout: utxo.vout || 0,
value: this.toMainDenominationString((_a = utxo.value) !== null && _a !== void 0 ? _a : 0),
address: (_b = utxo.addresses) === null || _b === void 0 ? void 0 : _b[0],
satoshis: Number.parseInt((_c = utxo.value) !== null && _c !== void 0 ? _c : '0'),
});
});
const fromAddresses = tx.vin.map(({ addresses = [] }) => {
return this.standardizeAddress(addresses[0]) || '';
});
let changeAddresses = [...fromAddresses];
if (options === null || options === void 0 ? void 0 : options.changeAddress) {
if (Array.isArray(options === null || options === void 0 ? void 0 : options.changeAddress)) {
changeAddresses = changeAddresses.concat(options.changeAddress);
}
else {
changeAddresses.push(options.changeAddress);
}
}
let fromAddress = 'batch';
if (fromAddresses.length === 0) {
throw new Error(`Unable to determine fromAddress of ${this.coinSymbol} tx ${txId}`);
}
else if (fromAddresses.length === 1) {
fromAddress = fromAddresses[0];
}
const outputUtxos = tx.vout.map((output) => this.txVoutToUtxoInfo(tx, output));
const outputAddresses = outputUtxos.map(({ address }) => address);
let externalAddresses = outputAddresses.filter((oA) => !changeAddresses.includes(oA));
if (options === null || options === void 0 ? void 0 : options.filterChangeAddresses) {
externalAddresses = await options.filterChangeAddresses(externalAddresses);
}
const externalOutputs = outputUtxos
.map(({ address, value }) => ({ address, value }))
.filter(({ address }) => externalAddresses.includes(address));
const amount = externalOutputs.reduce((total, { value }) => total.plus(value), new BigNumber(0)).toFixed();
let toAddress = 'batch';
if (externalOutputs.length === 0) ;
else if (externalOutputs.length === 1) {
toAddress = externalOutputs[0].address;
}
return {
status,
id: tx.txid,
fromIndex: null,
fromAddress,
fromExtraId: null,
toIndex: null,
toAddress,
toExtraId: null,
amount,
fee,
sequenceNumber: null,
confirmationId,
confirmationNumber,
currentBlockNumber,
confirmationTimestamp,
isExecuted: isConfirmed,
isConfirmed,
confirmations: tx.confirmations,
data: tx,
inputUtxos,
outputUtxos,
externalOutputs,
weight,
};
}
}
class BitcoinishPayments extends BitcoinishPaymentsUtils {
constructor(config) {
super(config);
this.minTxFee = config.minTxFee;
this.defaultFeeLevel = config.defaultFeeLevel;
this.targetUtxoPoolSize = isUndefined(config.targetUtxoPoolSize) ? 1 : config.targetUtxoPoolSize;
const minChange = toBigNumber(isUndefined(config.minChange) ? 0 : config.minChange);
if (minChange.lt(0)) {
throw new Error(`invalid minChange amount ${config.minChange}, must be positive`);
}
this.minChangeSat = this.toBaseDenominationNumber(minChange);
}
async init() { }
async destroy() { }
requiresBalanceMonitor() {
return false;
}
async getPayport(index) {
return { address: this.getAddress(index) };
}
getAddressType(address, index) {
const standartizedAddress = this.standardizeAddress(address);
for (const addressType of this.getSupportedAddressTypes()) {
if (standartizedAddress === this.getAddress(index, addressType)) {
return addressType;
}
}
throw new Error(`Failed to identify type of address ${address} at index ${index}`);
}
getInputUtxoTxSizeEstimateKeys(inputUtxos) {
return inputUtxos.map(({ address, signer }) => {
if (!address) {
throw new Error('Missing inputUtxo address field');
}
if (isUndefined(signer)) {
throw new Error('Missing inputUtxo signer field');
}
const addressType = this.getAddressType(address, signer);
const { m, n } = this.getRequiredSignatureCounts();
if (n === 1) {
return addressType;
}
return `${addressType}:${m}-${n}`;
});
}
async resolvePayport(payport) {
if (typeof payport === 'number') {
return this.getPayport(payport);
}
else if (typeof payport === 'string') {
if (!this.isValidAddress(payport)) {
throw new Error(`Invalid ${this.coinSymbol} address: ${payport}`);
}
return { address: this.standardizeAddress(payport) };
}
else if (Payport.is(payport)) {
if (!this.isValidAddress(payport.address)) {
throw new Error(`Invalid ${this.coinSymbol} payport.address: ${payport.address}`);
}
return { ...payport, address: this.standardizeAddress(payport.address) };
}
else if (DerivablePayport.is(payport)) {
const { addressType = this.addressType } = payport;
return { address: this.getAddress(payport.index, addressType) };
}
else {
throw new Error('Invalid payport');
}
}
async resolveFeeOption(feeOption) {
let targetLevel;
let target;
let feeBase = '';
let feeMain = '';
if (isType(FeeOptionCustom, feeOption)) {
targetLevel = FeeLevel.Custom;
target = feeOption;
}
else {
targetLevel = feeOption.feeLevel || this.defaultFeeLevel;
target = await this.getFeeRateRecommendation(targetLevel);
}
if (target.feeRateType === FeeRateType.Base) {
feeBase = target.feeRate;
feeMain = this.toMainDenominationString(feeBase);
}
else if (target.feeRateType === FeeRateType.Main) {
feeMain = target.feeRate;
feeBase = this.toBaseDenominationString(feeMain);
}
return {
targetFeeLevel: targetLevel,
targetFeeRate: target.feeRate,
targetFeeRateType: target.feeRateType,
feeBase,
feeMain,
};
}
async getBalance(payport) {
const { address } = await this.resolvePayport(payport);
return this.getAddressBalance(address);
}
isSweepableBalance(balance) {
return this.isAddressBalanceSweepable(balance);
}
usesUtxos() {
return true;
}
async getUtxos(payport) {
const { address } = await this.resolvePayport(payport);
const utxos = await this.getAddressUtxos(address);
if (typeof payport === 'number') {
utxos.forEach((u) => {
u.signer = payport;
});
}
else if (DerivablePayport.is(payport)) {
utxos.forEach((u) => {
u.signer = payport.index;
});
}
return utxos;
}
usesSequenceNumber() {
return false;
}
async getNextSequenceNumber() {
return null;
}
async resolveFromTo(from, to) {
const fromPayport = await this.getPayport(from);
const toPayport = await this.resolvePayport(to);
return {
fromAddress: fromPayport.address,
fromIndex: from,
fromExtraId: fromPayport.extraId,
fromPayport,
toAddress: toPayport.address,
toIndex: typeof to === 'number' ? to : null,
toExtraId: toPayport.extraId,
toPayport,
};
}
convertOutputsToExternalFormat(outputs) {
return outputs.map(({ address, satoshis }) => ({ address, value: this.toMainDenominationString(satoshis) }));
}
estimateTxSize(inputUtxos, changeOutputCount, externalOutputAddresses) {
return 10 + 148 * inputUtxos.length + 34 * (changeOutputCount + externalOutputAddresses.length);
}
feeRateToSatoshis({ feeRate, feeRateType }, inputUtxos, changeOutputCount, externalOutputAddresses) {
if (feeRateType === FeeRateType.BasePerWeight) {
const estimatedTxSize = this.estimateTxSize(inputUtxos, changeOutputCount, externalOutputAddresses);
this.logger.debug(`${this.coinSymbol} buildPaymentTx - ` +
`Estimated tx size of ${estimatedTxSize} vbytes for a tx with ${inputUtxos.length} inputs, ` +
`${externalOutputAddresses.length} external outputs, and ${changeOutputCount} change outputs`);
return Number.parseFloat(feeRate) * estimatedTxSize;
}
else if (feeRateType === FeeRateType.Main) {
return this.toBaseDenominationNumber(feeRate);
}
return Number.parseFloat(feeRate);
}
estimateTxFee(targetRate, inputUtxos, changeOutputCount, externalOutputAddresses) {
let feeSat = this.feeRateToSatoshis(targetRate, inputUtxos, changeOutputCount, externalOutputAddresses);
if (this.minTxFee) {
const minTxFeeSat = this.feeRateToSatoshis(this.minTxFee, inputUtxos, changeOutputCount, externalOutputAddresses);
if (feeSat < minTxFeeSat) {
this.logger.debug(`Using min tx fee of ${minTxFeeSat} sat (${this.minTxFee} sat/byte) instead of ${feeSat} sat`);
feeSat = minTxFeeSat;
}
}
const minRelaySat = 1 +
this.feeRateToSatoshis({
feeRate: String(this.networkMinRelayFee / 1000),
feeRateType: FeeRateType.BasePerWeight,
}, inputUtxos, changeOutputCount, externalOutputAddresses);
if (feeSat < minRelaySat) {
this.logger.debug(`${this.coinSymbol} buildPaymentTx - ` +
`Using network min relay fee of ${minRelaySat} sat instead of ${feeSat} sat`);
feeSat = minRelaySat;
}
const result = Math.ceil(feeSat);
this.logger.debug(`${this.coinSymbol} buildPaymentTx - ` +
`Estimated fee of ${result} sat for target rate ${targetRate.feeRate} ${targetRate.feeRateType} for a tx with ` +
`${inputUtxos.length} inputs, ${externalOutputAddresses.length} external outputs, and ${changeOutputCount} change outputs`);
return result;
}
determineTargetChangeOutputCount(unusedUtxoCount, inputUtxoCount) {
const remainingUtxoCount = unusedUtxoCount - inputUtxoCount;
const additionalUtxosNeeded = remainingUtxoCount < this.targetUtxoPoolSize ? this.targetUtxoPoolSize - remainingUtxoCount : 1;
return Math.min(additionalUtxosNeeded, inputUtxoCount + 1);
}
adjustOutputAmounts(tbc, newOutputTotal, description, newError = (m) => new Error(m)) {
const totalBefore = tbc.externalOutputTotal;
let totalAdjustment = newOutputTotal - totalBefore;
const outputCount = tbc.externalOutputs.length;
const amountChangePerOutput = Math.floor(totalAdjustment / outputCount);
totalAdjustment = amountChangePerOutput * outputCount;
this.logger.log(`${this.coinSymbol} buildPaymentTx - Adjusting external output total (${tbc.externalOutputTotal} sat) by ${totalAdjustment} sat ` +
`across ${outputCount} outputs (${amountChangePerOutput} sat each) for ${description}`);
for (let i = 0; i < outputCount; i++) {
const externalOutput = tbc.externalOutputs[i];
if (externalOutput.satoshis + amountChangePerOutput <= this.dustThreshold) {
const errorMessage = `${this.coinSymbol} buildPaymentTx - output ${i} for ${externalOutput.satoshis} sat ` +
`after ${description} of ${amountChangePerOutput} sat is too small to send`;
if (externalOutput.satoshis + amountChangePerOutput <= 0) {
throw newError(errorMessage);
}
throw newError(`${errorMessage} (below dust threshold of ${this.dustThreshold} sat)`);
}
externalOutput.satoshis += amountChangePerOutput;
}
tbc.externalOutputTotal += totalAdjustment;
this.logger.log(`${this.coinSymbol} buildPaymentTx - Adjusted external output total from ${totalBefore} sat to ${tbc.externalOutputTotal} sat for ${description}`);
}
adjustTxFee(tbc, newFeeSat, description) {
if (newFeeSat > Math.ceil(tbc.desiredOutputTotal * (tbc.maxFeePercent / 100))) {
throw new PaymentsError(PaymentsErrorCode.TxFeeTooHigh, `${description} (${newFeeSat} sat) exceeds maximum fee percent (${tbc.maxFeePercent}%) ` +
`of desired output total (${tbc.desiredOutputTotal} sat)`);
}
const feeSatAdjustment = newFeeSat - tbc.feeSat;
if (!tbc.recipientPaysFee && !tbc.isSweep) {
this.applyFeeAdjustment(tbc, feeSatAdjustment, description);
return;
}
this.adjustOutputAmounts(tbc, tbc.externalOutputTotal - feeSatAdjustment, description, (m) => new PaymentsError(PaymentsErrorCode.TxFeeTooHigh, m));
this.applyFeeAdjustment(tbc, feeSatAdjustment, description);
}
applyFeeAdjustment(tbc, feeSatAdjustment, description) {
const feeBefore = tbc.feeSat;
tbc.feeSat += feeSatAdjustment;
this.logger.log(`${this.coinSymbol} buildPaymentTx - Adjusted fee from ${feeBefore} sat to ${tbc.feeSat} sat for ${description}`);
}
selectInputUtxos(tbc) {
if (tbc.useAllUtxos) {
this.selectInputUtxosForAll(tbc);
}
else {
this.selectInputUtxosPartial(tbc);
}
if (tbc.externalOutputTotal + tbc.feeSat > tbc.inputTotal) {
throw new PaymentsError(PaymentsErrorCode.TxInsufficientBalance, `${this.coinSymbol} buildPaymentTx - You do not have enough UTXOs (${tbc.inputTotal} sat) ` +
`to send ${tbc.externalOutputTotal} sat with ${tbc.feeSat} sat fee`);
}
}
selectInputUtxosForAll(tbc) {
for (const utxo of tbc.enforcedUtxos) {
tbc.inputTotal += utxo.satoshis;
tbc.inputUtxos.push(utxo);
}
for (const utxo of tbc.selectableUtxos) {
if (tbc.inputUtxos.map((u) => `${u.txid}${u.vout}`).includes(`${utxo.txid}${utxo.vout}`)) {
continue;
}
tbc.inputTotal += utxo.satoshis;
tbc.inputUtxos.push(utxo);
}
if (tbc.isSweep && tbc.inputTotal !== tbc.externalOutputTotal) {
this.adjustOutputAmounts(tbc, tbc.inputTotal, 'dust inputs filtering');
}
const feeSat = this.estimateTxFee(tbc.desiredFeeRate, tbc.inputUtxos, 0, tbc.externalOutputAddresses);
this.adjustTxFee(tbc, feeSat, 'sweep fee');
}
selectInputUtxosPartial(tbc) {
if (tbc.enforcedUtxos && tbc.enforcedUtxos.length > 0) {
return this.selectWithForcedUtxos(tbc);
}
else {
return this.selectFromAvailableUtxos(tbc);
}
}
estimateIdealUtxoSelectionFee(tbc, inputUtxos) {
return this.estimateTxFee(tbc.desiredFeeRate, inputUtxos, 0, tbc.externalOutputAddresses);
}
isIdealUtxoSelection(tbc, utxosSelected, feeSat) {
const idealSolutionMinSat = tbc.desiredOutputTotal + (tbc.recipientPaysFee ? 0 : feeSat);
const idealSolutionMaxSat = idealSolutionMinSat + this.dustThreshold;
let selectedTotal = 0;
for (const utxo of utxosSelected) {
selectedTotal += utxo.satoshis;
}
return selectedTotal >= idealSolutionMinSat && selectedTotal <= idealSolutionMaxSat;
}
selectWithForcedUtxos(tbc) {
for (const utxo of tbc.enforcedUtxos) {
tbc.inputTotal += utxo.satoshis;
tbc.inputUtxos.push(utxo);
}
const idealSolutionFeeSat = this.estimateIdealUtxoSelectionFee(tbc, tbc.inputUtxos);
if (this.isIdealUtxoSelection(tbc, tbc.inputUtxos, idealSolutionFeeSat)) {
this.adjustTxFee(tbc, idealSolutionFeeSat, 'forced inputs ideal solution fee');
return;
}
const targetChangeOutputCount = this.determineTargetChangeOutputCount(tbc.nonDustUtxoCount, tbc.inputUtxos.length);
const feeSat = this.estimateTxFee(tbc.desiredFeeRate, tbc.inputUtxos, targetChangeOutputCount, tbc.externalOutputAddresses);
const minimumSat = tbc.desiredOutputTotal + (tbc.recipientPaysFee ? 0 : feeSat);
if (tbc.inputTotal >= minimumSat) {
this.adjustTxFee(tbc, feeSat, 'forced inputs fee');
return;
}
this.selectFromAvailableUtxos(tbc);
}
selectFromAvailableUtxos(tbc) {
for (const utxo of tbc.selectableUtxos) {
const potentialIdealInputs = [...tbc.inputUtxos, utxo];
const idealSolutionFeeSat = this.estimateIdealUtxoSelectionFee(tbc, potentialIdealInputs);
if (this.isIdealUtxoSelection(tbc, potentialIdealInputs, idealSolutionFeeSat)) {
tbc.inputUtxos.push(utxo);
tbc.inputTotal += utxo.satoshis;
this.logger.log(`${this.coinSymbol} buildPaymentTx - ` +
`Found ideal ${this.coinSymbol} input utxo solution to send ${tbc.desiredOutputTotal} sat ` +
`${tbc.recipientPaysFee ? 'less' : 'plus'} fee of ${idealSolutionFeeSat} sat ` +
`using single utxo ${utxo.txid}:${utxo.vout}`);
this.adjustTxFee(tbc, idealSolutionFeeSat, 'ideal solution fee');
return;
}
}
let feeSat = 0;
for (const utxo of shuffleUtxos(tbc.selectableUtxos)) {
tbc.inputUtxos.push(utxo);
tbc.inputTotal += utxo.satoshis;
const targetChangeOutputCount = this.determineTargetChangeOutputCount(tbc.nonDustUtxoCount, tbc.inputUtxos.length);
feeSat = this.estimateTxFee(tbc.desiredFeeRate, tbc.inputUtxos, targetChangeOutputCount, tbc.externalOutputAddresses);
const neededSat = tbc.externalOutputTotal + (tbc.recipientPaysFee ? 0 : feeSat);
if (tbc.inputTotal >= neededSat) {
break;
}
}
this.adjustTxFee(tbc, feeSat, 'selected inputs fee');
}
allocateChangeOutputs(tbc) {
tbc.totalChange = tbc.inputTotal - tbc.externalOutputTotal - tbc.feeSat;
if (tbc.totalChange < 0) {
throw new Error(`${this.coinSymbol} buildPaymentTx - totalChange is negative when building tx, this shouldnt happen!`);
}
if (tbc.totalChange === 0) {
this.logger.debug(`${this.coinSymbol} buildPaymentTx - no change to allocate`);
return;
}
const targetChangeOutputCount = this.determineTargetChangeOutputCount(tbc.nonDustUtxoCount, tbc.inputUtxos.length);
const changeOutputWeights = this.createWeightedChangeOutputs(targetChangeOutputCount, tbc.changeAddress);
const totalChangeAllocated = this.allocateChangeUsingWeights(tbc, changeOutputWeights);
let changeOutputCount = tbc.changeOutputs.length;
let looseChange = tbc.totalChange - totalChangeAllocated;
if (changeOutputCount < targetChangeOutputCount) {
looseChange = this.readjustTxFeeAfterDroppingChangeOutputs(tbc, changeOutputCount, totalChangeAllocated, looseChange);
}
if (looseChange < 0) {
throw new Error(`${this.coinSymbol} buildPaymentTx - looseChange should never be negative!`);
}
if (changeOutputCount > 0 && looseChange > 0) {
looseChange = this.reallocateLooseChange(tbc, changeOutputCount, looseChange);
}
else if (changeOutputCount === 0 && looseChange > this.dustThreshold) {
this.logger.log(`${this.coinSymbol} buildPaymentTx - allocating all loose change towards single ${looseChange} sat change output`);
tbc.changeOutputs.push({ address: tbc.changeAddress[0], satoshis: looseChange });
changeOutputCount += 1;
looseChange = 0;
}
if (looseChange > 0) {
if (looseChange > this.dustThreshold) {
throw new Error(`${this.coinSymbol} buildPaymentTx - Ended up with loose change (${looseChange} sat) exceeding dust threshold, this should never happen!`);
}
this.applyFeeAdjustment(tbc, looseChange, 'loose change allocation');
tbc.totalChange -= looseChange;
looseChange = 0;
}
}
validateBuildContext(tbc) {
const inputSum = sumField(tbc.inputUtxos, 'satoshis');
if (!inputSum.eq(tbc.inputTotal)) {
throw new Error(`${this.coinSymbol} buildPaymentTx - invalid context: input utxo sum ${inputSum} doesn't equal inputTotal ${tbc.inputTotal}`);
}
const externalOutputSum = sumField(tbc.externalOutputs, 'satoshis');
if (!externalOutputSum.eq(tbc.externalOutputTotal)) {
throw new Error(`${this.coinSymbol} buildPaymentTx - invalid context: external output sum ${externalOutputSum} doesn't equal externalOutputTotal ${tbc.externalOutputTotal}`);
}
const changeOutputSum = sumField(tbc.changeOutputs, 'satoshis');
if (!changeOutputSum.eq(tbc.totalChange)) {
throw new Error(`${this.coinSymbol} buildPaymentTx - invalid context: change output sum ${changeOutputSum} doesn't equal totalChange ${tbc.totalChange}`);
}
const actualFee = inputSum.minus(externalOutputSum).minus(changeOutputSum);
if (!actualFee.eq(tbc.feeSat)) {
throw new Error(`${this.coinSymbol} buildPaymentTx - invalid context: inputs minus outputs sum ${actualFee} doesn't equal feeSat ${tbc.feeSat}`);
}
}
omitDustUtxos(utxos, feeRate, maxFeePercent) {
return this.prepareUtxos(utxos).filter((utxo) => {
const utxoSpendCost = this.estimateTxFee(feeRate, [utxo], 0, []) - this.estimateTxFee(feeRate, [], 0, []);
const minUtxoSatoshis = Math.ceil((100 * utxoSpendCost) / maxFeePercent);
if (utxo.satoshis > minUtxoSatoshis) {
return true;
}
this.logger.log(`${this.coinSymbol} buildPaymentTx - Ignoring dust utxo (${minUtxoSatoshis} sat or lower) ${utxo.txid}:${utxo.vout}`);
return false;
});
}
async buildPaymentTx(params) {
const nonDustUtxos = this.omitDustUtxos(params.unusedUtxos, params.desiredFeeRate, params.maxFeePercent);
const tbc = {
...params,
desiredOutputTotal: 0,
externalOutputs: [],
externalOutputTotal: 0,
externalOutputAddresses: [],
isSweep: false,
inputUtxos: [],
inputTotal: 0,
feeSat: 0,
totalChange: 0,
changeOutputs: [],
unusedUtxos: this.prepareUtxos(params.unusedUtxos),
enforcedUtxos: this.prepareUtxos(params.enforcedUtxos),
nonDustUtxoCount: nonDustUtxos.length,
selectableUtxos: nonDustUtxos
.filter((utxo) => params.useUnconfirmedUtxos || isConfirmedUtxo(utxo))
.filter((utxo) => !params.enforcedUtxos.find((u) => u.txid === utxo.txid && u.vout === utxo.vout)),
};
for (let i = 0; i < tbc.desiredOutputs.length; i++) {
const { address: unvalidatedAddress, value } = tbc.desiredOutputs[i];
if (!this.isValidAddress(unvalidatedAddress)) {
throw new Error(`Invalid ${this.coinSymbol} address ${unvalidatedAddress} provided for output ${i}`);
}
const address = this.standardizeAddress(unvalidatedAddress);
const satoshis = this.toBaseDenominationNumber(value);
if (isNaN(satoshis) || satoshis <= 0) {
throw new Error(`Invalid ${this.coinSymbol} value (${value}) provided for output ${i} (${address}) - not a positive, non-zero number`);
}
if (satoshis <= this.dustThreshold) {
throw new Error(`Invalid ${this.coinSymbol} value (${value}) provided for output ${i} (${address}) - below dust threshold (${this.dustThreshold} sat)`);
}
tbc.externalOutputs.push({ address, satoshis });
tbc.externalOutputAddresses.push(address);
tbc.externalOutputTotal += satoshis;
tbc.desiredOutputTotal += satoshis;
}
for (const address of tbc.changeAddress) {
if (!this.isValidAddress(address)) {
throw new Error(`Invalid ${this.coinSymbol} change address ${address} provided`);
}
}
const unfilteredUtxoTotal = sumUtxoValue(params.unusedUtxos, params.useUnconfirmedUtxos);
tbc.isSweep = tbc.useAllUtxos && tbc.desiredOutputTotal >= this.toBaseDenominationNumber(unfilteredUtxoTotal);
this.selectInputUtxos(tbc);
tbc.inputUtxos = tbc.inputUtxos.sort((a, b) => (`${a.txid}${a.vout}` > `${b.txid}${b.vout}` && 1) || -1);
this