From 48e42c5b2c7eeb15166feef5804aeeffeb03e034 Mon Sep 17 00:00:00 2001 From: OlegMakarenko Date: Fri, 3 Jul 2026 18:16:30 +0200 Subject: [PATCH 01/19] [wallet/common/symbol] feat: add TokenModule --- wallet/common/symbol/src/api/MosaicService.js | 65 ++-- wallet/common/symbol/src/constants/index.js | 13 +- .../common/symbol/src/modules/TokenModule.js | 254 ++++++++++++++ wallet/common/symbol/src/modules/index.js | 1 + wallet/common/symbol/src/utils/mosaic.js | 41 +++ .../symbol/tests/api/MosaicServise.test.js | 20 ++ wallet/common/symbol/tests/index.test.js | 1 + .../symbol/tests/modules/TokenModule.test.js | 316 ++++++++++++++++++ .../common/symbol/tests/modules/index.test.js | 7 +- .../common/symbol/tests/utils/mosaic.test.js | 39 ++- 10 files changed, 716 insertions(+), 41 deletions(-) create mode 100644 wallet/common/symbol/src/modules/TokenModule.js create mode 100644 wallet/common/symbol/tests/modules/TokenModule.test.js diff --git a/wallet/common/symbol/src/api/MosaicService.js b/wallet/common/symbol/src/api/MosaicService.js index 2a90077c62..e30da791c8 100644 --- a/wallet/common/symbol/src/api/MosaicService.js +++ b/wallet/common/symbol/src/api/MosaicService.js @@ -1,15 +1,10 @@ -import { - addressFromRaw, - isRestrictableFlag, - isRevokableFlag, - isSupplyMutableFlag, - isTransferableFlag -} from '../utils'; +import { createSearchUrl, 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/Network').NetworkProperties} NetworkProperties */ +/** @typedef {import('../types/SearchCriteria').SearchCriteria} SearchCriteria */ export class MosaicService { #api; @@ -53,34 +48,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 +85,26 @@ 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] || [] + })); + }; } 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..c940b34756 --- /dev/null +++ b/wallet/common/symbol/src/modules/TokenModule.js @@ -0,0 +1,254 @@ +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/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 {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 = 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); + }; + + /** + * 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/utils/mosaic.js b/wallet/common/symbol/src/utils/mosaic.js index 85d972e727..784ea0c050 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. diff --git a/wallet/common/symbol/tests/api/MosaicServise.test.js b/wallet/common/symbol/tests/api/MosaicServise.test.js index d3f090d7dd..a0a971615b 100644 --- a/wallet/common/symbol/tests/api/MosaicServise.test.js +++ b/wallet/common/symbol/tests/api/MosaicServise.test.js @@ -64,4 +64,24 @@ describe('MosaicService', () => { expect(result).toStrictEqual(expectedResult); }); }); + + describe('fetchAccountMosaics', () => { + it('fetches the mosaics created by an account', async () => { + // Arrange: + const creatorAddress = 'TAWGTICRU4V7XYY25WTSKCWGY5D3OVYLH2OABNQ'; + mockMakeRequest.mockResolvedValueOnce({ data: mosaicInfosResponse }); + mockApi.namespace.fetchMosaicNames.mockResolvedValueOnce(mosaicNames); + const expectedResult = Object.values(mosaicInfos); + const expectedEndpoint = + `${networkProperties.nodeUrl}/mosaics?pageNumber=1&pageSize=100&order=desc&ownerAddress=${creatorAddress}`; + + // Act: + const result = await mosaicService.fetchAccountMosaics(networkProperties, creatorAddress); + + // Assert: + expect(mockMakeRequest).toHaveBeenCalledWith(expectedEndpoint); + expect(mockApi.namespace.fetchMosaicNames).toHaveBeenCalledWith(networkProperties, Object.keys(mosaicInfos)); + expect(result).toStrictEqual(expectedResult); + }); + }); }); 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..dd979406f2 --- /dev/null +++ b/wallet/common/symbol/tests/modules/TokenModule.test.js @@ -0,0 +1,316 @@ +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 { 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 FIXED_NOW_MS = 1_700_000_000_000; +const TOKEN_ID = '78C3CDF0896248DB'; + +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 SENDER = { + currentAccount: { publicKey: currentAccount.publicKey, isMultisig: false }, + multisigAccount: { publicKey: multisigAccount.publicKey, isMultisig: true } +}; + +const withSender = (options, sender) => + (sender.isMultisig ? { ...options, senderPublicKey: sender.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(() => { + api = { + mosaic: { + fetchAccountMosaics: jest.fn() + } + }; + + walletController = { + currentAccount, + networkProperties, + networkIdentifier: networkProperties.networkIdentifier + }; + + tokenModule = new TokenModule(); + tokenModule.init({ walletController, api }); + jest.spyOn(Date, 'now').mockReturnValue(FIXED_NOW_MS); + jest.clearAllMocks(); + }); + + 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 + initial supply change signed by the sender, 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 = sender => { + // Act: + const result = tokenModule.createTransaction(withSender(createOptions, sender)); + + // Assert: the nonce is generated internally, so the expected mosaic id is derived from the result. + const { nonce } = extractInnerTransactions(result, sender.isMultisig)[0]; + const expectedInner = buildExpectedInnerTransactions(sender.publicKey, nonce); + const expectedResult = sender.isMultisig + ? buildMultisigBundle(expectedInner, TransactionBundleType.MULTISIG_TOKEN_CREATION) + : buildAggregateCompleteBundle(expectedInner, sender.publicKey, TransactionBundleType.TOKEN_CREATION); + + expectBundlesEqual(result, expectedResult); + }; + + it('creates an aggregate complete bundle with definition and initial supply for the current account', () => { + runCreateTransactionTest(SENDER.currentAccount); + }); + + it('creates an aggregate bonded + hash lock bundle for a multisig account', () => { + runCreateTransactionTest(SENDER.multisigAccount); + }); + }); + + describe('createSupplyChangeTransaction()', () => { + const supplyChangeOptions = { + mosaicId: TOKEN_ID, + divisibility: 2, + delta: '5', + action: MosaicSupplyChangeAction.Decrease + }; + + const buildExpectedTransaction = senderPublicKey => ({ + type: TransactionType.MOSAIC_SUPPLY_CHANGE, + signerPublicKey: senderPublicKey, + signerAddress: resolveSenderAddress(senderPublicKey), + mosaicId: supplyChangeOptions.mosaicId, + action: MosaicSupplyChangeActionMessage[MosaicSupplyChangeAction.Decrease], + delta: relativeToAbsoluteAmount(supplyChangeOptions.delta, supplyChangeOptions.divisibility) + }); + + const runSupplyChangeTest = sender => { + // Act: + const result = tokenModule.createSupplyChangeTransaction(withSender(supplyChangeOptions, sender)); + + // Assert: + const expectedTransaction = buildExpectedTransaction(sender.publicKey); + const expectedResult = sender.isMultisig + ? buildMultisigBundle([expectedTransaction], TransactionBundleType.MULTISIG_TOKEN_SUPPLY_CHANGE) + : buildSingleAccountBundle(expectedTransaction, TransactionBundleType.TOKEN_SUPPLY_CHANGE); + + expectBundlesEqual(result, expectedResult); + }; + + it('creates a bare supply change transaction for the current account', () => { + runSupplyChangeTest(SENDER.currentAccount); + }); + + it('creates an aggregate bonded + hash lock bundle for a multisig account', () => { + runSupplyChangeTest(SENDER.multisigAccount); + }); + }); + + describe('createRevocationTransaction()', () => { + const revocationOptions = { + mosaicId: TOKEN_ID, + divisibility: 2, + amount: '2.5', + sourceAddress: holderAccount.address + }; + + const buildExpectedTransaction = senderPublicKey => ({ + type: TransactionType.MOSAIC_SUPPLY_REVOCATION, + signerPublicKey: senderPublicKey, + signerAddress: resolveSenderAddress(senderPublicKey), + mosaic: { + id: revocationOptions.mosaicId, + amount: revocationOptions.amount, + divisibility: revocationOptions.divisibility + }, + sourceAddress: revocationOptions.sourceAddress + }); + + const runRevocationTest = sender => { + // Act: + const result = tokenModule.createRevocationTransaction(withSender(revocationOptions, sender)); + + // Assert: + const expectedTransaction = buildExpectedTransaction(sender.publicKey); + const expectedResult = sender.isMultisig + ? buildMultisigBundle([expectedTransaction], TransactionBundleType.MULTISIG_TOKEN_REVOCATION) + : buildSingleAccountBundle(expectedTransaction, TransactionBundleType.TOKEN_REVOCATION); + + expectBundlesEqual(result, expectedResult); + }; + + it('creates a bare revocation transaction for the current account', () => { + runRevocationTest(SENDER.currentAccount); + }); + + it('creates an aggregate bonded + hash lock bundle for a multisig account', () => { + runRevocationTest(SENDER.multisigAccount); + }); + }); + + describe('fetchAccountTokens()', () => { + const runFetchAccountTokensTest = async (config, expected) => { + // Arrange: + const expectedTokens = [{ id: TOKEN_ID }]; + api.mosaic.fetchAccountMosaics.mockResolvedValue(expectedTokens); + + // Act: + const result = await tokenModule.fetchAccountTokens(config.address, config.searchCriteria); + + // Assert: + expect(api.mosaic.fetchAccountMosaics).toHaveBeenCalledWith( + networkProperties, + expected.address, + config.searchCriteria + ); + expect(result).toBe(expectedTokens); + }; + + it('fetches created tokens for the current account by default', async () => { + await runFetchAccountTokensTest({}, { address: currentAccount.address }); + }); + + it('fetches created tokens for a given address with search criteria', async () => { + await runFetchAccountTokensTest( + { address: holderAccount.address, searchCriteria: { pageNumber: 2, pageSize: 10 } }, + { address: holderAccount.address } + ); + }); + }); + + describe('calculateTransactionFees()', () => { + it('returns a fee tier entry for each transaction in the bundle', async () => { + // Arrange: + const bundle = tokenModule.createSupplyChangeTransaction({ + mosaicId: TOKEN_ID, + divisibility: 2, + delta: '5', + action: MosaicSupplyChangeAction.Decrease + }); + const expectedResult = bundle.transactions.map(transaction => + createTransactionFeeTiers(networkProperties, calculateTransactionSize(networkProperties.networkIdentifier, transaction))); + + // Act: + const result = await tokenModule.calculateTransactionFees(bundle); + + // Assert: + expect(result).toStrictEqual(expectedResult); + }); + }); +}); 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); + }); + }); }); From 7b408d5221e23ba31f893fc524004c39cdd52085 Mon Sep 17 00:00:00 2001 From: OlegMakarenko Date: Fri, 3 Jul 2026 20:03:00 +0200 Subject: [PATCH 02/19] [wallet/symbol/mobile] feat: add CreateMosaic screen --- wallet/symbol/mobile/__tests__/Router.test.js | 6 + .../screens/mosaic/CreateMosaic.test.js | 637 ++++++++++++++++++ .../mosaic/utils/mosaic-display.test.js | 42 ++ .../screens/mosaic/utils/validators.test.js | 128 ++++ .../src/lib/controller/symbol/controller.js | 5 +- .../mobile/src/localization/locales/cn.json | 2 +- .../mobile/src/localization/locales/en.json | 9 +- .../mobile/src/localization/locales/ja.json | 2 +- .../mobile/src/localization/locales/ko.json | 2 +- .../mobile/src/localization/locales/uk.json | 2 +- .../mobile/src/localization/locales/zh.json | 2 +- wallet/symbol/mobile/src/router/Router.js | 3 + .../symbol/mobile/src/router/RouterView.jsx | 1 + wallet/symbol/mobile/src/router/config.js | 1 + .../mobile/src/screens/actions/Actions.jsx | 6 + wallet/symbol/mobile/src/screens/index.js | 3 + .../src/screens/mosaic/CreateMosaic.jsx | 278 ++++++++ .../mosaic/components/MosaicFlagList.jsx | 67 ++ .../src/screens/mosaic/components/index.js | 1 + .../src/screens/mosaic/constants/index.js | 29 + .../mobile/src/screens/mosaic/hooks/index.js | 2 + .../mosaic/hooks/useCreateMosaicFormState.js | 93 +++ .../mosaic/hooks/useMosaicTransaction.js | 127 ++++ .../mobile/src/screens/mosaic/types/Mosaic.js | 15 + .../mobile/src/screens/mosaic/utils/index.js | 2 + .../screens/mosaic/utils/mosaic-display.js | 10 + .../src/screens/mosaic/utils/validators.js | 43 ++ 27 files changed, 1510 insertions(+), 8 deletions(-) create mode 100644 wallet/symbol/mobile/__tests__/screens/mosaic/CreateMosaic.test.js create mode 100644 wallet/symbol/mobile/__tests__/screens/mosaic/utils/mosaic-display.test.js create mode 100644 wallet/symbol/mobile/__tests__/screens/mosaic/utils/validators.test.js create mode 100644 wallet/symbol/mobile/src/screens/mosaic/CreateMosaic.jsx create mode 100644 wallet/symbol/mobile/src/screens/mosaic/components/MosaicFlagList.jsx create mode 100644 wallet/symbol/mobile/src/screens/mosaic/components/index.js create mode 100644 wallet/symbol/mobile/src/screens/mosaic/constants/index.js create mode 100644 wallet/symbol/mobile/src/screens/mosaic/hooks/index.js create mode 100644 wallet/symbol/mobile/src/screens/mosaic/hooks/useCreateMosaicFormState.js create mode 100644 wallet/symbol/mobile/src/screens/mosaic/hooks/useMosaicTransaction.js create mode 100644 wallet/symbol/mobile/src/screens/mosaic/types/Mosaic.js create mode 100644 wallet/symbol/mobile/src/screens/mosaic/utils/index.js create mode 100644 wallet/symbol/mobile/src/screens/mosaic/utils/mosaic-display.js create mode 100644 wallet/symbol/mobile/src/screens/mosaic/utils/validators.js diff --git a/wallet/symbol/mobile/__tests__/Router.test.js b/wallet/symbol/mobile/__tests__/Router.test.js index 9e545d7ea9..e07612ac6d 100644 --- a/wallet/symbol/mobile/__tests__/Router.test.js +++ b/wallet/symbol/mobile/__tests__/Router.test.js @@ -88,6 +88,7 @@ jest.mock('@/app/screens', () => { CreateMultisigAccount: createMockScreen('CreateMultisigAccount'), ModifyMultisigAccount: createMockScreen('ModifyMultisigAccount'), Harvesting: createMockScreen('Harvesting'), + CreateMosaic: createMockScreen('CreateMosaic'), Scan: createMockScreen('Scan'), TransportRequest: createMockScreen('TransportRequest'), Send: createMockScreen('Send'), @@ -222,6 +223,11 @@ const NAVIGATION_SCREENS_CONFIG = [ screenName: 'Harvesting', shouldReset: false, hasParams: true + }, + { + screenName: 'CreateMosaic', + shouldReset: false, + hasParams: true } ]; 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..0e2e246734 --- /dev/null +++ b/wallet/symbol/mobile/__tests__/screens/mosaic/CreateMosaic.test.js @@ -0,0 +1,637 @@ +import { CreateMosaic } from '@/app/screens/mosaic/CreateMosaic'; +import { AccountFixtureBuilder } from '__fixtures__/local/AccountFixtureBuilder'; +import { AccountInfoFixtureBuilder } from '__fixtures__/local/AccountInfoFixtureBuilder'; +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_DIVISIBILITY = '2'; +const VALID_SUPPLY = '1000'; +const VALID_DURATION = '2880'; + +// Screen Text + +const SCREEN_TEXT = { + // Screen titles + textScreenTitle: 's_mosaicCreation_mosaic_title', + textScreenDescription: 's_mosaicCreation_mosaic_description', + + // Sender section + textSenderTitle: 's_mosaicCreation_sender_title', + senderTabCurrentAccount: 'c_selectTransactionSender_currentAccount', + senderTabMultisigAccount: 'c_selectTransactionSender_multisigAccount', + + // Input sections + textDivisibilityTitle: 's_mosaicCreation_divisibility_title', + textDivisibilityDescription: 's_mosaicCreation_divisibility_description', + textSupplyTitle: 's_mosaicCreation_supply_title', + textSupplyDescription: 's_mosaicCreation_supply_description', + textDurationTitle: 's_mosaicCreation_duration_title', + textDurationDescription: 's_mosaicCreation_duration_description', + textDurationDaysHint: 's_mosaicCreation_durationDays', + + // Flag sections + 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', + + // Checkboxes + checkboxNeverExpire: 's_mosaicCreation_duration_checkbox', + checkboxSupplyMutable: 's_mosaicCreation_supplyMutable_checkbox', + checkboxTransferable: 's_mosaicCreation_transferable_checkbox', + checkboxRestrictable: 's_mosaicCreation_restrictable_checkbox', + checkboxRevokable: 's_mosaicCreation_revokable_checkbox', + + // Input labels + inputDivisibility: 'input_divisibility', + inputSupply: 'input_supply', + inputDuration: 'input_duration', + + // Buttons + buttonSend: 'button_send', + buttonConfirm: 'button_confirm', + + // Dialog + textDialogConfirmTitle: 's_mosaicCreation_confirm_title', + textDialogConfirmText: 's_mosaicCreation_confirm_text', + + // Validation errors + errorRequired: 'validation_error_field_required', + errorDivisibility: 'validation_error_mosaic_divisibility', + errorSupply: 'validation_error_mosaic_supply', + errorDuration: 'validation_error_mosaic_duration' +}; + +const ALL_VALIDATION_ERRORS = [ + SCREEN_TEXT.errorRequired, + SCREEN_TEXT.errorDivisibility, + SCREEN_TEXT.errorSupply, + SCREEN_TEXT.errorDuration +]; + +// Account Fixtures + +const currentAccount = AccountFixtureBuilder + .createWithAccount(CHAIN_NAME, NETWORK_IDENTIFIER, 0) + .build(); + +const multisigAccount = AccountFixtureBuilder + .createWithAccount(CHAIN_NAME, NETWORK_IDENTIFIER, 3) + .build(); + +const multisigAccountInfo = AccountInfoFixtureBuilder + .createWithAccount(CHAIN_NAME, NETWORK_IDENTIFIER, 3) + .setBalance('5000000') + .override({ + address: multisigAccount.address, + publicKey: multisigAccount.publicKey, + isMultisig: true + }) + .build(); + +// Network Properties Fixtures + +const networkProperties = 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 Transaction Bundle + +const mosaicDefinitionTransaction = { + type: 'mosaicDefinition', + signerAddress: currentAccount.address, + mosaicId: MOSAIC_ID, + divisibility: 2, + duration: 0, + isSupplyMutable: true, + isTransferable: true, + isRestrictable: false, + isRevokable: false +}; + +const mosaicSupplyChangeTransaction = { + type: 'mosaicSupplyChange', + signerAddress: currentAccount.address, + mosaicId: MOSAIC_ID, + action: 'Increase', + delta: '100000' +}; + +const mockAggregateTransaction = { + type: 'aggregateComplete', + signerAddress: currentAccount.address, + innerTransactions: [mosaicDefinitionTransaction, mosaicSupplyChangeTransaction], + fee: { token: { amount: '0.1' } } +}; + +const mockTransactionBundle = { + transactions: [mockAggregateTransaction], + applyFeeTier: jest.fn() +}; + +const signedTransactionBundle = { + transactions: [{ hash: 'SIGNED_TX_HASH' }] +}; + +// Expected Transaction Options + +const expectedDefaultTransactionOptions = { + senderPublicKey: undefined, + initialSupply: VALID_SUPPLY, + divisibility: 2, + duration: 0, + isSupplyMutable: true, + isTransferable: true, + isRestrictable: false, + isRevokable: false +}; + +// Token Module Mock Factory + +const createTokenModuleMock = () => ({ + createTransaction: jest.fn().mockReturnValue(mockTransactionBundle) +}); + +// Transfer Module Mock Factory + +const createTransferModuleMock = () => ({ + calculateTransactionFees: jest.fn().mockResolvedValue(transactionFees) +}); + +// Multisig Module Mock Factory + +const createMultisigModuleMock = (multisigAccounts = []) => ({ + multisigAccounts, + fetchData: jest.fn().mockResolvedValue(multisigAccounts) +}); + +// Setup + +const setupMocks = (config = {}) => { + const { multisigAccounts = [] } = config; + + const walletControllerMock = mockWalletController({ + chainName: CHAIN_NAME, + networkIdentifier: NETWORK_IDENTIFIER, + networkProperties, + ticker: TICKER, + currentAccount, + signTransactionBundle: jest.fn().mockResolvedValue(signedTransactionBundle), + announceSignedTransactionBundle: jest.fn().mockResolvedValue({}), + modules: { + token: createTokenModuleMock(), + transfer: createTransferModuleMock(), + multisig: createMultisigModuleMock(multisigAccounts), + addressBook: createAddressBookMock() + } + }); + + mockLocalization(); + + return { walletControllerMock }; +}; + +// Helpers + +const fillValidForm = screenTester => { + screenTester.inputText(SCREEN_TEXT.inputDivisibility, VALID_DIVISIBILITY); + screenTester.inputText(SCREEN_TEXT.inputSupply, VALID_SUPPLY); +}; + +const selectMultisigSender = async screenTester => { + screenTester.pressButton(SCREEN_TEXT.senderTabMultisigAccount); // opens the dropdown + await screenTester.waitForTimer(); + screenTester.pressButton(multisigAccountInfo.address); // selects the multisig account + await screenTester.waitForTimer(); +}; + +describe('screens/mosaic/CreateMosaic', () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.clearAllTimers(); + jest.useRealTimers(); + }); + + describe('render', () => { + it('renders screen text with titles, descriptions, inputs and send button', async () => { + // Arrange: + setupMocks(); + const expectedTexts = [ + SCREEN_TEXT.textScreenTitle, + SCREEN_TEXT.textScreenDescription, + SCREEN_TEXT.textSenderTitle, + SCREEN_TEXT.textDivisibilityTitle, + SCREEN_TEXT.textDivisibilityDescription, + SCREEN_TEXT.inputDivisibility, + SCREEN_TEXT.textSupplyTitle, + SCREEN_TEXT.textSupplyDescription, + SCREEN_TEXT.inputSupply, + SCREEN_TEXT.textDurationTitle, + SCREEN_TEXT.textDurationDescription, + SCREEN_TEXT.inputDuration, + SCREEN_TEXT.checkboxNeverExpire, + SCREEN_TEXT.textSupplyMutableTitle, + SCREEN_TEXT.textSupplyMutableDescription, + SCREEN_TEXT.checkboxSupplyMutable, + SCREEN_TEXT.textTransferableTitle, + SCREEN_TEXT.textTransferableDescription, + SCREEN_TEXT.checkboxTransferable, + SCREEN_TEXT.textRestrictableTitle, + SCREEN_TEXT.textRestrictableDescription, + SCREEN_TEXT.checkboxRestrictable, + SCREEN_TEXT.textRevokableTitle, + SCREEN_TEXT.textRevokableDescription, + SCREEN_TEXT.checkboxRevokable, + SCREEN_TEXT.buttonSend + ]; + + // Act: + const screenTester = new ScreenTester(CreateMosaic); + await screenTester.waitForTimer(); // initial sender options load + + // Assert: + screenTester.expectText(expectedTexts); + }); + + it('renders the default divisibility value', async () => { + // Arrange: + setupMocks(); + + // Act: + const screenTester = new ScreenTester(CreateMosaic); + await screenTester.waitForTimer(); // initial sender options load + + // Assert: + screenTester.expectInputValue('0'); + }); + }); + + describe('sender selector', () => { + const runSenderSelectorTest = (description, config, expected) => { + it(description, async () => { + // Arrange: + setupMocks({ multisigAccounts: config.multisigAccounts }); + + // Act: + const screenTester = new ScreenTester(CreateMosaic); + await screenTester.waitForTimer(); // initial sender options load + + // Assert: + if (expected.hasMultisigTabs) + screenTester.expectText([SCREEN_TEXT.senderTabCurrentAccount, SCREEN_TEXT.senderTabMultisigAccount]); + else + screenTester.notExpectText([SCREEN_TEXT.senderTabCurrentAccount, SCREEN_TEXT.senderTabMultisigAccount]); + }); + }; + + const senderSelectorTests = [ + { + description: 'shows sender tab selector when account is cosignatory of multisig accounts', + config: { multisigAccounts: [multisigAccountInfo] }, + expected: { hasMultisigTabs: true } + }, + { + description: 'shows only the current account when there are no multisig accounts', + config: { multisigAccounts: [] }, + expected: { hasMultisigTabs: false } + } + ]; + + senderSelectorTests.forEach(test => { + runSenderSelectorTest(test.description, test.config, test.expected); + }); + }); + + describe('validation', () => { + const runValidationTest = (description, config, expected) => { + it(description, async () => { + // Arrange: + setupMocks(); + const screenTester = new ScreenTester(CreateMosaic); + await screenTester.waitForTimer(); // initial sender options load + fillValidForm(screenTester); + + // Act: + if (config.isNeverExpireUnchecked) + screenTester.pressButton(SCREEN_TEXT.checkboxNeverExpire); + screenTester.inputText(config.inputLabel, config.value); + + // Assert: + if (expected.errorText) + screenTester.expectText([expected.errorText]); + else + screenTester.notExpectText(ALL_VALIDATION_ERRORS); + + if (expected.inputValue) + screenTester.expectInputValue(expected.inputValue); + }); + }; + + const validationTests = [ + { + description: 'shows divisibility error when divisibility is above the maximum', + config: { inputLabel: SCREEN_TEXT.inputDivisibility, value: '7' }, + expected: { errorText: SCREEN_TEXT.errorDivisibility } + }, + { + description: 'shows required error when divisibility is cleared', + config: { inputLabel: SCREEN_TEXT.inputDivisibility, value: '' }, + expected: { errorText: SCREEN_TEXT.errorRequired } + }, + { + description: 'shows no error when divisibility is at the maximum', + config: { inputLabel: SCREEN_TEXT.inputDivisibility, value: '6' }, + expected: { errorText: null } + }, + { + description: 'shows supply error when supply is below the minimum', + config: { inputLabel: SCREEN_TEXT.inputSupply, value: '0' }, + expected: { errorText: SCREEN_TEXT.errorSupply } + }, + { + description: 'shows supply error when supply is above the maximum', + config: { inputLabel: SCREEN_TEXT.inputSupply, value: '10000000000' }, + expected: { errorText: SCREEN_TEXT.errorSupply } + }, + { + description: 'shows no error when supply is at the maximum', + config: { inputLabel: SCREEN_TEXT.inputSupply, value: '9999999999' }, + expected: { errorText: null } + }, + { + description: 'keeps only digits in a numeric input and shows no error', + config: { inputLabel: SCREEN_TEXT.inputSupply, value: '12a3' }, + expected: { errorText: null, inputValue: '123' } + }, + { + description: 'shows required error when duration is empty and never expire is unchecked', + config: { inputLabel: SCREEN_TEXT.inputDuration, value: '', isNeverExpireUnchecked: true }, + expected: { errorText: SCREEN_TEXT.errorRequired } + }, + { + description: 'shows duration error when duration is above the maximum', + config: { inputLabel: SCREEN_TEXT.inputDuration, value: '10512001', isNeverExpireUnchecked: true }, + expected: { errorText: SCREEN_TEXT.errorDuration } + }, + { + description: 'shows no error when duration is valid and never expire is unchecked', + config: { inputLabel: SCREEN_TEXT.inputDuration, value: VALID_DURATION, isNeverExpireUnchecked: true }, + expected: { errorText: null } + } + ]; + + validationTests.forEach(test => { + runValidationTest(test.description, test.config, test.expected); + }); + }); + + describe('duration', () => { + it('shows the approximate duration in days when a valid duration is entered', async () => { + // Arrange: + setupMocks(); + const screenTester = new ScreenTester(CreateMosaic); + await screenTester.waitForTimer(); // initial sender options load + + // Act: + screenTester.pressButton(SCREEN_TEXT.checkboxNeverExpire); + screenTester.inputText(SCREEN_TEXT.inputDuration, VALID_DURATION); + + // Assert: + screenTester.expectText([SCREEN_TEXT.textDurationDaysHint]); + }); + + it('hides the days hint when never expire is checked', async () => { + // Arrange: + setupMocks(); + + // Act: + const screenTester = new ScreenTester(CreateMosaic); + await screenTester.waitForTimer(); // initial sender options load + + // Assert: + screenTester.notExpectText([SCREEN_TEXT.textDurationDaysHint]); + }); + + it('creates the transaction with the entered duration when never expire is unchecked', async () => { + // Arrange: + const { walletControllerMock } = setupMocks(); + const screenTester = new ScreenTester(CreateMosaic); + await screenTester.waitForTimer(); // initial sender options load + + // Act: + screenTester.pressButton(SCREEN_TEXT.checkboxNeverExpire); + screenTester.inputText(SCREEN_TEXT.inputDuration, VALID_DURATION); + fillValidForm(screenTester); + await screenTester.waitForTimer(); // fee calculation + + // Assert: + expect(walletControllerMock.modules.token.createTransaction).toHaveBeenCalledWith(expect.objectContaining({ duration: 2880 })); + }); + }); + + describe('fee calculation', () => { + it('calculates fees with the entered mosaic parameters when the form is valid', async () => { + // Arrange: + const { walletControllerMock } = setupMocks(); + const screenTester = new ScreenTester(CreateMosaic); + await screenTester.waitForTimer(); // initial sender options load + + // Act: + fillValidForm(screenTester); + await screenTester.waitForTimer(); // fee calculation + + // Assert: + expect(walletControllerMock.modules.token.createTransaction).toHaveBeenCalledWith(expectedDefaultTransactionOptions); + expect(walletControllerMock.modules.transfer.calculateTransactionFees).toHaveBeenCalledWith(mockTransactionBundle); + }); + + it('does not calculate fees when the form is invalid', async () => { + // Arrange: + const { walletControllerMock } = setupMocks(); + + // Act: + const screenTester = new ScreenTester(CreateMosaic); + await screenTester.waitForTimer(); // initial sender options load + await screenTester.waitForTimer(); // would-be fee calculation + + // Assert: + expect(walletControllerMock.modules.token.createTransaction).not.toHaveBeenCalled(); + expect(walletControllerMock.modules.transfer.calculateTransactionFees).not.toHaveBeenCalled(); + }); + }); + + describe('flags', () => { + const runFlagToggleTest = (description, config, expected) => { + it(description, async () => { + // Arrange: + const { walletControllerMock } = setupMocks(); + const screenTester = new ScreenTester(CreateMosaic); + await screenTester.waitForTimer(); // initial sender options load + + // Act: + screenTester.pressButton(config.checkboxText); + fillValidForm(screenTester); + await screenTester.waitForTimer(); // fee calculation + + // Assert: + const { createTransaction } = walletControllerMock.modules.token; + expect(createTransaction).toHaveBeenCalledWith(expect.objectContaining(expected.transactionOptions)); + }); + }; + + const flagToggleTests = [ + { + description: 'creates the transaction with supply mutable disabled when its checkbox is unchecked', + config: { checkboxText: SCREEN_TEXT.checkboxSupplyMutable }, + expected: { transactionOptions: { isSupplyMutable: false } } + }, + { + description: 'creates the transaction with transferable disabled when its checkbox is unchecked', + config: { checkboxText: SCREEN_TEXT.checkboxTransferable }, + expected: { transactionOptions: { isTransferable: false } } + }, + { + description: 'creates the transaction with restrictable enabled when its checkbox is checked', + config: { checkboxText: SCREEN_TEXT.checkboxRestrictable }, + expected: { transactionOptions: { isRestrictable: true } } + }, + { + description: 'creates the transaction with revokable enabled when its checkbox is checked', + config: { checkboxText: SCREEN_TEXT.checkboxRevokable }, + expected: { transactionOptions: { isRevokable: true } } + } + ]; + + flagToggleTests.forEach(test => { + runFlagToggleTest(test.description, test.config, test.expected); + }); + }); + + describe('send button availability', () => { + const runSendButtonTest = (description, config, expected) => { + it(description, async () => { + // Arrange: + setupMocks(); + const screenTester = new ScreenTester(CreateMosaic); + await screenTester.waitForTimer(); // initial sender options load + + // Act: + config.actions(screenTester); + await screenTester.waitForTimer(); // fee calculation + + // Assert: + if (expected.isDisabled) + screenTester.expectButtonDisabled(SCREEN_TEXT.buttonSend); + else + screenTester.expectButtonEnabled(SCREEN_TEXT.buttonSend); + }); + }; + + const sendButtonTests = [ + { + description: 'send button is disabled when the form is empty', + config: { + actions: () => {} + }, + expected: { + isDisabled: true + } + }, + { + description: 'send button is enabled when the form is valid and fees are loaded', + config: { + actions: screenTester => fillValidForm(screenTester) + }, + expected: { + isDisabled: false + } + }, + { + description: 'send button is disabled when a field is invalid', + config: { + actions: screenTester => { + fillValidForm(screenTester); + screenTester.inputText(SCREEN_TEXT.inputDivisibility, '9'); + } + }, + expected: { + isDisabled: true + } + } + ]; + + sendButtonTests.forEach(test => { + runSendButtonTest(test.description, test.config, test.expected); + }); + }); + + describe('send transaction flow', () => { + it('sends transaction when send button is pressed and confirmed', async () => { + // Arrange: + const { walletControllerMock } = setupMocks(); + mockPasscode(); + mockRouter({ goToHome: jest.fn() }); + const screenTester = new ScreenTester(CreateMosaic); + await screenTester.waitForTimer(); // initial sender options load + fillValidForm(screenTester); + await screenTester.waitForTimer(); // fee calculation + + // Act: + screenTester.pressButton(SCREEN_TEXT.buttonSend); + await screenTester.waitForTimer(); // dialog + screenTester.expectText([SCREEN_TEXT.textDialogConfirmTitle, SCREEN_TEXT.textDialogConfirmText]); + screenTester.pressButton(SCREEN_TEXT.buttonConfirm); + await screenTester.waitForTimer(); // passcode + await screenTester.waitForTimer(); // sign + await screenTester.waitForTimer(); // announce + + // Assert: + expect(walletControllerMock.modules.token.createTransaction).toHaveBeenCalledWith(expectedDefaultTransactionOptions); + expect(walletControllerMock.signTransactionBundle).toHaveBeenCalledWith(mockTransactionBundle); + expect(walletControllerMock.announceSignedTransactionBundle).toHaveBeenCalledWith(signedTransactionBundle); + }); + }); + + describe('multisig sender integration', () => { + it('creates the mosaic with the selected multisig account as creator', async () => { + // Arrange: + const { walletControllerMock } = setupMocks({ multisigAccounts: [multisigAccountInfo] }); + const screenTester = new ScreenTester(CreateMosaic); + await screenTester.waitForTimer(); // initial sender options load + + // Act: + await selectMultisigSender(screenTester); + fillValidForm(screenTester); + await screenTester.waitForTimer(); // fee calculation + + // Assert: + const { createTransaction } = walletControllerMock.modules.token; + const expectedOptions = expect.objectContaining({ senderPublicKey: multisigAccountInfo.publicKey }); + expect(createTransaction).toHaveBeenCalledWith(expectedOptions); + }); + }); +}); diff --git a/wallet/symbol/mobile/__tests__/screens/mosaic/utils/mosaic-display.test.js b/wallet/symbol/mobile/__tests__/screens/mosaic/utils/mosaic-display.test.js new file mode 100644 index 0000000000..d9af7ea69d --- /dev/null +++ b/wallet/symbol/mobile/__tests__/screens/mosaic/utils/mosaic-display.test.js @@ -0,0 +1,42 @@ +import { calculateMosaicDurationDays } from '@/app/screens/mosaic/utils'; + +describe('screens/mosaic/utils/mosaic-display', () => { + describe('calculateMosaicDurationDays', () => { + const runCalculateDurationDaysTest = (description, config, expected) => { + it(description, () => { + // Act: + const result = calculateMosaicDurationDays(config.duration, config.blockGenerationTargetTime); + + // Assert: + expect(result).toBe(expected.result); + }); + }; + + const calculateDurationDaysTests = [ + { + description: 'returns one day for a day worth of 30 second blocks', + config: { duration: '2880', blockGenerationTargetTime: '30' }, + expected: { result: 1 } + }, + { + description: 'returns the maximum rental period for the maximum duration', + config: { duration: 10512000, blockGenerationTargetTime: 30 }, + expected: { result: 3650 } + }, + { + description: 'returns zero for a zero duration', + config: { duration: '0', blockGenerationTargetTime: '30' }, + expected: { result: 0 } + }, + { + description: 'rounds the result to the nearest day', + config: { duration: '100', blockGenerationTargetTime: '15' }, + expected: { result: 0 } + } + ]; + + calculateDurationDaysTests.forEach(test => { + runCalculateDurationDaysTest(test.description, test.config, test.expected); + }); + }); +}); diff --git a/wallet/symbol/mobile/__tests__/screens/mosaic/utils/validators.test.js b/wallet/symbol/mobile/__tests__/screens/mosaic/utils/validators.test.js new file mode 100644 index 0000000000..c85b36300c --- /dev/null +++ b/wallet/symbol/mobile/__tests__/screens/mosaic/utils/validators.test.js @@ -0,0 +1,128 @@ +import { validateMosaicDivisibility, validateMosaicDuration, validateMosaicSupply } from '@/app/screens/mosaic/utils'; + +describe('screens/mosaic/utils/validators', () => { + const runValidatorTest = (createValidator, description, config, expected) => { + it(description, () => { + // Arrange: + const validate = createValidator(); + + // Act: + const result = validate(config.value); + + // Assert: + expect(result).toBe(expected.result); + }); + }; + + describe('validateMosaicDivisibility', () => { + const validatorTests = [ + { + description: 'passes when value is at the minimum', + config: { value: '0' }, + expected: { result: undefined } + }, + { + description: 'passes when value is at the maximum', + config: { value: '6' }, + expected: { result: undefined } + }, + { + description: 'fails when value is above the maximum', + config: { value: '7' }, + expected: { result: 'validation_error_mosaic_divisibility' } + }, + { + description: 'fails when value is negative', + config: { value: '-1' }, + expected: { result: 'validation_error_mosaic_divisibility' } + }, + { + description: 'fails when value is not an integer', + config: { value: '3.5' }, + expected: { result: 'validation_error_mosaic_divisibility' } + }, + { + description: 'fails when value is not a number', + config: { value: 'abc' }, + expected: { result: 'validation_error_mosaic_divisibility' } + } + ]; + + validatorTests.forEach(test => { + runValidatorTest(validateMosaicDivisibility, test.description, test.config, test.expected); + }); + }); + + describe('validateMosaicSupply', () => { + const validatorTests = [ + { + description: 'passes when value is at the minimum', + config: { value: '1' }, + expected: { result: undefined } + }, + { + description: 'passes when value is at the maximum', + config: { value: '9999999999' }, + expected: { result: undefined } + }, + { + description: 'fails when value is below the minimum', + config: { value: '0' }, + expected: { result: 'validation_error_mosaic_supply' } + }, + { + description: 'fails when value is above the maximum', + config: { value: '10000000000' }, + expected: { result: 'validation_error_mosaic_supply' } + }, + { + description: 'fails when value is not an integer', + config: { value: '1.5' }, + expected: { result: 'validation_error_mosaic_supply' } + }, + { + description: 'fails when value is not a number', + config: { value: 'abc' }, + expected: { result: 'validation_error_mosaic_supply' } + } + ]; + + validatorTests.forEach(test => { + runValidatorTest(validateMosaicSupply, test.description, test.config, test.expected); + }); + }); + + describe('validateMosaicDuration', () => { + const validatorTests = [ + { + description: 'passes when value is at the minimum', + config: { value: '1' }, + expected: { result: undefined } + }, + { + description: 'passes when value is at the maximum', + config: { value: '10512000' }, + expected: { result: undefined } + }, + { + description: 'fails when value is below the minimum', + config: { value: '0' }, + expected: { result: 'validation_error_mosaic_duration' } + }, + { + description: 'fails when value is above the maximum', + config: { value: '10512001' }, + expected: { result: 'validation_error_mosaic_duration' } + }, + { + description: 'fails when value is not a number', + config: { value: 'abc' }, + expected: { result: 'validation_error_mosaic_duration' } + } + ]; + + validatorTests.forEach(test => { + runValidatorTest(validateMosaicDuration, test.description, test.config, test.expected); + }); + }); +}); 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..a64007e85f 100644 --- a/wallet/symbol/mobile/src/localization/locales/cn.json +++ b/wallet/symbol/mobile/src/localization/locales/cn.json @@ -79,7 +79,7 @@ "screen_Welcome": "欢迎", "screen_CreateWallet": "创建钱包", "screen_ImportWallet": "导入钱包", - "screen_MosaicCreation": "创建马赛克", + "screen_CreateMosaic": "创建马赛克", "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..34f7ebc4fe 100644 --- a/wallet/symbol/mobile/src/localization/locales/en.json +++ b/wallet/symbol/mobile/src/localization/locales/en.json @@ -91,7 +91,7 @@ "screen_Welcome": "Welcome", "screen_CreateWallet": "Create Wallet", "screen_ImportWallet": "Import Wallet", - "screen_MosaicCreation": "Create Mosaic", + "screen_CreateMosaic": "Create Mosaic", "screen_Home": "Home", "screen_History": "History", "screen_Assets": "Assets", @@ -508,6 +508,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 +527,10 @@ "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_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.", diff --git a/wallet/symbol/mobile/src/localization/locales/ja.json b/wallet/symbol/mobile/src/localization/locales/ja.json index b1647e49b6..70b3e38617 100644 --- a/wallet/symbol/mobile/src/localization/locales/ja.json +++ b/wallet/symbol/mobile/src/localization/locales/ja.json @@ -79,7 +79,7 @@ "screen_Welcome": "ようこそ", "screen_CreateWallet": "ウォレット作成", "screen_ImportWallet": "ウォレットインポート", - "screen_MosaicCreation": "モザイク作成", + "screen_CreateMosaic": "モザイク作成", "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..63f87235c1 100644 --- a/wallet/symbol/mobile/src/localization/locales/ko.json +++ b/wallet/symbol/mobile/src/localization/locales/ko.json @@ -79,7 +79,7 @@ "screen_Welcome": "환영", "screen_CreateWallet": "지갑 생성", "screen_ImportWallet": "지갑 추가", - "screen_MosaicCreation": "모자이크 생성", + "screen_CreateMosaic": "모자이크 생성", "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..41588f7076 100644 --- a/wallet/symbol/mobile/src/localization/locales/uk.json +++ b/wallet/symbol/mobile/src/localization/locales/uk.json @@ -79,7 +79,7 @@ "screen_Welcome": "Ласкаво просимо", "screen_CreateWallet": "Створити гаманець", "screen_ImportWallet": "Імпортувати гаманець", - "screen_MosaicCreation": "Створити мозаїку", + "screen_CreateMosaic": "Створити мозаїку", "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..a71b1bf5fb 100644 --- a/wallet/symbol/mobile/src/localization/locales/zh.json +++ b/wallet/symbol/mobile/src/localization/locales/zh.json @@ -79,7 +79,7 @@ "screen_Welcome": "歡迎", "screen_CreateWallet": "創建錢包", "screen_ImportWallet": "導入錢包", - "screen_MosaicCreation": "創建馬賽克", + "screen_CreateMosaic": "創建馬賽克", "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..1c99c193a5 100644 --- a/wallet/symbol/mobile/src/router/Router.js +++ b/wallet/symbol/mobile/src/router/Router.js @@ -120,6 +120,9 @@ export class Router { static goToHarvesting(params) { navigationRef.navigate(RouteName.Harvesting, parseNavigationParams(params)); } + static goToCreateMosaic(params) { + navigationRef.navigate(RouteName.CreateMosaic, 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..54be6d5c9e 100644 --- a/wallet/symbol/mobile/src/router/RouterView.jsx +++ b/wallet/symbol/mobile/src/router/RouterView.jsx @@ -96,6 +96,7 @@ 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..dea355128b 100644 --- a/wallet/symbol/mobile/src/router/config.js +++ b/wallet/symbol/mobile/src/router/config.js @@ -32,6 +32,7 @@ export const RouteName = { CreateContact: 'CreateContact', EditContact: 'EditContact', Harvesting: 'Harvesting', + CreateMosaic: 'CreateMosaic', 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..692f41b697 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/symbol-ascii.png'), + onPress: Router.goToCreateMosaic + }, { title: $t('s_actions_bridge_title'), description: $t('s_actions_bridge_description'), diff --git a/wallet/symbol/mobile/src/screens/index.js b/wallet/symbol/mobile/src/screens/index.js index 84ab0b3108..92596ca8d0 100644 --- a/wallet/symbol/mobile/src/screens/index.js +++ b/wallet/symbol/mobile/src/screens/index.js @@ -42,6 +42,9 @@ export { ModifyMultisigAccount } from './multisig/ModifyMultisigAccount'; // Harvesting export { Harvesting } from './harvesting/Harvesting'; +// Mosaic +export { CreateMosaic } from './mosaic/CreateMosaic'; + // 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..6eb4c7cd96 --- /dev/null +++ b/wallet/symbol/mobile/src/screens/mosaic/CreateMosaic.jsx @@ -0,0 +1,278 @@ +import { + Button, + Checkbox, + 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 { MosaicFlagList } from '@/app/screens/mosaic/components'; +import { useCreateMosaicFormState, useMosaicTransaction } from '@/app/screens/mosaic/hooks'; +import { + calculateMosaicDurationDays, + validateMosaicDivisibility, + validateMosaicDuration, + validateMosaicSupply +} from '@/app/screens/mosaic/utils'; +import { validateRequired } from '@/app/utils'; +import React, { useEffect } from 'react'; +import Animated, { FadeInDown, FadeOut } from 'react-native-reanimated'; + +/** + * 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 or one of its multisig accounts. + * @returns {React.ReactNode} CreateMosaic component. + */ +export const CreateMosaic = () => { + const walletController = useWalletController(); + const { + isWalletReady, + isNetworkConnectionReady, + networkProperties, + networkIdentifier, + chainName, + ticker + } = walletController; + const currentAccountInfo = walletController.currentAccountInfo || {}; + const walletAccounts = walletController.accounts[networkIdentifier]; + + // Sender selection (current or multisig) + const { + options: senderOptions, + value: senderAddress, + changeValue: changeSenderAddress, + selectedAccount, + isMultisigSelected: isMultisigSender, + 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 divisibilityErrorMessage = useValidation(divisibility, [validateRequired(), validateMosaicDivisibility()], $t); + const supplyErrorMessage = useValidation(supply, [validateRequired(), validateMosaicSupply()], $t); + const durationValidationMessage = useValidation(duration, [validateRequired(), validateMosaicDuration()], $t); + const durationErrorMessage = isNeverExpiring ? undefined : durationValidationMessage; + const isFormValid = !divisibilityErrorMessage && !supplyErrorMessage && !durationErrorMessage; + + // When creating from a multisig account, that account is the mosaic creator + const senderPublicKey = isMultisigSender ? selectedAccount?.publicKey : undefined; + + // Transaction creation and preview + const { createMosaicTransaction, getConfirmationPreview } = useMosaicTransaction({ + walletController, + senderPublicKey, + 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, senderPublicKey]); + + // Derived state + const blockGenerationTargetTime = networkProperties?.blockGenerationTargetTime; + const isDurationHintVisible = !isNeverExpiring && !durationErrorMessage && !!blockGenerationTargetTime; + const durationDays = calculateMosaicDurationDays(duration, blockGenerationTargetTime); + const isButtonDisabled = !isNetworkConnectionReady + || !isFormValid + || isFeesLoading + || !transactionFees; + + // Handlers + const handleTransactionSendComplete = () => { + resetForm(); + 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')} + + + + + {/* Divisibility section */} + + + + {$t('s_mosaicCreation_divisibility_title')} + + + {$t('s_mosaicCreation_divisibility_description')} + + + + + + {/* Supply section */} + + + + {$t('s_mosaicCreation_supply_title')} + + + {$t('s_mosaicCreation_supply_description')} + + + + + + {/* Duration section */} + + + + {$t('s_mosaicCreation_duration_title')} + + + {$t('s_mosaicCreation_duration_description')} + + + + {isDurationHintVisible && ( + + {$t('s_mosaicCreation_durationDays', { duration: durationDays })} + + )} + + + + {/* Flags sections */} + + + {/* Fee selector */} + {!!transactionFees && ( + + + + )} + +