UNPKG

hsd

Version:
1,102 lines (851 loc) 27.9 kB
/*! * server.js - http server for hsd * Copyright (c) 2017-2018, Christopher Jeffrey (MIT License). * https://github.com/handshake-org/hsd */ 'use strict'; const assert = require('bsert'); const path = require('path'); const {Server} = require('bweb'); const Validator = require('bval'); const base58 = require('bcrypto/lib/encoding/base58'); const {BloomFilter} = require('@handshake-org/bfilter'); const sha256 = require('bcrypto/lib/sha256'); const random = require('bcrypto/lib/random'); const {safeEqual} = require('bcrypto/lib/safe'); const util = require('../utils/util'); const TX = require('../primitives/tx'); const Claim = require('../primitives/claim'); const Address = require('../primitives/address'); const Network = require('../protocol/network'); const scanActions = require('../blockchain/common').scanActions; const pkg = require('../pkg'); /** * HTTP * @alias module:http.Server */ class HTTP extends Server { /** * Create an http server. * @constructor * @param {Object} options */ constructor(options) { super(new HTTPOptions(options)); this.network = this.options.network; this.logger = this.options.logger.context('node-http'); this.node = this.options.node; this.chain = this.node.chain; this.mempool = this.node.mempool; this.pool = this.node.pool; this.fees = this.node.fees; this.miner = this.node.miner; this.rpc = this.node.rpc; this.init(); } /** * Initialize routes. * @private */ init() { this.on('request', (req, res) => { if (req.method === 'POST' && req.pathname === '/') return; this.logger.debug('Request for method=%s path=%s (%s).', req.method, req.pathname, req.socket.remoteAddress); }); this.on('listening', (address) => { this.logger.info('Node HTTP server listening on %s (port=%d).', address.address, address.port); }); this.initRouter(); this.initSockets(); } /** * Initialize routes. * @private */ initRouter() { if (this.options.cors) this.use(this.cors()); if (!this.options.noAuth) { this.use(this.basicAuth({ hash: sha256.digest, password: this.options.apiKey, realm: 'node' })); } this.use(this.bodyParser({ type: 'json' })); this.use(this.jsonRPC()); this.use(this.router()); this.error((err, req, res) => { const code = err.statusCode || 500; res.json(code, { error: { type: err.type, code: err.code, message: err.message } }); }); this.get('/', async (req, res) => { const totalTX = this.mempool ? this.mempool.map.size : 0; const size = this.mempool ? this.mempool.getSize() : 0; const claims = this.mempool ? this.mempool.claims.size : 0; const airdrops = this.mempool ? this.mempool.airdrops.size : 0; const orphans = this.mempool ? this.mempool.orphans.size : 0; const brontide = this.pool.hosts.brontide; const pub = { listen: this.pool.options.listen, host: null, port: null, brontidePort: null }; const addr = this.pool.hosts.getLocal(); if (addr && pub.listen) { pub.host = addr.host; pub.port = addr.port; pub.brontidePort = brontide.port; } const treeInterval = this.network.names.treeInterval; const prevHeight = this.chain.height - 1; const treeRootHeight = this.chain.height === 0 ? 0 : prevHeight - (prevHeight % treeInterval) + 1; const treeCompaction = { compacted: false, compactOnInit: false, compactInterval: null, lastCompaction: null, nextCompaction: null }; if (!this.chain.options.spv) { const chainOptions = this.chain.options; const { compactionHeight, compactFrom } = await this.chain.getCompactionHeights(); treeCompaction.compactOnInit = chainOptions.compactTreeOnInit; if (chainOptions.compactTreeOnInit) { treeCompaction.compactInterval = chainOptions.compactTreeInitInterval; treeCompaction.nextCompaction = compactFrom; } if (compactionHeight > 0) { treeCompaction.compacted = true; treeCompaction.lastCompaction = compactionHeight; } } res.json(200, { version: pkg.version, network: this.network.type, chain: { height: this.chain.height, tip: this.chain.tip.hash.toString('hex'), treeRoot: this.chain.tip.treeRoot.toString('hex'), treeRootHeight: treeRootHeight, progress: this.chain.getProgress(), indexers: { indexTX: this.chain.options.indexTX, indexAddress: this.chain.options.indexAddress }, options: { spv: this.chain.options.spv, prune: this.chain.options.prune }, treeCompaction: treeCompaction, state: { tx: this.chain.db.state.tx, coin: this.chain.db.state.coin, value: this.chain.db.state.value, burned: this.chain.db.state.burned } }, pool: { host: this.pool.options.host, port: this.pool.options.port, brontidePort: this.pool.options.brontidePort, identitykey: brontide.getKey('base32'), agent: this.pool.options.agent, services: this.pool.options.services.toString(2), outbound: this.pool.peers.outbound, inbound: this.pool.peers.inbound, public: pub }, mempool: { tx: totalTX, size: size, claims: claims, airdrops: airdrops, orphans: orphans }, time: { uptime: this.node.uptime(), system: util.now(), adjusted: this.network.now(), offset: this.network.time.offset }, memory: this.logger.memoryUsage() }); }); // UTXO by address this.get('/coin/address/:address', async (req, res) => { const valid = Validator.fromRequest(req); const address = valid.str('address'); enforce(address, 'Address is required.'); enforce(!this.chain.options.spv, 'Cannot get coins in SPV mode.'); const addr = Address.fromString(address, this.network); const coins = await this.node.getCoinsByAddress(addr); const result = []; for (const coin of coins) result.push(coin.getJSON(this.network)); res.json(200, result); }); // UTXO by id this.get('/coin/:hash/:index', async (req, res) => { const valid = Validator.fromRequest(req); const hash = valid.bhash('hash'); const index = valid.u32('index'); enforce(hash, 'Hash is required.'); enforce(index != null, 'Index is required.'); enforce(!this.chain.options.spv, 'Cannot get coins in SPV mode.'); const coin = await this.node.getCoin(hash, index); if (!coin) { res.json(404); return; } res.json(200, coin.getJSON(this.network)); }); // Bulk read UTXOs // TODO(boymanjor): Deprecate this endpoint // once the equivalent functionality is included // in the wallet API. this.post('/coin/address', async (req, res) => { const valid = Validator.fromRequest(req); const addresses = valid.array('addresses'); enforce(addresses, 'Addresses is required.'); enforce(!this.chain.options.spv, 'Cannot get coins in SPV mode.'); this.logger.warning('%s %s %s', 'Warning: endpoint being considered for deprecation.', 'Known to cause CPU exhaustion if too many addresses', 'are queried or too many results are found.'); const addrs = []; for (const address of addresses) { addrs.push(Address.fromString(address, this.network)); } const coins = await this.node.getCoinsByAddress(addrs); const result = []; for (const coin of coins) result.push(coin.getJSON(this.network)); res.json(200, result); }); // TX by hash this.get('/tx/:hash', async (req, res) => { const valid = Validator.fromRequest(req); const hash = valid.bhash('hash'); enforce(hash, 'Hash is required.'); enforce(!this.chain.options.spv, 'Cannot get TX in SPV mode.'); const meta = await this.node.getMeta(hash); if (!meta) { res.json(404); return; } const view = await this.node.getMetaView(meta); res.json(200, meta.getJSON(this.network, view, this.chain.height)); }); // TX by address this.get('/tx/address/:address', async (req, res) => { const valid = Validator.fromRequest(req); const address = valid.str('address'); enforce(address, 'Address is required.'); enforce(!this.chain.options.spv, 'Cannot get TX in SPV mode.'); const addr = Address.fromString(address, this.network); const metas = await this.node.getMetaByAddress(addr); const result = []; for (const meta of metas) { const view = await this.node.getMetaView(meta); result.push(meta.getJSON(this.network, view, this.chain.height)); } res.json(200, result); }); // Bulk read TXs // TODO(boymanjor): Deprecate this endpoint // once the equivalent functionality is included // in the wallet API. this.post('/tx/address', async (req, res) => { const valid = Validator.fromRequest(req); const addresses = valid.array('addresses'); enforce(addresses, 'Addresses is required.'); enforce(!this.chain.options.spv, 'Cannot get TX in SPV mode.'); this.logger.warning('%s %s %s', 'Warning: endpoint being considered for deprecation.', 'Known to cause CPU exhaustion if too many addresses', 'are queried or too many results are found.'); const addrs = []; for (const address of addresses) { addrs.push(Address.fromString(address, this.network)); } const metas = await this.node.getMetaByAddress(addrs); const result = []; for (const meta of metas) { const view = await this.node.getMetaView(meta); result.push(meta.getJSON(this.network, view, this.chain.height)); } res.json(200, result); }); // Block by hash/height this.get('/block/:block', async (req, res) => { const valid = Validator.fromRequest(req); const hash = valid.uintbhash('block'); enforce(hash != null, 'Hash or height required.'); enforce(!this.chain.options.spv, 'Cannot get block in SPV mode.'); const block = await this.chain.getBlock(hash); if (!block) { res.json(404); return; } const view = await this.chain.getBlockView(block); if (!view) { res.json(404); return; } const height = await this.chain.getHeight(hash); const depth = this.chain.height - height + 1; res.json(200, block.getJSON(this.network, view, height, depth)); }); // Block Header by hash/height this.get('/header/:block', async (req, res) => { const valid = Validator.fromRequest(req); const hash = valid.uintbhash('block'); enforce(hash != null, 'Hash or height required.'); const entry = await this.chain.getEntry(hash); if (!entry) { res.json(404); return; } res.json(200, entry.toJSON()); }); // Mempool snapshot this.get('/mempool', async (req, res) => { enforce(this.mempool, 'No mempool available.'); const hashes = this.mempool.getSnapshot(); const result = []; for (const hash of hashes) result.push(hash.toString('hex')); res.json(200, result); }); // Mempool Rejection Filter this.get('/mempool/invalid', async (req, res) => { enforce(this.mempool, 'No mempool available.'); const valid = Validator.fromRequest(req); const verbose = valid.bool('verbose', false); const rejects = this.mempool.rejects; res.json(200, { items: rejects.items, filter: verbose ? rejects.filter.toString('hex') : undefined, size: rejects.size, entries: rejects.entries, n: rejects.n, limit: rejects.limit, tweak: rejects.tweak }); }); // Mempool Rejection Test this.get('/mempool/invalid/:hash', async (req, res) => { enforce(this.mempool, 'No mempool available.'); const valid = Validator.fromRequest(req); const hash = valid.bhash('hash'); enforce(hash, 'Must pass hash.'); const invalid = this.mempool.rejects.test(hash, 'hex'); res.json(200, { invalid }); }); // Broadcast TX this.post('/broadcast', async (req, res) => { const valid = Validator.fromRequest(req); const raw = valid.buf('tx'); enforce(raw, 'TX is required.'); const tx = TX.decode(raw); await this.node.sendTX(tx); res.json(200, { success: true }); }); // Broadcast Claim this.post('/claim', async (req, res) => { const valid = Validator.fromRequest(req); const raw = valid.buf('claim'); enforce(raw, 'Claim is required.'); const claim = Claim.decode(raw); await this.node.sendClaim(claim); res.json(200, { success: true }); }); // Estimate fee this.get('/fee', async (req, res) => { const valid = Validator.fromRequest(req); const blocks = valid.u32('blocks'); if (!this.fees) { res.json(200, { rate: this.network.feeRate }); return; } const fee = this.fees.estimateFee(blocks); res.json(200, { rate: fee }); }); // Reset chain this.post('/reset', async (req, res) => { const valid = Validator.fromRequest(req); const height = valid.u32('height'); enforce(height != null, 'Height is required.'); enforce(height <= this.chain.height, 'Height cannot be greater than chain tip.'); await this.chain.reset(height); res.json(200, { success: true }); }); } /** * Handle new websocket. * @private * @param {WebSocket} socket */ handleSocket(socket) { socket.hook('auth', (...args) => { if (socket.channel('auth')) throw new Error('Already authed.'); if (!this.options.noAuth) { const valid = new Validator(args); const key = valid.str(0, ''); if (key.length > 255) throw new Error('Invalid API key.'); const data = Buffer.from(key, 'ascii'); const hash = sha256.digest(data); if (!safeEqual(hash, this.options.apiHash)) throw new Error('Invalid API key.'); } socket.join('auth'); this.logger.info('Successful auth from %s.', socket.host); this.handleAuth(socket); return null; }); socket.fire('version', { version: pkg.version, network: this.network.type }); } /** * Handle new auth'd websocket. * @private * @param {WebSocket} socket */ handleAuth(socket) { socket.hook('watch chain', () => { socket.join('chain'); return null; }); socket.hook('unwatch chain', () => { socket.leave('chain'); return null; }); socket.hook('watch mempool', () => { socket.join('mempool'); return null; }); socket.hook('unwatch mempool', () => { socket.leave('mempool'); return null; }); socket.hook('set filter', (...args) => { const valid = new Validator(args); const data = valid.buf(0); if (!data) throw new Error('Invalid parameter.'); socket.filter = BloomFilter.decode(data); return null; }); socket.hook('get tip', () => { return this.chain.tip.encode(); }); socket.hook('get entry', async (...args) => { const valid = new Validator(args); const block = valid.uintbhash(0); if (block == null) throw new Error('Invalid parameter.'); const entry = await this.chain.getEntry(block); if (!entry) return null; if (!await this.chain.isMainChain(entry)) return null; return entry.encode(); }); socket.hook('get median time', async (...args) => { const valid = new Validator(args); const block = valid.uintbhash(0); if (block == null) throw new Error('Invalid parameter.'); const entry = await this.chain.getEntry(block); if (!entry) return null; const mtp = await this.chain.getMedianTime(entry); return mtp; }); socket.hook('get hashes', async (...args) => { const valid = new Validator(args); const start = valid.i32(0, -1); const end = valid.i32(1, -1); return this.chain.getHashes(start, end); }); socket.hook('get entries', async (...args) => { const valid = new Validator(args); const start = valid.i32(0, -1); const end = valid.i32(1, -1); const entries = await this.chain.getEntries(start, end); return entries.map(entry => entry.encode()); }); socket.hook('add filter', (...args) => { const valid = new Validator(args); const chunks = valid.array(0); if (!chunks) throw new Error('Invalid parameter.'); if (!socket.filter) throw new Error('No filter set.'); const items = new Validator(chunks); for (let i = 0; i < chunks.length; i++) { const data = items.buf(i); if (!data) throw new Error('Bad data chunk.'); socket.filter.add(data); if (this.node.spv) this.pool.watch(data); } return null; }); socket.hook('reset filter', () => { socket.filter = null; return null; }); socket.hook('estimate fee', (...args) => { const valid = new Validator(args); const blocks = valid.u32(0); if (!this.fees) return this.network.feeRate; return this.fees.estimateFee(blocks); }); socket.hook('send', (...args) => { const valid = new Validator(args); const data = valid.buf(0); if (!data) throw new Error('Invalid parameter.'); const tx = TX.decode(data); this.node.relay(tx); return null; }); socket.hook('send claim', (...args) => { const valid = new Validator(args); const data = valid.buf(0); if (!data) throw new Error('Invalid parameter.'); const claim = Claim.decode(data); this.node.relayClaim(claim); return null; }); socket.hook('get name', async (...args) => { const valid = new Validator(args); const nameHash = valid.bhash(0); if (!nameHash) throw new Error('Invalid parameter.'); const ns = await this.node.getNameStatus(nameHash); return ns.getJSON(this.chain.height + 1, this.network); }); socket.hook('rescan', (...args) => { const valid = new Validator(args); const start = valid.uintbhash(0); if (start == null) throw new Error('Invalid parameter.'); return this.scan(socket, start); }); socket.hook('rescan interactive', (...args) => { const valid = new Validator(args); const start = valid.uintbhash(0); const rawFilter = valid.buf(1); const fullLock = valid.bool(2, false); let filter = socket.filter; if (start == null) throw new Error('Invalid parameter.'); if (rawFilter) filter = BloomFilter.fromRaw(rawFilter); return this.scanInteractive(socket, start, filter, fullLock); }); } /** * Bind to chain events. * @private */ initSockets() { const pool = this.mempool || this.pool; this.chain.on('connect', (entry, block, view) => { const sockets = this.channel('chain'); if (!sockets) return; const raw = entry.encode(); this.to('chain', 'chain connect', raw); for (const socket of sockets) { const txs = this.filterBlock(socket, block); socket.fire('block connect', raw, txs); } }); this.chain.on('disconnect', (entry, block, view) => { const sockets = this.channel('chain'); if (!sockets) return; const raw = entry.encode(); this.to('chain', 'chain disconnect', raw); this.to('chain', 'block disconnect', raw); }); this.chain.on('reset', (tip) => { const sockets = this.channel('chain'); if (!sockets) return; this.to('chain', 'chain reset', tip.encode()); }); pool.on('tx', (tx) => { const sockets = this.channel('mempool'); if (!sockets) return; const raw = tx.encode(); for (const socket of sockets) { if (!this.filterTX(socket, tx)) continue; socket.fire('tx', raw); } }); this.chain.on('tree commit', (root, entry, block) => { const sockets = this.channel('chain'); if (!sockets) return; this.to('chain', 'tree commit', root, entry, block); }); } /** * Filter block by socket. * @private * @param {WebSocket} socket * @param {Block} block * @returns {TX[]} */ filterBlock(socket, block) { if (!socket.filter) return []; const txs = []; for (const tx of block.txs) { if (this.filterTX(socket, tx)) txs.push(tx.encode()); } return txs; } /** * Filter transaction by socket. * @private * @param {WebSocket} socket * @param {TX} tx * @returns {Boolean} */ filterTX(socket, tx) { if (!socket.filter) return false; return tx.testAndMaybeUpdate(socket.filter); } /** * Scan using a socket's filter. * @private * @param {WebSocket} socket * @param {Hash} start * @returns {Promise} */ async scan(socket, start) { await this.node.scan(start, socket.filter, (entry, txs) => { const block = entry.encode(); const raw = []; for (const tx of txs) raw.push(tx.encode()); return socket.call('block rescan', block, raw); }); return null; } /** * Scan using a socket's filter (interactive). * @param {WebSocket} socket * @param {Hash} start * @param {BloomFilter} filter * @param {Boolean} [fullLock=false] * @returns {Promise} */ async scanInteractive(socket, start, filter, fullLock = false) { const iter = async (entry, txs) => { const block = entry.encode(); const raw = []; for (const tx of txs) raw.push(tx.encode()); const action = await socket.call('block rescan interactive', block, raw); const valid = new Validator(action); const actionType = valid.i32('type'); switch (actionType) { case scanActions.NEXT: case scanActions.ABORT: case scanActions.REPEAT: { return { type: actionType }; } case scanActions.REPEAT_SET: { // NOTE: This is operation is on the heavier side, // because it sends the whole Filter that can be quite // big depending on the situation. // NOTE: In HTTP Context REPEAT_SET wont modify socket.filter // but instead setup new one for the rescan. Further REPEAT_ADDs will // modify this filter instead of the socket.filter. const rawFilter = valid.buf('filter'); let filter = null; if (rawFilter != null) filter = BloomFilter.fromRaw(rawFilter); return { type: scanActions.REPEAT_SET, filter: filter }; } case scanActions.REPEAT_ADD: { // NOTE: This operation depending on the filter // that was provided can be either modifying the // socket.filter or the filter provided by REPEAT_SET. const chunks = valid.array('chunks'); if (!chunks) throw new Error('Invalid parameter.'); return { type: scanActions.REPEAT_ADD, chunks: chunks }; } default: throw new Error('Unknown action.'); } }; try { await this.node.scanInteractive(start, filter, iter, fullLock); } catch (err) { await socket.call('block rescan interactive abort', err.message); throw err; } } } class HTTPOptions { /** * HTTPOptions * @alias module:http.HTTPOptions * @constructor * @param {Object} options */ constructor(options) { this.network = Network.primary; this.logger = null; this.node = null; this.apiKey = base58.encode(random.randomBytes(20)); this.apiHash = sha256.digest(Buffer.from(this.apiKey, 'ascii')); this.noAuth = false; this.cors = false; this.prefix = null; this.host = '127.0.0.1'; this.port = 8080; this.ssl = false; this.keyFile = null; this.certFile = null; this.fromOptions(options); } /** * Inject properties from object. * @private * @param {Object} options * @returns {HTTPOptions} */ fromOptions(options) { assert(options); assert(options.node && typeof options.node === 'object', 'HTTP Server requires a Node.'); this.node = options.node; this.network = options.node.network; this.logger = options.node.logger; this.port = this.network.rpcPort; if (options.logger != null) { assert(typeof options.logger === 'object'); this.logger = options.logger; } if (options.apiKey != null) { assert(typeof options.apiKey === 'string', 'API key must be a string.'); assert(options.apiKey.length <= 255, 'API key must be under 256 bytes.'); this.apiKey = options.apiKey; this.apiHash = sha256.digest(Buffer.from(this.apiKey, 'ascii')); } if (options.noAuth != null) { assert(typeof options.noAuth === 'boolean'); this.noAuth = options.noAuth; } if (options.cors != null) { assert(typeof options.cors === 'boolean'); this.cors = options.cors; } if (options.prefix != null) { assert(typeof options.prefix === 'string'); this.prefix = options.prefix; this.keyFile = path.join(this.prefix, 'key.pem'); this.certFile = path.join(this.prefix, 'cert.pem'); } if (options.host != null) { assert(typeof options.host === 'string'); this.host = options.host; } if (options.port != null) { assert((options.port & 0xffff) === options.port, 'Port must be a number.'); this.port = options.port; } if (options.ssl != null) { assert(typeof options.ssl === 'boolean'); this.ssl = options.ssl; } if (options.keyFile != null) { assert(typeof options.keyFile === 'string'); this.keyFile = options.keyFile; } if (options.certFile != null) { assert(typeof options.certFile === 'string'); this.certFile = options.certFile; } // Allow no-auth implicitly // if we're listening locally. if (!options.apiKey) { if ( this.host === '127.0.0.1' || this.host === '::1' || this.host === 'localhost') this.noAuth = true; } return this; } /** * Instantiate http options from object. * @param {Object} options * @returns {HTTPOptions} */ static fromOptions(options) { return new HTTPOptions().fromOptions(options); } } /* * Helpers */ function enforce(value, msg) { if (!value) { const err = new Error(msg); err.statusCode = 400; throw err; } } /* * Expose */ module.exports = HTTP;