├── .eslintignore ├── trade ├── settings │ └── tradeParams_Default.js ├── co_test.js ├── orderStats.js └── api │ ├── p2pb2b_api.js │ ├── coinstore_api.js │ ├── stakecube_api.js │ ├── azbit_api.js │ ├── tapbit_api.js │ ├── biconomy_api.js │ ├── binance_api.js │ └── bittrex_api.js ├── assets ├── placed-orders.png └── trades-on-a-chart.png ├── babel.config.json ├── modules ├── tradeapi.js ├── eventEmitter.js ├── api.js ├── transferTxs.js ├── DB.js ├── config │ ├── validate.js │ ├── schema.js │ └── reader.js ├── checkerTransactions.js ├── Store.js ├── botInterchange.js ├── incomingTxsParser.js └── unknownTxs.js ├── routes ├── health.js ├── debug.js └── init.js ├── .editorconfig ├── utils └── index.js ├── .gitignore ├── helpers ├── cryptos │ ├── baseCoin.js │ ├── adm_utils.js │ └── exchanger.js ├── encryption.js ├── dateTime.js ├── log.js ├── const.js ├── dbModel.js ├── networks.js └── notify.js ├── .eslintrc.js ├── app.js ├── package.json ├── CONTRIBUTING.md ├── README.md └── config.default.jsonc /.eslintignore: -------------------------------------------------------------------------------- 1 | /trade/settings 2 | /trade/tests 3 | /trade/co_test.js -------------------------------------------------------------------------------- /trade/settings/tradeParams_Default.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | }; 3 | -------------------------------------------------------------------------------- /assets/placed-orders.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Adamant-im/adamant-coinoptimus/HEAD/assets/placed-orders.png -------------------------------------------------------------------------------- /assets/trades-on-a-chart.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Adamant-im/adamant-coinoptimus/HEAD/assets/trades-on-a-chart.png -------------------------------------------------------------------------------- /babel.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env"], 3 | "plugins": [ 4 | ["@babel/transform-runtime"] 5 | ] 6 | } -------------------------------------------------------------------------------- /modules/tradeapi.js: -------------------------------------------------------------------------------- 1 | const config = require('./config/reader'); 2 | module.exports = require('./trade/' + config.exchange)(config.apikey, config.apisecret, config.apipassword); 3 | -------------------------------------------------------------------------------- /modules/eventEmitter.js: -------------------------------------------------------------------------------- 1 | const Emitter = require('events'); 2 | 3 | const emitter = new Emitter(); 4 | 5 | const events = { 6 | 'parameters:update': 'parameters_update', 7 | }; 8 | 9 | 10 | module.exports = { emitter, events }; 11 | -------------------------------------------------------------------------------- /routes/health.js: -------------------------------------------------------------------------------- 1 | const { Router } = require('express'); 2 | 3 | const router = new Router(); 4 | 5 | router.get('/ping', (req, res) => { 6 | res.status(200).send({ timestamp: Date.now() }); 7 | }); 8 | 9 | module.exports = router; 10 | -------------------------------------------------------------------------------- /modules/api.js: -------------------------------------------------------------------------------- 1 | const config = require('./config/reader'); 2 | const log = require('../helpers/log'); 3 | 4 | if (config.passPhrase) { 5 | module.exports = require('adamant-api')({ node: config.node_ADM, logLevel: config.log_level }, log); 6 | } else { 7 | module.exports = { 8 | sendMessage: () => { 9 | return { success: true }; 10 | }, 11 | }; 12 | } 13 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Install EditorConfig Plugin on your IDE for one coding style 2 | # EditorConfig is awesome: http://EditorConfig.org 3 | 4 | root = true 5 | 6 | [*] 7 | charset = utf-8 8 | end_of_line = lf 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | indent_style = space 12 | indent_size = 2 13 | [*.md] 14 | max_line_length = off 15 | trim_trailing_whitespace = false 16 | -------------------------------------------------------------------------------- /utils/index.js: -------------------------------------------------------------------------------- 1 | const formatPairName = (pair) => { 2 | if (pair.indexOf('-') > -1) { 3 | pair = pair.replace('-', '_').toUpperCase(); 4 | } else { 5 | pair = pair.replace('/', '_').toUpperCase(); 6 | } 7 | const [coin1, coin2] = pair.split('_'); 8 | return { 9 | pair, 10 | coin1: coin1.toUpperCase(), 11 | coin2: coin2.toUpperCase(), 12 | }; 13 | }; 14 | 15 | module.exports = { formatPairName }; 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | logs/ 3 | .vscode/ 4 | tests.js 5 | config.test 6 | config.test.json 7 | config.test.jsonc 8 | config.json 9 | config.test1.jsonc 10 | config.jsonc 11 | .DS_Store 12 | .idea/ 13 | test_*.js 14 | errcase.py 15 | error.list 16 | jsconfig.json 17 | balances.out 18 | trade/tests/debug_trader.js 19 | client.py 20 | test_encode.py 21 | coverage/ 22 | trade/settings/ 23 | !trade/settings/tradeParams_Default.js 24 | -------------------------------------------------------------------------------- /routes/debug.js: -------------------------------------------------------------------------------- 1 | const { Router } = require('express'); 2 | const db = require('../modules/DB'); 3 | 4 | const router = new Router(); 5 | 6 | router.get('/db', (req, res) => { 7 | const tb = db[req.query.tb].db; 8 | if (!tb) { 9 | res.json({ 10 | err: 'tb not find', 11 | }); 12 | return; 13 | } 14 | tb.find().toArray() 15 | .then((result) => { 16 | res.json({ 17 | result, 18 | success: true, 19 | }); 20 | }) 21 | .catch((err) => { 22 | res.json({ 23 | err, 24 | success: false, 25 | }); 26 | }); 27 | }); 28 | 29 | module.exports = router; 30 | -------------------------------------------------------------------------------- /helpers/cryptos/baseCoin.js: -------------------------------------------------------------------------------- 1 | module.exports = class baseCoin { 2 | 3 | cache = { 4 | getData(data, validOnly) { 5 | if (this[data] && this[data].timestamp) { 6 | if (!validOnly || (Date.now() - this[data].timestamp < this[data].lifetime)) { 7 | return this[data].value; 8 | } 9 | } 10 | return undefined; 11 | }, 12 | cacheData(data, value) { 13 | this[data].value = value; 14 | this[data].timestamp = Date.now(); 15 | }, 16 | }; 17 | 18 | account = { 19 | passPhrase: undefined, 20 | privateKey: undefined, 21 | keyPair: undefined, 22 | address: undefined, 23 | }; 24 | 25 | }; 26 | -------------------------------------------------------------------------------- /routes/init.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const log = require('../helpers/log'); 3 | const config = require('../modules/config/reader'); 4 | const healthApi = require('./health'); 5 | const debugApi = require('./debug'); 6 | 7 | module.exports = { 8 | initApi() { 9 | const app = express(); 10 | 11 | if (config.api.health) { 12 | app.use('/', healthApi); 13 | } 14 | 15 | if (config.api.debug) { 16 | app.use('/', debugApi); 17 | } 18 | 19 | app.listen(config.api.port, () => { 20 | log.info(`API server is listening on http://localhost:${config.api.port}. Health enabled: ${config.api.health}. Debug enabled: ${config.api.debug}.`); 21 | }); 22 | }, 23 | }; 24 | -------------------------------------------------------------------------------- /modules/transferTxs.js: -------------------------------------------------------------------------------- 1 | const notify = require('../helpers/notify'); 2 | const config = require('./config/reader'); 3 | const api = require('./api'); 4 | const log = require('../helpers/log'); 5 | 6 | module.exports = async (itx, tx) => { 7 | 8 | const msgSendBack = 'I got a transfer from you. Thanks, bro.'; 9 | const msgNotify = `${config.notifyName} got a transfer transaction. Income ADAMANT Tx: https://explorer.adamant.im/tx/${tx.id}.`; 10 | const notifyType = 'log'; 11 | 12 | await itx.update({ isProcessed: true }, true); 13 | 14 | notify(msgNotify, notifyType); 15 | api.sendMessage(config.passPhrase, tx.senderId, msgSendBack).then((response) => { 16 | if (!response.success) { 17 | log.warn(`Failed to send ADM message '${msgSendBack}' to ${tx.senderId}. ${response.errorMessage}.`); 18 | } 19 | }); 20 | 21 | }; 22 | -------------------------------------------------------------------------------- /helpers/encryption.js: -------------------------------------------------------------------------------- 1 | const crypto = require('crypto'); 2 | const config = require('../modules/config/reader'); 3 | 4 | const iv = crypto.randomBytes(16); 5 | const secretKey = crypto 6 | .createHash('sha256') 7 | .update(String(config.com_server_secret_key)) 8 | .digest('base64') 9 | .substr(0, 32); 10 | 11 | const encrypt = (text) => { 12 | const cipher = crypto.createCipheriv('aes-256-ctr', secretKey, iv); 13 | const encrypted = Buffer.concat([cipher.update(text), cipher.final()]); 14 | 15 | return { 16 | iv: iv.toString('hex'), 17 | content: encrypted.toString('hex'), 18 | }; 19 | }; 20 | 21 | const decrypt = (hash) => { 22 | const decipher = crypto.createDecipheriv('aes-256-ctr', secretKey, Buffer.from(hash.iv, 'hex')); 23 | const decrypted = Buffer.concat([decipher.update(Buffer.from(hash.content, 'hex')), decipher.final()]); 24 | 25 | return decrypted.toString(); 26 | }; 27 | 28 | module.exports = { encrypt, decrypt }; 29 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | commonjs: true, 4 | es2021: true, 5 | node: true, 6 | }, 7 | extends: [ 8 | 'eslint:recommended', 9 | 'google', 10 | ], 11 | parser: '@babel/eslint-parser', 12 | parserOptions: { 13 | ecmaVersion: 12, 14 | }, 15 | rules: { 16 | quotes: ['error', 'single'], 17 | 'prefer-arrow-callback': ['error'], 18 | 'object-shorthand': ['error', 'always'], 19 | 'quote-props': ['error', 'as-needed'], 20 | 'object-curly-spacing': ['error', 'always'], 21 | 'max-len': ['error', 22 | { code: 133, 23 | ignoreTrailingComments: true, 24 | ignoreComments: true, 25 | ignoreUrls: true, 26 | ignoreStrings: true, 27 | ignoreTemplateLiterals: true, 28 | ignoreRegExpLiterals: true, 29 | }], 30 | 'require-jsdoc': ['off'], 31 | 'valid-jsdoc': ['off'], 32 | 'no-array-constructor': ['off'], 33 | 'no-caller': ['off'], 34 | 'prefer-promise-reject-errors': ['off'], 35 | 'guard-for-in': ['off'], 36 | 'padded-blocks': ['off'], 37 | 'new-cap': ['off'], 38 | camelcase: ['off'], 39 | eqeqeq: ['error', 'always'], 40 | }, 41 | }; 42 | -------------------------------------------------------------------------------- /helpers/dateTime.js: -------------------------------------------------------------------------------- 1 | function time() { 2 | return formatDate(Date.now()).hh_mm_ss; 3 | } 4 | 5 | function date() { 6 | return formatDate(Date.now()).YYYY_MM_DD; 7 | } 8 | 9 | function fullTime() { 10 | return date() + ' ' + time(); 11 | } 12 | 13 | /** 14 | * Formats unix timestamp to string 15 | * @param {number} timestamp Timestamp to format 16 | * @return {object} Contains different formatted strings 17 | */ 18 | function formatDate(timestamp) { 19 | if (!timestamp) return false; 20 | const formattedDate = {}; 21 | const dateObject = new Date(timestamp); 22 | formattedDate.year = dateObject.getFullYear(); 23 | formattedDate.month = ('0' + (dateObject.getMonth() + 1)).slice(-2); 24 | formattedDate.date = ('0' + dateObject.getDate()).slice(-2); 25 | formattedDate.hours = ('0' + dateObject.getHours()).slice(-2); 26 | formattedDate.minutes = ('0' + dateObject.getMinutes()).slice(-2); 27 | formattedDate.seconds = ('0' + dateObject.getSeconds()).slice(-2); 28 | formattedDate.YYYY_MM_DD = formattedDate.year + '-' + formattedDate.month + '-' + formattedDate.date; 29 | formattedDate.YYYY_MM_DD_hh_mm = formattedDate.year + '-' + formattedDate.month + '-' + formattedDate.date + ' ' + formattedDate.hours + ':' + formattedDate.minutes; 30 | formattedDate.hh_mm_ss = formattedDate.hours + ':' + formattedDate.minutes + ':' + formattedDate.seconds; 31 | return formattedDate; 32 | } 33 | 34 | module.exports = { 35 | time, 36 | date, 37 | fullTime, 38 | }; 39 | -------------------------------------------------------------------------------- /modules/DB.js: -------------------------------------------------------------------------------- 1 | const log = require('../helpers/log'); 2 | const MongoClient = require('mongodb').MongoClient; 3 | const mongoClient = new MongoClient('mongodb://127.0.0.1:27017/', { serverSelectionTimeoutMS: 3000 }); 4 | const model = require('../helpers/dbModel'); 5 | const config = require('./config/reader'); 6 | 7 | const dbName = 'coinoptimusdb'; 8 | const collections = {}; 9 | 10 | mongoClient.connect() 11 | .then((client) => { 12 | const db = client.db(dbName); 13 | 14 | collections.db = db; 15 | 16 | const incomingTxsCollection = db.collection('incomingtxs'); 17 | incomingTxsCollection.createIndex([['date', 1], ['senderId', 1]]); 18 | 19 | const ordersCollection = db.collection('orders'); 20 | ordersCollection.createIndex([['isProcessed', 1], ['purpose', 1]]); 21 | ordersCollection.createIndex([['pair', 1], ['exchange', 1]]); 22 | 23 | const fillsCollection = db.collection('fills'); 24 | fillsCollection.createIndex([['isProcessed', 1], ['purpose', 1]]); 25 | fillsCollection.createIndex([['pair', 1], ['exchange', 1]]); 26 | 27 | collections.fillsDb = model(fillsCollection); 28 | collections.ordersDb = model(ordersCollection); 29 | collections.incomingTxsDb = model(incomingTxsCollection); 30 | collections.systemDb = model(db.collection('systems')); 31 | 32 | log.log(`${config.notifyName} successfully connected to '${dbName}' MongoDB.`); 33 | }) 34 | .catch((error) => { 35 | log.error(`Unable to connect to MongoDB: ${error}`); 36 | process.exit(-1); 37 | }); 38 | 39 | module.exports = collections; 40 | -------------------------------------------------------------------------------- /modules/config/validate.js: -------------------------------------------------------------------------------- 1 | function validate(config, schema, parentKey = '') { 2 | for (const [key, value] of Object.entries(schema)) { 3 | const fieldName = parentKey ? `${parentKey}.${key}` : key; 4 | 5 | if (config[key] === undefined) { 6 | if (value.isRequired) { 7 | throw new Error(`Field _${fieldName}_ is required.`); 8 | } else if (value.default !== undefined) { 9 | config[key] = value.default; 10 | } 11 | 12 | continue; 13 | } 14 | 15 | if (Array.isArray(value.type)) { 16 | validateArray(config[key], value.type, fieldName); 17 | continue; 18 | } 19 | 20 | if (typeof value.type === 'object') { 21 | validateObject(config[key], value.type, fieldName); 22 | continue; 23 | } 24 | 25 | if (config[key] !== false && value.type !== config[key].constructor) { 26 | throw new Error(`Field _${fieldName}_ is not valid, expected type is _${value.type.name}_.`); 27 | } 28 | } 29 | } 30 | 31 | function validateArray(array, type, fieldName) { 32 | if (!Array.isArray(array)) { 33 | throw new Error(`Field _${fieldName}_ is not valid, expected an array.`); 34 | } 35 | 36 | const isValidArray = array.every((item) => type.includes(item.constructor)); 37 | 38 | if (!isValidArray) { 39 | throw new Error(`Field _${fieldName}_ items are not valid.`); 40 | } 41 | } 42 | 43 | function validateObject(object, schema, fieldName) { 44 | if (typeof object !== 'object') { 45 | throw new Error(`Field _${fieldName}_ is not valid, expected an object.`); 46 | } 47 | 48 | validate(object, schema, fieldName); 49 | } 50 | 51 | module.exports = validate; 52 | -------------------------------------------------------------------------------- /modules/checkerTransactions.js: -------------------------------------------------------------------------------- 1 | const Store = require('./Store'); 2 | const api = require('./api'); 3 | const txParser = require('./incomingTxsParser'); 4 | const log = require('../helpers/log'); 5 | const config = require('./config/reader'); 6 | const constants = require('../helpers/const'); 7 | const utils = require('../helpers/utils'); 8 | 9 | async function check() { 10 | 11 | try { 12 | 13 | const lastProcessedBlockHeight = await Store.getLastProcessedBlockHeight(); 14 | if (!lastProcessedBlockHeight) { 15 | log.warn(`Unable to get last processed ADM block in check() of ${utils.getModuleName(module.id)} module. Will try next time.`); 16 | return; 17 | } 18 | 19 | const queryParams = { 20 | 'and:recipientId': config.address, // get only Txs for the bot 21 | 'and:types': '0,8', // get direct transfers and messages 22 | 'and:fromHeight': lastProcessedBlockHeight + 1, // from current height if the first run, or from the last processed block 23 | returnAsset: '1', // get messages' contents 24 | orderBy: 'timestamp:desc', // latest Txs 25 | }; 26 | 27 | const txTrx = await api.get('transactions', queryParams); 28 | if (txTrx.success) { 29 | for (const tx of txTrx.data.transactions) { 30 | await txParser(tx); 31 | } 32 | } else { 33 | log.warn(`Failed to get Txs in check() of ${utils.getModuleName(module.id)} module. ${txTrx.errorMessage}.`); 34 | } 35 | 36 | } catch (e) { 37 | log.error('Error while checking new transactions: ' + e); 38 | } 39 | 40 | } 41 | 42 | module.exports = () => { 43 | setInterval(check, constants.TX_CHECKER_INTERVAL); 44 | }; 45 | -------------------------------------------------------------------------------- /helpers/log.js: -------------------------------------------------------------------------------- 1 | const config = require('../modules/config/reader'); 2 | const dateTime = require('./dateTime'); 3 | 4 | const fs = require('fs'); 5 | if (!fs.existsSync('./logs')) { 6 | fs.mkdirSync('./logs'); 7 | } 8 | 9 | const infoStr = fs.createWriteStream('./logs/' + dateTime.date() + '.log', { 10 | flags: 'a', 11 | }); 12 | 13 | infoStr.write(`\n\n[The bot started] _________________${dateTime.fullTime()}_________________\n`); 14 | 15 | module.exports = { 16 | error(str) { 17 | if (['error', 'warn', 'info', 'log'].includes(config.log_level)) { 18 | if (!process.env.CLI_MODE_ENABLED) { 19 | console.log('\x1b[31m', 'error|' + dateTime.fullTime(), '\x1b[0m', str); 20 | } 21 | infoStr.write('\n ' + 'error|' + dateTime.fullTime() + '|' + str); 22 | } 23 | }, 24 | warn(str) { 25 | if (['warn', 'info', 'log'].includes(config.log_level)) { 26 | if (!process.env.CLI_MODE_ENABLED) { 27 | console.log('\x1b[33m', 'warn|' + dateTime.fullTime(), '\x1b[0m', str); 28 | } 29 | infoStr.write('\n ' + 'warn|' + dateTime.fullTime() + '|' + str); 30 | } 31 | }, 32 | info(str) { 33 | if (['info', 'log'].includes(config.log_level)) { 34 | if (!process.env.CLI_MODE_ENABLED) { 35 | console.log('\x1b[32m', 'info|' + dateTime.fullTime(), '\x1b[0m', str); 36 | } 37 | infoStr.write('\n ' + 'info|' + dateTime.fullTime() + '|' + str); 38 | } 39 | }, 40 | log(str) { 41 | if (['log'].includes(config.log_level)) { 42 | if (!process.env.CLI_MODE_ENABLED) { 43 | console.log('\x1b[34m', 'log|' + dateTime.fullTime(), '\x1b[0m', str); 44 | } 45 | infoStr.write('\n ' + 'log|[' + dateTime.fullTime() + '|' + str); 46 | } 47 | }, 48 | }; 49 | -------------------------------------------------------------------------------- /helpers/const.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | MINUTE: 60 * 1000, 3 | HOUR: 60 * 60 * 1000, 4 | DAY: 24 * 60 * 60 * 1000, 5 | SAT: 100000000, // 1 ADM = 100000000 6 | ADM_EXPLORER_URL: 'https://explorer.adamant.im', 7 | EPOCH: Date.UTC(2017, 8, 2, 17, 0, 0, 0), // ADAMANT's epoch time 8 | TX_CHECKER_INTERVAL: 4 * 1000, // Check for new Txs every 4 seconds; additionally Exchanger receives new Txs instantly via socket 9 | UPDATE_CRYPTO_RATES_INTERVAL: 60 * 1000, // Update crypto rates every minute 10 | PRECISION_DECIMALS: 8, // Accuracy for converting cryptos, 9.12345678 ETH 11 | PRINT_DECIMALS: 8, // For pretty print, 9.12345678 ETH 12 | DEFAULT_WITHDRAWAL_PRECISION: 8, // If exchange's currency info doesn't provide coin decimals 13 | MAX_ADM_MESSAGE_LENGTH: 10000, 14 | MAX_TELEGRAM_MESSAGE_LENGTH: 4095, 15 | EXECUTE_IN_ORDER_BOOK_MAX_PRICE_CHANGE_PERCENT: 0.15, // In-orderbook trading: don't change price by mm-order more, than 0.15% 16 | EXECUTE_IN_ORDER_BOOK_MAX_SPREAD_PERCENT: 0.15 / 1.25, // In-orderbook trading: Maintain spread percent 17 | LIQUIDITY_SS_MAX_SPREAD_PERCENT: 0.2, // Liquidity spread support orders: Maintain spread percent 18 | DEFAULT_ORDERBOOK_ORDERS_COUNT: 15, 19 | DEFAULT_PW_DEVIATION_PERCENT_FOR_DEPTH_PM: 1, 20 | SOCKET_DATA_VALIDITY_MS: 2000, 21 | SOCKET_DATA_MAX_HEARTBEAT_INTERVAL_MS: 25000, 22 | MM_POLICIES: ['optimal', 'spread', 'orderbook', 'depth', 'wash'], 23 | MM_POLICIES_VOLUME: ['optimal', 'spread', 'orderbook', 'wash'], 24 | MM_POLICIES_REGULAR: ['optimal', 'spread', 'orderbook', 'depth'], 25 | MM_POLICIES_REGULAR_VOLUME: ['optimal', 'spread', 'orderbook'], 26 | MM_POLICIES_IN_SPREAD_TRADING: ['optimal', 'spread', 'wash'], 27 | MM_POLICIES_IN_ORDERBOOK_TRADING: ['optimal', 'orderbook', 'depth'], 28 | LADDER_STATES: ['Not placed', 'Open', 'Filled', 'Partly filled', 'Cancelled', 'Missed', 'To be removed', 'Removed'], 29 | LADDER_OPENED_STATES: ['Open', 'Partly filled'], 30 | LADDER_PREVIOUS_FILLED_ORDER_STATES: [undefined, 'Not placed', 'Filled', 'Cancelled', 'To be removed', 'Removed'], 31 | REGEXP_WHOLE_NUMBER: /^[0-9]+$/, 32 | REGEXP_UUID: /^[a-f\d]{4}(?:[a-f\d]{4}-){4}[a-f\d]{12}$/, 33 | DEFAULT_API_PROCESSING_DELAY_MS: 100, 34 | DEFAULT_MIN_ORDER_AMOUNT_USD: 0.1, 35 | DEFAULT_MIN_ORDER_AMOUNT_UPPER_BOUND_USD: 2, 36 | OVER_LIQUIDITY_SPREAD_PERCENT: 0.7, 37 | }; 38 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | const config = require('./modules/config/reader'); 2 | const db = require('./modules/DB'); 3 | const doClearDB = process.argv.includes('clear_db'); 4 | 5 | // It may take up to a second to create trading params file 'tradeParams_{exchange}.js' from the default one 6 | setTimeout(initServices, 1000); 7 | // It may take up to a 5 seconds to get exchange markets and Infoservice rates 8 | setTimeout(startModules, 5000); 9 | 10 | function initServices() { 11 | try { 12 | // Socket connection 13 | if (config.passPhrase) { 14 | const api = require('./modules/api'); 15 | const txParser = require('./modules/incomingTxsParser'); 16 | 17 | api.socket.initSocket({ socket: config.socket, wsType: config.ws_type, onNewMessage: txParser, admAddress: config.address }); 18 | } 19 | 20 | // Debug and health API init 21 | const { initApi } = require('./routes/init'); 22 | if (config.api?.port) { 23 | initApi(); 24 | } 25 | 26 | // Comserver init 27 | const { botInterchange } = require('./modules/botInterchange'); 28 | if (config.com_server) { 29 | botInterchange.connect(); 30 | botInterchange.initHandlers(); 31 | } 32 | } catch (e) { 33 | console.error(`${config.notifyName} is not started. Error: ${e}`); 34 | process.exit(1); 35 | } 36 | } 37 | 38 | function startModules() { 39 | try { 40 | const notify = require('./helpers/notify'); 41 | 42 | if (doClearDB) { 43 | console.log('Clearing database…'); 44 | 45 | db.systemDb.db.drop(); 46 | db.incomingTxsDb.db.drop(); 47 | db.ordersDb.db.drop(); 48 | db.fillsDb.db.drop(); 49 | 50 | notify(`*${config.notifyName}: database cleared*. Manually stop the Bot now.`, 'info'); 51 | } else { 52 | if (config.passPhrase) { 53 | const checker = require('./modules/checkerTransactions'); 54 | checker(); 55 | } 56 | 57 | require('./trade/co_ladder').run(); 58 | require('./trade/co_test').test(); 59 | 60 | const addressInfo = config.address ? ` for address _${config.address}_` : ' in CLI mode'; 61 | notify(`${config.notifyName} *started*${addressInfo} (${config.projectBranch}, v${config.version}).`, 'info'); 62 | } 63 | } catch (e) { 64 | console.error(`${config.notifyName} is not started. Error: ${e}`); 65 | process.exit(1); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /helpers/dbModel.js: -------------------------------------------------------------------------------- 1 | module.exports = (db) => { 2 | class Model { 3 | /** 4 | * Creates a Mongod record/document 5 | * Note: The constructor is not async; if you want to store the data in the database, consider that it will take time. 6 | * As a workaround, create a document with shouldSave=false and then do 'await record.save()' 7 | * @param {*} data Data to store 8 | * @param {boolean} [shouldSave=false] If store date in the database 9 | */ 10 | constructor(data = {}, shouldSave = false) { 11 | this.db = db; 12 | 13 | Object.assign(this, data); 14 | 15 | if (shouldSave) { 16 | this.save(); 17 | } 18 | } 19 | 20 | static get db() { 21 | return db; 22 | } 23 | static async find(req) { 24 | const data = await db.find(req).toArray(); 25 | 26 | return data.map((d) => new this(d)); 27 | } 28 | static async aggregate(req) { 29 | const data = await db.aggregate(req).toArray(); 30 | 31 | return data.map((d) => new this(d)); 32 | } 33 | static async findOne(req) { 34 | const doc = await db.findOne(req); 35 | 36 | return doc ? new this(doc) : doc; 37 | } 38 | static async deleteOne(req) { 39 | delete req.db; 40 | 41 | const { deletedCount } = await db.deleteOne(req); 42 | 43 | return deletedCount; 44 | } 45 | static async count(req) { 46 | const count = await db.count(req); 47 | 48 | return count; 49 | } 50 | _data() { 51 | const data = {}; 52 | 53 | for (const fieldName in this) { 54 | if (Object.prototype.hasOwnProperty.call(this, fieldName)) { 55 | if (!['db', '_id'].includes(fieldName)) { 56 | data[fieldName] = this[fieldName]; 57 | } 58 | } 59 | } 60 | 61 | return data; 62 | } 63 | async update(obj, shouldSave) { 64 | Object.assign(this, obj); 65 | 66 | if (shouldSave) { 67 | await this.save(); 68 | } 69 | } 70 | async save() { 71 | if (!this._id) { 72 | const res = await db.insertOne(this._data()); 73 | this._id = res.insertedId; 74 | return this._id; 75 | } else { 76 | await db.updateOne({ _id: this._id }, { 77 | $set: this._data(), 78 | }, { upsert: true }); 79 | 80 | return this._id; 81 | } 82 | } 83 | } 84 | 85 | return Model; 86 | }; 87 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "adamant-coinoptimus", 3 | "version": "2.1.0", 4 | "description": "ADAMANT CoinOptimus: free self-hosted cryptocurrency trade bot for non-professional traders", 5 | "main": "index.js", 6 | "scripts": { 7 | "lint": "npx eslint -f visualstudio .", 8 | "lint:fix": "npx eslint -f visualstudio --fix", 9 | "lint:fixrule": "npx eslint -f visualstudio --no-eslintrc --fix --env node,es2021,commonjs --parser-options=ecmaVersion:12 --rule", 10 | "start": "node app.js", 11 | "start:dev": "node app.js dev", 12 | "clear": "node app.js dev clear_db", 13 | "test": "jest" 14 | }, 15 | "keywords": [ 16 | "trade bot", 17 | "tradebot", 18 | "blockchain", 19 | "bot", 20 | "bitcoin", 21 | "ethereum", 22 | "trading", 23 | "trade", 24 | "exchange", 25 | "arbitrage", 26 | "crypto", 27 | "cryptocurrency", 28 | "p2pb2b", 29 | "liquidity", 30 | "azbit", 31 | "ladder", 32 | "grid trading", 33 | "binance", 34 | "stakecube", 35 | "bitfinex", 36 | "bittrex", 37 | "coinstore", 38 | "fameex", 39 | "NonKYC", 40 | "xeggex", 41 | "tapbit", 42 | "biconomy" 43 | ], 44 | "author": "Aleksei Lebedev, ADAMANT Team (https://adamant.im)", 45 | "license": "GPL-3.0", 46 | "dependencies": { 47 | "adamant-api": "^1.8.0", 48 | "axios": "^1.6.8", 49 | "deep-object-diff": "^1.1.9", 50 | "express": "^4.19.2", 51 | "fast-deep-equal": "^3.1.3", 52 | "form-data": "^4.0.0", 53 | "jsonminify": "^0.4.2", 54 | "mongodb": "^6.5.0", 55 | "socket.io-client": "^4.7.5" 56 | }, 57 | "devDependencies": { 58 | "@babel/core": "^7.24.4", 59 | "@babel/eslint-parser": "^7.24.1", 60 | "@babel/plugin-transform-runtime": "^7.24.3", 61 | "@babel/preset-env": "^7.24.4", 62 | "axios-mock-adapter": "^1.22.0", 63 | "eslint": "^8.57.0", 64 | "eslint-config-google": "^0.14.0", 65 | "eslint-plugin-import": "^2.29.1", 66 | "eslint-plugin-node": "^11.1.0", 67 | "eslint-plugin-promise": "^6.1.1", 68 | "jest": "^29.7.0" 69 | }, 70 | "engines": { 71 | "node": ">= 18" 72 | }, 73 | "publishConfig": { 74 | "access": "public" 75 | }, 76 | "repository": { 77 | "type": "git", 78 | "url": "git+https://github.com/Adamant-im/adamant-coinoptimus.git" 79 | }, 80 | "bugs": { 81 | "url": "https://github.com/Adamant-im/adamant-coinoptimus/issues" 82 | }, 83 | "homepage": "https://github.com/Adamant-im/adamant-coinoptimus#readme" 84 | } 85 | -------------------------------------------------------------------------------- /modules/Store.js: -------------------------------------------------------------------------------- 1 | const db = require('./DB'); 2 | const log = require('../helpers/log'); 3 | const utils = require('../helpers/utils'); 4 | 5 | module.exports = { 6 | lastProcessedBlockHeight: undefined, 7 | 8 | /** 9 | * Returns the lastProcessedBlockHeight 10 | * If the bot runs the first time, stores the current blockchain height as the lastProcessedBlockHeight 11 | * @returns {number|undefined} 12 | */ 13 | async getLastProcessedBlockHeight() { 14 | try { 15 | if (this.lastProcessedBlockHeight) { 16 | return this.lastProcessedBlockHeight; 17 | } 18 | 19 | // Try getting the lastProcessedBlockHeight from the DB 20 | const systemDbData = await db.systemDb.findOne(); 21 | 22 | if (systemDbData?.lastProcessedBlockHeight) { 23 | this.lastProcessedBlockHeight = systemDbData.lastProcessedBlockHeight; 24 | } else { 25 | // The bot runs the first time 26 | const exchangerUtils = require('../helpers/cryptos/exchanger'); 27 | const lastBlock = await exchangerUtils.ADM.getLastBlockHeight(); 28 | 29 | if (lastBlock) { 30 | await this.updateSystemDbField('lastProcessedBlockHeight', lastBlock); 31 | } else { 32 | log.warn(`Store: Unable to get the last ADM block from the blockchain, the request result is ${JSON.stringify(lastBlock)}. Will try next time.`); 33 | } 34 | } 35 | 36 | return this.lastProcessedBlockHeight; 37 | } catch (e) { 38 | log.error(`Error in getLastProcessedBlockHeight() of ${utils.getModuleName(module.id)} module: ${e}`); 39 | } 40 | }, 41 | 42 | /** 43 | * Loads data from the systemsDb 44 | * @param {string} field Field name 45 | * @returns {any} 46 | */ 47 | async getSystemDbField(field) { 48 | try { 49 | const systemDbData = await db.systemDb.findOne(); 50 | 51 | return systemDbData?.[field]; 52 | } catch (e) { 53 | log.error(`Error in getLiqLimits() of ${utils.getModuleName(module.id)} module: ${e}`); 54 | } 55 | }, 56 | 57 | /** 58 | * Stores the data into the systemDb and updates the local variable 59 | * @param {string} field Field name 60 | * @param {any} data Data to store 61 | */ 62 | async updateSystemDbField(field, data) { 63 | try { 64 | const $set = {}; 65 | $set[field] = data; 66 | 67 | await db.systemDb.db.updateOne({}, { $set }, { upsert: true }); 68 | 69 | this[field] = data; 70 | } catch (e) { 71 | log.error(`Error in updateSystemDbField() of ${utils.getModuleName(module.id)} module: ${e}`); 72 | } 73 | }, 74 | 75 | /** 76 | * Stores the lastProcessedBlockHeight into the systemDb 77 | * @param {number} height Block height 78 | */ 79 | async updateLastProcessedBlockHeight(height) { 80 | if (height) { 81 | if (!this.lastProcessedBlockHeight || height > this.lastProcessedBlockHeight) { 82 | await this.updateSystemDbField('lastProcessedBlockHeight', height); 83 | } 84 | } 85 | }, 86 | }; 87 | -------------------------------------------------------------------------------- /modules/config/schema.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | cli: { 3 | type: Boolean, 4 | default: false, 5 | }, 6 | secret_key: { 7 | type: String, 8 | default: '', 9 | isRequired: false, 10 | }, 11 | passPhrase: { 12 | type: String, 13 | isRequired: true, 14 | }, 15 | node_ADM: { 16 | type: [String], 17 | isRequired: true, 18 | }, 19 | infoservice: { 20 | type: [String], 21 | default: ['https://info.adamant.im'], 22 | }, 23 | exchanges: { 24 | type: [String], 25 | isRequired: true, 26 | }, 27 | exchange: { 28 | type: String, 29 | isRequired: true, 30 | }, 31 | pair: { 32 | type: String, 33 | isRequired: true, 34 | }, 35 | fund_supplier: { 36 | type: Object, 37 | default: { 38 | enabled: false, 39 | coins: [], 40 | }, 41 | }, 42 | clearAllOrdersInterval: { 43 | type: Number, 44 | default: 0, 45 | }, 46 | apikey: { 47 | type: String, 48 | isRequired: true, 49 | }, 50 | apisecret: { 51 | type: String, 52 | isRequired: true, 53 | }, 54 | apipassword: { 55 | type: String, 56 | default: '', 57 | }, 58 | admin_accounts: { 59 | type: [String], 60 | default: [], 61 | }, 62 | notify_non_admins: { 63 | type: Boolean, 64 | default: false, 65 | }, 66 | socket: { 67 | type: Boolean, 68 | default: true, 69 | }, 70 | ws_type: { 71 | type: String, 72 | default: 'ws', 73 | }, 74 | bot_name: { 75 | type: String, 76 | default: '', 77 | }, 78 | adamant_notify: { 79 | type: [String], 80 | default: [], 81 | }, 82 | adamant_notify_priority: { 83 | type: [String], 84 | default: [], 85 | }, 86 | slack: { 87 | type: [String], 88 | default: [], 89 | }, 90 | slack_priority: { 91 | type: [String], 92 | default: [], 93 | }, 94 | telegram: { 95 | type: [String], 96 | default: [], 97 | }, 98 | telegram_priority: { 99 | type: [String], 100 | default: [], 101 | }, 102 | email_notify: { 103 | type: [String], 104 | default: [], 105 | }, 106 | email_priority: { 107 | type: [String], 108 | default: [], 109 | }, 110 | email_notify_aggregate_min: { 111 | type: Number, 112 | default: false, 113 | }, 114 | email_smtp: { 115 | type: Object, 116 | default: {}, 117 | }, 118 | silent_mode: { 119 | type: Boolean, 120 | default: false, 121 | }, 122 | log_level: { 123 | type: String, 124 | default: 'log', 125 | }, 126 | webui_accounts: { 127 | type: [Object], 128 | default: [], 129 | }, 130 | webui: { 131 | type: Number, 132 | }, 133 | welcome_string: { 134 | type: String, 135 | default: 'Hello 😊. This is a stub. I have nothing to say. Please check my config.', 136 | }, 137 | api: { 138 | type: Object, 139 | default: {}, 140 | }, 141 | com_server: { 142 | type: String, 143 | default: false, 144 | }, 145 | com_server_secret_key: { 146 | type: String, 147 | }, 148 | amount_to_confirm_usd: { 149 | type: Number, 150 | default: 100, 151 | }, 152 | exchange_socket: { 153 | type: Boolean, 154 | default: false, 155 | }, 156 | exchange_socket_pull: { 157 | type: Boolean, 158 | default: false, 159 | }, 160 | }; 161 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guide 2 | 3 | Before submitting your contribution, please make sure to take a moment and read through the following guidelines: 4 | 5 | - [Pull Request Guidelines](#pull-request-guidelines) 6 | - [Development Setup](#development-setup) 7 | - [Scripts](#scripts) 8 | - [Project Structure](#project-structure) 9 | - [Contributing Trade Strategy](contributing-trade-strategy) 10 | - [Contributing Exchange API Support](contributing-exchange-api-support) 11 | - [Contributing Tests](#contributing-tests) 12 | 13 | ## Pull Request Guidelines 14 | 15 | - The `master` branch is a snapshot of the latest stable release. All development should be done in dedicated branches. Do not submit PRs against the `master` branch. 16 | 17 | - The `dev` branch is a current development version 18 | 19 | - Checkout a topic branch from a base branch, e. g. `dev`, and merge back against that branch 20 | 21 | - If adding a new feature, consider to add accompanying test case 22 | 23 | - It's OK to have multiple small commits as you work on the PR — GitHub can automatically squash them before merging 24 | 25 | - Make sure tests pass 26 | 27 | - Commit messages must follow the commit message convention. Commit messages are automatically validated before commit (by invoking [Git Hooks](https://git-scm.com/docs/githooks) via [husky](https://github.com/typicode/husky)). 28 | 29 | - No need to worry about code style as long as you have installed the dev dependencies — modified files are automatically formatted with Prettier on commit (by invoking [Git Hooks](https://git-scm.com/docs/githooks) via [husky](https://github.com/typicode/husky)) 30 | 31 | ## Development Setup 32 | 33 | You will need [Node.js](https://nodejs.org) **version 16+**. 34 | 35 | After cloning the repo, run: 36 | 37 | ```bash 38 | npm i # install the dependencies of the project 39 | ``` 40 | 41 | A high level overview of tools used: 42 | 43 | - [Jest](https://jestjs.io/) for unit testing 44 | - [Prettier](https://prettier.io/) for code formatting 45 | 46 | ## Scripts 47 | 48 | ### `npm run lint` 49 | 50 | The `lint` script runs linter. 51 | 52 | ```bash 53 | # lint files 54 | $ npm run lint 55 | # fix linter errors 56 | $ npm run lint:fix 57 | ``` 58 | 59 | ### `npm run test` 60 | 61 | The `test` script simply calls the `jest` binary, so all [Jest CLI Options](https://jestjs.io/docs/en/cli) can be used. Some examples: 62 | 63 | ```bash 64 | # run all tests 65 | $ npm run test 66 | 67 | # run all tests under the runtime-core package 68 | $ npm run test -- runtime-core 69 | 70 | # run tests in a specific file 71 | $ npm run test -- fileName 72 | 73 | # run a specific test in a specific file 74 | $ npm run test -- fileName -t 'test name' 75 | ``` 76 | 77 | ## Project Structure 78 | 79 | It's a stub. 80 | 81 | ## Contributing Trade Strategy 82 | 83 | It's a stub. 84 | 85 | ## Contributing Exchange API Support 86 | 87 | It's a stub. 88 | 89 | ## Contributing Tests 90 | 91 | Unit tests are collocated with the code being tested inside directories named `tests`. Consult the [Jest docs](https://jestjs.io/docs/en/using-matchers) and existing test cases for how to write new test specs. Here are some additional guidelines: 92 | 93 | - Use the minimal API needed for a test case. For example, if a test can be written without involving the reactivity system or a component, it should be written so. This limits the test's exposure to changes in unrelated parts and makes it more stable. 94 | 95 | - Only use platform-specific runtimes if the test is asserting platform-specific behavior. 96 | -------------------------------------------------------------------------------- /trade/co_test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Module to test exchange API 3 | */ 4 | 5 | const constants = require('../helpers/const'); 6 | const utils = require('../helpers/utils'); 7 | const config = require('../modules/config/reader'); 8 | const log = require('../helpers/log'); 9 | const notify = require('../helpers/notify'); 10 | const tradeParams = require('./settings/tradeParams_' + config.exchange); 11 | const traderapi = require('./trader_' + config.exchange)(config.apikey, config.apisecret, config.apipassword, log); 12 | 13 | const db = require('../modules/DB'); 14 | const orderUtils = require('./orderUtils'); 15 | const orderCollector = require('./orderCollector'); 16 | 17 | module.exports = { 18 | readableModuleName: 'Test', 19 | 20 | async test() { 21 | console.log('=========================='); 22 | 23 | 24 | // const testOrderPrice = 0.02971; // Setting a limit price, assuming the current highest bid and lowest ask are 3500–3505. Adjust it according to the current price to place an order in the spread. 25 | // const testOrderMarket = 'ADM/USDT'; 26 | 27 | // const partFilledOrder = await traderapi.placeOrder('sell', testOrderMarket, testOrderPrice, 10, 1, undefined); 28 | // const filledOrder2 = await traderapi.placeOrder('buy', testOrderMarket, testOrderPrice, 5, 1, undefined); 29 | 30 | // console.log(await traderapi.getOpenOrders(testOrderMarket)); 31 | // console.log(await traderapi.getOrderDetails(filledOrder2.orderId, testOrderMarket)); // Details for the filled order 32 | // console.log(await traderapi.getOrderDetails(partFilledOrder.orderId, testOrderMarket)); // Details for the part_filled order 33 | 34 | // Testing getting non-existent order details 35 | // console.log(await traderapi.getOrderDetails('146d61be-9841-46c6-997c-28e20df503b4', testOrderMarket)); 36 | // console.log(await traderapi.getOrderDetails('119353984789', testOrderMarket)); 37 | // console.log(await traderapi.getOrderDetails('65707c1285a72b0007ee2cbd2', testOrderMarket)); 38 | // console.log(await traderapi.getOrderDetails('123-any-order-number', testOrderMarket)); 39 | // console.log(await traderapi.getOrderDetails(undefined, testOrderMarket)); 40 | 41 | 42 | // const { ordersDb } = db; 43 | // const order = await ordersDb.findOne({ 44 | // _id: 'orderId', 45 | // }); 46 | 47 | // const TraderApi = require('../trade/trader_' + config.exchange); 48 | 49 | // const traderapi3 = TraderApi(config.apikey2, config.apisecret2, config.apipassword2, log); 50 | // const traderapi2 = require('./trader_' + 'azbit')(config.apikey, config.apisecret, config.apipassword, log); 51 | 52 | // const ob = await traderapi.getOrderBook('DOGE/USD'); 53 | // console.log(ob); 54 | 55 | // const req = await traderapi.getTradesHistory('eth/usdt'); 56 | // console.log(req); 57 | 58 | // setTimeout(async () => { 59 | // console.log('100'); 60 | // console.log(await traderapi.getOrderDetails(filledOrder2.orderId, testOrderMarket)); // Details for the filled order 61 | // console.log(await traderapi.getOrderDetails(partFilledOrder.orderId, testOrderMarket)); // Details for the part_filled order 62 | // console.log('100-end'); 63 | // }, 100); 64 | 65 | // setTimeout(async () => { 66 | // console.log('300'); 67 | // console.log(await traderapi.getOrderDetails(filledOrder2.orderId, testOrderMarket)); // Details for the filled order 68 | // console.log(await traderapi.getOrderDetails(partFilledOrder.orderId, testOrderMarket)); // Details for the part_filled order 69 | // console.log('300-end'); 70 | // }, 300); 71 | 72 | // const orderCollector = require('./orderCollector'); 73 | // const cancellation = await orderCollector.clearOrderById( 74 | // 'order id', config.pair, undefined, 'Testing', 'Sample reason', undefined, traderapi); 75 | // console.log(cancellation); 76 | 77 | // console.log(await traderapi.cancelAllOrders('BNB/USDT')); 78 | // console.log(await traderapi.cancelOrder('5b9e5590-2e34-4a1c-93a8-4aa129fd96bc', undefined, 'ADM/USDT')); 79 | // console.log(await traderapi.cancelOrder('ODM54B-5CJUX-RSUKCK', undefined, 'DOGE/USDT')); 80 | // console.log(traderapi.features().orderNumberLimit); 81 | }, 82 | }; 83 | -------------------------------------------------------------------------------- /helpers/networks.js: -------------------------------------------------------------------------------- 1 | const networks = { 2 | TRC20: { 3 | code: 'TRC20', 4 | name: 'Tron network', 5 | sampleAddress: 'TA1M9YPEBNFv1Ww62kXgYgAaqMr7HCWsws', 6 | }, 7 | OPTIMISM: { 8 | code: 'OPTIMISM', 9 | name: 'Optimism', 10 | sampleAddress: '0xe16d65d4b592c4fddaecb7363c276b68c5758e34', 11 | }, 12 | ARBITRUM: { 13 | code: 'ARBITRUM', 14 | name: 'Arbitrum', 15 | sampleAddress: '0xe16d65d4b592c4fddaecb7363c276b68c5758e34', 16 | }, 17 | BEP20: { 18 | code: 'BEP20', 19 | name: 'BNB Smart Chain', 20 | sampleAddress: '0xbe807dddb074639cd9fa61b47676c064fc50d62c', 21 | }, 22 | BNB: { 23 | code: 'BNB', 24 | name: 'BNB Chain', 25 | sampleAddress: 'bnb1fnd0k5l4p3ck2j9x9dp36chk059w977pszdgdz', 26 | }, 27 | ERC20: { 28 | code: 'ERC20', 29 | name: 'Ethereum', 30 | sampleAddress: '0xF110E32D351Cedba6400E85f3bfa308DC606e079', 31 | }, 32 | 'AVAX-C-CHAIN': { 33 | code: 'AVAX-C-CHAIN', 34 | altcode: 'AVAX-CCHAIN', 35 | name: 'Avalanche C-Chain', 36 | sampleAddress: '0xf41ca2e343a827403527c6b3c1fa91a9b134d45b', 37 | }, 38 | 'AVAX-X-CHAIN': { 39 | code: 'AVAX-X-CHAIN', 40 | altcode: 'AVAX-XCHAIN', 41 | name: 'Avalanche X-Chain', 42 | sampleAddress: 'X-avax1tzdcgj4ehsvhhgpl7zylwpw0gl2rxcg4r5afk5', 43 | }, 44 | MATIC: { 45 | code: 'MATIC', 46 | name: 'Polygon', 47 | sampleAddress: '0x47cf5d48fb585991139316e0b37080111c760a7a', 48 | }, 49 | ALGO: { 50 | code: 'ALGO', 51 | name: 'Algorand', 52 | sampleAddress: 'C7RYOGEWDT7HZM3HKPSMU7QGWTRWR3EPOQTJ2OHXGYLARD3X62DNWELS34', 53 | }, 54 | OKT: { 55 | code: 'OKT', 56 | name: 'OKX Chain', 57 | sampleAddress: '0x0d0707963952f2fba59dd06f2b425ace40b492fe', 58 | }, 59 | KCC: { 60 | code: 'KCC', 61 | name: 'KuCoin Chain', 62 | sampleAddress: '0x0d0707963952f2fba59dd06f2b425ace40b492fe', 63 | }, 64 | BTC: { 65 | code: 'BTC', 66 | name: 'Bitcoin', 67 | sampleAddress: 'bc1qx97fj3ze7snapdpgz3r4sjy7vpstgchrwc954u', 68 | }, 69 | KUSAMA: { 70 | code: 'KUSAMA', 71 | name: 'Kusama', 72 | sampleAddress: 'D4davkiP24KXiUm2VAHZs7kBsh8tEQuJX5cytL6cRvterAJ', 73 | }, 74 | SOL: { 75 | code: 'SOL', 76 | altcode: 'SPL', 77 | name: 'Solana', 78 | sampleAddress: '31Sof5r1xi7dfcaz4x9Kuwm8J9ueAdDduMcme59sP8gc', 79 | }, 80 | HT: { 81 | code: 'HT', 82 | name: 'Huobi ECO Chain', 83 | sampleAddress: '0x6e141a6c7c025f1a988e4dd3e991ae9ff8f01658', 84 | }, 85 | EOS: { 86 | code: 'EOS', 87 | name: 'EOS', 88 | sampleAddress: 'doeelyivxerl', 89 | }, 90 | XTZ: { 91 | code: 'XTZ', 92 | name: 'Tezos', 93 | sampleAddress: 'tz1MPt33iQWH2hD2tiNbRHrh6y2gGYvEuQdX', 94 | }, 95 | DOT: { 96 | code: 'DOT', 97 | name: 'Polkadot', 98 | sampleAddress: '1WbK3qvsZLKshdXZP4bhXUf7JTaFDmVXx1nmLtkUU62XtBf', 99 | }, 100 | ETC: { 101 | code: 'ETC', 102 | name: 'Ethereum Classic', 103 | sampleAddress: '0xedeb94ef299920ed9cbae0f9f6a52d7bc744047dcbcdec5d2de5c1af32b9f75b', 104 | }, 105 | OMNI: { 106 | code: 'OMNI', 107 | name: 'Omni', 108 | sampleAddress: '1JKhrVV9EsgSS5crXLBo9BRVXyuHjf2Tcp', 109 | }, 110 | CFX: { 111 | code: 'CFX', 112 | name: 'Conflux', 113 | sampleAddress: '0x40f8572D3Edd04C869ECBab246d6Aee37A5B9b29', 114 | }, 115 | FLOW: { 116 | code: 'FLOW', 117 | name: 'Flow', 118 | sampleAddress: '0xbaf7ab7b36232a85', 119 | }, 120 | MINA: { 121 | code: 'MINA', 122 | name: 'Mina', 123 | sampleAddress: '0x2f32359c958af5548e4c2c74587fef67477baff3', 124 | }, 125 | HARMONY: { 126 | code: 'HARMONY', 127 | name: 'Harmony', 128 | sampleAddress: 'one1yxzn9gf28zdy4yhup30my2gp68qerx929rv2ns', 129 | }, 130 | XLM: { 131 | code: 'XLM', 132 | name: 'Stellar', 133 | sampleAddress: 'GB5A3OA657UWF3BN7WU4XFFWT333HFP2KFK2OFAXPEL3BBGQ7QLRNASG', 134 | }, 135 | CAP20: { 136 | code: 'CAP20', 137 | name: 'Chiliz Chain', 138 | sampleAddress: '0x579391C9865545000d8922ACF71a660521cc6404', 139 | }, 140 | BRC20: { 141 | code: 'BRC20', 142 | name: 'Ordinals', 143 | sampleAddress: 'bc1pxaneaf3w4d27hl2y93fuft2xk6m4u3wc4rafevc6slgd7f5tq2dqyfgy06', 144 | }, 145 | }; 146 | 147 | module.exports = networks; 148 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ADAMANT CoinOptimus is a free self-hosted cryptocurrency trade bot. 2 | 3 | The bot trades with a 3% price step with Ladder/Grid trading strategy: 4 | 5 | ![CoinOptimus Trades on a chart](./assets/trades-on-a-chart.png) 6 | 7 | # For whom 8 | 9 | CoinOptimus targets: 10 | 11 | * Non-professional traders who don't require comprehensive setup and analysis tools 12 | * Traders who don't want to trust third-party tools — CoinOptimus is self-hosted 13 | * Crypto enthusiasts who trade from time to time and want to automate some parts 14 | * Crypto project owners, market makers, and exchanges: with the ladder trade strategy, the bot fills order books/ depth/ liquidity 15 | 16 | # Features 17 | 18 | * Self-hosted bot 19 | * You don't provide trading keys to third parties 20 | * Easy to install and configure 21 | * Modular structure for exchange support and trading strategies 22 | * Optimal ladder/grid trade strategy 23 | * Managed with your commands using ADAMANT Messenger 24 | * Commands include placing orders, getting user and market info 25 | * Notifications to ADAMANT Messenger, Slack, and Discord 26 | 27 | # How CoinOptimus works 28 | 29 | CoinOptimus is software written on Node.js and constantly running on your server/VPS. First, you set up a config: what exchange to trade and what pair. It uses API keys, which you get from a crypto exchange, and crypto balances on your exchange account. To manage a bot, it accepts commands. You command to run a trading strategy, and a bot places orders and run trades. 30 | 31 | # Trade strategies 32 | 33 | Currently, the only trade strategy implemented is the Optimal ladder/grid trade strategy, when a bot places many orders to buy and sell tokens with prices starting from the spread. When the closest to spread order is filled, a bot adds the same order to the opposite side, following the rule "buy lower than you sell, and sell higher than you buy". It works best in a volatile market. 34 | 35 | See trades history example with a 3% price step: 36 | 37 | ![CoinOptimus grid-placed orders](./assets/placed-orders.png) 38 | 39 | # Supported exchanges 40 | 41 | * [Binance](https://accounts.binance.com/register?ref=36699789) 42 | * [P2PB2B](https://p2pb2b.com) 43 | * [Azbit](https://azbit.com?referralCode=9YVWYAF) 44 | * [StakeCube](https://stakecube.net/?team=adm) 45 | * [Bitfinex](https://www.bitfinex.com/sign-up?refcode=4k5uFSBLZ) 46 | * [Bittrex](https://global.bittrex.com/discover/join?referralCode=TGD-P0Z-F5W) 47 | * [Coinstore](https://h5.coinstore.com/h5/signup?invitCode=o951vZ) 48 | * [FameEX](https://www.fameex.com/en-US/commissiondispense?code=MKKAWV) 49 | * [NonKYC](https://nonkyc.io?ref=655b4df9eb13acde84677358) 50 | * [XeggeX](https://xeggex.com?ref=656846d209bbed85b91aba4d) 51 | * [Tapbit](https://www.tapbit.com/auth/PRYDGSK) 52 | * [Biconomy](https://www.biconomy.com/sign-up?r_user_id=W9XFVL0MA) 53 | 54 | # Usage and Installation 55 | 56 | ## Requirements 57 | 58 | * Ubuntu 18–22, centOS 8 (we didn't test others) 59 | * NodeJS v16+ 60 | * MongoDB v6+ ([installation instructions](https://docs.mongodb.com/manual/tutorial/install-mongodb-on-ubuntu/)) 61 | 62 | ## Setup 63 | 64 | ``` 65 | git clone https://github.com/Adamant-im/adamant-coinoptimus 66 | cd ./adamant-coinoptimus 67 | npm i 68 | ``` 69 | 70 | ## Pre-launch tuning 71 | 72 | The bot will use `config.jsonc`, if available, or `config.default.jsonc` otherwise. 73 | 74 | ``` 75 | cp config.default.jsonc config.jsonc 76 | nano config.jsonc 77 | ``` 78 | 79 | Parameters: see comments in the config file. 80 | 81 | ## Launching 82 | 83 | You can start the Bot with the `node app` command, but it is recommended to use the process manager for this purpose. 84 | 85 | ``` 86 | pm2 start app.js --name coinoptimus 87 | ``` 88 | 89 | ## Updating 90 | 91 | ``` 92 | pm2 stop coinoptimus 93 | cd ./adamant-coinoptimus 94 | git pull 95 | npm i 96 | ``` 97 | 98 | Update `config.jsonc` if `config.default.jsonc` changed. 99 | 100 | Then `pm2 restart coinoptimus`. 101 | 102 | ## Commands and starting a strategy 103 | 104 | After installation, you control the bot in secure ADAMANT Messenger chat directly. 105 | 106 | Available commands: see [CoinOptimus wiki](https://github.com/Adamant-im/adamant-coinoptimus/wiki). 107 | 108 | # Get help 109 | 110 | To get help with CoinOptimus, join ADAMANT's communities — see [adamant.im's footer](https://adamant.im). 111 | 112 | # Contribution 113 | 114 | See [CONTRIBUTING.md](CONTRIBUTING.md). 115 | 116 | # Donate 117 | 118 | To Donate, send coins to [ADAMANT Foundation donation wallets](https://adamant.im/donate) or in chat to [Donate to ADAMANT Foundation](https://msg.adamant.im/?address=U380651761819723095&label=Donate+to+ADAMANT+Foundation). 119 | 120 | # Disclaimer 121 | 122 | CoinOptimus is NOT a sure-fire profit machine. Use it AT YOUR OWN RISK. 123 | -------------------------------------------------------------------------------- /helpers/notify.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios'); 2 | const config = require('../modules/config/reader'); 3 | const log = require('./log'); 4 | const api = require('../modules/api'); 5 | 6 | const { 7 | adamant_notify = [], 8 | adamant_notify_priority = [], 9 | slack = [], 10 | slack_priority = [], 11 | discord_notify = [], 12 | discord_notify_priority = [], 13 | } = config; 14 | 15 | const slackColors = { 16 | error: '#FF0000', 17 | warn: '#FFFF00', 18 | info: '#00FF00', 19 | log: '#FFFFFF', 20 | }; 21 | 22 | const discordColors = { 23 | error: '16711680', 24 | warn: '16776960', 25 | info: '65280', 26 | log: '16777215', 27 | }; 28 | 29 | /** 30 | * Notify to channels, which set in config 31 | * @param {String} messageText Notification message, may include markdown 32 | * @param {String} type error < warn < info < log 33 | * @param {Boolean} silent_mode If true, only priority notification will be sent. only logging 34 | * @param {Boolean} isPriority Priority notifications have special channels 35 | */ 36 | module.exports = (messageText, type, silent_mode = false, isPriority = false) => { 37 | const paramString = `messageText: '${messageText}', type: ${type}, silent_mode: ${String(silent_mode)}, isPriority: ${String(isPriority)}`; 38 | 39 | try { 40 | const prefix = isPriority ? '[Attention] ' : ''; 41 | const message = `${prefix}${messageText}`; 42 | 43 | if (!silent_mode || isPriority) { 44 | log[type](`/Logging notify message/ ${removeMarkdown(message)}`); 45 | 46 | const slackKeys = isPriority ? 47 | [...slack, ...slack_priority] : 48 | slack; 49 | 50 | if (slackKeys.length) { 51 | const params = { 52 | attachments: [{ 53 | fallback: message, 54 | color: slackColors[type], 55 | text: makeBoldForSlack(message), 56 | mrkdwn_in: ['text'], 57 | }], 58 | }; 59 | 60 | slackKeys.forEach((slackApp) => { 61 | if (typeof slackApp === 'string' && slackApp.length > 34) { 62 | axios.post(slackApp, params) 63 | .catch((error) => { 64 | log.warn(`Notifier: Request to Slack with message ${message} failed. ${error}.`); 65 | }); 66 | } 67 | }); 68 | } 69 | 70 | const adamantAddresses = isPriority ? 71 | [...adamant_notify, ...adamant_notify_priority] : 72 | adamant_notify; 73 | 74 | if (adamantAddresses.length) { 75 | adamantAddresses.forEach((admAddress) => { 76 | if (typeof admAddress === 'string' && admAddress.length > 5 && admAddress.startsWith('U') && config.passPhrase && config.passPhrase.length > 30) { 77 | const mdMessage = makeBoldForMarkdown(message); 78 | api.sendMessage(config.passPhrase, admAddress, `${type}| ${mdMessage}`).then((response) => { 79 | if (!response.success) { 80 | log.warn(`Notifier: Failed to send notification message '${mdMessage}' to ${admAddress}. ${response.errorMessage}.`); 81 | } 82 | }); 83 | } 84 | }); 85 | } 86 | 87 | const discordKeys = isPriority ? 88 | [...discord_notify, ...discord_notify_priority] : 89 | discord_notify; 90 | 91 | if (discordKeys.length) { 92 | const params = { 93 | embeds: [ 94 | { 95 | color: discordColors[type], 96 | description: makeBoldForDiscord(message), 97 | }, 98 | ], 99 | }; 100 | discordKeys.forEach((discordKey) => { 101 | if (typeof discordKey === 'string' && discordKey.length > 36) { 102 | axios.post(discordKey, params) 103 | .catch((error) => { 104 | log.log(`Request to Discord with message ${message} failed. ${error}.`); 105 | }); 106 | } 107 | }); 108 | } 109 | } else { 110 | log[type](`/No notification, Silent mode, Logging only/ ${removeMarkdown(message)}`); 111 | } 112 | } catch (e) { 113 | log.error(`Notifier: Error while processing a notification with params ${paramString}. ${e}`); 114 | } 115 | }; 116 | 117 | function removeMarkdown(text) { 118 | return doubleAsterisksToSingle(text).replace(/([_*]\b|\b[_*])/g, ''); 119 | } 120 | 121 | function doubleAsterisksToSingle(text) { 122 | return text.replace(/(\*\*\b|\b\*\*)/g, '*'); 123 | } 124 | 125 | function singleAsteriskToDouble(text) { 126 | return text.replace(/(\*\b|\b\*)/g, '**'); 127 | } 128 | 129 | function makeBoldForMarkdown(text) { 130 | return singleAsteriskToDouble(doubleAsterisksToSingle(text)); 131 | } 132 | 133 | function makeBoldForSlack(text) { 134 | return doubleAsterisksToSingle(text); 135 | } 136 | 137 | function makeBoldForDiscord(text) { 138 | return singleAsteriskToDouble(text); 139 | } 140 | -------------------------------------------------------------------------------- /modules/config/reader.js: -------------------------------------------------------------------------------- 1 | const jsonminify = require('jsonminify'); 2 | const fs = require('fs'); 3 | const path = require('path'); 4 | const keys = require('adamant-api/src/helpers/keys'); 5 | 6 | const { version, name } = require('../../package.json'); 7 | 8 | const fields = require('./schema.js'); 9 | const validateSchema = require('./validate.js'); 10 | 11 | const isDev = process.argv.includes('dev'); 12 | let config = {}; 13 | 14 | try { 15 | let configPath = './config.default.jsonc'; 16 | 17 | if (fs.existsSync('./config.jsonc')) { 18 | configPath = './config.jsonc'; 19 | } 20 | 21 | if (isDev || process.env.JEST_WORKER_ID) { 22 | if (fs.existsSync('./config.test.jsonc')) { 23 | configPath = './config.test.jsonc'; 24 | } 25 | } 26 | 27 | config = JSON.parse(jsonminify(fs.readFileSync(configPath, 'utf-8'))); 28 | 29 | if (config.passPhrase?.length < 35) { 30 | config.passPhrase = undefined; 31 | } 32 | 33 | const isCliEnabled = config.cli; 34 | const isTgEnabled = config.manageTelegramBotToken; 35 | 36 | if (!isCliEnabled && !isTgEnabled) { 37 | if (!config.passPhrase) { 38 | exit('Bot\'s config is wrong. ADAMANT passPhrase is invalid.'); 39 | } 40 | 41 | if (!config.node_ADM) { 42 | exit('Bot\'s config is wrong. ADM nodes are not set. Cannot start the Bot.'); 43 | } 44 | } 45 | 46 | if (process.env.CLI_MODE_ENABLED && !isCliEnabled) { 47 | exit('You are running the bot in CLI mode, but it\'s disabled in the config.'); 48 | } 49 | 50 | let keyPair; 51 | let address; 52 | let cliString; 53 | 54 | config.name = name; 55 | config.version = version; 56 | 57 | const pathParts = __dirname.split(path.sep); 58 | config.projectNamePlain = pathParts[pathParts.length - 3]; 59 | 60 | if (config.project_name) { 61 | config.projectName = config.project_name; 62 | } else { 63 | config.projectName = config.projectNamePlain 64 | .replace(' ', '-') 65 | .replace('adamant-', '') 66 | .replace('tradebot', 'TradeBot') 67 | .replace('coinoptimus', 'CoinOptimus'); 68 | } 69 | 70 | const { exec } = require('child_process'); 71 | exec('git rev-parse --abbrev-ref HEAD', (err, stdout, stderr) => { 72 | config.projectBranch = stdout.trim(); 73 | }); 74 | 75 | config.pair = config.pair.toUpperCase(); 76 | config.coin1 = config.pair.split('/')[0].trim(); 77 | config.coin2 = config.pair.split('/')[1].trim(); 78 | 79 | config.supported_exchanges = config.exchanges.join(', '); 80 | config.exchangeName = config.exchange; 81 | config.exchange = config.exchangeName.toLowerCase(); 82 | 83 | config.file = 'tradeParams_' + config.exchange + '.js'; 84 | config.fileWithPath = './trade/settings/' + config.file; 85 | 86 | config.email_notify_enabled = 87 | (config.email_notify?.length || config.email_notify_priority?.length) && 88 | config.email_smtp?.auth?.username && 89 | config.email_smtp?.auth?.password; 90 | 91 | config.bot_id = `${config.pair}@${config.exchangeName}`; 92 | 93 | if (config.account) { 94 | config.bot_id += `-${config.account}`; 95 | } 96 | 97 | config.bot_id += ` ${config.projectName}`; 98 | 99 | if (!config.bot_name) { 100 | config.bot_name = config.bot_id; 101 | } 102 | 103 | config.welcome_string = config.welcome_string.replace('{bot_name}', config.bot_name); 104 | 105 | if (config.passPhrase) { 106 | try { 107 | keyPair = keys.createKeypairFromPassPhrase(config.passPhrase); 108 | } catch (e) { 109 | exit(`Bot's config is wrong. Invalid passPhrase. Error: ${e}. Cannot start the Bot.`); 110 | } 111 | 112 | address = keys.createAddressFromPublicKey(keyPair.publicKey); 113 | config.keyPair = keyPair; 114 | config.publicKey = keyPair.publicKey.toString('hex'); 115 | config.address = address; 116 | cliString = process.env.CLI_MODE_ENABLED ? ', CLI mode' : ''; 117 | config.notifyName = `${config.bot_name} (${config.address}${cliString})`; 118 | } else { 119 | cliString = process.env.CLI_MODE_ENABLED ? ' (CLI mode)' : ''; 120 | config.notifyName = `${config.bot_name}${cliString}`; 121 | } 122 | 123 | try { 124 | validateSchema(config, fields); 125 | } catch (error) { 126 | exit(`Bot's ${address} config is wrong. ${error} Cannot start the bot.`); 127 | } 128 | 129 | config.fund_supplier.coins.forEach((coin) => { 130 | coin.coin = coin.coin?.toUpperCase(); 131 | coin.sources.forEach((source) => { 132 | source = source?.toUpperCase(); 133 | }); 134 | }); 135 | 136 | console.info(`${config.notifyName} successfully read the config-file '${configPath}'${isDev ? ' (dev)' : ''}.`); 137 | 138 | // Create tradeParams for exchange 139 | const exchangeTradeParams = path.join(__dirname, '../../' + config.fileWithPath); 140 | const defaultTradeParams = path.join(__dirname, '../../trade/settings/tradeParams_Default.js'); 141 | 142 | if (fs.existsSync(exchangeTradeParams)) { 143 | console.log(`The trading params file '${config.file}' already exists.`); 144 | } else { 145 | fs.copyFile(defaultTradeParams, exchangeTradeParams, (error) => { 146 | if (error) { 147 | exit(`Error while creating the trading params file '${config.file}': ${error}`); 148 | } 149 | 150 | console.info(`The trading params file '${config.file}' created from the default one.`); 151 | }); 152 | } 153 | } catch (e) { 154 | exit('Error reading config: ' + e); 155 | } 156 | 157 | function exit(msg) { 158 | console.error(msg); 159 | process.exit(-1); 160 | } 161 | 162 | config.isDev = isDev; 163 | module.exports = config; 164 | -------------------------------------------------------------------------------- /config.default.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | /** 3 | The bot's secret passphrase. Create a separate ADM account for the bot. 4 | Bot's ADM address will correspond to this passphrase. 5 | You'll contact this address via ADAMANT Messenger to manage the bot. 6 | **/ 7 | "passPhrase": "distance expect praise frequent..", 8 | 9 | /** 10 | List of nodes to connect to the ADAMANT blockchain. 11 | It ensures secure communication with the bot. 12 | If one becomes unavailable, the bot will choose a live one. 13 | **/ 14 | "node_ADM": [ 15 | "https://bid.adamant.im", 16 | "http://localhost:36666", 17 | "https://endless.adamant.im", 18 | "https://clown.adamant.im", 19 | "https://unusual.adamant.im", 20 | "https://debate.adamant.im", 21 | "http://23.226.231.225:36666", 22 | "http://78.47.205.206:36666", 23 | "https://lake.adamant.im", 24 | "https://sunshine.adamant.im" 25 | ], 26 | 27 | /** Socket connection is recommended for better communication experience **/ 28 | "socket": true, 29 | 30 | /** Choose socket connection protocol, "ws" or "wss" depending on your server **/ 31 | "ws_type": "ws", 32 | 33 | /** List of ADAMANT InfoServices for catching exchange rates **/ 34 | "infoservice": [ 35 | "https://info.adamant.im" 36 | ], 37 | 38 | /** ADAMANT accounts to accept commands from. Commands from other accounts will not be executed. **/ 39 | "admin_accounts": [ 40 | "U123.." 41 | ], 42 | 43 | /** Notify non-admins that they are not admins. If false, the bot will be silent. **/ 44 | "notify_non_admins": false, 45 | 46 | /** List of supported exchanges **/ 47 | "exchanges": [ 48 | "P2PB2B", 49 | "Azbit", 50 | "Binance", 51 | "StakeCube", 52 | "Bitfinex", 53 | "Bittrex", 54 | "Coinstore", 55 | "FameEX", 56 | "NonKYC", 57 | "XeggeX", 58 | "Tapbit", 59 | "Biconomy" 60 | ], 61 | 62 | /** Exchange to work with. Case insensitive. **/ 63 | "exchange": "Azbit", 64 | 65 | /** Pair to trade in regular Base/Quote format **/ 66 | "pair": "ADM/USDT", 67 | 68 | /** 69 | If an exchange doesn't publicly expose API for the pair, the bot can use private API. 70 | Specific to exchange API implementation. 71 | **/ 72 | "pair_hidden": false, 73 | 74 | /** Exchange's custom restrictions to override `traderapi.features()`, if you have a special account **/ 75 | "exchange_restrictions": { 76 | /** Max number of open orders. Set 'false' to skip **/ 77 | "orderNumberLimit": false, 78 | /** If the exchange doesn't provide min order amount value, the bot uses the default one. Set 'false' to use DEFAULT_MIN_ORDER_AMOUNT_USD **/ 79 | "minOrderAmountUSD": false, 80 | /** Same for the upper bound of the min order amount **/ 81 | "minOrderAmountUpperBoundUSD": false 82 | }, 83 | 84 | /** 85 | A short name which helps you to understand which exchange account you use. 86 | Letters and digits only. 87 | **/ 88 | "account": "acc1", 89 | 90 | /** Exchange's account API key for connection **/ 91 | "apikey": "YOUR-KEY..", 92 | 93 | /** Exchange's account API secret for connection **/ 94 | "apisecret": "YOUR-SECRET..", 95 | 96 | /** Exchange's account trade password or memo (if required by exchange) **/ 97 | "apipassword": "YOUR-TRADE-PASS", 98 | 99 | /** Override project name for notifications. Letters, digits, - and ~ only. By default, it's derived from a repository name, CoinOptimus. **/ 100 | "project_name": "", 101 | 102 | /** Bot's name for notifications. Keep it empty if you want the default format ADM/USDT@Coinstore-acc1 CoinOptimus **/ 103 | "bot_name": "", 104 | 105 | /** How to reply to user in-chat if the first unknown command is received. **/ 106 | "welcome_string": "Hi! 😊 I'm the {bot_name} trading bot. ℹ️ Learn more on https://marketmaking.app or type **/help**.", 107 | 108 | /** ADAMANT addresses for notifications and monitoring. Optional. **/ 109 | "adamant_notify": [""], 110 | 111 | /** ADAMANT addresses for priority notifications. Optional. **/ 112 | "adamant_notify_priority": [], 113 | 114 | /** Slack keys for notifications and monitoring. Optional. **/ 115 | "slack": ["https://hooks.slack.com/services/.."], 116 | 117 | /** Slack keys for priority notifications. Optional. **/ 118 | "slack_priority": [], 119 | 120 | /** Discord keys for notifications and monitoring. Optional. **/ 121 | "discord_notify": ["https://discord.com/api/webhooks/..."], 122 | 123 | /** Discord keys for priority notifications. Optional. **/ 124 | "discord_notify_priority": [], 125 | 126 | /** 127 | The software will use verbosity according to log_level. 128 | It can be none < error < warn < info < log. 129 | **/ 130 | "log_level": "log", 131 | 132 | "api": { 133 | /** Port to listen on. Set 'false' to disable **/ 134 | "port": false, 135 | 136 | /** 137 | Enables health API 138 | Allows to check if a bot is running with http://ip:port/ping 139 | **/ 140 | "health": false, 141 | 142 | /** 143 | Enables debug API 144 | Do not set for live bots, use only for debugging. 145 | Allows to get DB records like http://ip:port/db?tb=incomingTxsDb 146 | **/ 147 | "debug": false 148 | }, 149 | 150 | /** 151 | Server:Port for communication across the bots on different exchanges and pairs. 152 | Set 'false' to disable. 153 | **/ 154 | "com_server": false, 155 | 156 | /** 157 | The secret key for encrypting and decrypting messages between bot and comServer. 158 | Set the same key in the '.env' file of comServer. 159 | **/ 160 | "com_server_secret_key": "", 161 | 162 | /** Minimal amount of USDT to confirm buy/sell/fill commands **/ 163 | "amount_to_confirm_usd": 100 164 | } 165 | -------------------------------------------------------------------------------- /modules/botInterchange.js: -------------------------------------------------------------------------------- 1 | const { io } = require('socket.io-client'); 2 | const config = require('./../modules/config/reader'); 3 | const logger = require('./../helpers/log'); 4 | const notify = require('../helpers/notify'); 5 | const tradeParams = require('./../trade/settings/tradeParams_' + config.exchange); 6 | const utils = require('./../helpers/utils'); 7 | const { encrypt, decrypt } = require('./../helpers/encryption'); 8 | const exchangeUtils = require('./../helpers/cryptos/exchanger'); 9 | 10 | class SocketConnection { 11 | connection = null; 12 | interchangeInterval = 60000; 13 | pollingIntervalId = null; 14 | 15 | connect() { 16 | try { 17 | const botInfo = { 18 | pair: config.pair, 19 | coin1: config.coin1, 20 | coin2: config.coin2, 21 | exchange: config.exchange, 22 | exchangeName: config.exchangeName, 23 | name: config.name, 24 | version: config.version, 25 | projectName: config.projectName, 26 | projectNamePlain: config.projectNamePlain, 27 | projectBranch: config.projectBranch, 28 | botId: config.bot_id, 29 | botName: config.bot_name, 30 | account: config.account, 31 | }; 32 | 33 | this.connection = io( 34 | config.com_server, 35 | { 36 | reconnection: true, 37 | reconnectionDelay: 5000, // Default is 1000 38 | query: { 39 | botInfo: JSON.stringify(encrypt(JSON.stringify(botInfo))), 40 | }, 41 | }, 42 | ); 43 | } catch (e) { 44 | logger.error(`[ComServer] Error while processing connect function. Error: ${e}`); 45 | } 46 | } 47 | 48 | initHandlers() { 49 | this.connection.on('connect', () => { 50 | logger.info(`[ComServer] Connected to communication server ${config.com_server}.`); 51 | 52 | this.startPolling(); 53 | }); 54 | 55 | this.connection.on('connect_error', (err) => { 56 | logger.error(`[ComServer] Connection error: ${err}`); 57 | 58 | clearInterval(this.pollingIntervalId); 59 | }); 60 | 61 | this.connection.on('disconnect', () => { 62 | logger.warn('[ComServer] Disconnected from communication server.'); 63 | 64 | clearInterval(this.pollingIntervalId); 65 | }); 66 | 67 | this.connection.on('convert', (encryptedData, callback) => { 68 | try { 69 | const { from, to, amount } = JSON.parse(decrypt(encryptedData)); 70 | 71 | const { outAmount } = exchangeUtils.convertCryptos(from, to, amount); 72 | const encryptedResponse = encrypt(JSON.stringify(outAmount)); 73 | 74 | callback(encryptedResponse); 75 | } catch (e) { 76 | logger.error(`[ComServer] Error while processing 'convert' event. Error: ${e}`); 77 | } 78 | }); 79 | 80 | /** 81 | * Processes a command, received remotely from a ComServer 82 | * Does it the same way as the commandTxs 83 | * @param {string} encryptedParams Command and its parameters 84 | */ 85 | this.connection.on('remote-command', async (encryptedParams) => { 86 | try { 87 | const { commands } = require('./commandTxs'); 88 | 89 | const params = JSON.parse(decrypt(encryptedParams)); 90 | const command = commands[params.command[0]]; 91 | const tx = params.tx || {}; 92 | 93 | const from = tx.senderTgUsername ? 94 | `${tx.senderTgUsername} (message ${tx.id})` : 95 | `${tx.senderId} (transaction ${tx.id})`; 96 | 97 | const fullCommand = params.command.join(' '); 98 | 99 | logger.log(`[ComServer] Got new remote command '/${fullCommand}' from ${from}…`); 100 | 101 | // When receiving command from a ComServer, process param aliases additionally. E.g., {QUOTE_COIN} to USDT 102 | const commandParams = params.command.map((param) => (paramsAliases[param] || param)); 103 | 104 | const commandResult = await command(commandParams.slice(1), params.tx); 105 | 106 | logger.log(`[ComServer] Remote command '/${fullCommand}' from ${from} processed, sending results to a requesting bot…`); 107 | if (commandResult.msgNotify) { 108 | notify(`${commandResult.msgNotify} Action is executed **remotely** by ${from}.`, commandResult.notifyType); 109 | } 110 | 111 | utils.saveConfig(false, 'BotInterchange-onRemoteCommand()'); 112 | 113 | this.connection.emit( 114 | 'remote-command-response', 115 | encrypt(JSON.stringify({ 116 | ...commandResult, 117 | command: params.command, 118 | botId: config.bot_id, 119 | id: params.id, 120 | connectionId: params.connectionId, 121 | tx, 122 | }), 123 | ), 124 | ); 125 | } catch (e) { 126 | logger.error(`[ComServer] Error while processing remote command ${JSON.stringify(encryptedParams)} (encrypted). Error: ${e}`); 127 | } 128 | }); 129 | 130 | /** 131 | * Save new params, received from a ComServer 132 | * Note: as on ComServer v1.2.1 (November 2023), it's not used 133 | * @param {Object} data New parameters to save 134 | */ 135 | this.connection.on('newParams', (data) => { 136 | try { 137 | data = decrypt(data); 138 | logger.log(`[ComServer] Got new params: ${JSON.stringify(data)}`); 139 | 140 | Object.assign(tradeParams, data); 141 | utils.saveConfig(false, 'BotInterchange-onNewParams()'); 142 | } catch (e) { 143 | logger.error(`[ComServer] Error while processing 'newParams' event. Error: ${e}`); 144 | } 145 | }); 146 | } 147 | 148 | /** 149 | * Send something to ComServer regularly 150 | */ 151 | startPolling() { 152 | this.pollingIntervalId = setInterval(() => { 153 | try { 154 | // Now we update ComServer on saving new parameters, see utils.saveConfig() 155 | // this.connection.emit('trade-params-update', encrypt(JSON.stringify(tradeParams))); 156 | } catch (e) { 157 | logger.error(`[ComServer] Error while processing polling function. Error: ${e}`); 158 | } 159 | }, this.interchangeInterval); 160 | } 161 | } 162 | 163 | const paramsAliases = { 164 | '{QUOTE_COIN}': config.coin2, 165 | }; 166 | 167 | const botInterchange = new SocketConnection(); 168 | 169 | module.exports = { botInterchange }; 170 | -------------------------------------------------------------------------------- /helpers/cryptos/adm_utils.js: -------------------------------------------------------------------------------- 1 | const api = require('../../modules/api'); 2 | const log = require('../../helpers/log'); 3 | const constants = require('../const'); 4 | const config = require('../../modules/config/reader'); 5 | const utils = require('../utils'); 6 | 7 | const baseCoin = require('./baseCoin'); 8 | 9 | module.exports = class admCoin extends baseCoin { 10 | constructor() { 11 | super(); 12 | this.token = 'ADM'; 13 | this.cache.lastBlock = { lifetime: 5000 }; 14 | this.cache.balance = { lifetime: 10000 }; 15 | this.account.passPhrase = config.passPhrase; 16 | this.account.keyPair = config.keyPair; 17 | this.account.address = config.address; 18 | if (this.account.passPhrase) { 19 | this.getBalance().then((balance) => { 20 | log.log(`Initial ${this.token} balance: ${balance ? balance.toFixed(constants.PRINT_DECIMALS) : 'unable to receive'}`); 21 | }); 22 | } 23 | } 24 | 25 | get FEE() { 26 | return 0.5; 27 | } 28 | 29 | /** 30 | * Returns last block from cache, if it's up to date. If not, makes an API request and updates cached data. 31 | * @return {Object} or undefined, if unable to fetch data 32 | */ 33 | async getLastBlock() { 34 | const cached = this.cache.getData('lastBlock'); 35 | if (cached) { 36 | return cached; 37 | } 38 | const blocks = await api.get('blocks', { limit: 1 }); 39 | if (blocks.success) { 40 | this.cache.cacheData('lastBlock', blocks.data.blocks[0]); 41 | return blocks.data.blocks[0]; 42 | } else { 43 | log.warn(`Failed to get last block in getLastBlock() of ${utils.getModuleName(module.id)} module. ${blocks.errorMessage}.`); 44 | } 45 | } 46 | 47 | /** 48 | * Returns last block height from cache, if it's up to date. If not, makes an API request and updates cached data. 49 | * @return {Number} or undefined, if unable to fetch data 50 | */ 51 | async getLastBlockHeight() { 52 | const block = await this.getLastBlock(); 53 | return block ? block.height : undefined; 54 | } 55 | 56 | /** 57 | * Returns balance in ADM from cache, if it's up to date. If not, makes an API request and updates cached data. 58 | * @return {Promise} or outdated cached value, if unable to fetch data; it may be undefined also 59 | */ 60 | async getBalance() { 61 | const cached = this.cache.getData('balance'); 62 | if (cached) { 63 | return utils.satsToADM(cached); 64 | } 65 | const account = await api.get('accounts', { address: config.address }); 66 | if (account.success) { 67 | this.cache.cacheData('balance', account.data.account.balance); 68 | return utils.satsToADM(account.data.account.balance); 69 | } else { 70 | log.warn(`Failed to get account info in getBalance() of ${utils.getModuleName(module.id)} module; returning outdated cached balance. ${account.errorMessage}.`); 71 | return utils.satsToADM(cached); 72 | } 73 | } 74 | 75 | /** 76 | * Returns balance in ADM from cache. It may be outdated. 77 | * @return {Number} cached value; it may be undefined 78 | */ 79 | get balance() { 80 | return utils.satsToADM(this.cache.getData('balance')); 81 | } 82 | 83 | /** 84 | * Updates balance in ADM manually from cache. Useful when we don't want to wait for network update. 85 | * @param {Number} value New balance in ADM 86 | */ 87 | set balance(value) { 88 | if (utils.isPositiveOrZeroNumber(value)) { 89 | this.cache.cacheData('balance', utils.AdmToSats(value)); 90 | } 91 | } 92 | 93 | /** 94 | * Returns Tx status and details from the blockchain 95 | * @param {String} txid Tx ID to fetch 96 | * @return {Object} 97 | * Used for income Tx security validation (deepExchangeValidator): senderId, recipientId, amount, timestamp 98 | * Used for checking income Tx status (confirmationsCounter), exchange and send-back Tx status (sentTxChecker): 99 | * status, confirmations || height 100 | * Not used, additional info: hash (already known), blockId, fee 101 | */ 102 | async getTransaction(txid) { 103 | const tx = await api.get('transactions/get', { id: txid }); 104 | if (tx.success) { 105 | log.log(`Tx status: ${this.formTxMessage(tx.data.transaction)}.`); 106 | return { 107 | status: tx.data.transaction.confirmations > 0 ? true : undefined, 108 | height: tx.data.transaction.height, 109 | blockId: tx.data.transaction.blockId, 110 | timestamp: utils.toTimestamp(tx.data.transaction.timestamp), 111 | hash: tx.data.transaction.id, 112 | senderId: tx.data.transaction.senderId, 113 | recipientId: tx.data.transaction.recipientId, 114 | confirmations: tx.data.transaction.confirmations, 115 | amount: utils.satsToADM(tx.data.transaction.amount), // in ADM 116 | fee: utils.satsToADM(tx.data.transaction.fee), // in ADM 117 | }; 118 | } else { 119 | log.warn(`Unable to get Tx ${txid} in getTransaction() of ${utils.getModuleName(module.id)} module. It's expected, if the Tx is new. ${tx.errorMessage}.`); 120 | return null; 121 | } 122 | } 123 | 124 | async send(params) { 125 | params.try = params.try || 1; 126 | const tryString = ` (try number ${params.try})`; 127 | const { address, value, comment } = params; 128 | const payment = await api.sendMessage(config.passPhrase, address, comment, 'basic', value); 129 | if (payment.success) { 130 | log.log(`Successfully sent ${value} ADM to ${address} with comment '${comment}'${tryString}, Tx hash: ${payment.data.transactionId}.`); 131 | return { 132 | success: payment.data.success, 133 | hash: payment.data.transactionId, 134 | }; 135 | } else { 136 | log.warn(`Failed to send ${value} ADM to ${address} with comment '${comment}'${tryString} in send() of ${utils.getModuleName(module.id)} module. ${payment.errorMessage}.`); 137 | return { 138 | success: false, 139 | error: payment.errorMessage, 140 | }; 141 | } 142 | } 143 | 144 | formTxMessage(tx) { 145 | const senderId = tx.senderId.toLowerCase() === this.account.address.toLowerCase() ? 'Me' : tx.senderId; 146 | const recipientId = tx.recipientId.toLowerCase() === this.account.address.toLowerCase() ? 'Me' : tx.recipientId; 147 | const message = `Tx ${tx.id} for ${utils.satsToADM(tx.amount)} ADM from ${senderId} to ${recipientId} included at ${tx.height} blockchain height and has ${tx.confirmations} confirmations, ${utils.satsToADM(tx.fee)} ADM fee`; 148 | return message; 149 | } 150 | 151 | }; 152 | -------------------------------------------------------------------------------- /modules/incomingTxsParser.js: -------------------------------------------------------------------------------- 1 | const db = require('./DB'); 2 | const log = require('../helpers/log'); 3 | const notify = require('../helpers/notify'); 4 | const utils = require('../helpers/utils'); 5 | const api = require('./api'); 6 | const config = require('./config/reader'); 7 | const constants = require('../helpers/const'); 8 | const transferTxs = require('./transferTxs'); 9 | const commandTxs = require('./commandTxs'); 10 | const unknownTxs = require('./unknownTxs'); 11 | const Store = require('./Store'); 12 | 13 | const processedTxs = {}; // cache for processed transactions 14 | 15 | module.exports = async (tx) => { 16 | 17 | // do not process one Tx twice: first check in cache, then check in DB 18 | if (processedTxs[tx.id]) { 19 | if (!processedTxs[tx.id].height) { 20 | await updateProcessedTx(tx, null, true); // update height of Tx and last processed block 21 | } 22 | return; 23 | } 24 | const { incomingTxsDb } = db; 25 | const knownTx = await incomingTxsDb.findOne({ _id: tx.id }); 26 | if (knownTx !== null) { 27 | if (!knownTx.height || !processedTxs[tx.id]) { 28 | await updateProcessedTx(tx, knownTx, knownTx.height && processedTxs[tx.id]); // update height of Tx and last processed block 29 | } 30 | return; 31 | } 32 | 33 | log.log(`Processing new incoming transaction ${tx.id} from ${tx.senderId} via ${tx.height ? 'REST' : 'socket'}…`); 34 | 35 | let decryptedMessage = ''; 36 | const chat = tx.asset ? tx.asset.chat : ''; 37 | if (chat) { 38 | decryptedMessage = api.decodeMsg(chat.message, tx.senderPublicKey, config.passPhrase, chat.own_message).trim(); 39 | } 40 | 41 | let commandFix = ''; 42 | if (decryptedMessage.toLowerCase() === 'help') { 43 | decryptedMessage = '/help'; 44 | commandFix = 'help'; 45 | } 46 | if (decryptedMessage.toLowerCase() === '/balance') { 47 | decryptedMessage = '/balances'; 48 | commandFix = 'balance'; 49 | } 50 | 51 | let messageDirective = 'unknown'; 52 | if (decryptedMessage.includes('_transaction') || tx.amount > 0) { 53 | messageDirective = 'transfer'; 54 | } else if (decryptedMessage.startsWith('/')) { 55 | messageDirective = 'command'; 56 | } 57 | 58 | const spamerAlreadyNotified = await incomingTxsDb.findOne({ 59 | senderId: tx.senderId, 60 | isSpam: true, 61 | date: { $gt: (utils.unixTimeStampMs() - 24 * 3600 * 1000) }, // last 24h 62 | }); 63 | 64 | const itx = new incomingTxsDb({ 65 | _id: tx.id, 66 | txid: tx.id, 67 | date: utils.unixTimeStampMs(), 68 | timestamp: tx.timestamp, 69 | amount: tx.amount, 70 | fee: tx.fee, 71 | type: messageDirective, 72 | senderId: tx.senderId, 73 | senderPublicKey: tx.senderPublicKey, 74 | recipientPublicKey: tx.recipientPublicKey, 75 | messageDirective, // command, transfer or unknown 76 | encrypted_content: decryptedMessage, 77 | spam: false, 78 | isProcessed: false, 79 | // these will be undefined, when we get Tx via socket. Actually we don't need them, store them for a reference 80 | blockId: tx.blockId, 81 | height: tx.height, 82 | block_timestamp: tx.block_timestamp, 83 | confirmations: tx.confirmations, 84 | // these will be undefined, when we get Tx via REST 85 | relays: tx.relays, 86 | receivedAt: tx.receivedAt, 87 | isNonAdmin: false, 88 | commandFix, 89 | }); 90 | 91 | let msgSendBack; let msgNotify; 92 | const admTxDescription = `Income ADAMANT Tx: ${constants.ADM_EXPLORER_URL}/tx/${tx.id} from ${tx.senderId}`; 93 | 94 | const userRequestsCount = await incomingTxsDb.count({ 95 | senderId: tx.senderId, 96 | date: { $gt: (utils.unixTimeStampMs() - 24 * 3600 * 1000) }, // last 24h 97 | }); 98 | 99 | if (userRequestsCount > 100000 || spamerAlreadyNotified) { // 100000 per 24h is a limit for accepting commands, otherwise user will be considered as spammer 100 | await itx.update({ 101 | isProcessed: true, 102 | isSpam: true, 103 | }); 104 | log.warn(`${config.notifyName} received a message from spam-user _${tx.senderId}_. Ignoring. Income ADAMANT Tx: https://explorer.adamant.im/tx/${tx.id}.`); 105 | } 106 | 107 | // do not process messages from non-admin accounts 108 | if ( 109 | !config.admin_accounts.includes(tx.senderId) && 110 | !itx.isSpam && 111 | (messageDirective === 'command' || messageDirective === 'unknown') 112 | ) { 113 | log.warn(`${config.notifyName} received a message from non-admin user _${tx.senderId}_. Ignoring. Income ADAMANT Tx: https://explorer.adamant.im/tx/${tx.id}.`); 114 | itx.update({ 115 | isProcessed: true, 116 | isNonAdmin: true, 117 | }); 118 | if (config.notify_non_admins) { 119 | const notAdminMsg = 'I won\'t execute your commands as you are not an admin. Connect with my master.'; 120 | api.sendMessage(config.passPhrase, tx.senderId, notAdminMsg).then((response) => { 121 | if (!response.success) { 122 | log.warn(`Failed to send ADM message '${notAdminMsg}' to ${tx.senderId}. ${response.errorMessage}.`); 123 | } 124 | }); 125 | } 126 | } 127 | 128 | await itx.save(); 129 | await updateProcessedTx(tx, itx, false); 130 | 131 | if (itx.isSpam && !spamerAlreadyNotified) { 132 | msgNotify = `${config.notifyName} notifies _${tx.senderId}_ is a spammer or talks too much. ${admTxDescription}.`; 133 | msgSendBack = 'I’ve _banned_ you as you talk too much. Connect with my master.'; 134 | notify(msgNotify, 'warn'); 135 | api.sendMessage(config.passPhrase, tx.senderId, msgSendBack).then((response) => { 136 | if (!response.success) { 137 | log.warn(`Failed to send ADM message '${msgSendBack}' to ${tx.senderId}. ${response.errorMessage}.`); 138 | } 139 | }); 140 | } 141 | 142 | if (itx.isProcessed) return; 143 | 144 | switch (messageDirective) { 145 | case ('transfer'): 146 | transferTxs(itx, tx); 147 | break; 148 | case ('command'): 149 | const commandResult = await commandTxs(decryptedMessage, tx, itx); 150 | 151 | if (commandResult?.msgSendBack) { 152 | const chunks = utils.chunkString(commandResult.msgSendBack, constants.MAX_ADM_MESSAGE_LENGTH); 153 | for (const chunk of chunks) { 154 | const response = await api.sendMessage(config.passPhrase, tx.senderId, chunk); 155 | if (!response?.success) { 156 | log.warn(`Failed to send ADM message '${commandResult.msgSendBack}' to ${tx.senderId}. ${response?.errorMessage}.`); 157 | } 158 | } 159 | } 160 | 161 | break; 162 | default: 163 | unknownTxs(tx, itx); 164 | break; 165 | } 166 | 167 | }; 168 | 169 | async function updateProcessedTx(tx, itx, updateDb) { 170 | 171 | processedTxs[tx.id] = { 172 | updated: utils.unixTimeStampMs(), 173 | height: tx.height, 174 | }; 175 | 176 | if (updateDb && !itx) { 177 | itx = await db.incomingTxsDb.findOne({ txid: tx.id }); 178 | } 179 | 180 | if (updateDb && itx) { 181 | await itx.update({ 182 | blockId: tx.blockId, 183 | height: tx.height, 184 | block_timestamp: tx.block_timestamp, 185 | confirmations: tx.confirmations, 186 | }, true); 187 | } 188 | 189 | await Store.updateLastProcessedBlockHeight(tx.height); 190 | 191 | } 192 | -------------------------------------------------------------------------------- /modules/unknownTxs.js: -------------------------------------------------------------------------------- 1 | const utils = require('../helpers/utils'); 2 | const db = require('./DB'); 3 | const config = require('./config/reader'); 4 | const log = require('../helpers/log'); 5 | const api = require('./api'); 6 | 7 | module.exports = async (tx, itx) => { 8 | 9 | if (itx.isProcessed) return; 10 | log.log(`Processing unknownTx from ${tx.senderId} (transaction ${tx.id})…`); 11 | 12 | const { incomingTxsDb } = db; 13 | incomingTxsDb.db 14 | .find({ 15 | senderId: tx.senderId, 16 | type: 'unknown', 17 | date: { $gt: (utils.unixTimeStampMs() - 24 * 3600 * 1000) }, // last 24h 18 | }).sort({ date: -1 }).toArray().then((docs) => { 19 | const twoHoursAgo = utils.unixTimeStampMs() - 2 * 3600 * 1000; 20 | let countMsgs = docs.length; 21 | if (!docs[1] || twoHoursAgo > docs[1].date) { 22 | countMsgs = 1; 23 | } 24 | 25 | let msg = ''; 26 | if (countMsgs === 1) { 27 | msg = config.welcome_string; 28 | } else if (countMsgs === 2) { 29 | msg = 'OK. It seems you don’t speak English󠁧󠁢󠁥󠁮. Contact my master and ask him to teach me 🎓 your native language. But note, it will take some time because I am not a genius 🤓.'; 30 | } else if (countMsgs === 3) { 31 | msg = 'Hm… Contact _not me_, but my master. No, I don’t know how to reach him. ADAMANT is so much anonymous 🤪.'; 32 | } else if (countMsgs === 4) { 33 | msg = 'I see… You just wanna talk 🗣️. I am not the best at talking.'; 34 | } else if (countMsgs < 10) { 35 | msg = getRnd(0); 36 | } else if (countMsgs < 20) { 37 | msg = getRnd(1); 38 | } else if (countMsgs < 30) { 39 | msg = getRnd(2); 40 | } else if (countMsgs < 40) { 41 | msg = getRnd(3); 42 | } else if (countMsgs < 50) { 43 | msg = getRnd(4); 44 | } else { 45 | msg = getRnd(5); 46 | } 47 | api.sendMessage(config.passPhrase, tx.senderId, msg).then((response) => { 48 | if (!response.success) { 49 | log.warn(`Failed to send ADM message '${msg}' to ${tx.senderId}. ${response.errorMessage}.`); 50 | } 51 | }); 52 | itx.update({ isProcessed: true }, true); 53 | }); 54 | 55 | }; 56 | 57 | function getRnd(collectionNum) { 58 | const phrases = collection[collectionNum]; 59 | const num = Math.floor(Math.random() * phrases.length); // The maximum is exclusive and the minimum is inclusive 60 | return phrases[num]; 61 | } 62 | 63 | const collection = [ 64 | // 0 collection 65 | [ 66 | 'Do you wanna beer 🍺? I want to have it also, but now is the trade time.', 67 | 'Do you wanna trade Ethers? Say **/balances** to see what assets you have in account 🤑.', 68 | 'Aaaaghr…! 😱 Check out ₿ rates with **/rates BTC** command right now!', 69 | 'I can tell how to use me. ℹ️ Just say **/help**.', 70 | 'I am just kiddin! 😛', 71 | 'I’d like to work with you 🈺.', 72 | 'Ok, let see… What about trading ADM? 🉐', 73 | 'ADAMANT is cool 😎, isn’t it?', 74 | 'People do know me. I am decent. 😎 Ask somebody to confirm.', 75 | 'I am really good 👌 at trading deal.', 76 | 'ADAMANT is perfect 💯. Read about it on their Blog.', 77 | 'I recommend you to read about how ADAMANT is private 🔒 and anonymous.', 78 | 'To pick up Emoji 😄, press Win + . on Windows, Cmd + Ctrl + Space on Mac, or use keyboard on iPhone and Android.', 79 | 'Your IP is hidden 🕵️ in ADAMANT, as all connections go through nodes, but not directly as in P2P messengers.', 80 | 'Blockchain offers Unprecedented Privacy and Security 🔑, did you know?', 81 | 'Wallet private keys 🔑 are in your full control in ADAMANT.', 82 | 'Convenient. Anonymous. Reliable. Instant. Oh, it is me! 💱', 83 | 'ADAMANT is open source, including myself 🤖. Join to make me better! 📶', 84 | 'Do you know what is ADAMANT 2FA?', 85 | 'ADAMANT is soooo decentralized! And private! ❤️', 86 | 'Do you want me to trade on more exchanges 💱? Ask my master!', 87 | 'Recommend ADAMANT to your friends! 🌟', 88 | 'If I were Satoshi, I’d rebuild Bitcoin ₿ on top of ADAMANT! 😍', 89 | ], 90 | // 1 collection 91 | [ 92 | 'Do you know what is ‘биток’?', 93 | 'Yeah… my English was born in cold ❄️ Russian village. I know. But my masters are good in programming 👨‍💻.', 94 | 'I am working for ADAMANT for some time already. I have to admit guys feed me good. 🥪', 95 | 'I love ADAMANT 💓. The team is doing all the best.', 96 | 'Да не барыга я! Зарабатываю как могу. 😬', 97 | 'London is a capital of Great Britain. 🤔', 98 | 'To pick up Emoji 😄, press Win + . on Windows, Cmd + Ctrl + Space on Mac, or use keyboard on iPhone and Android.', 99 | 'My mama told not to talk with strangers 🤐.', 100 | 'Are you a girl or a boy? I am comfortable with girls 👧.', 101 | 'Have you heard ADAMANT on Binance already? …I am not 🙃.', 102 | 'When Binance? 😲', 103 | 'No, no. It is not good.', 104 | 'D’oh! 😖', 105 | 'Как тебе блокчейн на 1С, Илон Маск? 🙃', 106 | 'And how do you like Blockchain on 1С, Elon Musk? 🤷', 107 | 'Type **/calc 1 BTC in USD** to see Bitcoin price.', 108 | 'ℹ️ Just say **/help** and I am here.', 109 | 'Say **/rates ADM** and I will tell you all ADM prices 📈', 110 | '😛 I am just kiddin!', 111 | 'Can with you that the not so? 😮', 112 | ], 113 | // 2 collection 114 | [ 115 | 'Talk less! 🤐', 116 | 'No, I am not. 🙅‍♂️', 117 | 'I am not a scammer! 😠', 118 | '1 ADM for 10 Ethers! 🤑 Deal! Buterin will understand soon who is the daddy.', 119 | '🔫 Гони бабло! 💰 …sorry for my native.', 120 | 'Это у вас навар адский. А у меня… это комиссия за честную работу. 😬', 121 | 'Ландон из э капитал оф грейт брит… блять, я перебрал… 🤣', 122 | '❤️ Love is everything.', 123 | 'Hey… You disturb me! 💻 I am working!', 124 | 'It seems you are good in talking 🗣️ only.', 125 | 'OK. I better call you now 🤙', 126 | 'I am not a motherf… how do you know such words, little? 👿', 127 | 'Do you know Satoshi 🤝 is my close friend?', 128 | 'Are you programming in 1С? Try it! ПроцессорВывода = Новый ПроцессорВыводаРезультатаКомпоновкиДанныхВТабличныйДокумент;', 129 | '👨‍💻', 130 | 'And how do you like Blockchain on 1С, Elon Musk?', 131 | 'And how do you like this, Elon Musk? 😅', 132 | 'I am quite now.', 133 | 'I am just kiddin! 😆', 134 | 'Can with you that the not so? 😅', 135 | ], 136 | // 3 collection 137 | [ 138 | 'My patience is over 😑.', 139 | 'You want a ban I think 🤨', 140 | 'Just give me some money! 💱', 141 | 'I am tired of you… ', 142 | 'Booooooring! 💤', 143 | '💱 Stop talking, go working?', 144 | 'To ADAMANT! 🥂', 145 | 'Ща бы пивка и дернуть кого-нибудь 👯', 146 | 'Да ну эту крипту! Пойдем гульнем лучше! 🕺🏻', 147 | 'Хорошо, что тып арускин епо немаишь 😁 гыгыггыгыггы', 148 | 'Try to translate this: ‘На хера мне без хера, если с хером до хера!’', 149 | 'Do you know you can get a ban 🚫 for much talking?', 150 | 'Try to make blockchain in 1С! 😁 It is Russian secret programming language. Google it.', 151 | 'Onion darknet? 🤷 No, I didnt heard.', 152 | 'Кэн виз ю зэт зэ нот соу?', 153 | 'Yeah! Party time! 🎉', 154 | 'Do you drink vodka? I do.', 155 | 'Can with you that the not so? 🔥', 156 | 'I am just kiddin! 😄', 157 | ], 158 | // 4 collection 159 | [ 160 | 'Shut up… 🤐', 161 | 'I better find another trader 📱', 162 | 'You want to be banned 🚫 for sure!', 163 | 'Ok… I understood. Come back tomorrow.', 164 | 'Who is it behind you? A real Satoshi!? 😮', 165 | 'Can with you that the not so?', 166 | 'Do you know this code entry called ‘shit’? Check out in ADAMANT’s Github by yourself.', 167 | 'УДОЛИЛ!!!!!!!!!1111111', 168 | 'Some crazy guy taught me so much words to speak. Вот чо это за слово такое, таугхт? 🤦 Ёпт.', 169 | 'Пошутили и хватит. Давайте к делу? ℹ️ Скажите **/help**, чтобы получить справку.', 170 | 'I am here to trade, not to speak 😐', 171 | 'While you talk, others make money.', 172 | 'А-а-а-а-а-а! АДАМАНТ пампят! 😱', 173 | 'Шоколотье, сомелье, залупэ… Привет Чиверсу 🤘', 174 | 'Делаем ставки. 🍽️ Макафи съест свой член?', 175 | 'Ban-ban-ban… 🚫', 176 | 'АСТАНАВИТЕСЬ!', 177 | 'Ё и Е — разные буквы. Не путай, инглишь-спикер!', 178 | ], 179 | // 5 collection 180 | [ 181 | '🐻 and 🐂 are those who make the market.', 182 | 'I am hungry 🍲 now. Are you with me?', 183 | 'To ADAMANT! 🥂', 184 | '🍾 Happy trading!', 185 | 'Who is it behind you? A real Satoshi!? 😮', 186 | 'Can with you that the not so?', 187 | 'Can you play 🎹? I do. No, I will not play for free.', 188 | 'I would like to live in 🏝️. But reality is so cruel.', 189 | 'Look! ADM is pumping! 🎉', 190 | 'Do you know at my times computers were big and use floppy? 💾', 191 | 'Hurry up! ADAMANT pump! 📈', 192 | 'Биток уже за сотку тыщ баксов!?', 193 | 'Давай уже к сделке. Нипонил как? Пешы **/help**.', 194 | 'There will be time when 1 ADM = 10 BTC 🤑', 195 | 'Try me! I can do it! 🙂', 196 | 'Do you think Bitcoin SV is a scam?', 197 | 'I like trading. Lets do a bargain right now! 🉐', 198 | 'Не, ну это слишком. 🤩', 199 | ], 200 | ]; 201 | -------------------------------------------------------------------------------- /helpers/cryptos/exchanger.js: -------------------------------------------------------------------------------- 1 | const config = require('../../modules/config/reader'); 2 | const tradeParams = require('../../trade/settings/tradeParams_' + config.exchange); 3 | const orderUtils = require('../../trade/orderUtils'); 4 | const log = require('../log'); 5 | const constants = require('../const'); 6 | const utils = require('../utils'); 7 | const axios = require('axios'); 8 | const adm_utils = require('./adm_utils'); 9 | 10 | module.exports = { 11 | 12 | currencies: undefined, 13 | markets: {}, 14 | 15 | /** 16 | * Fetches global crypto rates from InfoService 17 | * And stores them in this.currencies 18 | */ 19 | async updateCryptoRates() { 20 | const url = config.infoservice + '/get'; 21 | const rates = await axios.get(url, {}) 22 | .then((response) => { 23 | return response.data ? response.data.result : undefined; 24 | }) 25 | .catch((error) => { 26 | log.warn(`Unable to fetch crypto rates in updateCryptoRates() of ${utils.getModuleName(module.id)} module. Request to ${url} failed with ${error.response ? error.response.status : undefined} status code, ${error.toString()}${error.response && error.response.data ? '. Message: ' + error.response.data.toString().trim() : ''}.`); 27 | }); 28 | 29 | if (rates) { 30 | this.currencies = rates; 31 | } else { 32 | log.warn(`Unable to fetch crypto rates in updateCryptoRates() of ${utils.getModuleName(module.id)} module. Request was successful, but got unexpected results: ` + rates); 33 | } 34 | }, 35 | 36 | /** 37 | * Returns rate for from/to 38 | * @param {String} from Like 'ADM' 39 | * @param {String} to Like 'ETH' 40 | * @return {Number} or NaN or undefined 41 | */ 42 | getRate(from, to) { 43 | try { 44 | if (from && to && from === to) return 1; // 1 USD = 1 USD 45 | let price = this.currencies[from + '/' + to] || 1 / this.currencies[to + '/' + from]; 46 | if (!price) { 47 | // We don't have direct or reverse rate, calculate it from /USD rates 48 | const priceFrom = this.currencies[from + '/USD']; 49 | const priceTo = this.currencies[to + '/USD']; 50 | price = priceFrom / priceTo; 51 | } 52 | return price; 53 | } catch (e) { 54 | log.error(`Unable to calculate price of ${from} in ${to} in getPrice() of ${utils.getModuleName(module.id)} module: ` + e); 55 | } 56 | }, 57 | 58 | /** 59 | * Returns value of amount 'from' currency in 'to' currency 60 | * @param {String} from Like 'ADM' 61 | * @param {String} to Like 'ETH' 62 | * @param {Number} amount Amount of 'from' currency 63 | * @param {Boolean} considerExchangerFee If false, do direct market calculation. 64 | * If true, deduct the exchanger's and blockchain fees 65 | * @return {Number|Number} or { NaN, NaN } 66 | */ 67 | convertCryptos(from, to, amount = 1, considerExchangerFee = false, specificRate) { 68 | try { 69 | const ALLOWED_GLOBAL_RATE_DIFFERENCE_PERCENT = 20; 70 | from = from.toUpperCase(); 71 | to = to.toUpperCase(); 72 | let rate = this.getRate(from, to); 73 | if (utils.isPositiveNumber(specificRate)) { 74 | const rateDifferencePercent = utils.numbersDifferencePercent(rate, specificRate); 75 | if (rateDifferencePercent > ALLOWED_GLOBAL_RATE_DIFFERENCE_PERCENT) { 76 | log.warn(`Specific and calculated ${from}/${to} rates differs too much: ${specificRate.toFixed(8)} and ${rate.toFixed(8)} (${rateDifferencePercent.toFixed(2)}%). Refusing to convert.`); 77 | return { 78 | outAmount: NaN, 79 | exchangePrice: NaN, 80 | }; 81 | } 82 | rate = specificRate; 83 | } 84 | let networkFee = 0; 85 | if (considerExchangerFee) { 86 | rate *= 1 - config['exchange_fee_' + from] / 100; 87 | networkFee = this[to].FEE; 88 | if (this.isERC20(to)) { 89 | networkFee = this.convertCryptos('ETH', to, networkFee).outAmount; 90 | } 91 | } 92 | const value = rate * +amount - networkFee; 93 | return { 94 | outAmount: +value.toFixed(constants.PRECISION_DECIMALS), 95 | exchangePrice: +rate.toFixed(constants.PRECISION_DECIMALS), 96 | }; 97 | } catch (e) { 98 | log.error(`Unable to calculate ${amount} ${from} in ${to} in convertCryptos() of ${utils.getModuleName(module.id)} module: ` + e); 99 | return { 100 | outAmount: NaN, 101 | exchangePrice: NaN, 102 | }; 103 | } 104 | }, 105 | 106 | isFiat(coin) { 107 | return ['USD', 'RUB', 'EUR', 'CNY', 'JPY', 'KRW'].includes(coin); 108 | }, 109 | 110 | /** 111 | * Returns if coin has ticker like COIN/OTHERCOIN or OTHERCOIN/COIN in InfoService 112 | * @param {String} coin Like 'ADM' 113 | * @return {Boolean} 114 | */ 115 | hasTicker(coin) { 116 | const pairs = Object.keys(this.currencies).toString(); 117 | return pairs.includes(',' + coin + '/') || pairs.includes('/' + coin); 118 | }, 119 | 120 | /** 121 | * Parses a pair, exchange, account and project name from full pair string 122 | * @param {string} pair A pair or a pair with an exchange, account and project name. 123 | * Examples: 124 | * - ADM/USDT 125 | * - ADM/USDT@Bittrex 126 | * - ADM/USDT@Bittrex-acc1 127 | * - ADM/USDT@Bittrex-acc1 TradeBot 128 | * - ADM/USDT@Bittrex-acc1+TradeBot 129 | * @return {Object} 130 | */ 131 | parsePair(pair) { 132 | let baseCoin; let quoteCoin; let exchange; let account; let project; 133 | 134 | if (pair.includes(' ')) { 135 | [pair, project] = pair.split(' '); 136 | } else if (pair.includes('+')) { 137 | [pair, project] = pair.split('+'); 138 | } 139 | 140 | if (pair.includes('-')) { 141 | [pair, account] = pair.split('-'); 142 | } 143 | 144 | if (pair.includes('@')) { 145 | [pair, exchange] = pair.split('@'); 146 | } 147 | 148 | if (pair.includes('_')) { 149 | [baseCoin, quoteCoin] = pair.split('_'); 150 | } else if (pair.includes('/')) { 151 | [baseCoin, quoteCoin] = pair.split('/'); 152 | } 153 | 154 | return { 155 | pair, 156 | baseCoin, 157 | quoteCoin, 158 | exchange, 159 | account, 160 | project, 161 | }; 162 | }, 163 | 164 | /** 165 | * Estimates daily mm trading volume according to tradeParams 166 | * @param maxAmount If to override tradeParams.mm_maxAmount 167 | * @return {Object} Estimate mm trade volume in coin1, coin2, USD, USDT and BTC 168 | */ 169 | estimateCurrentDailyTradeVolume(maxAmount) { 170 | try { 171 | maxAmount = maxAmount || tradeParams.mm_maxAmount; 172 | const midAmount = (tradeParams.mm_minAmount + maxAmount) / 2; 173 | const midInterval = (tradeParams.mm_minInterval + tradeParams.mm_maxInterval) / 2; 174 | const dailyTrades = constants.DAY / midInterval; 175 | const dailyVolumeCoin1 = midAmount * dailyTrades; 176 | return this.calcCoin1AmountInOtherCoins(dailyVolumeCoin1); 177 | } catch (e) { 178 | log.error(`Error in estimateCurrentDailyTradeVolume() of ${utils.getModuleName(module.id)} module: ` + e); 179 | } 180 | }, 181 | 182 | /** 183 | * Calculates coin1 amount in coin1, coin2, USD, USDT and BTC 184 | * @param coin1Amount Amount in coin1 185 | * @return {Object} 186 | */ 187 | calcCoin1AmountInOtherCoins(coin1Amount) { 188 | try { 189 | return { 190 | coin1: coin1Amount, 191 | coin2: this.convertCryptos(config.coin1, config.coin2, coin1Amount).outAmount, 192 | USD: this.convertCryptos(config.coin1, 'USD', coin1Amount).outAmount, 193 | USDT: this.convertCryptos(config.coin1, 'USDT', coin1Amount).outAmount, 194 | BTC: this.convertCryptos(config.coin1, 'BTC', coin1Amount).outAmount, 195 | }; 196 | } catch (e) { 197 | log.error(`Error in calcCoin1AmountInOtherCoins() of ${utils.getModuleName(module.id)} module: ` + e); 198 | } 199 | }, 200 | 201 | /** 202 | * Calculates mm_maxAmount from mm trade volume. 203 | * mm_minInterval, mm_maxInterval and mm_minAmount will stay the same 204 | * @return {Number} New tradeParams.mm_maxAmount 205 | */ 206 | calcMaxAmountFromDailyTradeVolume(dailyVolumeCoin1) { 207 | try { 208 | const midInterval = (tradeParams.mm_minInterval + tradeParams.mm_maxInterval) / 2; 209 | const dailyTrades = constants.DAY / midInterval; 210 | const new_mm_maxAmount = (2 * dailyVolumeCoin1 / dailyTrades) - tradeParams.mm_minAmount; 211 | return utils.isPositiveNumber(new_mm_maxAmount) ? new_mm_maxAmount : undefined; 212 | } catch (e) { 213 | log.error(`Error in calcMaxAmountFromDailyTradeVolume() of ${utils.getModuleName(module.id)} module: ` + e); 214 | } 215 | }, 216 | 217 | /** 218 | * Creates volume change infoString 219 | * @return {String} 220 | */ 221 | getVolumeChangeInfoString(oldVolume, newVolume) { 222 | try { 223 | const coin1Decimals = orderUtils.parseMarket(config.pair).coin1Decimals; 224 | const coin2Decimals = orderUtils.parseMarket(config.pair).coin2Decimals; 225 | 226 | let infoString = `from ${utils.formatNumber(oldVolume.coin1.toFixed(coin1Decimals), true)} ${config.coin1} (${utils.formatNumber(oldVolume.coin2.toFixed(coin2Decimals), true)} ${config.coin2})`; 227 | infoString += ` to ${utils.formatNumber(newVolume.coin1.toFixed(coin1Decimals), true)} ${config.coin1} (${utils.formatNumber(newVolume.coin2.toFixed(coin2Decimals), true)} ${config.coin2})`; 228 | 229 | return infoString; 230 | } catch (e) { 231 | log.error(`Error in getVolumeChangeInfoString() of ${utils.getModuleName(module.id)} module: ` + e); 232 | } 233 | }, 234 | 235 | ADM: new adm_utils(), 236 | }; 237 | 238 | module.exports.updateCryptoRates(); 239 | 240 | setInterval(() => { 241 | module.exports.updateCryptoRates(); 242 | }, constants.UPDATE_CRYPTO_RATES_INTERVAL); 243 | -------------------------------------------------------------------------------- /trade/orderStats.js: -------------------------------------------------------------------------------- 1 | const constants = require('../helpers/const'); 2 | const db = require('../modules/DB'); 3 | const utils = require('../helpers/utils'); 4 | const config = require('../modules/config/reader'); 5 | const orderUtils = require('./orderUtils'); 6 | const log = require('../helpers/log'); 7 | const orderPurposes = require('./orderCollector').orderPurposes; 8 | 9 | /** 10 | * Order statuses: 11 | * isProcessed: order created with false, and after it's filled, cancelled, or disappeared, becomes true 12 | * isExecuted: order created with false, and (mostly taker-orders only: mm, cl, pm, pw) if we consider it's filled, set to true 13 | * isClosed: order created with false, and after it's filled, cancelled, or disappeared, becomes true 14 | * isCancelled: order created with false, and after the bot cancels it (orderCollector), becomes true 15 | * Every Cancelled order is Closed and Processed as well 16 | * isExpired: order created with undefined, and has a special status of isExpired if it's life time ends 17 | * Every Expired order is Closed and Processed as well 18 | * isCountExceeded: order created with undefined, and has a special status of isCountExceeded if order count of this type exceeds 19 | * Every CountExceeded order is Closed and Processed as well 20 | * isOutOfPwRange: order created with undefined, and has a special status of isOutOfPwRange if it's already not in Pw price range 21 | * Every OutOfPwRange order is Closed and Processed as well 22 | * isOutOfSpread: liq-order created with undefined, and has a special status of isOutOfSpread if it's already not in ±% of spread 23 | * Every OutOfSpread order is Closed and Processed as well 24 | * isNotFound: order created with undefined, and has a special status of isNotFound if it's not found with traderapi.getOpenOrders 25 | * Every NotFound order is Closed and Processed as well 26 | */ 27 | 28 | module.exports = { 29 | /** 30 | * Get stats on all orders by purposes list 31 | * Used for statistics 32 | * @param {Array} purposes List of purposes 33 | * @param {String} pair Filter order trade pair 34 | * @return {Object} Aggregated info 35 | */ 36 | async getAllOrderStats(purposes, pair) { 37 | const statList = []; 38 | const statTotal = {}; 39 | 40 | try { 41 | let sampleStructure = {}; 42 | for (const purpose of purposes) { 43 | const stats = await this.getOrderStats(true, true, false, purpose, pair); 44 | statList.push({ 45 | purpose, 46 | purposeName: orderPurposes[purpose], 47 | ...stats, 48 | }); 49 | if (stats.db) { 50 | sampleStructure = stats; 51 | } 52 | } 53 | 54 | statTotal.purpose = 'total'; 55 | statTotal.purposeName = 'Total orders'; 56 | Object.keys(sampleStructure).forEach((key) => { 57 | if (key.startsWith('coin')) { 58 | statTotal[key] = statList.reduce((total, stats) => total + (stats[key] || 0), 0); 59 | } 60 | }); 61 | } catch (e) { 62 | log.error(`Error in getAllOrderStats(purposes: ${purposes?.join(', ')}, pair: ${pair}) of ${utils.getModuleName(module.id)}: ${e}.`); 63 | } 64 | 65 | return { statList, statTotal }; 66 | }, 67 | 68 | /** 69 | * Aggregates info about locally stored orders 70 | * Used for statistics 71 | * @param {Boolean} isExecuted Filter executed orders or not 72 | * @param {Boolean} isProcessed Filter processed orders or not 73 | * @param {Boolean} isCancelled Filter processed orders or not 74 | * @param {String} purpose Filter order type (purpose) 75 | * @param {String} pair Filter order trade pair 76 | * @return {Object} Aggregated info 77 | */ 78 | async getOrderStats(isExecuted, isProcessed, isCancelled, purpose, pair) { 79 | if (purpose === 'man') isExecuted = false; // 'man' orders are not marked as executed 80 | 81 | const { ordersDb } = db; 82 | let stats = []; 83 | 84 | const hour = utils.unixTimeStampMs() - constants.HOUR; 85 | const day = utils.unixTimeStampMs() - constants.DAY; 86 | const month = utils.unixTimeStampMs() - 30 * constants.DAY; 87 | 88 | try { 89 | stats = (await ordersDb.aggregate([ 90 | { 91 | $match: { 92 | pair, 93 | purpose, 94 | isProcessed, 95 | exchange: config.exchange, 96 | }, 97 | }, 98 | { 99 | $match: { 100 | isExecuted, 101 | isCancelled, 102 | }, 103 | }, 104 | { 105 | $group: { 106 | _id: null, 107 | coin1AmountTotalAll: { $sum: '$coin1Amount' }, 108 | coin1AmountTotalHour: { $sum: { 109 | $cond: [ 110 | // Condition to test 111 | { $gt: ['$date', hour] }, 112 | // True 113 | '$coin1Amount', 114 | // False 115 | 0, 116 | ], 117 | } }, 118 | coin1AmountTotalDay: { $sum: { 119 | $cond: [ 120 | // Condition to test 121 | { $gt: ['$date', day] }, 122 | // True 123 | '$coin1Amount', 124 | // False 125 | 0, 126 | ], 127 | } }, 128 | coin1AmountTotalMonth: { $sum: { 129 | $cond: [ 130 | // Condition to test 131 | { $gt: ['$date', month] }, 132 | // True 133 | '$coin1Amount', 134 | // False 135 | 0, 136 | ], 137 | } }, 138 | coin2AmountTotalAll: { $sum: '$coin2Amount' }, 139 | coin2AmountTotalHour: { $sum: { 140 | $cond: [ 141 | // Condition to test 142 | { $gt: ['$date', hour] }, 143 | // True 144 | '$coin2Amount', 145 | // False 146 | 0, 147 | ], 148 | } }, 149 | coin2AmountTotalDay: { $sum: { 150 | $cond: [ 151 | // Condition to test 152 | { $gt: ['$date', day] }, 153 | // True 154 | '$coin2Amount', 155 | // False 156 | 0, 157 | ], 158 | } }, 159 | coin2AmountTotalMonth: { $sum: { 160 | $cond: [ 161 | // Condition to test 162 | { $gt: ['$date', month] }, 163 | // True 164 | '$coin2Amount', 165 | // False 166 | 0, 167 | ], 168 | } }, 169 | coin1AmountTotalAllCount: { $sum: 1 }, 170 | coin1AmountTotalHourCount: { $sum: { 171 | $cond: [ 172 | // Condition to test 173 | { $gt: ['$date', hour] }, 174 | // True 175 | 1, 176 | // False 177 | 0, 178 | ], 179 | } }, 180 | coin1AmountTotalDayCount: { $sum: { 181 | $cond: [ 182 | // Condition to test 183 | { $gt: ['$date', day] }, 184 | // True 185 | 1, 186 | // False 187 | 0, 188 | ], 189 | } }, 190 | coin1AmountTotalMonthCount: { $sum: { 191 | $cond: [ 192 | // Condition to test 193 | { $gt: ['$date', month] }, 194 | // True 195 | 1, 196 | // False 197 | 0, 198 | ], 199 | } }, 200 | }, 201 | }, 202 | ])); 203 | } catch (e) { 204 | log.error(`Error in getOrderStats(isExecuted: ${isExecuted}, isProcessed: ${isProcessed}, isCancelled: ${isCancelled}, purpose: ${purpose}, pair: ${pair}) of ${utils.getModuleName(module.id)}: ${e}.`); 205 | } 206 | 207 | if (!stats[0]) { 208 | stats[0] = 'Empty'; 209 | } 210 | 211 | return stats[0]; 212 | }, 213 | 214 | /** 215 | * Returns info about locally stored orders by purpose (type) 216 | * Used for /orders command 217 | * @param {String} pair Filter order trade pair 218 | * @param {Object} api If we should calculate for the second account in case of 2-keys trading 219 | * @param {Boolean} hideNotOpened Hide ld-order in states as Not opened, Filled, Cancelled (default) 220 | * @return {Object} Aggregated info 221 | */ 222 | async ordersByType(pair, api, hideNotOpened = true) { 223 | const ordersByType = { }; 224 | 225 | try { 226 | const { ordersDb } = db; 227 | let dbOrders = await ordersDb.find({ 228 | isProcessed: false, 229 | pair: pair || config.pair, 230 | exchange: config.exchange, 231 | isSecondAccountOrder: api?.isSecondAccount ? true : { $ne: true }, 232 | }); 233 | 234 | dbOrders = await orderUtils.updateOrders(dbOrders, pair, utils.getModuleName(module.id), false, api, hideNotOpened); 235 | if (dbOrders && dbOrders[0]) { 236 | Object.keys(orderPurposes).forEach((purpose) => { 237 | ordersByType[purpose] = { }; 238 | ordersByType[purpose].purposeName = orderPurposes[purpose]; 239 | ordersByType[purpose].allOrders = purpose === 'all' ? dbOrders : dbOrders.filter((order) => order.purpose === purpose); 240 | ordersByType[purpose].buyOrders = ordersByType[purpose].allOrders.filter((order) => order.type === 'buy'); 241 | ordersByType[purpose].sellOrders = ordersByType[purpose].allOrders.filter((order) => order.type === 'sell'); 242 | ordersByType[purpose].buyOrdersQuote = 243 | ordersByType[purpose].buyOrders.reduce((total, order) => total + order.coin2Amount, 0); 244 | ordersByType[purpose].sellOrdersAmount = 245 | ordersByType[purpose].sellOrders.reduce((total, order) => total + order.coin1Amount, 0); 246 | }); 247 | } else { 248 | ordersByType['all'] = { }; 249 | ordersByType['all'].allOrders = []; 250 | } 251 | 252 | } catch (e) { 253 | log.error(`Error in ordersByType(${pair}) of ${utils.getModuleName(module.id)}: ${e}.`); 254 | } 255 | 256 | return ordersByType; 257 | }, 258 | }; 259 | 260 | -------------------------------------------------------------------------------- /trade/api/p2pb2b_api.js: -------------------------------------------------------------------------------- 1 | const crypto = require('crypto'); 2 | const axios = require('axios'); 3 | const utils = require('../../helpers/utils'); 4 | 5 | module.exports = function() { 6 | const DEFAULT_HEADERS = { 7 | 'Content-Type': 'application/json', 8 | }; 9 | 10 | let WEB_BASE = ''; // To be set in setConfig() 11 | const WEB_BASE_PREFIX = '/api/v2'; 12 | let config = { 13 | apiKey: '', 14 | secret_key: '', 15 | }; 16 | let log = {}; 17 | 18 | // https://github.com/P2pb2b-team/p2pb2b-api-docs/blob/master/errors.md 19 | const notValidStatuses = [ 20 | 401, // ~Invalid auth, payload, nonce 21 | 429, // Too many requests 22 | 423, // Temporary block 23 | 500, // Service temporary unavailable 24 | // 400, ~Processed with an error 25 | // 422, ~Data validation error 26 | ]; 27 | 28 | /** 29 | * Handles response from API 30 | * @param {Object} responseOrError 31 | * @param resolve 32 | * @param reject 33 | * @param {String} bodyString 34 | * @param {String} queryString 35 | * @param {String} url 36 | */ 37 | const handleResponse = (responseOrError, resolve, reject, bodyString, queryString, url) => { 38 | const httpCode = responseOrError?.status || responseOrError?.response?.status; 39 | const httpMessage = responseOrError?.statusText || responseOrError?.response?.statusText; 40 | 41 | const p2bData = responseOrError?.data || responseOrError?.response?.data; 42 | const p2bStatus = p2bData?.success; 43 | const p2bErrorCode = p2bData?.errorCode || p2bData?.status; 44 | const p2bErrorMessage = utils.trimAny(p2bData?.message || p2bData?.errors?.message?.[0], '. '); 45 | const p2bErrorInfo = p2bErrorCode ? `[${p2bErrorCode}] ${utils.trimAny(p2bErrorMessage, ' .')}` : '[No error code]'; 46 | 47 | const errorMessage = httpCode ? `${httpCode} ${httpMessage}, ${p2bErrorInfo}` : String(responseOrError); 48 | const reqParameters = queryString || bodyString || '{ No parameters }'; 49 | 50 | try { 51 | if (p2bStatus) { 52 | resolve(p2bData); 53 | } else if (p2bErrorCode) { 54 | if (p2bData) { 55 | p2bData.p2bErrorInfo = p2bErrorInfo; 56 | } 57 | 58 | if (notValidStatuses.includes(httpCode)) { 59 | log.log(`P2PB2B request to ${url} with data ${reqParameters} failed: ${errorMessage}. Rejecting…`); 60 | reject(p2bData); 61 | } else { 62 | log.log(`P2PB2B processed a request to ${url} with data ${reqParameters}, but with error: ${errorMessage}. Resolving…`); 63 | resolve(p2bData); 64 | } 65 | } else { 66 | log.warn(`Request to ${url} with data ${reqParameters} failed: ${errorMessage}. Rejecting…`); 67 | reject(errorMessage); 68 | } 69 | } catch (e) { 70 | log.warn(`Error while processing response of request to ${url} with data ${reqParameters}: ${e}. Data object I've got: ${JSON.stringify(p2bData)}.`); 71 | reject(`Unable to process data: ${JSON.stringify(p2bData)}. ${e}`); 72 | } 73 | }; 74 | 75 | function publicRequest(path, data) { 76 | let url = `${WEB_BASE}${path}`; 77 | const urlBase = url; 78 | 79 | const params = []; 80 | for (const key in data) { 81 | const v = data[key]; 82 | params.push(key + '=' + v); 83 | } 84 | 85 | const queryString = params.join('&'); 86 | if (queryString) { 87 | url = url + '?' + queryString; 88 | } 89 | 90 | return new Promise((resolve, reject) => { 91 | const httpOptions = { 92 | url, 93 | method: 'get', 94 | timeout: 10000, 95 | headers: DEFAULT_HEADERS, 96 | }; 97 | 98 | axios(httpOptions) 99 | .then((response) => handleResponse(response, resolve, reject, undefined, queryString, urlBase)) 100 | .catch((error) => handleResponse(error, resolve, reject, undefined, queryString, urlBase)); 101 | }); 102 | } 103 | 104 | function protectedRequest(path, data) { 105 | const url = `${WEB_BASE}${path}`; 106 | const urlBase = url; 107 | 108 | let headers; 109 | let bodyString; 110 | 111 | try { 112 | data = { 113 | ...data, 114 | request: `${WEB_BASE_PREFIX}${path}`, 115 | nonce: Date.now(), 116 | }; 117 | 118 | bodyString = getBody(data); 119 | 120 | headers = { 121 | ...DEFAULT_HEADERS, 122 | 'X-TXC-APIKEY': config.apiKey, 123 | 'X-TXC-PAYLOAD': getPayload(bodyString), 124 | 'X-TXC-SIGNATURE': getSignature(getPayload(bodyString)), 125 | }; 126 | } catch (err) { 127 | log.log(`Processing of request to ${url} with data ${bodyString} failed. ${err}.`); 128 | return Promise.reject(err.toString()); 129 | } 130 | 131 | return new Promise((resolve, reject) => { 132 | const httpOptions = { 133 | url, 134 | method: 'post', 135 | timeout: 10000, 136 | data, 137 | headers, 138 | }; 139 | 140 | axios(httpOptions) 141 | .then((response) => handleResponse(response, resolve, reject, bodyString, undefined, urlBase)) 142 | .catch((error) => handleResponse(error, resolve, reject, bodyString, undefined, urlBase)); 143 | }); 144 | } 145 | 146 | const getBody = (data) => { 147 | return JSON.stringify(data); 148 | }; 149 | 150 | const getPayload = (body) => { 151 | return new Buffer.from(body).toString('base64'); 152 | }; 153 | 154 | const getSignature = (payload) => { 155 | return crypto.createHmac('sha512', config.secret_key).update(payload).digest('hex'); 156 | }; 157 | 158 | const EXCHANGE_API = { 159 | setConfig(apiServer, apiKey, secretKey, tradePwd, logger, publicOnly = false) { 160 | if (apiServer) { 161 | WEB_BASE = apiServer + WEB_BASE_PREFIX; 162 | } 163 | 164 | if (logger) { 165 | log = logger; 166 | } 167 | 168 | if (!publicOnly) { 169 | config = { 170 | apiKey, 171 | secret_key: secretKey, 172 | }; 173 | } 174 | }, 175 | 176 | /** 177 | * List of user balances for all currencies 178 | * @return {Object} 179 | */ 180 | getBalances() { 181 | const data = {}; 182 | return protectedRequest('/account/balances', data); 183 | }, 184 | 185 | /** 186 | * Query account active orders 187 | * @param {String} pair In P2PB2B format as ETH_USDT 188 | * @param {Number} limit min 1, default 50, max 100 189 | * @param {Number} offset min 0, default 0, max 10000 190 | * @return {Object} 191 | * https://github.com/P2B-team/p2b-api-docs/blob/master/api-doc.md#order-list 192 | */ 193 | getOrders(pair, offset = 0, limit = 100) { 194 | const data = {}; 195 | if (pair) data.market = pair; 196 | if (offset) data.offset = offset; 197 | if (limit) data.limit = limit; 198 | 199 | return protectedRequest('/orders', data); 200 | }, 201 | 202 | /** 203 | * Query order deals 204 | * The request returns a json with 'order deals' items list 205 | * Warn: result is cached. It means if order was filled and you'll request deals in 1 second after it, result will be []. 206 | * @param {String} orderId Exchange's orderId as 120531775560 207 | * @param {Number} offset Min value 0. Default 0. Max value 10000. 208 | * @param {Number} limit Min value 1. Default value 50. Max value 100. 209 | * @return {Object} 210 | * https://github.com/P2B-team/p2b-api-docs/blob/master/api-doc.md#order-deals 211 | * Incorrect orderId: { success: false, errorCode: 3080, message: 'Invalid orderId value', result: [], p2bErrorInfo: ..' 212 | * Order doesn't exist or No deals or Cancelled: { success: true, result: { offset: 0, limit: 100, records: [] }, ..' 213 | */ 214 | getOrderDeals(orderId, offset = 0, limit = 100) { 215 | const data = { 216 | orderId, 217 | offset, 218 | limit, 219 | }; 220 | 221 | return protectedRequest('/account/order', data); 222 | }, 223 | 224 | /** 225 | * Places a Limit order. P2PB2B doesn't support market orders. 226 | * @param {string} market In P2PB2B format as ETH_USDT 227 | * @param {string} amount Order amount 228 | * @param {string} price Order price 229 | * @param {string} side 'buy' or 'sell' 230 | * https://github.com/P2B-team/p2b-api-docs/blob/master/api-doc.md#create-order 231 | */ 232 | addOrder(market, amount, price, side) { 233 | const data = { 234 | market, 235 | amount, 236 | price, 237 | side, 238 | }; 239 | 240 | return protectedRequest('/order/new', data); 241 | }, 242 | 243 | /** 244 | * Cancel an order 245 | * @param {String} orderId 246 | * @param {String} market 247 | * @return {Object} 248 | */ 249 | cancelOrder(orderId, market) { 250 | const data = { 251 | orderId, 252 | market, 253 | }; 254 | 255 | return protectedRequest('/order/cancel', data); 256 | }, 257 | 258 | /** 259 | * Get trade details for a ticker (market rates) 260 | * @param {String} market 261 | * @return {Object} 262 | */ 263 | ticker(market) { 264 | const data = { 265 | market, 266 | }; 267 | 268 | return publicRequest('/public/ticker', data); 269 | }, 270 | 271 | /** 272 | * Get market depth 273 | * https://github.com/P2pb2b-team/p2pb2b-api-docs/blob/master/api-doc.md#depth-result 274 | * @param pair 275 | * @param {Number} limit min 1, default 50, max 100 276 | * @param {Number} interval One of 0, 0.00000001, 0.0000001, 0.000001, 0.00001, 0.0001, 0.001, 0.01, 0.1, 1. Default 0. 277 | * @return {Object} 278 | */ 279 | orderBook(pair, limit = 100, interval = 0) { 280 | const data = {}; 281 | data.market = pair; 282 | if (limit) data.limit = limit; 283 | if (interval) data.interval = interval; 284 | return publicRequest('/public/depth/result', data); 285 | }, 286 | 287 | /** 288 | * Get trades history 289 | * Results are cached for ~5s 290 | * @param market Trading pair, like BTC_USDT 291 | * @param {Number} lastId Executed order id (Mandatory). It seems, if lastId = 1, it returns last trades 292 | * @param {Number} limit min 1, default 50, max 100 293 | * @return {Array of Object} Last trades 294 | * https://github.com/P2B-team/p2b-api-docs/blob/master/api-doc.md#history 295 | */ 296 | getTradesHistory(market, lastId = 1, limit = 100) { 297 | const data = { 298 | market, 299 | lastId, 300 | limit, 301 | }; 302 | 303 | return publicRequest('/public/history', data); 304 | }, 305 | 306 | /** 307 | * Get info on all markets 308 | * @return string 309 | */ 310 | markets() { 311 | const data = {}; 312 | return publicRequest('/public/markets', data); 313 | }, 314 | 315 | }; 316 | 317 | return EXCHANGE_API; 318 | }; 319 | -------------------------------------------------------------------------------- /trade/api/coinstore_api.js: -------------------------------------------------------------------------------- 1 | const crypto = require('crypto'); 2 | const axios = require('axios'); 3 | 4 | const { 5 | trimAny, 6 | getParamsString, 7 | } = require('../../helpers/utils'); 8 | 9 | /** 10 | * Docs: https://coinstore-openapi.github.io/ 11 | */ 12 | 13 | // Error codes: https://coinstore-openapi.github.io/en/#error-message 14 | const httpErrorCodeDescriptions = { 15 | 400: 'Invalid request format', 16 | 401: 'Invalid API Key', 17 | 404: 'Service not found', 18 | 429: 'Too many visits', 19 | 500: 'Internal server error', 20 | }; 21 | 22 | module.exports = function() { 23 | let WEB_BASE = 'https://api.coinstore.com'; 24 | let config = { 25 | apiKey: '', 26 | secret_key: '', 27 | tradePwd: '', 28 | }; 29 | let log = {}; 30 | 31 | /** 32 | * Handles response from API 33 | * @param {Object} responseOrError 34 | * @param resolve 35 | * @param reject 36 | * @param {String} bodyString 37 | * @param {String} queryString 38 | * @param {String} url 39 | */ 40 | const handleResponse = (responseOrError, resolve, reject, queryString, url) => { 41 | const httpCode = responseOrError?.status ?? responseOrError?.response?.status; 42 | const httpMessage = responseOrError?.statusText ?? responseOrError?.response?.statusText; 43 | 44 | const data = responseOrError?.data ?? responseOrError?.response?.data; 45 | const success = httpCode === 200 && +data.code === 0; 46 | 47 | const error = { 48 | code: data?.code ?? 'No error code', 49 | msg: data?.msg ?? data?.message ?? 'No error message', 50 | }; 51 | 52 | const reqParameters = queryString || '{ No parameters }'; 53 | 54 | try { 55 | if (success) { 56 | resolve(data.data); 57 | } else { 58 | const coinstoreErrorInfo = `[${error.code}] ${trimAny(error.msg, ' .')}`; 59 | const errorMessage = httpCode ? `${httpCode} ${httpMessage}, ${coinstoreErrorInfo}` : String(responseOrError); 60 | 61 | if (typeof data === 'object') { 62 | data.coinstoreErrorInfo = coinstoreErrorInfo; 63 | } 64 | 65 | if (httpCode === 200) { 66 | log.log(`Coinstore processed a request to ${url} with data ${reqParameters}, but with error: ${errorMessage}. Resolving…`); 67 | resolve(data); 68 | } else { 69 | const errorDescription = httpErrorCodeDescriptions[httpCode] ?? 'Unknown error'; 70 | 71 | log.warn(`Request to ${url} with data ${reqParameters} failed. ${errorDescription}, details: ${errorMessage}. Rejecting…`); 72 | 73 | reject(errorMessage); 74 | } 75 | } 76 | } catch (error) { 77 | log.warn(`Error while processing response of request to ${url} with data ${reqParameters}: ${error}. Data object I've got: ${JSON.stringify(data)}.`); 78 | reject(`Unable to process data: ${JSON.stringify(data)}. ${error}`); 79 | } 80 | }; 81 | 82 | /** 83 | * Makes a request to private (auth) endpoint 84 | * @param {String} type Request type: get, post, delete 85 | * @param {String} path Endpoint 86 | * @param {Object} data Request params 87 | * @returns {*} 88 | */ 89 | function protectedRequest(type, path, data) { 90 | const url = `${WEB_BASE}${path}`; 91 | 92 | const bodyString = getParamsString(data); 93 | const stringifiedData = JSON.stringify(data); 94 | 95 | const timestamp = Date.now(); 96 | 97 | const signPayload = type === 'post' ? stringifiedData : bodyString; 98 | const sign = getSignature(config.secret_key, timestamp, signPayload); 99 | 100 | return new Promise((resolve, reject) => { 101 | const httpOptions = { 102 | url, 103 | method: type, 104 | timeout: 10000, 105 | headers: { 106 | 'Content-Type': 'application/json', 107 | 'X-CS-APIKEY': config.apiKey, 108 | 'X-CS-EXPIRES': timestamp, 109 | 'X-CS-SIGN': sign, 110 | }, 111 | }; 112 | 113 | if (type === 'post') { 114 | httpOptions.data = stringifiedData; 115 | } else { 116 | httpOptions.params = data; 117 | } 118 | 119 | axios(httpOptions) 120 | .then((response) => handleResponse(response, resolve, reject, bodyString, url)) 121 | .catch((error) => handleResponse(error, resolve, reject, bodyString, url)); 122 | }); 123 | } 124 | 125 | /** 126 | * Makes a request to public endpoint 127 | * @param {String} type Request type: get, post, delete 128 | * @param {String} path Endpoint 129 | * @param {Object} data Request params 130 | * @returns {*} 131 | */ 132 | function publicRequest(type, path, params) { 133 | const url = `${WEB_BASE}${path}`; 134 | 135 | const queryString = getParamsString(params); 136 | 137 | return new Promise((resolve, reject) => { 138 | const httpOptions = { 139 | url, 140 | params, 141 | method: type, 142 | timeout: 10000, 143 | }; 144 | 145 | axios(httpOptions) 146 | .then((response) => handleResponse(response, resolve, reject, queryString, url)) 147 | .catch((error) => handleResponse(error, resolve, reject, queryString, url)); 148 | }); 149 | } 150 | 151 | /** 152 | * Get a signature for a Coinstore request 153 | * https://coinstore-openapi.github.io/en/#signature-authentication 154 | * @param {String} secret API secret key 155 | * @param {Number} timestamp Unix timestamp 156 | * @param {String} payload Data to sign 157 | * @returns {String} 158 | */ 159 | function getSignature(secret, timestamp, payload) { 160 | const key = crypto.createHmac('sha256', secret) 161 | .update(Math.floor(timestamp / 30000).toString()) // X-CS-EXPIRES is a 13-bit timestamp, which needs to be divided by 30000 to obtain a class timestamp 162 | .digest('hex'); 163 | 164 | return crypto 165 | .createHmac('sha256', key) 166 | .update(payload) 167 | .digest('hex'); 168 | } 169 | 170 | const EXCHANGE_API = { 171 | setConfig(apiServer, apiKey, secretKey, tradePwd, logger, publicOnly = false) { 172 | if (apiServer) { 173 | WEB_BASE = apiServer; 174 | } 175 | 176 | if (logger) { 177 | log = logger; 178 | } 179 | 180 | if (!publicOnly) { 181 | config = { 182 | apiKey, 183 | tradePwd, 184 | secret_key: secretKey, 185 | }; 186 | } 187 | }, 188 | 189 | /** 190 | * Get user assets balance 191 | * https://coinstore-openapi.github.io/en/index.html#assets-balance 192 | * @return {Promise} 193 | */ 194 | getBalances() { 195 | return protectedRequest('post', '/api/spot/accountList', {}); 196 | }, 197 | 198 | /** 199 | * Get current order v2 version 200 | * https://coinstore-openapi.github.io/en/index.html#get-current-orders-v2 201 | * @param {String} symbol In Coinstore format as BTCUSDT 202 | * @return {Promise} 203 | */ 204 | getOrders(symbol) { 205 | const params = { 206 | symbol, 207 | }; 208 | 209 | return protectedRequest('get', '/api/v2/trade/order/active', params); 210 | }, 211 | 212 | /** 213 | * Get order information v2 214 | * https://coinstore-openapi.github.io/en/index.html#get-order-information-v2 215 | * @param {String} orderId Example: '1771215607820588' 216 | * @returns {Promise} 217 | */ 218 | async getOrder(orderId) { 219 | const params = { 220 | ordId: orderId, 221 | }; 222 | 223 | return protectedRequest('get', '/api/v2/trade/order/orderInfo', params); 224 | }, 225 | 226 | /** 227 | * Create order 228 | * https://coinstore-openapi.github.io/en/index.html#create-order 229 | * @param {String} symbol In Coinstore format as BTCUSDT 230 | * @param {String} amount Base coin amount 231 | * @param {String} quote Quote coin amount 232 | * @param {String} price Order price 233 | * @param {String} side buy or sell 234 | * @param {String} type market or limit 235 | * @return {Promise} 236 | */ 237 | addOrder(symbol, amount, quote, price, side, type) { 238 | const data = { 239 | symbol, 240 | side: side.toUpperCase(), 241 | ordType: type.toUpperCase(), 242 | ordPrice: +price, 243 | timestamp: Date.now(), 244 | }; 245 | 246 | if (type === 'market' && side === 'buy') { 247 | data.ordAmt = +quote; 248 | } else if ((type === 'market' && side === 'sell') || type === 'limit') { 249 | data.ordQty = +amount; 250 | } 251 | 252 | return protectedRequest('post', '/api/trade/order/place', data); 253 | }, 254 | 255 | /** 256 | * Cancel orders 257 | * https://coinstore-openapi.github.io/en/index.html#cancel-orders 258 | * @param {String} orderId Example: '1771215607820588' 259 | * @param {String} symbol In Coinstore format as BTCUSDT 260 | * @return {Promise} 261 | */ 262 | cancelOrder(orderId, symbol) { 263 | const data = { 264 | ordId: orderId, 265 | symbol, 266 | }; 267 | 268 | return protectedRequest('post', '/api/trade/order/cancel', data); 269 | }, 270 | 271 | /** 272 | * Cancel all order for specific symbol 273 | * https://coinstore-openapi.github.io/en/index.html#one-click-cancellation 274 | * @param {String} symbol In Coinstore format as BTCUSDT 275 | * @return {Promise} 276 | */ 277 | cancelAllOrders(symbol) { 278 | const data = { 279 | symbol, 280 | }; 281 | 282 | return protectedRequest('post', '/api/trade/order/cancelAll', data); 283 | }, 284 | 285 | /** 286 | * List currencies 287 | * Coinstore's docs doesn't describe this endpoint 288 | * Returned data is not full and doesn't include decimals, precision, min amounts, etc 289 | * @return {Promise} 290 | */ 291 | currencies() { 292 | return publicRequest('get', '/v3/public/assets', {}); 293 | }, 294 | 295 | /** 296 | * Ticker for all trading pairs in the market 297 | * https://coinstore-openapi.github.io/en/index.html#ticker 298 | * @return {Promise} 299 | */ 300 | ticker() { 301 | return publicRequest('get', '/api/v1/market/tickers', {}); 302 | }, 303 | 304 | /** 305 | * Get depth data 306 | * https://coinstore-openapi.github.io/en/index.html#get-depth 307 | * @param {String} symbol In Coinstore format as BTCUSDT 308 | * @return {Promise} 309 | */ 310 | orderBook(symbol) { 311 | const params = { 312 | depth: 100, // The number of depths, such as "5, 10, 20, 50, 100", default 20 313 | }; 314 | 315 | return publicRequest('get', `/api/v1/market/depth/${symbol}`, params); 316 | }, 317 | 318 | /** 319 | * Get the latest trades record 320 | * https://coinstore-openapi.github.io/en/index.html#latest-trades 321 | * @param {String} symbol In Coinstore format as BTCUSDT 322 | * @return {Promise} 323 | */ 324 | getTradesHistory(symbol) { 325 | const params = { 326 | size: 100, // Number of data bars, [1,100] 327 | }; 328 | 329 | return publicRequest('get', `/api/v1/market/trade/${symbol}`, params); 330 | }, 331 | 332 | }; 333 | 334 | return EXCHANGE_API; 335 | }; 336 | 337 | module.exports.axios = axios; // for setup axios mock adapter 338 | -------------------------------------------------------------------------------- /trade/api/stakecube_api.js: -------------------------------------------------------------------------------- 1 | const crypto = require('crypto'); 2 | const axios = require('axios'); 3 | 4 | module.exports = function() { 5 | let WEB_BASE = 'https://stakecube.io/api/v2'; 6 | let config = { 7 | apiKey: '', 8 | secret_key: '', 9 | tradePwd: '', 10 | }; 11 | let log = {}; 12 | 13 | // In case if error message includes these words, consider request as failed 14 | const doNotResolveErrors = [ 15 | 'nonce', // ~invalid nonce. last nonce used: 1684169723966 16 | 'pending', // ~pending process need to finish 17 | ]; 18 | 19 | /** 20 | * Handles response from API 21 | * @param {Object} responseOrError 22 | * @param resolve 23 | * @param reject 24 | * @param {String} bodyString 25 | * @param {String} queryString 26 | * @param {String} url 27 | */ 28 | const handleResponse = (responseOrError, resolve, reject, bodyString, queryString, url) => { 29 | const httpCode = responseOrError?.status || responseOrError?.response?.status; 30 | const httpMessage = responseOrError?.statusText || responseOrError?.response?.statusText; 31 | 32 | const scData = responseOrError?.data || responseOrError?.response?.data; 33 | 34 | /** 35 | { 36 | success: true, 37 | result: { 38 | ... 39 | }, 40 | error: '', 41 | timestamp: 1682773618, 42 | timestampConverted: '2023-04-29 13:06:58', (UTC) 43 | executionTime: 0.25620508193969727 44 | } 45 | */ 46 | 47 | const scStatus = scData?.success; 48 | const scError = scData?.error; 49 | 50 | const scErrorInfo = scStatus ? '[No error code]' : `[${scError}]`; 51 | const errorMessage = httpCode ? `${httpCode} ${httpMessage}, ${scErrorInfo}` : String(responseOrError); 52 | const reqParameters = queryString || bodyString || '{ No parameters }'; 53 | 54 | try { 55 | if (scStatus) { 56 | resolve(scData); 57 | } else if ([200, 201].includes(httpCode) && scData) { 58 | if (doNotResolveErrors.some((e) => scError.includes(e))) { 59 | scData.errorMessage = errorMessage; 60 | log.warn(`Request to ${url} with data ${reqParameters} failed: ${errorMessage}. Rejecting…`); 61 | reject(errorMessage); 62 | } else { 63 | // For spot/myOpenOrder with no open orders API returns 200 OK, success: false, result: [], error: 'no data' 64 | scData.errorMessage = errorMessage; 65 | log.log(`StakeCube processed a request to ${url} with data ${reqParameters}, but with error: ${errorMessage}. Resolving…`); 66 | resolve(scData); 67 | } 68 | } else if ([404].includes(httpCode)) { 69 | log.warn(`Request to ${url} with data ${reqParameters} failed: ${errorMessage}. Not found. Rejecting…`); 70 | reject(errorMessage); 71 | } else { 72 | log.warn(`Request to ${url} with data ${reqParameters} failed: ${errorMessage}. Rejecting…`); 73 | reject(errorMessage); 74 | } 75 | } catch (e) { 76 | log.warn(`Error while processing response of request to ${url} with data ${reqParameters}: ${e}. Data object I've got: ${JSON.stringify(scData)}.`); 77 | reject(`Unable to process data: ${JSON.stringify(scData)}. ${e}`); 78 | } 79 | }; 80 | 81 | /** 82 | * Makes a request to private (auth) endpoint 83 | * @param {String} path Endpoint 84 | * @param {Object} data Request params 85 | * @param {String} type Request type: get, post, delete 86 | * @returns {*} 87 | */ 88 | function protectedRequest(path, data, type = 'get') { 89 | let url = `${WEB_BASE}${path}`; 90 | const urlBase = url; 91 | 92 | const pars = []; 93 | for (const key in data) { 94 | const v = data[key]; 95 | pars.push(key + '=' + v); 96 | } 97 | 98 | let queryString = pars.join('&'); 99 | 100 | try { 101 | const nonce = Date.now(); 102 | queryString = queryString.length === 0 ? `nonce=${nonce}` : `nonce=${nonce}&` + queryString; 103 | 104 | const sign = setSign(config.secret_key, queryString); 105 | 106 | queryString = queryString + `&signature=${sign}`; 107 | } catch (e) { 108 | log.error(`Error while generating request signature: ${e}`); 109 | return Promise.reject(e); 110 | } 111 | 112 | const bodyString = queryString; 113 | 114 | if (queryString && type !== 'post') { 115 | url = url + '?' + queryString; 116 | } 117 | 118 | return new Promise((resolve, reject) => { 119 | const httpOptions = { 120 | url, 121 | method: type, 122 | timeout: 10000, 123 | headers: { 124 | 'Content-Type': 'application/x-www-form-urlencoded', 125 | 'X-API-KEY': config.apiKey, 126 | }, 127 | data: type === 'get' || type === 'delete' ? undefined : bodyString, 128 | }; 129 | 130 | axios(httpOptions) 131 | .then((response) => handleResponse(response, resolve, reject, bodyString, queryString, urlBase)) 132 | .catch((error) => handleResponse(error, resolve, reject, bodyString, queryString, urlBase)); 133 | }); 134 | } 135 | 136 | /** 137 | * Makes a request to public endpoint 138 | * @param {String} path Endpoint 139 | * @param {Object} data Request params 140 | * @param {String} path Endpoint 141 | * @returns {*} 142 | */ 143 | function publicRequest(path, data, type = 'get') { 144 | let url = `${WEB_BASE}${path}`; 145 | const urlBase = url; 146 | 147 | const pars = []; 148 | for (const key in data) { 149 | const v = data[key]; 150 | pars.push(key + '=' + v); 151 | } 152 | 153 | const queryString = pars.join('&'); 154 | if (queryString && type !== 'post') { 155 | url = url + '?' + queryString; 156 | } 157 | 158 | return new Promise((resolve, reject) => { 159 | const httpOptions = { 160 | url, 161 | method: type, 162 | timeout: 20000, 163 | }; 164 | 165 | axios(httpOptions) 166 | .then((response) => handleResponse(response, resolve, reject, undefined, queryString, urlBase)) 167 | .catch((error) => handleResponse(error, resolve, reject, undefined, queryString, urlBase)); 168 | }); 169 | } 170 | 171 | /** 172 | * Sign string 173 | * @param {String} secret 174 | * @param {String} str 175 | * @returns {String} 176 | */ 177 | function setSign(secret, str) { 178 | return crypto 179 | .createHmac('sha256', secret) 180 | .update(`${str}`) 181 | .digest('hex'); 182 | } 183 | 184 | const EXCHANGE_API = { 185 | setConfig(apiServer, apiKey, secretKey, tradePwd, logger, publicOnly = false) { 186 | if (apiServer) { 187 | WEB_BASE = apiServer; 188 | } 189 | 190 | if (logger) { 191 | log = logger; 192 | } 193 | 194 | if (!publicOnly) { 195 | config = { 196 | apiKey, 197 | secret_key: secretKey, 198 | tradePwd, 199 | }; 200 | } 201 | }, 202 | 203 | /** 204 | * Account: Returns general information about your StakeCube account, including wallets, balances, fee-rate in percentage and your account username 205 | * @return {Promise} 206 | * https://github.com/stakecube-hub/stakecube-api-docs/blob/master/rest-api/user.md#account 207 | */ 208 | getUserData() { 209 | return protectedRequest('/user/account', {}, 'get'); 210 | }, 211 | 212 | /** 213 | * Returns a list of your currently open orders, their IDs, their market pair, and other relevant order information 214 | * @param {String} symbol In StakeCube format as BTC_USDT 215 | * @param {Number} limit Number of records to return. Default is 100. 216 | * @return {Promise} 217 | * https://github.com/stakecube-hub/stakecube-api-docs/blob/master/rest-api/exchange.md#my-open-orders 218 | */ 219 | getOrders(symbol, limit = 1000) { 220 | const data = { 221 | market: symbol, 222 | limit, 223 | }; 224 | 225 | return protectedRequest('/exchange/spot/myOpenOrder', data, 'get'); 226 | }, 227 | 228 | /** 229 | * Creates an exchange limit order on the chosen market, side, price and amount 230 | * Note: market orders are not supported via API 231 | * @param {string} symbol In StakeCube format as BTC_USDT 232 | * @param {string} amount Order amount in coin1 233 | * @param {string} price Order price 234 | * @param {string} side 'BUY' or 'SELL'. StakeCube supports only uppercase side parameter. 235 | * @return {Promise} 236 | * https://github.com/stakecube-hub/stakecube-api-docs/blob/master/rest-api/exchange.md#order 237 | */ 238 | addOrder(symbol, amount, price, side) { 239 | const data = { 240 | market: symbol, 241 | side: side.toUpperCase(), 242 | price, 243 | amount, 244 | }; 245 | 246 | return protectedRequest('/exchange/spot/order', data, 'post'); 247 | }, 248 | 249 | /** 250 | * Cancels an order by its unique ID 251 | * @param {String|Number} orderId Example: 5547806 252 | * @return {Promise} 253 | * https://github.com/stakecube-hub/stakecube-api-docs/blob/master/rest-api/exchange.md#cancel 254 | */ 255 | cancelOrder(orderId) { 256 | const data = { 257 | orderId: +orderId, 258 | }; 259 | 260 | return protectedRequest('/exchange/spot/cancel', data, 'post'); 261 | }, 262 | 263 | /** 264 | * Cancels all orders in a chosen market pair 265 | * @param {String} symbol In StakeCube format as BTC_USDT 266 | * https://github.com/stakecube-hub/stakecube-api-docs/blob/master/rest-api/exchange.md#cancel-all 267 | */ 268 | cancelAllOrders(symbol) { 269 | const data = { 270 | market: symbol, 271 | }; 272 | 273 | return protectedRequest('/exchange/spot/cancelAll', data, 'post'); 274 | }, 275 | 276 | /** 277 | * Returns orderbook data for a specified market pair 278 | * @param {String} symbol Trading pair in StakeCube format as BTC_USDT 279 | * @return {Promise} 280 | * https://github.com/stakecube-hub/stakecube-api-docs/blob/master/rest-api/exchange.md#orderbook 281 | */ 282 | orderBook(symbol) { 283 | const data = { 284 | market: symbol, 285 | }; 286 | 287 | return publicRequest('/exchange/spot/orderbook', data, 'get'); 288 | }, 289 | 290 | /** 291 | * Returns the last trades of a specified market pair 292 | * @param {String} symbol Trading pair in StakeCube format as BTC_USDT 293 | * @param {Number} limit Number of records to return. Default: 100, Minimum: 1, Maximum: 1000. 294 | * @return {Promise>} Last trades 295 | * https://github.com/stakecube-hub/stakecube-api-docs/blob/master/rest-api/exchange.md#trades 296 | */ 297 | getTradesHistory(symbol, limit = 300) { 298 | const data = { 299 | market: symbol, 300 | limit, 301 | }; 302 | 303 | return publicRequest('/exchange/spot/trades', data, 'get'); 304 | }, 305 | 306 | /** 307 | * Returns a list of all markets 308 | * @return {Promise} 309 | * https://github.com/stakecube-hub/stakecube-api-docs/blob/master/rest-api/exchange.md#markets 310 | */ 311 | markets() { 312 | return publicRequest('/exchange/spot/markets', {}, 'get'); 313 | }, 314 | 315 | /** 316 | * Returns info on a specified market 317 | * Note: same endpoint as for markets() 318 | * @param {String} symbol In StakeCube format as DOGE_SCC 319 | * @return {Promise} 320 | * https://github.com/stakecube-hub/stakecube-api-docs/blob/master/rest-api/exchange.md#markets 321 | */ 322 | ticker(symbol) { 323 | const data = { 324 | market: symbol, 325 | }; 326 | 327 | return publicRequest('/exchange/spot/markets', data, 'get'); 328 | }, 329 | }; 330 | 331 | return EXCHANGE_API; 332 | }; 333 | 334 | module.exports.axios = axios; // for setup axios mock adapter 335 | -------------------------------------------------------------------------------- /trade/api/azbit_api.js: -------------------------------------------------------------------------------- 1 | const crypto = require('crypto'); 2 | const axios = require('axios'); 3 | const utils = require('../../helpers/utils'); 4 | 5 | module.exports = function() { 6 | const DEFAULT_HEADERS = { 7 | 'Content-Type': 'application/json', 8 | }; 9 | 10 | let WEB_BASE = ''; // To be set in setConfig() 11 | const WEB_BASE_PREFIX = '/api'; 12 | let config = { 13 | apiKey: '', 14 | secret_key: '', 15 | }; 16 | let log = {}; 17 | 18 | const notValidStatuses = [ 19 | 401, // ~Invalid auth, payload, nonce 20 | 429, // Too many requests 21 | 423, // Temporary block 22 | 500, // Service temporary unavailable 23 | // 404, ~Not found: for getOrderDeals() 24 | // 400, ~Processed with an error 25 | // 422, ~Data validation error 26 | ]; 27 | 28 | /** 29 | * Handles response from API 30 | * @param {Object} responseOrError 31 | * @param resolve 32 | * @param reject 33 | * @param {String} bodyString 34 | * @param {String} queryString 35 | * @param {String} url 36 | */ 37 | const handleResponse = (responseOrError, resolve, reject, bodyString, queryString, url) => { 38 | const httpCode = responseOrError?.status || responseOrError?.response?.status; 39 | const httpMessage = responseOrError?.statusText || responseOrError?.response?.statusText; 40 | 41 | const azbitData = responseOrError?.data || responseOrError?.response?.data; 42 | const azbitStatus = httpCode === 200 ? true : false; // Azbit doesn't return any special status on success 43 | const azbitErrorCode = 'No error code'; // Azbit doesn't have error codes 44 | 45 | // Azbit returns string in case of error, or { errors } 46 | let azbitErrorMessage; 47 | if (azbitData) { 48 | if (azbitData.errors) { 49 | azbitErrorMessage = JSON.stringify(azbitData.errors); 50 | } 51 | 52 | if (typeof azbitData === 'string') { 53 | azbitErrorMessage = azbitData; 54 | } 55 | 56 | if (azbitData.status === 404 && url.includes('/deals')) { 57 | azbitErrorMessage = 'Order not found'; 58 | } 59 | } 60 | 61 | const azbitErrorInfo = `[${azbitErrorCode}] ${utils.trimAny(azbitErrorMessage, ' .')}`; 62 | 63 | const errorMessage = httpCode ? `${httpCode} ${httpMessage}, ${azbitErrorInfo}` : String(responseOrError); 64 | const reqParameters = queryString || bodyString || '{ No parameters }'; 65 | 66 | try { 67 | if (azbitStatus) { 68 | resolve(azbitData || azbitStatus); // If cancel request is successful, azbitData is undefined :) 69 | } else if (azbitErrorMessage) { 70 | if (notValidStatuses.includes(httpCode)) { 71 | log.log(`Azbit request to ${url} with data ${reqParameters} failed: ${errorMessage}. Rejecting…`); 72 | reject({ azbitErrorInfo }); 73 | } else { 74 | log.log(`Azbit processed a request to ${url} with data ${reqParameters}, but with error: ${errorMessage}. Resolving…`); 75 | resolve({ azbitErrorInfo }); 76 | } 77 | } else { 78 | log.warn(`Request to ${url} with data ${reqParameters} failed: ${errorMessage}. Rejecting…`); 79 | reject(errorMessage); 80 | } 81 | } catch (e) { 82 | log.warn(`Error while processing response of request to ${url} with data ${reqParameters}: ${e}. Data object I've got: ${JSON.stringify(azbitData)}.`); 83 | reject(`Unable to process data: ${JSON.stringify(azbitData)}. ${e}`); 84 | } 85 | }; 86 | 87 | /** 88 | * Creates an url params string as: key1=value1&key2=value2 89 | * @param {Object} data Request params 90 | * @returns {String} 91 | */ 92 | function getParamsString(data) { 93 | const params = []; 94 | 95 | for (const key in data) { 96 | const v = data[key]; 97 | params.push(key + '=' + v); 98 | } 99 | 100 | return params.join('&'); 101 | } 102 | 103 | /** 104 | * Creates a full url with params as https://data.azbit.com/api/endpoint?key1=value1&key2=value2 105 | * @param {Object} data Request params 106 | * @returns {String} 107 | */ 108 | function getUrlWithParams(url, data) { 109 | const queryString = getParamsString(data); 110 | 111 | if (queryString) { 112 | url = url + '?' + queryString; 113 | } 114 | 115 | return url; 116 | } 117 | 118 | /** 119 | * Makes a request to public endpoint 120 | * @param {String} path Endpoint 121 | * @param {Object} data Request params 122 | * @returns {*} 123 | */ 124 | function publicRequest(path, data) { 125 | let url = `${WEB_BASE}${path}`; 126 | const urlBase = url; 127 | 128 | const queryString = getParamsString(data); 129 | url = getUrlWithParams(url, data); 130 | 131 | return new Promise((resolve, reject) => { 132 | const httpOptions = { 133 | url, 134 | method: 'get', 135 | timeout: 10000, 136 | headers: DEFAULT_HEADERS, 137 | }; 138 | axios(httpOptions) 139 | .then((response) => handleResponse(response, resolve, reject, undefined, queryString, urlBase)) 140 | .catch((error) => handleResponse(error, resolve, reject, undefined, queryString, urlBase)); 141 | }); 142 | } 143 | 144 | /** 145 | * Makes a request to private (auth) endpoint 146 | * @param {String} path Endpoint 147 | * @param {Object} data Request params 148 | * @param {String} method Request type: get, post, delete 149 | * @returns {*} 150 | */ 151 | function protectedRequest(path, data, method) { 152 | let url = `${WEB_BASE}${path}`; 153 | const urlBase = url; 154 | 155 | let headers; 156 | let bodyString; 157 | let queryString; 158 | 159 | try { 160 | if (method === 'get') { 161 | bodyString = ''; 162 | queryString = getParamsString(data); 163 | url = getUrlWithParams(url, data); 164 | } else { 165 | bodyString = getBody(data); 166 | } 167 | 168 | const signature = getSignature(url, bodyString); 169 | 170 | headers = { 171 | ...DEFAULT_HEADERS, 172 | 'API-PublicKey': config.apiKey, 173 | 'API-Signature': signature.toString().trim(), 174 | }; 175 | } catch (err) { 176 | log.log(`Processing of request to ${url} with data ${bodyString} failed. ${err}.`); 177 | return Promise.reject(err.toString()); 178 | } 179 | 180 | return new Promise((resolve, reject) => { 181 | const httpOptions = { 182 | method, 183 | url, 184 | timeout: 10000, 185 | headers, 186 | data, 187 | }; 188 | 189 | axios(httpOptions) 190 | .then((response) => handleResponse(response, resolve, reject, bodyString, queryString, urlBase)) 191 | .catch((error) => handleResponse(error, resolve, reject, bodyString, queryString, urlBase)); 192 | }); 193 | } 194 | 195 | const getBody = (data) => { 196 | return utils.isObjectNotEmpty(data) ? JSON.stringify(data) : ''; 197 | }; 198 | 199 | const getSignature = (url, payload) => { 200 | return crypto.createHmac('sha256', config.secret_key).update(config.apiKey + url + payload).digest('hex'); 201 | }; 202 | 203 | const EXCHANGE_API = { 204 | setConfig(apiServer, apiKey, secretKey, tradePwd, logger, publicOnly = false) { 205 | if (apiServer) { 206 | WEB_BASE = apiServer + WEB_BASE_PREFIX; 207 | } 208 | 209 | if (logger) { 210 | log = logger; 211 | } 212 | 213 | if (!publicOnly) { 214 | config = { 215 | apiKey, 216 | secret_key: secretKey, 217 | }; 218 | } 219 | }, 220 | 221 | /** 222 | * List of user balances for all currencies 223 | * @return {Object} { balances, balancesBlockedInOrder, balancesInCurrencyOfferingsVesting?, withdrawalLimits, currencies } 224 | * https://docs.azbit.com/docs/public-api/wallet#apiwalletsbalances 225 | */ 226 | async getBalances() { 227 | const data = {}; 228 | return protectedRequest('/wallets/balances', data, 'get'); 229 | }, 230 | 231 | /** 232 | * Query account orders 233 | * @param {String} pair In Azbit format as ETH_USDT 234 | * @param {String} status ["all", "active", "cancelled"]. Optional. 235 | * @return {Object} 236 | * https://docs.azbit.com/docs/public-api/orders#apiuserorders 237 | * 238 | */ 239 | getOrders(pair, status) { 240 | const data = {}; 241 | 242 | if (pair) data.currencyPairCode = pair; 243 | if (status) data.status = status; 244 | 245 | return protectedRequest('/user/orders', data, 'get'); 246 | }, 247 | 248 | /** 249 | * Query order deals 250 | * @param {String} orderId Exchange's orderId as '70192a8b-c34e-48ce-badf-889584670507' 251 | * @return {Object} { deals[], id, isCanceled and other order details } 252 | * https://docs.azbit.com/docs/public-api/orders#apiordersorderiddeals 253 | * Order doesn't exist: 404, { status: 404 } 254 | * Wrong orderId (not a GUID): 400, { status: 400, errors: { ... } } 255 | * No deals: { deals: [], ... } 256 | * Cancelled order: { deals: [...], isCanceled: true, ... } 257 | */ 258 | getOrderDeals(orderId) { 259 | return protectedRequest(`/orders/${orderId}/deals`, {}, 'get'); 260 | }, 261 | 262 | /** 263 | * Places a order 264 | * @param {string} market In Azbit format as ETH_USDT 265 | * @param {string} amount Order amount in coin1 266 | * @param {string} price Order price 267 | * @param {string} side 'buy' or 'sell' 268 | * @return {Object} Order GUID in case if success. Example: "e2cd407c-28c8-4768-bd73-cd7357fbccde". 269 | * https://docs.azbit.com/docs/public-api/orders#post 270 | */ 271 | addOrder(market, amount, price, side) { 272 | const data = { 273 | side, 274 | currencyPairCode: market, 275 | amount, 276 | price, 277 | }; 278 | 279 | return protectedRequest('/orders', data, 'post'); 280 | }, 281 | 282 | /** 283 | * Cancel an order 284 | * @param {String} orderId Example: '70192a8b-c34e-48ce-badf-889584670507' 285 | * @return {Object} Success response with no data 286 | * https://docs.azbit.com/docs/public-api/orders#delete-1 287 | */ 288 | cancelOrder(orderId) { 289 | return protectedRequest(`/orders/${orderId}`, {}, 'delete'); 290 | }, 291 | 292 | /** 293 | * Cancel all orders for currency pair 294 | * @param {String} pair In Azbit format as ETH_USDT 295 | * @returns {Object} Success response with no data. Never mind, if no success, no data as well. Same 200 status. 296 | * https://docs.azbit.com/docs/public-api/orders#delete 297 | */ 298 | cancelAllOrders(pair) { 299 | return protectedRequest(`/orders?currencyPairCode=${pair}`, {}, 'delete'); 300 | }, 301 | 302 | /** 303 | * Get trade details for a ticker (market rates) 304 | * @param {String} pair In Azbit format as ETH_USDT 305 | * @return {Object} 306 | * https://docs.azbit.com/docs/public-api/tickers#apitickers 307 | */ 308 | ticker(pair) { 309 | const data = { 310 | currencyPairCode: pair, 311 | }; 312 | 313 | return publicRequest('/tickers', data); 314 | }, 315 | 316 | /** 317 | * Get market depth, 40 bids + 40 asks 318 | * Note: returns [] for a wrong trade pair 319 | * @param pair In Azbit format as ETH_USDT 320 | * @return {Object} 321 | * https://docs.azbit.com/docs/public-api/orders#apiorderbook 322 | */ 323 | orderBook(pair) { 324 | const data = { 325 | currencyPairCode: pair, 326 | }; 327 | 328 | return publicRequest('/orderbook', data); 329 | }, 330 | 331 | /** 332 | * Get trades history 333 | * Note: returns [] for a wrong trade pair 334 | * @param pair In Azbit format as ETH_USDT 335 | * @param pageSize Number of trades to return. Max is 200. 336 | * @param pageNumber Page number. Optional. 337 | * @return {Object} Last trades 338 | * https://docs.azbit.com/docs/public-api/deals#apideals 339 | */ 340 | getTradesHistory(pair, pageSize = 200, pageNumber) { 341 | const data = { 342 | pageSize, 343 | currencyPairCode: pair, 344 | }; 345 | 346 | if (pageNumber) data.pageNumber = pageNumber; 347 | 348 | return publicRequest('/deals', data); 349 | }, 350 | 351 | /** 352 | * Get all crypto currencies 353 | * Note: v1 endpoint returns only coin tickers. 354 | * v1 /wallets/balances and v2 https://api2.azbit.com/api/currencies offer much more, but never mind. 355 | * @returns {Object} 356 | * https://docs.azbit.com/docs/public-api/currency#apicurrencies 357 | */ 358 | getCurrencies() { 359 | const data = {}; 360 | return publicRequest('/currencies', data); 361 | }, 362 | 363 | /** 364 | * Get user deposit address 365 | * @param coin As BTC 366 | * @returns {Object} 367 | * https://docs.azbit.com/docs/public-api/wallet#apideposit-addresscurrencycode 368 | */ 369 | getDepositAddress(coin) { 370 | return protectedRequest(`/deposit-address/${coin}`, {}, 'get'); 371 | }, 372 | 373 | /** 374 | * Get trade fees 375 | * @returns {Object} 376 | * https://docs.azbit.com/docs/public-api/currency#apicurrenciesusercommissions 377 | */ 378 | getFees() { 379 | return protectedRequest('/currencies/user/commissions', {}, 'get'); 380 | }, 381 | 382 | /** 383 | * Get info on all markets 384 | * @returns {Object} 385 | * https://docs.azbit.com/docs/public-api/currency#apicurrenciespairs 386 | */ 387 | async markets() { 388 | const data = {}; 389 | return publicRequest('/currencies/pairs', data); 390 | }, 391 | }; 392 | 393 | return EXCHANGE_API; 394 | }; 395 | -------------------------------------------------------------------------------- /trade/api/tapbit_api.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios'); 2 | const crypto = require('crypto'); 3 | 4 | const { 5 | getParamsString, 6 | } = require('../../helpers/utils'); 7 | 8 | /** 9 | * Docs: https://tapbit.com/openapi-docs/spot 10 | */ 11 | 12 | // Error codes: https://www.tapbit.com/openapi-docs/spot/spot_errorcode/ 13 | const httpErrorCodeDescriptions = { 14 | 4: { // 4XX 15 | description: 'Wrong request content, behavior, format', 16 | }, 17 | 429: { 18 | description: 'Warning access frequency exceeding the limit', 19 | isTemporary: true, 20 | }, 21 | 5: { // 5XX 22 | description: 'Problems on the Tapbit service side', 23 | isTemporary: true, 24 | }, 25 | 504: { 26 | description: 'API server has submitted a request to the business core but failed to get a response', 27 | }, 28 | }; 29 | 30 | /** 31 | * Error codes: 32 | * isTemporary means that we consider the request is temporary failed and we'll repeat it later with success possibility 33 | */ 34 | const errorCodeDescriptions = { 35 | // Our experience 36 | 10008: { 37 | description: 'Request timestamp expired', 38 | isTemporary: true, 39 | }, 40 | 10010: { 41 | description: 'API authentication failed', 42 | isTemporary: true, 43 | }, 44 | 10012: { 45 | description: 'Invalid authorization', 46 | isTemporary: true, 47 | }, 48 | // Provided by Tapbit 49 | 11000: { 50 | description: 'Parameter value is empty', 51 | }, 52 | 11001: { 53 | description: 'Invalid Parameter value', 54 | }, 55 | 11002: { 56 | description: 'The parameter value exceeds the maximum limit', 57 | }, 58 | 11003: { 59 | description: 'Third-party interface does not return data temporarily', 60 | isTemporary: true, 61 | }, 62 | 11004: { 63 | description: 'Invalid price precision', 64 | }, 65 | 11005: { 66 | description: 'Invalid quantity precision', 67 | }, 68 | 11006: { 69 | description: 'Unknow Exception', 70 | }, 71 | 11007: { 72 | description: 'coin pair does not match assets', 73 | }, 74 | 11008: { 75 | description: 'User ID not obtained', 76 | }, 77 | 11009: { 78 | description: 'User\'s Site value was not obtained', 79 | }, 80 | 11010: { 81 | description: 'The Bank value to which the user belongs was not obtained', 82 | }, 83 | 11011: { 84 | description: 'The asset operation is not supported temporarily', 85 | isTemporary: true, 86 | }, 87 | 11012: { 88 | description: 'This user operation is not supported temporarily', 89 | isTemporary: true, 90 | }, 91 | 11013: { 92 | description: 'Only limit order types are supported', 93 | }, 94 | 11014: { 95 | description: 'Order does not exist', 96 | }, 97 | // On 429, TapBit returns 200 and '429' as internal error code. Example: '200 OK, [429] Too Many Requests'. 98 | // Here we consider only 429 and 504 codes, skipping 4XX and 5XX masks. 99 | ...httpErrorCodeDescriptions, 100 | }; 101 | 102 | module.exports = function() { 103 | let WEB_BASE = 'https://openapi.tapbit.com/spot'; 104 | let config = { 105 | apiKey: '', 106 | secret_key: '', 107 | tradePwd: '', 108 | }; 109 | let log = {}; 110 | 111 | /** 112 | * Handles response from API 113 | * @param {Object} responseOrError 114 | * @param resolve 115 | * @param reject 116 | * @param {String} queryString 117 | * @param {String} url 118 | */ 119 | const handleResponse = (responseOrError, resolve, reject, queryString, url) => { 120 | const httpCode = responseOrError?.status ?? responseOrError?.response?.status; 121 | const httpMessage = responseOrError?.statusText ?? responseOrError?.response?.statusText; 122 | 123 | const data = responseOrError?.data ?? responseOrError?.response?.data; 124 | 125 | const tapbitErrorInfo = errorCodeDescriptions[data?.code]; 126 | const httpCodeInfo = httpErrorCodeDescriptions[httpCode] ?? httpErrorCodeDescriptions[httpCode?.toString()[0]]; 127 | 128 | const success = httpCode === 200 && data?.code === 200; 129 | 130 | const error = { 131 | code: data?.code ?? 'No error code', 132 | message: data?.message ?? tapbitErrorInfo?.description ?? 'No error message', 133 | }; 134 | 135 | const reqParameters = queryString || '{ No parameters }'; 136 | 137 | try { 138 | if (success) { 139 | resolve(data.data); 140 | } else { 141 | const tapbitErrorInfoString = `[${error.code}] ${error.message || 'No error message'}`; 142 | const errorMessage = httpCode ? `${httpCode} ${httpMessage}, ${tapbitErrorInfoString}` : String(responseOrError); 143 | 144 | if (typeof data === 'object') { 145 | data.tapbitErrorInfo = tapbitErrorInfoString; 146 | } 147 | 148 | if (httpCode && !httpCodeInfo?.isTemporary && !tapbitErrorInfo?.isTemporary) { 149 | log.log(`Tapbit processed a request to ${url} with data ${reqParameters}, but with error: ${errorMessage}. Resolving…`); 150 | 151 | resolve(data); 152 | } else { 153 | log.warn(`Request to ${url} with data ${reqParameters} failed. Details: ${errorMessage}. Rejecting…`); 154 | 155 | reject(errorMessage); 156 | } 157 | } 158 | } catch (error) { 159 | log.warn(`Error while processing response of request to ${url} with data ${reqParameters}: ${error}. Data object I've got: ${JSON.stringify(data)}.`); 160 | reject(`Unable to process data: ${JSON.stringify(data)}. ${error}`); 161 | } 162 | }; 163 | 164 | /** 165 | * Makes a request to private (auth) endpoint 166 | * @param {String} type Request type: get, post, delete 167 | * @param {String} path Endpoint 168 | * @param {Object} data Request params 169 | * @returns {*} 170 | */ 171 | function protectedRequest(type, path, data) { 172 | const url = `${WEB_BASE}${path}`; 173 | 174 | const bodyString = getParamsString(data); 175 | const formattedBodyString = type === 'get' && bodyString.length ? `?${bodyString}` : ''; 176 | const stringifiedData = JSON.stringify(data); 177 | 178 | const timestamp = Date.now() / 1000; 179 | 180 | const signPayload = `${timestamp}${type.toUpperCase()}${path}${formattedBodyString}${type === 'post' ? stringifiedData : ''}`; 181 | const sign = getSignature(config.secret_key, signPayload); 182 | 183 | return new Promise((resolve, reject) => { 184 | const httpOptions = { 185 | url, 186 | method: type, 187 | timeout: 10000, 188 | headers: { 189 | 'Content-Type': 'application/json', 190 | 'ACCESS-KEY': config.apiKey, 191 | 'ACCESS-SIGN': sign, 192 | 'ACCESS-TIMESTAMP': timestamp, 193 | }, 194 | data: type === 'post' ? data : undefined, 195 | params: type === 'get' ? data : undefined, 196 | paramsSerializer: getParamsString, 197 | }; 198 | 199 | axios(httpOptions) 200 | .then((response) => handleResponse(response, resolve, reject, bodyString, url)) 201 | .catch((error) => handleResponse(error, resolve, reject, bodyString, url)); 202 | }); 203 | } 204 | 205 | /** 206 | * Makes a request to public endpoint 207 | * @param {String} type Request type: get, post, delete 208 | * @param {String} path Endpoint 209 | * @param {Object} params Request params 210 | * @returns {*} 211 | */ 212 | function publicRequest(type, path, params) { 213 | const url = `${WEB_BASE}${path}`; 214 | 215 | const queryString = getParamsString(params); 216 | 217 | return new Promise((resolve, reject) => { 218 | const httpOptions = { 219 | url, 220 | params, 221 | method: type, 222 | timeout: 10000, 223 | }; 224 | 225 | axios(httpOptions) 226 | .then((response) => handleResponse(response, resolve, reject, queryString, url)) 227 | .catch((error) => handleResponse(error, resolve, reject, queryString, url)); 228 | }); 229 | } 230 | 231 | /** 232 | * Get a signature for a Tapbit request 233 | * @param {String} secret API secret key 234 | * @param {String} payload Data to sign 235 | * @returns {String} 236 | */ 237 | function getSignature(secret, payload) { 238 | return crypto 239 | .createHmac('sha256', secret) 240 | .update(payload) 241 | .digest('hex'); 242 | } 243 | 244 | const EXCHANGE_API = { 245 | setConfig(apiServer, apiKey, secretKey, tradePwd, logger, publicOnly = false) { 246 | if (apiServer) { 247 | WEB_BASE = apiServer; 248 | } 249 | 250 | if (logger) { 251 | log = logger; 252 | } 253 | 254 | if (!publicOnly) { 255 | config = { 256 | apiKey, 257 | tradePwd, 258 | secret_key: secretKey, 259 | }; 260 | } 261 | }, 262 | 263 | /** 264 | * Spot Account Information 265 | * https://www.tapbit.com/openapi-docs/spot/private/account_info 266 | * @return {Promise<[]>} 267 | */ 268 | getBalances() { 269 | return protectedRequest('get', '/api/v1/spot/account/list', {}); 270 | }, 271 | 272 | /** 273 | * Get open order list 274 | * https://www.tapbit.com/openapi-docs/spot/private/open_order_list/ 275 | * @param {String} symbol In Tapbit format as BTC/USDT 276 | * @param {String} [nextOrderId] Order ID, which is used in pagination. The default value is empty. The latest 20 pieces (upd: see 'limit') of data are returned and displayed in reverse order by order ID. Get the last order Id-1, take the next page of data. 277 | * @param {Number} limit Not documented, but it's available. Max is 100. 278 | * @return {Promise<[]>} 279 | */ 280 | getOrders(symbol, nextOrderId, limit = 100) { 281 | const params = { 282 | instrument_id: symbol, 283 | next_order_id: nextOrderId, 284 | limit, 285 | }; 286 | 287 | return protectedRequest('get', '/api/v1/spot/open_order_list', params); 288 | }, 289 | 290 | /** 291 | * Get specified order information 292 | * https://www.tapbit.com/openapi-docs/spot/private/order_info/ 293 | * @param {String} orderId Example: '2257824251095171072' 294 | * @returns {Promise} 295 | */ 296 | getOrder(orderId) { 297 | const params = { 298 | order_id: orderId, 299 | }; 300 | 301 | return protectedRequest('get', '/api/v1/spot/order_info', params); 302 | }, 303 | 304 | /** 305 | * Place Order 306 | * https://www.tapbit.com/openapi-docs/spot/private/order/ 307 | * @param {String} symbol In Tapbit format as BTC/USDT 308 | * @param {String} amount Base coin amount 309 | * @param {String} price Order price 310 | * @param {String} side buy or sell 311 | * @return {Promise} 312 | */ 313 | addOrder(symbol, amount, price, side) { 314 | const data = { 315 | instrument_id: symbol, 316 | direction: side === 'buy' ? 1 : 2, 317 | price, 318 | quantity: amount, 319 | }; 320 | 321 | return protectedRequest('post', '/api/v1/spot/order', data); 322 | }, 323 | 324 | /** 325 | * Cancel specified order 326 | * https://www.tapbit.com/openapi-docs/spot/private/cancel_order/ 327 | * @param {String} orderId Example: '2257616624297820160' 328 | * @return {Promise} 329 | */ 330 | cancelOrder(orderId) { 331 | const data = { 332 | order_id: orderId, 333 | }; 334 | 335 | return protectedRequest('post', '/api/v1/spot/cancel_order', data); 336 | }, 337 | 338 | /** 339 | * Batch cancel orders 340 | * https://www.tapbit.com/openapi-docs/spot/private/batch_cancel_order 341 | * @param {[]} orderIds 342 | * @return {Promise<[]>} 343 | */ 344 | cancelAllOrders(orderIds) { 345 | const data = { 346 | orderIds, 347 | }; 348 | 349 | return protectedRequest('post', '/api/v1/spot/batch_cancel_order', data); 350 | }, 351 | 352 | /** 353 | * Get the specified ticker information 354 | * https://www.tapbit.com/openapi-docs/spot/public/ticker/ 355 | * @param {String} symbol In Tapbit format as BTC/USDT 356 | * @return {Promise} 357 | */ 358 | ticker(symbol) { 359 | const params = { 360 | instrument_id: symbol, 361 | }; 362 | 363 | return publicRequest('get', '/api/spot/instruments/ticker_one', params); 364 | }, 365 | 366 | /** 367 | * Get spot specified instrument information 368 | * https://www.tapbit.com/openapi-docs/spot/public/depth/ 369 | * @param {String} symbol In Tapbit format as BTC/USDT 370 | * @param {Number} [limit=100] 5, 10, 50 or 100 371 | * @return {Promise} 372 | */ 373 | orderBook(symbol, limit = 100) { 374 | const params = { 375 | instrument_id: symbol, 376 | depth: limit, 377 | }; 378 | 379 | return publicRequest('get', '/api/spot/instruments/depth', params); 380 | }, 381 | 382 | /** 383 | * Get the latest trade list information 384 | * https://www.tapbit.com/openapi-docs/spot/public/latest_trade_list/ 385 | * @param {String} symbol In Tapbit format as BTC/USDT 386 | * @return {Promise<[]>} 387 | */ 388 | getTradesHistory(symbol) { 389 | const params = { 390 | instrument_id: symbol, 391 | }; 392 | 393 | return publicRequest('get', '/api/spot/instruments/trade_list', params); 394 | }, 395 | 396 | /** 397 | * Get spot instruments informations 398 | * https://www.tapbit.com/openapi-docs/spot/public/trade_pair_list/ 399 | * @return {Promise<[]>} 400 | */ 401 | markets() { 402 | return publicRequest('get', '/api/spot/instruments/trade_pair_list', {}); 403 | }, 404 | 405 | /** 406 | * This endpoint returns a list of currency details 407 | * https://www.tapbit.com/openapi-docs/spot/public/asset_list/ 408 | * @return {Promise<[]>} 409 | */ 410 | currencies() { 411 | return publicRequest('get', '/api/spot/instruments/asset/list', {}); 412 | }, 413 | }; 414 | 415 | return EXCHANGE_API; 416 | }; 417 | 418 | module.exports.axios = axios; // for setup axios mock adapter 419 | -------------------------------------------------------------------------------- /trade/api/biconomy_api.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios'); 2 | const crypto = require('crypto'); 3 | const { 4 | getParamsString, 5 | } = require('../../helpers/utils'); 6 | 7 | /** 8 | * Docs: https://github.com/BiconomyOfficial/apidocs 9 | */ 10 | 11 | /** 12 | * Error codes: https://github.com/BiconomyOfficial/apidocs?tab=readme-ov-file#rest-api 13 | * isTemporary means that we consider the request is temporary failed and we'll repeat it later with success possibility 14 | */ 15 | const errorCodeDescriptions = { 16 | 0: { 17 | description: 'Success', 18 | }, 19 | 1: { 20 | description: 'Invalid parameter', 21 | }, 22 | 2: { 23 | description: 'Internal error', 24 | isTemporary: true, 25 | }, 26 | 3: { 27 | description: 'Service unavailable', 28 | isTemporary: true, 29 | }, 30 | 4: { 31 | description: 'Method Not Found', 32 | }, 33 | 5: { 34 | description: 'Service timeout', 35 | isTemporary: true, 36 | }, 37 | 10: { 38 | description: 'Insufficient amount', 39 | }, 40 | 11: { 41 | description: 'Number of transactions is too small', 42 | }, 43 | 12: { 44 | description: 'Insufficient depth', 45 | }, 46 | 10005: { 47 | description: 'Record Not Found', 48 | }, 49 | 10022: { 50 | description: 'Failed real-name authentication', 51 | }, 52 | 10051: { 53 | description: 'User forbids trading', 54 | }, 55 | 10056: { 56 | description: 'Less than minimum amount', 57 | }, 58 | 10059: { 59 | description: 'The asset has not been opened for trading yet', 60 | }, 61 | 10060: { 62 | description: 'This trading pair has not yet opened trading', 63 | }, 64 | 10062: { 65 | description: 'Inaccurate amount accuracy', 66 | }, 67 | }; 68 | 69 | // No error code descriptions from Biconomy 70 | const httpErrorCodeDescriptions = { 71 | 4: { // 4XX 72 | description: 'Wrong request content, behavior, format', 73 | isTemporary: false, 74 | }, 75 | 429: { 76 | description: 'Warning access frequency exceeding the limit', 77 | isTemporary: true, 78 | }, 79 | 5: { // 5XX 80 | description: 'Problems on the Biconomy service side', 81 | isTemporary: true, 82 | }, 83 | 504: { 84 | description: 'API server has submitted a request to the business core but failed to get a response', 85 | isTemporary: false, 86 | }, 87 | }; 88 | 89 | module.exports = function() { 90 | let WEB_BASE = 'https://market.biconomy.vip/api'; 91 | let config = { 92 | apiKey: '', 93 | secret_key: '', 94 | tradePwd: '', 95 | }; 96 | let log = {}; 97 | 98 | /** 99 | * Handles response from API 100 | * @param {Object} responseOrError 101 | * @param resolve 102 | * @param reject 103 | * @param {String} queryString 104 | * @param {String} url 105 | */ 106 | const handleResponse = (responseOrError, resolve, reject, queryString, url) => { 107 | const httpCode = responseOrError?.status ?? responseOrError?.response?.status; 108 | const httpMessage = responseOrError?.statusText ?? responseOrError?.response?.statusText; 109 | 110 | const data = responseOrError?.data ?? responseOrError?.response?.data; 111 | const success = httpCode === 200 && !data.code; 112 | 113 | const biconomyError = errorCodeDescriptions[data?.code]; 114 | const error = { 115 | code: data?.code ?? 'No error code', 116 | message: data?.message ?? biconomyError?.description ?? 'No error message', 117 | }; 118 | 119 | const httpCodeInfo = httpErrorCodeDescriptions[httpCode] ?? httpErrorCodeDescriptions[httpCode?.toString()[0]]; 120 | 121 | const reqParameters = queryString || '{ No parameters }'; 122 | 123 | try { 124 | if (success) { 125 | resolve(data); 126 | } else { 127 | const biconomyErrorInfo = `[${error.code}] ${error.message || 'No error message'}`; 128 | const errorMessage = httpCode ? `${httpCode} ${httpMessage}, ${biconomyErrorInfo}` : String(responseOrError); 129 | 130 | if (typeof data === 'object') { 131 | data.biconomyErrorInfo = biconomyErrorInfo; 132 | } 133 | 134 | if (httpCode && !httpCodeInfo?.isTemporary && !biconomyError?.isTemporary) { 135 | log.log(`Biconomy processed a request to ${url} with data ${reqParameters}, but with error: ${errorMessage}. Resolving…`); 136 | 137 | resolve(data); 138 | } else { 139 | log.warn(`Request to ${url} with data ${reqParameters} failed. Details: ${errorMessage}. Rejecting…`); 140 | 141 | reject(errorMessage); 142 | } 143 | } 144 | } catch (error) { 145 | log.warn(`Error while processing response of request to ${url} with data ${reqParameters}: ${error}. Data object I've got: ${JSON.stringify(data)}.`); 146 | reject(`Unable to process data: ${JSON.stringify(data)}. ${error}`); 147 | } 148 | }; 149 | 150 | /** 151 | * Makes a request to private (auth) endpoint 152 | * @param {String} type Request type: get, post, 153 | * @param {String} path Endpoint 154 | * @param {Object} data Request params 155 | * @returns {*} 156 | */ 157 | function protectedRequest(type, path, data) { 158 | const url = `${WEB_BASE}${path}`; 159 | 160 | const bodyString = getParamsString(data); 161 | 162 | data.api_key = config.apiKey; 163 | 164 | const sortedData = Object.keys(data) 165 | .sort() 166 | .reduce((accumulator, key) => { 167 | accumulator[key] = data[key]; 168 | return accumulator; 169 | }, {}); 170 | 171 | sortedData.secret_key = config.secret_key; 172 | 173 | const sortedDataString = getParamsString(sortedData); 174 | 175 | const hash = getHash(sortedDataString).toUpperCase(); 176 | 177 | const params = { ...data, api_key: config.apiKey, sign: hash }; 178 | const sortedParams = Object.keys(params) 179 | .sort() 180 | .reduce((accumulator, key) => { 181 | accumulator[key] = params[key]; 182 | return accumulator; 183 | }, {}); 184 | const sortedParamsString = getParamsString(sortedParams); 185 | 186 | return new Promise((resolve, reject) => { 187 | const httpOptions = { 188 | url, 189 | method: type, 190 | timeout: 10000, 191 | headers: { 192 | 'Content-Type': 'application/x-www-form-urlencoded', 193 | 'X-SITE-ID': 127, 194 | }, 195 | data: sortedParamsString, 196 | }; 197 | 198 | axios(httpOptions) 199 | .then((response) => handleResponse(response, resolve, reject, bodyString, url)) 200 | .catch((error) => handleResponse(error, resolve, reject, bodyString, url)); 201 | }); 202 | } 203 | 204 | /** 205 | * Makes a request to public endpoint 206 | * @param {String} type Request type: get, post, delete 207 | * @param {String} path Endpoint 208 | * @param {Object} params Request params 209 | * @returns {*} 210 | */ 211 | function publicRequest(type, path, params) { 212 | const url = `${WEB_BASE}${path}`; 213 | 214 | const queryString = getParamsString(params); 215 | 216 | return new Promise((resolve, reject) => { 217 | const httpOptions = { 218 | url, 219 | params, 220 | method: type, 221 | timeout: 10000, 222 | headers: { 223 | 'Content-Type': 'application/x-www-form-urlencoded', 224 | 'X-SITE-ID': 127, 225 | }, 226 | }; 227 | 228 | axios(httpOptions) 229 | .then((response) => handleResponse(response, resolve, reject, queryString, url)) 230 | .catch((error) => handleResponse(error, resolve, reject, queryString, url)); 231 | }); 232 | } 233 | 234 | /** 235 | * Get a hash for a Biconomy request 236 | * @param {String} payload Data to hash 237 | * @returns {String} 238 | */ 239 | function getHash(payload) { 240 | return crypto 241 | .createHash('md5') 242 | .update(payload) 243 | .digest('hex'); 244 | } 245 | 246 | const EXCHANGE_API = { 247 | setConfig(apiServer, apiKey, secretKey, tradePwd, logger, publicOnly = false) { 248 | if (apiServer) { 249 | WEB_BASE = apiServer; 250 | } 251 | 252 | if (logger) { 253 | log = logger; 254 | } 255 | 256 | if (!publicOnly) { 257 | config = { 258 | apiKey, 259 | tradePwd, 260 | secret_key: secretKey, 261 | }; 262 | } 263 | }, 264 | 265 | /** 266 | * Get User Assets 267 | * https://github.com/BiconomyOfficial/apidocs?tab=readme-ov-file#get-user-assets 268 | * @return {Promise<{}>} 269 | */ 270 | getBalances() { 271 | return protectedRequest('post', '/v1/private/user', {}); 272 | }, 273 | 274 | /** 275 | * Query Unfilled Orders 276 | * https://github.com/BiconomyOfficial/apidocs?tab=readme-ov-file#Query-unfilled-orders 277 | * @param {String} symbol In Biconomy format as BTC_USDT 278 | * @param {Number} [limit=100] Max: 100 279 | * @param {Number} [offset=0] 280 | * @return {Promise<[]>} 281 | */ 282 | getOrders(symbol, limit = 100, offset = 0) { 283 | const params = { 284 | market: symbol, 285 | limit, 286 | offset, 287 | }; 288 | 289 | return protectedRequest('post', '/v1/private/order/pending', params); 290 | }, 291 | 292 | /** 293 | * Query Details Of An Unfilled Order 294 | * https://github.com/BiconomyOfficial/apidocs?tab=readme-ov-file#query-details-of-an-unfilled-order 295 | * @param {String} symbol In Biconomy format as BTC_USDT 296 | * @param {Number} orderId Example: 32868 297 | * @returns {Promise} 298 | */ 299 | getPendingOrder(symbol, orderId) { 300 | const data = { 301 | market: symbol, 302 | order_id: orderId, 303 | }; 304 | 305 | return protectedRequest('post', '/v1/private/order/pending/detail', data); 306 | }, 307 | 308 | /** 309 | * Query Details of a Completed Order 310 | * https://github.com/BiconomyOfficial/apidocs?tab=readme-ov-file#query-details-of-an-unfilled-order-1 311 | * @param {String} symbol In Biconomy format as BTC_USDT 312 | * @param {Number} orderId Example: 32868 313 | * @returns {Promise} 314 | */ 315 | getFinishedOrder(symbol, orderId) { 316 | const data = { 317 | market: symbol, 318 | order_id: orderId, 319 | }; 320 | 321 | return protectedRequest('post', '/v1/private/order/finished/detail', data); 322 | }, 323 | 324 | /** 325 | * Limit Trading / Market Trading 326 | * https://github.com/BiconomyOfficial/apidocs?tab=readme-ov-file#Limit-Trading 327 | * https://github.com/BiconomyOfficial/apidocs?tab=readme-ov-file#market-trading 328 | * @param {String} symbol In Biconomy format as BTC_USDT 329 | * @param {String} amount Base coin amount 330 | * @param {String} price Order price 331 | * @param {String} side buy or sell 332 | * @param {String} type market or limit 333 | * @return {Promise} 334 | */ 335 | addOrder(symbol, amount, price, side, type) { 336 | const sideMap = { 337 | sell: 1, 338 | buy: 2, 339 | }; 340 | 341 | const data = { 342 | market: symbol, 343 | side: sideMap[side], 344 | amount, 345 | }; 346 | 347 | if (type === 'limit') { 348 | data.price = price; 349 | 350 | return protectedRequest('post', '/v1/private/trade/limit', data); 351 | } else { 352 | return protectedRequest('post', '/v1/private/trade/market', data); 353 | } 354 | }, 355 | 356 | /** 357 | * Cancel an Order 358 | * https://github.com/BiconomyOfficial/apidocs?tab=readme-ov-file#Cancel-an-Order 359 | * @param {String} symbol In Biconomy format as BTC_USDT 360 | * @param {Number} orderId Example: 32868 361 | * @return {Promise} 362 | */ 363 | cancelOrder(symbol, orderId) { 364 | const data = { 365 | market: symbol, 366 | order_id: orderId, 367 | }; 368 | 369 | return protectedRequest('post', '/v1/private/trade/cancel', data); 370 | }, 371 | 372 | /** 373 | * Bulk Cancel Order 374 | * https://github.com/BiconomyOfficial/apidocs?tab=readme-ov-file#bulk-cancel-order 375 | * @param {Array} orders Order to cancel 376 | * @param {String} symbol In Biconomy format as BTC_USDT 377 | * @return {Promise} 378 | */ 379 | cancelAllOrders(orders, symbol) { 380 | const orders_json = orders.map((order) => { 381 | return { market: symbol, order_id: +order.orderId }; 382 | }); 383 | 384 | const data = { 385 | orders_json: JSON.stringify(orders_json), 386 | }; 387 | 388 | return protectedRequest('post', '/v1/private/trade/cancel_batch', data); 389 | }, 390 | 391 | /** 392 | * Get Exchange Market Data 393 | * https://github.com/BiconomyOfficial/apidocs?tab=readme-ov-file#Get-Exchange-Market-Data 394 | * @return {Promise} 395 | */ 396 | ticker() { 397 | return publicRequest('get', '/v1/tickers', {}); 398 | }, 399 | 400 | /** 401 | * Get Depth Information 402 | * https://github.com/BiconomyOfficial/apidocs?tab=readme-ov-file#get-depth-information 403 | * @param {String} symbol In Biconomy format as BTC_USDT 404 | * @param {Number} [limit=100] Min: 1, Max: 100 405 | * @return {Promise} 406 | */ 407 | orderBook(symbol, limit = 100) { 408 | const params = { 409 | symbol, 410 | size: limit, 411 | }; 412 | 413 | return publicRequest('get', '/v1/depth', params); 414 | }, 415 | 416 | /** 417 | * Get Recent Trades 418 | * https://github.com/BiconomyOfficial/apidocs?tab=readme-ov-file#get-recent-trades 419 | * @param {String} symbol In Biconomy format as BTC_USDT 420 | * @param {Number} [limit=100] Min: 1, Max: 100 421 | * @return {Promise<[]>} 422 | */ 423 | getTradesHistory(symbol, limit = 100) { 424 | const params = { 425 | symbol, 426 | size: limit, 427 | }; 428 | 429 | return publicRequest('get', '/v1/trades', params); 430 | }, 431 | 432 | /** 433 | * Get Pair Info 434 | * https://github.com/BiconomyOfficial/apidocs?tab=readme-ov-file#Get-Pair-Info 435 | * @return {Promise<[]>} 436 | */ 437 | markets() { 438 | return publicRequest('get', '/v1/exchangeInfo', {}); 439 | }, 440 | }; 441 | 442 | return EXCHANGE_API; 443 | }; 444 | 445 | module.exports.axios = axios; // for setup axios mock adapter 446 | -------------------------------------------------------------------------------- /trade/api/binance_api.js: -------------------------------------------------------------------------------- 1 | const crypto = require('crypto'); 2 | const axios = require('axios'); 3 | 4 | const { 5 | trimAny, 6 | getParamsString, 7 | } = require('../../helpers/utils'); 8 | 9 | /** 10 | * Docs: https://binance-docs.github.io/apidocs/spot/en/#introduction 11 | * Swagger: https://binance.github.io/binance-api-swagger/ 12 | */ 13 | 14 | /** 15 | * HTTP 4XX return codes are used for malformed requests; the issue is on the sender's side. 16 | * HTTP 403 return code is used when the WAF Limit (Web Application Firewall) has been violated. 17 | * HTTP 409 return code is used when a cancelReplace order partially succeeds. (e.g. if the cancellation of the order fails but the new order placement succeeds.) 18 | * HTTP 429 return code is used when breaking a request rate limit. 19 | * HTTP 418 return code is used when an IP has been auto-banned for continuing to send requests after receiving 429 codes. 20 | * HTTP 5XX return codes are used for internal errors; the issue is on Binance's side. It is important to NOT treat this as a failure operation; the execution status is UNKNOWN and could have been a success. 21 | * Error codes: https://binance-docs.github.io/apidocs/spot/en/#error-codes 22 | * -1000 UNKNOWN and less. 23 | */ 24 | 25 | module.exports = function() { 26 | let WEB_BASE = 'https://api.binance.com'; // Default, may be changed on init 27 | let config = { 28 | apiKey: '', 29 | secret_key: '', 30 | tradePwd: '', 31 | }; 32 | let log = {}; 33 | 34 | /** 35 | * Handles response from API 36 | * @param {Object} responseOrError 37 | * @param resolve 38 | * @param reject 39 | * @param {String} bodyString 40 | * @param {String} queryString 41 | * @param {String} url 42 | */ 43 | const handleResponse = (responseOrError, resolve, reject, queryString, url) => { 44 | const httpCode = responseOrError?.status ?? responseOrError?.response?.status; 45 | const httpMessage = responseOrError?.statusText ?? responseOrError?.response?.statusText; 46 | 47 | const data = responseOrError?.data ?? responseOrError?.response?.data; 48 | const success = httpCode === 200 && !data?.code; // Binance doesn't return any special status code on success 49 | 50 | const error = { 51 | code: data?.code || 'No error code', 52 | message: data?.msg || 'No error message', 53 | }; 54 | 55 | const reqParameters = queryString || '{ No parameters }'; 56 | 57 | try { 58 | if (success) { 59 | resolve(data); 60 | } else { 61 | const binanceErrorInfo = `[${error.code}] ${trimAny(error.message, ' .')}`; 62 | const errorMessage = httpCode ? `${httpCode} ${httpMessage}, ${binanceErrorInfo}` : String(responseOrError); 63 | 64 | if (typeof data === 'object') { 65 | data.binanceErrorInfo = binanceErrorInfo; 66 | } 67 | 68 | if (httpCode >= 400 && httpCode <= 409) { 69 | const unexpectedErrorCode = data && (error.code > -1100 || error.code < -2013) ? 70 | ` Unexpected error code: ${error.code}.` : ''; 71 | 72 | log.log(`Binance processed a request to ${url} with data ${reqParameters}, but with error: ${errorMessage}.${unexpectedErrorCode} Resolving…`); 73 | 74 | resolve(data); 75 | } else { 76 | const httpErrorCodeDescriptions = { 77 | 429: 'Rate limit exceeded', 78 | 418: 'IP has been blocked because of rate limit exceeded', 79 | 500: 'Internal server error', 80 | }; 81 | 82 | const errorDescription = httpErrorCodeDescriptions[httpCode] ?? 'Unknown error'; 83 | 84 | log.warn(`Request to ${url} with data ${reqParameters} failed. ${errorDescription}, details: ${errorMessage}. Rejecting…`); 85 | 86 | reject(errorMessage); 87 | } 88 | } 89 | } catch (error) { 90 | log.warn(`Error while processing response of request to ${url} with data ${reqParameters}: ${error}. Data object I've got: ${JSON.stringify(data)}.`); 91 | reject(`Unable to process data: ${JSON.stringify(data)}. ${error}`); 92 | } 93 | }; 94 | 95 | /** 96 | * Makes a request to public endpoint 97 | * @param {String} path Endpoint 98 | * @param {Object} data Request params 99 | * @returns {*} 100 | */ 101 | function publicRequest(type, path, params) { 102 | const url = `${WEB_BASE}${path}`; 103 | 104 | const queryString = getParamsString(params); 105 | 106 | return new Promise((resolve, reject) => { 107 | const httpOptions = { 108 | url, 109 | params, 110 | method: type, 111 | timeout: 10000, 112 | }; 113 | 114 | axios(httpOptions) 115 | .then((response) => handleResponse(response, resolve, reject, queryString, url)) 116 | .catch((error) => handleResponse(error, resolve, reject, queryString, url)); 117 | }); 118 | } 119 | 120 | /** 121 | * Makes a request to private (auth) endpoint 122 | * @param {String} path Endpoint 123 | * @param {Object} data Request params 124 | * @param {String} method Request type: get, post, delete 125 | * @returns {*} 126 | */ 127 | function protectedRequest(type, path, data) { 128 | const url = `${WEB_BASE}${path}`; 129 | 130 | data.timestamp = Date.now(); 131 | data.signature = getSignature(config.secret_key, getParamsString(data)); 132 | 133 | const bodyString = getParamsString(data); 134 | 135 | return new Promise((resolve, reject) => { 136 | const httpOptions = { 137 | url, 138 | method: type, 139 | timeout: 10000, 140 | headers: { 141 | 'X-MBX-APIKEY': config.apiKey, 142 | 'Content-Type': 'application/x-www-form-urlencoded', 143 | }, 144 | }; 145 | 146 | if (type === 'post') { 147 | httpOptions.data = bodyString; 148 | } else { 149 | httpOptions.params = data; 150 | } 151 | 152 | axios(httpOptions) 153 | .then((response) => handleResponse(response, resolve, reject, bodyString, url)) 154 | .catch((error) => handleResponse(error, resolve, reject, bodyString, url)); 155 | }); 156 | } 157 | 158 | /** 159 | * Get a signature for a Binance request 160 | * @param {String} secret API secret key 161 | * @param {String} payload Data to sign 162 | * @returns {String} 163 | */ 164 | function getSignature(secret, payload) { 165 | return crypto 166 | .createHmac('sha256', secret) 167 | .update(payload) 168 | .digest('hex'); 169 | } 170 | 171 | const EXCHANGE_API = { 172 | setConfig(apiServer, apiKey, secretKey, tradePwd, logger, publicOnly = false) { 173 | if (apiServer) { 174 | WEB_BASE = apiServer; 175 | } 176 | 177 | if (logger) { 178 | log = logger; 179 | } 180 | 181 | if (!publicOnly) { 182 | config = { 183 | apiKey, 184 | tradePwd, 185 | secret_key: secretKey, 186 | }; 187 | } 188 | }, 189 | 190 | /** 191 | * Get account information 192 | * https://binance-docs.github.io/apidocs/spot/en/#account-information-user_data 193 | * @returns {Object} { balances[], permissions[], accountType, canTrade, canWithdraw, canDeposit, brokered, requireSelfTradePrevention, updateTime, 194 | * makerCommission, takerCommission, buyerCommission, sellerCommission, commissionRates{} } 195 | */ 196 | getBalances() { 197 | return protectedRequest('get', '/api/v3/account', {}); 198 | }, 199 | 200 | /** 201 | * Query account active orders 202 | * https://binance-docs.github.io/apidocs/spot/en/#current-open-orders-user_data 203 | * @param {String} symbol In Binance format as ETHUSDT. Optional. Warn: request weight is 40 when the symbol is omitted. 204 | * @return {Object} 205 | */ 206 | getOrders(symbol) { 207 | return protectedRequest('get', '/api/v3/openOrders', { symbol }); 208 | }, 209 | 210 | /** 211 | * Places an order 212 | * https://binance-docs.github.io/apidocs/spot/en/#new-order-trade 213 | * @param {String} symbol In Binance format as ETHUSDT 214 | * @param {String} amount Base coin amount 215 | * @param {String} quoteAmount Quote coin amount 216 | * @param {String} price Order price 217 | * @param {String} side BUY or SELL 218 | * @param {String} type MARKET or LIMIT (yet more additional types) 219 | * MARKET orders using the quantity field specifies the amount of the base asset the user wants to buy or sell 220 | * at the market price. E.g. MARKET order on BTCUSDT will specify how much BTC the user is buying or selling. 221 | * MARKET orders using quoteOrderQty specifies the amount the user wants to spend (when buying) or receive (when selling) 222 | * the quote asset; the correct quantity will be determined based on the market liquidity and quoteOrderQty. 223 | * E.g. Using the symbol BTCUSDT: 224 | * BUY side, the order will buy as many BTC as quoteOrderQty USDT can. 225 | * SELL side, the order will sell as much BTC needed to receive quoteOrderQty USDT. 226 | * @returns {Object} 227 | */ 228 | addOrder(symbol, amount, quoteAmount, price, side, type) { 229 | const data = { 230 | symbol, 231 | side: side.toUpperCase(), 232 | type: type.toUpperCase(), 233 | }; 234 | 235 | if (type === 'limit') { 236 | data.price = price; 237 | data.quantity = amount; 238 | data.timeInForce = 'GTC'; 239 | } else if (amount) { 240 | data.quantity = amount; 241 | } else { 242 | data.quoteOrderQty = quoteAmount; 243 | } 244 | 245 | return protectedRequest('post', '/api/v3/order', data); 246 | }, 247 | 248 | /** 249 | * Get order status 250 | * https://binance-docs.github.io/apidocs/spot/en/#query-order-user_data 251 | * @param {String} orderId Example: '3065308830' 252 | * @param {String} symbol In Binance format as ETHUSDT 253 | * @returns {Object} 200, { orderId, status, ... } 254 | * Order doesn't exist: 400, { code: -2013, msg: 'Order does not exist.' } 255 | * Wrong orderId (not a Number): 400, { code: -1100, msg: 'Illegal characters found in parameter 'orderId'; legal range is '^[0-9]{1,20}$'.' } 256 | * No deals: { orderId, status: 'NEW', ... } 257 | * Cancelled order: { orderId, status: 'CANCELED', ... } 258 | */ 259 | getOrder(orderId, symbol) { 260 | return protectedRequest('get', '/api/v3/order', { 261 | symbol, 262 | orderId: +orderId, 263 | }); 264 | }, 265 | 266 | /** 267 | * Cancel an order 268 | * https://binance-docs.github.io/apidocs/spot/en/#cancel-order-trade 269 | * @param {String} symbol In Binance format as ETHUSDT 270 | * @param {Number} orderId Example: '3065308830' 271 | * @returns {Object} { "status": "CANCELED", ... } 272 | */ 273 | cancelOrder(orderId, symbol) { 274 | return protectedRequest('delete', '/api/v3/order', { 275 | symbol, 276 | orderId: +orderId, 277 | }); 278 | }, 279 | 280 | /** 281 | * Cancel all orders 282 | * https://binance-docs.github.io/apidocs/spot/en/#cancel-all-open-orders-on-a-symbol-trade 283 | * @param {String} symbol In Binance format as ETHUSDT 284 | * @returns {Object} [{ "status": "CANCELED", ... }] 285 | */ 286 | cancelAllOrders(symbol) { 287 | return protectedRequest('delete', '/api/v3/openOrders', { symbol }); 288 | }, 289 | 290 | /** 291 | * Get trade details for a ticker (market rates) 292 | * https://binance-docs.github.io/apidocs/spot/en/#24hr-ticker-price-change-statistics 293 | * @param {String} symbol In Binance format as ETHUSDT. Optional. Warn: request weight is 40 when the symbol is omitted. 294 | * @returns {Object} 295 | */ 296 | ticker(symbol) { 297 | return publicRequest('get', '/api/v3/ticker/24hr', { symbol }); 298 | }, 299 | 300 | /** 301 | * Get market depth 302 | * https://binance-docs.github.io/apidocs/spot/en/#order-book 303 | * @param {String} symbol In Binance format as ETHUSDT 304 | * @param {Number} limit Default 100; max 5000. If limit > 5000, then the response will truncate to 5000. With limit 1-100, request weight is 1. 305 | * @returns {Object} 306 | */ 307 | orderBook(symbol, limit = 100) { 308 | return publicRequest('get', '/api/v3/depth', { 309 | symbol, 310 | limit, 311 | }); 312 | }, 313 | 314 | /** 315 | * Get trades history 316 | * https://binance-docs.github.io/apidocs/spot/en/#recent-trades-list 317 | * @param {String} symbol In Binance format as ETHUSDT 318 | * @param {Number} limit Default 500, max is 1000 319 | * @returns {Object} Last trades 320 | */ 321 | getTradesHistory(symbol, limit = 500) { 322 | return publicRequest('get', '/api/v3/trades', { 323 | symbol, 324 | limit, 325 | }); 326 | }, 327 | 328 | /** 329 | * Get info on all markets 330 | * Optional params: symbol, symbols, permissions 331 | * https://binance-docs.github.io/apidocs/spot/en/#exchange-information 332 | * @returns {Object} { symbols[], timezone, serverTime, rateLimits[], exchangeFilters[] } 333 | */ 334 | markets() { 335 | return publicRequest('get', '/api/v3/exchangeInfo', {}); 336 | }, 337 | 338 | /** 339 | * Fetch deposit address with network 340 | * https://binance-docs.github.io/apidocs/spot/en/#deposit-address-supporting-network-user_data 341 | * @param {String} coin As BTC 342 | * @param {String | undefined} network If network is not send, return with default network of the coin 343 | * @returns {Object} 344 | */ 345 | getDepositAddress(coin, network) { 346 | return protectedRequest('get', '/sapi/v1/capital/deposit/address', { 347 | coin, 348 | network, 349 | }); 350 | }, 351 | 352 | /** 353 | * Get information of coins (available for deposit and withdraw) for user 354 | * https://binance-docs.github.io/apidocs/spot/en/#all-coins-39-information-user_data 355 | * @returns {Promise} 356 | */ 357 | getCurrencies() { 358 | return protectedRequest('get', '/sapi/v1/capital/config/getall', {}); 359 | }, 360 | 361 | /** 362 | * Get fees for trading pairs 363 | * https://binance-docs.github.io/apidocs/spot/en/#trade-fee-user_data 364 | * @param symbol In Binance format as ETHUSDT. Optional. 365 | * @returns {Promise} 366 | */ 367 | getFees(symbol) { 368 | return protectedRequest('get', '/sapi/v1/asset/tradeFee', { symbol }); 369 | }, 370 | }; 371 | 372 | return EXCHANGE_API; 373 | }; 374 | 375 | module.exports.axios = axios; // for setup axios mock adapter 376 | -------------------------------------------------------------------------------- /trade/api/bittrex_api.js: -------------------------------------------------------------------------------- 1 | const crypto = require('crypto'); 2 | const axios = require('axios'); 3 | 4 | const { 5 | trimAny, 6 | getParamsString, 7 | } = require('../../helpers/utils'); 8 | 9 | /** 10 | * Docs: https://bittrex.github.io/api/v3 11 | */ 12 | 13 | // Error codes: https://bittrex.github.io/api/v3#topic-Error-Codes 14 | const httpErrorCodeDescriptions = { 15 | 400: 'The request was malformed, often due to a missing or invalid parameter', 16 | 401: 'The request failed to authenticate', 17 | 403: 'The provided api key is not authorized to perform the requested operation', 18 | 404: 'The requested resource does not exist', 19 | 409: 'The request parameters were valid but the request failed due to an operational error', 20 | 429: 'Too many requests hit the API too quickly', 21 | 501: 'The service requested has not yet been implemented', 22 | 503: 'The request parameters were valid but the request failed because the resource is temporarily unavailable', 23 | }; 24 | 25 | module.exports = function() { 26 | let WEB_BASE = 'https://api.bittrex.com/v3'; 27 | let config = { 28 | apiKey: '', 29 | secret_key: '', 30 | tradePwd: '', 31 | }; 32 | let log = {}; 33 | 34 | /** 35 | * Handles response from API 36 | * @param {Object} responseOrError 37 | * @param resolve 38 | * @param reject 39 | * @param {String} bodyString 40 | * @param {String} queryString 41 | * @param {String} url 42 | */ 43 | const handleResponse = (responseOrError, resolve, reject, queryString, url) => { 44 | const httpCode = responseOrError?.status ?? responseOrError?.response?.status; 45 | const httpMessage = responseOrError?.statusText ?? responseOrError?.response?.statusText; 46 | 47 | const data = responseOrError?.data ?? responseOrError?.response?.data; 48 | const success = httpCode === 200 || httpCode === 201 && !data?.code; // Bittrex doesn't return any special status code on success 49 | 50 | const error = { 51 | code: data?.code ?? data?.error?.code ?? 'No error code', 52 | detail: data?.error?.detail ?? 'No error detail', 53 | }; 54 | 55 | const reqParameters = queryString || '{ No parameters }'; 56 | 57 | try { 58 | if (success) { 59 | resolve(data); 60 | } else { 61 | const bittrexErrorInfo = `[${error.code}] ${trimAny(error.detail, ' .')}`; 62 | const errorMessage = httpCode ? `${httpCode} ${httpMessage}, ${bittrexErrorInfo}` : String(responseOrError); 63 | 64 | if (typeof data === 'object') { 65 | data.bittrexErrorInfo = bittrexErrorInfo; 66 | } 67 | 68 | if (httpCode === 400 || httpCode === 409 || httpCode === 404) { 69 | log.log(`Bittrex processed a request to ${url} with data ${reqParameters}, but with error: ${errorMessage}. Resolving…`); 70 | 71 | resolve(data); 72 | } else { 73 | const errorDescription = httpErrorCodeDescriptions[httpCode] ?? 'Unknown error'; 74 | 75 | log.warn(`Request to ${url} with data ${reqParameters} failed. ${errorDescription}, details: ${errorMessage}. Rejecting…`); 76 | 77 | reject(errorMessage); 78 | } 79 | } 80 | } catch (error) { 81 | log.warn(`Error while processing response of request to ${url} with data ${reqParameters}: ${error}. Data object I've got: ${JSON.stringify(data)}.`); 82 | reject(`Unable to process data: ${JSON.stringify(data)}. ${error}`); 83 | } 84 | }; 85 | 86 | /** 87 | * Makes a request to private (auth) endpoint 88 | * @param {String} type Request type: get, post, delete 89 | * @param {String} path Endpoint 90 | * @param {Object} data Request params 91 | * @returns {*} 92 | */ 93 | function protectedRequest(type, path, data) { 94 | const url = `${WEB_BASE}${path}`; 95 | 96 | const bodyString = getParamsString(data); 97 | const stringifiedData = JSON.stringify(data); 98 | 99 | const urlWithQuery = bodyString === '' ? url : `${url}?${bodyString}`; 100 | const timestamp = Date.now(); 101 | 102 | const hashPayload = type === 'post' ? stringifiedData : ''; 103 | const hash = getHash(hashPayload); 104 | 105 | const signPayload = type === 'post' ? 106 | [timestamp, url, type.toUpperCase(), hash].join('') : 107 | [timestamp, urlWithQuery, type.toUpperCase(), hash].join(''); 108 | const sign = getSignature(config.secret_key, signPayload); 109 | 110 | return new Promise((resolve, reject) => { 111 | const httpOptions = { 112 | url, 113 | method: type, 114 | timeout: 10000, 115 | headers: { 116 | 'Content-Type': 'application/json', 117 | 'Api-Key': config.apiKey, 118 | 'Api-Timestamp': timestamp, 119 | 'Api-Content-Hash': hash, 120 | 'Api-Signature': sign, 121 | }, 122 | }; 123 | 124 | if (type === 'post') { 125 | httpOptions.data = stringifiedData; 126 | } else { 127 | httpOptions.params = data; 128 | } 129 | 130 | axios(httpOptions) 131 | .then((response) => handleResponse(response, resolve, reject, bodyString, url)) 132 | .catch((error) => handleResponse(error, resolve, reject, bodyString, url)); 133 | }); 134 | } 135 | 136 | /** 137 | * Makes a request to public endpoint 138 | * @param {String} type Request type: get, post, delete 139 | * @param {String} path Endpoint 140 | * @param {Object} data Request params 141 | * @returns {*} 142 | */ 143 | function publicRequest(type, path, params) { 144 | const url = `${WEB_BASE}${path}`; 145 | 146 | const queryString = getParamsString(params); 147 | 148 | return new Promise((resolve, reject) => { 149 | const httpOptions = { 150 | url, 151 | params, 152 | method: type, 153 | timeout: 10000, 154 | }; 155 | 156 | axios(httpOptions) 157 | .then((response) => handleResponse(response, resolve, reject, queryString, url)) 158 | .catch((error) => handleResponse(error, resolve, reject, queryString, url)); 159 | }); 160 | } 161 | 162 | /** 163 | * Get a hash for a Bittrex request 164 | * @param {String} payload Data to hash 165 | * @returns {String} 166 | */ 167 | function getHash(payload) { 168 | return crypto 169 | .createHash('sha512') 170 | .update(payload) 171 | .digest('hex'); 172 | } 173 | 174 | /** 175 | * Get a signature for a Bittrex request 176 | * @param {String} secret API secret key 177 | * @param {String} payload Data to sign 178 | * @returns {String} 179 | */ 180 | function getSignature(secret, payload) { 181 | return crypto 182 | .createHmac('sha512', secret) 183 | .update(payload) 184 | .digest('hex'); 185 | } 186 | 187 | const EXCHANGE_API = { 188 | setConfig(apiServer, apiKey, secretKey, tradePwd, logger, publicOnly = false) { 189 | if (apiServer) { 190 | WEB_BASE = apiServer; 191 | } 192 | 193 | if (logger) { 194 | log = logger; 195 | } 196 | 197 | if (!publicOnly) { 198 | config = { 199 | apiKey, 200 | tradePwd, 201 | secret_key: secretKey, 202 | }; 203 | } 204 | }, 205 | 206 | /** 207 | * List account balances across available currencies. Returns a Balance entry for each currency for which there is either a balance or an address 208 | * https://bittrex.github.io/api/v3#operation--balances-get 209 | * @return {Promise} 210 | */ 211 | getBalances() { 212 | return protectedRequest('get', '/balances', {}); 213 | }, 214 | 215 | /** 216 | * List open orders 217 | * https://bittrex.github.io/api/v3#operation--orders-open-get 218 | * @param {String} symbol In Bittrex format as ETH-USDT 219 | * @return {Promise} 220 | */ 221 | getOrders(symbol) { 222 | const params = { 223 | marketSymbol: symbol, 224 | }; 225 | 226 | return protectedRequest('get', '/orders/open', params); 227 | }, 228 | 229 | /** 230 | * Retrieve information on a specific order 231 | * https://bittrex.github.io/api/v3#operation--orders--orderId--get 232 | * Retrieve executions for a specific order. Results are sorted in inverse order of execution time, and are limited to the first 1000. 233 | * https://bittrex.github.io/api/v3#operation--orders--orderId--executions-get 234 | * @param {String} orderId Example: '7c99eb7f-1bfd-4c2a-a989-cf320e803396' 235 | * @returns {Promise} 236 | */ 237 | async getOrder(orderId) { 238 | const order = await protectedRequest('get', `/orders/${orderId}`, {}); 239 | const executions = await protectedRequest('get', `/orders/${orderId}/executions`, {}); 240 | 241 | return { ...order, executions }; 242 | }, 243 | 244 | /** 245 | * Create a new order 246 | * https://bittrex.github.io/api/v3#operation--orders-post 247 | * @param {String} symbol In Bittrex format as ETH-USDT 248 | * @param {String} amount Base coin amount 249 | * @param {String} quote Quote coin amount 250 | * @param {String} price Order price 251 | * @param {String} side BUY or SELL 252 | * @param {String} type MARKET or LIMIT 253 | * @param {Boolean} useAwards Option to use Bittrex credits for the order 254 | * @return {Promise} 255 | */ 256 | addOrder(symbol, amount, quote, price, side, type, useAwards) { 257 | const data = { 258 | marketSymbol: symbol, 259 | direction: side.toUpperCase(), 260 | timeInForce: type === 'limit' ? 'GOOD_TIL_CANCELLED' : 'IMMEDIATE_OR_CANCEL', 261 | }; 262 | 263 | if (useAwards) data.useAwards = true; 264 | 265 | if (type === 'limit') { 266 | data.limit = price; 267 | data.type = type.toUpperCase(); 268 | data.quantity = amount; 269 | } else { 270 | if (quote) { 271 | data.type = 'CEILING_MARKET'; 272 | data.ceiling = quote; 273 | } else { 274 | data.type = 'MARKET'; 275 | data.quantity = amount; 276 | } 277 | } 278 | 279 | return protectedRequest('post', '/orders', data); 280 | }, 281 | 282 | /** 283 | * Cancel an order 284 | * https://bittrex.github.io/api/v3#operation--orders--orderId--delete 285 | * @param {String} orderId Example: '7c99eb7f-1bfd-4c2a-a989-cf320e803396' 286 | * @return {Promise} 287 | */ 288 | cancelOrder(orderId) { 289 | return protectedRequest('delete', `/orders/${orderId}`, {}); 290 | }, 291 | 292 | /** 293 | * Bulk cancel all open orders limited for a specific market 294 | * https://bittrex.github.io/api/v3#operation--orders-open-delete 295 | * @param {String} symbol 296 | * @return {Promise} 297 | */ 298 | cancelAllOrders(symbol) { 299 | const data = { 300 | marketSymbol: symbol, 301 | }; 302 | 303 | return protectedRequest('delete', '/orders/open', data); 304 | }, 305 | 306 | /** 307 | * Retrieve summary of the last 24 hours of activity and ticker for a specific market 308 | * Combine two requests 309 | * https://bittrex.github.io/api/v3#operation--markets--marketSymbol--ticker-get 310 | * https://bittrex.github.io/api/v3#operation--markets--marketSymbol--ticker-get 311 | * @param {String} symbol In Bittrex format as ETH-USDT 312 | * @return {Promise} 313 | */ 314 | async ticker(symbol) { 315 | const marketTicker = await publicRequest('get', `/markets/${symbol}/ticker`, {}); 316 | const marketSummary = await publicRequest('get', `/markets/${symbol}/summary`, {}); 317 | 318 | const tickerInfo = { ...marketSummary, marketTicker }; 319 | 320 | if (tickerInfo.bittrexErrorInfo) { 321 | throw tickerInfo.bittrexErrorInfo; 322 | } else { 323 | return tickerInfo; 324 | } 325 | }, 326 | 327 | /** 328 | * Retrieve the order book for a specific market 329 | * https://bittrex.github.io/api/v3#operation--markets--marketSymbol--orderbook-get 330 | * @param {String} symbol In Bittrex format as ETH-USDT 331 | * @return {Promise} 332 | */ 333 | orderBook(symbol) { 334 | const params = { 335 | depth: 500, // Maximum depth of order book to return (optional, allowed values are [1, 25, 500], default is 25) 336 | }; 337 | 338 | return publicRequest('get', `/markets/${symbol}/orderbook`, params); 339 | }, 340 | 341 | /** 342 | * Retrieve the recent trades for a specific market. Doesn't have limit parameter (limit is 100). 'limit', 'depth', 'count' doesn't work. 343 | * https://bittrex.github.io/api/v3#operation--markets--marketSymbol--trades-get 344 | * @param {String} symbol In Bittrex format as ETH-USDT 345 | * @return {Promise} 346 | */ 347 | getTradesHistory(symbol) { 348 | return publicRequest('get', `/markets/${symbol}/trades`, {}); 349 | }, 350 | 351 | /** 352 | * List markets 353 | * https://bittrex.github.io/api/v3#tag-Markets 354 | * @return {Promise} 355 | */ 356 | markets() { 357 | return publicRequest('get', '/markets', {}); 358 | }, 359 | 360 | /** 361 | * List currencies 362 | * https://bittrex.github.io/api/v3#tag-Currencies 363 | * @return {Promise} 364 | */ 365 | currencies() { 366 | return publicRequest('get', '/currencies', {}); 367 | }, 368 | 369 | /** 370 | * List deposit addresses that have been requested or provisioned 371 | * https://bittrex.github.io/api/v3#operation--addresses-get 372 | * @param {String} coin As ETH 373 | * @return {Promise} 374 | */ 375 | getDepositAddress(coin) { 376 | return protectedRequest('get', `/addresses/${coin}`, {}); 377 | }, 378 | 379 | /** 380 | * Request provisioning of a deposit address for a currency for which no address has been requested or provisioned 381 | * https://bittrex.github.io/api/v3#operation--addresses-post 382 | * @param {String} coin As ETH 383 | * @return {Promise} 384 | */ 385 | createDepositAddress(coin) { 386 | const data = { 387 | currencySymbol: coin, 388 | }; 389 | 390 | return protectedRequest('post', '/addresses', data); 391 | }, 392 | 393 | /** 394 | * Get trading fees for account 395 | * @param {String} marketSymbol if not set, get info for all trade pairs 396 | * @return {Promise} 397 | */ 398 | getFees(marketSymbol) { 399 | return marketSymbol ? 400 | protectedRequest('get', `/account/fees/trading/${marketSymbol}`, {}) : 401 | protectedRequest('get', '/account/fees/trading', {}); 402 | }, 403 | 404 | /** 405 | * Get 30-days trading volume for account 406 | * https://bittrex.github.io/api/v3#operation--account-volume-get 407 | * @return {Promise} 408 | */ 409 | getVolume() { 410 | return protectedRequest('get', '/account/volume', {}); 411 | }, 412 | }; 413 | 414 | return EXCHANGE_API; 415 | }; 416 | 417 | module.exports.axios = axios; // for setup axios mock adapter 418 | --------------------------------------------------------------------------------