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 = ( +
+ + {R.map( + it => ( + + ), + R.range(0, 10) + )} + +

+ {tx.walletScore} +

+
+ ) + const getCancelMessage = () => { const cashInMessage = `The user will not be able to redeem the inserted bills, even if they subsequently confirm the transaction. If they've already deposited bills, you'll need to reconcile this transaction with them manually.` const cashOutMessage = `The user will not be able to redeem the cash, even if they subsequently send the required coins. If they've already sent you coins, you'll need to reconcile this transaction with them manually.` @@ -282,7 +323,14 @@ const DetailsRow = ({ it: tx, timezone }) => {
- +
+ + {!R.isNil(tx.walletScore) && ( + + {`CipherTrace score: ${tx.walletScore}/10`} + + )} +
{formatAddress(tx.cryptoCode, tx.toAddress)} diff --git a/new-lamassu-admin/src/pages/Transactions/DetailsCard.styles.js b/new-lamassu-admin/src/pages/Transactions/DetailsCard.styles.js index 5b587ba5..349c96d1 100644 --- a/new-lamassu-admin/src/pages/Transactions/DetailsCard.styles.js +++ b/new-lamassu-admin/src/pages/Transactions/DetailsCard.styles.js @@ -1,5 +1,5 @@ import typographyStyles from 'src/components/typography/styles' -import { offColor, comet, white } from 'src/styling/variables' +import { offColor, comet, white, tomato } from 'src/styling/variables' const { p } = typographyStyles @@ -113,5 +113,22 @@ export default { otherActionsGroup: { display: 'flex', flexDirection: 'row' + }, + addressHeader: { + display: 'flex', + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center' + }, + walletScore: { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + '& > p': { + marginLeft: 5 + } + }, + error: { + color: tomato } } diff --git a/new-lamassu-admin/src/pages/Transactions/Transactions.js b/new-lamassu-admin/src/pages/Transactions/Transactions.js index 546f7476..e001a69d 100644 --- a/new-lamassu-admin/src/pages/Transactions/Transactions.js +++ b/new-lamassu-admin/src/pages/Transactions/Transactions.js @@ -116,6 +116,7 @@ const GET_TRANSACTIONS = gql` isAnonymous batched batchTime + walletScore } } ` diff --git a/new-lamassu-admin/src/utils/constants.js b/new-lamassu-admin/src/utils/constants.js index 6201e948..a17f6fa0 100644 --- a/new-lamassu-admin/src/utils/constants.js +++ b/new-lamassu-admin/src/utils/constants.js @@ -1,6 +1,7 @@ const CURRENCY_MAX = 9999999 const MIN_NUMBER_OF_CASSETTES = 2 const MAX_NUMBER_OF_CASSETTES = 4 +const WALLET_SCORING_DEFAULT_THRESHOLD = 9 const AUTOMATIC = 'automatic' const MANUAL = 'manual' @@ -10,5 +11,6 @@ export { MIN_NUMBER_OF_CASSETTES, MAX_NUMBER_OF_CASSETTES, AUTOMATIC, - MANUAL + MANUAL, + WALLET_SCORING_DEFAULT_THRESHOLD } diff --git a/package.json b/package.json index 52cb226e..c66beb2f 100644 --- a/package.json +++ b/package.json @@ -117,7 +117,7 @@ "test": "mocha --recursive tests", "jtest": "jest --detectOpenHandles", "build-admin": "npm run build-admin:css && npm run build-admin:main && npm run build-admin:lamassu", - "server": "nodemon bin/lamassu-server --mockSms --mockScoring", + "server": "nodemon bin/lamassu-server --mockSms", "admin-server": "nodemon bin/lamassu-admin-server --dev", "graphql-server": "nodemon bin/new-graphql-dev-insecure", "watch": "concurrently \"npm:server\" \"npm:admin-server\" \"npm:graphql-server\"",