bitcore-wallet-service
Version:
A service for Mutisig HD Bitcoin Wallets
1,021 lines (855 loc) • 33.6 kB
text/typescript
import * as async from 'async';
import { BitcoreLib } from 'crypto-wallet-core';
import _ from 'lodash';
import { WalletService } from 'src/lib/server';
import { IChain } from '..';
import config from '../../../config';
import { Common } from '../../common';
import { ClientError } from '../../errors/clienterror';
import { Errors } from '../../errors/errordefinitions';
import logger from '../../logger';
import { IWallet, TxProposal } from '../../model';
const $ = require('preconditions').singleton();
const Constants = Common.Constants;
const Utils = Common.Utils;
const Defaults = Common.Defaults;
export class BtcChain implements IChain {
protected sizeEstimationMargin: number;
protected inputSizeEstimationMargin: number;
constructor(private bitcoreLib = BitcoreLib) {
this.sizeEstimationMargin = config.btc?.sizeEstimationMargin ?? 0.01;
this.inputSizeEstimationMargin = config.btc?.inputSizeEstimationMargin ?? 2;
}
getSizeSafetyMargin(opts: any = {}): number {
if (opts.conservativeEstimation) {
return this.sizeEstimationMargin;
}
return 0;
}
getInputSizeSafetyMargin(opts: any = {}): number {
if (opts.conservativeEstimation) {
return this.inputSizeEstimationMargin;
}
return 0;
}
getWalletBalance(server: WalletService, wallet, opts, cb) {
server.getUtxosForCurrentWallet(
{
coin: opts.coin,
addresses: opts.addresses
},
(err, utxos) => {
if (err) return cb(err);
const balance = {
...this.totalizeUtxos(utxos),
byAddress: []
};
// Compute balance by address
const byAddress = {};
_.each(_.keyBy(_.sortBy(utxos, 'address'), 'address'), (value, key) => {
byAddress[key] = {
address: key,
path: value.path,
amount: 0
};
});
_.each(utxos, utxo => {
byAddress[utxo.address].amount += utxo.satoshis;
});
balance.byAddress = _.values(byAddress);
return cb(null, balance);
}
);
}
// opts.payProUrl => only to use different safety margin or not
getWalletSendMaxInfo(server: WalletService, wallet, opts, cb) {
server.getUtxosForCurrentWallet({}, (err, utxos) => {
if (err) return cb(err);
const MAX_TX_SIZE_IN_KB = Defaults.MAX_TX_SIZE_IN_KB_BTC;
const info = {
size: 0,
amount: 0,
fee: 0,
feePerKb: 0,
inputs: [],
utxosBelowFee: 0,
amountBelowFee: 0,
utxosAboveMaxSize: 0,
amountAboveMaxSize: 0
};
let inputs = _.reject(utxos, 'locked');
if (!!opts.excludeUnconfirmedUtxos) {
inputs = _.filter(inputs, 'confirmations');
}
inputs = _.sortBy(inputs, input => {
return -input.satoshis;
});
if (_.isEmpty(inputs)) return cb(null, info);
server._getFeePerKb(wallet, opts, (err, feePerKb) => {
if (err) return cb(err);
info.feePerKb = feePerKb;
const txp = TxProposal.create({
walletId: server.walletId,
coin: wallet.coin,
addressType: wallet.addressType,
network: wallet.network,
walletM: wallet.m,
walletN: wallet.n,
feePerKb
});
const baseTxpSize = this.getEstimatedSize(txp, { conservativeEstimation: true });
const sizePerInput = this.getEstimatedSizeForSingleInput(txp, { conservativeEstimation: true });
const feePerInput = (sizePerInput * txp.feePerKb) / 1000;
const partitionedByAmount = _.partition(inputs, input => {
return input.satoshis > feePerInput;
});
info.utxosBelowFee = partitionedByAmount[1].length;
info.amountBelowFee = _.sumBy(partitionedByAmount[1], 'satoshis');
inputs = partitionedByAmount[0];
_.each(inputs, (input, i) => {
const sizeInKb = (baseTxpSize + (i + 1) * sizePerInput) / 1000;
if (sizeInKb > MAX_TX_SIZE_IN_KB) {
info.utxosAboveMaxSize = inputs.length - i;
info.amountAboveMaxSize = _.sumBy(_.slice(inputs, i), 'satoshis');
return false;
}
txp.inputs.push(input);
});
if (_.isEmpty(txp.inputs)) return cb(null, info);
const fee = this.getEstimatedFee(txp, { conservativeEstimation: true });
const amount = _.sumBy(txp.inputs, 'satoshis') - fee;
if (amount < Defaults.MIN_OUTPUT_AMOUNT) return cb(null, info);
info.size = this.getEstimatedSize(txp, { conservativeEstimation: true });
info.fee = fee;
info.amount = amount;
if (opts.returnInputs) {
info.inputs = _.shuffle(txp.inputs);
}
return cb(null, info);
});
});
}
getDustAmountValue() {
return this.bitcoreLib.Transaction.DUST_AMOUNT;
}
getTransactionCount() {
return null;
}
getChangeAddress(server: WalletService, wallet, opts) {
return new Promise((resolve, reject) => {
const getChangeAddress = (wallet, cb) => {
if (wallet.singleAddress) {
server.storage.fetchAddresses(server.walletId, (err, addresses) => {
if (err) return cb(err);
if (_.isEmpty(addresses)) return cb(new ClientError('The wallet has no addresses'));
return cb(null, _.head(addresses));
});
} else {
if (opts.changeAddress) {
try {
this.validateAddress(wallet, opts.changeAddress, opts);
} catch (addrErr) {
return cb(addrErr);
}
server.storage.fetchAddressByWalletId(wallet.id, opts.changeAddress, (err, address) => {
if (err || !address) return cb(Errors.INVALID_CHANGE_ADDRESS);
return cb(null, address);
});
} else {
const escrowInputs = opts.instantAcceptanceEscrow ? opts.inputs : undefined;
return cb(null, wallet.createAddress(true, undefined, escrowInputs), true);
}
}
};
getChangeAddress(wallet, (err, address, isNew) => {
if (err) return reject(err);
return resolve(address);
});
});
}
checkDust(output) {
const dustThreshold = Math.max(Defaults.MIN_OUTPUT_AMOUNT, this.bitcoreLib.Transaction.DUST_AMOUNT);
if (output.amount < dustThreshold) {
return Errors.DUST_AMOUNT;
}
}
checkScriptOutput(output) {
if (output.script) {
if (typeof output.script !== 'string') {
return Errors.SCRIPT_TYPE;
}
// check OP_RETURN
if (!output.script.startsWith('6a')) {
return Errors.SCRIPT_OP_RETURN;
}
// check OP_RETURN amount
if (output.script.startsWith('6a') && output.amount != 0) {
return Errors.SCRIPT_OP_RETURN_AMOUNT;
}
}
}
// https://bitcoin.stackexchange.com/questions/88226/how-to-calculate-the-size-of-multisig-transaction
getEstimatedSizeForSingleInput(txp, opts = { conservativeEstimation: false }) {
const SIGNATURE_SIZE = 72 + 1; // 73 is for non standanrd, not our wallet. +1 OP_DATA
const PUBKEY_SIZE = 33 + 1; // +1 OP_DATA
const inputSafetyMargin = this.getInputSizeSafetyMargin({ conservativeEstimation: opts.conservativeEstimation });
switch (txp.addressType) {
case Constants.SCRIPT_TYPES.P2PKH:
// https://bitcoin.stackexchange.com/questions/48279/how-big-is-the-input-of-a-p2pkh-transaction
return 148 + inputSafetyMargin;
case Constants.SCRIPT_TYPES.P2WPKH:
return 69 + inputSafetyMargin; // vsize
case Constants.SCRIPT_TYPES.P2TR:
return 58 + inputSafetyMargin; // vsize
case Constants.SCRIPT_TYPES.P2WSH:
return Math.ceil(32 + 4 + 1 + (5 + txp.requiredSignatures * 74 + txp.walletN * 34) / 4 + 4) + inputSafetyMargin; // vsize
case Constants.SCRIPT_TYPES.P2SH:
return 46 + txp.requiredSignatures * SIGNATURE_SIZE + txp.walletN * PUBKEY_SIZE + inputSafetyMargin;
default:
logger.warn('Unknown address type at getEstimatedSizeForSingleInput: %o', txp.addressType);
return 46 + txp.requiredSignatures * SIGNATURE_SIZE + txp.walletN * PUBKEY_SIZE + inputSafetyMargin;
}
}
// Data from:
// https://bitcoin.stackexchange.com/questions/88226/how-to-calculate-the-size-of-multisig-transaction
getEstimatedSizeForSingleOutput(address?: string) {
let addressType = '';
if (address) {
const a = this.bitcoreLib.Address(address);
addressType = a.type;
}
return this.getEstimatedSizeForAddressType(addressType);
}
getEstimatedSizeForAddressType(addressType?: string) {
let scriptSize;
switch (addressType) {
case 'pubkeyhash':
scriptSize = 25;
break;
case 'scripthash':
scriptSize = 23;
break;
case 'witnesspubkeyhash':
scriptSize = 22;
break;
case 'witnessscripthash':
scriptSize = 34;
break;
default:
scriptSize = 34;
// logger.warn('Unknown address type at getEstimatedSizeForSingleOutput: %o', addressType);
break;
}
return scriptSize + 8 + 1; // value + script length
}
getEstimatedSize(txp, opts) {
const overhead = 4 + 4 + 1 + 1; // version, locktime, ninputs, noutputs
// This assumed ALL inputs of the wallet are the same time
const inputSize = this.getEstimatedSizeForSingleInput(txp, opts);
const nbInputs = txp.inputs.length;
let outputsSize = 0;
let outputs = _.isArray(txp.outputs) ? txp.outputs : [txp.toAddress];
let addresses = outputs.map(x => x.toAddress);
if (txp.changeAddress) {
addresses.push(txp.changeAddress.address);
}
_.each(addresses, x => {
outputsSize += this.getEstimatedSizeForSingleOutput(x);
});
if (opts && opts.instantAcceptanceEscrow) {
outputsSize += this.getEstimatedSizeForAddressType('scripthash');
}
// If there is no *output* yet defined, (eg: get sendmax info), add a single, default, output);
if (!outputsSize) {
outputsSize = this.getEstimatedSizeForSingleOutput();
}
const size = overhead + inputSize * nbInputs + outputsSize;
return Math.ceil(size * 1 + this.getSizeSafetyMargin(opts));
}
getEstimatedFee(txp, opts) {
$.checkState(_.isNumber(txp.feePerKb), 'Failed state: txp.feePerKb is not a number at <getEstimatedFee()>');
let fee;
// if TX is ready? no estimation is needed.
if (txp.inputs.length && !txp.changeAddress && txp.outputs.length) {
const totalInputs = _.sumBy(txp.inputs, 'satoshis');
const totalOutputs = _.sumBy(txp.outputs, 'amount');
if (totalInputs && totalOutputs) {
fee = totalInputs - totalOutputs;
}
}
if (!fee) {
fee = (txp.feePerKb * this.getEstimatedSize(txp, opts)) / 1000;
fee = Math.max(fee, this.bitcoreLib.Transaction.DUST_AMOUNT);
}
return parseInt(fee.toFixed(0));
}
getFee(server: WalletService, wallet, opts) {
return new Promise(resolve => {
server._getFeePerKb(wallet, opts, (err, feePerKb) => {
return resolve({ feePerKb });
});
});
}
getBitcoreTx(txp, opts = { signed: true }) {
const t = new this.bitcoreLib.Transaction();
// BTC tx version
if (txp.version <= 3) {
t.setVersion(1);
} else {
t.setVersion(2);
// set nLockTime (only txp.version>=4)
if (txp.lockUntilBlockHeight) t.lockUntilBlockHeight(txp.lockUntilBlockHeight);
}
if (txp.multiTx) {
throw Errors.MULTI_TX_UNSUPPORTED;
}
/*
* txp.inputs clean txp.input
* removes possible nSequence number (BIP68)
*/
let inputs = txp.inputs.map(x => {
return {
address: x.address,
txid: x.txid,
vout: x.vout,
outputIndex: x.outputIndex,
scriptPubKey: x.scriptPubKey,
satoshis: x.satoshis,
publicKeys: x.publicKeys
};
});
switch (txp.addressType) {
case Constants.SCRIPT_TYPES.P2WSH:
case Constants.SCRIPT_TYPES.P2SH:
for (const i of inputs) {
$.checkState(i.publicKeys, 'Failed state: Inputs should include public keys at <getBitcoreTx()>');
t.from(i, i.publicKeys, txp.requiredSignatures);
}
break;
case Constants.SCRIPT_TYPES.P2WPKH:
case Constants.SCRIPT_TYPES.P2PKH:
case Constants.SCRIPT_TYPES.P2TR:
t.from(inputs);
break;
}
for (const o of txp.outputs || []) {
$.checkState(
o.script || o.toAddress,
'Failed state: Output should have either toAddress or script specified at <getBitcoreTx()>'
);
if (o.script) {
t.addOutput(
new this.bitcoreLib.Transaction.Output({
script: o.script,
satoshis: o.amount
})
);
} else {
t.to(o.toAddress, o.amount);
}
}
t.fee(txp.fee);
if (txp.instantAcceptanceEscrow && txp.escrowAddress) {
t.escrow(txp.escrowAddress.address, txp.instantAcceptanceEscrow + txp.fee);
}
if (txp.enableRBF) t.enableRBF();
if (txp.changeAddress) {
t.change(txp.changeAddress.address);
}
// Shuffle outputs for improved privacy
if (t.outputs.length > 1) {
const outputOrder = _.reject(txp.outputOrder, (order: number) => {
return order >= t.outputs.length;
});
$.checkState(
t.outputs.length == outputOrder.length,
'Failed state: t.outputs.length not equal to outputOrder.length at <getBitcoreTx()>'
);
t.sortOutputs(outputs => {
return _.map(outputOrder, i => {
return outputs[i];
});
});
}
// Validate actual inputs vs outputs independently of Bitcore
const totalInputs = _.sumBy(t.inputs, 'output.satoshis');
const totalOutputs = _.sumBy(t.outputs, 'satoshis');
$.checkState(
totalInputs > 0 && totalOutputs > 0 && totalInputs >= totalOutputs,
'Failed state: not-enough-inputs at <getBitcoreTx()>'
);
$.checkState(
totalInputs - totalOutputs <= Defaults.MAX_TX_FEE[txp.coin],
'Failed state: fee-too-high at <getBitcoreTx()>'
);
if (opts.signed) {
const sigs = txp.getCurrentSignatures();
_.each(sigs, x => {
this.addSignaturesToBitcoreTx(t, txp.inputs, txp.inputPaths, x.signatures, x.xpub, txp.signingMethod);
});
}
return t;
}
convertFeePerKb(p, feePerKb) {
return [p, Utils.strip(feePerKb * 1e8)];
}
checkTx(txp) {
let bitcoreError;
const MAX_TX_SIZE_IN_KB = Defaults.MAX_TX_SIZE_IN_KB_BTC;
if (this.getEstimatedSize(txp, { conservativeEstimation: true }) / 1000 > MAX_TX_SIZE_IN_KB)
return Errors.TX_MAX_SIZE_EXCEEDED;
const serializationOpts = {
disableIsFullySigned: true,
disableSmallFees: true,
disableLargeFees: true,
disableDustOutputs: false
};
if (txp.outputs && Array.isArray(txp.outputs)) {
for (let output of txp.outputs) {
if (output.script && output.script.startsWith('6a')) { // check OP_RETURN
serializationOpts.disableDustOutputs = true;
}
}
}
if (_.isEmpty(txp.inputPaths)) return Errors.NO_INPUT_PATHS;
try {
const bitcoreTx = this.getBitcoreTx(txp);
bitcoreError = bitcoreTx.getSerializationError(serializationOpts);
if (!bitcoreError) {
txp.fee = bitcoreTx.getFee();
}
} catch (ex) {
logger.warn('Error building Bitcore transaction: %o', ex);
return ex;
}
if (bitcoreError instanceof this.bitcoreLib.errors.Transaction.FeeError) {
return new ClientError(
Errors.codes.INSUFFICIENT_FUNDS_FOR_FEE,
`${Errors.INSUFFICIENT_FUNDS_FOR_FEE.message}. RequiredFee: ${txp.fee} Coin: ${txp.coin} feePerKb: ${txp.feePerKb} Err1`,
{
coin: txp.coin,
feePerKb: txp.feePerKb,
requiredFee: txp.fee
}
);
}
if (bitcoreError instanceof this.bitcoreLib.errors.Transaction.DustOutputs) return Errors.DUST_AMOUNT;
return bitcoreError;
}
checkTxUTXOs(server: WalletService, txp, opts, cb) {
logger.debug('Rechecking UTXOs availability for publishTx');
if (txp.replaceTxByFee) {
logger.debug('Ignoring spend utxos check (Replacing tx designated as RBF)');
return cb();
}
const utxoKey = utxo => {
return utxo.txid + '|' + utxo.vout;
};
server.getUtxosForCurrentWallet(
{
addresses: txp.inputs
},
(err, utxos) => {
if (err) return cb(err);
const txpInputs = _.map(txp.inputs, utxoKey);
const utxosIndex = _.keyBy(utxos, utxoKey);
const unavailable = _.some(txpInputs, i => {
const utxo = utxosIndex[i];
return !utxo || utxo.locked;
});
if (unavailable) return cb(Errors.UNAVAILABLE_UTXOS);
return cb();
}
);
}
totalizeUtxos(utxos) {
const balance = {
totalAmount: _.sumBy(utxos, 'satoshis'),
lockedAmount: _.sumBy(_.filter(utxos, 'locked'), 'satoshis'),
totalConfirmedAmount: _.sumBy(_.filter(utxos, 'confirmations'), 'satoshis'),
lockedConfirmedAmount: _.sumBy(_.filter(_.filter(utxos, 'locked'), 'confirmations'), 'satoshis'),
availableAmount: undefined,
availableConfirmedAmount: undefined
};
balance.availableAmount = balance.totalAmount - balance.lockedAmount;
balance.availableConfirmedAmount = balance.totalConfirmedAmount - balance.lockedConfirmedAmount;
return balance;
}
selectTxInputs(server: WalletService, txp, wallet, opts, cb) {
const MAX_TX_SIZE_IN_KB = Defaults.MAX_TX_SIZE_IN_KB_BTC;
// todo: check inputs are ours and have enough value
if (txp.inputs && !_.isEmpty(txp.inputs) && !txp.replaceTxByFee) {
if (!_.isNumber(txp.fee)) txp.fee = this.getEstimatedFee(txp, { conservativeEstimation: true });
return cb(this.checkTx(txp));
}
const feeOpts = {
conservativeEstimation: opts.payProUrl ? true : false,
instantAcceptanceEscrow: opts.instantAcceptanceEscrow
};
const escrowAmount = opts.instantAcceptanceEscrow || 0;
const txpAmount = txp.getTotalAmount() + escrowAmount;
const baseTxpSize = this.getEstimatedSize(txp, feeOpts);
const baseTxpFee = (baseTxpSize * txp.feePerKb) / 1000;
const sizePerInput = this.getEstimatedSizeForSingleInput(txp, feeOpts);
const feePerInput = (sizePerInput * txp.feePerKb) / 1000;
logger.debug(
`Amount ${Utils.formatAmountInBtc(
txpAmount
)} baseSize ${baseTxpSize} baseTxpFee ${baseTxpFee} sizePerInput ${sizePerInput} feePerInput ${feePerInput}`
);
const sanitizeUtxos = utxos => {
const excludeIndex = _.reduce(
opts.utxosToExclude,
(res, val) => {
res[val] = val;
return res;
},
{}
);
return _.filter(utxos, utxo => {
if (utxo.locked) return false;
if (txp.excludeUnconfirmedUtxos && !txp.replaceTxByFee && !utxo.confirmations) return false;
if (excludeIndex[utxo.txid + ':' + utxo.vout]) return false;
return true;
});
};
const select = (utxos, coin, cb) => {
let totalValueInUtxos = _.sumBy(utxos, 'satoshis');
if (totalValueInUtxos < txpAmount) {
logger.debug(
'Total value in all utxos (' +
Utils.formatAmountInBtc(totalValueInUtxos) +
') is insufficient to cover for txp amount (' +
Utils.formatAmountInBtc(txpAmount) +
')'
);
return cb(Errors.INSUFFICIENT_FUNDS);
}
// remove utxos not economically worth to send
utxos = _.filter(utxos, utxo => {
if (utxo.satoshis <= feePerInput) return false;
return true;
});
totalValueInUtxos = _.sumBy(utxos, 'satoshis');
const netValueInUtxos = totalValueInUtxos - (baseTxpFee - utxos.length * feePerInput);
if (netValueInUtxos < txpAmount) {
logger.debug(
'Value after fees in all utxos (' +
Utils.formatAmountInBtc(netValueInUtxos) +
') is insufficient to cover for txp amount (' +
Utils.formatAmountInBtc(txpAmount) +
')'
);
return cb(
new ClientError(
Errors.codes.INSUFFICIENT_FUNDS_FOR_FEE,
`${Errors.INSUFFICIENT_FUNDS_FOR_FEE.message}. RequiredFee: ${baseTxpFee} Coin: ${txp.coin} feePerKb: ${txp.feePerKb} Err2`,
{
coin: txp.coin,
feePerKb: txp.feePerKb,
requiredFee: baseTxpFee
}
)
);
}
const bigInputThreshold = txpAmount * Defaults.UTXO_SELECTION_MAX_SINGLE_UTXO_FACTOR + (baseTxpFee + feePerInput);
logger.debug('Big input threshold ' + Utils.formatAmountInBtc(bigInputThreshold));
const partitions = _.partition(utxos, utxo => {
return utxo.satoshis > bigInputThreshold;
});
const bigInputs = _.sortBy(partitions[0], 'satoshis');
const smallInputs = _.sortBy(partitions[1], utxo => {
return -utxo.satoshis;
});
logger.debug('Considering ' + bigInputs.length + ' big inputs (' + Utils.formatUtxos(bigInputs) + ')');
logger.debug('Considering ' + smallInputs.length + ' small inputs (' + Utils.formatUtxos(smallInputs) + ')');
let total = 0;
let netTotal = -baseTxpFee;
let selected = [];
let fullTxpAmount = txpAmount;
let fee;
let error;
_.each(smallInputs, (input, i) => {
logger.debug('Input #' + i + ': ' + Utils.formatUtxos(input));
const netInputAmount = input.satoshis - feePerInput;
logger.debug('The input contributes ' + Utils.formatAmountInBtc(netInputAmount));
selected.push(input);
total += input.satoshis;
netTotal += netInputAmount;
const txpSize = baseTxpSize + selected.length * sizePerInput;
fee = Math.round(baseTxpFee + selected.length * feePerInput);
// The escrow address must contain the instantAcceptanceEscrow satoshis specified
// by the merchant plus the miner fee on the ZCE-secured payment.
// Rationale: https://github.com/bitjson/bch-zce#zce-extension-to-json-payment-protocol
fullTxpAmount = escrowAmount ? txpAmount + fee : txpAmount;
logger.debug('Tx size: ' + Utils.formatSize(txpSize) + ', Tx fee: ' + Utils.formatAmountInBtc(fee));
const feeVsAmountRatio = fee / fullTxpAmount;
const amountVsUtxoRatio = netInputAmount / fullTxpAmount;
// logger.debug('Fee/Tx amount: ' + Utils.formatRatio(feeVsAmountRatio) + ' (max: ' + Utils.formatRatio(Defaults.UTXO_SELECTION_MAX_FEE_VS_TX_AMOUNT_FACTOR) + ')');
// logger.debug('Tx amount/Input amount:' + Utils.formatRatio(amountVsUtxoRatio) + ' (min: ' + Utils.formatRatio(Defaults.UTXO_SELECTION_MIN_TX_AMOUNT_VS_UTXO_FACTOR) + ')');
if (txpSize / 1000 > MAX_TX_SIZE_IN_KB) {
// logger.debug('Breaking because tx size (' + Utils.formatSize(txpSize) + ') is too big (max: ' + Utils.formatSize(this.MAX_TX_SIZE_IN_KB * 1000.) + ')');
error = Errors.TX_MAX_SIZE_EXCEEDED;
return false;
}
if (!_.isEmpty(bigInputs)) {
if (amountVsUtxoRatio < Defaults.UTXO_SELECTION_MIN_TX_AMOUNT_VS_UTXO_FACTOR) {
// logger.debug('Breaking because utxo is too small compared to tx amount');
return false;
}
if (feeVsAmountRatio > Defaults.UTXO_SELECTION_MAX_FEE_VS_TX_AMOUNT_FACTOR) {
const feeVsSingleInputFeeRatio = fee / (baseTxpFee + feePerInput);
// logger.debug('Fee/Single-input fee: ' + Utils.formatRatio(feeVsSingleInputFeeRatio) + ' (max: ' + Utils.formatRatio(Defaults.UTXO_SELECTION_MAX_FEE_VS_SINGLE_UTXO_FEE_FACTOR) + ')' + ' loses wrt single-input tx: ' + Utils.formatAmountInBtc((selected.length - 1) * feePerInput));
if (feeVsSingleInputFeeRatio > Defaults.UTXO_SELECTION_MAX_FEE_VS_SINGLE_UTXO_FEE_FACTOR) {
// logger.debug('Breaking because fee is too significant compared to tx amount and it is too expensive compared to using single input');
return false;
}
}
}
logger.debug(
'Cumuled total so far: ' +
Utils.formatAmountInBtc(total) +
', Net total so far: ' +
Utils.formatAmountInBtc(netTotal)
);
if (netTotal >= fullTxpAmount) {
const changeAmount = Math.round(total - fullTxpAmount - fee);
logger.debug('Tx change: %o', Utils.formatAmountInBtc(changeAmount));
const dustThreshold = Math.max(Defaults.MIN_OUTPUT_AMOUNT, this.bitcoreLib.Transaction.DUST_AMOUNT);
if (changeAmount > 0 && changeAmount <= dustThreshold) {
logger.debug(
'Change below dust threshold (' +
Utils.formatAmountInBtc(dustThreshold) +
'). Incrementing fee to remove change.'
);
// Remove dust change by incrementing fee
fee += changeAmount;
}
return false;
}
});
if (netTotal < fullTxpAmount) {
logger.debug(
'Could not reach Txp total (' +
Utils.formatAmountInBtc(fullTxpAmount) +
'), still missing: ' +
Utils.formatAmountInBtc(fullTxpAmount - netTotal)
);
selected = [];
if (!_.isEmpty(bigInputs)) {
const input = _.head(bigInputs);
logger.debug('Using big input: %o', Utils.formatUtxos(input));
total = input.satoshis;
fee = Math.round(baseTxpFee + feePerInput);
netTotal = total - fee;
selected = [input];
}
}
if (_.isEmpty(selected)) {
// logger.debug('Could not find enough funds within this utxo subset');
return cb(
error ||
new ClientError(
Errors.codes.INSUFFICIENT_FUNDS_FOR_FEE,
`${Errors.INSUFFICIENT_FUNDS_FOR_FEE.message}. RequiredFee: ${fee} Coin: ${txp.coin} feePerKb: ${txp.feePerKb} Err3`,
{
coin: txp.coin,
feePerKb: txp.feePerKb,
requiredFee: fee
}
)
);
}
return cb(null, selected, fee);
};
// logger.debug('Selecting inputs for a ' + Utils.formatAmountInBtc(txp.getTotalAmount()) + ' txp');
server.getUtxosForCurrentWallet(
{
instantAcceptanceEscrow: txp.instantAcceptanceEscrow,
replaceTxByFee: txp.replaceTxByFee,
inputs: txp.inputs
},
(err, utxos) => {
if (err) return cb(err);
let totalAmount;
let availableAmount;
const balance = this.totalizeUtxos(utxos);
if (txp.excludeUnconfirmedUtxos && !txp.replaceTxByFee) {
totalAmount = balance.totalConfirmedAmount;
availableAmount = balance.availableConfirmedAmount;
} else {
totalAmount = balance.totalAmount;
availableAmount = balance.availableAmount;
}
if (totalAmount < txp.getTotalAmount()) return cb(Errors.INSUFFICIENT_FUNDS);
if (availableAmount < txp.getTotalAmount()) return cb(Errors.LOCKED_FUNDS);
utxos = sanitizeUtxos(utxos);
// logger.debug('Considering ' + utxos.length + ' utxos (' + Utils.formatUtxos(utxos) + ')');
const groups = [6, 1];
if (!txp.excludeUnconfirmedUtxos) groups.push(0);
let inputs = [];
let fee;
let selectionError;
let i = 0;
let lastGroupLength;
async.whilst(
() => {
return i < groups.length && _.isEmpty(inputs);
},
next => {
const group = groups[i++];
let candidateUtxos = _.filter(utxos, utxo => {
return utxo.confirmations >= group;
});
if (opts.instantAcceptanceEscrow && wallet.isZceCompatible()) {
const utxosSortedByDescendingAmount = candidateUtxos.sort((a, b) => b.amount - a.amount);
const utxosWithUniqueAddresses = _.uniqBy(utxosSortedByDescendingAmount, 'address');
candidateUtxos = utxosWithUniqueAddresses;
}
if (txp.replaceTxByFee) {
// make sure we are using at least one input from the transaction that we are replacing
const txIdArray: any[] = _.map(opts.inputs, 'txid');
candidateUtxos = candidateUtxos.sort((a, b) => {
return txIdArray.indexOf(b.txid) - txIdArray.indexOf(a.txid);
});
}
// logger.debug('Group >= ' + group);
// If this group does not have any new elements, skip it
if (lastGroupLength === candidateUtxos.length) {
// logger.debug('This group is identical to the one already explored');
return next();
}
// logger.debug('Candidate utxos: ' + Utils.formatUtxos(candidateUtxos));
lastGroupLength = candidateUtxos.length;
select(candidateUtxos, txp.coin, (err, selectedInputs, selectedFee) => {
if (err) {
// logger.debug('No inputs selected on this group: ', err);
selectionError = err;
return next();
}
selectionError = null;
inputs = selectedInputs;
fee = selectedFee;
logger.debug('Selected inputs from this group: ' + Utils.formatUtxos(inputs));
logger.debug('Fee for this selection: ' + Utils.formatAmountInBtc(fee));
return next();
});
},
err => {
if (err) return cb(err);
if (selectionError || _.isEmpty(inputs))
return cb(selectionError || new Error('Could not select tx inputs'));
txp.setInputs(_.shuffle(inputs));
txp.fee = fee;
err = this.checkTx(txp);
if (!err) {
const change = _.sumBy(txp.inputs, 'satoshis') - _.sumBy(txp.outputs, 'amount') - txp.fee;
logger.debug(
'Successfully built transaction. Total fees: ' +
Utils.formatAmountInBtc(txp.fee) +
', total change: ' +
Utils.formatAmountInBtc(change)
);
} else {
logger.warn('Error building transaction: %o', err);
}
return cb(err);
}
);
}
);
}
checkUtxos(opts) {
if (_.isNumber(opts.fee) && _.isEmpty(opts.inputs)) return true;
}
checkValidTxAmount(output): boolean {
if (!_.isNumber(output.amount) || _.isNaN(output.amount) || output.amount <= 0) {
return false;
}
return true;
}
supportsMultisig() {
return true;
}
notifyConfirmations(network: string) {
if (network != 'livenet') return false;
return true;
}
isUTXOChain() {
return true;
}
isSingleAddress() {
return false;
}
addressFromStorageTransform(network, address) {}
addressToStorageTransform(network, address) {}
addSignaturesToBitcoreTx(tx, inputs, inputPaths, signatures, xpub, signingMethod) {
signingMethod = signingMethod || 'ecdsa';
if (signatures.length != inputs.length) throw new Error('Number of signatures does not match number of inputs');
let i = 0;
const x = new this.bitcoreLib.HDPublicKey(xpub);
for (const signatureHex of signatures) {
try {
const signature = this.bitcoreLib.crypto.Signature.fromString(signatureHex);
const pub = x.deriveChild(inputPaths[i]).publicKey;
// tslint:disable-next-line:no-bitwise
const SIGHASH_TYPE = this.bitcoreLib.crypto.Signature.SIGHASH_ALL | this.bitcoreLib.crypto.Signature.SIGHASH_FORKID;
const s = {
inputIndex: i,
signature,
sigtype: SIGHASH_TYPE,
publicKey: pub
};
tx.inputs[i].addSignature(tx, s, signingMethod);
i++;
} catch (e) {}
}
if (i != tx.inputs.length) throw new Error('Wrong signatures');
}
validateAddress(wallet, inaddr, opts) {
const A = this.bitcoreLib.Address;
let addr: {
network?: string;
toString?: (cashAddr: boolean) => string;
} = {};
try {
addr = new A(inaddr);
} catch (ex) {
throw Errors.INVALID_ADDRESS;
}
if (!this._isCorrectNetwork(wallet, addr)) {
throw Errors.INCORRECT_ADDRESS_NETWORK;
}
return;
}
protected _isCorrectNetwork(wallet, addr) {
const addrNetwork = Utils.getNetworkName(wallet.chain, addr.network.toString())
const walNetwork = wallet.network;
if (Utils.getNetworkType(addrNetwork) === 'testnet' && walNetwork === 'regtest') {
return !!config.allowRegtest;
}
return addrNetwork === walNetwork;
}
// Push notification handling
onCoin(coin) {
// script output, or similar.
if (!coin || !coin.address) return;
return {
out: {
address: coin.address,
amount: coin.value
},
txid: coin.mintTxid
};
}
// Push notification handling
onTx(tx) {
return null;
}
getReserve(server: WalletService, wallet: IWallet, cb: (err?, reserve?: number) => void) {
return cb(null, 0);
}
refreshTxData(_server: WalletService, txp, _opts, cb) {
return cb(null, txp);
}
}