Merge pull request #1763 from RafaelTaranto/backport/market-currency-selector

LAM-551 backport: market currency selector
This commit is contained in:
Rafael Taranto 2024-11-29 13:48:38 +00:00 committed by GitHub
commit b2a28d4fa9
28 changed files with 725 additions and 350 deletions

View file

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

View file

@ -11,6 +11,7 @@ const funding = require('./funding.resolver')
const log = require('./log.resolver') const log = require('./log.resolver')
const loyalty = require('./loyalty.resolver') const loyalty = require('./loyalty.resolver')
const machine = require('./machine.resolver') const machine = require('./machine.resolver')
const market = require('./market.resolver')
const notification = require('./notification.resolver') const notification = require('./notification.resolver')
const pairing = require('./pairing.resolver') const pairing = require('./pairing.resolver')
const rates = require('./rates.resolver') const rates = require('./rates.resolver')
@ -35,6 +36,7 @@ const resolvers = [
log, log,
loyalty, loyalty,
machine, machine,
market,
notification, notification,
pairing, pairing,
rates, rates,

View file

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

View file

@ -11,6 +11,7 @@ const funding = require('./funding.type')
const log = require('./log.type') const log = require('./log.type')
const loyalty = require('./loyalty.type') const loyalty = require('./loyalty.type')
const machine = require('./machine.type') const machine = require('./machine.type')
const market = require('./market.type')
const notification = require('./notification.type') const notification = require('./notification.type')
const pairing = require('./pairing.type') const pairing = require('./pairing.type')
const rates = require('./rates.type') const rates = require('./rates.type')
@ -35,6 +36,7 @@ const types = [
log, log,
loyalty, loyalty,
machine, machine,
market,
notification, notification,
pairing, pairing,
rates, rates,

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

@ -475,25 +475,28 @@ function plugins (settings, deviceId) {
function buyAndSell (rec, doBuy, tx) { function buyAndSell (rec, doBuy, tx) {
const cryptoCode = rec.cryptoCode const cryptoCode = rec.cryptoCode
const fiatCode = rec.fiatCode return exchange.fetchExchange(settings, cryptoCode)
const cryptoAtoms = doBuy ? commissionMath.fiatToCrypto(tx, rec, deviceId, settings.config) : rec.cryptoAtoms.negated() .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('') const market = [fiatCode, cryptoCode].join('')
if (!exchange.active(settings, cryptoCode)) return if (!exchange.active(settings, cryptoCode)) return
const direction = doBuy ? 'cashIn' : 'cashOut' const direction = doBuy ? 'cashIn' : 'cashOut'
const internalTxId = tx ? tx.id : rec.id const internalTxId = tx ? tx.id : rec.id
logger.debug('[%s] Pushing trade: %d', market, cryptoAtoms) logger.debug('[%s] Pushing trade: %d', market, cryptoAtoms)
if (!tradesQueues[market]) tradesQueues[market] = [] if (!tradesQueues[market]) tradesQueues[market] = []
tradesQueues[market].push({ tradesQueues[market].push({
direction, direction,
internalTxId, internalTxId,
fiatCode, fiatCode,
cryptoAtoms, cryptoAtoms,
cryptoCode, cryptoCode,
timestamp: Date.now() timestamp: Date.now()
}) })
})
} }
function consolidateTrades (cryptoCode, fiatCode) { function consolidateTrades (cryptoCode, fiatCode) {
@ -550,19 +553,22 @@ function plugins (settings, deviceId) {
const deviceIds = devices.map(device => device.deviceId) const deviceIds = devices.map(device => device.deviceId)
const lists = deviceIds.map(deviceId => { const lists = deviceIds.map(deviceId => {
const localeConfig = configManager.getLocale(deviceId, settings.config) const localeConfig = configManager.getLocale(deviceId, settings.config)
const fiatCode = localeConfig.fiatCurrency
const cryptoCodes = localeConfig.cryptoCurrencies const cryptoCodes = localeConfig.cryptoCurrencies
return cryptoCodes.map(cryptoCode => ({ return Promise.all(cryptoCodes.map(cryptoCode => {
fiatCode, return exchange.fetchExchange(settings, cryptoCode)
cryptoCode .then(exchange => ({
fiatCode: exchange.account.currencyMarket,
cryptoCode
}))
})) }))
}) })
const tradesPromises = _.uniq(_.flatten(lists)) return Promise.all(lists)
.map(r => executeTradesForMarket(settings, r.fiatCode, r.cryptoCode)) })
.then(lists => {
return Promise.all(tradesPromises) return Promise.all(_.uniq(_.flatten(lists))
.map(r => executeTradesForMarket(settings, r.fiatCode, r.cryptoCode)))
}) })
.catch(logger.error) .catch(logger.error)
} }

View file

@ -33,11 +33,8 @@ function buildMarket (fiatCode, cryptoCode, serviceName) {
if (!_.includes(cryptoCode, ALL[serviceName].CRYPTO)) { if (!_.includes(cryptoCode, ALL[serviceName].CRYPTO)) {
throw new Error('Unsupported crypto: ' + cryptoCode) throw new Error('Unsupported crypto: ' + cryptoCode)
} }
const fiatSupported = ALL[serviceName].FIAT
if (fiatSupported !== 'ALL_CURRENCIES' && !_.includes(fiatCode, fiatSupported)) { if (_.isNil(fiatCode)) throw new Error('Market pair building failed: Missing fiat code')
logger.info('Building a market for an unsupported fiat. Defaulting to EUR market')
return cryptoCode + '/' + 'EUR'
}
return cryptoCode + '/' + fiatCode return cryptoCode + '/' + fiatCode
} }

View file

@ -7,7 +7,8 @@ const ORDER_TYPE = ORDER_TYPES.MARKET
const { BTC, BCH, XMR, ETH, LTC, ZEC, LN } = COINS const { BTC, BCH, XMR, ETH, LTC, ZEC, LN } = COINS
const CRYPTO = [BTC, ETH, LTC, ZEC, BCH, XMR, LN] const CRYPTO = [BTC, ETH, LTC, ZEC, BCH, XMR, LN]
const FIAT = ['USD', 'EUR'] 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 loadConfig = (account) => {
const mapper = { const mapper = {
@ -17,4 +18,4 @@ const loadConfig = (account) => {
return { ...mapped, timeout: 3000 } 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 { BTC, BCH, DASH, ETH, LTC, ZEC, USDT, USDT_TRON, LN } = COINS
const CRYPTO = [BTC, ETH, LTC, DASH, ZEC, BCH, USDT, USDT_TRON, LN] const CRYPTO = [BTC, ETH, LTC, DASH, ZEC, BCH, USDT, USDT_TRON, LN]
const FIAT = ['USD'] 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 loadConfig = (account) => {
const mapper = { const mapper = {
@ -17,4 +18,4 @@ const loadConfig = (account) => {
return { ...mapped, timeout: 3000 } 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 { BTC, ETH, LTC, BCH, USDT, LN } = COINS
const CRYPTO = [BTC, ETH, LTC, BCH, USDT, LN] const CRYPTO = [BTC, ETH, LTC, BCH, USDT, LN]
const FIAT = ['USD', 'EUR'] const FIAT = ['USD', 'EUR']
const DEFAULT_FIAT_MARKET = 'EUR'
const AMOUNT_PRECISION = 8 const AMOUNT_PRECISION = 8
const REQUIRED_CONFIG_FIELDS = ['key', 'secret'] const REQUIRED_CONFIG_FIELDS = ['key', 'secret']
@ -18,4 +19,4 @@ const loadConfig = (account) => {
return { ...mapped, timeout: 3000 } 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 { BTC, ETH, LTC, BCH, USDT, LN } = COINS
const CRYPTO = [BTC, ETH, LTC, BCH, USDT, LN] const CRYPTO = [BTC, ETH, LTC, BCH, USDT, LN]
const FIAT = ['USD', 'EUR'] const FIAT = ['USD', 'EUR']
const DEFAULT_FIAT_MARKET = 'EUR'
const AMOUNT_PRECISION = 8 const AMOUNT_PRECISION = 8
const REQUIRED_CONFIG_FIELDS = ['key', 'secret', 'clientId'] const REQUIRED_CONFIG_FIELDS = ['key', 'secret', 'clientId', 'currencyMarket']
const loadConfig = (account) => { const loadConfig = (account) => {
const mapper = { const mapper = {
@ -19,4 +20,4 @@ const loadConfig = (account) => {
return { ...mapped, timeout: 3000 } 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 { utils: coinUtils } = require('@lamassu/coins')
const _ = require('lodash/fp') const _ = require('lodash/fp')
const ccxt = require('ccxt') const ccxt = require('ccxt')
const mem = require('mem')
const { buildMarket, ALL, isConfigValid } = require('../common/ccxt') const { buildMarket, ALL, isConfigValid } = require('../common/ccxt')
const { ORDER_TYPES } = require('./consts') 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_PRICE_PRECISION = 2
const DEFAULT_AMOUNT_PRECISION = 8 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 const { USER_REF, loadOptions, loadConfig = _.noop, REQUIRED_CONFIG_FIELDS, ORDER_TYPE, AMOUNT_PRECISION } = exchangeConfig
if (!isConfigValid(account, REQUIRED_CONFIG_FIELDS)) throw Error('Invalid config') 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 precision = _.defaultTo(DEFAULT_AMOUNT_PRECISION, AMOUNT_PRECISION)
const amount = coinUtils.toUnit(cryptoAtoms, cryptoCode).toFixed(precision) const amount = coinUtils.toUnit(cryptoAtoms, cryptoCode).toFixed(precision)
const accountOptions = _.isFunction(loadOptions) ? loadOptions(account) : {} const accountOptions = _.isFunction(loadOptions) ? loadOptions(account) : {}
@ -50,4 +55,36 @@ function calculatePrice (side, amount, orderBook) {
throw new Error('Insufficient market depth') throw new Error('Insufficient market depth')
} }
module.exports = { trade } function _getMarkets (exchangeName, 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, availableCryptos) && _.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 { BTC, BCH, DASH, ETH, LTC, USDT, TRX, USDT_TRON, LN } = COINS
const CRYPTO = [BTC, ETH, LTC, DASH, BCH, USDT, TRX, USDT_TRON, LN] const CRYPTO = [BTC, ETH, LTC, DASH, BCH, USDT, TRX, USDT_TRON, LN]
const FIAT = ['USD', 'EUR'] 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 loadConfig = (account) => {
const mapper = { const mapper = {
@ -17,4 +18,4 @@ const loadConfig = (account) => {
return { ...mapped, timeout: 3000 } 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 { BTC, ETH, USDT, LN } = COINS
const CRYPTO = [BTC, ETH, USDT, LN] const CRYPTO = [BTC, ETH, USDT, LN]
const FIAT = ['USD'] const FIAT = ['USD']
const DEFAULT_FIAT_MARKET = 'USD'
const AMOUNT_PRECISION = 4 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 loadConfig = (account) => {
const mapper = { const mapper = {
@ -21,4 +22,4 @@ const loadConfig = (account) => {
} }
const loadOptions = ({ walletId }) => ({ walletId }) 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 { 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 CRYPTO = [BTC, ETH, LTC, DASH, ZEC, BCH, XMR, USDT, TRX, USDT_TRON, LN]
const FIAT = ['USD', 'EUR'] const FIAT = ['USD', 'EUR']
const DEFAULT_FIAT_MARKET = 'EUR'
const AMOUNT_PRECISION = 6 const AMOUNT_PRECISION = 6
const REQUIRED_CONFIG_FIELDS = ['apiKey', 'privateKey'] const REQUIRED_CONFIG_FIELDS = ['apiKey', 'privateKey', 'currencyMarket']
const USER_REF = 'userref' const USER_REF = 'userref'
const loadConfig = (account) => { const loadConfig = (account) => {
@ -26,4 +27,4 @@ const loadConfig = (account) => {
const loadOptions = () => ({ expiretm: '+60' }) 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

@ -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

@ -1,8 +1,13 @@
import { Box } from '@material-ui/core'
import MAutocomplete from '@material-ui/lab/Autocomplete' import MAutocomplete from '@material-ui/lab/Autocomplete'
import sort from 'match-sorter' import sort from 'match-sorter'
import * as R from 'ramda' import * as R from 'ramda'
import React from 'react' import React from 'react'
import { HoverableTooltip } from 'src/components/Tooltip'
import { P } from 'src/components/typography'
import { errorColor, orangeYellow, spring4 } from 'src/styling/variables'
import TextInput from './TextInput' import TextInput from './TextInput'
const Autocomplete = ({ const Autocomplete = ({
@ -95,6 +100,39 @@ const Autocomplete = ({
/> />
) )
}} }}
renderOption={props => {
if (!props.warning && !props.warningMessage)
return R.path([labelProp])(props)
const warningColors = {
clean: spring4,
partial: orangeYellow,
important: errorColor
}
const hoverableElement = (
<Box
width={18}
height={18}
borderRadius={6}
bgcolor={warningColors[props.warning]}
/>
)
return (
<Box
width="100%"
display="flex"
flexDirection="row"
justifyContent="space-between"
alignItems="center">
<Box>{R.path([labelProp])(props)}</Box>
<HoverableTooltip parentElements={hoverableElement} width={250}>
<P>{props.warningMessage}</P>
</HoverableTooltip>
</Box>
)
}}
/> />
) )
} }

View file

@ -12,7 +12,7 @@ import SingleRowTable from 'src/components/single-row-table/SingleRowTable'
import { formatLong } from 'src/utils/string' import { formatLong } from 'src/utils/string'
import FormRenderer from './FormRenderer' import FormRenderer from './FormRenderer'
import schemas from './schemas' import _schemas from './schemas'
const GET_INFO = gql` const GET_INFO = gql`
query getData { query getData {
@ -21,6 +21,12 @@ const GET_INFO = gql`
} }
` `
const GET_MARKETS = gql`
query getMarkets {
getMarkets
}
`
const SAVE_ACCOUNT = gql` const SAVE_ACCOUNT = gql`
mutation Save($accounts: JSONObject) { mutation Save($accounts: JSONObject) {
saveAccounts(accounts: $accounts) saveAccounts(accounts: $accounts)
@ -40,12 +46,17 @@ const useStyles = makeStyles(styles)
const Services = () => { const Services = () => {
const [editingSchema, setEditingSchema] = useState(null) const [editingSchema, setEditingSchema] = useState(null)
const { data } = useQuery(GET_INFO) const { data, loading: configLoading } = useQuery(GET_INFO)
const { data: marketsData, loading: marketsLoading } = useQuery(GET_MARKETS)
const [saveAccount] = useMutation(SAVE_ACCOUNT, { const [saveAccount] = useMutation(SAVE_ACCOUNT, {
onCompleted: () => setEditingSchema(null), onCompleted: () => setEditingSchema(null),
refetchQueries: ['getData'] refetchQueries: ['getData']
}) })
const markets = marketsData?.getMarkets
const schemas = _schemas(markets)
const classes = useStyles() const classes = useStyles()
const accounts = data?.accounts ?? {} const accounts = data?.accounts ?? {}
@ -101,40 +112,44 @@ const Services = () => {
const getValidationSchema = ({ code, getValidationSchema }) => const getValidationSchema = ({ code, getValidationSchema }) =>
getValidationSchema(accounts[code]) getValidationSchema(accounts[code])
const loading = marketsLoading || configLoading
return ( return (
<div className={classes.wrapper}> !loading && (
<TitleSection title="Third-Party services" /> <div className={classes.wrapper}>
<Grid container spacing={4}> <TitleSection title="Third-Party services" />
{R.values(schemas).map(schema => ( <Grid container spacing={4}>
<Grid item key={schema.code}> {R.values(schemas).map(schema => (
<SingleRowTable <Grid item key={schema.code}>
editMessage={'Configure ' + schema.title} <SingleRowTable
title={schema.title} editMessage={'Configure ' + schema.title}
onEdit={() => setEditingSchema(schema)} title={schema.title}
items={getItems(schema.code, schema.elements)} onEdit={() => setEditingSchema(schema)}
items={getItems(schema.code, schema.elements)}
/>
</Grid>
))}
</Grid>
{editingSchema && (
<Modal
title={`Edit ${editingSchema.name}`}
width={525}
handleClose={() => setEditingSchema(null)}
open={true}>
<FormRenderer
save={it =>
saveAccount({
variables: { accounts: { [editingSchema.code]: it } }
})
}
elements={getElements(editingSchema)}
validationSchema={getValidationSchema(editingSchema)}
value={getAccounts(editingSchema)}
/> />
</Grid> </Modal>
))} )}
</Grid> </div>
{editingSchema && ( )
<Modal
title={`Edit ${editingSchema.name}`}
width={525}
handleClose={() => setEditingSchema(null)}
open={true}>
<FormRenderer
save={it =>
saveAccount({
variables: { accounts: { [editingSchema.code]: it } }
})
}
elements={getElements(editingSchema)}
validationSchema={getValidationSchema(editingSchema)}
value={getAccounts(editingSchema)}
/>
</Modal>
)}
</div>
) )
} }

View file

@ -1,36 +1,57 @@
import * as Yup from 'yup' import * as Yup from 'yup'
import SecretInputFormik from 'src/components/inputs/formik/SecretInput' import {
import TextInputFormik from 'src/components/inputs/formik/TextInput' SecretInput,
TextInput,
Autocomplete
} from 'src/components/inputs/formik'
import { secretTest } from './helper' import { secretTest, buildCurrencyOptions } from './helper'
export default { const schema = markets => {
code: 'binance', return {
name: 'Binance', code: 'binance',
title: 'Binance (Exchange)', name: 'Binance',
elements: [ title: 'Binance (Exchange)',
{ elements: [
code: 'apiKey', {
display: 'API key', code: 'apiKey',
component: TextInputFormik, display: 'API key',
face: true, component: TextInput,
long: true face: true,
}, long: true
{ },
code: 'privateKey', {
display: 'Private key', code: 'privateKey',
component: SecretInputFormik display: 'Private key',
component: SecretInput
},
{
code: 'currencyMarket',
display: 'Currency market',
component: Autocomplete,
inputProps: {
options: buildCurrencyOptions(markets),
labelProp: 'display',
valueProp: 'code'
},
face: true
}
],
getValidationSchema: account => {
return Yup.object().shape({
apiKey: Yup.string('The API key must be a string')
.max(100, 'The API key is too long')
.required('The API key is required'),
privateKey: Yup.string('The private key must be a string')
.max(100, 'The private key is too long')
.test(secretTest(account?.privateKey, 'private key')),
currencyMarket: Yup.string(
'The currency market must be a string'
).required('The currency market is required')
})
} }
],
getValidationSchema: account => {
return Yup.object().shape({
apiKey: Yup.string('The API key must be a string')
.max(100, 'The API key is too long')
.required('The API key is required'),
privateKey: Yup.string('The private key must be a string')
.max(100, 'The private key is too long')
.test(secretTest(account?.privateKey, 'private key'))
})
} }
} }
export default schema

View file

@ -1,36 +1,57 @@
import * as Yup from 'yup' import * as Yup from 'yup'
import SecretInputFormik from 'src/components/inputs/formik/SecretInput' import {
import TextInputFormik from 'src/components/inputs/formik/TextInput' SecretInput,
TextInput,
Autocomplete
} from 'src/components/inputs/formik'
import { secretTest } from './helper' import { secretTest, buildCurrencyOptions } from './helper'
export default { const schema = markets => {
code: 'binanceus', return {
name: 'Binance.us', code: 'binanceus',
title: 'Binance.us (Exchange)', name: 'Binance.us',
elements: [ title: 'Binance.us (Exchange)',
{ elements: [
code: 'apiKey', {
display: 'API key', code: 'apiKey',
component: TextInputFormik, display: 'API key',
face: true, component: TextInput,
long: true face: true,
}, long: true
{ },
code: 'privateKey', {
display: 'Private key', code: 'privateKey',
component: SecretInputFormik display: 'Private key',
component: SecretInput
},
{
code: 'currencyMarket',
display: 'Currency market',
component: Autocomplete,
inputProps: {
options: buildCurrencyOptions(markets),
labelProp: 'display',
valueProp: 'code'
},
face: true
}
],
getValidationSchema: account => {
return Yup.object().shape({
apiKey: Yup.string('The API key must be a string')
.max(100, 'The API key is too long')
.required('The API key is required'),
privateKey: Yup.string('The private key must be a string')
.max(100, 'The private key is too long')
.test(secretTest(account?.privateKey, 'private key')),
currencyMarket: Yup.string(
'The currency market must be a string'
).required('The currency market is required')
})
} }
],
getValidationSchema: account => {
return Yup.object().shape({
apiKey: Yup.string('The API key must be a string')
.max(100, 'The API key is too long')
.required('The API key is required'),
privateKey: Yup.string('The private key must be a string')
.max(100, 'The private key is too long')
.test(secretTest(account?.privateKey, 'private key'))
})
} }
} }
export default schema

View file

@ -1,36 +1,57 @@
import * as Yup from 'yup' import * as Yup from 'yup'
import SecretInputFormik from 'src/components/inputs/formik/SecretInput' import {
import TextInputFormik from 'src/components/inputs/formik/TextInput' SecretInput,
TextInput,
Autocomplete
} from 'src/components/inputs/formik'
import { secretTest } from './helper' import { secretTest, buildCurrencyOptions } from './helper'
export default { const schema = markets => {
code: 'bitfinex', return {
name: 'Bitfinex', code: 'bitfinex',
title: 'Bitfinex (Exchange)', name: 'Bitfinex',
elements: [ title: 'Bitfinex (Exchange)',
{ elements: [
code: 'key', {
display: 'API Key', code: 'key',
component: TextInputFormik, display: 'API key',
face: true, component: TextInput,
long: true face: true,
}, long: true
{ },
code: 'secret', {
display: 'API Secret', code: 'secret',
component: SecretInputFormik display: 'API secret',
component: SecretInput
},
{
code: 'currencyMarket',
display: 'Currency Market',
component: Autocomplete,
inputProps: {
options: buildCurrencyOptions(markets),
labelProp: 'display',
valueProp: 'code'
},
face: true
}
],
getValidationSchema: account => {
return Yup.object().shape({
key: Yup.string('The API key must be a string')
.max(100, 'The API key is too long')
.required('The API key is required'),
secret: Yup.string('The API secret must be a string')
.max(100, 'The API secret is too long')
.test(secretTest(account?.secret, 'API secret')),
currencyMarket: Yup.string(
'The currency market must be a string'
).required('The currency market is required')
})
} }
],
getValidationSchema: account => {
return Yup.object().shape({
key: Yup.string('The API key must be a string')
.max(100, 'The API key is too long')
.required('The API key is required'),
secret: Yup.string('The API secret must be a string')
.max(100, 'The API secret is too long')
.test(secretTest(account?.secret, 'API secret'))
})
} }
} }
export default schema

View file

@ -1,46 +1,67 @@
import * as Yup from 'yup' import * as Yup from 'yup'
import SecretInputFormik from 'src/components/inputs/formik/SecretInput' import {
import TextInputFormik from 'src/components/inputs/formik/TextInput' SecretInput,
TextInput,
Autocomplete
} from 'src/components/inputs/formik'
import { secretTest } from './helper' import { secretTest, buildCurrencyOptions } from './helper'
export default { const schema = markets => {
code: 'bitstamp', return {
name: 'Bitstamp', code: 'bitstamp',
title: 'Bitstamp (Exchange)', name: 'Bitstamp',
elements: [ title: 'Bitstamp (Exchange)',
{ elements: [
code: 'clientId', {
display: 'Client ID', code: 'clientId',
component: TextInputFormik, display: 'Client ID',
face: true, component: TextInput,
long: true face: true,
}, long: true
{ },
code: 'key', {
display: 'API key', code: 'key',
component: TextInputFormik, display: 'API key',
face: true, component: TextInput,
long: true face: true,
}, long: true
{ },
code: 'secret', {
display: 'API secret', code: 'secret',
component: SecretInputFormik display: 'API secret',
component: SecretInput
},
{
code: 'currencyMarket',
display: 'Currency market',
component: Autocomplete,
inputProps: {
options: buildCurrencyOptions(markets),
labelProp: 'display',
valueProp: 'code'
},
face: true
}
],
getValidationSchema: account => {
return Yup.object().shape({
clientId: Yup.string('The client ID must be a string')
.max(100, 'The client ID is too long')
.required('The client ID is required'),
key: Yup.string('The API key must be a string')
.max(100, 'The API key is too long')
.required('The API key is required'),
secret: Yup.string('The API secret must be a string')
.max(100, 'The API secret is too long')
.test(secretTest(account?.secret, 'API secret')),
currencyMarket: Yup.string(
'The currency market must be a string'
).required('The currency market is required')
})
} }
],
getValidationSchema: account => {
return Yup.object().shape({
clientId: Yup.string('The client ID must be a string')
.max(100, 'The client ID is too long')
.required('The client ID is required'),
key: Yup.string('The API key must be a string')
.max(100, 'The API key is too long')
.required('The API key is required'),
secret: Yup.string('The API secret must be a string')
.max(100, 'The API secret is too long')
.test(secretTest(account?.secret, 'API secret'))
})
} }
} }
export default schema

View file

@ -1,46 +1,67 @@
import * as Yup from 'yup' import * as Yup from 'yup'
import SecretInputFormik from 'src/components/inputs/formik/SecretInput' import {
import TextInputFormik from 'src/components/inputs/formik/TextInput' SecretInput,
TextInput,
Autocomplete
} from 'src/components/inputs/formik'
import { secretTest } from './helper' import { secretTest, buildCurrencyOptions } from './helper'
export default { const schema = markets => {
code: 'cex', return {
name: 'CEX.IO', code: 'cex',
title: 'CEX.IO (Exchange)', name: 'CEX.IO',
elements: [ title: 'CEX.IO (Exchange)',
{ elements: [
code: 'apiKey', {
display: 'API key', code: 'apiKey',
component: TextInputFormik, display: 'API key',
face: true, component: TextInput,
long: true face: true,
}, long: true
{ },
code: 'uid', {
display: 'User ID', code: 'uid',
component: TextInputFormik, display: 'User ID',
face: true, component: TextInput,
long: true face: true,
}, long: true
{ },
code: 'privateKey', {
display: 'Private key', code: 'privateKey',
component: SecretInputFormik display: 'Private key',
component: SecretInput
},
{
code: 'currencyMarket',
display: 'Currency Market',
component: Autocomplete,
inputProps: {
options: buildCurrencyOptions(markets),
labelProp: 'display',
valueProp: 'code'
},
face: true
}
],
getValidationSchema: account => {
return Yup.object().shape({
apiKey: Yup.string('The API key must be a string')
.max(100, 'The API key is too long')
.required('The API key is required'),
uid: Yup.string('The User ID must be a string')
.max(100, 'The User ID is too long')
.required('The User ID is required'),
privateKey: Yup.string('The private key must be a string')
.max(100, 'The private key is too long')
.test(secretTest(account?.privateKey, 'private key')),
currencyMarket: Yup.string(
'The currency market must be a string'
).required('The currency market is required')
})
} }
],
getValidationSchema: account => {
return Yup.object().shape({
apiKey: Yup.string('The API key must be a string')
.max(100, 'The API key is too long')
.required('The API key is required'),
uid: Yup.string('The User ID must be a string')
.max(100, 'The User ID is too long')
.required('The User ID is required'),
privateKey: Yup.string('The private key must be a string')
.max(100, 'The private key is too long')
.test(secretTest(account?.privateKey, 'private key'))
})
} }
} }
export default schema

View file

@ -1,5 +1,12 @@
import { ALL_CRYPTOS } from '@lamassu/coins'
import * as R from 'ramda' import * as R from 'ramda'
const WARNING_LEVELS = {
CLEAN: 'clean',
PARTIAL: 'partial',
IMPORTANT: 'important'
}
const secretTest = (secret, message) => ({ const secretTest = (secret, message) => ({
name: 'secret-test', name: 'secret-test',
message: message ? `The ${message} is invalid` : 'Invalid field', message: message ? `The ${message} is invalid` : 'Invalid field',
@ -21,4 +28,35 @@ const leadingZerosTest = (value, context) => {
return true return true
} }
export { secretTest, leadingZerosTest } const buildCurrencyOptions = markets => {
return R.map(it => {
const unavailableCryptos = R.difference(ALL_CRYPTOS, markets[it])
const unavailableCryptosFiltered = R.difference(unavailableCryptos, [it]) // As the markets can have stablecoins to trade against other crypto, filter them out, as there can't be pairs such as USDT/USDT
const unavailableMarketsStr =
R.length(unavailableCryptosFiltered) > 1
? `${R.join(
', ',
R.slice(0, -1, unavailableCryptosFiltered)
)} and ${R.last(unavailableCryptosFiltered)}`
: unavailableCryptosFiltered[0]
const warningLevel = R.isEmpty(unavailableCryptosFiltered)
? WARNING_LEVELS.CLEAN
: !R.isEmpty(unavailableCryptosFiltered) &&
R.length(unavailableCryptosFiltered) < R.length(ALL_CRYPTOS)
? WARNING_LEVELS.PARTIAL
: WARNING_LEVELS.IMPORTANT
return {
code: R.toUpper(it),
display: R.toUpper(it),
warning: warningLevel,
warningMessage: !R.isEmpty(unavailableCryptosFiltered)
? `No market pairs available for ${unavailableMarketsStr}`
: `All market pairs are available`
}
}, R.keys(markets))
}
export { secretTest, leadingZerosTest, buildCurrencyOptions }

View file

@ -1,16 +1,16 @@
import binance from './binance' import _binance from './binance'
import binanceus from './binanceus' import _binanceus from './binanceus'
import bitfinex from './bitfinex' import _bitfinex from './bitfinex'
import bitgo from './bitgo' import bitgo from './bitgo'
import bitstamp from './bitstamp' import _bitstamp from './bitstamp'
import blockcypher from './blockcypher' import blockcypher from './blockcypher'
import cex from './cex' import _cex from './cex'
import elliptic from './elliptic' import elliptic from './elliptic'
import galoy from './galoy' import galoy from './galoy'
import inforu from './inforu' import inforu from './inforu'
import infura from './infura' import infura from './infura'
import itbit from './itbit' import _itbit from './itbit'
import kraken from './kraken' import _kraken from './kraken'
import mailgun from './mailgun' import mailgun from './mailgun'
import scorechain from './scorechain' import scorechain from './scorechain'
import sumsub from './sumsub' import sumsub from './sumsub'
@ -19,25 +19,37 @@ import trongrid from './trongrid'
import twilio from './twilio' import twilio from './twilio'
import vonage from './vonage' import vonage from './vonage'
export default { const schemas = (markets = {}) => {
[bitgo.code]: bitgo, const binance = _binance(markets?.binance)
[galoy.code]: galoy, const bitfinex = _bitfinex(markets?.bitfinex)
[bitstamp.code]: bitstamp, const binanceus = _binanceus(markets?.binanceus)
[blockcypher.code]: blockcypher, const bitstamp = _bitstamp(markets?.bitstamp)
[elliptic.code]: elliptic, const cex = _cex(markets?.cex)
[inforu.code]: inforu, const itbit = _itbit(markets?.itbit)
[infura.code]: infura, const kraken = _kraken(markets?.kraken)
[itbit.code]: itbit,
[kraken.code]: kraken, return {
[mailgun.code]: mailgun, [bitgo.code]: bitgo,
[telnyx.code]: telnyx, [galoy.code]: galoy,
[vonage.code]: vonage, [bitstamp.code]: bitstamp,
[twilio.code]: twilio, [blockcypher.code]: blockcypher,
[binanceus.code]: binanceus, [elliptic.code]: elliptic,
[cex.code]: cex, [inforu.code]: inforu,
[scorechain.code]: scorechain, [infura.code]: infura,
[trongrid.code]: trongrid, [itbit.code]: itbit,
[binance.code]: binance, [kraken.code]: kraken,
[bitfinex.code]: bitfinex, [mailgun.code]: mailgun,
[sumsub.code]: sumsub [telnyx.code]: telnyx,
[vonage.code]: vonage,
[twilio.code]: twilio,
[binanceus.code]: binanceus,
[cex.code]: cex,
[scorechain.code]: scorechain,
[trongrid.code]: trongrid,
[binance.code]: binance,
[bitfinex.code]: bitfinex,
[sumsub.code]: sumsub
}
} }
export default schemas

View file

@ -1,54 +1,75 @@
import * as Yup from 'yup' import * as Yup from 'yup'
import SecretInputFormik from 'src/components/inputs/formik/SecretInput' import {
import TextInputFormik from 'src/components/inputs/formik/TextInput' SecretInput,
TextInput,
Autocomplete
} from 'src/components/inputs/formik'
import { secretTest } from './helper' import { buildCurrencyOptions, secretTest } from './helper'
export default { const schema = markets => {
code: 'itbit', return {
name: 'itBit', code: 'itbit',
title: 'itBit (Exchange)', name: 'itBit',
elements: [ title: 'itBit (Exchange)',
{ elements: [
code: 'userId', {
display: 'User ID', code: 'userId',
component: TextInputFormik, display: 'User ID',
face: true, component: TextInput,
long: true face: true,
}, long: true
{ },
code: 'walletId', {
display: 'Wallet ID', code: 'walletId',
component: TextInputFormik, display: 'Wallet ID',
face: true, component: TextInput,
long: true face: true,
}, long: true
{ },
code: 'clientKey', {
display: 'Client key', code: 'clientKey',
component: TextInputFormik display: 'Client key',
}, component: TextInput
{ },
code: 'clientSecret', {
display: 'Client secret', code: 'clientSecret',
component: SecretInputFormik display: 'Client secret',
component: SecretInput
},
{
code: 'currencyMarket',
display: 'Currency market',
component: Autocomplete,
inputProps: {
options: buildCurrencyOptions(markets),
labelProp: 'display',
valueProp: 'code'
},
face: true
}
],
getValidationSchema: account => {
return Yup.object().shape({
userId: Yup.string('The user ID must be a string')
.max(100, 'The user ID is too long')
.required('The user ID is required'),
walletId: Yup.string('The wallet ID must be a string')
.max(100, 'The wallet ID is too long')
.required('The wallet ID is required'),
clientKey: Yup.string('The client key must be a string')
.max(100, 'The client key is too long')
.required('The client key is required'),
clientSecret: Yup.string('The client secret must be a string')
.max(100, 'The client secret is too long')
.test(secretTest(account?.clientSecret, 'client secret')),
currencyMarket: Yup.string(
'The currency market must be a string'
).required('The currency market is required')
})
} }
],
getValidationSchema: account => {
return Yup.object().shape({
userId: Yup.string('The user ID must be a string')
.max(100, 'The user ID is too long')
.required('The user ID is required'),
walletId: Yup.string('The wallet ID must be a string')
.max(100, 'The wallet ID is too long')
.required('The wallet ID is required'),
clientKey: Yup.string('The client key must be a string')
.max(100, 'The client key is too long')
.required('The client key is required'),
clientSecret: Yup.string('The client secret must be a string')
.max(100, 'The client secret is too long')
.test(secretTest(account?.clientSecret, 'client secret'))
})
} }
} }
export default schema

View file

@ -1,36 +1,57 @@
import * as Yup from 'yup' import * as Yup from 'yup'
import SecretInputFormik from 'src/components/inputs/formik/SecretInput' import {
import TextInputFormik from 'src/components/inputs/formik/TextInput' SecretInput,
TextInput,
Autocomplete
} from 'src/components/inputs/formik'
import { secretTest } from './helper' import { secretTest, buildCurrencyOptions } from './helper'
export default { const schema = markets => {
code: 'kraken', return {
name: 'Kraken', code: 'kraken',
title: 'Kraken (Exchange)', name: 'Kraken',
elements: [ title: 'Kraken (Exchange)',
{ elements: [
code: 'apiKey', {
display: 'API key', code: 'apiKey',
component: TextInputFormik, display: 'API key',
face: true, component: TextInput,
long: true face: true,
}, long: true
{ },
code: 'privateKey', {
display: 'Private key', code: 'privateKey',
component: SecretInputFormik display: 'Private key',
component: SecretInput
},
{
code: 'currencyMarket',
display: 'Currency market',
component: Autocomplete,
inputProps: {
options: buildCurrencyOptions(markets),
labelProp: 'display',
valueProp: 'code'
},
face: true
}
],
getValidationSchema: account => {
return Yup.object().shape({
apiKey: Yup.string('The API key must be a string')
.max(100, 'The API key is too long')
.required('The API key is required'),
privateKey: Yup.string('The private key must be a string')
.max(100, 'The private key is too long')
.test(secretTest(account?.privateKey, 'private key')),
currencyMarket: Yup.string(
'The currency market must be a string'
).required('The currency market is required')
})
} }
],
getValidationSchema: account => {
return Yup.object().shape({
apiKey: Yup.string('The API key must be a string')
.max(100, 'The API key is too long')
.required('The API key is required'),
privateKey: Yup.string('The private key must be a string')
.max(100, 'The private key is too long')
.test(secretTest(account?.privateKey, 'private key'))
})
} }
} }
export default schema

View file

@ -32,6 +32,9 @@ const mistyRose = '#ffeceb'
const pumpkin = '#ff7311' const pumpkin = '#ff7311'
const linen = '#fbf3ec' const linen = '#fbf3ec'
// Warning
const orangeYellow = '#ffcc00'
// Color Variables // Color Variables
const primaryColor = zodiac const primaryColor = zodiac
@ -136,6 +139,7 @@ export {
java, java,
neon, neon,
linen, linen,
orangeYellow,
// named colors // named colors
primaryColor, primaryColor,
secondaryColor, secondaryColor,