@ducatus/ducatus-wallet-service-rev
Version:
A service for Mutisig HD Bitcoin Wallets
502 lines (437 loc) • 12.7 kB
text/typescript
import { Transactions } from '@ducatus/ducatus-crypto-wallet-core-rev';
import _ from 'lodash';
import { ChainService } from '../chain';
import { TxProposalLegacy } from './txproposal_legacy';
import { TxProposalAction } from './txproposalaction';
const $ = require('preconditions').singleton();
const Uuid = require('uuid');
const log = require('npmlog');
log.debug = log.verbose;
log.disableColor();
const Common = require('../common');
const Constants = Common.Constants,
Defaults = Common.Defaults,
Utils = Common.Utils;
enum SwapStatusesEnum {
WaitingForValidation = 'WAITING_FOR_VALIDATION',
InsufficientAmount = 'INSUFFICIENT_AMOUNT',
WaitingFor_Relay = 'WAITING_FOR_RELAY',
InsufficientTokenBalance = 'INSUFFICIENT_TOKEN_BALANCE',
InsufficientBalance = 'INSUFFICIENT_BALANCE',
Pending = 'PENDING',
Success = 'SUCCESS',
Revert = 'REVERT',
Fail = 'FAIL',
WaitingForReturn = 'WAITING_FOR_RETURN',
PendingReturn = 'PENDING_RETURN',
Return = 'RETURN'
}
export interface IStatusHistory {
status: SwapStatusesEnum;
date: string;
}
export interface ISwapTx {
txid: string;
status: SwapStatusesEnum;
convertedFrom: 'DUC' | 'DUCX' | 'ETH' | 'BTC';
convertedFromAmount: string;
convertedTo: 'DUC' | 'DUCX' | 'WDUCX';
convertedToAmount: string;
sentFrom: string;
sentTo: string;
statusHistory: IStatusHistory[];
}
export interface ITxProposal {
type: string;
creatorName: string;
createdOn: number;
txid: string;
id: string;
walletId: string;
creatorId: string;
coin: string;
network: string;
message: string;
payProUrl: string;
from: string;
changeAddress: string;
inputs: any[];
outputs: Array<{
amount: number;
address: string;
toAddress?: string;
message?: string;
data?: string;
gasLimit?: number;
script?: string;
}>;
outputOrder: number;
walletM: number;
walletN: number;
requiredSignatures: number;
requiredRejections: number;
status: string;
actions: [];
feeLevel: number;
feePerKb: number;
excludeUnconfirmedUtxos: boolean;
addressType: string;
customData: any;
amount: string;
fee: number;
version: number;
broadcastedOn: number;
inputPaths: string;
proposalSignature: string;
proposalSignaturePubKey: string;
proposalSignaturePubKeySig: string;
lowFees: boolean;
nonce?: number;
gasPrice?: number;
gasLimit?: number; // Backward compatibility for BWC <= 8.9.0
data?: string; // Backward compatibility for BWC <= 8.9.0
tokenAddress?: string;
destinationTag?: string;
invoiceID?: string;
wDucxAddress?: string;
swap?: ISwapTx;
}
export class TxProposal {
type: string;
creatorName: string;
createdOn: number;
id: string;
txid: string;
walletId: string;
creatorId: string;
coin: string;
network: string;
message: string;
payProUrl: string;
from: string;
changeAddress: any;
inputs: any[];
outputs: Array<{
amount: number;
address?: string;
toAddress?: string;
message?: string;
data?: string;
gasLimit?: number;
script?: string;
satoshis?: number;
}>;
outputOrder: number[];
walletM: number;
walletN: number;
requiredSignatures: number;
requiredRejections: number;
status: string;
actions: any[] = [];
feeLevel: number;
feePerKb: number;
excludeUnconfirmedUtxos: boolean;
addressType: string;
customData: any;
amount: string | number;
fee: number;
version: number;
broadcastedOn: number;
inputPaths: string | any[];
proposalSignature: string;
proposalSignaturePubKey: string;
proposalSignaturePubKeySig: string;
raw?: Array<string> | string;
nonce?: number;
gasPrice?: number;
gasLimit?: number; // Backward compatibility for BWC <= 8.9.0
data?: string; // Backward compatibility for BWC <= 8.9.0
tokenAddress?: string;
tokenId?: string;
destinationTag?: string;
invoiceID?: string;
wDucxAddress?: string;
swap?: ISwapTx;
static create(opts) {
opts = opts || {};
$.checkArgument(Utils.checkValueInCollection(opts.coin, Constants.COINS));
$.checkArgument(Utils.checkValueInCollection(opts.network, Constants.NETWORKS));
const x = new TxProposal();
// allow creating legacy tx version == 3 only
if (opts.version) {
$.checkArgument(opts.version === 3);
}
x.version = opts.version || 4;
const now = Date.now();
x.createdOn = Math.floor(now / 1000);
x.id = opts.id || Uuid.v4();
x.walletId = opts.walletId;
x.creatorId = opts.creatorId;
x.coin = opts.coin;
x.network = opts.network;
x.message = opts.message;
x.payProUrl = opts.payProUrl;
x.changeAddress = opts.changeAddress;
x.outputs = _.map(opts.outputs, output => {
return _.pick(output, ['amount', 'toAddress', 'message', 'data', 'gasLimit', 'script']);
});
x.outputOrder = _.range(x.outputs.length + 1);
if (!opts.noShuffleOutputs) {
x.outputOrder = _.shuffle(x.outputOrder);
}
x.walletM = opts.walletM;
x.walletN = opts.walletN;
x.requiredSignatures = x.walletM;
(x.requiredRejections = Math.min(x.walletM, x.walletN - x.walletM + 1)), (x.status = 'temporary');
x.actions = [];
x.feeLevel = opts.feeLevel;
x.feePerKb = opts.feePerKb;
x.excludeUnconfirmedUtxos = opts.excludeUnconfirmedUtxos;
x.addressType = opts.addressType || (x.walletN > 1 ? Constants.SCRIPT_TYPES.P2SH : Constants.SCRIPT_TYPES.P2PKH);
$.checkState(Utils.checkValueInCollection(x.addressType, Constants.SCRIPT_TYPES));
x.customData = opts.customData;
x.amount = opts.amount ? opts.amount : x.getTotalAmount();
x.setInputs(opts.inputs);
x.fee = opts.fee;
// ETH
x.gasPrice = opts.gasPrice;
x.from = opts.from;
x.nonce = opts.nonce;
x.gasLimit = opts.gasLimit; // Backward compatibility for BWC <= 8.9.0
x.data = opts.data; // Backward compatibility for BWC <= 8.9.0
x.tokenAddress = opts.tokenAddress;
x.tokenId = opts.tokenId;
// XRP
x.destinationTag = opts.destinationTag;
x.invoiceID = opts.invoiceID;
// DUCX TO wDUCX
x.wDucxAddress = opts.wDucxAddress;
// SWAP
x.swap = opts.swap;
return x;
}
static fromObj(obj) {
if (!(obj.version >= 3)) {
return TxProposalLegacy.fromObj(obj);
}
const x = new TxProposal();
x.version = obj.version;
x.createdOn = obj.createdOn;
x.id = obj.id;
x.walletId = obj.walletId;
x.creatorId = obj.creatorId;
x.coin = obj.coin || Defaults.COIN;
x.network = obj.network;
x.outputs = obj.outputs;
x.amount = obj.amount;
x.message = obj.message;
x.payProUrl = obj.payProUrl;
x.changeAddress = obj.changeAddress;
x.inputs = obj.inputs;
x.walletM = obj.walletM;
x.walletN = obj.walletN;
x.requiredSignatures = obj.requiredSignatures;
x.requiredRejections = obj.requiredRejections;
x.status = obj.status;
x.txid = obj.txid;
x.broadcastedOn = obj.broadcastedOn;
x.inputPaths = obj.inputPaths;
x.actions = _.map(obj.actions, action => {
return TxProposalAction.fromObj(action);
});
x.outputOrder = obj.outputOrder;
x.fee = obj.fee;
x.feeLevel = obj.feeLevel;
x.feePerKb = obj.feePerKb;
x.excludeUnconfirmedUtxos = obj.excludeUnconfirmedUtxos;
x.addressType = obj.addressType;
x.customData = obj.customData;
x.proposalSignature = obj.proposalSignature;
x.proposalSignaturePubKey = obj.proposalSignaturePubKey;
x.proposalSignaturePubKeySig = obj.proposalSignaturePubKeySig;
// ETH
x.gasPrice = obj.gasPrice;
x.from = obj.from;
x.nonce = obj.nonce;
x.gasLimit = obj.gasLimit; // Backward compatibility for BWC <= 8.9.0
x.data = obj.data; // Backward compatibility for BWC <= 8.9.0
x.tokenAddress = obj.tokenAddress;
x.tokenId = obj.tokenId;
// XRP
x.destinationTag = obj.destinationTag;
x.invoiceID = obj.invoiceID;
// DUCX TO wDUCX
x.wDucxAddress = obj.wDucxAddress;
// SWAP
x.swap = obj.swap;
if (x.status == 'broadcasted') {
x.raw = obj.raw;
}
return x;
}
toObject() {
const x: any = _.cloneDeep(this);
x.isPending = this.isPending();
return x;
}
setInputs(inputs) {
this.inputs = inputs || [];
this.inputPaths = _.map(inputs, 'path') || [];
}
_updateStatus() {
if (this.status != 'pending') return;
if (this.isRejected()) {
this.status = 'rejected';
} else if (this.isAccepted()) {
this.status = 'accepted';
}
}
/* this will build the Bitcoin-lib tx OR an adaptor for CWC transactions */
_buildTx() {
$.checkState(Utils.checkValueInCollection(this.addressType, Constants.SCRIPT_TYPES));
return ChainService.buildTx(this);
}
_getCurrentSignatures() {
const acceptedActions = _.filter(this.actions, a => {
return a.type == 'accept';
});
return _.map(acceptedActions, x => {
return {
signatures: x.signatures,
xpub: x.xpub
};
});
}
getBitcoreTx() {
const t = this._buildTx();
const sigs = this._getCurrentSignatures();
_.each(sigs, x => {
ChainService.addSignaturesToBitcoreTx(this.coin, t, this.inputs, this.inputPaths, x.signatures, x.xpub);
});
return t;
}
getRawTx() {
const t = this.getBitcoreTx();
return t.uncheckedSerialize();
}
getEstimatedSizeForSingleInput() {
switch (this.addressType) {
case Constants.SCRIPT_TYPES.P2PKH:
return 147;
default:
case Constants.SCRIPT_TYPES.P2SH:
return this.requiredSignatures * 72 + this.walletN * 36 + 44;
}
}
getEstimatedSize() {
// 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 = (_.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));
}
getEstimatedFee() {
$.checkState(_.isNumber(this.feePerKb));
const fee = (this.feePerKb * this.getEstimatedSize()) / 1000;
return parseInt(fee.toFixed(0));
}
estimateFee() {
this.fee = this.getEstimatedFee();
}
/**
* getTotalAmount
*
* @return {Number} total amount of all outputs excluding change output
*/
getTotalAmount() {
return _.sumBy(this.outputs, 'amount');
}
/**
* getActors
*
* @return {String[]} copayerIds that performed actions in this proposal (accept / reject)
*/
getActors() {
return _.map(this.actions, 'copayerId');
}
/**
* getApprovers
*
* @return {String[]} copayerIds that approved the tx proposal (accept)
*/
getApprovers() {
return _.map(
_.filter(this.actions, a => {
return a.type == 'accept';
}),
'copayerId'
);
}
/**
* getActionBy
*
* @param {String} copayerId
* @return {Object} type / createdOn
*/
getActionBy(copayerId) {
return _.find(this.actions, {
copayerId
});
}
addAction(copayerId, type, comment, signatures?, xpub?) {
const action = TxProposalAction.create({
copayerId,
type,
signatures,
xpub,
comment
});
this.actions.push(action);
this._updateStatus();
}
sign(copayerId, signatures, xpub) {
try {
// Tests signatures are OK
const tx = this.getBitcoreTx();
ChainService.addSignaturesToBitcoreTx(this.coin, tx, this.inputs, this.inputPaths, 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;
}
}
reject(copayerId, reason) {
this.addAction(copayerId, 'reject', reason);
}
isTemporary() {
return this.status == 'temporary';
}
isPending() {
return !_.includes(['temporary', 'broadcasted', 'rejected'], this.status);
}
isAccepted() {
const votes = _.countBy(this.actions, 'type');
return votes['accept'] >= this.requiredSignatures;
}
isRejected() {
const votes = _.countBy(this.actions, 'type');
return votes['reject'] >= this.requiredRejections;
}
isBroadcasted() {
return this.status == 'broadcasted';
}
setBroadcasted() {
$.checkState(this.txid);
this.status = 'broadcasted';
this.broadcastedOn = Math.floor(Date.now() / 1000);
}
}