diff --git a/lib/app.js b/lib/app.js index 72394605..7692ce2f 100644 --- a/lib/app.js +++ b/lib/app.js @@ -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) - }) }) } diff --git a/lib/graphql/resolvers.js b/lib/graphql/resolvers.js new file mode 100644 index 00000000..5d457c64 --- /dev/null +++ b/lib/graphql/resolvers.js @@ -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 + } +} diff --git a/lib/graphql/server.js b/lib/graphql/server.js new file mode 100644 index 00000000..fda7d85c --- /dev/null +++ b/lib/graphql/server.js @@ -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 +}) diff --git a/lib/graphql/types.js b/lib/graphql/types.js new file mode 100644 index 00000000..a9318978 --- /dev/null +++ b/lib/graphql/types.js @@ -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! +} +` diff --git a/lib/middlewares/recordPing.js b/lib/middlewares/recordPing.js new file mode 100644 index 00000000..e74de771 --- /dev/null +++ b/lib/middlewares/recordPing.js @@ -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()) diff --git a/lib/new-admin/admin-server.js b/lib/new-admin/admin-server.js index 53f86ccc..550d71ed 100644 --- a/lib/new-admin/admin-server.js +++ b/lib/new-admin/admin-server.js @@ -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') diff --git a/lib/new-config-manager.js b/lib/new-config-manager.js index 4c05b540..8951b2a4 100644 --- a/lib/new-config-manager.js +++ b/lib/new-config-manager.js @@ -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)])) } diff --git a/lib/plugins.js b/lib/plugins.js index 80bbcc9b..0fbff479 100644 --- a/lib/plugins.js +++ b/lib/plugins.js @@ -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, diff --git a/lib/routes.js b/lib/routes.js index 22b72bf2..255490c8 100644 --- a/lib/routes.js +++ b/lib/routes.js @@ -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' }) diff --git a/lib/routes/pollingRoutes.js b/lib/routes/pollingRoutes.js index a2ca4847..89e7aab1 100644 --- a/lib/routes/pollingRoutes.js +++ b/lib/routes/pollingRoutes.js @@ -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, diff --git a/package-lock.json b/package-lock.json index 5daa70b8..f5c222af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "lamassu-server", - "version": "8.0.0-beta.4", + "version": "8.1.0-beta.0", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 539bdfaf..b4771d8a 100644 --- a/package.json +++ b/package.json @@ -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": {