diff --git a/groups/newDelegate.js b/groups/newDelegate.js new file mode 100644 index 0000000..1906f9a --- /dev/null +++ b/groups/newDelegate.js @@ -0,0 +1,72 @@ +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_NEW_DELEGATE_RETRIES = 4; // How much re-tries for send tokens requests by default. Total 4+1 tries + +module.exports = (nodeManager) => { + /** + * 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) => { + + let transaction; + + try { + if (!validator.validatePassPhrase(passPhrase)) { + return validator.badParameter('passPhrase'); + } + + const keyPair = keys.createKeypairFromPassPhrase(passPhrase); + + if (!validator.validateDelegateName(username)) { + return validator.badParameter('username'); + } + + const type = constants.transactionTypes.DELEGATE; + + const data = { + type, + keyPair, + username, + }; + + transaction = transactionFormer.createTransaction(type, data); + + } catch (e) { + return validator.badParameter('#exception_catched#', e); + } + + const url = nodeManager.node() + '/api/delegates'; + + try { + const response = await axios.post(url, transaction); + + return validator.formatRequestResults(response, true); + } catch (error) { + const logMessage = `[ADAMANT js-api] New 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) + )); + } + + logger.warn(`${logMessage} No more attempts, returning error.`); + + return validator.formatRequestResults(error, false); + } + } +}; diff --git a/groups/voteForDelegate.js b/groups/voteForDelegate.js new file mode 100644 index 0000000..1d37d67 --- /dev/null +++ b/groups/voteForDelegate.js @@ -0,0 +1,121 @@ +const axios = require('axios'); +const get = require('./get'); +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_VOTE_FOR_DELEGATE_RETRIES = 4; // How much re-tries for send tokens requests by default. Total 4+1 tries + +const publicKeysCache = { }; + +module.exports = (nodeManager) => { + /** + * 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) => { + + let transaction; + + try { + if (!validator.validatePassPhrase(passPhrase)) { + return validator.badParameter('passPhrase'); + } + + const keyPair = keys.createKeypairFromPassPhrase(passPhrase); + + const uniqueVotes = []; + + for (let i = votes.length - 1; i >= 0; i--) { + const vote = votes[i]; + const voteName = vote.slice(1); + const voteDirection = vote.charAt(0); + + const cachedPublicKey = publicKeysCache[voteName]; + + if (cachedPublicKey) { + votes[i] = `${voteDirection}${cachedPublicKey}`; + } else { + if (validator.validateAdmVoteForAddress(vote)) { + const res = await get(nodeManager)('/accounts', { address: voteName }); + + if (res.success) { + const publicKey = res.data.account.publicKey; + + votes[i] = `${voteDirection}${publicKey}`; + publicKeysCache[voteName] = publicKey; + } else { + logger.warn(`[ADAMANT js-api] Failed to get public key for ${vote}. ${res.errorMessage}.`); + + return validator.badParameter('votes'); + } + } else if (validator.validateAdmVoteForDelegateName(vote)) { + const res = await get(nodeManager)('/delegates/get', { username: voteName }); + + if (res.success) { + const publicKey = res.data.delegate.publicKey; + + votes[i] = `${voteDirection}${publicKey}`; + publicKeysCache[voteName] = publicKey; + } else { + logger.warn(`[ADAMANT js-api] Failed to get public key for ${vote}. ${res.errorMessage}.`); + + return validator.badParameter('votes'); + } + } else if (!validator.validateAdmVoteForPublicKey(vote)) { + return validator.badParameter('votes'); + } + } + + // Exclude duplicates + const foundCopy = uniqueVotes.find((v) => v.slice(1) === votes[i].slice(1)); + + if (!foundCopy) { + uniqueVotes.push(votes[i]); + } + } + + const type = constants.transactionTypes.VOTE; + + const data = { + type, + keyPair, + votes: uniqueVotes, + }; + + transaction = transactionFormer.createTransaction(type, data); + } catch (error) { + return validator.badParameter('#exception_catched#', error) + } + + const url = nodeManager.node() + '/api/accounts/delegates'; + + try { + const response = await axios.post(url, transaction); + + return validator.formatRequestResults(response, true); + } 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) + )); + } + + logger.warn(`${logMessage} No more attempts, returning error.`); + + return validator.formatRequestResults(error, false); + } + } +}; diff --git a/helpers/constants.js b/helpers/constants.js index 55ed58e..bee5c31 100644 --- a/helpers/constants.js +++ b/helpers/constants.js @@ -31,9 +31,13 @@ module.exports = { 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/validator.js b/helpers/validator.js index 4483aaf..d1df0a8 100644 --- a/helpers/validator.js +++ b/helpers/validator.js @@ -52,6 +52,18 @@ module.exports = { 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 @@ -84,24 +96,24 @@ module.exports = { let json = this.tryParseJSON(message) - if (!json) + 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` - } + } } } @@ -110,6 +122,14 @@ module.exports = { } }, + 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() }, diff --git a/index.js b/index.js index 97bcc82..a016996 100644 --- a/index.js +++ b/index.js @@ -2,6 +2,8 @@ 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'); @@ -19,12 +21,14 @@ 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, diff --git a/package.json b/package.json index 306cd89..04d8ec2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "adamant-api", - "version": "1.3.0", + "version": "1.4.0", "description": "REST API for ADAMANT Blockchain", "main": "index.js", "scripts": { @@ -9,14 +9,14 @@ "author": "RomanS, Aleksei Lebedev (https://adamant.im)", "license": "GPL-3.0", "dependencies": { - "axios": "^0.23.0", - "bignumber.js": "^9.0.1", + "axios": "^0.25.0", + "bignumber.js": "^9.0.2", "bitcoinjs-lib": "^5.2.0", - "bitcore-mnemonic": "^8.25.10", + "bitcore-mnemonic": "^8.25.25", "bytebuffer": "^5.0.1", "coininfo": "^5.1.0", "ed2curve": "^0.3.0", - "ethereumjs-util": "^7.1.3", + "ethereumjs-util": "^7.1.4", "hdkey": "^2.0.1", "socket.io-client": "^2.4.0", "sodium-browserify-tweetnacl": "^0.2.6"