├── .editorconfig ├── .eslintignore ├── .eslintrc.json ├── .gitignore ├── .huskyrc ├── Jenkinsfile ├── LICENSE ├── MonitorApp.js ├── MonitorClient.js ├── README.md ├── examples ├── basicExample.js ├── package-lock.json └── package.json ├── index.js ├── package-lock.json ├── package.json ├── reference.md └── test └── MonitorClient.test.js /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 4 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.json] 12 | charset = utf-8 13 | indent_style = space 14 | indent_size = 2 15 | end_of_line = lf 16 | insert_final_newline = true 17 | trim_trailing_whitespace = true 18 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amilabs/eth-bulk-monitor-client-nodejs/e3c70d35d6af1468511cf7f19931a5a6d3f75c5e/.eslintignore -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["airbnb"], 3 | "parser": "babel-eslint", 4 | "env": { 5 | "browser": true, 6 | "node": true 7 | }, 8 | "rules": { 9 | "global-require": "off", 10 | "no-console": "warn", 11 | "comma-dangle": "off", 12 | "no-await-in-loop": "off", 13 | "no-underscore-dangle": "off", 14 | "no-param-reassign": "off", 15 | "class-methods-use-this": "off", 16 | "no-mixed-operators": "off", 17 | "no-plusplus": "off", 18 | "indent": ["error", 4], 19 | "no-alert": "error", 20 | "max-len": ["error", { 21 | "code": 120, 22 | "ignoreComments": true 23 | }] 24 | } 25 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | -------------------------------------------------------------------------------- /.huskyrc: -------------------------------------------------------------------------------- 1 | { 2 | "hooks": { 3 | "pre-commit": "npm run lint-quiet" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /Jenkinsfile: -------------------------------------------------------------------------------- 1 | pipeline { 2 | agent { label "node12" } 3 | options { disableConcurrentBuilds() } 4 | stages { 5 | stage("Checkout") { 6 | steps { 7 | cleanWs() 8 | checkout([$class: 'GitSCM', branches: [[name: '$BRANCH_NAME']], doGenerateSubmoduleConfigurations: false, extensions: [], submoduleCfg: [], userRemoteConfigs: [[credentialsId: '83ff6dc5-45b4-4996-b383-e1f225203f3c', url: 'git@github.com:amilabs/eth-bulk-monitor-client-nodejs.git']]]) 9 | } 10 | } 11 | stage("Run tests") { 12 | steps { 13 | script{ 14 | sh "npm i --save-dev" 15 | sh "npx mocha --exit --reporter mocha-junit-reporter test/" 16 | junit "test-results.xml" 17 | } 18 | } 19 | } 20 | stage("Publish") { 21 | when { tag 'v*' } 22 | steps { 23 | script{ 24 | withCredentials([string(credentialsId: 'amilabs-npm-token', variable: 'NPM_TOKEN')]) { 25 | sh "git reset --hard" 26 | sh "echo //registry.npmjs.org/:_authToken=${env.NPM_TOKEN} > .npmrc" 27 | sh "echo email=jenkins@amilabs.pro >> .npmrc" 28 | sh "echo always-auth=true >> .npmrc" 29 | sh "npm publish" 30 | } 31 | } 32 | } 33 | } 34 | } 35 | } 36 | 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /MonitorApp.js: -------------------------------------------------------------------------------- 1 | const MonitorClient = require('./MonitorClient'); 2 | 3 | const path = require('path'); 4 | const fs = require('fs'); 5 | const os = require('os'); 6 | 7 | // Error messages 8 | const errorMessages = { 9 | no_api_key: 'No API Key specified' 10 | }; 11 | 12 | // Watching flag 13 | let watching = false; 14 | 15 | // Initialization flag 16 | let initialized = false; 17 | 18 | class MonitorApp { 19 | /** 20 | * Constructor has the same params as the monitorClient class constructor. 21 | * 22 | * @param {string} apiKey 23 | * @param {object} options 24 | * @returns {MonitorApp} 25 | */ 26 | constructor(apiKey, options = {}) { 27 | if (!apiKey) { 28 | throw new Error(errorMessages.no_api_key); 29 | } 30 | 31 | this.monitor = new MonitorClient(apiKey, options); 32 | 33 | this.state = { 34 | poolId: false 35 | }; 36 | 37 | this.options = { 38 | // Temporary file to save watching state 39 | tmpFile: path.join(os.tmpdir(), 'monitorAppState.tmp'), 40 | ...options 41 | }; 42 | 43 | this.restoreState(); 44 | } 45 | 46 | /** 47 | * Initializes the app and adds addresses to the pool. 48 | * 49 | * @param {array} addresses 50 | */ 51 | async init(addresses) { 52 | if (initialized) return; 53 | if (this.state.poolId === false) { 54 | // Create a new pool 55 | this.state.poolId = await this.monitor.createPool(); 56 | this.saveState(); 57 | } 58 | this.monitor.credentials.poolId = this.state.poolId; 59 | if (addresses && addresses.length) { 60 | this.monitor.addAddresses(addresses); 61 | } 62 | initialized = true; 63 | } 64 | 65 | /** 66 | * Saves the app state to a file 67 | */ 68 | saveState() { 69 | fs.writeFileSync(this.options.tmpFile, JSON.stringify(this.state)); 70 | } 71 | 72 | /** 73 | * Restores the app state from a file. 74 | */ 75 | restoreState() { 76 | if (fs.existsSync(this.options.tmpFile)) { 77 | const data = fs.readFileSync(this.options.tmpFile); 78 | this.state = JSON.parse(data); 79 | } 80 | } 81 | 82 | /** 83 | * Starts watching for addresses changes. 84 | * Will create a new pool if no poolId was stored in the watching state 85 | * 86 | * @param {function} callback 87 | */ 88 | async watch(callback) { 89 | if (watching === true) return; 90 | 91 | await this.init(); 92 | 93 | if (typeof (callback) === 'function') { 94 | this.monitor.on('data', callback); 95 | } 96 | this.monitor.on('stateChanged', () => this.saveState); 97 | 98 | this.monitor.watch(); 99 | watching = true; 100 | } 101 | 102 | /** 103 | * Stops watching and removes listeners 104 | */ 105 | async unwatch() { 106 | this.monitor.removeAllListeners(); 107 | this.monitor.unwatch(); 108 | watching = false; 109 | } 110 | } 111 | 112 | module.exports = MonitorApp; 113 | -------------------------------------------------------------------------------- /MonitorClient.js: -------------------------------------------------------------------------------- 1 | const EventEmitter = require('events'); 2 | const FormData = require('form-data'); 3 | const BigNumber = require('bignumber.js'); 4 | 5 | // Known networks 6 | const networks = { 7 | mainnet: { 8 | api: 'https://api.ethplorer.io', 9 | monitor: 'https://api-mon.ethplorer.io' 10 | }, 11 | kovan: { 12 | api: 'https://kovan-api.ethplorer.io', 13 | monitor: 'https://kovan-api-mon.ethplorer.io' 14 | }, 15 | custom: false 16 | }; 17 | 18 | // Error messages 19 | const errorMessages = { 20 | unkonwn_network: 'Unknown network', 21 | custom_api_uri: 'Custom network requires Ethplorer API uri to be set in options', 22 | custom_monitor_uri: 'Custom network requires Bulk API uri to be set in options', 23 | no_pool_id: 'No poolId specified: set poolId option or create a new pool using createPool method', 24 | invalid_state: 'Invalid state object', 25 | request_failed: 'Request failed:', 26 | unknown_method: 'Unknown API method', 27 | already_watching: 'Watching is already started, use unwatch first', 28 | err_get_updates: 'Can not get last pool updates', 29 | rq_unknown_method: 'Unknown request method', 30 | rq_unknown_driver: 'Unknown request driver' 31 | }; 32 | 33 | // Last unwatch event timestamp 34 | let lastUnwatchTs = 0; 35 | 36 | // Ethereum pseudo-token addess 37 | const ETHAddress = '0x0000000000000000000000000000000000000000'; 38 | 39 | // Events already emitted 40 | const eventsEmitted = {}; 41 | 42 | // Watching state 43 | const state = { 44 | lastBlock: 0, 45 | lastTs: 0, 46 | blocks: {} 47 | }; 48 | 49 | // Request library instance 50 | let rq = null; 51 | 52 | class MonitorClient extends EventEmitter { 53 | /** 54 | * Constructor. 55 | * 56 | * @param {string} apiKey 57 | * @param {object} options 58 | * @returns {MonitorClient} 59 | */ 60 | constructor(apiKey, options) { 61 | super(); 62 | this.options = { 63 | // Ethereum network 64 | network: 'mainnet', 65 | // Data request period (in seconds) 66 | period: 300, 67 | // How often to request updates (in seconds) 68 | interval: 60, 69 | // Maximum errors in a row to unwatch (0 for infinite) 70 | maxErrorCount: 0, 71 | // Number of cache lock checks 72 | cacheLockCheckLimit: 100, 73 | // Tokens cache lifetime (ms) 74 | tokensCacheLifeTime: 600000, 75 | // Request timeout (ms) 76 | requestTimeout: 30000, 77 | // Watch for failed transactions/operations 78 | watchFailed: false, 79 | // Request driver (got, axios) 80 | requestDriver: 'axios', 81 | // Override options 82 | ...options 83 | }; 84 | // Try to get poolId from options 85 | const poolId = this.options.poolId ? this.options.poolId : false; 86 | // Token data will be stored here 87 | this.tokensCache = {}; 88 | // Used to lock token cache 89 | this.tokensCacheLocks = {}; 90 | // API pool credentials: apiKey and poolId 91 | this.credentials = { apiKey, poolId }; 92 | // Configure network services 93 | if (!this.options.network || (networks[this.options.network] === undefined)) { 94 | throw new Error(`${errorMessages.unknown_network} ${this.options.network}`); 95 | } 96 | if (this.options.network !== 'custom') { 97 | this.options.api = networks[this.options.network].api; 98 | this.options.monitor = networks[this.options.network].monitor; 99 | } 100 | if (!this.options.api) { 101 | throw new Error(errorMessages.custom_api_uri); 102 | } 103 | if (!this.options.monitor) { 104 | throw new Error(errorMessages.custom_monitor_uri); 105 | } 106 | this.errors = 0; 107 | BigNumber.config({ ERRORS: false }); 108 | } 109 | 110 | /** 111 | * Returns current state. 112 | * 113 | * @returns {Promise} 114 | */ 115 | async saveState() { 116 | return state; 117 | } 118 | 119 | /** 120 | * Restores state from saved data. 121 | * 122 | * @param {Object} stateData 123 | */ 124 | restoreState(stateData) { 125 | if (!stateData || (stateData.lastBlock === undefined)) { 126 | throw new Error(errorMessages.invalid_state); 127 | } 128 | delete stateData.blocksTx; 129 | delete stateData.blocksOp; 130 | if (!stateData.blocks) { 131 | stateData.blocks = {}; 132 | } 133 | lastUnwatchTs = stateData.lastTs ? stateData.lastTs : 0; 134 | 135 | state.lastBlock = stateData.lastBlock; 136 | state.lastTs = stateData.lastTs; 137 | state.blocks = stateData.blocks; 138 | } 139 | 140 | /** 141 | * Checks if the block was already processed. 142 | * 143 | * @param {int} blockNumber 144 | * @returns {Boolean} 145 | */ 146 | isBlockProcessed(blockNumber) { 147 | return (state.lastBlock > blockNumber) || (state.blocks && state.blocks[blockNumber]); 148 | } 149 | 150 | /** 151 | * Creates a new pool. 152 | * 153 | * @param {string[]} addresses 154 | * @returns {Boolean|string} 155 | */ 156 | async createPool(addresses = []) { 157 | const result = await this.postBulkAPI('createPool', { addresses }); 158 | return result.poolId; 159 | } 160 | 161 | /** 162 | * Deletes current pool. 163 | * 164 | * @returns {Boolean} 165 | */ 166 | async deletePool() { 167 | await this.postBulkAPI('deletePool'); 168 | return true; 169 | } 170 | 171 | /** 172 | * Returns a list of addresses in the pool. 173 | * 174 | * @returns {Array} 175 | */ 176 | async getAddresses() { 177 | let result = []; 178 | const { apiKey, poolId } = this.credentials; 179 | const url = `${this.options.monitor}/getPoolAddresses/${poolId}?apiKey=${apiKey}`; 180 | try { 181 | const data = this.request('get', url); 182 | if (data && data.addresses && data.addresses.length) { 183 | result = data.addresses; 184 | } 185 | } catch (e) { 186 | throw new Error(`${url} ${errorMessages.request_failed} ${e.message}`); 187 | } 188 | return result; 189 | } 190 | 191 | /** 192 | * Adds addresses to the pool. 193 | * 194 | * @param {string[]} addresses 195 | * @returns {Boolean} 196 | */ 197 | async addAddresses(addresses) { 198 | let result = false; 199 | if (addresses && addresses.length) { 200 | await this.postBulkAPI('addPoolAddresses', { addresses }); 201 | result = true; 202 | } 203 | return result; 204 | } 205 | 206 | /** 207 | * Removes addresses from the pool. 208 | * 209 | * @param {string[]} addresses 210 | * @returns {Boolean} 211 | */ 212 | async removeAddresses(addresses) { 213 | let result = false; 214 | if (addresses && addresses.length) { 215 | await this.postBulkAPI('deletePoolAddresses', { addresses }); 216 | result = true; 217 | } 218 | return result; 219 | } 220 | 221 | /** 222 | * Removes all addresses from the pool. 223 | * 224 | * @returns {bool} 225 | */ 226 | async removeAllAddresses() { 227 | await this.postBulkAPI('clearPoolAddresses'); 228 | return true; 229 | } 230 | 231 | /** 232 | * Starts watching for address acitivity. 233 | * 234 | * @returns {Promise} 235 | */ 236 | watch() { 237 | if (!this.credentials.poolId) { 238 | throw new Error(errorMessages.no_pool_id); 239 | } 240 | if (this.watching) { 241 | throw new Error(errorMessages.already_watching); 242 | } 243 | this.watching = true; 244 | return (this.intervalHandler())().then(() => { 245 | this.emit('watched', null); 246 | return 'ok'; 247 | }); 248 | } 249 | 250 | /** 251 | * Stops watching for address activity. 252 | * 253 | * @returns {undefined} 254 | */ 255 | unwatch() { 256 | if (this.watching) { 257 | lastUnwatchTs = Date.now(); 258 | this.watching = false; 259 | this.emit('unwatched', null); 260 | } 261 | } 262 | 263 | /** 264 | * Handles the watching interval. 265 | * 266 | * @returns {undefined} 267 | */ 268 | intervalHandler() { 269 | return async () => { 270 | try { 271 | const dataEvents = []; 272 | if (!this.watching) return; 273 | const blocksToAdd = []; 274 | const updatesData = await this.getPoolUpdates(lastUnwatchTs); 275 | if (!updatesData) { 276 | throw new Error(errorMessages.err_get_updates); 277 | } 278 | const transactionsData = updatesData.transactions; 279 | const operationsData = updatesData.operations; 280 | if (transactionsData) { 281 | this.log('Processing transactions...'); 282 | const { rate } = await this.getToken(ETHAddress); 283 | Object.keys(transactionsData).forEach((address) => { 284 | const txData = transactionsData[address]; 285 | for (let i = 0; i < txData.length; i++) { 286 | const data = { ...txData[i], rate }; 287 | const skipFailed = (!this.options.watchFailed && !data.success); 288 | data.usdValue = parseFloat((data.value * rate).toFixed(2)); 289 | if (!skipFailed && data.blockNumber && !this.isBlockProcessed(data.blockNumber)) { 290 | if (this.watching) { 291 | const type = 'transaction'; 292 | const id = `${type}-${address}-${data.hash}`; 293 | if (eventsEmitted[id] === undefined) { 294 | blocksToAdd.push(data.blockNumber); 295 | dataEvents.push({ 296 | id, 297 | address, 298 | data, 299 | type 300 | }); 301 | } 302 | } 303 | } 304 | } 305 | }); 306 | } 307 | if (operationsData) { 308 | this.log('Processing operations...'); 309 | const addresses = Object.keys(operationsData); 310 | for (let j = 0; j < addresses.length; j++) { 311 | const address = addresses[j]; 312 | const opData = operationsData[address]; 313 | for (let i = 0; i < opData.length; i++) { 314 | const operation = opData[i]; 315 | const { blockNumber } = operation; 316 | const token = await this.getToken(operation.contract); 317 | const validOpType = (['approve'].indexOf(operation.type) < 0); 318 | if (blockNumber && !this.isBlockProcessed(blockNumber) && validOpType) { 319 | const data = { ...operation, token }; 320 | if (data.token && (data.token.decimals !== undefined)) { 321 | data.rawValue = data.value; 322 | const bn = (new BigNumber(data.value)).div(10 ** data.token.decimals); 323 | data.value = bn.toString(10); 324 | if (data.token.rate) { 325 | data.usdValue = parseFloat((parseFloat(data.value) * data.token.rate) 326 | .toFixed(2)); 327 | } 328 | } 329 | if (this.watching) { 330 | const type = 'operation'; 331 | const id = `${type}-${address}-${data.hash}-${data.priority}`; 332 | if (eventsEmitted[id] === undefined) { 333 | blocksToAdd.push(data.blockNumber); 334 | dataEvents.push({ 335 | id, 336 | address, 337 | data, 338 | type 339 | }); 340 | } 341 | } 342 | } 343 | } 344 | } 345 | } 346 | const lsb = updatesData.lastSolidBlock; 347 | const lsbChanged = (lsb && lsb.timestamp && (lsb.block > state.lastBlock)); 348 | if (lsbChanged || blocksToAdd.length) { 349 | if (blocksToAdd.length) { 350 | for (let i = 0; i < blocksToAdd.length; i++) { 351 | state.blocks[blocksToAdd[i]] = true; 352 | } 353 | } 354 | if (lsbChanged) { 355 | state.lastBlock = lsb.block; 356 | state.lastTs = lsb.timestamp; 357 | this.clearCachedBlocks(); 358 | } 359 | lastUnwatchTs = 0; 360 | setImmediate(() => this.emit('stateChanged', state)); 361 | } 362 | if (dataEvents.length > 0) { 363 | this.log(`Firing ${dataEvents.length} events...`); 364 | setImmediate(() => { 365 | for (let i = 0; i < dataEvents.length; i++) { 366 | const event = dataEvents[i]; 367 | eventsEmitted[event.id] = true; 368 | delete event.id; 369 | this.emit('data', event); 370 | } 371 | }); 372 | } else { 373 | this.log('No new events found'); 374 | } 375 | } catch (e) { 376 | this.errors++; 377 | setImmediate(() => this.emit('exception', e)); 378 | if ((this.options.maxErrorCount > 0) && (this.errors >= this.options.maxErrorCount)) { 379 | this.unwatch(); 380 | this.errors = 0; 381 | return; 382 | } 383 | } 384 | this.log(`Wait for ${this.options.interval} seconds before new updates check...`); 385 | setTimeout(this.intervalHandler(), this.options.interval * 1000); 386 | }; 387 | } 388 | 389 | /** 390 | * Returns token data by token address. 391 | * 392 | * @param {string} address 393 | * @returns {Object|bool} 394 | */ 395 | async getToken(address) { 396 | address = address.toLowerCase(); 397 | const unknownToken = { name: 'Unknown', symbol: 'Unknown', decimals: 0 }; 398 | if (this.tokensCacheLocks[address]) { 399 | // If cache locked then wait repeatedly 0.3s for unlock 400 | let lockCheckCount = 0; 401 | if (this.tokensCacheLocks[address]) { 402 | while (this.tokensCacheLocks[address]) { 403 | await this.sleep(100); 404 | lockCheckCount++; 405 | if (lockCheckCount >= this.options.cacheLockCheckLimit) { 406 | if (!this.tokensCache[address]) { 407 | this.emit('exception', `Error retrieving locked token ${address}, "Unknown" used`); 408 | } 409 | // Clear lock 410 | delete this.tokensCacheLocks[address]; 411 | return (this.tokensCache[address] && this.tokensCache[address].result) ? 412 | this.tokensCache[address].result : unknownToken; 413 | } 414 | } 415 | } 416 | } 417 | const cache = this.tokensCache[address]; 418 | if (cache === undefined || (Date.now() - cache.saveTs) > this.options.tokensCacheLifeTime) { 419 | this.tokensCacheLocks[address] = true; 420 | let result = false; 421 | const { apiKey } = this.credentials; 422 | let errorCount = 0; 423 | while (!result && errorCount < 3) { 424 | try { 425 | const requestUrl = `${this.options.api}/getTokenInfo/${address}?apiKey=${apiKey}`; 426 | const tokenData = await this.request('get', requestUrl); 427 | await this.sleep(100); 428 | if (tokenData) { 429 | this.log(`Token ${tokenData.name} successfully loaded`); 430 | const { name, symbol, decimals } = tokenData; 431 | const rate = tokenData.price && tokenData.price.rate ? tokenData.price.rate : false; 432 | result = { 433 | name, 434 | symbol, 435 | decimals, 436 | rate 437 | }; 438 | } else { 439 | this.log(`No data loaded for token ${address}`); 440 | errorCount++; 441 | await this.sleep(1000); 442 | } 443 | } catch (e) { 444 | if (e.response && (e.response.data || e.response.body)) { 445 | let json = false; 446 | try { 447 | json = e.response.data ? e.response.data : JSON.parse(e.response.body); 448 | } catch (jsonException) { 449 | // do nothing 450 | } 451 | if (json && json.error && json.error.code && json.error.code === 150) { 452 | this.log(`Address ${address} is not a token contract!`); 453 | delete this.tokensCacheLocks[address]; 454 | result = unknownToken; 455 | } 456 | } 457 | if (!result) { 458 | if (errorCount === 0) { 459 | this.emit('exception', e); 460 | } 461 | errorCount++; 462 | await this.sleep(1000); 463 | } 464 | } 465 | } 466 | 467 | delete this.tokensCacheLocks[address]; 468 | 469 | if (!result && !this.tokensCache[address]) { 470 | this.emit(`Cannot get token ${address} info after ${errorCount} attempts`); 471 | return unknownToken; 472 | } 473 | 474 | // Use previously cached value on error 475 | if (!result && this.tokensCache[address] && this.tokensCache[address].result) { 476 | this.tokensCache[address].saveTs = Date.now(); 477 | } else { 478 | this.tokensCache[address] = { result, saveTs: Date.now() }; 479 | } 480 | } 481 | return this.tokensCache[address].result; 482 | } 483 | 484 | /** 485 | * Parses data from Bulk API, checks for errors 486 | * 487 | * @param {object} data 488 | * @returns {Object|null} 489 | */ 490 | processBulkAPIData(data) { 491 | if (data && data.body) { 492 | const poolData = JSON.parse(data.body); 493 | if (poolData.error) { 494 | throw new Error(poolData.error.message); 495 | } 496 | return poolData; 497 | } 498 | return null; 499 | } 500 | 501 | /** 502 | * Asks Bulk API for updates 503 | * 504 | * @param {string} method 505 | * @param {int} startTime 506 | * @returns {Object|null} 507 | */ 508 | async getUpdates(method, startTime = 0) { 509 | if (['getPoolLastTransactions', 'getPoolLastOperations', 'getPoolUpdates'].indexOf(method) < 0) { 510 | throw new Error(`${errorMessages.unknown_method} ${method}`); 511 | } 512 | const promise = this._getUpdates(method, startTime); 513 | this.emit(method, promise); 514 | return promise; 515 | } 516 | 517 | /** 518 | * Utility function fot getUpdates 519 | * 520 | * @param {string} method 521 | * @param {int} startTime 522 | * @returns {Object|null} 523 | */ 524 | async _getUpdates(method, startTime = 0) { 525 | if (!this.credentials.poolId) { 526 | throw new Error(errorMessages.no_pool_id); 527 | } 528 | let result = null; 529 | if (startTime > 10000000000) startTime /= 1000; // JS ts protection; 530 | const now = Date.now() / 1000; 531 | const startTs = startTime ? Math.floor(now - startTime) : 0; 532 | const lastTs = state.lastTs ? Math.floor(now - state.lastTs) : 0; 533 | const period = Math.min(Math.max(this.options.period, startTs, lastTs), 360000); 534 | const { apiKey, poolId } = this.credentials; 535 | const url = `${this.options.monitor}/${method}/${poolId}?apiKey=${apiKey}&period=${period}`; 536 | try { 537 | result = await this.request('get', url); 538 | } catch (e) { 539 | throw new Error(`${errorMessages.request_failed} ${e.message} (${url})`); 540 | } 541 | return result; 542 | } 543 | 544 | /** 545 | * Makes post request to Bulk API 546 | * 547 | * @param {string} method 548 | * @param {object} data 549 | * @returns {Object|null} 550 | */ 551 | async postBulkAPI(method, data) { 552 | if ([ 553 | 'createPool', 554 | 'deletePool', 555 | 'addPoolAddresses', 556 | 'deletePoolAddresses', 557 | 'clearPoolAddresses' 558 | ].indexOf(method) < 0) { 559 | throw new Error(`${errorMessages.unknown_method} ${method}`); 560 | } 561 | if ((method !== 'createPool') && !this.credentials.poolId) { 562 | throw new Error(errorMessages.no_pool_id); 563 | } 564 | const promise = this._postBulkAPI(method, data); 565 | this.emit(method, promise); 566 | return promise; 567 | } 568 | 569 | /** 570 | * Makes post request to Bulk API 571 | * 572 | * @param {string} method 573 | * @param {object} data 574 | * @returns {Object|null} 575 | */ 576 | async _postBulkAPI(method, data) { 577 | data = data || {}; 578 | data.apiKey = this.credentials.apiKey; 579 | if (method !== 'createPool') { 580 | data.poolId = this.credentials.poolId; 581 | } 582 | if (data && data.addresses) { 583 | data.addresses = data.addresses.join(); 584 | } 585 | let result = null; 586 | const url = `${this.options.monitor}/${method}`; 587 | try { 588 | result = await this.request('post', url, data); 589 | } catch (e) { 590 | if (e.response && (e.response.data || e.response.body)) { 591 | let json = false; 592 | try { 593 | json = e.response.data ? e.response.data : JSON.parse(e.response.body); 594 | } catch (jsonException) { 595 | this.log(`Impossible to parse JSON body: ${e.response.body}`); 596 | } 597 | if (json && json.error) { 598 | this.log(`Monitor API Error [code ${json.error.code}]: ${json.error.message}`); 599 | } 600 | } 601 | this.log(e); 602 | throw new Error(`${url} POST ${errorMessages.request_failed} ${e.message}`); 603 | } 604 | return result; 605 | } 606 | 607 | /** 608 | * Returns last tracked transactions since the startTime 609 | * 610 | * @param {int} startTime 611 | * @returns {Object|null} 612 | */ 613 | async getPoolUpdates(startTime = 0) { 614 | return this.getUpdates('getPoolUpdates', startTime); 615 | } 616 | 617 | /** 618 | * Returns last tracked transactions since the startTime 619 | * 620 | * @param {int} startTime 621 | * @returns {Object|null} 622 | */ 623 | async getTransactions(startTime = 0) { 624 | return this.getUpdates('getPoolLastTransactions', startTime); 625 | } 626 | 627 | /** 628 | * Returns last tracked operations since the startTime 629 | * 630 | * @param {int} startTime 631 | * @returns {Object|null} 632 | */ 633 | async getOperations(startTime = 0) { 634 | return this.getUpdates('getPoolLastOperations', startTime); 635 | } 636 | 637 | /** 638 | * Makes HTTP request using got or axios library. 639 | * 640 | * @param {string} method 641 | * @param {string} url 642 | * @param {Objet} postData 643 | * @returns {result} 644 | */ 645 | async request(method, url, postData = null) { 646 | let result = null; 647 | let data = null; 648 | const body = new FormData(); 649 | if (postData) { 650 | Object.keys(postData).map(name => body.append(name, postData[name])); 651 | } 652 | const timeout = this.options.requestTimeout; 653 | const startTs = Date.now(); 654 | switch (this.options.requestDriver) { 655 | case 'got': 656 | if (!rq) rq = require('got'); 657 | switch (method) { 658 | case 'get': 659 | data = await rq(url, { timeout }); 660 | break; 661 | case 'post': 662 | this.log(`${method.toUpperCase()} Request [${this.options.requestDriver}] ${url}`); 663 | this.log(body); 664 | 665 | data = await rq.post(url, { body, timeout }); 666 | break; 667 | default: 668 | throw new Error(`${errorMessages.rq_unkonwn_method} ${method}`); 669 | } 670 | if (data && data.body) { 671 | this.log(data.timings); 672 | result = JSON.parse(data.body); 673 | } 674 | break; 675 | case 'axios': 676 | if (!rq) rq = require('axios'); 677 | switch (method) { 678 | case 'get': 679 | data = await rq(url, { timeout }); 680 | break; 681 | case 'post': 682 | this.log(`${method.toUpperCase()} Request [${this.options.requestDriver}] ${url}`); 683 | this.log(body); 684 | data = await rq.post(url, body, { timeout, headers: body.getHeaders() }); 685 | break; 686 | default: 687 | throw new Error(`${errorMessages.rq_unkonwn_method} ${method}`); 688 | } 689 | if (data && data.data) { 690 | result = data.data; 691 | } 692 | break; 693 | default: 694 | throw new Error(`${errorMessages.rq_unkonwn_driver} ${this.options.requestDriver}`); 695 | } 696 | 697 | const time = ((Date.now() - startTs) / 1000).toPrecision(2); 698 | this.log(`${method.toUpperCase()} Request [${this.options.requestDriver}] ${url} finished in ${time} s.`); 699 | if (postData) { 700 | this.log(postData); 701 | } 702 | if (result && result.error) { 703 | throw new Error(result.error.message); 704 | } 705 | return result; 706 | } 707 | 708 | /** 709 | * Wait for N seconds in async function 710 | * 711 | * @param {int} time 712 | */ 713 | sleep(time) { 714 | this.log(`Sleep ${time} ms.`); 715 | return new Promise(resolve => setTimeout(resolve, time)); 716 | } 717 | 718 | /** 719 | * Logs a message to console. 720 | * 721 | * @param {string} message 722 | */ 723 | log(message) { 724 | if (this.options.debug) { 725 | console.log(message); 726 | } 727 | } 728 | 729 | /** 730 | * Clears cached blocks and tx/op data 731 | * 732 | * @returns {undefined} 733 | * @private 734 | */ 735 | clearCachedBlocks() { 736 | if (state && state.blocks) { 737 | const blocks = Object.keys(state.blocks); 738 | if (blocks.length) { 739 | // Remove old blocks from the state 740 | for (let i = 0; i < blocks.length; i++) { 741 | const blockNumber = blocks[i]; 742 | if (blockNumber < state.lastBlock) { 743 | delete state.blocks[blockNumber]; 744 | } 745 | } 746 | // Clear tx/op cache 747 | const events = Object.keys(eventsEmitted); 748 | for (let i = 0; i < events.length; i++) { 749 | const eventName = events[i]; 750 | const eventBlock = eventsEmitted[eventName]; 751 | if (eventBlock < state.lastBlock) { 752 | delete eventsEmitted[eventName]; 753 | } 754 | } 755 | } 756 | } 757 | } 758 | } 759 | 760 | module.exports = MonitorClient; 761 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NodeJS client for Ethplorer Bulk API Monitor 2 | Fast tracking an unlimited number of ERC20 tokens and Ethereum addresses, even millions. 3 | 4 | https://docs.ethplorer.io/monitor 5 | 6 | ## Quickstart 7 | 8 | Learn how to start tracking your Ethereum addresses with Ethplorer Bulk API and and Node.js. 9 | 10 | Let's create a new project and add ```eth-bulk-monitor-client-nodejs``` library via npm. 11 | ``` 12 | $ mkdir monitor-example 13 | $ cd monitor-example 14 | $ npm init 15 | $ npm i --save eth-bulk-monitor-client-nodejs 16 | $ vim index.js 17 | ``` 18 | 19 | Create a new js file and start edit it. 20 | 21 | First of all, let's include MonitorApp class: 22 | ``` 23 | const { MonitorApp } = require('eth-bulk-monitor-client-nodejs'); 24 | ``` 25 | 26 | Then instantiate the class with your [API key](https://ethplorer.zendesk.com/hc/en-us/articles/900000976026-How-to-get-access-to-the-Bulk-API-Monitor-). 27 | ``` 28 | const monitorApp = new MonitorApp('put your API key here'); 29 | ``` 30 | 31 | Finally, lets define the addresses we would like to monitor and a callback function: 32 | ``` 33 | monitorApp.init([ 34 | '0x0000000000000000000000000000000000000001', 35 | '0x0000000000000000000000000000000000000002', 36 | '0x0000000000000000000000000000000000000003' 37 | ]).then(() => monitorApp.watch((data) => console.log(data))); 38 | ``` 39 | 40 | Voila, now we can get and process all the new transactions and ERC-20 operations for the specified addresses using just a single npm library and Node.js. 41 | 42 | ## Examples 43 | 44 | - [Basic example](examples/basicExample.js) 45 | - [Crypto exchanger example](https://github.com/amilabs/crypto-exchanger) 46 | 47 | ## Reference 48 | 49 | You can find the class reference [here](reference.md). 50 | -------------------------------------------------------------------------------- /examples/basicExample.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Bulk API Monitor client library basic usage example. 3 | */ 4 | const { MonitorApp } = require('../index'); 5 | 6 | /** 7 | * Initialize client application. 8 | * 9 | * @type MonitorApp 10 | */ 11 | const monitorApp = new MonitorApp('put your API key here'); 12 | 13 | /** 14 | * Watch for the addresses new transactions/operations and print out any update 15 | */ 16 | monitorApp.init([ 17 | '0x0000000000000000000000000000000000000001', 18 | '0x0000000000000000000000000000000000000002', 19 | '0x0000000000000000000000000000000000000003' 20 | ]).then(() => monitorApp.watch((data) => console.log(data)).catch((err) => console.log(err))); 21 | -------------------------------------------------------------------------------- /examples/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "examples", 3 | "version": "1.0.0", 4 | "lockfileVersion": 1, 5 | "requires": true, 6 | "dependencies": { 7 | "@sindresorhus/is": { 8 | "version": "2.1.1", 9 | "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-2.1.1.tgz", 10 | "integrity": "sha512-/aPsuoj/1Dw/kzhkgz+ES6TxG0zfTMGLwuK2ZG00k/iJzYHTLCE8mVU8EPqEOp/lmxPoq1C1C9RYToRKb2KEfg==" 11 | }, 12 | "@szmarczak/http-timer": { 13 | "version": "4.0.5", 14 | "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.5.tgz", 15 | "integrity": "sha512-PyRA9sm1Yayuj5OIoJ1hGt2YISX45w9WcFbh6ddT0Z/0yaFxOtGLInr4jUfU1EAFVs0Yfyfev4RNwBlUaHdlDQ==", 16 | "requires": { 17 | "defer-to-connect": "^2.0.0" 18 | } 19 | }, 20 | "@types/cacheable-request": { 21 | "version": "6.0.1", 22 | "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.1.tgz", 23 | "integrity": "sha512-ykFq2zmBGOCbpIXtoVbz4SKY5QriWPh3AjyU4G74RYbtt5yOc5OfaY75ftjg7mikMOla1CTGpX3lLbuJh8DTrQ==", 24 | "requires": { 25 | "@types/http-cache-semantics": "*", 26 | "@types/keyv": "*", 27 | "@types/node": "*", 28 | "@types/responselike": "*" 29 | } 30 | }, 31 | "@types/http-cache-semantics": { 32 | "version": "4.0.0", 33 | "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.0.tgz", 34 | "integrity": "sha512-c3Xy026kOF7QOTn00hbIllV1dLR9hG9NkSrLQgCVs8NF6sBU+VGWjD3wLPhmh1TYAc7ugCFsvHYMN4VcBN1U1A==" 35 | }, 36 | "@types/keyv": { 37 | "version": "3.1.1", 38 | "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.1.tgz", 39 | "integrity": "sha512-MPtoySlAZQ37VoLaPcTHCu1RWJ4llDkULYZIzOYxlhxBqYPB0RsRlmMU0R6tahtFe27mIdkHV+551ZWV4PLmVw==", 40 | "requires": { 41 | "@types/node": "*" 42 | } 43 | }, 44 | "@types/node": { 45 | "version": "14.0.24", 46 | "resolved": "https://registry.npmjs.org/@types/node/-/node-14.0.24.tgz", 47 | "integrity": "sha512-btt/oNOiDWcSuI721MdL8VQGnjsKjlTMdrKyTcLCKeQp/n4AAMFJ961wMbp+09y8WuGPClDEv07RIItdXKIXAA==" 48 | }, 49 | "@types/responselike": { 50 | "version": "1.0.0", 51 | "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.0.tgz", 52 | "integrity": "sha512-85Y2BjiufFzaMIlvJDvTTB8Fxl2xfLo4HgmHzVBz08w4wDePCTjYw66PdrolO0kzli3yam/YCgRufyo1DdQVTA==", 53 | "requires": { 54 | "@types/node": "*" 55 | } 56 | }, 57 | "asynckit": { 58 | "version": "0.4.0", 59 | "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", 60 | "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=" 61 | }, 62 | "cacheable-lookup": { 63 | "version": "5.0.3", 64 | "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.3.tgz", 65 | "integrity": "sha512-W+JBqF9SWe18A72XFzN/V/CULFzPm7sBXzzR6ekkE+3tLG72wFZrBiBZhrZuDoYexop4PHJVdFAKb/Nj9+tm9w==" 66 | }, 67 | "cacheable-request": { 68 | "version": "7.0.1", 69 | "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.1.tgz", 70 | "integrity": "sha512-lt0mJ6YAnsrBErpTMWeu5kl/tg9xMAWjavYTN6VQXM1A/teBITuNcccXsCxF0tDQQJf9DfAaX5O4e0zp0KlfZw==", 71 | "requires": { 72 | "clone-response": "^1.0.2", 73 | "get-stream": "^5.1.0", 74 | "http-cache-semantics": "^4.0.0", 75 | "keyv": "^4.0.0", 76 | "lowercase-keys": "^2.0.0", 77 | "normalize-url": "^4.1.0", 78 | "responselike": "^2.0.0" 79 | } 80 | }, 81 | "clone-response": { 82 | "version": "1.0.2", 83 | "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.2.tgz", 84 | "integrity": "sha1-0dyXOSAxTfZ/vrlCI7TuNQI56Ws=", 85 | "requires": { 86 | "mimic-response": "^1.0.0" 87 | } 88 | }, 89 | "combined-stream": { 90 | "version": "1.0.8", 91 | "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", 92 | "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", 93 | "requires": { 94 | "delayed-stream": "~1.0.0" 95 | } 96 | }, 97 | "decompress-response": { 98 | "version": "6.0.0", 99 | "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", 100 | "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", 101 | "requires": { 102 | "mimic-response": "^3.1.0" 103 | }, 104 | "dependencies": { 105 | "mimic-response": { 106 | "version": "3.1.0", 107 | "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", 108 | "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==" 109 | } 110 | } 111 | }, 112 | "defer-to-connect": { 113 | "version": "2.0.0", 114 | "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.0.tgz", 115 | "integrity": "sha512-bYL2d05vOSf1JEZNx5vSAtPuBMkX8K9EUutg7zlKvTqKXHt7RhWJFbmd7qakVuf13i+IkGmp6FwSsONOf6VYIg==" 116 | }, 117 | "delayed-stream": { 118 | "version": "1.0.0", 119 | "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", 120 | "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=" 121 | }, 122 | "end-of-stream": { 123 | "version": "1.4.4", 124 | "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", 125 | "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", 126 | "requires": { 127 | "once": "^1.4.0" 128 | } 129 | }, 130 | "eth-bulk-monitor-client-nodejs": { 131 | "version": "0.0.4", 132 | "resolved": "https://registry.npmjs.org/eth-bulk-monitor-client-nodejs/-/eth-bulk-monitor-client-nodejs-0.0.4.tgz", 133 | "integrity": "sha512-Dx5ovhJkErNdwk+Eo7RBLiYmMr0FSW12rAvqpoNj6aUASqMM/PMPMnS1X3IFlIPYiff46dqZIELVzfR6T5SwtA==", 134 | "requires": { 135 | "form-data": "3.0.0", 136 | "got": "11.3.0" 137 | } 138 | }, 139 | "form-data": { 140 | "version": "3.0.0", 141 | "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.0.tgz", 142 | "integrity": "sha512-CKMFDglpbMi6PyN+brwB9Q/GOw0eAnsrEZDgcsH5Krhz5Od/haKHAX0NmQfha2zPPz0JpWzA7GJHGSnvCRLWsg==", 143 | "requires": { 144 | "asynckit": "^0.4.0", 145 | "combined-stream": "^1.0.8", 146 | "mime-types": "^2.1.12" 147 | } 148 | }, 149 | "get-stream": { 150 | "version": "5.1.0", 151 | "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.1.0.tgz", 152 | "integrity": "sha512-EXr1FOzrzTfGeL0gQdeFEvOMm2mzMOglyiOXSTpPC+iAjAKftbr3jpCMWynogwYnM+eSj9sHGc6wjIcDvYiygw==", 153 | "requires": { 154 | "pump": "^3.0.0" 155 | } 156 | }, 157 | "got": { 158 | "version": "11.3.0", 159 | "resolved": "https://registry.npmjs.org/got/-/got-11.3.0.tgz", 160 | "integrity": "sha512-yi/kiZY2tNMtt5IfbfX8UL3hAZWb2gZruxYZ72AY28pU5p0TZjZdl0uRsuaFbnC0JopdUi3I+Mh1F3dPQ9Dh0Q==", 161 | "requires": { 162 | "@sindresorhus/is": "^2.1.1", 163 | "@szmarczak/http-timer": "^4.0.5", 164 | "@types/cacheable-request": "^6.0.1", 165 | "@types/responselike": "^1.0.0", 166 | "cacheable-lookup": "^5.0.3", 167 | "cacheable-request": "^7.0.1", 168 | "decompress-response": "^6.0.0", 169 | "get-stream": "^5.1.0", 170 | "http2-wrapper": "^1.0.0-beta.4.5", 171 | "lowercase-keys": "^2.0.0", 172 | "p-cancelable": "^2.0.0", 173 | "responselike": "^2.0.0" 174 | } 175 | }, 176 | "http-cache-semantics": { 177 | "version": "4.1.0", 178 | "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz", 179 | "integrity": "sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ==" 180 | }, 181 | "http2-wrapper": { 182 | "version": "1.0.0-beta.5.2", 183 | "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.0-beta.5.2.tgz", 184 | "integrity": "sha512-xYz9goEyBnC8XwXDTuC/MZ6t+MrKVQZOk4s7+PaDkwIsQd8IwqvM+0M6bA/2lvG8GHXcPdf+MejTUeO2LCPCeQ==", 185 | "requires": { 186 | "quick-lru": "^5.1.1", 187 | "resolve-alpn": "^1.0.0" 188 | } 189 | }, 190 | "json-buffer": { 191 | "version": "3.0.1", 192 | "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", 193 | "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==" 194 | }, 195 | "keyv": { 196 | "version": "4.0.1", 197 | "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.0.1.tgz", 198 | "integrity": "sha512-xz6Jv6oNkbhrFCvCP7HQa8AaII8y8LRpoSm661NOKLr4uHuBwhX4epXrPQgF3+xdJnN4Esm5X0xwY4bOlALOtw==", 199 | "requires": { 200 | "json-buffer": "3.0.1" 201 | } 202 | }, 203 | "lowercase-keys": { 204 | "version": "2.0.0", 205 | "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", 206 | "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==" 207 | }, 208 | "mime-db": { 209 | "version": "1.44.0", 210 | "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.44.0.tgz", 211 | "integrity": "sha512-/NOTfLrsPBVeH7YtFPgsVWveuL+4SjjYxaQ1xtM1KMFj7HdxlBlxeyNLzhyJVx7r4rZGJAZ/6lkKCitSc/Nmpg==" 212 | }, 213 | "mime-types": { 214 | "version": "2.1.27", 215 | "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.27.tgz", 216 | "integrity": "sha512-JIhqnCasI9yD+SsmkquHBxTSEuZdQX5BuQnS2Vc7puQQQ+8yiP5AY5uWhpdv4YL4VM5c6iliiYWPgJ/nJQLp7w==", 217 | "requires": { 218 | "mime-db": "1.44.0" 219 | } 220 | }, 221 | "mimic-response": { 222 | "version": "1.0.1", 223 | "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", 224 | "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==" 225 | }, 226 | "normalize-url": { 227 | "version": "4.5.0", 228 | "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-4.5.0.tgz", 229 | "integrity": "sha512-2s47yzUxdexf1OhyRi4Em83iQk0aPvwTddtFz4hnSSw9dCEsLEGf6SwIO8ss/19S9iBb5sJaOuTvTGDeZI00BQ==" 230 | }, 231 | "once": { 232 | "version": "1.4.0", 233 | "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", 234 | "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", 235 | "requires": { 236 | "wrappy": "1" 237 | } 238 | }, 239 | "p-cancelable": { 240 | "version": "2.0.0", 241 | "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.0.0.tgz", 242 | "integrity": "sha512-wvPXDmbMmu2ksjkB4Z3nZWTSkJEb9lqVdMaCKpZUGJG9TMiNp9XcbG3fn9fPKjem04fJMJnXoyFPk2FmgiaiNg==" 243 | }, 244 | "pump": { 245 | "version": "3.0.0", 246 | "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", 247 | "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", 248 | "requires": { 249 | "end-of-stream": "^1.1.0", 250 | "once": "^1.3.1" 251 | } 252 | }, 253 | "quick-lru": { 254 | "version": "5.1.1", 255 | "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", 256 | "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==" 257 | }, 258 | "resolve-alpn": { 259 | "version": "1.0.0", 260 | "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.0.0.tgz", 261 | "integrity": "sha512-rTuiIEqFmGxne4IovivKSDzld2lWW9QCjqv80SYjPgf+gS35eaCAjaP54CCwGAwBtnCsvNLYtqxe1Nw+i6JEmA==" 262 | }, 263 | "responselike": { 264 | "version": "2.0.0", 265 | "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.0.tgz", 266 | "integrity": "sha512-xH48u3FTB9VsZw7R+vvgaKeLKzT6jOogbQhEe/jewwnZgzPcnyWui2Av6JpoYZF/91uueC+lqhWqeURw5/qhCw==", 267 | "requires": { 268 | "lowercase-keys": "^2.0.0" 269 | } 270 | }, 271 | "wrappy": { 272 | "version": "1.0.2", 273 | "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", 274 | "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" 275 | } 276 | } 277 | } 278 | -------------------------------------------------------------------------------- /examples/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "eth-bulk-monitor-client-nodejs-example", 3 | "version": "1.0.0", 4 | "description": "Ethplorer bulk Monitor API nodejs client", 5 | "main": "basicExample.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "author": "", 10 | "license": "ISC", 11 | "dependencies": { 12 | "eth-bulk-monitor-client-nodejs": "*" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const MonitorClient = require('./MonitorClient'); 2 | const MonitorApp = require('./MonitorApp'); 3 | 4 | module.exports = { MonitorClient, MonitorApp }; 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "eth-bulk-monitor-client-nodejs", 3 | "version": "0.0.38", 4 | "description": "Ethplorer bulk Monitor API nodejs client", 5 | "main": "index.js", 6 | "devDependencies": { 7 | "babel-eslint": "10.0.1", 8 | "eslint": "4.19.1", 9 | "eslint-config-airbnb": "16.1.0", 10 | "eslint-plugin-import": "2.14.0", 11 | "eslint-plugin-jsx-a11y": "6.1.2", 12 | "eslint-plugin-meteor": "5.1.0", 13 | "eslint-plugin-react": "7.12.3", 14 | "husky": "1.3.1", 15 | "less": ">2.7.3", 16 | "precise-commits": "1.0.2", 17 | "pretty-quick": "1.10.0", 18 | "mocha": "8.2.1", 19 | "mocha-junit-reporter": "2.0.0" 20 | }, 21 | "scripts": { 22 | "lint": "eslint ./imports ./server ./client ./*.js --ext js", 23 | "lint-quiet": "eslint ./imports ./server ./client ./*.js --ext js --quiet", 24 | "lint-jenkins": "eslint ./imports ./server ./client ./*.js --ext js -f checkstyle", 25 | "lint-fix": "eslint ./imports ./server ./client ./*.js --ext js --fix", 26 | "test": "mocha --timeout 10000" 27 | }, 28 | "repository": { 29 | "type": "git", 30 | "url": "git+https://github.com/amilabs/eth-bulk-monitor-client-nodejs.git" 31 | }, 32 | "author": "", 33 | "license": "ISC", 34 | "bugs": { 35 | "url": "https://github.com/amilabs/eth-bulk-monitor-client-nodejs/issues" 36 | }, 37 | "homepage": "https://github.com/amilabs/eth-bulk-monitor-client-nodejs#readme", 38 | "dependencies": { 39 | "axios": "^0.21.0", 40 | "bignumber.js": "9.0.1", 41 | "express": "4.17.1", 42 | "form-data": "3.0.0", 43 | "got": "11.3.0" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /reference.md: -------------------------------------------------------------------------------- 1 | ## Classes 2 | 3 |
4 |
MonitorApp
5 |
6 |
MonitorClient
7 |
8 |
9 | 10 | 11 | 12 | ## MonitorApp 13 | **Kind**: global class 14 | 15 | * [MonitorApp](#MonitorApp) 16 | * [new MonitorApp(apiKey, options)](#new_MonitorApp_new) 17 | * [.init(addresses)](#MonitorApp+init) 18 | * [.saveState()](#MonitorApp+saveState) 19 | * [.restoreState()](#MonitorApp+restoreState) 20 | * [.watch(callback)](#MonitorApp+watch) 21 | 22 | 23 | 24 | ### new MonitorApp(apiKey, options) 25 | Constructor has the same params as the monitorClient class constructor. 26 | 27 | | Param | Type | 28 | | --- | --- | 29 | | apiKey | string | 30 | | options | object | 31 | 32 | 33 | 34 | ### monitorApp.init(addresses) 35 | Initializes client and adds addresses to the pool. 36 | It will create a new pool if no poolId was stored in the watching state 37 | 38 | **Kind**: instance method of [MonitorApp](#MonitorApp) 39 | 40 | | Param | Type | 41 | | --- | --- | 42 | | addresses | array | 43 | 44 | 45 | 46 | ### monitorApp.saveState() 47 | Saves the watching state to a file 48 | 49 | **Kind**: instance method of [MonitorApp](#MonitorApp) 50 | 51 | 52 | ### monitorApp.restoreState() 53 | Restores the watching state from a file. 54 | 55 | **Kind**: instance method of [MonitorApp](#MonitorApp) 56 | 57 | 58 | ### monitorApp.watch(callback) 59 | Starts watching for events. 60 | 61 | **Kind**: instance method of [MonitorApp](#MonitorApp) 62 | 63 | | Param | Type | 64 | | --- | --- | 65 | | callback | function | 66 | 67 | 68 | 69 | ## MonitorClient 70 | **Kind**: global class 71 | 72 | * [MonitorClient](#MonitorClient) 73 | * [new MonitorClient(apiKey, options)](#new_MonitorClient_new) 74 | * [.saveState()](#MonitorClient+saveState) ⇒ Promise 75 | * [.restoreState(state)](#MonitorClient+restoreState) 76 | * [.createPool(addresses)](#MonitorClient+createPool) ⇒ Boolean \| string 77 | * [.deletePool()](#MonitorClient+deletePool) ⇒ Boolean 78 | * [.addAddresses(addresses)](#MonitorClient+addAddresses) ⇒ Boolean 79 | * [.removeAddresses(addresses)](#MonitorClient+removeAddresses) ⇒ Boolean 80 | * [.removeAllAddresses()](#MonitorClient+removeAllAddresses) ⇒ bool 81 | * [.watch()](#MonitorClient+watch) ⇒ Promise 82 | * [.unwatch()](#MonitorClient+unwatch) ⇒ undefined 83 | * [.getToken(address)](#MonitorClient+getToken) ⇒ Object \| bool 84 | * [.getUpdates(method, startTime)](#MonitorClient+getUpdates) ⇒ Object \| null 85 | * [.getTransactions(startTime)](#MonitorClient+getTransactions) ⇒ Object \| null 86 | * [.getOperations(startTime)](#MonitorClient+getOperations) ⇒ Object \| null 87 | 88 | 89 | 90 | ### new MonitorClient(apiKey, options) 91 | Constructor. 92 | 93 | 94 | | Param | Type | 95 | | --- | --- | 96 | | apiKey | string | 97 | | options | object | 98 | 99 | 100 | 101 | ### monitorClient.saveState() ⇒ Promise 102 | Returns current state. 103 | 104 | **Kind**: instance method of [MonitorClient](#MonitorClient) 105 | 106 | 107 | ### monitorClient.restoreState(state) 108 | Restores state from saved data. 109 | 110 | **Kind**: instance method of [MonitorClient](#MonitorClient) 111 | 112 | | Param | Type | 113 | | --- | --- | 114 | | state | Object | 115 | 116 | 117 | 118 | ### monitorClient.createPool(addresses) ⇒ Boolean \| string 119 | Creates a new pool. 120 | 121 | **Kind**: instance method of [MonitorClient](#MonitorClient) 122 | 123 | | Param | Type | 124 | | --- | --- | 125 | | addresses | Array.<string> | 126 | 127 | 128 | 129 | ### monitorClient.deletePool() ⇒ Boolean 130 | Deletes current pool. 131 | 132 | **Kind**: instance method of [MonitorClient](#MonitorClient) 133 | 134 | 135 | ### monitorClient.addAddresses(addresses) ⇒ Boolean 136 | Adds addresses to the pool. 137 | 138 | **Kind**: instance method of [MonitorClient](#MonitorClient) 139 | 140 | | Param | Type | 141 | | --- | --- | 142 | | addresses | Array.<string> | 143 | 144 | 145 | 146 | ### monitorClient.removeAddresses(addresses) ⇒ Boolean 147 | Removes addresses from the pool. 148 | 149 | **Kind**: instance method of [MonitorClient](#MonitorClient) 150 | 151 | | Param | Type | 152 | | --- | --- | 153 | | addresses | Array.<string> | 154 | 155 | 156 | 157 | ### monitorClient.removeAllAddresses() ⇒ bool 158 | Removes all addresses from the pool. 159 | 160 | **Kind**: instance method of [MonitorClient](#MonitorClient) 161 | 162 | 163 | ### monitorClient.watch() ⇒ Promise 164 | Starts watching for address acitivity. 165 | 166 | **Kind**: instance method of [MonitorClient](#MonitorClient) 167 | 168 | 169 | ### monitorClient.unwatch() ⇒ undefined 170 | Stops watching for address activity. 171 | 172 | **Kind**: instance method of [MonitorClient](#MonitorClient) 173 | 174 | 175 | ### monitorClient.getToken(address) ⇒ Object \| bool 176 | Returns token data by token address. 177 | 178 | **Kind**: instance method of [MonitorClient](#MonitorClient) 179 | 180 | | Param | Type | 181 | | --- | --- | 182 | | address | string | 183 | 184 | 185 | 186 | ### monitorClient.getUpdates(method, startTime) ⇒ Object \| null 187 | Asks Bulk API for updates 188 | 189 | **Kind**: instance method of [MonitorClient](#MonitorClient) 190 | 191 | | Param | Type | Default | 192 | | --- | --- | --- | 193 | | method | string | | 194 | | startTime | int | 0 | 195 | 196 | 197 | 198 | ### monitorClient.getTransactions(startTime) ⇒ Object \| null 199 | Returns last tracked transactions since the startTime 200 | 201 | **Kind**: instance method of [MonitorClient](#MonitorClient) 202 | 203 | | Param | Type | Default | 204 | | --- | --- | --- | 205 | | startTime | int | 0 | 206 | 207 | 208 | 209 | ### monitorClient.getOperations(startTime) ⇒ Object \| null 210 | Returns last tracked operations since the startTime 211 | 212 | **Kind**: instance method of [MonitorClient](#MonitorClient) 213 | 214 | | Param | Type | Default | 215 | | --- | --- | --- | 216 | | startTime | int | 0 | 217 | 218 | -------------------------------------------------------------------------------- /test/MonitorClient.test.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert'); 2 | const lib = require('../MonitorClient.js'); 3 | const express = require('express'); 4 | const ws = express(); 5 | 6 | const ETHAddress = "0x0000000000000000000000000000000000000000"; 7 | const A1 = "0x0000000000000000000000000000000000000001"; 8 | const A2 = "0x0000000000000000000000000000000000000002"; 9 | const C0 = "0x0000000000000000000000000000000000000003"; 10 | 11 | let transactions = {}; 12 | let operations = {}; 13 | let updates = { transactions, operations, lastBlock: { block: 0, timestamp: 0 } }; 14 | 15 | let blockNumber = 1000; 16 | 17 | const options = { 18 | poolId: 'poolId', 19 | interval: 0.1, 20 | network: 'custom', 21 | api: 'http://127.0.0.1:63101', 22 | monitor: 'http://127.0.0.1:63101', 23 | maxErrorCount: 3 24 | }; 25 | 26 | const badOptions = { ...options, poolId: 'badPoolId' }; 27 | 28 | describe('MonitorClient test', () => { 29 | before(() => { 30 | ws.route(`/getTokenInfo/${ETHAddress}`) 31 | .get((req, res) => res.json({ 32 | address: ETHAddress, 33 | name: "Ethereum", 34 | symbol: "ETH", 35 | decimals: 18, 36 | price: { 37 | rate: 300 38 | } 39 | })); 40 | 41 | ws.route(`/getTokenInfo/${C0}`) 42 | .get((req, res) => { 43 | res.json({ 44 | address: C0, 45 | name: "Token", 46 | symbol: "TKN", 47 | decimals: 4, 48 | price: { 49 | rate: 100 50 | } 51 | }); 52 | }); 53 | 54 | ws.route("/createPool") 55 | .get((req, res) => { 56 | res.json(operations); 57 | }); 58 | 59 | ws.route("/deletePool") 60 | .get((req, res) => { 61 | res.json(operations); 62 | }); 63 | 64 | ws.route("/getPoolLastOperations/poolId") 65 | .get((req, res) => { 66 | res.json(operations); 67 | }); 68 | 69 | ws.route("/getPoolLastTransactions/poolId") 70 | .get((req, res) => { 71 | res.json(transactions); 72 | }); 73 | 74 | ws.route("/getPoolUpdates/poolId") 75 | .get((req, res) => { 76 | res.json(updates); 77 | }); 78 | this.server = ws.listen(63101); 79 | }); 80 | 81 | after(() => this.server.close()); 82 | 83 | it('getTokenInfo should return correct ETH data', async () => { 84 | const mon = new lib('apiKey', options); 85 | const data = await mon.getToken(ETHAddress); 86 | assert.equal(data.name, "Ethereum"); 87 | assert.equal(data.symbol, "ETH"); 88 | assert.equal(data.decimals, 18); 89 | assert.equal(data.rate, 300); 90 | delete mon; 91 | }); 92 | 93 | it('watch() should fire the watched, data, stateChanged and unwatch events', (done) => { 94 | addNextBlockTx(A1, A2, C0, 1000, 1.5); 95 | const mon = new lib('apiKey', options); 96 | mon.on("watched", () => { 97 | assert.equal(true, true); 98 | }); 99 | mon.on("data", (eventData) => { 100 | assert.equal(eventData.address, A1); 101 | assert.equal(eventData.data.blockNumber, 1000); 102 | assert.equal(eventData.data.hash, '1000'); 103 | if (eventData.type === 'transaction') { 104 | assert.equal(eventData.data.rate, 300); 105 | assert.equal(eventData.data.usdValue, 450); 106 | } 107 | if (eventData.type === 'operation') { 108 | assert.equal(eventData.data.token.rate, 100); 109 | assert.equal(eventData.data.rawValue, 1000); 110 | assert.equal(eventData.data.value, 0.1); 111 | assert.equal(eventData.data.usdValue, 10); 112 | } 113 | }); 114 | mon.on("stateChanged", async (state) => { 115 | assert.equal(state.lastBlock, 1000); 116 | assert.equal(state.lastTs, 1000); 117 | mon.unwatch(); 118 | }); 119 | mon.on("unwatched", () => { 120 | assert.equal(true, true); 121 | delete mon; 122 | done(); 123 | }); 124 | mon.watch(); 125 | }); 126 | 127 | it('should restore the saved state', (done) => { 128 | addNextBlockTx(A1, A2, C0, 500, 1); 129 | const mon = new lib('apiKey', options); 130 | const savedState = { lastBlock: 1000, lastTs: 1000 }; 131 | mon.restoreState(savedState); 132 | mon.on("data", (eventData) => { 133 | assert.equal(eventData.address, A1); 134 | assert.equal(eventData.data.blockNumber, 1001); 135 | assert.equal(eventData.data.hash, '1001'); 136 | }); 137 | mon.on("stateChanged", (state) => { 138 | assert.equal(Object.keys(state.blocks).length, 1); 139 | assert.equal(state.lastBlock, 1001); 140 | assert.equal(state.lastTs, 1001); 141 | mon.unwatch(); 142 | delete mon; 143 | done(); 144 | }); 145 | mon.watch(); 146 | }); 147 | 148 | it('should unwatch after maxErrorCount errors', (done) => { 149 | addNextBlockTx(A1, A2, C0, 500, 1); 150 | const mon = new lib('apiKey', badOptions); 151 | mon.on("unwatched", () => { 152 | assert.equal(mon.errors, 3); 153 | delete mon; 154 | done(); 155 | }); 156 | mon.watch(); 157 | }); 158 | 159 | it('should watch failed if watchFailed flag is on', (done) => { 160 | clearTransactionsAndOperations(); 161 | const mon = new lib('apiKey', {...options, watchFailed: true }); 162 | mon.restoreState({ lastBlock: 0, lastTs: 0, blocks: {} }); 163 | mon.on("data", (eventData) => { 164 | assert.equal(eventData.data.success, false); 165 | mon.unwatch(); 166 | delete mon; 167 | done(); 168 | }); 169 | setTimeout(() => { 170 | addNextBlockTx(A1, A2, C0, 500, 1, false); 171 | }, 0); 172 | mon.watch(); 173 | }); 174 | 175 | it('should not watch failed if watchFailed flag is off', (done) => { 176 | clearTransactionsAndOperations(); 177 | const mon = new lib('apiKey', {...options }); 178 | mon.restoreState({ lastBlock: 0, lastTs: 0, blocks: {} }); 179 | mon.on("data", (eventData) => { 180 | if(eventData.type === 'transaction') { 181 | assert.equal(eventData.data.success, true); 182 | mon.unwatch(); 183 | delete mon; 184 | done(); 185 | } 186 | }); 187 | setTimeout(() => { 188 | addNextBlockTx(A1, A2, C0, 500, 1); 189 | }, 0); 190 | mon.watch(); 191 | }); 192 | 193 | it('should not raise data event for duplicate data', (done) => { 194 | clearTransactionsAndOperations(); 195 | const mon = new lib('apiKey', {...options, watchFailed: true }); 196 | mon.restoreState({ lastBlock: 0, lastTs: 0, blocks: {} }); 197 | let dups = 0; 198 | mon.on("data", (eventData) => { 199 | if(eventData.data.blockNumber === 1) { 200 | dups++; 201 | } 202 | if(eventData.data.blockNumber === 2) { 203 | assert.equal(dups, 1); 204 | mon.unwatch(); 205 | delete mon; 206 | done(); 207 | } 208 | }); 209 | addBlockOp(1, A1, A2, C0, 500); 210 | setTimeout(() => { 211 | addBlockOp(1, A1, A2, C0, 500); 212 | }, 50); 213 | setTimeout(() => { 214 | addBlockOp(1, A1, A2, C0, 500); 215 | }, 100); 216 | setTimeout(() => addBlockOp(2, A1, A2, C0, 500), 150); 217 | mon.watch(); 218 | }); 219 | }); 220 | 221 | function addNextBlockTx(from, to, contract, value, valueETH, success = true, noOperation = false) { 222 | if (transactions[from] === undefined) { 223 | transactions[from] = []; 224 | } 225 | 226 | if (success && !noOperation) { 227 | addBlockOp(blockNumber, from, to, contract, value); 228 | } 229 | 230 | const tx = { 231 | timestamp: Date.now(), 232 | blockNumber, 233 | from, 234 | to: contract, 235 | hash: blockNumber.toString(), 236 | value: valueETH, 237 | input: '0xa9059cbb', 238 | balances: {}, 239 | success 240 | }; 241 | 242 | tx.balances[from] = 10000; 243 | tx.balances[contract] = 10000; 244 | 245 | transactions[from].push(tx); 246 | 247 | updates = { transactions, operations, lastSolidBlock: { block: blockNumber, timestamp: blockNumber } }; 248 | 249 | blockNumber++; 250 | } 251 | 252 | function addBlockOp(blockNumber, from, to, contract, value) { 253 | if (operations[from] === undefined) { 254 | operations[from] = []; 255 | } 256 | const op = { 257 | timestamp: Date.now(), 258 | blockNumber, 259 | contract, 260 | value, 261 | type: 'transfer', 262 | priority: 0, 263 | from, 264 | to, 265 | hash: blockNumber.toString(), 266 | balances: {} 267 | }; 268 | 269 | op.balances[from] = 10000; 270 | op.balances[to] = 10000; 271 | 272 | operations[from].push(op); 273 | 274 | updates = { transactions, operations, lastSolidBlock: { block: blockNumber, timestamp: blockNumber } }; 275 | } 276 | 277 | function clearTransactionsAndOperations() { 278 | transactions = {}; 279 | operations = {}; 280 | updates = { transactions, operations, lastSolidBlock: { block: 0, timestamp: 0 } }; 281 | } --------------------------------------------------------------------------------