diff --git a/integration/regtest-node.js b/integration/regtest-node.js index 8b55db7f6..6a54cf388 100644 --- a/integration/regtest-node.js +++ b/integration/regtest-node.js @@ -577,8 +577,9 @@ describe('Node Functionality', function() { if (err) { throw err; } - results.totalReceived.should.equal(2000000000); - results.totalSpent.should.equal(1999990000); + // This address receives 10 BTC (from a 50 btc input split 10BTC/39.99999045) then sends it around + results.totalReceived.should.equal(1000000000); + results.totalSpent.should.equal(999990000); results.balance.should.equal(10000); results.unconfirmedBalance.should.equal(0); results.appearances.should.equal(6); diff --git a/lib/services/address/index.js b/lib/services/address/index.js index a557ba24c..896b797d6 100644 --- a/lib/services/address/index.js +++ b/lib/services/address/index.js @@ -1387,11 +1387,12 @@ AddressService.prototype.getAddressHistory = function(addresses, options, callba /** * This will give an object with: * balance - confirmed balance - * unconfirmedBalance - unconfirmed balance + * unconfirmedReceived - unconfirmed outputs sent to this address + * unconfirmedSpent - unconfirmed outputs sent from this address * totalReceived - satoshis received * totalSpent - satoshis spent - * appearances - number of transactions - * unconfirmedAppearances - number of unconfirmed transactions + * appearances - number of confirmed outputs sent to this address + * unconfirmedAppearances - number of unconfirmed outputs sent to this address * txids - list of txids (unless noTxList is set) * * @param {String} address @@ -1406,72 +1407,88 @@ AddressService.prototype.getAddressSummary = function(address, options, callback queryMempool: true }; - var outputs; - var inputs; - async.parallel( [ - function(next) { - self.getInputs(address, opt, function(err, ins) { - inputs = ins; - next(err); - }); - }, - function(next) { - self.getOutputs(address, opt, function(err, outs) { - outputs = outs; - next(err); - }); - } + self.getInputs.bind(self, address, opt), + self.getOutputs.bind(self, address, opt) ], - function(err) { + function(err, results) { if(err) { return callback(err); } + var inputs = results[0]; + var outputs = results[1]; var totalReceived = 0; var totalSpent = 0; var balance = 0; - var unconfirmedBalance = 0; + var unconfirmedReceived = 0; + var unconfirmedSpent = 0; + var unconfirmedBalance = 0; // TODO remove this, for backcompat with insight-API + var inputAppearanceIds = {}; var appearanceIds = {}; var unconfirmedAppearanceIds = {}; var txids = []; - for(var i = 0; i < outputs.length; i++) { + for(var j = 0, len = inputs.length; j < len; j++) { + var input = inputs[j]; + inputAppearanceIds[input.txid] = true; // marked for change txs + if (input.confirmations) { + appearanceIds[input.txid] = true; + } else { + unconfirmedAppearanceIds[input.txid] = true; + } + } + + for(var i = 0, len = outputs.length; i < len; i++) { + var output = outputs[i]; // Bitcoind's isSpent only works for confirmed transactions - var spentDB = self.node.services.bitcoind.isSpent(outputs[i].txid, outputs[i].outputIndex); + var spentDB = self.node.services.bitcoind.isSpent(output.txid, output.outputIndex); var spentIndexSyncKey = self._encodeSpentIndexSyncKey( - new Buffer(outputs[i].txid, 'hex'), // TODO: get buffer directly - outputs[i].outputIndex + new Buffer(output.txid, 'hex'), // TODO: get buffer directly + output.outputIndex ); var spentMempool = self.mempoolSpentIndex[spentIndexSyncKey]; + var isChangeTx = inputAppearanceIds[output.txid]; + var confirmed = output.confirmations > 0; - txids.push(outputs[i]); + txids.push(output); - if(outputs[i].confirmations) { - totalReceived += outputs[i].satoshis; - balance += outputs[i].satoshis; - appearanceIds[outputs[i].txid] = true; - } else { - unconfirmedBalance += outputs[i].satoshis; - unconfirmedAppearanceIds[outputs[i].txid] = true; + // If this is a confirmed TX, add it to our balance & totalReceived. + if(confirmed) { + totalReceived += output.satoshis; + balance += output.satoshis; + appearanceIds[output.txid] = true; + } + // Otherwise, it's added to unconfirmedBalance. + else { + unconfirmedReceived += output.satoshis; + unconfirmedAppearanceIds[output.txid] = true; + unconfirmedBalance += output.satoshis; // TODO remove } - if(spentDB || spentMempool) { - if(spentDB) { - totalSpent += outputs[i].satoshis; - balance -= outputs[i].satoshis; - } else if(!outputs[i].confirmations) { - unconfirmedBalance -= outputs[i].satoshis; - } + // We back it out of our balance and add to Spent if it's been spent in the DB, + // otherwise we toss it in unconfirmedSpent. + // Note unconfirmed txs don't affect balance. + if (spentDB) { + totalSpent += output.satoshis; + balance -= output.satoshis; + } else if (spentMempool) { // mempool + unconfirmedSpent += output.satoshis; + unconfirmedBalance -= output.satoshis; // TODO remove } - } - for(var j = 0; j < inputs.length; j++) { - if (inputs[j].confirmations) { - appearanceIds[inputs[j].txid] = true; - } else { - unconfirmedAppearanceIds[outputs[j].txid] = true; + // If this is a change tx output, we don't count it as spent or received. + // We have to deduct from the right totals depending on confirmation status. + if (isChangeTx) { + if (confirmed) { + totalReceived -= output.satoshis; + totalSpent -= output.satoshis; + } else { + unconfirmedReceived -= output.satoshis; + unconfirmedSpent -= output.satoshis; + // intentionally no change to unconfirmedBalance. TODO remove this comment + } } } @@ -1479,7 +1496,9 @@ AddressService.prototype.getAddressSummary = function(address, options, callback totalReceived: totalReceived, totalSpent: totalSpent, balance: balance, - unconfirmedBalance: unconfirmedBalance, + unconfirmedReceived: unconfirmedReceived, + unconfirmedSpent: unconfirmedSpent, + unconfirmedBalance: unconfirmedBalance, // TODO remove, legacy appearances: Object.keys(appearanceIds).length, unconfirmedAppearances: Object.keys(unconfirmedAppearanceIds).length }; diff --git a/test/services/address/index.unit.js b/test/services/address/index.unit.js index e7660649d..28bfa46a7 100644 --- a/test/services/address/index.unit.js +++ b/test/services/address/index.unit.js @@ -1803,7 +1803,8 @@ describe('Address Service', function() { summary.totalReceived.should.equal(3487110); summary.totalSpent.should.equal(0); summary.balance.should.equal(3487110); - summary.unconfirmedBalance.should.equal(0); + summary.unconfirmedReceived.should.equal(0); + summary.unconfirmedSpent.should.equal(3487110); summary.appearances.should.equal(1); summary.unconfirmedAppearances.should.equal(1); summary.txids.should.deep.equal( @@ -1821,12 +1822,298 @@ describe('Address Service', function() { summary.totalReceived.should.equal(3487110); summary.totalSpent.should.equal(0); summary.balance.should.equal(3487110); - summary.unconfirmedBalance.should.equal(0); + summary.unconfirmedReceived.should.equal(0); + summary.unconfirmedSpent.should.equal(3487110); summary.appearances.should.equal(1); summary.unconfirmedAppearances.should.equal(1); should.not.exist(summary.txids); done(); }); }); + + describe('unconfirmed (mempool) transactions', function() { + var node = { + datadir: 'testdir', + network: Networks.testnet, + services: { + bitcoind: { + isSpent: sinon.stub().returns(false), + on: sinon.spy() + } + } + }; + // Technically this was a change tx, but for this test we're removing the input + // so we can just test a mempool tx to some other addr + var inputs = []; + + var outputs = [ + { // not spent + "address": "2NBMEXj3BM7C2k4HCjfqn1Q4mwezNUzmrs2", + "txid": "c0dfe181d45094c5c4e99b56be0699c4a6423b9e0086f6f8096ea8a5bfa57cff", + "outputIndex": 1, + "height": 310280, + "satoshis": 80000000, + "script": "a914c695365fc599b2960d92cfd720c04e054c4cb2f987", + "confirmations": 316481 + }, + { // spent + "address": "2NBMEXj3BM7C2k4HCjfqn1Q4mwezNUzmrs2", + "txid": "187ec93c565522f17605a8591a51c602eb1463f42df4b169c855a591ec952a04", + "outputIndex": 0, + "height": 310818, + "satoshis": 59990000, + "script": "a914c695365fc599b2960d92cfd720c04e054c4cb2f987", + "confirmations": 315943 + } + ]; + var mempoolAS = new AddressService({ + node: node + }); + mempoolAS.getInputs = sinon.stub().callsArgWith(2, null, inputs); + mempoolAS.getOutputs = sinon.stub().callsArgWith(2, null, outputs); + // Add to spent mempool + var spentIndexSyncKey = mempoolAS._encodeSpentIndexSyncKey(new Buffer(outputs[1].txid, 'hex'), 0); + mempoolAS.mempoolSpentIndex[spentIndexSyncKey] = true; + + it('should add to unconfirmedSpent and not deduct from balance until conf', function(done) { + mempoolAS.getAddressSummary('2NBMEXj3BM7C2k4HCjfqn1Q4mwezNUzmrs2', {}, function(err, summary) { + should.not.exist(err); + summary.totalReceived.should.equal(139990000); + summary.totalSpent.should.equal(0); + summary.balance.should.equal(139990000); + summary.unconfirmedReceived.should.equal(0); + summary.unconfirmedSpent.should.equal(59990000); + summary.appearances.should.equal(2); + summary.unconfirmedAppearances.should.equal(0); + summary.txids.should.deep.equal( + [ + 'c0dfe181d45094c5c4e99b56be0699c4a6423b9e0086f6f8096ea8a5bfa57cff', + '187ec93c565522f17605a8591a51c602eb1463f42df4b169c855a591ec952a04' + ] + ); + done(); + }); + }) + }); + + // In this test, we've received 0.8 BTC. + // We've then spent that output to send 0.2001 elsewhere and 0.5999 back to ourselves. + // The 0.5999 is unspent, so we sould have a balance of 0.5999 with 0.2001 spent. + // Previously, this would count again as received funds which is not like any other explorer. + describe('change transactions', function() { + var isSpentStub = sinon.stub(); + // https://github.com/sinonjs/sinon/issues/176 + isSpentStub.withArgs('c0dfe181d45094c5c4e99b56be0699c4a6423b9e0086f6f8096ea8a5bfa57cff', 1).returns(true); + isSpentStub.withArgs('187ec93c565522f17605a8591a51c602eb1463f42df4b169c855a591ec952a04', 0).returns(false); + var changeNode = { + datadir: 'testdir', + network: Networks.testnet, + services: { + bitcoind: { + // c0d txid is spent, 187 is not + isSpent: isSpentStub, + on: sinon.spy() + } + } + }; + var inputs = [ + { + "address": "2NBMEXj3BM7C2k4HCjfqn1Q4mwezNUzmrs2", + "txid": "187ec93c565522f17605a8591a51c602eb1463f42df4b169c855a591ec952a04", + "inputIndex": 0, + "height": 310818, + "confirmations": 315943 + } + ]; + + var outputs = [ + { + "address": "2NBMEXj3BM7C2k4HCjfqn1Q4mwezNUzmrs2", + "txid": "c0dfe181d45094c5c4e99b56be0699c4a6423b9e0086f6f8096ea8a5bfa57cff", + "outputIndex": 1, + "height": 310280, + "satoshis": 80000000, + "script": "a914c695365fc599b2960d92cfd720c04e054c4cb2f987", + "confirmations": 316481 + }, + { + "address": "2NBMEXj3BM7C2k4HCjfqn1Q4mwezNUzmrs2", + "txid": "187ec93c565522f17605a8591a51c602eb1463f42df4b169c855a591ec952a04", + "outputIndex": 0, + "height": 310818, + "satoshis": 59990000, + "script": "a914c695365fc599b2960d92cfd720c04e054c4cb2f987", + "confirmations": 315943 + } + ]; + var changeAS = new AddressService({ + node: changeNode + }); + changeAS.getInputs = sinon.stub().callsArgWith(2, null, inputs); + changeAS.getOutputs = sinon.stub().callsArgWith(2, null, outputs); + it('should not count toward totalReceived or totalSpent', function(done) { + changeAS.getAddressSummary('2NBMEXj3BM7C2k4HCjfqn1Q4mwezNUzmrs2', {}, function(err, summary) { + should.not.exist(err); + summary.totalReceived.should.equal(80000000); + summary.totalSpent.should.equal(20010000); + summary.balance.should.equal(59990000); + summary.unconfirmedReceived.should.equal(0); + summary.unconfirmedSpent.should.equal(0); + summary.appearances.should.equal(2); + summary.unconfirmedAppearances.should.equal(0); + summary.txids.should.deep.equal( + [ + 'c0dfe181d45094c5c4e99b56be0699c4a6423b9e0086f6f8096ea8a5bfa57cff', + '187ec93c565522f17605a8591a51c602eb1463f42df4b169c855a591ec952a04' + ] + ); + done(); + }); + }) + }); + + // In this test, we have: + // * One confirmed output of 0.8 BTC sent to our address + // * One unconfirmed (mempool) output of 0.5999 BTC sent to our address, + // but this is a change tx. + // So none of the txs are spent in the db, but the 0.8 one is spent in the mempool. + // Most block explorers show this as a balance of 0.8 with 0.2001 unconfirmed spent. + describe('change transactions with mempool', function() { + var changeNode = { + datadir: 'testdir', + network: Networks.testnet, + services: { + bitcoind: { + isSpent: sinon.stub().returns(false), + on: sinon.spy() + } + } + }; + var inputs = [ + { + "address": "2NBMEXj3BM7C2k4HCjfqn1Q4mwezNUzmrs2", + "txid": "187ec93c565522f17605a8591a51c602eb1463f42df4b169c855a591ec952a04", + "inputIndex": 0, + "height": 626761, + "confirmations": 0 + } + ]; + + var outputs = [ + { + "address": "2NBMEXj3BM7C2k4HCjfqn1Q4mwezNUzmrs2", + "txid": "c0dfe181d45094c5c4e99b56be0699c4a6423b9e0086f6f8096ea8a5bfa57cff", + "outputIndex": 1, + "height": 310280, + "satoshis": 80000000, + "script": "a914c695365fc599b2960d92cfd720c04e054c4cb2f987", + "confirmations": 316481 + }, + { + "address": "2NBMEXj3BM7C2k4HCjfqn1Q4mwezNUzmrs2", + "txid": "187ec93c565522f17605a8591a51c602eb1463f42df4b169c855a591ec952a04", + "outputIndex": 0, + "height": 626761, + "satoshis": 59990000, + "script": "a914c695365fc599b2960d92cfd720c04e054c4cb2f987", + "confirmations": 0 + } + ]; + var changeAS = new AddressService({ + node: changeNode + }); + changeAS.getInputs = sinon.stub().callsArgWith(2, null, inputs); + changeAS.getOutputs = sinon.stub().callsArgWith(2, null, outputs); + var spentIndexSyncKey = changeAS._encodeSpentIndexSyncKey(new Buffer(outputs[0].txid, 'hex'), 1); + changeAS.mempoolSpentIndex[spentIndexSyncKey] = true; + it('should not deduct from totalReceived or add to totalSpent, and should add to unconfirmedSpent', function(done) { + changeAS.getAddressSummary('2NBMEXj3BM7C2k4HCjfqn1Q4mwezNUzmrs2', {}, function(err, summary) { + should.not.exist(err); + summary.totalReceived.should.equal(80000000); + summary.totalSpent.should.equal(0); + summary.balance.should.equal(80000000); + summary.unconfirmedReceived.should.equal(0); + summary.unconfirmedSpent.should.equal(20010000); + summary.appearances.should.equal(1); + summary.unconfirmedAppearances.should.equal(1); + summary.txids.should.deep.equal( + [ + 'c0dfe181d45094c5c4e99b56be0699c4a6423b9e0086f6f8096ea8a5bfa57cff', + '187ec93c565522f17605a8591a51c602eb1463f42df4b169c855a591ec952a04' + ] + ); + done(); + }); + }) + }); + + describe('multiple utxos', function() { + var changeNode = { + datadir: 'testdir', + network: Networks.testnet, + services: { + bitcoind: { + // c0d txid is spent, 187 is not + isSpent: sinon.stub().returns(false), + on: sinon.spy() + } + } + }; + var inputs = []; + + var outputs = [ + { + "address": "2NBMEXL8JwUrLxgdBKtFauiKivvHH8agdpK", + "txid": "4a14b9c1734b91681a22653ca890fe81429b49ffb4dac3e3f7bb8bee26c4b4a9", + "outputIndex": 1, + "height": 410826, + "satoshis": 110000000, + "script": "a914c69534eb2ce851c19dc0ff96e80b7a1c6298da4587", + "confirmations": 215952 + }, + { + "address": "2NBMEXL8JwUrLxgdBKtFauiKivvHH8agdpK", + "txid": "29daa591706b156e3b50e29b7586188f520d55b98db2806c3b243dd9620a7124", + "outputIndex": 0, + "height": 417750, + "satoshis": 141302, + "script": "a914c69534eb2ce851c19dc0ff96e80b7a1c6298da4587", + "confirmations": 209028 + }, + { + "address": "2NBMEXL8JwUrLxgdBKtFauiKivvHH8agdpK", + "txid": "29daa591706b156e3b50e29b7586188f520d55b98db2806c3b243dd9620a7124", + "outputIndex": 1, + "height": 417750, + "satoshis": 838698, + "script": "a914c69534eb2ce851c19dc0ff96e80b7a1c6298da4587", + "confirmations": 209028 + } + ]; + var changeAS = new AddressService({ + node: changeNode + }); + changeAS.getInputs = sinon.stub().callsArgWith(2, null, inputs); + changeAS.getOutputs = sinon.stub().callsArgWith(2, null, outputs); + it('should not create negative totalSpent due to multiple utxos in single tx', function(done) { + changeAS.getAddressSummary('2NBMEXj3BM7C2k4HCjfqn1Q4mwezNUzmrs2', {}, function(err, summary) { + should.not.exist(err); + summary.totalReceived.should.equal(110980000); + summary.totalSpent.should.equal(0); + summary.balance.should.equal(110980000); + summary.unconfirmedReceived.should.equal(0); + summary.unconfirmedSpent.should.equal(0); + summary.appearances.should.equal(2); + summary.unconfirmedAppearances.should.equal(0); + summary.txids.should.deep.equal( + [ + '4a14b9c1734b91681a22653ca890fe81429b49ffb4dac3e3f7bb8bee26c4b4a9', + '29daa591706b156e3b50e29b7586188f520d55b98db2806c3b243dd9620a7124' + ] + ); + done(); + }); + }) + }); }); });