From cf6216a1681f9bf2d0d5e1ce83cb19205fda1f76 Mon Sep 17 00:00:00 2001 From: Xaber Date: Mon, 17 Jun 2019 14:54:02 +0800 Subject: [PATCH] First commit --- .gitignore | 8 + LICENSE | 21 ++ README.md | 5 + app/check.js | 4 + app/mmConfig.js | 18 + app/start.js | 4 + package.json | 50 +++ src/check/deal.ts | 27 ++ src/check/index.ts | 46 +++ src/check/indicativePrice.ts | 7 + src/check/pairs.ts | 28 ++ src/check/price.ts | 7 + src/check/priceCheckHelper.ts | 89 +++++ src/config/index.ts | 39 +++ src/constants/index.ts | 11 + src/global.d.ts | 4 + src/index.ts | 2 + src/request/_request.ts | 83 +++++ src/request/imToken/index.ts | 75 ++++ src/request/imToken/interface.ts | 20 ++ src/request/marketMaker/http.ts | 39 +++ src/request/marketMaker/index.ts | 35 ++ src/request/marketMaker/interface.ts | 38 ++ src/request/marketMaker/zerorpc.ts | 37 ++ src/request/mockBinance/index.ts | 9 + src/request/mockBinance/pairs.ts | 10 + src/request/mockBinance/price.ts | 23 ++ src/request/mockBinance/utils/binance.ts | 3 + src/request/mockBinance/utils/price.ts | 350 +++++++++++++++++++ src/request/mockBinance/utils/quoteId.ts | 13 + src/request/mockBinance/utils/symbols.ts | 40 +++ src/request/mockBinance/utils/timestamp.ts | 1 + src/router/getBalance.ts | 28 ++ src/router/getBalances.ts | 27 ++ src/router/getOrderState.ts | 20 ++ src/router/getOrdersHistory.ts | 27 ++ src/router/getRate.ts | 26 ++ src/router/getSupportedTokenList.ts | 11 + src/router/index.ts | 9 + src/router/newOrder.ts | 74 ++++ src/router/reconnect.ts | 31 ++ src/router/version.ts | 6 + src/start.ts | 128 +++++++ src/types/index.ts | 68 ++++ src/utils/address.ts | 14 + src/utils/balance.ts | 16 + src/utils/ethereum.ts | 25 ++ src/utils/format.ts | 34 ++ src/utils/helper.ts | 6 + src/utils/intervalUpdater/index.ts | 68 ++++ src/utils/intervalUpdater/intervalUpdater.ts | 48 +++ src/utils/math.ts | 7 + src/utils/order.ts | 127 +++++++ src/utils/quoteId.ts | 11 + src/utils/rate.ts | 48 +++ src/utils/sign.ts | 144 ++++++++ src/utils/stomp.ts | 159 +++++++++ src/utils/timestamp.ts | 1 + src/utils/token.ts | 57 +++ src/utils/tracker.ts | 45 +++ src/utils/wallet.ts | 12 + src/utils/web3.ts | 13 + src/validations/index.ts | 56 +++ tsconfig.json | 30 ++ tslint.json | 135 +++++++ 65 files changed, 2657 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 app/check.js create mode 100644 app/mmConfig.js create mode 100644 app/start.js create mode 100644 package.json create mode 100644 src/check/deal.ts create mode 100644 src/check/index.ts create mode 100644 src/check/indicativePrice.ts create mode 100644 src/check/pairs.ts create mode 100644 src/check/price.ts create mode 100644 src/check/priceCheckHelper.ts create mode 100644 src/config/index.ts create mode 100644 src/constants/index.ts create mode 100644 src/global.d.ts create mode 100644 src/index.ts create mode 100644 src/request/_request.ts create mode 100644 src/request/imToken/index.ts create mode 100644 src/request/imToken/interface.ts create mode 100644 src/request/marketMaker/http.ts create mode 100644 src/request/marketMaker/index.ts create mode 100644 src/request/marketMaker/interface.ts create mode 100644 src/request/marketMaker/zerorpc.ts create mode 100644 src/request/mockBinance/index.ts create mode 100644 src/request/mockBinance/pairs.ts create mode 100644 src/request/mockBinance/price.ts create mode 100644 src/request/mockBinance/utils/binance.ts create mode 100644 src/request/mockBinance/utils/price.ts create mode 100644 src/request/mockBinance/utils/quoteId.ts create mode 100644 src/request/mockBinance/utils/symbols.ts create mode 100644 src/request/mockBinance/utils/timestamp.ts create mode 100644 src/router/getBalance.ts create mode 100644 src/router/getBalances.ts create mode 100644 src/router/getOrderState.ts create mode 100644 src/router/getOrdersHistory.ts create mode 100644 src/router/getRate.ts create mode 100644 src/router/getSupportedTokenList.ts create mode 100644 src/router/index.ts create mode 100644 src/router/newOrder.ts create mode 100644 src/router/reconnect.ts create mode 100644 src/router/version.ts create mode 100644 src/start.ts create mode 100644 src/types/index.ts create mode 100644 src/utils/address.ts create mode 100644 src/utils/balance.ts create mode 100644 src/utils/ethereum.ts create mode 100644 src/utils/format.ts create mode 100644 src/utils/helper.ts create mode 100644 src/utils/intervalUpdater/index.ts create mode 100644 src/utils/intervalUpdater/intervalUpdater.ts create mode 100644 src/utils/math.ts create mode 100644 src/utils/order.ts create mode 100644 src/utils/quoteId.ts create mode 100644 src/utils/rate.ts create mode 100644 src/utils/sign.ts create mode 100644 src/utils/stomp.ts create mode 100644 src/utils/timestamp.ts create mode 100644 src/utils/token.ts create mode 100644 src/utils/tracker.ts create mode 100644 src/utils/wallet.ts create mode 100644 src/utils/web3.ts create mode 100644 src/validations/index.ts create mode 100644 tsconfig.json create mode 100644 tslint.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ebace73 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.vscode +node_modules +yarn.lock +npm-debug.log +npm-debug.log.* +yarn-error.log +package-lock.json +lib \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7dfdd73 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 ConsenLabs + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..eee8425 --- /dev/null +++ b/README.md @@ -0,0 +1,5 @@ +# tokenlon-mmsk + +See [docs](https://docs.token.im/tokenlon-mmsk/) + +copyright© imToken PTE. LTD. \ No newline at end of file diff --git a/app/check.js b/app/check.js new file mode 100644 index 0000000..e1fda76 --- /dev/null +++ b/app/check.js @@ -0,0 +1,4 @@ +const mmConf = require('./mmConfig') +const mmsk = require('../lib') + +mmsk.checkMMSK(mmConf) diff --git a/app/mmConfig.js b/app/mmConfig.js new file mode 100644 index 0000000..3c3dca7 --- /dev/null +++ b/app/mmConfig.js @@ -0,0 +1,18 @@ +module.exports = { + EXCHANGE_URL: process.env.EXCHANGE_URL, + WEBSOCKET_URL: process.env.WEBSOCKET_URL, + PROVIDER_URL: process.env.PROVIDER_URL, + + WALLET_ADDRESS: process.env.WALLET_ADDRESS, + USE_KEYSTORE: true, + WALLET_KEYSTORE: {}, + // WALLET_PRIVATE_KEY: process.env.WALLET_PRIVATE_KEY, + MMSK_SERVER_PORT: process.env.MMSK_SERVER_PORT || 80, + + USE_ZERORPC: true, + // HTTP_SERVER_ENDPOINT: process.env.HTTP_SERVER_ENDPOINT, + ZERORPC_SERVER_ENDPOINT: process.env.ZERORPC_SERVER_ENDPOINT, + SENTRY_DSN: '', + + NODE_ENV: 'PRODUCTION', +} diff --git a/app/start.js b/app/start.js new file mode 100644 index 0000000..001d14c --- /dev/null +++ b/app/start.js @@ -0,0 +1,4 @@ +const mmConf = require('./mmConfig') +const mmsk = require('../lib') + +mmsk.startMMSK(mmConf) diff --git a/package.json b/package.json new file mode 100644 index 0000000..760d2c3 --- /dev/null +++ b/package.json @@ -0,0 +1,50 @@ +{ + "name": "tokenlon-mmsk", + "version": "0.2.4", + "description": "", + "main": "lib/index.js", + "types": "src/globals.d.ts", + "repository": { + "type": "git", + "url": "git+https://github.com/consenlabs/tokenlon-mmsk.git" + }, + "author": "imToken PTE. LTD.", + "license": "MIT", + "scripts": { + "watch": "tsc -w", + "clean": "rm -rf ./lib", + "build:commonjs": "tsc", + "build": "run-s clean build:commonjs", + "start": "node ./app/start.js", + "check": "node ./app/check.js" + }, + "bugs": { + "url": "https://github.com/consenlabs/tokenlon-mmsk/issues" + }, + "homepage": "https://github.com/consenlabs/tokenlon-mmsk#readme", + "devDependencies": { + "@types/node": "10.11.5", + "npm-run-all": "4.1.5", + "ts-node": "7.0.1", + "tslint": "5.11.0", + "typescript": "3.1.1" + }, + "dependencies": { + "0x.js": "1.0.8", + "@babel/runtime": "7.3.1", + "@sentry/node": "4.5.3", + "axios": "0.18.0", + "babel-polyfill": "6.26.0", + "binance-api-node": "0.8.18", + "keythereum": "1.0.4", + "koa": "2.5.3", + "koa-bodyparser": "4.2.1", + "koa-router": "7.4.0", + "lodash": "4.17.11", + "readline-sync": "1.4.9", + "sockjs-client": "1.3.0", + "stompjs": "2.3.3", + "web3": "1.0.0-beta.37", + "zerorpc": "0.9.8" + } +} diff --git a/src/check/deal.ts b/src/check/deal.ts new file mode 100644 index 0000000..cc2cb07 --- /dev/null +++ b/src/check/deal.ts @@ -0,0 +1,27 @@ +import * as _ from 'lodash' +import { dealOrder } from '../request/marketMaker' +import { getSupportedTokens } from '../utils/token' + +const check = async () => { + try { + const tokenA = getSupportedTokens()[0] + const mockOrder = { + makerToken: tokenA.symbol, + takerToken: tokenA.opposites[0], + makerTokenAmount: 40.19308759, + takerTokenAmount: 0.3, + timestamp: 1551855180, + quoteId: 'assdcjfsdhfdfoesfhafh', + } + const resp = await dealOrder(mockOrder) + if (resp.result !== false) { + return `deal an non-exist order ${JSON.stringify(mockOrder)} but got result not false` + } + } catch (e) { + return `deal API request error ${e.message}` + } + + return '' +} + +export default check \ No newline at end of file diff --git a/src/check/index.ts b/src/check/index.ts new file mode 100644 index 0000000..7903a28 --- /dev/null +++ b/src/check/index.ts @@ -0,0 +1,46 @@ +import 'babel-polyfill' +import { ConfigForStart } from '../types' +import { getWallet } from '../utils/wallet' +import { startUpdater } from '../utils/intervalUpdater' +import { setConfig } from '../config' +import checkPairs from './pairs' +import checkIndicativePrice from './indicativePrice' +import checkPrice from './price' +import checkDeal from './deal' + +export const checkMMSK = async (config: ConfigForStart) => { + const arr = [ + { + title: 'checking Pairs API', + check: checkPairs, + }, + { + title: 'checking indicativePrice API', + check: checkIndicativePrice, + }, + { + title: 'checking price API', + check: checkPrice, + }, + { + title: 'checking deal API', + check: checkDeal, + }, + ] + + setConfig(config) + + const wallet = getWallet() + await startUpdater(wallet) + + for (let i = 0; i < arr.length; i += 1) { + const item = arr[i] + console.log(item.title) + const errorMsg = await item.check() + console.log(errorMsg ? `check failed: ${errorMsg}` : 'OK') + if (i === 0 && errorMsg) break + console.log('\n') + } + + process.exit(0) +} \ No newline at end of file diff --git a/src/check/indicativePrice.ts b/src/check/indicativePrice.ts new file mode 100644 index 0000000..604c0f9 --- /dev/null +++ b/src/check/indicativePrice.ts @@ -0,0 +1,7 @@ +import { priceCheckHelper } from './priceCheckHelper' +import { getIndicativePrice } from '../request/marketMaker' + +export default async () => { + const isIndicative = true + return priceCheckHelper(getIndicativePrice, isIndicative) +} \ No newline at end of file diff --git a/src/check/pairs.ts b/src/check/pairs.ts new file mode 100644 index 0000000..b4aa810 --- /dev/null +++ b/src/check/pairs.ts @@ -0,0 +1,28 @@ +import * as _ from 'lodash' +import { getPairs } from '../request/marketMaker' +import { getSupportedTokens } from '../utils/token' + +const check = async () => { + let pairsFromMM = [] + + try { + pairsFromMM = await getPairs() + if (!pairsFromMM) return 'pairs API no reponse' + if (!pairsFromMM.length) return 'pairs API token array is empty' + if (!pairsFromMM.every(pairStr => pairStr.indexOf('/') !== -1)) return 'pairs API pair str must be TokenA/TokenB' + } catch (e) { + return `pairs API request error ${e.message}` + } + + try { + const supportedTokenList = getSupportedTokens() + if (supportedTokenList.length === 0) return 'intergrated supported token list is empty' + if (supportedTokenList.length === 1) return `intergrated supported token list only has one token trade ${supportedTokenList[0].symbol}-${supportedTokenList[0].opposites[0]}` + } catch (e) { + return `imToken getTokenList error ${e.message}` + } + + return '' +} + +export default check \ No newline at end of file diff --git a/src/check/price.ts b/src/check/price.ts new file mode 100644 index 0000000..7b628e1 --- /dev/null +++ b/src/check/price.ts @@ -0,0 +1,7 @@ +import { priceCheckHelper } from './priceCheckHelper' +import { getPrice } from '../request/marketMaker' + +export default async () => { + const isIndicative = false + return priceCheckHelper(getPrice, isIndicative) +} \ No newline at end of file diff --git a/src/check/priceCheckHelper.ts b/src/check/priceCheckHelper.ts new file mode 100644 index 0000000..de88f7f --- /dev/null +++ b/src/check/priceCheckHelper.ts @@ -0,0 +1,89 @@ +import * as _ from 'lodash' +import { getSupportedTokens } from '../utils/token' + +const checkMinAndMaxAmount = (resp, { base, quote, side }) => { + const { minAmount, maxAmount } = resp + if (minAmount === void 0 || maxAmount === void 0) return `${base}-${quote} ${side} trade must have minAmount and maxAmount field` + if (minAmount > maxAmount) return `${base}-${quote} ${side} trade's minAmount largerer than maxAmount` +} + +const checkBaseQuoteTrade = async (apiFunc, { base, quote }, isIndicative, tokenSupported) => { + const uniqId = 'uniq' + const amountArr = isIndicative ? [undefined, 0, 0.1] : [0.1, 100] + + for (let side of ['BUY', 'SELL']) { + for (let item of [{ base, quote }, { base: quote, quote: base }]) { + for (let amount of amountArr) { + const base = item.base + const quote = item.quote + const params = { base, quote, side } + if (amount !== undefined) { + Object.assign(params, { amount }) + } + if (!isIndicative) { + Object.assign(params, { uniqId }) + } + try { + console.log('params:', JSON.stringify(params)) + const resp = await apiFunc(params) + resp.exchangeable = resp.exchangeable || resp.exchangable + console.log('response:', JSON.stringify(resp)) + + if (!resp.result || !resp.exchangeable) { + // 数量不存在的报价,必须支持 + if (tokenSupported && amount === undefined) return `can not support ${base}-${quote} ${side} trade` + + // 必须包含 message + if (!resp.message) return `${base}-${quote} ${side} message is needed if result or exchangeable is false` + + } else { + + if (tokenSupported) { + // 价格不存在问题 + if (!resp.price) return `${base}-${quote} ${side} price ${resp.price} incorrect` + + // price 接口 必须包含 quoteId 字段 + if (!isIndicative && (!resp.quoteId || !_.isString(resp.quoteId))) return `${base}-${quote} ${side} response need an non-empty string quoteId` + + // TODO: 价格合理性检查 + } + } + + // 支持的 token 必须包含最大最小值 + if (tokenSupported) { + const minMaxAmountValidateMsg = checkMinAndMaxAmount(resp, { base, quote, side }) + if (minMaxAmountValidateMsg) return minMaxAmountValidateMsg + } + + } catch (e) { + return `API request ${base}-${quote} ${side} error ${e.message}` + } + } + } + } +} + +export const priceCheckHelper = async (apiFunc, isIndicative) => { + const supportedTokens = getSupportedTokens() + + // ETH token + const ethToken = supportedTokens.find(t => t.symbol === 'ETH') + let base = ethToken.symbol + let quote = ethToken.opposites[0] + + const supportedTokenTradeValidateMsg = await checkBaseQuoteTrade(apiFunc, { base, quote }, isIndicative, true) + if (supportedTokenTradeValidateMsg) return supportedTokenTradeValidateMsg + + quote = 'ABCDEFG' + const unsupportedTokenTradeValidateMsg = await checkBaseQuoteTrade(apiFunc, { base, quote }, isIndicative, false) + if (unsupportedTokenTradeValidateMsg) return unsupportedTokenTradeValidateMsg + + const otherToken = supportedTokens.find(t => t.symbol !== 'ETH' && t.opposites.length > 1) + + if (otherToken) { + base = otherToken.symbol + quote = otherToken.opposites.find(symbol => symbol !== 'ETH') + const twoErc20TokenTradeValidateMsg = await checkBaseQuoteTrade(apiFunc, { base, quote }, isIndicative, true) + if (twoErc20TokenTradeValidateMsg) return twoErc20TokenTradeValidateMsg + } +} \ No newline at end of file diff --git a/src/config/index.ts b/src/config/index.ts new file mode 100644 index 0000000..e85bbcd --- /dev/null +++ b/src/config/index.ts @@ -0,0 +1,39 @@ +import * as readlineSync from 'readline-sync' +import * as keythereum from 'keythereum' +import { ConfigForStart } from '../types' + +const config = { + EXCHANGE_URL: null, + WEBSOCKET_URL: null, + PROVIDER_URL: null, + + WALLET_ADDRESS: null, + WALLET_PRIVATE_KEY: null, + USE_KEYSTORE: true, + WALLET_KEYSTORE: null, + MMSK_SERVER_PORT: null, + + USE_ZERORPC: null, + HTTP_SERVER_ENDPOINT: null, + ZERORPC_SERVER_ENDPOINT: null, + + NODE_ENV: 'DEVELOPMENT', + SENTRY_DSN: null, +} as ConfigForStart + +const setConfig = (conf: ConfigForStart) => { + if (conf.USE_KEYSTORE) { + const KEYSTORE_PASSWORD = readlineSync.question('Please input your keystore\'s password: ', { + hideEchoBack: true, + }) + const privateKeyBuf = keythereum.recover(KEYSTORE_PASSWORD, conf.WALLET_KEYSTORE) + const privateKey = privateKeyBuf.toString('hex') + conf.WALLET_PRIVATE_KEY = privateKey + } + return Object.assign(config, conf) +} + +export { + config, + setConfig, +} \ No newline at end of file diff --git a/src/constants/index.ts b/src/constants/index.ts new file mode 100644 index 0000000..69623b4 --- /dev/null +++ b/src/constants/index.ts @@ -0,0 +1,11 @@ +export const ETH_ADDRESS = `0x${'0'.repeat(40)}` + +export const FEE_RECIPIENT_ADDRESS = '0xb9e29984fe50602e7a619662ebed4f90d93824c7' + +export const REQUEST_TIMEOUT = 10000 + +export const INTERVAL_UPDAER_TIME = 5 * 60 * 1000 + +export const MAX_WEBSOCKET_RECONNECT_TRIED_TIMES = 100 + +export const WEBSOCKET_TRY_CONNECT_INTERVAL = 1000 \ No newline at end of file diff --git a/src/global.d.ts b/src/global.d.ts new file mode 100644 index 0000000..2b193f6 --- /dev/null +++ b/src/global.d.ts @@ -0,0 +1,4 @@ +declare module '*.json' { + const value: any + export default value +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..4bef7dd --- /dev/null +++ b/src/index.ts @@ -0,0 +1,2 @@ +export { checkMMSK } from './check' +export { startMMSK } from './start' \ No newline at end of file diff --git a/src/request/_request.ts b/src/request/_request.ts new file mode 100644 index 0000000..d149686 --- /dev/null +++ b/src/request/_request.ts @@ -0,0 +1,83 @@ +import axios from 'axios' +import * as _ from 'lodash' +import { REQUEST_TIMEOUT } from '../constants' + +// `validateStatus` defines whether to resolve or reject the promise for a given +// HTTP response status code. If `validateStatus` returns `true` (or is set to `null` +// or `undefined`), the promise will be resolved; otherwise, the promise will be rejected. +const validateStatus = function (status: number): boolean { + return status >= 200 && status < 300 // default +} + +const getHeaders = () => { + return { + 'Content-Type': 'application/json', + } +} + +const newError = (message, url: string) => { + if (_.isObject(message) && message.message) { + const error = message + if (_.isObject(error.response) && _.isObject(error.response.data)) { + if (error.response.data.error) { + message = error.response.data.error.message + } + } else { + message = `${url}: ${message.message}` + } + } else { + message = `${url}: ${message}` + } + const error = new Error(message) + error.message = message + error.toString = () => message + return error +} + +// TODO add debounceTime +export const sendRequest = (config): Promise => { + const rConfig = { + validateStatus, + timeout: REQUEST_TIMEOUT, + ...config, + } + return new Promise((resolve, reject) => { + axios(rConfig).then(res => { + if (res.data) { + resolve(res.data) + } else { + reject(newError('null response', config.url)) + } + }).catch(error => { + console.log('request error', error) + reject(newError(error, config.url)) + }) + }) as Promise<{ error: object, result: any }> +} + +export const jsonrpc = { + get(url, header = {}, method, params, timeout: number = REQUEST_TIMEOUT) { + const headers = { + ...getHeaders(), + ...header, + } + const data = { + jsonrpc: '2.0', + id: 1, + method, + params, + } + return sendRequest({ method: 'post', url, data, timeout, headers }).then(data => { + if (data.error) { + throw newError(data.error, url) + } + + if (_.isUndefined(data.result)) { + throw newError('server result is undefined', url) + } + return data.result + }).catch(err => { + throw err + }) + }, +} diff --git a/src/request/imToken/index.ts b/src/request/imToken/index.ts new file mode 100644 index 0000000..d7e40e3 --- /dev/null +++ b/src/request/imToken/index.ts @@ -0,0 +1,75 @@ +import { Token, MarketMakerConfig, TokenConfig } from '../../types' +import { config } from '../../config' +import { jsonrpc } from '../_request' +import { personalSign } from '../../utils/sign' +import { getTimestamp } from '../../utils/timestamp' +import { OrderForMM, GetOrdersHistoryForMMParams } from './interface' + +export const getMarketMakerConfig = async (signerAddr): Promise => { + return jsonrpc.get( + config.EXCHANGE_URL, + {}, + 'tokenlon.getMarketMakerConfig', + { + signerAddr, + }, + ) +} + +const getTokenFromServer = async ({ timestamp, signature }): Promise => { + return jsonrpc.get( + config.WEBSOCKET_URL, + {}, + 'auth.getMMJwtToken', + { + timestamp, + signature, + }, + ) +} + +export const getMMJwtToken = async (privateKey: string) => { + const timestamp = getTimestamp() + const signature = personalSign(privateKey, timestamp.toString()) + return getTokenFromServer({ timestamp, signature }) +} + +export const getTokenList = async (): Promise => { + return jsonrpc.get( + config.EXCHANGE_URL, + {}, + 'tokenlon.getTokenList', + {}, + ) +} + +export const getTokenConfigsForMM = async (signerAddr: string): Promise => { + return jsonrpc.get( + config.EXCHANGE_URL, + {}, + 'tokenlon.getTokenConfigsForMM', + { + signerAddr, + }, + ) +} + +export const getOrdersHistoryForMM = async (params: GetOrdersHistoryForMMParams): Promise => { + return jsonrpc.get( + config.EXCHANGE_URL, + {}, + 'tokenlon.getOrdersHistoryForMM', + params, + ) +} + +export const getOrderStateForMM = async (quoteId: string): Promise => { + return jsonrpc.get( + config.EXCHANGE_URL, + {}, + 'tokenlon.getOrderStateForMM', + { + quoteId, + }, + ) +} \ No newline at end of file diff --git a/src/request/imToken/interface.ts b/src/request/imToken/interface.ts new file mode 100644 index 0000000..014f567 --- /dev/null +++ b/src/request/imToken/interface.ts @@ -0,0 +1,20 @@ +type OrderStatus = 'success' | 'failed' | 'timeout' | 'pending' | 'invalid' | 'unbroadcast' + +export interface OrderForMM { + makerToken: string + takerToken: string + makerTokenAmount: number + takerTokenAmount: number + quoteId: string + status: OrderStatus + txHash: string + blockNumber: number + timestamp: number + blockTimestamp: number +} + +export interface GetOrdersHistoryForMMParams { + signerAddr: string + page: number + perpage: number +} \ No newline at end of file diff --git a/src/request/marketMaker/http.ts b/src/request/marketMaker/http.ts new file mode 100644 index 0000000..c7162f6 --- /dev/null +++ b/src/request/marketMaker/http.ts @@ -0,0 +1,39 @@ +import { IndicativePriceApiParams, IndicativePriceApiResult, PriceApiParams, PriceApiResult, DealApiParams, DealApiResult } from './interface' +import { sendRequest } from '../_request' +import { config } from '../../config' + +export const getPairs = async (): Promise => { + return sendRequest({ + method: 'get', + url: `${config.HTTP_SERVER_ENDPOINT}/pairs`, + }).then((res: any) => { + return res.pairs + }) +} + +export const getIndicativePrice = async (data: IndicativePriceApiParams): Promise => { + return sendRequest({ + method: 'get', + url: `${config.HTTP_SERVER_ENDPOINT}/indicativePrice`, + params: data, + }) +} + +export const getPrice = async (data: PriceApiParams): Promise => { + return sendRequest({ + method: 'get', + url: `${config.HTTP_SERVER_ENDPOINT}/price`, + params: data, + }) +} + +export const dealOrder = async (data: DealApiParams): Promise => { + return sendRequest({ + method: 'post', + url: `${config.HTTP_SERVER_ENDPOINT}/deal`, + data, + header: { + 'Content-Type': 'application/json', + }, + }) +} \ No newline at end of file diff --git a/src/request/marketMaker/index.ts b/src/request/marketMaker/index.ts new file mode 100644 index 0000000..f0029df --- /dev/null +++ b/src/request/marketMaker/index.ts @@ -0,0 +1,35 @@ +import * as httpClient from './http' +import * as zerorpcClient from './zerorpc' +import { IndicativePriceApiParams, IndicativePriceApiResult, PriceApiParams, PriceApiResult, DealApiParams, DealApiResult } from './interface' +import { config } from '../../config' +import { removeQuoteIdPrefix } from '../../utils/quoteId' + +export const getPairs = (): Promise => { + return config.USE_ZERORPC ? zerorpcClient.getPairs() : httpClient.getPairs() +} + +export const getIndicativePrice = (data: IndicativePriceApiParams): Promise => { + return config.USE_ZERORPC ? zerorpcClient.getIndicativePrice(data) : httpClient.getIndicativePrice(data) +} + +export const getPrice = (data: PriceApiParams): Promise => { + return config.USE_ZERORPC ? zerorpcClient.getPrice(data) : httpClient.getPrice(data) +} + +export const dealOrder = (params: DealApiParams): Promise => { + const { quoteId } = params + const data = { + ...params, + quoteId: removeQuoteIdPrefix(quoteId), + } + return config.USE_ZERORPC ? zerorpcClient.dealOrder(data) : httpClient.dealOrder(data) +} + +// for binance mock +// export { getPairs, getPrice, getIndicativePrice } from '../mockBinance' + +// export const dealOrder = (_data: any): Promise => { +// return Promise.resolve({ +// result: true, +// }) +// } \ No newline at end of file diff --git a/src/request/marketMaker/interface.ts b/src/request/marketMaker/interface.ts new file mode 100644 index 0000000..9c859b2 --- /dev/null +++ b/src/request/marketMaker/interface.ts @@ -0,0 +1,38 @@ +import { SIDE } from '../../types' + +export interface IndicativePriceApiParams { + base: string + quote: string + side: SIDE + amount?: number +} + +export interface IndicativePriceApiResult { + result: boolean + exchangeable: boolean + minAmount: number + maxAmount: number + price: number + message?: string +} + +export interface PriceApiParams extends IndicativePriceApiParams { + amount: number +} + +export interface PriceApiResult extends IndicativePriceApiResult { + quoteId: string +} + +export interface DealApiParams { + makerToken: string + takerToken: string + makerTokenAmount: number + takerTokenAmount: number + quoteId: string + timestamp: number +} + +export interface DealApiResult { + result: boolean +} \ No newline at end of file diff --git a/src/request/marketMaker/zerorpc.ts b/src/request/marketMaker/zerorpc.ts new file mode 100644 index 0000000..b8dd8ba --- /dev/null +++ b/src/request/marketMaker/zerorpc.ts @@ -0,0 +1,37 @@ +import * as zerorpc from 'zerorpc' +import { IndicativePriceApiParams, IndicativePriceApiResult, PriceApiParams, PriceApiResult, DealApiParams, DealApiResult } from './interface' + +const client = new zerorpc.Client() + +export const connectClient = (endpoint) => { + client.connect(endpoint) +} + +const promisify = (apiName, req?: object): Promise => { + return new Promise((resolve, reject) => { + client.invoke(apiName, req, (err, res) => { + if (err) { + reject(err) + } else { + resolve(res) + } + }) + }) +} + +export const getPairs = async (): Promise => { + const res = await promisify('pairs') + return res.pairs +} + +export const getIndicativePrice = async (data: IndicativePriceApiParams): Promise => { + return promisify('indicativePrice', data) +} + +export const getPrice = async (data: PriceApiParams): Promise => { + return promisify('price', data) +} + +export const dealOrder = async (data: DealApiParams): Promise => { + return promisify('deal', data) +} \ No newline at end of file diff --git a/src/request/mockBinance/index.ts b/src/request/mockBinance/index.ts new file mode 100644 index 0000000..47c5751 --- /dev/null +++ b/src/request/mockBinance/index.ts @@ -0,0 +1,9 @@ +import getPairs from './pairs' +import getPrice from './price' + +export const getIndicativePrice = getPrice + +export { + getPairs, + getPrice, +} \ No newline at end of file diff --git a/src/request/mockBinance/pairs.ts b/src/request/mockBinance/pairs.ts new file mode 100644 index 0000000..b823589 --- /dev/null +++ b/src/request/mockBinance/pairs.ts @@ -0,0 +1,10 @@ +import { getSymbols } from './utils/symbols' + +export default async () => { + const symbols = await getSymbols() + const result = [] + symbols.forEach(symbol => { + result.push(`${symbol.baseAsset}/${symbol.quoteAsset}`) + }) + return result +} \ No newline at end of file diff --git a/src/request/mockBinance/price.ts b/src/request/mockBinance/price.ts new file mode 100644 index 0000000..66b0100 --- /dev/null +++ b/src/request/mockBinance/price.ts @@ -0,0 +1,23 @@ +import { getPriceObj } from './utils/price' +import { generateQuoteId } from './utils/quoteId' + +export default async (query) => { + const { amount } = query + const priceObj = await getPriceObj(query) + return priceObj.price ? { + result: true, + exchangeable: true, + maxAmount: 100, + minAmount: 0.0002, + price: priceObj.price, + quoteId: amount && +amount ? generateQuoteId() : '', + } : { + result: false, + exchangeable: false, + maxAmount: 100, + minAmount: 0.0002, + price: 0, + message: priceObj.message, + quoteId: amount && +amount ? generateQuoteId() : '', + } +} \ No newline at end of file diff --git a/src/request/mockBinance/utils/binance.ts b/src/request/mockBinance/utils/binance.ts new file mode 100644 index 0000000..1e668a7 --- /dev/null +++ b/src/request/mockBinance/utils/binance.ts @@ -0,0 +1,3 @@ +import Binance from 'binance-api-node' + +export default Binance() \ No newline at end of file diff --git a/src/request/mockBinance/utils/price.ts b/src/request/mockBinance/utils/price.ts new file mode 100644 index 0000000..d3577ef --- /dev/null +++ b/src/request/mockBinance/utils/price.ts @@ -0,0 +1,350 @@ +import binance from './binance' +import { isSameSideSymbol, isOppositeSideSymbol, isSupportedSymbolWithOppositeSide, isSupportedSymbolWithSameSide, getSymbols } from './symbols' + +const getBaseQuoteSymbol = (symbols, { base, quote }) => { + return symbols.find(symbol => { + return isSameSideSymbol(symbol, { base, quote }) || isOppositeSideSymbol(symbol, { base, quote }) + }) +} + +const translateSymbolToPair = symbol => { + return { + base: symbol.baseAsset, + quote: symbol.quoteAsset, + } +} + +const getSameSideObj = async ({ base, quote, amount, side }) => { + let sortedOrderbook = null + if (side === 'BUY') { + // binance 已做过排序 + const res = await binance.book({ + symbol: `${base}${quote}`, + limit: 100, + }) + sortedOrderbook = res.asks + } else { + // binance 已做过排序 + const res = await binance.book({ + symbol: `${base}${quote}`, + limit: 100, + }) + sortedOrderbook = res.bids + } + + // 没有数量的情况,返回最好的价格 + if (!amount || !(+amount)) { + return { + price: sortedOrderbook[0].price, + baseAmount: 0, + quoteAmount: 0, + } + } + + let restBaseAmount = amount + let totalQuoteAmount = 0 + + sortedOrderbook.some((orderItem) => { + const { price, quantity } = orderItem + const baseAmount = +quantity + const quoteAmount = price * quantity + if (baseAmount <= restBaseAmount) { + totalQuoteAmount += quoteAmount + restBaseAmount -= baseAmount + } else { + totalQuoteAmount += (price * restBaseAmount) + restBaseAmount = 0 + return true + } + }) + + // 数量过大 未满足 + if (restBaseAmount) { + return { + price: 0, + baseAmount: 0, + quoteAmount: 0, + } + } + + // 总花销 / 总获得数量, 即为平均价格 + return { + price: totalQuoteAmount / amount, + baseAmount: amount, + quoteAmount: totalQuoteAmount, + } +} + +/** + * base: DAI, quote: ETH, side: sell (价格最好的在前面) + * amount is quote amount + * 实际: ETH-DAI, side: buy + */ +const getOppositeSideObj = async ({ base, quote, amount, side }) => { + let sortedOrderbook = null + if (side === 'BUY') { + + const res = await binance.book({ + symbol: `${quote}${base}`, + limit: 100, + }) + sortedOrderbook = res.bids + } else { + const res = await binance.book({ + symbol: `${quote}${base}`, + limit: 100, + }) + sortedOrderbook = res.asks + } + + // 没有数量的情况,返回最好的价格 + if (!amount || !(+amount)) { + return { + price: 1 / sortedOrderbook[0].price, + baseAmount: 0, + quoteAmount: 0, + } + } + + // now amount is totalQuoteAmount + let restObQuoteAmount = amount + let totalObBaseAmount = 0 + + sortedOrderbook.some((orderItem) => { + const { price, quantity } = orderItem + const obBaseAmount = +quantity + const obQuoteAmount = price * quantity + if (obQuoteAmount <= restObQuoteAmount) { + restObQuoteAmount -= obQuoteAmount + totalObBaseAmount += obBaseAmount + } else { + totalObBaseAmount += (restObQuoteAmount / price) + restObQuoteAmount = 0 + return true + } + }) + + // 数量过大 未满足 + if (restObQuoteAmount) { + return { + price: 0, + baseAmount: 0, + quoteAmount: 0, + } + } + + // 总花销 / 总获得数量, 即为平均价格 + // 注意,这里的 base/quote 需要反向 + return { + price: totalObBaseAmount / amount, + baseAmount: amount, + quoteAmount: totalObBaseAmount, + } +} + +// 寻找路径 +const getTradeCombinations = (symbols, { base, quote, side }) => { + const symbolArrs = [] + + symbols.forEach(symbol1 => { + if (symbol1.baseAsset === base || symbol1.quoteAsset === base) { + const opposite = symbol1.baseAsset === base ? symbol1.quoteAsset : symbol1.baseAsset + const symbol2 = getBaseQuoteSymbol(symbols, { + base: quote, + quote: opposite, + }) + if (symbol2) { + symbolArrs.push([symbol1, symbol2]) + } + } + }) + + return symbolArrs.map(([symbol1, symbol2]) => { + const p1 = translateSymbolToPair(symbol1) as any + const p2 = translateSymbolToPair(symbol2) as any + if (side === 'BUY') { + p1.side = p1.base === base || p1.quote === quote ? 'BUY' : 'SELL' + p2.side = p2.base === base || p2.quote === quote ? 'BUY' : 'SELL' + + // SELL + } else { + p1.side = p1.base === base || p1.quote === quote ? 'SELL' : 'BUY' + p2.side = p2.base === base || p2.quote === quote ? 'SELL' : 'BUY' + } + return [p1, p2] + }) +} + +const getCombinationsPriceObjsWithoutAmount = (combinations, { base, quote }) => { + return Promise.all( + combinations.map(([baseQuery, quoteQuery]) => { + return Promise.all([getSameSideObj(baseQuery), getSameSideObj(quoteQuery)]) + .then(([priceObj1, priceObj2]) => { + let basePrice = null + let quotePrice = null + + if (baseQuery.base === base) { + basePrice = priceObj1.price + } else if (baseQuery.quote === base) { + basePrice = 1 / priceObj1.price + } + + if (quoteQuery.base === quote) { + quotePrice = priceObj2.price + } else if (quoteQuery.quote === quote) { + quotePrice = 1 / priceObj2.price + } + + return { + price: basePrice / quotePrice, + baseAmount: 0, + quoteAmount: 0, + } + }) + }), + ) +} +// @1 base=KNC"e=DAI&side=SELL&amount=1 +// base KNC quote DAI side SELL, amount is KNC amount +// @2 base=DAI"e=KNC&side=SELL&amount=1000 +// base DAI quote KNC side SELL amount is DAI amount +const getCombinationsPriceObjsWithAmount = (combinations, { base, quote, amount }) => { + return Promise.all( + combinations.map(([baseQuery, quoteQuery]) => { + return new Promise(async (resolve, reject) => { + let basePrice = null + let quotePrice = null + let middleTokenAmount = 0 + let quoteAmount = 0 + + try { + // @1 { base: 'KNC', quote: 'BTC', side: 'SELL' } + if (baseQuery.base === base) { + // this returnd KNC/BTC price + const priceObj1 = await getSameSideObj({ ...baseQuery, amount }) + const { price, quoteAmount } = priceObj1 + basePrice = price + middleTokenAmount = quoteAmount + + // @2 { base: 'BTC', quote: 'DAI', side: 'BUY' } + } else if (baseQuery.quote === base) { + // this returnd KNC/BTC price + const priceObj1 = await getOppositeSideObj({ + base: baseQuery.quote, + quote: baseQuery.base, + amount, // now amount is quote amount + side: baseQuery.side === 'BUY' ? 'SELL' : 'BUY', + }) + const { price, baseAmount } = priceObj1 + basePrice = price + middleTokenAmount = baseAmount + } + + if (!basePrice) { + resolve({ + price: 0, + baseAmount: 0, + quoteAmount: 0, + }) + return + } + + // @2 { base: 'KNC', quote: 'BTC', side: 'BUY' } + // middleTokenAmount is BTC amount + if (quoteQuery.base === quote) { + // this returnd quoteQuery.quote/quoteQuery.base's price, so is BTC/KNC price + const priceObj2 = await getOppositeSideObj({ + base: quoteQuery.quote, + quote: quoteQuery.base, + side: quoteQuery.side === 'BUY' ? 'SELL' : 'BUY', + amount: middleTokenAmount, + }) + const { price } = priceObj2 + quoteAmount = priceObj2.quoteAmount + // so need to 1 / price + quotePrice = price ? (1 / price) : 0 + + // @1 { base: 'BTC', quote: 'DAI', side: 'SELL' } + // middleTokenAmount is BTC amount + } else if (quoteQuery.quote === quote) { + // this returnd BTC/KNC price + const priceObj2 = await getSameSideObj({ ...quoteQuery, amount: middleTokenAmount }) + const { price } = priceObj2 + quoteAmount = priceObj2.quoteAmount + // so need to 1 / price + quotePrice = price ? (1 / price) : 0 + } + + resolve(basePrice && quotePrice ? { + baseAmount: amount, + quoteAmount, + price: basePrice / quotePrice, + } : { + baseAmount: 0, + quoteAmount: 0, + price: 0, + }) + } catch (e) { + reject(e) + } + + }) + }), + ) +} + +export const getPriceObj = async ({ base, quote, amount, side }) => { + const isSameSide = await isSupportedSymbolWithSameSide({ base, quote }) + if (isSameSide) { + const priceObj = await getSameSideObj({ base, quote, amount, side }) + return priceObj + } + + const isOppositeSide = await isSupportedSymbolWithOppositeSide({ base, quote }) + + if (isOppositeSide) { + const priceObj = await getOppositeSideObj({ base, quote, amount, side }) + return priceObj + } + + const symbols = await getSymbols() + const combinations = getTradeCombinations(symbols, { base, quote, side }) + + let allPriceObjs = [] + + // 没有数量的情况 + if (!amount || !(+amount)) { + allPriceObjs = await getCombinationsPriceObjsWithoutAmount(combinations, { base, quote }) + + // 有数量的情况下 + } else { + allPriceObjs = await getCombinationsPriceObjsWithAmount(combinations, { base, quote, amount }) + } + + const allPriceObjsFiltered = allPriceObjs.filter(priceObj => !!priceObj.price) + + if (!allPriceObjsFiltered.length) { + return allPriceObjs[0] ? allPriceObjs[0] : { + price: 0, + baseAmount: 0, + quoteAmount: 0, + message: 'Can\'t solved this trade pair', + } + } + + const priceObj = allPriceObjsFiltered.reduce((memo, priceObj) => { + if (!memo) return priceObj + if (side === 'BUY') { + return memo.price < priceObj.price ? memo : priceObj + } else { + return memo.price > priceObj.price ? memo : priceObj + } + }, null) + + return priceObj +} + +export const getPrice = async ({ base, quote, amount, side }) => { + const priceObj = await getPriceObj({ base, quote, amount, side }) + return priceObj.price +} \ No newline at end of file diff --git a/src/request/mockBinance/utils/quoteId.ts b/src/request/mockBinance/utils/quoteId.ts new file mode 100644 index 0000000..98fde1c --- /dev/null +++ b/src/request/mockBinance/utils/quoteId.ts @@ -0,0 +1,13 @@ +const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' + +const randomString = (length) => { + const arr = [] + for (; length--; ) { + arr.push(possible.charAt(Math.floor(Math.random() * possible.length))) + } + return arr.join('') +} + +export const generateQuoteId = () => { + return `${randomString(8)}-${randomString(4)}-${randomString(4)}-${randomString(4)}-${randomString(12)}` +} \ No newline at end of file diff --git a/src/request/mockBinance/utils/symbols.ts b/src/request/mockBinance/utils/symbols.ts new file mode 100644 index 0000000..abef514 --- /dev/null +++ b/src/request/mockBinance/utils/symbols.ts @@ -0,0 +1,40 @@ +import binance from './binance' + +let symbolsInCache = null + +export const getSymbols = async () => { + if (!symbolsInCache) { + const res = await binance.exchangeInfo() + symbolsInCache = res.symbols.filter(item => item.status === 'TRADING') + } + + return symbolsInCache +} + +export const isSameSideSymbol = (symbol, { base, quote }) => { + return base === symbol.baseAsset && symbol.quoteAsset === quote +} + +export const isOppositeSideSymbol = (symbol, { base, quote }) => { + return quote === symbol.baseAsset && symbol.quoteAsset === base +} + +export const isSupportedSymbolWithSameSide = async ({ base, quote }) => { + let symbols = symbolsInCache + if (!symbols) { + symbols = await getSymbols() + } + return symbols.some(symbol => { + return isSameSideSymbol(symbol, { base, quote }) + }) +} + +export const isSupportedSymbolWithOppositeSide = async ({ base, quote }) => { + let symbols = symbolsInCache + if (!symbols) { + symbols = await getSymbols() + } + return symbols.some(symbol => { + return isOppositeSideSymbol(symbol, { base, quote }) + }) +} \ No newline at end of file diff --git a/src/request/mockBinance/utils/timestamp.ts b/src/request/mockBinance/utils/timestamp.ts new file mode 100644 index 0000000..ce6f925 --- /dev/null +++ b/src/request/mockBinance/utils/timestamp.ts @@ -0,0 +1 @@ +export const getTimestamp = () => Math.ceil(Date.now() / 1000) \ No newline at end of file diff --git a/src/router/getBalance.ts b/src/router/getBalance.ts new file mode 100644 index 0000000..381f52a --- /dev/null +++ b/src/router/getBalance.ts @@ -0,0 +1,28 @@ +import * as _ from 'lodash' +import { getSupportedTokens } from '../utils/token' +import { getTokenlonTokenBalance } from '../utils/balance' + +export const getBalance = async (ctx) => { + try { + const tokenList = getSupportedTokens() + const token = tokenList.find(t => t.symbol === ctx.query.token) + + if (token && token.contractAddress) { + const balance = await getTokenlonTokenBalance(token) + ctx.body = { + result: true, + balance, + } + } else { + ctx.body = { + result: false, + message: `Don't support token ${ctx.query.token} trade`, + } + } + } catch (e) { + ctx.body = { + result: false, + message: e.message, + } + } +} \ No newline at end of file diff --git a/src/router/getBalances.ts b/src/router/getBalances.ts new file mode 100644 index 0000000..247d51b --- /dev/null +++ b/src/router/getBalances.ts @@ -0,0 +1,27 @@ +import * as _ from 'lodash' +import { getSupportedTokens } from '../utils/token' +import { getTokenlonTokenBalance } from '../utils/balance' + +export const getBalances = async (ctx) => { + try { + const tokenList = getSupportedTokens() + const balances = await Promise.all(tokenList.map(async (token) => { + const balance = await getTokenlonTokenBalance(token) + return { + symbol: token.symbol, + balance, + } + })) + + ctx.body = { + result: true, + balances, + } + + } catch (e) { + ctx.body = { + result: true, + message: e.message, + } + } +} \ No newline at end of file diff --git a/src/router/getOrderState.ts b/src/router/getOrderState.ts new file mode 100644 index 0000000..36692a3 --- /dev/null +++ b/src/router/getOrderState.ts @@ -0,0 +1,20 @@ +import { getOrderStateForMM } from '../request/imToken' +import { addQuoteIdPrefix, removeQuoteIdPrefix } from '../utils/quoteId' + +export const getOrderState = async (ctx) => { + try { + const order = await getOrderStateForMM(addQuoteIdPrefix(ctx.query.quoteId)) + if (order.quoteId) { + order.quoteId = removeQuoteIdPrefix(order.quoteId) + } + ctx.body = { + result: true, + order, + } + } catch (e) { + ctx.body = { + result: false, + message: e.message, + } + } +} \ No newline at end of file diff --git a/src/router/getOrdersHistory.ts b/src/router/getOrdersHistory.ts new file mode 100644 index 0000000..a4e37f9 --- /dev/null +++ b/src/router/getOrdersHistory.ts @@ -0,0 +1,27 @@ +import { getOrdersHistoryForMM } from '../request/imToken' +import { getWallet } from '../utils/wallet' +import { removeQuoteIdPrefix } from '../utils/quoteId' + +export const getOrdersHistory = async (ctx) => { + const wallet = getWallet() + try { + const orders = await getOrdersHistoryForMM({ + ...ctx.query, + signerAddr: wallet.address, + }) + orders.forEach(order => { + if (order.quoteId) { + order.quoteId = removeQuoteIdPrefix(order.quoteId) + } + }) + ctx.body = { + result: true, + orders, + } + } catch (e) { + ctx.body = { + result: false, + message: e.message, + } + } +} \ No newline at end of file diff --git a/src/router/getRate.ts b/src/router/getRate.ts new file mode 100644 index 0000000..8e81c9e --- /dev/null +++ b/src/router/getRate.ts @@ -0,0 +1,26 @@ +import * as _ from 'lodash' +import { getIndicativePrice } from '../request/marketMaker' +import { checkParams } from '../validations' +import { transferIndicativePriceResultToRateBody } from '../utils/rate' + +export const getRate = async (ctx) => { + const { query } = ctx + const checkResult = checkParams(query, false) + if (!checkResult.result) { + ctx.body = checkResult + return + } + + try { + const { side } = query + const priceResult = await getIndicativePrice(query) + ctx.body = transferIndicativePriceResultToRateBody(priceResult, side) + + } catch (e) { + ctx.body = { + result: false, + exchangeable: false, + message: e.message, + } + } +} \ No newline at end of file diff --git a/src/router/getSupportedTokenList.ts b/src/router/getSupportedTokenList.ts new file mode 100644 index 0000000..3516c89 --- /dev/null +++ b/src/router/getSupportedTokenList.ts @@ -0,0 +1,11 @@ +import { getSupportedTokens } from '../utils/token' + +export const getSupportedTokenList = (ctx) => { + const tokenList = getSupportedTokens() + ctx.body = { + result: true, + tokens: tokenList.map(({ symbol, opposites }) => { + return { symbol, opposites } + }), + } +} \ No newline at end of file diff --git a/src/router/index.ts b/src/router/index.ts new file mode 100644 index 0000000..5ab70b5 --- /dev/null +++ b/src/router/index.ts @@ -0,0 +1,9 @@ +export { getBalance } from './getBalance' +export { getBalances } from './getBalances' +export { getRate } from './getRate' +export { getSupportedTokenList } from './getSupportedTokenList' +export { newOrder } from './newOrder' +export { getOrdersHistory } from './getOrdersHistory' +export { getOrderState } from './getOrderState' +export { reconnect } from './reconnect' +export { version } from './version' \ No newline at end of file diff --git a/src/router/newOrder.ts b/src/router/newOrder.ts new file mode 100644 index 0000000..370ac31 --- /dev/null +++ b/src/router/newOrder.ts @@ -0,0 +1,74 @@ +import * as _ from 'lodash' +import { getPrice } from '../request/marketMaker' +import { PriceApiResult } from '../request/marketMaker/interface' +import { getFormatedSignedOrder } from '../utils/order' +import { getSupportedTokens } from '../utils/token' +import { updaterStack } from '../utils/intervalUpdater' +import { checkParams } from '../validations' +import { transferPriceResultToRateBody } from '../utils/rate' + +export const newOrder = async (ctx) => { + const { query } = ctx + const checkResult = checkParams(query, true) + let rateBody = {} as any + + if (!checkResult.result) { + ctx.body = checkResult + return + } + + try { + const { side } = query + const priceResult = await getPrice(query) + rateBody = transferPriceResultToRateBody(priceResult as PriceApiResult, side) as any + + } catch (e) { + ctx.body = { + result: false, + exchangeable: false, + message: e.message, + } + return + } + + if (!rateBody.result) { + ctx.body = rateBody + + } else { + const { rate, minAmount, maxAmount, quoteId } = rateBody + const { userAddr } = query + const config = updaterStack.markerMakerConfigUpdater.cacheResult + const tokenConfigs = updaterStack.tokenConfigsFromImtokenUpdater.cacheResult + const tokenList = getSupportedTokens() + try { + const orderFormated = getFormatedSignedOrder( + ctx.query, + rate, + userAddr.toLowerCase(), + tokenList, + tokenConfigs, + config, + ) + ctx.body = { + result: true, + exchangeable: true, + rate, + minAmount, + maxAmount, + order: { + ...orderFormated, + quoteId, + }, + } + } catch (e) { + ctx.body = { + result: false, + exchangeable: false, + rate, + minAmount, + maxAmount, + message: e.message, + } + } + } +} \ No newline at end of file diff --git a/src/router/reconnect.ts b/src/router/reconnect.ts new file mode 100644 index 0000000..0a67ab6 --- /dev/null +++ b/src/router/reconnect.ts @@ -0,0 +1,31 @@ +import * as Sentry from '@sentry/node' +import StompForExchange from '../utils/stomp' +import tracker from '../utils/tracker' + +export const reconnect = (ctx, stompForExchange: StompForExchange) => { + const { reconnect } = ctx.request.body + + if (reconnect === true) { + if (!stompForExchange.connecting && !stompForExchange.connected && stompForExchange.isTriedMaxTimes()) { + stompForExchange.resetTriedTimes() + stompForExchange.connectStomp() + tracker.captureMessage('reconnect: try', Sentry.Severity.Critical) + ctx.body = { + result: true, + message: 'try connecting', + } + } else { + tracker.captureMessage('reconnect: already connecting or trying to connect or connected', Sentry.Severity.Info) + ctx.body = { + result: true, + message: 'already connecting or trying to connect or connected', + } + } + } else { + tracker.captureMessage('params reconnect need to be true', Sentry.Severity.Error) + ctx.body = { + result: false, + message: 'params reconnect need to be true', + } + } +} \ No newline at end of file diff --git a/src/router/version.ts b/src/router/version.ts new file mode 100644 index 0000000..914116f --- /dev/null +++ b/src/router/version.ts @@ -0,0 +1,6 @@ +export const version = (ctx) => { + ctx.body = { + result: true, + version: '0.2.4', + } +} \ No newline at end of file diff --git a/src/start.ts b/src/start.ts new file mode 100644 index 0000000..e758208 --- /dev/null +++ b/src/start.ts @@ -0,0 +1,128 @@ +import 'babel-polyfill' +import * as Sentry from '@sentry/node' +import * as Koa from 'koa' +import * as Router from 'koa-router' +import * as Bodyparser from 'koa-bodyparser' +import { getRate, newOrder, getSupportedTokenList, getBalances, getBalance, getOrderState, getOrdersHistory, reconnect, version } from './router' +import StompForExchange from './utils/stomp' +import { setConfig } from './config' +import { ConfigForStart } from './types' +import { startUpdater } from './utils/intervalUpdater' +import { getWallet } from './utils/wallet' +import { connectClient } from './request/marketMaker/zerorpc' +import { isValidWallet } from './validations' +import tracker from './utils/tracker' + +const app = new Koa() +const router = new Router() + +const beforeStartAndGetStompClient = async (config: ConfigForStart, triedTimes?: number) => { + const wallet = getWallet() + triedTimes = triedTimes || 0 + try { + if (config.USE_ZERORPC) { + connectClient(config.ZERORPC_SERVER_ENDPOINT) + } + + await startUpdater(wallet) + const stompForExchange = new StompForExchange() + stompForExchange.connectStomp() + await new Promise((resolve, reject) => { + setTimeout(() => { + if (stompForExchange.connected) { + resolve() + } else { + reject() + } + }, 5000) + }) + return stompForExchange + + } catch (e) { + triedTimes += 1 + tracker.captureException(e) + tracker.captureEvent({ + message: `mmsk before start program faild`, + level: Sentry.Severity.Warning, + extra: { + triedTimes, + }, + }) + + if (triedTimes > 10) { + delete config.WALLET_ADDRESS + delete config.WALLET_KEYSTORE + delete config.WALLET_PRIVATE_KEY + tracker.captureEvent({ + message: `need to check config (except wallet address, keystore or privateKey)`, + level: Sentry.Severity.Fatal, + extra: config, + }) + throw e + } + + return beforeStartAndGetStompClient(config, triedTimes) + } +} + +export const startMMSK = async (config: ConfigForStart) => { + // default 80 + const MMSK_SERVER_PORT = config.MMSK_SERVER_PORT || 80 + + setConfig(config) + + try { + const wallet = getWallet() + if (!isValidWallet(wallet)) { + throw `wallet's address and ${config.USE_KEYSTORE ? 'keystore' : 'privateKey'} not matched` + } + + // init sentry + tracker.init({ SENTRY_DSN: config.SENTRY_DSN, NODE_ENV: config.NODE_ENV }) + + const stompForExchange = await beforeStartAndGetStompClient(config) + + // for imToken server + router.get('/getRate', getRate) + router.get('/newOrder', newOrder) + router.get('/version', version) + router.get('/getSupportedTokenList', getSupportedTokenList) + router.post('/reconnect', (ctx) => { + reconnect(ctx, stompForExchange) + }) + + // for market maker + router.get('/getOrderState', getOrderState) + router.get('/getOrdersHistory', getOrdersHistory) + router.get('/getBalance', getBalance) + router.get('/getBalances', getBalances) + + app + .use(async (ctx, next) => { + ctx.set('Strict-Transport-Security', 'max-age=2592000; includeSubDomains; preload') + ctx.set('X-Content-Type-Option', 'nosniff') + ctx.set('X-Frame-Options', 'SAMEORIGIN') + ctx.set('X-XSS-Protection', '1; mode=block') + await next() + }) + .use(Bodyparser()) + .use(router.routes()) + .use(router.allowedMethods()) + + app.on('error', err => { + tracker.captureEvent({ + message: 'app onerror', + level: Sentry.Severity.Warning, + extra: err, + }) + }) + + app.listen(MMSK_SERVER_PORT) + + console.log(`app listen on ${MMSK_SERVER_PORT}`) + + } catch (e) { + console.log(e) + process.exit(0) + } +} \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..78f0469 --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,68 @@ +export interface MarketMakerConfig { + mmId: number + networkId: number + erc20ProxyContractAddress: string + exchangeContractAddress: string + forwarderContractAddress: string + zrxContractAddress: string + mmProxyContractAddress: string + userProxyContractAddress: string + tokenlonExchangeContractAddress: string + wethContractAddress: string + orderExpirationSeconds: number + feeFactor: number +} + +export interface Wallet { + address: string + privateKey: string +} + +export interface ConfigForStart { + EXCHANGE_URL: string + WEBSOCKET_URL: string + PROVIDER_URL: string + + WALLET_ADDRESS: string + USE_KEYSTORE?: boolean + WALLET_PRIVATE_KEY?: string + WALLET_KEYSTORE?: object + MMSK_SERVER_PORT?: string | number + + NODE_ENV?: 'DEVELOPMENT' | 'STAGING' | 'PRODUCTION' + SENTRY_DSN?: string + + USE_ZERORPC?: boolean + HTTP_SERVER_ENDPOINT?: string + ZERORPC_SERVER_ENDPOINT?: string +} + +export interface DealOrder { + makerToken: string + takerToken: string + makerTokenAmount: string + takerTokenAmount: string + quoteId: string + timestamp: number +} + +export interface Token { + symbol: string + logo: string + contractAddress: string + decimal: number + precision: number + minTradeAmount: number + maxTradeAmount: number +} + +export interface SupportedToken extends Token { + opposites: string[] +} + +export interface TokenConfig { + symbol: string + feeFactor: number +} + +export type SIDE = 'BUY' | 'SELL' \ No newline at end of file diff --git a/src/utils/address.ts b/src/utils/address.ts new file mode 100644 index 0000000..6ae0a12 --- /dev/null +++ b/src/utils/address.ts @@ -0,0 +1,14 @@ +import { ETH_ADDRESS } from '../constants' +import { MarketMakerConfig } from '../types' + +export const getWethAddrIfIsEth = (address, config: MarketMakerConfig) => { + return address.toLowerCase() === ETH_ADDRESS ? + config.wethContractAddress.toLowerCase() : address.toLowerCase() +} + +export const addressWithout0x = (address) => { + if (address.startsWith('0x')) { + return address.slice(2).toLowerCase() + } + return address.toLowerCase() +} \ No newline at end of file diff --git a/src/utils/balance.ts b/src/utils/balance.ts new file mode 100644 index 0000000..4ce2a62 --- /dev/null +++ b/src/utils/balance.ts @@ -0,0 +1,16 @@ +import * as _ from 'lodash' +import { getWethAddrIfIsEth } from './address' +import { fromDecimalToUnit } from './format' +import { getTokenBalance } from './ethereum' +import { updaterStack } from './intervalUpdater' + +export const getTokenlonTokenBalance = async (token) => { + const config = updaterStack.markerMakerConfigUpdater.cacheResult + const balance = await getTokenBalance({ + address: config.mmProxyContractAddress, + contractAddress: getWethAddrIfIsEth(token.contractAddress, config), + }).then(balanceBN => { + return balanceBN ? fromDecimalToUnit(balanceBN, token.decimal).toNumber() : 0 + }) + return balance +} \ No newline at end of file diff --git a/src/utils/ethereum.ts b/src/utils/ethereum.ts new file mode 100644 index 0000000..921006f --- /dev/null +++ b/src/utils/ethereum.ts @@ -0,0 +1,25 @@ +import { BigNumber } from '0x.js' +import { toBN } from './math' +import { getweb3 } from './web3' +import { addressWithout0x } from './address' +import { leftPadWith0 } from './helper' + +export const getEthBalance = (address) => { + const web3 = getweb3() + return web3.eth.getBalance(address).then(toBN) +} + +export const getTokenBalance = ({ address, contractAddress }): Promise => { + const web3 = getweb3() + return new Promise((resolve, reject) => { + web3.eth.call({ + to: contractAddress, + data: `0x70a08231${leftPadWith0(addressWithout0x(address), 64)}`, + }, (err, res) => { + if (err) { + return reject(err) + } + resolve(toBN(res === '0x' ? 0 : res)) + }) + }) +} \ No newline at end of file diff --git a/src/utils/format.ts b/src/utils/format.ts new file mode 100644 index 0000000..857f1e0 --- /dev/null +++ b/src/utils/format.ts @@ -0,0 +1,34 @@ +import * as _ from 'lodash' +import { toBN } from './math' +import { isBigNumber } from '../validations' +import { BigNumber } from '0x.js' + +const translateValueHelper = (obj: object, check: (v) => boolean, operate: (v) => any): any => { + let result = {} + _.keys(obj).forEach((key) => { + const v = obj[key] + result[key] = check(v) ? operate(v) : v + }) + return result +} + +export const orderBNToString = (order) => { + let result = {} + result = translateValueHelper(order, isBigNumber, (v) => v.toString()) + return result +} + +export const fromUnitToDecimalBN = (balance, decimal): BigNumber => { + const amountBN = toBN(balance || 0) + const decimalBN = toBN(10).toPower(decimal) + // 避免出现小数点的情况 + return toBN(Math.round(amountBN.times(decimalBN).toNumber())) +} + +export const fromDecimalToUnit = (balance, decimal) => { + return toBN(balance).dividedBy(Math.pow(10, decimal)) +} + +export const fromUnitToDecimal = (balance, decimal, base) => { + return fromUnitToDecimalBN(balance, decimal).toString(base) +} \ No newline at end of file diff --git a/src/utils/helper.ts b/src/utils/helper.ts new file mode 100644 index 0000000..3b99528 --- /dev/null +++ b/src/utils/helper.ts @@ -0,0 +1,6 @@ +export const leftPadWith0 = (str, len) => { + str = str + '' + len = len - str.length + if (len <= 0) return str + return '0'.repeat(len) + str +} \ No newline at end of file diff --git a/src/utils/intervalUpdater/index.ts b/src/utils/intervalUpdater/index.ts new file mode 100644 index 0000000..fc7d70d --- /dev/null +++ b/src/utils/intervalUpdater/index.ts @@ -0,0 +1,68 @@ +import { getMarketMakerConfig, getTokenList, getTokenConfigsForMM, getMMJwtToken } from '../../request/imToken' +import { getPairs } from '../../request/marketMaker' +import IntervalUpdater from './intervalUpdater' +import { Wallet } from '../../types' + +const updaterStack = { + markerMakerConfigUpdater: null as IntervalUpdater, + tokenListFromImtokenUpdater: null as IntervalUpdater, + jwtTokenFromImtokenUpdater: null as IntervalUpdater, + tokenConfigsFromImtokenUpdater: null as IntervalUpdater, + pairsFromMMUpdater: null as IntervalUpdater, +} + +const startUpdater = async (wallet: Wallet) => { + updaterStack.markerMakerConfigUpdater = new IntervalUpdater({ + name: 'markerMakerConfig', + updater() { + return getMarketMakerConfig(wallet.address) + }, + }) + + updaterStack.tokenListFromImtokenUpdater = new IntervalUpdater({ + name: 'tokenListFromImtoken', + updater() { + return getTokenList() + }, + }) + + updaterStack.tokenConfigsFromImtokenUpdater = new IntervalUpdater({ + name: 'tokenConfigsFromImtoken', + updater() { + return getTokenConfigsForMM(wallet.address) + }, + }) + + updaterStack.jwtTokenFromImtokenUpdater = new IntervalUpdater({ + name: 'jwtTokenFromImtoken', + updater() { + return getMMJwtToken(wallet.privateKey) + }, + }) + + updaterStack.pairsFromMMUpdater = new IntervalUpdater({ + name: 'pairsFromMM', + updater() { + return getPairs() + }, + }) + + const marketMakerConfig = await updaterStack.markerMakerConfigUpdater.start() + const tokenListFromImtoken = await updaterStack.tokenListFromImtokenUpdater.start() + const jwtTokenFromImtoken = await updaterStack.jwtTokenFromImtokenUpdater.start() + const tokenConfigsFromImtoken = await updaterStack.tokenConfigsFromImtokenUpdater.start() + const pairsFrom = await updaterStack.pairsFromMMUpdater.start() + + return { + marketMakerConfig, + jwtTokenFromImtoken, + tokenListFromImtoken, + tokenConfigsFromImtoken, + pairsFrom, + } +} + +export { + startUpdater, + updaterStack, +} \ No newline at end of file diff --git a/src/utils/intervalUpdater/intervalUpdater.ts b/src/utils/intervalUpdater/intervalUpdater.ts new file mode 100644 index 0000000..4ff1c12 --- /dev/null +++ b/src/utils/intervalUpdater/intervalUpdater.ts @@ -0,0 +1,48 @@ +import * as Sentry from '@sentry/node' +import { INTERVAL_UPDAER_TIME } from '../../constants' +import tracker from '../tracker' + +interface Props { + INTERVAL_UPDAER_TIME?: number + name: string + updater: () => {} +} + +export default class IntervalUpdater { + INTERVAL_UPDAER_TIME: number + + constructor(props: Props) { + this.name = props.name + this.updater = props.updater + this.INTERVAL_UPDAER_TIME = props.INTERVAL_UPDAER_TIME || INTERVAL_UPDAER_TIME + } + cacheResult = null + + name = '' + updater = () => {} + + intervalUpdater = () => { + setTimeout(async () => { + try { + this.cacheResult = await this.updater() + } catch (error) { + tracker.captureException(error) + tracker.captureEvent({ + message: 'interval updater faild', + level: Sentry.Severity.Error, + extra: { + name: this.name, + error, + }, + }) + } + this.intervalUpdater() + }, this.INTERVAL_UPDAER_TIME) + } + + start = async () => { + this.cacheResult = await this.updater() + this.intervalUpdater() + return this.cacheResult + } +} \ No newline at end of file diff --git a/src/utils/math.ts b/src/utils/math.ts new file mode 100644 index 0000000..677c02c --- /dev/null +++ b/src/utils/math.ts @@ -0,0 +1,7 @@ +import { BigNumber } from '0x.js' + +export const toBN = obj => new BigNumber(obj) + +export const toFixed = (n, dp = 4, rm = 1): string => { + return toBN(n).toFixed(dp, rm) +} \ No newline at end of file diff --git a/src/utils/order.ts b/src/utils/order.ts new file mode 100644 index 0000000..ca4a840 --- /dev/null +++ b/src/utils/order.ts @@ -0,0 +1,127 @@ +import { MarketMakerConfig, Token, TokenConfig } from '../types' +import { assetDataUtils, generatePseudoRandomSalt, orderHashUtils, signatureUtils, SignerType, SignatureType } from '0x.js' +import * as ethUtils from 'ethereumjs-util' +import { toBN } from './math' +import { getTokenBySymbol } from './token' +import { getTimestamp } from './timestamp' +import { fromUnitToDecimalBN, orderBNToString } from './format' +import { getWallet } from './wallet' +import { ecSignOrderHash } from './sign' +import { getWethAddrIfIsEth } from './address' +import { FEE_RECIPIENT_ADDRESS } from '../constants' + +const getFixPrecision = (decimal) => { + return decimal < 8 ? decimal : 9 +} + +const getOrderAndFeeFactor = (simpleOrder, rate, tokenList: Token[], tokenConfigs: TokenConfig[], config: MarketMakerConfig) => { + const { side, amount } = simpleOrder + const baseToken = getTokenBySymbol(tokenList, simpleOrder.base) + const quoteToken = getTokenBySymbol(tokenList, simpleOrder.quote) + const amountBN = toBN(amount) + let makerToken = null + let takerToken = null + let makerAssetAmount = null + let takerAssetAmount = null + + // 针对用户买,对于做市商是提供卖单 + // 用户用quote 买base,做市商要构建卖base 换quote的order + // 因此 order makerToken 是 base,takerToken 是 quote + // 例如:用户 ETH -> DAI + // rate 200 + // side BUY + + // order makerToken is DAI + // order takerToken is WETH + // order makerAssetAmount is amount(DAI / base amount) + // order takerAssetAmount is amount of WETH (amount / rate) + if (side === 'BUY') { + makerToken = baseToken + takerToken = quoteToken + const makerTokenPrecision = getFixPrecision(makerToken.decimal) + const takerTokenPrecision = getFixPrecision(takerToken.decimal) + makerAssetAmount = fromUnitToDecimalBN( + amountBN.toFixed(makerTokenPrecision), makerToken.decimal) + takerAssetAmount = fromUnitToDecimalBN( + (amountBN.dividedBy(rate)).toFixed(takerTokenPrecision), takerToken.decimal) + + // user side SELL + } else { + makerToken = quoteToken + takerToken = baseToken + const makerTokenPrecision = getFixPrecision(makerToken.decimal) + const takerTokenPrecision = getFixPrecision(takerToken.decimal) + makerAssetAmount = fromUnitToDecimalBN( + (amountBN.times(rate)).toFixed(makerTokenPrecision), makerToken.decimal) + takerAssetAmount = fromUnitToDecimalBN( + amountBN.toFixed(takerTokenPrecision), takerToken.decimal) + } + + const order = { + makerAddress: config.mmProxyContractAddress.toLowerCase(), + makerAssetAmount, + makerAssetData: assetDataUtils.encodeERC20AssetData(getWethAddrIfIsEth(makerToken.contractAddress, config)), + makerFee: toBN(0), + + takerAddress: config.userProxyContractAddress, + takerAssetAmount, + takerAssetData: assetDataUtils.encodeERC20AssetData(getWethAddrIfIsEth(takerToken.contractAddress, config)), + takerFee: toBN(0), + + senderAddress: config.tokenlonExchangeContractAddress.toLowerCase(), + feeRecipientAddress: FEE_RECIPIENT_ADDRESS, + expirationTimeSeconds: toBN(getTimestamp() + (+config.orderExpirationSeconds)), + exchangeAddress: config.exchangeContractAddress, + } + + const foundTokenConfig = tokenConfigs.find(t => t.symbol === takerToken.symbol) + const feeFactor = foundTokenConfig && foundTokenConfig.feeFactor ? foundTokenConfig.feeFactor : (config.feeFactor ? config.feeFactor : 0) + + return { + order, + feeFactor, + } +} + +export const getFormatedSignedOrder = (simpleOrder, rate, userAddr, tokenList: Token[], tokenConfigs: TokenConfig[], config: MarketMakerConfig) => { + const { order, feeFactor } = getOrderAndFeeFactor(simpleOrder, rate, tokenList, tokenConfigs, config) + const wallet = getWallet() + + const o = { + ...order, + salt: generatePseudoRandomSalt(), + } + const orderHash = orderHashUtils.getOrderHashHex(o) + + const hash = ethUtils.bufferToHex( + Buffer.concat([ + ethUtils.toBuffer(orderHash), + ethUtils.toBuffer(userAddr.toLowerCase()), + ethUtils.toBuffer(feeFactor > 255 ? feeFactor : [0, feeFactor]), + ]), + ) + + const signature = ecSignOrderHash( + wallet.privateKey, + hash, + wallet.address, + SignerType.Default, + ) + + const walletSign = ethUtils.bufferToHex( + Buffer.concat([ + ethUtils.toBuffer(signature).slice(0, 65), + ethUtils.toBuffer(userAddr.toLowerCase()), + ethUtils.toBuffer(feeFactor > 255 ? feeFactor : [0, feeFactor]), + ]), + ) + const makerWalletSignature = signatureUtils.convertToSignatureWithType(walletSign, SignatureType.Wallet) + + const signedOrder = { + ...o, + feeFactor, + makerWalletSignature, + } + + return orderBNToString(signedOrder) +} \ No newline at end of file diff --git a/src/utils/quoteId.ts b/src/utils/quoteId.ts new file mode 100644 index 0000000..5116405 --- /dev/null +++ b/src/utils/quoteId.ts @@ -0,0 +1,11 @@ +import { updaterStack } from './intervalUpdater' + +const getPrefix = () => `${updaterStack.markerMakerConfigUpdater.cacheResult.mmId}--` + +export const addQuoteIdPrefix = quoteId => `${getPrefix()}${quoteId}` + +export const removeQuoteIdPrefix = (quoteId) => { + const prefix = getPrefix() + if (quoteId.startsWith(prefix)) return quoteId.replace(prefix, '') + return quoteId +} \ No newline at end of file diff --git a/src/utils/rate.ts b/src/utils/rate.ts new file mode 100644 index 0000000..0dc82c6 --- /dev/null +++ b/src/utils/rate.ts @@ -0,0 +1,48 @@ +import { IndicativePriceApiResult, PriceApiResult } from '../request/marketMaker/interface' +import { SIDE } from '../types' +import { toBN } from './math' +import { addQuoteIdPrefix } from './quoteId' + +export const transferIndicativePriceResultToRateBody = (priceResult: IndicativePriceApiResult, side: SIDE) => { + const { minAmount, maxAmount, message } = priceResult + if (priceResult.exchangeable === false || !priceResult.price) { + return { + result: false, + exchangeable: false, + minAmount, + maxAmount, + message: message || 'Can\'t support this trade', + } + } + + const rate = side === 'BUY' ? 1 / priceResult.price : priceResult.price + return { + result: true, + exchangeable: true, + minAmount, + maxAmount, + rate: toBN((+rate).toFixed(8)).toNumber(), + } +} + +export const transferPriceResultToRateBody = (priceResult: PriceApiResult, side: SIDE) => { + const { minAmount, maxAmount } = priceResult + const rateBody = transferIndicativePriceResultToRateBody(priceResult, side) + + if (rateBody.result && rateBody.exchangeable) { + if (!priceResult.quoteId) { + return { + result: false, + exchangeable: false, + minAmount, + maxAmount, + message: 'quoteId must be a string', + } + } + Object.assign(rateBody, { + quoteId: addQuoteIdPrefix(priceResult.quoteId), + }) + } + + return rateBody +} \ No newline at end of file diff --git a/src/utils/sign.ts b/src/utils/sign.ts new file mode 100644 index 0000000..ab40249 --- /dev/null +++ b/src/utils/sign.ts @@ -0,0 +1,144 @@ +import * as ethUtil from 'ethereumjs-util' +import { ECSignature, signatureUtils, SignerType } from '0x.js' +import { leftPadWith0 } from './helper' +import * as _ from 'lodash' + +type ECSignatureBuffer = { + v: number + r: Buffer + s: Buffer; +} + +// sig is buffer +export const concatSig = (ecSignatureBuffer: ECSignatureBuffer): Buffer => { + const { v, r, s } = ecSignatureBuffer + const vSig = ethUtil.bufferToInt(v) + const rSig = ethUtil.fromSigned(r) + const sSig = ethUtil.fromSigned(s) + const rStr = leftPadWith0(ethUtil.toUnsigned(rSig).toString('hex'), 64) + const sStr = leftPadWith0(ethUtil.toUnsigned(sSig).toString('hex'), 64) + const vStr = ethUtil.stripHexPrefix(ethUtil.intToHex(vSig)) + return ethUtil.addHexPrefix(rStr.concat(sStr, vStr)).toString('hex') +} +export const personalECSign = (privateKey: string, msg: string): ECSignatureBuffer => { + const message = ethUtil.toBuffer(msg) + const msgHash = ethUtil.hashPersonalMessage(message) + return ethUtil.ecsign(msgHash, new Buffer(privateKey, 'hex')) +} + +export const personalSign = (privateKey: string, msg: string): string => { + const sig = personalECSign(privateKey, msg) + return ethUtil.bufferToHex(concatSig(sig)) +} + +export const personalECSignHex = (privateKey: string, msg: string): ECSignature => { + const { r, s, v } = personalECSign(privateKey, msg) + const ecSignature = { + v, + r: ethUtil.bufferToHex(r), + s: ethUtil.bufferToHex(s), + } + return ecSignature +} + +// cp from https://github.com/0xProject/0x.js/blob/4d61d56639ad70b13245ca25047c6f299e746393/packages/0x.js/src/utils/signature_utils.ts +export const parseSignatureHexAsVRS = (signatureHex: string): ECSignature => { + const signatureBuffer = ethUtil.toBuffer(signatureHex) + let v = signatureBuffer[0] + if (v < 27) { + v += 27 + } + const r = signatureBuffer.slice(1, 33) + const s = signatureBuffer.slice(33, 65) + const ecSignature: ECSignature = { + v, + r: ethUtil.bufferToHex(r), + s: ethUtil.bufferToHex(s), + } + return ecSignature +} + +// cp from https://github.com/0xProject/0x.js/blob/4d61d56639ad70b13245ca25047c6f299e746393/packages/0x.js/src/utils/signature_utils.ts +export const parseSignatureHexAsRSV = (signatureHex: string): ECSignature => { + const { v, r, s } = ethUtil.fromRpcSig(signatureHex) + const ecSignature: ECSignature = { + v, + r: ethUtil.bufferToHex(r), + s: ethUtil.bufferToHex(s), + } + return ecSignature +} + +/** + * @description use personalSign to replace eth_sign + * params are same with signatureUtls.ecSignOrderHashAsync + * only replaced provider by privateKey + * https://github.com/0xProject/0x-monorepo/blob/30525d15f468dc084f923b280b265cb8d5fd4975/packages/order-utils/src/signature_utils.ts#L222 + * https://github.com/0xProject/0x-monorepo/blob/30525d15f468dc084f923b280b265cb8d5fd4975/packages/web3-wrapper/src/web3_wrapper.ts#L308 + * https://github.com/ethereumjs/ethereumjs-util/blob/90558346d41a03dc71cbde35a7df009aaabe5ee0/index.js#L362 + * https://github.com/ethereum/go-ethereum/blob/f951e23fb5ad2f7017f314a95287bc0506a67d05/internal/ethapi/api.go#L1259 + * https://github.com/ethereum/go-ethereum/blob/f951e23fb5ad2f7017f314a95287bc0506a67d05/internal/ethapi/api.go#L419 + * @author Xaber + * @param signerPrivateKey signer privateKey + * @param orderHash + * @param signerAddress + * @param signerType + */ +export const ecSignOrderHash = ( + signerPrivateKey, + orderHash: string, + signerAddress: string, + signerType: SignerType, +) => { + let msgHashHex = orderHash + const normalizedSignerAddress = signerAddress.toLowerCase() + const prefixedMsgHashHex = signatureUtils.addSignedMessagePrefix(orderHash, signerType) + + // Metamask incorrectly implements eth_sign and does not prefix the message as per the spec + // Source: https://github.com/MetaMask/metamask-extension/commit/a9d36860bec424dcee8db043d3e7da6a5ff5672e + if (signerType === SignerType.Metamask) { + msgHashHex = prefixedMsgHashHex + } + + const signature = personalSign(signerPrivateKey, msgHashHex) + + // HACK: There is no consensus on whether the signatureHex string should be formatted as + // v + r + s OR r + s + v, and different clients (even different versions of the same client) + // return the signature params in different orders. In order to support all client implementations, + // we parse the signature in both ways, and evaluate if either one is a valid signature. + // r + s + v is the most prevalent format from eth_sign, so we attempt this first. + // tslint:disable-next-line:custom-no-magic-numbers + const validVParamValues = [27, 28] + const ecSignatureRSV = parseSignatureHexAsRSV(signature) + if (_.includes(validVParamValues, ecSignatureRSV.v)) { + const isValidRSVSignature = signatureUtils.isValidECSignature( + prefixedMsgHashHex, + ecSignatureRSV, + normalizedSignerAddress, + ) + if (isValidRSVSignature) { + const convertedSignatureHex = signatureUtils.convertECSignatureToSignatureHex( + ecSignatureRSV, + signerType, + ) + return convertedSignatureHex + } + } + const ecSignatureVRS = parseSignatureHexAsVRS(signature) + if (_.includes(validVParamValues, ecSignatureVRS.v)) { + const isValidVRSSignature = signatureUtils.isValidECSignature( + prefixedMsgHashHex, + ecSignatureVRS, + normalizedSignerAddress, + ) + if (isValidVRSSignature) { + const convertedSignatureHex = signatureUtils.convertECSignatureToSignatureHex( + ecSignatureVRS, + signerType, + ) + return convertedSignatureHex + } + } + + throw new Error('InvalidSignature') +} \ No newline at end of file diff --git a/src/utils/stomp.ts b/src/utils/stomp.ts new file mode 100644 index 0000000..8bfbdc0 --- /dev/null +++ b/src/utils/stomp.ts @@ -0,0 +1,159 @@ +import * as Sentry from '@sentry/node' +import * as SockJS from 'sockjs-client' +import { Stomp } from 'stompjs/lib/stomp' +import { config } from '../config' +import { updaterStack } from './intervalUpdater' +import { MAX_WEBSOCKET_RECONNECT_TRIED_TIMES, WEBSOCKET_TRY_CONNECT_INTERVAL } from '../constants' +import tracker from './tracker' +import { dealOrder } from '../request/marketMaker' + +// 提供ws服务的端点名称 +const endpoint = 'exchange' + +export default class StompForExchange { + stompClient = null + userDealSubscription = null + connecting = false + connected = false + triedFailedTimes = 0 + + connectStomp = () => { + const token = updaterStack.jwtTokenFromImtokenUpdater.cacheResult + const Authorization = `MMSK ${token}` + const host = config.WEBSOCKET_URL.replace(/\/rpc$/, '') + + if (this.connecting) { + tracker.captureEvent({ + message: 'connecting', + level: Sentry.Severity.Warning, + extra: { + triedFailedTimes: this.triedFailedTimes, + }, + }) + throw new Error('connecting') + } + + if (this.isTriedMaxTimes()) { + tracker.captureEvent({ + message: `tried MAX_WEBSOCKET_RECONNECT_TRIED_TIMES ${MAX_WEBSOCKET_RECONNECT_TRIED_TIMES} times still can not connect`, + level: Sentry.Severity.Fatal, + extra: { + triedFailedTimes: this.triedFailedTimes, + }, + }) + throw new Error(`tried ${this.triedFailedTimes} times still can not connect`) + } + + this.connecting = true + + try { + const socket = new SockJS(`${host}/${endpoint}?Authorization=${encodeURIComponent(Authorization)}`) + this.stompClient = Stomp.over(socket) + this.stompClient.connect( + '', + '', + () => { + this.userDeal() + this.connecting = false + this.connected = true + this.resetTriedTimes() + }, + async (error) => { + tracker.captureEvent({ + message: 'disconnect', + level: Sentry.Severity.Warning, + extra: { + triedFailedTimes: this.triedFailedTimes, + error, + }, + }) + this.connecting = false + this.connected = false + this.triedFailedTimes += 1 + setTimeout(this.connectStomp, WEBSOCKET_TRY_CONNECT_INTERVAL) + }, + ) + } catch (error) { + tracker.captureEvent({ + message: 'connect error', + level: Sentry.Severity.Error, + extra: { + triedFailedTimes: this.triedFailedTimes, + error, + }, + }) + this.connecting = false + this.connected = false + this.triedFailedTimes += 1 + setTimeout(this.connectStomp, WEBSOCKET_TRY_CONNECT_INTERVAL) + } + } + + resetTriedTimes = () => { + this.triedFailedTimes = 0 + } + + isTriedMaxTimes = () => { + return this.triedFailedTimes >= MAX_WEBSOCKET_RECONNECT_TRIED_TIMES + } + + disconnectStomp = () => { + this.userDealSubscription && this.userDealSubscription.unsubscribe() + this.stompClient && this.stompClient.disconnect() + this.userDealSubscription = null + this.stompClient = null + } + + private wsSubscribeJsonHelper = (subscribeName, path, callback) => { + if (this.stompClient) { + this[subscribeName] && this[subscribeName].unsubscribe() + try { + this[subscribeName] = this.stompClient.subscribe(path, (message) => { + try { + const obj = JSON.parse(message.body) + callback(obj) + } catch (error) { + tracker.captureEvent({ + message: 'path get message JSON.parse error', + level: Sentry.Severity.Warning, + extra: { + path, + error, + subscribeName, + }, + }) + } + }) + } catch (error) { + tracker.captureEvent({ + message: 'subscrible error', + level: Sentry.Severity.Error, + extra: { + path, + error, + subscribeName, + }, + }) + } + return this[subscribeName] + } + } + + userDeal = () => { + const mmProxyContractAddress = updaterStack.markerMakerConfigUpdater.cacheResult.mmProxyContractAddress + const path = `/user/deal/${mmProxyContractAddress}` + this.wsSubscribeJsonHelper('userDealSubscription', path, async (order) => { + tracker.captureEvent({ + message: 'userDeal trigger', + level: Sentry.Severity.Log, + extra: order, + }) + dealOrder(order) + if (this.stompClient) { + const { quoteId } = order + // mark order dealt + this.stompClient.send(path, {}, JSON.stringify({ quoteId })) + } + }) + } +} diff --git a/src/utils/timestamp.ts b/src/utils/timestamp.ts new file mode 100644 index 0000000..ce6f925 --- /dev/null +++ b/src/utils/timestamp.ts @@ -0,0 +1 @@ +export const getTimestamp = () => Math.ceil(Date.now() / 1000) \ No newline at end of file diff --git a/src/utils/token.ts b/src/utils/token.ts new file mode 100644 index 0000000..dafa112 --- /dev/null +++ b/src/utils/token.ts @@ -0,0 +1,57 @@ +import * as _ from 'lodash' +import { updaterStack } from '../utils/intervalUpdater' +import { SupportedToken } from '../types' + +const helper = (stack, token1, token2) => { + if (stack[token1] && stack[token1].indexOf(token2) === -1) { + stack[token1].push(token2) + } else if (!stack[token1]) { + stack[token1] = [token2] + } +} + +/** + * ['SNT/ETH', 'SNT/TUSD'] => + * { + * SNT: ['ETH', 'TUSD'] + * ETH: ['SNT'] + * TUSD: ['SNT'] + * } + */ +const transferPairStrArrToTokenStack = (pairStrArr) => { + const stack = {} + pairStrArr.forEach(pairStr => { + const [tokenA, tokenB] = pairStr.split('/') + helper(stack, tokenA, tokenB) + helper(stack, tokenB, tokenA) + }) + return stack +} + +export const getSupportedTokens = (): SupportedToken[] => { + const { tokenListFromImtokenUpdater, pairsFromMMUpdater } = updaterStack + const tokenStack = transferPairStrArrToTokenStack(pairsFromMMUpdater.cacheResult) + const tokenList = tokenListFromImtokenUpdater.cacheResult + const result = [] + tokenList.forEach(token => { + const { symbol } = token + const opposites = tokenStack[symbol] + if (opposites && opposites.length) { + result.push({ + ...token, + opposites: opposites.filter(symbol => !!tokenList.find(t => t.symbol === symbol)), + }) + } + }) + return result +} + +export const isSupportedBaseQuote = (tokens: SupportedToken[], baseQuote): boolean => { + return tokens.some(t => { + return t.symbol === baseQuote.base && t.opposites.indexOf(baseQuote.quote) !== -1 + }) +} + +export const getTokenBySymbol = (tokens, symbol) => { + return tokens.find(t => t.symbol === symbol) +} \ No newline at end of file diff --git a/src/utils/tracker.ts b/src/utils/tracker.ts new file mode 100644 index 0000000..bbbf906 --- /dev/null +++ b/src/utils/tracker.ts @@ -0,0 +1,45 @@ +import * as Sentry from '@sentry/node' + +let enabled = false + +export const initSentry = ({ SENTRY_DSN, NODE_ENV }) => { + Sentry.init({ dsn: SENTRY_DSN, environment: NODE_ENV ? NODE_ENV.toLowerCase() : 'development' }) +} + +export const init = ({ SENTRY_DSN, NODE_ENV }) => { + enabled = SENTRY_DSN && SENTRY_DSN.indexOf('https') !== -1 && NODE_ENV && ['DEVELOPMENT', 'STAGING', 'PRODUCTION'].includes(NODE_ENV) + if (enabled) { + initSentry({ SENTRY_DSN, NODE_ENV }) + } +} + +export const captureMessage = (message: string, level?: Sentry.Severity) => { + if (enabled) { + Sentry.captureMessage(message, level) + } else { + console.log(message, level) + } +} + +export const captureException = (error: Error) => { + if (enabled) { + Sentry.captureException(error) + } else { + console.log(error) + } +} + +export const captureEvent = (options: Sentry.SentryEvent) => { + if (enabled) { + Sentry.captureEvent(options) + } else { + console.log(options) + } +} + +export default { + init, + captureEvent, + captureMessage, + captureException, +} diff --git a/src/utils/wallet.ts b/src/utils/wallet.ts new file mode 100644 index 0000000..42d5b06 --- /dev/null +++ b/src/utils/wallet.ts @@ -0,0 +1,12 @@ +import { config } from '../config' +let wallet = null + +export const getWallet = () => { + if (!wallet) { + wallet = { + address: config.WALLET_ADDRESS, + privateKey: config.WALLET_PRIVATE_KEY, + } + } + return wallet +} diff --git a/src/utils/web3.ts b/src/utils/web3.ts new file mode 100644 index 0000000..91c1701 --- /dev/null +++ b/src/utils/web3.ts @@ -0,0 +1,13 @@ +import * as Web3Export from 'web3' +import { config } from '../config' + +const Web3 = Web3Export.default ? Web3Export.default : Web3Export + +let web3 = null + +export const getweb3 = () => { + if (!web3) { + web3 = new Web3(new Web3.providers.HttpProvider(config.PROVIDER_URL)) + } + return web3 +} \ No newline at end of file diff --git a/src/validations/index.ts b/src/validations/index.ts new file mode 100644 index 0000000..26b0012 --- /dev/null +++ b/src/validations/index.ts @@ -0,0 +1,56 @@ +import * as _ from 'lodash' +import * as Web3Export from 'web3' +import { Wallet } from '../types' +import * as ethUtils from 'ethereumjs-util' +import { BigNumber } from '0x.js' +import { isSupportedBaseQuote, getSupportedTokens } from '../utils/token' + +const Web3 = Web3Export.default ? Web3Export.default : Web3Export + +export const isValidWallet = (wallet: Wallet) => { + if (!wallet) return false + const { address, privateKey } = wallet + if (!Web3.utils.isAddress(address)) return false + const addr = ethUtils.privateToAddress(new Buffer(privateKey, 'hex')) + return `0x${addr.toString('hex')}`.toLowerCase() === address.toLowerCase() +} + +export const isBigNumber = (v: any) => { + return v instanceof BigNumber || + (v && v.isBigNumber === true) || + (v && v._isBigNumber === true) || + false +} + +export const checkParams = (query, isNewOrderAPI?: boolean) => { + let message = '' + if (!['base', 'quote', 'side'].every((key) => { + return _.isString(query[key]) + })) { + message = 'base, quote, side must be string type' + + } else if (query.side !== 'BUY' && query.side !== 'SELL') { + message = 'side must be one of \'BUY\' and \'SELL\'' + + } else if (!isSupportedBaseQuote(getSupportedTokens(), query)) { + message = `Don't support ${query.base} - ${query.quote} trade` + + } else if (query.amount && (_.isNaN(+query.amount) || +query.amount < 0)) { + message = `getRate's amount ${query.amount} must be a number >= 0, or you should not send this amount parameter` + + } else if (isNewOrderAPI) { + if (_.isNaN(+query.amount) || +query.amount <= 0) { + message = `order's amount ${query.amount} must be a number > 0` + + } else if (!_.isString(query.uniqId)) { + message = `query.uniqId ${query.uniqId} must be string type` + + } else if (!Web3.utils.isAddress(query.userAddr)) { + message = `userAddr ${query.userAddr} is not a valid address` + } + } + return { + result: !message, + message, + } +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..8256785 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "target": "es5", + "sourceMap": true, + "outDir": "lib", + "rootDir": "src", + "noImplicitThis": true, + "noUnusedParameters": true, + "noUnusedLocals": true, + "resolveJsonModule": true, + "declaration": true, + "declarationDir": "lib", + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "moduleResolution": "node", + "baseUrl": "./", + "pretty": true, + "lib": [ + "es2017", + "dom" + ] + }, + "include": [ + "./src/**/*" + ], + "exclude": [ + "node_modules" + ] +} \ No newline at end of file diff --git a/tslint.json b/tslint.json new file mode 100644 index 0000000..1d61df5 --- /dev/null +++ b/tslint.json @@ -0,0 +1,135 @@ +{ + "rules": { + "member-access": false, + "no-any": false, + "no-inferrable-types": [ + false + ], + "no-internal-module": true, + "no-var-requires": true, + "typedef": [ + false + ], + "typedef-whitespace": [ + true, + { + "call-signature": "nospace", + "index-signature": "nospace", + "parameter": "nospace", + "property-declaration": "nospace", + "variable-declaration": "nospace" + }, + { + "call-signature": "space", + "index-signature": "space", + "parameter": "space", + "property-declaration": "space", + "variable-declaration": "space" + } + ], + "ban": false, + "curly": false, + "forin": false, + "label-position": true, + "no-arg": true, + "no-bitwise": true, + "no-conditional-assignment": true, + "no-console": [ + true, + "debug", + "info", + "time", + "timeEnd", + "trace" + ], + "no-construct": true, + "no-debugger": true, + "no-duplicate-variable": true, + "no-empty": false, + "no-eval": true, + "no-null-keyword": false, + "no-shadowed-variable": false, + "no-string-literal": true, + "no-switch-case-fall-through": true, + "no-unused-expression": [ + true, + "allow-fast-null-checks" + ], + "no-use-before-declare": true, + "no-var-keyword": true, + "radix": true, + "switch-default": true, + "triple-equals": [ + true, + "allow-undefined-check" + ], + "eofline": false, + "indent": [ + true, + "spaces" + ], + "max-line-length": [ + false, + 150 + ], + "no-require-imports": false, + "no-trailing-whitespace": true, + "object-literal-sort-keys": false, + "trailing-comma": [ + true, + { + "multiline": "always", + "singleline": "never" + } + ], + "align": [ + true + ], + "class-name": true, + "comment-format": [ + true, + "check-space" + ], + "interface-name": [ + false + ], + "jsdoc-format": false, + "no-consecutive-blank-lines": [ + true + ], + "no-parameter-properties": false, + "one-line": [ + true, + "check-open-brace", + "check-catch", + "check-else", + "check-finally", + "check-whitespace" + ], + "quotemark": [ + true, + "single", + "jsx-double", + "avoid-escape" + ], + "semicolon": [ + true, + "never" + ], + "variable-name": [ + true, + "check-format", + "allow-pascal-case", + "allow-leading-underscore", + "ban-keywords" + ], + "whitespace": [ + true, + "check-branch", + "check-decl", + "check-operator", + "check-separator", + "check-type" + ] + } +} \ No newline at end of file