├── .dockerignore ├── .gitignore ├── app ├── .jshintrc ├── package.json ├── modules │ ├── gasprice.mjs │ ├── require-envs.mjs │ ├── logger.mjs │ ├── telegram.mjs │ ├── rules-parser.mjs │ ├── 1inch.mjs │ ├── api.mjs │ └── app.mjs └── index.js ├── Dockerfile ├── docker-compose.yml └── README.md /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | package-lock.json -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | node_modules 3 | package-lock.json -------------------------------------------------------------------------------- /app/.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "strict": true, 3 | "esversion": 8, 4 | "node": true 5 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:alpine 2 | 3 | ENV NODE_OPTIONS=--no-warnings 4 | 5 | WORKDIR /usr/src/app 6 | ADD ./app/ ./ 7 | RUN npm install --loglevel=error 8 | 9 | CMD ["node", "-r", "esm", "index.js"] -------------------------------------------------------------------------------- /app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "1inch-monitor", 3 | "version": "1.0.0", 4 | "private": true, 5 | "dependencies": { 6 | "esm": "^3.2.25", 7 | "winston": "^3.2.1" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | 1inch-monitor: 5 | build: . 6 | environment: 7 | TELEGRAM_BOT_TOKEN: CHANGEME 8 | TELEGRAM_CHAT_ID: CHANGEME 9 | RULES: |- 10 | 1 USDC-ETH-USDC >= 1 !0X Relays 11 | 1 DAI-ETH-DAI >= 1 -------------------------------------------------------------------------------- /app/modules/gasprice.mjs: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import API from './api'; 4 | 5 | class GasPrice { 6 | constructor(maxInFlight) { 7 | this.api = new API(`https://gasprice.poa.network`, maxInFlight); 8 | } 9 | 10 | get(cb) { 11 | this.api.get('', {}, (body, error) => { 12 | cb.call(this.api, body, error); 13 | }); 14 | } 15 | } 16 | 17 | export default GasPrice; -------------------------------------------------------------------------------- /app/modules/require-envs.mjs: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import log from './logger'; 4 | 5 | const requireEnvs = (...envs) => { 6 | const missingEnv = envs.some((env) => { 7 | if (process.env[env] === undefined) { 8 | log.error('Missing required env variable: %s', env); 9 | return true; 10 | } 11 | }); 12 | 13 | if (missingEnv) { 14 | process.exit(); 15 | } 16 | }; 17 | 18 | export default requireEnvs; -------------------------------------------------------------------------------- /app/modules/logger.mjs: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import winston from 'winston'; 4 | 5 | const { createLogger, format, transports } = winston; 6 | const { combine, timestamp, colorize, printf, splat } = format; 7 | const log_level = process.env.LOG_LEVEL || 'info'; 8 | 9 | const logger = createLogger({ 10 | level: log_level.toLowerCase(), 11 | format: combine( 12 | colorize(), 13 | timestamp(), 14 | splat(), 15 | printf(l => { 16 | return `${l.timestamp} ${l.level}: ${l.message}`; 17 | }) 18 | ), 19 | transports: [new transports.Console()] 20 | }); 21 | 22 | export default logger; -------------------------------------------------------------------------------- /app/modules/telegram.mjs: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import API from './api'; 4 | import log from './logger'; 5 | 6 | class Telegram { 7 | constructor(botToken, defaultChatId) { 8 | this.api = new API(`https://api.telegram.org/bot${botToken}`) 9 | this.defaultChatId = defaultChatId; 10 | } 11 | 12 | sendNotification(params, cb) { 13 | if (!params.chat_id && !this.defaultChatId) { 14 | const error = 'No default chat id provided.'; 15 | 16 | log.error(error); 17 | 18 | cb(null, error); 19 | 20 | return; 21 | } 22 | 23 | params.chat_id = params.chat_id ? params.chat_id : this.defaultChatId; 24 | 25 | this.api.post('sendMessage', params, cb); 26 | } 27 | } 28 | 29 | export default Telegram; -------------------------------------------------------------------------------- /app/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import log from './modules/logger'; 4 | import requireEnvs from './modules/require-envs'; 5 | import Telegram from './modules/telegram'; 6 | import OneInch from './modules/1inch'; 7 | import RulesParser from './modules/rules-parser'; 8 | import App from './modules/app'; 9 | import GasPrice from './modules/gasprice.mjs'; 10 | 11 | requireEnvs('TELEGRAM_BOT_TOKEN', 'TELEGRAM_CHAT_ID', 'RULES'); 12 | 13 | const interval = (parseInt(process.env.INTERVAL_SECONDS) || 10) * 1000, 14 | maxInFlight = parseInt(process.env.MAX_INFLIGHT) || 3, 15 | oneInch = new OneInch(process.env.API_VERSION || 'v1.1', maxInFlight, true), 16 | gasPrice = new GasPrice(maxInFlight), 17 | telegram = new Telegram(process.env.TELEGRAM_BOT_TOKEN, process.env.TELEGRAM_CHAT_ID), 18 | rules = new RulesParser().parse(process.env.RULES), 19 | app = new App(oneInch, gasPrice, telegram); 20 | 21 | app.monitor(rules, interval); -------------------------------------------------------------------------------- /app/modules/rules-parser.mjs: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import log from './logger'; 4 | 5 | class RulesParser { 6 | constructor(rules) { 7 | this.regex = /^([0-9]*\.?[0-9]+|SLOW|STANDARD|FAST|INSTANT)\s([a-zA-Z\-]+)\s(>? { 14 | const m = this.regex.exec(rule); 15 | 16 | if (m.length < 5 || m.length > 6) { 17 | log.error(`Rule not recognized: ${rule}: If you have recently upgraded, note that rules formatting has changed. Please see README for new format.`); 18 | return null; 19 | } 20 | 21 | return { 22 | rule: rule, 23 | alerted: false, 24 | fromTokenAmount: m[1], 25 | tokenPath: m[2].split('-'), 26 | comparitor: m[3], 27 | toTokenAmount: m[4], 28 | disabledExchangeList: m[5] 29 | }; 30 | }).filter(r => !!r); 31 | } 32 | } 33 | 34 | export default RulesParser; -------------------------------------------------------------------------------- /app/modules/1inch.mjs: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import API from './api'; 4 | import log from './logger'; 5 | 6 | class OneInch { 7 | constructor(apiVersion, maxInFlight, convertTokenAmounts) { 8 | this.tokens = null; 9 | this.convertTokenAmounts = convertTokenAmounts == null ? false : convertTokenAmounts; 10 | this.api = new API(`https://api.1inch.exchange/${apiVersion}`, maxInFlight); 11 | } 12 | 13 | getTokens(cb) { 14 | const _self = this; 15 | 16 | this.api.get('tokens', null, (body, error) => { 17 | if (body && body.message) { 18 | cb.call(this.api, body, `${body.message}`); 19 | return; 20 | } 21 | 22 | _self.tokens = body; 23 | cb.call(this.api, body, error); 24 | }); 25 | } 26 | 27 | getQuote(params, cb) { 28 | this.get('quote', params, cb); 29 | } 30 | 31 | getSwap(params, cb) { 32 | this.get('swap', params, cb); 33 | } 34 | 35 | getSwapQuote(params, cb) { 36 | this.get('swapQuote', params, cb); 37 | } 38 | 39 | get(type, params, cb) { 40 | const _self = this; 41 | 42 | if (this.tokens == null) { 43 | this.getTokens((tokens, error) => { 44 | if (error) { 45 | return; 46 | } 47 | 48 | _self.get(type, params, cb); 49 | }); 50 | 51 | return; 52 | } 53 | 54 | [params.fromTokenSymbol, params.toTokenSymbol].forEach(symbol => { 55 | if (!_self.tokens.hasOwnProperty(symbol)) { 56 | log.error(`Unknown token: ${symbol}`); 57 | return; 58 | } 59 | }); 60 | 61 | if (this.convertTokenAmounts && params.amount) { 62 | params.amount = (params.amount * parseFloat(`${10}e${this.tokens[params.fromTokenSymbol].decimals}`)).toLocaleString('fullwide', {useGrouping: false}); 63 | } 64 | 65 | this.api.get(type, params, (body, error) => { 66 | if (body && body.message) { 67 | cb.call(this.api, body, `${body.message}`); 68 | return; 69 | } 70 | 71 | if (error) { 72 | cb.call(this.api, body, error); 73 | return; 74 | } 75 | 76 | if (_self.convertTokenAmounts && body && body.toTokenAmount && body.toToken.decimals) { 77 | body.toTokenAmount = body.toTokenAmount / parseFloat(`${10}e${body.toToken.decimals}`); 78 | } 79 | 80 | cb.call(this.api, body, error); 81 | }); 82 | } 83 | } 84 | 85 | export default OneInch; -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 1inch Monitor 2 | Monitors [1inch.exchange](https://1inch.exchange) price pairs via the [API](https://1inch.exchange/#/api) and sends notifications via Telegram if price pair targets are met. 3 | 4 | ## Configuration 5 | 6 | ### Environment Variabls 7 | - **LOG_LEVEL** (default: `info`) Sets the log level. Available log levels are `silly|debug|verbose|http|info|warn|error`. 8 | - **INTERVAL_SECONDS** (default: `10`) Sets the number of seconds to wait between checks for all rules. All rules will are checked in parallel and the next check occurs after the interval. 9 | - **API_VERSION** (default: `v1.1`) Sets the API version to use. The latest version should be documented at [API](https://1inch.exchange/#/api). 10 | - **MAX_INFLIGHT** (default: `3`) Maximum in-flight 1inch API requests. 11 | - **TELEGRAM_BOT_TOKEN** (required) Sets the [Telegram Bot](https://core.telegram.org/bots#3-how-do-i-create-a-bot) token to use for sending notifications. 12 | - **TELEGRAM_CHAT_ID** (required) Sets the [Telegram chat id](https://stackoverflow.com/a/32572159/882223) to use for sending notifications. 13 | - **RULES** (required) Defines rules to be used for alerting. More information below. New line separated. 14 | 15 | ### Rules 16 | *Note: Rule parsing has changed. Please use the new format below.* 17 | Rules are defined as follows. 18 | 19 | ``` 20 | -[-] [!] 21 | ``` 22 | 23 | Symbols are chained together via a hyphen. 24 | 25 | Gas alerting is also supported by using the following rule recipe where `speed` can be one of `SLOW`, `STANDARD`, `FAST`, or `INSTANT`. 26 | 27 | ``` 28 | GAS 29 | ``` 30 | 31 | ## Running 32 | 33 | ### Docker 34 | ```shell 35 | docker run \ 36 | --name 1inch-monitor \ 37 | -e TELEGRAM_BOT_TOKEN=CHANGEME \ 38 | -e TELEGRAM_CHAT_ID=CHANGEME \ 39 | -e RULES='1 USDC-DAI >= 1.01 !0X Relays \ 40 | 1 DAI-USDC >= 1.01 !OX Relays,Uniswap,Kyber \ 41 | 1 ETH-USDC >= 250 !AirSwap,Kyber,Uniswap \ 42 | 250 DAI-ETH <= 1 \ 43 | STANDARD GAS <= 10' \ 44 | divthis/1inch-monitor 45 | ``` 46 | 47 | ### Docker-compose 48 | 49 | Use the following for your `docker-compose.yml`. 50 | 51 | ```yaml 52 | version: '3' 53 | 54 | services: 55 | 1inch-monitor: 56 | image: divthis/1inch-monitor 57 | environment: 58 | TELEGRAM_BOT_TOKEN: CHANGEME 59 | TELEGRAM_CHAT_ID: CHANGEME 60 | RULES: |- 61 | 1 USDC-DAI >= 1.01 !0X Relays 62 | 1 DAI-USDC >= 1.01 !OX Relays,Uniswap,Kyber 63 | 1 ETH-USDC >= 250 !AirSwap,Kyber,Uniswap 64 | 250 DAI-ETH <= 1 65 | STANDARD GAS <= 10 66 | ``` 67 | 68 | Run the following from the same directory as `docker-compose.yml`. 69 | 70 | ```shell 71 | docker-compose up 72 | ``` 73 | -------------------------------------------------------------------------------- /app/modules/api.mjs: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import { request } from 'https'; 4 | import { stringify } from 'querystring'; 5 | import log from './logger'; 6 | 7 | class APIRequest { 8 | constructor(baseUrl, maxInFlight, ttlCache) { 9 | this.counter = 0; 10 | this.baseUrl = baseUrl; 11 | this.maxInFlight = maxInFlight || 100; 12 | this.ttlCache = ttlCache || 100; 13 | this.queue = []; 14 | this.inFlight = []; 15 | this.cache = []; 16 | } 17 | 18 | buildUrl(path, params) { 19 | const query = stringify(params); 20 | return `${this.baseUrl}/${path}${query == '' ? '' : '?' + query}`; 21 | } 22 | 23 | _inFlight(url, options) { 24 | this.inFlight.push({ 25 | url: url, 26 | options: options 27 | }); 28 | } 29 | 30 | _getInFlight(url, options) { 31 | return this.inFlight.find((i) => { 32 | return i.url === url && JSON.stringify(i.options) === JSON.stringify(options); 33 | }); 34 | } 35 | 36 | _removeInFlight(url, options) { 37 | this.inFlight = this.inFlight.filter((i) => { 38 | return !(i.url === url && JSON.stringify(i.options) === JSON.stringify(options)); 39 | }); 40 | } 41 | 42 | _cache(url, options, response) { 43 | this.cache.push({ 44 | url: url, 45 | options: options, 46 | response: response, 47 | timestamp: Date.now() 48 | }); 49 | } 50 | 51 | _getCached(url, options) { 52 | // Remove old cached items 53 | this.cache = this.cache.filter((i) => Date.now() - i.timestamp < this.ttlCache); 54 | 55 | return this.cache.find((i) => { 56 | return i.url === url && JSON.stringify(i.options) === JSON.stringify(options); 57 | }); 58 | } 59 | 60 | _next() { 61 | if (this.queue.length == 0) { 62 | return; 63 | } 64 | 65 | const _self = this; 66 | const req = this.queue.shift(); 67 | const url = this.buildUrl(req.path, req.method === 'GET' ? req.params : null); 68 | const options = { 69 | method: req.method 70 | }; 71 | 72 | const query = stringify(req.params); 73 | 74 | if (req.method === 'POST') { 75 | options.headers = { 76 | 'Content-Type': 'application/x-www-form-urlencoded', 77 | 'Content-Length': Buffer.byteLength(query) 78 | }; 79 | } 80 | 81 | const cache = this._getCached(url, options); 82 | 83 | if (cache && cache.response) { 84 | log.debug('Returning from cache'); 85 | 86 | if (req.cb) { 87 | req.cb(JSON.parse(cache.response), null); 88 | } 89 | 90 | _self._next(); 91 | return; 92 | } 93 | 94 | if (this.inFlight.length >= this.maxInFlight) { 95 | return; 96 | } 97 | 98 | if (this._getInFlight(url, options)) { 99 | this.queue.unshift(req); 100 | return; 101 | } 102 | 103 | this._inFlight(url, options); 104 | 105 | log.http(`${options.method} ${url}`); 106 | log.debug(`Requests: count[${req.count}] inFlight[${this.inFlight.length}] queue[${this.queue.length}]`); 107 | 108 | const inFlight = request(url, options, (res) => { 109 | const chunks = []; 110 | 111 | res.on("data", (chunk) => { 112 | chunks.push(chunk); 113 | }); 114 | 115 | res.on("end", () => { 116 | const body = Buffer.concat(chunks); 117 | 118 | let obj, 119 | error; 120 | 121 | if (res.statusCode >= 400) { 122 | error = res.statusMessage; 123 | } 124 | 125 | if (!error) { 126 | try { 127 | obj = JSON.parse(body); 128 | } catch (e) { 129 | error = e; 130 | } 131 | } 132 | 133 | if (!error && body) { 134 | _self._cache(url, options, body); 135 | } 136 | 137 | _self._removeInFlight(url, options); 138 | _self._next(); 139 | 140 | if (req.cb) { 141 | req.cb(obj, error); 142 | } else if (error) { 143 | log.error(error); 144 | } 145 | }); 146 | }).on('error', (e) => { 147 | _self._removeInFlight(url, options); 148 | _self._next(); 149 | 150 | if (req.cb) { 151 | req.cb(null, e); 152 | } else if (e) { 153 | log.error(e); 154 | } 155 | }); 156 | 157 | if (req.method === 'POST') { 158 | inFlight.write(query); 159 | } 160 | 161 | inFlight.end(); 162 | } 163 | 164 | get(path, params, cb) { 165 | this.queue.push({ 166 | method: 'GET', 167 | path: path, 168 | params: params, 169 | cb: cb, 170 | count: this.counter 171 | }); 172 | 173 | this.counter++; 174 | this._next(); 175 | } 176 | 177 | post(path, params, cb) { 178 | this.queue.push({ 179 | method: 'POST', 180 | path: path, 181 | params: params, 182 | cb: cb, 183 | count: this.counter 184 | }); 185 | 186 | this.counter++; 187 | this._next(); 188 | } 189 | } 190 | 191 | export default APIRequest; -------------------------------------------------------------------------------- /app/modules/app.mjs: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import log from './logger'; 4 | 5 | class App { 6 | constructor(oneInch, gasPrice, telegram) { 7 | this.oneInch = oneInch; 8 | this.gasPrice = gasPrice; 9 | this.telegram = telegram; 10 | this.timeout = null; 11 | 12 | process.stdin.resume(); 13 | 14 | process.on('exit', this.close.bind(this, {cleanup: true})); 15 | process.on('SIGINT', this.close.bind(this, {exit: true})); 16 | process.on('SIGUSR1', this.close.bind(this, {exit: true})); 17 | process.on('SIGUSR2', this.close.bind(this, {exit: true})); 18 | process.on('uncaughtException', this.close.bind(this, {exit: true})); 19 | } 20 | 21 | close(options, exitCode) { 22 | if (options.cleanup) clearTimeout(this.timeout); 23 | if (exitCode || exitCode === 0) log.info(`Shutting down: ${exitCode}`); 24 | if (options.exit) process.exit(); 25 | } 26 | 27 | getQuote(params) { 28 | const _self = this; 29 | 30 | return new Promise((resolve, reject) => { 31 | _self.oneInch.getQuote(params, (body, error) => { 32 | if (error) { 33 | reject(error); 34 | return; 35 | } 36 | 37 | resolve(body.toTokenAmount); 38 | }); 39 | }); 40 | } 41 | 42 | getGasPrice(rule) { 43 | const _self = this; 44 | const speed = rule.fromTokenAmount.toLowerCase(); 45 | 46 | return new Promise(async (resolve, reject) => { 47 | log.debug(`Checking if ${rule.rule}`); 48 | 49 | await _self.gasPrice.get((body, error) => { 50 | if (error) { 51 | reject(error); 52 | return; 53 | } 54 | 55 | const amount = body[speed]; 56 | 57 | if (!amount) { 58 | reject(`Unable to retrieve ${speed} speed.`); 59 | return; 60 | } 61 | 62 | resolve(amount); 63 | }); 64 | }); 65 | } 66 | 67 | getMultiPathQuote(rule) { 68 | const _self = this; 69 | 70 | return new Promise(async (resolve, reject) => { 71 | log.debug(`Checking if ${rule.rule}`); 72 | 73 | const tokens = rule.tokenPath; 74 | let amount = rule.fromTokenAmount; 75 | let quoteError; 76 | 77 | if (rule.fromTokenAmount.match(/SLOW|STANDARD|FAST|INSTANT/i) && rule.tokenPath.includes('GAS')) { 78 | amount = await _self.getGasPrice(rule).catch((error) => { 79 | quoteError = `Error getting GAS quote: ${error}`; 80 | }); 81 | } else { 82 | for (let i = 0; i < tokens.length - 1; i++) { 83 | const fromAmount = amount; 84 | const fromTokenSymbol = tokens[i]; 85 | const toTokenSymbol = tokens[i+1]; 86 | 87 | amount = await _self.getQuote({ 88 | fromTokenSymbol: fromTokenSymbol, 89 | toTokenSymbol: toTokenSymbol, 90 | amount: fromAmount, 91 | disabledExchangeList: rule.disabledExchangeList 92 | }).catch((error) => { 93 | quoteError = `Error getting ${fromTokenSymbol}-${toTokenSymbol} quote: ${error}`; 94 | }); 95 | 96 | if (quoteError) { 97 | break; 98 | } 99 | 100 | log.debug(`${fromAmount} ${fromTokenSymbol} = ${amount} ${toTokenSymbol}`); 101 | } 102 | } 103 | 104 | if (quoteError) { 105 | reject(quoteError); 106 | return; 107 | } 108 | 109 | const rate = !isNaN(rule.fromTokenAmount) ? ` (${amount / rule.fromTokenAmount})` : ''; 110 | const message = `${rule.fromTokenAmount} ${tokens.join('-')} = ${amount}${rate}`; 111 | 112 | log.info(message); 113 | 114 | if (eval(`${amount} ${rule.comparitor} ${rule.toTokenAmount}`)) { 115 | if (!rule.alerted) { 116 | _self.telegram.sendNotification({ 117 | text: message 118 | }, (body, error) => { 119 | if (error != null) { 120 | reject(`Error sending notification: ${error}`); 121 | return; 122 | } 123 | 124 | rule.alerted = true; 125 | resolve(); 126 | }); 127 | } else { 128 | resolve(); 129 | } 130 | } else { 131 | rule.alerted = false; 132 | resolve(); 133 | } 134 | }); 135 | } 136 | 137 | monitor(rules, interval) { 138 | const _self = this; 139 | 140 | const checkAll = async (body, error) => { 141 | if (error) { 142 | log.error(`Error getting tokens: ${error}`); 143 | _self.monitor(rules, interval); 144 | return; 145 | } 146 | 147 | log.info('Checking rules'); 148 | 149 | const quotes = rules.map(async (rule) => { 150 | return _self.getMultiPathQuote(rule).catch((error) => { 151 | log.error(error); 152 | }); 153 | }); 154 | 155 | Promise.all(quotes).catch((error) => { 156 | log.error(error); 157 | }).finally(() => { 158 | clearTimeout(_self.timeout); 159 | 160 | log.info(`Waiting ${interval / 1000} seconds until next check.`); 161 | 162 | _self.timeout = setTimeout(checkAll, interval); 163 | }); 164 | }; 165 | 166 | log.info('Getting tokens'); 167 | this.oneInch.getTokens(checkAll); 168 | } 169 | } 170 | 171 | export default App; --------------------------------------------------------------------------------