diff --git a/.dockerignore b/.dockerignore index b2196be..cd75aae 100644 --- a/.dockerignore +++ b/.dockerignore @@ -6,4 +6,7 @@ npm-debug.log *.env *.env.* # except the example .env.example -!.env.example \ No newline at end of file +!.env.example + +# Gateway API files +*.pem diff --git a/.env.example b/.env.example index 259452b..b7a96c6 100644 --- a/.env.example +++ b/.env.example @@ -7,25 +7,31 @@ PORT=5000 # ipv6 format for locahost ["::ffff:127.0.0.1", "::ffff:1", "fe80::1", "::1"] IP_WHITELIST= +HUMMINGBOT_CLIENT_ID={client_id} + # Celo # Terra -TERRA_LCD_URL=https://tequila-lcd.terra.dev -TERRA_CHAIN=tequila-0004 +# - mainnet: https://lcd.terra.dev +# - mainnet chain: columbus-4 +# - testnet: https://tequila-lcd.terra.dev +# - testnet chain: tequila-0004 +TERRA_LCD_URL={testnet_lcd_url} +TERRA_CHAIN={testnet_chain_id} # Ethereum -# - network: mainnet, kovan, etc +# - chain: mainnet, kovan, etc # - rpc url: infura or other rpc url -ETHEREUM CHAIN={network} -ETHEREUM_RPC_URL=https://{network}.infura.io/v3/{api_key} +ETHEREUM_CHAIN={chain} +ETHEREUM_RPC_URL=https://{chain}.infura.io/v3/{api_key} # Balancer -# subgraph_network +# subgraph_chain # Reference: https://docs.balancer.finance/sor/development#subgraph # - mainnet: balancer # - kovan: balancer-kovan # Note: REACT_APP_SUBGRAPH_URL used by @balancer-labs/sor -REACT_APP_SUBGRAPH_URL=https://api.thegraph.com/subgraphs/name/balancer-labs/{subgraph_network} +REACT_APP_SUBGRAPH_URL=https://api.thegraph.com/subgraphs/name/balancer-labs/{subgraph_chain} # exchange_proxy: # Reference: https://docs.balancer.finance/smart-contracts/addresses diff --git a/.gitignore b/.gitignore index ff3f6d6..8a4f589 100644 --- a/.gitignore +++ b/.gitignore @@ -21,7 +21,6 @@ npm-debug.log dist/ # cert -certs/ *.pem *.srl *.key diff --git a/Dockerfile b/Dockerfile index cb27280..718af71 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,8 @@ FROM node:10.22.0-alpine +# Set labels +LABEL application="gateway-api" + # app directory WORKDIR /usr/src/app diff --git a/README.md b/README.md index 723c745..01aa595 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,8 @@ We created hummingbot to promote **decentralized market-making**: enabling membe ### Install Hummingbot -- [Installation](https://docs.hummingbot.io/installation/overview/) +- [Quickstart guide](https://docs.hummingbot.io/quickstart/) +- [All installation options](https://docs.hummingbot.io/installation/overview/) ### Get support - Chat with our support team on [Discord](https://discord.hummingbot.io) diff --git a/certs/readme.md b/certs/readme.md index 4fa6b9d..8d9f12b 100644 --- a/certs/readme.md +++ b/certs/readme.md @@ -1 +1 @@ -certs dir \ No newline at end of file +certs dir for local testing only \ No newline at end of file diff --git a/setup.md b/setup.md index a7d189e..3340dce 100644 --- a/setup.md +++ b/setup.md @@ -9,7 +9,7 @@ This can be used as a common API server to handle transactions that requires cus ## Development Requirements - NodeJS - - Tested on Node v10.22.0 + - Tested on Node v10.22.1 - https://docs.npmjs.com/downloading-and-installing-node-js-and-npm ```bash diff --git a/src/app.js b/src/app.js index a98af7b..910e3c9 100644 --- a/src/app.js +++ b/src/app.js @@ -3,6 +3,7 @@ import bodyParser from 'body-parser' import express from 'express' import helmet from 'helmet' import { statusMessages } from './services/utils'; +import { validateAccess } from './services/access'; import { IpFilter } from 'express-ipfilter' // Routes @@ -35,23 +36,18 @@ if (ipWhitelist) { app.use(bodyParser.json()); app.use(bodyParser.urlencoded({ extended: true })); +app.use(validateAccess) + // mount all routes to this path -app.use('/api', apiRoutes); -app.use('/eth', ethRoutes); -// app.use('/celo', celoRoutes); -app.use('/terra', terraRoutes); -app.use('/balancer', balancerRoutes); -app.use('/uniswap', uniswapRoutes); +app.use('/uniswap', validateAccess, uniswapRoutes); +app.use('/api', validateAccess, apiRoutes); +app.use('/eth', validateAccess, ethRoutes); +// app.use('/celo', validateAccess, celoRoutes); +app.use('/terra', validateAccess, terraRoutes); +app.use('/balancer', validateAccess, balancerRoutes); app.get('/', (req, res, next) => { - const cert = req.connection.getPeerCertificate() - if (req.client.authorized) { - next() - } else if (cert.subject) { - res.status(403).send({ error: statusMessages.ssl_cert_invalid }) - } else { - res.status(401).send({ error: statusMessages.ssl_cert_required }) - } + res.send('ok') }) /** diff --git a/src/index.js b/src/index.js index 083fbb5..a3c9aab 100644 --- a/src/index.js +++ b/src/index.js @@ -20,6 +20,7 @@ const env = process.env.NODE_ENV const port = process.env.PORT const certPassphrase = process.env.CERT_PASSPHRASE const ethereumChain = process.env.ETHEREUM_CHAIN +const terraChain = process.env.TERRA_CHAIN let certPath = process.env.CERT_PATH if ((typeof certPath === 'undefined' && certPath == null) || certPath === '') { @@ -79,4 +80,6 @@ server.listen(port) server.on('error', onError) server.on('listening', onListening) -console.log('server: gateway-api | port:', port, '| ethereum-chain:', ethereumChain); +console.log('server: gateway-api | port:', port) +console.log(' - ethereum-chain:', ethereumChain) +console.log(' - terra-chain:', terraChain) diff --git a/src/routes/balancer.route.js b/src/routes/balancer.route.js index cd213e8..f5bb4eb 100644 --- a/src/routes/balancer.route.js +++ b/src/routes/balancer.route.js @@ -3,9 +3,10 @@ import { ethers } from 'ethers'; import express from 'express'; import { getParamData, latency, reportConnectionError, statusMessages } from '../services/utils'; + import Balancer from '../services/balancer'; -require('dotenv').config() +// require('dotenv').config() const debug = require('debug')('router') const router = express.Router() @@ -15,17 +16,6 @@ const denomMultiplier = 1e18 const swapMoreThanMaxPriceError = 'Price too high' const swapLessThanMaxPriceError = 'Price too low' -router.use((req, res, next) => { - const cert = req.connection.getPeerCertificate() - if (req.client.authorized) { - next() - } else if (cert.subject) { - res.status(403).send({ error: statusMessages.ssl_cert_invalid }) - } else { - res.status(401).send({ error: statusMessages.ssl_cert_required }) - } -}) - router.post('/', async (req, res) => { /* POST / @@ -34,7 +24,8 @@ router.post('/', async (req, res) => { network: balancer.network, provider: balancer.provider.connection.url, exchangeProxy: balancer.exchangeProxy, - subgraphUrl: process.env.REACT_APP_SUBGRAPH_URL, + subgraphUrl: balancer.subgraphUrl, + gasLimit: balancer.gasLimit, connection: true, timestamp: Date.now(), }) @@ -47,7 +38,7 @@ router.post('/sell-price', async (req, res) => { "quote":"0x....." "base":"0x....." "amount":0.1 - "swaps": 4 (optional) + "maxSwaps":4 } */ const initTime = Date.now() @@ -105,6 +96,7 @@ router.post('/buy-price', async (req, res) => { "quote":"0x....." "base":"0x....." "amount":0.1 + "maxSwaps":4 } */ const initTime = Date.now() @@ -163,6 +155,7 @@ router.post('/sell', async (req, res) => { "amount":0.1 "minPrice":1 "gasPrice":10 + "maxSwaps":4 "privateKey":{{privateKey}} } */ @@ -214,8 +207,6 @@ router.post('/sell', async (req, res) => { gasPrice, ) - debug(txObj) - // submit response res.status(200).json({ network: balancer.network, @@ -256,6 +247,7 @@ router.post('/buy', async (req, res) => { "amount":0.1 "maxPrice":1 "gasPrice":10 + "maxSwaps":4 "privateKey":{{privateKey}} } */ diff --git a/src/routes/index.route.js b/src/routes/index.route.js index d69541d..ac1cc57 100644 --- a/src/routes/index.route.js +++ b/src/routes/index.route.js @@ -4,20 +4,10 @@ const express = require('express'); const router = express.Router(); -router.use((req, res, next) => { - const cert = req.connection.getPeerCertificate() - if (req.client.authorized) { - next() - } else if (cert.subject) { - res.status(403).send({ error: statusMessages.ssl_cert_invalid }) - } else { - res.status(401).send({ error: statusMessages.ssl_cert_required }) - } -}) - router.get('/', (req, res) => { res.status(200).json({ app: process.env.APPNAME, + image: process.env.IMAGE, status: 'ok', }); }) diff --git a/src/routes/terra.route.js b/src/routes/terra.route.js index b29edaa..74594e9 100644 --- a/src/routes/terra.route.js +++ b/src/routes/terra.route.js @@ -1,281 +1,198 @@ 'use strict' import express from 'express' -import BigNumber from 'bignumber.js' -import { LCDClient, MnemonicKey, Coin, MsgSwap } from '@terra-money/terra.js' -import { getParamData, getSymbols, latency, reportConnectionError, statusMessages } from '../services/utils'; +import { getParamData, latency, reportConnectionError, statusMessages } from '../services/utils'; -const router = express.Router(); -const debug = require('debug')('router') - -const TerraTokens = { - LUNA: { denom: 'uluna' }, - UST: { denom: 'uusd' }, - KRT: { denom: 'ukrw' }, - SDT: { denom: 'usdr' }, - MNT: { denom: 'umnt' }, -} - -const getTerraSymbol = (denom) => { - let symbol - Object.keys(TerraTokens).forEach((item) => { - if (TerraTokens[item].denom === denom) { - symbol = item - } - }) - return symbol -} - -const denomUnitMultiplier = BigNumber('1e+6') - -const getTxAttributes = (attributes) => { - let attrib = {} - console.log(attributes) - attributes.forEach((item) => { - console.log(item) - attrib[item.key] = item.value - }) - return attrib -} - -// load environment config -const network = 'terra' -const lcdUrl = process.env.TERRA_LCD_URL; -const chain = process.env.TERRA_CHAIN; +import Terra from '../services/terra'; -/** - * Connect to network - */ -const connect = () => { - const terra = new LCDClient({ - URL: lcdUrl, - chainID: chain, - }) - - terra.market.parameters().catch(() => { - throw new Error('Connection error') - }) - - return terra -} +const debug = require('debug')('router') +const router = express.Router(); +const terra = new Terra() -router.use((req, res, next) => { - const cert = req.connection.getPeerCertificate() - if (req.client.authorized) { - next() - } else if (cert.subject) { - res.status(403).send({ error: statusMessages.ssl_cert_invalid }) - } else { - res.status(401).send({ error: statusMessages.ssl_cert_required }) - } -}) +// constants +const network = terra.lcd.config.chainID +const denomUnitMultiplier = terra.denomUnitMultiplier -router.get('/', async (req, res) => { +router.post('/', async (req, res) => { /* - GET / + POST / */ - const terra = connect() - - const marketParams = await terra.market.parameters().catch((err) => { - reportConnectionError(res, err) - }) - res.status(200).json({ network: network, - chain: chain, + lcdUrl: terra.lcd.config.URL, + gasPrices: terra.lcd.config.gasPrices, + gasAdjustment: terra.lcd.config.gasAdjustment, connection: true, - timestamp: Date.now(), - market_params: marketParams + timestamp: Date.now() }) }) -router.get('/price', async (req, res) => { +router.post('/balances', async (req, res) => { /* - GET /price?trading_pair=LUNA-UST&trade_type=sell&amount=1.2345 + POST: + address:{{address}} */ const initTime = Date.now() - const keyFormat = ['trading_pair', 'trade_type', 'amount'] - - const paramData = getParamData(req.query, keyFormat) - const tradingPair = paramData.trading_pair - const requestAmount = paramData.amount - const amount = parseFloat(requestAmount) * denomUnitMultiplier - debug('params', req.params) - debug('paramData', paramData) - const terra = connect() - const exchangeRates = await terra.oracle.exchangeRates().catch((err) => { - reportConnectionError(res, err) - }); + const paramData = getParamData(req.body) + const address = paramData.address + debug(paramData) - const symbols = getSymbols(tradingPair) - const symbolsKeys = Object.keys(symbols) - let price + let balances = {} - if (symbolsKeys.includes('LUNA')) { - let targetSymbol - if (symbolsKeys.includes('UST')) { - targetSymbol = TerraTokens.UST.denom - } else if (symbolsKeys.includes('KRT')) { - targetSymbol = TerraTokens.KRT.denom - } else if (symbolsKeys.includes('SDT')) { - targetSymbol = TerraTokens.SDT.denom + try { + await terra.lcd.bank.balance(address).then(bal => { + bal.toArray().forEach(async (x) => { + const item = x.toData() + const denom = item.denom + const amount = item.amount / denomUnitMultiplier + const symbol = terra.tokens[denom].symbol + balances[symbol] = amount + }) + }) + res.status(200).json({ + network: network, + timestamp: initTime, + latency: latency(initTime, Date.now()), + balances: balances, + }) + } catch (err) { + let message + let reason + err.reason ? reason = err.reason : reason = statusMessages.operation_error + const isAxiosError = err.isAxiosError + if (isAxiosError) { + reason = err.response.status + message = err.response.statusText + } else { + message = err } - price = exchangeRates.get(targetSymbol) * amount - } else { - // get the current swap rate - const baseDenom = TerraTokens[symbols.base].denom - const quoteDenom = TerraTokens[symbols.quote].denom - - const offerCoin = new Coin(baseDenom, amount); - await terra.market.swapRate(offerCoin, quoteDenom).then(swapCoin => { - price = Number(swapCoin.amount) / denomUnitMultiplier - }).catch((err) => { - reportConnectionError(res, err) + res.status(500).json({ + error: reason, + message: message }) } - - const result = Object.assign(paramData, { - price: price, - timestamp: initTime, - latency: latency(initTime, Date.now()) - }) - res.status(200).json(result) }) -router.get('/balance', async (req, res) => { +router.post('/price', async (req, res) => { /* - GET: /balance?address=0x87A4...b120 + POST: + x-www-form-urlencoded: { + "base":"UST" + "quote":"KRT" + "trade_type":"buy" or "sell" + "amount":1 + } */ - const keyFormat = ['address'] - const paramData = getParamData(req.query, keyFormat) - const address = paramData.address - debug(paramData) + const initTime = Date.now() - const terra = connect() + const paramData = getParamData(req.body) + const baseToken = paramData.base + const quoteToken = paramData.quote + const tradeType = paramData.trade_type + const amount = parseFloat(paramData.amount) - let balance = {} - let txSuccess, message + let exchangeRate try { - await terra.bank.balance(address).then(bal => { - bal.toArray().forEach((x) => { - const item = x.toData() - const denom = item.denom - const amount = item.amount / denomUnitMultiplier - const symbol = getTerraSymbol(denom) - balance[symbol] = amount - }) + await terra.getSwapRate(baseToken, quoteToken, amount, tradeType).then((rate) => { + exchangeRate = rate + }).catch((err) => { + reportConnectionError(res, err) }) + + res.status(200).json( + { + network: network, + timestamp: initTime, + latency: latency(initTime, Date.now()), + base: baseToken, + quote: quoteToken, + amount: amount, + tradeType: tradeType, + price: exchangeRate.price.amount, + cost: exchangeRate.cost.amount, + txFee: exchangeRate.txFee.amount, + } + ) } catch (err) { - txSuccess = false + let message + let reason + err.reason ? reason = err.reason : reason = statusMessages.operation_error const isAxiosError = err.isAxiosError if (isAxiosError) { - const status = err.response.status - const statusText = err.response.statusText - message = { error: statusText, status: status, data: err.response.data } + reason = err.response.status + message = err.response.statusText } else { - message = err.status + message = err } + res.status(500).json({ + error: reason, + message: message + }) } - - res.status(200).json({ - success: txSuccess, - address: address, - balance: balance, - timestamp: Date.now(), - message: message - }) }) router.post('/trade', async (req, res) => { /* POST: /trade data: { - "trading_pair":SDT-KRT - "trade_type": "buy" - "amount": "1.01" - "address": "0x...123" + "base":"UST" + "quote":"KRT" + "trade_type":"buy" or "sell" + "amount":1 "secret": "mysupersecret" } */ - const keyFormat = ['trading_pair', 'trade_type', 'amount', 'address', 'secret'] - const paramData = getParamData(req.body, keyFormat) - const tradeType = paramData.tradeType - const secret = paramData.secret - debug(paramData) - - const terra = connect() - const mk = new MnemonicKey({ - mnemonic: secret, - }); - const wallet = terra.wallet(mk); - const address = wallet.key.accAddress - - // get the current swap rate - const symbols = getSymbols(paramData.trading_pair) - debug('symbols', symbols) - const baseDenom = TerraTokens[symbols.base].denom - const quoteDenom = TerraTokens[symbols.quote].denom - - let offerDenom, swapDenom, swapAmount - swapAmount = paramData.amount * denomUnitMultiplier - if (tradeType === 'sell') { - offerDenom = baseDenom - swapDenom = quoteDenom - } else { - offerDenom = quoteDenom - swapDenom = baseDenom - } - - const offerCoin = new Coin(offerDenom, swapAmount); - debug('base', offerDenom, 'quote', swapDenom) + const initTime = Date.now() - // Create and Sign Transaction - const swap = new MsgSwap(address, offerCoin, swapDenom); - const memo = 'tx: 0802...1520' + const paramData = getParamData(req.body) + const baseToken = paramData.base + const quoteToken = paramData.quote + const tradeType = paramData.trade_type + const amount = parseFloat(paramData.amount) + const gasPrice = parseFloat(paramData.gas_price) || terra.lcd.config.gasPrices.uluna + const gasAdjustment = paramData.gas_adjustment || terra.lcd.config.gasAdjustment + const secret = paramData.secret - let txSuccess, txAttributes, message + let tokenSwaps try { - const tx = await wallet.createAndSignTx({ - msgs: [swap], - memo: memo - }).then(tx => terra.tx.broadcast(tx)).then(result => { - debug(`TX hash: ${result.txhash}`); - txSuccess = true - const txHash = result.txhash - const events = JSON.parse(result.raw_log)[0].events - console.log(events) - const swap = events.find(obj => { - return obj.type === 'swap' - }) - txAttributes = getTxAttributes(swap.attributes) - - message = { - txHash: txHash - } + await terra.swapTokens(baseToken, quoteToken, amount, tradeType, gasPrice, gasAdjustment, secret).then((swap) => { + tokenSwaps = swap + }).catch((err) => { + reportConnectionError(res, err) }) + + const swapResult = { + network: network, + timestamp: initTime, + latency: latency(initTime, Date.now()), + base: baseToken, + tradeType: tradeType, + quote: quoteToken, + amount: amount, + } + Object.assign(swapResult, tokenSwaps); + res.status(200).json( + swapResult + ) } catch (err) { - txSuccess = false + let message + let reason + err.reason ? reason = err.reason : reason = statusMessages.operation_error const isAxiosError = err.isAxiosError if (isAxiosError) { - const status = err.response.status - const statusText = err.response.statusText - message = { error: statusText, status: status, data: err.response.data } + reason = err.response.status + message = err.response.statusText } else { - message = err.status + message = err } + res.status(500).json({ + error: reason, + message: message + }) } - - res.status(200).json({ - success: txSuccess, - timestamp: Date.now(), - buy: txAttributes.swap_coin, - sell: txAttributes.offer, - fee: txAttributes.swap_fee, - message: message - }) }) module.exports = router; diff --git a/src/services/access.js b/src/services/access.js new file mode 100644 index 0000000..e35ff81 --- /dev/null +++ b/src/services/access.js @@ -0,0 +1,21 @@ +/* + middleware for validating mutual authentication access +*/ + +import { statusMessages } from './utils'; + +const debug = require('debug')('router') + +export const validateAccess = (req, res, next) => { + const cert = req.connection.getPeerCertificate() + if (req.client.authorized) { + debug('Access granted') + next() + } else if (cert.subject) { + console.log('Error!', statusMessages.ssl_cert_invalid) + res.status(403).send({ error: statusMessages.ssl_cert_invalid }) + } else { + console.log('Error!', statusMessages.ssl_cert_required) + res.status(401).send({ error: statusMessages.ssl_cert_required }) + } +} diff --git a/src/services/balancer.js b/src/services/balancer.js index 9aa3593..cee470b 100644 --- a/src/services/balancer.js +++ b/src/services/balancer.js @@ -7,11 +7,12 @@ const debug = require('debug')('router') // constants const MULTI = '0xeefba1e63905ef1d7acba5a8513c70307c1ce441'; +const MULTI_KOVAN = ' 0x2cc8688C5f75E365aaEEb4ea8D6a480405A48D2A'; const EXCHANGE_PROXY = '0x3E66B66Fd1d0b02fDa6C811Da9E0547970DB2f21'; const EXCHANGE_PROXY_KOVAN = '0x4e67bf5bD28Dd4b570FBAFe11D0633eCbA2754Ec'; const MAX_UINT = ethers.constants.MaxUint256; const MAX_SWAPS = 4; -const GAS_BASE = 200000; +const GAS_BASE = 200688; const GAS_PER_SWAP = 100000; export default class Balancer { @@ -23,9 +24,11 @@ export default class Balancer { switch (network) { case 'mainnet': this.exchangeProxy = EXCHANGE_PROXY; + this.multiCall = MULTI; break; case 'kovan': this.exchangeProxy = EXCHANGE_PROXY_KOVAN; + this.multiCall = MULTI_KOVAN; break; default: throw Error(`Invalid network ${network}`) @@ -41,10 +44,12 @@ export default class Balancer { } console.log('Pools Retrieved.', this.network); + // Get current on-chain data about the fetched pools let poolData if (this.network === 'mainnet') { - poolData = await sor.parsePoolDataOnChain(pools.pools, tokenIn, tokenOut, MULTI, this.provider) + poolData = await sor.parsePoolDataOnChain(pools.pools, tokenIn, tokenOut, this.multiCall, this.provider) } else { + // Kovan multicall throws an ENS error poolData = await sor.parsePoolData(pools.pools, tokenIn, tokenOut) } @@ -86,10 +91,12 @@ export default class Balancer { } console.log('Pools Retrieved.', this.network); + // Get current on-chain data about the fetched pools let poolData if (this.network === 'mainnet') { - poolData = await sor.parsePoolDataOnChain(pools.pools, tokenIn, tokenOut, MULTI, this.provider) + poolData = await sor.parsePoolDataOnChain(pools.pools, tokenIn, tokenOut, this.multiCall, this.provider) } else { + // Kovan multicall throws an ENS error poolData = await sor.parsePoolData(pools.pools, tokenIn, tokenOut) } diff --git a/src/services/eth.js b/src/services/eth.js index 4788792..50cdede 100644 --- a/src/services/eth.js +++ b/src/services/eth.js @@ -2,13 +2,16 @@ require('dotenv').config() const fs = require('fs'); const ethers = require('ethers') const abi = require('../static/abi') +const debug = require('debug')('router') + +// constants export default class Ethereum { constructor (network = 'kovan') { // network defaults to kovan const providerUrl = process.env.ETHEREUM_RPC_URL - this.network = process.env.BALANCER_NETWORK this.provider = new ethers.providers.JsonRpcProvider(providerUrl) + this.network = network if (network === 'kovan') { // for kovan testing only @@ -62,8 +65,7 @@ export default class Ethereum { } // approve a spender to transfer tokens from a wallet address - async approveERC20 (wallet, spender, tokenAddress, amount, gasPrice = process.env.GAS_PRICE) { - const GAS_LIMIT = 100000 + async approveERC20 (wallet, spender, tokenAddress, amount, gasPrice = this.gasPrice, gasLimit = this.approvalGasLimit) { try { // instantiate a contract and pass in wallet, which act on behalf of that signer const contract = new ethers.Contract(tokenAddress, abi.ERC20Abi, wallet) @@ -71,7 +73,7 @@ export default class Ethereum { spender, amount, { gasPrice: gasPrice * 1e9, - gasLimit: GAS_LIMIT + gasLimit: gasLimit } ) } catch (err) { @@ -81,15 +83,29 @@ export default class Ethereum { } } - async deposit (wallet, tokenAddress, amount, gasPrice = process.env.GAS_PRICE) { - const GAS_LIMIT = 100000 + // get current Gas + async getCurrentGasPrice () { + try { + this.provider.getGasPrice().then(function (gas) { + // gasPrice is a BigNumber; convert it to a decimal string + const gasPrice = gas.toString(); + return gasPrice + }) + } catch (err) { + let reason + err.reason ? reason = err.reason : reason = 'error gas lookup' + return reason + } + } + + async deposit (wallet, tokenAddress, amount, gasPrice = this.gasPrice, gasLimit = this.approvalGasLimit) { // deposit ETH to a contract address try { const contract = new ethers.Contract(tokenAddress, abi.KovanWETHAbi, wallet) return await contract.deposit( { value: amount, gasPrice: gasPrice * 1e9, - gasLimit: GAS_LIMIT + gasLimit: gasLimit } ) } catch (err) { diff --git a/src/services/terra.js b/src/services/terra.js new file mode 100644 index 0000000..3c6ac1b --- /dev/null +++ b/src/services/terra.js @@ -0,0 +1,315 @@ +import { LCDClient, Coin, MsgSwap, StdTx, StdFee, Dec, MnemonicKey, isTxError, Coins } from '@terra-money/terra.js' +import BigNumber from 'bignumber.js' +import { getHummingbotMemo } from './utils'; + +require('dotenv').config() +const debug = require('debug')('router') + +// constants +const TERRA_TOKENS = { + uluna: { symbol: 'LUNA' }, + uusd: { symbol: 'UST' }, + ukrw: { symbol: 'KRT' }, + usdr: { symbol: 'SDT' }, + umnt: { symbol: 'MNT' }, +} +const DENOM_UNIT = BigNumber('1e+6') +const TOBIN_TAX = 0.0025 // a Tobin Tax (set at 0.25%) for spot-converting Terra<>Terra swaps +const MIN_SPREAD = 0.02 // a minimum spread (set at 2%) for Terra<>Luna swaps +const GAS_PRICE = { uluna: 0.16 } +const GAS_ADJUSTMENT = 1.4 + +export default class Terra { + constructor () { + this.lcdUrl = process.env.TERRA_LCD_URL; + this.network = process.env.TERRA_CHAIN; + this.tokens = TERRA_TOKENS + this.denomUnitMultiplier = DENOM_UNIT + this.tobinTax = TOBIN_TAX + this.minSpread = MIN_SPREAD + this.memo = getHummingbotMemo() + + try { + this.lcd = this.connect() + + this.lcd.market.parameters().catch(() => { + throw new Error('Connection error') + }) + // set gas & fee + this.lcd.config.gasAdjustment = GAS_ADJUSTMENT + this.lcd.config.gasPrices = GAS_PRICE + } catch (err) { + throw Error(`Connection failed: ${this.network}`) + } + } + + // connect Terra LCD + connect () { + try { + const lcd = new LCDClient({ + URL: this.lcdUrl, + chainID: this.network, + }) + lcd.config.gasAdjustment = GAS_ADJUSTMENT + lcd.config.gasPrices = GAS_PRICE + return lcd + } catch (err) { + let reason + console.log(reason) + err.reason ? reason = err.reason : reason = 'error Terra LCD connect' + return reason + } + } + + // get Token Denom + getTokenDenom (symbol) { + try { + let denom + Object.keys(TERRA_TOKENS).forEach((item) => { + if (TERRA_TOKENS[item].symbol === symbol) { + denom = item + } + }) + return denom + } catch (err) { + let reason + console.log(reason) + err.reason ? reason = err.reason : reason = 'error Terra Denom lookup' + return reason + } + } + + // get Token Symbol + getTokenSymbol (denom) { + try { + const symbol = TERRA_TOKENS[denom].symbol + return symbol + } catch (err) { + let reason + console.log(reason) + err.reason ? reason = err.reason : reason = 'error Terra Denom lookup' + return reason + } + } + + getTxAttributes (attributes) { + let attrib = {} + attributes.forEach((item) => { + attrib[item.key] = item.value + }) + return attrib + } + + async getEstimateFee (tx) { + try { + const fee = await this.lcd.tx.estimateFee(tx) + return fee + } catch (err) { + let reason + console.log(reason) + err.reason ? reason = err.reason : reason = 'error Terra estimate fee lookup' + return reason + } + } + + async getExchangeRate (denom) { + try { + const exchangeRates = await this.lcd.oracle.exchangeRates() + return exchangeRates.get(denom) + } catch (err) { + let reason + console.log(reason) + err.reason ? reason = err.reason : reason = 'error Terra exchange rate lookup' + return reason + } + } + + async getTxFee () { + try { + const lunaFee = GAS_PRICE.uluna * GAS_ADJUSTMENT + let feeList = { uluna: lunaFee } + await this.lcd.oracle.exchangeRates().then(rates => { + Object.keys(rates._coins).forEach(key => { + feeList[key] = rates._coins[key].amount * lunaFee + }) + }) + debug('lunaFee', lunaFee, feeList) + + return feeList + } catch (err) { + let reason + console.log(reason) + err.reason ? reason = err.reason : reason = 'error Terra exchange rate lookup' + return reason + } + } + + // get Terra Swap Rate + async getSwapRate (baseToken, quoteToken, amount, tradeType) { + try { + let exchangeRate, offerCoin, offerDenom, swapDenom, cost, costAmount, offer + let swaps = {} + + if (tradeType.toLowerCase() === 'sell') { + // sell base + offerDenom = this.getTokenDenom(baseToken) + swapDenom = this.getTokenDenom(quoteToken) + + offerCoin = new Coin(offerDenom, amount * DENOM_UNIT); + await this.lcd.market.swapRate(offerCoin, swapDenom).then(swapCoin => { + offer = { amount: amount } + exchangeRate = { + amount: (swapCoin.amount / DENOM_UNIT) / amount, + token: quoteToken + } + costAmount = amount * exchangeRate.amount + cost = { + amount: costAmount, + token: quoteToken + } + }) + } else { + // buy base + offerDenom = this.getTokenDenom(quoteToken) + swapDenom = this.getTokenDenom(baseToken) + + offerCoin = new Coin(offerDenom, 1 * DENOM_UNIT); + await this.lcd.market.swapRate(offerCoin, swapDenom).then(swapCoin => { + exchangeRate = { + amount: (amount / parseInt(swapCoin.amount) * DENOM_UNIT) / amount, // adjusted amount + token: quoteToken + } + costAmount = amount * exchangeRate.amount + cost = { + amount: costAmount, + token: quoteToken + } + offer = { amount: cost.amount } + }) + } + + let txFee + await this.getTxFee().then(fee => { + // fee in quote + txFee = { amount: parseFloat(fee[this.getTokenDenom(quoteToken)]), token: quoteToken } + }) + + swaps.offer = offer + swaps.price = exchangeRate + swaps.cost = cost + swaps.txFee = txFee + debug('swaps', swaps) + return swaps + } catch (err) { + let reason + console.log(reason) + err.reason ? reason = err.reason : reason = 'error swap rate lookup' + return reason + } + } + + // Swap tokens + async swapTokens (baseToken, quoteToken, amount, tradeType, gasPrice, gasAdjustment, secret) { + let swapResult + try { + // connect to lcd + const lcd = this.connect() + + const mk = new MnemonicKey({ + mnemonic: secret, + }); + let wallet + try { + wallet = lcd.wallet(mk); + } catch (err) { + throw Error('Wallet access error') + } + + const address = wallet.key.accAddress + + // get the current swap rate + const baseDenom = this.getTokenDenom(baseToken) + const quoteDenom = this.getTokenDenom(quoteToken) + + let offerDenom, swapDenom + let swaps, txAttributes + let tokenSwap = {} + + if (tradeType.toLowerCase() === 'sell') { + offerDenom = baseDenom + swapDenom = quoteDenom + } else { + offerDenom = quoteDenom + swapDenom = baseDenom + } + + await this.getSwapRate(baseToken, quoteToken, amount, tradeType, secret).then((rate) => { + swaps = rate + }) + + const offerAmount = parseInt((swaps.offer.amount) * DENOM_UNIT) + const offerCoin = new Coin(offerDenom, offerAmount) + // debug('offerCoin', offerCoin, offerAmount, 'gasPrice', gasPrice) + + // Create and Sign Transaction + const msgSwap = new MsgSwap(address, offerCoin, swapDenom); + + let txOptions + if (gasPrice !== null && gasPrice !== null) { // ignore gasAdjustment when gasPrice is not set + txOptions = { + msgs: [msgSwap], + gasPrices: { uluna: parseFloat(gasPrice) }, + gasAdjustment: gasAdjustment, + memo: this.memo + } + } else { + txOptions = { + msgs: [msgSwap], + memo: this.memo + } + } + + await wallet.createAndSignTx(txOptions).then(tx => lcd.tx.broadcast(tx)).then((txResult) => { + swapResult = txResult + + const swapSuccess = !isTxError(txResult) + if (swapSuccess) { + tokenSwap.txSuccess = swapSuccess + } else { + tokenSwap.txSuccess = !swapSuccess + throw new Error(`encountered an error while running the transaction: ${txResult.code} ${txResult.codespace}`); + } + + const txHash = txResult.txhash + const events = JSON.parse(txResult.raw_log)[0].events + const swap = events.find(obj => { + return obj.type === 'swap' + }) + txAttributes = this.getTxAttributes(swap.attributes) + const offer = Coin.fromString(txAttributes.offer) + const ask = Coin.fromString(txAttributes.swap_coin) + const fee = Coin.fromString(txAttributes.swap_fee) + + tokenSwap.expectedIn = { + amount: parseFloat(offer.amount) / DENOM_UNIT, + token: TERRA_TOKENS[offer.denom].symbol + } + tokenSwap.expectedOut = { + amount: parseFloat(ask.amount) / DENOM_UNIT, + token: TERRA_TOKENS[ask.denom].symbol + } + tokenSwap.fee = { + amount: parseFloat(fee.amount) / DENOM_UNIT, + token: TERRA_TOKENS[fee.denom].symbol + } + tokenSwap.txHash = txHash + }) + return tokenSwap + } catch (err) { + let reason + console.log(err) + err.reason ? reason = err.reason : reason = swapResult + return { txSuccess: false, message: reason } + } + } +} diff --git a/src/services/utils.js b/src/services/utils.js index 4095936..133d34d 100644 --- a/src/services/utils.js +++ b/src/services/utils.js @@ -8,6 +8,7 @@ export const statusMessages = { ssl_cert_invalid: 'Invalid SSL Certificate', operation_error: 'Operation Error', no_pool_available: 'No Pool Available', + invalid_token_symbol: 'Invalid Token Symbol', } export const latency = (startTime, endTime) => parseFloat((endTime - startTime) / 1000) @@ -45,6 +46,11 @@ export const getParamData = (data, format = null) => { return dataObject } +export const splitParamData = (param, separator = ',') => { + const dataArray = param.split(separator) + return dataArray +} + export const getSymbols = (tradingPair) => { const symbols = tradingPair.split('-') const baseQuotePair = { @@ -62,3 +68,9 @@ export const reportConnectionError = (res, error) => { } export const strToDecimal = (str) => parseInt(str) / 100; + +export const getHummingbotMemo = () => { + const prefix = 'hbot' + const clientId = process.env.HUMMINGBOT_CLIENT_ID || '' + return [prefix, clientId].join('-') +} diff --git a/test/command.md b/test/command.md new file mode 100644 index 0000000..7ddb10c --- /dev/null +++ b/test/command.md @@ -0,0 +1,3 @@ + +# test endpoint +curl --insecure --key /home/dev/bots/hbot_files/hummingbot_certs/client_key.pem --cert /home/dev/bots/hbot_files/hummingbot_certs/client_cert.pem https://localhost:5000/api diff --git a/test/postman/Gateway-Terra.postman_collection.json b/test/postman/Gateway-Terra.postman_collection.json new file mode 100644 index 0000000..40866b8 --- /dev/null +++ b/test/postman/Gateway-Terra.postman_collection.json @@ -0,0 +1,328 @@ +{ + "info": { + "_postman_id": "3cca9e73-0e1f-4e4f-8973-87c985a43219", + "name": "Gateway-Terra", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "item": [ + { + "name": "terra", + "item": [ + { + "name": "terra/balances", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "urlencoded", + "urlencoded": [ + { + "key": "address", + "value": "{{address}}", + "type": "text" + } + ], + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://localhost:{{port}}/terra/balances", + "protocol": "https", + "host": [ + "localhost" + ], + "port": "{{port}}", + "path": [ + "terra", + "balances" + ] + } + }, + "response": [] + }, + { + "name": "terra/price", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "urlencoded", + "urlencoded": [ + { + "key": "base", + "value": "SDT", + "type": "text" + }, + { + "key": "quote", + "value": "KRT", + "type": "text" + }, + { + "key": "trade_type", + "value": "buy", + "type": "text" + }, + { + "key": "amount", + "value": "3", + "type": "text" + } + ] + }, + "url": { + "raw": "https://localhost:{{port}}/terra/price", + "protocol": "https", + "host": [ + "localhost" + ], + "port": "{{port}}", + "path": [ + "terra", + "price" + ] + } + }, + "response": [ + { + "name": "{network}/quote", + "originalRequest": { + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:5000/{{network}}/quote/trading_pair/{{celo-cusd}}/amount/1", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "5000", + "path": [ + "{{network}}", + "quote", + "trading_pair", + "{{celo-cusd}}", + "amount", + "1" + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Security-Policy", + "value": "default-src 'self';base-uri 'self';block-all-mixed-content;font-src 'self' https: data:;frame-ancestors 'self';img-src 'self' data:;object-src 'none';script-src 'self';script-src-attr 'none';style-src 'self' https: 'unsafe-inline';upgrade-insecure-requests" + }, + { + "key": "X-DNS-Prefetch-Control", + "value": "off" + }, + { + "key": "Expect-CT", + "value": "max-age=0" + }, + { + "key": "X-Frame-Options", + "value": "SAMEORIGIN" + }, + { + "key": "Strict-Transport-Security", + "value": "max-age=15552000; includeSubDomains" + }, + { + "key": "X-Download-Options", + "value": "noopen" + }, + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "X-Permitted-Cross-Domain-Policies", + "value": "none" + }, + { + "key": "Referrer-Policy", + "value": "no-referrer" + }, + { + "key": "X-XSS-Protection", + "value": "0" + }, + { + "key": "Content-Type", + "value": "application/json; charset=utf-8" + }, + { + "key": "Content-Length", + "value": "97" + }, + { + "key": "ETag", + "value": "W/\"61-Wemp9YmP9g/CsUFMa7Y5zK6SoLQ\"" + }, + { + "key": "Date", + "value": "Wed, 23 Sep 2020 18:07:26 GMT" + }, + { + "key": "Connection", + "value": "keep-alive" + } + ], + "cookie": [], + "body": "{\n \"timestamp\": 1600884444051,\n \"latency\": 2.542,\n \"trading_pair\": \"CELO-CUSD\",\n \"price\": 2.5435604641582747\n}" + } + ] + }, + { + "name": "terra/trade", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "urlencoded", + "urlencoded": [ + { + "key": "base", + "value": "SDT", + "type": "text" + }, + { + "key": "quote", + "value": "KRT", + "type": "text" + }, + { + "key": "trade_type", + "value": "buy", + "type": "text" + }, + { + "key": "amount", + "value": "3", + "type": "text" + }, + { + "key": "secret", + "value": "{{secret}}", + "type": "text" + } + ] + }, + "url": { + "raw": "https://localhost:{{port}}/terra/trade", + "protocol": "https", + "host": [ + "localhost" + ], + "port": "{{port}}", + "path": [ + "terra", + "trade" + ] + } + }, + "response": [ + { + "name": "{network}/quote", + "originalRequest": { + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:5000/{{network}}/quote/trading_pair/{{celo-cusd}}/amount/1", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "5000", + "path": [ + "{{network}}", + "quote", + "trading_pair", + "{{celo-cusd}}", + "amount", + "1" + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Security-Policy", + "value": "default-src 'self';base-uri 'self';block-all-mixed-content;font-src 'self' https: data:;frame-ancestors 'self';img-src 'self' data:;object-src 'none';script-src 'self';script-src-attr 'none';style-src 'self' https: 'unsafe-inline';upgrade-insecure-requests" + }, + { + "key": "X-DNS-Prefetch-Control", + "value": "off" + }, + { + "key": "Expect-CT", + "value": "max-age=0" + }, + { + "key": "X-Frame-Options", + "value": "SAMEORIGIN" + }, + { + "key": "Strict-Transport-Security", + "value": "max-age=15552000; includeSubDomains" + }, + { + "key": "X-Download-Options", + "value": "noopen" + }, + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "X-Permitted-Cross-Domain-Policies", + "value": "none" + }, + { + "key": "Referrer-Policy", + "value": "no-referrer" + }, + { + "key": "X-XSS-Protection", + "value": "0" + }, + { + "key": "Content-Type", + "value": "application/json; charset=utf-8" + }, + { + "key": "Content-Length", + "value": "97" + }, + { + "key": "ETag", + "value": "W/\"61-Wemp9YmP9g/CsUFMa7Y5zK6SoLQ\"" + }, + { + "key": "Date", + "value": "Wed, 23 Sep 2020 18:07:26 GMT" + }, + { + "key": "Connection", + "value": "keep-alive" + } + ], + "cookie": [], + "body": "{\n \"timestamp\": 1600884444051,\n \"latency\": 2.542,\n \"trading_pair\": \"CELO-CUSD\",\n \"price\": 2.5435604641582747\n}" + } + ] + } + ], + "protocolProfileBehavior": {} + } + ], + "protocolProfileBehavior": {} +} \ No newline at end of file diff --git a/test/postman/terra.postman_environment.json b/test/postman/terra.postman_environment.json new file mode 100644 index 0000000..735927a --- /dev/null +++ b/test/postman/terra.postman_environment.json @@ -0,0 +1,29 @@ +{ + "id": "aebe7d1f-0e85-4441-8679-2ddc38d74350", + "name": "terra", + "values": [ + { + "key": "protocol", + "value": "terra", + "enabled": true + }, + { + "key": "port", + "value": "5000", + "enabled": true + }, + { + "key": "address", + "value": "myaddresss", + "enabled": true + }, + { + "key": "secret", + "value": "mysupersecret", + "enabled": true + } + ], + "_postman_variable_scope": "environment", + "_postman_exported_at": "2020-11-13T06:00:14.142Z", + "_postman_exported_using": "Postman/7.35.0" +} \ No newline at end of file