diff --git a/lib/bcoin/hd.js b/lib/bcoin/hd.js index dd45e04d..d9632ce7 100644 --- a/lib/bcoin/hd.js +++ b/lib/bcoin/hd.js @@ -844,7 +844,7 @@ HDPrivateKey.prototype.toJSON = function toJSON(passphrase) { return json; } - json.xpubkey = this.hd.xpubkey; + json.xpubkey = this.xpubkey; return json; }; diff --git a/lib/bcoin/http.js b/lib/bcoin/http.js index 6b1e66e5..710b14df 100644 --- a/lib/bcoin/http.js +++ b/lib/bcoin/http.js @@ -182,72 +182,94 @@ HTTPServer.prototype._init = function _init() { // Create/get wallet this.post('/wallet/:id', function(req, res, next, send) { req.body.id = req.params.id; - self.node.walletdb.createJSON(req.body.id, req.body, function(err, json) { + self.node.walletdb.create(req.body, function(err, wallet) { + var wallet; + if (err) return next(err); - if (!json) + if (!wallet) return send(404); + json = wallet.toJSON(); + wallet.destroy(); + send(200, json); }); }); // Update wallet / sync address depth this.put('/wallet/:id', function(req, res, next, send) { - req.body.id = req.params.id; - self.node.walletdb.saveJSON(req.body.id, req.body, function(err, json) { + var id = req.params.id; + var receive = req.body.receiveDepth; + var change = req.body.changeDepth; + self.node.walletdb.syncDepth(id, receive, change, function(err) { if (err) return next(err); if (!json) return send(404); - send(200, json); + send(200, { success: true }); + }); + }); + + // Wallet Balance + this.get('/wallet/:id/balance', function(req, res, next, send) { + self.node.walletdb.getBalance(req.params.id, function(err, balance) { + if (err) + return next(err); + + if (!coins.length) + return send(404); + + send(200, { balance: utils.btc(balance) }); }); }); // Wallet UTXOs this.get('/wallet/:id/utxo', function(req, res, next, send) { - self.node.walletdb.getJSON(req.params.id, function(err, json) { + self.node.walletdb.getUnspent(req.params.id, function(err, coins) { if (err) return next(err); - if (!json) + if (!coins.length) return send(404); - self.node.getCoinByAddress(Object.keys(json.addressMap), function(err, coins) { - if (err) - return next(err); - - if (!coins.length) - return send(404); - - send(200, coins.map(function(coin) { return coin.toJSON(); })); - }); + send(200, coins.map(function(coin) { return coin.toJSON(); })); }); }); // Wallet TXs this.get('/wallet/:id/tx', function(req, res, next, send) { - self.node.walletdb.getJSON(req.params.id, function(err, json) { + self.node.walletdb.getAll(req.params.id, function(err, txs) { if (err) return next(err); - if (!json) + if (!txs.length) return send(404); - self.node.getTXByAddress(Object.keys(json.addressMap), function(err, txs) { - if (err) - return next(err); - - if (!txs.length) - return send(404); - - send(200, coins.map(function(tx) { return tx.toJSON(); })); - }); + send(200, txs.map(function(tx) { return tx.toJSON(); })); }); }); + + // Wallet Pending TXs + this.get('/wallet/:id/pending', function(req, res, next, send) { + self.node.walletdb.getPending(req.params.id, function(err, txs) { + if (err) + return next(err); + + if (!txs.length) + return send(404); + + send(200, txs.map(function(tx) { return tx.toJSON(); })); + }); + }); + + // Emit events for any wallet txs that come in. + this.node.on('wallet tx', function(tx, ids) { + self.sendWebhook({ tx: tx, ids: ids }); + }); }; HTTPServer.prototype.listen = function listen(port, host, callback) { @@ -370,6 +392,42 @@ HTTPServer.prototype.del = function del(path, callback) { this.routes.del.push({ path: path, callback: callback }); }; +// Send webhooks to notify other servers +// of incoming txs and wallet updates. +HTTPServer.prototype.sendWebhook = function sendWebhook(msg, callback) { + var request, body, secret, hmac; + + callback = utils.ensure(callback); + + if (!this.options.webhook) + return callback(); + + try { + request = require('request'); + } catch (e) { + return callback(e); + } + + body = new Buffer(JSON.stringify(msg) + '\n', 'utf8'); + secret = new Buffer(this.options.webhook.secret || '', 'utf8'); + hmac = utils.sha512hmac(body, secret); + + request({ + method: 'POST', + uri: this.options.webhook.endpoint, + headers: { + 'Content-Type': 'application/json; charset=utf-8', + 'Content-Length': body.length + '', + 'X-Bcoin-Hmac': hmac.toString('hex') + }, + body: body + }, function(err, res, body) { + if (err) + return callback(err); + return callback(); + }); +}; + /** * Helpers */ diff --git a/lib/bcoin/node.js b/lib/bcoin/node.js index 73d1d967..c05159e8 100644 --- a/lib/bcoin/node.js +++ b/lib/bcoin/node.js @@ -81,7 +81,8 @@ Fullnode.prototype._init = function _init() { // and blockdb. this.http = new bcoin.http(this, { key: this.options.httpKey, - cert: this.options.httpCert + cert: this.options.httpCert, + webhook: this.options.webhook }); // Bind to errors @@ -97,12 +98,20 @@ Fullnode.prototype._init = function _init() { self.emit('error', err); }); + this.walletdb.on('error', function(err) { + self.emit('error', err); + }); + // Emit events for any TX we see that's // is relevant to one of our wallets. + this.walletdb.on('wallet tx', function(tx, ids) { + self.emit('wallet tx', tx, ids); + }); + this.on('tx', function(tx) { - self.walletdb.tx.addTX(tx, function(err, updated) { - if (updated) - self.emit('wallet tx', tx); + self.walletdb.tx.addTX(tx, function(err) { + if (err) + self.emit('error', err); }); }); diff --git a/lib/bcoin/node2.js b/lib/bcoin/node2.js index 8ef701ab..34afb643 100644 --- a/lib/bcoin/node2.js +++ b/lib/bcoin/node2.js @@ -43,7 +43,7 @@ function Node(options) { this.pool = null; this.chain = null; this.miner = null; - this.profiler = null; + this.walletdb = null; Node.global = this; } diff --git a/lib/bcoin/txdb.js b/lib/bcoin/txdb.js index c220ac0b..a95f84d1 100644 --- a/lib/bcoin/txdb.js +++ b/lib/bcoin/txdb.js @@ -14,18 +14,27 @@ var EventEmitter = require('events').EventEmitter; * TXPool */ -function TXPool(prefix, db) { +function TXPool(prefix, db, options) { var self = this; if (!(this instanceof TXPool)) - return new TXPool(wallet, txs); + return new TXPool(prefix, db, options); EventEmitter.call(this); + if (!options) + options = {}; + this.db = db; this.prefix = prefix || 'pool'; + this.options = options; this.busy = false; this.jobs = []; + + if (options.addressFilter) + this._hasAddress = options.addressFilter; + + this.setMaxListeners(Number.MAX_SAFE_INTEGER); } utils.inherits(TXPool, EventEmitter); @@ -93,6 +102,7 @@ TXPool.prototype._add = function add(tx, callback, force) { var p = this.prefix + '/'; var hash = tx.hash('hex'); var updated = false; + var own = false; var batch; var unlock = this._lock(add, [tx, callback], force); @@ -112,6 +122,7 @@ TXPool.prototype._add = function add(tx, callback, force) { batch = self.db.batch(); if (existing) { + own = true; // Tricky - update the tx and coin in storage, // and remove pending flag to mark as confirmed. if (existing.ts === 0 && tx.ts !== 0) { @@ -217,6 +228,7 @@ TXPool.prototype._add = function add(tx, callback, force) { return done(null, false); updated = true; + own = true; type = input.getType(); address = input.getAddress(); @@ -250,6 +262,8 @@ TXPool.prototype._add = function add(tx, callback, force) { if (!result) return next(); + own = true; + self.getTX(input.prevout.hash, function(err, result) { if (err) return done(err); @@ -305,6 +319,7 @@ TXPool.prototype._add = function add(tx, callback, force) { key = hash + '/' + i; coin = bcoin.coin(tx, i); + own = true; self.db.get(p + 'o/' + key, function(err, orphans) { var some; @@ -400,6 +415,9 @@ TXPool.prototype._add = function add(tx, callback, force) { if (err) return done(err); + if (!own) + return done(null, false); + batch.put(p + 't/t/' + hash, tx.toExtended()); if (tx.ts === 0) batch.put(p + 'p/t/' + hash, new Buffer([])); @@ -418,11 +436,12 @@ TXPool.prototype._add = function add(tx, callback, force) { self.emit('tx', tx); - if (tx.ts !== 0) - self.emit('confirmed', tx); + if (updated) { + if (tx.ts !== 0) + self.emit('confirmed', tx); - if (updated) self.emit('updated', tx); + } return done(null, true); }); @@ -856,6 +875,8 @@ TXPool.prototype.getHeightHashes = function getHeightHashes(height, callback) { if (err) return callback(err); + txs = utils.uniqs(txs); + return callback(null, txs); }); } diff --git a/lib/bcoin/wallet.js b/lib/bcoin/wallet.js index 21776384..6f920115 100644 --- a/lib/bcoin/wallet.js +++ b/lib/bcoin/wallet.js @@ -44,6 +44,8 @@ function Wallet(options) { options.master = bcoin.hd.fromSeed(); this.options = options; + this.db = options.db || null; + this.tx = options.tx || null; this.provider = options.provider || null; this.addresses = []; this.master = options.master || null; @@ -87,6 +89,10 @@ function Wallet(options) { this.id = this.getID(); + // Non-alphanumeric IDs will break leveldb sorting. + if (!/^[a-zA-Z0-9]$/.test(this.id)) + throw new Error('Wallet IDs must be alphanumeric.'); + this.addKey(this.accountKey); (options.keys || []).forEach(function(key) { @@ -124,6 +130,63 @@ Wallet.prototype._init = function _init() { assert(this.receiveAddress); assert(!this.receiveAddress.change); assert(this.changeAddress.change); + + if (!this.tx) + return; + + this.tx.on('tx', this._onTX = function(tx) { + if (!self.ownInput(tx) && !self.ownOutput(tx)) + return; + + self.emit('tx', tx); + }); + + this.tx.on('updated', this._onUpdated = function(tx) { + if (!self.ownInput(tx) && !self.ownOutput(tx)) + return; + + self.emit('updated', tx); + + if (!self.provider) + return; + + self.getBalance(function(err, balance) { + if (err) + return self.emit('error', err); + + self.emit('balance', balance); + }); + }); + + this.tx.on('confirmed', this._onConfirmed = function(tx) { + if (!self.ownInput(tx) && !self.ownOutput(tx)) + return; + + self.syncOutputDepth(tx); + self.emit('confirmed', tx); + }); +}; + +Wallet.prototype.destroy = function destroy() { + if (this.tx) { + if (this._onTX) { + this.tx.removeListener('tx', this._onTX); + delete this._onTX; + } + + if (this._onUpdated) { + this.tx.removeListener('updated', this._onUpdated); + delete this._onUpdated; + } + + if (this._onConfirmed) { + this.tx.removeListener('confirmed', this._onConfirmed); + delete this._onConfirmed; + } + } + this.db = null; + this.tx = null; + this.provider = null; }; Wallet.prototype.addKey = function addKey(key) { @@ -341,6 +404,10 @@ Wallet.prototype.deriveAddress = function deriveAddress(change, index) { if (this.witness) this.addressMap[address.getProgramAddress()] = data.path; + // Update the DB with the new address. + if (this.db) + this.db.update(this, address); + this.emit('add address', address); return address; @@ -473,27 +540,9 @@ Wallet.prototype.fillPrevout = function fillPrevout(tx, callback) { return this.provider.fillCoin(tx, callback); }; -Wallet.prototype.sync = function sync(callback) { +Wallet.prototype.createTX = function createTX(options, outputs, callback) { var self = this; - - callback = utils.ensure(callback); - - this.getUnspent(function(err, unspent) { - if (err) - return callback(err); - - unspent.forEach(function(coin) { - self.syncOutputDepth(coin); - }); - - return callback(); - }); -}; - -Wallet.prototype.tx = function _tx(options, outputs, callback) { - var self = this; - var tx = bcoin.mtx(); - var target; + var tx; if (typeof outputs === 'function') { callback = outputs; @@ -508,6 +557,9 @@ Wallet.prototype.tx = function _tx(options, outputs, callback) { if (!Array.isArray(outputs)) outputs = [outputs]; + // Create mutable tx + tx = bcoin.mtx(); + // Add the outputs outputs.forEach(function(output) { tx.addOutput(output); @@ -521,20 +573,9 @@ Wallet.prototype.tx = function _tx(options, outputs, callback) { // Sort members a la BIP69 tx.sortMembers(); - // Find the necessary locktime if there is - // a checklocktimeverify script in the unspents. - target = tx.getTargetLocktime(); - - // No target value. The unspents have an - // incompatible locktime type. - if (!target) - return callback(new Error('Incompatible locktime.')); - // Set the locktime to target value or // `height - whatever` to avoid fee sniping. - if (target.value > 0) - tx.setLocktime(target.value); - else if (options.locktime != null) + if (options.locktime != null) tx.setLocktime(options.locktime); else tx.avoidFeeSniping(); @@ -758,12 +799,10 @@ Wallet.prototype.sign = function sign(tx, type, index) { }; Wallet.prototype.addTX = function addTX(tx, callback) { - this.syncOutputDepth(tx); + if (!this.tx) + return callback(new Error('No transaction pool available.')); - if (!this.provider) - return callback(new Error('No wallet provider available.')); - - return this.provider.addTX(tx, callback); + return this.tx.add(tx, callback); }; Wallet.prototype.getAll = function getAll(callback) { @@ -929,6 +968,10 @@ Wallet.prototype.toJSON = function toJSON() { receiveDepth: this.receiveDepth, changeDepth: this.changeDepth, master: this.master.toJSON(this.options.passphrase), + // Store account key to: + // a. save ourselves derivations. + // b. allow deriving of addresses without decrypting the master key. + accountKey: this.accountKey.xpubkey, addressMap: this.addressMap, keys: this.keys.map(function(key) { return key.xpubkey; @@ -961,6 +1004,39 @@ Wallet._fromJSON = function _fromJSON(json, passphrase) { }; }; +// For updating the address table quickly +// without decrypting the master key. +Wallet.sync = function sync(json, options) { + var master, wallet; + + assert.equal(json.v, 3); + assert.equal(json.name, 'wallet'); + + if (json.network) + assert.equal(json.network, network.type); + + master = json.master; + json.master = json.accountKey; + wallet = new Wallet(json); + + if (options.txs != null) { + options.txs.forEach(function(tx) { + wallet.syncOutputDepth(tx); + }); + } + + if (options.receiveDepth != null) + wallet.setReceiveDepth(options.receiveDepth); + + if (options.changeDepth != null) + wallet.setChangeDepth(options.changeDepth); + + wallet = wallet.toJSON(); + wallet.master = master; + + return wallet; +}; + Wallet.fromJSON = function fromJSON(json, passphrase) { return new Wallet(Wallet._fromJSON(json, passphrase)); }; diff --git a/lib/bcoin/walletdb.js b/lib/bcoin/walletdb.js index c9b03272..784a7e66 100644 --- a/lib/bcoin/walletdb.js +++ b/lib/bcoin/walletdb.js @@ -83,7 +83,7 @@ WalletDB.prototype.dump = function dump(callback) { }); } - records[key] = value.slice(0, 50).toString('hex'); + records[key] = value; next(); }); @@ -116,8 +116,75 @@ WalletDB.prototype._init = function _init() { this.db = WalletDB._db[this.file]; - this.tx = new bcoin.txdb('w', this.db); - this.tx._hasAddress = this.hasAddress.bind(this); + this.tx = new bcoin.txdb('w', this.db, { + addressFilter: this.hasAddress.bind(this) + }); + + this.tx.on('error', function(err) { + self.emit('error', err); + }); + + this.tx.on('updated', function(tx) { + self.getIDs(tx.getOutputAddresses(), function(err, ids) { + if (err) + return self.emit('error', err); + + self.emit('wallet tx', tx, ids); + + // Only sync for confirmed txs. + if (tx.ts === 0) + return; + + utils.forEachSerial(ids, function(id, next) { + self.getJSON(id, function(err, json) { + if (err) { + self.emit('error', err); + return next(); + } + + // Allocate new addresses if necessary. + json = bcoin.wallet.sync(json, { txs: [tx] }); + + self.saveJSON(id, json, function(err) { + if (err) + return next(err); + next(); + }); + }); + }, function(err) { + if (err) + self.emit('error', err); + }); + }); + }); +}; + +WalletDB.prototype.syncDepth = function syncDepth(id, changeDepth, receiveDepth, callback) { + callback = utils.ensure(callback); + + if (!receiveDepth) + receiveDepth = 0; + + if (!changeDepth) + changeDepth = 0; + + self.getJSON(id, function(err, json) { + if (err) + return callback(err); + + // Allocate new addresses if necessary. + json = bcoin.wallet.sync(json, { + receiveDepth: receiveDepth, + changeDepth: changeDepth + }); + + self.saveJSON(id, json, function(err) { + if (err) + return callback(err); + self.emit('sync depth', id, receiveDepth, changeDepth); + callback(); + }); + }); }; WalletDB.prototype.getJSON = function getJSON(id, callback) { @@ -251,6 +318,8 @@ WalletDB.prototype._removeDB = function _removeDB(id, callback) { }; WalletDB.prototype.get = function get(id, passphrase, callback) { + var self = this; + if (typeof passphrase === 'function') { callback = passphrase; passphrase = null; @@ -268,14 +337,15 @@ WalletDB.prototype.get = function get(id, passphrase, callback) { return callback(); try { - wallet = bcoin.wallet.fromJSON(options, passphrase); - wallet.provider = self; + options = bcoin.wallet._fromJSON(options, passphrase); + options.db = self; + options.tx = self.tx; + options.provider = self; + wallet = new bcoin.wallet(options); } catch (e) { return callback(e); } - wallet.on('add address', self._onAddress(wallet, wallet.id)); - return callback(null, wallet); }); }; @@ -286,14 +356,8 @@ WalletDB.prototype.save = function save(options, callback) { callback = utils.ensure(callback); - if (options instanceof bcoin.wallet) { - if (!options.provider) { - options.on('add address', self._onAddress(options, options.id)); - options.provider = self; - } - if (options instanceof bcoin.wallet) - options = options.toJSON(); - } + if (options instanceof bcoin.wallet) + assert(options.db === this); this.saveJSON(options.id, options, callback); }; @@ -304,7 +368,7 @@ WalletDB.prototype.remove = function remove(id, callback) { callback = utils.ensure(callback); if (id instanceof bcoin.wallet) { - id.provider = null; + id.destroy(); id = id.id; } @@ -337,13 +401,18 @@ WalletDB.prototype.create = function create(options, callback) { if (json) { try { - wallet = bcoin.wallet.fromJSON(json, options.passphrase); - wallet.provider = self; + options = bcoin.wallet._fromJSON(json, options.passphrase); + options.db = self; + options.tx = self.tx; + options.provider = self; + wallet = new bcoin.wallet(options); } catch (e) { return callback(e); } done(); } else { + options.db = self; + options.tx = self.tx; options.provider = self; wallet = new bcoin.wallet(options); self.saveJSON(wallet.id, wallet.toJSON(), done); @@ -353,44 +422,46 @@ WalletDB.prototype.create = function create(options, callback) { if (err) return callback(err); - wallet.on('add address', self._onAddress(wallet, wallet.id)); - return callback(null, wallet); } }); }; -WalletDB.prototype._onAddress = function _onAddress(wallet, id) { +WalletDB.prototype.update = function update(wallet, address) { var self = this; - return function(address) { - var batch = self.db.batch(); + var batch; + // Ugly hack to avoid extra writes. + if (!wallet.changeAddress && wallet.changeDepth > 1) + return; + + batch = this.db.batch(); + + batch.put( + 'w/a/' + address.getKeyAddress() + '/' + wallet.id, + new Buffer([])); + + if (address.type === 'multisig') { batch.put( - 'w/a/' + address.getKeyAddress() + '/' + id, + 'w/a/' + address.getScriptAddress() + '/' + wallet.id, new Buffer([])); + } - if (address.type === 'multisig') { - batch.put( - 'w/a/' + address.getScriptAddress() + '/' + id, - new Buffer([])); - } + if (address.witness) { + batch.put( + 'w/a/' + address.getProgramAddress() + '/' + wallet.id, + new Buffer([])); + } - if (address.witness) { - batch.put( - 'w/a/' + address.getProgramAddress() + '/' + id, - new Buffer([])); - } + batch.write(function(err) { + if (err) + self.emit('error', err); - batch.write(function(err) { + self._saveDB(wallet.id, wallet.toJSON(), function(err) { if (err) self.emit('error', err); - - self._saveDB(wallet.id, wallet.toJSON(), function(err) { - if (err) - self.emit('error', err); - }); }); - }; + }); }; WalletDB.prototype.addTX = function addTX(tx, callback) { @@ -448,7 +519,8 @@ WalletDB.prototype.getLast = function getLast(id, callback) { }; WalletDB.prototype.getAddresses = function getAddresses(id, callback) { - if (typeof id === 'string') + // Try to avoid a database lookup if we can... + if (typeof id === 'string' && bcoin.address.validate(id)) return callback(null, [id]); if (Array.isArray(id)) @@ -476,44 +548,6 @@ WalletDB.prototype.getAddresses = function getAddresses(id, callback) { }); }; -WalletDB.prototype.getIDs = function _getIDs(address, callback) { - var self = this; - var ids = []; - - var iter = this.db.db.iterator({ - gte: 'w/a/' + address, - lte: 'w/a/' + address + '~', - keys: true, - values: false, - fillCache: false, - keyAsBuffer: false - }); - - callback = utils.ensure(callback); - - (function next() { - iter.next(function(err, key, value) { - if (err) { - return iter.end(function() { - callback(err); - }); - } - - if (key === undefined) { - return iter.end(function(err) { - if (err) - return callback(err); - return callback(null, ids); - }); - } - - ids.push(key.split('/')[2]); - - next(); - }); - })(); -}; - WalletDB.prototype.hasAddress = function hasAddress(address, callback) { var self = this; @@ -556,6 +590,64 @@ WalletDB.prototype.hasAddress = function hasAddress(address, callback) { })(); }; +WalletDB.prototype.getIDs = function getIDs(address, callback) { + var self = this; + var ids = []; + + if (Array.isArray(address)) { + return utils.forEachSerial(address, function(address, next) { + self.getIDs(address, function(err, id) { + if (err) + return next(err); + + ids = ids.concat(id); + + next(); + }); + }, function(err) { + if (err) + return callback(err); + + ids = utils.uniqs(ids); + + return callback(null, ids); + }); + } + + var iter = this.db.db.iterator({ + gte: 'w/a/' + address, + lte: 'w/a/' + address + '~', + keys: true, + values: false, + fillCache: false, + keyAsBuffer: false + }); + + callback = utils.ensure(callback); + + (function next() { + iter.next(function(err, key, value) { + if (err) { + return iter.end(function() { + callback(err); + }); + } + + if (key === undefined) { + return iter.end(function(err) { + if (err) + return callback(err); + return callback(null, ids); + }); + } + + ids.push(key.split('/')[3]); + + next(); + }); + })(); +}; + /** * Expose */