diff --git a/__tests__/helpers.test.js b/__tests__/helpers.test.js index 5bdfd6436..dfe105708 100644 --- a/__tests__/helpers.test.js +++ b/__tests__/helpers.test.js @@ -28,6 +28,7 @@ test('Update list', () => { test('Transaction type', () => { const transaction1 = { + 'version': 1, 'tx_id': '00034a15973117852c45520af9e4296c68adb9d39dc99a0342e23cd6686b295e', 'inputs': [], 'outputs': [ @@ -53,6 +54,7 @@ test('Transaction type', () => { }; const transaction2 = { + 'version': 1, 'tx_id': '00034a15973117852c45520af9e4296c68adb9d39dc99a0342e23cd6686b295d', 'inputs': [ { @@ -83,6 +85,7 @@ test('Transaction type', () => { }; const genesisBlock = { + 'version': 0, 'tx_id': '000164e1e7ec7700a18750f9f50a1a9b63f6c7268637c072ae9ee181e58eb01b', 'inputs': [], 'outputs': [ @@ -98,7 +101,7 @@ test('Transaction type', () => { ] }; - expect(helpers.getTxType(transaction1).toLowerCase()).toBe('block'); + expect(helpers.getTxType(transaction1).toLowerCase()).toBe('transaction'); expect(helpers.getTxType(transaction2).toLowerCase()).toBe('transaction'); expect(helpers.getTxType(genesisBlock).toLowerCase()).toBe('block'); }); diff --git a/__tests__/tokens_utils.js b/__tests__/tokens_utils.js index 34379dd1c..979a634dc 100644 --- a/__tests__/tokens_utils.js +++ b/__tests__/tokens_utils.js @@ -12,13 +12,14 @@ import wallet from '../src/wallet'; import version from '../src/version'; import { util } from 'bitcore-lib'; import WebSocketHandler from '../src/WebSocketHandler'; -import { InsufficientFundsError } from '../src/errors'; +import { InsufficientFundsError, TokenValidationError } from '../src/errors'; const storage = require('../src/storage').default; const createdTxHash = '00034a15973117852c45520af9e4296c68adb9d39dc99a0342e23cd6686b295e'; const createdToken = util.buffer.bufferToHex(tokens.getTokenUID(createdTxHash, 0)); const pin = '123456'; +const token1 = {'name': '1234', 'uid': '1234', 'symbol': '1234'}; beforeEach(() => { WebSocketHandler.started = true; @@ -38,6 +39,18 @@ mock.onPost('thin_wallet/send_tokens').reply((config) => { return [200, ret]; }); +mock.onGet('thin_wallet/token').reply((config) => { + const ret = { + 'mint': [], + 'melt': [], + 'name': token1.name, + 'symbol': token1.symbol, + 'total': 100 + } + return [200, ret]; +}); + + test('Token UID', () => { const txID = '00034a15973117852c45520af9e4296c68adb9d39dc99a0342e23cd6686b295e'; const txID2 = '00034a15973117852c45520af9e4296c68adb9d39dc99a0342e23cd6686b295c'; @@ -121,8 +134,7 @@ test('Insufficient funds', async (done) => { } }); -test('Tokens handling', () => { - const token1 = {'name': '1234', 'uid': '1234', 'symbol': '1234'}; +test('Tokens handling', async () => { const token2 = {'name': 'abcd', 'uid': 'abcd', 'symbol': 'abcd'}; const token3 = {'name': HATHOR_TOKEN_CONFIG.name, 'uid': HATHOR_TOKEN_CONFIG.uid}; const myTokens = [token1, token2, token3]; @@ -167,12 +179,12 @@ test('Tokens handling', () => { // Validates configuration string before add const config = tokens.getConfigurationString(token1.uid, token1.name, token1.symbol); - expect(tokens.validateTokenToAddByConfigurationString(config).success).toBe(true); - expect(tokens.validateTokenToAddByConfigurationString(config, token2.uid).success).toBe(false); - expect(tokens.validateTokenToAddByConfigurationString(config+'a').success).toBe(false); - expect(tokens.validateTokenToAddByConfigurationString('').success).toBe(false); + await expect(tokens.validateTokenToAddByConfigurationString(config)).resolves.toBeInstanceOf(Object); + await expect(tokens.validateTokenToAddByConfigurationString(config, token2.uid)).rejects.toThrow(TokenValidationError); + await expect(tokens.validateTokenToAddByConfigurationString(config+'a')).rejects.toThrow(TokenValidationError); + await expect(tokens.validateTokenToAddByConfigurationString('')).rejects.toThrow(TokenValidationError); // Cant add the same token twice tokens.addToken(token1.uid, token1.name, token1.symbol) - expect(tokens.validateTokenToAddByConfigurationString(config).success).toBe(false); + await expect(tokens.validateTokenToAddByConfigurationString(config)).rejects.toThrow(TokenValidationError); }); diff --git a/__tests__/transaction_utils.test.js b/__tests__/transaction_utils.test.js index 5c62e7265..5c2336c62 100644 --- a/__tests__/transaction_utils.test.js +++ b/__tests__/transaction_utils.test.js @@ -226,7 +226,7 @@ test('Prepare data to send tokens', async (done) => { expect(txData['inputs'][0].data.length > 0).toBeTruthy(); transaction.completeTx(txData); - transaction.setWeight(txData); + transaction.setWeightIfNeeded(txData); expect(txData['nonce']).toBe(0); expect(txData['version']).toBe(DEFAULT_TX_VERSION); expect(txData['timestamp'] > 0).toBeTruthy(); diff --git a/__tests__/wallet.test.js b/__tests__/wallet.test.js index 93efd4d4c..f6d140530 100644 --- a/__tests__/wallet.test.js +++ b/__tests__/wallet.test.js @@ -17,6 +17,7 @@ test('Wallet operations for transaction', () => { let historyTransactions = { '00034a15973117852c45520af9e4296c68adb9d39dc99a0342e23cd6686b295e': { + 'version': 1, 'tx_id': '00034a15973117852c45520af9e4296c68adb9d39dc99a0342e23cd6686b295e', 'inputs': [], 'outputs': [ @@ -39,9 +40,10 @@ test('Wallet operations for transaction', () => { 'token': '01', } ], - 'tokens': ['01'] + 'tokens': [{uid: '01', name: '01', symbol: '01'}] }, '00034a15973117852c45520af9e4296c68adb9d39dc99a0342e23cd6686b295f': { + 'version': 1, 'tx_id': '00034a15973117852c45520af9e4296c68adb9d39dc99a0342e23cd6686b295f', 'inputs': [], 'outputs': [ @@ -66,9 +68,10 @@ test('Wallet operations for transaction', () => { 'token': '01', }, ], - 'tokens': ['01'] + 'tokens': [{uid: '01', name: '01', symbol: '01'}] }, '00034a15973117852c45520af9e4296c68adb9d39dc99a0342e23cd6686b295d': { + 'version': 1, 'tx_id': '00034a15973117852c45520af9e4296c68adb9d39dc99a0342e23cd6686b295d', 'inputs': [ { @@ -104,9 +107,10 @@ test('Wallet operations for transaction', () => { 'spent_by': null, }, ], - 'tokens': ['01'] + 'tokens': [{uid: '01', name: '01', symbol: '01'}] }, '00034a15973117852c45520af9e4296c68adb9d39dc99a0342e23cd6686b295c': { + 'version': 0, 'tx_id': '00034a15973117852c45520af9e4296c68adb9d39dc99a0342e23cd6686b295c', 'inputs': [], 'outputs': [ @@ -121,7 +125,7 @@ test('Wallet operations for transaction', () => { 'spent_by': null, }, ], - 'tokens': ['01'] + 'tokens': [{uid: '01', name: '01', symbol: '01'}] } } @@ -184,7 +188,7 @@ test('Wallet operations for transaction', () => { 'spent_by': null, }, ], - 'tokens': ['01'] + 'tokens': [{uid: '01', name: '01', symbol: '01'}] }; diff --git a/index.js b/index.js index 1992ecb86..43ee9efb4 100644 --- a/index.js +++ b/index.js @@ -12,6 +12,7 @@ var txApi = require('./lib/api/txApi'); var versionApi = require('./lib/api/version'); var axios = require('./lib/api/axiosInstance'); var storage = require('./lib/storage'); +var MemoryStore = require('./lib/memory_store'); module.exports = { helpers: helpers.default, @@ -28,4 +29,5 @@ module.exports = { constants: constants, axios: axios, storage: storage.default, + MemoryStore: MemoryStore.default, } diff --git a/src/api/wallet.js b/src/api/wallet.js index 7cc5e28f0..b341aef1e 100644 --- a/src/api/wallet.js +++ b/src/api/wallet.js @@ -53,6 +53,54 @@ const walletApi = { }); }, + /** + * Call get general token info API + * + * @param {string} uid Token uid to get the general info + * @param {function} resolve Method to be called after response arrives + * + * @return {Promise} + * @memberof ApiWallet + * @inner + */ + getGeneralTokenInfo(uid, resolve) { + const data = {id: uid}; + return createRequestInstance(resolve).get('thin_wallet/token', {'params': data}).then((res) => { + resolve(res.data) + }, (res) => { + return Promise.reject(res); + }); + }, + + /** + * Call get token transaction history API + * + * @param {string} uid Token uid to get the info + * @param {number} count Quantity of elements to be returned + * @param {string} hash Hash of transaction as reference in pagination + * @param {number} timestamp Timestamp of transaction as reference in pagination + * @param {string} page The button clicked in the pagination ('previous' or 'next') + * @param {function} resolve Method to be called after response arrives + * + * @return {Promise} + * @memberof ApiWallet + * @inner + */ + getTokenHistory(uid, count, hash, timestamp, page, resolve) { + const data = {id: uid, count}; + + if (hash) { + data['hash'] = hash + data['timestamp'] = timestamp + data['page'] = page + } + + return createRequestInstance(resolve).get('thin_wallet/token_history', {'params': data}).then((res) => { + resolve(res.data) + }, (res) => { + return Promise.reject(res); + }); + }, }; export default walletApi; diff --git a/src/constants.js b/src/constants.js index 28acd225d..3d0e02ae8 100644 --- a/src/constants.js +++ b/src/constants.js @@ -68,6 +68,11 @@ export const DEFAULT_SERVERS = [ */ export const DEFAULT_SERVER = DEFAULT_SERVERS[0]; +/** + * Block version field + */ +export const BLOCK_VERSION = 0; + /** * Transaction version field */ diff --git a/src/errors.js b/src/errors.js index 7f53948a0..73de1485f 100644 --- a/src/errors.js +++ b/src/errors.js @@ -50,3 +50,11 @@ export class ConstantNotSet extends Error {} * @inner */ export class CreateTokenTxInvalid extends Error {} + +/** + * Error thrown when validating a registration of new token + * + * @memberof Errors + * @inner + */ +export class TokenValidationError extends Error {} diff --git a/src/helpers.js b/src/helpers.js index f1e9f7513..169e99243 100644 --- a/src/helpers.js +++ b/src/helpers.js @@ -9,7 +9,7 @@ import path from 'path'; import storage from './storage'; import tokens from './tokens'; -import { GENESIS_BLOCK, DECIMAL_PLACES, DEFAULT_SERVER } from './constants'; +import { BLOCK_VERSION, CREATE_TOKEN_TX_VERSION, DEFAULT_TX_VERSION, GENESIS_BLOCK, DECIMAL_PLACES, DEFAULT_SERVER } from './constants'; /** * Helper methods @@ -56,8 +56,15 @@ const helpers = { if (this.isBlock(tx)) { return 'Block'; } else { - return 'Transaction'; + if (tx.version === DEFAULT_TX_VERSION) { + return 'Transaction'; + } else if (tx.version === CREATE_TOKEN_TX_VERSION) { + return 'Create token transaction'; + } } + + // If there is no match + return 'Unknown'; }, /** @@ -71,13 +78,7 @@ const helpers = { * @inner */ isBlock(tx) { - if (GENESIS_BLOCK.indexOf(tx.tx_id) > -1) { - return true; - } - if (tx.inputs.length === 0) { - return true; - } - return false; + return tx.version === BLOCK_VERSION; }, @@ -328,6 +329,20 @@ const helpers = { getWithdrawAmount(meltAmount) { return Math.floor(tokens.getDepositPercentage() * meltAmount); }, + + /** + * Cleans a string for comparison. Remove multiple spaces, and spaces at the beginning and end, and transform to lowercase. + * + * @param {string} string String to be cleaned + * + * @return {string} String after clean + * @memberof Helpers + * @inner + * + */ + cleanupString(string) { + return string.replace(/\s\s+/g, ' ').trim().toLowerCase(); + } } export default helpers; diff --git a/src/memory_store.js b/src/memory_store.js new file mode 100644 index 000000000..7c937a095 --- /dev/null +++ b/src/memory_store.js @@ -0,0 +1,32 @@ +/** + * Copyright (c) Hathor Labs and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +export default class MemoryStore { + constructor() { + this.hathorMemoryStorage = {}; + } + + getItem(key) { + const ret = this.hathorMemoryStorage[key]; + if (ret === undefined) { + return null + } + return ret; + } + + setItem(key, value) { + this.hathorMemoryStorage[key] = value; + } + + removeItem(key) { + delete this.hathorMemoryStorage[key]; + } + + clear() { + this.hathorMemoryStorage = {}; + } +} \ No newline at end of file diff --git a/src/tokens.js b/src/tokens.js index 5c6d9764e..bb140193d 100644 --- a/src/tokens.js +++ b/src/tokens.js @@ -12,7 +12,7 @@ import wallet from './wallet'; import storage from './storage'; import helpers from './helpers'; import walletApi from './api/wallet'; -import { InsufficientFundsError, ConstantNotSet } from './errors'; +import { InsufficientFundsError, ConstantNotSet, TokenValidationError } from './errors'; import { CREATE_TOKEN_TX_VERSION, HATHOR_TOKEN_CONFIG, TOKEN_MINT_MASK, TOKEN_MELT_MASK, AUTHORITY_TOKEN_DATA } from './constants'; @@ -112,45 +112,72 @@ const tokens = { * @param {string} config Token configuration string * @param {string} uid Uid to check if matches with uid from config (optional) * - * @return {Object} {success: boolean, message: in case of failure, tokenData: object with token data in case of success} + * @return {Promise} Promise that resolves when validation finishes. Resolves with tokenData {uid, name, symbol} and reject with TokenValidationError * * @memberof Tokens * @inner */ validateTokenToAddByConfigurationString(config, uid) { - const tokenData = this.getTokenFromConfigurationString(config); - if (tokenData === null) { - return {success: false, message: 'Invalid configuration string'}; - } - if (uid && uid !== tokenData.uid) { - return {success: false, message: `Configuration string uid does not match: ${uid} != ${tokenData.uid}`}; - } + const promise = new Promise((resolve, reject) => { + const tokenData = this.getTokenFromConfigurationString(config); + if (tokenData === null) { + reject(new TokenValidationError('Invalid configuration string')); + } + if (uid && uid !== tokenData.uid) { + reject(new TokenValidationError(`Configuration string uid does not match: ${uid} != ${tokenData.uid}`)); + } - const validation = this.validateTokenToAddByUid(tokenData.uid); - if (validation.success) { - return {success: true, tokenData: tokenData}; - } else { - return validation; - } + const promiseValidation = this.validateTokenToAddByUid(tokenData.uid, tokenData.name, tokenData.symbol); + promiseValidation.then(() => { + resolve(tokenData); + }, (error) => { + reject(error); + }); + }); + return promise; }, /** - * Validation token by uid. Check if already exist + * Validation token by uid. + * Check if this uid was already added, if name and symbol match with the information in the DAG, + * and if already have another token with this name or symbol already added * * @param {string} uid Uid to check for existence + * @param {string} name Token name to execute validation + * @param {string} symbol Token symbol to execute validation * - * @return {Object} {success: boolean, message: in case of failure} + * @return {Promise} Promise that will be resolved when validation finishes. Resolve with no data and reject with TokenValidationError * * @memberof Tokens * @inner */ - validateTokenToAddByUid(uid) { - const existedToken = this.tokenExists(uid); - if (existedToken) { - return {success: false, message: `You already have this token: ${uid} (${existedToken.name})`}; - } + validateTokenToAddByUid(uid, name, symbol) { + const promise = new Promise((resolve, reject) => { + // Validate if token uid was already added + const token = this.tokenExists(uid); + if (token) { + reject(new TokenValidationError(`You already have this token: ${uid} (${token.name})`)); + } + - return {success: true}; + // Validate if already have another token with this same name and symbol added + const tokenInfo = this.tokenInfoExists(name, symbol); + if (tokenInfo) { + reject(new TokenValidationError(`You already have a token with this ${tokenInfo.key}: ${tokenInfo.token.uid} - ${tokenInfo.token.name} (${tokenInfo.token.symbol})`)); + } + + // Validate if name and symbol match with the token info in the DAG + walletApi.getGeneralTokenInfo(uid, (response) => { + if (response.name !== name) { + reject(new TokenValidationError(`Token name does not match with the real one. Added: ${name}. Real: ${response.name}`)); + } else if (response.symbol !== symbol) { + reject(new TokenValidationError(`Token symbol does not match with the real one. Added: ${symbol}. Real: ${response.symbol}`)); + } else { + resolve(); + } + }); + }); + return promise; }, /** @@ -259,6 +286,30 @@ const tokens = { return null; }, + /** + * Validates if already has a token with same name or symbol added in the wallet + * + * @param {string} name Token name to search + * @param {string} symbol Token symbol to search + * + * @return {Object|null} Token if name or symbol already exists, else null + * + * @memberof Tokens + * @inner + */ + tokenInfoExists(name, symbol) { + const tokens = this.getTokens(); + for (const token of tokens) { + if (helpers.cleanupString(token.name) === helpers.cleanupString(name)) { + return {token, key: 'name'}; + } + if (helpers.cleanupString(token.symbol) === helpers.cleanupString(symbol)) { + return {token, key: 'symbol'}; + } + } + return null; + }, + /** * Create the tx for the new token in the backend and creates a new mint and melt outputs to be used in the future * @@ -295,8 +346,11 @@ const tokens = { const tokenUid = response.tx.hash; this.addToken(tokenUid, name, symbol); resolve({uid: tokenUid, name, symbol}); - }, (error) => { - reject(error); + }, (message) => { + // I need to reject an error because we've changed the createMintData to reject an error + // Changing sendTransaction method to reject an error also would require refactor in other methods + // We already have an issue to always reject an error but while we don't do it, we need this + reject(new Error(message)); }); }); return promise; @@ -757,6 +811,20 @@ const tokens = { clearDepositPercentage() { this._depositPercentage = null; }, + + /** + * Checks if the uid passed is from Hathor token + * + * @param {string} uid UID to check if is Hathor's + * + * @return {boolean} true if is Hathor uid, false otherwise + * + * @memberof Tokens + * @inner + */ + isHathorToken(uid) { + return uid === HATHOR_TOKEN_CONFIG.uid; + }, } export default tokens; diff --git a/src/transaction.js b/src/transaction.js index 8b54e6b00..80b96eb63 100644 --- a/src/transaction.js +++ b/src/transaction.js @@ -467,9 +467,9 @@ const transaction = { * @memberof Transaction * @inner */ - setWeight(data) { + setWeightIfNeeded(data) { // Calculate tx weight if needed. - if (data.weight === 0) { + if (!('weight' in data) || data.weight === 0) { let minimumWeight = this.calculateTxWeight(data); data['weight'] = minimumWeight; } @@ -613,7 +613,7 @@ const transaction = { } // Set weight only after completing all the fields - transaction.setWeight(data); + transaction.setWeightIfNeeded(data); const txBytes = transaction.txToBytes(data); const txHex = util.buffer.bufferToHex(txBytes); diff --git a/src/wallet.js b/src/wallet.js index b1d232c1d..841422c3b 100644 --- a/src/wallet.js +++ b/src/wallet.js @@ -5,7 +5,7 @@ * LICENSE file in the root directory of this source tree. */ -import { GAP_LIMIT, LIMIT_ADDRESS_GENERATION, HATHOR_BIP44_CODE, NETWORK, TOKEN_AUTHORITY_MASK, TOKEN_MINT_MASK, TOKEN_MELT_MASK, HATHOR_TOKEN_INDEX, HATHOR_TOKEN_CONFIG, MAX_OUTPUT_VALUE, HASH_KEY_SIZE, HASH_ITERATIONS } from './constants'; +import { GAP_LIMIT, LIMIT_ADDRESS_GENERATION, HATHOR_BIP44_CODE, NETWORK, TOKEN_AUTHORITY_MASK, TOKEN_MINT_MASK, TOKEN_MELT_MASK, TOKEN_INDEX_MASK, HATHOR_TOKEN_INDEX, HATHOR_TOKEN_CONFIG, MAX_OUTPUT_VALUE, HASH_KEY_SIZE, HASH_ITERATIONS } from './constants'; import Mnemonic from 'bitcore-mnemonic'; import { HDPublicKey, Address } from 'bitcore-lib'; import CryptoJS from 'crypto-js'; @@ -1497,7 +1497,7 @@ const wallet = { if (txin.decoded.token_data === HATHOR_TOKEN_INDEX) { tokenUID = HATHOR_TOKEN_CONFIG.uid; } else { - tokenUID = tx.tokens[txin.decoded.token_data - 1]; + tokenUID = tx.tokens[this.getTokenIndex(txin.decoded.token_data) - 1].uid; } if (tokenUID in balance) { balance[tokenUID] -= txin.value; @@ -1516,7 +1516,7 @@ const wallet = { if (txout.decoded.token_data === HATHOR_TOKEN_INDEX) { tokenUID = HATHOR_TOKEN_CONFIG.uid; } else { - tokenUID = tx.tokens[txout.decoded.token_data - 1]; + tokenUID = tx.tokens[this.getTokenIndex(txout.decoded.token_data) - 1].uid; } if (tokenUID in balance) { balance[tokenUID] += txout.value; @@ -1567,6 +1567,20 @@ const wallet = { } return mine; }, + + /** + * Get index of token list of the output + * + * @param {number} token_data Token data of the output + * + * @return {number} Index of the token of this output + * + * @memberof Wallet + * @inner + */ + getTokenIndex(token_data) { + return token_data & TOKEN_INDEX_MASK; + }, } export default wallet;