@owstack/wallet-service
Version:
A service for multisignature HD wallets
427 lines (359 loc) • 12.2 kB
JavaScript
const owsCommon = require('@owstack/ows-common');
const keyLib = require('@owstack/key-lib');
const Constants = owsCommon.Constants;
const HDPublicKey = keyLib.HDPublicKey;
const log = require('npmlog');
const Signature = keyLib.crypto.Signature;
const TxProposalAction = require('./txproposalaction');
const Uuid = require('uuid');
const lodash = owsCommon.deps.lodash;
const $ = require('preconditions').singleton();
log.debug = log.verbose;
log.disableColor();
const FIELDS = [
'version',
'createdOn',
'id',
'walletId',
'creatorId',
'creatorName',
'networkName',
'outputs',
'amount',
'message',
'payProUrl',
'changeAddress',
'inputs',
'walletM',
'walletN',
'requiredSignatures',
'requiredRejections',
'status',
'txid',
'broadcastedOn',
'inputPaths',
'actions',
'outputOrder',
'fee',
'feeLevel',
'feePerKb',
'excludeUnconfirmedUtxos',
'addressType',
'customData',
'proposalSignature',
'proposalSignaturePubKey',
'proposalSignaturePubKeySig'
];
class TxProposal {
constructor(context, opts) {
// Context defines the coin network and is set by the implementing service in
// order to instance this base service; e.g., btc-service.
context.inject(this);
// Set some frequently used contant values based on context.
this.LIVENET = this.ctx.Networks.livenet;
this.TESTNET = this.ctx.Networks.testnet;
this.atomicsAccessor = this.ctx.Unit().atomicsAccessor();
opts = opts || {};
if (opts.fromObj) {
return;
}
this.version = 3;
const now = Date.now();
this.createdOn = Math.floor(now / 1000);
this.id = opts.id || Uuid.v4();
this.walletId = opts.walletId;
this.creatorId = opts.creatorId;
this.message = opts.message;
this.payProUrl = opts.payProUrl;
this.changeAddress = opts.changeAddress;
this.outputs = lodash.map(opts.outputs, function (output) {
return lodash.pick(output, ['amount', 'toAddress', 'message', 'script']);
});
this.outputOrder = lodash.range(this.outputs.length + 1);
if (!opts.noShuffleOutputs) {
this.outputOrder = lodash.shuffle(this.outputOrder);
}
this.walletM = opts.walletM;
this.walletN = opts.walletN;
this.requiredSignatures = this.walletM;
this.requiredRejections = Math.min(this.walletM, this.walletN - this.walletM + 1),
this.status = 'temporary';
this.actions = [];
this.feeLevel = opts.feeLevel;
this.feePerKb = opts.feePerKb;
this.excludeUnconfirmedUtxos = opts.excludeUnconfirmedUtxos;
this.addressType = opts.addressType || (this.walletN > 1 ? Constants.SCRIPT_TYPES.P2SH : Constants.SCRIPT_TYPES.P2PKH);
$.checkState(lodash.includes(lodash.values(Constants.SCRIPT_TYPES), this.addressType));
this.customData = opts.customData;
this.amount = this.getTotalAmount();
let address;
try {
address = this.ctx.Address(this.outputs[0].toAddress).toObject();
} catch (ex) {
// nothing
}
try {
this.networkName = opts.networkName || address.network;
} catch (ex) {
// nothing
}
$.checkState(lodash.includes(lodash.values([this.LIVENET.name, this.TESTNET.name]), this.networkName));
this.setInputs(opts.inputs);
this.fee = opts.fee;
}
}
TxProposal.fromObj = function (context, obj) {
// Context defines the coin network and is set by the implementing service in
// order to instance this base service; e.g., btc-service.
const x = new TxProposal(context, {fromObj: true});
lodash.each(FIELDS, function (k) {
x[k] = obj[k];
});
x.actions = lodash.map(obj.actions, function (action) {
return TxProposalAction.fromObj(action);
});
return x;
};
TxProposal.prototype.toObject = function () {
const self = this;
const x = {};
lodash.each(FIELDS, function (k) {
x[k] = self[k];
});
x.changeAddress = (x.changeAddress && x.changeAddress.toObject ? x.changeAddress.toObject() : x.changeAddress);
x.isPending = this.isPending();
return x;
};
TxProposal.prototype.setInputs = function (inputs) {
this.inputs = inputs || [];
this.inputPaths = lodash.map(inputs, 'path') || [];
};
TxProposal.prototype._updateStatus = function () {
if (this.status != 'pending') {
return;
}
if (this.isRejected()) {
this.status = 'rejected';
} else if (this.isAccepted()) {
this.status = 'accepted';
}
};
TxProposal.prototype._buildTx = function () {
const self = this;
const t = new self.ctx.Transaction();
$.checkState(lodash.includes(lodash.values(Constants.SCRIPT_TYPES), self.addressType));
switch (self.addressType) {
case Constants.SCRIPT_TYPES.P2SH:
lodash.each(self.inputs, function (i) {
$.checkState(i.publicKeys, 'Inputs should include public keys');
t.from(i, i.publicKeys, self.requiredSignatures);
});
break;
case Constants.SCRIPT_TYPES.P2PKH:
t.from(self.inputs);
break;
}
lodash.each(self.outputs, function (o) {
$.checkState(o.script || o.toAddress, 'Output should have either toAddress or script specified');
if (o.script) {
const arg = {};
arg.script = o.script;
arg[self.atomicsAccessor] = o.amount;
t.addOutput(new self.ctx.Transaction.Output(arg));
} else {
t.to(o.toAddress, o.amount);
}
});
t.fee(self.fee);
if (self.changeAddress) {
t.change(self.changeAddress.address);
}
// Shuffle outputs for improved privacy
if (t.outputs.length > 1) {
const outputOrder = lodash.reject(self.outputOrder, function (order) {
return order >= t.outputs.length;
});
$.checkState(t.outputs.length == outputOrder.length);
t.sortOutputs(function (outputs) {
return lodash.map(outputOrder, function (i) {
return outputs[i];
});
});
}
// Validate actual inputs vs outputs independently
const totalInputs = lodash.sumBy(t.inputs, `output.${self.atomicsAccessor}`);
const totalOutputs = lodash.sumBy(t.outputs, self.atomicsAccessor);
$.checkState(totalInputs > 0 && totalOutputs > 0 && totalInputs >= totalOutputs);
$.checkState(totalInputs - totalOutputs <= this.ctx.Defaults.MAX_TX_FEE);
return t;
};
TxProposal.prototype._getCurrentSignatures = function () {
const acceptedActions = lodash.filter(this.actions, {
type: 'accept'
});
return lodash.map(acceptedActions, function (x) {
return {
signatures: x.signatures,
xpub: x.xpub,
};
});
};
TxProposal.prototype.getTx = function () {
const self = this;
const t = this._buildTx();
const sigs = this._getCurrentSignatures();
lodash.each(sigs, function (x) {
self._addSignaturesToTx(t, x.signatures, x.xpub);
});
return t;
};
TxProposal.prototype.getRawTx = function () {
const t = this.getTx();
return t.uncheckedSerialize();
};
TxProposal.prototype.getEstimatedSizeForSingleInput = function () {
switch (this.addressType) {
case Constants.SCRIPT_TYPES.P2PKH:
return 147;
default:
case Constants.SCRIPT_TYPES.P2SH:
return this.requiredSignatures * 72 + this.walletN * 36 + 44;
}
};
TxProposal.prototype.getEstimatedSize = function () {
// Note: found empirically based on all multisig P2SH inputs and within m & n allowed limits.
const safetyMargin = 0.02;
const overhead = 4 + 4 + 9 + 9;
const inputSize = this.getEstimatedSizeForSingleInput();
const outputSize = 34;
const nbInputs = this.inputs.length;
const nbOutputs = (lodash.isArray(this.outputs) ? Math.max(1, this.outputs.length) : 1) + 1;
const size = overhead + inputSize * nbInputs + outputSize * nbOutputs;
return parseInt((size * (1 + safetyMargin)).toFixed(0));
};
TxProposal.prototype.getEstimatedFee = function () {
$.checkState(lodash.isNumber(this.feePerKb));
const fee = this.feePerKb * this.getEstimatedSize() / 1000;
return parseInt(fee.toFixed(0));
};
TxProposal.prototype.estimateFee = function () {
this.fee = this.getEstimatedFee();
};
/**
* getTotalAmount
*
* @return {Number} total amount of all outputs excluding change output
*/
TxProposal.prototype.getTotalAmount = function () {
return lodash.sumBy(this.outputs, 'amount');
};
/**
* getActors
*
* @return {String[]} copayerIds that performed actions in this proposal (accept / reject)
*/
TxProposal.prototype.getActors = function () {
return lodash.map(this.actions, 'copayerId');
};
/**
* getApprovers
*
* @return {String[]} copayerIds that approved the tx proposal (accept)
*/
TxProposal.prototype.getApprovers = function () {
return lodash.map(
lodash.filter(this.actions, {
type: 'accept'
}), 'copayerId');
};
/**
* getActionBy
*
* @param {String} copayerId
* @return {Object} type / createdOn
*/
TxProposal.prototype.getActionBy = function (copayerId) {
return lodash.find(this.actions, {
copayerId: copayerId
});
};
TxProposal.prototype.addAction = function (copayerId, type, comment, signatures, xpub) {
const action = TxProposalAction.create({
copayerId: copayerId,
type: type,
signatures: signatures,
xpub: xpub,
comment: comment,
});
this.actions.push(action);
this._updateStatus();
};
TxProposal.prototype._addSignaturesToTx = function (tx, signatures, xpub) {
const self = this;
if (signatures.length != this.inputs.length) {
throw new Error('Number of signatures does not match number of inputs');
}
let i = 0;
const x = new HDPublicKey(xpub);
lodash.each(signatures, function (signatureHex) {
try {
const signature = Signature.fromString(signatureHex);
const pub = x.deriveChild(self.inputPaths[i]).publicKey;
const s = {
inputIndex: i,
signature: signature,
sigtype: Signature.SIGHASH_ALL,
publicKey: pub,
};
tx.inputs[i].addSignature(tx, s);
i++;
} catch (e) {
// console.error(e);
}
});
if (i != tx.inputs.length) {
throw new Error('Wrong signatures');
}
};
TxProposal.prototype.sign = function (copayerId, signatures, xpub) {
try {
// Tests signatures are OK
const tx = this.getTx();
this._addSignaturesToTx(tx, signatures, xpub);
this.addAction(copayerId, 'accept', null, signatures, xpub);
if (this.status == 'accepted') {
this.raw = tx.uncheckedSerialize();
this.txid = tx.id;
}
return true;
} catch (e) {
log.debug(e);
return false;
}
};
TxProposal.prototype.reject = function (copayerId, reason) {
this.addAction(copayerId, 'reject', reason);
};
TxProposal.prototype.isTemporary = function () {
return this.status == 'temporary';
};
TxProposal.prototype.isPending = function () {
return !lodash.includes(['temporary', 'broadcasted', 'rejected'], this.status);
};
TxProposal.prototype.isAccepted = function () {
const votes = lodash.countBy(this.actions, 'type');
return votes['accept'] >= this.requiredSignatures;
};
TxProposal.prototype.isRejected = function () {
const votes = lodash.countBy(this.actions, 'type');
return votes['reject'] >= this.requiredRejections;
};
TxProposal.prototype.isBroadcasted = function () {
return this.status == 'broadcasted';
};
TxProposal.prototype.setBroadcasted = function () {
$.checkState(this.txid);
this.status = 'broadcasted';
this.broadcastedOn = Math.floor(Date.now() / 1000);
};
module.exports = TxProposal;