@ducatus/ducatus-wallet-service-rev
Version:
A service for Mutisig HD Bitcoin Wallets
399 lines (330 loc) • 11 kB
text/typescript
import { BitcoreLib } from '@ducatus/ducatus-crypto-wallet-core-rev';
import _ from 'lodash';
import { IChain } from '..';
import { ClientError } from '../../errors/clienterror';
import { TxProposal } from '../../model';
const $ = require('preconditions').singleton();
const Common = require('../../common');
const Constants = Common.Constants;
const Utils = Common.Utils;
const Defaults = Common.Defaults;
const Errors = require('../../errors/errordefinitions');
export class BtcChain implements IChain {
constructor(private bitcoreLib = BitcoreLib) {}
getWalletBalance(server, wallet, opts, cb) {
server._getUtxosForCurrentWallet(
{
coin: opts.coin,
addresses: opts.addresses
},
(err, utxos) => {
if (err) return cb(err);
const balance = {
...server._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);
}
);
}
getWalletSendMaxInfo(server, wallet, opts, cb) {
server._getUtxosForCurrentWallet({}, (err, utxos) => {
if (err) return cb(err);
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,
network: wallet.network,
walletM: wallet.m,
walletN: wallet.n,
feePerKb
});
const baseTxpSize = txp.getEstimatedSize();
const sizePerInput = txp.getEstimatedSizeForSingleInput();
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 > Defaults.MAX_TX_SIZE_IN_KB[wallet.coin]) {
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 = txp.getEstimatedFee();
const amount = _.sumBy(txp.inputs, 'satoshis') - fee;
if (amount < Defaults.MIN_OUTPUT_AMOUNT) return cb(null, info);
info.size = txp.getEstimatedSize();
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, 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 {
return cb(null, wallet.createAddress(true), 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;
}
}
getFee(server, wallet, opts) {
return new Promise(resolve => {
server._getFeePerKb(wallet, opts, (err, feePerKb) => {
return resolve({ feePerKb });
});
});
}
buildTx(txp) {
const t = new this.bitcoreLib.Transaction();
// BTC tx version
if (txp.version <= 3) {
t.setVersion(1);
} else {
t.setVersion(2);
}
switch (txp.addressType) {
case Constants.SCRIPT_TYPES.P2WSH:
case Constants.SCRIPT_TYPES.P2SH:
_.each(txp.inputs, i => {
$.checkState(i.publicKeys, 'Inputs should include public keys');
t.from(i, i.publicKeys, txp.requiredSignatures);
});
break;
case Constants.SCRIPT_TYPES.P2WPKH:
case Constants.SCRIPT_TYPES.P2PKH:
t.from(txp.inputs);
break;
}
_.each(txp.outputs, o => {
$.checkState(o.script || o.toAddress, 'Output should have either toAddress or script specified');
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.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);
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, 'not-enought-inputs');
$.checkState(totalInputs - totalOutputs <= Defaults.MAX_TX_FEE[txp.coin], 'fee-too-high');
return t;
}
convertFeePerKb(p, feePerKb) {
return [p, Utils.strip(feePerKb * 1e8)];
}
checkTx(server, txp) {
let bitcoreError;
const serializationOpts = {
disableIsFullySigned: true,
disableSmallFees: true,
disableLargeFees: true
};
if (_.isEmpty(txp.inputPaths)) return Errors.NO_INPUT_PATHS;
try {
const bitcoreTx = txp.getBitcoreTx();
bitcoreError = bitcoreTx.getSerializationError(serializationOpts);
if (!bitcoreError) {
txp.fee = bitcoreTx.getFee();
}
} catch (ex) {
server.logw('Error building Bitcore transaction', ex);
return ex;
}
if (bitcoreError instanceof this.bitcoreLib.errors.Transaction.FeeError) return Errors.INSUFFICIENT_FUNDS_FOR_FEE;
if (bitcoreError instanceof this.bitcoreLib.errors.Transaction.DustOutputs) return Errors.DUST_AMOUNT;
return bitcoreError;
}
checkTxUTXOs(server, txp, opts, cb) {
server.logd('Rechecking UTXOs availability for publishTx');
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();
}
);
}
selectTxInputs(server, txp, wallet, opts, cb, next) {
return server._selectTxInputs(txp, opts.utxosToExclude, next);
}
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;
}
setInputs(info) {
return info.inputs;
}
supportsMultisig() {
return true;
}
notifyConfirmations(network: string) {
if (network != 'livenet') return false;
return true;
}
isUTXOCoin() {
return true;
}
isSingleAddress() {
return false;
}
addressFromStorageTransform(network, address) {}
addressToStorageTransform(network, address) {}
addSignaturesToBitcoreTx(tx, inputs, inputPaths, signatures, xpub) {
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);
_.each(signatures, signatureHex => {
try {
const signature = this.bitcoreLib.crypto.Signature.fromString(signatureHex);
const pub = x.deriveChild(inputPaths[i]).publicKey;
const s = {
inputIndex: i,
signature,
sigtype:
// tslint:disable-next-line:no-bitwise
this.bitcoreLib.crypto.Signature.SIGHASH_ALL | this.bitcoreLib.crypto.Signature.SIGHASH_FORKID,
publicKey: pub
};
tx.inputs[i].addSignature(tx, s);
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 (addr.network.toString() != wallet.network) {
throw Errors.INCORRECT_ADDRESS_NETWORK;
}
return;
}
}