diff --git a/integration/regtest-node.js b/integration/regtest-node.js index 932b1483..1a661a1b 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,407 @@ 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]; + should.exist(info.addresses[address]); + info.addresses[address].outputIndexes.length.should.equal(1); + info.addresses[address].outputIndexes[0].should.be.within(0, 1); + info.addresses[address].inputIndexes.should.deep.equal([]); + 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.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(4); + history[0].height.should.equal(157); + history[0].confirmations.should.equal(1); + history[1].height.should.equal(156); + should.exist(history[1].addresses[address4]); + history[2].height.should.equal(155); + should.exist(history[2].addresses[address3]); + history[3].height.should.equal(154); + should.exist(history[3].addresses[address2]); + history[3].satoshis.should.equal(99990000); + history[3].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(2); + history[0].height.should.equal(157); + history[0].confirmations.should.equal(1); + history[1].height.should.equal(156); + should.exist(history[1].addresses[address4]); + done(); + }); + }); + + it('five addresses (limited by height 155 to 154)', function(done) { + var addresses = [ + address2, + address3, + address4, + address5, + address6 + ]; + var options = { + start: 155, + end: 154 + }; + node.services.address.getAddressHistory(addresses, options, function(err, history) { + if (err) { + throw err; + } + history.length.should.equal(2); + history[0].height.should.equal(155); + history[1].height.should.equal(154); + 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(156); + should.exist(history[1].addresses[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].addresses[address].inputIndexes.should.deep.equal([0, 1]); + history[0].addresses[address].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].addresses[address].outputIndexes.should.deep.equal([0, 1, 2, 3, 4]); + history[4].addresses[address].inputIndexes.should.deep.equal([0]); + history[5].height.should.equal(150); + history[5].satoshis.should.equal(10 * 1e8); + done(); + }); + }); + + it('total transaction count (sending and receiving)', function(done) { + var addresses = [ + address + ]; + var options = {}; + node.services.address.getAddressHistoryCount(addresses, options, function(err, count) { + if (err) { + throw err; + } + count.should.equal(6); + 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].addresses[address].outputIndexes.should.deep.equal([0, 1, 2, 3, 4]); + history[0].addresses[address].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.js b/lib/services/address.js deleted file mode 100644 index 5cdf2362..00000000 --- a/lib/services/address.js +++ /dev/null @@ -1,586 +0,0 @@ -'use strict'; - -var BaseService = require('../service'); -var inherits = require('util').inherits; -var async = require('async'); -var index = require('../'); -var log = index.log; -var levelup = require('levelup'); -var errors = index.errors; -var bitcore = require('bitcore'); -var $ = bitcore.util.preconditions; -var _ = bitcore.deps._; -var EventEmitter = require('events').EventEmitter; -var PublicKey = bitcore.PublicKey; -var Address = bitcore.Address; - -var AddressService = function(options) { - BaseService.call(this, options); - - this.subscriptions = {}; - this.subscriptions['address/transaction'] = {}; - this.subscriptions['address/balance'] = {}; - - this.node.services.bitcoind.on('tx', this.transactionHandler.bind(this)); - -}; - -inherits(AddressService, BaseService); - -AddressService.dependencies = [ - 'bitcoind', - 'db' -]; - -AddressService.PREFIXES = { - OUTPUTS: 'outs', - SPENTS: 'sp' -}; - -AddressService.prototype.getAPIMethods = function() { - return [ - ['getBalance', this, this.getBalance, 2], - ['getOutputs', this, this.getOutputs, 2], - ['getUnspentOutputs', this, this.getUnspentOutputs, 2], - ['isSpent', this, this.isSpent, 2], - ['getAddressHistory', this, this.getAddressHistory, 2] - ]; -}; - -AddressService.prototype.getPublishEvents = function() { - return [ - { - name: 'address/transaction', - scope: this, - subscribe: this.subscribe.bind(this, 'address/transaction'), - unsubscribe: this.unsubscribe.bind(this, 'address/transaction') - }, - { - name: 'address/balance', - scope: this, - subscribe: this.subscribe.bind(this, 'address/balance'), - unsubscribe: this.unsubscribe.bind(this, 'address/balance') - } - ]; -}; - -/** - * Will process each output of a transaction from the daemon "tx" event, and construct - * an object with the data for the message to be relayed to any subscribers for an address. - * - * @param {Object} messages - An object to collect messages - * @param {Transaction} tx - Instance of the transaction - * @param {Number} outputIndex - The index of the output in the transaction - * @param {Boolean} rejected - If the transaction was rejected by the mempool - */ -AddressService.prototype.transactionOutputHandler = function(messages, tx, outputIndex, rejected) { - var script = tx.outputs[outputIndex].script; - - // If the script is invalid skip - if (!script) { - return; - } - - // Find the address for the output - var address = script.toAddress(this.node.network); - if (!address && script.isPublicKeyOut()) { - var pubkey = script.chunks[0].buf; - address = Address.fromPublicKey(new PublicKey(pubkey), this.node.network); - } else if (!address){ - return; - } - - // Collect data to publish to address subscribers - if (messages[address]) { - messages[address].outputIndexes.push(outputIndex); - } else { - messages[address] = { - tx: tx, - outputIndexes: [outputIndex], - address: address.toString(), - rejected: rejected - }; - } -}; - -/** - * This will handle data from the daemon "tx" event, go through each of the outputs - * and send messages to any subscribers for a particular address. - * - * @param {Object} txInfo - The data from the daemon.on('tx') event - * @param {Buffer} txInfo.buffer - The transaction buffer - * @param {Boolean} txInfo.mempool - If the transaction was accepted in the mempool - * @param {String} txInfo.hash - The hash of the transaction - */ -AddressService.prototype.transactionHandler = function(txInfo) { - - // Basic transaction format is handled by the daemon - // and we can safely assume the buffer is properly formatted. - var tx = bitcore.Transaction().fromBuffer(txInfo.buffer); - - var messages = {}; - - var outputsLength = tx.outputs.length; - for (var i = 0; i < outputsLength; i++) { - this.transactionOutputHandler(messages, tx, i, !txInfo.mempool); - } - - for (var key in messages) { - this.transactionEventHandler(messages[key]); - } -}; - -AddressService.prototype.blockHandler = function(block, addOutput, callback) { - var txs = block.transactions; - - var action = 'put'; - if (!addOutput) { - action = 'del'; - } - - var operations = []; - - var transactionLength = txs.length; - for (var i = 0; i < transactionLength; i++) { - - var tx = txs[i]; - var txid = tx.id; - var inputs = tx.inputs; - var outputs = tx.outputs; - - // Subscription messages - var txmessages = {}; - - var outputLength = outputs.length; - for (var j = 0; j < outputLength; j++) { - var output = outputs[j]; - - var script = output.script; - - if(!script) { - log.debug('Invalid script'); - continue; - } - - var address = script.toAddress(this.node.network); - if (!address && script.isPublicKeyOut()) { - var pubkey = script.chunks[0].buf; - address = Address.fromPublicKey(new PublicKey(pubkey), this.node.network); - } else if (!address){ - continue; - } - - var outputIndex = j; - // TODO: expose block timestamp as a date object in bitcore? - var timestamp = block.header.timestamp; - 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(':'); - - operations.push({ - type: action, - key: key, - value: value - }); - - // Collect data for subscribers - if (txmessages[addressStr]) { - txmessages[addressStr].outputIndexes.push(outputIndex); - } else { - txmessages[addressStr] = { - tx: tx, - height: block.__height, - outputIndexes: [outputIndex], - address: addressStr, - timestamp: block.header.timestamp - }; - } - - this.balanceEventHandler(block, address); - - } - - // Publish events to any subscribers for this transaction - for (var addressKey in txmessages) { - this.transactionEventHandler(txmessages[addressKey]); - } - - if(tx.isCoinbase()) { - 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(':') - }); - } - } - - setImmediate(function() { - callback(null, operations); - }); -}; - -/** - * @param {Object} obj - * @param {Transaction} obj.tx - The transaction - * @param {String} [obj.address] - The address for the subscription - * @param {Array} [obj.outputIndexes] - Indexes of the inputs that includes the address - * @param {Array} [obj.inputIndexes] - Indexes of the outputs that includes the address - * @param {Date} [obj.timestamp] - The time of the block the transaction was included - * @param {Number} [obj.height] - The height of the block the transaction was included - * @param {Boolean} [obj.rejected] - If the transaction was not accepted in the mempool - */ -AddressService.prototype.transactionEventHandler = function(obj) { - if(this.subscriptions['address/transaction'][obj.address]) { - var emitters = this.subscriptions['address/transaction'][obj.address]; - for(var i = 0; i < emitters.length; i++) { - emitters[i].emit('address/transaction', obj); - } - } -}; - -AddressService.prototype.balanceEventHandler = function(block, address) { - if(this.subscriptions['address/balance'][address]) { - var emitters = this.subscriptions['address/balance'][address]; - this.getBalance(address, true, function(err, balance) { - if(err) { - return this.emit(err); - } - - for(var i = 0; i < emitters.length; i++) { - emitters[i].emit('address/balance', address, balance, block); - } - }); - } -}; - -AddressService.prototype.subscribe = function(name, emitter, addresses) { - $.checkArgument(emitter instanceof EventEmitter, 'First argument is expected to be an EventEmitter'); - $.checkArgument(Array.isArray(addresses), 'Second argument is expected to be an Array of addresses'); - - for(var i = 0; i < addresses.length; i++) { - if(!this.subscriptions[name][addresses[i]]) { - this.subscriptions[name][addresses[i]] = []; - } - this.subscriptions[name][addresses[i]].push(emitter); - } -}; - -AddressService.prototype.unsubscribe = function(name, emitter, addresses) { - $.checkArgument(emitter instanceof EventEmitter, 'First argument is expected to be an EventEmitter'); - $.checkArgument(Array.isArray(addresses) || _.isUndefined(addresses), 'Second argument is expected to be an Array of addresses or undefined'); - - if(!addresses) { - return this.unsubscribeAll(name, emitter); - } - - for(var i = 0; i < addresses.length; i++) { - if(this.subscriptions[name][addresses[i]]) { - var emitters = this.subscriptions[name][addresses[i]]; - var index = emitters.indexOf(emitter); - if(index > -1) { - emitters.splice(index, 1); - } - } - } -}; - -AddressService.prototype.unsubscribeAll = function(name, emitter) { - $.checkArgument(emitter instanceof EventEmitter, 'First argument is expected to be an EventEmitter'); - - for(var address in this.subscriptions[name]) { - var emitters = this.subscriptions[name][address]; - var index = emitters.indexOf(emitter); - if(index > -1) { - emitters.splice(index, 1); - } - } -}; - -AddressService.prototype.getBalance = function(address, queryMempool, callback) { - this.getUnspentOutputs(address, queryMempool, function(err, outputs) { - if(err) { - return callback(err); - } - - var satoshis = outputs.map(function(output) { - return output.satoshis; - }); - - var sum = satoshis.reduce(function(a, b) { - return a + b; - }, 0); - - return callback(null, sum); - }); -}; - -AddressService.prototype.getOutputs = function(addressStr, queryMempool, callback) { - var self = this; - - var outputs = []; - var key = [AddressService.PREFIXES.OUTPUTS, addressStr].join('-'); - - var stream = this.node.services.db.store.createReadStream({ - start: key, - end: key + '~' - }); - - stream.on('data', function(data) { - - var key = data.key.split('-'); - var value = data.value.split(':'); - - var output = { - address: addressStr, - txid: key[3], - outputIndex: Number(key[4]), - timestamp: 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 - }; - - outputs.push(output); - - }); - - var error; - - stream.on('error', function(streamError) { - if (streamError) { - error = streamError; - } - }); - - stream.on('close', function() { - if (error) { - return callback(error); - } - - if(queryMempool) { - outputs = outputs.concat(self.node.services.bitcoind.getMempoolOutputs(addressStr)); - } - - callback(null, outputs); - }); - - return stream; - -}; - -AddressService.prototype.getUnspentOutputs = function(addresses, queryMempool, callback) { - var self = this; - - if(!Array.isArray(addresses)) { - addresses = [addresses]; - } - - var utxos = []; - - async.eachSeries(addresses, function(address, next) { - self.getUnspentOutputsForAddress(address, queryMempool, function(err, unspents) { - if(err && err instanceof errors.NoOutputs) { - return next(); - } else if(err) { - return next(err); - } - - utxos = utxos.concat(unspents); - next(); - }); - }, function(err) { - callback(err, utxos); - }); -}; - -AddressService.prototype.getUnspentOutputsForAddress = function(address, queryMempool, callback) { - - var self = this; - - this.getOutputs(address, queryMempool, function(err, outputs) { - if (err) { - return callback(err); - } else if(!outputs.length) { - return callback(new errors.NoOutputs('Address ' + address + ' has no outputs'), []); - } - - var isUnspent = function(output, callback) { - self.isUnspent(output, queryMempool, callback); - }; - - async.filter(outputs, isUnspent, function(results) { - callback(null, results); - }); - }); -}; - -AddressService.prototype.isUnspent = function(output, queryMempool, callback) { - this.isSpent(output, queryMempool, function(spent) { - callback(!spent); - }); -}; - -AddressService.prototype.isSpent = function(output, queryMempool, callback) { - var self = this; - var txid = output.prevTxId ? output.prevTxId.toString('hex') : output.txid; - - setImmediate(function() { - callback(self.node.services.bitcoind.isSpent(txid, output.outputIndex)); - }); -}; - -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); - } - ); - }); -}; - -module.exports = AddressService; diff --git a/lib/services/address/history.js b/lib/services/address/history.js new file mode 100644 index 00000000..a74e69ce --- /dev/null +++ b/lib/services/address/history.js @@ -0,0 +1,257 @@ +'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.combinedArray = []; + this.detailedArray = []; +} + +AddressHistory.MAX_ADDRESS_QUERIES = 20; + +AddressHistory.prototype.getCount = function(callback) { + var self = this; + + async.eachLimit( + self.addresses, + AddressHistory.MAX_ADDRESS_QUERIES, + function(address, next) { + self.getTransactionInfo(address, next); + }, + function(err) { + if (err) { + return callback(err); + } + self.combineTransactionInfo(); + callback(null, self.combinedArray.length); + } + ); +}; + +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); + } + + self.combineTransactionInfo(); + self.sortAndPaginateCombinedArray(); + + async.eachSeries( + self.combinedArray, + function(txInfo, next) { + self.getDetailedInfo(txInfo, next); + }, + function(err) { + if (err) { + return callback(err); + } + callback(null, self.detailedArray); + } + ); + } + ); +}; + +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(); + }); +}; + +/** + * This function combines results from getInputs and getOutputs by + * combining inputIndexes and outputIndexes for address transaction + * matching combinations. + */ +AddressHistory.prototype.combineTransactionInfo = function() { + var combinedArrayMap = {}; + this.combinedArray = []; + var l = this.transactionInfo.length; + for(var i = 0; i < l; i++) { + var item = this.transactionInfo[i]; + var mapKey = item.txid; + if (combinedArrayMap[mapKey] >= 0) { + var combined = this.combinedArray[combinedArrayMap[mapKey]]; + if (!combined.addresses[item.address]) { + combined.addresses[item.address] = { + outputIndexes: [], + inputIndexes: [] + }; + } + if (item.outputIndex >= 0) { + combined.satoshis += item.satoshis; + combined.addresses[item.address].outputIndexes.push(item.outputIndex); + } else if (item.inputIndex >= 0) { + combined.addresses[item.address].inputIndexes.push(item.inputIndex); + } + } else { + item.addresses = {}; + item.addresses[item.address] = { + outputIndexes: [], + inputIndexes: [] + }; + if (item.outputIndex >= 0) { + item.addresses[item.address].outputIndexes.push(item.outputIndex); + } else if (item.inputIndex >= 0) { + item.addresses[item.address].inputIndexes.push(item.inputIndex); + } + delete item.outputIndex; + delete item.inputIndex; + delete item.address; + this.combinedArray.push(item); + combinedArrayMap[mapKey] = this.combinedArray.length - 1; + } + } +}; + +AddressHistory.prototype.sortAndPaginateCombinedArray = function() { + this.combinedArray.sort(AddressHistory.sortByHeight); + if (!_.isUndefined(this.options.from) && !_.isUndefined(this.options.to)) { + this.combinedArray = this.combinedArray.slice(this.options.from, this.options.to); + } +}; + +AddressHistory.sortByHeight = function(a, b) { + if (a.height < 0 && b.height < 0) { + // Both are from the mempool, compare timestamps + if (a.timestamp === b.timestamp) { + return 0; + } else { + return a.timestamp < b.timestamp ? 1 : -1; + } + } else if (a.height < 0 && b.height > 0) { + // A is from the mempool and B is in a block + return -1; + } else if (a.height > 0 && b.height < 0) { + // A is in a block and B is in the mempool + return 1; + } else if (a.height === b.height) { + // The heights are equal + return 0; + } else { + // Otherwise compare heights + return a.height < b.height ? 1 : -1; + } +}; + +AddressHistory.prototype.getDetailedInfo = function(txInfo, next) { + var self = this; + var queryMempool = _.isUndefined(self.options.queryMempool) ? true : self.options.queryMempool; + + 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); + } + + self.detailedArray.push({ + addresses: txInfo.addresses, + satoshis: self.getSatoshisDetail(transaction, txInfo), + height: transaction.__height, + confirmations: self.getConfirmationsDetail(transaction), + timestamp: transaction.__timestamp, + // TODO bitcore should return null instead of throwing error on coinbase + fees: !transaction.isCoinbase() ? transaction.getFee() : null, + tx: transaction + }); + + next(); + }); + } + ); +}; + +AddressHistory.prototype.getConfirmationsDetail = function(transaction) { + var confirmations = 0; + if (transaction.__height >= 0) { + confirmations = this.node.services.db.tip.__height - transaction.__height + 1; + } + return confirmations; +}; + +AddressHistory.prototype.getSatoshisDetail = function(transaction, txInfo) { + var satoshis = txInfo.satoshis || 0; + + for(var address in txInfo.addresses) { + if (txInfo.addresses[address].inputIndexes.length >= 0) { + for(var j = 0; j < txInfo.addresses[address].inputIndexes.length; j++) { + satoshis -= transaction.inputs[txInfo.addresses[address].inputIndexes[j]].output.satoshis; + } + } + } + + return satoshis; +}; + +module.exports = AddressHistory; diff --git a/lib/services/address/index.js b/lib/services/address/index.js new file mode 100644 index 00000000..dbf944ce --- /dev/null +++ b/lib/services/address/index.js @@ -0,0 +1,775 @@ +'use strict'; + +var BaseService = require('../../service'); +var inherits = require('util').inherits; +var async = require('async'); +var index = require('../../'); +var log = index.log; +var errors = index.errors; +var bitcore = require('bitcore'); +var $ = bitcore.util.preconditions; +var _ = bitcore.deps._; +var Hash = bitcore.crypto.Hash; +var BufferReader = bitcore.encoding.BufferReader; +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); + + this.subscriptions = {}; + this.subscriptions['address/transaction'] = {}; + this.subscriptions['address/balance'] = {}; + + this.node.services.bitcoind.on('tx', this.transactionHandler.bind(this)); + +}; + +inherits(AddressService, BaseService); + +AddressService.dependencies = [ + 'bitcoind', + 'db' +]; + +AddressService.PREFIXES = { + OUTPUTS: new Buffer('32', 'hex'), + SPENTS: new Buffer('33', 'hex') +}; + +AddressService.SPACER_MIN = new Buffer('00', 'hex'); +AddressService.SPACER_MAX = new Buffer('ff', 'hex'); + +AddressService.prototype.getAPIMethods = function() { + return [ + ['getBalance', this, this.getBalance, 2], + ['getOutputs', this, this.getOutputs, 2], + ['getUnspentOutputs', this, this.getUnspentOutputs, 2], + ['isSpent', this, this.isSpent, 2], + ['getAddressHistory', this, this.getAddressHistory, 2], + ['getAddressHistoryCount', this, this.getAddressHistoryCount, 2] + ]; +}; + +AddressService.prototype.getPublishEvents = function() { + return [ + { + name: 'address/transaction', + scope: this, + subscribe: this.subscribe.bind(this, 'address/transaction'), + unsubscribe: this.unsubscribe.bind(this, 'address/transaction') + }, + { + name: 'address/balance', + scope: this, + subscribe: this.subscribe.bind(this, 'address/balance'), + unsubscribe: this.unsubscribe.bind(this, 'address/balance') + } + ]; +}; + +/** + * Will process each output of a transaction from the daemon "tx" event, and construct + * an object with the data for the message to be relayed to any subscribers for an address. + * + * @param {Object} messages - An object to collect messages + * @param {Transaction} tx - Instance of the transaction + * @param {Number} outputIndex - The index of the output in the transaction + * @param {Boolean} rejected - If the transaction was rejected by the mempool + */ +AddressService.prototype.transactionOutputHandler = function(messages, tx, outputIndex, rejected) { + var script = tx.outputs[outputIndex].script; + + // If the script is invalid skip + if (!script) { + return; + } + + var addressInfo = this._extractAddressInfoFromScript(script); + if (!addressInfo) { + return; + } + + addressInfo.hashHex = addressInfo.hashBuffer.toString('hex'); + + // Collect data to publish to address subscribers + if (messages[addressInfo.hashHex]) { + messages[addressInfo.hashHex].outputIndexes.push(outputIndex); + } else { + messages[addressInfo.hashHex] = { + tx: tx, + outputIndexes: [outputIndex], + addressInfo: addressInfo, + rejected: rejected + }; + } +}; + +/** + * This will handle data from the daemon "tx" event, go through each of the outputs + * and send messages to any subscribers for a particular address. + * + * @param {Object} txInfo - The data from the daemon.on('tx') event + * @param {Buffer} txInfo.buffer - The transaction buffer + * @param {Boolean} txInfo.mempool - If the transaction was accepted in the mempool + * @param {String} txInfo.hash - The hash of the transaction + */ +AddressService.prototype.transactionHandler = function(txInfo) { + + // Basic transaction format is handled by the daemon + // and we can safely assume the buffer is properly formatted. + var tx = bitcore.Transaction().fromBuffer(txInfo.buffer); + + var messages = {}; + + var outputsLength = tx.outputs.length; + for (var i = 0; i < outputsLength; i++) { + this.transactionOutputHandler(messages, tx, i, !txInfo.mempool); + } + + for (var key in messages) { + this.transactionEventHandler(messages[key]); + } +}; + +AddressService.prototype._extractAddressInfoFromScript = function(script) { + var hashBuffer; + var addressType; + if (script.isPublicKeyHashOut()) { + hashBuffer = script.chunks[2].buf; + addressType = Address.PayToPublicKeyHash; + } else if (script.isScriptHashOut()) { + hashBuffer = script.chunks[1].buf; + addressType = Address.PayToScriptHash; + } else if (script.isPublicKeyOut()) { + var pubkey = script.chunks[0].buf; + var address = Address.fromPublicKey(new PublicKey(pubkey), this.node.network); + hashBuffer = address.hashBuffer; + } else { + return false; + } + return { + hashBuffer: hashBuffer, + addressType: addressType + }; +}; + +AddressService.prototype.blockHandler = function(block, addOutput, callback) { + var txs = block.transactions; + var height = block.__height; + + var action = 'put'; + if (!addOutput) { + action = 'del'; + } + + var operations = []; + + var transactionLength = txs.length; + for (var i = 0; i < transactionLength; i++) { + + var tx = txs[i]; + var txid = tx.id; + var inputs = tx.inputs; + var outputs = tx.outputs; + + // Subscription messages + var txmessages = {}; + + var outputLength = outputs.length; + for (var outputIndex = 0; outputIndex < outputLength; outputIndex++) { + var output = outputs[outputIndex]; + + var script = output.script; + + if(!script) { + log.debug('Invalid script'); + continue; + } + + var addressInfo = this._extractAddressInfoFromScript(script); + if (!addressInfo) { + continue; + } + + // 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 key = this._encodeOutputKey(addressInfo.hashBuffer, height, txid, outputIndex); + var value = this._encodeOutputValue(output.satoshis, output._scriptBuffer); + operations.push({ + type: action, + key: key, + value: value + }); + + addressInfo.hashHex = addressInfo.hashBuffer.toString('hex'); + + // Collect data for subscribers + if (txmessages[addressInfo.hashHex]) { + txmessages[addressInfo.hashHex].outputIndexes.push(outputIndex); + } else { + txmessages[addressInfo.hashHex] = { + tx: tx, + height: height, + outputIndexes: [outputIndex], + addressInfo: addressInfo, + timestamp: block.header.timestamp + }; + } + + this.balanceEventHandler(block, addressInfo); + + } + + // Publish events to any subscribers for this transaction + for (var addressKey in txmessages) { + this.transactionEventHandler(txmessages[addressKey]); + } + + if(tx.isCoinbase()) { + continue; + } + + for(var inputIndex = 0; inputIndex < inputs.length; inputIndex++) { + + var input = inputs[inputIndex]; + var inputHash; + + if (input.script.isPublicKeyHashIn()) { + inputHash = Hash.sha256ripemd160(input.script.chunks[1].buf); + } else if (input.script.isScriptHashIn()) { + inputHash = Hash.sha256ripemd160(input.script.chunks[input.script.chunks.length - 1].buf); + } else { + continue; + } + + // To be able to query inputs by address and spent height + var inputKey = this._encodeInputKey(inputHash, height, input.prevTxId, input.outputIndex); + var inputValue = this._encodeInputValue(txid, inputIndex); + + operations.push({ + type: action, + key: inputKey, + value: inputValue + }); + } + } + + setImmediate(function() { + callback(null, operations); + }); +}; + +AddressService.prototype._encodeOutputKey = function(hashBuffer, height, txid, outputIndex) { + var heightBuffer = new Buffer(4); + heightBuffer.writeUInt32BE(height); + var outputIndexBuffer = new Buffer(4); + outputIndexBuffer.writeUInt32BE(outputIndex); + var key = Buffer.concat([ + AddressService.PREFIXES.OUTPUTS, + hashBuffer, + AddressService.SPACER_MIN, + heightBuffer, + new Buffer(txid, 'hex'), //TODO get buffer directly from tx + outputIndexBuffer + ]); + return key; +}; + +AddressService.prototype._decodeOutputKey = function(buffer) { + var reader = new BufferReader(buffer); + var prefix = reader.read(1); + var hashBuffer = reader.read(20); + var spacer = reader.read(1); + var height = reader.readUInt32BE(); + var txid = reader.read(32); + var outputIndex = reader.readUInt32BE(); + return { + prefix: prefix, + hashBuffer: hashBuffer, + height: height, + txid: txid, + outputIndex: outputIndex + }; +}; + +AddressService.prototype._encodeOutputValue = function(satoshis, scriptBuffer) { + var satoshisBuffer = new Buffer(8); + satoshisBuffer.writeDoubleBE(satoshis); + return Buffer.concat([satoshisBuffer, scriptBuffer]); +}; + +AddressService.prototype._decodeOutputValue = function(buffer) { + var satoshis = buffer.readDoubleBE(0); + var scriptBuffer = buffer.slice(8, buffer.length); + return { + satoshis: satoshis, + scriptBuffer: scriptBuffer + }; +}; + +AddressService.prototype._encodeInputKey = function(hashBuffer, height, prevTxIdBuffer, outputIndex) { + var heightBuffer = new Buffer(4); + heightBuffer.writeUInt32BE(height); + var outputIndexBuffer = new Buffer(4); + outputIndexBuffer.writeUInt32BE(outputIndex); + return Buffer.concat([ + AddressService.PREFIXES.SPENTS, + hashBuffer, + AddressService.SPACER_MIN, + heightBuffer, + prevTxIdBuffer, + outputIndexBuffer + ]); +}; + +AddressService.prototype._decodeInputKey = function(buffer) { + var reader = new BufferReader(buffer); + var prefix = reader.read(1); + var hashBuffer = reader.read(20); + var spacer = reader.read(1); + var height = reader.readUInt32BE(); + var prevTxId = reader.read(32); + var outputIndex = reader.readUInt32BE(); + return { + prefix: prefix, + hashBuffer: hashBuffer, + height: height, + prevTxId: prevTxId, + outputIndex: outputIndex + }; +}; + +AddressService.prototype._encodeInputValue = function(txid, inputIndex) { + var inputIndexBuffer = new Buffer(4); + inputIndexBuffer.writeUInt32BE(inputIndex); + return Buffer.concat([ + new Buffer(txid, 'hex'), + inputIndexBuffer + ]); +}; + +AddressService.prototype._decodeInputValue = function(buffer) { + var txid = buffer.slice(0, 32); + var inputIndex = buffer.readUInt32BE(32); + return { + txid: txid, + inputIndex: inputIndex + }; +}; + +/** + * @param {Object} obj + * @param {Transaction} obj.tx - The transaction + * @param {Object} obj.addressInfo + * @param {String} obj.addressInfo.hashHex - The hex string of address hash for the subscription + * @param {String} obj.addressInfo.hashBuffer - The address hash buffer + * @param {String} obj.addressInfo.addressType - The address type + * @param {Array} obj.outputIndexes - Indexes of the inputs that includes the address + * @param {Array} obj.inputIndexes - Indexes of the outputs that includes the address + * @param {Date} obj.timestamp - The time of the block the transaction was included + * @param {Number} obj.height - The height of the block the transaction was included + * @param {Boolean} obj.rejected - If the transaction was not accepted in the mempool + */ +AddressService.prototype.transactionEventHandler = function(obj) { + if(this.subscriptions['address/transaction'][obj.addressInfo.hashHex]) { + var emitters = this.subscriptions['address/transaction'][obj.addressInfo.hashHex]; + var address = new Address({ + hashBuffer: obj.addressInfo.hashBuffer, + network: this.node.network, + type: obj.addressInfo.addressType + }); + for(var i = 0; i < emitters.length; i++) { + emitters[i].emit('address/transaction', { + rejected: obj.rejected, + height: obj.height, + timestamp: obj.timestamp, + inputIndexes: obj.inputIndexes, + outputIndexes: obj.outputIndexes, + address: address, + tx: obj.tx + }); + } + } +}; + +/** + * @param {Block} block + * @param {Object} obj + * @param {String} obj.hashHex + * @param {Buffer} obj.hashBuffer + * @param {String} obj.addressType + */ +AddressService.prototype.balanceEventHandler = function(block, obj) { + if(this.subscriptions['address/balance'][obj.hashHex]) { + var emitters = this.subscriptions['address/balance'][obj.hashHex]; + var address = new Address({ + hashBuffer: obj.hashBuffer, + network: this.node.network, + type: obj.addressType + }); + this.getBalance(address, true, function(err, balance) { + if(err) { + return this.emit(err); + } + for(var i = 0; i < emitters.length; i++) { + emitters[i].emit('address/balance', address, balance, block); + } + }); + } +}; + +AddressService.prototype.subscribe = function(name, emitter, addresses) { + $.checkArgument(emitter instanceof EventEmitter, 'First argument is expected to be an EventEmitter'); + $.checkArgument(Array.isArray(addresses), 'Second argument is expected to be an Array of addresses'); + + for(var i = 0; i < addresses.length; i++) { + var hashHex = bitcore.Address(addresses[i]).hashBuffer.toString('hex'); + if(!this.subscriptions[name][hashHex]) { + this.subscriptions[name][hashHex] = []; + } + this.subscriptions[name][hashHex].push(emitter); + } +}; + +AddressService.prototype.unsubscribe = function(name, emitter, addresses) { + $.checkArgument(emitter instanceof EventEmitter, 'First argument is expected to be an EventEmitter'); + $.checkArgument(Array.isArray(addresses) || _.isUndefined(addresses), 'Second argument is expected to be an Array of addresses or undefined'); + + if(!addresses) { + return this.unsubscribeAll(name, emitter); + } + + for(var i = 0; i < addresses.length; i++) { + var hashHex = bitcore.Address(addresses[i]).hashBuffer.toString('hex'); + if(this.subscriptions[name][hashHex]) { + var emitters = this.subscriptions[name][hashHex]; + var index = emitters.indexOf(emitter); + if(index > -1) { + emitters.splice(index, 1); + } + } + } +}; + +AddressService.prototype.unsubscribeAll = function(name, emitter) { + $.checkArgument(emitter instanceof EventEmitter, 'First argument is expected to be an EventEmitter'); + + for(var hashHex in this.subscriptions[name]) { + var emitters = this.subscriptions[name][hashHex]; + var index = emitters.indexOf(emitter); + if(index > -1) { + emitters.splice(index, 1); + } + } +}; + +AddressService.prototype.getBalance = function(address, queryMempool, callback) { + this.getUnspentOutputs(address, queryMempool, function(err, outputs) { + if(err) { + return callback(err); + } + + var satoshis = outputs.map(function(output) { + return output.satoshis; + }); + + var sum = satoshis.reduce(function(a, b) { + return a + b; + }, 0); + + return callback(null, sum); + }); +}; + +/** + * @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 inputs = []; + var stream; + + var hashBuffer = bitcore.Address(addressStr).hashBuffer; + + if (options.start && options.end) { + + var endBuffer = new Buffer(4); + endBuffer.writeUInt32BE(options.end); + + var startBuffer = new Buffer(4); + startBuffer.writeUInt32BE(options.start + 1); + + stream = this.node.services.db.store.createReadStream({ + gte: Buffer.concat([ + AddressService.PREFIXES.SPENTS, + hashBuffer, + AddressService.SPACER_MIN, + endBuffer + ]), + lte: Buffer.concat([ + AddressService.PREFIXES.SPENTS, + hashBuffer, + AddressService.SPACER_MIN, + startBuffer + ]), + valueEncoding: 'binary', + keyEncoding: 'binary' + }); + } else { + var allKey = Buffer.concat([AddressService.PREFIXES.SPENTS, hashBuffer]); + stream = this.node.services.db.store.createReadStream({ + gte: Buffer.concat([allKey, AddressService.SPACER_MIN]), + lte: Buffer.concat([allKey, AddressService.SPACER_MAX]), + valueEncoding: 'binary', + keyEncoding: 'binary' + }); + } + + stream.on('data', function(data) { + + var key = self._decodeInputKey(data.key); + var value = self._decodeInputValue(data.value); + + var input = { + address: addressStr, + txid: value.txid.toString('hex'), + inputIndex: value.inputIndex, + height: key.height, + confirmations: self.node.services.db.tip.__height - key.height + 1 + }; + + inputs.push(input); + + }); + + 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 hashBuffer = bitcore.Address(addressStr).hashBuffer; + + var outputs = []; + var stream; + + if (options.start && options.end) { + + var startBuffer = new Buffer(4); + startBuffer.writeUInt32BE(options.start + 1); + var endBuffer = new Buffer(4); + endBuffer.writeUInt32BE(options.end); + + stream = this.node.services.db.store.createReadStream({ + gte: Buffer.concat([ + AddressService.PREFIXES.OUTPUTS, + hashBuffer, + AddressService.SPACER_MIN, + endBuffer + ]), + lte: Buffer.concat([ + AddressService.PREFIXES.OUTPUTS, + hashBuffer, + AddressService.SPACER_MIN, + startBuffer + ]), + valueEncoding: 'binary', + keyEncoding: 'binary' + }); + } else { + var allKey = Buffer.concat([AddressService.PREFIXES.OUTPUTS, hashBuffer]); + stream = this.node.services.db.store.createReadStream({ + gte: Buffer.concat([allKey, AddressService.SPACER_MIN]), + lte: Buffer.concat([allKey, AddressService.SPACER_MAX]), + valueEncoding: 'binary', + keyEncoding: 'binary' + }); + } + + stream.on('data', function(data) { + + var key = self._decodeOutputKey(data.key); + var value = self._decodeOutputValue(data.value); + + var output = { + address: addressStr, + txid: key.txid.toString('hex'), + outputIndex: key.outputIndex, + height: key.height, + satoshis: value.satoshis, + script: value.scriptBuffer.toString('hex'), + confirmations: self.node.services.db.tip.__height - key.height + 1 + }; + + outputs.push(output); + + }); + + var error; + + stream.on('error', function(streamError) { + if (streamError) { + error = streamError; + } + }); + + stream.on('close', function() { + if (error) { + return callback(error); + } + + if(options.queryMempool) { + outputs = outputs.concat(self.node.services.bitcoind.getMempoolOutputs(addressStr)); + } + callback(null, outputs); + }); + + return stream; + +}; + +AddressService.prototype.getUnspentOutputs = function(addresses, queryMempool, callback) { + var self = this; + + if(!Array.isArray(addresses)) { + addresses = [addresses]; + } + + var utxos = []; + + async.eachSeries(addresses, function(address, next) { + self.getUnspentOutputsForAddress(address, queryMempool, function(err, unspents) { + if(err && err instanceof errors.NoOutputs) { + return next(); + } else if(err) { + return next(err); + } + + utxos = utxos.concat(unspents); + next(); + }); + }, function(err) { + callback(err, utxos); + }); +}; + +AddressService.prototype.getUnspentOutputsForAddress = function(address, queryMempool, callback) { + + var self = this; + + this.getOutputs(address, {queryMempool: queryMempool}, function(err, outputs) { + if (err) { + return callback(err); + } else if(!outputs.length) { + return callback(new errors.NoOutputs('Address ' + address + ' has no outputs'), []); + } + + var isUnspent = function(output, callback) { + self.isUnspent(output, queryMempool, callback); + }; + + async.filter(outputs, isUnspent, function(results) { + callback(null, results); + }); + }); +}; + +AddressService.prototype.isUnspent = function(output, queryMempool, callback) { + this.isSpent(output, queryMempool, function(spent) { + callback(!spent); + }); +}; + +AddressService.prototype.isSpent = function(output, queryMempool, callback) { + var self = this; + var txid = output.prevTxId ? output.prevTxId.toString('hex') : output.txid; + + setImmediate(function() { + callback(self.node.services.bitcoind.isSpent(txid, output.outputIndex)); + }); +}; + +/** + * This will give the total count of transactions for a single or several addresses + * limited by a range of block heights. + * @param {Array} addresses - An array of addresses + * @param {Object} options - The options to limit the query + * @param {Number} [options.start] - The beginning block height (e.g. 1500 the most recent block height). + * @param {Number} [options.end] - The ending block height (e.g. 0 the older block height, results are inclusive). + * @param {Boolean} [options.queryMempool] - Include the mempool in the query + * @param {Function} callback + */ +AddressService.prototype.getAddressHistoryCount = function(addresses, options, callback) { + var history = new AddressHistory({ + node: this.node, + options: options, + addresses: addresses + }); + history.getCount(callback); +}; + +/** + * This will give the history for many addresses limited by a range of block heights (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 (e.g. 1500 the most recent block height). + * @param {Number} [options.end] - The ending block height (e.g. 0 the older block height, results are inclusive). + * @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..608081e9 --- /dev/null +++ b/test/services/address/history.unit.js @@ -0,0 +1,714 @@ +'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.transactionInfo.should.deep.equal([]); + history.combinedArray.should.deep.equal([]); + history.detailedArray.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('#getCount', function() { + it('will complete the async each limit series', function(done) { + var addresses = [address]; + var history = new AddressHistory({ + node: {}, + options: {}, + addresses: addresses + }); + history.getTransactionInfo = sinon.stub().callsArg(1); + history.combineTransactionInfo = sinon.stub(); + history.get(function(err, results) { + if (err) { + throw err; + } + history.getTransactionInfo.callCount.should.equal(1); + history.combineTransactionInfo.callCount.should.equal(1); + 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('#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.detailedArray = expected; + history.combinedArray = [{}]; + history.getTransactionInfo = sinon.stub().callsArg(1); + history.combineTransactionInfo = sinon.stub(); + history.sortAndPaginateCombinedArray = 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.combineTransactionInfo.callCount.should.equal(1); + history.sortAndPaginateCombinedArray.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: 276328 + }, + { + height: 273845, + }, + { + height: 555655 + }, + { + height: 325496 + }, + { + height: 329186 + }, + { + height: 534195 + } + ]; + transactionInfo.sort(AddressHistory.sortByHeight); + transactionInfo[0].height.should.equal(555655); + transactionInfo[1].height.should.equal(534195); + transactionInfo[2].height.should.equal(329186); + transactionInfo[3].height.should.equal(325496); + transactionInfo[4].height.should.equal(276328); + transactionInfo[5].height.should.equal(273845); + }); + it('mempool and tip with time in the future', function() { + var transactionInfo = [ + { + timestamp: 1442050425439, + height: 14, + }, + { + timestamp: 1442050424328, + height: -1 + }, + { + timestamp: 1442050424429, + height: -1 + }, + { + timestamp: 1442050425439, + height: 15 + } + ]; + transactionInfo.sort(AddressHistory.sortByHeight); + transactionInfo[0].height.should.equal(-1); + transactionInfo[0].timestamp.should.equal(1442050424429); + transactionInfo[1].height.should.equal(-1); + transactionInfo[1].timestamp.should.equal(1442050424328); + transactionInfo[2].height.should.equal(15); + transactionInfo[3].height.should.equal(14); + }); + it('tip with time in the future and mempool', function() { + var transactionInfo = [ + { + timestamp: 1442050425439, + height: 14, + }, + { + timestamp: 1442050424328, + height: -1 + } + ]; + transactionInfo.sort(AddressHistory.sortByHeight); + transactionInfo[0].height.should.equal(-1); + transactionInfo[1].height.should.equal(14); + }); + it('many transactions in the mempool', function() { + var transactionInfo = [ + { + timestamp: 1442259670462, + height: -1 + }, + { + timestamp: 1442259785114, + height: -1 + }, + { + timestamp: 1442259759896, + height: -1 + }, + { + timestamp: 1442259692601, + height: -1 + }, + { + timestamp: 1442259692601, + height: 100 + }, + { + timestamp: 1442259749463, + height: -1 + }, + { + timestamp: 1442259737719, + height: -1 + }, + { + timestamp: 1442259773138, + height: -1, + } + ]; + transactionInfo.sort(AddressHistory.sortByHeight); + transactionInfo[0].timestamp.should.equal(1442259785114); + transactionInfo[1].timestamp.should.equal(1442259773138); + transactionInfo[2].timestamp.should.equal(1442259759896); + transactionInfo[3].timestamp.should.equal(1442259749463); + transactionInfo[4].timestamp.should.equal(1442259737719); + transactionInfo[5].timestamp.should.equal(1442259692601); + transactionInfo[6].timestamp.should.equal(1442259670462); + transactionInfo[7].height.should.equal(100); + }); + it('mempool and mempool', function() { + var transactionInfo = [ + { + timestamp: 1442050424328, + height: -1 + }, + { + timestamp: 1442050425439, + height: -1, + } + ]; + transactionInfo.sort(AddressHistory.sortByHeight); + transactionInfo[0].timestamp.should.equal(1442050425439); + transactionInfo[1].timestamp.should.equal(1442050424328); + }); + it('mempool and mempool with the same timestamp', function() { + var transactionInfo = [ + { + timestamp: 1442050425439, + height: -1, + txid: '1', + }, + { + timestamp: 1442050425439, + height: -1, + txid: '2' + } + ]; + transactionInfo.sort(AddressHistory.sortByHeight); + transactionInfo[0].txid.should.equal('1'); + transactionInfo[1].txid.should.equal('2'); + }); + it('matching block heights', function() { + var transactionInfo = [ + { + height: 325496, + txid: '1', + }, + { + height: 325496, + txid: '2' + } + ]; + transactionInfo.sort(AddressHistory.sortByHeight); + transactionInfo[0].txid.should.equal('1'); + transactionInfo[1].txid.should.equal('2'); + }); + }); + + describe('#sortAndPaginateCombinedArray', function() { + it('from 0 to 2', function() { + var history = new AddressHistory({ + node: {}, + options: { + from: 0, + to: 2 + }, + addresses: [] + }); + history.combinedArray = [ + { + height: 13 + }, + { + height: 14, + }, + { + height: 12 + } + ]; + history.sortAndPaginateCombinedArray(); + history.combinedArray.length.should.equal(2); + history.combinedArray[0].height.should.equal(14); + history.combinedArray[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.combinedArray = [ + { + height: 13 + }, + { + height: 14, + }, + { + height: 12 + } + ]; + history.sortAndPaginateCombinedArray(); + history.combinedArray.length.should.equal(3); + history.combinedArray[0].height.should.equal(14); + history.combinedArray[1].height.should.equal(13); + history.combinedArray[2].height.should.equal(12); + }); + it('from 0 to 1', function() { + var history = new AddressHistory({ + node: {}, + options: { + from: 0, + to: 1 + }, + addresses: [] + }); + history.combinedArray = [ + { + height: 13 + }, + { + height: 14, + }, + { + height: 12 + } + ]; + history.sortAndPaginateCombinedArray(); + history.combinedArray.length.should.equal(1); + history.combinedArray[0].height.should.equal(14); + }); + it('from 2 to 3', function() { + var history = new AddressHistory({ + node: {}, + options: { + from: 2, + to: 3 + }, + addresses: [] + }); + history.combinedArray = [ + { + height: 13 + }, + { + height: 14, + }, + { + height: 12 + } + ]; + history.sortAndPaginateCombinedArray(); + history.combinedArray.length.should.equal(1); + history.combinedArray[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.combinedArray = [ + { + height: 13 + }, + { + height: 14, + }, + { + height: 12 + } + ]; + history.sortAndPaginateCombinedArray(); + history.combinedArray.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.getDetailedInfo(txid, function(err) { + if (err) { + throw err; + } + 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 = { + addresses: {}, + txid: txid, + timestamp: 1407292005, + satoshis: 48020000, + address: txAddress + }; + transactionInfo.addresses[txAddress] = {}; + transactionInfo.addresses[txAddress].outputIndexes = [1]; + transactionInfo.addresses[txAddress].inputIndexes = []; + history.getDetailedInfo(transactionInfo, function(err) { + if (err) { + throw err; + } + var info = history.detailedArray[0]; + info.addresses[txAddress].should.deep.equal({ + outputIndexes: [1], + inputIndexes: [] + }); + 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.tx.should.equal(transaction); + }); + }); + }); + describe('#getConfirmationsDetail', function() { + it('the correct confirmations when included in the tip', function() { + var history = new AddressHistory({ + node: { + services: { + db: { + tip: { + __height: 100 + } + } + } + }, + options: {}, + addresses: [] + }); + var transaction = { + __height: 100 + }; + history.getConfirmationsDetail(transaction).should.equal(1); + }); + }); + describe('#getSatoshisDetail', function() { + it('subtract inputIndexes satoshis without outputIndexes', function() { + var history = new AddressHistory({ + node: {}, + options: {}, + addresses: [] + }); + var transaction = { + inputs: [ + { + output: { + satoshis: 10000 + } + } + ] + }; + var txInfo = { + addresses: {} + }; + txInfo.addresses[address] = {}; + txInfo.addresses[address].inputIndexes = [0]; + history.getSatoshisDetail(transaction, txInfo).should.equal(-10000); + }); + }); +}); diff --git a/test/services/address.unit.js b/test/services/address/index.unit.js similarity index 59% rename from test/services/address.unit.js rename to test/services/address/index.unit.js index 2b7c2a89..f8f8a65c 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 = { }; @@ -29,7 +29,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); }); }); @@ -74,13 +74,15 @@ describe('Address Service', function() { var am = new AddressService({node: mocknode}); am.node.network = Networks.livenet; var address = '12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX'; + var hashHex = bitcore.Address(address).hashBuffer.toString('hex'); var messages = {}; am.transactionOutputHandler(messages, tx, 0, true); - should.exist(messages[address]); - var message = messages[address]; + should.exist(messages[hashHex]); + var message = messages[hashHex]; message.tx.should.equal(tx); message.outputIndexes.should.deep.equal([0]); - message.address.should.equal(address); + message.addressInfo.hashBuffer.toString('hex').should.equal(hashHex); + message.addressInfo.hashHex.should.equal(hashHex); message.rejected.should.equal(true); }); }); @@ -106,52 +108,6 @@ describe('Address Service', function() { var am; var testBlock = bitcore.Block.fromString(blockData); - var data = [ - { - key: { - address: '1F1MAvhTKg2VG29w8cXsiSN2PJ8gSsrJw', - timestamp: 1424836934, - txid: 'fdbefe0d064729d85556bd3ab13c3a889b685d042499c02b4aa2064fb1e16923', - outputIndex: 0 - }, - value: { - satoshis: 2502227470, - script: '76a91402a61d2066d19e9e2fd348a8320b7ebd4dd3ca2b88ac', - blockHeight: 345003 - } - }, - { - key: { - prevTxId: '3d7d5d98df753ef2a4f82438513c509e3b11f3e738e94a7234967b03a03123a9', - prevOutputIndex: 32 - }, - value: { - txid: '5780f3ee54889a0717152a01abee9a32cec1b0cdf8d5537a08c7bd9eeb6bfbca', - inputIndex: 0, - timestamp: 1424836934 - } - }, - { - key: { - address: '1Ep5LA4T6Y7zaBPiwruUJurjGFvCJHzJhm', - timestamp: 1424836934, - txid: 'e66f3b989c790178de2fc1a5329f94c0d8905d0d3df4e7ecf0115e7f90a6283d', - outputIndex: 1 - }, - value: { - satoshis: 3100000, - script: '76a9149780ccd5356e2acc0ee439ee04e0fe69426c752888ac', - blockHeight: 345003 - } - } - ]; - var key0 = data[0].key; - var value0 = data[0].value; - var key3 = data[1].key; - var value3 = data[1].value; - var key64 = data[2].key; - var value64 = data[2].value; - before(function() { am = new AddressService({node: mocknode}); am.node.network = Networks.livenet; @@ -170,17 +126,14 @@ 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('-'); - operations[0].key.should.equal(expected0); - operations[0].value.should.equal([value0.satoshis, value0.script, value0.blockHeight].join(':')); + operations[0].key.toString('hex').should.equal('3202a61d2066d19e9e2fd348a8320b7ebd4dd3ca2b00000543abfdbefe0d064729d85556bd3ab13c3a889b685d042499c02b4aa2064fb1e1692300000000'); + operations[0].value.toString('hex').should.equal('41e2a49ec1c0000076a91402a61d2066d19e9e2fd348a8320b7ebd4dd3ca2b88ac'); operations[3].type.should.equal('put'); - var expected3 = ['sp', key3.prevTxId, key3.prevOutputIndex].join('-'); - operations[3].key.should.equal(expected3); - operations[3].value.should.equal([value3.txid, value3.inputIndex].join(':')); + operations[3].key.toString('hex').should.equal('33fdbd324b28ea69e49c998816407dc055fb81d06e00000543ab3d7d5d98df753ef2a4f82438513c509e3b11f3e738e94a7234967b03a03123a900000020'); + operations[3].value.toString('hex').should.equal('5780f3ee54889a0717152a01abee9a32cec1b0cdf8d5537a08c7bd9eeb6bfbca00000000'); operations[64].type.should.equal('put'); - var expected64 = ['outs', key64.address, key64.timestamp, 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].key.toString('hex').should.equal('329780ccd5356e2acc0ee439ee04e0fe69426c752800000543abe66f3b989c790178de2fc1a5329f94c0d8905d0d3df4e7ecf0115e7f90a6283d00000001'); + operations[64].value.toString('hex').should.equal('4147a6b00000000076a9149780ccd5356e2acc0ee439ee04e0fe69426c752888ac'); done(); }); }); @@ -196,14 +149,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.toString('hex').should.equal('3202a61d2066d19e9e2fd348a8320b7ebd4dd3ca2b00000543abfdbefe0d064729d85556bd3ab13c3a889b685d042499c02b4aa2064fb1e1692300000000'); + operations[0].value.toString('hex').should.equal('41e2a49ec1c0000076a91402a61d2066d19e9e2fd348a8320b7ebd4dd3ca2b88ac'); operations[3].type.should.equal('del'); - operations[3].key.should.equal(['sp', key3.prevTxId, key3.prevOutputIndex].join('-')); - operations[3].value.should.equal([value3.txid, value3.inputIndex].join(':')); + operations[3].key.toString('hex').should.equal('33fdbd324b28ea69e49c998816407dc055fb81d06e00000543ab3d7d5d98df753ef2a4f82438513c509e3b11f3e738e94a7234967b03a03123a900000020'); + operations[3].value.toString('hex').should.equal('5780f3ee54889a0717152a01abee9a32cec1b0cdf8d5537a08c7bd9eeb6bfbca00000000'); 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.toString('hex').should.equal('329780ccd5356e2acc0ee439ee04e0fe69426c752800000543abe66f3b989c790178de2fc1a5329f94c0d8905d0d3df4e7ecf0115e7f90a6283d00000001'); + operations[64].value.toString('hex').should.equal('4147a6b00000000076a9149780ccd5356e2acc0ee439ee04e0fe69426c752888ac'); done(); }); }); @@ -276,16 +229,16 @@ describe('Address Service', function() { it('will emit a transaction if there is a subscriber', function(done) { var am = new AddressService({node: mocknode}); var emitter = new EventEmitter(); - am.subscriptions['address/transaction'] = { - '1DzjESe6SLmAKVPLFMj6Sx1sWki3qt5i8N': [emitter] - }; + var address = bitcore.Address('1DzjESe6SLmAKVPLFMj6Sx1sWki3qt5i8N'); + am.subscriptions['address/transaction'] = {}; + am.subscriptions['address/transaction'][address.hashBuffer.toString('hex')] = [emitter]; var block = { __height: 0, timestamp: new Date() }; var tx = {}; emitter.on('address/transaction', function(obj) { - obj.address.should.equal('1DzjESe6SLmAKVPLFMj6Sx1sWki3qt5i8N'); + obj.address.toString().should.equal('1DzjESe6SLmAKVPLFMj6Sx1sWki3qt5i8N'); obj.tx.should.equal(tx); obj.timestamp.should.equal(block.timestamp); obj.height.should.equal(block.__height); @@ -293,7 +246,11 @@ describe('Address Service', function() { done(); }); am.transactionEventHandler({ - address: '1DzjESe6SLmAKVPLFMj6Sx1sWki3qt5i8N', + addressInfo: { + hashHex: address.hashBuffer.toString('hex'), + hashBuffer: address.hashBuffer, + addressType: address.type + }, height: block.__height, timestamp: block.timestamp, outputIndexes: [1], @@ -306,19 +263,22 @@ describe('Address Service', function() { it('will emit a balance if there is a subscriber', function(done) { var am = new AddressService({node: mocknode}); var emitter = new EventEmitter(); - am.subscriptions['address/balance'] = { - '1DzjESe6SLmAKVPLFMj6Sx1sWki3qt5i8N': [emitter] - }; + var address = bitcore.Address('1DzjESe6SLmAKVPLFMj6Sx1sWki3qt5i8N'); + am.subscriptions['address/balance'][address.hashBuffer.toString('hex')] = [emitter]; var block = {}; var balance = 1000; am.getBalance = sinon.stub().callsArgWith(2, null, balance); - emitter.on('address/balance', function(address, bal, b) { - address.should.equal('1DzjESe6SLmAKVPLFMj6Sx1sWki3qt5i8N'); + emitter.on('address/balance', function(a, bal, b) { + a.toString().should.equal('1DzjESe6SLmAKVPLFMj6Sx1sWki3qt5i8N'); bal.should.equal(balance); b.should.equal(block); done(); }); - am.balanceEventHandler(block, '1DzjESe6SLmAKVPLFMj6Sx1sWki3qt5i8N'); + am.balanceEventHandler(block, { + hashHex: address.hashBuffer.toString('hex'), + hashBuffer: address.hashBuffer, + addressType: address.type + }); }); }); @@ -327,34 +287,40 @@ describe('Address Service', function() { var am = new AddressService({node: mocknode}); var emitter = new EventEmitter(); - var address = '1DzjESe6SLmAKVPLFMj6Sx1sWki3qt5i8N'; + var address = bitcore.Address('1DzjESe6SLmAKVPLFMj6Sx1sWki3qt5i8N'); var name = 'address/transaction'; am.subscribe(name, emitter, [address]); - am.subscriptions['address/transaction'][address].should.deep.equal([emitter]); + am.subscriptions['address/transaction'][address.hashBuffer.toString('hex')] + .should.deep.equal([emitter]); - var address2 = '1KiW1A4dx1oRgLHtDtBjcunUGkYtFgZ1W'; + var address2 = bitcore.Address('1KiW1A4dx1oRgLHtDtBjcunUGkYtFgZ1W'); am.subscribe(name, emitter, [address2]); - am.subscriptions['address/transaction'][address2].should.deep.equal([emitter]); + am.subscriptions['address/transaction'][address2.hashBuffer.toString('hex')] + .should.deep.equal([emitter]); var emitter2 = new EventEmitter(); am.subscribe(name, emitter2, [address]); - am.subscriptions['address/transaction'][address].should.deep.equal([emitter, emitter2]); + am.subscriptions['address/transaction'][address.hashBuffer.toString('hex')] + .should.deep.equal([emitter, emitter2]); }); it('will add an emitter to the subscribers array (balance)', function() { var am = new AddressService({node: mocknode}); var emitter = new EventEmitter(); var name = 'address/balance'; - var address = '1DzjESe6SLmAKVPLFMj6Sx1sWki3qt5i8N'; + var address = bitcore.Address('1DzjESe6SLmAKVPLFMj6Sx1sWki3qt5i8N'); am.subscribe(name, emitter, [address]); - am.subscriptions['address/balance'][address].should.deep.equal([emitter]); + am.subscriptions['address/balance'][address.hashBuffer.toString('hex')] + .should.deep.equal([emitter]); - var address2 = '1KiW1A4dx1oRgLHtDtBjcunUGkYtFgZ1W'; + var address2 = bitcore.Address('1KiW1A4dx1oRgLHtDtBjcunUGkYtFgZ1W'); am.subscribe(name, emitter, [address2]); - am.subscriptions['address/balance'][address2].should.deep.equal([emitter]); + am.subscriptions['address/balance'][address2.hashBuffer.toString('hex')] + .should.deep.equal([emitter]); var emitter2 = new EventEmitter(); am.subscribe(name, emitter2, [address]); - am.subscriptions['address/balance'][address].should.deep.equal([emitter, emitter2]); + am.subscriptions['address/balance'][address.hashBuffer.toString('hex')] + .should.deep.equal([emitter, emitter2]); }); }); @@ -363,35 +329,37 @@ describe('Address Service', function() { var am = new AddressService({node: mocknode}); var emitter = new EventEmitter(); var emitter2 = new EventEmitter(); - var address = '1DzjESe6SLmAKVPLFMj6Sx1sWki3qt5i8N'; - am.subscriptions['address/transaction'][address] = [emitter, emitter2]; + var address = bitcore.Address('1DzjESe6SLmAKVPLFMj6Sx1sWki3qt5i8N'); + am.subscriptions['address/transaction'][address.hashBuffer.toString('hex')] = [emitter, emitter2]; var name = 'address/transaction'; am.unsubscribe(name, emitter, [address]); - am.subscriptions['address/transaction'][address].should.deep.equal([emitter2]); + am.subscriptions['address/transaction'][address.hashBuffer.toString('hex')] + .should.deep.equal([emitter2]); }); it('will remove emitter from subscribers array (balance)', function() { var am = new AddressService({node: mocknode}); var emitter = new EventEmitter(); var emitter2 = new EventEmitter(); - var address = '1DzjESe6SLmAKVPLFMj6Sx1sWki3qt5i8N'; + var address = bitcore.Address('1DzjESe6SLmAKVPLFMj6Sx1sWki3qt5i8N'); var name = 'address/balance'; - am.subscriptions['address/balance'][address] = [emitter, emitter2]; + am.subscriptions['address/balance'][address.hashBuffer.toString('hex')] = [emitter, emitter2]; am.unsubscribe(name, emitter, [address]); - am.subscriptions['address/balance'][address].should.deep.equal([emitter2]); + am.subscriptions['address/balance'][address.hashBuffer.toString('hex')] + .should.deep.equal([emitter2]); }); it('should unsubscribe from all addresses if no addresses are specified', function() { var am = new AddressService({node: mocknode}); var emitter = new EventEmitter(); var emitter2 = new EventEmitter(); - am.subscriptions['address/balance'] = { - '1KiW1A4dx1oRgLHtDtBjcunUGkYtFgZ1W': [emitter, emitter2], - '1DzjESe6SLmAKVPLFMj6Sx1sWki3qt5i8N': [emitter2, emitter] - }; + var address1 = bitcore.Address('1KiW1A4dx1oRgLHtDtBjcunUGkYtFgZ1W'); + var hashHex1 = address1.hashBuffer.toString('hex'); + var address2 = bitcore.Address('1DzjESe6SLmAKVPLFMj6Sx1sWki3qt5i8N'); + var hashHex2 = address2.hashBuffer.toString('hex'); + am.subscriptions['address/balance'][hashHex1] = [emitter, emitter2]; + am.subscriptions['address/balance'][hashHex2] = [emitter2, emitter]; am.unsubscribe('address/balance', emitter); - am.subscriptions['address/balance'].should.deep.equal({ - '1KiW1A4dx1oRgLHtDtBjcunUGkYtFgZ1W': [emitter2], - '1DzjESe6SLmAKVPLFMj6Sx1sWki3qt5i8N': [emitter2] - }); + am.subscriptions['address/balance'][hashHex1].should.deep.equal([emitter2]); + am.subscriptions['address/balance'][hashHex2].should.deep.equal([emitter2]); }); }); @@ -421,9 +389,10 @@ describe('Address Service', function() { }); - describe('#getOutputs', function() { + describe('#getInputs', function() { var am; var address = '1KiW1A4dx1oRgLHtDtBjcunUGkYtFgZ1W'; + var hashBuffer = bitcore.Address(address).hashBuffer; var db = { tip: { __height: 1 @@ -437,11 +406,170 @@ describe('Address Service', function() { } } }; + 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) { + var gte = Buffer.concat([AddressService.PREFIXES.SPENTS, hashBuffer, new Buffer('000000000c', 'hex')]); + ops.gte.toString('hex').should.equal(gte.toString('hex')); + var lte = Buffer.concat([AddressService.PREFIXES.SPENTS, hashBuffer, new Buffer('0000000010', 'hex')]); + ops.lte.toString('hex').should.equal(lte.toString('hex')); + 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: new Buffer('33038a213afdfc551fc658e9a2a58a86e98d69b687000000000f125dd0e50fc732d67c37b6c56be7f9dc00b6859cebf982ee2cc83ed2d604bf8700000001', 'hex'), + value: new Buffer('3b6bc2939d1a70ce04bc4f619ee32608fbff5e565c1f9b02e4eaa97959c59ae700000000', 'hex') + }; + 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) { + var gte = Buffer.concat([AddressService.PREFIXES.SPENTS, hashBuffer, new Buffer('00', 'hex')]); + ops.gte.toString('hex').should.equal(gte.toString('hex')); + var lte = Buffer.concat([AddressService.PREFIXES.SPENTS, hashBuffer, new Buffer('ff', 'hex')]); + ops.lte.toString('hex').should.equal(lte.toString('hex')); + 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: new Buffer('33038a213afdfc551fc658e9a2a58a86e98d69b687000000000f125dd0e50fc732d67c37b6c56be7f9dc00b6859cebf982ee2cc83ed2d604bf8700000001', 'hex'), + value: new Buffer('3b6bc2939d1a70ce04bc4f619ee32608fbff5e565c1f9b02e4eaa97959c59ae700000000', 'hex') + }; + 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'; + var hashBuffer = bitcore.Address('1KiW1A4dx1oRgLHtDtBjcunUGkYtFgZ1W').hashBuffer; + var db = { + tip: { + __height: 1 + } + }; + var testnode = { + services: { + db: db, + bitcoind: { + on: sinon.stub() + } + } + }; + 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) { + var gte = Buffer.concat([AddressService.PREFIXES.OUTPUTS, hashBuffer, new Buffer('000000000c', 'hex')]); + ops.gte.toString('hex').should.equal(gte.toString('hex')); + var lte = Buffer.concat([AddressService.PREFIXES.OUTPUTS, hashBuffer, new Buffer('0000000010', 'hex')]); + ops.lte.toString('hex').should.equal(lte.toString('hex')); + 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: new Buffer('32038a213afdfc551fc658e9a2a58a86e98d69b687000000000f125dd0e50fc732d67c37b6c56be7f9dc00b6859cebf982ee2cc83ed2d604bf8700000001', 'hex'), + value: new Buffer('41f0de058a80000076a914038a213afdfc551fc658e9a2a58a86e98d69b68788ac', 'hex') + }; + 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 +580,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 +588,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: new Buffer('32038a213afdfc551fc658e9a2a58a86e98d69b68700000543a8125dd0e50fc732d67c37b6c56be7f9dc00b6859cebf982ee2cc83ed2d604bf8700000001', 'hex'), + value: new Buffer('41f0de058a80000076a914038a213afdfc551fc658e9a2a58a86e98d69b68788ac', 'hex') }; var data2 = { - key: ['outs', address, 1424837300000, '3b6bc2939d1a70ce04bc4f619ee32608fbff5e565c1f9b02e4eaa97959c59ae7', '2'].join('-'), - value: ['10000', 'OP_DUP OP_HASH160 038a213afdfc551fc658e9a2a58a86e98d69b687 OP_EQUALVERIFY OP_CHECKSIG', '345004'].join(':') + key: new Buffer('32038a213afdfc551fc658e9a2a58a86e98d69b68700000543ac3b6bc2939d1a70ce04bc4f619ee32608fbff5e565c1f9b02e4eaa97959c59ae700000002', 'hex'), + value: new Buffer('40c388000000000076a914038a213afdfc551fc658e9a2a58a86e98d69b68788ac', 'hex') }; readStream1.emit('data', data1); @@ -505,14 +631,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 +857,39 @@ 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'); + describe('#getAddressHistoryCount', function() { + it('will call getCount on address history instance', function(done) { + function TestAddressHistory(args) { + args.node.should.equal(mocknode); + args.addresses.should.deep.equal([]); + args.options.should.deep.equal({}); + } + TestAddressHistory.prototype.getCount = sinon.stub().callsArg(0); + var TestAddressService = proxyquire('../../../lib/services/address', { + './history': TestAddressHistory + }); + var am = new TestAddressService({node: mocknode}); + am.getAddressHistoryCount([], {}, function(err, history) { + TestAddressHistory.prototype.getCount.callCount.should.equal(1); 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(); }); });