diff --git a/integration/regtest-node.js b/integration/regtest-node.js index 8cd91990..c22ce171 100644 --- a/integration/regtest-node.js +++ b/integration/regtest-node.js @@ -33,6 +33,8 @@ var testWIF = 'cSdkPxkAjA4HDr5VHgsebAPDEh9Gyub4HK8UJr2DFGGqKKy4K5sG'; var testKey; var client; +var outputForIsSpentTest1; + describe('Node Functionality', function() { var regtest; @@ -264,7 +266,7 @@ describe('Node Functionality', function() { throw err; } results.length.should.equal(1); - unspentOutput = results[0]; + unspentOutput = outputForIsSpentTest1 = results[0]; done(); }); }); @@ -293,6 +295,25 @@ describe('Node Functionality', function() { done(); }); }); + it('correctly give the summary for the address', function(done) { + var options = { + queryMempool: false + }; + node.services.address.getAddressSummary(address, options, function(err, results) { + if (err) { + throw err; + } + + results.totalReceived.should.equal(1000000000); + results.totalSpent.should.equal(0); + results.balance.should.equal(1000000000); + results.unconfirmedBalance.should.equal(1000000000); + results.appearances.should.equal(1); + results.unconfirmedAppearances.should.equal(0); + results.txids.length.should.equal(1); + done(); + }); + }); describe('History', function() { this.timeout(20000); @@ -695,5 +716,36 @@ describe('Node Functionality', function() { }); }); + + describe('isSpent', function() { + it('will return true if an input is spent in a confirmed transaction', function(done) { + var result = node.services.bitcoind.isSpent(outputForIsSpentTest1.txid, outputForIsSpentTest1.outputIndex); + result.should.equal(true); + done(); + }); + it('will incorrectly return false for an input that is spent in an unconfirmed transaction', function(done) { + node.services.address.getUnspentOutputs(address, false, function(err, results) { + if (err) { + throw err; + } + + var unspentOutput = results[0]; + + var tx = new Transaction(); + tx.from(unspentOutput); + tx.to(address, unspentOutput.satoshis - 1000); + tx.fee(1000); + tx.sign(testKey); + + node.services.bitcoind.sendTransaction(tx.serialize()); + + setImmediate(function() { + var result = node.services.bitcoind.isSpent(unspentOutput.txid, unspentOutput.outputIndex); + result.should.equal(false); + done(); + }); + }); + }); + }); }); }); diff --git a/lib/services/address/index.js b/lib/services/address/index.js index af9ff7df..eea07afd 100644 --- a/lib/services/address/index.js +++ b/lib/services/address/index.js @@ -28,6 +28,7 @@ var AddressService = function(options) { this.mempoolOutputIndex = {}; this.mempoolInputIndex = {}; + this.mempoolSpentIndex = {}; }; @@ -52,7 +53,8 @@ AddressService.prototype.getAPIMethods = function() { ['getOutputs', this, this.getOutputs, 2], ['getUnspentOutputs', this, this.getUnspentOutputs, 2], ['isSpent', this, this.isSpent, 2], - ['getAddressHistory', this, this.getAddressHistory, 2] + ['getAddressHistory', this, this.getAddressHistory, 2], + ['getAddressSummary', this, this.getAddressSummary, 1] ]; }; @@ -176,6 +178,11 @@ AddressService.prototype.updateMempoolIndex = function(tx) { for (var inputIndex = 0; inputIndex < inputLength; inputIndex++) { var input = tx.inputs[inputIndex]; + + // Update spent index + var spentIndexKey = [input.prevTxId.toString('hex'), input.outputIndex].join('-'); + this.mempoolSpentIndex[spentIndexKey] = true; + var address = input.script.toAddress(this.node.network); if (!address) { continue; @@ -197,6 +204,7 @@ AddressService.prototype.resetMempoolIndex = function(callback) { var transactionBuffers = self.node.services.bitcoind.getMempoolTransactions(); this.mempoolInputIndex = {}; this.mempoolOutputIndex = {}; + this.mempoolSpentIndex = {}; async.each(transactionBuffers, function(txBuffer, next) { var tx = Transaction().fromBuffer(txBuffer); self.updateMempoolIndex(tx); @@ -871,4 +879,121 @@ AddressService.prototype.getAddressHistory = function(addresses, options, callba history.get(callback); }; +/** + * This will return an object with: + * balance - confirmed balance + * unconfirmedBalance - unconfirmed balance + * totalReceived - satoshis received + * totalSpent - satoshis spent + * appearances - number of times used in confirmed transactions + * unconfirmedAppearances - number of times used in unconfirmed transactions + * txids - list of txids (unless noTxList is set) + * + * @param {String} address + * @param {Object} options + * @param {Boolean} options.noTxList - if set, txid array will not be included + * @param {Function} callback + */ +AddressService.prototype.getAddressSummary = function(address, options, callback) { + var self = this; + + var opt = { + queryMempool: true + }; + + var outputs; + var inputs; + var mempoolInputs; + + async.parallel( + [ + function(next) { + if(options.noTxList) { + setImmediate(next); + } else { + self.getInputs(address, opt, function(err, ins) { + inputs = ins; + next(err); + }); + } + }, + function(next) { + self.getOutputs(address, opt, function(err, outs) { + outputs = outs; + next(err); + }); + } + ], + function(err) { + if(err) { + return callback(err); + } + + var totalReceived = 0; + var totalSpent = 0; + var balance = 0; + var unconfirmedBalance = 0; + var appearances = 0; + var unconfirmedAppearances = 0; + var txids = []; + + for(var i = 0; i < outputs.length; i++) { + // Bitcoind's isSpent at the moment only works for confirmed transactions + var spentDB = self.node.services.bitcoind.isSpent(outputs[i].txid, outputs[i].outputIndex); + var spentIndexKey = [outputs[i].txid, outputs[i].outputIndex].join('-'); + var spentMempool = self.mempoolSpentIndex[spentIndexKey]; + + txids.push(outputs[i]); + unconfirmedBalance += outputs[i].satoshis; + if(outputs[i].confirmations) { + totalReceived += outputs[i].satoshis; + balance += outputs[i].satoshis; + appearances++; + } else { + unconfirmedAppearances++; + } + + if(spentDB || spentMempool) { + unconfirmedBalance -= outputs[i].satoshis; + if(spentDB) { + totalSpent += outputs[i].satoshis; + balance -= outputs[i].satoshis; + appearances++; + } else { + unconfirmedAppearances++; + } + } + } + + var summary = { + totalReceived: totalReceived, + totalSpent: totalSpent, + balance: balance, + unconfirmedBalance: unconfirmedBalance, + appearances: appearances, + unconfirmedAppearances: unconfirmedAppearances + }; + + if(inputs) { + for(var i = 0; i < inputs.length; i++) { + txids.push(inputs[i]); + } + + // sort by height + txids = txids.sort(function(a, b) { + return a.height > b.height ? 1 : -1; + }).map(function(obj) { + return obj.txid; + }).filter(function(value, index, self) { + return self.indexOf(value) === index; + }); + + summary.txids = txids; + } + + callback(null, summary); + } + ); +}; + module.exports = AddressService; diff --git a/test/services/address/index.unit.js b/test/services/address/index.unit.js index 9bf9622d..8dd3df87 100644 --- a/test/services/address/index.unit.js +++ b/test/services/address/index.unit.js @@ -31,7 +31,7 @@ describe('Address Service', function() { it('should return the correct methods', function() { var am = new AddressService({node: mocknode}); var methods = am.getAPIMethods(); - methods.length.should.equal(5); + methods.length.should.equal(6); }); }); @@ -954,4 +954,81 @@ describe('Address Service', function() { }); }); }); + describe('#getAddressSummary', function() { + var node = { + services: { + bitcoind: { + isSpent: sinon.stub().returns(false), + on: sinon.spy() + } + } + }; + var inputs = [ + { + "txid": "9f183412de12a6c1943fc86c390174c1cde38d709217fdb59dcf540230fa58a6", + "height": -1, + "confirmations": 0, + "addresses": { + "mpkDdnLq26djg17s6cYknjnysAm3QwRzu2": { + "outputIndexes": [], + "inputIndexes": [ + 3 + ] + } + }, + "address": "mpkDdnLq26djg17s6cYknjnysAm3QwRzu2" + } + ]; + + var outputs = [ + { + "address": "mpkDdnLq26djg17s6cYknjnysAm3QwRzu2", + "txid": "689e9f543fa4aa5b2daa3b5bb65f9a00ad5aa1a2e9e1fc4e11061d85f2aa9bc5", + "outputIndex": 0, + "height": 556351, + "satoshis": 3487110, + "script": "76a914653b58493c2208481e0902a8ffb97b8112b13fe188ac", + "confirmations": 13190 + } + ]; + + var as = new AddressService({node: node}); + as.getInputs = sinon.stub().callsArgWith(2, null, inputs); + as.getOutputs = sinon.stub().callsArgWith(2, null, outputs); + as.mempoolSpentIndex = { + '689e9f543fa4aa5b2daa3b5bb65f9a00ad5aa1a2e9e1fc4e11061d85f2aa9bc5-0': true + }; + + it('should handle unconfirmed and confirmed outputs and inputs', function(done) { + as.getAddressSummary('mpkDdnLq26djg17s6cYknjnysAm3QwRzu2', {}, function(err, summary) { + should.not.exist(err); + summary.totalReceived.should.equal(3487110); + summary.totalSpent.should.equal(0); + summary.balance.should.equal(3487110); + summary.unconfirmedBalance.should.equal(0); + summary.appearances.should.equal(1); + summary.unconfirmedAppearances.should.equal(1); + summary.txids.should.deep.equal( + [ + '9f183412de12a6c1943fc86c390174c1cde38d709217fdb59dcf540230fa58a6', + '689e9f543fa4aa5b2daa3b5bb65f9a00ad5aa1a2e9e1fc4e11061d85f2aa9bc5' + ] + ); + done(); + }); + }); + it('noTxList should not include txids array', function(done) { + as.getAddressSummary('mpkDdnLq26djg17s6cYknjnysAm3QwRzu2', {noTxList: true}, function(err, summary) { + should.not.exist(err); + summary.totalReceived.should.equal(3487110); + summary.totalSpent.should.equal(0); + summary.balance.should.equal(3487110); + summary.unconfirmedBalance.should.equal(0); + summary.appearances.should.equal(1); + summary.unconfirmedAppearances.should.equal(1); + should.not.exist(summary.txids); + done(); + }); + }); + }); });