Merge branch 'release-8.1' into chore/update-ciphertrace-logs

This commit is contained in:
Rafael Taranto 2022-11-18 11:18:43 +01:00 committed by GitHub
commit 5e1d706ca2
115 changed files with 1416 additions and 580 deletions

View file

@ -57,3 +57,4 @@ HTTP=
DEV_MODE=
## Uncategorized variables
WEBHOOK_URL=

View file

@ -0,0 +1,43 @@
#!/usr/bin/env node
require('../lib/environment-helper')
const argv = require('minimist')(process.argv.slice(2))
const _ = require('lodash')
const db = require('../lib/db')
const txId = argv.tx
const customerId = argv.customer
if ((!txId && !customerId) || (txId && customerId)) {
console.log('Usage: lamassu-clean-parsed-id [--tx <txId> | --customer <customerId>]')
console.log('The command can only be run with EITHER --tx OR --customer, NOT BOTH')
process.exit(2)
}
if (!_.isNil(txId)) {
db.oneOrNone('SELECT * FROM (SELECT id, customer_id FROM cash_in_txs UNION SELECT id, customer_id FROM cash_out_txs) as txs WHERE txs.id = $1', [txId])
.then(res => {
return db.none('UPDATE customers SET id_card_data = null WHERE id = $1', [res.customer_id])
.then(() => {
console.log(`ID card data from customer ${res.customer_id} was cleared with success`)
process.exit(0)
})
})
.catch(() => {
console.log('A transaction with that ID was not found')
process.exit(0)
})
}
if (!_.isNil(customerId)) {
db.none('UPDATE customers SET id_card_data = null WHERE id = $1', [customerId])
.then(() => {
console.log(`ID card data from customer ${customerId} was cleared with success`)
process.exit(0)
})
.catch(() => {
console.log('A customer with that ID was not found')
process.exit(0)
})
}

View file

@ -1,5 +1,7 @@
#!/usr/bin/env node
require('../lib/environment-helper')
const install = require('../lib/blockchain/install')
install.run()

View file

@ -1,3 +1,5 @@
require('../lib/environment-helper')
const settingsLoader = require('../lib/new-settings-loader')
const pp = require('../lib/pp')

View file

@ -0,0 +1,75 @@
require('../lib/environment-helper')
const hdkey = require('ethereumjs-wallet/hdkey')
const _ = require('lodash/fp')
const hkdf = require('futoin-hkdf')
const pify = require('pify')
const fs = pify(require('fs'))
const Web3 = require('web3')
const web3 = new Web3()
const db = require('../lib/db')
const configManager = require('../lib/new-config-manager')
const { loadLatest } = require('../lib/new-settings-loader')
const mnemonicHelpers = require('../lib/mnemonic-helpers')
const { sweep } = require('../lib/wallet')
const ph = require('../lib/plugin-helper')
const MNEMONIC_PATH = process.env.MNEMONIC_PATH
function fetchWallet (settings, cryptoCode) {
return fs.readFile(MNEMONIC_PATH, 'utf8')
.then(mnemonic => {
const masterSeed = mnemonicHelpers.toEntropyBuffer(mnemonic)
const plugin = configManager.getWalletSettings(cryptoCode, settings.config).wallet
const wallet = ph.load(ph.WALLET, plugin)
const rawAccount = settings.accounts[plugin]
const account = _.set('seed', computeSeed(masterSeed), rawAccount)
if (_.isFunction(wallet.run)) wallet.run(account)
return { wallet, account }
})
}
function computeSeed (masterSeed) {
return hkdf(masterSeed, 32, { salt: 'lamassu-server-salt', info: 'wallet-seed' })
}
function paymentHdNode (account) {
const masterSeed = account.seed
if (!masterSeed) throw new Error('No master seed!')
const key = hdkey.fromMasterSeed(masterSeed)
return key.derivePath("m/44'/60'/0'/0'")
}
const getHdIndices = db => {
const sql = `SELECT id, crypto_code, hd_index FROM cash_out_txs WHERE hd_index IS NOT NULL AND status IN ('confirmed', 'instant') AND crypto_code = 'ETH'`
return db.any(sql)
}
const getCashoutAddresses = (settings, indices) => {
return Promise.all(_.map(it => {
return fetchWallet(settings, it.crypto_code)
.then(({ wallet, account }) => Promise.all([wallet, paymentHdNode(account).deriveChild(it.hd_index).getWallet().getChecksumAddressString()]))
.then(([wallet, address]) => Promise.all([address, wallet._balance(false, address, 'ETH')]))
.then(([address, balance]) => ({ address, balance: balance.toNumber(), cryptoCode: it.crypto_code, index: it.hd_index, txId: it.id }))
}, indices))
}
Promise.all([getHdIndices(db), loadLatest()])
.then(([indices, settings]) => Promise.all([settings, getCashoutAddresses(settings, indices)]))
.then(([settings, addresses]) => {
console.log('Found these cash-out addresses for ETH:')
console.log(addresses)
return Promise.all(_.map(it => {
// If the address only has dust in it, don't bother sweeping
if (web3.utils.fromWei(it.balance.toString()) > 0.00001) {
console.log(`Address ${it.address} found to have ${web3.utils.fromWei(it.balance.toString())} ETH in it. Sweeping...`)
return sweep(settings, it.txId, it.cryptoCode, it.index)
}
console.log(`Address ${it.address} contains no significant balance (${web3.utils.fromWei(it.balance.toString())}). Skipping the sweep process...`)
return Promise.resolve()
}, addresses))
})
.then(() => console.log('Process finished!'))

View file

@ -1,4 +1,7 @@
#!/usr/bin/env node
require('../lib/environment-helper')
const hdkey = require('ethereumjs-wallet/hdkey')
const hkdf = require('futoin-hkdf')
const crypto = require('crypto')
@ -263,7 +266,7 @@ settingsLoader.loadLatest()
}
const opts = {
chainId: 3,
chainId: 1,
nonce: 0,
includesFee: true
}

View file

@ -1,5 +1,7 @@
#!/usr/bin/env node
require('../lib/environment-helper')
const migrate = require('../lib/migrate-options')
migrate.run()

View file

@ -1,5 +1,7 @@
#!/usr/bin/env node
require('../lib/environment-helper')
const ofac = require('../lib/ofac/update')
console.log('Updating OFAC databases.')

View file

@ -1,5 +1,7 @@
#!/usr/bin/env node
require('../lib/environment-helper')
const settingsLoader = require('../lib/new-settings-loader')
const configManager = require('../lib/new-config-manager')
const wallet = require('../lib/wallet')

View file

@ -1,5 +1,7 @@
#!/usr/bin/env node
require('../lib/environment-helper')
const _ = require('lodash')
const db = require('../lib/db')

View file

@ -1,5 +1,6 @@
#!/usr/bin/env node
require('../lib/environment-helper')
const _ = require('lodash/fp')
const common = require('../lib/blockchain/common')
const { utils: coinUtils } = require('@lamassu/coins')

36
build/Dockerfile Normal file
View file

@ -0,0 +1,36 @@
FROM ubuntu:20.04 as base
ARG VERSION
ENV SERVER_VERSION=$VERSION
ARG DEBIAN_FRONTEND=noninteractive
ENV TZ=Europe/Lisbon
RUN apt-get update
RUN apt-get install -y -q curl \
sudo \
git \
python2-minimal \
build-essential \
libpq-dev \
net-tools \
tar
RUN curl -sL https://deb.nodesource.com/setup_14.x | sudo -E bash -
RUN apt-get install nodejs -y -q
FROM base as l-s-build
WORKDIR /lamassu
RUN git clone https://github.com/lamassu/lamassu-server -b ${SERVER_VERSION}
RUN rm -rf /lamassu/lamassu-server/public/*
RUN cd lamassu-server && npm install --production
RUN cd lamassu-server/new-lamassu-admin && npm install && npm run build
RUN cp -r /lamassu/lamassu-server/new-lamassu-admin/build/* /lamassu/lamassu-server/public
RUN rm -rf /lamassu/lamassu-server/new-lamassu-admin/node_modules
RUN tar -zcvf lamassu-server-$SERVER_VERSION.tar.gz lamassu-server/
ENTRYPOINT [ "/bin/bash" ]

13
build/build.sh Executable file
View file

@ -0,0 +1,13 @@
#!/usr/bin/env bash
if [ $# -eq 0 ]; then
echo "Error: no arguments specified"
echo "Usage: ./build.sh <SERVER_VERSION_TAG>"
exit 1
fi
docker build --rm --build-arg VERSION=$1 --tag l-s-prepackage:$1 --file Dockerfile .
id=$(docker create l-s-prepackage:$1)
docker cp $id:/lamassu/lamassu-server-$1.tar.gz ./lamassu-server-$1.tar.gz
docker rm -v $id

View file

@ -109,7 +109,7 @@ function makeChange(outCassettes, amount) {
)
if (available < amount) {
console.log(`Tried to dispense more than was available for amount ${amount.toNumber()} with cassettes ${JSON.stringify(cassettes)}`)
console.log(`Tried to dispense more than was available for amount ${amount.toNumber()} with cassettes ${JSON.stringify(outCassettes)}`)
return null
}

View file

@ -29,22 +29,22 @@ const BINARIES = {
dir: 'bitcoin-23.0/bin'
},
ETH: {
url: 'https://gethstore.blob.core.windows.net/builds/geth-linux-amd64-1.10.19-23bee162.tar.gz',
dir: 'geth-linux-amd64-1.10.19-23bee162'
url: 'https://gethstore.blob.core.windows.net/builds/geth-linux-amd64-1.10.25-69568c55.tar.gz',
dir: 'geth-linux-amd64-1.10.25-69568c55'
},
ZEC: {
url: 'https://z.cash/downloads/zcash-5.0.0-linux64-debian-bullseye.tar.gz',
dir: 'zcash-5.0.0/bin'
url: 'https://z.cash/downloads/zcash-5.3.0-linux64-debian-bullseye.tar.gz',
dir: 'zcash-5.3.0/bin'
},
DASH: {
url: 'https://github.com/dashpay/dash/releases/download/v0.17.0.3/dashcore-0.17.0.3-x86_64-linux-gnu.tar.gz',
dir: 'dashcore-0.17.0/bin'
url: 'https://github.com/dashpay/dash/releases/download/v18.1.0/dashcore-18.1.0-x86_64-linux-gnu.tar.gz',
dir: 'dashcore-18.1.0/bin'
},
LTC: {
defaultUrl: 'https://download.litecoin.org/litecoin-0.18.1/linux/litecoin-0.18.1-x86_64-linux-gnu.tar.gz',
defaultDir: 'litecoin-0.18.1/bin',
url: 'https://download.litecoin.org/litecoin-0.21.2.1/linux/litecoin-0.21.2.1-x86_64-linux-gnu.tar.gz',
dir: 'litecoin-0.21.2.1/bin'
dir: 'litecoin-0.21.2.1/bin'
},
BCH: {
url: 'https://github.com/bitcoin-cash-node/bitcoin-cash-node/releases/download/v24.1.0/bitcoin-cash-node-24.1.0-x86_64-linux-gnu.tar.gz',
@ -52,8 +52,8 @@ const BINARIES = {
files: [['bitcoind', 'bitcoincashd'], ['bitcoin-cli', 'bitcoincash-cli']]
},
XMR: {
url: 'https://downloads.getmonero.org/cli/monero-linux-x64-v0.17.3.2.tar.bz2',
dir: 'monero-x86_64-linux-gnu-v0.17.3.2',
url: 'https://downloads.getmonero.org/cli/monero-linux-x64-v0.18.1.2.tar.bz2',
dir: 'monero-x86_64-linux-gnu-v0.18.1.2',
files: [['monerod', 'monerod'], ['monero-wallet-rpc', 'monero-wallet-rpc']]
}
}

View file

@ -139,19 +139,33 @@ function getBlockchainSyncStatus (cryptoList) {
})
}
function isInstalled (crypto) {
return isInstalledSoftware(crypto) && isInstalledVolume(crypto)
}
function isDisabled (crypto) {
switch (crypto.cryptoCode) {
case 'ETH':
return 'Use admin\'s Infura plugin'
case 'ZEC':
return isInstalled(crypto) && 'Installed' || isInstalled(_.find(it => it.code === 'monero', cryptos)) && 'Insufficient resources. Contact support.'
case 'XMR':
return isInstalled(crypto) && 'Installed' || isInstalled(_.find(it => it.code === 'zcash', cryptos)) && 'Insufficient resources. Contact support.'
default:
return isInstalled(crypto) && 'Installed'
}
}
function run () {
const choices = _.flow([
_.filter(c => c.type !== 'erc-20'),
_.map(c => {
const checked = isInstalledSoftware(c) && isInstalledVolume(c)
const name = c.code === 'ethereum' ? 'Ethereum' : c.display
const name = c.code === 'ethereum' ? 'Ethereum and/or USDT' : c.display
return {
name,
value: c.code,
checked,
disabled: c.cryptoCode === 'ETH'
? 'Use admin\'s Infura plugin'
: checked && 'Installed'
checked: isInstalled(c),
disabled: isDisabled(c)
}
}),
])(cryptos)
@ -160,6 +174,15 @@ function run () {
const validateAnswers = async (answers) => {
if (_.size(answers) > 2) return { message: `Please insert a maximum of two coins to install.`, isValid: false }
if (
_.isEmpty(_.difference(['monero', 'zcash'], answers)) ||
(_.includes('monero', answers) && isInstalled(_.find(it => it.code === 'zcash', cryptos))) ||
(_.includes('zcash', answers) && isInstalled(_.find(it => it.code === 'monero', cryptos)))
) {
return { message: `Zcash and Monero installations are temporarily mutually exclusive, given the space needed for their blockchains. Contact support for more information.`, isValid: false }
}
return getBlockchainSyncStatus(cryptos)
.then(blockchainStatuses => {
const result = _.reduce((acc, value) => ({ ...acc, [value]: _.isNil(acc[value]) ? 1 : acc[value] + 1 }), {}, _.values(blockchainStatuses))

View file

@ -167,23 +167,32 @@ function postProcess (r, pi, isBlacklisted, addressReuse, walletScore) {
function doesTxReuseAddress (tx) {
if (!tx.fiat || tx.fiat.isZero()) {
const sql = `SELECT EXISTS (SELECT DISTINCT to_address FROM cash_in_txs WHERE to_address = $1)`
return db.any(sql, [tx.toAddress])
const sql = `
SELECT EXISTS (
SELECT DISTINCT to_address FROM (
SELECT to_address FROM cash_in_txs WHERE id != $1
) AS x WHERE to_address = $2
)`
return db.one(sql, [tx.id, tx.toAddress]).then(({ exists }) => exists)
}
return Promise.resolve(false)
}
function getWalletScore (tx, pi) {
if (!tx.fiat || tx.fiat.isZero()) {
return pi.rateWallet(tx.cryptoCode, tx.toAddress)
}
// Passthrough the previous result
return pi.isValidWalletScore(tx.walletScore)
.then(isValid => ({
address: tx.toAddress,
score: tx.walletScore,
isValid
}))
pi.isWalletScoringEnabled(tx)
.then(isEnabled => {
if(!isEnabled) return null
if (!tx.fiat || tx.fiat.isZero()) {
return pi.rateWallet(tx.cryptoCode, tx.toAddress)
}
// Passthrough the previous result
return pi.isValidWalletScore(tx.walletScore)
.then(isValid => ({
address: tx.toAddress,
score: tx.walletScore,
isValid
}))
})
}
function monitorPending (settings) {

View file

@ -25,7 +25,6 @@ module.exports = {
const STALE_INCOMING_TX_AGE = T.day
const STALE_LIVE_INCOMING_TX_AGE = 10 * T.minutes
const STALE_LIVE_INCOMING_TX_AGE_FILTER = 5 * T.minutes
const MAX_NOTIFY_AGE = T.day
const MIN_NOTIFY_AGE = 5 * T.minutes
const INSUFFICIENT_FUNDS_CODE = 570
@ -37,7 +36,8 @@ function selfPost (tx, pi) {
}
function post (tx, pi, fromClient = true) {
logger.silly('Updating cashout tx:', tx)
logger.silly('Updating cashout -- tx:', JSON.stringify(tx))
logger.silly('Updating cashout -- fromClient:', JSON.stringify(fromClient))
return cashOutAtomic.atomic(tx, pi, fromClient)
.then(txVector => {
const [, newTx, justAuthorized] = txVector
@ -64,7 +64,7 @@ function postProcess (txVector, justAuthorized, pi) {
fiat: newTx.fiat
})
const bills = billMath.makeChange(cassettes.cassettes, newTx.fiat)
logger.silly('Bills to dispense:', bills)
logger.silly('Bills to dispense:', JSON.stringify(bills))
if (!bills) throw httpError('Out of bills', INSUFFICIENT_FUNDS_CODE)
return bills
@ -91,21 +91,17 @@ function postProcess (txVector, justAuthorized, pi) {
return Promise.resolve({})
}
function fetchOpenTxs (statuses, fromAge, toAge, applyFilter, coinFilter) {
const notClause = applyFilter ? '' : 'not'
function fetchOpenTxs (statuses, fromAge, toAge) {
const sql = `select *
from cash_out_txs
where ((extract(epoch from (now() - created))) * 1000)>$1
and ((extract(epoch from (now() - created))) * 1000)<$2
${_.isEmpty(coinFilter)
? ``
: `and crypto_code ${notClause} in ($3^)`}
and status in ($4^)`
and status in ($3^)
and error is distinct from 'Operator cancel'`
const coinClause = _.map(pgp.as.text, coinFilter).join(',')
const statusClause = _.map(pgp.as.text, statuses).join(',')
return db.any(sql, [fromAge, toAge, coinClause, statusClause])
return db.any(sql, [fromAge, toAge, statusClause])
.then(rows => rows.map(toObj))
}
@ -119,67 +115,55 @@ function processTxStatus (tx, settings) {
}
function getWalletScore (tx, pi) {
const statuses = ['published', 'authorized', 'rejected', 'insufficientFunds']
const rejectEmpty = message => x => _.isNil(x) || _.isEmpty(x) ? Promise.reject({ message }) : x
const statuses = ['published', 'authorized', 'confirmed']
if (_.includes(tx.status, statuses) && _.isNil(tx.walletScore))
return tx
if (_.includes(tx.status, statuses) && _.isNil(tx.walletScore)) {
// Transaction shows up on the blockchain, we can request the sender address
return pi.getTransactionHash(tx)
.then(txHashes => pi.getInputAddresses(tx, txHashes))
.then(addresses => {
const addressesPromise = []
_.forEach(it => addressesPromise.push(pi.rateWallet(tx.cryptoCode, it)), addresses)
return Promise.all(addressesPromise)
})
.then(scores => {
if (_.isNil(scores) || _.isEmpty(scores)) return tx
const highestScore = _.maxBy(it => it.score, scores)
// Conservatively assign the highest risk of all input addresses to the risk of this transaction
return highestScore.isValid
? _.assign(tx, { walletScore: highestScore.score })
: _.assign(tx, {
walletScore: highestScore.score,
error: 'Address score is above defined threshold',
errorCode: 'scoreThresholdReached',
dispense: true
})
})
.catch(error => _.assign(tx, {
walletScore: 10,
error: `Failure getting address score: ${error.message}`,
errorCode: 'ciphertraceError',
dispense: true
}))
}
if (_.includes(tx.status, statuses) && !_.isNil(tx.walletScore) && _.get('errorCode', tx) !== 'ciphertraceError') {
return pi.isValidWalletScore(tx.walletScore)
.then(isValid => isValid ? tx : _.assign(tx, {
error: 'Address score is above defined threshold',
errorCode: 'scoreThresholdReached',
dispense: true
}))
}
return tx
return pi.isWalletScoringEnabled(tx)
.then(isEnabled => {
if (!isEnabled) return tx
return pi.getTransactionHash(tx)
.then(rejectEmpty("No transaction hashes"))
.then(txHashes => pi.getInputAddresses(tx, txHashes))
.then(rejectEmpty("No input addresses"))
.then(addresses => Promise.all(_.map(it => pi.rateWallet(tx.cryptoCode, it), addresses)))
.then(rejectEmpty("No score ratings"))
.then(_.maxBy(_.get(['score'])))
.then(highestScore =>
// Conservatively assign the highest risk of all input addresses to the risk of this transaction
highestScore.isValid
? _.assign(tx, { walletScore: highestScore.score })
: _.assign(tx, {
walletScore: highestScore.score,
error: 'Address score is above defined threshold',
errorCode: 'scoreThresholdReached',
dispense: true
})
)
.catch(error => _.assign(tx, {
walletScore: 10,
error: `Failure getting address score: ${error.message}`,
errorCode: 'ciphertraceError',
dispense: true
}))
})
}
function monitorLiveIncoming (settings, applyFilter, coinFilter) {
function monitorLiveIncoming (settings) {
const statuses = ['notSeen', 'published', 'insufficientFunds']
const toAge = applyFilter ? STALE_LIVE_INCOMING_TX_AGE_FILTER : STALE_LIVE_INCOMING_TX_AGE
return monitorIncoming(settings, statuses, 0, toAge, applyFilter, coinFilter)
return monitorIncoming(settings, statuses, 0, STALE_LIVE_INCOMING_TX_AGE)
}
function monitorStaleIncoming (settings, applyFilter, coinFilter) {
function monitorStaleIncoming (settings) {
const statuses = ['notSeen', 'published', 'authorized', 'instant', 'rejected', 'insufficientFunds']
const fromAge = applyFilter ? STALE_LIVE_INCOMING_TX_AGE_FILTER : STALE_LIVE_INCOMING_TX_AGE
return monitorIncoming(settings, statuses, fromAge, STALE_INCOMING_TX_AGE, applyFilter, coinFilter)
return monitorIncoming(settings, statuses, STALE_LIVE_INCOMING_TX_AGE, STALE_INCOMING_TX_AGE)
}
function monitorIncoming (settings, statuses, fromAge, toAge, applyFilter, coinFilter) {
return fetchOpenTxs(statuses, fromAge, toAge, applyFilter, coinFilter)
function monitorIncoming (settings, statuses, fromAge, toAge) {
return fetchOpenTxs(statuses, fromAge, toAge)
.then(txs => pEachSeries(txs, tx => processTxStatus(tx, settings)))
.catch(err => {
if (err.code === dbErrorCodes.SERIALIZATION_FAILURE) {

View file

@ -32,6 +32,11 @@ const RECEIPT = 'sms_receipt'
const WALLET_SCORE_THRESHOLD = 9
const BALANCE_FETCH_SPEED_MULTIPLIER = {
NORMAL: 1,
SLOW: 3
}
module.exports = {
anonymousCustomer,
CASSETTE_MAX_CAPACITY,
@ -48,5 +53,6 @@ module.exports = {
CASH_OUT_MAXIMUM_AMOUNT_OF_CASSETTES,
WALLET_SCORE_THRESHOLD,
RECEIPT,
PSQL_URL
PSQL_URL,
BALANCE_FETCH_SPEED_MULTIPLIER
}

View file

@ -262,7 +262,7 @@ function deleteEditedData (id, data) {
*/
async function updateEditedPhoto (id, photo, photoType) {
const newPatch = {}
const baseDir = photoType === 'frontCamera' ? frontCameraBaseDir : idPhotoCardBasedir
const baseDir = photoType === 'frontCamera' ? FRONT_CAMERA_DIR : ID_PHOTO_CARD_DIR
const { createReadStream, filename } = photo
const stream = createReadStream()
@ -339,7 +339,7 @@ function camelizeDeep (customer) {
/**
* Get all available complianceTypes
* that can be overriden (excluding hard_limit)
* that can be overridden (excluding hard_limit)
*
* @name getComplianceTypes
* @function
@ -404,7 +404,7 @@ function enhanceAtFields (fields) {
*/
function enhanceOverrideFields (fields, userToken) {
if (!userToken) return fields
// Populate with computedFields (user who overrode and overriden timestamps date)
// Populate with computedFields (user who overrode and overridden timestamps date)
return _.reduce(_.assign, fields, _.map((type) => {
return (fields[type + '_override'])
? {
@ -484,7 +484,7 @@ function getCustomersList (phone = null, name = null, address = null, id = null)
const sql = `SELECT id, authorized_override, days_suspended, is_suspended, front_camera_path, front_camera_override,
phone, sms_override, id_card_data, id_card_data_override, id_card_data_expiration,
id_card_photo_path, id_card_photo_override, us_ssn, us_ssn_override, sanctions, sanctions_at,
sanctions_override, total_txs, total_spent, LEAST(created, last_transaction) AS last_active, fiat AS last_tx_fiat,
sanctions_override, total_txs, total_spent, GREATEST(created, last_transaction, last_data_provided) AS last_active, fiat AS last_tx_fiat,
fiat_code AS last_tx_fiat_code, tx_class AS last_tx_class, custom_fields, notes, is_test_customer
FROM (
SELECT c.id, c.authorized_override,
@ -493,6 +493,7 @@ function getCustomersList (phone = null, name = null, address = null, id = null)
c.front_camera_path, c.front_camera_override,
c.phone, c.sms_override, c.id_card_data, c.id_card_data_override, c.id_card_data_expiration,
c.id_card_photo_path, c.id_card_photo_override, c.us_ssn, c.us_ssn_override, c.sanctions,
GREATEST(c.phone_at, c.id_card_data_at, c.front_camera_at, c.id_card_photo_at, c.us_ssn_at) AS last_data_provided,
c.sanctions_at, c.sanctions_override, c.is_test_customer, c.created, t.tx_class, t.fiat, t.fiat_code, t.created as last_transaction, cn.notes,
row_number() OVER (partition by c.id order by t.created desc) AS rn,
sum(CASE WHEN t.id IS NOT NULL THEN 1 ELSE 0 END) OVER (partition by c.id) AS total_txs,

View file

@ -1,2 +1,3 @@
const path = require('path')
require('dotenv').config({ path: process.env.NODE_ENV === 'production' ? path.resolve('/etc', 'lamassu', '.env') : path.resolve(__dirname, '../.env') })
require('dotenv').config({ path: path.resolve(__dirname, '../.env') })

View file

@ -23,16 +23,18 @@ const speedtestFiles = [
]
const addSmthInfo = (dstField, srcFields) => smth =>
smth && smth.active ? _.set(dstField, _.pick(srcFields, smth)) : _.identity
(smth && smth.active) ? _.set(dstField, _.pick(srcFields, smth)) : _.identity
const addOperatorInfo = addSmthInfo(
'operatorInfo',
['name', 'phone', 'email', 'website', 'companyNumber']
)
const addReceiptInfo = addSmthInfo(
'receiptInfo',
[
const addReceiptInfo = receiptInfo => ret => {
if (!receiptInfo) return ret
const fields = [
'paper',
'sms',
'operatorWebsite',
'operatorEmail',
@ -43,10 +45,22 @@ const addReceiptInfo = addSmthInfo(
'exchangeRate',
'addressQRCode',
]
)
const defaults = _.fromPairs(_.map(field => [field, false], fields))
receiptInfo = _.flow(
o => _.set('paper', o.active, o),
_.assign(defaults),
_.pick(fields),
)(receiptInfo)
return (receiptInfo.paper || receiptInfo.sms) ?
_.set('receiptInfo', receiptInfo, ret) :
ret
}
/* TODO: Simplify this. */
const buildTriggers = (allTriggers) => {
const buildTriggers = allTriggers => {
const normalTriggers = []
const customTriggers = _.filter(o => {
if (_.isEmpty(o.customInfoRequestId) || _.isNil(o.customInfoRequestId)) normalTriggers.push(o)
@ -82,7 +96,6 @@ const staticConfig = ({ currentConfigVersion, deviceId, deviceName, pq, settings
const staticConf = _.flow(
_.pick([
'areThereAvailablePromoCodes',
'coins',
'configVersion',
'timezone'
@ -139,7 +152,7 @@ const staticConfig = ({ currentConfigVersion, deviceId, deviceName, pq, settings
const setZeroConfLimit = config => coin =>
_.set(
'zeroConfLimit',
configManager.getWalletSettings(coin.cryptoCode, config).zeroConfLimit,
configManager.getWalletSettings(coin.cryptoCode, config).zeroConfLimit ?? 0,
coin
)
@ -157,7 +170,7 @@ const dynamicConfig = ({ deviceId, operatorId, pid, pq, settings, }) => {
state.pids = _.update(operatorId, _.set(deviceId, { pid, ts: Date.now() }), state.pids)
return _.flow(
_.pick(['balances', 'cassettes', 'coins', 'rates']),
_.pick(['areThereAvailablePromoCodes', 'balances', 'cassettes', 'coins', 'rates']),
_.update('cassettes', massageCassettes),
@ -172,7 +185,8 @@ const dynamicConfig = ({ deviceId, operatorId, pid, pq, settings, }) => {
/* Group the separate objects by cryptoCode */
/* { balances, coins, rates } => { cryptoCode: { balance, ask, bid, cashIn, cashOut }, ... } */
({ balances, cassettes, coins, rates }) => ({
({ areThereAvailablePromoCodes, balances, cassettes, coins, rates }) => ({
areThereAvailablePromoCodes,
cassettes,
coins: _.flow(
_.reduce(
@ -184,7 +198,10 @@ const dynamicConfig = ({ deviceId, operatorId, pid, pq, settings, }) => {
_.toPairs,
/* [[cryptoCode, { balance, ask, bid, cashIn, cashOut }], ...] => [{ cryptoCode, balance, ask, bid, cashIn, cashOut }, ...] */
_.map(([cryptoCode, obj]) => _.set('cryptoCode', cryptoCode, obj))
_.map(([cryptoCode, obj]) => _.set('cryptoCode', cryptoCode, obj)),
/* Only send coins which have all information needed by the machine. This prevents the machine going down if there's an issue with the coin node */
_.filter(coin => ['ask', 'bid', 'balance', 'cashIn', 'cashOut', 'cryptoCode'].every(it => it in coin))
)(_.concat(balances, coins))
}),
@ -218,6 +235,7 @@ const configs = (parent, { currentConfigVersion }, { deviceId, deviceName, opera
const massageTerms = terms => (terms.active && terms.text) ? ({
tcPhoto: Boolean(terms.tcPhoto),
delay: Boolean(terms.delay),
title: terms.title,
text: nmd(terms.text),

View file

@ -7,7 +7,7 @@ type Coin {
cashInFee: String!
cashInCommission: String!
cashOutCommission: String!
cryptoNetwork: Boolean!
cryptoNetwork: String!
cryptoUnits: String!
batchable: Boolean!
}
@ -32,6 +32,7 @@ type MachineInfo {
}
type ReceiptInfo {
paper: Boolean!
sms: Boolean!
operatorWebsite: Boolean!
operatorEmail: Boolean!
@ -57,6 +58,32 @@ type TriggersAutomation {
usSsn: Boolean!
}
type CustomScreen {
text: String!
title: String!
}
type CustomInput {
type: String!
constraintType: String!
label1: String
label2: String
choiceList: [String]
}
type CustomRequest {
name: String!
input: CustomInput!
screen1: CustomScreen!
screen2: CustomScreen!
}
type CustomInfoRequest {
id: String!
enabled: Boolean!
customRequest: CustomRequest!
}
type Trigger {
id: String!
customInfoRequestId: String!
@ -64,12 +91,14 @@ type Trigger {
requirement: String!
triggerType: String!
suspensionDays: Int
suspensionDays: Float
threshold: Int
thresholdDays: Int
customInfoRequest: CustomInfoRequest
}
type TermsDetails {
tcPhoto: Boolean!
delay: Boolean!
title: String!
accept: String!
@ -85,7 +114,6 @@ type Terms {
type StaticConfig {
configVersion: Int!
areThereAvailablePromoCodes: Boolean!
coins: [Coin!]!
enablePaperWalletOnly: Boolean!
hasLightning: Boolean!
@ -136,6 +164,7 @@ type Cassettes {
}
type DynamicConfig {
areThereAvailablePromoCodes: Boolean!
cassettes: Cassettes
coins: [DynamicCoinValues!]!
reboot: Boolean!

View file

@ -1,8 +1,9 @@
const _ = require('lodash/fp')
const { format } = require('date-fns/fp')
const { format, isValid } = require('date-fns/fp')
const { utcToZonedTime } = require('date-fns-tz/fp')
const db = require('./db')
const logger = require('./logger')
const pgp = require('pg-promise')()
const getMachineName = require('./machine-loader').getMachineName
@ -118,6 +119,10 @@ function logDateFormat (timezone, logs, fields) {
field =>
{
if (_.isNil(log[field])) return null
if (!isValid(log[field])) {
logger.warn(`Tried to convert to ${timezone} timezone the value ${log[field]} and failed. Returning original value...`)
return log[field]
}
const date = utcToZonedTime(timezone, log[field])
return `${format('yyyy-MM-dd', date)}T${format('HH:mm:ss.SSS', date)}`
},

View file

@ -1,9 +1,6 @@
const _ = require('lodash/fp')
const db = require('../db')
const state = require('./state')
const newSettingsLoader = require('../new-settings-loader')
const helpers = require('../route-helpers')
const logger = require('../logger')
db.connect({ direct: true }).then(sco => {
@ -58,31 +55,54 @@ const populateSettings = function (req, res, next) {
}
try {
const operatorSettings = settingsCache.get(operatorId)
if (!versionId && (!operatorSettings || !!needsSettingsReload[operatorId])) {
// Priority of configs to retrieve
// 1. Machine is in the middle of a transaction and has the config-version header set, fetch that config from cache or database, depending on whether it exists in cache
// 2. The operator settings changed, so we must update the cache
// 3. There's a cached config, send the cached value
// 4. There's no cached config, cache and send the latest config
if (versionId) {
const cachedVersionedSettings = settingsCache.get(`${operatorId}-v${versionId}`)
if (!cachedVersionedSettings) {
logger.debug('Fetching a specific config version cached value')
return newSettingsLoader.load(versionId)
.then(settings => {
settingsCache.set(`${operatorId}-v${versionId}`, settings)
req.settings = settings
})
.then(() => next())
.catch(next)
}
logger.debug('Fetching and caching a specific config version')
req.settings = cachedVersionedSettings
return next()
}
const operatorSettings = settingsCache.get(`${operatorId}-latest`)
if (!!needsSettingsReload[operatorId] || !operatorSettings) {
!!needsSettingsReload[operatorId]
? logger.debug('Fetching and caching a new latest config value, as a reload was requested')
: logger.debug('Fetching the latest config version because there\'s no cached value')
return newSettingsLoader.loadLatest()
.then(settings => {
settingsCache.set(operatorId, settings)
delete needsSettingsReload[operatorId]
settingsCache.set(`${operatorId}-latest`, settings)
if (!!needsSettingsReload[operatorId]) delete needsSettingsReload[operatorId]
req.settings = settings
})
.then(() => next())
.catch(next)
}
if (!versionId && operatorSettings) {
req.settings = operatorSettings
return next()
}
logger.debug('Fetching the latest config value from cache')
req.settings = operatorSettings
return next()
} catch (e) {
logger.error(e)
}
newSettingsLoader.load(versionId)
.then(settings => { req.settings = settings })
.then(() => helpers.updateDeviceConfigVersion(versionId))
.then(() => next())
.catch(next)
}
module.exports = populateSettings

View file

@ -57,7 +57,7 @@ function updateOptionBasepath (result, optionName) {
async function run () {
// load current opts
const options = load().opts
const shouldMigrate = !fs.existsSync(process.env.NODE_ENV === 'production' ? path.resolve('/etc', 'lamassu', '.env') : path.resolve(__dirname, '../.env'))
const shouldMigrate = !fs.existsSync(path.resolve(__dirname, '../.env'))
// write the resulting .env
if (shouldMigrate) {

View file

@ -3,7 +3,7 @@ const _ = require('lodash/fp')
const { ALL } = require('../../plugins/common/ccxt')
const { BTC, BCH, DASH, ETH, LTC, ZEC, XMR } = COINS
const { BTC, BCH, DASH, ETH, LTC, USDT, ZEC, XMR } = COINS
const { bitpay, coinbase, itbit, bitstamp, kraken, binanceus, cex, ftx, binance } = ALL
const TICKER = 'ticker'
@ -29,8 +29,8 @@ const ALL_ACCOUNTS = [
{ code: 'mock-ticker', display: 'Mock (Caution!)', class: TICKER, cryptos: ALL_CRYPTOS, dev: true },
{ code: 'bitcoind', display: 'bitcoind', class: WALLET, cryptos: [BTC] },
{ code: 'no-layer2', display: 'No Layer 2', class: LAYER_2, cryptos: ALL_CRYPTOS },
{ code: 'infura', display: 'Infura', class: WALLET, cryptos: [ETH] },
{ code: 'geth', display: 'geth', class: WALLET, cryptos: [ETH] },
{ code: 'infura', display: 'Infura', class: WALLET, cryptos: [ETH, USDT] },
{ code: 'geth', display: 'geth', class: WALLET, cryptos: [ETH, USDT] },
{ code: 'zcashd', display: 'zcashd', class: WALLET, cryptos: [ZEC] },
{ code: 'litecoind', display: 'litecoind', class: WALLET, cryptos: [LTC] },
{ code: 'dashd', display: 'dashd', class: WALLET, cryptos: [DASH] },

View file

@ -21,7 +21,8 @@ function transaction () {
SELECT 'address' AS type, to_address AS value FROM cash_in_txs UNION
SELECT 'address' AS type, to_address AS value FROM cash_out_txs UNION
SELECT 'status' AS type, ${cashInTx.TRANSACTION_STATES} AS value FROM cash_in_txs UNION
SELECT 'status' AS type, ${CASH_OUT_TRANSACTION_STATES} AS value FROM cash_out_txs
SELECT 'status' AS type, ${CASH_OUT_TRANSACTION_STATES} AS value FROM cash_out_txs UNION
SELECT 'sweep status' AS type, CASE WHEN swept THEN 'Swept' WHEN NOT swept THEN 'Unswept' END AS value FROM cash_out_txs
) f`
return db.any(sql)

View file

@ -19,14 +19,14 @@ const resolvers = {
isAnonymous: parent => (parent.customerId === anonymous.uuid)
},
Query: {
transactions: (...[, { from, until, limit, offset, deviceId, txClass, machineName, customerName, fiatCode, cryptoCode, toAddress, status, excludeTestingCustomers }]) =>
transactions.batch(from, until, limit, offset, deviceId, txClass, machineName, customerName, fiatCode, cryptoCode, toAddress, status, excludeTestingCustomers),
transactionsCsv: (...[, { from, until, limit, offset, txClass, machineName, customerName, fiatCode, cryptoCode, toAddress, status, timezone, excludeTestingCustomers, simplified }]) =>
transactions.batch(from, until, limit, offset, null, txClass, machineName, customerName, fiatCode, cryptoCode, toAddress, status, excludeTestingCustomers, simplified)
.then(data => parseAsync(logDateFormat(timezone, data, ['created', 'sendTime']))),
transactions: (...[, { from, until, limit, offset, deviceId, txClass, machineName, customerName, fiatCode, cryptoCode, toAddress, status, swept, excludeTestingCustomers }]) =>
transactions.batch(from, until, limit, offset, deviceId, txClass, machineName, customerName, fiatCode, cryptoCode, toAddress, status, swept, excludeTestingCustomers),
transactionsCsv: (...[, { from, until, limit, offset, txClass, machineName, customerName, fiatCode, cryptoCode, toAddress, status, swept, timezone, excludeTestingCustomers, simplified }]) =>
transactions.batch(from, until, limit, offset, null, txClass, machineName, customerName, fiatCode, cryptoCode, toAddress, status, swept, excludeTestingCustomers, simplified)
.then(data => parseAsync(logDateFormat(timezone, data, ['created', 'sendTime', 'publishedAt']))),
transactionCsv: (...[, { id, txClass, timezone }]) =>
transactions.getTx(id, txClass).then(data =>
parseAsync(logDateFormat(timezone, [data], ['created', 'sendTime']))
parseAsync(logDateFormat(timezone, [data], ['created', 'sendTime', 'publishedAt']))
),
txAssociatedDataCsv: (...[, { id, txClass, timezone }]) =>
transactions.getTxAssociatedData(id, txClass).then(data =>

View file

@ -50,6 +50,7 @@ const typeDef = gql`
batchError: String
walletScore: Int
profit: String
swept: Boolean
}
type Filter {
@ -58,8 +59,8 @@ const typeDef = gql`
}
type Query {
transactions(from: Date, until: Date, limit: Int, offset: Int, deviceId: ID, txClass: String, machineName: String, customerName: String, fiatCode: String, cryptoCode: String, toAddress: String, status: String, excludeTestingCustomers: Boolean): [Transaction] @auth
transactionsCsv(from: Date, until: Date, limit: Int, offset: Int, txClass: String, machineName: String, customerName: String, fiatCode: String, cryptoCode: String, toAddress: String, status: String, timezone: String, excludeTestingCustomers: Boolean, simplified: Boolean): String @auth
transactions(from: Date, until: Date, limit: Int, offset: Int, deviceId: ID, txClass: String, machineName: String, customerName: String, fiatCode: String, cryptoCode: String, toAddress: String, status: String, swept: Boolean, excludeTestingCustomers: Boolean): [Transaction] @auth
transactionsCsv(from: Date, until: Date, limit: Int, offset: Int, txClass: String, machineName: String, customerName: String, fiatCode: String, cryptoCode: String, toAddress: String, status: String, swept: Boolean, timezone: String, excludeTestingCustomers: Boolean, simplified: Boolean): String @auth
transactionCsv(id: ID, txClass: String, timezone: String): String @auth
txAssociatedDataCsv(id: ID, txClass: String, timezone: String): String @auth
transactionFilters: [Filter] @auth

View file

@ -46,6 +46,7 @@ function batch (
cryptoCode = null,
toAddress = null,
status = null,
swept = null,
excludeTestingCustomers = false,
simplified
) {
@ -109,14 +110,33 @@ function batch (
AND ($11 is null or txs.crypto_code = $11)
AND ($12 is null or txs.to_address = $12)
AND ($13 is null or txs.txStatus = $13)
AND ($14 is null or txs.swept = $14)
${excludeTestingCustomers ? `AND c.is_test_customer is false` : ``}
AND (fiat > 0)
ORDER BY created DESC limit $4 offset $5`
return Promise.all([
db.any(cashInSql, [cashInTx.PENDING_INTERVAL, from, until, limit, offset, id, txClass, machineName, customerName, fiatCode, cryptoCode, toAddress, status]),
db.any(cashOutSql, [REDEEMABLE_AGE, from, until, limit, offset, id, txClass, machineName, customerName, fiatCode, cryptoCode, toAddress, status])
])
// The swept filter is cash-out only, so omit the cash-in query entirely
const hasCashInOnlyFilters = false
const hasCashOutOnlyFilters = !_.isNil(swept)
let promises
if (hasCashInOnlyFilters && hasCashOutOnlyFilters) {
throw new Error('Trying to filter transactions with mutually exclusive filters')
}
if (hasCashInOnlyFilters) {
promises = [db.any(cashInSql, [cashInTx.PENDING_INTERVAL, from, until, limit, offset, id, txClass, machineName, customerName, fiatCode, cryptoCode, toAddress, status])]
} else if (hasCashOutOnlyFilters) {
promises = [db.any(cashOutSql, [REDEEMABLE_AGE, from, until, limit, offset, id, txClass, machineName, customerName, fiatCode, cryptoCode, toAddress, status, swept])]
} else {
promises = [
db.any(cashInSql, [cashInTx.PENDING_INTERVAL, from, until, limit, offset, id, txClass, machineName, customerName, fiatCode, cryptoCode, toAddress, status]),
db.any(cashOutSql, [REDEEMABLE_AGE, from, until, limit, offset, id, txClass, machineName, customerName, fiatCode, cryptoCode, toAddress, status, swept])
]
}
return Promise.all(promises)
.then(packager)
.then(res => {
if (simplified) return simplifiedBatch(res)
@ -138,7 +158,7 @@ function advancedBatch (data) {
'txVersion', 'publishedAt', 'termsAccepted', 'layer2Address',
'commissionPercentage', 'rawTickerPrice', 'receivedCryptoAtoms',
'discount', 'txHash', 'customerPhone', 'customerIdCardDataNumber',
'customerIdCardDataExpiration', 'customerIdCardData', 'customerName',
'customerIdCardDataExpiration', 'customerIdCardData', 'customerName', 'sendTime',
'customerFrontCameraPath', 'customerIdCardPhotoPath', 'expired', 'machineName', 'walletScore']
const addAdvancedFields = _.map(it => ({
@ -169,8 +189,8 @@ function simplifiedBatch (data) {
const getCryptoAmount = it => coinUtils.toUnit(BN(it.cryptoAtoms), it.cryptoCode)
const getProfit = it => {
/* fiat - crypto*tickerPrice + fee */
const calcCashInProfit = (fiat, crypto, tickerPrice, fee) => fiat.minus(crypto.times(tickerPrice)).plus(fee)
/* fiat - crypto*tickerPrice */
const calcCashInProfit = (fiat, crypto, tickerPrice) => fiat.minus(crypto.times(tickerPrice))
/* crypto*tickerPrice - fiat */
const calcCashOutProfit = (fiat, crypto, tickerPrice) => crypto.times(tickerPrice).minus(fiat)
@ -180,7 +200,7 @@ const getProfit = it => {
const isCashIn = it.txClass === 'cashIn'
return isCashIn
? calcCashInProfit(fiat, crypto, tickerPrice, BN(it.cashInFee))
? calcCashInProfit(fiat, crypto, tickerPrice)
: calcCashOutProfit(fiat, crypto, tickerPrice)
}

View file

@ -145,15 +145,13 @@ const getTriggersAutomation = (customInfoRequests, config) => {
const splitGetFirst = _.compose(_.head, _.split('_'))
const getCryptosFromWalletNamespace = config => {
return _.uniq(_.map(splitGetFirst, _.keys(fromNamespace('wallets', config))))
}
const getCryptosFromWalletNamespace =
_.compose(_.without(['advanced']), _.uniq, _.map(splitGetFirst), _.keys, fromNamespace('wallets'))
const getCashInSettings = config => fromNamespace(namespaces.CASH_IN)(config)
const getCryptoUnits = (crypto, config) => {
return getWalletSettings(crypto, config).cryptoUnits
}
const getCryptoUnits = (crypto, config) =>
getWalletSettings(crypto, config).cryptoUnits ?? 'full'
const setTermsConditions = toNamespace(namespaces.TERMS_CONDITIONS)

View file

@ -112,6 +112,17 @@ function saveConfig (config) {
})
}
function removeFromConfig (fields) {
return Promise.all([loadLatestConfigOrNone(), getOperatorId('middleware')])
.then(([currentConfig, operatorId]) => {
const newConfig = _.omit(fields, currentConfig)
return db.tx(t => {
return t.none(configSql, ['config', { config: newConfig }, true, NEW_SETTINGS_LOADER_SCHEMA_VERSION])
.then(() => t.none('NOTIFY $1:name, $2', ['reload', JSON.stringify({ schema: asyncLocalStorage.getStore().get('schema'), operatorId })]))
}).catch(console.error)
})
}
function migrationSaveConfig (config) {
return loadLatestConfigOrNone()
.then(currentConfig => {
@ -221,5 +232,6 @@ module.exports = {
loadLatestConfig,
loadLatestConfigOrNone,
load,
migrate
migrate,
removeFromConfig
}

View file

@ -10,6 +10,7 @@ const notificationCenter = require('./notificationCenter')
const utils = require('./utils')
const emailFuncs = require('./email')
const smsFuncs = require('./sms')
const webhookFuncs = require('./webhook')
const { STALE, STALE_STATE } = require('./codes')
function buildMessage (alerts, notifications) {
@ -185,6 +186,10 @@ function complianceNotify (customer, deviceId, action, period) {
email: {
subject: `Customer compliance`,
body: `Customer ${customer.phone} ${msgCore[action]} in machine ${machineName}`
},
webhook: {
topic: `Customer compliance`,
content: `Customer ${customer.phone} ${msgCore[action]} in machine ${machineName}`
}
}
@ -198,8 +203,11 @@ function complianceNotify (customer, deviceId, action, period) {
notifications.sms.active &&
notifications.sms.compliance
const webhookActive = true
if (emailActive) promises.push(emailFuncs.sendMessage(settings, rec))
if (smsActive) promises.push(smsFuncs.sendMessage(settings, rec))
if (webhookActive) promises.push(webhookFuncs.sendMessage(settings, rec))
notifyIfActive('compliance', 'customerComplianceNotify', customer, deviceId, action, period)
@ -220,6 +228,10 @@ function sendRedemptionMessage (txId, error) {
email: {
subject,
body
},
webhook: {
topic: `Transaction update`,
content: body
}
}
return sendTransactionMessage(rec)
@ -241,6 +253,11 @@ function sendTransactionMessage (rec, isHighValueTx) {
(notifications.sms.transactions || isHighValueTx)
if (smsActive) promises.push(smsFuncs.sendMessage(settings, rec))
// TODO: Webhook transaction notifications are dependent on notification settings, due to how transactionNotify() is programmed
// As changing it would require structural change to that function and the current behavior is temporary (webhooks will eventually have settings tied to them), it's not worth those changes right now
const webhookActive = true
if (webhookActive) promises.push(webhookFuncs.sendMessage(settings, rec))
return Promise.all(promises)
})
}
@ -259,6 +276,10 @@ function cashboxNotify (deviceId) {
email: {
subject: `Cashbox removal`,
body: `Cashbox removed in machine ${machineName}`
},
webhook: {
topic: `Cashbox removal`,
content: `Cashbox removed in machine ${machineName}`
}
}
@ -271,9 +292,12 @@ function cashboxNotify (deviceId) {
const smsActive =
notifications.sms.active &&
notifications.sms.security
const webhookActive = true
if (emailActive) promises.push(emailFuncs.sendMessage(settings, rec))
if (smsActive) promises.push(smsFuncs.sendMessage(settings, rec))
if (webhookActive) promises.push(webhookFuncs.sendMessage(settings, rec))
notifyIfActive('security', 'cashboxNotify', deviceId)
return Promise.all(promises)

View file

@ -71,7 +71,9 @@ const fiatBalancesNotify = (fiatWarnings) => {
const { cassette, deviceId } = o.detail
return cassette === balance.cassette && deviceId === balance.deviceId
}, notInvalidated)) return
const message = `Cash-out cassette ${balance.cassette} low or empty!`
const message = balance.code === 'LOW_CASH_OUT' ?
`Cash-out cassette ${balance.cassette} low or empty!` :
`Cash box full or almost full!`
const detailB = utils.buildDetail({ deviceId: balance.deviceId, cassette: balance.cassette })
return queries.addNotification(FIAT_BALANCE, message, detailB)
})
@ -111,11 +113,18 @@ const cryptoBalancesNotify = (cryptoWarnings) => {
}
const balancesNotify = (balances) => {
const cryptoFilter = o => o.code === 'HIGH_CRYPTO_BALANCE' || o.code === 'LOW_CRYPTO_BALANCE'
const fiatFilter = o => o.code === 'LOW_CASH_OUT'
const cryptoWarnings = _.filter(cryptoFilter, balances)
const fiatWarnings = _.filter(fiatFilter, balances)
return Promise.all([cryptoBalancesNotify(cryptoWarnings), fiatBalancesNotify(fiatWarnings)])
const isCryptoCode = c => _.includes(c, ['HIGH_CRYPTO_BALANCE', 'LOW_CRYPTO_BALANCE'])
const isFiatCode = c => _.includes(c, ['LOW_CASH_OUT', 'CASH_BOX_FULL'])
const by = o =>
isCryptoCode(o) ? 'crypto' :
isFiatCode(o) ? 'fiat' :
undefined
const warnings = _.flow(
_.groupBy(_.flow(_.get(['code']), by)),
_.update('crypto', _.defaultTo([])),
_.update('fiat', _.defaultTo([])),
)(balances)
return Promise.all([cryptoBalancesNotify(warnings.crypto), fiatBalancesNotify(warnings.fiat)])
}
const clearOldErrorNotifications = alerts => {

View file

@ -132,6 +132,10 @@ const buildTransactionMessage = (tx, rec, highValueTx, machineName, customer) =>
email: {
emailSubject,
body
},
webhook: {
topic: `New transaction`,
content: body
}
}, highValueTx]
}

21
lib/notifier/webhook.js Normal file
View file

@ -0,0 +1,21 @@
const axios = require('axios')
const _ = require('lodash/fp')
const uuid = require('uuid')
const WEBHOOK_URL = process.env.WEBHOOK_URL
const sendMessage = (settings, rec) => {
if (_.isEmpty(WEBHOOK_URL)) return Promise.resolve()
const body = _.merge(rec.webhook, { id: uuid.v4() })
return axios({
method: 'POST',
url: WEBHOOK_URL,
data: body
})
}
module.exports = {
sendMessage
}

View file

@ -218,6 +218,7 @@ function plugins (settings, deviceId) {
return {
cryptoCode,
display: cryptoRec.display,
isCashInOnly: Boolean(cryptoRec.isCashinOnly),
minimumTx: BN.max(minimumTx, cashInFee),
cashInFee,
cashInCommission,
@ -788,9 +789,10 @@ function plugins (settings, deviceId) {
}
function sweepHdRow (row) {
const txId = row.id
const cryptoCode = row.crypto_code
return wallet.sweep(settings, cryptoCode, row.hd_index)
return wallet.sweep(settings, txId, cryptoCode, row.hd_index)
.then(txHash => {
if (txHash) {
logger.debug('[%s] Swept address with tx: %s', cryptoCode, txHash)
@ -801,12 +803,12 @@ function plugins (settings, deviceId) {
return db.none(sql, row.id)
}
})
.catch(err => logger.error('[%s] Sweep error: %s', cryptoCode, err.message))
.catch(err => logger.error('[%s] [Session ID: %s] Sweep error: %s', cryptoCode, row.id, err.message))
}
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')`
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') AND created > now() - interval '1 week'`
return db.any(sql)
.then(rows => Promise.all(rows.map(sweepHdRow)))
@ -848,6 +850,10 @@ function plugins (settings, deviceId) {
return walletScoring.getInputAddresses(settings, tx.cryptoCode, txHashes)
}
function isWalletScoringEnabled (tx) {
return walletScoring.isWalletScoringEnabled(settings, tx.cryptoCode)
}
return {
getRates,
recordPing,
@ -880,7 +886,8 @@ function plugins (settings, deviceId) {
rateWallet,
isValidWalletScore,
getTransactionHash,
getInputAddresses
getInputAddresses,
isWalletScoringEnabled
}
}

View file

@ -11,7 +11,7 @@ const bitpay = require('../ticker/bitpay')
const binance = require('../exchange/binance')
const logger = require('../../logger')
const { BTC, BCH, DASH, ETH, LTC, ZEC } = COINS
const { BTC, BCH, DASH, ETH, LTC, ZEC, USDT } = COINS
const ALL = {
cex: cex,
@ -22,7 +22,7 @@ const ALL = {
itbit: itbit,
bitpay: bitpay,
coinbase: {
CRYPTO: [BTC, ETH, LTC, DASH, ZEC, BCH],
CRYPTO: [BTC, ETH, LTC, DASH, ZEC, BCH, USDT],
FIAT: 'ALL_CURRENCIES'
},
binance: binance

View file

@ -4,8 +4,8 @@ const _ = require('lodash/fp')
const { ORDER_TYPES } = require('./consts')
const ORDER_TYPE = ORDER_TYPES.MARKET
const { BTC, BCH, DASH, ETH, LTC, ZEC } = COINS
const CRYPTO = [BTC, ETH, LTC, DASH, ZEC, BCH]
const { BTC, BCH, DASH, ETH, LTC, ZEC, USDT } = COINS
const CRYPTO = [BTC, ETH, LTC, DASH, ZEC, BCH, USDT]
const FIAT = ['USD']
const REQUIRED_CONFIG_FIELDS = ['apiKey', 'privateKey']

View file

@ -4,8 +4,8 @@ const _ = require('lodash/fp')
const { ORDER_TYPES } = require('./consts')
const ORDER_TYPE = ORDER_TYPES.MARKET
const { BTC, ETH, LTC, BCH } = COINS
const CRYPTO = [BTC, ETH, LTC, BCH]
const { BTC, ETH, LTC, BCH, USDT } = COINS
const CRYPTO = [BTC, ETH, LTC, BCH, USDT]
const FIAT = ['USD', 'EUR']
const AMOUNT_PRECISION = 8
const REQUIRED_CONFIG_FIELDS = ['key', 'secret', 'clientId']

View file

@ -4,8 +4,8 @@ const _ = require('lodash/fp')
const { ORDER_TYPES } = require('./consts')
const ORDER_TYPE = ORDER_TYPES.MARKET
const { BTC, BCH, DASH, ETH, LTC } = COINS
const CRYPTO = [BTC, ETH, LTC, DASH, BCH]
const { BTC, BCH, DASH, ETH, LTC, USDT } = COINS
const CRYPTO = [BTC, ETH, LTC, DASH, BCH, USDT]
const FIAT = ['USD', 'EUR']
const REQUIRED_CONFIG_FIELDS = ['apiKey', 'privateKey']

View file

@ -4,8 +4,8 @@ const _ = require('lodash/fp')
const { ORDER_TYPES } = require('./consts')
const ORDER_TYPE = ORDER_TYPES.MARKET
const { BTC, BCH, ETH, LTC } = COINS
const CRYPTO = [BTC, ETH, LTC, BCH]
const { BTC, BCH, ETH, LTC, USDT } = COINS
const CRYPTO = [BTC, ETH, LTC, BCH, USDT]
const FIAT = ['USD']
const REQUIRED_CONFIG_FIELDS = ['apiKey', 'privateKey']

View file

@ -4,8 +4,8 @@ const { ORDER_TYPES } = require('./consts')
const { COINS } = require('@lamassu/coins')
const ORDER_TYPE = ORDER_TYPES.LIMIT
const { BTC, ETH } = COINS
const CRYPTO = [BTC, ETH]
const { BTC, ETH, USDT } = COINS
const CRYPTO = [BTC, ETH, USDT]
const FIAT = ['USD']
const AMOUNT_PRECISION = 4
const REQUIRED_CONFIG_FIELDS = ['clientKey', 'clientSecret', 'userId', 'walletId']

View file

@ -4,8 +4,8 @@ const { ORDER_TYPES } = require('./consts')
const { COINS } = require('@lamassu/coins')
const ORDER_TYPE = ORDER_TYPES.MARKET
const { BTC, BCH, DASH, ETH, LTC, ZEC, XMR } = COINS
const CRYPTO = [BTC, ETH, LTC, DASH, ZEC, BCH, XMR]
const { BTC, BCH, DASH, ETH, LTC, ZEC, XMR, USDT } = COINS
const CRYPTO = [BTC, ETH, LTC, DASH, ZEC, BCH, XMR, USDT]
const FIAT = ['USD', 'EUR']
const AMOUNT_PRECISION = 6
const REQUIRED_CONFIG_FIELDS = ['apiKey', 'privateKey']

View file

@ -52,44 +52,62 @@ function isValidWalletScore (account, score) {
return _.isNil(account) ? Promise.resolve(true) : Promise.resolve(score < threshold)
}
function getTransactionHash (account, cryptoCode, receivingAddress) {
const client = getClient(account)
if (!_.includes(_.toUpper(cryptoCode), SUPPORTED_COINS) || _.isNil(client)) return Promise.resolve(null)
function getAddressTransactionsHashes (receivingAddress, cryptoCode, client, wallet) {
const { apiVersion, authHeader } = client
logger.info(`** DEBUG ** getTransactionHash ENDPOINT: https://rest.ciphertrace.com/api/${apiVersion}/${_.toLower(cryptoCode) !== 'btc' ? `${_.toLower(cryptoCode)}_` : ``}address/search?features=tx&address=${receivingAddress}&mempool=true`)
return new Promise(resolve => {
setTimeout(resolve, 2000)
return axios.get(`https://rest.ciphertrace.com/api/${apiVersion}/${_.toLower(cryptoCode) !== 'btc' ? `${_.toLower(cryptoCode)}_` : ``}address/search?features=tx&address=${receivingAddress}&mempool=true`, {
headers: authHeader
})
.then(axios.get(`https://rest.ciphertrace.com/api/${apiVersion}/${_.toLower(cryptoCode) !== 'btc' ? `${_.toLower(cryptoCode)}_` : ``}address/search?features=tx&address=${receivingAddress}&mempool=true`, {
headers: authHeader
}))
.then(res => {
const data = res.data
if (_.size(data.txHistory) > 1) {
logger.warn('An address generated by this wallet was used in more than one transaction')
}
logger.info(`** DEBUG ** getTransactionHash RETURN: ${_.join(', ', _.map(it => it.txHash, data.txHistory))}`)
return _.join(', ', _.map(it => it.txHash, data.txHistory))
})
.then(_.flow(
_.get(['data', 'txHistory']),
_.map(_.get(['txHash']))
))
.catch(err => {
logger.error(`** DEBUG ** getTransactionHash ERROR: ${err}`)
logger.error(`** DEBUG ** Fetching transactions hashes via wallet node...`)
return wallet.getTxHashesByAddress(cryptoCode, receivingAddress)
})
}
function getTransactionHash (account, cryptoCode, receivingAddress, wallet) {
const client = getClient(account)
if (!_.includes(_.toUpper(cryptoCode), SUPPORTED_COINS) || _.isNil(client)) return Promise.resolve(null)
return getAddressTransactionsHashes(receivingAddress, cryptoCode, client, wallet)
.then(txHashes => {
if (_.size(txHashes) > 1) {
logger.warn('An address generated by this wallet was used in more than one transaction')
}
logger.info('** DEBUG ** getTransactionHash RETURN: ', _.join(', ', txHashes))
return txHashes
})
.catch(err => {
logger.error('** DEBUG ** getTransactionHash from wallet node ERROR: ', err)
throw err
})
}
function getInputAddresses (account, cryptoCode, txHashes) {
const client = getClient(account)
if (!_.includes(_.toUpper(cryptoCode), SUPPORTED_COINS) || _.isNil(client)) return Promise.resolve(null)
if (_.isEmpty(txHashes) || !_.includes(_.toUpper(cryptoCode), SUPPORTED_COINS) || _.isNil(client))
return Promise.resolve([])
/* NOTE: The API accepts at most 10 hashes, and for us here more than 1 is already an exception, not the norm. */
if (_.size(txHashes) > 10)
return Promise.reject(new Error("Too many tx hashes -- shouldn't happen!"))
const { apiVersion, authHeader } = client
logger.info(`** DEBUG ** getInputAddresses ENDPOINT: https://rest.ciphertrace.com/api/${apiVersion}/${_.toLower(cryptoCode) !== 'btc' ? `${_.toLower(cryptoCode)}_` : ``}tx?txhashes=${txHashes}`)
cryptoCode = _.toLower(cryptoCode)
const lastPathComp = cryptoCode !== 'btc' ? cryptoCode + '_tx' : 'tx'
return axios.get(`https://rest.ciphertrace.com/api/${apiVersion}/${_.toLower(cryptoCode) !== 'btc' ? `${_.toLower(cryptoCode)}_` : ``}tx?txhashes=${txHashes}`, {
headers: authHeader
})
txHashes = _(txHashes).take(10).join(',')
const url = `https://rest.ciphertrace.com/api/${apiVersion}/${lastPathComp}?txhashes=${txHashes}`
console.log('** DEBUG ** getInputAddresses ENDPOINT: ', url)
return axios.get(url, { headers: authHeader })
.then(res => {
const data = res.data
if (_.size(data.transactions) > 1) {
@ -109,10 +127,19 @@ function getInputAddresses (account, cryptoCode, txHashes) {
})
}
function isWalletScoringEnabled (account, cryptoCode) {
if (!SUPPORTED_COINS.includes(cryptoCode)) {
return Promise.reject(new Error('Unsupported crypto: ' + cryptoCode))
}
return Promise.resolve(!_.isNil(account) && account.enabled)
}
module.exports = {
NAME,
rateWallet,
isValidWalletScore,
getTransactionHash,
getInputAddresses
getInputAddresses,
isWalletScoringEnabled
}

View file

@ -36,10 +36,20 @@ function getInputAddresses (account, cryptoCode, txHashes) {
})
}
function isWalletScoringEnabled (account, cryptoCode) {
return new Promise((resolve, _) => {
setTimeout(() => {
return resolve(true)
}, 100)
})
}
module.exports = {
NAME,
rateWallet,
isValidWalletScore,
getTransactionHash,
getInputAddresses
getInputAddresses,
isWalletScoringEnabled
}

View file

@ -129,6 +129,13 @@ function checkBlockchainStatus (cryptoCode) {
.then(res => !!res['initialblockdownload'] ? 'syncing' : 'ready')
}
function getTxHashesByAddress (cryptoCode, address) {
checkCryptoCode(cryptoCode)
.then(() => fetch('listreceivedbyaddress', [0, true, true, address]))
.then(txsByAddress => Promise.all(_.map(id => fetch('getrawtransaction', [id]), _.flatMap(it => it.txids, txsByAddress))))
.then(_.map(({ hash }) => hash))
}
module.exports = {
balance,
sendCoins,
@ -136,5 +143,6 @@ module.exports = {
getStatus,
newFunding,
cryptoNetwork,
checkBlockchainStatus
checkBlockchainStatus,
getTxHashesByAddress
}

View file

@ -191,6 +191,13 @@ function checkBlockchainStatus (cryptoCode) {
.then(res => !!res['initialblockdownload'] ? 'syncing' : 'ready')
}
function getTxHashesByAddress (cryptoCode, address) {
checkCryptoCode(cryptoCode)
.then(() => fetch('listreceivedbyaddress', [0, true, true, address]))
.then(txsByAddress => Promise.all(_.map(id => fetch('getrawtransaction', [id]), _.flatMap(it => it.txids, txsByAddress))))
.then(_.map(({ hash }) => hash))
}
module.exports = {
balance,
sendCoins,
@ -202,5 +209,6 @@ module.exports = {
estimateFee,
sendCoinsBatch,
checkBlockchainStatus,
getTxHashesByAddress,
SUPPORTS_BATCHING
}

View file

@ -125,11 +125,19 @@ function checkBlockchainStatus (cryptoCode) {
.then(res => !!res['initialblockdownload'] ? 'syncing' : 'ready')
}
function getTxHashesByAddress (cryptoCode, address) {
checkCryptoCode(cryptoCode)
.then(() => fetch('listreceivedbyaddress', [0, true, true, true, address]))
.then(txsByAddress => Promise.all(_.map(id => fetch('getrawtransaction', [id]), _.flatMap(it => it.txids, txsByAddress))))
.then(_.map(({ hash }) => hash))
}
module.exports = {
balance,
sendCoins,
newAddress,
getStatus,
newFunding,
checkBlockchainStatus
checkBlockchainStatus,
getTxHashesByAddress
}

View file

@ -6,11 +6,15 @@ const web3 = new Web3()
const hdkey = require('ethereumjs-wallet/hdkey')
const { FeeMarketEIP1559Transaction } = require('@ethereumjs/tx')
const { default: Common, Chain, Hardfork } = require('@ethereumjs/common')
const Tx = require('ethereumjs-tx')
const { default: PQueue } = require('p-queue')
const util = require('ethereumjs-util')
const coins = require('@lamassu/coins')
const pify = require('pify')
const _pify = require('pify')
const BN = require('../../../bn')
const ABI = require('../../tokens')
const logger = require('../../../logger')
exports.SUPPORTED_MODULES = ['wallet']
@ -30,7 +34,27 @@ module.exports = {
privateKey,
isStrictAddress,
connect,
checkBlockchainStatus
checkBlockchainStatus,
getTxHashesByAddress,
_balance
}
const SWEEP_QUEUE = new PQueue({
concurrency: 3,
interval: 250,
})
const infuraCalls = {}
const pify = _function => {
if (_.isString(_function.call)) logInfuraCall(_function.call)
return _pify(_function)
}
const logInfuraCall = call => {
if (!_.includes('infura', web3.currentProvider.host)) return
_.isNil(infuraCalls[call]) ? infuraCalls[call] = 1 : infuraCalls[call]++
logger.info(`Calling web3 method ${call} via Infura. Current count for this session: ${JSON.stringify(infuraCalls)}`)
}
function connect (url) {
@ -44,12 +68,19 @@ function privateKey (account) {
}
function isStrictAddress (cryptoCode, toAddress, settings, operatorId) {
return cryptoCode === 'ETH' && util.isValidChecksumAddress(toAddress)
return checkCryptoCode(cryptoCode)
.then(() => util.isValidChecksumAddress(toAddress))
}
function getTxHashesByAddress (cryptoCode, address) {
throw new Error(`Transactions hash retrieval is not implemented for this coin!`)
}
function sendCoins (account, tx, settings, operatorId, feeMultiplier) {
const { toAddress, cryptoAtoms, cryptoCode } = tx
return generateTx(toAddress, defaultWallet(account), cryptoAtoms, false, cryptoCode)
const isErc20Token = coins.utils.isErc20Token(cryptoCode)
return (isErc20Token ? generateErc20Tx : generateTx)(toAddress, defaultWallet(account), cryptoAtoms, false, cryptoCode)
.then(pify(web3.eth.sendSignedTransaction))
.then(txid => {
return pify(web3.eth.getTransaction)(txid)
@ -77,14 +108,16 @@ function balance (account, cryptoCode, settings, operatorId) {
const pendingBalance = (address, cryptoCode) => {
const promises = [_balance(true, address, cryptoCode), _balance(false, address, cryptoCode)]
return Promise.all(promises).then(([pending, confirmed]) => pending.minus(confirmed))
return Promise.all(promises).then(([pending, confirmed]) => BN(pending).minus(confirmed))
}
const confirmedBalance = (address, cryptoCode) => _balance(false, address, cryptoCode)
function _balance (includePending, address, cryptoCode) {
if (coins.utils.isErc20Token(cryptoCode)) {
const contract = web3.eth.contract(ABI.ERC20).at(coins.utils.getErc20Token(cryptoCode).contractAddress)
return contract.balanceOf(address.toLowerCase())
const contract = new web3.eth.Contract(ABI.ERC20, coins.utils.getErc20Token(cryptoCode).contractAddress)
return contract.methods.balanceOf(address.toLowerCase()).call((_, balance) => {
return contract.methods.decimals().call((_, decimals) => BN(balance).div(10 ** decimals))
})
}
const block = includePending ? 'pending' : undefined
return pify(web3.eth.getBalance)(address.toLowerCase(), block)
@ -92,27 +125,78 @@ function _balance (includePending, address, cryptoCode) {
.then(balance => balance ? BN(balance) : BN(0))
}
function generateTx (_toAddress, wallet, amount, includesFee, cryptoCode) {
function generateErc20Tx (_toAddress, wallet, amount, includesFee, cryptoCode) {
const fromAddress = '0x' + wallet.getAddress().toString('hex')
const isErc20Token = coins.utils.isErc20Token(cryptoCode)
const toAddress = isErc20Token ? coins.utils.getErc20Token(cryptoCode).contractAddress : _toAddress.toLowerCase()
const toAddress = coins.utils.getErc20Token(cryptoCode).contractAddress
let contract, contractData
if (isErc20Token) {
contract = web3.eth.contract(ABI.ERC20).at(toAddress)
contractData = isErc20Token && contract.transfer.getData(_toAddress.toLowerCase(), hex(toSend))
const contract = new web3.eth.Contract(ABI.ERC20, toAddress)
const contractData = contract.methods.transfer(_toAddress.toLowerCase(), hex(amount))
const txTemplate = {
from: fromAddress,
to: toAddress,
value: hex(BN(0)),
data: contractData.encodeABI()
}
const common = new Common({ chain: Chain.Mainnet, hardfork: Hardfork.London })
const promises = [
pify(contractData.estimateGas)(txTemplate),
pify(web3.eth.getTransactionCount)(fromAddress),
pify(web3.eth.getBlock)('pending')
]
return Promise.all(promises)
.then(([gas, txCount, { baseFeePerGas }]) => [
BN(gas),
_.max([0, txCount, lastUsedNonces[fromAddress] + 1]),
BN(baseFeePerGas)
])
.then(([gas, txCount, baseFeePerGas]) => {
lastUsedNonces[fromAddress] = txCount
const maxPriorityFeePerGas = new BN(web3.utils.toWei('2.5', 'gwei')) // web3 default value
const maxFeePerGas = new BN(2).times(baseFeePerGas).plus(maxPriorityFeePerGas)
if (includesFee && (toSend.isNegative() || toSend.isZero())) {
throw new Error(`Trying to send a nil or negative amount (Transaction ID: ${txId} | Value provided: ${toSend.toNumber()}). This is probably caused due to the estimated fee being higher than the address' balance.`)
}
const rawTx = {
chainId: 1,
nonce: txCount,
maxPriorityFeePerGas: web3.utils.toHex(maxPriorityFeePerGas),
maxFeePerGas: web3.utils.toHex(maxFeePerGas),
gasLimit: hex(gas),
to: toAddress,
from: fromAddress,
value: hex(BN(0)),
data: contractData.encodeABI()
}
const tx = FeeMarketEIP1559Transaction.fromTxData(rawTx, { common })
const privateKey = wallet.getPrivateKey()
const signedTx = tx.sign(privateKey)
return '0x' + signedTx.serialize().toString('hex')
})
}
function generateTx (_toAddress, wallet, amount, includesFee, cryptoCode, txId) {
const fromAddress = '0x' + wallet.getAddress().toString('hex')
const toAddress = _toAddress.toLowerCase()
const txTemplate = {
from: fromAddress,
to: toAddress,
value: amount.toString()
}
if (isErc20Token) txTemplate.data = contractData
const common = new Common({ chain: Chain.Ropsten, hardfork: Hardfork.London })
const common = new Common({ chain: Chain.Mainnet, hardfork: Hardfork.London })
const promises = [
pify(web3.eth.estimateGas)(txTemplate),
@ -122,34 +206,33 @@ function generateTx (_toAddress, wallet, amount, includesFee, cryptoCode) {
]
return Promise.all(promises)
.then(([gas, gasPrice, txCount]) => [
.then(([gas, gasPrice, txCount, { baseFeePerGas }]) => [
BN(gas),
BN(gasPrice),
_.max([0, txCount, lastUsedNonces[fromAddress] + 1])
_.max([0, txCount, lastUsedNonces[fromAddress] + 1]),
BN(baseFeePerGas)
])
.then(([gas, gasPrice, txCount, baseFeePerGas]) => {
lastUsedNonces[fromAddress] = txCount
const toSend = includesFee
? amount.minus(gasPrice.times(gas))
: amount
const maxPriorityFeePerGas = new BN(web3.utils.toWei('2.5', 'gwei')) // web3 default value
const neededPriority = new BN(web3.utils.toWei('2.0', 'gwei'))
const maxFeePerGas = baseFeePerGas.plus(neededPriority)
const newGasPrice = BN.minimum(maxFeePerGas, baseFeePerGas.plus(maxPriorityFeePerGas))
const maxPriorityFeePerGas = new BN(2.5) // web3 default value
const maxFeePerGas = new BN(2).times(baseFeePerGas).plus(maxPriorityFeePerGas)
const toSend = includesFee
? new BN(amount).minus(newGasPrice.times(gas))
: amount
const rawTx = {
chainId: 1,
nonce: txCount,
maxPriorityFeePerGas: web3.utils.toHex(web3.utils.toWei(maxPriorityFeePerGas.toString(), 'gwei')),
maxFeePerGas: web3.utils.toHex(web3.utils.toWei(maxFeePerGas.toString(), 'gwei')),
maxPriorityFeePerGas: web3.utils.toHex(maxPriorityFeePerGas),
maxFeePerGas: web3.utils.toHex(maxFeePerGas),
gasLimit: hex(gas),
to: toAddress,
from: fromAddress,
value: isErc20Token ? hex(BN(0)) : hex(toSend)
}
if (isErc20Token) {
rawTx.data = contractData
value: hex(toSend)
}
const tx = FeeMarketEIP1559Transaction.fromTxData(rawTx, { common })
@ -169,17 +252,18 @@ function defaultAddress (account) {
return defaultWallet(account).getChecksumAddressString()
}
function sweep (account, cryptoCode, hdIndex, settings, operatorId) {
function sweep (account, txId, cryptoCode, hdIndex, settings, operatorId) {
const wallet = paymentHdNode(account).deriveChild(hdIndex).getWallet()
const fromAddress = wallet.getChecksumAddressString()
return confirmedBalance(fromAddress, cryptoCode)
return SWEEP_QUEUE.add(() => confirmedBalance(fromAddress, cryptoCode)
.then(r => {
if (r.eq(0)) return
return generateTx(defaultAddress(account), wallet, r, true, cryptoCode)
return generateTx(defaultAddress(account), wallet, r, true, cryptoCode, txId)
.then(signedTx => pify(web3.eth.sendSignedTransaction)(signedTx))
})
)
}
function newAddress (account, info, tx, settings, operatorId) {

View file

@ -1,5 +1,10 @@
const _ = require('lodash/fp')
const NodeCache = require('node-cache')
const base = require('../geth/base')
const T = require('../../../time')
const { BALANCE_FETCH_SPEED_MULTIPLIER } = require('../../../constants')
const REGULAR_TX_POLLING = 5 * T.seconds
const NAME = 'infura'
@ -12,4 +17,54 @@ function run (account) {
base.connect(endpoint)
}
module.exports = _.merge(base, { NAME, run })
const txsCache = new NodeCache({
stdTTL: T.hour / 1000,
checkperiod: T.minute / 1000,
deleteOnExpire: true
})
function shouldGetStatus (tx) {
const timePassedSinceTx = Date.now() - new Date(tx.created)
const timePassedSinceReq = Date.now() - new Date(txsCache.get(tx.id).lastReqTime)
// Allow for infura to gradually lower the amount of requests based on the time passed since the transaction
// Until first 5 minutes - 1/2 regular polling speed
// Until first 10 minutes - 1/4 regular polling speed
// Until first hour - 1/8 polling speed
// Until first two hours - 1/12 polling speed
// Until first four hours - 1/16 polling speed
// Until first day - 1/24 polling speed
// After first day - 1/32 polling speed
if (timePassedSinceTx < 5 * T.minutes) return _.isNil(txsCache.get(tx.id).res) || timePassedSinceReq > 2 * REGULAR_TX_POLLING
if (timePassedSinceTx < 10 * T.minutes) return _.isNil(txsCache.get(tx.id).res) || timePassedSinceReq > 4 * REGULAR_TX_POLLING
if (timePassedSinceTx < 1 * T.hour) return _.isNil(txsCache.get(tx.id).res) || timePassedSinceReq > 8 * REGULAR_TX_POLLING
if (timePassedSinceTx < 2 * T.hours) return _.isNil(txsCache.get(tx.id).res) || timePassedSinceReq > 12 * REGULAR_TX_POLLING
if (timePassedSinceTx < 4 * T.hours) return _.isNil(txsCache.get(tx.id).res) || timePassedSinceReq > 16 * REGULAR_TX_POLLING
if (timePassedSinceTx < 1 * T.day) return _.isNil(txsCache.get(tx.id).res) || timePassedSinceReq > 24 * REGULAR_TX_POLLING
return _.isNil(txsCache.get(tx.id).res) || timePassedSinceReq > 32 * REGULAR_TX_POLLING
}
// Override geth's getStatus function to allow for different polling timing
function getStatus (account, tx, requested, settings, operatorId) {
if (_.isNil(txsCache.get(tx.id))) {
txsCache.set(tx.id, { lastReqTime: Date.now() })
}
// return last available response
if (!shouldGetStatus(tx)) {
return Promise.resolve(txsCache.get(tx.id).res)
}
return base.getStatus(account, tx, requested, settings, operatorId)
.then(res => {
if (res.status === 'confirmed') {
txsCache.del(tx.id) // Transaction reached final status, can trim it from the caching obj
} else {
txsCache.set(tx.id, { lastReqTime: Date.now(), res })
txsCache.ttl(tx.id, T.hour / 1000)
}
return res
})
}
module.exports = _.merge(base, { NAME, run, getStatus, fetchSpeed: BALANCE_FETCH_SPEED_MULTIPLIER.SLOW })

View file

@ -125,11 +125,16 @@ function checkBlockchainStatus (cryptoCode) {
.then(res => !!res['initialblockdownload'] ? 'syncing' : 'ready')
}
function getTxHashesByAddress (cryptoCode, address) {
throw new Error(`Transactions hash retrieval not implemented for this coin!`)
}
module.exports = {
balance,
sendCoins,
newAddress,
getStatus,
newFunding,
checkBlockchainStatus
checkBlockchainStatus,
getTxHashesByAddress
}

View file

@ -10,9 +10,14 @@ const SECONDS = 1000
const PUBLISH_TIME = 3 * SECONDS
const AUTHORIZE_TIME = PUBLISH_TIME + 5 * SECONDS
const CONFIRM_TIME = AUTHORIZE_TIME + 10 * SECONDS
const SUPPORTED_COINS = coinUtils.cryptoCurrencies()
let t0
const checkCryptoCode = (cryptoCode) => !_.includes(cryptoCode, SUPPORTED_COINS)
? Promise.reject(new Error('Unsupported crypto: ' + cryptoCode))
: Promise.resolve()
function _balance (cryptoCode) {
const cryptoRec = coinUtils.getCryptoCurrency(cryptoCode)
const unitScale = cryptoRec.unitScale
@ -107,7 +112,15 @@ function getStatus (account, tx, requested, settings, operatorId) {
console.log('[%s] DEBUG: Mock wallet has confirmed transaction [%s]', cryptoCode, toAddress.slice(0, 5))
return Promise.resolve({status: 'confirmed'})
return Promise.resolve({ status: 'confirmed' })
}
function getTxHashesByAddress (cryptoCode, address) {
return new Promise((resolve, reject) => {
setTimeout(() => {
return resolve([]) // TODO: should return something other than empty list?
}, 100)
})
}
function checkBlockchainStatus (cryptoCode) {
@ -123,5 +136,6 @@ module.exports = {
newAddress,
getStatus,
newFunding,
checkBlockchainStatus
checkBlockchainStatus,
getTxHashesByAddress
}

View file

@ -92,7 +92,7 @@ function handleError (error, method) {
function openWallet () {
return fetch('open_wallet', { filename: 'Wallet' })
.catch(err => handleError(err, 'openWallet'))
.catch(() => openWalletWithPassword())
}
function openWalletWithPassword () {
@ -164,7 +164,7 @@ function getStatus (account, tx, requested, settings, operatorId) {
return checkCryptoCode(cryptoCode)
.then(() => refreshWallet())
.then(() => fetch('get_address_index', { address: toAddress }))
.then(addressRes => fetch('get_transfers', { in: true, pool: true, account_index: addressRes.index.major, address_indices: [addressRes.index.minor] }))
.then(addressRes => fetch('get_transfers', { in: true, pool: true, account_index: addressRes.index.major, subaddr_indices: [addressRes.index.minor] }))
.then(transferRes => {
const confirmedToAddress = _.filter(it => it.address === toAddress, transferRes.in ?? [])
const pendingToAddress = _.filter(it => it.address === toAddress, transferRes.pool ?? [])
@ -235,6 +235,14 @@ function checkBlockchainStatus (cryptoCode) {
})
}
function getTxHashesByAddress (cryptoCode, address) {
checkCryptoCode(cryptoCode)
.then(() => refreshWallet())
.then(() => fetch('get_address_index', { address: address }))
.then(addressRes => fetch('get_transfers', { in: true, pool: true, pending: true, account_index: addressRes.index.major, subaddr_indices: [addressRes.index.minor] }))
.then(_.map(({ txid }) => txid))
}
module.exports = {
balance,
sendCoins,
@ -242,5 +250,6 @@ module.exports = {
getStatus,
newFunding,
cryptoNetwork,
checkBlockchainStatus
checkBlockchainStatus,
getTxHashesByAddress
}

View file

@ -84,7 +84,7 @@ function newFunding (account, cryptoCode, settings, operatorId) {
throw new E.NotImplementedError()
}
function sweep (account, cryptoCode, hdIndex, settings, operatorId) {
function sweep (account, txId, cryptoCode, hdIndex, settings, operatorId) {
throw new E.NotImplementedError()
}

View file

@ -74,7 +74,7 @@ function sendCoins (account, tx, settings, operatorId) {
const checker = opid => pRetry(() => checkSendStatus(opid), { retries: 20, minTimeout: 300, factor: 1.05 })
return checkCryptoCode(cryptoCode)
.then(() => fetch('z_sendmany', ['ANY_TADDR', [{ address: toAddress, amount: coins }]]))
.then(() => fetch('z_sendmany', ['ANY_TADDR', [{ address: toAddress, amount: coins }], null, null, 'NoPrivacy']))
.then(checker)
.then((res) => {
return {
@ -151,11 +151,17 @@ function checkBlockchainStatus (cryptoCode) {
.then(res => !!res['initial_block_download_complete'] ? 'ready' : 'syncing')
}
function getTxHashesByAddress (cryptoCode, address) {
checkCryptoCode(cryptoCode)
.then(() => fetch('getaddresstxids', [address]))
}
module.exports = {
balance,
sendCoins,
newAddress,
getStatus,
newFunding,
checkBlockchainStatus
checkBlockchainStatus,
getTxHashesByAddress
}

View file

@ -21,10 +21,8 @@ const processBatches = require('./tx-batching-processing')
const INCOMING_TX_INTERVAL = 30 * T.seconds
const LIVE_INCOMING_TX_INTERVAL = 5 * T.seconds
const INCOMING_TX_INTERVAL_FILTER = 1 * T.minute
const LIVE_INCOMING_TX_INTERVAL_FILTER = 10 * T.seconds
const UNNOTIFIED_INTERVAL = 10 * T.seconds
const SWEEP_HD_INTERVAL = T.minute
const SWEEP_HD_INTERVAL = 5 * T.minute
const TRADE_INTERVAL = 60 * T.seconds
const PONG_INTERVAL = 10 * T.seconds
const LOGS_CLEAR_INTERVAL = 1 * T.day
@ -61,7 +59,6 @@ const QUEUE = {
SLOW: SLOW_QUEUE
}
const coinFilter = ['ETH']
const schemaCallbacks = new Map()
const cachedVariables = new NodeCache({
@ -168,12 +165,8 @@ function doPolling (schema) {
pi().executeTrades()
pi().pong()
pi().clearOldLogs()
cashOutTx.monitorLiveIncoming(settings(), false, coinFilter)
cashOutTx.monitorStaleIncoming(settings(), false, coinFilter)
if (!_.isEmpty(coinFilter)) {
cashOutTx.monitorLiveIncoming(settings(), true, coinFilter)
cashOutTx.monitorStaleIncoming(settings(), true, coinFilter)
}
cashOutTx.monitorLiveIncoming(settings())
cashOutTx.monitorStaleIncoming(settings())
cashOutTx.monitorUnnotified(settings())
pi().sweepHd()
notifier.checkNotification(pi())
@ -181,12 +174,8 @@ function doPolling (schema) {
addToQueue(pi().getRawRates, TICKER_RATES_INTERVAL, schema, QUEUE.FAST)
addToQueue(pi().executeTrades, TRADE_INTERVAL, schema, QUEUE.FAST)
addToQueue(cashOutTx.monitorLiveIncoming, LIVE_INCOMING_TX_INTERVAL, schema, QUEUE.FAST, settings, false, coinFilter)
addToQueue(cashOutTx.monitorStaleIncoming, INCOMING_TX_INTERVAL, schema, QUEUE.FAST, settings, false, coinFilter)
if (!_.isEmpty(coinFilter)) {
addToQueue(cashOutTx.monitorLiveIncoming, LIVE_INCOMING_TX_INTERVAL_FILTER, schema, QUEUE.FAST, settings, true, coinFilter)
addToQueue(cashOutTx.monitorStaleIncoming, INCOMING_TX_INTERVAL_FILTER, schema, QUEUE.FAST, settings, true, coinFilter)
}
addToQueue(cashOutTx.monitorLiveIncoming, LIVE_INCOMING_TX_INTERVAL, schema, QUEUE.FAST, settings)
addToQueue(cashOutTx.monitorStaleIncoming, INCOMING_TX_INTERVAL, schema, QUEUE.FAST, settings)
addToQueue(cashOutTx.monitorUnnotified, UNNOTIFIED_INTERVAL, schema, QUEUE.FAST, settings)
addToQueue(cashInTx.monitorPending, PENDING_INTERVAL, schema, QUEUE.FAST, settings)
addToQueue(processBatches, UNNOTIFIED_INTERVAL, schema, QUEUE.FAST, settings, TRANSACTION_BATCH_LIFECYCLE)

View file

@ -85,14 +85,9 @@ function fetchStatusTx (txId, status) {
})
}
function updateDeviceConfigVersion (versionId) {
return db.none('update devices set user_config_id=$1', [versionId])
}
module.exports = {
stateChange,
fetchPhoneTx,
fetchStatusTx,
updateDeviceConfigVersion,
httpError
}

View file

@ -6,21 +6,36 @@ const notifier = require('../notifier')
const { getMachine, setMachine } = require('../machine-loader')
const { loadLatestConfig } = require('../new-settings-loader')
const { getCashInSettings } = require('../new-config-manager')
const { AUTOMATIC } = require('../constants.js')
const { AUTOMATIC } = require('../constants')
const logger = require('../logger')
function notifyCashboxRemoval (req, res, next) {
const operatorId = res.locals.operatorId
logger.info(`** DEBUG ** - Cashbox removal - Received a cashbox opening request from device ${req.deviceId}`)
return notifier.cashboxNotify(req.deviceId)
.then(() => Promise.all([getMachine(req.deviceId), loadLatestConfig()]))
.then(([machine, config]) => {
logger.info('** DEBUG ** - Cashbox removal - Retrieving system options for cash-in')
const cashInSettings = getCashInSettings(config)
if (cashInSettings.cashboxReset !== AUTOMATIC) {
logger.info('** DEBUG ** - Cashbox removal - Cashbox reset is set to manual. A cashbox batch will NOT be created')
logger.info(`** DEBUG ** - Cashbox removal - Process finished`)
return res.status(200).send({ status: 'OK' })
}
logger.info('** DEBUG ** - Cashbox removal - Cashbox reset is set to automatic. A cashbox batch WILL be created')
logger.info('** DEBUG ** - Cashbox removal - Creating new batch...')
return cashbox.createCashboxBatch(req.deviceId, machine.cashbox)
.then(() => setMachine({ deviceId: req.deviceId, action: 'emptyCashInBills' }, operatorId))
.then(() => res.status(200).send({ status: 'OK' }))
.then(() => {
logger.info(`** DEBUG ** - Cashbox removal - Finished creating the new cashbox batch`)
logger.info(`** DEBUG ** - Cashbox removal - Resetting the cashbox counter on device ${req.deviceId}`)
return setMachine({ deviceId: req.deviceId, action: 'emptyCashInBills' }, operatorId)
})
.then(() => {
logger.info(`** DEBUG ** - Cashbox removal - Process finished`)
return res.status(200).send({ status: 'OK' })
})
})
.catch(next)
}

View file

@ -1,11 +1,12 @@
const express = require('express')
const router = express.Router()
const semver = require('semver')
const sms = require('../sms')
const _ = require('lodash/fp')
const BN = require('../bn')
const { zonedTimeToUtc, utcToZonedTime } = require('date-fns-tz/fp')
const { add, intervalToDuration } = require('date-fns/fp')
const sms = require('../sms')
const BN = require('../bn')
const compliance = require('../compliance')
const complianceTriggers = require('../compliance-triggers')
const configManager = require('../new-config-manager')
@ -18,6 +19,7 @@ const { getTx } = require('../new-admin/services/transactions.js')
const machineLoader = require('../machine-loader')
const { loadLatestConfig } = require('../new-settings-loader')
const customInfoRequestQueries = require('../new-admin/services/customInfoRequests')
const T = require('../time')
function updateCustomerCustomInfoRequest (customerId, patch, req, res) {
if (_.isNil(patch.data)) {
@ -112,9 +114,9 @@ function triggerSuspend (req, res, next) {
const days = triggerId === 'no-ff-camera' ? 1 : getSuspendDays(triggers)
const date = new Date()
date.setDate(date.getDate() + days)
customers.update(id, { suspendedUntil: date })
const suspensionDuration = intervalToDuration({ start: 0, end: T.day * days })
customers.update(id, { suspendedUntil: add(suspensionDuration, new Date()) })
.then(customer => {
notifier.complianceNotify(customer, req.deviceId, 'SUSPENDED', days)
return respond(req, res, { customer })

View file

@ -1,3 +1,4 @@
const _ = require('lodash/fp')
const mem = require('mem')
const configManager = require('./new-config-manager')
const logger = require('./logger')
@ -9,6 +10,8 @@ const bitpay = require('./plugins/ticker/bitpay')
const FETCH_INTERVAL = 58000
const PEGGED_FIAT_CURRENCIES = { NAD: 'ZAR' }
function _getRates (settings, fiatCode, cryptoCode) {
return Promise.resolve()
.then(() => {
@ -33,9 +36,12 @@ function _getRates (settings, fiatCode, cryptoCode) {
}
function buildTicker (fiatCode, cryptoCode, tickerName) {
if (tickerName === 'bitpay') return bitpay.ticker(fiatCode, cryptoCode)
if (tickerName === 'mock-ticker') return mockTicker.ticker(fiatCode, cryptoCode)
return ccxt.ticker(fiatCode, cryptoCode, tickerName)
const fiatPeggedEquivalent = _.includes(fiatCode, _.keys(PEGGED_FIAT_CURRENCIES))
? PEGGED_FIAT_CURRENCIES[fiatCode]
: fiatCode
if (tickerName === 'bitpay') return bitpay.ticker(fiatPeggedEquivalent, cryptoCode)
if (tickerName === 'mock-ticker') return mockTicker.ticker(fiatPeggedEquivalent, cryptoCode)
return ccxt.ticker(fiatPeggedEquivalent, cryptoCode, tickerName)
}
const getRates = mem(_getRates, {

View file

@ -1,13 +1,14 @@
const ph = require('./plugin-helper')
const _ = require('lodash/fp')
const argv = require('minimist')(process.argv.slice(2))
const configManager = require('./new-config-manager')
function loadWalletScoring (settings) {
function loadWalletScoring (settings, cryptoCode) {
const pluginCode = argv.mockScoring ? 'mock-scoring' : 'ciphertrace'
const wallet = cryptoCode ? ph.load(ph.WALLET, configManager.getWalletSettings(cryptoCode, settings.config).wallet) : null
const plugin = ph.load(ph.WALLET_SCORING, pluginCode)
const account = settings.accounts[pluginCode]
return { plugin, account }
return { plugin, account, wallet }
}
function rateWallet (settings, cryptoCode, address) {
@ -31,9 +32,9 @@ function isValidWalletScore (settings, score) {
function getTransactionHash (settings, cryptoCode, receivingAddress) {
return Promise.resolve()
.then(() => {
const { plugin, account } = loadWalletScoring(settings)
const { plugin, account, wallet } = loadWalletScoring(settings, cryptoCode)
return plugin.getTransactionHash(account, cryptoCode, receivingAddress)
return plugin.getTransactionHash(account, cryptoCode, receivingAddress, wallet)
})
}
@ -46,9 +47,19 @@ function getInputAddresses (settings, cryptoCode, txHashes) {
})
}
function isWalletScoringEnabled (settings, cryptoCode) {
return Promise.resolve()
.then(() => {
const { plugin, account } = loadWalletScoring(settings)
return plugin.isWalletScoringEnabled(account, cryptoCode)
})
}
module.exports = {
rateWallet,
isValidWalletScore,
getTransactionHash,
getInputAddresses
getInputAddresses,
isWalletScoringEnabled
}

View file

@ -13,6 +13,7 @@ const httpError = require('./route-helpers').httpError
const logger = require('./logger')
const { getOpenBatchCryptoValue } = require('./tx-batching')
const BN = require('./bn')
const { BALANCE_FETCH_SPEED_MULTIPLIER } = require('./constants')
const FETCH_INTERVAL = 5000
const INSUFFICIENT_FUNDS_CODE = 570
@ -169,6 +170,11 @@ function authorizeZeroConf (settings, tx, machineId) {
return Promise.reject(new Error('tx.fiat is undefined!'))
}
// TODO: confirm if this treatment is needed for ERC-20 tokens, once their cash-out transactions are enabled
if (tx.cryptoCode === 'ETH') {
return Promise.resolve(false)
}
if (tx.fiat.gt(zeroConfLimit)) {
return Promise.resolve(false)
}
@ -205,9 +211,9 @@ function getStatus (settings, tx, machineId) {
})
}
function sweep (settings, cryptoCode, hdIndex) {
function sweep (settings, txId, cryptoCode, hdIndex) {
return fetchWallet(settings, cryptoCode)
.then(r => r.wallet.sweep(r.account, cryptoCode, hdIndex, settings, r.operatorId))
.then(r => r.wallet.sweep(r.account, txId, cryptoCode, hdIndex, settings, r.operatorId))
}
function isHd (settings, tx) {
@ -255,20 +261,28 @@ function checkBlockchainStatus (settings, cryptoCode) {
.then(({ checkBlockchainStatus }) => checkBlockchainStatus(cryptoCode))
}
const coinFilter = ['ETH']
const balance = (settings, cryptoCode) => {
if (_.includes(coinFilter, cryptoCode)) return balanceFiltered(settings, cryptoCode)
return balanceUnfiltered(settings, cryptoCode)
return fetchWallet(settings, cryptoCode)
.then(r => r.wallet.fetchSpeed ?? BALANCE_FETCH_SPEED_MULTIPLIER.NORMAL)
.then(multiplier => {
switch (multiplier) {
case BALANCE_FETCH_SPEED_MULTIPLIER.NORMAL:
return balanceNormal(settings, cryptoCode)
case BALANCE_FETCH_SPEED_MULTIPLIER.SLOW:
return balanceSlow(settings, cryptoCode)
default:
throw new Error()
}
})
}
const balanceUnfiltered = mem(_balance, {
maxAge: FETCH_INTERVAL,
const balanceNormal = mem(_balance, {
maxAge: BALANCE_FETCH_SPEED_MULTIPLIER.NORMAL * FETCH_INTERVAL,
cacheKey: (settings, cryptoCode) => cryptoCode
})
const balanceFiltered = mem(_balance, {
maxAge: 3 * FETCH_INTERVAL,
const balanceSlow = mem(_balance, {
maxAge: BALANCE_FETCH_SPEED_MULTIPLIER.SLOW * FETCH_INTERVAL,
cacheKey: (settings, cryptoCode) => cryptoCode
})

View file

@ -0,0 +1,20 @@
const { saveConfig, loadLatest } = require('../lib/new-settings-loader')
exports.up = function (next) {
const newConfig = {}
return loadLatest()
.then(config => {
if (config.config.locale_timezone === "0:0") {
newConfig[`locale_timezone`] = 'GMT'
return saveConfig(newConfig)
}
})
.then(next)
.catch(err => {
return next(err)
})
}
module.exports.down = function (next) {
next()
}

View file

@ -0,0 +1,22 @@
const { removeFromConfig, loadLatest } = require('../lib/new-settings-loader')
const { getCryptosFromWalletNamespace } = require('../lib/new-config-manager.js')
const _ = require('lodash/fp')
exports.up = function (next) {
loadLatest()
.then(settings => {
const configuredCryptos = getCryptosFromWalletNamespace(settings.config)
if (!configuredCryptos.length) return Promise.resolve()
return removeFromConfig(_.map(it => `wallets_${it}_cryptoUnits`, configuredCryptos))
})
.then(() => next())
.catch(err => {
console.log(err.message)
return next(err)
})
}
exports.down = function (next) {
next()
}

View file

@ -0,0 +1,22 @@
const _ = require('lodash/fp')
const { saveConfig, loadLatest } = require('../lib/new-settings-loader')
exports.up = function (next) {
const newConfig = {}
return loadLatest()
.then(config => {
if (!_.isNil(config.config.wallets_ETH_zeroConfLimit) && config.config.wallets_ETH_zeroConfLimit !== 0) {
newConfig[`wallets_ETH_zeroConfLimit`] = 0
return saveConfig(newConfig)
}
})
.then(next)
.catch(err => {
return next(err)
})
}
module.exports.down = function (next) {
next()
}

View file

@ -7,7 +7,7 @@ import { P, Label3 } from 'src/components/typography'
import { ReactComponent as CloseIcon } from 'src/styling/icons/action/close/zodiac.svg'
import { ReactComponent as FilterIcon } from 'src/styling/icons/button/filter/white.svg'
import { ReactComponent as ReverseFilterIcon } from 'src/styling/icons/button/filter/zodiac.svg'
import { onlyFirstToUpper } from 'src/utils/string'
import { onlyFirstToUpper, singularOrPlural } from 'src/utils/string'
import { chipStyles, styles } from './SearchFilter.styles'
@ -18,7 +18,7 @@ const SearchFilter = ({
filters,
onFilterDelete,
deleteAllFilters,
entries
entries = 0
}) => {
const chipClasses = useChipStyles()
const classes = useStyles()
@ -40,8 +40,11 @@ const SearchFilter = ({
</div>
<div className={classes.deleteWrapper}>
{
<Label3 className={classes.entries}>{`${entries ??
0} entries`}</Label3>
<Label3 className={classes.entries}>{`${entries} ${singularOrPlural(
entries,
`entry`,
`entries`
)}`}</Label3>
}
<ActionButton
color="secondary"

View file

@ -1,6 +1,7 @@
import { makeStyles } from '@material-ui/core/styles'
import classnames from 'classnames'
import { compareAsc, differenceInDays, set } from 'date-fns/fp'
import * as R from 'ramda'
import React, { useState, useEffect } from 'react'
import Calendar from './Calendar'
@ -37,7 +38,12 @@ const DateRangePicker = ({ minDate, maxDate, className, onRangeChange }) => {
set({ hours: 23, minutes: 59, seconds: 59, milliseconds: 999 }, day)
)
} else {
setTo(from)
setTo(
set(
{ hours: 23, minutes: 59, seconds: 59, milliseconds: 999 },
R.clone(from)
)
)
setFrom(day)
}
return

View file

@ -70,7 +70,7 @@ const Header = () => {
}
const mapElement = (
{ name, width = DEFAULT_COL_SIZE, header, textAlign },
{ name, display, width = DEFAULT_COL_SIZE, header, textAlign },
idx
) => {
const orderClasses = classnames({
@ -99,7 +99,7 @@ const Header = () => {
<>{attachOrderedByToComplexHeader(header) ?? header}</>
) : (
<span className={orderClasses}>
{startCase(name)}{' '}
{!R.isNil(display) ? display : startCase(name)}{' '}
{!R.isNil(orderedBy) && R.equals(name, orderedBy.code) && '-'}
</span>
)}

View file

@ -257,6 +257,7 @@ const ERow = ({ editing, disabled, lastOfGroup, newRow }) => {
size={rowSize}
error={editing && hasErrors}
newRow={newRow && !hasErrors}
shouldShowError
errorMessage={errorMessage}>
{innerElements.map((it, idx) => {
return (

View file

@ -277,7 +277,7 @@ const Analytics = () => {
case 'topMachines':
return (
<TopMachinesWrapper
title="Transactions over time"
title="Top 5 Machines"
representing={representing}
period={period}
data={R.map(convertFiatToLocale)(filteredData(period.code).current)}

View file

@ -34,7 +34,7 @@ const Graph = ({
const GRAPH_MARGIN = useMemo(
() => ({
top: 25,
right: 0.5,
right: 3.5,
bottom: 27,
left: 36.5
}),
@ -158,6 +158,12 @@ const Graph = ({
.domain(periodDomains[period.code])
.range([GRAPH_MARGIN.left, GRAPH_WIDTH - GRAPH_MARGIN.right])
// Create a second X axis for mouseover events to be placed correctly across the entire graph width and not limited by X's domain
const x2 = d3
.scaleUtc()
.domain(periodDomains[period.code])
.range([GRAPH_MARGIN.left, GRAPH_WIDTH])
const y = d3
.scaleLinear()
.domain([
@ -167,11 +173,11 @@ const Graph = ({
.nice()
.range([GRAPH_HEIGHT - GRAPH_MARGIN.bottom, GRAPH_MARGIN.top])
const getAreaInterval = (breakpoints, limits) => {
const getAreaInterval = (breakpoints, dataLimits, graphLimits) => {
const fullBreakpoints = [
limits[1],
...R.filter(it => it > limits[0] && it < limits[1], breakpoints),
limits[0]
graphLimits[1],
...R.filter(it => it > dataLimits[0] && it < dataLimits[1], breakpoints),
dataLimits[0]
]
const intervals = []
@ -238,7 +244,7 @@ const Graph = ({
.selectAll('.tick line')
.filter(d => d === 0)
.clone()
.attr('x2', GRAPH_WIDTH - GRAPH_MARGIN.right - GRAPH_MARGIN.left)
.attr('x2', GRAPH_WIDTH - GRAPH_MARGIN.left)
.attr('stroke-width', 1)
.attr('stroke', primaryColor)
),
@ -276,7 +282,7 @@ const Graph = ({
.attr('y1', d => 0.5 + y(d))
.attr('y2', d => 0.5 + y(d))
.attr('x1', GRAPH_MARGIN.left)
.attr('x2', GRAPH_WIDTH - GRAPH_MARGIN.right)
.attr('x2', GRAPH_WIDTH)
)
// Vertical transparent rectangles for events
.call(g =>
@ -291,7 +297,8 @@ const Graph = ({
const xValue = Math.round(x(d) * 100) / 100
const intervals = getAreaInterval(
buildAreas(x.domain()).map(it => Math.round(x(it) * 100) / 100),
x.range()
x.range(),
x2.range()
)
const interval = getAreaIntervalByX(intervals, xValue)
return Math.round((interval[0] - interval[1]) * 100) / 100
@ -307,10 +314,12 @@ const Graph = ({
const areas = buildAreas(x.domain())
const intervals = getAreaInterval(
buildAreas(x.domain()).map(it => Math.round(x(it) * 100) / 100),
x.range()
x.range(),
x2.range()
)
const dateInterval = getDateIntervalByX(areas, intervals, xValue)
if (!dateInterval) return
const filteredData = data.filter(it => {
const created = new Date(it.created)
const tzCreated = created.setTime(created.getTime() + offset)
@ -426,6 +435,7 @@ const Graph = ({
buildTicks,
getPastAndCurrentDayLabels,
x,
x2,
y,
period,
buildAreas,
@ -482,7 +492,7 @@ const Graph = ({
0.5 + y(d3.mean(data, d => new BigNumber(d.fiat).toNumber()) ?? 0)
)
.attr('x1', GRAPH_MARGIN.left)
.attr('x2', GRAPH_WIDTH - GRAPH_MARGIN.right)
.attr('x2', GRAPH_WIDTH)
)
},
[GRAPH_MARGIN, y, data]

View file

@ -34,6 +34,7 @@ const BlackListModal = ({
DASH: 'XqQ7gU8eM76rEfey726cJpT2RGKyJyBrcn',
ZEC: 't1KGyyv24eL354C9gjveBGEe8Xz9UoPKvHR',
BCH: 'qrd6za97wm03lfyg82w0c9vqgc727rhemg5yd9k3dm',
USDT: '0x5754284f345afc66a98fbb0a0afe71e0f007b949',
XMR:
'888tNkZrPN6JsEgekjMnABU4TBzc2Dt29EPAvkRxbANsAnjyPbb3iQ1YBRk1UXcdRsiKc9dhwMVgN5S9cQUiyoogDavup3H'
}

View file

@ -74,13 +74,13 @@ const Commissions = ({ name: SCREEN_KEY }) => {
}
const saveOverridesFromList = it => (_, override) => {
const cryptoOverriden = R.path(['cryptoCurrencies', 0], override)
const cryptoOverridden = R.path(['cryptoCurrencies', 0], override)
const sameMachine = R.eqProps('machine', override)
const notSameOverride = it => !R.eqProps('cryptoCurrencies', override, it)
const filterMachine = R.filter(R.both(sameMachine, notSameOverride))
const removeCoin = removeCoinFromOverride(cryptoOverriden)
const removeCoin = removeCoinFromOverride(cryptoOverridden)
const machineOverrides = R.map(removeCoin)(filterMachine(it))

View file

@ -318,7 +318,7 @@ const getOverridesSchema = (values, rawData, locale) => {
'deviceId'
)(machine)
const message = `${codes} already overriden for machine: ${machineView}`
const message = `${codes} already overridden for machine: ${machineView}`
return this.createError({ message })
}

View file

@ -16,6 +16,7 @@ import { fromNamespace, namespaces } from 'src/utils/config'
import CustomersList from './CustomersList'
import CreateCustomerModal from './components/CreateCustomerModal'
import { getAuthorizedStatus } from './helper'
const GET_CUSTOMER_FILTERS = gql`
query filters {
@ -130,9 +131,20 @@ const Customers = () => {
R.path(['customInfoRequests'], customersResponse) ?? []
const locale = configData && fromNamespace(namespaces.LOCALE, configData)
const triggers = configData && fromNamespace(namespaces.TRIGGERS, configData)
const customersData = R.sortWith([
R.descend(it => new Date(R.prop('lastActive', it) ?? '0'))
])(filteredCustomers ?? [])
const setAuthorizedStatus = c =>
R.assoc(
'authorizedStatus',
getAuthorizedStatus(c, triggers, customRequirementsData),
c
)
const byAuthorized = c => (c.authorizedStatus.label === 'Pending' ? 0 : 1)
const byLastActive = c => new Date(R.prop('lastActive', c) ?? '0')
const customersData = R.pipe(
R.map(setAuthorizedStatus),
R.sortWith([R.ascend(byAuthorized), R.descend(byLastActive)])
)(filteredCustomers ?? [])
const onFilterChange = filters => {
const filtersObject = getFiltersObj(filters)

View file

@ -9,7 +9,7 @@ import { ReactComponent as TxInIcon } from 'src/styling/icons/direction/cash-in.
import { ReactComponent as TxOutIcon } from 'src/styling/icons/direction/cash-out.svg'
import styles from './CustomersList.styles'
import { getAuthorizedStatus, getFormattedPhone, getName } from './helper'
import { getFormattedPhone, getName } from './helper'
const useStyles = makeStyles(styles)
@ -73,11 +73,7 @@ const CustomersList = ({
{
header: 'Status',
width: 191,
view: it => (
<MainStatus
statuses={[getAuthorizedStatus(it, triggers, customRequests)]}
/>
)
view: it => <MainStatus statuses={[it.authorizedStatus]} />
}
]

View file

@ -22,7 +22,7 @@ const Graph = ({ data, timeFrame, timezone }) => {
const GRAPH_MARGIN = useMemo(
() => ({
top: 20,
right: 0.5,
right: 3.5,
bottom: 27,
left: 33.5
}),
@ -211,7 +211,7 @@ const Graph = ({ data, timeFrame, timezone }) => {
.attr('y1', d => 0.5 + y(d))
.attr('y2', d => 0.5 + y(d))
.attr('x1', GRAPH_MARGIN.left)
.attr('x2', GRAPH_WIDTH - GRAPH_MARGIN.right)
.attr('x2', GRAPH_WIDTH)
)
// Thick vertical lines
.call(g =>

View file

@ -27,10 +27,10 @@ const allFields = (getData, onChange, auxElements = []) => {
return R.compose(R.join(', '), R.map(getView(data, 'code')))(it)
}
const overridenMachines = R.map(override => override.machine, auxElements)
const overriddenMachines = R.map(override => override.machine, auxElements)
const suggestionFilter = it =>
R.differenceWith((x, y) => x.deviceId === y, it, overridenMachines)
R.differenceWith((x, y) => x.deviceId === y, it, overriddenMachines)
const machineData = getData(['machines'])
const countryData = getData(['countries'])

View file

@ -55,6 +55,7 @@ const GET_TRANSACTIONS = gql`
customerId
isAnonymous
rawTickerPrice
profit
}
}
`

View file

@ -74,11 +74,11 @@ const MachineRoute = () => {
setLoading(false)
},
variables: {
deviceId: id
},
billFilters: {
deviceId: id,
batch: 'none'
billFilters: {
deviceId: id,
batch: 'none'
}
}
})

View file

@ -10,7 +10,7 @@ import WizardSplash from './WizardSplash'
import WizardStep from './WizardStep'
const MODAL_WIDTH = 554
const MODAL_HEIGHT = 520
const MODAL_HEIGHT = 535
const CASHBOX_DEFAULT_CAPACITY = 500
const CASSETTE_FIELDS = R.map(

View file

@ -4,6 +4,7 @@ import { Formik, Form, Field } from 'formik'
import * as R from 'ramda'
import React from 'react'
import ErrorMessage from 'src/components/ErrorMessage'
import Stepper from 'src/components/Stepper'
import { HoverableTooltip } from 'src/components/Tooltip'
import { Button } from 'src/components/buttons'
@ -94,6 +95,10 @@ const styles = {
},
errorMessage: {
color: errorColor
},
stepErrorMessage: {
maxWidth: 275,
marginTop: 25
}
}
@ -284,6 +289,11 @@ const WizardStep = ({
= {numberToFiatAmount(cassetteTotal(values))}{' '}
{fiatCurrency}
</P>
{!R.isEmpty(errors) && (
<ErrorMessage className={classes.stepErrorMessage}>
{R.head(R.values(errors))}
</ErrorMessage>
)}
</div>
</div>
</div>

View file

@ -35,9 +35,9 @@ const CryptoBalanceOverrides = ({ section }) => {
return save(newOverrides)
}
const overridenCryptos = R.map(R.prop(CRYPTOCURRENCY_KEY))(setupValues)
const overriddenCryptos = R.map(R.prop(CRYPTOCURRENCY_KEY))(setupValues)
const suggestionFilter = R.filter(
it => !R.contains(it.code, overridenCryptos)
it => !R.contains(it.code, overriddenCryptos)
)
const suggestions = suggestionFilter(cryptoCurrencies)

View file

@ -17,8 +17,11 @@ import styles from './FiatBalanceAlerts.styles.js'
const useStyles = makeStyles(styles)
const NAME = 'fiatBalanceAlerts'
const CASH_IN_KEY = 'fiatBalanceAlertsCashIn'
const CASH_OUT_KEY = 'fiatBalanceAlertsCashOut'
const DEFAULT_NUMBER_OF_CASSETTES = 2
const notesMin = 0
const notesMax = 9999999
const FiatBalance = ({ section, min = 0, max = 100, fieldWidth = 80 }) => {
const {
@ -36,9 +39,13 @@ const FiatBalance = ({ section, min = 0, max = 100, fieldWidth = 80 }) => {
DEFAULT_NUMBER_OF_CASSETTES
)
const editing = isEditing(NAME)
const schema = Yup.object().shape({
cashInAlertThreshold: Yup.number()
.transform(transformNumber)
.integer()
.min(notesMin)
.max(notesMax)
.nullable(),
fillingPercentageCassette1: Yup.number()
.transform(transformNumber)
.integer()
@ -71,6 +78,7 @@ const FiatBalance = ({ section, min = 0, max = 100, fieldWidth = 80 }) => {
validateOnChange={false}
enableReinitialize
initialValues={{
cashInAlertThreshold: data?.cashInAlertThreshold ?? '',
fillingPercentageCassette1: data?.fillingPercentageCassette1 ?? '',
fillingPercentageCassette2: data?.fillingPercentageCassette2 ?? '',
fillingPercentageCassette3: data?.fillingPercentageCassette3 ?? '',
@ -79,52 +87,80 @@ const FiatBalance = ({ section, min = 0, max = 100, fieldWidth = 80 }) => {
validationSchema={schema}
onSubmit={it => save(section, schema.cast(it))}
onReset={() => {
setEditing(NAME, false)
setEditing(CASH_IN_KEY, false)
setEditing(CASH_OUT_KEY, false)
}}>
{({ values }) => (
<Form className={classes.form}>
<PromptWhenDirty />
<Header
title="Cash out (Empty)"
editing={editing}
disabled={isDisabled(NAME)}
setEditing={it => setEditing(NAME, it)}
/>
<div className={classes.wrapper}>
{R.map(
it => (
<>
<div className={classes.row}>
<Cashbox
labelClassName={classes.cashboxLabel}
emptyPartClassName={classes.cashboxEmptyPart}
percent={
values[`fillingPercentageCassette${it + 1}`] ??
data[`cassette${it + 1}`]
}
applyColorVariant
applyFiatBalanceAlertsStyling
omitInnerPercentage
cashOut
<>
<Form className={classes.form}>
<PromptWhenDirty />
<Header
title="Cash box"
editing={isEditing(CASH_IN_KEY)}
disabled={isDisabled(CASH_IN_KEY)}
setEditing={it => setEditing(CASH_IN_KEY, it)}
/>
<div className={classes.wrapper}>
<div className={classes.first}>
<div className={classes.row}>
<div className={classes.col2}>
<EditableNumber
label="Alert me over"
name="cashInAlertThreshold"
editing={isEditing(CASH_IN_KEY)}
displayValue={x => (x === '' ? '-' : x)}
decoration="notes"
width={fieldWidth}
/>
<div className={classes.col2}>
<TL2 className={classes.title}>Cassette {it + 1}</TL2>
<EditableNumber
label="Alert me under"
name={`fillingPercentageCassette${it + 1}`}
editing={editing}
displayValue={x => (x === '' ? '-' : x)}
decoration="%"
width={fieldWidth}
/>
</div>
</div>
</>
),
R.times(R.identity, maxNumberOfCassettes)
)}
</div>
</Form>
</div>
</div>
</div>
</Form>
<Form className={classes.form}>
<PromptWhenDirty />
<Header
title="Cash out (Empty)"
editing={isEditing(CASH_OUT_KEY)}
disabled={isDisabled(CASH_OUT_KEY)}
setEditing={it => setEditing(CASH_OUT_KEY, it)}
/>
<div className={classes.wrapper}>
{R.map(
it => (
<>
<div className={classes.row}>
<Cashbox
labelClassName={classes.cashboxLabel}
emptyPartClassName={classes.cashboxEmptyPart}
percent={
values[`fillingPercentageCassette${it + 1}`] ??
data[`cassette${it + 1}`]
}
applyColorVariant
applyFiatBalanceAlertsStyling
omitInnerPercentage
cashOut
/>
<div className={classes.col2}>
<TL2 className={classes.title}>Cassette {it + 1}</TL2>
<EditableNumber
label="Alert me under"
name={`fillingPercentageCassette${it + 1}`}
editing={isEditing(CASH_OUT_KEY)}
displayValue={x => (x === '' ? '-' : x)}
decoration="%"
width={fieldWidth}
/>
</div>
</div>
</>
),
R.times(R.identity, maxNumberOfCassettes)
)}
</div>
</Form>
</>
)}
</Formik>
)

View file

@ -10,12 +10,14 @@ import { transformNumber } from 'src/utils/number'
import NotificationsCtx from '../NotificationsContext'
const CASHBOX_KEY = 'cashbox'
const CASSETTE_1_KEY = 'fillingPercentageCassette1'
const CASSETTE_2_KEY = 'fillingPercentageCassette2'
const CASSETTE_3_KEY = 'fillingPercentageCassette3'
const CASSETTE_4_KEY = 'fillingPercentageCassette4'
const MACHINE_KEY = 'machine'
const NAME = 'fiatBalanceOverrides'
const DEFAULT_NUMBER_OF_CASSETTES = 2
const CASSETTE_LIST = [
CASSETTE_1_KEY,
@ -25,9 +27,9 @@ const CASSETTE_LIST = [
]
const widthsByNumberOfCassettes = {
2: { machine: 230, cassette: 250 },
3: { machine: 216, cassette: 270 },
4: { machine: 210, cassette: 204 }
2: { machine: 230, cashbox: 150, cassette: 250 },
3: { machine: 216, cashbox: 150, cassette: 270 },
4: { machine: 210, cashbox: 150, cassette: 204 }
}
const FiatBalanceOverrides = ({ config, section }) => {
@ -42,33 +44,35 @@ const FiatBalanceOverrides = ({ config, section }) => {
const setupValues = data?.fiatBalanceOverrides ?? []
const innerSetEditing = it => setEditing(NAME, it)
const cashoutConfig = it => fromNamespace(it)(config)
const overridenMachines = R.map(override => override.machine, setupValues)
const suggestionFilter = R.filter(
it =>
!R.includes(it.deviceId, overridenMachines) &&
cashoutConfig(it.deviceId).active
const overriddenMachines = R.map(override => override.machine, setupValues)
const suggestions = R.differenceWith(
(it, m) => it.deviceId === m,
machines,
overriddenMachines
)
const suggestions = suggestionFilter(machines)
const findSuggestion = it => {
const coin = R.compose(R.find(R.propEq('deviceId', it?.machine)))(machines)
const coin = R.find(R.propEq('deviceId', it?.machine), machines)
return coin ? [coin] : []
}
const initialValues = {
[MACHINE_KEY]: null,
[CASHBOX_KEY]: '',
[CASSETTE_1_KEY]: '',
[CASSETTE_2_KEY]: '',
[CASSETTE_3_KEY]: '',
[CASSETTE_4_KEY]: ''
}
const notesMin = 0
const notesMax = 9999999
const maxNumberOfCassettes = Math.max(
...R.map(it => it.numberOfCassettes, machines),
2
DEFAULT_NUMBER_OF_CASSETTES
)
const percentMin = 0
@ -77,8 +81,14 @@ const FiatBalanceOverrides = ({ config, section }) => {
.shape({
[MACHINE_KEY]: Yup.string()
.label('Machine')
.nullable()
.required(),
[CASHBOX_KEY]: Yup.number()
.label('Cash box')
.transform(transformNumber)
.integer()
.min(notesMin)
.max(notesMax)
.nullable(),
[CASSETTE_1_KEY]: Yup.number()
.label('Cassette 1')
.transform(transformNumber)
@ -108,39 +118,49 @@ const FiatBalanceOverrides = ({ config, section }) => {
.max(percentMax)
.nullable()
})
.test((values, context) => {
const picked = R.pick(CASSETTE_LIST, values)
if (CASSETTE_LIST.some(it => !R.isNil(picked[it]))) return
return context.createError({
path: CASSETTE_1_KEY,
message: 'At least one of the cassettes must have a value'
})
})
.test((values, context) =>
R.any(key => !R.isNil(values[key]), R.prepend(CASHBOX_KEY, CASSETTE_LIST))
? undefined
: context.createError({
path: CASHBOX_KEY,
message:
'The cash box or at least one of the cassettes must have a value'
})
)
const viewMachine = it =>
R.compose(R.path(['name']), R.find(R.propEq('deviceId', it)))(machines)
const elements = [
{
name: MACHINE_KEY,
width: widthsByNumberOfCassettes[maxNumberOfCassettes].machine,
size: 'sm',
view: viewMachine,
input: Autocomplete,
inputProps: {
options: it => R.concat(suggestions, findSuggestion(it)),
valueProp: 'deviceId',
labelProp: 'name'
const elements = R.concat(
[
{
name: MACHINE_KEY,
display: 'Machine',
width: widthsByNumberOfCassettes[maxNumberOfCassettes].machine,
size: 'sm',
view: viewMachine,
input: Autocomplete,
inputProps: {
options: it => R.concat(suggestions, findSuggestion(it)),
valueProp: 'deviceId',
labelProp: 'name'
}
},
{
name: CASHBOX_KEY,
display: 'Cash box',
width: widthsByNumberOfCassettes[maxNumberOfCassettes].cashbox,
textAlign: 'right',
bold: true,
input: NumberInput,
suffix: 'notes',
inputProps: {
decimalPlaces: 0
}
}
}
]
R.until(
R.gt(R.__, maxNumberOfCassettes),
it => {
elements.push({
],
R.map(
it => ({
name: `fillingPercentageCassette${it}`,
display: `Cash cassette ${it}`,
width: widthsByNumberOfCassettes[maxNumberOfCassettes].cassette,
@ -152,15 +172,18 @@ const FiatBalanceOverrides = ({ config, section }) => {
inputProps: {
decimalPlaces: 0
},
view: it => it?.toString() ?? '—',
view: el => el?.toString() ?? '—',
isHidden: value =>
!cashoutConfig(value.machine).active ||
it >
machines.find(({ deviceId }) => deviceId === value.machine)
?.numberOfCassettes
})
return R.add(1, it)
},
1
R.defaultTo(
0,
machines.find(({ deviceId }) => deviceId === value.machine)
?.numberOfCassettes
)
}),
R.range(1, maxNumberOfCassettes + 1)
)
)
return (

View file

@ -153,16 +153,18 @@ const TermsConditions = () => {
}
const validationSchema = Yup.object().shape({
title: Yup.string()
.required()
title: Yup.string('The screen title must be a string')
.required('The screen title is required')
.max(50, 'Too long'),
text: Yup.string().required(),
acceptButtonText: Yup.string()
.required()
.max(50, 'Too long'),
cancelButtonText: Yup.string()
.required()
.max(50, 'Too long')
text: Yup.string('The text content must be a string').required(
'The text content is required'
),
acceptButtonText: Yup.string('The accept button text must be a string')
.required('The accept button text is required')
.max(50, 'The accept button text is too long'),
cancelButtonText: Yup.string('The cancel button text must be a string')
.required('The cancel button text is required')
.max(50, 'The cancel button text is too long')
})
return (
@ -236,37 +238,42 @@ const TermsConditions = () => {
setEditing(false)
setError(null)
}}>
<Form>
<PromptWhenDirty />
{fields.map((f, idx) => (
<div className={classes.row} key={idx}>
<Field
editing={editing}
name={f.name}
width={f.width}
placeholder={f.placeholder}
label={f.label}
value={f.value}
multiline={f.multiline}
rows={f.rows}
onFocus={() => setError(null)}
/>
{({ errors }) => (
<Form>
<PromptWhenDirty />
{fields.map((f, idx) => (
<div className={classes.row} key={idx}>
<Field
editing={editing}
name={f.name}
width={f.width}
placeholder={f.placeholder}
label={f.label}
value={f.value}
multiline={f.multiline}
rows={f.rows}
onFocus={() => setError(null)}
/>
</div>
))}
<div className={classnames(classes.row, classes.submit)}>
{editing && (
<>
<Link color="primary" type="submit">
Save
</Link>
<Link color="secondary" type="reset">
Cancel
</Link>
{!R.isEmpty(errors) && (
<ErrorMessage>{R.head(R.values(errors))}</ErrorMessage>
)}
{error && <ErrorMessage>Failed to save changes</ErrorMessage>}
</>
)}
</div>
))}
<div className={classnames(classes.row, classes.submit)}>
{editing && (
<>
<Link color="primary" type="submit">
Save
</Link>
<Link color="secondary" type="reset">
Cancel
</Link>
{error && <ErrorMessage>Failed to save changes</ErrorMessage>}
</>
)}
</div>
</Form>
</Form>
)}
</Formik>
</>
)

View file

@ -1,7 +1,6 @@
import * as Yup from 'yup'
import CheckboxInput from 'src/components/inputs/formik/Checkbox'
import TextInputFormik from 'src/components/inputs/formik/TextInput'
import { Checkbox, TextInput, NumberInput } from 'src/components/inputs/formik'
export default {
code: 'blockcypher',
@ -11,19 +10,19 @@ export default {
{
code: 'token',
display: 'API Token',
component: TextInputFormik,
component: TextInput,
face: true,
long: true
},
{
code: 'confidenceFactor',
display: 'Confidence Factor',
component: TextInputFormik,
component: NumberInput,
face: true
},
{
code: 'rbf',
component: CheckboxInput,
component: Checkbox,
settings: {
field: 'wallets_BTC_wallet',
enabled: true,
@ -43,7 +42,8 @@ export default {
.required('The token is required'),
confidenceFactor: Yup.number('The confidence factor must be a number')
.integer('The confidence factor must be an integer')
.positive('The confidence factor must be positive')
.min(0, 'The confidence factor must be between 0 and 100')
.max(100, 'The confidence factor must be between 0 and 100')
.required('The confidence factor is required')
})
}

View file

@ -33,6 +33,7 @@ import {
offErrorColor
} from 'src/styling/variables'
import { URI } from 'src/utils/apollo'
import { SWEEPABLE_CRYPTOS } from 'src/utils/constants'
import * as Customer from 'src/utils/customer'
import CopyToClipboard from './CopyToClipboard'
@ -88,24 +89,6 @@ const CANCEL_CASH_IN_TRANSACTION = gql`
const getCryptoAmount = tx =>
coinUtils.toUnit(new BigNumber(tx.cryptoAtoms), tx.cryptoCode).toNumber()
/* Port of getProfit() from lib/new-admin/services/transactions.js */
const getCommission = tx => {
const calcCashInProfit = (fiat, crypto, tickerPrice, fee) =>
fiat - crypto * tickerPrice + fee
const calcCashOutProfit = (fiat, crypto, tickerPrice) =>
crypto * tickerPrice - fiat
const fiat = Number.parseFloat(tx.fiat)
const crypto = getCryptoAmount(tx)
const tickerPrice = Number.parseFloat(tx.rawTickerPrice)
const isCashIn = tx.txClass === 'cashIn'
const cashInFee = isCashIn ? Number.parseFloat(tx.cashInFee) : 0
return isCashIn
? calcCashInProfit(fiat, crypto, tickerPrice, cashInFee)
: calcCashOutProfit(fiat, crypto, tickerPrice)
}
const formatAddress = (cryptoCode = '', address = '') =>
coinUtils.formatCryptoAddress(cryptoCode, address).replace(/(.{5})/g, '$1 ')
@ -124,7 +107,7 @@ const DetailsRow = ({ it: tx, timezone }) => {
const zip = new JSZip()
const [fetchSummary] = useLazyQuery(TX_SUMMARY, {
onCompleted: data => createCsv(data)
onCompleted: data => createCsv(R.filter(it => !R.isEmpty(it), data))
})
const [cancelTransaction] = useMutation(
@ -136,7 +119,7 @@ const DetailsRow = ({ it: tx, timezone }) => {
}
)
const commission = BigNumber(getCommission(tx))
const commission = BigNumber(tx.profit)
.abs()
.toFixed(2, 1) // ROUND_DOWN
const commissionPercentage =
@ -407,6 +390,14 @@ const DetailsRow = ({ it: tx, timezone }) => {
</ActionButton>
)}
</div>
{!R.isNil(tx.swept) && R.includes(tx.cryptoCode, SWEEPABLE_CRYPTOS) && (
<div className={classes.swept}>
<Label>Sweep status</Label>
<span className={classes.bold}>
{tx.swept ? `Swept` : `Unswept`}
</span>
</div>
)}
<div>
<Label>Other actions</Label>
<div className={classes.otherActionsGroup}>

View file

@ -131,5 +131,8 @@ export default {
},
error: {
color: tomato
},
swept: {
width: 250
}
}

View file

@ -75,6 +75,7 @@ const GET_TRANSACTIONS = gql`
$cryptoCode: String
$toAddress: String
$status: String
$swept: Boolean
) {
transactions(
limit: $limit
@ -87,6 +88,7 @@ const GET_TRANSACTIONS = gql`
cryptoCode: $cryptoCode
toAddress: $toAddress
status: $status
swept: $swept
) {
id
txClass
@ -121,6 +123,8 @@ const GET_TRANSACTIONS = gql`
rawTickerPrice
batchError
walletScore
profit
swept
}
}
`
@ -246,7 +250,8 @@ const Transactions = () => {
fiatCode: filtersObject.fiat,
cryptoCode: filtersObject.crypto,
toAddress: filtersObject.address,
status: filtersObject.status
status: filtersObject.status,
swept: filtersObject.swept === 'Swept'
})
refetch && refetch()
@ -269,7 +274,8 @@ const Transactions = () => {
fiatCode: filtersObject.fiat,
cryptoCode: filtersObject.crypto,
toAddress: filtersObject.address,
status: filtersObject.status
status: filtersObject.status,
swept: filtersObject.swept === 'Swept'
})
refetch && refetch()
@ -287,7 +293,8 @@ const Transactions = () => {
fiatCode: filtersObject.fiat,
cryptoCode: filtersObject.crypto,
toAddress: filtersObject.address,
status: filtersObject.status
status: filtersObject.status,
swept: filtersObject.swept === 'Swept'
})
refetch && refetch()

View file

@ -173,7 +173,12 @@ const Wizard = ({
const classes = useStyles()
const isEditing = !R.isNil(toBeEdited)
const [step, setStep] = useState(isEditing ? 1 : 0)
const stepOptions = getStep(step, existingRequirements)
// If we're editing, filter out the requirement being edited so that validation schemas don't enter in circular conflicts
const _existingRequirements = isEditing
? R.filter(it => it.id !== toBeEdited.id, existingRequirements)
: existingRequirements
const stepOptions = getStep(step, _existingRequirements)
const isLastStep = step === LAST_STEP
const onContinue = (values, actions) => {

View file

@ -47,13 +47,15 @@ const getOverridesSchema = (values, customInfoRequests) => {
.required()
.test({
test() {
const { requirement } = this.parent
if (R.find(R.propEq('requirement', requirement))(values)) {
const { id, requirement } = this.parent
// If we're editing, filter out the override being edited so that validation schemas don't enter in circular conflicts
const _values = R.filter(it => it.id !== id, values)
if (R.find(R.propEq('requirement', requirement))(_values)) {
return this.createError({
message: `Requirement ${displayRequirement(
message: `Requirement '${displayRequirement(
requirement,
customInfoRequests
)} already overriden`
)}' already overridden`
})
}
return true

View file

@ -253,7 +253,9 @@ const Schema = Yup.object()
// TYPE
const typeSchema = Yup.object()
.shape({
triggerType: Yup.string().required(),
triggerType: Yup.string('The trigger type must be a string').required(
'The trigger type is required'
),
threshold: Yup.object({
threshold: Yup.number()
.transform(transformNumber)
@ -297,6 +299,8 @@ const typeSchema = Yup.object()
consecutiveDays: threshold => threshold.thresholdDays > 0
}
if (!triggerType) return
if (triggerType && thresholdValidator[triggerType](threshold)) return
return context.createError({

View file

@ -67,11 +67,11 @@ const AdvancedWallet = () => {
const AdvancedWalletSettingsOverrides = AdvancedWalletSettings.overrides ?? []
const overridenCryptos = R.map(R.prop(CRYPTOCURRENCY_KEY))(
const overriddenCryptos = R.map(R.prop(CRYPTOCURRENCY_KEY))(
AdvancedWalletSettingsOverrides
)
const suggestionFilter = R.filter(
it => !R.contains(it.code, overridenCryptos)
it => !R.contains(it.code, overriddenCryptos)
)
const coinSuggestions = suggestionFilter(cryptoCurrencies)

View file

@ -1,4 +1,3 @@
import { utils as coinUtils } from '@lamassu/coins'
import * as R from 'ramda'
import React, { useState } from 'react'
import * as Yup from 'yup'
@ -104,14 +103,7 @@ const Wizard = ({
: accountsToSave
if (isLastStep) {
const defaultCryptoUnit = R.head(
R.keys(coinUtils.getCryptoCurrency(coin.code).units)
)
const configToSave = {
...newConfig,
cryptoUnits: defaultCryptoUnit
}
return save(toNamespace(coin.code, configToSave), newAccounts)
return save(toNamespace(coin.code, newConfig), newAccounts)
}
setState({

View file

@ -8,7 +8,7 @@ import {
} from 'src/components/inputs/formik'
import { disabledColor } from 'src/styling/variables'
import { CURRENCY_MAX } from 'src/utils/constants'
import { transformNumber } from 'src/utils/number'
import { defaultToZero } from 'src/utils/number'
const classes = {
editDisabled: {
@ -19,15 +19,21 @@ const filterClass = type => R.filter(it => it.class === type)
const filterCoins = ({ id }) => R.filter(it => R.contains(id)(it.cryptos))
const WalletSchema = Yup.object().shape({
ticker: Yup.string().required(),
wallet: Yup.string().required(),
exchange: Yup.string().required(),
zeroConf: Yup.string(),
zeroConfLimit: Yup.number()
.integer()
.min(0)
ticker: Yup.string('The ticker must be a string').required(
'The ticker is required'
),
wallet: Yup.string('The wallet must be a string').required(
'The wallet is required'
),
exchange: Yup.string('The exchange must be a string').required(
'The exchange is required'
),
zeroConf: Yup.string('The confidence checking must be a string'),
zeroConfLimit: Yup.number('The 0-conf limit must be an integer')
.integer('The 0-conf limit must be an integer')
.min(0, 'The 0-conf limit must be a positive integer')
.max(CURRENCY_MAX)
.transform(transformNumber)
.transform(defaultToZero)
})
const AdvancedWalletSchema = Yup.object().shape({
@ -195,7 +201,7 @@ const getAdvancedWalletElementsOverrides = (
const has0Conf = R.complement(
/* NOTE: List of coins without 0conf settings. */
R.pipe(R.prop('id'), R.flip(R.includes)(['ETH']))
R.pipe(R.prop('id'), R.flip(R.includes)(['ETH', 'USDT']))
)
const getElements = (cryptoCurrencies, accounts, onChange, wizard = false) => {

View file

@ -1,5 +1,4 @@
import { useQuery, useMutation } from '@apollo/react-hooks'
import { utils as coinUtils } from '@lamassu/coins'
import { makeStyles } from '@material-ui/core'
import gql from 'graphql-tag'
import * as R from 'ramda'
@ -54,13 +53,9 @@ const AllSet = ({ data: currentData, doContinue }) => {
const cryptoCurrencies = data?.cryptoCurrencies ?? []
const save = () => {
const defaultCryptoUnit = R.head(
R.keys(coinUtils.getCryptoCurrency(coin).units)
)
const adjustedData = {
zeroConfLimit: 0,
...currentData,
cryptoUnits: defaultCryptoUnit
...currentData
}
if (!WalletSchema.isValidSync(adjustedData)) return setError(true)

View file

@ -96,7 +96,7 @@ const Blockcypher = ({ addData }) => {
value={accounts.blockcypher}
save={save}
elements={schema.blockcypher.elements}
validationSchema={schema.blockcypher.validationSchema}
validationSchema={schema.blockcypher.getValidationSchema}
buttonLabel={'Continue'}
buttonClass={classes.formButton}
/>

Some files were not shown because too many files have changed in this diff Show more