diff --git a/lib/bcoin/fullnode.js b/lib/bcoin/fullnode.js index 3ac81cd6..6f5e99b9 100644 --- a/lib/bcoin/fullnode.js +++ b/lib/bcoin/fullnode.js @@ -133,7 +133,7 @@ function Fullnode(options) { port: this.options.httpPort || this.network.rpcPort, host: this.options.httpHost || '0.0.0.0', apiKey: this.options.apiKey, - auth: this.options.auth + walletAuth: this.options.walletAuth }); } diff --git a/lib/bcoin/http/base.js b/lib/bcoin/http/base.js index b5a60344..8eed78c1 100644 --- a/lib/bcoin/http/base.js +++ b/lib/bcoin/http/base.js @@ -221,6 +221,12 @@ HTTPBase.prototype._open = function open(callback) { */ HTTPBase.prototype._close = function close(callback) { + if (this.io) { + this.server.once('close', callback); + this.io.close(); + return; + } + this.server.close(callback); }; diff --git a/lib/bcoin/http/client.js b/lib/bcoin/http/client.js index f3196da9..40beedaa 100644 --- a/lib/bcoin/http/client.js +++ b/lib/bcoin/http/client.js @@ -36,9 +36,19 @@ function HTTPClient(options) { this.options = options; this.network = bcoin.network.get(options.network); - this.uri = options.uri || 'localhost:' + this.network.rpcPort; - this.id = null; + this.uri = options.uri || 'http://localhost:' + this.network.rpcPort; this.socket = null; + this.apiKey = options.apiKey; + this.auth = options.auth; + + if (this.apiKey) { + if (typeof this.apiKey === 'string') { + assert(utils.isHex(this.apiKey), 'API key must be a hex string.'); + this.apiKey = new Buffer(this.apiKey, 'hex'); + } + assert(Buffer.isBuffer(this.apiKey)); + assert(this.apiKey.length === 32, 'API key must be 32 bytes.'); + } // Open automatically. this.open(); @@ -63,7 +73,7 @@ HTTPClient.prototype._open = function _open(callback) { } if (!IOClient) - return; + return callback(); this.socket = new IOClient(this.uri); @@ -71,70 +81,70 @@ HTTPClient.prototype._open = function _open(callback) { self.emit('error', err); }); + this.socket.on('version', function(info) { + if (info.network !== self.network.type) + self.emit('error', new Error('Wrong network.')); + }); + + this.socket.on('tx', function(tx, map) { + try { + tx = bcoin.tx.fromJSON(tx); + } catch (e) { + return self.emit('error', e); + } + self.emit('tx', tx, map); + }); + + this.socket.on('confirmed', function(tx, map) { + try { + tx = bcoin.tx.fromJSON(tx); + } catch (e) { + return self.emit('error', e); + } + self.emit('confirmed', tx, map); + }); + + this.socket.on('updated', function(tx, map) { + try { + tx = bcoin.tx.fromJSON(tx); + } catch (e) { + return self.emit('error', e); + } + self.emit('updated', tx, map); + }); + + this.socket.on('address', function(receive, change, map) { + receive = receive.map(function(address) { + return bcoin.keyring.fromJSON(address); + }); + change = change.map(function(address) { + return bcoin.keyring.fromJSON(address); + }); + self.emit('address', receive, change, map); + }); + + this.socket.on('balance', function(balance, id) { + self.emit('balance', { + confirmed: utils.satoshi(balance.confirmed), + unconfirmed: utils.satoshi(balance.unconfirmed), + total: utils.satoshi(balance.total) + }, id); + }); + + this.socket.on('balances', function(json) { + var balances = {}; + Object.keys(json).forEach(function(id) { + balances[id] = { + confirmed: utils.satoshi(json[id].confirmed), + unconfirmed: utils.satoshi(json[id].unconfirmed), + total: utils.satoshi(json[id].total) + }; + }); + self.emit('balances', balances); + }); + this.socket.on('connect', function() { - self.socket.on('version', function(info) { - assert(info.network === self.network.type, 'Wrong network.'); - }); - - self.socket.on('tx', function(tx, map) { - try { - tx = bcoin.tx.fromJSON(tx); - } catch (e) { - return self.emit('error', e); - } - self.emit('tx', tx, map); - }); - - self.socket.on('confirmed', function(tx, map) { - try { - tx = bcoin.tx.fromJSON(tx); - } catch (e) { - return self.emit('error', e); - } - self.emit('confirmed', tx, map); - }); - - self.socket.on('updated', function(tx, map) { - try { - tx = bcoin.tx.fromJSON(tx); - } catch (e) { - return self.emit('error', e); - } - self.emit('updated', tx, map); - }); - - self.socket.on('balance', function(balance, id) { - self.emit('balance', { - confirmed: utils.satoshi(balance.confirmed), - unconfirmed: utils.satoshi(balance.unconfirmed), - total: utils.satoshi(balance.total) - }, id); - }); - - self.socket.on('address', function(receive, change, map) { - receive = receive.map(function(address) { - return bcoin.keyring.fromJSON(address); - }); - change = change.map(function(address) { - return bcoin.keyring.fromJSON(address); - }); - self.emit('address', receive, change, map); - }); - - self.socket.on('balances', function(json) { - var balances = {}; - Object.keys(json).forEach(function(id) { - balances[id] = { - confirmed: utils.satoshi(json[id].confirmed), - unconfirmed: utils.satoshi(json[id].unconfirmed), - total: utils.satoshi(json[id].total) - }; - }); - self.emit('balances', balances); - }); - - self.loaded = true; - self.emit('open'); + callback(); }); }; @@ -159,11 +169,11 @@ HTTPClient.prototype._close = function close(callback) { * @param {WalletID} id */ -HTTPClient.prototype.join = function join(id) { +HTTPClient.prototype.join = function join(id, token) { if (!this.socket) return; - this.socket.emit('join', id); + this.socket.emit('join', id, token); }; /** @@ -182,8 +192,8 @@ HTTPClient.prototype.leave = function leave(id) { * Listen for events on all wallets. */ -HTTPClient.prototype.all = function all() { - this.join('!all'); +HTTPClient.prototype.all = function all(token) { + this.join('!all', token); }; /** @@ -205,8 +215,7 @@ HTTPClient.prototype.none = function none() { HTTPClient.prototype._request = function _request(method, endpoint, json, callback) { var self = this; - var query; - var networkType; + var query, network, height; if (!callback) { callback = json; @@ -218,19 +227,36 @@ HTTPClient.prototype._request = function _request(method, endpoint, json, callba json = true; } + if (this.apiKey) { + if (method === 'get') { + query = query || {}; + query.apiKey = this.apiKey.toString('hex'); + } else { + json = json || {}; + json.apiKey = this.apiKey.toString('hex'); + } + } + request({ method: method, uri: this.uri + endpoint, query: query, json: json, + auth: this.auth, expect: 'json' }, function(err, res, body) { if (err) return callback(err); - networkType = res.headers['x-bcoin-network']; - assert(networkType === self.network.type, 'Wrong network.'); - self.network.updateHeight(+res.headers['x-bcoin-height']); + network = res.headers['x-bcoin-network']; + + if (network !== self.network.type) + return callback(new Error('Wrong network.')); + + height = +res.headers['x-bcoin-height']; + + if (utils.isNumber(height)) + self.network.updateHeight(height); if (res.statusCode === 404) return callback(); @@ -820,6 +846,40 @@ HTTPClient.prototype.walletSend = function walletSend(id, options, callback) { }); }; +/** + * Generate a new token. + * @param {(String|Buffer)?} passphrase + * @param {Function} callback + */ + +HTTPClient.prototype.walletRetoken = function walletRetoken(id, passphrase, callback) { + var options = { passphrase: passphrase }; + + callback = utils.ensure(callback); + + return this._post('/wallet/' + id + '/retoken', options, function(err, body) { + if (err) + return callback(err); + + return callback(null, body.token); + }); +}; + +/** + * Change or set master key's passphrase. + * @param {(String|Buffer)?} old + * @param {String|Buffer} new_ + * @param {Function} callback + */ + +HTTPClient.prototype.walletSetPassphrase = function walletSetPassphrase(id, old, new_, callback) { + var options = { old: old, passphrase: new_ }; + + callback = utils.ensure(callback); + + return this._post('/wallet/' + id + '/passphrase', options, callback); +}; + /** * Create a transaction, fill. * @param {WalletID} id diff --git a/lib/bcoin/http/server.js b/lib/bcoin/http/server.js index a47bcc97..cf872ef7 100644 --- a/lib/bcoin/http/server.js +++ b/lib/bcoin/http/server.js @@ -32,6 +32,8 @@ function HTTPServer(options) { if (!options) options = {}; + EventEmitter.call(this); + this.options = options; this.node = options.node; @@ -221,7 +223,16 @@ HTTPServer.prototype._init = function _init() { if (req.password) { assert(utils.isHex(req.password), 'API key must be a hex string.'); assert(req.password.length === 64, 'API key must be 32 bytes.'); - options.apiKey = new Buffer(req.password, 'hex'); + options.token = new Buffer(req.password, 'hex'); + } + + if (req.headers['x-bcoin-api-key']) + params.apiKey = req.headers['x-bcoin-api-key']; + + if (params.apiKey) { + assert(utils.isHex(params.apiKey), 'API key must be a hex string.'); + assert(params.apiKey.length === 64, 'API key must be 32 bytes.'); + options.apiKey = new Buffer(params.apiKey, 'hex'); } req.options = options; @@ -230,21 +241,20 @@ HTTPServer.prototype._init = function _init() { }); this.use(function(req, res, next, send) { - if (req.path.length < 2 || req.path[0] !== 'wallet') { - if (self.apiKey) { - if (!utils.ccmp(req.options.apiKey, self.apiKey)) { - res.setHeader('WWW-Authenticate', 'Basic realm="node"'); - send(401, { error: 'Unauthorized.' }); - return; - } + if (self.apiKey) { + if (!utils.ccmp(req.options.apiKey, self.apiKey)) { + send(403, { error: 'Forbidden.' }); + return; } - return next(); } - if (!self.options.auth) + if (req.path.length < 2 || req.path[0] !== 'wallet') return next(); - self.walletdb.auth(req.options.id, req.options.apiKey, function(err) { + if (!self.options.walletAuth) + return next(); + + self.walletdb.auth(req.options.id, req.options.token, function(err) { if (err) { if (err.message === 'Wallet not found.') return next(); @@ -255,6 +265,7 @@ HTTPServer.prototype._init = function _init() { return; } + self.logger.info('Successful auth for %s.', req.options.id); next(); }); }); @@ -515,16 +526,6 @@ HTTPServer.prototype._init = function _init() { }); }); - // Broadcast TX - this.post('/wallet/:id/broadcast', function(req, res, next, send) { - self.node.sendTX(req.options.tx, function(err) { - if (err) - return next(err); - - send(200, { success: true }); - }); - }); - // Send TX this.post('/wallet/:id/send', function(req, res, next, send) { var id = req.options.id; @@ -833,25 +834,17 @@ HTTPServer.prototype._initIO = function _initIO() { self.emit('error', err); }); - socket.on('join', function(id, apiKey) { - if (!self.options.auth) { + socket.on('join', function(id, token) { + if (!self.options.walletAuth) { socket.join(id); return; } - if (id === '!all') { - if (self.apiKey) { - if (!utils.ccmp(apiKey, self.apiKey)) { - self.logger.info('Auth failure for %s.', id); - return; - } - } - return socket.join(id); - } - self.walletdb.auth(id, apiKey, function(err) { + self.walletdb.auth(id, token, function(err) { if (err) { self.logger.info('Auth failure for %s: %s.', id, err.message); return; } + self.logger.info('Successful auth for %s.', id); socket.join(id); }); }); @@ -912,17 +905,13 @@ HTTPServer.prototype._initIO = function _initIO() { this.walletdb.on('address', function(receive, change, map) { var summary = map.toJSON(); - if (receive) { - receive = receive.map(function(address) { - return address.toJSON(); - }); - } + receive = receive.map(function(address) { + return address.toJSON(); + }); - if (change) { - change = change.map(function(address) { - return address.toJSON(); - }); - } + change = change.map(function(address) { + return address.toJSON(); + }); map.getWallets().forEach(function(id) { self.server.io.to(id).emit('address', receive, change, summary); diff --git a/lib/bcoin/http/wallet.js b/lib/bcoin/http/wallet.js index 66535e01..472d17df 100644 --- a/lib/bcoin/http/wallet.js +++ b/lib/bcoin/http/wallet.js @@ -37,6 +37,8 @@ function HTTPWallet(options) { this.client = new http.client(options); this.uri = options.uri; + this.id = null; + this.token = null; this._init(); } @@ -63,8 +65,8 @@ HTTPWallet.prototype._init = function _init() { self.emit('updated', tx, map); }); - this.client.on('balance', function(balance, map) { - self.emit('balance', balance, map); + this.client.on('balance', function(balance, id) { + self.emit('balance', balance, id); }); this.client.on('address', function(receive, change, map) { @@ -74,8 +76,6 @@ HTTPWallet.prototype._init = function _init() { this.client.on('error', function(err) { self.emit('error', err); }); - - this.client.join(this.id); }; /** @@ -84,12 +84,39 @@ HTTPWallet.prototype._init = function _init() { * @param {Function} callback */ -HTTPWallet.prototype.open = function open(callback) { +HTTPWallet.prototype.open = function open(options, callback) { var self = this; + + if (options.token) { + if (typeof options.token === 'string') { + assert(utils.isHex(options.token), 'API key must be a hex string.'); + options.token = new Buffer(options.token, 'hex'); + } + assert(Buffer.isBuffer(options.token)); + assert(options.token.length === 32, 'API key must be 32 bytes.'); + this.id = options.id; + this.client.auth = { username: 'x', password: options.token.toString('hex') }; + } + this.client.open(function(err) { if (err) return callback(err); - self.createWallet(self.options, callback); + + if (options.token) { + self.token = options.token; + self.client.join(options.id, options.token.toString('hex')); + return callback(); + } + + self.client.createWallet(options, function(err, wallet) { + if (err) + return callback(err); + self.id = wallet.id; + self.client.auth = { username: 'x', password: wallet.token }; + self.token = new Buffer(wallet.token, 'hex'); + self.client.join(self.id, wallet.token); + callback(null, wallet); + }); }); }; @@ -179,16 +206,16 @@ HTTPWallet.prototype.zap = function zap(account, age, callback) { * @see Wallet#createTX */ -HTTPWallet.prototype.createTX = function createTX(tx, options, outputs, callback) { - return this.client.walletCreate(this.id, tx, options, outputs, callback); +HTTPWallet.prototype.createTX = function createTX(options, outputs, callback) { + return this.client.walletCreate(this.id, options, outputs, callback); }; /** * @see HTTPClient#walletSend */ -HTTPWallet.prototype.send = function send(tx, options, callback) { - return this.client.walletSend(this.id, tx, options, callback); +HTTPWallet.prototype.send = function send(options, callback) { + return this.client.walletSend(this.id, options, callback); }; /** @@ -231,6 +258,31 @@ HTTPWallet.prototype.createAccount = function createAccount(options, callback) { return this.client.createWalletAccount(this.id, options, callback); }; +/** + * @see Wallet#setPassphrase + */ + +HTTPWallet.prototype.setPassphrase = function setPassphrase(old, _new, callback) { + return this.client.walletSetPassphrase(this.id, old, _new, callback); +}; + +/** + * @see Wallet#retoken + */ + +HTTPWallet.prototype.retoken = function retoken(passphrase, callback) { + var self = this; + return this.client.walletRetoken(this.id, passphrase, function(err, token) { + if (err) + return callback(err); + + self.client.auth = { username: 'x', password: token }; + self.token = new Buffer(token, 'hex'); + + return callback(null, token); + }); +}; + /* * Expose */ diff --git a/lib/bcoin/spvnode.js b/lib/bcoin/spvnode.js index 5a97076e..e3b44b93 100644 --- a/lib/bcoin/spvnode.js +++ b/lib/bcoin/spvnode.js @@ -81,7 +81,7 @@ function SPVNode(options) { port: this.options.httpPort || this.network.rpcPort, host: this.options.httpHost || '0.0.0.0', apiKey: this.options.apiKey, - auth: this.options.auth + walletAuth: this.options.walletAuth }); } diff --git a/lib/bcoin/txdb.js b/lib/bcoin/txdb.js index 0824c53a..ef4c449e 100644 --- a/lib/bcoin/txdb.js +++ b/lib/bcoin/txdb.js @@ -1937,6 +1937,22 @@ WalletMap.prototype.fromTX = function fromTX(table, tx) { continue; } + // Already have a member for this account. + // i.e. Different address, but same account. + if (member) { + // Increment value. + if (io.coin) + member.value += io.coin.value; + else if (io.value) + member.value += io.value; + + // Set address and add path. + path.address = address; + member.paths.push(path); + + continue; + } + // Create a member for this account. assert(!member); member = MapMember.fromPath(path); @@ -1966,7 +1982,8 @@ WalletMap.prototype.fromTX = function fromTX(table, tx) { // Update this guy last so the above if // clause does not return true while // we're still iterating over paths. - hashes[hash] = true; + if (paths.length > 0) + hashes[hash] = true; } } diff --git a/lib/bcoin/wallet.js b/lib/bcoin/wallet.js index d99bebf1..ab6f8fe7 100644 --- a/lib/bcoin/wallet.js +++ b/lib/bcoin/wallet.js @@ -377,6 +377,7 @@ Wallet.prototype.unlock = function unlock(passphrase, timeout, callback) { * It is represented as `m/44` (public) hashed * and converted to an address with a prefix * of `0x03be04` (`WLT` in base58). + * @private * @returns {Base58String} */ @@ -400,6 +401,8 @@ Wallet.prototype.getID = function getID() { /** * Generate the wallet api key if none was passed in. * It is represented as HASH256(m/44'->public|nonce). + * @private + * @param {HDPrivateKey} master * @param {Number} nonce * @returns {Buffer} */ @@ -779,6 +782,12 @@ Wallet.prototype.deriveInputs = function deriveInputs(tx, callback) { }); }; +/** + * Retrieve a single keyring by address hash. + * @param {Hash} hash + * @param {Function} callback + */ + Wallet.prototype.getKeyring = function getKeyring(hash, callback) { var self = this; var address; diff --git a/lib/bcoin/walletdb.js b/lib/bcoin/walletdb.js index 4d1f1d82..3514a011 100644 --- a/lib/bcoin/walletdb.js +++ b/lib/bcoin/walletdb.js @@ -474,6 +474,7 @@ WalletDB.prototype.save = function save(wallet, callback) { */ WalletDB.prototype.auth = function auth(id, token, callback) { + var self = this; var wallet; if (!id) @@ -1154,14 +1155,14 @@ WalletDB.prototype.fetchWallet = function fetchWallet(id, callback, handler) { if (!wallet) return callback(new Error('No wallet.')); - handler(wallet, function(err, result) { + handler(wallet, function(err, res1, res2) { // Kill the reference. wallet.destroy(); if (err) return callback(err); - callback(null, result); + callback(null, res1, res2); }); }); }; diff --git a/test/http-test.js b/test/http-test.js new file mode 100644 index 00000000..640ec95e --- /dev/null +++ b/test/http-test.js @@ -0,0 +1,178 @@ +'use strict'; + +var bn = require('bn.js'); +var bcoin = require('../').set('regtest'); +var constants = bcoin.protocol.constants; +var network = bcoin.protocol.network; +var utils = bcoin.utils; +var assert = require('assert'); +var scriptTypes = constants.scriptTypes; + +var dummyInput = { + prevout: { + hash: constants.NULL_HASH, + index: 0 + }, + coin: { + version: 1, + height: 0, + value: constants.MAX_MONEY, + script: new bcoin.script([]), + coinbase: false, + hash: constants.NULL_HASH, + index: 0 + }, + script: new bcoin.script([]), + witness: new bcoin.witness([]), + sequence: 0xffffffff +}; + +describe('HTTP', function() { + var request = bcoin.http.request; + var apiKey = utils.hash256(new Buffer([])); + var w, addr, hash; + + this.timeout(15000); + + var node = new bcoin.fullnode({ + network: 'regtest', + apiKey: apiKey, + walletAuth: true + }); + + var wallet = new bcoin.http.wallet({ + network: 'regtest', + apiKey: apiKey + }); + + node.on('error', function() {}); + + it('should open node', function(cb) { + constants.tx.COINBASE_MATURITY = 0; + node.open(cb); + }); + + it('should create wallet', function(cb) { + wallet.open({ id: 'test' }, function(err, wallet) { + assert.ifError(err); + assert.equal(wallet.id, 'test'); + cb(); + }); + }); + + it('should get info', function(cb) { + wallet.client.getInfo(function(err, info) { + assert.ifError(err); + assert.equal(info.network, node.network.type); + assert.equal(info.version, constants.USER_VERSION); + assert.equal(info.agent, constants.USER_AGENT); + assert.equal(info.height, 0); + cb(); + }); + }); + + it('should get wallet info', function(cb) { + wallet.getInfo(function(err, wallet) { + assert.ifError(err); + assert.equal(wallet.id, 'test'); + addr = wallet.account.receiveAddress; + assert.equal(typeof addr, 'string'); + cb(); + }); + }); + +/* + it('should get internal wallet', function(cb) { + node.walletdb.get('test', function(err, w_) { + assert.ifError(err); + assert(w_); + w = w_; + cb(); + }); + }); +*/ + + it('should fill with funds', function(cb) { + var balance, receive; + + // Coinbase + var t1 = bcoin.mtx() + .addOutput(addr, 50460) + .addOutput(addr, 50460) + .addOutput(addr, 50460) + .addOutput(addr, 50460); + + t1.addInput(dummyInput); + + wallet.once('balance', function(b, id) { + balance = b; + }); + + wallet.once('address', function(r, c, map) { + receive = r[0]; + }); + + node.walletdb.addTX(t1, function(err) { + assert.ifError(err); + setTimeout(function() { + return cb(); + assert(receive); + assert.equal(receive.id, 'test'); + assert.equal(receive.type, 'pubkeyhash'); + assert.equal(receive.change, 0); + assert(balance); + assert.equal(balance.confirmed, 0); + assert.equal(balance.unconfirmed, 201840); + assert.equal(balance.total, 201840); + cb(); + }, 2000); + }); + }); + + it('should get balance', function(cb) { + wallet.getBalance(function(err, balance) { + assert.ifError(err); + assert.equal(balance.confirmed, 0); + assert.equal(balance.unconfirmed, 201840); + assert.equal(balance.total, 201840); + cb(); + }); + }); + + it('should send a tx', function(cb) { + var options = { + rate: 10000, + outputs: [{ + value: 10000, + address: addr + }] + }; + + wallet.send(options, function(err, tx) { + assert.ifError(err); + assert(tx); + assert.equal(tx.inputs.length, 1); + assert.equal(tx.outputs.length, 2); + assert.equal(tx.getOutputValue(), 48190); + hash = tx.hash('hex'); + node.walletdb.addTX(tx, function(err) { + assert.ifError(err); + cb(); + }); + }); + }); + + it('should get a tx', function(cb) { + wallet.getTX(hash, function(err, tx) { + assert.ifError(err); + assert(tx); + assert.equal(tx.hash('hex'), hash); + cb(); + }); + }); + + it('should cleanup', function(cb) { + constants.tx.COINBASE_MATURITY = 100; + node.close(cb); + }); +});