diff --git a/wallet/common/core/src/constants/index.js b/wallet/common/core/src/constants/index.js index a6ab3676a..456502b8f 100644 --- a/wallet/common/core/src/constants/index.js +++ b/wallet/common/core/src/constants/index.js @@ -86,5 +86,7 @@ export const REQUIRED_SDK_METHODS = [ 'encryptMessage', 'decryptMessage', 'createPrivateAccount', - 'createPrivateKeysFromMnemonic' + 'createPrivateKeysFromMnemonic', + 'normalizeAddress', + 'normalizeTransactionHash' ]; diff --git a/wallet/common/core/src/lib/bridge/BridgeManager.js b/wallet/common/core/src/lib/bridge/BridgeManager.js index 9d3a7be5f..54aaa905c 100644 --- a/wallet/common/core/src/lib/bridge/BridgeManager.js +++ b/wallet/common/core/src/lib/bridge/BridgeManager.js @@ -23,6 +23,21 @@ import { absoluteToRelativeAmount, relativeToAbsoluteAmount } from '../../utils/ /** @typedef {import('../../types/Token').Token} Token */ /** @typedef {import('../../types/Token').TokenInfo} TokenInfo */ +/** + * @typedef {Object} SwapSideInfo + * @property {string} chainName - The blockchain name. + * @property {TokenInfo} tokenInfo - Token info for this side of the swap. + * @property {function(string): string} normalizeAddress - Function to normalize an address for this chain. + * @property {function(string): string} normalizeTransactionHash - Function to normalize a transaction hash for this chain. + */ + +/** + * @typedef {Object} SwapContext + * @property {string} mode - The bridge mode ('wrap' or 'unwrap'). + * @property {SwapSideInfo} source - Source side information. + * @property {SwapSideInfo} target - Target side information. + */ + const BridgeMode = { WRAP: 'wrap', @@ -151,15 +166,42 @@ export class BridgeManager { return controllers.find(c => c.chainName === chainName) || null; }; + /** + * Create a SwapSideInfo object for a given wallet controller. + * @param {WalletControllerWithBridgeModule} walletController - Wallet controller. + * @param {TokenInfo} tokenInfo - Token info. + * @returns {SwapSideInfo} - Swap side information. + */ + #createSwapSideInfo = (walletController, tokenInfo) => ({ + chainName: walletController.chainName, + tokenInfo, + normalizeAddress: walletController.walletSdk.normalizeAddress, + normalizeTransactionHash: walletController.walletSdk.normalizeTransactionHash + }); + + /** + * Get swap context with source and target information based on mode. + * @param {string} mode - 'wrap' or 'unwrap' + * @returns {SwapContext} - Swap context with source and target information. + */ + #getSwapContext = mode => { + const sourceWalletController = this.#getSourceWalletController(mode); + const targetWalletController = this.#getTargetWalletController(mode); + const sourceToken = this.#getSourceToken(mode); + const targetToken = this.#getTargetToken(mode); + + return { + mode, + source: this.#createSwapSideInfo(sourceWalletController, sourceToken), + target: this.#createSwapSideInfo(targetWalletController, targetToken) + }; + }; + /** * Fetch bridge configuration, source and target token infos, and register tokens in wallet controllers. * @returns {Promise} - Promise that resolves when loading is complete. */ load = async () => { - // Clear config - this.#config = null; - this.#bridgeApi.setNetworkIdentifier(null); - // Get network identifiers from both wallet controllers const nativeNetworkIdentifier = this.#nativeWalletController.networkIdentifier; const wrappedNetworkIdentifier = this.#wrappedWalletController.networkIdentifier; @@ -197,6 +239,8 @@ export class BridgeManager { delete config.wrappedNetwork.tokenId; config.nativeNetwork.tokenInfo = nativeToken; config.wrappedNetwork.tokenInfo = wrappedToken; + config.nativeNetwork.bridgeAddress = this.#nativeWalletController.walletSdk.normalizeAddress(config.nativeNetwork.bridgeAddress); + config.wrappedNetwork.bridgeAddress = this.#wrappedWalletController.walletSdk.normalizeAddress(config.wrappedNetwork.bridgeAddress); // Update state this.#config = config; @@ -222,10 +266,10 @@ export class BridgeManager { // Merge and sort by request transaction timestamp descending const allData = [...wrapRequests, ...unwrapRequests, ...wrapErrors, ...unwrapErrors, ...wrapPending, ...unwrapPending]; - const normalizeHash = hash => hash.startsWith('0x') ? hash.slice(2).toUpperCase() : hash.toUpperCase(); + const filteredByRequestHash = allData.filter((request, index, self) => index === self.findIndex(item => - normalizeHash(item.requestTransaction.hash) === normalizeHash(request.requestTransaction.hash))); + item.requestTransaction.hash === request.requestTransaction.hash)); const sortedByTimestamp = filteredByRequestHash.sort((a, b) => b.requestTransaction.timestamp - a.requestTransaction.timestamp); // Return only the requested number of items @@ -242,6 +286,9 @@ export class BridgeManager { * @returns {Promise} - List of requests. */ fetchSentRequests = async (mode, { pageSize, pageNumber } = {}) => { + if (!this.#config) + throw new Error('Failed to fetch sent requests. No bridge config fetched'); + const walletController = this.#getSourceWalletController(mode); const bridgeAddress = mode === BridgeMode.WRAP ? this.#config.nativeNetwork.bridgeAddress @@ -251,15 +298,9 @@ export class BridgeManager { pageSize, pageNumber }); - const lowercaseBridgeAddress = bridgeAddress.toLowerCase(); - const filteredTransactions = transactions.filter(tx => tx.recipientAddress?.toLowerCase() === lowercaseBridgeAddress); - const context = { - mode, - sourceToken: this.#getSourceToken(mode), - targetToken: this.#getTargetToken(mode), - sourceChainName: walletController.chainName, - targetChainName: this.#getTargetWalletController(mode).chainName - }; + const context = this.#getSwapContext(mode); + const filteredTransactions = transactions.filter(tx => context.source.normalizeAddress(tx.recipientAddress) === bridgeAddress); + return filteredTransactions.map(transaction => this.#transactionToPendingRequest(transaction, context)); }; @@ -282,13 +323,7 @@ export class BridgeManager { throw new Error('Failed to fetch bridge requests. No bridge config fetched'); const requestDtos = await this.#bridgeApi.fetchRequests(mode, currentAccount.address, { pageSize, pageNumber }); - const context = { - mode, - sourceToken: this.#getSourceToken(mode), - targetToken: this.#getTargetToken(mode), - sourceChainName: this.#getSourceWalletController(mode).chainName, - targetChainName: this.#getTargetWalletController(mode).chainName - }; + const context = this.#getSwapContext(mode); return requestDtos.map(dto => this.#requestFromDto(dto, context)); }; @@ -311,13 +346,7 @@ export class BridgeManager { throw new Error('Failed to fetch errors. No bridge config fetched'); const errorDtos = await this.#bridgeApi.fetchErrors(mode, currentAccount.address, { pageSize, pageNumber }); - const context = { - mode, - sourceToken: this.#getSourceToken(mode), - targetToken: this.#getTargetToken(mode), - sourceChainName: this.#getSourceWalletController(mode).chainName, - targetChainName: this.#getTargetWalletController(mode).chainName - }; + const context = this.#getSwapContext(mode); return errorDtos.map(dto => this.#errorFromDto(dto, context)); }; @@ -357,70 +386,80 @@ export class BridgeManager { } }; + /** + * Convert a transaction to a pending bridge request. + * @param {object} transaction - The transaction object. + * @param {SwapContext} context - Swap context with source and target information. + * @returns {BridgeRequest} - The pending bridge request. + */ #transactionToPendingRequest(transaction, context) { - const { mode, sourceToken, targetToken, sourceChainName, targetChainName } = context; + const { mode, source, target } = context; + + if (!source.tokenInfo || !target.tokenInfo) + throw new Error('Failed to create pending request. Token info is not available'); + + const transactionTokens = transaction.mosaics ?? transaction.tokens ?? []; return { type: mode, requestStatus: 'confirmed', - sourceChainName, - targetChainName, - sourceTokenInfo: sourceToken, - targetTokenInfo: targetToken, + sourceChainName: source.chainName, + targetChainName: target.chainName, + sourceTokenInfo: source.tokenInfo, + targetTokenInfo: target.tokenInfo, requestTransaction: { - signerAddress: transaction.senderAddress, - hash: transaction.hash, + signerAddress: source.normalizeAddress(transaction.signerAddress), + hash: source.normalizeTransactionHash(transaction.hash), height: transaction.height, - timestamp: transaction.timestamp + timestamp: transaction.timestamp, + token: transactionTokens[0] ?? null } }; } /** * Map request DTO to request object. - * @param {object} dto - Request DTO from the bridge - * @param {object} context - Context information - * @param {string} context.mode - 'wrap' or 'unwrap' - * @param {TokenInfo} context.sourceToken - Source token info - * @param {TokenInfo} context.targetToken - Target token info - * @param {string} context.sourceChainName - Source chain name - * @param {string} context.targetChainName - Target chain name - * @returns {BridgeRequest} - Mapped request object + * @param {object} dto - Request DTO from the bridge. + * @param {SwapContext} context - Swap context with source and target information. + * @returns {BridgeRequest} - Mapped request object. */ - #requestFromDto(dto, { mode, sourceToken, targetToken, sourceChainName, targetChainName }) { + #requestFromDto(dto, { mode, source, target }) { + if (!source.tokenInfo || !target.tokenInfo) + throw new Error('Failed to map request from DTO. Token info is not available'); + const requestTransaction = { - signerAddress: dto.senderAddress, - hash: dto.requestTransactionHash, + signerAddress: source.normalizeAddress(dto.senderAddress), + hash: source.normalizeTransactionHash(dto.requestTransactionHash), height: dto.requestTransactionHeight ?? null, timestamp: Math.trunc(dto.requestTimestamp * 1000), token: { - ...sourceToken, - amount: absoluteToRelativeAmount(dto.requestAmount, sourceToken.divisibility) + ...source.tokenInfo, + amount: absoluteToRelativeAmount(dto.requestAmount, source.tokenInfo.divisibility) } }; const payoutTransaction = dto.payoutTransactionHash ? { - recipientAddress: dto.destinationAddress, - hash: dto.payoutTransactionHash, + recipientAddress: target.normalizeAddress(dto.destinationAddress), + hash: target.normalizeTransactionHash(dto.payoutTransactionHash), height: dto.payoutTransactionHeight ?? null, timestamp: Math.trunc(dto.payoutTimestamp * 1000), token: { - ...targetToken, - amount: absoluteToRelativeAmount(dto.payoutNetAmount, targetToken.divisibility) + ...target.tokenInfo, + amount: absoluteToRelativeAmount(dto.payoutNetAmount, target.tokenInfo.divisibility) } } : null; return { type: mode, - sourceChainName, - targetChainName, - sourceTokenInfo: sourceToken, - targetTokenInfo: targetToken, + sourceChainName: source.chainName, + targetChainName: target.chainName, + sourceTokenInfo: source.tokenInfo, + targetTokenInfo: target.tokenInfo, payoutStatus: dto.payoutStatus, payoutConversionRate: dto.payoutConversionRate - ? absoluteToRelativeAmount(dto.payoutConversionRate, targetToken.divisibility) + ? absoluteToRelativeAmount(dto.payoutConversionRate, target.tokenInfo.divisibility) : null, payoutTotalFee: dto.payoutTotalFee - ? absoluteToRelativeAmount(dto.payoutTotalFee, targetToken.divisibility) + ? absoluteToRelativeAmount(dto.payoutTotalFee, target.tokenInfo.divisibility) : null, requestTransaction, payoutTransaction @@ -428,20 +467,15 @@ export class BridgeManager { } /** - * Private: Map error DTO to error object. - * @param {object} dto - Error DTO from the bridge - * @param {object} context - Context information - * @param {string} context.mode - 'wrap' or 'unwrap' - * @param {TokenInfo} context.sourceToken - Source token info - * @param {TokenInfo} context.targetToken - Target token info - * @param {string} context.sourceChainName - Source chain name - * @param {string} context.targetChainName - Target chain name - * @returns {BridgeError} - Mapped error object + * Map error DTO to error object. + * @param {object} dto - Error DTO from the bridge. + * @param {SwapContext} context - Swap context with source and target information. + * @returns {BridgeError} - Mapped error object. */ - #errorFromDto(dto, { mode, sourceToken, targetToken, sourceChainName, targetChainName }) { + #errorFromDto(dto, { mode, source, target }) { const requestTransaction = { - signerAddress: dto.senderAddress, - hash: dto.requestTransactionHash, + signerAddress: source.normalizeAddress(dto.senderAddress), + hash: source.normalizeTransactionHash(dto.requestTransactionHash), height: dto.requestTransactionHeight ?? null, timestamp: Math.trunc(dto.requestTimestamp * 1000) }; @@ -449,10 +483,10 @@ export class BridgeManager { return { type: mode, requestStatus: 'error', - sourceChainName, - targetChainName, - sourceTokenInfo: sourceToken, - targetTokenInfo: targetToken, + sourceChainName: source.chainName, + targetChainName: target.chainName, + sourceTokenInfo: source.tokenInfo, + targetTokenInfo: target.tokenInfo, errorMessage: dto.errorMessage, requestTransaction }; diff --git a/wallet/common/core/src/lib/controller/WalletController.js b/wallet/common/core/src/lib/controller/WalletController.js index b6dfa6d74..736451d92 100644 --- a/wallet/common/core/src/lib/controller/WalletController.js +++ b/wallet/common/core/src/lib/controller/WalletController.js @@ -73,6 +73,9 @@ export class WalletController { /** @type {ProtocolNetworkApi} */ _api; + /** @type {ProtocolWalletSdk} */ + _sdk; + /** @type {NetworkManager} */ _networkManager; @@ -124,6 +127,7 @@ export class WalletController { this.#chainName = chainName; this.#ticker = ticker; this._api = api; + this._sdk = sdk; this.networkIdentifiers = networkIdentifiers; this.#createDefaultNetworkProperties = createDefaultNetworkProperties; this.#setStateProcessor = setStateProcessor; @@ -186,6 +190,14 @@ export class WalletController { return this._api; } + /** + * Returns the wallet SDK instance. + * @returns {ProtocolWalletSdk} - The wallet SDK instance. + */ + get walletSdk() { + return this._sdk; + } + /** * Returns the ticker symbol of the main network currency. * @returns {string} - The ticker symbol of the main network currency. diff --git a/wallet/common/core/src/types/Network.js b/wallet/common/core/src/types/Network.js index 00fa9bc9c..9246f47c0 100644 --- a/wallet/common/core/src/types/Network.js +++ b/wallet/common/core/src/types/Network.js @@ -17,11 +17,19 @@ * A map where the key is a network identifier and the value is another object/map with keys of type K and values of type V. */ +/** + * @typedef {Object} NetworkCurrency + * @property {string} id - Token identifier. + * @property {number} divisibility - Token divisibility. + * @property {string} name - Token name or symbol. + */ + /** * @typedef {Object} NetworkProperties * @property {string} nodeUrl - API node URL. * @property {string} networkIdentifier - Network identifier. * @property {number} chainHeight - Chain height at the time of the request. + * @property {NetworkCurrency} networkCurrency - The native currency information for the network. */ export default {}; diff --git a/wallet/common/core/tests/lib/BridgeManager.test.js b/wallet/common/core/tests/lib/BridgeManager.test.js index 3eda357b9..e0a5f31ec 100644 --- a/wallet/common/core/tests/lib/BridgeManager.test.js +++ b/wallet/common/core/tests/lib/BridgeManager.test.js @@ -8,6 +8,7 @@ const SYMBOL_TOKEN_ID = '72C0212E67A08BCE'; const ETHEREUM_BRIDGE_ADDRESS = '0x9B5b717FEC711af80050986D1306D5c8Fb9FA953'; const ETHEREUM_TOKEN_ID = '0x5E8343A455F03109B737B6D8b410e4ECCE998cdA'; const ETHEREUM_ACCOUNT_ADDRESS = '0xeCA7dadA410614B604FFcBE0378C05474b0aeD8D'; +const ETHEREUM_ADDRESS_FORMATTED = '0xeca7dada410614b604ffcbe0378c05474b0aed8d'; const BRIDGE_URL = 'https://bridge.example.com'; const BRIDGE_ID = 'symbol-ethereum-bridge'; @@ -102,13 +103,28 @@ describe('BridgeManager', () => { divisibility: 6, amount }); - const createController = ({ chainName, currentAccountAddress }) => ({ + const symbolSdk = { + normalizeAddress: address => address.replace(/-/g, '').toUpperCase(), + normalizeTransactionHash: hash => hash.toUpperCase() + }; + const ethereumSdk = { + normalizeAddress: address => { + const lower = address.toLowerCase(); + return lower.startsWith('0x') ? lower : `0x${lower}`; + }, + normalizeTransactionHash: hash => { + const lower = hash.toLowerCase(); + return lower.startsWith('0x') ? lower : `0x${lower}`; + } + }; + const createController = ({ chainName, currentAccountAddress, walletSdk }) => ({ networkIdentifier, chainName, networkProperties: { chainId: 'test-chain' }, currentAccount: { address: currentAccountAddress }, isWalletReady: true, fetchAccountTransactions: jest.fn(async () => []), + walletSdk, modules: { bridge: { fetchTokenInfo: jest.fn(async tokenId => createTokenInfo(tokenId)), @@ -118,11 +134,13 @@ describe('BridgeManager', () => { }); const createNativeController = () => createController({ chainName: 'symbol', - currentAccountAddress: SYMBOL_ACCOUNT_ADDRESS + currentAccountAddress: SYMBOL_ACCOUNT_ADDRESS, + walletSdk: symbolSdk }); const createWrappedController = () => createController({ chainName: 'ethereum', - currentAccountAddress: ETHEREUM_ACCOUNT_ADDRESS + currentAccountAddress: ETHEREUM_ACCOUNT_ADDRESS, + walletSdk: ethereumSdk }); const toAbsolute = (amount, divisibility = 6) => { const [i, f = ''] = String(amount).split('.'); @@ -264,7 +282,7 @@ describe('BridgeManager', () => { }; const expectedWrappedConfig = { blockchain: configResponse.wrappedNetwork.blockchain, - bridgeAddress: configResponse.wrappedNetwork.bridgeAddress, + bridgeAddress: configResponse.wrappedNetwork.bridgeAddress.toLowerCase(), defaultNodeUrl: configResponse.wrappedNetwork.defaultNodeUrl, explorerUrl: configResponse.wrappedNetwork.explorerUrl, network: configResponse.wrappedNetwork.network, @@ -427,8 +445,8 @@ describe('BridgeManager', () => { token: createToken(SYMBOL_TOKEN_ID, '15') }, payoutTransaction: { - recipientAddress: ETHEREUM_ACCOUNT_ADDRESS, - hash: 'C240B79CCC230438A685EB684A82DD81B58BD5A896805DC97A72D33F4812EEFF', + recipientAddress: ETHEREUM_ADDRESS_FORMATTED, + hash: '0xc240b79ccc230438a685eb684a82dd81b58bd5a896805dc97a72d33f4812eeff', height: 49896, timestamp: 1757430500000, token: createToken(ETHEREUM_TOKEN_ID, '14.999999') @@ -463,8 +481,8 @@ describe('BridgeManager', () => { payoutConversionRate: '1', payoutTotalFee: '0.0176', requestTransaction: { - signerAddress: ETHEREUM_ACCOUNT_ADDRESS, - hash: 'D2C59C2516AE16728673B0BAF82753270FBCCB261D0212976A4FD3D885418F93', + signerAddress: ETHEREUM_ADDRESS_FORMATTED, + hash: '0xd2c59c2516ae16728673b0baf82753270fbccb261d0212976a4fd3d885418f93', height: 8313, timestamp: 1756931504000, token: createToken(ETHEREUM_TOKEN_ID, '499.99999') @@ -678,8 +696,8 @@ describe('BridgeManager', () => { targetTokenInfo: createTokenInfo(SYMBOL_TOKEN_ID), errorMessage: 'Oops', requestTransaction: { - signerAddress: ETHEREUM_ACCOUNT_ADDRESS, - hash: 'ERR_UNWRAP', + signerAddress: ETHEREUM_ADDRESS_FORMATTED, + hash: '0xerr_unwrap', height: 22, timestamp: 111000 } @@ -744,14 +762,14 @@ describe('BridgeManager', () => { const pageNumber = 1; const transactions = [ { - senderAddress: nativeWalletController.currentAccount.address, + signerAddress: nativeWalletController.currentAccount.address, recipientAddress: lowerCaseBridge, hash: 'PENDING_WRAP_HASH_1', height: 100, timestamp: 1000 }, { - senderAddress: nativeWalletController.currentAccount.address, + signerAddress: nativeWalletController.currentAccount.address, recipientAddress: 'SOME_OTHER_ADDRESS', hash: 'IGNORED_HASH', height: 200, @@ -770,7 +788,8 @@ describe('BridgeManager', () => { signerAddress: nativeWalletController.currentAccount.address, hash: 'PENDING_WRAP_HASH_1', height: 100, - timestamp: 1000 + timestamp: 1000, + token: null } } ]; @@ -792,14 +811,14 @@ describe('BridgeManager', () => { const pageNumber = 2; const transactions = [ { - senderAddress: wrappedWalletController.currentAccount.address, + signerAddress: wrappedWalletController.currentAccount.address, recipientAddress: mixedCaseBridge, hash: 'PENDING_UNWRAP_HASH_1', height: 300, timestamp: 2000 }, { - senderAddress: wrappedWalletController.currentAccount.address, + signerAddress: wrappedWalletController.currentAccount.address, recipientAddress: '0xnotbridge', hash: 'IGNORED_HASH_2', height: 301, @@ -815,10 +834,11 @@ describe('BridgeManager', () => { sourceTokenInfo: manager.config.wrappedNetwork.tokenInfo, targetTokenInfo: manager.config.nativeNetwork.tokenInfo, requestTransaction: { - signerAddress: wrappedWalletController.currentAccount.address, - hash: 'PENDING_UNWRAP_HASH_1', + signerAddress: ETHEREUM_ADDRESS_FORMATTED, + hash: '0xpending_unwrap_hash_1', height: 300, - timestamp: 2000 + timestamp: 2000, + token: null } } ]; @@ -896,7 +916,7 @@ describe('BridgeManager', () => { errorMessage: 'error', requestTransaction: { signerAddress: SYMBOL_ACCOUNT_ADDRESS, - hash: 'hash1', + hash: 'HASH1', height: 0, timestamp: 2500 } diff --git a/wallet/common/core/tests/lib/WalletController.test.js b/wallet/common/core/tests/lib/WalletController.test.js index 330d289cb..3e955c563 100644 --- a/wallet/common/core/tests/lib/WalletController.test.js +++ b/wallet/common/core/tests/lib/WalletController.test.js @@ -40,7 +40,9 @@ const defaultParameters = { encryptMessage: jest.fn().mockResolvedValue(''), decryptMessage: jest.fn().mockResolvedValue(''), createPrivateAccount: jest.fn().mockResolvedValue(), - createPrivateKeysFromMnemonic: jest.fn().mockResolvedValue([]) + createPrivateKeysFromMnemonic: jest.fn().mockResolvedValue([]), + normalizeAddress: jest.fn(address => address), + normalizeTransactionHash: jest.fn(hash => hash) }, persistentStorageInterface: createStorageMock({}), secureStorageInterface: createStorageMock({}), diff --git a/wallet/common/ethereum/src/sdk/index.js b/wallet/common/ethereum/src/sdk/index.js index aa66838a8..79c3d6df1 100644 --- a/wallet/common/ethereum/src/sdk/index.js +++ b/wallet/common/ethereum/src/sdk/index.js @@ -4,7 +4,9 @@ export { signTransaction, signTransactionBundle, createPrivateAccount, - createPrivateKeysFromMnemonic + createPrivateKeysFromMnemonic, + normalizeAddress, + normalizeTransactionHash } from '../utils'; export const cosignTransaction = () => { diff --git a/wallet/common/ethereum/src/utils/account.js b/wallet/common/ethereum/src/utils/account.js index d56cce76b..a129ece29 100644 --- a/wallet/common/ethereum/src/utils/account.js +++ b/wallet/common/ethereum/src/utils/account.js @@ -1,3 +1,4 @@ +import { to0x } from './format'; import { SigningKey, Wallet, computeAddress, isAddress } from 'ethers'; /** @typedef {import('../types/Account').KeyPair} KeyPair */ @@ -6,20 +7,15 @@ import { SigningKey, Wallet, computeAddress, isAddress } from 'ethers'; /** @typedef {import('../types/Account').WalletAccount} WalletAccount */ /** - * Ensures a hex string follows the '0x' lowercase convention. - * @param {string} hex - The hex string. - * @returns {string} The lowercase hex string prefixed with '0x'. + * Normalizes an Ethereum address by ensuring it follows the '0x' lowercase convention. + * @param {string} address - The address to normalize. + * @returns {string} The normalized address. */ -export const to0x = hex => { - if (typeof hex !== 'string') - throw new TypeError('Expected a string value'); +export const normalizeAddress = address => { + if (typeof address !== 'string') + throw new TypeError('Expected address to be a string value'); - const lowercaseHex = hex.toLowerCase(); - - if (lowercaseHex.startsWith('0x')) - return lowercaseHex; - - return `0x${lowercaseHex}`; + return to0x(address); }; /** diff --git a/wallet/common/ethereum/src/utils/format.js b/wallet/common/ethereum/src/utils/format.js new file mode 100644 index 000000000..0af116e40 --- /dev/null +++ b/wallet/common/ethereum/src/utils/format.js @@ -0,0 +1,13 @@ +/** + * Ensures a hex string follows the '0x' lowercase convention. + * @param {string} hex - The hex string. + * @returns {string} The lowercase hex string prefixed with '0x'. + */ +export const to0x = hex => { + const lowercaseHex = hex.toLowerCase(); + + if (lowercaseHex.startsWith('0x')) + return lowercaseHex; + + return `0x${lowercaseHex}`; +}; diff --git a/wallet/common/ethereum/src/utils/index.js b/wallet/common/ethereum/src/utils/index.js index b153e891d..6d0d04c9e 100644 --- a/wallet/common/ethereum/src/utils/index.js +++ b/wallet/common/ethereum/src/utils/index.js @@ -1,5 +1,6 @@ export * from './account'; export * from './fee'; +export * from './format'; export * from './jrpc'; export * from './network'; export * from './transaction'; diff --git a/wallet/common/ethereum/src/utils/transaction-from-dto.js b/wallet/common/ethereum/src/utils/transaction-from-dto.js index 239cf3591..72e39cba2 100644 --- a/wallet/common/ethereum/src/utils/transaction-from-dto.js +++ b/wallet/common/ethereum/src/utils/transaction-from-dto.js @@ -1,5 +1,7 @@ -import { to0x } from './account'; +import { normalizeAddress } from './account'; import { createFee } from './fee'; +import { to0x } from './format'; +import { normalizeTransactionHash } from './transaction'; import { TransactionType } from '../constants'; import { absoluteToRelativeAmount, hexToBase32 } from 'wallet-common-core'; @@ -34,9 +36,11 @@ export const transactionFromDTO = (transactionDTO, config) => { const baseTransactionFromDTO = (transactionDTO, config) => { const transaction = { height: transactionDTO.blockNumber ? BigInt(transactionDTO.blockNumber).toString() : null, - hash: transactionDTO.hash ? transactionDTO.hash.toLowerCase() : null, + hash: transactionDTO.hash + ? normalizeTransactionHash(transactionDTO.hash) + : null, nonce: transactionDTO.nonce ? BigInt(transactionDTO.nonce).toString() : null, - signerAddress: transactionDTO.from.toLowerCase(), + signerAddress: normalizeAddress(transactionDTO.from), fee: createFee({ maxFeePerGas: absoluteToRelativeAmount( BigInt(transactionDTO.maxFeePerGas).toString(), @@ -68,7 +72,9 @@ const transferTransactionFromDTO = (transactionDTO, config) => { ...baseTransactionFromDTO(transactionDTO, config), type: TransactionType.TRANSFER, tokens: [token], - recipientAddress: transactionDTO.to ? transactionDTO.to.toLowerCase() : null + recipientAddress: transactionDTO.to + ? normalizeAddress(transactionDTO.to) + : null }; }; @@ -76,7 +82,7 @@ const erc20LikeTransactionFromDTO = (transactionDTO, config) => { const data = transactionDTO.input.slice(10); const amountHex = data.slice(64, 128); const amountAbsolute = BigInt(to0x(amountHex)).toString(); - const contractAddress = transactionDTO.to.toLowerCase(); + const contractAddress = normalizeAddress(transactionDTO.to); const tokenInfo = config.tokenInfos[contractAddress]; const token = { ...tokenInfo, @@ -86,7 +92,7 @@ const erc20LikeTransactionFromDTO = (transactionDTO, config) => { ...baseTransactionFromDTO(transactionDTO, config), type: TransactionType.ERC_20_TRANSFER, tokens: [token], - recipientAddress: to0x(data.slice(24, 64).toLowerCase()) + recipientAddress: normalizeAddress(to0x(data.slice(24, 64).toLowerCase())) }; // If input data has more than just the recipient and amount, treat it as a message diff --git a/wallet/common/ethereum/src/utils/transaction.js b/wallet/common/ethereum/src/utils/transaction.js index 0fab761e7..f38b8ab0c 100644 --- a/wallet/common/ethereum/src/utils/transaction.js +++ b/wallet/common/ethereum/src/utils/transaction.js @@ -1,3 +1,4 @@ +import { to0x } from './format'; import { transactionToEthereum } from './transaction-to-ethereum'; import { ethers } from 'ethers'; import { TransactionBundle } from 'wallet-common-core'; @@ -8,6 +9,17 @@ import { TransactionBundle } from 'wallet-common-core'; /** @typedef {import('../types/Transaction').Transaction} Transaction */ /** @typedef {import('../types/Transaction').SignedTransaction} SignedTransaction */ +/** + * Normalizes a transaction hash by ensuring it follows the '0x' lowercase convention. + * @param {string} hash - The transaction hash to normalize. + * @returns {string} The normalized transaction hash. + */ +export const normalizeTransactionHash = hash => { + if (typeof hash !== 'string') + throw new TypeError('Expected hash to be a string value'); + + return to0x(hash); +}; /** * Checks if a transaction is an outgoing transaction. diff --git a/wallet/common/ethereum/tests/utils/account.test.js b/wallet/common/ethereum/tests/utils/account.test.js index fd5a4c2cb..f71eb5edf 100644 --- a/wallet/common/ethereum/tests/utils/account.test.js +++ b/wallet/common/ethereum/tests/utils/account.test.js @@ -7,6 +7,7 @@ import { isEthereumAddress, isPrivateKey, isPublicKey, + normalizeAddress, publicAccountFromPrivateKey, publicAccountFromPublicKey } from '../../src/utils'; @@ -261,4 +262,48 @@ describe('utils/account', () => { runIsEthereumAddressTest(input, expectedResult); }); }); + + describe('normalizeAddress', () => { + it('prepends 0x and lowercases address without prefix', () => { + // Arrange: + const address = 'B1B2145B7D2BA5AB20EE0BCB0F7FAD08A1BFC7A4'; + const expectedResult = '0xb1b2145b7d2ba5ab20ee0bcb0f7fad08a1bfc7a4'; + + // Act: + const result = normalizeAddress(address); + + // Assert: + expect(result).toBe(expectedResult); + }); + + it('lowercases address that already has 0x prefix', () => { + // Arrange: + const address = '0xB1B2145B7D2BA5AB20EE0BCB0F7FAD08A1BFC7A4'; + const expectedResult = '0xb1b2145b7d2ba5ab20ee0bcb0f7fad08a1bfc7a4'; + + // Act: + const result = normalizeAddress(address); + + // Assert: + expect(result).toBe(expectedResult); + }); + + it('returns already normalized address unchanged', () => { + // Arrange: + const address = '0xb1b2145b7d2ba5ab20ee0bcb0f7fad08a1bfc7a4'; + + // Act: + const result = normalizeAddress(address); + + // Assert: + expect(result).toBe(address); + }); + + it('throws TypeError when address is not a string', () => { + // Act & Assert: + expect(() => normalizeAddress(123)).toThrow(TypeError); + expect(() => normalizeAddress(null)).toThrow(TypeError); + expect(() => normalizeAddress(undefined)).toThrow(TypeError); + }); + }); }); diff --git a/wallet/common/ethereum/tests/utils/transaction.test.js b/wallet/common/ethereum/tests/utils/transaction.test.js index 43810daf2..2543206e4 100644 --- a/wallet/common/ethereum/tests/utils/transaction.test.js +++ b/wallet/common/ethereum/tests/utils/transaction.test.js @@ -2,6 +2,7 @@ import { createFee, isIncomingTransaction, isOutgoingTransaction, + normalizeTransactionHash, signTransaction } from '../../src/utils'; import { networkCurrency } from '../__fixtures__/local/network'; @@ -86,5 +87,49 @@ describe('utils/transaction', () => { } }); }); + + describe('normalizeTransactionHash', () => { + it('prepends 0x and lowercases hash without prefix', () => { + // Arrange: + const hash = 'A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2'; + const expectedResult = '0xa1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2'; + + // Act: + const result = normalizeTransactionHash(hash); + + // Assert: + expect(result).toBe(expectedResult); + }); + + it('lowercases hash that already has 0x prefix', () => { + // Arrange: + const hash = '0xA1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2'; + const expectedResult = '0xa1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2'; + + // Act: + const result = normalizeTransactionHash(hash); + + // Assert: + expect(result).toBe(expectedResult); + }); + + it('returns already normalized hash unchanged', () => { + // Arrange: + const hash = '0xa1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2'; + + // Act: + const result = normalizeTransactionHash(hash); + + // Assert: + expect(result).toBe(hash); + }); + + it('throws TypeError when hash is not a string', () => { + // Act & Assert: + expect(() => normalizeTransactionHash(123)).toThrow(TypeError); + expect(() => normalizeTransactionHash(null)).toThrow(TypeError); + expect(() => normalizeTransactionHash(undefined)).toThrow(TypeError); + }); + }); }); diff --git a/wallet/common/symbol/src/sdk/index.js b/wallet/common/symbol/src/sdk/index.js index a7747ecf7..929df69a7 100644 --- a/wallet/common/symbol/src/sdk/index.js +++ b/wallet/common/symbol/src/sdk/index.js @@ -1,19 +1,11 @@ -import { +export { cosignTransaction, createPrivateAccount, createPrivateKeysFromMnemonic, decryptMessage, encryptMessage, - signTransaction, - signTransactionBundle -} from '../utils'; - -export { signTransaction, signTransactionBundle, - cosignTransaction, - encryptMessage, - decryptMessage, - createPrivateAccount, - createPrivateKeysFromMnemonic -}; + normalizeAddress, + normalizeTransactionHash +} from '../utils'; diff --git a/wallet/common/symbol/src/utils/account.js b/wallet/common/symbol/src/utils/account.js index 78913db26..965a7a85c 100644 --- a/wallet/common/symbol/src/utils/account.js +++ b/wallet/common/symbol/src/utils/account.js @@ -7,6 +7,18 @@ import { Address, Network, SymbolFacade } from 'symbol-sdk/symbol'; /** @typedef {import('../types/Account').PrivateAccount} PrivateAccount */ /** @typedef {import('../types/Account').WalletAccount} WalletAccount */ +/** + * Normalizes a Symbol address by ensuring it follows the uppercase convention without dashes. + * @param {string} address - The address to normalize. + * @returns {string} The normalized address. + */ +export const normalizeAddress = address => { + if (typeof address !== 'string') + throw new TypeError('Expected address to be a string value'); + + return address.replace(/-/g, '').toUpperCase(); +}; + /** * Generates a key pair consisting of a private key and a public key. * @returns {KeyPair} An object containing the generated private key and public key. diff --git a/wallet/common/symbol/src/utils/transaction.js b/wallet/common/symbol/src/utils/transaction.js index 7457be83d..54f5e6fc0 100644 --- a/wallet/common/symbol/src/utils/transaction.js +++ b/wallet/common/symbol/src/utils/transaction.js @@ -15,6 +15,18 @@ import { TransactionBundle, absoluteToRelativeAmount } from 'wallet-common-core' const { TransactionFactory } = models; +/** + * Normalizes a transaction hash by ensuring it follows the uppercase convention. + * @param {string} hash - The transaction hash to normalize. + * @returns {string} The normalized transaction hash. + */ +export const normalizeTransactionHash = hash => { + if (typeof hash !== 'string') + throw new TypeError('Expected hash to be a string value'); + + return hash.toUpperCase(); +}; + /** * Checks if a transaction is an aggregate transaction. * @param {Transaction | object} transaction - The transaction or Symbol transaction object. diff --git a/wallet/common/symbol/tests/utils/account.test.js b/wallet/common/symbol/tests/utils/account.test.js index 14074a69c..f08d56d10 100644 --- a/wallet/common/symbol/tests/utils/account.test.js +++ b/wallet/common/symbol/tests/utils/account.test.js @@ -8,6 +8,7 @@ import { generateKeyPair, isPrivateKey, isSymbolAddress, + normalizeAddress, publicAccountFromPrivateKey, publicAccountFromPublicKey } from '../../src/utils'; @@ -274,4 +275,48 @@ describe('utils/account', () => { expect(result).toBe(expectedResult); }); }); + + describe('normalizeAddress', () => { + it('returns uppercase address without dashes', () => { + // Arrange: + const address = 'NALS-BRWZ-TK3W-QEGZ-25NO-4YH2-MOU4S'; + const expectedResult = 'NALSBRWZTK3WQEGZ25NO4YH2MOU4S'; + + // Act: + const result = normalizeAddress(address); + + // Assert: + expect(result).toBe(expectedResult); + }); + + it('converts lowercase address to uppercase', () => { + // Arrange: + const address = 'nalsbrwztk3wqegz25no4yh2mou4sxyy6avy72i'; + const expectedResult = 'NALSBRWZTK3WQEGZ25NO4YH2MOU4SXYY6AVY72I'; + + // Act: + const result = normalizeAddress(address); + + // Assert: + expect(result).toBe(expectedResult); + }); + + it('returns already normalized address unchanged', () => { + // Arrange: + const address = 'NALSBRWZTK3WQEGZ25NO4YH2MOU4SXYY6AVY72I'; + + // Act: + const result = normalizeAddress(address); + + // Assert: + expect(result).toBe(address); + }); + + it('throws TypeError when address is not a string', () => { + // Act & Assert: + expect(() => normalizeAddress(123)).toThrow(TypeError); + expect(() => normalizeAddress(null)).toThrow(TypeError); + expect(() => normalizeAddress(undefined)).toThrow(TypeError); + }); + }); }); diff --git a/wallet/common/symbol/tests/utils/transaction.test.js b/wallet/common/symbol/tests/utils/transaction.test.js index f3802b1da..04aa290b2 100644 --- a/wallet/common/symbol/tests/utils/transaction.test.js +++ b/wallet/common/symbol/tests/utils/transaction.test.js @@ -14,6 +14,7 @@ import { isIncomingTransaction, isOutgoingTransaction, isTransactionAwaitingSignatureByAccount, + normalizeTransactionHash, removeAllowedTransactions, removeBlockedTransactions, signTransaction, @@ -751,4 +752,36 @@ describe('utils/transaction', () => { runUnresolvedIdsExtractionTests(transactions, config, expected); }); }); + + describe('normalizeTransactionHash', () => { + it('converts lowercase hash to uppercase', () => { + // Arrange: + const hash = 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2'; + const expectedResult = 'A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2'; + + // Act: + const result = normalizeTransactionHash(hash); + + // Assert: + expect(result).toBe(expectedResult); + }); + + it('returns already uppercase hash unchanged', () => { + // Arrange: + const hash = 'A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2'; + + // Act: + const result = normalizeTransactionHash(hash); + + // Assert: + expect(result).toBe(hash); + }); + + it('throws TypeError when hash is not a string', () => { + // Act & Assert: + expect(() => normalizeTransactionHash(123)).toThrow(TypeError); + expect(() => normalizeTransactionHash(null)).toThrow(TypeError); + expect(() => normalizeTransactionHash(undefined)).toThrow(TypeError); + }); + }); }); diff --git a/wallet/symbol/mobile/__fixtures__/local/AccountInfoFixtureBuilder.js b/wallet/symbol/mobile/__fixtures__/local/AccountInfoFixtureBuilder.js index 967d0bdc5..206cf4e02 100644 --- a/wallet/symbol/mobile/__fixtures__/local/AccountInfoFixtureBuilder.js +++ b/wallet/symbol/mobile/__fixtures__/local/AccountInfoFixtureBuilder.js @@ -1,3 +1,5 @@ +import { walletStorageAccounts } from '__fixtures__/local/wallet'; + const LINKED_KEYS = { linkedPublicKey: '67599EEC06BE291E8608E3475D36E4C520389D6C853EA223CF9D744B47A4F630', nodePublicKey: 'E4EAF960E8C4291AF1810F706E16750E3790237FDCF8887B4B0C1854603AD0FF', @@ -76,7 +78,7 @@ export class AccountInfoFixtureBuilder { ...EMPTY_FIXTURE, address: account.address, publicKey: account.publicKey - }); + }, chainName, networkIdentifier); }; /** @@ -234,4 +236,28 @@ export class AccountInfoFixtureBuilder { return this; }; + + /** + * Sets the tokens for the account. + * + * @param {Array} tokens - The account tokens. + * @returns {AccountInfoFixtureBuilder} The builder instance. + */ + setTokens = tokens => { + this._data.tokens = tokens; + + return this; + }; + + /** + * Sets the mosaics for the account. + * + * @param {Array} mosaics - The account mosaics. + * @returns {AccountInfoFixtureBuilder} The builder instance. + */ + setMosaics = mosaics => { + this._data.mosaics = mosaics; + + return this; + }; } diff --git a/wallet/symbol/mobile/__fixtures__/local/TokenFixtureBuilder.js b/wallet/symbol/mobile/__fixtures__/local/TokenFixtureBuilder.js index 8c3dfd13d..70048e6ed 100644 --- a/wallet/symbol/mobile/__fixtures__/local/TokenFixtureBuilder.js +++ b/wallet/symbol/mobile/__fixtures__/local/TokenFixtureBuilder.js @@ -123,4 +123,52 @@ export class TokenFixtureBuilder { return this; }; + + /** + * Sets the start height for the token. + * + * @param {number} startHeight - The token creation height. + * @returns {TokenFixtureBuilder} The builder instance. + */ + setStartHeight = startHeight => { + this._data.startHeight = startHeight; + + return this; + }; + + /** + * Sets the end height for the token. + * + * @param {number} endHeight - The token expiration height. + * @returns {TokenFixtureBuilder} The builder instance. + */ + setEndHeight = endHeight => { + this._data.endHeight = endHeight; + + return this; + }; + + /** + * Sets whether the token has unlimited duration (no expiration). + * + * @param {boolean} isUnlimitedDuration - True if the token has unlimited duration, false otherwise. + * @returns {TokenFixtureBuilder} The builder instance. + */ + setIsUnlimitedDuration = isUnlimitedDuration => { + this._data.isUnlimitedDuration = isUnlimitedDuration; + + return this; + }; + + /** + * Sets the creator address for the token. + * + * @param {string} creator - The creator address. + * @returns {TokenFixtureBuilder} The builder instance. + */ + setCreator = creator => { + this._data.creator = creator; + + return this; + }; } diff --git a/wallet/symbol/mobile/__fixtures__/local/TransactionFeeFixtureBuilder.js b/wallet/symbol/mobile/__fixtures__/local/TransactionFeeFixtureBuilder.js new file mode 100644 index 000000000..3f5fed5f3 --- /dev/null +++ b/wallet/symbol/mobile/__fixtures__/local/TransactionFeeFixtureBuilder.js @@ -0,0 +1,248 @@ + +import { TokenFixtureBuilder } from '__fixtures__/local/TokenFixtureBuilder'; + +const DEFAULT_CHAIN_NAME = 'symbol'; +const DEFAULT_NETWORK_IDENTIFIER = 'testnet'; + +const createTokenWithAmount = (chainName, networkIdentifier, amount) => { + return TokenFixtureBuilder + .createWithToken(chainName, networkIdentifier, 0) + .setAmount(amount) + .build(); +}; + +const createSymbolTransactionFeeTiers = (chainName, networkIdentifier, slow, medium, fast) => { + const tokenSlow = createTokenWithAmount(chainName, networkIdentifier, slow); + const tokenMedium = createTokenWithAmount(chainName, networkIdentifier, medium); + const tokenFast = createTokenWithAmount(chainName, networkIdentifier, fast); + + return { + slow: { token: tokenSlow }, + medium: { token: tokenMedium }, + fast: { token: tokenFast } + }; +}; + +const createEthereumTransactionFeeTiers = (chainName, networkIdentifier, slow, medium, fast) => { + const tokenSlow = createTokenWithAmount(chainName, networkIdentifier, slow); + const tokenMedium = createTokenWithAmount(chainName, networkIdentifier, medium); + const tokenFast = createTokenWithAmount(chainName, networkIdentifier, fast); + + return { + slow: { + gasLimit: '1000', + maxFeePerGas: '0.1', + maxPriorityFeePerGas: '5', + token: tokenSlow + }, + medium: { + gasLimit: '1000', + maxFeePerGas: '0.2', + maxPriorityFeePerGas: '6', + token: tokenMedium + }, + fast: { + gasLimit: '1000', + maxFeePerGas: '0.3', + maxPriorityFeePerGas: '7', + token: tokenFast + } + }; +}; + +const EMPTY_FIXTURE_SYMBOL = createSymbolTransactionFeeTiers('symbol', 'testnet', '0', '0', '0'); +const EMPTY_FIXTURE_ETHEREUM = createEthereumTransactionFeeTiers('ethereum', 'testnet', '0', '0', '0'); + +const createTransactionFeeTiers = (chainName, networkIdentifier, slow, medium, fast) => { + return 'ethereum' === chainName + ? createEthereumTransactionFeeTiers(chainName, networkIdentifier, slow, medium, fast) + : createSymbolTransactionFeeTiers(chainName, networkIdentifier, slow, medium, fast); +}; + +export class TransactionFeeFixtureBuilder { + _data = {}; + _chainName = DEFAULT_CHAIN_NAME; + _networkIdentifier = DEFAULT_NETWORK_IDENTIFIER; + + /** + * Creates a transaction fee fixture with the provided data. + * + * @param {import('wallet-common-core/src/types/Transaction').TransactionFeeTiers | object} data - transaction fee data. + * @param {'symbol' | 'ethereum'} [chainName] - Chain name. + * @param {'mainnet' | 'testnet'} [networkIdentifier] - Network identifier. + */ + constructor(data, chainName = DEFAULT_CHAIN_NAME, networkIdentifier = DEFAULT_NETWORK_IDENTIFIER) { + this._data = { ...data }; + this._chainName = chainName; + this._networkIdentifier = networkIdentifier; + } + + /** + * Creates an empty transaction fee fixture. + * + * @param {'symbol' | 'ethereum'} [chainName='symbol'] - Chain name. + * @returns {TransactionFeeFixtureBuilder} + */ + static createEmpty = (chainName = DEFAULT_CHAIN_NAME) => { + const data = 'ethereum' === chainName ? EMPTY_FIXTURE_ETHEREUM : EMPTY_FIXTURE_SYMBOL; + + return new TransactionFeeFixtureBuilder(data, chainName, DEFAULT_NETWORK_IDENTIFIER); + }; + + /** + * Creates a transaction fee fixture with the provided chain and network. + * + * @param {'symbol' | 'ethereum'} chainName - Chain name. + * @param {'mainnet' | 'testnet'} networkIdentifier - Network identifier. + * @returns {TransactionFeeFixtureBuilder} + */ + static createWithNetwork = (chainName, networkIdentifier) => { + return new TransactionFeeFixtureBuilder( + createTransactionFeeTiers(chainName, networkIdentifier, '0', '0', '0'), + chainName, + networkIdentifier + ); + }; + + /** + * Creates a transaction fee fixture with the provided amounts for each fee tier. + * + * @param {string} slow - The fee amount for the slow tier. + * @param {string} medium - The fee amount for the medium tier. + * @param {string} fast - The fee amount for the fast tier. + * @param {'symbol' | 'ethereum'} [chainName='symbol'] - Chain name. + * @param {'mainnet' | 'testnet'} [networkIdentifier='testnet'] - Network identifier. + * @returns {TransactionFeeFixtureBuilder} + */ + static createWithAmounts = ( + slow, + medium, + fast, + chainName = DEFAULT_CHAIN_NAME, + networkIdentifier = DEFAULT_NETWORK_IDENTIFIER + ) => { + return new TransactionFeeFixtureBuilder( + createTransactionFeeTiers(chainName, networkIdentifier, slow, medium, fast), + chainName, + networkIdentifier + ); + }; + + /** + * Creates a transaction fee fixture with the provided data. + * + * @param {import('wallet-common-core/src/types/Transaction').TransactionFeeTiers | object} data - transaction fee data. + * @param {'symbol' | 'ethereum'} [chainName='symbol'] - Chain name. + * @param {'mainnet' | 'testnet'} [networkIdentifier='testnet'] - Network identifier. + * @returns {TransactionFeeFixtureBuilder} + */ + static createWithData = ( + data, + chainName = DEFAULT_CHAIN_NAME, + networkIdentifier = DEFAULT_NETWORK_IDENTIFIER + ) => { + return new TransactionFeeFixtureBuilder(data, chainName, networkIdentifier); + }; + + /** + * Gets the built transaction fee data. + * + * @returns {import('wallet-common-core/src/types/Transaction').TransactionFeeTiers | object} + */ + build() { + return { ...this._data }; + }; + + /** + * Overrides the transaction fee data with the provided data. + * + * @param {Partial | object} data - The data to override. + * @returns {TransactionFeeFixtureBuilder} The builder instance. + */ + override = data => { + this._data = { ...this._data, ...data }; + + return this; + }; + + /** + * Sets slow tier token amount. + * + * @param {string} amount - Slow tier amount. + * @returns {TransactionFeeFixtureBuilder} The builder instance. + */ + setSlowAmount = amount => { + this._data.slow = { + ...this._data.slow, + token: createTokenWithAmount(this._chainName, this._networkIdentifier, amount) + }; + + return this; + }; + + /** + * Sets medium tier token amount. + * + * @param {string} amount - Medium tier amount. + * @returns {TransactionFeeFixtureBuilder} The builder instance. + */ + setMediumAmount = amount => { + this._data.medium = { + ...this._data.medium, + token: createTokenWithAmount(this._chainName, this._networkIdentifier, amount) + }; + + return this; + }; + + /** + * Sets fast tier token amount. + * + * @param {string} amount - Fast tier amount. + * @returns {TransactionFeeFixtureBuilder} The builder instance. + */ + setFastAmount = amount => { + this._data.fast = { + ...this._data.fast, + token: createTokenWithAmount(this._chainName, this._networkIdentifier, amount) + }; + + return this; + }; + + /** + * Overrides slow tier. + * + * @param {object} slow - Slow tier override data. + * @returns {TransactionFeeFixtureBuilder} The builder instance. + */ + overrideSlow = slow => { + this._data.slow = { ...this._data.slow, ...slow }; + + return this; + }; + + /** + * Overrides medium tier. + * + * @param {object} medium - Medium tier override data. + * @returns {TransactionFeeFixtureBuilder} The builder instance. + */ + overrideMedium = medium => { + this._data.medium = { ...this._data.medium, ...medium }; + + return this; + }; + + /** + * Overrides fast tier. + * + * @param {object} fast - Fast tier override data. + * @returns {TransactionFeeFixtureBuilder} The builder instance. + */ + overrideFast = fast => { + this._data.fast = { ...this._data.fast, ...fast }; + + return this; + }; +} diff --git a/wallet/symbol/mobile/__fixtures__/local/network.js b/wallet/symbol/mobile/__fixtures__/local/network.js index 82fe4e2fa..09339ec48 100644 --- a/wallet/symbol/mobile/__fixtures__/local/network.js +++ b/wallet/symbol/mobile/__fixtures__/local/network.js @@ -40,5 +40,55 @@ export const networkProperties = { ...tokens.symbol.testnet[0] } } + }, + ethereum: { + mainnet: { + nodeUrl: 'https://node.ethereum.com:8545', + wsUrl: 'wss://node.ethereum.com:8545/ws', + networkIdentifier: 'mainnet', + chainId: 1, + chainHeight: 9876543, + transactionFees: { + slow: { + maxPriorityFeePerGas: '0.000000002', + maxFeePerGas: '0.000000122' + }, + medium: { + maxPriorityFeePerGas: '0.000000003', + maxFeePerGas: '0.000000153' + }, + fast: { + maxPriorityFeePerGas: '0.000000004', + maxFeePerGas: '0.000000204' + } + }, + networkCurrency: { + ...tokens.ethereum.mainnet[0] + } + }, + testnet: { + nodeUrl: 'https://node.ethereum.com:8545', + wsUrl: 'wss://node.ethereum.com:8545/ws', + networkIdentifier: 'testnet', + chainId: 5, + chainHeight: 1319595, + transactionFees: { + slow: { + maxPriorityFeePerGas: '0.000000002', + maxFeePerGas: '0.000000122' + }, + medium: { + maxPriorityFeePerGas: '0.000000003', + maxFeePerGas: '0.000000153' + }, + fast: { + maxPriorityFeePerGas: '0.000000004', + maxFeePerGas: '0.000000204' + } + }, + networkCurrency: { + ...tokens.ethereum.testnet[0] + } + } } }; diff --git a/wallet/symbol/mobile/__fixtures__/local/token.js b/wallet/symbol/mobile/__fixtures__/local/token.js index 9f8896fce..a7ed6aa9b 100644 --- a/wallet/symbol/mobile/__fixtures__/local/token.js +++ b/wallet/symbol/mobile/__fixtures__/local/token.js @@ -7,17 +7,17 @@ export const tokens = { divisibility: 6 }, { - name: 'Test Token 1', + name: 'Mainnet Symbol Token 1', id: '3A8416DB2D53B6C8', divisibility: 0 }, { - name: 'Test Token 2', + name: 'Mainnet Symbol Token 2', id: '4A8416DB2D53B6C8', divisibility: 3 }, { - name: 'Test Token 3', + name: 'Mainnet Symbol Token 3', id: '5A8416DB2D53B6C8', divisibility: 2 } @@ -29,20 +29,46 @@ export const tokens = { divisibility: 6 }, { - name: 'Test Token 1', + name: 'Testnet Symbol Token 1', id: 'E74B99BA41F4AFB4', divisibility: 0 }, { - name: 'Test Token 2', + name: 'Testnet Symbol Token 2', id: 'F74B99BA41F4AFB4', divisibility: 3 }, { - name: 'Test Token 3', + name: 'Testnet Symbol Token 3', id: '0874B99BA41F4AFB4', divisibility: 2 } ] + }, + ethereum: { + mainnet: [ + { + name: 'ETH', + id: 'ETH', + divisibility: 18 + }, + { + name: 'WXYM', + id: '0x5E8343A455F03109B737B6D8b410e4ECCE998cdA', + divisibility: 6 + } + ], + testnet: [ + { + name: 'ETH', + id: 'ETH', + divisibility: 18 + }, + { + name: 'WXYM', + id: '0x5E8343A455F03109B737B6D8b410e4ECCE998cdA', + divisibility: 6 + } + ] } }; diff --git a/wallet/symbol/mobile/__fixtures__/local/wallet.js b/wallet/symbol/mobile/__fixtures__/local/wallet.js index ae98c1b1e..bf91bdf84 100644 --- a/wallet/symbol/mobile/__fixtures__/local/wallet.js +++ b/wallet/symbol/mobile/__fixtures__/local/wallet.js @@ -98,6 +98,100 @@ export const walletStorageAccounts = { privateKey: '14F81341CEB72DDD694E7D4D3C17F4D58A6F00FFDAFCDA1A4BD2A554D67E3C95' } ] + }, + ethereum: { + mainnet: [ + { + address: '0xb1b2145b7d2ba5ab20ee0bcb0f7fad08a1bfc7a4', + publicKey: '0x04d180bfa90bb100d21df55b10cc535b392e87d595593afa9de219f4bd006bd2893d80827f43c47794029a8b4218699e65d837a6beb95b5b2f95a31b52f3e93b13', + networkIdentifier: 'mainnet', + index: 0, + accountType: 'mnemonic', + name: 'Ethereum Mainnet 0' + }, + { + address: '0x38f3fa5dfb5359f8425bc90b4ebdeaf96d0670c4', + publicKey: '0x04a172ab918fac698c5792c9a467f1323e522cef6ac42bf64798fa397e8153cf67bb4abb9a6fdb67024cbb64775c391b3168dbcd2fb3736085ec4e3a059caaecd4', + networkIdentifier: 'mainnet', + index: 1, + accountType: 'mnemonic', + privateKey: '0x17bf8d4fadf6e9f0bffefaa94f13068bae2f9521176008baa4892ac8345a453b', + name: 'Ethereum Mainnet 1' + }, + { + address: '0xc5d9cf0ee687e357aea5d26592f8bc9fe32abaa2', + publicKey: '0x04e9b65ffc96c359d93d9b4cd956e3e823cff31fe20697f2d82b279eb492352211738c1c1fc91ceafcf9f175be1c0ab9112c426feb6989dd11a296ae681e9fb0a3', + networkIdentifier: 'mainnet', + index: 2, + accountType: 'mnemonic', + privateKey: '0x28f5122129ba513e4dc6cab5eede6675eda0f2e6a3283a14c085981fc5089388', + name: 'Ethereum Mainnet 2' + }, + { + address: '0xe61c8ba605b4a808dd8138c990e941feae532307', + publicKey: '0x04564f21add19960bac1dc859da23e037d9436fee8e8dc5aa6a993a102528f5f8824093e5b06ac383be415cb1f533a082b5372c762ab74eed7b9240080b873930a', + networkIdentifier: 'mainnet', + index: 3, + accountType: 'mnemonic', + privateKey: '0xf3890a0e4292269fa16950f48ccb031f30a5f0b2a75e65c9388fe45542d85961', + name: 'Ethereum Mainnet 3' + }, + { + address: '0x6fe1f90116fd1225c4b713a6efb3f87dce77b445', + publicKey: '0x0440b16472b032c0b659cdb0e4a3cd016441849fd4b765b294a2098ae958fce685327d7ef687d07985f6c6ff590039a7adb651fb8412c2606feebd6c5b660d1d7e', + networkIdentifier: 'mainnet', + index: 4, + accountType: 'mnemonic', + privateKey: '0xec880a6bdd3150dc8e6a7b9c3ca5fe1edb6ba34e9b05b7522f7567480cfe004f', + name: 'Ethereum Mainnet 4' + } + ], + testnet: [ + { + address: '0xb1b2145b7d2ba5ab20ee0bcb0f7fad08a1bfc7a4', + publicKey: '0x04d180bfa90bb100d21df55b10cc535b392e87d595593afa9de219f4bd006bd2893d80827f43c47794029a8b4218699e65d837a6beb95b5b2f95a31b52f3e93b13', + networkIdentifier: 'testnet', + index: 0, + accountType: 'mnemonic', + name: 'Ethereum Testnet 0' + }, + { + address: '0x38f3fa5dfb5359f8425bc90b4ebdeaf96d0670c4', + publicKey: '0x04a172ab918fac698c5792c9a467f1323e522cef6ac42bf64798fa397e8153cf67bb4abb9a6fdb67024cbb64775c391b3168dbcd2fb3736085ec4e3a059caaecd4', + networkIdentifier: 'testnet', + index: 1, + accountType: 'mnemonic', + privateKey: '0x17bf8d4fadf6e9f0bffefaa94f13068bae2f9521176008baa4892ac8345a453b', + name: 'Ethereum Testnet 1' + }, + { + address: '0xc5d9cf0ee687e357aea5d26592f8bc9fe32abaa2', + publicKey: '0x04e9b65ffc96c359d93d9b4cd956e3e823cff31fe20697f2d82b279eb492352211738c1c1fc91ceafcf9f175be1c0ab9112c426feb6989dd11a296ae681e9fb0a3', + networkIdentifier: 'testnet', + index: 2, + accountType: 'mnemonic', + privateKey: '0x28f5122129ba513e4dc6cab5eede6675eda0f2e6a3283a14c085981fc5089388', + name: 'Ethereum Testnet 2' + }, + { + address: '0xe61c8ba605b4a808dd8138c990e941feae532307', + publicKey: '0x04564f21add19960bac1dc859da23e037d9436fee8e8dc5aa6a993a102528f5f8824093e5b06ac383be415cb1f533a082b5372c762ab74eed7b9240080b873930a', + networkIdentifier: 'testnet', + index: 3, + accountType: 'mnemonic', + privateKey: '0xf3890a0e4292269fa16950f48ccb031f30a5f0b2a75e65c9388fe45542d85961', + name: 'Ethereum Testnet 3' + }, + { + address: '0x6fe1f90116fd1225c4b713a6efb3f87dce77b445', + publicKey: '0x0440b16472b032c0b659cdb0e4a3cd016441849fd4b765b294a2098ae958fce685327d7ef687d07985f6c6ff590039a7adb651fb8412c2606feebd6c5b660d1d7e', + networkIdentifier: 'testnet', + index: 4, + accountType: 'mnemonic', + privateKey: '0xec880a6bdd3150dc8e6a7b9c3ca5fe1edb6ba34e9b05b7522f7567480cfe004f', + name: 'Ethereum Testnet 4' + } + ] } }; diff --git a/wallet/symbol/mobile/__tests__/HookTester.js b/wallet/symbol/mobile/__tests__/HookTester.js new file mode 100644 index 000000000..d8b17a11d --- /dev/null +++ b/wallet/symbol/mobile/__tests__/HookTester.js @@ -0,0 +1,70 @@ +import { act, renderHook, waitFor } from '@testing-library/react-native'; + +/** + * A helper class to facilitate testing of React Native hooks. + * + * @class HookTester + */ +export class HookTester { + /** @type {import('@testing-library/react-native').RenderHookResult} */ + hookRenderer; + + /** + * Creates an instance of HookTester. + * + * @param {Function} hook - The hook function to test. + * @param {Array} props - Array of hook props to pass to the function as arguments. + */ + constructor(hook, props = []) { + const config = { + initialProps: props + }; + + this.hookRenderer = renderHook( + props => hook(...props), + config + ); + }; + + get currentResult() { + return this.hookRenderer.result?.current; + } + + /** + * Updates the props passed to the hook and re-renders it. + * + * @param {Array} newProps - The new props to pass to the hook as arguments. + */ + updateProps = newProps => { + this.hookRenderer.rerender(newProps); + }; + + /** + * Expects the current result of the hook to strictly equal the expected value. + * + * @param {any} expected - The expected value to compare against the current result. + */ + expectResult = expected => { + expect(this.currentResult).toStrictEqual(expected); + }; + + /** + * Advances timers by a specified time to simulate waiting. + * + * @param {number} time - The time in milliseconds to advance timers by. Default is 2000ms. + */ + waitForTimer = async (time = 2000) => { + await act(async () => { + jest.advanceTimersByTime(time); + }); + }; + + /** + * Waits for the expected condition to be met. + * + * @param {function} callback - The callback function to wait for. + */ + waitFor = async callback => { + await waitFor(callback); + }; +}; diff --git a/wallet/symbol/mobile/__tests__/Router.test.js b/wallet/symbol/mobile/__tests__/Router.test.js index f6d480176..05633ede9 100644 --- a/wallet/symbol/mobile/__tests__/Router.test.js +++ b/wallet/symbol/mobile/__tests__/Router.test.js @@ -68,9 +68,15 @@ jest.mock('@/app/screens', () => { ImportWallet: createMockScreen('ImportWallet'), Home: createMockScreen('Home'), History: createMockScreen('History'), + Assets: createMockScreen('Assets'), + TokenDetails: createMockScreen('TokenDetails'), AccountDetails: createMockScreen('AccountDetails'), AccountList: createMockScreen('AccountList'), AddSeedAccount: createMockScreen('AddSeedAccount'), + BridgeAccountList: createMockScreen('BridgeAccountList'), + BridgeAccountDetails: createMockScreen('BridgeAccountDetails'), + BridgeSwap: createMockScreen('BridgeSwap'), + BridgeSwapDetails: createMockScreen('BridgeSwapDetails'), Send: createMockScreen('Send'), Settings: createMockScreen('Settings'), SettingsAbout: createMockScreen('SettingsAbout'), @@ -107,6 +113,11 @@ const NAVIGATION_SCREENS_CONFIG = [ shouldReset: true, hasParams: false }, + { + screenName: 'Assets', + shouldReset: true, + hasParams: false + }, { screenName: 'Send', shouldReset: false, diff --git a/wallet/symbol/mobile/__tests__/ScreenTester.js b/wallet/symbol/mobile/__tests__/ScreenTester.js index 7b0c81616..729637990 100644 --- a/wallet/symbol/mobile/__tests__/ScreenTester.js +++ b/wallet/symbol/mobile/__tests__/ScreenTester.js @@ -155,6 +155,25 @@ export class ScreenTester { } }; + /** + * Asserts that a loading indicator is present on the screen. + * + * @param {number} [count=1] - The expected number of loading indicators. + */ + expectLoadingIndicator = (count = 1) => { + const { getAllByTestId } = this.renderer; + const indicators = getAllByTestId('loading-indicator'); + expect(indicators.length).toBe(count); + }; + + /** + * Asserts that no loading indicator is present on the screen. + */ + notExpectLoadingIndicator = () => { + const { queryByTestId } = this.renderer; + expect(queryByTestId('loading-indicator')).toBeNull(); + }; + /** * Asserts that an element with the specified testID is present on the screen. * diff --git a/wallet/symbol/mobile/__tests__/components/controls/FeeSelector.test.js b/wallet/symbol/mobile/__tests__/components/controls/FeeSelector.test.js index baf38bb99..c36c7d973 100644 --- a/wallet/symbol/mobile/__tests__/components/controls/FeeSelector.test.js +++ b/wallet/symbol/mobile/__tests__/components/controls/FeeSelector.test.js @@ -46,6 +46,7 @@ const createDefaultProps = (overrides = {}) => ({ feeTiers: FEE_TIERS_DEFAULT, value: FeeTierLevel.MEDIUM, onChange: jest.fn(), + ticker: 'XYM', ...overrides }); diff --git a/wallet/symbol/mobile/__tests__/components/controls/SelectToken.test.js b/wallet/symbol/mobile/__tests__/components/controls/SelectToken.test.js index 20efb5296..0d111bc9e 100644 --- a/wallet/symbol/mobile/__tests__/components/controls/SelectToken.test.js +++ b/wallet/symbol/mobile/__tests__/components/controls/SelectToken.test.js @@ -1,8 +1,10 @@ import { SelectToken } from '@/app/components/controls/SelectToken'; import { TokenFixtureBuilder } from '__fixtures__/local/TokenFixtureBuilder'; +import { ScreenTester } from '__tests__/ScreenTester'; import { runDropdownSelectTest, runRenderTextTest } from '__tests__/component-tests'; import { mockLocalization } from '__tests__/mock-helpers'; -import { fireEvent, render } from '@testing-library/react-native'; + +// Mocks jest.mock('@/app/utils', () => ({ getTokenKnownInfo: (chainName, networkIdentifier, tokenId) => { @@ -15,48 +17,62 @@ jest.mock('@/app/utils', () => ({ } })); +// Constants + +const CHAIN_NAME = 'symbol'; +const NETWORK_IDENTIFIER = 'mainnet'; + +// Screen Text + const SCREEN_TEXT = { - selectTokenLabel: 'select_token', + inputSelectTokenLabel: 'select_token', textSymbolTokenName: 'Symbol Token', textCustomTokenName: 'Custom Token', - textUnknownTokenName: 'Test Token 2', + textUnknownTokenName: 'Mainnet Symbol Token 2', textSymbolTokenDisplay: 'Symbol Token • XYM', textCustomTokenDisplay: 'Custom Token • CTK' }; -const TOKEN_SYMBOL = TokenFixtureBuilder - .createWithToken('symbol', 'mainnet', 0) +// Token Fixtures + +const tokenSymbol = TokenFixtureBuilder + .createWithToken(CHAIN_NAME, NETWORK_IDENTIFIER, 0) .setAmount('1000') .build(); -const TOKEN_CUSTOM = TokenFixtureBuilder - .createWithToken('symbol', 'mainnet', 1) +const tokenCustom = TokenFixtureBuilder + .createWithToken(CHAIN_NAME, NETWORK_IDENTIFIER, 1) .setAmount('500') .build(); -const TOKEN_UNKNOWN = TokenFixtureBuilder - .createWithToken('symbol', 'mainnet', 2) +const tokenUnknown = TokenFixtureBuilder + .createWithToken(CHAIN_NAME, NETWORK_IDENTIFIER, 2) .setAmount('250') .build(); -const TOKEN_LIST = [TOKEN_SYMBOL, TOKEN_CUSTOM, TOKEN_UNKNOWN]; +const TOKEN_LIST = [tokenSymbol, tokenCustom, tokenUnknown]; + +// Dropdown Items const DROPDOWN_ITEMS = [ - { value: TOKEN_SYMBOL.id, label: SCREEN_TEXT.textSymbolTokenDisplay }, - { value: TOKEN_CUSTOM.id, label: SCREEN_TEXT.textCustomTokenDisplay }, - { value: TOKEN_UNKNOWN.id, label: SCREEN_TEXT.textUnknownTokenName } + { value: tokenSymbol.id, label: SCREEN_TEXT.textSymbolTokenDisplay }, + { value: tokenCustom.id, label: SCREEN_TEXT.textCustomTokenDisplay }, + { value: tokenUnknown.id, label: SCREEN_TEXT.textUnknownTokenName } ]; +// Props Factory + const createDefaultProps = (overrides = {}) => ({ - label: SCREEN_TEXT.selectTokenLabel, - value: TOKEN_SYMBOL.id, + label: SCREEN_TEXT.inputSelectTokenLabel, + value: tokenSymbol.id, tokens: TOKEN_LIST, - chainName: 'symbol', - networkIdentifier: 'mainnet', + chainName: CHAIN_NAME, + networkIdentifier: NETWORK_IDENTIFIER, onChange: jest.fn(), ...overrides }); +// Tests describe('components/SelectToken', () => { beforeEach(() => { @@ -66,7 +82,7 @@ describe('components/SelectToken', () => { runRenderTextTest(SelectToken, { props: createDefaultProps(), textToRender: [ - { type: 'text', value: SCREEN_TEXT.selectTokenLabel }, + { type: 'text', value: SCREEN_TEXT.inputSelectTokenLabel }, { type: 'text', value: SCREEN_TEXT.textSymbolTokenName } ] }); @@ -85,27 +101,27 @@ describe('components/SelectToken', () => { const props = createDefaultProps(config.props); // Act: - const { getByText } = render(); + const screenTester = new ScreenTester(SelectToken, props); // Assert: - expect(getByText(expected.displayedName)).toBeTruthy(); + screenTester.expectText([expected.displayedName]); }); }; const tokenNameResolutionTests = [ { description: 'displays resolved name from getTokenKnownInfo', - config: { props: { value: TOKEN_SYMBOL.id } }, + config: { props: { value: tokenSymbol.id } }, expected: { displayedName: SCREEN_TEXT.textSymbolTokenName } }, { description: 'displays resolved name for custom token', - config: { props: { value: TOKEN_CUSTOM.id } }, + config: { props: { value: tokenCustom.id } }, expected: { displayedName: SCREEN_TEXT.textCustomTokenName } }, { description: 'falls back to token.name when getTokenKnownInfo returns null', - config: { props: { value: TOKEN_UNKNOWN.id } }, + config: { props: { value: tokenUnknown.id } }, expected: { displayedName: SCREEN_TEXT.textUnknownTokenName } } ]; @@ -116,42 +132,37 @@ describe('components/SelectToken', () => { }); describe('token list', () => { - it('renders all tokens in dropdown when opened', async () => { + it('renders all tokens in dropdown when opened', () => { // Arrange: const props = createDefaultProps(); - const { getByText, findByText } = render(); + const screenTester = new ScreenTester(SelectToken, props); // Act: - const trigger = getByText(SCREEN_TEXT.textSymbolTokenName); - fireEvent.press(trigger); + screenTester.pressButton(SCREEN_TEXT.textSymbolTokenName); // Assert: - const symbolToken = await findByText(SCREEN_TEXT.textSymbolTokenDisplay); - const customToken = await findByText(SCREEN_TEXT.textCustomTokenDisplay); - const unknownToken = await findByText(SCREEN_TEXT.textUnknownTokenName); - - expect(symbolToken).toBeTruthy(); - expect(customToken).toBeTruthy(); - expect(unknownToken).toBeTruthy(); + screenTester.expectText([ + SCREEN_TEXT.textSymbolTokenDisplay, + SCREEN_TEXT.textCustomTokenDisplay, + SCREEN_TEXT.textUnknownTokenName + ]); }); }); describe('onChange', () => { const runOnChangeTest = (description, config, expected) => { - it(description, async () => { + it(description, () => { // Arrange: const onChangeMock = jest.fn(); const props = createDefaultProps({ value: config.initialValue, onChange: onChangeMock }); - const { getByText, findByText } = render(); + const screenTester = new ScreenTester(SelectToken, props); // Act: - const trigger = getByText(config.triggerText); - fireEvent.press(trigger); - const tokenOption = await findByText(config.selectText); - fireEvent.press(tokenOption); + screenTester.pressButton(config.triggerText); + screenTester.pressButton(config.selectText); // Assert: expect(onChangeMock).toHaveBeenCalledWith(expected.selectedValue); @@ -162,29 +173,29 @@ describe('components/SelectToken', () => { { description: 'calls onChange with custom token id when selected', config: { - initialValue: TOKEN_SYMBOL.id, + initialValue: tokenSymbol.id, triggerText: SCREEN_TEXT.textSymbolTokenName, selectText: SCREEN_TEXT.textCustomTokenDisplay }, - expected: { selectedValue: TOKEN_CUSTOM.id } + expected: { selectedValue: tokenCustom.id } }, { description: 'calls onChange with unknown token id when selected', config: { - initialValue: TOKEN_SYMBOL.id, + initialValue: tokenSymbol.id, triggerText: SCREEN_TEXT.textSymbolTokenName, selectText: SCREEN_TEXT.textUnknownTokenName }, - expected: { selectedValue: TOKEN_UNKNOWN.id } + expected: { selectedValue: tokenUnknown.id } }, { description: 'calls onChange with symbol token id when selected from custom', config: { - initialValue: TOKEN_CUSTOM.id, + initialValue: tokenCustom.id, triggerText: SCREEN_TEXT.textCustomTokenName, selectText: SCREEN_TEXT.textSymbolTokenDisplay }, - expected: { selectedValue: TOKEN_SYMBOL.id } + expected: { selectedValue: tokenSymbol.id } } ]; @@ -199,10 +210,10 @@ describe('components/SelectToken', () => { const props = createDefaultProps({ tokens: [], value: '' }); // Act: - const { getByText } = render(); + const screenTester = new ScreenTester(SelectToken, props); // Assert: - expect(getByText(SCREEN_TEXT.selectTokenLabel)).toBeTruthy(); + screenTester.expectText([SCREEN_TEXT.inputSelectTokenLabel]); }); }); }); diff --git a/wallet/symbol/mobile/__tests__/hook-tests.js b/wallet/symbol/mobile/__tests__/hook-tests.js new file mode 100644 index 000000000..f3f8ae967 --- /dev/null +++ b/wallet/symbol/mobile/__tests__/hook-tests.js @@ -0,0 +1,65 @@ +import { renderHook } from '@testing-library/react-native'; +import { act } from 'react'; + +/** + * Runs contract tests for a given hook. + * + * @param {Function} hook - The custom React hook to be tested. + * @param {Object} options - The options for the contract test. + * @param {Object} options.props - The props to be passed to the hook. + * @param {boolean} [options.waitAsyncEffects=false] - Whether to wait for async effects to resolve before assertions. + * @param {Object} options.contract - The contract defining the expected fields and their types. + * + * @example + * runHookContractTest(useMyCustomHook, { + * props: { a: 1, b: 'test' }, + * contract: { + * field1: 'string', + * field2: 'number', + * field3: 'function' + * } + * }); + */ +export const runHookContractTest = (hook, { + props = [], + waitAsyncEffects = false, + contract +}) => { + describe('contract', () => { + it('returns all expected fields', async () => { + // Act: + const { result } = renderHook(() => hook(...props)); + + if (waitAsyncEffects) { + await act(async () => { + await Promise.resolve(); + }); + } + + // Assert: + Object.keys(contract).forEach(key => { + expect(result.current).toHaveProperty(key); + if (contract[key] === 'array') + expect(Array.isArray(result.current[key])).toBe(true); + else + expect(typeof result.current[key]).toBe(contract[key]); + }); + }); + + it('does not return unexpected fields', async () => { + // Act: + const { result } = renderHook(() => hook(...props)); + + if (waitAsyncEffects) { + await act(async () => { + await Promise.resolve(); + }); + } + + // Assert: + Object.keys(result.current).forEach(key => { + expect(contract).toHaveProperty(key); + }); + }); + }); +}; diff --git a/wallet/symbol/mobile/__tests__/mock-helpers.js b/wallet/symbol/mobile/__tests__/mock-helpers.js index 405908930..f9ec00fa1 100644 --- a/wallet/symbol/mobile/__tests__/mock-helpers.js +++ b/wallet/symbol/mobile/__tests__/mock-helpers.js @@ -6,13 +6,14 @@ import { jest } from '@jest/globals'; import SplashScreen from 'react-native-splash-screen'; /** - * Mocks the useWalletController hook to simulate wallet controller behavior. + * Create a mock wallet controller object to simulate wallet controller behavior. * * @param {Object} overrides - An object to override specific methods of the wallet controller mock. + * @param {Object} [options] - Mock options. * * @return {import('wallet-common-core').WalletController} The mocked wallet controller. */ -export const mockWalletController = (overrides = {}) => { +export const createWalletControllerMock = (overrides = {}) => { const walletControllerMock = { chainName: 'symbol', networkApi: {}, @@ -36,6 +37,7 @@ export const mockWalletController = (overrides = {}) => { isNetworkConnectionReady: true, isStateReady: true, isWalletReady: true, + modules: {}, loadCache: jest.fn(), selectAccount: jest.fn(), @@ -65,6 +67,20 @@ export const mockWalletController = (overrides = {}) => { removeListener: jest.fn(), ...overrides }; + + return walletControllerMock; +}; + +/** + * Mocks the useWalletController hook to simulate wallet controller behavior. + * + * @param {Object} overrides - An object to override specific methods of the wallet controller mock. + * @param {Object} [options] - Mock options. + * + * @return {import('wallet-common-core').WalletController} The mocked wallet controller. + */ +export const mockWalletController = (overrides = {}) => { + const walletControllerMock = createWalletControllerMock(overrides); jest.spyOn(hooks, 'useWalletController').mockReturnValue(walletControllerMock); return walletControllerMock; diff --git a/wallet/symbol/mobile/__tests__/screens/assets/Assets.test.js b/wallet/symbol/mobile/__tests__/screens/assets/Assets.test.js new file mode 100644 index 000000000..e0390db9e --- /dev/null +++ b/wallet/symbol/mobile/__tests__/screens/assets/Assets.test.js @@ -0,0 +1,489 @@ +import { walletControllers } from '@/app/lib/controller'; +import { Assets } from '@/app/screens/assets/Assets'; +import { AccountFixtureBuilder } from '__fixtures__/local/AccountFixtureBuilder'; +import { AccountInfoFixtureBuilder } from '__fixtures__/local/AccountInfoFixtureBuilder'; +import { NetworkPropertiesFixtureBuilder } from '__fixtures__/local/NetworkPropertiesFixtureBuilder'; +import { TokenFixtureBuilder } from '__fixtures__/local/TokenFixtureBuilder'; +import { ScreenTester } from '__tests__/ScreenTester'; +import { mockLocalization, mockOs } from '__tests__/mock-helpers'; +import { act } from '@testing-library/react-native'; +import { constants } from 'wallet-common-core'; + +const { ControllerEventName } = constants; + +// Mocks + +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useIsFocused: () => true, + useNavigation: () => ({ + navigate: jest.fn(), + goBack: jest.fn() + }) +})); + +jest.mock('@/app/lib/controller', () => ({ + walletControllers: { + main: {}, + additional: [] + } +})); + +// Constants + +const CHAIN_NAME_SYMBOL = 'symbol'; +const CHAIN_NAME_ETHEREUM = 'ethereum'; +const NETWORK_IDENTIFIER = 'testnet'; +const CHAIN_HEIGHT = 150_000; + +// Screen Text + +const SCREEN_TEXT = { + textFilterExpired: 's_assets_filter_expired', + textFilterCreated: 's_assets_filter_created', + buttonClear: 'button_clear', + textEmptyList: 'message_emptyList' +}; + +// Account Fixtures + +const symbolMainAccount = AccountFixtureBuilder + .createWithAccount(CHAIN_NAME_SYMBOL, NETWORK_IDENTIFIER, 0) + .build(); + +const symbolExternalAccount = AccountFixtureBuilder + .createWithAccount(CHAIN_NAME_SYMBOL, NETWORK_IDENTIFIER, 1) + .build(); + +const ethereumMainAccount = AccountFixtureBuilder + .createWithAccount(CHAIN_NAME_ETHEREUM, NETWORK_IDENTIFIER, 0) + .build(); + +// Network Properties Fixtures + +const networkPropertiesSymbol = NetworkPropertiesFixtureBuilder + .createWithType(CHAIN_NAME_SYMBOL, NETWORK_IDENTIFIER) + .setChainHeight(CHAIN_HEIGHT) + .build(); + +const networkPropertiesEthereum = NetworkPropertiesFixtureBuilder + .createWithData({ + networkIdentifier: NETWORK_IDENTIFIER, + chainHeight: 50_000, + blockGenerationTargetTime: 12, + networkCurrency: { name: 'eth', id: 'ETH', divisibility: 18 } + }) + .build(); + +// Token Fixtures + +const tokenSymbolCreatedActive = TokenFixtureBuilder + .createWithToken(CHAIN_NAME_SYMBOL, NETWORK_IDENTIFIER, 1) + .setId('SYMBOL_TOKEN_ACTIVE_1') + .setName('Symbol Created Active') + .setAmount('100') + .setStartHeight(100_000) + .setEndHeight(250_000) + .setIsUnlimitedDuration(false) + .setCreator(symbolMainAccount.address) + .build(); + +const tokenSymbolExternalActive = TokenFixtureBuilder + .createWithToken(CHAIN_NAME_SYMBOL, NETWORK_IDENTIFIER, 2) + .setId('SYMBOL_TOKEN_ACTIVE_2') + .setName('Symbol External Active') + .setAmount('200') + .setStartHeight(100_000) + .setEndHeight(250_000) + .setIsUnlimitedDuration(false) + .setCreator(symbolExternalAccount.address) + .build(); + +const tokenSymbolCreatedExpired = TokenFixtureBuilder + .createWithToken(CHAIN_NAME_SYMBOL, NETWORK_IDENTIFIER, 3) + .setId('SYMBOL_TOKEN_EXPIRED') + .setName('Symbol Created Expired') + .setAmount('300') + .setStartHeight(100_000) + .setEndHeight(120_000) + .setIsUnlimitedDuration(false) + .setCreator(symbolMainAccount.address) + .build(); + +const tokenEthereumActive = TokenFixtureBuilder + .createWithToken(CHAIN_NAME_ETHEREUM, NETWORK_IDENTIFIER, 1) + .setId('ETH_TOKEN_ACTIVE_1') + .setName('Ethereum Active') + .setAmount('400') + .setStartHeight(10_000) + .setEndHeight(90_000) + .setIsUnlimitedDuration(false) + .setCreator(ethereumMainAccount.address) + .build(); + +// Account Info Fixtures + +const accountInfoSymbolWithTokens = AccountInfoFixtureBuilder + .createWithAccount(CHAIN_NAME_SYMBOL, NETWORK_IDENTIFIER, 0) + .setMosaics([tokenSymbolCreatedActive, tokenSymbolExternalActive, tokenSymbolCreatedExpired]) + .build(); + +const accountInfoEthereumWithTokens = AccountInfoFixtureBuilder + .createWithAccount(CHAIN_NAME_ETHEREUM, NETWORK_IDENTIFIER, 0) + .setTokens([tokenEthereumActive]) + .build(); + +const accountInfoEmpty = AccountInfoFixtureBuilder + .createWithAccount(CHAIN_NAME_SYMBOL, NETWORK_IDENTIFIER, 0) + .setMosaics([]) + .build(); + +// Event Handlers Store + +const createEventHandlersStore = () => { + const handlersByEvent = new Map(); + + return { + addHandler: (eventName, handler) => { + const handlers = handlersByEvent.get(eventName) ?? new Set(); + handlers.add(handler); + handlersByEvent.set(eventName, handlers); + }, + removeHandler: (eventName, handler) => { + handlersByEvent.get(eventName)?.delete(handler); + }, + emit: eventName => { + handlersByEvent.get(eventName)?.forEach(handler => handler()); + } + }; +}; + +// Wallet Controller Mock Factory + +const createWalletControllerMock = config => { + const events = createEventHandlersStore(); + + return { + chainName: config.chainName, + networkIdentifier: config.networkIdentifier ?? NETWORK_IDENTIFIER, + ticker: config.chainName === CHAIN_NAME_SYMBOL ? 'XYM' : 'ETH', + accounts: { mainnet: [], testnet: config.currentAccount ? [config.currentAccount] : [] }, + currentAccount: config.currentAccount ?? null, + currentAccountInfo: config.currentAccountInfo ?? accountInfoEmpty, + networkProperties: config.networkProperties ?? networkPropertiesSymbol, + isWalletReady: config.isWalletReady ?? true, + fetchAccountInfo: jest.fn().mockResolvedValue(undefined), + on: jest.fn((eventName, callback) => events.addHandler(eventName, callback)), + removeListener: jest.fn((eventName, callback) => events.removeHandler(eventName, callback)), + emit: events.emit, + modules: { addressBook: { whiteList: [], blackList: [], contacts: [] } } + }; +}; + +// Predefined Controller Configurations + +const CONTROLLER_CONFIG_SYMBOL_WITH_TOKENS = { + chainName: CHAIN_NAME_SYMBOL, + currentAccount: symbolMainAccount, + currentAccountInfo: accountInfoSymbolWithTokens, + networkProperties: networkPropertiesSymbol, + isWalletReady: true +}; + +const CONTROLLER_CONFIG_ETHEREUM_WITH_TOKENS = { + chainName: CHAIN_NAME_ETHEREUM, + currentAccount: ethereumMainAccount, + currentAccountInfo: accountInfoEthereumWithTokens, + networkProperties: networkPropertiesEthereum, + isWalletReady: true +}; + +const CONTROLLER_CONFIG_SYMBOL_EMPTY = { + chainName: CHAIN_NAME_SYMBOL, + currentAccount: symbolMainAccount, + currentAccountInfo: accountInfoEmpty, + networkProperties: networkPropertiesSymbol, + isWalletReady: true +}; + +const CONTROLLER_CONFIG_NO_ACCOUNT = { + chainName: CHAIN_NAME_ETHEREUM, + currentAccount: null, + currentAccountInfo: accountInfoEthereumWithTokens, + networkProperties: networkPropertiesEthereum, + isWalletReady: true +}; + +const CONTROLLER_CONFIG_NOT_READY = { + chainName: CHAIN_NAME_ETHEREUM, + currentAccount: ethereumMainAccount, + currentAccountInfo: accountInfoEthereumWithTokens, + networkProperties: networkPropertiesEthereum, + isWalletReady: false +}; + +// Mock Setup + +const mockWalletControllers = configs => { + const mainController = createWalletControllerMock(configs.main); + const additionalControllers = (configs.additional ?? []).map(createWalletControllerMock); + + walletControllers.main = mainController; + walletControllers.additional = additionalControllers; + + return { mainController, additionalControllers }; +}; + +// Tests + +describe('screens/assets/Assets', () => { + beforeEach(() => { + mockLocalization(); + mockOs('android'); + jest.clearAllMocks(); + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe('render', () => { + it('renders filter labels', async () => { + // Arrange: + mockWalletControllers({ + main: CONTROLLER_CONFIG_SYMBOL_WITH_TOKENS, + additional: [CONTROLLER_CONFIG_ETHEREUM_WITH_TOKENS] + }); + + // Act: + const screenTester = new ScreenTester(Assets); + await screenTester.waitForTimer(); + + // Assert: + screenTester.expectText([ + SCREEN_TEXT.textFilterExpired, + SCREEN_TEXT.textFilterCreated, + SCREEN_TEXT.buttonClear + ]); + }); + + it('renders tokens from main wallet controller', async () => { + // Arrange: + mockWalletControllers({ + main: CONTROLLER_CONFIG_SYMBOL_WITH_TOKENS, + additional: [] + }); + + // Act: + const screenTester = new ScreenTester(Assets); + await screenTester.waitForTimer(); + + // Assert: + screenTester.expectText([ + symbolMainAccount.address, + CHAIN_NAME_SYMBOL, + tokenSymbolCreatedActive.name, + tokenSymbolCreatedActive.amount + ]); + }); + + it('renders tokens from multiple wallet controllers', async () => { + // Arrange: + mockWalletControllers({ + main: CONTROLLER_CONFIG_SYMBOL_WITH_TOKENS, + additional: [CONTROLLER_CONFIG_ETHEREUM_WITH_TOKENS] + }); + + // Act: + const screenTester = new ScreenTester(Assets); + await screenTester.waitForTimer(); + + // Assert: + screenTester.expectText([ + symbolMainAccount.address, + ethereumMainAccount.address, + CHAIN_NAME_SYMBOL, + CHAIN_NAME_ETHEREUM, + tokenSymbolCreatedActive.name, + tokenSymbolCreatedActive.amount, + tokenEthereumActive.name, + tokenEthereumActive.amount + ]); + }); + }); + + describe('filters', () => { + const runToggleFilterTest = (description, config, expected) => { + it(description, async () => { + // Arrange: + mockWalletControllers(config.controllers); + + // Act: + const screenTester = new ScreenTester(Assets); + await screenTester.waitForTimer(); + + if (config.filterToPress) + screenTester.pressButton(config.filterToPress); + + // Assert: + if (expected.visibleTexts?.length > 0) + screenTester.expectText(expected.visibleTexts); + + if (expected.hiddenTexts?.length > 0) + screenTester.notExpectText(expected.hiddenTexts); + }); + }; + + const filterTests = [ + { + description: 'hides expired tokens by default', + config: { + controllers: { + main: CONTROLLER_CONFIG_SYMBOL_WITH_TOKENS, + additional: [CONTROLLER_CONFIG_ETHEREUM_WITH_TOKENS] + }, + filterToPress: null + }, + expected: { + visibleTexts: [tokenSymbolCreatedActive.name, tokenSymbolExternalActive.name], + hiddenTexts: [tokenSymbolCreatedExpired.name] + } + }, + { + description: 'shows expired tokens when expired filter is enabled', + config: { + controllers: { + main: CONTROLLER_CONFIG_SYMBOL_WITH_TOKENS, + additional: [CONTROLLER_CONFIG_ETHEREUM_WITH_TOKENS] + }, + filterToPress: SCREEN_TEXT.textFilterExpired + }, + expected: { + visibleTexts: [ + tokenSymbolCreatedActive.name, + tokenSymbolExternalActive.name, + tokenSymbolCreatedExpired.name + ] + } + }, + { + description: 'shows only self-created tokens when created filter is enabled', + config: { + controllers: { + main: CONTROLLER_CONFIG_SYMBOL_WITH_TOKENS, + additional: [CONTROLLER_CONFIG_ETHEREUM_WITH_TOKENS] + }, + filterToPress: SCREEN_TEXT.textFilterCreated + }, + expected: { + visibleTexts: [tokenSymbolCreatedActive.name, tokenEthereumActive.name], + hiddenTexts: [tokenSymbolExternalActive.name, tokenSymbolCreatedExpired.name] + } + } + ]; + + filterTests.forEach(test => { + runToggleFilterTest(test.description, test.config, test.expected); + }); + }); + + describe('data fetch', () => { + it('fetches account info only for controllers that are ready and have current account', async () => { + // Arrange: + const { mainController, additionalControllers } = mockWalletControllers({ + main: CONTROLLER_CONFIG_SYMBOL_WITH_TOKENS, + additional: [ + CONTROLLER_CONFIG_ETHEREUM_WITH_TOKENS, + CONTROLLER_CONFIG_NO_ACCOUNT, + CONTROLLER_CONFIG_NOT_READY + ] + }); + + // Act: + const screenTester = new ScreenTester(Assets); + await screenTester.waitForTimer(); + + // Assert: + expect(mainController.fetchAccountInfo).toHaveBeenCalledTimes(1); + expect(additionalControllers[0].fetchAccountInfo).toHaveBeenCalledTimes(1); + expect(additionalControllers[1].fetchAccountInfo).toHaveBeenCalledTimes(0); + expect(additionalControllers[2].fetchAccountInfo).toHaveBeenCalledTimes(0); + }); + + const runRefreshOnEventTest = (description, config, expected) => { + it(description, async () => { + // Arrange: + const { mainController, additionalControllers } = mockWalletControllers({ + main: CONTROLLER_CONFIG_SYMBOL_WITH_TOKENS, + additional: [CONTROLLER_CONFIG_ETHEREUM_WITH_TOKENS] + }); + const screenTester = new ScreenTester(Assets); + await screenTester.waitForTimer(); + mainController.fetchAccountInfo.mockClear(); + additionalControllers[0].fetchAccountInfo.mockClear(); + + // Act: + await act(async () => { + mainController.emit(config.eventName); + }); + await screenTester.waitForTimer(); + + // Assert: + expect(mainController.fetchAccountInfo).toHaveBeenCalledTimes(expected.fetchCallCount); + expect(additionalControllers[0].fetchAccountInfo).toHaveBeenCalledTimes(expected.fetchCallCount); + }); + }; + + const eventTests = [ + { + description: 'refreshes assets on account change event', + config: { eventName: ControllerEventName.ACCOUNT_CHANGE }, + expected: { fetchCallCount: 1 } + }, + { + description: 'refreshes assets on new confirmed transaction event', + config: { eventName: ControllerEventName.NEW_TRANSACTION_CONFIRMED }, + expected: { fetchCallCount: 1 } + } + ]; + + eventTests.forEach(test => { + runRefreshOnEventTest(test.description, test.config, test.expected); + }); + }); + + describe('empty state', () => { + it('shows empty list message when no assets are available', async () => { + // Arrange: + mockWalletControllers({ + main: CONTROLLER_CONFIG_SYMBOL_EMPTY, + additional: [] + }); + + // Act: + const screenTester = new ScreenTester(Assets); + await screenTester.waitForTimer(); + + // Assert: + screenTester.expectText([SCREEN_TEXT.textEmptyList]); + }); + + it('hides empty list message during initial loading', async () => { + // Arrange: + const pendingFetch = new Promise(() => {}); + const { mainController } = mockWalletControllers({ + main: CONTROLLER_CONFIG_SYMBOL_EMPTY, + additional: [] + }); + mainController.fetchAccountInfo.mockReturnValue(pendingFetch); + + // Act: + const screenTester = new ScreenTester(Assets); + await screenTester.waitForTimer(); + + // Assert: + screenTester.notExpectText([SCREEN_TEXT.textEmptyList]); + }); + }); +}); diff --git a/wallet/symbol/mobile/__tests__/screens/assets/TokenDetails.test.js b/wallet/symbol/mobile/__tests__/screens/assets/TokenDetails.test.js new file mode 100644 index 000000000..8ca0070b9 --- /dev/null +++ b/wallet/symbol/mobile/__tests__/screens/assets/TokenDetails.test.js @@ -0,0 +1,429 @@ +import { TokenDetails } from '@/app/screens/assets/TokenDetails'; +import { AccountFixtureBuilder } from '__fixtures__/local/AccountFixtureBuilder'; +import { AccountInfoFixtureBuilder } from '__fixtures__/local/AccountInfoFixtureBuilder'; +import { NetworkPropertiesFixtureBuilder } from '__fixtures__/local/NetworkPropertiesFixtureBuilder'; +import { TokenFixtureBuilder } from '__fixtures__/local/TokenFixtureBuilder'; +import { ScreenTester } from '__tests__/ScreenTester'; +import { mockLocalization, mockRouter, mockWalletController } from '__tests__/mock-helpers'; + +// Constants + +const CHAIN_NAME = 'symbol'; +const NETWORK_IDENTIFIER = 'testnet'; +const CHAIN_HEIGHT_ACTIVE = 150_000; +const CHAIN_HEIGHT_EXPIRED = 200_000; +const BLOCK_GENERATION_TARGET_TIME = 30; + +const TOKEN_ID = 'E74B99BA41F4AFB4'; +const TOKEN_NAME = 'Test Token'; +const TOKEN_TICKER = 'XYM'; +const TOKEN_DISPLAY_NAME = `${TOKEN_NAME} • ${TOKEN_TICKER}`; +const TOKEN_AMOUNT = '1000'; +const TOKEN_SUPPLY = '10000000'; +const TOKEN_DIVISIBILITY = 6; +const TOKEN_START_HEIGHT = 100_000; +const TOKEN_END_HEIGHT = 180_000; + +// Mocks + +jest.mock('@/app/utils', () => ({ + ...jest.requireActual('@/app/utils'), + getTokenKnownInfo: () => ({ + name: TOKEN_NAME, + ticker: TOKEN_TICKER, + imageId: 'image' + }) +})); + +// Screen Text + +const SCREEN_TEXT = { + // Field titles + textFieldTitleBalance: 'fieldTitle_balance', + textFieldTitleId: 'fieldTitle_id', + textFieldTitleChainName: 'fieldTitle_chainName', + textFieldTitleCreator: 'fieldTitle_creator', + textFieldTitleSupply: 'fieldTitle_supply', + textFieldTitleDivisibility: 'fieldTitle_divisibility', + textFieldTitleRegistrationHeight: 'fieldTitle_registrationHeight', + textFieldTitleCurrentChainHeight: 'fieldTitle_currentChainHeight', + textFieldTitleExpirationHeight: 'fieldTitle_expirationHeight', + + // Alert texts + textAlertExpired: 's_assetDetails_alert_expired_description', + textAlertExpirable: 's_assetDetails_alert_expirable_description', + + // Expiration progress texts + textExpired: 's_assets_item_expired', + textExpireIn: 's_assets_item_expireIn', + + // Buttons + buttonSend: 'button_send' +}; + +// Account Fixtures + +const currentAccount = AccountFixtureBuilder + .createWithAccount(CHAIN_NAME, NETWORK_IDENTIFIER, 0) + .build(); + +const externalAccount = AccountFixtureBuilder + .createWithAccount(CHAIN_NAME, NETWORK_IDENTIFIER, 1) + .build(); + +// Network Properties Fixtures + +const networkPropertiesActive = NetworkPropertiesFixtureBuilder + .createWithType(CHAIN_NAME, NETWORK_IDENTIFIER) + .setChainHeight(CHAIN_HEIGHT_ACTIVE) + .override({ blockGenerationTargetTime: BLOCK_GENERATION_TARGET_TIME }) + .build(); + +const networkPropertiesExpired = NetworkPropertiesFixtureBuilder + .createWithType(CHAIN_NAME, NETWORK_IDENTIFIER) + .setChainHeight(CHAIN_HEIGHT_EXPIRED) + .override({ blockGenerationTargetTime: BLOCK_GENERATION_TARGET_TIME }) + .build(); + +// Token Fixtures + +const tokenBase = TokenFixtureBuilder + .createWithToken(CHAIN_NAME, NETWORK_IDENTIFIER, 1) + .setId(TOKEN_ID) + .setAmount(TOKEN_AMOUNT) + .setDivisibility(TOKEN_DIVISIBILITY); + +const tokenOwnedByCurrentAccount = tokenBase + .setCreator(currentAccount.address) + .override({ supply: TOKEN_SUPPLY }) + .build(); + +const tokenOwnedByExternalAccount = TokenFixtureBuilder + .createWithToken(CHAIN_NAME, NETWORK_IDENTIFIER, 1) + .setId(TOKEN_ID) + .setAmount(TOKEN_AMOUNT) + .setDivisibility(TOKEN_DIVISIBILITY) + .setCreator(externalAccount.address) + .override({ supply: TOKEN_SUPPLY }) + .build(); + +const tokenWithoutCreator = TokenFixtureBuilder + .createWithToken(CHAIN_NAME, NETWORK_IDENTIFIER, 1) + .setId(TOKEN_ID) + .setAmount(TOKEN_AMOUNT) + .setDivisibility(TOKEN_DIVISIBILITY) + .build(); + +const tokenNonExpirable = TokenFixtureBuilder + .createWithToken(CHAIN_NAME, NETWORK_IDENTIFIER, 1) + .setId(TOKEN_ID) + .setAmount(TOKEN_AMOUNT) + .setCreator(currentAccount.address) + .setIsUnlimitedDuration(true) + .build(); + +const tokenExpirable = TokenFixtureBuilder + .createWithToken(CHAIN_NAME, NETWORK_IDENTIFIER, 1) + .setId(TOKEN_ID) + .setAmount(TOKEN_AMOUNT) + .setCreator(currentAccount.address) + .setStartHeight(TOKEN_START_HEIGHT) + .setEndHeight(TOKEN_END_HEIGHT) + .setIsUnlimitedDuration(false) + .build(); + +// Account Info Fixtures + +const createAccountInfoWithToken = token => AccountInfoFixtureBuilder + .createWithAccount(CHAIN_NAME, NETWORK_IDENTIFIER, 0) + .setMosaics([token]) + .build(); + +// Route Props Factory + +const createRouteProps = tokenId => ({ + route: { + params: { + chainName: CHAIN_NAME, + tokenId + } + } +}); + +// Wallet Controller Configuration + +const createWalletControllerConfig = (accountInfo, networkProperties) => ({ + chainName: CHAIN_NAME, + networkIdentifier: NETWORK_IDENTIFIER, + currentAccount, + currentAccountInfo: accountInfo, + networkProperties, + modules: { + addressBook: { + whiteList: [], + blackList: [], + contacts: [], + getContactByAddress: jest.fn().mockReturnValue(null) + } + } +}); + +// Tests + +describe('screens/assets/TokenDetails', () => { + beforeEach(() => { + mockLocalization(); + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.clearAllTimers(); + jest.useRealTimers(); + }); + + describe('render', () => { + it('renders token main info', async () => { + // Arrange: + const accountInfo = createAccountInfoWithToken(tokenOwnedByCurrentAccount); + mockWalletController(createWalletControllerConfig(accountInfo, networkPropertiesActive)); + const expectedTexts = [ + SCREEN_TEXT.textFieldTitleBalance, + SCREEN_TEXT.textFieldTitleId, + SCREEN_TEXT.textFieldTitleChainName, + SCREEN_TEXT.textFieldTitleCreator, + SCREEN_TEXT.buttonSend, + TOKEN_DISPLAY_NAME, + TOKEN_AMOUNT, + TOKEN_ID, + CHAIN_NAME, + currentAccount.address + ]; + + // Act: + const screenTester = new ScreenTester(TokenDetails, createRouteProps(TOKEN_ID)); + await screenTester.waitForTimer(); + + // Assert: + screenTester.expectText(expectedTexts); + }); + }); + + describe('additional token info', () => { + const runAdditionalInfoTest = (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)); + + // Assert: + if (expected.textsRendered?.length) + screenTester.expectText(expected.textsRendered); + + if (expected.textsNotRendered?.length) + screenTester.notExpectText(expected.textsNotRendered); + }); + }; + + const additionalInfoTests = [ + { + description: 'renders supply and divisibility when token creator is current account', + config: { + token: tokenOwnedByCurrentAccount + }, + expected: { + textsRendered: [ + SCREEN_TEXT.textFieldTitleSupply, + SCREEN_TEXT.textFieldTitleDivisibility, + TOKEN_SUPPLY, + String(TOKEN_DIVISIBILITY) + ] + } + }, + { + description: 'does not render supply and divisibility when token creator is not current account', + config: { + token: tokenOwnedByExternalAccount + }, + expected: { + textsNotRendered: [ + SCREEN_TEXT.textFieldTitleSupply, + SCREEN_TEXT.textFieldTitleDivisibility, + TOKEN_SUPPLY + ] + } + }, + { + description: 'does not render creator title and value if value is not in the token object', + config: { + token: tokenWithoutCreator + }, + expected: { + textsNotRendered: [ + SCREEN_TEXT.textFieldTitleCreator + ] + } + } + ]; + + additionalInfoTests.forEach(test => { + runAdditionalInfoTest(test.description, test.config, test.expected); + }); + }); + + describe('expiration section', () => { + const runExpirationTest = (description, config, expected) => { + it(description, async () => { + // Arrange: + const accountInfo = createAccountInfoWithToken(config.token); + mockWalletController(createWalletControllerConfig(accountInfo, config.networkProperties)); + + // Act: + const screenTester = new ScreenTester(TokenDetails, createRouteProps(TOKEN_ID)); + + // Assert: + if (expected.textsRendered?.length) + screenTester.expectText(expected.textsRendered); + + if (expected.textsNotRendered?.length) + screenTester.notExpectText(expected.textsNotRendered); + }); + }; + + const expirationTests = [ + { + description: 'does not render expiration section when token has no end height', + config: { + token: tokenNonExpirable, + networkProperties: networkPropertiesActive + }, + expected: { + textsNotRendered: [ + SCREEN_TEXT.textFieldTitleRegistrationHeight, + SCREEN_TEXT.textFieldTitleCurrentChainHeight, + SCREEN_TEXT.textFieldTitleExpirationHeight + ] + } + }, + { + description: 'renders expiration section with warning when token is expirable but not expired', + config: { + token: tokenExpirable, + networkProperties: networkPropertiesActive + }, + expected: { + textsRendered: [ + SCREEN_TEXT.textFieldTitleRegistrationHeight, + SCREEN_TEXT.textFieldTitleCurrentChainHeight, + SCREEN_TEXT.textFieldTitleExpirationHeight, + String(TOKEN_START_HEIGHT), + String(CHAIN_HEIGHT_ACTIVE), + String(TOKEN_END_HEIGHT), + SCREEN_TEXT.textAlertExpirable, + SCREEN_TEXT.textExpireIn + ], + textsNotRendered: [ + SCREEN_TEXT.textAlertExpired, + SCREEN_TEXT.textExpired + ] + } + }, + { + description: 'renders expiration section with danger when token is expired', + config: { + token: tokenExpirable, + networkProperties: networkPropertiesExpired + }, + expected: { + textsRendered: [ + SCREEN_TEXT.textFieldTitleRegistrationHeight, + SCREEN_TEXT.textFieldTitleCurrentChainHeight, + SCREEN_TEXT.textFieldTitleExpirationHeight, + String(TOKEN_START_HEIGHT), + String(CHAIN_HEIGHT_EXPIRED), + String(TOKEN_END_HEIGHT), + SCREEN_TEXT.textAlertExpired, + SCREEN_TEXT.textExpired + ], + textsNotRendered: [ + SCREEN_TEXT.textAlertExpirable, + SCREEN_TEXT.textExpireIn + ] + } + } + ]; + + expirationTests.forEach(test => { + runExpirationTest(test.description, test.config, test.expected); + }); + }); + + describe('send button', () => { + const runSendButtonTest = (description, config, expected) => { + it(description, async () => { + // Arrange: + const accountInfo = createAccountInfoWithToken(config.token); + mockWalletController(createWalletControllerConfig(accountInfo, config.networkProperties)); + const routerMock = mockRouter({ goToSend: jest.fn() }); + const navigationParams = { chainName: CHAIN_NAME, tokenId: TOKEN_ID }; + + // Act: + const screenTester = new ScreenTester(TokenDetails, createRouteProps(TOKEN_ID)); + screenTester.pressButton(SCREEN_TEXT.buttonSend); + + // Assert: + if (expected.shouldCallRouter) + expect(routerMock.goToSend).toHaveBeenCalledWith({ params: navigationParams }); + else + expect(routerMock.goToSend).not.toHaveBeenCalled(); + + if (expected.isDisabled) + screenTester.expectButtonDisabled(SCREEN_TEXT.buttonSend); + else + screenTester.expectButtonEnabled(SCREEN_TEXT.buttonSend); + }); + }; + + const sendButtonTests = [ + { + description: 'send button is enabled when token is not expirable', + config: { + token: tokenNonExpirable, + networkProperties: networkPropertiesActive + }, + expected: { + isDisabled: false, + shouldCallRouter: true + } + }, + { + description: 'send button is enabled when token is not yet expired', + config: { + token: tokenExpirable, + networkProperties: networkPropertiesActive + }, + expected: { + isDisabled: false, + shouldCallRouter: true + } + }, + { + description: 'send button is disabled when token is expired', + config: { + token: tokenExpirable, + networkProperties: networkPropertiesExpired + }, + expected: { + isDisabled: true, + shouldCallRouter: false + } + } + ]; + + sendButtonTests.forEach(test => { + runSendButtonTest(test.description, test.config, test.expected); + }); + }); +}); diff --git a/wallet/symbol/mobile/__tests__/screens/bridge/BridgeAccountDetails.test.js b/wallet/symbol/mobile/__tests__/screens/bridge/BridgeAccountDetails.test.js new file mode 100644 index 000000000..831b64a4a --- /dev/null +++ b/wallet/symbol/mobile/__tests__/screens/bridge/BridgeAccountDetails.test.js @@ -0,0 +1,242 @@ +import { BridgeAccountDetails } from '@/app/screens/bridge/BridgeAccountDetails'; +import { AccountFixtureBuilder } from '__fixtures__/local/AccountFixtureBuilder'; +import { AccountInfoFixtureBuilder } from '__fixtures__/local/AccountInfoFixtureBuilder'; +import { NetworkPropertiesFixtureBuilder } from '__fixtures__/local/NetworkPropertiesFixtureBuilder'; +import { TokenFixtureBuilder } from '__fixtures__/local/TokenFixtureBuilder'; +import { ScreenTester } from '__tests__/ScreenTester'; +import { mockLink, mockLocalization, mockPasscode, mockRouter, mockWalletController } from '__tests__/mock-helpers'; + +// Mocks +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useIsFocused: () => true +})); + +// Constants + +const CHAIN_NAME = 'ethereum'; +const NETWORK_IDENTIFIER = 'testnet'; +const MOCK_PRIVATE_KEY = 'mockPrivateKey123456789'; +const TOKEN_ETH_AMOUNT = '2.5'; +const TOKEN_WXYM_AMOUNT = '1000'; + +// Screen Text + +const SCREEN_TEXT = { + // Field titles + textFieldChainName: 'fieldTitle_chainName', + textFieldAddress: 'fieldTitle_address', + + // Section titles + textTokensTitle: 's_bridge_tokens_title', + + // Token display names + displayNameTokenWxym: 'Wrapped XYM • wXYM', + displayNameTokenEth: 'Ether • ETH', + + // Buttons + buttonSendTransaction: 'button_sendTransferTransaction', + buttonOpenExplorer: 'button_openTransactionInExplorer', + buttonRevealPrivateKey: 'button_revealPrivateKey', + buttonRemoveAccount: 'button_removeAccount', + buttonConfirm: 'button_confirm', + + // Dialogs + dialogSensitiveTitle: 'dialog_sensitive', + dialogRemoveAccountTitle: 'dialog_removeAccount_title', + dialogRemoveAccountBody: 'dialog_removeAccount_body' +}; + +// Account Fixtures + +const ethereumAccount = AccountFixtureBuilder + .createWithAccount(CHAIN_NAME, NETWORK_IDENTIFIER, 0) + .build(); + +// Network Properties Fixtures + +const ethereumNetworkProperties = NetworkPropertiesFixtureBuilder + .createWithType(CHAIN_NAME, NETWORK_IDENTIFIER) + .build(); + +// Token Fixtures + +const tokenEth = TokenFixtureBuilder + .createWithToken(CHAIN_NAME, NETWORK_IDENTIFIER, 0) + .setAmount(TOKEN_ETH_AMOUNT) + .build(); + +const tokenWxym = TokenFixtureBuilder + .createWithToken(CHAIN_NAME, NETWORK_IDENTIFIER, 1) + .setAmount(TOKEN_WXYM_AMOUNT) + .build(); + +// Account Info Fixtures + +const accountInfoWithTokens = AccountInfoFixtureBuilder + .createWithAccount(CHAIN_NAME, NETWORK_IDENTIFIER, 0) + .setTokens([tokenEth, tokenWxym]) + .build(); + +// Wallet Controller Mock Factory + +const mockWalletControllerConfigured = (overrides = {}) => { + return mockWalletController({ + chainName: CHAIN_NAME, + networkIdentifier: NETWORK_IDENTIFIER, + networkProperties: ethereumNetworkProperties, + currentAccount: ethereumAccount, + currentAccountInfo: overrides.currentAccountInfo ?? null, + getCurrentAccountPrivateKey: jest.fn().mockResolvedValue(MOCK_PRIVATE_KEY), + fetchAccountInfo: jest.fn().mockResolvedValue({}), + clear: jest.fn(), + ...overrides + }); +}; + +// Route Props Factory + +const createRouteProps = () => ({ + route: { + params: { + chainName: CHAIN_NAME + } + } +}); + +describe('screens/bridge/BridgeAccountDetails', () => { + beforeEach(() => { + mockLocalization(); + mockLink(); + jest.clearAllMocks(); + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.clearAllTimers(); + jest.useRealTimers(); + }); + + describe('render', () => { + it('renders main account info with chain name and address', () => { + // Arrange: + mockWalletControllerConfigured(); + const expectedTexts = [ + SCREEN_TEXT.textFieldChainName, + CHAIN_NAME, + SCREEN_TEXT.textFieldAddress, + ethereumAccount.address + ]; + + // Act: + const screenTester = new ScreenTester(BridgeAccountDetails, createRouteProps()); + + // Assert: + screenTester.expectText(expectedTexts); + }); + + it('renders token list when account info has tokens', () => { + // Arrange: + mockWalletControllerConfigured({ + currentAccountInfo: accountInfoWithTokens + }); + + // Act: + const screenTester = new ScreenTester(BridgeAccountDetails, createRouteProps()); + + // Assert: + screenTester.expectText([ + SCREEN_TEXT.textTokensTitle, + SCREEN_TEXT.displayNameTokenEth, + SCREEN_TEXT.displayNameTokenWxym, + TOKEN_WXYM_AMOUNT + ]); + }); + + it('navigates to token details when token is pressed', () => { + // Arrange: + mockWalletControllerConfigured({ + currentAccountInfo: accountInfoWithTokens + }); + const routerMock = mockRouter({ + goToTokenDetails: jest.fn() + }); + const screenTester = new ScreenTester(BridgeAccountDetails, createRouteProps()); + + // Act: + screenTester.pressButton(SCREEN_TEXT.displayNameTokenEth); + + // Assert: + expect(routerMock.goToTokenDetails).toHaveBeenCalledWith({ + params: { + chainName: CHAIN_NAME, + tokenId: tokenEth.id + } + }); + }); + }); + + describe('buttons', () => { + it('opens send screen when button is pressed', () => { + // Arrange: + mockWalletControllerConfigured(); + const routerMock = mockRouter({ + goToSend: jest.fn() + }); + const screenTester = new ScreenTester(BridgeAccountDetails, createRouteProps()); + + // Act: + screenTester.pressButton(SCREEN_TEXT.buttonSendTransaction); + + // Assert: + expect(routerMock.goToSend).toHaveBeenCalledWith({ + params: { chainName: CHAIN_NAME } + }); + }); + + it('opens block explorer when button is pressed', () => { + // Arrange: + const openLinkMock = mockLink(); + mockWalletControllerConfigured(); + const screenTester = new ScreenTester(BridgeAccountDetails, createRouteProps()); + const expectedUrl = `http://otterscan.symboltest.net/address/${ethereumAccount.address}`; + + // Act: + screenTester.pressButton(SCREEN_TEXT.buttonOpenExplorer); + + // Assert: + expect(openLinkMock).toHaveBeenCalledWith(expectedUrl); + }); + + it('reveals private key when button is pressed', async () => { + // Arrange: + const walletControllerMock = mockWalletControllerConfigured(); + mockPasscode(); + const screenTester = new ScreenTester(BridgeAccountDetails, createRouteProps()); + + // Act: + screenTester.pressButton(SCREEN_TEXT.buttonRevealPrivateKey); + await screenTester.waitForTimer(); + + // Assert: + expect(walletControllerMock.getCurrentAccountPrivateKey).toHaveBeenCalled(); + screenTester.expectText([MOCK_PRIVATE_KEY, SCREEN_TEXT.dialogSensitiveTitle]); + }); + + it('removes account and navigates back when button is pressed', async () => { + // Arrange: + const walletControllerMock = mockWalletControllerConfigured(); + const routerMock = mockRouter(); + const screenTester = new ScreenTester(BridgeAccountDetails, createRouteProps()); + + // Act: + screenTester.pressButton(SCREEN_TEXT.buttonRemoveAccount); + screenTester.expectText([SCREEN_TEXT.dialogRemoveAccountTitle]); + screenTester.pressButton(SCREEN_TEXT.buttonConfirm); + + // Assert: + expect(routerMock.goBack).toHaveBeenCalled(); + expect(walletControllerMock.clear).toHaveBeenCalled(); + }); + }); +}); diff --git a/wallet/symbol/mobile/__tests__/screens/bridge/BridgeAccountList.test.js b/wallet/symbol/mobile/__tests__/screens/bridge/BridgeAccountList.test.js new file mode 100644 index 000000000..40ba1971d --- /dev/null +++ b/wallet/symbol/mobile/__tests__/screens/bridge/BridgeAccountList.test.js @@ -0,0 +1,211 @@ +import { BridgeAccountList } from '@/app/screens/bridge/BridgeAccountList'; +import * as bridgeUtils from '@/app/screens/bridge/utils'; +import { AccountFixtureBuilder } from '__fixtures__/local/AccountFixtureBuilder'; +import { ScreenTester } from '__tests__/ScreenTester'; +import { mockLocalization } from '__tests__/mock-helpers'; + +// Mocks + +const mockUseBridgeAccounts = jest.fn(); + +jest.mock('@/app/screens/bridge/hooks', () => ({ + useBridgeAccounts: () => mockUseBridgeAccounts() +})); + +jest.mock('@/app/screens/bridge/utils', () => ({ + generateFromMnemonic: jest.fn() +})); + +// Constants + +const ChainName = { + SYMBOL: 'symbol', + ETHEREUM: 'ethereum' +}; + +const NetworkIdentifier = { + TESTNET: 'testnet' +}; + +const Ticker = { + SYMBOL: 'XYM', + ETHEREUM: 'ETH' +}; + +const BALANCE_SYMBOL = '1000'; +const BALANCE_ETHEREUM = '500'; +const BALANCE_ZERO = '0'; + +// Screen Text + +const SCREEN_TEXT = { + // Card titles + textFieldAccount: 'c_accountCard_title_account', + textFieldBalance: 'c_accountCard_title_balance', + textFieldAddress: 'c_accountCard_title_address', + + // Buttons + buttonActivate: 'button_activateAccount' +}; + +// Account Fixtures + +const symbolAccount = AccountFixtureBuilder + .createWithAccount(ChainName.SYMBOL, NetworkIdentifier.TESTNET, 0) + .build(); + +const ethereumAccount = AccountFixtureBuilder + .createWithAccount(ChainName.ETHEREUM, NetworkIdentifier.TESTNET, 0) + .build(); + +// Bridge Account Data Fixtures + +const bridgeAccountSymbolActive = { + chainName: ChainName.SYMBOL, + ticker: Ticker.SYMBOL, + isActive: true, + account: symbolAccount, + balance: BALANCE_SYMBOL, + tokens: [], + isAccountInfoLoaded: true +}; + +const bridgeAccountEthereumActive = { + chainName: ChainName.ETHEREUM, + ticker: Ticker.ETHEREUM, + isActive: true, + account: ethereumAccount, + balance: BALANCE_ETHEREUM, + tokens: [], + isAccountInfoLoaded: true +}; + +const bridgeAccountEthereumInactive = { + chainName: ChainName.ETHEREUM, + ticker: Ticker.ETHEREUM, + isActive: false, + account: null, + balance: BALANCE_ZERO, + tokens: [], + isAccountInfoLoaded: false +}; + +// Test Helpers + +const mockBridgeAccounts = (accounts, refresh = jest.fn()) => { + mockUseBridgeAccounts.mockReturnValue({ + accounts, + refresh + }); +}; + +describe('screens/bridge/BridgeAccountList', () => { + beforeEach(() => { + mockLocalization(); + jest.clearAllMocks(); + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.clearAllTimers(); + jest.useRealTimers(); + }); + + describe('render', () => { + const runRenderAccountsTest = (description, config, expected) => { + it(description, () => { + // Arrange: + mockBridgeAccounts(config.accounts); + + // Act: + const screenTester = new ScreenTester(BridgeAccountList); + + // Assert: + screenTester.expectText(expected.visibleTexts); + + if (expected.hiddenTexts) + screenTester.notExpectText(expected.hiddenTexts); + }); + }; + + const renderTests = [ + { + description: 'renders inactive account card with activate button', + config: { + accounts: [bridgeAccountEthereumInactive] + }, + expected: { + visibleTexts: [ + SCREEN_TEXT.textFieldAccount, + ChainName.ETHEREUM, + SCREEN_TEXT.textFieldBalance, + SCREEN_TEXT.buttonActivate + ], + hiddenTexts: [ + SCREEN_TEXT.textFieldAddress + ] + } + }, + { + description: 'renders active account card with address and balance', + config: { + accounts: [bridgeAccountEthereumActive] + }, + expected: { + visibleTexts: [ + SCREEN_TEXT.textFieldAccount, + ChainName.ETHEREUM, + SCREEN_TEXT.textFieldBalance, + BALANCE_ETHEREUM, + SCREEN_TEXT.textFieldAddress, + ethereumAccount.address + ], + hiddenTexts: [ + SCREEN_TEXT.buttonActivate + ] + } + }, + { + description: 'renders multiple account cards', + config: { + accounts: [bridgeAccountSymbolActive, bridgeAccountEthereumInactive] + }, + expected: { + visibleTexts: [ + // Symbol account (active) + ChainName.SYMBOL, + symbolAccount.address, + BALANCE_SYMBOL, + // Ethereum account (inactive) + ChainName.ETHEREUM, + SCREEN_TEXT.buttonActivate + ] + } + } + ]; + + renderTests.forEach(test => { + runRenderAccountsTest(test.description, test.config, test.expected); + }); + }); + + describe('activation', () => { + it('calls generateFromMnemonic and refresh when activate button is pressed', async () => { + // Arrange: + const refreshMock = jest.fn(); + const generateFromMnemonicMock = jest + .spyOn(bridgeUtils, 'generateFromMnemonic') + .mockResolvedValue(); + mockBridgeAccounts([bridgeAccountEthereumInactive], refreshMock); + const screenTester = new ScreenTester(BridgeAccountList); + + // Act: + screenTester.pressButton(SCREEN_TEXT.buttonActivate); + await screenTester.waitForTimer(); + + // Assert: + expect(generateFromMnemonicMock).toHaveBeenCalledWith(ChainName.ETHEREUM); + expect(refreshMock).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/wallet/symbol/mobile/__tests__/screens/bridge/BridgeSwap.test.js b/wallet/symbol/mobile/__tests__/screens/bridge/BridgeSwap.test.js new file mode 100644 index 000000000..fa200fd87 --- /dev/null +++ b/wallet/symbol/mobile/__tests__/screens/bridge/BridgeSwap.test.js @@ -0,0 +1,608 @@ +import { BridgeSwap } from '@/app/screens/bridge/BridgeSwap'; +import { BridgeMode, BridgePairsStatus, BridgeRequestStatus } from '@/app/screens/bridge/types/Bridge'; +import { AccountFixtureBuilder } from '__fixtures__/local/AccountFixtureBuilder'; +import { NetworkPropertiesFixtureBuilder } from '__fixtures__/local/NetworkPropertiesFixtureBuilder'; +import { TokenFixtureBuilder } from '__fixtures__/local/TokenFixtureBuilder'; +import { TransactionFeeFixtureBuilder } from '__fixtures__/local/TransactionFeeFixtureBuilder'; +import { ScreenTester } from '__tests__/ScreenTester'; +import { createWalletControllerMock, mockLocalization, mockPasscode, mockRouter } from '__tests__/mock-helpers'; +import { TransactionBundle } from 'wallet-common-core'; // eslint-disable-line import/order + +// Mocks + +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useIsFocused: () => true, + useFocusEffect: callback => { + callback(); + }, + useNavigation: () => ({ + navigate: jest.fn(), + goBack: jest.fn() + }) +})); + +jest.mock('@/app/screens/bridge/hooks', () => ({ + useBridge: jest.fn(), + useBridgeAmount: jest.fn(), + useBridgeHistory: jest.fn(), + useBridgeNoPairsDialog: jest.fn(), + useBridgeTransaction: jest.fn(), + useEstimation: jest.fn(), + useSwapSelector: jest.fn() +})); + +jest.mock('@/app/hooks', () => { + const original = jest.requireActual('@/app/hooks'); + return { + ...original, + useTransactionFees: jest.fn(() => ({ + data: null, + isLoading: false, + call: jest.fn() + })), + useWalletController: jest.fn() + }; +}); + +// Constants + +const CHAIN_NAME_SYMBOL = 'symbol'; +const CHAIN_NAME_ETHEREUM = 'ethereum'; +const NETWORK_IDENTIFIER = 'testnet'; +const BRIDGE_ID_XYM_TO_WXYM = 'symbol-xym-ethereum-wxym'; +const PAYOUT_AMOUNT = '99'; +const HISTORY_ITEM_TRANSACTION_HASH = '0C905EB065E6A42029CD1A10E710422761495A63D433535BA6EAA9BCF36AB8B6'; + +// Screen Text + +const SCREEN_TEXT = { + // Titles + textScreenTitle: 's_bridge_title', + textScreenDescription: 's_bridge_description', + textHistoryTitle: 's_bridge_history_title', + textHistoryDescription: 's_bridge_history_description', + textSummaryTitle: 's_bridge_summary_title', + + // Summary + textSummaryAmountSend: 's_bridge_summary_amountSend', + textSummaryBridgeFee: 's_bridge_summary_bridgeFee', + textSummaryTransactionFee: 's_bridge_summary_transactionFee', + textSummaryAmountReceive: 's_bridge_summary_amountReceive', + + // Dialog + textDialogNoPairsTitle: 's_bridge_swap_dialog_noPairs_title', + textDialogNoPairsText: 's_bridge_swap_dialog_noPairs_text', + textDialogConfirmTitle: 's_bridge_swap_dialog_confirm_title', + textDialogConfirmText: 's_bridge_swap_dialog_confirm_text', + + // Buttons + buttonSend: 'button_send', + buttonConfirm: 'button_confirm', + buttonCancel: 'button_cancel', + + // Accessibility Labels + labelSelectSourceToken: 'Select source token', + labelSelectTargetToken: 'Select target token', + inputAmountLabel: 'form_transfer_input_amount', + + // History item + textSwapAction: 'transactionDescriptor_swap', + + // Token Display Names + displayNameTokenXym: 'Symbol • XYM', + displayNameTokenWxym: 'Wrapped XYM • wXYM', + displayNameTokenEth: 'Ether • ETH' +}; + +// Account Fixtures + +const symbolAccount = AccountFixtureBuilder + .createWithAccount(CHAIN_NAME_SYMBOL, NETWORK_IDENTIFIER, 0) + .build(); + +const ethereumAccount = AccountFixtureBuilder + .createWithAccount(CHAIN_NAME_ETHEREUM, NETWORK_IDENTIFIER, 0) + .build(); + +// Network Properties Fixtures + +const symbolNetworkProperties = NetworkPropertiesFixtureBuilder + .createWithType(CHAIN_NAME_SYMBOL, NETWORK_IDENTIFIER) + .build(); + +const ethereumNetworkProperties = NetworkPropertiesFixtureBuilder + .createWithType(CHAIN_NAME_ETHEREUM, NETWORK_IDENTIFIER) + .build(); + +// Token Fixtures + +const tokenXym = TokenFixtureBuilder + .createWithToken(CHAIN_NAME_SYMBOL, NETWORK_IDENTIFIER, 0) + .setAmount('1000') + .build(); + +const tokenWxym = TokenFixtureBuilder + .createWithToken(CHAIN_NAME_ETHEREUM, NETWORK_IDENTIFIER, 1) + .setAmount('500') + .build(); + +const tokenWxymPayout = TokenFixtureBuilder + .createWithToken(CHAIN_NAME_ETHEREUM, NETWORK_IDENTIFIER, 1) + .setAmount(PAYOUT_AMOUNT) + .build(); + +const tokenEth = TokenFixtureBuilder + .createWithToken(CHAIN_NAME_ETHEREUM, NETWORK_IDENTIFIER, 0) + .setAmount('2000') + .build(); + +// Wallet Controller Fixtures + +const symbolWalletController = createWalletControllerMock({ + chainName: CHAIN_NAME_SYMBOL, + networkIdentifier: NETWORK_IDENTIFIER, + networkProperties: symbolNetworkProperties, + currentAccount: symbolAccount, + modules: { + bridge: { + createTransaction: jest.fn().mockResolvedValue({}) + } + } +}); + +const ethereumWalletController = createWalletControllerMock({ + chainName: CHAIN_NAME_ETHEREUM, + networkIdentifier: NETWORK_IDENTIFIER, + networkProperties: ethereumNetworkProperties, + currentAccount: ethereumAccount, + modules: { + bridge: { + createTransaction: jest.fn().mockResolvedValue({}) + } + } +}); + +// Bridge Fixtures + +const bridgeMock = { + id: BRIDGE_ID_XYM_TO_WXYM, + isReady: true, + estimateRequest: jest.fn().mockResolvedValue({ + bridgeFee: '1', + receiveAmount: PAYOUT_AMOUNT + }), + createTransaction: jest.fn(), + fetchRecentHistory: jest.fn().mockResolvedValue([]) +}; + +// Swap Side Fixtures + +const swapSideSymbolXym = { + token: tokenXym, + chainName: CHAIN_NAME_SYMBOL, + networkIdentifier: NETWORK_IDENTIFIER, + walletController: symbolWalletController +}; + +const swapSideEthereumWxym = { + token: tokenWxym, + chainName: CHAIN_NAME_ETHEREUM, + networkIdentifier: NETWORK_IDENTIFIER, + walletController: ethereumWalletController +}; + +const swapSideEthereumEth = { + token: tokenEth, + chainName: CHAIN_NAME_ETHEREUM, + networkIdentifier: NETWORK_IDENTIFIER, + walletController: ethereumWalletController +}; + +// Swap Pair Fixtures + +const swapPairXymToWxym = { + source: swapSideSymbolXym, + target: swapSideEthereumWxym, + bridge: bridgeMock, + mode: BridgeMode.WRAP +}; + +const swapPairXymToEth = { + source: swapSideSymbolXym, + target: swapSideEthereumEth, + bridge: bridgeMock, + mode: BridgeMode.WRAP +}; + +// Pair Collections + +const allPairs = [swapPairXymToWxym, swapPairXymToEth]; + +// History Fixtures + +const historyItem = { + requestTransaction: { + hash: HISTORY_ITEM_TRANSACTION_HASH, + timestamp: 1684265310994 + }, + sourceChainName: CHAIN_NAME_SYMBOL, + targetChainName: CHAIN_NAME_ETHEREUM, + sourceTokenInfo: tokenXym, + targetTokenInfo: tokenWxym, + payoutTransaction: { + token: tokenWxymPayout + }, + requestStatus: BridgeRequestStatus.CONFIRMED, + payoutStatus: 2 +}; + +// Transaction Fixtures + +const transactionBundle = new TransactionBundle([{ hash: 'ABC123', type: 'transfer' }]); + +const signedTransactionBundle = new TransactionBundle([{ hash: 'ABC123DEF456' }]); + +// Fee Tiers Fixtures + +const transactionFeeTiers = [ + TransactionFeeFixtureBuilder + .createWithAmounts('1', '2', '3') + .build() +]; + +// Estimation Fixtures + +const estimationResult = { + bridgeFee: '1', + receiveAmount: PAYOUT_AMOUNT +}; + +// Hook Mocks + +const { useTransactionFees, useWalletController } = require('@/app/hooks'); +const { + useBridge, + useBridgeAmount, + useBridgeHistory, + useBridgeNoPairsDialog, + useBridgeTransaction, + useEstimation, + useSwapSelector +} = require('@/app/screens/bridge/hooks'); + + +// Default Hook Return Values + +const createUseBridgeMock = (overrides = {}) => ({ + pairs: allPairs, + pairsStatus: BridgePairsStatus.OK, + loadBridges: jest.fn().mockResolvedValue(), + loadWalletControllers: jest.fn().mockResolvedValue(), + fetchBalances: jest.fn().mockResolvedValue(), + ...overrides +}); + +const createUseSwapSelectorMock = (overrides = {}) => ({ + isReady: true, + bridge: bridgeMock, + mode: BridgeMode.WRAP, + source: swapSideSymbolXym, + target: swapSideEthereumWxym, + sourceList: [swapSideSymbolXym, swapSideEthereumEth], + targetList: [swapSideEthereumWxym, swapSideEthereumEth], + changeSource: jest.fn(), + changeTarget: jest.fn(), + reverse: jest.fn(), + ...overrides +}); + +const createUseBridgeAmountMock = (overrides = {}) => ({ + amount: '0', + isAmountValid: true, + availableBalance: '1000', + changeAmount: jest.fn(), + changeAmountValidity: jest.fn(), + reset: jest.fn(), + ...overrides +}); + +const createUseBridgeTransactionMock = (overrides = {}) => ({ + createTransaction: jest.fn().mockResolvedValue(transactionBundle), + getTransactionPreviewTable: jest.fn().mockReturnValue([]), + ...overrides +}); + +const createUseEstimationMock = (overrides = {}) => ({ + estimation: null, + estimate: jest.fn(), + clearEstimation: jest.fn(), + isLoading: false, + ...overrides +}); + +const createUseBridgeHistoryMock = (overrides = {}) => ({ + history: [], + isHistoryLoading: false, + refreshHistory: jest.fn(), + clearHistory: jest.fn(), + ...overrides +}); + +const createUseBridgeNoPairsDialogMock = (overrides = {}) => ({ + isVisible: false, + onSuccess: jest.fn(), + onCancel: jest.fn(), + onScreenFocus: jest.fn(), + ...overrides +}); + +const createUseTransactionFeesMock = (overrides = {}) => ({ + data: null, + isLoading: false, + call: jest.fn(), + ...overrides +}); + +// Default Props + +const createDefaultProps = (overrides = {}) => ({ + route: { + params: { + chainName: CHAIN_NAME_SYMBOL + } + }, + ...overrides +}); + +// Mock Setup Helpers + +const setupMocks = (config = {}) => { + const walletController = config.walletController ?? symbolWalletController; + + useBridge.mockReturnValue(createUseBridgeMock(config.useBridge)); + useSwapSelector.mockReturnValue(createUseSwapSelectorMock(config.useSwapSelector)); + useBridgeAmount.mockReturnValue(createUseBridgeAmountMock(config.useBridgeAmount)); + useBridgeTransaction.mockReturnValue(createUseBridgeTransactionMock(config.useBridgeTransaction)); + useEstimation.mockReturnValue(createUseEstimationMock(config.useEstimation)); + useBridgeHistory.mockReturnValue(createUseBridgeHistoryMock(config.useBridgeHistory)); + useBridgeNoPairsDialog.mockReturnValue(createUseBridgeNoPairsDialogMock(config.useBridgeNoPairsDialog)); + useTransactionFees.mockReturnValue(createUseTransactionFeesMock(config.useTransactionFees)); + useWalletController.mockReturnValue(walletController); + + return walletController; +}; + +describe('screens/bridge/BridgeSwap', () => { + beforeEach(() => { + mockLocalization(); + mockRouter({ + goBack: jest.fn(), + goToBridgeAccountList: jest.fn(), + goToBridgeSwapDetails: jest.fn() + }); + jest.clearAllMocks(); + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.clearAllTimers(); + jest.useRealTimers(); + }); + + describe('swap token selection and transaction flow', () => { + it('selects tokens, enters amount, shows estimation summary, and sends transaction', async () => { + // Arrange: + const changeSourceMock = jest.fn(); + const changeTargetMock = jest.fn(); + const createTransactionMock = jest.fn().mockResolvedValue(transactionBundle); + const signTransactionBundleMock = jest.fn().mockResolvedValue(signedTransactionBundle); + const announceSignedTransactionBundleMock = jest.fn().mockResolvedValue({}); + + const walletController = createWalletControllerMock({ + ...symbolWalletController, + signTransactionBundle: signTransactionBundleMock, + announceSignedTransactionBundle: announceSignedTransactionBundleMock + }); + + setupMocks({ + walletController, + useSwapSelector: { + isReady: true, + source: swapSideSymbolXym, + target: swapSideEthereumWxym, + sourceList: [swapSideSymbolXym, swapSideEthereumEth], + targetList: [swapSideEthereumWxym, swapSideEthereumEth], + changeSource: changeSourceMock, + changeTarget: changeTargetMock + }, + useBridgeAmount: { + amount: '100', + isAmountValid: true, + availableBalance: '1000' + }, + useBridgeTransaction: { + createTransaction: createTransactionMock + }, + useEstimation: { + estimation: estimationResult, + isLoading: false + }, + useTransactionFees: { + data: transactionFeeTiers, + isLoading: false + } + }); + mockPasscode(); + + const screenTester = new ScreenTester(BridgeSwap, createDefaultProps()); + + // Change source token + + // Act: + screenTester.presButtonByLabel(SCREEN_TEXT.labelSelectSourceToken); + screenTester.pressButton(SCREEN_TEXT.displayNameTokenEth); + + // Assert: + expect(changeSourceMock).toHaveBeenCalledWith(swapSideEthereumEth); + + // Change target token + + // Act: + screenTester.presButtonByLabel(SCREEN_TEXT.labelSelectTargetToken); + screenTester.pressButton(SCREEN_TEXT.displayNameTokenEth); + + // Assert: + expect(changeTargetMock).toHaveBeenCalledWith(swapSideEthereumEth); + + // Check estimation summary is rendered + + // Assert: + screenTester.expectText([ + SCREEN_TEXT.textSummaryTitle, + SCREEN_TEXT.textSummaryAmountSend, + SCREEN_TEXT.textSummaryBridgeFee, + SCREEN_TEXT.textSummaryAmountReceive + ]); + + // Send swap transaction + + // Act: + screenTester.pressButton(SCREEN_TEXT.buttonSend); + await screenTester.waitForTimer(); + + // Assert: + screenTester.expectText([SCREEN_TEXT.textDialogConfirmTitle]); + + // Act: + screenTester.pressButton(SCREEN_TEXT.buttonConfirm); + await screenTester.waitForTimer(); // show passcode + await screenTester.waitForTimer(); // delay due to issue with modals on iOS. + // Cannot open a status dialog immediately after a passcode success + await screenTester.waitForTimer(); // sign transaction + await screenTester.waitForTimer(); // announce transaction + + // Assert: + expect(createTransactionMock).toHaveBeenCalled(); + expect(signTransactionBundleMock).toHaveBeenCalledWith(transactionBundle); + expect(announceSignedTransactionBundleMock).toHaveBeenCalledWith(signedTransactionBundle); + }); + }); + + describe('loading state', () => { + it('shows loading indicator and disables send button when isReady is false', () => { + // Arrange: + setupMocks({ + useSwapSelector: { + isReady: false, + source: null, + target: null, + bridge: null, + mode: null, + sourceList: [], + targetList: [] + } + }); + + // Act: + const screenTester = new ScreenTester(BridgeSwap, createDefaultProps()); + + // Assert: + screenTester.expectLoadingIndicator(); + screenTester.expectButtonDisabled(SCREEN_TEXT.buttonSend); + }); + }); + + describe('no pairs dialog', () => { + const runNoPairsDialogTest = (description, config, expected) => { + it(description, async () => { + // Arrange: + const onSuccessMock = jest.fn(); + const onCancelMock = jest.fn(); + + setupMocks({ + useBridge: { + pairsStatus: BridgePairsStatus.NO_PAIRS + }, + useBridgeNoPairsDialog: { + isVisible: true, + onSuccess: onSuccessMock, + onCancel: onCancelMock + } + }); + + const screenTester = new ScreenTester(BridgeSwap, createDefaultProps()); + + // Assert: dialog is visible + screenTester.expectText([ + SCREEN_TEXT.textDialogNoPairsTitle, + SCREEN_TEXT.textDialogNoPairsText + ]); + + // Act: + screenTester.pressButton(config.buttonToPress); + + // Assert: + if (expected.shouldCallOnSuccess) + expect(onSuccessMock).toHaveBeenCalled(); + + if (expected.shouldCallOnCancel) + expect(onCancelMock).toHaveBeenCalled(); + }); + }; + + const noPairsDialogTests = [ + { + description: 'calls onSuccess when confirm is pressed', + config: { + buttonToPress: SCREEN_TEXT.buttonConfirm + }, + expected: { + shouldCallOnSuccess: true, + shouldCallOnCancel: false + } + }, + { + description: 'calls onCancel when cancel is pressed', + config: { + buttonToPress: SCREEN_TEXT.buttonCancel + }, + expected: { + shouldCallOnSuccess: false, + shouldCallOnCancel: true + } + } + ]; + + noPairsDialogTests.forEach(test => { + runNoPairsDialogTest(test.description, test.config, test.expected); + }); + }); + + describe('history item press', () => { + it('navigates to swap details when history item is pressed', async () => { + // Arrange: + const { Router } = require('@/app/router/Router'); + + setupMocks({ + useBridgeHistory: { + history: [historyItem] + } + }); + + const screenTester = new ScreenTester(BridgeSwap, createDefaultProps()); + + // Act: + screenTester.pressButton(SCREEN_TEXT.textSwapAction); + + // Assert: + expect(Router.goToBridgeSwapDetails).toHaveBeenCalledWith({ + params: { + bridgeId: BRIDGE_ID_XYM_TO_WXYM, + requestTransactionHash: HISTORY_ITEM_TRANSACTION_HASH, + preloadedData: historyItem + } + }); + }); + }); +}); diff --git a/wallet/symbol/mobile/__tests__/screens/bridge/BridgeSwapDetails.test.js b/wallet/symbol/mobile/__tests__/screens/bridge/BridgeSwapDetails.test.js new file mode 100644 index 000000000..dba5fb0c9 --- /dev/null +++ b/wallet/symbol/mobile/__tests__/screens/bridge/BridgeSwapDetails.test.js @@ -0,0 +1,368 @@ +import { BridgeSwapDetails } from '@/app/screens/bridge/BridgeSwapDetails'; +import { BridgePayoutStatus, BridgeRequestStatus } from '@/app/screens/bridge/types/Bridge'; +import { formatDate } from '@/app/utils'; +import { AccountFixtureBuilder } from '__fixtures__/local/AccountFixtureBuilder'; +import { NetworkPropertiesFixtureBuilder } from '__fixtures__/local/NetworkPropertiesFixtureBuilder'; +import { TokenFixtureBuilder } from '__fixtures__/local/TokenFixtureBuilder'; +import { ScreenTester } from '__tests__/ScreenTester'; +import { createWalletControllerMock, mockLink, mockLocalization, mockWalletController } from '__tests__/mock-helpers'; + +// Constants + +const CHAIN_NAME_SYMBOL = 'symbol'; +const CHAIN_NAME_ETHEREUM = 'ethereum'; +const NETWORK_IDENTIFIER = 'testnet'; +const BRIDGE_ID = 'symbol-xym-ethereum-wxym'; + +const REQUEST_TRANSACTION_HASH = 'ABC123DEF456789REQUEST'; +const PAYOUT_TRANSACTION_HASH = '0xPAYOUT789ABC123DEF'; +const REQUEST_TIMESTAMP = 1684265310994; +const PAYOUT_TIMESTAMP = 1684351710994; +const ERROR_MESSAGE = 'Bridge processing error'; +const REQUEST_TIMESTAMP_TEXT = formatDate(REQUEST_TIMESTAMP, key => key, true); +const PAYOUT_TIMESTAMP_TEXT = formatDate(PAYOUT_TIMESTAMP, key => key, true); + +// Screen Text + +const SCREEN_TEXT = { + // Status + textStatusCompleted: 's_bridge_history_status_completed', + textStatusProcessing: 's_bridge_history_status_processing', + textStatusFailed: 's_bridge_history_status_failed', + + // Titles + textTokenSendTitle: 's_bridge_swapDetails_tokenSend_title', + textTokenReceiveTitle: 's_bridge_swapDetails_tokenReceive_title', + textStatusTrackingTitle: 's_bridge_swapDetails_statusTracking_title', + + // Field titles + textFieldChainName: 'fieldTitle_chainName', + textFieldSenderAddress: 'fieldTitle_senderAddress', + textFieldRecipientAddress: 'fieldTitle_recipientAddress', + textFieldTransactionHash: 'fieldTitle_transactionHash', + + // N/A placeholder + textNotAvailable: 'data_v_na', + + // Activity log steps + textStepRequestSend: 's_bridge_swapStatus_step_requestSend', + textStepAwaitingBridge: 's_bridge_swapStatus_step_awaitingBridge', + textStepPayoutSend: 's_bridge_swapStatus_step_payoutSend', + textStepPayoutConfirmation: 's_bridge_swapStatus_step_payoutConfirmation', + + // Buttons + buttonOpenExplorer: 'button_openTransactionInExplorer' +}; + +// Account Fixtures + +const symbolAccount = AccountFixtureBuilder + .createWithAccount(CHAIN_NAME_SYMBOL, NETWORK_IDENTIFIER, 0) + .build(); + +const ethereumAccount = AccountFixtureBuilder + .createWithAccount(CHAIN_NAME_ETHEREUM, NETWORK_IDENTIFIER, 0) + .build(); + +// Network Properties Fixtures + +const symbolNetworkProperties = NetworkPropertiesFixtureBuilder + .createWithType(CHAIN_NAME_SYMBOL, NETWORK_IDENTIFIER) + .build(); + +const ethereumNetworkProperties = NetworkPropertiesFixtureBuilder + .createWithType(CHAIN_NAME_ETHEREUM, NETWORK_IDENTIFIER) + .build(); + +// Token Fixtures + +const tokenXym = TokenFixtureBuilder + .createWithToken(CHAIN_NAME_SYMBOL, NETWORK_IDENTIFIER, 0) + .setAmount('100') + .build(); + +const tokenWxym = TokenFixtureBuilder + .createWithToken(CHAIN_NAME_ETHEREUM, NETWORK_IDENTIFIER, 1) + .setAmount('99') + .build(); + +// Wallet Controller Fixtures + +const symbolWalletController = createWalletControllerMock({ + chainName: CHAIN_NAME_SYMBOL, + networkIdentifier: NETWORK_IDENTIFIER, + networkProperties: symbolNetworkProperties, + currentAccount: symbolAccount +}); + +const ethereumWalletController = createWalletControllerMock({ + chainName: CHAIN_NAME_ETHEREUM, + networkIdentifier: NETWORK_IDENTIFIER, + networkProperties: ethereumNetworkProperties, + currentAccount: ethereumAccount +}); + +// Bridge Request Fixtures + +const createBridgeRequestData = (overrides = {}) => ({ + sourceChainName: CHAIN_NAME_SYMBOL, + targetChainName: CHAIN_NAME_ETHEREUM, + sourceTokenInfo: tokenXym, + targetTokenInfo: tokenWxym, + requestStatus: BridgeRequestStatus.CONFIRMED, + payoutStatus: BridgePayoutStatus.COMPLETED, + requestTransaction: { + hash: REQUEST_TRANSACTION_HASH, + timestamp: REQUEST_TIMESTAMP, + signerAddress: symbolAccount.address, + token: { amount: '100' } + }, + payoutTransaction: { + hash: PAYOUT_TRANSACTION_HASH, + timestamp: PAYOUT_TIMESTAMP, + recipientAddress: ethereumAccount.address, + token: { amount: '99' } + }, + errorMessage: null, + ...overrides +}); + +const bridgeRequestCompleted = createBridgeRequestData(); + +const bridgeRequestOnlyRequest = createBridgeRequestData({ + payoutStatus: BridgePayoutStatus.UNPROCESSED, + payoutTransaction: null +}); + +const bridgeRequestWithError = createBridgeRequestData({ + requestStatus: BridgeRequestStatus.ERROR, + payoutStatus: BridgePayoutStatus.FAILED, + payoutTransaction: null, + errorMessage: ERROR_MESSAGE +}); + +// Bridge Mock + +const bridgeMock = { + id: BRIDGE_ID, + nativeWalletController: symbolWalletController, + wrappedWalletController: ethereumWalletController +}; + +// Route Props Factory + +const createRouteProps = preloadedData => ({ + route: { + params: { + bridgeId: BRIDGE_ID, + preloadedData + } + } +}); + +// Setup + +const setupBridgeMock = () => { + const controllers = require('@/app/lib/controller'); + controllers.bridges = [bridgeMock]; +}; + +describe('screens/bridge/BridgeSwapDetails', () => { + beforeEach(() => { + setupBridgeMock(); + mockWalletController(); + mockLocalization(); + mockLink(); + }); + + describe('render', () => { + it('renders basic screen text with completed status', () => { + // Arrange: + const props = createRouteProps(bridgeRequestCompleted); + const expectedTexts = [ + SCREEN_TEXT.textStatusCompleted, + SCREEN_TEXT.textTokenSendTitle, + SCREEN_TEXT.textTokenReceiveTitle, + SCREEN_TEXT.textStatusTrackingTitle + ]; + + // Act: + const screenTester = new ScreenTester(BridgeSwapDetails, props); + + // Assert: + screenTester.expectText(expectedTexts); + }); + }); + + describe('swap details', () => { + const runSwapDetailsTest = (description, config, expected) => { + it(description, () => { + // Arrange: + const props = createRouteProps(config.bridgeRequest); + + // Act: + const screenTester = new ScreenTester(BridgeSwapDetails, props); + + // Assert: + screenTester.expectText(expected.visibleTexts, true); + + if (expected.notVisibleTexts?.length) + screenTester.notExpectText(expected.notVisibleTexts); + }); + }; + + const swapDetailsTests = [ + { + description: 'renders swap details with request and payout transactions', + config: { + bridgeRequest: bridgeRequestCompleted + }, + expected: { + visibleTexts: [ + // Source side + CHAIN_NAME_SYMBOL, + symbolAccount.address, + REQUEST_TRANSACTION_HASH, + // Target side + CHAIN_NAME_ETHEREUM, + ethereumAccount.address, + PAYOUT_TRANSACTION_HASH + ], + notVisibleTexts: [] + } + }, + { + description: 'renders swap details with only request transaction (no payout yet)', + config: { + bridgeRequest: bridgeRequestOnlyRequest + }, + expected: { + visibleTexts: [ + // Source side + CHAIN_NAME_SYMBOL, + symbolAccount.address, + REQUEST_TRANSACTION_HASH, + // Target side chain name visible + CHAIN_NAME_ETHEREUM + ], + notVisibleTexts: [ + PAYOUT_TRANSACTION_HASH, + ethereumAccount.address + ] + } + } + ]; + + swapDetailsTests.forEach(test => { + runSwapDetailsTest(test.description, test.config, test.expected); + }); + }); + + describe('explorer links', () => { + const runExplorerLinkTest = (description, config, expected) => { + it(description, () => { + // Arrange: + const openLinkMock = mockLink(); + const props = createRouteProps(bridgeRequestCompleted); + const screenTester = new ScreenTester(BridgeSwapDetails, props); + + // Act: + screenTester.pressButton(SCREEN_TEXT.buttonOpenExplorer, config.linkIndex); + + // Assert: + expect(openLinkMock).toHaveBeenCalledWith(expected.url); + }); + }; + + const explorerLinkTests = [ + { + description: 'opens request transaction explorer when press first link', + config: { linkIndex: 0 }, + expected: { url: `https://testnet.symbol.fyi/transactions/${REQUEST_TRANSACTION_HASH}` } + }, + { + description: 'opens payout transaction explorer when press second link', + config: { linkIndex: 1 }, + expected: { url: `http://otterscan.symboltest.net/tx/${PAYOUT_TRANSACTION_HASH}` } + } + ]; + + explorerLinkTests.forEach(test => + runExplorerLinkTest(test.description, test.config, test.expected)); + }); + + describe('status tracking', () => { + const runStatusTrackingTest = (description, config, expected) => { + it(description, () => { + // Arrange: + const props = createRouteProps(config.bridgeRequest); + + // Act: + const screenTester = new ScreenTester(BridgeSwapDetails, props); + + // Assert: + screenTester.expectText(expected.visibleTexts, true); + + if (expected.notVisibleTexts?.length) + screenTester.notExpectText(expected.notVisibleTexts); + }); + }; + + const statusTrackingTests = [ + { + description: 'displays request date in activity log', + config: { + bridgeRequest: bridgeRequestOnlyRequest + }, + expected: { + visibleTexts: [ + SCREEN_TEXT.textStepRequestSend, + SCREEN_TEXT.textStepAwaitingBridge, + SCREEN_TEXT.textStepPayoutSend, + SCREEN_TEXT.textStepPayoutConfirmation, + // Request timestamp formatted with time + REQUEST_TIMESTAMP_TEXT + ], + notVisibleTexts: [] + } + }, + { + description: 'displays error message in activity log when bridge fails', + config: { + bridgeRequest: bridgeRequestWithError + }, + expected: { + visibleTexts: [ + SCREEN_TEXT.textStepRequestSend, + SCREEN_TEXT.textStepAwaitingBridge, + ERROR_MESSAGE + ], + notVisibleTexts: [] + } + }, + { + description: 'displays both request and payout dates when completed', + config: { + bridgeRequest: bridgeRequestCompleted + }, + expected: { + visibleTexts: [ + SCREEN_TEXT.textStepRequestSend, + SCREEN_TEXT.textStepAwaitingBridge, + SCREEN_TEXT.textStepPayoutSend, + SCREEN_TEXT.textStepPayoutConfirmation, + // Request timestamp with time + REQUEST_TIMESTAMP_TEXT, + // Payout timestamp with time + PAYOUT_TIMESTAMP_TEXT + ], + notVisibleTexts: [] + } + } + ]; + + statusTrackingTests.forEach(test => { + runStatusTrackingTest(test.description, test.config, test.expected); + }); + }); +}); diff --git a/wallet/symbol/mobile/__tests__/screens/bridge/hooks/useBridge.test.js b/wallet/symbol/mobile/__tests__/screens/bridge/hooks/useBridge.test.js new file mode 100644 index 000000000..8d58085a6 --- /dev/null +++ b/wallet/symbol/mobile/__tests__/screens/bridge/hooks/useBridge.test.js @@ -0,0 +1,705 @@ +import { useBridge } from '@/app/screens/bridge/hooks/useBridge'; +import { BridgeMode, BridgePairsStatus } from '@/app/screens/bridge/types/Bridge'; +import { AccountFixtureBuilder } from '__fixtures__/local/AccountFixtureBuilder'; +import { AccountInfoFixtureBuilder } from '__fixtures__/local/AccountInfoFixtureBuilder'; +import { NetworkPropertiesFixtureBuilder } from '__fixtures__/local/NetworkPropertiesFixtureBuilder'; +import { TokenFixtureBuilder } from '__fixtures__/local/TokenFixtureBuilder'; +import { HookTester } from '__tests__/HookTester'; +import { runHookContractTest } from '__tests__/hook-tests'; +import { createWalletControllerMock } from '__tests__/mock-helpers'; +import { act } from '@testing-library/react-native'; +import { ControllerEventName } from 'wallet-common-core/src/constants'; + +// Mocks + +const mockLoadWalletController = jest.fn().mockResolvedValue(undefined); + +jest.mock('@/app/screens/bridge/utils', () => ({ + loadWalletController: (...args) => mockLoadWalletController(...args) +})); + +const mockBridges = []; +const mockMainWalletController = { networkIdentifier: 'testnet' }; +const mockEthereumWalletController = { networkIdentifier: 'testnet' }; + +jest.mock('@/app/lib/controller', () => ({ + default: mockMainWalletController, + symbolWalletController: mockMainWalletController, + ethereumWalletController: mockEthereumWalletController, + walletControllers: { + main: mockMainWalletController, + additional: [mockEthereumWalletController] + }, + get bridges() { + return mockBridges; + } +})); + +// Constants + +const NATIVE_CHAIN_NAME = 'symbol'; +const WRAPPED_CHAIN_NAME = 'ethereum'; +const NETWORK_IDENTIFIER = 'testnet'; +const BRIDGE_ID = 'symbol-xym-ethereum-wxym'; + +const BalanceValue = { + ZERO: '0', + NATIVE: '1000000000', + WRAPPED: '500000000' +}; + +const BridgeControllerState = { + READY: { + isStateReady: true, + hasAccounts: true, + isNetworkConnectionReady: true, + isWalletReady: true + }, + NOT_READY: { + isStateReady: false, + hasAccounts: false, + isNetworkConnectionReady: false, + isWalletReady: false + }, + CACHE_LOADED_NO_ACCOUNTS: { + isStateReady: true, + hasAccounts: false, + isNetworkConnectionReady: true, + isWalletReady: false + }, + CACHE_LOADED_WITH_ACCOUNTS_NOT_CONNECTED: { + isStateReady: true, + hasAccounts: true, + isNetworkConnectionReady: false, + isWalletReady: false + } +}; + +// Fixtures + +const nativeAccount = AccountFixtureBuilder + .createWithAccount(NATIVE_CHAIN_NAME, NETWORK_IDENTIFIER, 0) + .build(); + +const wrappedAccount = AccountFixtureBuilder + .createWithAccount(WRAPPED_CHAIN_NAME, NETWORK_IDENTIFIER, 0) + .build(); + +const nativeNetworkProperties = NetworkPropertiesFixtureBuilder + .createWithType(NATIVE_CHAIN_NAME, NETWORK_IDENTIFIER) + .build(); + +const wrappedNetworkProperties = NetworkPropertiesFixtureBuilder + .createWithType(WRAPPED_CHAIN_NAME, NETWORK_IDENTIFIER) + .build(); + +const nativeToken = TokenFixtureBuilder + .createWithToken(NATIVE_CHAIN_NAME, NETWORK_IDENTIFIER, 0) + .build(); + +const wrappedToken = TokenFixtureBuilder + .createWithToken(WRAPPED_CHAIN_NAME, NETWORK_IDENTIFIER, 1) + .build(); + +const nativeTokenInfo = { + id: nativeToken.id, + name: nativeToken.name, + divisibility: nativeToken.divisibility +}; + +const wrappedTokenInfo = { + id: wrappedToken.id, + name: wrappedToken.name, + divisibility: wrappedToken.divisibility +}; + +const createAccountInfoWithTokens = (chainName, account, tokens) => { + return AccountInfoFixtureBuilder + .createEmpty(chainName, NETWORK_IDENTIFIER) + .override({ address: account.address, publicKey: account.publicKey }) + .setTokens(tokens) + .build(); +}; + +const createAccountInfoWithMosaics = (chainName, account, mosaics) => { + return AccountInfoFixtureBuilder + .createEmpty(chainName, NETWORK_IDENTIFIER) + .override({ address: account.address, publicKey: account.publicKey }) + .setMosaics(mosaics) + .build(); +}; + +const nativeAccountInfoWithToken = createAccountInfoWithTokens( + NATIVE_CHAIN_NAME, + nativeAccount, + [{ id: nativeTokenInfo.id, amount: BalanceValue.NATIVE }] +); + +const wrappedAccountInfoWithToken = createAccountInfoWithTokens( + WRAPPED_CHAIN_NAME, + wrappedAccount, + [{ id: wrappedTokenInfo.id, amount: BalanceValue.WRAPPED }] +); + +const nativeAccountInfoWithMosaic = createAccountInfoWithMosaics( + NATIVE_CHAIN_NAME, + nativeAccount, + [{ id: nativeTokenInfo.id, amount: BalanceValue.NATIVE }] +); + +const wrappedAccountInfoWithMosaic = createAccountInfoWithMosaics( + WRAPPED_CHAIN_NAME, + wrappedAccount, + [{ id: wrappedTokenInfo.id, amount: BalanceValue.WRAPPED }] +); + +const nativeBaseController = { + chainName: NATIVE_CHAIN_NAME, + networkIdentifier: NETWORK_IDENTIFIER, + networkProperties: nativeNetworkProperties, + currentAccount: nativeAccount +}; + +const wrappedBaseController = { + chainName: WRAPPED_CHAIN_NAME, + networkIdentifier: NETWORK_IDENTIFIER, + networkProperties: wrappedNetworkProperties, + currentAccount: wrappedAccount +}; + +const BridgeScenario = { + FULLY_READY: { + nativeState: BridgeControllerState.READY, + wrappedState: BridgeControllerState.READY, + nativeAccountInfo: null, + wrappedAccountInfo: null + }, + NOT_READY: { + nativeState: BridgeControllerState.NOT_READY, + wrappedState: BridgeControllerState.NOT_READY, + nativeAccountInfo: null, + wrappedAccountInfo: null + }, + CACHE_LOADED_NO_ACCOUNTS: { + nativeState: BridgeControllerState.CACHE_LOADED_NO_ACCOUNTS, + wrappedState: BridgeControllerState.CACHE_LOADED_NO_ACCOUNTS, + nativeAccountInfo: null, + wrappedAccountInfo: null + }, + NATIVE_NOT_CONNECTED: { + nativeState: BridgeControllerState.CACHE_LOADED_WITH_ACCOUNTS_NOT_CONNECTED, + wrappedState: BridgeControllerState.READY, + nativeAccountInfo: null, + wrappedAccountInfo: null + }, + WITH_TOKEN_BALANCES: { + nativeState: BridgeControllerState.READY, + wrappedState: BridgeControllerState.READY, + nativeAccountInfo: nativeAccountInfoWithToken, + wrappedAccountInfo: wrappedAccountInfoWithToken + }, + WITH_MOSAIC_BALANCES: { + nativeState: BridgeControllerState.READY, + wrappedState: BridgeControllerState.READY, + nativeAccountInfo: nativeAccountInfoWithMosaic, + wrappedAccountInfo: wrappedAccountInfoWithMosaic + } +}; + +// Test helpers + +const setBridges = bridgesList => { + mockBridges.length = 0; + mockBridges.push(...bridgesList); +}; + +const createUseBridgeHookTester = async () => { + const hookTester = new HookTester(useBridge); + + await act(async () => { + await Promise.resolve(); + }); + + return hookTester; +}; + +const createBridgeWalletController = (baseController, state, currentAccountInfo = null) => { + return createWalletControllerMock({ + ...baseController, + ...state, + currentAccountInfo, + loadCache: jest.fn().mockResolvedValue(), + connectToNetwork: jest.fn().mockResolvedValue(), + fetchAccountInfo: jest.fn().mockResolvedValue(), + selectNetwork: jest.fn().mockResolvedValue(), + on: jest.fn(), + removeListener: jest.fn() + }, { + bindUseWalletController: false + }); +}; + +const createBridgeManagerMock = (scenario = BridgeScenario.FULLY_READY, overrides = {}) => { + const nativeWalletController = overrides.nativeWalletController + ?? createBridgeWalletController( + nativeBaseController, + scenario.nativeState, + scenario.nativeAccountInfo + ); + const wrappedWalletController = overrides.wrappedWalletController + ?? createBridgeWalletController( + wrappedBaseController, + scenario.wrappedState, + scenario.wrappedAccountInfo + ); + + return { + id: BRIDGE_ID, + nativeWalletController, + wrappedWalletController, + nativeTokenInfo, + wrappedTokenInfo, + load: overrides.load ?? jest.fn().mockResolvedValue(), + isEnabled: true, + isReady: true, + config: { enabled: true } + }; +}; + +describe('hooks/useBridge', () => { + beforeEach(() => { + jest.clearAllMocks(); + setBridges([]); + }); + + runHookContractTest(useBridge, { + props: [], + contract: { + bridges: 'array', + pairs: 'array', + pairsStatus: 'string', + loadBridges: 'function', + loadWalletControllers: 'function', + fetchBalances: 'function' + }, + waitAsyncEffects: true + }); + + describe('initialization', () => { + const runInitializationTest = (description, config, expected) => { + it(description, async () => { + // Arrange: + setBridges(config.bridges); + + // Act: + const hookTester = await createUseBridgeHookTester(); + + // Assert: + await hookTester.waitFor(() => { + expect(hookTester.currentResult.pairsStatus).toBe(expected.pairsStatus); + }); + }); + }; + + const initializationTests = [ + { + description: 'returns loading when no bridge cache is loaded', + config: { bridges: [createBridgeManagerMock(BridgeScenario.NOT_READY)] }, + expected: { pairsStatus: BridgePairsStatus.LOADING } + }, + { + description: 'returns no_pairs when cache is loaded but accounts are missing', + config: { bridges: [createBridgeManagerMock(BridgeScenario.CACHE_LOADED_NO_ACCOUNTS)] }, + expected: { pairsStatus: BridgePairsStatus.NO_PAIRS } + }, + { + description: 'returns ok when bridge controllers are fully ready', + config: { bridges: [createBridgeManagerMock(BridgeScenario.FULLY_READY)] }, + expected: { pairsStatus: BridgePairsStatus.OK } + }, + { + description: 'returns not configured when bridge list is empty', + config: { bridges: [] }, + expected: { pairsStatus: BridgePairsStatus.NOT_CONFIGURED } + } + ]; + + initializationTests.forEach(test => { + runInitializationTest(test.description, test.config, test.expected); + }); + }); + + describe('swap pairs', () => { + it('creates two pairs per ready bridge (wrap + unwrap)', async () => { + // Arrange: + setBridges([createBridgeManagerMock(BridgeScenario.FULLY_READY)]); + + // Act: + const hookTester = await createUseBridgeHookTester(); + + // Assert: + await hookTester.waitFor(() => { + expect(hookTester.currentResult.pairs).toHaveLength(2); + }); + }); + + const runSwapDirectionTest = (description, mode, expected) => { + it(description, async () => { + // Arrange: + setBridges([createBridgeManagerMock(BridgeScenario.FULLY_READY)]); + + // Act: + const hookTester = await createUseBridgeHookTester(); + + // Assert: + await hookTester.waitFor(() => { + const pair = hookTester.currentResult.pairs.find(item => item.mode === mode); + expect(pair).toBeDefined(); + expect(pair.source.chainName).toBe(expected.sourceChainName); + expect(pair.target.chainName).toBe(expected.targetChainName); + }); + }); + }; + + const swapDirectionTests = [ + { + description: 'creates wrap pair with native source and wrapped target', + mode: BridgeMode.WRAP, + expected: { + sourceChainName: NATIVE_CHAIN_NAME, + targetChainName: WRAPPED_CHAIN_NAME + } + }, + { + description: 'creates unwrap pair with wrapped source and native target', + mode: BridgeMode.UNWRAP, + expected: { + sourceChainName: WRAPPED_CHAIN_NAME, + targetChainName: NATIVE_CHAIN_NAME + } + } + ]; + + swapDirectionTests.forEach(test => { + runSwapDirectionTest(test.description, test.mode, test.expected); + }); + + it('includes token info with balance in swap pairs', async () => { + // Arrange: + setBridges([createBridgeManagerMock(BridgeScenario.WITH_TOKEN_BALANCES)]); + + // Act: + const hookTester = await createUseBridgeHookTester(); + + // Assert: + await hookTester.waitFor(() => { + const wrapPair = hookTester.currentResult.pairs.find(item => item.mode === BridgeMode.WRAP); + expect(wrapPair.source.token.id).toBe(nativeTokenInfo.id); + expect(wrapPair.source.token.amount).toBe(BalanceValue.NATIVE); + expect(wrapPair.target.token.id).toBe(wrappedTokenInfo.id); + expect(wrapPair.target.token.amount).toBe(BalanceValue.WRAPPED); + }); + }); + + it('falls back to mosaics when tokens are absent', async () => { + // Arrange: + setBridges([createBridgeManagerMock(BridgeScenario.WITH_MOSAIC_BALANCES)]); + + // Act: + const hookTester = await createUseBridgeHookTester(); + + // Assert: + await hookTester.waitFor(() => { + const wrapPair = hookTester.currentResult.pairs.find(item => item.mode === BridgeMode.WRAP); + expect(wrapPair.source.token.amount).toBe(BalanceValue.NATIVE); + expect(wrapPair.target.token.amount).toBe(BalanceValue.WRAPPED); + }); + }); + + it('sets zero amount when account has no matching token', async () => { + // Arrange: + setBridges([createBridgeManagerMock(BridgeScenario.FULLY_READY)]); + + // Act: + const hookTester = await createUseBridgeHookTester(); + + // Assert: + await hookTester.waitFor(() => { + const wrapPair = hookTester.currentResult.pairs.find(item => item.mode === BridgeMode.WRAP); + expect(wrapPair.source.token.amount).toBe(BalanceValue.ZERO); + }); + }); + + it('keeps bridge and wallet-controller references in pair object', async () => { + // Arrange: + const bridge = createBridgeManagerMock(BridgeScenario.FULLY_READY); + setBridges([bridge]); + + // Act: + const hookTester = await createUseBridgeHookTester(); + + // Assert: + await hookTester.waitFor(() => { + const wrapPair = hookTester.currentResult.pairs.find(item => item.mode === BridgeMode.WRAP); + expect(wrapPair.bridge).toBe(bridge); + expect(wrapPair.source.walletController).toBe(bridge.nativeWalletController); + expect(wrapPair.target.walletController).toBe(bridge.wrappedWalletController); + }); + }); + + it('does not create pairs when one controller is not network-connected', async () => { + // Arrange: + setBridges([createBridgeManagerMock(BridgeScenario.NATIVE_NOT_CONNECTED)]); + + // Act: + const hookTester = await createUseBridgeHookTester(); + + // Assert: + await hookTester.waitFor(() => { + expect(hookTester.currentResult.pairs).toHaveLength(0); + }); + }); + + it('creates pairs for each ready bridge', async () => { + // Arrange: + setBridges([ + createBridgeManagerMock(BridgeScenario.FULLY_READY), + createBridgeManagerMock(BridgeScenario.FULLY_READY) + ]); + + // Act: + const hookTester = await createUseBridgeHookTester(); + + // Assert: + await hookTester.waitFor(() => { + expect(hookTester.currentResult.pairs).toHaveLength(4); + }); + }); + }); + + describe('wallet and bridge loading', () => { + it('loads only not-ready wallet controllers', async () => { + // Arrange: + const bridge = createBridgeManagerMock(BridgeScenario.NOT_READY); + setBridges([bridge]); + + // Act: + const hookTester = await createUseBridgeHookTester(); + + // Assert: + await hookTester.waitFor(() => { + expect(mockLoadWalletController).toHaveBeenCalledTimes(2); + }); + expect(mockLoadWalletController).toHaveBeenCalledWith(bridge.nativeWalletController); + expect(mockLoadWalletController).toHaveBeenCalledWith(bridge.wrappedWalletController); + }); + + it('does not load already-ready wallet controllers', async () => { + // Arrange: + setBridges([createBridgeManagerMock(BridgeScenario.FULLY_READY)]); + + // Act: + const hookTester = await createUseBridgeHookTester(); + await hookTester.waitFor(() => { + expect(hookTester.currentResult.pairsStatus).toBeDefined(); + }); + + // Assert: + expect(mockLoadWalletController).not.toHaveBeenCalled(); + }); + + it('loads only fully-ready bridges', async () => { + // Arrange: + const readyBridge = createBridgeManagerMock(BridgeScenario.FULLY_READY); + const notReadyBridge = createBridgeManagerMock(BridgeScenario.NOT_READY); + setBridges([readyBridge, notReadyBridge]); + + // Act: + const hookTester = await createUseBridgeHookTester(); + + // Assert: + await hookTester.waitFor(() => { + expect(readyBridge.load).toHaveBeenCalledTimes(1); + }); + expect(notReadyBridge.load).not.toHaveBeenCalled(); + }); + + it('can manually reload wallet controllers', async () => { + // Arrange: + const notReadyNativeController = createBridgeWalletController( + nativeBaseController, + BridgeControllerState.NOT_READY + ); + const bridge = createBridgeManagerMock(BridgeScenario.FULLY_READY, { + nativeWalletController: notReadyNativeController + }); + setBridges([bridge]); + + // Act: + const hookTester = await createUseBridgeHookTester(); + await hookTester.waitFor(() => { + expect(mockLoadWalletController).toHaveBeenCalledWith(notReadyNativeController); + }); + + mockLoadWalletController.mockClear(); + await act(async () => { + await hookTester.currentResult.loadWalletControllers(); + }); + + // Assert: + expect(mockLoadWalletController).toHaveBeenCalledTimes(1); + expect(mockLoadWalletController).toHaveBeenCalledWith(notReadyNativeController); + }); + + it('can manually reload ready bridges', async () => { + // Arrange: + const bridge = createBridgeManagerMock(BridgeScenario.FULLY_READY); + setBridges([bridge]); + + // Act: + const hookTester = await createUseBridgeHookTester(); + await hookTester.waitFor(() => { + expect(bridge.load).toHaveBeenCalledTimes(1); + }); + + bridge.load.mockClear(); + await act(async () => { + await hookTester.currentResult.loadBridges(); + }); + + // Assert: + expect(bridge.load).toHaveBeenCalledTimes(1); + }); + }); + + describe('balance fetching', () => { + it('fetches account info only for wallet-ready controllers with accounts', async () => { + // Arrange: + const readyBridge = createBridgeManagerMock(BridgeScenario.FULLY_READY); + const noAccountsBridge = createBridgeManagerMock(BridgeScenario.CACHE_LOADED_NO_ACCOUNTS); + setBridges([readyBridge, noAccountsBridge]); + + // Act: + const hookTester = await createUseBridgeHookTester(); + + // Assert: + await hookTester.waitFor(() => { + expect(readyBridge.nativeWalletController.fetchAccountInfo).toHaveBeenCalled(); + expect(readyBridge.wrappedWalletController.fetchAccountInfo).toHaveBeenCalled(); + }); + expect(noAccountsBridge.nativeWalletController.fetchAccountInfo).not.toHaveBeenCalled(); + expect(noAccountsBridge.wrappedWalletController.fetchAccountInfo).not.toHaveBeenCalled(); + }); + + it('can manually refetch balances', async () => { + // Arrange: + const bridge = createBridgeManagerMock(BridgeScenario.FULLY_READY); + setBridges([bridge]); + + // Act: + const hookTester = await createUseBridgeHookTester(); + await hookTester.waitFor(() => { + expect(bridge.nativeWalletController.fetchAccountInfo).toHaveBeenCalledTimes(1); + }); + + bridge.nativeWalletController.fetchAccountInfo.mockClear(); + bridge.wrappedWalletController.fetchAccountInfo.mockClear(); + await act(async () => { + await hookTester.currentResult.fetchBalances(); + }); + + // Assert: + expect(bridge.nativeWalletController.fetchAccountInfo).toHaveBeenCalledTimes(1); + expect(bridge.wrappedWalletController.fetchAccountInfo).toHaveBeenCalledTimes(1); + }); + }); + + describe('event subscriptions', () => { + const runSubscriptionTest = (description, eventName) => { + it(description, async () => { + // Arrange: + const bridge = createBridgeManagerMock(BridgeScenario.FULLY_READY); + setBridges([bridge]); + + // Act: + const hookTester = await createUseBridgeHookTester(); + await hookTester.waitFor(() => { + expect(bridge.nativeWalletController.on).toHaveBeenCalled(); + }); + + // Assert: + expect(bridge.nativeWalletController.on).toHaveBeenCalledWith(eventName, expect.any(Function)); + expect(bridge.wrappedWalletController.on).toHaveBeenCalledWith(eventName, expect.any(Function)); + }); + }; + + const subscriptionTests = [ + { + description: 'subscribes to account change', + eventName: ControllerEventName.ACCOUNT_CHANGE + }, + { + description: 'subscribes to network connected', + eventName: ControllerEventName.NETWORK_CONNECTED + }, + { + description: 'subscribes to new transaction confirmed', + eventName: ControllerEventName.NEW_TRANSACTION_CONFIRMED + } + ]; + + subscriptionTests.forEach(test => { + runSubscriptionTest(test.description, test.eventName); + }); + + it('removes all listeners on unmount', async () => { + // Arrange: + const bridge = createBridgeManagerMock(BridgeScenario.FULLY_READY); + setBridges([bridge]); + + // Act: + const hookTester = await createUseBridgeHookTester(); + await hookTester.waitFor(() => { + expect(bridge.nativeWalletController.on).toHaveBeenCalled(); + }); + hookTester.hookRenderer.unmount(); + + // Assert: + expect(bridge.nativeWalletController.removeListener).toHaveBeenCalledWith( + ControllerEventName.ACCOUNT_CHANGE, + expect.any(Function) + ); + expect(bridge.nativeWalletController.removeListener).toHaveBeenCalledWith( + ControllerEventName.NETWORK_CONNECTED, + expect.any(Function) + ); + expect(bridge.nativeWalletController.removeListener).toHaveBeenCalledWith( + ControllerEventName.NEW_TRANSACTION_CONFIRMED, + expect.any(Function) + ); + }); + + it('fetches account info when NEW_TRANSACTION_CONFIRMED callback runs', async () => { + // Arrange: + const bridge = createBridgeManagerMock(BridgeScenario.FULLY_READY); + setBridges([bridge]); + + // Act: + const hookTester = await createUseBridgeHookTester(); + await hookTester.waitFor(() => { + expect(bridge.nativeWalletController.fetchAccountInfo).toHaveBeenCalled(); + }); + + bridge.nativeWalletController.fetchAccountInfo.mockClear(); + const transactionEventCall = bridge.nativeWalletController.on.mock.calls + .find(call => call[0] === ControllerEventName.NEW_TRANSACTION_CONFIRMED); + + await act(async () => { + await transactionEventCall[1](); + }); + + // Assert: + expect(bridge.nativeWalletController.fetchAccountInfo).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/wallet/symbol/mobile/__tests__/screens/bridge/hooks/useBridgeAccounts.test.js b/wallet/symbol/mobile/__tests__/screens/bridge/hooks/useBridgeAccounts.test.js new file mode 100644 index 000000000..768484690 --- /dev/null +++ b/wallet/symbol/mobile/__tests__/screens/bridge/hooks/useBridgeAccounts.test.js @@ -0,0 +1,710 @@ +import { useBridgeAccounts } from '@/app/screens/bridge/hooks/useBridgeAccounts'; +import { AccountFixtureBuilder } from '__fixtures__/local/AccountFixtureBuilder'; +import { AccountInfoFixtureBuilder } from '__fixtures__/local/AccountInfoFixtureBuilder'; +import { NetworkPropertiesFixtureBuilder } from '__fixtures__/local/NetworkPropertiesFixtureBuilder'; +import { TokenFixtureBuilder } from '__fixtures__/local/TokenFixtureBuilder'; +import { HookTester } from '__tests__/HookTester'; +import { runHookContractTest } from '__tests__/hook-tests'; +import { createWalletControllerMock } from '__tests__/mock-helpers'; +import { act } from '@testing-library/react-native'; +import { ControllerEventName } from 'wallet-common-core/src/constants'; + +// Mocks + +let mockAdditionalControllers = []; + +jest.mock('@/app/hooks', () => ({ + useReactiveWalletControllers: () => mockAdditionalControllers +})); + +jest.mock('@/app/lib/controller', () => ({ + walletControllers: { + main: { networkIdentifier: 'testnet' }, + additional: [] + } +})); + +// Constants + +const ChainName = { + SYMBOL: 'symbol', + ETHEREUM: 'ethereum' +}; + +const NetworkIdentifier = { + TESTNET: 'testnet' +}; + +const Ticker = { + SYMBOL: 'XYM', + ETHEREUM: 'ETH' +}; + +const BalanceValue = { + ZERO_STRING: '0', + SYMBOL: '1000000000', + ETHEREUM: '500000000' +}; + +const ControllerEvent = { + ACCOUNT_CHANGE: ControllerEventName.ACCOUNT_CHANGE, + NEW_TRANSACTION_CONFIRMED: ControllerEventName.NEW_TRANSACTION_CONFIRMED, + NETWORK_CONNECTED: ControllerEventName.NETWORK_CONNECTED +}; + +const EXPECTED_EVENT_SUBSCRIPTIONS_PER_CONTROLLER = 3; + +// Fixtures + +const symbolAccount = AccountFixtureBuilder + .createWithAccount(ChainName.SYMBOL, NetworkIdentifier.TESTNET, 0) + .build(); + +const ethereumAccount = AccountFixtureBuilder + .createWithAccount(ChainName.ETHEREUM, NetworkIdentifier.TESTNET, 0) + .build(); + +const networkPropertiesSymbolTestnet = NetworkPropertiesFixtureBuilder + .createWithType(ChainName.SYMBOL, NetworkIdentifier.TESTNET) + .build(); + +const networkPropertiesEthereumTestnet = NetworkPropertiesFixtureBuilder + .createWithType(ChainName.ETHEREUM, NetworkIdentifier.TESTNET) + .build(); + +const tokenSymbol = TokenFixtureBuilder + .createEmpty() + .setId('token-symbol-1') + .setName('Token Symbol 1') + .setDivisibility(6) + .setAmount('1000') + .build(); + +const tokenEthereum = TokenFixtureBuilder + .createEmpty() + .setId('token-ethereum-1') + .setName('Token Ethereum 1') + .setDivisibility(18) + .setAmount('2000') + .build(); + +const tokensMixed = [tokenSymbol, tokenEthereum]; + +const createAccountInfoFixture = ({ chainName, account, balance, tokens = [], hasFetchedAt = true }) => { + const builder = AccountInfoFixtureBuilder + .createEmpty(chainName, NetworkIdentifier.TESTNET) + .override({ address: account.address, publicKey: account.publicKey }) + .setBalance(balance) + .setTokens(tokens); + + if (hasFetchedAt) + builder.setFetchedAt(Date.now() - 60000); + + return builder.build(); +}; + +const symbolAccountInfoLoaded = createAccountInfoFixture({ + chainName: ChainName.SYMBOL, + account: symbolAccount, + balance: BalanceValue.SYMBOL +}); + +const symbolAccountInfoNotLoaded = createAccountInfoFixture({ + chainName: ChainName.SYMBOL, + account: symbolAccount, + balance: BalanceValue.SYMBOL, + hasFetchedAt: false +}); + +const symbolAccountInfoWithTokens = createAccountInfoFixture({ + chainName: ChainName.SYMBOL, + account: symbolAccount, + balance: BalanceValue.SYMBOL, + tokens: tokensMixed +}); + +const ethereumAccountInfoLoaded = createAccountInfoFixture({ + chainName: ChainName.ETHEREUM, + account: ethereumAccount, + balance: BalanceValue.ETHEREUM +}); + +const ControllerState = { + READY: { + isStateReady: true, + hasAccounts: true, + isNetworkConnectionReady: true, + isWalletReady: true + }, + NOT_READY: { + isStateReady: false, + hasAccounts: false, + isNetworkConnectionReady: false, + isWalletReady: false + } +}; + +const ControllerScenario = { + SYMBOL_READY: { + chainName: ChainName.SYMBOL, + ticker: Ticker.SYMBOL, + networkIdentifier: NetworkIdentifier.TESTNET, + networkProperties: networkPropertiesSymbolTestnet, + currentAccount: symbolAccount, + currentAccountInfo: symbolAccountInfoLoaded, + ...ControllerState.READY + }, + ETHEREUM_READY: { + chainName: ChainName.ETHEREUM, + ticker: Ticker.ETHEREUM, + networkIdentifier: NetworkIdentifier.TESTNET, + networkProperties: networkPropertiesEthereumTestnet, + currentAccount: ethereumAccount, + currentAccountInfo: ethereumAccountInfoLoaded, + ...ControllerState.READY + }, + SYMBOL_READY_NO_ACCOUNT: { + chainName: ChainName.SYMBOL, + ticker: Ticker.SYMBOL, + networkIdentifier: NetworkIdentifier.TESTNET, + networkProperties: networkPropertiesSymbolTestnet, + currentAccount: null, + currentAccountInfo: null, + ...ControllerState.READY + }, + SYMBOL_NOT_READY: { + chainName: ChainName.SYMBOL, + ticker: Ticker.SYMBOL, + networkIdentifier: NetworkIdentifier.TESTNET, + networkProperties: networkPropertiesSymbolTestnet, + currentAccount: symbolAccount, + currentAccountInfo: symbolAccountInfoLoaded, + ...ControllerState.NOT_READY + }, + SYMBOL_READY_NO_ACCOUNT_INFO: { + chainName: ChainName.SYMBOL, + ticker: Ticker.SYMBOL, + networkIdentifier: NetworkIdentifier.TESTNET, + networkProperties: networkPropertiesSymbolTestnet, + currentAccount: symbolAccount, + currentAccountInfo: null, + ...ControllerState.READY + }, + SYMBOL_READY_INFO_NOT_LOADED: { + chainName: ChainName.SYMBOL, + ticker: Ticker.SYMBOL, + networkIdentifier: NetworkIdentifier.TESTNET, + networkProperties: networkPropertiesSymbolTestnet, + currentAccount: symbolAccount, + currentAccountInfo: symbolAccountInfoNotLoaded, + ...ControllerState.READY + }, + SYMBOL_READY_WITH_TOKENS: { + chainName: ChainName.SYMBOL, + ticker: Ticker.SYMBOL, + networkIdentifier: NetworkIdentifier.TESTNET, + networkProperties: networkPropertiesSymbolTestnet, + currentAccount: symbolAccount, + currentAccountInfo: symbolAccountInfoWithTokens, + ...ControllerState.READY + } +}; + +// Test helpers + +const createWalletController = (scenario, overrides = {}) => { + const eventHandlers = {}; + + return createWalletControllerMock({ + ...scenario, + fetchAccountInfo: jest.fn().mockResolvedValue(undefined), + on: jest.fn((eventName, handler) => { + eventHandlers[eventName] = eventHandlers[eventName] || []; + eventHandlers[eventName].push(handler); + }), + removeListener: jest.fn((eventName, handler) => { + if (!eventHandlers[eventName]) + return; + + eventHandlers[eventName] = eventHandlers[eventName].filter(currentHandler => currentHandler !== handler); + }), + emit: eventName => { + if (!eventHandlers[eventName]) + return; + + eventHandlers[eventName].forEach(handler => handler()); + }, + ...overrides + }, { bindUseWalletController: false }); +}; + +const setAdditionalControllers = controllers => { + mockAdditionalControllers = controllers; +}; + +describe('hooks/useBridgeAccounts', () => { + beforeEach(() => { + jest.clearAllMocks(); + setAdditionalControllers([]); + }); + + runHookContractTest(useBridgeAccounts, { + props: [], + contract: { + accounts: 'array', + refresh: 'function' + } + }); + + describe('initialization', () => { + const runInitializationFetchTest = (description, config, expected) => { + it(description, () => { + // Arrange: + const fetchAccountInfoMock = jest.fn(); + setAdditionalControllers([ + createWalletController(config.scenario, { + fetchAccountInfo: fetchAccountInfoMock + }) + ]); + + // Act: + new HookTester(useBridgeAccounts); + + // Assert: + if (expected.shouldFetch) + expect(fetchAccountInfoMock).toHaveBeenCalledTimes(1); + else + expect(fetchAccountInfoMock).not.toHaveBeenCalled(); + }); + }; + + const initializationTests = [ + { + description: 'calls fetchAccountInfo on mount for ready controllers', + config: { scenario: ControllerScenario.SYMBOL_READY }, + expected: { shouldFetch: true } + }, + { + description: 'does not call fetchAccountInfo when no currentAccount', + config: { scenario: ControllerScenario.SYMBOL_READY_NO_ACCOUNT }, + expected: { shouldFetch: false } + }, + { + description: 'does not call fetchAccountInfo when wallet is not ready', + config: { scenario: ControllerScenario.SYMBOL_NOT_READY }, + expected: { shouldFetch: false } + } + ]; + + initializationTests.forEach(test => { + runInitializationFetchTest(test.description, test.config, test.expected); + }); + + it('calls fetchAccountInfo for each ready controller', () => { + // Arrange: + const symbolFetchAccountInfoMock = jest.fn(); + const ethereumFetchAccountInfoMock = jest.fn(); + setAdditionalControllers([ + createWalletController(ControllerScenario.SYMBOL_READY, { + fetchAccountInfo: symbolFetchAccountInfoMock + }), + createWalletController(ControllerScenario.ETHEREUM_READY, { + fetchAccountInfo: ethereumFetchAccountInfoMock + }) + ]); + + // Act: + new HookTester(useBridgeAccounts); + + // Assert: + expect(symbolFetchAccountInfoMock).toHaveBeenCalledTimes(1); + expect(ethereumFetchAccountInfoMock).toHaveBeenCalledTimes(1); + }); + }); + + describe('accounts mapping', () => { + it('creates account object for each wallet controller', () => { + // Arrange: + setAdditionalControllers([ + createWalletController(ControllerScenario.SYMBOL_READY), + createWalletController(ControllerScenario.ETHEREUM_READY) + ]); + + // Act: + const hookTester = new HookTester(useBridgeAccounts); + + // Assert: + expect(hookTester.currentResult.accounts).toHaveLength(2); + }); + + it('returns empty array when no additional controllers exist', () => { + // Arrange: + setAdditionalControllers([]); + + // Act: + const hookTester = new HookTester(useBridgeAccounts); + + // Assert: + expect(hookTester.currentResult.accounts).toEqual([]); + }); + + describe('account object properties', () => { + const runAccountObjectPropertyTest = (description, config, expected) => { + it(description, () => { + // Arrange: + setAdditionalControllers([ + createWalletController(config.scenario) + ]); + + // Act: + const hookTester = new HookTester(useBridgeAccounts); + const mappedAccount = hookTester.currentResult.accounts[0]; + + // Assert: + expect(mappedAccount).toMatchObject(expected); + }); + }; + + const accountObjectPropertyTests = [ + { + description: 'maps chainName from controller', + config: { scenario: ControllerScenario.SYMBOL_READY }, + expected: { chainName: ChainName.SYMBOL } + }, + { + description: 'maps ticker from controller', + config: { scenario: ControllerScenario.SYMBOL_READY }, + expected: { ticker: Ticker.SYMBOL } + }, + { + description: 'sets isActive true when currentAccount exists', + config: { scenario: ControllerScenario.SYMBOL_READY }, + expected: { isActive: true } + }, + { + description: 'sets isActive false when currentAccount is null', + config: { scenario: ControllerScenario.SYMBOL_READY_NO_ACCOUNT }, + expected: { isActive: false } + }, + { + description: 'maps account from currentAccount', + config: { scenario: ControllerScenario.SYMBOL_READY }, + expected: { account: symbolAccount } + }, + { + description: 'maps balance from currentAccountInfo', + config: { scenario: ControllerScenario.SYMBOL_READY }, + expected: { balance: BalanceValue.SYMBOL } + }, + { + description: 'returns zero balance when currentAccountInfo is null', + config: { scenario: ControllerScenario.SYMBOL_READY_NO_ACCOUNT_INFO }, + expected: { balance: 0 } + }, + { + description: 'maps tokens from currentAccountInfo', + config: { scenario: ControllerScenario.SYMBOL_READY_WITH_TOKENS }, + expected: { tokens: tokensMixed } + }, + { + description: 'returns empty tokens array when currentAccountInfo is null', + config: { scenario: ControllerScenario.SYMBOL_READY_NO_ACCOUNT_INFO }, + expected: { tokens: [] } + }, + { + description: 'sets isAccountInfoLoaded true when fetchedAt exists', + config: { scenario: ControllerScenario.SYMBOL_READY }, + expected: { isAccountInfoLoaded: true } + }, + { + description: 'sets isAccountInfoLoaded false when fetchedAt is missing', + config: { scenario: ControllerScenario.SYMBOL_READY_INFO_NOT_LOADED }, + expected: { isAccountInfoLoaded: false } + }, + { + description: 'sets isAccountInfoLoaded false when currentAccountInfo is null', + config: { scenario: ControllerScenario.SYMBOL_READY_NO_ACCOUNT_INFO }, + expected: { isAccountInfoLoaded: false } + } + ]; + + accountObjectPropertyTests.forEach(test => { + runAccountObjectPropertyTest(test.description, test.config, test.expected); + }); + }); + }); + + describe('event subscriptions', () => { + const runEventSubscriptionTest = (description, eventName) => { + it(description, () => { + // Arrange: + const onMock = jest.fn(); + setAdditionalControllers([ + createWalletController(ControllerScenario.SYMBOL_READY, { + on: onMock + }) + ]); + + // Act: + new HookTester(useBridgeAccounts); + + // Assert: + expect(onMock).toHaveBeenCalledWith(eventName, expect.any(Function)); + }); + }; + + const eventSubscriptionTests = [ + { + description: 'subscribes to account change events', + eventName: ControllerEvent.ACCOUNT_CHANGE + }, + { + description: 'subscribes to new transaction confirmed events', + eventName: ControllerEvent.NEW_TRANSACTION_CONFIRMED + }, + { + description: 'subscribes to network connected events', + eventName: ControllerEvent.NETWORK_CONNECTED + } + ]; + + eventSubscriptionTests.forEach(test => { + runEventSubscriptionTest(test.description, test.eventName); + }); + + it('subscribes to all events for each controller', () => { + // Arrange: + const symbolOnMock = jest.fn(); + const ethereumOnMock = jest.fn(); + setAdditionalControllers([ + createWalletController(ControllerScenario.SYMBOL_READY, { + on: symbolOnMock + }), + createWalletController(ControllerScenario.ETHEREUM_READY, { + on: ethereumOnMock + }) + ]); + + // Act: + new HookTester(useBridgeAccounts); + + // Assert: + expect(symbolOnMock).toHaveBeenCalledTimes(EXPECTED_EVENT_SUBSCRIPTIONS_PER_CONTROLLER); + expect(ethereumOnMock).toHaveBeenCalledTimes(EXPECTED_EVENT_SUBSCRIPTIONS_PER_CONTROLLER); + }); + }); + + describe('event handlers', () => { + const runEventHandlerFetchTest = (description, config, expected) => { + it(description, () => { + // Arrange: + const fetchAccountInfoMock = jest.fn(); + const controller = createWalletController(config.scenario, { + fetchAccountInfo: fetchAccountInfoMock + }); + setAdditionalControllers([controller]); + new HookTester(useBridgeAccounts); + fetchAccountInfoMock.mockClear(); + + // Act: + act(() => { + controller.emit(config.eventName); + }); + + // Assert: + if (expected.shouldFetch) + expect(fetchAccountInfoMock).toHaveBeenCalledTimes(1); + else + expect(fetchAccountInfoMock).not.toHaveBeenCalled(); + }); + }; + + const eventHandlerFetchTests = [ + { + description: 'fetches account info when account change event is emitted', + config: { + scenario: ControllerScenario.SYMBOL_READY, + eventName: ControllerEvent.ACCOUNT_CHANGE + }, + expected: { shouldFetch: true } + }, + { + description: 'fetches account info when new transaction confirmed event is emitted', + config: { + scenario: ControllerScenario.SYMBOL_READY, + eventName: ControllerEvent.NEW_TRANSACTION_CONFIRMED + }, + expected: { shouldFetch: true } + }, + { + description: 'fetches account info when network connected event is emitted', + config: { + scenario: ControllerScenario.SYMBOL_READY, + eventName: ControllerEvent.NETWORK_CONNECTED + }, + expected: { shouldFetch: true } + }, + { + description: 'does not fetch when controller is not ready on event trigger', + config: { + scenario: ControllerScenario.SYMBOL_NOT_READY, + eventName: ControllerEvent.ACCOUNT_CHANGE + }, + expected: { shouldFetch: false } + } + ]; + + eventHandlerFetchTests.forEach(test => { + runEventHandlerFetchTest(test.description, test.config, test.expected); + }); + }); + + describe('cleanup', () => { + it('unsubscribes from events on unmount', () => { + // Arrange: + const removeListenerMock = jest.fn(); + setAdditionalControllers([ + createWalletController(ControllerScenario.SYMBOL_READY, { + removeListener: removeListenerMock + }) + ]); + + // Act: + const hookTester = new HookTester(useBridgeAccounts); + hookTester.hookRenderer.unmount(); + + // Assert: + expect(removeListenerMock).toHaveBeenCalledTimes(EXPECTED_EVENT_SUBSCRIPTIONS_PER_CONTROLLER); + expect(removeListenerMock).toHaveBeenCalledWith(ControllerEvent.ACCOUNT_CHANGE, expect.any(Function)); + expect(removeListenerMock).toHaveBeenCalledWith(ControllerEvent.NEW_TRANSACTION_CONFIRMED, expect.any(Function)); + expect(removeListenerMock).toHaveBeenCalledWith(ControllerEvent.NETWORK_CONNECTED, expect.any(Function)); + }); + + it('unsubscribes from all controllers on unmount', () => { + // Arrange: + const symbolRemoveListenerMock = jest.fn(); + const ethereumRemoveListenerMock = jest.fn(); + setAdditionalControllers([ + createWalletController(ControllerScenario.SYMBOL_READY, { + removeListener: symbolRemoveListenerMock + }), + createWalletController(ControllerScenario.ETHEREUM_READY, { + removeListener: ethereumRemoveListenerMock + }) + ]); + + // Act: + const hookTester = new HookTester(useBridgeAccounts); + hookTester.hookRenderer.unmount(); + + // Assert: + expect(symbolRemoveListenerMock).toHaveBeenCalledTimes(EXPECTED_EVENT_SUBSCRIPTIONS_PER_CONTROLLER); + expect(ethereumRemoveListenerMock).toHaveBeenCalledTimes(EXPECTED_EVENT_SUBSCRIPTIONS_PER_CONTROLLER); + }); + }); + + describe('refresh function', () => { + const runRefreshFetchTest = (description, config, expected) => { + it(description, () => { + // Arrange: + const fetchAccountInfoMock = jest.fn(); + setAdditionalControllers([ + createWalletController(config.scenario, { + fetchAccountInfo: fetchAccountInfoMock + }) + ]); + + const hookTester = new HookTester(useBridgeAccounts); + fetchAccountInfoMock.mockClear(); + + // Act: + act(() => { + hookTester.currentResult.refresh(); + }); + + // Assert: + if (expected.shouldFetch) + expect(fetchAccountInfoMock).toHaveBeenCalledTimes(1); + else + expect(fetchAccountInfoMock).not.toHaveBeenCalled(); + }); + }; + + const refreshFetchTests = [ + { + description: 'calls fetchAccountInfo for ready controllers', + config: { scenario: ControllerScenario.SYMBOL_READY }, + expected: { shouldFetch: true } + }, + { + description: 'does not call fetchAccountInfo for controllers without currentAccount', + config: { scenario: ControllerScenario.SYMBOL_READY_NO_ACCOUNT }, + expected: { shouldFetch: false } + }, + { + description: 'does not call fetchAccountInfo for not-ready controllers', + config: { scenario: ControllerScenario.SYMBOL_NOT_READY }, + expected: { shouldFetch: false } + } + ]; + + refreshFetchTests.forEach(test => { + runRefreshFetchTest(test.description, test.config, test.expected); + }); + + it('calls fetchAccountInfo for all ready controllers', () => { + // Arrange: + const symbolFetchAccountInfoMock = jest.fn(); + const ethereumFetchAccountInfoMock = jest.fn(); + setAdditionalControllers([ + createWalletController(ControllerScenario.SYMBOL_READY, { + fetchAccountInfo: symbolFetchAccountInfoMock + }), + createWalletController(ControllerScenario.ETHEREUM_READY, { + fetchAccountInfo: ethereumFetchAccountInfoMock + }) + ]); + + const hookTester = new HookTester(useBridgeAccounts); + symbolFetchAccountInfoMock.mockClear(); + ethereumFetchAccountInfoMock.mockClear(); + + // Act: + act(() => { + hookTester.currentResult.refresh(); + }); + + // Assert: + expect(symbolFetchAccountInfoMock).toHaveBeenCalledTimes(1); + expect(ethereumFetchAccountInfoMock).toHaveBeenCalledTimes(1); + }); + + it('skips not-ready controllers when refreshing multiple controllers', () => { + // Arrange: + const symbolFetchAccountInfoMock = jest.fn(); + const ethereumFetchAccountInfoMock = jest.fn(); + setAdditionalControllers([ + createWalletController(ControllerScenario.SYMBOL_READY, { + fetchAccountInfo: symbolFetchAccountInfoMock + }), + createWalletController(ControllerScenario.ETHEREUM_READY, { + ...ControllerState.NOT_READY, + fetchAccountInfo: ethereumFetchAccountInfoMock + }) + ]); + + const hookTester = new HookTester(useBridgeAccounts); + symbolFetchAccountInfoMock.mockClear(); + ethereumFetchAccountInfoMock.mockClear(); + + // Act: + act(() => { + hookTester.currentResult.refresh(); + }); + + // Assert: + expect(symbolFetchAccountInfoMock).toHaveBeenCalledTimes(1); + expect(ethereumFetchAccountInfoMock).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/wallet/symbol/mobile/__tests__/screens/bridge/hooks/useBridgeAmount.test.js b/wallet/symbol/mobile/__tests__/screens/bridge/hooks/useBridgeAmount.test.js new file mode 100644 index 000000000..cb7854ec8 --- /dev/null +++ b/wallet/symbol/mobile/__tests__/screens/bridge/hooks/useBridgeAmount.test.js @@ -0,0 +1,176 @@ +import { useBridgeAmount } from '@/app/screens/bridge/hooks/useBridgeAmount'; +import { AccountFixtureBuilder } from '__fixtures__/local/AccountFixtureBuilder'; +import { NetworkPropertiesFixtureBuilder } from '__fixtures__/local/NetworkPropertiesFixtureBuilder'; +import { TokenFixtureBuilder } from '__fixtures__/local/TokenFixtureBuilder'; +import { HookTester } from '__tests__/HookTester'; +import { runHookContractTest } from '__tests__/hook-tests'; +import { createWalletControllerMock } from '__tests__/mock-helpers'; + +// Constants + +const CHAIN_NAME = 'symbol'; +const NETWORK_IDENTIFIER = 'testnet'; +const FEE_TIER_LEVEL_AVERAGE = 'average'; + +// Fixtures + +const account = AccountFixtureBuilder + .createWithAccount(CHAIN_NAME, NETWORK_IDENTIFIER, 0) + .build(); + +const nativeToken = TokenFixtureBuilder + .createWithToken(CHAIN_NAME, NETWORK_IDENTIFIER, 0) + .setAmount('100') + .build(); + +const nonNativeToken = TokenFixtureBuilder + .createWithToken(CHAIN_NAME, NETWORK_IDENTIFIER, 1) + .setAmount('150') + .build(); + +const networkCurrency = { + id: nativeToken.id, + mosaicId: nativeToken.id, + divisibility: nativeToken.divisibility, + name: nativeToken.name +}; + +const networkProperties = NetworkPropertiesFixtureBuilder + .createWithType(CHAIN_NAME, NETWORK_IDENTIFIER) + .setNetworkCurrency(networkCurrency) + .build(); + +const walletControllerWithNetwork = createWalletControllerMock({ + chainName: CHAIN_NAME, + networkIdentifier: NETWORK_IDENTIFIER, + networkProperties, + currentAccount: account +}); + +const walletControllerWithoutNetworkCurrency = createWalletControllerMock({ + chainName: CHAIN_NAME, + networkIdentifier: NETWORK_IDENTIFIER, + networkProperties: { networkCurrency: null }, + currentAccount: account +}); + +const sourceNative = { + chainName: CHAIN_NAME, + networkIdentifier: NETWORK_IDENTIFIER, + token: nativeToken, + walletController: walletControllerWithNetwork +}; + +const sourceNonNative = { + chainName: CHAIN_NAME, + networkIdentifier: NETWORK_IDENTIFIER, + token: nonNativeToken, + walletController: walletControllerWithNetwork +}; + +const sourceWithoutNetworkCurrency = { + chainName: CHAIN_NAME, + networkIdentifier: NETWORK_IDENTIFIER, + token: nativeToken, + walletController: walletControllerWithoutNetworkCurrency +}; + +const transactionFees = [ + { + [FEE_TIER_LEVEL_AVERAGE]: { + token: { + amount: '1', + divisibility: nativeToken.divisibility + } + } + } +]; + +// Hook Helpers + +const createHookParams = overrides => ({ + source: sourceNative, + transactionFees, + transactionFeeTierLevel: FEE_TIER_LEVEL_AVERAGE, + ...overrides +}); + +describe('hooks/useBridgeAmount', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + runHookContractTest(useBridgeAmount, { + props: [createHookParams()], + contract: { + amount: 'string', + amountInput: 'string', + isAmountValid: 'boolean', + availableBalance: 'string', + changeAmount: 'function', + changeAmountValidity: 'function', + reset: 'function' + } + }); + + describe('initialization', () => { + const runInitializationTest = (description, config, expected) => { + it(description, () => { + // Arrange: + const params = createHookParams(config); + + // Act: + const hookTester = new HookTester(useBridgeAmount, [params]); + + // Assert: + expect(hookTester.currentResult.amount).toBe(expected.amount); + expect(hookTester.currentResult.amountInput).toBe(expected.amountInput); + expect(hookTester.currentResult.isAmountValid).toBe(expected.isAmountValid); + expect(hookTester.currentResult.availableBalance).toBe(expected.availableBalance); + }); + }; + + const initializationTests = [ + { + description: 'initializes with native currency balance minus fee', + config: { + source: sourceNative + }, + expected: { + amount: '0', + amountInput: '0', + isAmountValid: true, + availableBalance: '99' + } + }, + { + description: 'initializes with zero available balance when native currency is missing', + config: { + source: sourceWithoutNetworkCurrency + }, + expected: { + amount: '0', + amountInput: '0', + isAmountValid: true, + availableBalance: '0' + } + }, + { + description: 'initializes non-native source with full available balance', + config: { + source: sourceNonNative + }, + expected: { + amount: '0', + amountInput: '0', + isAmountValid: true, + availableBalance: '150' + } + } + ]; + + initializationTests.forEach(test => { + runInitializationTest(test.description, test.config, test.expected); + }); + }); +}); diff --git a/wallet/symbol/mobile/__tests__/screens/bridge/hooks/useBridgeHistory.test.js b/wallet/symbol/mobile/__tests__/screens/bridge/hooks/useBridgeHistory.test.js new file mode 100644 index 000000000..0bdc2534f --- /dev/null +++ b/wallet/symbol/mobile/__tests__/screens/bridge/hooks/useBridgeHistory.test.js @@ -0,0 +1,69 @@ +import { BRIDGE_HISTORY_PAGE_SIZE } from '@/app/screens/bridge/constants'; +import { useBridgeHistory } from '@/app/screens/bridge/hooks/useBridgeHistory'; +import { AccountFixtureBuilder } from '__fixtures__/local/AccountFixtureBuilder'; +import { HookTester } from '__tests__/HookTester'; +import { runHookContractTest } from '__tests__/hook-tests'; + +// Constants + +const CHAIN_NAME = 'symbol'; +const NETWORK_IDENTIFIER = 'testnet'; + +// Fixtures + +const account = AccountFixtureBuilder + .createWithAccount(CHAIN_NAME, NETWORK_IDENTIFIER, 0) + .build(); + +const historyEntry1 = { + id: 'bridge-request-1', + sourceAddress: account.address, + targetAddress: 'target-address-1', + amount: '10' +}; + +const historyEntries = [historyEntry1]; + +const bridgeReady = { + isReady: true, + fetchRecentHistory: jest.fn().mockResolvedValue(historyEntries) +}; + +// Hook Helpers + +const createHookParams = overrides => ({ + bridge: bridgeReady, + ...overrides +}); + +describe('hooks/useBridgeHistory', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + runHookContractTest(useBridgeHistory, { + props: [createHookParams()], + contract: { + history: 'object', + isHistoryLoading: 'boolean', + refreshHistory: 'function', + clearHistory: 'function' + } + }); + + describe('initialization', () => { + it('fetches bridge history on initial render when bridge is ready', async () => { + // Arrange: + const params = createHookParams(); + + // Act: + const hookTester = new HookTester(useBridgeHistory, [params]); + + // Assert: + await hookTester.waitFor(() => { + expect(bridgeReady.fetchRecentHistory).toHaveBeenCalledWith(BRIDGE_HISTORY_PAGE_SIZE); + expect(hookTester.currentResult.history).toStrictEqual(historyEntries); + }); + }); + }); +}); diff --git a/wallet/symbol/mobile/__tests__/screens/bridge/hooks/useBridgeNoPairsDialog.test.js b/wallet/symbol/mobile/__tests__/screens/bridge/hooks/useBridgeNoPairsDialog.test.js new file mode 100644 index 000000000..03388a1df --- /dev/null +++ b/wallet/symbol/mobile/__tests__/screens/bridge/hooks/useBridgeNoPairsDialog.test.js @@ -0,0 +1,89 @@ +import { useBridgeNoPairsDialog } from '@/app/screens/bridge/hooks/useBridgeNoPairsDialog'; +import { BridgePairsStatus } from '@/app/screens/bridge/types/Bridge'; +import { HookTester } from '__tests__/HookTester'; +import { runHookContractTest } from '__tests__/hook-tests'; +import { mockRouter } from '__tests__/mock-helpers'; + +// Constants + +const PairsStatus = { + NO_PAIRS: BridgePairsStatus.NO_PAIRS, + OK: BridgePairsStatus.OK, + LOADING: BridgePairsStatus.LOADING +}; + +// Hook Helpers + +const createHookParams = overrides => ({ + pairsStatus: PairsStatus.NO_PAIRS, + ...overrides +}); + +describe('hooks/useBridgeNoPairsDialog', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockRouter({ + goBack: jest.fn(), + goToBridgeAccountList: jest.fn() + }); + }); + + runHookContractTest(useBridgeNoPairsDialog, { + props: [createHookParams()], + contract: { + isVisible: 'boolean', + onSuccess: 'function', + onCancel: 'function', + onScreenFocus: 'function' + } + }); + + describe('initialization', () => { + const runInitializationTest = (description, config, expected) => { + it(description, () => { + // Arrange: + const params = createHookParams(config); + + // Act: + const hookTester = new HookTester(useBridgeNoPairsDialog, [params]); + + // Assert: + expect(hookTester.currentResult.isVisible).toBe(expected.isVisible); + }); + }; + + const initializationTests = [ + { + description: 'shows dialog initially when pairs status is no_pairs', + config: { + pairsStatus: PairsStatus.NO_PAIRS + }, + expected: { + isVisible: true + } + }, + { + description: 'hides dialog initially when pairs status is ok', + config: { + pairsStatus: PairsStatus.OK + }, + expected: { + isVisible: false + } + }, + { + description: 'hides dialog initially when pairs status is loading', + config: { + pairsStatus: PairsStatus.LOADING + }, + expected: { + isVisible: false + } + } + ]; + + initializationTests.forEach(test => { + runInitializationTest(test.description, test.config, test.expected); + }); + }); +}); diff --git a/wallet/symbol/mobile/__tests__/screens/bridge/hooks/useBridgeTransaction.test.js b/wallet/symbol/mobile/__tests__/screens/bridge/hooks/useBridgeTransaction.test.js new file mode 100644 index 000000000..c7fbab24d --- /dev/null +++ b/wallet/symbol/mobile/__tests__/screens/bridge/hooks/useBridgeTransaction.test.js @@ -0,0 +1,135 @@ +import { useBridgeTransaction } from '@/app/screens/bridge/hooks/useBridgeTransaction'; +import { AccountFixtureBuilder } from '__fixtures__/local/AccountFixtureBuilder'; +import { TokenFixtureBuilder } from '__fixtures__/local/TokenFixtureBuilder'; +import { TransactionFixtureBuilder } from '__fixtures__/local/TransactionFixtureBuilder'; +import { HookTester } from '__tests__/HookTester'; +import { runHookContractTest } from '__tests__/hook-tests'; +import { createWalletControllerMock } from '__tests__/mock-helpers'; +import { act } from '@testing-library/react-native'; + +// Constants + +const CHAIN_NAME = 'symbol'; +const NETWORK_IDENTIFIER = 'testnet'; +const BRIDGE_ID = 'symbol-xym-ethereum-wxym'; +const AMOUNT = '25'; + +// Fixtures + +const sourceAccount = AccountFixtureBuilder + .createWithAccount(CHAIN_NAME, NETWORK_IDENTIFIER, 0) + .build(); + +const targetAccount = AccountFixtureBuilder + .createWithAccount('ethereum', NETWORK_IDENTIFIER, 0) + .build(); + +const sourceToken = TokenFixtureBuilder + .createWithToken(CHAIN_NAME, NETWORK_IDENTIFIER, 0) + .setAmount('200') + .build(); + +const transferToken = TokenFixtureBuilder + .createWithToken(CHAIN_NAME, NETWORK_IDENTIFIER, 0) + .setAmount(AMOUNT) + .build(); + +const transactionFeeToken = TokenFixtureBuilder + .createWithToken(CHAIN_NAME, NETWORK_IDENTIFIER, 0) + .setAmount('0.1') + .build(); + +const bridgeTransaction = TransactionFixtureBuilder + .createDefault(CHAIN_NAME, NETWORK_IDENTIFIER) + .override({ + message: { text: targetAccount.address }, + mosaics: [transferToken], + fee: { + token: transactionFeeToken + } + }) + .build(); + +const bridgeModule = { + createTransaction: jest.fn().mockResolvedValue(bridgeTransaction) +}; + +const sourceWalletController = createWalletControllerMock({ + currentAccount: sourceAccount, + modules: { + bridge: bridgeModule + } +}); + +const targetWalletController = createWalletControllerMock({ + currentAccount: targetAccount +}); + +const sourceSide = { + chainName: CHAIN_NAME, + networkIdentifier: NETWORK_IDENTIFIER, + token: sourceToken, + walletController: sourceWalletController +}; + +const targetSide = { + chainName: 'ethereum', + networkIdentifier: NETWORK_IDENTIFIER, + token: sourceToken, + walletController: targetWalletController +}; + +// Hook Helpers + +const createHookParams = overrides => ({ + bridgeId: BRIDGE_ID, + source: sourceSide, + target: targetSide, + amount: AMOUNT, + ...overrides +}); + +describe('hooks/useBridgeTransaction', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + runHookContractTest(useBridgeTransaction, { + props: [createHookParams()], + contract: { + createTransaction: 'function', + getTransactionPreviewTable: 'function' + } + }); + + describe('initialization', () => { + it('creates transaction using initialized params and builds preview rows', async () => { + // Arrange: + const params = createHookParams(); + const expectedTransactionData = { + bridgeId: BRIDGE_ID, + recipientAddress: targetAccount.address, + amount: AMOUNT + }; + let createdTransaction; + let previewTable; + + // Act: + const hookTester = new HookTester(useBridgeTransaction, [params]); + await act(async () => { + createdTransaction = await hookTester.currentResult.createTransaction(); + previewTable = hookTester.currentResult.getTransactionPreviewTable(createdTransaction); + }); + + // Assert: + expect(bridgeModule.createTransaction).toHaveBeenCalledWith(expectedTransactionData); + expect(createdTransaction).toStrictEqual(bridgeTransaction); + expect(previewTable).toStrictEqual([ + { type: 'account', value: bridgeTransaction.signerAddress, title: 'signerAddress' }, + { type: 'account', value: bridgeTransaction.message.text, title: 'recipientAddress' }, + { type: 'token', value: bridgeTransaction.mosaics, title: 'mosaics' }, + { type: 'fee', value: bridgeTransaction.fee, title: 'fee' } + ]); + }); + }); +}); diff --git a/wallet/symbol/mobile/__tests__/screens/bridge/hooks/useEstimation.test.js b/wallet/symbol/mobile/__tests__/screens/bridge/hooks/useEstimation.test.js new file mode 100644 index 000000000..1f4f076fa --- /dev/null +++ b/wallet/symbol/mobile/__tests__/screens/bridge/hooks/useEstimation.test.js @@ -0,0 +1,84 @@ +import { useEstimation } from '@/app/screens/bridge/hooks/useEstimation'; +import { BridgeMode } from '@/app/screens/bridge/types/Bridge'; +import { TokenFixtureBuilder } from '__fixtures__/local/TokenFixtureBuilder'; +import { HookTester } from '__tests__/HookTester'; +import { runHookContractTest } from '__tests__/hook-tests'; +import { act } from '@testing-library/react-native'; + +// Constants + +const CHAIN_NAME = 'symbol'; +const NETWORK_IDENTIFIER = 'testnet'; +const AMOUNT = '12'; + +// Fixtures + +const estimationFeeToken = TokenFixtureBuilder + .createWithToken(CHAIN_NAME, NETWORK_IDENTIFIER, 0) + .setAmount('0.2') + .build(); + +const estimationReceiveToken = TokenFixtureBuilder + .createWithToken('ethereum', NETWORK_IDENTIFIER, 1) + .setAmount('11.8') + .build(); + +const estimationData = { + fee: { + token: estimationFeeToken + }, + receive: { + token: estimationReceiveToken + } +}; + +const bridgeManager = { + estimateRequest: jest.fn().mockResolvedValue(estimationData) +}; + +// Hook Helpers + +const createHookParams = overrides => ({ + bridge: bridgeManager, + mode: BridgeMode.WRAP, + amount: AMOUNT, + ...overrides +}); + +describe('hooks/useEstimation', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + runHookContractTest(useEstimation, { + props: [createHookParams()], + contract: { + estimate: 'function', + estimation: 'object', + clearEstimation: 'function', + isLoading: 'boolean' + } + }); + + describe('initialization', () => { + it('initializes estimation flow with provided bridge and request params', async () => { + // Arrange: + const params = createHookParams(); + const expectedMode = BridgeMode.WRAP; + const expectedAmount = AMOUNT; + + // Act: + const hookTester = new HookTester(useEstimation, [params]); + await act(async () => { + hookTester.currentResult.estimate(); + }); + + // Assert: + await hookTester.waitFor(() => { + expect(bridgeManager.estimateRequest).toHaveBeenCalledWith(expectedMode, expectedAmount); + expect(hookTester.currentResult.estimation).toStrictEqual(estimationData); + expect(hookTester.currentResult.isLoading).toBe(false); + }); + }); + }); +}); diff --git a/wallet/symbol/mobile/__tests__/screens/bridge/hooks/useSwapSelector.test.js b/wallet/symbol/mobile/__tests__/screens/bridge/hooks/useSwapSelector.test.js new file mode 100644 index 000000000..15e8c9fbc --- /dev/null +++ b/wallet/symbol/mobile/__tests__/screens/bridge/hooks/useSwapSelector.test.js @@ -0,0 +1,555 @@ +import { useSwapSelector } from '@/app/screens/bridge/hooks/useSwapSelector'; +import { BridgeMode } from '@/app/screens/bridge/types/Bridge'; +import { AccountFixtureBuilder } from '__fixtures__/local/AccountFixtureBuilder'; +import { NetworkPropertiesFixtureBuilder } from '__fixtures__/local/NetworkPropertiesFixtureBuilder'; +import { TokenFixtureBuilder } from '__fixtures__/local/TokenFixtureBuilder'; +import { HookTester } from '__tests__/HookTester'; +import { runHookContractTest } from '__tests__/hook-tests'; +import { createWalletControllerMock } from '__tests__/mock-helpers'; +import { act } from '@testing-library/react-native'; + +// Constants + +const CHAIN_NAME_SYMBOL = 'symbol'; +const CHAIN_NAME_ETHEREUM = 'ethereum'; +const NETWORK_IDENTIFIER = 'testnet'; + +const BRIDGE_ID_XYM_TO_WXYM = 'symbol-xym-ethereum-wxym'; +const BRIDGE_ID_XYM_TO_ETH = 'symbol-xym-ethereum-eth'; + +// Account Fixtures + +const symbolAccount = AccountFixtureBuilder + .createWithAccount(CHAIN_NAME_SYMBOL, NETWORK_IDENTIFIER, 0) + .build(); + +const ethereumAccount = AccountFixtureBuilder + .createWithAccount(CHAIN_NAME_ETHEREUM, NETWORK_IDENTIFIER, 0) + .build(); + +// Network Properties Fixtures + +const symbolNetworkProperties = NetworkPropertiesFixtureBuilder + .createWithType(CHAIN_NAME_SYMBOL, NETWORK_IDENTIFIER) + .build(); + +const ethereumNetworkProperties = NetworkPropertiesFixtureBuilder + .createWithType(CHAIN_NAME_ETHEREUM, NETWORK_IDENTIFIER) + .build(); + +// Wallet Controller Fixtures + +const symbolWalletController = createWalletControllerMock({ + chainName: CHAIN_NAME_SYMBOL, + networkIdentifier: NETWORK_IDENTIFIER, + networkProperties: symbolNetworkProperties, + currentAccount: symbolAccount +}); + +const ethereumWalletController = createWalletControllerMock({ + chainName: CHAIN_NAME_ETHEREUM, + networkIdentifier: NETWORK_IDENTIFIER, + networkProperties: ethereumNetworkProperties, + currentAccount: ethereumAccount +}); + +// Bridge Fixtures + +const createBridgeMock = id => ({ + id, + estimateFee: jest.fn(), + createTransaction: jest.fn() +}); + +const bridgeXymToWxym = createBridgeMock(BRIDGE_ID_XYM_TO_WXYM); +const bridgeXymToEth = createBridgeMock(BRIDGE_ID_XYM_TO_ETH); + +// Token Fixtures + +const swapTokenXym = TokenFixtureBuilder + .createWithToken(CHAIN_NAME_SYMBOL, NETWORK_IDENTIFIER, 0) + .setAmount('1000000000') + .build(); + +const swapTokenWxym = TokenFixtureBuilder + .createWithToken(CHAIN_NAME_ETHEREUM, NETWORK_IDENTIFIER, 1) + .setAmount('500000000') + .build(); + +const swapTokenEth = TokenFixtureBuilder + .createWithToken(CHAIN_NAME_ETHEREUM, NETWORK_IDENTIFIER, 0) + .setAmount('2000000000') + .build(); + +const swapTokenXymUpdated = TokenFixtureBuilder + .createWithToken(CHAIN_NAME_SYMBOL, NETWORK_IDENTIFIER, 0) + .setAmount('9999999') + .build(); + +// Swap Side Fixtures + +const swapSideSymbolXym = { + token: swapTokenXym, + chainName: CHAIN_NAME_SYMBOL, + networkIdentifier: NETWORK_IDENTIFIER, + walletController: symbolWalletController +}; + +const swapSideEthereumWxym = { + token: swapTokenWxym, + chainName: CHAIN_NAME_ETHEREUM, + networkIdentifier: NETWORK_IDENTIFIER, + walletController: ethereumWalletController +}; + +const swapSideEthereumEth = { + token: swapTokenEth, + chainName: CHAIN_NAME_ETHEREUM, + networkIdentifier: NETWORK_IDENTIFIER, + walletController: ethereumWalletController +}; + +// Swap Pair Fixtures + +const swapPairXymToWxym = { + source: swapSideSymbolXym, + target: swapSideEthereumWxym, + bridge: bridgeXymToWxym, + mode: BridgeMode.WRAP +}; + +const swapPairWxymToXym = { + source: swapSideEthereumWxym, + target: swapSideSymbolXym, + bridge: bridgeXymToWxym, + mode: BridgeMode.UNWRAP +}; + +const swapPairXymToEth = { + source: swapSideSymbolXym, + target: swapSideEthereumEth, + bridge: bridgeXymToEth, + mode: BridgeMode.WRAP +}; + +const swapPairEthToXym = { + source: swapSideEthereumEth, + target: swapSideSymbolXym, + bridge: bridgeXymToEth, + mode: BridgeMode.UNWRAP +}; + +const swapPairXymToWxymUpdated = { + ...swapPairXymToWxym, + source: { + ...swapPairXymToWxym.source, + token: swapTokenXymUpdated + } +}; + +// Pair Collections + +const pairsEmpty = []; +const pairsSingleWxym = [swapPairXymToWxym]; +const pairsBidirectionalWxym = [swapPairXymToWxym, swapPairWxymToXym]; +const pairsAll = [ + swapPairXymToWxym, + swapPairWxymToXym, + swapPairXymToEth, + swapPairEthToXym +]; + +// Hook Helpers + +const createHookParams = overrides => ({ + pairs: pairsBidirectionalWxym, + defaultSourceChainName: CHAIN_NAME_SYMBOL, + ...overrides +}); + +describe('hooks/useSwapSelector', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + runHookContractTest(useSwapSelector, { + props: [createHookParams()], + contract: { + isReady: 'boolean', + bridge: 'object', + mode: 'string', + source: 'object', + target: 'object', + sourceList: 'array', + targetList: 'array', + changeSource: 'function', + changeTarget: 'function', + reverse: 'function' + } + }); + + describe('initialization', () => { + const runInitializationTest = (description, config, expected) => { + it(description, () => { + // Arrange: + const params = createHookParams(config); + const hookTester = new HookTester(useSwapSelector, [params]); + + // Assert: + expect(hookTester.currentResult.source).toStrictEqual(expected.source); + expect(hookTester.currentResult.target).toStrictEqual(expected.target); + expect(hookTester.currentResult.bridge).toStrictEqual(expected.bridge); + expect(hookTester.currentResult.mode).toStrictEqual(expected.mode); + expect(hookTester.currentResult.isReady).toBe(expected.isReady); + }); + }; + + const initializationTests = [ + { + description: 'sets null state when pairs are empty', + config: { + pairs: pairsEmpty, + defaultSourceChainName: CHAIN_NAME_SYMBOL + }, + expected: { + source: null, + target: null, + bridge: null, + mode: null, + isReady: false + } + }, + { + description: 'falls back to first pair when default source chain is not found', + config: { + pairs: pairsSingleWxym, + defaultSourceChainName: 'unknown-chain' + }, + expected: { + source: swapPairXymToWxym.source, + target: swapPairXymToWxym.target, + bridge: bridgeXymToWxym, + mode: BridgeMode.WRAP, + isReady: true + } + } + ]; + + initializationTests.forEach(test => { + runInitializationTest(test.description, test.config, test.expected); + }); + }); + + describe('default pair and bridge selection', () => { + const runDefaultSelectionTest = (description, config, expected) => { + it(description, () => { + // Arrange: + const params = createHookParams(config); + const hookTester = new HookTester(useSwapSelector, [params]); + + // Assert: + expect(hookTester.currentResult.source.chainName).toBe(expected.sourceChainName); + expect(hookTester.currentResult.source.token.id).toBe(expected.sourceTokenId); + expect(hookTester.currentResult.target.chainName).toBe(expected.targetChainName); + expect(hookTester.currentResult.target.token.id).toBe(expected.targetTokenId); + expect(hookTester.currentResult.bridge).toBe(expected.bridge); + expect(hookTester.currentResult.mode).toBe(expected.mode); + }); + }; + + const defaultSelectionTests = [ + { + description: 'selects wrap direction for symbol as default source', + config: { pairs: pairsBidirectionalWxym, defaultSourceChainName: CHAIN_NAME_SYMBOL }, + expected: { + sourceChainName: CHAIN_NAME_SYMBOL, + sourceTokenId: swapTokenXym.id, + targetChainName: CHAIN_NAME_ETHEREUM, + targetTokenId: swapTokenWxym.id, + bridge: bridgeXymToWxym, + mode: BridgeMode.WRAP + } + }, + { + description: 'selects unwrap direction for ethereum as default source', + config: { pairs: pairsBidirectionalWxym, defaultSourceChainName: CHAIN_NAME_ETHEREUM }, + expected: { + sourceChainName: CHAIN_NAME_ETHEREUM, + sourceTokenId: swapTokenWxym.id, + targetChainName: CHAIN_NAME_SYMBOL, + targetTokenId: swapTokenXym.id, + bridge: bridgeXymToWxym, + mode: BridgeMode.UNWRAP + } + } + ]; + + defaultSelectionTests.forEach(test => { + runDefaultSelectionTest(test.description, test.config, test.expected); + }); + }); + + describe('selection updates', () => { + const runSelectionUpdateTest = (description, config, expected) => { + it(description, () => { + // Arrange: + const params = createHookParams({ + pairs: config.pairs, + defaultSourceChainName: config.defaultSourceChainName + }); + const hookTester = new HookTester(useSwapSelector, [params]); + + // Act: + act(() => { + if (config.changeSource) + hookTester.currentResult.changeSource(config.changeSource); + + if (config.changeTarget) + hookTester.currentResult.changeTarget(config.changeTarget); + }); + + // Assert: + expect(hookTester.currentResult.source).toStrictEqual(expected.source); + expect(hookTester.currentResult.target).toStrictEqual(expected.target); + expect(hookTester.currentResult.sourceList).toStrictEqual(expected.sourceList); + expect(hookTester.currentResult.targetList).toStrictEqual(expected.targetList); + expect(hookTester.currentResult.bridge).toBe(expected.bridge); + expect(hookTester.currentResult.mode).toBe(expected.mode); + expect(hookTester.currentResult.isReady).toBe(expected.isReady); + }); + }; + + const selectionUpdateTests = [ + { + description: 'ignores invalid source selection and keeps current state', + config: { + pairs: pairsSingleWxym, + defaultSourceChainName: CHAIN_NAME_SYMBOL, + changeSource: swapSideEthereumEth + }, + expected: { + source: swapSideSymbolXym, + target: swapSideEthereumWxym, + sourceList: [swapSideSymbolXym], + targetList: [swapSideEthereumWxym], + bridge: bridgeXymToWxym, + mode: BridgeMode.WRAP, + isReady: true + } + }, + { + description: 'updates target to ETH maintaining symbol XYM source', + config: { + pairs: pairsAll, + defaultSourceChainName: CHAIN_NAME_SYMBOL, + changeTarget: swapSideEthereumEth + }, + expected: { + source: swapSideSymbolXym, + target: swapSideEthereumEth, + sourceList: [swapSideSymbolXym], + targetList: [ + swapSideEthereumWxym, + swapSideEthereumEth + ], + bridge: bridgeXymToEth, + mode: BridgeMode.WRAP, + isReady: true + } + }, + { + description: 'switches from wrap to unwrap by changing both source and target', + config: { + pairs: pairsBidirectionalWxym, + defaultSourceChainName: CHAIN_NAME_SYMBOL, + changeSource: swapSideEthereumWxym, + changeTarget: swapSideSymbolXym + }, + expected: { + source: swapSideEthereumWxym, + target: swapSideSymbolXym, + sourceList: [swapSideEthereumWxym], + targetList: [swapSideSymbolXym], + bridge: bridgeXymToWxym, + mode: BridgeMode.UNWRAP, + isReady: true + } + }, + { + description: 'changes ethereum source from WXYM to ETH keeping XYM target', + config: { + pairs: pairsAll, + defaultSourceChainName: CHAIN_NAME_ETHEREUM, + changeSource: swapSideEthereumEth + }, + expected: { + source: swapSideEthereumEth, + target: swapSideSymbolXym, + sourceList: [ + swapSideEthereumWxym, + swapSideEthereumEth + ], + targetList: [swapSideSymbolXym], + bridge: bridgeXymToEth, + mode: BridgeMode.UNWRAP, + isReady: true + } + }, + { + description: 'switches from unwrap to wrap with cross-chain token change', + config: { + pairs: pairsAll, + defaultSourceChainName: CHAIN_NAME_ETHEREUM, + changeSource: swapSideSymbolXym, + changeTarget: swapSideEthereumEth + }, + expected: { + source: swapSideSymbolXym, + target: swapSideEthereumEth, + sourceList: [swapSideSymbolXym], + targetList: [ + swapSideEthereumWxym, + swapSideEthereumEth + ], + bridge: bridgeXymToEth, + mode: BridgeMode.WRAP, + isReady: true + } + } + ]; + + selectionUpdateTests.forEach(test => { + runSelectionUpdateTest(test.description, test.config, test.expected); + }); + }); + + describe('reverse', () => { + const runReverseTest = (description, config, expected) => { + it(description, () => { + // Arrange: + const hookTester = new HookTester(useSwapSelector, [createHookParams({ + pairs: pairsBidirectionalWxym, + defaultSourceChainName: CHAIN_NAME_SYMBOL + })]); + const initialSource = hookTester.currentResult.source; + const initialTarget = hookTester.currentResult.target; + + // Act: + for (let i = 0; i < config.reverseCount; i++) { + act(() => { + hookTester.currentResult.reverse(); + }); + } + + // Assert: + if (expected.isFinalValuesReversed) { + expect(hookTester.currentResult.source).toStrictEqual(initialTarget); + expect(hookTester.currentResult.target).toStrictEqual(initialSource); + } else { + expect(hookTester.currentResult.source).toStrictEqual(initialSource); + expect(hookTester.currentResult.target).toStrictEqual(initialTarget); + } + }); + }; + + const reverseTests = [ + { + description: 'reverses source and target correctly', + config: { reverseCount: 1 }, + expected: { isFinalValuesReversed: true } + }, + { + description: 'reverses back to initial state on second reverse', + config: { reverseCount: 2 }, + expected: { isFinalValuesReversed: false } + } + ]; + + reverseTests.forEach(test => { + runReverseTest(test.description, test.config, test.expected); + }); + }); + + describe('pair updates', () => { + it('refreshes selected side balance when pairs are rerendered', async () => { + // Arrange: + const params = createHookParams({ + pairs: pairsSingleWxym, + defaultSourceChainName: CHAIN_NAME_SYMBOL + }); + const hookTester = new HookTester(useSwapSelector, [params]); + + // Act: + act(() => { + hookTester.updateProps([{ + pairs: [swapPairXymToWxymUpdated], + defaultSourceChainName: CHAIN_NAME_SYMBOL + }]); + }); + + // Assert: + await hookTester.waitFor(() => { + expect(hookTester.currentResult.source.token.amount).toBe(swapTokenXymUpdated.amount); + }); + }); + + it('resets hook state when pairs become empty', async () => { + // Arrange: + const params = createHookParams({ + pairs: pairsBidirectionalWxym, + defaultSourceChainName: CHAIN_NAME_SYMBOL + }); + const hookTester = new HookTester(useSwapSelector, [params]); + + // Act: + act(() => { + hookTester.updateProps([{ + pairs: pairsEmpty, + defaultSourceChainName: CHAIN_NAME_SYMBOL + }]); + }); + + // Assert: + await hookTester.waitFor(() => { + expect(hookTester.currentResult.source).toBeNull(); + expect(hookTester.currentResult.target).toBeNull(); + expect(hookTester.currentResult.bridge).toBeNull(); + expect(hookTester.currentResult.mode).toBeNull(); + expect(hookTester.currentResult.sourceList).toStrictEqual([]); + expect(hookTester.currentResult.targetList).toStrictEqual([]); + expect(hookTester.currentResult.isReady).toBe(false); + }); + }); + + it('keeps previous state when selected pair no longer exists in updated pairs', async () => { + // Arrange: + const params = createHookParams({ + pairs: pairsAll, + defaultSourceChainName: CHAIN_NAME_SYMBOL + }); + const hookTester = new HookTester(useSwapSelector, [params]); + + // Change to XYM -> ETH pair + act(() => { + hookTester.currentResult.changeTarget(swapSideEthereumEth); + }); + + const previousSource = hookTester.currentResult.source; + const previousTarget = hookTester.currentResult.target; + + // Act: Update pairs to only include WXYM pairs + act(() => { + hookTester.updateProps([{ + pairs: pairsBidirectionalWxym, + defaultSourceChainName: CHAIN_NAME_SYMBOL + }]); + }); + + // Assert: Should keep previous state since selected pair no longer exists + await hookTester.waitFor(() => { + expect(hookTester.currentResult.source).toBe(previousSource); + expect(hookTester.currentResult.target).toBe(previousTarget); + }); + }); + }); +}); diff --git a/wallet/symbol/mobile/__tests__/screens/bridge/utils/activity-log.test.js b/wallet/symbol/mobile/__tests__/screens/bridge/utils/activity-log.test.js new file mode 100644 index 000000000..56c61ba24 --- /dev/null +++ b/wallet/symbol/mobile/__tests__/screens/bridge/utils/activity-log.test.js @@ -0,0 +1,260 @@ +import { ActivityStatus } from '@/app/constants'; +import { BridgePayoutStatus, BridgeRequestStatus } from '@/app/screens/bridge/types/Bridge'; +import { buildActivityLog } from '@/app/screens/bridge/utils/activity-log'; +import { formatDate } from '@/app/utils'; +import { mockLocalization } from '__tests__/mock-helpers'; + +// Constants + +const REQUEST_TIMESTAMP = 1684265310994; +const PAYOUT_TIMESTAMP = 1684351710994; +const ERROR_MESSAGE = 'Bridge processing error'; + +// Screen Text + +const SCREEN_TEXT = { + textStepRequestSend: 's_bridge_swapStatus_step_requestSend', + textStepAwaitingBridge: 's_bridge_swapStatus_step_awaitingBridge', + textStepPayoutSend: 's_bridge_swapStatus_step_payoutSend', + textStepPayoutConfirmation: 's_bridge_swapStatus_step_payoutConfirmation', + textRequestDateValue: formatDate(REQUEST_TIMESTAMP, key => key, true), + textPayoutDateValue: formatDate(PAYOUT_TIMESTAMP, key => key, true) +}; + +// Icon Names + +const IconName = { + SEND_PLANE: 'send-plane', + PENDING: 'pending', + SWAP: 'swap', + CHECK: 'check' +}; + +describe('screens/bridge/utils/activity-log', () => { + beforeEach(() => { + mockLocalization(); + }); + + describe('buildActivityLog', () => { + describe('activity log structure', () => { + it('returns array with four activity log items', () => { + // Arrange: + const params = { + requestStatus: BridgeRequestStatus.CONFIRMED, + payoutStatus: BridgePayoutStatus.COMPLETED + }; + + // Act: + const result = buildActivityLog(params); + + // Assert: + expect(result).toHaveLength(4); + }); + + it('returns items with correct titles', () => { + // Arrange: + const params = { + requestStatus: BridgeRequestStatus.CONFIRMED, + payoutStatus: BridgePayoutStatus.COMPLETED + }; + + // Act: + const result = buildActivityLog(params); + + // Assert: + expect(result[0].title).toBe(SCREEN_TEXT.textStepRequestSend); + expect(result[1].title).toBe(SCREEN_TEXT.textStepAwaitingBridge); + expect(result[2].title).toBe(SCREEN_TEXT.textStepPayoutSend); + expect(result[3].title).toBe(SCREEN_TEXT.textStepPayoutConfirmation); + }); + + it('returns items with correct icons', () => { + // Arrange: + const params = { + requestStatus: BridgeRequestStatus.CONFIRMED, + payoutStatus: BridgePayoutStatus.COMPLETED + }; + + // Act: + const result = buildActivityLog(params); + + // Assert: + expect(result[0].icon).toBe(IconName.SEND_PLANE); + expect(result[1].icon).toBe(IconName.PENDING); + expect(result[2].icon).toBe(IconName.SWAP); + expect(result[3].icon).toBe(IconName.CHECK); + }); + }); + + describe('status transitions', () => { + const runStatusTest = (description, config, expected) => { + it(description, () => { + // Arrange: + const params = { + requestStatus: config.requestStatus, + payoutStatus: config.payoutStatus, + requestTimestamp: config.requestTimestamp, + payoutTimestamp: config.payoutTimestamp, + errorMessage: config.errorMessage + }; + + // Act: + const result = buildActivityLog(params); + + // Assert: + expect(result[0].status).toBe(expected.requestSendStatus); + expect(result[1].status).toBe(expected.awaitingBridgeStatus); + expect(result[2].status).toBe(expected.payoutSendStatus); + expect(result[3].status).toBe(expected.payoutConfirmationStatus); + }); + }; + + const statusTests = [ + { + description: 'all steps complete when payout is completed', + config: { + requestStatus: BridgeRequestStatus.CONFIRMED, + payoutStatus: BridgePayoutStatus.COMPLETED + }, + expected: { + requestSendStatus: ActivityStatus.COMPLETE, + awaitingBridgeStatus: ActivityStatus.COMPLETE, + payoutSendStatus: ActivityStatus.COMPLETE, + payoutConfirmationStatus: ActivityStatus.COMPLETE + } + }, + { + description: 'payout confirmation loading when payout is sent', + config: { + requestStatus: BridgeRequestStatus.CONFIRMED, + payoutStatus: BridgePayoutStatus.SENT + }, + expected: { + requestSendStatus: ActivityStatus.COMPLETE, + awaitingBridgeStatus: ActivityStatus.COMPLETE, + payoutSendStatus: ActivityStatus.COMPLETE, + payoutConfirmationStatus: ActivityStatus.LOADING + } + }, + { + description: 'payout send loading when payout is unprocessed', + config: { + requestStatus: BridgeRequestStatus.CONFIRMED, + payoutStatus: BridgePayoutStatus.UNPROCESSED + }, + expected: { + requestSendStatus: ActivityStatus.COMPLETE, + awaitingBridgeStatus: ActivityStatus.COMPLETE, + payoutSendStatus: ActivityStatus.LOADING, + payoutConfirmationStatus: ActivityStatus.PENDING + } + }, + { + description: 'awaiting bridge loading when request is confirmed', + config: { + requestStatus: BridgeRequestStatus.CONFIRMED, + payoutStatus: undefined + }, + expected: { + requestSendStatus: ActivityStatus.COMPLETE, + awaitingBridgeStatus: ActivityStatus.LOADING, + payoutSendStatus: ActivityStatus.PENDING, + payoutConfirmationStatus: ActivityStatus.PENDING + } + }, + { + description: 'awaiting bridge error when request fails', + config: { + requestStatus: BridgeRequestStatus.ERROR, + payoutStatus: undefined, + errorMessage: ERROR_MESSAGE + }, + expected: { + requestSendStatus: ActivityStatus.COMPLETE, + awaitingBridgeStatus: ActivityStatus.ERROR, + payoutSendStatus: ActivityStatus.PENDING, + payoutConfirmationStatus: ActivityStatus.PENDING + } + }, + { + description: 'payout send error when payout fails', + config: { + requestStatus: BridgeRequestStatus.CONFIRMED, + payoutStatus: BridgePayoutStatus.FAILED, + errorMessage: ERROR_MESSAGE + }, + expected: { + requestSendStatus: ActivityStatus.COMPLETE, + awaitingBridgeStatus: ActivityStatus.COMPLETE, + payoutSendStatus: ActivityStatus.ERROR, + payoutConfirmationStatus: ActivityStatus.PENDING + } + } + ]; + + statusTests.forEach(test => { + runStatusTest(test.description, test.config, test.expected); + }); + }); + + describe('timestamps', () => { + it('formats request timestamp when provided', () => { + // Arrange: + const params = { + requestStatus: BridgeRequestStatus.CONFIRMED, + payoutStatus: BridgePayoutStatus.COMPLETED, + requestTimestamp: REQUEST_TIMESTAMP + }; + + // Act: + const result = buildActivityLog(params); + + // Assert: + expect(result[0].caption).toBe(SCREEN_TEXT.textRequestDateValue); + }); + + it('formats payout timestamp when provided', () => { + // Arrange: + const params = { + requestStatus: BridgeRequestStatus.CONFIRMED, + payoutStatus: BridgePayoutStatus.COMPLETED, + payoutTimestamp: PAYOUT_TIMESTAMP + }; + + // Act: + const result = buildActivityLog(params); + + // Assert: + expect(result[3].caption).toBe(SCREEN_TEXT.textPayoutDateValue); + }); + + it('returns empty caption when request timestamp not provided', () => { + // Arrange: + const params = { + requestStatus: BridgeRequestStatus.CONFIRMED, + payoutStatus: BridgePayoutStatus.COMPLETED + }; + + // Act: + const result = buildActivityLog(params); + + // Assert: + expect(result[0].caption).toBe(''); + }); + + it('returns empty caption when payout timestamp not provided', () => { + // Arrange: + const params = { + requestStatus: BridgeRequestStatus.CONFIRMED, + payoutStatus: BridgePayoutStatus.COMPLETED + }; + + // Act: + const result = buildActivityLog(params); + + // Assert: + expect(result[3].caption).toBe(''); + }); + }); + }); +}); diff --git a/wallet/symbol/mobile/__tests__/screens/bridge/utils/bridge-account-management.test.js b/wallet/symbol/mobile/__tests__/screens/bridge/utils/bridge-account-management.test.js new file mode 100644 index 000000000..b4202d70b --- /dev/null +++ b/wallet/symbol/mobile/__tests__/screens/bridge/utils/bridge-account-management.test.js @@ -0,0 +1,206 @@ +import { + generateFromMnemonic, + importFromPrivateKey, + removeAccount +} from '@/app/screens/bridge/utils/bridge-account-management'; +import { mnemonic } from '__fixtures__/local/wallet'; +import { createWalletControllerMock } from '__tests__/mock-helpers'; + +// Constants + +const CHAIN_NAME_ETHEREUM = 'ethereum'; +const CHAIN_NAME_SYMBOL = 'symbol'; +const NETWORK_IDENTIFIER = 'testnet'; +const TEST_MNEMONIC = mnemonic; +const TEST_PRIVATE_KEY = '0x17bf8d4fadf6e9f0bffefaa94f13068bae2f9521176008baa4892ac8345a453b'; + +// Mock Configurations + +const createMainWalletControllerMock = () => { + return createWalletControllerMock({ + chainName: CHAIN_NAME_SYMBOL, + networkIdentifier: NETWORK_IDENTIFIER, + getMnemonic: jest.fn().mockResolvedValue(TEST_MNEMONIC) + }); +}; + +const createAdditionalWalletControllerMock = (chainName = CHAIN_NAME_ETHEREUM) => { + return createWalletControllerMock({ + chainName, + networkIdentifier: NETWORK_IDENTIFIER, + saveMnemonicAndGenerateAccounts: jest.fn().mockResolvedValue(), + addExternalAccount: jest.fn().mockResolvedValue(), + clear: jest.fn().mockResolvedValue(), + loadCache: jest.fn().mockResolvedValue(), + selectNetwork: jest.fn().mockResolvedValue(), + connectToNetwork: jest.fn().mockResolvedValue() + }); +}; + +// Mocks + +const mockLoadWalletController = jest.fn().mockResolvedValue(); + +jest.mock('@/app/screens/bridge/utils/wallet-controller', () => ({ + loadWalletController: (...args) => mockLoadWalletController(...args) +})); + +jest.mock('@/app/lib/controller', () => ({ + walletControllers: { + main: null, + additional: [] + } +})); + +describe('screens/bridge/utils/bridge-account-management', () => { + let walletControllersModule; + let mainWalletController; + let additionalWalletController; + + beforeEach(() => { + jest.clearAllMocks(); + walletControllersModule = require('@/app/lib/controller'); + mainWalletController = createMainWalletControllerMock(); + additionalWalletController = createAdditionalWalletControllerMock(); + walletControllersModule.walletControllers.main = mainWalletController; + walletControllersModule.walletControllers.additional = [additionalWalletController]; + }); + + describe('generateFromMnemonic', () => { + it('retrieves mnemonic from main wallet controller', async () => { + // Act: + await generateFromMnemonic(CHAIN_NAME_ETHEREUM); + + // Assert: + expect(mainWalletController.getMnemonic).toHaveBeenCalledTimes(1); + }); + + it('saves mnemonic and generates accounts with correct parameters', async () => { + // Act: + await generateFromMnemonic(CHAIN_NAME_ETHEREUM); + + // Assert: + expect(additionalWalletController.saveMnemonicAndGenerateAccounts).toHaveBeenCalledWith({ + mnemonic: TEST_MNEMONIC, + name: CHAIN_NAME_ETHEREUM, + accountPerNetworkCount: 1 + }); + }); + + it('loads wallet controller after generating accounts', async () => { + // Act: + await generateFromMnemonic(CHAIN_NAME_ETHEREUM); + + // Assert: + expect(mockLoadWalletController).toHaveBeenCalledWith(additionalWalletController); + }); + + it('executes operations in correct order', async () => { + // Arrange: + const callOrder = []; + mainWalletController.getMnemonic.mockImplementation(() => { + callOrder.push('getMnemonic'); + return Promise.resolve(TEST_MNEMONIC); + }); + additionalWalletController.saveMnemonicAndGenerateAccounts.mockImplementation(() => { + callOrder.push('saveMnemonicAndGenerateAccounts'); + return Promise.resolve(); + }); + mockLoadWalletController.mockImplementation(() => { + callOrder.push('loadWalletController'); + return Promise.resolve(); + }); + + // Act: + await generateFromMnemonic(CHAIN_NAME_ETHEREUM); + + // Assert: + expect(callOrder).toEqual([ + 'getMnemonic', + 'saveMnemonicAndGenerateAccounts', + 'loadWalletController' + ]); + }); + }); + + describe('importFromPrivateKey', () => { + it('adds external account with correct parameters', async () => { + // Act: + await importFromPrivateKey(CHAIN_NAME_ETHEREUM, TEST_PRIVATE_KEY); + + // Assert: + expect(additionalWalletController.addExternalAccount).toHaveBeenCalledWith({ + privateKey: TEST_PRIVATE_KEY, + name: CHAIN_NAME_ETHEREUM, + networkIdentifier: NETWORK_IDENTIFIER + }); + }); + + it('loads wallet controller after adding external account', async () => { + // Act: + await importFromPrivateKey(CHAIN_NAME_ETHEREUM, TEST_PRIVATE_KEY); + + // Assert: + expect(mockLoadWalletController).toHaveBeenCalledWith(additionalWalletController); + }); + + it('executes operations in correct order', async () => { + // Arrange: + const callOrder = []; + additionalWalletController.addExternalAccount.mockImplementation(() => { + callOrder.push('addExternalAccount'); + return Promise.resolve(); + }); + mockLoadWalletController.mockImplementation(() => { + callOrder.push('loadWalletController'); + return Promise.resolve(); + }); + + // Act: + await importFromPrivateKey(CHAIN_NAME_ETHEREUM, TEST_PRIVATE_KEY); + + // Assert: + expect(callOrder).toEqual([ + 'addExternalAccount', + 'loadWalletController' + ]); + }); + }); + + describe('removeAccount', () => { + it('clears wallet controller for specified chain', async () => { + // Act: + await removeAccount(CHAIN_NAME_ETHEREUM); + + // Assert: + expect(additionalWalletController.clear).toHaveBeenCalledTimes(1); + }); + + it('does not load wallet controller after clearing', async () => { + // Act: + await removeAccount(CHAIN_NAME_ETHEREUM); + + // Assert: + expect(mockLoadWalletController).not.toHaveBeenCalled(); + }); + }); + + describe('wallet controller lookup', () => { + it('finds correct wallet controller by chain name', async () => { + // Arrange: + const ethereumController = createAdditionalWalletControllerMock(CHAIN_NAME_ETHEREUM); + const symbolController = createAdditionalWalletControllerMock(CHAIN_NAME_SYMBOL); + walletControllersModule.walletControllers.additional = [ + ethereumController, + symbolController + ]; + + // Act: + await generateFromMnemonic(CHAIN_NAME_ETHEREUM); + + // Assert: + expect(ethereumController.saveMnemonicAndGenerateAccounts).toHaveBeenCalled(); + expect(symbolController.saveMnemonicAndGenerateAccounts).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/wallet/symbol/mobile/__tests__/screens/bridge/utils/swap-status.test.js b/wallet/symbol/mobile/__tests__/screens/bridge/utils/swap-status.test.js new file mode 100644 index 000000000..a9039b827 --- /dev/null +++ b/wallet/symbol/mobile/__tests__/screens/bridge/utils/swap-status.test.js @@ -0,0 +1,222 @@ +import { BridgePayoutStatus, BridgeRequestStatus } from '@/app/screens/bridge/types/Bridge'; +import { getSwapStatus, getSwapStatusCaption } from '@/app/screens/bridge/utils/swap-status'; +import { mockLocalization } from '__tests__/mock-helpers'; + +// Screen Text + +const SCREEN_TEXT = { + textStatusUnprocessed: 's_bridge_history_status_unprocessed', + textStatusProcessing: 's_bridge_history_status_processing', + textStatusSent: 's_bridge_history_status_sent', + textStatusCompleted: 's_bridge_history_status_completed', + textStatusFailed: 's_bridge_history_status_failed', + textRequestTransactionConfirmed: 's_bridge_history_requestTransactionConfirmed' +}; + +// Icon Names + +const IconName = { + PENDING: 'pending', + SEND_PLANE: 'send-plane', + CHECK_CIRCLE: 'check-circle', + ALERT_DANGER: 'alert-danger' +}; + +// Variants + +const Variant = { + WARNING: 'warning', + SUCCESS: 'success', + DANGER: 'danger' +}; + +// Constants + +const ERROR_MESSAGE = 'Bridge processing error'; + +describe('screens/bridge/utils/swap-status', () => { + beforeEach(() => { + mockLocalization(); + }); + + describe('getSwapStatus', () => { + describe('request status only', () => { + const runRequestStatusTest = (description, config, expected) => { + it(description, () => { + // Act: + const result = getSwapStatus(config.requestStatus, config.payoutStatus); + + // Assert: + expect(result.variant).toBe(expected.variant); + expect(result.iconName).toBe(expected.iconName); + expect(result.text).toBe(expected.text); + }); + }; + + const requestStatusTests = [ + { + description: 'returns unprocessed status when request is confirmed without payout', + config: { + requestStatus: BridgeRequestStatus.CONFIRMED + }, + expected: { + variant: Variant.WARNING, + iconName: IconName.PENDING, + text: SCREEN_TEXT.textStatusUnprocessed + } + }, + { + description: 'returns failed status when request has error', + config: { + requestStatus: BridgeRequestStatus.ERROR + }, + expected: { + variant: Variant.DANGER, + iconName: IconName.ALERT_DANGER, + text: SCREEN_TEXT.textStatusFailed + } + } + ]; + + requestStatusTests.forEach(test => { + runRequestStatusTest(test.description, test.config, test.expected); + }); + }); + + describe('payout status', () => { + const runPayoutStatusTest = (description, config, expected) => { + it(description, () => { + // Act: + const result = getSwapStatus(config.requestStatus, config.payoutStatus); + + // Assert: + expect(result.variant).toBe(expected.variant); + expect(result.iconName).toBe(expected.iconName); + expect(result.text).toBe(expected.text); + }); + }; + + const payoutStatusTests = [ + { + description: 'returns processing status when payout is unprocessed', + config: { + requestStatus: BridgeRequestStatus.CONFIRMED, + payoutStatus: BridgePayoutStatus.UNPROCESSED + }, + expected: { + variant: Variant.WARNING, + iconName: IconName.PENDING, + text: SCREEN_TEXT.textStatusProcessing + } + }, + { + description: 'returns sent status when payout is sent', + config: { + requestStatus: BridgeRequestStatus.CONFIRMED, + payoutStatus: BridgePayoutStatus.SENT + }, + expected: { + variant: Variant.WARNING, + iconName: IconName.SEND_PLANE, + text: SCREEN_TEXT.textStatusSent + } + }, + { + description: 'returns completed status when payout is completed', + config: { + requestStatus: BridgeRequestStatus.CONFIRMED, + payoutStatus: BridgePayoutStatus.COMPLETED + }, + expected: { + variant: Variant.SUCCESS, + iconName: IconName.CHECK_CIRCLE, + text: SCREEN_TEXT.textStatusCompleted + } + }, + { + description: 'returns failed status when payout has failed', + config: { + requestStatus: BridgeRequestStatus.CONFIRMED, + payoutStatus: BridgePayoutStatus.FAILED + }, + expected: { + variant: Variant.DANGER, + iconName: IconName.ALERT_DANGER, + text: SCREEN_TEXT.textStatusFailed + } + } + ]; + + payoutStatusTests.forEach(test => { + runPayoutStatusTest(test.description, test.config, test.expected); + }); + }); + }); + + describe('getSwapStatusCaption', () => { + const runCaptionTest = (description, config, expected) => { + it(description, () => { + // Arrange: + const data = { + requestStatus: config.requestStatus, + errorMessage: config.errorMessage + }; + + // Act: + const result = getSwapStatusCaption(data); + + // Assert: + expect(result.isVisible).toBe(expected.isVisible); + expect(result.text).toBe(expected.text); + expect(result.textStyle).toBe(expected.textStyle); + expect(result.textType).toBe(expected.textType); + }); + }; + + const captionTests = [ + { + description: 'returns confirmed caption when request is confirmed', + config: { + requestStatus: BridgeRequestStatus.CONFIRMED, + errorMessage: null + }, + expected: { + isVisible: true, + text: SCREEN_TEXT.textRequestTransactionConfirmed, + textStyle: 'regular', + textType: 'body' + } + }, + { + description: 'returns error caption with message when request has error', + config: { + requestStatus: BridgeRequestStatus.ERROR, + errorMessage: ERROR_MESSAGE + }, + expected: { + isVisible: true, + text: ERROR_MESSAGE, + textStyle: 'error', + textType: 'label' + } + }, + { + description: 'returns hidden caption when request is unconfirmed', + config: { + requestStatus: BridgeRequestStatus.UNCONFIRMED, + errorMessage: null + }, + expected: { + isVisible: false, + text: null, + textStyle: null, + textType: null + } + } + ]; + + captionTests.forEach(test => { + runCaptionTest(test.description, test.config, test.expected); + }); + }); +}); diff --git a/wallet/symbol/mobile/__tests__/screens/bridge/utils/wallet-controller.test.js b/wallet/symbol/mobile/__tests__/screens/bridge/utils/wallet-controller.test.js new file mode 100644 index 000000000..22e7ea03c --- /dev/null +++ b/wallet/symbol/mobile/__tests__/screens/bridge/utils/wallet-controller.test.js @@ -0,0 +1,121 @@ +import { loadWalletController } from '@/app/screens/bridge/utils/wallet-controller'; +import { createWalletControllerMock } from '__tests__/mock-helpers'; + +// Constants + +const NETWORK_IDENTIFIER_MAINNET = 'mainnet'; +const NETWORK_IDENTIFIER_TESTNET = 'testnet'; + +// Mock Configurations + +const createMainWalletControllerMock = (networkIdentifier = NETWORK_IDENTIFIER_TESTNET) => { + return createWalletControllerMock({ + networkIdentifier, + getMnemonic: jest.fn().mockResolvedValue('mock mnemonic') + }); +}; + +const createAdditionalWalletControllerMock = (networkIdentifier = NETWORK_IDENTIFIER_TESTNET) => { + return createWalletControllerMock({ + chainName: 'ethereum', + networkIdentifier, + loadCache: jest.fn().mockResolvedValue(), + selectNetwork: jest.fn().mockResolvedValue(), + connectToNetwork: jest.fn().mockResolvedValue() + }); +}; + +// Mocks + +jest.mock('@/app/lib/controller', () => ({ + walletControllers: { + main: null, + additional: [] + } +})); + +describe('screens/bridge/utils/wallet-controller', () => { + let walletControllersModule; + + beforeEach(() => { + jest.clearAllMocks(); + walletControllersModule = require('@/app/lib/controller'); + }); + + describe('loadWalletController', () => { + describe('network selection', () => { + const runNetworkSelectionTest = (description, config, expected) => { + it(description, async () => { + // Arrange: + const mainWalletController = createMainWalletControllerMock(config.mainNetworkIdentifier); + const additionalWalletController = createAdditionalWalletControllerMock(config.additionalNetworkIdentifier); + walletControllersModule.walletControllers.main = mainWalletController; + + // Act: + await loadWalletController(additionalWalletController); + + // Assert: + if (expected.shouldSelectNetwork) { + expect(additionalWalletController.selectNetwork).toHaveBeenCalledTimes(1); + expect(additionalWalletController.selectNetwork).toHaveBeenCalledWith(config.mainNetworkIdentifier); + } else { + expect(additionalWalletController.selectNetwork).not.toHaveBeenCalled(); + } + }); + }; + + const networkSelectionTests = [ + { + description: 'selects network when network identifiers differ', + config: { + mainNetworkIdentifier: NETWORK_IDENTIFIER_TESTNET, + additionalNetworkIdentifier: NETWORK_IDENTIFIER_MAINNET + }, + expected: { + shouldSelectNetwork: true + } + }, + { + description: 'does not select network when network identifiers match', + config: { + mainNetworkIdentifier: NETWORK_IDENTIFIER_TESTNET, + additionalNetworkIdentifier: NETWORK_IDENTIFIER_TESTNET + }, + expected: { + shouldSelectNetwork: false + } + } + ]; + + networkSelectionTests.forEach(test => { + runNetworkSelectionTest(test.description, test.config, test.expected); + }); + }); + + it('executes operations in correct order', async () => { + // Arrange: + const mainWalletController = createMainWalletControllerMock(NETWORK_IDENTIFIER_TESTNET); + const additionalWalletController = createAdditionalWalletControllerMock(NETWORK_IDENTIFIER_MAINNET); + walletControllersModule.walletControllers.main = mainWalletController; + const callOrder = []; + additionalWalletController.loadCache.mockImplementation(() => { + callOrder.push('loadCache'); + return Promise.resolve(); + }); + additionalWalletController.selectNetwork.mockImplementation(() => { + callOrder.push('selectNetwork'); + return Promise.resolve(); + }); + additionalWalletController.connectToNetwork.mockImplementation(() => { + callOrder.push('connectToNetwork'); + return Promise.resolve(); + }); + + // Act: + await loadWalletController(additionalWalletController); + + // Assert: + expect(callOrder).toEqual(['loadCache', 'selectNetwork', 'connectToNetwork']); + }); + }); +}); diff --git a/wallet/symbol/mobile/__tests__/utils/network.test.js b/wallet/symbol/mobile/__tests__/utils/network.test.js index 1c6531ef8..3649fb887 100644 --- a/wallet/symbol/mobile/__tests__/utils/network.test.js +++ b/wallet/symbol/mobile/__tests__/utils/network.test.js @@ -6,7 +6,8 @@ describe('utils/network', () => { ok, status, statusText, - json: jest.fn().mockResolvedValue(body) + json: jest.fn().mockResolvedValue(body), + text: jest.fn().mockResolvedValue(JSON.stringify(body)) }); const createMockErrorResponse = (status, errorBody, statusText = 'Error') => @@ -125,12 +126,12 @@ describe('utils/network', () => { it('falls back to statusText when JSON parsing fails', async () => { // Arrange: - const mockResponse = { - ok: false, - status: 400, - statusText: 'Bad Request', - json: jest.fn().mockRejectedValue(new Error('Invalid JSON')) - }; + const mockResponse = createMockResponse( + false, + 400, + 'Invalid JSON Body', + 'Bad Request' + ); global.fetch.mockResolvedValue(mockResponse); // Act & Assert: diff --git a/wallet/symbol/mobile/ios/Podfile.lock b/wallet/symbol/mobile/ios/Podfile.lock index baef762d9..c64de427a 100644 --- a/wallet/symbol/mobile/ios/Podfile.lock +++ b/wallet/symbol/mobile/ios/Podfile.lock @@ -1786,7 +1786,7 @@ PODS: - SocketRocket - react-native-encrypted-storage (4.0.3): - React-Core - - react-native-randombytes (3.6.1): + - react-native-randombytes (3.6.2): - React-Core - react-native-safe-area-context (5.6.2): - boost @@ -2416,7 +2416,7 @@ PODS: - React-perflogger (= 0.82.1) - React-utils (= 0.82.1) - SocketRocket - - RNCAsyncStorage (2.1.2): + - RNCAsyncStorage (2.2.0): - boost - DoubleConversion - fast_float @@ -2444,7 +2444,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - RNGestureHandler (2.29.1): + - RNGestureHandler (2.30.0): - boost - DoubleConversion - fast_float @@ -2502,7 +2502,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - RNReanimated (4.2.0): + - RNReanimated (4.2.1): - boost - DoubleConversion - fast_float @@ -2529,11 +2529,11 @@ PODS: - ReactCodegen - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - - RNReanimated/reanimated (= 4.2.0) + - RNReanimated/reanimated (= 4.2.1) - RNWorklets - SocketRocket - Yoga - - RNReanimated/reanimated (4.2.0): + - RNReanimated/reanimated (4.2.1): - boost - DoubleConversion - fast_float @@ -2560,11 +2560,11 @@ PODS: - ReactCodegen - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - - RNReanimated/reanimated/apple (= 4.2.0) + - RNReanimated/reanimated/apple (= 4.2.1) - RNWorklets - SocketRocket - Yoga - - RNReanimated/reanimated/apple (4.2.0): + - RNReanimated/reanimated/apple (4.2.1): - boost - DoubleConversion - fast_float @@ -2594,7 +2594,7 @@ PODS: - RNWorklets - SocketRocket - Yoga - - RNScreens (4.18.0): + - RNScreens (4.23.0): - boost - DoubleConversion - fast_float @@ -2621,10 +2621,10 @@ PODS: - ReactCodegen - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - - RNScreens/common (= 4.18.0) + - RNScreens/common (= 4.23.0) - SocketRocket - Yoga - - RNScreens/common (4.18.0): + - RNScreens/common (4.23.0): - boost - DoubleConversion - fast_float @@ -2653,7 +2653,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - RNWorklets (0.7.1): + - RNWorklets (0.7.3): - boost - DoubleConversion - fast_float @@ -2680,10 +2680,10 @@ PODS: - ReactCodegen - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - - RNWorklets/worklets (= 0.7.1) + - RNWorklets/worklets (= 0.7.3) - SocketRocket - Yoga - - RNWorklets/worklets (0.7.1): + - RNWorklets/worklets (0.7.3): - boost - DoubleConversion - fast_float @@ -2710,10 +2710,10 @@ PODS: - ReactCodegen - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - - RNWorklets/worklets/apple (= 0.7.1) + - RNWorklets/worklets/apple (= 0.7.3) - SocketRocket - Yoga - - RNWorklets/worklets/apple (0.7.1): + - RNWorklets/worklets/apple (0.7.3): - boost - DoubleConversion - fast_float @@ -3063,7 +3063,7 @@ SPEC CHECKSUMS: React-Mapbuffer: 06d59c448da7e34eb05b3fb2189e12f6a30fec57 React-microtasksnativemodule: d1ee999dc9052e23f6488b730fa2d383a4ea40e5 react-native-encrypted-storage: 569d114e329b1c2c2d9f8c84bcdbe4478dda2258 - react-native-randombytes: 3c8f3e89d12487fd03a2f966c288d495415fc116 + react-native-randombytes: 4eeddad82d1fd57adda1cd5beffe67d041c9d6de react-native-safe-area-context: c00143b4823773bba23f2f19f85663ae89ceb460 react-native-splash-screen: 95994222cc95c236bd3cdc59fe45ed5f27969594 React-NativeModulesApple: 46690a0fe94ec28fc6fc686ec797b911d251ded0 @@ -3098,13 +3098,13 @@ SPEC CHECKSUMS: ReactAppDependencyProvider: a45ef34bb22dc1c9b2ac1f74167d9a28af961176 ReactCodegen: 878add6c7d8ff8cea87697c44d29c03b79b6f2d9 ReactCommon: 804dc80944fa90b86800b43c871742ec005ca424 - RNCAsyncStorage: 7e57f4fe4332cbf21bf6bdbffaf9fd34c9268abd - RNGestureHandler: e1cf8ef3f11045536eed6bd4f132b003ef5f9a5f + RNCAsyncStorage: 29f0230e1a25f36c20b05f65e2eb8958d6526e82 + RNGestureHandler: f97d19585553412302394c42495cdaf499589d34 RNKeychain: bbe2f6d5cc008920324acb49ef86ccc03d3b38e4 RNPermissions: b65e9cc101a4e6b58ec1f8d018b0b167f6eb63d5 - RNReanimated: f1868b36f4b2b52a0ed00062cfda69506f75eaee - RNScreens: d821082c6dd1cb397cc0c98b026eeafaa68be479 - RNWorklets: d9c050940f140af5d8b611d937eab1cbfce5e9a5 + RNReanimated: 7d59df16c018cf3ed5d5b45964cedea4fc5c26ef + RNScreens: afaf526a9c804c3b4503f950cf3e67ed81e29ada + RNWorklets: edd6646f96b38e2ae089396088bc7fcadfd2612f SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 VisionCamera: 7187b3dac1ff3071234ead959ce311875748e14f Yoga: 8e01cef9947ca77f0477a098f0b32848a8e448c6 diff --git a/wallet/symbol/mobile/src/app/components/NavigationMenu.jsx b/wallet/symbol/mobile/src/app/components/NavigationMenu.jsx index ef4131a56..5cd406e7e 100644 --- a/wallet/symbol/mobile/src/app/components/NavigationMenu.jsx +++ b/wallet/symbol/mobile/src/app/components/NavigationMenu.jsx @@ -18,6 +18,10 @@ const ICON_SOURCE_MAP = { [RouteName.History]: { default: require('@/app/assets/images/navigation/history_d.png'), active: require('@/app/assets/images/navigation/history_a.png') + }, + [RouteName.Assets]: { + default: require('@/app/assets/images/navigation/assets_d.png'), + active: require('@/app/assets/images/navigation/assets_a.png') } }; @@ -31,6 +35,11 @@ const TAB_CONFIG = [ titleKey: 'navigation_history', name: RouteName.History, navigate: () => Router.goToHistory() + }, + { + titleKey: 'navigation_assets', + name: RouteName.Assets, + navigate: () => Router.goToAssets() } ]; diff --git a/wallet/symbol/mobile/src/app/hooks/useSyncNetworkType.js b/wallet/symbol/mobile/src/app/hooks/useSyncNetworkType.js index 04f8946b6..b0cbfba4a 100644 --- a/wallet/symbol/mobile/src/app/hooks/useSyncNetworkType.js +++ b/wallet/symbol/mobile/src/app/hooks/useSyncNetworkType.js @@ -18,15 +18,18 @@ const { ControllerEventName } = constants; */ export const useSyncNetworkType = ({ mainWalletController, additionalWalletControllers }) => { useEffect(() => { - const handleNetworkChange = () => { - additionalWalletControllers.forEach(controller => - controller.selectNetwork(mainWalletController.networkIdentifier)); + const syncNetworkType = () => { + additionalWalletControllers.forEach(controller => { + if (controller.isStateReady) + controller.selectNetwork(mainWalletController.networkIdentifier); + }); }; - - mainWalletController.on(ControllerEventName.NETWORK_CHANGE, handleNetworkChange); + + syncNetworkType(); + mainWalletController.on(ControllerEventName.NETWORK_CHANGE, syncNetworkType); return () => { - mainWalletController.removeListener(ControllerEventName.NETWORK_CHANGE, handleNetworkChange); + mainWalletController.removeListener(ControllerEventName.NETWORK_CHANGE, syncNetworkType); }; }, []); }; diff --git a/wallet/symbol/mobile/src/app/layout/RootLayout.jsx b/wallet/symbol/mobile/src/app/layout/RootLayout.jsx index 0a92fbc1c..25a015126 100644 --- a/wallet/symbol/mobile/src/app/layout/RootLayout.jsx +++ b/wallet/symbol/mobile/src/app/layout/RootLayout.jsx @@ -8,7 +8,8 @@ import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context'; const SCREENS_THAT_SHOW_NAVIGATION_MENU = [ RouteName.Home, - RouteName.History + RouteName.History, + RouteName.Assets ]; /** diff --git a/wallet/symbol/mobile/src/assets/images/components/swap-reverse.png b/wallet/symbol/mobile/src/assets/images/components/swap-reverse.png new file mode 100644 index 000000000..1f7355683 Binary files /dev/null and b/wallet/symbol/mobile/src/assets/images/components/swap-reverse.png differ diff --git a/wallet/symbol/mobile/src/assets/images/icons/black/swap.png b/wallet/symbol/mobile/src/assets/images/icons/black/swap.png new file mode 100644 index 000000000..bb454c4c0 Binary files /dev/null and b/wallet/symbol/mobile/src/assets/images/icons/black/swap.png differ diff --git a/wallet/symbol/mobile/src/assets/images/icons/blue/sign.png b/wallet/symbol/mobile/src/assets/images/icons/blue/sign.png new file mode 100644 index 000000000..334214438 Binary files /dev/null and b/wallet/symbol/mobile/src/assets/images/icons/blue/sign.png differ diff --git a/wallet/symbol/mobile/src/assets/images/icons/green/check-circle-big.png b/wallet/symbol/mobile/src/assets/images/icons/green/check-circle-big.png new file mode 100644 index 000000000..500318c66 Binary files /dev/null and b/wallet/symbol/mobile/src/assets/images/icons/green/check-circle-big.png differ diff --git a/wallet/symbol/mobile/src/assets/images/icons/green/check-circle.png b/wallet/symbol/mobile/src/assets/images/icons/green/check-circle.png new file mode 100644 index 000000000..3de6e07cc Binary files /dev/null and b/wallet/symbol/mobile/src/assets/images/icons/green/check-circle.png differ diff --git a/wallet/symbol/mobile/src/assets/images/icons/grey/info-circle.png b/wallet/symbol/mobile/src/assets/images/icons/grey/info-circle.png new file mode 100644 index 000000000..848b58354 Binary files /dev/null and b/wallet/symbol/mobile/src/assets/images/icons/grey/info-circle.png differ diff --git a/wallet/symbol/mobile/src/assets/images/icons/red/alert-danger.png b/wallet/symbol/mobile/src/assets/images/icons/red/alert-danger.png new file mode 100644 index 000000000..752c42cf6 Binary files /dev/null and b/wallet/symbol/mobile/src/assets/images/icons/red/alert-danger.png differ diff --git a/wallet/symbol/mobile/src/assets/images/icons/white/chevron-left.png b/wallet/symbol/mobile/src/assets/images/icons/white/chevron-left.png new file mode 100644 index 000000000..fe63f17b6 Binary files /dev/null and b/wallet/symbol/mobile/src/assets/images/icons/white/chevron-left.png differ diff --git a/wallet/symbol/mobile/src/assets/images/icons/white/chevron-right.png b/wallet/symbol/mobile/src/assets/images/icons/white/chevron-right.png new file mode 100644 index 000000000..ba54581c5 Binary files /dev/null and b/wallet/symbol/mobile/src/assets/images/icons/white/chevron-right.png differ diff --git a/wallet/symbol/mobile/src/assets/images/icons/white/swap.png b/wallet/symbol/mobile/src/assets/images/icons/white/swap.png new file mode 100644 index 000000000..d4c10d16a Binary files /dev/null and b/wallet/symbol/mobile/src/assets/images/icons/white/swap.png differ diff --git a/wallet/symbol/mobile/src/assets/images/icons/yellow/alert-warning.png b/wallet/symbol/mobile/src/assets/images/icons/yellow/alert-warning.png new file mode 100644 index 000000000..70dcd37f0 Binary files /dev/null and b/wallet/symbol/mobile/src/assets/images/icons/yellow/alert-warning.png differ diff --git a/wallet/symbol/mobile/src/assets/images/icons/yellow/pending.png b/wallet/symbol/mobile/src/assets/images/icons/yellow/pending.png new file mode 100644 index 000000000..25aef3b5e Binary files /dev/null and b/wallet/symbol/mobile/src/assets/images/icons/yellow/pending.png differ diff --git a/wallet/symbol/mobile/src/assets/images/icons/yellow/send-plane.png b/wallet/symbol/mobile/src/assets/images/icons/yellow/send-plane.png new file mode 100644 index 000000000..25a4ae9d2 Binary files /dev/null and b/wallet/symbol/mobile/src/assets/images/icons/yellow/send-plane.png differ diff --git a/wallet/symbol/mobile/src/components/controls/ButtonPlain.jsx b/wallet/symbol/mobile/src/components/controls/ButtonPlain.jsx index a0750dd70..c374b93f3 100644 --- a/wallet/symbol/mobile/src/components/controls/ButtonPlain.jsx +++ b/wallet/symbol/mobile/src/components/controls/ButtonPlain.jsx @@ -67,8 +67,7 @@ export const ButtonPlain = ({ text, icon, isDisabled = false, isCentered = false const styles = StyleSheet.create({ root: { flexDirection: 'row', - alignItems: 'center', - height: Sizes.Semantic.controlHeight.m + alignItems: 'center' }, icon: { marginRight: Sizes.Semantic.spacing.s diff --git a/wallet/symbol/mobile/src/components/controls/FeeSelector.jsx b/wallet/symbol/mobile/src/components/controls/FeeSelector.jsx index 5b0e5631d..9c6cff6a8 100644 --- a/wallet/symbol/mobile/src/components/controls/FeeSelector.jsx +++ b/wallet/symbol/mobile/src/components/controls/FeeSelector.jsx @@ -1,7 +1,6 @@ /** @typedef {import('wallet-common-core/src/types/Transaction').TransactionFeeTierLevel} TransactionFeeTierLevel */ /** @typedef {import('wallet-common-core/src/types/Transaction').TransactionFeeTiers} TransactionFeeTiers */ -import { config } from '@/app/config'; import { useToggle } from '@/app/hooks'; import { $t } from '@/app/localization'; import { Colors, Sizes, Typography } from '@/app/styles'; @@ -56,11 +55,12 @@ const createFeeTierOption = (level, value) => ({ * @param {string} props.title - The title label displayed above the fee selector. * @param {TransactionFeeTiers | TransactionFeeTiers[]} props.feeTiers - The fee tiers configuration. * @param {TransactionFeeTierLevel} props.value - The currently selected fee tier level. + * @param {string} props.ticker - The ticker symbol for the fee currency. * @param {function} props.onChange - Function to call when the selected fee tier changes. * * @returns {React.ReactNode} FeeSelector component */ -export const FeeSelector = ({ style, title, feeTiers, value, onChange }) => { +export const FeeSelector = ({ style, title, feeTiers, value, ticker, onChange }) => { // State const [sliderKey, refreshSlider] = useToggle(true); const imageTranslation = useSharedValue(0); @@ -102,7 +102,7 @@ export const FeeSelector = ({ style, title, feeTiers, value, onChange }) => { const imageSrc = IMAGES[sliderValue]; const selectedFeeValue = options[sliderValue].value; const selectedFeeLabel = options[sliderValue].label; - const valueField = `${selectedFeeLabel} | ${selectedFeeValue} ${config.chains.symbol.ticker}`; + const valueField = `${selectedFeeLabel} | ${selectedFeeValue} ${ticker}`; // Animations const animatedImageStyle = useAnimatedStyle(() => ({ diff --git a/wallet/symbol/mobile/src/components/controls/InputAmount.jsx b/wallet/symbol/mobile/src/components/controls/InputAmount.jsx index f3c5a61ac..706b33f5c 100644 --- a/wallet/symbol/mobile/src/components/controls/InputAmount.jsx +++ b/wallet/symbol/mobile/src/components/controls/InputAmount.jsx @@ -29,7 +29,10 @@ export const InputAmount = props => { const [priceText, setPriceText] = useState(''); // Validation - const errorMessage = useValidation(value, [validateAmount(availableBalance), ...extraValidators], $t); + const amountValidator = availableBalance === undefined + ? () => null + : validateAmount(availableBalance); + const errorMessage = useValidation(value, [amountValidator, ...extraValidators], $t); useEffect(() => { onValidityChange?.(!errorMessage); diff --git a/wallet/symbol/mobile/src/components/display/Account/AccountView.jsx b/wallet/symbol/mobile/src/components/display/Account/AccountView.jsx index 3a709af29..db4147f7c 100644 --- a/wallet/symbol/mobile/src/components/display/Account/AccountView.jsx +++ b/wallet/symbol/mobile/src/components/display/Account/AccountView.jsx @@ -34,7 +34,7 @@ export const AccountView = ({ address, name, imageId, size = DEFAULT_SIZE }) => imageId={imageId} size={size} /> - + {isNameVisible && ( {name} @@ -53,7 +53,8 @@ export const AccountView = ({ address, name, imageId, size = DEFAULT_SIZE }) => const styles = StyleSheet.create({ root: { flexDirection: 'row', - alignItems: 'center' + alignItems: 'center', + flexShrink: 1 }, root_small: { gap: Sizes.Semantic.spacing.s @@ -63,5 +64,8 @@ const styles = StyleSheet.create({ }, root_large: { gap: Sizes.Semantic.spacing.m + }, + textContainer: { + flexShrink: 1 } }); diff --git a/wallet/symbol/mobile/src/components/display/Amount.jsx b/wallet/symbol/mobile/src/components/display/Amount.jsx index 5619e020d..fa45fa0d8 100644 --- a/wallet/symbol/mobile/src/components/display/Amount.jsx +++ b/wallet/symbol/mobile/src/components/display/Amount.jsx @@ -21,12 +21,18 @@ export const Amount = ({ value, ticker, isColored = false, size = 'm' }) => { m: Typography.Semantic.bodyBold.m, s: Typography.Semantic.body.s }; + const decimalTextStyleMap = { + l: Typography.Semantic.body.l, + m: Typography.Semantic.bodyBold.m, + s: Typography.Semantic.body.s + }; const tickerTextStyleMap = { - l: Typography.Semantic.body.m, + l: Typography.Semantic.body.l, m: Typography.Semantic.body.m, s: Typography.Semantic.body.s }; const amountTextStyle = amountTextStyleMap[size]; + const decimalTextStyle = decimalTextStyleMap[size]; const tickerTextStyle = tickerTextStyleMap[size]; // Color @@ -38,11 +44,19 @@ export const Amount = ({ value, ticker, isColored = false, size = 'm' }) => { let textColor; const stringValue = String(value); + const decimalSeparatorIndex = stringValue.indexOf('.'); + const integerPart = decimalSeparatorIndex === -1 + ? stringValue + : stringValue.slice(0, decimalSeparatorIndex); + const decimalPart = decimalSeparatorIndex === -1 + ? '' + : stringValue.slice(decimalSeparatorIndex); + if ( - isColored === false - || !stringValue - || typeof stringValue !== 'string' - || stringValue === '0' + isColored === false + || !stringValue + || typeof stringValue !== 'string' + || stringValue === '0' ) textColor = colorMap.neutral; else if (stringValue.startsWith('-')) @@ -55,6 +69,10 @@ export const Amount = ({ value, ticker, isColored = false, size = 'm' }) => { ...amountTextStyle, color: textColor }; + const decimalStyle = { + ...decimalTextStyle, + color: textColor + }; const tickerStyle = { ...tickerTextStyle, color: textColor @@ -62,8 +80,13 @@ export const Amount = ({ value, ticker, isColored = false, size = 'm' }) => { return ( - {value} - {` ${ticker}`} + {integerPart} + {Boolean(decimalPart) && ( + {decimalPart} + )} + {Boolean(ticker) && ( + {` ${ticker}`} + )} ); }; diff --git a/wallet/symbol/mobile/src/components/display/Token/TokenAvatar.jsx b/wallet/symbol/mobile/src/components/display/Token/TokenAvatar.jsx index 5e63710da..1de8c65cb 100644 --- a/wallet/symbol/mobile/src/components/display/Token/TokenAvatar.jsx +++ b/wallet/symbol/mobile/src/components/display/Token/TokenAvatar.jsx @@ -23,10 +23,11 @@ const sizeMap = { * @param {object} props - Component props * @param {string} props.imageId - Known token image identifier * @param {string} [props.size=DEFAULT_SIZE] - Size of the avatar + * @param {object} [props.style] - Additional styles for the avatar * * @returns {React.ReactNode} Token avatar component */ -export const TokenAvatar = ({ imageId, size = DEFAULT_SIZE }) => { +export const TokenAvatar = ({ imageId, size = DEFAULT_SIZE, style }) => { // Image const tokenImagePack = tokenImages[imageId]; const tokenImageSrcFromPack = tokenImagePack ? tokenImagePack[sizeMap[size]] : null; @@ -41,7 +42,7 @@ export const TokenAvatar = ({ imageId, size = DEFAULT_SIZE }) => { const rootSizeStyle = rootSizeStyleMap[size]; return ( - + ); }; diff --git a/wallet/symbol/mobile/src/components/features/TableView.jsx b/wallet/symbol/mobile/src/components/features/TableView.jsx index 5234334f6..be453ae73 100644 --- a/wallet/symbol/mobile/src/components/features/TableView.jsx +++ b/wallet/symbol/mobile/src/components/features/TableView.jsx @@ -75,13 +75,13 @@ const renderRowValue = (row, resolvedData, translate, key) => { switch (row.type) { case 'account': return ( - + + + ); case 'token': return ( diff --git a/wallet/symbol/mobile/src/components/feedback/Alert.jsx b/wallet/symbol/mobile/src/components/feedback/Alert.jsx index ed06c79fd..cc652fb53 100644 --- a/wallet/symbol/mobile/src/components/feedback/Alert.jsx +++ b/wallet/symbol/mobile/src/components/feedback/Alert.jsx @@ -36,7 +36,7 @@ export const Alert = ({ variant = DEFAULT_VARIANT, title, body, isIconHidden = f return ( {!isIconHidden && iconName && ( - + )} {!!title && ( @@ -55,7 +55,7 @@ const styles = StyleSheet.create({ root: { flexDirection: 'column', padding: Sizes.Semantic.spacing.m, - borderRadius: Sizes.Semantic.borderRadius.s, + borderRadius: Sizes.Semantic.borderRadius.m, alignItems: 'center', gap: Sizes.Semantic.spacing.xs }, diff --git a/wallet/symbol/mobile/src/components/feedback/DialogBox.jsx b/wallet/symbol/mobile/src/components/feedback/DialogBox.jsx index d28f61db7..6e3cd0eac 100644 --- a/wallet/symbol/mobile/src/components/feedback/DialogBox.jsx +++ b/wallet/symbol/mobile/src/components/feedback/DialogBox.jsx @@ -1,4 +1,4 @@ -import { StyledText, TextBox } from '@/app/components'; +import { Stack, StyledText, TextBox } from '@/app/components'; import { useValidation } from '@/app/hooks'; import { PlatformUtils } from '@/app/lib/platform/PlatformUtils'; import { $t } from '@/app/localization'; @@ -135,7 +135,7 @@ export const DialogBox = props => { setPromptValue(''); }, [isVisible]); - if (!isVisible) + if (!isVisible) return null; // Temporary workaround for broken Modal in react-native v82 on iOS @@ -152,17 +152,19 @@ export const DialogBox = props => { - - {title} - - {!!text && !isPrompt && ( - - {text} + + + {title} - )} - - {children} - + {!!text && !isPrompt && ( + + {text} + + )} + + {children} + + {isPrompt && ( diff --git a/wallet/symbol/mobile/src/components/feedback/StatusRow.jsx b/wallet/symbol/mobile/src/components/feedback/StatusRow.jsx new file mode 100644 index 000000000..ccf054500 --- /dev/null +++ b/wallet/symbol/mobile/src/components/feedback/StatusRow.jsx @@ -0,0 +1,39 @@ +import { Icon, StyledText } from '@/app/components'; +import { Colors, Sizes } from '@/app/styles'; +import React from 'react'; +import { StyleSheet, View } from 'react-native'; + +/** + * StatusRow component. + * + * @param {object} props - Component props + * + * @returns {React.ReactNode} StatusRow component + */ +export const StatusRow = ({ variant = 'neutral', statusText, icon }) => { + const pallette = Colors.Components.statusLabel[variant]; + const textStyle = { + color: pallette.text + }; + + return ( + + + + {statusText} + + + ); +}; + +const styles = StyleSheet.create({ + root: { + flexDirection: 'row', + alignItems: 'center', + gap: Sizes.Semantic.spacing.s + } +}); diff --git a/wallet/symbol/mobile/src/components/feedback/index.js b/wallet/symbol/mobile/src/components/feedback/index.js index 2e6f5394a..aaaceaea8 100644 --- a/wallet/symbol/mobile/src/components/feedback/index.js +++ b/wallet/symbol/mobile/src/components/feedback/index.js @@ -3,3 +3,4 @@ export * from './DialogBox'; export * from './LoadingIndicator'; export * from './MultisigAccountWarning'; export * from './StatusCard'; +export * from './StatusRow'; diff --git a/wallet/symbol/mobile/src/components/layout/Divider.jsx b/wallet/symbol/mobile/src/components/layout/Divider.jsx new file mode 100644 index 000000000..5f621afff --- /dev/null +++ b/wallet/symbol/mobile/src/components/layout/Divider.jsx @@ -0,0 +1,31 @@ +import { Colors, Sizes } from '@/app/styles'; +import { View } from 'react-native'; + +/** @typedef {import('react')} React */ + +const DEFAULT_ORIENTATION = 'horizontal'; +const WIDTH = Sizes.Semantic.borderWidth.s; + +/** + * Divider component. A visual separator used to divide content areas, supporting horizontal and vertical orientations. + * + * @param {object} props - Component props + * @param {'horizontal'|'vertical'} [props.orientation='horizontal'] - Orientation of the divider. + * @param {string} [props.color] - Color of the divider. Defaults to the standard card background color. + * + * @returns {React.ReactNode} Divider component + */ +export const Divider = ({ + orientation = DEFAULT_ORIENTATION, + color = Colors.Components.card.background +}) => { + const dynamicStyle = { + backgroundColor: color, + width: orientation === 'horizontal' ? '100%' : WIDTH, + height: orientation === 'horizontal' ? WIDTH : '100%' + }; + + return ( + + ); +}; diff --git a/wallet/symbol/mobile/src/components/layout/ListItemContainer.jsx b/wallet/symbol/mobile/src/components/layout/ListItemContainer.jsx index 092c457e6..da00385be 100644 --- a/wallet/symbol/mobile/src/components/layout/ListItemContainer.jsx +++ b/wallet/symbol/mobile/src/components/layout/ListItemContainer.jsx @@ -23,6 +23,7 @@ const MIN_CARD_HEIGHT = 75; * @param {object} [props.style] - Additional styles for the outer wrapper. * @param {object} [props.cardStyle] - Additional styles for card content container. * @param {object} [props.contentContainerStyle] - Additional styles for the inner card container. + * @param {string} [props.accessibilityLabel] - Accessibility label for the touchable element. * @param {function} [props.onPress] - Function to call when the container is pressed. * * @returns {React.ReactNode} ListItemContainer component @@ -34,6 +35,7 @@ export const ListItemContainer = ({ style, cardStyle, contentContainerStyle, + accessibilityLabel, onPress }) => { // State @@ -55,7 +57,7 @@ export const ListItemContainer = ({ // Handlers const handlePress = useCallback(() => { - if (isDisabled || !onPress) + if (isDisabled || !onPress) return; onPress(); @@ -66,10 +68,10 @@ export const ListItemContainer = ({ useEffect(() => { const shouldCollapse = isFocused && isExpanded; - if (!shouldCollapse) + if (!shouldCollapse) return; - if (PlatformUtils.getOS() === 'android') + if (PlatformUtils.getOS() === 'android') scale.value = SCALE_EXPANDED; scale.value = withTiming(SCALE_DEFAULT, { duration: ANIMATION_DURATION_MS }); @@ -78,7 +80,12 @@ export const ListItemContainer = ({ return ( - + {children} diff --git a/wallet/symbol/mobile/src/components/layout/index.js b/wallet/symbol/mobile/src/components/layout/index.js index 85c9ae74d..a8ea39e7b 100644 --- a/wallet/symbol/mobile/src/components/layout/index.js +++ b/wallet/symbol/mobile/src/components/layout/index.js @@ -1,5 +1,6 @@ export * from './ActionRow'; export * from './Card'; +export * from './Divider'; export * from './Field'; export * from './FlexContainer'; export * from './Grid'; diff --git a/wallet/symbol/mobile/src/components/templates/FilteredListScreenTemplate/FilteredListScreenTemplate.jsx b/wallet/symbol/mobile/src/components/templates/FilteredListScreenTemplate/FilteredListScreenTemplate.jsx index 6512ead17..f74c9d592 100644 --- a/wallet/symbol/mobile/src/components/templates/FilteredListScreenTemplate/FilteredListScreenTemplate.jsx +++ b/wallet/symbol/mobile/src/components/templates/FilteredListScreenTemplate/FilteredListScreenTemplate.jsx @@ -30,11 +30,9 @@ const EmptyListPlaceholder = () => ( * @returns {React.ReactNode} */ const SectionHeader = ({ title, titleStyle }) => ( - - - {title} - - + + {title} + ); /** @@ -88,6 +86,7 @@ const LoadingFooter = ({ isLoading, renderPlaceholder }) => ( * @param {function} props.keyExtractor - Function to extract unique keys from items * @param {function} props.renderItem - Function to render list items * @param {function} [props.renderScreenHeader] - Function to render custom screen header + * @param {function} [props.renderSectionHeader] - Function to render custom section headers * @param {function} [props.renderPlaceholder] - Function to render placeholder items during loading * @param {function} [props.shouldShowFooter] - Function to determine if footer should be shown for a section * @@ -107,6 +106,7 @@ export const FilteredListScreenTemplate = ({ onEndReached, keyExtractor, renderScreenHeader, + renderSectionHeader: renderSectionHeaderProp, renderItem, renderPlaceholder, shouldShowFooter @@ -135,12 +135,17 @@ export const FilteredListScreenTemplate = ({ }, [isLoading || isRefreshing || isPageLoading]); const renderSectionHeader = useCallback(({ section }) => ( - - ), []); + + {renderSectionHeaderProp + ? renderSectionHeaderProp({ section }) + : + } + + ), [renderSectionHeaderProp]); const renderSectionFooter = useCallback(({ section }) => { const showFooter = shouldShowFooter?.(section.group) ?? false; - if (!showFooter) + if (!showFooter) return null; return ( diff --git a/wallet/symbol/mobile/src/components/templates/TransactionScreenTemplate/TransactionScreenTemplate.jsx b/wallet/symbol/mobile/src/components/templates/TransactionScreenTemplate/TransactionScreenTemplate.jsx index 02ae145db..6a9cfb872 100644 --- a/wallet/symbol/mobile/src/components/templates/TransactionScreenTemplate/TransactionScreenTemplate.jsx +++ b/wallet/symbol/mobile/src/components/templates/TransactionScreenTemplate/TransactionScreenTemplate.jsx @@ -36,8 +36,9 @@ const TRANSACTION_SEND_EXECUTION_DELAY_MS = 2000; * @param {import('@/app/types/RefreshConfig').RefreshConfig} [props.refresh] - Refresh control. * @param {string} [props.confirmDialogTitle] - Title for the confirmation dialog. * @param {string} [props.confirmDialogText] - Text for the confirmation dialog body. - -* @returns {React.Node} rendered TransactionScreenTemplate component + * @param {React.Node} [props.modals] - Additional modals to be rendered. + * + * @returns {React.Node} rendered TransactionScreenTemplate component */ export const TransactionScreenTemplate = props => { const { @@ -58,7 +59,8 @@ export const TransactionScreenTemplate = props => { isCustomSendButtonUsed, refresh, confirmDialogTitle, - confirmDialogText + confirmDialogText, + modals } = props; // UI State @@ -162,6 +164,7 @@ export const TransactionScreenTemplate = props => { )} + {modals} { const isTransactionCounterShown = transactionBundle?.isComposite; + const isFirstDividerShown = !!text; + const isNextDividerShown = isTransactionCounterShown; + + const isDividerShown = index => { + if (index === 0) + return isFirstDividerShown; + + if (index > 0) + return isNextDividerShown; + + return false; + }; return ( {isVisible && transactionBundle?.transactions.map((transaction, index) => ( + {isDividerShown(index) && } {isTransactionCounterShown && ( - + {$t('form_transfer_transaction_preview_title', { index: index + 1 })} )} diff --git a/wallet/symbol/mobile/src/components/templates/TransactionScreenTemplate/utils/activity-log.js b/wallet/symbol/mobile/src/components/templates/TransactionScreenTemplate/utils/activity-log.js index a4e68ea66..36face870 100644 --- a/wallet/symbol/mobile/src/components/templates/TransactionScreenTemplate/utils/activity-log.js +++ b/wallet/symbol/mobile/src/components/templates/TransactionScreenTemplate/utils/activity-log.js @@ -7,14 +7,6 @@ import { ActivityStatus } from '@/app/constants'; /** @typedef {import('@/app/types/ActivityLog').ActivityLogItem} ActivityLogItem */ -/** - * @typedef {Object} ActivityLogStep - * @property {string} type - The transaction status step type from TransactionStatusStep - * @property {string} icon - Icon name to display for the step - * @property {ActionStatus} status - Current status of the step - * @property {string} caption - Additional caption text, typically error messages - */ - /** * @typedef {Object} BuildActivityLogParams * @property {ActionState} createStatus - Current status of the transaction creation step @@ -30,7 +22,7 @@ import { ActivityStatus } from '@/app/constants'; * The confirmation step's status is dynamically calculated based on announcement and confirmation states. * * @param {BuildActivityLogParams} params - Parameters containing all workflow step statuses and confirmation state - * @returns {ActivityLogStep[]} Array of activity log steps representing the complete transaction workflow + * @returns {ActivityLogItem[]} Array of activity log steps representing the complete transaction workflow */ export const buildActivityLog = ({ createStatus, diff --git a/wallet/symbol/mobile/src/components/visual/Icon.jsx b/wallet/symbol/mobile/src/components/visual/Icon.jsx index 8f3bd0317..d191c0a0b 100644 --- a/wallet/symbol/mobile/src/components/visual/Icon.jsx +++ b/wallet/symbol/mobile/src/components/visual/Icon.jsx @@ -48,6 +48,8 @@ const sourceMap = { 'chevron-down': require('@/app/assets/images/icons/white/chevron-down.png'), 'send-wallet': require('@/app/assets/images/icons/white/send-wallet.png'), 'chevron-up': require('@/app/assets/images/icons/white/chevron-up.png'), + 'chevron-right': require('@/app/assets/images/icons/white/chevron-right.png'), + 'chevron-left': require('@/app/assets/images/icons/white/chevron-left.png'), 'show': require('@/app/assets/images/icons/white/show.png'), 'copy': require('@/app/assets/images/icons/white/copy.png'), 'sign': require('@/app/assets/images/icons/white/sign.png'), @@ -65,7 +67,8 @@ const sourceMap = { 'plus': require('@/app/assets/images/icons/white/plus.png'), 'minus': require('@/app/assets/images/icons/white/minus.png'), 'file-code': require('@/app/assets/images/icons/white/file-code.png'), - 'settings': require('@/app/assets/images/icons/white/settings.png') + 'settings': require('@/app/assets/images/icons/white/settings.png'), + 'swap': require('@/app/assets/images/icons/white/swap.png') }, secondary: { 'account-add': require('@/app/assets/images/icons/aqua/account-add.png'), @@ -164,7 +167,26 @@ const sourceMap = { 'token-symbol': require('@/app/assets/images/icons/black/token-symbol.png'), 'ethereum': require('@/app/assets/images/icons/black/ethereum.png'), 'plus': require('@/app/assets/images/icons/black/plus.png'), - 'minus': require('@/app/assets/images/icons/black/minus.png') + 'minus': require('@/app/assets/images/icons/black/minus.png'), + 'swap': require('@/app/assets/images/icons/black/swap.png') + }, + danger: { + 'alert-danger': require('@/app/assets/images/icons/red/alert-danger.png') + }, + warning: { + 'alert-warning': require('@/app/assets/images/icons/yellow/alert-warning.png'), + 'pending': require('@/app/assets/images/icons/yellow/pending.png'), + 'send-plane': require('@/app/assets/images/icons/yellow/send-plane.png') + }, + success: { + 'check-circle': require('@/app/assets/images/icons/green/check-circle.png'), + 'check-circle-big': require('@/app/assets/images/icons/green/check-circle-big.png') + }, + info: { + 'sign': require('@/app/assets/images/icons/blue/sign.png') + }, + neutral: { + 'info-circle': require('@/app/assets/images/icons/grey/info-circle.png') } }; @@ -182,7 +204,7 @@ const sourceMap = { */ export const Icon = ({ name, size = 'm', variant = 'default', src, style: customStyle }) => { const iconSize = sizeMap[size]; - const iconSource = src ?? sourceMap[variant][name]; + const iconSource = src ?? sourceMap[variant]?.[name]; if (!iconSource) throw new Error(`Icon: Icon source not found for name "${name}" and color variant "${variant}".`); diff --git a/wallet/symbol/mobile/src/config/known-account-images.js b/wallet/symbol/mobile/src/config/known-account-images.js index 068dabf3b..f70ea369e 100644 --- a/wallet/symbol/mobile/src/config/known-account-images.js +++ b/wallet/symbol/mobile/src/config/known-account-images.js @@ -36,6 +36,7 @@ export const accountImages = { 'bitbank': require('@/app/assets/images/accounts/bitbank.png'), 'bybit': require('@/app/assets/images/accounts/bybit.png'), 'mexc': require('@/app/assets/images/accounts/mexc.png'), + 'symbol': require('@/app/assets/images/accounts/symbol.png'), 'symbol-protocol-treasury': require('@/app/assets/images/accounts/symbol.png'), 'quadratic-funding': require('@/app/assets/images/accounts/symbol.png'), 'harvest-network-fee-sink': require('@/app/assets/images/accounts/symbol.png'), diff --git a/wallet/symbol/mobile/src/config/known-accounts.json b/wallet/symbol/mobile/src/config/known-accounts.json index ba84e717d..1d41975cd 100644 --- a/wallet/symbol/mobile/src/config/known-accounts.json +++ b/wallet/symbol/mobile/src/config/known-accounts.json @@ -7,6 +7,13 @@ "accounts": [ "TARDV42KTAIZEF64EQT4NXT7K55DHWBEFIXVJQY" ] + }, + { + "imageId": "symbol", + "name": "Symbol Protocol", + "accounts": [ + "TCEUGLPCMO5Y72EEISSNUKGTMCN5RO4PVYMK5FI" + ] } ], "mainnet": [ @@ -321,6 +328,13 @@ "accounts": [ "NCVORTEX4XD5IQASZQEHDWUXT33XBOTBMKFDCLI" ] + }, + { + "imageId": "symbol", + "name": "Symbol Protocol", + "accounts": [ + "NASYMBOLLK6FSL7GSEMQEAWN7VW55ZSZU25TBOA" + ] } ] }, diff --git a/wallet/symbol/mobile/src/config/known-tokens.json b/wallet/symbol/mobile/src/config/known-tokens.json index 2bccb7dce..8212496d4 100644 --- a/wallet/symbol/mobile/src/config/known-tokens.json +++ b/wallet/symbol/mobile/src/config/known-tokens.json @@ -18,7 +18,27 @@ ] }, "ethereum": { - "testnet": [], - "mainnet": [] + "testnet": [ + { + "tokenId": "ETH", + "imageId": "eth", + "name": "Ether", + "ticker": "ETH" + }, + { + "tokenId": "0x5E8343A455F03109B737B6D8b410e4ECCE998cdA", + "imageId": "wxym", + "name": "Wrapped XYM", + "ticker": "wXYM" + } + ], + "mainnet": [ + { + "tokenId": "ETH", + "imageId": "eth", + "name": "Ether", + "ticker": "ETH" + } + ] } } diff --git a/wallet/symbol/mobile/src/hooks/index.js b/wallet/symbol/mobile/src/hooks/index.js index 7101d813d..d4da4758f 100644 --- a/wallet/symbol/mobile/src/hooks/index.js +++ b/wallet/symbol/mobile/src/hooks/index.js @@ -7,6 +7,7 @@ export * from './useLoading'; export * from './usePagination'; export * from './usePasscode'; export * from './useProp'; +export * from './useReactiveWalletControllers'; export * from './useTimer'; export * from './useToggle'; export * from './useTransactionFees'; diff --git a/wallet/symbol/mobile/src/hooks/useAsyncManager.js b/wallet/symbol/mobile/src/hooks/useAsyncManager.js index 52cf9073e..b96ad9c4a 100644 --- a/wallet/symbol/mobile/src/hooks/useAsyncManager.js +++ b/wallet/symbol/mobile/src/hooks/useAsyncManager.js @@ -12,6 +12,7 @@ import { useRef, useState } from 'react'; * @param {T} [config.defaultData=null] - The default data value. * @param {function(*)} [config.onError=null] - An optional error handler function called on error. * @param {function(T)} [config.onSuccess=null] - An optional success handler function called on success. + * @param {boolean} [config.shouldShowErrorPopup=true] - Whether to show an error popup on error. * @param {boolean} [config.shouldClearDataOnCall=false] - Whether to clear data when calling the async function. * @param {boolean} [config.defaultLoadingState=false] - The default loading state. @@ -24,8 +25,9 @@ export const useAsyncManager = config => { defaultData = null, onError = null, onSuccess = null, + shouldShowErrorPopup = true, shouldClearDataOnCall = false, - defaultLoadingState = false + defaultLoadingState = false } = config; const [isLoading, setIsLoading] = useState(defaultLoadingState); const [isCompleted, setIsCompleted] = useState(false); @@ -74,7 +76,7 @@ export const useAsyncManager = config => { if (onError) onError(error); - else + else if (shouldShowErrorPopup) showError(error); reject(error); diff --git a/wallet/symbol/mobile/src/hooks/useReactiveWalletControllers.js b/wallet/symbol/mobile/src/hooks/useReactiveWalletControllers.js new file mode 100644 index 000000000..d275cebcc --- /dev/null +++ b/wallet/symbol/mobile/src/hooks/useReactiveWalletControllers.js @@ -0,0 +1,31 @@ +import { walletControllers } from '@/app/lib/controller'; +import { useEffect, useMemo, useState } from 'react'; +import { WalletController, constants } from 'wallet-common-core'; + +/** + * Hook to access the wallet controller. + * It listens for state changes and updates the component when the state changes. + * @returns {Array} The wallet controller instance. + */ +export const useReactiveWalletControllers = walletControllers => { + const [, setVersion] = useState(0); + + const memoizedControllers = useMemo(() => walletControllers, [...walletControllers]); + + useEffect(() => { + const handleStateChange = () => { + setVersion(prevVersion => prevVersion + 1); + }; + memoizedControllers.forEach(controller => { + controller.on(constants.ControllerEventName.STATE_CHANGE, handleStateChange); + }); + + return () => { + memoizedControllers.forEach(controller => { + controller.removeListener(constants.ControllerEventName.STATE_CHANGE, handleStateChange); + }); + }; + }, [memoizedControllers]); + + return memoizedControllers; +}; diff --git a/wallet/symbol/mobile/src/hooks/useTransactionFees.js b/wallet/symbol/mobile/src/hooks/useTransactionFees.js index 79d73a3e2..8d8bcee88 100644 --- a/wallet/symbol/mobile/src/hooks/useTransactionFees.js +++ b/wallet/symbol/mobile/src/hooks/useTransactionFees.js @@ -14,6 +14,7 @@ export const useTransactionFees = (createTransaction, walletController) => { callback: async () => { const transaction = await createTransaction(); return await walletController.modules.transfer.calculateTransactionFees(transaction); - } + }, + shouldShowErrorPopup: false }); }; diff --git a/wallet/symbol/mobile/src/hooks/useWalletController.js b/wallet/symbol/mobile/src/hooks/useWalletController.js index 0428556c0..e7afc72c9 100644 --- a/wallet/symbol/mobile/src/hooks/useWalletController.js +++ b/wallet/symbol/mobile/src/hooks/useWalletController.js @@ -1,11 +1,14 @@ import { walletControllers } from '@/app/lib/controller'; import { useEffect, useMemo, useState } from 'react'; -import { WalletController, constants } from 'wallet-common-core'; +import { constants } from 'wallet-common-core'; + +/** @typedef {import('@/app/types/Wallet').MainWalletController} MainWalletController */ +/** @typedef {import('@/app/types/Wallet').AdditionalWalletController} AdditionalWalletController */ /** * Hook to access the wallet controller. * It listens for state changes and updates the component when the state changes. - * @returns {typeof walletControllers.main | WalletController} The wallet controller instance. + * @returns {MainWalletController | AdditionalWalletController} The wallet controller instance. */ export const useWalletController = chainName => { const walletController = useMemo(() => { @@ -25,7 +28,7 @@ export const useWalletController = chainName => { return () => { walletController.removeListener(constants.ControllerEventName.STATE_CHANGE, handleStateChange); }; - }, []); + }, [walletController]); return walletController; }; diff --git a/wallet/symbol/mobile/src/lib/controller/ethereum/controller.js b/wallet/symbol/mobile/src/lib/controller/ethereum/controller.js index c6d089f49..b269238e2 100644 --- a/wallet/symbol/mobile/src/lib/controller/ethereum/controller.js +++ b/wallet/symbol/mobile/src/lib/controller/ethereum/controller.js @@ -14,11 +14,7 @@ import { } from 'wallet-common-core'; import { TransferModule } from 'wallet-common-ethereum'; -/** - * @typedef {Object} WalletControllerModules - * @property {TransferModule} transfer - The module that handles transfer transaction operations. - * @property {BridgeModule} bridge - The module that handles bridge transaction operations. - */ +/** @typedef {import('@/app/types/Wallet').AdditionalWalletController} AdditionalWalletController */ const modules = [ new TransferModule(), @@ -30,7 +26,7 @@ const modules = [ /** * Wallet controller. - * @type {WalletController & { modules: WalletControllerModules }} + * @type {AdditionalWalletController} */ export const ethereumWalletController = /** @type {any} */ ( new WalletController({ diff --git a/wallet/symbol/mobile/src/lib/controller/symbol/controller.js b/wallet/symbol/mobile/src/lib/controller/symbol/controller.js index 604315858..a190faae1 100644 --- a/wallet/symbol/mobile/src/lib/controller/symbol/controller.js +++ b/wallet/symbol/mobile/src/lib/controller/symbol/controller.js @@ -17,16 +17,8 @@ import { } from 'wallet-common-core'; import { HarvestingModule, TransferModule } from 'wallet-common-symbol'; -/** @typedef {import('../../types/Network').NetworkProperties} NetworkProperties */ +/** @typedef {import('@/app/types/Wallet').MainWalletController} MainWalletController */ -/** - * @typedef {Object} WalletControllerModules - * @property {AddressBookModule} addressBook - The address book module. - * @property {HarvestingModule} harvesting - The module that handles harvesting information and operations. - * @property {LocalizationModule} localization - The module that handles localization and language settings. - * @property {MarketModule} market - The module that handles market information. - * @property {TransferModule} transfer - The module that handles transfer transaction operations. - */ const modules = [ new AddressBookModule(), new MarketModule({ @@ -43,7 +35,7 @@ const modules = [ /** * Symbol wallet controller. - * @type {WalletController & { modules: WalletControllerModules, networkProperties: NetworkProperties }} + * @type {MainWalletController} */ export const symbolWalletController = /** @type {any} */ ( new WalletController({ diff --git a/wallet/symbol/mobile/src/localization/locales/en.json b/wallet/symbol/mobile/src/localization/locales/en.json index 6510230f0..8c9c62199 100644 --- a/wallet/symbol/mobile/src/localization/locales/en.json +++ b/wallet/symbol/mobile/src/localization/locales/en.json @@ -99,6 +99,7 @@ "screen_BridgeAccountList": "Your Bridge Accounts", "screen_BridgeAccountDetails": "Bridge Account Details", "screen_BridgeSwap": "Swap Tokens", + "screen_BridgeSwapDetails": "Swap Details", "screen_Scan": "Scan", "screen_Send": "Send", "screen_Receive": "Receive", @@ -107,6 +108,7 @@ "screen_SettingsAbout": "About", "screen_SettingsNetwork": "Network", "screen_SettingsSecurity": "Security", + "screen_TokenDetails": "Token Details", "screen_TransactionDetails": "Transaction Details", "screen_TransactionRequest": "Transaction Request", "screen_AssetDetails": "Asset Details", @@ -139,8 +141,10 @@ "fieldTitle_status": "Status", "fieldTitle_innerTxs": "Inner transactions", "fieldTitle_signerAddress": "Signer Address", + "fieldTitle_senderAddress": "Sender Address", "fieldTitle_deadline": "Deadline", "fieldTitle_hash": "Hash", + "fieldTitle_transactionHash": "Transaction Hash", "fieldTitle_transferType": "Transfer Type", "fieldTitle_hasCustomMosaic": "Number of Transfered Mosaics", "fieldTitle_undefined": "n/a", @@ -249,6 +253,9 @@ "fieldTitle_generationHash": "Generation Hash", "fieldTitle_chainHeight": "Chain Height", "fieldTitle_description": "Description", + "fieldTitle_registrationHeight": "Registration Height", + "fieldTitle_currentChainHeight": "Current Chain Height", + "fieldTitle_expirationHeight": "Expiration Height", "data_v_unlimited": "UNLIMITED", "data_v_na": "N/A", "data_v_infinity": "INFINITY", @@ -321,6 +328,7 @@ "transactionDescriptor_16707_unlink": "Voting Key unlink", "transactionDescriptor_16972_unlink": "Node Key unlink", "transactionDescriptor_postLaunchOptIn": "Opt-in", + "transactionDescriptor_swap": "Swap", "transactionDescriptionShort_transferFrom": "From %{address}", "transactionDescriptionShort_transferTo": "To %{address}", "transactionDescriptionShort_transferIncoming": "Incoming transfer", @@ -353,6 +361,7 @@ "c_accountCard_button_accountDetails": "Details", "c_accountCard_button_send": "Send", "c_accountCard_button_receive": "Receive", + "c_accountCard_button_swap": "Swap", "c_connectionStatus_offline": "No Internet connection.", "c_connectionStatus_nodeDown": "Node is down. Please select different in the network settings.", "c_connectionStatus_connected": "Connected.", @@ -473,6 +482,10 @@ "s_assetDetails_namespace": "Namespace", "s_assetDetails_expired": "Expired (no longer available for sending)", "s_assetDetails_expireInBlocks": "Expire in %{blocks} blocks", + "s_assetDetails_alert_expired_title": "Token Expired", + "s_assetDetails_alert_expired_description": "This token was issued for a limited time and has now reached its expiration date - the expiration block height. It is no longer available for sending.", + "s_assetDetails_alert_expirable_title": "Token Expirable", + "s_assetDetails_alert_expirable_description": "This token is issued for a limited time. Once it reaches its expiration block height, it will no longer be available for sending.", "s_harvesting_title": "About Harvesting", "s_harvesting_description": "Delegated Harvesting allows receiving rewards from creating new blocks using the account's importance score without running a node. To activate Delegated Harvesting you have to request a node to harvest for your account. If the pending status does not change to active within 2 minutes, please restart harvesting using another node", "s_harvesting_status_unknown": "Unknown", @@ -499,11 +512,30 @@ "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_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.", + "s_bridge_swap_dialog_confirm_title": "Swap tokens?", + "s_bridge_swap_dialog_confirm_text": "You are about to swap %{amount} of %{sourceToken} on %{sourceChain} network for %{targetToken} on %{targetChain} network.", + "s_bridge_swap_dialog_noPairs_title": "Activate Bridge Swap", + "s_bridge_swap_dialog_noPairs_text": "To use Bridge Swap, you need to have account from another network added to your wallet. Proceed to add an account from another network to activate the Bridge Swap feature.", "s_bridge_summary_title": "Summary", "s_bridge_summary_bridgeFee": "Bridge Fee", "s_bridge_summary_transactionFee": "Transaction Fee", "s_bridge_summary_amountSend": "You Send", "s_bridge_summary_amountReceive": "You Receive", + "s_bridge_history_requestTransactionConfirmed": "Transaction was successfully sent to the Bridge. The status will be displayed in a few minutes, once Bridge starts to work with your request", + "s_bridge_history_status_unprocessed": "Unprocessed", + "s_bridge_history_status_processing": "Processing", + "s_bridge_history_status_sent": "Sent", + "s_bridge_history_status_completed": "Completed", + "s_bridge_history_status_failed": "Failed", + "s_bridge_history_page_size_message": "Showing last %{size} swaps", + "s_bridge_swapStatus_step_requestSend": "Send", + "s_bridge_swapStatus_step_awaitingBridge": "Awaiting Bridge", + "s_bridge_swapStatus_step_payoutSend": "Swap Token", + "s_bridge_swapStatus_step_payoutConfirmation": "Receive", + "s_bridge_tokens_title": "Tokens", + "s_bridge_swapDetails_tokenSend_title": "Token Send", + "s_bridge_swapDetails_tokenReceive_title": "Token Receive", + "s_bridge_swapDetails_statusTracking_title": "Status Tracking", "s_addAccount_seed_name_default": "Seed Account %{index}", "s_addAccount_name_title": "Think of a Name", "s_addAccount_name_input": "Name", diff --git a/wallet/symbol/mobile/src/router/Router.js b/wallet/symbol/mobile/src/router/Router.js index f4cfdcb6b..a995161ba 100644 --- a/wallet/symbol/mobile/src/router/Router.js +++ b/wallet/symbol/mobile/src/router/Router.js @@ -36,6 +36,15 @@ export class Router { routes: [{ name: RouteName.History }] }); } + static goToAssets() { + navigationRef.reset({ + index: 0, + routes: [{ name: RouteName.Assets }] + }); + } + static goToTokenDetails(params) { + navigationRef.navigate(RouteName.TokenDetails, parseNavigationParams(params)); + } static goToAccountDetails(params) { navigationRef.navigate(RouteName.AccountDetails, parseNavigationParams(params)); } @@ -51,6 +60,18 @@ export class Router { static goToSend(params) { navigationRef.navigate(RouteName.Send, parseNavigationParams(params)); } + static goToBridgeAccountList(params) { + navigationRef.navigate(RouteName.BridgeAccountList, parseNavigationParams(params)); + } + static goToBridgeAccountDetails(params) { + navigationRef.navigate(RouteName.BridgeAccountDetails, parseNavigationParams(params)); + } + static goToBridgeSwap(params) { + navigationRef.navigate(RouteName.BridgeSwap, parseNavigationParams(params)); + } + static goToBridgeSwapDetails(params) { + navigationRef.navigate(RouteName.BridgeSwapDetails, parseNavigationParams(params)); + } static goToSettings(params) { navigationRef.navigate(RouteName.Settings, parseNavigationParams(params)); } diff --git a/wallet/symbol/mobile/src/router/RouterView.jsx b/wallet/symbol/mobile/src/router/RouterView.jsx index d6d64f887..0694f18e6 100644 --- a/wallet/symbol/mobile/src/router/RouterView.jsx +++ b/wallet/symbol/mobile/src/router/RouterView.jsx @@ -63,12 +63,18 @@ export const RouterView = ({ isActive, flow }) => ( + + + + + + diff --git a/wallet/symbol/mobile/src/router/config.js b/wallet/symbol/mobile/src/router/config.js index 271b0644b..771738059 100644 --- a/wallet/symbol/mobile/src/router/config.js +++ b/wallet/symbol/mobile/src/router/config.js @@ -6,6 +6,8 @@ export const RouteName = { ImportWallet: 'ImportWallet', Home: 'Home', History: 'History', + Assets: 'Assets', + TokenDetails: 'TokenDetails', AccountDetails: 'AccountDetails', AccountList: 'AccountList', AddSeedAccount: 'AddSeedAccount', @@ -14,7 +16,11 @@ export const RouteName = { Settings: 'Settings', SettingsAbout: 'SettingsAbout', SettingsNetwork: 'SettingsNetwork', - SettingsSecurity: 'SettingsSecurity' + SettingsSecurity: 'SettingsSecurity', + BridgeAccountList: 'BridgeAccountList', + BridgeAccountDetails: 'BridgeAccountDetails', + BridgeSwap: 'BridgeSwap', + BridgeSwapDetails: 'BridgeSwapDetails' }; export const RouterFlow = { diff --git a/wallet/symbol/mobile/src/screens/account/AccountDetails.jsx b/wallet/symbol/mobile/src/screens/account/AccountDetails.jsx index 39a097312..e34fbe3fb 100644 --- a/wallet/symbol/mobile/src/screens/account/AccountDetails.jsx +++ b/wallet/symbol/mobile/src/screens/account/AccountDetails.jsx @@ -1,4 +1,4 @@ -import { ButtonPlain, Card, DialogBox, PasscodeView, Screen, Spacer, TableView } from '@/app/components'; +import { ButtonPlain, Card, DialogBox, PasscodeView, Screen, Spacer, Stack, TableView } from '@/app/components'; import { config } from '@/app/config'; import { usePasscode, useToggle, useWalletController } from '@/app/hooks'; import { PlatformUtils } from '@/app/lib/platform/PlatformUtils'; @@ -13,7 +13,7 @@ import { constants } from 'wallet-common-core'; * in block explorer, access faucet on testnet, and reveal private key. */ export const AccountDetails = () => { - const walletController = useWalletController(chainName); + const walletController = useWalletController(); const { chainName, networkIdentifier, currentAccount, currentAccountInfo } = walletController; const isTestnet = networkIdentifier === 'testnet'; const isMultisigCosigner = currentAccountInfo?.multisigAddresses.length; @@ -99,7 +99,7 @@ export const AccountDetails = () => { { - {isTestnet && ( + + {isTestnet && ( + + )} + privateKeyPasscode.show()} /> - )} - - privateKeyPasscode.show()} - /> + { + const walletController = useWalletController(); + const { + networkIdentifier, + networkProperties, + currentAccount + } = walletController; + + const { + sections, + filter, + setFilter, + filterConfig, + isLoading, + isRefreshing, + isPageLoading, + refresh + } = useAssetsData({ walletController }); + + const renderScreenHeader = useCallback(() => ( +
+ ), [currentAccount]); + + const keyExtractor = useCallback(item => { + return `${item.chainName}-${item.id}`; + }, []); + + const renderSectionHeader = useCallback(({ section }) => ( + + + + ), []); + + const renderItem = useCallback(({ item, section }) => { + const handleTokenPress = token => { + Router.goToTokenDetails({ params: { + chainName: section.chainName, + tokenId: token.id + }}); + }; + + return ( + + ); + }, [networkIdentifier, networkProperties.chainHeight, networkProperties.blockGenerationTargetTime]); + + return ( + + ); +}; diff --git a/wallet/symbol/mobile/src/screens/assets/TokenDetails.jsx b/wallet/symbol/mobile/src/screens/assets/TokenDetails.jsx new file mode 100644 index 000000000..b08e87432 --- /dev/null +++ b/wallet/symbol/mobile/src/screens/assets/TokenDetails.jsx @@ -0,0 +1,182 @@ +import { + Alert, + Amount, + Button, + Card, + Divider, + Field, + FlexContainer, + Screen, + Spacer, + Stack, + StyledText, + TableView, + TokenAvatar +} from '@/app/components'; +import { useWalletController } from '@/app/hooks'; +import { $t } from '@/app/localization'; +import { Router } from '@/app/router/Router'; +import { ExpirationProgress } from '@/app/screens/assets/components'; +import { getExpirationData, getTokenDisplayInfo } from '@/app/screens/assets/utils'; +import { Colors } from '@/app/styles'; +import React from 'react'; + +/** + * TokenDetails screen component. Displays detailed information about a specific token including + * balance, creator, supply, divisibility, and expiration status. Allows sending tokens if not expired. + * @param {{ route: { params: { chainName: string, tokenId: string } } }} props - Component props + * @returns {React.ReactNode} TokenDetails component + */ +export const TokenDetails = ({ route }) => { + const { chainName, tokenId } = route.params; + const walletController = useWalletController(chainName); + const { networkIdentifier, currentAccount, currentAccountInfo, networkProperties } = walletController; + + // Find token in wallet + const tokens = currentAccountInfo?.tokens || currentAccountInfo?.mosaics || []; + const token = tokens.find(t => t.id === tokenId); + + // Token display data + const { + name, + ticker, + imageId + } = getTokenDisplayInfo(token, chainName, networkIdentifier); + + // Info table data + const tableData = [ + { + title: 'id', + value: token.id, + type: 'copy' + }, + { + title: 'chainName', + value: chainName, + type: 'text' + } + ]; + + if (token.creator === currentAccount.address) { + tableData.push({ + title: 'supply', + value: token.supply, + type: 'text' + }); + tableData.push({ + title: 'divisibility', + value: token.divisibility, + type: 'text' + }); + }; + + if (token.creator) { + tableData.push({ + title: 'creator', + value: token.creator, + type: 'account' + }); + }; + + // Expiration data + const { + isTokenExpired, + isExpirationSectionShown, + isAlertVisible, + alertVariant, + alertText + } = getExpirationData(token, networkProperties); + + // Send button + const openSendScreen = () => Router.goToSend({ params: { chainName, tokenId } }); + + // Refresh data + const refresh = () => walletController.fetchAccountInfo(); + + const isSendButtonDisabled = isTokenExpired; + + return ( + + + + + + + + + + + {name} + + + + + + + + + + + + + + + {isExpirationSectionShown && ( + + + + + + + + {token.startHeight} + + + + + {networkProperties.chainHeight} + + + + + {token.endHeight} + + + + + + )} + + + + + + +