├── .env.example ├── .eslintignore ├── .eslintrc ├── .gitignore ├── .prettierrc.json ├── README.md ├── config.yaml ├── docker-compose.yml ├── docker └── Dockerfile ├── package.json ├── src ├── app │ └── index.ts ├── broker │ └── index.ts ├── config │ └── index.ts ├── constants │ └── index.ts ├── logger │ └── index.ts ├── provider │ └── index.ts ├── services │ ├── health │ │ └── index.ts │ ├── liquidation │ │ └── index.ts │ ├── notification │ │ └── index.ts │ ├── statemanager │ │ └── index.ts │ └── synchronization │ │ └── index.ts ├── types │ ├── BasicEvent.d.ts │ ├── Buyout.d.ts │ ├── JoinExit.d.ts │ ├── LiquidationTrigger.d.ts │ ├── Position.d.ts │ ├── Transfer.d.ts │ └── TxConfig.d.ts └── utils │ ├── index.ts │ └── oracle.ts ├── tsconfig.json └── yarn.lock /.env.example: -------------------------------------------------------------------------------- 1 | # for local development only, for prod is redefined in docker-compose 2 | CHAIN_NAME=mainnet 3 | # for local development use true 4 | IS_DEV=false 5 | 6 | # for all networks from config.yaml, prefix in uppercase 7 | MAINNET_WEBSOCKET_URL=ws://0.0.0.0:8546 8 | BSC_OLD_WEBSOCKET_URL=ws://0.0.0.0:8546 9 | BSC_WEBSOCKET_URL=ws://0.0.0.0:8546 10 | FANTOM_WEBSOCKET_URL=ws://0.0.0.0:8546 11 | GNOSIS_WEBSOCKET_URL=ws://0.0.0.0:8546 12 | AVALANCHE_WEBSOCKET_URL=ws://0.0.0.0:8546 13 | ARBITRUM_WEBSOCKET_URL=ws://0.0.0.0:8546 14 | OPTIMISM_WEBSOCKET_URL=ws://0.0.0.0:8546 15 | 16 | ETHEREUM_ADDRESS=0xcCCd68B91dc4E0dB6348bf30bB098059712E420b 17 | ETHEREUM_PRIVATE_KEY=0000000000000000000000000000000000000000000000000000000000000000 18 | 19 | TELEGRAM_BOT_TOKEN=1111111111:pxxRRZa2Tzv4w2Ua8t_U7tyuF45SPb66VHN 20 | # join/exit events 21 | TELEGRAM_CHAT_ID=-1001400632946 22 | # liquidation events 23 | LIQUIDATION_TELEGRAM_CHAT_ID=-1001350080552 24 | # not used, saved for history for some time 25 | LOGS_TELEGRAM_CHAT_ID=-1001260395518 26 | # critical logs 27 | SENTRY_TELEGRAM_CHAT_ID=-1001389771499 28 | 29 | # assetAddr:ownerAddr,assetAddr:ownerAddr 30 | IGNORE_POSITIONS= -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | src/registerServiceWorker.js 2 | react-app-env.d.ts 3 | node_modules/ 4 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "parserOptions": { 4 | "ecmaVersion": 2020, 5 | "jsx": true, 6 | "sourceType": "module", 7 | "useJSXTextNode": true 8 | }, 9 | "extends": [ 10 | "eslint-config-airbnb-base", 11 | "eslint-config-airbnb-base/rules/strict", 12 | "eslint-config-airbnb/rules/react", 13 | "plugin:@typescript-eslint/recommended", 14 | "prettier/@typescript-eslint", 15 | "plugin:prettier/recommended" 16 | ], 17 | "env": { 18 | "browser": true, 19 | "es6": true, 20 | "jest/globals": true 21 | }, 22 | "plugins": ["jest", "@typescript-eslint", "class-property", "simple-import-sort"], 23 | "rules": { 24 | "react/jsx-filename-extension": "off", 25 | "max-len": "off", 26 | "semi": ["error", "never"], 27 | "comma-dangle": 0, 28 | "import/prefer-default-export": "off", 29 | "no-console": "off", 30 | "react/jsx-wrap-multilines": "off", 31 | "react/prefer-stateless-function": "off", 32 | "react/no-array-index-key": "off", 33 | "camelcase": "off", 34 | "dot-notation": "off", 35 | "no-use-before-define": "off", 36 | "arrow-parens": "off", 37 | "import/no-extraneous-dependencies": "off", 38 | "react/jsx-no-bind": "off", 39 | "consistent-return": "off", 40 | "react/sort-comp": "off", 41 | "jsx-a11y/accessible-emoji": "off", 42 | "no-plusplus": "off", 43 | "no-case-declarations": "off", 44 | "no-continue": "off", 45 | "new-cap": "off", 46 | "import/no-named-as-default": "off", 47 | "react/require-default-props": "off", 48 | "no-new": "off", 49 | "indnet": "off", 50 | "no-nested-ternary": "off", 51 | "simple-import-sort/sort": "warn", 52 | "no-underscore-dangle": "off", 53 | "no-template-curly-in-string": "off", 54 | "prefer-destructuring": "off", 55 | "react/destructuring-assignment": "off", 56 | "react/prop-types": "off", 57 | "react/jsx-one-expression-per-line": "off", 58 | "@typescript-eslint/no-explicit-any": "off", 59 | "@typescript-eslint/member-delimiter-style": "off", 60 | "@typescript-eslint/explicit-function-return-type": "off", 61 | "@typescript-eslint/camelcase": "off", 62 | "@typescript-eslint/explicit-member-accessibility": "off", 63 | "@typescript-eslint/no-var-requires": "off", 64 | "import/no-dynamic-require": "off", 65 | "no-useless-constructor": "off", 66 | "@typescript-eslint/no-use-before-define": "off", 67 | "no-param-reassign": "off", 68 | "no-await-in-loop": "off", 69 | "import/extensions": [ 70 | "error", 71 | "ignorePackages", 72 | { 73 | "js": "never", 74 | "jsx": "never", 75 | "ts": "never", 76 | "tsx": "never" 77 | } 78 | ] 79 | }, 80 | "overrides": [ 81 | { 82 | "files": ["**/*.test.ts"], 83 | "env": { 84 | "jest": true 85 | } 86 | } 87 | ], 88 | "settings": { 89 | "import/resolver": { 90 | "node": { 91 | "paths": ["src/**"], 92 | "extensions": [".js", ".jsx", ".ts", ".tsx"] 93 | } 94 | } 95 | }, 96 | "globals":{ 97 | "BigInt":true 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env 17 | .env.local 18 | .env.development.local 19 | .env.test.local 20 | .env.production.local 21 | 22 | 23 | # Webstorm config files 24 | .idea 25 | 26 | # VS code config files 27 | .vscode/ 28 | 29 | npm-debug.log* 30 | yarn-debug.log* 31 | yarn-error.log* 32 | 33 | plugin.sketchplugin 34 | .storybook-out 35 | 36 | test.ts 37 | *.js 38 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "semi": false, 4 | "singleQuote": true, 5 | "trailingComma": "all", 6 | "bracketSpacing": true, 7 | "object-curly-spacing": "always" 8 | } 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Unit Protocol Liquidator 2 | 3 | Monitors Unit Protocol for liquidation opportunities and triggers liquidations 4 | 5 | This code is responsible for [Unit Protocol Monitoring Telegram Bot](https://t.me/unit_protocol_pulse) as well 6 | 7 | ### Starting your own liquidator 8 | 9 | #### Requirements 10 | 1. [Git](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) 11 | 1. [Node v18](https://nodejs.org/en/download/) 12 | 1. [Yarn](https://www.npmjs.com/package/yarn) 13 | 1. [Typescript](https://www.npmjs.com/package/typescript) 14 | 1. [pm2](https://www.npmjs.com/package/pm2) 15 | 1. [Telegram bot token](https://t.me/botfather) 16 | 1. [Ethereum websocket provider](https://infura.io/) 17 | 1. [Ethereum private key with ETH](https://ethereum.org/en/get-eth/) 18 | 19 | 20 | #### Developing mode 21 | 22 | 1. ```git clone https://github.com/unitprotocol/liquidator``` 23 | 2. ```cd liquidator && cp .env.example .env``` 24 | 3. Change variable values to your own in .env 25 | 4. ```yarn install --frozen-lockfile``` 26 | 5. ```yarn start``` 27 | 28 | #### Production mode 29 | 1. ```git clone https://github.com/unitprotocol/liquidator``` 30 | 2. ```cd liquidator && cp .env.example .env``` 31 | 3. Change variable values to your own in .env 32 | 4. ```docker-compose up --build``` 33 | -------------------------------------------------------------------------------- /config.yaml: -------------------------------------------------------------------------------- 1 | mainnet: 2 | explorer_url: 'https://etherscan.io' 3 | liquidation_url: 'https://unit.xyz/eth/liquidations' 4 | chain_id: 1 5 | hash_tag_prefix: '' 6 | main_symbol: 'ETH' 7 | usdp_symbol: 'USDP' 8 | oracle_registry: '0x75fBFe26B21fd3EA008af0C764949f8214150C8f' 9 | cdp_registry: '0x1a5Ff58BC3246Eb233fEA20D32b79B5F01eC650c' 10 | vault: '0xb1cFF81b9305166ff1EFc49A129ad2AfCd7BCf19' 11 | 12 | uniswap_factory: '0x5C69bEe701ef814a2B6a3EDD4B1652CB9cc5aA6f' 13 | 14 | sushiswap_factory: '0xC0AEe478e3658e2610c5F7A4A2E1777cE9e4f2Ac' 15 | sushiswap_init_code_hash: '0xe18a34eb0e04b04f7a0ac29a6e80748dca96319b42c54d679cb821dca90c6303' 16 | 17 | shibaswap_factory: '0x115934131916c8b277dd010ee02de363c09d037c' 18 | shibaswap_init_code_hash: '0x65d1a3b1e46c6e4f1be1ad5f99ef14dc488ae0549dc97db9b30afe2241ce1c7a' 19 | 20 | weth: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2' 21 | vault_managers: 22 | - '0x3b088b680ff7253e662bc29e5a7b696ba0100869' 23 | - '0x6a99d3840998a6a4612ff4e3735cc061bea75e1f' 24 | auctions: 25 | - '0x9cCbb2F03184720Eef5f8fA768425AF06604Daf4' 26 | fallback_liquidation_trigger: '0x6a99d3840998a6a4612ff4e3735cc061bea75e1f' 27 | main_liquidation_trigger: '0x3b088b680ff7253e662bc29e5a7b696ba0100869' 28 | blocks_check_delay: 10 29 | liquidation_confirmations_threshold: 3 30 | liquidation_debt_threshold: 100 31 | liquidation_debt_threshold_keydonix: 200 32 | min_balance: 1 33 | bsc_old: 34 | explorer_url: 'https://bscscan.com' 35 | liquidation_url: 'https://bsc-v1.unit.xyz/liquidations' 36 | chain_id: 56 37 | hash_tag_prefix: 'bsc_old_' 38 | main_symbol: 'BSC_OLD' 39 | usdp_symbol: 'USDP' 40 | oracle_registry: '0xbea721ACe12e881cb44Dbe9361ffEd9141CE547F' 41 | cdp_registry: '0xE8372dcef80189c0F88631507f6466b3f60E24A4' 42 | vault: '0xdacfeed000e12c356fb72ab5089e7dd80ff4dd93' 43 | 44 | uniswap_factory: '' 45 | sushiswap_factory: '' 46 | sushiswap_init_code_hash: '' 47 | shibaswap_factory: '' 48 | shibaswap_init_code_hash: '' 49 | 50 | weth: '0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c' 51 | vault_managers: 52 | - '0x1337dac01fc21fa21d17914f96725f7a7b73868f' 53 | auctions: 54 | - '0x852de08f3cD5b92dD8b3B92b321363D04EeEc39E' 55 | fallback_liquidation_trigger: '' 56 | main_liquidation_trigger: '0x1337dac01fc21fa21d17914f96725f7a7b73868f' 57 | blocks_check_delay: 50 58 | liquidation_confirmations_threshold: 3 59 | liquidation_debt_threshold: 2 60 | liquidation_debt_threshold_keydonix: 2 61 | min_balance: 0.3 62 | bsc: 63 | explorer_url: 'https://bscscan.com' 64 | liquidation_url: 'https://unit.xyz/bsc/liquidations' 65 | chain_id: 56 66 | hash_tag_prefix: 'bsc_' 67 | main_symbol: 'BSC' 68 | usdp_symbol: 'USDP' 69 | oracle_registry: '0xED724e65373Fc3505Da02E05957678C471105a60' 70 | cdp_registry: '0xe94C28d7FB600751D8C5C8A6435a2dFA9Fb7Cf08' 71 | vault: '0xaa22Eb53553Ca9921427F596d8F62e95ea27372e' 72 | 73 | uniswap_factory: '' 74 | sushiswap_factory: '' 75 | sushiswap_init_code_hash: '' 76 | shibaswap_factory: '' 77 | shibaswap_init_code_hash: '' 78 | 79 | weth: '0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c' 80 | vault_managers: 81 | - '0xF6E54a0a024D9A59406a4bA9dCE2d04c9e0fb7C7' 82 | auctions: 83 | - '0x6e0B3dBDCe0C9a8b4CD44067D8548b00f2BEbF16' 84 | fallback_liquidation_trigger: '' 85 | main_liquidation_trigger: '0xF6E54a0a024D9A59406a4bA9dCE2d04c9e0fb7C7' 86 | blocks_check_delay: 50 87 | liquidation_confirmations_threshold: 3 88 | liquidation_debt_threshold: 2 89 | liquidation_debt_threshold_keydonix: 2 90 | min_balance: 0.3 91 | fantom: 92 | explorer_url: 'https://ftmscan.com' 93 | liquidation_url: 'https://unit.xyz/ftm/liquidations' 94 | chain_id: 250 95 | hash_tag_prefix: 'ftm_' 96 | main_symbol: 'FTM' 97 | usdp_symbol: 'USDP' 98 | oracle_registry: '0x0058aB54d4405D8084e8D71B8AB36B3091b21c7D' 99 | cdp_registry: '0x1442bC024a92C2F96c3c1D2E9274bC4d8119d97e' 100 | vault: '0xD7A9b0D75e51bfB91c843b23FB2C19aa3B8D958e' 101 | 102 | uniswap_factory: '' 103 | sushiswap_factory: '' 104 | sushiswap_init_code_hash: '' 105 | shibaswap_factory: '' 106 | shibaswap_init_code_hash: '' 107 | 108 | weth: '0x21be370D5312f44cB42ce377BC9b8a0cEF1A4C83' 109 | vault_managers: 110 | - '0xD12d6082811709287AE8b6d899Ab841659075FC3' 111 | auctions: 112 | - '0x1F18FAc6A422cF4a8D18369F017a100C77b49DeF' 113 | fallback_liquidation_trigger: '' 114 | main_liquidation_trigger: '0xD12d6082811709287AE8b6d899Ab841659075FC3' 115 | blocks_check_delay: 100 116 | liquidation_confirmations_threshold: 3 117 | liquidation_debt_threshold: 1 118 | liquidation_debt_threshold_keydonix: 0 119 | min_balance: 9 120 | gnosis: 121 | explorer_url: 'https://blockscout.com/xdai/mainnet' 122 | liquidation_url: 'https://unit.xyz/gnosis/liquidations' 123 | chain_id: 100 124 | hash_tag_prefix: 'gno_' 125 | main_symbol: 'GNO' 126 | usdp_symbol: 'USG' 127 | oracle_registry: '0x7670225e8c72dC627EAe09640c2Ba9a088b837b8' 128 | cdp_registry: '0x8ae98DD5D6177BE5Eb86fdD3c216Ae1952968F91' 129 | vault: '0x2EBb09eC5ECdc20800031f9d6Cee98f90127A822' 130 | 131 | uniswap_factory: '' 132 | sushiswap_factory: '' 133 | sushiswap_init_code_hash: '' 134 | shibaswap_factory: '' 135 | shibaswap_init_code_hash: '' 136 | 137 | weth: '0xe91D153E0b41518A2Ce8Dd3D7944Fa863463a97d' 138 | vault_managers: 139 | - '0xCa5d2E0961fe43eAE4bf07FA961B3CA8Cc0f50f6' 140 | auctions: 141 | - '0x9095557b53E7701bB0AC685d33efE116231B2b19' 142 | fallback_liquidation_trigger: '' 143 | main_liquidation_trigger: '0xCa5d2E0961fe43eAE4bf07FA961B3CA8Cc0f50f6' 144 | blocks_check_delay: 20 145 | liquidation_confirmations_threshold: 3 146 | liquidation_debt_threshold: 1 147 | liquidation_debt_threshold_keydonix: 0 148 | min_balance: 9 149 | avalanche: 150 | explorer_url: 'https://snowtrace.io' 151 | liquidation_url: 'https://unit.xyz/avalanche/liquidations' 152 | chain_id: 43114 153 | hash_tag_prefix: 'ava_' 154 | main_symbol: 'AVA' 155 | usdp_symbol: 'USDP' 156 | oracle_registry: '0x91B3e6dc1506702702Cc6814461ceE1Fe059D246' 157 | cdp_registry: '0xE01030CFEE17D5FE938F9c92a2849D5Dc54Ba246' 158 | vault: '0xBF389addC2A319fAf111fbe1bBBc8BF4A562549C' 159 | 160 | uniswap_factory: '' 161 | sushiswap_factory: '' 162 | sushiswap_init_code_hash: '' 163 | shibaswap_factory: '' 164 | shibaswap_init_code_hash: '' 165 | 166 | weth: '0xB31f66AA3C1e785363F0875A1B74E27b85FD66c7' 167 | vault_managers: 168 | - '0xA7549fe82F3dE73b270880A817FE05d6c40094E0' 169 | auctions: 170 | - '0xC744c62BEbED8Db3eCB2BdB226E864eB0AA60A6D' 171 | fallback_liquidation_trigger: '' 172 | main_liquidation_trigger: '0xA7549fe82F3dE73b270880A817FE05d6c40094E0' 173 | blocks_check_delay: 20 174 | liquidation_confirmations_threshold: 3 175 | liquidation_debt_threshold: 1 176 | liquidation_debt_threshold_keydonix: 0 177 | min_balance: 2 178 | arbitrum: 179 | explorer_url: 'https://arbiscan.io/' 180 | liquidation_url: 'https://unit.xyz/arbitrum/liquidations' 181 | chain_id: 42161 182 | hash_tag_prefix: 'arb_' 183 | main_symbol: 'ARB' 184 | usdp_symbol: 'USDP' 185 | oracle_registry: '0xc7952a86896Bc00E268F696D7789e27aeE1FFF25' 186 | cdp_registry: '0xe6297CCe54d35eCb71B9014669a6187734aD8fea' 187 | vault: '0x1278615c4f0b09F32A73eFF2d24A1FC3652C2903' 188 | 189 | uniswap_factory: '' 190 | sushiswap_factory: '' 191 | sushiswap_init_code_hash: '' 192 | shibaswap_factory: '' 193 | shibaswap_init_code_hash: '' 194 | 195 | weth: '0x82af49447d8a07e3bd95bd0d56f35241523fbab1' 196 | vault_managers: 197 | - '0x85a19Ac3e47B0c16f4C7FE733e64a7D61a853D98' 198 | auctions: 199 | - '0x29FD96C94c9e120de16a1C6bBdf39fDD0C468aFe' 200 | fallback_liquidation_trigger: '' 201 | main_liquidation_trigger: '0x85a19Ac3e47B0c16f4C7FE733e64a7D61a853D98' 202 | blocks_check_delay: 60 203 | liquidation_confirmations_threshold: 3 204 | liquidation_debt_threshold: 1 205 | liquidation_debt_threshold_keydonix: 0 206 | min_balance: 0.06 207 | optimism: 208 | explorer_url: 'https://optimistic.etherscan.io/' 209 | liquidation_url: 'https://unit.xyz/optimism/liquidations' 210 | chain_id: 10 211 | hash_tag_prefix: 'opt_' 212 | main_symbol: 'OPT' 213 | usdp_symbol: 'USDP' 214 | oracle_registry: '0xBBD408eb2dc6b34C2D18893286aC0210557F5a94' 215 | cdp_registry: '0xeA2D946076F2f7AfE64353C4acf0B5ad66471f5F' 216 | vault: '0x112331e98e25BB1f4C8Eb0659674E40Fc1fD3BC4' 217 | 218 | uniswap_factory: '' 219 | sushiswap_factory: '' 220 | sushiswap_init_code_hash: '' 221 | shibaswap_factory: '' 222 | shibaswap_init_code_hash: '' 223 | 224 | weth: '0x4200000000000000000000000000000000000006' 225 | vault_managers: 226 | - '0xBb85794cd26516E4dB8E6a004236Ba06d34bfC60' 227 | auctions: 228 | - '0x5E7902A7d4c3ad0317b9C84B59348836E94158a2' 229 | fallback_liquidation_trigger: '' 230 | main_liquidation_trigger: '0xBb85794cd26516E4dB8E6a004236Ba06d34bfC60' 231 | blocks_check_delay: 100 232 | liquidation_confirmations_threshold: 3 233 | liquidation_debt_threshold: 1 234 | liquidation_debt_threshold_keydonix: 0 235 | min_balance: 0.06 -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | liquidator-mainnet: &default 5 | build: 6 | dockerfile: docker/Dockerfile 7 | context: . 8 | volumes: 9 | - liquidator_data:/data/ 10 | env_file: 11 | - .env 12 | environment: 13 | - CHAIN_NAME=mainnet 14 | restart: unless-stopped 15 | ports: 16 | - "3001:3000" 17 | 18 | liquidator-bsc_old: 19 | <<: *default 20 | environment: 21 | - CHAIN_NAME=bsc_old 22 | ports: 23 | - "3002:3000" 24 | 25 | liquidator-bsc: 26 | <<: *default 27 | environment: 28 | - CHAIN_NAME=bsc 29 | ports: 30 | - "3003:3000" 31 | 32 | liquidator-fantom: 33 | <<: *default 34 | environment: 35 | - CHAIN_NAME=fantom 36 | ports: 37 | - "3004:3000" 38 | 39 | liquidator-gnosis: 40 | <<: *default 41 | environment: 42 | - CHAIN_NAME=gnosis 43 | ports: 44 | - "3005:3000" 45 | 46 | liquidator-avalanche: 47 | <<: *default 48 | environment: 49 | - CHAIN_NAME=avalanche 50 | ports: 51 | - "3006:3000" 52 | 53 | liquidator-arbitrum: 54 | <<: *default 55 | environment: 56 | - CHAIN_NAME=arbitrum 57 | ports: 58 | - "3007:3000" 59 | 60 | liquidator-optimism: 61 | <<: *default 62 | environment: 63 | - CHAIN_NAME=optimism 64 | ports: 65 | - "3008:3000" 66 | 67 | volumes: 68 | liquidator_data: 69 | 70 | -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18.12-alpine3.16 as build_stage 2 | WORKDIR /app 3 | COPY package.json ./ 4 | COPY tsconfig.json ./ 5 | COPY yarn.lock ./ 6 | COPY src ./src 7 | RUN yarn install --frozen-lockfile 8 | RUN npx tsc 9 | 10 | 11 | FROM node:18.12-alpine3.16 12 | WORKDIR /app 13 | COPY --from=build_stage /app/dist dist/ 14 | COPY config.yaml ./ 15 | 16 | COPY package.json ./ 17 | COPY yarn.lock ./ 18 | RUN yarn install --frozen-lockfile --production 19 | 20 | RUN npm install pm2 -g 21 | EXPOSE 80 22 | CMD ["pm2-runtime", "start", "dist/app"] -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "liquidator", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "scripts": { 7 | "start": "npx tsc && npx ts-node src/app" 8 | }, 9 | "dependencies": { 10 | "@ethersproject/address": "^5.2.0", 11 | "@ethersproject/contracts": "^5.2.0", 12 | "@ethersproject/providers": "^5.2.0", 13 | "@ethersproject/solidity": "^5.2.0", 14 | "@keydonix/uniswap-oracle-sdk": "^1.0.6", 15 | "@uniswap/sdk": "^3.0.3", 16 | "axios": "^0.21.0", 17 | "bignumber.js": "^9.0.1", 18 | "dotenv": "^8.2.0", 19 | "events": "^3.2.0", 20 | "js-yaml": "^4.1.0", 21 | "module-alias": "^2.2.2", 22 | "node-telegram-bot-api": "^0.50.0", 23 | "web3": "^1.3.0" 24 | }, 25 | "_moduleAliases": { 26 | "src": "dist" 27 | }, 28 | "devDependencies": { 29 | "typescript": "^4.9.4" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/app/index.ts: -------------------------------------------------------------------------------- 1 | import 'module-alias/register' 2 | import SynchronizationService from 'src/services/synchronization' 3 | import LiquidationService from 'src/services/liquidation' 4 | import NotificationService from 'src/services/notification' 5 | import HealthService from 'src/services/health' 6 | import { Liquidation } from 'src/types/TxConfig' 7 | import { web3 } from 'src/provider' 8 | import EventBroker from 'src/broker' 9 | import StateManagerService from 'src/services/statemanager' 10 | 11 | class LiquidationMachine { 12 | public readonly synchronizer: SynchronizationService 13 | public readonly liquidator: LiquidationService 14 | public readonly notificator: NotificationService 15 | public readonly health: HealthService 16 | public readonly statemanager: StateManagerService 17 | public liquidatorReady: boolean 18 | public postponedLiquidationTriggers: Liquidation[] 19 | 20 | constructor() { 21 | const broker = EventBroker(this) 22 | this.statemanager = new StateManagerService(this) 23 | let loadedAppState = this.statemanager.loadState() 24 | 25 | this.notificator = new NotificationService(loadedAppState) 26 | this.synchronizer = new SynchronizationService(web3, broker, loadedAppState, this.notificator) 27 | this.liquidatorReady = false 28 | this.postponedLiquidationTriggers = [] 29 | 30 | this.liquidator = new LiquidationService(web3, this.notificator) 31 | this.liquidator.on('ready', () => { this.liquidatorReady = true }) 32 | 33 | this.health = new HealthService(web3, this.statemanager) 34 | 35 | const events = Object.keys(broker) 36 | 37 | const initListeners = () => { 38 | events.forEach(eventName => { 39 | const worker = eventName.substring(0, eventName.indexOf('_')).toLowerCase() 40 | this[worker].on(eventName, broker[eventName]) 41 | }) 42 | } 43 | 44 | initListeners() 45 | } 46 | } 47 | 48 | new LiquidationMachine() 49 | 50 | export default LiquidationMachine 51 | -------------------------------------------------------------------------------- /src/broker/index.ts: -------------------------------------------------------------------------------- 1 | import { BlockHeader } from 'web3-eth' 2 | import LiquidationMachine from 'src/app' 3 | 4 | type Process = (data: any) => any 5 | 6 | export type Broker = { [name: string]: Process } 7 | 8 | type EventBroker = (LiquidationMachine) => Broker 9 | 10 | const EventBroker: EventBroker = (machine: LiquidationMachine) => ({ 11 | SYNCHRONIZER_LIQUIDATION_TRIGGERED_EVENT: data => machine.notificator.notifyTriggered(data), 12 | SYNCHRONIZER_LIQUIDATED_EVENT: data => machine.notificator.notifyLiquidated(data), 13 | SYNCHRONIZER_NEW_BLOCK_EVENT: (header: BlockHeader) => [ 14 | machine.synchronizer.checkLiquidatable(header), 15 | machine.synchronizer.syncToBlock(header) 16 | ], 17 | SYNCHRONIZER_JOIN_EVENT: join => machine.notificator.notifyJoin(join), 18 | SYNCHRONIZER_SAVE_STATE_REQUEST: (synchronizerAppState) => machine.statemanager.saveState(synchronizerAppState), 19 | SYNCHRONIZER_DUCK_CREATION_EVENT: mint => machine.notificator.notifyDuck(mint), 20 | SYNCHRONIZER_EXIT_EVENT: exit => machine.notificator.notifyExit(exit), 21 | SYNCHRONIZER_TRIGGER_LIQUIDATION_EVENT: (event) => { 22 | 23 | // postpone liquidations when service is not yet available 24 | if (!machine.liquidatorReady) { 25 | machine.postponedLiquidationTriggers.push(event) 26 | return 27 | } 28 | 29 | const promises = [] 30 | 31 | if (machine.postponedLiquidationTriggers.length) { 32 | // process postponed liquidations 33 | for (const postponedTx of machine.postponedLiquidationTriggers) { 34 | promises.push(machine.liquidator.triggerLiquidation(postponedTx)) 35 | } 36 | 37 | machine.postponedLiquidationTriggers = [] 38 | } 39 | 40 | // trigger the liquidation 41 | promises.push(machine.liquidator.triggerLiquidation(event)) 42 | 43 | return promises 44 | }, 45 | }) 46 | 47 | export default EventBroker -------------------------------------------------------------------------------- /src/config/index.ts: -------------------------------------------------------------------------------- 1 | import yaml from 'js-yaml' 2 | import fs from 'fs' 3 | 4 | const config = yaml.load(fs.readFileSync('config.yaml', 'utf8')); 5 | 6 | export default config 7 | -------------------------------------------------------------------------------- /src/constants/index.ts: -------------------------------------------------------------------------------- 1 | import { web3 } from 'src/provider' 2 | import config from 'src/config' 3 | 4 | export const IS_DEV = process.env.IS_DEV === 'true' 5 | 6 | export const JOIN_TOPICS = [web3.utils.sha3('Join(address,address,uint256,uint256)')] 7 | export const LIQUIDATION_TRIGGERED_TOPICS = ["0x5b79a897d30813a62a1f95ba180d3320d3701d96605708b81105e00719a069e4"] 8 | export const BUYOUT_TOPICS = [web3.utils.sha3("Buyout(address,address,address,uint256,uint256,uint256)")] 9 | export const EXIT_TOPICS = [web3.utils.sha3('Exit(address,address,uint256,uint256)')] 10 | 11 | export const CHAIN_NAME = process.env.CHAIN_NAME; 12 | 13 | const conf = config[CHAIN_NAME] 14 | if (!conf) 15 | throw new Error(`Unsupported chain name: ${CHAIN_NAME}`) 16 | 17 | export const CHAIN_ID = Number(conf.chain_id) 18 | export const MAIN_SYMBOL = conf.main_symbol 19 | export const USDP_SYMBOL = conf.usdp_symbol 20 | export const HASHTAG_PREFIX = conf.hash_tag_prefix 21 | export const CDP_REGISTRY = conf.cdp_registry 22 | export const VAULT_ADDRESS = conf.vault 23 | 24 | export const UNISWAP_FACTORY = conf.uniswap_factory 25 | 26 | export const SUSHISWAP_FACTORY = conf.sushiswap_factory 27 | export const SUSHISWAP_PAIR_INIT_CODE_HASH = conf.sushiswap_init_code_hash 28 | 29 | export const SHIBASWAP_FACTORY = conf.shibaswap_factory 30 | export const SHIBASWAP_PAIR_INIT_CODE_HASH = conf.shibaswap_init_code_hash 31 | 32 | export const WETH = conf.weth 33 | export const ORACLE_REGISTRY = conf.oracle_registry 34 | 35 | export const AUCTIONS = conf.auctions 36 | export const MAIN_LIQUIDATION_TRIGGER = conf.main_liquidation_trigger 37 | export const FALLBACK_LIQUIDATION_TRIGGER = conf.fallback_liquidation_trigger 38 | 39 | export const SYNCHRONIZER_TRIGGER_LIQUIDATION_EVENT = 'SYNCHRONIZER_TRIGGER_LIQUIDATION_EVENT' 40 | export const SYNCHRONIZER_NEW_BLOCK_EVENT = 'SYNCHRONIZER_NEW_BLOCK_EVENT' 41 | export const SYNCHRONIZER_JOIN_EVENT = 'SYNCHRONIZER_JOIN_EVENT' 42 | export const SYNCHRONIZER_EXIT_EVENT = 'SYNCHRONIZER_EXIT_EVENT' 43 | export const SYNCHRONIZER_SAVE_STATE_REQUEST = 'SYNCHRONIZER_SAVE_STATE_REQUEST' 44 | export const SYNCHRONIZER_LIQUIDATION_TRIGGERED_EVENT = 'SYNCHRONIZER_LIQUIDATION_TRIGGERED_EVENT' 45 | export const SYNCHRONIZER_LIQUIDATED_EVENT = 'SYNCHRONIZER_LIQUIDATED_EVENT' 46 | export const CONFIRMATIONS_THRESHOLD = Number(conf.liquidation_confirmations_threshold) 47 | export const BLOCKS_CHECK_DELAY = Number(conf.blocks_check_delay) 48 | export const LIQUIDATION_DEBT_THRESHOLD = Number(conf.liquidation_debt_threshold) 49 | export const LIQUIDATION_DEBT_THRESHOLD_KEYDONIX = Number(conf.liquidation_debt_threshold_keydonix) 50 | export const MIN_BALANCE = BigInt(Number(conf.min_balance) * 1000000) * 10n**12n // 10**18 total 51 | 52 | export const EXPLORER_URL = conf.explorer_url 53 | export const LIQUIDATION_URL = conf.liquidation_url 54 | 55 | export const ZERO_ADDRESS = '0x' + '0'.repeat(40) 56 | 57 | export let ACTIVE_VAULT_MANAGERS = conf.vault_managers 58 | 59 | export const APP_STATE_FILENAME = IS_DEV ? 'app.dat' : `/data/app_${CHAIN_NAME}.dat` 60 | -------------------------------------------------------------------------------- /src/logger/index.ts: -------------------------------------------------------------------------------- 1 | import { inspect } from 'util' 2 | 3 | export default function(serviceName: string) { 4 | return { 5 | info: function(data: any) { 6 | console.log(this.format(data)) 7 | }, 8 | error: function(data: any) { 9 | console.error(this.format(data)) 10 | }, 11 | format: function(data: any, noTime = false) { 12 | return (noTime ? '' : `${new Date().toLocaleString()}::`) + `${serviceName}::${data.map(d => typeof d === 'object' ? inspect(d) : d ? d.toString() : d).join(', ')}` 13 | }, 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/provider/index.ts: -------------------------------------------------------------------------------- 1 | const Web3 = require('web3') 2 | require('dotenv').config() 3 | 4 | const websocketOptions = { 5 | clientConfig: { 6 | keepalive: true, 7 | keepaliveInterval: 500 8 | } 9 | } 10 | 11 | const WEBSOCKET_URL = process.env[`${process.env.CHAIN_NAME.toUpperCase()}_WEBSOCKET_URL`] 12 | 13 | export const web3 = new Web3(new Web3.providers.WebsocketProvider(WEBSOCKET_URL, websocketOptions)) 14 | export const web3Proof = process.env.PROOFS_RPC_URL ? new Web3(process.env.PROOFS_RPC_URL) : web3 15 | -------------------------------------------------------------------------------- /src/services/health/index.ts: -------------------------------------------------------------------------------- 1 | import http from 'http'; 2 | import Logger from "src/logger"; 3 | import StateManagerService from "src/services/statemanager"; 4 | import Web3 from "web3"; 5 | import {BLOCKS_CHECK_DELAY, CHAIN_NAME, MIN_BALANCE} from "src/constants"; 6 | import {web3} from "src/provider"; 7 | 8 | const PORT = 3000 9 | const RPC_LAG_THRESHOLD = 300; 10 | const PROCESSED_BLOCK_LAG_THRESHOLD = BLOCKS_CHECK_DELAY * 3; 11 | 12 | class HealthService { 13 | private readonly server: http.Server 14 | private readonly logger; 15 | 16 | private readonly web3: Web3 17 | private stateManager: StateManagerService 18 | 19 | constructor(web3, stateManager: StateManagerService) { 20 | this.logger = Logger('HealthService') 21 | this.web3 = web3 22 | this.stateManager = stateManager 23 | 24 | this.server = http.createServer(async (req, res) => { 25 | res.setHeader('Content-Type', 'application/json'); 26 | if (req.method !== 'GET') { 27 | res.end(`{"error": "${http.STATUS_CODES[405]}"}`) 28 | return; 29 | } else { 30 | if (req.url === '/health') { 31 | res.end(JSON.stringify(await this.checkHealth())) 32 | return; 33 | } 34 | } 35 | res.end(`{"error": "${http.STATUS_CODES[404]}"}`) 36 | }) 37 | 38 | this.server.listen(PORT, () => { 39 | this.logger.info([`Health http server listening on port ${PORT}`]); 40 | }) 41 | } 42 | 43 | private async checkHealth() { 44 | let resultRpc = true; 45 | let resultEvents = true; 46 | let resultLiquidator = true; 47 | let resultBalance = true; 48 | 49 | const currentTs = Math.floor(Date.now() / 1000) 50 | const lastBlock = await this.web3.eth.getBlock('latest') 51 | 52 | if (lastBlock.timestamp < currentTs - RPC_LAG_THRESHOLD) { 53 | resultRpc = false; 54 | } 55 | 56 | const state = this.stateManager.loadState(); 57 | if (state.lastProcessedBlock < lastBlock.number - PROCESSED_BLOCK_LAG_THRESHOLD) { 58 | resultEvents = false 59 | } 60 | if (state.lastLiquidationCheck < lastBlock.number - PROCESSED_BLOCK_LAG_THRESHOLD) { 61 | resultLiquidator = false; 62 | } 63 | 64 | const balance = await web3.eth.getBalance(process.env.ETHEREUM_ADDRESS) 65 | if (balance < MIN_BALANCE) { 66 | resultBalance = false; 67 | } 68 | 69 | return { 70 | health: resultRpc && resultEvents && resultLiquidator && resultBalance, 71 | health_rpc: resultRpc, 72 | health_events: resultEvents, 73 | health_liquidator: resultLiquidator, 74 | health_balance: resultBalance, 75 | chain: CHAIN_NAME, 76 | ts: currentTs, 77 | rpcLastBlock: lastBlock.number, 78 | rpcLastBlockTs: lastBlock.timestamp, 79 | eventsLastProcessedBlock: state.lastProcessedBlock, 80 | liquidatorLastProcessedBlock: state.lastLiquidationCheck, 81 | min_balance: MIN_BALANCE.toString(), 82 | current_balance: balance.toString(), 83 | }; 84 | } 85 | } 86 | 87 | export default HealthService -------------------------------------------------------------------------------- /src/services/liquidation/index.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events' 2 | import Web3 from 'web3' 3 | import Logger from 'src/logger' 4 | import { Liquidation, TxConfig } from 'src/types/TxConfig' 5 | import {CONFIRMATIONS_THRESHOLD, IS_DEV, BLOCKS_CHECK_DELAY, CHAIN_ID} from 'src/constants' 6 | import axios from 'axios' 7 | import { inspect } from 'util' 8 | import NotificationService from 'src/services/notification' 9 | 10 | declare interface LiquidationService { 11 | on(event: string, listener: Function): this; 12 | emit(event: string, payload: any): boolean; 13 | } 14 | 15 | class LiquidationService extends EventEmitter { 16 | private readonly web3: Web3 17 | private readonly transactions: Map 18 | private readonly preparing: Map 19 | private readonly postponedRemovals: Removal[] 20 | private readonly logger 21 | private readonly notificator: NotificationService 22 | private readonly senderAddress: string 23 | private readonly privateKey: string 24 | 25 | private nonce: number 26 | 27 | constructor(web3: Web3, notificator: NotificationService) { 28 | super() 29 | this.web3 = web3 30 | this.transactions = new Map() 31 | this.preparing = new Map() 32 | this.logger = Logger(LiquidationService.name) 33 | this.senderAddress = process.env.ETHEREUM_ADDRESS 34 | this.privateKey = process.env.ETHEREUM_PRIVATE_KEY 35 | this.postponedRemovals = [] 36 | this.notificator = notificator 37 | if (!this.privateKey.startsWith('0x')) 38 | this.privateKey = '0x' + this.privateKey 39 | this.updateNonce() 40 | } 41 | 42 | async triggerLiquidation(liquidation: Liquidation) { 43 | const { tx, blockNumber, buildTx } = liquidation 44 | this.log('.triggerLiquidation', tx.key) 45 | 46 | this._processPostponedRemovals(blockNumber) 47 | 48 | const prepared = this.preparing.get(tx.key) 49 | if (!prepared || blockNumber > prepared.lastSeenBlockNumber + BLOCKS_CHECK_DELAY * 1.5) { 50 | this.preparing.set(tx.key, { 51 | tx, 52 | lastSeenBlockNumber: blockNumber, 53 | confirmations: 1 54 | }) 55 | await this.logOnline(`.triggerLiquidation: 1/${CONFIRMATIONS_THRESHOLD} confirmations collected for ${tx.key}`); 56 | return 57 | } else if (prepared.confirmations < CONFIRMATIONS_THRESHOLD - 1) { 58 | if (blockNumber > prepared.lastSeenBlockNumber) { 59 | this.preparing.set(tx.key, { 60 | tx, 61 | lastSeenBlockNumber: blockNumber, 62 | confirmations: prepared.confirmations + 1 63 | }) 64 | } 65 | 66 | await this.logOnline(`.triggerLiquidation: ${prepared.confirmations + 1}/${CONFIRMATIONS_THRESHOLD} confirmations collected for ${tx.key}`); 67 | return 68 | } 69 | 70 | await this.logOnline(`.triggerLiquidation: collected ${CONFIRMATIONS_THRESHOLD} confirmations for ${tx.key}, sending tx`); 71 | 72 | let nonce 73 | 74 | let trx = tx; 75 | 76 | // if we want to rebuild tx according to the new block number we can do it here 77 | // we may want it for example to refresh proofs for keydonix transactions 78 | // if (buildTx) { 79 | // trx = await buildTx(blockNumber); 80 | // } 81 | 82 | if (!trx) { 83 | this.logError(`Cannot perform liquidation: ${inspect(tx)}`) 84 | } 85 | 86 | const sentTx = this.transactions.get(trx.key) 87 | const now = new Date().getTime() / 1000 88 | if (sentTx && sentTx.sentAt) { 89 | if (now - sentTx.sentAt > 60) { 90 | // load nonce of sent tx 91 | nonce = sentTx.nonce 92 | } else { 93 | this.log('.triggerLiquidation: already exists', this.transactions.get(trx.key).txHash); 94 | return 95 | } 96 | } else { 97 | // load stored nonce 98 | nonce = this.nonce 99 | // increment stored nonce 100 | this.nonce++ 101 | } 102 | 103 | this.transactions.set(trx.key, trx) 104 | this.log('.triggerLiquidation: buildingTx for', trx.key); 105 | 106 | const gasPriceResp = await axios.get("https://gasprice.poa.network/").catch(() => undefined) 107 | let gasPrice 108 | if (!gasPriceResp || !gasPriceResp.data || !gasPriceResp.data.health) { 109 | gasPrice = await this.web3.eth.getGasPrice() 110 | gasPrice = String(Number(gasPrice) * 120) 111 | gasPrice = Math.ceil(gasPrice.substr(0, gasPrice.length - 2)) 112 | } else { 113 | gasPrice = Math.ceil(gasPriceResp.data.instant * 1e9) 114 | } 115 | 116 | const txConfig = { 117 | to: trx.to, 118 | data: trx.data, 119 | gasLimit: +trx.gas + 200_000, 120 | gas: +trx.gas + 200_000, 121 | chainId: CHAIN_ID, 122 | gasPrice, 123 | nonce, 124 | } 125 | 126 | const signedTransaction = await this.web3.eth.accounts.signTransaction(txConfig, this.privateKey) 127 | 128 | // set sending params 129 | trx.txHash = signedTransaction.transactionHash 130 | trx.nonce = nonce 131 | trx.sentAt = now 132 | this.log('.triggerLiquidation: sending transaction', signedTransaction.transactionHash); 133 | 134 | this.transactions.set(trx.key, trx) 135 | 136 | if (!IS_DEV) { 137 | 138 | const result = await this.web3.eth.sendSignedTransaction(signedTransaction.rawTransaction).catch(async (e) => { 139 | if (e.toString().includes("nonce too low")) { 140 | this.log('.triggerLiquidation: nonce too low, updating', e) 141 | await this.updateNonce() 142 | this.transactions.delete(trx.key) 143 | } else { 144 | await this.alarm('.triggerLiquidation: tx sending error', e.toString()) 145 | } 146 | }) 147 | 148 | if (result) { 149 | this._postponeRemoval(trx.key, blockNumber + 20) 150 | this.log('.triggerLiquidation: tx sending result', result) 151 | } 152 | } 153 | } 154 | 155 | private async updateNonce() { 156 | const initializing = !this.nonce 157 | this.nonce = await this.web3.eth.getTransactionCount(this.senderAddress) 158 | if (initializing) { 159 | this.emit('ready', this) 160 | } 161 | } 162 | 163 | private _postponeRemoval(key, removeAtBlock) { 164 | this.postponedRemovals.push({ key, removeAtBlock }) 165 | } 166 | 167 | private _processPostponedRemovals(currentBlockNumber: number) { 168 | for (let i = this.postponedRemovals.length - 1; i >= 0; i--) { 169 | const { key, removeAtBlock } = this.postponedRemovals[i] 170 | if (currentBlockNumber >= removeAtBlock) { 171 | this.postponedRemovals.splice(i, 1) 172 | this.transactions.delete(key) 173 | this.preparing.delete(key) 174 | } 175 | } 176 | } 177 | 178 | 179 | private log(...args) { 180 | this.logger.info(args) 181 | } 182 | 183 | private logError(...args) { 184 | this.logger.error(args) 185 | } 186 | 187 | private logOnline(...args) { 188 | this.logger.info(args) 189 | //return this.notificator.logAction(this.logger.format(args, true)) 190 | } 191 | 192 | private alarm(...args) { 193 | return this.notificator.logAlarm(this.logger.format(args, true)) 194 | } 195 | } 196 | 197 | interface PreparingLiquidation { 198 | lastSeenBlockNumber: number 199 | confirmations: number 200 | tx: TxConfig 201 | } 202 | 203 | interface Removal { 204 | key: string 205 | removeAtBlock: number 206 | } 207 | 208 | export default LiquidationService 209 | -------------------------------------------------------------------------------- /src/services/notification/index.ts: -------------------------------------------------------------------------------- 1 | import { Transfer } from 'src/types/Transfer' 2 | import Logger from 'src/logger' 3 | import { JoinExit } from 'src/types/JoinExit' 4 | import { 5 | formatNumber, 6 | getLiquidationFee, 7 | getTokenDecimals, 8 | getTokenSymbol, 9 | getTotalDebt, 10 | tryFetchPrice, 11 | } from 'src/utils' 12 | import { LiquidationTrigger } from 'src/types/LiquidationTrigger' 13 | import { Buyout } from 'src/types/Buyout' 14 | import { BasicEvent } from 'src/types/BasicEvent' 15 | import { NotificationState } from 'src/services/statemanager' 16 | import BigNumber from 'bignumber.js' 17 | import {HASHTAG_PREFIX, EXPLORER_URL, IS_DEV, LIQUIDATION_URL, MAIN_SYMBOL, USDP_SYMBOL} from 'src/constants' 18 | import { web3 } from 'src/provider' 19 | 20 | const TelegramBot = require("node-telegram-bot-api") 21 | 22 | export type LogStore = { 23 | blockHash: string 24 | blockNumber?: number 25 | txIndex: number 26 | logIndexes: number[] 27 | } 28 | 29 | 30 | export default class NotificationService { 31 | private readonly bot 32 | private readonly logger 33 | private readonly defaultChatId 34 | private readonly liquidationChannel 35 | private readonly logsChannel 36 | private readonly sentryChannel 37 | private readonly processed: Map 38 | 39 | private lastOldLogsCheck 40 | 41 | constructor(notificationState: NotificationState) { 42 | this.logger = Logger(NotificationService.name) 43 | const botToken = process.env.TELEGRAM_BOT_TOKEN 44 | this.defaultChatId = process.env.TELEGRAM_CHAT_ID 45 | this.logsChannel = process.env.LOGS_TELEGRAM_CHAT_ID 46 | this.sentryChannel = process.env.SENTRY_TELEGRAM_CHAT_ID 47 | this.liquidationChannel = process.env.LIQUIDATION_TELEGRAM_CHAT_ID || this.defaultChatId 48 | this.bot = new TelegramBot(botToken, { polling: false }); 49 | 50 | this.processed = new Map() 51 | 52 | try { 53 | Object.keys(notificationState.logs).forEach((txHash) => { 54 | const log = notificationState.logs[txHash] 55 | this.processed.set(txHash, log) 56 | }) 57 | this.log(`Loaded notification state, processed notifications count: ${this.processed.size}`) 58 | } catch (e) { } 59 | } 60 | 61 | async notifyJoin(data: JoinExit) { 62 | const msg = await this.toMsg(data, true) 63 | if (msg) { 64 | return this.sendMessage(msg) 65 | } 66 | } 67 | 68 | async toMsg(data: JoinExit, isJoin) { 69 | if (!(await this._shouldNotify(data))) return 70 | 71 | let assetAction = '', usdpAction = '' 72 | 73 | const assetChange = data.main > 0 74 | 75 | const symbol = await getTokenSymbol(data.token) 76 | 77 | if (assetChange) { 78 | const assetPrefix = isJoin ? `#${HASHTAG_PREFIX}deposited` : `#${HASHTAG_PREFIX}withdrawn` 79 | const decimals = await getTokenDecimals(data.token) 80 | const assetValue = new BigNumber(data.main.toString()).div(10 ** decimals).toNumber() 81 | 82 | const assetPrice = await tryFetchPrice(data.token, data.main, decimals); 83 | 84 | assetAction = `${assetPrefix} ${formatNumber(assetValue)} ${symbol} (${assetPrice})` 85 | } 86 | 87 | const usdpChange = data.usdp > 0 88 | 89 | if (usdpChange) { 90 | const usdpPrefix = (assetChange ? '\n' : '') + (isJoin ? `#${HASHTAG_PREFIX}minted` : `#${HASHTAG_PREFIX}burned`) 91 | const usdp = Number(data.usdp / BigInt(10 ** 15)) / 1000 92 | const duckCount = isJoin ? usdp < 1_000 ? 1 : (usdp < 5_000 ? 2 : (Math.round(usdp / 5_000) + 2)) : 0 93 | 94 | const collateralInfo = assetChange ? '' : `(${symbol}) ` 95 | 96 | usdpAction = `${usdpPrefix} ${formatNumber(usdp)} ${USDP_SYMBOL} ${collateralInfo}${duckCount > 100 ? '🐋' : '🦆'.repeat(duckCount)}` 97 | } 98 | 99 | return assetAction + usdpAction + '\n' + `Explorer` 100 | } 101 | 102 | async notifyExit(data: JoinExit) { 103 | const msg = await this.toMsg(data, false) 104 | if (msg) { 105 | return this.sendMessage(msg) 106 | } 107 | } 108 | 109 | async notifyDuck(data: Transfer) { 110 | const amountFormatted = Number(data.amount / BigInt(10 ** (18 - 4))) / 1e4 111 | const text = `${amountFormatted} DUCK minted\n` + `Explorer 🦆` 112 | return this.sendMessage(text) 113 | } 114 | 115 | async notifyTriggered(data: LiquidationTrigger) { 116 | if (!(await this._shouldNotify(data))) return 117 | const symbol = await getTokenSymbol(data.token) 118 | 119 | const debt = await getTotalDebt(data.token, data.user) 120 | const liquidationFee = await getLiquidationFee(data.token, data.user) 121 | const debtFormatted = Number(debt * (100n + liquidationFee) / 10n ** 18n) / 1e2 122 | 123 | const text = `#${HASHTAG_PREFIX}liquidation_trigger` 124 | + `\nLiquidation auction for ${symbol} just started` 125 | + `\nInitial price ${debtFormatted} ${USDP_SYMBOL}` 126 | + `\nAsset ${data.token}` 127 | + `\nOwner ${data.user}` 128 | + `\nLiquidate` 129 | + `\nExplorer` 130 | 131 | return this.sendMessage(text, this.liquidationChannel) 132 | } 133 | 134 | async notifyLiquidated(data: Buyout) { 135 | if (!(await this._shouldNotify(data))) return 136 | const symbol = await getTokenSymbol(data.token) 137 | 138 | const decimals = await getTokenDecimals(data.token) 139 | 140 | const assetPrice = await tryFetchPrice(data.token, data.amount, decimals); 141 | 142 | const usdpPriceFormatted: number | string = Number(data.price / BigInt(10 ** (18 - 2))) / 1e2 143 | 144 | const price = usdpPriceFormatted === 0 ? 'free' : `${usdpPriceFormatted} ${USDP_SYMBOL}` 145 | 146 | const assetAmount = new BigNumber(data.amount.toString()).div(10 ** decimals).toNumber() 147 | 148 | const text = `#${HASHTAG_PREFIX}liquidated` 149 | + `\n${formatNumber(assetAmount)} ${symbol} (${assetPrice}) for ${price}` 150 | + `\nAsset ${data.token}` 151 | + `\nOwner ${data.owner}` 152 | + `\nLiquidator ${data.liquidator}` 153 | + '\n' + `Explorer` 154 | 155 | return this.sendMessage(text, this.liquidationChannel) 156 | } 157 | 158 | private async sendMessage(text, chatId = this.defaultChatId, form = { parse_mode: 'HTML', disable_web_page_preview: true }) { 159 | if (IS_DEV) { 160 | console.log(text); 161 | } else { 162 | return this.bot.sendMessage(chatId, text, form).catch((e) => { 163 | this.error('error', e); 164 | setTimeout(() => this.sendMessage(text, chatId, form), 5_000) 165 | }); 166 | } 167 | } 168 | 169 | public async logAction(text, chatId = this.logsChannel, form = { parse_mode: 'HTML', disable_web_page_preview: true }) { 170 | text = `[${MAIN_SYMBOL}]${text}` 171 | if (IS_DEV) { 172 | console.log(text); 173 | } else { 174 | return this.bot.sendMessage(chatId, text, form).catch((e) => { 175 | this.error('error', e); 176 | setTimeout(() => this.sendMessage(text, chatId, form), 5_000) 177 | }); 178 | } 179 | } 180 | 181 | public async logAlarm(text, chatId = this.sentryChannel, form = { parse_mode: 'HTML', disable_web_page_preview: true }) { 182 | text = `[${MAIN_SYMBOL}]${text}` 183 | if (IS_DEV) { 184 | console.log(text); 185 | } else { 186 | return this.bot.sendMessage(chatId, text, form).catch((e) => { 187 | this.error('error', e); 188 | setTimeout(() => this.sendMessage(text, chatId, form), 5_000) 189 | }); 190 | } 191 | } 192 | 193 | private async _shouldNotify(n: BasicEvent): Promise { 194 | const exists = await this._isExists(n) 195 | return !exists 196 | } 197 | 198 | private log(...args) { 199 | this.logger.info(args) 200 | } 201 | 202 | private error(...args) { 203 | this.logger.error(args) 204 | } 205 | 206 | public getState(): NotificationState { 207 | return { 208 | logs: Object.fromEntries(this.processed.entries()) 209 | } 210 | } 211 | 212 | private async _isExists(n: BasicEvent): Promise { 213 | await this._deleteLogsOlderThan(n.blockNumber - 10_000) 214 | if (!this.processed.get(n.txHash)) { 215 | this.processed.set(n.txHash, { blockHash: n.blockHash, txIndex: n.txIndex, logIndexes: [n.logIndex], blockNumber: n.blockNumber }) 216 | return false 217 | } 218 | const logStore = this.processed.get(n.txHash); 219 | if (logStore.txIndex === n.txIndex && logStore.blockHash === n.blockHash) { 220 | if (logStore.logIndexes.includes(n.logIndex)) { 221 | return true 222 | } 223 | this.processed.set(n.txHash, { blockHash: n.blockHash, blockNumber: n.blockNumber, txIndex: n.txIndex, logIndexes: [ ...logStore.logIndexes, n.logIndex] }) 224 | return false 225 | } else { 226 | return true 227 | } 228 | } 229 | 230 | private async _deleteLogsOlderThan(n: number) { 231 | if (this.lastOldLogsCheck && n < this.lastOldLogsCheck + 10_000) return 232 | this.lastOldLogsCheck = n 233 | let removed = 0 234 | for (const txHash of this.processed.keys()) { 235 | const log = this.processed.get(txHash) 236 | if (!log) continue 237 | if (!log.blockNumber && log.blockHash) { 238 | log.blockNumber = (await web3.eth.getBlock(log.blockHash, false)).number 239 | } 240 | const old = log.blockNumber < n 241 | if (old) { 242 | removed ++ 243 | this.processed.delete(txHash) 244 | } 245 | } 246 | if (removed) { 247 | this.log(`Removed ${removed} old logs`) 248 | } 249 | } 250 | } 251 | -------------------------------------------------------------------------------- /src/services/statemanager/index.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events' 2 | import Logger from 'src/logger' 3 | import fs from 'fs' 4 | import { APP_STATE_FILENAME } from 'src/constants' 5 | import LiquidationMachine from 'src/app' 6 | import { LogStore } from 'src/services/notification' 7 | 8 | export type SynchronizerState = { 9 | lastProcessedBlock: number, 10 | lastLiquidationCheck: number 11 | } 12 | 13 | export type NotificationState = { 14 | logs: { 15 | [txHash: string]: LogStore 16 | } 17 | } 18 | 19 | export interface AppState extends SynchronizerState, NotificationState {} 20 | 21 | class StateManagerService extends EventEmitter { 22 | private readonly liquidationMachine: LiquidationMachine 23 | private readonly logger 24 | 25 | constructor(machine: LiquidationMachine) { 26 | super() 27 | this.logger = Logger(StateManagerService.name) 28 | this.liquidationMachine = machine 29 | } 30 | 31 | public loadState(): AppState { 32 | let loadedAppState 33 | try { 34 | loadedAppState = JSON.parse(fs.readFileSync(APP_STATE_FILENAME, 'utf8')); 35 | } catch (e) { } 36 | return loadedAppState 37 | } 38 | 39 | public saveState(synchronizerState: SynchronizerState) { 40 | const liquidationState: NotificationState = this.liquidationMachine.notificator.getState() 41 | const appState: AppState = { 42 | ...synchronizerState, 43 | ...liquidationState, 44 | } 45 | try { 46 | fs.writeFileSync(APP_STATE_FILENAME, JSON.stringify(appState)) 47 | } catch (e) { 48 | this.logError(e) 49 | } 50 | } 51 | 52 | private logError(...args) { 53 | this.logger.error(args) 54 | } 55 | } 56 | 57 | export default StateManagerService -------------------------------------------------------------------------------- /src/services/synchronization/index.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | import Web3 from 'web3' 3 | import { CDP } from 'src/types/Position' 4 | import { 5 | ACTIVE_VAULT_MANAGERS, 6 | SYNCHRONIZER_JOIN_EVENT, 7 | SYNCHRONIZER_NEW_BLOCK_EVENT, 8 | SYNCHRONIZER_TRIGGER_LIQUIDATION_EVENT, 9 | SYNCHRONIZER_EXIT_EVENT, 10 | LIQUIDATION_TRIGGERED_TOPICS, 11 | SYNCHRONIZER_LIQUIDATION_TRIGGERED_EVENT, 12 | JOIN_TOPICS, 13 | EXIT_TOPICS, 14 | AUCTIONS, 15 | BUYOUT_TOPICS, 16 | SYNCHRONIZER_LIQUIDATED_EVENT, 17 | BLOCKS_CHECK_DELAY, 18 | SYNCHRONIZER_SAVE_STATE_REQUEST, 19 | FALLBACK_LIQUIDATION_TRIGGER, 20 | MAIN_LIQUIDATION_TRIGGER, 21 | CHAIN_NAME, 22 | IS_DEV, 23 | } from 'src/constants' 24 | import Logger from 'src/logger' 25 | import { TxConfig } from 'src/types/TxConfig' 26 | import { BlockHeader } from 'web3-eth' 27 | import { 28 | parseJoinExit, 29 | parseBuyout, 30 | parseLiquidationTrigger, 31 | encodeLiquidationTriggerWithProof, 32 | getProof, 33 | getAllCdpsData, 34 | getTriggerLiquidationSignature, 35 | } from 'src/utils' 36 | import { Log } from 'web3-core/types' 37 | import { Broker } from 'src/broker' 38 | import { SynchronizerState } from 'src/services/statemanager' 39 | import NotificationService from 'src/services/notification' 40 | 41 | declare interface SynchronizationService { 42 | on(event: string, listener: Function): this; 43 | emit(event: string, payload: any): boolean; 44 | } 45 | 46 | class SynchronizationService extends EventEmitter { 47 | private readonly web3: Web3 48 | private readonly logger 49 | private lastLiquidationCheck: number 50 | private lastProcessedBlock: number 51 | private readonly broker: Broker 52 | private readonly notificator: NotificationService 53 | 54 | constructor(web3, broker: Broker, appState: SynchronizerState, notificator: NotificationService) { 55 | super(); 56 | this.lastLiquidationCheck = 0 57 | this.lastProcessedBlock = 0 58 | this.web3 = web3 59 | this.broker = broker 60 | this.notificator = notificator 61 | this.logger = Logger(SynchronizationService.name) 62 | this.fetchInitialData(appState) 63 | } 64 | 65 | async fetchInitialData(state: SynchronizerState) { 66 | 67 | console.time('Fetched in') 68 | let currentBlock 69 | try { 70 | this.log('Connecting to the rpc...') 71 | currentBlock = await Promise.race([this.web3.eth.getBlockNumber(), timeout(5_000)]) 72 | if (!currentBlock) { 73 | this.logError('Timeout'); 74 | process.exit() 75 | } 76 | } catch (e) { this.logError('broken RPC'); process.exit() } 77 | 78 | try { 79 | this.lastProcessedBlock = +state.lastProcessedBlock 80 | this.lastLiquidationCheck = +state.lastLiquidationCheck 81 | } catch (e) { 82 | this.logError(`load state error: ${e.toString()}`) 83 | } 84 | 85 | this.log(`Fetching initial data lastProcessedBlock: ${this.lastProcessedBlock} lastLiquidationCheck: ${this.lastLiquidationCheck}`) 86 | 87 | await this.loadMissedEvents(this.lastProcessedBlock) 88 | this.setLastProcessedBlock(currentBlock) 89 | 90 | console.timeEnd('Fetched in') 91 | this.log('Tracking events...') 92 | this.emit('ready', this) 93 | await this.logOnline(`Started ${CHAIN_NAME} in ${IS_DEV?'devel':'production'} mode`) 94 | this.trackEvents() 95 | } 96 | 97 | private trackEvents() { 98 | 99 | this.web3.eth.subscribe("newBlockHeaders", (error, event) => { 100 | event && this.emit(SYNCHRONIZER_NEW_BLOCK_EVENT, event) 101 | }) 102 | 103 | } 104 | 105 | public async syncToBlock(header: BlockHeader) { 106 | // multiplier 1.1 not to run sync in one block with liquidation checks (to save rate limit) 107 | if (+header.number < this.lastProcessedBlock + BLOCKS_CHECK_DELAY * 1.1) 108 | return 109 | 110 | const toBlock = header.number 111 | if (this.lastProcessedBlock >= toBlock) return 112 | 113 | const fromBlock = this.lastProcessedBlock - 1000; 114 | 115 | let promises = [] 116 | 117 | ACTIVE_VAULT_MANAGERS.forEach((address: string) => { 118 | 119 | promises.push(this.web3.eth.getPastLogs({ 120 | address, 121 | fromBlock, 122 | toBlock, 123 | topics: JOIN_TOPICS 124 | }, (error, logs ) => { 125 | if (!this.checkPastLogsResult(error, logs)) 126 | return 127 | logs.forEach(log => { 128 | if (!error) { 129 | this.emit(SYNCHRONIZER_JOIN_EVENT, parseJoinExit(log)) 130 | } else { 131 | this.logError(error) 132 | } 133 | }) 134 | })) 135 | 136 | promises.push(this.web3.eth.getPastLogs({ 137 | address, 138 | fromBlock, 139 | toBlock, 140 | topics: EXIT_TOPICS 141 | }, (error, logs ) => { 142 | if (!this.checkPastLogsResult(error, logs)) 143 | return 144 | logs.forEach(log => { 145 | if (!error) { 146 | const exit = parseJoinExit(log) 147 | this.emit(SYNCHRONIZER_EXIT_EVENT, exit) 148 | } else { 149 | this.logError(error) 150 | } 151 | }) 152 | })) 153 | 154 | promises.push(this.web3.eth.getPastLogs({ 155 | address, 156 | fromBlock, 157 | toBlock, 158 | topics: LIQUIDATION_TRIGGERED_TOPICS, 159 | }, (error, logs ) =>{ 160 | if (!this.checkPastLogsResult(error, logs)) 161 | return 162 | logs.forEach(log => { 163 | if (!error) { 164 | this.emit(SYNCHRONIZER_LIQUIDATION_TRIGGERED_EVENT, parseLiquidationTrigger(log)) 165 | } else { 166 | this.logError(error) 167 | } 168 | }) 169 | })) 170 | 171 | }); 172 | 173 | AUCTIONS.forEach((address) => { 174 | 175 | promises.push(this.web3.eth.getPastLogs({ 176 | address, 177 | fromBlock, 178 | toBlock, 179 | topics: BUYOUT_TOPICS, 180 | }, (error, logs ) => { 181 | if (!this.checkPastLogsResult(error, logs)) 182 | return 183 | logs.forEach(log => { 184 | if (!error) { 185 | this.emit(SYNCHRONIZER_LIQUIDATED_EVENT, parseBuyout(log)) 186 | } else { 187 | this.logError(error) 188 | } 189 | }) 190 | })) 191 | 192 | }); 193 | 194 | await Promise.all(promises); 195 | 196 | this.setLastProcessedBlock(toBlock) 197 | } 198 | 199 | async checkLiquidatable(header: BlockHeader) { 200 | if (+header.number < this.lastLiquidationCheck + BLOCKS_CHECK_DELAY) 201 | return 202 | 203 | this.setLastLiquidationCheck(+header.number) 204 | const timeStart = new Date().getTime() 205 | const positions: Map = await getAllCdpsData(header.number) 206 | const triggerPromises = [] 207 | const txConfigs: TxConfig[] = [] 208 | const txConfigBuilders = {} 209 | 210 | let ignorePositions = new Set() 211 | if (!!(process.env.IGNORE_POSITIONS)) 212 | ignorePositions = new Set(process.env.IGNORE_POSITIONS.split(",").map(x => x.toLowerCase())) 213 | 214 | let skipped = 0 215 | for (const [key, position] of positions.entries()) { 216 | if (!position) { 217 | this.logError(`checkLiquidatable empty position ${position} ${key}`) 218 | skipped++ 219 | continue 220 | } 221 | if (ignorePositions.has(`${position.asset}:${position.owner}`.toLowerCase())) { 222 | skipped++ 223 | continue 224 | } 225 | if (position.liquidationBlock !== 0) { 226 | skipped++ 227 | continue 228 | } 229 | if (!position.isDebtsEnoughForLiquidationSpends) { 230 | skipped++ 231 | continue 232 | } 233 | 234 | let tx: TxConfig; 235 | const configId = txConfigs.length 236 | if (position.isFallback) { 237 | const buildTx = async (blockNumber: number): Promise => { 238 | const proof = await getProof(position.asset, position.oracleType, blockNumber) 239 | return { 240 | to: FALLBACK_LIQUIDATION_TRIGGER, 241 | data: encodeLiquidationTriggerWithProof(position.asset, position.owner, proof), 242 | from: process.env.ETHEREUM_ADDRESS, 243 | key 244 | } 245 | } 246 | 247 | tx = await buildTx(+header.number); 248 | txConfigBuilders[configId] = buildTx; 249 | } else { 250 | tx = { 251 | to: MAIN_LIQUIDATION_TRIGGER, 252 | data: getTriggerLiquidationSignature(position), 253 | from: process.env.ETHEREUM_ADDRESS, 254 | key, 255 | } 256 | } 257 | 258 | txConfigs.push(tx) 259 | triggerPromises.push(this.web3.eth.estimateGas(tx).catch((e) => { 260 | if (SynchronizationService.isSuspiciousError(e.toString())) { 261 | if (SynchronizationService.isConnectionError(String(e))) { 262 | process.exit(1); 263 | } 264 | this.alarm(e.toString()) 265 | this.alarm(txConfigs[configId]) 266 | } 267 | })) 268 | } 269 | 270 | const estimatedGas = await Promise.all(triggerPromises) 271 | 272 | const timeEnd = new Date().getTime() 273 | await this.logOnline(`Checked ${txConfigs.length - Object.keys(txConfigBuilders).length} + ${Object.keys(txConfigBuilders).length} fb CDPs (skipped: ${skipped}) on block ${header.number} ${header.hash} in ${timeEnd - timeStart}ms`) 274 | 275 | estimatedGas.forEach((gas, i) => { 276 | // during synchronization the node may respond with tx data to non-contract address 277 | // so check gas limit to prevent incorrect behaviour 278 | if (gas && +gas > 30_000) { 279 | const tx = txConfigs[i] 280 | tx.gas = gas as number 281 | this.emit(SYNCHRONIZER_TRIGGER_LIQUIDATION_EVENT, { tx, blockNumber: +header.number, buildTx: txConfigBuilders[i] }) 282 | } 283 | }) 284 | } 285 | 286 | private async loadMissedEvents(lastSyncedBlock) { 287 | 288 | if (!lastSyncedBlock) // otherwise we will spam to the pulse with all events from the beginning 289 | return 290 | 291 | this.log('Loading missed events...') 292 | 293 | const fromBlock = lastSyncedBlock + 1 294 | 295 | const joinPromises = [] 296 | const exitPromises = [] 297 | const triggerPromises = [] 298 | const liquidationPromises = [] 299 | 300 | ACTIVE_VAULT_MANAGERS.forEach((address: string) => { 301 | 302 | joinPromises.push(this.web3.eth.getPastLogs({ 303 | fromBlock, 304 | address, 305 | topics: JOIN_TOPICS 306 | })) 307 | 308 | exitPromises.push(this.web3.eth.getPastLogs({ 309 | fromBlock, 310 | address, 311 | topics: EXIT_TOPICS 312 | })) 313 | 314 | triggerPromises.push(this.web3.eth.getPastLogs({ 315 | fromBlock, 316 | address, 317 | topics: LIQUIDATION_TRIGGERED_TOPICS, 318 | })) 319 | 320 | }); 321 | 322 | AUCTIONS.forEach((address) => { 323 | 324 | liquidationPromises.push(this.web3.eth.getPastLogs({ 325 | fromBlock, 326 | address, 327 | topics: BUYOUT_TOPICS, 328 | })) 329 | 330 | }); 331 | 332 | const notifications = [] 333 | const joins = (await Promise.all(joinPromises)).reduce((acc, curr) => [...acc, ...curr], []) 334 | joins.forEach((log: Log) => { 335 | notifications.push({ time: log.blockNumber, args: [SYNCHRONIZER_JOIN_EVENT, parseJoinExit(log as Log)] }) 336 | }) 337 | 338 | const exits = (await Promise.all(exitPromises)).reduce((acc, curr) => [...acc, ...curr], []) 339 | exits.forEach(log => { 340 | notifications.push({ time: log.blockNumber, args: [SYNCHRONIZER_EXIT_EVENT, parseJoinExit(log as Log)] }) 341 | }) 342 | 343 | const triggers = (await Promise.all(triggerPromises)).reduce((acc, curr) => [...acc, ...curr], []) 344 | triggers.forEach(log => { 345 | notifications.push({ time: log.blockNumber, args: [SYNCHRONIZER_LIQUIDATION_TRIGGERED_EVENT, parseLiquidationTrigger(log as Log)] }) 346 | }) 347 | 348 | const liquidations = (await Promise.all(liquidationPromises)).reduce((acc, curr) => [...acc, ...curr], []) 349 | liquidations.forEach(log => { 350 | notifications.push({ time: log.blockNumber, args: [SYNCHRONIZER_LIQUIDATED_EVENT, parseBuyout(log as Log)] }) 351 | }) 352 | 353 | notifications.sort((a, b) => a.time > b.time ? 1 : -1) 354 | 355 | for (const { args } of notifications) { 356 | await this.broker[args[0]](args[1]); 357 | } 358 | 359 | } 360 | 361 | private static isSuspiciousError(errMsg) { 362 | const legitMsgs = ['SAFE_POSITION', 'LIQUIDATING_POSITION'] 363 | 364 | // in some networks hex representation of error is returned (gnosis) 365 | let decodedErrorMsg = null; 366 | try { 367 | let decodedErrorMsgGroups = errMsg.match(/Reverted 0x([0-9a-f]+)/) 368 | decodedErrorMsg = decodedErrorMsgGroups ? Buffer.from(decodedErrorMsgGroups[1], 'hex').toString() : null; 369 | } catch (error) {} 370 | 371 | for (const legitMsg of legitMsgs) { 372 | if (errMsg.includes(legitMsg) || (decodedErrorMsg && decodedErrorMsg.includes(legitMsg))) 373 | return false 374 | } 375 | return true 376 | } 377 | 378 | private static isConnectionError(errMsg) { 379 | return errMsg.includes('CONNECTION ERROR') 380 | } 381 | 382 | private getAppState(): SynchronizerState { 383 | return { 384 | lastProcessedBlock: this.lastProcessedBlock, 385 | lastLiquidationCheck: this.lastLiquidationCheck 386 | } 387 | } 388 | 389 | private setLastLiquidationCheck(value) { 390 | if (value <= this.lastLiquidationCheck) return 391 | this.lastLiquidationCheck = value 392 | this.saveState() 393 | } 394 | 395 | private setLastProcessedBlock(value) { 396 | if (value <= this.lastProcessedBlock) return 397 | this.lastProcessedBlock = value 398 | this.saveState() 399 | } 400 | 401 | private saveState() { 402 | this.emit(SYNCHRONIZER_SAVE_STATE_REQUEST, this.getAppState()) 403 | } 404 | 405 | private log(...args) { 406 | this.logger.info(args) 407 | } 408 | 409 | private logOnline(...args) { 410 | this.logger.info(args) 411 | // return this.notificator.logAction(this.logger.format(args, true)) 412 | } 413 | 414 | private logError(...args) { 415 | this.logger.error(args) 416 | } 417 | 418 | private alarm(...args) { 419 | return this.notificator.logAlarm(this.logger.format(args, true)) 420 | } 421 | 422 | private checkPastLogsResult(error, logs): boolean { 423 | if (typeof logs !== 'undefined') 424 | return true 425 | 426 | if (!!error) 427 | this.logError(error) 428 | return false 429 | } 430 | } 431 | 432 | function timeout(ms) { 433 | return new Promise(resolve => setTimeout(resolve, ms)); 434 | } 435 | 436 | export default SynchronizationService 437 | -------------------------------------------------------------------------------- /src/types/BasicEvent.d.ts: -------------------------------------------------------------------------------- 1 | export type BasicEvent = { 2 | blockNumber: number, 3 | blockHash: string, 4 | txHash: string, 5 | txIndex: number, 6 | logIndex: number, 7 | } 8 | -------------------------------------------------------------------------------- /src/types/Buyout.d.ts: -------------------------------------------------------------------------------- 1 | import { BasicEvent } from 'src/types/BasicEvent' 2 | 3 | export interface Buyout extends BasicEvent { 4 | token: string, 5 | owner: string, 6 | liquidator: string, 7 | amount: bigint, 8 | penalty: bigint, 9 | price: bigint, 10 | } 11 | -------------------------------------------------------------------------------- /src/types/JoinExit.d.ts: -------------------------------------------------------------------------------- 1 | import { BasicEvent } from 'src/types/BasicEvent' 2 | 3 | export interface JoinExit extends BasicEvent { 4 | token: string, 5 | user: string, 6 | main: bigint, 7 | usdp: bigint, 8 | } 9 | -------------------------------------------------------------------------------- /src/types/LiquidationTrigger.d.ts: -------------------------------------------------------------------------------- 1 | import { BasicEvent } from 'src/types/BasicEvent' 2 | 3 | export interface LiquidationTrigger extends BasicEvent { 4 | token: string, 5 | user: string, 6 | } 7 | -------------------------------------------------------------------------------- /src/types/Position.d.ts: -------------------------------------------------------------------------------- 1 | export type CDP = { 2 | asset: string, 3 | owner: string, 4 | isDebtsEnoughForLiquidationSpends: boolean, 5 | oracleType: number, 6 | isFallback: boolean, 7 | liquidationTrigger: string, 8 | liquidationBlock: number 9 | } 10 | -------------------------------------------------------------------------------- /src/types/Transfer.d.ts: -------------------------------------------------------------------------------- 1 | import { BasicEvent } from 'src/types/BasicEvent' 2 | 3 | export interface Transfer extends BasicEvent { 4 | to: string, 5 | amount: bigint, 6 | } 7 | -------------------------------------------------------------------------------- /src/types/TxConfig.d.ts: -------------------------------------------------------------------------------- 1 | export type TxConfig = { 2 | to: string, 3 | data: string, 4 | key: string, 5 | from: string, 6 | gas?: number, 7 | txHash?: string, 8 | sentAt?: number, 9 | nonce?: number, 10 | } 11 | 12 | export interface Liquidation { 13 | tx: TxConfig 14 | blockNumber: number 15 | buildTx?: (blockNumber: number) => TxConfig 16 | } -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | import {Log} from 'web3-core/types' 2 | import {JoinExit} from 'src/types/JoinExit' 3 | import {Transfer} from 'src/types/Transfer' 4 | import {LiquidationTrigger} from 'src/types/LiquidationTrigger' 5 | import { 6 | CDP_REGISTRY, 7 | FALLBACK_LIQUIDATION_TRIGGER, 8 | LIQUIDATION_DEBT_THRESHOLD, 9 | LIQUIDATION_DEBT_THRESHOLD_KEYDONIX, 10 | MAIN_LIQUIDATION_TRIGGER, 11 | ORACLE_REGISTRY, 12 | VAULT_ADDRESS, 13 | WETH, 14 | } from 'src/constants' 15 | import { Buyout } from 'src/types/Buyout' 16 | import { web3 } from 'src/provider' 17 | import { 18 | getLPAddressByOracle, 19 | getMerkleProof, getMerkleProofForLp, 20 | lookbackBlocks, 21 | ORACLE_TYPES 22 | } from 'src/utils/oracle' 23 | import {CDP} from "src/types/Position"; 24 | import BigNumber from "bignumber.js"; 25 | 26 | export function parseJoinExit(event: Log): JoinExit { 27 | const token = topicToAddr(event.topics[1]) 28 | const user = topicToAddr(event.topics[2]) 29 | event.data = event.data.substr(2) 30 | const main = hexToBN(event.data.substr(0, 64)) 31 | let usdp: bigint 32 | usdp = hexToBN(event.data.substr(64, 64)) 33 | 34 | const txHash = event.transactionHash 35 | return { 36 | token, 37 | user, 38 | main, 39 | usdp, 40 | txHash, 41 | blockHash: event.blockHash, 42 | blockNumber: event.blockNumber, 43 | logIndex: event.logIndex, 44 | txIndex: event.transactionIndex, 45 | } 46 | } 47 | 48 | export function parseLiquidationTrigger(event: Log): LiquidationTrigger { 49 | const token = topicToAddr(event.topics[1]) 50 | const user = topicToAddr(event.topics[2]) 51 | const txHash = event.transactionHash 52 | return { 53 | token, 54 | user, 55 | txHash, 56 | logIndex: event.logIndex, 57 | blockNumber: event.blockNumber, 58 | txIndex: event.transactionIndex, 59 | blockHash: event.blockHash, 60 | } 61 | } 62 | 63 | export function parseBuyout(event: Log): Buyout { 64 | const token = topicToAddr(event.topics[1]) 65 | const owner = topicToAddr(event.topics[2]) 66 | const liquidator = topicToAddr(event.topics[3]) 67 | event.data = event.data.substr(2) 68 | const amount = hexToBN(event.data.substr(0, 64)) 69 | const price = hexToBN(event.data.substr(64, 64)) 70 | const penalty = hexToBN(event.data.substr(128, 64)) 71 | const txHash = event.transactionHash 72 | return { 73 | token, 74 | owner, 75 | liquidator, 76 | amount, 77 | price, 78 | penalty, 79 | txHash, 80 | logIndex: event.logIndex, 81 | blockNumber: event.blockNumber, 82 | txIndex: event.transactionIndex, 83 | blockHash: event.blockHash, 84 | } 85 | } 86 | 87 | export function parseTransfer(event: Log): Transfer { 88 | const to = topicToAddr(event.topics[2]) 89 | event.data = event.data.substr(2) 90 | const amount = hexToBN(event.data.substr(0, 64)) 91 | const txHash = event.transactionHash 92 | return { 93 | to, 94 | amount, 95 | txHash, 96 | logIndex: event.logIndex, 97 | blockNumber: event.blockNumber, 98 | txIndex: event.transactionIndex, 99 | blockHash: event.blockHash, 100 | } 101 | } 102 | 103 | export function topicToAddr(topic) { 104 | return '0x' + topic.substr(26) 105 | } 106 | 107 | export function hexToBN(str) { 108 | return BigInt('0x' + str) 109 | } 110 | 111 | export function formatNumber(x: number) { 112 | if (x >= 1_000_000) { 113 | return `${Math.floor(x / 10_000) / 100}M` 114 | } 115 | if (x >= 1_000) { 116 | return `${Math.floor(x / 10) / 100}K` 117 | } 118 | if (x >= 1) { 119 | return `${Math.floor(x * 100) / 100}` 120 | } 121 | 122 | if (x < 0.0001) { 123 | return Number(x).toFixed(22).replace(/\.?0+$/, "") 124 | } 125 | 126 | return `${Math.floor(x * 10_000) / 10_000}` 127 | } 128 | 129 | export async function getTokenDecimals(token: string) : Promise { 130 | const decimalsSignature = web3.eth.abi.encodeFunctionSignature({ 131 | name: 'decimals', 132 | type: 'function', 133 | inputs: [] 134 | }) 135 | 136 | try { 137 | return Number(web3.eth.abi.decodeParameter('uint8', await web3.eth.call({ 138 | to: token, 139 | data: decimalsSignature 140 | }))) 141 | } catch (e) { 142 | return 18 143 | } 144 | } 145 | 146 | export async function tryFetchPrice(token: string, amount: bigint, decimals: number) : Promise { 147 | 148 | const blockNumber = await web3.eth.getBlockNumber(); 149 | const oracleType = await getOracleType(token) 150 | const oracleAddress = await getOracleAddress(token) 151 | const keydonixOracleTypes = await getKeydonixOracleTypes(); 152 | 153 | try { 154 | if ( keydonixOracleTypes.includes(oracleType) ) { 155 | return '$' + formatNumber( await tryFetchKeydonixPrice(token, amount, decimals, oracleType, oracleAddress, blockNumber) ); 156 | } 157 | 158 | return '$' + formatNumber( await getPriceFromOracle(oracleAddress, token, decimals, amount) ); 159 | } catch(error) { 160 | return 'unknown price'; 161 | } 162 | } 163 | 164 | export async function tryFetchKeydonixPrice(token: string, amount: bigint, decimals: number, oracleType: number, oracleAddress: string, blockNumber: number) : Promise { 165 | const proof = await getProof(token, oracleType, blockNumber); 166 | 167 | return await getPriceFromOracleKeydonix(oracleAddress, token, proof, decimals, amount); 168 | } 169 | 170 | // todo use abi for calls 171 | async function _getTokenSymbol(token: string) { 172 | const symbolSignature = web3.eth.abi.encodeFunctionSignature({ 173 | name: 'symbol', 174 | type: 'function', 175 | inputs: [] 176 | }) 177 | 178 | try { 179 | const symbolRaw = await web3.eth.call({ 180 | to: token, 181 | data: symbolSignature 182 | }) 183 | return parseSymbol(symbolRaw) 184 | } catch (e) { 185 | return token 186 | } 187 | } 188 | 189 | export function encodeLiquidationTriggerWithProof(asset: string, owner: string, proof: [string, string, string, string]) { 190 | return web3.eth.abi.encodeFunctionCall({ 191 | type: 'function', 192 | name: 'triggerLiquidation', 193 | inputs: [{ 194 | type: 'address', 195 | name: 'asset', 196 | }, { 197 | type: 'address', 198 | name: 'owner', 199 | }, { 200 | type: 'tuple', 201 | name: 'proof', 202 | components: [{ 203 | type: 'bytes', 204 | name: 'block', 205 | }, { 206 | type: 'bytes', 207 | name: 'accountProofNodesRlp', 208 | }, { 209 | type: 'bytes', 210 | name: 'reserveAndTimestampProofNodesRlp', 211 | }, { 212 | type: 'bytes', 213 | name: 'priceAccumulatorProofNodesRlp', 214 | }] 215 | }] 216 | }, 217 | [asset, owner, proof] 218 | ); 219 | } 220 | 221 | export async function getOracleType(token: string): Promise { 222 | const oracleTypeByAssetSignature = web3.eth.abi.encodeFunctionCall({ 223 | name: 'oracleTypeByAsset', 224 | type: 'function', 225 | inputs: [{ 226 | type: 'address', 227 | name: 'asset' 228 | }] 229 | }, [token]) 230 | 231 | try { 232 | const typeRaw = await web3.eth.call({ 233 | to: ORACLE_REGISTRY, 234 | data: oracleTypeByAssetSignature 235 | }) 236 | return Number(web3.eth.abi.decodeParameter('uint', typeRaw)) 237 | } catch (e) { 238 | return 0 239 | } 240 | } 241 | 242 | export async function getOracleAddress(token: string): Promise { 243 | const oracleTypeSignature = web3.eth.abi.encodeFunctionCall({ 244 | name: 'oracleByAsset', 245 | type: 'function', 246 | inputs: [{ 247 | type: 'address', 248 | name: 'asset' 249 | }] 250 | }, [token]) 251 | 252 | try { 253 | const typeRaw = await web3.eth.call({ 254 | to: ORACLE_REGISTRY, 255 | data: oracleTypeSignature 256 | }) 257 | return web3.eth.abi.decodeParameter('address', typeRaw).toString() 258 | } catch (e) { 259 | return null 260 | } 261 | } 262 | 263 | export async function getPriceFromOracle(oracle: string, token: string, decimals: number, amount: bigint): Promise { 264 | const signature = web3.eth.abi.encodeFunctionCall({ 265 | name: 'assetToUsd', 266 | type: 'function', 267 | inputs: [{ 268 | type: 'address', 269 | name: 'asset' 270 | },{ 271 | type: 'uint256', 272 | name: 'amount' 273 | }] 274 | }, [token, amount]) 275 | 276 | try { 277 | const typeRaw = await web3.eth.call({ 278 | to: oracle, 279 | data: signature 280 | }) 281 | 282 | return Number(BigInt(web3.eth.abi.decodeParameter('uint256', typeRaw)) / BigInt(2)**BigInt(112)) / 10**decimals; 283 | } catch (e) { 284 | return null 285 | } 286 | } 287 | 288 | export async function getPriceFromOracleKeydonix(oracle: string, token: string, proofData, decimals: number, amount: bigint): Promise { 289 | const signature = web3.eth.abi.encodeFunctionCall({ 290 | "inputs": [ 291 | { 292 | "internalType": "address", 293 | "name": "asset", 294 | "type": "address" 295 | }, 296 | { 297 | "internalType": "uint256", 298 | "name": "amount", 299 | "type": "uint256" 300 | }, 301 | { 302 | "components": [ 303 | { 304 | "internalType": "bytes", 305 | "name": "block", 306 | "type": "bytes" 307 | }, 308 | { 309 | "internalType": "bytes", 310 | "name": "accountProofNodesRlp", 311 | "type": "bytes" 312 | }, 313 | { 314 | "internalType": "bytes", 315 | "name": "reserveAndTimestampProofNodesRlp", 316 | "type": "bytes" 317 | }, 318 | { 319 | "internalType": "bytes", 320 | "name": "priceAccumulatorProofNodesRlp", 321 | "type": "bytes" 322 | } 323 | ], 324 | "internalType": "struct KeydonixOracleAbstract.ProofDataStruct", 325 | "name": "proofData", 326 | "type": "tuple" 327 | } 328 | ], 329 | "name": "assetToUsd", 330 | "outputs": [ 331 | { 332 | "internalType": "uint256", 333 | "name": "", 334 | "type": "uint256" 335 | } 336 | ], 337 | "stateMutability": "view", 338 | "type": "function" 339 | }, [token, amount, proofData]) 340 | 341 | try { 342 | const typeRaw = await web3.eth.call({ 343 | to: oracle, 344 | data: signature 345 | }) 346 | 347 | return Number(BigInt(web3.eth.abi.decodeParameter('uint256', typeRaw)) / BigInt(2)**BigInt(112)) / 10**decimals; 348 | } catch (e) { 349 | return null 350 | } 351 | } 352 | 353 | export async function getLiquidationBlock(asset: string, owner: string): Promise { 354 | const sig = web3.eth.abi.encodeFunctionCall({ 355 | name: 'liquidationBlock', 356 | type: 'function', 357 | inputs: [{ 358 | type: 'address', 359 | name: 'asset' 360 | }, { 361 | type: 'address', 362 | name: 'owner' 363 | }] 364 | }, [asset, owner]) 365 | 366 | try { 367 | const raw = await web3.eth.call({ 368 | to: VAULT_ADDRESS, 369 | data: sig 370 | }) 371 | return Number(web3.eth.abi.decodeParameter('uint', raw)) 372 | } catch (e) { 373 | return 0 374 | } 375 | } 376 | 377 | export async function getProof(token: string, oracleType: ORACLE_TYPES, blockNumber: number): Promise<[string, string, string,string]> { 378 | 379 | const proofBlockNumber = blockNumber - lookbackBlocks 380 | const denominationToken = BigInt(WETH) 381 | switch (oracleType) { 382 | case ORACLE_TYPES.KEYDONIX_WRAPPED: 383 | return getProofForWrapped(token, blockNumber) 384 | case ORACLE_TYPES.KEYDONIX_LP: 385 | return getMerkleProofForLp(token, BigInt(proofBlockNumber)) 386 | case ORACLE_TYPES.KEYDONIX_UNI: 387 | case ORACLE_TYPES.KEYDONIX_SUSHI: 388 | case ORACLE_TYPES.KEYDONIX_SHIBA: 389 | return getMerkleProof(BigInt(getLPAddressByOracle(oracleType, token, WETH)), denominationToken, BigInt(proofBlockNumber)) 390 | default: 391 | throw new Error(`Incorrect keydonix oracle type: ${oracleType}`) 392 | } 393 | } 394 | 395 | export async function getProofForWrapped(token: string, blockNumber: number): Promise<[string, string, string,string]> { 396 | const wrappedOracle = await getOracleAddress(token) 397 | const underlying = await getUnderlyingToken(wrappedOracle, token); 398 | const oracleType = await getOracleType(underlying); 399 | 400 | return getProof(underlying, oracleType, blockNumber); 401 | } 402 | 403 | export async function getKeydonixOracleTypes(): Promise { 404 | const sig = web3.eth.abi.encodeFunctionCall({ 405 | name: 'getKeydonixOracleTypes', 406 | type: 'function', 407 | inputs: [] 408 | }, []) 409 | 410 | try { 411 | const raw = await web3.eth.call({ 412 | to: ORACLE_REGISTRY, 413 | data: sig 414 | }) 415 | return web3.eth.abi.decodeParameter('uint[]', raw).map(Number) 416 | } catch (e) { 417 | return [] 418 | } 419 | } 420 | 421 | export async function getUnderlyingToken(oracleAddress: string, asset: string): Promise { 422 | const sig = web3.eth.abi.encodeFunctionCall({ 423 | name: 'assetToUnderlying', 424 | type: 'function', 425 | inputs: [{ 426 | type: 'address', 427 | name: 'asset' 428 | }] 429 | }, [asset]) 430 | 431 | try { 432 | const raw = await web3.eth.call({ 433 | to: oracleAddress, 434 | data: sig 435 | }) 436 | return String(web3.eth.abi.decodeParameter('address', raw)) 437 | 438 | } catch (e) { 439 | return '0x0000000000000000000000000000000000000000' 440 | } 441 | } 442 | 443 | export async function getTotalDebt(asset: string, owner: string): Promise { 444 | const sig = web3.eth.abi.encodeFunctionCall({ 445 | name: 'getTotalDebt', 446 | type: 'function', 447 | inputs: [{ 448 | type: 'address', 449 | name: 'asset' 450 | }, { 451 | type: 'address', 452 | name: 'owner' 453 | }] 454 | }, [asset, owner]) 455 | 456 | try { 457 | const raw = await web3.eth.call({ 458 | to: VAULT_ADDRESS, 459 | data: sig 460 | }) 461 | return BigInt(web3.eth.abi.decodeParameter('uint', raw)) 462 | } catch (e) { 463 | return 0n 464 | } 465 | } 466 | 467 | export async function getCollateralAmount(asset: string, owner: string): Promise { 468 | const sig = web3.eth.abi.encodeFunctionCall({ 469 | name: 'collaterals', 470 | type: 'function', 471 | inputs: [{ 472 | type: 'address', 473 | name: 'asset' 474 | }, { 475 | type: 'address', 476 | name: 'owner' 477 | }] 478 | }, [asset, owner]) 479 | 480 | try { 481 | const raw = await web3.eth.call({ 482 | to: VAULT_ADDRESS, 483 | data: sig 484 | }) 485 | return BigInt(web3.eth.abi.decodeParameter('uint', raw)) 486 | } catch (e) { 487 | return 0n 488 | } 489 | } 490 | 491 | export async function getReserves(pool: string): Promise<[bigint, bigint, bigint]> { 492 | const sig = web3.eth.abi.encodeFunctionCall({ 493 | name: 'getReserves', 494 | type: 'function', 495 | inputs: [] 496 | }, []) 497 | 498 | try { 499 | const raw = await web3.eth.call({ 500 | to: pool, 501 | data: sig 502 | }) 503 | const res = web3.eth.abi.decodeParameters(['uint112', 'uint112', 'uint32'], raw) 504 | return [res[0], res[1], res[2]].map(BigInt) as [bigint, bigint, bigint] 505 | } catch (e) { 506 | return [0n, 0n, 0n] 507 | } 508 | } 509 | 510 | export async function getToken0(pool: string): Promise { 511 | const sig = web3.eth.abi.encodeFunctionCall({ 512 | name: 'token0', 513 | type: 'function', 514 | inputs: [] 515 | }, []) 516 | 517 | try { 518 | const raw = await web3.eth.call({ 519 | to: pool, 520 | data: sig 521 | }) 522 | return String(web3.eth.abi.decodeParameter('address', raw)) 523 | 524 | } catch (e) { 525 | return '0x0000000000000000000000000000000000000000' 526 | } 527 | } 528 | 529 | export async function getToken1(pool: string): Promise { 530 | const sig = web3.eth.abi.encodeFunctionCall({ 531 | name: 'token1', 532 | type: 'function', 533 | inputs: [] 534 | }, []) 535 | 536 | try { 537 | const raw = await web3.eth.call({ 538 | to: pool, 539 | data: sig 540 | }) 541 | return String(web3.eth.abi.decodeParameter('address', raw)) 542 | 543 | } catch (e) { 544 | return '0x0000000000000000000000000000000000000000' 545 | } 546 | } 547 | 548 | export async function getTokenSymbol(token: string) { 549 | 550 | let symbol = await _getTokenSymbol(token) 551 | 552 | try { 553 | if (['UNI-V2', 'SLP'].includes(symbol)) { 554 | const token0 = web3.eth.abi.decodeParameter('address', await web3.eth.call({ 555 | to: token, 556 | data: '0x0dfe1681' 557 | })) as string 558 | 559 | const token1 = web3.eth.abi.decodeParameter('address', await web3.eth.call({ 560 | to: token, 561 | data: '0xd21220a7' 562 | })) as string 563 | 564 | const symbol0 = await _getTokenSymbol(token0) 565 | const symbol1 = await _getTokenSymbol(token1) 566 | 567 | symbol += ` ${symbol0}-${symbol1}` 568 | } 569 | } catch (e) { } 570 | 571 | return symbol 572 | 573 | } 574 | 575 | function parseSymbol(hex): string { 576 | try { 577 | return web3.eth.abi.decodeParameter('string', hex) as string 578 | } catch (e) { 579 | return web3.utils.toUtf8(hex) 580 | } 581 | } 582 | 583 | export async function getLiquidationFee(asset: string, owner: string): Promise { 584 | const sig = web3.eth.abi.encodeFunctionCall({ 585 | name: 'liquidationFee', 586 | type: 'function', 587 | inputs: [{ 588 | type: 'address', 589 | name: 'asset' 590 | }, { 591 | type: 'address', 592 | name: 'owner' 593 | }] 594 | }, [asset, owner]) 595 | 596 | try { 597 | const raw = await web3.eth.call({ 598 | to: VAULT_ADDRESS, 599 | data: sig 600 | }) 601 | return BigInt(web3.eth.abi.decodeParameter('uint', raw)) 602 | } catch (e) { 603 | return 0n 604 | } 605 | } 606 | 607 | export async function getAllCdps (blockNumber: number): Promise<{asset: string, owner: string}[]> { 608 | const sig = web3.eth.abi.encodeFunctionCall({ 609 | "inputs": [], 610 | "name": "getAllCdps", 611 | "stateMutability": "view", 612 | "type": "function" 613 | }, []) 614 | const raw = await web3.eth.call({ 615 | to: CDP_REGISTRY, 616 | data: sig 617 | }, blockNumber) 618 | return web3.eth.abi.decodeParameter({ 619 | "components": [ 620 | { 621 | "internalType": "address", 622 | "name": "asset", 623 | "type": "address" 624 | }, 625 | { 626 | "internalType": "address", 627 | "name": "owner", 628 | "type": "address" 629 | } 630 | ], 631 | "internalType": "struct CDPRegistry.CDP[]", 632 | "name": "r", 633 | "type": "tuple[]" 634 | }, raw).map((x) => ({owner: x['owner'], asset: x['asset']})) 635 | } 636 | 637 | export async function getAllCdpsData (blockNumber: number): Promise> { 638 | console.time(`getAllCdpsData in ${blockNumber}`) 639 | const cdps = await getAllCdps(blockNumber) 640 | const assets = [...(new Set(cdps.map(cdp => cdp.asset)))] 641 | const oracles = await Promise.all(assets.map(getOracleType)) 642 | const assetToOracleTypeMap = {} 643 | for (const [idx, asset] of assets.entries()) 644 | assetToOracleTypeMap[asset] = oracles[idx] 645 | 646 | const keydonixOracleTypes = new Set([...await getKeydonixOracleTypes(), 0]) 647 | 648 | const positions: CDP[] = await Promise.all( 649 | cdps.map( 650 | async (cdp) => { 651 | const isKeydonix = keydonixOracleTypes.has(assetToOracleTypeMap[cdp.asset]) 652 | const totalDebt = (new BigNumber((await getTotalDebt(cdp.asset, cdp.owner)).toString())).div(10**18) 653 | const debtThreshold = isKeydonix ? LIQUIDATION_DEBT_THRESHOLD_KEYDONIX : LIQUIDATION_DEBT_THRESHOLD 654 | return { 655 | ...cdp, 656 | isDebtsEnoughForLiquidationSpends: totalDebt.gte(debtThreshold), 657 | oracleType: assetToOracleTypeMap[cdp.asset], 658 | isFallback: isKeydonix, 659 | liquidationTrigger: isKeydonix ? FALLBACK_LIQUIDATION_TRIGGER : MAIN_LIQUIDATION_TRIGGER, 660 | liquidationBlock: await getLiquidationBlock(cdp.asset, cdp.owner) 661 | } as CDP 662 | } 663 | ) 664 | ) 665 | 666 | const result = new Map() 667 | for (const cdp of positions) 668 | result.set(`${cdp.asset}_${cdp.owner}`, cdp) 669 | 670 | console.timeEnd(`getAllCdpsData in ${blockNumber}`) 671 | return result 672 | } 673 | 674 | export function getTriggerLiquidationSignature(position: CDP): string { 675 | return web3.eth.abi.encodeFunctionCall({ 676 | name: 'triggerLiquidation', 677 | type: 'function', 678 | inputs: [{ 679 | type: 'address', 680 | name: 'asset' 681 | }, { 682 | type: 'address', 683 | name: 'owner' 684 | }] 685 | }, [position.asset, position.owner] 686 | ) 687 | } 688 | -------------------------------------------------------------------------------- /src/utils/oracle.ts: -------------------------------------------------------------------------------- 1 | import { web3, web3Proof } from 'src/provider' 2 | import { Pair, Token } from '@uniswap/sdk' 3 | import { getCreate2Address } from '@ethersproject/address' 4 | import { keccak256, pack } from '@ethersproject/solidity' 5 | import { 6 | SHIBASWAP_FACTORY, 7 | SHIBASWAP_PAIR_INIT_CODE_HASH, 8 | SUSHISWAP_FACTORY, 9 | SUSHISWAP_PAIR_INIT_CODE_HASH, 10 | WETH 11 | } from 'src/constants' 12 | import { 13 | getOracleType, 14 | getToken0, 15 | getToken1, 16 | } from 'src/utils/index' 17 | 18 | const Q112 = BigInt('0x10000000000000000000000000000') 19 | export const lookbackBlocks = 119 20 | 21 | const ethUsdDenominator = 10n ** 8n 22 | 23 | export enum ORACLE_TYPES { 24 | KEYDONIX_UNI = 1, 25 | KEYDONIX_LP = 2, 26 | WRAPPED = 11, 27 | KEYDONIX_SUSHI = 13, 28 | KEYDONIX_SHIBA = 18, 29 | KEYDONIX_WRAPPED = 19, 30 | } 31 | 32 | export async function _getProof(address: bigint, positions: readonly bigint[], block: bigint) { 33 | const encodedAddress = bigintToHexAddress(address) 34 | const encodedPositions = positions.map(bigintToHexQuantity) 35 | const encodedBlockTag = bigintToHexQuantity(block) 36 | 37 | const result: any = await web3Proof.eth.getProof(encodedAddress, encodedPositions, encodedBlockTag) 38 | const accountProof = result.accountProof.map(entry => { 39 | return stringToByteArray(entry) 40 | }) 41 | 42 | const storageProof = result.storageProof.map(entry => { 43 | return { 44 | key: BigInt(entry.key), 45 | value: BigInt(entry.key), 46 | proof: entry.proof.map(proofEntry => { 47 | return stringToByteArray(proofEntry) 48 | }), 49 | } 50 | }) 51 | return { accountProof, storageProof } 52 | } 53 | 54 | function bigintToHexAddress(value): string { 55 | if (typeof value === 'string') return value 56 | return `0x${value.toString(16).padStart(40, '0')}` 57 | } 58 | 59 | function bigintToHexQuantity(value: bigint): string { 60 | return `0x${value.toString(16)}` 61 | } 62 | 63 | function stringToByteArray(hex: string): Uint8Array { 64 | const match = /^(?:0x)?([a-fA-F0-9]*)$/.exec(hex) 65 | if (match === null) 66 | throw new Error(`Expected a hex string encoded byte array with an optional '0x' prefix but received ${hex}`) 67 | const normalized = match[1] 68 | if (normalized.length % 2) throw new Error(`Hex string encoded byte array must be an even number of charcaters long.`) 69 | const bytes: Array = [] 70 | for (let i = 0; i < normalized.length; i += 2) { 71 | const n = parseInt(`${normalized[i]}${normalized[i + 1]}`, 16) 72 | bytes.push(n) 73 | } 74 | return new Uint8Array(bytes) 75 | } 76 | 77 | export async function getMerkleProofForLp( 78 | exchangeAddress: string, 79 | blockNumber: bigint, 80 | ): Promise<[string, string, string, string]> { 81 | const [token0Address, token1Address] = await Promise.all([ 82 | getToken0(exchangeAddress), 83 | getToken1(exchangeAddress), 84 | ]) 85 | 86 | let token; 87 | if (token0Address.toLowerCase() === WETH.toLowerCase()) { 88 | token = token1Address.toLowerCase(); 89 | } else if (token1Address.toLowerCase() === WETH.toLowerCase()) { 90 | token = token0Address.toLowerCase(); 91 | } else { 92 | throw new Error(`Unsupported pair ${token0Address} ${token1Address}`) 93 | } 94 | 95 | const oracleType = await getOracleType(token); 96 | 97 | return getMerkleProof(BigInt(getLPAddressByOracle(oracleType, token, WETH)), BigInt(WETH), blockNumber) 98 | } 99 | 100 | export async function getMerkleProof( 101 | exchangeAddress: bigint, 102 | denominationToken: bigint, 103 | blockNumber: bigint, 104 | ): Promise<[string, string, string, string]> { 105 | const [token0Address, token1Address, block] = await Promise.all([ 106 | getStorageAt(exchangeAddress, 6n, 'latest'), 107 | getStorageAt(exchangeAddress, 7n, 'latest'), 108 | getBlockByNumber(blockNumber), 109 | ]) 110 | 111 | if (denominationToken !== token0Address && denominationToken !== token1Address) 112 | throw new Error( 113 | `Denomination token ${addressToString( 114 | denominationToken, 115 | )} is not one of the two tokens for the Uniswap exchange at ${addressToString(exchangeAddress)}`, 116 | ) 117 | const priceAccumulatorSlot = denominationToken === token0Address ? 10n : 9n 118 | const proof = await _getProof(exchangeAddress, [8n, priceAccumulatorSlot], blockNumber) 119 | if (block === null) throw new Error(`Received null for block ${Number(blockNumber)}`) 120 | const blockRlp = rlpEncodeBlock(block) 121 | const accountProofNodesRlp = rlpEncode(proof.accountProof.map(rlpDecode)) 122 | const reserveAndTimestampProofNodesRlp = rlpEncode(proof.storageProof[0].proof.map(rlpDecode)) 123 | const priceAccumulatorProofNodesRlp = rlpEncode(proof.storageProof[1].proof.map(rlpDecode)) 124 | 125 | return [ 126 | `0x${bufferToHex(blockRlp)}`, 127 | `0x${bufferToHex(accountProofNodesRlp)}`, 128 | `0x${bufferToHex(reserveAndTimestampProofNodesRlp)}`, 129 | `0x${bufferToHex(priceAccumulatorProofNodesRlp)}` 130 | ] 131 | } 132 | 133 | 134 | function bufferToHex(buffer: Uint8Array) { 135 | return [...new Uint8Array(buffer)].map(b => b.toString(16).padStart(2, '0')).join('') 136 | } 137 | 138 | function addressToString(value: bigint) { 139 | return `0x${value.toString(16).padStart(40, '0')}` 140 | } 141 | 142 | function rlpEncodeBlock(block) { 143 | return rlpEncode([ 144 | unsignedIntegerToUint8Array(block.parentHash, 32), 145 | unsignedIntegerToUint8Array(block.sha3Uncles, 32), 146 | unsignedIntegerToUint8Array(block.miner, 20), 147 | unsignedIntegerToUint8Array(block.stateRoot, 32), 148 | unsignedIntegerToUint8Array(block.transactionsRoot, 32), 149 | unsignedIntegerToUint8Array(block.receiptsRoot, 32), 150 | unsignedIntegerToUint8Array(block.logsBloom, 256), 151 | stripLeadingZeros(unsignedIntegerToUint8Array(block.difficulty, 32)), 152 | stripLeadingZeros(unsignedIntegerToUint8Array(block.number, 32)), 153 | stripLeadingZeros(unsignedIntegerToUint8Array(block.gasLimit, 32)), 154 | stripLeadingZeros(unsignedIntegerToUint8Array(block.gasUsed, 32)), 155 | stripLeadingZeros(unsignedIntegerToUint8Array(block.timestamp, 32)), 156 | stripLeadingZeros(block.extraData), 157 | ...(block.mixHash !== undefined ? [unsignedIntegerToUint8Array(block.mixHash, 32)] : []), 158 | ...(block.nonce !== null && block.nonce !== undefined ? [unsignedIntegerToUint8Array(block.nonce, 8)] : []), 159 | ...(block.baseFeePerGas ? [stripLeadingZeros(unsignedIntegerToUint8Array(block.baseFeePerGas, 32))] : []), 160 | ]) 161 | } 162 | 163 | function rlpEncode(item): Uint8Array { 164 | if (item instanceof Uint8Array) { 165 | return rlpEncodeItem(item) 166 | } 167 | if (Array.isArray(item)) { 168 | return rlpEncodeList(item) 169 | } 170 | throw new Error( 171 | `Can only RLP encode Uint8Arrays (items) and arrays (lists). Please encode your item into a Uint8Array first.\nType: ${typeof item}\n${item}`, 172 | ) 173 | } 174 | 175 | function unsignedIntegerToUint8Array(value: bigint | number, widthInBytes: 8 | 20 | 32 | 256 = 32) { 176 | if (typeof value === 'number') { 177 | if (!Number.isSafeInteger(value)) throw new Error(`${value} is not able to safely be cast into a bigint.`) 178 | value = BigInt(value) 179 | } 180 | if (value >= BigInt(`0x1${'00'.repeat(widthInBytes)}`) || value < 0n) 181 | throw new Error(`Cannot fit ${value} into a ${widthInBytes * 8}-bit unsigned integer.`) 182 | const result = new Uint8Array(widthInBytes) 183 | if (result.length !== widthInBytes) 184 | throw new Error(`Cannot a ${widthInBytes} value into a ${result.length} byte array.`) 185 | for (let i = 0; i < result.length; ++i) { 186 | // eslint-disable-next-line no-bitwise 187 | result[i] = Number((value >> BigInt((widthInBytes - i) * 8 - 8)) & 0xffn) 188 | } 189 | return result 190 | } 191 | 192 | function stripLeadingZeros(byteArray: Uint8Array): Uint8Array { 193 | let i = 0 194 | for (; i < byteArray.length; ++i) { 195 | if (byteArray[i] !== 0) break 196 | } 197 | const result = new Uint8Array(byteArray.length - i) 198 | for (let j = 0; j < result.length; ++j) { 199 | result[j] = byteArray[i + j] 200 | } 201 | return result 202 | } 203 | 204 | function rlpEncodeItem(data: Uint8Array): Uint8Array { 205 | if (data.length === 1 && data[0] < 0x80) return rlpEncodeTiny(data) 206 | if (data.length <= 55) return rlpEncodeSmall(data) 207 | return rlpEncodeLarge(data) 208 | } 209 | 210 | function rlpEncodeTiny(data: Uint8Array): Uint8Array { 211 | if (data.length > 1) throw new Error(`rlpEncodeTiny can only encode single byte values.`) 212 | if (data[0] > 0x80) throw new Error(`rlpEncodeTiny can only encode values less than 0x80`) 213 | return data 214 | } 215 | 216 | function rlpEncodeSmall(data: Uint8Array): Uint8Array { 217 | if (data.length === 1 && data[0] < 0x80) throw new Error(`rlpEncodeSmall can only encode a value > 0x7f`) 218 | if (data.length > 55) throw new Error(`rlpEncodeSmall can only encode data that is <= 55 bytes long`) 219 | const result = new Uint8Array(data.length + 1) 220 | result[0] = 0x80 + data.length 221 | result.set(data, 1) 222 | return result 223 | } 224 | 225 | function rlpEncodeLarge(data: Uint8Array): Uint8Array { 226 | if (data.length <= 55) throw new Error(`rlpEncodeLarge can only encode data that is > 55 bytes long`) 227 | const lengthBytes = hexStringToUint8Array(data.length.toString(16)) 228 | const result = new Uint8Array(data.length + lengthBytes.length + 1) 229 | result[0] = 0xb7 + lengthBytes.length 230 | result.set(lengthBytes, 1) 231 | result.set(data, 1 + lengthBytes.length) 232 | return result 233 | } 234 | 235 | function hexStringToUint8Array(hex: string): Uint8Array { 236 | const match = new RegExp(`^(?:0x)?([a-fA-F0-9]*)$`).exec(hex) 237 | if (match === null) 238 | throw new Error(`Expected a hex string encoded byte array with an optional '0x' prefix but received ${hex}`) 239 | const maybeLeadingZero = match[1].length % 2 ? '0' : '' 240 | const normalized = `${maybeLeadingZero}${match[1]}` 241 | const byteLength = normalized.length / 2 242 | const bytes = new Uint8Array(byteLength) 243 | for (let i = 0; i < byteLength; ++i) { 244 | bytes[i] = Number.parseInt(`${normalized[i * 2]}${normalized[i * 2 + 1]}`, 16) 245 | } 246 | return bytes 247 | } 248 | 249 | function rlpEncodeList(items): Uint8Array { 250 | const encodedItems = items.map(rlpEncode) 251 | const encodedItemsLength = encodedItems.reduce((total, item) => total + item.length, 0) 252 | if (encodedItemsLength <= 55) { 253 | const result = new Uint8Array(encodedItemsLength + 1) 254 | result[0] = 0xc0 + encodedItemsLength 255 | let offset = 1 256 | // eslint-disable-next-line no-restricted-syntax 257 | for (const encodedItem of encodedItems) { 258 | result.set(encodedItem, offset) 259 | offset += encodedItem.length 260 | } 261 | return result 262 | } 263 | const lengthBytes = hexStringToUint8Array(encodedItemsLength.toString(16)) 264 | const result = new Uint8Array(1 + lengthBytes.length + encodedItemsLength) 265 | result[0] = 0xf7 + lengthBytes.length 266 | result.set(lengthBytes, 1) 267 | let offset = 1 + lengthBytes.length 268 | // eslint-disable-next-line no-restricted-syntax 269 | for (const encodedItem of encodedItems) { 270 | result.set(encodedItem, offset) 271 | offset += encodedItem.length 272 | } 273 | return result 274 | } 275 | 276 | function rlpDecode(data: Uint8Array) { 277 | return rlpDecodeItem(data).decoded 278 | } 279 | 280 | function rlpDecodeItem(data: Uint8Array): { decoded; consumed: number } { 281 | if (data.length === 0) throw new Error(`Cannot RLP decode a 0-length byte array.`) 282 | if (data[0] <= 0x7f) { 283 | const consumed = 1 284 | const decoded = data.slice(0, consumed) 285 | return { decoded, consumed } 286 | } 287 | if (data[0] <= 0xb7) { 288 | const byteLength = data[0] - 0x80 289 | if (byteLength > data.length - 1) 290 | throw new Error(`Encoded data length (${byteLength}) is larger than remaining data (${data.length - 1}).`) 291 | const consumed = 1 + byteLength 292 | const decoded = data.slice(1, consumed) 293 | if (byteLength === 1 && decoded[0] <= 0x7f) 294 | throw new Error(`A tiny value (${decoded[0].toString(16)}) was found encoded as a small value (> 0x7f).`) 295 | return { decoded, consumed } 296 | } 297 | if (data[0] <= 0xbf) { 298 | const lengthBytesLength = data[0] - 0xb7 299 | if (lengthBytesLength > data.length - 1) 300 | throw new Error( 301 | `Encoded length of data length (${lengthBytesLength}) is larger than the remaining data (${data.length - 1})`, 302 | ) 303 | // the conversion to Number here is lossy, but we throw on the following line in that case so "meh" 304 | const length = decodeLength(data, 1, lengthBytesLength) 305 | if (length > data.length - 1 - lengthBytesLength) 306 | throw new Error( 307 | `Encoded data length (${length}) is larger than the remaining data (${data.length - 1 - lengthBytesLength})`, 308 | ) 309 | const consumed = 1 + lengthBytesLength + length 310 | const decoded = data.slice(1 + lengthBytesLength, consumed) 311 | if (length <= 0x37) throw new Error(`A small value (<= 55 bytes) was found encoded in a large value (> 55 bytes)`) 312 | return { decoded, consumed } 313 | } 314 | if (data[0] <= 0xf7) { 315 | const length = data[0] - 0xc0 316 | if (length > data.length - 1) 317 | throw new Error(`Encoded array length (${length}) is larger than remaining data (${data.length - 1}).`) 318 | let offset = 1 319 | const results = [] 320 | while (offset !== length + 1) { 321 | const { decoded, consumed } = rlpDecodeItem(data.slice(offset)) 322 | results.push(decoded) 323 | offset += consumed 324 | if (offset > length + 1) 325 | throw new Error( 326 | `Encoded array length (${length}) doesn't align with the sum of the lengths of the encoded elements (${offset})`, 327 | ) 328 | } 329 | return { decoded: results, consumed: offset } 330 | } 331 | const lengthBytesLength = data[0] - 0xf7 332 | // the conversion to Number here is lossy, but we throw on the following line in that case so "meh" 333 | const length = decodeLength(data, 1, lengthBytesLength) 334 | if (length > data.length - 1 - lengthBytesLength) 335 | throw new Error( 336 | `Encoded array length (${length}) is larger than the remaining data (${data.length - 1 - lengthBytesLength})`, 337 | ) 338 | let offset = 1 + lengthBytesLength 339 | const results = [] 340 | while (offset !== length + 1 + lengthBytesLength) { 341 | const { decoded, consumed } = rlpDecodeItem(data.slice(offset)) 342 | results.push(decoded) 343 | offset += consumed 344 | if (offset > length + 1 + lengthBytesLength) 345 | throw new Error( 346 | `Encoded array length (${length}) doesn't align with the sum of the lengths of the encoded elements (${offset})`, 347 | ) 348 | } 349 | return { decoded: results, consumed: offset } 350 | } 351 | 352 | function decodeLength(data: Uint8Array, offset: number, lengthBytesLength: number): number { 353 | const lengthBytes = data.slice(offset, offset + lengthBytesLength) 354 | let length = 0 355 | if (lengthBytes.length >= 1) length = lengthBytes[0] 356 | // eslint-disable-next-line no-bitwise 357 | if (lengthBytes.length >= 2) length = (length << 8) | lengthBytes[1] 358 | // eslint-disable-next-line no-bitwise 359 | if (lengthBytes.length >= 3) length = (length << 8) | lengthBytes[2] 360 | // eslint-disable-next-line no-bitwise 361 | if (lengthBytes.length >= 4) length = (length << 8) | lengthBytes[3] 362 | if (lengthBytes.length >= 5) throw new Error(`Unable to decode RLP item or array with a length larger than 2**32`) 363 | return length 364 | } 365 | 366 | async function getStorageAt(address: bigint, position: bigint, block: bigint | 'latest') { 367 | const encodedAddress = bigintToHexAddress(address) 368 | const encodedPosition = bigintToHexQuantity(position) 369 | const encodedBlockTag = block === 'latest' ? 'latest' : bigintToHexQuantity(block) 370 | 371 | const result = await web3.eth.getStorageAt(encodedAddress, encodedPosition, encodedBlockTag) 372 | if (typeof result !== 'string') { 373 | throw new Error(`Expected eth_getStorageAt to return a string but instead returned a ${typeof result}`) 374 | } 375 | return BigInt(result) 376 | } 377 | 378 | async function getBlockByNumber(blockNumber: bigint | string) { 379 | const block: any = await web3.eth.getBlock(blockNumber.toString()) 380 | 381 | return { 382 | parentHash: stringToBigint(block.parentHash), 383 | sha3Uncles: stringToBigint(block.sha3Uncles), 384 | miner: stringToBigint(block.miner), 385 | stateRoot: stringToBigint(block.stateRoot), 386 | transactionsRoot: stringToBigint(block.transactionsRoot), 387 | receiptsRoot: stringToBigint(block.receiptsRoot), 388 | logsBloom: stringToBigint(block.logsBloom), 389 | difficulty: BigInt(block.difficulty), 390 | number: BigInt(block.number), 391 | gasLimit: BigInt(block.gasLimit), 392 | gasUsed: BigInt(block.gasUsed), 393 | timestamp: BigInt(block.timestamp), 394 | extraData: stringToByteArray(block.extraData), 395 | mixHash: stringToBigint(block.mixHash), 396 | nonce: stringToBigint(block.nonce), 397 | baseFeePerGas: block.baseFeePerGas ? BigInt(block.baseFeePerGas) : null, 398 | } 399 | } 400 | 401 | function stringToBigint(hex: string): bigint { 402 | const match = /^(?:0x)?([a-fA-F0-9]*)$/.exec(hex) 403 | if (match === null) 404 | throw new Error(`Expected a hex string encoded number with an optional '0x' prefix but received ${hex}`) 405 | const normalized = match[1] 406 | return BigInt(`0x${normalized}`) 407 | } 408 | 409 | export function getLPAddressByOracle(oracleType: ORACLE_TYPES, asset1: string, asset2: string): string { 410 | switch (oracleType) { 411 | case ORACLE_TYPES.KEYDONIX_UNI: 412 | return uniLPAddress(asset1, asset2); 413 | case ORACLE_TYPES.KEYDONIX_SUSHI: 414 | return sushiLPAddress(asset1, asset2) 415 | case ORACLE_TYPES.KEYDONIX_SHIBA: 416 | return shibaLPAddress(asset1, asset2) 417 | default: 418 | throw new Error(`Incorrect keydonix oracle type: ${oracleType}`) 419 | } 420 | } 421 | 422 | // todo: refactor this 423 | export function uniLPAddress(asset1: string, asset2: string): string { 424 | return Pair.getAddress( 425 | new Token(1, web3.utils.toChecksumAddress(asset1), 18), 426 | new Token(1, web3.utils.toChecksumAddress(asset2), 18), 427 | ).toLowerCase() 428 | } 429 | 430 | export function sushiLPAddress(asset1: string, asset2: string): string { 431 | return getSushiSwapAddress( 432 | new Token(1, web3.utils.toChecksumAddress(asset1), 18), 433 | new Token(1, web3.utils.toChecksumAddress(asset2), 18), 434 | ).toLowerCase() 435 | } 436 | 437 | export function shibaLPAddress(asset1: string, asset2: string): string { 438 | return getShibaSwapAddress( 439 | new Token(1, web3.utils.toChecksumAddress(asset1), 18), 440 | new Token(1, web3.utils.toChecksumAddress(asset2), 18), 441 | ).toLowerCase() 442 | } 443 | 444 | // https://docs.uniswap.org/sdk/2.0.0/guides/getting-pair-addresses 445 | // initCodeHash = keccak256(init code of pair) 446 | // init code of pair could be got from etherscan, for example for sushi pair https://etherscan.io/address/0x06da0fd433C1A5d7a4faa01111c044910A184553#code 447 | // keccak256(Contract Creation Code from etherscan) = 0xe18a34eb0e04b04f7a0ac29a6e80748dca96319b42c54d679cb821dca90c6303 448 | function getSushiSwapAddress(tokenA: Token, tokenB: Token): string { 449 | const tokens = tokenA.sortsBefore(tokenB) ? [tokenA, tokenB] : [tokenB, tokenA] // does safety checks 450 | 451 | return getCreate2Address( 452 | SUSHISWAP_FACTORY, 453 | keccak256(['bytes'], [pack(['address', 'address'], [tokens[0].address, tokens[1].address])]), 454 | SUSHISWAP_PAIR_INIT_CODE_HASH 455 | ) 456 | } 457 | 458 | function getShibaSwapAddress(tokenA: Token, tokenB: Token): string { 459 | const tokens = tokenA.sortsBefore(tokenB) ? [tokenA, tokenB] : [tokenB, tokenA] // does safety checks 460 | 461 | return getCreate2Address( 462 | SHIBASWAP_FACTORY, 463 | keccak256(['bytes'], [pack(['address', 'address'], [tokens[0].address, tokens[1].address])]), 464 | SHIBASWAP_PAIR_INIT_CODE_HASH 465 | ) 466 | } 467 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2020", 4 | "baseUrl": ".", 5 | "rootDir": "src", 6 | "paths": { 7 | "src/*": [ 8 | "src/*", 9 | ] 10 | }, 11 | "module": "commonjs", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "noImplicitAny": false, 15 | "strict": true, 16 | "strictNullChecks":false, 17 | "lib": [ 18 | "dom", 19 | "dom.iterable", 20 | "esnext" 21 | ], 22 | "skipLibCheck": true, 23 | "outDir": "dist", 24 | "esModuleInterop": true, 25 | "allowSyntheticDefaultImports": true, 26 | "forceConsistentCasingInFileNames": true, 27 | "isolatedModules": true 28 | }, 29 | "include": [ 30 | "src" 31 | ] 32 | } 33 | --------------------------------------------------------------------------------