Merge branch 'dev' into feat/lam-1291/stress-testing

* dev: (41 commits)
  build: use bullseye as target build
  chore: remove whitespace
  refactor: simplify denominations list construction
  fix: sort coins by descending denomination
  feat: address prompt feature toggle on ui
  feat: reuse last address option
  fix: performance issues on SystemPerformance
  chore: clarify requirements on comment
  feat: allow address reuse if same customer
  feat: address reuse is now per customer
  fix: hide anon and show phone on customers
  fix: dev environment restarts
  feat: batch diagnostics script
  fix: custom info request returns array
  fix: name on customer if custom data is filled
  build: testing cache hit
  build: server cache improvements
  build: node_modules was ignored on .dockerignored
  build: leftovers from npm
  chore: commented by mistake
  ...
This commit is contained in:
siiky 2025-06-02 13:31:02 +01:00
commit 5feee6d5df
105 changed files with 17323 additions and 31348 deletions

View file

@ -9,6 +9,7 @@ const logger = require('../logger')
const settingsLoader = require('../new-settings-loader')
const configManager = require('../new-config-manager')
const notifier = require('../notifier')
const constants = require('../constants')
const cashInAtomic = require('./cash-in-atomic')
const cashInLow = require('./cash-in-low')
@ -194,14 +195,27 @@ function postProcess(r, pi, isBlacklisted, addressReuse, walletScore) {
})
}
// This feels like it can be simplified,
// but it's the most concise query to express the requirement and its edge cases.
// At most only one authenticated customer can use an address.
// If the current customer is anon, we can still allow one other customer to use the address,
// So we count distinct customers plus the current customer if they are not anonymous.
// To prevent malicious blocking of address, we only check for txs with actual fiat
function doesTxReuseAddress(tx) {
const sql = `
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)
SELECT COUNT(*) > 1 as exists
FROM (SELECT DISTINCT customer_id
FROM cash_in_txs
WHERE to_address = $1
AND customer_id != $3
AND fiat > 0
UNION
SELECT $2
WHERE $2 != $3) t;
`
return db
.one(sql, [tx.toAddress, tx.customerId, constants.anonymousCustomer.uuid])
.then(({ exists }) => exists)
}
function getWalletScore(tx, pi) {

View file

@ -9,11 +9,12 @@
*/
const prepare_denominations = denominations =>
JSON.parse(JSON.stringify(denominations))
.sort(([d1], [d2]) => d1 < d2)
.sort(([d1], [d2]) => d2 - d1)
.reduce(
([csum, denoms], [denom, count]) => {
csum += denom * count
return [csum, [{ denom, count, csum }].concat(denoms)]
denoms.push({ denom, count, csum })
return [csum, denoms]
},
[0, []],
)[1] /* ([csum, denoms]) => denoms */

View file

@ -8,16 +8,17 @@ const fs = require('fs')
const util = require('util')
const db = require('./db')
const anonymous = require('../lib/constants').anonymousCustomer
const complianceOverrides = require('./compliance_overrides')
const writeFile = util.promisify(fs.writeFile)
const notifierQueries = require('./notifier/queries')
const notifierUtils = require('./notifier/utils')
const NUM_RESULTS = 1000
const sms = require('./sms')
const settingsLoader = require('./new-settings-loader')
const logger = require('./logger')
const externalCompliance = require('./compliance-external')
const {
customers: { getCustomerList },
} = require('typesafe-db')
const { APPROVED, RETRY } = require('./plugins/compliance/consts')
@ -483,28 +484,6 @@ function addComplianceOverrides(id, customer, userToken) {
)
}
/**
* Query all customers
*
* Add status as computed column,
* which will indicate the name of the latest
* compliance verfication completed by user.
*
* @returns {array} Array of customers populated with status field
*/
function batch() {
const sql = `select * from customers
where id != $1
order by created desc limit $2`
return db.any(sql, [anonymous.uuid, NUM_RESULTS]).then(customers =>
Promise.all(
_.map(customer => {
return getCustomInfoRequestsData(customer).then(camelize)
}, customers),
),
)
}
function getSlimCustomerByIdBatch(ids) {
const sql = `SELECT id, phone, id_card_data
FROM customers
@ -512,88 +491,8 @@ function getSlimCustomerByIdBatch(ids) {
return db.any(sql, [ids]).then(customers => _.map(camelize, customers))
}
// TODO: getCustomersList and getCustomerById are very similar, so this should be refactored
/**
* Query all customers, ordered by last activity
* and with aggregate columns based on their
* transactions
*
* @returns {array} Array of customers with it's transactions aggregations
*/
function getCustomersList(
phone = null,
name = null,
address = null,
id = null,
email = null,
) {
const passableErrorCodes = _.map(
Pgp.as.text,
TX_PASSTHROUGH_ERROR_CODES,
).join(',')
const sql = `SELECT id, authorized_override, days_suspended, is_suspended, front_camera_path, front_camera_override,
phone, email, 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,
sanctions_override, total_txs, total_spent, GREATEST(created, last_transaction, last_data_provided, last_auth_attempt) 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
FROM (
SELECT c.id, c.authorized_override,
greatest(0, date_part('day', c.suspended_until - NOW())) AS days_suspended,
c.suspended_until > NOW() AS is_suspended,
c.front_camera_path, c.front_camera_override,
c.phone, c.email, 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.last_auth_attempt,
GREATEST(c.phone_at, c.email_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,
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,
coalesce(sum(CASE WHEN error_code IS NULL OR error_code NOT IN ($1^) THEN t.fiat ELSE 0 END) OVER (partition by c.id), 0) AS total_spent, ccf.custom_fields
FROM customers c LEFT OUTER JOIN (
SELECT 'cashIn' AS tx_class, id, fiat, fiat_code, created, customer_id, error_code
FROM cash_in_txs WHERE send_confirmed = true OR batched = true UNION
SELECT 'cashOut' AS tx_class, id, fiat, fiat_code, created, customer_id, error_code
FROM cash_out_txs WHERE confirmed_at IS NOT NULL) AS t ON c.id = t.customer_id
LEFT OUTER JOIN (
SELECT cf.customer_id, json_agg(json_build_object('id', cf.custom_field_id, 'label', cf.label, 'value', cf.value)) AS custom_fields FROM (
SELECT ccfp.custom_field_id, ccfp.customer_id, cfd.label, ccfp.value FROM custom_field_definitions cfd
LEFT OUTER JOIN customer_custom_field_pairs ccfp ON cfd.id = ccfp.custom_field_id
) cf GROUP BY cf.customer_id
) ccf ON c.id = ccf.customer_id
LEFT OUTER JOIN (
SELECT customer_id, coalesce(json_agg(customer_notes.*), '[]'::json) AS notes FROM customer_notes
GROUP BY customer_notes.customer_id
) cn ON c.id = cn.customer_id
WHERE c.id != $2
) AS cl WHERE rn = 1
AND ($4 IS NULL OR phone = $4)
AND ($5 IS NULL OR CONCAT(id_card_data::json->>'firstName', ' ', id_card_data::json->>'lastName') = $5 OR id_card_data::json->>'firstName' = $5 OR id_card_data::json->>'lastName' = $5)
AND ($6 IS NULL OR id_card_data::json->>'address' = $6)
AND ($7 IS NULL OR id_card_data::json->>'documentNumber' = $7)
AND ($8 IS NULL OR email = $8)
ORDER BY last_active DESC
limit $3`
return db
.any(sql, [
passableErrorCodes,
anonymous.uuid,
NUM_RESULTS,
phone,
name,
address,
id,
email,
])
.then(customers =>
Promise.all(
_.map(
customer => getCustomInfoRequestsData(customer).then(camelizeDeep),
customers,
),
),
)
function getCustomersList() {
return getCustomerList({ withCustomInfoRequest: true })
}
/**
@ -1081,12 +980,10 @@ function notifyApprovedExternalCompliance(settings, customerId) {
function checkExternalCompliance(settings) {
return getOpenExternalCompliance().then(externals => {
console.log(externals)
const promises = _.map(external => {
return externalCompliance
.getStatus(settings, external.service, external.customer_id)
.then(status => {
console.log('status', status, external.customer_id, external.service)
if (status.status.answer === RETRY)
notifyRetryExternalCompliance(
settings,
@ -1112,12 +1009,16 @@ function addExternalCompliance(customerId, service, id) {
return db.none(sql, [customerId, id, service])
}
function getLastUsedAddress(id, cryptoCode) {
const sql = `SELECT to_address FROM cash_in_txs WHERE customer_id=$1 AND crypto_code=$2 AND fiat > 0 ORDER BY created DESC LIMIT 1`
return db.oneOrNone(sql, [id, cryptoCode]).then(it => it?.to_address)
}
module.exports = {
add,
addWithEmail,
get,
getWithEmail,
batch,
getSlimCustomerByIdBatch,
getCustomersList,
getCustomerById,
@ -1139,4 +1040,5 @@ module.exports = {
updateLastAuthAttempt,
addExternalCompliance,
checkExternalCompliance,
getLastUsedAddress,
}

View file

@ -9,7 +9,6 @@ const eventBus = require('./event-bus')
const DATABASE_NOT_REACHABLE = 'Database not reachable.'
const pgp = Pgp({
pgNative: true,
schema: 'public',
error: (err, e) => {
if (e.cn) logger.error(DATABASE_NOT_REACHABLE)

View file

@ -30,7 +30,7 @@ function getBitPayFxRate(
fiatCodeProperty,
rateProperty,
) {
return getFiatRates().then(({ data: fxRates }) => {
return getFiatRates().then(fxRates => {
const defaultFiatRate = findCurrencyRates(
fxRates,
defaultFiatMarket,
@ -69,14 +69,15 @@ const getRate = (retries = 1, fiatCode, defaultFiatMarket) => {
defaultFiatMarket,
fiatCodeProperty,
rateProperty,
).catch(() => {
// Switch service
).catch(err => {
const erroredService = API_QUEUE.shift()
API_QUEUE.push(erroredService)
if (retries >= MAX_ROTATIONS)
throw new Error(`FOREX API error from ${erroredService.name}`)
throw new Error(
`FOREX API error from ${erroredService.name} ${err?.message}`,
)
return getRate(++retries, fiatCode)
return getRate(++retries, fiatCode, defaultFiatMarket)
})
}

View file

@ -523,6 +523,43 @@ function diagnostics(rec) {
)
}
function batchDiagnostics(deviceIds, operatorId) {
const diagnosticsDir = `${OPERATOR_DATA_DIR}/diagnostics/`
const removeDir = fsPromises
.rm(diagnosticsDir, { recursive: true })
.catch(err => {
if (err.code !== 'ENOENT') {
throw err
}
})
const sql = `UPDATE devices
SET diagnostics_timestamp = NULL,
diagnostics_scan_updated_at = NULL,
diagnostics_front_updated_at = NULL
WHERE device_id = ANY($1)`
// Send individual notifications for each machine
const sendNotifications = deviceIds.map(deviceId =>
db.none('NOTIFY $1:name, $2', [
'machineAction',
JSON.stringify({
action: 'diagnostics',
value: {
deviceId,
operatorId,
action: 'diagnostics',
},
}),
]),
)
return removeDir
.then(() => db.none(sql, [deviceIds]))
.then(() => Promise.all(sendNotifications))
}
function setMachine(rec, operatorId) {
rec.operatorId = operatorId
switch (rec.action) {
@ -681,4 +718,5 @@ module.exports = {
refillMachineUnits,
updateDiagnostics,
updateFailedQRScans,
batchDiagnostics,
}

View file

@ -27,18 +27,5 @@ function transaction() {
return db.any(sql)
}
function customer() {
const sql = `SELECT DISTINCT * FROM (
SELECT 'phone' AS type, phone AS value FROM customers WHERE phone IS NOT NULL UNION
SELECT 'email' AS type, email AS value FROM customers WHERE email IS NOT NULL UNION
SELECT 'name' AS type, id_card_data::json->>'firstName' AS value FROM customers WHERE id_card_data::json->>'firstName' IS NOT NULL AND id_card_data::json->>'lastName' IS NULL UNION
SELECT 'name' AS type, id_card_data::json->>'lastName' AS value FROM customers WHERE id_card_data::json->>'firstName' IS NULL AND id_card_data::json->>'lastName' IS NOT NULL UNION
SELECT 'name' AS type, concat(id_card_data::json->>'firstName', ' ', id_card_data::json->>'lastName') AS value FROM customers WHERE id_card_data::json->>'firstName' IS NOT NULL AND id_card_data::json->>'lastName' IS NOT NULL UNION
SELECT 'address' as type, id_card_data::json->>'address' AS value FROM customers WHERE id_card_data::json->>'address' IS NOT NULL UNION
SELECT 'id' AS type, id_card_data::json->>'documentNumber' AS value FROM customers WHERE id_card_data::json->>'documentNumber' IS NOT NULL
) f`
return db.any(sql)
}
module.exports = { transaction, customer }
module.exports = { transaction }

View file

@ -1,7 +1,6 @@
const authentication = require('../modules/userManagement')
const anonymous = require('../../../constants').anonymousCustomer
const customers = require('../../../customers')
const filters = require('../../filters')
const customerNotes = require('../../../customer-notes')
const machineLoader = require('../../../machine-loader')
@ -18,11 +17,9 @@ const resolvers = {
isAnonymous: parent => parent.customerId === anonymous.uuid,
},
Query: {
customers: (...[, { phone, email, name, address, id }]) =>
customers.getCustomersList(phone, name, address, id, email),
customers: () => customers.getCustomersList(),
customer: (...[, { customerId }]) =>
customers.getCustomerById(customerId).then(addLastUsedMachineName),
customerFilters: () => filters.customer(),
},
Mutation: {
setCustomer: (root, { customerId, customerInput }, context) => {

View file

@ -34,7 +34,7 @@ function ticker(fiatCode, cryptoCode, tickerName) {
return getCurrencyRates(ticker, fiatCode, cryptoCode)
}
return getRate(RETRIES, tickerName, defaultFiatMarket(tickerName)).then(
return getRate(RETRIES, fiatCode, defaultFiatMarket(tickerName)).then(
({ fxRate }) => {
try {
return getCurrencyRates(

View file

@ -55,7 +55,7 @@ const loadRoutes = async () => {
app.use(compression({ threshold: 500 }))
app.use(helmet())
app.use(nocache())
app.use(express.json({ limit: '2mb' }))
app.use(express.json({ limit: '25mb' }))
morgan.token('bytesRead', (_req, res) => res.bytesRead)
morgan.token('bytesWritten', (_req, res) => res.bytesWritten)

View file

@ -311,7 +311,13 @@ function getExternalComplianceLink(req, res, next) {
.then(url => respond(req, res, { url }))
}
function addOrUpdateCustomer(customerData, deviceId, config, isEmailAuth) {
function addOrUpdateCustomer(
customerData,
deviceId,
config,
isEmailAuth,
cryptoCode,
) {
const triggers = configManager.getTriggers(config)
const maxDaysThreshold = complianceTriggers.maxDaysThreshold(triggers)
@ -346,6 +352,18 @@ function addOrUpdateCustomer(customerData, deviceId, config, isEmailAuth) {
.getCustomerActiveIndividualDiscount(customer.id)
.then(discount => ({ ...customer, discount }))
})
.then(customer => {
const enableLastUsedAddress = !!configManager.getWalletSettings(
cryptoCode,
config,
).enableLastUsedAddress
if (!cryptoCode || !enableLastUsedAddress) return customer
return customers
.getLastUsedAddress(customer.id, cryptoCode)
.then(lastUsedAddress => {
return { ...customer, lastUsedAddress }
})
})
}
function getOrAddCustomerPhone(req, res, next) {
@ -354,6 +372,7 @@ function getOrAddCustomerPhone(req, res, next) {
const pi = plugins(req.settings, deviceId)
const phone = req.body.phone
const cryptoCode = req.query.cryptoCode
return pi
.getPhoneCode(phone)
@ -363,6 +382,7 @@ function getOrAddCustomerPhone(req, res, next) {
deviceId,
req.settings.config,
false,
cryptoCode,
).then(customer => respond(req, res, { code, customer }))
})
.catch(err => {
@ -375,6 +395,7 @@ function getOrAddCustomerPhone(req, res, next) {
function getOrAddCustomerEmail(req, res, next) {
const deviceId = req.deviceId
const customerData = req.body
const cryptoCode = req.query.cryptoCode
const pi = plugins(req.settings, req.deviceId)
const email = req.body.email
@ -387,6 +408,7 @@ function getOrAddCustomerEmail(req, res, next) {
deviceId,
req.settings.config,
true,
cryptoCode,
).then(customer => respond(req, res, { code, customer }))
})
.catch(err => {

View file

@ -53,7 +53,12 @@ function getTx(req, res, next) {
return helpers
.fetchStatusTx(req.params.id, req.query.status)
.then(r => res.json(r))
.catch(next)
.catch(err => {
if (err.name === 'HTTPError') {
return res.status(err.code).send(err.message)
}
next(err)
})
}
return next(httpError('Not Found', 404))

View file

@ -8,6 +8,7 @@ const T = require('./time')
// FP operations on Postgres result in very big errors.
// E.g.: 1853.013808 * 1000 = 1866149.494
const REDEEMABLE_AGE = T.day / 1000
const MAX_THRESHOLD_DAYS = 365 * 50 // 50 years maximum
function process(tx, pi) {
const mtx = massage(tx)
@ -92,7 +93,9 @@ function customerHistory(customerId, thresholdDays) {
AND fiat > 0
) ch WHERE NOT ch.expired ORDER BY ch.created`
const days = _.isNil(thresholdDays) ? 0 : thresholdDays
const days = _.isNil(thresholdDays)
? 0
: Math.min(thresholdDays, MAX_THRESHOLD_DAYS)
return db.any(sql, [customerId, `${days} days`, '60 minutes', REDEEMABLE_AGE])
}