tim
Version:
Tradle's P2P OTR-based chat with plaintext and structured messages, and the ability to externalize messages onto a blockchain (currently bitcoin's)
1,969 lines (1,670 loc) • 70.2 kB
JavaScript
var EventEmitter = require('events').EventEmitter
var assert = require('assert')
var util = require('util')
var omit = require('object.omit')
var Q = require('q')
var typeforce = require('typeforce')
var debug = require('debug')('tim')
var PassThrough = require('readable-stream').PassThrough
var reemit = require('re-emitter')
var bitcoin = require('@tradle/bitcoinjs-lib')
var extend = require('xtend/mutable')
var clone = require('xtend/immutable')
var collect = require('stream-collector')
var tradleUtils = require('@tradle/utils')
var map = require('map-stream')
var pump = require('pump')
var find = require('array-find')
var deepEqual = require('deep-equal')
var Cache = require('lru-cache')
var ChainedObj = require('@tradle/chained-obj')
var TxData = require('@tradle/tx-data').TxData
var TxInfo = require('@tradle/tx-data').TxInfo
var ChainWriter = require('@tradle/bitjoe-js')
var ChainLoader = require('@tradle/chainloader')
var Permission = require('@tradle/permission')
var Wallet = require('@tradle/simple-wallet')
var hrtime = require('monotonic-timestamp')
var mi = require('@tradle/identity')
var Identity = mi.Identity
var kiki = require('@tradle/kiki')
var toKey = kiki.toKey
var Builder = ChainedObj.Builder
var Parser = ChainedObj.Parser
var lb = require('logbase')
var Entry = lb.Entry
var unchainer = require('./lib/unchainer')
var constants = require('@tradle/constants')
var EventType = require('./lib/eventType')
var Dir = require('./lib/dir')
var createIdentityDB = require('./lib/identityDB')
var createMsgDB = require('./lib/msgDB')
var createTypeDB = require('./lib/typeDB')
var createTxDB = require('./lib/txDB')
var createMiscDB = require('./lib/miscDB')
var Errors = require('./lib/errors')
var utils = require('./lib/utils')
var getViaCache = require('./lib/getViaCache')
var RETRY_UNCHAIN_ERRORS = [
ChainLoader.Errors.ParticipantsNotFound,
ChainLoader.Errors.FileNotFound
].map(function (ErrType) {
return ErrType.type
})
var DEV = process.env.NODE_ENV !== 'production'
var TYPE = constants.TYPE
var TYPES = constants.TYPES
var SIGNEE = constants.SIGNEE
var ROOT_HASH = constants.ROOT_HASH
var PREV_HASH = constants.PREV_HASH
var CUR_HASH = constants.CUR_HASH
var NONCE = constants.NONCE
var CONFIRMATIONS_BEFORE_CONFIRMED = 10
Driver.BLOCKCHAIN_KEY_PURPOSE = 'messaging'
Driver.MIN_BALANCE = 10000
Driver.CHAIN_WRITE_THROTTLE = 60000
Driver.CHAIN_READ_THROTTLE = 300000
Driver.SEND_THROTTLE = 10000
Driver.CATCH_UP_INTERVAL = 2000
Driver.UNCHAIN_THROTTLE = 20000
// Driver.Zlorp = Zlorp
Driver.Kiki = kiki
Driver.Identity = Identity
Driver.Wallet = Wallet
Driver.EventType = EventType
Driver.PROTOCOL_VERSION = '1.0.0'
// TODO: export other deps
var DEFAULT_OPTIONS = {
opReturnPrefix: '',
chainThrottle: Driver.CHAIN_WRITE_THROTTLE,
syncInterval: Driver.CHAIN_READ_THROTTLE,
unchainThrottle: Driver.UNCHAIN_THROTTLE,
sendThrottle: Driver.SEND_THROTTLE,
afterBlockTimestamp: 0
}
var optionsTypes = {
// maybe allow read-only mode if this is missing
// TODO: replace with kiki (will need to adjust otr, zlorp for async APIs)
keys: 'Array',
identity: 'Identity',
blockchain: 'Object',
networkName: 'String',
keeper: 'Object',
leveldown: 'Function',
pathPrefix: 'String',
opReturnPrefix: '?String',
syncInterval: '?Number',
chainThrottle: '?Number',
unchainThrottle: '?Number',
sendThrottle: '?Number',
readOnly: '?Boolean',
afterBlockTimestamp: '?Number',
name: '?String'
}
var noop = function () {}
module.exports = Driver
util.inherits(Driver, EventEmitter)
Driver.enableOptimizations = require('./lib/optimizations')
function Driver (options) {
var self = this
typeforce(optionsTypes, options)
EventEmitter.call(this)
this.setMaxListeners(0)
tradleUtils.bindPrototypeFunctions(this)
// this._fetchAndReschedule = promiseDebounce(this._fetchAndReschedule, this)
options = clone(DEFAULT_OPTIONS, options)
extend(this, omit(options, ['name']))
this._options = utils.pick(this, Object.keys(optionsTypes))
this._name = options.name
this._signingKey = toKey(
this.getPrivateKey({
type: 'ec',
purpose: 'sign'
})
)
// copy
this.identityMeta = {}
this.setIdentity(options.identity.toJSON())
if (this.afterBlockTimestamp) {
this._debug('ignoring txs before', new Date(this.afterBlockTimestamp * 1000).toString())
}
var networkName = this.networkName
var keeper = this.keeper
var blockchain = this.blockchain
var leveldown = this.leveldown
var wallet = this.wallet = this.wallet || new Wallet({
networkName: networkName,
blockchain: blockchain,
priv: this.getBlockchainKey().priv()
})
// init balance while we rely on blockr for this info
this._balance = 0
this._paused = false
this.chainwriter = new ChainWriter({
wallet: wallet,
keeper: keeper,
networkName: networkName,
minConf: 0,
prefix: this.opReturnPrefix
})
this.chainloader = new ChainLoader({
keeper: keeper,
networkName: networkName,
prefix: this.opReturnPrefix,
lookup: this.getKeyAndIdentity2
})
this.unchainer = unchainer({
chainloader: this.chainloader,
lookup: this.lookupByDHTKey
})
this._streams = {}
this._ignoreTxs = []
this._watchedAddresses = [
this.wallet.addressString
]
this._watchedTxs = []
// in-memory cache of recent conversants
this._catchUpWithBlockchain()
this._fingerprintToIdentity = {}
this._pendingTxs = []
this._setupDBs()
this._setupTxFetching()
var keeperReadyDfd = Q.defer()
var keeperReady = keeperReadyDfd.promise
if (!keeper.isReady || keeper.isReady()) keeperReadyDfd.resolve()
else keeper.once('ready', keeperReadyDfd.resolve)
this._readyPromise = Q.all([
self._prepIdentity(),
self._setupTxStream(),
keeperReady,
this._loadWatches(),
this._updateBalance()
.catch(function (e) {
self._debug('unable to get balance')
})
])
.then(function () {
if (self._destroyed) {
return Q.reject(new Error('destroyed'))
}
self.msgDB.start()
self.txDB.start()
self.addressBook.start()
self.typeDB.start()
self.miscDB.start()
self._ready = true
self._debug('ready')
self.emit('ready')
// self.publishMyIdentity()
self._writeToChain()
self._readFromChain()
self._sendTheUnsent()
// self._watchMsgStatuses()
})
}
/**
* specify txIds for which to load txs
* @param {Array} txIds - transaction ids
*/
Driver.prototype.loadTxs = function (txIds) {
var self = this
Q.ninvoke(this.blockchain.transactions, 'get', txIds)
.then(function (txInfos) {
// TODO: get rid of this parse everywhere
txInfos = utils.parseCommonBlockchainTxs(txInfos)
return self._processTxs(txInfos)
})
}
Driver.prototype.ready = function () {
return this._readyPromise
}
Driver.prototype.isReady = function () {
return this._ready
}
Driver.prototype.isPaused = function () {
return this._paused
}
Driver.prototype.pause = function (resumeTimeout) {
if (this._paused) return
this._debug('pausing...')
this._paused = true
for (var name in this._streams) {
this._streams[name].pause()
}
if (typeof resumeTimeout === 'number') {
setTimeout(this.resume, resumeTimeout)
}
this.emit('pause')
}
Driver.prototype.resume = function () {
if (!this._paused) return
this._debug('resuming...')
this._paused = false
for (var name in this._streams) {
this._streams[name].resume()
}
this.emit('resume')
}
Driver.prototype._prepIdentity = function () {
var self = this
return utils.getDHTKey(this.identityJSON)
.then(function (key) {
copyDHTKeys(self.identityMeta, key)
})
}
Driver.prototype._updateBalance = function () {
var self = this
return Q.ninvoke(this.wallet, 'balance')
.then(function (balance) {
self._balance = balance
if (balance < Driver.MIN_BALANCE) {
self.emit('lowbalance')
}
})
}
/**
* read from chain
*/
Driver.prototype._readFromChain = function () {
var self = this
if (this._destroyed) return
if (!this.txDB.isLive()) {
return this.txDB.once('live', this._readFromChain)
}
if (this._queuedUnchains) return
this._queuedUnchains = {}
var stream = this.txDB.liveStream({
old: true,
tail: true
})
this._streams.txDB = stream
pump(
stream,
map(function (data, cb) {
if (self._destroyed || typeof data.value !== 'object') {
return cb()
}
cb(null, data.value)
}),
map(function (entry, cb) {
// was read from chain and hasn't been processed yet
var txId = entry.txId
if (!entry.dateDetected || entry.dateUnchained || entry.ignore) {
return finish()
}
// no body yet
if (!entry.tx) {
return finish()
}
// clear old errors
var errs = utils.getErrors(entry, 'unchain')
delete entry.errors
if (!errs || !errs.length) return finish(null, entry)
var shouldTryAgain = errs.every(function (err) {
return RETRY_UNCHAIN_ERRORS.indexOf(err.type) !== -1
})
if (!shouldTryAgain) return finish()
if (errs.length >= Errors.MAX_UNCHAIN) {
// console.log(entry.errors, entry.id)
self._debug('skipping unchain after', errs.length, 'errors for tx:', txId)
// TODO: remove
// self._remove(entry)
return finish()
}
if (self._queuedUnchains[txId]) {
finish()
return self._debug('already schedulded unchaining!')
}
self._queuedUnchains[txId] = true
var throttled = throttleIfRetrying(errs, self.unchainThrottle, function () {
finish(null, entry)
})
if (throttled) {
self._debug('throttling unchain retry of tx', txId, throttled, 'millis')
}
function finish (err, ret) {
if (err || !ret) self._rmPending(txId)
delete self._queuedUnchains[txId]
if (!ret) {
self._debug('skipping unchain of tx', txId)
}
cb(err, ret)
}
}),
map(function (txEntry, cb) {
if (self._destroyed) return cb()
var chainedObj
self.lookupObject(txEntry, true) // verify=true
.then(function (result) {
chainedObj = result
if (chainedObj.txType === TxData.types.public) {
self._debug('saving to keeper')
self.keeper.put(chainedObj.key, chainedObj.data)
}
return chainedObj
})
.catch(function (err) {
chainedObj = err.progress
})
// record failed unchains as well
.then(function () {
return self.unchainResultToEntry(chainedObj)
})
.done(function (entry) {
self._rmPending(utils.getEntryProp(entry, 'txId'))
cb(null, entry)
})
}),
utils.jsonify(),
this._log,
this._rethrow
)
this._pauseStreamIfPaused(stream)
}
Driver.prototype._pauseStreamIfPaused = function (stream) {
if (this._paused) {
stream.pause()
}
}
// Driver.prototype._remove = function (info, lookup) {
// var self = this
// this.lookupObject(info)
// .catch(function (err) {
// return err.progress
// })
// .then(function (chainedObj) {
// var tasks = ['key', 'permissionKey']
// .map(function (p) {
// return chainedObj[p]
// })
// .filter(function (key) {
// return !!key
// })
// return Q.all(tasks)
// })
// }
Driver.prototype._rmPending = function (txId) {
typeforce('String', txId)
var idx = this._pendingTxs.indexOf(txId)
if (idx !== -1) {
this._pendingTxs.splice(idx, 1)
}
}
Driver.prototype._rethrow = function (err) {
if (err) {
this._debug('experienced an error', err)
if (!this._destroyed && err) throw err
}
}
Driver.prototype._setupTxFetching = function () {
var self = this
var txDB = this.txDB
var txSubmodule = this.blockchain.transactions
txSubmodule.get = getViaCache(
txSubmodule.get.bind(txSubmodule),
getFromCache
)
function getFromCache (ids, cb) {
// ids = ids.filter(function (id) {
// return self._ignoreTxs.indexOf(id) === -1
// })
// var togo = ids.length
// if (togo === 0) return cb(null, [])
self._multiGetFromDB({
db: self.txDB,
keys: ids
})
.then(function (results) {
results = utils.pluck(results, 'value')
results.forEach(function (r, i) {
if (r && r.ignore) {
var id = ids[i]
if (self._ignoreTxs.indexOf(id) === -1) {
self._ignoreTxs.push(id)
}
}
})
results = results.map(function (v) {
return v && v.tx && v.confirmations >= CONFIRMATIONS_BEFORE_CONFIRMED ? v : undefined
})
cb(null, results)
})
}
}
Driver.prototype._catchUpWithBlockchain = function () {
var self = this
if (this._caughtUpPromise) return this._caughtUpPromise
var done
var txIds
var catchUp = Q.defer()
this._caughtUpPromise = catchUp.promise
.then(function () {
self._debug('caught up with blockchain')
})
.finally(function () {
done = true
})
this.once('txs', function (txInfos) {
txIds = txInfos
.filter(function (txInfo) {
return txInfo.blockTimestamp > self.afterBlockTimestamp
})
.map(function (txInfo) {
return txInfo.txId
})
checkDBs()
})
return this._caughtUpPromise
function checkDBs () {
if (self._destroyed) return
self._multiGetFromDB({
db: self.txDB,
keys: txIds,
strict: true,
then: function (entry) {
var hasErrors = !!utils.countErrors(entry)
var processed = entry.dateUnchained || entry.dateChained || entry.ignore || hasErrors
if (!processed) {
self._debug('still waiting for tx', entry.txId)
throw new Error('not ready')
}
}
})
.then(catchUp.resolve)
.catch(scheduleRetry)
}
function scheduleRetry () {
self._debug('not caught up yet with blockchain...')
setTimeout(checkDBs, Driver.CATCH_UP_INTERVAL)
}
}
// Driver.prototype.unignoreTxs = function (txIds) {
// this.log(new Entry({
// type: EventType.tx.load,
// ignore: false,
// txIds: txIds
// }))
// }
Driver.prototype._sortParsedTxs = function (txInfos) {
return txInfos
}
Driver.prototype._shouldLoadTx = function (txInfo) {
// override this if you want to be more choosy
return true
}
Driver.prototype._willLoadTx = function (txInfo) {
var txId = txInfo.txId
if (this._ignoreTxs.indexOf(txId) !== -1) return false
if (this._watchedTxs.indexOf(txId) !== -1) return true
return this._shouldLoadTx(txInfo)
}
// Driver.prototype.shouldLoadTxData = function (txData) {
// // override this if you want to be more choosy
// return true
// }
Driver.prototype._multiGetFromDB = function (opts) {
// override this if you have a better way
// e.g. in react-native, with AsyncStorage.multiGet
typeforce({
db: 'Object',
keys: 'Array',
then: '?Function',
strict: '?Boolean'
}, opts)
return Q[opts.strict ? 'all' : 'allSettled'](opts.keys.map(function (key) {
var promise = Q.ninvoke(opts.db, 'get', key)
return opts.then ? promise.then(opts.then) : promise
}))
}
Driver.prototype.identityPublishStatus = function () {
var self = this
// check if we've already chained it
if (!this._ready) {
return this._readyPromise.then(this.identityPublishStatus)
}
var rh = this.myRootHash()
var me = this.identityJSON
var status = {
ever: false,
current: false,
queued: false
}
return this._catchUpWithBlockchain()
.then(function () {
return Q.all([
Q.ninvoke(self.msgDB, 'byRootHash', rh),
Q.ninvoke(tradleUtils, 'getStorageKeyFor', utils.toBuffer(me))
])
})
.spread(function (entries, curHash) {
curHash = curHash.toString('hex')
var unchained = entries.filter(function (e) {
return e.dateUnchained
})
status.ever = !!unchained.length
unchained.some(function (e) {
if (e[CUR_HASH] === curHash) {
status.txId = e.txId
status.current = true
return true
}
})
status.queued = !status.current && entries.some(function (e) {
return e[CUR_HASH] === curHash
})
return status
})
.catch(function (err) {
if (!err.notFound) throw err
return status
})
}
Driver.prototype.publishIdentity = function (identity, toAddress) {
identity = identity || this.identityJSON
return this.publish({
msg: identity,
to: [{ fingerprint: toAddress || constants.IDENTITY_PUBLISH_ADDRESS }]
})
}
Driver.prototype.setIdentity = function (identityJSON) {
if (deepEqual(this.identityJSON, identityJSON)) return
this.identity = Identity.fromJSON(identityJSON)
this.identityJSON = this.identity.toJSON()
}
Driver.prototype.publishMyIdentity = function (toAddr) {
var self = this
if (this._publishingIdentity) {
throw new Error('wait till current publishing process ends')
}
if (!this._ready) {
return this._readyPromise.then(this.publishMyIdentity)
}
if (toAddr) {
this.watchAddresses(toAddr)
}
this._publishingIdentity = true
return this.identityPublishStatus()
.then(function (status) {
if (!status.ever) return self.publishIdentity()
if (status.queued) {
return Q.reject(new Error('already publishing this version'))
}
if (status.current) {
return Q.reject(new Error('already published this version'))
}
return publish()
})
.finally(function () {
delete self._publishingIdentity
})
function publish () {
var priv = self.getPrivateKey({ purpose: 'update' })
var update = extend({}, self.identityJSON)
var prevHash = self.myCurrentHash() || self.myRootHash()
utils.updateChainedObj(update, prevHash)
return Builder.addNonce(update)
.then(function () {
return Builder()
.data(update)
.signWith(toKey(priv))
.build()
})
.then(function (buf) {
self.setIdentity(update)
extend(self.identityMeta, utils.pick(update, PREV_HASH, ROOT_HASH))
return Q.all([
self._prepIdentity(),
self.publishIdentity(buf, toAddr)
])
})
}
}
Driver.prototype.identities = function () {
return this.addressBook
}
Driver.prototype.messages = function () {
return this.msgDB
}
Driver.prototype.decryptedMessagesStream = function () {
var self = this
return this.msgDB.createValueStream()
.pipe(map(function (info, cb) {
self.lookupObject(info)
.then(function (obj) {
cb(null, obj)
}, function (err) {
self._debug('failed to lookup object: ' + info[CUR_HASH])
cb()
})
}))
}
Driver.prototype.transactions = function () {
return this.txDB
}
Driver.prototype.unchainResultToEntry = function (chainedObj) {
var self = this
var success = !utils.countErrors(chainedObj)
var type = success ?
EventType.chain.readSuccess :
EventType.chain.readError
// no decrypted data should be stored in the log
var safeProps = omit(chainedObj, [
'type',
'parsed',
'key',
'data',
'encryptedData', // stored in keeper
'permission',
'encryptedPermission' // stored in keeper
])
var entry = new Entry(safeProps)
.set('type', type)
// if ('key' in chainedObj) {
// entry.set(CUR_HASH, chainedObj.key)
// }
if ('parsed' in chainedObj) {
entry.set('public', chainedObj.txType === TxData.types.public)
}
if ('tx' in chainedObj) {
entry.set('tx', utils.toBuffer(chainedObj.tx))
}
var tasks = ['from', 'to'].map(function (party) {
return chainedObj[party]
})
.map(function (party) {
if (!party) return
if (party[ROOT_HASH]) return party[ROOT_HASH]
// a bit scary
var fingerprint = party.identity.keys()[0].toJSON().fingerprint
return self.lookupRootHash(fingerprint)
})
return Q.allSettled(tasks)
.spread(function (from, to) {
if (from.value) {
entry.set('from', utils.toObj(ROOT_HASH, from.value))
}
if (to.value) {
entry.set('to', utils.toObj(ROOT_HASH, to.value))
}
if (success) utils.setUID(entry)
return entry
})
}
Driver.prototype._writeToChain = function () {
var self = this
var getStream = this.msgDB.getToChainStream.bind(this.msgDB, {
old: true,
tail: true
})
var throttle = this.chainThrottle
this._processQueue({
name: 'writeToChain',
getStream: getStream,
maxErrors: Errors.MAX_CHAIN,
errorsGroup: Errors.group.chain,
processItem: this.putOnChain,
throttle: throttle,
retryDelay: throttle,
successType: EventType.chain.writeSuccess,
// shouldSkipQueue: function (state) {
// return state.public
// },
shouldTryLater: function (state) {
if (self._balance < Driver.MIN_BALANCE) {
self._updateBalance()
return true
}
}
})
}
Driver.prototype.getSendQueue = function () {
var msgDB = this.msgDB
var stream = pump(
msgDB.getToSendStream({
old: true,
tail: false
}),
map(function (data, cb) {
msgDB.get(data.value, cb)
})
// ,
// utils.filterStream(function (data) {
// var errs = utils.getErrors(data, 'send')
// return !(errs && errs.length >= Errors.MAX_SEND)
// })
)
return Q.nfcall(collect, stream)
}
Driver.prototype.getChainQueue = function () {
var msgDB = this.msgDB
var stream = pump(
msgDB.getToChainStream({
old: true,
tail: false
}),
map(function (data, cb) {
msgDB.get(data.value, cb)
})
// ,
// utils.filterStream(function (data) {
// var errs = utils.getErrors(data, 'chain')
// return !(errs && errs.length >= Errors.MAX_CHAIN)
// })
)
return Q.nfcall(collect, stream)
}
Driver.prototype._sendTheUnsent = function () {
var getStream = this.msgDB.getToSendStream.bind(this.msgDB, {
old: true,
tail: true
})
this._processQueue({
name: 'sendTheUnsent',
getStream: getStream,
maxErrors: Errors.MAX_RESEND,
errorsGroup: Errors.group.send,
processItem: this._processSendQueueItem,
retryDelay: this.sendThrottle,
successType: EventType.msg.sendSuccess,
giveUpType: EventType.msg.giveUpSend
})
}
Driver.prototype._processQueue = function (opts) {
var self = this
typeforce({
name: 'String',
successType: 'Number',
getStream: 'Function',
processItem: 'Function',
maxErrors: '?Number',
shouldSkipQueue: '?Function',
shouldTryLater: '?Function',
retryDelay: '?Number',
retryOnFail: '?Boolean' // default: true
}, opts)
var name = opts.name
this._processingQueue = this._processingQueue || {}
if (this._processingQueue[name]) return
this._processingQueue[name] = true
// queues by recipient root hash
var queues = {}
var lastProcessTime = 0
var shouldSkipQueue = opts.shouldSkipQueue || alwaysFalse
var shouldTryLater = opts.shouldTryLater || alwaysFalse
var maxErrors = typeof opts.maxErrors === 'number' ? opts.maxErrors : Infinity
var errorsGroup = opts.errorsGroup
var processItem = opts.processItem
if (opts.throttle) {
processItem = utils.rateLimitPromiseFn(processItem, opts.throttle)
}
var retryDelay = opts.retryDelay
var retryOnFail = opts.retryOnFail !== false
var successType = opts.successType
var giveUpType = opts.giveUpType
var stream = opts.getStream()
var sync
stream.once('sync', function () {
sync = true
})
this._streams[name] = stream
pump(
stream,
map(function (data, cb) {
if (data.type === 'del') {
remove(data)
return cb()
}
self.msgDB.get(data.value, function (err, state) {
if (err) {
self._debug('error on "get" from msgDB', err)
remove(data)
return throwIfDev(err)
}
// HACK for bug fixed in 3.5.1
// to kill orphaned timestamps that were already introduced
if (state.timestamp !== Number(data.key.split('!')[1])) {
self._debug('ignoring orphaned timestamp', state.uuid)
return cb()
}
if (shouldSkipQueue(state)) {
runASAP(state)
} else {
insert(state)
processQueue(getQueueID(state))
}
cb()
})
})
)
this._pauseStreamIfPaused(stream)
function getQueueID (data) {
// should public go in one queue?
// return data.public ? data[ROOT_HASH] : data.to[ROOT_HASH]
return data.to[ROOT_HASH] || data.to.fingerprint
}
function runASAP (state) {
if (self._destroyed) return
if (shouldTryLater(state)) {
setTimeout(runASAP.bind(null, state), retryDelay)
} else {
processItem(state)
}
}
function processQueue (rid) {
if (self._destroyed) return
var q = queues[rid] = queues[rid] || []
if (!q.length || q.processing || q.waiting) return
self._debug('processing queue', name)
q.processing = true
var rawNext = q[0]
if (shouldTryLater(rawNext)) {
return utils.promiseDelay(retryDelay)
.done(processQueue.bind(null, rid))
}
rawNext.errors = rawNext.errors || {}
var errors = rawNext.errors[errorsGroup] = rawNext.errors[errorsGroup] || []
var next = omit(rawNext, ['errors']) // defensive copy
return processItem(next)
.catch(function (err) {
debug(name + ' processItem crashed (this should not happen)', err)
throw err
})
.then(function (entry) {
if (self._destroyed) return
self._debug('processed item from queue', name)
q.processing = false
var eType = utils.getEntryProp(entry, 'type')
var isFinished = eType === successType
|| eType === giveUpType
|| !retryOnFail
if (!isFinished) {
errors.push.apply(errors, utils.getErrors(entry, errorsGroup))
isFinished = errors.length >= maxErrors
}
if (isFinished) {
q.shift()
processQueue(rid)
} else if (retryOnFail) {
self._debug('throttling queue', name)
q.waiting = true
setTimeout(keepGoing, retryDelay)
}
})
function keepGoing () {
if (self._destroyed) return
q.waiting = false
// probably not needed
self.msgDB.onLive(function () {
processQueue(rid)
})
}
}
function insert (data) {
var rid = getQueueID(data)
var q = queues[rid] = queues[rid] || []
if (!sync) return q.push(data)
var exists = q.some(function (item) {
return item.uid === data.uid
})
if (!exists) q.push(data)
}
function remove (data) {
if (!data.value) {
debugger
return
}
var idx = -1
var uid = data.value
var rid = utils.parseUID(uid).to
var q = queues[rid]
if (!q || !q.length) return
q.some(function (item, i) {
if (item.uid === uid) {
idx = i
}
})
if (idx !== -1) q.splice(idx, 1)
}
}
Driver.prototype._processSendQueueItem = function (entry) {
// this._markSending(entry)
var self = this
var nextEntry = new Entry()
.set(utils.pick(entry, 'uid', ROOT_HASH))
// return Q.ninvoke(this.msgDB, 'onLive')
// .then(function () {
return self._trySend(entry)
// })
.then(function () {
self._debug('msg sent successfully')
return nextEntry.set('type', EventType.msg.sendSuccess)
})
.catch(function (err) {
self._debug('msg send failed', err.message)
var eType = err.type === 'fileNotFound'
? EventType.msg.giveUpSend
: EventType.msg.sendError
nextEntry.set({
type: eType
})
utils.addError({
entry: nextEntry,
group: Errors.group.send,
error: err
})
// self._markNotSending(nextEntry)
return nextEntry
})
.then(this.log)
}
Driver.prototype.name = function () {
if (this._name) return this._name
return this._name = this.identityJSON.pubkeys[0].fingerprint
}
Driver.prototype._markSending = function (entry) {
this._currentlySending.push(entry.uid)
}
Driver.prototype._markNotSending = function (entry) {
var idx = this._currentlySending.indexOf(entry.uid)
this._currentlySending.splice(idx, 1)
}
Driver.prototype._setupTxStream = function () {
// Uncomment when Blockr supports querying by height
// TODO: use txDB for this instead
// var self = this
// var defer = Q.defer()
// var lastBlock
// var lastBlockTxIds = []
// var chainTypes = EventType.chain
// var rs = this._log.createReadStream({ reverse: true })
// .pipe(filter(function (entry, cb) {
// var eType = entry.get('type')
// return eType === chainTypes.readSuccess || eType === chainTypes.readError
// }))
// .on('data', function (entry) {
// var txId = bitcoin.Transaction.fromBuffer(entry.get('tx')).getId()
// lastBlockTxIds.unshift(txId)
// if (typeof lastBlock === 'undefined') {
// lastBlock = entry.get('height')
// } else {
// if (entry.get('height') < lastBlock) {
// rs.destroy()
// }
// }
// })
// .on('error', this._rethrow)
// .once('close', function () {
// // start CONFIRMATIONS_BEFORE_CONFIRMED blocks back
// lastBlock = lastBlock || 0
// lastBlock = Math.max(0, lastBlock - CONFIRMATIONS_BEFORE_CONFIRMED)
// self._runFetchTxsLoop(lastBlock, lastBlockTxIds)
// defer.resolve()
// })
//
// return defer.promise
this._runTxProcessingLoop(0, [])
return Q.resolve()
}
Driver.prototype._runTxProcessingLoop = function (fromHeight, skipIds) {
var self = this
if (this._destroyed) return
// TODO: fetch based on height
if (!fromHeight) fromHeight = 0
this._fetchAndReschedule()
}
Driver.prototype.sync = function (force) {
if (force) {
delete this._pendingFetch
}
this._fetchAndReschedule()
}
Driver.prototype._fetchAndReschedule = function () {
var self = this
var waitTillUnpaused
if (this._destroyed) return
if (this._pendingFetch) return this._pendingFetch
clearTimeout(this._fetchTxsTimeout)
if (this._paused) {
waitTillUnpaused = Q.Promise(function (resolve) {
if (!self._paused) resolve()
else self.once('resume', resolve)
})
}
return this._pendingFetch = Q(waitTillUnpaused)
.then(this._fetchTxs)
.then(this._processTxs)
.catch(function (err) {
debug('failed to fetch/process txs', err)
})
.then(function () {
delete self._pendingFetch
if (!self._destroyed) {
waitAndLoop()
}
})
function waitAndLoop() {
var defer = Q.defer()
self._fetchTxsTimeout = setTimeout(defer.resolve, self.syncInterval)
defer.promise.then(self._fetchAndReschedule)
}
}
Driver.prototype._fetchTxs = function () {
var self = this
return this._loadWatchesPromise
.then(function () {
var watchedTxs = self._getWatchedTxs()
return Q.all([
Q.ninvoke(self.blockchain.addresses, 'transactions', self._getWatchedAddresses()),
watchedTxs.length && Q.ninvoke(self.blockchain.transactions, 'get', watchedTxs)
])
})
.spread(function (part1, part2) {
var txInfos = part2 ? part1.concat(part2) : part1
// TODO: get rid of this parse everywhere
txInfos = utils.parseCommonBlockchainTxs(txInfos)
self.emit('txs', txInfos)
return txInfos
})
}
Driver.prototype._processTxs = function (txInfos) {
var self = this
if (this._destroyed) return
var idsToLookup = []
txInfos = this._sortParsedTxs(txInfos)
var filtered = txInfos.filter(function (txInfo) {
var id = txInfo.txId
if (self._watchedTxs.indexOf(id) === -1 &&
!txInfo.watch &&
txInfo.blockTimestamp &&
txInfo.blockTimestamp <= self.afterBlockTimestamp) {
// TODO: remove this when we have a blockchain API
// that supports height-based queries
self.log(new Entry({
type: EventType.tx.ignore,
txId: id
}))
return
}
if (self._pendingTxs.indexOf(id) !== -1) {
return
}
// TODO: filter shouldn't have side effects
self._pendingTxs.push(id)
idsToLookup.push(id)
return true
})
if (!idsToLookup.length) return
return this._multiGetFromDB({
db: this.txDB,
keys: idsToLookup
})
.then(function (results) {
var logPromises = results.map(function (result, i) {
var txInfo = filtered[i]
var id = txInfo.txId
var entry = result.value
var err = result.reason
var unexpectedErr = err && !err.notFound
if (unexpectedErr) {
self._debug('unexpected txDB error', err)
}
var handled
if (entry && 'confirmations' in entry) {
if (entry.confirmations > CONFIRMATIONS_BEFORE_CONFIRMED
|| entry.confirmations === txInfo.confirmations) {
handled = true
}
}
if (unexpectedErr || handled) {
return self._rmPending(id)
}
var type
// console.log(TxInfo.parse(txInfo.tx))
var parsedTx = TxInfo.parse(txInfo.tx, self.networkName, self.opReturnPrefix)
if (entry) {
if (entry.tx && (entry.dateDetected || entry.ignore)) {
// already got this from chain
// at least once
return
}
// we put this tx on chain
// this is the first time we're getting it FROM the chain
type = EventType.tx.confirmation
if (!entry.dateChained) {
self._debug('uh oh, this should be a confirmation for a tx chained by us')
}
} else if (!parsedTx.txData) {
type = EventType.tx.ignore
} else {
type = EventType.tx.new
}
if (type !== EventType.tx.ignore) {
if (!(entry && entry.ignore === false) && !self._willLoadTx(parsedTx)) {
type = EventType.tx.ignore
}
}
var isOutbound = parsedTx.addressesFrom.some(function (addr) {
return addr === self.wallet.addressString
})
var nextEntry = new Entry(extend(
entry || {},
txInfo,
parsedTx,
{
type: type,
txId: id,
tx: utils.toBuffer(txInfo.tx),
// ignore can be revoked
ignore: type === EventType.tx.ignore,
dir: isOutbound ? Dir.outbound : Dir.inbound
}
))
// clear errors
nextEntry.set('errors', {})
return self.log(nextEntry)
})
return Q.all(logPromises)
})
}
Driver.prototype._setupDBs = function () {
var self = this
this._log = new lb.Log(this._prefix('msg-log.db'), {
db: this.leveldown
})
this._log.setMaxListeners(0)
this.addressBook = createIdentityDB(this._prefix('addressBook.db'), {
leveldown: this.leveldown,
log: this._log,
keeper: this.keeper,
timeout: false,
autostart: false
})
this.addressBook.name = this.name()
this._identityCache = {}
this.msgDB = createMsgDB(this._prefix('messages.db'), {
leveldown: this.leveldown,
log: this._log,
timeout: false,
autostart: false
})
this.msgDB.name = this.name()
var msgDBEvents = [
'chained',
'unchained',
'message',
'sent',
'forgot'
]
reemit(this.msgDB, this, msgDBEvents)
this._enableObjectCaching()
;['message', 'unchained'].forEach(function (event) {
self.msgDB.on(event, function (entry) {
if (entry.tx && entry.dateReceived) {
self.emit('resolved', entry)
}
})
})
this.on('unchained', function (entry) {
if (entry[TYPE] === TYPES.IDENTITY && entry[CUR_HASH] === self.myCurrentHash()) {
self.emit('unchained-self', entry)
}
})
this.txDB = createTxDB(this._prefix('txs.db'), {
leveldown: this.leveldown,
log: this._log,
timeout: false,
autostart: false
})
this.txDB.name = this.name()
this.miscDB = createMiscDB(this._prefix('misc.db'), {
leveldown: this.leveldown,
log: this._log,
timeout: false,
autostart: false
})
this.miscDB.on('watchedAddresses', function (addrs) {
self._setWatchedAddresses(addrs)
})
this.miscDB.on('watchedTxs', function (txIds) {
self._setWatchedTxs(txIds)
})
this.miscDB.name = this.name()
this.typeDB = createTypeDB(this._prefix('type.db'), {
leveldown: this.leveldown,
log: this._log,
timeout: false,
autostart: false,
keeper: this.keeper
})
this.typeDB.name = this.name()
}
Driver.prototype._trySend = function (entry) {
// TODO:
// do we log that we sent it?
// do we log when we delivered it? How do we know it was delivered?
var self = this
return this.lookupObject(entry)
.then(function (obj) {
var to = clone(obj.to)
to.identity = to.identity.toJSON()
self._debug('sending msg to peer', obj.parsed.data[TYPE])
var msg = utils.msgToBuffer(utils.getMsgProps(obj))
var toRootHash = obj.to[ROOT_HASH]
return self._send(toRootHash, msg, to)
})
}
/**
* Override this
* @return {Promise} send a message to a particular recipient
*/
Driver.prototype._send = function (recipientInfo, msg) {
throw new Error('override this method')
}
Driver.prototype.lookupObjectsByRootHash = function (rootHash) {
var self = this
return Q.ninvoke(this.messages(), 'byRootHash', rootHash)
.then(function (msgs) {
return Q.all(msgs.map(function (m) {
return self.lookupObject(m)
}))
})
}
Driver.prototype.lookupObjectByCurHash = function (curHash, all) {
var self = this
return Q.ninvoke(this.messages(), 'byCurHash', curHash, all)
.then(function (obj) {
return Array.isArray(obj)
? Q.all(obj.map(o => self.lookupObject(o))) :
self.lookupObject(obj)
})
}
Driver.prototype.lookupObject = function (info, verify) {
var self = this
if (this._destroyed) {
return Q.reject(new Error('already destroyed'))
}
// TODO: this unfortunately duplicates part of unchainer.js
var canLookup = info.txData || info.tx
if (!canLookup) {
if (info.txType === TxData.types.public && info[CUR_HASH]) {
info.txData = new Buffer(info[CUR_HASH], 'hex')
canLookup = true
}
if (!canLookup) {
throw new Error('missing required info to lookup chained obj')
}
}
if (!verify) {
var cached = this._getCachedObject(info)
if (cached) return Q(cached)
}
return this.unchainer.unchain(clone(info), {
verify: !!verify
})
.then(function (obj) {
self._cacheObject(obj)
return obj
})
}
Driver.prototype.byType = function (type) {
var self = this
return this.typeDB.createReadStream(type).pipe(map(function (meta, cb) {
self.lookupObject(meta)
.then(function (obj) {
cb(null, obj)
}, function (err) {
cb(err)
})
}))
}
Driver.prototype._enableObjectCaching = function () {
var self = this
if (this._objectCache) return
this._objectCache = new Cache({
max: 100
})
;['message', 'sent', 'chained', 'unchained'].forEach(function (event) {
self.msgDB.on(event, function (entry) {
self._debug('event', event, entry.uid)
var cached = self._getCachedObject(entry)
if (!cached) return
// cached object is frozen
cached = clone(cached)
for (var p in entry) {
if (!(p in cached)) {
cached[p] = entry[p]
}
}
self._cacheObject(cached)
})
})
}
Driver.prototype._getCachedObject = function (info) {
if (!this._objectCache) return
var keys = this._getCachedObjectKeys(info)
for (var i = 0; i < keys.length; i++) {
var cached = this._objectCache.get(keys[i])
if (cached) return cached
}
}
Driver.prototype._cacheObject = function (obj) {
if (!this._objectCache) return
obj = Object.freeze(obj)
this._getCachedObjectKeys(obj).forEach(function (key) {
this._objectCache.set(key, obj)
}, this)
}
Driver.prototype._uncacheObject = function (obj) {
if (!this._objectCache) return
this._getCachedObjectKeys(obj).forEach(function (key) {
this._objectCache.del(key)
}, this)
}
Driver.prototype._getCachedObjectKeys = function (obj) {
var keys = []
// if (obj[CUR_HASH]) keys.push(obj[CUR_HASH])
if (obj.txData) keys.push(obj.txData.toString('hex'))
return keys
}
Driver.prototype.lookupRootHash = function (fingerprint) {
var pub = this.getPublicKey(fingerprint)
if (pub) return Q.resolve(this.myRootHash())
return Q.ninvoke(this.addressBook, 'rootHashByFingerprint', fingerprint)
}
Driver.prototype.lookupByFingerprint = function (fingerprint) {
return this.lookupIdentity({
fingerprint: fingerprint
})
}
Driver.prototype.getKeyAndIdentity = function (fingerprint, returnPrivate) {
var self = this
return this.lookupByFingerprint(fingerprint)
.then(function (result) {
var identity = result.identity
var key = returnPrivate && self.getPrivateKey(fingerprint)
key = key || utils.keyForFingerprint(identity, fingerprint)
var ret = {
key: key,
identity: identity
}
ret[ROOT_HASH] = result[ROOT_HASH]
return ret
})
}
Driver.prototype.getKeyAndIdentity2 = function (fingerprint, returnPrivate) {
return this.getKeyAndIdentity.apply(this, arguments)
.then(function (result) {
result.identity = Identity.fromJSON(result.identity)
return result
})
}
/**
* Will look up latest version of an object
*/
Driver.prototype.lookupByDHTKey = function (key, cb) {
var self = this
cb = cb || noop
return Q.ninvoke(self.msgDB, 'byCurHash', key)
.then(this.lookupObject)
.nodeify(cb)
}
Driver.prototype.getPublicKey = function (fingerprint, identity) {
identity = identity || this.identityJSON
return find(identity.pubkeys, function (k) {
return k.fingerprint === fingerprint
})
}
Driver.prototype.getPrivateKey = function (where) {
return utils.firstKey(this.keys, where)
}
Driver.prototype.getBlockchainKey = function () {
return toKey(this.getPrivateKey({
networkName: this.networkName,
type: 'bitcoin',
purpose: Driver.BLOCKCHAIN_KEY_PURPOSE
}))
}
Driver.prototype.getCachedIdentity = function (query) {
if (this._destroyed) throw new Error('already destroyed')
for (var p in query) {
var cached = this._identityCache[query[p]]
if (cached) return cached
}
}
Driver.prototype._cacheIdentity = function (identityInfo, query) {
var queries = []
var identity = identityInfo.identity
identity.pubkeys.forEach(function (k) {
queries.push(k.fingerprint, k.value)
})
// each fingerprint/pubkey/hash is enough
// to uniquely identify an identity
var rh = identityInfo[ROOT_HASH]
var ch = identityInfo[CUR_HASH]
if (rh) queries.push(rh)
if (ch) queries.push(ch)
if (query) {
for (p in query) {
queries.push(p)
}
}
queries.forEach(function (q) {
this._identityCache[q] = identityInfo
}, this)
var size = Object.keys(this._identityCache)
if (size > 1000) {
for (var p in this._identityCache) {
delete this._identityCache[p]
if (--size < 1000) break
}
}
}
Driver.prototype.lookupIdentity = function (query) {
var self = this
var me = this.identityJSON
var isMe = query[ROOT_HASH] === this.myRootHash() ||
query[CUR_HASH] === this.myCurrentHash() ||
(query.fingerprint && this.getPublicKey(query.fingerprint))
if (isMe) {
var ret = {
identity: me
}
ret[ROOT_HASH] = this.myRootHash()
ret[CUR_HASH] = this.myCurrentHash()
return Q.resolve(ret)
}
var cached = this.getCachedIdentity(query)
if (cached) return Q(cached)
return Q.ninvoke(this.addressBook, 'query', query)
.then(function (result) {
self._cacheIdentity(result, query)
return result
})
}
Driver.prototype.log = function (entry) {
utils.jsonifyErrors(entry)
var defer = Q.defer()
// faster than ninvoke
this._log.append(entry, function (err) {
if (err) defer.reject(err)
// pass through for convenience
else defer.resolve(entry)
})
return defer.promise
}
Driver.prototype.createReadStream = function (options) {
return this._log.createReadStream(options)
}
Driver.prototype._prefix = function (path) {
return this.pathPrefix + '-' + path
}
/**
* Manually add a known identity
* @param {Object|Buffer} identity
*/
Driver.prototype.addContactIdentity = function (identity) {
var self = this
var identityJSON = typeof identity === 'string' || Buffer.isBuffer(identity)
? JSON.parse(identity)
: identity
var curHash
var buf = utils.toBuffer(identity)
return Q.ninvoke(tradleUtils, 'getStorageKeyFor', buf)
.then(function (_curHash) {
curHash = _curHash.toString('hex')
return Q.ninvoke(self.addressBook, 'byCurHash', curHash)
})
.catch(add)
function add () {
return self.keeper.put(curHash, buf)
.then(function () {
rootHash = identityJSON[ROOT_HASH] || curHash
var fromAddress = identityJSON.pubkeys.filter(function (key) {
return key.purpose === Driver.BLOCKCHAIN_KEY_PURPOSE && key.networkName === self.networkName
})[0].fingerprint
var entry = new Entry({
type: EventType.misc.addIdentity,
txType: TxData.types.public,
parsed: identityJSON,
from: utils.toObj(ROOT_HASH, rootHash),
to: utils.toObj(ROOT_HASH, self.myRootHash()),
addressesFrom: [fromAddress],
addressesTo: [self.wallet.addressString]
})
.set(TYPE, TYPES.IDENTITY)
.set(ROOT_HASH, rootHash)
.set(CUR_HASH, curHash)
return self.log(entry)
})
}
}
// Driver.prototype._receivePublicMsg = function (msg, senderInfo) {
// var self = this
// try {
// var parsed = JSON.parse(msg.data)
// } catch (err) {
// valid = false
// }
// var putPromise
// if (parsed[TYPE] === 'tradle.IdentityPublishRequest') {
// putPromise = this.keeper.put(parsed)
// }
// return Q(putPromise)
// .then(function () {
// var txInfo = {
// addressesFrom: [senderInfo.fingerprint],
// addressesTo: [self.wallet.addressString],
// txData: msg.txData,
// txType: msg.txType
// }
// return self.chainloader._processTxInfo(txInfo)
// })
// .then(this.lookupObject)
// .then(function (obj) {
// debugger
// })
// .catch(function (error) {
// debugger
// })
// }
Driver.prototype.receiveMsg = function (buf, senderInfo) {
var self = this
if (this._destroyed) {
return Q.reject(new Error('already destroyed'))
}
var msg
validateRecipients(senderInfo)
try {
msg = utils.bufferToMsg(buf)
} catch (err) {
return Q.reject(new Error('expected JSON'))
}
this._debug('received msg')
var timestamp = hrtime()
// this thing repeats work all over the place
var chainedObj
var promiseValid
var valid = utils.validateMsg(msg)
if (valid) {
// if (msg.txType === TxData.types.public) {
// return this._receivePublicMsg(msg, senderInfo)
// }
promiseValid = Q.resolve()
} else {
promiseValid = Q.reject(new Error('received invalid msg'))
}
var from
return this._receivingPromise = promiseValid
.then(function () {
return self.lookupIdentity(senderInfo)
})
.then(function (result) {
from = result
var fromKey = utils.firstKey(result.identity.pubkeys, {
type: 'bitcoin',
networkName: self.networkName,
purpose: 'messaging'
})
chainedObj = {
timestamp: timestamp,
addressesFrom: [fromKey.fingerprint],
addressesTo: [self.wallet.addressString],
// txData: msg.txData,
txType: msg.txType,
data: msg.data,
encryptedData: msg.encryptedData,
encryptedPermission: msg.encryptedPermission
}
// TODO: rethink how chainloader should work
// this doesn't look right
// this will verify that the message
// comes from the sender in senderInfo
return self.chainloader._processTxInfo(chainedObj)
})
.then(function (info) {
extend(chainedObj, info)
if (info.txType === TxData.types.public) {
return loadPublicMessage()
} else {
return loadPrivateMessage()
}
})
.then(function () {
return self.lookupObject(chainedObj, true)
})
.then(function (obj) {
return self.unchainResultToEntry(obj)
})
.then(function (entry) {
return entry.set({
type: EventType.msg.receivedValid,
dir: Dir.inbound
})
})
.catch(function (err) {
// TODO: retry
self._debug('failed to process inbound msg', err.message, err.stack)
return new Entry({
type: EventType.msg.receivedInvalid,
msg: msg,
from: utils.toObj(ROOT_HASH, from && from[ROOT_HASH]),
to: utils.toObj(ROOT_HASH, self.myRootHash()),
dir: Dir.inbound,
errors: {
receive: [err]
}
})
})
.then(function (entry) {
self._debug('processed received msg')
return self.log(entry)
})
function loadPublicMessage () {
// nothing to do here
return self.keeper.putOne({
key: chainedObj.key,
value: chainedObj.data
})
}
function loadPrivateMessage () {
return Q.ninvoke(Permission, 'recov