diff --git a/integration/regtest-node.js b/integration/regtest-node.js index 932b1483..0c41524e 100644 --- a/integration/regtest-node.js +++ b/integration/regtest-node.js @@ -24,6 +24,7 @@ var should = chai.should(); var BitcoinRPC = require('bitcoind-rpc'); var index = require('..'); +var Transaction = index.Transaction; var BitcoreNode = index.Node; var AddressService = index.services.Address; var BitcoinService = index.services.Bitcoin; @@ -34,6 +35,8 @@ var client; describe('Node Functionality', function() { + var regtest; + before(function(done) { this.timeout(30000); @@ -51,6 +54,7 @@ describe('Node Functionality', function() { port: 18444, dnsSeeds: [ ] }); + regtest = bitcore.Networks.get('regtest'); var datadir = __dirname + '/data'; @@ -238,4 +242,373 @@ describe('Node Functionality', function() { }); }); }); + + describe('Address Functionality', function() { + var address; + var unspentOutput; + before(function() { + address = testKey.toAddress().toString(); + }); + it('should be able to get the balance of the test address', function(done) { + node.services.address.getBalance(address, false, function(err, balance) { + if (err) { + throw err; + } + balance.should.equal(10 * 1e8); + done(); + }); + }); + it('can get unspent outputs for address', function(done) { + node.services.address.getUnspentOutputs(address, false, function(err, results) { + if (err) { + throw err; + } + results.length.should.equal(1); + unspentOutput = results[0]; + done(); + }); + }); + it('correctly give the history for the address', function(done) { + var options = { + from: 0, + to: 10, + queryMempool: false + }; + node.services.address.getAddressHistory(address, options, function(err, results) { + if (err) { + throw err; + } + results.length.should.equal(1); + var info = results[0]; + info.address.should.equal(address); + info.satoshis.should.equal(10 * 1e8); + info.confirmations.should.equal(3); + info.timestamp.should.be.a('number'); + info.fees.should.be.within(190, 193); + info.outputIndexes.length.should.equal(1); + info.outputIndexes[0].should.be.within(0, 1); + info.inputIndexes.should.deep.equal([]); + info.tx.should.be.an.instanceof(Transaction); + done(); + }); + }); + describe('History', function() { + + this.timeout(20000); + + var testKey2; + var address2; + var testKey3; + var address3; + var testKey4; + var address4; + var testKey5; + var address5; + var testKey6; + var address6; + + before(function(done) { + /* jshint maxstatements: 50 */ + + testKey2 = bitcore.PrivateKey.fromWIF('cNfF4jXiLHQnFRsxaJyr2YSGcmtNYvxQYSakNhuDGxpkSzAwn95x'); + address2 = testKey2.toAddress().toString(); + + testKey3 = bitcore.PrivateKey.fromWIF('cVTYQbaFNetiZcvxzXcVMin89uMLC43pEBMy2etgZHbPPxH5obYt'); + address3 = testKey3.toAddress().toString(); + + testKey4 = bitcore.PrivateKey.fromWIF('cPNQmfE31H2oCUFqaHpfSqjDibkt7XoT2vydLJLDHNTvcddCesGw'); + address4 = testKey4.toAddress().toString(); + + testKey5 = bitcore.PrivateKey.fromWIF('cVrzm9gCmnzwEVMGeCxY6xLVPdG3XWW97kwkFH3H3v722nb99QBF'); + address5 = testKey5.toAddress().toString(); + + testKey6 = bitcore.PrivateKey.fromWIF('cPfMesNR2gsQEK69a6xe7qE44CZEZavgMUak5hQ74XDgsRmmGBYF'); + address6 = testKey6.toAddress().toString(); + + var tx = new Transaction(); + tx.from(unspentOutput); + tx.to(address, 1 * 1e8); + tx.to(address, 2 * 1e8); + tx.to(address, 0.5 * 1e8); + tx.to(address, 3 * 1e8); + tx.fee(10000); + tx.change(address); + tx.sign(testKey); + + node.services.bitcoind.sendTransaction(tx.serialize()); + + function mineBlock(next) { + client.generate(1, function(err, response) { + if (err) { + throw err; + } + should.exist(response); + next(); + }); + } + + client.generate(1, function(err, response) { + if (err) { + throw err; + } + should.exist(response); + node.once('synced', function() { + node.services.address.getUnspentOutputs(address, false, function(err, results) { + /* jshint maxstatements: 50 */ + if (err) { + throw err; + } + results.length.should.equal(5); + + async.series([ + function(next) { + var tx2 = new Transaction(); + tx2.from(results[0]); + tx2.to(address2, results[0].satoshis - 10000); + tx2.change(address); + tx2.sign(testKey); + node.services.bitcoind.sendTransaction(tx2.serialize()); + mineBlock(next); + }, function(next) { + var tx3 = new Transaction(); + tx3.from(results[1]); + tx3.to(address3, results[1].satoshis - 10000); + tx3.change(address); + tx3.sign(testKey); + node.services.bitcoind.sendTransaction(tx3.serialize()); + mineBlock(next); + }, function(next) { + var tx4 = new Transaction(); + tx4.from(results[2]); + tx4.to(address4, results[2].satoshis - 10000); + tx4.change(address); + tx4.sign(testKey); + node.services.bitcoind.sendTransaction(tx4.serialize()); + mineBlock(next); + }, function(next) { + var tx5 = new Transaction(); + tx5.from(results[3]); + tx5.from(results[4]); + tx5.to(address5, results[3].satoshis - 10000); + tx5.to(address6, results[4].satoshis - 10000); + tx5.change(address); + tx5.sign(testKey); + node.services.bitcoind.sendTransaction(tx5.serialize()); + mineBlock(next); + } + ], function(err) { + if (err) { + throw err; + } + node.once('synced', function() { + done(); + }); + }); + }); + }); + }); + + }); + + it('five addresses', function(done) { + var addresses = [ + address2, + address3, + address4, + address5, + address6 + ]; + var options = {}; + node.services.address.getAddressHistory(addresses, options, function(err, history) { + if (err) { + throw err; + } + history.length.should.equal(5); + history[0].height.should.equal(157); + history[0].confirmations.should.equal(1); + history[1].height.should.equal(157); + history[2].height.should.equal(156); + history[2].address.should.equal(address4); + history[3].height.should.equal(155); + history[3].address.should.equal(address3); + history[4].height.should.equal(154); + history[4].address.should.equal(address2); + history[4].satoshis.should.equal(99990000); + history[4].confirmations.should.equal(4); + done(); + }); + }); + + it('five addresses (limited by height)', function(done) { + var addresses = [ + address2, + address3, + address4, + address5, + address6 + ]; + var options = { + start: 157, + end: 156 + }; + node.services.address.getAddressHistory(addresses, options, function(err, history) { + if (err) { + throw err; + } + history.length.should.equal(3); + history[0].height.should.equal(157); + history[0].confirmations.should.equal(1); + history[1].height.should.equal(157); + history[2].height.should.equal(156); + history[2].address.should.equal(address4); + done(); + }); + }); + + it('five addresses (paginated by index)', function(done) { + var addresses = [ + address2, + address3, + address4, + address5, + address6 + ]; + var options = { + from: 0, + to: 3 + }; + node.services.address.getAddressHistory(addresses, options, function(err, history) { + if (err) { + throw err; + } + history.length.should.equal(3); + history[0].height.should.equal(157); + history[0].confirmations.should.equal(1); + history[1].height.should.equal(157); + history[2].height.should.equal(156); + history[2].address.should.equal(address4); + done(); + }); + }); + + it('one address with sending and receiving', function(done) { + var addresses = [ + address + ]; + var options = {}; + node.services.address.getAddressHistory(addresses, options, function(err, history) { + if (err) { + throw err; + } + history.length.should.equal(6); + history[0].height.should.equal(157); + history[0].inputIndexes.should.deep.equal([0, 1]); + history[0].outputIndexes.should.deep.equal([2]); + history[0].confirmations.should.equal(1); + history[1].height.should.equal(156); + history[2].height.should.equal(155); + history[3].height.should.equal(154); + history[4].height.should.equal(153); + history[4].satoshis.should.equal(-10000); + history[4].outputIndexes.should.deep.equal([0, 1, 2, 3, 4]); + history[4].inputIndexes.should.deep.equal([0]); + history[5].height.should.equal(150); + history[5].satoshis.should.equal(10 * 1e8); + done(); + }); + }); + + describe('Pagination', function() { + it('from 0 to 1', function(done) { + var options = { + from: 0, + to: 1 + }; + node.services.address.getAddressHistory(address, options, function(err, history) { + if (err) { + throw err; + } + history.length.should.equal(1); + history[0].height.should.equal(157); + done(); + }); + }); + it('from 1 to 2', function(done) { + var options = { + from: 1, + to: 2 + }; + node.services.address.getAddressHistory(address, options, function(err, history) { + if (err) { + throw err; + } + history.length.should.equal(1); + history[0].height.should.equal(156); + done(); + }); + }); + it('from 2 to 3', function(done) { + var options = { + from: 2, + to: 3 + }; + node.services.address.getAddressHistory(address, options, function(err, history) { + if (err) { + throw err; + } + history.length.should.equal(1); + history[0].height.should.equal(155); + done(); + }); + }); + it('from 3 to 4', function(done) { + var options = { + from: 3, + to: 4 + }; + node.services.address.getAddressHistory(address, options, function(err, history) { + if (err) { + throw err; + } + history.length.should.equal(1); + history[0].height.should.equal(154); + done(); + }); + }); + it('from 4 to 5', function(done) { + var options = { + from: 4, + to: 5 + }; + node.services.address.getAddressHistory(address, options, function(err, history) { + if (err) { + throw err; + } + history.length.should.equal(1); + history[0].height.should.equal(153); + history[0].satoshis.should.equal(-10000); + history[0].outputIndexes.should.deep.equal([0, 1, 2, 3, 4]); + history[0].inputIndexes.should.deep.equal([0]); + done(); + }); + }); + it('from 5 to 6', function(done) { + var options = { + from: 5, + to: 6 + }; + node.services.address.getAddressHistory(address, options, function(err, history) { + if (err) { + throw err; + } + history.length.should.equal(1); + history[0].height.should.equal(150); + history[0].satoshis.should.equal(10 * 1e8); + done(); + }); + }); + + }); + }); + }); }); diff --git a/lib/services/address/history.js b/lib/services/address/history.js new file mode 100644 index 00000000..ad36b2d6 --- /dev/null +++ b/lib/services/address/history.js @@ -0,0 +1,188 @@ +'use strict'; + +var bitcore = require('bitcore'); +var async = require('async'); +var _ = bitcore.deps._; + +/** + * This represents an instance that keeps track of data over a series of + * asynchronous I/O calls to get the transaction history for a group of + * addresses. History can be queried by start and end block heights to limit large sets + * of results (uses leveldb key streaming). See AddressService.prototype.getAddressHistory + * for complete documentation about options. + */ +function AddressHistory(args) { + this.node = args.node; + this.options = args.options; + + if(Array.isArray(args.addresses)) { + this.addresses = args.addresses; + } else { + this.addresses = [args.addresses]; + } + this.transactionInfo = []; + this.transactions = {}; + this.sortedArray = []; +} + +AddressHistory.MAX_ADDRESS_QUERIES = 20; +AddressHistory.MAX_TX_QUERIES = 10; + +AddressHistory.prototype.get = function(callback) { + var self = this; + + // TODO check for mempool inputs and outputs by a group of addresses, currently + // each address individually loops through the mempool and does not check input scripts. + + async.eachLimit( + self.addresses, + AddressHistory.MAX_ADDRESS_QUERIES, + function(address, next) { + self.getTransactionInfo(address, next); + }, + function(err) { + if (err) { + return callback(err); + } + + async.eachSeries( + self.transactionInfo, + function(txInfo, next) { + self.getDetailedInfo(txInfo, next); + }, + function(err) { + if (err) { + return callback(err); + } + self.sortTransactionsIntoArray(); + self.paginateSortedArray(); + callback(null, self.sortedArray); + } + ); + } + ); +}; + +AddressHistory.prototype.getTransactionInfo = function(address, next) { + var self = this; + + var args = { + start: self.options.start, + end: self.options.end, + queryMempool: _.isUndefined(self.options.queryMempool) ? true : self.options.queryMempool + }; + + var outputs; + var inputs; + + async.parallel([ + function(done) { + self.node.services.address.getOutputs(address, args, function(err, result) { + if (err) { + return done(err); + } + outputs = result; + done(); + }); + }, + function(done) { + self.node.services.address.getInputs(address, args, function(err, result) { + if (err) { + return done(err); + } + inputs = result; + done(); + }); + } + ], function(err) { + if (err) { + return next(err); + } + self.transactionInfo = self.transactionInfo.concat(outputs, inputs); + next(); + }); +}; + +AddressHistory.sortByHeight = function(a, b) { + // TODO consider timestamp for mempool transactions + return a.height < b.height; +}; + +AddressHistory.prototype.paginateSortedArray = function() { + if (!_.isUndefined(this.options.from) && !_.isUndefined(this.options.to)) { + this.sortedArray = this.sortedArray.slice(this.options.from, this.options.to); + } +}; + +AddressHistory.prototype.getDetailedInfo = function(txInfo, next) { + var self = this; + var queryMempool = _.isUndefined(self.options.queryMempool) ? true : self.options.queryMempool; + + if (self.transactions[txInfo.address] && self.transactions[txInfo.address][txInfo.txid]) { + self.amendDetailedInfoWithSatoshis(txInfo); + setImmediate(next); + } else { + self.node.services.db.getTransactionWithBlockInfo( + txInfo.txid, + queryMempool, + function(err, transaction) { + if (err) { + return next(err); + } + + transaction.populateInputs(self.node.services.db, [], function(err) { + if(err) { + return next(err); + } + var confirmations = 0; + if (transaction.__height >= 0) { + confirmations = self.node.services.db.tip.__height - transaction.__height + 1; + } + + if (!self.transactions[txInfo.address]) { + self.transactions[txInfo.address] = {}; + } + + self.transactions[txInfo.address][txInfo.txid] = { + address: txInfo.address, + satoshis: 0, + height: transaction.__height, + confirmations: confirmations, + timestamp: transaction.__timestamp, + // TODO bitcore should return null instead of throwing error on coinbase + fees: !transaction.isCoinbase() ? transaction.getFee() : null, + outputIndexes: [], + inputIndexes: [], + tx: transaction + }; + + self.amendDetailedInfoWithSatoshis(txInfo); + next(); + }); + } + ); + } +}; + +AddressHistory.prototype.amendDetailedInfoWithSatoshis = function(txInfo) { + var historyItem = this.transactions[txInfo.address][txInfo.txid]; + if (txInfo.outputIndex >= 0) { + historyItem.outputIndexes.push(txInfo.outputIndex); + historyItem.satoshis += txInfo.satoshis; + } else if (txInfo.inputIndex >= 0){ + historyItem.inputIndexes.push(txInfo.inputIndex); + historyItem.satoshis -= historyItem.tx.inputs[txInfo.inputIndex].output.satoshis; + } +}; + +AddressHistory.prototype.sortTransactionsIntoArray = function() { + this.sortedArray = []; + for(var address in this.transactions) { + for(var txid in this.transactions[address]) { + this.sortedArray.push(this.transactions[address][txid]); + } + } + this.sortedArray.sort(AddressHistory.sortByHeight); +}; + +module.exports = AddressHistory; diff --git a/lib/services/address.js b/lib/services/address/index.js similarity index 66% rename from lib/services/address.js rename to lib/services/address/index.js index 5cdf2362..12eda69a 100644 --- a/lib/services/address.js +++ b/lib/services/address/index.js @@ -1,11 +1,10 @@ 'use strict'; -var BaseService = require('../service'); +var BaseService = require('../../service'); var inherits = require('util').inherits; var async = require('async'); -var index = require('../'); +var index = require('../../'); var log = index.log; -var levelup = require('levelup'); var errors = index.errors; var bitcore = require('bitcore'); var $ = bitcore.util.preconditions; @@ -13,6 +12,7 @@ var _ = bitcore.deps._; var EventEmitter = require('events').EventEmitter; var PublicKey = bitcore.PublicKey; var Address = bitcore.Address; +var AddressHistory = require('./history'); var AddressService = function(options) { BaseService.call(this, options); @@ -152,8 +152,8 @@ AddressService.prototype.blockHandler = function(block, addOutput, callback) { var txmessages = {}; var outputLength = outputs.length; - for (var j = 0; j < outputLength; j++) { - var output = outputs[j]; + for (var outputIndex = 0; outputIndex < outputLength; outputIndex++) { + var output = outputs[outputIndex]; var script = output.script; @@ -170,16 +170,26 @@ AddressService.prototype.blockHandler = function(block, addOutput, callback) { continue; } - var outputIndex = j; - // TODO: expose block timestamp as a date object in bitcore? - var timestamp = block.header.timestamp; + // We need to use the height for indexes (and not the timestamp) because the + // the timestamp has unreliable sequential ordering. The next block + // can have a time that is previous to the previous block (however not + // less than the mean of the 11 previous blocks) and not greater than 2 + // hours in the future. var height = block.__height; var addressStr = address.toString(); var scriptHex = output._scriptBuffer.toString('hex'); - var key = [AddressService.PREFIXES.OUTPUTS, addressStr, timestamp, txid, outputIndex].join('-'); - var value = [output.satoshis, scriptHex, height].join(':'); + // To lookup outputs by address and height + var key = [ + AddressService.PREFIXES.OUTPUTS, + addressStr, + height, + txid, + outputIndex + ].join('-'); + + var value = [output.satoshis, scriptHex].join(':'); operations.push({ type: action, @@ -213,13 +223,38 @@ AddressService.prototype.blockHandler = function(block, addOutput, callback) { continue; } - for(var k = 0; k < inputs.length; k++) { - var input = inputs[k].toObject(); - operations.push({ - type: action, - key: [AddressService.PREFIXES.SPENTS, input.prevTxId, input.outputIndex].join('-'), - value: [txid, k].join(':') - }); + for(var inputIndex = 0; inputIndex < inputs.length; inputIndex++) { + + var input = inputs[inputIndex]; + var inputAddress = input.script.toAddress(this.node.network); + + if (inputAddress) { + + var inputObject = input.toObject(); + var inputAddressStr = inputAddress.toString(); + + var height = block.__height; + + // To be able to query inputs by address and spent height + var inputKey = [ + AddressService.PREFIXES.SPENTS, + inputAddressStr, + height, + inputObject.prevTxId, + inputObject.outputIndex + ].join('-'); + + var inputValue = [ + txid, + inputIndex + ].join(':'); + + operations.push({ + type: action, + key: inputKey, + value: inputValue + }); + } } } @@ -323,17 +358,120 @@ AddressService.prototype.getBalance = function(address, queryMempool, callback) }); }; -AddressService.prototype.getOutputs = function(addressStr, queryMempool, callback) { +/** + * @param {String} addressStr - The relevant address + * @param {Object} options - Additional options for query the outputs + * @param {Number} [options.start] - The relevant start block height + * @param {Number} [options.end] - The relevant end block height + * @param {Boolean} [options.queryMempool] - Include the mempool in the results + * @param {Function} callback + */ +AddressService.prototype.getInputs = function(addressStr, options, callback) { + var self = this; - var outputs = []; - var key = [AddressService.PREFIXES.OUTPUTS, addressStr].join('-'); + var inputs = []; + var stream; + + if (options.start && options.end) { + + // The positions will be flipped because the end position should be greater + // than the starting position for the stream, and we'll add one to the end key + // so that it's included in the results. + + var endKey = [AddressService.PREFIXES.SPENTS, addressStr, options.start + 1].join('-'); + var startKey = [AddressService.PREFIXES.SPENTS, addressStr, options.end].join('-'); + + stream = this.node.services.db.store.createReadStream({ + start: startKey, + end: endKey + }); + } else { + var allKey = [AddressService.PREFIXES.SPENTS, addressStr].join('-'); + stream = this.node.services.db.store.createReadStream({ + start: allKey, + end: allKey + '~' + }); + } + + stream.on('data', function(data) { + + var key = data.key.split('-'); + var value = data.value.split(':'); + + var blockHeight = Number(key[2]); + + var output = { + address: addressStr, + txid: value[0], + inputIndex: Number(value[1]), + height: blockHeight, + confirmations: self.node.services.db.tip.__height - blockHeight + 1 + }; + + inputs.push(output); - var stream = this.node.services.db.store.createReadStream({ - start: key, - end: key + '~' }); + var error; + + stream.on('error', function(streamError) { + if (streamError) { + error = streamError; + } + }); + + stream.on('close', function() { + if (error) { + return callback(error); + } + + // TODO include results from mempool + + callback(null, inputs); + + }); + + return stream; + +}; + +/** + * @param {String} addressStr - The relevant address + * @param {Object} options - Additional options for query the outputs + * @param {Number} [options.start] - The relevant start block height + * @param {Number} [options.end] - The relevant end block height + * @param {Boolean} [options.queryMempool] - Include the mempool in the results + * @param {Function} callback + */ +AddressService.prototype.getOutputs = function(addressStr, options, callback) { + var self = this; + $.checkArgument(_.isObject(options), 'Second argument is expected to be an options object.'); + $.checkArgument(_.isFunction(callback), 'Third argument is expected to be a callback function.'); + + var outputs = []; + var stream; + + if (options.start && options.end) { + + // The positions will be flipped because the end position should be greater + // than the starting position for the stream, and we'll add one to the end key + // so that it's included in the results. + var endKey = [AddressService.PREFIXES.OUTPUTS, addressStr, options.start + 1].join('-'); + var startKey = [AddressService.PREFIXES.OUTPUTS, addressStr, options.end].join('-'); + + stream = this.node.services.db.store.createReadStream({ + start: startKey, + end: endKey + }); + } else { + var allKey = [AddressService.PREFIXES.OUTPUTS, addressStr].join('-'); + stream = this.node.services.db.store.createReadStream({ + start: allKey, + end: allKey + '~' + }); + } + stream.on('data', function(data) { var key = data.key.split('-'); @@ -343,11 +481,10 @@ AddressService.prototype.getOutputs = function(addressStr, queryMempool, callbac address: addressStr, txid: key[3], outputIndex: Number(key[4]), - timestamp: Number(key[2]), + height: Number(key[2]), satoshis: Number(value[0]), script: value[1], - blockHeight: Number(value[2]), - confirmations: self.node.services.db.tip.__height - Number(value[2]) + 1 + confirmations: self.node.services.db.tip.__height - Number(key[2]) + 1 }; outputs.push(output); @@ -367,10 +504,9 @@ AddressService.prototype.getOutputs = function(addressStr, queryMempool, callbac return callback(error); } - if(queryMempool) { + if(options.queryMempool) { outputs = outputs.concat(self.node.services.bitcoind.getMempoolOutputs(addressStr)); } - callback(null, outputs); }); @@ -407,7 +543,7 @@ AddressService.prototype.getUnspentOutputsForAddress = function(address, queryMe var self = this; - this.getOutputs(address, queryMempool, function(err, outputs) { + this.getOutputs(address, {queryMempool: queryMempool}, function(err, outputs) { if (err) { return callback(err); } else if(!outputs.length) { @@ -439,148 +575,25 @@ AddressService.prototype.isSpent = function(output, queryMempool, callback) { }); }; -AddressService.prototype.getSpendInfoForOutput = function(txid, outputIndex, callback) { - var self = this; - - var key = [AddressService.PREFIXES.SPENTS, txid, outputIndex].join('-'); - this.node.services.db.store.get(key, function(err, value) { - if(err) { - return callback(err); - } - - value = value.split(':'); - - var info = { - txid: value[0], - inputIndex: value[1] - }; - - callback(null, info); - }); -}; - -AddressService.prototype.getAddressHistory = function(addresses, queryMempool, callback) { - var self = this; - - if(!Array.isArray(addresses)) { - addresses = [addresses]; - } - - var history = []; - - async.eachSeries(addresses, function(address, next) { - self.getAddressHistoryForAddress(address, queryMempool, function(err, h) { - if(err) { - return next(err); - } - - history = history.concat(h); - next(); - }); - }, function(err) { - callback(err, history); - }); -}; - -AddressService.prototype.getAddressHistoryForAddress = function(address, queryMempool, callback) { - var self = this; - - var txinfos = {}; - - function getTransactionInfo(txid, callback) { - if(txinfos[txid]) { - return callback(null, txinfos[txid]); - } - - self.node.services.db.getTransactionWithBlockInfo(txid, queryMempool, function(err, transaction) { - if(err) { - return callback(err); - } - - transaction.populateInputs(self.node.services.db, [], function(err) { - if(err) { - return callback(err); - } - - var confirmations = 0; - if(transaction.__height >= 0) { - confirmations = self.node.services.db.tip.__height - transaction.__height; - confirmations = self.node.services.db.tip.__height - transaction.__height + 1; - } - - txinfos[transaction.hash] = { - address: address, - satoshis: 0, - height: transaction.__height, - confirmations: confirmations, - timestamp: transaction.__timestamp, - // TODO bitcore should return null instead of throwing error on coinbase - fees: !transaction.isCoinbase() ? transaction.getFee() : null, - outputIndexes: [], - inputIndexes: [], - tx: transaction - }; - - callback(null, txinfos[transaction.hash]); - }); - }); - } - - this.getOutputs(address, queryMempool, function(err, outputs) { - if(err) { - return callback(err); - } - - async.eachSeries( - outputs, - function(output, next) { - getTransactionInfo(output.txid, function(err, txinfo) { - if(err) { - return next(err); - } - - txinfo.outputIndexes.push(output.outputIndex); - txinfo.satoshis += output.satoshis; - - self.getSpendInfoForOutput(output.txid, output.outputIndex, function(err, spendInfo) { - if(err instanceof levelup.errors.NotFoundError) { - return next(); - } else if(err) { - return next(err); - } - - getTransactionInfo(spendInfo.txid, function(err, txinfo) { - if(err) { - return next(err); - } - - txinfo.inputIndexes.push(spendInfo.inputIndex); - txinfo.satoshis -= txinfo.tx.inputs[spendInfo.inputIndex].output.satoshis; - next(); - }); - }); - }); - }, - function(err) { - if(err) { - return callback(err); - } - - // convert to array - var history = []; - for(var txid in txinfos) { - history.push(txinfos[txid]); - } - - // sort by height - history.sort(function(a, b) { - return a.height > b.height; - }); - - callback(null, history); - } - ); +/** + * This will give the history for many addresses limited by a range of dates (to limit + * the database lookup times) and/or paginated to limit the results length. + * @param {Array} addresses - An array of addresses + * @param {Object} options - The options to limit the query + * @param {Number} [options.from] - The pagination "from" index + * @param {Number} [options.to] - The pagination "to" index + * @param {Number} [options.start] - The beginning block height + * @param {Number} [options.end] - The ending block height + * @param {Boolean} [options.queryMempool] - Include the mempool in the query + * @param {Function} callback + */ +AddressService.prototype.getAddressHistory = function(addresses, options, callback) { + var history = new AddressHistory({ + node: this.node, + options: options, + addresses: addresses }); + history.get(callback); }; module.exports = AddressService; diff --git a/test/services/address/history.unit.js b/test/services/address/history.unit.js new file mode 100644 index 00000000..06970b4b --- /dev/null +++ b/test/services/address/history.unit.js @@ -0,0 +1,574 @@ +'use strict'; + +var should = require('chai').should(); +var sinon = require('sinon'); +var Transaction = require('../../../lib/transaction'); +var AddressHistory = require('../../../lib/services/address/history'); + +describe('Address Service History', function() { + + var address = '12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX'; + + describe('@constructor', function() { + it('will construct a new instance', function() { + var node = {}; + var options = {}; + var addresses = [address]; + var history = new AddressHistory({ + node: node, + options: options, + addresses: addresses + }); + history.should.be.instanceof(AddressHistory); + history.node.should.equal(node); + history.options.should.equal(options); + history.addresses.should.equal(addresses); + history.transactions.should.deep.equal({}); + history.transactionInfo.should.deep.equal([]); + history.sortedArray.should.deep.equal([]); + }); + it('will set addresses an array if only sent a string', function() { + var history = new AddressHistory({ + node: {}, + options: {}, + addresses: address + }); + history.addresses.should.deep.equal([address]); + }); + }); + + describe('#get', function() { + it('will complete the async each limit series', function(done) { + var addresses = [address]; + var history = new AddressHistory({ + node: {}, + options: {}, + addresses: addresses + }); + var expected = [{}]; + history.sortedArray = expected; + history.transactionInfo = [{}]; + history.getTransactionInfo = sinon.stub().callsArg(1); + history.paginateSortedArray = sinon.stub(); + history.getDetailedInfo = sinon.stub().callsArg(1); + history.sortTransactionsIntoArray = sinon.stub(); + history.get(function(err, results) { + if (err) { + throw err; + } + history.getTransactionInfo.callCount.should.equal(1); + history.getDetailedInfo.callCount.should.equal(1); + history.sortTransactionsIntoArray.callCount.should.equal(1); + history.paginateSortedArray.callCount.should.equal(1); + results.should.equal(expected); + done(); + }); + }); + it('handle an error from getDetailedInfo', function(done) { + var addresses = [address]; + var history = new AddressHistory({ + node: {}, + options: {}, + addresses: addresses + }); + var expected = [{}]; + history.sortedArray = expected; + history.transactionInfo = [{}]; + history.getTransactionInfo = sinon.stub().callsArg(1); + history.paginateSortedArray = sinon.stub(); + history.getDetailedInfo = sinon.stub().callsArgWith(1, new Error('test')); + history.get(function(err) { + err.message.should.equal('test'); + done(); + }); + }); + it('handle an error from getTransactionInfo', function(done) { + var addresses = [address]; + var history = new AddressHistory({ + node: {}, + options: {}, + addresses: addresses + }); + var expected = [{}]; + history.sortedArray = expected; + history.transactionInfo = [{}]; + history.getTransactionInfo = sinon.stub().callsArgWith(1, new Error('test')); + history.get(function(err) { + err.message.should.equal('test'); + done(); + }); + }); + }); + + describe('#getTransactionInfo', function() { + it('will handle an error from getInputs', function(done) { + var history = new AddressHistory({ + node: { + services: { + address: { + getOutputs: sinon.stub().callsArgWith(2, null, []), + getInputs: sinon.stub().callsArgWith(2, new Error('test')) + } + } + }, + options: {}, + addresses: [] + }); + history.getTransactionInfo(address, function(err) { + err.message.should.equal('test'); + done(); + }); + }); + it('will handle an error from getOutputs', function(done) { + var history = new AddressHistory({ + node: { + services: { + address: { + getOutputs: sinon.stub().callsArgWith(2, new Error('test')), + getInputs: sinon.stub().callsArgWith(2, null, []) + } + } + }, + options: {}, + addresses: [] + }); + history.getTransactionInfo(address, function(err) { + err.message.should.equal('test'); + done(); + }); + }); + it('will call getOutputs and getInputs with the correct options', function() { + var startTimestamp = 1438289011844; + var endTimestamp = 1438289012412; + var expectedArgs = { + start: new Date(startTimestamp * 1000), + end: new Date(endTimestamp * 1000), + queryMempool: true + }; + var history = new AddressHistory({ + node: { + services: { + address: { + getOutputs: sinon.stub().callsArgWith(2, null, []), + getInputs: sinon.stub().callsArgWith(2, null, []) + } + } + }, + options: { + start: new Date(startTimestamp * 1000), + end: new Date(endTimestamp * 1000), + queryMempool: true + }, + addresses: [] + }); + history.transactionInfo = [{}]; + history.getTransactionInfo(address, function(err) { + if (err) { + throw err; + } + history.node.services.address.getOutputs.args[0][1].should.deep.equal(expectedArgs); + history.node.services.address.getInputs.args[0][1].should.deep.equal(expectedArgs); + }); + }); + it('will handle empty results from getOutputs and getInputs', function() { + var history = new AddressHistory({ + node: { + services: { + address: { + getOutputs: sinon.stub().callsArgWith(2, null, []), + getInputs: sinon.stub().callsArgWith(2, null, []) + } + } + }, + options: {}, + addresses: [] + }); + history.transactionInfo = [{}]; + history.getTransactionInfo(address, function(err) { + if (err) { + throw err; + } + history.transactionInfo.length.should.equal(1); + history.node.services.address.getOutputs.args[0][0].should.equal(address); + }); + }); + it('will concatenate outputs and inputs', function() { + var history = new AddressHistory({ + node: { + services: { + address: { + getOutputs: sinon.stub().callsArgWith(2, null, [{}]), + getInputs: sinon.stub().callsArgWith(2, null, [{}]) + } + } + }, + options: {}, + addresses: [] + }); + history.transactionInfo = [{}]; + history.getTransactionInfo(address, function(err) { + if (err) { + throw err; + } + history.transactionInfo.length.should.equal(3); + history.node.services.address.getOutputs.args[0][0].should.equal(address); + }); + }); + }); + + describe('@sortByHeight', function() { + it('will sort latest to oldest using height', function() { + var transactionInfo = [ + { + height: 12 + }, + { + height: 14, + }, + { + height: 13 + } + ]; + transactionInfo.sort(AddressHistory.sortByHeight); + transactionInfo[0].height.should.equal(14); + transactionInfo[1].height.should.equal(13); + transactionInfo[2].height.should.equal(12); + }); + }); + + describe('#paginateSortedArray', function() { + it('from 0 to 2', function() { + var history = new AddressHistory({ + node: {}, + options: { + from: 0, + to: 2 + }, + addresses: [] + }); + history.sortedArray = [ + { + height: 14 + }, + { + height: 13, + }, + { + height: 12 + } + ]; + history.paginateSortedArray(); + history.sortedArray.length.should.equal(2); + history.sortedArray[0].height.should.equal(14); + history.sortedArray[1].height.should.equal(13); + }); + it('from 0 to 4 (exceeds length)', function() { + var history = new AddressHistory({ + node: {}, + options: { + from: 0, + to: 4 + }, + addresses: [] + }); + history.sortedArray = [ + { + height: 14 + }, + { + height: 13, + }, + { + height: 12 + } + ]; + history.paginateSortedArray(); + history.sortedArray.length.should.equal(3); + history.sortedArray[0].height.should.equal(14); + history.sortedArray[1].height.should.equal(13); + history.sortedArray[2].height.should.equal(12); + }); + it('from 0 to 1', function() { + var history = new AddressHistory({ + node: {}, + options: { + from: 0, + to: 1 + }, + addresses: [] + }); + history.sortedArray = [ + { + height: 14 + }, + { + height: 13, + }, + { + height: 12 + } + ]; + history.paginateSortedArray(); + history.sortedArray.length.should.equal(1); + history.sortedArray[0].height.should.equal(14); + }); + it('from 2 to 3', function() { + var history = new AddressHistory({ + node: {}, + options: { + from: 2, + to: 3 + }, + addresses: [] + }); + history.sortedArray = [ + { + height: 14 + }, + { + height: 13, + }, + { + height: 12 + } + ]; + history.paginateSortedArray(); + history.sortedArray.length.should.equal(1); + history.sortedArray[0].height.should.equal(12); + }); + it('from 10 to 20 (out of range)', function() { + var history = new AddressHistory({ + node: {}, + options: { + from: 10, + to: 20 + }, + addresses: [] + }); + history.sortedArray = [ + { + height: 14 + }, + { + height: 13, + }, + { + height: 12 + } + ]; + history.paginateSortedArray(); + history.sortedArray.length.should.equal(0); + }); + }); + + describe('#getDetailedInfo', function() { + it('will add additional information to existing this.transactions', function() { + var txid = '46f24e0c274fc07708b781963576c4c5d5625d926dbb0a17fa865dcd9fe58ea0'; + var history = new AddressHistory({ + node: { + services: { + db: { + getTransactionWithBlockInfo: sinon.stub() + } + } + }, + options: {}, + addresses: [] + }); + history.transactions[txid] = {}; + history.amendDetailedInfoWithSatoshis = sinon.stub(); + history.getDetailedInfo(txid, function(err) { + if (err) { + throw err; + } + history.amendDetailedInfoWithSatoshis.callCount.should.equal(1); + history.node.services.db.getTransactionsWithBlockInfo.callCount.should.equal(0); + }); + }); + it('will handle error from getTransactionFromBlock', function() { + var txid = '46f24e0c274fc07708b781963576c4c5d5625d926dbb0a17fa865dcd9fe58ea0'; + var history = new AddressHistory({ + node: { + services: { + db: { + getTransactionWithBlockInfo: sinon.stub().callsArgWith(2, new Error('test')), + } + } + }, + options: {}, + addresses: [] + }); + history.getDetailedInfo(txid, function(err) { + err.message.should.equal('test'); + }); + }); + it('will handle error from populateInputs', function() { + var txid = '46f24e0c274fc07708b781963576c4c5d5625d926dbb0a17fa865dcd9fe58ea0'; + var history = new AddressHistory({ + node: { + services: { + db: { + getTransactionWithBlockInfo: sinon.stub().callsArgWith(2, null, { + populateInputs: sinon.stub().callsArgWith(2, new Error('test')) + }), + } + } + }, + options: {}, + addresses: [] + }); + history.getDetailedInfo(txid, function(err) { + err.message.should.equal('test'); + }); + }); + it('will set this.transactions with correct information', function() { + // block #314159 + // txid 30169e8bf78bc27c4014a7aba3862c60e2e3cce19e52f1909c8255e4b7b3174e + // outputIndex 1 + var txAddress = '1Cj4UZWnGWAJH1CweTMgPLQMn26WRMfXmo'; + var txString = '0100000001a08ee59fcd5d86fa170abb6d925d62d5c5c476359681b70877c04f270c4ef246000000008a47304402203fb9b476bb0c37c9b9ed5784ebd67ae589492be11d4ae1612be29887e3e4ce750220741ef83781d1b3a5df8c66fa1957ad0398c733005310d7d9b1d8c2310ef4f74c0141046516ad02713e51ecf23ac9378f1069f9ae98e7de2f2edbf46b7836096e5dce95a05455cc87eaa1db64f39b0c63c0a23a3b8df1453dbd1c8317f967c65223cdf8ffffffff02b0a75fac000000001976a91484b45b9bf3add8f7a0f3daad305fdaf6b73441ea88ac20badc02000000001976a914809dc14496f99b6deb722cf46d89d22f4beb8efd88ac00000000'; + var previousTxString = '010000000155532fad2869bb951b0bd646a546887f6ee668c4c0ee13bf3f1c4bce6d6e3ed9000000008c4930460221008540795f4ef79b1d2549c400c61155ca5abbf3089c84ad280e1ba6db2a31abce022100d7d162175483d51174d40bba722e721542c924202a0c2970b07e680b51f3a0670141046516ad02713e51ecf23ac9378f1069f9ae98e7de2f2edbf46b7836096e5dce95a05455cc87eaa1db64f39b0c63c0a23a3b8df1453dbd1c8317f967c65223cdf8ffffffff02f0af3caf000000001976a91484b45b9bf3add8f7a0f3daad305fdaf6b73441ea88ac80969800000000001976a91421277e65777760d1f3c7c982ba14ed8f934f005888ac00000000'; + var transaction = new Transaction(); + var previousTransaction = new Transaction(); + previousTransaction.fromString(previousTxString); + var previousTransactionTxid = '46f24e0c274fc07708b781963576c4c5d5625d926dbb0a17fa865dcd9fe58ea0'; + transaction.fromString(txString); + var txid = transaction.hash; + transaction.__blockHash = '00000000000000001bb82a7f5973618cfd3185ba1ded04dd852a653f92a27c45'; + transaction.__height = 314159; + transaction.__timestamp = 1407292005; + var history = new AddressHistory({ + node: { + services: { + db: { + tip: { + __height: 314159 + }, + getTransactionWithBlockInfo: sinon.stub().callsArgWith(2, null, transaction), + getTransaction: function(prevTxid, queryMempool, callback) { + prevTxid.should.equal(previousTransactionTxid); + setImmediate(function() { + callback(null, previousTransaction); + }); + } + } + } + }, + options: {}, + addresses: [] + }); + var transactionInfo = { + txid: txid, + timestamp: 1407292005, + outputIndex: 1, + satoshis: 48020000, + address: txAddress + }; + history.getDetailedInfo(transactionInfo, function(err) { + if (err) { + throw err; + } + var info = history.transactions[txAddress][txid]; + info.address.should.equal(txAddress); + info.satoshis.should.equal(48020000); + info.height.should.equal(314159); + info.confirmations.should.equal(1); + info.timestamp.should.equal(1407292005); + info.fees.should.equal(20000); + info.outputIndexes.should.deep.equal([1]); + info.inputIndexes.should.deep.equal([]); + info.tx.should.equal(transaction); + }); + }); + }); + + describe('#amendDetailedInfoWithSatoshis', function() { + it('will amend info with inputIndex and subtract satoshis', function() { + var txid = '46f24e0c274fc07708b781963576c4c5d5625d926dbb0a17fa865dcd9fe58ea0'; + var history = new AddressHistory({ + node: {}, + options: {}, + addresses: [] + }); + history.transactions[address] = {}; + history.transactions[address][txid] = { + inputIndexes: [], + satoshis: 10, + tx: { + inputs: [ + { + output: { + satoshis: 3000 + } + } + ] + } + }; + history.amendDetailedInfoWithSatoshis({ + address: address, + txid: txid, + inputIndex: 0 + }); + history.transactions[address][txid].inputIndexes.should.deep.equal([0]); + history.transactions[address][txid].satoshis.should.equal(-2990); + }); + it('will amend info with outputIndex and add satoshis', function() { + var txid = '46f24e0c274fc07708b781963576c4c5d5625d926dbb0a17fa865dcd9fe58ea0'; + var history = new AddressHistory({ + node: {}, + options: {}, + addresses: [] + }); + history.transactions[address] = {}; + history.transactions[address][txid] = { + outputIndexes: [], + satoshis: 10 + }; + history.amendDetailedInfoWithSatoshis({ + address: address, + txid: txid, + outputIndex: 10, + satoshis: 2000 + }); + history.transactions[address][txid].outputIndexes.should.deep.equal([10]); + history.transactions[address][txid].satoshis.should.equal(2010); + }); + }); + + describe('#sortTransactionIntoArray', function() { + it('will convert this.transactions into an array and sort by height', function() { + var history = new AddressHistory({ + node: {}, + options: { + from: 10, + to: 20 + }, + addresses: [] + }); + history.transactions = { + address1: { + txid1: { + height: 12 + }, + txid2: { + height: 14, + }, + txid3: { + height: 13 + } + }, + address2: { + txid4: { + height: 15 + } + } + }; + history.sortTransactionsIntoArray(); + history.sortedArray.length.should.equal(4); + history.sortedArray[0].height.should.equal(15); + history.sortedArray[1].height.should.equal(14); + history.sortedArray[2].height.should.equal(13); + history.sortedArray[3].height.should.equal(12); + }); + }); +}); diff --git a/test/services/address.unit.js b/test/services/address/index.unit.js similarity index 71% rename from test/services/address.unit.js rename to test/services/address/index.unit.js index 2b7c2a89..79c31287 100644 --- a/test/services/address.unit.js +++ b/test/services/address/index.unit.js @@ -2,14 +2,14 @@ var should = require('chai').should(); var sinon = require('sinon'); -var bitcorenode = require('../../'); +var proxyquire = require('proxyquire'); +var bitcorenode = require('../../../'); var AddressService = bitcorenode.services.Address; -var blockData = require('../data/livenet-345003.json'); +var blockData = require('../../data/livenet-345003.json'); var bitcore = require('bitcore'); var Networks = bitcore.Networks; var EventEmitter = require('events').EventEmitter; var errors = bitcorenode.errors; -var levelup = require('levelup'); var mockdb = { }; @@ -110,7 +110,7 @@ describe('Address Service', function() { { key: { address: '1F1MAvhTKg2VG29w8cXsiSN2PJ8gSsrJw', - timestamp: 1424836934, + height: 345003, txid: 'fdbefe0d064729d85556bd3ab13c3a889b685d042499c02b4aa2064fb1e16923', outputIndex: 0 }, @@ -122,19 +122,20 @@ describe('Address Service', function() { }, { key: { + address: '1Q8ec8kG7c7HqgK7uSzQyWsX9tzepRcKEL', + height: 345003, prevTxId: '3d7d5d98df753ef2a4f82438513c509e3b11f3e738e94a7234967b03a03123a9', prevOutputIndex: 32 }, value: { txid: '5780f3ee54889a0717152a01abee9a32cec1b0cdf8d5537a08c7bd9eeb6bfbca', - inputIndex: 0, - timestamp: 1424836934 + inputIndex: 0 } }, { key: { address: '1Ep5LA4T6Y7zaBPiwruUJurjGFvCJHzJhm', - timestamp: 1424836934, + height: 345003, txid: 'e66f3b989c790178de2fc1a5329f94c0d8905d0d3df4e7ecf0115e7f90a6283d', outputIndex: 1 }, @@ -170,17 +171,17 @@ describe('Address Service', function() { should.not.exist(err); operations.length.should.equal(81); operations[0].type.should.equal('put'); - var expected0 = ['outs', key0.address, key0.timestamp, key0.txid, key0.outputIndex].join('-'); + var expected0 = ['outs', key0.address, key0.height, key0.txid, key0.outputIndex].join('-'); operations[0].key.should.equal(expected0); - operations[0].value.should.equal([value0.satoshis, value0.script, value0.blockHeight].join(':')); + operations[0].value.should.equal([value0.satoshis, value0.script].join(':')); operations[3].type.should.equal('put'); - var expected3 = ['sp', key3.prevTxId, key3.prevOutputIndex].join('-'); + var expected3 = ['sp', key3.address, key3.height, key3.prevTxId, key3.prevOutputIndex].join('-'); operations[3].key.should.equal(expected3); operations[3].value.should.equal([value3.txid, value3.inputIndex].join(':')); operations[64].type.should.equal('put'); - var expected64 = ['outs', key64.address, key64.timestamp, key64.txid, key64.outputIndex].join('-'); + var expected64 = ['outs', key64.address, key64.height, key64.txid, key64.outputIndex].join('-'); operations[64].key.should.equal(expected64); - operations[64].value.should.equal([value64.satoshis, value64.script, value64.blockHeight].join(':')); + operations[64].value.should.equal([value64.satoshis, value64.script].join(':')); done(); }); }); @@ -196,14 +197,14 @@ describe('Address Service', function() { should.not.exist(err); operations.length.should.equal(81); operations[0].type.should.equal('del'); - operations[0].key.should.equal(['outs', key0.address, key0.timestamp, key0.txid, key0.outputIndex].join('-')); - operations[0].value.should.equal([value0.satoshis, value0.script, value0.blockHeight].join(':')); + operations[0].key.should.equal(['outs', key0.address, key0.height, key0.txid, key0.outputIndex].join('-')); + operations[0].value.should.equal([value0.satoshis, value0.script].join(':')); operations[3].type.should.equal('del'); - operations[3].key.should.equal(['sp', key3.prevTxId, key3.prevOutputIndex].join('-')); + operations[3].key.should.equal(['sp', key3.address, key3.height, key3.prevTxId, key3.prevOutputIndex].join('-')); operations[3].value.should.equal([value3.txid, value3.inputIndex].join(':')); operations[64].type.should.equal('del'); - operations[64].key.should.equal(['outs', key64.address, key64.timestamp, key64.txid, key64.outputIndex].join('-')); - operations[64].value.should.equal([value64.satoshis, value64.script, value64.blockHeight].join(':')); + operations[64].key.should.equal(['outs', key64.address, key64.height, key64.txid, key64.outputIndex].join('-')); + operations[64].value.should.equal([value64.satoshis, value64.script].join(':')); done(); }); }); @@ -421,6 +422,116 @@ describe('Address Service', function() { }); + describe('#getInputs', function() { + var am; + var address = '1KiW1A4dx1oRgLHtDtBjcunUGkYtFgZ1W'; + var db = { + tip: { + __height: 1 + } + }; + var testnode = { + services: { + db: db, + bitcoind: { + on: sinon.stub() + } + } + }; + before(function() { + am = new AddressService({node: testnode}); + }); + + it('will get inputs for an address and timestamp', function(done) { + var testStream = new EventEmitter(); + var args = { + start: 15, + end: 12, + queryMempool: true + }; + var createReadStreamCallCount = 0; + am.node.services.db.store = { + createReadStream: function(ops) { + ops.start.should.equal([AddressService.PREFIXES.SPENTS, address, 12].join('-')); + ops.end.should.equal([AddressService.PREFIXES.SPENTS, address, 16].join('-')); + createReadStreamCallCount++; + return testStream; + } + }; + am.node.services.bitcoind = { + getMempoolInputs: sinon.stub().returns([]) + }; + am.getInputs(address, args, function(err, inputs) { + should.not.exist(err); + inputs.length.should.equal(1); + inputs[0].address.should.equal(address); + inputs[0].txid.should.equal('3b6bc2939d1a70ce04bc4f619ee32608fbff5e565c1f9b02e4eaa97959c59ae7'); + inputs[0].inputIndex.should.equal(0); + inputs[0].height.should.equal(15); + done(); + }); + createReadStreamCallCount.should.equal(1); + var data = { + key: ['sp', address, '15', '125dd0e50fc732d67c37b6c56be7f9dc00b6859cebf982ee2cc83ed2d604bf87', '1'].join('-'), + value: ['3b6bc2939d1a70ce04bc4f619ee32608fbff5e565c1f9b02e4eaa97959c59ae7', '0'].join(':') + }; + testStream.emit('data', data); + testStream.emit('close'); + }); + it('should get inputs for address', function(done) { + var testStream = new EventEmitter(); + var args = { + queryMempool: true + }; + var createReadStreamCallCount = 0; + am.node.services.db.store = { + createReadStream: function(ops) { + ops.start.should.equal([AddressService.PREFIXES.SPENTS, address].join('-')); + ops.end.should.equal([AddressService.PREFIXES.SPENTS, address].join('-') + '~'); + createReadStreamCallCount++; + return testStream; + } + }; + am.node.services.bitcoind = { + getMempoolInputs: sinon.stub().returns([]) + }; + am.getInputs(address, args, function(err, inputs) { + should.not.exist(err); + inputs.length.should.equal(1); + inputs[0].address.should.equal(address); + inputs[0].txid.should.equal('3b6bc2939d1a70ce04bc4f619ee32608fbff5e565c1f9b02e4eaa97959c59ae7'); + inputs[0].inputIndex.should.equal(0); + inputs[0].height.should.equal(15); + done(); + }); + createReadStreamCallCount.should.equal(1); + var data = { + key: ['sp', address, '15', '125dd0e50fc732d67c37b6c56be7f9dc00b6859cebf982ee2cc83ed2d604bf87', '1'].join('-'), + value: ['3b6bc2939d1a70ce04bc4f619ee32608fbff5e565c1f9b02e4eaa97959c59ae7', '0'].join(':') + }; + testStream.emit('data', data); + testStream.emit('close'); + }); + it('should give an error if the readstream has an error', function(done) { + var testStream = new EventEmitter(); + am.node.services.db.store = { + createReadStream: sinon.stub().returns(testStream) + }; + + am.getOutputs(address, {}, function(err, outputs) { + should.exist(err); + err.message.should.equal('readstreamerror'); + done(); + }); + + testStream.emit('error', new Error('readstreamerror')); + setImmediate(function() { + testStream.emit('close'); + }); + }); + + }); + describe('#getOutputs', function() { var am; var address = '1KiW1A4dx1oRgLHtDtBjcunUGkYtFgZ1W'; @@ -437,11 +548,53 @@ describe('Address Service', function() { } } }; + var options = { + queryMempool: true + }; before(function() { am = new AddressService({node: testnode}); }); + it('will get outputs for an address and timestamp', function(done) { + var testStream = new EventEmitter(); + var args = { + start: 15, + end: 12, + queryMempool: true + }; + var createReadStreamCallCount = 0; + am.node.services.db.store = { + createReadStream: function(ops) { + ops.start.should.equal([AddressService.PREFIXES.OUTPUTS, address, 12].join('-')); + ops.end.should.equal([AddressService.PREFIXES.OUTPUTS, address, 16].join('-')); + createReadStreamCallCount++; + return testStream; + } + }; + am.node.services.bitcoind = { + getMempoolOutputs: sinon.stub().returns([]) + }; + am.getOutputs(address, args, function(err, outputs) { + should.not.exist(err); + outputs.length.should.equal(1); + outputs[0].address.should.equal(address); + outputs[0].txid.should.equal('125dd0e50fc732d67c37b6c56be7f9dc00b6859cebf982ee2cc83ed2d604bf87'); + outputs[0].outputIndex.should.equal(1); + outputs[0].satoshis.should.equal(4527773864); + outputs[0].script.should.equal('76a914038a213afdfc551fc658e9a2a58a86e98d69b68788ac'); + outputs[0].height.should.equal(15); + done(); + }); + createReadStreamCallCount.should.equal(1); + var data = { + key: ['outs', address, '15', '125dd0e50fc732d67c37b6c56be7f9dc00b6859cebf982ee2cc83ed2d604bf87', '1'].join('-'), + value: ['4527773864', '76a914038a213afdfc551fc658e9a2a58a86e98d69b68788ac'].join(':') + }; + testStream.emit('data', data); + testStream.emit('close'); + }); + it('should get outputs for an address', function(done) { var readStream1 = new EventEmitter(); am.node.services.db.store = { @@ -452,7 +605,7 @@ describe('Address Service', function() { address: '1KiW1A4dx1oRgLHtDtBjcunUGkYtFgZ1W', txid: 'aa2db23f670596e96ed94c405fd11848c8f236d266ee96da37ecd919e53b4371', satoshis: 307627737, - script: 'OP_DUP OP_HASH160 f6db95c81dea3d10f0ff8d890927751bf7b203c1 OP_EQUALVERIFY OP_CHECKSIG', + script: '76a914f6db95c81dea3d10f0ff8d890927751bf7b203c188ac', blockHeight: 352532 } ]; @@ -460,38 +613,36 @@ describe('Address Service', function() { getMempoolOutputs: sinon.stub().returns(mempoolOutputs) }; - am.getOutputs(address, true, function(err, outputs) { + am.getOutputs(address, options, function(err, outputs) { should.not.exist(err); outputs.length.should.equal(3); outputs[0].address.should.equal(address); outputs[0].txid.should.equal('125dd0e50fc732d67c37b6c56be7f9dc00b6859cebf982ee2cc83ed2d604bf87'); outputs[0].outputIndex.should.equal(1); outputs[0].satoshis.should.equal(4527773864); - outputs[0].script.should.equal('OP_DUP OP_HASH160 038a213afdfc551fc658e9a2a58a86e98d69b687 OP_EQUALVERIFY OP_CHECKSIG'); - outputs[0].blockHeight.should.equal(345000); - outputs[0].timestamp.should.equal(1424835319000); + outputs[0].script.should.equal('76a914038a213afdfc551fc658e9a2a58a86e98d69b68788ac'); + outputs[0].height.should.equal(345000); outputs[1].address.should.equal(address); outputs[1].txid.should.equal('3b6bc2939d1a70ce04bc4f619ee32608fbff5e565c1f9b02e4eaa97959c59ae7'); outputs[1].outputIndex.should.equal(2); outputs[1].satoshis.should.equal(10000); - outputs[1].script.should.equal('OP_DUP OP_HASH160 038a213afdfc551fc658e9a2a58a86e98d69b687 OP_EQUALVERIFY OP_CHECKSIG'); - outputs[1].blockHeight.should.equal(345004); - outputs[1].timestamp.should.equal(1424837300000); + outputs[1].script.should.equal('76a914038a213afdfc551fc658e9a2a58a86e98d69b68788ac'); + outputs[1].height.should.equal(345004); outputs[2].address.should.equal(address); outputs[2].txid.should.equal('aa2db23f670596e96ed94c405fd11848c8f236d266ee96da37ecd919e53b4371'); - outputs[2].script.should.equal('OP_DUP OP_HASH160 f6db95c81dea3d10f0ff8d890927751bf7b203c1 OP_EQUALVERIFY OP_CHECKSIG'); + outputs[2].script.should.equal('76a914f6db95c81dea3d10f0ff8d890927751bf7b203c188ac'); outputs[2].blockHeight.should.equal(352532); done(); }); var data1 = { - key: ['outs', address, 1424835319000, '125dd0e50fc732d67c37b6c56be7f9dc00b6859cebf982ee2cc83ed2d604bf87', '1'].join('-'), - value: ['4527773864', 'OP_DUP OP_HASH160 038a213afdfc551fc658e9a2a58a86e98d69b687 OP_EQUALVERIFY OP_CHECKSIG', '345000'].join(':') + key: ['outs', address, 345000, '125dd0e50fc732d67c37b6c56be7f9dc00b6859cebf982ee2cc83ed2d604bf87', '1'].join('-'), + value: ['4527773864', '76a914038a213afdfc551fc658e9a2a58a86e98d69b68788ac'].join(':') }; var data2 = { - key: ['outs', address, 1424837300000, '3b6bc2939d1a70ce04bc4f619ee32608fbff5e565c1f9b02e4eaa97959c59ae7', '2'].join('-'), - value: ['10000', 'OP_DUP OP_HASH160 038a213afdfc551fc658e9a2a58a86e98d69b687 OP_EQUALVERIFY OP_CHECKSIG', '345004'].join(':') + key: ['outs', address, 345004, '3b6bc2939d1a70ce04bc4f619ee32608fbff5e565c1f9b02e4eaa97959c59ae7', '2'].join('-'), + value: ['10000', '76a914038a213afdfc551fc658e9a2a58a86e98d69b68788ac'].join(':') }; readStream1.emit('data', data1); @@ -505,14 +656,14 @@ describe('Address Service', function() { createReadStream: sinon.stub().returns(readStream2) }; - am.getOutputs(address, true, function(err, outputs) { + am.getOutputs(address, options, function(err, outputs) { should.exist(err); err.message.should.equal('readstreamerror'); done(); }); readStream2.emit('error', new Error('readstreamerror')); - process.nextTick(function() { + setImmediate(function() { readStream2.emit('close'); }); }); @@ -731,245 +882,20 @@ describe('Address Service', function() { }); }); - describe('#getSpendInfoForOutput', function() { - it('should call store.get the right values', function(done) { - var db = { - store: { - get: sinon.stub().callsArgWith(1, null, 'spendtxid:1') - } - }; - var testnode = { - services: { - db: db, - bitcoind: { - on: sinon.stub() - } - } - }; - var am = new AddressService({node: testnode}); - am.getSpendInfoForOutput('txid', 3, function(err, info) { - should.not.exist(err); - info.txid.should.equal('spendtxid'); - info.inputIndex.should.equal('1'); - db.store.get.args[0][0].should.equal('sp-txid-3'); - done(); - }); - }); - }); - describe('#getAddressHistory', function() { - var incoming = [ - { - txid: 'tx1', - outputIndex: 0, - spentTx: 'tx2', - inputIndex: 0, - height: 1, - timestamp: 1438289011844, - satoshis: 5000, - getFee: sinon.stub().throws(new Error('inputs not populated')), - isCoinbase: sinon.stub().returns(true) - }, - { - txid: 'tx3', - outputIndex: 1, - height: 3, - timestamp: 1438289031844, - satoshis: 2000, - getFee: sinon.stub().returns(1000), - isCoinbase: sinon.stub().returns(false) - }, - { - txid: 'tx4', - outputIndex: 2, - spentTx: 'tx5', - inputIndex: 1, - height: 4, - timestamp: 1438289041844, - satoshis: 3000, - getFee: sinon.stub().returns(1000), - isCoinbase: sinon.stub().returns(false) - }, - ]; - - var outgoing = [ - { - txid: 'tx2', - height: 2, - timestamp: 1438289021844, - inputs: [ - { - output: { - satoshis: 5000 - } - } - ], - getFee: sinon.stub().returns(1000), - isCoinbase: sinon.stub().returns(false) - }, - { - txid: 'tx5', - height: 5, - timestamp: 1438289051844, - inputs: [ - {}, - { - output: { - satoshis: 3000 - } - } - ], - getFee: sinon.stub().returns(1000), - isCoinbase: sinon.stub().returns(false) + it('will call get on address history instance', function(done) { + function TestAddressHistory(args) { + args.node.should.equal(mocknode); + args.addresses.should.deep.equal([]); + args.options.should.deep.equal({}); } - ]; - - var db = { - tip: { - __height: 1 - }, - getTransactionWithBlockInfo: function(txid, queryMempool, callback) { - var transaction = { - populateInputs: sinon.stub().callsArg(2) - }; - for(var i = 0; i < incoming.length; i++) { - if(incoming[i].txid === txid) { - if(incoming[i].error) { - return callback(new Error(incoming[i].error)); - } - transaction.hash = txid; - transaction.__height = incoming[i].height; - transaction.__timestamp = incoming[i].timestamp; - transaction.getFee = incoming[i].getFee; - transaction.isCoinbase = incoming[i].isCoinbase; - return callback(null, transaction); - } - } - - for(var i = 0; i < outgoing.length; i++) { - if(outgoing[i].txid === txid) { - if(outgoing[i].error) { - return callback(new Error(outgoing[i].error)); - } - transaction.hash = txid; - transaction.__height = outgoing[i].height; - transaction.__timestamp = outgoing[i].timestamp; - transaction.inputs = outgoing[i].inputs; - transaction.getFee = outgoing[i].getFee; - transaction.isCoinbase = outgoing[i].isCoinbase; - return callback(null, transaction); - } - } - callback(new Error('tx ' + txid + ' not found')); - } - }; - var testnode = { - services: { - db: db, - bitcoind: { - on: sinon.stub() - } - } - }; - var am = new AddressService({node: testnode}); - - am.getOutputs = sinon.stub().callsArgWith(2, null, incoming); - am.getSpendInfoForOutput = function(txid, outputIndex, callback) { - for(var i = 0; i < incoming.length; i++) { - if(incoming[i].txid === txid && incoming[i].outputIndex === outputIndex && incoming[i].spentTx) { - if(incoming[i].spendError) { - return callback(new Error(incoming[i].spendError)); - } - return callback(null, { - txid: incoming[i].spentTx, - inputIndex: incoming[i].inputIndex - }); - } - } - - callback(new levelup.errors.NotFoundError()); - }; - - it('should give transaction history for an address', function(done) { - am.getAddressHistory('address', true, function(err, history) { - should.not.exist(err); - history[0].tx.hash.should.equal('tx1'); - history[0].satoshis.should.equal(5000); - history[0].height.should.equal(1); - history[0].timestamp.should.equal(1438289011844); - should.equal(history[0].fees, null); - history[1].tx.hash.should.equal('tx2'); - history[1].satoshis.should.equal(-5000); - history[1].height.should.equal(2); - history[1].timestamp.should.equal(1438289021844); - history[1].fees.should.equal(1000); - history[2].tx.hash.should.equal('tx3'); - history[2].satoshis.should.equal(2000); - history[2].height.should.equal(3); - history[2].timestamp.should.equal(1438289031844); - history[2].fees.should.equal(1000); - history[3].tx.hash.should.equal('tx4'); - history[3].satoshis.should.equal(3000); - history[3].height.should.equal(4); - history[3].timestamp.should.equal(1438289041844); - history[3].fees.should.equal(1000); - history[4].tx.hash.should.equal('tx5'); - history[4].satoshis.should.equal(-3000); - history[4].height.should.equal(5); - history[4].timestamp.should.equal(1438289051844); - history[4].fees.should.equal(1000); - done(); + TestAddressHistory.prototype.get = sinon.stub().callsArg(0); + var TestAddressService = proxyquire('../../../lib/services/address', { + './history': TestAddressHistory }); - }); - - it('should give an error if the second getTransactionInfo gives an error', function(done) { - outgoing[0].error = 'txinfo2err'; - am.getAddressHistory('address', true, function(err, history) { - should.exist(err); - err.message.should.equal('txinfo2err'); - outgoing[0].error = null; - done(); - }); - }); - - it('should give an error if getSpendInfoForOutput gives an error', function(done) { - incoming[0].spendError = 'spenderr'; - am.getAddressHistory('address', true, function(err, history) { - should.exist(err); - err.message.should.equal('spenderr'); - incoming[0].spendError = null; - done(); - }); - }); - - it('should give an error if the first getTransactionInfo gives an error', function(done) { - incoming[1].error = 'txinfo1err'; - am.getAddressHistory('address', true, function(err, history) { - should.exist(err); - err.message.should.equal('txinfo1err'); - incoming[1].error = null; - done(); - }); - }); - - it('should give an error if populateInputs gives an error', function(done) { - var populateStub = sinon.stub().callsArgWith(2, new Error('populateerr')); - sinon.stub(db, 'getTransactionWithBlockInfo').callsArgWith(2, null, { - populateInputs: populateStub - }); - am.getAddressHistory('address', true, function(err, history) { - should.exist(err); - err.message.should.equal('populateerr'); - db.getTransactionWithBlockInfo.restore(); - done(); - }); - }); - - it('should give an error if getOutputs gives an error', function(done) { - am.getOutputs = sinon.stub().callsArgWith(2, new Error('getoutputserr')); - am.getAddressHistory('address', true, function(err, history) { - should.exist(err); - err.message.should.equal('getoutputserr'); + var am = new TestAddressService({node: mocknode}); + am.getAddressHistory([], {}, function(err, history) { + TestAddressHistory.prototype.get.callCount.should.equal(1); done(); }); });