diff --git a/integration/regtest.js b/integration/regtest.js index d96f2228..116e2789 100644 --- a/integration/regtest.js +++ b/integration/regtest.js @@ -304,4 +304,16 @@ describe('Daemon Binding Functionality', function() { }); + describe('get transaction with block info', function() { + it('should include tx buffer, height and timestamp', function(done) { + bitcoind.getTransactionWithBlockInfo(utxo.txid, true, function(err, data) { + should.not.exist(err); + data.height.should.equal(151); + should.exist(data.timestamp); + should.exist(data.buffer); + done(); + }); + }); + }); + }); diff --git a/lib/daemon.js b/lib/daemon.js index 0133bfb2..3f9f985d 100644 --- a/lib/daemon.js +++ b/lib/daemon.js @@ -362,6 +362,10 @@ Daemon.prototype.getTransactionWithBlock = function(txid, blockhash, callback) { }); }; +Daemon.prototype.getTransactionWithBlockInfo = function(txid, queryMempool, callback) { + return bitcoindjs.getTransactionWithBlockInfo(txid, queryMempool, callback); +}; + Daemon.prototype.getMempoolOutputs = function(address) { return bitcoindjs.getMempoolOutputs(address); }; diff --git a/lib/db.js b/lib/db.js index 7d7b6298..dd0d89b8 100644 --- a/lib/db.js +++ b/lib/db.js @@ -80,6 +80,20 @@ DB.prototype.getTransaction = function(txid, queryMempool, callback) { }); }; +DB.prototype.getTransactionWithBlockInfo = function(txid, queryMempool, callback) { + this.bitcoind.getTransactionWithBlockInfo(txid, queryMempool, function(err, obj) { + if(err) { + return callback(err); + } + + var tx = Transaction().fromBuffer(obj.buffer); + tx.__height = obj.height; + tx.__timestamp = obj.timestamp; + + callback(null, tx); + }); +} + DB.prototype.validateBlockData = function(block, callback) { // bitcoind does the validation setImmediate(callback); diff --git a/lib/modules/address.js b/lib/modules/address.js index 89e62874..c4a582f8 100644 --- a/lib/modules/address.js +++ b/lib/modules/address.js @@ -5,6 +5,7 @@ var inherits = require('util').inherits; var async = require('async'); var chainlib = require('chainlib'); var log = chainlib.log; +var levelup = chainlib.deps.levelup; var errors = chainlib.errors; var bitcore = require('bitcore'); var $ = bitcore.util.preconditions; @@ -23,7 +24,8 @@ var AddressModule = function(options) { inherits(AddressModule, BaseModule); AddressModule.PREFIXES = { - OUTPUTS: 'outs' + OUTPUTS: 'outs', + SPENTS: 'sp' }; AddressModule.prototype.getAPIMethods = function() { @@ -31,7 +33,8 @@ AddressModule.prototype.getAPIMethods = function() { ['getBalance', this, this.getBalance, 2], ['getOutputs', this, this.getOutputs, 2], ['getUnspentOutputs', this, this.getUnspentOutputs, 2], - ['isSpent', this, this.isSpent, 2] + ['isSpent', this, this.isSpent, 2], + ['getAddressHistory', this, this.getAddressHistory, 2] ]; }; @@ -116,6 +119,14 @@ AddressModule.prototype.blockHandler = function(block, addOutput, callback) { continue; } + for(var j = 0; j < inputs.length; j++) { + var input = inputs[j].toObject(); + operations.push({ + type: action, + key: [AddressModule.PREFIXES.SPENTS, input.prevTxId, input.outputIndex].join('-'), + value: [txid, j].join(':') + }); + } } setImmediate(function() { @@ -281,4 +292,115 @@ AddressModule.prototype.isSpent = function(output, queryMempool, callback) { }); }; -module.exports = AddressModule; +AddressModule.prototype.getSpendInfoForOutput = function(txid, outputIndex, callback) { + var self = this; + + var key = [AddressModule.PREFIXES.SPENTS, txid, outputIndex].join('-'); + this.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); + }); +}; + +AddressModule.prototype.getAddressHistory = function(address, queryMempool, callback) { + var self = this; + + var txinfos = {}; + + function getTransactionInfo(txid, callback) { + if(txinfos[txid]) { + return callback(null, txinfos[txid]); + } + + self.db.getTransactionWithBlockInfo(txid, queryMempool, function(err, transaction) { + if(err) { + return callback(err); + } + + transaction.populateInputs(self.db, [], function(err) { + if(err) { + return callback(err); + } + + txinfos[transaction.hash] = { + satoshis: 0, + height: transaction.__height, + timestamp: transaction.__timestamp, + outputIndexes: [], + inputIndexes: [], + transaction: 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.transaction.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 = AddressModule; \ No newline at end of file diff --git a/lib/transaction.js b/lib/transaction.js index 76893db7..f2431409 100644 --- a/lib/transaction.js +++ b/lib/transaction.js @@ -54,6 +54,10 @@ Transaction.prototype._validateInputs = function(db, poolTransactions, callback) Transaction.prototype.populateInputs = function(db, poolTransactions, callback) { var self = this; + if(this.isCoinbase()) { + return setImmediate(callback); + } + async.each( this.inputs, function(input, next) { @@ -68,7 +72,7 @@ Transaction.prototype._populateInput = function(db, input, poolTransactions, cal return callback(new Error('Input is expected to have prevTxId as a buffer')); } var txid = input.prevTxId.toString('hex'); - db.getTransactionFromDB(txid, function(err, prevTx) { + db.getTransaction(txid, false, function(err, prevTx) { if(err instanceof levelup.errors.NotFoundError) { // Check the pool for transaction for(var i = 0; i < poolTransactions.length; i++) { diff --git a/src/bitcoindjs.cc b/src/bitcoindjs.cc index 172286f9..fb153016 100644 --- a/src/bitcoindjs.cc +++ b/src/bitcoindjs.cc @@ -74,6 +74,12 @@ async_get_tx(uv_work_t *req); static void async_get_tx_after(uv_work_t *req); +static void +async_get_tx_and_info(uv_work_t *req); + +static void +async_get_tx_and_info_after(uv_work_t *req); + static bool process_messages(CNode* pfrom); @@ -156,6 +162,8 @@ struct async_tx_data { std::string err_msg; std::string txid; std::string blockhash; + uint32_t nTime; + int64_t height; bool queryMempool; CTransaction ctx; Eternal callback; @@ -1018,7 +1026,7 @@ async_get_block_after(uv_work_t *req) { /** * GetTransaction() - * bitcoind.getTransaction(txid, callback) + * bitcoind.getTransaction(txid, queryMempool, callback) * Read any transaction from disk asynchronously. */ @@ -1030,7 +1038,7 @@ NAN_METHOD(GetTransaction) { || !args[1]->IsBoolean() || !args[2]->IsFunction()) { return NanThrowError( - "Usage: bitcoindjs.getTransaction(txid, callback)"); + "Usage: daemon.getTransaction(txid, queryMempool, callback)"); } String::Utf8Value txid_(args[0]->ToString()); @@ -1145,6 +1153,154 @@ async_get_tx_after(uv_work_t *req) { delete req; } +/** + * GetTransactionWithBlockInfo() + * bitcoind.getTransactionWithBlockInfo(txid, queryMempool, callback) + * Read any transaction from disk asynchronously with block timestamp and height. + */ + +NAN_METHOD(GetTransactionWithBlockInfo) { + Isolate* isolate = Isolate::GetCurrent(); + HandleScope scope(isolate); + if (args.Length() < 3 + || !args[0]->IsString() + || !args[1]->IsBoolean() + || !args[2]->IsFunction()) { + return NanThrowError( + "Usage: bitcoindjs.getTransactionWithBlockInfo(txid, queryMempool, callback)"); + } + + String::Utf8Value txid_(args[0]->ToString()); + bool queryMempool = args[1]->BooleanValue(); + Local callback = Local::Cast(args[2]); + + async_tx_data *data = new async_tx_data(); + + data->err_msg = std::string(""); + data->txid = std::string(""); + + std::string txid = std::string(*txid_); + + data->txid = txid; + data->queryMempool = queryMempool; + Eternal eternal(isolate, callback); + data->callback = eternal; + + uv_work_t *req = new uv_work_t(); + req->data = data; + + int status = uv_queue_work(uv_default_loop(), + req, async_get_tx_and_info, + (uv_after_work_cb)async_get_tx_and_info_after); + + assert(status == 0); + + NanReturnValue(Undefined(isolate)); +} + +static void +async_get_tx_and_info(uv_work_t *req) { + async_tx_data* data = static_cast(req->data); + + uint256 hash = uint256S(data->txid); + uint256 blockHash; + CTransaction ctx; + + if (data->queryMempool) { + LOCK(mempool.cs); + map::const_iterator i = mempool.mapTx.find(hash); + if (i != mempool.mapTx.end()) { + data->ctx = i->second.GetTx(); + data->nTime = i->second.GetTime(); + data->height = -1; + return; + } + } + + CDiskTxPos postx; + if (pblocktree->ReadTxIndex(hash, postx)) { + + CAutoFile file(OpenBlockFile(postx, true), SER_DISK, CLIENT_VERSION); + + if (file.IsNull()) { + data->err_msg = std::string("%s: OpenBlockFile failed", __func__); + return; + } + + CBlockHeader blockHeader; + + try { + // Read header first to get block timestamp and hash + file >> blockHeader; + blockHash = blockHeader.GetHash(); + data->nTime = blockHeader.nTime; + fseek(file.Get(), postx.nTxOffset, SEEK_CUR); + file >> ctx; + data->ctx = ctx; + } catch (const std::exception& e) { + data->err_msg = std::string("Deserialize or I/O error - %s", __func__); + return; + } + + // get block height + CBlockIndex* blockIndex; + + if (mapBlockIndex.count(blockHash) == 0) { + data->height = -1; + } else { + blockIndex = mapBlockIndex[blockHash]; + data->height = blockIndex->nHeight; + } + + } + +} + +static void +async_get_tx_and_info_after(uv_work_t *req) { + Isolate* isolate = Isolate::GetCurrent(); + HandleScope scope(isolate); + async_tx_data* data = static_cast(req->data); + + CTransaction ctx = data->ctx; + Local cb = data->callback.Get(isolate); + Local obj = NanNew(); + + if (data->err_msg != "") { + Local err = Exception::Error(NanNew(data->err_msg)); + const unsigned argc = 1; + Local argv[argc] = { err }; + TryCatch try_catch; + cb->Call(isolate->GetCurrentContext()->Global(), argc, argv); + if (try_catch.HasCaught()) { + node::FatalException(try_catch); + } + } else { + + CDataStream ssTx(SER_NETWORK, PROTOCOL_VERSION); + ssTx << ctx; + std::string stx = ssTx.str(); + Local rawNodeBuffer = node::Buffer::New(isolate, stx.c_str(), stx.size()); + + obj->Set(NanNew("height"), NanNew(data->height)); + obj->Set(NanNew("timestamp"), NanNew(data->nTime)); + obj->Set(NanNew("buffer"), rawNodeBuffer); + + const unsigned argc = 2; + Local argv[argc] = { + Local::New(isolate, NanNull()), + obj + }; + TryCatch try_catch; + cb->Call(isolate->GetCurrentContext()->Global(), argc, argv); + if (try_catch.HasCaught()) { + node::FatalException(try_catch); + } + } + delete data; + delete req; +} + /** * IsSpent() * bitcoindjs.isSpent() @@ -1465,6 +1621,7 @@ init(Handle target) { NODE_SET_METHOD(target, "stopped", IsStopped); NODE_SET_METHOD(target, "getBlock", GetBlock); NODE_SET_METHOD(target, "getTransaction", GetTransaction); + NODE_SET_METHOD(target, "getTransactionWithBlockInfo", GetTransactionWithBlockInfo); NODE_SET_METHOD(target, "getInfo", GetInfo); NODE_SET_METHOD(target, "isSpent", IsSpent); NODE_SET_METHOD(target, "getBlockIndex", GetBlockIndex); diff --git a/test/modules/address.unit.js b/test/modules/address.unit.js index 1bd55439..71c32478 100644 --- a/test/modules/address.unit.js +++ b/test/modules/address.unit.js @@ -8,6 +8,8 @@ var blockData = require('../data/livenet-345003.json'); var bitcore = require('bitcore'); var EventEmitter = require('events').EventEmitter; var errors = bitcoindjs.errors; +var chainlib = require('chainlib'); +var levelup = chainlib.deps.levelup; describe('AddressModule', function() { @@ -15,7 +17,7 @@ describe('AddressModule', function() { it('should return the correct methods', function() { var am = new AddressModule({}); var methods = am.getAPIMethods(); - methods.length.should.equal(4); + methods.length.should.equal(5); }); }); @@ -111,21 +113,35 @@ describe('AddressModule', function() { it('should create the correct operations when updating/adding outputs', function(done) { am.blockHandler({__height: 345003, timestamp: new Date(1424836934000)}, true, function(err, operations) { should.not.exist(err); - operations.length.should.equal(11); + 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[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[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(':')); done(); }); }); it('should create the correct operations when removing outputs', function(done) { am.blockHandler({__height: 345003, timestamp: new Date(1424836934000)}, false, function(err, operations) { should.not.exist(err); - operations.length.should.equal(11); + 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[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[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(':')); done(); }); }); @@ -481,4 +497,210 @@ describe('AddressModule', 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 am = new AddressModule({db: db}); + am.getSpendInfoForOutput('txid', 3, function(err, info) { + should.not.exist(err); + info.txid.should.equal('spendtxid'); + info.inputIndex.should.equal('1'); + db.store.get.args[0][0].should.equal('sp-txid-3'); + done(); + }); + }); + }); + + describe('#getAddressHistory', function() { + var incoming = [ + { + txid: 'tx1', + outputIndex: 0, + spentTx: 'tx2', + inputIndex: 0, + height: 1, + timestamp: 1438289011844, + satoshis: 5000 + }, + { + txid: 'tx3', + outputIndex: 1, + height: 3, + timestamp: 1438289031844, + satoshis: 2000 + }, + { + txid: 'tx4', + outputIndex: 2, + spentTx: 'tx5', + inputIndex: 1, + height: 4, + timestamp: 1438289041844, + satoshis: 3000 + }, + ]; + + var outgoing = [ + { + txid: 'tx2', + height: 2, + timestamp: 1438289021844, + inputs: [ + { + output: { + satoshis: 5000 + } + } + ] + }, + { + txid: 'tx5', + height: 5, + timestamp: 1438289051844, + inputs: [ + {}, + { + output: { + satoshis: 3000 + } + } + ] + } + ]; + + var db = { + 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; + 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; + return callback(null, transaction); + } + } + callback(new Error('tx ' + txid + ' not found')); + } + }; + var am = new AddressModule({db: db}); + + 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].transaction.hash.should.equal('tx1'); + history[0].satoshis.should.equal(5000); + history[0].height.should.equal(1); + history[0].timestamp.should.equal(1438289011844); + history[1].transaction.hash.should.equal('tx2'); + history[1].satoshis.should.equal(-5000); + history[1].height.should.equal(2); + history[1].timestamp.should.equal(1438289021844); + history[2].transaction.hash.should.equal('tx3'); + history[2].satoshis.should.equal(2000); + history[2].height.should.equal(3); + history[2].timestamp.should.equal(1438289031844); + history[3].transaction.hash.should.equal('tx4'); + history[3].satoshis.should.equal(3000); + history[3].height.should.equal(4); + history[3].timestamp.should.equal(1438289041844); + history[4].transaction.hash.should.equal('tx5'); + history[4].satoshis.should.equal(-3000); + history[4].height.should.equal(5); + history[4].timestamp.should.equal(1438289051844); + done(); + }); + }); + + 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'); + done(); + }); + }); + }); + }); diff --git a/test/transaction.unit.js b/test/transaction.unit.js index cb3d56ab..9f02ba74 100644 --- a/test/transaction.unit.js +++ b/test/transaction.unit.js @@ -106,6 +106,7 @@ describe('Bitcoin Transaction', function() { describe('#populateInputs', function() { it('will call _populateInput with transactions', function() { var tx = new Transaction(); + tx.isCoinbase = sinon.stub().returns(false); tx._populateInput = sinon.stub().callsArg(3); tx.inputs = ['input']; var transactions = []; @@ -138,7 +139,7 @@ describe('Bitcoin Transaction', function() { it('if an error happened it should pass it along', function(done) { var tx = new Transaction(); var db = { - getTransactionFromDB: sinon.stub().callsArgWith(1, new Error('error')) + getTransaction: sinon.stub().callsArgWith(2, new Error('error')) }; tx._populateInput(db, input, [], function(err) { should.exist(err); @@ -149,7 +150,7 @@ describe('Bitcoin Transaction', function() { it('should return an error if the transaction for the input does not exist', function(done) { var tx = new Transaction(); var db = { - getTransactionFromDB: sinon.stub().callsArgWith(1, new levelup.errors.NotFoundError()) + getTransaction: sinon.stub().callsArgWith(2, new levelup.errors.NotFoundError()) }; tx._populateInput(db, input, [], function(err) { should.exist(err); @@ -160,7 +161,7 @@ describe('Bitcoin Transaction', function() { it('should look through poolTransactions if database does not have transaction', function(done) { var tx = new Transaction(); var db = { - getTransactionFromDB: sinon.stub().callsArgWith(1, new levelup.errors.NotFoundError()) + getTransaction: sinon.stub().callsArgWith(2, new levelup.errors.NotFoundError()) }; var transactions = [ { @@ -179,7 +180,7 @@ describe('Bitcoin Transaction', function() { prevTx.outputs = ['output']; var tx = new Transaction(); var db = { - getTransactionFromDB: sinon.stub().callsArgWith(1, null, prevTx) + getTransaction: sinon.stub().callsArgWith(2, null, prevTx) }; tx._populateInput(db, input, [], function(err) { should.not.exist(err);