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= DEV_MODE=
## Uncategorized variables ## 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 #!/usr/bin/env node
require('../lib/environment-helper')
const install = require('../lib/blockchain/install') const install = require('../lib/blockchain/install')
install.run() install.run()

View file

@ -1,3 +1,5 @@
require('../lib/environment-helper')
const settingsLoader = require('../lib/new-settings-loader') const settingsLoader = require('../lib/new-settings-loader')
const pp = require('../lib/pp') 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 #!/usr/bin/env node
require('../lib/environment-helper')
const hdkey = require('ethereumjs-wallet/hdkey') const hdkey = require('ethereumjs-wallet/hdkey')
const hkdf = require('futoin-hkdf') const hkdf = require('futoin-hkdf')
const crypto = require('crypto') const crypto = require('crypto')
@ -263,7 +266,7 @@ settingsLoader.loadLatest()
} }
const opts = { const opts = {
chainId: 3, chainId: 1,
nonce: 0, nonce: 0,
includesFee: true includesFee: true
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -29,22 +29,22 @@ const BINARIES = {
dir: 'bitcoin-23.0/bin' dir: 'bitcoin-23.0/bin'
}, },
ETH: { ETH: {
url: 'https://gethstore.blob.core.windows.net/builds/geth-linux-amd64-1.10.19-23bee162.tar.gz', url: 'https://gethstore.blob.core.windows.net/builds/geth-linux-amd64-1.10.25-69568c55.tar.gz',
dir: 'geth-linux-amd64-1.10.19-23bee162' dir: 'geth-linux-amd64-1.10.25-69568c55'
}, },
ZEC: { ZEC: {
url: 'https://z.cash/downloads/zcash-5.0.0-linux64-debian-bullseye.tar.gz', url: 'https://z.cash/downloads/zcash-5.3.0-linux64-debian-bullseye.tar.gz',
dir: 'zcash-5.0.0/bin' dir: 'zcash-5.3.0/bin'
}, },
DASH: { DASH: {
url: 'https://github.com/dashpay/dash/releases/download/v0.17.0.3/dashcore-0.17.0.3-x86_64-linux-gnu.tar.gz', url: 'https://github.com/dashpay/dash/releases/download/v18.1.0/dashcore-18.1.0-x86_64-linux-gnu.tar.gz',
dir: 'dashcore-0.17.0/bin' dir: 'dashcore-18.1.0/bin'
}, },
LTC: { LTC: {
defaultUrl: 'https://download.litecoin.org/litecoin-0.18.1/linux/litecoin-0.18.1-x86_64-linux-gnu.tar.gz', 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', 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', 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: { 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', 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']] files: [['bitcoind', 'bitcoincashd'], ['bitcoin-cli', 'bitcoincash-cli']]
}, },
XMR: { XMR: {
url: 'https://downloads.getmonero.org/cli/monero-linux-x64-v0.17.3.2.tar.bz2', url: 'https://downloads.getmonero.org/cli/monero-linux-x64-v0.18.1.2.tar.bz2',
dir: 'monero-x86_64-linux-gnu-v0.17.3.2', dir: 'monero-x86_64-linux-gnu-v0.18.1.2',
files: [['monerod', 'monerod'], ['monero-wallet-rpc', 'monero-wallet-rpc']] 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 () { function run () {
const choices = _.flow([ const choices = _.flow([
_.filter(c => c.type !== 'erc-20'), _.filter(c => c.type !== 'erc-20'),
_.map(c => { _.map(c => {
const checked = isInstalledSoftware(c) && isInstalledVolume(c) const name = c.code === 'ethereum' ? 'Ethereum and/or USDT' : c.display
const name = c.code === 'ethereum' ? 'Ethereum' : c.display
return { return {
name, name,
value: c.code, value: c.code,
checked, checked: isInstalled(c),
disabled: c.cryptoCode === 'ETH' disabled: isDisabled(c)
? 'Use admin\'s Infura plugin'
: checked && 'Installed'
} }
}), }),
])(cryptos) ])(cryptos)
@ -160,6 +174,15 @@ function run () {
const validateAnswers = async (answers) => { const validateAnswers = async (answers) => {
if (_.size(answers) > 2) return { message: `Please insert a maximum of two coins to install.`, isValid: false } 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) return getBlockchainSyncStatus(cryptos)
.then(blockchainStatuses => { .then(blockchainStatuses => {
const result = _.reduce((acc, value) => ({ ...acc, [value]: _.isNil(acc[value]) ? 1 : acc[value] + 1 }), {}, _.values(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) { function doesTxReuseAddress (tx) {
if (!tx.fiat || tx.fiat.isZero()) { if (!tx.fiat || tx.fiat.isZero()) {
const sql = `SELECT EXISTS (SELECT DISTINCT to_address FROM cash_in_txs WHERE to_address = $1)` const sql = `
return db.any(sql, [tx.toAddress]) 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) return Promise.resolve(false)
} }
function getWalletScore (tx, pi) { function getWalletScore (tx, pi) {
if (!tx.fiat || tx.fiat.isZero()) { pi.isWalletScoringEnabled(tx)
return pi.rateWallet(tx.cryptoCode, tx.toAddress) .then(isEnabled => {
} if(!isEnabled) return null
// Passthrough the previous result if (!tx.fiat || tx.fiat.isZero()) {
return pi.isValidWalletScore(tx.walletScore) return pi.rateWallet(tx.cryptoCode, tx.toAddress)
.then(isValid => ({ }
address: tx.toAddress, // Passthrough the previous result
score: tx.walletScore, return pi.isValidWalletScore(tx.walletScore)
isValid .then(isValid => ({
})) address: tx.toAddress,
score: tx.walletScore,
isValid
}))
})
} }
function monitorPending (settings) { function monitorPending (settings) {

View file

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

View file

@ -32,6 +32,11 @@ const RECEIPT = 'sms_receipt'
const WALLET_SCORE_THRESHOLD = 9 const WALLET_SCORE_THRESHOLD = 9
const BALANCE_FETCH_SPEED_MULTIPLIER = {
NORMAL: 1,
SLOW: 3
}
module.exports = { module.exports = {
anonymousCustomer, anonymousCustomer,
CASSETTE_MAX_CAPACITY, CASSETTE_MAX_CAPACITY,
@ -48,5 +53,6 @@ module.exports = {
CASH_OUT_MAXIMUM_AMOUNT_OF_CASSETTES, CASH_OUT_MAXIMUM_AMOUNT_OF_CASSETTES,
WALLET_SCORE_THRESHOLD, WALLET_SCORE_THRESHOLD,
RECEIPT, 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) { async function updateEditedPhoto (id, photo, photoType) {
const newPatch = {} const newPatch = {}
const baseDir = photoType === 'frontCamera' ? frontCameraBaseDir : idPhotoCardBasedir const baseDir = photoType === 'frontCamera' ? FRONT_CAMERA_DIR : ID_PHOTO_CARD_DIR
const { createReadStream, filename } = photo const { createReadStream, filename } = photo
const stream = createReadStream() const stream = createReadStream()
@ -339,7 +339,7 @@ function camelizeDeep (customer) {
/** /**
* Get all available complianceTypes * Get all available complianceTypes
* that can be overriden (excluding hard_limit) * that can be overridden (excluding hard_limit)
* *
* @name getComplianceTypes * @name getComplianceTypes
* @function * @function
@ -404,7 +404,7 @@ function enhanceAtFields (fields) {
*/ */
function enhanceOverrideFields (fields, userToken) { function enhanceOverrideFields (fields, userToken) {
if (!userToken) return fields 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 _.reduce(_.assign, fields, _.map((type) => {
return (fields[type + '_override']) 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, 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, 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, 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 fiat_code AS last_tx_fiat_code, tx_class AS last_tx_class, custom_fields, notes, is_test_customer
FROM ( FROM (
SELECT c.id, c.authorized_override, 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.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.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, 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, 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, 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, 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') 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 => 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( const addOperatorInfo = addSmthInfo(
'operatorInfo', 'operatorInfo',
['name', 'phone', 'email', 'website', 'companyNumber'] ['name', 'phone', 'email', 'website', 'companyNumber']
) )
const addReceiptInfo = addSmthInfo( const addReceiptInfo = receiptInfo => ret => {
'receiptInfo', if (!receiptInfo) return ret
[
const fields = [
'paper',
'sms', 'sms',
'operatorWebsite', 'operatorWebsite',
'operatorEmail', 'operatorEmail',
@ -43,10 +45,22 @@ const addReceiptInfo = addSmthInfo(
'exchangeRate', 'exchangeRate',
'addressQRCode', '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. */ /* TODO: Simplify this. */
const buildTriggers = (allTriggers) => { const buildTriggers = allTriggers => {
const normalTriggers = [] const normalTriggers = []
const customTriggers = _.filter(o => { const customTriggers = _.filter(o => {
if (_.isEmpty(o.customInfoRequestId) || _.isNil(o.customInfoRequestId)) normalTriggers.push(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( const staticConf = _.flow(
_.pick([ _.pick([
'areThereAvailablePromoCodes',
'coins', 'coins',
'configVersion', 'configVersion',
'timezone' 'timezone'
@ -139,7 +152,7 @@ const staticConfig = ({ currentConfigVersion, deviceId, deviceName, pq, settings
const setZeroConfLimit = config => coin => const setZeroConfLimit = config => coin =>
_.set( _.set(
'zeroConfLimit', 'zeroConfLimit',
configManager.getWalletSettings(coin.cryptoCode, config).zeroConfLimit, configManager.getWalletSettings(coin.cryptoCode, config).zeroConfLimit ?? 0,
coin coin
) )
@ -157,7 +170,7 @@ const dynamicConfig = ({ deviceId, operatorId, pid, pq, settings, }) => {
state.pids = _.update(operatorId, _.set(deviceId, { pid, ts: Date.now() }), state.pids) state.pids = _.update(operatorId, _.set(deviceId, { pid, ts: Date.now() }), state.pids)
return _.flow( return _.flow(
_.pick(['balances', 'cassettes', 'coins', 'rates']), _.pick(['areThereAvailablePromoCodes', 'balances', 'cassettes', 'coins', 'rates']),
_.update('cassettes', massageCassettes), _.update('cassettes', massageCassettes),
@ -172,7 +185,8 @@ const dynamicConfig = ({ deviceId, operatorId, pid, pq, settings, }) => {
/* Group the separate objects by cryptoCode */ /* Group the separate objects by cryptoCode */
/* { balances, coins, rates } => { cryptoCode: { balance, ask, bid, cashIn, cashOut }, ... } */ /* { balances, coins, rates } => { cryptoCode: { balance, ask, bid, cashIn, cashOut }, ... } */
({ balances, cassettes, coins, rates }) => ({ ({ areThereAvailablePromoCodes, balances, cassettes, coins, rates }) => ({
areThereAvailablePromoCodes,
cassettes, cassettes,
coins: _.flow( coins: _.flow(
_.reduce( _.reduce(
@ -184,7 +198,10 @@ const dynamicConfig = ({ deviceId, operatorId, pid, pq, settings, }) => {
_.toPairs, _.toPairs,
/* [[cryptoCode, { balance, ask, bid, cashIn, cashOut }], ...] => [{ cryptoCode, balance, ask, bid, cashIn, cashOut }, ...] */ /* [[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)) )(_.concat(balances, coins))
}), }),
@ -218,6 +235,7 @@ const configs = (parent, { currentConfigVersion }, { deviceId, deviceName, opera
const massageTerms = terms => (terms.active && terms.text) ? ({ const massageTerms = terms => (terms.active && terms.text) ? ({
tcPhoto: Boolean(terms.tcPhoto),
delay: Boolean(terms.delay), delay: Boolean(terms.delay),
title: terms.title, title: terms.title,
text: nmd(terms.text), text: nmd(terms.text),

View file

@ -7,7 +7,7 @@ type Coin {
cashInFee: String! cashInFee: String!
cashInCommission: String! cashInCommission: String!
cashOutCommission: String! cashOutCommission: String!
cryptoNetwork: Boolean! cryptoNetwork: String!
cryptoUnits: String! cryptoUnits: String!
batchable: Boolean! batchable: Boolean!
} }
@ -32,6 +32,7 @@ type MachineInfo {
} }
type ReceiptInfo { type ReceiptInfo {
paper: Boolean!
sms: Boolean! sms: Boolean!
operatorWebsite: Boolean! operatorWebsite: Boolean!
operatorEmail: Boolean! operatorEmail: Boolean!
@ -57,6 +58,32 @@ type TriggersAutomation {
usSsn: Boolean! 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 { type Trigger {
id: String! id: String!
customInfoRequestId: String! customInfoRequestId: String!
@ -64,12 +91,14 @@ type Trigger {
requirement: String! requirement: String!
triggerType: String! triggerType: String!
suspensionDays: Int suspensionDays: Float
threshold: Int threshold: Int
thresholdDays: Int thresholdDays: Int
customInfoRequest: CustomInfoRequest
} }
type TermsDetails { type TermsDetails {
tcPhoto: Boolean!
delay: Boolean! delay: Boolean!
title: String! title: String!
accept: String! accept: String!
@ -85,7 +114,6 @@ type Terms {
type StaticConfig { type StaticConfig {
configVersion: Int! configVersion: Int!
areThereAvailablePromoCodes: Boolean!
coins: [Coin!]! coins: [Coin!]!
enablePaperWalletOnly: Boolean! enablePaperWalletOnly: Boolean!
hasLightning: Boolean! hasLightning: Boolean!
@ -136,6 +164,7 @@ type Cassettes {
} }
type DynamicConfig { type DynamicConfig {
areThereAvailablePromoCodes: Boolean!
cassettes: Cassettes cassettes: Cassettes
coins: [DynamicCoinValues!]! coins: [DynamicCoinValues!]!
reboot: Boolean! reboot: Boolean!

View file

@ -1,8 +1,9 @@
const _ = require('lodash/fp') 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 { utcToZonedTime } = require('date-fns-tz/fp')
const db = require('./db') const db = require('./db')
const logger = require('./logger')
const pgp = require('pg-promise')() const pgp = require('pg-promise')()
const getMachineName = require('./machine-loader').getMachineName const getMachineName = require('./machine-loader').getMachineName
@ -118,6 +119,10 @@ function logDateFormat (timezone, logs, fields) {
field => field =>
{ {
if (_.isNil(log[field])) return null 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]) const date = utcToZonedTime(timezone, log[field])
return `${format('yyyy-MM-dd', date)}T${format('HH:mm:ss.SSS', date)}` 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 db = require('../db')
const state = require('./state') const state = require('./state')
const newSettingsLoader = require('../new-settings-loader') const newSettingsLoader = require('../new-settings-loader')
const helpers = require('../route-helpers')
const logger = require('../logger') const logger = require('../logger')
db.connect({ direct: true }).then(sco => { db.connect({ direct: true }).then(sco => {
@ -58,31 +55,54 @@ const populateSettings = function (req, res, next) {
} }
try { try {
const operatorSettings = settingsCache.get(operatorId) // Priority of configs to retrieve
if (!versionId && (!operatorSettings || !!needsSettingsReload[operatorId])) { // 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() return newSettingsLoader.loadLatest()
.then(settings => { .then(settings => {
settingsCache.set(operatorId, settings) settingsCache.set(`${operatorId}-latest`, settings)
delete needsSettingsReload[operatorId] if (!!needsSettingsReload[operatorId]) delete needsSettingsReload[operatorId]
req.settings = settings req.settings = settings
}) })
.then(() => next()) .then(() => next())
.catch(next) .catch(next)
} }
if (!versionId && operatorSettings) { logger.debug('Fetching the latest config value from cache')
req.settings = operatorSettings req.settings = operatorSettings
return next() return next()
}
} catch (e) { } catch (e) {
logger.error(e) logger.error(e)
} }
newSettingsLoader.load(versionId)
.then(settings => { req.settings = settings })
.then(() => helpers.updateDeviceConfigVersion(versionId))
.then(() => next())
.catch(next)
} }
module.exports = populateSettings module.exports = populateSettings

View file

@ -57,7 +57,7 @@ function updateOptionBasepath (result, optionName) {
async function run () { async function run () {
// load current opts // load current opts
const options = load().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 // write the resulting .env
if (shouldMigrate) { if (shouldMigrate) {

View file

@ -3,7 +3,7 @@ const _ = require('lodash/fp')
const { ALL } = require('../../plugins/common/ccxt') 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 { bitpay, coinbase, itbit, bitstamp, kraken, binanceus, cex, ftx, binance } = ALL
const TICKER = 'ticker' const TICKER = 'ticker'
@ -29,8 +29,8 @@ const ALL_ACCOUNTS = [
{ code: 'mock-ticker', display: 'Mock (Caution!)', class: TICKER, cryptos: ALL_CRYPTOS, dev: true }, { code: 'mock-ticker', display: 'Mock (Caution!)', class: TICKER, cryptos: ALL_CRYPTOS, dev: true },
{ code: 'bitcoind', display: 'bitcoind', class: WALLET, cryptos: [BTC] }, { code: 'bitcoind', display: 'bitcoind', class: WALLET, cryptos: [BTC] },
{ code: 'no-layer2', display: 'No Layer 2', class: LAYER_2, cryptos: ALL_CRYPTOS }, { code: 'no-layer2', display: 'No Layer 2', class: LAYER_2, cryptos: ALL_CRYPTOS },
{ code: 'infura', display: 'Infura', class: WALLET, cryptos: [ETH] }, { code: 'infura', display: 'Infura', class: WALLET, cryptos: [ETH, USDT] },
{ code: 'geth', display: 'geth', class: WALLET, cryptos: [ETH] }, { code: 'geth', display: 'geth', class: WALLET, cryptos: [ETH, USDT] },
{ code: 'zcashd', display: 'zcashd', class: WALLET, cryptos: [ZEC] }, { code: 'zcashd', display: 'zcashd', class: WALLET, cryptos: [ZEC] },
{ code: 'litecoind', display: 'litecoind', class: WALLET, cryptos: [LTC] }, { code: 'litecoind', display: 'litecoind', class: WALLET, cryptos: [LTC] },
{ code: 'dashd', display: 'dashd', class: WALLET, cryptos: [DASH] }, { 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_in_txs UNION
SELECT 'address' AS type, to_address AS value FROM cash_out_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, ${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` ) f`
return db.any(sql) return db.any(sql)

View file

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

View file

@ -50,6 +50,7 @@ const typeDef = gql`
batchError: String batchError: String
walletScore: Int walletScore: Int
profit: String profit: String
swept: Boolean
} }
type Filter { type Filter {
@ -58,8 +59,8 @@ const typeDef = gql`
} }
type Query { 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 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, timezone: String, excludeTestingCustomers: Boolean, simplified: Boolean): String @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 transactionCsv(id: ID, txClass: String, timezone: String): String @auth
txAssociatedDataCsv(id: ID, txClass: String, timezone: String): String @auth txAssociatedDataCsv(id: ID, txClass: String, timezone: String): String @auth
transactionFilters: [Filter] @auth transactionFilters: [Filter] @auth

View file

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

View file

@ -145,15 +145,13 @@ const getTriggersAutomation = (customInfoRequests, config) => {
const splitGetFirst = _.compose(_.head, _.split('_')) const splitGetFirst = _.compose(_.head, _.split('_'))
const getCryptosFromWalletNamespace = config => { const getCryptosFromWalletNamespace =
return _.uniq(_.map(splitGetFirst, _.keys(fromNamespace('wallets', config)))) _.compose(_.without(['advanced']), _.uniq, _.map(splitGetFirst), _.keys, fromNamespace('wallets'))
}
const getCashInSettings = config => fromNamespace(namespaces.CASH_IN)(config) const getCashInSettings = config => fromNamespace(namespaces.CASH_IN)(config)
const getCryptoUnits = (crypto, config) => { const getCryptoUnits = (crypto, config) =>
return getWalletSettings(crypto, config).cryptoUnits getWalletSettings(crypto, config).cryptoUnits ?? 'full'
}
const setTermsConditions = toNamespace(namespaces.TERMS_CONDITIONS) 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) { function migrationSaveConfig (config) {
return loadLatestConfigOrNone() return loadLatestConfigOrNone()
.then(currentConfig => { .then(currentConfig => {
@ -221,5 +232,6 @@ module.exports = {
loadLatestConfig, loadLatestConfig,
loadLatestConfigOrNone, loadLatestConfigOrNone,
load, load,
migrate migrate,
removeFromConfig
} }

View file

@ -10,6 +10,7 @@ const notificationCenter = require('./notificationCenter')
const utils = require('./utils') const utils = require('./utils')
const emailFuncs = require('./email') const emailFuncs = require('./email')
const smsFuncs = require('./sms') const smsFuncs = require('./sms')
const webhookFuncs = require('./webhook')
const { STALE, STALE_STATE } = require('./codes') const { STALE, STALE_STATE } = require('./codes')
function buildMessage (alerts, notifications) { function buildMessage (alerts, notifications) {
@ -185,6 +186,10 @@ function complianceNotify (customer, deviceId, action, period) {
email: { email: {
subject: `Customer compliance`, subject: `Customer compliance`,
body: `Customer ${customer.phone} ${msgCore[action]} in machine ${machineName}` 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.active &&
notifications.sms.compliance notifications.sms.compliance
const webhookActive = true
if (emailActive) promises.push(emailFuncs.sendMessage(settings, rec)) if (emailActive) promises.push(emailFuncs.sendMessage(settings, rec))
if (smsActive) promises.push(smsFuncs.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) notifyIfActive('compliance', 'customerComplianceNotify', customer, deviceId, action, period)
@ -220,6 +228,10 @@ function sendRedemptionMessage (txId, error) {
email: { email: {
subject, subject,
body body
},
webhook: {
topic: `Transaction update`,
content: body
} }
} }
return sendTransactionMessage(rec) return sendTransactionMessage(rec)
@ -241,6 +253,11 @@ function sendTransactionMessage (rec, isHighValueTx) {
(notifications.sms.transactions || isHighValueTx) (notifications.sms.transactions || isHighValueTx)
if (smsActive) promises.push(smsFuncs.sendMessage(settings, rec)) 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) return Promise.all(promises)
}) })
} }
@ -259,6 +276,10 @@ function cashboxNotify (deviceId) {
email: { email: {
subject: `Cashbox removal`, subject: `Cashbox removal`,
body: `Cashbox removed in machine ${machineName}` 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 = const smsActive =
notifications.sms.active && notifications.sms.active &&
notifications.sms.security notifications.sms.security
const webhookActive = true
if (emailActive) promises.push(emailFuncs.sendMessage(settings, rec)) if (emailActive) promises.push(emailFuncs.sendMessage(settings, rec))
if (smsActive) promises.push(smsFuncs.sendMessage(settings, rec)) if (smsActive) promises.push(smsFuncs.sendMessage(settings, rec))
if (webhookActive) promises.push(webhookFuncs.sendMessage(settings, rec))
notifyIfActive('security', 'cashboxNotify', deviceId) notifyIfActive('security', 'cashboxNotify', deviceId)
return Promise.all(promises) return Promise.all(promises)

View file

@ -71,7 +71,9 @@ const fiatBalancesNotify = (fiatWarnings) => {
const { cassette, deviceId } = o.detail const { cassette, deviceId } = o.detail
return cassette === balance.cassette && deviceId === balance.deviceId return cassette === balance.cassette && deviceId === balance.deviceId
}, notInvalidated)) return }, 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 }) const detailB = utils.buildDetail({ deviceId: balance.deviceId, cassette: balance.cassette })
return queries.addNotification(FIAT_BALANCE, message, detailB) return queries.addNotification(FIAT_BALANCE, message, detailB)
}) })
@ -111,11 +113,18 @@ const cryptoBalancesNotify = (cryptoWarnings) => {
} }
const balancesNotify = (balances) => { const balancesNotify = (balances) => {
const cryptoFilter = o => o.code === 'HIGH_CRYPTO_BALANCE' || o.code === 'LOW_CRYPTO_BALANCE' const isCryptoCode = c => _.includes(c, ['HIGH_CRYPTO_BALANCE', 'LOW_CRYPTO_BALANCE'])
const fiatFilter = o => o.code === 'LOW_CASH_OUT' const isFiatCode = c => _.includes(c, ['LOW_CASH_OUT', 'CASH_BOX_FULL'])
const cryptoWarnings = _.filter(cryptoFilter, balances) const by = o =>
const fiatWarnings = _.filter(fiatFilter, balances) isCryptoCode(o) ? 'crypto' :
return Promise.all([cryptoBalancesNotify(cryptoWarnings), fiatBalancesNotify(fiatWarnings)]) 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 => { const clearOldErrorNotifications = alerts => {

View file

@ -132,6 +132,10 @@ const buildTransactionMessage = (tx, rec, highValueTx, machineName, customer) =>
email: { email: {
emailSubject, emailSubject,
body body
},
webhook: {
topic: `New transaction`,
content: body
} }
}, highValueTx] }, 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 { return {
cryptoCode, cryptoCode,
display: cryptoRec.display, display: cryptoRec.display,
isCashInOnly: Boolean(cryptoRec.isCashinOnly),
minimumTx: BN.max(minimumTx, cashInFee), minimumTx: BN.max(minimumTx, cashInFee),
cashInFee, cashInFee,
cashInCommission, cashInCommission,
@ -788,9 +789,10 @@ function plugins (settings, deviceId) {
} }
function sweepHdRow (row) { function sweepHdRow (row) {
const txId = row.id
const cryptoCode = row.crypto_code const cryptoCode = row.crypto_code
return wallet.sweep(settings, cryptoCode, row.hd_index) return wallet.sweep(settings, txId, cryptoCode, row.hd_index)
.then(txHash => { .then(txHash => {
if (txHash) { if (txHash) {
logger.debug('[%s] Swept address with tx: %s', cryptoCode, 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) 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 () { function sweepHd () {
const sql = `select id, crypto_code, hd_index from cash_out_txs 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')` WHERE hd_index IS NOT NULL AND NOT swept AND status IN ('confirmed', 'instant') AND created > now() - interval '1 week'`
return db.any(sql) return db.any(sql)
.then(rows => Promise.all(rows.map(sweepHdRow))) .then(rows => Promise.all(rows.map(sweepHdRow)))
@ -848,6 +850,10 @@ function plugins (settings, deviceId) {
return walletScoring.getInputAddresses(settings, tx.cryptoCode, txHashes) return walletScoring.getInputAddresses(settings, tx.cryptoCode, txHashes)
} }
function isWalletScoringEnabled (tx) {
return walletScoring.isWalletScoringEnabled(settings, tx.cryptoCode)
}
return { return {
getRates, getRates,
recordPing, recordPing,
@ -880,7 +886,8 @@ function plugins (settings, deviceId) {
rateWallet, rateWallet,
isValidWalletScore, isValidWalletScore,
getTransactionHash, getTransactionHash,
getInputAddresses getInputAddresses,
isWalletScoringEnabled
} }
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -4,8 +4,8 @@ const { ORDER_TYPES } = require('./consts')
const { COINS } = require('@lamassu/coins') const { COINS } = require('@lamassu/coins')
const ORDER_TYPE = ORDER_TYPES.LIMIT const ORDER_TYPE = ORDER_TYPES.LIMIT
const { BTC, ETH } = COINS const { BTC, ETH, USDT } = COINS
const CRYPTO = [BTC, ETH] const CRYPTO = [BTC, ETH, USDT]
const FIAT = ['USD'] const FIAT = ['USD']
const AMOUNT_PRECISION = 4 const AMOUNT_PRECISION = 4
const REQUIRED_CONFIG_FIELDS = ['clientKey', 'clientSecret', 'userId', 'walletId'] 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 { COINS } = require('@lamassu/coins')
const ORDER_TYPE = ORDER_TYPES.MARKET const ORDER_TYPE = ORDER_TYPES.MARKET
const { BTC, BCH, DASH, ETH, LTC, ZEC, XMR } = COINS const { BTC, BCH, DASH, ETH, LTC, ZEC, XMR, USDT } = COINS
const CRYPTO = [BTC, ETH, LTC, DASH, ZEC, BCH, XMR] const CRYPTO = [BTC, ETH, LTC, DASH, ZEC, BCH, XMR, USDT]
const FIAT = ['USD', 'EUR'] const FIAT = ['USD', 'EUR']
const AMOUNT_PRECISION = 6 const AMOUNT_PRECISION = 6
const REQUIRED_CONFIG_FIELDS = ['apiKey', 'privateKey'] 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) return _.isNil(account) ? Promise.resolve(true) : Promise.resolve(score < threshold)
} }
function getTransactionHash (account, cryptoCode, receivingAddress) { function getAddressTransactionsHashes (receivingAddress, cryptoCode, client, wallet) {
const client = getClient(account)
if (!_.includes(_.toUpper(cryptoCode), SUPPORTED_COINS) || _.isNil(client)) return Promise.resolve(null)
const { apiVersion, authHeader } = client 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`) 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`, { .then(_.flow(
headers: authHeader _.get(['data', 'txHistory']),
})) _.map(_.get(['txHash']))
.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))
})
.catch(err => { .catch(err => {
logger.error(`** DEBUG ** getTransactionHash ERROR: ${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 throw err
}) })
} }
function getInputAddresses (account, cryptoCode, txHashes) { function getInputAddresses (account, cryptoCode, txHashes) {
const client = getClient(account) 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 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}`, { txHashes = _(txHashes).take(10).join(',')
headers: authHeader
}) 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 => { .then(res => {
const data = res.data const data = res.data
if (_.size(data.transactions) > 1) { 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 = { module.exports = {
NAME, NAME,
rateWallet, rateWallet,
isValidWalletScore, isValidWalletScore,
getTransactionHash, 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 = { module.exports = {
NAME, NAME,
rateWallet, rateWallet,
isValidWalletScore, isValidWalletScore,
getTransactionHash, getTransactionHash,
getInputAddresses getInputAddresses,
isWalletScoringEnabled
} }

View file

@ -129,6 +129,13 @@ function checkBlockchainStatus (cryptoCode) {
.then(res => !!res['initialblockdownload'] ? 'syncing' : 'ready') .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 = { module.exports = {
balance, balance,
sendCoins, sendCoins,
@ -136,5 +143,6 @@ module.exports = {
getStatus, getStatus,
newFunding, newFunding,
cryptoNetwork, cryptoNetwork,
checkBlockchainStatus checkBlockchainStatus,
getTxHashesByAddress
} }

View file

@ -191,6 +191,13 @@ function checkBlockchainStatus (cryptoCode) {
.then(res => !!res['initialblockdownload'] ? 'syncing' : 'ready') .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 = { module.exports = {
balance, balance,
sendCoins, sendCoins,
@ -202,5 +209,6 @@ module.exports = {
estimateFee, estimateFee,
sendCoinsBatch, sendCoinsBatch,
checkBlockchainStatus, checkBlockchainStatus,
getTxHashesByAddress,
SUPPORTS_BATCHING SUPPORTS_BATCHING
} }

View file

@ -125,11 +125,19 @@ function checkBlockchainStatus (cryptoCode) {
.then(res => !!res['initialblockdownload'] ? 'syncing' : 'ready') .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 = { module.exports = {
balance, balance,
sendCoins, sendCoins,
newAddress, newAddress,
getStatus, getStatus,
newFunding, newFunding,
checkBlockchainStatus checkBlockchainStatus,
getTxHashesByAddress
} }

View file

@ -6,11 +6,15 @@ const web3 = new Web3()
const hdkey = require('ethereumjs-wallet/hdkey') const hdkey = require('ethereumjs-wallet/hdkey')
const { FeeMarketEIP1559Transaction } = require('@ethereumjs/tx') const { FeeMarketEIP1559Transaction } = require('@ethereumjs/tx')
const { default: Common, Chain, Hardfork } = require('@ethereumjs/common') 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 util = require('ethereumjs-util')
const coins = require('@lamassu/coins') const coins = require('@lamassu/coins')
const pify = require('pify')
const _pify = require('pify')
const BN = require('../../../bn') const BN = require('../../../bn')
const ABI = require('../../tokens') const ABI = require('../../tokens')
const logger = require('../../../logger')
exports.SUPPORTED_MODULES = ['wallet'] exports.SUPPORTED_MODULES = ['wallet']
@ -30,7 +34,27 @@ module.exports = {
privateKey, privateKey,
isStrictAddress, isStrictAddress,
connect, 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) { function connect (url) {
@ -44,12 +68,19 @@ function privateKey (account) {
} }
function isStrictAddress (cryptoCode, toAddress, settings, operatorId) { 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) { function sendCoins (account, tx, settings, operatorId, feeMultiplier) {
const { toAddress, cryptoAtoms, cryptoCode } = tx 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(pify(web3.eth.sendSignedTransaction))
.then(txid => { .then(txid => {
return pify(web3.eth.getTransaction)(txid) return pify(web3.eth.getTransaction)(txid)
@ -77,14 +108,16 @@ function balance (account, cryptoCode, settings, operatorId) {
const pendingBalance = (address, cryptoCode) => { const pendingBalance = (address, cryptoCode) => {
const promises = [_balance(true, address, cryptoCode), _balance(false, 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) const confirmedBalance = (address, cryptoCode) => _balance(false, address, cryptoCode)
function _balance (includePending, address, cryptoCode) { function _balance (includePending, address, cryptoCode) {
if (coins.utils.isErc20Token(cryptoCode)) { if (coins.utils.isErc20Token(cryptoCode)) {
const contract = web3.eth.contract(ABI.ERC20).at(coins.utils.getErc20Token(cryptoCode).contractAddress) const contract = new web3.eth.Contract(ABI.ERC20, coins.utils.getErc20Token(cryptoCode).contractAddress)
return contract.balanceOf(address.toLowerCase()) return contract.methods.balanceOf(address.toLowerCase()).call((_, balance) => {
return contract.methods.decimals().call((_, decimals) => BN(balance).div(10 ** decimals))
})
} }
const block = includePending ? 'pending' : undefined const block = includePending ? 'pending' : undefined
return pify(web3.eth.getBalance)(address.toLowerCase(), block) return pify(web3.eth.getBalance)(address.toLowerCase(), block)
@ -92,27 +125,78 @@ function _balance (includePending, address, cryptoCode) {
.then(balance => balance ? BN(balance) : BN(0)) .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 fromAddress = '0x' + wallet.getAddress().toString('hex')
const isErc20Token = coins.utils.isErc20Token(cryptoCode) const toAddress = coins.utils.getErc20Token(cryptoCode).contractAddress
const toAddress = isErc20Token ? coins.utils.getErc20Token(cryptoCode).contractAddress : _toAddress.toLowerCase()
let contract, contractData const contract = new web3.eth.Contract(ABI.ERC20, toAddress)
if (isErc20Token) { const contractData = contract.methods.transfer(_toAddress.toLowerCase(), hex(amount))
contract = web3.eth.contract(ABI.ERC20).at(toAddress)
contractData = isErc20Token && contract.transfer.getData(_toAddress.toLowerCase(), hex(toSend)) 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 = { const txTemplate = {
from: fromAddress, from: fromAddress,
to: toAddress, to: toAddress,
value: amount.toString() value: amount.toString()
} }
if (isErc20Token) txTemplate.data = contractData const common = new Common({ chain: Chain.Mainnet, hardfork: Hardfork.London })
const common = new Common({ chain: Chain.Ropsten, hardfork: Hardfork.London })
const promises = [ const promises = [
pify(web3.eth.estimateGas)(txTemplate), pify(web3.eth.estimateGas)(txTemplate),
@ -122,34 +206,33 @@ function generateTx (_toAddress, wallet, amount, includesFee, cryptoCode) {
] ]
return Promise.all(promises) return Promise.all(promises)
.then(([gas, gasPrice, txCount]) => [ .then(([gas, gasPrice, txCount, { baseFeePerGas }]) => [
BN(gas), BN(gas),
BN(gasPrice), BN(gasPrice),
_.max([0, txCount, lastUsedNonces[fromAddress] + 1]) _.max([0, txCount, lastUsedNonces[fromAddress] + 1]),
BN(baseFeePerGas)
]) ])
.then(([gas, gasPrice, txCount, baseFeePerGas]) => { .then(([gas, gasPrice, txCount, baseFeePerGas]) => {
lastUsedNonces[fromAddress] = txCount lastUsedNonces[fromAddress] = txCount
const toSend = includesFee const maxPriorityFeePerGas = new BN(web3.utils.toWei('2.5', 'gwei')) // web3 default value
? amount.minus(gasPrice.times(gas)) const neededPriority = new BN(web3.utils.toWei('2.0', 'gwei'))
: amount const maxFeePerGas = baseFeePerGas.plus(neededPriority)
const newGasPrice = BN.minimum(maxFeePerGas, baseFeePerGas.plus(maxPriorityFeePerGas))
const maxPriorityFeePerGas = new BN(2.5) // web3 default value const toSend = includesFee
const maxFeePerGas = new BN(2).times(baseFeePerGas).plus(maxPriorityFeePerGas) ? new BN(amount).minus(newGasPrice.times(gas))
: amount
const rawTx = { const rawTx = {
chainId: 1, chainId: 1,
nonce: txCount, nonce: txCount,
maxPriorityFeePerGas: web3.utils.toHex(web3.utils.toWei(maxPriorityFeePerGas.toString(), 'gwei')), maxPriorityFeePerGas: web3.utils.toHex(maxPriorityFeePerGas),
maxFeePerGas: web3.utils.toHex(web3.utils.toWei(maxFeePerGas.toString(), 'gwei')), maxFeePerGas: web3.utils.toHex(maxFeePerGas),
gasLimit: hex(gas), gasLimit: hex(gas),
to: toAddress, to: toAddress,
from: fromAddress, from: fromAddress,
value: isErc20Token ? hex(BN(0)) : hex(toSend) value: hex(toSend)
}
if (isErc20Token) {
rawTx.data = contractData
} }
const tx = FeeMarketEIP1559Transaction.fromTxData(rawTx, { common }) const tx = FeeMarketEIP1559Transaction.fromTxData(rawTx, { common })
@ -169,17 +252,18 @@ function defaultAddress (account) {
return defaultWallet(account).getChecksumAddressString() 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 wallet = paymentHdNode(account).deriveChild(hdIndex).getWallet()
const fromAddress = wallet.getChecksumAddressString() const fromAddress = wallet.getChecksumAddressString()
return confirmedBalance(fromAddress, cryptoCode) return SWEEP_QUEUE.add(() => confirmedBalance(fromAddress, cryptoCode)
.then(r => { .then(r => {
if (r.eq(0)) return 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)) .then(signedTx => pify(web3.eth.sendSignedTransaction)(signedTx))
}) })
)
} }
function newAddress (account, info, tx, settings, operatorId) { function newAddress (account, info, tx, settings, operatorId) {

View file

@ -1,5 +1,10 @@
const _ = require('lodash/fp') const _ = require('lodash/fp')
const NodeCache = require('node-cache')
const base = require('../geth/base') 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' const NAME = 'infura'
@ -12,4 +17,54 @@ function run (account) {
base.connect(endpoint) 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') .then(res => !!res['initialblockdownload'] ? 'syncing' : 'ready')
} }
function getTxHashesByAddress (cryptoCode, address) {
throw new Error(`Transactions hash retrieval not implemented for this coin!`)
}
module.exports = { module.exports = {
balance, balance,
sendCoins, sendCoins,
newAddress, newAddress,
getStatus, getStatus,
newFunding, newFunding,
checkBlockchainStatus checkBlockchainStatus,
getTxHashesByAddress
} }

View file

@ -10,9 +10,14 @@ const SECONDS = 1000
const PUBLISH_TIME = 3 * SECONDS const PUBLISH_TIME = 3 * SECONDS
const AUTHORIZE_TIME = PUBLISH_TIME + 5 * SECONDS const AUTHORIZE_TIME = PUBLISH_TIME + 5 * SECONDS
const CONFIRM_TIME = AUTHORIZE_TIME + 10 * SECONDS const CONFIRM_TIME = AUTHORIZE_TIME + 10 * SECONDS
const SUPPORTED_COINS = coinUtils.cryptoCurrencies()
let t0 let t0
const checkCryptoCode = (cryptoCode) => !_.includes(cryptoCode, SUPPORTED_COINS)
? Promise.reject(new Error('Unsupported crypto: ' + cryptoCode))
: Promise.resolve()
function _balance (cryptoCode) { function _balance (cryptoCode) {
const cryptoRec = coinUtils.getCryptoCurrency(cryptoCode) const cryptoRec = coinUtils.getCryptoCurrency(cryptoCode)
const unitScale = cryptoRec.unitScale 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)) 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) { function checkBlockchainStatus (cryptoCode) {
@ -123,5 +136,6 @@ module.exports = {
newAddress, newAddress,
getStatus, getStatus,
newFunding, newFunding,
checkBlockchainStatus checkBlockchainStatus,
getTxHashesByAddress
} }

View file

@ -92,7 +92,7 @@ function handleError (error, method) {
function openWallet () { function openWallet () {
return fetch('open_wallet', { filename: 'Wallet' }) return fetch('open_wallet', { filename: 'Wallet' })
.catch(err => handleError(err, 'openWallet')) .catch(() => openWalletWithPassword())
} }
function openWalletWithPassword () { function openWalletWithPassword () {
@ -164,7 +164,7 @@ function getStatus (account, tx, requested, settings, operatorId) {
return checkCryptoCode(cryptoCode) return checkCryptoCode(cryptoCode)
.then(() => refreshWallet()) .then(() => refreshWallet())
.then(() => fetch('get_address_index', { address: toAddress })) .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 => { .then(transferRes => {
const confirmedToAddress = _.filter(it => it.address === toAddress, transferRes.in ?? []) const confirmedToAddress = _.filter(it => it.address === toAddress, transferRes.in ?? [])
const pendingToAddress = _.filter(it => it.address === toAddress, transferRes.pool ?? []) 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 = { module.exports = {
balance, balance,
sendCoins, sendCoins,
@ -242,5 +250,6 @@ module.exports = {
getStatus, getStatus,
newFunding, newFunding,
cryptoNetwork, cryptoNetwork,
checkBlockchainStatus checkBlockchainStatus,
getTxHashesByAddress
} }

View file

@ -84,7 +84,7 @@ function newFunding (account, cryptoCode, settings, operatorId) {
throw new E.NotImplementedError() throw new E.NotImplementedError()
} }
function sweep (account, cryptoCode, hdIndex, settings, operatorId) { function sweep (account, txId, cryptoCode, hdIndex, settings, operatorId) {
throw new E.NotImplementedError() 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 }) const checker = opid => pRetry(() => checkSendStatus(opid), { retries: 20, minTimeout: 300, factor: 1.05 })
return checkCryptoCode(cryptoCode) 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(checker)
.then((res) => { .then((res) => {
return { return {
@ -151,11 +151,17 @@ function checkBlockchainStatus (cryptoCode) {
.then(res => !!res['initial_block_download_complete'] ? 'ready' : 'syncing') .then(res => !!res['initial_block_download_complete'] ? 'ready' : 'syncing')
} }
function getTxHashesByAddress (cryptoCode, address) {
checkCryptoCode(cryptoCode)
.then(() => fetch('getaddresstxids', [address]))
}
module.exports = { module.exports = {
balance, balance,
sendCoins, sendCoins,
newAddress, newAddress,
getStatus, getStatus,
newFunding, newFunding,
checkBlockchainStatus checkBlockchainStatus,
getTxHashesByAddress
} }

View file

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

View file

@ -6,21 +6,36 @@ const notifier = require('../notifier')
const { getMachine, setMachine } = require('../machine-loader') const { getMachine, setMachine } = require('../machine-loader')
const { loadLatestConfig } = require('../new-settings-loader') const { loadLatestConfig } = require('../new-settings-loader')
const { getCashInSettings } = require('../new-config-manager') const { getCashInSettings } = require('../new-config-manager')
const { AUTOMATIC } = require('../constants.js') const { AUTOMATIC } = require('../constants')
const logger = require('../logger')
function notifyCashboxRemoval (req, res, next) { function notifyCashboxRemoval (req, res, next) {
const operatorId = res.locals.operatorId const operatorId = res.locals.operatorId
logger.info(`** DEBUG ** - Cashbox removal - Received a cashbox opening request from device ${req.deviceId}`)
return notifier.cashboxNotify(req.deviceId) return notifier.cashboxNotify(req.deviceId)
.then(() => Promise.all([getMachine(req.deviceId), loadLatestConfig()])) .then(() => Promise.all([getMachine(req.deviceId), loadLatestConfig()]))
.then(([machine, config]) => { .then(([machine, config]) => {
logger.info('** DEBUG ** - Cashbox removal - Retrieving system options for cash-in')
const cashInSettings = getCashInSettings(config) const cashInSettings = getCashInSettings(config)
if (cashInSettings.cashboxReset !== AUTOMATIC) { 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' }) 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) return cashbox.createCashboxBatch(req.deviceId, machine.cashbox)
.then(() => setMachine({ deviceId: req.deviceId, action: 'emptyCashInBills' }, operatorId)) .then(() => {
.then(() => res.status(200).send({ status: 'OK' })) 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) .catch(next)
} }

View file

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

View file

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

View file

@ -1,13 +1,14 @@
const ph = require('./plugin-helper') const ph = require('./plugin-helper')
const _ = require('lodash/fp')
const argv = require('minimist')(process.argv.slice(2)) 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 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 plugin = ph.load(ph.WALLET_SCORING, pluginCode)
const account = settings.accounts[pluginCode] const account = settings.accounts[pluginCode]
return { plugin, account } return { plugin, account, wallet }
} }
function rateWallet (settings, cryptoCode, address) { function rateWallet (settings, cryptoCode, address) {
@ -31,9 +32,9 @@ function isValidWalletScore (settings, score) {
function getTransactionHash (settings, cryptoCode, receivingAddress) { function getTransactionHash (settings, cryptoCode, receivingAddress) {
return Promise.resolve() return Promise.resolve()
.then(() => { .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 = { module.exports = {
rateWallet, rateWallet,
isValidWalletScore, isValidWalletScore,
getTransactionHash, getTransactionHash,
getInputAddresses getInputAddresses,
isWalletScoringEnabled
} }

View file

@ -13,6 +13,7 @@ const httpError = require('./route-helpers').httpError
const logger = require('./logger') const logger = require('./logger')
const { getOpenBatchCryptoValue } = require('./tx-batching') const { getOpenBatchCryptoValue } = require('./tx-batching')
const BN = require('./bn') const BN = require('./bn')
const { BALANCE_FETCH_SPEED_MULTIPLIER } = require('./constants')
const FETCH_INTERVAL = 5000 const FETCH_INTERVAL = 5000
const INSUFFICIENT_FUNDS_CODE = 570 const INSUFFICIENT_FUNDS_CODE = 570
@ -169,6 +170,11 @@ function authorizeZeroConf (settings, tx, machineId) {
return Promise.reject(new Error('tx.fiat is undefined!')) 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)) { if (tx.fiat.gt(zeroConfLimit)) {
return Promise.resolve(false) 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) 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) { function isHd (settings, tx) {
@ -255,20 +261,28 @@ function checkBlockchainStatus (settings, cryptoCode) {
.then(({ checkBlockchainStatus }) => checkBlockchainStatus(cryptoCode)) .then(({ checkBlockchainStatus }) => checkBlockchainStatus(cryptoCode))
} }
const coinFilter = ['ETH']
const balance = (settings, cryptoCode) => { const balance = (settings, cryptoCode) => {
if (_.includes(coinFilter, cryptoCode)) return balanceFiltered(settings, cryptoCode) return fetchWallet(settings, cryptoCode)
return balanceUnfiltered(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, { const balanceNormal = mem(_balance, {
maxAge: FETCH_INTERVAL, maxAge: BALANCE_FETCH_SPEED_MULTIPLIER.NORMAL * FETCH_INTERVAL,
cacheKey: (settings, cryptoCode) => cryptoCode cacheKey: (settings, cryptoCode) => cryptoCode
}) })
const balanceFiltered = mem(_balance, { const balanceSlow = mem(_balance, {
maxAge: 3 * FETCH_INTERVAL, maxAge: BALANCE_FETCH_SPEED_MULTIPLIER.SLOW * FETCH_INTERVAL,
cacheKey: (settings, cryptoCode) => cryptoCode 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 CloseIcon } from 'src/styling/icons/action/close/zodiac.svg'
import { ReactComponent as FilterIcon } from 'src/styling/icons/button/filter/white.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 { 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' import { chipStyles, styles } from './SearchFilter.styles'
@ -18,7 +18,7 @@ const SearchFilter = ({
filters, filters,
onFilterDelete, onFilterDelete,
deleteAllFilters, deleteAllFilters,
entries entries = 0
}) => { }) => {
const chipClasses = useChipStyles() const chipClasses = useChipStyles()
const classes = useStyles() const classes = useStyles()
@ -40,8 +40,11 @@ const SearchFilter = ({
</div> </div>
<div className={classes.deleteWrapper}> <div className={classes.deleteWrapper}>
{ {
<Label3 className={classes.entries}>{`${entries ?? <Label3 className={classes.entries}>{`${entries} ${singularOrPlural(
0} entries`}</Label3> entries,
`entry`,
`entries`
)}`}</Label3>
} }
<ActionButton <ActionButton
color="secondary" color="secondary"

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -74,13 +74,13 @@ const Commissions = ({ name: SCREEN_KEY }) => {
} }
const saveOverridesFromList = it => (_, override) => { 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 sameMachine = R.eqProps('machine', override)
const notSameOverride = it => !R.eqProps('cryptoCurrencies', override, it) const notSameOverride = it => !R.eqProps('cryptoCurrencies', override, it)
const filterMachine = R.filter(R.both(sameMachine, notSameOverride)) const filterMachine = R.filter(R.both(sameMachine, notSameOverride))
const removeCoin = removeCoinFromOverride(cryptoOverriden) const removeCoin = removeCoinFromOverride(cryptoOverridden)
const machineOverrides = R.map(removeCoin)(filterMachine(it)) const machineOverrides = R.map(removeCoin)(filterMachine(it))

View file

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

View file

@ -16,6 +16,7 @@ import { fromNamespace, namespaces } from 'src/utils/config'
import CustomersList from './CustomersList' import CustomersList from './CustomersList'
import CreateCustomerModal from './components/CreateCustomerModal' import CreateCustomerModal from './components/CreateCustomerModal'
import { getAuthorizedStatus } from './helper'
const GET_CUSTOMER_FILTERS = gql` const GET_CUSTOMER_FILTERS = gql`
query filters { query filters {
@ -130,9 +131,20 @@ const Customers = () => {
R.path(['customInfoRequests'], customersResponse) ?? [] R.path(['customInfoRequests'], customersResponse) ?? []
const locale = configData && fromNamespace(namespaces.LOCALE, configData) const locale = configData && fromNamespace(namespaces.LOCALE, configData)
const triggers = configData && fromNamespace(namespaces.TRIGGERS, configData) const triggers = configData && fromNamespace(namespaces.TRIGGERS, configData)
const customersData = R.sortWith([
R.descend(it => new Date(R.prop('lastActive', it) ?? '0')) const setAuthorizedStatus = c =>
])(filteredCustomers ?? []) 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 onFilterChange = filters => {
const filtersObject = getFiltersObj(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 { ReactComponent as TxOutIcon } from 'src/styling/icons/direction/cash-out.svg'
import styles from './CustomersList.styles' import styles from './CustomersList.styles'
import { getAuthorizedStatus, getFormattedPhone, getName } from './helper' import { getFormattedPhone, getName } from './helper'
const useStyles = makeStyles(styles) const useStyles = makeStyles(styles)
@ -73,11 +73,7 @@ const CustomersList = ({
{ {
header: 'Status', header: 'Status',
width: 191, width: 191,
view: it => ( view: it => <MainStatus statuses={[it.authorizedStatus]} />
<MainStatus
statuses={[getAuthorizedStatus(it, triggers, customRequests)]}
/>
)
} }
] ]

View file

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

View file

@ -27,10 +27,10 @@ const allFields = (getData, onChange, auxElements = []) => {
return R.compose(R.join(', '), R.map(getView(data, 'code')))(it) 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 => 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 machineData = getData(['machines'])
const countryData = getData(['countries']) const countryData = getData(['countries'])

View file

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

View file

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

View file

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

View file

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

View file

@ -35,9 +35,9 @@ const CryptoBalanceOverrides = ({ section }) => {
return save(newOverrides) 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( const suggestionFilter = R.filter(
it => !R.contains(it.code, overridenCryptos) it => !R.contains(it.code, overriddenCryptos)
) )
const suggestions = suggestionFilter(cryptoCurrencies) const suggestions = suggestionFilter(cryptoCurrencies)

View file

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

View file

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

View file

@ -153,16 +153,18 @@ const TermsConditions = () => {
} }
const validationSchema = Yup.object().shape({ const validationSchema = Yup.object().shape({
title: Yup.string() title: Yup.string('The screen title must be a string')
.required() .required('The screen title is required')
.max(50, 'Too long'), .max(50, 'Too long'),
text: Yup.string().required(), text: Yup.string('The text content must be a string').required(
acceptButtonText: Yup.string() 'The text content is required'
.required() ),
.max(50, 'Too long'), acceptButtonText: Yup.string('The accept button text must be a string')
cancelButtonText: Yup.string() .required('The accept button text is required')
.required() .max(50, 'The accept button text is too long'),
.max(50, '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 ( return (
@ -236,37 +238,42 @@ const TermsConditions = () => {
setEditing(false) setEditing(false)
setError(null) setError(null)
}}> }}>
<Form> {({ errors }) => (
<PromptWhenDirty /> <Form>
{fields.map((f, idx) => ( <PromptWhenDirty />
<div className={classes.row} key={idx}> {fields.map((f, idx) => (
<Field <div className={classes.row} key={idx}>
editing={editing} <Field
name={f.name} editing={editing}
width={f.width} name={f.name}
placeholder={f.placeholder} width={f.width}
label={f.label} placeholder={f.placeholder}
value={f.value} label={f.label}
multiline={f.multiline} value={f.value}
rows={f.rows} multiline={f.multiline}
onFocus={() => setError(null)} 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>
))} </Form>
<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>
</Formik> </Formik>
</> </>
) )

View file

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

View file

@ -33,6 +33,7 @@ import {
offErrorColor offErrorColor
} from 'src/styling/variables' } from 'src/styling/variables'
import { URI } from 'src/utils/apollo' import { URI } from 'src/utils/apollo'
import { SWEEPABLE_CRYPTOS } from 'src/utils/constants'
import * as Customer from 'src/utils/customer' import * as Customer from 'src/utils/customer'
import CopyToClipboard from './CopyToClipboard' import CopyToClipboard from './CopyToClipboard'
@ -88,24 +89,6 @@ const CANCEL_CASH_IN_TRANSACTION = gql`
const getCryptoAmount = tx => const getCryptoAmount = tx =>
coinUtils.toUnit(new BigNumber(tx.cryptoAtoms), tx.cryptoCode).toNumber() 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 = '') => const formatAddress = (cryptoCode = '', address = '') =>
coinUtils.formatCryptoAddress(cryptoCode, address).replace(/(.{5})/g, '$1 ') coinUtils.formatCryptoAddress(cryptoCode, address).replace(/(.{5})/g, '$1 ')
@ -124,7 +107,7 @@ const DetailsRow = ({ it: tx, timezone }) => {
const zip = new JSZip() const zip = new JSZip()
const [fetchSummary] = useLazyQuery(TX_SUMMARY, { const [fetchSummary] = useLazyQuery(TX_SUMMARY, {
onCompleted: data => createCsv(data) onCompleted: data => createCsv(R.filter(it => !R.isEmpty(it), data))
}) })
const [cancelTransaction] = useMutation( const [cancelTransaction] = useMutation(
@ -136,7 +119,7 @@ const DetailsRow = ({ it: tx, timezone }) => {
} }
) )
const commission = BigNumber(getCommission(tx)) const commission = BigNumber(tx.profit)
.abs() .abs()
.toFixed(2, 1) // ROUND_DOWN .toFixed(2, 1) // ROUND_DOWN
const commissionPercentage = const commissionPercentage =
@ -407,6 +390,14 @@ const DetailsRow = ({ it: tx, timezone }) => {
</ActionButton> </ActionButton>
)} )}
</div> </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> <div>
<Label>Other actions</Label> <Label>Other actions</Label>
<div className={classes.otherActionsGroup}> <div className={classes.otherActionsGroup}>

View file

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

View file

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

View file

@ -173,7 +173,12 @@ const Wizard = ({
const classes = useStyles() const classes = useStyles()
const isEditing = !R.isNil(toBeEdited) const isEditing = !R.isNil(toBeEdited)
const [step, setStep] = useState(isEditing ? 1 : 0) 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 isLastStep = step === LAST_STEP
const onContinue = (values, actions) => { const onContinue = (values, actions) => {

View file

@ -47,13 +47,15 @@ const getOverridesSchema = (values, customInfoRequests) => {
.required() .required()
.test({ .test({
test() { test() {
const { requirement } = this.parent const { id, requirement } = this.parent
if (R.find(R.propEq('requirement', requirement))(values)) { // 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({ return this.createError({
message: `Requirement ${displayRequirement( message: `Requirement '${displayRequirement(
requirement, requirement,
customInfoRequests customInfoRequests
)} already overriden` )}' already overridden`
}) })
} }
return true return true

View file

@ -253,7 +253,9 @@ const Schema = Yup.object()
// TYPE // TYPE
const typeSchema = Yup.object() const typeSchema = Yup.object()
.shape({ .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.object({
threshold: Yup.number() threshold: Yup.number()
.transform(transformNumber) .transform(transformNumber)
@ -297,6 +299,8 @@ const typeSchema = Yup.object()
consecutiveDays: threshold => threshold.thresholdDays > 0 consecutiveDays: threshold => threshold.thresholdDays > 0
} }
if (!triggerType) return
if (triggerType && thresholdValidator[triggerType](threshold)) return if (triggerType && thresholdValidator[triggerType](threshold)) return
return context.createError({ return context.createError({

View file

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

View file

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

View file

@ -8,7 +8,7 @@ import {
} from 'src/components/inputs/formik' } from 'src/components/inputs/formik'
import { disabledColor } from 'src/styling/variables' import { disabledColor } from 'src/styling/variables'
import { CURRENCY_MAX } from 'src/utils/constants' import { CURRENCY_MAX } from 'src/utils/constants'
import { transformNumber } from 'src/utils/number' import { defaultToZero } from 'src/utils/number'
const classes = { const classes = {
editDisabled: { 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 filterCoins = ({ id }) => R.filter(it => R.contains(id)(it.cryptos))
const WalletSchema = Yup.object().shape({ const WalletSchema = Yup.object().shape({
ticker: Yup.string().required(), ticker: Yup.string('The ticker must be a string').required(
wallet: Yup.string().required(), 'The ticker is required'
exchange: Yup.string().required(), ),
zeroConf: Yup.string(), wallet: Yup.string('The wallet must be a string').required(
zeroConfLimit: Yup.number() 'The wallet is required'
.integer() ),
.min(0) 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) .max(CURRENCY_MAX)
.transform(transformNumber) .transform(defaultToZero)
}) })
const AdvancedWalletSchema = Yup.object().shape({ const AdvancedWalletSchema = Yup.object().shape({
@ -195,7 +201,7 @@ const getAdvancedWalletElementsOverrides = (
const has0Conf = R.complement( const has0Conf = R.complement(
/* NOTE: List of coins without 0conf settings. */ /* 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) => { const getElements = (cryptoCurrencies, accounts, onChange, wizard = false) => {

View file

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

View file

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

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