Merge branch 'dev' into releases/v10.1

This commit is contained in:
Rafael Taranto 2025-01-07 14:47:10 +00:00 committed by GitHub
commit 320e76fdae
393 changed files with 11183 additions and 31834 deletions

1
.tool-versions Normal file
View file

@ -0,0 +1 @@
nodejs 14

View file

@ -25,8 +25,8 @@ EXPOSE 3000
ENTRYPOINT [ "/lamassu-server/bin/lamassu-server-entrypoint.sh" ]
FROM alpine:3.14 AS build-ui
RUN apk add --no-cache nodejs npm git curl build-base python3
FROM node:22-alpine AS build-ui
RUN apk add --no-cache npm git curl build-base python3
COPY ["new-lamassu-admin/package.json", "new-lamassu-admin/package-lock.json", "./"]

View file

@ -1,48 +1,61 @@
const _ = require('lodash/fp')
const { addressDetector } = require('@lamassu/coins')
const db = require('./db')
const notifierQueries = require('./notifier/queries')
// Get all blacklist rows from the DB "blacklist" table that were manually inserted by the operator
const getBlacklist = () => {
return db.any(`SELECT * FROM blacklist`).then(res =>
res.map(item => ({
cryptoCode: item.crypto_code,
address: item.address
}))
const getBlacklist = () =>
db.any(
`SELECT blacklist.address AS address, blacklist_messages.content AS blacklistMessage
FROM blacklist JOIN blacklist_messages
ON blacklist.blacklist_message_id = blacklist_messages.id`
)
const deleteFromBlacklist = address => {
const sql = `DELETE FROM blacklist WHERE address = $1`
notifierQueries.clearBlacklistNotification(address)
return db.none(sql, [address])
}
// Delete row from blacklist table by crypto code and address
const deleteFromBlacklist = (cryptoCode, address) => {
const sql = `DELETE FROM blacklist WHERE crypto_code = $1 AND address = $2`
notifierQueries.clearBlacklistNotification(cryptoCode, address)
return db.none(sql, [cryptoCode, address])
const isValidAddress = address => {
try {
return !_.isEmpty(addressDetector.getSupportedCoinsForAddress(address).matches)
} catch {
return false
}
}
const insertIntoBlacklist = (cryptoCode, address) => {
const insertIntoBlacklist = address => {
if (!isValidAddress(address)) {
return Promise.reject(new Error('Invalid address'))
}
return db
.none(
'INSERT INTO blacklist (crypto_code, address) VALUES ($1, $2);',
[cryptoCode, address]
'INSERT INTO blacklist (address) VALUES ($1);',
[address]
)
}
function blocked (address, cryptoCode) {
const sql = `SELECT * FROM blacklist WHERE address = $1 AND crypto_code = $2`
return db.any(sql, [address, cryptoCode])
function blocked (address) {
const sql = `SELECT address, content FROM blacklist b LEFT OUTER JOIN blacklist_messages bm ON bm.id = b.blacklist_message_id WHERE address = $1`
return db.oneOrNone(sql, [address])
}
function addToUsedAddresses (address, cryptoCode) {
// ETH reuses addresses
if (cryptoCode === 'ETH') return Promise.resolve()
function getMessages () {
const sql = `SELECT * FROM blacklist_messages`
return db.any(sql)
}
const sql = `INSERT INTO blacklist (crypto_code, address) VALUES ($1, $2)`
return db.oneOrNone(sql, [cryptoCode, address])
function editBlacklistMessage (id, content) {
const sql = `UPDATE blacklist_messages SET content = $1 WHERE id = $2 RETURNING id`
return db.oneOrNone(sql, [content, id])
}
module.exports = {
blocked,
addToUsedAddresses,
getBlacklist,
deleteFromBlacklist,
insertIntoBlacklist
insertIntoBlacklist,
getMessages,
editBlacklistMessage
}

View file

@ -27,6 +27,10 @@ function updateCore (coinRec, isCurrentlyRunning) {
common.logger.info('Updating Bitcoin Core. This may take a minute...')
!isDevMode() && common.es(`sudo supervisorctl stop bitcoin`)
common.es(`curl -#o /tmp/bitcoin.tar.gz ${coinRec.url}`)
if (common.es(`sha256sum /tmp/bitcoin.tar.gz | awk '{print $1}'`).trim() !== coinRec.urlHash) {
common.logger.info('Failed to update Bitcoin Core: Package signature do not match!')
return
}
common.es(`tar -xzf /tmp/bitcoin.tar.gz -C /tmp/`)
common.logger.info('Updating wallet...')
@ -55,6 +59,20 @@ function updateCore (coinRec, isCurrentlyRunning) {
common.es(`echo "\nlistenonion=0" >> ${BLOCKCHAIN_DIR}/bitcoin/bitcoin.conf`)
}
if (common.es(`grep "fallbackfee=" ${BLOCKCHAIN_DIR}/bitcoin/bitcoin.conf || true`)) {
common.logger.info(`fallbackfee already defined, skipping...`)
} else {
common.logger.info(`Setting 'fallbackfee=0.00005' in config file...`)
common.es(`echo "\nfallbackfee=0.00005" >> ${BLOCKCHAIN_DIR}/bitcoin/bitcoin.conf`)
}
if (common.es(`grep "rpcworkqueue=" ${BLOCKCHAIN_DIR}/bitcoin/bitcoin.conf || true`)) {
common.logger.info(`rpcworkqueue already defined, skipping...`)
} else {
common.logger.info(`Setting 'rpcworkqueue=2000' in config file...`)
common.es(`echo "\nrpcworkqueue=2000" >> ${BLOCKCHAIN_DIR}/bitcoin/bitcoin.conf`)
}
if (isCurrentlyRunning && !isDevMode()) {
common.logger.info('Starting wallet...')
common.es(`sudo supervisorctl start bitcoin`)

View file

@ -20,6 +20,10 @@ function updateCore (coinRec, isCurrentlyRunning) {
common.logger.info('Updating Bitcoin Cash. This may take a minute...')
common.es(`sudo supervisorctl stop bitcoincash`)
common.es(`curl -#Lo /tmp/bitcoincash.tar.gz ${coinRec.url}`)
if (common.es(`sha256sum /tmp/bitcoincash.tar.gz | awk '{print $1}'`).trim() !== coinRec.urlHash) {
common.logger.info('Failed to update Bitcoin Cash: Package signature do not match!')
return
}
common.es(`tar -xzf /tmp/bitcoincash.tar.gz -C /tmp/`)
common.logger.info('Updating wallet...')

View file

@ -29,39 +29,49 @@ module.exports = {
const BINARIES = {
BTC: {
defaultUrl: 'https://bitcoincore.org/bin/bitcoin-core-0.20.1/bitcoin-0.20.1-x86_64-linux-gnu.tar.gz',
defaultUrlHash: '376194f06596ecfa40331167c39bc70c355f960280bd2a645fdbf18f66527397',
defaultDir: 'bitcoin-0.20.1/bin',
url: 'https://bitcoincore.org/bin/bitcoin-core-28.0/bitcoin-28.0-x86_64-linux-gnu.tar.gz',
dir: 'bitcoin-28.0/bin'
dir: 'bitcoin-28.0/bin',
urlHash: '7fe294b02b25b51acb8e8e0a0eb5af6bbafa7cd0c5b0e5fcbb61263104a82fbc',
},
ETH: {
url: 'https://gethstore.blob.core.windows.net/builds/geth-linux-amd64-1.14.12-293a300d.tar.gz',
dir: 'geth-linux-amd64-1.14.12-293a300d'
dir: 'geth-linux-amd64-1.14.12-293a300d',
urlHash: 'e56216b9d179a66a8f71d3dee13ad554da5544d3d29dba33f64c9c0eda5a2237',
},
ZEC: {
url: 'https://download.z.cash/downloads/zcash-6.0.0-linux64-debian-bullseye.tar.gz',
dir: 'zcash-6.0.0/bin'
dir: 'zcash-6.0.0/bin',
urlHash: '3cb82f490e9c8e88007a0216b5261b33ef0fda962b9258441b2def59cb272a4d',
},
DASH: {
defaultUrl: 'https://github.com/dashpay/dash/releases/download/v18.1.0/dashcore-18.1.0-x86_64-linux-gnu.tar.gz',
defaultUrlHash: 'd89c2afd78183f3ee815adcccdff02098be0c982633889e7b1e9c9656fbef219',
defaultDir: 'dashcore-18.1.0/bin',
url: 'https://github.com/dashpay/dash/releases/download/v21.1.1/dashcore-21.1.1-x86_64-linux-gnu.tar.gz',
dir: 'dashcore-21.1.1/bin'
urlHash: 'c3157d4a82a3cb7c904a68e827bd1e629854fefcc0dcaf1de4343a810a190bf5',
},
LTC: {
defaultUrl: 'https://download.litecoin.org/litecoin-0.18.1/linux/litecoin-0.18.1-x86_64-linux-gnu.tar.gz',
defaultUrlHash: 'ca50936299e2c5a66b954c266dcaaeef9e91b2f5307069b9894048acf3eb5751',
defaultDir: 'litecoin-0.18.1/bin',
url: 'https://download.litecoin.org/litecoin-0.21.4/linux/litecoin-0.21.4-x86_64-linux-gnu.tar.gz',
dir: 'litecoin-0.21.4/bin'
dir: 'litecoin-0.21.4/bin',
urlHash: '857fc41091f2bae65c3bf0fd4d388fca915fc93a03f16dd2578ac3cc92898390',
},
BCH: {
url: 'https://github.com/bitcoin-cash-node/bitcoin-cash-node/releases/download/v28.0.0/bitcoin-cash-node-28.0.0-x86_64-linux-gnu.tar.gz',
dir: 'bitcoin-cash-node-28.0.0/bin',
files: [['bitcoind', 'bitcoincashd'], ['bitcoin-cli', 'bitcoincash-cli']]
files: [['bitcoind', 'bitcoincashd'], ['bitcoin-cli', 'bitcoincash-cli']],
urlHash: 'ba735cd3b70fab35ac1496e38596cec1f8d34989924376de001d4a86198f7158',
},
XMR: {
url: 'https://downloads.getmonero.org/cli/monero-linux-x64-v0.18.3.4.tar.bz2',
dir: 'monero-x86_64-linux-gnu-v0.18.3.4',
files: [['monerod', 'monerod'], ['monero-wallet-rpc', 'monero-wallet-rpc']]
files: [['monerod', 'monerod'], ['monero-wallet-rpc', 'monero-wallet-rpc']],
urlHash: '51ba03928d189c1c11b5379cab17dd9ae8d2230056dc05c872d0f8dba4a87f1d',
}
}
@ -133,10 +143,15 @@ function fetchAndInstall (coinRec) {
if (!binaries) throw new Error(`No such coin: ${coinRec.code}`)
const url = requiresUpdate ? binaries.defaultUrl : binaries.url
const hash = requiresUpdate ? binaries.defaultUrlHash : binaries.urlHash
const downloadFile = path.basename(url)
const binDir = requiresUpdate ? binaries.defaultDir : binaries.dir
es(`wget -q ${url}`)
if (es(`sha256sum ${downloadFile} | awk '{print $1}'`).trim() !== hash) {
logger.info(`Failed to install ${coinRec.code}: Package signature do not match!`)
return
}
es(`tar -xf ${downloadFile}`)
const usrBinDir = isDevMode() ? path.resolve(BLOCKCHAIN_DIR, 'bin') : '/usr/local/bin'

View file

@ -20,6 +20,10 @@ function updateCore (coinRec, isCurrentlyRunning) {
common.logger.info('Updating Dash Core. This may take a minute...')
common.es(`sudo supervisorctl stop dash`)
common.es(`curl -#Lo /tmp/dash.tar.gz ${coinRec.url}`)
if (common.es(`sha256sum /tmp/dash.tar.gz | awk '{print $1}'`).trim() !== coinRec.urlHash) {
common.logger.info('Failed to update Dash Core: Package signature do not match!')
return
}
common.es(`tar -xzf /tmp/dash.tar.gz -C /tmp/`)
common.logger.info('Updating wallet...')

View file

@ -8,6 +8,10 @@ function updateCore (coinRec, isCurrentlyRunning) {
common.logger.info('Updating the Geth Ethereum wallet. This may take a minute...')
common.es(`sudo supervisorctl stop ethereum`)
common.es(`curl -#o /tmp/ethereum.tar.gz ${coinRec.url}`)
if (common.es(`sha256sum /tmp/ethereum.tar.gz | awk '{print $1}'`).trim() !== coinRec.urlHash) {
common.logger.info('Failed to update Geth: Package signature do not match!')
return
}
common.es(`tar -xzf /tmp/ethereum.tar.gz -C /tmp/`)
common.logger.info('Updating wallet...')

View file

@ -20,6 +20,10 @@ function updateCore (coinRec, isCurrentlyRunning) {
common.logger.info('Updating Litecoin Core. This may take a minute...')
common.es(`sudo supervisorctl stop litecoin`)
common.es(`curl -#o /tmp/litecoin.tar.gz ${coinRec.url}`)
if (common.es(`sha256sum /tmp/litecoin.tar.gz | awk '{print $1}'`).trim() !== coinRec.urlHash) {
common.logger.info('Failed to update Litecoin Core: Package signature do not match!')
return
}
common.es(`tar -xzf /tmp/litecoin.tar.gz -C /tmp/`)
common.logger.info('Updating wallet...')

View file

@ -22,6 +22,10 @@ function updateCore (coinRec, isCurrentlyRunning) {
common.logger.info('Updating Monero. This may take a minute...')
common.es(`sudo supervisorctl stop monero monero-wallet`)
common.es(`curl -#o /tmp/monero.tar.gz ${coinRec.url}`)
if (common.es(`sha256sum /tmp/monero.tar.gz | awk '{print $1}'`).trim() !== coinRec.urlHash) {
common.logger.info('Failed to update Monero: Package signature do not match!')
return
}
common.es(`tar -xf /tmp/monero.tar.gz -C /tmp/`)
common.logger.info('Updating wallet...')

View file

@ -13,6 +13,10 @@ function updateCore (coinRec, isCurrentlyRunning) {
common.logger.info('Updating your Zcash wallet. This may take a minute...')
common.es(`sudo supervisorctl stop zcash`)
common.es(`curl -#Lo /tmp/zcash.tar.gz ${coinRec.url}`)
if (common.es(`sha256sum /tmp/zcash.tar.gz | awk '{print $1}'`).trim() !== coinRec.urlHash) {
common.logger.info('Failed to update Zcash: Package signature do not match!')
return
}
common.es(`tar -xzf /tmp/zcash.tar.gz -C /tmp/`)
common.logger.info('Updating wallet...')

View file

@ -8,7 +8,7 @@ const E = require('../error')
const PENDING_INTERVAL_MS = 60 * T.minutes
const massageFields = ['direction', 'cryptoNetwork', 'bills', 'blacklisted', 'addressReuse', 'promoCodeApplied', 'validWalletScore', 'cashInFeeCrypto']
const massageFields = ['direction', 'cryptoNetwork', 'bills', 'blacklisted', 'blacklistMessage', 'addressReuse', 'promoCodeApplied', 'validWalletScore', 'cashInFeeCrypto']
const massageUpdateFields = _.concat(massageFields, 'cryptoAtoms')
const massage = _.flow(_.omit(massageFields),

View file

@ -32,38 +32,41 @@ function post (machineTx, pi) {
return cashInAtomic.atomic(machineTx, pi)
.then(r => {
const updatedTx = r.tx
let blacklisted = false
let addressReuse = false
let walletScore = {}
const promises = [settingsLoader.loadLatestConfig()]
const isFirstPost = !r.tx.fiat || r.tx.fiat.isZero()
if (isFirstPost) {
promises.push(checkForBlacklisted(updatedTx), doesTxReuseAddress(updatedTx), getWalletScore(updatedTx, pi))
promises.push(
checkForBlacklisted(updatedTx),
doesTxReuseAddress(updatedTx),
getWalletScore(updatedTx, pi)
)
}
return Promise.all(promises)
.then(([config, blacklistItems = false, isReusedAddress = false, fetchedWalletScore = null]) => {
const rejectAddressReuse = configManager.getCompliance(config).rejectAddressReuse
.then(([config, blacklisted = false, isReusedAddress = false, walletScore = null]) => {
const { rejectAddressReuse } = configManager.getCompliance(config)
const isBlacklisted = !!blacklisted
walletScore = fetchedWalletScore
if (_.some(it => it.address === updatedTx.toAddress)(blacklistItems)) {
blacklisted = true
if (isBlacklisted) {
notifier.notifyIfActive('compliance', 'blacklistNotify', r.tx, false)
} else if (isReusedAddress && rejectAddressReuse) {
notifier.notifyIfActive('compliance', 'blacklistNotify', r.tx, true)
addressReuse = true
}
return postProcess(r, pi, blacklisted, addressReuse, walletScore)
})
return postProcess(r, pi, isBlacklisted, addressReuse, walletScore)
.then(changes => _.set('walletScore', _.isNil(walletScore) ? null : walletScore.score, changes))
.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('validWalletScore', _.isNil(walletScore) ? true : walletScore.isValid, tx))
.then(_.flow(
_.set('bills', machineTx.bills),
_.set('blacklisted', isBlacklisted),
_.set('blacklistMessage', blacklisted?.content),
_.set('addressReuse', addressReuse),
_.set('validWalletScore', _.isNil(walletScore) || walletScore.isValid),
))
})
})
}
@ -94,7 +97,7 @@ function logActionById (action, _rec, txId) {
}
function checkForBlacklisted (tx) {
return blacklist.blocked(tx.toAddress, tx.cryptoCode)
return blacklist.blocked(tx.toAddress)
}
function postProcess (r, pi, isBlacklisted, addressReuse, walletScore) {
@ -150,16 +153,15 @@ function postProcess (r, pi, isBlacklisted, addressReuse, walletScore) {
.catch(err => {
// Important: We don't know what kind of error this is
// so not safe to assume that funds weren't sent.
// Therefore, don't set sendPending to false except for
// errors (like InsufficientFundsError) that are guaranteed
// not to send.
const sendPending = err.name !== 'InsufficientFundsError'
// Setting sendPending to true ensures that the transaction gets
// silently terminated and no retries are done
return {
sendTime: 'now()^',
error: err.message,
errorCode: err.name,
sendPending
sendPending: true
}
})
.then(sendRec => {

View file

@ -51,7 +51,7 @@ const mapValuesWithKey = _.mapValues.convert({cap: false})
function convertBigNumFields (obj) {
const convert = (value, key) => {
if (_.includes(key, [ 'cryptoAtoms', 'receivedCryptoAtoms', 'fiat' ])) {
if (_.includes(key, [ 'cryptoAtoms', 'receivedCryptoAtoms', 'fiat', 'fixedFee' ])) {
return value.toString()
}

View file

@ -6,7 +6,7 @@ const camelize = require('./utils')
function createCashboxBatch (deviceId, cashboxCount) {
if (_.isEqual(0, cashboxCount)) throw new Error('Cash box is empty. Cash box batch could not be created.')
const sql = `INSERT INTO cash_unit_operation (id, device_id, created, operation_type) VALUES ($1, $2, now(), 'cash-box-empty')`
const sql = `INSERT INTO cash_unit_operation (id, device_id, created, operation_type) VALUES ($1, $2, now(), 'cash-box-empty') RETURNING *`
const sql2 = `
UPDATE bills SET cashbox_batch_id=$1
FROM cash_in_txs
@ -25,6 +25,7 @@ function createCashboxBatch (deviceId, cashboxCount) {
const q2 = t.none(sql2, [batchId, deviceId])
const q3 = t.none(sql3, [batchId, deviceId])
return t.batch([q1, q2, q3])
.then(([it]) => it)
})
}
@ -100,14 +101,6 @@ function editBatchById (id, performedBy) {
return db.none(sql, [performedBy, id])
}
function getBillsByBatchId (id) {
const sql = `SELECT bi.* FROM (
SELECT b.id, b.fiat, b.fiat_code, b.created, b.cashbox_batch_id, cit.device_id AS device_id FROM bills b LEFT OUTER JOIN (SELECT id, device_id FROM cash_in_txs) AS cit ON cit.id = b.cash_in_txs_id UNION
SELECT id, fiat, fiat_code, created, cashbox_batch_id, device_id FROM empty_unit_bills
) AS bi WHERE bi.cashbox_batch_id=$1`
return db.any(sql, [id])
}
function logFormatter (data) {
return _.map(
it => {
@ -124,11 +117,62 @@ function logFormatter (data) {
)
}
function getMachineUnbatchedBills (deviceId) {
const sql = `
SELECT now() AS created, cash_in_txs.device_id, json_agg(b.*) AS bills FROM bills b LEFT OUTER JOIN cash_in_txs
ON b.cash_in_txs_id = cash_in_txs.id
WHERE b.cashbox_batch_id IS NULL AND cash_in_txs.device_id = $1
GROUP BY cash_in_txs.device_id
`
return db.oneOrNone(sql, [deviceId])
.then(res => _.mapKeys(it => _.camelCase(it), res))
.then(logFormatterSingle)
}
function getBatchById (id) {
const sql = `
SELECT cb.id, cb.device_id, cb.created, cb.operation_type, cb.bill_count_override, cb.performed_by, json_agg(b.*) AS bills
FROM cashbox_batches AS cb
LEFT JOIN bills AS b ON cb.id = b.cashbox_batch_id
WHERE cb.id = $1
GROUP BY cb.id
`
return db.oneOrNone(sql, [id]).then(res => _.mapKeys(it => _.camelCase(it), res))
.then(logFormatterSingle)
}
function logFormatterSingle (data) {
const bills = _.filter(
it => !(_.isNil(it) || _.isNil(it.fiat_code) || _.isNil(it.fiat) || _.isNaN(it.fiat)),
data.bills
)
return {
id: data.id,
deviceId: data.deviceId,
created: data.created,
operationType: data.operationType,
billCount: _.size(bills),
fiatTotals: _.reduce(
(acc, value) => {
acc[value.fiat_code] = (acc[value.fiat_code] || 0) + value.fiat
return acc
},
{},
bills
),
billsByDenomination: _.countBy(it => `${it.fiat} ${it.fiat_code}`, bills)
}
}
module.exports = {
createCashboxBatch,
updateMachineWithBatch,
getBatches,
getBillsByBatchId,
editBatchById,
getBatchById,
getMachineUnbatchedBills,
logFormatter
}

View file

@ -29,6 +29,7 @@ function mapCoin (rates, deviceId, settings, cryptoCode) {
const cashInFee = showCommissions ? commissions.cashIn / 100 : null
const cashOutFee = showCommissions ? commissions.cashOut / 100 : null
const cashInFixedFee = showCommissions ? commissions.fixedFee : null
const cashOutFixedFee = showCommissions ? commissions.cashOutFixedFee : null
const cashInRate = showCommissions ? _.invoke('cashIn.toNumber', buildedRates) : null
const cashOutRate = showCommissions ? _.invoke('cashOut.toNumber', buildedRates) : null
@ -37,6 +38,7 @@ function mapCoin (rates, deviceId, settings, cryptoCode) {
cashInFee,
cashOutFee,
cashInFixedFee,
cashOutFixedFee,
cashInRate,
cashOutRate
}

View file

@ -80,7 +80,7 @@ function getWithEmail (email) {
*
* @param {string} id Customer's id
* @param {object} data Fields to update
* @param {string} Acting user's token
* @param {string} userToken Acting user's token
*
* @returns {Promise} Newly updated Customer
*/
@ -114,6 +114,7 @@ function update (id, data, userToken) {
async function updateCustomer (id, data, userToken) {
const formattedData = _.pick(
[
'sanctions',
'authorized_override',
'id_card_photo_override',
'id_card_data_override',
@ -229,7 +230,7 @@ function enhanceEditedPhotos (fields) {
/**
* Remove the edited data from the db record
*
* @name enhanceOverrideFields
* @name deleteEditedData
* @function
*
* @param {string} id Customer's id

View file

@ -1,6 +1,10 @@
const _ = require('lodash/fp')
const { ALL_CRYPTOS } = require('@lamassu/coins')
const configManager = require('./new-config-manager')
const ccxt = require('./plugins/exchange/ccxt')
const mockExchange = require('./plugins/exchange/mock-exchange')
const accounts = require('./new-admin/config/accounts')
function lookupExchange (settings, cryptoCode) {
const exchange = configManager.getWalletSettings(cryptoCode, settings.config).exchange
@ -45,8 +49,33 @@ function active (settings, cryptoCode) {
return !!lookupExchange(settings, cryptoCode)
}
function getMarkets () {
const filterExchanges = _.filter(it => it.class === 'exchange' && !it.dev && it.code !== 'no-exchange')
const availableExchanges = _.map(it => it.code, filterExchanges(accounts.ACCOUNT_LIST))
const fetchMarketForExchange = exchange =>
ccxt.getMarkets(exchange, ALL_CRYPTOS)
.then(markets => ({ exchange, markets }))
.catch(error => ({
exchange,
markets: [],
error: error.message
}))
const transformToObject = _.reduce((acc, { exchange, markets }) => ({
...acc,
[exchange]: markets
}), {})
const promises = _.map(fetchMarketForExchange, availableExchanges)
return Promise.all(promises)
.then(transformToObject)
}
module.exports = {
fetchExchange,
buy,
sell,
active
active,
getMarkets
}

View file

@ -36,6 +36,7 @@ const addReceiptInfo = receiptInfo => ret => {
if (!receiptInfo) return ret
const fields = [
'automaticPrint',
'paper',
'sms',
'operatorWebsite',
@ -61,6 +62,18 @@ const addReceiptInfo = receiptInfo => ret => {
}
const addMachineScreenOpts = smth => _.update(
'screenOptions',
_.flow(
addSmthInfo(
'rates',
[
'active'
]
)(smth.rates)
)
)
/* TODO: Simplify this. */
const buildTriggers = allTriggers => {
const normalTriggers = []
@ -89,6 +102,7 @@ const staticConfig = ({ currentConfigVersion, deviceId, deviceName, pq, settings
'cashInCommission',
'cashInFee',
'cashOutCommission',
'cashOutFee',
'cryptoCode',
'cryptoCodeDisplay',
'cryptoNetwork',
@ -102,7 +116,8 @@ const staticConfig = ({ currentConfigVersion, deviceId, deviceName, pq, settings
_.pick([
'coins',
'configVersion',
'timezone'
'timezone',
'screenOptions'
]),
_.update('coins', massageCoins),
_.set('serverVersion', VERSION),
@ -116,6 +131,7 @@ const staticConfig = ({ currentConfigVersion, deviceId, deviceName, pq, settings
configManager.getLocale(deviceId, settings.config),
configManager.getOperatorInfo(settings.config),
configManager.getReceipt(settings.config),
configManager.getAllMachineScreenOpts(settings.config),
!!configManager.getCashOut(deviceId, settings.config).active,
getMachine(deviceId, currentConfigVersion),
configManager.getCustomerAuthenticationMethod(settings.config)
@ -128,6 +144,7 @@ const staticConfig = ({ currentConfigVersion, deviceId, deviceName, pq, settings
localeInfo,
operatorInfo,
receiptInfo,
machineScreenOpts,
twoWayMode,
{ numberOfCassettes, numberOfRecyclers },
customerAuthentication,
@ -152,7 +169,8 @@ const staticConfig = ({ currentConfigVersion, deviceId, deviceName, pq, settings
urlsToPing,
}),
addOperatorInfo(operatorInfo),
addReceiptInfo(receiptInfo)
addReceiptInfo(receiptInfo),
addMachineScreenOpts(machineScreenOpts)
)(staticConf))
}

View file

@ -6,6 +6,7 @@ type Coin {
display: String!
minimumTx: String!
cashInFee: String!
cashOutFee: String!
cashInCommission: String!
cashOutCommission: String!
cryptoNetwork: String!
@ -37,6 +38,7 @@ type MachineInfo {
type ReceiptInfo {
paper: Boolean!
automaticPrint: Boolean!
sms: Boolean!
operatorWebsite: Boolean!
operatorEmail: Boolean!
@ -48,6 +50,14 @@ type ReceiptInfo {
addressQRCode: Boolean!
}
type MachineScreenOptions {
rates: RateScreenOptions!
}
type RateScreenOptions {
active: Boolean!
}
type SpeedtestFile {
url: String!
size: Int!
@ -146,6 +156,7 @@ type StaticConfig {
operatorInfo: OperatorInfo
machineInfo: MachineInfo!
receiptInfo: ReceiptInfo
screenOptions: MachineScreenOptions
speedtestFiles: [SpeedtestFile!]!
urlsToPing: [String!]!

View file

@ -0,0 +1,15 @@
const addRWBytes = () => (req, res, next) => {
const handle = () => {
res.removeListener('finish', handle)
res.removeListener('close', handle)
res.bytesRead = req.connection.bytesRead
res.bytesWritten = req.connection.bytesWritten
}
res.on('finish', handle)
res.on('close', handle)
next()
}
module.exports = addRWBytes

View file

@ -14,6 +14,7 @@ const { ApolloServer } = require('apollo-server-express')
require('../environment-helper')
const { asyncLocalStorage, defaultStore } = require('../async-storage')
const logger = require('../logger')
const exchange = require('../exchange')
const { AuthDirective } = require('./graphql/directives')
const { typeDefs, resolvers } = require('./graphql/schema')
@ -98,6 +99,9 @@ function run () {
const serverLog = `lamassu-admin-server listening on port ${serverPort}`
// cache markets on startup
exchange.getMarkets().catch(console.error)
const webServer = https.createServer(certOptions, app)
webServer.listen(serverPort, () => logger.info(serverLog))
})

View file

@ -2,13 +2,16 @@ const blacklist = require('../../../blacklist')
const resolvers = {
Query: {
blacklist: () => blacklist.getBlacklist()
blacklist: () => blacklist.getBlacklist(),
blacklistMessages: () => blacklist.getMessages()
},
Mutation: {
deleteBlacklistRow: (...[, { cryptoCode, address }]) =>
blacklist.deleteFromBlacklist(cryptoCode, address),
insertBlacklistRow: (...[, { cryptoCode, address }]) =>
blacklist.insertIntoBlacklist(cryptoCode, address)
deleteBlacklistRow: (...[, { address }]) =>
blacklist.deleteFromBlacklist(address),
insertBlacklistRow: (...[, { address }]) =>
blacklist.insertIntoBlacklist(address),
editBlacklistMessage: (...[, { id, content }]) =>
blacklist.editBlacklistMessage(id, content)
}
}

View file

@ -11,9 +11,11 @@ const funding = require('./funding.resolver')
const log = require('./log.resolver')
const loyalty = require('./loyalty.resolver')
const machine = require('./machine.resolver')
const market = require('./market.resolver')
const notification = require('./notification.resolver')
const pairing = require('./pairing.resolver')
const rates = require('./rates.resolver')
const sanctions = require('./sanctions.resolver')
const scalar = require('./scalar.resolver')
const settings = require('./settings.resolver')
const sms = require('./sms.resolver')
@ -34,9 +36,11 @@ const resolvers = [
log,
loyalty,
machine,
market,
notification,
pairing,
rates,
sanctions,
scalar,
settings,
sms,

View file

@ -0,0 +1,9 @@
const exchange = require('../../../exchange')
const resolvers = {
Query: {
getMarkets: () => exchange.getMarkets()
}
}
module.exports = resolvers

View file

@ -0,0 +1,13 @@
const sanctions = require('../../../sanctions')
const authentication = require('../modules/userManagement')
const resolvers = {
Query: {
checkAgainstSanctions: (...[, { customerId }, context]) => {
const token = authentication.getToken(context)
return sanctions.checkByUser(customerId, token)
}
}
}
module.exports = resolvers

View file

@ -2,17 +2,26 @@ const { gql } = require('apollo-server-express')
const typeDef = gql`
type Blacklist {
cryptoCode: String!
address: String!
blacklistMessage: BlacklistMessage!
}
type BlacklistMessage {
id: ID
label: String
content: String
allowToggle: Boolean
}
type Query {
blacklist: [Blacklist] @auth
blacklistMessages: [BlacklistMessage] @auth
}
type Mutation {
deleteBlacklistRow(cryptoCode: String!, address: String!): Blacklist @auth
insertBlacklistRow(cryptoCode: String!, address: String!): Blacklist @auth
deleteBlacklistRow(address: String!): Blacklist @auth
insertBlacklistRow(address: String!): Blacklist @auth
editBlacklistMessage(id: ID, content: String): BlacklistMessage @auth
}
`

View file

@ -11,9 +11,11 @@ const funding = require('./funding.type')
const log = require('./log.type')
const loyalty = require('./loyalty.type')
const machine = require('./machine.type')
const market = require('./market.type')
const notification = require('./notification.type')
const pairing = require('./pairing.type')
const rates = require('./rates.type')
const sanctions = require('./sanctions.type')
const scalar = require('./scalar.type')
const settings = require('./settings.type')
const sms = require('./sms.type')
@ -34,9 +36,11 @@ const types = [
log,
loyalty,
machine,
market,
notification,
pairing,
rates,
sanctions,
scalar,
settings,
sms,

View file

@ -0,0 +1,9 @@
const { gql } = require('apollo-server-express')
const typeDef = gql`
type Query {
getMarkets: JSONObject @auth
}
`
module.exports = typeDef

View file

@ -0,0 +1,13 @@
const { gql } = require('apollo-server-express')
const typeDef = gql`
type SanctionMatches {
ofacSanctioned: Boolean
}
type Query {
checkAgainstSanctions(customerId: ID): SanctionMatches @auth
}
`
module.exports = typeDef

View file

@ -23,7 +23,7 @@ const typeDef = gql`
errorCode: String
operatorCompleted: Boolean
sendPending: Boolean
cashInFee: String
fixedFee: String
minimumTx: Float
customerId: ID
isAnonymous: Boolean

View file

@ -20,7 +20,6 @@ const buildApolloContext = async ({ req, res }) => {
req.session.user.role = user.role
res.set('lamassu_role', user.role)
res.cookie('pazuz_operatoridentifier', base64.encode(user.username))
res.set('Access-Control-Expose-Headers', 'lamassu_role')
return { req, res }

View file

@ -50,7 +50,20 @@ function batch (
excludeTestingCustomers = false,
simplified
) {
const packager = _.flow(_.flatten, _.orderBy(_.property('created'), ['desc']), _.map(camelize), addProfits, addNames)
const isCsvExport = _.isBoolean(simplified)
const packager = _.flow(
_.flatten,
_.orderBy(_.property('created'), ['desc']),
_.map(_.flow(
camelize,
_.mapKeys(k =>
k == 'cashInFee' ? 'fixedFee' :
k
)
)),
addProfits,
addNames
)
const cashInSql = `SELECT 'cashIn' AS tx_class, txs.*,
c.phone AS customer_phone,
@ -80,7 +93,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 (error IS NOT null OR tb.error_message IS NOT null OR fiat > 0)
${isCsvExport && !simplified ? '' : 'AND (error IS NOT null OR tb.error_message IS NOT null OR fiat > 0)'}
ORDER BY created DESC limit $4 offset $5`
const cashOutSql = `SELECT 'cashOut' AS tx_class,
@ -114,7 +127,7 @@ function batch (
AND ($13 is null or txs.txStatus = $13)
AND ($14 is null or txs.swept = $14)
${excludeTestingCustomers ? `AND c.is_test_customer is false` : ``}
AND (fiat > 0)
${isCsvExport ? '' : 'AND fiat > 0'}
ORDER BY created DESC limit $4 offset $5`
// The swept filter is cash-out only, so omit the cash-in query entirely
@ -140,20 +153,20 @@ function batch (
return Promise.all(promises)
.then(packager)
.then(res => {
if (simplified) return simplifiedBatch(res)
.then(res =>
!isCsvExport ? res :
// GQL transactions and transactionsCsv both use this function and
// if we don't check for the correct simplified value, the Transactions page polling
// will continuously build a csv in the background
else if (simplified === false) return advancedBatch(res)
return res
})
simplified ? simplifiedBatch(res) :
advancedBatch(res)
)
}
function advancedBatch (data) {
const fields = ['txClass', 'id', 'deviceId', 'toAddress', 'cryptoAtoms',
'cryptoCode', 'fiat', 'fiatCode', 'fee', 'status', 'fiatProfit', 'cryptoAmount',
'dispense', 'notified', 'redeem', 'phone', 'error',
'dispense', 'notified', 'redeem', 'phone', 'error', 'fixedFee',
'created', 'confirmedAt', 'hdIndex', 'swept', 'timedout',
'dispenseConfirmed', 'provisioned1', 'provisioned2', 'provisioned3', 'provisioned4',
'provisionedRecycler1', 'provisionedRecycler2', 'provisionedRecycler3', 'provisionedRecycler4', 'provisionedRecycler5', 'provisionedRecycler6',
@ -169,7 +182,9 @@ function advancedBatch (data) {
...it,
status: getStatus(it),
fiatProfit: getProfit(it).toString(),
cryptoAmount: getCryptoAmount(it).toString()
cryptoAmount: getCryptoAmount(it).toString(),
fixedFee: it.fixedFee ?? null,
fee: it.fee ?? null,
}))
return _.compose(_.map(_.pick(fields)), addAdvancedFields)(data)

View file

@ -13,7 +13,12 @@ const namespaces = {
TERMS_CONDITIONS: 'termsConditions',
CASH_OUT: 'cashOut',
CASH_IN: 'cashIn',
COMPLIANCE: 'compliance'
COMPLIANCE: 'compliance',
MACHINE_SCREENS: 'machineScreens'
}
const machineScreens = {
RATES: 'rates'
}
const stripl = _.curry((q, str) => _.startsWith(q, str) ? str.slice(q.length) : str)
@ -72,6 +77,8 @@ const getCoinAtmRadar = fromNamespace(namespaces.COIN_ATM_RADAR)
const getTermsConditions = fromNamespace(namespaces.TERMS_CONDITIONS)
const getReceipt = fromNamespace(namespaces.RECEIPT)
const getCompliance = fromNamespace(namespaces.COMPLIANCE)
const getMachineScreenOpts = (screenName, config) => _.compose(fromNamespace(screenName), fromNamespace(namespaces.MACHINE_SCREENS))(config)
const getAllMachineScreenOpts = config => _.reduce((acc, value) => ({ ...acc, [value]: getMachineScreenOpts(value, config) }), {}, _.values(machineScreens))
const getAllCryptoCurrencies = (config) => {
const locale = fromNamespace(namespaces.LOCALE)(config)
@ -180,6 +187,8 @@ module.exports = {
getWalletSettings,
getCashInSettings,
getOperatorInfo,
getMachineScreenOpts,
getAllMachineScreenOpts,
getNotifications,
getGlobalNotifications,
getLocale,

View file

@ -100,16 +100,19 @@ function loadAccounts (schemaVersion) {
.then(_.compose(_.defaultTo({}), _.get('data.accounts')))
}
function hideSecretFields (accounts) {
return _.flow(
_.filter(path => !_.isEmpty(_.get(path, accounts))),
_.reduce(
(accounts, path) => _.assoc(path, PASSWORD_FILLED, accounts),
accounts
)
)(SECRET_FIELDS)
}
function showAccounts (schemaVersion) {
return loadAccounts(schemaVersion)
.then(accounts => {
const filledSecretPaths = _.compact(_.map(path => {
if (!_.isEmpty(_.get(path, accounts))) {
return path
}
}, SECRET_FIELDS))
return _.compose(_.map(path => _.assoc(path, PASSWORD_FILLED), filledSecretPaths))(accounts)
})
.then(hideSecretFields)
}
const insertConfigRow = (dbOrTx, data) =>

View file

@ -249,6 +249,7 @@ function plugins (settings, deviceId) {
const commissions = configManager.getCommissions(cryptoCode, deviceId, settings.config)
const minimumTx = new BN(commissions.minimumTx)
const cashInFee = new BN(commissions.fixedFee)
const cashOutFee = new BN(commissions.cashOutFixedFee)
const cashInCommission = new BN(commissions.cashIn)
const cashOutCommission = _.isNumber(commissions.cashOut) ? new BN(commissions.cashOut) : null
const cryptoRec = coinUtils.getCryptoCurrency(cryptoCode)
@ -261,6 +262,7 @@ function plugins (settings, deviceId) {
isCashInOnly: Boolean(cryptoRec.isCashinOnly),
minimumTx: BN.max(minimumTx, cashInFee),
cashInFee,
cashOutFee,
cashInCommission,
cashOutCommission,
cryptoNetwork,
@ -276,6 +278,7 @@ function plugins (settings, deviceId) {
const localeConfig = configManager.getLocale(deviceId, settings.config)
const fiatCode = localeConfig.fiatCurrency
const cryptoCodes = localeConfig.cryptoCurrencies
const machineScreenOpts = configManager.getAllMachineScreenOpts(settings.config)
const tickerPromises = cryptoCodes.map(c => getTickerRates(fiatCode, c))
const balancePromises = cryptoCodes.map(c => fiatBalance(fiatCode, c))
@ -325,7 +328,8 @@ function plugins (settings, deviceId) {
coins,
configVersion,
areThereAvailablePromoCodes: numberOfAvailablePromoCodes > 0,
timezone
timezone,
screenOptions: machineScreenOpts
}
})
}
@ -473,7 +477,9 @@ function plugins (settings, deviceId) {
function buyAndSell (rec, doBuy, tx) {
const cryptoCode = rec.cryptoCode
const fiatCode = rec.fiatCode
return exchange.fetchExchange(settings, cryptoCode)
.then(_exchange => {
const fiatCode = _exchange.account.currencyMarket
const cryptoAtoms = doBuy ? commissionMath.fiatToCrypto(tx, rec, deviceId, settings.config) : rec.cryptoAtoms.negated()
const market = [fiatCode, cryptoCode].join('')
@ -492,6 +498,7 @@ function plugins (settings, deviceId) {
cryptoCode,
timestamp: Date.now()
})
})
}
function consolidateTrades (cryptoCode, fiatCode) {
@ -548,19 +555,22 @@ function plugins (settings, deviceId) {
const deviceIds = devices.map(device => device.deviceId)
const lists = deviceIds.map(deviceId => {
const localeConfig = configManager.getLocale(deviceId, settings.config)
const fiatCode = localeConfig.fiatCurrency
const cryptoCodes = localeConfig.cryptoCurrencies
return cryptoCodes.map(cryptoCode => ({
fiatCode,
return Promise.all(cryptoCodes.map(cryptoCode => {
return exchange.fetchExchange(settings, cryptoCode)
.then(exchange => ({
fiatCode: exchange.account.currencyMarket,
cryptoCode
}))
}))
})
const tradesPromises = _.uniq(_.flatten(lists))
.map(r => executeTradesForMarket(settings, r.fiatCode, r.cryptoCode))
return Promise.all(tradesPromises)
return Promise.all(lists)
})
.then(lists => {
return Promise.all(_.uniq(_.flatten(lists))
.map(r => executeTradesForMarket(settings, r.fiatCode, r.cryptoCode)))
})
.catch(logger.error)
}

View file

@ -23,7 +23,8 @@ const ALL = {
bitpay: bitpay,
coinbase: {
CRYPTO: [BTC, ETH, LTC, DASH, ZEC, BCH, USDT, USDT_TRON, TRX, LN],
FIAT: 'ALL_CURRENCIES'
FIAT: 'ALL_CURRENCIES',
DEFAULT_FIAT_MARKET: 'EUR'
},
binance: binance,
bitfinex: bitfinex
@ -33,11 +34,8 @@ function buildMarket (fiatCode, cryptoCode, serviceName) {
if (!_.includes(cryptoCode, ALL[serviceName].CRYPTO)) {
throw new Error('Unsupported crypto: ' + cryptoCode)
}
const fiatSupported = ALL[serviceName].FIAT
if (fiatSupported !== 'ALL_CURRENCIES' && !_.includes(fiatCode, fiatSupported)) {
logger.info('Building a market for an unsupported fiat. Defaulting to EUR market')
return cryptoCode + '/' + 'EUR'
}
if (_.isNil(fiatCode)) throw new Error('Market pair building failed: Missing fiat code')
return cryptoCode + '/' + fiatCode
}
@ -51,4 +49,8 @@ function isConfigValid (config, fields) {
return _.every(it => it || it === 0)(values)
}
module.exports = { buildMarket, ALL, verifyFiatSupport, isConfigValid }
function defaultFiatMarket (serviceName) {
return ALL[serviceName].DEFAULT_FIAT_MARKET
}
module.exports = { buildMarket, ALL, verifyFiatSupport, isConfigValid, defaultFiatMarket }

View file

@ -6,8 +6,9 @@ const { ORDER_TYPES } = require('./consts')
const ORDER_TYPE = ORDER_TYPES.MARKET
const { BTC, BCH, XMR, ETH, LTC, ZEC, LN } = COINS
const CRYPTO = [BTC, ETH, LTC, ZEC, BCH, XMR, LN]
const FIAT = ['USD', 'EUR']
const REQUIRED_CONFIG_FIELDS = ['apiKey', 'privateKey']
const FIAT = ['EUR']
const DEFAULT_FIAT_MARKET = 'EUR'
const REQUIRED_CONFIG_FIELDS = ['apiKey', 'privateKey', 'currencyMarket']
const loadConfig = (account) => {
const mapper = {
@ -17,4 +18,4 @@ const loadConfig = (account) => {
return { ...mapped, timeout: 3000 }
}
module.exports = { loadConfig, REQUIRED_CONFIG_FIELDS, CRYPTO, FIAT, ORDER_TYPE }
module.exports = { loadConfig, DEFAULT_FIAT_MARKET, REQUIRED_CONFIG_FIELDS, CRYPTO, FIAT, ORDER_TYPE }

View file

@ -7,7 +7,8 @@ const ORDER_TYPE = ORDER_TYPES.MARKET
const { BTC, BCH, DASH, ETH, LTC, ZEC, USDT, USDT_TRON, LN } = COINS
const CRYPTO = [BTC, ETH, LTC, DASH, ZEC, BCH, USDT, USDT_TRON, LN]
const FIAT = ['USD']
const REQUIRED_CONFIG_FIELDS = ['apiKey', 'privateKey']
const DEFAULT_FIAT_MARKET = 'USD'
const REQUIRED_CONFIG_FIELDS = ['apiKey', 'privateKey', 'currencyMarket']
const loadConfig = (account) => {
const mapper = {
@ -17,4 +18,4 @@ const loadConfig = (account) => {
return { ...mapped, timeout: 3000 }
}
module.exports = { loadConfig, REQUIRED_CONFIG_FIELDS, CRYPTO, FIAT, ORDER_TYPE }
module.exports = { loadConfig, DEFAULT_FIAT_MARKET, REQUIRED_CONFIG_FIELDS, CRYPTO, FIAT, ORDER_TYPE }

View file

@ -7,6 +7,7 @@ const ORDER_TYPE = ORDER_TYPES.MARKET
const { BTC, ETH, LTC, BCH, USDT, LN } = COINS
const CRYPTO = [BTC, ETH, LTC, BCH, USDT, LN]
const FIAT = ['USD', 'EUR']
const DEFAULT_FIAT_MARKET = 'EUR'
const AMOUNT_PRECISION = 8
const REQUIRED_CONFIG_FIELDS = ['key', 'secret']
@ -18,4 +19,4 @@ const loadConfig = (account) => {
return { ...mapped, timeout: 3000 }
}
module.exports = { loadConfig, REQUIRED_CONFIG_FIELDS, CRYPTO, FIAT, ORDER_TYPE, AMOUNT_PRECISION }
module.exports = { loadConfig, REQUIRED_CONFIG_FIELDS, DEFAULT_FIAT_MARKET, CRYPTO, FIAT, ORDER_TYPE, AMOUNT_PRECISION }

View file

@ -7,8 +7,9 @@ const ORDER_TYPE = ORDER_TYPES.MARKET
const { BTC, ETH, LTC, BCH, USDT, LN } = COINS
const CRYPTO = [BTC, ETH, LTC, BCH, USDT, LN]
const FIAT = ['USD', 'EUR']
const DEFAULT_FIAT_MARKET = 'EUR'
const AMOUNT_PRECISION = 8
const REQUIRED_CONFIG_FIELDS = ['key', 'secret', 'clientId']
const REQUIRED_CONFIG_FIELDS = ['key', 'secret', 'clientId', 'currencyMarket']
const loadConfig = (account) => {
const mapper = {
@ -19,4 +20,4 @@ const loadConfig = (account) => {
return { ...mapped, timeout: 3000 }
}
module.exports = { loadConfig, REQUIRED_CONFIG_FIELDS, CRYPTO, FIAT, ORDER_TYPE, AMOUNT_PRECISION }
module.exports = { loadConfig, DEFAULT_FIAT_MARKET, REQUIRED_CONFIG_FIELDS, CRYPTO, FIAT, ORDER_TYPE, AMOUNT_PRECISION }

View file

@ -1,9 +1,13 @@
const { utils: coinUtils } = require('@lamassu/coins')
const _ = require('lodash/fp')
const ccxt = require('ccxt')
const mem = require('mem')
const { buildMarket, ALL, isConfigValid } = require('../common/ccxt')
const { ORDER_TYPES } = require('./consts')
const logger = require('../../logger')
const { currencies } = require('../../new-admin/config')
const T = require('../../time')
const DEFAULT_PRICE_PRECISION = 2
const DEFAULT_AMOUNT_PRECISION = 8
@ -18,7 +22,8 @@ function trade (side, account, tradeEntry, exchangeName) {
const { USER_REF, loadOptions, loadConfig = _.noop, REQUIRED_CONFIG_FIELDS, ORDER_TYPE, AMOUNT_PRECISION } = exchangeConfig
if (!isConfigValid(account, REQUIRED_CONFIG_FIELDS)) throw Error('Invalid config')
const symbol = buildMarket(fiatCode, cryptoCode, exchangeName)
const selectedFiatMarket = account.currencyMarket
const symbol = buildMarket(selectedFiatMarket, cryptoCode, exchangeName)
const precision = _.defaultTo(DEFAULT_AMOUNT_PRECISION, AMOUNT_PRECISION)
const amount = coinUtils.toUnit(cryptoAtoms, cryptoCode).toFixed(precision)
const accountOptions = _.isFunction(loadOptions) ? loadOptions(account) : {}
@ -50,4 +55,38 @@ function calculatePrice (side, amount, orderBook) {
throw new Error('Insufficient market depth')
}
module.exports = { trade }
function _getMarkets (exchangeName, availableCryptos) {
const prunedCryptos = _.compose(_.uniq, _.map(coinUtils.getEquivalentCode))(availableCryptos)
try {
const exchange = new ccxt[exchangeName]()
const cryptosToQuoteAgainst = ['USDT']
const currencyCodes = _.concat(_.map(it => it.code, currencies), cryptosToQuoteAgainst)
return exchange.fetchMarkets()
.then(_.filter(it => (it.type === 'spot' || it.spot)))
.then(res =>
_.reduce((acc, value) => {
if (_.includes(value.base, prunedCryptos) && _.includes(value.quote, currencyCodes)) {
if (value.quote === value.base) return acc
if (_.isNil(acc[value.quote])) {
return { ...acc, [value.quote]: [value.base] }
}
acc[value.quote].push(value.base)
}
return acc
}, {}, res)
)
} catch (e) {
logger.debug(`No CCXT exchange found for ${exchangeName}`)
}
}
const getMarkets = mem(_getMarkets, {
maxAge: T.week,
cacheKey: (exchangeName, availableCryptos) => exchangeName
})
module.exports = { trade, getMarkets }

View file

@ -7,7 +7,8 @@ const ORDER_TYPE = ORDER_TYPES.MARKET
const { BTC, BCH, DASH, ETH, LTC, USDT, TRX, USDT_TRON, LN } = COINS
const CRYPTO = [BTC, ETH, LTC, DASH, BCH, USDT, TRX, USDT_TRON, LN]
const FIAT = ['USD', 'EUR']
const REQUIRED_CONFIG_FIELDS = ['apiKey', 'privateKey']
const DEFAULT_FIAT_MARKET = 'EUR'
const REQUIRED_CONFIG_FIELDS = ['apiKey', 'privateKey', 'currencyMarket']
const loadConfig = (account) => {
const mapper = {
@ -17,4 +18,4 @@ const loadConfig = (account) => {
return { ...mapped, timeout: 3000 }
}
module.exports = { loadConfig, REQUIRED_CONFIG_FIELDS, CRYPTO, FIAT, ORDER_TYPE }
module.exports = { loadConfig, DEFAULT_FIAT_MARKET, REQUIRED_CONFIG_FIELDS, CRYPTO, FIAT, ORDER_TYPE }

View file

@ -7,8 +7,9 @@ const ORDER_TYPE = ORDER_TYPES.LIMIT
const { BTC, ETH, USDT, LN } = COINS
const CRYPTO = [BTC, ETH, USDT, LN]
const FIAT = ['USD']
const DEFAULT_FIAT_MARKET = 'USD'
const AMOUNT_PRECISION = 4
const REQUIRED_CONFIG_FIELDS = ['clientKey', 'clientSecret', 'userId', 'walletId']
const REQUIRED_CONFIG_FIELDS = ['clientKey', 'clientSecret', 'userId', 'walletId', 'currencyMarket']
const loadConfig = (account) => {
const mapper = {
@ -21,4 +22,4 @@ const loadConfig = (account) => {
}
const loadOptions = ({ walletId }) => ({ walletId })
module.exports = { loadOptions, loadConfig, REQUIRED_CONFIG_FIELDS, CRYPTO, FIAT, ORDER_TYPE, AMOUNT_PRECISION }
module.exports = { loadOptions, loadConfig, DEFAULT_FIAT_MARKET, REQUIRED_CONFIG_FIELDS, CRYPTO, FIAT, ORDER_TYPE, AMOUNT_PRECISION }

View file

@ -7,8 +7,9 @@ const ORDER_TYPE = ORDER_TYPES.MARKET
const { BTC, BCH, DASH, ETH, LTC, ZEC, XMR, USDT, TRX, USDT_TRON, LN } = COINS
const CRYPTO = [BTC, ETH, LTC, DASH, ZEC, BCH, XMR, USDT, TRX, USDT_TRON, LN]
const FIAT = ['USD', 'EUR']
const DEFAULT_FIAT_MARKET = 'EUR'
const AMOUNT_PRECISION = 6
const REQUIRED_CONFIG_FIELDS = ['apiKey', 'privateKey']
const REQUIRED_CONFIG_FIELDS = ['apiKey', 'privateKey', 'currencyMarket']
const USER_REF = 'userref'
const loadConfig = (account) => {
@ -26,4 +27,4 @@ const loadConfig = (account) => {
const loadOptions = () => ({ expiretm: '+60' })
module.exports = { USER_REF, loadOptions, loadConfig, REQUIRED_CONFIG_FIELDS, CRYPTO, FIAT, ORDER_TYPE, AMOUNT_PRECISION }
module.exports = { USER_REF, loadOptions, loadConfig, DEFAULT_FIAT_MARKET, REQUIRED_CONFIG_FIELDS, CRYPTO, FIAT, ORDER_TYPE, AMOUNT_PRECISION }

View file

@ -1,7 +1,7 @@
const ccxt = require('ccxt')
const BN = require('../../bn')
const { buildMarket, verifyFiatSupport } = require('../common/ccxt')
const { buildMarket, verifyFiatSupport, defaultFiatMarket } = require('../common/ccxt')
const { getRate } = require('../../../lib/forex')
const RETRIES = 2
@ -33,7 +33,7 @@ function ticker (fiatCode, cryptoCode, tickerName) {
return getRate(RETRIES, fiatCode)
.then(({ fxRate }) => {
try {
return getCurrencyRates(ticker, 'USD', cryptoCode)
return getCurrencyRates(ticker, defaultFiatMarket(tickerName), cryptoCode)
.then(res => ({
rates: {
ask: res.rates.ask.times(fxRate),

View file

@ -7,10 +7,11 @@ const nocache = require('nocache')
const logger = require('./logger')
const addRWBytes = require('./middlewares/addRWBytes')
const authorize = require('./middlewares/authorize')
const computeSchema = require('./middlewares/compute-schema')
const errorHandler = require('./middlewares/errorHandler')
const filterOldRequests = require('./middlewares/filterOldRequests')
const computeSchema = require('./middlewares/compute-schema')
const findOperatorId = require('./middlewares/operatorId')
const populateDeviceId = require('./middlewares/populateDeviceId')
const populateSettings = require('./middlewares/populateSettings')
@ -50,11 +51,24 @@ const configRequiredRoutes = [
]
// middleware setup
app.use(addRWBytes())
app.use(compression({ threshold: 500 }))
app.use(helmet())
app.use(nocache())
app.use(express.json({ limit: '2mb' }))
app.use(morgan(':method :url :status :response-time ms -- :req[content-length]/:res[content-length] b', { stream: logger.stream }))
morgan.token('bytesRead', (_req, res) => res.bytesRead)
morgan.token('bytesWritten', (_req, res) => res.bytesWritten)
app.use(morgan(':method :url :status :response-time ms -- :bytesRead/:bytesWritten B', { stream: logger.stream }))
app.use('/robots.txt', (req, res) => {
res.type('text/plain')
res.send("User-agent: *\nDisallow: /")
})
app.get('/', (req, res) => {
res.sendStatus(404)
})
// app /pair and /ca routes
app.use('/', pairingRoutes)

View file

@ -1,40 +1,41 @@
const express = require('express')
const _ = require('lodash/fp')
const router = express.Router()
const cashbox = require('../cashbox-batches')
const notifier = require('../notifier')
const { getMachine, setMachine } = require('../machine-loader')
const { getMachine, setMachine, getMachineName } = require('../machine-loader')
const { loadLatestConfig } = require('../new-settings-loader')
const { getCashInSettings } = require('../new-config-manager')
const { AUTOMATIC } = require('../constants')
const logger = require('../logger')
function notifyCashboxRemoval (req, res, next) {
function cashboxRemoval (req, res, next) {
const operatorId = res.locals.operatorId
logger.info(`** DEBUG ** - Cashbox removal - Received a cashbox opening request from device ${req.deviceId}`)
notifier.cashboxNotify(req.deviceId).catch(logger.error)
return notifier.cashboxNotify(req.deviceId)
.then(() => Promise.all([getMachine(req.deviceId), loadLatestConfig()]))
return Promise.all([getMachine(req.deviceId), loadLatestConfig()])
.then(([machine, config]) => {
logger.info('** DEBUG ** - Cashbox removal - Retrieving system options for cash-in')
const cashInSettings = getCashInSettings(config)
if (cashInSettings.cashboxReset !== AUTOMATIC) {
logger.info('** DEBUG ** - Cashbox removal - Cashbox reset is set to manual. A cashbox batch will NOT be created')
logger.info(`** DEBUG ** - Cashbox removal - Process finished`)
return res.status(200).send({ status: 'OK' })
return Promise.all([
cashbox.getMachineUnbatchedBills(req.deviceId),
getMachineName(req.deviceId)
])
}
logger.info('** DEBUG ** - Cashbox removal - Cashbox reset is set to automatic. A cashbox batch WILL be created')
logger.info('** DEBUG ** - Cashbox removal - Creating new batch...')
return cashbox.createCashboxBatch(req.deviceId, machine.cashUnits.cashbox)
.then(() => {
logger.info(`** DEBUG ** - Cashbox removal - Process finished`)
return res.status(200).send({ status: 'OK' })
})
return cashbox.createCashboxBatch(req.deviceId, machine.cashbox)
.then(batch => Promise.all([
cashbox.getBatchById(batch.id),
getMachineName(batch.device_id),
setMachine({ deviceId: req.deviceId, action: 'emptyCashInBills' }, operatorId)
]))
})
.then(([batch, machineName]) => res.status(200).send({ batch: _.merge(batch, { machineName }), status: 'OK' }))
.catch(next)
}
router.post('/removal', notifyCashboxRemoval)
router.post('/removal', cashboxRemoval)
module.exports = router

View file

@ -108,7 +108,7 @@ function updateCustomer (req, res, next) {
.then(_.merge(patch))
.then(newPatch => customers.updatePhotoCard(id, newPatch))
.then(newPatch => customers.updateFrontCamera(id, newPatch))
.then(newPatch => customers.update(id, newPatch, null, txId))
.then(newPatch => customers.update(id, newPatch, null))
.then(customer => {
createPendingManualComplianceNotifs(settings, customer, deviceId)
respond(req, res, { customer })

View file

@ -114,6 +114,7 @@ function poll (req, res, next) {
locale,
version,
receiptPrintingActive: receipt.active,
automaticReceiptPrint: receipt.automaticPrint,
smsReceiptActive: receipt.sms,
enablePaperWalletOnly,
twoWayMode: cashOutConfig.active,

44
lib/sanctions.js Normal file
View file

@ -0,0 +1,44 @@
const _ = require('lodash/fp')
const ofac = require('./ofac')
const T = require('./time')
const logger = require('./logger')
const customers = require('./customers')
const sanctionStatus = {
loaded: false,
timestamp: null
}
const loadOrUpdateSanctions = () => {
if (!sanctionStatus.loaded || (sanctionStatus.timestamp && Date.now() > sanctionStatus.timestamp + T.day)) {
logger.info('No sanction lists loaded. Loading sanctions...')
return ofac.load()
.then(() => {
logger.info('OFAC sanction list loaded!')
sanctionStatus.loaded = true
sanctionStatus.timestamp = Date.now()
})
.catch(e => {
logger.error('Couldn\'t load OFAC sanction list!')
})
}
return Promise.resolve()
}
const checkByUser = (customerId, userToken) => {
return Promise.all([loadOrUpdateSanctions(), customers.getCustomerById(customerId)])
.then(([, customer]) => {
const { firstName, lastName, dateOfBirth } = customer?.idCardData
const birthdate = _.replace(/-/g, '')(dateOfBirth)
const ofacMatches = ofac.match({ firstName, lastName }, birthdate, { threshold: 0.85, fullNameThreshold: 0.95, debug: false })
const isOfacSanctioned = _.size(ofacMatches) > 0
customers.updateCustomer(customerId, { sanctions: !isOfacSanctioned }, userToken)
return { ofacSanctioned: isOfacSanctioned }
})
}
module.exports = {
checkByUser
}

View file

@ -40,6 +40,7 @@ function massage (tx, pi) {
: {
cryptoAtoms: new BN(r.cryptoAtoms),
fiat: new BN(r.fiat),
fixedFee: r.cashOutFee ? new BN(r.cashOutFee) : null,
rawTickerPrice: r.rawTickerPrice ? new BN(r.rawTickerPrice) : null,
commissionPercentage: new BN(r.commissionPercentage)
}
@ -50,7 +51,9 @@ function massage (tx, pi) {
const mapper = _.flow(
transformDates,
mapBN,
_.unset('dirty'))
_.unset('dirty'),
_.unset('cashOutFee')
)
return mapper(tx)
}
@ -69,7 +72,7 @@ function cancel (txId) {
}
function customerHistory (customerId, thresholdDays) {
const sql = `SELECT * FROM (
const sql = `SELECT ch.id, ch.created, ch.fiat, ch.direction FROM (
SELECT txIn.id, txIn.created, txIn.fiat, 'cashIn' AS direction,
((NOT txIn.send_confirmed) AND (txIn.created <= now() - interval $3)) AS expired
FROM cash_in_txs txIn

View file

@ -0,0 +1,7 @@
const db = require('./db')
exports.up = next => db.multi([
'ALTER TABLE cash_out_txs ADD COLUMN fixed_fee numeric(14, 5) NOT NULL DEFAULT 0;'
], next)
exports.down = next => next()

View file

@ -0,0 +1,7 @@
const { saveConfig } = require('../lib/new-settings-loader')
exports.up = next => saveConfig({ 'commissions_cashOutFixedFee': 0 })
.then(next)
.catch(next)
exports.down = next => next()

View file

@ -0,0 +1,30 @@
const _ = require('lodash/fp')
const { loadLatest, saveAccounts } = require('../lib/new-settings-loader')
const { ACCOUNT_LIST } = require('../lib/new-admin/config/accounts')
const { ALL } = require('../lib/plugins/common/ccxt')
exports.up = function (next) {
return loadLatest()
.then(({ accounts }) => {
const allExchanges = _.map(it => it.code)(_.filter(it => it.class === 'exchange', ACCOUNT_LIST))
const configuredExchanges = _.intersection(allExchanges, _.keys(accounts))
const newAccounts = _.reduce(
(acc, value) => {
if (!_.isNil(accounts[value].currencyMarket)) return acc
if (_.includes('EUR', ALL[value].FIAT)) return { ...acc, [value]: { currencyMarket: 'EUR' } }
return { ...acc, [value]: { currencyMarket: ALL[value].DEFAULT_FIAT_CURRENCY } }
},
{},
configuredExchanges
)
return saveAccounts(newAccounts)
})
.then(next)
.catch(next)
}
module.exports.down = function (next) {
next()
}

View file

@ -0,0 +1,18 @@
var db = require('./db')
exports.up = function (next) {
var sql = [
`CREATE TABLE blacklist_temp (
address TEXT NOT NULL UNIQUE
)`,
`INSERT INTO blacklist_temp (address) SELECT DISTINCT address FROM blacklist`,
`DROP TABLE blacklist`,
`ALTER TABLE blacklist_temp RENAME TO blacklist`
]
db.multi(sql, next)
}
exports.down = function (next) {
next()
}

View file

@ -0,0 +1,24 @@
const uuid = require('uuid')
var db = require('./db')
exports.up = function (next) {
const defaultMessageId = uuid.v4()
var sql = [
`CREATE TABLE blacklist_messages (
id UUID PRIMARY KEY,
label TEXT NOT NULL,
content TEXT NOT NULL,
allow_toggle BOOLEAN NOT NULL DEFAULT true
)`,
`INSERT INTO blacklist_messages (id, label, content, allow_toggle) VALUES ('${defaultMessageId}', 'Suspicious address', 'This address may be associated with a deceptive offer or a prohibited group. Please make sure you''re using an address from your own wallet.', false)`,
`ALTER TABLE blacklist ADD COLUMN blacklist_message_id UUID REFERENCES blacklist_messages(id) NOT NULL DEFAULT '${defaultMessageId}'`
]
db.multi(sql, next)
}
exports.down = function (next) {
next()
}

View file

@ -0,0 +1,20 @@
const _ = require('lodash/fp')
const { saveConfig, loadLatest } = require('../lib/new-settings-loader')
exports.up = function (next) {
const newConfig = {}
return loadLatest()
.then(({ config }) => {
if (!_.isNil(config.machineScreens_rates_active)) return
newConfig[`machineScreens_rates_active`] = true
return saveConfig(newConfig)
})
.then(next)
.catch(err => {
return next(err)
})
}
module.exports.down = function (next) {
next()
}

View file

@ -2,4 +2,3 @@ SKIP_PREFLIGHT_CHECK=true
HTTPS=true
REACT_APP_TYPE_CHECK_SANCTUARY=false
PORT=3001
REACT_APP_BUILD_TARGET=LAMASSU

View file

@ -1,29 +0,0 @@
module.exports = {
extends: ['react-app', 'prettier-standard', 'prettier/react'],
plugins: ['import'],
settings: {
'import/resolver': {
alias: [['src', './src']]
}
},
rules: {
'import/no-anonymous-default-export': [2, { allowObject: true }],
'import/order': [
'error',
{
groups: [
'builtin',
'external',
'internal',
'parent',
'sibling',
'index'
],
alphabetize: {
order: 'asc'
},
'newlines-between': 'always'
}
]
}
}

View file

@ -0,0 +1,8 @@
{
"trailingComma": "none",
"tabWidth": 2,
"semi": false,
"singleQuote": true,
"arrowParens": "avoid",
"bracketSameLine": true
}

View file

@ -1,10 +0,0 @@
module.exports = {
stories: ['../src/stories/index.js'],
addons: [
'@storybook/addon-actions',
'@storybook/addon-links',
'@storybook/addon-knobs',
'@storybook/addon-backgrounds',
'@storybook/preset-create-react-app'
]
}

View file

@ -0,0 +1 @@
nodejs 22

View file

@ -1,5 +1,3 @@
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
## Dev Environment
### formatting
@ -10,16 +8,6 @@ The configuration for vscode is already on the repo, all you need to do is insta
This project has a husky pre commit hook to format the staged changes using our styleguide.
To take advantage of that make sure to run `git commit` from within this folder.
### Sanctuary
Sanctuary has a runtime typechecker that can make be quite slow, but its turned off by default.
To turn it on add the following line to a `.env.local` file.
```
REACT_APP_TYPE_CHECK_SANCTUARY=true
```
## Available Scripts
In the project directory, you can run:
@ -36,10 +24,6 @@ You will also see any lint errors in the console.
Runs eslint --fix on the src folder
### `npm storybook`
Runs the storybook server
### `npm test`
Launches the test runner in the interactive watch mode.<br>

View file

@ -0,0 +1,39 @@
import globals from 'globals'
import pluginJs from '@eslint/js'
import pluginReact from 'eslint-plugin-react'
import reactCompiler from 'eslint-plugin-react-compiler'
import eslintConfigPrettier from 'eslint-config-prettier'
/** @type {import('eslint').Linter.Config[]} */
export default [
{
files: ['**/*.{js,mjs,cjs,jsx}'],
languageOptions: {
...pluginReact.configs.flat.recommended.languageOptions,
globals: {
...globals.browser,
process: 'readonly'
}
},
settings: {
react: {
version: '16'
}
},
plugins: {
'react-compiler': reactCompiler
}
},
pluginJs.configs.recommended,
pluginReact.configs.flat.recommended,
{
rules: {
'no-unused-vars': 'off',
'react/prop-types': 'off',
'react/display-name': 'off',
'react/no-unescaped-entities': 'off',
'react-compiler/react-compiler': 'warn'
}
},
eslintConfigPrettier
]

View file

@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="shortcut icon" href="/favicon.ico" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, shrink-to-fit=no"
/>
<meta name="robots" content="noindex"/>
<meta name="theme-color" content="#000000" />
<link rel="manifest" href="/manifest.json" />
<title>Lamassu Admin</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root" class="root"></div>
<script type="module" src="/src/index.jsx"></script>
</body>
</html>

File diff suppressed because it is too large Load diff

View file

@ -2,12 +2,13 @@
"name": "lamassu-admin",
"version": "0.2.1",
"license": "../LICENSE",
"type": "module",
"dependencies": {
"@apollo/react-hooks": "^3.1.3",
"@lamassu/coins": "v1.4.0-beta.4",
"@material-ui/core": "4.11.0",
"@material-ui/icons": "4.9.1",
"@material-ui/lab": "^4.0.0-alpha.56",
"@lamassu/coins": "v1.5.3",
"@material-ui/core": "4.12.4",
"@material-ui/icons": "4.11.2",
"@material-ui/lab": "^4.0.0-alpha.61",
"@simplewebauthn/browser": "^3.0.0",
"@use-hooks/axios": "1.3.0",
"apollo-cache-inmemory": "^1.6.6",
@ -27,12 +28,11 @@
"downshift": "3.3.4",
"file-saver": "2.0.2",
"formik": "2.2.0",
"google-libphonenumber": "^3.2.22",
"graphql": "^14.5.8",
"graphql-tag": "^2.10.3",
"graphql-tag": "^2.12.6",
"jss-plugin-extend": "^10.0.0",
"jszip": "^3.6.0",
"libphonenumber-js": "^1.7.50",
"libphonenumber-js": "^1.11.15",
"match-sorter": "^4.2.0",
"pretty-ms": "^2.1.0",
"qrcode.react": "0.9.3",
@ -41,69 +41,34 @@
"react-copy-to-clipboard": "^5.0.2",
"react-dom": "^16.10.2",
"react-dropzone": "^11.4.2",
"react-material-ui-carousel": "^2.2.7",
"react-material-ui-carousel": "^2.3.11",
"react-number-format": "^4.4.1",
"react-otp-input": "^2.3.0",
"react-router-dom": "5.1.2",
"react-use": "15.3.2",
"react-virtualized": "^9.21.2",
"sanctuary": "^2.0.1",
"ua-parser-js": "^1.0.2",
"uuid": "^8.3.2",
"yup": "0.32.9"
"yup": "1.4.0"
},
"devDependencies": {
"@storybook/addon-actions": "6.0.26",
"@storybook/addon-backgrounds": "6.0.26",
"@storybook/addon-knobs": "6.0.26",
"@storybook/addon-links": "6.0.26",
"@storybook/addons": "6.0.26",
"@storybook/preset-create-react-app": "^3.1.4",
"@storybook/react": "6.0.26",
"@welldone-software/why-did-you-render": "^3.3.9",
"eslint": "^7.19.0",
"eslint-config-prettier": "^6.7.0",
"eslint-config-prettier-standard": "^3.0.1",
"eslint-config-standard": "^14.1.0",
"eslint-import-resolver-alias": "^1.1.2",
"eslint-plugin-import": "^2.22.1",
"eslint-plugin-node": "^10.0.0",
"eslint-plugin-prettier": "^3.1.2",
"eslint-plugin-promise": "^4.2.1",
"eslint-plugin-react": "^7.20.3",
"eslint-plugin-standard": "^4.0.1",
"husky": "^3.1.0",
"lint-staged": "^9.5.0",
"patch-package": "^6.2.2",
"prettier": "1.19.1",
"prettier-config-standard": "^1.0.1",
"react-scripts": "4.0.0",
"serve": "^11.3.2",
"source-map-explorer": "^2.4.2"
},
"husky": {
"hooks": {
"pre-commit": "lint-staged"
}
},
"lint-staged": {
"src/**/*.{js,jsx,ts,tsx,json,css,scss,md}": [
"eslint --fix",
"git add"
]
"@eslint/js": "^9.16.0",
"@vitejs/plugin-react-swc": "^3.7.2",
"esbuild-plugin-react-virtualized": "^1.0.4",
"eslint": "^9.16.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-react": "^7.37.2",
"eslint-plugin-react-compiler": "^19.0.0-beta-df7b47d-20241124",
"globals": "^15.13.0",
"lint-staged": "^15.2.10",
"prettier": "3.4.1",
"vite": "^6.0.1",
"vite-plugin-svgr": "^4.3.0"
},
"scripts": {
"start": "react-scripts start",
"fix": "eslint --fix --ext .js,.md,.json src/",
"build": "react-scripts build",
"analyze": "source-map-explorer 'build/static/js/*.js'",
"test": "react-scripts test",
"eject": "react-scripts eject",
"storybook": "start-storybook -p 9009 -s public",
"postinstall": "patch-package",
"build-storybook": "build-storybook -s public",
"lamassu": "REACT_APP_BUILD_TARGET=LAMASSU react-scripts start",
"pazuz": "REACT_APP_BUILD_TARGET=PAZUZ react-scripts start"
"start": "vite",
"build": "vite build",
"preview": "vite preview"
},
"browserslist": {
"production": [
@ -116,5 +81,9 @@
"last 1 firefox version",
"last 1 safari version"
]
},
"lint-staged": {
"*.{js,jsx,md,json}": "eslint --cache --fix",
"*.{js,jsx,css,md,json}": "prettier --write"
}
}

View file

@ -1,42 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, shrink-to-fit=no"
/>
<meta name="robots" content="noindex"/>
<meta name="theme-color" content="#000000" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>Lamassu Admin</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root" class="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

View file

@ -0,0 +1,2 @@
User-agent: *
Disallow: /

View file

@ -17,21 +17,16 @@ import {
useHistory,
BrowserRouter as Router
} from 'react-router-dom'
import AppContext from 'src/AppContext'
import Header from 'src/components/layout/Header'
import Sidebar from 'src/components/layout/Sidebar'
import TitleSection from 'src/components/layout/TitleSection'
import { tree, hasSidebar, Routes, getParent } from 'src/routing/routes'
import ApolloProvider from 'src/utils/apollo'
import AppContext from 'src/AppContext'
import global from 'src/styling/global'
import theme from 'src/styling/theme'
import { backgroundColor, mainWidth } from 'src/styling/variables'
import ApolloProvider from 'src/utils/apollo'
if (process.env.NODE_ENV !== 'production') {
const whyDidYouRender = require('@welldone-software/why-did-you-render')
whyDidYouRender(React)
}
const jss = create({
plugins: [extendJss(), ...jssPreset().plugins]
@ -120,17 +115,11 @@ const Main = () => {
)}
<main className={classes.wrapper}>
{sidebar && !is404 && wizardTested && (
<Slide
direction="left"
in={true}
mountOnEnter
unmountOnExit
children={
<Slide direction="left" in={true} mountOnEnter unmountOnExit>
<div>
<TitleSection title={parent.title}></TitleSection>
</div>
}
/>
</Slide>
)}
<Grid container className={classes.grid}>

View file

@ -1,10 +1,8 @@
import { makeStyles } from '@material-ui/core/styles'
import React, { memo } from 'react'
import ReactCarousel from 'react-material-ui-carousel'
import { ReactComponent as LeftArrow } from 'src/styling/icons/arrow/carousel-left-arrow.svg'
import { ReactComponent as RightArrow } from 'src/styling/icons/arrow/carousel-right-arrow.svg'
import { URI } from 'src/utils/apollo'
import LeftArrow from 'src/styling/icons/arrow/carousel-left-arrow.svg?react'
import RightArrow from 'src/styling/icons/arrow/carousel-right-arrow.svg?react'
const useStyles = makeStyles({
imgWrapper: {
@ -13,9 +11,10 @@ const useStyles = makeStyles({
display: 'flex'
},
imgInner: {
objectFit: 'cover',
objectFit: 'contain',
objectPosition: 'center',
width: 500,
height: 400,
marginBottom: 40
}
})
@ -48,11 +47,11 @@ export const Carousel = memo(({ photosData, slidePhoto }) => {
next={activeIndex => slidePhoto(activeIndex)}
prev={activeIndex => slidePhoto(activeIndex)}>
{photosData.map((item, i) => (
<div>
<div key={i}>
<div className={classes.imgWrapper}>
<img
className={classes.imgInner}
src={`${URI}/${item?.photoDir}/${item?.path}`}
src={`/${item?.photoDir}/${item?.path}`}
alt=""
/>
</div>

View file

@ -6,11 +6,11 @@ import {
InputLabel
} from '@material-ui/core'
import React, { memo, useState } from 'react'
import { H4, P } from 'src/components/typography'
import CloseIcon from 'src/styling/icons/action/close/zodiac.svg?react'
import { Button, IconButton } from 'src/components/buttons'
import { TextInput } from 'src/components/inputs'
import { H4, P } from 'src/components/typography'
import { ReactComponent as CloseIcon } from 'src/styling/icons/action/close/zodiac.svg'
import { spacer } from 'src/styling/variables'
import ErrorMessage from './ErrorMessage'

View file

@ -5,10 +5,10 @@ import {
makeStyles
} from '@material-ui/core'
import React from 'react'
import { H4, P } from 'src/components/typography'
import CloseIcon from 'src/styling/icons/action/close/zodiac.svg?react'
import { Button, IconButton } from 'src/components/buttons'
import { H4, P } from 'src/components/typography'
import { ReactComponent as CloseIcon } from 'src/styling/icons/action/close/zodiac.svg'
import { spacer } from 'src/styling/variables'
import ErrorMessage from './ErrorMessage'

View file

@ -1,8 +1,8 @@
import { makeStyles } from '@material-ui/core'
import classnames from 'classnames'
import React from 'react'
import ErrorIcon from 'src/styling/icons/warning-icon/tomato.svg?react'
import { ReactComponent as ErrorIcon } from 'src/styling/icons/warning-icon/tomato.svg'
import { errorColor } from 'src/styling/variables'
import { Info3 } from './typography'

View file

@ -1,11 +1,11 @@
import { makeStyles, ClickAwayListener } from '@material-ui/core'
import classnames from 'classnames'
import React, { memo, useState } from 'react'
import Popper from 'src/components/Popper'
import ZoomIconInverse from 'src/styling/icons/circle buttons/search/white.svg?react'
import ZoomIcon from 'src/styling/icons/circle buttons/search/zodiac.svg?react'
import { FeatureButton } from 'src/components/buttons'
import { ReactComponent as ZoomIconInverse } from 'src/styling/icons/circle buttons/search/white.svg'
import { ReactComponent as ZoomIcon } from 'src/styling/icons/circle buttons/search/zodiac.svg'
import imagePopperStyles from './ImagePopper.styles'

View file

@ -1,8 +1,7 @@
import { Box, makeStyles } from '@material-ui/core'
import React from 'react'
import { Label1 } from 'src/components/typography'
import { ReactComponent as WarningIcon } from 'src/styling/icons/warning-icon/comet.svg'
import WarningIcon from 'src/styling/icons/warning-icon/comet.svg?react'
const useStyles = makeStyles({
message: ({ width }) => ({

View file

@ -1,9 +1,9 @@
import { Dialog, DialogContent, makeStyles } from '@material-ui/core'
import React, { memo } from 'react'
import { H1 } from 'src/components/typography'
import CloseIcon from 'src/styling/icons/action/close/zodiac.svg?react'
import { IconButton } from 'src/components/buttons'
import { H1 } from 'src/components/typography'
import { ReactComponent as CloseIcon } from 'src/styling/icons/action/close/zodiac.svg'
import { spacer } from 'src/styling/variables'
const useStyles = makeStyles({

View file

@ -5,11 +5,11 @@ import { format, set } from 'date-fns/fp'
import FileSaver from 'file-saver'
import * as R from 'ramda'
import React, { useState, useCallback } from 'react'
import Arrow from 'src/styling/icons/arrow/download_logs.svg?react'
import DownloadInverseIcon from 'src/styling/icons/button/download/white.svg?react'
import Download from 'src/styling/icons/button/download/zodiac.svg?react'
import { FeatureButton, Link } from 'src/components/buttons'
import { ReactComponent as Arrow } from 'src/styling/icons/arrow/download_logs.svg'
import { ReactComponent as DownloadInverseIcon } from 'src/styling/icons/button/download/white.svg'
import { ReactComponent as Download } from 'src/styling/icons/button/download/zodiac.svg'
import { primaryColor, offColor, zircon } from 'src/styling/variables'
import { formatDate } from 'src/utils/timezones'

View file

@ -1,10 +1,10 @@
import { makeStyles, Modal as MaterialModal, Paper } from '@material-ui/core'
import classnames from 'classnames'
import React from 'react'
import { H1, H4 } from 'src/components/typography'
import CloseIcon from 'src/styling/icons/action/close/zodiac.svg?react'
import { IconButton } from 'src/components/buttons'
import { H1, H4 } from 'src/components/typography'
import { ReactComponent as CloseIcon } from 'src/styling/icons/action/close/zodiac.svg'
const styles = {
modal: {

View file

@ -3,13 +3,12 @@ import { makeStyles } from '@material-ui/core/styles'
import gql from 'graphql-tag'
import * as R from 'ramda'
import React, { useState, useEffect } from 'react'
import ActionButton from 'src/components/buttons/ActionButton'
import { H5 } from 'src/components/typography'
import { ReactComponent as NotificationIconZodiac } from 'src/styling/icons/menu/notification-zodiac.svg'
import { ReactComponent as ClearAllIconInverse } from 'src/styling/icons/stage/spring/empty.svg'
import { ReactComponent as ClearAllIcon } from 'src/styling/icons/stage/zodiac/empty.svg'
import { ReactComponent as ShowUnreadIcon } from 'src/styling/icons/stage/zodiac/full.svg'
import NotificationIconZodiac from 'src/styling/icons/menu/notification-zodiac.svg?react'
import ClearAllIconInverse from 'src/styling/icons/stage/spring/empty.svg?react'
import ClearAllIcon from 'src/styling/icons/stage/zodiac/empty.svg?react'
import ShowUnreadIcon from 'src/styling/icons/stage/zodiac/full.svg?react'
import styles from './NotificationCenter.styles'
import NotificationRow from './NotificationRow'

View file

@ -3,11 +3,10 @@ import classnames from 'classnames'
import prettyMs from 'pretty-ms'
import * as R from 'ramda'
import React from 'react'
import { Label1, Label2, TL2 } from 'src/components/typography'
import { ReactComponent as Wrench } from 'src/styling/icons/action/wrench/zodiac.svg'
import { ReactComponent as Transaction } from 'src/styling/icons/arrow/transaction.svg'
import { ReactComponent as WarningIcon } from 'src/styling/icons/warning-icon/tomato.svg'
import Wrench from 'src/styling/icons/action/wrench/zodiac.svg?react'
import Transaction from 'src/styling/icons/arrow/transaction.svg?react'
import WarningIcon from 'src/styling/icons/warning-icon/tomato.svg?react'
import styles from './NotificationCenter.styles'
const useStyles = makeStyles(styles)

View file

@ -105,20 +105,29 @@ const Popover = ({
const classes = useStyles()
const arrowClasses = {
const getArrowClasses = placement => ({
[classes.arrow]: true,
[classes.arrowBottom]: props.placement === 'bottom',
[classes.arrowTop]: props.placement === 'top',
[classes.arrowRight]: props.placement === 'right',
[classes.arrowLeft]: props.placement === 'left'
[classes.arrowBottom]: placement === 'bottom',
[classes.arrowTop]: placement === 'top',
[classes.arrowRight]: placement === 'right',
[classes.arrowLeft]: placement === 'left'
})
const flipPlacements = {
top: ['bottom'],
bottom: ['top'],
left: ['right'],
right: ['left']
}
const modifiers = R.merge(props.modifiers, {
const modifiers = R.mergeDeepLeft(props.modifiers, {
flip: {
enabled: false
enabled: R.defaultTo(false, props.flip),
allowedAutoPlacements: flipPlacements[props.placement],
boundary: 'clippingParents'
},
preventOverflow: {
enabled: true,
enabled: R.defaultTo(true, props.preventOverflow),
boundariesElement: 'scrollParent'
},
offset: {
@ -126,7 +135,7 @@ const Popover = ({
offset: '0, 10'
},
arrow: {
enabled: true,
enabled: R.defaultTo(true, props.showArrow),
element: arrowRef
},
computeStyle: {
@ -134,6 +143,12 @@ const Popover = ({
}
})
if (props.preventOverflow === false) {
modifiers.hide = {
enabled: false
}
}
return (
<>
<MaterialPopper
@ -141,10 +156,15 @@ const Popover = ({
modifiers={modifiers}
className={classes.popover}
{...props}>
{({ placement }) => (
<Paper className={classnames(classes.root, className)}>
<span className={classnames(arrowClasses)} ref={setArrowRef} />
<span
className={classnames(getArrowClasses(placement))}
ref={setArrowRef}
/>
{children}
</Paper>
)}
</MaterialPopper>
</>
)

View file

@ -4,9 +4,8 @@ import { makeStyles } from '@material-ui/core/styles'
import MAutocomplete from '@material-ui/lab/Autocomplete'
import classnames from 'classnames'
import React, { memo, useState } from 'react'
import { P } from 'src/components/typography'
import { ReactComponent as SearchIcon } from 'src/styling/icons/circle buttons/search/zodiac.svg'
import SearchIcon from 'src/styling/icons/circle buttons/search/zodiac.svg?react'
import styles from './SearchBox.styles'

View file

@ -1,12 +1,12 @@
import { makeStyles } from '@material-ui/core'
import React from 'react'
import Chip from 'src/components/Chip'
import { ActionButton } from 'src/components/buttons'
import { P, Label3 } from 'src/components/typography'
import { ReactComponent as CloseIcon } from 'src/styling/icons/action/close/zodiac.svg'
import { ReactComponent as FilterIcon } from 'src/styling/icons/button/filter/white.svg'
import { ReactComponent as ReverseFilterIcon } from 'src/styling/icons/button/filter/zodiac.svg'
import CloseIcon from 'src/styling/icons/action/close/zodiac.svg?react'
import FilterIcon from 'src/styling/icons/button/filter/white.svg?react'
import ReverseFilterIcon from 'src/styling/icons/button/filter/zodiac.svg?react'
import { ActionButton } from 'src/components/buttons'
import { onlyFirstToUpper, singularOrPlural } from 'src/utils/string'
import { chipStyles, styles } from './SearchFilter.styles'

View file

@ -2,13 +2,13 @@ import { makeStyles } from '@material-ui/core'
import classnames from 'classnames'
import * as R from 'ramda'
import React, { memo } from 'react'
import CompleteStageIconSpring from 'src/styling/icons/stage/spring/complete.svg?react'
import CurrentStageIconSpring from 'src/styling/icons/stage/spring/current.svg?react'
import EmptyStageIconSpring from 'src/styling/icons/stage/spring/empty.svg?react'
import CompleteStageIconZodiac from 'src/styling/icons/stage/zodiac/complete.svg?react'
import CurrentStageIconZodiac from 'src/styling/icons/stage/zodiac/current.svg?react'
import EmptyStageIconZodiac from 'src/styling/icons/stage/zodiac/empty.svg?react'
import { ReactComponent as CompleteStageIconSpring } from 'src/styling/icons/stage/spring/complete.svg'
import { ReactComponent as CurrentStageIconSpring } from 'src/styling/icons/stage/spring/current.svg'
import { ReactComponent as EmptyStageIconSpring } from 'src/styling/icons/stage/spring/empty.svg'
import { ReactComponent as CompleteStageIconZodiac } from 'src/styling/icons/stage/zodiac/complete.svg'
import { ReactComponent as CurrentStageIconZodiac } from 'src/styling/icons/stage/zodiac/current.svg'
import { ReactComponent as EmptyStageIconZodiac } from 'src/styling/icons/stage/zodiac/empty.svg'
import {
primaryColor,
secondaryColor,

View file

@ -2,13 +2,13 @@ import { makeStyles } from '@material-ui/core'
import classnames from 'classnames'
import * as R from 'ramda'
import React, { memo } from 'react'
import CompleteStageIconSpring from 'src/styling/icons/stage/spring/complete.svg?react'
import CurrentStageIconSpring from 'src/styling/icons/stage/spring/current.svg?react'
import EmptyStageIconSpring from 'src/styling/icons/stage/spring/empty.svg?react'
import CompleteStageIconZodiac from 'src/styling/icons/stage/zodiac/complete.svg?react'
import CurrentStageIconZodiac from 'src/styling/icons/stage/zodiac/current.svg?react'
import EmptyStageIconZodiac from 'src/styling/icons/stage/zodiac/empty.svg?react'
import { ReactComponent as CompleteStageIconSpring } from 'src/styling/icons/stage/spring/complete.svg'
import { ReactComponent as CurrentStageIconSpring } from 'src/styling/icons/stage/spring/current.svg'
import { ReactComponent as EmptyStageIconSpring } from 'src/styling/icons/stage/spring/empty.svg'
import { ReactComponent as CompleteStageIconZodiac } from 'src/styling/icons/stage/zodiac/complete.svg'
import { ReactComponent as CurrentStageIconZodiac } from 'src/styling/icons/stage/zodiac/current.svg'
import { ReactComponent as EmptyStageIconZodiac } from 'src/styling/icons/stage/zodiac/empty.svg'
import {
primaryColor,
secondaryColor,

View file

@ -1,9 +1,8 @@
import { makeStyles, ClickAwayListener } from '@material-ui/core'
import * as R from 'ramda'
import React, { useState, memo } from 'react'
import Popper from 'src/components/Popper'
import { ReactComponent as HelpIcon } from 'src/styling/icons/action/help/zodiac.svg'
import HelpIcon from 'src/styling/icons/action/help/zodiac.svg?react'
const useStyles = makeStyles({
transparentButton: {
@ -13,6 +12,16 @@ const useStyles = makeStyles({
cursor: 'pointer',
marginTop: 4
},
relativelyPositioned: {
position: 'relative'
},
safeSpace: {
position: 'absolute',
backgroundColor: '#0000',
height: 40,
left: '-50%',
width: '200%'
},
popoverContent: ({ width }) => ({
width,
padding: [[10, 15]]
@ -27,6 +36,10 @@ const usePopperHandler = width => {
setHelpPopperAnchorEl(helpPopperAnchorEl ? null : event.currentTarget)
}
const openHelpPopper = event => {
setHelpPopperAnchorEl(event.currentTarget)
}
const handleCloseHelpPopper = () => {
setHelpPopperAnchorEl(null)
}
@ -38,25 +51,32 @@ const usePopperHandler = width => {
helpPopperAnchorEl,
helpPopperOpen,
handleOpenHelpPopper,
openHelpPopper,
handleCloseHelpPopper
}
}
const Tooltip = memo(({ children, width, Icon = HelpIcon }) => {
const HelpTooltip = memo(({ children, width }) => {
const handler = usePopperHandler(width)
return (
<ClickAwayListener onClickAway={handler.handleCloseHelpPopper}>
<div>
<div
className={handler.classes.relativelyPositioned}
onMouseLeave={handler.handleCloseHelpPopper}>
{handler.helpPopperOpen && (
<div className={handler.classes.safeSpace}></div>
)}
<button
type="button"
className={handler.classes.transparentButton}
onClick={handler.handleOpenHelpPopper}>
<Icon />
onMouseEnter={handler.openHelpPopper}>
<HelpIcon />
</button>
<Popper
open={handler.helpPopperOpen}
anchorEl={handler.helpPopperAnchorEl}
arrowEnabled={true}
placement="bottom">
<div className={handler.classes.popoverContent}>{children}</div>
</Popper>
@ -69,11 +89,12 @@ const HoverableTooltip = memo(({ parentElements, children, width }) => {
const handler = usePopperHandler(width)
return (
<ClickAwayListener onClickAway={handler.handleCloseHelpPopper}>
<div>
{!R.isNil(parentElements) && (
<div
onMouseEnter={handler.handleOpenHelpPopper}
onMouseLeave={handler.handleCloseHelpPopper}>
onMouseLeave={handler.handleCloseHelpPopper}
onMouseEnter={handler.handleOpenHelpPopper}>
{parentElements}
</div>
)}
@ -93,7 +114,8 @@ const HoverableTooltip = memo(({ parentElements, children, width }) => {
<div className={handler.classes.popoverContent}>{children}</div>
</Popper>
</div>
</ClickAwayListener>
)
})
export { Tooltip, HoverableTooltip }
export { HoverableTooltip, HelpTooltip }

View file

@ -3,17 +3,17 @@ import classnames from 'classnames'
import { useFormikContext, Form, Formik, Field as FormikField } from 'formik'
import * as R from 'ramda'
import React, { useState, memo } from 'react'
import PromptWhenDirty from 'src/components/PromptWhenDirty'
import { H4 } from 'src/components/typography'
import EditIconDisabled from 'src/styling/icons/action/edit/disabled.svg?react'
import EditIcon from 'src/styling/icons/action/edit/enabled.svg?react'
import FalseIcon from 'src/styling/icons/table/false.svg?react'
import TrueIcon from 'src/styling/icons/table/true.svg?react'
import * as Yup from 'yup'
import PromptWhenDirty from 'src/components/PromptWhenDirty'
import { Link, IconButton } from 'src/components/buttons'
import { RadioGroup } from 'src/components/inputs/formik'
import { Table, TableBody, TableRow, TableCell } from 'src/components/table'
import { H4 } from 'src/components/typography'
import { ReactComponent as EditIconDisabled } from 'src/styling/icons/action/edit/disabled.svg'
import { ReactComponent as EditIcon } from 'src/styling/icons/action/edit/enabled.svg'
import { ReactComponent as FalseIcon } from 'src/styling/icons/table/false.svg'
import { ReactComponent as TrueIcon } from 'src/styling/icons/table/true.svg'
import { booleanPropertiesTableStyles } from './BooleanPropertiesTable.styles'

View file

@ -1,9 +1,9 @@
import { makeStyles } from '@material-ui/core/styles'
import classnames from 'classnames'
import React, { memo } from 'react'
import AddIcon from 'src/styling/icons/button/add/zodiac.svg?react'
import typographyStyles from 'src/components/typography/styles'
import { ReactComponent as AddIcon } from 'src/styling/icons/button/add/zodiac.svg'
import { zircon, zircon2, comet, fontColor, white } from 'src/styling/variables'
const { p } = typographyStyles

Some files were not shown because too many files have changed in this diff Show more