diff --git a/.editorconfig b/.editorconfig deleted file mode 100644 index 6f445f1..0000000 --- a/.editorconfig +++ /dev/null @@ -1,15 +0,0 @@ -# Install EditorConfig Plugin on your IDE for one coding style -# EditorConfig is awesome: http://EditorConfig.org - -root = true - -[*] -charset = utf-8 -end_of_line = lf -insert_final_newline = true -trim_trailing_whitespace = true -indent_style = tab -tab_width = 4 -[*.md] -max_line_length = off -trim_trailing_whitespace = false diff --git a/.eslintrc.js b/.eslintrc.js index 6dd62da..b349f4d 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,43 +1,29 @@ module.exports = { - "parserOptions": { - "ecmaVersion": 8, - "sourceType": "module" - }, - "rules": { - "semi": "warn", // обязательно ; - "semi-spacing": ["error", {"before": false, "after": true}], - "indent": ["error", "tab"], - "space-infix-ops": "error",// отступы вокруг + - * / = и тд - "eqeqeq": "error", // обязательно === и !== (нельзя == и !=) - // "no-eq-null": "error", // обязательно === и !== (нельзя == и !=) но тоько в отношении null - "curly": "error", // проверка шаблонов `${name}` - // "space-before-function-paren": [ // отступ до и после function - // "error", { - // "anonymous": "always", - // "named": "always", - // "asyncArrow": "ignore" - // } - // ], - "key-spacing": ["error", { "mode": "strict" }], // оформление обЪекта - "space-in-parens": ["error", "never"], // запрет отступов ( a,b) - "computed-property-spacing": ["error", "never"], // запрет лишних отступов в выражениях a[ i] - "array-bracket-spacing": ["error", "never"], - "no-multi-spaces": "error", // запрет лишних пробелов var a = 2 - "no-sparse-arrays": "warn", // предупреждение при дырке в массиве - "no-mixed-spaces-and-tabs": "error", // нельзя миксовать табы и пробелы - "keyword-spacing": ["error", { "after": true }], - "comma-spacing": ["error", { "before": false, "after": true }], // отступ после запятой, а перед нельзя - "no-undef":"error", - "array-callback-return": "error" // коллбек методов массива типа arr.map arr.filter должны иметь return в коллбеке - }, - "env": { - "browser": true, - "node": true - }, - "globals": { - "Vue":true, - "Symbol":true, - "Promise":true, - }, - "plugins": [] -} + env: { + commonjs: true, + es2021: true, + browser: true, + node: true, + 'jest/globals': true, + }, + extends: ['eslint:recommended', 'google'], + plugins: ['jest'], + parserOptions: { + ecmaVersion: 12, + }, + rules: { + 'max-len': [ + 'error', + { + code: 200, + ignoreTrailingComments: true, + ignoreUrls: true, + ignoreStrings: true, + ignoreTemplateLiterals: true, + ignoreRegExpLiterals: true, + }, + ], + 'require-jsdoc': 'off', + 'quote-props': 'off', + }, +}; diff --git a/.gitignore b/.gitignore index f081cae..977a377 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ logs/ .vscode/ test.js package-lock.json +.editorconfig diff --git a/.husky/commit-msg b/.husky/commit-msg new file mode 100755 index 0000000..7fed485 --- /dev/null +++ b/.husky/commit-msg @@ -0,0 +1,4 @@ +#!/bin/sh +. "$(dirname "$0")/_/husky.sh" + +npx --no -- commitlint --edit diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 0000000..20d0d06 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,4 @@ +#!/bin/sh +. "$(dirname "$0")/_/husky.sh" + +npm run lint diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..f45a23b --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,91 @@ +# Contributing Guide + +Before submitting your contribution, please make sure to take a moment and read through the following guidelines: + +- [Pull Request Guidelines](#pull-request-guidelines) +- [Development Setup](#development-setup) +- [Scripts](#scripts) +- [Project Structure](#project-structure) +- [Contributing Tests](#contributing-tests) + +## Pull Request Guidelines + +- The master branch is just a snapshot of the latest stable release. All development should be done in dedicated branches. Do not submit PRs against the master branch. + +- Checkout a topic branch from a base branch, e.g. `master`, and merge back against that branch. + +- If adding a new feature add accompanying test case. + +- It's OK to have multiple small commits as you work on the PR - GitHub can automatically squash them before merging. + +- Make sure tests pass! + +- Commit messages must follow the [commit message convention](./commit-convention.md). Commit messages are automatically validated before commit (by invoking [Git Hooks](https://git-scm.com/docs/githooks) via [husky](https://github.com/typicode/husky)). + +- No need to worry about code style as long as you have installed the dev dependencies - modified files are automatically formatted with Prettier on commit (by invoking [Git Hooks](https://git-scm.com/docs/githooks) via [husky](https://github.com/typicode/husky)). + +## Development Setup + +You will need [Node.js](https://nodejs.org) **version 16+**. + +After cloning the repo, run: + +```bash +$ npm i # install the dependencies of the project +``` + +A high level overview of tools used: + +- [Jest](https://jestjs.io/) for unit testing +- [Prettier](https://prettier.io/) for code formatting + +## Scripts + +### `npm run lint` + +The `lint` script runs linter. + +```bash +# lint files +$ npm run lint +# fix linter errors +$ npm run lint:fix +``` + +### `npm run test` + +The `test` script simply calls the `jest` binary, so all [Jest CLI Options](https://jestjs.io/docs/en/cli) can be used. Some examples: + +```bash +# run all tests +$ npm run test + +# run all tests under the runtime-core package +$ npm run test -- runtime-core + +# run tests in a specific file +$ npm run test -- fileName + +# run a specific test in a specific file +$ npm run test -- fileName -t 'test name' +``` + +## Project Structure + +- **`src`**: contains the source code + + - **`api`**: contains group of methods and methods for the API. + + - **`helpers`**: contains utilities shared across the entire codebase. + + - **`tests`**: contains tests for the helpers directory. + + - **`tests`**: contains tests for the src directory. + +## Contributing Tests + +Unit tests are collocated with the code being tested inside directories named `tests`. Consult the [Jest docs](https://jestjs.io/docs/en/using-matchers) and existing test cases for how to write new test specs. Here are some additional guidelines: + +- Use the minimal API needed for a test case. For example, if a test can be written without involving the reactivity system or a component, it should be written so. This limits the test's exposure to changes in unrelated parts and makes it more stable. + +- Only use platform-specific runtimes if the test is asserting platform-specific behavior. diff --git a/commitlint.config.js b/commitlint.config.js new file mode 100644 index 0000000..c34aa79 --- /dev/null +++ b/commitlint.config.js @@ -0,0 +1,3 @@ +module.exports = { + extends: ['@commitlint/config-conventional'] +}; diff --git a/groups/decodeMsg.js b/groups/decodeMsg.js deleted file mode 100644 index 31829bb..0000000 --- a/groups/decodeMsg.js +++ /dev/null @@ -1,83 +0,0 @@ -const ed2curve = require('ed2curve'); -const nacl = require('tweetnacl/nacl-fast'); -const keys = require('../helpers/keys'); - -module.exports = (msg, senderPublicKey, passPhrase, nonce) => { - - const keypair = keys.createKeypairFromPassPhrase(passPhrase); - let privateKey = keypair.privateKey; - if (typeof msg === 'string') { - msg = hexToBytes(msg) - } - if (typeof nonce === 'string') { - nonce = hexToBytes(nonce) - } - - if (typeof senderPublicKey === 'string') { - senderPublicKey = hexToBytes(senderPublicKey) - } - - if (typeof privateKey === 'string') { - privateKey = hexToBytes(privateKey) - } - const DHPublicKey = ed2curve.convertPublicKey(senderPublicKey); - const DHSecretKey = ed2curve.convertSecretKey(privateKey); - const decrypted = nacl.box.open(msg, nonce, DHPublicKey, DHSecretKey); - return decrypted ? Utf8ArrayToStr(decrypted) : '' - -} - -function hexToBytes(hexString = '') { - - const bytes = [] - - for (let c = 0; c < hexString.length; c += 2) { - bytes.push(parseInt(hexString.substr(c, 2), 16)) - } - - return Uint8Array.from(bytes); - -} - -function Utf8ArrayToStr(array) { - - var out, i, len, c; - var char2, char3; - - out = ""; - len = array.length; - i = 0; - while (i < len) { - c = array[i++]; - switch (c >> 4) { - case 0: - case 1: - case 2: - case 3: - case 4: - case 5: - case 6: - case 7: - // 0xxxxxxx - out += String.fromCharCode(c); - break; - case 12: - case 13: - // 110x xxxx 10xx xxxx - char2 = array[i++]; - out += String.fromCharCode(((c & 0x1F) << 6) | (char2 & 0x3F)); - break; - case 14: - // 1110 xxxx 10xx xxxx 10xx xxxx - char2 = array[i++]; - char3 = array[i++]; - out += String.fromCharCode(((c & 0x0F) << 12) | - ((char2 & 0x3F) << 6) | - ((char3 & 0x3F) << 0)); - break; - } - } - - return out; - -} \ No newline at end of file diff --git a/groups/eth.js b/groups/eth.js deleted file mode 100644 index a8b086f..0000000 --- a/groups/eth.js +++ /dev/null @@ -1,24 +0,0 @@ -var Mnemonic = require('bitcore-mnemonic'); -const hdkey = require('hdkey'); -const HD_KEY_PATH = "m/44'/60'/3'/1/0"; -const { bufferToHex, privateToAddress } = require('ethereumjs-util'); -const eth = { } - -/** - * Generates a ETH account from the passphrase specified. - * @param {string} passphrase ADAMANT account passphrase - * @returns {{address: String, privateKey: Buffer}} - */ - -eth.keys = passphrase => { - const mnemonic = new Mnemonic(passphrase, Mnemonic.Words.ENGLISH); - const seed = mnemonic.toSeed(); - const privateKey = hdkey.fromMasterSeed(seed).derive(HD_KEY_PATH)._privateKey; - - return { - address: bufferToHex(privateToAddress(privateKey)), - privateKey: bufferToHex(privateKey) - }; -}; - -module.exports = eth; diff --git a/groups/get.js b/groups/get.js deleted file mode 100644 index 507c522..0000000 --- a/groups/get.js +++ /dev/null @@ -1,46 +0,0 @@ -const axios = require('axios'); -const logger = require('../helpers/logger'); -const validator = require('../helpers/validator'); - -const DEFAULT_GET_REQUEST_RETRIES = 3; // How much re-tries for get-requests by default. Total 3+1 tries - -module.exports = (nodeManager) => { - return (endpoint, params, maxRetries = DEFAULT_GET_REQUEST_RETRIES, retryNo = 0) => { - - let url = trimAny(endpoint, "/ ").replace(/^api\//, ''); - if (!url || !validator.validateEndpoint(endpoint)) - return validator.badParameter('endpoint') - - url = nodeManager.node() + '/api/' + url; - return axios.get(url, { params }) - .then(function (response) { - return validator.formatRequestResults(response, true) - }) - .catch(function (error) { - let logMessage = `[ADAMANT js-api] Get-request: Request to ${url} failed with ${error.response ? error.response.status : undefined} status code, ${error.toString()}${error.response && error.response.data ? '. Message: ' + error.response.data.toString().trim() : ''}. Try ${retryNo+1} of ${maxRetries+1}.`; - if (retryNo < maxRetries) { - logger.log(`${logMessage} Retrying…`); - return nodeManager.changeNodes() - .then(function () { - return module.exports(nodeManager)(endpoint, params, maxRetries, ++retryNo) - }) - } - logger.warn(`${logMessage} No more attempts, returning error.`); - return validator.formatRequestResults(error, false) - }) - - } -}; - -function trimAny(str, chars) { - if (!str || typeof str !== 'string') - return '' - let start = 0, - end = str.length; - while(start < end && chars.indexOf(str[start]) >= 0) - ++start; - while(end > start && chars.indexOf(str[end - 1]) >= 0) - --end; - return (start > 0 || end < str.length) ? str.substring(start, end) : str; -} - diff --git a/groups/sendMessage.js b/groups/sendMessage.js deleted file mode 100644 index 991628c..0000000 --- a/groups/sendMessage.js +++ /dev/null @@ -1,143 +0,0 @@ -const axios = require('axios'); -const logger = require('../helpers/logger'); -const keys = require('../helpers/keys'); -const constants = require('../helpers/constants'); -const encryptor = require('../helpers/encryptor'); -const transactionFormer = require('../helpers/transactionFormer'); -const validator = require('../helpers/validator'); -const getPublicKey = require('./getPublicKey'); - -const DEFAULT_SEND_MESSAGE_RETRIES = 4; // How much re-tries for send message requests by default. Total 4+1 tries - -module.exports = (nodeManager) => { - /** - * Encrypts a message, creates Message transaction, signs it, and broadcasts to ADAMANT network. Supports Basic, Rich and Signal Message Types. - * See https://github.com/Adamant-im/adamant/wiki/Message-Types - * @param {string} passPhrase Senders's passPhrase. Sender's address will be derived from it. - * @param {string} addressOrPublicKey Recipient's ADAMANT address or public key. - * Using public key is faster, as the library wouldn't request it from the network. - * Though we cache public keys, and next request with address will be processed as fast as with public key. - * @param {string} message Message plain text in case of basic message. Stringified JSON in case of rich or signal messages. The library will encrypt a message. - * Example of rich message for Ether in-chat transfer: `{"type":"eth_transaction","amount":"0.002","hash":"0xfa46d2b3c99878f1f9863fcbdb0bc27d220d7065c6528543cbb83ced84487deb","comments":"I like to send it, send it"}` - * @param {string, number} message_type Type of message: basic, rich, or signal - * @param {string, number} amount Amount to send with a message - * @param {boolean} isAmountInADM If amount specified in ADM, or in sats (10^-8 ADM) - * @param {number} maxRetries How much times to retry request - * @returns {Promise} Request results - */ - return async (passPhrase, addressOrPublicKey, message, message_type = 'basic', amount, isAmountInADM = true, maxRetries = DEFAULT_SEND_MESSAGE_RETRIES, retryNo = 0) => { - - let keyPair, data; - let address, publicKey; - - try { - - if (!validator.validatePassPhrase(passPhrase)) - return validator.badParameter('passPhrase') - - keyPair = keys.createKeypairFromPassPhrase(passPhrase); - - if (!validator.validateAdmAddress(addressOrPublicKey)) { - if (!validator.validateAdmPublicKey(addressOrPublicKey)) { - return validator.badParameter('addressOrPublicKey', addressOrPublicKey) - } else { - publicKey = addressOrPublicKey; - try { - address = keys.createAddressFromPublicKey(publicKey) - } catch (e) { - return validator.badParameter('addressOrPublicKey', addressOrPublicKey) - } - } - } else { - publicKey = ''; - address = addressOrPublicKey - } - - if (message_type === 'basic') - message_type = 1; - if (message_type === 'rich') - message_type = 2; - if (message_type === 'signal') - message_type = 3; - - if (!validator.validateMessageType(message_type)) - return validator.badParameter('message_type', message_type) - - let messageValidation = validator.validateMessage(message, message_type); - if (!messageValidation.result) - return validator.badParameter('message', message, messageValidation.error) - - data = { - keyPair, - recipientId: address, - message_type - }; - - if (amount) { - if (isAmountInADM) { - amountInSat = validator.AdmToSats(amount) - } else { - amountInSat = amount - } - if (!validator.validateIntegerAmount(amountInSat)) - return validator.badParameter('amount', amount) - data.amount = amountInSat; - } - - } catch (e) { - - return validator.badParameter('#exception_catched#', e) - - } - - if (!publicKey) - publicKey = await getPublicKey(nodeManager)(address); - - if (!publicKey) - return new Promise((resolve, reject) => { - resolve({ - success: false, - errorMessage: `Unable to get public key for ${addressOrPublicKey}. It is necessary for sending an encrypted message. Account may be uninitialized (https://medium.com/adamant-im/chats-and-uninitialized-accounts-in-adamant-5035438e2fcd), or network error` - }) - }) - - try { - - const encryptedMessage = encryptor.encodeMessage(message, keyPair, publicKey); - data.message = encryptedMessage.message; - data.own_message = encryptedMessage.own_message; - - let transaction = transactionFormer.createTransaction(constants.transactionTypes.CHAT_MESSAGE, data); - - let url = nodeManager.node() + '/api/transactions/process'; - return axios.post(url, { transaction }) - .then(function (response) { - return validator.formatRequestResults(response, true) - }) - .catch(function (error) { - let logMessage = `[ADAMANT js-api] Send message request: Request to ${url} failed with ${error.response ? error.response.status : undefined} status code, ${error.toString()}${error.response && error.response.data ? '. Message: ' + error.response.data.toString().trim() : ''}. Try ${retryNo+1} of ${maxRetries+1}.`; - if (retryNo < maxRetries) { - logger.log(`${logMessage} Retrying…`); - return nodeManager.changeNodes() - .then(function () { - return module.exports(nodeManager)(passPhrase, addressOrPublicKey, message, message_type, tokensAmount, maxRetries, ++retryNo) - }) - } - logger.warn(`${logMessage} No more attempts, returning error.`); - return validator.formatRequestResults(error, false) - }) - - } catch (e) { - - return new Promise((resolve, reject) => { - resolve({ - success: false, - errorMessage: `Unable to encode message '${message}' with public key ${publicKey}, or unable to build a transaction. Exception: ` + e - }) - }) - - } - - } // sendMessage() - -}; diff --git a/groups/sendTokens.js b/groups/sendTokens.js deleted file mode 100644 index 6e5da0d..0000000 --- a/groups/sendTokens.js +++ /dev/null @@ -1,92 +0,0 @@ -const axios = require('axios'); -const logger = require('../helpers/logger'); -const keys = require('../helpers/keys'); -const constants = require('../helpers/constants'); -const transactionFormer = require('../helpers/transactionFormer'); -const validator = require('../helpers/validator'); - -const DEFAULT_SEND_TOKENS_RETRIES = 4; // How much re-tries for send tokens requests by default. Total 4+1 tries - -module.exports = (nodeManager) => { - /** - * Creates Token Transfer transaction, signs it, and broadcasts to ADAMANT network - * See https://github.com/Adamant-im/adamant/wiki/Transaction-Types#type-0-token-transfer-transaction - * @param {string} passPhrase Senders's passPhrase. Sender's address will be derived from it. - * @param {string} addressOrPublicKey Recipient's ADAMANT address or public key. - * Address is preferred, as if we get public key, we should derive address from it. - * @param {string, number} amount Amount to send - * @param {boolean} isAmountInADM If amount specified in ADM, or in sats (10^-8 ADM) - * @param {number} maxRetries How much times to retry request - * @returns {Promise} Request results - */ - return (passPhrase, addressOrPublicKey, amount, isAmountInADM = true, maxRetries = DEFAULT_SEND_TOKENS_RETRIES, retryNo = 0) => { - - let transaction; - let address, publicKey; - - try { - - if (!validator.validatePassPhrase(passPhrase)) - return validator.badParameter('passPhrase') - - const keyPair = keys.createKeypairFromPassPhrase(passPhrase); - - if (!validator.validateAdmAddress(addressOrPublicKey)) { - if (!validator.validateAdmPublicKey(addressOrPublicKey)) { - return validator.badParameter('addressOrPublicKey', addressOrPublicKey) - } else { - publicKey = addressOrPublicKey; - try { - address = keys.createAddressFromPublicKey(publicKey) - } catch (e) { - return validator.badParameter('addressOrPublicKey', addressOrPublicKey) - } - } - } else { - publicKey = ''; - address = addressOrPublicKey - } - - if (isAmountInADM) { - amountInSat = validator.AdmToSats(amount) - } else { - amountInSat = amount - } - - if (!validator.validateIntegerAmount(amountInSat)) - return validator.badParameter('amount', amount) - - const data = { - keyPair, - recipientId: address, - amount: amountInSat - }; - - transaction = transactionFormer.createTransaction(constants.transactionTypes.SEND, data); - - } catch (e) { - - return validator.badParameter('#exception_catched#', e) - - } - - let url = nodeManager.node() + '/api/transactions/process'; - return axios.post(url, { transaction }) - .then(function (response) { - return validator.formatRequestResults(response, true) - }) - .catch(function (error) { - let logMessage = `[ADAMANT js-api] Send tokens request: Request to ${url} failed with ${error.response ? error.response.status : undefined} status code, ${error.toString()}${error.response && error.response.data ? '. Message: ' + error.response.data.toString().trim() : ''}. Try ${retryNo+1} of ${maxRetries+1}.`; - if (retryNo < maxRetries) { - logger.log(`${logMessage} Retrying…`); - return nodeManager.changeNodes() - .then(function () { - return module.exports(nodeManager)(passPhrase, addressOrPublicKey, amount, isAmountInADM, maxRetries, ++retryNo) - }) - } - logger.warn(`${logMessage} No more attempts, returning error.`); - return validator.formatRequestResults(error, false) - }) - - } -}; diff --git a/helpers/bignumber.js b/helpers/bignumber.js deleted file mode 100644 index c6b556d..0000000 --- a/helpers/bignumber.js +++ /dev/null @@ -1,119 +0,0 @@ -/* eslint-disable no-redeclare */ -'use strict' - -/** - * Buffer functions that implements bignumber. - * @memberof module:helpers - * @requires bignumber - * @constructor -*/ -var BigNumber = require('bignumber.js') - -/** - * Creates an instance from a Buffer. - * @param {ArrayBuffer} buf - * @param {Object} opts - * @return {ArrayBuffer} new BigNumber instance - * @throws {RangeError} error description multiple of size -*/ -BigNumber.fromBuffer = function (buf, opts) { - if (!opts) opts = {} - - var endian = { 1: 'big', '-1': 'little' }[opts.endian] || opts.endian || 'big' - - var size = opts.size === 'auto' ? Math.ceil(buf.length) : (opts.size || 1) - - if (buf.length % size !== 0) { - throw new RangeError('Buffer length (' + buf.length + ')' + - ' must be a multiple of size (' + size + ')' - ) - } - - var hex = [] - for (var i = 0; i < buf.length; i += size) { - var chunk = [] - for (var j = 0; j < size; j++) { - chunk.push(buf[i + (endian === 'big' ? j : (size - j - 1))]) - } - - hex.push(chunk - .map(function (c) { - return (c < 16 ? '0' : '') + c.toString(16) - }) - .join('') - ) - } - - return new BigNumber(hex.join(''), 16) -} - -/** - * Returns an instance as Buffer. - * @param {Object} opts - * @return {ArrayBuffer} new buffer | error message invalid option -*/ -BigNumber.prototype.toBuffer = function (opts) { - if (typeof opts === 'string') { - if (opts !== 'mpint') return 'Unsupported Buffer representation' - - var abs = this.abs() - var buf = abs.toBuffer({ size: 1, endian: 'big' }) - var len = buf.length === 1 && buf[0] === 0 ? 0 : buf.length - if (buf[0] & 0x80) len++ - - var ret = Buffer.alloc(4 + len) - if (len > 0) buf.copy(ret, 4 + (buf[0] & 0x80 ? 1 : 0)) - if (buf[0] & 0x80) ret[4] = 0 - - ret[0] = len & (0xff << 24) - ret[1] = len & (0xff << 16) - ret[2] = len & (0xff << 8) - ret[3] = len & (0xff << 0) - - // Two's compliment for negative integers - var isNeg = this.lt(0) - if (isNeg) { - for (var i = 4; i < ret.length; i++) { - ret[i] = 0xff - ret[i] - } - } - ret[4] = (ret[4] & 0x7f) | (isNeg ? 0x80 : 0) - if (isNeg) ret[ret.length - 1] ++ - - return ret - } - - if (!opts) opts = {} - - var endian = { 1: 'big', '-1': 'little' }[opts.endian] || opts.endian || 'big' - - var hex = this.toString(16) - if (hex.charAt(0) === '-') { - throw new Error( - 'Converting negative numbers to Buffers not supported yet' - ) - } - - var size = opts.size === 'auto' ? Math.ceil(hex.length / 2) : (opts.size || 1) - - var len = Math.ceil(hex.length / (2 * size)) * size - var buf = Buffer.alloc(len) - - // Zero-pad the hex string so the chunks are all `size` long - while (hex.length < 2 * len) hex = '0' + hex - - var hx = hex - .split(new RegExp('(.{' + (2 * size) + '})')) - .filter(function (s) { return s.length > 0 }) - - hx.forEach(function (chunk, i) { - for (var j = 0; j < size; j++) { - var ix = i * size + (endian === 'big' ? j : size - j - 1) - buf[ix] = parseInt(chunk.slice(j * 2, j * 2 + 2), 16) - } - }) - - return buf -} - -module.exports = BigNumber diff --git a/helpers/constants.js b/helpers/constants.js deleted file mode 100644 index bee5c31..0000000 --- a/helpers/constants.js +++ /dev/null @@ -1,43 +0,0 @@ -module.exports = { - - epochTime: new Date(Date.UTC(2017, 8, 2, 17, 0, 0, 0)), - fees: { - send: 50000000, - vote: 1000000000, - secondsignature: 500000000, - delegate: 30000000000, - multisignature: 500000000, - dapp: 2500000000, - old_chat_message: 500000, - chat_message: 100000, - profile_update: 5000000, - avatar_upload: 10000000, - state_store: 100000 - }, - transactionTypes: { - SEND: 0, - SIGNATURE: 1, - DELEGATE: 2, - VOTE: 3, - MULTI: 4, - DAPP: 5, - IN_TRANSFER: 6, - OUT_TRANSFER: 7, - CHAT_MESSAGE: 8, - STATE: 9 - }, - maxVotesPerTransaction: 33, - SAT: 100000000, - RE_HEX: /^[a-fA-F0-9]+$/, - RE_BASE64: /^([A-Za-z0-9+/]{4})*([A-Za-z0-9+/]{4}|[A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{2}==)$/, - RE_ADM_ADDRESS: /^U([0-9]{6,})$/, - RE_ADM_VOTE_FOR_PUBLIC_KEY: /^(\+|-)[a-fA-F0-9]{64}$/, - RE_ADM_VOTE_FOR_ADDRESS: /^(\+|-)U([0-9]{6,})$/, - RE_ADM_VOTE_FOR_DELEGATE_NAME: /^(\+|-)([a-z0-9!@$&_]{1,20})$/, - RE_ADM_DELEGATE_NAME: /^[a-z0-9!@$&_]{1,20}$/, - RE_BTC_ADDRESS: /^(bc1|[13])[a-km-zA-HJ-NP-Z02-9]{25,39}$/, - RE_DASH_ADDRESS: /^[7X][1-9A-HJ-NP-Za-km-z]{33,}$/, - RE_DOGE_ADDRESS: /^[A|D|9][A-Z0-9]([0-9a-zA-Z]{9,})$/, - RE_LSK_ADDRESS: /^[0-9]{2,21}L$/ - -} diff --git a/helpers/encryptor.js b/helpers/encryptor.js deleted file mode 100644 index fe5e4cf..0000000 --- a/helpers/encryptor.js +++ /dev/null @@ -1,35 +0,0 @@ -var sodium = require('sodium-browserify-tweetnacl') -var nacl = require('tweetnacl/nacl-fast') -var ed2curve = require('ed2curve') - -module.exports = { - - bytesToHex: function (bytes) { - for (var hex = [], i = 0; i < bytes.length; i++) { - hex.push((bytes[i] >>> 4).toString(16)) - hex.push((bytes[i] & 0xF).toString(16)) - } - return hex.join('') - }, - - hexToBytes: function (hex) { - for (var bytes = [], c = 0; c < hex.length; c += 2) { - bytes.push(parseInt(hex.substr(c, 2), 16)) - } - return bytes - }, - - encodeMessage: function (msg, keypair, recipientPublicKey) { - var nonce = Buffer.allocUnsafe(24) - sodium.randombytes(nonce) - var plainText = Buffer.from(msg.toString()) - var DHPublicKey = ed2curve.convertPublicKey(new Uint8Array(this.hexToBytes(recipientPublicKey))) - var DHSecretKey = ed2curve.convertSecretKey(keypair.privateKey) - var encrypted = nacl.box(plainText, nonce, DHPublicKey, DHSecretKey) - return { - message: this.bytesToHex(encrypted), - own_message: this.bytesToHex(nonce) - } - } - -} diff --git a/helpers/healthCheck.js b/helpers/healthCheck.js deleted file mode 100644 index 71b57e7..0000000 --- a/helpers/healthCheck.js +++ /dev/null @@ -1,198 +0,0 @@ -const axios = require('axios'); -const socket = require('./wsClient'); -const logger = require('./logger'); -const validator = require('./validator'); -const dnsPromises = require('dns').promises; - -const CHECK_NODES_INTERVAL = 60 * 5 * 1000; // Update active nodes every 5 minutes -const HEIGHT_EPSILON = 5; // Used to group nodes by height and choose synced - -module.exports = (nodes) => { - - isCheckingNodes = false; - nodesList = nodes; - activeNode = nodesList[0]; // Note: it may be not synced; and before first health check a node can reply with obsolete data - liveNodes = []; - - /** - * Updates active nodes. If nodes are already updating, returns Promise of previous call - * @returns {Promise} Call changeNodes().then to do something when update complete - */ - function changeNodes (isPlannedUpdate = false) { - if (!isCheckingNodes) { - changeNodesPromise = new Promise(async (resolve) => { - if (!isPlannedUpdate) { - logger.warn('[ADAMANT js-api] Health check: Forcing to update active nodes…'); - } - await checkNodes(isPlannedUpdate? false : true) - resolve(true) - }); - } - return changeNodesPromise - } - - changeNodes(true) - setInterval(() => { changeNodes(true) }, CHECK_NODES_INTERVAL); - - return { - - /** - * @returns {string} Current active node, f. e. http://88.198.156.44:36666 - */ - node: () => { - return activeNode; - }, - - changeNodes - - }; - -}; - -/** - * Requests every ADAMANT node for its status, makes a list of live nodes, and chooses one active - */ -async function checkNodes(forceChangeActiveNode) { - - this.isCheckingNodes = true; - this.liveNodes = []; - - try { - - for (const n of this.nodesList) { - try { - let start = unixTimestamp(); - let req = await checkNode(n + '/api/node/status'); - let url = n.replace(/^https?:\/\/(.*)$/, '$1').split(":")[0]; - let ifIP = /(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}/.test(url) - - if (req.status) { - this.liveNodes.push({ - node: n, - ifHttps: n.startsWith("https"), - ifIP, - url, - outOfSync: false, - ping: unixTimestamp() - start, - height: req.status.network.height, - heightEpsilon: Math.round(req.status.network.height / HEIGHT_EPSILON), - ip: ifIP ? url : await getIP(url), - socketSupport: req.status.wsClient && req.status.wsClient.enabled, - wsPort: req.status.wsClient.port - }); - - } else { - logger.log(`[ADAMANT js-api] Health check: Node ${n} haven't returned its status`); - } - - } catch (e) { - logger.log(`[ADAMANT js-api] Health check: Error while checking node ${n}, ` + e); - } - } - - const count = this.liveNodes.length; - let outOfSyncCount = 0; - - if (!count) { - logger.error(`[ADAMANT js-api] Health check: All of ${this.nodesList.length} nodes are unavailable. Check internet connection and nodes list in config.`); - } else { - - // Set activeNode to one that have maximum height and minimum ping - if (count === 1) { - - this.activeNode = this.liveNodes[0].node; - - } else if (count === 2) { - - const h0 = this.liveNodes[0]; - const h1 = this.liveNodes[1]; - this.activeNode = h0.height > h1.height ? h0.node : h1.node; - // Mark node outOfSync if needed - if (h0.heightEpsilon > h1.heightEpsilon) { - this.liveNodes[1].outOfSync = true - outOfSyncCount += 1; - } else if (h0.heightEpsilon < h1.heightEpsilon) { - this.liveNodes[0].outOfSync = true - outOfSyncCount += 1; - } - - } else { - - let biggestGroup = []; - // Removing lodash: const groups = _.groupBy(this.liveNodes, n => n.heightEpsilon); - const groups = this.liveNodes.reduce(function (grouped, node) { - var int = Math.floor(node.heightEpsilon); // Excessive, it is already rounded - if (!grouped.hasOwnProperty(int)) { - grouped[int] = []; - } - grouped[int].push(node); - return grouped; - }, {}); - - Object.keys(groups).forEach(key => { - if (groups[key].length > biggestGroup.length) { - biggestGroup = groups[key]; - } - }); - - // All the nodes from the biggestGroup list are considered to be in sync, all the others are not - this.liveNodes.forEach(node => { - node.outOfSync = !biggestGroup.includes(node) - }) - outOfSyncCount = this.liveNodes.length - biggestGroup.length; - - biggestGroup.sort((a, b) => a.ping - b.ping); - this.liveNodes.sort((a, b) => a.ping - b.ping); - - if (forceChangeActiveNode && biggestGroup.length > 1 && this.activeNode === biggestGroup[0].node) - this.activeNode = biggestGroup[validator.getRandomIntInclusive(1, biggestGroup.length-1)].node // Use random node from which are synced - else - this.activeNode = biggestGroup[0].node; // Use node with minimum ping among which are synced - - } - socket.reviseConnection(this.liveNodes); - let unavailableCount = this.nodesList.length - this.liveNodes.length; - let supportedCount = this.liveNodes.length - outOfSyncCount; - let nodesInfoString = ''; - if (unavailableCount) - nodesInfoString += `, ${unavailableCount} nodes didn't respond` - if (outOfSyncCount) - nodesInfoString += `, ${outOfSyncCount} nodes are not synced` - logger.log(`[ADAMANT js-api] Health check: Found ${supportedCount} supported and synced nodes${nodesInfoString}. Active node is ${this.activeNode}.`); - - } - - } catch (e) { - logger.warn('[ADAMANT js-api] Health check: Error in checkNodes(), ' + e); - } - - this.isCheckingNodes = false; - -} - -async function getIP(url) { - try { - let addresses = await dnsPromises.resolve4(url); - if (addresses && addresses[0] !== '0.0.0.0') - return addresses[0] - } catch (e) { } -}; - -/** - * Requests status from a single ADAMANT node - * @param url {string} Node URL to request - * @returns {Promise} Node's status information - */ -function checkNode(url) { - return axios.get(url) - .then(function (response) { - return { status: response.data } - }) - .catch(function (error) { - return false - }) -}; - -function unixTimestamp() { - return new Date().getTime(); -} diff --git a/helpers/keys.js b/helpers/keys.js deleted file mode 100644 index 0e2b317..0000000 --- a/helpers/keys.js +++ /dev/null @@ -1,39 +0,0 @@ -var sodium = require('sodium-browserify-tweetnacl'); -var crypto = require('crypto'); -var Mnemonic = require('bitcore-mnemonic'); -var bignum = require('./bignumber.js'); - -module.exports = { - - createNewPassPhrase: function () { - return new Mnemonic(Mnemonic.Words.ENGLISH).toString(); - }, - - makeKeypairFromHash: function (hash) { - var keypair = sodium.crypto_sign_seed_keypair(hash); - return { - publicKey: keypair.publicKey, - privateKey: keypair.secretKey - }; - }, - - createHashFromPassPhrase: function (passPhrase) { - var secretMnemonic = new Mnemonic(passPhrase, Mnemonic.Words.ENGLISH); - return crypto.createHash('sha256').update(secretMnemonic.toSeed().toString('hex'), 'hex').digest(); - }, - - createKeypairFromPassPhrase: function (passPhrase) { - var hash = this.createHashFromPassPhrase(passPhrase); - return this.makeKeypairFromHash(hash); - }, - - createAddressFromPublicKey: function (publicKey) { - var publicKeyHash = crypto.createHash('sha256').update(publicKey, 'hex').digest(); - var temp = Buffer.alloc(8); - for (var i = 0; i < 8; i++) { - temp[i] = publicKeyHash[7 - i]; - } - return 'U' + bignum.fromBuffer(temp).toString(); - } - -} diff --git a/helpers/logger.js b/helpers/logger.js deleted file mode 100644 index 43ca552..0000000 --- a/helpers/logger.js +++ /dev/null @@ -1,35 +0,0 @@ -let logger = { - - errorLevel: 'log', - l: console, - - initLogger(errorLevel, log) { - if (errorLevel) - this.errorLevel = errorLevel; - if (log) - this.l = log; - }, - - error(str) { - if (['error', 'warn', 'info', 'log'].includes(this.errorLevel)) - this.l.error(str); - }, - - warn(str) { - if (['warn', 'info', 'log'].includes(this.errorLevel)) - this.l.warn(str); - }, - - info(str) { - if (['info', 'log'].includes(this.errorLevel)) - this.l.info(str); - }, - - log(str) { - if (['log'].includes(this.errorLevel)) - this.l.log(str); - } - -}; - -module.exports = logger; diff --git a/helpers/time.js b/helpers/time.js deleted file mode 100644 index 4e46bf4..0000000 --- a/helpers/time.js +++ /dev/null @@ -1,18 +0,0 @@ -const constants = require('./constants.js'); - -module.exports = { - - getEpochTime: function (time) { - if (time === undefined) { - time = Date.now(); - } - var d = constants.epochTime; - var t = d.getTime(); - return Math.floor((time - t) / 1000); - }, - - getTime: function (time) { - return this.getEpochTime(time); - } - -} diff --git a/helpers/transactionFormer.js b/helpers/transactionFormer.js deleted file mode 100644 index 52107cd..0000000 --- a/helpers/transactionFormer.js +++ /dev/null @@ -1,273 +0,0 @@ -var sodium = require('sodium-browserify-tweetnacl'); -var crypto = require('crypto'); -var bignum = require('./bignumber.js'); -var keys = require('./keys.js'); -var ByteBuffer = require('bytebuffer'); -const constants = require('./constants.js'); -const time = require('./time.js'); - -module.exports = { - - createTransaction: function (type, data) { - switch (type) { - case constants.transactionTypes.SEND: - return this.createSendTransaction(data); - case constants.transactionTypes.VOTE: - return this.createVoteTransaction(data); - case constants.transactionTypes.DELEGATE: - return this.createDelegateTransaction(data); - case constants.transactionTypes.CHAT_MESSAGE: - return this.createChatTransaction(data); - case constants.transactionTypes.STATE: - return this.createStateTransaction(data); - } - return {}; - }, - - createBasicTransaction: function (data) { - var transaction = { type: data.transactionType, amount: 0, timestamp: time.getTime(), asset: {}, senderPublicKey: data.keyPair.publicKey.toString('hex'), senderId: keys.createAddressFromPublicKey(data.keyPair.publicKey) }; - return transaction; - }, - - createSendTransaction: function (data) { - data.transactionType = constants.transactionTypes.SEND; - var transaction = this.createBasicTransaction(data); - transaction.asset = {}; - transaction.recipientId = data.recipientId; - transaction.amount = data.amount; - transaction.signature = this.transactionSign(transaction, data.keyPair); - return transaction; - }, - - createStateTransaction: function (data) { - data.transactionType = constants.transactionTypes.STATE; - var transaction = this.createBasicTransaction(data); - transaction.asset = { - "state": { - key: data.key, - value: data.value, - type: 0 - } - }; - transaction.recipientId = null; - transaction.amount = 0; - transaction.signature = this.transactionSign(transaction, data.keyPair); - return transaction; - }, - - createChatTransaction: function (data) { - data.transactionType = constants.transactionTypes.CHAT_MESSAGE; - var transaction = this.createBasicTransaction(data); - transaction.asset = { - "chat": { - message: data.message, - own_message: data.own_message, - type: data.message_type - } - }; - transaction.recipientId = data.recipientId; - transaction.amount = data.amount || 0; - transaction.signature = this.transactionSign(transaction, data.keyPair); - return transaction; - }, - - createDelegateTransaction: function (data) { - data.transactionType = constants.transactionTypes.DELEGATE; - var transaction = this.createBasicTransaction(data); - transaction.asset = { "delegate": { "username": data.username, publicKey: data.keyPair.publicKey.toString('hex') } }; - transaction.recipientId = null; - transaction.signature = this.transactionSign(transaction, data.keyPair); - return transaction; - }, - - createVoteTransaction: function (data) { - data.transactionType = constants.transactionTypes.VOTE; - var transaction = this.createBasicTransaction(data); - transaction.asset = { "votes": data.votes }; - transaction.recipientId = transaction.senderId; - transaction.signature = this.transactionSign(transaction, data.keyPair); - return transaction; - }, - - getHash: function (trs) { - return crypto.createHash('sha256').update(this.getBytes(trs)).digest(); - }, - - getBytes: function (transaction) { - var skipSignature = false; - var skipSecondSignature = true; - var assetSize = 0; - var assetBytes = null; - - switch (transaction.type) { - case constants.transactionTypes.SEND: - break; - case constants.transactionTypes.DELEGATE: - assetBytes = this.delegatesGetBytes(transaction); - assetSize = assetBytes.length; - break; - case constants.transactionTypes.STATE: - assetBytes = this.statesGetBytes(transaction); - assetSize = assetBytes.length; - break; - case constants.transactionTypes.VOTE: - assetBytes = this.voteGetBytes(transaction); - assetSize = assetBytes.length; - break; - case constants.transactionTypes.CHAT_MESSAGE: - assetBytes = this.chatGetBytes(transaction); - assetSize = assetBytes.length; - break; - default: - // 'Not supported yet' - return 0; - } - - var bb = new ByteBuffer(1 + 4 + 32 + 8 + 8 + 64 + 64 + assetSize, true); - - bb.writeByte(transaction.type); - bb.writeInt(transaction.timestamp); - - var senderPublicKeyBuffer = Buffer.from(transaction.senderPublicKey, 'hex'); - for (var i = 0; i < senderPublicKeyBuffer.length; i++) { - bb.writeByte(senderPublicKeyBuffer[i]); - } - - if (transaction.requesterPublicKey) { - var requesterPublicKey = Buffer.from(transaction.requesterPublicKey, 'hex'); - - for (var i = 0; i < requesterPublicKey.length; i++) { - bb.writeByte(requesterPublicKey[i]); - } - } - - if (transaction.recipientId) { - var recipient = transaction.recipientId.slice(1); - recipient = new bignum(recipient).toBuffer({ size: 8 }); - - for (i = 0; i < 8; i++) { - bb.writeByte(recipient[i] || 0); - } - } else { - for (i = 0; i < 8; i++) { - bb.writeByte(0); - } - } - - bb.writeLong(transaction.amount); - - if (assetSize > 0) { - for (var i = 0; i < assetSize; i++) { - bb.writeByte(assetBytes[i]); - } - } - - if (!skipSignature && transaction.signature) { - var signatureBuffer = Buffer.from(transaction.signature, 'hex'); - for (var i = 0; i < signatureBuffer.length; i++) { - bb.writeByte(signatureBuffer[i]); - } - } - - if (!skipSecondSignature && transaction.signSignature) { - var signSignatureBuffer = Buffer.from(transaction.signSignature, 'hex'); - for (var i = 0; i < signSignatureBuffer.length; i++) { - bb.writeByte(signSignatureBuffer[i]); - } - } - - bb.flip(); - var arrayBuffer = new Uint8Array(bb.toArrayBuffer()); - var buffer = []; - - for (var i = 0; i < arrayBuffer.length; i++) { - buffer[i] = arrayBuffer[i]; - } - - return Buffer.from(buffer); - }, - - transactionSign: function (trs, keypair) { - var hash = this.getHash(trs); - return this.sign(hash, keypair).toString('hex'); - }, - - voteGetBytes: function (trs) { - var buf; - try { - buf = trs.asset.votes ? Buffer.from(trs.asset.votes.join(''), 'utf8') : null; - } catch (e) { - throw e; - } - return buf; - }, - - delegatesGetBytes: function (trs) { - if (!trs.asset.delegate.username) { - return null; - } - var buf; - - try { - buf = Buffer.from(trs.asset.delegate.username, 'utf8'); - } catch (e) { - throw e; - } - return buf; - }, - - statesGetBytes: function (trs) { - if (!trs.asset.state.value) { - return null; - } - var buf; - - try { - buf = Buffer.from([]); - var stateBuf = Buffer.from(trs.asset.state.value); - buf = Buffer.concat([buf, stateBuf]); - if (trs.asset.state.key) { - var keyBuf = Buffer.from(trs.asset.state.key); - buf = Buffer.concat([buf, keyBuf]); - } - - var bb = new ByteBuffer(4 + 4, true); - bb.writeInt(trs.asset.state.type); - bb.flip(); - - buf = Buffer.concat([buf, bb.toBuffer()]); - } catch (e) { - throw e; - } - - return buf; - }, - - chatGetBytes: function (trs) { - var buf; - - try { - buf = Buffer.from([]); - var messageBuf = Buffer.from(trs.asset.chat.message, 'hex'); - buf = Buffer.concat([buf, messageBuf]); - - if (trs.asset.chat.own_message) { - var ownMessageBuf = Buffer.from(trs.asset.chat.own_message, 'hex'); - buf = Buffer.concat([buf, ownMessageBuf]); - } - var bb = new ByteBuffer(4 + 4, true); - bb.writeInt(trs.asset.chat.type); - bb.flip(); - buf = Buffer.concat([buf, Buffer.from(bb.toBuffer())]); - } catch (e) { - throw e; - } - - return buf; - }, - - sign: function (hash, keypair) { - return sodium.crypto_sign_detached(hash, Buffer.from(keypair.privateKey, 'hex')); - } - -}; diff --git a/helpers/validator.js b/helpers/validator.js deleted file mode 100644 index d1df0a8..0000000 --- a/helpers/validator.js +++ /dev/null @@ -1,173 +0,0 @@ -const constants = require('./constants'); -const BigNumber = require('bignumber.js') - -module.exports = { - - getRandomIntInclusive(min, max) { - min = Math.ceil(min); - max = Math.floor(max); - return Math.floor(Math.random() * (max - min + 1) + min); //The maximum is inclusive and the minimum is inclusive - }, - - isNumeric(str) { - if (typeof str !== "string") return false - return !isNaN(str) && !isNaN(parseFloat(str)) - }, - - tryParseJSON(jsonString) { - try { - let o = JSON.parse(jsonString); - if (o && typeof o === "object") { - return o; - } - } catch (e) { } - return false - }, - - validatePassPhrase(passPhrase) { - if (!passPhrase || typeof(passPhrase) !== 'string' || passPhrase.length < 30) - return false - else - return true - }, - - validateEndpoint(endpoint) { - if (!endpoint || typeof(endpoint) !== 'string') - return false - else - return true - }, - - validateAdmAddress(address) { - if (!address || typeof(address) !== 'string' || !constants.RE_ADM_ADDRESS.test(address)) - return false - else - return true - }, - - validateAdmPublicKey(publicKey) { - if (!publicKey || typeof(publicKey) !== 'string' || publicKey.length !== 64 || !constants.RE_HEX.test(publicKey)) - return false - else - return true - }, - - validateAdmVoteForPublicKey(publicKey) { - return (publicKey && typeof(publicKey) === 'string' && constants.RE_ADM_VOTE_FOR_PUBLIC_KEY.test(publicKey)); - }, - - validateAdmVoteForAddress(address) { - return (address && typeof(address) === 'string' && constants.RE_ADM_VOTE_FOR_ADDRESS.test(address)); - }, - - validateAdmVoteForDelegateName(delegateName) { - return (delegateName && typeof(delegateName) === 'string' && constants.RE_ADM_VOTE_FOR_DELEGATE_NAME.test(delegateName)); - }, - - validateIntegerAmount(amount) { - if (!amount || typeof(amount) !== 'number' || isNaN(amount) || !Number.isSafeInteger(amount)) - return false - else - return true - }, - - validateStringAmount(amount) { - if (!amount || !this.isNumeric(amount)) - return false - else - return true - }, - - validateMessageType(message_type) { - if (!message_type || typeof(message_type) !== 'number' || ![1,2,3].includes(message_type)) - return false - else - return true - }, - - validateMessage(message, message_type) { - if (typeof(message) !== 'string') - return { - result: false, - error: `Message must be a string` - } - else { - if (message_type === 2 || message_type === 3) { - - let json = this.tryParseJSON(message) - - if (!json) - return { - result: false, - error: `For rich and signal messages, 'message' must be a JSON string` - } - - if (json.type && json.type.toLowerCase().includes('_transaction')) - if (json.type.toLowerCase() !== json.type) - return { - result: false, - error: `Value '_transaction' must be in lower case` - } - - if (typeof json.amount !== 'string' || !this.validateStringAmount(json.amount)) - return { - result: false, - error: `Field 'amount' must be a string, representing a number` - } - - } - } - return { - result: true - } - }, - - validateDelegateName(name) { - if (typeof name !== 'string') { - return false; - } - - return constants.RE_ADM_DELEGATE_NAME.test(name); - }, - - AdmToSats(amount) { - return BigNumber(String(amount)).multipliedBy(constants.SAT).integerValue().toNumber() - }, - - badParameter(name, value, customMessage) { - return new Promise((resolve, reject) => { - resolve({ - success: false, - errorMessage: `Wrong '${name}' parameter${value ? ': ' + value : ''}${customMessage ? '. Error: ' + customMessage : ''}` - }) - }) - }, - - formatRequestResults(response, isRequestSuccess) { - let results = {}; - results.details = {}; - - if (isRequestSuccess) { - results.success = response.data && response.data.success; - results.data = response.data; - results.details.status = response.status; - results.details.statusText = response.statusText; - results.details.response = response; - if (!results.success && results.data) - results.errorMessage = `Node's reply: ${results.data.error}` - } else { - results.success = false; - results.data = undefined; - results.details.status = response.response ? response.response.status : undefined; - results.details.statusText = response.response ? response.response.statusText : undefined; - results.details.error = response.toString(); - results.details.message = response.response && response.response.data ? response.response.data.toString().trim() : undefined; - results.details.response = response.response; - results.errorMessage = `${results.details.error}${results.details.message ? '. Message: ' + results.details.message : ''}`; - } - - return results; - - } - -}; diff --git a/helpers/wsClient.js b/helpers/wsClient.js deleted file mode 100644 index 05853b7..0000000 --- a/helpers/wsClient.js +++ /dev/null @@ -1,100 +0,0 @@ -const ioClient = require("socket.io-client"); -const logger = require('./logger'); -const validator = require('./validator'); - -module.exports = { - - isSocketEnabled: false, // If we need socket connection - wsType: "ws", // Socket connection type, "ws" (default) or "wss" - admAddress: '', // ADM address to subscribe to notifications - connection: null, // Socket connection - onNewMessage: null, // Method to process new messages or transactions - activeNodes: [], // List of nodes that are active. Not all of them synced and support socket. - activeSocketNodes: [], // List of nodes that are active, synced and support socket - useFastest: false, // If to connect to node with minimum ping. Not recommended. - - // Constructor - initSocket(params) { - this.onNewMessage = params.onNewMessage; - this.isSocketEnabled = params.socket; - this.wsType = params.wsType; - this.admAddress = params.admAddress; - }, - - // Runs after every healthCheck() to re-connect socket if needed - reviseConnection(nodes) { - if (!this.isSocketEnabled) { - return; - } - if (!this.connection || !this.connection.connected) { - this.activeNodes = nodes.slice(); - this.setNodes(); - this.setConnection(); - } - }, - - // Make socket connection and subscribe to new transactions - setConnection() { - if (this.activeSocketNodes.length === 0) { - logger.warn(`[Socket] No supported socket nodes at the moment.`); - return; - } - - const node = this.socketAddress(); - logger.log(`[Socket] Supported nodes: ${this.activeSocketNodes.length}. Connecting to ${node}...`); - this.connection = ioClient.connect(node, { reconnection: false, timeout: 5000 }); - - this.connection.on('connect', () => { - this.connection.emit('address', this.admAddress); - logger.info('[Socket] Connected to ' + node + ' and subscribed to incoming transactions for ' + this.admAddress + '.'); - }); - - this.connection.on('disconnect', reason => { - logger.warn('[Socket] Disconnected. Reason: ' + reason) - }); - - this.connection.on('connect_error', (err) => { - logger.warn('[Socket] Connection error: ' + err) - }); - - this.connection.on('newTrans', transaction => { - if ((transaction.recipientId === this.admAddress) && (transaction.type === 0 || transaction.type === 8)) { - // console.info(`[Socket] New incoming socket transaction received: ${transaction.id}`); - this.onNewMessage(transaction); - } - }); - }, - - // Save the list of nodes activeSocketNodes that are active, synced and support socket - setNodes() { - this.activeSocketNodes = this.activeNodes.filter(n => n.socketSupport & !n.outOfSync); - // Remove nodes without IP if "ws" connection type - if (this.wsType === "ws") { - this.activeSocketNodes = this.activeSocketNodes.filter(n => !n.ifHttps || n.ip); - } - }, - - // Returns socket url for connection - socketAddress() { - const node = this.useFastest ? this.fastestNode() : this.randomNode(); - let socketUrl = this.wsType + "://"; - if (this.wsType === "ws") { - let host = node.ip; - if (!host || host === undefined) - host = node.url; - socketUrl = socketUrl + host + ":" + node.wsPort - } else { - socketUrl = socketUrl + node.url; // no port if wss - } - return socketUrl; - }, - - fastestNode() { - return this.activeSocketNodes[0]; // They are sorted by ping - }, - - randomNode() { - return this.activeSocketNodes[validator.getRandomIntInclusive(0, this.activeSocketNodes.length - 1)] - } - -} diff --git a/package.json b/package.json index 7a7c87e..9ce49f2 100644 --- a/package.json +++ b/package.json @@ -2,16 +2,20 @@ "name": "adamant-api", "version": "1.5.0", "description": "REST API for ADAMANT Blockchain", - "main": "index.js", + "main": "src/index.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "test": "jest", + "lint": "eslint src", + "lint:fix": "eslint --fix src", + "prepare": "husky install" }, - "author": "RomanS, Aleksei Lebedev (https://adamant.im)", + "author": "ADAMANT Foundation (https://adamant.im)", "license": "GPL-3.0", "dependencies": { + "@liskhq/lisk-cryptography": "3.2.0", "axios": "^0.25.0", "bignumber.js": "^9.0.2", - "bitcoinjs-lib": "^5.2.0", + "bitcoinjs-lib": "^5.2.0", "bitcore-mnemonic": "^8.25.25", "bytebuffer": "^5.0.1", "coininfo": "^5.1.0", @@ -19,7 +23,6 @@ "ethereumjs-util": "^7.1.4", "hdkey": "^2.0.1", "pbkdf2": "^3.1.2", - "@liskhq/lisk-cryptography": "3.2.0", "socket.io-client": "^2.4.0", "sodium-browserify-tweetnacl": "^0.2.6" }, @@ -58,5 +61,14 @@ "bugs": { "url": "https://github.com/Adamant-im/adamant-api-jsclient/issues" }, - "homepage": "https://github.com/Adamant-im/adamant-api-jsclient#readme" + "homepage": "https://github.com/Adamant-im/adamant-api-jsclient#readme", + "devDependencies": { + "@commitlint/cli": "^16.2.1", + "@commitlint/config-conventional": "^16.2.1", + "eslint": "^8.9.0", + "eslint-config-google": "^0.14.0", + "eslint-plugin-jest": "^26.1.0", + "husky": "^7.0.4", + "jest": "^27.5.1" + } } diff --git a/groups/btc.js b/src/groups/btc.js similarity index 57% rename from groups/btc.js rename to src/groups/btc.js index 601023f..40c4771 100644 --- a/groups/btc.js +++ b/src/groups/btc.js @@ -1,6 +1,6 @@ -var bitcoin = require('bitcoinjs-lib'); -var coinNetworks = require('./coinNetworks'); -const btc = { } +const bitcoin = require('bitcoinjs-lib'); +const coinNetworks = require('./coinNetworks'); +const btc = { }; /** * Generates a BTC account from the passphrase specified. @@ -8,20 +8,19 @@ const btc = { } * @returns {object} network info, keyPair, privateKey, privateKeyWIF */ -btc.keys = passphrase => { +btc.keys = (passphrase) => { const network = coinNetworks.BTC; const pwHash = bitcoin.crypto.sha256(Buffer.from(passphrase)); - const keyPair = bitcoin.ECPair.fromPrivateKey(pwHash, { network }); + const keyPair = bitcoin.ECPair.fromPrivateKey(pwHash, {network}); return { network, keyPair, - address: bitcoin.payments.p2pkh({ pubkey: keyPair.publicKey, network }).address, + address: bitcoin.payments.p2pkh({pubkey: keyPair.publicKey, network}).address, // BTC private key is a regular 256-bit key privateKey: keyPair.privateKey.toString('hex'), // regular 256-bit (32 bytes, 64 characters) private key - privateKeyWIF: keyPair.toWIF() // Wallet Import Format (52 base58 characters) - } - + privateKeyWIF: keyPair.toWIF(), // Wallet Import Format (52 base58 characters) + }; }; module.exports = btc; diff --git a/groups/coinNetworks.js b/src/groups/coinNetworks.js similarity index 78% rename from groups/coinNetworks.js rename to src/groups/coinNetworks.js index 3a42b31..d3bc749 100644 --- a/groups/coinNetworks.js +++ b/src/groups/coinNetworks.js @@ -1,4 +1,4 @@ -coininfo = require('coininfo'); +const coininfo = require('coininfo'); module.exports = { DOGE: coininfo.dogecoin.main.toBitcoinJS(), @@ -8,6 +8,6 @@ module.exports = { name: 'Lisk', port: 8000, wsPort: 8001, - unit: 'LSK' + unit: 'LSK', }, -} +}; diff --git a/groups/dash.js b/src/groups/dash.js similarity index 58% rename from groups/dash.js rename to src/groups/dash.js index fc95ee8..80daec5 100644 --- a/groups/dash.js +++ b/src/groups/dash.js @@ -1,6 +1,6 @@ -var bitcoin = require('bitcoinjs-lib'); -var coinNetworks = require('./coinNetworks'); -const dash = { } +const bitcoin = require('bitcoinjs-lib'); +const coinNetworks = require('./coinNetworks'); +const dash = { }; /** * Generates a DASH account from the passphrase specified. @@ -8,20 +8,19 @@ const dash = { } * @returns {object} network info, keyPair, privateKey, privateKeyWIF */ -dash.keys = passphrase => { +dash.keys = (passphrase) => { const network = coinNetworks.DASH; const pwHash = bitcoin.crypto.sha256(Buffer.from(passphrase)); - const keyPair = bitcoin.ECPair.fromPrivateKey(pwHash, { network }); + const keyPair = bitcoin.ECPair.fromPrivateKey(pwHash, {network}); return { network, keyPair, - address: bitcoin.payments.p2pkh({ pubkey: keyPair.publicKey, network }).address, + address: bitcoin.payments.p2pkh({pubkey: keyPair.publicKey, network}).address, // DASH private key is a regular 256-bit key privateKey: keyPair.privateKey.toString('hex'), // regular 256-bit (32 bytes, 64 characters) private key - privateKeyWIF: keyPair.toWIF() // Wallet Import Format (52 base58 characters) - } - + privateKeyWIF: keyPair.toWIF(), // Wallet Import Format (52 base58 characters) + }; }; module.exports = dash; diff --git a/src/groups/decodeMsg.js b/src/groups/decodeMsg.js new file mode 100644 index 0000000..3b700c1 --- /dev/null +++ b/src/groups/decodeMsg.js @@ -0,0 +1,83 @@ +const ed2curve = require('ed2curve'); +const nacl = require('tweetnacl/nacl-fast'); +const keys = require('../helpers/keys'); + +module.exports = (msg, senderPublicKey, passPhrase, nonce) => { + const keypair = keys.createKeypairFromPassPhrase(passPhrase); + + let {privateKey} = keypair; + + if (typeof msg === 'string') { + msg = hexToBytes(msg); + } + + if (typeof nonce === 'string') { + nonce = hexToBytes(nonce); + } + + if (typeof senderPublicKey === 'string') { + senderPublicKey = hexToBytes(senderPublicKey); + } + + if (typeof privateKey === 'string') { + privateKey = hexToBytes(privateKey); + } + + const DHPublicKey = ed2curve.convertPublicKey(senderPublicKey); + const DHSecretKey = ed2curve.convertSecretKey(privateKey); + const decrypted = nacl.box.open(msg, nonce, DHPublicKey, DHSecretKey); + + return decrypted ? utf8ArrayToStr(decrypted) : ''; +}; + +function hexToBytes(hexString = '') { + const bytes = []; + + for (let c = 0; c < hexString.length; c += 2) { + bytes.push(parseInt(hexString.substring(c, c + 2), 16)); + } + + return Uint8Array.from(bytes); +} + +function utf8ArrayToStr(array) { + const len = array.length; + let out = ''; + let i = 0; + let c; + let char2; + let char3; + + while (i < len) { + c = array[i++]; + switch (c >> 4) { + case 0: + case 1: + case 2: + case 3: + case 4: + case 5: + case 6: + case 7: + // 0xxxxxxx + out += String.fromCharCode(c); + break; + case 12: + case 13: + // 110x xxxx 10xx xxxx + char2 = array[i++]; + out += String.fromCharCode(((c & 0x1F) << 6) | (char2 & 0x3F)); + break; + case 14: + // 1110 xxxx 10xx xxxx 10xx xxxx + char2 = array[i++]; + char3 = array[i++]; + out += String.fromCharCode(((c & 0x0F) << 12) | + ((char2 & 0x3F) << 6) | + ((char3 & 0x3F) << 0)); + break; + } + } + + return out; +} diff --git a/groups/doge.js b/src/groups/doge.js similarity index 58% rename from groups/doge.js rename to src/groups/doge.js index 08eca09..ceb0ad5 100644 --- a/groups/doge.js +++ b/src/groups/doge.js @@ -1,6 +1,6 @@ -var bitcoin = require('bitcoinjs-lib'); -var coinNetworks = require('./coinNetworks'); -const doge = { } +const bitcoin = require('bitcoinjs-lib'); +const coinNetworks = require('./coinNetworks'); +const doge = { }; /** * Generates a DOGE account from the passphrase specified. @@ -8,20 +8,19 @@ const doge = { } * @returns {object} network info, keyPair, privateKey, privateKeyWIF */ -doge.keys = passphrase => { +doge.keys = (passphrase) => { const network = coinNetworks.DOGE; const pwHash = bitcoin.crypto.sha256(Buffer.from(passphrase)); - const keyPair = bitcoin.ECPair.fromPrivateKey(pwHash, { network }); + const keyPair = bitcoin.ECPair.fromPrivateKey(pwHash, {network}); return { network, keyPair, - address: bitcoin.payments.p2pkh({ pubkey: keyPair.publicKey, network }).address, + address: bitcoin.payments.p2pkh({pubkey: keyPair.publicKey, network}).address, // DOGE private key is a regular 256-bit key privateKey: keyPair.privateKey.toString('hex'), // regular 256-bit (32 bytes, 64 characters) private key - privateKeyWIF: keyPair.toWIF() // Wallet Import Format (52 base58 characters) - } - + privateKeyWIF: keyPair.toWIF(), // Wallet Import Format (52 base58 characters) + }; }; module.exports = doge; diff --git a/src/groups/eth.js b/src/groups/eth.js new file mode 100644 index 0000000..524f43e --- /dev/null +++ b/src/groups/eth.js @@ -0,0 +1,24 @@ +const Mnemonic = require('bitcore-mnemonic'); +const hdkey = require('hdkey'); +const HD_KEY_PATH = 'm/44\'/60\'/3\'/1/0'; +const {bufferToHex, privateToAddress} = require('ethereumjs-util'); +const eth = { }; + +/** + * Generates a ETH account from the passphrase specified. + * @param {string} passphrase ADAMANT account passphrase + * @returns {{address: String, privateKey: Buffer}} + */ + +eth.keys = (passphrase) => { + const mnemonic = new Mnemonic(passphrase, Mnemonic.Words.ENGLISH); + const seed = mnemonic.toSeed(); + const privateKey = hdkey.fromMasterSeed(seed).derive(HD_KEY_PATH)._privateKey; + + return { + address: bufferToHex(privateToAddress(privateKey)), + privateKey: bufferToHex(privateKey), + }; +}; + +module.exports = eth; diff --git a/src/groups/get.js b/src/groups/get.js new file mode 100644 index 0000000..8090efa --- /dev/null +++ b/src/groups/get.js @@ -0,0 +1,48 @@ +const axios = require('../helpers/axiosClient'); +const logger = require('../helpers/logger'); +const validator = require('../helpers/validator'); + +const DEFAULT_GET_REQUEST_RETRIES = 3; // How much re-tries for get-requests by default. Total 3+1 tries + +module.exports = (nodeManager) => { + return (endpoint, params, maxRetries = DEFAULT_GET_REQUEST_RETRIES, retryNo = 0) => { + let url = trimAny(endpoint, '/ ').replace(/^api\//, ''); + if (!url || !validator.validateEndpoint(endpoint)) { + return validator.badParameter('endpoint'); + } + + url = nodeManager.node() + '/api/' + url; + return axios.get(url, {params}) + .then(function(response) { + return validator.formatRequestResults(response, true); + }) + .catch(function(error) { + const logMessage = `[ADAMANT js-api] Get-request: Request to ${url} failed with ${error.response ? error.response.status : undefined} status code, ${error.toString()}${error.response && error.response.data ? '. Message: ' + error.response.data.toString().trim() : ''}. Try ${retryNo + 1} of ${maxRetries + 1}.`; + if (retryNo < maxRetries) { + logger.log(`${logMessage} Retrying…`); + return nodeManager.changeNodes() + .then(function() { + return module.exports(nodeManager)(endpoint, params, maxRetries, ++retryNo); + }); + } + logger.warn(`${logMessage} No more attempts, returning error.`); + return validator.formatRequestResults(error, false); + }); + }; +}; + +function trimAny(str, chars) { + if (!str || typeof str !== 'string') { + return ''; + } + let start = 0; + let end = str.length; + while (start < end && chars.indexOf(str[start]) >= 0) { + ++start; + } + while (end > start && chars.indexOf(str[end - 1]) >= 0) { + --end; + } + return (start > 0 || end < str.length) ? str.substring(start, end) : str; +} + diff --git a/groups/getPublicKey.js b/src/groups/getPublicKey.js similarity index 73% rename from groups/getPublicKey.js rename to src/groups/getPublicKey.js index dc40b21..1ce213f 100644 --- a/groups/getPublicKey.js +++ b/src/groups/getPublicKey.js @@ -3,19 +3,18 @@ const logger = require('../helpers/logger'); const publicKeysCache = { }; module.exports = (nodeManager) => { - return async (address) => { - if (publicKeysCache[address]) - return publicKeysCache[address] - - const publicKey = await get(nodeManager)('/accounts/getPublicKey', { address }); + if (publicKeysCache[address]) { + return publicKeysCache[address]; + } + + const publicKey = await get(nodeManager)('/accounts/getPublicKey', {address}); if (publicKey.success) { publicKeysCache[address] = publicKey.data.publicKey; - return publicKey.data.publicKey + return publicKey.data.publicKey; } else { logger.warn(`[ADAMANT js-api] Failed to get public key for ${address}. ${publicKey.errorMessage}.`); - return false + return false; } - } - + }; }; diff --git a/groups/lsk.js b/src/groups/lsk.js similarity index 85% rename from groups/lsk.js rename to src/groups/lsk.js index 51f467b..9d2ea53 100644 --- a/groups/lsk.js +++ b/src/groups/lsk.js @@ -1,5 +1,5 @@ -const cryptography = require('@liskhq/lisk-cryptography') -const sodium = require('sodium-browserify-tweetnacl') +const cryptography = require('@liskhq/lisk-cryptography'); +const sodium = require('sodium-browserify-tweetnacl'); const pbkdf2 = require('pbkdf2'); const coinNetworks = require('./coinNetworks'); @@ -11,8 +11,8 @@ const LiskHashSettings = { SALT: 'adm', ITERATIONS: 2048, KEYLEN: 32, - DIGEST: 'sha256' -} + DIGEST: 'sha256', +}; /** * Generates a LSK account from the passphrase specified. @@ -20,7 +20,7 @@ const LiskHashSettings = { * @returns {object} network info, keyPair, address, addressHexBinary, addressHex, privateKey */ -lsk.keys = passphrase => { +lsk.keys = (passphrase) => { const network = coinNetworks.LSK; const liskSeed = pbkdf2.pbkdf2Sync(passphrase, LiskHashSettings.SALT, LiskHashSettings.ITERATIONS, LiskHashSettings.KEYLEN, LiskHashSettings.DIGEST); const keyPair = sodium.crypto_sign_seed_keypair(liskSeed); @@ -35,8 +35,8 @@ lsk.keys = passphrase => { address, addressHexBinary, addressHex, - privateKey - } + privateKey, + }; }; module.exports = lsk; diff --git a/groups/newDelegate.js b/src/groups/newDelegate.js similarity index 80% rename from groups/newDelegate.js rename to src/groups/newDelegate.js index 1906f9a..48088b2 100644 --- a/groups/newDelegate.js +++ b/src/groups/newDelegate.js @@ -1,4 +1,4 @@ -const axios = require('axios'); +const axios = require('../helpers/axiosClient'); const logger = require('../helpers/logger'); const keys = require('../helpers/keys'); const constants = require('../helpers/constants'); @@ -9,27 +9,27 @@ const DEFAULT_NEW_DELEGATE_RETRIES = 4; // How much re-tries for send tokens req module.exports = (nodeManager) => { /** - * Registers user account as delegate + * Registers user account as delegate * @param {string} passPhrase Senders's passPhrase. Sender's address will be derived from it. * @param {string} username Delegate name you want to register with. * It must be unique in ADAMANT blockchain. It should not be similar to ADAMANT address. * Delegate name can only contain alphanumeric characters and symbols !@$&_. * @param {number} maxRetries How much times to retry request - * @returns {Promise} Request results - */ - return async (passPhrase, username, maxRetries = DEFAULT_NEW_DELEGATE_RETRIES, retryNo = 0) => { - + * @param {number} retryNo Number of request already made + * @return {Promise} Request results + */ + return async (passPhrase, username, maxRetries = DEFAULT_NEW_DELEGATE_RETRIES, retryNo = 0) => { let transaction; try { if (!validator.validatePassPhrase(passPhrase)) { - return validator.badParameter('passPhrase'); + return validator.badParameter('passPhrase'); } const keyPair = keys.createKeypairFromPassPhrase(passPhrase); if (!validator.validateDelegateName(username)) { - return validator.badParameter('username'); + return validator.badParameter('username'); } const type = constants.transactionTypes.DELEGATE; @@ -41,7 +41,6 @@ module.exports = (nodeManager) => { }; transaction = transactionFormer.createTransaction(type, data); - } catch (e) { return validator.badParameter('#exception_catched#', e); } @@ -59,14 +58,14 @@ module.exports = (nodeManager) => { logger.log(`${logMessage} Retrying…`); return nodeManager.changeNodes() - .then(() => ( - module.exports(nodeManager)(passPhrase, addressOrPublicKey, amount, isAmountInADM, maxRetries, ++retryNo) - )); + .then(() => ( + module.exports(nodeManager)(passPhrase, username, maxRetries, ++retryNo) + )); } logger.warn(`${logMessage} No more attempts, returning error.`); return validator.formatRequestResults(error, false); } - } + }; }; diff --git a/src/groups/sendMessage.js b/src/groups/sendMessage.js new file mode 100644 index 0000000..e402f34 --- /dev/null +++ b/src/groups/sendMessage.js @@ -0,0 +1,142 @@ +const axios = require('../helpers/axiosClient'); +const logger = require('../helpers/logger'); +const keys = require('../helpers/keys'); +const constants = require('../helpers/constants'); +const encryptor = require('../helpers/encryptor'); +const transactionFormer = require('../helpers/transactionFormer'); +const validator = require('../helpers/validator'); +const getPublicKey = require('./getPublicKey'); + +const DEFAULT_SEND_MESSAGE_RETRIES = 4; // How much re-tries for send message requests by default. Total 4+1 tries + +module.exports = (nodeManager) => { + /** + * Encrypts a message, creates Message transaction, signs it, and broadcasts to ADAMANT network. Supports Basic, Rich and Signal Message Types. + * See https://github.com/Adamant-im/adamant/wiki/Message-Types + * @param {string} passPhrase Senders's passPhrase. Sender's address will be derived from it. + * @param {string} addressOrPublicKey Recipient's ADAMANT address or public key. + * Using public key is faster, as the library wouldn't request it from the network. + * Though we cache public keys, and next request with address will be processed as fast as with public key. + * @param {string} message Message plain text in case of basic message. Stringified JSON in case of rich or signal messages. The library will encrypt a message. + * Example of rich message for Ether in-chat transfer: + * `{"type":"eth_transaction","amount":"0.002","hash":"0xfa46d2b3c99878f1f9863fcbdb0bc27d220d7065c6528543cbb83ced84487deb","comments":"I like to send it, send it"}` + * @param {string | number} messageType Type of message: basic, rich, or signal + * @param {string | number} amount Amount to send with a message + * @param {boolean} isAmountInADM If amount specified in ADM, or in sats (10^-8 ADM) + * @param {number} maxRetries How much times to retry request + * @param {number} retryNo Number of request already made + * @return {Promise} Request results + */ + return async (passPhrase, addressOrPublicKey, message, messageType = 'basic', amount, isAmountInADM = true, maxRetries = DEFAULT_SEND_MESSAGE_RETRIES, retryNo = 0) => { + let keyPair; + let data; + let address; + let publicKey; + + try { + if (!validator.validatePassPhrase(passPhrase)) { + return validator.badParameter('passPhrase'); + } + + keyPair = keys.createKeypairFromPassPhrase(passPhrase); + + if (!validator.validateAdmAddress(addressOrPublicKey)) { + if (!validator.validateAdmPublicKey(addressOrPublicKey)) { + return validator.badParameter('addressOrPublicKey', addressOrPublicKey); + } else { + publicKey = addressOrPublicKey; + try { + address = keys.createAddressFromPublicKey(publicKey); + } catch (e) { + return validator.badParameter('addressOrPublicKey', addressOrPublicKey); + } + } + } else { + publicKey = ''; + address = addressOrPublicKey; + } + + const messageTypes = { + basic: 1, + rich: 2, + signal: 3, + }; + + messageType = messageTypes[messageType]; + + if (!validator.validateMessageType(messageType)) { + return validator.badParameter('messageType', messageType); + } + + const messageValidation = validator.validateMessage(message, messageType); + + if (!messageValidation.result) { + return validator.badParameter('message', message, messageValidation.error); + } + + data = { + keyPair, + recipientId: address, + message_type: messageType, + }; + + if (amount) { + let amountInSat = amount; + + if (isAmountInADM) { + amountInSat = validator.admToSats(amount); + } + + if (!validator.validateIntegerAmount(amountInSat)) { + return validator.badParameter('amount', amount); + } + + data.amount = amountInSat; + } + } catch (e) { + return validator.badParameter('#exception_catched#', e); + } + + if (!publicKey) { + publicKey = await getPublicKey(nodeManager)(address); + } + + if (!publicKey) { + return { + success: false, + errorMessage: `Unable to get public key for ${addressOrPublicKey}. It is necessary for sending an encrypted message. Account may be uninitialized (https://medium.com/adamant-im/chats-and-uninitialized-accounts-in-adamant-5035438e2fcd), or network error`, + }; + } + + try { + const encryptedMessage = encryptor.encodeMessage(message, keyPair, publicKey); + data.message = encryptedMessage.message; + data.own_message = encryptedMessage.own_message; + + const transaction = transactionFormer.createTransaction(constants.transactionTypes.CHAT_MESSAGE, data); + + const url = nodeManager.node() + '/api/transactions/process'; + return axios.post(url, {transaction}) + .then(function(response) { + return validator.formatRequestResults(response, true); + }) + .catch(function(error) { + const logMessage = `[ADAMANT js-api] Send message request: Request to ${url} failed with ${error.response ? error.response.status : undefined} status code, ${error.toString()}${error.response && error.response.data ? '. Message: ' + error.response.data.toString().trim() : ''}. Try ${retryNo+1} of ${maxRetries+1}.`; + if (retryNo < maxRetries) { + logger.log(`${logMessage} Retrying…`); + return nodeManager.changeNodes() + .then(function() { + return module.exports(nodeManager)(passPhrase, addressOrPublicKey, message, messageType, amount, isAmountInADM, maxRetries, ++retryNo); + }); + } + logger.warn(`${logMessage} No more attempts, returning error.`); + return validator.formatRequestResults(error, false); + }); + } catch (e) { + return { + success: false, + errorMessage: `Unable to encode message '${message}' with public key ${publicKey}, or unable to build a transaction. Exception: ` + e, + }; + } + }; // sendMessage() +}; diff --git a/src/groups/sendTokens.js b/src/groups/sendTokens.js new file mode 100644 index 0000000..11d5fce --- /dev/null +++ b/src/groups/sendTokens.js @@ -0,0 +1,89 @@ +const axios = require('../helpers/axiosClient'); +const logger = require('../helpers/logger'); +const keys = require('../helpers/keys'); +const constants = require('../helpers/constants'); +const transactionFormer = require('../helpers/transactionFormer'); +const validator = require('../helpers/validator'); + +const DEFAULT_SEND_TOKENS_RETRIES = 4; // How much re-tries for send tokens requests by default. Total 4+1 tries + +module.exports = (nodeManager) => { + /** + * Creates Token Transfer transaction, signs it, and broadcasts to ADAMANT network + * See https://github.com/Adamant-im/adamant/wiki/Transaction-Types#type-0-token-transfer-transaction + * @param {string} passPhrase Senders's passPhrase. Sender's address will be derived from it. + * @param {string} addressOrPublicKey Recipient's ADAMANT address or public key. + * Address is preferred, as if we get public key, we should derive address from it. + * @param {string | number} amount Amount to send + * @param {boolean} isAmountInADM If amount specified in ADM, or in sats (10^-8 ADM) + * @param {number} maxRetries How much times to retry request + * @param {number} retryNo Number of request already made + * @return {Promise} Request results + */ + return (passPhrase, addressOrPublicKey, amount, isAmountInADM = true, maxRetries = DEFAULT_SEND_TOKENS_RETRIES, retryNo = 0) => { + let transaction; + let address; let publicKey; + + try { + if (!validator.validatePassPhrase(passPhrase)) { + return validator.badParameter('passPhrase'); + } + + const keyPair = keys.createKeypairFromPassPhrase(passPhrase); + + if (!validator.validateAdmAddress(addressOrPublicKey)) { + if (!validator.validateAdmPublicKey(addressOrPublicKey)) { + return validator.badParameter('addressOrPublicKey', addressOrPublicKey); + } else { + publicKey = addressOrPublicKey; + try { + address = keys.createAddressFromPublicKey(publicKey); + } catch (e) { + return validator.badParameter('addressOrPublicKey', addressOrPublicKey); + } + } + } else { + publicKey = ''; + address = addressOrPublicKey; + } + + let amountInSat = amount; + + if (isAmountInADM) { + amountInSat = validator.admToSats(amount); + } + + if (!validator.validateIntegerAmount(amountInSat)) { + return validator.badParameter('amount', amount); + } + + const data = { + keyPair, + recipientId: address, + amount: amountInSat, + }; + + transaction = transactionFormer.createTransaction(constants.transactionTypes.SEND, data); + } catch (e) { + return validator.badParameter('#exception_catched#', e); + } + + const url = nodeManager.node() + '/api/transactions/process'; + return axios.post(url, {transaction}) + .then(function(response) { + return validator.formatRequestResults(response, true); + }) + .catch(function(error) { + const logMessage = `[ADAMANT js-api] Send tokens request: Request to ${url} failed with ${error.response ? error.response.status : undefined} status code, ${error.toString()}${error.response && error.response.data ? '. Message: ' + error.response.data.toString().trim() : ''}. Try ${retryNo + 1} of ${maxRetries + 1}.`; + if (retryNo < maxRetries) { + logger.log(`${logMessage} Retrying…`); + return nodeManager.changeNodes() + .then(function() { + return module.exports(nodeManager)(passPhrase, addressOrPublicKey, amount, isAmountInADM, maxRetries, ++retryNo); + }); + } + logger.warn(`${logMessage} No more attempts, returning error.`); + return validator.formatRequestResults(error, false); + }); + }; +}; diff --git a/groups/voteForDelegate.js b/src/groups/voteForDelegate.js similarity index 85% rename from groups/voteForDelegate.js rename to src/groups/voteForDelegate.js index 1d37d67..5ef15c0 100644 --- a/groups/voteForDelegate.js +++ b/src/groups/voteForDelegate.js @@ -1,4 +1,4 @@ -const axios = require('axios'); +const axios = require('../helpers/axiosClient'); const get = require('./get'); const logger = require('../helpers/logger'); const keys = require('../helpers/keys'); @@ -12,21 +12,21 @@ const publicKeysCache = { }; module.exports = (nodeManager) => { /** - * Creates votes for delegate transaction, signs it, and broadcasts to ADAMANT network + * Creates votes for delegate transaction, signs it, and broadcasts to ADAMANT network * See https://github.com/Adamant-im/adamant/wiki/Transaction-Types#type-3-vote-for-delegate-transaction * @param {string} passPhrase Senders's passPhrase. Sender's address will be derived from it. * @param {string[]} votes PublicKeys, ADM addresses and delegate names for upvote and downvote. * It would be more efficient to pass publicKey, otherwise the api will make additional queries * @param {number} maxRetries How much times to retry request - * @returns {Promise} Request results - */ - return async (passPhrase, votes, maxRetries = DEFAULT_VOTE_FOR_DELEGATE_RETRIES, retryNo = 0) => { - + * @param {number} retryNo Number of request already made + * @return {Promise} Request results + */ + return async (passPhrase, votes, maxRetries = DEFAULT_VOTE_FOR_DELEGATE_RETRIES, retryNo = 0) => { let transaction; try { if (!validator.validatePassPhrase(passPhrase)) { - return validator.badParameter('passPhrase'); + return validator.badParameter('passPhrase'); } const keyPair = keys.createKeypairFromPassPhrase(passPhrase); @@ -44,7 +44,7 @@ module.exports = (nodeManager) => { votes[i] = `${voteDirection}${cachedPublicKey}`; } else { if (validator.validateAdmVoteForAddress(vote)) { - const res = await get(nodeManager)('/accounts', { address: voteName }); + const res = await get(nodeManager)('/accounts', {address: voteName}); if (res.success) { const publicKey = res.data.account.publicKey; @@ -57,7 +57,7 @@ module.exports = (nodeManager) => { return validator.badParameter('votes'); } } else if (validator.validateAdmVoteForDelegateName(vote)) { - const res = await get(nodeManager)('/delegates/get', { username: voteName }); + const res = await get(nodeManager)('/delegates/get', {username: voteName}); if (res.success) { const publicKey = res.data.delegate.publicKey; @@ -92,7 +92,7 @@ module.exports = (nodeManager) => { transaction = transactionFormer.createTransaction(type, data); } catch (error) { - return validator.badParameter('#exception_catched#', error) + return validator.badParameter('#exception_catched#', error); } const url = nodeManager.node() + '/api/accounts/delegates'; @@ -101,21 +101,21 @@ module.exports = (nodeManager) => { const response = await axios.post(url, transaction); return validator.formatRequestResults(response, true); - } catch(error) { + } catch (error) { const logMessage = `[ADAMANT js-api] Vote for delegate request: Request to ${url} failed with ${error.response ? error.response.status : undefined} status code, ${error.toString()}${error.response && error.response.data ? '. Message: ' + error.response.data.toString().trim() : ''}. Try ${retryNo+1} of ${maxRetries+1}.`; if (retryNo < maxRetries) { logger.log(`${logMessage} Retrying…`); return nodeManager.changeNodes() - .then(() => ( - module.exports(nodeManager)(passPhrase, addressOrPublicKey, amount, isAmountInADM, maxRetries, ++retryNo) - )); + .then(() => ( + module.exports(nodeManager)(passPhrase, votes, maxRetries, ++retryNo) + )); } logger.warn(`${logMessage} No more attempts, returning error.`); return validator.formatRequestResults(error, false); } - } + }; }; diff --git a/src/helpers/axiosClient.js b/src/helpers/axiosClient.js new file mode 100644 index 0000000..d002737 --- /dev/null +++ b/src/helpers/axiosClient.js @@ -0,0 +1,4 @@ +const axios = require('axios'); + +const axiosClient = axios.create(); +module.exports = axiosClient; diff --git a/src/helpers/bignumber.js b/src/helpers/bignumber.js new file mode 100644 index 0000000..3d03e46 --- /dev/null +++ b/src/helpers/bignumber.js @@ -0,0 +1,125 @@ +/* eslint-disable no-redeclare */ +'use strict'; + +/** + * Buffer functions that implements bignumber. + * @memberof module:helpers + * @requires bignumber + * @constructor +*/ +const BigNumber = require('bignumber.js'); + +/** + * Creates an instance from a Buffer. + * @param {ArrayBuffer} buf + * @param {Object} opts + * @return {ArrayBuffer} new BigNumber instance + * @throws {RangeError} error description multiple of size +*/ +BigNumber.fromBuffer = function(buf, opts) { + if (!opts) opts = {}; + + const endian = {1: 'big', '-1': 'little'}[opts.endian] || opts.endian || 'big'; + + const size = opts.size === 'auto' ? Math.ceil(buf.length) : (opts.size || 1); + + if (buf.length % size !== 0) { + throw new RangeError('Buffer length (' + buf.length + ')' + + ' must be a multiple of size (' + size + ')', + ); + } + + const hex = []; + for (let i = 0; i < buf.length; i += size) { + const chunk = []; + for (let j = 0; j < size; j++) { + chunk.push(buf[i + (endian === 'big' ? j : (size - j - 1))]); + } + + hex.push(chunk + .map(function(c) { + return (c < 16 ? '0' : '') + c.toString(16); + }) + .join(''), + ); + } + + return new BigNumber(hex.join(''), 16); +}; + +/** + * Returns an instance as Buffer. + * @param {Object} opts + * @return {ArrayBuffer} new buffer | error message invalid option +*/ +BigNumber.prototype.toBuffer = function(opts) { + if (typeof opts === 'string') { + if (opts !== 'mpint') { + return 'Unsupported Buffer representation'; + } + + const abs = this.abs(); + const buf = abs.toBuffer({size: 1, endian: 'big'}); + + let len = buf.length === 1 && buf[0] === 0 ? 0 : buf.length; + + if (buf[0] & 0x80) len++; + + const ret = Buffer.alloc(4 + len); + if (len > 0) buf.copy(ret, 4 + (buf[0] & 0x80 ? 1 : 0)); + if (buf[0] & 0x80) ret[4] = 0; + + ret[0] = len & (0xff << 24); + ret[1] = len & (0xff << 16); + ret[2] = len & (0xff << 8); + ret[3] = len & (0xff << 0); + + // Two's compliment for negative integers + const isNeg = this.lt(0); + if (isNeg) { + for (let i = 4; i < ret.length; i++) { + ret[i] = 0xff - ret[i]; + } + } + ret[4] = (ret[4] & 0x7f) | (isNeg ? 0x80 : 0); + if (isNeg) ret[ret.length - 1]++; + + return ret; + } + + if (!opts) { + opts = {}; + } + + const endian = {1: 'big', '-1': 'little'}[opts.endian] || opts.endian || 'big'; + + let hex = this.toString(16); + if (hex.charAt(0) === '-') { + throw new Error( + 'Converting negative numbers to Buffers not supported yet', + ); + } + + const size = opts.size === 'auto' ? Math.ceil(hex.length / 2) : (opts.size || 1); + + const len = Math.ceil(hex.length / (2 * size)) * size; + const buf = Buffer.alloc(len); + + // Zero-pad the hex string so the chunks are all `size` long + while (hex.length < 2 * len) hex = '0' + hex; + + const hx = hex + .split(new RegExp('(.{' + (2 * size) + '})')) + .filter((s) => s.length > 0); + + hx.forEach((chunk, i) => { + for (let j = 0; j < size; j++) { + const ix = i * size + (endian === 'big' ? j : size - j - 1); + buf[ix] = parseInt(chunk.slice(j * 2, j * 2 + 2), 16); + } + }); + + return buf; +}; + +module.exports = BigNumber; diff --git a/src/helpers/constants.js b/src/helpers/constants.js new file mode 100644 index 0000000..9af3e15 --- /dev/null +++ b/src/helpers/constants.js @@ -0,0 +1,46 @@ +module.exports = { + + epochTime: new Date(Date.UTC(2017, 8, 2, 17, 0, 0, 0)), + fees: { + send: 50000000, + vote: 1000000000, + secondsignature: 500000000, + delegate: 30000000000, + multisignature: 500000000, + dapp: 2500000000, + old_chat_message: 500000, + chat_message: 100000, + profile_update: 5000000, + avatar_upload: 10000000, + state_store: 100000, + }, + transactionTypes: { + SEND: 0, + SIGNATURE: 1, + DELEGATE: 2, + VOTE: 3, + MULTI: 4, + DAPP: 5, + IN_TRANSFER: 6, + OUT_TRANSFER: 7, + CHAT_MESSAGE: 8, + STATE: 9, + }, + maxVotesPerTransaction: 33, + SAT: 100000000, + RE_HEX: /^[a-fA-F0-9]+$/, + RE_BASE64: /^([A-Za-z0-9+/]{4})*([A-Za-z0-9+/]{4}|[A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{2}==)$/, + RE_ADM_ADDRESS: /^U([0-9]{6,})$/, + RE_ADM_VOTE_FOR_PUBLIC_KEY: /^(\+|-)[a-fA-F0-9]{64}$/, + RE_ADM_VOTE_FOR_ADDRESS: /^(\+|-)U([0-9]{6,})$/, + RE_ADM_VOTE_FOR_DELEGATE_NAME: /^(\+|-)([a-z0-9!@$&_]{1,20})$/, + RE_ADM_DELEGATE_NAME: /^[a-z0-9!@$&_]{1,20}$/, + RE_BTC_ADDRESS: /^(bc1|[13])[a-km-zA-HJ-NP-Z02-9]{25,39}$/, + RE_DASH_ADDRESS: /^[7X][1-9A-HJ-NP-Za-km-z]{33,}$/, + RE_DOGE_ADDRESS: /^[A|D|9][A-Z0-9]([0-9a-zA-Z]{9,})$/, + RE_LSK_ADDRESS: /^[0-9]{2,21}L$/, + + RE_HTTP_URL: /^https?:\/\/(.*)$/, + RE_IP: /(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}/, + +}; diff --git a/src/helpers/encryptor.js b/src/helpers/encryptor.js new file mode 100644 index 0000000..265c64e --- /dev/null +++ b/src/helpers/encryptor.js @@ -0,0 +1,42 @@ +const sodium = require('sodium-browserify-tweetnacl'); +const nacl = require('tweetnacl/nacl-fast'); +const ed2curve = require('ed2curve'); + +module.exports = { + bytesToHex(bytes) { + let hex = ''; + + for (const byte of bytes) { + hex += (byte >>> 4).toString(16); + hex += (byte & 0xF).toString(16); + } + + return hex; + }, + hexToBytes(hex) { + const bytes = []; + + for (let c = 0; c < hex.length; c += 2) { + bytes.push(parseInt(hex.substr(c, 2), 16)); + } + + return bytes; + }, + encodeMessage(msg, keypair, recipientPublicKey) { + const nonce = Buffer.allocUnsafe(24); + sodium.randombytes(nonce); + + const plainText = Buffer.from(msg.toString()); + const DHSecretKey = ed2curve.convertSecretKey(keypair.privateKey); + const DHPublicKey = ed2curve.convertPublicKey( + new Uint8Array(this.hexToBytes(recipientPublicKey)), + ); + + const encrypted = nacl.box(plainText, nonce, DHPublicKey, DHSecretKey); + + return { + message: this.bytesToHex(encrypted), + own_message: this.bytesToHex(nonce), + }; + }, +}; diff --git a/src/helpers/healthCheck.js b/src/helpers/healthCheck.js new file mode 100644 index 0000000..c471138 --- /dev/null +++ b/src/helpers/healthCheck.js @@ -0,0 +1,211 @@ +const dnsPromises = require('dns').promises; + +const axios = require('../helpers/axiosClient'); +const socket = require('./wsClient'); +const logger = require('./logger'); +const validator = require('./validator'); + +const {RE_IP, RE_HTTP_URL} = require('./constants'); + +const CHECK_NODES_INTERVAL = 60 * 5 * 1000; // Update active nodes every 5 minutes +const HEIGHT_EPSILON = 5; // Used to group nodes by height and choose synced + +module.exports = (nodes, checkHealthAtStartup = true) => { + const nodesList = nodes; + let isCheckingNodes = false; + + // Note: it may be not synced; and before first health check a node can reply with obsolete data + let [activeNode] = nodesList; + + /** + * Updates active nodes. If nodes are already updating, returns Promise of previous call + * @param {boolean} isPlannedUpdate + * @return {Promise} Call changeNodes().then to do something when update complete + */ + async function changeNodes(isPlannedUpdate = false) { + if (!isCheckingNodes) { + if (!isPlannedUpdate) { + logger.warn('[ADAMANT js-api] Health check: Forcing to update active nodes…'); + } + + await checkNodes(!isPlannedUpdate); + + return true; + } + } + + /** + * Requests every ADAMANT node for its status, makes a list of live nodes, and chooses one active + * @param {boolean} forceChangeActiveNode + */ + async function checkNodes(forceChangeActiveNode) { + isCheckingNodes = true; + + const liveNodes = []; + + try { + for (const node of nodesList) { + try { + const start = unixTimestamp(); + + const req = await checkNode(`${node}/api/node/status`); + + const [url] = node.replace(RE_HTTP_URL, '$1').split(':'); + const ifIP = RE_IP.test(url); + + const ip = ifIP ? url : await getIP(url); + const ifHttps = node.startsWith('https'); + + if (req.status) { + liveNodes.push({ + node, + ifIP, + url, + ip, + ifHttps, + outOfSync: false, + ping: unixTimestamp() - start, + height: req.status.network.height, + heightEpsilon: Math.round(req.status.network.height / HEIGHT_EPSILON), + socketSupport: req.status.wsClient?.enabled, + wsPort: req.status.wsClient?.port, + }); + } else { + logger.log(`[ADAMANT js-api] Health check: Node ${node} haven't returned its status`); + } + } catch (e) { + logger.log(`[ADAMANT js-api] Health check: Error while checking node ${node}, ${e}`); + } + } + + const count = liveNodes.length; + + let outOfSyncCount = 0; + + if (!count) { + logger.error(`[ADAMANT js-api] Health check: All of ${nodesList.length} nodes are unavailable. Check internet connection and nodes list in config.`); + } else { + // Set activeNode to one that have maximum height and minimum ping + if (count === 1) { + activeNode = liveNodes[0].node; + } else if (count === 2) { + const [h0, h1] = liveNodes; + + activeNode = h0.height > h1.height ? h0.node : h1.node; + + // Mark node outOfSync if needed + if (h0.heightEpsilon > h1.heightEpsilon) { + liveNodes[1].outOfSync = true; + outOfSyncCount += 1; + } else if (h0.heightEpsilon < h1.heightEpsilon) { + liveNodes[0].outOfSync = true; + outOfSyncCount += 1; + } + } else { + let biggestGroup = []; + // Removing lodash: const groups = _.groupBy(liveNodes, n => n.heightEpsilon); + const groups = liveNodes.reduce((grouped, node) => { + const int = Math.floor(node.heightEpsilon); // Excessive, it is already rounded + + if (!Object.prototype.hasOwnProperty.call(grouped, int)) { + grouped[int] = []; + } + + grouped[int].push(node); + + return grouped; + }, {}); + + Object.keys(groups).forEach((key) => { + if (groups[key].length > biggestGroup.length) { + biggestGroup = groups[key]; + } + }); + + // All the nodes from the biggestGroup list are considered to be in sync, all the others are not + liveNodes.forEach((node) => { + node.outOfSync = !biggestGroup.includes(node); + }); + + outOfSyncCount = liveNodes.length - biggestGroup.length; + + biggestGroup.sort((a, b) => a.ping - b.ping); + liveNodes.sort((a, b) => a.ping - b.ping); + + if (forceChangeActiveNode && biggestGroup.length > 1 && activeNode === biggestGroup[0].node) { + // Use random node from which are synced + activeNode = biggestGroup[validator.getRandomIntInclusive(1, biggestGroup.length - 1)].node; + } else { + // Use node with minimum ping among which are synced + activeNode = biggestGroup[0].node; + } + } + + socket.reviseConnection(liveNodes); + + const unavailableCount = nodesList.length - liveNodes.length; + const supportedCount = liveNodes.length - outOfSyncCount; + + let nodesInfoString = ''; + + if (unavailableCount) { + nodesInfoString += `, ${unavailableCount} nodes didn't respond`; + } + + if (outOfSyncCount) { + nodesInfoString += `, ${outOfSyncCount} nodes are not synced`; + } + + logger.log(`[ADAMANT js-api] Health check: Found ${supportedCount} supported and synced nodes${nodesInfoString}. Active node is ${activeNode}.`); + } + } catch (e) { + logger.warn('[ADAMANT js-api] Health check: Error in checkNodes(), ' + e); + } + + isCheckingNodes = false; + } + + if (checkHealthAtStartup) { + changeNodes(true); + + setInterval( + () => changeNodes(true), + CHECK_NODES_INTERVAL, + ); + } + + return { + /** + * @return {string} Current active node, f. e. http://88.198.156.44:36666 + */ + node: () => activeNode, + changeNodes, + }; +}; + +async function getIP(url) { + try { + const addresses = await dnsPromises.resolve4(url); + + if (addresses && addresses[0] !== '0.0.0.0') { + return addresses[0]; + } + } catch (error) { + return; + } +} + +/** + * Requests status from a single ADAMANT node + * @param {string} url Node URL to request + * @return {Promise} Node's status information + */ +function checkNode(url) { + return axios.get(url) + .then((response) => ({status: response.data})) + .catch((err) => false); +} + +function unixTimestamp() { + return new Date().getTime(); +} diff --git a/src/helpers/keys.js b/src/helpers/keys.js new file mode 100644 index 0000000..9df0573 --- /dev/null +++ b/src/helpers/keys.js @@ -0,0 +1,49 @@ +const sodium = require('sodium-browserify-tweetnacl'); +const crypto = require('crypto'); +const Mnemonic = require('bitcore-mnemonic'); + +const bignum = require('./bignumber.js'); + +module.exports = { + createNewPassPhrase() { + return new Mnemonic(Mnemonic.Words.ENGLISH).toString(); + }, + makeKeypairFromHash(hash) { + const keypair = sodium.crypto_sign_seed_keypair(hash); + + return { + publicKey: keypair.publicKey, + privateKey: keypair.secretKey, + }; + }, + createHashFromPassPhrase(passPhrase) { + const secretMnemonic = new Mnemonic(passPhrase, Mnemonic.Words.ENGLISH); + + return crypto + .createHash('sha256') + .update( + secretMnemonic.toSeed().toString('hex'), + 'hex', + ) + .digest(); + }, + createKeypairFromPassPhrase(passPhrase) { + const hash = this.createHashFromPassPhrase(passPhrase); + + return this.makeKeypairFromHash(hash); + }, + createAddressFromPublicKey(publicKey) { + const publicKeyHash = crypto + .createHash('sha256') + .update(publicKey, 'hex') + .digest(); + + const temp = Buffer.alloc(8); + + for (let i = 0; i < 8; i++) { + temp[i] = publicKeyHash[7 - i]; + } + + return `U${bignum.fromBuffer(temp)}`; + }, +}; diff --git a/src/helpers/logger.js b/src/helpers/logger.js new file mode 100644 index 0000000..4278e1e --- /dev/null +++ b/src/helpers/logger.js @@ -0,0 +1,36 @@ +const logger = { + errorLevel: 'log', + logger: console, + + initLogger(errorLevel, log) { + if (errorLevel) { + this.errorLevel = errorLevel; + } + + if (log) { + this.logger = log; + } + }, + error(str) { + if (['error', 'warn', 'info', 'log'].includes(this.errorLevel)) { + this.logger.error(str); + } + }, + warn(str) { + if (['warn', 'info', 'log'].includes(this.errorLevel)) { + this.logger.warn(str); + } + }, + info(str) { + if (['info', 'log'].includes(this.errorLevel)) { + this.logger.info(str); + } + }, + log(str) { + if (this.errorLevel === 'log') { + this.logger.log(str); + } + }, +}; + +module.exports = logger; diff --git a/src/helpers/tests/keys.test.js b/src/helpers/tests/keys.test.js new file mode 100644 index 0000000..eb4ee6d --- /dev/null +++ b/src/helpers/tests/keys.test.js @@ -0,0 +1,62 @@ +const keys = require('../keys'); +const {validateAdmAddress} = require('../validator'); + +describe('createNewPassPhrase', () => { + test('Should return string that contains more than 11 words', () => { + const passPhrase = keys.createNewPassPhrase(); + + expect(typeof passPhrase).toBe('string'); + expect( + passPhrase.split(' ').length, + ).toBeGreaterThanOrEqual(12); + }); +}); + +describe('makeKeypairFromHash', () => { + test('Should return object with buffers publicKey and privateKey', () => { + const passPhrase = keys.createNewPassPhrase(); + const hash = keys.createHashFromPassPhrase(passPhrase); + + const keypair = keys.makeKeypairFromHash(hash); + + expect(typeof keypair).toBe('object'); + + expect(Buffer.isBuffer(keypair.publicKey)).toBe(true); + expect(Buffer.isBuffer(keypair.privateKey)).toBe(true); + }); +}); + +describe('createHashFromPassPhrase', () => { + test('Should return different hashes for different passPhrases', () => { + const passPhrase = keys.createNewPassPhrase(); + const passPhrase2 = keys.createNewPassPhrase(); + + const hash = keys.createHashFromPassPhrase(passPhrase); + const hash2 = keys.createHashFromPassPhrase(passPhrase2); + + expect(hash.equals(hash2)).toBe(false); + }); +}); + +describe('createKeypairFromPassPhrase', () => { + test('Should return keypair with publicKey and privateKey', () => { + const passPhrase = keys.createNewPassPhrase(); + const keypair = keys.createKeypairFromPassPhrase(passPhrase); + + expect(typeof keypair).toBe('object'); + + expect(Buffer.isBuffer(keypair.publicKey)).toBe(true); + expect(Buffer.isBuffer(keypair.privateKey)).toBe(true); + }); +}); + +describe('createAddressFromPublicKey', () => { + test('Should return a string which matches the address pattern', () => { + const passPhrase = keys.createNewPassPhrase(); + const keypair = keys.createKeypairFromPassPhrase(passPhrase); + + const address = keys.createAddressFromPublicKey(keypair.publicKey); + + expect(validateAdmAddress(address)).toBe(true); + }); +}); diff --git a/src/helpers/tests/logger.test.js b/src/helpers/tests/logger.test.js new file mode 100644 index 0000000..a463297 --- /dev/null +++ b/src/helpers/tests/logger.test.js @@ -0,0 +1,203 @@ +const logger = require('../logger'); + +describe('logger: log', () => { + const logLevel = 'log'; + + test('Should log log level', (done) => { + logger.initLogger(logLevel, { + log(str) { + expect(str).toBe('log'); + + done(); + }, + }); + + logger.log('log'); + }); + + test('Should log info level', (done) => { + logger.initLogger(logLevel, { + info(str) { + expect(str).toBe('info'); + + done(); + }, + }); + + logger.info('info'); + }); + + test('Should log warn level', (done) => { + logger.initLogger(logLevel, { + warn(str) { + expect(str).toBe('warn'); + + done(); + }, + }); + + logger.warn('warn'); + }); + + test('Should log error level', (done) => { + logger.initLogger(logLevel, { + error(str) { + expect(str).toBe('error'); + + done(); + }, + }); + + logger.error('error'); + }); +}); + +describe('logger: info', () => { + const logLevel = 'info'; + + test('Should not log log level', (done) => { + logger.initLogger(logLevel, { + log() { + done('Log level has been called'); + }, + }); + + logger.log('log'); + done(); + }); + + test('Should log info level', (done) => { + logger.initLogger(logLevel, { + info(str) { + expect(str).toBe('info'); + + done(); + }, + }); + + logger.info('info'); + }); + + test('Should log warn level', (done) => { + logger.initLogger(logLevel, { + warn(str) { + expect(str).toBe('warn'); + + done(); + }, + }); + + logger.warn('warn'); + }); + + test('Should log error level', (done) => { + logger.initLogger(logLevel, { + error(str) { + expect(str).toBe('error'); + + done(); + }, + }); + + logger.error('error'); + }); +}); + +describe('logger: warn', () => { + const logLevel = 'warn'; + + test('Should not log log level', (done) => { + logger.initLogger(logLevel, { + log() { + done('Log level has been called'); + }, + }); + + logger.log('log'); + done(); + }); + + test('Should not log info level', (done) => { + logger.initLogger(logLevel, { + info() { + done('Info level has been called'); + }, + }); + + logger.info('info'); + done(); + }); + + test('Should log warn level', (done) => { + logger.initLogger(logLevel, { + warn(str) { + expect(str).toBe('warn'); + + done(); + }, + }); + + logger.warn('warn'); + }); + + test('Should log error level', (done) => { + logger.initLogger(logLevel, { + error(str) { + expect(str).toBe('error'); + + done(); + }, + }); + + logger.error('error'); + }); +}); + +describe('logger: error', () => { + const logLevel = 'error'; + + test('Should not log log level', (done) => { + logger.initLogger(logLevel, { + log() { + done('Log level has been called'); + }, + }); + + logger.log('log'); + done(); + }); + + test('Should not log info level', (done) => { + logger.initLogger(logLevel, { + info() { + done('Info level has been called'); + }, + }); + + logger.info('info'); + done(); + }); + + test('Should not log warn level', (done) => { + logger.initLogger(logLevel, { + warn() { + done('Warn level has been called'); + }, + }); + + logger.warn('warn'); + done(); + }); + + test('Should log error level', (done) => { + logger.initLogger(logLevel, { + error(str) { + expect(str).toBe('error'); + + done(); + }, + }); + + logger.error('error'); + }); +}); diff --git a/src/helpers/tests/time.test.js b/src/helpers/tests/time.test.js new file mode 100644 index 0000000..48319a8 --- /dev/null +++ b/src/helpers/tests/time.test.js @@ -0,0 +1,10 @@ +const time = require('../time'); +const {epochTime} = require('../constants'); + +describe('getTime', () => { + test('Should return 0 for epoch time', () => { + expect( + time.getTime(epochTime.getTime()), + ).toBe(0); + }); +}); diff --git a/src/helpers/tests/transactionFormer.test.js b/src/helpers/tests/transactionFormer.test.js new file mode 100644 index 0000000..2e4e12b --- /dev/null +++ b/src/helpers/tests/transactionFormer.test.js @@ -0,0 +1,171 @@ +const transactionFormer = require('../transactionFormer'); +const keys = require('../keys'); +const constants = require('../constants'); + +const passPhrase = keys.createNewPassPhrase(); +const keyPair = keys.createKeypairFromPassPhrase(passPhrase); + +describe('Create send transaction', () => { + const transactionType = constants.transactionTypes.SEND; + + test('Should create base transaction', () => { + const data = { + keyPair, + recipientId: 'U123456', + amount: 1, + }; + + const transaction = transactionFormer.createTransaction(transactionType, data); + + expect(transaction).toMatchObject({ + type: transactionType, + amount: 1, + recipientId: 'U123456', + }); + expect(transaction).toHaveProperty('timestamp'); + expect(transaction).toHaveProperty('senderPublicKey'); + expect(transaction).toHaveProperty('senderId'); + expect(transaction).toHaveProperty('asset'); + expect(transaction).toHaveProperty('signature'); + expect( + typeof transaction.signature, + ).toBe('string'); + }); +}); + +describe('Create vote transaction', () => { + const transactionType = constants.transactionTypes.VOTE; + + test('Should create base transaction', () => { + const data = { + keyPair, + votes: [], + }; + + const transaction = transactionFormer.createTransaction(transactionType, data); + + expect(transaction).toMatchObject({ + type: transactionType, + amount: 0, + }); + expect(transaction).toHaveProperty('timestamp'); + expect(transaction).toHaveProperty('recipientId'); + expect(transaction).toHaveProperty('senderPublicKey'); + expect(transaction).toHaveProperty('senderId'); + expect(transaction).toHaveProperty('asset'); + expect(transaction).toHaveProperty('signature'); + expect( + typeof transaction.signature, + ).toBe('string'); + }); +}); + +describe('Create delegate transaction', () => { + const transactionType = constants.transactionTypes.DELEGATE; + const username = 'admtest'; + + test('Should create base transaction', () => { + const data = { + keyPair, + username, + }; + + const transaction = transactionFormer.createTransaction(transactionType, data); + + expect(transaction).toMatchObject({ + type: transactionType, + amount: 0, + asset: { + delegate: { + username, + }, + }, + }); + expect(transaction).toHaveProperty('timestamp'); + expect(transaction).toHaveProperty('senderPublicKey'); + expect(transaction).toHaveProperty('senderId'); + expect(transaction).toHaveProperty('asset'); + expect(transaction).toHaveProperty('recipientId'); + expect(transaction).toHaveProperty('asset.delegate.publicKey'); + expect(transaction).toHaveProperty('signature'); + expect( + typeof transaction.signature, + ).toBe('string'); + }); +}); + +describe('Create chat transaction', () => { + const transactionType = constants.transactionTypes.CHAT_MESSAGE; + + test('Should create base transaction', () => { + const data = { + keyPair, + amount: 1, + message: 'Hello!', + own_message: null, + message_type: 0, + recipientId: 'U123456', + }; + + const transaction = transactionFormer.createTransaction(transactionType, data); + + expect(transaction).toMatchObject({ + type: transactionType, + recipientId: data.recipientId, + amount: 1, + asset: { + chat: { + message: data.message, + own_message: data.own_message, + type: data.message_type, + }, + }, + }); + expect(transaction).toHaveProperty('timestamp'); + expect(transaction).toHaveProperty('senderPublicKey'); + expect(transaction).toHaveProperty('senderId'); + expect(transaction).toHaveProperty('asset'); + expect(transaction).toHaveProperty('recipientId'); + expect(transaction).toHaveProperty('signature'); + expect( + typeof transaction.signature, + ).toBe('string'); + }); +}); + +describe('Create state transaction', () => { + const transactionType = constants.transactionTypes.STATE; + + test('Should create base transaction', () => { + const data = { + keyPair, + key: 'key', + value: 'value', + }; + + const transaction = transactionFormer.createTransaction(transactionType, data); + + expect(transaction).toMatchObject({ + type: transactionType, + recipientId: null, + amount: 0, + asset: { + state: { + key: data.key, + value: data.value, + type: 0, + }, + }, + }); + expect(transaction).toHaveProperty('timestamp'); + expect(transaction).toHaveProperty('senderPublicKey'); + expect(transaction).toHaveProperty('senderId'); + expect(transaction).toHaveProperty('asset'); + expect(transaction).toHaveProperty('recipientId'); + expect(transaction).toHaveProperty('signature'); + expect( + typeof transaction.signature, + ).toBe('string'); + }); +}); + diff --git a/src/helpers/tests/validator.test.js b/src/helpers/tests/validator.test.js new file mode 100644 index 0000000..e9d211f --- /dev/null +++ b/src/helpers/tests/validator.test.js @@ -0,0 +1,321 @@ +const validator = require('../validator'); + +describe('isNumeric', () => { + test('Should return false for a number', () => { + expect(validator.isNumeric(3)).toBe(false); + }); + + test('Should return false for Infinity', () => { + expect(validator.isNumeric(Infinity)).toBe(false); + }); + + test('Should return false for an object', () => { + expect(validator.isNumeric({})).toBe(false); + }); + + test('Should return false for undefined', () => { + expect(validator.isNumeric(undefined)).toBe(false); + }); + + test('Should return false for NaN', () => { + expect(validator.isNumeric(undefined)).toBe(false); + }); + + test('Should return false for `n3.14`', () => { + expect(validator.isNumeric('n3.14')).toBe(false); + }); + + test('Should return false for `3,14`', () => { + expect(validator.isNumeric('3,14')).toBe(true); + }); + + test('Should return true for `3.14`', () => { + expect(validator.isNumeric('3.14')).toBe(true); + }); + + test('Should return true for ` 3.14`', () => { + expect(validator.isNumeric(' 3.14')).toBe(true); + }); +}); + +describe('validatePassPhrase', () => { + test('Should return false for a number', () => { + expect(validator.validatePassPhrase(3)).toBe(false); + }); + + test('Should return false for an object', () => { + expect(validator.validatePassPhrase({})).toBe(false); + }); + + test('Should return false for undefined', () => { + expect(validator.validatePassPhrase(undefined)).toBe(false); + }); + + test('Should return false for NaN', () => { + expect(validator.validatePassPhrase(undefined)).toBe(false); + }); + + test('Should return false for a too short string', () => { + expect(validator.validatePassPhrase('short')).toBe(false); + }); + + test('Should return true for a long string', () => { + expect(validator.validatePassPhrase('word '.repeat(12))).toBe(true); + }); +}); + +describe('validateAdmAddress', () => { + test('Should return false for a number', () => { + expect(validator.validateAdmAddress(3)).toBe(false); + }); + + test('Should return false for an object', () => { + expect(validator.validateAdmAddress({})).toBe(false); + }); + + test('Should return false for undefined', () => { + expect(validator.validateAdmAddress(undefined)).toBe(false); + }); + + test('Should return false for NaN', () => { + expect(validator.validateAdmAddress(undefined)).toBe(false); + }); + + test('Should return false for U123', () => { + expect(validator.validateAdmAddress('U123')).toBe(false); + }); + + test('Should return false for ` U123456`', () => { + expect(validator.validateAdmAddress(' U123456')).toBe(false); + }); + + test('Should return false for `U123213N123`', () => { + expect(validator.validateAdmAddress('U123213N123')).toBe(false); + }); + + test('Should return true for U123456', () => { + expect(validator.validateAdmAddress('U1234506')).toBe(true); + }); + + test('Should return true for U01234561293812931283918239', () => { + expect(validator.validateAdmAddress('U01234561293812931283918239')).toBe(true); + }); +}); + +describe('validateAdmPublicKey', () => { + test('Should return false for a number', () => { + expect(validator.validateAdmPublicKey(3)).toBe(false); + }); + + test('Should return false for an object', () => { + expect(validator.validateAdmPublicKey({})).toBe(false); + }); + + test('Should return false for undefined', () => { + expect(validator.validateAdmPublicKey(undefined)).toBe(false); + }); + + test('Should return false for NaN', () => { + expect(validator.validateAdmPublicKey(undefined)).toBe(false); + }); + + test('Should return false for a short string', () => { + expect(validator.validateAdmPublicKey('0f')).toBe(false); + }); + + test('Should return false for a string that contains `L`', () => { + expect(validator.validateAdmPublicKey('Le003f782cd1c1c84a6767a871321af2ecdb3da8d8f6b8d1f13179835b6ec432')).toBe(false); + }); + + test('Should return true for a public key that starts with a number', () => { + expect(validator.validateAdmPublicKey('4e003f782cd1c1c84A6767a871321af2ecdb3da8d8f6b8d1f13179835b6ec432')).toBe(true); + }); + + test('Should return true for a public key that starts with a letter', () => { + expect(validator.validateAdmPublicKey('e4003f782cd1c1c84A6767a871321af2ecdb3da8d8f6b8d1f13179835b6ec432')).toBe(true); + }); +}); + +describe('validateAdmVoteForAddress', () => { + test('Should return false for a number', () => { + expect(validator.validateAdmVoteForAddress(3)).toBe(false); + }); + + test('Should return false for an object', () => { + expect(validator.validateAdmVoteForAddress({})).toBe(false); + }); + + test('Should return false for undefined', () => { + expect(validator.validateAdmVoteForAddress(undefined)).toBe(false); + }); + + test('Should return false for NaN', () => { + expect(validator.validateAdmVoteForAddress(undefined)).toBe(false); + }); + + test('Should return false for a short string', () => { + expect(validator.validateAdmVoteForAddress('0f')).toBe(false); + }); + + test('Should return false for a string that starts with `L`', () => { + expect(validator.validateAdmVoteForAddress('L01234561293812931283918239')).toBe(false); + }); + + test('Should return false for an address that starts with a number', () => { + expect(validator.validateAdmVoteForAddress('0U1234561293812931283918239')).toBe(false); + }); + + test('Should return false for an address that starts with a letter', () => { + expect(validator.validateAdmVoteForAddress('U01234561293812931283918239')).toBe(false); + }); + + test('Should return true for an address with a plus', () => { + expect(validator.validateAdmVoteForAddress('+U01234561293812931283918239')).toBe(true); + }); + + test('Should return true for an address with a minus', () => { + expect(validator.validateAdmVoteForAddress('+U01234561293812931283918239')).toBe(true); + }); +}); + +describe('validateAdmVoteForPublicKey', () => { + test('Should return false for a number', () => { + expect(validator.validateAdmVoteForPublicKey(3)).toBe(false); + }); + + test('Should return false for an object', () => { + expect(validator.validateAdmVoteForPublicKey({})).toBe(false); + }); + + test('Should return false for undefined', () => { + expect(validator.validateAdmVoteForPublicKey(undefined)).toBe(false); + }); + + test('Should return false for NaN', () => { + expect(validator.validateAdmVoteForPublicKey(undefined)).toBe(false); + }); + + test('Should return false for a short string', () => { + expect(validator.validateAdmVoteForPublicKey('0f')).toBe(false); + }); + + test('Should return false for a string that starts with `L`', () => { + expect(validator.validateAdmVoteForPublicKey('+L4e003f782cd1c1c84A6767a871321af2ecdb3da8d8f6b8d1f13179835b6ec432')).toBe(false); + }); + + test('Should return false for a public key that starts with a number', () => { + expect(validator.validateAdmVoteForPublicKey('4e003f782cd1c1c84A6767a871321af2ecdb3da8d8f6b8d1f13179835b6ec432')).toBe(false); + }); + + test('Should return false for a public key that starts with a letter', () => { + expect(validator.validateAdmVoteForPublicKey('e4003f782cd1c1c84A6767a871321af2ecdb3da8d8f6b8d1f13179835b6ec432')).toBe(false); + }); + + test('Should return true for a public key with a plus', () => { + expect(validator.validateAdmVoteForPublicKey('+4e003f782cd1c1c84A6767a871321af2ecdb3da8d8f6b8d1f13179835b6ec432')).toBe(true); + }); + + test('Should return true for a public key with a minus', () => { + expect(validator.validateAdmVoteForPublicKey('+4e003f782cd1c1c84A6767a871321af2ecdb3da8d8f6b8d1f13179835b6ec432')).toBe(true); + }); +}); + +describe('validateAdmVoteForDelegateName', () => { + test('Should return false for a number', () => { + expect(validator.validateAdmVoteForDelegateName(3)).toBe(false); + }); + + test('Should return false for an object', () => { + expect(validator.validateAdmVoteForDelegateName({})).toBe(false); + }); + + test('Should return false for undefined', () => { + expect(validator.validateAdmVoteForDelegateName(undefined)).toBe(false); + }); + + test('Should return false for NaN', () => { + expect(validator.validateAdmVoteForDelegateName(undefined)).toBe(false); + }); + + test('Should return false for a short string', () => { + expect(validator.validateAdmVoteForDelegateName('0f')).toBe(false); + }); + + test('Should return false for a vote without delegate name', () => { + expect(validator.validateAdmVoteForDelegateName('+')).toBe(false); + }); + + test('Should return false for a too long delegate name', () => { + expect(validator.validateAdmVoteForDelegateName('+e003f782cd1c1c84A6767a871321af2e')).toBe(false); + }); + + test('Should return false for a vote that starts with a number', () => { + expect(validator.validateAdmVoteForDelegateName('4darksinc')).toBe(false); + }); + + test('Should return false for a vote that starts with a letter', () => { + expect(validator.validateAdmVoteForDelegateName('darksinc')).toBe(false); + }); + + test('Should return true for a delegate name with a plus', () => { + expect(validator.validateAdmVoteForDelegateName('+darksinc')).toBe(true); + }); + + test('Should return true for a delegate name with a minus', () => { + expect(validator.validateAdmVoteForDelegateName('+darksinc')).toBe(true); + }); +}); + +describe('validateMessage', () => { + test('Result should be false for a number message', () => { + expect(validator.validateMessage(3).result).toBe(false); + }); + + test('Result should be false for an object message', () => { + expect(validator.validateMessage({}).result).toBe(false); + }); + + test('Result should be true for a string message', () => { + expect(validator.validateMessage('').result).toBe(true); + }); + + test('Result should be false for a string rich message', () => { + expect(validator.validateMessage('', 2).result).toBe(false); + }); + + test('Result should be false for a string signal message', () => { + expect(validator.validateMessage('', 3).result).toBe(false); + }); + + test('Result should be false for an empty json rich message', () => { + expect(validator.validateMessage('{}', 2).result).toBe(false); + }); + + test('Result should be false for an empty json signal message', () => { + expect(validator.validateMessage('{}', 3).result).toBe(false); + }); + + test('Result should be true for a json rich message with the given amount', () => { + expect(validator.validateMessage('{"amount": "0.13"}', 2).result).toBe(true); + }); + + test('Result should be true for a json signal message with the given amount', () => { + expect(validator.validateMessage('{"amount": "0.13"}', 3).result).toBe(true); + }); + + test('Result should be false for a json rich message with upercase coin name', () => { + expect(validator.validateMessage('{"amount": "0.13", "type": "ETH_transaction"}', 2).result).toBe(false); + }); + + test('Result should be false for a json signal message with upercase coin name', () => { + expect(validator.validateMessage('{"amount": "0.13", "type": "ETH_transaction"}', 3).result).toBe(false); + }); + + test('Result should be true for a json rich message with lowercase coin name', () => { + expect(validator.validateMessage('{"amount": "0.13", "type": "eth_transaction"}', 2).result).toBe(true); + }); + + test('Result should be true for a json signal message with lowercase coin name', () => { + expect(validator.validateMessage('{"amount": "0.13", "type": "eth_transaction"}', 3).result).toBe(true); + }); +}); diff --git a/src/helpers/time.js b/src/helpers/time.js new file mode 100644 index 0000000..4504cac --- /dev/null +++ b/src/helpers/time.js @@ -0,0 +1,15 @@ +const constants = require('./constants.js'); + +module.exports = { + getEpochTime(time) { + const startTime = time ?? Date.now(); + + const {epochTime} = constants; + const epochTimeMs = epochTime.getTime(); + + return Math.floor((startTime - epochTimeMs) / 1000); + }, + getTime(time) { + return this.getEpochTime(time); + }, +}; diff --git a/src/helpers/transactionFormer.js b/src/helpers/transactionFormer.js new file mode 100644 index 0000000..e03cd91 --- /dev/null +++ b/src/helpers/transactionFormer.js @@ -0,0 +1,340 @@ +const sodium = require('sodium-browserify-tweetnacl'); +const crypto = require('crypto'); +const ByteBuffer = require('bytebuffer'); + +const BigNum = require('./bignumber.js'); +const keys = require('./keys.js'); +const constants = require('./constants.js'); +const time = require('./time.js'); + +module.exports = { + createTransaction(type, data) { + const { + SEND, + VOTE, + DELEGATE, + CHAT_MESSAGE, + STATE, + } = constants.transactionTypes; + + const actions = { + [SEND]: () => this.createSendTransaction(data), + [VOTE]: () => this.createVoteTransaction(data), + [DELEGATE]: () => this.createDelegateTransaction(data), + [CHAT_MESSAGE]: () => this.createChatTransaction(data), + [STATE]: () => this.createStateTransaction(data), + }; + + const action = actions[type]; + + return action ? action() : ({}); + }, + createBasicTransaction(data) { + const transaction = { + type: data.transactionType, + timestamp: time.getTime(), + amount: 0, + senderPublicKey: data.keyPair.publicKey.toString('hex'), + senderId: keys.createAddressFromPublicKey(data.keyPair.publicKey), + asset: {}, + }; + + return transaction; + }, + createSendTransaction(data) { + const details = { + ...data, + transactionType: constants.transactionTypes.SEND, + }; + + const transaction = { + ...this.createBasicTransaction(details), + recipientId: details.recipientId, + amount: details.amount, + asset: {}, + }; + + transaction.signature = this.transactionSign(transaction, details.keyPair); + + return transaction; + }, + createStateTransaction(data) { + const details = { + ...data, + transactionType: constants.transactionTypes.STATE, + }; + + const transaction = { + ...this.createBasicTransaction(details), + recipientId: null, + asset: { + state: { + key: details.key, + value: details.value, + type: 0, + }, + }, + }; + + transaction.signature = this.transactionSign(transaction, details.keyPair); + + return transaction; + }, + createChatTransaction(data) { + const details = { + ...data, + transactionType: constants.transactionTypes.CHAT_MESSAGE, + }; + + const transaction = { + ...this.createBasicTransaction(details), + recipientId: details.recipientId, + amount: details.amount || 0, + asset: { + chat: { + message: data.message, + own_message: data.own_message, + type: data.message_type, + }, + }, + }; + + transaction.signature = this.transactionSign(transaction, details.keyPair); + + return transaction; + }, + createDelegateTransaction(data) { + const details = { + ...data, + transactionType: constants.transactionTypes.DELEGATE, + }; + + const transaction = { + ...this.createBasicTransaction(details), + recipientId: null, + asset: { + delegate: { + username: details.username, + publicKey: details.keyPair.publicKey.toString('hex'), + }, + }, + }; + + transaction.signature = this.transactionSign(transaction, details.keyPair); + + return transaction; + }, + createVoteTransaction(data) { + const details = { + ...data, + transactionType: constants.transactionTypes.VOTE, + }; + + const transaction = { + ...this.createBasicTransaction(details), + asset: { + votes: details.votes, + }, + }; + + transaction.recipientId = transaction.senderId; + transaction.signature = this.transactionSign(transaction, details.keyPair); + + return transaction; + }, + getHash(trs) { + const hash = crypto + .createHash('sha256') + .update(this.getBytes(trs)) + .digest(); + + return hash; + }, + getAssetBytes(transaction) { + const {type} = transaction; + const { + SEND, + VOTE, + DELEGATE, + CHAT_MESSAGE, + STATE, + } = constants.transactionTypes; + + if (type === SEND) { + return {assetBytes: null, assetSize: 0}; + } + + const actions = { + [VOTE]: this.voteGetBytes, + [DELEGATE]: this.delegatesGetBytes, + [CHAT_MESSAGE]: this.chatGetBytes, + [STATE]: this.statesGetBytes, + }; + + const getBytes = actions[type]; + + if (typeof getBytes === 'function') { + const assetBytes = getBytes(transaction); + + return {assetBytes, assetSize: assetBytes.length}; + } + }, + getBytes(transaction) { + const skipSignature = false; + const skipSecondSignature = true; + + const {assetSize, assetBytes} = this.getAssetBytes(transaction); + + const bb = new ByteBuffer(1 + 4 + 32 + 8 + 8 + 64 + 64 + assetSize, true); + + bb.writeByte(transaction.type); + bb.writeInt(transaction.timestamp); + + const senderPublicKeyBuffer = Buffer.from(transaction.senderPublicKey, 'hex'); + + for (const buf of senderPublicKeyBuffer) { + bb.writeByte(buf); + } + + if (transaction.requesterPublicKey) { + const requesterPublicKey = Buffer.from(transaction.requesterPublicKey, 'hex'); + + for (const buf of requesterPublicKey) { + bb.writeByte(buf); + } + } + + if (transaction.recipientId) { + const recipient = new BigNum( + transaction.recipientId.slice(1), + ).toBuffer({size: 8}); + + for (let i = 0; i < 8; i++) { + bb.writeByte(recipient[i] || 0); + } + } else { + for (let i = 0; i < 8; i++) { + bb.writeByte(0); + } + } + + bb.writeLong(transaction.amount); + + if (assetSize > 0) { + for (const assetByte of assetBytes) { + bb.writeByte(assetByte); + } + } + + if (!skipSignature && transaction.signature) { + const signatureBuffer = Buffer.from(transaction.signature, 'hex'); + + for (const buf of signatureBuffer) { + bb.writeByte(buf); + } + } + + if (!skipSecondSignature && transaction.signSignature) { + const signSignatureBuffer = Buffer.from(transaction.signSignature, 'hex'); + + for (const buf of signSignatureBuffer) { + bb.writeByte(buf); + } + } + + bb.flip(); + + const arrayBuffer = new Uint8Array(bb.toArrayBuffer()); + const buffer = []; + + for (let i = 0; i < arrayBuffer.length; i++) { + buffer[i] = arrayBuffer[i]; + } + + return Buffer.from(buffer); + }, + transactionSign(trs, keypair) { + const hash = this.getHash(trs); + + return this.sign(hash, keypair).toString('hex'); + }, + voteGetBytes(trs) { + const {votes} = trs.asset; + + const buf = votes ? + Buffer.from(votes.join(''), 'utf8') : + null; + + return buf; + }, + delegatesGetBytes(trs) { + const {username} = trs.asset.delegate; + + const buf = username ? + Buffer.from(username, 'utf8') : + null; + + return buf; + }, + statesGetBytes(trs) { + const {value} = trs.asset.state; + + if (!value) { + return null; + } + + let buf = Buffer.from([]); + + const {key, type} = trs.asset.state; + + const stateBuf = Buffer.from(value); + + buf = Buffer.concat([buf, stateBuf]); + + if (key) { + const keyBuf = Buffer.from(key); + buf = Buffer.concat([buf, keyBuf]); + } + + const bb = new ByteBuffer(4 + 4, true); + + bb.writeInt(type); + bb.flip(); + + buf = Buffer.concat([buf, bb.toBuffer()]); + + return buf; + }, + chatGetBytes(trs) { + let buf = Buffer.from([]); + + const {message} = trs.asset.chat; + const messageBuf = Buffer.from(message, 'hex'); + + buf = Buffer.concat([buf, messageBuf]); + + const {own_message: ownMessage} = trs.asset.chat; + + if (ownMessage) { + const ownMessageBuf = Buffer.from(ownMessage, 'hex'); + buf = Buffer.concat([buf, ownMessageBuf]); + } + + const bb = new ByteBuffer(4 + 4, true); + + bb.writeInt(trs.asset.chat.type); + bb.flip(); + + buf = Buffer.concat([buf, Buffer.from(bb.toBuffer())]); + + return buf; + }, + sign(hash, keypair) { + const sign = sodium.crypto_sign_detached( + hash, + Buffer.from(keypair.privateKey, 'hex'), + ); + + return sign; + }, +}; diff --git a/src/helpers/validator.js b/src/helpers/validator.js new file mode 100644 index 0000000..179ed6c --- /dev/null +++ b/src/helpers/validator.js @@ -0,0 +1,169 @@ +const constants = require('./constants'); +const bigNumber = require('bignumber.js'); + +module.exports = { + getRandomIntInclusive(minimum, maximum) { + const min = Math.ceil(minimum); + const max = Math.floor(maximum); + + // The maximum is inclusive and the minimum is inclusive + return Math.floor(Math.random() * (max - min + 1) + min); + }, + isNumeric(str) { + if (typeof str !== 'string') { + return false; + } + + return !isNaN(parseFloat(str)); + }, + tryParseJSON(jsonString) { + try { + const o = JSON.parse(jsonString); + + return typeof o === 'object' ? o : false; + } catch (e) { + return false; + } + }, + validatePassPhrase(passPhrase) { + return typeof passPhrase === 'string' && passPhrase.length > 30; + }, + validateEndpoint(endpoint) { + return typeof endpoint === 'string'; + }, + validateAdmAddress(address) { + return typeof(address) === 'string' && constants.RE_ADM_ADDRESS.test(address); + }, + validateAdmPublicKey(publicKey) { + return ( + typeof publicKey === 'string' && + publicKey.length === 64 && + constants.RE_HEX.test(publicKey) + ); + }, + validateAdmVoteForPublicKey(publicKey) { + return ( + typeof publicKey === 'string' && + constants.RE_ADM_VOTE_FOR_PUBLIC_KEY.test(publicKey) + ); + }, + validateAdmVoteForAddress(address) { + return ( + typeof address === 'string' && + constants.RE_ADM_VOTE_FOR_ADDRESS.test(address) + ); + }, + validateAdmVoteForDelegateName(delegateName) { + return ( + typeof delegateName === 'string' && + constants.RE_ADM_VOTE_FOR_DELEGATE_NAME.test(delegateName) + ); + }, + validateIntegerAmount(amount) { + return Number.isSafeInteger(amount); + }, + validateStringAmount(amount) { + return this.isNumeric(amount); + }, + validateMessageType(messageType) { + return [1, 2, 3].includes(messageType); + }, + validateMessage(message, messageType) { + if (typeof message !== 'string') { + return { + result: false, + error: `Message must be a string`, + }; + } + + if ([2, 3].includes(messageType)) { + const json = this.tryParseJSON(message); + + if (!json) { + return { + result: false, + error: `For rich and signal messages, 'message' must be a JSON string`, + }; + } + + if (json.type && json.type.toLowerCase().includes('_transaction')) { + if (json.type.toLowerCase() !== json.type) { + return { + result: false, + error: `Value '_transaction' must be in lower case`, + }; + } + } + + if (typeof json.amount !== 'string' || !this.validateStringAmount(json.amount)) { + return { + result: false, + error: `Field 'amount' must be a string, representing a number`, + }; + } + } + + return { + result: true, + }; + }, + validateDelegateName(name) { + if (typeof name !== 'string') { + return false; + } + + return constants.RE_ADM_DELEGATE_NAME.test(name); + }, + admToSats(amount) { + return bigNumber(String(amount)) + .multipliedBy(constants.SAT) + .integerValue() + .toNumber(); + }, + async badParameter(name, value, customMessage) { + return { + success: false, + errorMessage: `Wrong '${name}' parameter${value ? ': ' + value : ''}${customMessage ? '. Error: ' + customMessage : ''}`, + }; + }, + formatRequestResults(response, isRequestSuccess) { + let results = { + details: {}, + }; + + if (isRequestSuccess) { + results = { + success: response.data && response.data.success, + data: response.data, + details: { + status: response.status, + statusText: response.statusText, + response: response, + }, + }; + + if (!results.success && results.data) { + results.errorMessage = `Node's reply: ${results.data.error}`; + } + } else { + results = { + success: false, + data: undefined, + details: { + status: response.status, + statusText: response.response && response.response.statusText, + response: response.response && response.response.status, + error: response.toString(), + message: ( + response.response && + response.response.data && + response.response.data.toString().trim() + ), + errorMessage: `${results.details.error}${results.details.message ? '. Message: ' + results.details.message : ''}`, + }, + }; + } + + return results; + }, +}; diff --git a/src/helpers/wsClient.js b/src/helpers/wsClient.js new file mode 100644 index 0000000..4070521 --- /dev/null +++ b/src/helpers/wsClient.js @@ -0,0 +1,91 @@ +const ioClient = require('socket.io-client'); +const logger = require('./logger'); +const validator = require('./validator'); + +module.exports = { + isSocketEnabled: false, // If we need socket connection + wsType: 'ws', // Socket connection type, 'ws' (default) or 'wss' + admAddress: '', // ADM address to subscribe to notifications + connection: null, // Socket connection + onNewMessage: null, // Method to process new messages or transactions + activeNodes: [], // List of nodes that are active. Not all of them synced and support socket. + activeSocketNodes: [], // List of nodes that are active, synced and support socket + useFastest: false, // If to connect to node with minimum ping. Not recommended. + // Constructor + initSocket(params) { + this.onNewMessage = params.onNewMessage; + this.isSocketEnabled = params.socket; + this.wsType = params.wsType; + this.admAddress = params.admAddress; + }, + // Runs after every healthCheck() to re-connect socket if needed + reviseConnection(nodes) { + if (!this.isSocketEnabled) { + return; + } + if (!this.connection || !this.connection.connected) { + this.activeNodes = nodes.slice(); + this.setNodes(); + this.setConnection(); + } + }, + // Make socket connection and subscribe to new transactions + setConnection() { + if (!this.activeSocketNodes.length) { + return logger.warn(`[Socket] No supported socket nodes at the moment.`); + } + + const node = this.socketAddress(); + logger.log(`[Socket] Supported nodes: ${this.activeSocketNodes.length}. Connecting to ${node}...`); + this.connection = ioClient.connect(node, {reconnection: false, timeout: 5000}); + + this.connection.on('connect', () => { + this.connection.emit('address', this.admAddress); + logger.info('[Socket] Connected to ' + node + ' and subscribed to incoming transactions for ' + this.admAddress + '.'); + }); + + this.connection.on('disconnect', (reason) => { + logger.warn('[Socket] Disconnected. Reason: ' + reason); + }); + + this.connection.on('connect_error', (err) => { + logger.warn('[Socket] Connection error: ' + err); + }); + + this.connection.on('newTrans', (transaction) => { + if ((transaction.recipientId === this.admAddress) && (transaction.type === 0 || transaction.type === 8)) { + // console.info(`[Socket] New incoming socket transaction received: ${transaction.id}`); + this.onNewMessage(transaction); + } + }); + }, + // Save the list of nodes activeSocketNodes that are active, synced and support socket + setNodes() { + this.activeSocketNodes = this.activeNodes.filter((n) => n.socketSupport & !n.outOfSync); + // Remove nodes without IP if 'ws' connection type + if (this.wsType === 'ws') { + this.activeSocketNodes = this.activeSocketNodes.filter((n) => !n.ifHttps || n.ip); + } + }, + // Returns socket url for connection + socketAddress() { + const node = this.useFastest ? this.fastestNode() : this.randomNode(); + let socketUrl = this.wsType + '://'; + if (this.wsType === 'ws') { + let host = node.ip; + if (!host || host === undefined) { + host = node.url; + } + socketUrl = socketUrl + host + ':' + node.wsPort; + } else { + socketUrl = socketUrl + node.url; // no port if wss + } + return socketUrl; + }, + fastestNode() { + return this.activeSocketNodes[0]; // They are sorted by ping + }, + randomNode() { + return this.activeSocketNodes[validator.getRandomIntInclusive(0, this.activeSocketNodes.length - 1)]; + }, +}; diff --git a/index.js b/src/index.js similarity index 59% rename from index.js rename to src/index.js index daba8ec..bdf9495 100644 --- a/index.js +++ b/src/index.js @@ -1,45 +1,51 @@ -const constants = require('./helpers/constants.js'); const get = require('./groups/get'); const getPublicKey = require('./groups/getPublicKey'); const decodeMsg = require('./groups/decodeMsg'); + const newDelegate = require('./groups/newDelegate'); const voteForDelegate = require('./groups/voteForDelegate'); const sendTokens = require('./groups/sendTokens'); const sendMessage = require('./groups/sendMessage'); const healthCheck = require('./helpers/healthCheck'); + const eth = require('./groups/eth'); const dash = require('./groups/dash'); const btc = require('./groups/btc'); const doge = require('./groups/doge'); const lsk = require('./groups/lsk'); -const transactionFormer = require('./helpers/transactionFormer'); -const keys = require('./helpers/keys'); + const encryptor = require('./helpers/encryptor'); const socket = require('./helpers/wsClient'); const logger = require('./helpers/logger'); -module.exports = (params, log) => { - log = log || console; - logger.initLogger(params.logLevel, log); - const nodeManager = healthCheck(params.node); - - return { - get: get(nodeManager), - getPublicKey: getPublicKey(nodeManager), - sendTokens: sendTokens(nodeManager), - sendMessage: sendMessage(nodeManager), - newDelegate: newDelegate(nodeManager), - voteForDelegate: voteForDelegate(nodeManager), - decodeMsg, - eth, - dash, - btc, - doge, - lsk, - transactionFormer, - keys, - encryptor, - socket, - constants - }; +const constants = require('./helpers/constants.js'); +const transactionFormer = require('./helpers/transactionFormer'); +const keys = require('./helpers/keys'); + +module.exports = (params, customLogger) => { + const log = customLogger || console; + + logger.initLogger(params.logLevel, log); + + const nodeManager = healthCheck(params.node, params.checkHealthAtStartup); + + return { + get: get(nodeManager), + getPublicKey: getPublicKey(nodeManager), + sendTokens: sendTokens(nodeManager), + sendMessage: sendMessage(nodeManager), + newDelegate: newDelegate(nodeManager), + voteForDelegate: voteForDelegate(nodeManager), + decodeMsg, + eth, + dash, + btc, + doge, + lsk, + transactionFormer, + keys, + encryptor, + socket, + constants, + }; };