diff --git a/bin/ssu b/bin/ssu new file mode 100755 index 00000000..81a3d430 --- /dev/null +++ b/bin/ssu @@ -0,0 +1,209 @@ +#!/usr/bin/env node + +'use strict' + +var wreck = require('wreck') +var argv = process.argv.slice(2) +var Promise = require('es6-promise') +var pgp = require('pg-promise')({ + promiseLib: Promise +}) +var inquirer = require('inquirer') + +var fs = require('fs') + +var cmd = argv[0] + +if (!cmd) bail() + +function bail () { + console.log('Command line utility for lamassu-server') + console.log('\nssu reboot ') + console.log('This will remotely reboot your lamassu-machine.') + console.log('\nssu crypto []') + console.log('This will configure a new cryptocurrency.') + console.log('\nssu config ') + console.log('Configure a plugin setting.') + process.exit(1) +} + +switch (cmd) { + case 'reboot': + reboot() + break + case 'crypto': + crypto() + break + case 'config': + configure() + break + default: + +} + +function reboot () { + var fingerprint = argv[1] + + if (!fingerprint) { + console.log('Fingerprint required') + process.exit(1) + } + + var opts = {json: true} + wreck.get('http://localhost:7070/pid?fingerprint=' + fingerprint, opts, function (err, res, payload) { + if (err) { + console.log('Please make sure that lamassu-server is running on this box.') + process.exit(2) + } + + if (!payload || !payload.pid) { + console.log('The requested lamassu-machine appears to be running an old version.') + process.exit(3) + } + + var pid = payload.pid + + if (Date.now() - payload.ts > 10000) { + console.log('lamassu-machine is not connected to server.') + process.exit(6) + } + + var opts2 = { + headers: {'Content-Type': 'application/json'}, + payload: JSON.stringify({pid: pid, fingerprint: fingerprint}) + } + + wreck.post('http://localhost:7070/reboot', opts2, function (err2, res) { + if (err2) { + console.log('Please make sure that lamassu-server is running on this box.') + process.exit(2) + } + + if (res.statusCode !== 200) { + console.log('Communication error') + return + } + + console.log('Rebooting...') + + var ts = null + + setTimeout(function () { + if (Date.now() - ts < 10000) { + console.log('lamassu-machine did not reboot but is still contacting server.') + process.exit(4) + } + + console.log('lamassu-machine rebooted but is not coming back up.') + process.exit(5) + }, 30000) + + setInterval(function () { + wreck.get('http://localhost:7070/pid?fingerprint=' + fingerprint, opts, function (err3, res, payload) { + if (err3) { + console.log('lamassu-server appears to be down.') + process.exit(2) + } + + ts = payload.ts + + if (payload.pid !== pid) { + console.log('lamassu-machine is back up!') + process.exit(0) + } + }) + }, 5000) + }) + }) +} + +function crypto () { + var code = argv[1] + var tickerPlugin = argv[2] + var walletPlugin = argv[3] + var traderPlugin = argv[4] + + if (!code || !tickerPlugin || !walletPlugin) { + console.log('\nssu crypto []') + console.log('This will configure a new cryptocurrency.') + process.exit(1) + } + + code = code.toUpperCase() + + var psqlUrl + try { + psqlUrl = process.env.DATABASE_URL || JSON.parse(fs.readFileSync('/etc/lamassu.json')).postgresql + } catch (ex) { + psqlUrl = 'psql://lamassu:lamassu@localhost/lamassu' + } + var db = pgp(psqlUrl) + + return db.one('select data from user_config where type=$1', 'exchanges') + .then(function (data) { + var config = data.data + config.exchanges.plugins.current[code] = { + ticker: tickerPlugin, + transfer: walletPlugin, + trader: traderPlugin + } + config.exchanges.settings.coins = ['BTC', code] + return db.none('update user_config set data=$1 where type=$2', [config, 'exchanges']) + }) + .then(function () { + console.log('success') + pgp.end() + }) + .catch(function (err) { + console.log(err.stack) + pgp.end() + }) +} + +function configure () { + var plugin = argv[1] + var key = argv[2] + + if (!plugin || !key) { + console.log('\nssu config ') + console.log('Configure a plugin setting.') + process.exit(1) + } + + inquirer.prompt([{ + type: 'password', + name: 'value', + message: 'Enter value for ' + key + ': ', + validate: function (val) { + return !val || val.length === 0 + ? 'Please enter a value for ' + key + : true + } + }]).then(function (result) { + var value = result.value + + var psqlUrl + try { + psqlUrl = process.env.DATABASE_URL || JSON.parse(fs.readFileSync('/etc/lamassu.json')).postgresql + } catch (ex) { + psqlUrl = 'psql://lamassu:lamassu@localhost/lamassu' + } + var db = pgp(psqlUrl) + + return db.one('select data from user_config where type=$1', 'exchanges') + .then(function (data) { + var config = data.data + config.exchanges.plugins.settings[plugin] = config.exchanges.plugins.settings[plugin] || {} + config.exchanges.plugins.settings[plugin][key] = value + return db.none('update user_config set data=$1 where type=$2', [config, 'exchanges']) + }) + .then(function () { + console.log('success') + pgp.end() + }) + .catch(function (err) { + console.log(err.stack) + pgp.end() + }) + }) +} diff --git a/lib/app.js b/lib/app.js index d4d5f00c..6488da6c 100644 --- a/lib/app.js +++ b/lib/app.js @@ -97,8 +97,14 @@ module.exports = function(options) { if (options.mock) logger.info('In mock mode'); + var localApp = express() + localApp.use(express.bodyParser()) + var localServer = http.createServer(localApp) + var localPort = 7070 + routes.init({ app: app, + localApp: localApp, lamassuConfig: lamassuConfig, plugins: plugins, authMiddleware: authMiddleware, @@ -106,5 +112,9 @@ module.exports = function(options) { mock: options.mock }); + localServer.listen(7070, function () { + console.log('lamassu-server is listening on local port %d', localPort) + }) + return server; }; diff --git a/lib/plugins.js b/lib/plugins.js index dbbdbb85..6173528c 100644 --- a/lib/plugins.js +++ b/lib/plugins.js @@ -1,198 +1,223 @@ -'use strict'; +'use strict' + +var _ = require('lodash') +var async = require('async') + +var BigNumber = require('bignumber.js') + +var logger = require('./logger') +var argv = require('minimist')(process.argv.slice(2)) var uuid = require('node-uuid') -var _ = require('lodash'); -var async = require('async'); -var logger = require('./logger'); -var argv = require('minimist')(process.argv.slice(2)); +var tradeIntervals = {} -var SATOSHI_FACTOR = 1e8; -var POLLING_RATE = 60 * 1000; // poll each minute -var REAP_RATE = 2 * 1000; -var PENDING_TIMEOUT = 70 * 1000; +var POLLING_RATE = 60 * 1000 // poll each minute +var REAP_RATE = 2 * 1000 +var PENDING_TIMEOUT = 70 * 1000 -if (argv.timeout) PENDING_TIMEOUT = argv.timeout / 1000; +if (argv.timeout) PENDING_TIMEOUT = argv.timeout / 1000 // TODO: might have to update this if user is allowed to extend monitoring time -var DEPOSIT_TIMEOUT = 130 * 1000; +var DEPOSIT_TIMEOUT = 130 * 1000 -var db = null; +var db = null -var tickerPlugin = null; -var traderPlugin = null; -var walletPlugin = null; -var idVerifierPlugin = null; -var infoPlugin = null; +var cryptoCodes = null -var currentlyUsedPlugins = {}; +var tickerPlugins = {} +var traderPlugins = {} +var walletPlugins = {} +var idVerifierPlugin = null +var infoPlugin = null -var cachedConfig = null; -var deviceCurrency = 'USD'; +var currentlyUsedPlugins = {} -var lastBalances = null; -var lastRates = {}; +var cachedConfig = null +var deviceCurrency = 'USD' -var balanceInterval = null; -var rateInterval = null; -var tradeInterval = null; -var reapTxInterval = null; +var lastBalances = {} +var lastRates = {} -var tradesQueue = []; +var tradesQueues = {} + +var coins = { + BTC: {unitScale: 8}, + ETH: {unitScale: 18} +} // that's basically a constructor -exports.init = function init(databaseHandle) { +exports.init = function init (databaseHandle) { if (!databaseHandle) { - throw new Error('\'db\' is required'); + throw new Error('\'db\' is required') } - db = databaseHandle; -}; - -function loadPlugin(name, config) { + db = databaseHandle +} +function loadPlugin (name, config) { // plugins definitions var moduleMethods = { ticker: ['ticker'], - trader: ['balance', 'purchase', 'sell'], + trader: ['purchase', 'sell'], wallet: ['balance', 'sendBitcoins', 'newAddress'], idVerifier: ['verifyUser', 'verifyTransaction'], info: ['checkAddress'] - }; + } - var plugin = null; + var plugin = null // each used plugin MUST be installed try { - plugin = require('lamassu-' + name); + plugin = require('lamassu-' + name) + } catch (_) { + try { + require('plugins/' + name) } catch (_) { throw new Error(name + ' module is not installed. ' + - 'Try running \'npm install --save lamassu-' + name + '\' first'); + 'Try running \'npm install --save lamassu-' + name + '\' first') + } } // each plugin MUST implement those if (typeof plugin.SUPPORTED_MODULES !== 'undefined') { - if (plugin.SUPPORTED_MODULES === 'string') - plugin.SUPPORTED_MODULES = [plugin.SUPPORTED_MODULES]; + if (plugin.SUPPORTED_MODULES === 'string') { + plugin.SUPPORTED_MODULES = [plugin.SUPPORTED_MODULES] + } } - if (!(plugin.SUPPORTED_MODULES instanceof Array)) + if (!(plugin.SUPPORTED_MODULES instanceof Array)) { throw new Error('\'' + name + '\' fails to implement *required* ' + - '\'SUPPORTED_MODULES\' constant'); + '\'SUPPORTED_MODULES\' constant') + } - plugin.SUPPORTED_MODULES.forEach(function(moduleName) { - moduleMethods[moduleName].forEach(function(methodName) { + plugin.SUPPORTED_MODULES.forEach(function (moduleName) { + moduleMethods[moduleName].forEach(function (methodName) { if (typeof plugin[methodName] !== 'function') { throw new Error('\'' + name + '\' declares \'' + moduleName + - '\', but fails to implement \'' + methodName + '\' method'); + '\', but fails to implement \'' + methodName + '\' method') } - }); - }); + }) + }) // each plugin SHOULD implement those - if (typeof plugin.NAME === 'undefined') + if (typeof plugin.NAME === 'undefined') { logger.warn(new Error('\'' + name + - '\' fails to implement *recommended* \'NAME\' field')); + '\' fails to implement *recommended* \'NAME\' field')) + } if (typeof plugin.config !== 'function') { logger.warn(new Error('\'' + name + - '\' fails to implement *recommended* \'config\' method')); - plugin.config = function() {}; + '\' fails to implement *recommended* \'config\' method')) + plugin.config = function () {} } else if (config !== null) { - plugin.config(config); // only when plugin supports it, and config is passed + plugin.config(config) // only when plugin supports it, and config is passed } - return plugin; + return plugin } -function loadOrConfigPlugin(pluginHandle, pluginType, currency, +function loadOrConfigPlugin (pluginHandle, pluginType, cryptoCode, currency, onChangeCallback) { - var currentName = cachedConfig.exchanges.plugins.current[pluginType]; - var pluginChanged = currentlyUsedPlugins[pluginType] !== currentName; + cryptoCode = cryptoCode || 'BTC' - if (!currentName) pluginHandle = null; + var currentName = cryptoCode === 'any' || cryptoCode === 'BTC' + ? cachedConfig.exchanges.plugins.current[pluginType] + : cachedConfig.exchanges.plugins.current[cryptoCode][pluginType] + + currentlyUsedPlugins[cryptoCode] = currentlyUsedPlugins[cryptoCode] || {} + + var pluginChanged = currentlyUsedPlugins[cryptoCode][pluginType] !== currentName + + if (!currentName) pluginHandle = null else { // some plugins may be disabled - var pluginConfig = cachedConfig.exchanges.plugins.settings[currentName] || - {}; + var pluginConfig = cachedConfig.exchanges.plugins.settings[currentName] || {} - if (currency) pluginConfig.currency = currency; + if (currency) pluginConfig.currency = currency - if (pluginHandle && !pluginChanged) pluginHandle.config(pluginConfig); + if (pluginHandle && !pluginChanged) pluginHandle.config(pluginConfig) else { - pluginHandle = loadPlugin(currentName, pluginConfig); - currentlyUsedPlugins[pluginType] = currentName - logger.debug('plugin(%s) loaded: %s', pluginType, pluginHandle.NAME || - currentName); + pluginHandle = loadPlugin(currentName, pluginConfig) + currentlyUsedPlugins[cryptoCode] = currentlyUsedPlugins[cryptoCode] || {} + currentlyUsedPlugins[cryptoCode][pluginType] = currentName + logger.debug('[%s] plugin(%s) loaded: %s', cryptoCode, pluginType, pluginHandle.NAME || + currentName) } } - if (typeof onChangeCallback === 'function') - onChangeCallback(pluginHandle, currency); + if (typeof onChangeCallback === 'function') onChangeCallback(pluginHandle, currency) - return pluginHandle; + return pluginHandle } -exports.configure = function configure(config) { +exports.configure = function configure (config) { if (config.exchanges.settings.lowBalanceMargin < 1) { - throw new Error('\'settings.lowBalanceMargin\' has to be >= 1'); + throw new Error('\'settings.lowBalanceMargin\' has to be >= 1') } - cachedConfig = config; - deviceCurrency = config.exchanges.settings.currency; + cachedConfig = config + deviceCurrency = config.exchanges.settings.currency + cryptoCodes = config.exchanges.settings.coins || ['BTC', 'ETH'] + cryptoCodes.forEach(function (cryptoCode) { // TICKER [required] configure (or load) loadOrConfigPlugin( - tickerPlugin, + tickerPlugins[cryptoCode], 'ticker', + cryptoCode, deviceCurrency, // device currency - function onTickerChange(newTicker) { - tickerPlugin = newTicker; - pollRate(); + function onTickerChange (newTicker) { + tickerPlugins[cryptoCode] = newTicker + pollRate(cryptoCode) } - ); + ) // WALLET [required] configure (or load) loadOrConfigPlugin( - walletPlugin, + walletPlugins[cryptoCode], 'transfer', + cryptoCode, null, - function onWalletChange(newWallet) { - walletPlugin = newWallet; - pollBalance(); + function onWalletChange (newWallet) { + walletPlugins[cryptoCode] = newWallet + pollBalance(cryptoCode) } - ); + ) - // TRADER [optional] configure (or load) - traderPlugin = loadOrConfigPlugin( - traderPlugin, - 'trade', + tradesQueues[cryptoCode] = tradesQueues[cryptoCode] || [] + + loadOrConfigPlugin( + traderPlugins[cryptoCode], + 'trader', + cryptoCode, null, - function onTraderChange(newTrader) { - traderPlugin = newTrader; - if (newTrader === null) stopTrader(); - else startTrader(); + function onTraderChange (newTrader) { + traderPlugins[cryptoCode] = newTrader + if (newTrader === null) stopTrader(cryptoCode) + else startTrader(cryptoCode) } - ); + ) + }) // ID VERIFIER [optional] configure (or load) idVerifierPlugin = loadOrConfigPlugin( idVerifierPlugin, 'idVerifier' - ); + ) infoPlugin = loadOrConfigPlugin( infoPlugin, 'info' - ); -}; -exports.getConfig = function getConfig() { - return cachedConfig; -}; + ) +} +exports.getConfig = function getConfig () { + return cachedConfig +} -exports.logEvent = function event(session, rawEvent) { - db.recordDeviceEvent(session, rawEvent); -}; +exports.logEvent = function event (session, rawEvent) { + db.recordDeviceEvent(session, rawEvent) +} -function buildCartridges(cartridges, virtualCartridges, rec) { +function buildCartridges (cartridges, virtualCartridges, rec) { return { cartridges: [ { @@ -206,115 +231,133 @@ function buildCartridges(cartridges, virtualCartridges, rec) { ], virtualCartridges: virtualCartridges, id: rec.id - }; + } } -exports.pollQueries = function pollQueries(session, cb) { - var cartridges = cachedConfig.exchanges.settings.cartridges; - if (!cartridges) return cb(null, {}); - var virtualCartridges = cachedConfig.exchanges.settings.virtualCartridges; +exports.pollQueries = function pollQueries (session, cb) { + var cartridges = cachedConfig.exchanges.settings.cartridges + if (!cartridges) return cb(null, {}) + var virtualCartridges = cachedConfig.exchanges.settings.virtualCartridges - db.cartridgeCounts(session, function(err, result) { - if (err) return cb(err); + db.cartridgeCounts(session, function (err, result) { + if (err) return cb(err) return cb(null, { cartridges: buildCartridges(cartridges, virtualCartridges, result) - }); - }); -}; - -function _sendBitcoins(toAddress, satoshis, cb) { - var transactionFee = cachedConfig.exchanges.settings.transactionFee; - walletPlugin.sendBitcoins(toAddress, satoshis, transactionFee, cb); + }) + }) } -function executeTx(session, tx, authority, cb) { - db.addOutgoingTx(session, tx, function(err, toSend) { - if (err) return cb(err); - var satoshisToSend = toSend.satoshis; - if (satoshisToSend === 0) - return cb(null, {statusCode: 204, txId: tx.txId, txHash: null}); +function _sendCoins (toAddress, cryptoAtoms, cryptoCode, cb) { + var walletPlugin = walletPlugins[cryptoCode] + var transactionFee = cachedConfig.exchanges.settings.transactionFee + if (cryptoCode === 'BTC') { + walletPlugin.sendBitcoins(toAddress, cryptoAtoms.truncated().toNumber(), transactionFee, cb) + } else { + walletPlugin.sendBitcoins(toAddress, cryptoAtoms, cryptoCode, transactionFee, cb) + } +} - _sendBitcoins(tx.toAddress, satoshisToSend, function(_err, txHash) { - var fee = null; // Need to fill this out in plugins - if (_err) toSend = {satoshis: 0, fiat: 0}; - db.sentCoins(session, tx, authority, toSend, fee, _err, txHash); +function executeTx (session, tx, authority, cb) { + db.addOutgoingTx(session, tx, function (err, toSend) { + if (err) { + logger.error(err) + return cb(err) + } + var cryptoAtomsToSend = toSend.satoshis + if (cryptoAtomsToSend === 0) { + logger.debug('No cryptoAtoms to send') + return cb(null, {statusCode: 204, txId: tx.txId, txHash: null}) +} - if (_err) return cb(_err); + var cryptoCode = tx.cryptoCode + _sendCoins(tx.toAddress, cryptoAtomsToSend, cryptoCode, function (_err, txHash) { + var fee = null // Need to fill this out in plugins + if (_err) { + logger.error(_err) + toSend = {cryptoAtoms: new BigNumber(0), fiat: 0} + } + db.sentCoins(session, tx, authority, toSend, fee, _err, txHash) + + if (_err) return cb(_err) + + pollBalance('BTC') - pollBalance(); cb(null, { statusCode: 201, // Created txHash: txHash, txId: tx.txId - }); - }); - }); + }) + }) + }) } -function reapOutgoingTx(session, tx) { - executeTx(session, tx, 'timeout', function(err) { - if (err) logger.error(err); - }); +function reapOutgoingTx (session, tx) { + executeTx(session, tx, 'timeout', function (err) { + if (err) logger.error(err) + }) } -function reapTx(row) { - var session = {fingerprint: row.device_fingerprint, id: row.session_id}; +function reapTx (row) { + var session = {fingerprint: row.device_fingerprint, id: row.session_id} var tx = { fiat: 0, - satoshis: row.satoshis, + satoshis: new BigNumber(row.satoshis), toAddress: row.to_address, currencyCode: row.currency_code, + cryptoCode: row.crypto_code, incoming: row.incoming - }; - if (!row.incoming) reapOutgoingTx(session, tx); + } + if (!row.incoming) reapOutgoingTx(session, tx) } -function reapTxs() { - db.removeOldPending(DEPOSIT_TIMEOUT); +function reapTxs () { + db.removeOldPending(DEPOSIT_TIMEOUT) // NOTE: No harm in processing old pending tx, we don't need to wait for // removeOldPending to complete. - db.pendingTxs(PENDING_TIMEOUT, function(err, results) { - if (err) return logger.warn(err); - var rows = results.rows; - var rowCount = rows.length; + db.pendingTxs(PENDING_TIMEOUT, function (err, results) { + if (err) return logger.warn(err) + var rows = results.rows + var rowCount = rows.length for (var i = 0; i < rowCount; i++) { - var row = rows[i]; - reapTx(row); + var row = rows[i] + reapTx(row) } - }); + }) } // TODO: Run these in parallel and return success -exports.trade = function trade(session, rawTrade, cb) { +exports.trade = function trade (session, rawTrade, cb) { + logger.debug('DEBUG2') // TODO: move this to DB, too // add bill to trader queue (if trader is enabled) + var cryptoCode = rawTrade.cryptoCode || 'BTC' + var traderPlugin = traderPlugins[cryptoCode] + if (traderPlugin) { - tradesQueue.push({ + logger.debug('[%s] Pushing trade: %d', cryptoCode, rawTrade.cryptoAtoms) + tradesQueues[cryptoCode].push({ currency: rawTrade.currency, - satoshis: rawTrade.satoshis - }); + cryptoAtoms: rawTrade.cryptoAtoms, + cryptoCode: cryptoCode + }) } + logger.debug('DEBUG3') + if (!rawTrade.toAddress) { - var newRawTrade = _.cloneDeep(rawTrade); - newRawTrade.toAddress = 'remit'; - return db.recordBill(session, newRawTrade, cb); + var newRawTrade = _.cloneDeep(rawTrade) + newRawTrade.toAddress = 'remit' + return db.recordBill(session, newRawTrade, cb) } - var tx = { - txId: rawTrade.txId, - fiat: 0, - satoshis: 0, - toAddress: rawTrade.toAddress, - currencyCode: rawTrade.currency - }; + logger.debug('DEBUG1') async.parallel([ - async.apply(db.addOutgoingPending, session, tx.currencyCode, tx.toAddress), + async.apply(db.addOutgoingPending, session, rawTrade.currency, rawTrade.cryptoCode, rawTrade.toAddress), async.apply(db.recordBill, session, rawTrade) - ], cb); + ], cb) }; exports.stateChange = function stateChange(session, rec, cb) { @@ -339,211 +382,237 @@ exports.recordPing = function recordPing(session, rec, cb) { db.machineEvent(rec, cb) }; -exports.sendBitcoins = function sendBitcoins(session, rawTx, cb) { +exports.sendCoins = function sendCoins(session, rawTx, cb) { executeTx(session, rawTx, 'machine', cb); -}; +} -exports.cashOut = function cashOut(session, tx, cb) { +exports.cashOut = function cashOut (session, tx, cb) { var tmpInfo = { label: 'TX ' + Date.now(), account: 'deposit' - }; - walletPlugin.newAddress(tmpInfo, function(err, address) { - if (err) return cb(err); + } - var newTx = _.clone(tx); - newTx.toAddress = address; - db.addInitialIncoming(session, newTx, function(_err) { - cb(_err, address); - }); - }); -}; + var cryptoCode = tx.cryptoCode || 'BTC' + var walletPlugin = walletPlugins[cryptoCode] -exports.dispenseAck = function dispenseAck(session, rec) { - db.addDispense(session, rec.tx, rec.cartridges); -}; + walletPlugin.newAddress(tmpInfo, function (err, address) { + if (err) return cb(err) -exports.fiatBalance = function fiatBalance() { - var rawRate = exports.getDeviceRate().rates.ask; - var commission = cachedConfig.exchanges.settings.commission; + var newTx = _.clone(tx) + newTx.toAddress = address + db.addInitialIncoming(session, newTx, function (_err) { + cb(_err, address) + }) + }) +} - if (!rawRate || !lastBalances) return null; +exports.dispenseAck = function dispenseAck (session, rec) { + db.addDispense(session, rec.tx, rec.cartridges) +} + +exports.fiatBalance = function fiatBalance (cryptoCode) { + var rawRate = exports.getDeviceRate(cryptoCode).rates.ask + var commission = cachedConfig.exchanges.settings.commission + var lastBalance = lastBalances[cryptoCode] + + if (!rawRate || !lastBalance) return null // The rate is actually our commission times real rate. - var rate = commission * rawRate; + var rate = rawRate.times(commission) // `lowBalanceMargin` is our safety net. It's a number > 1, and we divide // all our balances by it to provide a safety margin. - var lowBalanceMargin = cachedConfig.exchanges.settings.lowBalanceMargin; + var lowBalanceMargin = cachedConfig.exchanges.settings.lowBalanceMargin - // `balance.transferBalance` is the balance of our transfer account (the one - // we use to send Bitcoins to clients) in satoshis. - var transferBalance = lastBalances.transferBalance.BTC; + var unitScale = new BigNumber(10).pow(coins[cryptoCode].unitScale) + var fiatTransferBalance = lastBalance.div(unitScale).times(rate).div(lowBalanceMargin) - // Since `transferBalance` is in satoshis, we need to turn it into - // bitcoins and then fiat to learn how much fiat currency we can exchange. - // - // Unit validity proof: [ $ ] = [ (B * 10^8) / 10^8 * $/B ] - // [ $ ] = [ B * $/B ] - // [ $ ] = [ $ ] - var fiatTransferBalance = ((transferBalance / SATOSHI_FACTOR) * rate) / - lowBalanceMargin; - - return fiatTransferBalance; -}; + return fiatTransferBalance +} /* * Polling livecycle */ -exports.startPolling = function startPolling() { - executeTrades(); +exports.startPolling = function startPolling () { + executeTrades() - if (!balanceInterval) - balanceInterval = setInterval(pollBalance, POLLING_RATE); + cryptoCodes.forEach(function (cryptoCode) { + setInterval(async.apply(pollBalance, cryptoCode), POLLING_RATE) + setInterval(async.apply(pollRate, cryptoCode), POLLING_RATE) + startTrader(cryptoCode) + }) - if (!rateInterval) - rateInterval = setInterval(pollRate, POLLING_RATE); + setInterval(reapTxs, REAP_RATE) +} - if (!reapTxInterval) - reapTxInterval = setInterval(reapTxs, REAP_RATE); - - startTrader(); -}; - -function startTrader() { +function startTrader (cryptoCode) { // Always start trading, even if we don't have a trade exchange configured, // since configuration can always change in `Trader#configure`. // `Trader#executeTrades` returns early if we don't have a trade exchange // configured at the moment. - if (traderPlugin && !tradeInterval) { - tradeInterval = setInterval( - executeTrades, + var traderPlugin = traderPlugins[cryptoCode] + if (!traderPlugin || tradeIntervals[cryptoCode]) return + + logger.debug('[%s] startTrader', cryptoCode) + + tradeIntervals[cryptoCode] = setInterval( + function () { executeTrades(cryptoCode) }, cachedConfig.exchanges.settings.tradeInterval - ); + ) } -} -function stopTrader() { - if (tradeInterval) { - clearInterval(tradeInterval); - tradeInterval = null; - tradesQueue = []; + +function stopTrader (cryptoCode) { + if (!tradeIntervals[cryptoCode]) return + + logger.debug('[%s] stopTrader', cryptoCode) + clearInterval(tradeIntervals[cryptoCode]) + tradeIntervals[cryptoCode] = null + tradesQueues[cryptoCode] = [] } -} -function pollBalance(cb) { +function pollBalance (cryptoCode, cb) { + logger.debug('[%s] collecting balance', cryptoCode) - var jobs = { - transferBalance: walletPlugin.balance - }; + var walletPlugin = walletPlugins[cryptoCode] - // only add if trader is enabled - // if (traderPlugin) { - // // NOTE: we would need to handle when traderCurrency!=deviceCurrency - // jobs.tradeBalance = traderPlugin.balance; - // } - - async.parallel(jobs, function(err, balance) { + walletPlugin.balance(function (err, balance) { if (err) { - logger.error(err); - return cb && cb(err); + logger.error(err) + return cb && cb(err) } - balance.timestamp = Date.now(); - lastBalances = balance; + logger.debug('[%s] Balance update: %j', cryptoCode, balance) + balance.timestamp = Date.now() + lastBalances[cryptoCode] = new BigNumber(balance[cryptoCode]) - return cb && cb(null, lastBalances); - }); + return cb && cb(null, lastBalances) + }) } -function pollRate(cb) { - tickerPlugin.ticker(deviceCurrency, function(err, resRates) { +function pollRate (cryptoCode, cb) { + var tickerPlugin = tickerPlugins[cryptoCode] + logger.debug('[%s] polling for rates (%s)', cryptoCode, tickerPlugin.NAME) + + var currencies = deviceCurrency + if (typeof currencies === 'string') currencies = [currencies] + + var tickerF = cryptoCode === 'BTC' + ? async.apply(tickerPlugin.ticker, currencies) + : async.apply(tickerPlugin.ticker, currencies, cryptoCode) + + tickerF(function (err, resRates) { if (err) { - logger.error(err); - return cb && cb(err); + logger.error(err) + return cb && cb(err) } - resRates.timestamp = new Date(); - lastRates = resRates; + resRates.timestamp = new Date() + var rates = resRates[deviceCurrency].rates + if (rates) { + rates.ask = rates.ask && new BigNumber(rates.ask) + rates.bid = rates.bid && new BigNumber(rates.bid) + } + logger.debug('[%s] got rates: %j', cryptoCode, resRates) - return cb && cb(null, lastRates); - }); + lastRates[cryptoCode] = resRates + + return cb && cb(null, lastRates) + }) } /* * Getters | Helpers */ -function getLastRate(currency) { - if (!lastRates) return null; - var tmpCurrency = currency || deviceCurrency; - if (!lastRates[tmpCurrency]) return null; +exports.getDeviceRate = function getDeviceRate (cryptoCode) { + if (!lastRates[cryptoCode]) return null - return lastRates[tmpCurrency]; + var lastRate = lastRates[cryptoCode] + if (!lastRate) return null + + return lastRate[deviceCurrency] } -exports.getDeviceRate = function getDeviceRate() { - return getLastRate(deviceCurrency); -}; -exports.getBalance = function getBalance() { - if (!lastBalances) return null; +exports.getBalance = function getBalance (cryptoCode) { + var lastBalance = lastBalances[cryptoCode] - return lastBalances.transferBalance; -}; + return lastBalance +} /* * Trader functions */ -function purchase(trade, cb) { - traderPlugin.purchase(trade.satoshis, null, function(err) { - if (err) return cb(err); - pollBalance(); - if (typeof cb === 'function') cb(); - }); -} - -function consolidateTrades() { - // NOTE: value in satoshis stays the same no matter the currency - var consolidatedTrade = { - currency: deviceCurrency, - satoshis: tradesQueue.reduce(function(prev, current) { - return prev + current.satoshis; - }, 0) - }; - - tradesQueue = []; - - logger.debug('consolidated: ', JSON.stringify(consolidatedTrade)); - return consolidatedTrade; -} - -function executeTrades() { - if (!traderPlugin) return; - - logger.debug('checking for trades'); - - var trade = consolidateTrades(); - - if (trade.satoshis === 0) { - logger.debug('rejecting 0 trade'); - return; +function purchase (trade, cb) { + var cryptoCode = trade.cryptoCode + var traderPlugin = traderPlugins[cryptoCode] + var opts = { + cryptoCode: cryptoCode, + fiat: deviceCurrency } - logger.debug('making a trade: %d', trade.satoshis / SATOSHI_FACTOR); - purchase(trade, function(err) { + traderPlugin.purchase(trade.cryptoAtoms, opts, function (err) { + if (err) return cb(err) + pollBalance(cryptoCode) + if (typeof cb === 'function') cb() + }) +} + +function consolidateTrades (cryptoCode) { + // NOTE: value in cryptoAtoms stays the same no matter the currency + + logger.debug('tradesQueues size: %d', tradesQueues[cryptoCode].length) + logger.debug('tradesQueues head: %j', tradesQueues[cryptoCode][0]) + var cryptoAtoms = tradesQueues[cryptoCode].reduce(function (prev, current) { + return prev.plus(current.cryptoAtoms) + }, new BigNumber(0)) + + var consolidatedTrade = { + currency: deviceCurrency, + cryptoAtoms: cryptoAtoms, + cryptoCode: cryptoCode + } + + tradesQueues[cryptoCode] = [] + + logger.debug('[%s] consolidated: %j', cryptoCode, consolidatedTrade) + return consolidatedTrade +} + +function executeTrades (cryptoCode) { + var traderPlugin = traderPlugins[cryptoCode] + if (!traderPlugin) return + + logger.debug('[%s] checking for trades', cryptoCode) + + var trade = consolidateTrades(cryptoCode) + + if (trade.cryptoAtoms.eq(0)) { + logger.debug('[%s] rejecting 0 trade', cryptoCode) + return + } + + logger.debug('making a trade: %d', trade.cryptoAtoms.toString()) + purchase(trade, function (err) { if (err) { - tradesQueue.push(trade); - if (err.name !== 'orderTooSmall') logger.error(err); + logger.debug(err) + tradesQueues[cryptoCode].push(trade) + if (err.name !== 'orderTooSmall') logger.error(err) } - }); + logger.debug('Successful trade.') + }) } /* * ID Verifier functions */ -exports.verifyUser = function verifyUser(data, cb) { - idVerifierPlugin.verifyUser(data, cb); -}; +exports.verifyUser = function verifyUser (data, cb) { + idVerifierPlugin.verifyUser(data, cb) +} -exports.verifyTx = function verifyTx(data, cb) { - idVerifierPlugin.verifyTransaction(data, cb); -}; +exports.verifyTx = function verifyTx (data, cb) { + idVerifierPlugin.verifyTransaction(data, cb) +} + +exports.getcryptoCodes = function getcryptoCodes () { + return cryptoCodes +} diff --git a/lib/postgresql_interface.js b/lib/postgresql_interface.js index 189fa621..c30ea9a8 100644 --- a/lib/postgresql_interface.js +++ b/lib/postgresql_interface.js @@ -1,269 +1,281 @@ -'use strict'; +'use strict' // TODO: Consider using serializable transactions for true ACID -var pg = require('pg'); -var async = require('async'); -var _ = require('lodash'); +var BigNumber = require('bignumber.js') +var pg = require('pg') +var async = require('async') +var _ = require('lodash') -var logger = require('./logger'); +var logger = require('./logger') /* function inspect(rec) { - console.log(require('util').inspect(rec, {depth: null, colors: true})); + console.log(require('util').inspect(rec, {depth: null, colors: true})) } */ -var SATOSHI_FACTOR = 1e8; - -function isUniqueViolation(err) { - return err.code === '23505'; +function isUniqueViolation (err) { + return err.code === '23505' } -function isLowSeverity(err) { - return isUniqueViolation(err) || err.severity === 'low'; +function isLowSeverity (err) { + return isUniqueViolation(err) || err.severity === 'low' } -var conString = null; +var conString = null -function rollback(client, done) { - client.query('ROLLBACK', function(err) { - return done(err); - }); +function rollback (client, done) { + client.query('ROLLBACK', function (err) { + return done(err) + }) } -function getInsertQuery(tableName, fields, hasId) { - +function getInsertQuery (tableName, fields, hasId) { // outputs string like: '$1, $2, $3...' with proper No of items - var placeholders = fields.map(function(_, i) { - return '$' + (i + 1); - }).join(', '); + var placeholders = fields.map(function (_, i) { + return '$' + (i + 1) + }).join(', ') var query = 'INSERT INTO ' + tableName + ' (' + fields.join(', ') + ')' + ' VALUES' + - ' (' + placeholders + ')'; + ' (' + placeholders + ')' - if (hasId) query += ' RETURNING id'; + if (hasId) query += ' RETURNING id' - return query; + return query } -exports.init = function init(_conString) { - conString = _conString; +exports.init = function init (_conString) { + conString = _conString if (!conString) { - throw new Error('Postgres connection string is required'); + throw new Error('Postgres connection string is required') + } } -}; -function connect(cb) { - pg.connect(conString, function(err, client, done) { - if (err) logger.error(err); - cb(err, client, done); - }); +function connect (cb) { + pg.connect(conString, function (err, client, done) { + if (err) logger.error(err) + cb(err, client, done) + }) } // logs inputted bill and overall tx status (if available) -exports.recordBill = function recordBill(session, rec, cb) { +exports.recordBill = function recordBill (session, rec, cb) { var fields = [ 'id', 'device_fingerprint', 'currency_code', + 'crypto_code', 'to_address', 'session_id', 'device_time', 'satoshis', 'denomination' - ]; + ] var values = [ rec.uuid, session.fingerprint, rec.currency, + rec.cryptoCode, rec.toAddress, session.id, rec.deviceTime, rec.satoshis, rec.fiat - ]; + ] - connect(function(cerr, client, done) { - if (cerr) return cb(cerr); - query(client, getInsertQuery('bills', fields, false), values, function(err) { - done(); + connect(function (cerr, client, done) { + if (cerr) return cb(cerr) + query(client, getInsertQuery('bills', fields, false), values, function (err) { + done() if (err && isUniqueViolation(err)) { - logger.warn('Attempt to report bill twice'); - return cb(); + logger.warn('Attempt to report bill twice') + return cb() } - cb(err); - }); - }); -}; - -function query(client, queryStr, values, cb) { - if (!cb) { - cb = values; - values = []; - } - - client.query(queryStr, values, function(err, results) { - if (err) { - if (!isLowSeverity(err)) { - console.error(err) - console.log(queryStr); - console.log(values); - } - return cb(err); - } - cb(null, results); - }); + cb(err) + }) + }) } -function silentQuery(client, queryStr, values, cb) { +exports.recordDeviceEvent = function recordDeviceEvent (session, event) { + connect(function (cerr, client, done) { + if (cerr) return + var sql = 'INSERT INTO device_events (device_fingerprint, event_type, ' + + 'note, device_time) VALUES ($1, $2, $3, $4)' + var values = [session.fingerprint, event.eventType, event.note, + event.deviceTime] + client.query(sql, values, done) + }) + } + +function query (client, queryStr, values, cb) { if (!cb) { - cb = values; - values = []; + cb = values + values = [] } - client.query(queryStr, values, function(err) { + client.query(queryStr, values, function (err, results) { if (err) { if (!isLowSeverity(err)) { console.error(err) - console.log(queryStr); - console.log(values); + console.log(queryStr) + console.log(values) } - cb(err); + return cb(err) } - cb(); - }); + cb(null, results) + }) +} + +function silentQuery (client, queryStr, values, cb) { + if (!cb) { + cb = values + values = [] + } + + client.query(queryStr, values, function (err) { + if (err) { + if (!isLowSeverity(err)) { + console.error(err) + console.log(queryStr) + console.log(values) + } + cb(err) + } + cb() + }) } // OPTIMIZE: No need to query bills if tx.fiat and tx.satoshis are set -function billsAndTxs(client, session, cb) { - var sessionId = session.id; - var fingerprint = session.fingerprint; +function billsAndTxs (client, session, cb) { + var sessionId = session.id + var fingerprint = session.fingerprint var billsQuery = 'SELECT COALESCE(SUM(denomination), 0) as fiat, ' + 'COALESCE(SUM(satoshis), 0) AS satoshis ' + 'FROM bills ' + - 'WHERE device_fingerprint=$1 AND session_id=$2'; - var billsValues = [fingerprint, sessionId]; + 'WHERE device_fingerprint=$1 AND session_id=$2' + var billsValues = [fingerprint, sessionId] var txQuery = 'SELECT COALESCE(SUM(fiat), 0) AS fiat, ' + 'COALESCE(SUM(satoshis), 0) AS satoshis ' + 'FROM transactions ' + - 'WHERE device_fingerprint=$1 AND session_id=$2 AND stage=$3'; - var txValues = [fingerprint, sessionId, 'partial_request']; + 'WHERE device_fingerprint=$1 AND session_id=$2 AND stage=$3' + var txValues = [fingerprint, sessionId, 'partial_request'] async.parallel([ async.apply(query, client, billsQuery, billsValues), async.apply(query, client, txQuery, txValues) - ], function(err, results) { - if (err) return cb(err); + ], function (err, results) { + if (err) return cb(err) // Note: PG SUM function returns int8, which is returned as a string, so // we need to parse, since we know these won't be huge numbers. cb(null, { - billsFiat: parseInt(results[0].rows[0].fiat), - billsSatoshis: parseInt(results[0].rows[0].satoshis), - txFiat: parseInt(results[1].rows[0].fiat), - txSatoshis: parseInt(results[1].rows[0].satoshis) - }); - }); + billsFiat: parseInt(results[0].rows[0].fiat, 10), + billsSatoshis: new BigNumber(results[0].rows[0].satoshis), + txFiat: parseInt(results[1].rows[0].fiat, 10), + txSatoshis: new BigNumber(results[1].rows[0].satoshis) + }) + }) } -function computeSendAmount(tx, totals) { - var fiatRemaining = (tx.fiat || totals.billsFiat) - totals.txFiat; - var satoshisRemaining = (tx.satoshis || totals.billsSatoshis) - - totals.txSatoshis; +function computeSendAmount (tx, totals) { + var fiatRemaining = (tx.fiat || totals.billsFiat) - totals.txFiat + + var satoshisRemaining = tx.satoshis.eq(0) + ? totals.billsSatoshis.minus(totals.txSatoshis) + : tx.satoshis.minus(totals.txSatoshis) + var result = { fiat: fiatRemaining, satoshis: satoshisRemaining - }; - if (result.fiat < 0 || result.satoshis < 0) { - logger.warn({tx: tx, totals: totals, result: result}, - 'computeSendAmount result < 0, this shouldn\'t happen. ' + - 'Maybe timeout arrived after machineSend.'); - result.fiat = 0; - result.satoshis = 0; } - return result; + if (result.fiat < 0 || result.satoshis.lt(0)) { + logger.warn({tx: tx, totals: totals, result: result}, + "computeSendAmount result < 0, this shouldn't happen. " + + 'Maybe timeout arrived after machineSend.') + result.fiat = 0 + result.satoshis = new BigNumber(0) + } + return result } -exports.removeOldPending = function removeOldPending(timeoutMS) { - connect(function(cerr, client, done) { - if (cerr) return; +exports.removeOldPending = function removeOldPending (timeoutMS) { + connect(function (cerr, client, done) { + if (cerr) return var sql = 'DELETE FROM pending_transactions ' + - 'WHERE incoming AND extract(EPOCH FROM now() - updated) > $1'; - var timeoutS = timeoutMS / 1000; - var values = [timeoutS]; - query(client, sql, values, function(err) { - done(); - if (err) logger.error(err); - }); - }); -}; + 'WHERE incoming AND extract(EPOCH FROM now() - updated) > $1' + var timeoutS = timeoutMS / 1000 + var values = [timeoutS] + query(client, sql, values, function (err) { + done() + if (err) logger.error(err) + }) + }) +} -exports.pendingTxs = function pendingTxs(timeoutMS, cb) { - connect(function(cerr, client, done) { - if (cerr) return cb(cerr); +exports.pendingTxs = function pendingTxs (timeoutMS, cb) { + connect(function (cerr, client, done) { + if (cerr) return cb(cerr) var sql = 'SELECT * ' + 'FROM pending_transactions ' + 'WHERE (incoming OR extract(EPOCH FROM now() - updated) > $1) ' + - 'ORDER BY updated ASC'; - var timeoutS = timeoutMS / 1000; - var values = [timeoutS]; - query(client, sql, values, function(err, results) { - done(); - cb(err, results); - }); - }); -}; - -function removePendingTx(client, session, cb) { - var sql = 'DELETE FROM pending_transactions ' + - 'WHERE device_fingerprint=$1 AND session_id=$2'; - silentQuery(client, sql, [session.fingerprint, session.id], cb); + 'ORDER BY updated ASC' + var timeoutS = timeoutMS / 1000 + var values = [timeoutS] + query(client, sql, values, function (err, results) { + done() + cb(err, results) + }) + }) } -function insertOutgoingTx(client, session, tx, totals, cb) { - var sendAmount = computeSendAmount(tx, totals); - var stage = 'partial_request'; - var authority = tx.fiat ? 'machine' : 'timeout'; - var satoshis = sendAmount.satoshis; - var fiat = sendAmount.fiat; - if (satoshis === 0) return cb(null, {satoshis: 0, fiat: 0}); +function removePendingTx (client, session, cb) { + var sql = 'DELETE FROM pending_transactions ' + + 'WHERE device_fingerprint=$1 AND session_id=$2' + silentQuery(client, sql, [session.fingerprint, session.id], cb) +} + +function insertOutgoingTx (client, session, tx, totals, cb) { + var sendAmount = computeSendAmount(tx, totals) + var stage = 'partial_request' + var authority = tx.fiat ? 'machine' : 'timeout' + var satoshis = sendAmount.satoshis + var fiat = sendAmount.fiat + if (satoshis.eq(0)) return cb(null, {satoshis: new BigNumber(0), fiat: 0}) insertOutgoing(client, session, tx, satoshis, fiat, stage, authority, - function(err) { - - if (err) return cb(err); - cb(null, {satoshis: satoshis, fiat: fiat}); - }); + function (err) { + if (err) return cb(err) + cb(null, {satoshis: satoshis, fiat: fiat}) + }) } -function insertOutgoingCompleteTx(client, session, tx, cb) { - +function insertOutgoingCompleteTx (client, session, tx, cb) { // Only relevant for machine source transactions, not timeouts - if (!tx.fiat) return cb(); + if (!tx.fiat) return cb() - var stage = 'final_request'; - var authority = 'machine'; - var satoshis = tx.satoshis; - var fiat = tx.fiat; - insertOutgoing(client, session, tx, satoshis, fiat, stage, authority, cb); + var stage = 'final_request' + var authority = 'machine' + var satoshis = tx.satoshis + var fiat = tx.fiat + insertOutgoing(client, session, tx, satoshis, fiat, stage, authority, cb) } -function insertIncoming(client, session, tx, satoshis, fiat, stage, authority, +function insertIncoming (client, session, tx, satoshis, fiat, stage, authority, cb) { - var realSatoshis = satoshis || 0; - insertTx(client, session, true, tx, realSatoshis, fiat, stage, authority, cb); + var realSatoshis = satoshis || new BigNumber(0) + insertTx(client, session, true, tx, realSatoshis, fiat, stage, authority, cb) } -function insertOutgoing(client, session, tx, satoshis, fiat, stage, authority, +function insertOutgoing (client, session, tx, satoshis, fiat, stage, authority, cb) { - insertTx(client, session, false, tx, satoshis, fiat, stage, authority, cb); + insertTx(client, session, false, tx, satoshis, fiat, stage, authority, cb) } -function insertTx(client, session, incoming, tx, satoshis, fiat, stage, +function insertTx (client, session, incoming, tx, satoshis, fiat, stage, authority, cb) { var fields = [ 'session_id', @@ -274,10 +286,11 @@ function insertTx(client, session, incoming, tx, satoshis, fiat, stage, 'to_address', 'satoshis', 'currency_code', + 'crypto_code', 'fiat', 'tx_hash', 'error' - ]; + ] var values = [ session.id, @@ -286,219 +299,220 @@ function insertTx(client, session, incoming, tx, satoshis, fiat, stage, incoming, session.fingerprint, tx.toAddress, - satoshis, + satoshis.toString(), tx.currencyCode, + tx.cryptoCode, fiat, tx.txHash, tx.error - ]; + ] query(client, getInsertQuery('transactions', fields, true), values, - function(err, results) { - if (err) return cb(err); - cb(null, results.rows[0].id); - }); + function (err, results) { + if (err) return cb(err) + cb(null, results.rows[0].id) + }) } -function refreshPendingTx(client, session, cb) { +function refreshPendingTx (client, session, cb) { var sql = 'UPDATE pending_transactions SET updated=now() ' + - 'WHERE device_fingerprint=$1 AND session_id=$2'; - connect(function(cerr, client, done) { - if (cerr) return cb(cerr); - query(client, sql, [session.fingerprint, session.id], function(err) { - done(err); - cb(err); - }); - }); + 'WHERE device_fingerprint=$1 AND session_id=$2' + connect(function (cerr, client, done) { + if (cerr) return cb(cerr) + query(client, sql, [session.fingerprint, session.id], function (err) { + done(err) + cb(err) + }) + }) } -function addPendingTx(client, session, incoming, currencyCode, toAddress, +function addPendingTx (client, session, incoming, currencyCode, cryptoCode, toAddress, satoshis, cb) { var fields = ['device_fingerprint', 'session_id', 'incoming', - 'currency_code', 'to_address', 'satoshis']; - var sql = getInsertQuery('pending_transactions', fields, false); + 'currency_code', 'crypto_code', 'to_address', 'satoshis'] + var sql = getInsertQuery('pending_transactions', fields, false) var values = [session.fingerprint, session.id, incoming, currencyCode, - toAddress, satoshis]; - query(client, sql, values, function(err) { - cb(err); - }); + cryptoCode, toAddress, satoshis.toString()] + query(client, sql, values, function (err) { + cb(err) + }) } -function buildOutgoingTx(client, session, tx, cb) { +function buildOutgoingTx (client, session, tx, cb) { async.waterfall([ async.apply(billsAndTxs, client, session), async.apply(insertOutgoingTx, client, session, tx) - ], cb); + ], cb) } // Calling function should only send bitcoins if result.satoshisToSend > 0 -exports.addOutgoingTx = function addOutgoingTx(session, tx, cb) { - connect(function(cerr, client, done) { - if (cerr) return cb(cerr); +exports.addOutgoingTx = function addOutgoingTx (session, tx, cb) { + connect(function (cerr, client, done) { + if (cerr) return cb(cerr) async.series([ async.apply(silentQuery, client, 'BEGIN'), async.apply(silentQuery, client, 'LOCK TABLE transactions'), async.apply(insertOutgoingCompleteTx, client, session, tx), async.apply(removePendingTx, client, session), async.apply(buildOutgoingTx, client, session, tx) - ], function(err, results) { + ], function (err, results) { if (err) { - rollback(client, done); - return cb(err); + rollback(client, done) + return cb(err) + } + silentQuery(client, 'COMMIT', [], function () { + done() + var toSend = results[4] + cb(null, toSend) + }) + }) + }) } - silentQuery(client, 'COMMIT', [], function() { - done(); - var toSend = results[4]; - cb(null, toSend); - }); - }); - }); -}; -exports.sentCoins = function sentCoins(session, tx, authority, toSend, fee, +exports.sentCoins = function sentCoins (session, tx, authority, toSend, fee, error, txHash) { - connect(function(cerr, client, done) { - if (cerr) return logger.error(cerr); + connect(function (cerr, client, done) { + if (cerr) return logger.error(cerr) - var newTx = _.clone(tx); - newTx.txHash = txHash; - newTx.error = error; + var newTx = _.clone(tx) + newTx.txHash = txHash + newTx.error = error insertOutgoing(client, session, newTx, toSend.satoshis, toSend.fiat, - 'partial_send', authority, function(err) { - done(); - if (err) logger.error(err); - }); - }); -}; + 'partial_send', authority, function (err) { + done() + if (err) logger.error(err) + }) + }) +} -function ensureNotFinal(client, session, cb) { +function ensureNotFinal (client, session, cb) { var sql = 'SELECT id FROM transactions ' + 'WHERE device_fingerprint=$1 AND session_id=$2 AND incoming=$3 ' + 'AND stage=$4' + - 'LIMIT 1'; - var values = [session.fingerprint, session.id, false, 'final_request']; + 'LIMIT 1' + var values = [session.fingerprint, session.id, false, 'final_request'] - client.query(sql, values, function(err, results) { - var error; - if (err) return cb(err); + client.query(sql, values, function (err, results) { + var error + if (err) return cb(err) if (results.rows.length > 0) { - error = new Error('Final request already exists'); - error.name = 'staleBill'; - error.severity = 'low'; - return cb(error); + error = new Error('Final request already exists') + error.name = 'staleBill' + error.severity = 'low' + return cb(error) } - cb(); - }); + cb() + }) } -exports.addOutgoingPending = function addOutgoingPending(session, currencyCode, - toAddress, cb) { - connect(function(cerr, client, done) { - if (cerr) return cb(cerr); +exports.addOutgoingPending = function addOutgoingPending (session, currencyCode, + cryptoCode, toAddress, cb) { + connect(function (cerr, client, done) { + if (cerr) return cb(cerr) async.series([ async.apply(silentQuery, client, 'BEGIN', null), async.apply(ensureNotFinal, client, session), - async.apply(addPendingTx, client, session, false, currencyCode, toAddress, + async.apply(addPendingTx, client, session, false, currencyCode, cryptoCode, toAddress, 0) - ], function(err) { + ], function (err) { if (err) { - return rollback(client, function(rberr) { - done(rberr); + return rollback(client, function (rberr) { + done(rberr) if (isUniqueViolation(err)) { // Pending tx exists, refresh it. - return refreshPendingTx(client, session, cb); + return refreshPendingTx(client, session, cb) } if (err.name === 'staleBill') { - logger.info('Received a bill insert after send coins request'); - return cb(); + logger.info('Received a bill insert after send coins request') + return cb() } - logger.error(err); - return cb(err); - }); + logger.error(err) + return cb(err) + }) + } + silentQuery(client, 'COMMIT', null, function () { + done() + cb() + }) + }) + }) } - silentQuery(client, 'COMMIT', null, function() { - done(); - cb(); - }); - }); - }); -}; -exports.addInitialIncoming = function addInitialIncoming(session, tx, cb) { - connect(function(cerr, client, done) { - if (cerr) return cb(cerr); +exports.addInitialIncoming = function addInitialIncoming (session, tx, cb) { + connect(function (cerr, client, done) { + if (cerr) return cb(cerr) async.series([ async.apply(silentQuery, client, 'BEGIN', null), async.apply(addPendingTx, client, session, true, tx.currencyCode, - tx.toAddress, tx.satoshis), + tx.cryptoCode, tx.toAddress, tx.satoshis), async.apply(insertIncoming, client, session, tx, tx.satoshis, tx.fiat, 'initial_request', 'pending') - ], function(err) { + ], function (err) { if (err) { - rollback(client, done); - return cb(err); + rollback(client, done) + return cb(err) + } + silentQuery(client, 'COMMIT', null, function () { + done() + cb() + }) + }) + }) } - silentQuery(client, 'COMMIT', null, function() { - done(); - cb(); - }); - }); - }); -}; -function insertDispense(client, session, tx, cartridges, transactionId, cb) { +function insertDispense (client, session, tx, cartridges, transactionId, cb) { var fields = [ 'device_fingerprint', 'transaction_id', 'dispense1', 'reject1', 'count1', 'dispense2', 'reject2', 'count2', 'refill', 'error' - ]; + ] - var sql = getInsertQuery('dispenses', fields, true); + var sql = getInsertQuery('dispenses', fields, true) - var dispense1 = tx.bills[0].actualDispense; - var dispense2 = tx.bills[1].actualDispense; - var reject1 = tx.bills[0].rejected; - var reject2 = tx.bills[1].rejected; - var count1 = cartridges[0].count; - var count2 = cartridges[1].count; + var dispense1 = tx.bills[0].actualDispense + var dispense2 = tx.bills[1].actualDispense + var reject1 = tx.bills[0].rejected + var reject2 = tx.bills[1].rejected + var count1 = cartridges[0].count + var count2 = cartridges[1].count var values = [ session.fingerprint, transactionId, dispense1, reject1, count1, dispense2, reject2, count2, false, tx.error - ]; - client.query(sql, values, cb); + ] + client.query(sql, values, cb) } -exports.addDispense = function addDispense(session, tx, cartridges) { - connect(function(cerr, client, done) { - if (cerr) return; +exports.addDispense = function addDispense (session, tx, cartridges) { + connect(function (cerr, client, done) { + if (cerr) return async.waterfall([ async.apply(insertIncoming, client, session, tx, 0, tx.fiat, 'dispense', 'authorized'), async.apply(insertDispense, client, session, tx, cartridges) - ], function(err) { - done(); - if (err) logger.error(err); - }); - }); -}; + ], function (err) { + done() + if (err) logger.error(err) + }) + }) +} -exports.cartridgeCounts = function cartridgeCounts(session, cb) { - connect(function(cerr, client, done) { - if (cerr) return cb(cerr); +exports.cartridgeCounts = function cartridgeCounts (session, cb) { + connect(function (cerr, client, done) { + if (cerr) return cb(cerr) var sql = 'SELECT id, count1, count2 FROM dispenses ' + 'WHERE device_fingerprint=$1 AND refill=$2 ' + - 'ORDER BY id DESC LIMIT 1'; - query(client, sql, [session.fingerprint, true], function(err, results) { - done(); - if (err) return cb(err); - var counts = results.rows.length === 1 ? - [results.rows[0].count1, results.rows[0].count2] : - [0, 0]; + 'ORDER BY id DESC LIMIT 1' + query(client, sql, [session.fingerprint, true], function (err, results) { + done() + if (err) return cb(err) + var counts = results.rows.length === 1 + ? [results.rows[0].count1, results.rows[0].count2] + : [0, 0] cb(null, {id: results.rows[0].id, counts: counts}); }); }); @@ -552,12 +566,12 @@ exports.machineEvents = function machineEvents(cb) { } /* -exports.init('postgres://lamassu:lamassu@localhost/lamassu'); +exports.init('postgres://lamassu:lamassu@localhost/lamassu') connect(function(err, client, done) { - var sql = 'select * from transactions where id=$1'; + var sql = 'select * from transactions where id=$1' query(client, sql, [130], function(_err, results) { - done(); - console.dir(results.rows[0]); - }); -}); + done() + console.dir(results.rows[0]) + }) +}) */ diff --git a/lib/routes.js b/lib/routes.js index 8ecd8c60..4b2ca919 100644 --- a/lib/routes.js +++ b/lib/routes.js @@ -1,94 +1,122 @@ -'use strict'; +'use strict' -var logger = require('./logger'); +var BigNumber = require('bignumber.js') +var logger = require('./logger') -var mock = false; +var mock = false -var plugins; -var lamassuConfig; +var plugins +var lamassuConfig module.exports = { init: init, getFingerprint: getFingerprint -}; +} +/* // Make sure these are higher than polling interval // or there will be a lot of errors -var STALE_TICKER = 180000; -var STALE_BALANCE = 180000; +var STALE_TICKER = 180000 +var STALE_BALANCE = 180000 +*/ -function poll(req, res) { - var rateRec = plugins.getDeviceRate(); - var balanceRec = plugins.getBalance(); +var pids = {} +var reboots = {} - logger.debug('poll request from: %s', getFingerprint(req)); +function buildRates () { + var cryptoCodes = plugins.getcryptoCodes() + var config = plugins.getConfig() + var settings = config.exchanges.settings - // `rateRec` and `balanceRec` are both objects, so there's no danger - // of misinterpreting rate or balance === 0 as 'Server initializing'. - if (!rateRec || !balanceRec) { - return res.json({err: 'Server initializing'}); + var cashInCommission = settings.commission + var cashOutCommission = settings.fiatCommission || cashInCommission + + var rates = {} + cryptoCodes.forEach(function (cryptoCode) { + var _rate = plugins.getDeviceRate(cryptoCode) + if (!_rate) return + var rate = _rate.rates + rates[cryptoCode] = { + cashIn: rate.ask.times(cashInCommission), + cashOut: rate.bid.div(cashOutCommission) + } + }) + + return rates } - var now = Date.now(); - if (now - rateRec.timestamp > STALE_TICKER) { - return res.json({err: 'Stale ticker'}); +function buildBalances () { + var cryptoCodes = plugins.getcryptoCodes() + + var _balances = {} + cryptoCodes.forEach(function (cryptoCode) { + var balance = plugins.fiatBalance(cryptoCode) + _balances[cryptoCode] = balance + }) + + return _balances } - if (now - balanceRec.timestamp > STALE_BALANCE) { - return res.json({err: 'Stale balance'}); - } +function poll (req, res) { + var fingerprint = getFingerprint(req) + var pid = req.query.pid - var rate = rateRec.rates.ask; - var fiatRate = rateRec.rates.bid || rate; + pids[fingerprint] = {pid: pid, ts: Date.now()} - if (rate === null) return res.json({err: 'No rate available'}); - if (!fiatRate) - logger.warn('No bid rate, using ask rate'); + logger.debug('poll request from: %s', fingerprint) - var fiatBalance = plugins.fiatBalance(); - if (fiatBalance === null) { - logger.warn('No balance available.'); - return res.json({err: 'No balance available'}); - } + var rates = buildRates() + var balances = buildBalances() - var config = plugins.getConfig(); - var settings = config.exchanges.settings; - var complianceSettings = settings.compliance; - var fiatCommission = settings.fiatCommission || settings.commission; + var config = plugins.getConfig() + var settings = config.exchanges.settings + var complianceSettings = settings.compliance + + plugins.pollQueries(session(req), function (err, results) { + if (err) return logger.error(err) + var cartridges = results.cartridges + + var reboot = reboots[fingerprint] === pid - plugins.pollQueries(session(req), function(err, results) { - if (err) return logger.error(err); - var cartridges = results.cartridges; var response = { err: null, - rate: rate * settings.commission, - fiatRate: fiatRate / fiatCommission, - fiat: fiatBalance, + rate: rates.BTC.cashIn, + fiatRate: rates.BTC.cashOut, + fiat: balances.BTC, locale: config.brain.locale, txLimit: parseInt(complianceSettings.maximum.limit, 10), idVerificationEnabled: complianceSettings.idVerificationEnabled, cartridges: cartridges, - twoWayMode: cartridges ? true : false, + twoWayMode: !!cartridges, zeroConfLimit: settings.zeroConfLimit, - fiatTxLimit: settings.fiatTxLimit - }; + fiatTxLimit: settings.fiatTxLimit, + reboot: reboot, + rates: rates, + balances: balances, + coins: settings.coins + } - if (response.idVerificationEnabled) - response.idVerificationLimit = complianceSettings.idVerificationLimit; + if (response.idVerificationEnabled) { + response.idVerificationLimit = complianceSettings.idVerificationLimit + } - res.json(response); - }); + res.json(response) + }) plugins.recordPing(session(req), req.query, function(err) { if (err) console.error(err); }); } -function trade(req, res) { - plugins.trade(session(req), req.body, function(err) { - var statusCode = err ? 500 : 201; - res.json(statusCode, {err: err}); - }); +function trade (req, res) { + var tx = req.body + tx.cryptoAtoms = new BigNumber(tx.cryptoAtoms) + + plugins.trade(session(req), tx, function (err) { + if (err) logger.error(err) + var statusCode = err ? 500 : 201 + res.json(statusCode, {err: err}) + }) } function stateChange(req, res) { @@ -98,8 +126,12 @@ function stateChange(req, res) { }) } -function send(req, res) { - plugins.sendBitcoins(session(req), req.body, function(err, status) { +function send (req, res) { + var tx = req.body + tx.cryptoAtoms = new BigNumber(tx.cryptoAtoms) + tx.satoshis = tx.cryptoAtoms + + plugins.sendCoins(session(req), tx, function (err, status) { // TODO: use status.statusCode here after confirming machine compatibility // FIX: (joshm) set txHash to status.txId instead of previous status.txHash which wasn't being set // Need to clean up txHash vs txId @@ -108,122 +140,141 @@ function send(req, res) { err: err && err.message, txHash: status && status.txHash, txId: status && status.txId - }); - }); + }) + }) } -function cashOut(req, res) { - logger.info({tx: req.body, cmd: 'cashOut'}); - plugins.cashOut(session(req), req.body, function(err, bitcoinAddress) { - if (err) logger.error(err); +function cashOut (req, res) { + logger.info({tx: req.body, cmd: 'cashOut'}) + var tx = req.body + tx.cryptoAtoms = new BigNumber(tx.cryptoAtoms) + tx.satoshis = tx.cryptoAtoms + + plugins.cashOut(session(req), req.body, function (err, bitcoinAddress) { + if (err) logger.error(err) res.json({ err: err && err.message, errType: err && err.name, bitcoinAddress: bitcoinAddress - }); - }); + }) + }) } -function dispenseAck(req, res) { - plugins.dispenseAck(session(req), req.body); - res.json(200); +function dispenseAck (req, res) { + plugins.dispenseAck(session(req), req.body) + res.json(200) } -function deviceEvent(req, res) { - plugins.logEvent(session(req), req.body); - res.json({err: null}); +function deviceEvent (req, res) { + plugins.logEvent(session(req), req.body) + res.json({err: null}) } -function verifyUser(req, res) { - if (mock) return res.json({success: true}); +function verifyUser (req, res) { + if (mock) return res.json({success: true}) plugins.verifyUser(req.body, function (err, idResult) { if (err) { - logger.error(err); - return res.json({err: 'Verification failed'}); + logger.error(err) + return res.json({err: 'Verification failed'}) } - res.json(idResult); - }); + res.json(idResult) + }) } -function verifyTx(req, res) { - if (mock) return res.json({success: true}); +function verifyTx (req, res) { + if (mock) return res.json({success: true}) plugins.verifyTx(req.body, function (err, idResult) { if (err) { - logger.error(err); - return res.json({err: 'Verification failed'}); + logger.error(err) + return res.json({err: 'Verification failed'}) } - res.json(idResult); - }); + res.json(idResult) + }) } -function pair(req, res) { - var token = req.body.token; - var name = req.body.name; +function pair (req, res) { + var token = req.body.token + var name = req.body.name lamassuConfig.pair( token, getFingerprint(req), name, - function(err) { - if (err) return res.json(500, { err: err.message }); + function (err) { + if (err) return res.json(500, { err: err.message }) - res.json(200); + res.json(200) } - ); + ) } -function raqia(req, res) { - var raqiaCreds; +function raqia (req, res) { + var raqiaCreds try { - var raqiaRec = require('../raqia.json'); - raqiaCreds = raqiaRec[getFingerprint(req)].apiKeys[0]; - } catch(ex) { - raqiaCreds = null; + var raqiaRec = require('../raqia.json') + raqiaCreds = raqiaRec[getFingerprint(req)].apiKeys[0] + } catch (ex) { + raqiaCreds = null } - res.json(raqiaCreds || {}); + res.json(raqiaCreds || {}) } -function init(localConfig) { - lamassuConfig = localConfig.lamassuConfig; - plugins = localConfig.plugins; - mock = localConfig.mock; +function init (localConfig) { + lamassuConfig = localConfig.lamassuConfig + plugins = localConfig.plugins + mock = localConfig.mock - var authMiddleware = localConfig.authMiddleware; - var reloadConfigMiddleware = localConfig.reloadConfigMiddleware; - var app = localConfig.app; + var authMiddleware = localConfig.authMiddleware + var reloadConfigMiddleware = localConfig.reloadConfigMiddleware + var app = localConfig.app + var localApp = localConfig.localApp - app.get('/poll', authMiddleware, reloadConfigMiddleware, poll); + app.get('/poll', authMiddleware, reloadConfigMiddleware, poll) - app.post('/trade', authMiddleware, trade); - app.post('/state', authMiddleware, stateChange); - app.post('/send', authMiddleware, send); + app.post('/trade', authMiddleware, trade) + app.post('/send', authMiddleware, send) + app.post('/state', authMiddleware, stateChange) + app.post('/cash_out', authMiddleware, cashOut) + app.post('/dispense_ack', authMiddleware, dispenseAck) - app.post('/cash_out', authMiddleware, cashOut); - app.post('/dispense_ack', authMiddleware, dispenseAck); + app.post('/event', authMiddleware, deviceEvent) + app.post('/verify_user', authMiddleware, verifyUser) + app.post('/verify_transaction', authMiddleware, verifyTx) + app.post('/pair', pair) + app.get('/raqia', raqia) - app.post('/event', authMiddleware, deviceEvent); - app.post('/verify_user', authMiddleware, verifyUser); - app.post('/verify_transaction', authMiddleware, verifyTx); - app.post('/pair', pair); - app.get('/raqia', raqia); + localApp.get('/pid', function (req, res) { + var machineFingerprint = req.query.fingerprint + var pidRec = pids[machineFingerprint] + res.json(pidRec) + }) - return app; + localApp.post('/reboot', function (req, res) { + var pid = req.body.pid + var fingerprint = req.body.fingerprint + console.log('pid: %s, fingerprint: %s', pid, fingerprint) + + if (!fingerprint || !pid) { + return res.send(400) + } + + reboots[fingerprint] = pid + res.send(200) + }) + + return app } -function session(req) { - return { - fingerprint: getFingerprint(req), - id: req.get('session-id'), - deviceTime: Date.parse(req.get('date')) - }; +function session (req) { + return {fingerprint: getFingerprint(req), id: req.get('session-id')} } -function getFingerprint(req) { +function getFingerprint (req) { return (typeof req.connection.getPeerCertificate === 'function' && - req.connection.getPeerCertificate().fingerprint) || 'unknown'; + req.connection.getPeerCertificate().fingerprint) || 'unknown' } diff --git a/migrations/005-addCrypto.js b/migrations/005-addCrypto.js new file mode 100644 index 00000000..eaa551c7 --- /dev/null +++ b/migrations/005-addCrypto.js @@ -0,0 +1,18 @@ +var db = require('./db') + +exports.up = function (next) { + var sqls = [ + 'alter table transactions alter satoshis TYPE bigint', + "alter table transactions add crypto_code text default 'BTC'", + "alter table pending_transactions add crypto_code text default 'BTC'", + 'alter table pending_transactions alter satoshis TYPE bigint', + "alter table bills add crypto_code text default 'BTC'", + 'alter table bills alter satoshis TYPE bigint' + ] + + db.multi(sqls, next) +} + +exports.down = function (next) { + next() +} diff --git a/package.json b/package.json index 282f6850..88e91d5d 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "lamassu-server", "description": "bitcoin atm client server protocol module", "keywords": [], - "version": "2.2.10", + "version": "2.3.0", "license": "unlicense", "author": "Lamassu (https://lamassu.is)", "engines": { @@ -10,9 +10,13 @@ }, "dependencies": { "async": "~0.2.9", - "bunyan": "~0.22.3", + "axios": "^0.9.1", + "bignumber.js": "^2.3.0", + "bluebird": "^3.3.4", + "es6-promise": "^3.1.2", + "ethereumjs-wallet": "^0.5.1", "express": "~3.4.7", - "inquirer": "^0.8.0", + "inquirer": "^1.0.0", "joi": "^5.1.0", "lamassu-bitcoinaverage": "~1.0.0", "lamassu-bitcoind": "^1.1.0", @@ -27,10 +31,15 @@ "lamassu-coinfloor": "^0.1.2", "lamassu-config": "~0.4.0", "lamassu-identitymind": "^1.0.1", + "lamassu-snapcard": "^0.1.7", "lodash": "^2.4.1", "minimist": "0.0.8", "node-uuid": "^1.4.2", - "pg": "~2.11.1", + "pg": "^4.5.1", + "pg-promise": "^3.4.3", + "prompt": "^1.0.0", + "promptly": "^1.1.0", + "web3": "^0.15.3", "ramda": "^0.19.1", "smtp-connection": "^2.2.1", "twilio": "^3.3.0-edge", @@ -42,7 +51,8 @@ }, "bin": { "lamassu-server": "./bin/lamassu-server", - "ssu-raqia": "./bin/ssu-raqia" + "ssu-raqia": "./bin/ssu-raqia", + "ssu": "./bin/ssu" }, "scripts": { "test": "mocha --recursive test" diff --git a/test/plugins.js b/test/plugins.js index bf8aeaa4..687941d4 100644 --- a/test/plugins.js +++ b/test/plugins.js @@ -125,7 +125,7 @@ describe('Plugins', function() { }); it('should return config', function() { - var config = plugins.getCachedConfig(); + var config = plugins.getConfig(); should.exist(config); /* jshint expr: true */ config.should.be.an.Object; diff --git a/todo.txt b/todo.txt new file mode 100644 index 00000000..c3351e1e --- /dev/null +++ b/todo.txt @@ -0,0 +1,53 @@ +- getDeviceRate should return bignumber +- test with l-m + +backwards compatibility: + +- new l-m must be backwards compatible with old l-s + +- clean up db stuff satoshis/cryptoAtoms +- clean up other stuff + +- add 'ETH' to config in ssu crypto: config.exchanges.settings.coins + +[2016-04-06T19:58:17.827Z] ERROR: lamassu-server/39374 on MacBook-Pro: null value in column "satoshis" violates not-null constraint + error: null value in column "satoshis" violates not-null constraint + at Connection.parseE (/Users/josh/projects/lamassu-server/node_modules/pg/lib/connection.js:539:11) + at Connection.parseMessage (/Users/josh/projects/lamassu-server/node_modules/pg/lib/connection.js:366:17) + at Socket. (/Users/josh/projects/lamassu-server/node_modules/pg/lib/connection.js:105:22) + at Socket.emit (events.js:95:17) + at Socket. (_stream_readable.js:765:14) + at Socket.emit (events.js:92:17) + at emitReadable_ (_stream_readable.js:427:10) + at emitReadable (_stream_readable.js:423:5) + at readableAddChunk (_stream_readable.js:166:9) + at Socket.Readable.push (_stream_readable.js:128:10) + +alter table transactions alter satoshis TYPE bigint; +alter table transactions add crypto_code text default 'BTC'; +alter table pending_transactions add crypto_code text default 'BTC'; +alter table pending_transactions alter satoshis TYPE bigint; +alter table bills add crypto_code text default 'BTC'; +alter table bills alter satoshis TYPE bigint; + +- handle geth send failure better +- remove debug +- implement coin selection screen +- ask neal to work on config scripts + +[2016-04-17T22:10:57.917Z] ERROR: lamassu-server/32185 on MacBook-Pro.local: could not unlock signer account + Error: could not unlock signer account + at Object.module.exports.InvalidResponse (/Users/josh/projects/lamassu-geth/node_modules/web3/lib/web3/errors.js:35:16) + at /Users/josh/projects/lamassu-geth/node_modules/web3/lib/web3/requestmanager.js:86:36 + at request.onreadystatechange (/Users/josh/projects/lamassu-geth/node_modules/web3/lib/web3/httpprovider.js:114:13) + at dispatchEvent (/Users/josh/projects/lamassu-geth/node_modules/web3/node_modules/xmlhttprequest/lib/XMLHttpRequest.js:591:25) + at setState (/Users/josh/projects/lamassu-geth/node_modules/web3/node_modules/xmlhttprequest/lib/XMLHttpRequest.js:610:14) + at IncomingMessage. (/Users/josh/projects/lamassu-geth/node_modules/web3/node_modules/xmlhttprequest/lib/XMLHttpRequest.js:447:13) + at emitNone (events.js:72:20) + at IncomingMessage.emit (events.js:166:7) + at endReadableNT (_stream_readable.js:913:12) + at nextTickCallbackWith2Args (node.js:442:9) +/Users/josh/projects/lamassu-server/lib/postgresql_interface.js:301 + satoshis.toString(), + ^ +better handling of this error