UNPKG

@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
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