From 67e12b19cbdf5a19330e579c506792aea74bed11 Mon Sep 17 00:00:00 2001 From: Josh Harvey Date: Thu, 4 May 2017 21:27:12 +0300 Subject: [PATCH] Reserve notes for redeem --- .vscode/launch.json | 16 +++++ lib/cash-out-helper.js | 95 +++++++++++++++++++++++++ lib/cash-out-tx.js | 97 ++++++++++--------------- lib/notifier.js | 3 +- lib/plugins.js | 102 ++++++++++++++++++--------- migrations/030-cash-out-provision.js | 15 ++++ 6 files changed, 235 insertions(+), 93 deletions(-) create mode 100644 .vscode/launch.json create mode 100644 lib/cash-out-helper.js create mode 100644 migrations/030-cash-out-provision.js diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..f167992a --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,16 @@ +{ + // Use IntelliSense to learn about possible Node.js debug attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "node", + "request": "launch", + "name": "Launch Program", + "program": "${workspaceRoot}/bin/lamassu-server", + "cwd": "${workspaceRoot}", + "args": ["--mockSms"] + } + ] +} diff --git a/lib/cash-out-helper.js b/lib/cash-out-helper.js new file mode 100644 index 00000000..bff09505 --- /dev/null +++ b/lib/cash-out-helper.js @@ -0,0 +1,95 @@ +const _ = require('lodash/fp') + +const db = require('./db') +const T = require('./time') +const BN = require('./bn') + +const REDEEMABLE_AGE = T.day + +module.exports = {redeemableTxs, toObj, toDb} + +const mapValuesWithKey = _.mapValues.convert({cap: false}) + +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 convertField (key) { + return _.snakeCase(key) +} + +function addDbBills (tx) { + const bills = tx.bills + if (_.isEmpty(bills)) return tx + + return _.assign(tx, { + provisioned1: bills[0].provisioned, + provisioned2: bills[1].provisioned, + denomination1: bills[0].denomination, + denomination2: bills[1].denomination + }) +} + +function toDb (tx) { + const massager = _.flow(convertBigNumFields, addDbBills, + _.omit(['direction', 'bills']), _.mapKeys(convertField)) + + return massager(tx) +} + +function toObj (row) { + if (!row) return null + + const keys = _.keys(row) + let newObj = {} + + keys.forEach(key => { + const objKey = _.camelCase(key) + if (key === 'crypto_atoms' || key === 'fiat') { + newObj[objKey] = BN(row[key]) + return + } + + newObj[objKey] = row[key] + }) + + newObj.direction = 'cashOut' + + const billFields = ['denomination1', 'denomination2', 'provisioned1', 'provisioned2'] + + if (_.every(_.isNil, _.at(billFields, newObj))) return newObj + if (_.some(_.isNil, _.at(billFields, newObj))) throw new Error('Missing cassette values') + + const bills = [ + { + denomination: newObj.denomination1, + provisioned: newObj.provisioned1 + }, + { + denomination: newObj.denomination2, + provisioned: newObj.provisioned2 + } + ] + + return _.set('bills', bills, _.omit(billFields, newObj)) +} + +function redeemableTxs (deviceId) { + const sql = `select * from cash_out_txs + where device_id=$1 + and redeem=$2 + and dispense=$3 + and provisioned_1 is not null + and (extract(epoch from (now() - greatest(created, confirmation_time))) * 1000) < $4` + + return db.any(sql, [deviceId, true, false, REDEEMABLE_AGE]) + .then(_.map(toObj)) +} diff --git a/lib/cash-out-tx.js b/lib/cash-out-tx.js index 8cca541f..18ab00c6 100644 --- a/lib/cash-out-tx.js +++ b/lib/cash-out-tx.js @@ -2,11 +2,11 @@ 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') +const helper = require('./cash-out-helper') module.exports = { post, @@ -16,10 +16,8 @@ module.exports = { cancel } -const mapValuesWithKey = _.mapValues.convert({cap: false}) - -const UPDATEABLE_FIELDS = ['txHash', 'status', 'dispense', 'notified', 'redeem', - 'phone', 'error', 'swept'] +const UPDATEABLE_FIELDS = ['txHash', 'status', 'dispense', 'dispenseConfirmed', + 'notified', 'redeem', 'phone', 'error', 'swept'] const STALE_INCOMING_TX_AGE = T.week const STALE_LIVE_INCOMING_TX_AGE = 10 * T.minutes @@ -27,6 +25,9 @@ const MAX_NOTIFY_AGE = 2 * T.days const MIN_NOTIFY_AGE = 5 * T.minutes const INSUFFICIENT_FUNDS_CODE = 570 +const toObj = helper.toObj +const toDb = helper.toDb + function httpError (msg, code) { const err = new Error(msg) err.name = 'HTTPError' @@ -128,27 +129,6 @@ function diff (oldTx, newTx) { return updatedTx } -function toObj (row) { - if (!row) return null - - const keys = _.keys(row) - let newObj = {} - - keys.forEach(key => { - const objKey = _.camelCase(key) - if (key === 'crypto_atoms' || key === 'fiat') { - newObj[objKey] = BN(row[key]) - return - } - - newObj[objKey] = row[key] - }) - - newObj.direction = 'cashOut' - - return newObj -} - function upsert (oldTx, tx) { if (!oldTx) { return insert(tx) @@ -159,27 +139,6 @@ function upsert (oldTx, tx) { .then(newTx => [oldTx, newTx]) } -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 convertField (key) { - return _.snakeCase(key) -} - -function toDb (tx) { - const massager = _.flow(convertBigNumFields, _.omit(['direction', 'bills']), _.mapKeys(convertField)) - return massager(tx) -} - function insert (tx) { const dbTx = toDb(tx) @@ -191,7 +150,7 @@ function insert (tx) { function update (tx, changes) { if (_.isEmpty(changes)) return Promise.resolve(tx) - const dbChanges = toDb(tx) + const dbChanges = toDb(changes) const sql = pgp.helpers.update(dbChanges, null, 'cash_out_txs') + pgp.as.format(' where id=$1', [tx.id]) @@ -223,6 +182,16 @@ function updateCassettes (tx) { return db.none(sql, values) } +function wasJustAuthorized (oldTx, newTx, isZeroConf) { + const isAuthorized = () => _.includes(oldTx.status, ['notSeen', 'published']) && + _.includes(newTx.status, ['authorized', 'instant', 'confirmed']) + + const isConfirmed = () => _.includes(oldTx.status, ['notSeen', 'published', 'authorized']) && + _.includes(newTx.status, ['instant', 'confirmed']) + + return isZeroConf ? isAuthorized() : isConfirmed() +} + function preProcess (oldTx, newTx, pi) { if (!oldTx) { return pi.isHd(newTx) @@ -246,10 +215,14 @@ function preProcess (oldTx, newTx, pi) { if (!oldTx) return updatedTx if (updatedTx.status !== oldTx.status) { + const isZeroConf = pi.isZeroConf(updatedTx) + if (wasJustAuthorized(oldTx, updatedTx, isZeroConf)) pi.sell(updatedTx) + const rec = { to_address: updatedTx.toAddress, tx_hash: updatedTx.txHash } + return logAction(updatedTx.status, rec, updatedTx) } @@ -273,23 +246,29 @@ function preProcess (oldTx, newTx, pi) { function postProcess (txVector, pi) { const [oldTx, newTx] = txVector - if (newTx.dispense && !oldTx.dispense) { - return pi.buildCassettes() + if ((newTx.dispense && !oldTx.dispense) || (newTx.redeem && !oldTx.redeem)) { + return pi.buildAvailableCassettes(newTx.id) .then(cassettes => { - pi.sell(newTx) const bills = billMath.makeChange(cassettes.cassettes, newTx.fiat) + console.log('DEBUG130: %j', cassettes.cassettes) if (!bills) throw httpError('Out of bills', INSUFFICIENT_FUNDS_CODE) - return _.set('bills', bills, newTx) + return bills }) - .then(tx => { + .then(bills => { + const provisioned1 = bills[0].provisioned + const provisioned2 = bills[1].provisioned + const denomination1 = bills[0].denomination + const denomination2 = bills[1].denomination + const rec = { - provisioned_1: tx.bills[0].provisioned, - provisioned_2: tx.bills[1].provisioned, - denomination_1: tx.bills[0].denomination, - denomination_2: tx.bills[1].denomination + provisioned_1: provisioned1, + provisioned_2: provisioned2, + denomination_1: denomination1, + denomination_2: denomination2 } - return logAction('provisionNotes', rec, tx) + return logAction('provisionNotes', rec, newTx) + .then(_.constant({bills})) }) .catch(err => { return logError('provisionNotesError', err, newTx) @@ -297,7 +276,7 @@ function postProcess (txVector, pi) { }) } - return Promise.resolve(newTx) + return Promise.resolve({}) } function updateStatus (oldTx, newTx) { diff --git a/lib/notifier.js b/lib/notifier.js index e53b662a..48287a99 100644 --- a/lib/notifier.js +++ b/lib/notifier.js @@ -106,12 +106,11 @@ function checkPing (deviceEvents) { } function dropRepeatsWith (comparator, arr) { - var fullReduce = _.reduce.convert({cap: false}) const iteratee = (acc, val) => val === acc.last ? acc : {arr: _.concat(acc.arr, val), last: val} - return fullReduce(iteratee, {arr: []}, arr).arr + return _.reduce(iteratee, {arr: []}, arr).arr } function checkStuckScreen (deviceEvents) { diff --git a/lib/plugins.js b/lib/plugins.js index e9db8b28..fdf9c923 100644 --- a/lib/plugins.js +++ b/lib/plugins.js @@ -15,6 +15,7 @@ const wallet = require('./wallet') const exchange = require('./exchange') const sms = require('./sms') const email = require('./email') +const cashOutHelper = require('./cash-out-helper') const mapValuesWithKey = _.mapValues.convert({cap: false}) @@ -71,47 +72,83 @@ function plugins (settings, deviceId) { return balances } - function buildCassettes () { + function isZeroConf (tx) { + const config = configManager.scoped(tx.cryptoCode, deviceId, settings.config) + const zeroConfLimit = config.zeroConfLimit + return tx.fiat.lte(zeroConfLimit) + } + + function computeAvailableCassettes (cassettes, redeemableTxs) { + if (_.isEmpty(redeemableTxs)) return cassettes + + const sumTxs = (sum, tx) => { + const bills = tx.bills + const sameDenominations = a => a[0].denomination === a[1].denomination + const doDenominationsMatch = _.every(sameDenominations, _.zip(cassettes, bills)) + + if (!doDenominationsMatch) { + throw new Error('Denominations don\'t add up, cassettes were changed.') + } + + return _.map(r => r[0] + r[1].provisioned, _.zip(sum, tx.bills)) + } + + const provisioned = _.reduce(sumTxs, [0, 0], redeemableTxs) + const zipped = _.zip(_.map('count', cassettes), provisioned) + const counts = _.map(r => r[0] - r[1], zipped) + + if (_.some(_.lt(_, 0), counts)) { + throw new Error('Negative note count: %j', counts) + } + + return [ + { + denomination: cassettes[0].denomination, + count: counts[0] + }, + { + denomination: cassettes[1].denomination, + count: counts[1] + } + ] + } + + function buildAvailableCassettes (excludeTxId) { const config = configManager.machineScoped(deviceId, settings.config) if (!config.cashOutEnabled) return Promise.resolve() - const cassettes = [ config.topCashOutDenomination, + const denominations = [ config.topCashOutDenomination, config.bottomCashOutDenomination ] const virtualCassettes = [config.virtualCashOutDenomination] - return dbm.cassetteCounts(deviceId) - .then(rec => { - if (argv.cassettes) { - const counts = argv.cassettes.split(',') + return Promise.all([dbm.cassetteCounts(deviceId), cashOutHelper.redeemableTxs(deviceId, excludeTxId)]) + .then(([rec, _redeemableTxs]) => { + const redeemableTxs = _.reject(_.matchesProperty('id', excludeTxId), _redeemableTxs) + const counts = argv.cassettes + ? argv.cassettes.split(',') + : rec.counts + + const cassettes = [ + { + denomination: parseInt(denominations[0], 10), + count: parseInt(counts[0], 10) + }, + { + denomination: parseInt(denominations[1], 10), + count: parseInt(counts[1], 10) + } + ] + + try { return { - cassettes: [ - { - denomination: parseInt(cassettes[0], 10), - count: parseInt(counts[0], 10) - }, - { - denomination: parseInt(cassettes[1], 10), - count: parseInt(counts[1], 10) - } - ], + cassettes: computeAvailableCassettes(cassettes, redeemableTxs), virtualCassettes } - } - - return { - cassettes: [ - { - denomination: parseInt(cassettes[0], 10), - count: parseInt(rec.counts[0], 10) - }, - { - denomination: parseInt(cassettes[1], 10), - count: parseInt(rec.counts[1], 10) - } - ], - virtualCassettes + } catch (err) { + logger.error(err) + return {cassettes, virtualCassettes} } }) } @@ -138,7 +175,7 @@ function plugins (settings, deviceId) { const currentConfigVersionPromise = fetchCurrentConfigVersion() const promises = [ - buildCassettes(), + buildAvailableCassettes(), pingPromise, currentConfigVersionPromise ].concat(tickerPromises, balancePromises) @@ -512,6 +549,7 @@ function plugins (settings, deviceId) { sendCoins, newAddress, isHd, + isZeroConf, getStatus, dispenseAck, getPhoneCode, @@ -522,7 +560,7 @@ function plugins (settings, deviceId) { sweepHd, sendMessage, checkBalances, - buildCassettes, + buildAvailableCassettes, buy, sell } diff --git a/migrations/030-cash-out-provision.js b/migrations/030-cash-out-provision.js new file mode 100644 index 00000000..80b613b7 --- /dev/null +++ b/migrations/030-cash-out-provision.js @@ -0,0 +1,15 @@ +var db = require('./db') + +exports.up = function (next) { + var sql = [ + 'alter table cash_out_txs add column provisioned_1 integer', + 'alter table cash_out_txs add column provisioned_2 integer', + 'alter table cash_out_txs add column denomination_1 integer', + 'alter table cash_out_txs add column denomination_2 integer' + ] + db.multi(sql, next) +} + +exports.down = function (next) { + next() +}