feat: add ciphertrace base implementation

This commit is contained in:
Sérgio Salgado 2021-12-16 20:04:43 +00:00
parent 201fec33e4
commit 904c383431
20 changed files with 258 additions and 39 deletions

View file

@ -8,7 +8,7 @@ const E = require('../error')
const PENDING_INTERVAL_MS = 60 * T.minutes 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 massageUpdateFields = _.concat(massageFields, 'cryptoAtoms')
const massage = _.flow(_.omit(massageFields), const massage = _.flow(_.omit(massageFields),

View file

@ -15,7 +15,6 @@ const cashInLow = require('./cash-in-low')
const PENDING_INTERVAL = '60 minutes' const PENDING_INTERVAL = '60 minutes'
const MAX_PENDING = 10 const MAX_PENDING = 10
const WALLET_SCORE_THRESHOLD = 10
const TRANSACTION_STATES = ` const TRANSACTION_STATES = `
case case
@ -34,13 +33,13 @@ function post (machineTx, pi) {
const updatedTx = r.tx const updatedTx = r.tx
let blacklisted = false let blacklisted = false
let addressReuse = false let addressReuse = false
let failedWalletScore = false let walletScore = {}
return Promise.all([settingsLoader.loadLatest(), checkForBlacklisted(updatedTx), doesTxReuseAddress(updatedTx), doesWalletScoreFail(updatedTx, pi)]) return Promise.all([settingsLoader.loadLatest(), checkForBlacklisted(updatedTx), doesTxReuseAddress(updatedTx), getWalletScore(updatedTx, pi)])
.then(([{ config }, blacklistItems, isReusedAddress, walletScoreFailed]) => { .then(([{ config }, blacklistItems, isReusedAddress, fetchedWalletScore]) => {
const rejectAddressReuse = configManager.getCompliance(config).rejectAddressReuse const rejectAddressReuse = configManager.getCompliance(config).rejectAddressReuse
failedWalletScore = walletScoreFailed walletScore = fetchedWalletScore
if (_.some(it => it.address === updatedTx.toAddress)(blacklistItems)) { if (_.some(it => it.address === updatedTx.toAddress)(blacklistItems)) {
blacklisted = true blacklisted = true
@ -49,13 +48,14 @@ function post (machineTx, pi) {
notifier.notifyIfActive('compliance', 'blacklistNotify', r.tx, true) notifier.notifyIfActive('compliance', 'blacklistNotify', r.tx, true)
addressReuse = 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(changes => cashInLow.update(db, updatedTx, changes))
.then(tx => _.set('bills', machineTx.bills, tx)) .then(tx => _.set('bills', machineTx.bills, tx))
.then(tx => _.set('blacklisted', blacklisted, tx)) .then(tx => _.set('blacklisted', blacklisted, tx))
.then(tx => _.set('addressReuse', addressReuse, 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) return Promise.resolve(false)
} }
function postProcess (r, pi, isBlacklisted, addressReuse, failedWalletScore) { function postProcess (r, pi, isBlacklisted, addressReuse, walletScore) {
if (addressReuse) { if (addressReuse) {
return Promise.resolve({ return Promise.resolve({
operatorCompleted: true, operatorCompleted: true,
@ -108,10 +108,11 @@ function postProcess (r, pi, isBlacklisted, addressReuse, failedWalletScore) {
}) })
} }
if (failedWalletScore) { if (!_.isNil(walletScore) && !walletScore.isValid) {
return Promise.resolve({ return Promise.resolve({
walletScore: walletScore.score,
operatorCompleted: true, operatorCompleted: true,
error: 'Failed wallet score' error: 'Ciphertrace score is above defined threshold'
}) })
} }
@ -171,12 +172,17 @@ function doesTxReuseAddress (tx) {
return Promise.resolve(false) return Promise.resolve(false)
} }
function doesWalletScoreFail (tx, pi) { function getWalletScore (tx, pi) {
if (!tx.fiat || tx.fiat.isZero()) { if (!tx.fiat || tx.fiat.isZero()) {
return pi.rateWallet(tx.toAddress) return pi.rateWallet(tx.cryptoCode, tx.toAddress)
.then(res => res >= WALLET_SCORE_THRESHOLD)
} }
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) { function monitorPending (settings) {

View file

@ -14,6 +14,7 @@ const SMS = 'sms'
const ID_VERIFIER = 'idVerifier' const ID_VERIFIER = 'idVerifier'
const EMAIL = 'email' const EMAIL = 'email'
const ZERO_CONF = 'zeroConf' const ZERO_CONF = 'zeroConf'
const WALLET_SCORING = 'wallet_scoring'
const ALL_ACCOUNTS = [ const ALL_ACCOUNTS = [
{ code: 'binanceus', display: 'Binance.us', class: TICKER, cryptos: binanceus.CRYPTO }, { code: 'binanceus', display: 'Binance.us', class: TICKER, cryptos: binanceus.CRYPTO },
@ -50,7 +51,8 @@ const ALL_ACCOUNTS = [
{ code: 'mailgun', display: 'Mailgun', class: EMAIL }, { code: 'mailgun', display: 'Mailgun', class: EMAIL },
{ code: 'none', display: 'None', class: ZERO_CONF, cryptos: [BTC, ZEC, LTC, DASH, BCH, ETH, XMR] }, { 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: '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 const devMode = require('minimist')(process.argv.slice(2)).dev

View file

@ -20,7 +20,7 @@ const txLogFields = ['txClass', 'id', 'deviceId', 'toAddress', 'cryptoAtoms',
'commissionPercentage', 'rawTickerPrice', 'receivedCryptoAtoms', 'commissionPercentage', 'rawTickerPrice', 'receivedCryptoAtoms',
'discount', 'txHash', 'customerPhone', 'customerIdCardDataNumber', 'discount', 'txHash', 'customerPhone', 'customerIdCardDataNumber',
'customerIdCardDataExpiration', 'customerIdCardData', 'customerName', 'customerIdCardDataExpiration', 'customerIdCardData', 'customerName',
'customerFrontCameraPath', 'customerIdCardPhotoPath', 'expired', 'machineName'] 'customerFrontCameraPath', 'customerIdCardPhotoPath', 'expired', 'machineName', 'walletScore']
const resolvers = { const resolvers = {
Customer: { Customer: {

View file

@ -47,6 +47,7 @@ const typeDef = gql`
txCustomerPhotoAt: Date txCustomerPhotoAt: Date
batched: Boolean batched: Boolean
batchTime: Date batchTime: Date
walletScore: Int
} }
type Filter { type Filter {

View file

@ -69,7 +69,7 @@ function batch (
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)
${excludeTestingCustomers ? `AND c.is_test_customer is false` : ``} ${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` ORDER BY created DESC limit $4 offset $5`
const cashOutSql = `SELECT 'cashOut' AS tx_class, const cashOutSql = `SELECT 'cashOut' AS tx_class,

View file

@ -828,9 +828,12 @@ function plugins (settings, deviceId) {
.then(buildRates) .then(buildRates)
} }
function rateWallet (address) { function rateWallet (cryptoCode, address) {
return walletScoring.rateWallet(settings, address) return walletScoring.rateWallet(settings, cryptoCode, address)
.then(res => res.rating) }
function isValidWalletScore (score) {
return walletScoring.isValidWalletScore(settings, score)
} }
return { return {
@ -861,7 +864,8 @@ function plugins (settings, deviceId) {
notifyOperator, notifyOperator,
fetchCurrentConfigVersion, fetchCurrentConfigVersion,
pruneMachinesHeartbeat, pruneMachinesHeartbeat,
rateWallet rateWallet,
isValidWalletScore
} }
} }

View file

@ -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
}

View file

@ -1,15 +1,27 @@
const NAME = 'FakeScoring' const NAME = 'FakeScoring'
function rateWallet (account, address) { const { WALLET_SCORE_THRESHOLD } = require('../../../constants')
function rateWallet (account, cryptoCode, address) {
return new Promise((resolve, _) => { return new Promise((resolve, _) => {
setTimeout(() => { setTimeout(() => {
console.log('[WALLET-SCORING] DEBUG: Mock scoring rating wallet address %s', address) 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) }, 100)
}) })
} }
module.exports = { module.exports = {
NAME, NAME,
rateWallet rateWallet,
isValidWalletScore
} }

View file

@ -3,25 +3,32 @@ const _ = require('lodash/fp')
const argv = require('minimist')(process.argv.slice(2)) const argv = require('minimist')(process.argv.slice(2))
function loadWalletScoring (settings) { function loadWalletScoring (settings) {
if (_.isNil(argv.mockScoring)) { const pluginCode = argv.mockScoring ? 'mock-scoring' : 'ciphertrace'
throw new Error('No wallet scoring API set!')
}
const pluginCode = argv.mockScoring ? 'mock-scoring' : ''
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 }
} }
function rateWallet (settings, address) { function rateWallet (settings, cryptoCode, address) {
return Promise.resolve() return Promise.resolve()
.then(() => { .then(() => {
const { plugin, account } = loadWalletScoring(settings) 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 = { module.exports = {
rateWallet rateWallet,
isValidWalletScore
} }

View file

@ -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()
}

View file

@ -61,8 +61,10 @@ const Services = () => {
const updateSettings = element => { const updateSettings = element => {
const settings = element.settings const settings = element.settings
const wallet = R.lensPath(['config', 'wallets_BTC_wallet']) const field = R.lensPath(['config', settings.field])
const isEnabled = R.equals(R.view(wallet, data), settings.requirement) const isEnabled = R.isNil(settings.requirement)
? true
: R.equals(R.view(field, data), settings.requirement)
settings.enabled = isEnabled settings.enabled = isEnabled
return element return element
} }

View file

@ -25,6 +25,7 @@ export default {
code: 'rbf', code: 'rbf',
component: CheckboxInput, component: CheckboxInput,
settings: { settings: {
field: 'wallets_BTC_wallet',
enabled: true, enabled: true,
disabledMessage: 'RBF verification not available', disabledMessage: 'RBF verification not available',
label: 'Lower the confidence of RBF transactions', label: 'Lower the confidence of RBF transactions',

View file

@ -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))
})
}
}

View file

@ -3,6 +3,7 @@ import bitgo from './bitgo'
import bitstamp from './bitstamp' import bitstamp from './bitstamp'
import blockcypher from './blockcypher' import blockcypher from './blockcypher'
import cex from './cex' import cex from './cex'
import ciphertrace from './ciphertrace'
import ftx from './ftx' import ftx from './ftx'
import infura from './infura' import infura from './infura'
import itbit from './itbit' import itbit from './itbit'
@ -21,5 +22,6 @@ export default {
[twilio.code]: twilio, [twilio.code]: twilio,
[binanceus.code]: binanceus, [binanceus.code]: binanceus,
[cex.code]: cex, [cex.code]: cex,
[ftx.code]: ftx [ftx.code]: ftx,
[ciphertrace.code]: ciphertrace
} }

View file

@ -1,6 +1,7 @@
import { useLazyQuery, useMutation } from '@apollo/react-hooks' import { useLazyQuery, useMutation } from '@apollo/react-hooks'
import { makeStyles, Box } from '@material-ui/core' import { makeStyles, Box } from '@material-ui/core'
import BigNumber from 'bignumber.js' import BigNumber from 'bignumber.js'
import classNames from 'classnames'
import { add, differenceInYears, format, sub, parse } from 'date-fns/fp' import { add, differenceInYears, format, sub, parse } from 'date-fns/fp'
import FileSaver from 'file-saver' import FileSaver from 'file-saver'
import gql from 'graphql-tag' 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 Download } from 'src/styling/icons/button/download/zodiac.svg'
import { ReactComponent as TxInIcon } from 'src/styling/icons/direction/cash-in.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 { 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 { URI } from 'src/utils/apollo'
import { onlyFirstToUpper } from 'src/utils/string' import { onlyFirstToUpper } from 'src/utils/string'
@ -163,6 +170,40 @@ const DetailsRow = ({ it: tx, timezone }) => {
</> </>
) )
const walletScoreEl = (
<div className={classes.walletScore}>
<svg width={103} height={10}>
{R.map(
it => (
<circle
cx={it * 10 + 6}
cy={4}
r={3.5}
fill={
it < tx.walletScore
? !R.includes('score is above', tx.hasError ?? '')
? primaryColor
: errorColor
: !R.includes('score is above', tx.hasError ?? '')
? subheaderColor
: offErrorColor
}
/>
),
R.range(0, 10)
)}
</svg>
<P
noMargin
className={classNames({
[classes.bold]: true,
[classes.error]: R.includes('score is above', tx.hasError ?? '')
})}>
{tx.walletScore}
</P>
</div>
)
const getCancelMessage = () => { 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 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.` 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 }) => {
</div> </div>
<div className={classes.secondRow}> <div className={classes.secondRow}>
<div className={classes.address}> <div className={classes.address}>
<div className={classes.addressHeader}>
<Label>Address</Label> <Label>Address</Label>
{!R.isNil(tx.walletScore) && (
<HoverableTooltip parentElements={walletScoreEl}>
{`CipherTrace score: ${tx.walletScore}/10`}
</HoverableTooltip>
)}
</div>
<div> <div>
<CopyToClipboard> <CopyToClipboard>
{formatAddress(tx.cryptoCode, tx.toAddress)} {formatAddress(tx.cryptoCode, tx.toAddress)}

View file

@ -1,5 +1,5 @@
import typographyStyles from 'src/components/typography/styles' 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 const { p } = typographyStyles
@ -113,5 +113,22 @@ export default {
otherActionsGroup: { otherActionsGroup: {
display: 'flex', display: 'flex',
flexDirection: 'row' 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
} }
} }

View file

@ -116,6 +116,7 @@ const GET_TRANSACTIONS = gql`
isAnonymous isAnonymous
batched batched
batchTime batchTime
walletScore
} }
} }
` `

View file

@ -1,6 +1,7 @@
const CURRENCY_MAX = 9999999 const CURRENCY_MAX = 9999999
const MIN_NUMBER_OF_CASSETTES = 2 const MIN_NUMBER_OF_CASSETTES = 2
const MAX_NUMBER_OF_CASSETTES = 4 const MAX_NUMBER_OF_CASSETTES = 4
const WALLET_SCORING_DEFAULT_THRESHOLD = 9
const AUTOMATIC = 'automatic' const AUTOMATIC = 'automatic'
const MANUAL = 'manual' const MANUAL = 'manual'
@ -10,5 +11,6 @@ export {
MIN_NUMBER_OF_CASSETTES, MIN_NUMBER_OF_CASSETTES,
MAX_NUMBER_OF_CASSETTES, MAX_NUMBER_OF_CASSETTES,
AUTOMATIC, AUTOMATIC,
MANUAL MANUAL,
WALLET_SCORING_DEFAULT_THRESHOLD
} }

View file

@ -117,7 +117,7 @@
"test": "mocha --recursive tests", "test": "mocha --recursive tests",
"jtest": "jest --detectOpenHandles", "jtest": "jest --detectOpenHandles",
"build-admin": "npm run build-admin:css && npm run build-admin:main && npm run build-admin:lamassu", "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", "admin-server": "nodemon bin/lamassu-admin-server --dev",
"graphql-server": "nodemon bin/new-graphql-dev-insecure", "graphql-server": "nodemon bin/new-graphql-dev-insecure",
"watch": "concurrently \"npm:server\" \"npm:admin-server\" \"npm:graphql-server\"", "watch": "concurrently \"npm:server\" \"npm:admin-server\" \"npm:graphql-server\"",