diff --git a/bin/lamassu-hd-address b/bin/lamassu-hd-address new file mode 100755 index 00000000..d7f35599 --- /dev/null +++ b/bin/lamassu-hd-address @@ -0,0 +1,19 @@ +#!/usr/bin/env node + +const HKDF = require('node-hkdf-sync') +const wallet = require('lamassu-geth') +const pify = require('pify') +const fs = pify(require('fs')) + +const options = require('../lib/options') + +function computeSeed (masterSeed) { + const hkdf = new HKDF('sha256', 'lamassu-server-salt', masterSeed) + return hkdf.derive('wallet-seed', 32) +} + +fs.readFile(options.seedPath, 'utf8') +.then(hex => { + const masterSeed = Buffer.from(hex.trim(), 'hex') + console.log(wallet.defaultAddress({seed: computeSeed(masterSeed)})) +}) diff --git a/lib/admin/config.js b/lib/admin/config.js index 253eacf7..ca8d7a95 100644 --- a/lib/admin/config.js +++ b/lib/admin/config.js @@ -244,6 +244,7 @@ function fetchData () { {code: 'bitgo', display: 'BitGo', class: 'wallet', cryptos: ['BTC']}, {code: 'geth', display: 'geth', class: 'wallet', cryptos: ['ETH']}, {code: 'mock-wallet', display: 'Mock wallet', class: 'wallet', cryptos: ['BTC', 'ETH']}, + {code: 'mock-exchange', display: 'Mock exchange', class: 'exchange', cryptos: ['BTC', 'ETH']}, {code: 'mock-sms', display: 'Mock SMS', class: 'sms'}, {code: 'mock-id-verify', display: 'Mock ID verifier', class: 'idVerifier'}, {code: 'twilio', display: 'Twilio', class: 'sms'}, diff --git a/lib/admin/machines.js b/lib/admin/machines.js index 856696dd..30026dd4 100644 --- a/lib/admin/machines.js +++ b/lib/admin/machines.js @@ -22,15 +22,10 @@ function unpair (rec) { return pairing.unpair(rec.deviceId) } -function repair (rec) { - return pairing.repair(rec.deviceId) -} - function setMachine (rec) { switch (rec.action) { case 'resetCashOutBills': return resetCashOutBills(rec) case 'unpair': return unpair(rec) - case 'repair': return repair(rec) default: throw new Error('No such action: ' + rec.action) } } diff --git a/lib/admin/pairing.js b/lib/admin/pairing.js index 8bb26743..4ea8601c 100644 --- a/lib/admin/pairing.js +++ b/lib/admin/pairing.js @@ -11,14 +11,7 @@ const ALPHA_BASE = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:' const bsAlpha = baseX(ALPHA_BASE) function unpair (deviceId) { - const sql = 'update devices set paired=FALSE where device_id=$1' - - return db.none(sql, [deviceId]) -} - -function repair (deviceId) { - const sql = 'update devices set paired=TRUE where device_id=$1' - + const sql = 'delete from devices where device_id=$1' return db.none(sql, [deviceId]) } @@ -39,4 +32,4 @@ function totem (hostname, name) { }) } -module.exports = {totem, unpair, repair} +module.exports = {totem, unpair} diff --git a/lib/cash-in-tx.js b/lib/cash-in-tx.js index 2460f03c..42a730cf 100644 --- a/lib/cash-in-tx.js +++ b/lib/cash-in-tx.js @@ -2,6 +2,7 @@ const _ = require('lodash/fp') const pgp = require('pg-promise')() const db = require('./db') const BN = require('./bn') +const logger = require('./logger') const mapValuesWithKey = _.mapValues.convert({cap: false}) @@ -25,7 +26,7 @@ function post (tx, pi) { return upsert(row, tx) .then(vector => { return insertNewBills(billRows, tx) - .then(newBills => _.concat(vector, [billRows])) + .then(newBills => _.concat(vector, [newBills])) }) }) }) @@ -51,7 +52,6 @@ function diff (oldTx, newTx) { let updatedTx = {} UPDATEABLE_FIELDS.forEach(fieldKey => { - console.log('DEBUG80: %j', [oldTx[fieldKey], newTx[fieldKey]]) if (oldTx && _.isEqualWith(nilEqual, oldTx[fieldKey], newTx[fieldKey])) return // We never null out an existing field @@ -139,15 +139,6 @@ function insert (tx) { .then(toObj) } -// const tx = JSON.parse('{"id":"677ec2b7-8e7a-4efc-99fc-1c1aa1b6a3a6","fiat":"1","cryptoAtoms":"73100","bills":[{"id":"afc6103f-b8bf-4ef3-aa28-6bd14f0c2633","fiat":"1","fiatCode":"USD","cryptoAtoms":"73100","cryptoCode":"BTC","deviceTime":1489642154270,"cashInTxsId":"677ec2b7-8e7a-4efc-99fc-1c1aa1b6a3a6"}],"fiatCode":"USD","cryptoCode":"BTC","direction":"cashIn","toAddress":"1MyRmwUVffy5QC5NEbdu9u1Lb9pZkwcNGg","deviceId":"F2:9C:7F:2C:59:F6:3C:EB:C5:A7:AE:4D:C0:59:32:70:0B:9D:3D:FE"}') - -// insert(tx) -// .then(console.log) -// .catch(err => { -// console.log(err.stack) -// process.exit(1) -// }) - function update (tx, changes) { if (_.isEmpty(changes)) return Promise.resolve(tx) @@ -159,13 +150,21 @@ function update (tx, changes) { .then(toObj) } +function registerTrades (pi, txVector) { + console.log('DEBUG400') + const newBills = _.last(txVector) + console.log('DEBUG401: %j', newBills) + _.forEach(bill => pi.buy(bill), newBills) +} + function postProcess (txVector, pi) { const [oldTx, newTx] = txVector + registerTrades(pi, txVector) + if (newTx.send && !oldTx.send) { return pi.sendCoins(newTx) .then(txHash => ({txHash})) - .catch(error => ({error})) } return Promise.resolve({}) diff --git a/lib/cash-out-tx.js b/lib/cash-out-tx.js index 96a5c52b..1b6457ec 100644 --- a/lib/cash-out-tx.js +++ b/lib/cash-out-tx.js @@ -1,15 +1,29 @@ const _ = require('lodash/fp') const pgp = require('pg-promise')() + const db = require('./db') const BN = require('./bn') const billMath = require('./bill-math') +const T = require('./time') +const logger = require('./logger') +const plugins = require('./plugins') -module.exports = {post} +module.exports = { + post, + monitorLiveIncoming, + monitorStaleIncoming, + monitorUnnotified +} const mapValuesWithKey = _.mapValues.convert({cap: false}) const UPDATEABLE_FIELDS = ['txHash', 'status', 'dispensed', 'notified', 'redeem', - 'phone', 'error', 'confirmationTime'] + 'phone', 'error', 'confirmationTime', 'swept'] + +const STALE_INCOMING_TX_AGE = T.week +const STALE_LIVE_INCOMING_TX_AGE = 10 * T.minutes +const MAX_NOTIFY_AGE = 2 * T.days +const MIN_NOTIFY_AGE = 5 * T.minutes function post (tx, pi) { const TransactionMode = pgp.txMode.TransactionMode @@ -47,7 +61,6 @@ function diff (oldTx, newTx) { let updatedTx = {} UPDATEABLE_FIELDS.forEach(fieldKey => { - console.log('DEBUG80: %j', [oldTx[fieldKey], newTx[fieldKey]]) if (oldTx && _.isEqualWith(nilEqual, oldTx[fieldKey], newTx[fieldKey])) return // We never null out an existing field @@ -122,8 +135,12 @@ function convertBigNumFields (obj) { return _.mapKeys(convertKey, mapValuesWithKey(convert, obj)) } +function convertField (key) { + return _.snakeCase(key) +} + function toDb (tx) { - const massager = _.flow(convertBigNumFields, mapDispense, _.omit(['direction', 'bills']), _.mapKeys(_.snakeCase)) + const massager = _.flow(convertBigNumFields, mapDispense, _.omit(['direction', 'bills']), _.mapKeys(convertField)) return massager(tx) } @@ -148,17 +165,32 @@ function update (tx, changes) { .then(() => newTx) } +function nextHd (isHd, tx) { + console.log('DEBUG160: %s', isHd) + if (!isHd) return Promise.resolve(tx) + console.log('DEBUG161: %s', isHd) + + return db.one("select nextval('hd_indices_seq') as hd_index") + .then(row => _.set('hdIndex', row.hd_index, tx)) +} + function preProcess (tx, newTx, pi) { if (!tx) { - return pi.newAddress(newTx) - .then(_.set('toAddress', _, newTx)) + return pi.isHd(newTx) + .then(isHd => nextHd(isHd, newTx)) + .then(newTxHd => { + return pi.newAddress(newTxHd) + .then(_.set('toAddress', _, newTxHd)) + }) } - return Promise.resolve(newTx) + return Promise.resolve(updateStatus(tx, newTx)) } function postProcess (txVector, pi) { - const [, newTx] = txVector + const [oldTx, newTx] = txVector + + if (!oldTx) pi.sell(newTx) if (newTx.dispensed && !newTx.bills) { return pi.buildCartridges() @@ -169,3 +201,75 @@ function postProcess (txVector, pi) { return Promise.resolve(newTx) } + +function updateStatus (oldTx, newTx) { + const tx = _.set('status', ratchetStatus(oldTx.status, newTx.status), newTx) + const isConfirmed = _.includes(tx.status, ['instant', 'confirmed']) + + if (tx.status === oldTx.status || !isConfirmed) return tx + + return _.set('confirmationTime', 'now()^', tx) +} + +function ratchetStatus (oldStatus, newStatus) { + const statusOrder = ['notSeen', 'published', 'rejected', + 'authorized', 'instant', 'confirmed'] + + if (oldStatus === newStatus) return oldStatus + if (newStatus === 'insufficientFunds') return newStatus + + const idx = Math.max(statusOrder.indexOf(oldStatus), statusOrder.indexOf(newStatus)) + return statusOrder[idx] +} + +function fetchOpenTxs (statuses, age) { + const sql = `select * + from cash_out_txs + where ((extract(epoch from (now() - created))) * 1000)<$1 + and status in ($2^)` + + const statusClause = _.map(pgp.as.text, statuses).join(',') + + return db.any(sql, [age, statusClause]) + .then(rows => rows.map(toObj)) +} + +function processTxStatus (tx, settings) { + const pi = plugins(settings, tx.deviceId) + + return pi.getStatus(tx) + .then(res => _.set('status', res.status, tx)) + .then(_tx => post(_tx, pi)) +} + +function monitorLiveIncoming (settings) { + const statuses = ['notSeen', 'published', 'insufficientFunds'] + + return fetchOpenTxs(statuses, STALE_LIVE_INCOMING_TX_AGE) + .then(txs => Promise.all(txs.map(tx => processTxStatus(tx, settings)))) + .catch(logger.error) +} + +function monitorStaleIncoming (settings) { + const statuses = ['notSeen', 'published', 'authorized', 'instant', 'rejected', 'insufficientFunds'] + + return fetchOpenTxs(statuses, STALE_INCOMING_TX_AGE) + .then(txs => Promise.all(txs.map(tx => processTxStatus(tx, settings)))) + .catch(logger.error) +} + +function monitorUnnotified (settings) { + const sql = `select * + from cash_out_txs + where ((extract(epoch from (now() - created))) * 1000)<$1 + and notified=$2 and dispensed=$3 + and phone is not null + and status in ('instant', 'confirmed') + and (redeem=$4 or ((extract(epoch from (now() - created))) * 1000)>$5)` + + const notify = tx => plugins(settings, tx.deviceId).notifyConfirmation(tx) + return db.any(sql, [MAX_NOTIFY_AGE, false, false, true, MIN_NOTIFY_AGE]) + .then(rows => _.map(toObj, rows)) + .then(txs => Promise.all(txs.map(notify))) + .catch(logger.error) +} diff --git a/lib/exchange.js b/lib/exchange.js index 2908e6ec..e1cdae93 100644 --- a/lib/exchange.js +++ b/lib/exchange.js @@ -14,7 +14,7 @@ function lookupExchange (settings, cryptoCode) { function fetchExchange (settings, cryptoCode) { return Promise.resolve() .then(() => { - const plugin = lookupExchange(cryptoCode) + const plugin = lookupExchange(settings, cryptoCode) if (!plugin) throw noExchangeError(cryptoCode) const account = settings.accounts[plugin] const exchange = require('lamassu-' + plugin) @@ -33,7 +33,7 @@ function sell (settings, cryptoAtoms, fiatCode, cryptoCode) { .then(r => r.exchange.sell(r.account, cryptoAtoms, fiatCode, cryptoCode)) } -function active (settings, fiatCode, cryptoCode) { +function active (settings, cryptoCode) { return !!lookupExchange(settings, cryptoCode) } diff --git a/lib/plugins.js b/lib/plugins.js index bfcfeb38..58f7729b 100644 --- a/lib/plugins.js +++ b/lib/plugins.js @@ -2,6 +2,7 @@ const uuid = require('uuid') const _ = require('lodash/fp') const argv = require('minimist')(process.argv.slice(2)) const crypto = require('crypto') +const pgp = require('pg-promise')() const BN = require('./bn') const dbm = require('./postgresql_interface') @@ -15,10 +16,8 @@ const exchange = require('./exchange') const sms = require('./sms') const email = require('./email') -const STALE_INCOMING_TX_AGE = T.week -const STALE_LIVE_INCOMING_TX_AGE = 10 * T.minutes -const MAX_NOTIFY_AGE = 2 * T.days -const MIN_NOTIFY_AGE = 5 * T.minutes +const mapValuesWithKey = _.mapValues.convert({cap: false}) + const TRADE_TTL = 2 * T.minutes const STALE_TICKER = 3 * T.minutes const STALE_BALANCE = 3 * T.minutes @@ -42,13 +41,13 @@ function plugins (settings, deviceId) { const cryptoConfig = configManager.scoped(cryptoCode, deviceId, settings.config) const rateRec = tickers[i] - const cashInCommission = BN(1).minus(BN(cryptoConfig.cashInCommission).div(100)) - const cashOutCommission = cashOut && BN(1).plus(BN(cryptoConfig.cashOutCommission).div(100)) + const cashInCommission = BN(1).add(BN(cryptoConfig.cashInCommission).div(100)) + const cashOutCommission = cashOut && BN(1).add(BN(cryptoConfig.cashOutCommission).div(100)) if (Date.now() - rateRec.timestamp > STALE_TICKER) return logger.warn('Stale rate for ' + cryptoCode) const rate = rateRec.rates rates[cryptoCode] = { - cashIn: rate.ask.div(cashInCommission), + cashIn: rate.ask.mul(cashInCommission), cashOut: cashOut ? rate.bid.div(cashOutCommission) : undefined } }) @@ -160,36 +159,10 @@ function plugins (settings, deviceId) { }) } - // NOTE: This will fail if we have already sent coins because there will be - // a unique dbm record in the table already. function sendCoins (tx) { return wallet.sendCoins(settings, tx.toAddress, tx.cryptoAtoms, tx.cryptoCode) } - function trade (rawTrade) { - // TODO: move this to dbm, too - // add bill to trader queue (if trader is enabled) - const cryptoCode = rawTrade.cryptoCode - const fiatCode = rawTrade.fiatCode - const cryptoAtoms = rawTrade.cryptoAtoms - - return dbm.recordBill(deviceId, rawTrade) - .then(() => { - const market = [fiatCode, cryptoCode].join('') - - if (!exchange.active(settings, cryptoCode)) return - - logger.debug('[%s] Pushing trade: %d', market, cryptoAtoms) - if (!tradesQueues[market]) tradesQueues[market] = [] - tradesQueues[market].push({ - fiatCode, - cryptoAtoms, - cryptoCode, - timestamp: Date.now() - }) - }) - } - function recordPing (deviceTime, rec) { const event = { id: uuid.v4(), @@ -201,13 +174,22 @@ function plugins (settings, deviceId) { return dbm.machineEvent(event) } + function isHd (tx) { + return wallet.isHd(settings, tx.cryptoCode) + } + + function getStatus (tx) { + return wallet.getStatus(settings, tx.toAddress, tx.cryptoAtoms, tx.cryptoCode) + } + function newAddress (tx) { - const cryptoCode = tx.cryptoCode const info = { + cryptoCode: tx.cryptoCode, label: 'TX ' + Date.now(), - account: 'deposit' + account: 'deposit', + hdIndex: tx.hdIndex } - return wallet.newAddress(settings, cryptoCode, info) + return wallet.newAddress(settings, info) } function dispenseAck (tx) { @@ -244,11 +226,6 @@ function plugins (settings, deviceId) { }) } - function processTxStatus (tx) { - return wallet.getStatus(settings, tx.toAddress, tx.cryptoAtoms, tx.cryptoCode) - .then(res => dbm.updateTxStatus(tx, res.status)) - } - function notifyConfirmation (tx) { logger.debug('notifyConfirmation') @@ -256,34 +233,17 @@ function plugins (settings, deviceId) { const rec = { sms: { toNumber: phone, - body: 'Your cash is waiting! Go to the Cryptomat and press Redeem.' + body: 'Your cash is waiting! Go to the Cryptomat and press Redeem within 24 hours.' } } return sms.sendMessage(settings, rec) - .then(() => dbm.updateNotify(tx)) - } + .then(() => { + const sql = 'update cash_out_txs set notified=$1 where id=$2' + const values = [true, tx.id] - function monitorLiveIncoming () { - const statuses = ['notSeen', 'published', 'insufficientFunds'] - - return dbm.fetchOpenTxs(statuses, STALE_LIVE_INCOMING_TX_AGE) - .then(txs => Promise.all(txs.map(processTxStatus))) - .catch(logger.error) - } - - function monitorIncoming () { - const statuses = ['notSeen', 'published', 'authorized', 'instant', 'rejected', 'insufficientFunds'] - - return dbm.fetchOpenTxs(statuses, STALE_INCOMING_TX_AGE) - .then(txs => Promise.all(txs.map(processTxStatus))) - .catch(logger.error) - } - - function monitorUnnotified () { - dbm.fetchUnnotifiedTxs(MAX_NOTIFY_AGE, MIN_NOTIFY_AGE) - .then(txs => Promise.all(txs.map(notifyConfirmation))) - .catch(logger.error) + return db.none(sql, values) + }) } function pong () { @@ -304,6 +264,35 @@ function plugins (settings, deviceId) { * Trader functions */ + function buy (rec) { + return buyAndSell(rec, true) + } + + function sell (rec) { + return buyAndSell(rec, false) + } + + function buyAndSell (rec, doBuy) { + const cryptoCode = rec.cryptoCode + const fiatCode = rec.fiatCode + const cryptoAtoms = doBuy ? rec.cryptoAtoms : rec.cryptoAtoms.neg() + + const market = [fiatCode, cryptoCode].join('') + + console.log('DEBUG333') + if (!exchange.active(settings, cryptoCode)) return + console.log('DEBUG334') + + logger.debug('[%s] Pushing trade: %d', market, cryptoAtoms) + if (!tradesQueues[market]) tradesQueues[market] = [] + tradesQueues[market].push({ + fiatCode, + cryptoAtoms, + cryptoCode, + timestamp: Date.now() + }) + } + function consolidateTrades (cryptoCode, fiatCode) { const market = [fiatCode, cryptoCode].join('') @@ -371,27 +360,56 @@ function plugins (settings, deviceId) { if (!exchange.active(settings, cryptoCode)) return const market = [fiatCode, cryptoCode].join('') - logger.debug('[%s] checking for trades', market) - const tradeEntry = consolidateTrades(cryptoCode, fiatCode) - if (tradeEntry === null) return logger.debug('[%s] no trades', market) - if (tradeEntry.cryptoAtoms.eq(0)) { - logger.debug('[%s] rejecting 0 trade', market) - return - } + if (tradeEntry === null || tradeEntry.cryptoAtoms.eq(0)) return - logger.debug('[%s] making a trade: %d', market, tradeEntry.cryptoAtoms.toString()) - - return exchange.buy(settings, tradeEntry.cryptoAtoms, tradeEntry.fiatCode, tradeEntry.cryptoCode) - .then(() => logger.debug('[%s] Successful trade.', market)) + return executeTradeForType(tradeEntry) .catch(err => { tradesQueues[market].push(tradeEntry) if (err.name === 'NoExchangeError') return logger.debug(err.message) + if (err.name === 'orderTooSmall') return logger.debug(err.message) logger.error(err) }) } + function executeTradeForType (_tradeEntry) { + const expand = te => _.assign(te, { + cryptoAtoms: te.cryptoAtoms.abs(), + type: te.cryptoAtoms.gte(0) ? 'buy' : 'sell' + }) + + const tradeEntry = expand(_tradeEntry) + const execute = tradeEntry.type === 'buy' ? exchange.buy : exchange.sell + + return execute(settings, tradeEntry.cryptoAtoms, tradeEntry.fiatCode, tradeEntry.cryptoCode) + .then(() => recordTrade(tradeEntry)) + } + + function convertBigNumFields (obj) { + const convert = (value, key) => _.includes(key, ['cryptoAtoms', 'fiat']) + ? value.toString() + : value + + const convertKey = key => _.includes(key, ['cryptoAtoms', 'fiat']) + ? key + '#' + : key + + return _.mapKeys(convertKey, mapValuesWithKey(convert, obj)) + } + + function recordTrade (_tradeEntry) { + const massage = _.flow( + _.pick(['cryptoCode', 'cryptoAtoms', 'fiatCode', 'type']), + convertBigNumFields, + _.mapKeys(_.snakeCase) + ) + + const tradeEntry = massage(_tradeEntry) + const sql = pgp.helpers.insert(tradeEntry, null, 'trades') + return db.none(sql) + } + function sendMessage (rec) { const config = configManager.unscoped(settings.config) @@ -466,49 +484,51 @@ function plugins (settings, deviceId) { .then(() => code) } - function sweepHD (row) { + function sweepHdRow (row) { const cryptoCode = row.crypto_code - return wallet.sweep(settings, row.hd_serial) + console.log('DEBUG200') + return wallet.sweep(settings, cryptoCode, row.hd_index) .then(txHash => { if (txHash) { logger.debug('[%s] Swept address with tx: %s', cryptoCode, txHash) - return dbm.markSwept(row.tx_id) + + const sql = `update cash_out_txs set swept='t' + where id=$1` + + return db.none(sql, row.id) } }) .catch(err => logger.error('[%s] Sweep error: %s', cryptoCode, err.message)) } - function sweepLiveHD () { - return dbm.fetchLiveHD() - .then(rows => Promise.all(rows.map(sweepHD))) - .catch(err => logger.error(err)) - } + function sweepHd () { + const sql = `select id, crypto_code, hd_index from cash_out_txs + where hd_index is not null and not swept and status in ('confirmed', 'instant')` - function sweepOldHD () { - return dbm.fetchOldHD() - .then(rows => Promise.all(rows.map(sweepHD))) + return db.any(sql) + .then(rows => Promise.all(rows.map(sweepHdRow))) .catch(err => logger.error(err)) } return { pollQueries, - trade, sendCoins, newAddress, + isHd, + getStatus, dispenseAck, getPhoneCode, executeTrades, pong, pongClear, - monitorLiveIncoming, - monitorIncoming, - monitorUnnotified, - sweepLiveHD, - sweepOldHD, + notifyConfirmation, + sweepHd, sendMessage, checkBalances, - buildCartridges + buildCartridges, + buy, + sell } } diff --git a/lib/poller.js b/lib/poller.js index 17af68ff..aeb17887 100644 --- a/lib/poller.js +++ b/lib/poller.js @@ -2,12 +2,12 @@ const plugins = require('./plugins') const notifier = require('./notifier') const T = require('./time') const logger = require('./logger') +const cashOutTx = require('./cash-out-tx') const INCOMING_TX_INTERVAL = 30 * T.seconds const LIVE_INCOMING_TX_INTERVAL = 5 * T.seconds const UNNOTIFIED_INTERVAL = 10 * T.seconds -const SWEEP_LIVE_HD_INTERVAL = T.minute -const SWEEP_OLD_HD_INTERVAL = 2 * T.minutes +const SWEEP_HD_INTERVAL = T.minute const TRADE_INTERVAL = 10 * T.seconds const PONG_INTERVAL = 10 * T.seconds const PONG_CLEAR_INTERVAL = 1 * T.day @@ -26,19 +26,17 @@ function start (settings) { pi.executeTrades() pi.pong() pi.pongClear() - pi.monitorLiveIncoming() - pi.monitorIncoming() - pi.monitorUnnotified() - pi.sweepLiveHD() - pi.sweepOldHD() + cashOutTx.monitorLiveIncoming(settings) + cashOutTx.monitorStaleIncoming(settings) + cashOutTx.monitorUnnotified(settings) + pi.sweepHd() notifier.checkNotification(pi) setInterval(() => pi.executeTrades(), TRADE_INTERVAL) - setInterval(() => pi.monitorLiveIncoming(), LIVE_INCOMING_TX_INTERVAL) - setInterval(() => pi.monitorIncoming(), INCOMING_TX_INTERVAL) - setInterval(() => pi.monitorUnnotified(), UNNOTIFIED_INTERVAL) - setInterval(() => pi.sweepLiveHD(), SWEEP_LIVE_HD_INTERVAL) - setInterval(() => pi.sweepOldHD(), SWEEP_OLD_HD_INTERVAL) + setInterval(() => cashOutTx.monitorLiveIncoming(settings), LIVE_INCOMING_TX_INTERVAL) + setInterval(() => cashOutTx.monitorStaleIncoming(settings), INCOMING_TX_INTERVAL) + setInterval(() => cashOutTx.monitorUnnotified(settings), UNNOTIFIED_INTERVAL) + setInterval(() => pi.sweepHd(), SWEEP_HD_INTERVAL) setInterval(() => pi.pong(), PONG_INTERVAL) setInterval(() => pi.pongClear(), PONG_CLEAR_INTERVAL) setInterval(() => notifier.checkNotification(pi), CHECK_NOTIFICATION_INTERVAL) diff --git a/lib/postgresql_interface.js b/lib/postgresql_interface.js index f295b27f..53c0a1af 100644 --- a/lib/postgresql_interface.js +++ b/lib/postgresql_interface.js @@ -1,17 +1,4 @@ -// @flow weak -'use strict' - -const BigNumber = require('bignumber.js') const db = require('./db') -const pgp = require('pg-promise')() - -const logger = require('./logger') - -const LIVE_SWEEP_TTL = 48 * 60 * 60 * 1000 - -function isUniqueViolation (err) { - return err.code === '23505' -} function getInsertQuery (tableName, fields) { // outputs string like: '$1, $2, $3...' with proper No of items @@ -27,39 +14,6 @@ function getInsertQuery (tableName, fields) { return query } -// logs inputted bill and overall tx status (if available) -exports.recordBill = function recordBill (deviceId, rec) { - const fields = [ - 'id', - 'device_id', - 'currency_code', - 'crypto_code', - 'to_address', - 'cash_in_txs_id', - 'device_time', - 'crypto_atoms', - 'denomination' - ] - - const values = [ - rec.uuid, - deviceId, - rec.fiatCode, - rec.cryptoCode, - rec.toAddress, - rec.txId, - rec.deviceTime, - rec.cryptoAtoms.toString(), - rec.fiat - ] - - return db.none(getInsertQuery('bills', fields), values) - .catch(err => { - if (isUniqueViolation(err)) return logger.warn('Attempt to report bill twice') - throw err - }) -} - exports.recordDeviceEvent = function recordDeviceEvent (deviceId, event) { const sql = 'INSERT INTO device_events (device_id, event_type, ' + 'note, device_time) VALUES ($1, $2, $3, $4)' @@ -69,170 +23,6 @@ exports.recordDeviceEvent = function recordDeviceEvent (deviceId, event) { return db.none(sql, values) } -// NOTE: This will fail if we have already sent coins because there will be -// a unique cash_in_txs record in the table already keyed by txId. -exports.addOutgoingTx = function addOutgoingTx (deviceId, tx) { - const fields = ['id', 'device_id', 'to_address', - 'crypto_atoms', 'crypto_code', 'currency_code', 'fiat', 'tx_hash', - 'fee', 'phone', 'error' - ] - - const values = [ - tx.id, - deviceId, - tx.toAddress, - tx.cryptoAtoms.toString(), - tx.cryptoCode, - tx.fiatCode, - tx.fiat, - tx.txHash, - null, - tx.phone, - tx.error - ] - - return db.none(getInsertQuery('cash_in_txs', fields), values) -} - -exports.sentCoins = function sentCoins (tx, toSend, fee, error, txHash) { - const sql = 'update cash_in_txs set tx_hash=$1, error=$2 where id=$3' - return db.none(sql, [txHash, error, tx.id]) -} - -exports.addInitialIncoming = function addInitialIncoming (deviceId, tx) { - const fields = ['id', 'device_id', 'to_address', - 'crypto_atoms', 'crypto_code', 'currency_code', 'fiat', 'tx_hash', - 'phone', 'error' - ] - - const values = [ - tx.id, - deviceId, - tx.toAddress, - tx.cryptoAtoms.toString(), - tx.cryptoCode, - tx.fiatCode, - tx.fiat, - tx.txHash, - tx.phone, - tx.error - ] - - return db.none(getInsertQuery('cash_out_txs', fields), values) -} - -function insertDispense (deviceId, tx, cartridges) { - const fields = [ - 'device_id', 'cash_out_txs_id', - 'dispense1', 'reject1', - 'dispense2', 'reject2', 'error' - ] - - const sql = getInsertQuery('dispenses', fields) - - const dispense1 = tx.bills[0].actualDispense - const dispense2 = tx.bills[1].actualDispense - const reject1 = tx.bills[0].rejected - const reject2 = tx.bills[1].rejected - const values = [ - deviceId, tx.id, - dispense1, reject1, dispense2, reject2, - false, tx.error - ] - - const sql2 = `update devices set cassette1=cassette1-$1, cassette2=cassette2-$2 - where device_id=$3` - - const pulled1 = dispense1 + reject1 - const pulled2 = dispense2 + reject2 - - return db.none(sql, values) - .then(() => db.none(sql2, [pulled1, pulled2, deviceId])) -} - -exports.addIncomingPhone = function addIncomingPhone (tx, notified) { - const sql = `UPDATE cash_out_txs SET phone=$1, notified=$2 - WHERE id=$3 - AND phone IS NULL` - const values = [tx.phone, notified, tx.id] - - return db.result(sql, values) - .then(results => { - const noPhone = results.rowCount === 0 - const sql2 = 'insert into cash_out_actions (cash_out_txs_id, action) values ($1, $2)' - - if (noPhone) return {noPhone: noPhone} - - return db.none(sql2, [tx.id, 'addedPhone']) - .then(() => ({noPhone: noPhone})) - }) -} - -function normalizeTx (tx) { - tx.toAddress = tx.to_address - tx.fiatCode = tx.currency_code - tx.txHash = tx.tx_hash - tx.cryptoCode = tx.crypto_code - tx.cryptoAtoms = new BigNumber(tx.crypto_atoms) - - tx.to_address = undefined - tx.currency_code = undefined - tx.tx_hash = undefined - tx.crypto_code = undefined - - // Eventually turn this into BigDecimal, for now, integer - tx.fiat = parseInt(tx.fiat, 10) - - return tx -} - -function normalizeTxs (txs) { - return txs.map(normalizeTx) -} - -exports.fetchPhoneTxs = function fetchPhoneTxs (phone, dispenseTimeout) { - const sql = 'SELECT * FROM cash_out_txs ' + - 'WHERE phone=$1 AND dispensed=$2 ' + - 'AND (EXTRACT(EPOCH FROM (COALESCE(confirmation_time, now()) - created))) * 1000 < $3' - - const values = [phone, false, dispenseTimeout] - - return db.any(sql, values) - .then(rows => normalizeTxs(rows)) -} - -exports.fetchTx = function fetchTx (txId) { - const sql = 'SELECT * FROM cash_out_txs WHERE id=$1' - - return db.one(sql, [txId]) - .then(row => normalizeTx(row)) -} - -exports.addDispenseRequest = function addDispenseRequest (tx) { - const sql = 'update cash_out_txs set dispensed=$1 where id=$2 and dispensed=$3' - const values = [true, tx.id, false] - - return db.result(sql, values) - .then(results => { - const alreadyDispensed = results.rowCount === 0 - if (alreadyDispensed) return {dispense: false, reason: 'alreadyDispensed'} - - const sql2 = 'insert into cash_out_actions (cash_out_txs_id, action) values ($1, $2)' - - return db.none(sql2, [tx.id, 'dispenseRequested']) - .then(() => ({dispense: true, txId: tx.id})) - }) -} - -exports.addDispense = function addDispense (deviceId, tx, cartridges) { - return insertDispense(deviceId, tx, cartridges) - .then(() => { - const sql2 = 'insert into cash_out_actions (cash_out_txs_id, action) values ($1, $2)' - - return db.none(sql2, [tx.id, 'dispensed']) - }) -} - exports.cartridgeCounts = function cartridgeCounts (deviceId) { const sql = 'SELECT cassette1, cassette2 FROM devices ' + 'WHERE device_id=$1' @@ -271,165 +61,3 @@ exports.machineEvents = function machineEvents () { return db.any(sql, []) } - -function singleQuotify (item) { return '\'' + item + '\'' } - -exports.fetchOpenTxs = function fetchOpenTxs (statuses, age) { - const _statuses = '(' + statuses.map(singleQuotify).join(',') + ')' - - const sql = 'SELECT * ' + - 'FROM cash_out_txs ' + - 'WHERE ((EXTRACT(EPOCH FROM (now() - created))) * 1000)<$1 ' + - 'AND status IN ' + _statuses - - return db.any(sql, [age]) - .then(rows => normalizeTxs(rows)) -} - -exports.fetchUnnotifiedTxs = function fetchUnnotifiedTxs (age, waitPeriod) { - const sql = `SELECT * - FROM cash_out_txs - WHERE ((EXTRACT(EPOCH FROM (now() - created))) * 1000)<$1 - AND notified=$2 AND dispensed=$3 - AND phone IS NOT NULL - AND status IN ('instant', 'confirmed') - AND (redeem=$4 OR ((EXTRACT(EPOCH FROM (now() - created))) * 1000)>$5)` - - return db.any(sql, [age, false, false, true, waitPeriod]) - .then(rows => normalizeTxs(rows)) -} - -function ratchetStatus (oldStatus, newStatus) { - const statusOrder = ['notSeen', 'published', 'rejected', - 'authorized', 'instant', 'confirmed'] - - if (oldStatus === newStatus) return oldStatus - if (newStatus === 'insufficientFunds') return newStatus - - const idx = Math.max(statusOrder.indexOf(oldStatus), statusOrder.indexOf(newStatus)) - return statusOrder[idx] -} - -exports.updateTxStatus = function updateTxStatus (tx, status) { - const TransactionMode = pgp.txMode.TransactionMode - const isolationLevel = pgp.txMode.isolationLevel - const tmSRD = new TransactionMode({tiLevel: isolationLevel.serializable}) - - function transaction (t) { - const sql = 'select status, confirmation_time from cash_out_txs where id=$1' - return t.one(sql, [tx.id]) - .then(row => { - const newStatus = ratchetStatus(row.status, status) - if (row.status === newStatus) return - - const setConfirmationTime = !row.confirmation_time && - (newStatus === 'instant' || newStatus === 'confirmed') - - const sql2 = setConfirmationTime - ? 'UPDATE cash_out_txs SET status=$1, confirmation_time=now() WHERE id=$2' - : 'UPDATE cash_out_txs SET status=$1 WHERE id=$2' - - const values2 = [newStatus, tx.id] - - return t.none(sql2, values2) - .then(() => ({status: newStatus})) - }) - } - - transaction.txMode = tmSRD - - // Note: don't worry about retrying failed transaction here - // It will be tried again on the next status check - return db.tx(transaction) - .then(r => { - if (!r) return - - const sql3 = 'insert into cash_out_actions (cash_out_txs_id, action) values ($1, $2)' - return db.none(sql3, [tx.id, r.status]) - .then(() => { - if (r.status === 'confirmed') { - const sql4 = 'update cash_out_hds set confirmed=true where id=$1' - return db.none(sql4, [tx.id]) - } - }) - }) -} - -exports.registerRedeem = function registerRedeem (txId) { - const sql = 'UPDATE cash_out_txs SET redeem=$1 WHERE id=$2' - const values = [true, txId] - - return db.none(sql, values) - .then(() => { - const sql2 = 'insert into cash_out_actions (cash_out_txs_id, action) values ($1, $2)' - return db.none(sql2, [txId, 'redeem']) - }) -} - -exports.updateNotify = function updateNotify (tx) { - const sql = 'UPDATE cash_out_txs SET notified=$1 WHERE id=$2' - const values = [true, tx.id] - - return db.none(sql, values) - .then(() => { - const sql2 = 'insert into cash_out_actions (cash_out_txs_id, action) values ($1, $2)' - return db.none(sql2, [tx.id, 'notified']) - }) -} - -exports.cacheResponse = function (deviceId, txId, path, method, body) { - const sql = `update cached_responses - set body=$1 - where device_id=$2 - and tx_id=$3 - and path=$4 - and method=$5` - - const values = [body, deviceId, txId, path, method] - - return db.none(sql, values) -} - -exports.nextCashOutSerialHD = function nextCashOutSerialHD (txId, cryptoCode) { - const sql = `select hd_serial from cash_out_hds - where crypto_code=$1 order by hd_serial desc limit 1` - - const attempt = () => db.oneOrNone(sql, [cryptoCode]) - .then(row => { - const serialNumber = row ? row.hd_serial + 1 : 0 - const fields2 = ['id', 'crypto_code', 'hd_serial'] - const sql2 = getInsertQuery('cash_out_hds', fields2) - const values2 = [txId, cryptoCode, serialNumber] - return db.none(sql2, values2) - .then(() => serialNumber) - }) - - // TODO: retry on failure - return attempt() -} - -exports.fetchLiveHD = function fetchLiveHD () { - const sql = `select * from cash_out_txs, cash_out_hds - where cash_out_txs.id=cash_out_hds.id - and status=$1 and swept=$2 and - ((extract(epoch from (now() - cash_out_txs.created))) * 1000)<$3` - - const values = ['confirmed', false, LIVE_SWEEP_TTL] - - return db.any(sql, values) -} - -exports.fetchOldHD = function fetchLiveHD () { - const sql = `select * from cash_out_hds - where confirmed - order by last_checked - limit 10` - - return db.any(sql) -} - -exports.markSwept = function markSwept (txId) { - const sql = 'update cash_out_hds set swept=$1 where id=$2' - - return db.none(sql, [true, txId]) -} diff --git a/lib/routes.js b/lib/routes.js index dc4cf994..3f869261 100644 --- a/lib/routes.js +++ b/lib/routes.js @@ -73,7 +73,6 @@ function poll (req, res, next) { response.idVerificationLimit = config.idVerificationLimit } - console.log('DEBUG22: %j', response) return res.json(response) }) .catch(next) @@ -225,9 +224,7 @@ function authorize (req, res, next) { .catch(next) } -const skip = options.logLevel === 'debug' -? () => false -: (req, res) => _.includes(req.path, ['/poll', '/state']) && res.statusCode === 200 +const skip = (req, res) => _.includes(req.path, ['/poll', '/state']) && res.statusCode === 200 const configRequiredRoutes = [ '/poll', @@ -309,7 +306,8 @@ function populateDeviceId (req, res, next) { function populateSettings (req, res, next) { const versionId = req.headers['config-version'] - logger.debug('versionId: %s', versionId) + + console.log('DEBUG300: %s', versionId) if (!versionId) { return settingsLoader.loadLatest() diff --git a/lib/wallet.js b/lib/wallet.js index 062a4094..9c1c8767 100644 --- a/lib/wallet.js +++ b/lib/wallet.js @@ -1,27 +1,38 @@ +const _ = require('lodash/fp') const mem = require('mem') +const HKDF = require('node-hkdf-sync') + const configManager = require('./config-manager') +const pify = require('pify') +const fs = pify(require('fs')) +const options = require('./options') const FETCH_INTERVAL = 5000 +const INSUFFICIENT_FUNDS_CODE = 570 +const INSUFFICIENT_FUNDS_NAME = 'InsufficientFunds' + +function httpError (msg, code) { + const err = new Error(msg) + err.name = 'HTTPError' + err.code = code || 500 + + return err +} + +function computeSeed (masterSeed) { + const hkdf = new HKDF('sha256', 'lamassu-server-salt', masterSeed) + return hkdf.derive('wallet-seed', 32) +} function fetchWallet (settings, cryptoCode) { - return Promise.resolve() - .then(() => { - console.log('DEBUG44') - console.log('DEBUG44.0.0: %j', cryptoCode) - try { - console.log('DEBUG44.0: %j', configManager.cryptoScoped(cryptoCode, settings.config).wallet) - } catch (err) { - console.log('DEBUG44.0.e: %s', err.stack) - } + return fs.readFile(options.seedPath, 'utf8') + .then(hex => { + const masterSeed = Buffer.from(hex.trim(), 'hex') const plugin = configManager.cryptoScoped(cryptoCode, settings.config).wallet - console.log('DEBUG44.1') const account = settings.accounts[plugin] - console.log('DEBUG44.2') const wallet = require('lamassu-' + plugin) - console.log('DEBUG45: %j', {wallet, account}) - - return {wallet, account} + return {wallet, account: _.set('seed', computeSeed(masterSeed), account)} }) } @@ -44,11 +55,18 @@ function sendCoins (settings, toAddress, cryptoAtoms, cryptoCode) { return res }) }) + .catch(err => { + if (err.name === INSUFFICIENT_FUNDS_NAME) { + throw httpError(INSUFFICIENT_FUNDS_NAME, INSUFFICIENT_FUNDS_CODE) + } + + throw err + }) } -function newAddress (settings, cryptoCode, info) { - return fetchWallet(settings, cryptoCode) - .then(r => r.wallet.newAddress(r.account, cryptoCode, info)) +function newAddress (settings, info) { + return fetchWallet(settings, info.cryptoCode) + .then(r => r.wallet.newAddress(r.account, info)) } function getStatus (settings, toAddress, cryptoAtoms, cryptoCode) { @@ -56,9 +74,21 @@ function getStatus (settings, toAddress, cryptoAtoms, cryptoCode) { .then(r => r.wallet.getStatus(r.account, toAddress, cryptoAtoms, cryptoCode)) } +function sweep (settings, cryptoCode, hdIndex) { + return fetchWallet(settings, cryptoCode) + .then(r => r.wallet.sweep(r.account, cryptoCode, hdIndex)) +} + +function isHd (settings, cryptoCode) { + return fetchWallet(settings, cryptoCode) + .then(r => r.wallet.supportsHd) +} + module.exports = { balance: mem(balance, {maxAge: FETCH_INTERVAL}), sendCoins, newAddress, - getStatus + getStatus, + sweep, + isHd } diff --git a/migrations/024-consolidate-hd.js b/migrations/024-consolidate-hd.js new file mode 100644 index 00000000..aeb8333f --- /dev/null +++ b/migrations/024-consolidate-hd.js @@ -0,0 +1,23 @@ +var db = require('./db') + +exports.up = function (next) { + var sql = [ + 'create sequence hd_indices_seq minvalue 0 maxvalue 2147483647', + 'alter table cash_out_txs add column hd_index integer', + 'alter sequence hd_indices_seq owned by cash_out_txs.hd_index', + "alter table cash_out_txs add column swept boolean not null default 'f'", + 'alter table cash_out_txs drop column tx_hash', + 'create unique index on cash_out_txs (hd_index)', + 'drop table cash_out_hds', + 'drop table cash_out_actions', + 'drop table transactions', + 'drop table idempotents', + 'drop table machine_configs', + 'drop table pending_transactions' + ] + db.multi(sql, next) +} + +exports.down = function (next) { + next() +} diff --git a/migrations/025-create_trades.js b/migrations/025-create_trades.js new file mode 100644 index 00000000..9aeaa5e1 --- /dev/null +++ b/migrations/025-create_trades.js @@ -0,0 +1,20 @@ +var db = require('./db') + +exports.up = function (next) { + var sql = [ + "create type trade_type as enum ('buy', 'sell')", + `create table trades ( + id serial PRIMARY KEY, + type trade_type not null, + crypto_code text not null, + crypto_atoms bigint not null, + fiat_code text not null, + created timestamptz NOT NULL default now() + )` + ] + db.multi(sql, next) +} + +exports.down = function (next) { + next() +} diff --git a/package.json b/package.json index 55d4c0ca..f6ca5a0a 100644 --- a/package.json +++ b/package.json @@ -33,8 +33,8 @@ "node-hkdf-sync": "^1.0.0", "numeral": "^2.0.3", "pg": "^6.1.2", - "pg-native": "^1.10.0", - "pg-promise": "^5.5.0", + "pg-native": "latest", + "pg-promise": "^5.6.4", "pify": "^2.3.0", "pretty-ms": "^2.1.0", "ramda": "^0.22.1", diff --git a/public/elm.js b/public/elm.js index fcd9713f..6bd3420d 100644 --- a/public/elm.js +++ b/public/elm.js @@ -26920,6 +26920,8 @@ var _user$project$Css_Admin$className = function ($class) { return A2(_rtfeldman$elm_css_util$Css_Helpers$identifierToString, _user$project$Css_Admin$name, $class); }; +var _user$project$Css_Classes$CashIn = {ctor: 'CashIn'}; +var _user$project$Css_Classes$CashOut = {ctor: 'CashOut'}; var _user$project$Css_Classes$QrCode = {ctor: 'QrCode'}; var _user$project$Css_Classes$TxAddress = {ctor: 'TxAddress'}; var _user$project$Css_Classes$TxPhone = {ctor: 'TxPhone'}; @@ -32711,9 +32713,7 @@ var _user$project$TransactionTypes$CashOutTxRec = function (a) { return function (m) { return function (n) { return function (o) { - return function (p) { - return {id: a, machineName: b, toAddress: c, cryptoAtoms: d, cryptoCode: e, fiat: f, fiatCode: g, txHash: h, status: i, dispensed: j, notified: k, redeemed: l, phone: m, error: n, created: o, confirmed: p}; - }; + return {id: a, machineName: b, toAddress: c, cryptoAtoms: d, cryptoCode: e, fiat: f, fiatCode: g, status: h, dispensed: i, notified: j, redeemed: k, phone: l, error: m, created: n, confirmed: o}; }; }; }; @@ -32841,37 +32841,33 @@ var _user$project$TransactionDecoder$cashOutTxDecoder = A3( _elm_lang$core$Json_Decode$string, A3( _NoRedInk$elm_decode_pipeline$Json_Decode_Pipeline$required, - 'txHash', - _elm_lang$core$Json_Decode$nullable(_elm_lang$core$Json_Decode$string), + 'fiatCode', + _elm_lang$core$Json_Decode$string, A3( _NoRedInk$elm_decode_pipeline$Json_Decode_Pipeline$required, - 'fiatCode', - _elm_lang$core$Json_Decode$string, + 'fiat', + _user$project$TransactionDecoder$floatString, A3( _NoRedInk$elm_decode_pipeline$Json_Decode_Pipeline$required, - 'fiat', - _user$project$TransactionDecoder$floatString, + 'cryptoCode', + _elm_lang$core$Json_Decode$string, A3( _NoRedInk$elm_decode_pipeline$Json_Decode_Pipeline$required, - 'cryptoCode', - _elm_lang$core$Json_Decode$string, + 'cryptoAtoms', + _user$project$TransactionDecoder$intString, A3( _NoRedInk$elm_decode_pipeline$Json_Decode_Pipeline$required, - 'cryptoAtoms', - _user$project$TransactionDecoder$intString, + 'toAddress', + _elm_lang$core$Json_Decode$string, A3( _NoRedInk$elm_decode_pipeline$Json_Decode_Pipeline$required, - 'toAddress', + 'machineName', _elm_lang$core$Json_Decode$string, A3( _NoRedInk$elm_decode_pipeline$Json_Decode_Pipeline$required, - 'machineName', + 'id', _elm_lang$core$Json_Decode$string, - A3( - _NoRedInk$elm_decode_pipeline$Json_Decode_Pipeline$required, - 'id', - _elm_lang$core$Json_Decode$string, - _NoRedInk$elm_decode_pipeline$Json_Decode_Pipeline$decode(_user$project$TransactionTypes$CashOutTxRec))))))))))))))))); + _NoRedInk$elm_decode_pipeline$Json_Decode_Pipeline$decode(_user$project$TransactionTypes$CashOutTxRec)))))))))))))))); var _user$project$TransactionDecoder$txDecode = function (txClass) { var _p3 = txClass; switch (_p3) { @@ -32893,95 +32889,104 @@ var _user$project$TransactionDecoder$txsDecoder = A2( 'transactions', _elm_lang$core$Json_Decode$list(_user$project$TransactionDecoder$txDecoder)); +var _user$project$Transaction$multiplier = function (code) { + var _p0 = code; + switch (_p0) { + case 'BTC': + return 1.0e8; + case 'ETH': + return 1.0e18; + default: + return 1.0; + } +}; var _user$project$Transaction$rowView = function (tx) { - var _p0 = tx; - if (_p0.ctor === 'CashInTx') { - var _p1 = _p0._0; + var _p1 = tx; + if (_p1.ctor === 'CashInTx') { + var _p2 = _p1._0; return A2( _elm_lang$html$Html$tr, - {ctor: '[]'}, + { + ctor: '::', + _0: _user$project$Css_Admin$class( + { + ctor: '::', + _0: _user$project$Css_Classes$CashIn, + _1: {ctor: '[]'} + }), + _1: {ctor: '[]'} + }, { ctor: '::', _0: A2( _elm_lang$html$Html$td, + {ctor: '[]'}, { ctor: '::', - _0: _user$project$Css_Admin$class( - { - ctor: '::', - _0: _user$project$Css_Classes$NumberColumn, - _1: {ctor: '[]'} - }), - _1: {ctor: '[]'} - }, - { - ctor: '::', - _0: _elm_lang$html$Html$text( - A2(_justinmimbs$elm_date_extra$Date_Extra$toFormattedString, 'yyyy-MM-dd HH:mm', _p1.created)), + _0: _elm_lang$html$Html$text('Cash in'), _1: {ctor: '[]'} }), _1: { ctor: '::', _0: A2( _elm_lang$html$Html$td, - {ctor: '[]'}, { ctor: '::', - _0: _elm_lang$html$Html$text(_p1.machineName), + _0: _user$project$Css_Admin$class( + { + ctor: '::', + _0: _user$project$Css_Classes$NumberColumn, + _1: {ctor: '[]'} + }), + _1: {ctor: '[]'} + }, + { + ctor: '::', + _0: _elm_lang$html$Html$text( + A2(_justinmimbs$elm_date_extra$Date_Extra$toFormattedString, 'yyyy-MM-dd HH:mm', _p2.created)), _1: {ctor: '[]'} }), _1: { ctor: '::', _0: A2( _elm_lang$html$Html$td, + {ctor: '[]'}, { ctor: '::', - _0: _user$project$Css_Admin$class( - { - ctor: '::', - _0: _user$project$Css_Classes$NumberColumn, - _1: {ctor: '[]'} - }), - _1: {ctor: '[]'} - }, - { - ctor: '::', - _0: _elm_lang$html$Html$text( - A2( - _ggb$numeral_elm$Numeral$format, - '0,0.000000', - _elm_lang$core$Basics$negate( - _elm_lang$core$Basics$toFloat(_p1.cryptoAtoms)) / 1.0e8)), + _0: _elm_lang$html$Html$text(_p2.machineName), _1: {ctor: '[]'} }), _1: { ctor: '::', _0: A2( _elm_lang$html$Html$td, - {ctor: '[]'}, { ctor: '::', - _0: _elm_lang$html$Html$text(_p1.cryptoCode), + _0: _user$project$Css_Admin$class( + { + ctor: '::', + _0: _user$project$Css_Classes$NumberColumn, + _1: {ctor: '[]'} + }), + _1: {ctor: '[]'} + }, + { + ctor: '::', + _0: _elm_lang$html$Html$text( + A2( + _ggb$numeral_elm$Numeral$format, + '0,0.000000', + _elm_lang$core$Basics$toFloat(_p2.cryptoAtoms) / _user$project$Transaction$multiplier(_p2.cryptoCode))), _1: {ctor: '[]'} }), _1: { ctor: '::', _0: A2( _elm_lang$html$Html$td, + {ctor: '[]'}, { ctor: '::', - _0: _user$project$Css_Admin$class( - { - ctor: '::', - _0: _user$project$Css_Classes$NumberColumn, - _1: {ctor: '[]'} - }), - _1: {ctor: '[]'} - }, - { - ctor: '::', - _0: _elm_lang$html$Html$text( - A2(_ggb$numeral_elm$Numeral$format, '0,0.00', _p1.fiat)), + _0: _elm_lang$html$Html$text(_p2.cryptoCode), _1: {ctor: '[]'} }), _1: { @@ -33001,7 +33006,7 @@ var _user$project$Transaction$rowView = function (tx) { { ctor: '::', _0: _elm_lang$html$Html$text( - A2(_elm_lang$core$Maybe$withDefault, '', _p1.phone)), + A2(_ggb$numeral_elm$Numeral$format, '0,0.00', _p2.fiat)), _1: {ctor: '[]'} }), _1: { @@ -33013,17 +33018,38 @@ var _user$project$Transaction$rowView = function (tx) { _0: _user$project$Css_Admin$class( { ctor: '::', - _0: _user$project$Css_Classes$TxAddress, + _0: _user$project$Css_Classes$NumberColumn, _1: {ctor: '[]'} }), _1: {ctor: '[]'} }, { ctor: '::', - _0: _elm_lang$html$Html$text(_p1.toAddress), + _0: _elm_lang$html$Html$text( + A2(_elm_lang$core$Maybe$withDefault, '', _p2.phone)), _1: {ctor: '[]'} }), - _1: {ctor: '[]'} + _1: { + ctor: '::', + _0: A2( + _elm_lang$html$Html$td, + { + ctor: '::', + _0: _user$project$Css_Admin$class( + { + ctor: '::', + _0: _user$project$Css_Classes$TxAddress, + _1: {ctor: '[]'} + }), + _1: {ctor: '[]'} + }, + { + ctor: '::', + _0: _elm_lang$html$Html$text(_p2.toAddress), + _1: {ctor: '[]'} + }), + _1: {ctor: '[]'} + } } } } @@ -33032,98 +33058,94 @@ var _user$project$Transaction$rowView = function (tx) { } }); } else { - var _p2 = _p0._0; + var _p3 = _p1._0; return A2( _elm_lang$html$Html$tr, - {ctor: '[]'}, + { + ctor: '::', + _0: _user$project$Css_Admin$class( + { + ctor: '::', + _0: _user$project$Css_Classes$CashOut, + _1: {ctor: '[]'} + }), + _1: {ctor: '[]'} + }, { ctor: '::', _0: A2( _elm_lang$html$Html$td, + {ctor: '[]'}, { ctor: '::', - _0: _user$project$Css_Admin$class( - { - ctor: '::', - _0: _user$project$Css_Classes$NumberColumn, - _1: { - ctor: '::', - _0: _user$project$Css_Classes$DateColumn, - _1: {ctor: '[]'} - } - }), - _1: {ctor: '[]'} - }, - { - ctor: '::', - _0: _elm_lang$html$Html$text( - A2(_justinmimbs$elm_date_extra$Date_Extra$toFormattedString, 'yyyy-MM-dd HH:mm', _p2.created)), + _0: _elm_lang$html$Html$text('Cash out'), _1: {ctor: '[]'} }), _1: { ctor: '::', _0: A2( _elm_lang$html$Html$td, - {ctor: '[]'}, { ctor: '::', - _0: _elm_lang$html$Html$text(_p2.machineName), + _0: _user$project$Css_Admin$class( + { + ctor: '::', + _0: _user$project$Css_Classes$NumberColumn, + _1: { + ctor: '::', + _0: _user$project$Css_Classes$DateColumn, + _1: {ctor: '[]'} + } + }), + _1: {ctor: '[]'} + }, + { + ctor: '::', + _0: _elm_lang$html$Html$text( + A2(_justinmimbs$elm_date_extra$Date_Extra$toFormattedString, 'yyyy-MM-dd HH:mm', _p3.created)), _1: {ctor: '[]'} }), _1: { ctor: '::', _0: A2( _elm_lang$html$Html$td, + {ctor: '[]'}, { ctor: '::', - _0: _user$project$Css_Admin$class( - { - ctor: '::', - _0: _user$project$Css_Classes$NumberColumn, - _1: {ctor: '[]'} - }), - _1: {ctor: '[]'} - }, - { - ctor: '::', - _0: _elm_lang$html$Html$text( - A2( - _ggb$numeral_elm$Numeral$format, - '0,0.000000', - _elm_lang$core$Basics$toFloat(_p2.cryptoAtoms) / 1.0e8)), + _0: _elm_lang$html$Html$text(_p3.machineName), _1: {ctor: '[]'} }), _1: { ctor: '::', _0: A2( _elm_lang$html$Html$td, - {ctor: '[]'}, { ctor: '::', - _0: _elm_lang$html$Html$text(_p2.cryptoCode), + _0: _user$project$Css_Admin$class( + { + ctor: '::', + _0: _user$project$Css_Classes$NumberColumn, + _1: {ctor: '[]'} + }), + _1: {ctor: '[]'} + }, + { + ctor: '::', + _0: _elm_lang$html$Html$text( + A2( + _ggb$numeral_elm$Numeral$format, + '0,0.000000', + _elm_lang$core$Basics$toFloat(_p3.cryptoAtoms) / _user$project$Transaction$multiplier(_p3.cryptoCode))), _1: {ctor: '[]'} }), _1: { ctor: '::', _0: A2( _elm_lang$html$Html$td, + {ctor: '[]'}, { ctor: '::', - _0: _user$project$Css_Admin$class( - { - ctor: '::', - _0: _user$project$Css_Classes$NumberColumn, - _1: {ctor: '[]'} - }), - _1: {ctor: '[]'} - }, - { - ctor: '::', - _0: _elm_lang$html$Html$text( - A2( - _ggb$numeral_elm$Numeral$format, - '0,0.00', - _elm_lang$core$Basics$negate(_p2.fiat))), + _0: _elm_lang$html$Html$text(_p3.cryptoCode), _1: {ctor: '[]'} }), _1: { @@ -33143,7 +33165,7 @@ var _user$project$Transaction$rowView = function (tx) { { ctor: '::', _0: _elm_lang$html$Html$text( - A2(_elm_lang$core$Maybe$withDefault, '', _p2.phone)), + A2(_ggb$numeral_elm$Numeral$format, '0,0.00', _p3.fiat)), _1: {ctor: '[]'} }), _1: { @@ -33155,17 +33177,38 @@ var _user$project$Transaction$rowView = function (tx) { _0: _user$project$Css_Admin$class( { ctor: '::', - _0: _user$project$Css_Classes$TxAddress, + _0: _user$project$Css_Classes$NumberColumn, _1: {ctor: '[]'} }), _1: {ctor: '[]'} }, { ctor: '::', - _0: _elm_lang$html$Html$text(_p2.toAddress), + _0: _elm_lang$html$Html$text( + A2(_elm_lang$core$Maybe$withDefault, '', _p3.phone)), _1: {ctor: '[]'} }), - _1: {ctor: '[]'} + _1: { + ctor: '::', + _0: A2( + _elm_lang$html$Html$td, + { + ctor: '::', + _0: _user$project$Css_Admin$class( + { + ctor: '::', + _0: _user$project$Css_Classes$TxAddress, + _1: {ctor: '[]'} + }), + _1: {ctor: '[]'} + }, + { + ctor: '::', + _0: _elm_lang$html$Html$text(_p3.toAddress), + _1: {ctor: '[]'} + }), + _1: {ctor: '[]'} + } } } } @@ -33209,21 +33252,8 @@ var _user$project$Transaction$tableView = function (txs) { ctor: '::', _0: A2( _elm_lang$html$Html$td, - { - ctor: '::', - _0: _user$project$Css_Admin$class( - { - ctor: '::', - _0: _user$project$Css_Classes$TxDate, - _1: {ctor: '[]'} - }), - _1: {ctor: '[]'} - }, - { - ctor: '::', - _0: _elm_lang$html$Html$text('Time'), - _1: {ctor: '[]'} - }), + {ctor: '[]'}, + {ctor: '[]'}), _1: { ctor: '::', _0: A2( @@ -33233,14 +33263,14 @@ var _user$project$Transaction$tableView = function (txs) { _0: _user$project$Css_Admin$class( { ctor: '::', - _0: _user$project$Css_Classes$TxMachine, + _0: _user$project$Css_Classes$TxDate, _1: {ctor: '[]'} }), _1: {ctor: '[]'} }, { ctor: '::', - _0: _elm_lang$html$Html$text('Machine'), + _0: _elm_lang$html$Html$text('Time'), _1: {ctor: '[]'} }), _1: { @@ -33249,12 +33279,17 @@ var _user$project$Transaction$tableView = function (txs) { _elm_lang$html$Html$td, { ctor: '::', - _0: _elm_lang$html$Html_Attributes$colspan(2), + _0: _user$project$Css_Admin$class( + { + ctor: '::', + _0: _user$project$Css_Classes$TxMachine, + _1: {ctor: '[]'} + }), _1: {ctor: '[]'} }, { ctor: '::', - _0: _elm_lang$html$Html$text('Crypto'), + _0: _elm_lang$html$Html$text('Machine'), _1: {ctor: '[]'} }), _1: { @@ -33263,17 +33298,12 @@ var _user$project$Transaction$tableView = function (txs) { _elm_lang$html$Html$td, { ctor: '::', - _0: _user$project$Css_Admin$class( - { - ctor: '::', - _0: _user$project$Css_Classes$TxAmount, - _1: {ctor: '[]'} - }), + _0: _elm_lang$html$Html_Attributes$colspan(2), _1: {ctor: '[]'} }, { ctor: '::', - _0: _elm_lang$html$Html$text('Fiat'), + _0: _elm_lang$html$Html$text('Crypto'), _1: {ctor: '[]'} }), _1: { @@ -33285,14 +33315,14 @@ var _user$project$Transaction$tableView = function (txs) { _0: _user$project$Css_Admin$class( { ctor: '::', - _0: _user$project$Css_Classes$TxPhone, + _0: _user$project$Css_Classes$TxAmount, _1: {ctor: '[]'} }), _1: {ctor: '[]'} }, { ctor: '::', - _0: _elm_lang$html$Html$text('Phone'), + _0: _elm_lang$html$Html$text('Fiat'), _1: {ctor: '[]'} }), _1: { @@ -33304,17 +33334,37 @@ var _user$project$Transaction$tableView = function (txs) { _0: _user$project$Css_Admin$class( { ctor: '::', - _0: _user$project$Css_Classes$TxAddress, + _0: _user$project$Css_Classes$TxPhone, _1: {ctor: '[]'} }), _1: {ctor: '[]'} }, { ctor: '::', - _0: _elm_lang$html$Html$text('To address'), + _0: _elm_lang$html$Html$text('Phone'), _1: {ctor: '[]'} }), - _1: {ctor: '[]'} + _1: { + ctor: '::', + _0: A2( + _elm_lang$html$Html$td, + { + ctor: '::', + _0: _user$project$Css_Admin$class( + { + ctor: '::', + _0: _user$project$Css_Classes$TxAddress, + _1: {ctor: '[]'} + }), + _1: {ctor: '[]'} + }, + { + ctor: '::', + _0: _elm_lang$html$Html$text('To address'), + _1: {ctor: '[]'} + }), + _1: {ctor: '[]'} + } } } } @@ -33334,8 +33384,8 @@ var _user$project$Transaction$tableView = function (txs) { }); }; var _user$project$Transaction$view = function (model) { - var _p3 = model; - switch (_p3.ctor) { + var _p4 = model; + switch (_p4.ctor) { case 'NotAsked': return A2( _elm_lang$html$Html$div, @@ -33357,7 +33407,7 @@ var _user$project$Transaction$view = function (model) { { ctor: '::', _0: _elm_lang$html$Html$text( - _elm_lang$core$Basics$toString(_p3._0)), + _elm_lang$core$Basics$toString(_p4._0)), _1: {ctor: '[]'} }); default: @@ -33366,17 +33416,17 @@ var _user$project$Transaction$view = function (model) { {ctor: '[]'}, { ctor: '::', - _0: _user$project$Transaction$tableView(_p3._0), + _0: _user$project$Transaction$tableView(_p4._0), _1: {ctor: '[]'} }); } }; var _user$project$Transaction$update = F2( function (msg, model) { - var _p4 = msg; + var _p5 = msg; return A2( _elm_lang$core$Platform_Cmd_ops['!'], - _p4._0, + _p5._0, {ctor: '[]'}); }); var _user$project$Transaction$init = _krisajenkins$remotedata$RemoteData$NotAsked; diff --git a/public/styles.css b/public/styles.css index 213932a5..5eda75b2 100644 --- a/public/styles.css +++ b/public/styles.css @@ -33,6 +33,10 @@ p { width: 100%; } +.lamassuAdminCashOut { + background-color: #f6f6f4; +} + .lamassuAdminFormRow { margin: 20px 0; } diff --git a/todo.txt b/todo.txt index ffdf626f..d7982763 100644 --- a/todo.txt +++ b/todo.txt @@ -1,126 +1 @@ -- l-m shouldn't keep polling l-s when not on pending screen (low priority) - -- scrutinize hkdf, maybe use own simplified version -- test bitcoind cash-out -- test eth cash out, check that HD is still working -- throttle status check for 3rd services like bitgo - -- load stuff from master config file -- add default configuration - -- either remove lamassu-config or fix link in package.json - ------------------------- - -schemas: - -- global crypto / global machine -- global crypto / specific machine -- specific crypto / global machine -- specific crypto / specific machine - -- We'll have one group for config that applies to all 4 -- Machine config applies to only global crypto (machine config screen) -- Crypto config applies to only specific crypto - -v update migrate-config to match lamassu.json schema -- update machine name - ---------------------------- - -- are plugins loaded globally or per machine? -- list all used plugins: - ticker, trade, wallet, info (?), idVerifier, email, sms - - -- wallet doesn't care about fiat - -- look into how each plugin is used - - info plugin not used - -- need either transitive closure of all cryptos across machines, - or add new plugin when needed -- currently we're looking at all cryptos, so this is probably easier - - -- different machines can have different coins, currencies, etc - - necessitates different plugins - -- do plugins require initialization and state? - probably not - -- require caches, so probably not big penalty to require whenever a plugin is needed - -- essentially, plugins just need their account info, plus some additional - special info, such as masterSeed, fiatCurrencies, etc - -- deviceCurrency needs to be fixed. currently we're assuming one currency - globally - -load ticker plugin (not really, remove this), pollRate (can supply actual deviceCurrency), - purchase/trader (also device specific), consolidateTrades (see what calls this), - checkBalances (*check callee) - -* pollRate -- this sets lastRates which is keyed by crypto only, may need - to be keyed by fiat as well; probably better to fetch on demand, with throttle - see: https://github.com/sindresorhus/mem - -* consolidateTrades: one trader for each crypto, or separate per crypto/fiat? - does consolidateTrades need fiat currency at all? even so, shouldn't this - be in the trade record? - -example: operator may have machines in US and China; might want different exchanges - for china machines; same for ticker - -options: configure per machine; configure per crypto/fiat - - crypto/fiat requires more admin dev work design - - also restricts configurability - - but global ticker/exchange doesn't make sense if fiats are different - - which means it would be required to define ticker/exchange for each - machine, even if they all have the same crypto/fiat - -* checkBalances: used for notifier, to indicate if balances are too low; - how to do this generalized across all machines/currencies? - -* possibly configure home currency for admin; probably a good idea for - transactions, statistics, etc - --> For now, keep it as is -- one exchange per crypto, see how we handle fiat - currencies now. (passed in on trade -- we assume that we're using machine currency, which may not be true) - -- consider defining exchange fiat currency in exchange account config, we don't pass in fiat amount, anyway - ------------------------------ - -- default values -- server side validation, including required - - need to think hard about how to do required checks for scopes -- what to do if validation fails? - -- need to rethink cachedConfig, don't use global variables [later] - -- cartridge counts -- where to store? already in db, not ideal but can fix later - -- twoWayMode should be per crypto - -- add cassette count handling in machines/actions in admin - --------------------------------- - -v need to create CA: http://stackoverflow.com/questions/19665863/how-do-i-use-a-self-signed-certificate-for-a-https-node-js-server - --------------------------------- - -v consistent error handling -v usage of http status codes (good for got) -v finish idempotency for all calls - -------------- - -- test pending action (action needs to take a while so we can test) -- defaults and validation -- tweak install script - ---------------- - -- clean up lamassu-server script, app, routes +- migrate rest of postgresql_interface diff --git a/yarn.lock b/yarn.lock index 1f554abf..0cdc410b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3421,7 +3421,7 @@ pg-minify@0.4: version "0.4.1" resolved "https://registry.yarnpkg.com/pg-minify/-/pg-minify-0.4.1.tgz#a642c6bd256c7da833066590b1e414334a1f6e19" -pg-native@^1.10.0: +pg-native@latest: version "1.10.0" resolved "https://registry.yarnpkg.com/pg-native/-/pg-native-1.10.0.tgz#abe299214afa2be51db5f5104e14770c738230fd" dependencies: @@ -3436,14 +3436,14 @@ pg-pool@1.*: generic-pool "2.4.2" object-assign "4.1.0" -pg-promise@^5.5.0: - version "5.5.0" - resolved "https://registry.yarnpkg.com/pg-promise/-/pg-promise-5.5.0.tgz#a89c7e25e8695c343a51f7821d4e16bb5f46d5cc" +pg-promise@^5.6.4: + version "5.6.4" + resolved "https://registry.yarnpkg.com/pg-promise/-/pg-promise-5.6.4.tgz#80b18a2a1bdd9af7fb0087e01a1b87ada8559a71" dependencies: manakin "0.4" pg "5.1" pg-minify "0.4" - spex "1.1" + spex "1.2" pg-types@1.*, pg-types@1.6.0: version "1.6.0" @@ -4266,9 +4266,9 @@ spdx-license-ids@^1.0.2: version "1.2.2" resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-1.2.2.tgz#c9df7a3424594ade6bd11900d596696dc06bac57" -spex@1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/spex/-/spex-1.1.1.tgz#ceede36b128e7dcb26100b89e2b049a5f4477d50" +spex@1.2: + version "1.2.0" + resolved "https://registry.yarnpkg.com/spex/-/spex-1.2.0.tgz#6264b3b8acbc444477f06dbb66d425c0ee1074c0" split@^1.0.0: version "1.0.0"