UNPKG

bthread

Version:

Bitcoin based message thread

468 lines (386 loc) 11.5 kB
var assert = require('assert'); var bcoin = require('bcoin'); var inherits = require('inherits'); var levelup = require('levelup'); var bn = require('bn.js'); var pako = require('pako'); var EventEmitter = require('events').EventEmitter; function BThread(options) { if (!(this instanceof BThread)) return new BThread(options); EventEmitter.call(this); if (!options) options = {}; this.db = options.db || levelup(options.dbPath, { db: options.dbEngine, valueEncoding: 'json' }); this.host = options.host; this.addr = null; this.dust = 5460; this.fee = 10000; this.balance = new bn(0); this.resolveTxt = options.resolveTxt; this.pool = options.pool || new bcoin.pool({ size: options.size, createConnection: options.createConnection, storage: this.db }); this.loadWaiting = 2; this.loaded = false; this.dbReused = !!options.db; this.poolReused = !!options.pool; // Owner wallet this.loadTs = 0; this.wallet = null; // Listeners to remove on .destroy(); this._poolOnTX = null; this._poolOnReject = null; this._poolOnceFull = null; this._poolOnChainProgress = null; this.closed = false; this.createDNSCb = null; this._init(); } inherits(BThread, EventEmitter); module.exports = BThread; BThread.prototype._init = function init() { var self = this; this.resolveTxt(this.host, function(err, records) { var some = !err && records.some(function(record) { return self._parseTXT(record); }); if (!some) { self.emit('log', 'The domain does not have a BT record.'); self.createDNSCb = function(pass, cb) { if (!self.wallet) { self.wallet = new bcoin.wallet({ storage: self.db, scope: self.host, passphrase: pass }); // Start loading self.emit('wallet', self.wallet.getAddress()); } self.loadTs = +new Date() / 1000; cb('bt=v1 ' + self.wallet.getPublicKey('base58') + ' ' + new Date().toJSON()); }; self.emit('create-dns', self.createDNSCb); return; } self.emit('wallet', self.wallet.getAddress()); }); this.once('wallet', function() { // Emit new posts self.wallet.on('update', function(tx) { var post = self._tx2post(tx); if (post) self.emit('update', post); }); self.wallet.once('load' ,function(ts) { self.emit('log', 'Owner wallet loaded'); }); // Load wallet self.pool.addWallet(self.wallet, self.loadTs) .on('progress', function(c, t) { self.emit('search', c, t); }) .once('end', function() { self._load(); }); }); this._poolOnTX = function _poolOnTX(tx) { self._addTX(tx); self.emit('tx', tx); } this.pool.on('tx', this._poolOnTX); this._poolOnReject = function _poolOnReject(msg) { self.emit('log', 'Transaction rejected %j', msg); } this.pool.on('reject', this._poolOnReject); this._poolOnceFull = function _poolOnceFull() { self._poolOnceFull = null; if (self._poolOnChainProgress) self.pool.removeListener('chain-progress', self._poolOnChainProgress); self._poolOnChainProgress = null; self.emit('log', 'Blockchain is full and up-to-date'); self._load(); } if (this.pool.isFull()) { this._poolOnceFull(); } else { this.pool.once('full', this._poolOnceFull); this._poolOnChainProgress = function(percent) { self.emit('chain-progress', percent); }; this.pool.on('chain-progress', this._poolOnChainProgress); } }; BThread.prototype._load = function _load() { if (--this.loadWaiting !== 0) return; this.emit('load'); this.loaded = true; }; BThread.prototype._error = function error(msg) { if (typeof msg === 'string') msg = new Error(msg); this.emit('error', msg); }; BThread.prototype._parseTXT = function _parseTXT(record) { // Node.js v0.11 support if (Array.isArray(record)) record = record.join(''); var self = this; var match = record.match(/^bt\s*=\s*v1\s+([\w\d]+)\s+([\w\d\-:\.]+)$/); if (match === null) return false; // Parse public key var pub = bcoin.utils.fromBase58(match[1]); if (!(pub[0] === 4 && pub.length === 65) && !((pub[0] === 3 || pub[0] === 2) && pub.length === 33)) { return false; } this.loadTs = +new Date(match[2]) / 1000; this.wallet = new bcoin.wallet({ pub: pub, storage: this.db }); return true; }; BThread.prototype._addTX = function _addTX(tx) { if (this.wallet) this.wallet.addTX(tx); }; BThread.prototype.isOwner = function isOwner(w) { return w.getAddress() === this.wallet.getAddress(); }; BThread.prototype._msgScripts = function _msgScripts(msg) { var chunks = []; // Conver Uint8Array to Array msg = bcoin.utils.toArray(msg); var size = new Array(4); bcoin.utils.writeU32(size, msg.length, 0); msg = size.concat(msg); // Split message in 128-byte chunks for (var i = 0; i < msg.length; i += 128) chunks.push(msg.slice(i, i + 128)); var owner = this.wallet.getPublicKey(); var scripts = chunks.map(function(chunk) { var subchunks = []; // Split chunk in 64-byte subchunks for (var i = 0; i < chunk.length; i += 64) { subchunks.push(chunk.slice(i, i + 64)); } var keys = subchunks.map(function(subchunk) { var key = bcoin.utils.toArray(subchunk); var len = key.length < 32 ? 32 : 64; // Pad with zeroes for (var i = key.length; i < len; i++) key.push(0); // Add size prefix key.unshift(len === 32 ? 0x02 : 0x04); return key; }); // 8 for TXOut value, 4 for: varint len, num, num, checkmultisig return [ [ 1 ] ].concat( keys, [ owner, [ keys.length + 1 ], 'checkmultisig' ] ); }); return scripts; }; BThread.prototype.post = function post(w, postCost, data, confirm, cb) { if (!this.wallet) { this.once('wallet', function() { this.post(w, postCost, data, confirm, cb); }); return; } if (postCost.cmpn(0) !== 0 && postCost.cmpn(this.dust) < 0) { return cb(new Error('Post cost should be at least ' + this.dust + ' or ' + 'zero')); } // Reverse replyTo var replyTo = data.replyTo; // Verify that `replyTo` field is present and that it refers to real TX if (!this.isOwner(w)) { if (!data.replyTo) { return cb(new Error('You are not owner, please reply to ' + 'the existing post')); } } if (data.replyTo) { var hasTX = this.wallet.all().some(function(tx) { // Partial match var m = bcoin.utils.revHex(tx.hash('hex')).indexOf(replyTo) === 0; if (!m) return false; // Reverse hash from user input to normal representation data.replyTo = tx.hash('hex'); return true; }); if (!hasTX) return cb(new Error('TX ' + replyTo + ' is unknown')); } // Compress all data data = pako.deflate(JSON.stringify(data), { level: 9 }); var tx = bcoin.tx(); var scripts = this._msgScripts(data); // Add outputs for each script scripts.forEach(function(script, i) { tx.out({ value: new bn(this.dust), script: script }); }, this); // Additional money to author if (postCost.cmpn(0) !== 0) tx.out(this.wallet, postCost); var self = this; // Add enough inputs to cover both outputs and fee w.fill(tx, function(e) { if (e) return cb(e); var totalIn = tx.funds('in'); var totalOut = tx.funds('out'); var fee = totalIn.sub(totalOut); confirm(postCost.add(new bn(self.dust * scripts.length)), fee, onConfirm); }); function onConfirm(yes) { var hash = bcoin.utils.revHex(tx.hash('hex')); if (!yes) return cb(null, false, hash); self._addTX(tx); self.emit('log', 'Sending TX with id %s', hash); self.pool.sendTX(tx).once('ack', function() { cb(null, true, hash); }).on('ack', function() { self.emit('log', 'Got ACK for TX %s', hash); }); } }; BThread.prototype._tx2post = function _tx2post(tx) { var key = this.wallet.getPublicKey(); var multi = tx.outputs.filter(function(out) { return bcoin.script.isMultisig(out.script, key); }); if (multi.length === 0) return false; // Concat all scripts var data = []; multi.forEach(function(out) { for (var i = 1; i < out.script.length - 3; i++) data = data.concat(out.script[i].slice(1)); }); // Get length var len = bcoin.utils.readU32(data, 0); var body = data.slice(4, 4 + len); if (body.length === 0 || body.length !== len) return false; // Inflate body try { body = JSON.parse(pako.inflate(body, { to: 'string' })); } catch (e) { return false; } if (!body.content || typeof body.content !== 'string') return false; var title = body.content.match(/^(?:# )?\s*(.*)$/m); var author; if (this.wallet.ownInput(tx)) { author = 'owner'; } else { // TODO(indutny): load wallets for TXs with multisig inputs var addrs = tx.inputAddrs(); if (addrs.length === 0) author = null; // Edge case when the tx pool is incomplete else if (addrs[0] === this.wallet.getAddress()) author = 'owner'; else author = addrs[0]; } return { tx: tx, author: author, hash: bcoin.utils.revHex(tx.hash('hex')), ts: tx.ts || +new Date / 1000, title: title && title[1], content: body.content, replyTo: body.replyTo ? bcoin.utils.revHex(body.replyTo) : null, replies: [] }; }; BThread.prototype.list = function list(hash) { var all = this.wallet.all().map(function(tx) { if (hash && bcoin.utils.revHex(tx.hash('hex')).indexOf(hash) !== 0) return false; var body = this._tx2post(tx); if (!body) return false; return body; }, this).filter(function(post) { return post; }); // Exact match required if (hash) return all[0]; // Construct reply tree var threads = []; var map = {}; var orphans = {}; all.forEach(function(post) { if (post.replyTo) { if (map[post.replyTo]) map[post.replyTo].replies.push(post); else if (orphans[post.replyTo]) orphans[post.replyTo].push(post); else orphans[post.replyTo] = [ post ]; } else { threads.push(post); } map[post.hash] = post; // Fullfill orphans var o = orphans[post.hash]; if (!o) return; delete orphans[post.hash]; o.forEach(function(orphan) { post.replies.push(orphan); }); }, this); // Sort all replies all.forEach(function(post) { post.replies.sort(function(a, b) { return a.ts - b.ts; }); }); threads.sort(function(a, b) { return b.ts - a.ts; }); return threads; }; BThread.prototype.close = function close() { if (this.closed) return; this.closed = true; if (!this.poolReused) this.pool.destroy(); if (!this.dbReused) this.db.close(); if (this.wallet) this.pool.removeWallet(this.wallet); // Listeners to remove on .destroy(); this.pool.removeListener('tx', this._poolOnTX); this.pool.removeListener('reject', this._poolOnReject); if (this._poolOnceFull) this.pool.removeListener('full', this._poolOnceFull); if (this._poolOnChainProgress) this.pool.removeListener('chain-progress', this._poolOnChainProgress); this._poolOnChainProgress = null; };