diff --git a/lib/server.js b/lib/server.js index 7a8d1d9..af8c9fb 100644 --- a/lib/server.js +++ b/lib/server.js @@ -797,7 +797,7 @@ WalletService.prototype.rejectTx = function(opts, cb) { }; /** - * Retrieves all pending transaction proposals. + * Retrieves pending transaction proposals. * @param {Object} opts * @returns {TxProposal[]} Transaction proposal. */ @@ -812,7 +812,7 @@ WalletService.prototype.getPendingTxs = function(opts, cb) { }; /** - * Retrieves pending transaction proposals in the range (maxTs-minTs) + * Retrieves all transaction proposals in the range (maxTs-minTs) * Times are in UNIX EPOCH * * @param {Object} opts.minTs (defaults to 0) @@ -830,7 +830,7 @@ WalletService.prototype.getTxs = function(opts, cb) { /** - * Retrieves notifications in the range (maxTs-minTs). + * Retrieves notifications in the range (maxTs-minTs). * Times are in UNIX EPOCH. Order is assured even for events with the same time * * @param {Object} opts.minTs (defaults to 0) @@ -848,7 +848,141 @@ WalletService.prototype.getNotifications = function(opts, cb) { }; +WalletService.prototype._normalizeTxHistory = function(txs) { + return _.map(txs, function(tx) { + var inputs = _.map(tx.vin, function(item) { + return { + address: item.addr, + amount: item.valueSat, + } + }); + + var outputs = _.map(tx.vout, function(item) { + var itemAddr; + // If classic multisig, ignore + if (item.scriptPubKey && item.scriptPubKey.addresses.length == 1) { + itemAddr = item.scriptPubKey.addresses[0]; + } + + return { + address: itemAddr, + amount: parseInt((item.value * 1e8).toFixed(0)), + } + }); + + return { + txid: tx.txid, + confirmations: tx.confirmations, + fees: parseInt((tx.fees * 1e8).toFixed(0)), + minedTs: !_.isNaN(tx.time) ? tx.time * 1000 : undefined, + inputs: inputs, + outputs: outputs, + }; + }); +}; + +/** + * Retrieves all transactions (incoming & outgoing) in the range (maxTs-minTs) + * Times are in UNIX EPOCH + * + * @param {Object} opts.minTs (defaults to 0) + * @param {Object} opts.maxTs (defaults to now) + * @param {Object} opts.limit + * @returns {TxProposal[]} Transaction proposals, first newer + */ +WalletService.prototype.getTxHistory = function(opts, cb) { + var self = this; + + function decorate(txs, addresses, proposals) { + function sum(items, isMine, isChange) { + var filter = {}; + if (_.isBoolean(isMine)) filter.isMine = isMine; + if (_.isBoolean(isChange)) filter.isChange = isChange; + return _.reduce(_.where(items, filter), + function(memo, item) { + return memo + item.amount; + }, 0); + }; + + var indexedAddresses = _.indexBy(addresses, 'address'); + var indexedProposals = _.indexBy(proposals, 'txid'); + + _.each(txs, function(tx) { + _.each(tx.inputs.concat(tx.outputs), function(item) { + var address = indexedAddresses[item.address]; + item.isMine = !!address; + item.isChange = address ? address.isChange : false; + }); + + var amountIn = sum(tx.inputs, true); + var amountOut = sum(tx.outputs, true, false); + var amountOutChange = sum(tx.outputs, true, true); + var amount; + if (amountIn == (amountOut + amountOutChange + (amountIn > 0 ? tx.fees : 0))) { + tx.action = 'moved'; + amount = amountOut; + } else { + amount = amountIn - amountOut - amountOutChange - (amountIn > 0 ? tx.fees : 0); + tx.action = amount > 0 ? 'sent' : 'received'; + } + + tx.amount = Math.abs(amount); + if (tx.action == 'sent' || tx.action == 'moved') { + tx.addressTo = tx.outputs[0].address; + }; + delete tx.inputs; + delete tx.outputs; + + var proposal = indexedProposals[tx.txid]; + if (proposal) { + tx.message = proposal.message; + tx.actions = proposal.actions; + // tx.sentTs = proposal.sentTs; + // tx.merchant = proposal.merchant; + //tx.paymentAckMemo = proposal.paymentAckMemo; + } + }); + }; + + + + // Get addresses for this wallet + self.storage.fetchAddresses(self.walletId, function(err, addresses) { + if (err) return cb(err); + if (addresses.length == 0) return cb(null, []); + + var addressStrs = _.pluck(addresses, 'address'); + var networkName = Bitcore.Address(addressStrs[0]).toObject().network; + + var bc = self._getBlockExplorer('insight', networkName); + async.parallel([ + + function(next) { + self.storage.fetchTxs(self.walletId, opts, function(err, txps) { + if (err) return next(err); + next(null, txps); + }); + }, + function(next) { + bc.getTransactions(addressStrs, function(err, txs) { + if (err) return next(err); + + next(null, self._normalizeTxHistory(txs)); + }); + }, + ], function(err, res) { + if (err) return cb(err); + + var proposals = res[0]; + var txs = res[1]; + + decorate(txs, addresses, proposals); + + return cb(null, txs); + }); + }); +}; module.exports = WalletService; diff --git a/test/integration/server.js b/test/integration/server.js index d090af4..a60bd5e 100644 --- a/test/integration/server.js +++ b/test/integration/server.js @@ -2,12 +2,15 @@ var _ = require('lodash'); var async = require('async'); +var inspect = require('util').inspect; var chai = require('chai'); var sinon = require('sinon'); var should = chai.should(); var levelup = require('levelup'); var memdown = require('memdown'); +var log = require('npmlog'); +log.debug = log.verbose; var Bitcore = require('bitcore'); var Utils = require('../../lib/utils'); @@ -126,6 +129,9 @@ helpers.stubBroadcastFail = function() { blockExplorer.broadcast = sinon.stub().callsArgWith(1, 'broadcast error'); }; +helpers.stubHistory = function(txs) { + blockExplorer.getTransactions = sinon.stub().callsArgWith(1, null, txs); +}; helpers.clientSign = function(txp, xprivHex) { //Derive proper key to sign, for each input @@ -175,6 +181,19 @@ helpers.createProposalOpts = function(toAddress, amount, message, signingKey) { return opts; }; +helpers.createAddresses = function(server, wallet, main, change, cb) { + async.map(_.range(main + change), function(i, next) { + var address = wallet.createAddress(i >= main); + server.storage.storeAddressAndWallet(wallet, address, function(err) { + if (err) return next(err); + next(null, address); + }); + }, function(err, addresses) { + if (err) throw new Error('Could not generate addresses'); + return cb(_.take(addresses, main), _.takeRight(addresses, change)); + }); +}; + var db, storage, blockExplorer; @@ -926,15 +945,13 @@ describe('Copay server', function() { helpers.createAndJoinWallet(2, 3, function(s, w) { server = s; wallet = w; - server.createAddress({}, function(err, address) { - helpers.stubUtxos(server, wallet, _.range(1, 9), function() { - var txOpts = helpers.createProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 10, null, TestData.copayers[0].privKey); - server.createTx(txOpts, function(err, tx) { - should.not.exist(err); - should.exist(tx); - txid = tx.id; - done(); - }); + helpers.stubUtxos(server, wallet, _.range(1, 9), function() { + var txOpts = helpers.createProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 10, null, TestData.copayers[0].privKey); + server.createTx(txOpts, function(err, tx) { + should.not.exist(err); + should.exist(tx); + txid = tx.id; + done(); }); }); }); @@ -1084,10 +1101,8 @@ describe('Copay server', function() { helpers.createAndJoinWallet(1, 1, function(s, w) { server = s; wallet = w; - server.createAddress({}, function(err, address) { - helpers.stubUtxos(server, wallet, _.range(1, 9), function() { - done(); - }); + helpers.stubUtxos(server, wallet, _.range(1, 9), function() { + done(); }); }); }); @@ -1666,14 +1681,12 @@ describe('Copay server', function() { helpers.createAndJoinWallet(2, 3, function(s, w) { server = s; wallet = w; - server.createAddress({}, function(err, address) { - helpers.stubUtxos(server, wallet, [100, 200], function() { - var txOpts = helpers.createProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 80, 'some message', TestData.copayers[0].privKey); - server.createTx(txOpts, function(err, tx) { - server.getPendingTxs({}, function(err, txs) { - txp = txs[0]; - done(); - }); + helpers.stubUtxos(server, wallet, [100, 200], function() { + var txOpts = helpers.createProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 80, 'some message', TestData.copayers[0].privKey); + server.createTx(txOpts, function(err, tx) { + server.getPendingTxs({}, function(err, txs) { + txp = txs[0]; + done(); }); }); }); @@ -1742,4 +1755,169 @@ describe('Copay server', function() { }); }); }); + + + describe('#getTxHistory', function() { + var server, wallet, mainAddresses, changeAddresses; + beforeEach(function(done) { + helpers.createAndJoinWallet(1, 1, function(s, w) { + server = s; + wallet = w; + helpers.createAddresses(server, wallet, 3, 3, function(main, change) { + mainAddresses = main; + changeAddresses = change; + done(); + }); + }); + }); + + it('should get tx history from insight', function(done) { + helpers.stubHistory(TestData.history); + server.getTxHistory({}, function(err, txs) { + should.not.exist(err); + should.exist(txs); + txs.length.should.equal(2); + done(); + }); + }); + it('should get tx history for incoming txs', function(done) { + server._normalizeTxHistory = sinon.stub().returnsArg(0); + var txs = [{ + txid: '1', + confirmations: 1, + fees: 100, + minedTs: 1, + inputs: [{ + address: 'external', + amount: 500, + }], + outputs: [{ + address: mainAddresses[0].address, + amount: 200, + }], + }]; + helpers.stubHistory(txs); + server.getTxHistory({}, function(err, txs) { + should.not.exist(err); + should.exist(txs); + txs.length.should.equal(1); + var tx = txs[0]; + tx.action.should.equal('received'); + tx.amount.should.equal(200); + tx.fees.should.equal(100); + done(); + }); + }); + it('should get tx history for outgoing txs', function(done) { + server._normalizeTxHistory = sinon.stub().returnsArg(0); + var txs = [{ + txid: '1', + confirmations: 1, + fees: 100, + minedTs: 1, + inputs: [{ + address: mainAddresses[0].address, + amount: 500, + }], + outputs: [{ + address: 'external', + amount: 400, + }], + }]; + helpers.stubHistory(txs); + server.getTxHistory({}, function(err, txs) { + should.not.exist(err); + should.exist(txs); + txs.length.should.equal(1); + var tx = txs[0]; + tx.action.should.equal('sent'); + tx.amount.should.equal(400); + tx.fees.should.equal(100); + done(); + }); + }); + it('should get tx history for outgoing txs + change', function(done) { + server._normalizeTxHistory = sinon.stub().returnsArg(0); + var txs = [{ + txid: '1', + confirmations: 1, + fees: 100, + minedTs: 1, + inputs: [{ + address: mainAddresses[0].address, + amount: 500, + }], + outputs: [{ + address: 'external', + amount: 300, + }, { + address: changeAddresses[0].address, + amount: 100, + }], + }]; + helpers.stubHistory(txs); + server.getTxHistory({}, function(err, txs) { + should.not.exist(err); + should.exist(txs); + txs.length.should.equal(1); + var tx = txs[0]; + tx.action.should.equal('sent'); + tx.amount.should.equal(300); + tx.fees.should.equal(100); + done(); + }); + }); + it('should get tx history for outgoing txs with proposal', function(done) { + server._normalizeTxHistory = sinon.stub().returnsArg(0); + + helpers.stubUtxos(server, wallet, [100, 200], function(utxos) { + var txOpts = helpers.createProposalOpts(mainAddresses[0].address, 80, 'some message', TestData.copayers[0].privKey); + server.createTx(txOpts, function(err, tx) { + should.not.exist(err); + tx.should.exist; + + helpers.stubBroadcast('1122334455'); + var signatures = helpers.clientSign(tx, TestData.copayers[0].xPrivKey); + server.signTx({ + txProposalId: tx.id, + signatures: signatures, + }, function(err, tx) { + should.not.exist(err); + var txs = [{ + txid: '1122334455', + confirmations: 1, + fees: 5460, + minedTs: 1, + inputs: [{ + address: tx.inputs[0].address, + amount: utxos[0].satoshis, + }], + outputs: [{ + address: 'external', + amount: helpers.toSatoshi(80) - 5460, + }, { + address: changeAddresses[0].address, + amount: helpers.toSatoshi(20) - 5460, + }], + }]; + helpers.stubHistory(txs); + + server.getTxHistory({}, function(err, txs) { + should.not.exist(err); + should.exist(txs); + txs.length.should.equal(1); + var tx = txs[0]; + tx.action.should.equal('sent'); + tx.amount.should.equal(helpers.toSatoshi(80)); + tx.message.should.equal(tx.message); + tx.actions.length.should.equal(1); + tx.actions[0].type.should.equal('accept'); + tx.actions[0].copayerName.should.equal('copayer 1'); + done(); + }); + }); + }); + }); + }); + }); }); diff --git a/test/testdata.js b/test/testdata.js index fac1b1b..76e4ecc 100644 --- a/test/testdata.js +++ b/test/testdata.js @@ -62,6 +62,82 @@ var copayers = [{ }, ]; +var history = [{ + txid: "0279ef7b21630f859deb723e28beac9e7011660bd1346c2da40321d2f7e34f04", + vin: [{ + txid: "c8e221141e8bb60977896561b77fa59d6dacfcc10db82bf6f5f923048b11c70d", + vout: 0, + n: 0, + addr: "2N6Zutg26LEC4iYVxi7SHhopVLP1iZPU1rZ", + valueSat: 485645, + value: 0.00485645, + }, { + txid: "6e599eea3e2898b91087eb87e041c5d8dec5362447a8efba185ed593f6dc64c0", + vout: 1, + n: 1, + addr: "2MyqmcWjmVxW8i39wdk1CVPdEqKyFSY9H1S", + valueSat: 885590, + value: 0.0088559, + }], + vout: [{ + value: "0.00045753", + n: 0, + scriptPubKey: { + addresses: [ + "2NAVFnsHqy5JvqDJydbHPx393LFqFFBQ89V" + ] + }, + }, { + value: "0.01300000", + n: 1, + scriptPubKey: { + addresses: [ + "mq4D3Va5mYHohMEHrgHNGzCjKhBKvuEhPE" + ] + } + }], + confirmations: 2, + time: 1424471041, + blocktime: 1424471041, + valueOut: 0.01345753, + valueIn: 0.01371235, + fees: 0.00025482 +}, { + txid: "fad88682ccd2ff34cac6f7355fe9ecd8addd9ef167e3788455972010e0d9d0de", + vin: [{ + txid: "0279ef7b21630f859deb723e28beac9e7011660bd1346c2da40321d2f7e34f04", + vout: 0, + n: 0, + addr: "2NAVFnsHqy5JvqDJydbHPx393LFqFFBQ89V", + valueSat: 45753, + value: 0.00045753, + }], + vout: [{ + value: "0.00011454", + n: 0, + scriptPubKey: { + addresses: [ + "2N7GT7XaN637eBFMmeczton2aZz5rfRdZso" + ] + } + }, { + value: "0.00020000", + n: 1, + scriptPubKey: { + addresses: [ + "mq4D3Va5mYHohMEHrgHNGzCjKhBKvuEhPE" + ] + } + }], + confirmations: 1, + time: 1424472242, + blocktime: 1424472242, + valueOut: 0.00031454, + valueIn: 0.00045753, + fees: 0.00014299 +}]; + module.exports.keyPair = keyPair; module.exports.message = message; module.exports.copayers = copayers; +module.exports.history = history;