Merge pull request #1164 from siiky/feat/lam-258/graphql-config

feat: use GraphQL for all server-machine communication
This commit is contained in:
Rafael Taranto 2022-04-26 13:02:27 +01:00 committed by GitHub
commit da87301e4e
12 changed files with 483 additions and 61 deletions

View file

@ -77,8 +77,6 @@ function startServer (settings) {
: https.createServer(httpsServerOptions, routes.app)
const port = argv.port || 3000
const localPort = 3030
const localServer = http.createServer(routes.localApp)
if (options.devMode) logger.info('In dev mode')
@ -86,10 +84,6 @@ function startServer (settings) {
logger.info('lamassu-server listening on port ' +
port + ' ' + (devMode ? '(http)' : '(https)'))
})
localServer.listen(localPort, 'localhost', () => {
logger.info('lamassu-server listening on local port ' + localPort)
})
})
}

239
lib/graphql/resolvers.js Normal file
View file

@ -0,0 +1,239 @@
const _ = require('lodash/fp')
const nmd = require('nano-markdown')
const { accounts: accountsConfig, countries, languages } = require('../new-admin/config')
const plugins = require('../plugins')
const configManager = require('../new-config-manager')
const customRequestQueries = require('../new-admin/services/customInfoRequests')
const state = require('../middlewares/state')
const VERSION = require('../../package.json').version
const urlsToPing = [
`us.archive.ubuntu.com`,
`uk.archive.ubuntu.com`,
`za.archive.ubuntu.com`,
`cn.archive.ubuntu.com`
]
const speedtestFiles = [
{
url: 'https://github.com/lamassu/speed-test-assets/raw/main/python-defaults_2.7.18-3.tar.gz',
size: 44668
}
]
const addSmthInfo = (dstField, srcFields) => smth =>
smth && smth.active ? _.set(dstField, _.pick(srcFields, smth)) : _.identity
const addOperatorInfo = addSmthInfo(
'operatorInfo',
['name', 'phone', 'email', 'website', 'companyNumber']
)
const addReceiptInfo = addSmthInfo(
'receiptInfo',
[
'sms',
'operatorWebsite',
'operatorEmail',
'operatorPhone',
'companyNumber',
'machineLocation',
'customerNameOrPhoneNumber',
'exchangeRate',
'addressQRCode',
]
)
/* TODO: Simplify this. */
const buildTriggers = (allTriggers) => {
const normalTriggers = []
const customTriggers = _.filter(o => {
if (_.isEmpty(o.customInfoRequestId) || _.isNil(o.customInfoRequestId)) normalTriggers.push(o)
return !_.isNil(o.customInfoRequestId) && !_.isEmpty(o.customInfoRequestId)
}, allTriggers)
return _.flow(
_.map(_.get('customInfoRequestId')),
customRequestQueries.batchGetCustomInfoRequest
)(customTriggers)
.then(res => {
res.forEach((details, index) => {
// make sure we aren't attaching the details to the wrong trigger
if (customTriggers[index].customInfoRequestId !== details.id) return
customTriggers[index] = { ...customTriggers[index], customInfoRequest: details }
})
return [...normalTriggers, ...customTriggers]
})
}
/*
* TODO: From `lib/routes/termsAndConditionsRoutes.js` -- remove this after
* terms are removed from the GraphQL API too.
*/
const massageTerms = terms => (terms.active && terms.text) ? ({
delay: Boolean(terms.delay),
title: terms.title,
text: nmd(terms.text),
accept: terms.acceptButtonText,
cancel: terms.cancelButtonText,
}) : null
const staticConfig = ({ currentConfigVersion, deviceId, deviceName, pq, settings, }) => {
const massageCoins = _.map(_.pick([
'batchable',
'cashInCommission',
'cashInFee',
'cashOutCommission',
'cryptoCode',
'cryptoNetwork',
'cryptoUnits',
'display',
'minimumTx'
]))
const staticConf = _.flow(
_.pick([
'areThereAvailablePromoCodes',
'coins',
'configVersion',
'timezone'
]),
_.update('coins', massageCoins),
_.set('serverVersion', VERSION),
)(pq)
return Promise.all([
!!configManager.getCompliance(settings.config).enablePaperWalletOnly,
configManager.getTriggersAutomation(settings.config),
buildTriggers(configManager.getTriggers(settings.config)),
configManager.getWalletSettings('BTC', settings.config).layer2 !== 'no-layer2',
configManager.getLocale(deviceId, settings.config),
configManager.getOperatorInfo(settings.config),
configManager.getReceipt(settings.config),
massageTerms(configManager.getTermsConditions(settings.config)),
!!configManager.getCashOut(deviceId, settings.config).active,
])
.then(([
enablePaperWalletOnly,
triggersAutomation,
triggers,
hasLightning,
localeInfo,
operatorInfo,
receiptInfo,
terms,
twoWayMode,
]) =>
(currentConfigVersion && currentConfigVersion >= staticConf.configVersion) ?
null :
_.flow(
_.assign({
enablePaperWalletOnly,
triggersAutomation,
triggers,
hasLightning,
localeInfo: {
country: localeInfo.country,
languages: localeInfo.languages,
fiatCode: localeInfo.fiatCurrency
},
machineInfo: { deviceId, deviceName },
twoWayMode,
speedtestFiles,
urlsToPing,
terms,
}),
_.update('triggersAutomation', _.mapValues(_.eq('Automatic'))),
addOperatorInfo(operatorInfo),
addReceiptInfo(receiptInfo)
)(staticConf))
}
const setZeroConfLimit = config => coin =>
_.set(
'zeroConfLimit',
configManager.getWalletSettings(coin.cryptoCode, config).zeroConfLimit,
coin
)
const dynamicConfig = ({ deviceId, operatorId, pid, pq, settings, }) => {
const massageCassettes = cassettes =>
cassettes ?
_.flow(
cassettes => _.set('physical', _.get('cassettes', cassettes), cassettes),
cassettes => _.set('virtual', _.get('virtualCassettes', cassettes), cassettes),
_.unset('cassettes'),
_.unset('virtualCassettes')
)(cassettes) :
null
state.pids = _.update(operatorId, _.set(deviceId, { pid, ts: Date.now() }), state.pids)
return _.flow(
_.pick(['balances', 'cassettes', 'coins', 'rates']),
_.update('cassettes', massageCassettes),
/* [{ cryptoCode, rates }, ...] => [[cryptoCode, rates], ...] */
_.update('coins', _.map(({ cryptoCode, rates }) => [cryptoCode, rates])),
/* [{ cryptoCode: balance }, ...] => [[cryptoCode, { balance }], ...] */
_.update('balances', _.flow(
_.toPairs,
_.map(([cryptoCode, balance]) => [cryptoCode, { balance }])
)),
/* Group the separate objects by cryptoCode */
/* { balances, coins, rates } => { cryptoCode: { balance, ask, bid, cashIn, cashOut }, ... } */
({ balances, cassettes, coins, rates }) => ({
cassettes,
coins: _.flow(
_.reduce(
(ret, [cryptoCode, obj]) => _.update(cryptoCode, _.assign(obj), ret),
rates
),
/* { cryptoCode: { balance, ask, bid, cashIn, cashOut }, ... } => [[cryptoCode, { balance, ask, bid, cashIn, cashOut }], ...] */
_.toPairs,
/* [[cryptoCode, { balance, ask, bid, cashIn, cashOut }], ...] => [{ cryptoCode, balance, ask, bid, cashIn, cashOut }, ...] */
_.map(([cryptoCode, obj]) => _.set('cryptoCode', cryptoCode, obj))
)(_.concat(balances, coins))
}),
_.update('coins', _.map(setZeroConfLimit(settings.config))),
_.set('reboot', !!pid && state.reboots?.[operatorId]?.[deviceId] === pid),
_.set('shutdown', !!pid && state.shutdowns?.[operatorId]?.[deviceId] === pid),
_.set('restartServices', !!pid && state.restartServicesMap?.[operatorId]?.[deviceId] === pid),
)(pq)
}
const configs = (parent, { currentConfigVersion }, { deviceId, deviceName, operatorId, pid, settings }, info) =>
plugins(settings, deviceId)
.pollQueries()
.then(pq => ({
static: staticConfig({
currentConfigVersion,
deviceId,
deviceName,
pq,
settings,
}),
dynamic: dynamicConfig({
deviceId,
operatorId,
pid,
pq,
settings,
}),
}))
module.exports = {
Query: {
configs
}
}

27
lib/graphql/server.js Normal file
View file

@ -0,0 +1,27 @@
const logger = require('../logger')
const https = require('https')
const { ApolloServer } = require('apollo-server-express')
const devMode = !!require('minimist')(process.argv.slice(2)).dev
module.exports = new ApolloServer({
typeDefs: require('./types'),
resolvers: require('./resolvers'),
context: ({ req, res }) => ({
deviceId: req.deviceId, /* lib/middlewares/populateDeviceId.js */
deviceName: req.deviceName, /* lib/middlewares/authorize.js */
operatorId: res.locals.operatorId, /* lib/middlewares/operatorId.js */
pid: req.query.pid,
settings: req.settings, /* lib/middlewares/populateSettings.js */
}),
uploads: false,
playground: false,
introspection: false,
formatError: error => {
logger.error(error)
return error
},
debug: devMode,
logger
})

151
lib/graphql/types.js Normal file
View file

@ -0,0 +1,151 @@
const { gql } = require('apollo-server-express')
module.exports = gql`
type Coin {
cryptoCode: String!
display: String!
minimumTx: String!
cashInFee: String!
cashInCommission: String!
cashOutCommission: String!
cryptoNetwork: Boolean!
cryptoUnits: String!
batchable: Boolean!
}
type LocaleInfo {
country: String!
fiatCode: String!
languages: [String!]!
}
type OperatorInfo {
name: String!
phone: String!
email: String!
website: String!
companyNumber: String!
}
type MachineInfo {
deviceId: String!
deviceName: String
}
type ReceiptInfo {
sms: Boolean!
operatorWebsite: Boolean!
operatorEmail: Boolean!
operatorPhone: Boolean!
companyNumber: Boolean!
machineLocation: Boolean!
customerNameOrPhoneNumber: Boolean!
exchangeRate: Boolean!
addressQRCode: Boolean!
}
type SpeedtestFile {
url: String!
size: Int!
}
# True if automatic, False otherwise
type TriggersAutomation {
sanctions: Boolean!
idCardPhoto: Boolean!
idCardData: Boolean!
facephoto: Boolean!
usSsn: Boolean!
}
type Trigger {
id: String!
customInfoRequestId: String!
direction: String!
requirement: String!
triggerType: String!
suspensionDays: Int
threshold: Int
thresholdDays: Int
}
type Terms {
delay: Boolean!
title: String!
text: String!
accept: String!
cancel: String!
}
type StaticConfig {
configVersion: Int!
areThereAvailablePromoCodes: Boolean!
coins: [Coin!]!
enablePaperWalletOnly: Boolean!
hasLightning: Boolean!
serverVersion: String!
timezone: Int!
twoWayMode: Boolean!
localeInfo: LocaleInfo!
operatorInfo: OperatorInfo
machineInfo: MachineInfo!
receiptInfo: ReceiptInfo
speedtestFiles: [SpeedtestFile!]!
urlsToPing: [String!]!
triggersAutomation: TriggersAutomation!
triggers: [Trigger!]!
terms: Terms
}
type DynamicCoinValues {
# NOTE: Doesn't seem to be used anywhere outside of lib/plugins.js.
# However, it can be used to generate the cache key, if we ever move to an
# actual caching mechanism.
#timestamp: String!
cryptoCode: String!
balance: String!
# Raw rates
ask: String!
bid: String!
# Rates with commissions applied
cashIn: String!
cashOut: String!
zeroConfLimit: Int!
}
type PhysicalCassette {
denomination: Int!
count: Int!
}
type Cassettes {
physical: [PhysicalCassette!]!
virtual: [Int!]!
}
type DynamicConfig {
cassettes: Cassettes
coins: [DynamicCoinValues!]!
reboot: Boolean!
shutdown: Boolean!
restartServices: Boolean!
}
type Configs {
static: StaticConfig
dynamic: DynamicConfig!
}
type Query {
configs(currentConfigVersion: Int): Configs!
}
`

View file

@ -0,0 +1,7 @@
const plugins = require('../plugins')
module.exports = (req, res, next) =>
plugins(req.settings, req.deviceId)
.recordPing(req.deviceTime, req.query.version, req.query.model)
.then(() => next())
.catch(() => next())

View file

@ -8,13 +8,12 @@ const cors = require('cors')
const helmet = require('helmet')
const nocache = require('nocache')
const cookieParser = require('cookie-parser')
const { ApolloServer, AuthenticationError } = require('apollo-server-express')
const { ApolloServer } = require('apollo-server-express')
const { graphqlUploadExpress } = require('graphql-upload')
const _ = require('lodash/fp')
const { asyncLocalStorage, defaultStore } = require('../async-storage')
const options = require('../options')
const users = require('../users')
const logger = require('../logger')
const { AuthDirective } = require('./graphql/directives')

View file

@ -48,7 +48,6 @@ const getCommissions = (cryptoCode, deviceId, config) => {
const getLocale = (deviceId, it) => {
const locale = fromNamespace(namespaces.LOCALE)(it)
const filter = _.matches({ machine: deviceId })
return _.omit('overrides', _.assignAll([locale, ..._.filter(filter)(locale.overrides)]))
}

View file

@ -25,6 +25,7 @@ const customers = require('./customers')
const commissionMath = require('./commission-math')
const loyalty = require('./loyalty')
const transactionBatching = require('./tx-batching')
const state = require('./middlewares/state')
const { CASSETTE_MAX_CAPACITY, CASH_OUT_DISPENSE_READY, CONFIRMATION_CODE } = require('./constants')
@ -39,7 +40,6 @@ const mapValuesWithKey = _.mapValues.convert({
const TRADE_TTL = 2 * T.minutes
const STALE_TICKER = 3 * T.minutes
const STALE_BALANCE = 3 * T.minutes
const PONG_TTL = '1 week'
const tradesQueues = {}
function plugins (settings, deviceId) {
@ -206,8 +206,7 @@ function plugins (settings, deviceId) {
}
function mapCoinSettings (coinParams) {
const cryptoCode = coinParams[0]
const cryptoNetwork = coinParams[1]
const [ cryptoCode, cryptoNetwork ] = coinParams
const commissions = configManager.getCommissions(cryptoCode, deviceId, settings.config)
const minimumTx = new BN(commissions.minimumTx)
const cashInFee = new BN(commissions.fixedFee)
@ -228,56 +227,57 @@ function plugins (settings, deviceId) {
}
}
function pollQueries (serialNumber, deviceTime, deviceRec, machineVersion, machineModel) {
function pollQueries () {
const localeConfig = configManager.getLocale(deviceId, settings.config)
const fiatCode = localeConfig.fiatCurrency
const cryptoCodes = localeConfig.cryptoCurrencies
const timezone = millisecondsToMinutes(getTimezoneOffset(localeConfig.timezone))
const tickerPromises = cryptoCodes.map(c => ticker.getRates(settings, fiatCode, c))
const balancePromises = cryptoCodes.map(c => fiatBalance(fiatCode, c))
const testnetPromises = cryptoCodes.map(c => wallet.cryptoNetwork(settings, c))
const pingPromise = recordPing(deviceTime, machineVersion, machineModel)
const currentConfigVersionPromise = fetchCurrentConfigVersion()
const currentAvailablePromoCodes = loyalty.getNumberOfAvailablePromoCodes()
const networkPromises = cryptoCodes.map(c => wallet.cryptoNetwork(settings, c))
const supportsBatchingPromise = cryptoCodes.map(c => wallet.supportsBatching(settings, c))
const promises = [
return Promise.all([
buildAvailableCassettes(),
pingPromise,
currentConfigVersionPromise,
timezone
].concat(
supportsBatchingPromise,
tickerPromises,
balancePromises,
testnetPromises,
currentAvailablePromoCodes
)
fetchCurrentConfigVersion(),
millisecondsToMinutes(getTimezoneOffset(localeConfig.timezone)),
loyalty.getNumberOfAvailablePromoCodes(),
Promise.all(supportsBatchingPromise),
Promise.all(tickerPromises),
Promise.all(balancePromises),
Promise.all(networkPromises)
])
.then(([
cassettes,
configVersion,
timezone,
numberOfAvailablePromoCodes,
batchableCoins,
tickers,
balances,
networks
]) => {
const coinsWithoutRate = _.flow(
_.zip(cryptoCodes),
_.map(mapCoinSettings)
)(networks)
return Promise.all(promises)
.then(arr => {
const cassettes = arr[0]
const configVersion = arr[2]
const tz = arr[3]
const cryptoCodesCount = cryptoCodes.length
const batchableCoinsRes = arr.slice(4, cryptoCodesCount + 4)
const batchableCoins = batchableCoinsRes.map(it => ({ batchable: it }))
const tickers = arr.slice(cryptoCodesCount + 4, 2 * cryptoCodesCount + 4)
const balances = arr.slice(2 * cryptoCodesCount + 4, 3 * cryptoCodesCount + 4)
const testNets = arr.slice(3 * cryptoCodesCount + 4, arr.length - 1)
const coinParams = _.zip(cryptoCodes, testNets)
const coinsWithoutRate = _.map(mapCoinSettings, coinParams)
const areThereAvailablePromoCodes = arr[arr.length - 1] > 0
const coins = _.flow(
_.map(it => ({ batchable: it })),
_.zipWith(
_.assign,
_.zipWith(_.assign, coinsWithoutRate, tickers)
)
)(batchableCoins)
return {
cassettes,
rates: buildRates(tickers),
balances: buildBalances(balances),
coins: _.zipWith(_.assign, _.zipWith(_.assign, coinsWithoutRate, tickers), batchableCoins),
coins,
configVersion,
areThereAvailablePromoCodes,
timezone: tz
areThereAvailablePromoCodes: numberOfAvailablePromoCodes > 0,
timezone
}
})
}
@ -365,12 +365,12 @@ function plugins (settings, deviceId) {
const rate = rawRate.div(cashInCommission)
const lowBalanceMargin = new BN(1.05)
const lowBalanceMargin = new BN(0.95)
const cryptoRec = coinUtils.getCryptoCurrency(cryptoCode)
const unitScale = cryptoRec.unitScale
const shiftedRate = rate.shiftedBy(-unitScale)
const fiatTransferBalance = balance.times(shiftedRate).div(lowBalanceMargin)
const fiatTransferBalance = balance.times(shiftedRate).times(lowBalanceMargin)
return {
timestamp: balanceRec.timestamp,
@ -850,6 +850,7 @@ function plugins (settings, deviceId) {
return {
getRates,
recordPing,
buildRates,
getRawRates,
buildRatesNoCommission,

View file

@ -15,6 +15,7 @@ const computeSchema = require('./middlewares/compute-schema')
const findOperatorId = require('./middlewares/operatorId')
const populateDeviceId = require('./middlewares/populateDeviceId')
const populateSettings = require('./middlewares/populateSettings')
const recordPing = require('./middlewares/recordPing')
const cashboxRoutes = require('./routes/cashboxRoutes')
const customerRoutes = require('./routes/customerRoutes')
@ -30,6 +31,8 @@ const verifyUserRoutes = require('./routes/verifyUserRoutes')
const verifyTxRoutes = require('./routes/verifyTxRoutes')
const verifyPromoCodeRoutes = require('./routes/verifyPromoCodeRoutes')
const graphQLServer = require('./graphql/server')
const app = express()
const configRequiredRoutes = [
@ -39,7 +42,8 @@ const configRequiredRoutes = [
'/phone_code',
'/customer',
'/tx',
'/verify_promo_code'
'/verify_promo_code',
'/graphql'
]
const devMode = argv.dev || options.http
@ -56,11 +60,12 @@ app.use('/', pairingRoutes)
app.use(findOperatorId)
app.use(populateDeviceId)
app.use(computeSchema)
if (!devMode) app.use(authorize)
app.use(authorize)
app.use(configRequiredRoutes, populateSettings)
app.use(filterOldRequests)
// other app routes
app.use('/graphql', recordPing)
app.use('/poll', pollingRoutes)
app.use('/terms_conditions', termsAndConditionsRoutes)
app.use('/state', stateRoutes)
@ -79,6 +84,8 @@ app.use('/tx', txRoutes)
app.use('/logs', logsRoutes)
graphQLServer.applyMiddleware({ app })
app.use(errorHandler)
app.use((req, res) => {
res.status(404).json({ error: 'No such route' })

View file

@ -61,7 +61,6 @@ function poll (req, res, next) {
const machineModel = req.query.model
const deviceId = req.deviceId
const deviceTime = req.deviceTime
const serialNumber = req.query.sn
const pid = req.query.pid
const settings = req.settings
const operatorId = res.locals.operatorId
@ -73,9 +72,6 @@ function poll (req, res, next) {
const pi = plugins(settings, deviceId)
const hasLightning = checkHasLightning(settings)
const triggersAutomationPromise = configManager.getTriggersAutomation(settings.config)
const triggersPromise = buildTriggers(configManager.getTriggers(settings.config))
const operatorInfo = configManager.getOperatorInfo(settings.config)
const machineInfo = { deviceId: req.deviceId, deviceName: req.deviceName }
const cashOutConfig = configManager.getCashOut(deviceId, settings.config)
@ -85,10 +81,13 @@ function poll (req, res, next) {
state.pids = _.update(operatorId, _.set(deviceId, { pid, ts: Date.now() }), state.pids)
return Promise.all([pi.pollQueries(serialNumber, deviceTime, req.query, machineVersion, machineModel), triggersPromise, triggersAutomationPromise])
.then(([results, triggers, triggersAutomation]) => {
const cassettes = results.cassettes
return Promise.all([
pi.recordPing(deviceTime, machineVersion, machineModel),
pi.pollQueries(),
buildTriggers(configManager.getTriggers(settings.config)),
configManager.getTriggersAutomation(settings.config)
])
.then(([_pingRes, results, triggers, triggersAutomation]) => {
const reboot = pid && state.reboots?.[operatorId]?.[deviceId] === pid
const shutdown = pid && state.shutdowns?.[operatorId]?.[deviceId] === pid
const restartServices = pid && state.restartServicesMap?.[operatorId]?.[deviceId] === pid
@ -110,7 +109,6 @@ function poll (req, res, next) {
receiptPrintingActive: receipt.active,
smsReceiptActive: receipt.sms,
enablePaperWalletOnly,
cassettes,
twoWayMode: cashOutConfig.active,
zeroConfLimits,
reboot,

2
package-lock.json generated
View file

@ -1,6 +1,6 @@
{
"name": "lamassu-server",
"version": "8.0.0-beta.4",
"version": "8.1.0-beta.0",
"lockfileVersion": 1,
"requires": true,
"dependencies": {

View file

@ -2,7 +2,7 @@
"name": "lamassu-server",
"description": "bitcoin atm client server protocol module",
"keywords": [],
"version": "8.0.0-beta.4",
"version": "8.1.0-beta.0",
"license": "Unlicense",
"author": "Lamassu (https://lamassu.is)",
"dependencies": {