diff --git a/lib/cash-in/cash-in-low.js b/lib/cash-in/cash-in-low.js index 1b7ddf07..804e569a 100644 --- a/lib/cash-in/cash-in-low.js +++ b/lib/cash-in/cash-in-low.js @@ -8,7 +8,7 @@ const E = require('../error') const PENDING_INTERVAL_MS = 60 * T.minutes -const massageFields = ['direction', 'cryptoNetwork', 'bills', 'blacklisted', 'addressReuse', 'promoCodeApplied', 'failedWalletScore'] +const massageFields = ['direction', 'cryptoNetwork', 'bills', 'blacklisted', 'addressReuse', 'promoCodeApplied', 'validWalletScore'] const massageUpdateFields = _.concat(massageFields, 'cryptoAtoms') const massage = _.flow(_.omit(massageFields), diff --git a/lib/cash-in/cash-in-tx.js b/lib/cash-in/cash-in-tx.js index 6f932bd5..d97398da 100644 --- a/lib/cash-in/cash-in-tx.js +++ b/lib/cash-in/cash-in-tx.js @@ -15,7 +15,6 @@ const cashInLow = require('./cash-in-low') const PENDING_INTERVAL = '60 minutes' const MAX_PENDING = 10 -const WALLET_SCORE_THRESHOLD = 10 const TRANSACTION_STATES = ` case @@ -34,13 +33,13 @@ function post (machineTx, pi) { const updatedTx = r.tx let blacklisted = false let addressReuse = false - let failedWalletScore = false + let walletScore = {} - return Promise.all([settingsLoader.loadLatest(), checkForBlacklisted(updatedTx), doesTxReuseAddress(updatedTx), doesWalletScoreFail(updatedTx, pi)]) - .then(([{ config }, blacklistItems, isReusedAddress, walletScoreFailed]) => { + return Promise.all([settingsLoader.loadLatest(), checkForBlacklisted(updatedTx), doesTxReuseAddress(updatedTx), getWalletScore(updatedTx, pi)]) + .then(([{ config }, blacklistItems, isReusedAddress, fetchedWalletScore]) => { const rejectAddressReuse = configManager.getCompliance(config).rejectAddressReuse - failedWalletScore = walletScoreFailed + walletScore = fetchedWalletScore if (_.some(it => it.address === updatedTx.toAddress)(blacklistItems)) { blacklisted = true @@ -49,13 +48,14 @@ function post (machineTx, pi) { notifier.notifyIfActive('compliance', 'blacklistNotify', r.tx, true) addressReuse = true } - return postProcess(r, pi, blacklisted, addressReuse, failedWalletScore) + return postProcess(r, pi, blacklisted, addressReuse, walletScore) }) .then(changes => cashInLow.update(db, updatedTx, changes)) .then(tx => _.set('bills', machineTx.bills, tx)) .then(tx => _.set('blacklisted', blacklisted, tx)) .then(tx => _.set('addressReuse', addressReuse, tx)) - .then(tx => _.set('failedWalletScore', failedWalletScore, tx)) + .then(tx => _.set('validWalletScore', _.isNil(walletScore) ? true : walletScore.isValid, tx)) + .then(tx => _.set('walletScore', _.isNil(walletScore) ? null : walletScore.score, tx)) }) } @@ -93,7 +93,7 @@ function checkForBlacklisted (tx) { return Promise.resolve(false) } -function postProcess (r, pi, isBlacklisted, addressReuse, failedWalletScore) { +function postProcess (r, pi, isBlacklisted, addressReuse, walletScore) { if (addressReuse) { return Promise.resolve({ operatorCompleted: true, @@ -108,10 +108,11 @@ function postProcess (r, pi, isBlacklisted, addressReuse, failedWalletScore) { }) } - if (failedWalletScore) { + if (!_.isNil(walletScore) && !walletScore.isValid) { return Promise.resolve({ + walletScore: walletScore.score, operatorCompleted: true, - error: 'Failed wallet score' + error: 'Ciphertrace score is above defined threshold' }) } @@ -171,12 +172,17 @@ function doesTxReuseAddress (tx) { return Promise.resolve(false) } -function doesWalletScoreFail (tx, pi) { +function getWalletScore (tx, pi) { if (!tx.fiat || tx.fiat.isZero()) { - return pi.rateWallet(tx.toAddress) - .then(res => res >= WALLET_SCORE_THRESHOLD) + return pi.rateWallet(tx.cryptoCode, tx.toAddress) } - return Promise.resolve(false) + // Passthrough the previous result + return pi.isValidWalletScore(tx.walletScore) + .then(isValid => ({ + address: tx.toAddress, + score: tx.walletScore, + isValid + })) } function monitorPending (settings) { diff --git a/lib/new-admin/config/accounts.js b/lib/new-admin/config/accounts.js index cee89900..9a7854cb 100644 --- a/lib/new-admin/config/accounts.js +++ b/lib/new-admin/config/accounts.js @@ -14,6 +14,7 @@ const SMS = 'sms' const ID_VERIFIER = 'idVerifier' const EMAIL = 'email' const ZERO_CONF = 'zeroConf' +const WALLET_SCORING = 'wallet_scoring' const ALL_ACCOUNTS = [ { code: 'binanceus', display: 'Binance.us', class: TICKER, cryptos: binanceus.CRYPTO }, @@ -50,7 +51,8 @@ const ALL_ACCOUNTS = [ { code: 'mailgun', display: 'Mailgun', class: EMAIL }, { code: 'none', display: 'None', class: ZERO_CONF, cryptos: [BTC, ZEC, LTC, DASH, BCH, ETH, XMR] }, { code: 'blockcypher', display: 'Blockcypher', class: ZERO_CONF, cryptos: [BTC] }, - { code: 'mock-zero-conf', display: 'Mock 0-conf', class: ZERO_CONF, cryptos: ALL_CRYPTOS, dev: true } + { code: 'mock-zero-conf', display: 'Mock 0-conf', class: ZERO_CONF, cryptos: ALL_CRYPTOS, dev: true }, + { code: 'ciphertrace', display: 'CipherTrace', class: WALLET_SCORING, cryptos: [BTC, ETH, LTC, BCH] } ] const devMode = require('minimist')(process.argv.slice(2)).dev diff --git a/lib/new-admin/graphql/resolvers/transaction.resolver.js b/lib/new-admin/graphql/resolvers/transaction.resolver.js index 96bb406e..e353c891 100644 --- a/lib/new-admin/graphql/resolvers/transaction.resolver.js +++ b/lib/new-admin/graphql/resolvers/transaction.resolver.js @@ -20,7 +20,7 @@ const txLogFields = ['txClass', 'id', 'deviceId', 'toAddress', 'cryptoAtoms', 'commissionPercentage', 'rawTickerPrice', 'receivedCryptoAtoms', 'discount', 'txHash', 'customerPhone', 'customerIdCardDataNumber', 'customerIdCardDataExpiration', 'customerIdCardData', 'customerName', - 'customerFrontCameraPath', 'customerIdCardPhotoPath', 'expired', 'machineName'] + 'customerFrontCameraPath', 'customerIdCardPhotoPath', 'expired', 'machineName', 'walletScore'] const resolvers = { Customer: { diff --git a/lib/new-admin/graphql/types/transaction.type.js b/lib/new-admin/graphql/types/transaction.type.js index a0212e91..8208ba0e 100644 --- a/lib/new-admin/graphql/types/transaction.type.js +++ b/lib/new-admin/graphql/types/transaction.type.js @@ -47,6 +47,7 @@ const typeDef = gql` txCustomerPhotoAt: Date batched: Boolean batchTime: Date + walletScore: Int } type Filter { diff --git a/lib/new-admin/services/transactions.js b/lib/new-admin/services/transactions.js index e420355a..daa226b0 100644 --- a/lib/new-admin/services/transactions.js +++ b/lib/new-admin/services/transactions.js @@ -69,7 +69,7 @@ function batch ( AND ($12 is null or txs.to_address = $12) AND ($13 is null or txs.txStatus = $13) ${excludeTestingCustomers ? `AND c.is_test_customer is false` : ``} - AND (fiat > 0) + AND (error IS NOT null OR fiat > 0) ORDER BY created DESC limit $4 offset $5` const cashOutSql = `SELECT 'cashOut' AS tx_class, diff --git a/lib/plugins.js b/lib/plugins.js index 2a316d9a..f1a53c07 100644 --- a/lib/plugins.js +++ b/lib/plugins.js @@ -828,9 +828,12 @@ function plugins (settings, deviceId) { .then(buildRates) } - function rateWallet (address) { - return walletScoring.rateWallet(settings, address) - .then(res => res.rating) + function rateWallet (cryptoCode, address) { + return walletScoring.rateWallet(settings, cryptoCode, address) + } + + function isValidWalletScore (score) { + return walletScoring.isValidWalletScore(settings, score) } return { @@ -861,7 +864,8 @@ function plugins (settings, deviceId) { notifyOperator, fetchCurrentConfigVersion, pruneMachinesHeartbeat, - rateWallet + rateWallet, + isValidWalletScore } } diff --git a/lib/plugins/wallet-scoring/ciphertrace/ciphertrace.js b/lib/plugins/wallet-scoring/ciphertrace/ciphertrace.js new file mode 100644 index 00000000..99a4460d --- /dev/null +++ b/lib/plugins/wallet-scoring/ciphertrace/ciphertrace.js @@ -0,0 +1,52 @@ +const axios = require('axios') +const _ = require('lodash/fp') + +const { WALLET_SCORE_THRESHOLD } = require('../../../constants') + +const NAME = 'CipherTrace' +const SUPPORTED_COINS = ['BTC', 'ETH', 'BCH', 'LTC', 'BNB', 'RSK'] + +function getClient(account) { + if (_.isNil(account) || !account.enabled) return null + + const [ctv1, username, secretKey] = account.authorizationValue.split(':') + if (_.isNil(ctv1) || _.isNil(username) || _.isNil(secretKey)) { + throw new Error('Invalid CipherTrace configuration') + } + + const apiVersion = ctv1.slice(-2) + const authHeader = { + "Authorization": account + } + return { apiVersion, authHeader } +} + +function rateWallet(account, cryptoCode, address) { + const client = getClient(account) + console.log('client', client) + if (!_.includes(_.toUpper(cryptoCode), SUPPORTED_COINS) || _.isNil(client)) return Promise.resolve(null) + + const { apiVersion, authHeader } = client + const score = Math.floor(Math.random() * (10 - 1 + 1)) + 1 + const threshold = _.isNil(account.scoreThreshold) ? WALLET_SCORE_THRESHOLD : account.scoreThreshold + return Promise.resolve({ address, score, isValid: score < threshold }) + + // return axios.get(`https://rest.ciphertrace.com/aml/${apiVersion}/${_.toLower(cryptoCode)}/risk?address=${address}`, { + // headers: authHeader + // }) + // .then(res => ({ address, score: res.risk, isValid: res.risk <= SCORE_THRESHOLD })) +} + +function isValidWalletScore(account, score) { + const client = getClient(account) + if (_.isNil(client)) return Promise.resolve(true) + + const threshold = _.isNil(account) ? WALLET_SCORE_THRESHOLD : account.scoreThreshold + return Promise.resolve(score < threshold) +} + +module.exports = { + NAME, + rateWallet, + isValidWalletScore +} diff --git a/lib/plugins/wallet-scoring/mock-scoring/mock-scoring.js b/lib/plugins/wallet-scoring/mock-scoring/mock-scoring.js index 48b2240c..bd665358 100644 --- a/lib/plugins/wallet-scoring/mock-scoring/mock-scoring.js +++ b/lib/plugins/wallet-scoring/mock-scoring/mock-scoring.js @@ -1,15 +1,27 @@ const NAME = 'FakeScoring' -function rateWallet (account, address) { +const { WALLET_SCORE_THRESHOLD } = require('../../../constants') + +function rateWallet (account, cryptoCode, address) { return new Promise((resolve, _) => { setTimeout(() => { console.log('[WALLET-SCORING] DEBUG: Mock scoring rating wallet address %s', address) - return resolve({ address, rating: 5 }) + return Promise.resolve(7) + .then(score => resolve({ address, score, isValid: score < WALLET_SCORE_THRESHOLD })) + }, 100) + }) +} + +function isValidWalletScore (account, score) { + return new Promise((resolve, _) => { + setTimeout(() => { + return resolve(score < WALLET_SCORE_THRESHOLD) }, 100) }) } module.exports = { NAME, - rateWallet + rateWallet, + isValidWalletScore } diff --git a/lib/wallet-scoring.js b/lib/wallet-scoring.js index 2b0dbe8b..b7d4b2c3 100644 --- a/lib/wallet-scoring.js +++ b/lib/wallet-scoring.js @@ -3,25 +3,32 @@ const _ = require('lodash/fp') const argv = require('minimist')(process.argv.slice(2)) function loadWalletScoring (settings) { - if (_.isNil(argv.mockScoring)) { - throw new Error('No wallet scoring API set!') - } - const pluginCode = argv.mockScoring ? 'mock-scoring' : '' + const pluginCode = argv.mockScoring ? 'mock-scoring' : 'ciphertrace' const plugin = ph.load(ph.WALLET_SCORING, pluginCode) const account = settings.accounts[pluginCode] return { plugin, account } } -function rateWallet (settings, address) { +function rateWallet (settings, cryptoCode, address) { return Promise.resolve() .then(() => { const { plugin, account } = loadWalletScoring(settings) - return plugin.rateWallet(account, address) + return plugin.rateWallet(account, cryptoCode, address) + }) +} + +function isValidWalletScore (settings, score) { + return Promise.resolve() + .then(() => { + const { plugin, account } = loadWalletScoring(settings) + + return plugin.isValidWalletScore(account, score) }) } module.exports = { - rateWallet + rateWallet, + isValidWalletScore } diff --git a/migrations/1639577650032-wallet-scoring.js b/migrations/1639577650032-wallet-scoring.js new file mode 100644 index 00000000..3e3b01f2 --- /dev/null +++ b/migrations/1639577650032-wallet-scoring.js @@ -0,0 +1,13 @@ +var db = require('./db') + +exports.up = function (next) { + var sql = [ + `ALTER TABLE cash_in_txs ADD COLUMN wallet_score SMALLINT` + ] + + db.multi(sql, next) +} + +exports.down = function (next) { + next() +} diff --git a/new-lamassu-admin/src/pages/Services/Services.js b/new-lamassu-admin/src/pages/Services/Services.js index 2bfe619f..c7cd93ea 100644 --- a/new-lamassu-admin/src/pages/Services/Services.js +++ b/new-lamassu-admin/src/pages/Services/Services.js @@ -61,8 +61,10 @@ const Services = () => { const updateSettings = element => { const settings = element.settings - const wallet = R.lensPath(['config', 'wallets_BTC_wallet']) - const isEnabled = R.equals(R.view(wallet, data), settings.requirement) + const field = R.lensPath(['config', settings.field]) + const isEnabled = R.isNil(settings.requirement) + ? true + : R.equals(R.view(field, data), settings.requirement) settings.enabled = isEnabled return element } diff --git a/new-lamassu-admin/src/pages/Services/schemas/blockcypher.js b/new-lamassu-admin/src/pages/Services/schemas/blockcypher.js index a9e38734..f54b9c27 100644 --- a/new-lamassu-admin/src/pages/Services/schemas/blockcypher.js +++ b/new-lamassu-admin/src/pages/Services/schemas/blockcypher.js @@ -25,6 +25,7 @@ export default { code: 'rbf', component: CheckboxInput, settings: { + field: 'wallets_BTC_wallet', enabled: true, disabledMessage: 'RBF verification not available', label: 'Lower the confidence of RBF transactions', diff --git a/new-lamassu-admin/src/pages/Services/schemas/ciphertrace.js b/new-lamassu-admin/src/pages/Services/schemas/ciphertrace.js new file mode 100644 index 00000000..cd08a6c4 --- /dev/null +++ b/new-lamassu-admin/src/pages/Services/schemas/ciphertrace.js @@ -0,0 +1,49 @@ +import * as Yup from 'yup' + +import CheckboxFormik from 'src/components/inputs/formik/Checkbox' +import NumberInputFormik from 'src/components/inputs/formik/NumberInput' +import SecretInputFormik from 'src/components/inputs/formik/SecretInput' + +import secretTest from './helper' + +export default { + code: 'ciphertrace', + name: 'CipherTrace', + title: 'CipherTrace (Scoring)', + elements: [ + { + code: 'authorizationValue', + display: 'Authorization value', + component: SecretInputFormik + }, + { + code: 'scoreThreshold', + display: 'Score threshold', + component: NumberInputFormik, + face: true, + long: true + }, + { + code: 'enabled', + component: CheckboxFormik, + settings: { + enabled: true, + disabledMessage: 'This plugin is disabled', + label: 'Enabled', + requirement: null + }, + face: true + } + ], + getValidationSchema: account => { + return Yup.object().shape({ + authorizationValue: Yup.string() + .max(100, 'Too long') + .test(secretTest(account?.authorizationValue)), + scoreThreshold: Yup.number() + .min(1, 'The number should be between 1 and 10') + .max(10, 'The number should be between 1 and 10') + .test(secretTest(account?.scoreThreshold)) + }) + } +} diff --git a/new-lamassu-admin/src/pages/Services/schemas/index.js b/new-lamassu-admin/src/pages/Services/schemas/index.js index a7878702..5b5b8825 100644 --- a/new-lamassu-admin/src/pages/Services/schemas/index.js +++ b/new-lamassu-admin/src/pages/Services/schemas/index.js @@ -3,6 +3,7 @@ import bitgo from './bitgo' import bitstamp from './bitstamp' import blockcypher from './blockcypher' import cex from './cex' +import ciphertrace from './ciphertrace' import ftx from './ftx' import infura from './infura' import itbit from './itbit' @@ -21,5 +22,6 @@ export default { [twilio.code]: twilio, [binanceus.code]: binanceus, [cex.code]: cex, - [ftx.code]: ftx + [ftx.code]: ftx, + [ciphertrace.code]: ciphertrace } diff --git a/new-lamassu-admin/src/pages/Transactions/DetailsCard.js b/new-lamassu-admin/src/pages/Transactions/DetailsCard.js index c960a3ee..15ba107a 100644 --- a/new-lamassu-admin/src/pages/Transactions/DetailsCard.js +++ b/new-lamassu-admin/src/pages/Transactions/DetailsCard.js @@ -1,6 +1,7 @@ import { useLazyQuery, useMutation } from '@apollo/react-hooks' import { makeStyles, Box } from '@material-ui/core' import BigNumber from 'bignumber.js' +import classNames from 'classnames' import { add, differenceInYears, format, sub, parse } from 'date-fns/fp' import FileSaver from 'file-saver' import gql from 'graphql-tag' @@ -25,6 +26,12 @@ import { ReactComponent as DownloadInverseIcon } from 'src/styling/icons/button/ import { ReactComponent as Download } from 'src/styling/icons/button/download/zodiac.svg' import { ReactComponent as TxInIcon } from 'src/styling/icons/direction/cash-in.svg' import { ReactComponent as TxOutIcon } from 'src/styling/icons/direction/cash-out.svg' +import { + primaryColor, + subheaderColor, + errorColor, + offErrorColor +} from 'src/styling/variables' import { URI } from 'src/utils/apollo' import { onlyFirstToUpper } from 'src/utils/string' @@ -163,6 +170,40 @@ const DetailsRow = ({ it: tx, timezone }) => { > ) + const walletScoreEl = ( +
+ {tx.walletScore} +
+