Merge pull request #470 from isocolsky/feat/send-max

Feat/send max
This commit is contained in:
Matias Alejo Garcia 2016-03-18 10:47:51 -03:00
commit 93a8d65932
5 changed files with 459 additions and 46 deletions

View File

@ -350,6 +350,19 @@ ExpressApp.prototype.start = function(opts, cb) {
});
});
router.get('/v1/sendmaxinfo/', function(req, res) {
getServerWithAuth(req, res, function(server) {
var opts = {};
opts.feePerKb = +req.query.feePerKb;
if (req.query.excludeUnconfirmedUtxos == '1') opts.excludeUnconfirmedUtxos = true;
if (req.query.returnInputs == '1') opts.returnInputs = true;
server.getSendMaxInfo(opts, function(err, info) {
if (err) return returnError(err, res, req);
res.json(info);
});
});
});
router.get('/v1/utxos/', function(req, res) {
var opts = {};
var addresses = req.query.addresses;

View File

@ -164,7 +164,7 @@ TxProposal.prototype._buildTx = function() {
var totalInputs = _.sum(self.inputs, 'satoshis');
var totalOutputs = _.sum(self.outputs, 'satoshis');
if (totalInputs - totalOutputs - self.fee > 0) {
if (totalInputs - totalOutputs - self.fee > 0 && self.changeAddress) {
t.change(self.changeAddress.address);
}
@ -252,10 +252,14 @@ TxProposal.prototype.getEstimatedSize = function() {
return parseInt((size * (1 + safetyMargin)).toFixed(0));
};
TxProposal.prototype.estimateFee = function() {
TxProposal.prototype.getEstimatedFee = function() {
$.checkState(_.isNumber(this.feePerKb));
var fee = this.feePerKb * this.getEstimatedSize() / 1000;
this.fee = parseInt(fee.toFixed(0));
return parseInt(fee.toFixed(0));
};
TxProposal.prototype.estimateFee = function() {
this.fee = this.getEstimatedFee();
};
/**

View File

@ -606,6 +606,7 @@ WalletService._getCopayerHash = function(name, xPubKey, requestPubKey) {
* @param {string} opts.requestPubKey - Public Key used to check requests from this copayer.
* @param {string} opts.copayerSignature - S(name|xPubKey|requestPubKey). Used by other copayers to verify that the copayer joining knows the wallet secret.
* @param {string} opts.customData - (optional) Custom data for this copayer.
* @param {string} opts.dryRun[=false] - (optional) Simulate the action but do not change server state.
* @param {string} [opts.supportBIP44AndP2PKH = true] - Client supports BIP44 & P2PKH for joining wallets.
*/
WalletService.prototype.joinWallet = function(opts, cb) {
@ -1148,6 +1149,75 @@ WalletService.prototype.getBalance = function(opts, cb) {
});
};
/**
* Return info needed to send all funds in the wallet
* @param {Object} opts
* @param {string} opts.feePerKb - The fee per KB used to compute the TX.
* @param {string} opts.excludeUnconfirmedUtxos[=false] - Optional. Do not use UTXOs of unconfirmed transactions as inputs
* @param {string} opts.returnInputs[=false] - Optional. Return the list of UTXOs that would be included in the tx.
* @returns {Object} sendMaxInfo
*/
WalletService.prototype.getSendMaxInfo = function(opts, cb) {
var self = this;
opts = opts || {};
if (!Utils.checkRequired(opts, ['feePerKb']))
return cb(new ClientError('Required argument missing'));
self.getWallet({}, function(err, wallet) {
if (err) return cb(err);
self._getUtxosForCurrentWallet(null, function(err, utxos) {
if (err) return cb(err);
var info = {
size: 0,
amount: 0,
fee: 0,
inputs: [],
};
var inputs = _.reject(utxos, 'locked');
if (!!opts.excludeUnconfirmedUtxos) {
inputs = _.filter(inputs, 'confirmations');
}
inputs = _.sortBy(inputs, 'satoshis');
if (_.isEmpty(inputs)) return cb(null, info);
var txp = Model.TxProposal.create({
walletId: self.walletId,
network: wallet.network,
walletM: wallet.m,
walletN: wallet.n,
feePerKb: opts.feePerKb,
});
var lastFee = txp.getEstimatedFee();
_.eachRight(inputs, function(input) {
txp.inputs.push(input);
var fee = txp.getEstimatedFee();
var sizeInKb = txp.getEstimatedSize() / 1000.;
if (fee - lastFee > input.satoshis || sizeInKb > Defaults.MAX_TX_SIZE_IN_KB) {
txp.inputs.pop();
return false;
}
lastFee = fee;
});
info.size = txp.getEstimatedSize();
info.fee = txp.getEstimatedFee();
info.amount = _.sum(txp.inputs, 'satoshis') - info.fee;
if (opts.returnInputs) {
info.inputs = _.shuffle(txp.inputs);
}
return cb(null, info);
});
});
};
WalletService.prototype._sampleFeeLevels = function(network, points, cb) {
var self = this;
@ -1559,12 +1629,14 @@ WalletService.prototype._validateOutputs = function(opts, wallet) {
if (toAddress.network != wallet.getNetworkName()) {
return Errors.INCORRECT_ADDRESS_NETWORK;
}
if (!_.isNumber(output.amount) || _.isNaN(output.amount) || output.amount <= 0) {
return new ClientError('Invalid amount');
}
if (output.amount < Bitcore.Transaction.DUST_AMOUNT) {
return Errors.DUST_AMOUNT;
}
output.valid = true;
}
return null;
@ -1716,6 +1788,59 @@ WalletService.prototype.createTxLegacy = function(opts, cb) {
});
};
WalletService.prototype._validateAndSanitizeTxOpts = function(wallet, opts, cb) {
var self = this;
async.series([
function(next) {
if (!Utils.checkRequired(opts, ['outputs']))
return next(new ClientError('Required argument missing'));
next();
},
function(next) {
// feePerKb is required unless inputs & fee are specified
if (!_.isNumber(opts.feePerKb) && !(opts.inputs && _.isNumber(opts.fee)))
return next(new ClientError('Required argument missing'));
if (_.isNumber(opts.feePerKb)) {
if (opts.feePerKb < Defaults.MIN_FEE_PER_KB || opts.feePerKb > Defaults.MAX_FEE_PER_KB)
return next(new ClientError('Invalid fee per KB'));
}
next();
},
function(next) {
if (!opts.sendMax) return next();
if (!_.isArray(opts.outputs) || opts.outputs.length > 1) {
return next(new ClientError('Only one output allowed when sendMax is specified'));
}
if (_.isNumber(opts.outputs[0].amount))
return next(new ClientError('Amount is not allowed when sendMax is specified'));
if (_.isNumber(opts.fee))
return next(new ClientError('Fee is not allowed when sendMax is specified (use feePerKb instead)'));
self.getSendMaxInfo({
feePerKb: opts.feePerKb || Defaults.DEFAULT_FEE_PER_KB,
excludeUnconfirmedUtxos: !!opts.excludeUnconfirmedUtxos,
returnInputs: true,
}, function(err, info) {
if (err) return next(err);
opts.outputs[0].amount = info.amount;
opts.inputs = info.inputs;
return next();
});
},
function(next) {
if (opts.validateOutputs === false) return next();
var validationError = self._validateOutputs(opts, wallet);
if (validationError) {
return next(validationError);
}
next();
},
], cb);
};
/**
* Creates a new transaction proposal.
* @param {Object} opts
@ -1724,51 +1849,52 @@ WalletService.prototype.createTxLegacy = function(opts, cb) {
* @param {number} opts.outputs[].amount - Amount to transfer in satoshi.
* @param {string} opts.outputs[].message - A message to attach to this output.
* @param {string} opts.message - A message to attach to this transaction.
* @param {number} opts.feePerKb - The fee per kB to use for this TX.
* @param {string} opts.feePerKb - Use an alternative fee per KB for this TX.
* @param {string} opts.sendMax - Optional. Send maximum amount of funds that make sense under the specified fee/feePerKb conditions. (defaults to false).
* @param {string} opts.payProUrl - Optional. Paypro URL for peers to verify TX
* @param {string} opts.excludeUnconfirmedUtxos[=false] - Optional. Do not use UTXOs of unconfirmed transactions as inputs
* @param {string} opts.validateOutputs[=true] - Optional. Perform validation on outputs.
* @param {string} opts.dryRun[=false] - Optional. Simulate the action but do not change server state.
* @param {Array} opts.inputs - Optional. Inputs for this TX
* @param {number} opts.fee - Optional. The fee to use for this TX (used only when opts.inputs is specified).
* @param {number} opts.fee - Optional. Use an fixed fee for this TX (only when opts.inputs is specified)
* @returns {TxProposal} Transaction proposal.
*/
WalletService.prototype.createTx = function(opts, cb) {
var self = this;
if (!Utils.checkRequired(opts, ['outputs']))
return cb(new ClientError('Required argument missing'));
// feePerKb is required unless inputs & fee are specified
if (!_.isNumber(opts.feePerKb) && !(opts.inputs && _.isNumber(opts.fee)))
return cb(new ClientError('Required argument missing'));
if (_.isNumber(opts.feePerKb)) {
if (opts.feePerKb < Defaults.MIN_FEE_PER_KB || opts.feePerKb > Defaults.MAX_FEE_PER_KB)
return cb(new ClientError('Invalid fee per KB'));
}
self._runLocked(cb, function(cb) {
self.getWallet({}, function(err, wallet) {
if (err) return cb(err);
if (!wallet.isComplete()) return cb(Errors.WALLET_NOT_COMPLETE);
self._canCreateTx(function(err, canCreate) {
if (err) return cb(err);
if (!canCreate) return cb(Errors.TX_CANNOT_CREATE);
var wallet, txp, changeAddress;
async.series([
if (opts.validateOutputs !== false) {
var validationError = self._validateOutputs(opts, wallet);
if (validationError) {
return cb(validationError);
}
function(next) {
self.getWallet({}, function(err, w) {
if (err) return next(err);
if (!w.isComplete()) return next(Errors.WALLET_NOT_COMPLETE);
wallet = w;
next();
});
},
function(next) {
self._validateAndSanitizeTxOpts(wallet, opts, next);
},
function(next) {
self._canCreateTx(function(err, canCreate) {
if (err) return next(err);
if (!canCreate) return next(Errors.TX_CANNOT_CREATE);
next();
});
},
function(next) {
if (!opts.sendMax) {
changeAddress = wallet.createAddress(true);
}
var txOpts = {
walletId: self.walletId,
creatorId: self.copayerId,
outputs: opts.outputs,
message: opts.message,
changeAddress: wallet.createAddress(true),
changeAddress: changeAddress,
feePerKb: opts.feePerKb,
payProUrl: opts.payProUrl,
walletM: wallet.m,
@ -1781,21 +1907,23 @@ WalletService.prototype.createTx = function(opts, cb) {
fee: opts.inputs && !_.isNumber(opts.feePerKb) ? opts.fee : null,
};
var txp = Model.TxProposal.create(txOpts);
self._selectTxInputs(txp, opts.utxosToExclude, function(err) {
if (err) return cb(err);
self.storage.storeAddressAndWallet(wallet, txp.changeAddress, function(err) {
if (err) return cb(err);
self.storage.storeTx(wallet.id, txp, function(err) {
if (err) return cb(err);
return cb(null, txp);
});
});
});
});
txp = Model.TxProposal.create(txOpts);
next();
},
function(next) {
self._selectTxInputs(txp, opts.utxosToExclude, next);
},
function(next) {
if (!changeAddress || opts.dryRun) return next();
self.storage.storeAddressAndWallet(wallet, txp.changeAddress, next);
},
function(next) {
if (opts.dryRun) return next();
self.storage.storeTx(wallet.id, txp, next);
},
], function(err) {
if (err) return cb(err);
return cb(null, txp);
});
});
};

View File

@ -113,6 +113,38 @@ describe('ExpressApp', function() {
});
});
it('/v1/sendmaxinfo', function(done) {
var server = {
getSendMaxInfo: sinon.stub().callsArgWith(1, null, {
amount: 123
}),
};
var TestExpressApp = proxyquire('../lib/expressapp', {
'./server': {
initialize: sinon.stub().callsArg(1),
getInstanceWithAuth: sinon.stub().callsArgWith(1, null, server),
}
});
start(TestExpressApp, function() {
var requestOptions = {
url: testHost + ':' + testPort + config.basePath + '/v1/sendmaxinfo?feePerKb=10000&returnInputs=1',
headers: {
'x-identity': 'identity',
'x-signature': 'signature'
}
};
request(requestOptions, function(err, res, body) {
should.not.exist(err);
res.statusCode.should.equal(200);
var args = server.getSendMaxInfo.getCalls()[0].args[0];
args.feePerKb.should.equal(10000);
args.returnInputs.should.be.true;
JSON.parse(body).amount.should.equal(123);
done();
});
});
});
describe('Balance', function() {
it('should handle cache argument', function(done) {
var server = {

View File

@ -2841,6 +2841,32 @@ describe('Wallet service', function() {
});
});
it('should not be able to publish a temporary tx proposal created in a dry run', function(done) {
helpers.stubUtxos(server, wallet, [1, 2], function() {
var txOpts = {
outputs: [{
toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7',
amount: 0.8 * 1e8,
}],
feePerKb: 100e2,
dryRun: true,
};
server.createTx(txOpts, function(err, txp) {
should.not.exist(err);
should.exist(txp);
var publishOpts = helpers.getProposalSignatureOpts(txp, TestData.copayers[0].privKey_1H_0);
server.publishTx(publishOpts, function(err) {
should.exist(err);
err.code.should.equal('TX_NOT_FOUND');
server.getPendingTxs({}, function(err, txs) {
should.not.exist(err);
txs.length.should.equal(0);
done();
});
});
});
});
});
it('should delay NewTxProposal notification until published', function(done) {
helpers.stubUtxos(server, wallet, [1, 2], function() {
var txOpts = {
@ -3113,6 +3139,33 @@ describe('Wallet service', function() {
});
});
});
it('should be able to send max funds', function(done) {
helpers.stubUtxos(server, wallet, [1, 2], function() {
var txOpts = {
outputs: [{
toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7',
amount: null,
}],
feePerKb: 10000,
sendMax: true,
};
server.createTx(txOpts, function(err, tx) {
should.not.exist(err);
should.exist(tx);
should.not.exist(tx.changeAddress);
tx.amount.should.equal(3e8 - tx.fee);
var t = tx.getBitcoreTx();
t.getFee().should.equal(tx.fee);
should.not.exist(t.getChangeOutput());
t.toObject().inputs.length.should.equal(tx.inputs.length);
t.toObject().outputs[0].satoshis.should.equal(tx.amount);
done();
});
});
});
});
describe('Backoff time', function(done) {
@ -3284,7 +3337,6 @@ describe('Wallet service', function() {
});
});
});
it('should select smaller utxos if within fee constraints', function(done) {
helpers.stubUtxos(server, wallet, [1, '800bit', '800bit', '800bit'], function() {
var txOpts = {
@ -3563,6 +3615,20 @@ describe('Wallet service', function() {
});
});
});
});
});
describe('#createTx backoff time', function() {
var server, wallet, txid;
beforeEach(function(done) {
helpers.createAndJoinWallet(2, 2, function(s, w) {
server = s;
wallet = w;
helpers.stubUtxos(server, wallet, _.range(2, 6), function() {
done();
});
});
it('should ignore small utxos if fee is higher', function(done) {
helpers.stubUtxos(server, wallet, [].concat(_.times(10, function() {
return '30bit';
@ -3600,6 +3666,176 @@ describe('Wallet service', function() {
});
});
describe('#getSendMaxInfo', function() {
var server, wallet;
beforeEach(function(done) {
helpers.createAndJoinWallet(2, 3, function(s, w) {
server = s;
wallet = w;
done();
});
});
function sendTx(info, cb) {
var txOpts = {
outputs: [{
toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7',
amount: info.amount,
}],
inputs: info.inputs,
fee: info.fee,
};
server.createTx(txOpts, function(err, tx) {
should.not.exist(err);
should.exist(tx);
var t = tx.getBitcoreTx();
t.toObject().inputs.length.should.equal(info.inputs.length);
t.getFee().should.equal(info.fee);
should.not.exist(t.getChangeOutput());
return cb();
});
};
it('should be able to get send max info on empty wallet', function(done) {
server.getSendMaxInfo({
feePerKb: 10000,
returnInputs: true,
}, function(err, info) {
should.not.exist(err);
should.exist(info);
info.size.should.equal(0);
info.amount.should.equal(0);
info.fee.should.equal(0);
info.inputs.should.be.empty;
done();
});
});
it('should correctly get send max info', function(done) {
helpers.stubUtxos(server, wallet, [0.1, 0.2, 0.3, 0.4], function() {
server.getSendMaxInfo({
feePerKb: 10000,
returnInputs: true,
}, function(err, info) {
should.not.exist(err);
should.exist(info);
info.inputs.length.should.equal(4);
info.size.should.equal(1304);
info.fee.should.equal(info.size * 10000 / 1000.);
info.amount.should.equal(1e8 - info.fee);
sendTx(info, done);
});
});
});
it('should return inputs in random order', function(done) {
// NOTE: this test has a chance of failing of 1 in 1'073'741'824 :P
helpers.stubUtxos(server, wallet, _.range(1, 31), function(utxos) {
server.getSendMaxInfo({
feePerKb: 100e2,
returnInputs: true
}, function(err, info) {
should.not.exist(err);
should.exist(info);
var amounts = _.pluck(info.inputs, 'satoshis');
amounts.length.should.equal(30);
_.all(amounts, function(amount, i) {
if (i == 0) return true;
return amount < amounts[i - 1];
}).should.be.false;
done();
});
});
});
it('should exclude unconfirmed inputs', function(done) {
helpers.stubUtxos(server, wallet, ['u0.1', 0.2, 0.3, 0.4], function() {
server.getSendMaxInfo({
feePerKb: 10000,
excludeUnconfirmedUtxos: true,
returnInputs: true,
}, function(err, info) {
should.not.exist(err);
should.exist(info);
info.inputs.length.should.equal(3);
info.size.should.equal(1002);
info.fee.should.equal(info.size * 10000 / 1000.);
info.amount.should.equal(0.9e8 - info.fee);
sendTx(info, done);
});
});
});
it('should exclude locked inputs', function(done) {
helpers.stubUtxos(server, wallet, ['u0.1', 0.1, 0.1, 0.1], function() {
var txOpts = {
outputs: [{
toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7',
amount: 0.09e8,
}],
feePerKb: 100e2,
};
helpers.createAndPublishTx(server, txOpts, TestData.copayers[0].privKey_1H_0, function(tx) {
should.exist(tx);
server.getSendMaxInfo({
feePerKb: 10000,
excludeUnconfirmedUtxos: true,
returnInputs: true,
}, function(err, info) {
should.not.exist(err);
should.exist(info);
info.inputs.length.should.equal(2);
info.size.should.equal(700);
info.fee.should.equal(info.size * 10000 / 1000.);
info.amount.should.equal(0.2e8 - info.fee);
sendTx(info, done);
});
});
});
});
it('should ignore utxos not contributing to total amount (below their cost in fee)', function(done) {
helpers.stubUtxos(server, wallet, ['u0.1', 0.2, 0.3, 0.4, 0.000001, 0.0002, 0.0003], function() {
server.getSendMaxInfo({
feePerKb: 0.001e8,
returnInputs: true,
}, function(err, info) {
should.not.exist(err);
should.exist(info);
info.inputs.length.should.equal(4);
info.size.should.equal(1304);
info.fee.should.equal(info.size * 0.001e8 / 1000.);
info.amount.should.equal(1e8 - info.fee);
server.getSendMaxInfo({
feePerKb: 0.0001e8,
returnInputs: true,
}, function(err, info) {
should.not.exist(err);
should.exist(info);
info.inputs.length.should.equal(6);
info.size.should.equal(1907);
info.fee.should.equal(info.size * 0.0001e8 / 1000.);
info.amount.should.equal(1.0005e8 - info.fee);
sendTx(info, done);
});
});
});
});
it('should not go beyond max tx size', function(done) {
var _oldDefault = Defaults.MAX_TX_SIZE_IN_KB;
Defaults.MAX_TX_SIZE_IN_KB = 2;
helpers.stubUtxos(server, wallet, _.range(1, 10, 0), function() {
server.getSendMaxInfo({
feePerKb: 10000,
returnInputs: true,
}, function(err, info) {
should.not.exist(err);
should.exist(info);
info.size.should.be.below(2000);
info.inputs.length.should.be.below(9);
Defaults.MAX_TX_SIZE_IN_KB = _oldDefault;
sendTx(info, done);
});
});
});
})
describe('#rejectTx', function() {
var server, wallet, txid;