diff --git a/lib/cash-out/cash-out-low.js b/lib/cash-out/cash-out-low.js index b5fdd22c..210270af 100644 --- a/lib/cash-out/cash-out-low.js +++ b/lib/cash-out/cash-out-low.js @@ -8,7 +8,7 @@ const toObj = helper.toObj const UPDATEABLE_FIELDS = ['txHash', 'txVersion', 'status', 'dispense', 'dispenseConfirmed', 'notified', 'redeem', 'phone', 'error', 'swept', 'publishedAt', 'confirmedAt', 'errorCode', - 'receivedCryptoAtoms' ] + 'receivedCryptoAtoms', 'walletScore' ] module.exports = {upsert, update, insert} diff --git a/lib/cash-out/cash-out-tx.js b/lib/cash-out/cash-out-tx.js index a7ac11d9..34077546 100644 --- a/lib/cash-out/cash-out-tx.js +++ b/lib/cash-out/cash-out-tx.js @@ -107,9 +107,50 @@ function processTxStatus (tx, settings) { return pi.getStatus(tx) .then(res => _.assign(tx, { receivedCryptoAtoms: res.receivedCryptoAtoms, status: res.status })) + .then(_tx => getWalletScore(_tx, pi)) .then(_tx => selfPost(_tx, pi)) } +function getWalletScore (tx, pi) { + const statuses = ['published', 'authorized', 'rejected', 'insufficientFunds'] + + if (_.includes(tx.status, statuses) && _.isNil(tx.walletScore)) { + // Transaction shows up on the blockchain, we can request the sender address + return pi.getTransactionHash(tx) + .then(txHashes => pi.getInputAddresses(tx, txHashes)) + .then(addresses => { + const addressesPromise = [] + _.forEach(it => addressesPromise.push(pi.rateWallet(tx.cryptoCode, it)), addresses) + return Promise.all(addressesPromise) + }) + .then(scores => { + if (_.isNil(scores) || _.isEmpty(scores)) return tx + const highestScore = _.maxBy(it => it.score, scores) + + // Conservatively assign the highest risk of all input addresses to the risk of this transaction + return highestScore.isValid + ? _.assign(tx, { walletScore: highestScore.score }) + : _.assign(tx, { + walletScore: highestScore.score, + error: 'Ciphertrace score is above defined threshold', + errorCode: 'operatorCancel', + dispense: true + }) + }) + } + + if (tx.status !== 'notSeen' && !_.isNil(tx.walletScore)) { + return pi.isValidWalletScore(tx.walletScore) + .then(isValid => isValid ? tx : _.assign(tx, { + error: 'Ciphertrace score is above defined threshold', + errorCode: 'operatorCancel', + dispense: true + })) + } + + return tx +} + function monitorLiveIncoming (settings, applyFilter, coinFilter) { const statuses = ['notSeen', 'published', 'insufficientFunds'] const toAge = applyFilter ? STALE_LIVE_INCOMING_TX_AGE_FILTER : STALE_LIVE_INCOMING_TX_AGE diff --git a/lib/plugins.js b/lib/plugins.js index f1a53c07..9e367387 100644 --- a/lib/plugins.js +++ b/lib/plugins.js @@ -836,6 +836,14 @@ function plugins (settings, deviceId) { return walletScoring.isValidWalletScore(settings, score) } + function getTransactionHash (tx) { + return walletScoring.getTransactionHash(settings, tx.cryptoCode, tx.toAddress) + } + + function getInputAddresses (tx, txHashes) { + return walletScoring.getInputAddresses(settings, tx.cryptoCode, txHashes) + } + return { getRates, buildRates, @@ -865,7 +873,9 @@ function plugins (settings, deviceId) { fetchCurrentConfigVersion, pruneMachinesHeartbeat, rateWallet, - isValidWalletScore + isValidWalletScore, + getTransactionHash, + getInputAddresses } } diff --git a/lib/plugins/wallet-scoring/ciphertrace/ciphertrace.js b/lib/plugins/wallet-scoring/ciphertrace/ciphertrace.js index d82caf7a..0d306b46 100644 --- a/lib/plugins/wallet-scoring/ciphertrace/ciphertrace.js +++ b/lib/plugins/wallet-scoring/ciphertrace/ciphertrace.js @@ -1,6 +1,8 @@ const axios = require('axios') const _ = require('lodash/fp') +const logger = require('../../../logger') + const NAME = 'CipherTrace' const SUPPORTED_COINS = ['BTC', 'ETH', 'BCH', 'LTC', 'BNB', 'RSK'] @@ -37,11 +39,53 @@ function isValidWalletScore(account, score) { if (_.isNil(client)) return Promise.resolve(true) const threshold = account.scoreThreshold - return Promise.resolve(score < threshold) + return _.isNil(account) ? Promise.resolve(true) : Promise.resolve(score < threshold) +} + +function getTransactionHash(account, cryptoCode, receivingAddress) { + const client = getClient(account) + if (!_.includes(_.toUpper(cryptoCode), SUPPORTED_COINS) || _.isNil(client)) return Promise.resolve(null) + + const { apiVersion, authHeader } = client + + return axios.get(`https://rest.ciphertrace.com/api/${apiVersion}/${_.toLower(cryptoCode) !== 'btc' ? `${_.toLower(cryptoCode)}_` : ``}address/search?features=tx&address=${receivingAddress}`, { + headers: authHeader + }) + .then(res => { + const data = res.data + if (_.size(data.txHistory) > 1) { + logger.warn('An address generated by this wallet was used in more than one transaction') + } + return _.join(', ', _.map(it => it.txHash, data.txHistory)) + }) +} + +function getInputAddresses(account, cryptoCode, txHashes) { + const client = getClient(account) + if (!_.includes(_.toUpper(cryptoCode), SUPPORTED_COINS) || _.isNil(client)) return Promise.resolve(null) + + const { apiVersion, authHeader } = client + + return axios.get(`https://rest.ciphertrace.com/api/${apiVersion}/${_.toLower(cryptoCode) !== 'btc' ? `${_.toLower(cryptoCode)}_` : ``}tx?txhashes=${txHashes}`, { + headers: authHeader + }) + .then(res => { + const data = res.data + if (_.size(data.transactions) > 1) { + logger.warn('An address generated by this wallet was used in more than one transaction') + } + + const transactionInputs = _.map(it => it.inputs, data.transactions) + const inputAddresses = _.map(it => it.address, _.flatten(transactionInputs)) + + return inputAddresses + }) } module.exports = { NAME, rateWallet, - isValidWalletScore + isValidWalletScore, + getTransactionHash, + getInputAddresses } diff --git a/lib/plugins/wallet-scoring/mock-scoring/mock-scoring.js b/lib/plugins/wallet-scoring/mock-scoring/mock-scoring.js index bd665358..d3dfbf03 100644 --- a/lib/plugins/wallet-scoring/mock-scoring/mock-scoring.js +++ b/lib/plugins/wallet-scoring/mock-scoring/mock-scoring.js @@ -6,7 +6,7 @@ function rateWallet (account, cryptoCode, address) { return new Promise((resolve, _) => { setTimeout(() => { console.log('[WALLET-SCORING] DEBUG: Mock scoring rating wallet address %s', address) - return Promise.resolve(7) + return Promise.resolve(2) .then(score => resolve({ address, score, isValid: score < WALLET_SCORE_THRESHOLD })) }, 100) }) @@ -20,8 +20,26 @@ function isValidWalletScore (account, score) { }) } +function getTransactionHash (account, cryptoCode, receivingAddress) { + return new Promise((resolve, _) => { + setTimeout(() => { + return resolve('') + }, 100) + }) +} + +function getInputAddresses (account, cryptoCode, txHashes) { + return new Promise((resolve, _) => { + setTimeout(() => { + return resolve(['']) + }, 100) + }) +} + module.exports = { NAME, rateWallet, - isValidWalletScore + isValidWalletScore, + getTransactionHash, + getInputAddresses } diff --git a/lib/wallet-scoring.js b/lib/wallet-scoring.js index b7d4b2c3..ab461ce4 100644 --- a/lib/wallet-scoring.js +++ b/lib/wallet-scoring.js @@ -28,7 +28,27 @@ function isValidWalletScore (settings, score) { }) } +function getTransactionHash (settings, cryptoCode, receivingAddress) { + return Promise.resolve() + .then(() => { + const { plugin, account } = loadWalletScoring(settings) + + return plugin.getTransactionHash(account, cryptoCode, receivingAddress) + }) +} + +function getInputAddresses (settings, cryptoCode, txHashes) { + return Promise.resolve() + .then(() => { + const { plugin, account } = loadWalletScoring(settings) + + return plugin.getInputAddresses(account, cryptoCode, txHashes) + }) +} + module.exports = { rateWallet, - isValidWalletScore + isValidWalletScore, + getTransactionHash, + getInputAddresses } diff --git a/migrations/1641486859782-wallet-scoring-cash-out.js b/migrations/1641486859782-wallet-scoring-cash-out.js new file mode 100644 index 00000000..50ec3fd2 --- /dev/null +++ b/migrations/1641486859782-wallet-scoring-cash-out.js @@ -0,0 +1,13 @@ +var db = require('./db') + +exports.up = function (next) { + var sql = [ + `ALTER TABLE cash_out_txs ADD COLUMN wallet_score SMALLINT` + ] + + db.multi(sql, next) +} + +exports.down = function (next) { + next() +}