diff --git a/wallet/common/symbol/src/api/MosaicService.js b/wallet/common/symbol/src/api/MosaicService.js index 2a90077c62..53ab3ddb8a 100644 --- a/wallet/common/symbol/src/api/MosaicService.js +++ b/wallet/common/symbol/src/api/MosaicService.js @@ -1,15 +1,12 @@ -import { - addressFromRaw, - isRestrictableFlag, - isRevokableFlag, - isSupplyMutableFlag, - isTransferableFlag -} from '../utils'; +import { addressFromRaw, createSearchUrl, getMosaicAmount, mosaicInfoFromDTO } from '../utils'; import _ from 'lodash'; import { absoluteToRelativeAmount } from 'wallet-common-core'; /** @typedef {import('../types/Mosaic').Mosaic} Mosaic */ +/** @typedef {import('../types/Mosaic').MosaicInfo} MosaicInfo */ +/** @typedef {import('../types/Mosaic').MosaicOwner} MosaicOwner */ /** @typedef {import('../types/Network').NetworkProperties} NetworkProperties */ +/** @typedef {import('../types/SearchCriteria').SearchCriteria} SearchCriteria */ export class MosaicService { #api; @@ -53,34 +50,10 @@ export class MosaicService { }); // Create map from response - const mosaicInfosEntires = data.map(mosaicInfos => { - const duration = parseInt(mosaicInfos.mosaic.duration); - const startHeight = parseInt(mosaicInfos.mosaic.startHeight); - const endHeight = startHeight + duration; - const isUnlimitedDuration = duration === 0; - const creator = addressFromRaw(mosaicInfos.mosaic.ownerAddress); - const supply = absoluteToRelativeAmount(parseInt(mosaicInfos.mosaic.supply), mosaicInfos.mosaic.divisibility); - const { flags } = mosaicInfos.mosaic; - - return [ - mosaicInfos.mosaic.id, - { - id: mosaicInfos.mosaic.id, - divisibility: mosaicInfos.mosaic.divisibility, - names: [], - duration, - startHeight, - endHeight, - isUnlimitedDuration, - creator, - supply, - isSupplyMutable: isSupplyMutableFlag(flags), - isTransferable: isTransferableFlag(flags), - isRestrictable: isRestrictableFlag(flags), - isRevokable: isRevokableFlag(flags) - } - ]; - }); + const mosaicInfosEntires = data.map(mosaicInfos => [ + mosaicInfos.mosaic.id, + mosaicInfoFromDTO(mosaicInfos.mosaic) + ]); const mosaicInfos = Object.fromEntries(mosaicInfosEntires); // Find namespace ids if there are some in the mosaic list. Mosaic infos are not available for namespace ids @@ -114,4 +87,69 @@ export class MosaicService { return { ...mosaicInfos, ...remainedMosaicInfos }; }; + + /** + * Fetches the list of mosaics created by a given account from the node. + * @param {NetworkProperties} networkProperties - Network properties. + * @param {string} address - The mosaic creator address. + * @param {SearchCriteria} [searchCriteria] - Search criteria. + * @returns {Promise} - The created mosaics. + */ + fetchAccountMosaics = async (networkProperties, address, searchCriteria) => { + const endpoint = createSearchUrl(networkProperties.nodeUrl, '/mosaics', searchCriteria, { + ownerAddress: address + }); + const { data } = await this.#makeRequest(endpoint); + const mosaicInfos = data.map(mosaicDTO => mosaicInfoFromDTO(mosaicDTO.mosaic)); + const mosaicIds = mosaicInfos.map(mosaicInfo => mosaicInfo.id); + const mosaicNames = await this.#api.namespace.fetchMosaicNames(networkProperties, mosaicIds); + + return mosaicInfos.map(mosaicInfo => ({ + ...mosaicInfo, + names: mosaicNames[mosaicInfo.id] || [] + })); + }; + + /** + * Fetches the list of accounts holding a given mosaic from the node. + * @param {NetworkProperties} networkProperties - Network properties. + * @param {string} mosaicId - The mosaic id to search holders for. + * @param {SearchCriteria} [searchCriteria] - Search criteria. + * @returns {Promise} - The mosaic owners with their held amounts in relative units. + */ + fetchMosaicOwners = async (networkProperties, mosaicId, searchCriteria) => { + const endpoint = createSearchUrl(networkProperties.nodeUrl, '/accounts', searchCriteria, { + mosaicId + }); + const { data } = await this.#makeRequest(endpoint); + + if (!data.length) + return []; + + const divisibility = await this.#fetchMosaicDivisibility(networkProperties, mosaicId); + + return data.map(accountDTO => ({ + address: addressFromRaw(accountDTO.account.address), + amount: absoluteToRelativeAmount(getMosaicAmount(accountDTO.account.mosaics, mosaicId), divisibility) + })); + }; + + /** + * Fetches the divisibility of a single mosaic directly from the node, skipping name and namespace resolution. + * @param {NetworkProperties} networkProperties - Network properties. + * @param {string} mosaicId - The mosaic id. + * @returns {Promise} - The mosaic divisibility. + */ + #fetchMosaicDivisibility = async (networkProperties, mosaicId) => { + const endpoint = `${networkProperties.nodeUrl}/mosaics`; + const [mosaicInfoDTO] = await this.#makeRequest(endpoint, { + method: 'POST', + body: JSON.stringify({ mosaicIds: [mosaicId] }), + headers: { + 'Content-Type': 'application/json' + } + }); + + return mosaicInfoDTO.mosaic.divisibility; + }; } diff --git a/wallet/common/symbol/src/constants/index.js b/wallet/common/symbol/src/constants/index.js index 6e1a4a5e23..2b833a40b0 100644 --- a/wallet/common/symbol/src/constants/index.js +++ b/wallet/common/symbol/src/constants/index.js @@ -51,13 +51,22 @@ export const TransactionBundleType = { MULTISIG_TRANSFER: 'multisig-transfer', MULTISIG_ACCOUNT_MODIFICATION: 'multisig-account-modification', DELEGATED_HARVESTING: 'delegated-harvesting', - MULTISIG_DELEGATED_HARVESTING: 'multisig-delegated-harvesting' + MULTISIG_DELEGATED_HARVESTING: 'multisig-delegated-harvesting', + TOKEN_CREATION: 'token-creation', + MULTISIG_TOKEN_CREATION: 'multisig-token-creation', + TOKEN_SUPPLY_CHANGE: 'token-supply-change', + MULTISIG_TOKEN_SUPPLY_CHANGE: 'multisig-token-supply-change', + TOKEN_REVOCATION: 'token-revocation', + MULTISIG_TOKEN_REVOCATION: 'multisig-token-revocation' }; export const MULTISIG_BUNDLE_TYPES = [ TransactionBundleType.MULTISIG_TRANSFER, TransactionBundleType.MULTISIG_ACCOUNT_MODIFICATION, - TransactionBundleType.MULTISIG_DELEGATED_HARVESTING + TransactionBundleType.MULTISIG_DELEGATED_HARVESTING, + TransactionBundleType.MULTISIG_TOKEN_CREATION, + TransactionBundleType.MULTISIG_TOKEN_SUPPLY_CHANGE, + TransactionBundleType.MULTISIG_TOKEN_REVOCATION ]; export const HarvestingStatus = { diff --git a/wallet/common/symbol/src/modules/TokenModule.js b/wallet/common/symbol/src/modules/TokenModule.js new file mode 100644 index 0000000000..d88b2c3d92 --- /dev/null +++ b/wallet/common/symbol/src/modules/TokenModule.js @@ -0,0 +1,268 @@ +import { + MosaicSupplyChangeAction, + MosaicSupplyChangeActionMessage, + SINGLE_TRANSACTION_DEADLINE_HOURS, + TransactionBundleType, + TransactionType +} from '../constants'; +import { + addressFromPublicKey, + calculateTransactionSize, + createDeadline, + createMultisigAggregateBundle, + createTransactionFee, + createTransactionFeeTiers, + generateNonce, + mosaicIdFromNonce +} from '../utils'; +import { TransactionBundle, relativeToAbsoluteAmount } from 'wallet-common-core'; + +/** @typedef {import('../types/Transaction').Transaction} Transaction */ +/** @typedef {import('../types/Mosaic').MosaicInfo} MosaicInfo */ +/** @typedef {import('../types/Mosaic').MosaicOwner} MosaicOwner */ +/** @typedef {import('../types/Network').TransactionFees} TransactionFees */ +/** @typedef {import('../types/SearchCriteria').SearchCriteria} SearchCriteria */ + +export class TokenModule { + static name = 'token'; + #walletController; + #api; + + constructor() { } + + init = options => { + this.#walletController = options.walletController; + this.#api = options.api; + }; + + loadCache = async () => { }; + + resetState = () => { }; + + clear = () => { }; + + /** + * Prepares a token creation transaction bundle. + * The mosaic definition and its initial supply change are wrapped in an aggregate so the token is created + * with the requested supply atomically. When the sender is a multisig account, the bundle contains hash lock + * and aggregate bonded transactions instead. + * @param {object} options - The token creation options. + * @param {string} [options.senderPublicKey] - The creator public key. Defaults to the current account. + * @param {number} [options.nonce] - The mosaic nonce. Defaults to a freshly generated one. + * @param {string} options.initialSupply - The initial supply in relative units. + * @param {number} options.divisibility - The token divisibility. + * @param {number} options.duration - The token duration in blocks. 0 means unlimited. + * @param {boolean} options.isSupplyMutable - Whether the supply can be changed after creation. + * @param {boolean} options.isTransferable - Whether the token can be transferred between accounts. + * @param {boolean} options.isRestrictable - Whether the token supports restrictions. + * @param {boolean} options.isRevokable - Whether the creator can revoke the token. + * @returns {TransactionBundle} The token creation transaction bundle. + */ + createTransaction = options => { + const { initialSupply, divisibility, duration } = options; + const { senderPublicKey, senderAddress, isMultisig } = this.#resolveSender(options.senderPublicKey); + const nonce = options.nonce ?? generateNonce(); + const mosaicId = mosaicIdFromNonce(senderAddress, nonce); + + const definitionTransaction = { + type: TransactionType.MOSAIC_DEFINITION, + signerPublicKey: senderPublicKey, + signerAddress: senderAddress, + mosaicId, + nonce, + divisibility, + duration, + isSupplyMutable: options.isSupplyMutable, + isTransferable: options.isTransferable, + isRestrictable: options.isRestrictable, + isRevokable: options.isRevokable + }; + + const supplyChangeTransaction = { + type: TransactionType.MOSAIC_SUPPLY_CHANGE, + signerPublicKey: senderPublicKey, + signerAddress: senderAddress, + mosaicId, + action: MosaicSupplyChangeActionMessage[MosaicSupplyChangeAction.Increase], + delta: relativeToAbsoluteAmount(initialSupply, divisibility) + }; + + const innerTransactions = [definitionTransaction, supplyChangeTransaction]; + + if (isMultisig) + return this.#createMultisigBundle(innerTransactions, TransactionBundleType.MULTISIG_TOKEN_CREATION); + + return this.#createAggregateCompleteBundle(innerTransactions, senderPublicKey, TransactionBundleType.TOKEN_CREATION); + }; + + /** + * Prepares a token supply change transaction bundle to increase or decrease the supply of an existing token. + * When the sender is a multisig account, the bundle contains hash lock and aggregate bonded transactions. + * @param {object} options - The supply change options. + * @param {string} [options.senderPublicKey] - The creator public key. Defaults to the current account. + * @param {string} options.mosaicId - The token id. + * @param {number} options.divisibility - The token divisibility. + * @param {string} options.delta - The supply change amount in relative units. + * @param {number} options.action - The supply change action. One of MosaicSupplyChangeAction. + * @returns {TransactionBundle} The supply change transaction bundle. + */ + createSupplyChangeTransaction = options => { + const { mosaicId, divisibility, delta, action } = options; + const { senderPublicKey, senderAddress, isMultisig } = this.#resolveSender(options.senderPublicKey); + + const supplyChangeTransaction = { + type: TransactionType.MOSAIC_SUPPLY_CHANGE, + signerPublicKey: senderPublicKey, + signerAddress: senderAddress, + mosaicId, + action: MosaicSupplyChangeActionMessage[action], + delta: relativeToAbsoluteAmount(delta, divisibility) + }; + + if (isMultisig) + return this.#createMultisigBundle([supplyChangeTransaction], TransactionBundleType.MULTISIG_TOKEN_SUPPLY_CHANGE); + + return this.#createSingleTransactionBundle(supplyChangeTransaction, TransactionBundleType.TOKEN_SUPPLY_CHANGE); + }; + + /** + * Prepares a token revocation transaction bundle to reclaim a token amount from a holder back to the creator. + * When the sender is a multisig account, the bundle contains hash lock and aggregate bonded transactions. + * @param {object} options - The revocation options. + * @param {string} [options.senderPublicKey] - The creator public key. Defaults to the current account. + * @param {string} options.mosaicId - The token id. + * @param {number} options.divisibility - The token divisibility. + * @param {string} options.amount - The amount to revoke in relative units. + * @param {string} options.sourceAddress - The holder address to revoke the token from. + * @returns {TransactionBundle} The revocation transaction bundle. + */ + createRevocationTransaction = options => { + const { mosaicId, divisibility, amount, sourceAddress } = options; + const { senderPublicKey, senderAddress, isMultisig } = this.#resolveSender(options.senderPublicKey); + + const revocationTransaction = { + type: TransactionType.MOSAIC_SUPPLY_REVOCATION, + signerPublicKey: senderPublicKey, + signerAddress: senderAddress, + mosaic: { + id: mosaicId, + amount, + divisibility + }, + sourceAddress + }; + + if (isMultisig) + return this.#createMultisigBundle([revocationTransaction], TransactionBundleType.MULTISIG_TOKEN_REVOCATION); + + return this.#createSingleTransactionBundle(revocationTransaction, TransactionBundleType.TOKEN_REVOCATION); + }; + + /** + * Fetches the list of tokens created by the current account or a given account. + * @param {string} [address] - The creator address. Defaults to the current account. + * @param {SearchCriteria} [searchCriteria] - Pagination params. + * @returns {Promise} The created tokens. + */ + fetchAccountTokens = async (address, searchCriteria) => { + const { currentAccount, networkProperties } = this.#walletController; + const targetAddress = address ?? currentAccount.address; + + return this.#api.mosaic.fetchAccountMosaics(networkProperties, targetAddress, searchCriteria); + }; + + /** + * Fetches the list of accounts holding a given token. + * @param {string} mosaicId - The token id. + * @param {SearchCriteria} [searchCriteria] - Pagination params. + * @returns {Promise} The token owners with their held amounts in relative units. + */ + fetchMosaicOwners = async (mosaicId, searchCriteria) => { + const { networkProperties } = this.#walletController; + + return this.#api.mosaic.fetchMosaicOwners(networkProperties, mosaicId, searchCriteria); + }; + + /** + * Calculates the transaction fees for a given transaction bundle. + * @param {TransactionBundle} transactionBundle - The transaction bundle. + * @returns {TransactionFees[]} The transaction fees for each transaction in the bundle. + */ + calculateTransactionFees = async transactionBundle => { + const { networkProperties, networkIdentifier } = this.#walletController; + + return transactionBundle.transactions.map(transaction => { + const transactionSize = calculateTransactionSize(networkIdentifier, transaction); + + return createTransactionFeeTiers(networkProperties, transactionSize); + }); + }; + + /** + * Resolves the sender context. When a sender public key distinct from the current account is provided, + * the token action is performed on behalf of that multisig account. + * @param {string} [senderPublicKey] - The sender public key, if any. + * @returns {{ senderPublicKey: string, senderAddress: string, isMultisig: boolean }} The sender context. + */ + #resolveSender = senderPublicKey => { + const { currentAccount, networkIdentifier } = this.#walletController; + const resolvedPublicKey = senderPublicKey || currentAccount.publicKey; + + return { + senderPublicKey: resolvedPublicKey, + senderAddress: addressFromPublicKey(resolvedPublicKey, networkIdentifier), + isMultisig: resolvedPublicKey !== currentAccount.publicKey + }; + }; + + /** + * Wraps a single transaction into a bundle with fee and deadline for non-multisig announcement. + * @param {Transaction} transaction - The transaction. + * @param {string} bundleType - The transaction bundle type. + * @returns {TransactionBundle} The transaction bundle. + */ + #createSingleTransactionBundle = (transaction, bundleType) => { + const { networkProperties } = this.#walletController; + transaction.deadline = createDeadline(SINGLE_TRANSACTION_DEADLINE_HOURS, networkProperties.epochAdjustment); + transaction.fee = createTransactionFee(networkProperties, '0'); + + return new TransactionBundle([transaction], { type: bundleType }); + }; + + /** + * Wraps inner transactions into an aggregate complete bundle for non-multisig announcement. + * @param {Transaction[]} innerTransactions - The inner transactions. + * @param {string} signerPublicKey - The aggregate signer public key. + * @param {string} bundleType - The transaction bundle type. + * @returns {TransactionBundle} The transaction bundle. + */ + #createAggregateCompleteBundle = (innerTransactions, signerPublicKey, bundleType) => { + const { networkProperties } = this.#walletController; + + const aggregateTransaction = { + type: TransactionType.AGGREGATE_COMPLETE, + innerTransactions, + signerPublicKey, + fee: createTransactionFee(networkProperties, '0'), + deadline: createDeadline(SINGLE_TRANSACTION_DEADLINE_HOURS, networkProperties.epochAdjustment) + }; + + return new TransactionBundle([aggregateTransaction], { type: bundleType }); + }; + + /** + * Wraps inner transactions into a hash lock + aggregate bonded bundle for multisig announcement. + * @param {Transaction[]} innerTransactions - The inner transactions signed by the multisig account. + * @param {string} bundleType - The multisig transaction bundle type. + * @returns {TransactionBundle} The transaction bundle. + */ + #createMultisigBundle = (innerTransactions, bundleType) => { + const { currentAccount, networkProperties } = this.#walletController; + + return createMultisigAggregateBundle(innerTransactions, { + currentAccount, + networkProperties, + metadata: { type: bundleType } + }); + }; +} diff --git a/wallet/common/symbol/src/modules/index.js b/wallet/common/symbol/src/modules/index.js index 1fe004fc51..f81c63408d 100644 --- a/wallet/common/symbol/src/modules/index.js +++ b/wallet/common/symbol/src/modules/index.js @@ -1,3 +1,4 @@ export * from './HarvestingModule'; export * from './MultisigModule'; +export * from './TokenModule'; export * from './TransferModule'; diff --git a/wallet/common/symbol/src/types/Mosaic.js b/wallet/common/symbol/src/types/Mosaic.js index a7c43b3c3f..0774640347 100644 --- a/wallet/common/symbol/src/types/Mosaic.js +++ b/wallet/common/symbol/src/types/Mosaic.js @@ -31,7 +31,7 @@ * @property {number} endHeight - Mosaic expiration height. * @property {boolean} isUnlimitedDuration - Mosaic unlimited duration flag. * @property {string} creator - Mosaic creator address. - * @property {number} supply - Mosaic total supply. + * @property {string} supply - Mosaic total supply in relative units. * @property {boolean} isSupplyMutable - Mosaic supply mutable flag. * @property {boolean} isTransferable - Mosaic transferable flag. * @property {boolean} isRestrictable - Mosaic restrictable flag. @@ -44,4 +44,10 @@ * @property {string} name - Mosaic linked namespace name or id. */ +/** + * @typedef {Object} MosaicOwner + * @property {string} address - The holder account address. + * @property {string} amount - The held amount in relative units. + */ + export default {}; diff --git a/wallet/common/symbol/src/utils/mosaic.js b/wallet/common/symbol/src/utils/mosaic.js index 85d972e727..f5fa4446d4 100644 --- a/wallet/common/symbol/src/utils/mosaic.js +++ b/wallet/common/symbol/src/utils/mosaic.js @@ -1,4 +1,6 @@ +import { addressFromRaw } from './account'; import { MosaicFlags } from '../constants'; +import { Address, generateMosaicId } from 'symbol-sdk/symbol'; import { ApiError, absoluteToRelativeAmount } from 'wallet-common-core'; import * as Crypto from 'crypto'; @@ -18,6 +20,45 @@ export const generateNonce = () => { return new Uint32Array(nonce.buffer)[0]; }; +/** + * Derives the mosaic id from the owner address and nonce, matching the value the network assigns to the + * mosaic definition transaction. Used to reference a freshly created mosaic in the paired supply change. + * @param {string} ownerAddress - The mosaic creator address. + * @param {number} nonce - The mosaic nonce. + * @returns {string} The mosaic id. + */ +export const mosaicIdFromNonce = (ownerAddress, nonce) => { + const mosaicId = generateMosaicId(new Address(ownerAddress), nonce); + + return mosaicId.toString(16).toUpperCase().padStart(16, '0'); +}; + +/** + * Formats a mosaic node DTO into mosaic info. Names are left empty and resolved separately. + * @param {object} mosaic - The mosaic node from the API response. + * @returns {MosaicInfo} The mosaic info. + */ +export const mosaicInfoFromDTO = mosaic => { + const duration = parseInt(mosaic.duration); + const startHeight = parseInt(mosaic.startHeight); + + return { + id: mosaic.id, + divisibility: mosaic.divisibility, + names: [], + duration, + startHeight, + endHeight: startHeight + duration, + isUnlimitedDuration: duration === 0, + creator: addressFromRaw(mosaic.ownerAddress), + supply: absoluteToRelativeAmount(parseInt(mosaic.supply), mosaic.divisibility), + isSupplyMutable: isSupplyMutableFlag(mosaic.flags), + isTransferable: isTransferableFlag(mosaic.flags), + isRestrictable: isRestrictableFlag(mosaic.flags), + isRevokable: isRevokableFlag(mosaic.flags) + }; +}; + /** * Gets the mosaic amount from a mosaic list. * @param {Mosaic[]} mosaicList - The list of mosaics. @@ -91,6 +132,22 @@ export const isMosaicRevokable = (mosaic, chainHeight, currentAddress, sourceAdd return hasRevokableFlag && isCreatorCurrentAccount && !isSelfRevocation && isMosaicActive; }; +/** + * Checks if a mosaic total supply can be changed. + * @param {Mosaic} mosaic - The mosaic. + * @param {number} chainHeight - The chain height. + * @param {string} currentAddress - The current account address. + * @returns {boolean} True if the mosaic supply can be changed, false otherwise. + */ +export const isMosaicSupplyModifiable = (mosaic, chainHeight, currentAddress) => { + const hasSupplyMutableFlag = mosaic.isSupplyMutable; + const isCreatorCurrentAccount = mosaic.creator === currentAddress; + const isMosaicExpired = mosaic.endHeight - chainHeight <= 0; + const isMosaicActive = !isMosaicExpired || mosaic.isUnlimitedDuration; + + return hasSupplyMutableFlag && isCreatorCurrentAccount && isMosaicActive; +}; + /** * Checks if a mosaic flag is supply mutable. * @param {number} flags - The mosaic flags. diff --git a/wallet/common/symbol/src/utils/transaction-to-symbol.js b/wallet/common/symbol/src/utils/transaction-to-symbol.js index d6bbd780c3..6b64026a90 100644 --- a/wallet/common/symbol/src/utils/transaction-to-symbol.js +++ b/wallet/common/symbol/src/utils/transaction-to-symbol.js @@ -268,7 +268,7 @@ const mosaicDefinitionTransactionToSymbol = (transaction, config) => { fee: mapFee(transaction.fee), deadline: mapDeadline(transaction.deadline), duration: BigInt(transaction.duration), - flags: flags.join(' '), + flags: flags.length ? flags.join(' ') : 'none', nonce: transaction.nonce, divisibility: transaction.divisibility }; diff --git a/wallet/common/symbol/tests/__fixtures__/api/accounts-search-response.js b/wallet/common/symbol/tests/__fixtures__/api/accounts-search-response.js new file mode 100644 index 0000000000..5f1365fe55 --- /dev/null +++ b/wallet/common/symbol/tests/__fixtures__/api/accounts-search-response.js @@ -0,0 +1,54 @@ +export const accountsSearchResponse = { + 'data': [ + { + 'account': { + 'version': 1, + 'address': '982C69A051A72BFBE31AEDA7250AC6C747B7570B3E9C00B6', + 'addressHeight': '1', + 'publicKey': 'F9214C919AB21E14385107FE17E1BE6B95D8598C8BD1413B951D65D76ABA1A6C', + 'publicKeyHeight': '224497', + 'accountType': 0, + 'supplementalPublicKeys': {}, + 'activityBuckets': [], + 'mosaics': [ + { + 'id': '78C3CDF0896248DB', + 'amount': '1500000' + }, + { + 'id': '72C0212E67A08BCE', + 'amount': '10' + } + ], + 'importance': '0', + 'importanceHeight': '0' + }, + 'id': '6733BA562D1F6AABA297D730' + }, + { + 'account': { + 'version': 1, + 'address': '98A1B2C3D4E5F6A7B8C90A1B2C3D4E5F60718293A4B5C6D7', + 'addressHeight': '1', + 'publicKey': 'A1B2C3D4E5F60718293A4B5C6D7E8F90A1B2C3D4E5F60718293A4B5C6D7E8F90', + 'publicKeyHeight': '224498', + 'accountType': 0, + 'supplementalPublicKeys': {}, + 'activityBuckets': [], + 'mosaics': [ + { + 'id': '78C3CDF0896248DB', + 'amount': '250000' + } + ], + 'importance': '0', + 'importanceHeight': '0' + }, + 'id': '6733BA562D1F6AABA297D731' + } + ], + 'pagination': { + 'pageNumber': 1, + 'pageSize': 10 + } +}; diff --git a/wallet/common/symbol/tests/__fixtures__/local/mosaic.js b/wallet/common/symbol/tests/__fixtures__/local/mosaic.js index 5dae443985..8b27ee2f6a 100644 --- a/wallet/common/symbol/tests/__fixtures__/local/mosaic.js +++ b/wallet/common/symbol/tests/__fixtures__/local/mosaic.js @@ -108,3 +108,14 @@ export const mosaicNames = { '1213766D49458631': ['custom-3'], '699E9532708D2FB8': ['custom-4'] }; + +export const mosaicOwners = [ + { + address: 'TAWGTICRU4V7XYY25WTSKCWGY5D3OVYLH2OABNQ', + amount: '15000' + }, + { + address: 'TCQ3FQ6U4X3KPOGJBINSYPKOL5QHDAUTUS24NVY', + amount: '2500' + } +]; diff --git a/wallet/common/symbol/tests/api/MosaicServise.test.js b/wallet/common/symbol/tests/api/MosaicServise.test.js index d3f090d7dd..20479b338b 100644 --- a/wallet/common/symbol/tests/api/MosaicServise.test.js +++ b/wallet/common/symbol/tests/api/MosaicServise.test.js @@ -1,6 +1,8 @@ import { MosaicService } from '../../src/api/MosaicService'; +import { accountsSearchResponse } from '../__fixtures__/api/accounts-search-response'; import { mosaicInfosResponse } from '../__fixtures__/api/mosaic-infos-response'; -import { mosaicInfos, mosaicNames } from '../__fixtures__/local/mosaic'; +import { mosaicInfos, mosaicNames, mosaicOwners } from '../__fixtures__/local/mosaic'; +import { namespaceInfoWithMosaicAlias } from '../__fixtures__/local/namespace'; import { networkProperties } from '../__fixtures__/local/network'; import { expect, jest } from '@jest/globals'; @@ -12,6 +14,22 @@ const mockApi = { } }; +const mosaicsEndpoint = `${networkProperties.nodeUrl}/mosaics`; +const creatorAddress = 'TAWGTICRU4V7XYY25WTSKCWGY5D3OVYLH2OABNQ'; +const heldMosaic = mosaicInfos['78C3CDF0896248DB']; +const mosaicAliasNamespaceId = namespaceInfoWithMosaicAlias.id; +const linkedMosaic = mosaicInfos[namespaceInfoWithMosaicAlias.linkedMosaicId]; + +const createMosaicInfosRequestConfig = mosaicIds => ({ + method: 'POST', + body: JSON.stringify({ mosaicIds }), + headers: { + 'Content-Type': 'application/json' + } +}); + +const findMosaicInfosResponse = mosaicId => mosaicInfosResponse.filter(mosaicInfoDTO => mosaicInfoDTO.mosaic.id === mosaicId); + describe('MosaicService', () => { let mosaicService; @@ -26,42 +44,213 @@ describe('MosaicService', () => { describe('fetchMosaicInfo', () => { it('fetches a single mosaic info by calling fetchMosaicInfos', async () => { // Arrange: - const mosaicId = '72C0212E67A08BCE'; + const mosaicId = heldMosaic.id; mosaicService.fetchMosaicInfos = jest.fn().mockResolvedValue(mosaicInfos); + const expectedResult = heldMosaic; // Act: const result = await mosaicService.fetchMosaicInfo(networkProperties, mosaicId); // Assert: expect(mosaicService.fetchMosaicInfos).toHaveBeenCalledWith(networkProperties, [mosaicId]); - expect(result).toStrictEqual(mosaicInfos[mosaicId]); + expect(result).toStrictEqual(expectedResult); }); }); describe('fetchMosaicInfos', () => { - it('fetches mosaic infos for a list of ids', async () => { - // Arrange: - const mosaicIds = Object.keys(mosaicInfos); - mockMakeRequest.mockResolvedValueOnce(mosaicInfosResponse); - mockApi.namespace.fetchNamespaceInfos.mockResolvedValueOnce({}); - mockApi.namespace.fetchMosaicNames.mockResolvedValueOnce(mosaicNames); - const expectedResult = mosaicInfos; - const expectedRequestConfig = { - method: 'POST', - body: JSON.stringify({ - mosaicIds - }), - headers: { - 'Content-Type': 'application/json' + const runFetchMosaicInfosTest = (description, config, expected) => { + it(description, async () => { + // Arrange: + config.mosaicsResponses.forEach(response => mockMakeRequest.mockResolvedValueOnce(response)); + config.namespaceInfosResponses.forEach(response => mockApi.namespace.fetchNamespaceInfos.mockResolvedValueOnce(response)); + config.mosaicNamesResponses.forEach(response => mockApi.namespace.fetchMosaicNames.mockResolvedValueOnce(response)); + + // Act: + const result = await mosaicService.fetchMosaicInfos(networkProperties, config.mosaicIds); + + // Assert: + expect(mockMakeRequest).toHaveBeenCalledTimes(expected.requestedMosaicIds.length); + expected.requestedMosaicIds.forEach((mosaicIds, index) => { + expect(mockMakeRequest).toHaveBeenNthCalledWith( + index + 1, + mosaicsEndpoint, + createMosaicInfosRequestConfig(mosaicIds) + ); + }); + expect(result).toStrictEqual(expected.mosaicInfos); + }); + }; + + const fetchMosaicInfosTests = [ + { + description: 'fetches mosaic infos for a list of mosaic ids', + config: { + mosaicIds: Object.keys(mosaicInfos), + mosaicsResponses: [mosaicInfosResponse], + namespaceInfosResponses: [{}], + mosaicNamesResponses: [mosaicNames] + }, + expected: { + requestedMosaicIds: [Object.keys(mosaicInfos)], + mosaicInfos } - }; + }, + { + description: 'resolves a namespace id to the mosaic info of its linked mosaic', + config: { + mosaicIds: [mosaicAliasNamespaceId], + // The namespace id has no mosaic info, the linked mosaic is fetched in a second round. + mosaicsResponses: [[], findMosaicInfosResponse(linkedMosaic.id)], + namespaceInfosResponses: [{ [mosaicAliasNamespaceId]: namespaceInfoWithMosaicAlias }, {}], + mosaicNamesResponses: [{ [linkedMosaic.id]: linkedMosaic.names }, {}] + }, + expected: { + requestedMosaicIds: [[mosaicAliasNamespaceId], [linkedMosaic.id]], + // The info is returned under both the namespace id and the linked mosaic id. + mosaicInfos: { + [mosaicAliasNamespaceId]: linkedMosaic, + [linkedMosaic.id]: linkedMosaic + } + } + } + ]; + + fetchMosaicInfosTests.forEach(test => { + runFetchMosaicInfosTest(test.description, test.config, test.expected); + }); + }); + + describe('fetchAccountMosaics', () => { + const runFetchAccountMosaicsTest = (description, config, expected) => { + it(description, async () => { + // Arrange: + mockMakeRequest.mockResolvedValueOnce({ data: config.mosaicsResponse }); + mockApi.namespace.fetchMosaicNames.mockResolvedValueOnce(config.mosaicNames); + + // Act: + const result = await mosaicService.fetchAccountMosaics(networkProperties, creatorAddress, config.searchCriteria); + + // Assert: + expect(mockMakeRequest).toHaveBeenCalledWith(expected.endpoint); + expect(mockApi.namespace.fetchMosaicNames).toHaveBeenCalledWith(networkProperties, expected.mosaicIds); + expect(result).toStrictEqual(expected.mosaicInfos); + }); + }; + + const fetchAccountMosaicsTests = [ + { + description: 'fetches the mosaics created by an account with the default search criteria', + config: { + mosaicsResponse: mosaicInfosResponse, + mosaicNames + }, + expected: { + endpoint: `${mosaicsEndpoint}?pageNumber=1&pageSize=100&order=desc&ownerAddress=${creatorAddress}`, + mosaicIds: Object.keys(mosaicInfos), + mosaicInfos: Object.values(mosaicInfos) + } + }, + { + description: 'forwards the search criteria to the mosaics search url', + config: { + mosaicsResponse: mosaicInfosResponse, + mosaicNames, + searchCriteria: { pageNumber: 2, pageSize: 10, order: 'asc' } + }, + expected: { + endpoint: `${mosaicsEndpoint}?pageNumber=2&pageSize=10&order=asc&ownerAddress=${creatorAddress}`, + mosaicIds: Object.keys(mosaicInfos), + mosaicInfos: Object.values(mosaicInfos) + } + }, + { + description: 'returns an empty list when the account has not created any mosaic', + config: { + mosaicsResponse: [], + mosaicNames: {} + }, + expected: { + endpoint: `${mosaicsEndpoint}?pageNumber=1&pageSize=100&order=desc&ownerAddress=${creatorAddress}`, + mosaicIds: [], + mosaicInfos: [] + } + } + ]; + + fetchAccountMosaicsTests.forEach(test => { + runFetchAccountMosaicsTest(test.description, test.config, test.expected); + }); + }); + + describe('fetchMosaicOwners', () => { + const mosaicId = heldMosaic.id; + const accountsEndpoint = `${networkProperties.nodeUrl}/accounts`; + + const runFetchMosaicOwnersTest = (description, config, expected) => { + it(description, async () => { + // Arrange: + config.requestResponses.forEach(response => mockMakeRequest.mockResolvedValueOnce(response)); + + // Act: + const result = await mosaicService.fetchMosaicOwners(networkProperties, mosaicId, config.searchCriteria); + + // Assert: + expect(mockMakeRequest).toHaveBeenCalledTimes(config.requestResponses.length); + expect(mockMakeRequest).toHaveBeenNthCalledWith(1, expected.endpoint); + expect(result).toStrictEqual(expected.mosaicOwners); + }); + }; + + const fetchMosaicOwnersTests = [ + { + description: 'fetches the accounts holding a mosaic with their relative amounts', + config: { + requestResponses: [accountsSearchResponse, findMosaicInfosResponse(mosaicId)] + }, + expected: { + endpoint: `${accountsEndpoint}?pageNumber=1&pageSize=100&order=desc&mosaicId=${mosaicId}`, + mosaicOwners + } + }, + { + description: 'forwards the search criteria to the accounts search url', + config: { + requestResponses: [accountsSearchResponse, findMosaicInfosResponse(mosaicId)], + searchCriteria: { pageNumber: 2, pageSize: 10, order: 'asc' } + }, + expected: { + endpoint: `${accountsEndpoint}?pageNumber=2&pageSize=10&order=asc&mosaicId=${mosaicId}`, + mosaicOwners + } + }, + { + description: 'returns an empty list without fetching the mosaic info when there are no holders', + config: { + requestResponses: [{ data: [] }] + }, + expected: { + endpoint: `${accountsEndpoint}?pageNumber=1&pageSize=100&order=desc&mosaicId=${mosaicId}`, + mosaicOwners: [] + } + } + ]; + + fetchMosaicOwnersTests.forEach(test => { + runFetchMosaicOwnersTest(test.description, test.config, test.expected); + }); + + it('sends the divisibility request as a mosaic infos request for the searched mosaic', async () => { + // Arrange: + mockMakeRequest + .mockResolvedValueOnce(accountsSearchResponse) + .mockResolvedValueOnce(findMosaicInfosResponse(mosaicId)); + const expectedRequestConfig = createMosaicInfosRequestConfig([mosaicId]); // Act: - const result = await mosaicService.fetchMosaicInfos(networkProperties, mosaicIds); + await mosaicService.fetchMosaicOwners(networkProperties, mosaicId); // Assert: - expect(mockMakeRequest).toHaveBeenCalledWith(`${networkProperties.nodeUrl}/mosaics`, expectedRequestConfig); - expect(result).toStrictEqual(expectedResult); + expect(mockMakeRequest).toHaveBeenNthCalledWith(2, mosaicsEndpoint, expectedRequestConfig); }); }); }); diff --git a/wallet/common/symbol/tests/index.test.js b/wallet/common/symbol/tests/index.test.js index cd6a5c8197..0ce9a5c042 100644 --- a/wallet/common/symbol/tests/index.test.js +++ b/wallet/common/symbol/tests/index.test.js @@ -23,5 +23,6 @@ describe('package entry (src/index.js)', () => { expect(typeof entry.TransferModule).toBe('function'); expect(typeof entry.HarvestingModule).toBe('function'); + expect(typeof entry.TokenModule).toBe('function'); }); }); diff --git a/wallet/common/symbol/tests/modules/TokenModule.test.js b/wallet/common/symbol/tests/modules/TokenModule.test.js new file mode 100644 index 0000000000..f7ad859e11 --- /dev/null +++ b/wallet/common/symbol/tests/modules/TokenModule.test.js @@ -0,0 +1,437 @@ +import { + EMPTY_AGGREGATE_HASH, + HASH_LOCK_AMOUNT, + HASH_LOCK_DURATION, + MULTISIG_TRANSACTION_DEADLINE_HOURS, + MosaicSupplyChangeAction, + MosaicSupplyChangeActionMessage, + SINGLE_TRANSACTION_DEADLINE_HOURS, + TransactionBundleType, + TransactionType +} from '../../src/constants'; +import { TokenModule } from '../../src/modules/TokenModule'; +import { + addressFromPublicKey, + calculateTransactionSize, + createDeadline, + createTransactionFee, + createTransactionFeeTiers, + mosaicIdFromNonce +} from '../../src/utils'; +import { mosaicInfos, mosaicOwners } from '../__fixtures__/local/mosaic'; +import { networkProperties } from '../__fixtures__/local/network'; +import { currentAccount, walletStorageAccounts } from '../__fixtures__/local/wallet'; +import { expect, jest } from '@jest/globals'; +import { TransactionBundle, relativeToAbsoluteAmount } from 'wallet-common-core'; + +const multisigAccount = walletStorageAccounts.testnet[1]; +const holderAccount = walletStorageAccounts.testnet[2]; +const token = mosaicInfos['78C3CDF0896248DB']; +const fixedNowMilliseconds = 1_700_000_000_000; + +// A token action is performed on behalf of a multisig account when the sender public key differs from the current account. +const sender = { + currentAccount: { publicKey: currentAccount.publicKey, isMultisig: false }, + multisigAccount: { publicKey: multisigAccount.publicKey, isMultisig: true } +}; + +const defaultFee = createTransactionFee(networkProperties, '0'); +const singleDeadline = () => createDeadline(SINGLE_TRANSACTION_DEADLINE_HOURS, networkProperties.epochAdjustment); +const multisigDeadline = () => createDeadline(MULTISIG_TRANSACTION_DEADLINE_HOURS, networkProperties.epochAdjustment); +const resolveSenderAddress = publicKey => addressFromPublicKey(publicKey, networkProperties.networkIdentifier); + +const withSender = (options, senderContext) => + (senderContext.isMultisig ? { ...options, senderPublicKey: senderContext.publicKey } : options); + +const expectBundlesEqual = (result, expectedResult) => expect(result.toJSON()).toStrictEqual(expectedResult.toJSON()); + +const buildMultisigBundle = (innerTransactions, bundleType) => { + const hashLock = { + type: TransactionType.HASH_LOCK, + signerPublicKey: currentAccount.publicKey, + mosaic: { + id: networkProperties.networkCurrency.mosaicId, + amount: HASH_LOCK_AMOUNT, + divisibility: networkProperties.networkCurrency.divisibility + }, + lockedAmount: HASH_LOCK_AMOUNT, + duration: HASH_LOCK_DURATION, + fee: defaultFee, + deadline: singleDeadline(), + aggregateHash: EMPTY_AGGREGATE_HASH + }; + const aggregateBonded = { + type: TransactionType.AGGREGATE_BONDED, + innerTransactions, + signerPublicKey: currentAccount.publicKey, + signerAddress: currentAccount.address, + fee: defaultFee, + deadline: multisigDeadline() + }; + + return new TransactionBundle([hashLock, aggregateBonded], { type: bundleType }); +}; + +const buildSingleAccountBundle = (transaction, bundleType) => + new TransactionBundle([{ ...transaction, deadline: singleDeadline(), fee: defaultFee }], { type: bundleType }); + +const buildAggregateCompleteBundle = (innerTransactions, signerPublicKey, bundleType) => + new TransactionBundle( + [{ + type: TransactionType.AGGREGATE_COMPLETE, + innerTransactions, + signerPublicKey, + fee: defaultFee, + deadline: singleDeadline() + }], + { type: bundleType } + ); + +// Reads the inner transactions from a result bundle: index 0 for aggregate complete, index 1 for aggregate bonded. +const extractInnerTransactions = (bundle, isMultisig) => bundle.transactions[isMultisig ? 1 : 0].innerTransactions; + +describe('TokenModule', () => { + let tokenModule; + let api; + let walletController; + + beforeEach(() => { + jest.clearAllMocks(); + + api = { + mosaic: { + fetchAccountMosaics: jest.fn(), + fetchMosaicOwners: jest.fn() + } + }; + + walletController = { + currentAccount, + networkProperties, + networkIdentifier: networkProperties.networkIdentifier + }; + + tokenModule = new TokenModule(); + tokenModule.init({ walletController, api }); + + // The transaction deadlines are derived from the current time, which is frozen to keep them predictable. + jest.spyOn(Date, 'now').mockReturnValue(fixedNowMilliseconds); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('has correct static name', () => { + // Assert: + expect(TokenModule.name).toBe('token'); + }); + + describe('createTransaction()', () => { + const createOptions = { + initialSupply: '1000', + divisibility: 2, + duration: 0, + isSupplyMutable: true, + isTransferable: true, + isRestrictable: false, + isRevokable: true + }; + + // The mosaic definition and the initial supply change, both signed by the sender and referencing the derived mosaic id. + const buildExpectedInnerTransactions = (senderPublicKey, nonce) => { + const signerAddress = resolveSenderAddress(senderPublicKey); + const mosaicId = mosaicIdFromNonce(signerAddress, nonce); + + return [ + { + type: TransactionType.MOSAIC_DEFINITION, + signerPublicKey: senderPublicKey, + signerAddress, + mosaicId, + nonce, + divisibility: createOptions.divisibility, + duration: createOptions.duration, + isSupplyMutable: createOptions.isSupplyMutable, + isTransferable: createOptions.isTransferable, + isRestrictable: createOptions.isRestrictable, + isRevokable: createOptions.isRevokable + }, + { + type: TransactionType.MOSAIC_SUPPLY_CHANGE, + signerPublicKey: senderPublicKey, + signerAddress, + mosaicId, + action: MosaicSupplyChangeActionMessage[MosaicSupplyChangeAction.Increase], + delta: relativeToAbsoluteAmount(createOptions.initialSupply, createOptions.divisibility) + } + ]; + }; + + const runCreateTransactionTest = (description, config, expected) => { + it(description, () => { + // Arrange: + const options = withSender({ ...createOptions, nonce: config.nonce }, config.sender); + + // Act: + const result = tokenModule.createTransaction(options); + + // Assert: an omitted nonce is generated internally, so it is read back from the result. + const innerTransactions = extractInnerTransactions(result, config.sender.isMultisig); + const { nonce } = innerTransactions[0]; + expect(nonce).toStrictEqual(expected.nonce ?? expect.any(Number)); + + const expectedInnerTransactions = buildExpectedInnerTransactions(config.sender.publicKey, nonce); + const expectedResult = config.sender.isMultisig + ? buildMultisigBundle(expectedInnerTransactions, TransactionBundleType.MULTISIG_TOKEN_CREATION) + : buildAggregateCompleteBundle( + expectedInnerTransactions, + config.sender.publicKey, + TransactionBundleType.TOKEN_CREATION + ); + expectBundlesEqual(result, expectedResult); + }); + }; + + const createTransactionTests = [ + { + description: 'creates an aggregate complete bundle with definition and initial supply for the current account', + config: { sender: sender.currentAccount }, + expected: {} + }, + { + description: 'creates an aggregate bonded and hash lock bundle for a multisig account', + config: { sender: sender.multisigAccount }, + expected: {} + }, + { + description: 'derives the mosaic id from a given nonce', + config: { sender: sender.currentAccount, nonce: 1234567 }, + expected: { nonce: 1234567 } + } + ]; + + createTransactionTests.forEach(test => { + runCreateTransactionTest(test.description, test.config, test.expected); + }); + }); + + describe('createSupplyChangeTransaction()', () => { + const supplyChangeOptions = { + mosaicId: token.id, + divisibility: token.divisibility, + delta: '5' + }; + + const runSupplyChangeTest = (description, config, expected) => { + it(description, () => { + // Arrange: + const options = withSender({ ...supplyChangeOptions, action: config.action }, config.sender); + + // Act: + const result = tokenModule.createSupplyChangeTransaction(options); + + // Assert: + const expectedTransaction = { + type: TransactionType.MOSAIC_SUPPLY_CHANGE, + signerPublicKey: config.sender.publicKey, + signerAddress: resolveSenderAddress(config.sender.publicKey), + mosaicId: supplyChangeOptions.mosaicId, + action: expected.action, + delta: relativeToAbsoluteAmount(supplyChangeOptions.delta, supplyChangeOptions.divisibility) + }; + const expectedResult = config.sender.isMultisig + ? buildMultisigBundle([expectedTransaction], TransactionBundleType.MULTISIG_TOKEN_SUPPLY_CHANGE) + : buildSingleAccountBundle(expectedTransaction, TransactionBundleType.TOKEN_SUPPLY_CHANGE); + expectBundlesEqual(result, expectedResult); + }); + }; + + const supplyChangeTests = [ + { + description: 'creates a supply increase transaction for the current account', + config: { sender: sender.currentAccount, action: MosaicSupplyChangeAction.Increase }, + expected: { action: MosaicSupplyChangeActionMessage[MosaicSupplyChangeAction.Increase] } + }, + { + description: 'creates a supply decrease transaction for the current account', + config: { sender: sender.currentAccount, action: MosaicSupplyChangeAction.Decrease }, + expected: { action: MosaicSupplyChangeActionMessage[MosaicSupplyChangeAction.Decrease] } + }, + { + description: 'creates an aggregate bonded and hash lock bundle for a multisig account', + config: { sender: sender.multisigAccount, action: MosaicSupplyChangeAction.Decrease }, + expected: { action: MosaicSupplyChangeActionMessage[MosaicSupplyChangeAction.Decrease] } + } + ]; + + supplyChangeTests.forEach(test => { + runSupplyChangeTest(test.description, test.config, test.expected); + }); + }); + + describe('createRevocationTransaction()', () => { + const revocationOptions = { + mosaicId: token.id, + divisibility: token.divisibility, + amount: '2.5', + sourceAddress: holderAccount.address + }; + + const runRevocationTest = (description, config) => { + it(description, () => { + // Act: + const result = tokenModule.createRevocationTransaction(withSender(revocationOptions, config.sender)); + + // Assert: + const expectedTransaction = { + type: TransactionType.MOSAIC_SUPPLY_REVOCATION, + signerPublicKey: config.sender.publicKey, + signerAddress: resolveSenderAddress(config.sender.publicKey), + mosaic: { + id: revocationOptions.mosaicId, + amount: revocationOptions.amount, + divisibility: revocationOptions.divisibility + }, + sourceAddress: revocationOptions.sourceAddress + }; + const expectedResult = config.sender.isMultisig + ? buildMultisigBundle([expectedTransaction], TransactionBundleType.MULTISIG_TOKEN_REVOCATION) + : buildSingleAccountBundle(expectedTransaction, TransactionBundleType.TOKEN_REVOCATION); + expectBundlesEqual(result, expectedResult); + }); + }; + + const revocationTests = [ + { + description: 'creates a revocation transaction for the current account', + config: { sender: sender.currentAccount } + }, + { + description: 'creates an aggregate bonded and hash lock bundle for a multisig account', + config: { sender: sender.multisigAccount } + } + ]; + + revocationTests.forEach(test => { + runRevocationTest(test.description, test.config); + }); + }); + + describe('fetchAccountTokens()', () => { + const runFetchAccountTokensTest = (description, config, expected) => { + it(description, async () => { + // Arrange: + const accountTokens = Object.values(mosaicInfos); + api.mosaic.fetchAccountMosaics.mockResolvedValue(accountTokens); + + // Act: + const result = await tokenModule.fetchAccountTokens(config.address, config.searchCriteria); + + // Assert: + expect(api.mosaic.fetchAccountMosaics).toHaveBeenCalledWith( + networkProperties, + expected.address, + config.searchCriteria + ); + expect(result).toBe(accountTokens); + }); + }; + + const fetchAccountTokensTests = [ + { + description: 'fetches the tokens created by the current account by default', + config: {}, + expected: { address: currentAccount.address } + }, + { + description: 'fetches the tokens created by a given account with search criteria', + config: { address: holderAccount.address, searchCriteria: { pageNumber: 2, pageSize: 10 } }, + expected: { address: holderAccount.address } + } + ]; + + fetchAccountTokensTests.forEach(test => { + runFetchAccountTokensTest(test.description, test.config, test.expected); + }); + }); + + describe('fetchMosaicOwners()', () => { + const runFetchMosaicOwnersTest = (description, config) => { + it(description, async () => { + // Arrange: + api.mosaic.fetchMosaicOwners.mockResolvedValue(mosaicOwners); + + // Act: + const result = await tokenModule.fetchMosaicOwners(token.id, config.searchCriteria); + + // Assert: + expect(api.mosaic.fetchMosaicOwners).toHaveBeenCalledWith(networkProperties, token.id, config.searchCriteria); + expect(result).toBe(mosaicOwners); + }); + }; + + const fetchMosaicOwnersTests = [ + { + description: 'fetches the accounts holding a token', + config: {} + }, + { + description: 'forwards the search criteria to the api layer', + config: { searchCriteria: { pageNumber: 2, pageSize: 10 } } + } + ]; + + fetchMosaicOwnersTests.forEach(test => { + runFetchMosaicOwnersTest(test.description, test.config); + }); + }); + + describe('calculateTransactionFees()', () => { + const supplyChangeOptions = { + mosaicId: token.id, + divisibility: token.divisibility, + delta: '5', + action: MosaicSupplyChangeAction.Decrease + }; + + const createSupplyChangeBundle = senderContext => + tokenModule.createSupplyChangeTransaction(withSender(supplyChangeOptions, senderContext)); + + const runCalculateTransactionFeesTest = (description, config, expected) => { + it(description, async () => { + // Arrange: + const bundle = createSupplyChangeBundle(config.sender); + + // Act: + const result = await tokenModule.calculateTransactionFees(bundle); + + // Assert: + const expectedResult = bundle.transactions.map(transaction => + createTransactionFeeTiers( + networkProperties, + calculateTransactionSize(networkProperties.networkIdentifier, transaction) + )); + expect(result).toHaveLength(expected.transactionCount); + expect(result).toStrictEqual(expectedResult); + }); + }; + + const calculateTransactionFeesTests = [ + { + description: 'returns a fee tier entry for a single transaction bundle', + config: { sender: sender.currentAccount }, + expected: { transactionCount: 1 } + }, + { + description: 'returns a fee tier entry for each transaction of a multisig bundle', + config: { sender: sender.multisigAccount }, + expected: { transactionCount: 2 } + } + ]; + + calculateTransactionFeesTests.forEach(test => { + runCalculateTransactionFeesTest(test.description, test.config, test.expected); + }); + }); +}); diff --git a/wallet/common/symbol/tests/modules/index.test.js b/wallet/common/symbol/tests/modules/index.test.js index 620a252555..5923b6a71b 100644 --- a/wallet/common/symbol/tests/modules/index.test.js +++ b/wallet/common/symbol/tests/modules/index.test.js @@ -6,19 +6,22 @@ jest.unstable_mockModule('lodash', () => ({ default: { shuffle: mockShuffle } })); -const { TransferModule, HarvestingModule } = await import('../../src/modules'); +const { TransferModule, HarvestingModule, TokenModule } = await import('../../src/modules'); describe('modules/index.js re-exports', () => { - it('re-exports TransferModule and HarvestingModule', () => { + it('re-exports TransferModule, HarvestingModule and TokenModule', () => { // Assert: expect(typeof TransferModule).toBe('function'); expect(typeof HarvestingModule).toBe('function'); + expect(typeof TokenModule).toBe('function'); // Instances can be created without args and then initialized const transfer = new TransferModule(); const harvesting = new HarvestingModule(); + const token = new TokenModule(); expect(transfer).toBeInstanceOf(TransferModule); expect(harvesting).toBeInstanceOf(HarvestingModule); + expect(token).toBeInstanceOf(TokenModule); }); }); diff --git a/wallet/common/symbol/tests/utils/mosaic.test.js b/wallet/common/symbol/tests/utils/mosaic.test.js index 8347d10f84..dc465fa41a 100644 --- a/wallet/common/symbol/tests/utils/mosaic.test.js +++ b/wallet/common/symbol/tests/utils/mosaic.test.js @@ -5,8 +5,12 @@ import { isRestrictableFlag, isRevokableFlag, isSupplyMutableFlag, - isTransferableFlag + isTransferableFlag, + mosaicIdFromNonce, + mosaicInfoFromDTO } from '../../src/utils'; +import { mosaicInfosResponse } from '../__fixtures__/api/mosaic-infos-response'; +import { mosaicInfos } from '../__fixtures__/local/mosaic'; import { generateBitCombinations } from '../test-utils'; const SUPPLY_MUTABLE_FLAG = 1; @@ -380,4 +384,37 @@ describe('utils/mosaic', () => { runMosaicFlagsTest(flags, expectedResult, isRevokableFlag); }); }); + + describe('mosaicIdFromNonce', () => { + const ownerAddress = 'TAWGTICRU4V7XYY25WTSKCWGY5D3OVYLH2OABNQ'; + const testCases = [ + { description: 'derives the mosaic id from the owner address and nonce', nonce: 12345, expectedMosaicId: '619284EB8A8505DA' }, + { description: 'derives the mosaic id for a zero nonce', nonce: 0, expectedMosaicId: '64CC999288ED1BB9' } + ]; + + testCases.forEach(({ description, nonce, expectedMosaicId }) => it(description, () => { + // Act: + const result = mosaicIdFromNonce(ownerAddress, nonce); + + // Assert: + expect(result).toBe(expectedMosaicId); + })); + }); + + describe('mosaicInfoFromDTO', () => { + it('formats a mosaic node DTO into mosaic info with empty names', () => { + // Arrange: + const mosaicDTO = mosaicInfosResponse[0].mosaic; + const expectedResult = { + ...mosaicInfos[mosaicDTO.id], + names: [] + }; + + // Act: + const result = mosaicInfoFromDTO(mosaicDTO); + + // Assert: + expect(result).toStrictEqual(expectedResult); + }); + }); }); diff --git a/wallet/symbol/mobile/__tests__/Router.test.js b/wallet/symbol/mobile/__tests__/Router.test.js index 9e545d7ea9..048ad8e289 100644 --- a/wallet/symbol/mobile/__tests__/Router.test.js +++ b/wallet/symbol/mobile/__tests__/Router.test.js @@ -88,6 +88,9 @@ jest.mock('@/app/screens', () => { CreateMultisigAccount: createMockScreen('CreateMultisigAccount'), ModifyMultisigAccount: createMockScreen('ModifyMultisigAccount'), Harvesting: createMockScreen('Harvesting'), + CreateMosaic: createMockScreen('CreateMosaic'), + ModifyMosaic: createMockScreen('ModifyMosaic'), + RevokeMosaic: createMockScreen('RevokeMosaic'), Scan: createMockScreen('Scan'), TransportRequest: createMockScreen('TransportRequest'), Send: createMockScreen('Send'), @@ -222,6 +225,16 @@ const NAVIGATION_SCREENS_CONFIG = [ screenName: 'Harvesting', shouldReset: false, hasParams: true + }, + { + screenName: 'CreateMosaic', + shouldReset: false, + hasParams: true + }, + { + screenName: 'ModifyMosaic', + shouldReset: false, + hasParams: true } ]; diff --git a/wallet/symbol/mobile/__tests__/ScreenTester.js b/wallet/symbol/mobile/__tests__/ScreenTester.js index d3f62e146a..bf2696b33b 100644 --- a/wallet/symbol/mobile/__tests__/ScreenTester.js +++ b/wallet/symbol/mobile/__tests__/ScreenTester.js @@ -91,7 +91,7 @@ export class ScreenTester { const { getByLabelText} = this.renderer; const element = getByLabelText(label); - expect(element.props.accessibilityValue).toBe(expectedValue); + expect(element.props.accessibilityValue).toEqual(expectedValue); }; /** diff --git a/wallet/symbol/mobile/__tests__/components/controls/SelectTransactionSender.test.js b/wallet/symbol/mobile/__tests__/components/controls/SelectTransactionSender.test.js index dd8629a0b6..5a50750d2f 100644 --- a/wallet/symbol/mobile/__tests__/components/controls/SelectTransactionSender.test.js +++ b/wallet/symbol/mobile/__tests__/components/controls/SelectTransactionSender.test.js @@ -107,6 +107,22 @@ describe('components/controls/SelectTransactionSender', () => { currentAccount.address ]); }); + + it('renders only the current account when multisig is disabled', async () => { + // Arrange: + const props = createProps({ + multisigAccounts: MULTISIG_ACCOUNTS, + isMultisigDisabled: true + }); + + // Act: + const screenTester = new ScreenTester(SelectTransactionSender, props); + await screenTester.waitForTimer(); + + // Assert: + screenTester.expectText([currentAccount.name, currentAccount.address]); + screenTester.notExpectText([SCREEN_TEXT.tabCurrentAccount, SCREEN_TEXT.tabMultisigAccount]); + }); }); describe('sender selection', () => { diff --git a/wallet/symbol/mobile/__tests__/screens/assets/TokenDetails.test.js b/wallet/symbol/mobile/__tests__/screens/assets/TokenDetails.test.js index c4ee9d0a38..11be86de97 100644 --- a/wallet/symbol/mobile/__tests__/screens/assets/TokenDetails.test.js +++ b/wallet/symbol/mobile/__tests__/screens/assets/TokenDetails.test.js @@ -58,7 +58,9 @@ const SCREEN_TEXT = { textExpireIn: 's_assets_item_expireIn', // Buttons - buttonSend: 'button_send' + buttonSend: 'button_send', + buttonRevoke: 'button_revoke', + buttonModify: 'button_modifyMosaic' }; // Account Fixtures @@ -107,6 +109,24 @@ const tokenOwnedByExternalAccount = TokenFixtureBuilder .override({ supply: TOKEN_SUPPLY }) .build(); +// Tokens the creator is allowed to revoke and to change the supply of +const createManageableToken = creator => TokenFixtureBuilder + .createWithToken(CHAIN_NAME, NETWORK_IDENTIFIER, 1) + .setId(TOKEN_ID) + .setAmount(TOKEN_AMOUNT) + .setDivisibility(TOKEN_DIVISIBILITY) + .setCreator(creator) + .setIsUnlimitedDuration(true) + .override({ + supply: TOKEN_SUPPLY, + isRevokable: true, + isSupplyMutable: true + }) + .build(); + +const manageableTokenOwnedByCurrentAccount = createManageableToken(currentAccount.address); +const manageableTokenOwnedByExternalAccount = createManageableToken(externalAccount.address); + const tokenWithoutCreator = TokenFixtureBuilder .createWithToken(CHAIN_NAME, NETWORK_IDENTIFIER, 1) .setId(TOKEN_ID) @@ -141,12 +161,12 @@ const createAccountInfoWithToken = token => AccountInfoFixtureBuilder // Route Props Factory -const createRouteProps = (tokenId, preloadedData) => ({ +const createRouteProps = (tokenId, preloadedData, accountAddress = currentAccount.address) => ({ route: { params: { chainName: CHAIN_NAME, tokenId, - accountAddress: currentAccount.address, + accountAddress, preloadedData } } @@ -428,4 +448,98 @@ describe('screens/assets/TokenDetails', () => { runSendButtonTest(test.description, test.config, test.expected); }); }); + + describe('creator actions', () => { + const runCreatorActionsTest = (description, config, expected) => { + it(description, async () => { + // Arrange: + const accountInfo = createAccountInfoWithToken(config.token); + mockWalletController(createWalletControllerConfig(accountInfo, networkPropertiesActive)); + + // Act: + const screenTester = new ScreenTester( + TokenDetails, + createRouteProps(TOKEN_ID, config.token, config.accountAddress) + ); + + // Assert: + if (expected.areActionsRendered) + screenTester.expectText([SCREEN_TEXT.buttonRevoke, SCREEN_TEXT.buttonModify]); + else + screenTester.notExpectText([SCREEN_TEXT.buttonRevoke, SCREEN_TEXT.buttonModify]); + }); + }; + + const creatorActionsTests = [ + { + description: 'renders the revoke and modify actions for a token created by the current account', + config: { + token: manageableTokenOwnedByCurrentAccount, + accountAddress: currentAccount.address + }, + expected: { areActionsRendered: true } + }, + { + // Only the current account can sign the mosaic management transactions + description: 'hides the revoke and modify actions when the token is viewed on behalf of another account', + config: { + token: manageableTokenOwnedByExternalAccount, + accountAddress: externalAccount.address + }, + expected: { areActionsRendered: false } + }, + { + description: 'hides the revoke and modify actions for a token created by another account', + config: { + token: manageableTokenOwnedByExternalAccount, + accountAddress: currentAccount.address + }, + expected: { areActionsRendered: false } + } + ]; + + creatorActionsTests.forEach(test => { + runCreatorActionsTest(test.description, test.config, test.expected); + }); + }); + + describe('creator action navigation', () => { + const runNavigationTest = (description, config, expected) => { + it(description, async () => { + // Arrange: + const accountInfo = createAccountInfoWithToken(manageableTokenOwnedByCurrentAccount); + mockWalletController(createWalletControllerConfig(accountInfo, networkPropertiesActive)); + const routerMock = mockRouter({ goToRevokeMosaic: jest.fn(), goToModifyMosaic: jest.fn() }); + + // Act: + const screenTester = new ScreenTester( + TokenDetails, + createRouteProps(TOKEN_ID, manageableTokenOwnedByCurrentAccount) + ); + screenTester.pressButton(config.button); + + // Assert: + expect(routerMock[expected.routerMethod]).toHaveBeenCalledWith({ + params: { chainName: CHAIN_NAME, tokenId: TOKEN_ID } + }); + }); + }; + + const navigationTests = [ + { + description: 'opens the revoke screen for the token without a pre-selected creator', + config: { button: SCREEN_TEXT.buttonRevoke }, + expected: { routerMethod: 'goToRevokeMosaic' } + }, + { + description: 'opens the modify screen for the token without a pre-selected creator', + config: { button: SCREEN_TEXT.buttonModify }, + expected: { routerMethod: 'goToModifyMosaic' } + } + ]; + + navigationTests.forEach(test => { + runNavigationTest(test.description, test.config, test.expected); + }); + }); }); diff --git a/wallet/symbol/mobile/__tests__/screens/mosaic/CreateMosaic.test.js b/wallet/symbol/mobile/__tests__/screens/mosaic/CreateMosaic.test.js new file mode 100644 index 0000000000..c85e2b6f35 --- /dev/null +++ b/wallet/symbol/mobile/__tests__/screens/mosaic/CreateMosaic.test.js @@ -0,0 +1,530 @@ +import { CreateMosaic } from '@/app/screens/mosaic/CreateMosaic'; +import { DEFAULT_MOSAIC_DIVISIBILITY, MOSAIC_NEVER_EXPIRING_DURATION } from '@/app/screens/mosaic/constants'; +import { AccountFixtureBuilder } from '__fixtures__/local/AccountFixtureBuilder'; +import { NetworkPropertiesFixtureBuilder } from '__fixtures__/local/NetworkPropertiesFixtureBuilder'; +import { TransactionFeeFixtureBuilder } from '__fixtures__/local/TransactionFeeFixtureBuilder'; +import { ScreenTester } from '__tests__/ScreenTester'; +import { createAddressBookMock, mockLocalization, mockPasscode, mockRouter, mockWalletController } from '__tests__/mock-helpers'; + +// Constants + +const CHAIN_NAME = 'symbol'; +const NETWORK_IDENTIFIER = 'testnet'; +const TICKER = 'XYM'; + +const MOSAIC_ID = '78C3CDF0896248DB'; +const VALID_SUPPLY = '100'; + +// Screen Text + +const SCREEN_TEXT = { + // Section titles and descriptions + textMosaicTitle: 's_mosaicCreation_mosaic_title', + textMosaicDescription: 's_mosaicCreation_mosaic_description', + textCreatorTitle: 's_mosaicCreation_sender_title', + textQuantityTitle: 's_mosaicCreation_quantity_title', + textQuantityDescription: 's_mosaicCreation_quantity_description', + textDurationTitle: 's_mosaicCreation_duration_title', + textDurationDescription: 's_mosaicCreation_duration_description', + textFlagsTitle: 's_mosaicCreation_flags_title', + + // Flag section titles and descriptions + textSupplyMutableTitle: 's_mosaicCreation_supplyMutable_title', + textSupplyMutableDescription: 's_mosaicCreation_supplyMutable_description', + textTransferableTitle: 's_mosaicCreation_transferable_title', + textTransferableDescription: 's_mosaicCreation_transferable_description', + textRestrictableTitle: 's_mosaicCreation_restrictable_title', + textRestrictableDescription: 's_mosaicCreation_restrictable_description', + textRevokableTitle: 's_mosaicCreation_revokable_title', + textRevokableDescription: 's_mosaicCreation_revokable_description', + + // Duration / expiration summary + textExpirationPermanent: 's_mosaicCreation_expiration_permanent', + textDurationBlocksChip: 's_mosaicCreation_durationUnit_blocksChip', + textSmallestSendWhole: 's_mosaicCreation_smallestSend_whole', + + // Buttons + buttonSend: 'button_send', + buttonConfirm: 'button_confirm', + + // Checkboxes + checkboxExpires: 's_mosaicCreation_duration_expiresCheckbox', + checkboxSupplyMutable: 's_mosaicCreation_supplyMutable_checkbox', + checkboxTransferable: 's_mosaicCreation_transferable_checkbox', + checkboxRestrictable: 's_mosaicCreation_restrictable_checkbox', + checkboxRevokable: 's_mosaicCreation_revokable_checkbox', + + // Input labels (accessibility) + inputTotalSupplyLabel: 's_mosaicCreation_totalSupply_label', + inputDurationBlocksLabel: 's_mosaicCreation_duration_blocksInputLabel', + + // Confirmation dialog + textConfirmDialogTitle: 's_mosaicCreation_confirm_title', + + // Validation errors + errorFieldRequired: 'validation_error_field_required', + errorSupplyLow: 'validation_error_mosaic_supply_low', + errorSupplyHigh: 'validation_error_mosaic_supply_high', + errorSupplyWhole: 'validation_error_mosaic_supply_whole', + errorSupplyDecimals: 'validation_error_mosaic_supply_decimals', + errorDurationLow: 'validation_error_mosaic_duration_low', + errorDurationHigh: 'validation_error_mosaic_duration_high' +}; + +// Grouped error texts used to assert the absence of validation errors on valid input + +const SUPPLY_ERROR_TEXTS = [ + SCREEN_TEXT.errorFieldRequired, + SCREEN_TEXT.errorSupplyLow, + SCREEN_TEXT.errorSupplyHigh, + SCREEN_TEXT.errorSupplyWhole, + SCREEN_TEXT.errorSupplyDecimals +]; + +const DURATION_ERROR_TEXTS = [ + SCREEN_TEXT.errorFieldRequired, + SCREEN_TEXT.errorDurationLow, + SCREEN_TEXT.errorDurationHigh +]; + +// The mosaic flag checkbox each flag is toggled with + +const flagCheckboxLabels = { + isSupplyMutable: SCREEN_TEXT.checkboxSupplyMutable, + isTransferable: SCREEN_TEXT.checkboxTransferable, + isRestrictable: SCREEN_TEXT.checkboxRestrictable, + isRevokable: SCREEN_TEXT.checkboxRevokable +}; + +// Account Fixtures + +const currentAccount = AccountFixtureBuilder + .createWithAccount(CHAIN_NAME, NETWORK_IDENTIFIER, 0) + .build(); + +const walletAccounts = [currentAccount]; + +// Network Properties Fixtures + +const symbolNetworkProperties = NetworkPropertiesFixtureBuilder + .createWithType(CHAIN_NAME, NETWORK_IDENTIFIER) + .build(); + +// Transaction Fee Fixtures + +const transactionFees = TransactionFeeFixtureBuilder + .createWithAmounts('0.1', '0.2', '0.3', CHAIN_NAME, NETWORK_IDENTIFIER) + .build(); + +// Mock Mosaic Creation Transaction Bundle (returned by the token module) + +const mosaicDefinitionTransaction = { + type: 'mosaicDefinition', + signerAddress: currentAccount.address, + mosaicId: MOSAIC_ID, + divisibility: 0, + duration: MOSAIC_NEVER_EXPIRING_DURATION, + isSupplyMutable: true, + isTransferable: true, + isRestrictable: false, + isRevokable: false +}; + +const mosaicSupplyChangeTransaction = { + type: 'mosaicSupplyChange', + delta: '100' +}; + +const mosaicAggregateTransaction = { + type: 'aggregateComplete', + innerTransactions: [mosaicDefinitionTransaction, mosaicSupplyChangeTransaction], + fee: { token: { amount: '0.1' } } +}; + +const mosaicTransactionBundle = { + transactions: [mosaicAggregateTransaction], + applyFeeTier: jest.fn() +}; + +// Signed Transaction Bundle Fixtures + +const signedTransactionBundle = { + transactions: [{ hash: 'SIGNED_TX_HASH' }] +}; + +// Module Mock Factories + +const createTokenModuleMock = () => ({ + createTransaction: jest.fn().mockReturnValue(mosaicTransactionBundle) +}); + +const createTransferModuleMock = () => ({ + calculateTransactionFees: jest.fn().mockResolvedValue(transactionFees) +}); + +// Setup + +const setupMocks = () => { + mockLocalization(); + + const walletControllerMock = mockWalletController({ + chainName: CHAIN_NAME, + networkIdentifier: NETWORK_IDENTIFIER, + ticker: TICKER, + networkProperties: symbolNetworkProperties, + currentAccount, + accounts: { + [NETWORK_IDENTIFIER]: walletAccounts + }, + signTransactionBundle: jest.fn().mockResolvedValue(signedTransactionBundle), + announceSignedTransactionBundle: jest.fn().mockResolvedValue({}), + modules: { + token: createTokenModuleMock(), + transfer: createTransferModuleMock(), + addressBook: createAddressBookMock([]) + } + }); + + return { walletControllerMock }; +}; + +// Renders the screen and waits for the initial sender options to load + +const renderCreateMosaicScreen = async () => { + const screenTester = new ScreenTester(CreateMosaic); + await screenTester.waitForTimer(); // sender options load + + return screenTester; +}; + +// Fills the mosaic form (divisibility, supply, duration and flags) from a form config + +const fillMosaicForm = (screenTester, config) => { + if (config.divisibility !== DEFAULT_MOSAIC_DIVISIBILITY) + screenTester.pressButton(config.divisibility); + + screenTester.inputText(SCREEN_TEXT.inputTotalSupplyLabel, config.supply); + + if (config.isExpiring) { + screenTester.pressButton(SCREEN_TEXT.checkboxExpires); + screenTester.inputText(SCREEN_TEXT.inputDurationBlocksLabel, config.duration); + } + + (config.flagsToToggle ?? []).forEach(flagName => screenTester.pressButton(flagCheckboxLabels[flagName])); +}; + +describe('screens/mosaic/CreateMosaic', () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.clearAllTimers(); + jest.useRealTimers(); + }); + + describe('render', () => { + it('renders section titles and descriptions with the send button', async () => { + // Arrange: + setupMocks(); + const expectedTexts = [ + SCREEN_TEXT.textMosaicTitle, + SCREEN_TEXT.textMosaicDescription, + SCREEN_TEXT.textCreatorTitle, + SCREEN_TEXT.textQuantityTitle, + SCREEN_TEXT.textQuantityDescription, + SCREEN_TEXT.textDurationTitle, + SCREEN_TEXT.textDurationDescription, + SCREEN_TEXT.textFlagsTitle, + SCREEN_TEXT.textSupplyMutableTitle, + SCREEN_TEXT.textSupplyMutableDescription, + SCREEN_TEXT.textTransferableTitle, + SCREEN_TEXT.textTransferableDescription, + SCREEN_TEXT.textRestrictableTitle, + SCREEN_TEXT.textRestrictableDescription, + SCREEN_TEXT.textRevokableTitle, + SCREEN_TEXT.textRevokableDescription, + SCREEN_TEXT.buttonSend + ]; + + // Act: + const screenTester = await renderCreateMosaicScreen(); + + // Assert: + screenTester.expectText(expectedTexts); + }); + }); + + describe('quantity', () => { + const runQuantityTest = (description, config, expected) => { + it(description, async () => { + // Arrange: + setupMocks(); + const screenTester = await renderCreateMosaicScreen(); + + // Act: + if (config.divisibility !== DEFAULT_MOSAIC_DIVISIBILITY) + screenTester.pressButton(config.divisibility); + + screenTester.inputText(SCREEN_TEXT.inputTotalSupplyLabel, config.supply); + await screenTester.waitForTimer(); // fee calculation + + // Assert: + if (expected.errorText) + screenTester.expectText([expected.errorText]); + else + screenTester.notExpectText(SUPPLY_ERROR_TEXTS); + + if (expected.visibleText) + screenTester.expectText(expected.visibleText); + }); + }; + + const quantityTests = [ + { + description: 'renders no error and the whole-token hint for a valid whole supply', + config: { divisibility: '0', supply: '100' }, + expected: { errorText: null, visibleText: [SCREEN_TEXT.textSmallestSendWhole] } + }, + { + description: 'renders the required error for an empty supply', + config: { divisibility: '0', supply: '' }, + expected: { errorText: SCREEN_TEXT.errorFieldRequired } + }, + { + description: 'renders the low error when the supply is below one atomic unit', + config: { divisibility: '0', supply: '0' }, + expected: { errorText: SCREEN_TEXT.errorSupplyLow } + }, + { + description: 'renders the whole-number error when a zero-divisibility supply has decimals', + config: { divisibility: '0', supply: '1.5' }, + expected: { errorText: SCREEN_TEXT.errorSupplyWhole } + }, + { + description: 'renders the high error when the supply exceeds the maximum', + config: { divisibility: '0', supply: '9000000000000001' }, + expected: { errorText: SCREEN_TEXT.errorSupplyHigh } + }, + { + description: 'renders no error and the smallest-fraction hint for a valid fractional supply', + config: { divisibility: '3', supply: '1.5' }, + expected: { errorText: null, visibleText: ['0.001'] } + }, + { + description: 'renders the decimals error when the supply has more decimals than the divisibility', + config: { divisibility: '3', supply: '1.2345' }, + expected: { errorText: SCREEN_TEXT.errorSupplyDecimals } + } + ]; + + quantityTests.forEach(test => { + runQuantityTest(test.description, test.config, test.expected); + }); + }); + + describe('duration', () => { + describe('expiry checkbox', () => { + const runExpiryCheckboxTest = (description, config, expected) => { + it(description, async () => { + // Arrange: + setupMocks(); + const screenTester = await renderCreateMosaicScreen(); + + // Act: + for (let press = 0; press < config.pressCount; press++) + screenTester.pressButton(SCREEN_TEXT.checkboxExpires); + + // Assert: + screenTester.expectText(expected.visibleText); + screenTester.notExpectText(expected.hiddenText); + screenTester.expectAccessibilityValue(SCREEN_TEXT.checkboxExpires, { text: expected.checkboxState }); + }); + }; + + const expiryCheckboxTests = [ + { + description: 'shows the permanent note and hides the duration input by default', + config: { pressCount: 0 }, + expected: { + visibleText: [SCREEN_TEXT.textExpirationPermanent], + hiddenText: [SCREEN_TEXT.textDurationBlocksChip, SCREEN_TEXT.inputDurationBlocksLabel], + checkboxState: 'unchecked' + } + }, + { + description: 'reveals the duration input and hides the permanent note when expiring is enabled', + config: { pressCount: 1 }, + expected: { + visibleText: [SCREEN_TEXT.textDurationBlocksChip, SCREEN_TEXT.inputDurationBlocksLabel], + hiddenText: [SCREEN_TEXT.textExpirationPermanent], + checkboxState: 'checked' + } + }, + { + description: 'restores the permanent note when expiring is toggled off again', + config: { pressCount: 2 }, + expected: { + visibleText: [SCREEN_TEXT.textExpirationPermanent], + hiddenText: [SCREEN_TEXT.textDurationBlocksChip, SCREEN_TEXT.inputDurationBlocksLabel], + checkboxState: 'unchecked' + } + } + ]; + + expiryCheckboxTests.forEach(test => { + runExpiryCheckboxTest(test.description, test.config, test.expected); + }); + }); + + describe('duration input', () => { + const runDurationInputTest = (description, config, expected) => { + it(description, async () => { + // Arrange: + setupMocks(); + const screenTester = await renderCreateMosaicScreen(); + screenTester.inputText(SCREEN_TEXT.inputTotalSupplyLabel, VALID_SUPPLY); // isolate the duration validation + screenTester.pressButton(SCREEN_TEXT.checkboxExpires); // reveal the duration input (prefills one year) + + // Act: + screenTester.inputText(SCREEN_TEXT.inputDurationBlocksLabel, config.duration); + await screenTester.waitForTimer(); // fee calculation + + // Assert: + if (expected.errorText) + screenTester.expectText([expected.errorText]); + else + screenTester.notExpectText(DURATION_ERROR_TEXTS); + }); + }; + + const durationInputTests = [ + { + description: 'renders no error for a valid duration', + config: { duration: '1000' }, + expected: { errorText: null } + }, + { + description: 'renders the required error for an empty duration', + config: { duration: '' }, + expected: { errorText: SCREEN_TEXT.errorFieldRequired } + }, + { + description: 'renders the low error for a duration below the minimum', + config: { duration: '0' }, + expected: { errorText: SCREEN_TEXT.errorDurationLow } + }, + { + description: 'renders the high error for a duration above the maximum', + config: { duration: '99999999' }, + expected: { errorText: SCREEN_TEXT.errorDurationHigh } + } + ]; + + durationInputTests.forEach(test => { + runDurationInputTest(test.description, test.config, test.expected); + }); + }); + }); + + describe('send transaction flow', () => { + const runSendTransactionTest = (description, config, expected) => { + it(description, async () => { + // Arrange: + const { walletControllerMock } = setupMocks(); + mockPasscode(); + mockRouter({ goToHome: jest.fn() }); + const screenTester = await renderCreateMosaicScreen(); + + // Act: + fillMosaicForm(screenTester, config); + await screenTester.waitForTimer(); // fee calculation + + screenTester.pressButton(SCREEN_TEXT.buttonSend); + await screenTester.waitForTimer(); // create transaction, open confirmation dialog + screenTester.expectText([SCREEN_TEXT.textConfirmDialogTitle]); + + screenTester.pressButton(SCREEN_TEXT.buttonConfirm); + await screenTester.waitForTimer(); // passcode success, send execution delay + await screenTester.waitForTimer(); // sign transaction + await screenTester.waitForTimer(); // announce transaction + + // Assert: + expect(walletControllerMock.modules.token.createTransaction) + .toHaveBeenCalledWith(expect.objectContaining(expected.transactionOptions)); + expect(walletControllerMock.signTransactionBundle).toHaveBeenCalledWith(mosaicTransactionBundle); + expect(walletControllerMock.announceSignedTransactionBundle).toHaveBeenCalledWith(signedTransactionBundle); + }); + }; + + const sendTransactionTests = [ + { + description: 'creates and sends an unlimited-duration mosaic with the default flags', + config: { + divisibility: '0', + supply: '100', + isExpiring: false, + flagsToToggle: [] + }, + expected: { + transactionOptions: { + initialSupply: '100', + divisibility: 0, + duration: MOSAIC_NEVER_EXPIRING_DURATION, + isSupplyMutable: false, + isTransferable: true, + isRestrictable: false, + isRevokable: false + } + } + }, + { + description: 'creates and sends a limited-duration mosaic with restrictable and revokable enabled', + config: { + divisibility: '3', + supply: '500', + isExpiring: true, + duration: '1000', + flagsToToggle: ['isRestrictable', 'isRevokable'] + }, + expected: { + transactionOptions: { + initialSupply: '500', + divisibility: 3, + duration: 1000, + isSupplyMutable: false, + isTransferable: true, + isRestrictable: true, + isRevokable: true + } + } + }, + { + description: 'creates and sends a limited-duration mosaic with supply mutable enabled and transferable disabled', + config: { + divisibility: '6', + supply: '1', + isExpiring: true, + duration: '5000', + flagsToToggle: ['isSupplyMutable', 'isTransferable'] + }, + expected: { + transactionOptions: { + initialSupply: '1', + divisibility: 6, + duration: 5000, + isSupplyMutable: true, + isTransferable: false, + isRestrictable: false, + isRevokable: false + } + } + } + ]; + + sendTransactionTests.forEach(test => { + runSendTransactionTest(test.description, test.config, test.expected); + }); + }); +}); diff --git a/wallet/symbol/mobile/__tests__/screens/mosaic/ModifyMosaic.test.js b/wallet/symbol/mobile/__tests__/screens/mosaic/ModifyMosaic.test.js new file mode 100644 index 0000000000..8dc70dc74b --- /dev/null +++ b/wallet/symbol/mobile/__tests__/screens/mosaic/ModifyMosaic.test.js @@ -0,0 +1,494 @@ +import { MosaicSupplyChangeAction } from '@/app/constants'; +import { ModifyMosaic } from '@/app/screens/mosaic/ModifyMosaic'; +import { AccountFixtureBuilder } from '__fixtures__/local/AccountFixtureBuilder'; +import { NetworkPropertiesFixtureBuilder } from '__fixtures__/local/NetworkPropertiesFixtureBuilder'; +import { TransactionFeeFixtureBuilder } from '__fixtures__/local/TransactionFeeFixtureBuilder'; +import { ScreenTester } from '__tests__/ScreenTester'; +import { createAddressBookMock, mockLocalization, mockPasscode, mockRouter, mockWalletController } from '__tests__/mock-helpers'; + +// Constants + +const CHAIN_NAME = 'symbol'; +const NETWORK_IDENTIFIER = 'testnet'; +const TICKER = 'XYM'; + +const MOSAIC_ID = '78C3CDF0896248DB'; +const MOSAIC_NAME = 'my.mutable.mosaic'; +const MOSAIC_DIVISIBILITY = 0; +const DIVISIBLE_MOSAIC_DIVISIBILITY = 2; + +const CURRENT_SUPPLY = '1000'; +const CURRENT_SUPPLY_TEXT = '1 000'; +const INCREASED_SUPPLY = '1500'; +const INCREASE_DELTA = '500'; +const INCREASE_DELTA_TEXT = '+500'; +const DECREASED_SUPPLY = '400'; +const DECREASE_DELTA = '600'; +const DECREASE_DELTA_TEXT = '-600'; +const EXCESSIVE_SUPPLY = '9000000000000001'; +const FRACTIONAL_SUPPLY = '10.5'; +const TOO_MANY_DECIMALS_SUPPLY = '1000.555'; +const EMPTY_SUPPLY = ''; + +// Screen Text + +const SCREEN_TEXT = { + // Section titles and descriptions + textScreenTitle: 'screen_ModifyMosaic', + textDescription: 's_modifyMosaic_description', + textCreatorTitle: 's_mosaicCreation_sender_title', + + // Supply delta summary + textCurrentSupplyLabel: 's_modifyMosaic_currentSupply_label', + textDeltaLabel: 's_modifyMosaic_delta_label', + + // Input labels (accessibility) + inputMosaicLabel: 'input_mosaic', + inputNewSupplyLabel: 's_modifyMosaic_newSupply_label', + + // Fee selector + textFeeSpeedTitle: 'input_feeSpeed', + + // Buttons + buttonSend: 'button_send', + buttonConfirm: 'button_confirm', + + // Confirmation dialog + textConfirmDialogTitle: 's_modifyMosaic_confirm_title', + + // Validation errors + errorSupplyUnchanged: 'validation_error_mosaic_supply_unchanged', + errorSupplyHigh: 'validation_error_mosaic_supply_high', + errorSupplyWhole: 'validation_error_mosaic_supply_whole', + errorSupplyDecimals: 'validation_error_mosaic_supply_decimals', + errorFieldRequired: 'validation_error_field_required' +}; + +const validationErrors = [ + SCREEN_TEXT.errorSupplyUnchanged, + SCREEN_TEXT.errorSupplyHigh, + SCREEN_TEXT.errorSupplyWhole, + SCREEN_TEXT.errorSupplyDecimals, + SCREEN_TEXT.errorFieldRequired +]; + +// Account Fixtures + +const currentAccount = AccountFixtureBuilder + .createWithAccount(CHAIN_NAME, NETWORK_IDENTIFIER, 0) + .build(); + +const walletAccounts = [currentAccount]; + +// Network Properties Fixtures + +const symbolNetworkProperties = NetworkPropertiesFixtureBuilder + .createWithType(CHAIN_NAME, NETWORK_IDENTIFIER) + .build(); + +// Transaction Fee Fixtures + +const transactionFees = TransactionFeeFixtureBuilder + .createWithAmounts('0.1', '0.2', '0.3', CHAIN_NAME, NETWORK_IDENTIFIER) + .build(); + +// Mosaic Info Fixtures + +const mosaicInfo = { + id: MOSAIC_ID, + names: [MOSAIC_NAME], + divisibility: MOSAIC_DIVISIBILITY, + supply: CURRENT_SUPPLY +}; + +const divisibleMosaicInfo = { + ...mosaicInfo, + divisibility: DIVISIBLE_MOSAIC_DIVISIBILITY +}; + +// Mock Supply Change Transaction Bundle (returned by the token module) + +const supplyChangeTransaction = { + type: 'mosaicSupplyChange', + signerAddress: currentAccount.address, + mosaicId: MOSAIC_ID, + action: 'Increase', + delta: INCREASE_DELTA, + fee: { token: { amount: '0.1' } } +}; + +const supplyChangeTransactionBundle = { + transactions: [supplyChangeTransaction], + applyFeeTier: jest.fn() +}; + +// Signed Transaction Bundle Fixtures + +const signedTransactionBundle = { + transactions: [{ hash: 'SIGNED_TX_HASH' }] +}; + +// Setup + +const setupMocks = (overrides = {}) => { + mockLocalization(); + + const walletControllerMock = mockWalletController({ + chainName: CHAIN_NAME, + networkIdentifier: NETWORK_IDENTIFIER, + ticker: TICKER, + networkProperties: symbolNetworkProperties, + isWalletReady: overrides.isWalletReady ?? true, + isNetworkConnectionReady: overrides.isNetworkConnectionReady ?? true, + currentAccount, + accounts: { + [NETWORK_IDENTIFIER]: walletAccounts + }, + networkApi: { + mosaic: { + fetchMosaicInfo: jest.fn().mockResolvedValue(overrides.mosaic ?? mosaicInfo) + } + }, + signTransactionBundle: jest.fn().mockResolvedValue(signedTransactionBundle), + announceSignedTransactionBundle: jest.fn().mockResolvedValue({}), + modules: { + token: { + createSupplyChangeTransaction: jest.fn().mockReturnValue(supplyChangeTransactionBundle) + }, + transfer: { + calculateTransactionFees: jest.fn().mockResolvedValue(transactionFees) + }, + multisig: { + multisigAccounts: [], + fetchData: jest.fn().mockResolvedValue([]) + }, + addressBook: createAddressBookMock([]) + } + }); + + return { walletControllerMock }; +}; + +// Renders the screen and waits for the initial loads (sender options, mosaic info) + +const renderModifyMosaicScreen = async (routeParams = {}) => { + const props = { + route: { + params: { + chainName: CHAIN_NAME, + tokenId: MOSAIC_ID, + ...routeParams + } + } + }; + const screenTester = new ScreenTester(ModifyMosaic, props); + await screenTester.waitForTimer(); // sender options and mosaic info load + + return screenTester; +}; + +// Enters a new total supply and waits for the debounced fee calculation + +const enterNewSupply = async (screenTester, supply) => { + screenTester.inputText(SCREEN_TEXT.inputNewSupplyLabel, supply); + await screenTester.waitForTimer(); // fee calculation +}; + +describe('screens/mosaic/ModifyMosaic', () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.clearAllTimers(); + jest.useRealTimers(); + }); + + describe('render', () => { + it('renders the title, description, section title and the send button', async () => { + // Arrange: + setupMocks(); + const expectedTexts = [ + SCREEN_TEXT.textScreenTitle, + SCREEN_TEXT.textDescription, + SCREEN_TEXT.textCreatorTitle, + SCREEN_TEXT.textCurrentSupplyLabel, + SCREEN_TEXT.textDeltaLabel, + SCREEN_TEXT.buttonSend + ]; + + // Act: + const screenTester = await renderModifyMosaicScreen(); + + // Assert: + screenTester.expectText(expectedTexts); + }); + + it('shows the fixed mosaic name in the disabled token selector', async () => { + // Arrange: + setupMocks(); + + // Act: + const screenTester = await renderModifyMosaicScreen(); + + // Assert: + screenTester.expectText([MOSAIC_NAME]); + }); + + it('shows a loading indicator until the wallet and mosaic are ready', async () => { + // Arrange: + setupMocks({ isWalletReady: false }); + + // Act: + const screenTester = new ScreenTester(ModifyMosaic, { + route: { params: { chainName: CHAIN_NAME, tokenId: MOSAIC_ID } } + }); + + // Assert: + screenTester.expectLoadingIndicator(); + }); + }); + + describe('current supply', () => { + it('pre-fills the new supply input with the current mosaic supply', async () => { + // Arrange: + setupMocks(); + + // Act: + const screenTester = await renderModifyMosaicScreen(); + + // Assert: + screenTester.expectInputValue(CURRENT_SUPPLY); + }); + + it('shows the grouped current supply in the delta summary', async () => { + // Arrange: + setupMocks(); + + // Act: + const screenTester = await renderModifyMosaicScreen(); + + // Assert: both the current and the new supply row show the untouched supply + screenTester.expectText([CURRENT_SUPPLY_TEXT], true); + }); + }); + + describe('supply delta', () => { + const runSupplyDeltaTest = (description, config, expected) => { + it(description, async () => { + // Arrange: + setupMocks(); + const screenTester = await renderModifyMosaicScreen(); + + // Act: + await enterNewSupply(screenTester, config.newSupply); + + // Assert: + screenTester.expectText([expected.deltaText]); + }); + }; + + const supplyDeltaTests = [ + { + description: 'shows a positive delta when the supply is increased', + config: { newSupply: INCREASED_SUPPLY }, + expected: { deltaText: INCREASE_DELTA_TEXT } + }, + { + description: 'shows a negative delta when the supply is decreased', + config: { newSupply: DECREASED_SUPPLY }, + expected: { deltaText: DECREASE_DELTA_TEXT } + } + ]; + + supplyDeltaTests.forEach(test => { + runSupplyDeltaTest(test.description, test.config, test.expected); + }); + }); + + describe('supply validation', () => { + const runSupplyValidationTest = (description, config, expected) => { + it(description, async () => { + // Arrange: + setupMocks({ mosaic: config.mosaic }); + const screenTester = await renderModifyMosaicScreen(); + + // Act: + if (config.newSupply !== undefined) + await enterNewSupply(screenTester, config.newSupply); + + // Assert: + if (expected.errorText) + screenTester.expectText([expected.errorText]); + else + screenTester.notExpectText(validationErrors); + }); + }; + + const supplyValidationTests = [ + { + description: 'renders the unchanged error when the supply is left at its current value', + config: {}, + expected: { errorText: SCREEN_TEXT.errorSupplyUnchanged } + }, + { + description: 'renders the required error when the supply is cleared', + config: { newSupply: EMPTY_SUPPLY }, + expected: { errorText: SCREEN_TEXT.errorFieldRequired } + }, + { + description: 'renders the whole-number error when a fraction is entered for an indivisible mosaic', + config: { newSupply: FRACTIONAL_SUPPLY }, + expected: { errorText: SCREEN_TEXT.errorSupplyWhole } + }, + { + description: 'renders the decimals error when more decimals than the divisibility are entered', + config: { mosaic: divisibleMosaicInfo, newSupply: TOO_MANY_DECIMALS_SUPPLY }, + expected: { errorText: SCREEN_TEXT.errorSupplyDecimals } + }, + { + description: 'renders the supply-too-high error when the supply exceeds the network maximum', + config: { newSupply: EXCESSIVE_SUPPLY }, + expected: { errorText: SCREEN_TEXT.errorSupplyHigh } + }, + { + description: 'renders no error for a valid supply change', + config: { newSupply: INCREASED_SUPPLY }, + expected: { errorText: null } + } + ]; + + supplyValidationTests.forEach(test => { + runSupplyValidationTest(test.description, test.config, test.expected); + }); + }); + + describe('fee selector', () => { + it('hides the fee selector while the supply is unchanged', async () => { + // Arrange: + setupMocks(); + + // Act: + const screenTester = await renderModifyMosaicScreen(); + + // Assert: + screenTester.notExpectText([SCREEN_TEXT.textFeeSpeedTitle]); + }); + + it('shows the fee selector once a supply change triggers fee calculation', async () => { + // Arrange: + setupMocks(); + const screenTester = await renderModifyMosaicScreen(); + + // Act: + await enterNewSupply(screenTester, INCREASED_SUPPLY); + + // Assert: + screenTester.expectText([SCREEN_TEXT.textFeeSpeedTitle]); + }); + }); + + describe('send button', () => { + const runSendButtonStateTest = (description, config, expected) => { + it(description, async () => { + // Arrange: + setupMocks({ isNetworkConnectionReady: config.isNetworkConnectionReady }); + const screenTester = await renderModifyMosaicScreen(); + + // Act: + if (config.newSupply) + await enterNewSupply(screenTester, config.newSupply); + + // Assert: + if (expected.isDisabled) + screenTester.expectButtonDisabled(SCREEN_TEXT.buttonSend); + else + screenTester.expectButtonEnabled(SCREEN_TEXT.buttonSend); + }); + }; + + const sendButtonTests = [ + { + description: 'disables the send button while the supply is unchanged', + config: { isNetworkConnectionReady: true }, + expected: { isDisabled: true } + }, + { + description: 'disables the send button when the network connection is not ready', + config: { isNetworkConnectionReady: false, newSupply: INCREASED_SUPPLY }, + expected: { isDisabled: true } + }, + { + description: 'enables the send button once a valid supply change and fees are ready', + config: { isNetworkConnectionReady: true, newSupply: INCREASED_SUPPLY }, + expected: { isDisabled: false } + } + ]; + + sendButtonTests.forEach(test => { + runSendButtonStateTest(test.description, test.config, test.expected); + }); + }); + + describe('send transaction flow', () => { + const runSendTransactionTest = (description, config, expected) => { + it(description, async () => { + // Arrange: + const { walletControllerMock } = setupMocks(); + mockPasscode(); + mockRouter({ goBack: jest.fn() }); + const screenTester = await renderModifyMosaicScreen(); + + // Act: + await enterNewSupply(screenTester, config.newSupply); + + screenTester.pressButton(SCREEN_TEXT.buttonSend); + await screenTester.waitForTimer(); // create transaction, open confirmation dialog + screenTester.expectText([SCREEN_TEXT.textConfirmDialogTitle]); + + screenTester.pressButton(SCREEN_TEXT.buttonConfirm); + await screenTester.waitForTimer(); // passcode success, send execution delay + await screenTester.waitForTimer(); // sign transaction + await screenTester.waitForTimer(); // announce transaction + + // Assert: + expect(walletControllerMock.modules.token.createSupplyChangeTransaction) + .toHaveBeenCalledWith(expect.objectContaining(expected.transactionOptions)); + expect(walletControllerMock.signTransactionBundle).toHaveBeenCalledWith(supplyChangeTransactionBundle); + expect(walletControllerMock.announceSignedTransactionBundle).toHaveBeenCalledWith(signedTransactionBundle); + }); + }; + + const sendTransactionTests = [ + { + description: 'increases the supply on behalf of the current account', + config: { newSupply: INCREASED_SUPPLY }, + expected: { + transactionOptions: { + mosaicId: MOSAIC_ID, + divisibility: MOSAIC_DIVISIBILITY, + delta: INCREASE_DELTA, + action: MosaicSupplyChangeAction.Increase + } + } + }, + { + description: 'decreases the supply on behalf of the current account', + config: { newSupply: DECREASED_SUPPLY }, + expected: { + transactionOptions: { + mosaicId: MOSAIC_ID, + divisibility: MOSAIC_DIVISIBILITY, + delta: DECREASE_DELTA, + action: MosaicSupplyChangeAction.Decrease + } + } + } + ]; + + sendTransactionTests.forEach(test => { + runSendTransactionTest(test.description, test.config, test.expected); + }); + }); +}); diff --git a/wallet/symbol/mobile/__tests__/screens/mosaic/RevokeMosaic.test.js b/wallet/symbol/mobile/__tests__/screens/mosaic/RevokeMosaic.test.js new file mode 100644 index 0000000000..763950f282 --- /dev/null +++ b/wallet/symbol/mobile/__tests__/screens/mosaic/RevokeMosaic.test.js @@ -0,0 +1,473 @@ +import { RevokeMosaic } from '@/app/screens/mosaic/RevokeMosaic'; +import { AccountFixtureBuilder } from '__fixtures__/local/AccountFixtureBuilder'; +import { NetworkPropertiesFixtureBuilder } from '__fixtures__/local/NetworkPropertiesFixtureBuilder'; +import { TransactionFeeFixtureBuilder } from '__fixtures__/local/TransactionFeeFixtureBuilder'; +import { ScreenTester } from '__tests__/ScreenTester'; +import { createAddressBookMock, mockLocalization, mockPasscode, mockRouter, mockWalletController } from '__tests__/mock-helpers'; + +// Constants + +const CHAIN_NAME = 'symbol'; +const NETWORK_IDENTIFIER = 'testnet'; +const TICKER = 'XYM'; + +const MOSAIC_ID = '78C3CDF0896248DB'; +const MOSAIC_NAME = 'my.revokable.mosaic'; +const MOSAIC_DIVISIBILITY = 0; + +const HOLDER_A_BALANCE = '1000'; +const HOLDER_B_BALANCE = '500'; +const CREATOR_BALANCE = '250'; +const VALID_AMOUNT = '5'; +const EXCESSIVE_AMOUNT = '5000'; + +// Screen Text + +const SCREEN_TEXT = { + // Section titles and descriptions + textScreenTitle: 'screen_RevokeMosaic', + textDescription: 's_revoke_description', + textCreatorTitle: 's_mosaicCreation_sender_title', + textFromTitle: 's_send_from_title', + textTokenTitle: 's_send_token_title', + + // Input labels (accessibility) + inputMosaicLabel: 'input_mosaic', + inputAmountLabel: 'input_amount', + inputAccountLabel: 'fieldTitle_account', + + // Fee selector + textFeeSpeedTitle: 'input_feeSpeed', + + // Buttons + buttonSend: 'button_send', + buttonConfirm: 'button_confirm', + + // Confirmation dialog + textConfirmDialogTitle: 'form_transfer_confirm_title', + + // Validation errors + errorBalanceNotEnough: 'validation_error_balance_not_enough' +}; + +// Account Fixtures + +const currentAccount = AccountFixtureBuilder + .createWithAccount(CHAIN_NAME, NETWORK_IDENTIFIER, 0) + .build(); + +const holderAccountA = AccountFixtureBuilder + .createWithAccount(CHAIN_NAME, NETWORK_IDENTIFIER, 1) + .build(); + +const holderAccountB = AccountFixtureBuilder + .createWithAccount(CHAIN_NAME, NETWORK_IDENTIFIER, 2) + .build(); + +const walletAccounts = [currentAccount]; + +// Network Properties Fixtures + +const symbolNetworkProperties = NetworkPropertiesFixtureBuilder + .createWithType(CHAIN_NAME, NETWORK_IDENTIFIER) + .build(); + +// Transaction Fee Fixtures + +const transactionFees = TransactionFeeFixtureBuilder + .createWithAmounts('0.1', '0.2', '0.3', CHAIN_NAME, NETWORK_IDENTIFIER) + .build(); + +// Mosaic Info and Holders Fixtures + +const mosaicInfo = { + id: MOSAIC_ID, + names: [MOSAIC_NAME], + divisibility: MOSAIC_DIVISIBILITY +}; + +const holders = [ + { address: holderAccountA.address, amount: HOLDER_A_BALANCE }, + { address: holderAccountB.address, amount: HOLDER_B_BALANCE } +]; + +const holdersIncludingCreator = [ + ...holders, + { address: currentAccount.address, amount: CREATOR_BALANCE } +]; + +// Mock Revocation Transaction Bundle (returned by the token module) + +const revocationTransaction = { + type: 'mosaicSupplyRevocation', + signerAddress: currentAccount.address, + sourceAddress: holderAccountA.address, + mosaic: { id: MOSAIC_ID, amount: VALID_AMOUNT }, + fee: { token: { amount: '0.1' } } +}; + +const revokeTransactionBundle = { + transactions: [revocationTransaction], + applyFeeTier: jest.fn() +}; + +// Signed Transaction Bundle Fixtures + +const signedTransactionBundle = { + transactions: [{ hash: 'SIGNED_TX_HASH' }] +}; + +// Setup + +const setupMocks = (overrides = {}) => { + mockLocalization(); + + const walletControllerMock = mockWalletController({ + chainName: CHAIN_NAME, + networkIdentifier: NETWORK_IDENTIFIER, + ticker: TICKER, + networkProperties: symbolNetworkProperties, + isWalletReady: overrides.isWalletReady ?? true, + isNetworkConnectionReady: overrides.isNetworkConnectionReady ?? true, + currentAccount, + accounts: { + [NETWORK_IDENTIFIER]: walletAccounts + }, + networkApi: { + mosaic: { + fetchMosaicInfo: jest.fn().mockResolvedValue(overrides.mosaic ?? mosaicInfo) + } + }, + signTransactionBundle: jest.fn().mockResolvedValue(signedTransactionBundle), + announceSignedTransactionBundle: jest.fn().mockResolvedValue({}), + modules: { + token: { + fetchMosaicOwners: jest.fn().mockResolvedValue(overrides.owners ?? holders), + createRevocationTransaction: jest.fn().mockReturnValue(revokeTransactionBundle) + }, + transfer: { + calculateTransactionFees: jest.fn().mockResolvedValue(transactionFees) + }, + multisig: { + multisigAccounts: [], + fetchData: jest.fn().mockResolvedValue([]) + }, + addressBook: createAddressBookMock([]) + } + }); + + return { walletControllerMock }; +}; + +// Renders the screen and waits for the initial loads (sender options, mosaic info, holders) + +const renderRevokeMosaicScreen = async (routeParams = {}) => { + const props = { + route: { + params: { + chainName: CHAIN_NAME, + tokenId: MOSAIC_ID, + ...routeParams + } + } + }; + const screenTester = new ScreenTester(RevokeMosaic, props); + await screenTester.waitForTimer(); // sender options, mosaic info and holders load + + return screenTester; +}; + +// Opens the source account dropdown and selects the holder with the given address + +const selectSourceHolder = (screenTester, address) => { + screenTester.presButtonByLabel(SCREEN_TEXT.inputAccountLabel); + screenTester.pressButton(address); +}; + +describe('screens/mosaic/RevokeMosaic', () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.clearAllTimers(); + jest.useRealTimers(); + }); + + describe('render', () => { + it('renders the title, description, section titles and the send button', async () => { + // Arrange: + setupMocks(); + const expectedTexts = [ + SCREEN_TEXT.textScreenTitle, + SCREEN_TEXT.textDescription, + SCREEN_TEXT.textCreatorTitle, + SCREEN_TEXT.textFromTitle, + SCREEN_TEXT.textTokenTitle, + SCREEN_TEXT.buttonSend + ]; + + // Act: + const screenTester = await renderRevokeMosaicScreen(); + + // Assert: + screenTester.expectText(expectedTexts); + }); + + it('shows the fixed mosaic name in the disabled token selector', async () => { + // Arrange: + setupMocks(); + + // Act: + const screenTester = await renderRevokeMosaicScreen(); + + // Assert: + screenTester.expectText([MOSAIC_NAME]); + }); + + it('shows a loading indicator until the wallet, mosaic and holders are ready', async () => { + // Arrange: + setupMocks({ isWalletReady: false }); + + // Act: + const screenTester = new ScreenTester(RevokeMosaic, { + route: { params: { chainName: CHAIN_NAME, tokenId: MOSAIC_ID } } + }); + + // Assert: + screenTester.expectLoadingIndicator(); + }); + }); + + describe('source account', () => { + const runSourceAccountTest = (description, config, expected) => { + it(description, async () => { + // Arrange: + setupMocks({ owners: config.owners }); + const screenTester = await renderRevokeMosaicScreen(); + + // Act: + screenTester.presButtonByLabel(SCREEN_TEXT.inputAccountLabel); + + // Assert: + expected.addressCounts.forEach(([address, count]) => screenTester.expectTextCount(address, count)); + }); + }; + + // The creator is always rendered once in the creator section, so it is counted rather than + // asserted absent when checking that it is not offered as a holder option + const sourceAccountTests = [ + { + description: 'lists all mosaic holders as selectable options', + config: { owners: holders }, + expected: { + addressCounts: [ + [holderAccountA.address, 1], + [holderAccountB.address, 1] + ] + } + }, + { + description: 'excludes the creator from the holder options when the creator also holds the mosaic', + config: { owners: holdersIncludingCreator }, + expected: { + addressCounts: [ + [holderAccountA.address, 1], + [holderAccountB.address, 1], + [currentAccount.address, 1] + ] + } + } + ]; + + sourceAccountTests.forEach(test => { + runSourceAccountTest(test.description, test.config, test.expected); + }); + }); + + describe('amount validation', () => { + const runAmountValidationTest = (description, config, expected) => { + it(description, async () => { + // Arrange: + setupMocks(); + const screenTester = await renderRevokeMosaicScreen(); + selectSourceHolder(screenTester, holderAccountA.address); // available balance becomes the holder balance + + // Act: + screenTester.inputText(SCREEN_TEXT.inputAmountLabel, config.amount); + await screenTester.waitForTimer(); // fee calculation + + // Assert: + if (expected.errorText) + screenTester.expectText([expected.errorText]); + else + screenTester.notExpectText([SCREEN_TEXT.errorBalanceNotEnough]); + }); + }; + + const amountValidationTests = [ + { + description: 'renders no error for an amount within the holder balance', + config: { amount: VALID_AMOUNT }, + expected: { errorText: null } + }, + { + description: 'renders the balance-not-enough error when the amount exceeds the holder balance', + config: { amount: EXCESSIVE_AMOUNT }, + expected: { errorText: SCREEN_TEXT.errorBalanceNotEnough } + } + ]; + + amountValidationTests.forEach(test => { + runAmountValidationTest(test.description, test.config, test.expected); + }); + }); + + describe('fee selector', () => { + it('hides the fee selector until a holder and an amount are provided', async () => { + // Arrange: + setupMocks(); + + // Act: + const screenTester = await renderRevokeMosaicScreen(); + + // Assert: + screenTester.notExpectText([SCREEN_TEXT.textFeeSpeedTitle]); + }); + + it('shows the fee selector after a holder and an amount trigger fee calculation', async () => { + // Arrange: + setupMocks(); + const screenTester = await renderRevokeMosaicScreen(); + + // Act: + selectSourceHolder(screenTester, holderAccountA.address); + screenTester.inputText(SCREEN_TEXT.inputAmountLabel, VALID_AMOUNT); + await screenTester.waitForTimer(); // fee calculation + + // Assert: + screenTester.expectText([SCREEN_TEXT.textFeeSpeedTitle]); + }); + }); + + describe('send button', () => { + const runSendButtonStateTest = (description, config, expected) => { + it(description, async () => { + // Arrange: + setupMocks({ isNetworkConnectionReady: config.isNetworkConnectionReady }); + const screenTester = await renderRevokeMosaicScreen(); + + // Act: + if (config.selectHolder) { + selectSourceHolder(screenTester, holderAccountA.address); + screenTester.inputText(SCREEN_TEXT.inputAmountLabel, VALID_AMOUNT); + await screenTester.waitForTimer(); // fee calculation + } + + // Assert: + if (expected.isDisabled) + screenTester.expectButtonDisabled(SCREEN_TEXT.buttonSend); + else + screenTester.expectButtonEnabled(SCREEN_TEXT.buttonSend); + }); + }; + + const sendButtonTests = [ + { + description: 'disables the send button when no holder is selected', + config: { isNetworkConnectionReady: true, selectHolder: false }, + expected: { isDisabled: true } + }, + { + description: 'disables the send button when the network connection is not ready', + config: { isNetworkConnectionReady: false, selectHolder: true }, + expected: { isDisabled: true } + }, + { + description: 'enables the send button once a holder, a valid amount and fees are ready', + config: { isNetworkConnectionReady: true, selectHolder: true }, + expected: { isDisabled: false } + } + ]; + + sendButtonTests.forEach(test => { + runSendButtonStateTest(test.description, test.config, test.expected); + }); + }); + + describe('route params', () => { + it('pre-fills the amount from the amount route param', async () => { + // Arrange: + setupMocks(); + + // Act: + const screenTester = await renderRevokeMosaicScreen({ amount: VALID_AMOUNT }); + + // Assert: + screenTester.expectInputValue(VALID_AMOUNT); + }); + + it('pre-selects the source holder from the sourceAddress route param', async () => { + // Arrange: + setupMocks(); + + // Act: + const screenTester = await renderRevokeMosaicScreen({ sourceAddress: holderAccountA.address }); + + // Assert: + screenTester.expectText([holderAccountA.address]); + }); + }); + + describe('send transaction flow', () => { + const runSendTransactionTest = (description, config, expected) => { + it(description, async () => { + // Arrange: + const { walletControllerMock } = setupMocks(); + mockPasscode(); + mockRouter({ goBack: jest.fn() }); + const screenTester = await renderRevokeMosaicScreen(); + + // Act: + selectSourceHolder(screenTester, config.sourceAddress); + screenTester.inputText(SCREEN_TEXT.inputAmountLabel, config.amount); + await screenTester.waitForTimer(); // fee calculation + + screenTester.pressButton(SCREEN_TEXT.buttonSend); + await screenTester.waitForTimer(); // create transaction, open confirmation dialog + screenTester.expectText([SCREEN_TEXT.textConfirmDialogTitle]); + + screenTester.pressButton(SCREEN_TEXT.buttonConfirm); + await screenTester.waitForTimer(); // passcode success, send execution delay + await screenTester.waitForTimer(); // sign transaction + await screenTester.waitForTimer(); // announce transaction + + // Assert: + expect(walletControllerMock.modules.token.createRevocationTransaction) + .toHaveBeenCalledWith(expect.objectContaining(expected.transactionOptions)); + expect(walletControllerMock.signTransactionBundle).toHaveBeenCalledWith(revokeTransactionBundle); + expect(walletControllerMock.announceSignedTransactionBundle).toHaveBeenCalledWith(signedTransactionBundle); + }); + }; + + const sendTransactionTests = [ + { + description: 'revokes the mosaic from the selected holder on behalf of the current account', + config: { sourceAddress: holderAccountA.address, amount: VALID_AMOUNT }, + expected: { + transactionOptions: { + mosaicId: MOSAIC_ID, + divisibility: MOSAIC_DIVISIBILITY, + amount: VALID_AMOUNT, + sourceAddress: holderAccountA.address + } + } + } + ]; + + sendTransactionTests.forEach(test => { + runSendTransactionTest(test.description, test.config, test.expected); + }); + }); +}); diff --git a/wallet/symbol/mobile/src/assets/images/art/mosaic-puzzle.png b/wallet/symbol/mobile/src/assets/images/art/mosaic-puzzle.png new file mode 100644 index 0000000000..f7261a7d98 Binary files /dev/null and b/wallet/symbol/mobile/src/assets/images/art/mosaic-puzzle.png differ diff --git a/wallet/symbol/mobile/src/components/controls/SelectToken.jsx b/wallet/symbol/mobile/src/components/controls/SelectToken.jsx index 29a0960d4d..3a500cf0af 100644 --- a/wallet/symbol/mobile/src/components/controls/SelectToken.jsx +++ b/wallet/symbol/mobile/src/components/controls/SelectToken.jsx @@ -14,11 +14,12 @@ import React from 'react'; * @param {import('wallet-common-core/src/types/Token').Token[]} props.tokens - List of available tokens. * @param {ChainName} props.chainName - Current chain name. * @param {NetworkIdentifier} props.networkIdentifier - Current network identifier. + * @param {boolean} [props.isDisabled] - Whether the selector is disabled. * @param {function(object): void} props.onChange - Callback for when the selected token changes. * @returns {React.ReactNode} The InputAddress component. */ export const SelectToken = props => { - const { label, value, tokens, chainName, networkIdentifier, onChange } = props; + const { label, value, tokens, chainName, networkIdentifier, isDisabled, onChange } = props; const list = tokens.map(token => { const resolvedInfo = getTokenKnownInfo(chainName, networkIdentifier, token.id); @@ -51,6 +52,7 @@ export const SelectToken = props => { label={label} value={value} list={list} + isDisabled={isDisabled} onChange={onChange} renderItem={renderItem} /> diff --git a/wallet/symbol/mobile/src/components/controls/SelectTransactionSender.jsx b/wallet/symbol/mobile/src/components/controls/SelectTransactionSender.jsx index f5614a43cf..8e37c89e37 100644 --- a/wallet/symbol/mobile/src/components/controls/SelectTransactionSender.jsx +++ b/wallet/symbol/mobile/src/components/controls/SelectTransactionSender.jsx @@ -33,7 +33,7 @@ const SenderTab = { * SelectTransactionSender component. A control for choosing the account a transaction is sent from. * When the current account is a cosignatory of multisig accounts, it shows a tab selector to switch * between the current account and a multisig account, with a dropdown to pick the multisig account. - * When there are no multisig accounts, it simply renders the current account. + * When there are no multisig accounts, or when multisig is disabled, it simply renders the current account. * @param {object} props - Component props. * @param {string} [props.label] - Label displayed above the selector. * @param {string} props.value - The currently selected sender address. @@ -43,6 +43,7 @@ const SenderTab = { * @param {NetworkIdentifier} props.networkIdentifier - The network identifier. * @param {WalletAccount[]} [props.walletAccounts] - Wallet accounts for resolving account names. * @param {object} [props.addressBook] - Address book for resolving account names. + * @param {boolean} [props.isMultisigDisabled] - Whether to hide the multisig accounts, rendering only the current account. * @param {(address: string) => void} props.onChange - Callback when the selected sender changes. * @returns {React.ReactNode} SelectTransactionSender component. */ @@ -56,15 +57,17 @@ export const SelectTransactionSender = props => { networkIdentifier, walletAccounts, addressBook, + isMultisigDisabled, onChange } = props; - const { current, multisigAccounts } = options; + const { current } = options; const [isDropdownOpen, setIsDropdownOpen] = useState(false); // Derived state + const multisigAccounts = isMultisigDisabled ? [] : options.multisigAccounts; const hasMultisigAccounts = multisigAccounts.length > 0; - const isMultisigSelected = value !== current.address; + const isMultisigSelected = hasMultisigAccounts && value !== current.address; const activeTab = isMultisigSelected ? SenderTab.MULTISIG : SenderTab.CURRENT; const multisigDefaultName = $t('s_multisig_defaultAccountName'); const selectedAccount = isMultisigSelected diff --git a/wallet/symbol/mobile/src/components/display/Token/TokenInfoView.jsx b/wallet/symbol/mobile/src/components/display/Token/TokenInfoView.jsx new file mode 100644 index 0000000000..c9e207b436 --- /dev/null +++ b/wallet/symbol/mobile/src/components/display/Token/TokenInfoView.jsx @@ -0,0 +1,63 @@ +import { StyledText, TokenAvatar } from '@/app/components'; +import { Sizes } from '@/app/styles'; +import React from 'react'; +import { StyleSheet, View } from 'react-native'; + +/** @typedef {import('@/app/types/Sizes').SizeVariant} SizeVariant */ + +const DEFAULT_SIZE = 'm'; + +/** + * TokenInfoView component. A display component showing a token's avatar alongside its name and + * identifier, with support for different sizes. + * @param {object} props - Component props. + * @param {string} props.id - Token identifier. + * @param {string} [props.name] - Optional token name. + * @param {string} [props.imageId] - Known token image identifier. + * @param {SizeVariant} [props.size=DEFAULT_SIZE] - Size of the avatar. + * @returns {React.ReactNode} Token info view component. + */ +export const TokenInfoView = ({ id, name, imageId, size = DEFAULT_SIZE }) => { + // Root container + const rootSizeStyleMap = { + m: styles.root_medium + }; + const rootSizeStyle = rootSizeStyleMap[size]; + + // Id + const isNameVisible = !!name; + const idTextVariant = isNameVisible ? 'secondary' : 'primary'; + + return ( + + + + {isNameVisible && ( + + {name} + + )} + + {id} + + + + ); +}; + +const styles = StyleSheet.create({ + root: { + flexDirection: 'row', + alignItems: 'center', + flexShrink: 1 + }, + root_medium: { + gap: Sizes.Semantic.spacing.m + }, + textContainer: { + flexShrink: 1 + } +}); diff --git a/wallet/symbol/mobile/src/components/display/Token/index.js b/wallet/symbol/mobile/src/components/display/Token/index.js index cc95bc8e6f..1341086096 100644 --- a/wallet/symbol/mobile/src/components/display/Token/index.js +++ b/wallet/symbol/mobile/src/components/display/Token/index.js @@ -1,2 +1,3 @@ export * from './TokenAvatar'; +export * from './TokenInfoView'; export * from './TokenView'; diff --git a/wallet/symbol/mobile/src/components/layout/Field.jsx b/wallet/symbol/mobile/src/components/layout/Field.jsx index 80baad3818..4d4577ee13 100644 --- a/wallet/symbol/mobile/src/components/layout/Field.jsx +++ b/wallet/symbol/mobile/src/components/layout/Field.jsx @@ -3,6 +3,8 @@ import { Sizes } from '@/app/styles'; import React from 'react'; import { StyleSheet, View } from 'react-native'; +/** @typedef {import('@/app/components/typography/StyledText').TextSize} TextSize */ + /** * Field component. A layout component grouping a title label with associated content, supporting inverse color scheme for titles. * @param {object} props - Component props. @@ -10,12 +12,16 @@ import { StyleSheet, View } from 'react-native'; * @param {string} props.title - Title text displayed as a label above the content. * @param {object} [props.style] - Additional styles for the root element. * @param {boolean} [props.inverse=false] - Whether to use inverse color scheme for the title text. + * @param {boolean} [props.alignRight=false] - Whether to align the title and the content to the right edge. + * @param {TextSize} [props.size='m'] - Title text size. * @returns {React.ReactNode} Field component. */ -export const Field = ({ children, title, style, inverse = false }) => { +export const Field = ({ children, title, style, inverse = false, alignRight = false, size = 'm' }) => { return ( - - {title} + + + {title} + {children} ); @@ -25,7 +31,13 @@ const styles = StyleSheet.create({ root: { gap: Sizes.Semantic.spacing.none }, + rootAlignRight: { + alignItems: 'flex-end' + }, title: { opacity: 0.7 + }, + titleAlignRight: { + textAlign: 'right' } }); diff --git a/wallet/symbol/mobile/src/hooks/useValidation.js b/wallet/symbol/mobile/src/hooks/useValidation.js index 3f96cd89ec..803ebf9dce 100644 --- a/wallet/symbol/mobile/src/hooks/useValidation.js +++ b/wallet/symbol/mobile/src/hooks/useValidation.js @@ -1,19 +1,28 @@ /** * A custom hook that validates a value using an array of validator functions. * @param {*} value - The value to be validated. - * @param {Array} validators - An array of validator functions. - * Each validator should return an error message if validation fails, or `null` if it passes. - * @param {function(string): string} [formatResult] - Optional function to format the validation result. - * @returns {string | any | undefined} - The validation error message, or `undefined` if no errors. + * @param {Array} validators + * - An array of validator functions. Each returns a falsy value when the value is valid, or an error + * result when invalid: either a message key string, or a `{ key, values }` pair whose `values` are + * interpolated into the localized message. + * @param {function(string, object=): string} [formatResult] - Optional formatter for the error result, + * typically the localization function. Receives the message key and its interpolation values. + * @returns {string | any | undefined} - The formatted validation error message, or `undefined` if no errors. */ export const useValidation = (value, validators, formatResult) => { for (const validator of validators) { const validationResult = validator(value); - if (validationResult && formatResult) - return formatResult(validationResult); - - if (validationResult) - return validationResult; + if (!validationResult) + continue; + + if (!formatResult) + return validationResult; + + const { key, values } = typeof validationResult === 'string' + ? { key: validationResult, values: undefined } + : validationResult; + + return formatResult(key, values); } }; diff --git a/wallet/symbol/mobile/src/lib/controller/symbol/controller.js b/wallet/symbol/mobile/src/lib/controller/symbol/controller.js index ce4e69bd5e..8dd16083a0 100644 --- a/wallet/symbol/mobile/src/lib/controller/symbol/controller.js +++ b/wallet/symbol/mobile/src/lib/controller/symbol/controller.js @@ -12,7 +12,7 @@ import { StorageInterface, WalletController } from 'wallet-common-core'; -import { HarvestingModule, MultisigModule, TransferModule } from 'wallet-common-symbol'; +import { HarvestingModule, MultisigModule, TokenModule, TransferModule } from 'wallet-common-symbol'; /** @typedef {import('@/app/types/Wallet').MainWalletController} MainWalletController */ @@ -24,7 +24,8 @@ const modules = [ new MultisigModule(), new TransferModule(), new LocalizationModule(), - new HarvestingModule() + new HarvestingModule(), + new TokenModule() ]; /** diff --git a/wallet/symbol/mobile/src/localization/locales/cn.json b/wallet/symbol/mobile/src/localization/locales/cn.json index 85da4386fd..204c425c5a 100644 --- a/wallet/symbol/mobile/src/localization/locales/cn.json +++ b/wallet/symbol/mobile/src/localization/locales/cn.json @@ -79,7 +79,8 @@ "screen_Welcome": "欢迎", "screen_CreateWallet": "创建钱包", "screen_ImportWallet": "导入钱包", - "screen_MosaicCreation": "创建马赛克", + "screen_CreateMosaic": "创建马赛克", + "screen_RevokeMosaic": "撤销马赛克", "screen_Home": "首页", "screen_History": "历史纪录", "screen_Assets": "资产", diff --git a/wallet/symbol/mobile/src/localization/locales/en.json b/wallet/symbol/mobile/src/localization/locales/en.json index c80eecc43a..4219683945 100644 --- a/wallet/symbol/mobile/src/localization/locales/en.json +++ b/wallet/symbol/mobile/src/localization/locales/en.json @@ -46,6 +46,7 @@ "button_downloadBackup": "Download mnemonic to file", "button_showMnemonic": "Show Mnemonic Phrase", "button_revoke": "Revoke Mosaic", + "button_modifyMosaic": "Modify Mosaic", "button_activateAccount": "Activate Account", "button_signAndApprove": "Approve and Sign", "button_regenerateAddress": "I don't like this address", @@ -91,7 +92,9 @@ "screen_Welcome": "Welcome", "screen_CreateWallet": "Create Wallet", "screen_ImportWallet": "Import Wallet", - "screen_MosaicCreation": "Create Mosaic", + "screen_CreateMosaic": "Create Mosaic", + "screen_RevokeMosaic": "Revoke Mosaic", + "screen_ModifyMosaic": "Modify Mosaic", "screen_Home": "Home", "screen_History": "History", "screen_Assets": "Assets", @@ -508,6 +511,11 @@ "s_mosaicCreation_mosaic_description": "Mosaics are fixed assets that represent a set of multiple identical things that do not change. A mosaic can be what is conventionally called a token, but it can also be a collection of more specialized assets such as reward points, shares of stock, signatures, status flags, votes or other currencies, for example.", "s_mosaicCreation_namespace_title": "Namespace", "s_mosaicCreation_namespace_description": "Namespaces are human-readable text strings that can be used in place of a Mosaic ID. Namespaces function similarly to internet domains. Creating a namespace starts with choosing a name that you will use to refer to an asset. The name must be unique in the network, and has a maximum length of 64 characters (the only allowed characters are a through z, 0 through 9, _ and -).", + "s_mosaicCreation_sender_title": "Creator", + "s_mosaicCreation_divisibility_title": "Divisibility", + "s_mosaicCreation_divisibility_description": "Determines up to what decimal place the mosaic can be divided. A divisibility of 3 means the smallest fraction is 0.001. Allowed values are 0 to 6.", + "s_mosaicCreation_supply_title": "Supply", + "s_mosaicCreation_supply_description": "The number of mosaic units to create. If the supply mutable flag is set, the mosaic creator can change the supply at a later point.", "s_mosaicCreation_supplyMutable_title": "Supply mutable", "s_mosaicCreation_supplyMutable_checkbox": "Supply mutable", "s_mosaicCreation_supplyMutable_description": "If set to true, the mosaic supply can change at a later point. In this case, the mosaic creator is allowed to redefine the total mosaic supply.", @@ -522,8 +530,58 @@ "s_mosaicCreation_revokable_description": "Mosaics can be revoked (i.e., reclaimed) by the mosaic creator when this flag is set to true.", "s_mosaicCreation_duration_title": "Duration", "s_mosaicCreation_duration_description": "Specify the number of confirmed blocks the mosaic is rented for. Expiring mosaics are allowed to lie in Symbol’s public network up to 3650 days (10 years).", + "s_mosaicCreation_duration_checkbox": "Never expire", "s_mosaicCreation_durationDays": "~%{duration} days", "s_mosaicCreation_confirm_title": "Confirm Transaction", + "s_mosaicCreation_confirm_text": "You are about to create a new mosaic with an initial supply of %{supply} and a divisibility of %{divisibility}.", + "s_mosaicCreation_quantity_title": "Quantity", + "s_mosaicCreation_quantity_description": "Setups the total amount of token to be issued and how many decimal places the token supports.", + "s_mosaicCreation_totalSupply_label": "Total supply", + "s_mosaicCreation_smallestSend_label": "Smallest you can send", + "s_mosaicCreation_smallestSend_whole": "(whole tokens only)", + "s_mosaicCreation_decimalsNote": "Decimals are permanent. Supply can change later if you allow it.", + "s_mosaicCreation_decimalPlaces_label": "Decimal places", + "s_mosaicCreation_decimalsHint_whole": "= whole tokens", + "s_mosaicCreation_decimalsHint": { + "one": "= %{count} decimal", + "other": "= %{count} decimals" + }, + "s_mosaicCreation_namePlaceholder": "New Mosaic", + "s_mosaicCreation_flags_title": "Flags", + "s_mosaicCreation_duration_expiresCheckbox": "This token expires", + "s_mosaicCreation_duration_blocksInputLabel": "Duration in blocks", + "s_mosaicCreation_expiration_title": "Token expiration in", + "s_mosaicCreation_expiration_permanent": "This token never expires — its supply exists permanently.", + "s_mosaicCreation_durationUnit_blocksChip": "Exact Blocks", + "s_mosaicCreation_durationUnit_minutes": "Minutes", + "s_mosaicCreation_durationUnit_hours": "Hours", + "s_mosaicCreation_durationUnit_days": "Days", + "s_mosaicCreation_durationUnit_months": "Months", + "s_mosaicCreation_durationUnit_years": "Years", + "s_mosaicCreation_durationAmount_blocks": { + "one": "%{value} block", + "other": "%{value} blocks" + }, + "s_mosaicCreation_durationAmount_minutes": { + "one": "%{count} minute", + "other": "%{count} minutes" + }, + "s_mosaicCreation_durationAmount_hours": { + "one": "%{count} hour", + "other": "%{count} hours" + }, + "s_mosaicCreation_durationAmount_days": { + "one": "%{count} day", + "other": "%{count} days" + }, + "s_mosaicCreation_durationAmount_months": { + "one": "%{count} month", + "other": "%{count} months" + }, + "s_mosaicCreation_durationAmount_years": { + "one": "%{count} year", + "other": "%{count} years" + }, "s_scan_mnemonic_description": "This QR-code contains mnemonic backup phrase. If you wish to import it, you should first logout from current wallet (Settings -> Logout).", "s_scan_account_description": "This QR-code contains account. Please select an action below.", "s_scan_account_wrongNetwork_description": "This QR-code contains account from different network. Please switch a network type in the Settings and try again.", @@ -595,6 +653,14 @@ "s_bridge_history_title": "Recent History", "s_bridge_history_description": "Your recent bridge transactions will appear here. Please allow a few minutes after making a swap for the transaction to appear.", "s_revoke_description": "Mosaic Supply Revocation allows to collect back mosaics that you created with the Revokable flag from the accounts that hold them.", + "s_modifyMosaic_description": "Mosaic Supply Change allows to increase or decrease the total supply of a mosaic that you created with the Supply mutable flag.", + "s_modifyMosaic_supply_title": "Total supply", + "s_modifyMosaic_currentSupply_label": "Current supply", + "s_modifyMosaic_newSupply_label": "New supply", + "s_modifyMosaic_delta_label": "Change", + "s_modifyMosaic_divisibility_label": "Divisibility: %{divisibility}", + "s_modifyMosaic_confirm_title": "Confirm Transaction", + "s_modifyMosaic_confirm_text": "You are about to change the total supply of this mosaic to %{supply}.", "s_accountDetails_accountInfo_title": "Details", "s_accountList_confirm_removeExternal_title": "Remove external account", "s_accountList_confirm_removeExternal_body": "You are about to remove account \"%{name}\" %{address} from your wallet. Please make sure that the private key is securely backed up and you will be able to access it. Note that this account cannot be restored from you mnemonic backup phrase. You won't be able to access again this account unless you have its private key.", @@ -753,9 +819,14 @@ "validation_error_amount_low": "Amount too low", "validation_error_amount_high": "Amount too high", "validation_error_already_exists": "Already exists", - "validation_error_mosaic_supply": "Should be a number from 1 to 9999999999", + "validation_error_mosaic_supply_low": "Supply must be at least %{min}", + "validation_error_mosaic_supply_high": "Supply can't exceed %{max}", + "validation_error_mosaic_supply_decimals": "Up to %{divisibility} decimal places allowed", + "validation_error_mosaic_supply_whole": "Supply must be a whole number", + "validation_error_mosaic_supply_unchanged": "New supply must differ from the current supply", "validation_error_mosaic_divisibility": "Should be a number from 0 to 6", - "validation_error_mosaic_duration": "Invalid duration", + "validation_error_mosaic_duration_low": "Duration must be at least %{min} block", + "validation_error_mosaic_duration_high": "Duration can't exceed %{max} blocks (~10 years)", "warning_multisig_title": "Multisig Account", "warning_multisig_body": "To send transactions from this account, please announce it from cosignature account!", "message_emptyList": "Nothing to show", diff --git a/wallet/symbol/mobile/src/localization/locales/ja.json b/wallet/symbol/mobile/src/localization/locales/ja.json index b1647e49b6..9fe86abdf4 100644 --- a/wallet/symbol/mobile/src/localization/locales/ja.json +++ b/wallet/symbol/mobile/src/localization/locales/ja.json @@ -79,7 +79,8 @@ "screen_Welcome": "ようこそ", "screen_CreateWallet": "ウォレット作成", "screen_ImportWallet": "ウォレットインポート", - "screen_MosaicCreation": "モザイク作成", + "screen_CreateMosaic": "モザイク作成", + "screen_RevokeMosaic": "モザイク回収", "screen_Home": "ホーム", "screen_History": "履歴", "screen_Assets": "アセット", diff --git a/wallet/symbol/mobile/src/localization/locales/ko.json b/wallet/symbol/mobile/src/localization/locales/ko.json index 38bcc3e9da..81561be1e9 100644 --- a/wallet/symbol/mobile/src/localization/locales/ko.json +++ b/wallet/symbol/mobile/src/localization/locales/ko.json @@ -79,7 +79,8 @@ "screen_Welcome": "환영", "screen_CreateWallet": "지갑 생성", "screen_ImportWallet": "지갑 추가", - "screen_MosaicCreation": "모자이크 생성", + "screen_CreateMosaic": "모자이크 생성", + "screen_RevokeMosaic": "Revoke 모자이크", "screen_Home": "홈", "screen_History": "기록", "screen_Assets": "자산", diff --git a/wallet/symbol/mobile/src/localization/locales/uk.json b/wallet/symbol/mobile/src/localization/locales/uk.json index 1135cf0507..fcb540efc6 100644 --- a/wallet/symbol/mobile/src/localization/locales/uk.json +++ b/wallet/symbol/mobile/src/localization/locales/uk.json @@ -79,7 +79,8 @@ "screen_Welcome": "Ласкаво просимо", "screen_CreateWallet": "Створити гаманець", "screen_ImportWallet": "Імпортувати гаманець", - "screen_MosaicCreation": "Створити мозаїку", + "screen_CreateMosaic": "Створити мозаїку", + "screen_RevokeMosaic": "Відкликати мозаїку", "screen_Home": "Головна", "screen_History": "Історія", "screen_Assets": "Активи", diff --git a/wallet/symbol/mobile/src/localization/locales/zh.json b/wallet/symbol/mobile/src/localization/locales/zh.json index 72f8bef73f..613244eb9e 100644 --- a/wallet/symbol/mobile/src/localization/locales/zh.json +++ b/wallet/symbol/mobile/src/localization/locales/zh.json @@ -79,7 +79,8 @@ "screen_Welcome": "歡迎", "screen_CreateWallet": "創建錢包", "screen_ImportWallet": "導入錢包", - "screen_MosaicCreation": "創建馬賽克", + "screen_CreateMosaic": "創建馬賽克", + "screen_RevokeMosaic": "撤銷馬賽克", "screen_Home": "首頁", "screen_History": "歷史紀錄", "screen_Assets": "資產", diff --git a/wallet/symbol/mobile/src/router/Router.js b/wallet/symbol/mobile/src/router/Router.js index 15b4727bf0..3d3fe9c2aa 100644 --- a/wallet/symbol/mobile/src/router/Router.js +++ b/wallet/symbol/mobile/src/router/Router.js @@ -120,6 +120,15 @@ export class Router { static goToHarvesting(params) { navigationRef.navigate(RouteName.Harvesting, parseNavigationParams(params)); } + static goToCreateMosaic(params) { + navigationRef.navigate(RouteName.CreateMosaic, parseNavigationParams(params)); + } + static goToModifyMosaic(params) { + navigationRef.navigate(RouteName.ModifyMosaic, parseNavigationParams(params)); + } + static goToRevokeMosaic(params) { + navigationRef.navigate(RouteName.RevokeMosaic, parseNavigationParams(params)); + } static goToScan(params) { navigationRef.navigate(RouteName.Scan, parseNavigationParams(params)); } diff --git a/wallet/symbol/mobile/src/router/RouterView.jsx b/wallet/symbol/mobile/src/router/RouterView.jsx index 621cde4e64..7f6d56899b 100644 --- a/wallet/symbol/mobile/src/router/RouterView.jsx +++ b/wallet/symbol/mobile/src/router/RouterView.jsx @@ -96,6 +96,9 @@ export const RouterView = ({ isActive, flow }) => ( + + + diff --git a/wallet/symbol/mobile/src/router/config.js b/wallet/symbol/mobile/src/router/config.js index 5860e88ce9..98e60ce716 100644 --- a/wallet/symbol/mobile/src/router/config.js +++ b/wallet/symbol/mobile/src/router/config.js @@ -32,6 +32,9 @@ export const RouteName = { CreateContact: 'CreateContact', EditContact: 'EditContact', Harvesting: 'Harvesting', + CreateMosaic: 'CreateMosaic', + ModifyMosaic: 'ModifyMosaic', + RevokeMosaic: 'RevokeMosaic', Scan: 'Scan', TransportRequest: 'TransportRequest' }; diff --git a/wallet/symbol/mobile/src/screens/actions/Actions.jsx b/wallet/symbol/mobile/src/screens/actions/Actions.jsx index cbc434b1f7..97dcf3b754 100644 --- a/wallet/symbol/mobile/src/screens/actions/Actions.jsx +++ b/wallet/symbol/mobile/src/screens/actions/Actions.jsx @@ -49,6 +49,12 @@ export const Actions = () => { imageSource: require('@/app/assets/images/art/ship.png'), onPress: Router.goToSend }, + { + title: $t('s_actions_createMosaic_title'), + description: $t('s_actions_createMosaic_description'), + imageSource: require('@/app/assets/images/art/mosaic-puzzle.png'), + onPress: Router.goToCreateMosaic + }, { title: $t('s_actions_bridge_title'), description: $t('s_actions_bridge_description'), diff --git a/wallet/symbol/mobile/src/screens/assets/TokenDetails.jsx b/wallet/symbol/mobile/src/screens/assets/TokenDetails.jsx index bad2c50f9c..8f081d43e8 100644 --- a/wallet/symbol/mobile/src/screens/assets/TokenDetails.jsx +++ b/wallet/symbol/mobile/src/screens/assets/TokenDetails.jsx @@ -1,6 +1,7 @@ import { Alert, Amount, + ButtonPlain, Card, Divider, Field, @@ -21,6 +22,7 @@ import { getExpirationData, getTokenDisplayInfo } from '@/app/screens/assets/uti import { Colors } from '@/app/styles'; import { createTransactionQr } from '@/app/utils'; import React from 'react'; +import { isMosaicRevokable, isMosaicSupplyModifiable } from 'wallet-common-symbol'; /** @typedef {import('@/app/types/Network').ChainName} ChainName */ @@ -39,7 +41,7 @@ import React from 'react'; export const TokenDetails = ({ route }) => { const { chainName, tokenId, accountAddress, preloadedData } = route.params; const walletController = useWalletController(chainName); - const { networkIdentifier, networkProperties } = walletController; + const { currentAccount, networkIdentifier, networkProperties } = walletController; // Fetch data const dataManager = useAsyncManager({ @@ -122,6 +124,16 @@ export const TokenDetails = ({ route }) => { const isSendReceiveButtonsDisabled = isTokenExpired; + // Mosaic creator actions + const isCurrentAccountCreator = accountAddress === currentAccount.address; + const canRevokeMosaic = isCurrentAccountCreator + && isMosaicRevokable(token, networkProperties?.chainHeight, accountAddress); + const canModifyMosaic = isCurrentAccountCreator + && isMosaicSupplyModifiable(token, networkProperties?.chainHeight, accountAddress); + const isCreatorActionsVisible = canRevokeMosaic || canModifyMosaic; + const openRevokeScreen = () => Router.goToRevokeMosaic({ params: { chainName, tokenId } }); + const openModifyScreen = () => Router.goToModifyMosaic({ params: { chainName, tokenId } }); + return ( @@ -198,6 +210,25 @@ export const TokenDetails = ({ route }) => { )} + {isCreatorActionsVisible && ( + + + {canRevokeMosaic && ( + + )} + {canModifyMosaic && ( + + )} + + )} diff --git a/wallet/symbol/mobile/src/screens/index.js b/wallet/symbol/mobile/src/screens/index.js index 84ab0b3108..edfd4b6618 100644 --- a/wallet/symbol/mobile/src/screens/index.js +++ b/wallet/symbol/mobile/src/screens/index.js @@ -42,6 +42,11 @@ export { ModifyMultisigAccount } from './multisig/ModifyMultisigAccount'; // Harvesting export { Harvesting } from './harvesting/Harvesting'; +// Mosaic +export { CreateMosaic } from './mosaic/CreateMosaic'; +export { ModifyMosaic } from './mosaic/ModifyMosaic'; +export { RevokeMosaic } from './mosaic/RevokeMosaic'; + // Transport export { Scan } from './transport/Scan'; export { TransportRequest } from './transport/TransportRequest'; diff --git a/wallet/symbol/mobile/src/screens/mosaic/CreateMosaic.jsx b/wallet/symbol/mobile/src/screens/mosaic/CreateMosaic.jsx new file mode 100644 index 0000000000..daec434cf0 --- /dev/null +++ b/wallet/symbol/mobile/src/screens/mosaic/CreateMosaic.jsx @@ -0,0 +1,324 @@ +import { + Button, + Checkbox, + Divider, + FeeSelector, + SelectTransactionSender, + Spacer, + Stack, + StyledText, + TextBox, + TransactionScreenTemplate +} from '@/app/components'; +import { useStandardTransactionWorkflow } from '@/app/components/templates/TransactionScreenTemplate/hooks'; +import { + useDebounce, + useInit, + useTransactionFees, + useTransactionSender, + useValidation, + useWalletController, + useWalletRefreshLifecycle +} from '@/app/hooks'; +import { $t } from '@/app/localization'; +import { Router } from '@/app/router/Router'; +import { + ExpirationSummaryCard, + InputDuration, + MosaicFlagList, + MosaicPreviewCard, + SelectDivisibility +} from '@/app/screens/mosaic/components'; +import { useCreateMosaicFormState, useMosaicIdentity, useMosaicTransaction } from '@/app/screens/mosaic/hooks'; +import { + getDefaultDurationInputValue, + parseDurationBlocks, + validateMosaicDuration, + validateMosaicSupply +} from '@/app/screens/mosaic/utils'; +import { validateRequired } from '@/app/utils'; +import React, { useEffect } from 'react'; +import Animated, { FadeInDown, FadeInUp, FadeOut, FadeOutUp, withDelay, withTiming } from 'react-native-reanimated'; + + +const ENTERING_ANIMATION_DELAY = 250; +const LAYOUT_ANIMATION_DURATION = 300; +const LAYOUT_ANIMATION_DELAY = 250; + +const CustomLayout = values => { + 'worklet'; + const isMovingDown = values.currentOriginY < values.targetOriginY; + const timingConfig = { duration: LAYOUT_ANIMATION_DURATION }; + + return { + animations: { + originY: isMovingDown + ? withTiming(values.targetOriginY, timingConfig) + : withDelay(LAYOUT_ANIMATION_DELAY, withTiming(values.targetOriginY, timingConfig)) + }, + initialValues: { + originY: values.currentOriginY + } + }; +}; + +/** + * CreateMosaic screen component. Provides the interface for creating a new mosaic (token) + * on the Symbol network by configuring the divisibility, initial supply, duration and mosaic flags, + * on behalf of the current account. + * @returns {React.ReactNode} CreateMosaic component. + */ +export const CreateMosaic = () => { + const walletController = useWalletController(); + const { + currentAccount, + isWalletReady, + isNetworkConnectionReady, + networkProperties, + networkIdentifier, + chainName, + ticker + } = walletController; + const currentAccountInfo = walletController.currentAccountInfo || {}; + const walletAccounts = walletController.accounts[networkIdentifier]; + + // Transaction sender + const { + options: senderOptions, + value: senderAddress, + changeValue: changeSenderAddress, + load: loadSenderOptions, + reset: resetSenderOptions + } = useTransactionSender(walletController); + useWalletRefreshLifecycle({ walletController, onRefresh: loadSenderOptions, onClear: resetSenderOptions }); + useInit(loadSenderOptions, isWalletReady); + + // Form state + const { + divisibility, + supply, + duration, + isNeverExpiring, + flags, + transactionSpeed, + changeDivisibility, + changeSupply, + changeDuration, + toggleNeverExpiring, + toggleFlag, + changeTransactionSpeed, + reset: resetForm + } = useCreateMosaicFormState(); + + // Validation + const supplyErrorMessage = useValidation(supply, [validateRequired(), validateMosaicSupply(divisibility)], $t); + const durationValidationMessage = useValidation(duration, [validateRequired(), validateMosaicDuration()], $t); + const durationErrorMessage = isNeverExpiring ? undefined : durationValidationMessage; + const isFormValid = !supplyErrorMessage && !durationErrorMessage; + + // Mosaic identity. The nonce is generated once so the derived mosaic id stays stable across the create flow. + const { nonce, mosaicId, regenerate: regenerateMosaicIdentity } = useMosaicIdentity(currentAccount.address); + + // Transaction creation and preview + const { createMosaicTransaction, getConfirmationPreview } = useMosaicTransaction({ + walletController, + nonce, + supply, + divisibility, + duration, + isNeverExpiring, + flags + }); + + // Transaction fees + const { + data: transactionFees, + isLoading: isFeesLoading, + call: fetchFees + } = useTransactionFees(createMosaicTransaction, walletController); + const calculateFeesSafely = useDebounce(fetchFees, 1000); + useEffect(() => { + if (isWalletReady && isFormValid) + calculateFeesSafely(); + }, [isWalletReady, isFormValid, divisibility, supply, duration, isNeverExpiring, flags]); + + // Derived state + const blockGenerationTargetTime = networkProperties?.blockGenerationTargetTime; + const isButtonDisabled = !isNetworkConnectionReady + || !isFormValid + || isFeesLoading + || !transactionFees; + + // Handlers + const handleExpiryToggle = isExpiring => { + toggleNeverExpiring(); + + // Marking the token as expiring with no value yet pre-fills a safe one-year lifetime. + if (isExpiring && parseDurationBlocks(duration) === null) + changeDuration(getDefaultDurationInputValue(blockGenerationTargetTime)); + }; + + const handleTransactionSendComplete = () => { + resetForm(); + regenerateMosaicIdentity(); + Router.goToHome(); + }; + + // Transaction Workflow + const workflow = useStandardTransactionWorkflow({ + createTransaction: createMosaicTransaction, + walletController, + transactionFeeTiers: transactionFees, + transactionFeeTierLevel: transactionSpeed + }); + + return ( + + {buttonProps => ( + + + {/* Title and description */} + + + {$t('s_mosaicCreation_mosaic_title')} + + + {$t('s_mosaicCreation_mosaic_description')} + + + + {/* Creator section */} + + + {$t('s_mosaicCreation_sender_title')} + + + + + + + {/* Quantity section */} + + + + {$t('s_mosaicCreation_quantity_title')} + + + {$t('s_mosaicCreation_quantity_description')} + + + + + + + + + + + + {/* Duration section */} + + + + {$t('s_mosaicCreation_duration_title')} + + + {$t('s_mosaicCreation_duration_description')} + + + + + {!isNeverExpiring && ( + + + + )} + + + {/* Flags section */} + + + + + + {$t('s_mosaicCreation_flags_title')} + + + + + + + {/* Fee selector */} + {!!transactionFees && ( + + + + )} + +