refactor cash-in and cash-out
This commit is contained in:
parent
9f26aef790
commit
118e4ec4be
17 changed files with 791 additions and 750 deletions
|
|
@ -3,7 +3,7 @@ const _ = require('lodash/fp')
|
|||
const db = require('../db')
|
||||
const machineLoader = require('../machine-loader')
|
||||
const tx = require('../tx')
|
||||
const cashInTx = require('../cash-in-tx')
|
||||
const cashInTx = require('../cash-in/cash-in-tx')
|
||||
|
||||
const NUM_RESULTS = 20
|
||||
|
||||
|
|
|
|||
|
|
@ -1,327 +0,0 @@
|
|||
const _ = require('lodash/fp')
|
||||
const pgp = require('pg-promise')()
|
||||
const db = require('./db')
|
||||
const BN = require('./bn')
|
||||
const plugins = require('./plugins')
|
||||
const logger = require('./logger')
|
||||
const T = require('./time')
|
||||
const E = require('./error')
|
||||
|
||||
const PENDING_INTERVAL = '60 minutes'
|
||||
const PENDING_INTERVAL_MS = 60 * T.minutes
|
||||
const MAX_PENDING = 10
|
||||
|
||||
module.exports = {post, monitorPending, cancel, PENDING_INTERVAL}
|
||||
|
||||
function atomic (machineTx, pi) {
|
||||
const TransactionMode = pgp.txMode.TransactionMode
|
||||
const isolationLevel = pgp.txMode.isolationLevel
|
||||
const tmSRD = new TransactionMode({tiLevel: isolationLevel.serializable})
|
||||
|
||||
function transaction (t) {
|
||||
const sql = 'select * from cash_in_txs where id=$1'
|
||||
const sql2 = 'select * from bills where cash_in_txs_id=$1'
|
||||
|
||||
return t.oneOrNone(sql, [machineTx.id])
|
||||
.then(row => {
|
||||
if (row && row.tx_version >= machineTx.txVersion) throw new E.StaleTxError('Stale tx')
|
||||
|
||||
return t.any(sql2, [machineTx.id])
|
||||
.then(billRows => {
|
||||
const dbTx = toObj(row)
|
||||
|
||||
return preProcess(dbTx, machineTx, pi)
|
||||
.then(preProcessedTx => upsert(dbTx, preProcessedTx))
|
||||
.then(r => {
|
||||
return insertNewBills(billRows, machineTx)
|
||||
.then(newBills => _.set('newBills', newBills, r))
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
transaction.txMode = tmSRD
|
||||
|
||||
return transaction
|
||||
}
|
||||
|
||||
function post (machineTx, pi) {
|
||||
return db.tx(atomic(machineTx, pi))
|
||||
.then(r => {
|
||||
const updatedTx = r.tx
|
||||
|
||||
return postProcess(r, pi)
|
||||
.then(changes => update(updatedTx, changes))
|
||||
.then(tx => _.set('bills', machineTx.bills, tx))
|
||||
})
|
||||
}
|
||||
|
||||
function nilEqual (a, b) {
|
||||
if (_.isNil(a) && _.isNil(b)) return true
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function isMonotonic (oldField, newField, fieldKey) {
|
||||
if (_.isNil(newField)) return false
|
||||
if (_.isBoolean(oldField)) return oldField === newField || !oldField
|
||||
if (oldField.isBigNumber) return oldField.lte(newField)
|
||||
if (_.isNumber(oldField)) return oldField <= newField
|
||||
|
||||
throw new Error(`Unexpected value [${fieldKey}]: ${oldField}, ${newField}`)
|
||||
}
|
||||
|
||||
function ensureRatchet (oldField, newField, fieldKey) {
|
||||
const monotonic = ['cryptoAtoms', 'fiat', 'cashInFeeCrypto', 'send', 'sendConfirmed', 'operatorCompleted', 'timedout', 'txVersion']
|
||||
const free = ['sendPending', 'error', 'errorCode', 'customerId']
|
||||
|
||||
if (_.isNil(oldField)) return true
|
||||
|
||||
if (_.includes(fieldKey, monotonic)) return isMonotonic(oldField, newField, fieldKey)
|
||||
|
||||
if (_.includes(fieldKey, free)) {
|
||||
if (_.isNil(newField)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
if (_.isNil(newField)) return false
|
||||
if (oldField.isBigNumber && newField.isBigNumber) return BN(oldField).eq(newField)
|
||||
if (oldField.toString() === newField.toString()) return true
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function diff (oldTx, newTx) {
|
||||
let updatedTx = {}
|
||||
|
||||
if (!oldTx) throw new Error('oldTx must not be null')
|
||||
if (!newTx) throw new Error('newTx must not be null')
|
||||
|
||||
_.forEach(fieldKey => {
|
||||
const oldField = oldTx[fieldKey]
|
||||
const newField = newTx[fieldKey]
|
||||
if (fieldKey === 'bills') return
|
||||
if (_.isEqualWith(nilEqual, oldField, newField)) return
|
||||
|
||||
if (!ensureRatchet(oldField, newField, fieldKey)) {
|
||||
logger.warn('Value from lamassu-machine would violate ratchet [%s]', fieldKey)
|
||||
logger.warn('Old tx: %j', oldTx)
|
||||
logger.warn('New tx: %j', newTx)
|
||||
throw new E.RatchetError('Value from lamassu-machine would violate ratchet')
|
||||
}
|
||||
|
||||
updatedTx[fieldKey] = newField
|
||||
}, _.keys(newTx))
|
||||
|
||||
return updatedTx
|
||||
}
|
||||
|
||||
function toObj (row) {
|
||||
if (!row) return null
|
||||
|
||||
const keys = _.keys(row)
|
||||
let newObj = {}
|
||||
|
||||
keys.forEach(key => {
|
||||
const objKey = _.camelCase(key)
|
||||
if (_.includes(key, ['crypto_atoms', 'fiat', 'cash_in_fee', 'cash_in_fee_crypto'])) {
|
||||
newObj[objKey] = BN(row[key])
|
||||
return
|
||||
}
|
||||
|
||||
newObj[objKey] = row[key]
|
||||
})
|
||||
|
||||
newObj.direction = 'cashIn'
|
||||
|
||||
return newObj
|
||||
}
|
||||
|
||||
function convertBigNumFields (obj) {
|
||||
const convert = value => value && value.isBigNumber
|
||||
? value.toString()
|
||||
: value
|
||||
|
||||
return _.mapValues(convert, obj)
|
||||
}
|
||||
|
||||
function pullNewBills (billRows, machineTx) {
|
||||
if (_.isEmpty(machineTx.bills)) return []
|
||||
|
||||
const toBill = _.mapKeys(_.camelCase)
|
||||
const bills = _.map(toBill, billRows)
|
||||
|
||||
return _.differenceBy(_.get('id'), machineTx.bills, bills)
|
||||
}
|
||||
|
||||
const massage = _.flow(_.omit(['direction', 'cryptoNetwork', 'bills']), convertBigNumFields, _.mapKeys(_.snakeCase))
|
||||
|
||||
function insertNewBills (billRows, machineTx) {
|
||||
const bills = pullNewBills(billRows, machineTx)
|
||||
if (_.isEmpty(bills)) return Promise.resolve([])
|
||||
|
||||
const dbBills = _.map(massage, bills)
|
||||
const columns = _.keys(dbBills[0])
|
||||
const sql = pgp.helpers.insert(dbBills, columns, 'bills')
|
||||
|
||||
return db.none(sql)
|
||||
.then(() => bills)
|
||||
}
|
||||
|
||||
function upsert (dbTx, preProcessedTx) {
|
||||
if (!dbTx) {
|
||||
return insert(preProcessedTx)
|
||||
.then(tx => ({dbTx, tx}))
|
||||
}
|
||||
|
||||
return update(dbTx, diff(dbTx, preProcessedTx))
|
||||
.then(tx => ({dbTx, tx}))
|
||||
}
|
||||
|
||||
function insert (tx) {
|
||||
const dbTx = massage(tx)
|
||||
const sql = pgp.helpers.insert(dbTx, null, 'cash_in_txs') + ' returning *'
|
||||
return db.one(sql)
|
||||
.then(toObj)
|
||||
}
|
||||
|
||||
function update (tx, changes) {
|
||||
if (_.isEmpty(changes)) return Promise.resolve(tx)
|
||||
|
||||
const dbChanges = massage(changes)
|
||||
const sql = pgp.helpers.update(dbChanges, null, 'cash_in_txs') +
|
||||
pgp.as.format(' where id=$1', [tx.id]) + ' returning *'
|
||||
|
||||
return db.one(sql)
|
||||
.then(toObj)
|
||||
}
|
||||
|
||||
function registerTrades (pi, newBills) {
|
||||
_.forEach(bill => pi.buy(bill), newBills)
|
||||
}
|
||||
|
||||
function logAction (rec, tx) {
|
||||
const action = {
|
||||
tx_id: tx.id,
|
||||
action: rec.action || (rec.sendConfirmed ? 'sendCoins' : 'sendCoinsError'),
|
||||
error: rec.error,
|
||||
error_code: rec.errorCode,
|
||||
tx_hash: rec.txHash
|
||||
}
|
||||
|
||||
const sql = pgp.helpers.insert(action, null, 'cash_in_actions')
|
||||
|
||||
return db.none(sql)
|
||||
.then(_.constant(rec))
|
||||
}
|
||||
|
||||
function logActionById (action, _rec, txId) {
|
||||
const rec = _.assign(_rec, {action, tx_id: txId})
|
||||
const sql = pgp.helpers.insert(rec, null, 'cash_in_actions')
|
||||
|
||||
return db.none(sql)
|
||||
}
|
||||
|
||||
function isClearToSend (oldTx, newTx) {
|
||||
const now = Date.now()
|
||||
|
||||
return newTx.send &&
|
||||
(!oldTx || (!oldTx.sendPending && !oldTx.sendConfirmed)) &&
|
||||
(newTx.created > now - PENDING_INTERVAL_MS)
|
||||
}
|
||||
|
||||
function postProcess (r, pi) {
|
||||
registerTrades(pi, r.newBills)
|
||||
|
||||
if (!isClearToSend(r.dbTx, r.tx)) return Promise.resolve({})
|
||||
|
||||
return pi.sendCoins(r.tx)
|
||||
.then(txHash => ({
|
||||
txHash,
|
||||
sendConfirmed: true,
|
||||
sendTime: 'now()^',
|
||||
sendPending: false,
|
||||
error: null,
|
||||
errorCode: null
|
||||
}))
|
||||
.catch(err => {
|
||||
// Important: We don't know what kind of error this is
|
||||
// so not safe to assume that funds weren't sent.
|
||||
// Therefore, don't set sendPending to false except for
|
||||
// errors (like InsufficientFundsError) that are guaranteed
|
||||
// not to send.
|
||||
const sendPending = err.name !== 'InsufficientFundsError'
|
||||
|
||||
return {
|
||||
sendTime: 'now()^',
|
||||
error: err.message,
|
||||
errorCode: err.name,
|
||||
sendPending
|
||||
}
|
||||
})
|
||||
.then(sendRec => logAction(sendRec, r.tx))
|
||||
}
|
||||
|
||||
function preProcess (dbTx, machineTx, pi) {
|
||||
// Note: The way this works is if we're clear to send,
|
||||
// we mark the transaction as sendPending.
|
||||
//
|
||||
// If another process is trying to also mark this as sendPending
|
||||
// that means that it saw the tx as sendPending=false.
|
||||
// But if that's true, then it must be serialized before this
|
||||
// (otherwise it would see sendPending=true), and therefore we can't
|
||||
// be seeing sendPending=false (a pre-condition of clearToSend()).
|
||||
// Therefore, one of the conflicting transactions will error,
|
||||
// which is what we want.
|
||||
return new Promise(resolve => {
|
||||
if (!dbTx) return resolve(machineTx)
|
||||
|
||||
if (isClearToSend(dbTx, machineTx)) {
|
||||
return resolve(_.set('sendPending', true, machineTx))
|
||||
}
|
||||
|
||||
return resolve(machineTx)
|
||||
})
|
||||
}
|
||||
|
||||
function monitorPending (settings) {
|
||||
const sql = `select * from cash_in_txs
|
||||
where created > now() - interval $1
|
||||
and send
|
||||
and not send_confirmed
|
||||
and not send_pending
|
||||
and not operator_completed
|
||||
order by created
|
||||
limit $2`
|
||||
|
||||
const processPending = row => {
|
||||
const tx = toObj(row)
|
||||
const pi = plugins(settings, tx.deviceId)
|
||||
|
||||
return post(tx, pi)
|
||||
.catch(logger.error)
|
||||
}
|
||||
|
||||
return db.any(sql, [PENDING_INTERVAL, MAX_PENDING])
|
||||
.then(rows => Promise.all(_.map(processPending, rows)))
|
||||
.catch(logger.error)
|
||||
}
|
||||
|
||||
function cancel (txId) {
|
||||
const updateRec = {
|
||||
error: 'Operator cancel',
|
||||
error_code: 'operatorCancel',
|
||||
operator_completed: true
|
||||
}
|
||||
|
||||
return Promise.resolve()
|
||||
.then(() => {
|
||||
return pgp.helpers.update(updateRec, null, 'cash_in_txs') +
|
||||
pgp.as.format(' where id=$1', [txId])
|
||||
})
|
||||
.then(sql => db.result(sql, false))
|
||||
.then(res => {
|
||||
if (res.rowCount !== 1) throw new Error('No such tx-id')
|
||||
})
|
||||
.then(() => logActionById('operatorCompleted', {}, txId))
|
||||
}
|
||||
83
lib/cash-in/cash-in-atomic.js
Normal file
83
lib/cash-in/cash-in-atomic.js
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
const _ = require('lodash/fp')
|
||||
const pgp = require('pg-promise')()
|
||||
|
||||
const E = require('../error')
|
||||
|
||||
const cashInLow = require('./cash-in-low')
|
||||
|
||||
module.exports = {atomic}
|
||||
|
||||
function atomic (machineTx, pi) {
|
||||
const TransactionMode = pgp.txMode.TransactionMode
|
||||
const isolationLevel = pgp.txMode.isolationLevel
|
||||
const tmSRD = new TransactionMode({tiLevel: isolationLevel.serializable})
|
||||
|
||||
function transaction (t) {
|
||||
const sql = 'select * from cash_in_txs where id=$1'
|
||||
const sql2 = 'select * from bills where cash_in_txs_id=$1'
|
||||
|
||||
return t.oneOrNone(sql, [machineTx.id])
|
||||
.then(row => {
|
||||
if (row && row.tx_version >= machineTx.txVersion) throw new E.StaleTxError('Stale tx')
|
||||
|
||||
return t.any(sql2, [machineTx.id])
|
||||
.then(billRows => {
|
||||
const dbTx = cashInLow.toObj(row)
|
||||
|
||||
return preProcess(dbTx, machineTx, pi)
|
||||
.then(preProcessedTx => cashInLow.upsert(t, dbTx, preProcessedTx))
|
||||
.then(r => {
|
||||
return insertNewBills(t, billRows, machineTx)
|
||||
.then(newBills => _.set('newBills', newBills, r))
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
transaction.txMode = tmSRD
|
||||
|
||||
return transaction
|
||||
}
|
||||
|
||||
function insertNewBills (t, billRows, machineTx) {
|
||||
const bills = pullNewBills(billRows, machineTx)
|
||||
if (_.isEmpty(bills)) return Promise.resolve([])
|
||||
|
||||
const dbBills = _.map(cashInLow.massage, bills)
|
||||
const columns = _.keys(dbBills[0])
|
||||
const sql = pgp.helpers.insert(dbBills, columns, 'bills')
|
||||
|
||||
return t.none(sql)
|
||||
.then(() => bills)
|
||||
}
|
||||
|
||||
function pullNewBills (billRows, machineTx) {
|
||||
if (_.isEmpty(machineTx.bills)) return []
|
||||
|
||||
const toBill = _.mapKeys(_.camelCase)
|
||||
const bills = _.map(toBill, billRows)
|
||||
|
||||
return _.differenceBy(_.get('id'), machineTx.bills, bills)
|
||||
}
|
||||
|
||||
function preProcess (dbTx, machineTx, pi) {
|
||||
// Note: The way this works is if we're clear to send,
|
||||
// we mark the transaction as sendPending.
|
||||
//
|
||||
// If another process is trying to also mark this as sendPending
|
||||
// that means that it saw the tx as sendPending=false.
|
||||
// But if that's true, then it must be serialized before this
|
||||
// (otherwise it would see sendPending=true), and therefore we can't
|
||||
// be seeing sendPending=false (a pre-condition of clearToSend()).
|
||||
// Therefore, one of the conflicting transactions will error,
|
||||
// which is what we want.
|
||||
return new Promise(resolve => {
|
||||
if (!dbTx) return resolve(machineTx)
|
||||
|
||||
if (cashInLow.isClearToSend(dbTx, machineTx)) {
|
||||
return resolve(_.set('sendPending', true, machineTx))
|
||||
}
|
||||
|
||||
return resolve(machineTx)
|
||||
})
|
||||
}
|
||||
137
lib/cash-in/cash-in-low.js
Normal file
137
lib/cash-in/cash-in-low.js
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
const _ = require('lodash/fp')
|
||||
const pgp = require('pg-promise')()
|
||||
|
||||
const BN = require('../bn')
|
||||
const T = require('../time')
|
||||
const logger = require('../logger')
|
||||
|
||||
const PENDING_INTERVAL_MS = 60 * T.minutes
|
||||
|
||||
const massage = _.flow(_.omit(['direction', 'cryptoNetwork', 'bills']),
|
||||
convertBigNumFields, _.mapKeys(_.snakeCase))
|
||||
|
||||
module.exports = {toObj, upsert, insert, update, massage, isClearToSend}
|
||||
|
||||
function convertBigNumFields (obj) {
|
||||
const convert = value => value && value.isBigNumber
|
||||
? value.toString()
|
||||
: value
|
||||
|
||||
return _.mapValues(convert, obj)
|
||||
}
|
||||
|
||||
function toObj (row) {
|
||||
if (!row) return null
|
||||
|
||||
const keys = _.keys(row)
|
||||
let newObj = {}
|
||||
|
||||
keys.forEach(key => {
|
||||
const objKey = _.camelCase(key)
|
||||
if (_.includes(key, ['crypto_atoms', 'fiat', 'cash_in_fee', 'cash_in_fee_crypto'])) {
|
||||
newObj[objKey] = BN(row[key])
|
||||
return
|
||||
}
|
||||
|
||||
newObj[objKey] = row[key]
|
||||
})
|
||||
|
||||
newObj.direction = 'cashIn'
|
||||
|
||||
return newObj
|
||||
}
|
||||
|
||||
function upsert (t, dbTx, preProcessedTx) {
|
||||
if (!dbTx) {
|
||||
return insert(t, preProcessedTx)
|
||||
.then(tx => ({dbTx, tx}))
|
||||
}
|
||||
|
||||
return update(t, dbTx, diff(dbTx, preProcessedTx))
|
||||
.then(tx => ({dbTx, tx}))
|
||||
}
|
||||
|
||||
function insert (t, tx) {
|
||||
const dbTx = massage(tx)
|
||||
const sql = pgp.helpers.insert(dbTx, null, 'cash_in_txs') + ' returning *'
|
||||
return t.one(sql)
|
||||
.then(toObj)
|
||||
}
|
||||
|
||||
function update (t, tx, changes) {
|
||||
if (_.isEmpty(changes)) return Promise.resolve(tx)
|
||||
|
||||
const dbChanges = massage(changes)
|
||||
const sql = pgp.helpers.update(dbChanges, null, 'cash_in_txs') +
|
||||
pgp.as.format(' where id=$1', [tx.id]) + ' returning *'
|
||||
|
||||
return t.one(sql)
|
||||
.then(toObj)
|
||||
}
|
||||
|
||||
function diff (oldTx, newTx) {
|
||||
let updatedTx = {}
|
||||
|
||||
if (!oldTx) throw new Error('oldTx must not be null')
|
||||
if (!newTx) throw new Error('newTx must not be null')
|
||||
|
||||
_.forEach(fieldKey => {
|
||||
const oldField = oldTx[fieldKey]
|
||||
const newField = newTx[fieldKey]
|
||||
if (fieldKey === 'bills') return
|
||||
if (_.isEqualWith(nilEqual, oldField, newField)) return
|
||||
|
||||
if (!ensureRatchet(oldField, newField, fieldKey)) {
|
||||
logger.warn('Value from lamassu-machine would violate ratchet [%s]', fieldKey)
|
||||
logger.warn('Old tx: %j', oldTx)
|
||||
logger.warn('New tx: %j', newTx)
|
||||
throw new Error('Value from lamassu-machine would violate ratchet')
|
||||
}
|
||||
|
||||
updatedTx[fieldKey] = newField
|
||||
}, _.keys(newTx))
|
||||
|
||||
return updatedTx
|
||||
}
|
||||
|
||||
function ensureRatchet (oldField, newField, fieldKey) {
|
||||
const monotonic = ['cryptoAtoms', 'fiat', 'cashInFeeCrypto', 'send', 'sendConfirmed', 'operatorCompleted', 'timedout', 'txVersion']
|
||||
const free = ['sendPending', 'error', 'errorCode', 'customerId']
|
||||
|
||||
if (_.isNil(oldField)) return true
|
||||
if (_.includes(fieldKey, monotonic)) return isMonotonic(oldField, newField, fieldKey)
|
||||
|
||||
if (_.includes(fieldKey, free)) {
|
||||
if (_.isNil(newField)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
if (_.isNil(newField)) return false
|
||||
if (oldField.isBigNumber && newField.isBigNumber) return BN(oldField).eq(newField)
|
||||
if (oldField.toString() === newField.toString()) return true
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function isMonotonic (oldField, newField, fieldKey) {
|
||||
if (_.isNil(newField)) return false
|
||||
if (_.isBoolean(oldField)) return oldField === newField || !oldField
|
||||
if (oldField.isBigNumber) return oldField.lte(newField)
|
||||
if (_.isNumber(oldField)) return oldField <= newField
|
||||
|
||||
throw new Error(`Unexpected value [${fieldKey}]: ${oldField}, ${newField}`)
|
||||
}
|
||||
|
||||
function nilEqual (a, b) {
|
||||
if (_.isNil(a) && _.isNil(b)) return true
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function isClearToSend (oldTx, newTx) {
|
||||
const now = Date.now()
|
||||
|
||||
return newTx.send &&
|
||||
(!oldTx || (!oldTx.sendPending && !oldTx.sendConfirmed)) &&
|
||||
(newTx.created > now - PENDING_INTERVAL_MS)
|
||||
}
|
||||
126
lib/cash-in/cash-in-tx.js
Normal file
126
lib/cash-in/cash-in-tx.js
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
const _ = require('lodash/fp')
|
||||
const pgp = require('pg-promise')()
|
||||
const pEachSeries = require('p-each-series')
|
||||
|
||||
const db = require('../db')
|
||||
const plugins = require('../plugins')
|
||||
const logger = require('../logger')
|
||||
|
||||
const cashInAtomic = require('./cash-in-atomic')
|
||||
const cashInLow = require('./cash-in-low')
|
||||
|
||||
const PENDING_INTERVAL = '60 minutes'
|
||||
const MAX_PENDING = 10
|
||||
|
||||
module.exports = {post, monitorPending, cancel, PENDING_INTERVAL}
|
||||
|
||||
function post (machineTx, pi) {
|
||||
return db.tx(cashInAtomic.atomic(machineTx, pi))
|
||||
.then(r => {
|
||||
const updatedTx = r.tx
|
||||
|
||||
return postProcess(r, pi)
|
||||
.then(changes => cashInLow.update(db, updatedTx, changes))
|
||||
.then(tx => _.set('bills', machineTx.bills, tx))
|
||||
})
|
||||
}
|
||||
|
||||
function registerTrades (pi, newBills) {
|
||||
_.forEach(bill => pi.buy(bill), newBills)
|
||||
}
|
||||
|
||||
function logAction (rec, tx) {
|
||||
const action = {
|
||||
tx_id: tx.id,
|
||||
action: rec.action || (rec.sendConfirmed ? 'sendCoins' : 'sendCoinsError'),
|
||||
error: rec.error,
|
||||
error_code: rec.errorCode,
|
||||
tx_hash: rec.txHash
|
||||
}
|
||||
|
||||
const sql = pgp.helpers.insert(action, null, 'cash_in_actions')
|
||||
|
||||
return db.none(sql)
|
||||
.then(_.constant(rec))
|
||||
}
|
||||
|
||||
function logActionById (action, _rec, txId) {
|
||||
const rec = _.assign(_rec, {action, tx_id: txId})
|
||||
const sql = pgp.helpers.insert(rec, null, 'cash_in_actions')
|
||||
|
||||
return db.none(sql)
|
||||
}
|
||||
|
||||
function postProcess (r, pi) {
|
||||
registerTrades(pi, r.newBills)
|
||||
|
||||
if (!cashInLow.isClearToSend(r.dbTx, r.tx)) return Promise.resolve({})
|
||||
|
||||
return pi.sendCoins(r.tx)
|
||||
.then(txHash => ({
|
||||
txHash,
|
||||
sendConfirmed: true,
|
||||
sendTime: 'now()^',
|
||||
sendPending: false,
|
||||
error: null,
|
||||
errorCode: null
|
||||
}))
|
||||
.catch(err => {
|
||||
// Important: We don't know what kind of error this is
|
||||
// so not safe to assume that funds weren't sent.
|
||||
// Therefore, don't set sendPending to false except for
|
||||
// errors (like InsufficientFundsError) that are guaranteed
|
||||
// not to send.
|
||||
const sendPending = err.name !== 'InsufficientFundsError'
|
||||
|
||||
return {
|
||||
sendTime: 'now()^',
|
||||
error: err.message,
|
||||
errorCode: err.name,
|
||||
sendPending
|
||||
}
|
||||
})
|
||||
.then(sendRec => logAction(sendRec, r.tx))
|
||||
}
|
||||
|
||||
function monitorPending (settings) {
|
||||
const sql = `select * from cash_in_txs
|
||||
where created > now() - interval $1
|
||||
and send
|
||||
and not send_confirmed
|
||||
and not send_pending
|
||||
and not operator_completed
|
||||
order by created
|
||||
limit $2`
|
||||
|
||||
const processPending = row => {
|
||||
const tx = cashInLow.toObj(row)
|
||||
const pi = plugins(settings, tx.deviceId)
|
||||
|
||||
return post(tx, pi)
|
||||
.catch(logger.error)
|
||||
}
|
||||
|
||||
return db.any(sql, [PENDING_INTERVAL, MAX_PENDING])
|
||||
.then(rows => pEachSeries(rows, row => processPending(row)))
|
||||
.catch(logger.error)
|
||||
}
|
||||
|
||||
function cancel (txId) {
|
||||
const updateRec = {
|
||||
error: 'Operator cancel',
|
||||
error_code: 'operatorCancel',
|
||||
operator_completed: true
|
||||
}
|
||||
|
||||
return Promise.resolve()
|
||||
.then(() => {
|
||||
return pgp.helpers.update(updateRec, null, 'cash_in_txs') +
|
||||
pgp.as.format(' where id=$1', [txId])
|
||||
})
|
||||
.then(sql => db.result(sql, false))
|
||||
.then(res => {
|
||||
if (res.rowCount !== 1) throw new Error('No such tx-id')
|
||||
})
|
||||
.then(() => logActionById('operatorCompleted', {}, txId))
|
||||
}
|
||||
|
|
@ -1,410 +0,0 @@
|
|||
const _ = require('lodash/fp')
|
||||
const pgp = require('pg-promise')()
|
||||
|
||||
const db = require('./db')
|
||||
const billMath = require('./bill-math')
|
||||
const T = require('./time')
|
||||
const logger = require('./logger')
|
||||
const plugins = require('./plugins')
|
||||
const helper = require('./cash-out-helper')
|
||||
const socket = require('./socket-client')
|
||||
const E = require('./error')
|
||||
|
||||
module.exports = {
|
||||
post,
|
||||
monitorLiveIncoming,
|
||||
monitorStaleIncoming,
|
||||
monitorUnnotified,
|
||||
cancel
|
||||
}
|
||||
|
||||
const UPDATEABLE_FIELDS = ['txHash', 'txVersion', 'status', 'dispense', 'dispenseConfirmed',
|
||||
'notified', 'redeem', 'phone', 'error', 'swept', 'publishedAt', 'confirmedAt']
|
||||
|
||||
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 INSUFFICIENT_FUNDS_CODE = 570
|
||||
|
||||
const toObj = helper.toObj
|
||||
const toDb = helper.toDb
|
||||
|
||||
function httpError (msg, code) {
|
||||
const err = new Error(msg)
|
||||
err.name = 'HTTPError'
|
||||
err.code = code || 500
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
function selfPost (tx, pi) {
|
||||
return post(tx, pi, false)
|
||||
}
|
||||
|
||||
function post (tx, pi, fromClient = true) {
|
||||
const TransactionMode = pgp.txMode.TransactionMode
|
||||
const isolationLevel = pgp.txMode.isolationLevel
|
||||
const tmSRD = new TransactionMode({tiLevel: isolationLevel.serializable})
|
||||
|
||||
function transaction (t) {
|
||||
const sql = 'select * from cash_out_txs where id=$1'
|
||||
|
||||
return t.oneOrNone(sql, [tx.id])
|
||||
.then(toObj)
|
||||
.then(oldTx => {
|
||||
const isStale = fromClient && oldTx && (oldTx.txVersion >= tx.txVersion)
|
||||
if (isStale) throw new E.StaleTxError('Stale tx')
|
||||
|
||||
return preProcess(oldTx, tx, pi)
|
||||
.then(preProcessedTx => upsert(oldTx, preProcessedTx))
|
||||
})
|
||||
}
|
||||
|
||||
transaction.txMode = tmSRD
|
||||
|
||||
return db.tx(transaction)
|
||||
.then(txVector => {
|
||||
const [, newTx] = txVector
|
||||
return postProcess(txVector, pi)
|
||||
.then(changes => update(newTx, changes))
|
||||
})
|
||||
}
|
||||
|
||||
function logError (action, err, tx) {
|
||||
return logAction(action, {
|
||||
error: err.message,
|
||||
error_code: err.name
|
||||
}, tx)
|
||||
}
|
||||
|
||||
function mapDispense (tx) {
|
||||
const bills = tx.bills
|
||||
|
||||
if (_.isEmpty(bills)) return {}
|
||||
|
||||
return {
|
||||
provisioned_1: bills[0].provisioned,
|
||||
provisioned_2: bills[1].provisioned,
|
||||
dispensed_1: bills[0].dispensed,
|
||||
dispensed_2: bills[1].dispensed,
|
||||
rejected_1: bills[0].rejected,
|
||||
rejected_2: bills[1].rejected,
|
||||
denomination_1: bills[0].denomination,
|
||||
denomination_2: bills[1].denomination
|
||||
}
|
||||
}
|
||||
|
||||
function logDispense (tx) {
|
||||
const baseRec = {error: tx.error, error_code: tx.errorCode}
|
||||
const rec = _.merge(mapDispense(tx), baseRec)
|
||||
const action = _.isEmpty(tx.error) ? 'dispense' : 'dispenseError'
|
||||
return logAction(action, rec, tx)
|
||||
}
|
||||
|
||||
function logActionById (action, _rec, txId) {
|
||||
const rec = _.assign(_rec, {action, tx_id: txId, redeem: false})
|
||||
const sql = pgp.helpers.insert(rec, null, 'cash_out_actions')
|
||||
|
||||
return db.none(sql)
|
||||
}
|
||||
|
||||
function logAction (action, _rec, tx) {
|
||||
const rec = _.assign(_rec, {action, tx_id: tx.id, redeem: !!tx.redeem})
|
||||
const sql = pgp.helpers.insert(rec, null, 'cash_out_actions')
|
||||
|
||||
return db.none(sql)
|
||||
.then(_.constant(tx))
|
||||
}
|
||||
|
||||
function nilEqual (a, b) {
|
||||
if (_.isNil(a) && _.isNil(b)) return true
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function diff (oldTx, newTx) {
|
||||
let updatedTx = {}
|
||||
|
||||
UPDATEABLE_FIELDS.forEach(fieldKey => {
|
||||
if (oldTx && _.isEqualWith(nilEqual, oldTx[fieldKey], newTx[fieldKey])) return
|
||||
|
||||
// We never null out an existing field
|
||||
if (oldTx && _.isNil(newTx[fieldKey])) return
|
||||
|
||||
updatedTx[fieldKey] = newTx[fieldKey]
|
||||
})
|
||||
|
||||
return updatedTx
|
||||
}
|
||||
|
||||
function upsert (oldTx, tx) {
|
||||
if (!oldTx) {
|
||||
return insert(tx)
|
||||
.then(newTx => [oldTx, newTx])
|
||||
}
|
||||
|
||||
return update(tx, diff(oldTx, tx))
|
||||
.then(newTx => [oldTx, newTx])
|
||||
}
|
||||
|
||||
function insert (tx) {
|
||||
const dbTx = toDb(tx)
|
||||
|
||||
const sql = pgp.helpers.insert(dbTx, null, 'cash_out_txs') + ' returning *'
|
||||
return db.one(sql)
|
||||
.then(toObj)
|
||||
}
|
||||
|
||||
function update (tx, changes) {
|
||||
if (_.isEmpty(changes)) return Promise.resolve(tx)
|
||||
|
||||
const dbChanges = toDb(changes)
|
||||
const sql = pgp.helpers.update(dbChanges, null, 'cash_out_txs') +
|
||||
pgp.as.format(' where id=$1', [tx.id])
|
||||
|
||||
const newTx = _.merge(tx, changes)
|
||||
|
||||
return db.none(sql)
|
||||
.then(() => newTx)
|
||||
}
|
||||
|
||||
function nextHd (isHd, tx) {
|
||||
if (!isHd) return Promise.resolve(tx)
|
||||
|
||||
return db.one("select nextval('hd_indices_seq') as hd_index")
|
||||
.then(row => _.set('hdIndex', row.hd_index, tx))
|
||||
}
|
||||
|
||||
function dispenseOccurred (bills) {
|
||||
return _.every(_.overEvery([_.has('dispensed'), _.has('rejected')]), bills)
|
||||
}
|
||||
|
||||
function updateCassettes (tx) {
|
||||
if (!dispenseOccurred(tx.bills)) return Promise.resolve()
|
||||
|
||||
const sql = `update devices set
|
||||
cassette1 = cassette1 - $1,
|
||||
cassette2 = cassette2 - $2
|
||||
where device_id = $3
|
||||
returning cassette1, cassette2`
|
||||
|
||||
const values = [
|
||||
tx.bills[0].dispensed + tx.bills[0].rejected,
|
||||
tx.bills[1].dispensed + tx.bills[1].rejected,
|
||||
tx.deviceId
|
||||
]
|
||||
|
||||
return db.one(sql, values)
|
||||
.then(r => socket.emit(_.assign(r, {op: 'cassetteUpdate', deviceId: tx.deviceId})))
|
||||
}
|
||||
|
||||
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)
|
||||
.then(isHd => nextHd(isHd, newTx))
|
||||
.then(newTxHd => {
|
||||
return pi.newAddress(newTxHd)
|
||||
.then(_.set('toAddress', _, newTxHd))
|
||||
.then(_.unset('isLightning'))
|
||||
})
|
||||
.then(addressedTx => {
|
||||
const rec = {to_address: addressedTx.toAddress}
|
||||
return logAction('provisionAddress', rec, addressedTx)
|
||||
})
|
||||
.catch(err => {
|
||||
return logError('provisionAddress', err, newTx)
|
||||
.then(() => { throw err })
|
||||
})
|
||||
}
|
||||
|
||||
return Promise.resolve(updateStatus(oldTx, newTx))
|
||||
.then(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)
|
||||
}
|
||||
|
||||
const hasError = !oldTx.error && newTx.error
|
||||
const hasDispenseOccurred = !dispenseOccurred(oldTx.bills) && dispenseOccurred(newTx.bills)
|
||||
|
||||
if (hasError || hasDispenseOccurred) {
|
||||
return logDispense(updatedTx)
|
||||
.then(updateCassettes(updatedTx))
|
||||
}
|
||||
|
||||
if (!oldTx.phone && newTx.phone) {
|
||||
return logAction('addPhone', {}, updatedTx)
|
||||
}
|
||||
|
||||
if (!oldTx.redeem && newTx.redeem) {
|
||||
return logAction('redeemLater', {}, updatedTx)
|
||||
}
|
||||
|
||||
return updatedTx
|
||||
})
|
||||
}
|
||||
|
||||
function postProcess (txVector, pi) {
|
||||
const [oldTx, newTx] = txVector
|
||||
|
||||
if ((newTx.dispense && !oldTx.dispense) || (newTx.redeem && !oldTx.redeem)) {
|
||||
return pi.buildAvailableCassettes(newTx.id)
|
||||
.then(cassettes => {
|
||||
const bills = billMath.makeChange(cassettes.cassettes, newTx.fiat)
|
||||
|
||||
if (!bills) throw httpError('Out of bills', INSUFFICIENT_FUNDS_CODE)
|
||||
return bills
|
||||
})
|
||||
.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: provisioned1,
|
||||
provisioned_2: provisioned2,
|
||||
denomination_1: denomination1,
|
||||
denomination_2: denomination2
|
||||
}
|
||||
|
||||
return logAction('provisionNotes', rec, newTx)
|
||||
.then(_.constant({bills}))
|
||||
})
|
||||
.catch(err => {
|
||||
return logError('provisionNotesError', err, newTx)
|
||||
.then(() => { throw err })
|
||||
})
|
||||
}
|
||||
|
||||
return Promise.resolve({})
|
||||
}
|
||||
|
||||
function isPublished (status) {
|
||||
return _.includes(status, ['published', 'rejected', 'authorized', 'instant', 'confirmed'])
|
||||
}
|
||||
|
||||
function isConfirmed (status) {
|
||||
return status === 'confirmed'
|
||||
}
|
||||
|
||||
function updateStatus (oldTx, newTx) {
|
||||
const oldStatus = oldTx.status
|
||||
const newStatus = ratchetStatus(oldStatus, newTx.status)
|
||||
|
||||
const publishedAt = !oldTx.publishedAt && isPublished(newStatus)
|
||||
? 'now()^'
|
||||
: undefined
|
||||
|
||||
const confirmedAt = !oldTx.confirmedAt && isConfirmed(newStatus)
|
||||
? 'now()^'
|
||||
: undefined
|
||||
|
||||
const updateRec = {
|
||||
publishedAt,
|
||||
confirmedAt,
|
||||
status: newStatus
|
||||
}
|
||||
|
||||
return _.merge(newTx, updateRec)
|
||||
}
|
||||
|
||||
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 => _.assign(tx, {status: res.status}))
|
||||
.then(_tx => selfPost(_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 dispense=$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)
|
||||
}
|
||||
|
||||
function cancel (txId) {
|
||||
const updateRec = {
|
||||
error: 'Operator cancel',
|
||||
error_code: 'operatorCancel',
|
||||
dispense: true
|
||||
}
|
||||
|
||||
return Promise.resolve()
|
||||
.then(() => {
|
||||
return pgp.helpers.update(updateRec, null, 'cash_out_txs') +
|
||||
pgp.as.format(' where id=$1', [txId])
|
||||
})
|
||||
.then(sql => db.result(sql, false))
|
||||
.then(res => {
|
||||
if (res.rowCount !== 1) throw new Error('No such tx-id')
|
||||
})
|
||||
.then(() => logActionById('operatorCompleted', {}, txId))
|
||||
}
|
||||
50
lib/cash-out/cash-out-actions.js
Normal file
50
lib/cash-out/cash-out-actions.js
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
const _ = require('lodash/fp')
|
||||
const pgp = require('pg-promise')()
|
||||
|
||||
module.exports = {logDispense, logActionById, logAction, logError}
|
||||
|
||||
function logDispense (t, tx) {
|
||||
const baseRec = {error: tx.error, error_code: tx.errorCode}
|
||||
const rec = _.merge(mapDispense(tx), baseRec)
|
||||
const action = tx.dispenseConfirmed ? 'dispense' : 'dispenseError'
|
||||
return logAction(t, action, rec, tx)
|
||||
}
|
||||
|
||||
function logActionById (t, action, _rec, txId) {
|
||||
const rec = _.assign(_rec, {action, tx_id: txId, redeem: false})
|
||||
const sql = pgp.helpers.insert(rec, null, 'cash_out_actions')
|
||||
|
||||
return t.none(sql)
|
||||
}
|
||||
|
||||
function logAction (t, action, _rec, tx) {
|
||||
const rec = _.assign(_rec, {action, tx_id: tx.id, redeem: !!tx.redeem})
|
||||
const sql = pgp.helpers.insert(rec, null, 'cash_out_actions')
|
||||
|
||||
return t.none(sql)
|
||||
.then(_.constant(tx))
|
||||
}
|
||||
|
||||
function logError (t, action, err, tx) {
|
||||
return logAction(t, action, {
|
||||
error: err.message,
|
||||
error_code: err.name
|
||||
}, tx)
|
||||
}
|
||||
|
||||
function mapDispense (tx) {
|
||||
const bills = tx.bills
|
||||
|
||||
if (_.isEmpty(bills)) return {}
|
||||
|
||||
return {
|
||||
provisioned_1: bills[0].provisioned,
|
||||
provisioned_2: bills[1].provisioned,
|
||||
dispensed_1: bills[0].dispensed,
|
||||
dispensed_2: bills[1].dispensed,
|
||||
rejected_1: bills[0].rejected,
|
||||
rejected_2: bills[1].rejected,
|
||||
denomination_1: bills[0].denomination,
|
||||
denomination_2: bills[1].denomination
|
||||
}
|
||||
}
|
||||
160
lib/cash-out/cash-out-atomic.js
Normal file
160
lib/cash-out/cash-out-atomic.js
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
const _ = require('lodash/fp')
|
||||
const pgp = require('pg-promise')()
|
||||
|
||||
const E = require('../error')
|
||||
const socket = require('../socket-client')
|
||||
|
||||
const helper = require('./cash-out-helper')
|
||||
const cashOutActions = require('./cash-out-actions')
|
||||
const cashOutLow = require('./cash-out-low')
|
||||
|
||||
const toObj = helper.toObj
|
||||
|
||||
module.exports = {atomic}
|
||||
|
||||
function atomic (tx, pi, fromClient) {
|
||||
const TransactionMode = pgp.txMode.TransactionMode
|
||||
const isolationLevel = pgp.txMode.isolationLevel
|
||||
const tmSRD = new TransactionMode({tiLevel: isolationLevel.serializable})
|
||||
|
||||
function transaction (t) {
|
||||
const sql = 'select * from cash_out_txs where id=$1'
|
||||
|
||||
return t.oneOrNone(sql, [tx.id])
|
||||
.then(toObj)
|
||||
.then(oldTx => {
|
||||
const isStale = fromClient && oldTx && (oldTx.txVersion >= tx.txVersion)
|
||||
if (isStale) throw new E.StaleTxError('Stale tx')
|
||||
|
||||
return preProcess(t, oldTx, tx, pi)
|
||||
.then(preProcessedTx => cashOutLow.upsert(t, oldTx, preProcessedTx))
|
||||
})
|
||||
}
|
||||
|
||||
transaction.txMode = tmSRD
|
||||
|
||||
return transaction
|
||||
}
|
||||
|
||||
function preProcess (t, oldTx, newTx, pi) {
|
||||
if (!oldTx) {
|
||||
return pi.isHd(newTx)
|
||||
.then(isHd => nextHd(t, isHd, newTx))
|
||||
.then(newTxHd => {
|
||||
return pi.newAddress(newTxHd)
|
||||
.then(_.set('toAddress', _, newTxHd))
|
||||
})
|
||||
.then(addressedTx => {
|
||||
const rec = {to_address: addressedTx.toAddress}
|
||||
return cashOutActions.logAction(t, 'provisionAddress', rec, addressedTx)
|
||||
})
|
||||
.catch(err => {
|
||||
return cashOutActions.logError(t, 'provisionAddress', err, newTx)
|
||||
.then(() => { throw err })
|
||||
})
|
||||
}
|
||||
|
||||
return Promise.resolve(updateStatus(oldTx, newTx))
|
||||
.then(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 cashOutActions.logAction(t, updatedTx.status, rec, updatedTx)
|
||||
}
|
||||
|
||||
if (!oldTx.dispenseConfirmed && updatedTx.dispenseConfirmed) {
|
||||
return cashOutActions.logDispense(t, updatedTx)
|
||||
.then(updateCassettes(t, updatedTx))
|
||||
}
|
||||
|
||||
if (!oldTx.phone && newTx.phone) {
|
||||
return cashOutActions.logAction(t, 'addPhone', {}, updatedTx)
|
||||
}
|
||||
|
||||
if (!oldTx.redeem && newTx.redeem) {
|
||||
return cashOutActions.logAction(t, 'redeemLater', {}, updatedTx)
|
||||
}
|
||||
|
||||
return updatedTx
|
||||
})
|
||||
}
|
||||
|
||||
function nextHd (t, isHd, tx) {
|
||||
if (!isHd) return Promise.resolve(tx)
|
||||
|
||||
return t.one("select nextval('hd_indices_seq') as hd_index")
|
||||
.then(row => _.set('hdIndex', row.hd_index, tx))
|
||||
}
|
||||
|
||||
function updateCassettes (t, tx) {
|
||||
const sql = `update devices set
|
||||
cassette1 = cassette1 - $1,
|
||||
cassette2 = cassette2 - $2
|
||||
where device_id = $3
|
||||
returning cassette1, cassette2`
|
||||
|
||||
const values = [
|
||||
tx.bills[0].dispensed + tx.bills[0].rejected,
|
||||
tx.bills[1].dispensed + tx.bills[1].rejected,
|
||||
tx.deviceId
|
||||
]
|
||||
|
||||
return t.one(sql, values)
|
||||
.then(r => socket.emit(_.assign(r, {op: 'cassetteUpdate', deviceId: tx.deviceId})))
|
||||
}
|
||||
|
||||
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 isPublished (status) {
|
||||
return _.includes(status, ['published', 'rejected', 'authorized', 'instant', 'confirmed'])
|
||||
}
|
||||
|
||||
function isConfirmed (status) {
|
||||
return status === 'confirmed'
|
||||
}
|
||||
|
||||
function updateStatus (oldTx, newTx) {
|
||||
const oldStatus = oldTx.status
|
||||
const newStatus = ratchetStatus(oldStatus, newTx.status)
|
||||
|
||||
const publishedAt = !oldTx.publishedAt && isPublished(newStatus)
|
||||
? 'now()^'
|
||||
: undefined
|
||||
|
||||
const confirmedAt = !oldTx.confirmedAt && isConfirmed(newStatus)
|
||||
? 'now()^'
|
||||
: undefined
|
||||
|
||||
const updateRec = {
|
||||
publishedAt,
|
||||
confirmedAt,
|
||||
status: newStatus
|
||||
}
|
||||
|
||||
return _.merge(newTx, updateRec)
|
||||
}
|
||||
|
||||
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]
|
||||
}
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
const _ = require('lodash/fp')
|
||||
|
||||
const db = require('./db')
|
||||
const T = require('./time')
|
||||
const BN = require('./bn')
|
||||
const db = require('../db')
|
||||
const T = require('../time')
|
||||
const BN = require('../bn')
|
||||
|
||||
const REDEEMABLE_AGE = T.day
|
||||
|
||||
64
lib/cash-out/cash-out-low.js
Normal file
64
lib/cash-out/cash-out-low.js
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
const _ = require('lodash/fp')
|
||||
const pgp = require('pg-promise')()
|
||||
|
||||
const helper = require('./cash-out-helper')
|
||||
|
||||
const toDb = helper.toDb
|
||||
const toObj = helper.toObj
|
||||
|
||||
const UPDATEABLE_FIELDS = ['txHash', 'txVersion', 'status', 'dispense', 'dispenseConfirmed',
|
||||
'notified', 'redeem', 'phone', 'error', 'swept', 'publishedAt', 'confirmedAt']
|
||||
|
||||
module.exports = {upsert, update, insert}
|
||||
|
||||
function upsert (t, oldTx, tx) {
|
||||
if (!oldTx) {
|
||||
return insert(t, tx)
|
||||
.then(newTx => [oldTx, newTx])
|
||||
}
|
||||
|
||||
return update(t, tx, diff(oldTx, tx))
|
||||
.then(newTx => [oldTx, newTx])
|
||||
}
|
||||
|
||||
function insert (t, tx) {
|
||||
const dbTx = toDb(tx)
|
||||
|
||||
const sql = pgp.helpers.insert(dbTx, null, 'cash_out_txs') + ' returning *'
|
||||
return t.one(sql)
|
||||
.then(toObj)
|
||||
}
|
||||
|
||||
function update (t, tx, changes) {
|
||||
if (_.isEmpty(changes)) return Promise.resolve(tx)
|
||||
|
||||
const dbChanges = toDb(changes)
|
||||
const sql = pgp.helpers.update(dbChanges, null, 'cash_out_txs') +
|
||||
pgp.as.format(' where id=$1', [tx.id])
|
||||
|
||||
const newTx = _.merge(tx, changes)
|
||||
|
||||
return t.none(sql)
|
||||
.then(() => newTx)
|
||||
}
|
||||
|
||||
function diff (oldTx, newTx) {
|
||||
let updatedTx = {}
|
||||
|
||||
UPDATEABLE_FIELDS.forEach(fieldKey => {
|
||||
if (oldTx && _.isEqualWith(nilEqual, oldTx[fieldKey], newTx[fieldKey])) return
|
||||
|
||||
// We never null out an existing field
|
||||
if (oldTx && _.isNil(newTx[fieldKey])) return
|
||||
|
||||
updatedTx[fieldKey] = newTx[fieldKey]
|
||||
})
|
||||
|
||||
return updatedTx
|
||||
}
|
||||
|
||||
function nilEqual (a, b) {
|
||||
if (_.isNil(a) && _.isNil(b)) return true
|
||||
|
||||
return undefined
|
||||
}
|
||||
158
lib/cash-out/cash-out-tx.js
Normal file
158
lib/cash-out/cash-out-tx.js
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
const _ = require('lodash/fp')
|
||||
const pgp = require('pg-promise')()
|
||||
const pEachSeries = require('p-each-series')
|
||||
|
||||
const db = require('../db')
|
||||
const billMath = require('../bill-math')
|
||||
const T = require('../time')
|
||||
const logger = require('../logger')
|
||||
const plugins = require('../plugins')
|
||||
|
||||
const helper = require('./cash-out-helper')
|
||||
const cashOutAtomic = require('./cash-out-atomic')
|
||||
const cashOutActions = require('./cash-out-actions')
|
||||
const cashOutLow = require('./cash-out-low')
|
||||
|
||||
module.exports = {
|
||||
post,
|
||||
monitorLiveIncoming,
|
||||
monitorStaleIncoming,
|
||||
monitorUnnotified,
|
||||
cancel
|
||||
}
|
||||
|
||||
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 INSUFFICIENT_FUNDS_CODE = 570
|
||||
|
||||
const toObj = helper.toObj
|
||||
|
||||
function httpError (msg, code) {
|
||||
const err = new Error(msg)
|
||||
err.name = 'HTTPError'
|
||||
err.code = code || 500
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
function selfPost (tx, pi) {
|
||||
return post(tx, pi, false)
|
||||
}
|
||||
|
||||
function post (tx, pi, fromClient = true) {
|
||||
return db.tx(cashOutAtomic.atomic(tx, pi, fromClient))
|
||||
.then(txVector => {
|
||||
const [, newTx] = txVector
|
||||
return postProcess(txVector, pi)
|
||||
.then(changes => cashOutLow.update(db, newTx, changes))
|
||||
})
|
||||
}
|
||||
|
||||
function postProcess (txVector, pi) {
|
||||
const [oldTx, newTx] = txVector
|
||||
|
||||
if ((newTx.dispense && !oldTx.dispense) || (newTx.redeem && !oldTx.redeem)) {
|
||||
return pi.buildAvailableCassettes(newTx.id)
|
||||
.then(cassettes => {
|
||||
const bills = billMath.makeChange(cassettes.cassettes, newTx.fiat)
|
||||
|
||||
if (!bills) throw httpError('Out of bills', INSUFFICIENT_FUNDS_CODE)
|
||||
return bills
|
||||
})
|
||||
.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: provisioned1,
|
||||
provisioned_2: provisioned2,
|
||||
denomination_1: denomination1,
|
||||
denomination_2: denomination2
|
||||
}
|
||||
|
||||
return cashOutActions.logAction(db, 'provisionNotes', rec, newTx)
|
||||
.then(_.constant({bills}))
|
||||
})
|
||||
.catch(err => {
|
||||
return cashOutActions.logError(db, 'provisionNotesError', err, newTx)
|
||||
.then(() => { throw err })
|
||||
})
|
||||
}
|
||||
|
||||
return Promise.resolve({})
|
||||
}
|
||||
|
||||
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 => _.assign(tx, {status: res.status}))
|
||||
.then(_tx => selfPost(_tx, pi))
|
||||
}
|
||||
|
||||
function monitorLiveIncoming (settings) {
|
||||
const statuses = ['notSeen', 'published', 'insufficientFunds']
|
||||
|
||||
return fetchOpenTxs(statuses, STALE_LIVE_INCOMING_TX_AGE)
|
||||
.then(txs => pEachSeries(txs, 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 => pEachSeries(txs, 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 dispense=$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)
|
||||
}
|
||||
|
||||
function cancel (txId) {
|
||||
const updateRec = {
|
||||
error: 'Operator cancel',
|
||||
error_code: 'operatorCancel',
|
||||
dispense: true
|
||||
}
|
||||
|
||||
return Promise.resolve()
|
||||
.then(() => {
|
||||
return pgp.helpers.update(updateRec, null, 'cash_out_txs') +
|
||||
pgp.as.format(' where id=$1', [txId])
|
||||
})
|
||||
.then(sql => db.result(sql, false))
|
||||
.then(res => {
|
||||
if (res.rowCount !== 1) throw new Error('No such tx-id')
|
||||
})
|
||||
.then(() => cashOutActions.logActionById(db, 'operatorCompleted', {}, txId))
|
||||
}
|
||||
|
|
@ -16,7 +16,7 @@ const wallet = require('./wallet')
|
|||
const exchange = require('./exchange')
|
||||
const sms = require('./sms')
|
||||
const email = require('./email')
|
||||
const cashOutHelper = require('./cash-out-helper')
|
||||
const cashOutHelper = require('./cash-out/cash-out-helper')
|
||||
const machineLoader = require('./machine-loader')
|
||||
const coinUtils = require('./coin-utils')
|
||||
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ function getStatus (account, toAddress, cryptoAtoms, cryptoCode) {
|
|||
if (elapsed < AUTHORIZE_TIME) return Promise.resolve({status: 'published'})
|
||||
if (elapsed < CONFIRM_TIME) return Promise.resolve({status: 'authorized'})
|
||||
|
||||
console.log('[%s] DEBUG: Mock wallet has confirmed transaction', cryptoCode)
|
||||
console.log('[%s] DEBUG: Mock wallet has confirmed transaction [%s]', cryptoCode, toAddress.slice(0, 5))
|
||||
return Promise.resolve({status: 'confirmed'})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@ const plugins = require('./plugins')
|
|||
const notifier = require('./notifier')
|
||||
const T = require('./time')
|
||||
const logger = require('./logger')
|
||||
const cashOutTx = require('./cash-out-tx')
|
||||
const cashInTx = require('./cash-in-tx')
|
||||
const cashOutTx = require('./cash-out/cash-out-tx')
|
||||
const cashInTx = require('./cash-in/cash-in-tx')
|
||||
|
||||
const INCOMING_TX_INTERVAL = 30 * T.seconds
|
||||
const LIVE_INCOMING_TX_INTERVAL = 5 * T.seconds
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
const _ = require('lodash/fp')
|
||||
const BN = require('./bn')
|
||||
const CashInTx = require('./cash-in-tx')
|
||||
const CashOutTx = require('./cash-out-tx')
|
||||
const CashInTx = require('./cash-in/cash-in-tx')
|
||||
const CashOutTx = require('./cash-out/cash-out-tx')
|
||||
|
||||
function process (tx, pi) {
|
||||
const mtx = massage(tx)
|
||||
|
|
|
|||
|
|
@ -40,8 +40,8 @@
|
|||
"node-hkdf-sync": "^1.0.0",
|
||||
"node-mailjet": "^3.2.1",
|
||||
"numeral": "^2.0.3",
|
||||
"pg-native": "^2.0.1",
|
||||
"pg-promise": "^6.3.7",
|
||||
"pg-native": "^2.2.0",
|
||||
"pg-promise": "^7.4.1",
|
||||
"pify": "^3.0.0",
|
||||
"pretty-ms": "^2.1.0",
|
||||
"promise-sequential": "^1.1.1",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
const settingsLoader = require('../lib/settings-loader')
|
||||
|
||||
const fields = [
|
||||
settingsLoader.configDeleteField({crypto: 'ETH', machine: 'global'}, 'exchange')
|
||||
settingsLoader.configDeleteField({crypto: 'BTC', machine: 'global'}, 'wallet')
|
||||
]
|
||||
|
||||
settingsLoader.modifyConfig(fields)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue